fix(recipes): retire l'import manuel, le planning importe seul
Correction de comportement sur la gestion des recettes de sources externes — l'implémentation précédente avait dérivé d'une lecture erronée du besoin : - Plus aucun bouton d'import nulle part. Parcourir une source (RecipesPage, hors planning) ne fait plus jamais que prévisualiser — RecipeDetailPanel n'affiche plus de lien "Importer cette recette", seulement un bouton icône discret vers la page d'origine quand la recette en a une (nouveau .recipe-detail-panel__source-link, même emplacement que l'étoile favori). - Une recette externe n'est importée dans la base qu'au moment où quelqu'un l'ajoute effectivement à son planning — jamais avant. RecipePickerDialog.handleSelectDraftItem est désormais le seul endroit de toute l'appli qui importe quoi que ce soit : cliquer sur un item pas encore importé y déclenche une tentative d'import transparente (POST /sources/.../import puis POST /planning/items), sans écran intermédiaire, dès que rien ne manque (tryBuildCompleteImport, nouveau apps/web/src/features/recipes/ recipe-import-draft.ts). Seul un ingrédient non résolu (ou une erreur réseau) fait encore basculer vers l'écran de revue existant (ImportRecipePage), pré-rempli, pour compléter ce qui manque. - RecipeSourcesPanel gagne onSelectDraftItem (remplace planningSlot, qui n'a plus de raison d'être puisqu'il n'y a plus de lien d'import à qui le transmettre) : quand ce callback est fourni (RecipePickerDialog uniquement), un item pas encore importé n'est plus prévisualisé sur place, il est remonté tel quel à l'appelant. Tests : - planning.feature : le scénario existant retire l'étape "je clique le lien Importer cette recette" (redirection désormais automatique puisque le draft de test a un ingrédient non résolu) ; nouveau scénario pour le chemin transparent (draft entièrement résolu, aucun écran de revue). - recipe-sources.feature : le scénario qui important depuis /recettes (hors planning) est supprimé — cette capacité n'existe plus hors planning. Le scénario de deep-link vérifie maintenant l'absence du bouton d'import et la présence du lien discret. - pnpm exec tsc -b --force (web) — propre. - pnpm exec biome check — propre. - pnpm --filter web build — propre. - Cypress non exécutable localement sur cette machine (crash GPU Electron connu) — scénarios vérifiés par relecture attentive contre le markup/les clés i18n réels ; CI (GitHub Actions) fera foi à l'exécution. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
4381e63045
commit
73ae8169a1
12 changed files with 376 additions and 185 deletions
|
|
@ -15,7 +15,7 @@ Feature: Adding a recipe to the planning
|
||||||
And browsing TheMealDB returns some items
|
And browsing TheMealDB returns some items
|
||||||
And the planning request reflects whatever's been added so far
|
And the planning request reflects whatever's been added so far
|
||||||
|
|
||||||
Scenario: Adds a not-yet-imported source item to a planning slot, importing it on the way
|
Scenario: Adds a not-yet-imported source item to a planning slot, landing on the review screen since an ingredient needs resolving
|
||||||
Given previewing TheMealDB item "9999" is available
|
Given previewing TheMealDB item "9999" is available
|
||||||
And importing the previewed item will succeed and return id 99
|
And importing the previewed item will succeed and return id 99
|
||||||
And adding the imported recipe to the planning will succeed
|
And adding the imported recipe to the planning will succeed
|
||||||
|
|
@ -23,7 +23,6 @@ Feature: Adding a recipe to the planning
|
||||||
And I click the add button for the first empty planning slot
|
And I click the add button for the first empty planning slot
|
||||||
And I click the button "TheMealDB"
|
And I click the button "TheMealDB"
|
||||||
And I click the source item "Fish Pie"
|
And I click the source item "Fish Pie"
|
||||||
And I click the link "Importer cette recette"
|
|
||||||
Then I should see "Cette recette sera automatiquement ajoutée à votre planning une fois importée."
|
Then I should see "Cette recette sera automatiquement ajoutée à votre planning une fois importée."
|
||||||
|
|
||||||
When I choose an ingredient for the unresolved line "some mystery paste"
|
When I choose an ingredient for the unresolved line "some mystery paste"
|
||||||
|
|
@ -34,3 +33,15 @@ Feature: Adding a recipe to the planning
|
||||||
Then the planning add request should have included recipe 99, weekDay "lundi", meal "petit-dejeuner", and portions 4
|
Then the planning add request should have included recipe 99, weekDay "lundi", meal "petit-dejeuner", and portions 4
|
||||||
And the URL should be the home page
|
And the URL should be the home page
|
||||||
And the recipe "Fish Pie" should appear in the first planning slot with 4 portions
|
And the recipe "Fish Pie" should appear in the first planning slot with 4 portions
|
||||||
|
|
||||||
|
Scenario: Adds a fully-resolved not-yet-imported item to a planning slot transparently, with no review screen at all
|
||||||
|
Given previewing TheMealDB item "7777" is fully resolved as "Ratatouille"
|
||||||
|
And importing item "7777" will succeed and return id 100
|
||||||
|
And adding recipe 100 to the planning will succeed
|
||||||
|
When I visit "/"
|
||||||
|
And I click the add button for the first empty planning slot
|
||||||
|
And I click the button "TheMealDB"
|
||||||
|
And I click the source item "Ratatouille"
|
||||||
|
Then the import request for "7777" should have included the name "Ratatouille"
|
||||||
|
And the planning add request should have included recipe 100, weekDay "lundi", meal "petit-dejeuner", and portions 4
|
||||||
|
And the URL should be the home page
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,18 @@ Given("browsing TheMealDB returns some items", () => {
|
||||||
alreadyImported: false,
|
alreadyImported: false,
|
||||||
recipeId: null,
|
recipeId: null,
|
||||||
},
|
},
|
||||||
|
// Draft's own preview ("previewing TheMealDB item ... is fully
|
||||||
|
// resolved") has nothing left for a person to fix — unlike "Fish
|
||||||
|
// Pie" above, exercises the transparent-import path instead of the
|
||||||
|
// review screen.
|
||||||
|
{
|
||||||
|
externalId: "7777",
|
||||||
|
title: "Ratatouille",
|
||||||
|
picture: null,
|
||||||
|
url: "https://www.themealdb.com/meal/7777",
|
||||||
|
alreadyImported: false,
|
||||||
|
recipeId: null,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
nextCursor: null,
|
nextCursor: null,
|
||||||
},
|
},
|
||||||
|
|
@ -89,6 +101,76 @@ Given("previewing TheMealDB item {string} is available", (externalId: string) =>
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Unlike "previewing TheMealDB item ... is available" above, every
|
||||||
|
// ingredient line here already resolved to a real ingredient/unit/quantity
|
||||||
|
// — `tryBuildCompleteImport` (RecipePickerDialog.tsx) accepts a draft
|
||||||
|
// shaped exactly like this one as-is, no review screen needed.
|
||||||
|
Given(
|
||||||
|
"previewing TheMealDB item {string} is fully resolved as {string}",
|
||||||
|
(externalId: string, name: string) => {
|
||||||
|
cy.intercept("GET", `**/sources/theMealDb/preview/${externalId}`, {
|
||||||
|
statusCode: 200,
|
||||||
|
body: {
|
||||||
|
sourceKey: "theMealDb",
|
||||||
|
externalId,
|
||||||
|
name,
|
||||||
|
description: null,
|
||||||
|
picture: null,
|
||||||
|
portions: 4,
|
||||||
|
sourceUrl: `https://www.themealdb.com/meal/${externalId}`,
|
||||||
|
ingredients: [
|
||||||
|
{
|
||||||
|
rawText: "1 onion",
|
||||||
|
quantity: 1,
|
||||||
|
ingredient: {
|
||||||
|
id: 1,
|
||||||
|
key: "onion",
|
||||||
|
icon: "VEGETABLE",
|
||||||
|
category: "freshProduce",
|
||||||
|
subcategory: "vegetables",
|
||||||
|
reproducible: false,
|
||||||
|
allergens: [],
|
||||||
|
diets: [],
|
||||||
|
},
|
||||||
|
unit: { id: 1, key: "piece", type: "COUNT", toBaseFactor: 1 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
steps: [{ description: "Cuire à la poêle.", picture: null, techSteps: [] }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Given(
|
||||||
|
"importing item {string} will succeed and return id {int}",
|
||||||
|
(externalId: string, id: number) => {
|
||||||
|
cy.intercept("POST", `**/sources/theMealDb/import/${externalId}`, {
|
||||||
|
statusCode: 201,
|
||||||
|
body: { id },
|
||||||
|
}).as("importItem");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Given("adding recipe {int} to the planning will succeed", (recipeId: number) => {
|
||||||
|
cy.intercept("POST", "**/planning/items", {
|
||||||
|
statusCode: 201,
|
||||||
|
body: {
|
||||||
|
id: 2,
|
||||||
|
weekDay: "lundi",
|
||||||
|
meal: "petit-dejeuner",
|
||||||
|
portions: 4,
|
||||||
|
recipe: { id: recipeId, name: "Ratatouille" },
|
||||||
|
},
|
||||||
|
}).as("addPlanningItem");
|
||||||
|
});
|
||||||
|
|
||||||
|
Then(
|
||||||
|
"the import request for {string} should have included the name {string}",
|
||||||
|
(_externalId: string, name: string) => {
|
||||||
|
cy.wait("@importItem").its("request.body.name").should("eq", name);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Covers every reference catalog both `RecipePickerDialog` (ingredients/
|
// Covers every reference catalog both `RecipePickerDialog` (ingredients/
|
||||||
// diets, for its own filters) and `ImportRecipePage` (ingredients/diets/
|
// diets, for its own filters) and `ImportRecipePage` (ingredients/diets/
|
||||||
// units, for the review form) fetch — same endpoints, one fixture for both.
|
// units, for the review form) fetch — same endpoints, one fixture for both.
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ Feature: Browsing external recipe sources
|
||||||
And the recipe detail panel heading should be "Fish Pie"
|
And the recipe detail panel heading should be "Fish Pie"
|
||||||
And I should see the highlighted technique "Cuire"
|
And I should see the highlighted technique "Cuire"
|
||||||
|
|
||||||
Scenario: Deep-links straight to a not-yet-imported item's own page
|
Scenario: Deep-links straight to a not-yet-imported item's own page, with no import affordance at all
|
||||||
Given the recipe catalog contains nothing
|
Given the recipe catalog contains nothing
|
||||||
And the sources reference list has options
|
And the sources reference list has options
|
||||||
And the household has enabled TheMealDB
|
And the household has enabled TheMealDB
|
||||||
|
|
@ -53,31 +53,5 @@ Feature: Browsing external recipe sources
|
||||||
And previewing TheMealDB item "9999" is available
|
And previewing TheMealDB item "9999" is available
|
||||||
When I visit "/recettes/sources/theMealDb/9999"
|
When I visit "/recettes/sources/theMealDb/9999"
|
||||||
Then the recipe detail panel heading should be "Fish Pie"
|
Then the recipe detail panel heading should be "Fish Pie"
|
||||||
And I should see "Importer cette recette"
|
And I should not see "Importer cette recette"
|
||||||
|
And I should see a discreet link to the item's original page
|
||||||
Scenario: Reviews an import, resolving an unrecognized ingredient before confirming
|
|
||||||
Given the recipe catalog contains nothing
|
|
||||||
And the sources reference list has options
|
|
||||||
And the household has enabled TheMealDB
|
|
||||||
And browsing TheMealDB returns some items
|
|
||||||
And previewing TheMealDB item "9999" is available
|
|
||||||
And the ingredient and diet catalog is available for import
|
|
||||||
And importing the previewed item will succeed and return id 99
|
|
||||||
When I visit "/recettes"
|
|
||||||
And I click the button "TheMealDB"
|
|
||||||
And I click the source item "Fish Pie"
|
|
||||||
And I click the link "Importer cette recette"
|
|
||||||
Then the "recipe-name" field should have the value "Fish Pie"
|
|
||||||
And the recipe should include the ingredient "Oignon"
|
|
||||||
|
|
||||||
When I choose an ingredient for the unresolved line "some mystery paste"
|
|
||||||
And I select the ingredient "Sel" from the picker
|
|
||||||
Then the unresolved ingredients section should no longer be shown
|
|
||||||
And there should be 2 ingredient rows
|
|
||||||
|
|
||||||
When I select unit "unité" for the first ingredient
|
|
||||||
And I fill in the last ingredient's quantity with "1" and unit "unité"
|
|
||||||
Then the "Importer" button should not be disabled
|
|
||||||
When I click the button "Importer"
|
|
||||||
Then the import request should have included ingredient 2 with quantity 1 and unitId 1
|
|
||||||
And the URL should include "/recettes/99"
|
|
||||||
|
|
|
||||||
|
|
@ -135,66 +135,9 @@ Then("the source item {string} should be marked as already imported", (title: st
|
||||||
cy.contains("tr", title).find(".source-item-table__imported-badge").should("be.visible");
|
cy.contains("tr", title).find(".source-item-table__imported-badge").should("be.visible");
|
||||||
});
|
});
|
||||||
|
|
||||||
// ImportRecipePage (the review screen) loads its own ingredient/diet/unit
|
// Browsing a source (outside of adding-to-planning, `RecipePickerDialog`'s
|
||||||
// catalogs the same way RecipeFormPage does — "onion" matches the resolved
|
// own scenarios in planning.ts) never imports anything — the only action
|
||||||
// line in "previewing TheMealDB item ... is available" above, "salt" is
|
// this preview offers is a discreet way out to the item's own page.
|
||||||
// what "some mystery paste" (unresolved in that same fixture) gets
|
Then("I should see a discreet link to the item's original page", () => {
|
||||||
// corrected to in the review-and-import scenario.
|
cy.get(".recipe-detail-panel__source-link").should("be.visible");
|
||||||
Given("the ingredient and diet catalog is available for import", () => {
|
|
||||||
cy.intercept("GET", "**/reference/ingredients", {
|
|
||||||
statusCode: 200,
|
|
||||||
body: [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
key: "onion",
|
|
||||||
icon: "VEGETABLE",
|
|
||||||
category: "freshProduce",
|
|
||||||
subcategory: "vegetables",
|
|
||||||
allergens: [],
|
|
||||||
diets: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
key: "salt",
|
|
||||||
icon: "SPICE",
|
|
||||||
category: "condimentsAndSpices",
|
|
||||||
subcategory: "spices",
|
|
||||||
allergens: [],
|
|
||||||
diets: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
cy.intercept("GET", "**/reference/diets", {
|
|
||||||
statusCode: 200,
|
|
||||||
body: [{ id: 1, key: "omnivore" }],
|
|
||||||
});
|
|
||||||
cy.intercept("GET", "**/reference/units", {
|
|
||||||
statusCode: 200,
|
|
||||||
body: [{ id: 1, key: "piece", type: "COUNT", toBaseFactor: 1 }],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Given("importing the previewed item will succeed and return id {int}", (id: number) => {
|
|
||||||
cy.intercept("POST", "**/sources/theMealDb/import/9999", { statusCode: 201, body: { id } }).as(
|
|
||||||
"importRecipe",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
When("I choose an ingredient for the unresolved line {string}", (rawText: string) => {
|
|
||||||
cy.contains(".import-recipe__unresolved-row", rawText)
|
|
||||||
.contains("button", "Choisir un ingrédient")
|
|
||||||
.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
Then("the unresolved ingredients section should no longer be shown", () => {
|
|
||||||
cy.get(".import-recipe__unresolved").should("not.exist");
|
|
||||||
});
|
|
||||||
|
|
||||||
Then(
|
|
||||||
"the import request should have included ingredient {int} with quantity {int} and unitId {int}",
|
|
||||||
(ingredientId: number, quantity: number, unitId: number) => {
|
|
||||||
cy.wait("@importRecipe")
|
|
||||||
.its("request.body.ingredients")
|
|
||||||
.should("include.deep.members", [{ ingredientId, quantity, unitId }]);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
import {
|
import {
|
||||||
|
type BrowsableSourceItemView,
|
||||||
type DietView,
|
type DietView,
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
type IngredientView,
|
type IngredientView,
|
||||||
type Meal,
|
type Meal,
|
||||||
type PlanningItemView,
|
type PlanningItemView,
|
||||||
type RecipeSummaryView,
|
type RecipeSummaryView,
|
||||||
|
type RecipeView,
|
||||||
type WeekDay,
|
type WeekDay,
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import { ApiError, apiClient } from "../../api/client";
|
import { ApiError, apiClient } from "../../api/client";
|
||||||
import { CheckboxOption } from "../../components/ui/Checkbox";
|
import { CheckboxOption } from "../../components/ui/Checkbox";
|
||||||
import { Dialog } from "../../components/ui/Dialog";
|
import { Dialog } from "../../components/ui/Dialog";
|
||||||
|
|
@ -23,6 +26,7 @@ import {
|
||||||
isSourceTab,
|
isSourceTab,
|
||||||
parseSourceTabValue,
|
parseSourceTabValue,
|
||||||
} from "../recipes/RecipeTabs";
|
} from "../recipes/RecipeTabs";
|
||||||
|
import { tryBuildCompleteImport } from "../recipes/recipe-import-draft";
|
||||||
import { useEnabledSources } from "../recipes/useEnabledSources";
|
import { useEnabledSources } from "../recipes/useEnabledSources";
|
||||||
import "./recipe-picker-dialog.scss";
|
import "./recipe-picker-dialog.scss";
|
||||||
|
|
||||||
|
|
@ -55,13 +59,12 @@ export interface PlanningSlot {
|
||||||
* (ingredients / regime / "convient à tout le foyer" toggle, all wired to
|
* (ingredients / regime / "convient à tout le foyer" toggle, all wired to
|
||||||
* `GET /recipes`'s corresponding query params) since browsing here is
|
* `GET /recipes`'s corresponding query params) since browsing here is
|
||||||
* about finding something to cook, not just looking something up. Each
|
* about finding something to cook, not just looking something up. Each
|
||||||
* household-enabled source's own tab is included too (unlike an earlier
|
* household-enabled source's own tab is included too: picking an
|
||||||
* version of this dialog — see `ImportRecipePage`'s `planningSlot`, the
|
* already-imported item behaves exactly like picking a regular recipe,
|
||||||
* review/import flow that made including them here worthwhile): picking
|
* and picking one that isn't imported yet is what actually imports it —
|
||||||
* an already-imported item behaves exactly like picking a regular recipe,
|
* nowhere else in the app does (see `handleSelectDraftItem`) — since a
|
||||||
* and picking one that isn't imported yet hands off to that review
|
* source item only ever becomes a real, saved `Recipe` as a side effect of
|
||||||
* screen, which adds the freshly-created recipe straight to this slot
|
* someone adding it to their planning.
|
||||||
* once it's saved.
|
|
||||||
*
|
*
|
||||||
* Mounted only while open (see `PlanningPage`, same conditional-mount
|
* Mounted only while open (see `PlanningPage`, same conditional-mount
|
||||||
* convention as its own `CalendarPopover`) — every piece of local state
|
* convention as its own `CalendarPopover`) — every piece of local state
|
||||||
|
|
@ -71,10 +74,14 @@ export interface PlanningSlot {
|
||||||
* Selecting a row doesn't navigate anywhere (unlike `RecipesPage`'s own
|
* Selecting a row doesn't navigate anywhere (unlike `RecipesPage`'s own
|
||||||
* use of `RecipeTable`) — it switches this same dialog to a small
|
* use of `RecipeTable`) — it switches this same dialog to a small
|
||||||
* "how many portions?" confirmation step, then calls `POST
|
* "how many portions?" confirmation step, then calls `POST
|
||||||
* /planning/items` on submit. The one exception is picking a not-yet-
|
* /planning/items` on submit. Picking a not-yet-imported source item is
|
||||||
* imported source item, which does navigate away entirely (to
|
* handled differently still (`handleSelectDraftItem`): when the draft has
|
||||||
* `/recettes/importer/...`) — that flow has its own portions field
|
* everything a real recipe needs, it's imported and added to this slot
|
||||||
* already, on the review screen itself.
|
* transparently — no extra screen, same as picking anything else. Only
|
||||||
|
* when something's actually missing (an ingredient the automatic matcher
|
||||||
|
* couldn't resolve, say) does this navigate away entirely, to the review
|
||||||
|
* screen (`/recettes/importer/...`), which has its own portions field
|
||||||
|
* already.
|
||||||
*/
|
*/
|
||||||
export function RecipePickerDialog({
|
export function RecipePickerDialog({
|
||||||
slot,
|
slot,
|
||||||
|
|
@ -86,6 +93,7 @@ export function RecipePickerDialog({
|
||||||
onAdded: (item: PlanningItemView) => void;
|
onAdded: (item: PlanningItemView) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<RecipesPageTab>("favoris");
|
const [activeTab, setActiveTab] = useState<RecipesPageTab>("favoris");
|
||||||
const activeSourceKey = parseSourceTabValue(activeTab);
|
const activeSourceKey = parseSourceTabValue(activeTab);
|
||||||
|
|
@ -106,6 +114,12 @@ export function RecipePickerDialog({
|
||||||
// recipe was deleted between the browse fetch and the click), surfaced
|
// recipe was deleted between the browse fetch and the click), surfaced
|
||||||
// the same way any other catalog load error is on this dialog.
|
// the same way any other catalog load error is on this dialog.
|
||||||
const [sourceSelectError, setSourceSelectError] = useState(false);
|
const [sourceSelectError, setSourceSelectError] = useState(false);
|
||||||
|
// True while `handleSelectDraftItem` below is resolving a not-yet-
|
||||||
|
// imported item's transparent-import attempt — replaces the source tab's
|
||||||
|
// whole panel with a status message for that brief window (fetch the
|
||||||
|
// draft, maybe import it, maybe add it to the slot) rather than leaving
|
||||||
|
// the browse list clickable mid-flight.
|
||||||
|
const [isAddingDraft, setIsAddingDraft] = useState(false);
|
||||||
|
|
||||||
// The recipe picked in step 1 — `null` while still browsing, set once a
|
// The recipe picked in step 1 — `null` while still browsing, set once a
|
||||||
// row is clicked to switch this dialog into its confirmation step.
|
// row is clicked to switch this dialog into its confirmation step.
|
||||||
|
|
@ -180,6 +194,74 @@ export function RecipePickerDialog({
|
||||||
.catch(() => setSourceSelectError(true));
|
.catch(() => setSourceSelectError(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Navigates away to the full review/creation screen, pre-filled from this exact item and carrying `slot` along so a successful import there adds straight to it — the fallback `handleSelectDraftItem` below takes whenever a transparent import isn't possible. */
|
||||||
|
function goToReviewScreen(sourceKey: string, externalId: string) {
|
||||||
|
navigate(
|
||||||
|
`/recettes/importer/${sourceKey}/${encodeURIComponent(externalId)}` +
|
||||||
|
`?planningDate=${slot.date}&planningWeekDay=${slot.weekDay}&planningMeal=${slot.meal}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picking a not-yet-imported source item — the one action in the whole
|
||||||
|
* app that actually imports one (see this component's own doc comment).
|
||||||
|
* Fetches its full draft, and when {@link tryBuildCompleteImport} finds
|
||||||
|
* nothing missing, imports it and adds it to `slot` transparently: no
|
||||||
|
* extra screen, same end result as picking any other recipe. Anything
|
||||||
|
* short of that — an unresolved ingredient, a network hiccup on any of
|
||||||
|
* these three calls — falls back to the full review screen
|
||||||
|
* (`goToReviewScreen`) instead, since only a person can supply what's
|
||||||
|
* actually missing.
|
||||||
|
*/
|
||||||
|
async function handleSelectDraftItem(item: BrowsableSourceItemView) {
|
||||||
|
if (activeSourceKey === null) return;
|
||||||
|
const sourceKey = activeSourceKey;
|
||||||
|
setSourceSelectError(false);
|
||||||
|
setIsAddingDraft(true);
|
||||||
|
|
||||||
|
let payload: ReturnType<typeof tryBuildCompleteImport>;
|
||||||
|
try {
|
||||||
|
payload = tryBuildCompleteImport(
|
||||||
|
await apiClient.previewSourceItem(sourceKey, item.externalId),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
goToReviewScreen(sourceKey, item.externalId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!payload) {
|
||||||
|
setIsAddingDraft(false);
|
||||||
|
goToReviewScreen(sourceKey, item.externalId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let saved: RecipeView;
|
||||||
|
try {
|
||||||
|
saved = await apiClient.importSourceItem(sourceKey, item.externalId, payload);
|
||||||
|
} catch {
|
||||||
|
setIsAddingDraft(false);
|
||||||
|
goToReviewScreen(sourceKey, item.externalId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const planningItem = await apiClient.addPlanningItem({
|
||||||
|
date: slot.date,
|
||||||
|
weekDay: slot.weekDay,
|
||||||
|
meal: slot.meal,
|
||||||
|
recipeId: saved.id,
|
||||||
|
portions: payload.portions,
|
||||||
|
});
|
||||||
|
onAdded(planningItem);
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
// The recipe itself is already saved at this point — only adding it
|
||||||
|
// to this slot failed. Land on its own page rather than retrying the
|
||||||
|
// whole import through the review form (same fallback
|
||||||
|
// `ImportRecipePage`'s own submit takes for the identical failure).
|
||||||
|
navigate(`/recettes/${saved.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleConfirm() {
|
async function handleConfirm() {
|
||||||
if (!selectedRecipe) return;
|
if (!selectedRecipe) return;
|
||||||
const parsedPortions = Number(portions);
|
const parsedPortions = Number(portions);
|
||||||
|
|
@ -319,12 +401,16 @@ export function RecipePickerDialog({
|
||||||
{t("common.loadError")}
|
{t("common.loadError")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<RecipeSourcesPanel
|
{isAddingDraft ? (
|
||||||
key={activeSourceKey}
|
<p className="recipes-page__status">{t("planning.picker.addingDraft")}</p>
|
||||||
sourceKey={activeSourceKey}
|
) : (
|
||||||
planningSlot={slot}
|
<RecipeSourcesPanel
|
||||||
onSelectImportedRecipe={handleSelectImportedRecipe}
|
key={activeSourceKey}
|
||||||
/>
|
sourceKey={activeSourceKey}
|
||||||
|
onSelectImportedRecipe={handleSelectImportedRecipe}
|
||||||
|
onSelectDraftItem={handleSelectDraftItem}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,9 @@
|
||||||
import {
|
import { ErrorCode, type RecipeImportDraftView, type RecipeView } from "@batch-cooking/shared";
|
||||||
ErrorCode,
|
|
||||||
type Meal,
|
|
||||||
type RecipeImportDraftView,
|
|
||||||
type RecipeView,
|
|
||||||
type WeekDay,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { ApiError, apiClient } from "../../api/client";
|
import { ApiError, apiClient } from "../../api/client";
|
||||||
|
import { SourceLinkIcon } from "../../layouts/nav-icons";
|
||||||
import { errorMessageService } from "../../services/error-message.service";
|
import { errorMessageService } from "../../services/error-message.service";
|
||||||
import { AllergenBadges } from "./AllergenBadges";
|
import { AllergenBadges } from "./AllergenBadges";
|
||||||
import { FavoriteStarButton } from "./FavoriteStarButton";
|
import { FavoriteStarButton } from "./FavoriteStarButton";
|
||||||
|
|
@ -20,11 +15,14 @@ import "./recipes.scss";
|
||||||
* is distinct from `"not-found"` (a selected id that turned out invalid/
|
* is distinct from `"not-found"` (a selected id that turned out invalid/
|
||||||
* inaccessible), each with its own message. `"loaded-draft"` is the one
|
* inaccessible), each with its own message. `"loaded-draft"` is the one
|
||||||
* variant that isn't a real, saved `Recipe`: a not-yet-imported source
|
* variant that isn't a real, saved `Recipe`: a not-yet-imported source
|
||||||
* item's preview (`RecipeSourcesPanel`'s "Sources" tab) — rendered through
|
* item's read-only preview (`RecipeSourcesPanel`'s source tabs) — rendered
|
||||||
* this exact same component so viewing one looks and behaves like viewing
|
* through this exact same component so viewing one looks like viewing any
|
||||||
* any other recipe ("comme si c'était importé"), differing only in which
|
* other recipe, minus every action that doesn't make sense on something
|
||||||
* actions make sense (there's nothing to favorite/edit/delete yet, but
|
* that isn't saved yet (favorite/edit/delete — nor a manual "import"
|
||||||
* there is something to *import*).
|
* button: a source item only ever gets saved as a side effect of adding it
|
||||||
|
* to a planning slot, see `RecipePickerDialog`'s `handleSelectDraftItem`,
|
||||||
|
* never from this preview). The one action this state does offer is a
|
||||||
|
* discreet link to the item's original page, if it has one.
|
||||||
*/
|
*/
|
||||||
export type RecipeDetailState =
|
export type RecipeDetailState =
|
||||||
| { status: "empty" }
|
| { status: "empty" }
|
||||||
|
|
@ -50,20 +48,11 @@ export function RecipeDetailPanel({
|
||||||
dislikedIngredientIds = [],
|
dislikedIngredientIds = [],
|
||||||
onFavoriteToggled,
|
onFavoriteToggled,
|
||||||
onDeleted,
|
onDeleted,
|
||||||
planningSlot,
|
|
||||||
}: {
|
}: {
|
||||||
state: RecipeDetailState;
|
state: RecipeDetailState;
|
||||||
dislikedIngredientIds?: number[];
|
dislikedIngredientIds?: number[];
|
||||||
onFavoriteToggled?: (recipeId: number, isFavorite: boolean) => void;
|
onFavoriteToggled?: (recipeId: number, isFavorite: boolean) => void;
|
||||||
onDeleted?: (recipeId: number) => void;
|
onDeleted?: (recipeId: number) => void;
|
||||||
/**
|
|
||||||
* Set only when this panel is rendered from `RecipePickerDialog` (adding a
|
|
||||||
* recipe to one planning slot) — carried along on a `"loaded-draft"`
|
|
||||||
* item's "Importer cette recette" link as query params, so `ImportRecipePage`
|
|
||||||
* knows to add the freshly-created recipe to this exact slot once the
|
|
||||||
* import succeeds. See `ImportRecipePage`'s own `planningSlot`.
|
|
||||||
*/
|
|
||||||
planningSlot?: { date: string; weekDay: WeekDay; meal: Meal };
|
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
|
@ -108,6 +97,18 @@ export function RecipeDetailPanel({
|
||||||
<div className="recipe-detail-panel__photo" aria-hidden="true">
|
<div className="recipe-detail-panel__photo" aria-hidden="true">
|
||||||
{draft.picture ? <img src={draft.picture} alt="" /> : "🍽️"}
|
{draft.picture ? <img src={draft.picture} alt="" /> : "🍽️"}
|
||||||
</div>
|
</div>
|
||||||
|
{draft.sourceUrl && (
|
||||||
|
<a
|
||||||
|
href={draft.sourceUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="recipe-detail-panel__source-link"
|
||||||
|
title={t("recipes.sources.detail.viewSource")}
|
||||||
|
aria-label={t("recipes.sources.detail.viewSource")}
|
||||||
|
>
|
||||||
|
<SourceLinkIcon aria-hidden="true" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="recipe-detail-panel__title-row">
|
<div className="recipe-detail-panel__title-row">
|
||||||
|
|
@ -121,28 +122,6 @@ export function RecipeDetailPanel({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="recipe-detail-panel__actions">
|
|
||||||
<Link
|
|
||||||
to={{
|
|
||||||
pathname: `/recettes/importer/${draft.sourceKey}/${encodeURIComponent(draft.externalId)}`,
|
|
||||||
search: planningSlot
|
|
||||||
? `?planningDate=${planningSlot.date}&planningWeekDay=${planningSlot.weekDay}&planningMeal=${planningSlot.meal}`
|
|
||||||
: undefined,
|
|
||||||
}}
|
|
||||||
className="recipes-page__new-button"
|
|
||||||
>
|
|
||||||
{t("recipes.sources.detail.importButton")}
|
|
||||||
</Link>
|
|
||||||
<a
|
|
||||||
href={draft.sourceUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="recipes-page__new-button"
|
|
||||||
>
|
|
||||||
{t("recipes.sources.detail.viewSource")}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{draft.description && (
|
{draft.description && (
|
||||||
<section className="recipe-detail-panel__section recipe-detail-panel__section--description">
|
<section className="recipe-detail-panel__section recipe-detail-panel__section--description">
|
||||||
<p className="recipe-detail-panel__description">{draft.description}</p>
|
<p className="recipe-detail-panel__description">{draft.description}</p>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { BrowsableSourceItemView, Meal, WeekDay } from "@batch-cooking/shared";
|
import type { BrowsableSourceItemView } from "@batch-cooking/shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { apiClient } from "../../api/client";
|
import { apiClient } from "../../api/client";
|
||||||
|
|
@ -40,8 +40,9 @@ type BrowseState =
|
||||||
* The right-hand preview reuses `RecipeDetailPanel` itself (its
|
* The right-hand preview reuses `RecipeDetailPanel` itself (its
|
||||||
* `"loaded-draft"` state) rather than a separate component — viewing a
|
* `"loaded-draft"` state) rather than a separate component — viewing a
|
||||||
* not-yet-imported item is meant to look and feel exactly like viewing any
|
* not-yet-imported item is meant to look and feel exactly like viewing any
|
||||||
* other recipe, differing only in which actions are offered (there's an
|
* other recipe, minus the actions that don't apply to something that isn't
|
||||||
* "Importer" button where Modifier/Supprimer would be).
|
* saved yet. This is `RecipesPage`'s own mode: browsing/reading only,
|
||||||
|
* nothing here ever imports anything (see `onSelectDraftItem` below).
|
||||||
*
|
*
|
||||||
* Selecting an already-imported item navigates straight to its real
|
* Selecting an already-imported item navigates straight to its real
|
||||||
* recipe (`/recettes/:id`, leaving this tab) — `onSelectImportedRecipe`
|
* recipe (`/recettes/:id`, leaving this tab) — `onSelectImportedRecipe`
|
||||||
|
|
@ -58,20 +59,29 @@ type BrowseState =
|
||||||
* (`/recettes/sources/:sourceKey/:externalId`) without this panel needing
|
* (`/recettes/sources/:sourceKey/:externalId`) without this panel needing
|
||||||
* to know anything about routing itself — it reports selection changes
|
* to know anything about routing itself — it reports selection changes
|
||||||
* upward, and re-previews on mount/prop-change if handed one back.
|
* upward, and re-previews on mount/prop-change if handed one back.
|
||||||
* `RecipePickerDialog` leaves both unset: previewing inside that modal has
|
* `RecipePickerDialog` leaves both unset: it never previews a not-yet-
|
||||||
* no URL of its own to keep in sync.
|
* imported item inline at all (see `onSelectDraftItem`).
|
||||||
*/
|
*/
|
||||||
export function RecipeSourcesPanel({
|
export function RecipeSourcesPanel({
|
||||||
sourceKey,
|
sourceKey,
|
||||||
onSelectImportedRecipe,
|
onSelectImportedRecipe,
|
||||||
planningSlot,
|
onSelectDraftItem,
|
||||||
initialSelection,
|
initialSelection,
|
||||||
onItemSelected,
|
onItemSelected,
|
||||||
}: {
|
}: {
|
||||||
sourceKey: string;
|
sourceKey: string;
|
||||||
onSelectImportedRecipe: (recipeId: number) => void;
|
onSelectImportedRecipe: (recipeId: number) => void;
|
||||||
/** Forwarded as-is to `RecipeDetailPanel` — see its own doc comment. Only ever set by `RecipePickerDialog`. */
|
/**
|
||||||
planningSlot?: { date: string; weekDay: WeekDay; meal: Meal };
|
* Set only by `RecipePickerDialog` — picking a not-yet-imported item
|
||||||
|
* while adding to a planning slot isn't something to preview inline
|
||||||
|
* here at all (there's no "import" button on that preview to lead
|
||||||
|
* anywhere any more — see `RecipeDetailPanel`'s own doc comment). When
|
||||||
|
* set, a not-yet-imported row's click hands the item straight to this
|
||||||
|
* instead of previewing it, and the caller takes it from there
|
||||||
|
* (`RecipePickerDialog.handleSelectDraftItem`: import transparently when
|
||||||
|
* nothing's missing, otherwise hand off to the review screen).
|
||||||
|
*/
|
||||||
|
onSelectDraftItem?: (item: BrowsableSourceItemView) => void;
|
||||||
initialSelection?: SourceItemSelection;
|
initialSelection?: SourceItemSelection;
|
||||||
onItemSelected?: (item: SourceItemSelection | null) => void;
|
onItemSelected?: (item: SourceItemSelection | null) => void;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -168,6 +178,10 @@ export function RecipeSourcesPanel({
|
||||||
onSelectImportedRecipe(item.recipeId);
|
onSelectImportedRecipe(item.recipeId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (onSelectDraftItem) {
|
||||||
|
onSelectDraftItem(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const selection = { sourceKey, externalId: item.externalId };
|
const selection = { sourceKey, externalId: item.externalId };
|
||||||
setSelectedExternalId(item.externalId);
|
setSelectedExternalId(item.externalId);
|
||||||
setPreviewState({ status: "loading" });
|
setPreviewState({ status: "loading" });
|
||||||
|
|
@ -221,7 +235,7 @@ export function RecipeSourcesPanel({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<RecipeDetailPanel state={previewState} planningSlot={planningSlot} />
|
<RecipeDetailPanel state={previewState} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
60
apps/web/src/features/recipes/recipe-import-draft.ts
Normal file
60
apps/web/src/features/recipes/recipe-import-draft.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import {
|
||||||
|
type CreateRecipeInput,
|
||||||
|
type RecipeImportDraftView,
|
||||||
|
createRecipeSchema,
|
||||||
|
} from "@batch-cooking/shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to turn `draft` straight into a submittable {@link CreateRecipeInput}
|
||||||
|
* — no form, no person involved — for the planning picker's transparent
|
||||||
|
* import path (`RecipePickerDialog`'s `handleSelectDraftItem`): adding a
|
||||||
|
* not-yet-imported source item to a planning slot should just work,
|
||||||
|
* silently, whenever nothing about it actually needs a human's judgment
|
||||||
|
* call. Returns `null` the moment anything does — an ingredient line the
|
||||||
|
* automatic matcher (`ingredient-matcher.ts`) couldn't resolve to a real
|
||||||
|
* ingredient/unit/quantity, or a missing portions count — so the caller can
|
||||||
|
* fall back to the full review screen (`ImportRecipePage`), pre-filled from
|
||||||
|
* this exact same draft, for a person to fill in what's missing.
|
||||||
|
*
|
||||||
|
* Default `visibility`/`dietIds` mirror what a person would otherwise leave
|
||||||
|
* untouched on that same form (`PERSONAL`, no diet tags) — nothing here is
|
||||||
|
* a guess about data the draft doesn't have an opinion on.
|
||||||
|
* `createRecipeSchema.safeParse` is still the actual authority on whether
|
||||||
|
* the result is submittable (a positive-portions check, string lengths,
|
||||||
|
* etc.) — the checks above it exist only for what a schema alone can't
|
||||||
|
* catch: `ingredient`/`unit` being resolved references, not just present
|
||||||
|
* values.
|
||||||
|
*/
|
||||||
|
export function tryBuildCompleteImport(draft: RecipeImportDraftView): CreateRecipeInput | null {
|
||||||
|
if (draft.portions === null) return null;
|
||||||
|
if (draft.ingredients.length === 0) return null;
|
||||||
|
if (
|
||||||
|
draft.ingredients.some(
|
||||||
|
(line) => line.ingredient === null || line.unit === null || line.quantity === null,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate: CreateRecipeInput = {
|
||||||
|
name: draft.name,
|
||||||
|
description: draft.description,
|
||||||
|
picture: draft.picture,
|
||||||
|
portions: draft.portions,
|
||||||
|
visibility: "PERSONAL",
|
||||||
|
dietIds: [],
|
||||||
|
ingredients: draft.ingredients.map((line) => ({
|
||||||
|
// Every line is fully resolved by this point — guarded above.
|
||||||
|
ingredientId: (line.ingredient as NonNullable<typeof line.ingredient>).id,
|
||||||
|
quantity: line.quantity as number,
|
||||||
|
unitId: (line.unit as NonNullable<typeof line.unit>).id,
|
||||||
|
})),
|
||||||
|
steps: draft.steps.map((step) => ({
|
||||||
|
description: step.description,
|
||||||
|
picture: step.picture,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = createRecipeSchema.safeParse(candidate);
|
||||||
|
return result.success ? result.data : null;
|
||||||
|
}
|
||||||
|
|
@ -674,6 +674,42 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- External source link (draft preview header) ----------------------------
|
||||||
|
// Same overlay slot/sizing as `.favorite-star-button` above — a draft
|
||||||
|
// preview never has both (nothing to favorite yet), so the two never
|
||||||
|
// compete for the corner. Deliberately muted/small ("discret" per the
|
||||||
|
// product decision this button follows): a way out to the original page,
|
||||||
|
// not a call to action the way the buttons it replaced were.
|
||||||
|
.recipe-detail-panel__source-link {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--space-sm);
|
||||||
|
right: var(--space-sm);
|
||||||
|
width: 2.2rem;
|
||||||
|
height: 2.2rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: color-mix(in srgb, var(--color-surface) 82%, transparent);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
transition:
|
||||||
|
transform 0.12s ease,
|
||||||
|
color 0.12s ease;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
color: var(--color-primary);
|
||||||
|
transform: scale(1.08);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Recipe form (create/edit) ----------------------------------------------
|
// --- Recipe form (create/edit) ----------------------------------------------
|
||||||
// Per-field validation and whole-form error messages — same small rules as
|
// Per-field validation and whole-form error messages — same small rules as
|
||||||
// auth-form.scss/profile-forms.scss, redeclared here rather than shared
|
// auth-form.scss/profile-forms.scss, redeclared here rather than shared
|
||||||
|
|
|
||||||
|
|
@ -26,4 +26,5 @@ export {
|
||||||
Star as FavoriteIcon,
|
Star as FavoriteIcon,
|
||||||
Globe as PublicIcon,
|
Globe as PublicIcon,
|
||||||
Rss as SourcesIcon,
|
Rss as SourcesIcon,
|
||||||
|
ExternalLink as SourceLinkIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
|
||||||
|
|
@ -142,7 +142,8 @@
|
||||||
"portionsLabel": "Nombre de portions",
|
"portionsLabel": "Nombre de portions",
|
||||||
"backButton": "Retour",
|
"backButton": "Retour",
|
||||||
"confirmButton": "Ajouter au planning",
|
"confirmButton": "Ajouter au planning",
|
||||||
"adding": "Ajout…"
|
"adding": "Ajout…",
|
||||||
|
"addingDraft": "Ajout au planning…"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
|
|
@ -177,8 +178,7 @@
|
||||||
"loading": "Chargement…",
|
"loading": "Chargement…",
|
||||||
"loadError": "Impossible de charger cette source pour le moment.",
|
"loadError": "Impossible de charger cette source pour le moment.",
|
||||||
"detail": {
|
"detail": {
|
||||||
"viewSource": "Voir sur le site d'origine",
|
"viewSource": "Voir sur le site d'origine"
|
||||||
"importButton": "Importer cette recette"
|
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"title": "Revoir l'import",
|
"title": "Revoir l'import",
|
||||||
|
|
|
||||||
|
|
@ -72,33 +72,38 @@ function parsePlanningSlot(
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Review screen for finalizing an import — routed at
|
* Review screen for finalizing an import — routed at
|
||||||
* `/recettes/importer/:sourceKey/:externalId` (reached from
|
* `/recettes/importer/:sourceKey/:externalId`. Reached only one way now:
|
||||||
* `RecipeDetailPanel`'s "Importer cette recette" button, shown for its
|
* `RecipePickerDialog.handleSelectDraftItem`'s fallback, when picking a
|
||||||
* `"loaded-draft"` state). Pre-filled
|
* not-yet-imported source item to add to a planning slot turns out to need
|
||||||
* from `GET /sources/:sourceKey/preview/:externalId` (the same draft the
|
* a person's input (an ingredient line the automatic matching
|
||||||
* preview panel already showed), structurally the same form as
|
* (`ingredient-matcher.ts`) couldn't resolve, say) — a draft with nothing
|
||||||
* `RecipeFormPage` — same sub-components (`IngredientRow`,
|
* missing imports transparently from that dialog instead, without ever
|
||||||
|
* reaching this screen (`tryBuildCompleteImport`). There is no other way
|
||||||
|
* in any more: browsing a source outside of adding-to-planning
|
||||||
|
* (`RecipesPage`/`RecipeSourcesPanel`) only ever previews, on purpose — a
|
||||||
|
* source item isn't imported into the household's own catalog until
|
||||||
|
* someone actually plans it.
|
||||||
|
*
|
||||||
|
* Pre-filled from `GET /sources/:sourceKey/preview/:externalId` (the same
|
||||||
|
* draft the transparent-import attempt already fetched), structurally the
|
||||||
|
* same form as `RecipeFormPage` — same sub-components (`IngredientRow`,
|
||||||
* `IngredientPicker`, `StepListEditor`, `DietTagSelect`), same
|
* `IngredientPicker`, `StepListEditor`, `DietTagSelect`), same
|
||||||
* `CreateRecipeInput` submit shape — plus one thing a manual creation
|
* `CreateRecipeInput` submit shape — plus one thing a manual creation
|
||||||
* never has to handle: ingredient lines the automatic matching
|
* never has to handle: ingredient lines the automatic matching couldn't
|
||||||
* (`ingredient-matcher.ts`) couldn't resolve. Those render as their own
|
* resolve. Those render as their own "à compléter" list, each needing a
|
||||||
* "à compléter" list, each needing a real ingredient picked (or the line
|
* real ingredient picked (or the line discarded) before the form can
|
||||||
* discarded) before the form can submit — never silently drops/guesses one,
|
* submit — never silently drops/guesses one, per the product decision this
|
||||||
* per the product decision this stage was built against (no invalid
|
* stage was built against (no invalid recipe is ever persisted).
|
||||||
* recipe is ever persisted).
|
|
||||||
*
|
*
|
||||||
* Submits to `POST /sources/:sourceKey/import/:externalId`
|
* Submits to `POST /sources/:sourceKey/import/:externalId`
|
||||||
* (`apiClient.importSourceItem`) instead of `POST /recipes` — the only
|
* (`apiClient.importSourceItem`) instead of `POST /recipes` — the only
|
||||||
* other difference from `RecipeFormPage`'s own submit.
|
* other difference from `RecipeFormPage`'s own submit.
|
||||||
*
|
*
|
||||||
* `?planningDate=&planningWeekDay=&planningMeal=` are set only when this
|
* `?planningDate=&planningWeekDay=&planningMeal=` carry the planning slot
|
||||||
* page was reached from `RecipePickerDialog`'s "Sources" tab (via
|
* `RecipePickerDialog` was adding to along as query params (always present
|
||||||
* `RecipeDetailPanel`'s import link, see its own `planningSlot` prop) —
|
* in practice, given the only entry point above — still parsed
|
||||||
* picking a not-yet-imported item there hands off to this full review
|
* defensively, see `parsePlanningSlot`). A successful import adds the
|
||||||
* screen instead of the dialog's own small "how many portions?" step,
|
* freshly created recipe straight to that slot (`POST /planning/items`,
|
||||||
* since an unresolved-ingredient review doesn't fit in that step. When
|
|
||||||
* present and well-formed, a successful import also adds the freshly
|
|
||||||
* created recipe straight to that planning slot (`POST /planning/items`,
|
|
||||||
* using this form's own `portions` field) before landing back on the
|
* using this form's own `portions` field) before landing back on the
|
||||||
* planning page, instead of the recipe's own detail page.
|
* planning page, instead of the recipe's own detail page.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue