sveltekit-superforms
Version:
Making SvelteKit forms a pleasure to use!
1,174 lines (1,173 loc) • 69.4 kB
JavaScript
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 });