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 fields management #798

Closed
wants to merge 9 commits into from
Closed
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"@conform-to/validitystate": "workspace:*",
"@conform-to/yup": "workspace:*",
"@conform-to/zod": "workspace:*",
"@playwright/test": "^1.44.1",
"@playwright/test": "^1.49.0",
"@types/node": "^20.10.4",
"@typescript-eslint/eslint-plugin": "^7.11.0",
"@typescript-eslint/parser": "^7.11.0",
Expand Down
113 changes: 109 additions & 4 deletions packages/conform-dom/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import { invariant } from './util';
* Element that user can interact with,
* includes `<input>`, `<select>` and `<textarea>`.
*/
export type FieldElement =
export type FieldElement = (
| HTMLInputElement
| HTMLSelectElement
| HTMLTextAreaElement;
| HTMLTextAreaElement
) & { form: HTMLFormElement };

/**
* HTML Element that can be used as a form control,
Expand Down Expand Up @@ -37,12 +38,19 @@ export function isFormControl(element: unknown): element is FormControl {
* A type guard to check if the provided element is a field element, which
* is a form control excluding submit, button and reset type.
*/
export function isFieldElement(element: unknown): element is FieldElement {
export function isFieldElement(
element: unknown,
form?: HTMLFormElement,
): element is FieldElement {
return (
isFormControl(element) &&
element.type !== 'submit' &&
element.type !== 'button' &&
element.type !== 'reset'
element.type !== 'reset' &&
element.name !== '' &&
element.form !== null &&
element.form.isConnected &&
(typeof form === 'undefined' || element.form === form)
);
}

Expand Down Expand Up @@ -128,3 +136,100 @@ export function requestSubmit(
form.dispatchEvent(event);
}
}

/**
* Synchronizes the field elements with the provided state
*/
export function syncFieldValue(
element: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement,
value: unknown,
defaultValue: unknown,
): void {
const getInputValue = (value: unknown): string[] =>
typeof value === 'string'
? [value]
: Array.isArray(value) && value.every((item) => typeof item === 'string')
? value
: [];

if (element instanceof HTMLInputElement) {
switch (element.type) {
case 'checkbox':
case 'radio':
element.checked = getInputValue(value).includes(element.value);
element.defaultChecked = getInputValue(defaultValue).includes(
element.value,
);
break;
case 'file':
// Do nothing for now
break;
default:
element.value = getInputValue(value)[0] ?? '';
element.defaultValue = getInputValue(defaultValue)[0] ?? '';
break;
}
} else if (element instanceof HTMLSelectElement) {
for (const option of element.options) {
option.selected = getInputValue(value).includes(option.value);
option.defaultSelected = getInputValue(defaultValue).includes(
option.value,
);
}
} else {
element.value = getInputValue(value)[0] ?? '';
element.defaultValue = getInputValue(defaultValue)[0] ?? '';
}
}

