302 lines
9.3 KiB
TypeScript
302 lines
9.3 KiB
TypeScript
/* 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 activeStepKey: string;
|
|
readonly completed?: KeyedStepMap<boolean>;
|
|
readonly disabled?: KeyedStepMap<boolean>;
|
|
/**
|
|
* An internal copy of steps which only includes the key
|
|
*/
|
|
readonly stepKeys: readonly 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): { index: number, key: string } | 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>
|
|
): { index: number, key: string } {
|
|
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 {
|
|
back: () => void;
|
|
complete: (step: number) => void;
|
|
disable: (step: number) => void;
|
|
enable: (step: number) => void;
|
|
first: () => void;
|
|
goto: (step: number) => void;
|
|
last: () => void;
|
|
next: () => void;
|
|
uncomplete: (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 {
|
|
payload?: number | string | readonly KeyedStep[];
|
|
type: StepTypes;
|
|
}
|
|
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: () => { },
|
|
});
|
|
|
|
}
|