sveltekit-superforms
Advanced tools
Comparing version 2.8.1 to 2.9.0
/// <reference types="svelte" /> | ||
/// <reference types="@sveltejs/kit" /> | ||
import type { FormOptions } from './superForm.js'; | ||
@@ -21,80 +20,2 @@ import type { Writable } from 'svelte/store'; | ||
}; | ||
export declare const scrollToFirstError: <T extends Record<string, unknown>, M>(Form: HTMLFormElement, options: Partial<{ | ||
id: string; | ||
applyAction: boolean; | ||
invalidateAll: boolean | "force"; | ||
resetForm: boolean | (() => boolean); | ||
scrollToError: boolean | "auto" | ScrollIntoViewOptions | "smooth" | "off"; | ||
autoFocusOnError: boolean | "detect"; | ||
errorSelector: string; | ||
selectErrorText: boolean; | ||
stickyNavbar: string; | ||
taintedMessage: string | boolean | (() => import("../utils.js").MaybePromise<boolean>) | null; | ||
SPA: true | { | ||
failStatus?: number | undefined; | ||
}; | ||
onSubmit: (input: { | ||
action: URL; | ||
formData: FormData; | ||
formElement: HTMLFormElement; | ||
controller: AbortController; | ||
submitter: HTMLElement | null; | ||
cancel(): void; | ||
} & { | ||
jsonData: (data: Record<string, unknown>) => void; | ||
validators: (validators: false | import("../adapters/adapters.js").ValidationAdapter<Partial<T>, Record<string, unknown>>) => void; | ||
}) => unknown; | ||
onResult: (event: { | ||
result: import("@sveltejs/kit").ActionResult; | ||
formEl: HTMLFormElement; | ||
formElement: HTMLFormElement; | ||
cancel: () => void; | ||
}) => unknown; | ||
onUpdate: (event: { | ||
form: import("./index.js").SuperValidated<T, M, T>; | ||
formEl: HTMLFormElement; | ||
formElement: HTMLFormElement; | ||
cancel: () => void; | ||
}) => unknown; | ||
onUpdated: (event: { | ||
form: Readonly<import("./index.js").SuperValidated<T, M, T>>; | ||
}) => unknown; | ||
onError: "apply" | ((event: { | ||
result: { | ||
type: "error"; | ||
status?: number | undefined; | ||
error: App.Error; | ||
}; | ||
}) => unknown); | ||
onChange: (event: import("./superForm.js").ChangeEvent<T>) => void; | ||
dataType: "form" | "json"; | ||
jsonChunkSize: number; | ||
validators: import("../adapters/adapters.js").ClientValidationAdapter<Partial<T>, Record<string, unknown>> | (false | "clear" | import("../adapters/adapters.js").ValidationAdapter<Partial<T>, Record<string, unknown>>); | ||
validationMethod: "auto" | "oninput" | "onblur" | "onsubmit" | "submit-only"; | ||
customValidity: boolean; | ||
clearOnSubmit: "message" | "none" | "errors" | "errors-and-message"; | ||
delayMs: number; | ||
timeoutMs: number; | ||
multipleSubmits: "abort" | "prevent" | "allow"; | ||
syncFlashMessage?: boolean | undefined; | ||
flashMessage: { | ||
module: { | ||
getFlash(page: import("svelte/store").Readable<import("@sveltejs/kit").Page<Record<string, string>, string | null>>): Writable<any>; | ||
updateFlash(page: import("svelte/store").Readable<import("@sveltejs/kit").Page<Record<string, string>, string | null>>, update?: (() => Promise<void>) | undefined): Promise<boolean>; | ||
}; | ||
onError?: ((event: { | ||
result: { | ||
type: "error"; | ||
status?: number | undefined; | ||
error: App.Error; | ||
}; | ||
flashMessage: Writable<any>; | ||
}) => unknown) | undefined; | ||
cookiePath?: string | undefined; | ||
cookieName?: string | undefined; | ||
}; | ||
warnings: { | ||
duplicateId?: boolean | undefined; | ||
}; | ||
legacy: boolean; | ||
}>) => Promise<void>; | ||
export declare const scrollToFirstError: <T extends Record<string, unknown>, M>(Form: HTMLFormElement, options: FormOptions<T, M>) => Promise<void>; |
@@ -6,3 +6,3 @@ /// <reference types="svelte" /> | ||
import { type FormPathType, type FormPath, type FormPathLeaves } from '../stringPath.js'; | ||
import { enhance } from '$app/forms'; | ||
import { enhance as kitEnhance } from '$app/forms'; | ||
import type { ValidationErrors } from '../superValidate.js'; | ||
@@ -37,3 +37,3 @@ import type { MaybePromise } from '../utils.js'; | ||
failStatus?: number; | ||
}; | ||
} | string; | ||
onSubmit: (input: Parameters<SubmitFunction>[0] & { | ||
@@ -162,6 +162,6 @@ /** | ||
options: T extends T ? FormOptions<T, M> : never; | ||
enhance: (el: HTMLFormElement, events?: SuperFormEvents<T, M>) => ReturnType<typeof enhance>; | ||
isTainted: (path?: FormPath<T> | TaintedFields<T> | boolean) => boolean; | ||
enhance: (el: HTMLFormElement, events?: SuperFormEvents<T, M>) => ReturnType<typeof kitEnhance>; | ||
isTainted: (path?: T extends T ? FormPath<T> | TaintedFields<T> | boolean : never) => boolean; | ||
reset: (options?: ResetOptions<T>) => void; | ||
submit: (submitter?: HTMLElement | null) => void; | ||
submit: (submitter?: HTMLElement | Event | EventTarget | null) => void; | ||
capture: Capture<T, M>; | ||
@@ -198,5 +198,5 @@ restore: T extends T ? Restore<T, M> : never; | ||
* 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. | ||
* @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 | ||
@@ -203,0 +203,0 @@ */ |
@@ -11,3 +11,3 @@ import { derived, get, readonly, writable } from 'svelte/store'; | ||
import { cancelFlash, shouldSyncFlash } from './flash.js'; | ||
import { applyAction, enhance } from '$app/forms'; | ||
import { applyAction, enhance as kitEnhance } from '$app/forms'; | ||
import { setCustomValidityForm, updateCustomValidity } from './customValidity.js'; | ||
@@ -18,6 +18,7 @@ import { inputInfo } from './elements.js'; | ||
import { fieldProxy } from './proxies.js'; | ||
import { shapeFromObject } from '../jsonSchema/schemaShape.js'; | ||
const formIds = new WeakMap(); | ||
const initialForms = new WeakMap(); | ||
const defaultOnError = (event) => { | ||
console.warn('Unhandled Superform error, use onError event to handle it:', event.result.error); | ||
console.warn('Unhandled error caught by Superforms, use onError event to handle it:', event.result.error); | ||
}; | ||
@@ -83,5 +84,5 @@ const defaultFormOptions = { | ||
* 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. | ||
* @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 | ||
@@ -106,2 +107,9 @@ */ | ||
} | ||
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; | ||
@@ -112,3 +120,4 @@ options = { | ||
}; | ||
if (options.SPA && options.validators === undefined) { | ||
if ((options.SPA === true || typeof options.SPA === 'object') && | ||
options.validators === undefined) { | ||
console.warn('No validators set for superForm in SPA mode. ' + | ||
@@ -124,7 +133,8 @@ 'Add a validation adapter to the validators option, or set it to false to disable this warning.'); | ||
form = { | ||
id: options.id ?? '', | ||
id: options.id ?? Math.random().toString(36).slice(2, 10), | ||
valid: false, | ||
posted: false, | ||
errors: {}, | ||
data: form | ||
data: form, | ||
shape: shapeFromObject(form) | ||
}; | ||
@@ -159,6 +169,2 @@ } | ||
initialForm = initialForms.get(form); | ||
if (typeof initialForm.valid !== 'boolean') { | ||
throw new SuperFormError('A non-validation object was passed to superForm. ' + | ||
'It should be an object of type SuperValidated, usually returned from superValidate.'); | ||
} | ||
// Detect if a form is posted without JavaScript. | ||
@@ -198,2 +204,3 @@ if (!browser && _currentPage.form && typeof _currentPage.form === 'object') { | ||
formIds.get(_currentPage)?.delete(_initialFormId); | ||
ActionForm_remove(); | ||
}); | ||
@@ -285,2 +292,5 @@ // Check for nested objects, throw if datatype isn't json | ||
}; | ||
function Form_isSPA() { | ||
return options.SPA === true || typeof options.SPA === 'object'; | ||
} | ||
async function Form_validate(opts = {}) { | ||
@@ -376,13 +386,20 @@ const dataToValidate = opts.formData ?? Data.form; | ||
} | ||
if (!event || !options.validators || options.validators == 'clear') | ||
return; | ||
let skipValidation = false; | ||
if (!force) { | ||
if (options.validationMethod == 'onsubmit' || options.validationMethod == 'submit-only') { | ||
return; | ||
skipValidation = true; | ||
} | ||
if (options.validationMethod == 'onblur' && event.type == 'input') | ||
return; | ||
if (options.validationMethod == 'oninput' && event.type == 'blur') | ||
return; | ||
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; | ||
if (formElement) | ||
Form__clearCustomValidity(formElement, event.paths); | ||
} | ||
return; | ||
} | ||
const result = await Form_validate({ adapter }); | ||
@@ -398,10 +415,6 @@ // TODO: Add option for always setting result.data? | ||
} | ||
async function Form__displayNewErrors(errors, event, force) { | ||
const { type, immediate, multiple, paths } = event; | ||
const previous = Data.errors; | ||
const output = {}; | ||
function Form__clearCustomValidity(formElement, paths) { | ||
const validity = new Map(); | ||
const formElement = event.formElement ?? EnhancedForm; | ||
if (options.customValidity && formElement) { | ||
for (const path of event.paths) { | ||
for (const path of paths) { | ||
const name = CSS.escape(mergePath(path)); | ||
@@ -416,2 +429,12 @@ const el = formElement.querySelector(`[name="${name}"]`); | ||
} | ||
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; | ||
if (formElement) | ||
validity = Form__clearCustomValidity(formElement, event.paths); | ||
traversePaths(errors, (error) => { | ||
@@ -745,2 +768,23 @@ if (!Array.isArray(error.value)) | ||
//#endregion | ||
//#region ActionForm | ||
// SPA action mode | ||
let ActionForm = undefined; | ||
function ActionForm_create(action) { | ||
ActionForm = document.createElement('form'); | ||
ActionForm.method = 'POST'; | ||
ActionForm.action = action; | ||
superFormEnhance(ActionForm); | ||
document.body.appendChild(ActionForm); | ||
} | ||
function ActionForm_setAction(action) { | ||
if (ActionForm) | ||
ActionForm.action = action; | ||
} | ||
function ActionForm_remove() { | ||
if (ActionForm?.parentElement) { | ||
ActionForm.remove(); | ||
ActionForm = undefined; | ||
} | ||
} | ||
//#endregion | ||
const AllErrors = derived(Errors, ($errors) => ($errors ? flattenErrors($errors) : [])); | ||
@@ -797,2 +841,3 @@ // Used for options.customValidity to display errors, even if programmatically set | ||
}; | ||
///// Store subscriptions /////////////////////////////////////////////////// | ||
if (browser) { | ||
@@ -880,3 +925,390 @@ // Tainted check | ||
})); | ||
if (typeof options.SPA === 'string') { | ||
ActionForm_create(options.SPA); | ||
} | ||
} | ||
/** | ||
* Custom use:enhance that enables all the client-side functionality. | ||
* @param FormElement | ||
* @param events | ||
* @DCI-context | ||
*/ | ||
function superFormEnhance(FormElement, events) { | ||
ActionForm_remove(); | ||
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, | ||
// so we can enable the tainted form option. | ||
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); | ||
EnhancedForm = undefined; | ||
}); | ||
///// SvelteKit enhance function ////////////////////////////////// | ||
const htmlForm = HtmlForm(FormElement, { submitting: Submitting, delayed: Delayed, timeout: Timeout }, options); | ||
let currentRequest; | ||
return kitEnhance(FormElement, async (submitParams) => { | ||
let jsonData = undefined; | ||
let validationAdapter = options.validators; | ||
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; | ||
} | ||
}; | ||
const _submitCancel = submit.cancel; | ||
let cancelled = false; | ||
function clientValidationResult(validation) { | ||
const validationResult = { ...validation, posted: true }; | ||
const status = validationResult.valid | ||
? 200 | ||
: (typeof options.SPA === 'boolean' || typeof options.SPA === 'string' | ||
? undefined | ||
: options.SPA?.failStatus) ?? 400; | ||
const data = { form: validationResult }; | ||
const result = validationResult.valid | ||
? { type: 'success', status, data } | ||
: { type: 'failure', status, data }; | ||
setTimeout(() => validationResponse({ result }), 0); | ||
} | ||
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) { | ||
await event(submit); | ||
} | ||
} | ||
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 }); | ||
}; | ||
if (!noValidate) { | ||
validation = await validateForm(); | ||
if (!validation.valid) { | ||
cancel({ resetTimers: false }); | ||
clientValidationResult(validation); | ||
} | ||
} | ||
if (!cancelled) { | ||
switch (options.clearOnSubmit) { | ||
case 'errors-and-message': | ||
Errors.clear(); | ||
Message.set(undefined); | ||
break; | ||
case 'errors': | ||
Errors.clear(); | ||
break; | ||
case 'message': | ||
Message.set(undefined); | ||
break; | ||
} | ||
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); | ||
} | ||
}); | ||
// Split the form data into chunks, in case it gets too large for proxy servers | ||
const chunks = chunkSubstr(stringify(postData), 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') { | ||
ActionForm_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; | ||
} | ||
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 = event.result.type | ||
? event.result | ||
: { | ||
type: 'error', | ||
status: 500, | ||
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(); | ||
}); | ||
for (const event of formEvents.onResult) { | ||
await event(data); | ||
} | ||
// 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) | ||
}; | ||
for (const event of formEvents.onUpdate) { | ||
await event(data); | ||
} | ||
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 { | ||
// Error result | ||
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. | ||
const failResult = { | ||
type: 'failure', | ||
status: Math.floor(result.status || 500), | ||
data: result | ||
}; | ||
await applyAction(failResult); | ||
} | ||
} | ||
// Check if the error message should be replaced | ||
if (options.onError !== 'apply') { | ||
const data = { result, message: Message }; | ||
for (const onErrorEvent of formEvents.onError) { | ||
if (onErrorEvent !== 'apply' && | ||
(onErrorEvent != defaultOnError || !options.flashMessage?.onError)) { | ||
await onErrorEvent(data); | ||
} | ||
} | ||
} | ||
} | ||
// Trigger flash message event if there was an error | ||
if (options.flashMessage) { | ||
if (result.type == 'error' && options.flashMessage.onError) { | ||
await options.flashMessage.onError({ | ||
result, | ||
flashMessage: options.flashMessage.module.getFlash(page) | ||
}); | ||
} | ||
} | ||
} | ||
} | ||
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(); | ||
} | ||
return validationResponse; | ||
}); | ||
} | ||
///// Return the SuperForm object ///////////////////////////////// | ||
@@ -997,377 +1429,4 @@ return { | ||
isTainted: Tainted_isTainted, | ||
///// Custom use:enhance //////////////////////////////////////// | ||
// @DCI-context | ||
enhance(FormElement, events) { | ||
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, | ||
// so we can enable the tainted form option. | ||
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); | ||
EnhancedForm = undefined; | ||
}); | ||
///// SvelteKit enhance function ////////////////////////////////// | ||
const htmlForm = HtmlForm(FormElement, { submitting: Submitting, delayed: Delayed, timeout: Timeout }, options); | ||
let currentRequest; | ||
return enhance(FormElement, async (submitParams) => { | ||
let jsonData = undefined; | ||
let validationAdapter = options.validators; | ||
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; | ||
} | ||
}; | ||
const _submitCancel = submit.cancel; | ||
let cancelled = false; | ||
function clientValidationResult(validation) { | ||
const validationResult = { ...validation, posted: true }; | ||
const status = validationResult.valid | ||
? 200 | ||
: (typeof options.SPA === 'boolean' ? undefined : options.SPA?.failStatus) ?? 400; | ||
const data = { form: validationResult }; | ||
const result = validationResult.valid | ||
? { type: 'success', status, data } | ||
: { type: 'failure', status, data }; | ||
setTimeout(() => validationResponse({ result }), 0); | ||
} | ||
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) { | ||
await event(submit); | ||
} | ||
} | ||
if (cancelled && options.flashMessage) | ||
cancelFlash(options); | ||
if (!cancelled) { | ||
// Client validation | ||
const noValidate = !options.SPA && | ||
(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 }); | ||
}; | ||
if (!noValidate) { | ||
validation = await validateForm(); | ||
if (!validation.valid) { | ||
cancel({ resetTimers: false }); | ||
clientValidationResult(validation); | ||
} | ||
} | ||
if (!cancelled) { | ||
switch (options.clearOnSubmit) { | ||
case 'errors-and-message': | ||
Errors.clear(); | ||
Message.set(undefined); | ||
break; | ||
case 'errors': | ||
Errors.clear(); | ||
break; | ||
case 'message': | ||
Message.set(undefined); | ||
break; | ||
} | ||
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 (options.SPA) { | ||
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); | ||
} | ||
}); | ||
// Split the form data into chunks, in case it gets too large for proxy servers | ||
const chunks = chunkSubstr(stringify(postData), options.jsonChunkSize ?? 500000); | ||
for (const chunk of chunks) { | ||
submitData.append('__superform_json', chunk); | ||
} | ||
} | ||
if (!options.SPA && !submitData.has('__superform_id')) { | ||
// Add formId | ||
const id = Data.formId; | ||
if (id !== undefined) | ||
submitData.set('__superform_id', id); | ||
} | ||
} | ||
} | ||
// 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; | ||
} | ||
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 = event.result.type | ||
? event.result | ||
: { | ||
type: 'error', | ||
status: 500, | ||
error: event.result | ||
}; | ||
const cancel = () => (cancelled = true); | ||
const data = { | ||
result, | ||
formEl: FormElement, | ||
formElement: FormElement, | ||
cancel | ||
}; | ||
const unsubCheckforNav = STORYBOOK_MODE | ||
? () => { } | ||
: 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(); | ||
}); | ||
for (const event of formEvents.onResult) { | ||
await event(data); | ||
} | ||
// 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) | ||
}; | ||
for (const event of formEvents.onUpdate) { | ||
await event(data); | ||
} | ||
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 { | ||
// Error result | ||
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. | ||
const failResult = { | ||
type: 'failure', | ||
status: Math.floor(result.status || 500), | ||
data: result | ||
}; | ||
await applyAction(failResult); | ||
} | ||
} | ||
// Check if the error message should be replaced | ||
if (options.onError !== 'apply') { | ||
const data = { result, message: Message }; | ||
for (const onErrorEvent of formEvents.onError) { | ||
if (onErrorEvent !== 'apply' && | ||
(onErrorEvent != defaultOnError || !options.flashMessage?.onError)) { | ||
await onErrorEvent(data); | ||
} | ||
} | ||
} | ||
} | ||
// Trigger flash message event if there was an error | ||
if (options.flashMessage) { | ||
if (result.type == 'error' && options.flashMessage.onError) { | ||
await options.flashMessage.onError({ | ||
result, | ||
flashMessage: options.flashMessage.module.getFlash(page) | ||
}); | ||
} | ||
} | ||
} | ||
} | ||
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(); | ||
} | ||
return validationResponse; | ||
}); | ||
} | ||
enhance: superFormEnhance | ||
}; | ||
} |
import { schemaInfo } from './schemaInfo.js'; | ||
import { merge as deepMerge } from 'ts-deepmerge'; | ||
export function constraints(schema) { | ||
return _constraints(schemaInfo(schema, false, []), []); | ||
} | ||
function merge(constraints) { | ||
let output = {}; | ||
for (const constraint of constraints) { | ||
if (!constraint) | ||
continue; | ||
output = { ...output, ...constraint }; | ||
} | ||
return output; | ||
function merge(...constraints) { | ||
const filtered = constraints.filter((c) => !!c); | ||
if (!filtered.length) | ||
return undefined; | ||
if (filtered.length == 1) | ||
return filtered[0]; | ||
return deepMerge(...filtered); | ||
} | ||
@@ -17,7 +17,8 @@ function _constraints(info, path) { | ||
return undefined; | ||
let output = undefined; | ||
// Union | ||
if (info.union) { | ||
if (info.union && info.union.length) { | ||
const infos = info.union.map((s) => schemaInfo(s, info.isOptional, path)); | ||
const merged = infos.map((i) => _constraints(i, path)); | ||
const output = merge(merged); | ||
output = merge(output, ...merged); | ||
// Delete required if any part of the union is optional | ||
@@ -28,15 +29,10 @@ if (output && | ||
} | ||
return output && Object.values(output).length ? output : undefined; | ||
} | ||
// Arrays | ||
if (info.array) { | ||
if (info.array.length == 1) { | ||
//console.log('Array constraint', schema, path); | ||
return _constraints(schemaInfo(info.array[0], info.isOptional, path), path); | ||
} | ||
return merge(info.array.map((i) => _constraints(schemaInfo(i, info.isOptional, path), path))); | ||
output = merge(output, ...info.array.map((i) => _constraints(schemaInfo(i, info.isOptional, path), path))); | ||
} | ||
// Objects | ||
if (info.properties) { | ||
const output = {}; | ||
const obj = {}; | ||
for (const [key, prop] of Object.entries(info.properties)) { | ||
@@ -46,8 +42,8 @@ const propInfo = schemaInfo(prop, !info.required?.includes(key) || prop.default !== undefined, [key]); | ||
if (typeof propConstraint === 'object' && Object.values(propConstraint).length > 0) { | ||
output[key] = propConstraint; | ||
obj[key] = propConstraint; | ||
} | ||
} | ||
return output; | ||
output = merge(output, obj); | ||
} | ||
return constraint(info); | ||
return output ?? constraint(info); | ||
} | ||
@@ -54,0 +50,0 @@ function constraint(info) { |
@@ -11,1 +11,2 @@ import type { JSONSchema } from './index.js'; | ||
export declare function schemaShape(schema: JSONSchema, path?: string[]): SchemaShape; | ||
export declare function shapeFromObject(obj: object): SchemaShape; |
@@ -34,1 +34,14 @@ import { SchemaError } from '../errors.js'; | ||
} | ||
export function shapeFromObject(obj) { | ||
let output = {}; | ||
const isArray = Array.isArray(obj); | ||
for (const [key, value] of Object.entries(obj)) { | ||
if (!value || typeof value !== 'object') | ||
continue; | ||
if (isArray) | ||
output = { ...output, ...shapeFromObject(value) }; | ||
else | ||
output[key] = shapeFromObject(value); | ||
} | ||
return output; | ||
} |
@@ -1,12 +0,5 @@ | ||
import type { IsAny } from './utils.js'; | ||
import type { AllKeys, IsAny, MergeUnion } from './utils.js'; | ||
export declare function splitPath(path: string): string[]; | ||
export declare function mergePath(path: (string | number | symbol)[]): string; | ||
type BuiltInObjects = Date | Set<unknown> | File; | ||
export type AllKeys<T> = T extends T ? keyof T : never; | ||
export type PickType<T, K extends AllKeys<T>> = T extends { | ||
[k in K]: any; | ||
} ? T[K] : never; | ||
export type MergeUnion<T> = { | ||
[K in AllKeys<T>]: PickType<T, K>; | ||
}; | ||
/** | ||
@@ -13,0 +6,0 @@ * Lists all paths in an object as string accessors. |
@@ -0,6 +1,7 @@ | ||
import type { AllKeys, MergeUnion } from './utils.js'; | ||
export type SuperStructArray<T extends Record<string, unknown>, Data, ArrayData = unknown> = { | ||
[Property in keyof T]?: T extends any ? NonNullable<T[Property]> extends Record<string, unknown> ? SuperStructArray<NonNullable<T[Property]>, Data, ArrayData> : NonNullable<T[Property]> extends (infer A)[] ? ArrayData & Record<number | string, NonNullable<A> extends Record<string, unknown> ? SuperStructArray<NonNullable<A>, Data, ArrayData> : Data> : Data : never; | ||
[Property in AllKeys<T>]?: [T] extends [any] ? NonNullable<T[Property]> extends Record<string, unknown> ? SuperStructArray<MergeUnion<NonNullable<T[Property]>>, Data, ArrayData> : NonNullable<T[Property]> extends (infer A)[] ? ArrayData & Record<number | string, NonNullable<A> extends Record<string, unknown> ? SuperStructArray<MergeUnion<NonNullable<A>>, Data, ArrayData> : Data> : Data : never; | ||
}; | ||
export type SuperStruct<T extends Record<string, unknown>, Data> = Partial<{ | ||
[Property in keyof T]: T extends any ? NonNullable<T[Property]> extends Record<string, unknown> ? SuperStruct<NonNullable<T[Property]>, Data> : NonNullable<T[Property]> extends (infer A)[] ? NonNullable<A> extends Record<string, unknown> ? SuperStruct<NonNullable<A>, Data> : Data : Data : never; | ||
[Property in AllKeys<T>]: [T] extends [any] ? NonNullable<T[Property]> extends Record<string, unknown> ? SuperStruct<MergeUnion<NonNullable<T[Property]>>, Data> : NonNullable<T[Property]> extends (infer A)[] ? NonNullable<A> extends Record<string, unknown> ? SuperStruct<MergeUnion<NonNullable<A>>, Data> : Data : Data : never; | ||
}>; |
@@ -12,1 +12,9 @@ import type { JSONSchema7Definition } from 'json-schema'; | ||
export declare function assertSchema(schema: JSONSchema7Definition, path: string | (string | number | symbol)[]): asserts schema is JSONSchema; | ||
export type AllKeys<T> = T extends T ? keyof T : never; | ||
type PickType<T, K extends AllKeys<T>> = T extends { | ||
[k in K]: any; | ||
} ? T[K] : never; | ||
export type MergeUnion<T> = { | ||
[K in AllKeys<T>]: PickType<T, K>; | ||
}; | ||
export {}; |
{ | ||
"name": "sveltekit-superforms", | ||
"version": "2.8.1", | ||
"version": "2.9.0", | ||
"author": "Andreas Söderlund <ciscoheat@gmail.com> (https://blog.encodeart.dev)", | ||
@@ -127,3 +127,3 @@ "description": "Making SvelteKit forms a pleasure to use!", | ||
"valibot": "^0.29.0", | ||
"yup": "^1.3.3", | ||
"yup": "^1.4.0", | ||
"zod": "^3.22.4", | ||
@@ -144,3 +144,3 @@ "zod-to-json-schema": "^3.22.4" | ||
"@types/json-schema": "^7.0.15", | ||
"@types/node": "^20.11.24", | ||
"@types/node": "^20.11.25", | ||
"@types/throttle-debounce": "^5.0.2", | ||
@@ -152,3 +152,3 @@ "@types/uuid": "^9.0.8", | ||
"eslint-config-prettier": "^9.1.0", | ||
"eslint-plugin-dci-lint": "^0.3.1", | ||
"eslint-plugin-dci-lint": "^0.3.2", | ||
"eslint-plugin-svelte": "2.36.0-next.8", | ||
@@ -163,7 +163,8 @@ "json-schema-to-ts": "^3.0.0", | ||
"svelte-check": "^3.6.6", | ||
"sveltekit-flash-message": "^2.4.2", | ||
"svelte-french-toast": "^1.2.0", | ||
"sveltekit-flash-message": "^2.4.4", | ||
"sveltekit-rate-limiter": "^0.4.3", | ||
"throttle-debounce": "^5.0.0", | ||
"tslib": "^2.6.2", | ||
"typescript": "^5.3.3", | ||
"typescript": "^5.4.2", | ||
"uuid": "^9.0.1", | ||
@@ -170,0 +171,0 @@ "vite": "^5.1.5", |
Sorry, the diff of this file is not supported yet
5
60
262558
31
5530