From 38ffc11c21000f2be88d91dacf251447ed4a94e3 Mon Sep 17 00:00:00 2001 From: Stu Kabakoff Date: Tue, 8 Oct 2024 10:24:45 -0400 Subject: [PATCH] DeepPartial, docs updates --- README.md | 27 +++++++--- docs/src/app/Intro.mdx | 5 +- docs/src/app/docs/api/page.mdx | 16 +++--- docs/src/app/docs/page.mdx | 2 +- docs/src/app/header.tsx | 2 +- docs/src/app/layout.tsx | 7 +-- docs/src/app/page.tsx | 2 +- docs/src/mdx-components.tsx | 7 +++ package.json | 9 +--- src/__tests__/initialization.spec.tsx | 4 +- src/__tests__/sequenceFollower.spec.tsx | 8 +-- src/base.ts | 4 +- src/hookForm.ts | 2 + src/testUtils/FormPagesTester.tsx | 8 +-- .../components/FormPageMultipleTester.tsx | 3 +- src/testUtils/components/FormPageTester.tsx | 4 +- src/testUtils/followSequence.ts | 9 +++- src/types.ts | 53 ++++++++++++------- src/utils.ts | 9 +++- 19 files changed, 111 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index b772d6e..51ac386 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# react-multi-page-form +![React Multi Page Form](https://stutrek.github.io/react-multi-page-form/Logo.svg "") This is a tool for managing the sequence and flow of multi-page workflows. Given a long series of screens, it can put them in the proper order makes it easy to show and hide screens based on previous input. @@ -6,7 +6,9 @@ Workflows can be composed, allowing you to reuse parts of a flow. This should be used in combination with a component library and validation schema to improve your form management. -All of the examples use react-hook-form, but any library could be used. +An integration with React Hook Form is provided, but the base could be used with any library. + +[View the docs](https://stutrek.github.io/react-multi-page-form/) ## Basic Usage @@ -61,6 +63,9 @@ const MyMultiPageForm = () => { ); }; ``` +[View the docs](https://stutrek.github.io/react-multi-page-form/) + + ### Pages and Sequences A **page** represents a single screen that will be shown to the user as part of this multi-step form. It can have as many fields or as few as you'd like. It can even have logic to show and hide fields built in. @@ -71,18 +76,20 @@ A **sequence** is an array of pages and sequences that represent a specific flow type FormPage = { id: string; // determines whether or not this page is needed - isRequired?: (data: Partial) => boolean | undefined + isRequired?: (data: DeepPartial) => boolean | undefined // determines if the page is already complete - isComplete: (data: Partial) => boolean; + isComplete: (data: DeepPartial) => boolean; // determines if this should be a final step in the flow - isFinal?: (data: Partial) => boolean; + isFinal?: (data: DeepPartial) => boolean; + // if you need to break the flow of the sequence, this makes that possible + alternateNextPage?: (data: DeepPartial) => Boolean // Mounted inputs are automatically validated. // If you need specific validation logic, put it here. - validate?: (data: Partial) => ErrorList | undefined; + validate?: (data: DeepPartial) => ErrorList | undefined; // callback on arrival - onArrive?: (data: Partial) => void; + onArrive?: (data: DeepPartial) => void; // callback on departure - onExit?: (data: Partial) => Promise | void; + onExit?: (data: DeepPartial) => Promise | void; // the component that will be rendered Component: (props: ComponentProps) => JSX.Element; }; @@ -96,6 +103,9 @@ export type FormSequence = { }; ``` +[View the docs](https://stutrek.github.io/react-multi-page-form/) + + ## A More Complete Example ```typescript @@ -163,5 +173,6 @@ export function MyMultiPageForm() { ); } ``` +[View the docs](https://stutrek.github.io/react-multi-page-form/) diff --git a/docs/src/app/Intro.mdx b/docs/src/app/Intro.mdx index 676577c..0b9416f 100644 --- a/docs/src/app/Intro.mdx +++ b/docs/src/app/Intro.mdx @@ -10,17 +10,18 @@ This example features a short form that displays additional pages only if the us ## Integration within the Ecosystem - + React Multi Page Form seamlessly integrates into the React ecosystem by focusing exclusively on sequence and flow management. It doesn’t handle UI rendering or data persistence, allowing developers to pair it with their preferred component libraries and state management tools. While it includes integration with React Hook Form, it can also work with Formik, Final Form, or any other React form library. ## Why Use React Multi Page Form? - **Streamline Long Forms:** Skip unnecessary pages dynamically based on user inputs. +- **Easy Testing:** Quickly see the list of pages used for any set of data, and render all pages at once. - **Simplified Navigation:** Implement next and previous buttons effortlessly. - **Dynamic Form Testing:** Quickly test and determine which forms are displayed to users depending on their selections. - **Resume Functionality:** Enable users to pick up where they left off, improving completion rates. - **Composable Workflows:** Combine multiple workflows into a single cohesive process, promoting reusability and modularity in form management. - **Great for AI:** Clear separation of concerns makes it easy for AI to generate the first draft of your sequences and forms. -Ready to simplify your multi-page forms? See the [getting started page](/docs) or check out the [GitHub repository](https://github.com/stutrek/react-multi-page-form). \ No newline at end of file +Ready to simplify your multi-page forms? See the [getting started page](/react-multi-page-form/docs) or check out the [GitHub repository](https://github.com/stutrek/react-multi-page-form). \ No newline at end of file diff --git a/docs/src/app/docs/api/page.mdx b/docs/src/app/docs/api/page.mdx index da2bde6..d57f1b3 100644 --- a/docs/src/app/docs/api/page.mdx +++ b/docs/src/app/docs/api/page.mdx @@ -20,17 +20,17 @@ type HookFormPage = { // these three are required id: string; // Unique identifier for the form page. Component: (props: ComponentProps) => JSX.Element; // React component to render the form page. - isComplete: (data: Partial) => boolean; // Checks if the user has already filled out this page. + isComplete: (data: DeepPartial) => boolean; // Checks if the user has already filled out this page. // these manage the sequence - isFinal?: (data: Partial) => boolean; // return true if this should be the final page of the form. - isRequired?: (data: Partial) => boolean | undefined; // Determines if this page is needed based on form data. Default: () => true - validate?: (data: Partial) => Promise; // Determines whether or not to continue. - alternateNextPage: (data: Partial) => pageId; // Useful if you need to override the default order of pages + isFinal?: (data: DeepPartial) => boolean; // return true if this should be the final page of the form. + isRequired?: (data: DeepPartial) => boolean | undefined; // Determines if this page is needed based on form data. Default: () => true + validate?: (data: DeepPartial) => Promise; // Determines whether or not to continue. + alternateNextPage?: (data: DeepPartial) => pageId; // Useful if you need to override the default order of pages // event handlers - onArrive?: (data: Partial) => void; // Function to execute upon arriving at this page. - onExit?: (data: Partial) => Promise | void; // Function to execute when exiting the page. + onArrive?: (data: DeepPartial) => void; // Function to execute upon arriving at this page. + onExit?: (data: DeepPartial) => Promise | void; // Function to execute when exiting the page. }; ``` @@ -63,7 +63,7 @@ A sequence contains pages or more sequences that represent a single workflow. Th export type FormSequence = { id: string; // Unique identifier for the form sequence. pages: HookFormSequenceChild[]; // A SequenceChild is either a FormPage or a FormSequence. - isRequired?: (data: Partial) => boolean | undefined; // Determines if the sequence is needed based on form data. + isRequired?: (data: DeepPartial) => boolean | undefined; // Determines if the sequence is needed based on form data. }; ``` diff --git a/docs/src/app/docs/page.mdx b/docs/src/app/docs/page.mdx index 545c77a..30eca4e 100644 --- a/docs/src/app/docs/page.mdx +++ b/docs/src/app/docs/page.mdx @@ -29,7 +29,7 @@ To allow consistent typing throughout the library, you need a single type for yo ### Schema -This is the shape the data takes for the entirety of the form. Not all of it will be on the screen at the same time, and you'll have a chance to save data when changing pages. This data will be Partial'd, since it won't all be available until the user is done providing it. +This is the shape the data takes for the entirety of the form. Not all of it will be on the screen at the same time, and you'll have a chance to save data when changing pages. This data will be DeepPartial'd, since it won't all be available until the user is done providing it. ```typescript type MySchema = { diff --git a/docs/src/app/header.tsx b/docs/src/app/header.tsx index 8fad397..91c382c 100644 --- a/docs/src/app/header.tsx +++ b/docs/src/app/header.tsx @@ -16,7 +16,7 @@ export function Header({ showLogo = true }: HeaderProps) {
  • React Multi Page Form diff --git a/docs/src/app/layout.tsx b/docs/src/app/layout.tsx index aa9ca9d..cda8b5d 100644 --- a/docs/src/app/layout.tsx +++ b/docs/src/app/layout.tsx @@ -9,10 +9,11 @@ export default function RootLayout({ }>) { return ( - - - {children} +
    +
    + © 2024 Stu Kabakoff +
    ); } diff --git a/docs/src/app/page.tsx b/docs/src/app/page.tsx index c5f0846..066d9ea 100644 --- a/docs/src/app/page.tsx +++ b/docs/src/app/page.tsx @@ -5,7 +5,7 @@ export default function () {
    React Multi Page Form diff --git a/docs/src/mdx-components.tsx b/docs/src/mdx-components.tsx index e49a5ea..63852af 100644 --- a/docs/src/mdx-components.tsx +++ b/docs/src/mdx-components.tsx @@ -2,6 +2,13 @@ import type { MDXComponents } from 'mdx/types'; export function useMDXComponents(components: MDXComponents): MDXComponents { return { + a: (props) => { + let href = props.href as string; + if (href?.startsWith('/')) { + href = `/react-multi-page-form${href}`; + } + return ; + }, ...components, }; } diff --git a/package.json b/package.json index 17cce29..535903c 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,7 @@ "version": "0.1.0", "description": "Tools to handle multi-page forms", "main": "dist/index.js", - "files": [ - "dist" - ], + "files": ["dist"], "scripts": { "build": "tsc", "build:watch": "tsc -w", @@ -16,10 +14,7 @@ "test": "jest", "test:watch": "jest --watch" }, - "keywords": [ - "forms", - "multi-page" - ], + "keywords": ["forms", "multi-page"], "author": "Stu Kabakoff", "license": "MIT", "devDependencies": { diff --git a/src/__tests__/initialization.spec.tsx b/src/__tests__/initialization.spec.tsx index 0c1b267..f43dcc4 100644 --- a/src/__tests__/initialization.spec.tsx +++ b/src/__tests__/initialization.spec.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { renderHook } from '@testing-library/react'; import { useMultiPageFormBase } from '../base'; -import { StartingPage } from '../types'; +import { type DeepPartial, StartingPage } from '../types'; describe('useMultiPageFormBase - Initialization Tests', () => { const pages = [ @@ -285,7 +285,7 @@ describe('useMultiPageFormBase - Initialization Tests', () => { { id: 'page2', isComplete: () => false, - isRequired: (data: Partial<{ skipPage2: boolean }>) => + isRequired: (data: DeepPartial<{ skipPage2: boolean }>) => !data.skipPage2, Component: () =>
    , }, diff --git a/src/__tests__/sequenceFollower.spec.tsx b/src/__tests__/sequenceFollower.spec.tsx index be730f2..c04ff56 100644 --- a/src/__tests__/sequenceFollower.spec.tsx +++ b/src/__tests__/sequenceFollower.spec.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { followSequence } from '../testUtils/followSequence'; -import type { SequenceChild } from '../types'; +import type { DeepPartial, SequenceChild } from '../types'; describe('followSequence', () => { beforeEach(() => { @@ -142,14 +142,14 @@ describe('followSequence', () => { const pages = [ { id: 'page1', - isRequired: (data: Partial) => + isRequired: (data: DeepPartial) => data.includePage1, isComplete: () => false, Component: () =>
    , }, { id: 'page2', - alternateNextPage: (data: Partial) => + alternateNextPage: (data: DeepPartial) => data.skipToPage4 ? 'page4' : undefined, isComplete: () => false, Component: () =>
    , @@ -209,7 +209,7 @@ describe('followSequence', () => { const pages = [ { id: 'page1', - isFinal: (data: Partial) => !!data.endHere, + isFinal: (data: DeepPartial) => !!data.endHere, isComplete: () => false, Component: () =>
    , }, diff --git a/src/base.ts b/src/base.ts index 2a5e0b9..4db06ee 100644 --- a/src/base.ts +++ b/src/base.ts @@ -6,12 +6,12 @@ import { useState, } from 'react'; import { StartingPage } from './types'; -import type { FormPage, MultiPageFormParams } from './types'; +import type { DeepPartial, FormPage, MultiPageFormParams } from './types'; import { flattenPages, useCallbackRef } from './utils'; function isRequired>( page: Page, - data: DataT, + data: DeepPartial, ) { if (page.isRequired === undefined || page.isRequired(data) !== false) { return true; diff --git a/src/hookForm.ts b/src/hookForm.ts index e0c3de3..5ca3b57 100644 --- a/src/hookForm.ts +++ b/src/hookForm.ts @@ -1,4 +1,5 @@ import type { + DeepPartial, FieldErrors, FieldValues, Path, @@ -124,6 +125,7 @@ export const useMultiPageHookForm = < const { trigger, reset, control } = hookForm; const multiPageForm = useMultiPageFormBase({ + // @ts-ignore getCurrentData: () => hookForm.getValues(), onBeforePageChange: async (data, page) => { if (onBeforePageChange) { diff --git a/src/testUtils/FormPagesTester.tsx b/src/testUtils/FormPagesTester.tsx index bd65e93..72a1448 100644 --- a/src/testUtils/FormPagesTester.tsx +++ b/src/testUtils/FormPagesTester.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import type { FormPage } from '../types'; // Adjust the import path as needed +import type { DeepPartial, FormPage } from '../types'; // Adjust the import path as needed import { FormPageMultipleTester } from './components/FormPageMultipleTester'; // Adjust the import path as needed import type { DefaultValues, FieldValues } from 'react-hook-form'; @@ -14,8 +14,8 @@ import type { DefaultValues, FieldValues } from 'react-hook-form'; * * @param {object} props - The component props. * @param {HookFormPage[]} props.pages - An array of `FormPage` objects to be tested. - * @param {Partial} props.sampleData - Sample data to use for testing. - * @param {Partial} [props.additionalProps] - Optional additional props to pass to the components. + * @param {DeepPartial} props.sampleData - Sample data to use for testing. + * @param {DeepPartial} [props.additionalProps] - Optional additional props to pass to the components. * @param {Resolver} [props.validator] - Optional React Hook Form resolver for validation. * * @returns {JSX.Element} The rendered component. @@ -28,7 +28,7 @@ export function FormPagesTester({ }: { pages: FormPage[]; sampleData: DefaultValues; - additionalProps?: Partial; + additionalProps?: DeepPartial; validator?: any; // Replace 'any' with the appropriate type for the validator }): JSX.Element { return ( diff --git a/src/testUtils/components/FormPageMultipleTester.tsx b/src/testUtils/components/FormPageMultipleTester.tsx index 12737f3..efba78c 100644 --- a/src/testUtils/components/FormPageMultipleTester.tsx +++ b/src/testUtils/components/FormPageMultipleTester.tsx @@ -1,6 +1,7 @@ import type { DefaultValues, FieldValues } from 'react-hook-form'; import { FormPageTester } from './FormPageTester'; import type { HookFormPage } from '../../hookForm'; +import type { DeepPartial } from '../../types'; export function FormPageMultipleTester< DataT extends FieldValues, @@ -13,7 +14,7 @@ export function FormPageMultipleTester< }: { page: HookFormPage; sampleData: DefaultValues; - additionalProps?: Partial; + additionalProps?: DeepPartial; validator?: any; // Replace 'any' with the appropriate type for the validator }): JSX.Element { return ( diff --git a/src/testUtils/components/FormPageTester.tsx b/src/testUtils/components/FormPageTester.tsx index 0e2ae01..64bbf3f 100644 --- a/src/testUtils/components/FormPageTester.tsx +++ b/src/testUtils/components/FormPageTester.tsx @@ -4,7 +4,7 @@ import { useForm, type UseFormReturn, } from 'react-hook-form'; -import type { FormPage } from '../../types'; // Adjust the import path as needed +import type { DeepPartial, FormPage } from '../../types'; // Adjust the import path as needed import { useLayoutEffect } from 'react'; export function FormPageTester({ @@ -16,7 +16,7 @@ export function FormPageTester({ }: { page: FormPage; defaultValues?: DefaultValues; - additionalProps?: Partial; + additionalProps?: DeepPartial; validator?: any; // Replace 'any' with the appropriate type for the validator shouldValidate?: boolean; }): JSX.Element { diff --git a/src/testUtils/followSequence.ts b/src/testUtils/followSequence.ts index 3a2a479..dbf46a9 100644 --- a/src/testUtils/followSequence.ts +++ b/src/testUtils/followSequence.ts @@ -1,4 +1,9 @@ -import type { FormPage, FormSequence, SequenceChild } from '../types'; +import type { + DeepPartial, + FormPage, + FormSequence, + SequenceChild, +} from '../types'; import { flattenPages } from '../utils'; /** @@ -11,7 +16,7 @@ import { flattenPages } from '../utils'; */ export function followSequence( sequence: SequenceChild[] | FormSequence, - data: DataT, + data: DeepPartial, ): FormPage[] { if (!Array.isArray(sequence)) { sequence = sequence.pages; diff --git a/src/types.ts b/src/types.ts index 9b8ab75..9566db9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -19,62 +19,62 @@ export type FormPage = { /** * Optional predicate to determine if this page is needed based on the current form data. * - * @param data - Partial form data available at the current step. + * @param data - DeepPartial form data available at the current step. * @returns A boolean indicating whether the page is needed. */ - isRequired?: (data: Partial) => boolean | undefined; + isRequired?: (data: DeepPartial) => boolean | undefined; /** * Function to determine if the page is complete based on the current form data. * This can be loose, it should not include validation. It is used to determine * what the next incomplete page is when navigating the form. * - * @param data - Partial form data available at the current step. + * @param data - DeepPartial form data available at the current step. * @returns A boolean indicating whether the page is complete. */ - isComplete: (data: Partial) => boolean; + isComplete: (data: DeepPartial) => boolean; /** * Optional predicate to determine if this page is the final page in the form sequence. * - * @param data - Partial form data available at the current step. + * @param data - DeepPartial form data available at the current step. * @returns A boolean indicating whether this is the final page. */ - isFinal?: (data: Partial) => boolean; + isFinal?: (data: DeepPartial) => boolean; /** * Optional function to validate the form data for this page. If using React Hook Form, * all fields in the DOM will automatically be vaidated. This is useful for further * validation that may be asynchronous or require multiple fields. * - * @param data - Partial form data available at the current step. + * @param data - DeepPartial form data available at the current step. * @returns An ErrorList if validation fails, or undefined if validation passes. */ - validate?: (data: Partial) => ErrorList | undefined; + validate?: (data: DeepPartial) => ErrorList | undefined; /** * Optional function to execute when arriving at this page. * - * @param data - Partial form data available at the current step. + * @param data - DeepPartial form data available at the current step. */ - onArrive?: (data: Partial) => void; + onArrive?: (data: DeepPartial) => void; /** * Optional function to execute when exiting this page. * - * @param data - Partial form data available at the current step. + * @param data - DeepPartial form data available at the current step. * @returns A Promise or void. If a Promise is returned, the form will wait for it to resolve. */ - onExit?: (data: Partial) => Promise | void; + onExit?: (data: DeepPartial) => Promise | void; /** * Function to determine the next page by ID. For use in special * circumstances where using a sequence is not possible or practical. * - * @param data - Partial form data available at the current step. + * @param data - DeepPartial form data available at the current step. * @returns The ID of the next page, or undefined if the next page should be the default. */ - alternateNextPage?: (data: Partial) => string | undefined; + alternateNextPage?: (data: DeepPartial) => string | undefined; /** * The React component that renders the content of this form page. @@ -106,10 +106,10 @@ export type FormSequence = { /** * Optional predicate to determine if this sequence is needed based on the current form data. * - * @param data - Partial form data available at the current step. + * @param data - DeepPartial form data available at the current step. * @returns A boolean indicating whether the sequence is needed. */ - isRequired?: (data: Partial) => boolean | undefined; + isRequired?: (data: DeepPartial) => boolean | undefined; }; export type SequenceChild = @@ -129,7 +129,7 @@ export type MultiPageFormParams = { * * @returns The current data of type DataT. */ - getCurrentData: () => DataT; + getCurrentData: () => DeepPartial; /** * An array of form pages or nested sequences that constitute the form workflow. @@ -152,7 +152,7 @@ export type MultiPageFormParams = { * @returns A Promise resolving to an ErrorList or a boolean indicating whether to proceed with the page change. */ onBeforePageChange?: ( - data: DataT, + data: DeepPartial, page: FormPage, ) => Promise | ErrorList | boolean; @@ -163,7 +163,7 @@ export type MultiPageFormParams = { * @param newPage - The new form page being navigated to. */ onPageChange?: ( - data: DataT, + data: DeepPartial, newPage: FormPage, ) => void; @@ -173,7 +173,7 @@ export type MultiPageFormParams = { * * @param data - The final form data. */ - onComplete?: (data: DataT) => void; + onComplete?: (data: DeepPartial) => void; /** * Callback function that is invoked when there are validation errors in the form. @@ -182,3 +182,16 @@ export type MultiPageFormParams = { */ onValidationError?: (errorList: ErrorList) => void; }; + +// https://pendletonjones.com/deep-partial +export type DeepPartial = unknown extends T + ? T + : T extends object + ? { + [P in keyof T]?: T[P] extends Array + ? Array> + : T[P] extends ReadonlyArray + ? ReadonlyArray> + : DeepPartial; + } + : T; diff --git a/src/utils.ts b/src/utils.ts index b9da200..5349884 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,10 @@ import { useCallback, useRef } from 'react'; -import type { FormPage, FormSequence, SequenceChild } from './types'; +import type { + DeepPartial, + FormPage, + FormSequence, + SequenceChild, +} from './types'; // this provides a stable function that always calls the latest callback. // it doesn't need a dependency array, and prevents memory leaks. @@ -32,7 +37,7 @@ function wrapChild( parent: FormSequence, ): SequenceChild { const newId = child.id; // `${parent.id}.${child.id}`; - const newIsRequired = (data: Partial) => { + const newIsRequired = (data: DeepPartial) => { if (parent.isRequired) { const parentIsRequired = parent.isRequired(data); if (parentIsRequired === false) {