diff --git a/packages/ui/src/components/FormRenderer/components/Wizard/index.stories.tsx b/packages/ui/src/components/FormRenderer/components/Wizard/index.stories.tsx index a9811205..796a865b 100644 --- a/packages/ui/src/components/FormRenderer/components/Wizard/index.stories.tsx +++ b/packages/ui/src/components/FormRenderer/components/Wizard/index.stories.tsx @@ -15,6 +15,8 @@ ******************************************************************************************************************** */ import { ComponentMeta } from '@storybook/react'; import Box from '@cloudscape-design/components/box'; +import { WizardProps as WizardComponentProps } from '@cloudscape-design/components/wizard'; +import { NonCancelableCustomEvent } from '@cloudscape-design/components/internal/events'; import FormRenderer, { componentTypes, validatorTypes } from '../..'; import { Template, DEFAULT_ARGS } from '../../index.stories'; @@ -158,3 +160,32 @@ Disabled.args = { })), }, }; + +export const NavigatingEvent = Template.bind({}); +NavigatingEvent.args = { + ...Default.args, + schema: { + ...Default.args.schema, + fields: [ + { + ...Default.args.schema!.fields[0], + onNavigating: (event: NonCancelableCustomEvent) => { + return new Promise((resolve) => { + window.setTimeout(() => { + if (event.detail.requestedStepIndex >= 2) { + resolve({ + continued: false, + errorText: 'Unable to proceed', + }); + } else { + resolve({ + continued: true, + }); + } + }, 2000); + }); + }, + }, + ], + }, +}; diff --git a/packages/ui/src/components/FormRenderer/components/Wizard/index.test.tsx b/packages/ui/src/components/FormRenderer/components/Wizard/index.test.tsx index 5ec0e76b..891bff8b 100644 --- a/packages/ui/src/components/FormRenderer/components/Wizard/index.test.tsx +++ b/packages/ui/src/components/FormRenderer/components/Wizard/index.test.tsx @@ -19,7 +19,7 @@ import wrapper from '@cloudscape-design/components/test-utils/dom'; import { composeStories } from '@storybook/testing-react'; import * as stories from './index.stories'; -const { Default, WithInitialValue, Submittting } = composeStories(stories); +const { Default, WithInitialValue, Submittting, NavigatingEvent } = composeStories(stories); const handleCancel = jest.fn(); const handleSubmit = jest.fn(); @@ -145,4 +145,38 @@ describe('Wizard', () => { expect(screen.getByText('Submit').parentElement).toHaveAttribute('aria-disabled', 'true'); expect(screen.getByText('Previous').parentElement).toBeDisabled(); }); + + it('should render wizard with onNavigating event handler', async () => { + render(); + + const element = await screen.findByTestId('form-renderer'); + const wizard = wrapper(element).findWizard(); + + expect(wizard?.findHeader()?.getElement()).toHaveTextContent('Step 1'); + + await act(async () => { + await userEvent.click(wizard?.findPrimaryButton().getElement()!); + }); + expect(screen.getByText('Required')).toBeVisible(); + + await act(async () => { + await userEvent.type(screen.getByLabelText('Config 1'), 'Config 1 Content'); + await userEvent.click(wizard?.findPrimaryButton().getElement()!); + }); + + expect(wizard?.findPrimaryButton().findLoadingIndicator()).not.toBeNull(); + + await waitFor(() => expect(wizard?.findHeader()?.getElement()).toHaveTextContent('Step 2'), { + timeout: 3000, + }); + + await act(async () => { + await userEvent.type(screen.getByLabelText('Config 2'), 'Config 2 Content'); + await userEvent.click(wizard?.findPrimaryButton().getElement()!); + }); + + await waitFor(() => expect(screen.getByText('Unable to proceed')).toBeVisible(), { + timeout: 3000, + }); + }); }); diff --git a/packages/ui/src/components/FormRenderer/components/Wizard/index.tsx b/packages/ui/src/components/FormRenderer/components/Wizard/index.tsx index 19cef6f9..3a8351fe 100644 --- a/packages/ui/src/components/FormRenderer/components/Wizard/index.tsx +++ b/packages/ui/src/components/FormRenderer/components/Wizard/index.tsx @@ -16,10 +16,16 @@ import { FC, memo, useCallback, useEffect, useMemo, useState } from 'react'; import useFormApi from '@data-driven-forms/react-form-renderer/use-form-api'; import WizardComponent, { WizardProps as WizardComponentProps } from '@cloudscape-design/components/wizard'; +import { NonCancelableCustomEvent, NonCancelableEventHandler } from '@cloudscape-design/components/internal/events'; import { Field } from '../../types'; import WizardSteps from './components/WizardSteps'; import { useFormRendererContext } from '../../formRendererContext'; +export interface NavigatingEventResponse { + continued: boolean; + errorText?: string; +} + export interface WizardProps { fields: Field[]; allowSkipTo: WizardComponentProps['allowSkipTo']; @@ -27,6 +33,10 @@ export interface WizardProps { activeStepIndex: WizardComponentProps['activeStepIndex']; isReadOnly?: boolean; isDisabled?: boolean; + onNavigating?: ( + event: NonCancelableCustomEvent + ) => Promise; + isLoadingNextStep?: WizardComponentProps['isLoadingNextStep']; } const DEFAULT_RESOURCE_STRINGS: WizardComponentProps.I18nStrings = { @@ -41,11 +51,25 @@ const DEFAULT_RESOURCE_STRINGS: WizardComponentProps.I18nStrings = { optional: 'optional', }; -const Wizard: FC = ({ i18nStrings, allowSkipTo, fields, isReadOnly, isDisabled, ...props }) => { +const Wizard: FC = ({ + i18nStrings, + allowSkipTo, + fields, + isReadOnly, + isDisabled, + onNavigating, + ...props +}) => { const [showError, setShowError] = useState(false); const formOptions = useFormApi(); const { isSubmitting } = useFormRendererContext(); const [activeStepIndex, setActiveStepIndex] = useState(props.activeStepIndex || 0); + const [isLoadingNextStep, setIsLoadingNextStep] = useState(props.isLoadingNextStep || isSubmitting); + const [errorText, setErrorText] = useState(); + + useEffect(() => { + setIsLoadingNextStep(props.isLoadingNextStep || isSubmitting); + }, [props.isLoadingNextStep, isSubmitting]); const resourceStrings = useMemo(() => { return { @@ -56,13 +80,13 @@ const Wizard: FC = ({ i18nStrings, allowSkipTo, fields, isReadOnly, const steps = useMemo(() => { return fields.map((field) => { - const { title, info, description, isOptional, errorText, fields: fieldFields, ...rest } = field; + const { title, info, description, isOptional, fields: fieldFields, ...rest } = field; return { title: title, info: info, description: field.description, isOptional: field.isOptional, - errorText: field.errorText, + errorText: field.errorText || errorText, content: ( = ({ i18nStrings, allowSkipTo, fields, isReadOnly, ), }; }); - }, [fields, showError, isReadOnly, isDisabled]); + }, [fields, showError, isReadOnly, isDisabled, errorText]); useEffect(() => { setShowError(false); // When steps change }, [activeStepIndex]); - const handleNavigation = useCallback( - ({ detail }: { detail: WizardComponentProps.NavigateDetail }) => { - const requestedStepIndex = detail.requestedStepIndex; - if (activeStepIndex < detail.requestedStepIndex) { + const handleNavigating = useCallback( + async (event: NonCancelableCustomEvent) => { + if (onNavigating) { + setIsLoadingNextStep(true); + const response = await onNavigating(event); + setIsLoadingNextStep(false); + return response; + } + + return { + continued: true, + errorText: undefined, + }; + }, + [onNavigating] + ); + + const processNavigate = useCallback( + async (requestedStepIndex: number, event: NonCancelableCustomEvent) => { + const response = await handleNavigating(event); + if (response.continued) { + setActiveStepIndex(requestedStepIndex); + } else { + setErrorText(response.errorText); + } + }, + [handleNavigating] + ); + + const handleNavigation: NonCancelableEventHandler = useCallback( + async (event) => { + setErrorText(undefined); + const requestedStepIndex = event.detail.requestedStepIndex; + + if (activeStepIndex < event.detail.requestedStepIndex) { const state = formOptions.getState(); setShowError(true); if (!(state.invalid || state.validating || state.submitting)) { - setActiveStepIndex(requestedStepIndex); + processNavigate(requestedStepIndex, event); } } else { - setActiveStepIndex(requestedStepIndex); + processNavigate(requestedStepIndex, event); } }, - [activeStepIndex, formOptions] + [activeStepIndex, formOptions, processNavigate] ); return ( @@ -103,7 +158,7 @@ const Wizard: FC = ({ i18nStrings, allowSkipTo, fields, isReadOnly, onNavigate={handleNavigation} activeStepIndex={activeStepIndex} allowSkipTo={allowSkipTo} - isLoadingNextStep={isSubmitting} + isLoadingNextStep={isLoadingNextStep} steps={steps} onSubmit={formOptions.handleSubmit} onCancel={formOptions.onCancel}