sveltekit-superforms
Version:
Making SvelteKit validation and displaying of forms easier than ever!
348 lines (347 loc) • 15 kB
JavaScript
import { enhance, applyAction } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import { page } from '$app/stores';
import { get } from 'svelte/store';
import { browser } from '$app/environment';
import { SuperFormError } from '../index.js';
import { stringify } from 'devalue';
import { clientValidation, validateField } from './clientValidation.js';
import { Form } from './form.js';
import { onDestroy } from 'svelte';
import { traversePath } from '../traversal.js';
import { mergePath, splitPath } from '../stringPath.js';
export function cancelFlash(options) {
if (!options.flashMessage || !browser)
return;
if (!shouldSyncFlash(options))
return;
document.cookie = `flash=; Max-Age=0; Path=${options.flashMessage.cookiePath ?? '/'};`;
}
export function shouldSyncFlash(options) {
if (!options.flashMessage || !browser)
return false;
return options.syncFlashMessage;
}
///// Custom validity /////
const noCustomValidityDataAttribute = 'noCustomValidity';
function setCustomValidity(el, errors) {
const message = errors && errors.length ? errors.join('\n') : '';
el.setCustomValidity(message);
if (message)
el.reportValidity();
}
function setCustomValidityForm(formEl, errors) {
for (const el of formEl.querySelectorAll('input')) {
if (noCustomValidityDataAttribute in el.dataset)
continue;
const error = traversePath(errors, splitPath(el.name));
setCustomValidity(el, error?.value);
if (error?.value)
return;
}
}
//////////////////////////////////
/**
* Custom use:enhance version. Flash message support, friendly error messages, for usage with initializeForm.
* @param formEl Form element from the use:formEnhance default parameter.
*/
export function formEnhance(formEl, submitting, delayed, timeout, errs, Form_updateFromActionResult, options, data, message, enableTaintedForm, formEvents, formId, constraints, tainted, lastChanges, Context_findValidationForms, posted) {
// Now we know that we are upgraded, so we can enable the tainted form option.
enableTaintedForm();
// Using this type in the function argument causes a type recursion error.
const errors = errs;
async function validateChange(change, event, validityEl) {
if (options.customValidity && validityEl) {
// Always reset validity, in case it has been validated on the server.
if ('setCustomValidity' in validityEl) {
validityEl.setCustomValidity('');
}
// If event is input but element shouldn't use custom validity,
// return immediately since validateField don't have to be called
// in this case, validation is happening elsewhere.
if (noCustomValidityDataAttribute in validityEl.dataset)
if (event == 'input')
return;
else
validityEl = null;
}
const newErrors = await validateField(change, options, data, errors, tainted);
if (validityEl) {
setCustomValidity(validityEl, newErrors);
}
}
/**
* Some input fields have timing issues with the stores, need to wait in that case.
*/
function timingIssue(el) {
return (el &&
(el instanceof HTMLSelectElement ||
(el instanceof HTMLInputElement &&
(el.type == 'radio' || el.type == 'checkbox'))));
}
// Add blur event, to check tainted
async function checkBlur(e) {
if (options.validationMethod == 'oninput' ||
options.validationMethod == 'submit-only') {
return;
}
if (timingIssue(e.target)) {
await new Promise((r) => setTimeout(r, 0));
}
for (const change of get(lastChanges)) {
let validityEl = null;
if (options.customValidity) {
const name = CSS.escape(mergePath(change));
validityEl = formEl.querySelector(`[name="${name}"]`);
}
validateChange(change, 'blur', validityEl);
}
// Clear last changes after blur (not after input)
lastChanges.set([]);
}
formEl.addEventListener('focusout', checkBlur);
// Add input event, for custom validity
async function checkCustomValidity(e) {
if (timingIssue(e.target)) {
await new Promise((r) => setTimeout(r, 0));
}
for (const change of get(lastChanges)) {
const name = CSS.escape(mergePath(change));
const validityEl = formEl.querySelector(`[name="${name}"]`);
if (!validityEl)
continue;
const hadErrors = traversePath(get(errors), change);
if (hadErrors && hadErrors.key in hadErrors.parent) {
// Problem - store hasn't updated here with new value yet.
setTimeout(() => validateChange(change, 'input', validityEl), 0);
}
}
}
if (options.customValidity) {
formEl.addEventListener('input', checkCustomValidity);
}
onDestroy(() => {
formEl.removeEventListener('focusout', checkBlur);
formEl.removeEventListener('input', checkCustomValidity);
});
const htmlForm = Form(formEl, { submitting, delayed, timeout }, options);
let currentRequest;
return enhance(formEl, async (submit) => {
const submitCancel = submit.cancel;
let cancelled = false;
function cancel() {
cancelled = true;
return submitCancel();
}
submit.cancel = cancel;
if (htmlForm.isSubmitting() && options.multipleSubmits == 'prevent') {
cancel();
}
else {
if (htmlForm.isSubmitting() && options.multipleSubmits == 'abort') {
if (currentRequest)
currentRequest.abort();
}
currentRequest = submit.controller;
for (const event of formEvents.onSubmit) {
await event(submit);
}
}
if (cancelled) {
if (options.flashMessage)
cancelFlash(options);
}
else {
// Client validation
const validation = await clientValidation(options, get(data), get(formId), get(constraints), get(posted));
if (!validation.valid) {
cancel();
const result = {
type: 'failure',
status: (typeof options.SPA === 'boolean'
? undefined
: options.SPA?.failStatus) ?? 400,
data: { form: validation }
};
setTimeout(() => validationResponse({ result }), 0);
}
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);
}
htmlForm.submitting();
// Deprecation fix
const submitData = 'formData' in submit
? submit.formData
: submit.data;
if (options.SPA) {
cancel();
const validationResult = {
valid: true,
posted: true,
errors: {},
data: get(data),
constraints: get(constraints),
message: undefined,
id: get(formId)
};
const result = {
type: 'success',
status: 200,
data: { form: validationResult }
};
setTimeout(() => validationResponse({ result }), 0);
}
else if (options.dataType === 'json') {
const postData = get(data);
const chunks = chunkSubstr(stringify(postData), options.jsonChunkSize ?? 500000);
for (const chunk of chunks) {
submitData.append('__superform_json', chunk);
}
// 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);
}
});
}
if (!options.SPA && !submitData.has('__superform_id')) {
// Add formId
const id = get(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) {
const result = event.result;
currentRequest = null;
let cancelled = false;
const data = {
result,
formEl,
cancel: () => (cancelled = true)
};
for (const event of formEvents.onResult) {
await event(data);
}
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 !== get(formId))
continue;
const data = {
form: newForm,
formEl,
cancel: () => (cancelled = true)
};
for (const event of formEvents.onUpdate) {
await event(data);
}
if (!cancelled && options.customValidity) {
setCustomValidityForm(formEl, data.form.errors);
}
}
}
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 };
for (const event of formEvents.onError) {
if (event !== 'apply')
await event(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,
message: options.flashMessage.module.getFlash(page)
});
}
}
}
}
if (cancelled && options.flashMessage) {
cancelFlash(options);
}
// Redirect messages are handled in onDestroy and afterNavigate in client/form.ts.
// Also fixing an edge case when timers weren't resetted when redirecting to the same route.
if (cancelled || result.type != 'redirect') {
htmlForm.completed(cancelled);
}
else if (result.type == 'redirect' &&
new URL(result.location, /^https?:\/\//.test(result.location)
? undefined
: document.location.origin).pathname == document.location.pathname) {
htmlForm.completed(true);
}
}
return validationResponse;
});
}