UNPKG

sveltekit-superforms

Version:

Making SvelteKit forms a pleasure to use!

1,174 lines (1,173 loc) 69.4 kB
import { derived, get, readonly, writable } from 'svelte/store'; import { navigating, page } from '$app/stores'; import { clone } from '../utils.js'; import { browser } from '$app/environment'; import { onDestroy, tick } from 'svelte'; import { comparePaths, pathExists, setPaths, traversePath, traversePaths } from '../traversal.js'; import { splitPath, mergePath } from '../stringPath.js'; import { beforeNavigate, goto, invalidateAll } from '$app/navigation'; import { SuperFormError, flattenErrors, mapErrors, updateErrors } from '../errors.js'; import { cancelFlash, shouldSyncFlash } from './flash.js'; import { applyAction, deserialize, enhance as kitEnhance } from '$app/forms'; import { setCustomValidityForm, updateCustomValidity } from './customValidity.js'; import { inputInfo } from './elements.js'; import { Form as HtmlForm, scrollToFirstError } from './form.js'; import { stringify } from 'devalue'; import { fieldProxy } from './proxies.js'; import { shapeFromObject } from '../jsonSchema/schemaShape.js'; const formIds = new WeakMap(); const initialForms = new WeakMap(); const defaultOnError = (event) => { throw event.result.error; }; const defaultFormOptions = { applyAction: true, invalidateAll: true, resetForm: true, autoFocusOnError: 'detect', scrollToError: 'smooth', errorSelector: '[aria-invalid="true"],[data-invalid]', selectErrorText: false, stickyNavbar: undefined, taintedMessage: false, onSubmit: undefined, onResult: undefined, onUpdate: undefined, onUpdated: undefined, onError: defaultOnError, dataType: 'form', validators: undefined, customValidity: false, clearOnSubmit: 'message', delayMs: 500, timeoutMs: 8000, multipleSubmits: 'prevent', SPA: undefined, validationMethod: 'auto' }; function multipleFormIdError(id) { return (`Duplicate form id's found: "${id}". ` + 'Multiple forms will receive the same data. Use the id option to differentiate between them, ' + 'or if this is intended, set the warnings.duplicateId option to false in superForm to disable this warning. ' + 'More information: https://superforms.rocks/concepts/multiple-forms'); } ///////////////////////////////////////////////////////////////////// /** * V1 compatibilty. resetForm = false and taintedMessage = true */ let LEGACY_MODE = false; try { // @ts-expect-error Vite define check if (SUPERFORMS_LEGACY) LEGACY_MODE = true; } catch { // No legacy mode defined } /** * Storybook compatibility mode, basically disables the navigating store. */ let STORYBOOK_MODE = false; try { // @ts-expect-error Storybook check if (globalThis.STORIES) STORYBOOK_MODE = true; } catch { // No Storybook } ///////////////////////////////////////////////////////////////////// /** * Initializes a SvelteKit form, for convenient handling of values, errors and sumbitting data. * @param {SuperValidated} form Usually data.form from PageData or defaults, but can also be an object with default values, but then constraints won't be available. * @param {FormOptions} formOptions Configuration for the form. * @returns {SuperForm} A SuperForm object that can be used in a Svelte component. * @DCI-context */ export function superForm(form, formOptions) { // Used in reset let initialForm; let options = formOptions ?? {}; // To check if a full validator is used when switching options.validators dynamically let initialValidator = undefined; { if (options.legacy ?? LEGACY_MODE) { if (options.resetForm === undefined) options.resetForm = false; if (options.taintedMessage === undefined) options.taintedMessage = true; } if (STORYBOOK_MODE) { if (options.applyAction === undefined) options.applyAction = false; } if (typeof options.SPA === 'string') { // SPA action mode is "passive", no page updates are made. if (options.invalidateAll === undefined) options.invalidateAll = false; if (options.applyAction === undefined) options.applyAction = false; } initialValidator = options.validators; options = { ...defaultFormOptions, ...options }; if ((options.SPA === true || typeof options.SPA === 'object') && options.validators === undefined) { console.warn('No validators set for superForm in SPA mode. ' + 'Add a validation adapter to the validators option, or set it to false to disable this warning.'); } if (!form) { throw new SuperFormError('No form data sent to superForm. ' + "Make sure the output from superValidate is used (usually data.form) and that it's not null or undefined. " + "Alternatively, an object with default values for the form can also be used, but then constraints won't be available."); } if (Context_isValidationObject(form) === false) { form = { id: options.id ?? Math.random().toString(36).slice(2, 10), valid: false, posted: false, errors: {}, data: form, shape: shapeFromObject(form) }; } form = form; // Assign options.id to form, if it exists const _initialFormId = (form.id = options.id ?? form.id); const _currentPage = get(page) ?? (STORYBOOK_MODE ? {} : undefined); // Check multiple id's if (browser && options.warnings?.duplicateId !== false) { if (!formIds.has(_currentPage)) { formIds.set(_currentPage, new Set([_initialFormId])); } else { const currentForms = formIds.get(_currentPage); if (currentForms?.has(_initialFormId)) { console.warn(multipleFormIdError(_initialFormId)); } else { currentForms?.add(_initialFormId); } } } /** * Need to clone the form data, in case it's used to populate multiple forms * and in components that are mounted and destroyed multiple times. * This also means that it needs to be set here, before it's cloned further below. */ if (!initialForms.has(form)) { initialForms.set(form, form); } initialForm = initialForms.get(form); // Detect if a form is posted without JavaScript. if (!browser && _currentPage.form && typeof _currentPage.form === 'object') { const postedData = _currentPage.form; for (const postedForm of Context_findValidationForms(postedData).reverse()) { if (postedForm.id == _initialFormId && !initialForms.has(postedForm)) { // Prevent multiple "posting" that can happen when components are recreated. initialForms.set(postedData, postedData); const pageDataForm = form; // Add the missing fields from the page data form form = postedForm; form.constraints = pageDataForm.constraints; form.shape = pageDataForm.shape; // Reset the form if option set and form is valid. if (form.valid && options.resetForm && (options.resetForm === true || options.resetForm())) { form = clone(pageDataForm); form.message = clone(postedForm.message); } break; } } } else { form = clone(initialForm); } ///// From here, form is properly initialized ///// onDestroy(() => { Unsubscriptions_unsubscribe(); NextChange_clear(); EnhancedForm_destroy(); for (const events of Object.values(formEvents)) { events.length = 0; } formIds.get(_currentPage)?.delete(_initialFormId); }); // Check for nested objects, throw if datatype isn't json if (options.dataType !== 'json') { const checkForNestedData = (key, value) => { if (!value || typeof value !== 'object') return; if (Array.isArray(value)) { if (value.length > 0) checkForNestedData(key, value[0]); } else if (!(value instanceof Date) && !(value instanceof File) && (!browser || !(value instanceof FileList))) { throw new SuperFormError(`Object found in form field "${key}". ` + `Set the dataType option to "json" and add use:enhance to use nested data structures. ` + `More information: https://superforms.rocks/concepts/nested-data`); } }; for (const [key, value] of Object.entries(form.data)) { checkForNestedData(key, value); } } } ///// Roles /////////////////////////////////////////////////////// //#region Data /** * Container for store data, subscribed to with Unsubscriptions * to avoid "get" usage. */ const __data = { formId: form.id, form: clone(form.data), constraints: form.constraints ?? {}, posted: form.posted, errors: clone(form.errors), message: clone(form.message), tainted: undefined, valid: form.valid, submitting: false, shape: form.shape }; const Data = __data; //#endregion //#region FormId const FormId = writable(options.id ?? form.id); //#endregion //#region Context // eslint-disable-next-line @typescript-eslint/no-unused-vars const Context = {}; function Context_findValidationForms(data) { const forms = Object.values(data).filter((v) => Context_isValidationObject(v) !== false); return forms; } /** * Return false if object isn't a validation object, otherwise the form id, * which can be an empty string, so always check with === false */ function Context_isValidationObject(object) { if (!object || typeof object !== 'object') return false; if (!('valid' in object && 'errors' in object && typeof object.valid === 'boolean')) { return false; } return 'id' in object && typeof object.id === 'string' ? object.id : false; } //#endregion //#region Form // eslint-disable-next-line dci-lint/grouped-rolemethods const _formData = writable(form.data); const Form = { subscribe: _formData.subscribe, set: (value, options = {}) => { // Need to clone the value, so it won't refer to $page for example. const newData = clone(value); Tainted_update(newData, options.taint ?? true); return _formData.set(newData); }, update: (updater, options = {}) => { return _formData.update((value) => { // No cloning here, since it's an update const newData = updater(value); Tainted_update(newData, options.taint ?? true); return newData; }); } }; function Form_isSPA() { return options.SPA === true || typeof options.SPA === 'object'; } function Form_resultStatus(defaultStatus) { if (defaultStatus > 400) return defaultStatus; return ((typeof options.SPA === 'boolean' || typeof options.SPA === 'string' ? undefined : options.SPA?.failStatus) || defaultStatus); } async function Form_validate(opts = {}) { const dataToValidate = opts.formData ?? Data.form; let errors = {}; let status; const validator = opts.adapter ?? options.validators; if (typeof validator == 'object') { // Checking for full validation with the jsonSchema field (doesn't exist in client validators). if (validator != initialValidator && !('jsonSchema' in validator)) { throw new SuperFormError('Client validation adapter found in options.validators. ' + 'A full adapter must be used when changing validators dynamically, for example "zod" instead of "zodClient".'); } status = await /* @__PURE__ */ validator.validate(dataToValidate); if (!status.success) { errors = mapErrors(status.issues, validator.shape ?? Data.shape ?? {}); } else if (opts.recheckValidData !== false) { // need to make an additional validation, in case the data has been transformed return Form_validate({ ...opts, recheckValidData: false }); } } else { status = { success: true, data: {} }; } const data = { ...Data.form, ...dataToValidate, ...(status.success ? status.data : {}) }; return { valid: status.success, posted: false, errors, data, constraints: Data.constraints, message: undefined, id: Data.formId, shape: Data.shape }; } function Form__changeEvent(event) { if (!options.onChange || !event.paths.length || event.type == 'blur') return; let changeEvent; const paths = event.paths.map(mergePath); if (event.type && event.paths.length == 1 && event.formElement && event.target instanceof Element) { changeEvent = { path: paths[0], paths, formElement: event.formElement, target: event.target, set(path, value, options) { // Casting trick to make it think it's a SuperForm fieldProxy({ form: Form }, path, options).set(value); }, get(path) { return get(fieldProxy(Form, path)); } }; } else { changeEvent = { paths, target: undefined, set(path, value, options) { // Casting trick to make it think it's a SuperForm fieldProxy({ form: Form }, path, options).set(value); }, get(path) { return get(fieldProxy(Form, path)); } }; } options.onChange(changeEvent); } /** * Make a client-side validation, updating the form data if successful. * @param event A change event, from html input or programmatically * @param force Is true if called from validateForm with update: true * @param adapter ValidationAdapter, if called from validateForm with schema set * @returns SuperValidated, or undefined if options prevented validation. */ async function Form_clientValidation(event, force = false, adapter) { if (event) { if (options.validators == 'clear') { Errors.update(($errors) => { setPaths($errors, event.paths, undefined); return $errors; }); } setTimeout(() => Form__changeEvent(event)); } let skipValidation = false; if (!force) { if (options.validationMethod == 'onsubmit' || options.validationMethod == 'submit-only') { skipValidation = true; } else if (options.validationMethod == 'onblur' && event?.type == 'input') skipValidation = true; else if (options.validationMethod == 'oninput' && event?.type == 'blur') skipValidation = true; } if (skipValidation || !event || !options.validators || options.validators == 'clear') { if (event?.paths) { const formElement = event?.formElement ?? EnhancedForm_get(); if (formElement) Form__clearCustomValidity(formElement); } return; } const result = await Form_validate({ adapter }); // TODO: Add option for always setting result.data? if (result.valid && (event.immediate || event.type != 'input')) { Form.set(result.data, { taint: 'ignore' }); } // Wait for tainted, so object errors can be displayed await tick(); Form__displayNewErrors(result.errors, event, force); return result; } function Form__clearCustomValidity(formElement) { const validity = new Map(); if (options.customValidity && formElement) { for (const el of formElement.querySelectorAll(`[name]`)) { if (typeof el.name !== 'string' || !el.name.length) continue; const message = 'validationMessage' in el ? String(el.validationMessage) : ''; validity.set(el.name, { el, message }); updateCustomValidity(el, undefined); } } return validity; } async function Form__displayNewErrors(errors, event, force) { const { type, immediate, multiple, paths } = event; const previous = Data.errors; const output = {}; let validity = new Map(); const formElement = event.formElement ?? EnhancedForm_get(); if (formElement) validity = Form__clearCustomValidity(formElement); traversePaths(errors, (error) => { if (!Array.isArray(error.value)) return; const currentPath = [...error.path]; if (currentPath[currentPath.length - 1] == '_errors') { currentPath.pop(); } const joinedPath = currentPath.join('.'); function addError() { //console.log('Adding error', `[${error.path.join('.')}]`, error.value); //debug setPaths(output, [error.path], error.value); if (options.customValidity && isEventError && validity.has(joinedPath)) { const { el, message } = validity.get(joinedPath); if (message != error.value) { setTimeout(() => updateCustomValidity(el, error.value)); // Only need one error to display validity.clear(); } } } if (force) return addError(); const lastPath = error.path[error.path.length - 1]; const isObjectError = lastPath == '_errors'; const isEventError = error.value && paths.some((path) => { // If array/object, any part of the path can match. If not, exact match is required return isObjectError ? currentPath && path && currentPath.length > 0 && currentPath[0] == path[0] : joinedPath == path.join('.'); }); if (isEventError && options.validationMethod == 'oninput') return addError(); // Immediate, non-multiple input should display the errors if (immediate && !multiple && isEventError) return addError(); // Special case for multiple, which should display errors on blur // or if any error has existed previously. Tricky UX. if (multiple) { // For multi-select, if any error has existed, display all errors const errorPath = pathExists(get(Errors), error.path.slice(0, -1)); if (errorPath?.value && typeof errorPath?.value == 'object') { for (const errors of Object.values(errorPath.value)) { if (Array.isArray(errors)) { return addError(); } } } } // If previous error exist, always display const previousError = pathExists(previous, error.path); if (previousError && previousError.key in previousError.parent) { return addError(); } if (isObjectError) { // New object errors should be displayed on blur events, // or the (parent) path is or has been tainted. if (options.validationMethod == 'oninput' || (type == 'blur' && Tainted_hasBeenTainted(mergePath(error.path.slice(0, -1))))) { return addError(); } } else { // Display text errors on blur, if the event matches the error path // Also, display errors if the error is in an array an it has been tainted. if (type == 'blur' && isEventError //|| (isErrorInArray && Tainted_hasBeenTainted(mergePath(error.path.slice(0, -1)) as FormPath<T>)) ) { return addError(); } } }); Errors.set(output); } function Form_set(data, options = {}) { // Check if file fields should be kept, usually when the server returns them as undefined. // in that case remove the undefined field from the new data. if (options.keepFiles) { traversePaths(Data.form, (info) => { if ((!browser || !(info.parent instanceof FileList)) && (info.value instanceof File || (browser && info.value instanceof FileList))) { const dataPath = pathExists(data, info.path); if (!dataPath || !(dataPath.key in dataPath.parent)) { setPaths(data, [info.path], info.value); } } }); } return Form.set(data, options); } function Form_shouldReset(validForm, successActionResult) { return (validForm && successActionResult && options.resetForm && (options.resetForm === true || options.resetForm())); } function Form_capture(removeFilesfromData = true) { let data = Data.form; let tainted = Data.tainted; if (removeFilesfromData) { const removed = removeFiles(Data.form); data = removed.data; const paths = removed.paths; if (paths.length) { tainted = clone(tainted) ?? {}; setPaths(tainted, paths, false); } } return { valid: Data.valid, posted: Data.posted, errors: Data.errors, data, constraints: Data.constraints, message: Data.message, id: Data.formId, tainted, shape: Data.shape }; } async function Form_updateFromValidation(form2, successResult) { if (form2.valid && successResult && Form_shouldReset(form2.valid, successResult)) { Form_reset({ message: form2.message, posted: true }); } else { rebind({ form: form2, untaint: successResult, keepFiles: true, // Check if the form data should be used for updating, or if the invalidateAll load function should be used: pessimisticUpdate: options.invalidateAll == 'force' || options.invalidateAll == 'pessimistic' }); } // onUpdated may check stores, so need to wait for them to update. if (formEvents.onUpdated.length) { await tick(); } // But do not await on onUpdated itself, since we're already finished with the request for (const event of formEvents.onUpdated) { event({ form: form2 }); } } function Form_reset(opts = {}) { if (opts.newState) initialForm.data = { ...initialForm.data, ...opts.newState }; const resetData = clone(initialForm); resetData.data = { ...resetData.data, ...opts.data }; if (opts.id !== undefined) resetData.id = opts.id; rebind({ form: resetData, untaint: true, message: opts.message, keepFiles: false, posted: opts.posted, resetted: true }); } async function Form_updateFromActionResult(result) { if (result.type == 'error') { throw new SuperFormError(`ActionResult of type "${result.type}" cannot be passed to update function.`); } if (result.type == 'redirect') { // All we need to do if redirected is to reset the form. // No events should be triggered because technically we're somewhere else. if (Form_shouldReset(true, true)) Form_reset({ posted: true }); return; } if (typeof result.data !== 'object') { throw new SuperFormError('Non-object validation data returned from ActionResult.'); } const forms = Context_findValidationForms(result.data); if (!forms.length) { throw new SuperFormError('No form data returned from ActionResult. Make sure you return { form } in the form actions.'); } for (const newForm of forms) { if (newForm.id !== Data.formId) continue; await Form_updateFromValidation(newForm, result.status >= 200 && result.status < 300); } } //#endregion const Message = writable(__data.message); const Constraints = writable(__data.constraints); const Posted = writable(__data.posted); const Shape = writable(__data.shape); //#region Errors const _errors = writable(form.errors); // eslint-disable-next-line dci-lint/grouped-rolemethods const Errors = { subscribe: _errors.subscribe, set(value, options) { return _errors.set(updateErrors(value, Data.errors, options?.force)); }, update(updater, options) { return _errors.update((value) => { return updateErrors(updater(value), Data.errors, options?.force); }); }, /** * To work with client-side validation, errors cannot be deleted but must * be set to undefined, to know where they existed before (tainted+error check in oninput) */ clear: () => Errors.set({}) }; //#endregion //#region NextChange ///// let NextChange = null; function NextChange_setHtmlEvent(event) { // For File inputs, if only paths are available, use that instead of replacing // (fileProxy updates causes this) if (NextChange && event && Object.keys(event).length == 1 && event.paths?.length && NextChange.target && NextChange.target instanceof HTMLInputElement && NextChange.target.type.toLowerCase() == 'file') { NextChange.paths = event.paths; } else { NextChange = event; } // Wait for on:input to provide additional information setTimeout(() => { Form_clientValidation(NextChange); }, 0); } function NextChange_additionalEventInformation(event, immediate, multiple, formElement, target) { if (NextChange === null) { NextChange = { paths: [] }; } NextChange.type = event; NextChange.immediate = immediate; NextChange.multiple = multiple; NextChange.formElement = formElement; NextChange.target = target; } function NextChange_paths() { return NextChange?.paths ?? []; } function NextChange_clear() { NextChange = null; } //#endregion //#region Tainted const Tainted = { defaultMessage: 'Leave page? Changes that you made may not be saved.', state: writable(), message: options.taintedMessage, clean: clone(form.data), // Important to clone form.data, so it's not comparing the same object, forceRedirection: false }; function Tainted_isEnabled() { return (options.taintedMessage && !Data.submitting && !Tainted.forceRedirection && Tainted_isTainted()); } function Tainted_checkUnload(e) { if (!Tainted_isEnabled()) return; // Chrome requires returnValue to be set e.preventDefault(); e.returnValue = ''; // Prompt the user const { taintedMessage } = options; const isTaintedFunction = typeof taintedMessage === 'function'; const confirmationMessage = isTaintedFunction || taintedMessage === true ? Tainted.defaultMessage : taintedMessage; (e || window.event).returnValue = confirmationMessage || Tainted.defaultMessage; return confirmationMessage; } async function Tainted_beforeNav(nav) { if (!Tainted_isEnabled()) return; const { taintedMessage } = options; const isTaintedFunction = typeof taintedMessage === 'function'; // As beforeNavigate does not support Promise, we cancel the redirection until the promise resolve // if it's a custom function if (isTaintedFunction) nav.cancel(); // Does not display any dialog on page refresh or closing tab, will use Tainted_checkUnload if (nav.type === 'leave') { return; } const message = isTaintedFunction || taintedMessage === true ? Tainted.defaultMessage : taintedMessage; let shouldRedirect; try { // - rejected => shouldRedirect = false // - resolved with false => shouldRedirect = false // - resolved with true => shouldRedirect = true shouldRedirect = isTaintedFunction ? await taintedMessage(nav) : window.confirm(message || Tainted.defaultMessage); } catch { shouldRedirect = false; } if (shouldRedirect && nav.to) { try { Tainted.forceRedirection = true; await goto(nav.to.url, { ...nav.to.params }); return; } finally { // Reset forceRedirection for multiple-tainted purpose Tainted.forceRedirection = false; } } else if (!shouldRedirect && !isTaintedFunction) { nav.cancel(); } } function Tainted_enable() { options.taintedMessage = Tainted.message; } function Tainted_currentState() { return Tainted.state; } function Tainted_hasBeenTainted(path) { if (!Data.tainted) return false; if (!path) return !!Data.tainted; const field = pathExists(Data.tainted, splitPath(path)); return !!field && field.key in field.parent; } function Tainted_isTainted(path) { if (!arguments.length) return Tainted__isObjectTainted(Data.tainted); if (typeof path === 'boolean') return path; if (typeof path === 'object') return Tainted__isObjectTainted(path); if (!Data.tainted || path === undefined) return false; const field = pathExists(Data.tainted, splitPath(path)); return Tainted__isObjectTainted(field?.value); } function Tainted__isObjectTainted(obj) { if (!obj) return false; if (typeof obj === 'object') { for (const obj2 of Object.values(obj)) { if (Tainted__isObjectTainted(obj2)) return true; } } return obj === true; } /** * Updates the tainted state. Use most of the time, except when submitting. */ function Tainted_update(newData, taintOptions) { // Ignore is set when returning errors from the server // so status messages and form-level errors won't be // immediately cleared by client-side validation. if (taintOptions == 'ignore') return; const paths = comparePaths(newData, Data.form); //console.log('paths:', JSON.stringify(paths)); const newTainted = comparePaths(newData, Tainted.clean).map((path) => path.join()); //console.log('newTainted:', JSON.stringify(newTainted)); if (paths.length) { if (taintOptions == 'untaint-all' || taintOptions == 'untaint-form') { Tainted.state.set(undefined); } else { Tainted.state.update((currentlyTainted) => { if (!currentlyTainted) currentlyTainted = {}; setPaths(currentlyTainted, paths, (path, data) => { // If value goes back to the clean value, untaint the path if (!newTainted.includes(path.join())) return undefined; const currentValue = traversePath(newData, path); const cleanPath = traversePath(Tainted.clean, path); const identical = currentValue && cleanPath && currentValue.value === cleanPath.value; const output = identical ? undefined : taintOptions === true ? true : taintOptions === 'untaint' ? undefined : data.value; return output; }); return currentlyTainted; }); } NextChange_setHtmlEvent({ paths }); } } /** * Overwrites the current tainted state and setting a new clean state for the form data. * @param tainted * @param newClean */ function Tainted_set(tainted, newClean) { // TODO: Is it better to set tainted values to undefined instead of just overwriting? Tainted.state.set(tainted); if (newClean) Tainted.clean = newClean; } //#endregion //#region Timers const Submitting = writable(false); const Delayed = writable(false); // eslint-disable-next-line dci-lint/grouped-rolemethods const Timeout = writable(false); //#endregion //#region Unsubscriptions /** * Subscribe to certain stores and store the current value in Data, to avoid using get. * Need to clone the form data, so it won't refer to the same object and prevent change detection */ const Unsubscriptions = [ // eslint-disable-next-line dci-lint/private-role-access Tainted.state.subscribe((tainted) => (__data.tainted = clone(tainted))), // eslint-disable-next-line dci-lint/private-role-access Form.subscribe((form) => (__data.form = clone(form))), Errors.subscribe((errors) => (__data.errors = clone(errors))), FormId.subscribe((id) => (__data.formId = id)), Constraints.subscribe((constraints) => (__data.constraints = constraints)), Posted.subscribe((posted) => (__data.posted = posted)), Message.subscribe((message) => (__data.message = message)), Submitting.subscribe((submitting) => (__data.submitting = submitting)), Shape.subscribe((shape) => (__data.shape = shape)) ]; function Unsubscriptions_add(func) { Unsubscriptions.push(func); } function Unsubscriptions_unsubscribe() { Unsubscriptions.forEach((unsub) => unsub()); } //#endregion //#region EnhancedForm /** * Used for SPA action mode and options.customValidity to display errors, even if programmatically set */ let EnhancedForm; function EnhancedForm_get() { return EnhancedForm; } function EnhancedForm_createFromSPA(action) { EnhancedForm = document.createElement('form'); EnhancedForm.method = 'POST'; EnhancedForm.action = action; superFormEnhance(EnhancedForm); document.body.appendChild(EnhancedForm); } function EnhancedForm_setAction(action) { if (EnhancedForm) EnhancedForm.action = action; } function EnhancedForm_destroy() { if (EnhancedForm?.parentElement) { EnhancedForm.remove(); } EnhancedForm = undefined; } //#endregion const AllErrors = derived(Errors, ($errors) => ($errors ? flattenErrors($errors) : [])); ///// End of Roles ////////////////////////////////////////////////////////// // Need to clear this and set it again when use:enhance has run, to avoid showing the // tainted dialog when a form doesn't use it or the browser doesn't use JS. options.taintedMessage = undefined; // Role rebinding function rebind(opts) { //console.log('🚀 ~ file: superForm.ts:721 ~ rebind ~ form:', form.data); //debug const form = opts.form; const message = opts.message ?? form.message; if (opts.untaint || opts.resetted) { Tainted_set(typeof opts.untaint === 'boolean' ? undefined : opts.untaint, form.data); } // Form data is not tainted when rebinding. // Prevents object errors from being revalidated after rebind. // Check if form was invalidated (usually with options.invalidateAll) to prevent data from being // overwritten by the load function data if (!opts.pessimisticUpdate) { Form_set(form.data, { taint: 'ignore', keepFiles: opts.keepFiles }); } Message.set(message); if (opts.resetted) Errors.update(() => ({}), { force: true }); else Errors.set(form.errors); FormId.set(form.id); Posted.set(opts.posted ?? form.posted); // Constraints and shape will only be set when they exist. if (form.constraints) Constraints.set(form.constraints); if (form.shape) Shape.set(form.shape); // Only allowed non-subscribe __data access, here in rebind __data.valid = form.valid; if (options.flashMessage && shouldSyncFlash(options)) { const flash = options.flashMessage.module.getFlash(page); if (message && get(flash) === undefined) { // eslint-disable-next-line @typescript-eslint/no-explicit-any flash.set(message); } } } const formEvents = { onSubmit: options.onSubmit ? [options.onSubmit] : [], onResult: options.onResult ? [options.onResult] : [], onUpdate: options.onUpdate ? [options.onUpdate] : [], onUpdated: options.onUpdated ? [options.onUpdated] : [], onError: options.onError ? [options.onError] : [] }; ///// Store subscriptions /////////////////////////////////////////////////// if (browser) { // Set up events for tainted check window.addEventListener('beforeunload', Tainted_checkUnload); onDestroy(() => { window.removeEventListener('beforeunload', Tainted_checkUnload); }); beforeNavigate(Tainted_beforeNav); // Need to subscribe to catch page invalidation. Unsubscriptions_add(page.subscribe(async (pageUpdate) => { if (STORYBOOK_MODE && pageUpdate === undefined) { pageUpdate = { status: 200 }; } const successResult = pageUpdate.status >= 200 && pageUpdate.status < 300; if (options.applyAction && pageUpdate.form && typeof pageUpdate.form === 'object') { const actionData = pageUpdate.form; // If actionData is an error, it's sent here from triggerOnError if (actionData.type === 'error') return; for (const newForm of Context_findValidationForms(actionData)) { const isInitial = initialForms.has(newForm); if (newForm.id !== Data.formId || isInitial) { continue; } // Prevent multiple "posting" that can happen when components are recreated. initialForms.set(newForm, newForm); await Form_updateFromValidation(newForm, successResult); } } else if (options.applyAction !== 'never' && pageUpdate.data && typeof pageUpdate.data === 'object') { // It's a page reload, redirect or error/failure, // so don't trigger any events, just update the data. for (const newForm of Context_findValidationForms(pageUpdate.data)) { const isInitial = initialForms.has(newForm); if (newForm.id !== Data.formId || isInitial) { continue; } if (options.invalidateAll === 'force' || options.invalidateAll === 'pessimistic') { initialForm.data = newForm.data; } const resetStatus = Form_shouldReset(newForm.valid, true); rebind({ form: newForm, untaint: successResult, keepFiles: !resetStatus, resetted: resetStatus }); } } })); if (typeof options.SPA === 'string') { EnhancedForm_createFromSPA(options.SPA); } } /** * Custom use:enhance that enables all the client-side functionality. * @param FormElement * @param events * @DCI-context */ function superFormEnhance(FormElement, events) { if (options.SPA !== undefined && FormElement.method == 'get') FormElement.method = 'post'; if (typeof options.SPA === 'string') { if (options.SPA.length && FormElement.action == document.location.href) { FormElement.action = options.SPA; } } else { EnhancedForm = FormElement; } if (events) { if (events.onError) { if (options.onError === 'apply') { throw new SuperFormError('options.onError is set to "apply", cannot add any onError events.'); } else if (events.onError === 'apply') { throw new SuperFormError('Cannot add "apply" as onError event in use:enhance.'); } formEvents.onError.push(events.onError); } if (events.onResult) formEvents.onResult.push(events.onResult); if (events.onSubmit) formEvents.onSubmit.push(events.onSubmit); if (events.onUpdate) formEvents.onUpdate.push(events.onUpdate); if (events.onUpdated) formEvents.onUpdated.push(events.onUpdated); } // Now we know that we are enhanced, we can enable the tainted form option // for in-site navigation. Refresh and close tab is handled by window.beforeunload. Tainted_enable(); let lastInputChange; // TODO: Debounce option? async function onInput(e) { const info = inputInfo(e.target); // Need to wait for immediate updates due to some timing issue if (info.immediate && !info.file) await new Promise((r) => setTimeout(r, 0)); lastInputChange = NextChange_paths(); NextChange_additionalEventInformation('input', info.immediate, info.multiple, FormElement, e.target ?? undefined); } async function onBlur(e) { // Avoid triggering client-side validation while submitting if (Data.submitting) return; if (!lastInputChange || NextChange_paths() != lastInputChange) { return; } const info = inputInfo(e.target); // Need to wait for immediate updates due to some timing issue if (info.immediate && !info.file) await new Promise((r) => setTimeout(r, 0)); Form_clientValidation({ paths: lastInputChange, immediate: info.multiple, multiple: info.multiple, type: 'blur', formElement: FormElement, target: e.target ?? undefined }); // Clear input change event, now that the field doesn't have focus anymore. lastInputChange = undefined; } FormElement.addEventListener('focusout', onBlur); FormElement.addEventListener('input', onInput); onDestroy(() => { FormElement.removeEventListener('focusout', onBlur); FormElement.removeEventListener('input', onInput); }); ///// SvelteKit enhance function ////////////////////////////////// const htmlForm = HtmlForm(FormElement, { submitting: Submitting, delayed: Delayed, timeout: Timeout }, options); let currentRequest; let customRequest = undefined; const enhanced = kitEnhance(FormElement, async (submitParams) => { let jsonData = undefined; let validationAdapter = options.validators; // eslint-disable-next-line @typescript-eslint/no-unused-expressions undefined; const submit = { ...submitParams, jsonData(data) { if (options.dataType !== 'json') { throw new SuperFormError("options.dataType must be set to 'json' to use jsonData."); } jsonData = data; }, validators(adapter) { validationAdapter = adapter; }, customRequest(request) { customRequest = request; } }; const _submitCancel = submit.cancel; let cancelled = false; function clientValidationResult(validation) { const validationResult = { ...validation, posted: true }; const status = validationResult.valid ? 200 : Form_resultStatus(400); const data = { form: validationResult }; const result = validationResult.valid ? { type: 'success', status, data } : { type: 'failure', status, data }; setTimeout(() => validationResponse({ result }), 0); } function clearOnSubmit() { switch (options.clearOnSubmit) { case 'errors-and-message': Errors.clear(); Message.set(undefined); break; case 'errors': Errors.clear(); break; case 'message': Message.set(undefined); break; } } async function triggerOnError( // eslint-disable-next-line @typescript-eslint/no-explicit-any result, status) { // For v3, then return { form } as data in applyAction below: //const form: SuperValidated<T, M, In> = Form_capture(false); result.status = status; // Check if the error message should be replaced if (options.onError !== 'apply') { const event = { result, message: Message, form }; for (const onErrorEvent of formEvents.onError) { if (onErrorEvent !== 'apply' && (onErrorEvent != defaultOnError || !options.flashMessage?.onError)) { await onErrorEvent(event); } } } if (options.flashMessage && options.flashMessage.onError) { await options.flashMessage.onError({ result, flashMessage: options.flashMessage.module.getFlash(page) }); } if (options.applyAction) { if (options.onError == 'apply') { await applyAction(result); } else { // Transform to failure, to avoid data loss // Set the data to the error result, so it will be // picked up in page.subscribe in superForm. await applyAction({ type: 'failure', status: Form_resultStatus(result.status), data: result }); } } } function cancel(opts = { resetTimers: true }) { cancelled = true; if (opts.resetTimers && htmlForm.isSubmitting()) { htmlForm.completed({ cancelled });