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 3 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
176 changes: 176 additions & 0 deletions packages/rum/src/getActionNameFromElement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
export function getActionNameFromElement(element: Element): string {
return (
getActionNameFromElementForGetters(element, priorityGetters) ||
getActionNameFromElementForGetters(element, fallbackGetters) ||
''
)
}
BenoitZugmeyer marked this conversation as resolved.
Show resolved Hide resolved

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)
}
},
// aria-label attribute value
(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(' ')
}
},
// alt attribute value
(element) => element.getAttribute('alt'),
// name attribute value
(element) => element.getAttribute('name'),
// title attribute value
(element) => element.getAttribute('title'),
// placeholder attribute value
(element) => element.getAttribute('placeholder'),
// SELECT first OPTION text
(element) => {
BenoitZugmeyer marked this conversation as resolved.
Show resolved Hide resolved
if ('options' in element && element.options.length > 0) {
return getTextualContent(element.options[0])
}
},
]

const fallbackGetters: NameGetter[] = [
// element text
(element) => {
return getTextualContent(element)
},
BenoitZugmeyer marked this conversation as resolved.
Show resolved Hide resolved
]

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 <= 10 &&
BenoitZugmeyer marked this conversation as resolved.
Show resolved Hide resolved
element &&
element.nodeName !== 'BODY' &&
element.nodeName !== 'HTML' &&
element.nodeName !== 'HEAD'
) {
for (const getter of getters) {
const name = getter(element)
if (typeof name === 'string') {
const trimedName = name.trim()
BenoitZugmeyer marked this conversation as resolved.
Show resolved Hide resolved
if (trimedName) {
return truncate(normalizeWhitespace(trimedName))
}
}
}
// Don't consider parents of FORM elements
if (element.nodeName === 'FORM') {
break
}
BenoitZugmeyer marked this conversation as resolved.
Show resolved Hide resolved
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) {
// 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 elementsToRemove: NodeListOf<HTMLElement> = element.querySelectorAll('script, style')
BenoitZugmeyer marked this conversation as resolved.
Show resolved Hide resolved
// tslint:disable-next-line: prefer-for-of
for (let i = 0; i < elementsToRemove.length; i += 1) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

according to MDN for-of should work correctly on node list, typing does not seem to allow that though.
it could worth to check if it is supported by newer ts version but not important now

Copy link
Member Author

@BenoitZugmeyer BenoitZugmeyer May 28, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It appears to be working on two conditions:

  • use the DOM.Iterable lib
  • and use ES6+ as a target or the downlevelIteration option

I'll keep the traditional for loop here for now.

const innerText = elementsToRemove[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