From e9185445ee1dedc6256790e4a2443d093d90cda2 Mon Sep 17 00:00:00 2001 From: Nick Hehr Date: Thu, 5 May 2022 18:35:45 -0400 Subject: [PATCH] feat(react-router): initial package w/ Wizard, Step components (#20) --- examples/react-router/package.json | 2 +- examples/react-router/src/App.tsx | 17 ++- examples/react-router/src/wizard-context.tsx | 56 -------- packages/react-router/package.json | 63 +++++++++ packages/react-router/src/components/Step.tsx | 9 ++ packages/react-router/src/components/index.ts | 1 + packages/react-router/src/context/index.tsx | 120 ++++++++++++++++++ packages/react-router/src/index.ts | 2 + packages/react-router/test/index.test.tsx | 47 +++++++ packages/react-router/test/setup.ts | 1 + packages/react-router/tsconfig.json | 23 ++++ packages/react-router/vite.config.ts | 33 +++++ pnpm-lock.yaml | 62 ++++++++- 13 files changed, 369 insertions(+), 67 deletions(-) delete mode 100644 examples/react-router/src/wizard-context.tsx create mode 100644 packages/react-router/package.json create mode 100644 packages/react-router/src/components/Step.tsx create mode 100644 packages/react-router/src/components/index.ts create mode 100644 packages/react-router/src/context/index.tsx create mode 100644 packages/react-router/src/index.ts create mode 100644 packages/react-router/test/index.test.tsx create mode 100644 packages/react-router/test/setup.ts create mode 100644 packages/react-router/tsconfig.json create mode 100644 packages/react-router/vite.config.ts diff --git a/examples/react-router/package.json b/examples/react-router/package.json index 512b637..8cd5aae 100644 --- a/examples/react-router/package.json +++ b/examples/react-router/package.json @@ -12,7 +12,7 @@ "react-dom": "^18.1.0", "react-router": "^6.3.0", "react-router-dom": "^6.3.0", - "robo-wizard": "workspace:*" + "@robo-wizard/react-router": "workspace:*" }, "devDependencies": { "@types/react": "^18.0.8", diff --git a/examples/react-router/src/App.tsx b/examples/react-router/src/App.tsx index 8e12744..a022614 100644 --- a/examples/react-router/src/App.tsx +++ b/examples/react-router/src/App.tsx @@ -1,9 +1,14 @@ import type { FormEvent } from 'react'; import { BrowserRouter } from 'react-router-dom' -import { Step, useWizardContext, WizardProvider } from './wizard-context' +import { Step, useWizardContext, Wizard } from '@robo-wizard/react-router' + +type Values = { + firstName?: string; + lastName?: string; +} const First = () => { - const wizard = useWizardContext(); + const wizard = useWizardContext(); const onSubmit = (event: FormEvent) => { event.preventDefault(); @@ -41,7 +46,7 @@ const First = () => { } const Second = () => { - const wizard = useWizardContext(); + const wizard = useWizardContext(); const onSubmit = (event: FormEvent) => { event.preventDefault(); @@ -79,7 +84,7 @@ const Second = () => { } const Third = () => { - const wizard = useWizardContext(); + const wizard = useWizardContext(); return ( <> @@ -114,11 +119,11 @@ function App() {

Robo Wizard w/ React-Router

- + initialValues={{ firstName: '', lastName: '' }}> } /> } /> } /> - +
) diff --git a/examples/react-router/src/wizard-context.tsx b/examples/react-router/src/wizard-context.tsx deleted file mode 100644 index ca0dfc5..0000000 --- a/examples/react-router/src/wizard-context.tsx +++ /dev/null @@ -1,56 +0,0 @@ -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/packages/react-router/package.json b/packages/react-router/package.json new file mode 100644 index 0000000..8882da6 --- /dev/null +++ b/packages/react-router/package.json @@ -0,0 +1,63 @@ +{ + "name": "@robo-wizard/react-router", + "version": "1.0.0", + "description": "RoboWizard components and hooks for react-router", + "main": "./dist/robo-wizard_react-router.umd.js", + "module": "./dist/robo-wizard_react-router.es.js", + "private": false, + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "import": "./dist/robo-wizard_react-router.es.js", + "require": "./dist/robo-wizard_react-router.umd.js" + } + }, + "types": "dist/index.d.ts", + "typedocMain": "src/index.ts", + "sideEffects": false, + "scripts": { + "start": "vite build --watch", + "build": "vite build", + "test": "vitest", + "prepare": "vite build" + }, + "keywords": [ + "wizard", + "workflow", + "state machine", + "xstate" + ], + "author": "HipsterBrown", + "homepage": "http://robo-wizard.js.org", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/HipsterBrown/robo-wizard.git", + "directory": "packages/react-router" + }, + "dependencies": { + "robo-wizard": "^1.1.0", + "@robo-wizard/react": "^1.0.0" + }, + "devDependencies": { + "@testing-library/jest-dom": "^5.16.4", + "@testing-library/react": "^13.2.0", + "@testing-library/react-hooks": "^8.0.0", + "@types/react": "^18.0.8", + "@types/react-dom": "^18.0.3", + "@vitejs/plugin-react": "^1.3.2", + "jsdom": "^19.0.0", + "react": "^18.1.0", + "react-dom": "^18.1.0", + "react-router": "^6.0.0", + "vite": "^2.9.8", + "vite-plugin-dts": "^1.1.1", + "vitest": "^0.10.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-router": "^6.0.0" + } +} diff --git a/packages/react-router/src/components/Step.tsx b/packages/react-router/src/components/Step.tsx new file mode 100644 index 0000000..0c277f8 --- /dev/null +++ b/packages/react-router/src/components/Step.tsx @@ -0,0 +1,9 @@ +import { Route, RouteProps } from 'react-router' +import type { StepConfig } from 'robo-wizard' + +/** + * @param props - accept same props as the [Route component from react-router](https://reactrouter.com/docs/en/v6/api#routes-and-route), excepte for `path`, as well as [[StepConfig]] from `robo-wizard` + **/ +export function Step({ name, ...routeProps }: Exclude & StepConfig) { + return ; +} diff --git a/packages/react-router/src/components/index.ts b/packages/react-router/src/components/index.ts new file mode 100644 index 0000000..20f2211 --- /dev/null +++ b/packages/react-router/src/components/index.ts @@ -0,0 +1 @@ +export * from "./Step"; diff --git a/packages/react-router/src/context/index.tsx b/packages/react-router/src/context/index.tsx new file mode 100644 index 0000000..90e8c96 --- /dev/null +++ b/packages/react-router/src/context/index.tsx @@ -0,0 +1,120 @@ +import { Children, createContext, useContext, useEffect, PropsWithChildren, ReactNode, ReactElement } from 'react'; +import { Route, Routes, useNavigate, useRoutes, Navigate, useLocation } from 'react-router'; +import type { RoboWizard, BaseValues, StepConfig } from 'robo-wizard' +import { useWizard } from '@robo-wizard/react' +import { Step } from '../components' + +const WizardContext = createContext(null); + +export type WizardProviderProps = PropsWithChildren<{ + initialValues?: Values; +}> + +function isReactElement(child: ReactNode): child is ReactElement { + return typeof child === 'object' && child !== null && 'type' in child; +} + +/** + * @typeParam Values Optional object to describe values being gathered by the wizard + * @param props + * + * Create a routed wizard experience under a Router from [react-router](https://reactrouter.com) + * + * An example set up for the Wizard: + * ```tsx + * function App() { + * return ( + * + * initialValues={{ firstName: '', lastName: '' }}> + * } /> + * } /> + * } /> + * + * + * ) + * } + * ``` + * + * An example step component: + * ```tsx + * 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

