Skip to content

Commit

Permalink
feat: observe dom update for form value
Browse files Browse the repository at this point in the history
  • Loading branch information
edmundhung committed Apr 1, 2024
1 parent 09142b1 commit 8722d59
Show file tree
Hide file tree
Showing 4 changed files with 202 additions and 7 deletions.
61 changes: 54 additions & 7 deletions packages/conform-dom/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ export type FormContext<
onInput(event: Event): void;
onBlur(event: Event): void;
onUpdate(options: Partial<FormOptions<Schema, FormError, FormValue>>): void;
observe(): () => void;
subscribe(
callback: () => void,
getSubject?: () => SubscriptionSubject | undefined,
Expand Down Expand Up @@ -825,6 +826,16 @@ export function createFormContext<
: shouldValidate === eventName;
}

function updateFormValue(form: HTMLFormElement) {
const formData = new FormData(form);
const result = getSubmissionContext(formData);

updateFormMeta({
...meta,
value: result.payload,
});
}

function onInput(event: Event) {
const element = resolveTarget(event);

Expand All @@ -833,13 +844,7 @@ export function createFormContext<
}

if (event.defaultPrevented || !willValidate(element, 'onInput')) {
const formData = new FormData(element.form);
const result = getSubmissionContext(formData);

updateFormMeta({
...meta,
value: result.payload,
});
updateFormValue(element.form);
} else {
dispatch({
type: 'validate',
Expand Down Expand Up @@ -999,6 +1004,47 @@ export function createFormContext<
});
}

function observe() {
const observer = new MutationObserver((mutations) => {
const form = getFormElement();

if (!form) {
return;
}

for (const mutation of mutations) {
const nodes =
mutation.type === 'childList'
? [...mutation.addedNodes, ...mutation.removedNodes]
: [mutation.target];

for (const node of nodes) {
const element = isFieldElement(node)
? node
: node instanceof HTMLElement
? node.querySelector<FieldElement>('input,select,textarea')
: null;

if (element?.form === form) {
updateFormValue(form);
return;
}
}
}
});

observer.observe(document, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['form', 'name'],
});

return () => {
observer.disconnect();
};
}

return {
get formId() {
return latestOptions.formId;
Expand All @@ -1017,5 +1063,6 @@ export function createFormContext<
subscribe,
getState,
getSerializedState,
observe,
};
}
2 changes: 2 additions & 0 deletions packages/conform-react/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,13 @@ export function useForm<
);

useSafeLayoutEffect(() => {
const disconnect = context.observe();
document.addEventListener('input', context.onInput);
document.addEventListener('focusout', context.onBlur);
document.addEventListener('reset', context.onReset);

return () => {
disconnect();
document.removeEventListener('input', context.onInput);
document.removeEventListener('focusout', context.onBlur);
document.removeEventListener('reset', context.onReset);
Expand Down
80 changes: 80 additions & 0 deletions playground/app/routes/dom-value.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import {
getCollectionProps,
getFormProps,
getInputProps,
useForm,
} from '@conform-to/react';
import { parseWithZod } from '@conform-to/zod';
import type { ActionArgs, LoaderArgs } from '@remix-run/node';
import { json } from '@remix-run/node';
import { Form, useActionData, useLoaderData } from '@remix-run/react';
import { z } from 'zod';
import { Playground, Field } from '~/components';

const schema = z.discriminatedUnion('type', [
z.object({
type: z.literal('message'),
message: z.string(),
}),
z.object({
type: z.literal('title'),
title: z.string(),
}),
]);

export async function loader({ request }: LoaderArgs) {
const url = new URL(request.url);

return {
noClientValidate: url.searchParams.get('noClientValidate') === 'yes',
};
}

export async function action({ request }: ActionArgs) {
const formData = await request.formData();
const submission = parseWithZod(formData, { schema });

return json(submission.reply());
}

export default function Example() {
const { noClientValidate } = useLoaderData<typeof loader>();
const lastResult = useActionData<typeof action>();
const [form, fields] = useForm({
lastResult,
defaultValue: {
type: 'message',
message: 'Hello',
},
onValidate: !noClientValidate
? ({ formData }) => parseWithZod(formData, { schema })
: undefined,
});

return (
<Form method="post" {...getFormProps(form)}>
<Playground title="Observe DOM Value" result={{ value: form.value }}>
<Field label="Type" meta={fields.type}>
{getCollectionProps(fields.type, {
type: 'radio',
options: ['title', 'message'],
}).map((props) => (
<label key={props.key}>
<input {...props} />
{props.value}
</label>
))}
</Field>
{fields.type.value === 'message' ? (
<Field key="message" label="Message" meta={fields.message}>
<input {...getInputProps(fields.message, { type: 'text' })} />
</Field>
) : fields.type.value === 'title' ? (
<Field key="title" label="Title" meta={fields.title}>
<input {...getInputProps(fields.title, { type: 'text' })} />
</Field>
) : null}
</Playground>
</Form>
);
}
66 changes: 66 additions & 0 deletions tests/integrations/dom-value.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { type Page, type Locator, test, expect } from '@playwright/test';
import { getPlayground } from '../helpers';

function getFieldset(form: Locator) {
return {
messageType: form.getByLabel('message', { exact: true }),
titleType: form.getByLabel('title', { exact: true }),
message: form.getByLabel('Message', { exact: true }),
title: form.getByLabel('Title', { exact: true }),
};
}

async function runTest(page: Page) {
const playground = getPlayground(page);
const fieldset = getFieldset(playground.container);

await expect.poll(playground.result).toEqual({
value: {
type: 'message',
message: 'Hello',
},
});

await fieldset.message.fill('Test');
await expect.poll(playground.result).toEqual({
value: {
type: 'message',
message: 'Test',
},
});

await fieldset.titleType.click();
await expect.poll(playground.result).toEqual({
value: {
type: 'title',
},
});

await fieldset.title.pressSequentially('foobar');
await expect.poll(playground.result).toEqual({
value: {
type: 'title',
title: 'foobar',
},
});

await fieldset.messageType.click();
await expect.poll(playground.result).toEqual({
value: {
type: 'message',
message: 'Hello',
},
});
}

test.describe('With JS', () => {
test('Client Validation', async ({ page }) => {
await page.goto('/dom-value');
await runTest(page);
});

test('Server Validation', async ({ page }) => {
await page.goto('/dom-value?noClientValidate=yes');
await runTest(page);
});
});

0 comments on commit 8722d59

Please sign in to comment.