Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(react): form values onChange and onSubmit #635

Merged
merged 3 commits into from
May 11, 2021
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
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>

rabelloo marked this conversation as resolved.
Show resolved Hide resolved
<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;
}