Skip to content

Commit

Permalink
feat: add form interaction event tracker plugin (#323)
Browse files Browse the repository at this point in the history
  • Loading branch information
kevinpagtakhan committed Feb 8, 2023
1 parent 1604409 commit f6ee918
Show file tree
Hide file tree
Showing 4 changed files with 250 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export const fileDownloadTracking = (): EnrichmentPlugin => {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeName == 'A') {
if (node.nodeName === 'A') {
addFileDownloadListener(node as HTMLAnchorElement);
}
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { BrowserClient, PluginType, Event, EnrichmentPlugin } from '@amplitude/analytics-types';
import { BrowserConfig } from '../config';

const FORM_START_EVENT = 'form_start';
const FORM_SUBMIT_EVENT = 'form_submit';

export const formInteractionTracking = (): EnrichmentPlugin => {
const name = 'formInteractionTracking';
const type = PluginType.ENRICHMENT;
const setup = async (config: BrowserConfig, amplitude?: BrowserClient) => {
/* istanbul ignore if */
if (!amplitude) {
// TODO: Add required minimum version of @amplitude/analytics-browser
config.loggerProvider.warn(
'Form interaction tracking requires a later version of @amplitude/analytics-browser. Form interaction events are not tracked.',
);
return;
}

const addFormInteractionListener = (form: HTMLFormElement) => {
let hasFormChanged = false;

form.addEventListener(
'change',
() => {
if (!hasFormChanged) {
amplitude.track(FORM_START_EVENT, {
form_id: form.id,
form_name: form.name,
form_destination: form.action,
});
}
hasFormChanged = true;
},
{},
);

form.addEventListener('submit', () => {
if (!hasFormChanged) {
amplitude.track(FORM_START_EVENT, {
form_id: form.id,
form_name: form.name,
form_destination: form.action,
});
}

amplitude.track(FORM_SUBMIT_EVENT, {
form_id: form.id,
form_name: form.name,
form_destination: form.action,
});
hasFormChanged = false;
});
};

// Adds listener to existing anchor tags
const forms = Array.from(document.getElementsByTagName('form'));
forms.forEach(addFormInteractionListener);

// Adds listener to anchor tags added after initial load
/* istanbul ignore else */
if (typeof MutationObserver !== 'undefined') {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeName === 'FORM') {
addFormInteractionListener(node as HTMLFormElement);
}
});
});
});

observer.observe(document.body, {
subtree: true,
childList: true,
});
}
};
const execute = async (event: Event) => event;

return {
name,
type,
setup,
execute,
};
};
2 changes: 0 additions & 2 deletions packages/analytics-browser/test/helpers/mock.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { SessionManager } from '@amplitude/analytics-client-common';
import { Logger, MemoryStorage, UUID } from '@amplitude/analytics-core';
import { BrowserClient, BrowserConfig, LogLevel, UserSession } from '@amplitude/analytics-types';

