finishes trail create

This commit is contained in:
Christian Beutel
2024-01-29 21:30:54 +01:00
parent 2d4c673060
commit efaf0d4e5c
47 changed files with 1739 additions and 130 deletions

View File

@@ -0,0 +1,255 @@
import {derived, writable, get} from 'svelte/store';
import {util} from './util';
const NO_ERROR = '';
const IS_TOUCHED = true;
function isCheckbox(element) {
return element.getAttribute && element.getAttribute('type') === 'checkbox';
}
function isFileInput(element) {
return element.getAttribute && element.getAttribute('type') === 'file';
}
function resolveValue(element) {
if (isFileInput(element)) {
return element.files;
} else if (isCheckbox(element)) {
return element.checked;
} else {
return element.value;
}
}
export const createForm = (config) => {
let initialValues = config.initialValues || {};
const validationSchema = config.validationSchema;
const validateFunction = config.validate;
const onSubmit = config.onSubmit;
const getInitial = {
values: () => util.cloneDeep(initialValues),
errors: () =>
validationSchema
? util.getErrorsFromSchema(initialValues, validationSchema.fields)
: util.assignDeep(initialValues, NO_ERROR),
touched: () => util.assignDeep(initialValues, !IS_TOUCHED),
};
const form = writable(getInitial.values());
const errors = writable(getInitial.errors());
const touched = writable(getInitial.touched());
const isSubmitting = writable(false);
const isValidating = writable(false);
const isValid = derived(errors, ($errors) => {
const noErrors = util
.getValues($errors)
.every((field) => field === NO_ERROR);
return noErrors;
});
const modified = derived(form, ($form) => {
const object = util.assignDeep($form, false);
for (let key in $form) {
object[key] = !util.deepEqual($form[key], initialValues[key]);
}
return object;
});
const isModified = derived(modified, ($modified) => {
return util.getValues($modified).includes(true);
});
function validateField(field) {
return util
.subscribeOnce(form)
.then((values) => validateFieldValue(field, values[field]));
}
function validateFieldValue(field, value) {
updateTouched(field, true);
if (validationSchema) {
isValidating.set(true);
return validationSchema
.validateAt(field, get(form))
.then(() => util.update(errors, field, ''))
.catch((error) => util.update(errors, field, error.message))
.finally(() => {
isValidating.set(false);
});
}
if (validateFunction) {
isValidating.set(true);
return Promise.resolve()
.then(() => validateFunction({[field]: value}))
.then((errs) =>
util.update(errors, field, !util.isNullish(errs) ? errs[field] : ''),
)
.finally(() => {
isValidating.set(false);
});
}
return Promise.resolve();
}
function updateValidateField(field, value) {
updateField(field, value);
return validateFieldValue(field, value);
}
function handleChange(event) {
const element = event.target;
const field = element.name || element.id;
const value = resolveValue(element);
return updateValidateField(field, value);
}
function handleSubmit(event) {
if (event && event.preventDefault) {
event.preventDefault();
}
isSubmitting.set(true);
return util.subscribeOnce(form).then((values) => {
if (typeof validateFunction === 'function') {
isValidating.set(true);
return Promise.resolve()
.then(() => validateFunction(values))
.then((error) => {
if (util.isNullish(error) || util.getValues(error).length === 0) {
return clearErrorsAndSubmit(values);
} else {
errors.set(error);
isSubmitting.set(false);
}
})
.finally(() => isValidating.set(false));
}
if (validationSchema) {
isValidating.set(true);
return (
validationSchema
.validate(values, {abortEarly: false})
.then(() => clearErrorsAndSubmit(values))
// eslint-disable-next-line unicorn/catch-error-name
.catch((yupErrors) => {
if (yupErrors && yupErrors.inner) {
const updatedErrors = getInitial.errors();
yupErrors.inner.map((error) =>
util.set(updatedErrors, error.path, error.message),
);
errors.set(updatedErrors);
}
isSubmitting.set(false);
})
.finally(() => isValidating.set(false))
);
}
return clearErrorsAndSubmit(values);
});
}
function handleReset() {
form.set(getInitial.values());
errors.set(getInitial.errors());
touched.set(getInitial.touched());
}
function clearErrorsAndSubmit(values) {
return Promise.resolve()
.then(() => errors.set(getInitial.errors()))
.then(() => onSubmit(values, form, errors))
.finally(() => isSubmitting.set(false));
}
/**
* Handler to imperatively update the value of a form field
*/
function updateField(field, value) {
util.update(form, field, value);
}
/**
* Handler to imperatively update the touched value of a form field
*/
function updateTouched(field, value) {
util.update(touched, field, value);
}
/**
* Update the initial values and reset form. Used to dynamically display new form values
*/
function updateInitialValues(newValues) {
initialValues = newValues;
handleReset();
}
return {
form,
errors,
touched,
modified,
isValid,
isSubmitting,
isValidating,
isModified,
handleChange,
handleSubmit,
handleReset,
updateField,
updateValidateField,
updateTouched,
validateField,
updateInitialValues,
state: derived(
[
form,
errors,
touched,
modified,
isValid,
isValidating,
isSubmitting,
isModified,
],
([
$form,
$errors,
$touched,
$modified,
$isValid,
$isValidating,
$isSubmitting,
$isModified,
]) => ({
form: $form,
errors: $errors,
touched: $touched,
modified: $modified,
isValid: $isValid,
isSubmitting: $isSubmitting,
isValidating: $isValidating,
isModified: $isModified,
}),
),
};
};

