diff --git a/app/javascript/components/hooks/useSteps.spec.ts b/app/javascript/components/hooks/useSteps.spec.ts new file mode 100644 index 00000000..a17c9aab --- /dev/null +++ b/app/javascript/components/hooks/useSteps.spec.ts @@ -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, + completed?: + KeyedStepMap + }, + stepChange: KeyedStep[], + expectation: { + activeStep?: number, + disabled?: KeyedStepMap, + completed?: KeyedStepMap + } + } + ): [ + KeyedStep[], number | undefined, KeyedStepMap | undefined, KeyedStepMap | undefined, // initial + KeyedStep[], //stepChange + number | undefined, KeyedStepMap, KeyedStepMap //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; + 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); + }); + }); +}); diff --git a/app/javascript/components/hooks/useSteps.ts b/app/javascript/components/hooks/useSteps.ts new file mode 100644 index 00000000..e484d8cb --- /dev/null +++ b/app/javascript/components/hooks/useSteps.ts @@ -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 { + [stepKey: string]: T +} + +interface ReadonlyStepsState { + readonly activeStep?: number; + readonly completed?: KeyedStepMap; + readonly disabled?: KeyedStepMap; + /** + * 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): {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; + readonly disabled?: KeyedStepMap; +} + + +interface InputStepsState extends Readonly { + 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 & Readonly & 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 = 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: () => { } + }); + +}