export function syncFieldConstraint(
element: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement,
constraint: {
required?: boolean;
minLength?: number;
maxLength?: number;
min?: string | number;
max?: string | number;
step?: string | number;
multiple?: boolean;
pattern?: string;
},
): void {
if (
typeof constraint.required !== 'undefined' &&
// If the element is a part of the checkbox group, it is unclear whether all checkboxes are required or only one.
!(
element.type === 'checkbox' &&
element.form?.elements.namedItem(element.name) instanceof RadioNodeList
)
) {
element.required = constraint.required;
}

if (typeof constraint.multiple !== 'undefined' && 'multiple' in element) {
element.multiple = constraint.multiple;
}

if (typeof constraint.minLength !== 'undefined' && 'minLength' in element) {
element.minLength = constraint.minLength;
}

if (typeof constraint.maxLength !== 'undefined' && 'maxLength' in element) {
element.maxLength = constraint.maxLength;
}
if (typeof constraint.min !== 'undefined' && 'min' in element) {
element.min = `${constraint.min}`;
}

if (typeof constraint.max !== 'undefined' && 'max' in element) {
element.max = `${constraint.max}`;
}

if (typeof constraint.step !== 'undefined' && 'step' in element) {
element.step = `${constraint.step}`;
}

if (typeof constraint.pattern !== 'undefined' && 'pattern' in element) {
element.pattern = constraint.pattern;
}
}
150 changes: 72 additions & 78 deletions packages/conform-dom/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
getFormEncType,
getFormMethod,
requestSubmit,
syncFieldValue,
syncFieldConstraint,
} from './dom';
import { clone, generateId, invariant } from './util';
import {
Expand Down Expand Up @@ -230,7 +232,7 @@ export type FormContext<
onInput(event: Event): void;
onBlur(event: Event): void;
onUpdate(options: Partial<FormOptions<Schema, FormError, FormValue>>): void;
observe(): () => void;
syncFormValue(): void;
subscribe(
callback: () => void,
getSubject?: () => SubscriptionSubject | undefined,
Expand Down Expand Up @@ -260,9 +262,9 @@ export type FormContext<

function createFormMeta<Schema, FormError, FormValue>(
options: FormOptions<Schema, FormError, FormValue>,
initialized?: boolean,
isResetting?: boolean,
): FormMeta<FormError> {
const lastResult = !initialized ? options.lastResult : undefined;
const lastResult = !isResetting ? options.lastResult : undefined;
const defaultValue = options.defaultValue
? (serialize(options.defaultValue) as Record<string, unknown>)
: {};
Expand All @@ -276,12 +278,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 +295,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 +440,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 @@ -827,13 +827,7 @@ export function createFormContext<
const form = getFormElement();
const element = event.target;

if (
!form ||
!isFieldElement(element) ||
element.form !== form ||
!element.form.isConnected ||
element.name === ''
) {
if (!form || !isFieldElement(element) || element.form !== form) {
return null;
}

Expand All @@ -854,15 +848,19 @@ export function createFormContext<
: shouldValidate === eventName;
}

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

updateFormMeta({
...meta,
isValueUpdated: true,
value: result.payload,
});
if (formElement) {
const formData = new FormData(formElement);
const result = getSubmissionContext(formData);

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

function onInput(event: Event) {
Expand All @@ -873,7 +871,7 @@ export function createFormContext<
}

if (event.defaultPrevented || !willValidate(element, 'onInput')) {
updateFormValue(element.form);
syncFormValue();
} else {
dispatch({
type: 'validate',
Expand Down Expand Up @@ -1038,47 +1036,6 @@ 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 {
getFormId() {
return meta.formId;
Expand All @@ -1094,9 +1051,46 @@ export function createFormContext<
insert: createFormControl('insert'),
remove: createFormControl('remove'),
reorder: createFormControl('reorder'),
syncFormValue,
subscribe,
getState,
getSerializedState,
observe,
};
}

export function syncFormState<FormError>(
formElement: HTMLFormElement,
stateSnapshot: FormState<FormError>,
/**
* Default to manage inputs that is configured using the `getInputProps` helpers
*/
shouldSyncElement: (element: FieldElement) => boolean = (element) =>
typeof element.dataset.conform !== 'undefined',
) {
for (const element of formElement.elements) {
if (
isFieldElement(element) &&
element.dataset.conform !== 'managed' &&
shouldSyncElement(element)
) {
const prev = element.dataset.conform;
const next = stateSnapshot.key[element.name];
const isInitializing = typeof prev === 'undefined';

if (isInitializing || prev !== next) {
element.dataset.conform = next;

// Sync the field constraint first to make sure multiple attribute is applied first before the value
syncFieldConstraint(
element,
stateSnapshot.constraint[element.name] ?? {},
);
syncFieldValue(
element,
stateSnapshot.initialValue[element.name],
stateSnapshot.defaultValue[element.name],
);
}
}
}
}
5 changes: 5 additions & 0 deletions packages/conform-dom/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ export {
type SubscriptionSubject,
type SubscriptionScope,
createFormContext as unstable_createFormContext,
syncFormState as unstable_syncFormState,
} from './form';
export {
type FormObserver,
createFormObserver as unstable_createFormObserver,
} from './observer';
export { type FieldElement, isFieldElement } from './dom';
export {
type Submission,
Expand Down
Loading
Loading