sveltekit-superforms
Version:
Making SvelteKit validation and displaying of forms easier than ever!
548 lines (547 loc) • 22.4 kB
JavaScript
import { beforeNavigate } from '$app/navigation';
import { page } from '$app/stores';
import { derived, get, writable } from 'svelte/store';
import { onDestroy, tick } from 'svelte';
import { browser } from '$app/environment';
import { SuperFormError } from '../index.js';
import { comparePaths, setPaths, pathExists, isInvalidPath } from '../traversal.js';
import { fieldProxy } from './proxies.js';
import { clone } from '../utils.js';
import { splitPath } from '../stringPath.js';
import { validateField, validateObjectErrors } from './clientValidation.js';
import { formEnhance, shouldSyncFlash } from './formEnhance.js';
import { clearErrors, flattenErrors } from '../errors.js';
import { clientValidation, validateForm } from './clientValidation.js';
export { intProxy, numberProxy, booleanProxy, dateProxy, fieldProxy, formFieldProxy, stringProxy } from './proxies.js';
export { superValidate, superValidateSync, actionResult, message, setMessage, setError, defaultValues } from '../superValidate.js';
const defaultFormOptions = {
applyAction: true,
invalidateAll: true,
resetForm: false,
autoFocusOnError: 'detect',
scrollToError: 'smooth',
errorSelector: '[aria-invalid="true"],[data-invalid]',
selectErrorText: false,
stickyNavbar: undefined,
taintedMessage: 'Do you want to leave this page? Changes you made may not be saved.',
onSubmit: undefined,
onResult: undefined,
onUpdate: undefined,
onUpdated: undefined,
onError: (event) => {
console.warn('Unhandled Superform error, use onError event to handle it:', event.result.error);
},
dataType: 'form',
validators: undefined,
defaultValidator: 'keep',
customValidity: false,
clearOnSubmit: 'errors-and-message',
delayMs: 500,
timeoutMs: 8000,
multipleSubmits: 'prevent',
validation: undefined,
SPA: undefined,
validateMethod: 'auto'
};
const formIds = new WeakMap();
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');
}
/**
* Initializes a SvelteKit form, for convenient handling of values, errors and sumbitting data.
* @param {SuperValidated} form Usually data.form from PageData.
* @param {FormOptions} options Configuration for the form.
* @returns {SuperForm} An object with properties for the form.
* @DCI-context
*/
export function superForm(form, options = {}) {
// Option guards
{
options = {
...defaultFormOptions,
...options
};
if (options.SPA && options.validators === undefined) {
console.warn('No validators set for superForm in SPA mode. ' +
'Add them to the validators option, or set it to false to disable this warning.');
}
}
let _formId = options.id;
// Normalize form argument to SuperValidated<T, M>
if (!form || Context_isValidationObject(form) === false) {
if (options.warnings?.noValidationAndConstraints !== false) {
console.warn((form
? 'Form data sent directly to superForm instead of through superValidate. No initial data validation is made. '
: 'No form data sent to superForm, schema type safety cannot be guaranteed. ') +
'Also, no constraints will exist for the form. ' +
'Set the warnings.noValidationAndConstraints option to false to disable this warning.');
}
form = {
valid: false,
posted: false,
errors: {},
data: form ?? {},
constraints: {}
};
}
else {
if (_formId === undefined)
_formId = form.id;
}
const _initialFormId = _formId;
const _currentPage = get(page);
// Check multiple id's
if (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);
}
}
}
// Detect if a form is posted without JavaScript.
const postedData = _currentPage.form;
if (postedData && typeof postedData === 'object') {
for (const postedForm of Context_findValidationForms(postedData).reverse()) {
if (postedForm.id === _formId) {
const pageDataForm = form;
form = postedForm;
// 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 = postedForm.message;
}
break;
}
}
}
const form2 = form;
// Need to clone the validation data, in case it's used to populate multiple forms.
const initialForm = clone(form2);
if (typeof initialForm.valid !== 'boolean') {
throw new SuperFormError('A non-validation object was passed to superForm. ' +
"Check what's passed to its first parameter.");
}
// Underlying store for Errors
const _errors = writable(form2.errors);
///// Roles ///////////////////////////////////////////////////////
const FormId = writable(_formId);
const Context = {
taintedMessage: options.taintedMessage,
taintedFormState: clone(initialForm.data)
};
function Context_randomId(length = 8) {
return Math.random()
.toString(36)
.substring(2, length + 2);
}
function Context_setTaintedFormState(data) {
Context.taintedFormState = clone(data);
}
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 may be undefined, so a falsy check isn't enough.
*/
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
: undefined;
}
function Context_useEnhanceEnabled() {
options.taintedMessage = Context.taintedMessage;
if (_formId === undefined)
FormId.set(Context_randomId());
}
function Context_newFormStore(data) {
const _formData = writable(data);
return {
subscribe: _formData.subscribe,
set: (value, options = {}) => {
Tainted_update(value, Context.taintedFormState, options.taint ?? true);
Context_setTaintedFormState(value);
// Need to clone the value, so it won't refer to $page for example.
return _formData.set(clone(value));
},
update: (updater, options = {}) => {
return _formData.update((value) => {
const output = updater(value);
Tainted_update(output, Context.taintedFormState, options.taint ?? true);
Context_setTaintedFormState(output);
// No cloning here, since it's an update
return output;
});
}
};
}
const Unsubscriptions = [
FormId.subscribe((id) => (_formId = id))
];
function Unsubscriptions_add(func) {
Unsubscriptions.push(func);
}
function Unsubscriptions_unsubscribe() {
Unsubscriptions.forEach((unsub) => unsub());
}
// Stores for the properties of SuperValidated<T, M>
const Form = Context_newFormStore(form2.data);
// Check for nested objects, throw if datatype isn't json
function Form_checkForNestedData(key, value) {
if (!value || typeof value !== 'object')
return;
if (Array.isArray(value)) {
if (value.length > 0)
Form_checkForNestedData(key, value[0]);
}
else if (!(value instanceof Date)) {
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`);
}
}
async function Form_updateFromValidation(form, untaint) {
if (form.valid &&
untaint &&
options.resetForm &&
(options.resetForm === true || options.resetForm())) {
Form_reset(form.message);
}
else {
rebind(form, untaint);
}
// 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 });
}
}
function Form_reset(message, data, id) {
const resetData = clone(initialForm);
resetData.data = { ...resetData.data, ...data };
if (id !== undefined)
resetData.id = id;
rebind(resetData, true, message);
}
const Form_updateFromActionResult = async (result, untaint) => {
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 (options.resetForm &&
(options.resetForm === true || options.resetForm())) {
Form_reset();
}
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 !== _formId)
continue;
await Form_updateFromValidation(newForm, untaint ?? (result.status >= 200 && result.status < 300));
}
};
const LastChanges = writable([]);
const Message = writable(form2.message);
const Constraints = writable(form2.constraints);
const Posted = writable(false);
// eslint-disable-next-line dci-lint/grouped-rolemethods
const Errors = {
subscribe: _errors.subscribe,
set: _errors.set,
update: _errors.update,
/**
* 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: () => clearErrors(_errors, {
undefinePath: null,
clearFormLevelErrors: true
})
};
const Tainted = writable();
function Tainted_data() {
return get(Tainted);
}
function Tainted_isTainted(obj) {
if (obj === null)
throw new SuperFormError('$tainted store contained null');
if (typeof obj === 'object') {
for (const obj2 of Object.values(obj)) {
if (Tainted_isTainted(obj2))
return true;
}
}
return obj === true;
}
async function Tainted__validate(path, taint) {
if (options.validationMethod == 'onblur' ||
options.validationMethod == 'submit-only') {
return false;
}
let shouldValidate = options.validationMethod === 'oninput';
if (!shouldValidate) {
const errorContent = get(Errors);
const errorNode = errorContent
? pathExists(errorContent, path, {
modifier: (pathData) => {
// Check if we have found a string in an error array.
if (isInvalidPath(path, pathData)) {
throw new SuperFormError('Errors can only be added to form fields, not to arrays or objects in the schema. Path: ' +
pathData.path.slice(0, -1));
}
return pathData.value;
}
})
: undefined;
// Need a special check here, since if the error has never existed,
// there won't be a key for the error. But if it existed and was cleared,
// the key exists with the value undefined.
const hasError = errorNode && errorNode.key in errorNode.parent;
shouldValidate = !!hasError;
}
if (shouldValidate) {
await validateField(path, options, Form, Errors, Tainted, { taint });
return true;
}
else {
return false;
}
}
async function Tainted_update(newObj, compareAgainst, taintOptions) {
if (taintOptions === false) {
return;
}
else if (taintOptions === 'untaint-all') {
Tainted.set(undefined);
return;
}
let paths = comparePaths(newObj, compareAgainst);
if (typeof taintOptions === 'object') {
if (typeof taintOptions.fields === 'string')
taintOptions.fields = [taintOptions.fields];
paths = taintOptions.fields.map((path) => splitPath(path));
taintOptions = true;
}
if (taintOptions === true) {
LastChanges.set(paths);
}
if (paths.length) {
Tainted.update((tainted) => {
//console.log('Update tainted:', paths, newObj, compareAgainst);
if (!tainted)
tainted = {};
setPaths(tainted, paths, taintOptions === true ? true : undefined);
return tainted;
});
let updated = false;
for (const path of paths) {
updated = updated || (await Tainted__validate(path, taintOptions));
}
if (!updated)
await validateObjectErrors(options, get(Form), Errors);
}
}
function Tainted_set(tainted, newData) {
Tainted.set(tainted);
Context_setTaintedFormState(newData);
}
// Timers
const Submitting = writable(false);
const Delayed = writable(false);
const Timeout = writable(false);
// Utilities
const AllErrors = derived(Errors, ($errors) => {
if (!$errors)
return [];
return flattenErrors($errors);
});
//////////////////////////////////////////////////////////////////////
// Need to clear this and set it after 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;
onDestroy(() => {
Unsubscriptions_unsubscribe();
for (const events of Object.values(formEvents)) {
events.length = 0;
}
formIds.get(_currentPage)?.delete(_initialFormId);
});
if (options.dataType !== 'json') {
for (const [key, value] of Object.entries(form2.data)) {
Form_checkForNestedData(key, value);
}
}
function rebind(form, untaint, message) {
if (untaint) {
Tainted_set(typeof untaint === 'boolean' ? undefined : untaint, form.data);
}
message = message ?? form.message;
// Form data is not tainted when rebinding.
// Prevents object errors from being revalidated after rebind.
// eslint-disable-next-line dci-lint/private-role-access
Form.set(form.data, { taint: false });
Message.set(message);
Errors.set(form.errors);
FormId.set(form.id);
Posted.set(form.posted);
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] : []
};
///// When use:enhance is enabled ///////////////////////////////////////////
if (browser) {
beforeNavigate((nav) => {
if (options.taintedMessage && !get(Submitting)) {
const taintStatus = Tainted_data();
if (taintStatus &&
Tainted_isTainted(taintStatus) &&
!window.confirm(options.taintedMessage)) {
nav.cancel();
}
}
});
// Need to subscribe to catch page invalidation.
Unsubscriptions_add(page.subscribe(async (pageUpdate) => {
if (!options.applyAction)
return;
const untaint = pageUpdate.status >= 200 && pageUpdate.status < 300;
if (pageUpdate.form && typeof pageUpdate.form === 'object') {
// Check if it is an error result, sent here from formEnhance
if (pageUpdate.form.type == 'error')
return;
const forms = Context_findValidationForms(pageUpdate.form);
for (const newForm of forms) {
//console.log('🚀~ ActionData ~ newForm:', newForm.id);
if (newForm.id !== _formId)
continue;
await Form_updateFromValidation(newForm, untaint);
}
}
else if (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.
const forms = Context_findValidationForms(pageUpdate.data);
for (const newForm of forms) {
//console.log('🚀 ~ PageData ~ newForm:', newForm.id);
if (newForm.id !== _formId)
continue;
rebind(newForm, untaint);
}
}
}));
}
const Fields = Object.fromEntries(Object.keys(initialForm.data).map((key) => {
return [
key,
{
name: key,
value: fieldProxy(Form, key),
errors: fieldProxy(Errors, key),
constraints: fieldProxy(Constraints, key)
}
];
}));
function validate(path, opts) {
if (path === undefined) {
return clientValidation(options, get(Form), _formId, get(Constraints), false);
}
return validateField(splitPath(path), options, Form, Errors, Tainted, opts);
}
return {
form: Form,
formId: FormId,
errors: Errors,
message: Message,
constraints: Constraints,
fields: Fields,
tainted: Tainted,
submitting: derived(Submitting, ($s) => $s),
delayed: derived(Delayed, ($d) => $d),
timeout: derived(Timeout, ($t) => $t),
options,
capture: function () {
return {
valid: initialForm.valid,
posted: get(Posted),
errors: get(Errors),
data: get(Form),
constraints: get(Constraints),
message: get(Message),
id: _formId,
tainted: get(Tainted)
};
},
restore: function (snapshot) {
return rebind(snapshot, snapshot.tainted ?? true);
},
validate: validate,
enhance: (el, events) => {
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);
}
return formEnhance(el, Submitting, Delayed, Timeout, Errors, Form_updateFromActionResult, options, Form, Message, Context_useEnhanceEnabled, formEvents, FormId, Constraints, Tainted, LastChanges, Context_findValidationForms, Posted);
},
allErrors: AllErrors,
posted: Posted,
reset: (options) => Form_reset(options?.keepMessage ? get(Message) : undefined, options?.data, options?.id)
};
}