diff --git a/pages/funnel-analytics/app-layout-iframe-wizard.page.tsx b/pages/funnel-analytics/app-layout-iframe-wizard.page.tsx new file mode 100644 index 0000000000..d94523f755 --- /dev/null +++ b/pages/funnel-analytics/app-layout-iframe-wizard.page.tsx @@ -0,0 +1,63 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import AppLayout from '~components/app-layout'; +import BreadcrumbGroup from '~components/breadcrumb-group'; +import { setFunnelMetrics } from '~components/internal/analytics'; +import ScreenreaderOnly from '~components/internal/components/screenreader-only'; + +import labels from '../app-layout/utils/labels'; +import { IframeWrapper } from '../utils/iframe-wrapper'; +import ScreenshotArea from '../utils/screenshot-area'; +import { MockedFunnelMetrics } from './mock-funnel'; +import { WizardFlow } from './shared/wizard-flow'; + +setFunnelMetrics(MockedFunnelMetrics); + +function InnerApp() { + const [mounted, setMounted] = useState(true); + return ( + + } + navigationHide={true} + toolsHide={true} + content={mounted ? setMounted(false)} /> : 'no wizard'} + /> + ); +} + +export default function () { + return ( + + + + All content lives in iframe + + + > + } + /> + + ); +} diff --git a/pages/funnel-analytics/shared/wizard-flow.tsx b/pages/funnel-analytics/shared/wizard-flow.tsx new file mode 100644 index 0000000000..f13d3e9ca6 --- /dev/null +++ b/pages/funnel-analytics/shared/wizard-flow.tsx @@ -0,0 +1,148 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import Container from '~components/container'; +import FormField from '~components/form-field'; +import Header from '~components/header'; +import Input from '~components/input'; +import Link from '~components/link'; +import SpaceBetween from '~components/space-between'; +import Wizard, { WizardProps } from '~components/wizard'; + +import { i18nStrings } from '../../wizard/common'; + +import styles from '../../wizard/styles.scss'; + +interface WizardFlowProps { + onUnmount?: () => void; +} + +export function WizardFlow({ onUnmount }: WizardFlowProps) { + const [value, setValue] = useState(''); + const [value2, setValue2] = useState(''); + const [value3, setValue3] = useState(''); + const [value4, setValue4] = useState(''); + + const [errorText, setErrorText] = useState(''); + const [activeStepIndex, setActiveStepIndex] = useState(0); + const steps: WizardProps.Step[] = [ + { + title: 'Step 1', + errorText, + content: ( + + Container 1 - header} + analyticsMetadata={{ + instanceIdentifier: 'step1-container1', + }} + > + + + Learn more + + } + errorText={value === 'error' ? 'Trigger error' : ''} + label="Field 1" + > + { + setValue(event.detail.value); + }} + /> + + + { + setValue2(event.detail.value); + }} + /> + + + + Container 2 - header} + analyticsMetadata={{ instanceIdentifier: 'step1-container2' }} + > + + + { + setValue3(event.detail.value); + }} + /> + + + { + setValue4(event.detail.value); + }} + /> + + + + + ), + analyticsMetadata: { instanceIdentifier: 'step-1' }, + }, + { + title: 'Step 2', + isOptional: true, + errorText, + content: ( + + Content 2 + + ), + analyticsMetadata: { instanceIdentifier: 'step-2' }, + }, + { + title: 'Step 3', + info: Info, + errorText: 'Simulated final step error', + content: ( + + {Array.from(Array(15).keys()).map(key => ( + + Item {key} + + ))} + + ), + analyticsMetadata: { instanceIdentifier: 'step-3' }, + }, + ]; + + return ( + { + if (value === 'error') { + setErrorText('There is an error'); + } else { + setErrorText(''); + setActiveStepIndex(e.detail.requestedStepIndex); + } + }} + onCancel={onUnmount} + onSubmit={onUnmount} + /> + ); +} diff --git a/pages/funnel-analytics/static-multi-page-flow.page.tsx b/pages/funnel-analytics/static-multi-page-flow.page.tsx index 6d96e7f41c..7226fda8fb 100644 --- a/pages/funnel-analytics/static-multi-page-flow.page.tsx +++ b/pages/funnel-analytics/static-multi-page-flow.page.tsx @@ -2,135 +2,18 @@ // SPDX-License-Identifier: Apache-2.0 import React, { useState } from 'react'; -import { - AppLayout, - BreadcrumbGroup, - Container, - FormField, - Header, - Input, - Link, - SpaceBetween, - Wizard, - WizardProps, -} from '~components'; +import AppLayout from '~components/app-layout'; +import BreadcrumbGroup from '~components/breadcrumb-group'; import { setFunnelMetrics } from '~components/internal/analytics'; import labels from '../app-layout/utils/labels'; -import { i18nStrings } from '../wizard/common'; import { MockedFunnelMetrics } from './mock-funnel'; - -import styles from '../wizard/styles.scss'; +import { WizardFlow } from './shared/wizard-flow'; setFunnelMetrics(MockedFunnelMetrics); export default function MultiPageCreate() { const [mounted, setMounted] = useState(true); - const [value, setValue] = useState(''); - const [value2, setValue2] = useState(''); - const [value3, setValue3] = useState(''); - const [value4, setValue4] = useState(''); - - const [errorText, setErrorText] = useState(''); - const [activeStepIndex, setActiveStepIndex] = useState(0); - - const steps: WizardProps.Step[] = [ - { - title: 'Step 1', - errorText, - content: ( - - Container 1 - header} - analyticsMetadata={{ - instanceIdentifier: 'step1-container1', - }} - > - - - Learn more - - } - errorText={value === 'error' ? 'Trigger error' : ''} - label="Field 1" - > - { - setValue(event.detail.value); - }} - /> - - - { - setValue2(event.detail.value); - }} - /> - - - - Container 2 - header} - analyticsMetadata={{ instanceIdentifier: 'step1-container2' }} - > - - - { - setValue3(event.detail.value); - }} - /> - - - { - setValue4(event.detail.value); - }} - /> - - - - - ), - analyticsMetadata: { instanceIdentifier: 'step-1' }, - }, - { - title: 'Step 2', - isOptional: true, - errorText, - content: ( - - Content 2 - - ), - analyticsMetadata: { instanceIdentifier: 'step-2' }, - }, - { - title: 'Step 3', - info: Info, - errorText: 'Simulated final step error', - content: ( - - {Array.from(Array(15).keys()).map(key => ( - - Item {key} - - ))} - - ), - analyticsMetadata: { instanceIdentifier: 'step-3' }, - }, - ]; return ( setMounted(false)}> Unmount - {mounted && ( - { - if (value === 'error') { - setErrorText('There is an error'); - } else { - setErrorText(''); - setActiveStepIndex(e.detail.requestedStepIndex); - } - }} - onCancel={() => { - setMounted(false); - }} - onSubmit={() => { - setMounted(false); - }} - /> - )} + {mounted && setMounted(false)} />} > } /> diff --git a/src/internal/analytics/__integ__/static-multi-page-create.test.ts b/src/internal/analytics/__integ__/static-multi-page-create.test.ts index 91843a5f44..c9eeffc55f 100644 --- a/src/internal/analytics/__integ__/static-multi-page-create.test.ts +++ b/src/internal/analytics/__integ__/static-multi-page-create.test.ts @@ -16,374 +16,376 @@ const wrapper = createWrapper(); const FUNNEL_INTERACTION_ID = 'mocked-funnel-id'; const FUNNEL_IDENTIFIER = 'multi-page-demo'; -class MultiPageCreate extends BasePageObject { - async visit(url: string) { - await this.browser.url(url); - await this.waitForVisible(wrapper.findAppLayout().findContentRegion().toSelector()); - } - - async getFunnelLog() { - const funnelLog = await this.browser.execute(() => window.__awsuiFunnelMetrics__); - const actions = funnelLog.map(item => item.action); - return { funnelLog, actions }; - } - - async getFunnelLogItem(index: number) { - const item = await this.browser.execute(index => window.__awsuiFunnelMetrics__[index], index); - if (!item) { - throw new Error(`No funnel log item at index ${index}`); - } - return item; - } -} - describe.each(['refresh', 'refresh-toolbar'] as Theme[])('%s', theme => { - function setupTest(testFn: (page: MultiPageCreate) => Promise) { - return useBrowser(async browser => { - const page = new MultiPageCreate(browser); - await browser.url(`#/light/funnel-analytics/static-multi-page-flow?${getUrlParams(theme, {})}`); - await new Promise(r => setTimeout(r, 10)); - await testFn(page); - }); - } - - test( - 'Starts funnel and funnel step and page is loaded', - setupTest(async page => { - const { funnelLog, actions } = await page.getFunnelLog(); - expect(actions).toEqual(['funnelStart', 'funnelStepStart']); - - const [funnelStartEvent, funnelStartStepEvent] = funnelLog; - expect(funnelStartEvent.props).toEqual({ - componentVersion: expect.any(String), - funnelNameSelector: expect.any(String), - funnelVersion: expect.any(String), - funnelIdentifier: FUNNEL_IDENTIFIER, - funnelName: 'Create Resource', - flowType: 'create', - funnelType: 'multi-page', - resourceType: 'Components', - optionalStepNumbers: [2], - componentTheme: 'vr', - totalFunnelSteps: 3, - stepConfiguration: [ - { - name: 'Step 1', - number: 1, - isOptional: false, - stepIdentifier: 'step-1', - }, - { - name: 'Step 2', - number: 2, - isOptional: true, - stepIdentifier: 'step-2', - }, - { - name: 'Step 3', - number: 3, - isOptional: false, - stepIdentifier: 'step-3', - }, - ], - }); - - expect(funnelStartEvent.resolvedProps).toEqual({ - funnelName: 'Create Resource', - }); - - expect(funnelStartStepEvent.props).toEqual({ - stepNameSelector: expect.any(String), - subStepAllSelector: expect.any(String), - funnelInteractionId: FUNNEL_INTERACTION_ID, - funnelIdentifier: FUNNEL_IDENTIFIER, - stepIdentifier: 'step-1', - stepName: 'Step 1', - subStepConfiguration: [ - { - name: 'Container 1 - header', - number: 1, - subStepIdentifier: 'step1-container1', - }, - { - name: 'Container 2 - header', - number: 2, - subStepIdentifier: 'step1-container2', - }, - ], - stepNumber: 1, - totalSubSteps: 2, - }); - }) - ); - - test( - 'Starts and ends substep when navigating between containers', - setupTest(async page => { - await page.click('[data-testid=field1]'); - await page.keys('Tab'); // Input 1 -> Input 2 - await page.keys('Tab'); // Input 2 -> Form Field 3 External Link - - const { funnelLog, actions } = await page.getFunnelLog(); - const [, , funnelSubStep1StartEvent, funnelSubStep1CompleteEvent, funnelSubStep2StartEvent] = funnelLog; - - expect(actions).toEqual([ - 'funnelStart', - 'funnelStepStart', - 'funnelSubStepStart', - 'funnelSubStepComplete', - 'funnelSubStepStart', - ]); - - expect(funnelSubStep1StartEvent.props).toEqual({ - stepNameSelector: expect.any(String), - subStepAllSelector: expect.any(String), - subStepNameSelector: expect.any(String), - subStepSelector: expect.any(String), - funnelIdentifier: FUNNEL_IDENTIFIER, - funnelInteractionId: FUNNEL_INTERACTION_ID, - stepIdentifier: 'step-1', - stepName: 'Step 1', - subStepName: 'Container 1 - header', - subStepNumber: 1, - subStepIdentifier: 'step1-container1', - stepNumber: 1, - }); - expect(funnelSubStep1StartEvent.resolvedProps).toEqual({ - subStepElement: expect.any(Object), - subStepAllElements: expect.any(Object), - stepName: 'Step 1', - subStepName: 'Container 1 - header', - }); - expect(funnelSubStep1StartEvent.resolvedProps.subStepAllElements.length).toBe(2); - - expect(funnelSubStep1CompleteEvent.props).toEqual({ - stepNameSelector: expect.any(String), - subStepAllSelector: expect.any(String), - subStepNameSelector: expect.any(String), - subStepSelector: expect.any(String), - funnelIdentifier: FUNNEL_IDENTIFIER, - funnelInteractionId: FUNNEL_INTERACTION_ID, - stepIdentifier: 'step-1', - stepName: 'Step 1', - subStepName: 'Container 1 - header', - subStepNumber: 1, - subStepIdentifier: 'step1-container1', - stepNumber: 1, - }); - expect(funnelSubStep1CompleteEvent.resolvedProps).toEqual({ - subStepElement: expect.any(Object), - subStepAllElements: expect.any(Object), - stepName: 'Step 1', - subStepName: 'Container 1 - header', - }); - - expect(funnelSubStep2StartEvent.props).toEqual({ - stepNameSelector: expect.any(String), - subStepAllSelector: expect.any(String), - subStepNameSelector: expect.any(String), - subStepSelector: expect.any(String), - funnelIdentifier: FUNNEL_IDENTIFIER, - funnelInteractionId: FUNNEL_INTERACTION_ID, - stepIdentifier: 'step-1', - stepName: 'Step 1', - subStepName: 'Container 2 - header', - subStepNumber: 2, - stepNumber: 1, - subStepIdentifier: 'step1-container2', - }); - expect(funnelSubStep2StartEvent.resolvedProps).toEqual({ - subStepElement: expect.any(Object), - subStepAllElements: expect.any(Object), - stepName: 'Step 1', - subStepName: 'Container 2 - header', - }); - expect(funnelSubStep2StartEvent.resolvedProps.subStepAllElements.length).toBe(2); - }) - ); - - test( - 'wizard step navigation', - setupTest(async page => { - await page.click(wrapper.findWizard().findPrimaryButton().toSelector()); - await page.click(wrapper.findWizard().findPreviousButton().toSelector()); - const { funnelLog, actions } = await page.getFunnelLog(); - const [, , funnelStepNavigationEvent, , , funnelStepPreviousNavigationEvent] = funnelLog; - - expect(actions).toEqual([ - 'funnelStart', - 'funnelStepStart', // Step 1 - Start - 'funnelStepNavigation', // Navigate to Step 2 - 'funnelStepComplete', // Step 1 - Complete - 'funnelStepStart', // Step 2 - Start - 'funnelStepNavigation', // Navigate back to Step 1 - 'funnelStepComplete', // Step 2 - Complete - 'funnelStepStart', // Step 1 - Start - ]); - - expect(funnelStepNavigationEvent.props).toEqual({ - stepNameSelector: expect.any(String), - subStepAllSelector: expect.any(String), - funnelInteractionId: FUNNEL_INTERACTION_ID, - stepName: 'Step 1', - navigationType: 'next', - destinationStepNumber: 2, - stepNumber: 1, - }); - - expect(funnelStepNavigationEvent.resolvedProps).toEqual({ - stepName: 'Step 1', - }); - - expect(funnelStepPreviousNavigationEvent.props).toEqual({ - stepNameSelector: expect.any(String), - subStepAllSelector: expect.any(String), - funnelInteractionId: FUNNEL_INTERACTION_ID, - stepName: 'Step 2', - navigationType: 'previous', - destinationStepNumber: 1, - stepNumber: 2, - }); - - expect(funnelStepPreviousNavigationEvent.resolvedProps).toEqual({ - stepName: 'Step 2', - }); - }) - ); - - test( - 'wizard submission', - setupTest(async page => { - const primaryButton = wrapper.findWizard().findPrimaryButton().toSelector(); - await page.click(primaryButton); - await page.click(primaryButton); - await page.click(primaryButton); // Create - - const { funnelLog, actions } = await page.getFunnelLog(); - - const funnelCompleteEventIndex = actions.findIndex(action => action === 'funnelComplete'); - const funnelCompleteEvent = funnelLog[funnelCompleteEventIndex]; - expect(funnelCompleteEvent.props).toEqual({ - funnelIdentifier: FUNNEL_IDENTIFIER, - funnelInteractionId: FUNNEL_INTERACTION_ID, - }); - - const funnelSucessEventIndex = actions.findIndex(action => action === 'funnelSuccessful'); - const funnelSuccessfulEvent = funnelLog[funnelSucessEventIndex]; - expect(funnelSuccessfulEvent.props).toEqual({ - funnelIdentifier: FUNNEL_IDENTIFIER, - funnelInteractionId: FUNNEL_INTERACTION_ID, - }); - }) - ); - - test( - 'wizard cancelled', - setupTest(async page => { - await page.click(wrapper.findWizard().findPrimaryButton().toSelector()); - await page.click(wrapper.findWizard().findCancelButton().toSelector()); - - const { funnelLog, actions } = await page.getFunnelLog(); - expect(actions).toEqual([ - 'funnelStart', - 'funnelStepStart', // Step 1 - Start - 'funnelStepNavigation', // Navigate to Step 2 - 'funnelStepComplete', // Step 1 - Complete - 'funnelStepStart', // Step 2 - Start - 'funnelCancelled', - ]); - const funnelCancelledEvent = funnelLog[5]; - expect(funnelCancelledEvent.props).toEqual({ - funnelIdentifier: FUNNEL_IDENTIFIER, - funnelInteractionId: FUNNEL_INTERACTION_ID, - }); - }) - ); - - test( - 'wizard abandoned', - setupTest(async page => { - await page.click('[data-testid=unmount]'); - const { funnelLog, actions } = await page.getFunnelLog(); - expect(actions).toEqual(['funnelStart', 'funnelStepStart', 'funnelCancelled']); - - const funnelCancelledEvent = funnelLog[2]; - expect(funnelCancelledEvent.props).toEqual({ - funnelIdentifier: FUNNEL_IDENTIFIER, - funnelInteractionId: FUNNEL_INTERACTION_ID, - }); - }) - ); - - test( - 'Field error', - setupTest(async page => { - await page.click('[data-testid=field1]'); - await page.setValue(wrapper.findInput('[data-testid=field1]').findNativeInput().toSelector(), 'error'); - const { funnelLog, actions } = await page.getFunnelLog(); - expect(actions).toEqual(['funnelStart', 'funnelStepStart', 'funnelSubStepStart', 'funnelSubStepError']); - - const funnelSubStepErrorEvent = funnelLog[3]; - expect(funnelSubStepErrorEvent.props).toEqual({ - fieldErrorSelector: expect.any(String), - fieldLabelSelector: expect.any(String), - subStepNameSelector: expect.any(String), - stepNameSelector: expect.any(String), - subStepSelector: expect.any(String), - subStepAllSelector: expect.any(String), - funnelInteractionId: FUNNEL_INTERACTION_ID, - funnelIdentifier: FUNNEL_IDENTIFIER, - stepIdentifier: 'step-1', - stepName: 'Step 1', - stepNumber: 1, - subStepName: 'Container 1 - header', - subStepIdentifier: 'step1-container1', - fieldErrorContext: null, - fieldIdentifier: null, - subStepErrorContext: null, - }); + describe.each(['static-multi-page-flow', 'app-layout-iframe-wizard'])('%s', pageName => { + class MultiPageCreate extends BasePageObject { + async visit(url: string) { + await this.browser.url(url); + await this.waitForVisible(wrapper.findAppLayout().findContentRegion().toSelector()); + } + + async getFunnelLog() { + const funnelLog = await this.browser.execute(() => window.__awsuiFunnelMetrics__); + const actions = funnelLog.map(item => item.action); + return { funnelLog, actions }; + } + + async getFunnelLogItem(index: number) { + const item = await this.browser.execute(index => window.__awsuiFunnelMetrics__[index], index); + if (!item) { + throw new Error(`No funnel log item at index ${index}`); + } + return item; + } + } - expect(funnelSubStepErrorEvent.resolvedProps).toEqual({ - fieldLabel: 'Field 1', - fieldError: 'Trigger error', - stepName: 'Step 1', - subStepName: 'Container 1 - header', + function setupTest(testFn: (page: MultiPageCreate) => Promise) { + return useBrowser(async browser => { + const page = new MultiPageCreate(browser); + await browser.url(`#/light/funnel-analytics/${pageName}?${getUrlParams(theme, {})}`); + await new Promise(r => setTimeout(r, 10)); + await testFn(page); }); - }) - ); - - test( - 'Emits a funnelError when an error is shown on the last step', - setupTest(async page => { - const nextButton = wrapper.findWizard().findPrimaryButton().toSelector(); - await page.click(nextButton); // Step 1 -> Step 2 - await page.click(nextButton); // Step 2 -> Step 3 (Last step) - - const { funnelLog, actions } = await page.getFunnelLog(); - const funnelErrorIndex = actions.findIndex(entry => entry === 'funnelError'); - const funnelErrorEvent = funnelLog[funnelErrorIndex]; - expect(funnelErrorEvent.props).toEqual({ - funnelIdentifier: FUNNEL_IDENTIFIER, - funnelInteractionId: FUNNEL_INTERACTION_ID, - funnelErrorContext: null, - }); - }) - ); - - test( - 'Correct substep number when the step is unmounted before blurring the substep', - setupTest(async page => { - await page.click(wrapper.findInput('[data-testid=field4]').findNativeInput().toSelector()); - await page.click(wrapper.findWizard().findPrimaryButton().toSelector()); - - const funnelSubStepCompleteEvent = await page.getFunnelLogItem(6); + } - expect(funnelSubStepCompleteEvent.action).toEqual('funnelSubStepComplete'); - expect(funnelSubStepCompleteEvent.props).toEqual( - expect.objectContaining({ + test( + 'Starts funnel and funnel step and page is loaded', + setupTest(async page => { + const { funnelLog, actions } = await page.getFunnelLog(); + expect(actions).toEqual(['funnelStart', 'funnelStepStart']); + + const [funnelStartEvent, funnelStartStepEvent] = funnelLog; + expect(funnelStartEvent.props).toEqual({ + componentVersion: expect.any(String), + funnelNameSelector: expect.any(String), + funnelVersion: expect.any(String), + funnelIdentifier: FUNNEL_IDENTIFIER, + funnelName: 'Create Resource', + flowType: 'create', + funnelType: 'multi-page', + resourceType: 'Components', + optionalStepNumbers: [2], + componentTheme: 'vr', + totalFunnelSteps: 3, + stepConfiguration: [ + { + name: 'Step 1', + number: 1, + isOptional: false, + stepIdentifier: 'step-1', + }, + { + name: 'Step 2', + number: 2, + isOptional: true, + stepIdentifier: 'step-2', + }, + { + name: 'Step 3', + number: 3, + isOptional: false, + stepIdentifier: 'step-3', + }, + ], + }); + + expect(funnelStartEvent.resolvedProps).toEqual({ + funnelName: 'Create Resource', + }); + + expect(funnelStartStepEvent.props).toEqual({ + stepNameSelector: expect.any(String), + subStepAllSelector: expect.any(String), + funnelInteractionId: FUNNEL_INTERACTION_ID, + funnelIdentifier: FUNNEL_IDENTIFIER, + stepIdentifier: 'step-1', + stepName: 'Step 1', + subStepConfiguration: [ + { + name: 'Container 1 - header', + number: 1, + subStepIdentifier: 'step1-container1', + }, + { + name: 'Container 2 - header', + number: 2, + subStepIdentifier: 'step1-container2', + }, + ], + stepNumber: 1, + totalSubSteps: 2, + }); + }) + ); + + test( + 'Starts and ends substep when navigating between containers', + setupTest(async page => { + await page.click('[data-testid=field1]'); + await page.keys('Tab'); // Input 1 -> Input 2 + await page.keys('Tab'); // Input 2 -> Form Field 3 External Link + + const { funnelLog, actions } = await page.getFunnelLog(); + const [, , funnelSubStep1StartEvent, funnelSubStep1CompleteEvent, funnelSubStep2StartEvent] = funnelLog; + + expect(actions).toEqual([ + 'funnelStart', + 'funnelStepStart', + 'funnelSubStepStart', + 'funnelSubStepComplete', + 'funnelSubStepStart', + ]); + + expect(funnelSubStep1StartEvent.props).toEqual({ + stepNameSelector: expect.any(String), + subStepAllSelector: expect.any(String), + subStepNameSelector: expect.any(String), + subStepSelector: expect.any(String), + funnelIdentifier: FUNNEL_IDENTIFIER, + funnelInteractionId: FUNNEL_INTERACTION_ID, + stepIdentifier: 'step-1', + stepName: 'Step 1', + subStepName: 'Container 1 - header', + subStepNumber: 1, + subStepIdentifier: 'step1-container1', + stepNumber: 1, + }); + expect(funnelSubStep1StartEvent.resolvedProps).toEqual({ + subStepElement: expect.any(Object), + subStepAllElements: expect.any(Object), + stepName: 'Step 1', + subStepName: 'Container 1 - header', + }); + expect(funnelSubStep1StartEvent.resolvedProps.subStepAllElements.length).toBe(2); + + expect(funnelSubStep1CompleteEvent.props).toEqual({ + stepNameSelector: expect.any(String), + subStepAllSelector: expect.any(String), + subStepNameSelector: expect.any(String), + subStepSelector: expect.any(String), + funnelIdentifier: FUNNEL_IDENTIFIER, + funnelInteractionId: FUNNEL_INTERACTION_ID, + stepIdentifier: 'step-1', + stepName: 'Step 1', + subStepName: 'Container 1 - header', + subStepNumber: 1, + subStepIdentifier: 'step1-container1', + stepNumber: 1, + }); + expect(funnelSubStep1CompleteEvent.resolvedProps).toEqual({ + subStepElement: expect.any(Object), + subStepAllElements: expect.any(Object), + stepName: 'Step 1', + subStepName: 'Container 1 - header', + }); + + expect(funnelSubStep2StartEvent.props).toEqual({ + stepNameSelector: expect.any(String), + subStepAllSelector: expect.any(String), + subStepNameSelector: expect.any(String), + subStepSelector: expect.any(String), + funnelIdentifier: FUNNEL_IDENTIFIER, + funnelInteractionId: FUNNEL_INTERACTION_ID, + stepIdentifier: 'step-1', + stepName: 'Step 1', + subStepName: 'Container 2 - header', subStepNumber: 2, - }) - ); - }) - ); + stepNumber: 1, + subStepIdentifier: 'step1-container2', + }); + expect(funnelSubStep2StartEvent.resolvedProps).toEqual({ + subStepElement: expect.any(Object), + subStepAllElements: expect.any(Object), + stepName: 'Step 1', + subStepName: 'Container 2 - header', + }); + expect(funnelSubStep2StartEvent.resolvedProps.subStepAllElements.length).toBe(2); + }) + ); + + test( + 'wizard step navigation', + setupTest(async page => { + await page.click(wrapper.findWizard().findPrimaryButton().toSelector()); + await page.click(wrapper.findWizard().findPreviousButton().toSelector()); + const { funnelLog, actions } = await page.getFunnelLog(); + const [, , funnelStepNavigationEvent, , , funnelStepPreviousNavigationEvent] = funnelLog; + + expect(actions).toEqual([ + 'funnelStart', + 'funnelStepStart', // Step 1 - Start + 'funnelStepNavigation', // Navigate to Step 2 + 'funnelStepComplete', // Step 1 - Complete + 'funnelStepStart', // Step 2 - Start + 'funnelStepNavigation', // Navigate back to Step 1 + 'funnelStepComplete', // Step 2 - Complete + 'funnelStepStart', // Step 1 - Start + ]); + + expect(funnelStepNavigationEvent.props).toEqual({ + stepNameSelector: expect.any(String), + subStepAllSelector: expect.any(String), + funnelInteractionId: FUNNEL_INTERACTION_ID, + stepName: 'Step 1', + navigationType: 'next', + destinationStepNumber: 2, + stepNumber: 1, + }); + + expect(funnelStepNavigationEvent.resolvedProps).toEqual({ + stepName: 'Step 1', + }); + + expect(funnelStepPreviousNavigationEvent.props).toEqual({ + stepNameSelector: expect.any(String), + subStepAllSelector: expect.any(String), + funnelInteractionId: FUNNEL_INTERACTION_ID, + stepName: 'Step 2', + navigationType: 'previous', + destinationStepNumber: 1, + stepNumber: 2, + }); + + expect(funnelStepPreviousNavigationEvent.resolvedProps).toEqual({ + stepName: 'Step 2', + }); + }) + ); + + test( + 'wizard submission', + setupTest(async page => { + const primaryButton = wrapper.findWizard().findPrimaryButton().toSelector(); + await page.click(primaryButton); + await page.click(primaryButton); + await page.click(primaryButton); // Create + + const { funnelLog, actions } = await page.getFunnelLog(); + + const funnelCompleteEventIndex = actions.findIndex(action => action === 'funnelComplete'); + const funnelCompleteEvent = funnelLog[funnelCompleteEventIndex]; + expect(funnelCompleteEvent.props).toEqual({ + funnelIdentifier: FUNNEL_IDENTIFIER, + funnelInteractionId: FUNNEL_INTERACTION_ID, + }); + + const funnelSucessEventIndex = actions.findIndex(action => action === 'funnelSuccessful'); + const funnelSuccessfulEvent = funnelLog[funnelSucessEventIndex]; + expect(funnelSuccessfulEvent.props).toEqual({ + funnelIdentifier: FUNNEL_IDENTIFIER, + funnelInteractionId: FUNNEL_INTERACTION_ID, + }); + }) + ); + + test( + 'wizard cancelled', + setupTest(async page => { + await page.click(wrapper.findWizard().findPrimaryButton().toSelector()); + await page.click(wrapper.findWizard().findCancelButton().toSelector()); + + const { funnelLog, actions } = await page.getFunnelLog(); + expect(actions).toEqual([ + 'funnelStart', + 'funnelStepStart', // Step 1 - Start + 'funnelStepNavigation', // Navigate to Step 2 + 'funnelStepComplete', // Step 1 - Complete + 'funnelStepStart', // Step 2 - Start + 'funnelCancelled', + ]); + const funnelCancelledEvent = funnelLog[5]; + expect(funnelCancelledEvent.props).toEqual({ + funnelIdentifier: FUNNEL_IDENTIFIER, + funnelInteractionId: FUNNEL_INTERACTION_ID, + }); + }) + ); + + test( + 'wizard abandoned', + setupTest(async page => { + await page.click('[data-testid=unmount]'); + const { funnelLog, actions } = await page.getFunnelLog(); + expect(actions).toEqual(['funnelStart', 'funnelStepStart', 'funnelCancelled']); + + const funnelCancelledEvent = funnelLog[2]; + expect(funnelCancelledEvent.props).toEqual({ + funnelIdentifier: FUNNEL_IDENTIFIER, + funnelInteractionId: FUNNEL_INTERACTION_ID, + }); + }) + ); + + test( + 'Field error', + setupTest(async page => { + await page.click('[data-testid=field1]'); + await page.setValue(wrapper.findInput('[data-testid=field1]').findNativeInput().toSelector(), 'error'); + const { funnelLog, actions } = await page.getFunnelLog(); + expect(actions).toEqual(['funnelStart', 'funnelStepStart', 'funnelSubStepStart', 'funnelSubStepError']); + + const funnelSubStepErrorEvent = funnelLog[3]; + expect(funnelSubStepErrorEvent.props).toEqual({ + fieldErrorSelector: expect.any(String), + fieldLabelSelector: expect.any(String), + subStepNameSelector: expect.any(String), + stepNameSelector: expect.any(String), + subStepSelector: expect.any(String), + subStepAllSelector: expect.any(String), + funnelInteractionId: FUNNEL_INTERACTION_ID, + funnelIdentifier: FUNNEL_IDENTIFIER, + stepIdentifier: 'step-1', + stepName: 'Step 1', + stepNumber: 1, + subStepName: 'Container 1 - header', + subStepIdentifier: 'step1-container1', + fieldErrorContext: null, + fieldIdentifier: null, + subStepErrorContext: null, + }); + + expect(funnelSubStepErrorEvent.resolvedProps).toEqual({ + fieldLabel: 'Field 1', + fieldError: 'Trigger error', + stepName: 'Step 1', + subStepName: 'Container 1 - header', + }); + }) + ); + + test( + 'Emits a funnelError when an error is shown on the last step', + setupTest(async page => { + const nextButton = wrapper.findWizard().findPrimaryButton().toSelector(); + await page.click(nextButton); // Step 1 -> Step 2 + await page.click(nextButton); // Step 2 -> Step 3 (Last step) + + const { funnelLog, actions } = await page.getFunnelLog(); + const funnelErrorIndex = actions.findIndex(entry => entry === 'funnelError'); + const funnelErrorEvent = funnelLog[funnelErrorIndex]; + expect(funnelErrorEvent.props).toEqual({ + funnelIdentifier: FUNNEL_IDENTIFIER, + funnelInteractionId: FUNNEL_INTERACTION_ID, + funnelErrorContext: null, + }); + }) + ); + + test( + 'Correct substep number when the step is unmounted before blurring the substep', + setupTest(async page => { + await page.click(wrapper.findInput('[data-testid=field4]').findNativeInput().toSelector()); + await page.click(wrapper.findWizard().findPrimaryButton().toSelector()); + + const funnelSubStepCompleteEvent = await page.getFunnelLogItem(6); + + expect(funnelSubStepCompleteEvent.action).toEqual('funnelSubStepComplete'); + expect(funnelSubStepCompleteEvent.props).toEqual( + expect.objectContaining({ + subStepNumber: 2, + }) + ); + }) + ); + }); }); diff --git a/src/internal/analytics/components/analytics-funnel.tsx b/src/internal/analytics/components/analytics-funnel.tsx index c68b575409..5670799d54 100644 --- a/src/internal/analytics/components/analytics-funnel.tsx +++ b/src/internal/analytics/components/analytics-funnel.tsx @@ -107,6 +107,7 @@ function evaluateSelectors(selectors: string[], defaultSelector: string) { } const InnerAnalyticsFunnel = ({ mounted = true, children, stepConfiguration, ...props }: AnalyticsFunnelProps) => { + const ref = useRef(null); const [funnelInteractionId, setFunnelInteractionId] = useState(''); const [submissionAttempt, setSubmissionAttempt] = useState(0); const isVisualRefresh = useVisualRefresh(); @@ -139,6 +140,7 @@ const InnerAnalyticsFunnel = ({ mounted = true, children, stepConfiguration, ... */ let funnelInteractionId: string; const handle = setTimeout(() => { + const currentDocument = ref.current!.ownerDocument; funnelNameSelector.current = evaluateSelectors(props.funnelNameSelectors || [], getFunnelNameSelector()); if (props.funnelType === 'single-page' && wizardCount.current > 0) { return; @@ -146,7 +148,7 @@ const InnerAnalyticsFunnel = ({ mounted = true, children, stepConfiguration, ... // Reset the state, in case the component was re-mounted. funnelState.current = 'default'; - const funnelName = getTextFromSelector(funnelNameSelector.current) ?? ''; + const funnelName = getTextFromSelector(currentDocument, funnelNameSelector.current) ?? ''; const singleStepFlowStepConfiguration = [ { @@ -169,7 +171,7 @@ const InnerAnalyticsFunnel = ({ mounted = true, children, stepConfiguration, ... componentTheme: isVisualRefresh ? 'vr' : 'classic', funnelVersion: FUNNEL_VERSION, stepConfiguration: stepConfiguration ?? singleStepFlowStepConfiguration, - resourceType: props.funnelResourceType || getTextFromSelector(getBreadcrumbLinkSelector(3)), + resourceType: props.funnelResourceType || getTextFromSelector(document, getBreadcrumbLinkSelector(3)), }); setFunnelInteractionId(funnelInteractionId); @@ -279,7 +281,11 @@ const InnerAnalyticsFunnel = ({ mounted = true, children, stepConfiguration, ... wizardCount, }; - return {children}; + return ( + + {children} + + ); }; interface AnalyticsFunnelStepProps { @@ -344,7 +350,7 @@ function useStepChangeListener(stepNumber: number, handler: (stepConfiguration: }; }, [stepNumber]); - /* We debounce this handler, so that multiple containers can change at once without causing + /* We debounce this handler, so that multiple containers can change at once without causing too many events. */ const stepChangeCallback = useDebounceCallback(() => { // We don't want to emit the event after the component has been unmounted. @@ -404,7 +410,7 @@ const InnerAnalyticsFunnelStep = ({ return; } - const stepName = getTextFromSelector(stepNameSelector); + const stepName = getTextFromSelector(document, stepNameSelector); const handler = setTimeout(() => { if (funnelState.current !== 'cancelled') { FunnelMetrics.funnelStepComplete({ @@ -455,7 +461,7 @@ const InnerAnalyticsFunnelStep = ({ return; } - const stepName = getTextFromSelector(stepNameSelector); + const stepName = getTextFromSelector(document, stepNameSelector); if (funnelState.current === 'default') { FunnelMetrics.funnelStepStart({ @@ -597,7 +603,7 @@ export const AnalyticsFunnelSubStep = ({ Some mouse events result in an element being focused. However, this happens only _after_ the onMouseUp event. We yield the event loop here, so that `document.activeElement` has the - correct new value. + correct new value. */ await new Promise(r => setTimeout(r, 1)); diff --git a/src/internal/analytics/selectors.ts b/src/internal/analytics/selectors.ts index faf70c4997..1b420ada62 100644 --- a/src/internal/analytics/selectors.ts +++ b/src/internal/analytics/selectors.ts @@ -34,5 +34,5 @@ export const getSubStepNameSelector = (subStepId?: string) => export const getFieldSlotSeletor = (id: string | undefined) => (id ? `[id="${id}"]` : undefined); -export const getTextFromSelector = (selector: string | undefined): string | undefined => +export const getTextFromSelector = (document: Document, selector: string | undefined): string | undefined => selector ? document.querySelector(selector)?.innerText?.trim() : undefined;