Skip to content

Feature: Add additional Step Handlers useWizard #197

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 13 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,18 +126,19 @@ Used to retrieve all methods and properties related to your wizard. Make sure `W

#### Methods

| name | type | description |
| ------------ | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| nextStep | () => Promise<void> | 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<void> | 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

Expand Down
14 changes: 12 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,27 @@ export type WizardValues = {
/**
* Go to the previous step
*/
previousStep: () => void;
previousStep: () => Promise<void>;
/**
* Go to the given step index
* @param stepIndex The step index, starts at 0
*/
goToStep: (stepIndex: number) => void;
goToStep: (stepIndex: number) => Promise<void>;
/**
* 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
*
Expand Down
72 changes: 62 additions & 10 deletions src/wizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,17 @@ const Wizard: React.FC<React.PropsWithChildren<WizardProps>> = React.memo(
const hasNextStep = React.useRef(true);
const hasPreviousStep = React.useRef(false);
const nextStepHandler = React.useRef<Handler>(() => {});
const previousStepHandler = React.useRef<Handler>(() => {});
const goToStepHandler = React.useRef<Handler>(() => {});
const stepCount = React.Children.toArray(children).length;

hasNextStep.current = activeStep < stepCount - 1;
hasPreviousStep.current = activeStep > 0;

const goToNextStep = React.useCallback(() => {
if (hasNextStep.current) {
previousStepHandler.current = null;
goToStepHandler.current = null;
const newActiveStepIndex = activeStep + 1;

setActiveStep(newActiveStepIndex);
Expand All @@ -35,6 +39,7 @@ const Wizard: React.FC<React.PropsWithChildren<WizardProps>> = React.memo(
const goToPreviousStep = React.useCallback(() => {
if (hasPreviousStep.current) {
nextStepHandler.current = null;
goToStepHandler.current = null;
const newActiveStepIndex = activeStep - 1;

setActiveStep(newActiveStepIndex);
Expand All @@ -46,6 +51,7 @@ const Wizard: React.FC<React.PropsWithChildren<WizardProps>> = React.memo(
(stepIndex: number) => {
if (stepIndex >= 0 && stepIndex < stepCount) {
nextStepHandler.current = null;
previousStepHandler.current = null;
setActiveStep(stepIndex);
onStepChange?.(stepIndex);
} else {
Expand All @@ -68,42 +74,88 @@ const Wizard: React.FC<React.PropsWithChildren<WizardProps>> = 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>} 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<Handler>,
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,
],
);

Expand Down
55 changes: 37 additions & 18 deletions test/useWizard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,51 +135,58 @@ 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);
});

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);
expect(result.current.isLastStep).toBe(true);
});

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);
Expand Down Expand Up @@ -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 }) => (
<Wizard startIndex={startIndex}>
{steps.map((step) => (
<p key={step}>{children}</p>
))}
</Wizard>
),
},
wrapper: ({ children, startIndex }) => (
<Wizard startIndex={startIndex}>
{steps.map((step) => (
<p key={step}>{children}</p>
))}
</Wizard>
),
});
);

steps.push('four');
rerender();
Expand All @@ -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);
Expand Down Expand Up @@ -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);
});
});
Expand Down