diff --git a/.gitignore b/.gitignore index 9dcd2ba..44f5822 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules dist examples/**/package-lock.json +examples/**/pnpm-lock.yaml examples/**/.cache examples/**/.parcel-cache docs/ diff --git a/README.md b/README.md index fe5e3bf..6c791c0 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,45 @@ wizard.goToPreviousStep({ values: { firstName: '', lastName: '' } }); console.log(wizard.currentValues); // { firstName: '', lastName: '' } ``` +**Navigation** + +In order to act as a good web citizen, robo-wizard provides a way to integrate with client-side routing APIs for steps that map to real URL paths. + +```typescript +import { createWizard } from 'robo-wizard'; + +const wizard = createWizard( + ['first', 'second', 'third'], + { firstName: '', lastName: '' } + { + navigate: () => history.pushState({}, '', `/${wizard.currentStep}`) + } +); + +window.addEventListener('popstate', () => { + const stepFromPath = window.location.pathname.split('/').pop(); + if (stepFromPath && stepFromPath !== wizard.currentStep) wizard.sync({ step: stepFromPath }) +}) + +wizard.start(updatedWizard => { console.log('Updated!', updatedWizard.currentStep), updatedWizard.currentValues }); + +console.log(wizard.currentValues); // { firstName: '', lastName: '' } + +wizard.goToNextStep({ values: { firstName: 'Jane' } }); + +console.log(wizard.currentValues); // { firstName: 'Jane', lastName: '' } + +wizard.goToNextStep({ values: { lastName: 'Doe' } }); + +console.log(wizard.currentValues); // { firstName: 'Jane', lastName: 'Doe' } + +wizard.goToPreviousStep({ values: { firstName: '', lastName: '' } }); + +console.log(wizard.currentValues); // { firstName: '', lastName: '' } +``` + +While the above example demonstrates using the [History API](http://developer.mozilla.org/en-US/docs/Web/API/History_API), see the examples directory for how the [`history`](https://www.npmjs.com/package/history) and [`react-router`](https://www.npmjs.com/package/react-router) packages can be integrated. + ## Examples Check out the [examples](./examples/) directory to see a sample of usage with HTML and a few framework integrations. @@ -71,7 +110,7 @@ Check out the [examples](./examples/) directory to see a sample of usage with HT ## Work In Progress Roadmap - [ ] `createForm` state machine generator to control form state for a step in the wizard -- [ ] example integration of routed wizard steps, i.e. using `react-router` or `history` packages +- [X] example integration of routed wizard steps, i.e. using `react-router` or `history` packages - [ ] add history stack to internal state machine to lookup the previous step when using custom progression ## Local Development diff --git a/examples/history/.gitignore b/examples/history/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/examples/history/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/examples/history/favicon.svg b/examples/history/favicon.svg new file mode 100644 index 0000000..de4aedd --- /dev/null +++ b/examples/history/favicon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/examples/history/index.html b/examples/history/index.html new file mode 100644 index 0000000..fba3263 --- /dev/null +++ b/examples/history/index.html @@ -0,0 +1,51 @@ + + + + + + + + + Robo Wizard Example App + + + +
+

Robo Wizard w/ History

+ +

+ step +

