diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 43265a6..be4e328 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -11,9 +11,17 @@ export async function registerAndLogin(page: Page, email: string) { } /** Creates a meal with the given name and markdown description. */ -export async function createMeal(page: Page, name: string, description: string) { +export async function createMeal( + page: Page, + name: string, + description: string, + category?: string, +) { await page.goto("/meals/new"); await page.fill("#meal-name", name); + if (category) { + await page.selectOption("#meal-category", { label: category }); + } await page.fill("#meal-description", description); await page.click('button:has-text("Save meal")'); await expect(page).toHaveURL(/\/meals\/\d+/); diff --git a/e2e/tests/meal-categories.spec.ts b/e2e/tests/meal-categories.spec.ts new file mode 100644 index 0000000..08c78b4 --- /dev/null +++ b/e2e/tests/meal-categories.spec.ts @@ -0,0 +1,111 @@ +import { expect } from "@playwright/test"; +import { test } from "../fixtures"; +import { registerAndLogin, createMeal } from "../helpers"; + +test("meals are grouped under their category on the meals page", async ({ page }) => { + await registerAndLogin(page, "alice@example.com"); + + await createMeal(page, "Beef Stew", "", "Beef"); + await createMeal(page, "Chicken Curry", "", "Chicken"); + await createMeal(page, "Plain Rice", ""); + + await page.goto("/meals"); + + // Each category appears as a heading with its meals beneath it. + const beef = page.locator(".category-group").filter({ hasText: "Beef" }); + await expect(beef.locator(".category-heading")).toContainText("Beef"); + await expect(beef.locator(".list-card").filter({ hasText: "Beef Stew" })).toBeVisible(); + + const chicken = page.locator(".category-group").filter({ hasText: "Chicken" }); + await expect(chicken.locator(".list-card").filter({ hasText: "Chicken Curry" })).toBeVisible(); + + // Uncategorized meals land in their own group. + const uncategorized = page.locator(".category-group").filter({ hasText: "Uncategorized" }); + await expect(uncategorized.locator(".list-card").filter({ hasText: "Plain Rice" })).toBeVisible(); +}); + +test("a user can create a meal category", async ({ page }) => { + await registerAndLogin(page, "alice@example.com"); + + await page.goto("/meals"); + await page.fill('form[action="/meals/categories"] input[name="name"]', "Breakfast"); + await page.click('form[action="/meals/categories"] button[type="submit"]'); + + await expect(page).toHaveURL(/\/meals$/); + await expect(page.locator(".meal-category-name").filter({ hasText: "Breakfast" })).toBeVisible(); +}); + +test("a user can delete a meal category and its meals become uncategorized", async ({ page }) => { + await registerAndLogin(page, "alice@example.com"); + + // Create a custom category and a meal in it. + await page.goto("/meals"); + await page.fill('form[action="/meals/categories"] input[name="name"]', "Breakfast"); + await page.click('form[action="/meals/categories"] button[type="submit"]'); + await expect(page).toHaveURL(/\/meals$/); + + await createMeal(page, "Pancakes", "", "Breakfast"); + + // Delete the category. + await page.goto("/meals"); + const row = page.locator(".meal-category-row").filter({ hasText: "Breakfast" }); + await row.locator(".meal-category-delete").click(); + await expect(page).toHaveURL(/\/meals$/); + + // The category is gone and the meal is now uncategorized. + await expect(page.locator(".meal-category-name").filter({ hasText: "Breakfast" })).toHaveCount(0); + const uncategorized = page.locator(".category-group").filter({ hasText: "Uncategorized" }); + await expect(uncategorized.locator(".list-card").filter({ hasText: "Pancakes" })).toBeVisible(); +}); + +test("a user can change a meal's category via the edit modal", async ({ page }) => { + await registerAndLogin(page, "alice@example.com"); + await createMeal(page, "Beef Stew", "", "Beef"); + + await page.click('button:has-text("Edit")'); + const dialog = page.locator("dialog#meal-edit-modal"); + await expect(dialog).toBeVisible(); + await dialog.locator("#meal-edit-category").selectOption({ label: "Chicken" }); + await dialog.locator("#meal-edit-save").click(); + + await expect(page).toHaveURL(/\/meals\/\d+/); + await page.goto("/meals"); + + const chicken = page.locator(".category-group").filter({ + has: page.locator(".category-heading", { hasText: "Chicken" }), + }); + await expect(chicken.locator(".list-card").filter({ hasText: "Beef Stew" })).toBeVisible(); + const beef = page.locator(".category-group").filter({ + has: page.locator(".category-heading", { hasText: "Beef" }), + }); + await expect(beef.locator(".list-card").filter({ hasText: "Beef Stew" })).toHaveCount(0); +}); + +test("a meal's category is shown on its page", async ({ page }) => { + await registerAndLogin(page, "alice@example.com"); + await createMeal(page, "Beef Stew", "", "Beef"); + + await expect(page.locator(".meal-category-label")).toHaveText("(Beef)"); +}); + +test("the add-meal picker groups meals by category", async ({ page }) => { + await registerAndLogin(page, "alice@example.com"); + await createMeal(page, "Beef Stew", "", "Beef"); + await createMeal(page, "Chicken Curry", "", "Chicken"); + + // Go to a list to open the picker. + await page.goto("/lists"); + await page.fill("#list-name", "Weekly shop"); + await page.click('button:has-text("Create list")'); + await expect(page).toHaveURL(/\/lists\/\d+/); + + await page.click(".add-meal-button"); + const picker = page.locator(".meal-picker-backdrop"); + await expect(picker).toBeVisible(); + + const beef = picker.locator(".category-group").filter({ hasText: "Beef" }); + await expect(beef.locator(".meal-picker-button").filter({ hasText: "Beef Stew" })).toBeVisible(); + + const chicken = picker.locator(".category-group").filter({ hasText: "Chicken" }); + await expect(chicken.locator(".meal-picker-button").filter({ hasText: "Chicken Curry" })).toBeVisible(); +}); diff --git a/migrations/20240102000000_meal_categories.sql b/migrations/20240102000000_meal_categories.sql new file mode 100644 index 0000000..6a6186c --- /dev/null +++ b/migrations/20240102000000_meal_categories.sql @@ -0,0 +1,10 @@ +CREATE TABLE meal_categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE COLLATE NOCASE, + position INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL +); + +ALTER TABLE meals ADD COLUMN category_id INTEGER REFERENCES meal_categories(id) ON DELETE SET NULL; + +CREATE INDEX meals_category_idx ON meals(category_id); diff --git a/src/domain.rs b/src/domain.rs index 90e6ed3..2c84095 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -65,11 +65,18 @@ pub struct Category { pub name: String, } +#[derive(Clone, Debug)] +pub struct MealCategory { + pub id: i64, + pub name: String, +} + #[derive(Clone, Debug)] pub struct Meal { pub id: i64, pub name: String, pub description: String, + pub category_id: Option, pub ingredients: Vec, } diff --git a/src/http.rs b/src/http.rs index 406bcab..1522315 100644 --- a/src/http.rs +++ b/src/http.rs @@ -104,6 +104,8 @@ pub fn build_router(state: AppState) -> Router { .route("/invitations", post(create_invitation)) .route("/meals", get(meals_page).post(create_meal)) .route("/meals/new", get(new_meal_page)) + .route("/meals/categories", post(create_meal_category)) + .route("/meals/categories/{category_id}/delete", post(delete_meal_category)) .route("/meals/{meal_id}", get(meal_page)) .route("/meals/{meal_id}/edit", post(edit_meal)) .route("/meals/{meal_id}/delete", post(delete_meal)) @@ -288,6 +290,8 @@ struct MealForm { name: String, #[serde(default)] description: String, + #[serde(default)] + category_id: Option, csrf: String, } @@ -721,26 +725,65 @@ async fn create_category( Ok(Redirect::to("/lists").into_response()) } +async fn create_meal_category( + State(state): State, + user: CurrentUser, + LoggedForm(form): LoggedForm, +) -> Result { + verify_csrf(&user, &form.csrf)?; + let name = form.name.trim().to_owned(); + if name.is_empty() || name.chars().count() > 60 { + return Err(AppError::BadRequest( + "Category names must be between 1 and 60 characters.".into(), + )); + } + state.meals.create_meal_category(name).await?; + Ok(Redirect::to("/meals").into_response()) +} + +async fn delete_meal_category( + State(state): State, + user: CurrentUser, + Path(category_id): Path, + LoggedForm(form): LoggedForm, +) -> Result { + verify_csrf(&user, &form.csrf)?; + state.meals.delete_meal_category(category_id).await?; + Ok(Redirect::to("/meals").into_response()) +} + async fn meals_page( State(state): State, user: CurrentUser, Query(query): Query, ) -> Result { let meals = state.meals.list_meals().await?; + let meal_categories = state.meals.list_meal_categories().await?; if let Some(list_id) = query.picker { return Ok(html_response(views::meal_picker( &meals, + &meal_categories, list_id, &user.session.csrf_token, ))); } - Ok(html_response(views::meals_page(&user.session.user, &meals))) + Ok(html_response(views::meals_page( + &user.session.user, + &meals, + &meal_categories, + &user.session.csrf_token, + ))) } -async fn new_meal_page(user: CurrentUser) -> Result { +async fn new_meal_page( + State(state): State, + user: CurrentUser, +) -> Result { + let meal_categories = state.meals.list_meal_categories().await?; Ok(html_response(views::meal_form_page( &user.session.user, None, + &meal_categories, &user.session.csrf_token, ))) } @@ -759,7 +802,11 @@ async fn create_meal( } let meal = state .meals - .create_meal(name, form.description.trim().to_owned()) + .create_meal( + name, + form.description.trim().to_owned(), + parse_category_id(form.category_id), + ) .await?; Ok(Redirect::to(&format!("/meals/{}", meal.id)).into_response()) } @@ -775,10 +822,12 @@ async fn meal_page( .await? .ok_or(AppError::NotFound)?; let categories = state.lists.categories().await?; + let meal_categories = state.meals.list_meal_categories().await?; Ok(html_response(views::meal_page( &user.session.user, &meal, &categories, + &meal_categories, &user.session.csrf_token, ))) } @@ -798,7 +847,12 @@ async fn edit_meal( } state .meals - .update_meal(meal_id, name, form.description.trim().to_owned()) + .update_meal( + meal_id, + name, + form.description.trim().to_owned(), + parse_category_id(form.category_id), + ) .await?; Ok(Redirect::to(&format!("/meals/{meal_id}")).into_response()) } diff --git a/src/main.rs b/src/main.rs index 95b84b0..4444002 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,15 +20,15 @@ use crate::http::{AppState, build_router}; use crate::hub::InMemoryHub; use crate::ports::{ CategoryRepository, InvitationRepository, ItemRepository, ListRepository, - MealIngredientRepository, MealRepository, PasskeyRepository, PasswordHasher, RealtimeNotifier, - SessionRepository, TokenGenerator, UserRepository, + MealCategoryRepository, MealIngredientRepository, MealRepository, PasskeyRepository, + PasswordHasher, RealtimeNotifier, SessionRepository, TokenGenerator, UserRepository, }; use crate::security::{Argon2PasswordHasher, RandomTokenGenerator}; use crate::services::{AuthService, InvitationService, ListService, MealService, RegistrationMode}; use crate::sqlite::{ SqliteCategoryRepository, SqliteDatabase, SqliteInvitationRepository, SqliteItemRepository, - SqliteListRepository, SqliteMealIngredientRepository, SqliteMealRepository, - SqlitePasskeyRepository, SqliteSessionRepository, SqliteUserRepository, + SqliteListRepository, SqliteMealCategoryRepository, SqliteMealIngredientRepository, + SqliteMealRepository, SqlitePasskeyRepository, SqliteSessionRepository, SqliteUserRepository, }; use crate::webauthn::{AppWebauthnConfig, WebAuthnService}; @@ -77,6 +77,7 @@ async fn main() -> Result<(), Box> { let meals: Arc = Arc::new(SqliteMealRepository); let meal_ingredients: Arc = Arc::new(SqliteMealIngredientRepository); + let meal_categories: Arc = Arc::new(SqliteMealCategoryRepository); let invitations: Arc = Arc::new(SqliteInvitationRepository); let passkeys: Arc = Arc::new(SqlitePasskeyRepository); let hasher: Arc = Arc::new(Argon2PasswordHasher); @@ -107,6 +108,7 @@ async fn main() -> Result<(), Box> { db.clone(), Arc::clone(&meals), Arc::clone(&meal_ingredients), + Arc::clone(&meal_categories), Arc::clone(&lists), Arc::clone(&items), Arc::clone(&realtime), diff --git a/src/ports.rs b/src/ports.rs index 31db3da..ec4aaaa 100644 --- a/src/ports.rs +++ b/src/ports.rs @@ -2,8 +2,8 @@ use async_trait::async_trait; use sqlx::SqliteConnection; use crate::domain::{ - Category, DomainResult, GroceryList, Item, Meal, MealIngredient, Passkey, PresenceUser, - SessionUser, User, + Category, DomainResult, GroceryList, Item, Meal, MealCategory, MealIngredient, Passkey, + PresenceUser, SessionUser, User, }; /// Repositories take `&mut SqliteConnection` (which a `Transaction` derefs to), @@ -174,6 +174,21 @@ pub trait InvitationRepository: Send + Sync { ) -> DomainResult<()>; } +#[async_trait] +pub trait MealCategoryRepository: Send + Sync { + async fn meal_categories(&self, txn: &mut SqliteConnection) -> DomainResult>; + async fn create_meal_category( + &self, + txn: &mut SqliteConnection, + name: String, + ) -> DomainResult; + async fn delete_meal_category( + &self, + txn: &mut SqliteConnection, + category_id: i64, + ) -> DomainResult<()>; +} + #[async_trait] pub trait MealRepository: Send + Sync { async fn create_meal( @@ -181,6 +196,7 @@ pub trait MealRepository: Send + Sync { txn: &mut SqliteConnection, name: String, description: String, + category_id: Option, ) -> DomainResult; async fn get_meal( &self, @@ -194,6 +210,7 @@ pub trait MealRepository: Send + Sync { meal_id: i64, name: String, description: String, + category_id: Option, ) -> DomainResult<()>; async fn delete_meal(&self, txn: &mut SqliteConnection, meal_id: i64) -> DomainResult<()>; } diff --git a/src/services.rs b/src/services.rs index 2f2c080..67e3cb0 100644 --- a/src/services.rs +++ b/src/services.rs @@ -1,10 +1,12 @@ use std::sync::Arc; -use crate::domain::{DomainError, DomainResult, GroceryList, Item, Meal, SessionUser, User}; +use crate::domain::{ + DomainError, DomainResult, GroceryList, Item, Meal, MealCategory, SessionUser, User, +}; use crate::ports::{ CategoryRepository, InvitationRepository, ItemRepository, ListRepository, - MealIngredientRepository, MealRepository, NewItem, PasswordHasher, RealtimeNotifier, - SessionRepository, TokenGenerator, UserRepository, + MealCategoryRepository, MealIngredientRepository, MealRepository, NewItem, PasswordHasher, + RealtimeNotifier, SessionRepository, TokenGenerator, UserRepository, }; use crate::sqlite::SqliteDatabase; @@ -313,6 +315,7 @@ pub struct MealService { db: SqliteDatabase, meals: Arc, ingredients: Arc, + meal_categories: Arc, lists: Arc, items: Arc, realtime: Arc, @@ -323,6 +326,7 @@ impl MealService { db: SqliteDatabase, meals: Arc, ingredients: Arc, + meal_categories: Arc, lists: Arc, items: Arc, realtime: Arc, @@ -331,17 +335,25 @@ impl MealService { db, meals, ingredients, + meal_categories, lists, items, realtime, } } - pub async fn create_meal(&self, name: String, description: String) -> DomainResult { + pub async fn create_meal( + &self, + name: String, + description: String, + category_id: Option, + ) -> DomainResult { let meals = Arc::clone(&self.meals); self.db .run(move |txn| { - Box::pin(async move { meals.create_meal(txn, name, description).await }) + Box::pin(async move { + meals.create_meal(txn, name, description, category_id).await + }) }) .await } @@ -365,11 +377,41 @@ impl MealService { meal_id: i64, name: String, description: String, + category_id: Option, ) -> DomainResult<()> { let meals = Arc::clone(&self.meals); self.db .run(move |txn| { - Box::pin(async move { meals.update_meal(txn, meal_id, name, description).await }) + Box::pin(async move { + meals + .update_meal(txn, meal_id, name, description, category_id) + .await + }) + }) + .await + } + + pub async fn list_meal_categories(&self) -> DomainResult> { + let meal_categories = Arc::clone(&self.meal_categories); + self.db + .run(move |txn| Box::pin(async move { meal_categories.meal_categories(txn).await })) + .await + } + + pub async fn create_meal_category(&self, name: String) -> DomainResult { + let meal_categories = Arc::clone(&self.meal_categories); + self.db + .run(move |txn| { + Box::pin(async move { meal_categories.create_meal_category(txn, name).await }) + }) + .await + } + + pub async fn delete_meal_category(&self, category_id: i64) -> DomainResult<()> { + let meal_categories = Arc::clone(&self.meal_categories); + self.db + .run(move |txn| { + Box::pin(async move { meal_categories.delete_meal_category(txn, category_id).await }) }) .await } diff --git a/src/sqlite.rs b/src/sqlite.rs index 06ed8c6..85a267e 100644 --- a/src/sqlite.rs +++ b/src/sqlite.rs @@ -7,13 +7,13 @@ use sha2::{Digest, Sha256}; use sqlx::{Connection, Row, SqliteConnection, SqlitePool, sqlite::SqliteConnectOptions}; use crate::domain::{ - Category, DomainError, DomainResult, GroceryList, Item, Meal, MealIngredient, Passkey, - SessionUser, User, + Category, DomainError, DomainResult, GroceryList, Item, Meal, MealCategory, MealIngredient, + Passkey, SessionUser, User, }; use crate::ports::{ CategoryRepository, InvitationRepository, ItemRepository, ListRepository, - MealIngredientRepository, MealRepository, NewItem, PasskeyRepository, SessionRepository, - UserRepository, + MealCategoryRepository, MealIngredientRepository, MealRepository, NewItem, PasskeyRepository, + SessionRepository, UserRepository, }; /// The embedded SQL migrations, applied automatically on startup. @@ -35,6 +35,7 @@ impl SqliteDatabase { let pool = SqlitePool::connect_with(options).await.map_err(db_error)?; MIGRATOR.run(&pool).await.map_err(migrate_error)?; seed_default_categories(&pool).await?; + seed_default_meal_categories(&pool).await?; Ok(Self { pool }) } @@ -61,6 +62,7 @@ impl SqliteDatabase { let pool = SqlitePool::connect_with(options).await.map_err(db_error)?; MIGRATOR.run(&pool).await.map_err(migrate_error)?; seed_default_categories(&pool).await?; + seed_default_meal_categories(&pool).await?; Ok(Self { pool }) } } @@ -117,6 +119,30 @@ async fn seed_default_categories(pool: &SqlitePool) -> DomainResult<()> { Ok(()) } +/// Inserts the default meal categories once, if the meal_categories table is empty. +async fn seed_default_meal_categories(pool: &SqlitePool) -> DomainResult<()> { + let count: i64 = sqlx::query("SELECT COUNT(*) FROM meal_categories") + .fetch_one(pool) + .await + .map_err(db_error)? + .get(0); + if count > 0 { + return Ok(()); + } + for (position, category_name) in DEFAULT_MEAL_CATEGORIES.iter().enumerate() { + sqlx::query( + "INSERT INTO meal_categories (name, position, created_at) VALUES (?1, ?2, ?3)", + ) + .bind(category_name) + .bind(position as i64) + .bind(now()) + .execute(pool) + .await + .map_err(db_error)?; + } + Ok(()) +} + #[derive(Clone, Copy)] pub struct SqliteUserRepository; @@ -779,6 +805,78 @@ impl InvitationRepository for SqliteInvitationRepository { } } +#[derive(Clone, Copy)] +pub struct SqliteMealCategoryRepository; + +#[async_trait] +impl MealCategoryRepository for SqliteMealCategoryRepository { + async fn meal_categories(&self, txn: &mut SqliteConnection) -> DomainResult> { + let rows = sqlx::query( + "SELECT id, name + FROM meal_categories + ORDER BY position ASC, name COLLATE NOCASE ASC", + ) + .fetch_all(&mut *txn) + .await + .map_err(db_error)?; + Ok(rows + .into_iter() + .map(|row| MealCategory { + id: row.get(0), + name: row.get(1), + }) + .collect()) + } + + async fn create_meal_category( + &self, + txn: &mut SqliteConnection, + name: String, + ) -> DomainResult { + let position: i64 = sqlx::query("SELECT COALESCE(MAX(position), -1) + 1 FROM meal_categories") + .fetch_one(&mut *txn) + .await + .map_err(db_error)? + .get(0); + let result = sqlx::query( + "INSERT INTO meal_categories (name, position, created_at) + VALUES (?1, ?2, ?3)", + ) + .bind(&name) + .bind(position) + .bind(now()) + .execute(&mut *txn) + .await; + match result { + Ok(_) => {} + Err(error) if is_unique_violation(&error) => return Err(DomainError::Conflict), + Err(error) => return Err(db_error(error)), + } + Ok(sqlx::query("SELECT last_insert_rowid()") + .fetch_one(&mut *txn) + .await + .map_err(db_error)? + .get::(0)) + } + + async fn delete_meal_category( + &self, + txn: &mut SqliteConnection, + category_id: i64, + ) -> DomainResult<()> { + let changed = sqlx::query("DELETE FROM meal_categories WHERE id = ?1") + .bind(category_id) + .execute(&mut *txn) + .await + .map_err(db_error)? + .rows_affected(); + if changed == 0 { + return Err(DomainError::NotFound); + } + Ok(()) + } +} + #[derive(Clone, Copy)] pub struct SqliteMealRepository; @@ -789,14 +887,16 @@ impl MealRepository for SqliteMealRepository { txn: &mut SqliteConnection, name: String, description: String, + category_id: Option, ) -> DomainResult { let now = now(); sqlx::query( - "INSERT INTO meals (name, description, created_at, updated_at) - VALUES (?1, ?2, ?3, ?3)", + "INSERT INTO meals (name, description, category_id, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?4)", ) .bind(&name) .bind(&description) + .bind(category_id) .bind(now) .execute(&mut *txn) .await @@ -810,6 +910,7 @@ impl MealRepository for SqliteMealRepository { id, name, description, + category_id, ingredients: Vec::new(), }) } @@ -820,7 +921,7 @@ impl MealRepository for SqliteMealRepository { meal_id: i64, ) -> DomainResult> { let row = sqlx::query( - "SELECT id, name, description + "SELECT id, name, description, category_id FROM meals WHERE id = ?1", ) @@ -835,6 +936,7 @@ impl MealRepository for SqliteMealRepository { id: row.get(0), name: row.get(1), description: row.get(2), + category_id: row.get(3), ingredients: Vec::new(), }; let ingredients = SqliteMealIngredientRepository @@ -848,7 +950,7 @@ impl MealRepository for SqliteMealRepository { async fn list_meals(&self, txn: &mut SqliteConnection) -> DomainResult> { let rows = sqlx::query( - "SELECT id, name, description + "SELECT id, name, description, category_id FROM meals ORDER BY name COLLATE NOCASE ASC", ) @@ -861,6 +963,7 @@ impl MealRepository for SqliteMealRepository { id: row.get(0), name: row.get(1), description: row.get(2), + category_id: row.get(3), ingredients: Vec::new(), }; let ingredients = SqliteMealIngredientRepository @@ -880,14 +983,16 @@ impl MealRepository for SqliteMealRepository { meal_id: i64, name: String, description: String, + category_id: Option, ) -> DomainResult<()> { let changed = sqlx::query( "UPDATE meals - SET name = ?1, description = ?2, updated_at = ?3 - WHERE id = ?4", + SET name = ?1, description = ?2, category_id = ?3, updated_at = ?4 + WHERE id = ?5", ) .bind(&name) .bind(&description) + .bind(category_id) .bind(now()) .bind(meal_id) .execute(&mut *txn) @@ -1076,6 +1181,15 @@ const DEFAULT_CATEGORIES: &[&str] = &[ "Household", ]; +const DEFAULT_MEAL_CATEGORIES: &[&str] = &[ + "Beef", + "Chicken", + "Pasta", + "Sandwiches", + "Salads", + "Soups", +]; + fn hash_secret(secret: &str) -> Vec { let mut hasher = Sha256::new(); hasher.update(secret.as_bytes()); @@ -1479,6 +1593,108 @@ mod tests { assert!(matches!(result, Err(DomainError::Conflict))); } + // ---- MealCategoryRepository ---- + + async fn get_meal_categories(db: &SqliteDatabase) -> Vec { + let categories = SqliteMealCategoryRepository; + db.run(move |txn| { + let categories = categories.clone(); + Box::pin(async move { categories.meal_categories(txn).await }) + }) + .await + .unwrap() + } + + #[tokio::test] + async fn meal_categories_are_seeded_with_defaults() { + let db = setup().await; + let categories = get_meal_categories(&db).await; + let names = categories.iter().map(|c| c.name.as_str()).collect::>(); + assert!(names.contains(&"Beef")); + assert!(names.contains(&"Chicken")); + assert!(names.contains(&"Pasta")); + assert!(names.contains(&"Sandwiches")); + assert!(names.contains(&"Salads")); + assert!(names.contains(&"Soups")); + } + + #[tokio::test] + async fn create_meal_category_returns_id_and_lists() { + let db = setup().await; + let categories = SqliteMealCategoryRepository; + let id = db + .run(move |txn| { + let categories = categories.clone(); + Box::pin(async move { + categories.create_meal_category(txn, "Breakfast".into()).await + }) + }) + .await + .unwrap(); + assert!(id > 0); + let cats = get_meal_categories(&db).await; + assert!(cats.iter().any(|c| c.id == id && c.name == "Breakfast")); + } + + #[tokio::test] + async fn create_duplicate_meal_category_conflicts() { + let db = setup().await; + let categories = SqliteMealCategoryRepository; + let result = db + .run(move |txn| { + let categories = categories.clone(); + Box::pin(async move { + categories.create_meal_category(txn, "Beef".into()).await + }) + }) + .await; + assert!(matches!(result, Err(DomainError::Conflict))); + } + + #[tokio::test] + async fn delete_meal_category_cascades_to_null_on_meals() { + let db = setup().await; + let categories = SqliteMealCategoryRepository; + let category_id = db + .run(move |txn| { + let categories = categories.clone(); + Box::pin(async move { + categories.create_meal_category(txn, "Breakfast".into()).await + }) + }) + .await + .unwrap(); + let meal = create_meal(&db, "Pancakes").await; + let meals = SqliteMealRepository; + db.run(move |txn| { + let meals = meals.clone(); + Box::pin(async move { + meals + .update_meal(txn, meal.id, "Pancakes".into(), String::new(), Some(category_id)) + .await + }) + }) + .await + .unwrap(); + + db.run(move |txn| { + let categories = categories.clone(); + Box::pin(async move { categories.delete_meal_category(txn, category_id).await }) + }) + .await + .unwrap(); + + let fetched = db + .run(move |txn| { + let meals = meals.clone(); + Box::pin(async move { meals.get_meal(txn, meal.id).await }) + }) + .await + .unwrap() + .unwrap(); + assert_eq!(fetched.category_id, None); + } + // ---- ItemRepository ---- #[tokio::test] @@ -1828,7 +2044,7 @@ mod tests { let name = name.to_owned(); db.run(move |txn| { let meals = meals.clone(); - Box::pin(async move { meals.create_meal(txn, name, String::new()).await }) + Box::pin(async move { meals.create_meal(txn, name, String::new(), None).await }) }) .await .unwrap() @@ -1946,7 +2162,7 @@ mod tests { let meals = meals.clone(); Box::pin(async move { meals - .update_meal(txn, meal.id, "Pasta al pomodoro".into(), "desc".into()) + .update_meal(txn, meal.id, "Pasta al pomodoro".into(), "desc".into(), None) .await }) }) @@ -1973,7 +2189,7 @@ mod tests { let meals = meals.clone(); Box::pin(async move { meals - .update_meal(txn, 9999, "X".into(), String::new()) + .update_meal(txn, 9999, "X".into(), String::new(), None) .await }) }) diff --git a/src/views.rs b/src/views.rs index 53eb9ef..5e7db87 100644 --- a/src/views.rs +++ b/src/views.rs @@ -3,7 +3,7 @@ use pulldown_cmark::{Options, Parser, html as cmark_html}; use crate::{ domain::PresenceUser, - domain::{Category, GroceryList, Item, Meal, MealIngredient, Passkey, User}, + domain::{Category, GroceryList, Item, Meal, MealCategory, MealIngredient, Passkey, User}, }; pub fn login_page(error: Option<&str>, invite: Option<&str>) -> Markup { @@ -234,7 +234,12 @@ pub fn lists_page(user: &User, lists: &[GroceryList], csrf_token: &str) -> Marku ) } -pub fn meals_page(user: &User, meals: &[Meal]) -> Markup { +pub fn meals_page( + user: &User, + meals: &[Meal], + meal_categories: &[MealCategory], + csrf_token: &str, +) -> Markup { page( "Meals", Some(user), @@ -247,6 +252,13 @@ pub fn meals_page(user: &User, meals: &[Meal]) -> Markup { } a class="button button-primary" href="/meals/new" { "New meal" } } + @if meals.is_empty() { + div class="empty-state" { + div class="empty-mark" { "🍽" } + h3 { "No meals yet" } + p { "Create a meal to reuse its ingredients across your lists." } + } + } div class="dashboard-grid" { section class="panel" { div class="panel-heading" { @@ -254,21 +266,52 @@ pub fn meals_page(user: &User, meals: &[Meal]) -> Markup { span class="count-badge" { (meals.len()) } } @if meals.is_empty() { - div class="empty-state" { - div class="empty-mark" { "🍽" } - h3 { "No meals yet" } - p { "Create a meal to reuse its ingredients across your lists." } - } + p class="muted" { "Create a meal to get started." } } @else { - div class="list-cards" { - @for meal in 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" } + @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" { "→" } + } + } + } + } + } + } + } + aside class="side-column" { + section class="panel categories-panel" { + div class="panel-heading" { + h2 { "Meal categories" } + } + p { "Organize meals by type." } + form method="post" action="/meals/categories" class="category-form" { + input type="hidden" name="csrf" value=(csrf_token); + input name="name" type="text" maxlength="60" placeholder="New category" required; + button class="button button-small button-secondary" type="submit" { "Add" } + } + @if meal_categories.is_empty() { + p class="muted category-empty" { "No categories yet." } + } @else { + div class="meal-category-list" { + @for category in meal_categories { + div class="meal-category-row" { + span class="meal-category-name" { (category.name) } + form method="post" action=(format!("/meals/categories/{}/delete", category.id)) { + input type="hidden" name="csrf" value=(csrf_token); + button class="meal-category-delete" type="submit" aria-label=(format!("Delete {}", category.name)) { "✕" } + } } - span class="list-card-arrow" { "→" } } } } @@ -279,7 +322,47 @@ pub fn meals_page(user: &User, meals: &[Meal]) -> Markup { ) } -pub fn meal_picker(meals: &[Meal], list_id: i64, csrf_token: &str) -> Markup { +fn meal_groups<'a>( + meals: &'a [Meal], + meal_categories: &[MealCategory], +) -> Vec<(String, Vec<&'a Meal>)> { + let mut groups = Vec::new(); + for category in meal_categories { + let in_category = meals + .iter() + .filter(|meal| meal.category_id == Some(category.id)) + .collect::>(); + if !in_category.is_empty() { + groups.push((category.name.clone(), in_category)); + } + } + + let uncategorized = meals + .iter() + .filter(|meal| meal.category_id.is_none()) + .collect::>(); + if !uncategorized.is_empty() { + groups.push(("Uncategorized".into(), uncategorized)); + } + groups +} + +/// Returns the display name of a meal's category, if it has one. +fn meal_category_name(meal: &Meal, meal_categories: &[MealCategory]) -> Option { + meal.category_id.and_then(|id| { + meal_categories + .iter() + .find(|category| category.id == id) + .map(|category| category.name.clone()) + }) +} + +pub fn meal_picker( + meals: &[Meal], + meal_categories: &[MealCategory], + list_id: i64, + csrf_token: &str, +) -> Markup { html! { div class="meal-picker-backdrop" onclick="if (event.target === this) this.remove()" { div class="meal-picker-modal" role="dialog" aria-modal="true" aria-label="Add a meal" { @@ -299,23 +382,30 @@ pub fn meal_picker(meals: &[Meal], list_id: i64, csrf_token: &str) -> Markup { } } @else { div class="meal-picker-list" { - @for meal in meals { - form - hx-post=(format!("/lists/{}/add-meal", list_id)) - hx-target="#list-items" - hx-swap="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" } + @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="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" } + } } - span class="meal-picker-add" { "Add" } } } } @@ -326,15 +416,27 @@ pub fn meal_picker(meals: &[Meal], list_id: i64, csrf_token: &str) -> Markup { } } -pub fn meal_form_page(user: &User, meal: Option<&Meal>, csrf_token: &str) -> Markup { - let (title, action, name, description) = match meal { +pub fn meal_form_page( + user: &User, + meal: Option<&Meal>, + meal_categories: &[MealCategory], + csrf_token: &str, +) -> Markup { + let (title, action, name, description, category_id) = match meal { Some(meal) => ( "Edit meal", format!("/meals/{}/edit", meal.id), meal.name.clone(), meal.description.clone(), + meal.category_id, + ), + None => ( + "New meal", + "/meals".into(), + String::new(), + String::new(), + None, ), - None => ("New meal", "/meals".into(), String::new(), String::new()), }; page( title, @@ -349,6 +451,21 @@ pub fn meal_form_page(user: &User, meal: Option<&Meal>, csrf_token: &str) -> Mar input type="hidden" name="csrf" value=(csrf_token); label for="meal-name" { "Name" } input id="meal-name" name="name" type="text" maxlength="120" value=(name) required; + label for="meal-category" { "Category" } + select id="meal-category" name="category_id" { + @if category_id.is_none() { + option value="" selected { "Uncategorized" } + } @else { + option value="" { "Uncategorized" } + } + @for category in meal_categories { + @if category_id == Some(category.id) { + option value=(category.id) selected { (category.name) } + } @else { + option value=(category.id) { (category.name) } + } + } + } label for="meal-description" { "Description (markdown)" } textarea id="meal-description" name="description" rows="8" { (description) } button class="button button-primary" type="submit" { "Save meal" } @@ -361,7 +478,13 @@ pub fn meal_form_page(user: &User, meal: Option<&Meal>, csrf_token: &str) -> Mar ) } -pub fn meal_page(user: &User, meal: &Meal, categories: &[Category], csrf_token: &str) -> Markup { +pub fn meal_page( + user: &User, + meal: &Meal, + categories: &[Category], + meal_categories: &[MealCategory], + csrf_token: &str, +) -> Markup { page( &meal.name, Some(user), @@ -386,6 +509,21 @@ pub fn meal_page(user: &User, meal: &Meal, categories: &[Category], csrf_token: input type="hidden" name="csrf" value=(csrf_token); label { "Name" } input id="meal-edit-name" name="name" value=(meal.name) maxlength="120" required; + label { "Category" } + select id="meal-edit-category" name="category_id" { + @if meal.category_id.is_none() { + option value="" selected { "Uncategorized" } + } @else { + option value="" { "Uncategorized" } + } + @for category in meal_categories { + @if meal.category_id == Some(category.id) { + option value=(category.id) selected { (category.name) } + } @else { + option value=(category.id) { (category.name) } + } + } + } label { "Description (markdown)" } textarea id="meal-edit-description" name="description" rows="8" { (meal.description) } button id="meal-edit-save" class="button button-primary" type="submit" { "Save meal" } @@ -397,7 +535,7 @@ pub fn meal_page(user: &User, meal: &Meal, categories: &[Category], csrf_token: div class="list-heading" { div { p class="eyebrow" { "MEAL" } - h1 { (meal.name) } + h1 { (meal.name) @if let Some(category_name) = meal_category_name(meal, meal_categories) { span class="meal-category-label" { "(" (category_name) ")" } } } } } @if meal.description.is_empty() { diff --git a/static/style.css b/static/style.css index f90994c..21314c5 100644 --- a/static/style.css +++ b/static/style.css @@ -135,6 +135,7 @@ textarea:focus { border-color: var(--deep-sage); box-shadow: 0 0 0 4px rgba(85, .list-heading { display: flex; justify-content: space-between; margin-bottom: 25px; } .list-heading h1 { max-width: 100%; margin-bottom: 5px; overflow-wrap: anywhere; font-size: clamp(1.45rem, 2.8vw, 2.05rem); } .list-meta { margin: 0; color: var(--muted); font-size: .85rem; } +.meal-category-label { margin-left: 10px; color: var(--muted); font-size: .8em; font-weight: 500; white-space: nowrap; } .add-item-form { display: grid; grid-template-columns: minmax(0, 1fr) 145px 90px auto; gap: 8px; margin-bottom: 19px; } .add-item-form input { min-height: 50px; } .add-item-form select { min-height: 50px; } @@ -229,6 +230,13 @@ textarea:focus { border-color: var(--deep-sage); box-shadow: 0 0 0 4px rgba(85, .category-form input { min-height: 38px; padding: 7px 10px; font-size: .84rem; } .category-list { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 14px; } .category-chip { padding: 5px 9px; border-radius: 99px; color: var(--deep-sage); background: #edf3e8; font-size: .72rem; font-weight: 800; } +/* Meal categories side panel */ +.meal-category-list { display: grid; gap: 2px; margin-top: 14px; } +.meal-category-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 7px 4px; border-bottom: 1px solid #edf0e6; } +.meal-category-row:last-child { border-bottom: 0; } +.meal-category-name { font-size: .9rem; font-weight: 700; } +.meal-category-delete { padding: 2px 6px; border: 0; border-radius: 7px; color: var(--muted); background: transparent; cursor: pointer; font-size: .8rem; line-height: 1; } +.meal-category-delete:hover { color: var(--coral); background: #fbeae4; } .category-empty { margin: 13px 0 0; font-size: .8rem; } .category-result { margin-top: 10px; } .category-success { margin: 0; color: var(--deep-sage); font-size: .76rem; font-weight: 800; }