feat(catalog) : M6 — écran Modification produit + onglets placeholder (ERP-206)

Écran de modification (ajout pré-rempli, bouton « Enregistrer ») et pose des
onglets Fournisseurs/Clients en placeholder « en cours de développement ».

- route /admin/products/{id}/edit : useProduct(id) charge le détail, prefill du formulaire principal
- RG-6.08 : useProductForm en mode édition → PATCH /products/{id} (merge-patch), bouton « Enregistrer »
- unicité du code re-validée serveur en édition (409 doublon mappé inline)
- onglets Fournisseurs + Clients : ComingSoonPlaceholder, aucun appel API ni champ (HP-M6-01 / RG-6.10)
- mêmes onglets placeholder posés sur l'écran Ajouter (cohérence)
- i18n admin.products.edit / tab ; 11 tests Vitest (prefill + PATCH + placeholder)
This commit is contained in:
2026-06-25 18:01:33 +02:00
parent ce0e274743
commit 64c3b9b6ec
10 changed files with 544 additions and 10 deletions
@@ -0,0 +1,154 @@
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'],
manufactured: false,
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: '',
}
// Holders crees dans les factories (vue initialise au moment de l'import page).
const fx = vi.hoisted(() => ({
isSale: null as unknown as { value: boolean },
submit: vi.fn(),
prefill: vi.fn(),
loadReferentials: vi.fn(),
load: vi.fn(),
}))
vi.mock('~/modules/catalog/composables/useProductForm', async () => {
const { ref, reactive } = await import('vue')
fx.isSale = ref(false)
return {
PRODUCT_STATES: ['PURCHASE', 'SALE', 'OTHER'],
useProductForm: () => ({
form: reactive({
code: null, name: null, states: [], siteIris: [],
categoryIri: null, storageTypeIris: [], manufactured: false, containsMolasses: false,
}),
errors: reactive({}),
submitting: ref(false),
isSale: fx.isSale,
siteOptions: ref([]),
categoryOptions: ref([]),
storageTypeOptions: ref([]),
setStates: vi.fn(),
setCategory: vi.fn(),
setStorageTypes: vi.fn(),
setSites: vi.fn(),
loadReferentials: fx.loadReferentials,
prefill: fx.prefill,
submit: fx.submit,
}),
}
})
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 EditPage = (await import('../admin/products/[id]/edit.vue')).default
const ButtonStub = defineComponent({
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
emits: ['click'],
setup(props, { emit }) {
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
},
})
const InputStub = defineComponent({
props: { label: { type: String, default: '' }, modelValue: { default: null } },
setup(props) { return () => h('input', { 'data-label': props.label }) },
})
const CheckboxStub = defineComponent({
props: { label: { type: String, default: '' } },
setup(props) { return () => h('input', { 'type': 'checkbox', 'data-label': props.label }) },
})
// Placeholder : rendu sans aucun appel API (juste un marqueur).
const TabsStub = defineComponent({ setup() { return () => h('div', { 'data-testid': 'placeholder-tabs' }) } })
const stubs = {
MalioButtonIcon: ButtonStub,
MalioButton: ButtonStub,
MalioInputText: InputStub,
MalioSelect: InputStub,
MalioSelectCheckbox: InputStub,
MalioCheckbox: CheckboxStub,
ProductPlaceholderTabs: TabsStub,
}
async function mountPage() {
const wrapper = mount(defineComponent({
components: { EditPage },
setup: () => () => h(Suspense, null, { default: () => h(EditPage) }),
}), { global: { stubs } })
await flushPromises()
return wrapper
}
describe('Écran Modifier un produit (page /admin/products/{id}/edit)', () => {
beforeEach(() => {
fx.submit.mockReset().mockResolvedValue(true)
fx.prefill.mockReset().mockResolvedValue(undefined)
fx.loadReferentials.mockReset().mockResolvedValue(undefined)
fx.load.mockReset().mockResolvedValue(undefined)
mockPush.mockReset()
mockNavigateTo.mockReset()
mockCan.mockReset().mockReturnValue(true)
fx.isSale.value = false
})
it('charge le produit et pre-remplit le formulaire au montage', async () => {
await mountPage()
expect(fx.load).toHaveBeenCalled()
expect(fx.prefill).toHaveBeenCalledWith(PRODUCT)
})
it('redirige vers la liste sans la permission manage', async () => {
mockCan.mockReturnValue(false)
await mountPage()
expect(mockNavigateTo).toHaveBeenCalledWith('/admin/products')
})
it('bouton « Enregistrer » : submit (PATCH) puis retour a la liste', 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')
})
it('affiche les onglets placeholder (rendu sans appel API)', async () => {
const wrapper = await mountPage()
expect(wrapper.find('[data-testid="placeholder-tabs"]').exists()).toBe(true)
})
})
@@ -67,6 +67,9 @@ const CheckboxStub = defineComponent({
setup(props) { return () => h('input', { 'type': 'checkbox', 'data-label': props.label }) },
})
// Placeholder (onglets Fournisseurs/Clients) : marqueur sans appel API.
const TabsStub = defineComponent({ setup() { return () => h('div', { 'data-testid': 'placeholder-tabs' }) } })
const stubs = {
MalioButtonIcon: ButtonStub,
MalioButton: ButtonStub,
@@ -74,6 +77,7 @@ const stubs = {
MalioSelect: InputStub,
MalioSelectCheckbox: InputStub,
MalioCheckbox: CheckboxStub,
ProductPlaceholderTabs: TabsStub,
}
async function mountPage() {
@@ -0,0 +1,182 @@
<template>
<div>
<!-- En-tete : retour vers le catalogue + nom du produit. -->
<div class="flex items-center gap-3 pt-11">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
:title="t('admin.products.edit.back')"
v-bind="{ ariaLabel: t('admin.products.edit.back') }"
@click="goBack"
/>
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
</div>
<!-- Etats de chargement / introuvable. -->
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('admin.products.edit.loading') }}</p>
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('admin.products.edit.notFound') }}</p>
<template v-else-if="product">
<!-- Formulaire principal pre-rempli (mêmes champs/regles que l'ajout,
RG-6.016.07). Bouton « Enregistrer » PATCH (RG-6.08). -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<!-- Etat du produit : multi-select obligatoire (>= 1, RG-6.02). -->
<MalioSelectCheckbox
:model-value="form.states"
:options="stateOptions"
:label="t('admin.products.form.states')"
:display-tag="true"
:required="true"
:error="errors.states"
@update:model-value="(v: (string | number)[]) => setStates(v.map(String))"
/>
<!-- Sites de disponibilite : multi-select obligatoire (>= 1, RG-6.04).
Pilote la cascade Type de stockage (RG-6.06). -->
<MalioSelectCheckbox
:model-value="form.siteIris"
:options="siteOptions"
:label="t('admin.products.form.sites')"
:display-tag="true"
:required="true"
:error="errors.sites"
@update:model-value="(v: (string | number)[]) => setSites(v.map(String))"
/>
<MalioInputText
v-model="form.name"
:mask="FREE_TEXT_MASK"
:label="t('admin.products.form.name')"
:required="true"
:error="errors.name"
/>
<!-- Code modifiable techniquement ; l'unicite reste re-validee serveur (RG-6.01). -->
<MalioInputText
v-model="form.code"
:mask="CODE_ALNUM_MASK"
:label="t('admin.products.form.code')"
:required="true"
:error="errors.code"
/>
<!-- Categorie produit : select simple obligatoire, filtre type PRODUIT (RG-6.05). -->
<MalioSelect
:model-value="form.categoryIri"
:options="categoryOptions"
:label="t('admin.products.form.category')"
empty-option-label=""
:required="true"
:error="errors.category"
@update:model-value="(v: string | number | null) => setCategory(v === null || v === '' ? null : String(v))"
/>
<!-- Type de stockage : multi-select obligatoire (>= 1), options filtrees
par les sites selectionnes (RG-6.06). -->
<MalioSelectCheckbox
:model-value="form.storageTypeIris"
:options="storageTypeOptions"
:label="t('admin.products.form.storageTypes')"
:display-tag="true"
:required="true"
:error="errors.storageTypes"
@update:model-value="(v: (string | number)[]) => setStorageTypes(v.map(String))"
/>
<!-- RG-6.03 : « Fabriqué » + « Contient de la mélasse » visibles
uniquement si l'Etat contient « Vendu ». -->
<MalioCheckbox
v-if="isSale"
v-model="form.manufactured"
:label="t('admin.products.form.manufactured')"
group-class="self-center"
/>
<MalioCheckbox
v-if="isSale"
v-model="form.containsMolasses"
:label="t('admin.products.form.containsMolasses')"
group-class="self-center"
/>
</div>
<div class="mt-12 flex justify-center">
<MalioButton
variant="primary"
:label="t('admin.products.edit.save')"
:disabled="submitting"
@click="onSubmit"
/>
</div>
<!-- Onglets Fournisseurs / Clients en placeholder (HP-M6-01). -->
<ProductPlaceholderTabs />
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useProductForm, PRODUCT_STATES } from '~/modules/catalog/composables/useProductForm'
import { useProduct } from '~/modules/catalog/composables/useProduct'
import { CODE_ALNUM_MASK, FREE_TEXT_MASK } from '~/shared/utils/textSanitize'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const { can } = usePermissions()
// Gating de la route : la modification est reservee a `manage` (catalogue admin-only).
if (!can('catalog.products.manage')) {
await navigateTo('/admin/products')
}
const productId = route.params.id as string
const { product, loading, error, load } = useProduct(productId)
const {
form,
errors,
submitting,
isSale,
siteOptions,
categoryOptions,
storageTypeOptions,
setStates,
setCategory,
setStorageTypes,
setSites,
loadReferentials,
prefill,
submit,
} = useProductForm()
const headerTitle = computed(() => product.value?.name ?? t('admin.products.edit.title'))
useHead({ title: headerTitle })
// Options de l'etat : libelles i18n (la valeur d'option = code enum).
const stateOptions = computed(() =>
PRODUCT_STATES.map(code => ({ value: code, label: t(`admin.products.state.${code}`) })),
)
/** Retour vers le catalogue produit (fleche d'en-tete). */
function goBack(): void {
router.push('/admin/products')
}
/** Soumet la modification (PATCH) ; au succes, retour a la liste. */
async function onSubmit(): Promise<void> {
const ok = await submit()
if (ok) {
router.push('/admin/products')
}
}
onMounted(async () => {
// Referentiels (selects) + detail du produit charges en parallele.
await Promise.all([
loadReferentials().catch(() => {}),
load(),
])
// Pre-remplissage une fois le produit charge (echec de chargement => message).
if (product.value) {
await prefill(product.value)
}
})
</script>
@@ -97,6 +97,10 @@
@click="onSubmit"
/>
</div>
<!-- Onglets Fournisseurs / Clients en placeholder (HP-M6-01) : presents
des l'ajout pour coherence avec l'ecran de modification. -->
<ProductPlaceholderTabs />
</div>
</template>