Skip to content

Commit

Permalink
fix(react): form values onChange and onSubmit
Browse files Browse the repository at this point in the history
  • Loading branch information
rabelloo committed May 7, 2021
1 parent 351ea2b commit 7047e07
Show file tree
Hide file tree
Showing 4 changed files with 221 additions and 28 deletions.
2 changes: 1 addition & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{ "varsIgnorePattern": "^_" }
{ "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }
]
},
"overrides": [
Expand Down
12 changes: 7 additions & 5 deletions packages/react/src/components/form/form.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,13 +111,16 @@ export const Playground: Story<FormProps<Values>> = (props) => (
}}
>
<FieldsetLegend>How did you hear about us?</FieldsetLegend>
<Radio name="source">
<Radio name="source" value="social-media">
Social media
<HelperText>Facebook, Twitter, TikTok, etc...</HelperText>
</Radio>
<Radio name="source">Search engine</Radio>
<Radio name="source">Word of mouth</Radio>
<Radio name="source">Other...</Radio>
<Radio name="source" value="word-of-mouth">
Word of mouth
</Radio>
<Radio name="source" value="other">
Other...
</Radio>
</Fieldset>

<Field>
Expand All @@ -142,7 +145,6 @@ export const Playground: Story<FormProps<Values>> = (props) => (
Please confirm you agree
</Validation>
</Field>

<Button>Send email</Button>
</Form>
);
162 changes: 150 additions & 12 deletions packages/react/src/components/form/getFormValues.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,158 @@ import { describe, expect, it } from '@jest/globals';
import { getFormValues } from './getFormValues';

