diff --git a/packages/analytics-browser/e2e/form-interactions.spec.ts b/packages/analytics-browser/e2e/form-interactions.spec.ts new file mode 100644 index 0000000000..e2d060ee71 --- /dev/null +++ b/packages/analytics-browser/e2e/form-interactions.spec.ts @@ -0,0 +1,39 @@ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +import { test, expect } from '@playwright/test'; + +const FORM_STARTED_EVENT = '[Amplitude] Form Started'; + +test.describe('Form Interactions Page', () => { + let requests: any[] = []; + + test.beforeEach(async ({ page }) => { + requests = []; + await page.route('https://api2.amplitude.com/2/httpapi', async (route) => { + const request = route.request(); + const postData = request.postData(); + if (postData) { + const data = JSON.parse(postData); + requests.push(data); + } + await route.continue(); + }); + }); + + test('should track form started events', async ({ page }) => { + await page.goto('/form-interactions/form-interactions.html'); + await page.waitForLoadState('networkidle'); + + // Trigger form started by changing a form field (first interaction) and then navigate away to trigger pagehide/beforeunload → form abandoned + await page.fill('#name', 'Test User'); + await page.fill('#email', 'test@example.com'); + // mock dispatch pagehide event + await page.evaluate(() => window.dispatchEvent(new Event('beforeunload'))); + await page.waitForTimeout(2000); + + // Wait for the form started event to be sent + await expect(async () => { + const allEvents = requests.flatMap((r) => r.events || []).filter((e: any) => e.event_type !== '$identify'); + expect(allEvents.some((e: any) => e.event_type === FORM_STARTED_EVENT)).toBe(true); + }).toPass({ timeout: 5000 }); + }); +}); diff --git a/packages/analytics-browser/src/constants.ts b/packages/analytics-browser/src/constants.ts index da6c9d46e3..de200e6d18 100644 --- a/packages/analytics-browser/src/constants.ts +++ b/packages/analytics-browser/src/constants.ts @@ -5,6 +5,7 @@ export const DEFAULT_EVENT_PREFIX = '[Amplitude]'; export const DEFAULT_PAGE_VIEW_EVENT = `${DEFAULT_EVENT_PREFIX} Page Viewed`; export const DEFAULT_FORM_START_EVENT = `${DEFAULT_EVENT_PREFIX} Form Started`; export const DEFAULT_FORM_SUBMIT_EVENT = `${DEFAULT_EVENT_PREFIX} Form Submitted`; +export const DEFAULT_FORM_ABANDONED_EVENT = `${DEFAULT_EVENT_PREFIX} Form Abandoned`; export const DEFAULT_FILE_DOWNLOAD_EVENT = `${DEFAULT_EVENT_PREFIX} File Downloaded`; export const DEFAULT_SESSION_START_EVENT = 'session_start'; export const DEFAULT_SESSION_END_EVENT = 'session_end'; diff --git a/packages/analytics-browser/src/plugins/form-interaction-tracking.ts b/packages/analytics-browser/src/plugins/form-interaction-tracking.ts index 99269fc594..bf63fa1490 100644 --- a/packages/analytics-browser/src/plugins/form-interaction-tracking.ts +++ b/packages/analytics-browser/src/plugins/form-interaction-tracking.ts @@ -1,6 +1,7 @@ import { DEFAULT_FORM_START_EVENT, DEFAULT_FORM_SUBMIT_EVENT, + DEFAULT_FORM_ABANDONED_EVENT, FORM_ID, FORM_NAME, FORM_DESTINATION, @@ -15,9 +16,13 @@ import { } from '@amplitude/analytics-core'; import { getFormInteractionsConfig } from '../default-tracking'; +type ElementOrWindow = Element | Pick; + +type EventType = 'change' | 'submit' | 'pagehide' | 'beforeunload'; + interface EventListener { - element: Element; - type: 'change' | 'submit'; + elementOrWindow: ElementOrWindow; + type: EventType; handler: (event: Event) => void; } @@ -25,19 +30,19 @@ export const formInteractionTracking = (): EnrichmentPlugin => { let observer: MutationObserver | undefined; let eventListeners: EventListener[] = []; - const addEventListener = (element: Element, type: 'change' | 'submit', handler: (event: Event) => void) => { - element.addEventListener(type, handler); + const addEventListener = (elementOrWindow: ElementOrWindow, type: EventType, handler: (event: Event) => void) => { + elementOrWindow.addEventListener(type, handler); eventListeners.push({ - element, + elementOrWindow, type, handler, }); }; const removeClickListeners = () => { - eventListeners.forEach(({ element, type, handler }) => { + eventListeners.forEach(({ elementOrWindow, type, handler }) => { /* istanbul ignore next */ - element?.removeEventListener(type, handler); + elementOrWindow?.removeEventListener(type, handler); }); eventListeners = []; }; @@ -72,17 +77,40 @@ export const formInteractionTracking = (): EnrichmentPlugin => { addedFormNodes.add(form); let hasFormChanged = false; + let hasFormAbandoned = false; + + const trackFormAbandoned = () => { + if (hasFormChanged && !hasFormAbandoned) { + hasFormAbandoned = true; + const formDestination = extractFormAction(form); + amplitude.track(DEFAULT_FORM_ABANDONED_EVENT, { + [FORM_ID]: stringOrUndefined(form.id), + [FORM_NAME]: stringOrUndefined(form.name), + [FORM_DESTINATION]: formDestination, + }); + } + // left the page indicating + hasFormChanged = false; + }; + + const win = getGlobalScope() as Pick; + + /* istanbul ignore if */ + if (formInteractionsConfig?.shouldTrackAbandoned && win) { + addEventListener(win, 'pagehide', trackFormAbandoned); + addEventListener(win, 'beforeunload', trackFormAbandoned); + } addEventListener(form, 'change', () => { - const formDestination = extractFormAction(form); if (!hasFormChanged) { + hasFormChanged = true; + const formDestination = extractFormAction(form); amplitude.track(DEFAULT_FORM_START_EVENT, { [FORM_ID]: stringOrUndefined(form.id), [FORM_NAME]: stringOrUndefined(form.name), [FORM_DESTINATION]: formDestination, }); } - hasFormChanged = true; }); addEventListener(form, 'submit', (event: Event) => { diff --git a/packages/analytics-browser/test/plugins/form-interaction-tracking.test.ts b/packages/analytics-browser/test/plugins/form-interaction-tracking.test.ts index 3393e1a8ee..a66fa8a2ef 100644 --- a/packages/analytics-browser/test/plugins/form-interaction-tracking.test.ts +++ b/packages/analytics-browser/test/plugins/form-interaction-tracking.test.ts @@ -541,4 +541,51 @@ describe('formInteractionTracking', () => { }); }); }); + + describe('shouldTrackAbandoned', () => { + beforeEach(async () => { + const config = createConfigurationMock({ + defaultTracking: { + formInteractions: { + shouldTrackAbandoned: true, + }, + }, + }); + const plugin = formInteractionTracking(); + await plugin.setup?.(config, amplitude); + window.dispatchEvent(new Event('load')); + }); + + test('should track form abandoned when form started then abandoned (pagehide)', async () => { + document.getElementById('my-form-id')?.dispatchEvent(new Event('change')); + window.dispatchEvent(new Event('pagehide')); + expect(amplitude.track).toHaveBeenNthCalledWith(2, '[Amplitude] Form Abandoned', { + [FORM_ID]: 'my-form-id', + [FORM_NAME]: 'my-form-name', + [FORM_DESTINATION]: 'http://localhost/submit', + }); + }); + + test('should track form abandoned when form started then abandoned (beforeunload)', async () => { + document.getElementById('my-form-id')?.dispatchEvent(new Event('change')); + window.dispatchEvent(new Event('beforeunload')); + expect(amplitude.track).toHaveBeenNthCalledWith(2, '[Amplitude] Form Abandoned', { + [FORM_ID]: 'my-form-id', + [FORM_NAME]: 'my-form-name', + [FORM_DESTINATION]: 'http://localhost/submit', + }); + }); + + test('should track form abandoned when form started then abandoned (multiple times)', async () => { + document.getElementById('my-form-id')?.dispatchEvent(new Event('change')); + window.dispatchEvent(new Event('beforeunload')); + window.dispatchEvent(new Event('pagehide')); + expect(amplitude.track).toHaveBeenCalledTimes(2); + }); + + test('should not track form abandoned when form is not started', async () => { + window.dispatchEvent(new Event('pagehide')); + expect(amplitude.track).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/analytics-core/src/types/form-interactions.ts b/packages/analytics-core/src/types/form-interactions.ts index 2bf04909f7..76cb22a261 100644 --- a/packages/analytics-core/src/types/form-interactions.ts +++ b/packages/analytics-core/src/types/form-interactions.ts @@ -22,4 +22,11 @@ export interface FormInteractionsOptions { * ``` */ shouldTrackSubmit?: (event: SubmitEvent) => boolean; + + /** + * Whether to track `[Amplitude] Form Abandoned` event + * @defaultValue `true` + * @experimental This feature is experimental and may not be stable + */ + shouldTrackAbandoned?: boolean; } diff --git a/test-server/form-interactions/form-interactions.html b/test-server/form-interactions/form-interactions.html new file mode 100644 index 0000000000..b2c5a78b25 --- /dev/null +++ b/test-server/form-interactions/form-interactions.html @@ -0,0 +1,47 @@ + + + + + + Form Interactions Test + + +

Form Interactions Test

+

This page initializes Amplitude with autocapture.formInteractions enabled.

+ +
+

+ + +

+

+ + +

+

+ + +

+

+ +

+
+ + + +