From 5101ddaa7d5c9e18e4c3042799b62fa9b4129cea Mon Sep 17 00:00:00 2001 From: oluwatimio Date: Thu, 8 Apr 2021 20:28:13 -0400 Subject: [PATCH] reset function and dirty state test baselist Update baselist.test.tsx lint fix and additional tests update tests Update CHANGELOG.md Update README.md Update CHANGELOG.md add dynamic list to the form update comments Update form.ts add check for input since theres a possibility of adding undefined input dynamicListFields Update baselist.ts Update baselist.ts added tests to form Update form.test.tsx fixed reset recreating on each render Update listdirty.ts Update README.md Update README.md Update README.md added DynamicListBag Update packages/react-form/README.md Co-authored-by: Sylvain Hamann Update packages/react-form/README.md Co-authored-by: Sylvain Hamann Update README.md Update baselist.ts docs used lazy ref fix docs more improvement to docs return default bag on undefined dynamic list Update form.test.tsx rebase gone bad fix improve dynamicLists typing (#1848) * improve dynamicLists typing * revert to initial tests * mock console.error to clean logs * add dirty tests for dynamic lists * update form tests * remove useless file Update dynamiclist.test.tsx --- packages/react-form/CHANGELOG.md | 4 +- packages/react-form/README.md | 30 ++- packages/react-form/src/hooks/form.ts | 87 ++++++-- .../react-form/src/hooks/list/baselist.ts | 23 +- .../react-form/src/hooks/list/dynamiclist.ts | 12 +- .../src/hooks/list/dynamiclistdirty.ts | 7 + .../src/hooks/list/dynamiclistreset.ts | 19 ++ .../react-form/src/hooks/list/hooks/index.ts | 1 + .../src/hooks/list/hooks/reducer.ts | 16 +- packages/react-form/src/hooks/list/index.ts | 2 + .../src/hooks/list/test/baselist.test.tsx | 99 +++++++-- .../src/hooks/list/test/dynamiclist.test.tsx | 179 ++++++++++++--- .../test/form-with-dynamic-list.test.tsx | 145 ++++++++++++ .../react-form/src/hooks/test/form.test.tsx | 207 ++---------------- .../components/FormWithDynamicVariantList.tsx | 95 ++++++++ .../test/utilities/components/ProductForm.tsx | 82 +++++++ .../test/utilities/components/TextField.tsx | 35 +++ .../hooks/test/utilities/components/index.ts | 4 + .../hooks/test/utilities/components/types.ts | 13 ++ .../src/hooks/test/utilities/index.ts | 68 ++++++ packages/react-form/src/types.ts | 33 +++ packages/react-form/src/utilities.ts | 1 + 22 files changed, 892 insertions(+), 270 deletions(-) create mode 100644 packages/react-form/src/hooks/list/dynamiclistdirty.ts create mode 100644 packages/react-form/src/hooks/list/dynamiclistreset.ts create mode 100644 packages/react-form/src/hooks/test/form-with-dynamic-list.test.tsx create mode 100644 packages/react-form/src/hooks/test/utilities/components/FormWithDynamicVariantList.tsx create mode 100644 packages/react-form/src/hooks/test/utilities/components/ProductForm.tsx create mode 100644 packages/react-form/src/hooks/test/utilities/components/TextField.tsx create mode 100644 packages/react-form/src/hooks/test/utilities/components/index.ts create mode 100644 packages/react-form/src/hooks/test/utilities/components/types.ts create mode 100644 packages/react-form/src/hooks/test/utilities/index.ts diff --git a/packages/react-form/CHANGELOG.md b/packages/react-form/CHANGELOG.md index 0c4f01d669..0e21b8804a 100644 --- a/packages/react-form/CHANGELOG.md +++ b/packages/react-form/CHANGELOG.md @@ -5,7 +5,9 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). - +## Unreleased + +- Adds reset functionality, dirty state for dynamic list, and the ability to add multiple dynamic lists to a form by adding a dynamicLists parameter to useForm [#1828](https://github.com/Shopify/quilt/pull/1828) ## 0.12.8 - 2021-04-13 diff --git a/packages/react-form/README.md b/packages/react-form/README.md index 06e67d9851..a5bf79d452 100644 --- a/packages/react-form/README.md +++ b/packages/react-form/README.md @@ -652,7 +652,7 @@ const emptyCardFactory = (): Card => ({ cvv: '', }); -const {fields, addItem, removeItem, moveItem} = useDynamicList( +const {fields, addItem, removeItem, moveItem, reset, dirty} = useDynamicList( [{cardNumber: '4242 4242 4242 4242', cvv: '000'}], emptyCardFactory, ); @@ -672,7 +672,7 @@ const emptyCardFactory = (): Card[] => [ }, ]; -const {fields, addItem, removeItem, moveItem} = useDynamicList( +const {fields, addItem, removeItem, moveItem, reset, dirty} = useDynamicList( [{cardNumber: '4242 4242 4242 4242', cvv: '000'}], emptyCardFactory, ); @@ -717,6 +717,9 @@ You can choose to initialize the list with an existing number of cards or no car Move Item Down + )); } @@ -742,12 +745,29 @@ You can then use this argument to do as you wish :). Yes this works out of the box with `useForm` and is compatible with what `useList` is compatible with. -We would utilize it the following way +You would utilize it the following way in order to use the forms reset and dirty state for the `DynamicList` as well as others fields. + +```tsx +const {submit, dirty, submitting, reset} = useForm({ + fields: { + title: useField('') + }, + dynamicLists: { + customerCards: useDynamicList([{cardNumber: '4242 4242 4242 4242', cvv: '422'}], emptyCardFactory) + }, + onSubmit: async fieldValues => { + console.log(fieldValues); + return {status: 'success'}; + }, +}); +``` + +you can directly pass a dynamic list within `fields` but note that the `reset` function returned by `useForm` will only reset each field, plus the `dirty` state will only reflect dirty fields within each item. ```tsx -const {submit, dirty, submitting} = useForm({ +const {submit, dirty, submitting, reset} = useForm({ fields: { - fields, // the fields returned from useDynamicList. + fields, // the fields returned from useDynamicList. }, onSubmit: async fieldValues => { console.log(fieldValues); diff --git a/packages/react-form/src/hooks/form.ts b/packages/react-form/src/hooks/form.ts index 4e2a238fe7..9765c41bf1 100755 --- a/packages/react-form/src/hooks/form.ts +++ b/packages/react-form/src/hooks/form.ts @@ -1,11 +1,21 @@ -import {useCallback, useRef} from 'react'; +import {useCallback, useMemo} from 'react'; +import {useLazyRef} from '@shopify/react-hooks'; -import {SubmitHandler, FormMapping, FieldBag, Form} from '../types'; +import { + FieldBag, + FormInput, + FormWithDynamicListsInput, + FormWithoutDynamicListsInput, + Form, + FormWithDynamicLists, + DynamicListBag, +} from '../types'; import {validateAll, makeCleanFields} from '../utilities'; import {useDirty} from './dirty'; import {useReset} from './reset'; import {useSubmit} from './submit'; +import {useDynamicListDirty, useDynamicListReset} from './list'; /** * A custom hook for managing the state of an entire form. `useForm` wraps up many of the other hooks in this package in one API, and when combined with `useField` and `useList`, allows you to easily build complex forms with smart defaults for common cases. @@ -17,14 +27,18 @@ import {useSubmit} from './submit'; * import {useField, useForm} from '@shopify/react-form'; * * function MyComponent() { - * const { fields, submit, submitting, dirty, reset, submitErrors } = useForm({ + * const { fields, submit, submitting, dirty, reset, submitErrors, dynamicLists} = useForm({ * fields: { * title: useField('some default title'), * }, + * dynamicLists: { + * customerPaymentMethods: useDynamicList([{cardNumber: '4242 4242 4242 4242', cvv: '422'}], emptyCardFactory) + * } * onSubmit: (fieldValues) => { * return {status: "fail", errors: [{message: 'bad form data'}]} * } * }); + * const {customerPaymentMethods: {fields: paymentMethodFields, addItem, removeItem}} = dynamicLists * * return ( *
@@ -41,8 +55,14 @@ import {useSubmit} from './submit'; * /> * {title.error &&

{title.error}

} * + * {paymentMethodFields.map((field, index) => { + * + * + * + * })} * * + * * * ); *``` @@ -50,36 +70,65 @@ import {useSubmit} from './submit'; * @param fields - A dictionary of `Field` objects, dictionaries of `Field` objects, and lists of dictionaries of `Field` objects. Generally, you'll want these to be generated by the other hooks in this package, either `useField` or `useList`. This will be returned back out as the `fields` property of the return value. * * @param onSubmit - An async function to handle submission of the form. If this function returns an object of `{status: 'fail', error: FormError[]}` then the submission is considered a failure. Otherwise, it should return an object with `{status: 'success'}` and the submission will be considered a success. `useForm` will also call all client-side validation methods for the fields passed to it. The `onSubmit` handler will not be called if client validations fails. + * @param dynamicLists - Pass in dynamic lists objects into the form + * @param makeCleanAfterSubmit * @returns An object representing the current state of the form, with imperative methods to reset, submit, validate, and clean. Generally, the returned properties correspond 1:1 with the specific hook/utility for their functionality. * * @remarks * **Building your own:** Internally, `useForm` is a convenience wrapper over `useDirty`, `useReset`, and `useSubmit`. If you only need some of its functionality, consider building a custom hook combining a subset of them. * **Subforms:** You can have multiple `useForm`s wrapping different subsets of a group of fields. Using this you can submit subsections of the form independently and have all the error and dirty tracking logic "just work" together. */ + export function useForm({ fields, onSubmit, + makeCleanAfterSubmit, +}: FormWithoutDynamicListsInput): Form; + +export function useForm({ + fields, + dynamicLists, + onSubmit, + makeCleanAfterSubmit, +}: FormWithDynamicListsInput): FormWithDynamicLists; + +export function useForm({ + fields, + dynamicLists, + onSubmit, makeCleanAfterSubmit = false, -}: { - fields: T; - onSubmit?: SubmitHandler>; - makeCleanAfterSubmit?: boolean; -}): Form { - const dirty = useDirty(fields); - const basicReset = useReset(fields); +}: FormInput) { + const fieldsWithLists = useMemo(() => { + if (dynamicLists) { + const fieldsWithList = {...fields}; + Object.entries(dynamicLists).forEach(([key, value]) => { + (fieldsWithList as any)[key] = value.fields; + }); + return fieldsWithList; + } + return fields; + }, [dynamicLists, fields]); + + const dirty = useDirty(fieldsWithLists); + const basicReset = useReset(fieldsWithLists); + + const dynamicListDirty = useDynamicListDirty(dynamicLists); + const dynamicListReset = useDynamicListReset(dynamicLists); + const {submit, submitting, errors, setErrors} = useSubmit( onSubmit, - fields, + fieldsWithLists, makeCleanAfterSubmit, ); const reset = useCallback(() => { setErrors([]); basicReset(); - }, [basicReset, setErrors]); + dynamicListReset(); + }, [basicReset, dynamicListReset, setErrors]); - const fieldsRef = useRef(fields); - fieldsRef.current = fields; + const fieldsRef = useLazyRef(() => fieldsWithLists); + fieldsRef.current = fieldsWithLists; const validate = useCallback(() => { return validateAll(fieldsRef.current); @@ -89,9 +138,9 @@ export function useForm({ fieldsRef, ]); - return { + const form: Form = { fields, - dirty, + dirty: dirty || dynamicListDirty, submitting, submit, reset, @@ -99,4 +148,10 @@ export function useForm({ makeClean, submitErrors: errors, }; + + if (dynamicLists) { + return {...form, dynamicLists}; + } + + return form; } diff --git a/packages/react-form/src/hooks/list/baselist.ts b/packages/react-form/src/hooks/list/baselist.ts index c1f9af1e8b..9dfc199663 100644 --- a/packages/react-form/src/hooks/list/baselist.ts +++ b/packages/react-form/src/hooks/list/baselist.ts @@ -8,12 +8,14 @@ import { ListValidationContext, } from '../../types'; import {mapObject, normalizeValidation} from '../../utilities'; +import {useDirty} from '../dirty'; import { useHandlers, useListReducer, ListAction, reinitializeAction, + resetListAction, } from './hooks'; /* @@ -42,6 +44,8 @@ export interface FieldListConfig { interface BaseList { fields: FieldDictionary[]; dispatch: React.Dispatch>; + reset(): void; + dirty: boolean; } export function useBaseList( @@ -73,6 +77,10 @@ export function useBaseList( [validates, ...validationDependencies], ); + function reset() { + dispatch(resetListAction()); + } + const handlers = useHandlers(state, dispatch, validationConfigs); const fields: FieldDictionary[] = useMemo(() => { @@ -86,5 +94,18 @@ export function useBaseList( }); }, [state.list, handlers]); - return {fields, dispatch}; + const listWithoutFieldStates: Item[] = useMemo(() => { + return state.list.map(item => { + return mapObject(item, field => field.value); + }); + }, [state.list]); + + const isBaseListDirty = useMemo( + () => !isEqual(listWithoutFieldStates, state.initial), + [listWithoutFieldStates, state.initial], + ); + + const fieldsDirty = useDirty({fields}); + + return {fields, dispatch, reset, dirty: fieldsDirty || isBaseListDirty}; } diff --git a/packages/react-form/src/hooks/list/dynamiclist.ts b/packages/react-form/src/hooks/list/dynamiclist.ts index 9f446a4b5f..06a6b9f333 100644 --- a/packages/react-form/src/hooks/list/dynamiclist.ts +++ b/packages/react-form/src/hooks/list/dynamiclist.ts @@ -7,11 +7,13 @@ import { } from './hooks'; import {useBaseList, FieldListConfig} from './baselist'; -interface DynamicList { +export interface DynamicList { fields: FieldDictionary[]; addItem(factoryArgument?: any): void; removeItem(index: number): void; moveItem(fromIndex: number, toIndex: number): void; + reset(): void; + dirty: boolean; } type FactoryFunction = ( @@ -32,7 +34,10 @@ export function useDynamicList( fieldFactory: FactoryFunction, validationDependencies: unknown[] = [], ): DynamicList { - const {fields, dispatch} = useBaseList(listOrConfig, validationDependencies); + const {fields, dispatch, reset, dirty} = useBaseList( + listOrConfig, + validationDependencies, + ); function addItem(factoryArgument?: any) { const itemToAdd = fieldFactory(factoryArgument); @@ -51,5 +56,6 @@ export function useDynamicList( function removeItem(index: number) { dispatch(removeFieldItemAction(index)); } - return {fields, addItem, removeItem, moveItem}; + + return {fields, addItem, removeItem, moveItem, reset, dirty}; } diff --git a/packages/react-form/src/hooks/list/dynamiclistdirty.ts b/packages/react-form/src/hooks/list/dynamiclistdirty.ts new file mode 100644 index 0000000000..e33470c312 --- /dev/null +++ b/packages/react-form/src/hooks/list/dynamiclistdirty.ts @@ -0,0 +1,7 @@ +import {DynamicListBag} from '../../types'; + +export function useDynamicListDirty(lists?: DynamicListBag) { + return lists + ? Object.entries(lists).some(([key]) => lists[key].dirty) + : false; +} diff --git a/packages/react-form/src/hooks/list/dynamiclistreset.ts b/packages/react-form/src/hooks/list/dynamiclistreset.ts new file mode 100644 index 0000000000..13603cdeca --- /dev/null +++ b/packages/react-form/src/hooks/list/dynamiclistreset.ts @@ -0,0 +1,19 @@ +import {useCallback} from 'react'; +import {useLazyRef} from '@shopify/react-hooks'; + +import {DynamicListBag} from '../../types'; + +export function useDynamicListReset(lists?: DynamicListBag) { + const listBagRef = useLazyRef(() => lists); + listBagRef.current = lists; + + return useCallback(() => { + return resetFields(listBagRef.current); + }, [listBagRef]); +} + +function resetFields(lists?: DynamicListBag) { + if (lists) { + Object.entries(lists).forEach(([key]) => lists[key].reset()); + } +} diff --git a/packages/react-form/src/hooks/list/hooks/index.ts b/packages/react-form/src/hooks/list/hooks/index.ts index 28972c623b..91297e7723 100644 --- a/packages/react-form/src/hooks/list/hooks/index.ts +++ b/packages/react-form/src/hooks/list/hooks/index.ts @@ -9,6 +9,7 @@ export { addFieldItemAction, moveFieldItemAction, removeFieldItemAction, + resetListAction, } from './reducer'; export type {ListAction, ListState} from './reducer'; diff --git a/packages/react-form/src/hooks/list/hooks/reducer.ts b/packages/react-form/src/hooks/list/hooks/reducer.ts index 3249fe859b..775a14174e 100644 --- a/packages/react-form/src/hooks/list/hooks/reducer.ts +++ b/packages/react-form/src/hooks/list/hooks/reducer.ts @@ -17,7 +17,8 @@ export type ListAction = | UpdateErrorAction | UpdateAction | ResetAction - | NewDefaultAction; + | NewDefaultAction + | ResetListAction; interface ReinitializeAction { type: 'reinitialize'; @@ -34,6 +35,10 @@ interface MoveFieldItemAction { payload: {fromIndex: number; toIndex: number}; } +interface ResetListAction { + type: 'resetList'; +} + interface RemoveFieldItemAction { type: 'removeFieldItem'; payload: {indexToRemove: number}; @@ -133,6 +138,12 @@ export function resetAction( }; } +export function resetListAction(): ResetListAction { + return { + type: 'resetList', + }; +} + export function newDefaultAction( payload: TargetedPayload, ): NewDefaultAction { @@ -233,6 +244,9 @@ function reduceList( return {...state, list: [...state.list]}; } + case 'resetList': { + return {...state, list: state.initial.map(initialListItemState)}; + } case 'update': case 'newDefaultValue': { const { diff --git a/packages/react-form/src/hooks/list/index.ts b/packages/react-form/src/hooks/list/index.ts index 3a58031926..d5f72861c3 100644 --- a/packages/react-form/src/hooks/list/index.ts +++ b/packages/react-form/src/hooks/list/index.ts @@ -1,2 +1,4 @@ export {useList} from './list'; export {useDynamicList} from './dynamiclist'; +export {useDynamicListDirty} from './dynamiclistdirty'; +export {useDynamicListReset} from './dynamiclistreset'; diff --git a/packages/react-form/src/hooks/list/test/baselist.test.tsx b/packages/react-form/src/hooks/list/test/baselist.test.tsx index d67019a8ad..917dab350e 100644 --- a/packages/react-form/src/hooks/list/test/baselist.test.tsx +++ b/packages/react-form/src/hooks/list/test/baselist.test.tsx @@ -17,26 +17,36 @@ import { describe('useBaseList', () => { function TestList(config: FieldListConfig) { - const {fields} = useBaseList(config); + const {fields, dirty, reset} = useBaseList(config); return ( -
    - {fields.map((fields, index) => ( -
  • - - - -
  • - ))} -
+ <> +
    + {fields.map((fields, index) => ( +
  • + + + +
  • + ))} +
+ + + ); } @@ -705,4 +715,57 @@ describe('useBaseList', () => { }); }); }); + + describe('reset and dirty', () => { + it('can reset base list', () => { + const price = '1.00'; + const variants: Variant[] = [ + { + price, + optionName: 'material', + optionValue: faker.commerce.productMaterial(), + }, + ]; + + const newPrice = faker.commerce.price(); + const wrapper = mount(); + wrapper.find(TextField, {name: 'price0'})!.trigger('onChange', newPrice); + + expect(wrapper).toContainReactComponent(TextField, { + name: 'price0', + value: newPrice, + }); + + wrapper.find('button')!.trigger('onClick'); + + expect(wrapper).toContainReactComponent(TextField, { + name: 'price0', + value: price, + }); + }); + + it('returns the expected dirty state', () => { + const variants: Variant[] = [ + { + price: '1.00', + optionName: 'material', + optionValue: faker.commerce.productMaterial(), + }, + ]; + + const newPrice = faker.commerce.price(); + const wrapper = mount(); + wrapper.find(TextField, {name: 'price0'})!.trigger('onChange', newPrice); + + expect(wrapper).toContainReactComponent('button', { + children: true, + }); + + wrapper.find('button')!.trigger('onClick'); + + expect(wrapper).toContainReactComponent('button', { + children: false, + }); + }); + }); }); diff --git a/packages/react-form/src/hooks/list/test/dynamiclist.test.tsx b/packages/react-form/src/hooks/list/test/dynamiclist.test.tsx index bffd718dfa..4cca8b162a 100644 --- a/packages/react-form/src/hooks/list/test/dynamiclist.test.tsx +++ b/packages/react-form/src/hooks/list/test/dynamiclist.test.tsx @@ -13,38 +13,48 @@ describe('useDynamicList', () => { return {price: '', optionName: '', optionValue: ''}; }; function DynamicListComponent(config: FieldListConfig) { - const {fields, addItem, removeItem, moveItem} = useDynamicList( - config, - factory, - ); + const { + fields, + addItem, + removeItem, + moveItem, + reset, + dirty, + } = useDynamicList(config, factory); return ( -
    - {fields.map((fields, index) => ( -
  • - - - -
  • - ))} - + + + ))} + +
+ - +

Dirty: {dirty.toString()}

+ ); } @@ -69,6 +79,9 @@ describe('useDynamicList', () => { }); it('will throw an error if new position is out of index range', () => { + const consoleErrorMock = jest.spyOn(console, 'error'); + consoleErrorMock.mockImplementation(); + const variants: Variant[] = [ {price: 'A', optionName: 'A', optionValue: 'A'}, {price: 'B', optionName: 'B', optionValue: 'B'}, @@ -82,6 +95,8 @@ describe('useDynamicList', () => { .findAll('button', {children: 'Move Variant up'})![0] .trigger('onClick'); }).toThrow('Failed to move item from 0 to -1'); + + consoleErrorMock.mockRestore(); }); it('can remove field', () => { @@ -104,8 +119,6 @@ describe('useDynamicList', () => { .find('button', {children: 'Add Variant'})! .trigger('onClick', clickEvent()); - // const addedTextField = wrapper.findAll(TextField)![0]; - expect(wrapper).toContainReactComponent(TextField, { name: 'price1', value: '', @@ -155,6 +168,110 @@ describe('useDynamicList', () => { wrapper.findAll('button', {children: 'Remove Variant'}), ).toHaveLength(0); }); + + describe('reset dynamic list', () => { + it('can reset a dynamic list after adding a field', () => { + const variants: Variant[] = randomVariants(1); + + const wrapper = mount(); + + wrapper + .find('button', {children: 'Add Variant'})! + .trigger('onClick', clickEvent()); + + expect(wrapper).toContainReactComponent(TextField, { + name: 'price1', + value: '', + }); + + wrapper + .find('button', {children: 'Reset'})! + .trigger('onClick', clickEvent()); + + expect(wrapper).not.toContainReactComponent(TextField, { + name: 'price1', + value: '', + }); + }); + + it('can reset a dynamic list after removing a field', () => { + const variants: Variant[] = randomVariants(1); + + const wrapper = mount(); + + wrapper + .find('button', {children: 'Remove Variant'})! + .trigger('onClick', clickEvent()); + + expect(wrapper).not.toContainReactComponent(TextField); + + wrapper + .find('button', {children: 'Reset'})! + .trigger('onClick', clickEvent()); + + expect(wrapper).toContainReactComponent(TextField); + }); + }); + + describe('dirty dynamic list', () => { + it('handles dirty state when changing the value of a field', () => { + const wrapper = mount( + , + ); + + expect(wrapper).toContainReactText('Dirty: false'); + + wrapper + .find(TextField, {name: 'price0'})! + .trigger('onChange', 'new value'); + + expect(wrapper).toContainReactText('Dirty: true'); + + wrapper + .find('button', {children: 'Reset'})! + .trigger('onClick', clickEvent()); + + expect(wrapper).toContainReactText('Dirty: false'); + }); + + it('handles dirty state when adding a field and resetting it', () => { + const wrapper = mount(); + + expect(wrapper).toContainReactText('Dirty: false'); + + wrapper + .find('button', {children: 'Add Variant'})! + .trigger('onClick', clickEvent()); + + expect(wrapper).toContainReactText('Dirty: true'); + + wrapper + .find('button', {children: 'Reset'})! + .trigger('onClick', clickEvent()); + + expect(wrapper).toContainReactText('Dirty: false'); + }); + + it('handles dirty state when removing a field and resetting it', () => { + const wrapper = mount( + , + ); + + expect(wrapper).toContainReactText('Dirty: false'); + + wrapper + .find('button', {children: 'Remove Variant'})! + .trigger('onClick', clickEvent()); + + expect(wrapper).toContainReactText('Dirty: true'); + + wrapper + .find('button', {children: 'Reset'})! + .trigger('onClick', clickEvent()); + + expect(wrapper).toContainReactText('Dirty: false'); + }); + }); }); describe('add mulitiple items with payload', () => { diff --git a/packages/react-form/src/hooks/test/form-with-dynamic-list.test.tsx b/packages/react-form/src/hooks/test/form-with-dynamic-list.test.tsx new file mode 100644 index 0000000000..7195e9b821 --- /dev/null +++ b/packages/react-form/src/hooks/test/form-with-dynamic-list.test.tsx @@ -0,0 +1,145 @@ +import React from 'react'; +import {mount} from '@shopify/react-testing'; + +import { + FormWithDynamicVariantList, + TextField, + isDirty, + fakeProduct, + hitSubmit, + hitReset, + waitForSubmit, +} from './utilities'; + +import {submitSuccess, submitFail} from '..'; + +describe('useForm with dynamic list', () => { + describe('dirty state', () => { + it('dirty state is false when no field has been changed', () => { + const wrapper = mount( + , + ); + + expect(isDirty(wrapper)).toBe(false); + }); + + it('dirty state is true when a new variant item has been added', () => { + const wrapper = mount( + , + ); + + wrapper.find('button', {children: 'Add item'}).trigger('onClick'); + + expect(isDirty(wrapper)).toBe(true); + }); + + it('dirty state is true when a variant item has been removed', () => { + const wrapper = mount( + , + ); + + wrapper.find('button', {children: 'Remove item'}).trigger('onClick'); + + expect(isDirty(wrapper)).toBe(true); + }); + + it('dirty state is true when a variant item has been edited', () => { + const wrapper = mount( + , + ); + + wrapper + .find(TextField, {label: 'price'}) + .trigger('onChange', 'next price'); + + expect(isDirty(wrapper)).toBe(true); + }); + }); + + describe('submit', () => { + it('validates dynamic list fields with their latest values before submitting and bails out if any fail', () => { + const submitSpy = jest.fn(() => Promise.resolve(submitSuccess())); + const product = fakeProduct(); + const wrapper = mount( + , + ); + + const textFields = wrapper.findAll(TextField, {label: 'option'}); + + textFields.forEach(textField => textField.trigger('onChange', '')); + hitSubmit(wrapper); + + expect(submitSpy).not.toHaveBeenCalled(); + + expect(wrapper).toContainReactComponentTimes( + TextField, + textFields.length, + { + error: 'Option name is required!', + }, + ); + }); + + it('propagates remote submission errors to matching fields', async () => { + const errors = [ + { + field: ['variants', '0', 'price'], + message: 'The server hates your price', + }, + ]; + const promise = Promise.resolve(submitFail(errors)); + const wrapper = mount( + promise} + />, + ); + + await waitForSubmit(wrapper, promise); + + expect(wrapper).toContainReactComponent(TextField, { + error: errors[0].message, + }); + }); + }); + + describe('reset', () => { + it('reset dynamic list after adding new item', () => { + const wrapper = mount( + , + ); + + wrapper.find('button', {children: 'Add item'}).trigger('onClick'); + + expect(wrapper).toContainReactComponentTimes(TextField, 3, { + label: 'option', + }); + + hitReset(wrapper); + + expect(wrapper).toContainReactComponentTimes(TextField, 2, { + label: 'option', + }); + expect(isDirty(wrapper)).toBe(false); + }); + + it('reset dynamic list after removing item', () => { + const wrapper = mount( + , + ); + + wrapper.find('button', {children: 'Remove item'}).trigger('onClick'); + + expect(wrapper).toContainReactComponentTimes(TextField, 1, { + label: 'option', + }); + + hitReset(wrapper); + + expect(wrapper).toContainReactComponentTimes(TextField, 2, { + label: 'option', + }); + expect(isDirty(wrapper)).toBe(false); + }); + }); +}); diff --git a/packages/react-form/src/hooks/test/form.test.tsx b/packages/react-form/src/hooks/test/form.test.tsx index f54d40eabc..a947d7353e 100644 --- a/packages/react-form/src/hooks/test/form.test.tsx +++ b/packages/react-form/src/hooks/test/form.test.tsx @@ -1,145 +1,21 @@ import React from 'react'; -import faker from 'faker'; import {mount} from '@shopify/react-testing'; -import {SubmitHandler} from '../../types'; -import {positiveNumericString, notEmpty} from '../../validation'; - -import {useList, useField, useForm, submitSuccess, submitFail} from '..'; - -interface SimpleProduct { - title: string; - description: string; - defaultVariant: { - optionName: string; - optionValue: string; - price: string; - }; - variants: { - id: string; - optionName: string; - optionValue: string; - price: string; - }[]; -} +import { + ProductForm, + TextField, + isDirty, + changeTitle, + fakeProduct, + hitSubmit, + hitReset, + hitClean, + waitForSubmit, +} from './utilities'; -describe('useForm', () => { - function ProductForm({ - data, - onSubmit, - makeCleanAfterSubmit, - }: { - data: SimpleProduct; - onSubmit?: SubmitHandler; - makeCleanAfterSubmit?: boolean; - }) { - const title = useField({ - value: data.title, - validates: notEmpty('Title is required!'), - }); - const description = useField(data.description); - - const defaultVariant = { - price: useField({ - value: data.defaultVariant.price, - validates: positiveNumericString('price must be a number'), - }), - optionName: useField(data.defaultVariant.optionName), - optionValue: useField(data.defaultVariant.optionValue), - }; - - const variants = useList({ - list: data.variants, - validates: { - price: positiveNumericString('price must be a number'), - }, - }); - - const {submit, submitting, dirty, reset, makeClean, submitErrors} = useForm( - { - fields: {title, description, defaultVariant, variants}, - onSubmit: onSubmit as any, - makeCleanAfterSubmit, - }, - ); - - return ( -
- {submitting &&

loading...

} - {submitErrors.length > 0 && - submitErrors.map(({message}) =>

{message}

)} - -
- - -
-
- - - -
- {variants.map(({price, optionName, optionValue, id}) => { - return ( -
- - - -
- ); - })} - - - -
- ); - } - - function isDirty(wrapper) { - try { - expect(wrapper).toContainReactComponent('button', { - type: 'reset', - disabled: false, - }); - expect(wrapper).toContainReactComponent('button', { - type: 'submit', - disabled: false, - }); - } catch { - return false; - } - return true; - } - - function changeTitle(wrapper, newTitle) { - wrapper.find(TextField, {label: 'title'})!.trigger('onChange', newTitle); - } - - function hitSubmit(wrapper) { - wrapper.find('button', {type: 'submit'})!.trigger('onClick', clickEvent()); - } - - async function waitForSubmit(wrapper, successPromise) { - hitSubmit(wrapper); - - await wrapper.act(async () => { - await successPromise; - }); - } - - function hitReset(wrapper) { - wrapper.find('button', {type: 'reset'})!.trigger('onClick', clickEvent()); - } - - function hitClean(wrapper) { - wrapper.find('button', {type: 'button'})!.trigger('onClick', clickEvent()); - } +import {submitSuccess, submitFail} from '..'; +describe('useForm', () => { describe('dirty state', () => { it('dirty state is false when no field has been changed', () => { const wrapper = mount(); @@ -446,60 +322,3 @@ describe('useForm', () => { }); }); }); - -interface TextFieldProps { - value: string; - label: string; - name?: string; - error?: string; - onChange(value): void; - onBlur(): void; -} - -function TextField({ - label, - name = label, - onChange, - onBlur, - value, - error, -}: TextFieldProps) { - return ( - <> - - {error &&

{error}

} - - ); -} - -function fakeProduct(): SimpleProduct { - return { - title: faker.commerce.product(), - description: faker.lorem.paragraph(), - defaultVariant: { - price: faker.commerce.price(), - optionName: 'material', - optionValue: faker.commerce.productMaterial(), - }, - variants: Array.from({length: 2}).map(() => ({ - id: faker.random.uuid(), - price: faker.commerce.price(), - optionName: faker.lorem.word(), - optionValue: faker.commerce.productMaterial(), - })), - }; -} - -function clickEvent() { - // we don't actually use these at all so it is ok to just return an empty object - return {} as any; -} diff --git a/packages/react-form/src/hooks/test/utilities/components/FormWithDynamicVariantList.tsx b/packages/react-form/src/hooks/test/utilities/components/FormWithDynamicVariantList.tsx new file mode 100644 index 0000000000..7065b8f66f --- /dev/null +++ b/packages/react-form/src/hooks/test/utilities/components/FormWithDynamicVariantList.tsx @@ -0,0 +1,95 @@ +import React from 'react'; + +import {SubmitHandler} from '../../../../types'; +import {notEmpty} from '../../../../validation'; +import {useDynamicList, useField, useForm} from '../../..'; + +import {SimpleProduct, Variant} from './types'; +import {TextField} from './TextField'; + +export function FormWithDynamicVariantList({ + data, + onSubmit, + makeCleanAfterSubmit, +}: { + data: SimpleProduct; + onSubmit?: SubmitHandler; + makeCleanAfterSubmit?: boolean; +}) { + const variantFactory = () => { + return { + id: Date.now().toString(), + price: '', + optionName: '', + optionValue: '', + }; + }; + + const { + fields: {title}, + dynamicLists: {variants}, + submit, + submitting, + dirty, + reset, + makeClean, + submitErrors, + } = useForm({ + fields: { + title: useField({ + value: data.title, + validates: notEmpty('Title is required!'), + }), + }, + dynamicLists: { + variants: useDynamicList( + { + list: data.variants, + validates: { + optionName: notEmpty('Option name is required!'), + }, + }, + variantFactory, + ), + }, + onSubmit: onSubmit as any, + makeCleanAfterSubmit, + }); + + return ( +
+ {submitting &&

loading...

} + {submitErrors.length > 0 && + // eslint-disable-next-line react/no-array-index-key + submitErrors.map(({message}, index) =>

{message}

)} + +
+ +
+ {variants.fields.map(({price, optionName, optionValue, id}, index) => { + return ( +
+ + + + +
+ ); + })} + + + + +
+ ); +} diff --git a/packages/react-form/src/hooks/test/utilities/components/ProductForm.tsx b/packages/react-form/src/hooks/test/utilities/components/ProductForm.tsx new file mode 100644 index 0000000000..ec224e1321 --- /dev/null +++ b/packages/react-form/src/hooks/test/utilities/components/ProductForm.tsx @@ -0,0 +1,82 @@ +import React from 'react'; + +import {SubmitHandler} from '../../../../types'; +import {positiveNumericString, notEmpty} from '../../../../validation'; +import {useList, useField, useForm} from '../../..'; + +import {SimpleProduct} from './types'; +import {TextField} from './TextField'; + +export function ProductForm({ + data, + onSubmit, + makeCleanAfterSubmit, +}: { + data: SimpleProduct; + onSubmit?: SubmitHandler; + makeCleanAfterSubmit?: boolean; +}) { + const title = useField({ + value: data.title, + validates: notEmpty('Title is required!'), + }); + const description = useField(data.description); + + const defaultVariant = { + price: useField({ + value: data.defaultVariant.price, + validates: positiveNumericString('price must be a number'), + }), + optionName: useField(data.defaultVariant.optionName), + optionValue: useField(data.defaultVariant.optionValue), + }; + + const variants = useList({ + list: data.variants, + validates: { + price: positiveNumericString('price must be a number'), + }, + }); + + const {submit, submitting, dirty, reset, makeClean, submitErrors} = useForm({ + fields: {title, description, defaultVariant, variants}, + onSubmit: onSubmit as any, + makeCleanAfterSubmit, + }); + + return ( +
+ {submitting &&

loading...

} + {submitErrors.length > 0 && + submitErrors.map(({message}) =>

{message}

)} + +
+ + +
+
+ + + +
+ {variants.map(({price, optionName, optionValue, id}) => { + return ( +
+ + + +
+ ); + })} + + + +
+ ); +} diff --git a/packages/react-form/src/hooks/test/utilities/components/TextField.tsx b/packages/react-form/src/hooks/test/utilities/components/TextField.tsx new file mode 100644 index 0000000000..7e9b26605f --- /dev/null +++ b/packages/react-form/src/hooks/test/utilities/components/TextField.tsx @@ -0,0 +1,35 @@ +import React from 'react'; + +interface TextFieldProps { + value: string; + label: string; + name?: string; + error?: string; + onChange(value): void; + onBlur(): void; +} + +export function TextField({ + label, + name = label, + onChange, + onBlur, + value, + error, +}: TextFieldProps) { + return ( + <> + + {error &&

{error}

} + + ); +} diff --git a/packages/react-form/src/hooks/test/utilities/components/index.ts b/packages/react-form/src/hooks/test/utilities/components/index.ts new file mode 100644 index 0000000000..b2f805fe7c --- /dev/null +++ b/packages/react-form/src/hooks/test/utilities/components/index.ts @@ -0,0 +1,4 @@ +export {FormWithDynamicVariantList} from './FormWithDynamicVariantList'; +export {ProductForm} from './ProductForm'; +export {TextField} from './TextField'; +export * from './types'; diff --git a/packages/react-form/src/hooks/test/utilities/components/types.ts b/packages/react-form/src/hooks/test/utilities/components/types.ts new file mode 100644 index 0000000000..52a3a0b0fe --- /dev/null +++ b/packages/react-form/src/hooks/test/utilities/components/types.ts @@ -0,0 +1,13 @@ +export interface Variant { + id: string; + optionName: string; + optionValue: string; + price: string; +} + +export interface SimpleProduct { + title: string; + description: string; + defaultVariant: Omit; + variants: Variant[]; +} diff --git a/packages/react-form/src/hooks/test/utilities/index.ts b/packages/react-form/src/hooks/test/utilities/index.ts new file mode 100644 index 0000000000..10b3b3c6e4 --- /dev/null +++ b/packages/react-form/src/hooks/test/utilities/index.ts @@ -0,0 +1,68 @@ +import faker from 'faker'; + +import {SimpleProduct, TextField} from './components'; + +export * from './components'; + +export function isDirty(wrapper) { + try { + expect(wrapper).toContainReactComponent('button', { + type: 'reset', + disabled: false, + }); + expect(wrapper).toContainReactComponent('button', { + type: 'submit', + disabled: false, + }); + } catch { + return false; + } + return true; +} + +export function changeTitle(wrapper, newTitle) { + wrapper.find(TextField, {label: 'title'})!.trigger('onChange', newTitle); +} + +export function hitSubmit(wrapper) { + wrapper.find('button', {type: 'submit'})!.trigger('onClick', clickEvent()); +} + +export async function waitForSubmit(wrapper, successPromise) { + hitSubmit(wrapper); + + await wrapper.act(async () => { + await successPromise; + }); +} + +export function hitReset(wrapper) { + wrapper.find('button', {type: 'reset'})!.trigger('onClick', clickEvent()); +} + +export function hitClean(wrapper) { + wrapper.find('button', {type: 'button'})!.trigger('onClick', clickEvent()); +} + +export function fakeProduct(): SimpleProduct { + return { + title: faker.commerce.product(), + description: faker.lorem.paragraph(), + defaultVariant: { + price: faker.commerce.price(), + optionName: 'material', + optionValue: faker.commerce.productMaterial(), + }, + variants: Array.from({length: 2}).map(() => ({ + id: faker.random.uuid(), + price: faker.commerce.price(), + optionName: faker.lorem.word(), + optionValue: faker.commerce.productMaterial(), + })), + }; +} + +export function clickEvent() { + // we don't actually use these at all so it is ok to just return an empty object + return {} as any; +} diff --git a/packages/react-form/src/types.ts b/packages/react-form/src/types.ts index 594af8bbec..d7ba034045 100755 --- a/packages/react-form/src/types.ts +++ b/packages/react-form/src/types.ts @@ -1,5 +1,7 @@ import {ChangeEvent} from 'react'; +import {DynamicList} from './hooks/list/dynamiclist'; + export type ErrorValue = string | undefined; export type DirtyStateComparator = ( defaultValue: Value, @@ -63,6 +65,24 @@ export type FieldDictionary = { [Key in keyof Record]: Field; }; +export interface FormWithoutDynamicListsInput { + fields: T; + onSubmit?: SubmitHandler>; + makeCleanAfterSubmit?: boolean; +} + +export interface FormWithDynamicListsInput< + T extends FieldBag, + D extends DynamicListBag +> extends FormWithoutDynamicListsInput { + dynamicLists: D; +} + +export interface FormInput + extends FormWithoutDynamicListsInput { + dynamicLists?: D; +} + export interface Form { fields: T; dirty: boolean; @@ -74,6 +94,13 @@ export interface Form { makeClean(): void; } +export interface FormWithDynamicLists< + T extends FieldBag, + D extends DynamicListBag +> extends Form { + dynamicLists: D; +} + export interface FormError { field?: string[] | null; message: string; @@ -97,6 +124,12 @@ export interface FieldBag { [key: string]: FieldOutput; } +export type DynamicListOutput = DynamicList; + +export interface DynamicListBag { + [key: string]: DynamicListOutput; +} + export interface SubmitHandler { (fields: Fields): Promise; } diff --git a/packages/react-form/src/utilities.ts b/packages/react-form/src/utilities.ts index ad6273fd41..d1f2e115a0 100755 --- a/packages/react-form/src/utilities.ts +++ b/packages/react-form/src/utilities.ts @@ -13,6 +13,7 @@ import { export function isField(input: any): input is Field { return ( + Boolean(input) && Object.prototype.hasOwnProperty.call(input, 'value') && Object.prototype.hasOwnProperty.call(input, 'onChange') && Object.prototype.hasOwnProperty.call(input, 'onBlur') &&