Add inline timer form validation
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s

This commit is contained in:
Shinuwa 2026-08-03 16:20:05 +02:00
parent 3e16ea278d
commit d54a93e1f4
6 changed files with 80 additions and 4 deletions

View file

@ -173,6 +173,7 @@ Regles UI :
- les boutons de controle utilisent les icones `play-circle`, `pause-circle`, `record-circle`, `stop-circle`, `refresh`, `sort-time`, `scrollable`. - les boutons de controle utilisent les icones `play-circle`, `pause-circle`, `record-circle`, `stop-circle`, `refresh`, `sort-time`, `scrollable`.
- pour `Heure precise` et `Intervalle`, le refresh inline est un toggle compact de repetition automatique ; il est bloque si la frequence est inferieure a 5 minutes. - pour `Heure precise` et `Intervalle`, le refresh inline est un toggle compact de repetition automatique ; il est bloque si la frequence est inferieure a 5 minutes.
- le formulaire `Intervalle` affiche deux champs `hh:mm:ss` compacts : duree de repetition et depart optionnel ; un depart vide signifie `Maintenant`. - le formulaire `Intervalle` affiche deux champs `hh:mm:ss` compacts : duree de repetition et depart optionnel ; un depart vide signifie `Maintenant`.
- les invalidations du formulaire countdown s'affichent en notice inline rouge compacte, portée par `useDraftForm`.
Alertes timer : Alertes timer :

View file