+ +
+ + + + + + +
+ + +
+
+
+ + + + diff --git a/examples/history/package.json b/examples/history/package.json new file mode 100644 index 0000000..741425d --- /dev/null +++ b/examples/history/package.json @@ -0,0 +1,18 @@ +{ + "name": "history", + "private": true, + "version": "0.0.0", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "typescript": "^4.5.4", + "vite": "^2.9.7" + }, + "dependencies": { + "history": "^5.3.0", + "robo-wizard": "../../" + } +} diff --git a/examples/history/src/main.ts b/examples/history/src/main.ts new file mode 100644 index 0000000..277ab27 --- /dev/null +++ b/examples/history/src/main.ts @@ -0,0 +1,67 @@ +import { createWizard } from 'robo-wizard'; +import history from 'history/browser'; + + +type Values = { + firstName?: string; + lastName?: string; +}; + +const steps: HTMLElement[] = Array.prototype.slice.call( + document.querySelectorAll('[data-step]') +); +const currentStep = document.querySelector('[data-current-step]') +const currentValues = document.querySelectorAll('[data-values]') +const stepInputs = document.querySelectorAll('input'); + +const wizard = createWizard(steps.map(element => element.dataset.step), { firstName: '', lastName: '' }, { + navigate: () => { + history.push(`/${wizard.currentStep}`) + } +}); + +function render() { + if (currentStep) currentStep.textContent = wizard.currentStep; + currentValues.forEach((element: HTMLElement) => { + element.textContent = wizard.currentValues[element.dataset.values] || ''; + }); + stepInputs.forEach((element: HTMLInputElement) => { + element.value = wizard.currentValues[element.name] || ''; + }); + + for (const step of steps) { + if (step.dataset.step === wizard.currentStep) { + step.classList.remove('hidden'); + } else { + step.classList.add('hidden'); + } + } +} + +document.querySelectorAll('button[data-event]').forEach(button => + button.addEventListener('click', ({ target }) => { + const { dataset } = target as HTMLButtonElement; + if (dataset.event === 'previous') { + wizard.goToPreviousStep(); + } + }) +); + +document.querySelectorAll('form[data-event]').forEach(form => { + form.addEventListener('submit', event => { + event.preventDefault(); + const values = Object.fromEntries(new FormData(event.currentTarget as HTMLFormElement)) + wizard.goToNextStep({ values }); + }); +}); + +if (history.location.pathname === '/') { + history.push(`/${steps[0].dataset.step}`) +} + +history.listen(({ location }) => { + const stepFromPath = location.pathname.split('/').pop(); + if (stepFromPath && stepFromPath !== wizard.currentStep) wizard.sync({ step: stepFromPath }) +}) + +wizard.start(render); diff --git a/examples/history/src/vite-env.d.ts b/examples/history/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/examples/history/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/history/tsconfig.json b/examples/history/tsconfig.json new file mode 100644 index 0000000..fbd0225 --- /dev/null +++ b/examples/history/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ESNext", "DOM"], + "moduleResolution": "Node", + "strict": true, + "sourceMap": true, + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "noEmit": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/examples/react-router/.gitignore b/examples/react-router/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/examples/react-router/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/examples/react-router/index.html b/examples/react-router/index.html new file mode 100644 index 0000000..bc80514 --- /dev/null +++ b/examples/react-router/index.html @@ -0,0 +1,17 @@ + + + + + + + + + Robo-Wizard Example App + + + +
+ + + + diff --git a/examples/react-router/package.json b/examples/react-router/package.json new file mode 100644 index 0000000..383900f --- /dev/null +++ b/examples/react-router/package.json @@ -0,0 +1,24 @@ +{ + "name": "react-router", + "private": true, + "version": "0.0.0", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.1.0", + "react-dom": "^18.1.0", + "react-router": "^6.3.0", + "react-router-dom": "^6.3.0", + "robo-wizard": "link:../.." + }, + "devDependencies": { + "@types/react": "^18.0.8", + "@types/react-dom": "^18.0.3", + "@vitejs/plugin-react": "^1.3.2", + "typescript": "^4.6.4", + "vite": "^2.9.7" + } +} diff --git a/examples/react-router/src/App.tsx b/examples/react-router/src/App.tsx new file mode 100644 index 0000000..8e12744 --- /dev/null +++ b/examples/react-router/src/App.tsx @@ -0,0 +1,127 @@ +import type { FormEvent } from 'react'; +import { BrowserRouter } from 'react-router-dom' +import { Step, useWizardContext, WizardProvider } from './wizard-context' + +const First = () => { + const wizard = useWizardContext(); + + const onSubmit = (event: FormEvent) => { + event.preventDefault(); + const values = Object.fromEntries(new FormData(event.currentTarget)) + wizard.goToNextStep({ values }) + } + + return ( + <> +

+ {wizard.currentStep} step +

+
+
+ + +
+
+ + +
+
+ + ) +} + +const Second = () => { + const wizard = useWizardContext(); + + const onSubmit = (event: FormEvent) => { + event.preventDefault(); + const values = Object.fromEntries(new FormData(event.currentTarget)) + wizard.goToNextStep({ values }) + } + + return ( + <> +

+ {wizard.currentStep} step +

+
+
+ + +
+
+ + +
+
+ + ) +} + +const Third = () => { + const wizard = useWizardContext(); + + return ( + <> +

+ {wizard.currentStep} step +

+
+

+ Welcome {wizard.currentValues.firstName}{' '} + {wizard.currentValues.lastName}! +

+
+
+ + +
+ + ); +}; + +function App() { + return ( +
+

Robo Wizard w/ React-Router

+ + + } /> + } /> + } /> + + +
+ ) +} + +export default App diff --git a/examples/react-router/src/favicon.svg b/examples/react-router/src/favicon.svg new file mode 100644 index 0000000..de4aedd --- /dev/null +++ b/examples/react-router/src/favicon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/examples/react-router/src/main.tsx b/examples/react-router/src/main.tsx new file mode 100644 index 0000000..eaad244 --- /dev/null +++ b/examples/react-router/src/main.tsx @@ -0,0 +1,9 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +) diff --git a/examples/react-router/src/vite-env.d.ts b/examples/react-router/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/examples/react-router/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/react-router/src/wizard-context.tsx b/examples/react-router/src/wizard-context.tsx new file mode 100644 index 0000000..ca0dfc5 --- /dev/null +++ b/examples/react-router/src/wizard-context.tsx @@ -0,0 +1,56 @@ +import { Children, createContext, useContext, useReducer, useState, useEffect } from 'react'; +import { Route, Routes, useNavigate, useRoutes, Navigate, RouteProps, useLocation } from 'react-router'; +import { createWizard } from 'robo-wizard' + +const WizardContext = createContext>(null); + +export function Step({ name, ...routeProps }: Exclude & { name: string }) { + return ; +} + +export function WizardProvider({ children, initialValues }) { + const steps = Children.map(children, child => { + if (child.type === Step) return { ...child.props, path: child.props.name }; + return null; + }).filter(Boolean); + const wizard = useWizard(steps, initialValues); + const step = useRoutes(steps) + + return ( + + {step} + + } /> + + + ) +} + +export function useWizardContext() { + const wizard = useContext(WizardContext); + if (wizard === null) throw new Error('useWizardContext must be used within WizardProvider') + return wizard; +} + +function useWizard(steps, initialValues) { + const navigate = useNavigate(); + const [_, refreshState] = useReducer(s => !s, false); + const [currentWizard] = useState(() => { + const wizard = createWizard(steps, initialValues, { + navigate: () => { + navigate(wizard.currentStep) + } + }) + wizard.start(refreshState) + return wizard; + }) + const location = useLocation(); + const stepFromLocation = location.pathname.split('/').pop(); + + useEffect(() => { + if (stepFromLocation !== currentWizard.currentStep) { + currentWizard.sync({ step: stepFromLocation }) + } + }, [stepFromLocation, currentWizard]) + return Object.create(currentWizard); +} diff --git a/examples/react-router/tsconfig.json b/examples/react-router/tsconfig.json new file mode 100644 index 0000000..3d0a51a --- /dev/null +++ b/examples/react-router/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/examples/react-router/tsconfig.node.json b/examples/react-router/tsconfig.node.json new file mode 100644 index 0000000..e993792 --- /dev/null +++ b/examples/react-router/tsconfig.node.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "composite": true, + "module": "esnext", + "moduleResolution": "node" + }, + "include": ["vite.config.ts"] +} diff --git a/examples/react-router/vite.config.ts b/examples/react-router/vite.config.ts new file mode 100644 index 0000000..b1b5f91 --- /dev/null +++ b/examples/react-router/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()] +}) diff --git a/src/index.ts b/src/index.ts index 43c055a..4255f32 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,7 @@ export type BaseValues = object; * Event object containing any new values to be updated */ type UpdateEvent = { - type: 'next'; + type: 'next' | 'previous'; values?: Partial; }; @@ -74,6 +74,10 @@ type WizardEvent = } | { type: 'previous'; + } + | { + type: string; + values?: Partial; }; /** @@ -157,6 +161,14 @@ class RoboWizard< this.service.send({ type: 'previous', ...event }); } + /** + * @param event Optional object with a `step` field to described what the current step _should_ be + * Sync the external updated step with internal service + */ + public sync(event: { step: string }) { + this.service.send({ type: event.step }); + } + /** @ignore */ private get service() { if (typeof this._service === 'undefined') { @@ -327,11 +339,22 @@ function hasTarget(config: MaybeTarget | MaybeTarget[]): config is HasTarget { */ export function createWizard( steps: FlowStep[], - initialValues: Values = {} as Values + initialValues: Values = {} as Values, + actions: { + navigate?: StateMachine.ActionFunction>; + } = { + navigate: () => { + /* noop */ + }, + } ) { const normalizedSteps: StepConfig[] = steps.map((step) => typeof step === 'string' ? { name: step } : step ); + const syncTargets = normalizedSteps.reduce( + (result, { name }) => ({ ...result, [name]: { target: name } }), + {} + ); const config: StateMachine.Config> = { id: 'robo-wizard', initial: normalizedSteps[0]?.name ?? 'unknown', @@ -346,9 +369,11 @@ export function createWizard( // eslint-disable-next-line no-param-reassign result[step.name] = { + entry: ['navigate'], on: { ...(previousTarget ? { previous: { target: previousTarget } } : {}), ...(hasTarget(nextTarget) ? { next: nextTarget } : {}), + ...syncTargets, }, }; return result; @@ -368,6 +393,10 @@ export function createWizard( const machine = createMachine(config, { actions: { assignNewValues, + navigate: (values, event) => { + if (['next', 'previous'].includes(event.type)) + actions.navigate?.(values, event); + }, }, }); return new RoboWizard(machine); diff --git a/test/index.test.ts b/test/index.test.ts index f1f732a..8fc0d31 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { createWizard, when, WhenFunction } from '../src'; describe('createWizard', () => { @@ -180,4 +180,44 @@ describe('createWizard', () => { expect(wizard.currentStep).toBe('third'); }); }); + + describe('when passing navigate action', () => { + const steps = ['first', 'second', 'third']; + + it('calls navigate when entering a step', () => { + const navigate = vi.fn(); + const wizard = createWizard(steps, {}, { navigate }) + + wizard.start(() => { }); + + expect(navigate).not.toHaveBeenCalled(); + + wizard.goToNextStep() + + expect(navigate).toHaveBeenCalled(); + + wizard.goToPreviousStep() + + expect(navigate).toHaveBeenCalledTimes(2); + }) + }) + + describe('when calling sync method', () => { + const steps = ['first', 'second', 'third']; + + it('updates the currentStep without navigating', () => { + const navigate = vi.fn(); + const wizard = createWizard(steps, {}, { navigate }) + + wizard.start(() => { }); + + expect(wizard.currentStep).toBe('first') + + wizard.sync({ step: 'third' }); + + expect(wizard.currentStep).toBe('third') + + expect(navigate).not.toHaveBeenCalled(); + }) + }) });