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:
2026-06-30 11:27:57 +02:00
parent fd6b7e4c79
commit aa7cda48b3
4 changed files with 469 additions and 1 deletions
@@ -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,
}
}