Merge branch 'develop' into feat/erp-m3-technique-module-taxonomie
This commit is contained in:
@@ -386,7 +386,10 @@
|
||||
},
|
||||
"title": "Erreur",
|
||||
"generic": "Une erreur est survenue.",
|
||||
"unknown": "Erreur inconnue."
|
||||
"unknown": "Erreur inconnue.",
|
||||
"validation": {
|
||||
"invalidDate": "Date invalide"
|
||||
}
|
||||
},
|
||||
"sites": {
|
||||
"selector": {
|
||||
|
||||
@@ -187,7 +187,7 @@ import {
|
||||
addressTypeFromFlags,
|
||||
isBillingEmailRequired,
|
||||
type AddressType,
|
||||
} from '~/modules/commercial/utils/clientFormRules'
|
||||
} from '~/modules/commercial/utils/forms/clientFormRules'
|
||||
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
|
||||
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useClientReferentials'
|
||||
import type { AddressFormDraft } from '~/modules/commercial/types/clientForm'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ref } from 'vue'
|
||||
import type { ClientDetail } from '~/modules/commercial/utils/clientConsultation'
|
||||
import type { ClientDetail } from '~/modules/commercial/utils/forms/clientConsultation'
|
||||
|
||||
/**
|
||||
* Chargement et actions d'archivage d'un client unique (ecran « Consultation
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ref } from 'vue'
|
||||
import type { SupplierDetail } from '~/modules/commercial/utils/supplierConsultation'
|
||||
import type { SupplierDetail } from '~/modules/commercial/utils/forms/supplierConsultation'
|
||||
|
||||
/**
|
||||
* Chargement et actions d'archivage d'un fournisseur unique (ecran « Consultation
|
||||
|
||||
@@ -116,6 +116,7 @@
|
||||
:readonly="businessReadonly"
|
||||
:editable="true"
|
||||
:error="informationErrors.errors.foundedAt"
|
||||
@update:raw-value="(v: string) => information.foundedAtRaw = v"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="information.employeesCount"
|
||||
@@ -401,7 +402,7 @@ import {
|
||||
mapAddressToDraft,
|
||||
mapRibToDraft,
|
||||
type ClientDetail,
|
||||
} from '~/modules/commercial/utils/clientConsultation'
|
||||
} from '~/modules/commercial/utils/forms/clientConsultation'
|
||||
import {
|
||||
buildAccountingPayload,
|
||||
buildAddressPayload,
|
||||
@@ -417,7 +418,7 @@ import {
|
||||
type ClientEditAbilities,
|
||||
type InformationFormDraft,
|
||||
type MainFormDraft,
|
||||
} from '~/modules/commercial/utils/clientEdit'
|
||||
} from '~/modules/commercial/utils/forms/clientEdit'
|
||||
import {
|
||||
buildClientFormTabKeys,
|
||||
isAddressValid,
|
||||
@@ -429,7 +430,7 @@ import {
|
||||
isRibComplete,
|
||||
isRibRequiredForPaymentType,
|
||||
showsRelationAndTriageFields,
|
||||
} from '~/modules/commercial/utils/clientFormRules'
|
||||
} from '~/modules/commercial/utils/forms/clientFormRules'
|
||||
import {
|
||||
emptyAddress,
|
||||
emptyContact,
|
||||
|
||||
@@ -280,7 +280,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useClient } from '~/modules/commercial/composables/useClient'
|
||||
import { buildClientFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/clientFormRules'
|
||||
import { buildClientFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/clientFormRules'
|
||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||
import {
|
||||
canEditClient,
|
||||
@@ -297,7 +297,7 @@ import {
|
||||
showRestoreAction,
|
||||
type ClientDetail,
|
||||
type SelectOption,
|
||||
} from '~/modules/commercial/utils/clientConsultation'
|
||||
} from '~/modules/commercial/utils/forms/clientConsultation'
|
||||
import { emptyAddress, emptyContact } from '~/modules/commercial/types/clientForm'
|
||||
|
||||
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
:readonly="isValidated('information')"
|
||||
:editable="true"
|
||||
:error="informationErrors.errors.foundedAt"
|
||||
@update:raw-value="(v: string) => information.foundedAtRaw = v"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="information.employeesCount"
|
||||
@@ -401,12 +402,12 @@ import {
|
||||
isRibRequiredForPaymentType,
|
||||
lastFillableTabKey,
|
||||
showsRelationAndTriageFields,
|
||||
} from '~/modules/commercial/utils/clientFormRules'
|
||||
} from '~/modules/commercial/utils/forms/clientFormRules'
|
||||
import {
|
||||
buildAddressPayload,
|
||||
buildMainPayload,
|
||||
buildRibPayload,
|
||||
} from '~/modules/commercial/utils/clientEdit'
|
||||
} from '~/modules/commercial/utils/forms/clientEdit'
|
||||
import {
|
||||
emptyAddress,
|
||||
emptyContact,
|
||||
@@ -651,6 +652,8 @@ const information = reactive({
|
||||
description: null as string | null,
|
||||
competitors: null as string | null,
|
||||
foundedAt: null as string | null,
|
||||
// Saisie brute invalide remontee par MalioDate (cf. foundedAtRaw, MUI-44).
|
||||
foundedAtRaw: '',
|
||||
employeesCount: null as string | null,
|
||||
revenueAmount: null as string | null,
|
||||
profitAmount: null as string | null,
|
||||
@@ -666,7 +669,8 @@ async function submitInformation(): Promise<void> {
|
||||
await api.patch(`/clients/${clientId.value}`, {
|
||||
description: information.description || null,
|
||||
competitors: information.competitors || null,
|
||||
foundedAt: information.foundedAt || null,
|
||||
// Saisie invalide prioritaire -> 422 back sur foundedAt (cf. foundedAtRaw).
|
||||
foundedAt: information.foundedAtRaw || information.foundedAt || null,
|
||||
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
|
||||
revenueAmount: information.revenueAmount || null,
|
||||
profitAmount: information.profitAmount || null,
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
:readonly="businessReadonly"
|
||||
:editable="true"
|
||||
:error="informationErrors.errors.foundedAt"
|
||||
@update:raw-value="(v: string) => information.foundedAtRaw = v"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="information.employeesCount"
|
||||
@@ -370,7 +371,7 @@ import {
|
||||
mapAddressToDraft,
|
||||
mapRibToDraft,
|
||||
type SupplierDetail,
|
||||
} from '~/modules/commercial/utils/supplierConsultation'
|
||||
} from '~/modules/commercial/utils/forms/supplierConsultation'
|
||||
import {
|
||||
buildAccountingPayload,
|
||||
buildAddressPayload,
|
||||
@@ -386,7 +387,7 @@ import {
|
||||
type InformationFormDraft,
|
||||
type MainFormDraft,
|
||||
type SupplierEditAbilities,
|
||||
} from '~/modules/commercial/utils/supplierEdit'
|
||||
} from '~/modules/commercial/utils/forms/supplierEdit'
|
||||
import {
|
||||
buildSupplierFormTabKeys,
|
||||
isAddressValid,
|
||||
@@ -396,7 +397,7 @@ import {
|
||||
isRibBlank,
|
||||
isRibComplete,
|
||||
isRibRequiredForPaymentType,
|
||||
} from '~/modules/commercial/utils/supplierFormRules'
|
||||
} from '~/modules/commercial/utils/forms/supplierFormRules'
|
||||
import {
|
||||
emptyAddress,
|
||||
emptyContact,
|
||||
|
||||
@@ -263,7 +263,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useSupplier } from '~/modules/commercial/composables/useSupplier'
|
||||
import { buildSupplierFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/supplierFormRules'
|
||||
import { buildSupplierFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/supplierFormRules'
|
||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||
import {
|
||||
canEditSupplier,
|
||||
@@ -280,7 +280,7 @@ import {
|
||||
showRestoreAction,
|
||||
type SelectOption,
|
||||
type SupplierDetail,
|
||||
} from '~/modules/commercial/utils/supplierConsultation'
|
||||
} from '~/modules/commercial/utils/forms/supplierConsultation'
|
||||
import { emptyContact } from '~/modules/commercial/types/supplierForm'
|
||||
|
||||
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
:readonly="isValidated('information')"
|
||||
:editable="true"
|
||||
:error="informationErrors.errors.foundedAt"
|
||||
@update:raw-value="(v: string) => information.foundedAtRaw = v"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="information.employeesCount"
|
||||
@@ -361,7 +362,7 @@ import {
|
||||
isRibComplete,
|
||||
isRibRequiredForPaymentType,
|
||||
lastFillableTabKey,
|
||||
} from '~/modules/commercial/utils/supplierFormRules'
|
||||
} from '~/modules/commercial/utils/forms/supplierFormRules'
|
||||
import {
|
||||
buildAccountingPayload,
|
||||
buildAddressPayload,
|
||||
@@ -369,7 +370,7 @@ import {
|
||||
buildInformationPayload,
|
||||
buildMainPayload,
|
||||
buildRibPayload,
|
||||
} from '~/modules/commercial/utils/supplierEdit'
|
||||
} from '~/modules/commercial/utils/forms/supplierEdit'
|
||||
import {
|
||||
emptyAddress,
|
||||
emptyContact,
|
||||
@@ -549,6 +550,8 @@ const information = reactive({
|
||||
description: null as string | null,
|
||||
competitors: null as string | null,
|
||||
foundedAt: null as string | null,
|
||||
// Saisie brute invalide remontee par MalioDate (cf. foundedAtRaw, MUI-44).
|
||||
foundedAtRaw: '',
|
||||
employeesCount: null as string | null,
|
||||
revenueAmount: null as string | null,
|
||||
profitAmount: null as string | null,
|
||||
|
||||
+11
@@ -36,6 +36,7 @@ function informationDraft(overrides: Partial<InformationFormDraft> = {}): Inform
|
||||
description: 'desc',
|
||||
competitors: 'concurrents',
|
||||
foundedAt: '2010-05-01',
|
||||
foundedAtRaw: '',
|
||||
employeesCount: '42',
|
||||
revenueAmount: '1000000',
|
||||
profitAmount: '50000',
|
||||
@@ -140,6 +141,16 @@ describe('buildInformationPayload — scoping strict groupe client:write:informa
|
||||
expect(payload.description).toBeNull()
|
||||
expect(payload.directorName).toBeNull()
|
||||
})
|
||||
|
||||
it('envoie la saisie invalide (foundedAtRaw) en priorite -> le back tranchera (422)', () => {
|
||||
// Saisie malformee : on transmet le texte brut tel quel pour declencher la
|
||||
// 422 back sur foundedAt (validation autoritaire du format, MUI-44).
|
||||
expect(buildInformationPayload(informationDraft({ foundedAt: null, foundedAtRaw: '32/13/2026' })).foundedAt)
|
||||
.toBe('32/13/2026')
|
||||
// Saisie valide : foundedAtRaw vide -> on envoie la date ISO.
|
||||
expect(buildInformationPayload(informationDraft({ foundedAt: '2010-05-01', foundedAtRaw: '' })).foundedAt)
|
||||
.toBe('2010-05-01')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildAccountingPayload — scoping strict groupe client:write:accounting', () => {
|
||||
+11
-2
@@ -11,7 +11,7 @@ import {
|
||||
mapMainDraft,
|
||||
resolveTabEditability,
|
||||
} from '../supplierEdit'
|
||||
import type { SupplierDetail } from '~/modules/commercial/utils/supplierConsultation'
|
||||
import type { SupplierDetail } from '~/modules/commercial/utils/forms/supplierConsultation'
|
||||
import { emptyAddress, emptyContact, emptyRib } from '~/modules/commercial/types/supplierForm'
|
||||
|
||||
describe('buildMainPayload (groupe supplier:write:main)', () => {
|
||||
@@ -37,7 +37,7 @@ describe('buildMainPayload (groupe supplier:write:main)', () => {
|
||||
|
||||
describe('buildInformationPayload (groupe supplier:write:information)', () => {
|
||||
const base = {
|
||||
description: null, competitors: null, foundedAt: null, employeesCount: null,
|
||||
description: null, competitors: null, foundedAt: null, foundedAtRaw: '', employeesCount: null,
|
||||
revenueAmount: null, profitAmount: null, directorName: null, volumeForecast: null,
|
||||
}
|
||||
|
||||
@@ -48,6 +48,15 @@ describe('buildInformationPayload (groupe supplier:write:information)', () => {
|
||||
})
|
||||
expect(buildInformationPayload(base)).toMatchObject({ employeesCount: null, volumeForecast: null })
|
||||
})
|
||||
|
||||
it('envoie la saisie invalide (foundedAtRaw) en priorite -> le back tranchera (422)', () => {
|
||||
// Saisie malformee transmise telle quelle pour declencher la 422 back (MUI-44).
|
||||
expect(buildInformationPayload({ ...base, foundedAt: null, foundedAtRaw: '32/13/2026' }).foundedAt)
|
||||
.toBe('32/13/2026')
|
||||
// Saisie valide : foundedAtRaw vide -> on envoie la date ISO.
|
||||
expect(buildInformationPayload({ ...base, foundedAt: '2008-04-01', foundedAtRaw: '' }).foundedAt)
|
||||
.toBe('2008-04-01')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildContactPayload (sous-ressource supplier_contact)', () => {
|
||||
+14
-3
@@ -20,14 +20,14 @@ import {
|
||||
iriOf,
|
||||
relationOf,
|
||||
type ClientDetail,
|
||||
} from '~/modules/commercial/utils/clientConsultation'
|
||||
} from '~/modules/commercial/utils/forms/clientConsultation'
|
||||
import {
|
||||
ADDRESS_REQUIRED_NON_NULLABLE_KEYS,
|
||||
blankEmptyRequired,
|
||||
MAIN_REQUIRED_NON_NULLABLE_KEYS,
|
||||
omitEmptyRequired,
|
||||
RIB_REQUIRED_NON_NULLABLE_KEYS,
|
||||
} from '~/modules/commercial/utils/clientFormRules'
|
||||
} from '~/modules/commercial/utils/forms/clientFormRules'
|
||||
import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm'
|
||||
|
||||
/**
|
||||
@@ -53,6 +53,13 @@ export interface InformationFormDraft {
|
||||
competitors: string | null
|
||||
/** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */
|
||||
foundedAt: string | null
|
||||
/**
|
||||
* Saisie brute invalide remontee par MalioDate (`@update:rawValue`) : '' tant
|
||||
* que la saisie est valide/vide, sinon le texte tel que tape. On l'envoie au
|
||||
* back en priorite sur `foundedAt` pour que la 422 (validation autoritaire du
|
||||
* format, ERP-101) porte sur le champ et s'affiche inline. Cf. MUI-44.
|
||||
*/
|
||||
foundedAtRaw: string
|
||||
/** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */
|
||||
employeesCount: string | null
|
||||
revenueAmount: string | null
|
||||
@@ -118,6 +125,8 @@ export function mapInformationDraft(client: ClientDetail): InformationFormDraft
|
||||
competitors: client.competitors ?? null,
|
||||
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime.
|
||||
foundedAt: client.foundedAt ? client.foundedAt.slice(0, 10) : null,
|
||||
// Aucune saisie brute invalide au chargement (la valeur stockee est valide).
|
||||
foundedAtRaw: '',
|
||||
employeesCount: client.employeesCount != null ? String(client.employeesCount) : null,
|
||||
revenueAmount: client.revenueAmount ?? null,
|
||||
profitAmount: client.profitAmount ?? null,
|
||||
@@ -191,7 +200,9 @@ export function buildInformationPayload(information: InformationFormDraft): Reco
|
||||
return {
|
||||
description: information.description || null,
|
||||
competitors: information.competitors || null,
|
||||
foundedAt: information.foundedAt || null,
|
||||
// Saisie invalide (foundedAtRaw) prioritaire : on l'envoie telle quelle
|
||||
// pour que le back renvoie une 422 sur foundedAt (cf. foundedAtRaw).
|
||||
foundedAt: information.foundedAtRaw || information.foundedAt || null,
|
||||
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
|
||||
revenueAmount: information.revenueAmount || null,
|
||||
profitAmount: information.profitAmount || null,
|
||||
+14
-3
@@ -17,8 +17,8 @@ import {
|
||||
MAIN_REQUIRED_NON_NULLABLE_KEYS,
|
||||
omitEmptyRequired,
|
||||
RIB_REQUIRED_NON_NULLABLE_KEYS,
|
||||
} from '~/modules/commercial/utils/supplierFormRules'
|
||||
import { iriOf, type SupplierDetail } from '~/modules/commercial/utils/supplierConsultation'
|
||||
} from '~/modules/commercial/utils/forms/supplierFormRules'
|
||||
import { iriOf, type SupplierDetail } from '~/modules/commercial/utils/forms/supplierConsultation'
|
||||
import type {
|
||||
SupplierAddressFormDraft,
|
||||
SupplierContactFormDraft,
|
||||
@@ -38,6 +38,13 @@ export interface InformationFormDraft {
|
||||
competitors: string | null
|
||||
/** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */
|
||||
foundedAt: string | null
|
||||
/**
|
||||
* Saisie brute invalide remontee par MalioDate (`@update:rawValue`) : '' tant
|
||||
* que la saisie est valide/vide, sinon le texte tel que tape. On l'envoie au
|
||||
* back en priorite sur `foundedAt` pour que la 422 (validation autoritaire du
|
||||
* format, ERP-101) porte sur le champ et s'affiche inline. Cf. MUI-44.
|
||||
*/
|
||||
foundedAtRaw: string
|
||||
/** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */
|
||||
employeesCount: string | null
|
||||
revenueAmount: string | null
|
||||
@@ -95,6 +102,8 @@ export function mapInformationDraft(supplier: SupplierDetail): InformationFormDr
|
||||
competitors: supplier.competitors ?? null,
|
||||
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime.
|
||||
foundedAt: supplier.foundedAt ? supplier.foundedAt.slice(0, 10) : null,
|
||||
// Aucune saisie brute invalide au chargement (la valeur stockee est valide).
|
||||
foundedAtRaw: '',
|
||||
employeesCount: supplier.employeesCount != null ? String(supplier.employeesCount) : null,
|
||||
revenueAmount: supplier.revenueAmount ?? null,
|
||||
profitAmount: supplier.profitAmount ?? null,
|
||||
@@ -177,7 +186,9 @@ export function buildInformationPayload(information: InformationFormDraft): Reco
|
||||
return {
|
||||
description: information.description || null,
|
||||
competitors: information.competitors || null,
|
||||
foundedAt: information.foundedAt || null,
|
||||
// Saisie invalide (foundedAtRaw) prioritaire : on l'envoie telle quelle
|
||||
// pour que le back renvoie une 422 sur foundedAt (cf. foundedAtRaw).
|
||||
foundedAt: information.foundedAtRaw || information.foundedAt || null,
|
||||
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
|
||||
revenueAmount: information.revenueAmount || null,
|
||||
profitAmount: information.profitAmount || null,
|
||||
Generated
+4
-4
@@ -7,7 +7,7 @@
|
||||
"name": "starseed-frontend",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.7.8",
|
||||
"@malio/layer-ui": "^1.7.10",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
@@ -1866,9 +1866,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@malio/layer-ui": {
|
||||
"version": "1.7.8",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.8/layer-ui-1.7.8.tgz",
|
||||
"integrity": "sha512-gUMAZzBsPCfQUF3OQSjN/OFzjONvQZYfwqH0u5VUbxaqwBdX1hUGtjD4ym6RvZkyNsKulrxkncFZYTWCS+IdGA==",
|
||||
"version": "1.7.10",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.10/layer-ui-1.7.10.tgz",
|
||||
"integrity": "sha512-ZWYaKvl+VpGAqeTE+4xdyKOmuRd4zwjlUYVppeIBZwGeNAK16kZnrztR+4eQmnzUqPZVybBhEBdKP9weqWHSUg==",
|
||||
"dependencies": {
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.7.8",
|
||||
"@malio/layer-ui": "^1.7.10",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -41,6 +41,22 @@ describe('useFormErrors', () => {
|
||||
expect(hasErrors.value).toBe(true)
|
||||
})
|
||||
|
||||
it('setServerErrors surcharge un message technique (erreur de type) par la cle i18n', () => {
|
||||
const { errors, setServerErrors } = useFormErrors()
|
||||
const mapped = setServerErrors({
|
||||
violations: [
|
||||
// Code Symfony Type::INVALID_TYPE_ERROR (date non parsable) : surcharge.
|
||||
{ propertyPath: 'foundedAt', message: 'Cette valeur doit être de type DateTimeImmutable|null.', code: 'ba785a8c-82cb-4283-967c-3cf342181b40' },
|
||||
// Violation metier classique : message back conserve.
|
||||
{ propertyPath: 'companyName', message: 'Obligatoire.', code: 'c1051bb4-d103-4f74-8988-acbcafc7fdc3' },
|
||||
],
|
||||
})
|
||||
expect(mapped).toBe(true)
|
||||
// Stub i18n -> renvoie la cle telle quelle.
|
||||
expect(errors.foundedAt).toBe('errors.validation.invalidDate')
|
||||
expect(errors.companyName).toBe('Obligatoire.')
|
||||
})
|
||||
|
||||
it('setServerErrors retourne false et ne touche rien sans violation', () => {
|
||||
const { errors, setServerErrors } = useFormErrors()
|
||||
expect(setServerErrors({})).toBe(false)
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* appel par ligne), utiliser directement `mapViolationsToRecord` par ligne.
|
||||
*/
|
||||
import { computed, reactive } from 'vue'
|
||||
import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api'
|
||||
import { extractApiErrorMessage, extractApiViolations, resolveViolationMessage } from '~/shared/utils/api'
|
||||
|
||||
/**
|
||||
* Erreur HTTP capturee par ofetch. On n'expose que les champs lus ici (status
|
||||
@@ -69,13 +69,16 @@ export function useFormErrors() {
|
||||
* violation exploitable).
|
||||
*/
|
||||
function setServerErrors(data: unknown): boolean {
|
||||
const mapped = mapViolationsToRecord(data)
|
||||
const keys = Object.keys(mapped)
|
||||
if (keys.length === 0) return false
|
||||
for (const key of keys) {
|
||||
errors[key] = mapped[key]
|
||||
const violations = extractApiViolations(data)
|
||||
let mapped = false
|
||||
for (const v of violations) {
|
||||
if (!v.propertyPath) continue
|
||||
// Message back tel quel, sauf code surcharge par une cle i18n (ex.
|
||||
// erreur de type sur une date non parsable -> « Date invalide »).
|
||||
errors[v.propertyPath] = resolveViolationMessage(v, t)
|
||||
mapped = true
|
||||
}
|
||||
return true
|
||||
return mapped
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mapViolationsToRecord } from '../api'
|
||||
import { mapViolationsToRecord, resolveViolationMessage } from '../api'
|
||||
|
||||
/**
|
||||
* Tests de `mapViolationsToRecord` — fondation du mapping erreur→champ des
|
||||
@@ -56,3 +56,30 @@ describe('mapViolationsToRecord', () => {
|
||||
expect(mapViolationsToRecord(data)).toEqual({ name: 'Second message.' })
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests de `resolveViolationMessage` — surcharge i18n d'un message back par code
|
||||
* de violation. Le back peut renvoyer un message technique (erreur de type sur
|
||||
* une date non parsable) : on le remplace via le `code` Symfony (stable) par une
|
||||
* cle i18n, sans toucher au back. Le `t` ici renvoie la cle telle quelle.
|
||||
*/
|
||||
describe('resolveViolationMessage', () => {
|
||||
const t = (key: string) => key
|
||||
// Code Symfony Constraints\Type::INVALID_TYPE_ERROR (fige).
|
||||
const TYPE_ERROR = 'ba785a8c-82cb-4283-967c-3cf342181b40'
|
||||
|
||||
it('surcharge le message technique d\'une erreur de type par la cle i18n', () => {
|
||||
const v = { propertyPath: 'foundedAt', message: 'Cette valeur doit être de type DateTimeImmutable|null.', code: TYPE_ERROR }
|
||||
expect(resolveViolationMessage(v, t)).toBe('errors.validation.invalidDate')
|
||||
})
|
||||
|
||||
it('renvoie le message back tel quel quand le code n\'est pas surcharge', () => {
|
||||
const v = { propertyPath: 'companyName', message: 'Le nom est obligatoire.', code: 'c1051bb4-d103-4f74-8988-acbcafc7fdc3' }
|
||||
expect(resolveViolationMessage(v, t)).toBe('Le nom est obligatoire.')
|
||||
})
|
||||
|
||||
it('renvoie le message back tel quel quand il n\'y a pas de code', () => {
|
||||
const v = { propertyPath: 'siren', message: 'SIREN deja utilise.', code: '' }
|
||||
expect(resolveViolationMessage(v, t)).toBe('SIREN deja utilise.')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,11 +34,15 @@ export function extractHydraMembers<T>(collection: HydraCollection<T>): T[] {
|
||||
|
||||
/**
|
||||
* Une violation de contrainte API Platform (reponse 422). Le `propertyPath`
|
||||
* pointe le champ concerne, `message` est le libelle a afficher.
|
||||
* pointe le champ concerne, `message` est le libelle a afficher, `code` est le
|
||||
* code de contrainte Symfony (UUID stable, independant de la langue) — il sert
|
||||
* a surcharger un message back technique par une cle i18n (cf.
|
||||
* `VIOLATION_MESSAGE_I18N` / `resolveViolationMessage`).
|
||||
*/
|
||||
export interface ApiViolation {
|
||||
propertyPath: string
|
||||
message: string
|
||||
code: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,6 +65,7 @@ export function extractApiViolations(data: unknown): ApiViolation[] {
|
||||
out.push({
|
||||
propertyPath: String(obj.propertyPath ?? ''),
|
||||
message: String(obj.message ?? ''),
|
||||
code: String(obj.code ?? ''),
|
||||
})
|
||||
}
|
||||
return out
|
||||
@@ -85,6 +90,45 @@ export function mapViolationsToRecord(data: unknown): Record<string, string> {
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Surcharge i18n d'un message back par CODE de violation.
|
||||
*
|
||||
* La plupart des contraintes back portent deja un message FR explicite (ex.
|
||||
* `#[Assert\NotBlank(message: '...')]`) : on l'affiche tel quel. Mais certaines
|
||||
* 422 portent un message TECHNIQUE non montrable a l'utilisateur — typiquement
|
||||
* l'erreur de TYPE renvoyee par API Platform quand le back ne peut pas
|
||||
* denormaliser la valeur (date non parsable envoyee sur un champ
|
||||
* `DateTimeImmutable` : « Cette valeur doit être de type DateTimeImmutable|null. »,
|
||||
* voire en anglais selon la negociation de langue).
|
||||
*
|
||||
* Plutot que de traduire/maquiller cote back, on surcharge ces messages cote
|
||||
* front via leur `code` de violation. Ce code est un UUID Symfony FIGE (contrat
|
||||
* de compatibilite : il ne change pas entre versions), donc bien plus robuste
|
||||
* qu'un match sur le texte du message (qui depend de la langue). La table
|
||||
* associe un code -> une cle i18n ; `resolveViolationMessage` l'applique.
|
||||
*
|
||||
* Limite a connaitre : le code de type-error est GENERIQUE (toute valeur de
|
||||
* mauvais type). Dans nos formulaires, seul un champ date saisi en texte libre
|
||||
* (MalioDate, qui forwarde la saisie brute invalide) le declenche, d'ou le
|
||||
* libelle « Date invalide ». Si un autre champ typé en saisie libre apparait,
|
||||
* affiner la resolution via `propertyPath` plutot que par code seul.
|
||||
*/
|
||||
export const VIOLATION_MESSAGE_I18N: Record<string, string> = {
|
||||
// Symfony `Constraints\Type::INVALID_TYPE_ERROR` — valeur de mauvais type.
|
||||
'ba785a8c-82cb-4283-967c-3cf342181b40': 'errors.validation.invalidDate',
|
||||
}
|
||||
|
||||
/**
|
||||
* Resout le message a afficher pour une violation : si son `code` est surcharge
|
||||
* par `VIOLATION_MESSAGE_I18N`, renvoie la traduction de la cle associee ;
|
||||
* sinon, le message back tel quel (cas nominal). `t` est passe par l'appelant
|
||||
* (les utils sont purs, sans acces a useI18n).
|
||||
*/
|
||||
export function resolveViolationMessage(v: ApiViolation, t: (key: string) => string): string {
|
||||
const i18nKey = VIOLATION_MESSAGE_I18N[v.code]
|
||||
return i18nKey ? t(i18nKey) : v.message
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait un message d'erreur lisible depuis un payload Hydra / JSON
|
||||
* d'erreur API Platform. Essaie les champs courants dans l'ordre :
|
||||
|
||||
Reference in New Issue
Block a user