From bc31d9471924e3a7c1ff04ff574f0873c1b71b38 Mon Sep 17 00:00:00 2001 From: tristan Date: Fri, 12 Jun 2026 07:38:22 +0000 Subject: [PATCH] =?UTF-8?q?feat(ui)=20:=20MalioDate/DateTime=20=E2=80=94?= =?UTF-8?q?=20event=20update:rawValue=20pour=20validation=20back-autoritat?= =?UTF-8?q?ive=20(#MUI-44)=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## MUI-44 — Exposer la saisie brute invalide (`@update:rawValue`) Suite de MUI-43. Une app consommatrice (Starseed/ERP) fait de la **validation back-autoritative** : plutôt que bloquer le submit côté front, elle transmet la saisie invalide au serveur qui renvoie un `422` mappé inline. Or `MalioDate`/`MalioDateTime` **avalent** la saisie invalide (ni `modelValue`, ni texte brut) → le parent ne peut rien envoyer. ### Changements - Nouvel emit `(e: 'update:rawValue', value: string)` sur `Date.vue` et `DateTime.vue`, émis à chaque commit : - saisie **invalide** (non parsable ou hors `min`/`max`) → chaîne brute trimmée telle que tapée (ex. `"32/13/2026"`), **sans** emit `update:modelValue` ; - saisie **valide ou vide**, **clear**, **sélection au calendrier** (+ réglage d'heure pour DateTime) → `''`. - Canal **séparé** : `modelValue` reste `string` ISO `| null` (affichage + round-trip). Le parent construit son payload via `valid ? modelValue : rawValue`. ### Tests (TDD) 6 cas ajoutés par composant : malformé, hors bornes, valide, vidé, clear, sélection calendrier. Suite complète **987 ✓**, ESLint 0 erreur. ### Doc `COMPONENTS.md` (paragraphe + Events + exemples) et `CHANGELOG.md` (entrée MUI-44) à jour. ### Hors périmètre `DateRange`/`DateWeek` (pas de saisie texte libre). Branchement Starseed (`collectDenormalizationErrors`, `useFormErrors`) traité côté ERP. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://gitea.malio.fr/MALIO-DEV/malio-layer-ui/pulls/74 Co-authored-by: tristan Co-committed-by: tristan --- CHANGELOG.md | 1 + COMPONENTS.md | 12 +++-- app/components/malio/date/Date.test.ts | 54 ++++++++++++++++++++++ app/components/malio/date/Date.vue | 9 ++++ app/components/malio/date/DateTime.test.ts | 54 ++++++++++++++++++++++ app/components/malio/date/DateTime.vue | 10 ++++ 6 files changed, 137 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 904ce6f..81c7a2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ Liste des évolutions de la librairie Malio layer UI * [#MUI-43] MalioDateTime : saisie clavier `JJ/MM/AAAA HH:MM` optionnelle (prop `editable`, masque maska, `invalidMessage`) + même event `update:valid` que MalioDate (mêmes règles, émis dès le montage). Nouveau parseur `parseDisplayToIsoDateTime`. * [#MUI-43] Famille Date editable (MalioDate, MalioDateTime) : gabarit fantôme progressif — le format (`JJ/MM/AAAA` / `JJ/MM/AAAA HH:MM`) s'affiche en gris et se remplit au fil de la saisie (tapé en noir, reste en gris) ; séparateurs (`/`, espace, `:`) posés automatiquement dès qu'un groupe est complet (maska `eager`). CalendarField : prop `placeholderTemplate` (le masque maska en est dérivé), remplace l'ancienne mécanique de masque codé en dur. * [#MUI-43] CalendarField : la croix d'effacement réinitialise désormais la saisie clavier même après une date invalide (le `v-model` restant `null`, le champ se vidait pas). +* [#MUI-44] MalioDate / MalioDateTime : event `update:rawValue` (string) exposant la saisie brute sur un canal séparé pour la validation back-autoritative — saisie invalide (non parsable ou hors `min`/`max`) → texte trimmé tel que tapé, saisie valide/vide + clear + sélection au calendrier → `''`. `modelValue` reste `string` ISO `| null` (la saisie invalide n'y transite jamais) ; le parent construit son payload via `valid ? modelValue : rawValue`. ### Changed * DataTable : libellés de pagination en français — `Préc.` / `Suiv.` (étaient `Prev` / `Next`) ; aria-labels déjà en français inchangés. diff --git a/COMPONENTS.md b/COMPONENTS.md index 0df8e98..cb02580 100644 --- a/COMPONENTS.md +++ b/COMPONENTS.md @@ -509,6 +509,8 @@ Avec `editable`, l'utilisateur peut aussi taper la date au clavier. La valeur n' L'event `update:valid` remonte l'état de validité de la saisie au parent (`true` = vide ou date valide dans les bornes ; `false` = saisie malformée ou hors `min`/`max`). Il est émis **dès le montage** (état d'un champ pré-rempli connu sans interaction) puis à chaque transition. Il permet d'agréger la validité des champs date dans la gate de submit d'un formulaire — une saisie invalide n'émettant pas `modelValue`, c'est le seul signal disponible côté parent. La validité ne couvre **pas** l'obligation `required` (un champ vide reste valide), qui reste à la charge du parent. +L'event `update:rawValue` expose la **saisie brute** sur un canal séparé, pour les formulaires en validation back-autoritative (le serveur tranche le format et renvoie un `422`). Il est émis à chaque commit : saisie invalide (non parsable ou hors `min`/`max`) → la chaîne trimmée telle que tapée (ex. `"32/13/2026"`) ; saisie valide ou vide, clear, sélection au calendrier → `''`. Le parent construit alors son payload via `valid ? modelValue : rawValue`. La saisie invalide **ne transite jamais** par `modelValue` (qui reste `string` ISO `| null` pour l'affichage et le round-trip) ; `valid` dit *qu'il y a* une erreur, `rawValue` dit *quoi* envoyer. + | Prop | Type | Défaut | Description | |------|------|--------|-------------| | `modelValue` | `string \| null` | `undefined` | Date ISO `"YYYY-MM-DD"` (v-model) | @@ -530,7 +532,7 @@ L'event `update:valid` remonte l'état de validité de la saisie au parent (`tru | `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. | | `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes | -**Events :** `update:modelValue(value: string | null)`, `update:valid(value: boolean)` +**Events :** `update:modelValue(value: string | null)`, `update:valid(value: boolean)`, `update:rawValue(value: string)` **Clavier :** `Entrée` / `Espace` ouvrent le calendrier, `Échap` ferme. Anneau de focus clavier (combo champ + calendrier à l'ouverture). La croix d'effacement est focusable. _(Comportement partagé par DateRange, DateTime, DateWeek via le shell CalendarField.)_ @@ -540,6 +542,9 @@ L'event `update:valid` remonte l'état de validité de la saisie au parent (`tru + + + ``` --- @@ -694,16 +699,17 @@ La valeur est une chaîne **ISO naïve sans fuseau** au format `"YYYY-MM-DDTHH:M | `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. | | `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes | -**Events :** `update:modelValue(value: string | null)`, `update:valid(value: boolean)` +**Events :** `update:modelValue(value: string | null)`, `update:valid(value: boolean)`, `update:rawValue(value: string)` Flux : cliquer un jour fixe la date (heure par défaut `00:00`), régler l'heure met à jour l'heure ; le popover se ferme au clic extérieur. La valeur est émise en direct à chaque interaction. -Avec `editable`, l'utilisateur peut aussi taper `JJ/MM/AAAA HH:MM` au clavier. La valeur n'est émise qu'au blur (ou sur Entrée) si elle est valide et dans les bornes ; sinon le texte est conservé et le champ passe en erreur (`invalidMessage`). Un **gabarit fantôme** affiche le format en gris et se remplit au fil de la saisie (cf. MalioDate). L'event `update:valid` (booléen) — émis **dès le montage** puis à chaque transition — remonte l'état de validité au parent (`false` = saisie malformée ou hors `min`/`max`, qui n'émet pas `modelValue`), pour bloquer un submit. La validité ne couvre **pas** `required` (champ vide = valide), comme sur `MalioDate`. +Avec `editable`, l'utilisateur peut aussi taper `JJ/MM/AAAA HH:MM` au clavier. La valeur n'est émise qu'au blur (ou sur Entrée) si elle est valide et dans les bornes ; sinon le texte est conservé et le champ passe en erreur (`invalidMessage`). Un **gabarit fantôme** affiche le format en gris et se remplit au fil de la saisie (cf. MalioDate). L'event `update:valid` (booléen) — émis **dès le montage** puis à chaque transition — remonte l'état de validité au parent (`false` = saisie malformée ou hors `min`/`max`, qui n'émet pas `modelValue`), pour bloquer un submit. La validité ne couvre **pas** `required` (champ vide = valide), comme sur `MalioDate`. L'event `update:rawValue` expose la saisie brute pour la validation back-autoritative (mêmes règles que `MalioDate` : texte trimmé sur saisie invalide, `''` sinon — clear et sélection au calendrier compris). ```vue + ``` --- diff --git a/app/components/malio/date/Date.test.ts b/app/components/malio/date/Date.test.ts index 1bd6676..382cda0 100644 --- a/app/components/malio/date/Date.test.ts +++ b/app/components/malio/date/Date.test.ts @@ -471,4 +471,58 @@ describe('MalioDate', () => { expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true]) }) }) + + describe('saisie brute (update:rawValue)', () => { + it('émet le texte brut trimmé sur saisie malformée, sans émettre modelValue', async () => { + const wrapper = mountDate({editable: true}) + const input = wrapper.get('[data-test="date-input"]') + await input.setValue('32/13/2026') + await input.trigger('blur') + expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['32/13/2026']) + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) + + it('émet le texte brut trimmé sur saisie hors min/max', async () => { + const wrapper = mountDate({editable: true, min: '2026-05-10', max: '2026-05-20'}) + const input = wrapper.get('[data-test="date-input"]') + await input.setValue('25/12/2026') + await input.trigger('blur') + expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['25/12/2026']) + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) + + it('émet rawValue vide et l\'ISO sur saisie clavier valide', async () => { + const wrapper = mountDate({editable: true}) + const input = wrapper.get('[data-test="date-input"]') + await input.setValue('19/05/2026') + await input.trigger('blur') + expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['']) + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19']) + }) + + it('émet rawValue vide sur saisie vidée au blur', async () => { + const wrapper = mountDate({editable: true, modelValue: '2026-05-19'}) + const input = wrapper.get('[data-test="date-input"]') + await input.setValue('') + await input.trigger('blur') + expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['']) + }) + + it('émet rawValue vide sur clear', async () => { + const wrapper = mountDate({modelValue: '2026-05-19'}) + await wrapper.get('[data-test="clear"]').trigger('click') + expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['']) + }) + + it('émet rawValue vide quand on sélectionne une date au calendrier', async () => { + const wrapper = mountDate({editable: true}) + const input = wrapper.get('[data-test="date-input"]') + await input.setValue('32/13/2026') + await input.trigger('blur') + expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['32/13/2026']) + await input.trigger('focus') + await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click') + expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['']) + }) + }) }) diff --git a/app/components/malio/date/Date.vue b/app/components/malio/date/Date.vue index 76a3559..34aeed0 100644 --- a/app/components/malio/date/Date.vue +++ b/app/components/malio/date/Date.vue @@ -90,6 +90,10 @@ const props = withDefaults( const emit = defineEmits<{ (e: 'update:modelValue', value: string | null): void (e: 'update:valid', value: boolean): void + // Canal séparé pour la saisie invalide (validation back-autoritative) : texte brut + // tel que tapé sur saisie non parsable/hors plage, '' sinon. Ne JAMAIS transiter + // par modelValue, qui doit rester ISO|null pour l'affichage et le round-trip. + (e: 'update:rawValue', value: string): void }>() const displayValue = computed(() => formatIsoToDisplay(props.modelValue ?? null)) @@ -108,25 +112,30 @@ const onCommit = (text: string) => { const trimmed = text.trim() if (trimmed === '') { setError('') + emit('update:rawValue', '') emit('update:modelValue', null) return } const iso = parseDisplayToIso(trimmed) if (iso && isDateInRange(iso, props.min, props.max)) { setError('') + emit('update:rawValue', '') emit('update:modelValue', iso) return } setError(props.invalidMessage) + emit('update:rawValue', trimmed) } const onClear = () => { setError('') + emit('update:rawValue', '') emit('update:modelValue', null) } const onSelect = (iso: string, close: () => void) => { setError('') + emit('update:rawValue', '') emit('update:modelValue', iso) close() } diff --git a/app/components/malio/date/DateTime.test.ts b/app/components/malio/date/DateTime.test.ts index 1b0c7b5..054977b 100644 --- a/app/components/malio/date/DateTime.test.ts +++ b/app/components/malio/date/DateTime.test.ts @@ -283,4 +283,58 @@ describe('MalioDateTime', () => { expect(wrapper.emitted('update:valid')?.at(-1)).toEqual([true]) }) }) + + describe('saisie brute (update:rawValue)', () => { + it('émet le texte brut trimmé sur saisie malformée, sans émettre modelValue', async () => { + const wrapper = mountDateTime({editable: true}) + const input = wrapper.get('[data-test="date-input"]') + await input.setValue('32/13/2026 14:30') + await input.trigger('blur') + expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['32/13/2026 14:30']) + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) + + it('émet le texte brut trimmé sur saisie hors min/max', async () => { + const wrapper = mountDateTime({editable: true, min: '2026-05-10T00:00:00', max: '2026-05-20T00:00:00'}) + const input = wrapper.get('[data-test="date-input"]') + await input.setValue('25/12/2026 10:00') + await input.trigger('blur') + expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['25/12/2026 10:00']) + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) + + it('émet rawValue vide et l\'ISO sur saisie clavier valide', async () => { + const wrapper = mountDateTime({editable: true}) + const input = wrapper.get('[data-test="date-input"]') + await input.setValue('20/05/2026 14:30') + await input.trigger('blur') + expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['']) + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-20T14:30:00']) + }) + + it('émet rawValue vide sur saisie vidée au blur', async () => { + const wrapper = mountDateTime({editable: true, modelValue: '2026-05-20T14:30:00'}) + const input = wrapper.get('[data-test="date-input"]') + await input.setValue('') + await input.trigger('blur') + expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['']) + }) + + it('émet rawValue vide sur clear', async () => { + const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'}) + await wrapper.get('[data-test="clear"]').trigger('click') + expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['']) + }) + + it('émet rawValue vide quand on sélectionne une date au calendrier', async () => { + const wrapper = mountDateTime({editable: true}) + const input = wrapper.get('[data-test="date-input"]') + await input.setValue('32/13/2026 14:30') + await input.trigger('blur') + expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['32/13/2026 14:30']) + await input.trigger('focus') + await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click') + expect(wrapper.emitted('update:rawValue')?.at(-1)).toEqual(['']) + }) + }) }) diff --git a/app/components/malio/date/DateTime.vue b/app/components/malio/date/DateTime.vue index f73259e..57ae540 100644 --- a/app/components/malio/date/DateTime.vue +++ b/app/components/malio/date/DateTime.vue @@ -103,6 +103,10 @@ const props = withDefaults( const emit = defineEmits<{ (e: 'update:modelValue', value: string | null): void (e: 'update:valid', value: boolean): void + // Canal séparé pour la saisie invalide (validation back-autoritative) : texte brut + // tel que tapé sur saisie non parsable/hors plage, '' sinon. Ne JAMAIS transiter + // par modelValue, qui doit rester ISO|null pour l'affichage et le round-trip. + (e: 'update:rawValue', value: string): void }>() // pendingTime : heure réglée avant qu'un jour ne soit choisi (sinon on ne peut pas émettre). @@ -129,6 +133,7 @@ function onSelectDay(iso: string) { const now = new Date() const time = parts.value.time || pendingTime.value || formatTime(now.getHours(), now.getMinutes()) setError('') + emit('update:rawValue', '') emit('update:modelValue', composeDateTime(iso, time)) } @@ -136,6 +141,7 @@ function onTimeChange(value: string | null) { if (!value) return if (datePart.value) { setError('') + emit('update:rawValue', '') emit('update:modelValue', composeDateTime(datePart.value, value)) } else { @@ -147,21 +153,25 @@ function onCommit(text: string) { const trimmed = text.trim() if (trimmed === '') { setError('') + emit('update:rawValue', '') emit('update:modelValue', null) return } const iso = parseDisplayToIsoDateTime(trimmed) if (iso && isDateInRange(iso, props.min, props.max)) { setError('') + emit('update:rawValue', '') emit('update:modelValue', iso) return } setError(props.invalidMessage) + emit('update:rawValue', trimmed) } function onClear() { setError('') pendingTime.value = '' + emit('update:rawValue', '') emit('update:modelValue', null) }