From 7a7ec1385a45419661a69b8f40f075e72ed8dca3 Mon Sep 17 00:00:00 2001 From: Joakim Bjerknes Date: Thu, 12 Sep 2024 13:44:08 +0200 Subject: [PATCH 01/10] add tests --- .../__tests__/WizardContainer.test.tsx | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/__tests__/WizardContainer.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/__tests__/WizardContainer.test.tsx index b0bddccef66..3584817e622 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/__tests__/WizardContainer.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/__tests__/WizardContainer.test.tsx @@ -1533,6 +1533,42 @@ describe('Wizard.Container', () => { expect(screen.queryByRole('alert')).toBeInTheDocument() }) + it('should prevent navigation if `preventNavigation` is called', async () => { + render( + + { + console.log('e', e) + }} + > + +

Step 1

+ +
+ +

Step 2

+ +
+
+
+ ) + + const [firstStep, secondStep] = document.querySelectorAll( + '.dnb-step-indicator__item' + ) + + const nextButton = document.querySelector('.dnb-forms-submit-button') + + expect(firstStep).toHaveClass('.dnb-step-indicator__item--current') + + await userEvent.click(nextButton) + + expect(firstStep).toHaveClass('.dnb-step-indicator__item--current') + expect(secondStep).not.toHaveClass( + '.dnb-step-indicator__item--current' + ) + }) + describe('prerenderFieldProps and filterData', () => { it('should keep field props in memory during step change', async () => { const filterDataHandler = jest.fn(({ props }) => { From 98693fbe8dc9ff40a674fb493fdd9ad91071a603 Mon Sep 17 00:00:00 2001 From: Joakim Bjerknes Date: Thu, 12 Sep 2024 13:44:15 +0200 Subject: [PATCH 02/10] add docs --- .../extensions/forms/Wizard/Container/WizardContainerDocs.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/WizardContainerDocs.ts b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/WizardContainerDocs.ts index 13fe0f72049..d20cd768a16 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/WizardContainerDocs.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/WizardContainerDocs.ts @@ -50,7 +50,7 @@ export const WizardContainerProperties: PropertiesTableProps = { export const WizardContainerEvents: PropertiesTableProps = { onStepChange: { - doc: 'Will be called when the user navigate to a different step, with step `index` as the first argument and `previous` or `next` (string) as the second argument. When an async function is provided, it will show an indicator on the submit button during the form submission. All form elements will be disabled during the submit. The indicator will be shown for minimum 1 second. Related Form.Handler props: `minimumAsyncBehaviorTime` and `asyncSubmitTimeout`.', + doc: 'Will be called when the user navigate to a different step, with step `index` as the first argument and `previous` or `next` (string) as the second argument, and a `preventNavigation` function as a third parameter. When an async function is provided, it will show an indicator on the submit button during the form submission. All form elements will be disabled during the submit. The indicator will be shown for minimum 1 second. Related Form.Handler props: `minimumAsyncBehaviorTime` and `asyncSubmitTimeout`.', type: 'function', status: 'optional', }, From f0121bc319443f584b7fbc4faaf59cedcd83021d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20H=C3=B8egh?= Date: Thu, 12 Sep 2024 13:44:58 +0200 Subject: [PATCH 03/10] Add possible solution --- .../Wizard/Container/WizardContainer.tsx | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/WizardContainer.tsx b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/WizardContainer.tsx index 73d16eaaf85..9eb7d8e68ee 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/WizardContainer.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/WizardContainer.tsx @@ -126,6 +126,7 @@ function WizardContainer(props: Props) { const totalStepsRef = useRef(NaN) const errorOnStepRef = useRef>({}) const stepElementRef = useRef() + const preventNextStepRef = useRef(false) // - Handle shared state const sharedStateRef = @@ -141,15 +142,19 @@ function WizardContainer(props: Props) { // Store the current state of showAllErrors errorOnStepRef.current[activeIndexRef.current] = showAllErrors + const preventNavigation = useCallback((state = true) => { + preventNextStepRef.current = state + }, []) + const callOnStepChange = useCallback( async (index: StepIndex, mode: 'previous' | 'next') => { if (isAsync(onStepChange)) { - return await onStepChange(index, mode) + return await onStepChange(index, mode, { preventNavigation }) } - return onStepChange?.(index, mode) + return onStepChange?.(index, mode, { preventNavigation }) }, - [onStepChange] + [onStepChange, preventNavigation] ) const { setFocus, scrollToTop, isInteractionRef } = @@ -182,7 +187,9 @@ function WizardContainer(props: Props) { enableAsyncBehavior: isAsync(onStepChange), onSubmit: async () => { if (!skipStepChangeCallFromHook) { - sharedStateRef.current?.data?.onStepChange?.(index, mode) + sharedStateRef.current?.data?.onStepChange?.(index, mode, { + preventNavigation, + }) } const result = @@ -199,13 +206,15 @@ function WizardContainer(props: Props) { setShowAllErrors(errorOnStepRef.current[index]) } - if (!(result instanceof Error)) { + if (!preventNextStepRef.current && !(result instanceof Error)) { handleLayoutEffect() activeIndexRef.current = index forceUpdate() } + preventNextStepRef.current = false + return result }, }) @@ -216,6 +225,7 @@ function WizardContainer(props: Props) { handleSubmitCall, isInteractionRef, onStepChange, + preventNavigation, setFormState, setShowAllErrors, ] From e14da1960aa03c2a09ca7ba5b2abfb777d4c0c2f Mon Sep 17 00:00:00 2001 From: Joakim Bjerknes Date: Thu, 12 Sep 2024 14:19:13 +0200 Subject: [PATCH 04/10] update tests --- .../__tests__/WizardContainer.test.tsx | 48 +++++++++++++------ 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/__tests__/WizardContainer.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/__tests__/WizardContainer.test.tsx index 3584817e622..b1fb0769a01 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/__tests__/WizardContainer.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/__tests__/WizardContainer.test.tsx @@ -1537,36 +1537,54 @@ describe('Wizard.Container', () => { render( { - console.log('e', e) + onStepChange={(step, mode, { preventNavigation }) => { + // Stop navigation of user presses the next button on step 2 + if (step === 2 && mode === 'next') { + preventNavigation() + } }} > - -

Step 1

+ +

Step A

- -

Step 2

+ +

Step B

+ +
+ +

Step C

) - const [firstStep, secondStep] = document.querySelectorAll( - '.dnb-step-indicator__item' + const [stepA, stepB, stepC] = Array.from( + document.querySelectorAll('.dnb-step-indicator__item') ) - const nextButton = document.querySelector('.dnb-forms-submit-button') + expect(stepA).toHaveClass('dnb-step-indicator__item--current') + expect(stepB).not.toHaveClass('dnb-step-indicator__item--current') + expect(stepC).not.toHaveClass('dnb-step-indicator__item--current') - expect(firstStep).toHaveClass('.dnb-step-indicator__item--current') + await userEvent.click(screen.getByText('Neste')) - await userEvent.click(nextButton) + expect(stepA).not.toHaveClass('dnb-step-indicator__item--current') + expect(stepB).toHaveClass('dnb-step-indicator__item--current') + expect(stepC).not.toHaveClass('dnb-step-indicator__item--current') - expect(firstStep).toHaveClass('.dnb-step-indicator__item--current') - expect(secondStep).not.toHaveClass( - '.dnb-step-indicator__item--current' - ) + await userEvent.click(screen.getByText('Neste')) + + expect(stepA).not.toHaveClass('dnb-step-indicator__item--current') + expect(stepB).toHaveClass('dnb-step-indicator__item--current') + expect(stepC).not.toHaveClass('dnb-step-indicator__item--current') + + await userEvent.click(screen.getByText('Tilbake')) + + expect(stepA).toHaveClass('dnb-step-indicator__item--current') + expect(stepB).not.toHaveClass('dnb-step-indicator__item--current') + expect(stepC).not.toHaveClass('dnb-step-indicator__item--current') }) describe('prerenderFieldProps and filterData', () => { From 80b3d27f7c8c911f8b9fc75e7ec63f6da26f6ee4 Mon Sep 17 00:00:00 2001 From: Joakim Bjerknes Date: Thu, 12 Sep 2024 14:22:26 +0200 Subject: [PATCH 05/10] update docs --- .../extensions/forms/Wizard/Container/WizardContainerDocs.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/WizardContainerDocs.ts b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/WizardContainerDocs.ts index d20cd768a16..8626c03e9df 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/WizardContainerDocs.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/WizardContainerDocs.ts @@ -50,7 +50,7 @@ export const WizardContainerProperties: PropertiesTableProps = { export const WizardContainerEvents: PropertiesTableProps = { onStepChange: { - doc: 'Will be called when the user navigate to a different step, with step `index` as the first argument and `previous` or `next` (string) as the second argument, and a `preventNavigation` function as a third parameter. When an async function is provided, it will show an indicator on the submit button during the form submission. All form elements will be disabled during the submit. The indicator will be shown for minimum 1 second. Related Form.Handler props: `minimumAsyncBehaviorTime` and `asyncSubmitTimeout`.', + doc: 'Will be called when the user navigate to a different step, with step `index` as the first argument and `previous` or `next` (string) as the second argument, and an options object containing `preventNavigation` function as the third parameter. When an async function is provided, it will show an indicator on the submit button during the form submission. All form elements will be disabled during the submit. The indicator will be shown for minimum 1 second. Related Form.Handler props: `minimumAsyncBehaviorTime` and `asyncSubmitTimeout`.', type: 'function', status: 'optional', }, From ea9d2dd379462b1e445de9cc58c91d8f020d58b8 Mon Sep 17 00:00:00 2001 From: Joakim Bjerknes Date: Thu, 12 Sep 2024 14:37:00 +0200 Subject: [PATCH 06/10] update types --- .../src/extensions/forms/Wizard/Container/WizardContainer.tsx | 4 ++-- .../src/extensions/forms/Wizard/Context/WizardContext.ts | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/WizardContainer.tsx b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/WizardContainer.tsx index 9eb7d8e68ee..adae69459f3 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/WizardContainer.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/WizardContainer.tsx @@ -142,8 +142,8 @@ function WizardContainer(props: Props) { // Store the current state of showAllErrors errorOnStepRef.current[activeIndexRef.current] = showAllErrors - const preventNavigation = useCallback((state = true) => { - preventNextStepRef.current = state + const preventNavigation = useCallback((shouldPrevent = true) => { + preventNextStepRef.current = shouldPrevent }, []) const callOnStepChange = useCallback( diff --git a/packages/dnb-eufemia/src/extensions/forms/Wizard/Context/WizardContext.ts b/packages/dnb-eufemia/src/extensions/forms/Wizard/Context/WizardContext.ts index a5d6f597e83..d32d0c5422c 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Wizard/Context/WizardContext.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Wizard/Context/WizardContext.ts @@ -4,7 +4,8 @@ import { VisibleWhen } from '../../Form/Visibility' export type OnStepChange = ( index: StepIndex, - mode: 'previous' | 'next' + mode: 'previous' | 'next', + options: { preventNavigation: (shouldPrevent?: boolean) => void } ) => | EventReturnWithStateObject | void From 4abd19cb13988e5238b6e906f8168ade89153dc5 Mon Sep 17 00:00:00 2001 From: Joakim Bjerknes Date: Thu, 12 Sep 2024 14:52:39 +0200 Subject: [PATCH 07/10] add example --- .../forms/Wizard/Container/info.mdx | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/Container/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/Container/info.mdx index f2cfcbeca66..9bc72a2b616 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/Container/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/Container/info.mdx @@ -140,6 +140,41 @@ const MyForm = () => { } ``` +You can also prevent the user from from navigating to the next or previous step, by using the `preventNavigation` callback function found as the third parameter, in the `onStepChange` event. + +```tsx +import { Form, Wizard } from '@dnb/eufemia/extensions/forms' + +const MyForm = () => { + const { setActiveIndex, activeIndex } = Wizard.useStep('unique-id') + + return ( + + { + if (step === 2 && type === 'next') { + preventNavigation() + } + }} + > + +

Step 1

+ +
+ +

Step 2

+ +
+ +

Step 3

+ +
+
+
+ ) +} +``` + ## Accessibility The `Wizard.Step` component uses an `aria-label` attribute that matches the title prop value. The step content is enclosed within a section element, which further enhances accessibility. From 3bb51ea5077d2458bfa01c612982e7795d5b9ed8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20H=C3=B8egh?= Date: Thu, 12 Sep 2024 15:15:44 +0200 Subject: [PATCH 08/10] Update packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/Container/info.mdx Co-authored-by: Anders --- .../src/docs/uilib/extensions/forms/Wizard/Container/info.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/Container/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/Container/info.mdx index 9bc72a2b616..1118f9c72a8 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/Container/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/Container/info.mdx @@ -140,7 +140,7 @@ const MyForm = () => { } ``` -You can also prevent the user from from navigating to the next or previous step, by using the `preventNavigation` callback function found as the third parameter, in the `onStepChange` event. +You can also prevent the user from navigating to the next or previous step, by using the `preventNavigation` callback function found as the third parameter, in the `onStepChange` event. ```tsx import { Form, Wizard } from '@dnb/eufemia/extensions/forms' From e511b7604b3a74200bf6eca653b56f00dac5439f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20H=C3=B8egh?= Date: Thu, 12 Sep 2024 15:19:13 +0200 Subject: [PATCH 09/10] Fix failing test --- .../__tests__/WizardContainer.test.tsx | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/__tests__/WizardContainer.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/__tests__/WizardContainer.test.tsx index b1fb0769a01..cbb163e0d99 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/__tests__/WizardContainer.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/__tests__/WizardContainer.test.tsx @@ -89,19 +89,31 @@ describe('Wizard.Container', () => { await userEvent.click(secondStep.querySelector('.dnb-button')) expect(output()).toHaveTextContent('Step 2') expect(onStepChange).toHaveBeenCalledTimes(1) - expect(onStepChange).toHaveBeenLastCalledWith(1, 'next') + expect(onStepChange).toHaveBeenLastCalledWith( + 1, + 'next', + expect.anything() + ) await userEvent.click(firstStep.querySelector('.dnb-button')) await wait(1000) expect(output()).toHaveTextContent('Step 1') expect(onStepChange).toHaveBeenCalledTimes(2) - expect(onStepChange).toHaveBeenLastCalledWith(0, 'previous') + expect(onStepChange).toHaveBeenLastCalledWith( + 0, + 'previous', + expect.anything() + ) await userEvent.click(nextButton()) expect(nextButton()).not.toBeDisabled() expect(onStepChange).toHaveBeenCalledTimes(3) - expect(onStepChange).toHaveBeenLastCalledWith(1, 'next') + expect(onStepChange).toHaveBeenLastCalledWith( + 1, + 'next', + expect.anything() + ) // Use fireEvent to trigger the event fast fireEvent.click(previousButton()) @@ -110,7 +122,11 @@ describe('Wizard.Container', () => { await waitFor(() => { expect(previousButton()).toBeNull() expect(onStepChange).toHaveBeenCalledTimes(4) - expect(onStepChange).toHaveBeenLastCalledWith(0, 'previous') + expect(onStepChange).toHaveBeenLastCalledWith( + 0, + 'previous', + expect.anything() + ) expect(previousButton()).not.toBeInTheDocument() }) }) @@ -1538,7 +1554,7 @@ describe('Wizard.Container', () => { { - // Stop navigation of user presses the next button on step 2 + // Stop navigation of user presses the next button on step 2 (B) if (step === 2 && mode === 'next') { preventNavigation() } @@ -1576,6 +1592,8 @@ describe('Wizard.Container', () => { await userEvent.click(screen.getByText('Neste')) + // Stay on Step B + expect(stepA).not.toHaveClass('dnb-step-indicator__item--current') expect(stepB).toHaveClass('dnb-step-indicator__item--current') expect(stepC).not.toHaveClass('dnb-step-indicator__item--current') From 42ee5016d5bbb67cab942a9178446c80d2fea8a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20H=C3=B8egh?= Date: Thu, 12 Sep 2024 15:30:25 +0200 Subject: [PATCH 10/10] Add another test to verify validation is run before preventNavigation --- .../__tests__/WizardContainer.test.tsx | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/__tests__/WizardContainer.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/__tests__/WizardContainer.test.tsx index cbb163e0d99..7139c7535ea 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/__tests__/WizardContainer.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/__tests__/WizardContainer.test.tsx @@ -1605,6 +1605,54 @@ describe('Wizard.Container', () => { expect(stepC).not.toHaveClass('dnb-step-indicator__item--current') }) + it('should run validation before `preventNavigation` result is evaluated', async () => { + const onStepChange = jest.fn((step, mode, { preventNavigation }) => { + if (step === 1 && mode === 'next') { + preventNavigation() + } + }) + + render( + + + + + Step 1 + + + + Step 2 + + + + + ) + + expect(output()).toHaveTextContent('Step 1') + + await userEvent.click(nextButton()) + + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + }) + + expect(output()).toHaveTextContent('Step 1') + + await userEvent.type(document.querySelector('input'), 'valid') + + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeNull() + }) + + await userEvent.click(nextButton()) + + expect(output()).toHaveTextContent('Step 1') + expect(onStepChange).toHaveBeenCalledTimes(1) + expect(onStepChange).toHaveBeenLastCalledWith(1, 'next', { + preventNavigation: expect.any(Function), + }) + }) + describe('prerenderFieldProps and filterData', () => { it('should keep field props in memory during step change', async () => { const filterDataHandler = jest.fn(({ props }) => {