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();
+ })
+ })
});