Skip to content

Commit 1c911e0

Browse files
authored
feat: identify DOM events using CSS locator (#87)
1 parent 2f43f5e commit 1c911e0

File tree

6 files changed

+319
-5
lines changed

6 files changed

+319
-5
lines changed

app/dom_event.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@
5959
<button id="button1">Button One</button>
6060
<a> Link One </a>
6161
<hr />
62+
<button id="button2" label="label1">Button Two</button>
63+
<button id="button3" label="label1">Button Three</button>
64+
<hr />
6265
<button id="dispatch" onclick="dispatch()">Dispatch</button>
6366
<button id="clearRequestResponse" onclick="clearRequestResponse()">
6467
Clear

src/event-schemas/dom-event.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616
"elementId": {
1717
"type": "string",
1818
"description": "DOM element ID."
19+
},
20+
"cssLocator": {
21+
"type": "string",
22+
"description": "CSS Locator string."
1923
}
2024
},
2125
"additionalProperties": false,

src/loader/loader-dom-event.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,21 @@ loader('cwr', 'abc123', '1.0', 'us-west-2', './rum_javascript_telemetry.js', {
66
dispatchInterval: 0,
77
metaDataPluginsToLoad: [],
88
eventPluginsToLoad: [
9-
new DomEventPlugin({ events: [{ event: 'click', element: document }] })
9+
new DomEventPlugin({
10+
events: [
11+
{ event: 'click', element: document },
12+
{
13+
event: 'click',
14+
elementId: 'button1',
15+
cssLocator: '[label="label1"]'
16+
},
17+
{
18+
event: 'click',
19+
elementId: 'button1',
20+
element: document
21+
}
22+
]
23+
})
1024
],
1125
telemetries: [],
1226
clientBuilder: showRequestClientBuilder

src/plugins/event-plugins/DomEventPlugin.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ export type TargetDomEvent = {
1515
*/
1616
elementId?: string;
1717

18+
/**
19+
* DOM element map to identify one element attribute and its expected value
20+
*/
21+
cssLocator?: string;
22+
1823
/**
1924
* DOM element
2025
*/
@@ -84,13 +89,16 @@ export class DomEventPlugin implements Plugin {
8489
);
8590
}
8691

87-
private getEventListener(): EventListener {
92+
private getEventListener(cssLocator?: string): EventListener {
8893
return (event: Event): void => {
8994
const eventData: DomEvent = {
9095
version: '1.0.0',
9196
event: event.type,
9297
elementId: this.getElementId(event)
9398
};
99+
if (cssLocator !== undefined) {
100+
eventData.cssLocator = cssLocator;
101+
}
94102
if (this.recordEvent) {
95103
this.recordEvent(DOM_EVENT_TYPE, eventData);
96104
}
@@ -115,10 +123,16 @@ export class DomEventPlugin implements Plugin {
115123

116124
private addEventHandler(domEvent: TargetDomEvent): void {
117125
const eventType = domEvent.event;
118-
const eventListener = this.getEventListener();
126+
const eventListener = this.getEventListener(domEvent.cssLocator);
119127
this.eventListenerMap.set(domEvent, eventListener);
120128

121-
if (domEvent.elementId) {
129+
// first add event listener to all elements identified by the CSS locator
130+
if (domEvent.cssLocator) {
131+
const elementList = document.querySelectorAll(domEvent.cssLocator);
132+
elementList.forEach((element: HTMLElement) => {
133+
element.addEventListener(eventType, eventListener);
134+
});
135+
} else if (domEvent.elementId) {
122136
document
123137
.getElementById(domEvent.elementId)
124138
?.addEventListener(eventType, eventListener);
@@ -132,7 +146,12 @@ export class DomEventPlugin implements Plugin {
132146
| EventListener
133147
| undefined = this.eventListenerMap.get(domEvent);
134148

135-
if (domEvent.elementId && eventListener) {
149+
if (domEvent.cssLocator && eventListener) {
150+
const elementList = document.querySelectorAll(domEvent.cssLocator);
151+
elementList.forEach((element: HTMLElement) => {
152+
element.removeEventListener(domEvent.event, eventListener);
153+
});
154+
} else if (domEvent.elementId && eventListener) {
136155
const element = document.getElementById(domEvent.elementId);
137156
if (element) {
138157
element.removeEventListener(domEvent.event, eventListener);

src/plugins/event-plugins/__integ__/DomEventPlugin.test.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ const enable: Selector = Selector(`#enable`);
1111
const button1: Selector = Selector(`#button1`);
1212
const link1: Selector = Selector(`a`);
1313

14+
const button2: Selector = Selector(`#button2`);
15+
const button3: Selector = Selector(`#button3`);
16+
1417
const dispatch: Selector = Selector(`#dispatch`);
1518
const clear: Selector = Selector(`#clearRequestResponse`);
1619

@@ -139,3 +142,121 @@ test('when client is disabled and enabled then button click is recorded', async
139142
elementId: 'button1'
140143
});
141144
});
145+
146+
test('when element identified by a CSS selector is clicked then CSS selector is recorded', async (t: TestController) => {
147+
// If we click too soon, the client/event collector plugin will not be loaded and will not record the click.
148+
// This could be a symptom of an issue with RUM web client load speed, or prioritization of script execution.
149+
await t
150+
.wait(300)
151+
.click(button2)
152+
.click(dispatch)
153+
.expect(REQUEST_BODY.textContent)
154+
.contains('BatchId');
155+
156+
const events = JSON.parse(await REQUEST_BODY.textContent).RumEvents.filter(
157+
(e) =>
158+
e.type === DOM_EVENT_TYPE &&
159+
JSON.parse(e.details).cssLocator === '[label="label1"]'
160+
);
161+
162+
const eventType = events[0].type;
163+
const eventDetails = JSON.parse(events[0].details);
164+
165+
await t
166+
.expect(eventType)
167+
.eql(DOM_EVENT_TYPE)
168+
.expect(eventDetails)
169+
.contains({
170+
event: 'click',
171+
cssLocator: '[label="label1"]'
172+
});
173+
});
174+
test('when two elements identified by a CSS selector are clicked then CSS selector is recorded', async (t: TestController) => {
175+
// If we click too soon, the client/event collector plugin will not be loaded and will not record the click.
176+
// This could be a symptom of an issue with RUM web client load speed, or prioritization of script execution.
177+
await t
178+
.wait(300)
179+
.click(button2)
180+
.click(button3)
181+
.click(dispatch)
182+
.expect(REQUEST_BODY.textContent)
183+
.contains('BatchId');
184+
185+
const events = JSON.parse(await REQUEST_BODY.textContent).RumEvents.filter(
186+
(e) =>
187+
e.type === DOM_EVENT_TYPE &&
188+
JSON.parse(e.details).cssLocator === '[label="label1"]'
189+
);
190+
191+
for (let i = 0; i < events.length; i++) {
192+
let eventType = events[i].type;
193+
let eventDetails = JSON.parse(events[i].details);
194+
195+
await t
196+
.expect(events.length)
197+
.eql(2)
198+
.expect(eventType)
199+
.eql(DOM_EVENT_TYPE)
200+
.expect(eventDetails)
201+
.contains({
202+
event: 'click',
203+
cssLocator: '[label="label1"]'
204+
});
205+
}
206+
});
207+
208+
test('when element not identified by a CSS selector is clicked then CSS selector field is not recorded', async (t: TestController) => {
209+
// If we click too soon, the client/event collector plugin will not be loaded and will not record the click.
210+
// This could be a symptom of an issue with RUM web client load speed, or prioritization of script execution.
211+
await t
212+
.wait(300)
213+
.click(button1)
214+
.click(dispatch)
215+
.expect(REQUEST_BODY.textContent)
216+
.contains('BatchId');
217+
218+
const events = JSON.parse(await REQUEST_BODY.textContent).RumEvents.filter(
219+
(e) =>
220+
e.type === DOM_EVENT_TYPE &&
221+
JSON.parse(e.details).elementId === 'button1'
222+
);
223+
224+
const eventType = events[0].type;
225+
const eventDetails = JSON.parse(events[0].details);
226+
227+
await t
228+
.expect(eventType)
229+
.eql(DOM_EVENT_TYPE)
230+
.expect(eventDetails)
231+
.notContains({
232+
cssLocator: '[label="label1"]'
233+
});
234+
});
235+
236+
test('when element ID and CSS selector are specified then only event for element identified by CSS selector is recorded', async (t: TestController) => {
237+
// If we click too soon, the client/event collector plugin will not be loaded and will not record the click.
238+
// This could be a symptom of an issue with RUM web client load speed, or prioritization of script execution.
239+
await t
240+
.wait(300)
241+
.click(button1)
242+
.click(button3)
243+
.click(dispatch)
244+
.expect(REQUEST_BODY.textContent)
245+
.contains('BatchId');
246+
247+
const events = JSON.parse(await REQUEST_BODY.textContent).RumEvents.filter(
248+
(e) =>
249+
e.type === DOM_EVENT_TYPE &&
250+
JSON.parse(e.details).cssLocator === '[label="label1"]'
251+
);
252+
const eventType = events[0].type;
253+
const eventDetails = JSON.parse(events[0].details);
254+
255+
await t
256+
.expect(eventType)
257+
.eql(DOM_EVENT_TYPE)
258+
.expect(eventDetails)
259+
.notContains({
260+
elementId: 'button1'
261+
});
262+
});

src/plugins/event-plugins/__tests__/DomEventPlugin.test.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,4 +102,157 @@ describe('DomEventPlugin tests', () => {
102102
})
103103
);
104104
});
105+
test('when listening to document click and event target has CSS selector, element CSS selector is used as CSS selector', async () => {
106+
// Init
107+
document.body.innerHTML = '<button label="label1"/>';
108+
const plugin: DomEventPlugin = new DomEventPlugin({
109+
events: [{ event: 'click', cssLocator: '[label="label1"]' }]
110+
});
111+
112+
// Run
113+
plugin.load(context);
114+
let element: HTMLElement = document.querySelector(
115+
'[label="label1"]'
116+
) as HTMLElement;
117+
element.click();
118+
plugin.disable();
119+
120+
// Assert
121+
expect(record).toHaveBeenCalledTimes(1);
122+
expect(record.mock.calls[0][0]).toEqual(DOM_EVENT_TYPE);
123+
expect(record.mock.calls[0][1]).toMatchObject(
124+
expect.objectContaining({
125+
version: '1.0.0',
126+
event: 'click',
127+
cssLocator: '[label="label1"]'
128+
})
129+
);
130+
});
131+
132+
test('when listening to document click and two event targets have the same CSS selector, element CSS selector is used as CSS selector for both', async () => {
133+
// Init
134+
document.body.innerHTML =
135+
'<button label="label1"></button> <button label="label1"></button>';
136+
const plugin: DomEventPlugin = new DomEventPlugin({
137+
events: [{ event: 'click', cssLocator: '[label="label1"]' }]
138+
});
139+
140+
// Run
141+
plugin.load(context);
142+
let elementList: NodeListOf<HTMLElement> = document.querySelectorAll(
143+
'[label="label1"]'
144+
) as NodeListOf<HTMLElement>;
145+
for (let i = 0; i < elementList.length; i++) {
146+
elementList[i].click();
147+
}
148+
plugin.disable();
149+
150+
// Assert
151+
expect(record).toHaveBeenCalledTimes(2);
152+
for (let i = 0; i < record.mock.calls.length; i++) {
153+
expect(record.mock.calls[i][0]).toEqual(DOM_EVENT_TYPE);
154+
expect(record.mock.calls[i][1]).toMatchObject(
155+
expect.objectContaining({
156+
version: '1.0.0',
157+
event: 'click',
158+
cssLocator: '[label="label1"]'
159+
})
160+
);
161+
}
162+
});
163+
164+
test('when listening to document click and CSS selector is not specified, CSS selector field not recorded as part of event data', async () => {
165+
// Init
166+
document.body.innerHTML = '<button/>';
167+
const plugin: DomEventPlugin = new DomEventPlugin({
168+
events: [{ event: 'click', element: document as any }]
169+
});
170+
171+
// Run
172+
plugin.load(context);
173+
document.getElementsByTagName('button')[0].click();
174+
plugin.disable();
175+
176+
// Assert
177+
expect(record).toHaveBeenCalledTimes(1);
178+
expect(record.mock.calls[0][0]).toEqual(DOM_EVENT_TYPE);
179+
expect(record.mock.calls[0][1]).toMatchObject(
180+
expect.objectContaining({
181+
version: '1.0.0',
182+
event: 'click'
183+
})
184+
);
185+
expect('cssLocator' in record.mock.calls[0][1]).toEqual(false);
186+
});
187+
188+
test('when listening to document click and both element ID and CSS selector is specified, only event for element identified by CSS selector is recorded', async () => {
189+
// Init
190+
document.body.innerHTML =
191+
'<button id="button1"></button> <button id = "button2" label="label1"></button>';
192+
const plugin: DomEventPlugin = new DomEventPlugin({
193+
events: [
194+
{
195+
event: 'click',
196+
elementId: 'button1',
197+
cssLocator: '[label="label1"]'
198+
}
199+
]
200+
});
201+
202+
// Run
203+
plugin.load(context);
204+
document.getElementById('button1').click();
205+
let element: HTMLElement = document.querySelector(
206+
'[label="label1"]'
207+
) as HTMLElement;
208+
element.click();
209+
plugin.disable();
210+
211+
// Assert
212+
expect(record).toHaveBeenCalledTimes(1);
213+
// Assert
214+
expect(record).toHaveBeenCalledTimes(1);
215+
expect(record.mock.calls[0][0]).toEqual(DOM_EVENT_TYPE);
216+
expect(record.mock.calls[0][1]).toMatchObject(
217+
expect.objectContaining({
218+
version: '1.0.0',
219+
event: 'click',
220+
cssLocator: '[label="label1"]'
221+
})
222+
);
223+
});
224+
225+
test('when listening to document click and both element ID and element is specified, only event for element identified by ID is recorded', async () => {
226+
// Init
227+
document.body.innerHTML =
228+
'<button id="button1"></button> <button id = "button2"></button>';
229+
const plugin: DomEventPlugin = new DomEventPlugin({
230+
events: [
231+
{
232+
event: 'click',
233+
elementId: 'button1',
234+
element: document as any
235+
}
236+
]
237+
});
238+
239+
// Run
240+
plugin.load(context);
241+
document.getElementById('button1').click();
242+
document.getElementById('button2').click();
243+
plugin.disable();
244+
245+
// Assert
246+
expect(record).toHaveBeenCalledTimes(1);
247+
// Assert
248+
expect(record).toHaveBeenCalledTimes(1);
249+
expect(record.mock.calls[0][0]).toEqual(DOM_EVENT_TYPE);
250+
expect(record.mock.calls[0][1]).toMatchObject(
251+
expect.objectContaining({
252+
version: '1.0.0',
253+
event: 'click',
254+
elementId: 'button1'
255+
})
256+
);
257+
});
105258
});

0 commit comments

Comments
 (0)