UNPKG

@conform-to/dom

Version:

A set of opinionated helpers built on top of the Constraint Validation API

688 lines (651 loc) 22.8 kB
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 };