View File

@@ -0,0 +1,102 @@
/// <reference lib="svelte2tsx" />
import type {SvelteComponentTyped} from 'svelte';
import type {Readable, Writable} from 'svelte/store';
import type {ObjectSchema} from 'yup';
export type FormProps<Inf = Record<string, unknown>> = {
context?: FormState;
initialValues?: Inf;
onSubmit?: ((values: Inf) => any) | ((values: Inf) => Promise<any>);
validate?: (values: Inf) => any | undefined;
validationSchema?: ObjectSchema<any>;
} & svelte.JSX.HTMLAttributes<HTMLFormElement>;
type FieldProperties = {
name: string;
type?: string;
value?: string;
} & svelte.JSX.HTMLProps<HTMLInputElement>;
type SelectProperties = {
name: string;
} & svelte.JSX.HTMLProps<HTMLSelectElement>;
type ErrorProperties = {
name: string;
} & svelte.JSX.HTMLProps<HTMLDivElement>;
type TextareaProperties = {
name: string;
} & svelte.JSX.HTMLProps<HTMLTextAreaElement>;
type FormState<Inf = Record<string, any>> = {
form: Writable<Inf>;
errors: Writable<Record<keyof Inf, string>>;
touched: Writable<Record<keyof Inf, boolean>>;
modified: Readable<Record<keyof Inf, boolean>>;
isValid: Readable<boolean>;
isSubmitting: Writable<boolean>;
isValidating: Writable<boolean>;
isModified: Readable<boolean>;
updateField: (field: keyof Inf, value: any) => void;
updateValidateField: (field: keyof Inf, value: any) => void;
updateTouched: (field: keyof Inf, value: any) => void;
validateField: (field: keyof Inf) => Promise<any>;
updateInitialValues: (newValues: Inf) => void;
handleReset: () => void;
state: Readable<{
form: Inf;
errors: Record<keyof Inf, string>;
touched: Record<keyof Inf, boolean>;
modified: Record<keyof Inf, boolean>;
isValid: boolean;
isSubmitting: boolean;
isValidating: boolean;
isModified: boolean;
}>;
handleChange: (event: Event) => any;
handleSubmit: (event: Event) => any;
};
declare function createForm<Inf = Record<string, any>>(formProperties: {
initialValues: Inf;
onSubmit: (values: Inf) => any | Promise<any>;
validate?: (values: Inf) => any | undefined;
validationSchema?: ObjectSchema<any>;
}): FormState<Inf>;
declare class Form extends SvelteComponentTyped<
FormProps,
Record<string, unknown>,
{
default: FormState;
}
> {}
declare class Field extends SvelteComponentTyped<
FieldProperties,
Record<string, unknown>,
Record<string, unknown>
> {}
declare class Textarea extends SvelteComponentTyped<
TextareaProperties,
Record<string, unknown>,
Record<string, unknown>
> {}
declare class Select extends SvelteComponentTyped<
SelectProperties,
Record<string, unknown>,
{default: any}
> {}
declare class ErrorMessage extends SvelteComponentTyped<
ErrorProperties,
Record<string, unknown>,
{default: any}
> {}
declare const key: {};
export {createForm, key, Form, Field, Select, ErrorMessage, Textarea};