@ -265,6 +265,8 @@ export function validateSiteContent(site) {
"toolboxes.modules.timer.intervalStartTimeOption", "toolboxes.modules.timer.intervalStartTimeOption",
"toolboxes.modules.timer.intervalStartTimeLabel", "toolboxes.modules.timer.intervalStartTimeLabel",
"toolboxes.modules.timer.addCountdownButton", "toolboxes.modules.timer.addCountdownButton",
"toolboxes.modules.timer.requiredError",
"toolboxes.modules.timer.invalidError",
"toolboxes.modules.timer.defaultCountdownLabel", "toolboxes.modules.timer.defaultCountdownLabel",
"toolboxes.modules.timer.finishedLabel", "toolboxes.modules.timer.finishedLabel",
"toolboxes.modules.timer.targetLabel", "toolboxes.modules.timer.targetLabel",

View file

@ -430,6 +430,8 @@
"intervalStartTimeOption": "Heure précise", "intervalStartTimeOption": "Heure précise",
"intervalStartTimeLabel": "Départ", "intervalStartTimeLabel": "Départ",
"addCountdownButton": "Ajouter", "addCountdownButton": "Ajouter",
"requiredError": "Valeur requise.",
"invalidError": "Valeur invalide.",
"defaultCountdownLabel": "Timer", "defaultCountdownLabel": "Timer",
"finishedLabel": "Terminé", "finishedLabel": "Terminé",
"targetLabel": "Suivant", "targetLabel": "Suivant",

View file

@ -94,6 +94,27 @@ function hasTimePartValue(parts) {
return Boolean(String(parts?.hours || "").trim() || String(parts?.minutes || "").trim() || String(parts?.seconds || "").trim()); return Boolean(String(parts?.hours || "").trim() || String(parts?.minutes || "").trim() || String(parts?.seconds || "").trim());
} }
function getCountdownValidationError(type, draft, textContent) {
const requiredError = textContent.requiredError || "Valeur requise.";
const invalidError = textContent.invalidError || "Valeur invalide.";
if (type === "duration" && timePartsToDurationMs(draft.duration) <= 0) {
return requiredError;
}
if (type === "daily_time" && !timePartsToString(draft.time)) {
return invalidError;
}
if (type === "time_pattern") {
if (!hasTimePartValue(draft.pattern)) return requiredError;
if (!timePartsToString(draft.pattern, { allowWildcard: true })) return invalidError;
}
if (type === "interval") {
const startTimeHasValue = hasTimePartValue(draft.intervalStartTime);
if (timePartsToDurationMs(draft.interval) <= 0) return requiredError;
if (startTimeHasValue && !timePartsToString(draft.intervalStartTime)) return invalidError;
}
return "";
}
export function TimerModule({ toolboxId, moduleId, context }) { export function TimerModule({ toolboxId, moduleId, context }) {
const data = context.normalizeTimerData(context.getModuleData(toolboxId, moduleId, { activeTab: "stopwatch" })); const data = context.normalizeTimerData(context.getModuleData(toolboxId, moduleId, { activeTab: "stopwatch" }));
const textContent = context.moduleText?.timer || {}; const textContent = context.moduleText?.timer || {};
@ -194,6 +215,8 @@ export function TimerModule({ toolboxId, moduleId, context }) {
function createCountdown(event) { function createCountdown(event) {
countdownDraft.handleSubmit(event, (draft, { updateValues }) => { countdownDraft.handleSubmit(event, (draft, { updateValues }) => {
const type = COUNTDOWN_TYPES.includes(draft.type) ? draft.type : "duration"; const type = COUNTDOWN_TYPES.includes(draft.type) ? draft.type : "duration";
const validationError = getCountdownValidationError(type, draft, textContent);
if (validationError) return validationError;
const label = draft.label.trim() || textContent.defaultCountdownLabel || "Timer"; const label = draft.label.trim() || textContent.defaultCountdownLabel || "Timer";
const now = getNowMs(); const now = getNowMs();
let countdown = null; let countdown = null;
@ -335,6 +358,7 @@ export function TimerModule({ toolboxId, moduleId, context }) {
<CountdownControls <CountdownControls
textContent={textContent} textContent={textContent}
form={countdownDraft.values} form={countdownDraft.values}
error={countdownDraft.error}
onChange={countdownDraft.updateValues} onChange={countdownDraft.updateValues}
onSubmit={createCountdown} onSubmit={createCountdown}
/> />
@ -441,9 +465,9 @@ function StopwatchControls({ textContent, elapsedMs, running, onStart, onPause,
); );
} }
function CountdownControls({ textContent, form, onChange, onSubmit }) { function CountdownControls({ textContent, form, error, onChange, onSubmit }) {
return ( return (
<form className="timer-control-panel timer-countdown-form" role="tabpanel" onSubmit={onSubmit}> <form className="timer-control-panel timer-countdown-form" role="tabpanel" onSubmit={onSubmit} aria-describedby={error ? "timer-countdown-error" : undefined}>
<label> <label>
<span>{textContent.countdownLabel || "Libellé"}</span> <span>{textContent.countdownLabel || "Libellé"}</span>
<input value={form.label} onChange={(event) => onChange({ label: event.target.value })} placeholder={textContent.countdownLabelPlaceholder || "Boss, event, craft..."} /> <input value={form.label} onChange={(event) => onChange({ label: event.target.value })} placeholder={textContent.countdownLabelPlaceholder || "Boss, event, craft..."} />
@ -490,6 +514,12 @@ function CountdownControls({ textContent, form, onChange, onSubmit }) {
</label> </label>
</div> </div>
)} )}
{error && (
<p id="timer-countdown-error" className="timer-form-error" role="alert">
<Icon name="close" />
<span>{error}</span>
</p>
)}
<button className="primary" type="submit">{textContent.addCountdownButton || "Ajouter"}</button> <button className="primary" type="submit">{textContent.addCountdownButton || "Ajouter"}</button>
</form> </form>
); );

View file

@ -1,23 +1,31 @@
// Rôle : centralise les petits formulaires contrôlés avec brouillon local. // Rôle : centralise les petits formulaires contrôlés avec brouillon local.
// Fournit helpers de champs, submit preventDefault et reset, sans porter de validation métier. // Fournit helpers de champs, submit preventDefault, reset et erreur inline retournée par le submit métier.
// À utiliser quand plusieurs champs sont simplement saisis puis nettoyés au submit. // À utiliser quand plusieurs champs sont simplement saisis puis nettoyés au submit.
import { useState } from "react"; import { useState } from "react";
export function useDraftForm(initialValues) { export function useDraftForm(initialValues) {
const [values, setValues] = useState(initialValues); const [values, setValues] = useState(initialValues);
const [error, setError] = useState("");
function setField(name, value) { function setField(name, value) {
setError("");
setValues((current) => ({ ...current, [name]: value })); setValues((current) => ({ ...current, [name]: value }));
} }
function updateValues(patch) { function updateValues(patch) {
setError("");
setValues((current) => ({ ...current, ...patch })); setValues((current) => ({ ...current, ...patch }));
} }
function reset(nextValues = initialValues) { function reset(nextValues = initialValues) {
setError("");
setValues(nextValues); setValues(nextValues);
} }
function clearError() {
setError("");
}
function getFieldProps(name, props = {}) { function getFieldProps(name, props = {}) {
return { return {
...props, ...props,
@ -32,13 +40,18 @@ export function useDraftForm(initialValues) {
function handleSubmit(event, onSubmit) { function handleSubmit(event, onSubmit) {
event.preventDefault(); event.preventDefault();
onSubmit(values, { reset, setField, setValues, updateValues }); const result = onSubmit(values, { reset, setField, setValues, updateValues, setError, clearError });
if (typeof result === "string") setError(result);
else setError("");
} }
return { return {
values, values,
error,
setField, setField,
setValues, setValues,
setError,
clearError,
updateValues, updateValues,
reset, reset,
getFieldProps, getFieldProps,

View file

@ -3609,6 +3609,34 @@ button.combo-input-token.combo-value-grab .ui-icon {
font-size: var(--font-size-xs); font-size: var(--font-size-xs);
} }
.timer-form-error {
display: inline-flex;
width: 100%;
min-width: 0;
align-items: center;
gap: 7px;
padding: 7px 9px;
border: 1px solid rgba(251, 113, 133, 0.26);
border-radius: var(--radius-sm);
background: rgba(80, 25, 42, 0.28);
color: var(--color-danger);
font-size: var(--font-size-xs);
font-weight: 800;
}
.timer-form-error .ui-icon {
width: 13px;
height: 13px;
flex: 0 0 auto;
}
.timer-form-error span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.timer-control-panel > span, .timer-control-panel > span,
.timer-control-panel label span { .timer-control-panel label span {
color: var(--color-text-secondary); color: var(--color-text-secondary);