bc31d94719
## 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: #74 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
187 lines
5.3 KiB
Vue
187 lines
5.3 KiB
Vue
<template>
|
|
<CalendarField
|
|
:id="id"
|
|
:display-value="displayValue"
|
|
:sync-to="datePart"
|
|
:name="name"
|
|
:label="label"
|
|
:placeholder="placeholder"
|
|
:required="required"
|
|
:disabled="disabled"
|
|
:readonly="readonly"
|
|
:hint="hint"
|
|
:error="mergedError"
|
|
:success="success"
|
|
:clearable="clearable"
|
|
:editable="editable"
|
|
placeholder-template="JJ/MM/AAAA HH:MM"
|
|
:input-class="inputClass"
|
|
:label-class="labelClass"
|
|
:group-class="groupClass"
|
|
v-bind="$attrs"
|
|
@clear="onClear"
|
|
@commit="onCommit"
|
|
>
|
|
<template #default="{ currentMonth, currentYear }">
|
|
<MonthGrid
|
|
:month="currentMonth"
|
|
:year="currentYear"
|
|
:selected-date="datePart"
|
|
:min="min?.slice(0, 10)"
|
|
:max="max?.slice(0, 10)"
|
|
@select="onSelectDay"
|
|
/>
|
|
<div class="mt-4">
|
|
<MalioTimePicker
|
|
:model-value="timeValue || null"
|
|
label="Heure"
|
|
:clearable="false"
|
|
static-popover
|
|
@update:model-value="onTimeChange"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</CalendarField>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import {computed, ref, watch} from 'vue'
|
|
import CalendarField from './internal/CalendarField.vue'
|
|
import MonthGrid from './internal/MonthGrid.vue'
|
|
import MalioTimePicker from '../time/TimePicker.vue'
|
|
import {formatTime} from '../time/composables/timeFormat'
|
|
import {isDateInRange} from './composables/dateFormat'
|
|
import {composeDateTime, formatIsoDateTimeToDisplay, isValidIsoDateTime, parseDisplayToIsoDateTime, splitDateTime} from './composables/datetimeFormat'
|
|
|
|
defineOptions({name: 'MalioDateTime', inheritAttrs: false})
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
id?: string
|
|
name?: string
|
|
label?: string
|
|
modelValue?: string | null
|
|
placeholder?: string
|
|
required?: boolean
|
|
disabled?: boolean
|
|
readonly?: boolean
|
|
hint?: string
|
|
error?: string
|
|
success?: string
|
|
min?: string
|
|
max?: string
|
|
clearable?: boolean
|
|
editable?: boolean
|
|
invalidMessage?: string
|
|
inputClass?: string
|
|
labelClass?: string
|
|
groupClass?: string
|
|
}>(),
|
|
{
|
|
id: '',
|
|
name: '',
|
|
label: '',
|
|
modelValue: undefined,
|
|
placeholder: 'JJ/MM/AAAA HH:MM',
|
|
required: false,
|
|
disabled: false,
|
|
readonly: false,
|
|
hint: '',
|
|
error: '',
|
|
success: '',
|
|
min: undefined,
|
|
max: undefined,
|
|
clearable: true,
|
|
editable: false,
|
|
invalidMessage: 'Date invalide',
|
|
inputClass: '',
|
|
labelClass: '',
|
|
groupClass: '',
|
|
},
|
|
)
|
|
|
|
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).
|
|
const pendingTime = ref('')
|
|
|
|
const parts = computed(() => splitDateTime(props.modelValue ?? null))
|
|
const datePart = computed(() => parts.value.date)
|
|
const displayValue = computed(() => formatIsoDateTimeToDisplay(props.modelValue ?? null))
|
|
const timeValue = computed(() => parts.value.time || pendingTime.value)
|
|
|
|
const internalError = ref('')
|
|
const mergedError = computed(() => props.error || internalError.value)
|
|
|
|
// La validité ne reflète que la saisie clavier : malformée/hors plage → false. Un
|
|
// champ vide est valide (l'obligation `required` reste à la charge du parent).
|
|
const setError = (message: string) => {
|
|
internalError.value = message
|
|
emit('update:valid', message === '')
|
|
}
|
|
|
|
function onSelectDay(iso: string) {
|
|
// Si aucune heure n'a été choisie, on prend l'heure actuelle (pas 00:00).
|
|
// (heure courante au moment du clic)
|
|
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))
|
|
}
|
|
|
|
function onTimeChange(value: string | null) {
|
|
if (!value) return
|
|
if (datePart.value) {
|
|
setError('')
|
|
emit('update:rawValue', '')
|
|
emit('update:modelValue', composeDateTime(datePart.value, value))
|
|
}
|
|
else {
|
|
pendingTime.value = value
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// immediate : émet aussi la validité au montage, pour que le parent connaisse
|
|
// l'état d'un champ pré-rempli (formulaire d'édition) sans interaction préalable.
|
|
watch(() => props.modelValue, (val) => {
|
|
setError('')
|
|
if (val && !isValidIsoDateTime(val) && import.meta.dev) {
|
|
console.warn(`[MalioDateTime] modelValue invalide ignoré : "${val}"`)
|
|
}
|
|
}, {immediate: true})
|
|
</script>
|