+ *
+ *
+ * + * + *
+ *
+ * + * + *
+ *
+ * + * ) + * } + * ``` + **/ +export function Wizard({ children, initialValues = {} as Values }: WizardProviderProps) { + if (typeof children !== 'object' || children === null) throw new Error('WizardProvider must have at least one child Step component') + + const steps = Children.map(children, child => { + if (isReactElement(child) && child.type === Step) { + return { ...child.props as StepConfig, path: child.props.name as string }; + } + return null; + })?.filter(Boolean) ?? []; + const navigate = useNavigate(); + const location = useLocation(); + const wizard = useWizard(steps, initialValues, { + navigate: () => { + navigate(wizard.currentStep); + } + }); + const step = useRoutes(steps) + const stepFromLocation = location.pathname.split('/').pop(); + + useEffect(() => { + if (stepFromLocation && stepFromLocation !== wizard.currentStep) { + wizard.sync({ step: stepFromLocation }) + } + }, [stepFromLocation, wizard]) + + return ( + + {step} + + } /> + + + ) +} + +/** + * @typeParam Values - object describing the [[currentValues]] gathered by [[RoboWizard]] + * + * Access the [[RoboWizard]] from the [[Wizard]] Context Provider + **/ +export function useWizardContext() { + const wizard = useContext(WizardContext); + if (wizard === null) throw new Error('useWizardContext must be used within WizardProvider') + return wizard as RoboWizard; +} diff --git a/packages/react-router/src/index.ts b/packages/react-router/src/index.ts new file mode 100644 index 0000000..ec1aad2 --- /dev/null +++ b/packages/react-router/src/index.ts @@ -0,0 +1,2 @@ +export * from "./context"; +export * from "./components"; diff --git a/packages/react-router/test/index.test.tsx b/packages/react-router/test/index.test.tsx new file mode 100644 index 0000000..90267d1 --- /dev/null +++ b/packages/react-router/test/index.test.tsx @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { MemoryRouter, useNavigate } from 'react-router' +import { Wizard, Step, useWizardContext } from '../src' + +function TestStep() { + const wizard = useWizardContext() + const navigate = useNavigate() + + return ( + <> +

{wizard.currentStep} step

+ + + + ) +} + +describe('Wizard', () => { + const subject = () => ( + + + } /> + } /> + } /> + + + ) + + it('navigates through steps', () => { + render(subject()); + + expect(screen.getByText('first step')).toBeTruthy(); + + fireEvent.click(screen.getByText('Next')) + + expect(screen.getByText('second step')).toBeTruthy(); + + fireEvent.click(screen.getByText('Next')) + + expect(screen.getByText('third step')).toBeTruthy(); + + fireEvent.click(screen.getByText('Back')) + + expect(screen.getByText('second step')).toBeTruthy(); + }) +}) diff --git a/packages/react-router/test/setup.ts b/packages/react-router/test/setup.ts new file mode 100644 index 0000000..c44951a --- /dev/null +++ b/packages/react-router/test/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom' diff --git a/packages/react-router/tsconfig.json b/packages/react-router/tsconfig.json new file mode 100644 index 0000000..0b8a729 --- /dev/null +++ b/packages/react-router/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.json", + "include": [ + "./src", + "./types" + ], + "exclude": [ + "node_modules", + "test", + "dist" + ], + "compilerOptions": { + "rootDir": "src", + "lib": [ + "DOM", + "DOM.Iterable", + "ES2019" + ], + "jsx": "react-jsx", + "target": "es2020", + "skipLibCheck": true + } +} diff --git a/packages/react-router/vite.config.ts b/packages/react-router/vite.config.ts new file mode 100644 index 0000000..d96082e --- /dev/null +++ b/packages/react-router/vite.config.ts @@ -0,0 +1,33 @@ +/// + +import path from 'path' +import { defineConfig } from 'vite' +import dts from 'vite-plugin-dts' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + build: { + lib: { + entry: path.resolve(__dirname, 'src/index.ts'), + name: 'RoboWizardReactRouter', + fileName: format => `robo-wizard_react-router.${format}.js` + }, + sourcemap: true, + rollupOptions: { + external: ['react', 'react-dom', 'react-router'], + output: { + globals: { + react: 'React', + 'react-dom': 'ReactDom', + 'react-router': 'ReactRouter' + } + } + } + }, + plugins: [dts(), react()], + test: { + globals: true, + environment: 'jsdom', + setupFiles: './test/setup.ts', + } +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc0a80d..1a35a8c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,6 +98,7 @@ importers: examples/react-router: specifiers: + '@robo-wizard/react-router': workspace:* '@types/react': ^18.0.8 '@types/react-dom': ^18.0.3 '@vitejs/plugin-react': ^1.3.2 @@ -105,15 +106,14 @@ importers: react-dom: ^18.1.0 react-router: ^6.3.0 react-router-dom: ^6.3.0 - robo-wizard: workspace:* typescript: ^4.6.4 vite: ^2.9.7 dependencies: + '@robo-wizard/react-router': link:../../packages/react-router react: 18.1.0 react-dom: 18.1.0_react@18.1.0 react-router: 6.3.0_react@18.1.0 react-router-dom: 6.3.0_ef5jwxihqo6n7gxfmzogljlgcm - robo-wizard: link:../../packages/core devDependencies: '@types/react': 18.0.8 '@types/react-dom': 18.0.3 @@ -197,6 +197,41 @@ importers: vite-plugin-dts: 1.1.1_vite@2.9.8 vitest: 0.10.0_jsdom@19.0.0 + packages/react-router: + specifiers: + '@robo-wizard/react': ^1.0.0 + '@testing-library/jest-dom': ^5.16.4 + '@testing-library/react': ^13.2.0 + '@testing-library/react-hooks': ^8.0.0 + '@types/react': ^18.0.8 + '@types/react-dom': ^18.0.3 + '@vitejs/plugin-react': ^1.3.2 + jsdom: ^19.0.0 + react: ^18.1.0 + react-dom: ^18.1.0 + react-router: ^6.0.0 + robo-wizard: ^1.1.0 + vite: ^2.9.8 + vite-plugin-dts: ^1.1.1 + vitest: ^0.10.0 + dependencies: + '@robo-wizard/react': link:../react + robo-wizard: link:../core + devDependencies: + '@testing-library/jest-dom': 5.16.4 + '@testing-library/react': 13.2.0_ef5jwxihqo6n7gxfmzogljlgcm + '@testing-library/react-hooks': 8.0.0_i6xcbgvvylvakhe2evaee3dlf4 + '@types/react': 18.0.8 + '@types/react-dom': 18.0.3 + '@vitejs/plugin-react': 1.3.2 + jsdom: 19.0.0 + react: 18.1.0 + react-dom: 18.1.0_react@18.1.0 + react-router: 6.3.0_react@18.1.0 + vite: 2.9.8 + vite-plugin-dts: 1.1.1_vite@2.9.8 + vitest: 0.10.0_jsdom@19.0.0 + packages: /@ampproject/remapping/2.2.0: @@ -997,6 +1032,8 @@ packages: peerDependenciesMeta: '@types/react': optional: true + react: + optional: true react-dom: optional: true react-test-renderer: @@ -1015,6 +1052,11 @@ packages: peerDependencies: react: ^18.0.0 react-dom: ^18.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true dependencies: '@babel/runtime': 7.17.9 '@testing-library/dom': 8.13.0 @@ -2985,7 +3027,6 @@ packages: resolution: {integrity: sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==} dependencies: '@babel/runtime': 7.17.9 - dev: false /hook-std/2.0.0: resolution: {integrity: sha512-zZ6T5WcuBMIUVh49iPQS9t977t7C0l7OtHrpeMb5uk48JdflRX0NSFvCekfYNmGQETnLq9W/isMyHl69kxGi8g==} @@ -4286,6 +4327,9 @@ packages: resolution: {integrity: sha512-fU1Txz7Budmvamp7bshe4Zi32d0ll7ect+ccxNu9FlObT605GOEB8BfO4tmRJ39R5Zj831VCpvQ05QPBW5yb+w==} peerDependencies: react: ^18.1.0 + peerDependenciesMeta: + react: + optional: true dependencies: loose-envify: 1.4.0 react: 18.1.0 @@ -4296,6 +4340,9 @@ packages: engines: {node: '>=10', npm: '>=6'} peerDependencies: react: '>=16.13.1' + peerDependenciesMeta: + react: + optional: true dependencies: '@babel/runtime': 7.17.9 react: 18.1.0 @@ -4315,6 +4362,11 @@ packages: peerDependencies: react: '>=16.8' react-dom: '>=16.8' + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true dependencies: history: 5.3.0 react: 18.1.0 @@ -4326,10 +4378,12 @@ packages: resolution: {integrity: sha512-7Wh1DzVQ+tlFjkeo+ujvjSqSJmkt1+8JO+T5xklPlgrh70y7ogx75ODRW0ThWhY7S+6yEDks8TYrtQe/aoboBQ==} peerDependencies: react: '>=16.8' + peerDependenciesMeta: + react: + optional: true dependencies: history: 5.3.0 react: 18.1.0 - dev: false /react/18.1.0: resolution: {integrity: sha512-4oL8ivCz5ZEPyclFQXaNksK3adutVS8l2xzZU0cqEFrE9Sb7fC0EFK5uEk74wIreL1DERyjvsU915j1pcT2uEQ==}