Add inline timer form validation
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s
This commit is contained in:
parent
3e16ea278d
commit
d54a93e1f4
6 changed files with 80 additions and 4 deletions
|
|
@ -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 :
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue