From 902484eb7ccb2958c426b0e9004cbeb574342ff1 Mon Sep 17 00:00:00 2001 From: Marius Holter Berntzen Date: Wed, 16 Oct 2024 15:55:08 +0200 Subject: [PATCH 1/3] feat(wizard): Add handlers for previousStep and goToStep This allows users to attach callbacks to be called when navigating backwards, and jumping in the step hiearchy. This takes the handler for next step as inspiration for the other triggers, encapsulating them in the same async call methods. These differ from usig the onStepChange method, and allows for attaching different behaviour inside step components instead of higher up in a component hiearachy. --- src/types.ts | 14 ++++++++-- src/wizard.tsx | 72 +++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 74 insertions(+), 12 deletions(-) diff --git a/src/types.ts b/src/types.ts index a54d06b..7e514bc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,17 +29,27 @@ export type WizardValues = { /** * Go to the previous step */ - previousStep: () => void; + previousStep: () => Promise; /** * Go to the given step index * @param stepIndex The step index, starts at 0 */ - goToStep: (stepIndex: number) => void; + goToStep: (stepIndex: number) => Promise; /** * Attach a callback that will be called when calling `nextStep()` * @param handler Can be either sync or async */ handleStep: (handler: Handler) => void; + /** + * Attach a callback that will be called when calling `previousStep()` + * @param handler Can be either sync or async + */ + handlePreviousStep: (handler: Handler) => void; + /** + * Attach a callback that will be called when calling `goToStep()` + * @param handler Can be either sync or async + */ + handleGoToStep: (handler: Handler) => void; /** * Indicate the current state of the handler * diff --git a/src/wizard.tsx b/src/wizard.tsx index 43260ed..20b0e9d 100644 --- a/src/wizard.tsx +++ b/src/wizard.tsx @@ -18,6 +18,8 @@ const Wizard: React.FC> = React.memo( const hasNextStep = React.useRef(true); const hasPreviousStep = React.useRef(false); const nextStepHandler = React.useRef(() => {}); + const previousStepHandler = React.useRef(() => {}); + const goToStepHandler = React.useRef(() => {}); const stepCount = React.Children.toArray(children).length; hasNextStep.current = activeStep < stepCount - 1; @@ -25,6 +27,8 @@ const Wizard: React.FC> = React.memo( const goToNextStep = React.useCallback(() => { if (hasNextStep.current) { + previousStepHandler.current = null; + goToStepHandler.current = null; const newActiveStepIndex = activeStep + 1; setActiveStep(newActiveStepIndex); @@ -35,6 +39,7 @@ const Wizard: React.FC> = React.memo( const goToPreviousStep = React.useCallback(() => { if (hasPreviousStep.current) { nextStepHandler.current = null; + goToStepHandler.current = null; const newActiveStepIndex = activeStep - 1; setActiveStep(newActiveStepIndex); @@ -46,6 +51,7 @@ const Wizard: React.FC> = React.memo( (stepIndex: number) => { if (stepIndex >= 0 && stepIndex < stepCount) { nextStepHandler.current = null; + previousStepHandler.current = null; setActiveStep(stepIndex); onStepChange?.(stepIndex); } else { @@ -68,42 +74,88 @@ const Wizard: React.FC> = React.memo( nextStepHandler.current = handler; }); - const doNextStep = React.useCallback(async () => { - if (hasNextStep.current && nextStepHandler.current) { + // Callback to attach the previous step handler + const handlePreviousStep = React.useRef((handler: Handler) => { + previousStepHandler.current = handler; + }); + + // Callback to attach the go-to-step handler + const handleGoToStep = React.useRef((handler: Handler) => { + goToStepHandler.current = handler; + }); + + /** + * Attempts to execute the provided step handler if the condition is met. + * If the handler is executed successfully, it triggers the step change handler + * and then executes the provided step function. + * @param {React.MutableRefObject} handler - The step handler to be executed. + * @param {boolean} andCondition - Condition to check before executing the handler. + * @param {() => void} stepFunction - Function to execute after the handler. + * @throws Will throw an error if the handler execution fails. + */ + async function tryStepHandler( + handler: React.MutableRefObject, + andCondition: boolean, + stepFunction: () => void, + ) { + if (andCondition && handler.current) { try { setIsLoading(true); - await nextStepHandler.current(); + await handler.current(); setIsLoading(false); - nextStepHandler.current = null; - goToNextStep(); + stepFunction(); } catch (error) { setIsLoading(false); throw error; } } else { - goToNextStep(); + stepFunction(); } + } + + const doNextStep = React.useCallback(async () => { + await tryStepHandler(nextStepHandler, hasNextStep.current, goToNextStep); }, [goToNextStep]); + const doPreviousStep = React.useCallback(async () => { + await tryStepHandler( + previousStepHandler, + hasPreviousStep.current, + goToPreviousStep, + ); + }, [goToPreviousStep]); + + const doGoToStep = React.useCallback( + async (stepIndex: number) => { + const validStepIndex = stepIndex >= 0 && stepIndex < stepCount; + tryStepHandler(goToStepHandler, validStepIndex, () => + goToStep(stepIndex), + ); + }, + [stepCount, goToStep], + ); + const wizardValue = React.useMemo( () => ({ nextStep: doNextStep, - previousStep: goToPreviousStep, + previousStep: doPreviousStep, handleStep: handleStep.current, + handlePreviousStep: handlePreviousStep.current, + handleGoToStep: handleGoToStep.current, isLoading, activeStep, stepCount, isFirstStep: !hasPreviousStep.current, isLastStep: !hasNextStep.current, - goToStep, + goToStep: doGoToStep, }), [ doNextStep, - goToPreviousStep, + doPreviousStep, isLoading, activeStep, stepCount, - goToStep, + doGoToStep, ], ); From 62393b9d18520211e27c197c71bdc9edded5e028 Mon Sep 17 00:00:00 2001 From: Marius Holter Berntzen Date: Wed, 16 Oct 2024 15:56:33 +0200 Subject: [PATCH 2/3] fix(useWizard.test): Accommodate tests for new async calls to goToStep and previousStep methods --- test/useWizard.test.tsx | 55 +++++++++++++++++++++++++++-------------- 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/test/useWizard.test.tsx b/test/useWizard.test.tsx index 7728e3d..9e94bad 100644 --- a/test/useWizard.test.tsx +++ b/test/useWizard.test.tsx @@ -135,24 +135,28 @@ describe('useWizard', () => { }); test('should go to given step index', async () => { - const { result } = renderUseWizardHook(); + const { result, waitForNextUpdate } = renderUseWizardHook(); act(() => { result.current.goToStep(1); }); + await waitForNextUpdate(); + expect(result.current.activeStep).toBe(1); expect(result.current.isFirstStep).toBe(false); expect(result.current.isLastStep).toBe(true); }); test('should go to given step index', async () => { - const { result } = renderUseWizardHook(1); + const { result, waitForNextUpdate } = renderUseWizardHook(1); act(() => { result.current.goToStep(0); }); + await waitForNextUpdate(); + expect(result.current.activeStep).toBe(0); expect(result.current.isFirstStep).toBe(true); expect(result.current.isLastStep).toBe(false); @@ -160,13 +164,15 @@ describe('useWizard', () => { test('should go to given step index and not invoke `handleStep` handler', async () => { const handler = jest.fn(); - const { result } = renderUseWizardHook(); + const { result, waitForNextUpdate } = renderUseWizardHook(); act(() => { result.current.handleStep(handler); result.current.goToStep(1); }); + await waitForNextUpdate(); + expect(handler).not.toBeCalled(); expect(result.current.activeStep).toBe(1); expect(result.current.isFirstStep).toBe(false); @@ -174,12 +180,13 @@ describe('useWizard', () => { }); test('should not go to given step index when out of boundary', async () => { - const { result } = renderUseWizardHook(); + const { result, waitForNextUpdate } = renderUseWizardHook(); try { act(() => { result.current.goToStep(2); }); + await waitForNextUpdate(); } catch (error) { expect(result.current.activeStep).toBe(0); expect(result.current.isFirstStep).toBe(true); @@ -249,21 +256,24 @@ describe('useWizard', () => { expect(result.current.stepCount).toBe(4); }); - test('should go to step index of dynamic step', () => { + test('should go to step index of dynamic step', async () => { const steps = ['one', 'two', 'three']; - const { result, rerender } = renderHook(() => useWizard(), { - initialProps: { - startIndex: 0, + const { result, rerender, waitForNextUpdate } = renderHook( + () => useWizard(), + { + initialProps: { + startIndex: 0, + }, + wrapper: ({ children, startIndex }) => ( + + {steps.map((step) => ( +

{children}

+ ))} +
+ ), }, - wrapper: ({ children, startIndex }) => ( - - {steps.map((step) => ( -

{children}

- ))} -
- ), - }); + ); steps.push('four'); rerender(); @@ -272,6 +282,8 @@ describe('useWizard', () => { result.current.goToStep(3); }); + await waitForNextUpdate(); + expect(result.current.activeStep).toBe(3); expect(result.current.isFirstStep).toBe(false); expect(result.current.isLastStep).toBe(true); @@ -310,23 +322,30 @@ describe('useWizard', () => { test('should invoke onStepChange when previousStep is called', async () => { const onStepChange = jest.fn(); - const { result } = renderUseWizardHook(onStepChange, 1); + const { result, waitForNextUpdate } = renderUseWizardHook( + onStepChange, + 1, + ); act(() => { result.current.previousStep(); }); + await waitForNextUpdate(); + expect(onStepChange).toHaveBeenCalledWith(0); }); test('should invoke onStepChange when goToStep is called', async () => { const onStepChange = jest.fn(); - const { result } = renderUseWizardHook(onStepChange); + const { result, waitForNextUpdate } = renderUseWizardHook(onStepChange); act(() => { result.current.goToStep(1); }); + await waitForNextUpdate(); + expect(onStepChange).toHaveBeenCalledWith(1); }); }); From 2459ebece11ad92cfb1c7e30591b7a5d906c8811 Mon Sep 17 00:00:00 2001 From: Marius Holter Berntzen Date: Wed, 16 Oct 2024 16:01:09 +0200 Subject: [PATCH 3/3] chore(README): Update useWizard methods readme table --- README.md | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 2890401..7ad7d4f 100644 --- a/README.md +++ b/README.md @@ -126,18 +126,19 @@ Used to retrieve all methods and properties related to your wizard. Make sure `W #### Methods -| name | type | description | -| ------------ | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| nextStep | () => Promise | Go to the next step | -| previousStep | () => void | Go to the previous step index | -| goToStep | (stepIndex: number) => void | Go to the given step index | -| handleStep | (handler: Handler) => void | Attach a callback that will be called when calling `nextStep`. `handler` can be either sync or async | -| isLoading | boolean | \* Will reflect the handler promise state: will be `true` if the handler promise is pending and `false` when the handler is either fulfilled or rejected | -| activeStep | number | The current active step of the wizard | -| stepCount | number | The total number of steps of the wizard | -| isFirstStep | boolean | Indicate if the current step is the first step (aka no previous step) | -| isLastStep | boolean | Indicate if the current step is the last step (aka no next step) | -| | +| name | type | description | +| ------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| nextStep | () => Promise | Go to the next step | +| previousStep | () => void | Go to the previous step index | +| goToStep | (stepIndex: number) => void | Go to the given step index | +| handleStep | (handler: Handler) => void | Attach a callback that will be called when calling `nextStep`. `handler` can be either sync or async | +| handlePreviousStep | (handler: Handler) => void | Attach a callback that will be called when calling `previousStep`. `handler` can be either sync or async | +| handleGoToStep | (handler: Handler) => void | Attach a callback that will be called when calling `nextGoToStep`. `handler` can be either sync or async | +| isLoading | boolean | \* Will reflect the handler promise state: will be `true` if the handler promise is pending and `false` when the handler is either fulfilled or rejected | +| activeStep | number | The current active step of the wizard | +| stepCount | number | The total number of steps of the wizard | +| isFirstStep | boolean | Indicate if the current step is the first step (aka no previous step) | +| isLastStep | boolean | Indicate if the current step is the last step (aka no next step) | #### Example