View File

@@ -0,0 +1 @@
export {createForm} from './create-form';

View File

@@ -0,0 +1,135 @@
import { dequal as isEqual } from 'dequal/lite';
function subscribeOnce(observable) {
return new Promise((resolve) => {
observable.subscribe(resolve)(); // immediately invoke to unsubscribe
});
}
function update(object, path, value) {
object.update((o) => {
set(o, path, value);
return o;
});
}
function cloneDeep(object) {
try {
return JSON.parse(JSON.stringify(object));
} catch (e) {
return object;
}
}
function isNullish(value) {
return value === undefined || value === null;
}
function isEmpty(object) {
return isNullish(object) || Object.keys(object).length <= 0;
}
function getValues(object) {
let results = [];
for (const [, value] of Object.entries(object)) {
const values = typeof value === 'object' ? getValues(value) : [value];
results = [...results, ...values];
}
return results;
}
// TODO: refactor this so as not to rely directly on yup's API
// This should use dependency injection, with a default callback which may assume
// yup as the validation schema
function getErrorsFromSchema(initialValues, schema, errors = {}) {
for (const key in schema) {
switch (true) {
case schema[key].type === 'object' && !isEmpty(schema[key].fields): {
errors[key] = getErrorsFromSchema(
initialValues[key],
schema[key].fields,
{ ...errors[key] },
);
break;
}
case schema[key].type === 'array': {
const values =
initialValues && initialValues[key] ? initialValues[key] : [];
errors[key] = values.map((value) => {
const innerError = getErrorsFromSchema(
value,
schema[key].innerType.fields,
{ ...errors[key] },
);
return Object.keys(innerError).length > 0 ? innerError : '';
});
break;
}
default: {
errors[key] = '';
}
}
}
return errors;
}
const deepEqual = isEqual;
function assignDeep(object, value) {
if (Array.isArray(object)) {
return object.map((o) => assignDeep(o, value));
}
const copy = {};
for (const key in object) {
copy[key] =
typeof object[key] === 'object' && !isNullish(object[key]) ? assignDeep(object[key], value) : value;
}
return copy;
}
function set(object, path, value) {
if (new Object(object) !== object) return object;
if (!Array.isArray(path)) {
path = path.toString().match(/[^.[\]]+/g) || [];
}
const result = path
.slice(0, -1)
// TODO: replace this reduce with something more readable
// eslint-disable-next-line unicorn/no-array-reduce
.reduce(
(accumulator, key, index) =>
new Object(accumulator[key]) === accumulator[key]
? accumulator[key]
: (accumulator[key] =
Math.trunc(Math.abs(path[index + 1])) === +path[index + 1]
? []
: {}),
object,
);
result[path[path.length - 1]] = value;
return object;
}
export const util = {
assignDeep,
cloneDeep,
deepEqual,
getErrorsFromSchema,
getValues,
isEmpty,
isNullish,
set,
subscribeOnce,
update,
};