Add useSteps hook
This commit is contained in:
parent
192b163156
commit
260e0596ab
2 changed files with 766 additions and 0 deletions
465
app/javascript/components/hooks/useSteps.spec.ts
Normal file
465
app/javascript/components/hooks/useSteps.spec.ts
Normal file
|
@ -0,0 +1,465 @@
|
|||
/* eslint-disable jest/no-hooks */
|
||||
// License: LGPL-3.0-or-later
|
||||
import { renderHook, act } from '@testing-library/react-hooks';
|
||||
import useSteps, { KeyedStep, KeyedStepMap } from './useSteps';
|
||||
import fromPairs from 'lodash/fromPairs';
|
||||
import { initial } from 'lodash';
|
||||
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
const stepActions = { addStep: () => { }, removeStep: () => { } };
|
||||
|
||||
describe('.next', () => {
|
||||
|
||||
it('nothing changes if there is no next', async () => {
|
||||
expect.assertions(2);
|
||||
const { result } = renderHook(() => useSteps({ steps: [{ key: 'i' }], ...stepActions }));
|
||||
const original = result.current;
|
||||
act(() => {
|
||||
result.current.next();
|
||||
});
|
||||
|
||||
expect(result.current.activeStep).toBe(0);
|
||||
expect(Object.is(result.current, original)).toBe(true);
|
||||
});
|
||||
|
||||
it('nothing changes if next is disabled', async () => {
|
||||
expect.assertions(2);
|
||||
const { result } = renderHook(() => useSteps({ steps: [{ key: 'i' }, { key: '2' }], ...stepActions }, {
|
||||
disabled: { 'i': false, '2': true }
|
||||
}));
|
||||
const original = result.current;
|
||||
act(() => {
|
||||
result.current.next();
|
||||
});
|
||||
|
||||
expect(result.current.activeStep).toBe(0);
|
||||
expect(Object.is(result.current, original)).toBe(true);
|
||||
});
|
||||
|
||||
it('go to next if next exists', async () => {
|
||||
expect.assertions(2);
|
||||
const { result } = renderHook(() => useSteps({ steps: [{ key: 'i' }, { key: '2' }], ...stepActions }));
|
||||
const original = result.current;
|
||||
act(() => {
|
||||
result.current.next();
|
||||
});
|
||||
|
||||
expect(result.current.activeStep).toBe(1);
|
||||
expect(Object.is(result.current, original)).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
describe('.back', () => {
|
||||
|
||||
it('nothing changes if there is no back', async () => {
|
||||
expect.assertions(2);
|
||||
const { result } = renderHook(() => useSteps({ steps: [{ key: 'i' }], ...stepActions }));
|
||||
const original = result.current;
|
||||
act(() => {
|
||||
result.current.back();
|
||||
});
|
||||
|
||||
expect(result.current.activeStep).toBe(0);
|
||||
expect(Object.is(result.current, original)).toBe(true);
|
||||
});
|
||||
|
||||
it('nothing changes if back is disabled', async () => {
|
||||
expect.assertions(2);
|
||||
const { result } = renderHook(() => useSteps({ steps: [{ key: 'i' }, { key: '2' }, { key: 'last' }], ...stepActions }, {
|
||||
activeStep: 2,
|
||||
disabled: { 'i': true, '2': true, 'last': false }
|
||||
}));
|
||||
const original = result.current;
|
||||
act(() => {
|
||||
result.current.back();
|
||||
});
|
||||
|
||||
expect(result.current.activeStep).toBe(2);
|
||||
expect(Object.is(result.current, original)).toBe(true);
|
||||
});
|
||||
|
||||
it('if back is the first item, we ignore its disabled state', async () => {
|
||||
expect.assertions(2);
|
||||
const { result } = renderHook(() => useSteps({ steps: [{ key: 'i' }, { key: '2' }, { key: 'last' }], ...stepActions }, {
|
||||
activeStep: 1,
|
||||
disabled: { 'i': true, '2': false, 'last': false }
|
||||
}));
|
||||
const original = result.current;
|
||||
act(() => {
|
||||
result.current.back();
|
||||
});
|
||||
|
||||
expect(result.current.activeStep).toBe(0);
|
||||
expect(Object.is(result.current, original)).toBe(false);
|
||||
});
|
||||
|
||||
it('go to back if back exists', async () => {
|
||||
expect.assertions(2);
|
||||
const { result } = renderHook(() => useSteps({ steps: [{ key: 'i' }, { key: '2' }], ...stepActions }, { activeStep: 1 }));
|
||||
const original = result.current;
|
||||
act(() => {
|
||||
result.current.back();
|
||||
});
|
||||
|
||||
expect(result.current.activeStep).toBe(0);
|
||||
expect(Object.is(result.current, original)).toBe(false);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('.goto', () => {
|
||||
|
||||
it('nothing changes if goto value is below 0', async () => {
|
||||
expect.assertions(2);
|
||||
const { result } = renderHook(() => useSteps({ steps: [{ key: 'i' }], ...stepActions }));
|
||||
const original = result.current;
|
||||
act(() => {
|
||||
result.current.goto(-1);
|
||||
});
|
||||
|
||||
expect(result.current.activeStep).toBe(0);
|
||||
expect(Object.is(result.current, original)).toBe(true);
|
||||
});
|
||||
|
||||
it('nothing changes if goto value is bigger number of steps', async () => {
|
||||
expect.assertions(2);
|
||||
const { result } = renderHook(() => useSteps({ steps: [{ key: 'i' }], ...stepActions }));
|
||||
const original = result.current;
|
||||
act(() => {
|
||||
result.current.goto(1);
|
||||
});
|
||||
|
||||
expect(result.current.activeStep).toBe(0);
|
||||
expect(Object.is(result.current, original)).toBe(true);
|
||||
});
|
||||
|
||||
it('nothing changes if goto value is disabled', async () => {
|
||||
expect.assertions(2);
|
||||
const { result } = renderHook(() => useSteps({ steps: [{ key: 'i' }, { key: '2' }], ...stepActions }, {
|
||||
disabled: { 'i': false, '2': true }
|
||||
}));
|
||||
const original = result.current;
|
||||
act(() => {
|
||||
result.current.goto(1);
|
||||
});
|
||||
|
||||
expect(result.current.activeStep).toBe(0);
|
||||
expect(Object.is(result.current, original)).toBe(true);
|
||||
});
|
||||
|
||||
it('if goto step is 0, we always go back regardless of disabledness', async () => {
|
||||
expect.assertions(2);
|
||||
const { result } = renderHook(() => useSteps({ steps: [{ key: 'i' }, { key: '2' }], ...stepActions }, {
|
||||
activeStep: 1,
|
||||
disabled: { 'i': false, '2': true }
|
||||
}));
|
||||
const original = result.current;
|
||||
act(() => {
|
||||
result.current.goto(0);
|
||||
});
|
||||
|
||||
expect(result.current.activeStep).toBe(0);
|
||||
expect(Object.is(result.current, original)).toBe(false);
|
||||
});
|
||||
|
||||
it('goto step if it exists', async () => {
|
||||
expect.assertions(2);
|
||||
const { result } = renderHook(() => useSteps({ steps: [{ key: 'i' }, { key: '2' }], ...stepActions }, { activeStep: 1 }));
|
||||
const original = result.current;
|
||||
act(() => {
|
||||
result.current.goto(1);
|
||||
});
|
||||
|
||||
expect(result.current.activeStep).toBe(1);
|
||||
expect(Object.is(result.current, original)).toBe(false);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('.first', () => {
|
||||
|
||||
it('changes nothing if already first', async () => {
|
||||
expect.assertions(2);
|
||||
const { result } = renderHook(() => useSteps({ steps: [{ key: 'i' }, { key: '2' }], ...stepActions }));
|
||||
const original = result.current;
|
||||
act(() => {
|
||||
result.current.first();
|
||||
});
|
||||
|
||||
expect(result.current.activeStep).toBe(0);
|
||||
expect(Object.is(result.current, original)).toBe(true);
|
||||
});
|
||||
|
||||
it('if first is disabled we still go back', async () => {
|
||||
expect.assertions(2);
|
||||
const { result } = renderHook(() => useSteps({ steps: [{ key: 'i' }, { key: '2' }], ...stepActions }, {
|
||||
activeStep: 1,
|
||||
disabled: { 'i': true, '2': false }
|
||||
}));
|
||||
const original = result.current;
|
||||
act(() => {
|
||||
result.current.first();
|
||||
});
|
||||
|
||||
expect(result.current.activeStep).toBe(0);
|
||||
expect(Object.is(result.current, original)).toBe(false);
|
||||
});
|
||||
|
||||
it('go to first', async () => {
|
||||
expect.assertions(2);
|
||||
const { result } = renderHook(() => useSteps({ steps: [{ key: 'i' }, { key: '2' }], ...stepActions }, { activeStep: 1 }));
|
||||
const original = result.current;
|
||||
|
||||
act(() => {
|
||||
result.current.first();
|
||||
});
|
||||
|
||||
expect(result.current.activeStep).toBe(0);
|
||||
expect(Object.is(result.current, original)).toBe(false);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('.last', () => {
|
||||
it('changes nothing if already last', async () => {
|
||||
expect.assertions(2);
|
||||
const { result } = renderHook(() => useSteps({ steps: [{ key: 'i' }, { key: '2' }], ...stepActions }, { activeStep: 1 }));
|
||||
const original = result.current;
|
||||
act(() => {
|
||||
result.current.last();
|
||||
});
|
||||
|
||||
expect(result.current.activeStep).toBe(1);
|
||||
expect(Object.is(result.current, original)).toBe(true);
|
||||
});
|
||||
|
||||
it('changes nothing if last is disabled', async () => {
|
||||
expect.assertions(2);
|
||||
const { result } = renderHook(() => useSteps({ steps: [{ key: 'i' }, { key: '2' }], ...stepActions }, {
|
||||
disabled: { 'i': false, '2': true }
|
||||
}));
|
||||
const original = result.current;
|
||||
act(() => {
|
||||
result.current.last();
|
||||
});
|
||||
|
||||
expect(result.current.activeStep).toBe(0);
|
||||
expect(Object.is(result.current, original)).toBe(true);
|
||||
});
|
||||
|
||||
it('go to last', async () => {
|
||||
expect.assertions(2);
|
||||
const { result } = renderHook(() => useSteps({ steps: [{ key: 'i' }, { key: '2' }], ...stepActions }));
|
||||
const original = result.current;
|
||||
act(() => {
|
||||
result.current.last();
|
||||
});
|
||||
|
||||
expect(result.current.activeStep).toBe(1);
|
||||
expect(Object.is(result.current, original)).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
describe('.disable', () => {
|
||||
describe.each([
|
||||
[[{ key: 'i' }, { key: '2' }], 1, { i: false, 2: false }, 1, 0, { i: false, 2: true }],
|
||||
[[{ key: 'i' }, { key: '2' }], 0, { i: false, 2: false }, 1, 0, { i: false, 2: true }],
|
||||
[[{ key: 'i' }, { key: '2' }], 1, { i: true, 2: false }, 1, 0, { i: true, 2: true }],
|
||||
[[{ key: 'i' }, { key: '2' }], 0, { i: false, 2: false }, 0, 0, { i: true, 2: false }]
|
||||
])('.disable with keys of %j, activeStep: %d, initial disabled: %j, we disabled index %d', (
|
||||
steps,
|
||||
initialActiveStep,
|
||||
initialDisabled,
|
||||
indexToDisable,
|
||||
activeStep,
|
||||
disabled) => {
|
||||
let current: any = null;
|
||||
|
||||
beforeEach(() => {
|
||||
const { result } = renderHook(() => useSteps({ steps, ...stepActions },
|
||||
{ activeStep: initialActiveStep, disabled: initialDisabled }));
|
||||
act(() => {
|
||||
result.current.disable(indexToDisable);
|
||||
});
|
||||
current = result.current;
|
||||
});
|
||||
|
||||
|
||||
it(`sets activeStep to ${activeStep}`, () => {
|
||||
expect.hasAssertions();
|
||||
expect(current.activeStep).toBe(activeStep);
|
||||
});
|
||||
|
||||
|
||||
it(`sets disabled to properly`, () => {
|
||||
expect.hasAssertions();
|
||||
expect(current.disabled).toStrictEqual(disabled);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('.enable', () => {
|
||||
describe.each([
|
||||
[[{ key: 'i' }, { key: '2' }], 1, { i: false, 2: false }, 1, 1, { i: false, 2: false }],
|
||||
[[{ key: 'i' }, { key: '2' }], 0, { i: false, 2: true }, 1, 0, { i: false, 2: false }],
|
||||
[[{ key: 'i' }, { key: '2' }], 1, { i: true, 2: false }, 1, 1, { i: true, 2: false }],
|
||||
[[{ key: 'i' }, { key: '2' }], 0, { i: true, 2: false }, 0, 0, { i: false, 2: false }]
|
||||
])('.enable with keys of %j, activeStep: %d, initial enabled: %j, we enable index %d', (
|
||||
steps,
|
||||
initialActiveStep,
|
||||
initialDisabled,
|
||||
indexToEnable,
|
||||
activeStep,
|
||||
disabled) => {
|
||||
let current: any = null;
|
||||
|
||||
beforeEach(() => {
|
||||
const { result } = renderHook(() => useSteps({ steps, ...stepActions },
|
||||
{ activeStep: initialActiveStep, disabled: initialDisabled }));
|
||||
act(() => {
|
||||
result.current.enable(indexToEnable);
|
||||
});
|
||||
current = result.current;
|
||||
});
|
||||
|
||||
|
||||
it(`sets activeStep to ${activeStep}`, () => {
|
||||
expect.hasAssertions();
|
||||
expect(current.activeStep).toBe(activeStep);
|
||||
});
|
||||
|
||||
|
||||
it(`sets disabled to properly`, () => {
|
||||
expect.hasAssertions();
|
||||
expect(current.disabled).toStrictEqual(disabled);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('modify steps', () => {
|
||||
function createTableEntry(props: {
|
||||
initial: {
|
||||
steps: KeyedStep[],
|
||||
activeStep?: number,
|
||||
disabled?: KeyedStepMap<boolean>,
|
||||
completed?:
|
||||
KeyedStepMap<boolean>
|
||||
},
|
||||
stepChange: KeyedStep[],
|
||||
expectation: {
|
||||
activeStep?: number,
|
||||
disabled?: KeyedStepMap<boolean>,
|
||||
completed?: KeyedStepMap<boolean>
|
||||
}
|
||||
}
|
||||
): [
|
||||
KeyedStep[], number | undefined, KeyedStepMap<boolean> | undefined, KeyedStepMap<boolean> | undefined, // initial
|
||||
KeyedStep[], //stepChange
|
||||
number | undefined, KeyedStepMap<boolean>, KeyedStepMap<boolean> //expectations
|
||||
] {
|
||||
const expectation = {
|
||||
activeStep: props.initial.activeStep || 0,
|
||||
disabled: props.initial.disabled || fromPairs(props.initial.steps.map(i => [i.key, false])),
|
||||
completed: props.initial.completed || fromPairs(props.initial.steps.map(i => [i.key, false])),
|
||||
...props.expectation
|
||||
};
|
||||
return [
|
||||
props.initial.steps, props.initial.activeStep, props.initial.disabled, props.initial.completed,
|
||||
props.stepChange,
|
||||
expectation.activeStep, expectation.disabled, expectation.completed
|
||||
];
|
||||
}
|
||||
|
||||
describe.each([
|
||||
createTableEntry({
|
||||
initial: { steps: [{ key: 'i' }, { key: '2' }] },
|
||||
stepChange: [{ key: 'i' }, { key: '2' }],
|
||||
expectation: {
|
||||
}
|
||||
}),
|
||||
createTableEntry({
|
||||
initial: { steps: [{ key: 'i' }, { key: '2' }], activeStep: 0 },
|
||||
stepChange: [ { key: '2' }],
|
||||
expectation: {
|
||||
disabled: {2: false},
|
||||
completed: {2: false}
|
||||
}
|
||||
}),
|
||||
createTableEntry({
|
||||
initial: { steps: [{ key: 'i' }, { key: '2' }], activeStep: 1 },
|
||||
stepChange: [ { key: 'i' }],
|
||||
expectation: {
|
||||
activeStep: 0,
|
||||
disabled: {i: false},
|
||||
completed: {i: false}
|
||||
}
|
||||
}),
|
||||
createTableEntry({
|
||||
initial: { steps: [{ key: 'i' }, { key: '2' }, {key: 'last'}], activeStep: 2, disabled: {i: false, 2: true, last:false}},
|
||||
stepChange: [ { key: 'i' }, { key: '2' }],
|
||||
expectation: {
|
||||
activeStep: 0,
|
||||
disabled: {i: false, 2: true},
|
||||
completed: {i: false, 2: false}
|
||||
}
|
||||
}),
|
||||
createTableEntry({
|
||||
initial: { steps: [{ key: 'i' }, { key: '2' }, {key: 'last'}], activeStep: 2, disabled: {i: false, 2: true, last:false}},
|
||||
stepChange: [ { key: 'i' }, {key: 'last'}, { key: '2' }],
|
||||
expectation: {
|
||||
activeStep: 1,
|
||||
disabled: {i: false, 2: true, last:false},
|
||||
completed: {i: false, 2: false, last: false}
|
||||
}
|
||||
}),
|
||||
|
||||
createTableEntry({
|
||||
initial: { steps: [{ key: 'i' }, { key: '2' }, {key: 'last'}], activeStep: 2, disabled: {i: false, 2: true, last:false}},
|
||||
stepChange: [ {key: 'last'},{ key: 'i' },],
|
||||
expectation: {
|
||||
activeStep: 0,
|
||||
disabled: {i: false, last:false},
|
||||
completed: {i: false, last: false}
|
||||
}
|
||||
})
|
||||
])('with initial steps %j, active: %d, disabled: %o, completed: %o and change steps to %j', (
|
||||
initialSteps, initialActiveStep, initialDisabled, initialCompleted,
|
||||
stepChange,
|
||||
expectationActiveStep, expectationDisabled, expectationCompleted
|
||||
) => {
|
||||
let finalState:ReturnType<typeof useSteps>;
|
||||
beforeEach(() => {
|
||||
const { result, rerender } = renderHook((steps) => useSteps({ steps, ...stepActions },
|
||||
{ activeStep: initialActiveStep, disabled: initialDisabled, completed: initialCompleted }), {initialProps: initialSteps});
|
||||
rerender(stepChange);
|
||||
finalState = result.current;
|
||||
});
|
||||
|
||||
it(`has the correct steps of ${JSON.stringify(stepChange)}`, () => {
|
||||
expect.hasAssertions();
|
||||
expect(finalState.steps).toStrictEqual(stepChange);
|
||||
});
|
||||
|
||||
it(`has an activeStep of ${expectationActiveStep}`, () => {
|
||||
expect.hasAssertions();
|
||||
expect(finalState.activeStep).toBe(expectationActiveStep);
|
||||
});
|
||||
|
||||
it(`has an disabled list of ${JSON.stringify(expectationDisabled)}`, () => {
|
||||
expect.hasAssertions();
|
||||
expect(finalState.disabled).toStrictEqual(expectationDisabled);
|
||||
});
|
||||
|
||||
it(`has completed list of ${JSON.stringify(expectationCompleted)}`, () => {
|
||||
expect.hasAssertions();
|
||||
expect(finalState.completed).toStrictEqual(expectationCompleted);
|
||||
});
|
||||
});
|
||||
});
|
301
app/javascript/components/hooks/useSteps.ts
Normal file
301
app/javascript/components/hooks/useSteps.ts
Normal file
|
@ -0,0 +1,301 @@
|
|||
/* eslint-disable no-case-declarations */
|
||||
// License: LGPL-3.0-or-later
|
||||
import { useReducer, useCallback, useEffect } from "react";
|
||||
import take from 'lodash/take';
|
||||
import fromPairs from 'lodash/fromPairs';
|
||||
|
||||
import findLastIndex from 'lodash/findLastIndex';
|
||||
import hashLeftAntiJoin from '../../common/lodash-joins/hash/hashLeftAntiJoin';
|
||||
import hashRightAntiJoin from '../../common/lodash-joins/hash/hashRightAntiJoin';
|
||||
|
||||
export interface KeyedStep {
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface KeyedStepMap<T = unknown> {
|
||||
[stepKey: string]: T
|
||||
}
|
||||
|
||||
interface ReadonlyStepsState {
|
||||
readonly activeStep?: number;
|
||||
readonly completed?: KeyedStepMap<boolean>;
|
||||
readonly disabled?: KeyedStepMap<boolean>;
|
||||
/**
|
||||
* An internal copy of steps which only includes the key
|
||||
*/
|
||||
readonly stepKeys: readonly string[]
|
||||
readonly activeStepKey: string;
|
||||
}
|
||||
|
||||
function areKeyedStepsDifferent(first: readonly string[], second: readonly string[]) {
|
||||
return first.length != second.length || first.find((value, index) => second[index] != value);
|
||||
}
|
||||
|
||||
function getIndexAndKeyPair(steps: readonly string[], step: string | number | unknown): {key:string, index:number} | false {
|
||||
|
||||
if (typeof step === 'string') {
|
||||
const index = steps.findIndex((i) => i === step);
|
||||
if (index){
|
||||
return {key: step, index};
|
||||
}
|
||||
}
|
||||
else if (typeof step === 'number' && step >= 0 && step < steps.length) {
|
||||
return {key: steps[step], index: step};
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getLastEnabledBeforeGivenStep(steps: readonly string[], currentActiveStep:number, disabled:KeyedStepMap<boolean>): {key:string, index:number}{
|
||||
const possibleNewActiveStep = findLastIndex(take(steps, currentActiveStep + 1), (i) => !disabled[i]);
|
||||
const index = possibleNewActiveStep >= 0 ? possibleNewActiveStep : 0;
|
||||
return {key: steps[index], index};
|
||||
}
|
||||
|
||||
function reindexState(state: ReadonlyStepsState, incomingSteps: readonly KeyedStep[]): ReadonlyStepsState {
|
||||
const incomingStepKeys = incomingSteps.map(i => i.key);
|
||||
//if true, we've had new steps added or removed
|
||||
if (areKeyedStepsDifferent(state.stepKeys, incomingStepKeys) ) {
|
||||
const newIndexOfActiveStep = incomingStepKeys.findIndex(v => v === state.activeStepKey);
|
||||
let activeStep = state.activeStep;
|
||||
let activeStepKey = state.activeStepKey;
|
||||
let completed = state.completed;
|
||||
let disabled = state.disabled;
|
||||
|
||||
const deleted = hashLeftAntiJoin(state.stepKeys as string[], (a) => a, incomingStepKeys as string[], (b) => b);
|
||||
const added = hashRightAntiJoin(state.stepKeys as string[], (a) => a, incomingStepKeys as string[], (b) => b);
|
||||
|
||||
if (deleted.length > 0 || added.length > 0) {
|
||||
completed = { ...completed };
|
||||
disabled = { ...disabled };
|
||||
|
||||
deleted.forEach((i) => {
|
||||
delete completed[i];
|
||||
delete disabled[i];
|
||||
});
|
||||
|
||||
added.forEach((i) => {
|
||||
completed[i] = false;
|
||||
disabled[i] = false;
|
||||
});
|
||||
}
|
||||
|
||||
//did activeStep move
|
||||
if (newIndexOfActiveStep != state.activeStep) {
|
||||
//activeStep moved!
|
||||
|
||||
//is activeStep still in the new list?
|
||||
if (newIndexOfActiveStep >= 0) {
|
||||
activeStep = newIndexOfActiveStep;
|
||||
}
|
||||
else {
|
||||
// new activestep is the last step before where activeStep was that is enabled (or 0)
|
||||
const newActiveStep = getLastEnabledBeforeGivenStep(incomingStepKeys, state.activeStep, disabled);
|
||||
activeStep = newActiveStep.index;
|
||||
activeStepKey = newActiveStep.key;
|
||||
}
|
||||
}
|
||||
|
||||
return { ...state, stepKeys: incomingStepKeys, activeStep, activeStepKey, completed, disabled };
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
interface StepsInitOptions {
|
||||
readonly activeStep?: number;
|
||||
readonly completed?: KeyedStepMap<boolean>;
|
||||
readonly disabled?: KeyedStepMap<boolean>;
|
||||
}
|
||||
|
||||
|
||||
interface InputStepsState extends Readonly<InputStepsMethods> {
|
||||
readonly steps: readonly KeyedStep[]
|
||||
}
|
||||
|
||||
interface InputStepsMethods {
|
||||
addStep: (step: KeyedStep, before?: number) => void
|
||||
removeStep: (step: KeyedStep) => void
|
||||
}
|
||||
|
||||
|
||||
interface MutableStepsObject extends InputStepsMethods {
|
||||
goto: (step: number) => void
|
||||
back: () => void
|
||||
next: () => void
|
||||
first: () => void
|
||||
last: () => void
|
||||
complete: (step: number) => void
|
||||
uncomplete: (step: number) => void
|
||||
disable: (step: number) => void
|
||||
enable: (step: number) => void
|
||||
|
||||
}
|
||||
type StepsObject = Readonly<MutableStepsObject> & Readonly<InputStepsState> & StepsInitOptions & { readonly steps: readonly KeyedStep[] }
|
||||
|
||||
type StepTypes = 'goto' | 'first' | 'last' | 'back' | 'next' | 'complete' | 'uncomplete' | 'disable' | 'enable' | 'stepsChanged'
|
||||
|
||||
interface StepAction {
|
||||
type: StepTypes, payload?: number | string | readonly KeyedStep[]
|
||||
}
|
||||
function stepsReducer(state: ReadonlyStepsState, args: StepAction): ReadonlyStepsState {
|
||||
let indexKeyPair:ReturnType<typeof getIndexAndKeyPair> = false;
|
||||
switch (args.type) {
|
||||
case ('goto'):
|
||||
indexKeyPair = getIndexAndKeyPair(state.stepKeys, args.payload);
|
||||
if (indexKeyPair && !state.disabled[indexKeyPair.key]) {
|
||||
return {...state, activeStep:indexKeyPair.index, activeStepKey: indexKeyPair.key};
|
||||
}
|
||||
return state;
|
||||
case ('first'):
|
||||
const firstStep = 0;
|
||||
if (state.activeStep != firstStep) {
|
||||
return { ...state, activeStep: firstStep, activeStepKey: state.stepKeys[firstStep] };
|
||||
}
|
||||
return state;
|
||||
case ('last'):
|
||||
const lastStep = state.stepKeys.length - 1 >=0 ? state.stepKeys.length - 1 : 0 ;
|
||||
if (state.activeStep != lastStep && !state.disabled[state.stepKeys[lastStep]]) {
|
||||
return { ...state, activeStep: lastStep, activeStepKey: state.stepKeys[lastStep] };
|
||||
}
|
||||
return state;
|
||||
case ('back'):
|
||||
const backStep = state.activeStep - 1;
|
||||
if ((backStep === 0) || (backStep >= 0 && !state.disabled[state.stepKeys[backStep]])) {
|
||||
return { ...state, activeStep: backStep, activeStepKey: state.stepKeys[backStep] };
|
||||
}
|
||||
return state;
|
||||
case ('next'):
|
||||
const nextStep = state.activeStep + 1;
|
||||
if (nextStep < state.stepKeys.length && !state.disabled[state.stepKeys[nextStep]]) {
|
||||
return { ...state, activeStep: nextStep, activeStepKey: state.stepKeys[nextStep] };
|
||||
}
|
||||
return state;
|
||||
case ('complete'):
|
||||
indexKeyPair = getIndexAndKeyPair(state.stepKeys, args.payload);
|
||||
if (indexKeyPair) {
|
||||
const completed = { ...state.completed };
|
||||
completed[indexKeyPair.key] = true;
|
||||
return { ...state, completed: completed };
|
||||
}
|
||||
return state;
|
||||
case ('uncomplete'):
|
||||
indexKeyPair = getIndexAndKeyPair(state.stepKeys, args.payload);
|
||||
if (indexKeyPair) {
|
||||
const completed = { ...state.completed };
|
||||
completed[indexKeyPair.key] = false;
|
||||
return { ...state, completed };
|
||||
}
|
||||
return state;
|
||||
case ('disable'):
|
||||
indexKeyPair = getIndexAndKeyPair(state.stepKeys, args.payload);
|
||||
if (indexKeyPair) {
|
||||
const disabled = { ...state.disabled };
|
||||
disabled[indexKeyPair.key] = true;
|
||||
let {activeStep, activeStepKey} = state;
|
||||
if (state.activeStep == indexKeyPair.index) {
|
||||
const result = getLastEnabledBeforeGivenStep(state.stepKeys, activeStep, disabled);
|
||||
activeStep = result.index;
|
||||
activeStepKey = result.key;
|
||||
}
|
||||
return { ...state, disabled, activeStep, activeStepKey };
|
||||
}
|
||||
return state;
|
||||
case ('enable'):
|
||||
indexKeyPair = getIndexAndKeyPair(state.stepKeys, args.payload);
|
||||
if (indexKeyPair) {
|
||||
const disabled = { ...state.disabled };
|
||||
disabled[indexKeyPair.key] = false;
|
||||
return { ...state, disabled };
|
||||
}
|
||||
return state;
|
||||
case ('stepsChanged'):
|
||||
if (typeof args.payload === 'object') {
|
||||
return reindexState(state, args.payload);
|
||||
}
|
||||
return state;
|
||||
default:
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
|
||||
export default function useSteps(state: InputStepsState, initOptions: StepsInitOptions = {}): StepsObject {
|
||||
|
||||
const activeStep = initOptions.activeStep || 0;
|
||||
|
||||
const initialSteps = state.steps.map(i => i.key);
|
||||
const initialState: ReadonlyStepsState = {
|
||||
completed: initOptions.completed || fromPairs(initialSteps.map((i) => [i, false])),
|
||||
disabled: initOptions.disabled || fromPairs(initialSteps.map((i) => [i, false])),
|
||||
stepKeys: initialSteps,
|
||||
activeStep,
|
||||
activeStepKey: activeStep >= 0 && activeStep < state.steps.length ? state.steps[activeStep].key : null
|
||||
};
|
||||
const [stepsState, dispatch] = useReducer(stepsReducer, initialState);
|
||||
|
||||
const { steps } = state;
|
||||
|
||||
const goto = useCallback((step: number | string) => {
|
||||
dispatch({ type: "goto", payload: step });
|
||||
}, []);
|
||||
|
||||
const back = useCallback(() => {
|
||||
dispatch({ type: 'back' });
|
||||
}, []);
|
||||
|
||||
const next = useCallback(() => {
|
||||
dispatch({ type: 'next' });
|
||||
}, []);
|
||||
|
||||
const first = useCallback(() => {
|
||||
dispatch({ type: 'first' });
|
||||
}, []);
|
||||
|
||||
const last = useCallback(() => {
|
||||
dispatch({ type: 'last' });
|
||||
}, []);
|
||||
|
||||
const complete = useCallback((step: number) => {
|
||||
dispatch({ type: "complete", payload: step });
|
||||
}, []);
|
||||
|
||||
const uncomplete = useCallback((step: number) => {
|
||||
dispatch({ type: "uncomplete", payload: step });
|
||||
}, []);
|
||||
|
||||
const disable = useCallback((step: number) => {
|
||||
dispatch({ type: "disable", payload: step });
|
||||
}, []);
|
||||
|
||||
const enable = useCallback((step: number) => {
|
||||
dispatch({ type: "enable", payload: step });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({ type: 'stepsChanged', payload: steps });
|
||||
}, [steps]);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { activeStepKey, stepKeys, ...outputSteps } = stepsState;
|
||||
return Object.freeze({
|
||||
steps,
|
||||
...outputSteps,
|
||||
goto,
|
||||
back,
|
||||
next,
|
||||
first,
|
||||
last,
|
||||
complete,
|
||||
uncomplete,
|
||||
disable,
|
||||
enable,
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
addStep: () => { },
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
removeStep: () => { }
|
||||
});
|
||||
|
||||
}
|
Loading…
Reference in a new issue