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…
	
	Add table
		
		Reference in a new issue
	
	 Eric Schultz
						Eric Schultz