feat(catalog) : M6 — écran consultation produit + onglets conditionnés + édition sans redirection
Auto Tag Develop / tag (push) Successful in 37s

- Nouvel écran de consultation lecture seule /admin/products/{id} (calque
  client/fournisseur) : clic sur une ligne ouvre la consultation (plus l'édition
  directe), bouton « Modifier » → édition.
- Règle ERP-193 en consultation : champs vides / checkbox non cochées masqués
  (isFilled) ; onglets vides masqués → les coquilles Fournisseurs/Clients
  (placeholder, module Contrat inexistant) ne sont pas rendues en consultation.
- Onglets Fournisseurs/Clients : non affichés à l'ajout (avant validation du
  formulaire principal) ; visibilité conditionnée par l'état (spec C3, « Aucun »
  = OTHER) : Fournisseurs si Achat/Aucun, Clients si Vendu/Aucun.
- Édition : après « Enregistrer » on reste sur l'écran (l'utilisateur garde la
  main, calque client/fournisseur) ; réaffichage des valeurs normalisées serveur
  (RG-6.07) via re-prefill, plus de redirection.
- i18n consultation + tests (consultation, onglets, no-redirect) ; spec écran 8.bis.
This commit is contained in:
2026-06-27 17:18:17 +02:00
parent 58d0c499d4
commit eb94204c55
14 changed files with 464 additions and 37 deletions
@@ -0,0 +1,139 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, h, Suspense } from 'vue'
// Produit charge simule (cles de la reponse reelle § 4.0.bis).
const PRODUCT = {
id: 34,
code: 'BLE-01',
name: 'Blé tendre',
states: ['PURCHASE', 'SALE'],
manufactured: true,
containsMolasses: false,
category: { '@id': '/api/categories/12', id: 12, name: 'Céréales', code: 'CEREALES' },
sites: [{ '@id': '/api/sites/1', id: 1, name: 'Chatellerault' }],
storageTypes: [{ '@id': '/api/storage_types/9', id: 9, code: 'TAS', label: 'Tas' }],
createdAt: '', updatedAt: '',
}
const fx = vi.hoisted(() => ({ load: vi.fn() }))
vi.mock('~/modules/catalog/composables/useProduct', async () => {
const { ref } = await import('vue')
return {
useProduct: () => ({
product: ref(PRODUCT),
loading: ref(false),
error: ref(false),
load: fx.load,
}),
}
})
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
const mockPush = vi.hoisted(() => vi.fn())
const mockNavigateTo = vi.hoisted(() => vi.fn())
const mockCan = vi.hoisted(() => vi.fn())
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('useHead', () => undefined)
vi.stubGlobal('useRoute', () => ({ params: { id: '34' } }))
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
vi.stubGlobal('usePermissions', () => ({ can: mockCan }))
vi.stubGlobal('navigateTo', mockNavigateTo)
const ViewPage = (await import('../admin/products/[id]/index.vue')).default
const ButtonStub = defineComponent({
props: { label: { type: String, default: '' } },
emits: ['click'],
setup(props, { emit }) {
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
},
})
// Input lecture seule : expose le label + la valeur affichee (model-value).
const InputStub = defineComponent({
props: { label: { type: String, default: '' }, modelValue: { default: null } },
setup(props) { return () => h('input', { 'data-label': props.label, 'data-value': String(props.modelValue ?? '') }) },
})
const CheckboxStub = defineComponent({
props: { label: { type: String, default: '' }, modelValue: { type: Boolean, default: false } },
setup(props) { return () => h('input', { 'type': 'checkbox', 'data-label': props.label }) },
})
const TabsStub = defineComponent({ setup() { return () => h('div', { 'data-testid': 'placeholder-tabs' }) } })
const stubs = {
MalioButtonIcon: ButtonStub,
MalioButton: ButtonStub,
MalioInputText: InputStub,
MalioCheckbox: CheckboxStub,
ProductPlaceholderTabs: TabsStub,
}
async function mountPage() {
const wrapper = mount(defineComponent({
components: { ViewPage },
setup: () => () => h(Suspense, null, { default: () => h(ViewPage) }),
}), { global: { stubs } })
await flushPromises()
return wrapper
}
describe('Écran Consultation produit (page /admin/products/{id})', () => {
beforeEach(() => {
fx.load.mockReset().mockResolvedValue(undefined)
mockPush.mockReset()
mockNavigateTo.mockReset()
mockCan.mockReset().mockReturnValue(true)
})
it('charge le produit au montage', async () => {
await mountPage()
expect(fx.load).toHaveBeenCalled()
})
it('redirige vers la liste sans la permission view', async () => {
mockCan.mockReturnValue(false)
await mountPage()
expect(mockNavigateTo).toHaveBeenCalledWith('/admin/products')
})
it('affiche les champs en lecture seule (libelles mappes)', async () => {
const wrapper = await mountPage()
const valueOf = (label: string) =>
wrapper.find(`[data-label="${label}"]`).attributes('data-value')
expect(valueOf('admin.products.form.name')).toBe('Blé tendre')
expect(valueOf('admin.products.form.code')).toBe('BLE-01')
expect(valueOf('admin.products.form.category')).toBe('Céréales')
expect(valueOf('admin.products.form.sites')).toBe('Chatellerault')
expect(valueOf('admin.products.form.storageTypes')).toBe('Tas')
// Etats : libelles i18n joints.
expect(valueOf('admin.products.form.states')).toBe('admin.products.state.PURCHASE, admin.products.state.SALE')
})
it('bouton « Modifier » (manage) → ecran d\'edition', async () => {
const wrapper = await mountPage()
await wrapper.find('[data-label="admin.products.action.edit"]').trigger('click')
expect(mockPush).toHaveBeenCalledWith('/admin/products/34/edit')
})
it('masque « Modifier » sans la permission manage', async () => {
// view OK mais manage refuse.
mockCan.mockImplementation((perm: string) => perm === 'catalog.products.view')
const wrapper = await mountPage()
expect(wrapper.find('[data-label="admin.products.action.edit"]').exists()).toBe(false)
})
it('n\'affiche AUCUN onglet en consultation (coquilles vides masquees, ERP-193)', async () => {
const wrapper = await mountPage()
expect(wrapper.find('[data-testid="placeholder-tabs"]').exists()).toBe(false)
})
it('masque un champ vide / une checkbox non cochee (ERP-193, isFilled)', async () => {
const wrapper = await mountPage()
// containsMolasses = false dans le fixture => case masquee.
expect(wrapper.find('[data-label="admin.products.form.containsMolasses"]').exists()).toBe(false)
// manufactured = true => case affichee.
expect(wrapper.find('[data-label="admin.products.form.manufactured"]').exists()).toBe(true)
})
})
@@ -133,18 +133,19 @@ describe('Écran Modifier un produit (page /admin/products/{id}/edit)', () => {
expect(fx.prefill).toHaveBeenCalledWith(PRODUCT)
})
it('redirige vers la liste sans la permission manage', async () => {
it('redirige vers la consultation sans la permission manage', async () => {
mockCan.mockReturnValue(false)
await mountPage()
expect(mockNavigateTo).toHaveBeenCalledWith('/admin/products')
expect(mockNavigateTo).toHaveBeenCalledWith('/admin/products/34')
})
it('bouton « Enregistrer » : submit (PATCH) puis retour a la liste', async () => {
it('bouton « Enregistrer » : submit (PATCH) SANS redirection (l\'utilisateur garde la main)', async () => {
const wrapper = await mountPage()
await wrapper.find('[data-label="admin.products.edit.save"]').trigger('click')
await flushPromises()
expect(fx.submit).toHaveBeenCalled()
expect(mockPush).toHaveBeenCalledWith('/admin/products')
// On reste sur l'ecran d'edition : aucune navigation au succes (calque client/fournisseur).
expect(mockPush).not.toHaveBeenCalled()
})
it('affiche les onglets placeholder (rendu sans appel API)', async () => {
@@ -139,4 +139,9 @@ describe('Écran Ajouter un produit (page /admin/products/new)', () => {
await flushPromises()
expect(mockPush).not.toHaveBeenCalled()
})
it('n\'affiche PAS les onglets Fournisseurs/Clients a l\'ajout (avant validation)', async () => {
const wrapper = await mountPage()
expect(wrapper.find('[data-testid="placeholder-tabs"]').exists()).toBe(false)
})
})
@@ -188,11 +188,11 @@ describe('Catalogue produit (page /admin/products)', () => {
expect(wrapper.find('[data-label="admin.products.add"]').exists()).toBe(false)
})
it('navigue vers l\'édition au clic sur une ligne', async () => {
it('navigue vers la consultation au clic sur une ligne', async () => {
const wrapper = mountPage()
await flushPromises()
await wrapper.find('tr[data-row-id="34"]').trigger('click')
expect(mockPush).toHaveBeenCalledWith('/admin/products/34/edit')
expect(mockPush).toHaveBeenCalledWith('/admin/products/34')
})
it('navigue vers la création au clic sur « + Ajouter »', async () => {