diff --git a/.mapping.json b/.mapping.json index df929d8..1c66f4b 100644 --- a/.mapping.json +++ b/.mapping.json @@ -419,6 +419,7 @@ "src/utils/dom/__tests__/nodeName.spec.ts":"metrika/frontend/watch/public/src/utils/dom/__tests__/nodeName.spec.ts", "src/utils/dom/__tests__/nodeText.spec.ts":"metrika/frontend/watch/public/src/utils/dom/__tests__/nodeText.spec.ts", "src/utils/dom/__tests__/waitForBody.spec.ts":"metrika/frontend/watch/public/src/utils/dom/__tests__/waitForBody.spec.ts", + "src/utils/dom/block.ts":"metrika/frontend/watch/public/src/utils/dom/block.ts", "src/utils/dom/button.ts":"metrika/frontend/watch/public/src/utils/dom/button.ts", "src/utils/dom/closest.ts":"metrika/frontend/watch/public/src/utils/dom/closest.ts", "src/utils/dom/dom.ts":"metrika/frontend/watch/public/src/utils/dom/dom.ts", diff --git a/features.json b/features.json index 26edcd9..d98a53e 100644 --- a/features.json +++ b/features.json @@ -193,6 +193,10 @@ "path": "remoteControl", "desc": "Enables metrika inpage scripts work (i.g. selecting buttons for quick goals in the metrika interface)" }, + { + "code": "REMOTE_CONTROL_BLOCK_HELPERS_FEATURE", + "desc": "Enables metrika block selection helpers for inpage scripts to work" + }, { "code": "CSRF_TOKEN_FEATURE", "desc": "Add API token required for authorized traffic", diff --git a/src/providers/remoteControl/remoteControl.ts b/src/providers/remoteControl/remoteControl.ts index e483d1d..d7c6b24 100644 --- a/src/providers/remoteControl/remoteControl.ts +++ b/src/providers/remoteControl/remoteControl.ts @@ -13,6 +13,7 @@ import { SUBMIT_TRACKING_FEATURE, CHECK_STATUS_FEATURE, REMOTE_CONTROL_FEATURE, + REMOTE_CONTROL_BLOCK_HELPERS_FEATURE, } from 'generated/features'; import { memo, @@ -34,6 +35,11 @@ import { closestForm, getFormData, selectForms } from 'src/utils/dom/form'; import { checkStatusFn } from 'src/providers/statusCheck/statusCheckFn'; import { isNumber, parseDecimalInt } from 'src/utils/number'; import { AnyFunc } from 'src/utils/function/types'; +import { + getClosestTextContainer, + getTextContainerData, + selectTextContainer, +} from 'src/utils/dom/block'; /* eslint-disable camelcase */ type ExtendedWindow = Window & { @@ -144,6 +150,8 @@ export type Message = { inpageMode: string; /** Feature init data */ initMessage: string; + /** Are block utils needed */ + isBlockUtilsEnabled?: boolean; } & InlineMessageProps; export const UTILS_KEY = '_u'; @@ -200,6 +208,7 @@ export const setupUtilsAndLoadScript = ( ctx: ExtendedWindow, src?: string, counterId = '', + isBlockUtilsEnabled?: boolean, ) => { if ( flags[CLICK_TRACKING_FEATURE] || @@ -221,6 +230,19 @@ export const setupUtilsAndLoadScript = ( [UTILS_GET_DATA_KEY]: bindArg(ctx, getFormData), }; } + if ( + flags[REMOTE_CONTROL_BLOCK_HELPERS_FEATURE] && + (flags[REMOTE_CONTROL_FEATURE] || + flags[CLICK_TRACKING_FEATURE] || + flags[SUBMIT_TRACKING_FEATURE]) && + isBlockUtilsEnabled + ) { + utils['block'] = { + [UTILS_CLOSEST_KEY]: bindArg(ctx, getClosestTextContainer), + [UTILS_SELECT_KEY]: selectTextContainer, + [UTILS_GET_DATA_KEY]: bindArg(ctx, getTextContainerData), + }; + } if (flags[CLICK_TRACKING_FEATURE] || flags[REMOTE_CONTROL_FEATURE]) { utils['button'] = { [UTILS_CLOSEST_KEY]: bindArg(ctx, closestButton), @@ -256,7 +278,12 @@ export const handleMessage = memo( if (message['inline']) { const src = getResourceUrl(ctx, message); const { id = '' } = message; - setupUtilsAndLoadScript(ctx, src, id); + setupUtilsAndLoadScript( + ctx, + src, + id, + message['isBlockUtilsEnabled'], + ); } else if ( message['resource'] && isAllowedResource(message['resource']) diff --git a/src/utils/dom/__tests__/element.spec.ts b/src/utils/dom/__tests__/element.spec.ts index 4ae8fd6..fb42f8c 100644 --- a/src/utils/dom/__tests__/element.spec.ts +++ b/src/utils/dom/__tests__/element.spec.ts @@ -1,27 +1,65 @@ -import { getElementPath } from 'src/utils/dom'; +import { getElementPath, getElementCSSSelector } from 'src/utils/dom'; import { JSDOMWrapper } from 'src/__tests__/utils/jsdom'; import chai from 'chai'; describe('Element', () => { - const { window } = new JSDOMWrapper(` - -
- Hello! -
-
`); + describe('getElementPath', () => { + const { window } = new JSDOMWrapper(` + +
+ Hello! +
+
`); - it('get path', () => { - const el = window.document.querySelector('#id') as HTMLElement; + it('get path', () => { + const el = window.document.querySelector('#id') as HTMLElement; - chai.expect(getElementPath(window, el)).eq(' { + const el = window.document.querySelector('#id') as HTMLElement; + const ignored = window.document.querySelector( + '#ignored', + ) as HTMLElement; + + chai.expect(getElementPath(window, el, ignored)).eq(' { - const el = window.document.querySelector('#id') as HTMLElement; - const ignored = window.document.querySelector( - '#ignored', - ) as HTMLElement; + describe('getElementCSSSelector', () => { + it('returns null if it is impossible to get unique selector', () => { + const { window } = new JSDOMWrapper(` +
+
+
+
+
+
+ `); + + const element = window.document.querySelector( + '.non-unique', + ) as HTMLElement; + const result = getElementCSSSelector(window, element); + chai.expect(result).to.be.null; + }); - chai.expect(getElementPath(window, el, ignored)).eq(' { + const { window } = new JSDOMWrapper(` +
+
+
+
+
+
+
+ `); + const element = window.document.querySelector( + '#unique .non-unique', + ) as HTMLElement; + const result = getElementCSSSelector(window, element); + chai.expect(result).to.equal('#unique .non-unique'); + }); }); }); diff --git a/src/utils/dom/block.ts b/src/utils/dom/block.ts new file mode 100644 index 0000000..5520257 --- /dev/null +++ b/src/utils/dom/block.ts @@ -0,0 +1,15 @@ +import { bindArg } from '../function'; +import { closest } from './closest'; +import { CSS, PATH, getData } from './identifiers'; +import { select } from './select'; + +const BLOCK_SELECTOR = + 'div,span,main,section,p,b,h1,h2,h3,h4,h5,h6,td,small,a,i,td,li,q'; + +export const getClosestTextContainer = bindArg(BLOCK_SELECTOR, closest); +export const selectTextContainer = bindArg(BLOCK_SELECTOR, select); +export const getTextContainerData = ( + ctx: Window, + form: HTMLElement, + ignored?: HTMLElement, +) => getData(ctx, form, [PATH, CSS], undefined, ignored); diff --git a/src/utils/dom/element.ts b/src/utils/dom/element.ts index 3c921b0..220a6d0 100644 --- a/src/utils/dom/element.ts +++ b/src/utils/dom/element.ts @@ -23,6 +23,8 @@ import { hasClass, } from './dom'; import { isRemovedFromDoc } from './isRemovedFromDoc'; +import { arrayFrom } from '../array/arrayFrom'; +import { select } from './select'; /* eslint-disable */ export const getElementXY = (ctx: Window, el: HTMLElement | null) => { @@ -141,6 +143,55 @@ export const getCachedTags = memo(() => { return cacheTags; }); +export const getElementCSSSelector = (ctx: Window, el: HTMLElement | null) => { + let selector = ''; + let element = el; + const body = getBody(ctx)!; + let elementSelector: string[] = []; + const getFullSelector = () => { + const lastComponentSelector = arrayJoin('', elementSelector); + return selector + ? `${lastComponentSelector} ${selector}` + : lastComponentSelector; + }; + const getUniqueSelector = () => { + const fullSelector = getFullSelector(); + const selectedNodes = select(fullSelector, body); + if (selectedNodes.length === 1) { + return fullSelector; + } + return null; + }; + + while (element && element.parentElement) { + if (element.id) { + elementSelector.push(`#${element.id}`); + const uniqueSelector = getUniqueSelector(); + if (uniqueSelector) { + return uniqueSelector; + } + } + if (element.classList.length) { + cForEach( + (className) => elementSelector.push(`.${className}`), + arrayFrom(element.classList), + ); + const uniqueSelector = getUniqueSelector(); + if (uniqueSelector) { + return uniqueSelector; + } + } + + if (elementSelector.length) { + selector = getFullSelector(); + } + elementSelector = []; + element = element.parentElement; + } + + return null; +}; + export const getElementPath = ( ctx: Window, el: HTMLElement | null, diff --git a/src/utils/dom/identifiers.ts b/src/utils/dom/identifiers.ts index 71e047b..92e708c 100644 --- a/src/utils/dom/identifiers.ts +++ b/src/utils/dom/identifiers.ts @@ -1,6 +1,9 @@ import { cReduce, cSome } from 'src/utils/array'; import { ctxPath, getPath } from 'src/utils/object'; -import { getElementPathCached } from 'src/utils/dom/element'; +import { + getElementCSSSelector, + getElementPathCached, +} from 'src/utils/dom/element'; import { convertToString } from 'src/utils/string'; import { trimText } from 'src/utils/string/remove'; import { isInputElement } from 'src/utils/dom/dom'; @@ -9,6 +12,7 @@ import { CLICK_TRACKING_FEATURE, LOCAL_FEATURE, PREPROD_FEATURE, + REMOTE_CONTROL_BLOCK_HELPERS_FEATURE, REMOTE_CONTROL_FEATURE, SUBMIT_TRACKING_FEATURE, } from 'generated/features'; @@ -22,8 +26,9 @@ export const PATH = 'p'; export const CONTENT = 'c'; export const HREF = 'h'; export const TYPE = 'ty'; +export const CSS = 'cs'; -const IDENTIFIERS = [ID, NAME, HREF, PATH, CONTENT, TYPE] as const; +const IDENTIFIERS = [ID, NAME, HREF, PATH, CONTENT, TYPE, CSS] as const; export type Identifier = typeof IDENTIFIERS[number]; type GenericGetter = (ctx: Window, element: HTMLElement) => string | null; @@ -84,6 +89,15 @@ if ( GETTERS_MAP[PATH] = getElementPathCached; } +if ( + flags[REMOTE_CONTROL_BLOCK_HELPERS_FEATURE] && + (flags[REMOTE_CONTROL_FEATURE] || + flags[CLICK_TRACKING_FEATURE] || + flags[SUBMIT_TRACKING_FEATURE]) +) { + GETTERS_MAP[CSS] = getElementCSSSelector; +} + if (flags[CLICK_TRACKING_FEATURE] || flags[REMOTE_CONTROL_FEATURE]) { GETTERS_MAP[CONTENT] = (ctx, element, selectFn) => { let result = trimText(getPath(element, 'textContent'));