Skip to content
This repository was archived by the owner on Jan 10, 2025. It is now read-only.

[react-form | DynamicList] add reset function and dirty state #1828

Merged
merged 1 commit into from
Apr 21, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/react-form/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 -->
## 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

Expand Down
30 changes: 25 additions & 5 deletions packages/react-form/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand All @@ -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,
);
Expand Down Expand Up @@ -717,6 +717,9 @@ You can choose to initialize the list with an existing number of cards or no car
Move Item Down
</Button>
</div>
<Button disabled={!dirty} onClick={reset}>
Reset
</Button>
</FormLayout.Group>
));
}
Expand All @@ -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<Card>([{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);
Expand Down
87 changes: 71 additions & 16 deletions packages/react-form/src/hooks/form.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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<Card>([{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 (
* <form onSubmit={submit}>
Expand All @@ -41,45 +55,80 @@ import {useSubmit} from './submit';
* />
* {title.error && <p className="error">{title.error}</p>}
* </div>
* {paymentMethodFields.map((field, index) => {
* <TextField onChange={field.cardNumber.onChange} value={field.cardNumber.value} />
* <TextField onChange={field.cvv.onChange} value={field.cvv.value} />
* <button type="button" onClick={() => removeItem(index)}>Remove Payment Method</button>
* })}
* <button disabled={!dirty} onClick={reset}>Reset</button>
* <button type="submit" disabled={!dirty}>Submit</button>
* <button type="button" onClick={addItem}>Add Payment Method</button>
* </form>
* );
*```
*
* @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<T extends FieldBag>({
fields,
onSubmit,
makeCleanAfterSubmit,
}: FormWithoutDynamicListsInput<T>): Form<T>;

export function useForm<T extends FieldBag, D extends DynamicListBag>({
fields,
dynamicLists,
onSubmit,
makeCleanAfterSubmit,
}: FormWithDynamicListsInput<T, D>): FormWithDynamicLists<T, D>;

export function useForm<T extends FieldBag, D extends DynamicListBag>({
fields,
dynamicLists,
onSubmit,
makeCleanAfterSubmit = false,
}: {
fields: T;
onSubmit?: SubmitHandler<FormMapping<T, 'value'>>;
makeCleanAfterSubmit?: boolean;
}): Form<T> {
const dirty = useDirty(fields);
const basicReset = useReset(fields);
}: FormInput<T, D>) {
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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this was already here, but a note on the pattern:

This technically works but may stop working in future react updates. We recently removed similar patterns in other hooks in quilt and are considering adding a lint rule. It might be best to find an alternative way of doing this as a result.

Actually, looking at it closer, since we update it literally every render, why can't we just use the value directly?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh yes I think we can get rid of the ref since we update it, Thanks! Will try that

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noticing removing the ref causes the makeClean function to be re-created on every render so I switched to useLazyRef to avoid mutating refs directly during the render phase


const validate = useCallback(() => {
return validateAll(fieldsRef.current);
Expand All @@ -89,14 +138,20 @@ export function useForm<T extends FieldBag>({
fieldsRef,
]);

return {
const form: Form<T> = {
fields,
dirty,
dirty: dirty || dynamicListDirty,
submitting,
submit,
reset,
validate,
makeClean,
submitErrors: errors,
};

if (dynamicLists) {
return {...form, dynamicLists};
}

return form;
}
23 changes: 22 additions & 1 deletion packages/react-form/src/hooks/list/baselist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/*
Expand Down Expand Up @@ -42,6 +44,8 @@ export interface FieldListConfig<Item extends object> {
interface BaseList<Item extends object> {
fields: FieldDictionary<Item>[];
dispatch: React.Dispatch<ListAction<Item>>;
reset(): void;
dirty: boolean;
}

export function useBaseList<Item extends object>(
Expand Down Expand Up @@ -73,6 +77,10 @@ export function useBaseList<Item extends object>(
[validates, ...validationDependencies],
);

function reset() {
dispatch(resetListAction());
}

const handlers = useHandlers(state, dispatch, validationConfigs);

const fields: FieldDictionary<Item>[] = useMemo(() => {
Expand All @@ -86,5 +94,18 @@ export function useBaseList<Item extends object>(
});
}, [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};
}
12 changes: 9 additions & 3 deletions packages/react-form/src/hooks/list/dynamiclist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import {
} from './hooks';
import {useBaseList, FieldListConfig} from './baselist';

interface DynamicList<Item extends object> {
export interface DynamicList<Item extends object> {
fields: FieldDictionary<Item>[];
addItem(factoryArgument?: any): void;
removeItem(index: number): void;
moveItem(fromIndex: number, toIndex: number): void;
reset(): void;
dirty: boolean;
}

type FactoryFunction<Item extends object> = (
Expand All @@ -32,7 +34,10 @@ export function useDynamicList<Item extends object>(
fieldFactory: FactoryFunction<Item>,
validationDependencies: unknown[] = [],
): DynamicList<Item> {
const {fields, dispatch} = useBaseList(listOrConfig, validationDependencies);
const {fields, dispatch, reset, dirty} = useBaseList(
listOrConfig,
validationDependencies,
);

function addItem(factoryArgument?: any) {
const itemToAdd = fieldFactory(factoryArgument);
Expand All @@ -51,5 +56,6 @@ export function useDynamicList<Item extends object>(
function removeItem(index: number) {
dispatch(removeFieldItemAction(index));
}
return {fields, addItem, removeItem, moveItem};

return {fields, addItem, removeItem, moveItem, reset, dirty};
}
7 changes: 7 additions & 0 deletions packages/react-form/src/hooks/list/dynamiclistdirty.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import {DynamicListBag} from '../../types';

export function useDynamicListDirty(lists?: DynamicListBag) {
return lists
? Object.entries(lists).some(([key]) => lists[key].dirty)
: false;
}
19 changes: 19 additions & 0 deletions packages/react-form/src/hooks/list/dynamiclistreset.ts
Original file line number Diff line number Diff line change
@@ -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());
}
}
1 change: 1 addition & 0 deletions packages/react-form/src/hooks/list/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export {
addFieldItemAction,
moveFieldItemAction,
removeFieldItemAction,
resetListAction,
} from './reducer';

export type {ListAction, ListState} from './reducer';
Loading