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

feat: auto field value update #837

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
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
341 changes: 314 additions & 27 deletions packages/conform-dom/form.ts

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions packages/conform-dom/formdata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ export function flatten(
const resolve = options.resolve ?? ((data) => data);

function process(data: unknown, prefix: string) {
const value = normalize(resolve(data));
const value = resolve(data);

if (typeof value !== 'undefined') {
result[prefix] = value;
Expand All @@ -276,7 +276,7 @@ export function flatten(
}
}

if (data) {
if (typeof data !== 'undefined') {
process(data, options.prefix ?? '');
}

Expand Down
17 changes: 13 additions & 4 deletions packages/conform-dom/submission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,9 +175,18 @@ export function parse<FormValue, FormError>(

if (typeof intent.payload.value !== 'undefined') {
if (name) {
setValue(context.payload, name, () => value);
setValue(context.payload, name, (currentValue) => {
if (isPlainObject(currentValue)) {
return Object.assign({}, currentValue, value);
}

return value;
});
} else {
context.payload = value;
context.payload = {
...context.payload,
...value,
};
}
}
break;
Expand Down Expand Up @@ -505,10 +514,10 @@ export function setState(
resolve(data) {
if (isPlainObject(data) || Array.isArray(data)) {
// @ts-expect-error
return data[root] ?? null;
return normalize(data[root] ?? null);
}

return data;
return normalize(data);
},
prefix: name,
}),
Expand Down
6 changes: 5 additions & 1 deletion packages/conform-react/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,11 @@ export function updateSubjectRef(
scope?: keyof SubscriptionScope,
name?: string,
): void {
if (subject === 'status' || subject === 'formId') {
if (
subject === 'status' ||
subject === 'formId' ||
subject === 'lastIntent'
) {
ref.current[subject] = true;
} else if (typeof scope !== 'undefined' && typeof name !== 'undefined') {
ref.current[subject] = {
Expand Down
2 changes: 1 addition & 1 deletion packages/conform-react/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ export function getFormControlProps<Schema>(
options?: FormControlOptions,
): FormControlProps {
return simplify({
key: metadata.key,
key: undefined,
required: metadata.required || undefined,
...getFieldsetProps(metadata, options),
});
Expand Down
8 changes: 7 additions & 1 deletion packages/conform-react/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,17 @@ export function useForm<
context.onUpdate({ ...formConfig, formId });
});

const subjectRef = useSubjectRef();
const subjectRef = useSubjectRef({
lastIntent: true,
});
const stateSnapshot = useFormState(context, subjectRef);
const noValidate = useNoValidate(options.defaultNoValidate);
const form = getFormMetadata(context, subjectRef, stateSnapshot, noValidate);

useEffect(() => {
context.runSideEffect(stateSnapshot.pendingIntents);
}, [context, stateSnapshot.pendingIntents]);

return [form, form.getFieldset()];
}

Expand Down
31 changes: 31 additions & 0 deletions playground/app/routes/form-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,37 @@ export default function FormControl() {
>
Update number
</button>
<button
className="rounded-md border p-2 hover:border-black"
{...form.update.getButtonProps({
name: form.name,
value: {
name: 'Partial update',
message: 'This works!',
},
})}
>
Partial update
</button>
<button
className="rounded-md border p-2 hover:border-black"
type="button"
onClick={() => {
// We should wrap each form.update() in flushSync ideally
// But this is not well documented, so people are likely doing this as "it looks working"
// This makes sure we are not breaking the existing code
form.update({
name: fields.message.name,
value: 'Updated message',
});
form.update({
name: fields.number.name,
value: 987,
});
}}
>
Multiple updates
</button>
<button
className="rounded-md border p-2 hover:border-black"
{...form.update.getButtonProps({
Expand Down
196 changes: 196 additions & 0 deletions playground/app/routes/sync-form-state.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { getFormProps, useForm } from '@conform-to/react';
import { parseWithZod } from '@conform-to/zod';
import type { ActionFunctionArgs } from '@remix-run/node';
import { json } from '@remix-run/node';
import { Form, useActionData } from '@remix-run/react';
import { useState } from 'react';
import { z } from 'zod';
import { Playground, Field } from '~/components';

const schema = z.object({
input: z.object({
text: z.string(),
files: z.instanceof(File).array(),
number: z.number(),
}),
textarea: z.string(),
select: z.string(),
multiSelect: z.array(z.string()),
checkbox: z.boolean(),
checkboxGroup: z.array(z.string()),
radioGroup: z.string(),
});

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

return json({
lastResult: submission.reply({
resetForm: true,
}),
defaultValue: {
input: {
text: 'Default text',
number: 4,
files: [],
},
textarea: 'You need to write something here',
select: 'red',
multiSelect: ['apple', 'banana', 'cherry'],
checkbox: false,
checkboxGroup: ['JS', 'CSS'],
radioGroup: 'Français',
},
});
}

export default function Example() {
const actionData = useActionData<typeof action>();
const [form, fields] = useForm({
lastResult: actionData?.lastResult,
shouldValidate: 'onBlur',
onValidate: ({ formData }) => parseWithZod(formData, { schema }),
defaultValue: actionData?.defaultValue ?? {
input: {
text: 'Hello World',
number: 2,
files: [],
},
textarea: 'Once upon a time',
select: 'green',
multiSelect: ['banana', 'cherry'],
checkbox: false,
checkboxGroup: ['HTML', 'CSS'],
radioGroup: 'Deutsch',
},
constraint: {
'input.text': {
required: true,
minLength: 5,
maxLength: 30,
pattern: '[a-zA-Z]+',
},
'input.number': {
min: 5,
max: 10,
step: 1,
},
'input.files': {
required: true,
multiple: true,
},
textarea: {
required: true,
minLength: 10,
maxLength: 1000,
},
select: {
required: true,
},
multiSelect: {
multiple: true,
},
checkbox: {
required: true,
},
checkboxGroup: {
required: true,
},
radioGroup: {
required: true,
},
},
});
const inputFields = fields.input.getFieldset();
const [showNumberField, setShowNumberField] = useState(true);

return (
<Form method="post" {...getFormProps(form)}>
<Playground title="Sync form state" result={actionData?.lastResult}>
<Field label="Token">
<input name="token" value="1-0624770" onChange={() => {}} />
</Field>
<Field label="Text" meta={inputFields.text}>
<input name={inputFields.text.name} />
</Field>
{showNumberField ? (
<Field label="Number" meta={inputFields.number}>
<input type="number" name={inputFields.number.name} />
</Field>
) : null}
<Field label="Files" meta={inputFields.files}>
<input type="file" name={inputFields.files.name} />
</Field>
<Field label="Textarea" meta={fields.textarea}>
<textarea name={fields.textarea.name} />
</Field>
<Field label="Select" meta={fields.select}>
<select name={fields.select.name}>
<option value="red">Red</option>
<option value="green">Green</option>
<option value="blue">Blue</option>
</select>
</Field>
<Field label="Multi select" meta={fields.multiSelect}>
<select name={fields.multiSelect.name}>
<option value="apple">Apple</option>
<option value="banana">Banana</option>
<option value="cherry">Cherry</option>
</select>
</Field>
<Field label="Checkbox" meta={fields.checkbox}>
<label className="inline-block">
<input type="checkbox" name={fields.checkbox.name} />
<span className="p-2">Show number field</span>
</label>
</Field>
<Field label="Checkbox group" meta={fields.checkboxGroup}>
{['HTML', 'CSS', 'JS'].map((value) => (
<label key={value} className="inline-block">
<input
type="checkbox"
name={fields.checkboxGroup.name}
value={value}
/>
<span className="p-2">{value}</span>
</label>
))}
</Field>
<Field label="Radio" meta={fields.radioGroup}>
{['English', 'Deutsch', 'Français'].map((value) => (
<label key={value} className="inline-block">
<input type="radio" name={fields.radioGroup.name} value={value} />
<span className="p-2">{value}</span>
</label>
))}
</Field>
<button
{...form.update.getButtonProps({
value: {
input: {
text: 'Updated',
number: 3,
},
textarea: 'Some text here',
select: 'blue',
multiSelect: ['apple', 'cherry'],
checkbox: true,
checkboxGroup: ['HTML', 'JS'],
radioGroup: 'English',
},
})}
>
Update value
</button>
<hr />
<button
type="button"
onClick={() => setShowNumberField((prev) => !prev)}
>
Toggle number field
</button>
</Playground>
</Form>
);
}
22 changes: 18 additions & 4 deletions tests/integrations/form-control.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ function getFieldset(form: Locator) {
clearMessage: form.locator('button:text("Clear message")'),
resetMessage: form.locator('button:text("Reset message")'),
resetForm: form.locator('button:text("Reset form")'),
partialUpdate: form.locator('button:text("Partial update")'),
multipleUpdates: form.locator('button:text("Multiple updates")'),
};
}

async function runValidationScenario(page: Page) {
async function runValidationScenario(page: Page, hasClientValidation: boolean) {
const playground = getPlayground(page);
const fieldset = getFieldset(playground.container);

Expand Down Expand Up @@ -74,17 +76,29 @@ async function runValidationScenario(page: Page) {
await expect(fieldset.message).toHaveValue(
'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
);

await fieldset.partialUpdate.click();
await expect(fieldset.name).toHaveValue('Partial update');
await expect(fieldset.number).toHaveValue('13579');
await expect(fieldset.message).toHaveValue('This works!');

if (hasClientValidation) {
await fieldset.multipleUpdates.click();
await expect(fieldset.name).toHaveValue('Partial update');
await expect(fieldset.number).toHaveValue('987');
await expect(fieldset.message).toHaveValue('Updated message');
}
}

test.describe('With JS', () => {
test('Client Validation', async ({ page }) => {
await page.goto('/form-control');
await runValidationScenario(page);
await runValidationScenario(page, true);
});

test('Server Validation', async ({ page }) => {
await page.goto('/form-control?noClientValidate=yes');
await runValidationScenario(page);
await runValidationScenario(page, false);
});
});

Expand All @@ -93,6 +107,6 @@ test.describe('No JS', () => {

test('Validation', async ({ page }) => {
await page.goto('/form-control');
await runValidationScenario(page);
await runValidationScenario(page, false);
});
});
Loading
Loading