describe('getFormValues', () => {
it('should return an object with [name, value] pairs from elements in a <form>', () => {
const form = {
elements: [
{ name: 'foo', value: {} },
{ value: 1 },
{ name: 'bar', value: [] },
],
};
it('should return an object with { name: value } pairs from elements in a <form>', () => {
const elements = [
{ name: 'foo', value: 1 },
{ name: 'bar', value: 2 },
];

const result = getFormValues(form as any);
const result = getFormValues({ elements } as any);

expect(result).toStrictEqual({
foo: form.elements[0].value,
bar: form.elements[2].value,
expect(result).toStrictEqual({ foo: 1, bar: 2 });
});

it('should filter out elements with no name', () => {
const elements = [{ name: 'foo', value: 'foo' }, { value: 'bar' }];

const result = getFormValues({ elements } as any);

expect(result).toStrictEqual({ foo: 'foo' });
});

it('should return an array of "value"s for several elements with the same name', () => {
const elements = [
{ name: 'name', value: 'foo' },
{ name: 'name', value: 'bar' },
];

const result = getFormValues({ elements } as any);

expect(result).toStrictEqual({ name: ['foo', 'bar'] });
});

describe('<input type="number" />', () => {
const number = { type: 'number' };

it('should cast "value"s from strings to numbers', () => {
const elements = [{ ...number, name: 'foo', value: '0' }];

const result = getFormValues({ elements } as any);

expect(result).toStrictEqual({ foo: 0 });
});

it('should return null when "value" is unset', () => {
const elements = [{ ...number, name: 'foo' }];

const result = getFormValues({ elements } as any);

expect(result).toStrictEqual({ foo: null });
});

it('should return null when "value" is non-numeric', () => {
const elements = [{ ...number, name: 'foo', value: 'e' }];

const result = getFormValues({ elements } as any);

expect(result).toStrictEqual({ foo: null });
});
});

describe('<input type="checkbox" />', () => {
const checkbox = { hasAttribute, type: 'checkbox' };

it('should return "checked" when "value" is unset', () => {
const elements = [
{ ...checkbox, name: 'foo', checked: true },
{ ...checkbox, name: 'bar', checked: false },
];

const result = getFormValues({ elements } as any);

expect(result).toStrictEqual({ foo: true, bar: false });
});

it('should return "value" when defined, depending on "checked"', () => {
const elements = [
{ ...checkbox, name: 'foo', value: 'foo', checked: true },
{ ...checkbox, name: 'bar', value: 'bar' },
];

const result = getFormValues({ elements } as any);

expect(result).toStrictEqual({ foo: 'foo', bar: null });
});
});

describe('<input type="radio" />', () => {
const radio = { hasAttribute, type: 'radio' };

it('should find the "checked" element and return its "value" if set', () => {
const elements = [
{ ...radio, name: 'a', value: 'foo' },
{ ...radio, name: 'a', value: 'bar', checked: true },
];

const result = getFormValues({ elements } as any);

expect(result).toStrictEqual({ a: 'bar' });
});

it('should find the "checked" element and return an empty string if "value" is unset', () => {
const elements = [
{ ...radio, name: 'foo' },
{ ...radio, name: 'foo', checked: true },
];

const result = getFormValues({ elements } as any);

expect(result).toStrictEqual({ foo: '' });
});

it('should return "null" if there is no "checked" element', () => {
const elements = [
{ ...radio, name: 'a', value: 'foo' },
{ ...radio, name: 'a', value: 'bar' },
];

const result = getFormValues({ elements } as any);

expect(result).toStrictEqual({ a: null });
});
});

describe('<select multiple />', () => {
const selectMultiple = { multiple: true };

it('should filter "selected" options', () => {
const elements = [
{
...selectMultiple,
name: 'a',
options: [
{ selected: true, value: 'foo' },
{ selected: false, value: 'bar' },
{ selected: true, value: 'zed' },
],
},
];

const result = getFormValues({ elements } as any);

expect(result).toStrictEqual({ a: ['foo', 'zed'] });
});

it('should return an empty array for a select multiple with no options', () => {
const elements = [{ ...selectMultiple, name: 'foo' }];

const result = getFormValues({ elements } as any);

expect(result).toStrictEqual({ foo: [] });
});
});
});

function hasAttribute(attr: string) {
return attr in this;
}
73 changes: 63 additions & 10 deletions packages/react/src/components/form/getFormValues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,73 @@
* <input name="foo" value="bar" />
* </form>
*
* getFormValues(formRef);
* getFormValues(formRef.current);
* // { foo: 'bar' }
*/
export const getFormValues = <T extends Result>(form: HTMLFormElement): T =>
Object.fromEntries(
(Array.from(form.elements) as HTMLNamedElement[])
.filter((el) => !!el.name)
.map((el) => [el.name, el.value])
export function getFormValues<T extends Values>(form: HTMLFormElement): T {
const elementsByName = groupBy(
Array.from(form.elements) as NamedElement[],
(el) => el.name || ''
);

return Object.fromEntries(
Object.entries(elementsByName)
.filter(([name]) => name)
.map(([name, elements]) => [name, getValue(elements)])
) as T;
}

const groupBy = <T>(array: T[], keyFn: (item: T, index: number) => string) =>
array.reduce((obj, item, index) => {
const key = keyFn(item, index);
(obj[key] ??= []).push(item);
return obj;
}, {} as Record<string, T[]>);

function getValue(elements: NamedElement[]) {
if (elements.some(isRadio))
return valueOf(elements.find((el) => el.checked)) as string | null;

if (elements.length > 1) return elements.map(valueOf).filter(Boolean);

const [element] = elements;

if (isSelectMultiple(element))
return Array.from(element.options ?? [])
.filter((opt) => opt.selected)
.map((opt) => opt.value) as string[];

return valueOf(element);
}

function valueOf(el: NamedElement | undefined) {
if (!el) return null;

if (isCheckbox(el))
if (!el.hasAttribute('value')) return el.checked;
else return el.checked ? el.value : null;

if (isRadio(el)) return el.value || '';

if (isNumber(el)) return el.value?.length ? toNumber(el.value) : null;

return el.value;
}

const isCheckbox = (el: NamedElement) => el.type === 'checkbox';
const isNumber = (el: NamedElement) => el.type === 'number';
const isRadio = (el: NamedElement) => el.type === 'radio';
const isSelectMultiple = (el: NamedElement) => el.multiple;
const toNumber = (value: string) => (+value === 0 ? 0 : +value || null);

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Result = Record<string, any>;
type Values = Record<string, any>;

interface HTMLNamedElement extends HTMLElement {
name: string;
value: string;
interface NamedElement extends HTMLElement {
checked?: boolean;
multiple?: boolean;
name?: string;
options?: HTMLOptionElement[];
type?: string;
value?: string;
}

0 comments on commit 7047e07

Please sign in to comment.