Expand Down Expand Up @@ -27,7 +26,6 @@ export const createAmplitudeMock = (): jest.MockedObject<BrowserClient> => ({
export const createConfigurationMock = (options?: Partial<BrowserConfig>) => {
const apiKey = options?.apiKey ?? UUID();
const cookieStorage = new MemoryStorage<UserSession>();
const sessionStorage = new SessionManager(cookieStorage, apiKey);

return {
// core config
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/* eslint-disable @typescript-eslint/unbound-method */

import { createAmplitudeMock, createConfigurationMock } from '../helpers/mock';
import { formInteractionTracking } from '../../src/plugins/form-interaction-tracking';

describe('formInteractionTracking', () => {
let amplitude = createAmplitudeMock();

beforeEach(() => {
amplitude = createAmplitudeMock();

const form = document.createElement('form');
form.setAttribute('id', 'my-form-id');
form.setAttribute('name', 'my-form-name');
form.setAttribute('action', '/submit');

const text = document.createElement('input');
text.setAttribute('type', 'text');
text.setAttribute('id', 'my-text-id');

const submit = document.createElement('input');
submit.setAttribute('type', 'submit');
submit.setAttribute('id', 'my-submit-id');

form.appendChild(text);
form.appendChild(submit);
document.body.appendChild(form);
});

afterEach(() => {
document.querySelector('form#my-form-id')?.remove();
});

test('should track form_start event', async () => {
// setup
const config = createConfigurationMock();
const plugin = formInteractionTracking();
await plugin.setup(config, amplitude);

// trigger change event
document.getElementById('my-form-id')?.dispatchEvent(new Event('change'));

// assert first event was tracked
expect(amplitude.track).toHaveBeenCalledTimes(1);
expect(amplitude.track).toHaveBeenNthCalledWith(1, 'form_start', {
form_id: 'my-form-id',
form_name: 'my-form-name',
form_destination: 'http://localhost/submit',
});

// trigger change event again
document.getElementById('my-form-id')?.dispatchEvent(new Event('change'));

// assert second event was not tracked
expect(amplitude.track).toHaveBeenCalledTimes(1);
});

test('should track form_start event for a dynamically added form tag', async () => {
// setup
const config = createConfigurationMock();
const plugin = formInteractionTracking();
await plugin.setup(config, amplitude);

// add form elemen dynamically
const form = document.createElement('form');
form.setAttribute('id', 'my-form-2-id');
form.setAttribute('name', 'my-form-2-name');
form.setAttribute('action', '/submit');

const text = document.createElement('input');
text.setAttribute('type', 'text');
text.setAttribute('id', 'my-text-2-id');

const submit = document.createElement('input');
submit.setAttribute('type', 'submit');
submit.setAttribute('id', 'my-submit-2-id');

form.appendChild(text);
form.appendChild(submit);
document.body.appendChild(form);

// allow mutation observer to execute and event listener to be attached
await new Promise((r) => r(undefined)); // basically, await next clock tick
// trigger change event
form.dispatchEvent(new Event('change'));

// assert first event was tracked
expect(amplitude.track).toHaveBeenCalledTimes(1);
expect(amplitude.track).toHaveBeenNthCalledWith(1, 'form_start', {
form_id: 'my-form-2-id',
form_name: 'my-form-2-name',
form_destination: 'http://localhost/submit',
});

// trigger change event again
form.dispatchEvent(new Event('change'));

// assert second event was not tracked
expect(amplitude.track).toHaveBeenCalledTimes(1);
});

test('should track form_start and form_submit events on change and submit', async () => {
// setup
const config = createConfigurationMock();
const plugin = formInteractionTracking();
await plugin.setup(config, amplitude);

// trigger change event
document.getElementById('my-form-id')?.dispatchEvent(new Event('submit'));

// assert both events were tracked
expect(amplitude.track).toHaveBeenCalledTimes(2);
expect(amplitude.track).toHaveBeenNthCalledWith(1, 'form_start', {
form_id: 'my-form-id',
form_name: 'my-form-name',
form_destination: 'http://localhost/submit',
});
expect(amplitude.track).toHaveBeenNthCalledWith(2, 'form_submit', {
form_id: 'my-form-id',
form_name: 'my-form-name',
form_destination: 'http://localhost/submit',
});
});

test('should track form_start and form_submit events on submit only', async () => {
// setup
const config = createConfigurationMock();
const plugin = formInteractionTracking();
await plugin.setup(config, amplitude);

// trigger change event again
document.getElementById('my-form-id')?.dispatchEvent(new Event('change'));

// assert first event was tracked
expect(amplitude.track).toHaveBeenCalledTimes(1);
expect(amplitude.track).toHaveBeenNthCalledWith(1, 'form_start', {
form_id: 'my-form-id',
form_name: 'my-form-name',
form_destination: 'http://localhost/submit',
});

// trigger submit event
document.getElementById('my-form-id')?.dispatchEvent(new Event('submit'));

// assert second event was tracked
expect(amplitude.track).toHaveBeenCalledTimes(2);
expect(amplitude.track).toHaveBeenNthCalledWith(2, 'form_submit', {
form_id: 'my-form-id',
form_name: 'my-form-name',
form_destination: 'http://localhost/submit',
});
});

test('should not enrich events', async () => {
const input = {
event_type: 'page_view',
};
const plugin = formInteractionTracking();
const result = await plugin.execute(input);
expect(result).toEqual(input);
});
});

0 comments on commit f6ee918

Please sign in to comment.