(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 (
-
- );
- }
-
- 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 (
+
+ );
+}
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 (
+
+ );
+}
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') &&