@@ -216,6 +260,41 @@ function confirmDelete() {
{{ t('common.delete') }}
+
+
+ {{ t('recipes.edit') }}
+
+
+
+ {{ t('recipes.common.yourCopy') }}
+
+
+ {{ t('recipes.copied') }}
+
+
+ {{ t('recipes.copy') }}
+
+
+
+
+
+
+
+ {{ t('recipes.common.open') }}
+ {{ t('recipes.common.openHint') }}
+
+
+
+
+
+
+
+ {{ t('recipes.common.delete') }}
+
+
+
+
+
+
+ {{
+ t('common.close')
+ }}
+
+
recipeId.value !== null)
const types = useRecipeTypes()
const ingredients = useIngredientOptions()
-const existing = useRecipe(() => recipeId.value ?? '')
+// La recette à modifier, et si c'est la version commune d'une recette Marmiton, que seul l'administrateur corrige (MAR-005) : ni
+// interrupteur « secrète » (elle n'a pas d'auteur), une ligne le dit en tête, et un message confirme l'enregistrement.
+const { recipe: existing, ownership, isCommon } = useRecipeStance(() => recipeId.value ?? '')
const save = useSaveRecipe()
let nextKey = 1
@@ -91,7 +95,21 @@ watch(
// seulement quand ce nom existe dans deux rayons (ING-017). Un ancien back (ni rayon ni icône) : des options sans repère.
const { iconOf } = useDepartmentIcons()
const options = computed(() => {
- const members = ingredients.data.value?.member ?? []
+ const listed = ingredients.data.value?.member ?? []
+ // Les ingrédients d'une version commune appartiennent à un propriétaire réservé et ne sont dans la liste de personne : le nom de chaque
+ // ligne existante vient alors de la ligne elle-même (nom et rayon), sinon le champ resterait vide. Renvoyé tel quel, le slug est gardé.
+ const known = new Set(listed.map((option) => option.slug))
+ const missing = new Map()
+ for (const row of existing.data.value?.rows ?? []) {
+ if (known.has(row.ingredientSlug) || missing.has(row.ingredientSlug)) continue
+ missing.set(row.ingredientSlug, {
+ slug: row.ingredientSlug,
+ name: row.ingredientName,
+ departmentSlug: row.departmentSlug,
+ departmentName: row.departmentName,
+ })
+ }
+ const members = [...listed, ...missing.values()]
const ambiguous = namesInSeveralDepartments(members, (option) => option)
return members.map((option) => ({
@@ -153,7 +171,7 @@ function submit() {
typeSlug: form.typeSlug,
// Création : la clé ne part que pour une recette secrète (absente = publique) ; modification : la valeur choisie, si le back la connaît
...(editing.value
- ? secretKnown.value
+ ? secretKnown.value && !isCommon.value
? { secret: form.secret }
: {}
: form.secret
@@ -167,7 +185,11 @@ function submit() {
},
},
{
- onSuccess: (id) => router.push({ name: 'recipe', params: { id } }),
+ onSuccess: (id) => {
+ if (isCommon.value) useNotificationsStore().push('success', t('recipes.common.saved'))
+
+ return router.push({ name: 'recipe', params: { id } })
+ },
onError: (error) => {
failure.value = messageForFailure(error)
},
@@ -188,6 +210,10 @@ function submit() {
{{ editing ? t('editor.editTitle') : t('editor.writeTitle') }}
+
+ {{ t('recipes.common.editing') }}
+
+
{{ t('common.loading') }}
@@ -245,7 +271,10 @@ function submit() {
-
+
{{ t('recipes.ingredients') }}
diff --git a/src/features/recipes/__tests__/RecipeCommon.spec.ts b/src/features/recipes/__tests__/RecipeCommon.spec.ts
new file mode 100644
index 00000000..2b2330b4
--- /dev/null
+++ b/src/features/recipes/__tests__/RecipeCommon.spec.ts
@@ -0,0 +1,659 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { flushPromises, mount } from '@vue/test-utils'
+
+import { appContext, calls, json, me, stubApi } from '@/__tests__/helpers'
+import type { Handler } from '@/__tests__/helpers'
+import OtherRecipesView from '@/features/recipes/OtherRecipesView.vue'
+import RecipeDetailView from '@/features/recipes/RecipeDetailView.vue'
+import RecipeEditorView from '@/features/recipes/RecipeEditorView.vue'
+import { useNotificationsStore } from '@/notifications/store'
+import { useSessionStore } from '@/session/session'
+
+// La version commune d'une recette Marmiton, corrigée ou supprimée par l'administrateur (MAR-005, ADR 0037 du back). Le back n'est jamais
+// joint : un faux back par route. Un utilisateur ordinaire ne voit rien de plus qu'avant.
+
+const LINK = 'https://www.marmiton.org/recettes/recette_the-tarte-au-citron-meringuee_22082.aspx'
+const admin = { ...me, id: 'a1', roles: ['ROLE_USER', 'ROLE_ADMIN'], isAdmin: true }
+
+const row = (slug: string, name: string, quantity: number, department = 'cremerie') => ({
+ ingredientSlug: slug,
+ ingredientName: name,
+ ingredientImage: '',
+ quantity,
+ unitSlug: 'unite',
+ unitSymbol: '',
+ unitName: 'Unité',
+ purchaseUnitSlug: 'unite',
+ purchaseUnitName: 'Unité',
+ convertible: true,
+ departmentSlug: department,
+ departmentName: department === 'cremerie' ? 'Crémerie' : 'Épicerie',
+})
+
+/** La version commune : sans propriétaire, ses ingrédients sont ceux du propriétaire réservé (aucun n'est dans la liste de l'administrateur). */
+const common = {
+ id: 'c1',
+ name: 'Tarte au citron meringuée',
+ serving: 6,
+ typeSlug: 'dessert',
+ typeName: 'Dessert',
+ version: 3,
+ secret: false,
+ favorite: false,
+ sourceUrl: LINK,
+ originalId: null,
+ canEdit: true,
+ rows: [row('jaune-reserve', 'Jaune d’œuf', 4), row('farine-reservee', 'Farine', 250, 'epicerie')],
+}
+/** La copie que l'administrateur en a faite. */
+const copy = {
+ ...common,
+ id: 'm1',
+ version: 1,
+ originalId: 'c1',
+ rows: [row('jaune-admin', 'Jaune d’œuf', 4)],
+}
+
+const types = {
+ member: [{ slug: 'dessert', name: 'Dessert', image: '', order: 1 }],
+ totalItems: 1,
+}
+const backend = {
+ 'POST /api/auth/refresh': () => json({ token: 'jeton' }),
+ 'GET /api/v1/me': () => json(admin),
+ 'GET /api/v1/recipe-types': () => json(types),
+ 'GET /api/v1/shopping-lists/active/summary': () =>
+ json({ shoppingListId: null, quantityByIngredientSlug: {}, plannedRecipeIds: [] }),
+}
+
+async function mountView(
+ component: typeof RecipeDetailView | typeof RecipeEditorView | typeof OtherRecipesView,
+ path: string,
+ handlers: Record,
+) {
+ const fetchMock = stubApi({ ...backend, ...handlers })
+ localStorage.setItem('repas.session', '1')
+ const context = appContext()
+ await useSessionStore().restore()
+ await context.router.push(path)
+ const wrapper = mount(component, {
+ attachTo: document.body,
+ global: { plugins: context.plugins },
+ })
+
+ return { wrapper, context, fetchMock }
+}
+
+const buttons = (wrapper: { findAll: (selector: string) => { text: () => string }[] }) =>
+ wrapper.findAll('main button, main a').map((element) => element.text().replace(/\s+/g, ' '))
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+ document.body.innerHTML = ''
+})
+
+describe('« Recettes des autres » vu par l’administrateur', () => {
+ const others = (member: Record[]) => ({
+ 'GET /api/v1/recipes/other-people': () => json({ member, totalItems: member.length }),
+ })
+ const marmiton = (id: string, name: string, alreadyCopied: boolean) => ({
+ id,
+ name,
+ typeName: 'Dessert',
+ typeSlug: 'dessert',
+ fromMarmiton: true,
+ alreadyCopied,
+ })
+ const ordinary = {
+ id: 'o1',
+ name: 'Gratin dauphinois',
+ typeName: 'Plat',
+ typeSlug: 'plat',
+ fromMarmiton: false,
+ alreadyCopied: false,
+ }
+ const open = (member: Record[], user: unknown = admin, more = {}) =>
+ mountView(OtherRecipesView, '/recettes/autres', {
+ 'GET /api/v1/me': () => json(user),
+ ...others(member),
+ ...more,
+ })
+
+ it('une version commune déjà copiée : pastille, « Tu as déjà ta copie » et « Ma copie » à la place de « Copier »', async () => {
+ const { wrapper } = await open([marmiton('c1', 'Tarte au citron meringuée', true), ordinary])
+ await vi.waitFor(() => expect(wrapper.find('li').exists()).toBe(true))
+
+ const item = wrapper.findAll('li')[0]!
+ expect(item.find('[data-marmiton-badge]').text()).toBe('Marmiton')
+ expect(item.find('[data-have-copy]').text()).toBe('Tu as déjà ta copie')
+ const link = item.find('[data-open-copy]')
+ expect(link.text()).toBe('Ma copie')
+ expect(link.element.tagName).toBe('BUTTON')
+ expect(link.attributes('aria-label')).toBe('Ouvrir ma copie de Tarte au citron meringuée')
+ expect(item.text()).not.toContain('Copier')
+ // Un lien discret, pas un bouton contour
+ expect(link.classes()).toContain('underline')
+ expect(link.classes().join(' ')).not.toMatch(/border-/)
+ // La fiche de la version commune reste ouvrable par le nom
+ expect(item.find('a').attributes('href')).toBe('/recettes/c1')
+ })
+
+ it('une version commune pas encore copiée garde la pastille et « Copier »', async () => {
+ const { wrapper } = await open([marmiton('c2', 'Gratin de courgettes', false)])
+ await vi.waitFor(() => expect(wrapper.find('li').exists()).toBe(true))
+
+ const item = wrapper.find('li')
+ expect(item.find('[data-marmiton-badge]').exists()).toBe(true)
+ expect(item.find('[data-have-copy]').exists()).toBe(false)
+ expect(item.find('[data-open-copy]').exists()).toBe(false)
+ expect(item.find('button').text()).toBe('Copier')
+ })
+
+ it('la phrase d’aide est en tête de liste, tant qu’il y a une recette Marmiton', async () => {
+ const { wrapper } = await open([marmiton('c1', 'Tarte', true), ordinary])
+ await vi.waitFor(() => expect(wrapper.find('li').exists()).toBe(true))
+
+ expect(wrapper.find('[data-common-help]').text()).toBe(
+ 'Les recettes Marmiton sont communes. Touche-en une pour la corriger.',
+ )
+ // Après le titre et sa phrase, avant la recherche et la liste
+ const order = Array.from(wrapper.element.querySelectorAll('h1, [data-common-help], ul'))
+ expect((order[1] as Element | undefined)?.hasAttribute('data-common-help')).toBe(true)
+ })
+
+ it('sans recette Marmiton, pas de phrase d’aide', async () => {
+ const { wrapper } = await open([ordinary])
+ await vi.waitFor(() => expect(wrapper.find('li').exists()).toBe(true))
+
+ expect(wrapper.find('[data-common-help]').exists()).toBe(false)
+ })
+
+ it('« Ma copie » ouvre SA copie : retrouvée parmi ses recettes du type et confirmée par son original', async () => {
+ const { wrapper, context, fetchMock } = await open(
+ [marmiton('c1', 'Tarte au citron meringuée', true)],
+ admin,
+ {
+ 'GET /api/v1/recipe-types/dessert/recipes': () =>
+ json({
+ member: [
+ { id: 'm0', name: 'Tarte au citron meringuée', secret: false },
+ { id: 'm1', name: 'Tarte au citron meringuée', secret: false },
+ ],
+ totalItems: 2,
+ }),
+ // Une homonyme qui n'est pas la copie de la commune, puis la bonne
+ 'GET /api/v1/recipes/m0': () => json({ ...copy, id: 'm0', originalId: null }),
+ 'GET /api/v1/recipes/m1': () => json(copy),
+ },
+ )
+ await vi.waitFor(() => expect(wrapper.find('[data-open-copy]').exists()).toBe(true))
+
+ await wrapper.find('[data-open-copy]').trigger('click')
+ await flushPromises()
+
+ expect(context.router.currentRoute.value.name).toBe('recipe')
+ expect(context.router.currentRoute.value.params.id).toBe('m1')
+ expect(calls(fetchMock)).toContain('GET /api/v1/recipes/m0')
+ })
+
+ it('copie introuvable (renommée, changée de type) : la version commune s’ouvre', async () => {
+ const { wrapper, context } = await open(
+ [marmiton('c1', 'Tarte au citron meringuée', true)],
+ admin,
+ {
+ 'GET /api/v1/recipe-types/dessert/recipes': () => json({ member: [], totalItems: 0 }),
+ },
+ )
+ await vi.waitFor(() => expect(wrapper.find('[data-open-copy]').exists()).toBe(true))
+
+ await wrapper.find('[data-open-copy]').trigger('click')
+ await flushPromises()
+
+ expect(context.router.currentRoute.value.params.id).toBe('c1')
+ })
+
+ describe('un utilisateur ordinaire : rien ne change', () => {
+ it('ni phrase d’aide, ni « Ma copie » : la pastille et « Copier » seulement', async () => {
+ const { wrapper } = await open([marmiton('c2', 'Gratin de courgettes', false), ordinary], me)
+ await vi.waitFor(() => expect(wrapper.find('li').exists()).toBe(true))
+
+ expect(wrapper.find('[data-common-help]').exists()).toBe(false)
+ expect(wrapper.findAll('li').map((item) => item.find('button').text())).toEqual([
+ 'Copier',
+ 'Copier',
+ ])
+ expect(wrapper.find('[data-open-copy]').exists()).toBe(false)
+ expect(wrapper.text()).not.toContain('Tu as déjà ta copie')
+ })
+
+ it('même si le back disait « déjà copiée » : seul l’administrateur le montre', async () => {
+ const { wrapper } = await open([marmiton('c1', 'Tarte', true)], me)
+ await vi.waitFor(() => expect(wrapper.find('li').exists()).toBe(true))
+
+ expect(wrapper.find('li button').text()).toBe('Copier')
+ expect(wrapper.find('[data-have-copy]').exists()).toBe(false)
+ })
+ })
+})
+
+describe('la fiche de la version commune, lue par l’administrateur', () => {
+ const handlers = (more: Record = {}) => ({
+ 'GET /api/v1/recipes/c1': () => json(common),
+ 'GET /api/v1/recipe-types/dessert/recipes': () => json({ member: [], totalItems: 0 }),
+ ...more,
+ })
+ async function open(more: Record = {}) {
+ const opened = await mountView(RecipeDetailView, '/recettes/c1', handlers(more))
+ await vi.waitFor(() => expect(opened.wrapper.find('[data-common-banner]').exists()).toBe(true))
+ await vi.waitFor(() => expect(buttons(opened.wrapper)).toContain('Copier'))
+
+ return opened
+ }
+
+ it('dit « Version commune » et ce que ça change, avec Modifier pour seul bouton principal', async () => {
+ const { wrapper } = await open()
+
+ const banner = wrapper.find('[data-common-banner]')
+ expect(banner.find('h2').text()).toBe('Version commune')
+ expect(banner.text()).toContain(
+ 'Les prochains qui la copient auront ta version. Les copies déjà faites ne changent pas.',
+ )
+ const edit = wrapper.find('a[href="/recettes/c1/modifier"]')
+ expect(edit.text()).toBe('Modifier')
+ expect(edit.classes()).toContain('bg-primary')
+ // Un seul bouton principal : tout le reste est secondaire
+ expect(wrapper.findAll('main .bg-primary')).toHaveLength(1)
+ // Le lien d'origine reste
+ expect(wrapper.find('a[target="_blank"]').attributes('href')).toBe(LINK)
+ })
+
+ it('propose Copier, sans Ajouter à la liste ni favori ni pastille « Secrète »', async () => {
+ const { wrapper } = await open()
+
+ expect(buttons(wrapper)).toContain('Copier')
+ expect(wrapper.text()).not.toContain('Ajouter')
+ expect(wrapper.find('[aria-label^="Mettre"]').exists()).toBe(false)
+ expect(wrapper.find('[data-secret-badge]').exists()).toBe(false)
+ })
+
+ it('« Supprimer cette recette » est tout en bas, en rouge de danger, à part de Modifier', async () => {
+ const { wrapper } = await open()
+
+ const order = buttons(wrapper)
+ expect(order[order.length - 1]).toBe('Supprimer cette recette')
+ const remove = wrapper.findAll('button').find((b) => b.text() === 'Supprimer cette recette')!
+ expect(remove.classes()).toContain('text-danger')
+ expect(remove.classes()).toContain('border-danger')
+ // Après la liste des ingrédients
+ const html = wrapper.html()
+ expect(html.indexOf('Farine')).toBeLessThan(html.indexOf('Supprimer cette recette'))
+ expect(buttons(wrapper)).not.toContain('Supprimer')
+ })
+
+ it('« Copier » copie la version commune (comme pour une recette d’un autre)', async () => {
+ const { wrapper, fetchMock } = await open({
+ 'POST /api/v1/recipes/c1/copy': () =>
+ new Response(null, { status: 201, headers: { Location: '/api/v1/recipes/m1' } }),
+ })
+
+ await wrapper
+ .findAll('button')
+ .find((b) => b.text() === 'Copier')!
+ .trigger('click')
+ await flushPromises()
+
+ expect(calls(fetchMock)).toContain('POST /api/v1/recipes/c1/copy')
+ expect(buttons(wrapper)).toContain('Copiée')
+ })
+
+ describe('quand l’administrateur a déjà sa copie', () => {
+ it('« Ta copie » remplace « Copier » et mène à sa copie', async () => {
+ const { wrapper } = await mountView(RecipeDetailView, '/recettes/c1', {
+ ...handlers({
+ 'GET /api/v1/recipe-types/dessert/recipes': () =>
+ json({
+ member: [{ id: 'm1', name: 'Tarte au citron meringuée', secret: false }],
+ totalItems: 1,
+ }),
+ 'GET /api/v1/recipes/m1': () => json(copy),
+ }),
+ })
+ await vi.waitFor(() => expect(buttons(wrapper)).toContain('Ta copie'))
+
+ expect(buttons(wrapper)).not.toContain('Copier')
+ expect(wrapper.find('a[href="/recettes/m1"]').text()).toBe('Ta copie')
+ })
+ })
+
+ describe('supprimer', () => {
+ const confirm = async (wrapper: Awaited>['wrapper']) => {
+ await wrapper
+ .findAll('button')
+ .find((b) => b.text() === 'Supprimer cette recette')!
+ .trigger('click')
+ await flushPromises()
+ }
+ const dialog = () => document.body.querySelector('[role="dialog"]')
+
+ it('demande confirmation avec les mots du produit', async () => {
+ const { wrapper } = await open()
+
+ await confirm(wrapper)
+
+ expect(dialog()?.querySelector('h2')?.textContent).toBe('Supprimer la recette Marmiton ?')
+ expect(dialog()?.textContent).toContain(
+ 'Elle disparaît de « Recettes des autres ». Les copies déjà faites restent à leurs auteurs.',
+ )
+ expect(
+ Array.from(dialog()?.querySelectorAll('button') ?? []).map((b) => b.textContent),
+ ).toEqual(expect.arrayContaining(['Supprimer', 'Annuler']))
+ })
+
+ it('Annuler ne supprime rien', async () => {
+ const { wrapper, fetchMock } = await open()
+ await confirm(wrapper)
+
+ const cancel = Array.from(dialog()?.querySelectorAll('button') ?? []).find(
+ (b) => b.textContent === 'Annuler',
+ )!
+ cancel.click()
+ await flushPromises()
+
+ expect(calls(fetchMock)).not.toContain('DELETE /api/v1/recipes/c1')
+ })
+
+ it('supprime puis revient à « Recettes des autres »', async () => {
+ const { wrapper, context, fetchMock } = await open({
+ 'DELETE /api/v1/recipes/c1': () => new Response(null, { status: 204 }),
+ })
+ await confirm(wrapper)
+
+ Array.from(dialog()?.querySelectorAll('button') ?? [])
+ .find((b) => b.textContent === 'Supprimer')!
+ .click()
+ await vi.waitFor(() => expect(context.router.currentRoute.value.name).toBe('recipes-others'))
+
+ expect(calls(fetchMock)).toContain('DELETE /api/v1/recipes/c1')
+ })
+
+ it('409 (encore dans une liste de courses) : « Suppression impossible », en trois phrases, avec « Fermer »', async () => {
+ const { wrapper, context } = await open({
+ 'DELETE /api/v1/recipes/c1': () =>
+ json({ error: { message: 'RECIPE_USED_IN_SHOPPING_LIST' } }, { status: 409 }),
+ })
+ await confirm(wrapper)
+
+ Array.from(dialog()?.querySelectorAll('button') ?? [])
+ .find((b) => b.textContent === 'Supprimer')!
+ .click()
+ await vi.waitFor(() =>
+ expect(dialog()?.querySelector('h2')?.textContent).toBe('Suppression impossible'),
+ )
+
+ expect(dialog()?.textContent).toContain(
+ 'Des listes de courses utilisent encore cette recette. Rien n’a été supprimé. Réessaie quand ces listes seront terminées.',
+ )
+ expect(dialog()?.textContent).not.toMatch(/RECIPE_USED|409/)
+ // Pas de message en plus, et on reste sur la fiche
+ expect(useNotificationsStore().items).toEqual([])
+ expect(context.router.currentRoute.value.name).toBe('recipe')
+ const close = Array.from(dialog()?.querySelectorAll('button') ?? []).filter(
+ (b) => b.textContent === 'Fermer',
+ )
+ expect(close.length).toBeGreaterThan(0)
+ close[close.length - 1]!.click()
+ await flushPromises()
+ expect(dialog()).toBeNull()
+ })
+ })
+})
+
+describe('la fiche de SA copie d’une recette Marmiton, lue par l’administrateur', () => {
+ const handlers = (recipe: Record = copy) => ({
+ 'GET /api/v1/recipes/m1': () => json(recipe),
+ 'GET /api/v1/recipe-types/dessert/recipes': () =>
+ json({ member: [{ id: 'm1', name: 'x', secret: false }], totalItems: 1 }),
+ })
+ const open = async (
+ recipe: Record = copy,
+ user: Record = admin,
+ ) => {
+ const opened = await mountView(RecipeDetailView, '/recettes/m1', {
+ 'GET /api/v1/me': () => json(user),
+ ...handlers(recipe),
+ })
+ await vi.waitFor(() => expect(buttons(opened.wrapper)).toContain('Modifier'))
+
+ return opened
+ }
+
+ it('garde « Voir sur Marmiton » et ajoute « Ouvrir la version commune » vers la fiche de la commune', async () => {
+ const { wrapper } = await open()
+
+ expect(wrapper.find('a[target="_blank"]').text()).toBe('Voir sur Marmiton')
+ const card = wrapper.find('[data-common-link]')
+ expect(card.findAll('span > span').map((line) => line.text())).toEqual([
+ 'Ouvrir la version commune',
+ 'Celle que copient les autres.',
+ ])
+ expect(card.attributes('href')).toBe('/recettes/c1')
+ expect(card.find('svg[aria-hidden="true"]').exists()).toBe(true)
+ // Sa copie reste une recette ordinaire : ses actions habituelles, pas de bandeau « Version commune »
+ expect(wrapper.find('[data-common-banner]').exists()).toBe(false)
+ expect(buttons(wrapper)).toEqual(
+ expect.arrayContaining(['Voir sur Marmiton', 'Modifier', 'Supprimer']),
+ )
+ })
+
+ it('pas de carte quand la copie n’a plus d’original', async () => {
+ const { wrapper } = await open({ ...copy, originalId: null })
+
+ expect(wrapper.find('[data-common-link]').exists()).toBe(false)
+ })
+
+ it('pas de carte pour une copie qui ne vient pas de Marmiton', async () => {
+ const { wrapper } = await open({ ...copy, sourceUrl: null })
+
+ expect(wrapper.find('[data-common-link]').exists()).toBe(false)
+ })
+
+ it('un utilisateur ordinaire ne la voit jamais, même avec un original', async () => {
+ const { wrapper } = await open({ ...copy }, me)
+
+ expect(wrapper.find('[data-common-link]').exists()).toBe(false)
+ expect(wrapper.find('a[target="_blank"]').exists()).toBe(true)
+ })
+})
+
+describe('les autres fiches ne changent pas', () => {
+ const plain = {
+ ...common,
+ id: 'r1',
+ name: 'Gratin de courgettes',
+ typeSlug: 'plat',
+ typeName: 'Plat',
+ sourceUrl: null,
+ rows: [row('courgette', 'Courgette', 600)],
+ }
+ const open = async (recipe: Record, user: unknown, mineIds: string[]) => {
+ const opened = await mountView(RecipeDetailView, '/recettes/r1', {
+ 'GET /api/v1/me': () => json(user),
+ 'GET /api/v1/recipes/r1': () => json(recipe),
+ 'GET /api/v1/recipe-types/plat/recipes': () =>
+ json({ member: mineIds.map((id) => ({ id, name: 'x' })), totalItems: mineIds.length }),
+ })
+ await vi.waitFor(() => expect(opened.wrapper.find('h1').exists()).toBe(true))
+ await vi.waitFor(() => expect(buttons(opened.wrapper).length).toBeGreaterThan(1))
+
+ return opened
+ }
+
+ it('l’auteur voit toujours Ajouter, Modifier, Supprimer sur la sienne (canEdit vrai)', async () => {
+ const { wrapper } = await open({ ...plain, canEdit: true }, me, ['r1'])
+
+ expect(wrapper.find('[data-common-banner]').exists()).toBe(false)
+ expect(wrapper.find('[data-common-link]').exists()).toBe(false)
+ expect(buttons(wrapper)).toEqual(expect.arrayContaining(['Modifier', 'Supprimer']))
+ expect(wrapper.text()).toContain('Ajouter')
+ })
+
+ it('un utilisateur ordinaire ne voit que Copier sur celle d’un autre (canEdit faux), même version Marmiton', async () => {
+ for (const recipe of [
+ { ...plain, canEdit: false },
+ { ...plain, canEdit: false, sourceUrl: LINK, originalId: null },
+ ]) {
+ const { wrapper } = await open(recipe, me, [])
+
+ expect(buttons(wrapper)).toContain('Copier')
+ expect(buttons(wrapper)).not.toContain('Modifier')
+ expect(buttons(wrapper)).not.toContain('Supprimer')
+ expect(wrapper.find('[data-common-banner]').exists()).toBe(false)
+ expect(wrapper.find('a[href="/recettes/autres"]').exists()).toBe(true)
+ wrapper.unmount()
+ document.body.innerHTML = ''
+ }
+ })
+
+ it('sans canEdit (back plus ancien) : la liste de ses recettes décide, comme avant', async () => {
+ const legacy: Record = { ...plain }
+ delete legacy.canEdit
+ delete legacy.originalId
+ const { wrapper } = await open(legacy, me, ['r1'])
+
+ expect(buttons(wrapper)).toEqual(expect.arrayContaining(['Modifier', 'Supprimer']))
+ })
+
+ it('l’administrateur sur la recette ordinaire d’un autre : Copier seulement, ni bandeau ni suppression', async () => {
+ const { wrapper } = await open({ ...plain, canEdit: true }, admin, [])
+
+ expect(buttons(wrapper)).toContain('Copier')
+ expect(buttons(wrapper)).not.toContain('Modifier')
+ expect(wrapper.find('[data-common-banner]').exists()).toBe(false)
+ expect(wrapper.text()).not.toContain('Supprimer cette recette')
+ })
+
+ it('l’administrateur sur la copie Marmiton ordinaire d’un autre : pas non plus la version commune', async () => {
+ const { wrapper } = await open(
+ { ...plain, canEdit: true, sourceUrl: LINK, originalId: 'c1' },
+ admin,
+ [],
+ )
+
+ expect(wrapper.find('[data-common-banner]').exists()).toBe(false)
+ expect(buttons(wrapper)).toContain('Copier')
+ })
+})
+
+describe('l’éditeur de la version commune', () => {
+ const units = {
+ member: [
+ { slug: 'unite', name: 'Unité' },
+ { slug: 'gramme', name: 'Gramme' },
+ ],
+ totalItems: 2,
+ }
+ const handlers = (more: Record = {}) => ({
+ 'GET /api/v1/recipes/c1': () => json(common),
+ 'GET /api/v1/recipe-types/dessert/recipes': () => json({ member: [], totalItems: 0 }),
+ // Les ingrédients de la version commune ne sont dans la liste de personne : la liste de l'administrateur n'en a aucun
+ 'GET /api/v1/ingredients': () =>
+ json({
+ member: [
+ {
+ slug: 'beurre',
+ name: 'Beurre',
+ departmentSlug: 'cremerie',
+ departmentName: 'Crémerie',
+ },
+ ],
+ totalItems: 1,
+ }),
+ 'GET /api/v1/ingredients/jaune-reserve/units': () => json(units),
+ 'GET /api/v1/ingredients/farine-reservee/units': () => json(units),
+ ...more,
+ })
+ async function open(more: Record = {}) {
+ const opened = await mountView(RecipeEditorView, '/recettes/c1/modifier', handlers(more))
+ await vi.waitFor(() => expect(opened.wrapper.find('[data-common-note]').exists()).toBe(true))
+ await vi.waitFor(() =>
+ expect(opened.wrapper.findAll('li input[type="number"]').length).toBeGreaterThan(1),
+ )
+
+ return opened
+ }
+
+ it('est l’éditeur ordinaire, avec une ligne discrète “Tu modifies la version commune.”', async () => {
+ const { wrapper } = await open()
+
+ expect(wrapper.find('h1').text()).toBe('Modifier la recette')
+ expect(wrapper.find('[data-common-note]').text()).toBe('Tu modifies la version commune.')
+ expect(wrapper.find('button[type="submit"]').text()).toBe('Enregistrer')
+ })
+
+ it('sans interrupteur « Recette secrète » : elle n’a pas d’auteur', async () => {
+ const { wrapper } = await open()
+
+ expect(wrapper.text()).not.toContain('Recette secrète')
+ })
+
+ it('montre le nom de chaque ligne existante même quand son ingrédient n’est dans la liste de personne', async () => {
+ const { wrapper } = await open()
+
+ const names = wrapper
+ .findAll('li input[role="combobox"], li input[type="text"]')
+ .map((input) => (input.element as HTMLInputElement).value)
+ expect(names).toEqual(expect.arrayContaining(['Jaune d’œuf', 'Farine']))
+ // Leurs unités viennent de la liste de l'ingrédient réservé (le back y répond)
+ expect(
+ wrapper.findAll('li select').map((select) => (select.element as HTMLSelectElement).value),
+ ).toEqual(['unite', 'unite'])
+ })
+
+ it('enregistre avec les slugs réservés tels quels, sans le champ secret, puis dit que la recette est à jour', async () => {
+ const { wrapper, context, fetchMock } = await open({
+ 'PUT /api/v1/recipes/c1': () => new Response(null, { status: 204 }),
+ })
+
+ await wrapper.find('form').trigger('submit')
+ await vi.waitFor(() => expect(context.router.currentRoute.value.name).toBe('recipe'))
+
+ const put = fetchMock.mock.calls
+ .map(([call]) => call as Request)
+ .find((request) => request.method === 'PUT')
+ expect(put?.headers.get('If-Match')).toBe('"3"')
+ const body = (await put?.json()) as Record
+ expect(body).toMatchObject({
+ name: 'Tarte au citron meringuée',
+ serving: 6,
+ typeSlug: 'dessert',
+ rows: [
+ { ingredientSlug: 'jaune-reserve', unitSlug: 'unite', quantity: 4 },
+ { ingredientSlug: 'farine-reservee', unitSlug: 'unite', quantity: 250 },
+ ],
+ })
+ expect(body).not.toHaveProperty('secret')
+ expect(context.router.currentRoute.value.params.id).toBe('c1')
+ expect(useNotificationsStore().items.map((item) => item.text)).toEqual([
+ 'La recette Marmiton est à jour.',
+ ])
+ })
+
+ it('une recette ordinaire garde son éditeur : interrupteur “secrète”, pas de ligne ni de message en plus', async () => {
+ const own = { ...common, id: 'r1', originalId: null, sourceUrl: null, canEdit: true }
+ const { wrapper, context } = await mountView(RecipeEditorView, '/recettes/r1/modifier', {
+ ...handlers({
+ 'GET /api/v1/recipes/r1': () => json(own),
+ 'GET /api/v1/recipe-types/dessert/recipes': () =>
+ json({ member: [{ id: 'r1', name: 'x', secret: false }], totalItems: 1 }),
+ 'PUT /api/v1/recipes/r1': () => new Response(null, { status: 204 }),
+ }),
+ 'GET /api/v1/me': () => json(me),
+ })
+ await vi.waitFor(() => expect(wrapper.text()).toContain('Recette secrète'))
+
+ expect(wrapper.find('[data-common-note]').exists()).toBe(false)
+ await wrapper.find('form').trigger('submit')
+ await vi.waitFor(() => expect(context.router.currentRoute.value.name).toBe('recipe'))
+ expect(useNotificationsStore().items).toEqual([])
+ })
+})
diff --git a/src/features/recipes/__tests__/recipeStance.spec.ts b/src/features/recipes/__tests__/recipeStance.spec.ts
new file mode 100644
index 00000000..3c7d8adb
--- /dev/null
+++ b/src/features/recipes/__tests__/recipeStance.spec.ts
@@ -0,0 +1,75 @@
+// @vitest-environment node
+import { describe, expect, it } from 'vitest'
+
+import { sameName, stanceOf } from '../recipeStance'
+import type { StanceInput } from '../recipeStance'
+
+const LINK = 'https://www.marmiton.org/recettes/recette_the-tarte-au-citron-meringuee_22082.aspx'
+const base: StanceInput = {
+ isAdmin: false,
+ canEdit: undefined,
+ mineIds: undefined,
+ id: 'r1',
+ sourceUrl: null,
+ originalId: null,
+}
+
+describe('stanceOf : à qui est la recette', () => {
+ describe('un utilisateur ordinaire', () => {
+ it('suit canEdit du back, sans attendre la liste de ses recettes', () => {
+ expect(stanceOf({ ...base, canEdit: true })).toBe('mine')
+ expect(stanceOf({ ...base, canEdit: false })).toBe('other')
+ })
+
+ it('ne devient jamais la version commune, même avec le lien Marmiton et sans original', () => {
+ expect(stanceOf({ ...base, canEdit: false, sourceUrl: LINK, mineIds: [] })).toBe('other')
+ expect(stanceOf({ ...base, canEdit: true, sourceUrl: LINK, mineIds: [] })).toBe('mine')
+ })
+
+ it('sans canEdit (back plus ancien) : la liste de ses recettes, comme avant', () => {
+ expect(stanceOf(base)).toBe('unknown')
+ expect(stanceOf({ ...base, mineIds: ['r1', 'r2'] })).toBe('mine')
+ expect(stanceOf({ ...base, mineIds: ['r2'] })).toBe('other')
+ })
+ })
+
+ describe("l'administrateur (canEdit est vrai pour toute recette qu'il lit)", () => {
+ const admin: StanceInput = { ...base, isAdmin: true, canEdit: true }
+
+ it('attend la liste de ses recettes', () => {
+ expect(stanceOf(admin)).toBe('unknown')
+ })
+
+ it('la sienne', () => {
+ expect(stanceOf({ ...admin, mineIds: ['r1'] })).toBe('mine')
+ expect(stanceOf({ ...admin, mineIds: ['r1'], sourceUrl: LINK, originalId: null })).toBe(
+ 'mine',
+ )
+ })
+
+ it("celle d'un autre reste celle d'un autre", () => {
+ expect(stanceOf({ ...admin, mineIds: [] })).toBe('other')
+ })
+
+ it('la version commune : une recette Marmiton qui n’est pas la sienne ni la copie d’une autre', () => {
+ expect(stanceOf({ ...admin, mineIds: [], sourceUrl: LINK, originalId: null })).toBe('common')
+ })
+
+ it('la copie Marmiton d’un autre (elle a un original) n’est pas la version commune', () => {
+ expect(stanceOf({ ...admin, mineIds: [], sourceUrl: LINK, originalId: 'c1' })).toBe('other')
+ })
+
+ it('un back qui ne rend pas originalId ne fait pas deviner la version commune', () => {
+ expect(stanceOf({ ...admin, mineIds: [], sourceUrl: LINK, originalId: undefined })).toBe(
+ 'other',
+ )
+ })
+ })
+})
+
+describe('sameName', () => {
+ it('ignore la casse et les espaces autour', () => {
+ expect(sameName(' Tarte au citron ', 'tarte au citron')).toBe(true)
+ expect(sameName('Tarte', 'Tarte au citron')).toBe(false)
+ })
+})
diff --git a/src/features/recipes/otherRecipeGroups.ts b/src/features/recipes/otherRecipeGroups.ts
index 1c057b6b..48bcdec0 100644
--- a/src/features/recipes/otherRecipeGroups.ts
+++ b/src/features/recipes/otherRecipeGroups.ts
@@ -6,6 +6,8 @@ export interface OtherRecipe {
typeSlug: string
/** Recette copiée de Marmiton : la pastille « Marmiton ». Un back qui ne le rend pas encore : pas de pastille. */
fromMarmiton?: boolean
+ /** Seul l'administrateur le reçoit à vrai : il a déjà sa copie de cette version commune (MAR-005), « Ma copie » remplace « Copier ». */
+ alreadyCopied?: boolean
}
export interface OtherRecipeGroup {
diff --git a/src/features/recipes/ownCopy.ts b/src/features/recipes/ownCopy.ts
new file mode 100644
index 00000000..d643bee2
--- /dev/null
+++ b/src/features/recipes/ownCopy.ts
@@ -0,0 +1,64 @@
+import { computed } from 'vue'
+import { useQuery, useQueryClient } from '@tanstack/vue-query'
+import type { QueryClient } from '@tanstack/vue-query'
+
+import { unwrap } from '@/api'
+import type { ApiClient } from '@/api'
+import { useSessionStore } from '@/session/session'
+import { sameName } from './recipeStance'
+import { recipeKeys } from './queries'
+
+/** La version commune dont on cherche la copie. */
+export interface CommonRecipeRef {
+ id: string
+ name: string
+ typeSlug: string
+}
+
+/**
+ * La copie que l'administrateur a déjà faite d'une version commune (MAR-005) : le back dit seulement « tu l'as déjà » (`alreadyCopied`),
+ * pas où. On la retrouve parmi SES recettes du même type, de même nom, et on la confirme par la fiche (`originalId` est la version commune).
+ * Plusieurs de même nom : la première qui est bien une copie de celle-ci. Rien de trouvé (copie renommée ou changée de type) : `null`.
+ */
+export async function findOwnCopy(
+ queryClient: QueryClient,
+ api: ApiClient,
+ common: CommonRecipeRef,
+): Promise {
+ const list = await queryClient.fetchQuery({
+ queryKey: recipeKeys.byType(common.typeSlug),
+ queryFn: () =>
+ unwrap(
+ api.GET('/api/v1/recipe-types/{slug}/recipes', {
+ params: { path: { slug: common.typeSlug } },
+ }),
+ ),
+ staleTime: 30_000,
+ })
+
+ for (const candidate of (list.member ?? []).filter((member) =>
+ sameName(member.name, common.name),
+ )) {
+ const recipe = await queryClient.fetchQuery({
+ queryKey: recipeKeys.detail(candidate.id),
+ queryFn: () =>
+ unwrap(api.GET('/api/v1/recipes/{id}', { params: { path: { id: candidate.id } } })),
+ staleTime: 30_000,
+ })
+ if (recipe.originalId === common.id) return candidate.id
+ }
+
+ return null
+}
+
+/** Sa copie de la version commune affichée (`undefined` : pas encore su ; `null` : il n'en a pas). Désactivé pour tout autre que l'administrateur. */
+export function useOwnCopy(common: () => CommonRecipeRef | null) {
+ const { api } = useSessionStore()
+ const queryClient = useQueryClient()
+
+ return useQuery({
+ queryKey: computed(() => recipeKeys.ownCopy(common()?.id ?? '')),
+ queryFn: () => findOwnCopy(queryClient, api, common() as CommonRecipeRef),
+ enabled: computed(() => common() !== null),
+ })
+}
diff --git a/src/features/recipes/queries.ts b/src/features/recipes/queries.ts
index c664825d..36d7c134 100644
--- a/src/features/recipes/queries.ts
+++ b/src/features/recipes/queries.ts
@@ -20,6 +20,8 @@ export const recipeKeys = {
favorites: ['recipes', 'favorites'] as const,
others: (search: string) => ['recipes', 'others', search] as const,
detail: (id: string) => ['recipes', 'detail', id] as const,
+ /** La copie que l'administrateur a faite d'une version commune (MAR-005) */
+ ownCopy: (commonId: string) => ['recipes', 'own-copy', commonId] as const,
}
export function useRecipeTypes() {
@@ -203,11 +205,16 @@ export function useImportMarmitonRecipe() {
})
}
-export function useDeleteRecipe() {
+/**
+ * Supprime une recette. `silent` : l'écran dit lui-même chaque refus (la suppression de la version commune d'une recette Marmiton,
+ * MAR-005, où un 409 ouvre sa propre fenêtre) au lieu du message commun des écritures.
+ */
+export function useDeleteRecipe(silent = false) {
const { api } = useSessionStore()
const queryClient = useQueryClient()
return useMutation({
+ meta: { silent },
mutationFn: ({ id }: { id: string }) =>
unwrap(api.DELETE('/api/v1/recipes/{id}', { params: { path: { id } } })),
onSettled: () => queryClient.invalidateQueries({ queryKey: recipeKeys.all }),
diff --git a/src/features/recipes/recipeStance.ts b/src/features/recipes/recipeStance.ts
new file mode 100644
index 00000000..4c9dc522
--- /dev/null
+++ b/src/features/recipes/recipeStance.ts
@@ -0,0 +1,44 @@
+/**
+ * À qui est la recette qu'on regarde, et que peut-on en faire ? (ADR 0037 du back, amendement MAR-005)
+ * - `mine` : la sienne (Ajouter à la liste, Modifier, Supprimer) ;
+ * - `other` : celle d'un autre (Copier seulement) ;
+ * - `common` : la version commune d'une recette Marmiton, sans propriétaire, vue par l'administrateur seul, qui la corrige ou la supprime ;
+ * - `unknown` : pas encore su, la zone des actions reste vide.
+ */
+export type RecipeStance = 'mine' | 'other' | 'common' | 'unknown'
+
+export interface StanceInput {
+ isAdmin: boolean
+ /** `canEdit` de la fiche : la sienne, ou n'importe quelle recette lue par un administrateur. Absent d'un back plus ancien. */
+ canEdit: boolean | undefined
+ /** Les identifiants de SES recettes du même type, `undefined` tant que la liste n'est pas lue. */
+ mineIds: readonly string[] | undefined
+ id: string
+ /** L'adresse Marmiton de la fiche, déjà jugée par `marmitonSourceUrl` (`null` : aucune). */
+ sourceUrl: string | null
+ originalId: string | null | undefined
+}
+
+/**
+ * Pour un utilisateur ordinaire `canEdit` suffit (vrai : la sienne ; faux : celle d'un autre), sans attendre la liste de ses recettes.
+ * Pour l'administrateur il est vrai pour toute recette lue : la liste de SES recettes dit si elle est la sienne. Parmi les autres, la version
+ * commune est celle d'une recette Marmiton (`sourceUrl`) qui n'est la copie d'aucune autre (`originalId` nul) : la fiche ne dit pas plus.
+ * Un back qui ne rend pas `canEdit` : la liste seule, comme avant.
+ */
+export function stanceOf(input: StanceInput): RecipeStance {
+ if (!input.isAdmin && input.canEdit !== undefined) return input.canEdit ? 'mine' : 'other'
+ if (input.mineIds === undefined) return 'unknown'
+ if (input.mineIds.includes(input.id)) return 'mine'
+
+ return input.isAdmin &&
+ input.canEdit === true &&
+ input.sourceUrl !== null &&
+ input.originalId === null
+ ? 'common'
+ : 'other'
+}
+
+/** Même nom à la casse et aux espaces près : sert à retrouver parmi ses recettes la copie d'une recette. */
+export function sameName(a: string, b: string): boolean {
+ return a.trim().toLocaleLowerCase('fr') === b.trim().toLocaleLowerCase('fr')
+}
diff --git a/src/features/recipes/useRecipeStance.ts b/src/features/recipes/useRecipeStance.ts
new file mode 100644
index 00000000..f9c26d7d
--- /dev/null
+++ b/src/features/recipes/useRecipeStance.ts
@@ -0,0 +1,42 @@
+import { computed } from 'vue'
+
+import { useSessionStore } from '@/session/session'
+import { marmitonSourceUrl } from './marmitonSourceUrl'
+import { useRecipe, useRecipesByType } from './queries'
+import { stanceOf } from './recipeStance'
+
+/**
+ * La recette lue et à qui elle est (`recipeStance.ts`) : la fiche et l'éditeur en tirent les mêmes actions. Le détail d'une recette est
+ * lisible par tous ; `canEdit` du back dit à un utilisateur ordinaire si elle est la sienne, et pour l'administrateur (à qui il est vrai
+ * partout) on lit la liste de SES recettes du même type. Tant que ce n'est pas su : `unknown`.
+ */
+export function useRecipeStance(id: () => string) {
+ const session = useSessionStore()
+ const recipe = useRecipe(id)
+ const mine = useRecipesByType(() => recipe.data.value?.typeSlug ?? null)
+
+ // Le lien d'origine d'une recette copiée de Marmiton : lu de la réponse, jamais composé ici
+ const sourceUrl = computed(() => marmitonSourceUrl(recipe.data.value?.sourceUrl))
+ const ownership = computed(() => {
+ const data = recipe.data.value
+ if (data === undefined) return 'unknown' as const
+
+ return stanceOf({
+ isAdmin: session.isAdmin,
+ canEdit: data.canEdit,
+ mineIds: mine.data.value?.member?.map((candidate) => candidate.id),
+ id: data.id,
+ sourceUrl: sourceUrl.value,
+ originalId: data.originalId,
+ })
+ })
+
+ return {
+ recipe,
+ mine,
+ sourceUrl,
+ ownership,
+ isMine: computed(() => ownership.value === 'mine'),
+ isCommon: computed(() => ownership.value === 'common'),
+ }
+}
diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts
index b29c5898..aa36472f 100644
--- a/src/i18n/fr.ts
+++ b/src/i18n/fr.ts
@@ -383,6 +383,27 @@ export default {
copiedToast: '« {name} » est dans tes recettes.',
// Le lien d'origine d'une recette copiée de Marmiton (MAR-004) : un texte et une icône, jamais le logo du site
viewOnMarmiton: 'Voir sur Marmiton',
+ // La version commune d'une recette Marmiton (MAR-005, ADR 0037 du back) : l'administrateur la corrige ou la supprime, les autres la copient
+ common: {
+ title: 'Version commune',
+ help: 'Les prochains qui la copient auront ta version. Les copies déjà faites ne changent pas.',
+ othersHelp: 'Les recettes Marmiton sont communes. Touche-en une pour la corriger.',
+ haveCopy: 'Tu as déjà ta copie',
+ myCopy: 'Ma copie',
+ myCopyNamed: 'Ouvrir ma copie de {name}',
+ yourCopy: 'Ta copie',
+ open: 'Ouvrir la version commune',
+ openHint: 'Celle que copient les autres.',
+ editing: 'Tu modifies la version commune.',
+ saved: 'La recette Marmiton est à jour.',
+ delete: 'Supprimer cette recette',
+ deleteTitle: 'Supprimer la recette Marmiton ?',
+ deleteHelp:
+ 'Elle disparaît de « Recettes des autres ». Les copies déjà faites restent à leurs auteurs.',
+ cannotDeleteTitle: 'Suppression impossible',
+ cannotDelete:
+ 'Des listes de courses utilisent encore cette recette. Rien n’a été supprimé. Réessaie quand ces listes seront terminées.',
+ },
},
// « Nouvelle recette » ne fait que choisir : écrire la sienne ou copier celle de Marmiton (deux parcours distincts)
recipeNew: {