feat(planning): le picker prévisualise avant de confirmer, formulaire de revue intégré
Trois ajustements successifs sur le dialogue de sélection de recette (RecipePickerDialog), demandés en continu après le premier correctif de débordement : 1. Dialogue élargi et à hauteur fixe (95vw plafonné à 85rem, 80vh) au lieu de dépendre du contenu, avec la répartition liste/détail redéfinie en fractions du dialogue lui-même (3fr/2fr) plutôt qu'en vw — cette dernière suivait la largeur du viewport, sans rapport avec la largeur désormais fixe du dialogue. 2. Le formulaire de revue d'import (ex-ImportRecipePage) est extrait dans un composant partagé, RecipeImportForm — toujours monté en page autonome (route directe/rechargement), mais désormais aussi intégré comme une étape du dialogue lui-même quand un item de source a besoin d'une résolution manuelle, au lieu de naviguer et perdre le contexte du picker (recherche, filtres, créneau). 3. Cliquer sur une recette dans le dialogue ne fait plus que la sélectionner/prévisualiser (RecipeDetailPanel, comme /recettes) — plus de saut automatique vers l'étape suivante. Un nouveau pied de dialogue (Dialog.tsx gagne une prop ) porte Confirmer/ Fermer : Confirmer agit sur la sélection en cours (recette réelle → étape portions existante ; item de source pas encore importé → import transparent ou formulaire intégré, point 2). Les onglets réguliers gagnent leur propre paire maître-détail (RecipeTable + RecipeDetailPanel, showActions=false) sur ce même modèle ; les onglets source prévisualisent désormais aussi les items déjà importés en interne (RecipeSourcesPanel), plus de saut direct. Cypress (planning.feature/planning.ts) mis à jour en conséquence : sélectionner puis confirmer sont deux étapes distinctes, le clic sur la ligne ne déclenche plus rien tout seul. Bug pré-existant trouvé en testant en direct (sans rapport avec ce qui précède) : l'import d'une recette source plante avec une contrainte d'unicité Prisma dès que deux lignes d'ingrédient se résolvent au même ingrédient catalogue — signalé séparément (tâche en arrière-plan), pas corrigé ici. Vérifié en direct (navigateur, comptes de test) : sélection sans saut d'écran, pied de dialogue activé/désactivé correctement, Confirmer sur un item de source non résolu bascule vers le formulaire intégré, Fermer ferme bien le dialogue. pnpm exec tsc -b --force (web) — propre. pnpm exec biome check — propre. pnpm --filter web build — propre. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
260bc3dc05
commit
85fd9bae7d
12 changed files with 777 additions and 619 deletions
|
|
@ -13,9 +13,18 @@ Feature: Adding a recipe to the planning
|
||||||
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
|
||||||
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 returns nothing
|
||||||
|
|
||||||
Scenario: Adds a not-yet-imported source item to a planning slot, landing on the review screen since an ingredient needs resolving
|
Scenario: Selecting a source item only previews it, the footer's Confirmer is what acts on it
|
||||||
|
Given previewing TheMealDB item "9999" is available
|
||||||
|
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 "Fish Pie"
|
||||||
|
Then the recipe detail panel heading should be "Fish Pie"
|
||||||
|
And the URL should be the home page
|
||||||
|
|
||||||
|
Scenario: Confirming a not-yet-imported source item lands on the embedded review form 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,6 +32,7 @@ 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 button "Confirmer"
|
||||||
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"
|
||||||
|
|
@ -31,10 +41,10 @@ Feature: Adding a recipe to the planning
|
||||||
And I fill in the last ingredient's quantity with "1" and unit "unité"
|
And I fill in the last ingredient's quantity with "1" and unit "unité"
|
||||||
And I click the button "Importer"
|
And I click the button "Importer"
|
||||||
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 recipe picker dialog should be closed
|
||||||
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
|
Scenario: Confirming a fully-resolved not-yet-imported item adds it to the planning transparently, with no review screen at all
|
||||||
Given previewing TheMealDB item "7777" is fully resolved as "Ratatouille"
|
Given previewing TheMealDB item "7777" is fully resolved as "Ratatouille"
|
||||||
And importing item "7777" will succeed and return id 100
|
And importing item "7777" will succeed and return id 100
|
||||||
And adding recipe 100 to the planning will succeed
|
And adding recipe 100 to the planning will succeed
|
||||||
|
|
@ -42,6 +52,8 @@ 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 "Ratatouille"
|
And I click the source item "Ratatouille"
|
||||||
|
And I click the button "Confirmer"
|
||||||
Then the import request for "7777" should have included the name "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 planning add request should have included recipe 100, weekDay "lundi", meal "petit-dejeuner", and portions 4
|
||||||
And the URL should be the home page
|
And the recipe picker dialog should be closed
|
||||||
|
And the recipe "Ratatouille" should appear in the first planning slot with 4 portions
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,6 @@ import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor";
|
||||||
// its own comment for the full reasoning), so most of what's below mirrors
|
// its own comment for the full reasoning), so most of what's below mirrors
|
||||||
// recipe-sources.ts's fixtures rather than importing them.
|
// recipe-sources.ts's fixtures rather than importing them.
|
||||||
|
|
||||||
// Flips once, from `false` to `true`, as the single scenario in this file
|
|
||||||
// actually performs the planning-add — module-level `let` rather than
|
|
||||||
// something reset per-scenario, since there's only ever the one here (see
|
|
||||||
// household-settings.ts for the same pattern used across several scenarios
|
|
||||||
// instead).
|
|
||||||
let fishPiePlanned = false;
|
|
||||||
|
|
||||||
Given("the recipe catalog contains nothing", () => {
|
Given("the recipe catalog contains nothing", () => {
|
||||||
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
|
cy.intercept("GET", /\/recipes\?/, { statusCode: 200, body: [] });
|
||||||
});
|
});
|
||||||
|
|
@ -216,9 +209,7 @@ Given("importing the previewed item will succeed and return id {int}", (id: numb
|
||||||
});
|
});
|
||||||
|
|
||||||
Given("adding the imported recipe to the planning will succeed", () => {
|
Given("adding the imported recipe to the planning will succeed", () => {
|
||||||
cy.intercept("POST", "**/planning/items", (req) => {
|
cy.intercept("POST", "**/planning/items", {
|
||||||
fishPiePlanned = true;
|
|
||||||
req.reply({
|
|
||||||
statusCode: 201,
|
statusCode: 201,
|
||||||
body: {
|
body: {
|
||||||
id: 1,
|
id: 1,
|
||||||
|
|
@ -227,39 +218,17 @@ Given("adding the imported recipe to the planning will succeed", () => {
|
||||||
portions: 4,
|
portions: 4,
|
||||||
recipe: { id: 99, name: "Fish Pie" },
|
recipe: { id: 99, name: "Fish Pie" },
|
||||||
},
|
},
|
||||||
});
|
|
||||||
}).as("addPlanningItem");
|
}).as("addPlanningItem");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Stateful — landing back on "/" after the import journey remounts
|
// Every scenario here confirms/imports without ever leaving "/" (the
|
||||||
// `PlanningPage` from scratch (a real cross-route navigation, not a
|
// footer's "Confirmer" patches the grid locally via `PlanningPage`'s own
|
||||||
// same-component state update: see `ImportRecipePage`'s `navigate("/")`),
|
// `onAdded` — `RecipePickerDialog`'s doc comment — rather than navigating
|
||||||
// so only a fresh `GET /planning?date=` that reflects the just-added item
|
// away and back), so unlike a real cross-route remount, this fixture never
|
||||||
// makes it show up there — nothing client-side survives that remount to
|
// needs to reflect what's been added: the grid picks it up from local
|
||||||
// patch it in locally the way `PlanningPage`'s own `patchPlanningItems`
|
// state, not a fresh fetch.
|
||||||
// does for an add made without leaving the page.
|
Then("the recipe picker dialog should be closed", () => {
|
||||||
Given("the planning request reflects whatever's been added so far", () => {
|
cy.get(".dialog-panel").should("not.exist");
|
||||||
cy.intercept("GET", /\/planning\?/, (req) => {
|
|
||||||
req.reply({
|
|
||||||
statusCode: 200,
|
|
||||||
body: fishPiePlanned
|
|
||||||
? {
|
|
||||||
id: 1,
|
|
||||||
startDate: "2026-08-17T00:00:00.000Z",
|
|
||||||
finishDate: "2026-08-23T00:00:00.000Z",
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
weekDay: "lundi",
|
|
||||||
meal: "petit-dejeuner",
|
|
||||||
portions: 4,
|
|
||||||
recipe: { id: 99, name: "Fish Pie" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// The very first "+" in DOM order is Lundi's Petit-déjeuner cell (`MEALS`'s
|
// The very first "+" in DOM order is Lundi's Petit-déjeuner cell (`MEALS`'s
|
||||||
|
|
|
||||||
|
|
@ -22,11 +22,14 @@ export function Dialog({
|
||||||
title,
|
title,
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
|
footer,
|
||||||
}: {
|
}: {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
title?: string;
|
title?: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** Optional action bar pinned below the scrollable body (`.dialog-panel__footer`) — outside `.dialog-panel__body`'s own scroll, same idea as `title`'s header. Omit for a plain dialog with no persistent footer actions. */
|
||||||
|
footer?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
|
|
@ -99,6 +102,7 @@ export function Dialog({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="dialog-panel__body">{children}</div>
|
<div className="dialog-panel__body">{children}</div>
|
||||||
|
{footer && <div className="dialog-panel__footer">{footer}</div>}
|
||||||
</dialog>
|
</dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,3 +61,13 @@
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: var(--space-lg);
|
padding: var(--space-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dialog-panel__footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: var(--space-md) var(--space-lg);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import {
|
import {
|
||||||
type BrowsableSourceItemView,
|
|
||||||
type DietView,
|
type DietView,
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
type IngredientView,
|
type IngredientView,
|
||||||
|
|
@ -18,7 +17,9 @@ import { Dialog } from "../../components/ui/Dialog";
|
||||||
import { errorMessageService } from "../../services/error-message.service";
|
import { errorMessageService } from "../../services/error-message.service";
|
||||||
import { DietTagSelect } from "../recipes/DietTagSelect";
|
import { DietTagSelect } from "../recipes/DietTagSelect";
|
||||||
import { IngredientPicker } from "../recipes/IngredientPicker";
|
import { IngredientPicker } from "../recipes/IngredientPicker";
|
||||||
import { RecipeSourcesPanel } from "../recipes/RecipeSourcesPanel";
|
import { RecipeDetailPanel, type RecipeDetailState } from "../recipes/RecipeDetailPanel";
|
||||||
|
import { RecipeImportForm } from "../recipes/RecipeImportForm";
|
||||||
|
import { RecipeSourcesPanel, type SourceItemSelection } from "../recipes/RecipeSourcesPanel";
|
||||||
import { RecipeTable } from "../recipes/RecipeTable";
|
import { RecipeTable } from "../recipes/RecipeTable";
|
||||||
import {
|
import {
|
||||||
RecipeTabs,
|
RecipeTabs,
|
||||||
|
|
@ -59,29 +60,36 @@ 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: picking an
|
* household-enabled source's own tab is included too.
|
||||||
* already-imported item behaves exactly like picking a regular recipe,
|
|
||||||
* and picking one that isn't imported yet is what actually imports it —
|
|
||||||
* nowhere else in the app does (see `handleSelectDraftItem`) — since a
|
|
||||||
* source item only ever becomes a real, saved `Recipe` as a side effect of
|
|
||||||
* someone adding it to their planning.
|
|
||||||
*
|
*
|
||||||
* 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
|
||||||
* below resets for free the next time it's reopened, no manual reset
|
* below resets for free the next time it's reopened, no manual reset
|
||||||
* needed.
|
* needed.
|
||||||
*
|
*
|
||||||
* Selecting a row doesn't navigate anywhere (unlike `RecipesPage`'s own
|
* Clicking a row only ever *selects* it — same "preview before you commit"
|
||||||
* use of `RecipeTable`) — it switches this same dialog to a small
|
* shape for every kind of row: a regular tab's own master-detail pair
|
||||||
* "how many portions?" confirmation step, then calls `POST
|
* (`RecipeTable` + a `RecipeDetailPanel` fetched here, mirroring
|
||||||
* /planning/items` on submit. Picking a not-yet-imported source item is
|
* `RecipesPage`'s own layout) fetches and previews a real recipe; a
|
||||||
* handled differently still (`handleSelectDraftItem`): when the draft has
|
* source tab's `RecipeSourcesPanel` already previews either kind of row it
|
||||||
* everything a real recipe needs, it's imported and added to this slot
|
* has (already-imported or not) inline, on its own. Nothing about a click
|
||||||
* transparently — no extra screen, same as picking anything else. Only
|
* commits to anything by itself — the pinned footer's "Confirmer" button
|
||||||
* when something's actually missing (an ingredient the automatic matcher
|
* (`handleFooterConfirm`) is what acts on whichever preview is currently
|
||||||
* couldn't resolve, say) does this navigate away entirely, to the review
|
* pending (`previewedRecipe`/`previewedDraft`, mutually exclusive):
|
||||||
* screen (`/recettes/importer/...`), which has its own portions field
|
* - A real recipe (regular tab, or an already-imported source item) moves
|
||||||
* already.
|
* to the small "how many portions?" step (`selectedRecipe`), same as
|
||||||
|
* before this dialog grew a footer.
|
||||||
|
* - A not-yet-imported source item is what actually imports one — nowhere
|
||||||
|
* else in the app does (see `confirmDraftSelection`) — since a source
|
||||||
|
* item only ever becomes a real, saved `Recipe` as a side effect of
|
||||||
|
* someone adding it to their planning. When the draft has everything a
|
||||||
|
* real recipe needs, it's imported and added to this slot transparently
|
||||||
|
* — no extra screen. Only when something's actually missing (an
|
||||||
|
* ingredient the automatic matcher couldn't resolve, say) does this
|
||||||
|
* switch to a third step instead, embedding the full review form
|
||||||
|
* (`RecipeImportForm`) right in this same dialog rather than navigating
|
||||||
|
* away to `ImportRecipePage` and losing the picker's own context (search
|
||||||
|
* term, filters, which slot this even was).
|
||||||
*/
|
*/
|
||||||
export function RecipePickerDialog({
|
export function RecipePickerDialog({
|
||||||
slot,
|
slot,
|
||||||
|
|
@ -97,6 +105,14 @@ export function RecipePickerDialog({
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<RecipesPageTab>("favoris");
|
const [activeTab, setActiveTab] = useState<RecipesPageTab>("favoris");
|
||||||
const activeSourceKey = parseSourceTabValue(activeTab);
|
const activeSourceKey = parseSourceTabValue(activeTab);
|
||||||
|
|
||||||
|
/** Switching tabs drops whatever was previewed/pending on the one just left — a stale "Confirmer" target from a different tab would be confusing at best. */
|
||||||
|
function handleTabChange(tab: RecipesPageTab) {
|
||||||
|
setActiveTab(tab);
|
||||||
|
setPreviewedRecipe(null);
|
||||||
|
setPreviewedDraft(null);
|
||||||
|
setRegularPreviewState({ status: "empty" });
|
||||||
|
}
|
||||||
const enabledSources = useEnabledSources();
|
const enabledSources = useEnabledSources();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||||
|
|
@ -109,20 +125,37 @@ export function RecipePickerDialog({
|
||||||
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
|
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
|
||||||
const [dietsCatalog, setDietsCatalog] = useState<DietView[]>([]);
|
const [dietsCatalog, setDietsCatalog] = useState<DietView[]>([]);
|
||||||
const [listState, setListState] = useState<ListState>({ status: "loading" });
|
const [listState, setListState] = useState<ListState>({ status: "loading" });
|
||||||
// Set when picking an already-imported source item fails to resolve to a
|
// The regular tabs' own master-detail pair — `RecipeTable` on the left,
|
||||||
// real recipe (see `handleSelectImportedRecipe`) — a rare race (the
|
// this on the right, fetched on row click (mirrors `RecipesPage`'s
|
||||||
// recipe was deleted between the browse fetch and the click), surfaced
|
// identical layout). Source tabs don't use this at all: `RecipeSourcesPanel`
|
||||||
// the same way any other catalog load error is on this dialog.
|
// previews its own rows internally.
|
||||||
const [sourceSelectError, setSourceSelectError] = useState(false);
|
const [regularPreviewState, setRegularPreviewState] = useState<RecipeDetailState>({
|
||||||
// True while `handleSelectDraftItem` below is resolving a not-yet-
|
status: "empty",
|
||||||
// imported item's transparent-import attempt — replaces the source tab's
|
});
|
||||||
// whole panel with a status message for that brief window (fetch the
|
// Which real recipe is the pending selection — from either the regular
|
||||||
// draft, maybe import it, maybe add it to the slot) rather than leaving
|
// tabs' own preview above, or a source tab's already-imported row
|
||||||
// the browse list clickable mid-flight.
|
// (`RecipeSourcesPanel`'s `onSelectImportedRecipe`, which already
|
||||||
const [isAddingDraft, setIsAddingDraft] = useState(false);
|
// previewed it internally). Mutually exclusive with `previewedDraft`
|
||||||
|
// below; the footer's "Confirmer" (`handleFooterConfirm`) acts on
|
||||||
|
// whichever one is set.
|
||||||
|
const [previewedRecipe, setPreviewedRecipe] = useState<RecipeView | null>(null);
|
||||||
|
// Which not-yet-imported source item is the pending selection —
|
||||||
|
// `RecipeSourcesPanel`'s `onDraftSelected`, fired the moment such a row
|
||||||
|
// is clicked (it previews itself internally; this is just "which one").
|
||||||
|
const [previewedDraft, setPreviewedDraft] = useState<SourceItemSelection | null>(null);
|
||||||
|
// True while `confirmDraftSelection` below is resolving the footer's
|
||||||
|
// "Confirmer" for a pending draft (fetch it, maybe import it, maybe add
|
||||||
|
// it to the slot) — disables the footer for that brief window rather
|
||||||
|
// than allowing a second click mid-flight.
|
||||||
|
const [isConfirmingDraft, setIsConfirmingDraft] = useState(false);
|
||||||
|
// Set by `confirmDraftSelection`'s fallback when the pending draft needs
|
||||||
|
// a person's input before it can be imported — switches this whole
|
||||||
|
// dialog to its third step (see the top-level `if` below), embedding
|
||||||
|
// `RecipeImportForm` instead of showing it inline here.
|
||||||
|
const [reviewDraftItem, setReviewDraftItem] = useState<SourceItemSelection | null>(null);
|
||||||
|
|
||||||
// The recipe picked in step 1 — `null` while still browsing, set once a
|
// The recipe the footer's "Confirmer" moved to this small step for —
|
||||||
// row is clicked to switch this dialog into its confirmation step.
|
// `null` while still browsing.
|
||||||
const [selectedRecipe, setSelectedRecipe] = useState<RecipeSummaryView | null>(null);
|
const [selectedRecipe, setSelectedRecipe] = useState<RecipeSummaryView | null>(null);
|
||||||
const [portions, setPortions] = useState("1");
|
const [portions, setPortions] = useState("1");
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
@ -182,64 +215,84 @@ export function RecipePickerDialog({
|
||||||
selectedIngredientIds.includes(ingredient.id),
|
selectedIngredientIds.includes(ingredient.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Picking an already-imported source item (one of the source tabs' `RecipeSourcesPanel`) — resolved to its full recipe, then treated exactly like picking that same recipe from one of the regular tabs, moving straight to the confirm-portions step below. */
|
/** A regular tab's own row click — fetches the full recipe and previews it in this dialog's own master-detail pair, exactly like `RecipesPage` does. */
|
||||||
function handleSelectImportedRecipe(recipeId: number) {
|
function handleSelectRegularRecipe(id: number) {
|
||||||
setSourceSelectError(false);
|
setPreviewedDraft(null);
|
||||||
|
setRegularPreviewState({ status: "loading" });
|
||||||
apiClient
|
apiClient
|
||||||
.getRecipe(recipeId)
|
.getRecipe(id)
|
||||||
.then((recipe) => {
|
.then((recipe) => {
|
||||||
setSelectedRecipe(recipe);
|
setRegularPreviewState({ status: "loaded", recipe });
|
||||||
setPortions(String(recipe.portions));
|
setPreviewedRecipe(recipe);
|
||||||
})
|
})
|
||||||
.catch(() => setSourceSelectError(true));
|
.catch(() => setRegularPreviewState({ status: "error" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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. */
|
/** A source tab's already-imported row — `RecipeSourcesPanel` already fetched and is previewing it itself; this just records it as the pending selection. */
|
||||||
function goToReviewScreen(sourceKey: string, externalId: string) {
|
function handleSelectImportedRecipe(recipe: RecipeView) {
|
||||||
navigate(
|
setPreviewedDraft(null);
|
||||||
`/recettes/importer/${sourceKey}/${encodeURIComponent(externalId)}` +
|
setPreviewedRecipe(recipe);
|
||||||
`?planningDate=${slot.date}&planningWeekDay=${slot.weekDay}&planningMeal=${slot.meal}`,
|
}
|
||||||
);
|
|
||||||
|
/** A source tab's not-yet-imported row — `RecipeSourcesPanel` previews it itself; this just records it as the pending selection. */
|
||||||
|
function handleDraftSelected(selection: SourceItemSelection) {
|
||||||
|
setPreviewedRecipe(null);
|
||||||
|
setPreviewedDraft(selection);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Picking a not-yet-imported source item — the one action in the whole
|
* The footer's "Confirmer" — acts on whichever preview is currently
|
||||||
* app that actually imports one (see this component's own doc comment).
|
* pending. A real recipe moves to the small portions step below; a
|
||||||
* Fetches its full draft, and when {@link tryBuildCompleteImport} finds
|
* not-yet-imported draft runs {@link confirmDraftSelection}.
|
||||||
* 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) {
|
function handleFooterConfirm() {
|
||||||
if (activeSourceKey === null) return;
|
if (previewedRecipe) {
|
||||||
const sourceKey = activeSourceKey;
|
setSelectedRecipe(previewedRecipe);
|
||||||
setSourceSelectError(false);
|
setPortions(String(previewedRecipe.portions));
|
||||||
setIsAddingDraft(true);
|
return;
|
||||||
|
}
|
||||||
|
if (previewedDraft) {
|
||||||
|
void confirmDraftSelection(previewedDraft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirming 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 — switches to the embedded review-form step instead
|
||||||
|
* (`setReviewDraftItem`), since only a person can supply what's actually
|
||||||
|
* missing.
|
||||||
|
*/
|
||||||
|
async function confirmDraftSelection(selection: SourceItemSelection) {
|
||||||
|
const { sourceKey, externalId } = selection;
|
||||||
|
setIsConfirmingDraft(true);
|
||||||
|
|
||||||
|
function needsReview() {
|
||||||
|
setIsConfirmingDraft(false);
|
||||||
|
setReviewDraftItem({ sourceKey, externalId });
|
||||||
|
}
|
||||||
|
|
||||||
let payload: ReturnType<typeof tryBuildCompleteImport>;
|
let payload: ReturnType<typeof tryBuildCompleteImport>;
|
||||||
try {
|
try {
|
||||||
payload = tryBuildCompleteImport(
|
payload = tryBuildCompleteImport(await apiClient.previewSourceItem(sourceKey, externalId));
|
||||||
await apiClient.previewSourceItem(sourceKey, item.externalId),
|
|
||||||
);
|
|
||||||
} catch {
|
} catch {
|
||||||
goToReviewScreen(sourceKey, item.externalId);
|
needsReview();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
setIsAddingDraft(false);
|
needsReview();
|
||||||
goToReviewScreen(sourceKey, item.externalId);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let saved: RecipeView;
|
let saved: RecipeView;
|
||||||
try {
|
try {
|
||||||
saved = await apiClient.importSourceItem(sourceKey, item.externalId, payload);
|
saved = await apiClient.importSourceItem(sourceKey, externalId, payload);
|
||||||
} catch {
|
} catch {
|
||||||
setIsAddingDraft(false);
|
needsReview();
|
||||||
goToReviewScreen(sourceKey, item.externalId);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -256,8 +309,10 @@ export function RecipePickerDialog({
|
||||||
} catch {
|
} catch {
|
||||||
// The recipe itself is already saved at this point — only adding it
|
// 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
|
// to this slot failed. Land on its own page rather than retrying the
|
||||||
// whole import through the review form (same fallback
|
// whole import (same fallback `RecipeImportForm`'s own submit takes
|
||||||
// `ImportRecipePage`'s own submit takes for the identical failure).
|
// for the identical failure — see this dialog's `onImported` handler
|
||||||
|
// below).
|
||||||
|
setIsConfirmingDraft(false);
|
||||||
navigate(`/recettes/${saved.id}`);
|
navigate(`/recettes/${saved.id}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -286,6 +341,34 @@ export function RecipePickerDialog({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (reviewDraftItem) {
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
onClose={onClose}
|
||||||
|
title={t("recipes.sources.import.title")}
|
||||||
|
className="recipe-picker-dialog"
|
||||||
|
>
|
||||||
|
<RecipeImportForm
|
||||||
|
sourceKey={reviewDraftItem.sourceKey}
|
||||||
|
externalId={reviewDraftItem.externalId}
|
||||||
|
planningSlot={slot}
|
||||||
|
onImported={({ recipe, planningItem }) => {
|
||||||
|
if (planningItem) {
|
||||||
|
onAdded(planningItem);
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
// The recipe itself is saved at this point — only adding it
|
||||||
|
// to this slot failed. Same fallback as the transparent-
|
||||||
|
// import path above: land on its own page instead of
|
||||||
|
// retrying.
|
||||||
|
navigate(`/recettes/${recipe.id}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (selectedRecipe) {
|
if (selectedRecipe) {
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
|
|
@ -321,8 +404,29 @@ export function RecipePickerDialog({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canConfirm = previewedRecipe !== null || previewedDraft !== null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog onClose={onClose} title={t("planning.picker.title")} className="recipe-picker-dialog">
|
<Dialog
|
||||||
|
onClose={onClose}
|
||||||
|
title={t("planning.picker.title")}
|
||||||
|
className="recipe-picker-dialog"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={onClose}>
|
||||||
|
{t("planning.picker.footerClose")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="recipe-picker-confirm__confirm"
|
||||||
|
onClick={handleFooterConfirm}
|
||||||
|
disabled={!canConfirm || isConfirmingDraft}
|
||||||
|
>
|
||||||
|
{isConfirmingDraft ? t("planning.picker.adding") : t("planning.picker.footerConfirm")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
{activeSourceKey === null && (
|
{activeSourceKey === null && (
|
||||||
<div className="recipe-picker__filters">
|
<div className="recipe-picker__filters">
|
||||||
<input
|
<input
|
||||||
|
|
@ -390,28 +494,17 @@ export function RecipePickerDialog({
|
||||||
|
|
||||||
<RecipeTabs
|
<RecipeTabs
|
||||||
active={activeTab}
|
active={activeTab}
|
||||||
onChange={setActiveTab}
|
onChange={handleTabChange}
|
||||||
sources={enabledSources.status === "loaded" ? enabledSources.sources : []}
|
sources={enabledSources.status === "loaded" ? enabledSources.sources : []}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{activeSourceKey !== null ? (
|
{activeSourceKey !== null ? (
|
||||||
<>
|
|
||||||
{sourceSelectError && (
|
|
||||||
<p className="recipes-page__status recipes-page__status--error">
|
|
||||||
{t("common.loadError")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{isAddingDraft ? (
|
|
||||||
<p className="recipes-page__status">{t("planning.picker.addingDraft")}</p>
|
|
||||||
) : (
|
|
||||||
<RecipeSourcesPanel
|
<RecipeSourcesPanel
|
||||||
key={activeSourceKey}
|
key={activeSourceKey}
|
||||||
sourceKey={activeSourceKey}
|
sourceKey={activeSourceKey}
|
||||||
onSelectImportedRecipe={handleSelectImportedRecipe}
|
onSelectImportedRecipe={handleSelectImportedRecipe}
|
||||||
onSelectDraftItem={handleSelectDraftItem}
|
onDraftSelected={handleDraftSelected}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{listState.status === "loading" && (
|
{listState.status === "loading" && (
|
||||||
|
|
@ -426,18 +519,14 @@ export function RecipePickerDialog({
|
||||||
<p className="recipes-page__status">{t("planning.picker.empty")}</p>
|
<p className="recipes-page__status">{t("planning.picker.empty")}</p>
|
||||||
)}
|
)}
|
||||||
{listState.status === "loaded" && listState.recipes.length > 0 && (
|
{listState.status === "loaded" && listState.recipes.length > 0 && (
|
||||||
|
<div className="recipes-page__catalog">
|
||||||
<RecipeTable
|
<RecipeTable
|
||||||
recipes={listState.recipes}
|
recipes={listState.recipes}
|
||||||
selectedId={null}
|
selectedId={previewedRecipe?.id ?? null}
|
||||||
onSelect={(id) => {
|
onSelect={handleSelectRegularRecipe}
|
||||||
const recipe = listState.recipes.find((r) => r.id === id) ?? null;
|
|
||||||
setSelectedRecipe(recipe);
|
|
||||||
// Pre-fill from the recipe's own written yield rather than
|
|
||||||
// always starting at 1 — still freely editable below, this
|
|
||||||
// is just a better starting point (see `Recipe.portions`).
|
|
||||||
if (recipe) setPortions(String(recipe.portions));
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
<RecipeDetailPanel state={regularPreviewState} showActions={false} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,8 @@
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-base);
|
border-radius: var(--radius-base);
|
||||||
padding: var(--space-sm) var(--space-md);
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
|
@ -172,3 +174,27 @@
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The dialog-level footer (`Dialog`'s `footer` prop, used by this dialog's
|
||||||
|
// main browsing step) — "Confirmer" reuses `.recipe-picker-confirm__confirm`
|
||||||
|
// above (same primary-button look as the portions step's own "Ajouter au
|
||||||
|
// planning"), "Fermer" needs its own secondary style since a bare
|
||||||
|
// `<button>` here would render with the browser's own default sizing/
|
||||||
|
// font instead of matching it — same "bordered, no fill" secondary look
|
||||||
|
// as `.recipe-detail-panel__delete-confirm`'s cancel button.
|
||||||
|
.dialog-panel__footer button:not(.recipe-picker-confirm__confirm) {
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,11 +48,14 @@ export function RecipeDetailPanel({
|
||||||
dislikedIngredientIds = [],
|
dislikedIngredientIds = [],
|
||||||
onFavoriteToggled,
|
onFavoriteToggled,
|
||||||
onDeleted,
|
onDeleted,
|
||||||
|
showActions = true,
|
||||||
}: {
|
}: {
|
||||||
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;
|
||||||
|
/** Default `true`. `false` hides the favorite star and Modifier/Supprimer buttons on the `"loaded"` branch — for a caller previewing someone's pick before deciding what happens next (`RecipePickerDialog`'s browsing step), not viewing one's own catalog. */
|
||||||
|
showActions?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
|
@ -154,11 +157,13 @@ export function RecipeDetailPanel({
|
||||||
<div className="recipe-detail-panel__photo" aria-hidden="true">
|
<div className="recipe-detail-panel__photo" aria-hidden="true">
|
||||||
{recipe.picture ? <img src={recipe.picture} alt="" /> : "🍽️"}
|
{recipe.picture ? <img src={recipe.picture} alt="" /> : "🍽️"}
|
||||||
</div>
|
</div>
|
||||||
|
{showActions && (
|
||||||
<FavoriteStarButton
|
<FavoriteStarButton
|
||||||
recipeId={recipe.id}
|
recipeId={recipe.id}
|
||||||
isFavorite={recipe.isFavorite}
|
isFavorite={recipe.isFavorite}
|
||||||
onToggled={(isFavorite) => onFavoriteToggled?.(recipe.id, isFavorite)}
|
onToggled={(isFavorite) => onFavoriteToggled?.(recipe.id, isFavorite)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="recipe-detail-panel__title-row">
|
<div className="recipe-detail-panel__title-row">
|
||||||
|
|
@ -182,12 +187,14 @@ export function RecipeDetailPanel({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showActions && (
|
||||||
<div className="recipe-detail-panel__actions">
|
<div className="recipe-detail-panel__actions">
|
||||||
<Link to={`/recettes/${recipe.id}/modifier`} className="recipes-page__new-button">
|
<Link to={`/recettes/${recipe.id}/modifier`} className="recipes-page__new-button">
|
||||||
{t("recipes.editButton")}
|
{t("recipes.editButton")}
|
||||||
</Link>
|
</Link>
|
||||||
<DeleteRecipeButton recipeId={recipe.id} onDeleted={() => onDeleted?.(recipe.id)} />
|
<DeleteRecipeButton recipeId={recipe.id} onDeleted={() => onDeleted?.(recipe.id)} />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{recipe.description && (
|
{recipe.description && (
|
||||||
<section className="recipe-detail-panel__section recipe-detail-panel__section--description">
|
<section className="recipe-detail-panel__section recipe-detail-panel__section--description">
|
||||||
|
|
|
||||||
437
apps/web/src/features/recipes/RecipeImportForm.tsx
Normal file
437
apps/web/src/features/recipes/RecipeImportForm.tsx
Normal file
|
|
@ -0,0 +1,437 @@
|
||||||
|
import {
|
||||||
|
type CreateRecipeInput,
|
||||||
|
type DietView,
|
||||||
|
ErrorCode,
|
||||||
|
type IngredientView,
|
||||||
|
type Meal,
|
||||||
|
type PlanningItemView,
|
||||||
|
type RecipeView,
|
||||||
|
type RecipeVisibility,
|
||||||
|
type UnitView,
|
||||||
|
type WeekDay,
|
||||||
|
createRecipeSchema,
|
||||||
|
} from "@batch-cooking/shared";
|
||||||
|
import { type FormEvent, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ApiError, apiClient } from "../../api/client";
|
||||||
|
import { makeClientKey } from "../../lib/client-key";
|
||||||
|
import { errorMessageService } from "../../services/error-message.service";
|
||||||
|
import { DietTagSelect } from "./DietTagSelect";
|
||||||
|
import { IngredientPicker } from "./IngredientPicker";
|
||||||
|
import { IngredientRow } from "./IngredientRow";
|
||||||
|
import { type StepDraft, StepListEditor } from "./StepListEditor";
|
||||||
|
import "./recipes.scss";
|
||||||
|
|
||||||
|
/** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). Same list as `RecipeFormPage`. */
|
||||||
|
const VISIBILITY_OPTIONS: RecipeVisibility[] = ["PERSONAL", "HOUSE", "PUBLIC"];
|
||||||
|
|
||||||
|
/** A resolved ingredient line — identical shape to `RecipeFormPage`'s own `IngredientLine`. */
|
||||||
|
interface IngredientLine {
|
||||||
|
key: string;
|
||||||
|
ingredient: IngredientView;
|
||||||
|
quantity: string;
|
||||||
|
unitId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One draft line that didn't resolve to a real `Ingredient` on preview (`DraftRecipeIngredientView.ingredient === null`) — still needs a person to pick the right one, or discard it, before this recipe can be saved. */
|
||||||
|
interface UnresolvedIngredientLine {
|
||||||
|
key: string;
|
||||||
|
rawText: string;
|
||||||
|
quantity: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoadState = "loading" | "loaded" | "error";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The review/creation form for finalizing a source item's import — the
|
||||||
|
* form itself, extracted out of `ImportRecipePage` so `RecipePickerDialog`
|
||||||
|
* can embed it directly as a step of its own (the normal way this is
|
||||||
|
* reached now: `handleSelectDraftItem`'s fallback when a draft has
|
||||||
|
* something `tryBuildCompleteImport` couldn't resolve on its own) instead
|
||||||
|
* of navigating to a separate page and losing the picker's context.
|
||||||
|
* `ImportRecipePage` still wraps this as a standalone, directly-linkable
|
||||||
|
* route — a safety net (a stale bookmark, a reload mid-flow), not the
|
||||||
|
* primary path any more.
|
||||||
|
*
|
||||||
|
* Pre-filled from `GET /sources/:sourceKey/preview/:externalId`,
|
||||||
|
* structurally the same form as `RecipeFormPage` — same sub-components
|
||||||
|
* (`IngredientRow`, `IngredientPicker`, `StepListEditor`, `DietTagSelect`),
|
||||||
|
* same `CreateRecipeInput` submit shape — plus one thing a manual creation
|
||||||
|
* never has to handle: ingredient lines the automatic matching
|
||||||
|
* (`ingredient-matcher.ts`) couldn't resolve. Those render as their own "à
|
||||||
|
* compléter" list, each needing a real ingredient picked (or the line
|
||||||
|
* discarded) before the form can submit — never silently drops/guesses
|
||||||
|
* one, per the product decision this stage was built against (no invalid
|
||||||
|
* recipe is ever persisted).
|
||||||
|
*
|
||||||
|
* Submits to `POST /sources/:sourceKey/import/:externalId`
|
||||||
|
* (`apiClient.importSourceItem`) instead of `POST /recipes` — the only
|
||||||
|
* other difference from `RecipeFormPage`'s own submit. When `planningSlot`
|
||||||
|
* is given, a successful import also adds the freshly created recipe
|
||||||
|
* straight to that slot (`POST /planning/items`, using this form's own
|
||||||
|
* `portions` field) before calling `onImported` — `planningItem` on that
|
||||||
|
* result is `null` either when there's no slot to add to, or when the
|
||||||
|
* recipe saved fine but that add itself failed (the caller decides what to
|
||||||
|
* do about that rather than this component guessing — see
|
||||||
|
* `ImportRecipePage`/`RecipePickerDialog`'s own handling).
|
||||||
|
*/
|
||||||
|
export function RecipeImportForm({
|
||||||
|
sourceKey,
|
||||||
|
externalId,
|
||||||
|
planningSlot,
|
||||||
|
onImported,
|
||||||
|
}: {
|
||||||
|
sourceKey: string;
|
||||||
|
externalId: string;
|
||||||
|
planningSlot?: { date: string; weekDay: WeekDay; meal: Meal };
|
||||||
|
onImported: (result: { recipe: RecipeView; planningItem: PlanningItemView | null }) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [loadState, setLoadState] = useState<LoadState>("loading");
|
||||||
|
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
|
||||||
|
const [dietsCatalog, setDietsCatalog] = useState<DietView[]>([]);
|
||||||
|
const [unitsCatalog, setUnitsCatalog] = useState<UnitView[]>([]);
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [picture, setPicture] = useState("");
|
||||||
|
const [portions, setPortions] = useState("4");
|
||||||
|
const [visibility, setVisibility] = useState<RecipeVisibility>("PERSONAL");
|
||||||
|
const [dietIds, setDietIds] = useState<number[]>([]);
|
||||||
|
const [ingredientLines, setIngredientLines] = useState<IngredientLine[]>([]);
|
||||||
|
const [unresolvedIngredients, setUnresolvedIngredients] = useState<UnresolvedIngredientLine[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
// Which unresolved line's picker is currently open — at most one at a
|
||||||
|
// time (IngredientPicker is a whole browsable grid, not a compact
|
||||||
|
// popover; showing one per unresolved line at once would be unwieldy).
|
||||||
|
const [resolvingKey, setResolvingKey] = useState<string | null>(null);
|
||||||
|
const [steps, setSteps] = useState<StepDraft[]>([]);
|
||||||
|
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoadState("loading");
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
apiClient.getIngredients(),
|
||||||
|
apiClient.getDiets(),
|
||||||
|
apiClient.getUnits(),
|
||||||
|
apiClient.previewSourceItem(sourceKey, externalId),
|
||||||
|
])
|
||||||
|
.then(([ingredients, diets, units, draft]) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setIngredientsCatalog(ingredients);
|
||||||
|
setDietsCatalog(diets);
|
||||||
|
setUnitsCatalog(units);
|
||||||
|
|
||||||
|
setName(draft.name);
|
||||||
|
setDescription(draft.description ?? "");
|
||||||
|
setPicture(draft.picture ?? "");
|
||||||
|
setPortions(draft.portions !== null ? String(draft.portions) : "4");
|
||||||
|
|
||||||
|
const resolved: IngredientLine[] = [];
|
||||||
|
const unresolved: UnresolvedIngredientLine[] = [];
|
||||||
|
for (const line of draft.ingredients) {
|
||||||
|
if (line.ingredient !== null) {
|
||||||
|
resolved.push({
|
||||||
|
key: makeClientKey(),
|
||||||
|
ingredient: line.ingredient,
|
||||||
|
quantity: line.quantity !== null ? String(line.quantity) : "",
|
||||||
|
unitId: line.unit?.id ?? null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
unresolved.push({
|
||||||
|
key: makeClientKey(),
|
||||||
|
rawText: line.rawText,
|
||||||
|
quantity: line.quantity !== null ? String(line.quantity) : "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIngredientLines(resolved);
|
||||||
|
setUnresolvedIngredients(unresolved);
|
||||||
|
|
||||||
|
setSteps(
|
||||||
|
draft.steps.map((step) => ({
|
||||||
|
key: makeClientKey(),
|
||||||
|
description: step.description,
|
||||||
|
picture: step.picture ?? "",
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
setLoadState("loaded");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setLoadState("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [sourceKey, externalId]);
|
||||||
|
|
||||||
|
function addIngredient(ingredient: IngredientView) {
|
||||||
|
setIngredientLines((lines) => [
|
||||||
|
...lines,
|
||||||
|
{ key: makeClientKey(), ingredient, quantity: "", unitId: null },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateIngredientLine(
|
||||||
|
key: string,
|
||||||
|
patch: Partial<Pick<IngredientLine, "quantity" | "unitId">>,
|
||||||
|
) {
|
||||||
|
setIngredientLines((lines) =>
|
||||||
|
lines.map((line) => (line.key === key ? { ...line, ...patch } : line)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeIngredientLine(key: string) {
|
||||||
|
setIngredientLines((lines) => lines.filter((line) => line.key !== key));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Promotes an unresolved line into a real ingredient line, carrying its quantity over — its unit still needs picking, same as a freshly-added ingredient. */
|
||||||
|
function resolveIngredient(unresolvedKey: string, ingredient: IngredientView) {
|
||||||
|
setUnresolvedIngredients((lines) => {
|
||||||
|
const line = lines.find((l) => l.key === unresolvedKey);
|
||||||
|
if (line) {
|
||||||
|
setIngredientLines((resolved) => [
|
||||||
|
...resolved,
|
||||||
|
{ key: makeClientKey(), ingredient, quantity: line.quantity, unitId: null },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return lines.filter((l) => l.key !== unresolvedKey);
|
||||||
|
});
|
||||||
|
setResolvingKey(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function discardUnresolvedIngredient(key: string) {
|
||||||
|
setUnresolvedIngredients((lines) => lines.filter((line) => line.key !== key));
|
||||||
|
setResolvingKey((current) => (current === key ? null : current));
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSubmit =
|
||||||
|
name.trim().length > 0 &&
|
||||||
|
Number.isInteger(Number(portions)) &&
|
||||||
|
Number(portions) > 0 &&
|
||||||
|
ingredientLines.length > 0 &&
|
||||||
|
ingredientLines.every((line) => Number(line.quantity) > 0 && line.unitId !== null) &&
|
||||||
|
unresolvedIngredients.length === 0 &&
|
||||||
|
steps.length > 0 &&
|
||||||
|
steps.every((step) => step.description.trim().length > 0);
|
||||||
|
|
||||||
|
async function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setFormError(null);
|
||||||
|
|
||||||
|
const payload: CreateRecipeInput = {
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim() || null,
|
||||||
|
picture: picture.trim() || null,
|
||||||
|
portions: Number(portions),
|
||||||
|
visibility,
|
||||||
|
dietIds,
|
||||||
|
ingredients: ingredientLines.map((line) => ({
|
||||||
|
ingredientId: line.ingredient.id,
|
||||||
|
quantity: Number(line.quantity),
|
||||||
|
// `canSubmit` already requires every line to have a unit picked —
|
||||||
|
// same "?? 0, the schema rejects it if ever reached" reasoning as
|
||||||
|
// RecipeFormPage's identical submit.
|
||||||
|
unitId: line.unitId ?? 0,
|
||||||
|
})),
|
||||||
|
steps: steps.map((step) => ({
|
||||||
|
description: step.description.trim(),
|
||||||
|
picture: step.picture.trim() || null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = createRecipeSchema.safeParse(payload);
|
||||||
|
if (!result.success) {
|
||||||
|
setFormError(result.error.issues[0]?.message ?? t("recipes.sources.import.genericError"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const saved = await apiClient.importSourceItem(sourceKey, externalId, result.data);
|
||||||
|
if (planningSlot) {
|
||||||
|
try {
|
||||||
|
const planningItem = await apiClient.addPlanningItem({
|
||||||
|
date: planningSlot.date,
|
||||||
|
weekDay: planningSlot.weekDay,
|
||||||
|
meal: planningSlot.meal,
|
||||||
|
recipeId: saved.id,
|
||||||
|
portions: Number(portions),
|
||||||
|
});
|
||||||
|
onImported({ recipe: saved, planningItem });
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// The recipe itself was already imported successfully — only the
|
||||||
|
// planning add failed. The caller decides what to do with a
|
||||||
|
// `null` planningItem (land on the recipe's own page rather than
|
||||||
|
// stranding the user on a form that already submitted; it can
|
||||||
|
// still be added to that slot afterwards via the normal
|
||||||
|
// "déjà importée" picker path).
|
||||||
|
onImported({ recipe: saved, planningItem: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onImported({ recipe: saved, planningItem: null });
|
||||||
|
} catch (err) {
|
||||||
|
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
||||||
|
setFormError(errorMessageService.getLabel(code));
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadState === "loading") {
|
||||||
|
return (
|
||||||
|
<div className="recipe-form">
|
||||||
|
<p className="recipes-page__status">{t("recipes.loading")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadState === "error") {
|
||||||
|
return (
|
||||||
|
<div className="recipe-form">
|
||||||
|
<p className="recipes-page__status recipes-page__status--error">
|
||||||
|
{t("recipes.sources.import.loadError")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedIds = ingredientLines.map((line) => line.ingredient.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="recipe-form" onSubmit={handleSubmit} noValidate>
|
||||||
|
{planningSlot && (
|
||||||
|
<p className="source-item-preview__hint">{t("recipes.sources.import.planningHint")}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label htmlFor="recipe-name">{t("recipes.form.nameLabel")}</label>
|
||||||
|
<input id="recipe-name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
|
||||||
|
<label htmlFor="recipe-description">{t("recipes.form.descriptionLabel")}</label>
|
||||||
|
<textarea
|
||||||
|
id="recipe-description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label htmlFor="recipe-picture">{t("recipes.form.pictureLabel")}</label>
|
||||||
|
<input
|
||||||
|
id="recipe-picture"
|
||||||
|
type="url"
|
||||||
|
value={picture}
|
||||||
|
onChange={(e) => setPicture(e.target.value)}
|
||||||
|
placeholder="https://…"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label htmlFor="recipe-portions">{t("recipes.form.portionsLabel")}</label>
|
||||||
|
<input
|
||||||
|
id="recipe-portions"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
value={portions}
|
||||||
|
onChange={(e) => setPortions(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label htmlFor="recipe-visibility">{t("recipes.form.visibilityLabel")}</label>
|
||||||
|
<select
|
||||||
|
id="recipe-visibility"
|
||||||
|
value={visibility}
|
||||||
|
onChange={(e) => setVisibility(e.target.value as RecipeVisibility)}
|
||||||
|
>
|
||||||
|
{VISIBILITY_OPTIONS.map((option) => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{t(`recipes.form.visibility.${option}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<DietTagSelect diets={dietsCatalog} value={dietIds} onChange={setDietIds} />
|
||||||
|
|
||||||
|
<section className="recipe-form__section">
|
||||||
|
<h2>{t("recipes.ingredientsTitle")}</h2>
|
||||||
|
<ul className="recipe-form__ingredient-list">
|
||||||
|
{ingredientLines.map((line) => (
|
||||||
|
<IngredientRow
|
||||||
|
key={line.key}
|
||||||
|
ingredient={line.ingredient}
|
||||||
|
quantity={line.quantity}
|
||||||
|
unitId={line.unitId}
|
||||||
|
unitsCatalog={unitsCatalog}
|
||||||
|
onQuantityChange={(quantity) => updateIngredientLine(line.key, { quantity })}
|
||||||
|
onUnitChange={(unitId) => updateIngredientLine(line.key, { unitId })}
|
||||||
|
onRemove={() => removeIngredientLine(line.key)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{unresolvedIngredients.length > 0 && (
|
||||||
|
<section className="import-recipe__unresolved">
|
||||||
|
<h3>{t("recipes.sources.import.unresolvedTitle")}</h3>
|
||||||
|
<p className="source-item-preview__hint">
|
||||||
|
{t("recipes.sources.import.unresolvedHint")}
|
||||||
|
</p>
|
||||||
|
<ul className="import-recipe__unresolved-list">
|
||||||
|
{unresolvedIngredients.map((line) => (
|
||||||
|
<li key={line.key}>
|
||||||
|
<div className="import-recipe__unresolved-row">
|
||||||
|
<span>{line.rawText}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setResolvingKey((current) => (current === line.key ? null : line.key))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("recipes.sources.import.resolveButton")}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => discardUnresolvedIngredient(line.key)}>
|
||||||
|
{t("recipes.sources.import.discardButton")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{resolvingKey === line.key && (
|
||||||
|
<IngredientPicker
|
||||||
|
ingredients={ingredientsCatalog}
|
||||||
|
excludeIds={selectedIds}
|
||||||
|
onSelect={(ingredient) => resolveIngredient(line.key, ingredient)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<IngredientPicker
|
||||||
|
ingredients={ingredientsCatalog}
|
||||||
|
excludeIds={selectedIds}
|
||||||
|
onSelect={addIngredient}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="recipe-form__section">
|
||||||
|
<h2>{t("recipes.stepsTitle")}</h2>
|
||||||
|
<StepListEditor steps={steps} onChange={setSteps} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{formError && <p className="form-error">{formError}</p>}
|
||||||
|
|
||||||
|
<div className="recipe-form__actions">
|
||||||
|
<button type="submit" disabled={isSubmitting || !canSubmit}>
|
||||||
|
{isSubmitting
|
||||||
|
? t("recipes.sources.import.submitting")
|
||||||
|
: t("recipes.sources.import.submit")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { BrowsableSourceItemView } from "@batch-cooking/shared";
|
import type { BrowsableSourceItemView, RecipeView } 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";
|
||||||
|
|
@ -37,51 +37,37 @@ type BrowseState =
|
||||||
* `RecipePickerDialog`/`CalendarPopover` elsewhere — simpler than this
|
* `RecipePickerDialog`/`CalendarPopover` elsewhere — simpler than this
|
||||||
* component reacting to its own `sourceKey` prop changing mid-lifetime.
|
* component reacting to its own `sourceKey` prop changing mid-lifetime.
|
||||||
*
|
*
|
||||||
* The right-hand preview reuses `RecipeDetailPanel` itself (its
|
* The right-hand preview reuses `RecipeDetailPanel` itself — for a
|
||||||
* `"loaded-draft"` state) rather than a separate component — viewing a
|
* not-yet-imported item, its `"loaded-draft"` state; for an already-
|
||||||
* not-yet-imported item is meant to look and feel exactly like viewing any
|
* imported one, this panel fetches the real thing (`GET /recipes/:id`)
|
||||||
* other recipe, minus the actions that don't apply to something that isn't
|
* and shows it through the exact same `"loaded"` state `RecipesPage` uses,
|
||||||
* saved yet. This is `RecipesPage`'s own mode: browsing/reading only,
|
* `showActions={false}` since editing/deleting isn't a click that belongs
|
||||||
* nothing here ever imports anything (see `onSelectDraftItem` below).
|
* on a browsing/preview screen. Either way, selecting a row only ever
|
||||||
*
|
* previews here — nothing about clicking one imports, saves, or navigates
|
||||||
* Selecting an already-imported item navigates straight to its real
|
* by itself; what a selection *means* is entirely up to the caller
|
||||||
* recipe (`/recettes/:id`, leaving this tab) — `onSelectImportedRecipe`
|
* (`onSelectImportedRecipe`/`onDraftSelected` below just report which one
|
||||||
* hands back the id instead of this panel navigating anywhere itself, since
|
* is currently previewed).
|
||||||
* what "viewing" an already-imported item means depends on the caller:
|
|
||||||
* `RecipesPage` navigates to the recipe's detail page (switching its own
|
|
||||||
* active tab first — see its own doc comment), while `RecipePickerDialog`
|
|
||||||
* instead treats it exactly like picking that recipe from one of the
|
|
||||||
* regular tabs — moving to its own confirm-portions step, no navigation
|
|
||||||
* at all.
|
|
||||||
*
|
*
|
||||||
* `initialSelection`/`onItemSelected` are how `RecipesPage` keeps a
|
* `initialSelection`/`onItemSelected` are how `RecipesPage` keeps a
|
||||||
* not-yet-imported item's preview addressable by URL
|
* not-yet-imported item's preview addressable by URL
|
||||||
* (`/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: it never previews a not-yet-
|
* `RecipePickerDialog` leaves both unset: previewing inside that dialog
|
||||||
* imported item inline at all (see `onSelectDraftItem`).
|
* has no URL of its own to keep in sync.
|
||||||
*/
|
*/
|
||||||
export function RecipeSourcesPanel({
|
export function RecipeSourcesPanel({
|
||||||
sourceKey,
|
sourceKey,
|
||||||
onSelectImportedRecipe,
|
onSelectImportedRecipe,
|
||||||
onSelectDraftItem,
|
onDraftSelected,
|
||||||
initialSelection,
|
initialSelection,
|
||||||
onItemSelected,
|
onItemSelected,
|
||||||
}: {
|
}: {
|
||||||
sourceKey: string;
|
sourceKey: string;
|
||||||
onSelectImportedRecipe: (recipeId: number) => void;
|
/** Fired once an already-imported row's own fetch resolves — the full recipe, already what this panel is itself previewing, handed up so the caller (`RecipePickerDialog`) knows a real recipe is now the pending selection without fetching it again itself. */
|
||||||
/**
|
onSelectImportedRecipe: (recipe: RecipeView) => void;
|
||||||
* Set only by `RecipePickerDialog` — picking a not-yet-imported item
|
/** Fired the moment a not-yet-imported row is clicked (before its own preview fetch even resolves) — same "which one is pending" role as `onSelectImportedRecipe`, just for a draft instead of a real recipe. Only `RecipePickerDialog` sets this; `RecipesPage` has nothing to do with "pending" since browsing there is never building up to a confirm step. */
|
||||||
* while adding to a planning slot isn't something to preview inline
|
onDraftSelected?: (selection: SourceItemSelection) => void;
|
||||||
* 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;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -175,11 +161,15 @@ export function RecipeSourcesPanel({
|
||||||
|
|
||||||
function handleSelectItem(item: BrowsableSourceItemView) {
|
function handleSelectItem(item: BrowsableSourceItemView) {
|
||||||
if (item.alreadyImported && item.recipeId !== null) {
|
if (item.alreadyImported && item.recipeId !== null) {
|
||||||
onSelectImportedRecipe(item.recipeId);
|
setSelectedExternalId(item.externalId);
|
||||||
return;
|
setPreviewState({ status: "loading" });
|
||||||
}
|
apiClient
|
||||||
if (onSelectDraftItem) {
|
.getRecipe(item.recipeId)
|
||||||
onSelectDraftItem(item);
|
.then((recipe) => {
|
||||||
|
setPreviewState({ status: "loaded", recipe });
|
||||||
|
onSelectImportedRecipe(recipe);
|
||||||
|
})
|
||||||
|
.catch(() => setPreviewState({ status: "error" }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const selection = { sourceKey, externalId: item.externalId };
|
const selection = { sourceKey, externalId: item.externalId };
|
||||||
|
|
@ -190,6 +180,7 @@ export function RecipeSourcesPanel({
|
||||||
// not a fresh one to make (see that effect's own doc comment).
|
// not a fresh one to make (see that effect's own doc comment).
|
||||||
setPreviewedItem(selection);
|
setPreviewedItem(selection);
|
||||||
onItemSelected?.(selection);
|
onItemSelected?.(selection);
|
||||||
|
onDraftSelected?.(selection);
|
||||||
apiClient
|
apiClient
|
||||||
.previewSourceItem(sourceKey, item.externalId)
|
.previewSourceItem(sourceKey, item.externalId)
|
||||||
.then((draft) => setPreviewState({ status: "loaded-draft", draft }))
|
.then((draft) => setPreviewState({ status: "loaded-draft", draft }))
|
||||||
|
|
@ -235,7 +226,7 @@ export function RecipeSourcesPanel({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<RecipeDetailPanel state={previewState} />
|
<RecipeDetailPanel state={previewState} showActions={false} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -143,7 +143,8 @@
|
||||||
"backButton": "Retour",
|
"backButton": "Retour",
|
||||||
"confirmButton": "Ajouter au planning",
|
"confirmButton": "Ajouter au planning",
|
||||||
"adding": "Ajout…",
|
"adding": "Ajout…",
|
||||||
"addingDraft": "Ajout au planning…"
|
"footerClose": "Fermer",
|
||||||
|
"footerConfirm": "Confirmer"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,8 @@
|
||||||
import {
|
import { MEALS, type Meal, WEEK_DAYS, type WeekDay } from "@batch-cooking/shared";
|
||||||
type CreateRecipeInput,
|
|
||||||
type DietView,
|
|
||||||
ErrorCode,
|
|
||||||
type IngredientView,
|
|
||||||
MEALS,
|
|
||||||
type Meal,
|
|
||||||
type RecipeVisibility,
|
|
||||||
type UnitView,
|
|
||||||
WEEK_DAYS,
|
|
||||||
type WeekDay,
|
|
||||||
createRecipeSchema,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
import { type FormEvent, useEffect, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
import { ApiError, apiClient } from "../api/client";
|
import { RecipeImportForm } from "../features/recipes/RecipeImportForm";
|
||||||
import { DietTagSelect } from "../features/recipes/DietTagSelect";
|
|
||||||
import { IngredientPicker } from "../features/recipes/IngredientPicker";
|
|
||||||
import { IngredientRow } from "../features/recipes/IngredientRow";
|
|
||||||
import { type StepDraft, StepListEditor } from "../features/recipes/StepListEditor";
|
|
||||||
import "../features/recipes/recipes.scss";
|
import "../features/recipes/recipes.scss";
|
||||||
import { makeClientKey } from "../lib/client-key";
|
|
||||||
import { errorMessageService } from "../services/error-message.service";
|
|
||||||
|
|
||||||
/** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). Same list as `RecipeFormPage`. */
|
|
||||||
const VISIBILITY_OPTIONS: RecipeVisibility[] = ["PERSONAL", "HOUSE", "PUBLIC"];
|
|
||||||
|
|
||||||
/** A resolved ingredient line — identical shape to `RecipeFormPage`'s own `IngredientLine`. */
|
|
||||||
interface IngredientLine {
|
|
||||||
key: string;
|
|
||||||
ingredient: IngredientView;
|
|
||||||
quantity: string;
|
|
||||||
unitId: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One draft line that didn't resolve to a real `Ingredient` on preview (`DraftRecipeIngredientView.ingredient === null`) — still needs a person to pick the right one, or discard it, before this recipe can be saved. */
|
|
||||||
interface UnresolvedIngredientLine {
|
|
||||||
key: string;
|
|
||||||
rawText: string;
|
|
||||||
quantity: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type LoadState = "loading" | "loaded" | "error";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads and validates `?planningDate=&planningWeekDay=&planningMeal=` off
|
* Reads and validates `?planningDate=&planningWeekDay=&planningMeal=` off
|
||||||
|
|
@ -71,41 +32,20 @@ function parsePlanningSlot(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Review screen for finalizing an import — routed at
|
* Standalone route wrapper around `RecipeImportForm`, at
|
||||||
* `/recettes/importer/:sourceKey/:externalId`. Reached only one way now:
|
* `/recettes/importer/:sourceKey/:externalId` — a directly-linkable
|
||||||
* `RecipePickerDialog.handleSelectDraftItem`'s fallback, when picking a
|
* fallback (a stale bookmark, a reload mid-flow) rather than the normal way
|
||||||
* not-yet-imported source item to add to a planning slot turns out to need
|
* this form is reached now: `RecipePickerDialog` embeds `RecipeImportForm`
|
||||||
* a person's input (an ingredient line the automatic matching
|
* directly as one of its own steps, without ever navigating here, when
|
||||||
* (`ingredient-matcher.ts`) couldn't resolve, say) — a draft with nothing
|
* picking a not-yet-imported source item turns out to need a person's
|
||||||
* missing imports transparently from that dialog instead, without ever
|
* input (see that dialog's own doc comment). Landing on a real page instead
|
||||||
* reaching this screen (`tryBuildCompleteImport`). There is no other way
|
* of a dialog step just changes where a successful import goes afterwards —
|
||||||
* in any more: browsing a source outside of adding-to-planning
|
* this page's own `handleImported` navigates, the dialog's own handler
|
||||||
* (`RecipesPage`/`RecipeSourcesPanel`) only ever previews, on purpose — a
|
* closes itself instead.
|
||||||
* 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
|
* `?planningDate=&planningWeekDay=&planningMeal=` would only realistically
|
||||||
* draft the transparent-import attempt already fetched), structurally the
|
* be set by a stale URL at this point (the dialog's own path never
|
||||||
* same form as `RecipeFormPage` — same sub-components (`IngredientRow`,
|
* navigates), still parsed the same defensive way as before.
|
||||||
* `IngredientPicker`, `StepListEditor`, `DietTagSelect`), same
|
|
||||||
* `CreateRecipeInput` submit shape — plus one thing a manual creation
|
|
||||||
* never has to handle: ingredient lines the automatic matching couldn't
|
|
||||||
* resolve. Those render as their own "à compléter" list, each needing a
|
|
||||||
* real ingredient picked (or the line discarded) before the form can
|
|
||||||
* submit — never silently drops/guesses one, per the product decision this
|
|
||||||
* stage was built against (no invalid recipe is ever persisted).
|
|
||||||
*
|
|
||||||
* Submits to `POST /sources/:sourceKey/import/:externalId`
|
|
||||||
* (`apiClient.importSourceItem`) instead of `POST /recipes` — the only
|
|
||||||
* other difference from `RecipeFormPage`'s own submit.
|
|
||||||
*
|
|
||||||
* `?planningDate=&planningWeekDay=&planningMeal=` carry the planning slot
|
|
||||||
* `RecipePickerDialog` was adding to along as query params (always present
|
|
||||||
* in practice, given the only entry point above — still parsed
|
|
||||||
* defensively, see `parsePlanningSlot`). A successful import adds the
|
|
||||||
* freshly created recipe straight to that slot (`POST /planning/items`,
|
|
||||||
* using this form's own `portions` field) before landing back on the
|
|
||||||
* planning page, instead of the recipe's own detail page.
|
|
||||||
*/
|
*/
|
||||||
export function ImportRecipePage() {
|
export function ImportRecipePage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -114,218 +54,7 @@ export function ImportRecipePage() {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const planningSlot = parsePlanningSlot(searchParams);
|
const planningSlot = parsePlanningSlot(searchParams);
|
||||||
|
|
||||||
const [loadState, setLoadState] = useState<LoadState>("loading");
|
|
||||||
const [ingredientsCatalog, setIngredientsCatalog] = useState<IngredientView[]>([]);
|
|
||||||
const [dietsCatalog, setDietsCatalog] = useState<DietView[]>([]);
|
|
||||||
const [unitsCatalog, setUnitsCatalog] = useState<UnitView[]>([]);
|
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [description, setDescription] = useState("");
|
|
||||||
const [picture, setPicture] = useState("");
|
|
||||||
const [portions, setPortions] = useState("4");
|
|
||||||
const [visibility, setVisibility] = useState<RecipeVisibility>("PERSONAL");
|
|
||||||
const [dietIds, setDietIds] = useState<number[]>([]);
|
|
||||||
const [ingredientLines, setIngredientLines] = useState<IngredientLine[]>([]);
|
|
||||||
const [unresolvedIngredients, setUnresolvedIngredients] = useState<UnresolvedIngredientLine[]>(
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
// Which unresolved line's picker is currently open — at most one at a
|
|
||||||
// time (IngredientPicker is a whole browsable grid, not a compact
|
|
||||||
// popover; showing one per unresolved line at once would be unwieldy).
|
|
||||||
const [resolvingKey, setResolvingKey] = useState<string | null>(null);
|
|
||||||
const [steps, setSteps] = useState<StepDraft[]>([]);
|
|
||||||
|
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (sourceKey === undefined || externalId === undefined) {
|
if (sourceKey === undefined || externalId === undefined) {
|
||||||
setLoadState("error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let cancelled = false;
|
|
||||||
setLoadState("loading");
|
|
||||||
|
|
||||||
Promise.all([
|
|
||||||
apiClient.getIngredients(),
|
|
||||||
apiClient.getDiets(),
|
|
||||||
apiClient.getUnits(),
|
|
||||||
apiClient.previewSourceItem(sourceKey, externalId),
|
|
||||||
])
|
|
||||||
.then(([ingredients, diets, units, draft]) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setIngredientsCatalog(ingredients);
|
|
||||||
setDietsCatalog(diets);
|
|
||||||
setUnitsCatalog(units);
|
|
||||||
|
|
||||||
setName(draft.name);
|
|
||||||
setDescription(draft.description ?? "");
|
|
||||||
setPicture(draft.picture ?? "");
|
|
||||||
setPortions(draft.portions !== null ? String(draft.portions) : "4");
|
|
||||||
|
|
||||||
const resolved: IngredientLine[] = [];
|
|
||||||
const unresolved: UnresolvedIngredientLine[] = [];
|
|
||||||
for (const line of draft.ingredients) {
|
|
||||||
if (line.ingredient !== null) {
|
|
||||||
resolved.push({
|
|
||||||
key: makeClientKey(),
|
|
||||||
ingredient: line.ingredient,
|
|
||||||
quantity: line.quantity !== null ? String(line.quantity) : "",
|
|
||||||
unitId: line.unit?.id ?? null,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
unresolved.push({
|
|
||||||
key: makeClientKey(),
|
|
||||||
rawText: line.rawText,
|
|
||||||
quantity: line.quantity !== null ? String(line.quantity) : "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setIngredientLines(resolved);
|
|
||||||
setUnresolvedIngredients(unresolved);
|
|
||||||
|
|
||||||
setSteps(
|
|
||||||
draft.steps.map((step) => ({
|
|
||||||
key: makeClientKey(),
|
|
||||||
description: step.description,
|
|
||||||
picture: step.picture ?? "",
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
setLoadState("loaded");
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!cancelled) setLoadState("error");
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [sourceKey, externalId]);
|
|
||||||
|
|
||||||
function addIngredient(ingredient: IngredientView) {
|
|
||||||
setIngredientLines((lines) => [
|
|
||||||
...lines,
|
|
||||||
{ key: makeClientKey(), ingredient, quantity: "", unitId: null },
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateIngredientLine(
|
|
||||||
key: string,
|
|
||||||
patch: Partial<Pick<IngredientLine, "quantity" | "unitId">>,
|
|
||||||
) {
|
|
||||||
setIngredientLines((lines) =>
|
|
||||||
lines.map((line) => (line.key === key ? { ...line, ...patch } : line)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeIngredientLine(key: string) {
|
|
||||||
setIngredientLines((lines) => lines.filter((line) => line.key !== key));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Promotes an unresolved line into a real ingredient line, carrying its quantity over — its unit still needs picking, same as a freshly-added ingredient. */
|
|
||||||
function resolveIngredient(unresolvedKey: string, ingredient: IngredientView) {
|
|
||||||
setUnresolvedIngredients((lines) => {
|
|
||||||
const line = lines.find((l) => l.key === unresolvedKey);
|
|
||||||
if (line) {
|
|
||||||
setIngredientLines((resolved) => [
|
|
||||||
...resolved,
|
|
||||||
{ key: makeClientKey(), ingredient, quantity: line.quantity, unitId: null },
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
return lines.filter((l) => l.key !== unresolvedKey);
|
|
||||||
});
|
|
||||||
setResolvingKey(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function discardUnresolvedIngredient(key: string) {
|
|
||||||
setUnresolvedIngredients((lines) => lines.filter((line) => line.key !== key));
|
|
||||||
setResolvingKey((current) => (current === key ? null : current));
|
|
||||||
}
|
|
||||||
|
|
||||||
const canSubmit =
|
|
||||||
name.trim().length > 0 &&
|
|
||||||
Number.isInteger(Number(portions)) &&
|
|
||||||
Number(portions) > 0 &&
|
|
||||||
ingredientLines.length > 0 &&
|
|
||||||
ingredientLines.every((line) => Number(line.quantity) > 0 && line.unitId !== null) &&
|
|
||||||
unresolvedIngredients.length === 0 &&
|
|
||||||
steps.length > 0 &&
|
|
||||||
steps.every((step) => step.description.trim().length > 0);
|
|
||||||
|
|
||||||
async function handleSubmit(e: FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
setFormError(null);
|
|
||||||
if (sourceKey === undefined || externalId === undefined) return;
|
|
||||||
|
|
||||||
const payload: CreateRecipeInput = {
|
|
||||||
name: name.trim(),
|
|
||||||
description: description.trim() || null,
|
|
||||||
picture: picture.trim() || null,
|
|
||||||
portions: Number(portions),
|
|
||||||
visibility,
|
|
||||||
dietIds,
|
|
||||||
ingredients: ingredientLines.map((line) => ({
|
|
||||||
ingredientId: line.ingredient.id,
|
|
||||||
quantity: Number(line.quantity),
|
|
||||||
// `canSubmit` already requires every line to have a unit picked —
|
|
||||||
// same "?? 0, the schema rejects it if ever reached" reasoning as
|
|
||||||
// RecipeFormPage's identical submit.
|
|
||||||
unitId: line.unitId ?? 0,
|
|
||||||
})),
|
|
||||||
steps: steps.map((step) => ({
|
|
||||||
description: step.description.trim(),
|
|
||||||
picture: step.picture.trim() || null,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = createRecipeSchema.safeParse(payload);
|
|
||||||
if (!result.success) {
|
|
||||||
setFormError(result.error.issues[0]?.message ?? t("recipes.sources.import.genericError"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
try {
|
|
||||||
const saved = await apiClient.importSourceItem(sourceKey, externalId, result.data);
|
|
||||||
if (planningSlot) {
|
|
||||||
try {
|
|
||||||
await apiClient.addPlanningItem({
|
|
||||||
date: planningSlot.date,
|
|
||||||
weekDay: planningSlot.weekDay,
|
|
||||||
meal: planningSlot.meal,
|
|
||||||
recipeId: saved.id,
|
|
||||||
portions: Number(portions),
|
|
||||||
});
|
|
||||||
navigate("/");
|
|
||||||
return;
|
|
||||||
} catch {
|
|
||||||
// The recipe itself was already imported successfully — only the
|
|
||||||
// planning add failed. Land on the new recipe's own page rather
|
|
||||||
// than stranding the user on a form that already submitted; it
|
|
||||||
// can still be added to that slot afterwards via the normal
|
|
||||||
// "déjà importée" picker path.
|
|
||||||
navigate(`/recettes/${saved.id}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
navigate(`/recettes/${saved.id}`);
|
|
||||||
} catch (err) {
|
|
||||||
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
|
||||||
setFormError(errorMessageService.getLabel(code));
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loadState === "loading") {
|
|
||||||
return (
|
|
||||||
<div className="recipe-form">
|
|
||||||
<p className="recipes-page__status">{t("recipes.loading")}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loadState === "error") {
|
|
||||||
return (
|
return (
|
||||||
<div className="recipe-form">
|
<div className="recipe-form">
|
||||||
<p className="recipes-page__status recipes-page__status--error">
|
<p className="recipes-page__status recipes-page__status--error">
|
||||||
|
|
@ -335,134 +64,17 @@ export function ImportRecipePage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedIds = ingredientLines.map((line) => line.ingredient.id);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="recipe-form" onSubmit={handleSubmit} noValidate>
|
<div>
|
||||||
<h1>{t("recipes.sources.import.title")}</h1>
|
<h1>{t("recipes.sources.import.title")}</h1>
|
||||||
{planningSlot && (
|
<RecipeImportForm
|
||||||
<p className="source-item-preview__hint">{t("recipes.sources.import.planningHint")}</p>
|
sourceKey={sourceKey}
|
||||||
)}
|
externalId={externalId}
|
||||||
|
planningSlot={planningSlot ?? undefined}
|
||||||
<label htmlFor="recipe-name">{t("recipes.form.nameLabel")}</label>
|
onImported={({ recipe, planningItem }) => {
|
||||||
<input id="recipe-name" value={name} onChange={(e) => setName(e.target.value)} />
|
navigate(planningItem ? "/" : `/recettes/${recipe.id}`);
|
||||||
|
}}
|
||||||
<label htmlFor="recipe-description">{t("recipes.form.descriptionLabel")}</label>
|
|
||||||
<textarea
|
|
||||||
id="recipe-description"
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
rows={3}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label htmlFor="recipe-picture">{t("recipes.form.pictureLabel")}</label>
|
|
||||||
<input
|
|
||||||
id="recipe-picture"
|
|
||||||
type="url"
|
|
||||||
value={picture}
|
|
||||||
onChange={(e) => setPicture(e.target.value)}
|
|
||||||
placeholder="https://…"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<label htmlFor="recipe-portions">{t("recipes.form.portionsLabel")}</label>
|
|
||||||
<input
|
|
||||||
id="recipe-portions"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
step="1"
|
|
||||||
value={portions}
|
|
||||||
onChange={(e) => setPortions(e.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<label htmlFor="recipe-visibility">{t("recipes.form.visibilityLabel")}</label>
|
|
||||||
<select
|
|
||||||
id="recipe-visibility"
|
|
||||||
value={visibility}
|
|
||||||
onChange={(e) => setVisibility(e.target.value as RecipeVisibility)}
|
|
||||||
>
|
|
||||||
{VISIBILITY_OPTIONS.map((option) => (
|
|
||||||
<option key={option} value={option}>
|
|
||||||
{t(`recipes.form.visibility.${option}`)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<DietTagSelect diets={dietsCatalog} value={dietIds} onChange={setDietIds} />
|
|
||||||
|
|
||||||
<section className="recipe-form__section">
|
|
||||||
<h2>{t("recipes.ingredientsTitle")}</h2>
|
|
||||||
<ul className="recipe-form__ingredient-list">
|
|
||||||
{ingredientLines.map((line) => (
|
|
||||||
<IngredientRow
|
|
||||||
key={line.key}
|
|
||||||
ingredient={line.ingredient}
|
|
||||||
quantity={line.quantity}
|
|
||||||
unitId={line.unitId}
|
|
||||||
unitsCatalog={unitsCatalog}
|
|
||||||
onQuantityChange={(quantity) => updateIngredientLine(line.key, { quantity })}
|
|
||||||
onUnitChange={(unitId) => updateIngredientLine(line.key, { unitId })}
|
|
||||||
onRemove={() => removeIngredientLine(line.key)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{unresolvedIngredients.length > 0 && (
|
|
||||||
<section className="import-recipe__unresolved">
|
|
||||||
<h3>{t("recipes.sources.import.unresolvedTitle")}</h3>
|
|
||||||
<p className="source-item-preview__hint">
|
|
||||||
{t("recipes.sources.import.unresolvedHint")}
|
|
||||||
</p>
|
|
||||||
<ul className="import-recipe__unresolved-list">
|
|
||||||
{unresolvedIngredients.map((line) => (
|
|
||||||
<li key={line.key}>
|
|
||||||
<div className="import-recipe__unresolved-row">
|
|
||||||
<span>{line.rawText}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
setResolvingKey((current) => (current === line.key ? null : line.key))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("recipes.sources.import.resolveButton")}
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={() => discardUnresolvedIngredient(line.key)}>
|
|
||||||
{t("recipes.sources.import.discardButton")}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{resolvingKey === line.key && (
|
|
||||||
<IngredientPicker
|
|
||||||
ingredients={ingredientsCatalog}
|
|
||||||
excludeIds={selectedIds}
|
|
||||||
onSelect={(ingredient) => resolveIngredient(line.key, ingredient)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<IngredientPicker
|
|
||||||
ingredients={ingredientsCatalog}
|
|
||||||
excludeIds={selectedIds}
|
|
||||||
onSelect={addIngredient}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="recipe-form__section">
|
|
||||||
<h2>{t("recipes.stepsTitle")}</h2>
|
|
||||||
<StepListEditor steps={steps} onChange={setSteps} />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{formError && <p className="form-error">{formError}</p>}
|
|
||||||
|
|
||||||
<div className="recipe-form__actions">
|
|
||||||
<button type="submit" disabled={isSubmitting || !canSubmit}>
|
|
||||||
{isSubmitting
|
|
||||||
? t("recipes.sources.import.submitting")
|
|
||||||
: t("recipes.sources.import.submit")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -217,9 +217,9 @@ export function RecipesPage() {
|
||||||
: "/recettes",
|
: "/recettes",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onSelectImportedRecipe={(recipeId) => {
|
onSelectImportedRecipe={(recipe) => {
|
||||||
setActiveTab("favoris");
|
setActiveTab("favoris");
|
||||||
navigate(`/recettes/${recipeId}`);
|
navigate(`/recettes/${recipe.id}`);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue