feat(catalog) : M7 — écran Ajouter un stockage /admin/storages/new (ERP-217)
- Formulaire de création à plat (pas d'onglets, HP-M7-06), gate catalog.storages.manage - Champs Site, Type de stockage, Numéro, État (multi ≥1) en composants Malio, validation inline 422 par champ via useFormErrors - 409 doublon (site, type, numéro) RG-7.01 → erreur inline sous Numéro + toast explicite - Composable useStorageForm (POST /storages, payload relations en IRI), libellés i18n - Référentiel des types PLAT : pas de cascade Site→Type (RG-7.03 non portée côté back, StorageType sans relation Site — à reclarifier spec) - Tests Vitest de useStorageForm (référentiel plat, submit, 409/422)
This commit is contained in:
@@ -1117,9 +1117,20 @@
|
||||
"apply": "Voir les résultats",
|
||||
"reset": "Réinitialiser"
|
||||
},
|
||||
"form": {
|
||||
"title": "Ajouter un stockage",
|
||||
"back": "Retour à la liste",
|
||||
"submit": "Valider",
|
||||
"site": "Site",
|
||||
"storageType": "Type de stockage",
|
||||
"numero": "Numéro",
|
||||
"states": "État du type de stockage",
|
||||
"duplicateNumero": "Un stockage avec ce site, ce type et ce numéro existe déjà."
|
||||
},
|
||||
"toast": {
|
||||
"error": "Une erreur est survenue. Réessayez.",
|
||||
"exportError": "L'export du répertoire stockage a échoué. Réessayez."
|
||||
"exportError": "L'export du répertoire stockage a échoué. Réessayez.",
|
||||
"createSuccess": "Stockage créé avec succès"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { useFormErrors } from '~/shared/composables/useFormErrors'
|
||||
import { useStorageForm } from '../useStorageForm'
|
||||
|
||||
// Stubs des auto-imports Nuxt consommes par le composable + ses dependances.
|
||||
const mockGet = vi.hoisted(() => vi.fn())
|
||||
const mockPost = vi.hoisted(() => vi.fn())
|
||||
const mockToastSuccess = vi.hoisted(() => vi.fn())
|
||||
const mockToastError = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.stubGlobal('useApi', () => ({
|
||||
get: mockGet,
|
||||
post: mockPost,
|
||||
put: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
}))
|
||||
vi.stubGlobal('useToast', () => ({
|
||||
success: mockToastSuccess,
|
||||
error: mockToastError,
|
||||
}))
|
||||
// useFormErrors est un auto-import Nuxt : on expose l'implementation reelle
|
||||
// (elle consomme useToast/useI18n deja stubbes) pour tester l'integration 422/409.
|
||||
vi.stubGlobal('useFormErrors', useFormErrors)
|
||||
vi.stubGlobal('useI18n', () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) =>
|
||||
params ? `${key}::${JSON.stringify(params)}` : key,
|
||||
}))
|
||||
|
||||
/** Referentiel PLAT des types de stockage (renvoye tel quel, sans filtre site). */
|
||||
const STORAGE_TYPES = {
|
||||
member: [
|
||||
{ '@id': '/api/storage_types/9', label: 'Cellule' },
|
||||
{ '@id': '/api/storage_types/5', label: 'Tas' },
|
||||
],
|
||||
}
|
||||
|
||||
describe('useStorageForm', () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset()
|
||||
mockPost.mockReset()
|
||||
mockToastSuccess.mockReset()
|
||||
mockToastError.mockReset()
|
||||
|
||||
// Routage des GET par url (referentiels). Le type de stockage est un
|
||||
// referentiel plat : meme reponse quelle que soit la requete.
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/sites') {
|
||||
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault' }] })
|
||||
}
|
||||
if (url === '/storage_types') {
|
||||
return Promise.resolve(STORAGE_TYPES)
|
||||
}
|
||||
return Promise.resolve({ member: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('referentiel plat — pas de cascade Site->Type (RG-7.03 non portee back)', () => {
|
||||
it('loadReferentials charge les sites et TOUS les types, sans filtre site', async () => {
|
||||
const { siteOptions, storageTypeOptions, loadReferentials } = useStorageForm()
|
||||
await loadReferentials()
|
||||
|
||||
const storageCall = mockGet.mock.calls.find(c => c[0] === '/storage_types')
|
||||
expect(storageCall).toBeDefined()
|
||||
// Aucun filtre siteId envoye (referentiel plat).
|
||||
expect(storageCall?.[1]).not.toHaveProperty('siteId[]')
|
||||
|
||||
expect(siteOptions.value.map(o => o.value)).toEqual(['/api/sites/1'])
|
||||
expect(storageTypeOptions.value.map(o => o.value)).toEqual([
|
||||
'/api/storage_types/9',
|
||||
'/api/storage_types/5',
|
||||
])
|
||||
})
|
||||
|
||||
it('changer de site ne recharge pas les types ni ne purge la selection', async () => {
|
||||
const { form, setSite, setStorageType, loadReferentials } = useStorageForm()
|
||||
await loadReferentials()
|
||||
const callsBefore = mockGet.mock.calls.filter(c => c[0] === '/storage_types').length
|
||||
|
||||
setStorageType('/api/storage_types/9')
|
||||
setSite('/api/sites/1')
|
||||
|
||||
expect(form.siteIri).toBe('/api/sites/1')
|
||||
// Selection conservee : pas de cascade ni de purge par site.
|
||||
expect(form.storageTypeIri).toBe('/api/storage_types/9')
|
||||
const callsAfter = mockGet.mock.calls.filter(c => c[0] === '/storage_types').length
|
||||
expect(callsAfter).toBe(callsBefore)
|
||||
})
|
||||
})
|
||||
|
||||
describe('submit — POST /storages', () => {
|
||||
function fillValidForm(form: ReturnType<typeof useStorageForm>['form']): void {
|
||||
form.siteIri = '/api/sites/1'
|
||||
form.storageTypeIri = '/api/storage_types/9'
|
||||
form.numero = '12'
|
||||
form.states = ['RECEPTION', 'PRODUCTION']
|
||||
}
|
||||
|
||||
it('poste le payload (relations en IRI) et retourne true au succes', async () => {
|
||||
mockPost.mockResolvedValueOnce({ id: 42 })
|
||||
const { form, submit } = useStorageForm()
|
||||
fillValidForm(form)
|
||||
|
||||
const ok = await submit()
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
'/storages',
|
||||
{
|
||||
numero: '12',
|
||||
states: ['RECEPTION', 'PRODUCTION'],
|
||||
site: '/api/sites/1',
|
||||
storageType: '/api/storage_types/9',
|
||||
},
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
expect(mockToastSuccess).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('omet `site` / `storageType` du payload quand la relation n\'est pas choisie', async () => {
|
||||
// Envoyer null casserait la denormalisation back (IRI attendu) et
|
||||
// court-circuiterait les autres violations -> on omet la cle.
|
||||
mockPost.mockResolvedValueOnce({ id: 43 })
|
||||
const { form, submit } = useStorageForm()
|
||||
fillValidForm(form)
|
||||
form.siteIri = null
|
||||
form.storageTypeIri = null
|
||||
|
||||
await submit()
|
||||
|
||||
const payload = mockPost.mock.calls[0][1]
|
||||
expect(payload).not.toHaveProperty('site')
|
||||
expect(payload).not.toHaveProperty('storageType')
|
||||
// numero envoye en chaine vide si non saisi (NotBlank cote back).
|
||||
expect(payload).toHaveProperty('numero')
|
||||
})
|
||||
|
||||
it('mappe un 409 doublon (site, type, numero) sur errors.numero + toast explicite', async () => {
|
||||
mockPost.mockRejectedValueOnce({ response: { status: 409, _data: {} } })
|
||||
const { form, errors, submit } = useStorageForm()
|
||||
fillValidForm(form)
|
||||
|
||||
const ok = await submit()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(errors.numero).toBe('admin.storages.form.duplicateNumero')
|
||||
expect(mockToastError).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mappe une 422 inline par champ (errors.numero) sans toast', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: { violations: [{ propertyPath: 'numero', message: 'Le numéro du stockage est obligatoire.' }] },
|
||||
},
|
||||
})
|
||||
const { form, errors, submit } = useStorageForm()
|
||||
fillValidForm(form)
|
||||
form.numero = null
|
||||
|
||||
const ok = await submit()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(errors.numero).toBe('Le numéro du stockage est obligatoire.')
|
||||
expect(mockToastError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mappe une 422 sur site / storageType / states (NotNull / Count)', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: { violations: [
|
||||
{ propertyPath: 'site', message: 'Le site est obligatoire.' },
|
||||
{ propertyPath: 'storageType', message: 'Le type de stockage est obligatoire.' },
|
||||
{ propertyPath: 'states', message: 'Sélectionnez au moins un état.' },
|
||||
] },
|
||||
},
|
||||
})
|
||||
const { form, errors, submit } = useStorageForm()
|
||||
fillValidForm(form)
|
||||
|
||||
await submit()
|
||||
|
||||
expect(errors.site).toBe('Le site est obligatoire.')
|
||||
expect(errors.storageType).toBe('Le type de stockage est obligatoire.')
|
||||
expect(errors.states).toBe('Sélectionnez au moins un état.')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Composable du formulaire de creation d'un stockage (M7 — ERP-217).
|
||||
*
|
||||
* Porte l'etat du formulaire principal (a plat, PAS d'onglets — HP-M7-06), les
|
||||
* referentiels des selects et la soumission `POST /api/storages` avec mapping des
|
||||
* erreurs 422/409 inline (useFormErrors, ERP-101). Reference : ecran « Ajouter un
|
||||
* produit » (M6) / ecran Client.
|
||||
*
|
||||
* Referentiel des types de stockage : PLAT (RG-6.06 / decision back). Le concept
|
||||
* type<->site a ete retire en M6 (jointure storage_type_site droppee, migration
|
||||
* Version20260626100000) et `StorageType` n'a plus de relation Site ; le provider
|
||||
* ignore tout filtre `?siteId[]`. La cascade Site->Type de RG-7.03 n'est donc PAS
|
||||
* portee (decision produit du 30/06 : referentiel plat, fidele au back ; RG-7.03 a
|
||||
* reclarifier cote spec). On charge donc TOUS les types une fois, Site et Type
|
||||
* independants.
|
||||
*
|
||||
* Etat 100 % local a l'instance.
|
||||
*/
|
||||
import { reactive, ref } from 'vue'
|
||||
import {
|
||||
useSiteOptions,
|
||||
useStorageTypeOptions,
|
||||
} from '~/modules/catalog/composables/useProductOptions'
|
||||
|
||||
/** Etats d'un stockage (miroir de l'enum back Storage::STATE_*, RG-7.04). */
|
||||
export const STORAGE_STATES = ['RECEPTION', 'PRODUCTION', 'TRIAGE'] as const
|
||||
|
||||
export function useStorageForm() {
|
||||
const api = useApi()
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const formErrors = useFormErrors()
|
||||
|
||||
const sites = useSiteOptions()
|
||||
const storageTypes = useStorageTypeOptions()
|
||||
|
||||
// ── Etat du formulaire ───────────────────────────────────────────────────
|
||||
// Les relations (site, storageType) sont stockees en IRI (envoyees telles
|
||||
// quelles au POST) ; `states` porte les codes enum.
|
||||
const form = reactive({
|
||||
siteIri: null as string | null,
|
||||
storageTypeIri: null as string | null,
|
||||
numero: null as string | null,
|
||||
states: [] as string[],
|
||||
})
|
||||
|
||||
const submitting = ref(false)
|
||||
|
||||
/** Met a jour le site (select simple, RG-7.02). */
|
||||
function setSite(iri: string | null): void {
|
||||
form.siteIri = iri
|
||||
}
|
||||
|
||||
/** Met a jour le type de stockage (select simple, referentiel plat). */
|
||||
function setStorageType(iri: string | null): void {
|
||||
form.storageTypeIri = iri
|
||||
}
|
||||
|
||||
/** Met a jour les etats (multi-select, >= 1, RG-7.04). */
|
||||
function setStates(states: string[]): void {
|
||||
form.states = states
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge les referentiels (sites + TOUS les types de stockage). Resilient :
|
||||
* un referentiel en echec reste vide sans casser l'autre. Pas de cascade par
|
||||
* site (referentiel plat, cf. docblock).
|
||||
*/
|
||||
async function loadReferentials(): Promise<void> {
|
||||
await Promise.allSettled([sites.load(), storageTypes.load()])
|
||||
}
|
||||
|
||||
/**
|
||||
* Soumet la creation. Retourne true au succes (la page redirige), false sinon.
|
||||
* 422 → mapping inline par champ (useFormErrors, `{ toast: false }`) ; 409
|
||||
* doublon du triplet (site, type, numero, RG-7.01) → erreur inline sur `numero`
|
||||
* (propertyPath exploitable cote back) + toast explicite.
|
||||
*/
|
||||
async function submit(): Promise<boolean> {
|
||||
if (submitting.value) {
|
||||
return false
|
||||
}
|
||||
submitting.value = true
|
||||
formErrors.clearErrors()
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
// Chaine vide (jamais null) : le setter back setNumero attend un
|
||||
// `string` non-nullable -> envoyer null leverait une erreur de type
|
||||
// (denormalisation) qui court-circuiterait les autres violations.
|
||||
// Avec '', la contrainte NotBlank renvoie un message propre par champ.
|
||||
numero: form.numero ?? '',
|
||||
states: form.states,
|
||||
}
|
||||
// `site` / `storageType` attendent un IRI (string) : envoyer null
|
||||
// declencherait une erreur de denormalisation API Platform qui
|
||||
// court-circuiterait TOUTES les autres violations. On omet la cle quand
|
||||
// la relation n'est pas choisie -> la contrainte NotNull renvoie un
|
||||
// message propre, et les autres champs sont valides dans la meme 422.
|
||||
if (form.siteIri) {
|
||||
payload.site = form.siteIri
|
||||
}
|
||||
if (form.storageTypeIri) {
|
||||
payload.storageType = form.storageTypeIri
|
||||
}
|
||||
|
||||
const options = { headers: { Accept: 'application/ld+json' }, toast: false }
|
||||
await api.post('/storages', payload, options)
|
||||
toast.success({ title: t('admin.storages.toast.createSuccess') })
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
const status = (error as { response?: { status?: number } })?.response?.status
|
||||
if (status === 409) {
|
||||
// Doublon (site, type, numero) RG-7.01 : inline sur `numero` + toast.
|
||||
const message = t('admin.storages.form.duplicateNumero')
|
||||
formErrors.setError('numero', message)
|
||||
toast.error({ title: t('admin.storages.toast.error'), message })
|
||||
}
|
||||
else {
|
||||
formErrors.handleApiError(error, { fallbackMessage: t('admin.storages.toast.error') })
|
||||
}
|
||||
return false
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
form,
|
||||
errors: formErrors.errors,
|
||||
submitting,
|
||||
siteOptions: sites.options,
|
||||
storageTypeOptions: storageTypes.options,
|
||||
setSite,
|
||||
setStorageType,
|
||||
setStates,
|
||||
loadReferentials,
|
||||
submit,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tete : retour vers la liste + titre. -->
|
||||
<div class="flex items-center gap-3 pt-11">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
:title="t('admin.storages.form.back')"
|
||||
v-bind="{ ariaLabel: t('admin.storages.form.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('admin.storages.form.title') }}</h1>
|
||||
</div>
|
||||
|
||||
<!-- ── Formulaire principal de creation (a plat, PAS d'onglets — HP-M7-06)
|
||||
Bouton « Valider » TOUJOURS actif (ERP-101) : la validation
|
||||
autoritaire est serveur, les erreurs 422 reviennent inline. -->
|
||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<!-- Site : select simple obligatoire (RG-7.02). -->
|
||||
<MalioSelect
|
||||
:model-value="form.siteIri"
|
||||
:options="siteOptions"
|
||||
:label="t('admin.storages.form.site')"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="errors.site"
|
||||
@update:model-value="(v: string | number | null) => setSite(v === null || v === '' ? null : String(v))"
|
||||
/>
|
||||
<!-- Type de stockage : select simple obligatoire. Referentiel plat :
|
||||
tous les types (pas de cascade par site, RG-7.03 non portee back). -->
|
||||
<MalioSelect
|
||||
:model-value="form.storageTypeIri"
|
||||
:options="storageTypeOptions"
|
||||
:label="t('admin.storages.form.storageType')"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="errors.storageType"
|
||||
@update:model-value="(v: string | number | null) => setStorageType(v === null || v === '' ? null : String(v))"
|
||||
/>
|
||||
<!-- Numero : texte libre obligatoire (RG-7.01, normalise trim cote serveur). -->
|
||||
<MalioInputText
|
||||
v-model="form.numero"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
:label="t('admin.storages.form.numero')"
|
||||
:required="true"
|
||||
:error="errors.numero"
|
||||
/>
|
||||
<!-- Etat du type de stockage : multi-select obligatoire (>= 1, RG-7.04). -->
|
||||
<MalioSelectCheckbox
|
||||
:model-value="form.states"
|
||||
:options="stateOptions"
|
||||
:max-tags="3"
|
||||
:label="t('admin.storages.form.states')"
|
||||
:display-tag="true"
|
||||
:required="true"
|
||||
:error="errors.states"
|
||||
@update:model-value="(v: (string | number)[]) => setStates(v.map(String))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 flex justify-center">
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('admin.storages.form.submit')"
|
||||
:disabled="submitting"
|
||||
@click="onSubmit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Onglets de la maquette (Clients / Règles / Etiquette / Comptabilité) :
|
||||
HORS perimetre HP-M7-06 — aucune barre d'onglets a l'ajout. -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useStorageForm, STORAGE_STATES } from '~/modules/catalog/composables/useStorageForm'
|
||||
import { FREE_TEXT_MASK } from '~/shared/utils/textSanitize'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const { can } = usePermissions()
|
||||
|
||||
useHead({ title: t('admin.storages.form.title') })
|
||||
|
||||
// Gating de la route : la creation est reservee a `manage` (repertoire admin-only).
|
||||
if (!can('catalog.storages.manage')) {
|
||||
await navigateTo('/admin/storages')
|
||||
}
|
||||
|
||||
const {
|
||||
form,
|
||||
errors,
|
||||
submitting,
|
||||
siteOptions,
|
||||
storageTypeOptions,
|
||||
setSite,
|
||||
setStorageType,
|
||||
setStates,
|
||||
loadReferentials,
|
||||
submit,
|
||||
} = useStorageForm()
|
||||
|
||||
// Options de l'etat : libelles i18n (la valeur d'option = code enum).
|
||||
const stateOptions = computed(() =>
|
||||
STORAGE_STATES.map(code => ({ value: code, label: t(`admin.storages.state.${code}`) })),
|
||||
)
|
||||
|
||||
/** Retour vers la liste des stockages (fleche d'en-tete). */
|
||||
function goBack(): void {
|
||||
router.push('/admin/storages')
|
||||
}
|
||||
|
||||
/** Soumet la creation ; au succes, retour a la liste. */
|
||||
async function onSubmit(): Promise<void> {
|
||||
const ok = await submit()
|
||||
if (ok) {
|
||||
router.push('/admin/storages')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Echec du chargement des referentiels non bloquant : les selects restent vides.
|
||||
loadReferentials().catch(() => {})
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user