Skip to content

Commit

Permalink
feat(react-router): initial package w/ Wizard, Step components (#20)
Browse files Browse the repository at this point in the history
  • Loading branch information
HipsterBrown authored May 5, 2022
1 parent 3e0bffd commit e918544
Show file tree
Hide file tree
Showing 13 changed files with 369 additions and 67 deletions.
2 changes: 1 addition & 1 deletion examples/react-router/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 11 additions & 6 deletions examples/react-router/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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<Values>();

const onSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
Expand Down Expand Up @@ -41,7 +46,7 @@ const First = () => {
}

const Second = () => {
const wizard = useWizardContext();
const wizard = useWizardContext<Values>();

const onSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
Expand Down Expand Up @@ -79,7 +84,7 @@ const Second = () => {
}

const Third = () => {
const wizard = useWizardContext();
const wizard = useWizardContext<Values>();

return (
<>
Expand Down Expand Up @@ -114,11 +119,11 @@ function App() {
<div className="px-5">
<h1 className="text-2xl font-bold">Robo Wizard w/ React-Router</h1>
<BrowserRouter>
<WizardProvider initialValues={{ firstName: '', lastName: '' }}>
<Wizard<Values> initialValues={{ firstName: '', lastName: '' }}>
<Step name="first" element={<First />} />
<Step name="second" element={<Second />} />
<Step name="third" element={<Third />} />
</WizardProvider>
</Wizard>
</BrowserRouter>
</div>
)
Expand Down
56 changes: 0 additions & 56 deletions examples/react-router/src/wizard-context.tsx

This file was deleted.

63 changes: 63 additions & 0 deletions packages/react-router/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
9 changes: 9 additions & 0 deletions packages/react-router/src/components/Step.tsx
Original file line number Diff line number Diff line change
@@ -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<RouteProps, 'path'> & StepConfig) {
return <Route path={name} {...routeProps} />;
}
1 change: 1 addition & 0 deletions packages/react-router/src/components/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./Step";
120 changes: 120 additions & 0 deletions packages/react-router/src/context/index.tsx
Original file line number Diff line number Diff line change
@@ -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 | RoboWizard>(null);

export type WizardProviderProps<Values extends object> = 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 (
* <BrowserRouter>
* <Wizard<Values> initialValues={{ firstName: '', lastName: '' }}>
* <Step name="first" element={<First />} />
* <Step name="second" element={<Second />} />
* <Step name="third" element={<Third />} />
* </Wizard>
* </BrowserRouter>
* )
* }
* ```
*
* An example step component:
* ```tsx
* const First = () => {
* const wizard = useWizardContext<Values>();
*
* const onSubmit = (event: FormEvent<HTMLFormElement>) => {
* event.preventDefault();
* const values = Object.fromEntries(new FormData(event.currentTarget))
* wizard.goToNextStep({ values })
* }
*
* return (
* <>
* <p>{wizard.currentStep} step</p>
* <form onSubmit={onSubmit}>
* <div>
* <label htmlFor="firstName" id="firstName-label">
* First Name:
* </label>
* <input
* type="text"
* name="firstName"
* id="firstName"
* aria-labelledby="firstName-label"
* defaultValue={wizard.currentValues.firstName}
* autoFocus={true}
* />
* </div>
* <div>
* <button type="button" onClick={() => wizard.goToPreviousStep()}role="link" >Previous</button>
* <button type="submit">Next</button>
* </div>
* </form>
* </>
* )
* }
* ```
**/
export function Wizard<Values extends object = BaseValues>({ children, initialValues = {} as Values }: WizardProviderProps<Values>) {
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 (
<WizardContext.Provider value={Object.create(wizard)}>
{step}
<Routes>
<Route index={true} element={<Navigate to={String(steps[0]?.name)} replace={true} />} />
</Routes>
</WizardContext.Provider>
)
}

/**
* @typeParam Values - object describing the [[currentValues]] gathered by [[RoboWizard]]
*
* Access the [[RoboWizard]] from the [[Wizard]] Context Provider
**/
export function useWizardContext<Values extends object = BaseValues>() {
const wizard = useContext(WizardContext);
if (wizard === null) throw new Error('useWizardContext must be used within WizardProvider')
return wizard as RoboWizard<Values>;
}
2 changes: 2 additions & 0 deletions packages/react-router/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./context";
export * from "./components";
47 changes: 47 additions & 0 deletions packages/react-router/test/index.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<h1>{wizard.currentStep} step</h1>
<button type="button" onClick={() => navigate(-1)}>Back</button>
<button type="button" onClick={() => wizard.goToNextStep()}>Next</button>
</>
)
}

describe('Wizard', () => {
const subject = () => (
<MemoryRouter>
<Wizard>
<Step name="first" element={<TestStep />} />
<Step name="second" element={<TestStep />} />
<Step name="third" element={<TestStep />} />
</Wizard>
</MemoryRouter>
)

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();
})
})
1 change: 1 addition & 0 deletions packages/react-router/test/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import '@testing-library/jest-dom'
23 changes: 23 additions & 0 deletions packages/react-router/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading

0 comments on commit e918544

Please sign in to comment.