Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ [RUMF-385] improve click action naming #406

Merged
merged 5 commits into from
Jun 2, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 183 additions & 0 deletions packages/rum/src/getActionNameFromElement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
export function getActionNameFromElement(element: Element): string {
// Proceed to get the action name in two steps:
// * first, use strategies that are known to return good results. Those strategies will be used on
// the element and a few parents, but it's likely that they won't succeed at all.
// * if no name is found this way, use strategies returning less accurate names as a fallback.
// Those are much likely to succeed.
return (
getActionNameFromElementForGetters(element, priorityGetters) ||
getActionNameFromElementForGetters(element, fallbackGetters) ||
''
)
}

type NameGetter = (element: Element | HTMLElement | HTMLInputElement | HTMLSelectElement) => string | undefined | null

const priorityGetters: NameGetter[] = [
// associated LABEL text
(element) => {
// IE does not support element.labels, so we fallback to a CSS selector based on the element id
// instead
if (supportsLabelProperty()) {
if ('labels' in element && element.labels && element.labels.length > 0) {
return getTextualContent(element.labels[0])
}
} else if (element.id) {
const label =
element.ownerDocument && element.ownerDocument.querySelector(`label[for="${element.id.replace('"', '\\"')}"]`)
return label && getTextualContent(label)
}
},
// INPUT button (and associated) value
(element) => {
if (element.nodeName === 'INPUT') {
const input = element as HTMLInputElement
const type = input.getAttribute('type')
if (type === 'button' || type === 'submit' || type === 'reset') {
return input.value
}
}
},
// BUTTON, LABEL or button-like element text
(element) => {
if (element.nodeName === 'BUTTON' || element.nodeName === 'LABEL' || element.getAttribute('role') === 'button') {
return getTextualContent(element)
}
},
(element) => element.getAttribute('aria-label'),
// associated element text designated by the aria-labelledby attribute
(element) => {
const labelledByAttribute = element.getAttribute('aria-labelledby')
if (labelledByAttribute) {
return labelledByAttribute
.split(/\s+/)
.map((id) => getElementById(element, id))
.filter((label): label is HTMLElement => Boolean(label))
.map(getTextualContent)
.join(' ')
}
},
(element) => element.getAttribute('alt'),
(element) => element.getAttribute('name'),
(element) => element.getAttribute('title'),
(element) => element.getAttribute('placeholder'),
// SELECT first OPTION text
(element) => {
if ('options' in element && element.options.length > 0) {
return getTextualContent(element.options[0])
}
},
]

const fallbackGetters: NameGetter[] = [
(element) => {
return getTextualContent(element)
},
]

/**
* Iterates over the target element and its parent, using the getters list to get an action name.
* Each getters are applied on each element, stopping as soon as a non-empty value is returned.
*/
const MAX_PARENTS_TO_CONSIDER = 10
function getActionNameFromElementForGetters(targetElement: Element, getters: NameGetter[]) {
BenoitZugmeyer marked this conversation as resolved.
Show resolved Hide resolved
let element: Element | null = targetElement
let recursionCounter = 0
while (
recursionCounter <= MAX_PARENTS_TO_CONSIDER &&
element &&
element.nodeName !== 'BODY' &&
element.nodeName !== 'HTML' &&
element.nodeName !== 'HEAD'
) {
for (const getter of getters) {
const name = getter(element)
if (typeof name === 'string') {
const trimmedName = name.trim()
if (trimmedName) {
return truncate(normalizeWhitespace(trimmedName))
}
}
}
// Consider a FORM as a contextual limit to get the action name. This is experimental and may
// be reconsidered in the future.
if (element.nodeName === 'FORM') {
break
}
element = element.parentElement
recursionCounter += 1
}
}

function normalizeWhitespace(s: string) {
return s.replace(/\s+/g, ' ')
}

function truncate(s: string) {
return s.length > 100 ? `${s.slice(0, 100)} [...]` : s
}

function getElementById(refElement: Element, id: string) {
// Use the element ownerDocument here, because tests are executed in an iframe, so
// document.getElementById won't work.
// tslint:disable-next-line: no-null-keyword
return refElement.ownerDocument ? refElement.ownerDocument.getElementById(id) : null
BenoitZugmeyer marked this conversation as resolved.
Show resolved Hide resolved
}

function getTextualContent(element: Element | HTMLElement) {
if ((element as HTMLElement).isContentEditable) {
return
}

if ('innerText' in element) {
let text = element.innerText
if (!supportsInnerTextScriptAndStyleRemoval()) {
// remove the inner text of SCRIPT and STYLES from the result. This is a bit dirty, but should
// be relatively fast and work in most cases.
const elementsTextToRemove: NodeListOf<HTMLElement> = element.querySelectorAll('script, style')
// tslint:disable-next-line: prefer-for-of
for (let i = 0; i < elementsTextToRemove.length; i += 1) {
const innerText = elementsTextToRemove[i].innerText
if (innerText.trim().length > 0) {
text = text.replace(innerText, '')
}
}
}
return text
}

return element.textContent
}

/**
* Returns true if element.innerText excludes the text from inline SCRIPT and STYLE element. This
* should be the case everywhere except on some version of Internet Explorer.
* See http://perfectionkills.com/the-poor-misunderstood-innerText/#diff-with-textContent
*/
let supportsInnerTextScriptAndStyleRemovalResult: boolean | undefined
function supportsInnerTextScriptAndStyleRemoval() {
if (supportsInnerTextScriptAndStyleRemovalResult === undefined) {
const style = document.createElement('style')
style.textContent = '*'
const div = document.createElement('div')
div.appendChild(style)
document.body.appendChild(div)
supportsInnerTextScriptAndStyleRemovalResult = div.innerText === ''
document.body.removeChild(div)
}
return supportsInnerTextScriptAndStyleRemovalResult
}

/**
* Returns true if the browser supports the element.labels property. This should be the case
* everywhere except on Internet Explorer.
* Note: The result is computed lazily, because we don't want any DOM access when the SDK is
* evaluated.
*/
let supportsLabelPropertyResult: boolean | undefined
function supportsLabelProperty() {
if (supportsLabelPropertyResult === undefined) {
supportsLabelPropertyResult = 'labels' in HTMLInputElement.prototype
}
return supportsLabelPropertyResult
}
35 changes: 0 additions & 35 deletions packages/rum/src/getElementContent.ts

This file was deleted.

8 changes: 6 additions & 2 deletions packages/rum/src/userActionCollection.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Context, DOM_EVENT, generateUUID } from '@datadog/browser-core'
import { getElementContent } from './getElementContent'
import { getActionNameFromElement } from './getActionNameFromElement'
import { LifeCycle, LifeCycleEventType } from './lifeCycle'
import { trackEventCounts } from './trackEventCounts'
import { waitIdlePageActivity } from './trackPageActivities'
Expand Down Expand Up @@ -45,7 +45,11 @@ export function startUserActionCollection(lifeCycle: LifeCycle) {
if (!(event.target instanceof Element)) {
return
}
newUserAction(lifeCycle, UserActionType.CLICK, getElementContent(event.target))
const name = getActionNameFromElement(event.target)
if (!name) {
return
}
newUserAction(lifeCycle, UserActionType.CLICK, name)
}

// New views trigger the cancellation of the current pending User Action
Expand Down
Loading