Compare commits
11 commits
feat/tech-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f7aa696f55 | |||
| f19365e20e | |||
| 8a51f0bd6c | |||
| 32c7ed48c6 | |||
| 0ab30a4588 | |||
| 3c04efd16f | |||
| 7c423cc0fd | |||
| d58491e6f9 | |||
| 478914787f | |||
| 5ab9832131 | |||
|
|
eef5db92b5 |
7 changed files with 227 additions and 158 deletions
40
.github/workflows/ci.yml
vendored
40
.github/workflows/ci.yml
vendored
|
|
@ -12,7 +12,7 @@ on:
|
||||||
push:
|
push:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: "postgresql://ci:ci@localhost:5432/batchcooking_ci?schema=public"
|
DATABASE_URL: "postgresql://ci:ci@postgres:5432/batchcooking_ci?schema=public"
|
||||||
# Test-only secret, never used outside CI — real deployments must set their own.
|
# Test-only secret, never used outside CI — real deployments must set their own.
|
||||||
JWT_SECRET: "ci-only-secret-not-used-anywhere-else-32chars+"
|
JWT_SECRET: "ci-only-secret-not-used-anywhere-else-32chars+"
|
||||||
# Same reasoning as JWT_SECRET above — lets tech-step-worker.routes.test.ts
|
# Same reasoning as JWT_SECRET above — lets tech-step-worker.routes.test.ts
|
||||||
|
|
@ -33,11 +33,11 @@ jobs:
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: https://github.com/pnpm/action-setup@v4
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: https://github.com/actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
@ -55,27 +55,25 @@ jobs:
|
||||||
POSTGRES_PASSWORD: ci
|
POSTGRES_PASSWORD: ci
|
||||||
POSTGRES_DB: batchcooking_ci
|
POSTGRES_DB: batchcooking_ci
|
||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- 5433:5432
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd pg_isready
|
--health-cmd pg_isready
|
||||||
--health-interval 5s
|
--health-interval 5s
|
||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 10
|
--health-retries 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: https://github.com/pnpm/action-setup@v4
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: https://github.com/actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
- uses: https://github.com/astral-sh/setup-uv@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
- uses: astral-sh/setup-uv@v3
|
|
||||||
with:
|
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
|
|
||||||
# `services:` (like the `postgres` container above) can only pull an
|
# `services:` (like the `postgres` container above) can only pull an
|
||||||
|
|
@ -109,13 +107,11 @@ jobs:
|
||||||
intent-service-test:
|
intent-service-test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
- uses: https://github.com/astral-sh/setup-uv@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
- uses: astral-sh/setup-uv@v3
|
|
||||||
with:
|
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
|
|
||||||
- name: Install services/tech-step-intent-service
|
- name: Install services/tech-step-intent-service
|
||||||
|
|
@ -128,11 +124,11 @@ jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: https://github.com/pnpm/action-setup@v4
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: https://github.com/actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
@ -143,17 +139,17 @@ jobs:
|
||||||
e2e:
|
e2e:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: https://github.com/pnpm/action-setup@v4
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: https://github.com/actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
- name: Cache Cypress binary
|
- name: Cache Cypress binary
|
||||||
uses: actions/cache@v4
|
uses: https://github.com/actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/Cypress
|
path: ~/.cache/Cypress
|
||||||
key: cypress-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
key: cypress-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
|
|
||||||
|
|
@ -90,36 +90,65 @@ describe("TechStepCorrectionPopover", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the selected text and every technique option once loaded", () => {
|
it("shows the selected text, the technique catalog (searchable) and the metadata sections all together", () => {
|
||||||
cy.mount(<Harness />);
|
cy.mount(<Harness />);
|
||||||
cy.wait("@getTechSteps");
|
cy.wait("@getTechSteps");
|
||||||
|
|
||||||
cy.contains(".tech-step-correction-popover__selection", "Cuire").should("be.visible");
|
cy.contains(".tech-step-correction-popover__selection", "Cuire").should("be.visible");
|
||||||
cy.get(".tech-step-correction-popover__list button").should("have.length", 2);
|
// Merged editor (see TechStepCorrectionPopover's own doc comment) — no
|
||||||
|
// separate "pick, then metadata reveals itself" step, both render at
|
||||||
|
// once, and the technique catalog goes through the same searchable
|
||||||
|
// `CatalogSearchPicker` as the ingredient/utensil sub-flows (a plain
|
||||||
|
// unfiltered list of the real ~74-entry catalog isn't browsable).
|
||||||
|
cy.get(
|
||||||
|
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
||||||
|
).should("have.length", 2);
|
||||||
|
cy.contains("h4", "Ingrédients").should("be.visible");
|
||||||
|
cy.contains("h4", "Ustensiles").should("be.visible");
|
||||||
|
cy.contains("button", "Valider").should("be.visible");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("offers a 'no technique here' option only when correcting an existing match", () => {
|
it("offers a 'no technique here' option, and marks the current pick, only when correcting an existing match", () => {
|
||||||
cy.mount(<Harness previousTechStepId={null} />);
|
cy.mount(<Harness previousTechStepId={null} />);
|
||||||
cy.wait("@getTechSteps");
|
cy.wait("@getTechSteps");
|
||||||
cy.get(".tech-step-correction-popover__remove").should("not.exist");
|
cy.get(".tech-step-correction-popover__remove").should("not.exist");
|
||||||
|
cy.contains(".tech-step-correction-popover__chosen-technique", "Aucune technique sélectionnée");
|
||||||
|
|
||||||
cy.mount(<Harness previousTechStepId={cook.id} />);
|
cy.mount(<Harness previousTechStepId={cook.id} />);
|
||||||
cy.wait("@getTechSteps");
|
cy.wait("@getTechSteps");
|
||||||
cy.get(".tech-step-correction-popover__remove").should("exist");
|
cy.get(".tech-step-correction-popover__remove").should("exist");
|
||||||
|
cy.contains(".tech-step-correction-popover__chosen-technique", "Cuire");
|
||||||
|
cy.contains(".catalog-search-picker__list button", "Cuire").should(
|
||||||
|
"have.class",
|
||||||
|
"catalog-search-picker__item--selected",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("selecting a technique reveals the Ingrédients/Ustensiles sections instead of submitting immediately", () => {
|
it("picking a technique from the catalog selects it without submitting immediately", () => {
|
||||||
cy.mount(<Harness />);
|
cy.mount(<Harness />);
|
||||||
cy.wait("@getTechSteps");
|
cy.wait("@getTechSteps");
|
||||||
|
|
||||||
cy.contains(".tech-step-correction-popover__list button", "Mijoter").click();
|
cy.contains(
|
||||||
|
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
||||||
|
"Mijoter",
|
||||||
|
).click();
|
||||||
|
|
||||||
cy.get(".tech-step-correction-popover__list").should("not.exist");
|
cy.contains(".tech-step-correction-popover__chosen-technique", "Mijoter");
|
||||||
cy.contains("h4", "Ingrédients").should("be.visible");
|
|
||||||
cy.contains("h4", "Ustensiles").should("be.visible");
|
|
||||||
cy.contains("button", "Valider").should("be.visible");
|
cy.contains("button", "Valider").should("be.visible");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("Valider stays disabled until a technique is actually picked", () => {
|
||||||
|
cy.mount(<Harness />);
|
||||||
|
cy.wait("@getTechSteps");
|
||||||
|
|
||||||
|
cy.contains("button", "Valider").should("be.disabled");
|
||||||
|
cy.contains(
|
||||||
|
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
||||||
|
"Mijoter",
|
||||||
|
).click();
|
||||||
|
cy.contains("button", "Valider").should("not.be.disabled");
|
||||||
|
});
|
||||||
|
|
||||||
it("submits the selected technique (no metadata touched) with ingredients/utensils omitted from the request", () => {
|
it("submits the selected technique (no metadata touched) with ingredients/utensils omitted from the request", () => {
|
||||||
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
|
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
|
||||||
statusCode: 201,
|
statusCode: 201,
|
||||||
|
|
@ -136,7 +165,10 @@ describe("TechStepCorrectionPopover", () => {
|
||||||
cy.mount(<Harness onSubmitted={onSubmitted} />);
|
cy.mount(<Harness onSubmitted={onSubmitted} />);
|
||||||
cy.wait("@getTechSteps");
|
cy.wait("@getTechSteps");
|
||||||
|
|
||||||
cy.contains(".tech-step-correction-popover__list button", "Mijoter").click();
|
cy.contains(
|
||||||
|
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
||||||
|
"Mijoter",
|
||||||
|
).click();
|
||||||
cy.contains("button", "Valider").click();
|
cy.contains("button", "Valider").click();
|
||||||
|
|
||||||
cy.wait("@submitCorrection").its("request.body").should("deep.equal", {
|
cy.wait("@submitCorrection").its("request.body").should("deep.equal", {
|
||||||
|
|
@ -162,11 +194,14 @@ describe("TechStepCorrectionPopover", () => {
|
||||||
}).as("submitCorrection");
|
}).as("submitCorrection");
|
||||||
cy.mount(<Harness />);
|
cy.mount(<Harness />);
|
||||||
cy.wait("@getTechSteps");
|
cy.wait("@getTechSteps");
|
||||||
|
|
||||||
cy.contains(".tech-step-correction-popover__list button", "Mijoter").click();
|
|
||||||
cy.contains("button", "+ Ajouter un ingrédient").click();
|
|
||||||
cy.wait(["@getIngredients", "@getUnits", "@getUtensils"]);
|
cy.wait(["@getIngredients", "@getUnits", "@getUtensils"]);
|
||||||
|
|
||||||
|
cy.contains(
|
||||||
|
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
||||||
|
"Mijoter",
|
||||||
|
).click();
|
||||||
|
cy.contains("button", "+ Ajouter un ingrédient").click();
|
||||||
|
|
||||||
cy.contains(".catalog-search-picker button", "Beurre").click();
|
cy.contains(".catalog-search-picker button", "Beurre").click();
|
||||||
cy.get('input[type="number"]').type("50");
|
cy.get('input[type="number"]').type("50");
|
||||||
cy.get("select").select(String(gram.id));
|
cy.get("select").select(String(gram.id));
|
||||||
|
|
@ -199,9 +234,11 @@ describe("TechStepCorrectionPopover", () => {
|
||||||
existingUtensils={[{ utensil: pan, start: 14, end: 23, source: "auto" }]}
|
existingUtensils={[{ utensil: pan, start: 14, end: 23, source: "auto" }]}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
// An existing match starts pre-selected on itself (see
|
||||||
|
// TechStepCorrectionPopover's own doc comment) — the metadata sections,
|
||||||
|
// pre-seeded from `existingIngredients`/`existingUtensils`, are visible
|
||||||
|
// immediately, no need to re-pick "Cuire" from a list first.
|
||||||
cy.wait("@getTechSteps");
|
cy.wait("@getTechSteps");
|
||||||
|
|
||||||
cy.contains(".tech-step-correction-popover__list button", "Mijoter").click();
|
|
||||||
cy.wait(["@getIngredients", "@getUnits", "@getUtensils"]);
|
cy.wait(["@getIngredients", "@getUnits", "@getUtensils"]);
|
||||||
|
|
||||||
cy.contains(".tech-step-correction-popover__chip", "50 g Beurre").find("button").click();
|
cy.contains(".tech-step-correction-popover__chip", "50 g Beurre").find("button").click();
|
||||||
|
|
@ -217,7 +254,10 @@ describe("TechStepCorrectionPopover", () => {
|
||||||
cy.mount(<Harness onClose={onClose} />);
|
cy.mount(<Harness onClose={onClose} />);
|
||||||
cy.wait("@getTechSteps");
|
cy.wait("@getTechSteps");
|
||||||
|
|
||||||
cy.contains(".tech-step-correction-popover__list button", "Cuire").click();
|
cy.contains(
|
||||||
|
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
||||||
|
"Cuire",
|
||||||
|
).click();
|
||||||
cy.contains("button", "Valider").click();
|
cy.contains("button", "Valider").click();
|
||||||
|
|
||||||
cy.wait("@submitCorrection");
|
cy.wait("@submitCorrection");
|
||||||
|
|
|
||||||
|
|
@ -110,13 +110,20 @@ Then("I should see the technique correction options", () => {
|
||||||
cy.get(".tech-step-correction-popover").should("be.visible");
|
cy.get(".tech-step-correction-popover").should("be.visible");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Picking a technique only *selects* it now — it takes a separate
|
// Picking a technique only *selects* it — it takes a separate "Valider"
|
||||||
// "Valider" click to actually submit (room was made for attaching
|
// click to actually submit (room was made for attaching ingredient/utensil
|
||||||
// ingredient/utensil metadata first, see `TechStepCorrectionPopover.tsx`'s
|
// metadata alongside it, see `TechStepCorrectionPopover.tsx`'s own doc
|
||||||
// own doc comment) — folded into this one step since nothing in this
|
// comment on its merged editor) — folded into this one step since nothing
|
||||||
// scenario cares about that intermediate state on its own.
|
// in this scenario cares about that intermediate state on its own. The
|
||||||
|
// technique catalog is picked via the same searchable `CatalogSearchPicker`
|
||||||
|
// the ingredient/utensil sub-flows use, scoped to
|
||||||
|
// `__technique-section` since that same search-and-pick component is
|
||||||
|
// reused inside this popover for more than just techniques.
|
||||||
When("I choose {string} as the correct technique", (label: string) => {
|
When("I choose {string} as the correct technique", (label: string) => {
|
||||||
cy.contains(".tech-step-correction-popover__list button", label).click();
|
cy.contains(
|
||||||
|
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
||||||
|
label,
|
||||||
|
).click();
|
||||||
cy.contains(".tech-step-correction-popover__confirm-button", "Valider").click();
|
cy.contains(".tech-step-correction-popover__confirm-button", "Valider").click();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -729,40 +729,38 @@
|
||||||
margin: 0 0 var(--space-sm);
|
margin: 0 0 var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
&__list {
|
// Technique picker + Ingrédients/Ustensiles render together as one
|
||||||
display: flex;
|
// screen now (see `TechStepCorrectionPopover.tsx`'s own doc comment) —
|
||||||
flex-wrap: wrap;
|
// this section just needs its own small header row, the actual picker
|
||||||
gap: var(--space-xs);
|
// is `.catalog-search-picker` (below), reused as-is from the ingredient/
|
||||||
list-style: none;
|
// utensil sub-flows.
|
||||||
margin: 0 0 var(--space-sm);
|
&__technique-section {
|
||||||
padding: 0;
|
h4 {
|
||||||
|
margin: 0 0 var(--space-xs);
|
||||||
button {
|
|
||||||
padding: 0.3rem 0.6rem;
|
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
color: var(--color-text);
|
color: var(--color-text-muted);
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Nested (rather than a sibling `&__remove` block) so its border-color
|
&__technique-header {
|
||||||
// wins over the plain `button` rule above by class-count specificity,
|
display: flex;
|
||||||
// no `!important` needed.
|
align-items: baseline;
|
||||||
.tech-step-correction-popover__remove {
|
justify-content: space-between;
|
||||||
color: var(--color-error);
|
gap: var(--space-sm);
|
||||||
border-color: var(--color-error);
|
}
|
||||||
|
|
||||||
|
&__remove {
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-error);
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-error);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -808,36 +806,27 @@
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "Aucune technique sélectionnée."/"Technique retenue : X" — no button
|
||||||
|
// here anymore (re-picking happens directly through the search picker
|
||||||
|
// right below, see `TechStepCorrectionPopover.tsx`'s doc comment on the
|
||||||
|
// merged editor), just a small status line.
|
||||||
&__chosen-technique {
|
&__chosen-technique {
|
||||||
display: flex;
|
margin: 0 0 var(--space-xs);
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
margin: 0;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__change {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-primary);
|
|
||||||
text-decoration: underline;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__metadata-section {
|
&__metadata-section {
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
|
||||||
h4 {
|
h4 {
|
||||||
margin: 0 0 var(--space-xs);
|
margin: 0 0 var(--space-xs);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
// "+ Ajouter…" button — deliberately the same plain-text-link styling
|
// "+ Ajouter…" button — deliberately a plain text-link style, not
|
||||||
// as `&__change` above, not another pill button (`&__list button`) —
|
// another pill button (`.catalog-search-picker__list button`) — this
|
||||||
// this is a secondary action inside an already-open popover, not a
|
// is a secondary action inside an already-open popover, not a
|
||||||
// top-level choice competing with the chips above it.
|
// top-level choice competing with the chips above it.
|
||||||
> button {
|
> button {
|
||||||
background: none;
|
background: none;
|
||||||
|
|
@ -925,7 +914,10 @@
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
max-height: 8rem;
|
// Raised from the original 8rem — this component is now also the
|
||||||
|
// technique picker (~74 entries, see this file's own doc comment),
|
||||||
|
// where 8rem left only a couple of rows visible before scrolling.
|
||||||
|
max-height: 14rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
||||||
button {
|
button {
|
||||||
|
|
@ -941,6 +933,18 @@
|
||||||
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
|
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The technique picker's current pick (`selectedId` prop) — stays
|
||||||
|
// visually marked even while filtered/scrolled past, so re-opening
|
||||||
|
// this popover's picker doesn't read as "nothing chosen yet" when
|
||||||
|
// something already is. Unused by the ingredient/utensil sub-flows
|
||||||
|
// (they never pass `selectedId` — each pick there just appends a
|
||||||
|
// fresh mention, nothing to mark as "current").
|
||||||
|
&.catalog-search-picker__item--selected {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 20%, transparent);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,25 +3,30 @@ import { useState } from "react";
|
||||||
/**
|
/**
|
||||||
* Small search-and-pick list — a lighter alternative to `IngredientPicker.tsx`
|
* Small search-and-pick list — a lighter alternative to `IngredientPicker.tsx`
|
||||||
* (category/subcategory grid + allergen/diet toggles) for a context that
|
* (category/subcategory grid + allergen/diet toggles) for a context that
|
||||||
* doesn't have room for that: `TechStepCorrectionPopover.tsx`'s "attach an
|
* doesn't have room for that: `TechStepCorrectionPopover.tsx`'s ingredient/
|
||||||
* ingredient/utensil to this correction" flow, embedded in a small popover
|
* utensil/**technique** pickers, all embedded in a small popover rather than
|
||||||
* rather than a full recipe form. Reused for both — an ingredient and a
|
* a full recipe form. Reused for all three — an ingredient, a utensil, and a
|
||||||
* utensil are both "search a short reference list by translated label, pick
|
* technique are all "search a reference list by translated label, pick one"
|
||||||
* one" from this component's point of view, the only difference is which
|
* from this component's point of view, the only difference is which
|
||||||
* `items`/labels the caller passes in.
|
* `items`/labels the caller passes in. The technique catalog in particular
|
||||||
|
* (~74 entries) is exactly the case a plain unfiltered list stops being
|
||||||
|
* readable at — the original motivation for adding search here at all.
|
||||||
*
|
*
|
||||||
* Deliberately just `{ id, label }` in, `id` out — no `IngredientView`/
|
* Deliberately just `{ id, label }` in, `id` out — no `IngredientView`/
|
||||||
* `UtensilView` dependency here, so this stays reusable for any future
|
* `UtensilView`/`TechStepView` dependency here, so this stays reusable for
|
||||||
* "search this small reference catalog" need without growing a new prop
|
* any future "search this small reference catalog" need without growing a
|
||||||
* per catalog shape.
|
* new prop per catalog shape.
|
||||||
*/
|
*/
|
||||||
export function CatalogSearchPicker({
|
export function CatalogSearchPicker({
|
||||||
items,
|
items,
|
||||||
|
selectedId,
|
||||||
onSelect,
|
onSelect,
|
||||||
placeholder,
|
placeholder,
|
||||||
emptyLabel,
|
emptyLabel,
|
||||||
}: {
|
}: {
|
||||||
items: { id: number; label: string }[];
|
items: { id: number; label: string }[];
|
||||||
|
/** The currently-picked item, if any — marked with a distinct modifier class so it stays visible at a glance while browsing/filtering a longer list (e.g. `TechStepCorrectionPopover`'s ~74-entry technique catalog), not just implied by whatever's selected elsewhere on screen. Omit for a picker with no notion of a "current" pick (the ingredient/utensil span sub-flows — each `onSelect` there just appends a brand-new mention, nothing to mark as already chosen). */
|
||||||
|
selectedId?: number;
|
||||||
onSelect: (id: number) => void;
|
onSelect: (id: number) => void;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
emptyLabel: string;
|
emptyLabel: string;
|
||||||
|
|
@ -48,7 +53,13 @@ export function CatalogSearchPicker({
|
||||||
<ul className="catalog-search-picker__list">
|
<ul className="catalog-search-picker__list">
|
||||||
{visible.map((item) => (
|
{visible.map((item) => (
|
||||||
<li key={item.id}>
|
<li key={item.id}>
|
||||||
<button type="button" onClick={() => onSelect(item.id)}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className={
|
||||||
|
item.id === selectedId ? "catalog-search-picker__item--selected" : undefined
|
||||||
|
}
|
||||||
|
onClick={() => onSelect(item.id)}
|
||||||
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
|
|
@ -61,12 +61,17 @@ function toPendingUtensil(view: StepTechStepUtensilView): PendingUtensil {
|
||||||
* first — the description stays fully visible and selectable the whole
|
* first — the description stays fully visible and selectable the whole
|
||||||
* time, nothing overlays it.
|
* time, nothing overlays it.
|
||||||
*
|
*
|
||||||
* **Removing** a match (`submit(null)`) stays a single immediate action —
|
* **One merged editor, not a wizard**: picking a technique
|
||||||
* nothing to attach when removing. **Picking/relabeling** a technique used
|
* (`CatalogSearchPicker`, searchable — the reference catalog is ~74
|
||||||
* to submit immediately too; it no longer does, so there's room to attach
|
* entries, an unfiltered flat list wasn't browsable) and editing its
|
||||||
* metadata before committing — clicking a technique now only *selects* it
|
* Ingrédients/Ustensiles metadata render together on the same screen,
|
||||||
* (`selectedTechStepId`), revealing the Ingrédients/Ustensiles sections and
|
* always — there's no separate "pick, then a metadata step reveals
|
||||||
* a final "Valider" button that submits everything together.
|
* itself" sequence to go through, and no dead end where metadata is
|
||||||
|
* technically attachable but not visible until some other action happens
|
||||||
|
* first. A single "Valider" submits everything at once; disabled until a
|
||||||
|
* technique is actually selected (there's nothing to attach metadata to
|
||||||
|
* otherwise). **Removing** a match (`submit(null)`) stays its own
|
||||||
|
* immediate action next to the picker — nothing to attach when removing.
|
||||||
*
|
*
|
||||||
* The two metadata sections are pre-seeded from `existingIngredients`/
|
* The two metadata sections are pre-seeded from `existingIngredients`/
|
||||||
* `existingUtensils` (whatever's already attached to this occurrence, auto-
|
* `existingUtensils` (whatever's already attached to this occurrence, auto-
|
||||||
|
|
@ -118,7 +123,14 @@ export function TechStepCorrectionPopover({
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
const [techSteps, setTechSteps] = useState<TechStepView[] | null>(null);
|
const [techSteps, setTechSteps] = useState<TechStepView[] | null>(null);
|
||||||
const [selectedTechStepId, setSelectedTechStepId] = useState<number | null>(null);
|
// Opening this popover on an *already-detected* match (`previousTechStepId
|
||||||
|
// !== null`, i.e. the user clicked an existing highlight rather than
|
||||||
|
// selecting fresh text) starts pre-selected on that same technique, its
|
||||||
|
// name shown next to the picker right away — since the picker and the
|
||||||
|
// metadata sections render together regardless (see this component's own
|
||||||
|
// doc comment), this just saves re-picking the technique that's already
|
||||||
|
// correct before its metadata becomes editable.
|
||||||
|
const [selectedTechStepId, setSelectedTechStepId] = useState<number | null>(previousTechStepId);
|
||||||
const [catalogs, setCatalogs] = useState<{
|
const [catalogs, setCatalogs] = useState<{
|
||||||
ingredients: IngredientView[];
|
ingredients: IngredientView[];
|
||||||
units: UnitView[];
|
units: UnitView[];
|
||||||
|
|
@ -173,12 +185,12 @@ export function TechStepCorrectionPopover({
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Only fetched once a technique is actually selected — the Ingrédients/
|
// Fetched unconditionally on mount — the Ingrédients/Ustensiles sections
|
||||||
// Ustensiles sections (the only things that need these) don't render
|
// render alongside the technique picker from the start (see this
|
||||||
// before then, so a popover only ever used to relabel/remove a technique
|
// component's own doc comment on the merged editor), so there's no later
|
||||||
// never pays for these three extra requests.
|
// point to defer this to anymore.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedTechStepId === null || catalogs !== null) return;
|
if (catalogs !== null) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
Promise.all([apiClient.getIngredients(), apiClient.getUnits(), apiClient.getUtensils()])
|
Promise.all([apiClient.getIngredients(), apiClient.getUnits(), apiClient.getUtensils()])
|
||||||
.then(([ingredients, units, utensils]) => {
|
.then(([ingredients, units, utensils]) => {
|
||||||
|
|
@ -190,7 +202,7 @@ export function TechStepCorrectionPopover({
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [selectedTechStepId, catalogs]);
|
}, [catalogs]);
|
||||||
|
|
||||||
// Consumes a span `StepDescription` just resolved on this popover's
|
// Consumes a span `StepDescription` just resolved on this popover's
|
||||||
// behalf (see `resolvedMetadataSpan`'s own doc comment above) — opens the
|
// behalf (see `resolvedMetadataSpan`'s own doc comment above) — opens the
|
||||||
|
|
@ -391,47 +403,42 @@ export function TechStepCorrectionPopover({
|
||||||
</div>
|
</div>
|
||||||
) : techSteps === null ? (
|
) : techSteps === null ? (
|
||||||
<p>{t("recipes.loading")}</p>
|
<p>{t("recipes.loading")}</p>
|
||||||
) : selectedTechStepId === null ? (
|
|
||||||
<ul className="tech-step-correction-popover__list">
|
|
||||||
{previousTechStepId !== null && (
|
|
||||||
<li>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
onClick={removeMatch}
|
|
||||||
className="tech-step-correction-popover__remove"
|
|
||||||
>
|
|
||||||
{t("recipes.techStepCorrection.removeMatch")}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
{techSteps.map((techStep) => (
|
|
||||||
<li key={techStep.id}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
onClick={() => setSelectedTechStepId(techStep.id)}
|
|
||||||
>
|
|
||||||
{t(`catalog.techSteps.${techStep.key}`)}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="tech-step-correction-popover__confirm">
|
<div className="tech-step-correction-popover__confirm">
|
||||||
<p className="tech-step-correction-popover__chosen-technique">
|
<section className="tech-step-correction-popover__technique-section">
|
||||||
{t(
|
<div className="tech-step-correction-popover__technique-header">
|
||||||
`catalog.techSteps.${techSteps.find((ts) => ts.id === selectedTechStepId)?.key ?? ""}`,
|
<h4>{t("recipes.techStepCorrection.techniqueSection")}</h4>
|
||||||
)}
|
{previousTechStepId !== null && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="tech-step-correction-popover__change"
|
disabled={isSubmitting}
|
||||||
onClick={() => setSelectedTechStepId(null)}
|
onClick={removeMatch}
|
||||||
disabled={isSubmitting}
|
className="tech-step-correction-popover__remove"
|
||||||
>
|
>
|
||||||
{t("recipes.techStepCorrection.changeTechnique")}
|
{t("recipes.techStepCorrection.removeMatch")}
|
||||||
</button>
|
</button>
|
||||||
</p>
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="tech-step-correction-popover__chosen-technique">
|
||||||
|
{selectedTechStepId !== null
|
||||||
|
? t("recipes.techStepCorrection.currentTechnique", {
|
||||||
|
technique: t(
|
||||||
|
`catalog.techSteps.${techSteps.find((ts) => ts.id === selectedTechStepId)?.key ?? ""}`,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
: t("recipes.techStepCorrection.noTechniqueSelected")}
|
||||||
|
</p>
|
||||||
|
<CatalogSearchPicker
|
||||||
|
items={techSteps.map((techStep) => ({
|
||||||
|
id: techStep.id,
|
||||||
|
label: t(`catalog.techSteps.${techStep.key}`),
|
||||||
|
}))}
|
||||||
|
selectedId={selectedTechStepId ?? undefined}
|
||||||
|
onSelect={setSelectedTechStepId}
|
||||||
|
placeholder={t("recipes.techStepCorrection.searchTechniquePlaceholder")}
|
||||||
|
emptyLabel={t("recipes.techStepCorrection.noTechniqueFound")}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="tech-step-correction-popover__metadata-section">
|
<section className="tech-step-correction-popover__metadata-section">
|
||||||
<h4>{t("recipes.techStepCorrection.ingredientsSection")}</h4>
|
<h4>{t("recipes.techStepCorrection.ingredientsSection")}</h4>
|
||||||
|
|
@ -494,7 +501,7 @@ export function TechStepCorrectionPopover({
|
||||||
type="button"
|
type="button"
|
||||||
className="tech-step-correction-popover__confirm-button"
|
className="tech-step-correction-popover__confirm-button"
|
||||||
onClick={confirm}
|
onClick={confirm}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting || selectedTechStepId === null}
|
||||||
>
|
>
|
||||||
{t("recipes.techStepCorrection.confirm")}
|
{t("recipes.techStepCorrection.confirm")}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -170,8 +170,12 @@
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
"manualTooltip": "{{technique}} (correction manuelle)",
|
"manualTooltip": "{{technique}} (correction manuelle)",
|
||||||
"discoverabilityHint": "💡 Sélectionnez du texte, ou cliquez sur une technique surlignée, pour la corriger.",
|
"discoverabilityHint": "💡 Sélectionnez du texte, ou cliquez sur une technique surlignée, pour la corriger.",
|
||||||
"changeTechnique": "Changer",
|
|
||||||
"confirm": "Valider",
|
"confirm": "Valider",
|
||||||
|
"techniqueSection": "Technique",
|
||||||
|
"currentTechnique": "Technique retenue : {{technique}}",
|
||||||
|
"noTechniqueSelected": "Aucune technique sélectionnée.",
|
||||||
|
"searchTechniquePlaceholder": "Rechercher une technique…",
|
||||||
|
"noTechniqueFound": "Aucune technique trouvée.",
|
||||||
"ingredientsSection": "Ingrédients",
|
"ingredientsSection": "Ingrédients",
|
||||||
"utensilsSection": "Ustensiles",
|
"utensilsSection": "Ustensiles",
|
||||||
"addIngredient": "+ Ajouter un ingrédient",
|
"addIngredient": "+ Ajouter un ingrédient",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue