validation

This commit is contained in:
2026-01-09 16:54:42 +02:00
parent 4470d922b7
commit 84cd04c94f
26 changed files with 770 additions and 159 deletions
+139
View File
@@ -0,0 +1,139 @@
/* eslint-disable no-undefined,no-param-reassign,no-shadow */
/**
* Throttle execution of a function. Especially useful for rate limiting
* execution of handlers on events like resize and scroll.
*
* @param {number} delay - A zero-or-greater delay in milliseconds. For event callbacks, values around 100 or 250 (or even higher)
* are most useful.
* @param {Function} callback - A function to be executed after delay milliseconds. The `this` context and all arguments are passed through,
* as-is, to `callback` when the throttled-function is executed.
* @param {object} [options] - An object to configure options.
* @param {boolean} [options.noTrailing] - Optional, defaults to false. If noTrailing is true, callback will only execute every `delay` milliseconds
* while the throttled-function is being called. If noTrailing is false or unspecified, callback will be executed
* one final time after the last throttled-function call. (After the throttled-function has not been called for
* `delay` milliseconds, the internal counter is reset).
* @param {boolean} [options.noLeading] - Optional, defaults to false. If noLeading is false, the first throttled-function call will execute callback
* immediately. If noLeading is true, the first the callback execution will be skipped. It should be noted that
* callback will never executed if both noLeading = true and noTrailing = true.
* @param {boolean} [options.debounceMode] - If `debounceMode` is true (at begin), schedule `clear` to execute after `delay` ms. If `debounceMode` is
* false (at end), schedule `callback` to execute after `delay` ms.
*
* @returns {Function} A new, throttled, function.
*/
export function throttle(delay, callback, options) {
const {
noTrailing = false,
noLeading = false,
debounceMode = undefined,
} = options || {};
/*
* After wrapper has stopped being called, this timeout ensures that
* `callback` is executed at the proper times in `throttle` and `end`
* debounce modes.
*/
let timeoutID;
let cancelled = false;
// Keep track of the last time `callback` was executed.
let lastExec = 0;
// Function to clear existing timeout
function clearExistingTimeout() {
if (timeoutID) {
clearTimeout(timeoutID);
}
}
// Function to cancel next exec
function cancel(options) {
const { upcomingOnly = false } = options || {};
clearExistingTimeout();
cancelled = !upcomingOnly;
}
/*
* The `wrapper` function encapsulates all of the throttling / debouncing
* functionality and when executed will limit the rate at which `callback`
* is executed.
*/
function wrapper(...arguments_) {
let self = this;
let elapsed = Date.now() - lastExec;
if (cancelled) {
return;
}
// Execute `callback` and update the `lastExec` timestamp.
function exec() {
lastExec = Date.now();
callback.apply(self, arguments_);
}
/*
* If `debounceMode` is true (at begin) this is used to clear the flag
* to allow future `callback` executions.
*/
function clear() {
timeoutID = undefined;
}
if (!noLeading && debounceMode && !timeoutID) {
/*
* Since `wrapper` is being called for the first time and
* `debounceMode` is true (at begin), execute `callback`
* and noLeading != true.
*/
exec();
}
clearExistingTimeout();
if (debounceMode === undefined && elapsed > delay) {
if (noLeading) {
/*
* In throttle mode with noLeading, if `delay` time has
* been exceeded, update `lastExec` and schedule `callback`
* to execute after `delay` ms.
*/
lastExec = Date.now();
if (!noTrailing) {
timeoutID = setTimeout(debounceMode ? clear : exec, delay);
}
} else {
/*
* In throttle mode without noLeading, if `delay` time has been exceeded, execute
* `callback`.
*/
exec();
}
} else if (noTrailing !== true) {
/*
* In trailing throttle mode, since `delay` time has not been
* exceeded, schedule `callback` to execute `delay` ms after most
* recent execution.
*
* If `debounceMode` is true (at begin), schedule `clear` to execute
* after `delay` ms.
*
* If `debounceMode` is false (at end), schedule `callback` to
* execute after `delay` ms.
*/
timeoutID = setTimeout(
debounceMode ? clear : exec,
debounceMode === undefined ? delay - elapsed : delay,
);
}
}
wrapper.cancel = cancel;
// Return the wrapper function.
return wrapper;
}
export function debounce(delay, callback, options) {
const { atBegin = false } = options || {};
return throttle(delay, callback, { debounceMode: atBegin !== false });
}
@@ -82,6 +82,10 @@
Developers will use this to reference the field
</small>
</label>
<label>
Help text
<input bind:value={data.field.help} />
</label>
<label>
<input
bind:checked={data.field.translatable}
@@ -90,6 +94,32 @@
/>
Is Translatable
</label>
<fieldset>
<label>
<input
bind:checked={data.field.required}
type="checkbox"
role="switch"
/>
Required
</label>
<label>
<input
bind:checked={data.field.readonly}
type="checkbox"
role="switch"
/>
Readonly
</label>
<label>
<input
bind:checked={data.field.hidden}
type="checkbox"
role="switch"
/>
Hidden
</label>
</fieldset>
</fieldset>
{#if data.field.type === "text"}
@@ -2,41 +2,12 @@
let { field } = $props();
</script>
<fieldset>
<label>
<input
bind:checked={field.props.required}
type="checkbox"
role="switch"
/>
Required
</label>
<label>
<input
bind:checked={field.props.readonly}
type="checkbox"
role="switch"
/>
Readonly
</label>
<label>
<input
bind:checked={field.props.hidden}
type="checkbox"
role="switch"
/>
Hidden
</label>
</fieldset>
<fieldset>
<label>
Default
<input bind:value={field.props.default} />
</label>
<label>
Help text
<input bind:value={field.props.help} />
</label>
<label>
Min characters
<input type="number" bind:value={field.props.min} />
@@ -0,0 +1,32 @@
<script>
import { getSelectedLocales, getLocaleName } from "./locale.svelte.js";
let { channel, onLocaleChange } = $props();
let selectedLocales = $state(getSelectedLocales());
let selectedLocaleNames = $derived(
selectedLocales.map((id) => getLocaleName(channel, id)),
);
function handleChange() {
localStorage.setItem("selectedLocales", selectedLocales);
onLocaleChange();
}
</script>
<details class="dropdown">
<summary>Locales: {selectedLocaleNames.join(", ")}</summary>
<ul>
{#each channel.locales as locale}
<li>
<label>
<input
type="checkbox"
bind:group={selectedLocales}
onchange={handleChange}
value={locale.id}
/>
{locale.name}
</label>
</li>
{/each}
</ul>
</details>
@@ -0,0 +1,57 @@
<script>
import ChannelLayout from "../../layouts/ChannelLayout.svelte";
import { onMount } from "svelte";
import TextField from "./fields/TextField.svelte";
import LocaleChooser from "./LocaleChooser.svelte";
import { getSelectedLocales } from "./locale.svelte.js";
let { channel, user, data } = $props();
let selectedLocales = $state(getSelectedLocales());
let record = $state(data.record);
function handleLocaleChange() {
selectedLocales = getSelectedLocales();
}
</script>
<!-- <svelte:window on:beforeunload={beforeUnload} /> -->
<ChannelLayout {body} {channel} schemas={data.schemas} {user}></ChannelLayout>
{#snippet body()}
<LocaleChooser {channel} onLocaleChange={handleLocaleChange}
></LocaleChooser>
{#each data.fields as field}
<div style="display:flex;gap:20px;">
{#if field.type === "text"}
<TextField
{channel}
{record}
errors={data.validationErrors.filter(
(f) => f.id === field.fieldId && f.locale === "main",
)}
schemaField={field}
locale="main"
dataField={data.draftData.find(
(f) => f.id === field.id && f.locale === "main",
)}
></TextField>
{#if field.translatable}
{#each selectedLocales as locale (locale)}
<TextField
{channel}
{record}
errors={data.validationErrors.filter(
(f) =>
f.fieldId === field.id &&
f.locale === locale,
)}
schemaField={field}
{locale}
dataField={data.draftData.find(
(f) => f.id === field.id && f.locale === locale,
)}
></TextField>
{/each}
{/if}
{/if}
</div>
{/each}
{/snippet}
@@ -0,0 +1,132 @@
<script>
import { post } from "../../../modules/remote";
import { getApp } from "../../../app";
import { getLocaleName } from "../locale.svelte.js";
let { channel, record, schemaField, dataField, locale, errors } = $props();
let originalValue = dataField?.value ?? schemaField.props.default;
let newValue = $state(originalValue);
let valuesChanged = $derived(newValue !== originalValue);
let errorMessage = $state("");
let validationErrors = $state(errors);
let hasErrors = $derived(validationErrors.length > 0);
const app = getApp();
function save() {
if (!valuesChanged) {
return;
}
post(
app.url("records/fields"),
{
recordId: record.id,
id: schemaField.id,
locale: locale,
value: newValue,
},
(data, err) => {
if (err.isNotEmpty()) {
errorMessage = err.first();
} else {
validationErrors = data.validationErrors;
originalValue = newValue;
}
},
);
}
let delayMs = 1000;
let timer = $state(undefined);
let isComposing = $state(false);
const schedule = () => {
if (isComposing) {
return;
}
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(flush, delayMs);
};
const flush = () => {
if (timer) {
clearTimeout(timer);
timer = undefined;
}
// value = inputValue;
save();
};
const handleInput = () => {
schedule();
};
const handleKeydown = (event) => {
if (event.key === "Enter") {
flush();
}
};
const handleBlur = () => {
flush();
};
const handleCompositionStart = () => {
isComposing = true;
};
const handleCompositionEnd = () => {
isComposing = false;
};
</script>
<!-- <div style="position: relative;">
{#if field.selectOptions}
<Autocomplete {field} bind:value></Autocomplete>
{:else}
<input
type="text"
{id}
class="form-control"
class:is-invalid={errorMessage}
bind:value
autocomplete="off"
readonly={field.readonly && !isCreateMode}
/>
{/if}
{#if errorMessage}
<div class="invalid-feedback d-block">
{errorMessage}
</div>
{/if}
</div> -->
<div style="min-width: 400px;">
<label>
{#if locale !== "main"}
{getLocaleName(channel, locale)} >
{/if}
{schemaField.name} <br />
<input
type="text"
bind:value={newValue}
autocomplete="off"
readonly={schemaField.readonly}
aria-describedby={schemaField.id + "-help"}
oninput={handleInput}
onkeydown={handleKeydown}
onblur={handleBlur}
oncompositionstart={handleCompositionStart}
oncompositionend={handleCompositionEnd}
aria-invalid={hasErrors ? "true" : ""}
/>
{#if hasErrors}
<small id={schemaField.id + "-help"}
>{validationErrors[0].message}</small
>
{:else if schemaField.help != ""}
<small id={schemaField.id + "-help"}>{schemaField.help}</small>
{/if}
</label>
</div>
@@ -0,0 +1,11 @@
export function getSelectedLocales() {
let value = $state(localStorage.getItem("selectedLocales"));
if (value == "" || !value) {
return [];
}
return value.split(",");
}
export function getLocaleName(channel, id) {
return channel.locales.find((locale) => locale.id === id).name;
}
+2 -2
View File
@@ -9,7 +9,7 @@ import Profile from "./svelte/account/Profile.svelte";
import SetupEntry from "./entry/SetupEntry/SetupEntry.svelte";
import Members from "./svelte/members/Members.svelte";
import RecordNotFound from "./svelte/records/NotFound.svelte";
import RecordEdit from "./svelte/records/Edit.svelte";
import RecordEditEntry from "./entry/RecordEditEntry/RecordEditEntry.svelte";
import ContentEntry from "./entry/ContentEntry/ContentEntry.svelte";
import HomeEntry from "./entry/HomeEntry/HomeEntry.svelte";
import SchemaEntry from "./entry/SchemaEntry/SchemaEntry.svelte";
@@ -21,7 +21,7 @@ import { createApp } from "./app";
const entryComponents = {
members: Members,
recordEdit: RecordEdit,
recordEdit: RecordEditEntry,
recordNotFound: RecordNotFound,
contentIndex: ContentEntry,
homeIndex: HomeEntry,