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') &&