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 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, ], ); 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); }); });