diff --git a/e2e/tests/meal-filter.spec.ts b/e2e/tests/meal-filter.spec.ts new file mode 100644 index 0000000..86efbb2 --- /dev/null +++ b/e2e/tests/meal-filter.spec.ts @@ -0,0 +1,57 @@ +import { expect } from "@playwright/test"; +import { test } from "../fixtures"; +import { registerAndLogin, createList, createMeal } from "../helpers"; + +test("the meals page filters by name as you type", async ({ page }) => { + await registerAndLogin(page, "alice@example.com"); + await createMeal(page, "Spaghetti Bolognese", ""); + await createMeal(page, "Chicken Curry", ""); + await createMeal(page, "Pancakes", ""); + + await page.goto("/meals"); + await expect(page.locator(".list-card").filter({ hasText: "Spaghetti Bolognese" })).toBeVisible(); + await expect(page.locator(".list-card").filter({ hasText: "Chicken Curry" })).toBeVisible(); + await expect(page.locator(".list-card").filter({ hasText: "Pancakes" })).toBeVisible(); + + // Type a query; only matching meals remain. + await page.fill("#meal-search", "chicken"); + await expect(page.locator(".list-card").filter({ hasText: "Chicken Curry" })).toBeVisible(); + await expect(page.locator(".list-card").filter({ hasText: "Spaghetti Bolognese" })).toHaveCount(0); + await expect(page.locator(".list-card").filter({ hasText: "Pancakes" })).toHaveCount(0); + + // Clearing the search restores all meals. + await page.fill("#meal-search", ""); + await expect(page.locator(".list-card").filter({ hasText: "Spaghetti Bolognese" })).toBeVisible(); + await expect(page.locator(".list-card").filter({ hasText: "Pancakes" })).toBeVisible(); +}); + +test("the meals page shows an empty state when nothing matches", async ({ page }) => { + await registerAndLogin(page, "alice@example.com"); + await createMeal(page, "Spaghetti Bolognese", ""); + + await page.goto("/meals"); + await page.fill("#meal-search", "zzzz"); + await expect(page.locator(".meal-filter-empty")).toBeVisible(); + await expect(page.locator(".list-card")).toHaveCount(0); +}); + +test("the add-meal picker filters meals as you type", async ({ page }) => { + await registerAndLogin(page, "alice@example.com"); + await createMeal(page, "Spaghetti Bolognese", ""); + await createMeal(page, "Chicken Curry", ""); + await createList(page, "Weekly shop"); + + await page.click(".add-meal-button"); + const picker = page.locator(".meal-picker-backdrop"); + await expect(picker).toBeVisible(); + await expect(picker.locator(".meal-picker-button").filter({ hasText: "Spaghetti Bolognese" })).toBeVisible(); + await expect(picker.locator(".meal-picker-button").filter({ hasText: "Chicken Curry" })).toBeVisible(); + + await picker.locator(".meal-filter").fill("curry"); + await expect(picker.locator(".meal-picker-button").filter({ hasText: "Chicken Curry" })).toBeVisible(); + await expect(picker.locator(".meal-picker-button").filter({ hasText: "Spaghetti Bolognese" })).toHaveCount(0); + + // The search box keeps focus and the modal stays open. + await expect(picker.locator(".meal-filter")).toBeFocused(); + await expect(picker).toBeVisible(); +}); diff --git a/src/http.rs b/src/http.rs index 20928f0..3f296f7 100644 --- a/src/http.rs +++ b/src/http.rs @@ -340,6 +340,7 @@ struct AddMealForm { #[derive(Debug, Deserialize)] struct MealPickerQuery { picker: Option, + q: Option, } async fn home() -> Redirect { @@ -897,15 +898,50 @@ async fn meals_page( State(state): State, user: CurrentUser, Query(query): Query, + headers: HeaderMap, ) -> Result { - let meals = state.meals.list_meals().await?; + let q = query.q.clone().unwrap_or_default(); + let meals = state.meals.list_meals(&q).await?; let meal_categories = state.meals.list_meal_categories().await?; + let is_htmx = headers + .get("hx-request") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == "true"); + // A boosted navigation (e.g. the category form's POST redirect) also sends + // `HX-Request: true`, but must render the full page, not just the results + // fragment. Only a live search (an `hx-get` on the filter box) swaps the + // fragment. htmx marks boosted requests with `HX-Boosted: true`. + let is_boosted = headers + .get("hx-boosted") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == "true"); if let Some(list_id) = query.picker { - return Ok(html_response(views::meal_picker( + if query.q.is_some() { + // Live search inside the picker: swap only the grouped rows, keeping + // the modal and search box focused. The initial picker load has no + // `q`, so it falls through to the full modal below. + return Ok(html_response(views::meal_picker_results( + &meals, + &meal_categories, + list_id, + &user.session.csrf_token, + ))); + } + let picker = views::meal_picker( &meals, &meal_categories, list_id, &user.session.csrf_token, + &q, + ); + return Ok(html_response(picker)); + } + if is_htmx && !is_boosted { + // Live search on the meals page: swap only the grouped results. + return Ok(html_response(views::meal_results( + &meals, + &meal_categories, + &q, ))); } Ok(html_response(views::meals_page( @@ -913,6 +949,7 @@ async fn meals_page( &meals, &meal_categories, &user.session.csrf_token, + &q, ))) } diff --git a/src/ports.rs b/src/ports.rs index fb4c9a6..c717032 100644 --- a/src/ports.rs +++ b/src/ports.rs @@ -238,7 +238,7 @@ pub trait MealRepository: Send + Sync { txn: &mut SqliteConnection, meal_id: i64, ) -> DomainResult>; - async fn list_meals(&self, txn: &mut SqliteConnection) -> DomainResult>; + async fn list_meals(&self, txn: &mut SqliteConnection, query: &str) -> DomainResult>; async fn update_meal( &self, txn: &mut SqliteConnection, diff --git a/src/services.rs b/src/services.rs index d988e00..eaf99fc 100644 --- a/src/services.rs +++ b/src/services.rs @@ -390,10 +390,11 @@ impl MealService { .await } - pub async fn list_meals(&self) -> DomainResult> { + pub async fn list_meals(&self, query: &str) -> DomainResult> { let meals = Arc::clone(&self.meals); + let query = query.to_owned(); self.db - .run(move |txn| Box::pin(async move { meals.list_meals(txn).await })) + .run(move |txn| Box::pin(async move { meals.list_meals(txn, &query).await })) .await } diff --git a/src/sqlite.rs b/src/sqlite.rs index b921917..b426f1c 100644 --- a/src/sqlite.rs +++ b/src/sqlite.rs @@ -1097,12 +1097,15 @@ impl MealRepository for SqliteMealRepository { })) } - async fn list_meals(&self, txn: &mut SqliteConnection) -> DomainResult> { + async fn list_meals(&self, txn: &mut SqliteConnection, query: &str) -> DomainResult> { let rows = sqlx::query( "SELECT id, name, description, category_id FROM meals + WHERE (?1 = '' OR name LIKE '%' || ?2 || '%' ESCAPE '\\') ORDER BY name COLLATE NOCASE ASC", ) + .bind(query) + .bind(escape_like(query)) .fetch_all(&mut *txn) .await .map_err(db_error)?; @@ -1456,6 +1459,14 @@ fn db_error(error: sqlx::Error) -> DomainError { DomainError::Database(error.to_string()) } +/// Escapes a user-supplied search term so `%`, `_` and `\` are matched +/// literally when interpolated into a `LIKE` pattern with `ESCAPE '\'`. +fn escape_like(term: &str) -> String { + term.replace('\\', "\\\\") + .replace('%', "\\%") + .replace('_', "\\_") +} + fn migrate_error(error: sqlx::migrate::MigrateError) -> DomainError { DomainError::Database(error.to_string()) } @@ -2428,7 +2439,7 @@ mod tests { let meals = db .run(move |txn| { let meals = meals.clone(); - Box::pin(async move { meals.list_meals(txn).await }) + Box::pin(async move { meals.list_meals(txn, "").await }) }) .await .unwrap(); @@ -2437,6 +2448,51 @@ mod tests { assert_eq!(names, vec!["Pasta".to_owned(), "Salad".to_owned()]); } + #[tokio::test] + async fn list_meals_filters_by_name_case_insensitively() { + let db = setup().await; + create_meal(&db, "Pasta Carbonara").await; + create_meal(&db, "Chicken Curry").await; + let meals = SqliteMealRepository; + let names = |posted_query: &str| { + let meals = meals.clone(); + let posted_query = posted_query.to_owned(); + db.run(move |txn| { + let meals = meals.clone(); + Box::pin(async move { meals.list_meals(txn, &posted_query).await }) + }) + }; + let matched = names("pasta").await.unwrap(); + let matched_names = matched.iter().map(|m| m.name.as_str()).collect::>(); + assert_eq!(matched_names, vec!["Pasta Carbonara"]); + assert!(names("pizza").await.unwrap().is_empty()); + } + + #[tokio::test] + async fn list_meals_escapes_like_wildcards() { + let db = setup().await; + create_meal(&db, "100% Wholemeal Bread").await; + create_meal(&db, "Salad").await; + let meals = SqliteMealRepository; + let names = |posted_query: &str| { + let meals = meals.clone(); + let posted_query = posted_query.to_owned(); + db.run(move |txn| { + let meals = meals.clone(); + Box::pin(async move { meals.list_meals(txn, &posted_query).await }) + }) + }; + // A bare `%` must not act as a wildcard matching every meal: it only + // matches meals that contain a literal `%` character. + let matched = names("%").await.unwrap(); + assert_eq!(matched.len(), 1); + assert_eq!(matched[0].name, "100% Wholemeal Bread"); + // `_` and `%` in the query are matched literally. + let matched = names("100%").await.unwrap(); + assert_eq!(matched.len(), 1); + assert_eq!(matched[0].name, "100% Wholemeal Bread"); + } + #[tokio::test] async fn get_meal_returns_meal_with_ingredients() { let db = setup().await; diff --git a/src/views.rs b/src/views.rs index ec1534b..5520b91 100644 --- a/src/views.rs +++ b/src/views.rs @@ -293,6 +293,7 @@ pub fn meals_page( meals: &[Meal], meal_categories: &[MealCategory], csrf_token: &str, + q: &str, ) -> Markup { page( "Meals", @@ -306,7 +307,7 @@ pub fn meals_page( } a class="button button-primary" href="/meals/new" { "New meal" } } - @if meals.is_empty() { + @if meals.is_empty() && q.is_empty() { div class="empty-state" { div class="empty-mark" { "🍽" } h3 { "No meals yet" } @@ -319,28 +320,21 @@ pub fn meals_page( h2 { "All meals" } span class="count-badge" { (meals.len()) } } - @if meals.is_empty() { - p class="muted" { "Create a meal to get started." } - } @else { - @for (category_name, category_meals) in meal_groups(meals, meal_categories) { - div class="category-group" { - div class="category-heading" { - h3 { (category_name) " (" (category_meals.len()) ")" } - } - div class="list-cards" { - @for meal in category_meals { - a class="list-card" href=(format!("/meals/{}", meal.id)) { - span class="list-card-icon" { "🍽" } - span class="list-card-copy" { - strong { (meal.name) } - small { (meal.ingredients.len()) " ingredients" } - } - span class="list-card-arrow" { "→" } - } - } - } - } - } + div class="meal-filter-bar" { + input + class="meal-filter" + id="meal-search" + type="search" + name="q" + value=(q) + placeholder="Search meals" + hx-get="/meals" + hx-trigger="input changed delay:200ms" + hx-target="#meal-results" + hx-swap="innerHTML"; + } + div id="meal-results" { + (meal_results(meals, meal_categories, q)) } } aside class="side-column" { @@ -376,6 +370,42 @@ pub fn meals_page( ) } +/// Renders the meals page's grouped results, or the appropriate empty state. +/// Distinct from the database-empty state: when a search yields no matches the +/// list is empty (pre-filtered in SQL) even though meals exist, so we show a +/// search-specific message. +pub fn meal_results(meals: &[Meal], meal_categories: &[MealCategory], q: &str) -> Markup { + html! { + @if meals.is_empty() && q.is_empty() { + p class="muted" { "Create a meal to get started." } + } @else if meals.is_empty() { + div class="meal-filter-empty" { + p { "No meals match your search." } + } + } @else { + @for (category_name, category_meals) in meal_groups(meals, meal_categories) { + div class="category-group" { + div class="category-heading" { + h3 { (category_name) " (" (category_meals.len()) ")" } + } + div class="list-cards" { + @for meal in category_meals { + a class="list-card" href=(format!("/meals/{}", meal.id)) { + span class="list-card-icon" { "🍽" } + span class="list-card-copy" { + strong { (meal.name) } + small { (meal.ingredients.len()) " ingredients" } + } + span class="list-card-arrow" { "→" } + } + } + } + } + } + } + } +} + fn meal_groups<'a>( meals: &'a [Meal], meal_categories: &[MealCategory], @@ -416,6 +446,7 @@ pub fn meal_picker( meal_categories: &[MealCategory], list_id: i64, csrf_token: &str, + q: &str, ) -> Markup { html! { div class="meal-picker-backdrop" onclick="if (event.target === this) this.remove()" { @@ -427,7 +458,19 @@ pub fn meal_picker( } button class="meal-picker-close" type="button" aria-label="Close" onclick="this.closest('.meal-picker-backdrop').remove()" { "✕" } } - @if meals.is_empty() { + div class="meal-filter-bar meal-filter-bar-picker" { + input + class="meal-filter" + name="q" + type="search" + value=(q) + placeholder="Search meals" + hx-get=(format!("/meals?picker={}", list_id)) + hx-trigger="input changed delay:200ms" + hx-target="#meal-picker-results" + hx-swap="innerHTML"; + } + @if meals.is_empty() && q.is_empty() { div class="meal-picker-empty" { span class="empty-mark" { "🍽" } h3 { "No meals yet" } @@ -435,32 +478,51 @@ pub fn meal_picker( a class="button button-primary" href="/meals/new" { "Create a meal" } } } @else { - div class="meal-picker-list" { - @for (category_name, category_meals) in meal_groups(meals, meal_categories) { - div class="category-group" { - div class="category-heading" { - h3 { (category_name) } - } - @for meal in category_meals { - form - hx-post=(format!("/lists/{}/add-meal", list_id)) - hx-target="#list-items" - hx-swap="morph:outerHTML" - hx-on::after-request="if (event.detail.successful) this.closest('.meal-picker-backdrop').remove()" - class="meal-picker-row" - { - input type="hidden" name="csrf" value=(csrf_token); - input type="hidden" name="meal_id" value=(meal.id); - button class="meal-picker-button" type="submit" { - span class="meal-picker-icon" { "🍽" } - span class="meal-picker-copy" { - strong { (meal.name) } - small { (meal.ingredients.len()) " ingredients" } - } - span class="meal-picker-add" { "Add" } - } - } + div id="meal-picker-results" class="meal-picker-list" { + (meal_picker_results(meals, meal_categories, list_id, csrf_token)) + } + } + } + } + } +} + +/// The grouped picker rows, or the search-specific empty state. Kept separate +/// from the database-empty state above. +pub fn meal_picker_results( + meals: &[Meal], + meal_categories: &[MealCategory], + list_id: i64, + csrf_token: &str, +) -> Markup { + html! { + @if meals.is_empty() { + div class="meal-picker-empty" { + p { "No meals match your search." } + } + } @else { + @for (category_name, category_meals) in meal_groups(meals, meal_categories) { + div class="category-group" { + div class="category-heading" { + h3 { (category_name) } + } + @for meal in category_meals { + form + hx-post=(format!("/lists/{}/add-meal", list_id)) + hx-target="#list-items" + hx-swap="morph:outerHTML" + hx-on::after-request="if (event.detail.successful) this.closest('.meal-picker-backdrop').remove()" + class="meal-picker-row" + { + input type="hidden" name="csrf" value=(csrf_token); + input type="hidden" name="meal_id" value=(meal.id); + button class="meal-picker-button" type="submit" { + span class="meal-picker-icon" { "🍽" } + span class="meal-picker-copy" { + strong { (meal.name) } + small { (meal.ingredients.len()) " ingredients" } } + span class="meal-picker-add" { "Add" } } } } diff --git a/static/style.css b/static/style.css index afed54f..2cd44ff 100644 --- a/static/style.css +++ b/static/style.css @@ -378,6 +378,11 @@ textarea:focus { border-color: var(--deep-sage); box-shadow: 0 0 0 4px rgba(85, .meal-picker-empty { padding: 34px 22px 30px; text-align: center; color: var(--muted); } .meal-picker-empty h3 { color: var(--ink); } .meal-picker-empty p { margin-bottom: 18px; } +.meal-filter-bar { margin: -6px 0 18px; } +.meal-filter-bar input { min-height: 40px; padding: 8px 12px; font-size: .9rem; } +.meal-filter-bar-picker { margin: 14px 24px 4px; } +.meal-filter-empty { padding: 28px 18px; text-align: center; color: var(--muted); } +.meal-filter-empty p { margin: 0; } @media (max-width: 780px) { .site-header, .site-main, .site-footer { width: min(100% - 28px, 600px); }