Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion frontend/e2e/recipes/recipe-components.po.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,24 @@ export class RecipeComponentsPage {
return this.page.getByTestId(`component-editor-ingredient-row-${index}`);
}

ingredientToolbar(index: number): Locator {
return this.ingredientRow(index).getByTestId("ingredient-toolbar");
}

ingredientMoveUpBtn(index: number): Locator {
return this.ingredientRow(index).getByTestId("ingredient-move-up");
}

ingredientMoveDownBtn(index: number): Locator {
return this.ingredientRow(index).getByTestId("ingredient-move-down");
}

ingredientToggleNotesBtn(index: number): Locator {
return this.ingredientRow(index).getByTestId("ingredient-toggle-notes");
}

ingredientDeleteBtn(index: number): Locator {
return this.ingredientRow(index).locator("button").first();
return this.ingredientRow(index).getByTestId("ingredient-delete");
}

ingredientNameInput(index: number): Locator {
Expand Down
129 changes: 127 additions & 2 deletions frontend/e2e/recipes/recipe-components.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,17 @@ test.describe("recipe components editor", () => {
await expect(editor.ingredientRow(0)).toBeVisible();
await expect(editor.ingredientNameInput(0)).toHaveValue("Flour");
await expect(editor.ingredientQuantityInput(0)).toHaveValue("500 g");
await expect(editor.ingredientNotesTextarea(0)).toHaveValue("Type 00");

await expect(editor.ingredientRow(1)).toBeVisible();
await expect(editor.ingredientNameInput(1)).toHaveValue("Eggs");
await expect(editor.ingredientQuantityInput(1)).toHaveValue("5");
await expect(editor.ingredientNotesTextarea(1)).toHaveValue("");
});

await test.step("notes textarea visible for ingredient with notes, hidden for empty notes", async () => {
await expect(editor.ingredientNotesTextarea(0)).toBeVisible();
await expect(editor.ingredientNotesTextarea(0)).toHaveValue("Type 00");

await expect(editor.ingredientNotesTextarea(1)).not.toBeVisible();
});

await test.step("pre-populates steps from API", async () => {
Expand Down Expand Up @@ -198,6 +203,126 @@ test.describe("recipe components editor", () => {
});
});

test("ingredient toolbar buttons", async ({ page }) => {
await page.route("**/api/recipes/pasta", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(mockRecipe),
}),
);

const editor = new RecipeComponentsPage(page);
await editor.goto("pasta");

await test.step("first ingredient toolbar: move up disabled, move down enabled", async () => {
await expect(editor.ingredientMoveUpBtn(0)).toBeDisabled();
await expect(editor.ingredientMoveDownBtn(0)).toBeEnabled();
});

await test.step("second ingredient toolbar: move up enabled, move down disabled", async () => {
await expect(editor.ingredientMoveUpBtn(1)).toBeEnabled();
await expect(editor.ingredientMoveDownBtn(1)).toBeDisabled();
});

await test.step("toolbar has delete and notes toggle buttons", async () => {
await expect(editor.ingredientDeleteBtn(0)).toBeVisible();
await expect(editor.ingredientToggleNotesBtn(0)).toBeVisible();
});
});

test("ingredient notes toggle shows and hides textarea", async ({ page }) => {
await page.route("**/api/recipes/pasta", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(mockRecipe),
}),
);

const editor = new RecipeComponentsPage(page);
await editor.goto("pasta");

await test.step("notes textarea visible by default for ingredient with notes", async () => {
await expect(editor.ingredientNotesTextarea(0)).toBeVisible();
await expect(editor.ingredientNotesTextarea(0)).toHaveValue("Type 00");
});

await test.step("click toggle hides textarea for ingredient with notes", async () => {
await editor.ingredientToggleNotesBtn(0).click();
await expect(editor.ingredientNotesTextarea(0)).not.toBeVisible();
});

await test.step("notes textarea hidden by default for ingredient with empty notes", async () => {
await expect(editor.ingredientNotesTextarea(1)).not.toBeVisible();
});

await test.step("click toggle shows empty textarea for ingredient without notes", async () => {
await editor.ingredientToggleNotesBtn(1).click();
await expect(editor.ingredientNotesTextarea(1)).toBeVisible();
await expect(editor.ingredientNotesTextarea(1)).toHaveValue("");
});
});

test("notes textarea stays visible after typing then clearing", async ({ page }) => {
await page.route("**/api/recipes/pasta", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(mockRecipe),
}),
);

