meal filtering feature
ci/woodpecker/push/fmt Pipeline was successful
ci/woodpecker/push/test Pipeline failed

This commit is contained in:
2026-08-08 16:19:49 -04:00
parent 975cf38170
commit 35de0e1990
7 changed files with 274 additions and 56 deletions
+57
View File
@@ -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();
});
+39 -2
View File
@@ -340,6 +340,7 @@ struct AddMealForm {
#[derive(Debug, Deserialize)]
struct MealPickerQuery {
picker: Option<i64>,
q: Option<String>,
}
async fn home() -> Redirect {
@@ -897,15 +898,50 @@ async fn meals_page(
State(state): State<AppState>,
user: CurrentUser,
Query(query): Query<MealPickerQuery>,
headers: HeaderMap,
) -> Result<Response, AppError> {
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,
)))
}
+1 -1
View File
@@ -238,7 +238,7 @@ pub trait MealRepository: Send + Sync {
txn: &mut SqliteConnection,
meal_id: i64,
) -> DomainResult<Option<Meal>>;
async fn list_meals(&self, txn: &mut SqliteConnection) -> DomainResult<Vec<Meal>>;
async fn list_meals(&self, txn: &mut SqliteConnection, query: &str) -> DomainResult<Vec<Meal>>;
async fn update_meal(
&self,
txn: &mut SqliteConnection,
+3 -2
View File
@@ -390,10 +390,11 @@ impl MealService {
.await
}
pub async fn list_meals(&self) -> DomainResult<Vec<Meal>> {
pub async fn list_meals(&self, query: &str) -> DomainResult<Vec<Meal>> {
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
}
+58 -2
View File
@@ -1097,12 +1097,15 @@ impl MealRepository for SqliteMealRepository {
}))
}
async fn list_meals(&self, txn: &mut SqliteConnection) -> DomainResult<Vec<Meal>> {
async fn list_meals(&self, txn: &mut SqliteConnection, query: &str) -> DomainResult<Vec<Meal>> {
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::<Vec<_>>();
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;
+111 -49
View File
@@ -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" }
}
}
}
+5
View File
@@ -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); }