- Delete old Vite+Svelte frontend - Initialize new SvelteKit project with TypeScript - Configure Tailwind CSS v4 + DaisyUI - Implement JWT authentication with auto-refresh - Create login page with form validation (Zod) - Add protected route guards - Update Docker configuration for single-stage build - Add E2E tests with Playwright (6/11 passing) - Fix Svelte 5 reactivity with $state() runes Known issues: - 5 E2E tests failing (timing/async issues) - Token refresh implementation needs debugging - Validation error display timing
1610 lines
69 KiB
JavaScript
1610 lines
69 KiB
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('.');
|
|
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('.');
|
|
});
|
|
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();
|
|
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;
|
|
// Calculate which fields should remain tainted
|
|
// Only untaint fields that were actually reset (reverted to initial values)
|
|
const currentTainted = clone(__data.tainted);
|
|
const newTainted = {};
|
|
if (currentTainted && opts.data) {
|
|
// When partial data is provided, only untaint fields NOT included in opts.data
|
|
// since opts.data contains the fields that should KEEP their current values
|
|
for (const key in currentTainted) {
|
|
if (key in opts.data) {
|
|
// Field is in opts.data, so it's being kept - preserve its tainted state
|
|
newTainted[key] = currentTainted[key];
|
|
}
|
|
// Fields not in opts.data are being reset to initial values - don't preserve tainted state
|
|
}
|
|
}
|
|
rebind({
|
|
form: resetData,
|
|
untaint: Object.keys(newTainted).length > 0 ? newTainted : 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;
|
|
//@ts-expect-error Possible SvelteKit breaking change, it worked before.
|
|
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) {
|
|
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 });
|
|
}
|
|
if (taintOptions == 'untaint-all' || taintOptions == 'untaint-form') {
|
|
Tainted.state.set(undefined);
|
|
}
|
|
}
|
|
/**
|
|
* 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));
|
|
// If lastInputChange is undefined, someone set it to undefined before the preceding timeout, in which case we can ignore this blur event.
|
|
if (lastInputChange === undefined) {
|
|
return;
|
|
}
|
|
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 });
|
|
}
|
|
return _submitCancel();
|
|
}
|
|
submit.cancel = cancel;
|
|
if (htmlForm.isSubmitting() && options.multipleSubmits == 'prevent') {
|
|
cancel({ resetTimers: false });
|
|
}
|
|
else {
|
|
if (htmlForm.isSubmitting() && options.multipleSubmits == 'abort') {
|
|
if (currentRequest)
|
|
currentRequest.abort();
|
|
}
|
|
htmlForm.submitting();
|
|
currentRequest = submit.controller;
|
|
for (const event of formEvents.onSubmit) {
|
|
try {
|
|
await event(submit);
|
|
}
|
|
catch (error) {
|
|
cancel();
|
|
triggerOnError({ type: 'error', error }, 500);
|
|
}
|
|
}
|
|
}
|
|
if (cancelled && options.flashMessage)
|
|
cancelFlash(options);
|
|
if (!cancelled) {
|
|
// Client validation
|
|
const noValidate = !Form_isSPA() &&
|
|
(FormElement.noValidate ||
|
|
((submit.submitter instanceof HTMLButtonElement ||
|
|
submit.submitter instanceof HTMLInputElement) &&
|
|
submit.submitter.formNoValidate));
|
|
let validation = undefined;
|
|
const validateForm = async () => {
|
|
return await Form_validate({ adapter: validationAdapter });
|
|
};
|
|
clearOnSubmit();
|
|
if (!noValidate) {
|
|
validation = await validateForm();
|
|
if (!validation.valid) {
|
|
cancel({ resetTimers: false });
|
|
clientValidationResult(validation);
|
|
}
|
|
}
|
|
if (!cancelled) {
|
|
if (options.flashMessage &&
|
|
(options.clearOnSubmit == 'errors-and-message' || options.clearOnSubmit == 'message') &&
|
|
shouldSyncFlash(options)) {
|
|
options.flashMessage.module.getFlash(page).set(undefined);
|
|
}
|
|
// Deprecation fix
|
|
const submitData = 'formData' in submit ? submit.formData : submit.data;
|
|
// Prevent input/blur events to trigger client-side validation,
|
|
// and accidentally removing errors set by setError
|
|
lastInputChange = undefined;
|
|
if (Form_isSPA()) {
|
|
if (!validation)
|
|
validation = await validateForm();
|
|
cancel({ resetTimers: false });
|
|
clientValidationResult(validation);
|
|
}
|
|
else if (options.dataType === 'json') {
|
|
if (!validation)
|
|
validation = await validateForm();
|
|
const postData = clone(jsonData ?? validation.data);
|
|
// Move files to form data, since they cannot be serialized.
|
|
// Will be reassembled in superValidate.
|
|
traversePaths(postData, (data) => {
|
|
if (data.value instanceof File) {
|
|
const key = '__superform_file_' + mergePath(data.path);
|
|
submitData.append(key, data.value);
|
|
return data.set(undefined);
|
|
}
|
|
else if (Array.isArray(data.value) &&
|
|
data.value.length &&
|
|
data.value.every((v) => v instanceof File)) {
|
|
const key = '__superform_files_' + mergePath(data.path);
|
|
for (const file of data.value) {
|
|
submitData.append(key, file);
|
|
}
|
|
return data.set(undefined);
|
|
}
|
|
});
|
|
// Clear post data to reduce transfer size,
|
|
// since $form should be serialized and sent as json.
|
|
Object.keys(postData).forEach((key) => {
|
|
// Files should be kept though, even if same key.
|
|
if (typeof submitData.get(key) === 'string') {
|
|
submitData.delete(key);
|
|
}
|
|
});
|
|
const transport = options.transport
|
|
? Object.fromEntries(Object.entries(options.transport).map(([k, v]) => [k, v.encode]))
|
|
: undefined;
|
|
// Split the form data into chunks, in case it gets too large for proxy servers
|
|
const chunks = chunkSubstr(stringify(postData, transport), options.jsonChunkSize ?? 500000);
|
|
for (const chunk of chunks) {
|
|
submitData.append('__superform_json', chunk);
|
|
}
|
|
}
|
|
if (!submitData.has('__superform_id')) {
|
|
// Add formId
|
|
const id = Data.formId;
|
|
if (id !== undefined)
|
|
submitData.set('__superform_id', id);
|
|
}
|
|
if (typeof options.SPA === 'string') {
|
|
EnhancedForm_setAction(options.SPA);
|
|
}
|
|
}
|
|
}
|
|
///// End of submit interaction ///////////////////////////////////////
|
|
// Thanks to https://stackoverflow.com/a/29202760/70894
|
|
function chunkSubstr(str, size) {
|
|
const numChunks = Math.ceil(str.length / size);
|
|
const chunks = new Array(numChunks);
|
|
for (let i = 0, o = 0; i < numChunks; ++i, o += size) {
|
|
chunks[i] = str.substring(o, o + size);
|
|
}
|
|
return chunks;
|
|
}
|
|
// event can be a record if an external request was returning JSON,
|
|
// or if it failed parsing the expected JSON.
|
|
async function validationResponse(event) {
|
|
let cancelled = false;
|
|
currentRequest = null;
|
|
// Check if an error was thrown in hooks, in which case it has no type.
|
|
let result = 'type' in event.result && 'status' in event.result
|
|
? event.result
|
|
: {
|
|
type: 'error',
|
|
status: Form_resultStatus(parseInt(String(event.result.status)) || 500),
|
|
error: event.result.error instanceof Error ? event.result.error : event.result
|
|
};
|
|
const cancel = () => (cancelled = true);
|
|
const data = {
|
|
result,
|
|
formEl: FormElement,
|
|
formElement: FormElement,
|
|
cancel
|
|
};
|
|
const unsubCheckforNav = STORYBOOK_MODE || !Form_isSPA()
|
|
? () => { }
|
|
: navigating.subscribe(($nav) => {
|
|
// Check for goto to a different route in the events
|
|
if (!$nav || $nav.from?.route.id === $nav.to?.route.id)
|
|
return;
|
|
cancel();
|
|
});
|
|
function setErrorResult(error, data, status) {
|
|
data.result = {
|
|
type: 'error',
|
|
error,
|
|
status: Form_resultStatus(status)
|
|
};
|
|
}
|
|
for (const event of formEvents.onResult) {
|
|
try {
|
|
await event(data);
|
|
}
|
|
catch (error) {
|
|
setErrorResult(error, data, Math.max(result.status ?? 500, 400));
|
|
}
|
|
}
|
|
// In case it was modified in the event
|
|
result = data.result;
|
|
if (!cancelled) {
|
|
if ((result.type === 'success' || result.type === 'failure') && result.data) {
|
|
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;
|
|
const data = {
|
|
form: newForm,
|
|
formEl: FormElement,
|
|
formElement: FormElement,
|
|
cancel: () => (cancelled = true),
|
|
result: result
|
|
};
|
|
for (const event of formEvents.onUpdate) {
|
|
try {
|
|
await event(data);
|
|
}
|
|
catch (error) {
|
|
setErrorResult(error, data, Math.max(result.status ?? 500, 400));
|
|
}
|
|
}
|
|
// In case it was modified in the event
|
|
result = data.result;
|
|
if (!cancelled) {
|
|
if (options.customValidity) {
|
|
setCustomValidityForm(FormElement, data.form.errors);
|
|
}
|
|
// Special reset case for file inputs
|
|
if (Form_shouldReset(data.form.valid, result.type == 'success')) {
|
|
data.formElement
|
|
.querySelectorAll('input[type="file"]')
|
|
.forEach((e) => (e.value = ''));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (!cancelled) {
|
|
if (result.type !== 'error') {
|
|
if (result.type === 'success' && options.invalidateAll) {
|
|
await invalidateAll();
|
|
}
|
|
if (options.applyAction) {
|
|
// This will trigger the page subscription in superForm,
|
|
// which will in turn call Data_update.
|
|
await applyAction(result);
|
|
}
|
|
else {
|
|
// Call Data_update directly to trigger events
|
|
await Form_updateFromActionResult(result);
|
|
}
|
|
}
|
|
else {
|
|
await triggerOnError(result, Math.max(result.status ?? 500, 400));
|
|
}
|
|
}
|
|
}
|
|
if (cancelled && options.flashMessage) {
|
|
cancelFlash(options);
|
|
}
|
|
// Redirect messages are handled in onDestroy and afterNavigate in client/form.ts.
|
|
if (cancelled || result.type != 'redirect') {
|
|
htmlForm.completed({ cancelled });
|
|
}
|
|
else if (STORYBOOK_MODE) {
|
|
htmlForm.completed({ cancelled, clearAll: true });
|
|
}
|
|
else {
|
|
const unsub = navigating.subscribe(($nav) => {
|
|
if ($nav)
|
|
return;
|
|
// Timeout required when applyAction is false
|
|
setTimeout(() => {
|
|
try {
|
|
if (unsub)
|
|
unsub();
|
|
}
|
|
catch {
|
|
// If component is already destroyed?
|
|
}
|
|
});
|
|
if (htmlForm.isSubmitting()) {
|
|
htmlForm.completed({ cancelled, clearAll: true });
|
|
}
|
|
});
|
|
}
|
|
unsubCheckforNav();
|
|
}
|
|
if (!cancelled && customRequest) {
|
|
_submitCancel();
|
|
const response = await customRequest(submitParams);
|
|
let result;
|
|
if (response instanceof Response) {
|
|
result = deserialize(await response.text());
|
|
}
|
|
else if (response instanceof XMLHttpRequest) {
|
|
result = deserialize(response.responseText);
|
|
}
|
|
else {
|
|
result = response;
|
|
}
|
|
if (result.type === 'error')
|
|
result.status = response.status;
|
|
validationResponse({ result });
|
|
}
|
|
return validationResponse;
|
|
});
|
|
return {
|
|
destroy: () => {
|
|
// Remove only events added in enhance
|
|
for (const [name, events] of Object.entries(formEvents)) {
|
|
// @ts-expect-error formEvents and options have the same keys
|
|
formEvents[name] = events.filter((e) => e === options[name]);
|
|
}
|
|
enhanced.destroy();
|
|
}
|
|
};
|
|
}
|
|
function removeFiles(formData) {
|
|
const paths = [];
|
|
traversePaths(formData, (data) => {
|
|
if (data.value instanceof File) {
|
|
paths.push(data.path);
|
|
return 'skip';
|
|
}
|
|
else if (Array.isArray(data.value) &&
|
|
data.value.length &&
|
|
data.value.every((d) => d instanceof File)) {
|
|
paths.push(data.path);
|
|
return 'skip';
|
|
}
|
|
});
|
|
if (!paths.length)
|
|
return { data: formData, paths };
|
|
const data = clone(formData);
|
|
setPaths(data, paths, (path) => pathExists(initialForm.data, path)?.value);
|
|
return { data, paths };
|
|
}
|
|
///// Return the SuperForm object /////////////////////////////////
|
|
return {
|
|
form: Form,
|
|
formId: FormId,
|
|
errors: Errors,
|
|
message: Message,
|
|
constraints: Constraints,
|
|
tainted: Tainted_currentState(),
|
|
submitting: readonly(Submitting),
|
|
delayed: readonly(Delayed),
|
|
timeout: readonly(Timeout),
|
|
options: options,
|
|
capture: Form_capture,
|
|
restore: ((snapshot) => {
|
|
rebind({ form: snapshot, untaint: snapshot.tainted ?? true });
|
|
}),
|
|
async validate(path, opts = {}) {
|
|
if (!options.validators) {
|
|
throw new SuperFormError('options.validators must be set to use the validate method.');
|
|
}
|
|
if (opts.update === undefined)
|
|
opts.update = true;
|
|
if (opts.taint === undefined)
|
|
opts.taint = false;
|
|
if (typeof opts.errors == 'string')
|
|
opts.errors = [opts.errors];
|
|
let data;
|
|
const splittedPath = splitPath(path);
|
|
if ('value' in opts) {
|
|
if (opts.update === true || opts.update === 'value') {
|
|
// eslint-disable-next-line dci-lint/private-role-access
|
|
Form.update(($form) => {
|
|
setPaths($form, [splittedPath], opts.value);
|
|
return $form;
|
|
}, { taint: opts.taint });
|
|
data = Data.form;
|
|
}
|
|
else {
|
|
data = clone(Data.form);
|
|
setPaths(data, [splittedPath], opts.value);
|
|
}
|
|
}
|
|
else {
|
|
data = Data.form;
|
|
}
|
|
const result = await Form_validate({ formData: data });
|
|
const error = pathExists(result.errors, splittedPath);
|
|
// Replace with custom error, if it exist
|
|
if (error && error.value && opts.errors) {
|
|
error.value = opts.errors;
|
|
}
|
|
if (opts.update === true || opts.update == 'errors') {
|
|
Errors.update(($errors) => {
|
|
setPaths($errors, [splittedPath], error?.value);
|
|
return $errors;
|
|
});
|
|
}
|
|
return error?.value;
|
|
},
|
|
async validateForm(opts = {}) {
|
|
if (!options.validators && !opts.schema) {
|
|
throw new SuperFormError('options.validators or the schema option must be set to use the validateForm method.');
|
|
}
|
|
const result = opts.update
|
|
? await Form_clientValidation({ paths: [] }, true, opts.schema)
|
|
: Form_validate({ adapter: opts.schema });
|
|
const enhancedForm = EnhancedForm_get();
|
|
if (opts.update && enhancedForm) {
|
|
// Focus on first error field
|
|
setTimeout(() => {
|
|
if (!enhancedForm)
|
|
return;
|
|
scrollToFirstError(enhancedForm, {
|
|
...options,
|
|
scrollToError: opts.focusOnError === false ? 'off' : options.scrollToError
|
|
});
|
|
}, 1);
|
|
}
|
|
return result || Form_validate({ adapter: opts.schema });
|
|
},
|
|
allErrors: AllErrors,
|
|
posted: Posted,
|
|
reset(options) {
|
|
return Form_reset({
|
|
message: options?.keepMessage ? Data.message : undefined,
|
|
data: options?.data,
|
|
id: options?.id,
|
|
newState: options?.newState
|
|
});
|
|
},
|
|
submit(submitter) {
|
|
const form = EnhancedForm_get()
|
|
? EnhancedForm_get()
|
|
: submitter && submitter instanceof HTMLElement
|
|
? submitter.closest('form')
|
|
: undefined;
|
|
if (!form) {
|
|
throw new SuperFormError('use:enhance must be added to the form to use submit, or pass a HTMLElement inside the form (or the form itself) as an argument.');
|
|
}
|
|
if (!form.requestSubmit) {
|
|
return form.submit();
|
|
}
|
|
const isSubmitButton = submitter &&
|
|
((submitter instanceof HTMLButtonElement && submitter.type == 'submit') ||
|
|
(submitter instanceof HTMLInputElement && ['submit', 'image'].includes(submitter.type)));
|
|
form.requestSubmit(isSubmitButton ? submitter : undefined);
|
|
},
|
|
isTainted: Tainted_isTainted,
|
|
enhance: superFormEnhance
|
|
};
|
|
}
|