const editor = new RecipeComponentsPage(page);
await editor.goto("pasta");

await test.step("ingredient 1 has empty notes, textarea hidden by default", async () => {
await expect(editor.ingredientNotesTextarea(1)).not.toBeVisible();
});

await test.step("click toggle to show empty textarea", async () => {
await editor.ingredientToggleNotesBtn(1).click();
await expect(editor.ingredientNotesTextarea(1)).toBeVisible();
});

await test.step("type text then clear it, textarea should stay visible", async () => {
await editor.ingredientNotesTextarea(1).fill("hello world");
await editor.ingredientNotesTextarea(1).fill("");
await editor.ingredientNotesTextarea(1).blur();
await expect(editor.ingredientNotesTextarea(1)).toBeVisible();
});
});

test("moving ingredients up and down", async ({ page }) => {
await page.route("**/api/recipes/pasta", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(mockRecipe),
}),
);

const editor = new RecipeComponentsPage(page);
await editor.goto("pasta");

await test.step("initial order is Flour then Eggs", async () => {
await expect(editor.ingredientNameInput(0)).toHaveValue("Flour");
await expect(editor.ingredientNameInput(1)).toHaveValue("Eggs");
});

await test.step("move first ingredient down swaps order", async () => {
await editor.ingredientMoveDownBtn(0).click();
await expect(editor.ingredientNameInput(0)).toHaveValue("Eggs");
await expect(editor.ingredientNameInput(1)).toHaveValue("Flour");
});

await test.step("move second ingredient up restores original order", async () => {
await editor.ingredientMoveUpBtn(1).click();
await expect(editor.ingredientNameInput(0)).toHaveValue("Flour");
await expect(editor.ingredientNameInput(1)).toHaveValue("Eggs");
});
});

