- 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
503 lines
17 KiB
JavaScript
503 lines
17 KiB
JavaScript
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
import { derived, get, writable } from 'svelte/store';
|
|
import { SuperFormError } from '../errors.js';
|
|
import { pathExists, traversePath } from '../traversal.js';
|
|
import { splitPath } from '../stringPath.js';
|
|
import { browser } from '$app/environment';
|
|
const defaultOptions = {
|
|
trueStringValue: 'true',
|
|
dateFormat: 'iso',
|
|
step: 60
|
|
};
|
|
///// Proxy functions ///////////////////////////////////////////////
|
|
export function booleanProxy(form, path, options) {
|
|
return _stringProxy(form, path, 'boolean', {
|
|
...defaultOptions,
|
|
...options
|
|
});
|
|
}
|
|
export function intProxy(form, path, options) {
|
|
return _stringProxy(form, path, 'int', {
|
|
...defaultOptions,
|
|
...options
|
|
});
|
|
}
|
|
export function numberProxy(form, path, options) {
|
|
return _stringProxy(form, path, 'number', {
|
|
...defaultOptions,
|
|
...options
|
|
});
|
|
}
|
|
export function dateProxy(form, path, options) {
|
|
return _stringProxy(form, path, 'date', {
|
|
...defaultOptions,
|
|
dateFormat: options?.format ?? 'iso',
|
|
empty: options?.empty,
|
|
step: options?.step ?? 60
|
|
});
|
|
}
|
|
export function stringProxy(form, path, options) {
|
|
return _stringProxy(form, path, 'string', {
|
|
...defaultOptions,
|
|
...options
|
|
});
|
|
}
|
|
export function fileFieldProxy(form, path, options) {
|
|
const fileField = fileProxy(form, path, options);
|
|
const formField = formFieldProxy(form, path, options);
|
|
return { ...formField, value: fileField };
|
|
}
|
|
export function fileProxy(form, path, options) {
|
|
const formFile = fieldProxy(form, path, options);
|
|
const fileProxy = writable(browser ? new DataTransfer().files : {});
|
|
let initialized = false;
|
|
let initialValue;
|
|
formFile.subscribe((file) => {
|
|
if (!browser)
|
|
return;
|
|
if (!initialized) {
|
|
initialValue = options?.empty ? (options.empty === 'undefined' ? undefined : null) : file;
|
|
initialized = true;
|
|
}
|
|
const dt = new DataTransfer();
|
|
if (file instanceof File)
|
|
dt.items.add(file);
|
|
fileProxy.set(dt.files);
|
|
});
|
|
const fileStore = {
|
|
subscribe(run) {
|
|
return fileProxy.subscribe(run);
|
|
},
|
|
set(file) {
|
|
if (!browser)
|
|
return;
|
|
if (!file) {
|
|
const dt = new DataTransfer();
|
|
fileProxy.set(dt.files);
|
|
formFile.set(file);
|
|
}
|
|
else if (file instanceof File) {
|
|
const dt = new DataTransfer();
|
|
dt.items.add(file);
|
|
fileProxy.set(dt.files);
|
|
formFile.set(file);
|
|
}
|
|
else if (file instanceof FileList) {
|
|
fileProxy.set(file);
|
|
if (file.length > 0)
|
|
formFile.set(file.item(0));
|
|
else
|
|
formFile.set(initialValue);
|
|
}
|
|
},
|
|
update() {
|
|
throw new SuperFormError('You cannot update a fileProxy, only set it.');
|
|
}
|
|
};
|
|
return fileStore;
|
|
}
|
|
export function filesFieldProxy(form, path, options) {
|
|
const filesStore = filesProxy(form, path, options);
|
|
const arrayField = arrayProxy(form, path, options);
|
|
return { ...arrayField, values: filesStore };
|
|
}
|
|
export function filesProxy(form, path, options) {
|
|
const formFiles = fieldProxy(form, path, options);
|
|
const filesProxy = writable(browser ? new DataTransfer().files : {});
|
|
let initialized = false;
|
|
let initialValue;
|
|
formFiles.subscribe((files) => {
|
|
if (!browser)
|
|
return;
|
|
if (!initialized) {
|
|
initialValue = options?.empty ? (options.empty === 'undefined' ? undefined : null) : files;
|
|
initialized = true;
|
|
}
|
|
const dt = new DataTransfer();
|
|
if (Array.isArray(files)) {
|
|
if (files.length && files.every((f) => !f)) {
|
|
formFiles.set([]);
|
|
return;
|
|
}
|
|
files.filter((f) => f instanceof File).forEach((file) => dt.items.add(file));
|
|
}
|
|
filesProxy.set(dt.files);
|
|
});
|
|
const filesStore = {
|
|
subscribe(run) {
|
|
return filesProxy.subscribe(run);
|
|
},
|
|
set(files) {
|
|
if (!browser)
|
|
return;
|
|
if (!(files instanceof FileList)) {
|
|
const dt = new DataTransfer();
|
|
if (Array.isArray(files))
|
|
files.forEach((file) => {
|
|
if (file instanceof File)
|
|
dt.items.add(file);
|
|
});
|
|
filesProxy.set(dt.files);
|
|
formFiles.set(files);
|
|
}
|
|
else {
|
|
if (files.length > 0) {
|
|
const output = [];
|
|
for (let i = 0; i < files.length; i++) {
|
|
const file = files.item(i);
|
|
if (file)
|
|
output.push(file);
|
|
}
|
|
filesProxy.set(files);
|
|
formFiles.set(output);
|
|
}
|
|
else {
|
|
formFiles.set(initialValue);
|
|
}
|
|
}
|
|
},
|
|
update(updater) {
|
|
filesStore.set(updater(get(formFiles)));
|
|
}
|
|
};
|
|
return filesStore;
|
|
}
|
|
///// Implementation ////////////////////////////////////////////////
|
|
/**
|
|
* Creates a string store that will pass its value to a field in the form.
|
|
* @param form The form
|
|
* @param field Form field
|
|
* @param type 'number' | 'int' | 'boolean'
|
|
*/
|
|
function _stringProxy(form, path, type, options) {
|
|
function toValue(value) {
|
|
if (!value && options.empty !== undefined) {
|
|
return options.empty === 'null' ? null : options.empty === 'zero' ? 0 : undefined;
|
|
}
|
|
if (typeof value === 'number') {
|
|
value = value.toString();
|
|
}
|
|
if (typeof value !== 'string') {
|
|
// Can be undefined due to Proxy in Svelte 5
|
|
value = '';
|
|
}
|
|
const stringValue = value;
|
|
if (type == 'string')
|
|
return stringValue;
|
|
else if (type == 'boolean')
|
|
return !!stringValue;
|
|
else if (type == 'date') {
|
|
if (stringValue.indexOf('-') === -1) {
|
|
const utc = options.dateFormat.indexOf('utc') >= 0;
|
|
const date = utc ? UTCDate(new Date()) : localDate(new Date());
|
|
return new Date(date + 'T' + stringValue + (utc ? 'Z' : ''));
|
|
}
|
|
else
|
|
return new Date(stringValue);
|
|
}
|
|
const numberToConvert = options.delimiter
|
|
? stringValue.replace(options.delimiter, '.')
|
|
: stringValue;
|
|
let num;
|
|
if (numberToConvert === '' && options.empty == 'zero')
|
|
num = 0;
|
|
else if (type == 'number')
|
|
num = parseFloat(numberToConvert);
|
|
else
|
|
num = parseInt(numberToConvert, 10);
|
|
return num;
|
|
}
|
|
const isSuper = isSuperForm(form, options);
|
|
const realProxy = isSuper
|
|
? superFieldProxy(form, path, { taint: options.taint })
|
|
: fieldProxy(form, path);
|
|
let updatedValue = null;
|
|
let initialized = false;
|
|
const proxy = derived(realProxy, (value) => {
|
|
if (!initialized) {
|
|
initialized = true;
|
|
if (options.initiallyEmptyIfZero && !value)
|
|
return '';
|
|
}
|
|
// Prevent proxy updating itself
|
|
if (updatedValue !== null) {
|
|
const current = updatedValue;
|
|
updatedValue = null;
|
|
return current;
|
|
}
|
|
if (value === undefined || value === null)
|
|
return '';
|
|
if (type == 'string') {
|
|
return value;
|
|
}
|
|
else if (type == 'int' || type == 'number') {
|
|
if (value === '') {
|
|
// Special case for empty string values in number proxies
|
|
// Set the value to 0, to conform to the type.
|
|
realProxy.set(0, isSuper ? { taint: false } : undefined);
|
|
}
|
|
if (typeof value === 'number' && isNaN(value))
|
|
return '';
|
|
return String(value);
|
|
}
|
|
else if (type == 'date') {
|
|
const date = typeof value === 'string' || typeof value === 'number' ? new Date(value) : value;
|
|
if (isNaN(date))
|
|
return '';
|
|
switch (options.dateFormat) {
|
|
case 'iso':
|
|
return date.toISOString();
|
|
case 'date':
|
|
return date.toISOString().slice(0, 10);
|
|
case 'datetime':
|
|
return date.toISOString().slice(0, options.step % 60 ? 19 : 16);
|
|
case 'time':
|
|
return date.toISOString().slice(11, options.step % 60 ? 19 : 16);
|
|
case 'date-utc':
|
|
return UTCDate(date);
|
|
case 'datetime-utc':
|
|
return UTCDate(date) + 'T' + UTCTime(date, options.step);
|
|
case 'time-utc':
|
|
return UTCTime(date, options.step);
|
|
case 'date-local':
|
|
return localDate(date);
|
|
case 'datetime-local':
|
|
return localDate(date) + 'T' + localTime(date, options.step);
|
|
case 'time-local':
|
|
return localTime(date, options.step);
|
|
}
|
|
}
|
|
else {
|
|
// boolean
|
|
return value ? options.trueStringValue : '';
|
|
}
|
|
});
|
|
return {
|
|
subscribe: proxy.subscribe,
|
|
set(val) {
|
|
updatedValue = val;
|
|
const newValue = toValue(updatedValue);
|
|
realProxy.set(newValue);
|
|
},
|
|
update(updater) {
|
|
realProxy.update((f) => {
|
|
updatedValue = updater(String(f));
|
|
const newValue = toValue(updatedValue);
|
|
return newValue;
|
|
});
|
|
}
|
|
};
|
|
}
|
|
export function arrayProxy(superForm, path, options) {
|
|
const formErrors = fieldProxy(superForm.errors, `${path}`);
|
|
const onlyFieldErrors = derived(formErrors, ($errors) => {
|
|
const output = [];
|
|
for (const key in $errors) {
|
|
if (key == '_errors')
|
|
continue;
|
|
output[key] = $errors[key];
|
|
}
|
|
return output;
|
|
});
|
|
function updateArrayErrors(errors, value) {
|
|
for (const key in errors) {
|
|
if (key == '_errors')
|
|
continue;
|
|
errors[key] = undefined;
|
|
}
|
|
if (value !== undefined) {
|
|
for (const key in value) {
|
|
errors[key] = value[key];
|
|
}
|
|
}
|
|
return errors;
|
|
}
|
|
const fieldErrors = {
|
|
subscribe: onlyFieldErrors.subscribe,
|
|
update(upd) {
|
|
formErrors.update(($errors) =>
|
|
// @ts-expect-error Type is correct
|
|
updateArrayErrors($errors, upd($errors)));
|
|
},
|
|
set(value) {
|
|
// @ts-expect-error Type is correct
|
|
formErrors.update(($errors) => updateArrayErrors($errors, value));
|
|
}
|
|
};
|
|
const values = superFieldProxy(superForm, path, options);
|
|
// If array is shortened, delete all keys above length
|
|
// in errors, so they won't be kept if the array is lengthened again.
|
|
let lastLength = Array.isArray(get(values)) ? get(values).length : 0;
|
|
values.subscribe(($values) => {
|
|
const currentLength = Array.isArray($values) ? $values.length : 0;
|
|
if (currentLength < lastLength) {
|
|
superForm.errors.update(($errors) => {
|
|
const node = pathExists($errors, splitPath(path));
|
|
if (!node)
|
|
return $errors;
|
|
for (const key in node.value) {
|
|
if (Number(key) < currentLength)
|
|
continue;
|
|
delete node.value[key];
|
|
}
|
|
return $errors;
|
|
}, { force: true });
|
|
}
|
|
lastLength = currentLength;
|
|
});
|
|
return {
|
|
path,
|
|
values: values,
|
|
errors: fieldProxy(superForm.errors, `${path}._errors`),
|
|
valueErrors: fieldErrors
|
|
};
|
|
}
|
|
export function formFieldProxy(superForm, path, options) {
|
|
const path2 = splitPath(path);
|
|
// Filter out array indices, the constraints structure doesn't contain these.
|
|
const constraintsPath = path2.filter((p) => /\D/.test(String(p))).join('.');
|
|
const taintedProxy = derived(superForm.tainted, ($tainted) => {
|
|
if (!$tainted)
|
|
return $tainted;
|
|
const taintedPath = traversePath($tainted, path2);
|
|
return taintedPath ? taintedPath.value : undefined;
|
|
});
|
|
const tainted = {
|
|
subscribe: taintedProxy.subscribe,
|
|
update(upd) {
|
|
superForm.tainted.update(($tainted) => {
|
|
if (!$tainted)
|
|
$tainted = {};
|
|
const output = traversePath($tainted, path2, (path) => {
|
|
if (!path.value)
|
|
path.parent[path.key] = {};
|
|
return path.parent[path.key];
|
|
});
|
|
if (output)
|
|
output.parent[output.key] = upd(output.value);
|
|
return $tainted;
|
|
});
|
|
},
|
|
set(value) {
|
|
superForm.tainted.update(($tainted) => {
|
|
if (!$tainted)
|
|
$tainted = {};
|
|
const output = traversePath($tainted, path2, (path) => {
|
|
if (!path.value)
|
|
path.parent[path.key] = {};
|
|
return path.parent[path.key];
|
|
});
|
|
if (output)
|
|
output.parent[output.key] = value;
|
|
return $tainted;
|
|
});
|
|
}
|
|
};
|
|
return {
|
|
path,
|
|
value: superFieldProxy(superForm, path, options),
|
|
errors: fieldProxy(superForm.errors, path),
|
|
constraints: fieldProxy(superForm.constraints, constraintsPath),
|
|
tainted
|
|
};
|
|
}
|
|
function updateProxyField(obj, path, updater) {
|
|
const output = traversePath(obj, path, ({ parent, key, value }) => {
|
|
if (value === undefined)
|
|
parent[key] = /\D/.test(key) ? {} : [];
|
|
return parent[key];
|
|
});
|
|
if (output) {
|
|
const newValue = updater(output.value);
|
|
output.parent[output.key] = newValue;
|
|
}
|
|
return obj;
|
|
}
|
|
function superFieldProxy(superForm, path, baseOptions) {
|
|
const form = superForm.form;
|
|
const path2 = splitPath(path);
|
|
const proxy = derived(form, ($form) => {
|
|
const data = traversePath($form, path2);
|
|
return data?.value;
|
|
});
|
|
return {
|
|
subscribe(...params) {
|
|
const unsub = proxy.subscribe(...params);
|
|
return () => unsub();
|
|
},
|
|
update(upd, options) {
|
|
form.update((data) => updateProxyField(data, path2, upd), options ?? baseOptions);
|
|
},
|
|
set(value, options) {
|
|
form.update((data) => updateProxyField(data, path2, () => value), options ?? baseOptions);
|
|
}
|
|
};
|
|
}
|
|
function isSuperForm(form, options) {
|
|
const isSuperForm = 'form' in form;
|
|
if (!isSuperForm && options?.taint !== undefined) {
|
|
throw new SuperFormError('If options.taint is set, the whole superForm object must be used as a proxy.');
|
|
}
|
|
return isSuperForm;
|
|
}
|
|
export function fieldProxy(form, path, options) {
|
|
const path2 = splitPath(path);
|
|
if (isSuperForm(form, options)) {
|
|
return superFieldProxy(form, path, options);
|
|
}
|
|
const proxy = derived(form, ($form) => {
|
|
const data = traversePath($form, path2);
|
|
return data?.value;
|
|
});
|
|
return {
|
|
subscribe(...params) {
|
|
const unsub = proxy.subscribe(...params);
|
|
return () => unsub();
|
|
},
|
|
update(upd) {
|
|
form.update((data) => updateProxyField(data, path2, upd));
|
|
},
|
|
set(value) {
|
|
form.update((data) => updateProxyField(data, path2, () => value));
|
|
}
|
|
};
|
|
}
|
|
function localDate(date) {
|
|
return (date.getFullYear() +
|
|
'-' +
|
|
String(date.getMonth() + 1).padStart(2, '0') +
|
|
'-' +
|
|
String(date.getDate()).padStart(2, '0'));
|
|
}
|
|
function localTime(date, step) {
|
|
return (String(date.getHours()).padStart(2, '0') +
|
|
':' +
|
|
String(date.getMinutes()).padStart(2, '0') +
|
|
(step % 60 ? ':' + String(date.getSeconds()).padStart(2, '0') : ''));
|
|
}
|
|
function UTCDate(date) {
|
|
return (date.getUTCFullYear() +
|
|
'-' +
|
|
String(date.getUTCMonth() + 1).padStart(2, '0') +
|
|
'-' +
|
|
String(date.getUTCDate()).padStart(2, '0'));
|
|
}
|
|
function UTCTime(date, step) {
|
|
return (String(date.getUTCHours()).padStart(2, '0') +
|
|
':' +
|
|
String(date.getUTCMinutes()).padStart(2, '0') +
|
|
(step % 60 ? ':' + String(date.getUTCSeconds()).padStart(2, '0') : ''));
|
|
}
|
|
/*
|
|
function dateToUTC(date: Date) {
|
|
return new Date(
|
|
date.getUTCFullYear(),
|
|
date.getUTCMonth(),
|
|
date.getUTCDate(),
|
|
date.getUTCHours(),
|
|
date.getUTCMinutes(),
|
|
date.getUTCSeconds()
|
|
);
|
|
}
|
|
*/
|