feat(catalog) : M6 — écran Ajouter un produit /admin/products/new (ERP-205)
Formulaire principal de création produit (admin-only) : état, sites, nom, code, catégorie (type PRODUIT), types de stockage, booléens conditionnels. - RG-6.03 : « Fabriqué » / « Contient de la mélasse » visibles uniquement si l'état contient « Vendu » - RG-6.06 : cascade Site → Type de stockage (rechargement + purge des types indisponibles) dans useProductForm - RG-6.01 : POST /products (toast:false) ; 422 mappées inline (useFormErrors), 409 doublon de code → setError + toast - bouton « Valider » toujours actif, validation autoritaire serveur (ERP-101) - composables useSiteOptions / useCategoryOptions / useStorageTypeOptions (?pagination=false) - i18n admin.products.form ; 15 tests Vitest (useProductForm + page)
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { useFormErrors } from '~/shared/composables/useFormErrors'
|
||||
import { useProductForm } from '../useProductForm'
|
||||
|
||||
// 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,
|
||||
}))
|
||||
|
||||
/** Reponse Hydra des types de stockage selon les sites demandes. */
|
||||
function storageMembersForSites(siteIds: string[]): { member: Array<{ '@id': string, label: string }> } {
|
||||
// Site 1 → types 9 et 5 ; site 2 → type 7. Permet de tester la cascade.
|
||||
const byId: Record<string, Array<{ '@id': string, label: string }>> = {
|
||||
'1': [
|
||||
{ '@id': '/api/storage_types/9', label: 'Tas' },
|
||||
{ '@id': '/api/storage_types/5', label: 'Cellule' },
|
||||
],
|
||||
'2': [{ '@id': '/api/storage_types/7', label: 'Cuve mélasse' }],
|
||||
}
|
||||
const seen = new Map<string, { '@id': string, label: string }>()
|
||||
for (const id of siteIds) {
|
||||
for (const m of byId[id] ?? []) {
|
||||
seen.set(m['@id'], m)
|
||||
}
|
||||
}
|
||||
return { member: [...seen.values()] }
|
||||
}
|
||||
|
||||
describe('useProductForm', () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset()
|
||||
mockPost.mockReset()
|
||||
mockToastSuccess.mockReset()
|
||||
mockToastError.mockReset()
|
||||
|
||||
// Routage des GET par url (referentiels + cascade stockage).
|
||||
mockGet.mockImplementation((url: string, query: Record<string, unknown> = {}) => {
|
||||
if (url === '/sites') {
|
||||
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault' }] })
|
||||
}
|
||||
if (url === '/categories') {
|
||||
return Promise.resolve({ member: [{ '@id': '/api/categories/12', name: 'Céréales' }] })
|
||||
}
|
||||
if (url === '/storage_types') {
|
||||
const raw = (query['siteId[]'] ?? []) as string[]
|
||||
return Promise.resolve(storageMembersForSites(raw))
|
||||
}
|
||||
return Promise.resolve({ member: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('RG-6.03 — champs conditionnels « Vendu »', () => {
|
||||
it('isSale est vrai uniquement si states contient SALE', () => {
|
||||
const { form, isSale } = useProductForm()
|
||||
expect(isSale.value).toBe(false)
|
||||
form.states = ['PURCHASE']
|
||||
expect(isSale.value).toBe(false)
|
||||
form.states = ['PURCHASE', 'SALE']
|
||||
expect(isSale.value).toBe(true)
|
||||
})
|
||||
|
||||
it('remet manufactured / containsMolasses a false quand SALE est retire', async () => {
|
||||
const { form, isSale } = useProductForm()
|
||||
form.states = ['SALE']
|
||||
form.manufactured = true
|
||||
form.containsMolasses = true
|
||||
await nextTick()
|
||||
expect(isSale.value).toBe(true)
|
||||
|
||||
form.states = ['PURCHASE']
|
||||
await nextTick()
|
||||
expect(form.manufactured).toBe(false)
|
||||
expect(form.containsMolasses).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('RG-6.06 — cascade Site → Type de stockage', () => {
|
||||
it('charge les types de stockage filtres par les sites selectionnes', async () => {
|
||||
const { storageTypeOptions, setSites } = useProductForm()
|
||||
await setSites(['/api/sites/1'])
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'/storage_types',
|
||||
expect.objectContaining({ 'siteId[]': ['1'], pagination: 'false' }),
|
||||
expect.any(Object),
|
||||
)
|
||||
expect(storageTypeOptions.value.map(o => o.value)).toEqual([
|
||||
'/api/storage_types/9',
|
||||
'/api/storage_types/5',
|
||||
])
|
||||
})
|
||||
|
||||
it('retire de la selection les types devenus indisponibles', async () => {
|
||||
const { form, setStorageTypes, setSites } = useProductForm()
|
||||
|
||||
// Selection initiale sur le site 1 (types 9 et 5).
|
||||
await setSites(['/api/sites/1'])
|
||||
setStorageTypes(['/api/storage_types/9', '/api/storage_types/5'])
|
||||
|
||||
// Bascule vers le site 2 (type 7 seul) : 9 et 5 ne sont plus dispo.
|
||||
await setSites(['/api/sites/2'])
|
||||
expect(form.storageTypeIris).toEqual([])
|
||||
})
|
||||
|
||||
it('vide options + selection quand plus aucun site n\'est selectionne', async () => {
|
||||
const { form, storageTypeOptions, setStorageTypes, setSites } = useProductForm()
|
||||
await setSites(['/api/sites/1'])
|
||||
setStorageTypes(['/api/storage_types/9'])
|
||||
|
||||
await setSites([])
|
||||
expect(storageTypeOptions.value).toEqual([])
|
||||
expect(form.storageTypeIris).toEqual([])
|
||||
// Pas d'appel /storage_types inutile sans site.
|
||||
expect(mockGet).not.toHaveBeenCalledWith('/storage_types', expect.objectContaining({ 'siteId[]': [] }), expect.any(Object))
|
||||
})
|
||||
})
|
||||
|
||||
describe('submit — POST /products', () => {
|
||||
function fillValidForm(form: ReturnType<typeof useProductForm>['form']): void {
|
||||
form.code = 'ble-01'
|
||||
form.name = 'Blé tendre'
|
||||
form.states = ['PURCHASE', 'SALE']
|
||||
form.siteIris = ['/api/sites/1']
|
||||
form.categoryIri = '/api/categories/12'
|
||||
form.storageTypeIris = ['/api/storage_types/9']
|
||||
form.manufactured = true
|
||||
form.containsMolasses = false
|
||||
}
|
||||
|
||||
it('poste le payload (relations en IRI) et retourne true au succes', async () => {
|
||||
mockPost.mockResolvedValueOnce({ id: 34 })
|
||||
const { form, submit } = useProductForm()
|
||||
fillValidForm(form)
|
||||
|
||||
const ok = await submit()
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
'/products',
|
||||
{
|
||||
code: 'ble-01',
|
||||
name: 'Blé tendre',
|
||||
states: ['PURCHASE', 'SALE'],
|
||||
manufactured: true,
|
||||
containsMolasses: false,
|
||||
category: '/api/categories/12',
|
||||
sites: ['/api/sites/1'],
|
||||
storageTypes: ['/api/storage_types/9'],
|
||||
},
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
expect(mockToastSuccess).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('force manufactured / containsMolasses a false hors « Vendu » (RG-6.03)', async () => {
|
||||
mockPost.mockResolvedValueOnce({ id: 35 })
|
||||
const { form, submit } = useProductForm()
|
||||
fillValidForm(form)
|
||||
// L'utilisateur retire « Vendu » apres avoir coche les booleens.
|
||||
form.states = ['PURCHASE']
|
||||
|
||||
await submit()
|
||||
|
||||
const payload = mockPost.mock.calls[0][1]
|
||||
expect(payload.manufactured).toBe(false)
|
||||
expect(payload.containsMolasses).toBe(false)
|
||||
})
|
||||
|
||||
it('mappe un 409 doublon de code sur errors.code + toast explicite', async () => {
|
||||
mockPost.mockRejectedValueOnce({ response: { status: 409, _data: {} } })
|
||||
const { form, errors, submit } = useProductForm()
|
||||
fillValidForm(form)
|
||||
|
||||
const ok = await submit()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(errors.code).toBe('admin.products.form.duplicateCode')
|
||||
expect(mockToastError).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mappe une 422 inline par champ (errors.code) sans toast', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: { violations: [{ propertyPath: 'code', message: 'Le code produit est obligatoire.' }] },
|
||||
},
|
||||
})
|
||||
const { form, errors, submit } = useProductForm()
|
||||
fillValidForm(form)
|
||||
form.code = null
|
||||
|
||||
const ok = await submit()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(errors.code).toBe('Le code produit est obligatoire.')
|
||||
expect(mockToastError).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Composable du formulaire de creation produit (M6 — ERP-205).
|
||||
*
|
||||
* Porte l'etat du formulaire principal, les referentiels des selects, les regles
|
||||
* de gestion front (champs conditionnels RG-6.03, cascade site→stockage RG-6.06)
|
||||
* et la soumission `POST /api/products` avec mapping des erreurs 422/409 inline
|
||||
* (useFormErrors). Reference : ecran « Ajouter un client » / « Ajouter un
|
||||
* prestataire » (formulaire principal).
|
||||
*
|
||||
* Etat 100 % local a l'instance.
|
||||
*/
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import {
|
||||
useSiteOptions,
|
||||
useCategoryOptions,
|
||||
useStorageTypeOptions,
|
||||
} from '~/modules/catalog/composables/useProductOptions'
|
||||
|
||||
/** Etats produit (miroir de l'enum back Product::STATE_*). */
|
||||
export const PRODUCT_STATES = ['PURCHASE', 'SALE', 'OTHER'] as const
|
||||
|
||||
/** Extrait l'id numerique d'un IRI Hydra (`/api/sites/1` → 1), sinon null. */
|
||||
function iriToId(iri: string): number | null {
|
||||
const tail = iri.split('/').pop()
|
||||
return tail !== undefined && /^\d+$/.test(tail) ? Number(tail) : null
|
||||
}
|
||||
|
||||
export function useProductForm() {
|
||||
const api = useApi()
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const formErrors = useFormErrors()
|
||||
|
||||
const sites = useSiteOptions()
|
||||
const categories = useCategoryOptions({ typeCode: 'PRODUIT' })
|
||||
const storage = useStorageTypeOptions()
|
||||
|
||||
// ── Etat du formulaire ───────────────────────────────────────────────────
|
||||
// Les relations sont stockees en IRI (envoyees telles quelles au POST) ;
|
||||
// `states` porte les codes enum ; les booleens conditionnels RG-6.03 a part.
|
||||
const form = reactive({
|
||||
code: null as string | null,
|
||||
name: null as string | null,
|
||||
states: [] as string[],
|
||||
siteIris: [] as string[],
|
||||
categoryIri: null as string | null,
|
||||
storageTypeIris: [] as string[],
|
||||
manufactured: false,
|
||||
containsMolasses: false,
|
||||
})
|
||||
|
||||
const submitting = ref(false)
|
||||
|
||||
// RG-6.03 : « Fabriqué » / « Contient de la mélasse » saisissables uniquement
|
||||
// si l'etat contient « Vendu » (SALE).
|
||||
const isSale = computed(() => form.states.includes('SALE'))
|
||||
|
||||
// Quand l'etat ne contient plus SALE, on remet les booleens a false : le back
|
||||
// les forcerait de toute facon (RG-6.03), on evite de soumettre une valeur
|
||||
// fantome saisie avant de retirer « Vendu ».
|
||||
watch(isSale, (sale) => {
|
||||
if (!sale) {
|
||||
form.manufactured = false
|
||||
form.containsMolasses = false
|
||||
}
|
||||
})
|
||||
|
||||
/** Met a jour les etats (multi-select). */
|
||||
function setStates(states: string[]): void {
|
||||
form.states = states
|
||||
}
|
||||
|
||||
/** Met a jour la categorie (select simple). */
|
||||
function setCategory(iri: string | null): void {
|
||||
form.categoryIri = iri
|
||||
}
|
||||
|
||||
/** Met a jour les types de stockage (multi-select). */
|
||||
function setStorageTypes(iris: string[]): void {
|
||||
form.storageTypeIris = iris
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-6.06 (cascade) : a chaque changement de Site, recharge les options de Type
|
||||
* de stockage filtrees par les sites choisis et retire de la selection les
|
||||
* types devenus indisponibles.
|
||||
*/
|
||||
async function setSites(iris: string[]): Promise<void> {
|
||||
form.siteIris = iris
|
||||
const siteIds = iris
|
||||
.map(iriToId)
|
||||
.filter((id): id is number => id !== null)
|
||||
|
||||
await storage.load(siteIds)
|
||||
|
||||
const available = new Set(storage.options.value.map(o => o.value))
|
||||
form.storageTypeIris = form.storageTypeIris.filter(iri => available.has(iri))
|
||||
}
|
||||
|
||||
/** Charge les referentiels initiaux (sites + categories). Resilient. */
|
||||
async function loadReferentials(): Promise<void> {
|
||||
await Promise.allSettled([sites.load(), categories.load()])
|
||||
// Les types de stockage se chargent a la 1re selection de sites (cascade).
|
||||
}
|
||||
|
||||
/**
|
||||
* Soumet la creation. Retourne true au succes (la page redirige), false sinon.
|
||||
* 422 → mapping inline par champ (useFormErrors) ; 409 doublon de code →
|
||||
* erreur inline sur `code` + toast explicite (RG-6.01).
|
||||
*/
|
||||
async function submit(): Promise<boolean> {
|
||||
if (submitting.value) {
|
||||
return false
|
||||
}
|
||||
submitting.value = true
|
||||
formErrors.clearErrors()
|
||||
try {
|
||||
const payload = {
|
||||
code: form.code || null,
|
||||
name: form.name || null,
|
||||
states: form.states,
|
||||
// RG-6.03 : booleens forces a false hors « Vendu » (le back les
|
||||
// re-force, on garde le payload coherent).
|
||||
manufactured: isSale.value ? form.manufactured : false,
|
||||
containsMolasses: isSale.value ? form.containsMolasses : false,
|
||||
category: form.categoryIri,
|
||||
sites: form.siteIris,
|
||||
storageTypes: form.storageTypeIris,
|
||||
}
|
||||
await api.post('/products', payload, {
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
toast: false,
|
||||
})
|
||||
toast.success({ title: t('admin.products.toast.createSuccess') })
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
const status = (error as { response?: { status?: number } })?.response?.status
|
||||
if (status === 409) {
|
||||
// Doublon de code (RG-6.01) : inline sur le champ + toast explicite.
|
||||
const message = t('admin.products.form.duplicateCode')
|
||||
formErrors.setError('code', message)
|
||||
toast.error({ title: t('admin.products.toast.error'), message })
|
||||
}
|
||||
else {
|
||||
formErrors.handleApiError(error, { fallbackMessage: t('admin.products.toast.error') })
|
||||
}
|
||||
return false
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
form,
|
||||
errors: formErrors.errors,
|
||||
submitting,
|
||||
isSale,
|
||||
siteOptions: sites.options,
|
||||
categoryOptions: categories.options,
|
||||
storageTypeOptions: storage.options,
|
||||
setStates,
|
||||
setCategory,
|
||||
setStorageTypes,
|
||||
setSites,
|
||||
loadReferentials,
|
||||
submit,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Composables d'options des selects du formulaire produit (M6 — ERP-205).
|
||||
*
|
||||
* Chaque referentiel est borne (quelques dizaines d'entrees) : on le recupere en
|
||||
* entier via l'echappatoire `?pagination=false`, avec l'en-tete
|
||||
* `Accept: application/ld+json` impose par API Platform 4 pour obtenir l'enveloppe
|
||||
* Hydra (`member`). La valeur d'option est l'IRI Hydra (`@id`), renvoyee telle
|
||||
* quelle dans le payload POST (relations ManyToOne / ManyToMany).
|
||||
*
|
||||
* Etat 100 % local a l'instance (refs) — aucune persistance URL. Chaque appel cree
|
||||
* sa propre instance ; le formulaire en consomme une via `useProductForm`.
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
|
||||
/** Option generique au format attendu par MalioSelect / MalioSelectCheckbox. */
|
||||
export interface RefOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/** Membre Hydra minimal commun aux referentiels consommes ici. */
|
||||
interface HydraMember {
|
||||
'@id': string
|
||||
name?: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
|
||||
|
||||
/**
|
||||
* Recupere une collection complete (pagination desactivee) et la projette en
|
||||
* options `{ value: IRI, label }`. Resilient : l'appelant gere l'echec (liste vide).
|
||||
*/
|
||||
async function fetchOptions(
|
||||
url: string,
|
||||
query: Record<string, string | string[]>,
|
||||
toLabel: (member: HydraMember) => string,
|
||||
): Promise<RefOption[]> {
|
||||
const res = await useApi().get<{ member?: HydraMember[] }>(
|
||||
url,
|
||||
{ pagination: 'false', ...query },
|
||||
{ headers: LD_JSON_HEADERS, toast: false },
|
||||
)
|
||||
return (res.member ?? []).map(m => ({ value: m['@id'], label: toLabel(m) }))
|
||||
}
|
||||
|
||||
/** Sites de disponibilite (libelle = nom du site). */
|
||||
export function useSiteOptions() {
|
||||
const options = ref<RefOption[]>([])
|
||||
|
||||
async function load(): Promise<void> {
|
||||
options.value = await fetchOptions('/sites', {}, s => s.name ?? '')
|
||||
}
|
||||
|
||||
return { options, load }
|
||||
}
|
||||
|
||||
/**
|
||||
* Categories produit. Filtrees au type voulu (`?typeCode=PRODUIT` pour le produit,
|
||||
* RG-6.05) cote serveur — le provider Category supporte deja `typeCode`.
|
||||
*/
|
||||
export function useCategoryOptions(params: { typeCode: string }) {
|
||||
const options = ref<RefOption[]>([])
|
||||
|
||||
async function load(): Promise<void> {
|
||||
options.value = await fetchOptions('/categories', { typeCode: params.typeCode }, c => c.name ?? '')
|
||||
}
|
||||
|
||||
return { options, load }
|
||||
}
|
||||
|
||||
/**
|
||||
* Types de stockage (libelle = `label`). Filtres par les sites selectionnes
|
||||
* (`?siteId[]=…`, RG-6.06) : on ne charge que les types disponibles sur AU MOINS
|
||||
* UN des sites passes. Sans site, la liste est videe (le multi-select depend des
|
||||
* sites).
|
||||
*/
|
||||
export function useStorageTypeOptions() {
|
||||
const options = ref<RefOption[]>([])
|
||||
|
||||
async function load(siteIds: number[]): Promise<void> {
|
||||
if (siteIds.length === 0) {
|
||||
options.value = []
|
||||
return
|
||||
}
|
||||
options.value = await fetchOptions(
|
||||
'/storage_types',
|
||||
{ 'siteId[]': siteIds.map(String) },
|
||||
s => s.label ?? '',
|
||||
)
|
||||
}
|
||||
|
||||
return { options, load }
|
||||
}
|
||||
Reference in New Issue
Block a user