test("shows empty state when no components", async ({ page }) => {
await page.route("**/api/recipes/pasta", (route) =>
route.fulfill({
Expand Down
12 changes: 12 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,11 @@
"@js-temporal/polyfill": "^0.5.1",
"@playwright/test": "^1.59.1",
"@tailwindcss/postcss": "^4.1.12",
"@toolwind/anchors": "^1.0.10",
"@types/node": "^25.6.0",
"jsdom": "^28.0.0",
"lucide-static": "^1.8.0",
"postcss": "^8.5.3",

Check notice on line 46 in frontend/package.json

View workflow job for this annotation

GitHub Actions / Qodana for JVM

Vulnerable declared dependency

Dependency npm:postcss:8.5.8 is vulnerable , safe version 8.5.10 * [CVE-2026-41305](https://www.mend.io/vulnerability-database/CVE-2026-41305?utm_source=Jetbrains) 6.1 PostCSS has XSS via Unescaped \</style\> in its CSS Stringify Output Results powered by [Mend.io](https://www.mend.io/jetbrains-lp/?utm_source=JetBrains)
"prettier": "^3.8.1",
"tailwindcss": "^4.1.12",
"tailwindcss-animate": "^1.0.7",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,15 @@ import type { EditableIngredient } from "./recipe-component-editor.vm";

@if (ingredientsForm().length > 0) {
<div class="flex flex-col gap-3">
@for (ingredientForm of ingredientsForm(); track $index) {
@for (ingredientForm of ingredientsForm(); track $index; let first = $first, last = $last) {
<app-editor-ingredient
[ingredientForm]="ingredientForm"
[isFirst]="first"
[isLast]="last"
[attr.data-testid]="'component-editor-ingredient-row-' + $index"
(delete)="deleteIngredient.emit($index)"
(moveUp)="moveIngredientUp.emit($index)"
(moveDown)="moveIngredientDown.emit($index)"
/>
}
</div>
Expand All @@ -47,4 +51,6 @@ export class ComponentEditorIngredientListComponent {
readonly ingredientsForm = input.required<FieldTree<EditableIngredient[]>>();
readonly deleteIngredient = output<number>();
readonly addIngredient = output();
readonly moveIngredientUp = output<number>();
readonly moveIngredientDown = output<number>();
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { ChangeDetectionStrategy, Component, computed, input, output } from "@angular/core";
import { ChangeDetectionStrategy, Component, computed, input, linkedSignal, output } from "@angular/core";
import type { FieldTree } from "@angular/forms/signals";
import { FormField } from "@angular/forms/signals";
import { NgIcon, provideIcons } from "@ng-icons/core";
import { lucideAlertCircle, lucideTrash } from "@ng-icons/lucide";
import {
lucideAlertCircle,
lucideChevronDown,
lucideChevronUp,
lucideNotebookPen,
lucideTrash,
} from "@ng-icons/lucide";
import { ZardButtonComponent } from "@/shared/components/button";
import { ZardInputDirective } from "@/shared/components/input";
import { ZardInputGroupComponent } from "@/shared/components/input-group";
Expand All @@ -14,8 +20,8 @@ import type { EditableIngredient } from "./recipe-component-editor.vm";
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgIcon, FormField, ZardButtonComponent, ZardInputDirective, ZardInputGroupComponent, ...ZardTooltipImports],
template: `
<div class="flex flex-col gap-2 border border-stone-100 rounded-lg p-3">
<div class="grid grid-cols-[1fr_auto_auto] gap-2 items-center">
<div class="flex flex-col gap-2">
<div class="grid grid-cols-[1fr_auto] gap-2 items-center">
<input
z-input
[formField]="ingredientForm().name"
Expand All @@ -33,23 +39,60 @@ import type { EditableIngredient } from "./recipe-component-editor.vm";
placeholder="Quantity"
/>
</z-input-group>
</div>

<div class="flex items-center gap-1" data-testid="ingredient-toolbar">
<span class="flex-1"></span>
<button
z-button
zType="ghost"
zSize="icon-xs"
(click)="toggleNotes()"
[class.border-primary!]="hasNotes()"
data-testid="ingredient-toggle-notes"
>
<ng-icon name="lucideNotebookPen" [class.text-primary!]="hasNotes()" />
</button>
<button
z-button
zType="ghost"
zSize="icon-xs"
(click)="delete.emit()"
class="shrink-0"
data-testid="ingredient-delete"
>
<ng-icon name="lucideTrash" />
</button>
<button
z-button
zType="ghost"
zSize="icon-xs"
[zDisabled]="isFirst()"
(click)="moveUp.emit()"
data-testid="ingredient-move-up"
>
<ng-icon name="lucideChevronUp" />
</button>
<button
z-button
zType="ghost"
zSize="icon-xs"
[zDisabled]="isLast()"
(click)="moveDown.emit()"
data-testid="ingredient-move-down"
>
<ng-icon name="lucideChevronDown" />
</button>
</div>
<textarea
z-input
zType="textarea"
[formField]="ingredientForm().notes"
placeholder="Add notes..."
class="w-full min-h-[40px] border-none"
></textarea>

@if (showNotes()) {
<textarea
z-input
zType="textarea"
[formField]="ingredientForm().notes"
placeholder="Add notes..."
class="w-full min-h-[40px] border border-stone-200 rounded-md"
></textarea>
}
</div>

<ng-template #errorIcon>
Expand All @@ -63,11 +106,39 @@ import type { EditableIngredient } from "./recipe-component-editor.vm";
</div>
</ng-template>
`,
viewProviders: [provideIcons({ lucideAlertCircle, lucideTrash })],
viewProviders: [
provideIcons({
lucideAlertCircle,
lucideChevronDown,
lucideChevronUp,
lucideNotebookPen,
lucideTrash,
}),
],
})
export class EditorIngredientComponent {
readonly ingredientForm = input.required<FieldTree<EditableIngredient>>();
readonly isFirst = input(false);
readonly isLast = input(false);
readonly delete = output();
readonly moveUp = output();
readonly moveDown = output();

protected readonly showNotes = linkedSignal({
source: this.ingredientForm,
computation: (_form, prev) => {
if (prev !== undefined) return prev;
const n = _form.notes();
const val = n.value;
return val() != null && val().trim() !== "";
},
});

protected readonly hasNotes = computed(() => {
const n = this.ingredientForm().notes();
const val = n.value;
return val() != null && val().trim() !== "";
});

protected readonly isQuantityError = computed(() => {
const q = this.ingredientForm().quantity();
Expand All @@ -79,4 +150,8 @@ export class EditorIngredientComponent {
const errors = q.errors();
return errors?.map((e) => e.message).join(". ") ?? "";
});

protected toggleNotes(): void {
this.showNotes.update((v) => !v);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import { RecipeComponentEditorViewModel } from "./recipe-component-editor.vm";
[ingredientsForm]="vm.ingredientsForm"
(addIngredient)="vm.addIngredient()"
(deleteIngredient)="vm.deleteIngredient($event)"
(moveIngredientUp)="vm.moveIngredientUp($event)"
(moveIngredientDown)="vm.moveIngredientDown($event)"
/>

<app-component-editor-steps-list
Expand Down
Loading
Loading