Merge pull request #51 from kyuno053/feat/unify-source-detail-view
fix(recipes): retire l'import manuel, le planning importe seul
This commit is contained in:
commit
f7d7664397
17 changed files with 1094 additions and 699 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, importing it on the way
|
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,7 +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 link "Importer cette recette"
|
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"
|
||||||
|
|
@ -32,5 +41,19 @@ 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: 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"
|
||||||
|
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"
|
||||||
|
And I click the button "Confirmer"
|
||||||
|
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 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: [] });
|
||||||
});
|
});
|
||||||
|
|
@ -49,6 +42,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 +94,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.
|
||||||
|
|
@ -134,50 +209,26 @@ 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;
|
statusCode: 201,
|
||||||
req.reply({
|
body: {
|
||||||
statusCode: 201,
|
id: 1,
|
||||||
body: {
|
weekDay: "lundi",
|
||||||
id: 1,
|
meal: "petit-dejeuner",
|
||||||
weekDay: "lundi",
|
portions: 4,
|
||||||
meal: "petit-dejeuner",
|
recipe: { id: 99, name: "Fish Pie" },
|
||||||
portions: 4,
|
},
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -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 }]);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,17 +5,21 @@ import {
|
||||||
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";
|
||||||
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,
|
||||||
|
|
@ -23,6 +27,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,26 +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 (unlike an earlier
|
* household-enabled source's own tab is included too.
|
||||||
* version of this dialog — see `ImportRecipePage`'s `planningSlot`, the
|
|
||||||
* review/import flow that made including them here worthwhile): picking
|
|
||||||
* an already-imported item behaves exactly like picking a regular recipe,
|
|
||||||
* and picking one that isn't imported yet hands off to that review
|
|
||||||
* screen, which adds the freshly-created recipe straight to this slot
|
|
||||||
* 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
|
||||||
* 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. The one exception is picking a not-yet-
|
* `RecipesPage`'s own layout) fetches and previews a real recipe; a
|
||||||
* imported source item, which does navigate away entirely (to
|
* source tab's `RecipeSourcesPanel` already previews either kind of row it
|
||||||
* `/recettes/importer/...`) — that flow has its own portions field
|
* has (already-imported or not) inline, on its own. Nothing about a click
|
||||||
* already, on the review screen itself.
|
* commits to anything by itself — the pinned footer's "Confirmer" button
|
||||||
|
* (`handleFooterConfirm`) is what acts on whichever preview is currently
|
||||||
|
* pending (`previewedRecipe`/`previewedDraft`, mutually exclusive):
|
||||||
|
* - A real recipe (regular tab, or an already-imported source item) moves
|
||||||
|
* 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,
|
||||||
|
|
@ -86,9 +101,18 @@ 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);
|
||||||
|
|
||||||
|
/** 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("");
|
||||||
|
|
@ -101,14 +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>({
|
||||||
|
status: "empty",
|
||||||
|
});
|
||||||
|
// Which real recipe is the pending selection — from either the regular
|
||||||
|
// tabs' own preview above, or a source tab's already-imported row
|
||||||
|
// (`RecipeSourcesPanel`'s `onSelectImportedRecipe`, which already
|
||||||
|
// 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);
|
||||||
|
|
@ -168,16 +215,106 @@ 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" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A source tab's already-imported row — `RecipeSourcesPanel` already fetched and is previewing it itself; this just records it as the pending selection. */
|
||||||
|
function handleSelectImportedRecipe(recipe: RecipeView) {
|
||||||
|
setPreviewedDraft(null);
|
||||||
|
setPreviewedRecipe(recipe);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The footer's "Confirmer" — acts on whichever preview is currently
|
||||||
|
* pending. A real recipe moves to the small portions step below; a
|
||||||
|
* not-yet-imported draft runs {@link confirmDraftSelection}.
|
||||||
|
*/
|
||||||
|
function handleFooterConfirm() {
|
||||||
|
if (previewedRecipe) {
|
||||||
|
setSelectedRecipe(previewedRecipe);
|
||||||
|
setPortions(String(previewedRecipe.portions));
|
||||||
|
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>;
|
||||||
|
try {
|
||||||
|
payload = tryBuildCompleteImport(await apiClient.previewSourceItem(sourceKey, externalId));
|
||||||
|
} catch {
|
||||||
|
needsReview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!payload) {
|
||||||
|
needsReview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let saved: RecipeView;
|
||||||
|
try {
|
||||||
|
saved = await apiClient.importSourceItem(sourceKey, externalId, payload);
|
||||||
|
} catch {
|
||||||
|
needsReview();
|
||||||
|
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 (same fallback `RecipeImportForm`'s own submit takes
|
||||||
|
// for the identical failure — see this dialog's `onImported` handler
|
||||||
|
// below).
|
||||||
|
setIsConfirmingDraft(false);
|
||||||
|
navigate(`/recettes/${saved.id}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleConfirm() {
|
async function handleConfirm() {
|
||||||
|
|
@ -204,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
|
||||||
|
|
@ -239,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
|
||||||
|
|
@ -308,24 +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 ? (
|
||||||
<>
|
<RecipeSourcesPanel
|
||||||
{sourceSelectError && (
|
key={activeSourceKey}
|
||||||
<p className="recipes-page__status recipes-page__status--error">
|
sourceKey={activeSourceKey}
|
||||||
{t("common.loadError")}
|
onSelectImportedRecipe={handleSelectImportedRecipe}
|
||||||
</p>
|
onDraftSelected={handleDraftSelected}
|
||||||
)}
|
/>
|
||||||
<RecipeSourcesPanel
|
|
||||||
key={activeSourceKey}
|
|
||||||
sourceKey={activeSourceKey}
|
|
||||||
planningSlot={slot}
|
|
||||||
onSelectImportedRecipe={handleSelectImportedRecipe}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{listState.status === "loading" && (
|
{listState.status === "loading" && (
|
||||||
|
|
@ -340,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 && (
|
||||||
<RecipeTable
|
<div className="recipes-page__catalog">
|
||||||
recipes={listState.recipes}
|
<RecipeTable
|
||||||
selectedId={null}
|
recipes={listState.recipes}
|
||||||
onSelect={(id) => {
|
selectedId={previewedRecipe?.id ?? null}
|
||||||
const recipe = listState.recipes.find((r) => r.id === id) ?? null;
|
onSelect={handleSelectRegularRecipe}
|
||||||
setSelectedRecipe(recipe);
|
/>
|
||||||
// Pre-fill from the recipe's own written yield rather than
|
<RecipeDetailPanel state={regularPreviewState} showActions={false} />
|
||||||
// always starting at 1 — still freely editable below, this
|
</div>
|
||||||
// is just a better starting point (see `Recipe.portions`).
|
|
||||||
if (recipe) setPortions(String(recipe.portions));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,56 @@
|
||||||
// RecipeTabs import it themselves).
|
// RecipeTabs import it themselves).
|
||||||
|
|
||||||
.recipe-picker-dialog {
|
.recipe-picker-dialog {
|
||||||
max-width: 56rem;
|
width: 95vw;
|
||||||
|
max-width: 85rem;
|
||||||
|
// Fixed, not just capped — `.dialog-panel`'s own `max-height` (dialog.scss)
|
||||||
|
// only bounds how tall a shrink-to-fit dialog can grow, which is right
|
||||||
|
// for every other dialog's small form but leaves this one's browsing
|
||||||
|
// step at the mercy of how much content happens to be on screen (a short
|
||||||
|
// personal-recipe tab vs. a ~25-item source browse). Pinning both
|
||||||
|
// `height`/`max-height` to the same `80vh` keeps the browsing area a
|
||||||
|
// consistent, generous size regardless of tab/content, on top of the
|
||||||
|
// `.dialog-panel__body` fix below that makes that area actually use the
|
||||||
|
// space instead of overflowing it.
|
||||||
|
height: 80vh;
|
||||||
|
max-height: 80vh;
|
||||||
|
|
||||||
|
// `.dialog-panel__body` (dialog.scss) is a plain block-flow scroll
|
||||||
|
// container by default — fine for every other dialog's small form, but
|
||||||
|
// this one's browsing step embeds the same components `/recettes` uses
|
||||||
|
// (`RecipeTable`'s `.recipe-table-wrap`, `RecipeSourcesPanel`'s
|
||||||
|
// `.recipes-page__catalog`), both of which size themselves with
|
||||||
|
// `flex: 1; min-height: 0` and need a `display: flex` ancestor for that
|
||||||
|
// to mean anything — on the real page that ancestor is `.recipes-page`
|
||||||
|
// itself (see its own doc comment for the identical fix that page
|
||||||
|
// needed once); this dialog never renders that wrapper, so without this
|
||||||
|
// the catalog/table just grew to its full content height instead of
|
||||||
|
// being clipped and independently scrollable within the dialog's own
|
||||||
|
// bounds — harmless for the handful of rows a personal recipe tab
|
||||||
|
// usually has, but a source tab's ~25-item browse list made it obvious:
|
||||||
|
// everything past the dialog's fixed height rendered, technically, just
|
||||||
|
// never inside the visible/scrollable area. Scoped to this dialog only
|
||||||
|
// — every other `Dialog` caller keeps the plain block layout.
|
||||||
|
.dialog-panel__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-table-wrap {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `.recipes-page__catalog`'s own column split (recipes.scss) sizes the
|
||||||
|
// detail pane with `minmax(35vw, 38vw)` — a fraction of the *viewport*,
|
||||||
|
// which tracked `/recettes`' own width there (that grid spans nearly the
|
||||||
|
// whole page) but has nothing to do with this dialog's width, now a
|
||||||
|
// fixed 92vw/75rem of its own. Overridden here as a fraction of the
|
||||||
|
// dialog itself instead, so the two panes stay proportionate to each
|
||||||
|
// other regardless of viewport size.
|
||||||
|
.recipes-page__catalog {
|
||||||
|
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-picker__filters {
|
.recipe-picker__filters {
|
||||||
|
|
@ -111,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;
|
||||||
|
|
||||||
|
|
@ -123,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,14 @@ export function RecipeDetailPanel({
|
||||||
dislikedIngredientIds = [],
|
dislikedIngredientIds = [],
|
||||||
onFavoriteToggled,
|
onFavoriteToggled,
|
||||||
onDeleted,
|
onDeleted,
|
||||||
planningSlot,
|
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. */
|
||||||
* Set only when this panel is rendered from `RecipePickerDialog` (adding a
|
showActions?: boolean;
|
||||||
* 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 +100,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 +125,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>
|
||||||
|
|
@ -175,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>
|
||||||
<FavoriteStarButton
|
{showActions && (
|
||||||
recipeId={recipe.id}
|
<FavoriteStarButton
|
||||||
isFavorite={recipe.isFavorite}
|
recipeId={recipe.id}
|
||||||
onToggled={(isFavorite) => onFavoriteToggled?.(recipe.id, isFavorite)}
|
isFavorite={recipe.isFavorite}
|
||||||
/>
|
onToggled={(isFavorite) => onFavoriteToggled?.(recipe.id, isFavorite)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="recipe-detail-panel__title-row">
|
<div className="recipe-detail-panel__title-row">
|
||||||
|
|
@ -203,12 +187,14 @@ export function RecipeDetailPanel({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="recipe-detail-panel__actions">
|
{showActions && (
|
||||||
<Link to={`/recettes/${recipe.id}/modifier`} className="recipes-page__new-button">
|
<div className="recipe-detail-panel__actions">
|
||||||
{t("recipes.editButton")}
|
<Link to={`/recettes/${recipe.id}/modifier`} className="recipes-page__new-button">
|
||||||
</Link>
|
{t("recipes.editButton")}
|
||||||
<DeleteRecipeButton recipeId={recipe.id} onDeleted={() => onDeleted?.(recipe.id)} />
|
</Link>
|
||||||
</div>
|
<DeleteRecipeButton recipeId={recipe.id} onDeleted={() => onDeleted?.(recipe.id)} />
|
||||||
|
</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, Meal, WeekDay } 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,41 +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, differing only in which actions are offered (there's an
|
* and shows it through the exact same `"loaded"` state `RecipesPage` uses,
|
||||||
* "Importer" button where Modifier/Supprimer would be).
|
* `showActions={false}` since editing/deleting isn't a click that belongs
|
||||||
*
|
* on a browsing/preview screen. Either way, selecting a row only ever
|
||||||
* Selecting an already-imported item navigates straight to its real
|
* previews here — nothing about clicking one imports, saves, or navigates
|
||||||
* recipe (`/recettes/:id`, leaving this tab) — `onSelectImportedRecipe`
|
* by itself; what a selection *means* is entirely up to the caller
|
||||||
* hands back the id instead of this panel navigating anywhere itself, since
|
* (`onSelectImportedRecipe`/`onDraftSelected` below just report which one
|
||||||
* what "viewing" an already-imported item means depends on the caller:
|
* is currently previewed).
|
||||||
* `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: previewing inside that modal has
|
* `RecipePickerDialog` leaves both unset: previewing inside that dialog
|
||||||
* no URL of its own to keep in sync.
|
* has no URL of its own to keep in sync.
|
||||||
*/
|
*/
|
||||||
export function RecipeSourcesPanel({
|
export function RecipeSourcesPanel({
|
||||||
sourceKey,
|
sourceKey,
|
||||||
onSelectImportedRecipe,
|
onSelectImportedRecipe,
|
||||||
planningSlot,
|
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. */
|
||||||
/** Forwarded as-is to `RecipeDetailPanel` — see its own doc comment. Only ever set by `RecipePickerDialog`. */
|
onSelectImportedRecipe: (recipe: RecipeView) => void;
|
||||||
planningSlot?: { date: string; weekDay: WeekDay; meal: Meal };
|
/** 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. */
|
||||||
|
onDraftSelected?: (selection: SourceItemSelection) => void;
|
||||||
initialSelection?: SourceItemSelection;
|
initialSelection?: SourceItemSelection;
|
||||||
onItemSelected?: (item: SourceItemSelection | null) => void;
|
onItemSelected?: (item: SourceItemSelection | null) => void;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -165,7 +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);
|
||||||
|
setPreviewState({ status: "loading" });
|
||||||
|
apiClient
|
||||||
|
.getRecipe(item.recipeId)
|
||||||
|
.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 };
|
||||||
|
|
@ -176,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 }))
|
||||||
|
|
@ -221,7 +226,7 @@ export function RecipeSourcesPanel({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<RecipeDetailPanel state={previewState} planningSlot={planningSlot} />
|
<RecipeDetailPanel state={previewState} showActions={false} />
|
||||||
</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;
|
||||||
|
}
|
||||||
|
|
@ -430,11 +430,9 @@
|
||||||
&__photo {
|
&__photo {
|
||||||
height: 9rem;
|
height: 9rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: linear-gradient(
|
background: linear-gradient(160deg,
|
||||||
160deg,
|
color-mix(in srgb, var(--color-primary) 22%, var(--color-surface-alt)),
|
||||||
color-mix(in srgb, var(--color-primary) 22%, var(--color-surface-alt)),
|
var(--color-surface-alt));
|
||||||
var(--color-surface-alt)
|
|
||||||
);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
@ -541,7 +539,7 @@
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: var(--space-md);
|
padding: var(--space-md);
|
||||||
|
|
||||||
& + & {
|
&+& {
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -674,6 +672,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
|
||||||
|
|
@ -693,7 +727,6 @@
|
||||||
.recipe-form {
|
.recipe-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
max-width: 40rem;
|
|
||||||
gap: var(--space-xs);
|
gap: var(--space-xs);
|
||||||
|
|
||||||
label {
|
label {
|
||||||
|
|
@ -1328,4 +1361,4 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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,9 @@
|
||||||
"portionsLabel": "Nombre de portions",
|
"portionsLabel": "Nombre de portions",
|
||||||
"backButton": "Retour",
|
"backButton": "Retour",
|
||||||
"confirmButton": "Ajouter au planning",
|
"confirmButton": "Ajouter au planning",
|
||||||
"adding": "Ajout…"
|
"adding": "Ajout…",
|
||||||
|
"footerClose": "Fermer",
|
||||||
|
"footerConfirm": "Confirmer"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
|
|
@ -177,8 +179,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",
|
||||||
|
|
|
||||||
|
|
@ -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,36 +32,20 @@ function parsePlanningSlot(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Review screen for finalizing an import — routed at
|
* Standalone route wrapper around `RecipeImportForm`, at
|
||||||
* `/recettes/importer/:sourceKey/:externalId` (reached from
|
* `/recettes/importer/:sourceKey/:externalId` — a directly-linkable
|
||||||
* `RecipeDetailPanel`'s "Importer cette recette" button, shown for its
|
* fallback (a stale bookmark, a reload mid-flow) rather than the normal way
|
||||||
* `"loaded-draft"` state). Pre-filled
|
* this form is reached now: `RecipePickerDialog` embeds `RecipeImportForm`
|
||||||
* from `GET /sources/:sourceKey/preview/:externalId` (the same draft the
|
* directly as one of its own steps, without ever navigating here, when
|
||||||
* preview panel already showed), structurally the same form as
|
* picking a not-yet-imported source item turns out to need a person's
|
||||||
* `RecipeFormPage` — same sub-components (`IngredientRow`,
|
* input (see that dialog's own doc comment). Landing on a real page instead
|
||||||
* `IngredientPicker`, `StepListEditor`, `DietTagSelect`), same
|
* of a dialog step just changes where a successful import goes afterwards —
|
||||||
* `CreateRecipeInput` submit shape — plus one thing a manual creation
|
* this page's own `handleImported` navigates, the dialog's own handler
|
||||||
* never has to handle: ingredient lines the automatic matching
|
* closes itself instead.
|
||||||
* (`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`
|
* `?planningDate=&planningWeekDay=&planningMeal=` would only realistically
|
||||||
* (`apiClient.importSourceItem`) instead of `POST /recipes` — the only
|
* be set by a stale URL at this point (the dialog's own path never
|
||||||
* other difference from `RecipeFormPage`'s own submit.
|
* navigates), still parsed the same defensive way as before.
|
||||||
*
|
|
||||||
* `?planningDate=&planningWeekDay=&planningMeal=` are set only when this
|
|
||||||
* page was reached from `RecipePickerDialog`'s "Sources" tab (via
|
|
||||||
* `RecipeDetailPanel`'s import link, see its own `planningSlot` prop) —
|
|
||||||
* picking a not-yet-imported item there hands off to this full review
|
|
||||||
* screen instead of the dialog's own small "how many portions?" step,
|
|
||||||
* 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
|
|
||||||
* planning page, instead of the recipe's own detail page.
|
|
||||||
*/
|
*/
|
||||||
export function ImportRecipePage() {
|
export function ImportRecipePage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -109,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");
|
if (sourceKey === undefined || externalId === undefined) {
|
||||||
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) {
|
|
||||||
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">
|
||||||
|
|
@ -330,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}
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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