@conform-to/dom
Version:
A set of opinionated helpers built on top of the Constraint Validation API
688 lines (651 loc) • 22.8 kB
JavaScript
import { objectSpread2 as _objectSpread2 } from './_virtual/_rollupPluginBabelHelpers.mjs';
import { isSubmitter, isGlobalInstance } from './dom.mjs';
import { isPlainObject, stripFiles, deepEqual, getTypeName } from './util.mjs';
import { formatIssues } from './standard-schema.mjs';
var DEFAULT_INTENT_NAME = '__INTENT__';
/**
* Returns whether an error payload contains a meaningful value.
* Empty strings and empty arrays are treated as no error.
*/
function hasError(error) {
return error != null && error !== '' && (!Array.isArray(error) || error.length > 0);
}
/**
* Normalizes a form error object by removing empty error payloads such as
* empty strings and empty arrays.
*
* Returns `null` when no form-level or field-level errors remain.
*/
function normalizeFormError(error) {
var _error$fieldErrors;
if (error === null) {
return null;
}
var formErrors = hasError(error.formErrors) ? error.formErrors : null;
var fieldErrors = Object.entries((_error$fieldErrors = error.fieldErrors) !== null && _error$fieldErrors !== void 0 ? _error$fieldErrors : {}).reduce((result, _ref) => {
var [name, value] = _ref;
if (hasError(value)) {
result[name] = value;
}
return result;
}, {});
if (formErrors === null && Object.keys(fieldErrors).length === 0) {
return null;
}
return {
formErrors,
fieldErrors
};
}
/**
* Construct a form data with the submitter value.
* It utilizes the submitter argument on the FormData constructor from modern browsers
* with fallback to append the submitter value in case it is not unsupported.
*
* See https://developer.mozilla.org/en-US/docs/Web/API/FormData/FormData#parameters
*/
function getFormData(form, submitter) {
var payload = new FormData(form, submitter);
if (submitter) {
if (!isSubmitter(submitter)) {
throw new TypeError('The submitter must be an input or button element with type submit.');
}
if (submitter.name) {
var entries = payload.getAll(submitter.name);
// This assumes the submitter value to be always unique, which should be fine in most cases
if (!entries.includes(submitter.value)) {
payload.append(submitter.name, submitter.value);
}
}
}
return payload;
}
/**
* Convert a string path into an array of segments.
*
* **Example:**
* ```js
* parsePath("object.key"); // → ['object', 'key']
* parsePath("array[0].content"); // → ['array', 0, 'content']
* parsePath("todos[]"); // → ['todos', '']
* ```
*/
function parsePath(path) {
if (!path) return [];
var tokenRegex = /([^.[\]]+)|\[(\d*)\]/g;
var segments = [];
var lastIndex = 0,
match;
while (match = tokenRegex.exec(path)) {
// allow a single “.” between tokens
if (match.index !== lastIndex) {
if (!(match.index === lastIndex + 1 && path[lastIndex] === '.')) {
throw new Error("Invalid path syntax at position ".concat(lastIndex, " in \"").concat(path, "\""));
}
}
var [, key, index] = match;
if (key !== undefined) {
if (key === '__proto__' || key === 'constructor') {
throw new Error("Invalid path segment \"".concat(key, "\""));
}
segments.push(key);
} else if (index === '') {
segments.push('');
} else {
var number = Number(index);
if (!Number.isInteger(number) || number < 0) {
throw new Error("Invalid path segment: array index must be a non-negative integer, got ".concat(number));
}
segments.push(number);
}
lastIndex = tokenRegex.lastIndex;
}
if (lastIndex !== path.length) {
throw new Error("Invalid path syntax at position ".concat(lastIndex, " in \"").concat(path, "\""));
}
return segments;
}
/**
* Returns a formatted name from the path segments based on the dot and bracket notation.
*
* **Example:**
* ```js
* formatPath(['object', 'key']); // → "object.key"
* formatPath(['array', 0, 'content']); // → "array[0].content"
* formatPath(['todos', '']); // → "todos[]"
* ```
*/
function formatPath(segments) {
return segments.reduce((path, segment) => appendPath(path, segment), '');
}
/**
* Append one more segment onto an existing path string.
*
* - segment = `undefined` ⇒ no-op
* - segment = `""` ⇒ empty brackets "[]"
* - segment = `number` ⇒ bracket notation "[n]"
* - segment = `string` ⇒ dot-notation ".prop"
*/
function appendPath(path, segment) {
var base = path !== null && path !== void 0 ? path : '';
// 1) nothing to append
if (typeof segment === 'undefined') {
return base;
}
// 2) explicit empty-segment => empty bracket
if (segment === '') {
// even as first segment, "[]" is valid
return "".concat(base, "[]");
}
// 3) numeric index => [n]
if (typeof segment === 'number') {
return "".concat(base, "[").concat(segment, "]");
}
// 4) non-empty string => .prop (no leading dot if no base)
return base ? "".concat(base, ".").concat(segment) : segment;
}
/**
* Returns true if `prefix` is a valid leading path of `name`.
*
* **Example:**
* ```js
* isPathPrefix("foo.bar.baz", "foo.bar") // → true
* isPathPrefix("foo.bar[3].baz", "foo.bar[3]") // → true
* isPathPrefix("foo.bar[3].baz", "foo.bar") // → true
* isPathPrefix("foo.bar[3].baz", "foo.baz") // → false
* isPathPrefix("foo", "foo.bar") // → false
* ```
*/
function isPathPrefix(name, prefix) {
return getRelativePath(name, parsePath(prefix)) !== null;
}
/**
* Return the segments of `fullPath` that come after the `basePath` prefix.
*
* @param fullPath Full path as a dot/bracket string or array of segments
* @param basePath Base path as a dot/bracket string or array of segments
* @returns The “tail” segments, or `null` if `fullPath` isn’t nested under `basePath`
*
* **Example:**
* ```js
* getRelativePath("foo.bar[0].qux", ["foo","bar"]) // → [0, "qux"]
* getRelativePath("a.b.c.d", ["a","b"]) // → ["c","d"]
* getRelativePath("foo", ["foo","bar"]) // → null
* ```
*/
function getRelativePath(fullPath, basePath) {
var fullPathSegments = typeof fullPath === 'string' ? parsePath(fullPath) : fullPath;
var basePathSegments = typeof basePath === 'string' ? parsePath(basePath) : basePath;
// if full is at least as long *and* starts with the base…
if (fullPathSegments.length >= basePathSegments.length && basePathSegments.every((segment, i) => segment === fullPathSegments[i])) {
return fullPathSegments.slice(basePathSegments.length);
}
return null;
}
/**
* Assign a value to a target object by following the path segments.
*/
function setPathValue(target, pathOrSegments, valueOrFn) {
var options = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : {};
try {
// 1) normalize + validate path
var segments = typeof pathOrSegments === 'string' ? parsePath(pathOrSegments) : pathOrSegments;
if (segments.length === 0) {
throw new Error('Cannot set value at the object root');
}
if (segments.some((segment, i) => segment === '' && i < segments.length - 1)) {
throw new Error("Empty brackets '[]' only allowed at end of path (\"".concat(pathOrSegments, "\")"));
}
// 2) clone root if needed
var result = options.clone ? _objectSpread2({}, target) : target;
var pointer = result;
// 3) drill down, cloning ancestors
for (var i = 0; i < segments.length - 1; i++) {
var currentSegment = segments[i];
var nextSegment = segments[i + 1];
var child = pointer[currentSegment];
if (Array.isArray(child)) {
child = options.clone ? child.slice() : child;
} else if (isPlainObject(child)) {
child = options.clone ? _objectSpread2({}, child) : child;
} else {
child = typeof nextSegment === 'number' || nextSegment === '' ? [] : {};
}
pointer[currentSegment] = child;
pointer = child;
}
// 4) final set or push
var last = segments[segments.length - 1];
var oldValue = pointer[last];
var newValue = typeof valueOrFn === 'function' ? valueOrFn(oldValue) : valueOrFn;
if (last === '') {
if (!Array.isArray(pointer)) {
throw new Error("Cannot push to non-array at \"".concat(pathOrSegments, "\""));
}
pointer.push(newValue);
} else {
pointer[last] = newValue;
}
return result;
} catch (err) {
if (options !== null && options !== void 0 && options.silent) {
return target;
}
throw err;
}
}
/**
* Retrive the value from a target object by following the path segments.
*/
function getPathValue(target, pathOrSegments) {
var pointer = target;
var segments = typeof pathOrSegments === 'string' ? parsePath(pathOrSegments) : pathOrSegments;
for (var segment of segments) {
if (segment === '') {
throw new Error("Cannot access empty segment \"[]\" in \"".concat(pathOrSegments, "\""));
}
if (pointer == null || !Object.prototype.hasOwnProperty.call(pointer, segment)) {
return undefined;
}
pointer = pointer[segment];
}
return pointer;
}
/**
* Check if a form value is considered empty and should be stripped from the submission.
* A value is empty if:
* - It's an empty string ""
* - It's an empty File (size 0 and name "")
* - It's an array where all items are empty
*/
function isEmptyValue(value) {
if (value === '' || value === undefined) {
return true;
}
if (isGlobalInstance(value, 'File')) {
// Empty File has size 0 and empty name
return value.size === 0 && value.name === '';
}
if (Array.isArray(value)) {
// If all items are empty, consider it empty
return value.every(item => isEmptyValue(item));
}
return false;
}
/**
* Parse `FormData` or `URLSearchParams` into a submission object.
* This function structures the form values based on the naming convention.
* It also includes all the field names and extracts the intent from the submission.
*
* See https://conform.guide/api/react/future/parseSubmission
*
* **Example:**
* ```ts
* const formData = new FormData();
*
* formData.append('email', 'test@example.com');
* formData.append('password', 'secret');
*
* parseSubmission(formData)
* // {
* // payload: { email: 'test@example.com', password: 'secret' },
* // fields: ['email', 'password'],
* // intent: null,
* // }
*
* // If you have an intent field
* formData.append('intent', 'login');
* parseSubmission(formData, { intentName: 'intent' })
* // {
* // payload: { email: 'test@example.com', password: 'secret' },
* // fields: ['email', 'password'],
* // intent: 'login',
* // }
* ```
*/
function parseSubmission(formData, options) {
var _options$intentName;
var intentName = (_options$intentName = options === null || options === void 0 ? void 0 : options.intentName) !== null && _options$intentName !== void 0 ? _options$intentName : DEFAULT_INTENT_NAME;
var submission = {
payload: {},
fields: [],
intent: null
};
for (var _name of new Set(formData.keys())) {
var _options$skipEntry;
if (_name !== intentName && !(options !== null && options !== void 0 && (_options$skipEntry = options.skipEntry) !== null && _options$skipEntry !== void 0 && _options$skipEntry.call(options, _name))) {
var _options$stripEmptyVa;
var value = formData.getAll(_name);
var segments = parsePath(_name);
// If the name ends with [], remove the empty segment and keep the full array
// Otherwise, unwrap single values
if (segments.length > 0 && segments[segments.length - 1] === '') {
segments.pop();
} else {
value = value.length > 1 ? value : value[0];
}
var stripEmptyValues = (_options$stripEmptyVa = options === null || options === void 0 ? void 0 : options.stripEmptyValues) !== null && _options$stripEmptyVa !== void 0 ? _options$stripEmptyVa : false;
if (stripEmptyValues) {
// For arrays, filter out individual empty items
if (Array.isArray(value)) {
value = value.filter(item => !isEmptyValue(item));
}
if (isEmptyValue(value)) {
value = undefined;
}
}
setPathValue(submission.payload, segments, value, {
silent: true // Avoid errors if the path is invalid
});
submission.fields.push(_name);
}
}
if (intentName) {
// We take the first value of the intent field if it exists.
var intent = formData.get(intentName);
if (typeof intent === 'string') {
submission.intent = intent;
}
}
return submission;
}
/**
* Creates a SubmissionResult object from a submission, adding validation results and target values.
* This function will remove all files in the submission payload by default since
* file inputs cannot be initialized with files.
* You can specify `keepFiles: true` to keep the files if needed.
*
* See https://conform.guide/api/react/future/report
*
* **Example:**
* ```ts
* // Report the submission with the field errors
* report(submission, {
* error: {
* fieldErrors: {
* email: ['Invalid email format'],
* password: ['Password is required'],
* },
* })
*
* // Report the submission with a form error
* report(submission, {
* error: {
* formErrors: ['Invalid credentials'],
* },
* })
*
* // Reset the form
* report(submission, {
* reset: true,
* })
* ```
*/
function report(submission) {
var _options$value;
var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
var error;
if (options.error == null) {
error = options.error;
} else if ('issues' in options.error) {
var _options$error$issues;
error = formatIssues((_options$error$issues = options.error.issues) !== null && _options$error$issues !== void 0 ? _options$error$issues : []);
} else {
error = normalizeFormError(options.error);
}
var targetValue = typeof options.value === 'undefined' || submission.payload === options.value && !options.reset ? undefined : options.value && !options.keepFiles ? stripFiles(options.value) : (_options$value = options.value) !== null && _options$value !== void 0 ? _options$value : {};
if (options.hideFields) {
for (var _name2 of options.hideFields) {
var path = parsePath(_name2);
setPathValue(submission.payload, path, undefined);
if (targetValue) {
setPathValue(targetValue, path, undefined);
}
}
}
return {
submission: options.keepFiles ? submission : _objectSpread2(_objectSpread2({}, submission), {}, {
payload: stripFiles(submission.payload)
}),
reset: options.reset,
targetValue,
error
};
}
/**
* A utility function that checks whether the current form data differs from the default values.
*
* See https://conform.guide/api/react/future/isDirty
*
* **Example: Enable a submit button only if the form is dirty**
*
* ```tsx
* const dirty = useFormData(
* formRef,
* (formData) => isDirty(formData, { defaultValue }) ?? false,
* );
*
* return (
* <button type="submit" disabled={!dirty}>
* Save changes
* </button>
* );
* ```
*/
function isDirty(
/**
* The current form data to compare. It can be:
*
* - A `FormData` object
* - A `URLSearchParams` object
* - A plain object that was parsed from form data (i.e. `submission.payload`)
*/
formData, options) {
if (!formData) {
return;
}
var formValue = formData instanceof FormData || formData instanceof URLSearchParams ? parseSubmission(formData, {
intentName: options === null || options === void 0 ? void 0 : options.intentName,
skipEntry: options === null || options === void 0 ? void 0 : options.skipEntry
}).payload : formData;
var serialize = (value, context) => {
if (options !== null && options !== void 0 && options.serialize) {
return options.serialize(value, {
name: context.name,
defaultSerialize
});
}
return defaultSerialize(value);
};
return !deepEqual(normalize(formValue, serialize), normalize(options === null || options === void 0 ? void 0 : options.defaultValue, serialize));
}
/**
* Convert an unknown value into something acceptable for HTML form submission.
* Returns `undefined` when the value cannot be represented in form data.
*
* Input -> Output:
* - string -> string
* - null -> '' (empty string)
* - boolean -> 'on' | '' (checked semantics)
* - number | bigint -> value.toString()
* - Date -> value.toISOString() without trailing `Z`
* - File -> File
* - FileList -> File[]
* - Array -> string[] or File[] if all items serialize to the same kind; otherwise undefined
* - anything else -> undefined
*/
function defaultSerialize(value) {
function serializePrimitive(value) {
if (typeof value === 'string' || value === null) {
return value;
}
if (typeof value === 'boolean') {
return value ? 'on' : null;
}
if (typeof value === 'number' || typeof value === 'bigint') {
return value.toString();
}
if (value instanceof Date) {
return value.toISOString().slice(0, -1);
}
if (isGlobalInstance(value, 'File')) {
return value;
}
}
if (Array.isArray(value)) {
var _options = [];
var files = [];
for (var item of value) {
var serialized = serializePrimitive(item);
if (typeof serialized === 'undefined') {
return;
}
// It is unclear what `null` in a file array should mean, so we treat it as a string instead
if (typeof serialized === 'string' || serialized === null) {
if (files.length > 0) {
return;
}
_options.push(serialized !== null && serialized !== void 0 ? serialized : '');
} else {
if (_options.length > 0) {
return;
}
files.push(serialized);
}
}
if (_options.length === value.length) {
return _options;
}
if (files.length === value.length) {
return files;
}
// If not all items are strings or files, return nothing
}
if (isGlobalInstance(value, 'FileList')) {
return Array.from(value);
}
return serializePrimitive(value);
}
/**
* Recursively serializes a value using the provided serialize function,
* collapsing empty leaves (`null`, `''`, empty files) to `undefined`
* and removing empty containers (objects with no remaining keys, empty arrays).
*
* When serialize returns `undefined` for a value (i.e. it can't be represented
* as form data), the raw value is kept and recursed into if it's an object or array.
*
* Single-element arrays where the element is a string or undefined are unwrapped
* to handle the case where a multi-value field (e.g. checkboxes) has only one value.
*/
function normalize(value) {
var serialize = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : defaultSerialize;
var name = arguments.length > 2 ? arguments[2] : undefined;
var data = serialize(value, {
name
});
if (typeof data === 'undefined') {
data = value;
}
if (data === '' || data === null) {
return undefined;
}
if (isGlobalInstance(data, 'File')) {
if (data.name === '' && data.size === 0) {
return undefined;
}
return data;
}
if (Array.isArray(data)) {
if (data.length === 0) {
return undefined;
}
var array = data.map((item, index) => normalize(item, serialize, appendPath(name, index)));
if (array.length === 1 && (typeof array[0] === 'string' || array[0] === undefined)) {
return array[0];
}
return array;
}
if (isPlainObject(data)) {
var entries = Object.entries(data).reduce((list, _ref2) => {
var [key, value] = _ref2;
var normalizedValue = normalize(value, serialize, appendPath(name, key));
if (typeof normalizedValue !== 'undefined') {
list.push([key, normalizedValue]);
}
return list;
}, []);
if (entries.length === 0) {
return undefined;
}
return Object.fromEntries(entries);
}
return data;
}
/**
* Retrieve a field value from FormData with optional type guards.
*
* **Example:**
*
* ```ts
* // Basic field access: return `unknown`
* const email = getFieldValue(formData, 'email');
* // String type: returns `string`
* const name = getFieldValue(formData, 'name', { type: 'string' });
* // File type: returns `File`
* const avatar = getFieldValue(formData, 'avatar', { type: 'file' });
* // Object type: returns { city: unknown, ... }
* const address = getFieldValue<Address>(formData, 'address', { type: 'object' });
* // Array: returns `unknown[]`
* const tags = getFieldValue(formData, 'tags', { array: true });
* // Array with object type: returns `Array<{ name: unknown, ... }>`
* const items = getFieldValue<Item[]>(formData, 'items', { type: 'object', array: true });
* // Optional string type: returns `string | undefined`
* const bio = getFieldValue(formData, 'bio', { type: 'string', optional: true });
* ```
*/
function getFieldValue(formData, name, options) {
var {
type,
array,
optional
} = options !== null && options !== void 0 ? options : {};
var value;
// Check if formData has a direct entry
if (formData.has(name)) {
// Get value based on array option
value = array ? formData.getAll(name) : formData.get(name);
} else {
// Parse formData and use getPathValue
var _submission = parseSubmission(formData);
value = getPathValue(_submission.payload, name);
}
// If optional and value is undefined, skip validation and return early
if (optional && value === undefined) {
return;
}
// Type guards - validate the value matches the expected type
if (array && !Array.isArray(value)) {
throw new Error("Expected field \"".concat(name, "\" to be an array, but got ").concat(getTypeName(value)));
}
if (type) {
var items = array ? value : [value];
var predicate = {
string: v => typeof v === 'string',
file: v => v instanceof File,
object: isPlainObject
}[type];
var typeName = {
string: 'a string',
file: 'a File',
object: 'an object'
}[type];
for (var i = 0; i < items.length; i++) {
if (!predicate(items[i])) {
var field = array ? "".concat(name, "[").concat(i, "]") : name;
throw new Error("Expected field \"".concat(field, "\" to be ").concat(typeName, ", but got ").concat(getTypeName(items[i])));
}
}
}
return value;
}
export { DEFAULT_INTENT_NAME, appendPath, defaultSerialize, formatPath, getFieldValue, getFormData, getPathValue, getRelativePath, hasError, isDirty, isPathPrefix, normalize, normalizeFormError, parsePath, parseSubmission, report, setPathValue };