Skip to content

Commit

Permalink
feat: auto fields management
Browse files Browse the repository at this point in the history
  • Loading branch information
edmundhung committed Oct 1, 2024
1 parent 2c4ded4 commit b0d4012
Show file tree
Hide file tree
Showing 7 changed files with 145 additions and 48 deletions.
121 changes: 102 additions & 19 deletions packages/conform-dom/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,14 @@ export type FormOptions<Schema, FormError = string[], FormValue = Schema> = {
*/
shouldRevalidate?: 'onSubmit' | 'onBlur' | 'onInput';

/**
* Define if the input could be updated by conform.
* Default to inputs that are configured using the `getInputProps`, `getSelectProps` or `getTextareaProps` helpers.
*/
shouldManageInput?: (
element: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement,
) => boolean;

/**
* Define if conform should considered the field for dirty state.
* e.g. Excluding form fields that are not managed by Conform, such as CSRF token
Expand Down Expand Up @@ -230,7 +238,7 @@ export type FormContext<
onInput(event: Event): void;
onBlur(event: Event): void;
onUpdate(options: Partial<FormOptions<Schema, FormError, FormValue>>): void;
observe(): () => void;
observe(stateSnapshot: FormState<FormError>): () => void;
subscribe(
callback: () => void,
getSubject?: () => SubscriptionSubject | undefined,
Expand Down Expand Up @@ -276,12 +284,7 @@ function createFormMeta<Schema, FormError, FormValue>(
value: initialValue,
constraint: options.constraint ?? {},
validated: lastResult?.state?.validated ?? {},
key: !initialized
? getDefaultKey(defaultValue)
: {
'': generateId(),
...getDefaultKey(defaultValue),
},
key: getDefaultKey(defaultValue),
// The `lastResult` should comes from the server which we won't expect the error to be null
// We can consider adding a warning if it happens
error: (lastResult?.error as Record<string, FormError>) ?? {},
Expand All @@ -298,15 +301,20 @@ function getDefaultKey(
): Record<string, string> {
return Object.entries(flatten(defaultValue, { prefix })).reduce<
Record<string, string>
>((result, [key, value]) => {
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
result[formatName(key, i)] = generateId();
>(
(result, [key, value]) => {
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
result[formatName(key, i)] = generateId();
}
}
}

return result;
}, {});
return result;
},
{
[prefix ?? '']: generateId(),
},
);
}

function setFieldsValidated<Error>(
Expand Down Expand Up @@ -438,10 +446,8 @@ function updateValue<Error>(
if (name === '') {
meta.initialValue = value as Record<string, unknown>;
meta.value = value as Record<string, unknown>;
meta.key = {
...getDefaultKey(value as Record<string, unknown>),
'': generateId(),
};
meta.key = getDefaultKey(value as Record<string, unknown>);

return;
}

Expand Down Expand Up @@ -1038,7 +1044,82 @@ export function createFormContext<
});
}

function observe() {
function updateFormElement(stateSnapshot: FormState<FormError>) {
const formElement = getFormElement();

if (!formElement) {
return;
}

// Default to manage inputs that is configured using the `getInputProps` helpers
// The data attribute is just an implementation detail and should not be used by the user
// We will likely make this a opt-out feature in v2
const defaultShouldManageInput = (
element: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement,
) => typeof element.dataset.conform !== 'undefined';
const shouldManageInput =
latestOptions.shouldManageInput ?? defaultShouldManageInput;
const getAll = (value: unknown) => {
if (typeof value === 'string') {
return [value];
}

if (
Array.isArray(value) &&
value.every((item) => typeof item === 'string')
) {
return value;
}

return undefined;
};
const get = (value: unknown) => getAll(value)?.[0];

for (const element of formElement.elements) {
if (
element instanceof HTMLInputElement ||
element instanceof HTMLTextAreaElement ||
element instanceof HTMLSelectElement
) {
if (
element.dataset.conform === 'managed' ||
element.type === 'submit' ||
element.type === 'reset' ||
element.type === 'button' ||
!shouldManageInput(element)
) {
// Skip buttons and fields managed by useInputControl()
continue;
}

const prev = element.dataset.conform;
const next = stateSnapshot.key[element.name];
const defaultValue = stateSnapshot.initialValue[element.name];

if (typeof prev === 'undefined' || prev !== next) {
element.dataset.conform = next;

if ('options' in element) {
const value = getAll(defaultValue) ?? [];

for (const option of element.options) {
option.selected = value.includes(option.value);
}
} else if (
'checked' in element &&
(element.type === 'checkbox' || element.type === 'radio')
) {
element.checked =
getAll(defaultValue)?.includes(element.value) ?? false;
} else {
element.value = get(defaultValue) ?? '';
}
}
}
}
}

function observe(stateSnapshot: FormState<FormError>) {
const observer = new MutationObserver((mutations) => {
const form = getFormElement();

Expand All @@ -1061,6 +1142,7 @@ export function createFormContext<

if (element?.form === form) {
updateFormValue(form);
updateFormElement(state);
return;
}
}
Expand All @@ -1073,6 +1155,7 @@ export function createFormContext<
attributes: true,
attributeFilter: ['form', 'name'],
});
updateFormElement(stateSnapshot);

return () => {
observer.disconnect();
Expand Down
5 changes: 3 additions & 2 deletions packages/conform-react/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { FormMetadata, FieldMetadata, Metadata, Pretty } from './context';

type FormControlProps = {
key: string | undefined;
key?: string;
id: string;
name: string;
form: string;
Expand Down Expand Up @@ -214,7 +214,8 @@ export function getFormControlProps<Schema>(
options?: FormControlOptions,
): FormControlProps {
return simplify({
key: metadata.key,
// let Conform updates the field value for us
'data-conform': '',
required: metadata.required || undefined,
...getFieldsetProps(metadata, options),
});
Expand Down
18 changes: 15 additions & 3 deletions packages/conform-react/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,11 @@ 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 All @@ -91,11 +89,25 @@ export function useForm<
context.onUpdate({ ...formConfig, formId });
});

const subjectRef = useSubjectRef();
const subjectRef = useSubjectRef({
key: {
// Subscribe to all key changes so it will re-render and
// update the field value as soon as the DOM is updated
prefix: [''],
},
});
const stateSnapshot = useFormState(context, subjectRef);
const noValidate = useNoValidate(options.defaultNoValidate);
const form = getFormMetadata(context, subjectRef, stateSnapshot, noValidate);

useEffect(() => {
const disconnect = context.observe(stateSnapshot);

return () => {
disconnect();
};
}, [context, stateSnapshot]);

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

Expand Down
24 changes: 3 additions & 21 deletions packages/conform-react/integrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export function createDummySelect(

select.name = name;
select.multiple = true;
select.dataset.conform = 'true';
select.dataset.conform = 'managed';

// To make sure the input is hidden but still focusable
select.setAttribute('aria-hidden', 'true');
Expand All @@ -98,7 +98,7 @@ export function createDummySelect(
export function isDummySelect(
element: HTMLElement,
): element is HTMLSelectElement {
return element.dataset.conform === 'true';
return element.dataset.conform === 'managed';
}

export function updateFieldValue(
Expand Down Expand Up @@ -306,26 +306,8 @@ export function useControl<
change(value);
};

const refCallback: RefCallback<
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | undefined
> = (element) => {
register(element);

if (!element) {
return;
}

const prevKey = element.dataset.conform;
const nextKey = `${meta.key ?? ''}`;

if (prevKey !== nextKey) {
element.dataset.conform = nextKey;
updateFieldValue(element, value ?? '');
}
};

return {
register: refCallback,
register,
value,
change: handleChange,
focus,
Expand Down
6 changes: 6 additions & 0 deletions playground/app/routes/form-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,12 @@ export default function FormControl() {
>
Reset form
</button>
<input
type="submit"
className="rounded-md border p-2 hover:border-black"
name="example"
value="Submit"
/>
</div>
</Playground>
</Form>
Expand Down
15 changes: 12 additions & 3 deletions tests/conform-react.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ function getProps(metadata: FieldMetadata<any>) {
describe('conform-react', () => {
test('getInputProps', () => {
const metadata = createFieldMetadata();
const props = getProps(metadata);
const props = {
'data-conform': '',
...getProps(metadata),
};

expect(
getInputProps(
Expand Down Expand Up @@ -159,7 +162,10 @@ describe('conform-react', () => {

test('getTextareaProps', () => {
const metadata = createFieldMetadata();
const props = getProps(metadata);
const props = {
'data-conform': '',
...getProps(metadata),
};

expect(getTextareaProps(metadata)).toEqual(props);
expect(
Expand Down Expand Up @@ -229,7 +235,10 @@ describe('conform-react', () => {

test('getSelectProps', () => {
const metadata = createFieldMetadata();
const props = getProps(metadata);
const props = {
'data-conform': '',
...getProps(metadata),
};

expect(getSelectProps(metadata)).toEqual(props);
expect(
Expand Down
4 changes: 4 additions & 0 deletions tests/integrations/form-control.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,17 @@ 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")'),
inputButton: form.locator('input[type="submit"]'),
};
}

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

// Conform should not overwrite the value of any input buttons
await expect(fieldset.inputButton).toHaveValue('Submit');

await expect(playground.error).toHaveText(['', '', '']);

await fieldset.validateMessage.click();
Expand Down

0 comments on commit b0d4012

Please sign in to comment.