make categories global

This commit is contained in:
2026-08-01 19:09:22 -04:00
parent b71bdb2d5d
commit 2542f010b1
7 changed files with 139 additions and 126 deletions
+21 -16
View File
@@ -1,6 +1,6 @@
# Meal Feature Plan # Meal Feature Plan
Status: planning (not yet implemented) Status: Part A implemented; Parts BF not yet implemented
This plan adds the concept of a **meal** to Sustenance. A meal has a name, an This plan adds the concept of a **meal** to Sustenance. A meal has a name, an
optional description/recipe (markdown), and a list of ingredients. Meals are optional description/recipe (markdown), and a list of ingredients. Meals are
@@ -14,11 +14,13 @@ The plan is split into parts so each can be implemented and tested independently
## Part A — Refactor categories to be global ## Part A — Refactor categories to be global
**Status: implemented**
Currently `categories` are per-list (`categories.list_id`, with a Currently `categories` are per-list (`categories.list_id`, with a
`UNIQUE (list_id, name)` constraint). Since meals are global and ingredients `UNIQUE (list_id, name)` constraint). Since meals are global and ingredients
reference categories, categories become global too. reference categories, categories become global too.
### Schema change (in `migrate` in `src/sqlite.rs`) ### Schema change (in `migrate` in `src/sqlite.rs`) — done
```sql ```sql
categories ( categories (
@@ -29,27 +31,26 @@ categories (
) )
``` ```
- Drop `list_id`; `name` becomes globally unique. - Dropped `list_id`; `name` is globally unique.
- `items.category_id` stays a FK to `categories(id)` — unchanged. - `items.category_id` stays a FK to `categories(id)` — unchanged.
- **Migration concern:** the current `CREATE TABLE IF NOT EXISTS` won't alter an - Default categories are now seeded once at startup via `seed_default_categories`
existing DB. Need a real migration (or accept recreating the dev DB). (called from `SqliteDatabase::open` / `open_in_memory`).
- **Migration:** since `CREATE TABLE IF NOT EXISTS` won't reshape an existing DB,
the dev DB is recreated (see Open decisions).
### Repo / port changes (`CategoryRepository` in `src/ports.rs`) ### Repo / port changes (`CategoryRepository` in `src/ports.rs`) — done
- `categories(txn)` → returns **all** global categories (no `list_id` param). - `categories(txn)` → returns all global categories (no `list_id` param).
- `create_category(txn, name)` → global, no `list_id`, no per-list revision bump. - `create_category(txn, name)` → global, no `list_id`, no per-list revision bump.
- Add `category_by_name(txn, name)` for resolving ingredient categories. - Added `category_by_name(txn, name)` for resolving ingredient categories.
- `ListRepository::create_list` **no longer seeds** default categories (they're - `ListRepository::create_list` no longer seeds default categories.
global now). Default categories become a one-time seed at startup instead.
### Service / HTTP changes ### Service / HTTP changes — done
- `ListService::categories()` no longer takes `list_id`. - `ListService::categories()` no longer takes `list_id`.
- `create_category` handler moves from `/lists/{list_id}/categories` to a global - `create_category` handler moved to a global `POST /categories` route.
`/categories` route (or a categories management page). - The list page's categories panel shows the global category set.
- The list page's categories panel now shows the global category set. - `create_category` no longer bumps a list revision, so no realtime event for it.
- `create_category` no longer bumps a list revision (not list-scoped anymore),
so no realtime event for it.
--- ---
@@ -157,7 +158,11 @@ Implemented as an htmx-powered modal that fetches a meal list/search fragment.
1. **Migration handling for the category refactor** — since `CREATE TABLE IF NOT 1. **Migration handling for the category refactor** — since `CREATE TABLE IF NOT
EXISTS` won't reshape an existing DB, write a proper migration, or is it fine EXISTS` won't reshape an existing DB, write a proper migration, or is it fine
to drop/recreate the dev DB? to drop/recreate the dev DB?
**Resolution (Part A):** drop/recreate the dev DB. The app was never
deployed, so there is no production data to preserve.
2. **Category management UI** — with categories now global, do we want a 2. **Category management UI** — with categories now global, do we want a
dedicated categories page (e.g. `/categories`) to add/rename/delete them, or dedicated categories page (e.g. `/categories`) to add/rename/delete them, or
keep it minimal (just the add form on the list page, now creating global keep it minimal (just the add form on the list page, now creating global
categories)? categories)?
**Resolution (Part A):** keep it minimal — the add form stays on the list
page's categories panel, now posting to the global `POST /categories` route.
+1 -1
View File
@@ -45,7 +45,7 @@ The file is optional — if it is missing or invalid, seeding is silently skippe
- Shared lists with one-time, seven-day invitation links - Shared lists with one-time, seven-day invitation links
- Invite-only registration by default after the first account - Invite-only registration by default after the first account
- Add, edit, check, and delete grocery items - Add, edit, check, and delete grocery items
- List-scoped categories with common defaults and custom category creation - Global categories with common defaults seeded at startup and custom category creation
- Items grouped by category and assigned from the add/edit forms - Items grouped by category and assigned from the add/edit forms
- Server-authoritative last-write-wins updates - Server-authoritative last-write-wins updates
- Per-list WebSocket updates with server-rendered htmx fragments - Per-list WebSocket updates with server-rendered htmx fragments
+7 -18
View File
@@ -74,7 +74,7 @@ pub fn build_router(state: AppState) -> Router {
.route("/lists/{list_id}/items/{item_id}/check", post(check_item)) .route("/lists/{list_id}/items/{item_id}/check", post(check_item))
.route("/lists/{list_id}/items/{item_id}/edit", post(edit_item)) .route("/lists/{list_id}/items/{item_id}/edit", post(edit_item))
.route("/lists/{list_id}/items/{item_id}/delete", post(delete_item)) .route("/lists/{list_id}/items/{item_id}/delete", post(delete_item))
.route("/lists/{list_id}/categories", post(create_category)) .route("/categories", post(create_category))
.route("/invitations", post(create_invitation)) .route("/invitations", post(create_invitation))
.route("/lists/{list_id}/stream", get(list_stream)) .route("/lists/{list_id}/stream", get(list_stream))
.route("/invite/{token}", get(invitation_page)) .route("/invite/{token}", get(invitation_page))
@@ -361,7 +361,7 @@ async fn list_page(
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let access = require_list(&state, list_id).await?; let access = require_list(&state, list_id).await?;
let items = state.lists.items(list_id).await?; let items = state.lists.items(list_id).await?;
let categories = state.lists.categories(list_id).await?; let categories = state.lists.categories().await?;
let presence = state.realtime.presence(list_id).await; let presence = state.realtime.presence(list_id).await;
Ok(html_response(views::list_page( Ok(html_response(views::list_page(
&user.session.user, &user.session.user,
@@ -460,28 +460,17 @@ async fn delete_item(
async fn create_category( async fn create_category(
State(state): State<AppState>, State(state): State<AppState>,
user: CurrentUser, user: CurrentUser,
Path(list_id): Path<i64>,
LoggedForm(form): LoggedForm<CategoryForm>, LoggedForm(form): LoggedForm<CategoryForm>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?; verify_csrf(&user, &form.csrf)?;
require_list(&state, list_id).await?;
let name = form.name.trim().to_owned(); let name = form.name.trim().to_owned();
if name.is_empty() || name.chars().count() > 60 { if name.is_empty() || name.chars().count() > 60 {
return Err(AppError::BadRequest( return Err(AppError::BadRequest(
"Category names must be between 1 and 60 characters.".into(), "Category names must be between 1 and 60 characters.".into(),
)); ));
} }
state.lists.create_category(list_id, name).await?; state.lists.create_category(name).await?;
Ok(Redirect::to("/lists").into_response())
let access = require_list(&state, list_id).await?;
let items = state.lists.items(list_id).await?;
let categories = state.lists.categories(list_id).await?;
Ok(html_response(views::category_created(
&access,
&items,
&categories,
&user.session.csrf_token,
)))
} }
async fn create_invitation( async fn create_invitation(
@@ -647,7 +636,7 @@ async fn websocket_snapshot(
) -> Result<String, AppError> { ) -> Result<String, AppError> {
let access = require_list(state, list_id).await?; let access = require_list(state, list_id).await?;
let items = state.lists.items(list_id).await?; let items = state.lists.items(list_id).await?;
let categories = state.lists.categories(list_id).await?; let categories = state.lists.categories().await?;
Ok( Ok(
views::live_list_fragments(&access, &items, &categories, &user.session.csrf_token) views::live_list_fragments(&access, &items, &categories, &user.session.csrf_token)
.into_string() .into_string()
@@ -662,7 +651,7 @@ async fn websocket_list_update(
) -> Result<String, AppError> { ) -> Result<String, AppError> {
let access = require_list(state, list_id).await?; let access = require_list(state, list_id).await?;
let items = state.lists.items(list_id).await?; let items = state.lists.items(list_id).await?;
let categories = state.lists.categories(list_id).await?; let categories = state.lists.categories().await?;
Ok( Ok(
views::live_list_fragments(&access, &items, &categories, &user.session.csrf_token) views::live_list_fragments(&access, &items, &categories, &user.session.csrf_token)
.into_string(), .into_string(),
@@ -676,7 +665,7 @@ async fn list_fragment_response(
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let access = require_list(state, list_id).await?; let access = require_list(state, list_id).await?;
let items = state.lists.items(list_id).await?; let items = state.lists.items(list_id).await?;
let categories = state.lists.categories(list_id).await?; let categories = state.lists.categories().await?;
Ok(html_response(views::list_items_fragment( Ok(html_response(views::list_items_fragment(
&access, &access,
&items, &items,
+6 -6
View File
@@ -60,17 +60,17 @@ pub trait ListRepository: Send + Sync {
#[async_trait] #[async_trait]
pub trait CategoryRepository: Send + Sync { pub trait CategoryRepository: Send + Sync {
async fn categories( async fn categories(&self, txn: &mut SqliteConnection) -> DomainResult<Vec<Category>>;
&self,
txn: &mut SqliteConnection,
list_id: i64,
) -> DomainResult<Vec<Category>>;
async fn create_category( async fn create_category(
&self, &self,
txn: &mut SqliteConnection, txn: &mut SqliteConnection,
list_id: i64,
name: String, name: String,
) -> DomainResult<i64>; ) -> DomainResult<i64>;
async fn category_by_name(
&self,
txn: &mut SqliteConnection,
name: String,
) -> DomainResult<Option<Category>>;
} }
#[async_trait] #[async_trait]
+6 -11
View File
@@ -187,10 +187,10 @@ impl ListService {
.await .await
} }
pub async fn categories(&self, list_id: i64) -> DomainResult<Vec<crate::domain::Category>> { pub async fn categories(&self) -> DomainResult<Vec<crate::domain::Category>> {
let categories = Arc::clone(&self.categories); let categories = Arc::clone(&self.categories);
self.db self.db
.run(move |txn| Box::pin(async move { categories.categories(txn, list_id).await })) .run(move |txn| Box::pin(async move { categories.categories(txn).await }))
.await .await
} }
@@ -270,16 +270,11 @@ impl ListService {
Ok(revision) Ok(revision)
} }
pub async fn create_category(&self, list_id: i64, name: String) -> DomainResult<i64> { pub async fn create_category(&self, name: String) -> DomainResult<i64> {
let categories = Arc::clone(&self.categories); let categories = Arc::clone(&self.categories);
let revision = self self.db
.db .run(move |txn| Box::pin(async move { categories.create_category(txn, name).await }))
.run(move |txn| { .await
Box::pin(async move { categories.create_category(txn, list_id, name).await })
})
.await?;
self.realtime.publish_list_changed(list_id, revision).await;
Ok(revision)
} }
} }
+90 -53
View File
@@ -27,6 +27,7 @@ impl SqliteDatabase {
.create_if_missing(true); .create_if_missing(true);
let pool = SqlitePool::connect_with(options).await.map_err(db_error)?; let pool = SqlitePool::connect_with(options).await.map_err(db_error)?;
migrate(&pool).await?; migrate(&pool).await?;
seed_default_categories(&pool).await?;
Ok(Self { pool }) Ok(Self { pool })
} }
@@ -52,6 +53,7 @@ impl SqliteDatabase {
.create_if_missing(true); .create_if_missing(true);
let pool = SqlitePool::connect_with(options).await.map_err(db_error)?; let pool = SqlitePool::connect_with(options).await.map_err(db_error)?;
migrate(&pool).await?; migrate(&pool).await?;
seed_default_categories(&pool).await?;
Ok(Self { pool }) Ok(Self { pool })
} }
} }
@@ -109,11 +111,9 @@ async fn migrate(pool: &SqlitePool) -> DomainResult<()> {
); );
CREATE TABLE IF NOT EXISTS categories ( CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
list_id INTEGER NOT NULL REFERENCES lists(id) ON DELETE CASCADE, name TEXT NOT NULL UNIQUE COLLATE NOCASE,
name TEXT NOT NULL COLLATE NOCASE,
position INTEGER NOT NULL DEFAULT 0, position INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL
UNIQUE (list_id, name)
); );
CREATE TABLE IF NOT EXISTS invitations ( CREATE TABLE IF NOT EXISTS invitations (
token_hash TEXT PRIMARY KEY, token_hash TEXT PRIMARY KEY,
@@ -134,7 +134,6 @@ async fn migrate(pool: &SqlitePool) -> DomainResult<()> {
updated_at INTEGER NOT NULL updated_at INTEGER NOT NULL
); );
CREATE INDEX IF NOT EXISTS items_list_idx ON items(list_id); CREATE INDEX IF NOT EXISTS items_list_idx ON items(list_id);
CREATE INDEX IF NOT EXISTS categories_list_idx ON categories(list_id);
CREATE INDEX IF NOT EXISTS sessions_user_idx ON sessions(user_id);", CREATE INDEX IF NOT EXISTS sessions_user_idx ON sessions(user_id);",
) )
.execute(pool) .execute(pool)
@@ -143,6 +142,28 @@ async fn migrate(pool: &SqlitePool) -> DomainResult<()> {
Ok(()) Ok(())
} }
/// Inserts the default global categories once, if the categories table is empty.
async fn seed_default_categories(pool: &SqlitePool) -> DomainResult<()> {
let count: i64 = sqlx::query("SELECT COUNT(*) FROM categories")
.fetch_one(pool)
.await
.map_err(db_error)?
.get(0);
if count > 0 {
return Ok(());
}
for (position, category_name) in DEFAULT_CATEGORIES.iter().enumerate() {
sqlx::query("INSERT INTO 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)] #[derive(Clone, Copy)]
pub struct SqliteUserRepository; pub struct SqliteUserRepository;
@@ -323,19 +344,6 @@ impl ListRepository for SqliteListRepository {
.await .await
.map_err(db_error)? .map_err(db_error)?
.get::<i64, _>(0); .get::<i64, _>(0);
for (position, category_name) in DEFAULT_CATEGORIES.iter().enumerate() {
sqlx::query(
"INSERT INTO categories (list_id, name, position, created_at)
VALUES (?1, ?2, ?3, ?4)",
)
.bind(list_id)
.bind(category_name)
.bind(position as i64)
.bind(now())
.execute(&mut *txn)
.await
.map_err(db_error)?;
}
Ok(GroceryList { Ok(GroceryList {
id: list_id, id: list_id,
name, name,
@@ -370,18 +378,12 @@ pub struct SqliteCategoryRepository;
#[async_trait] #[async_trait]
impl CategoryRepository for SqliteCategoryRepository { impl CategoryRepository for SqliteCategoryRepository {
async fn categories( async fn categories(&self, txn: &mut SqliteConnection) -> DomainResult<Vec<Category>> {
&self,
txn: &mut SqliteConnection,
list_id: i64,
) -> DomainResult<Vec<Category>> {
let rows = sqlx::query( let rows = sqlx::query(
"SELECT id, name "SELECT id, name
FROM categories FROM categories
WHERE list_id = ?1
ORDER BY position ASC, name COLLATE NOCASE ASC", ORDER BY position ASC, name COLLATE NOCASE ASC",
) )
.bind(list_id)
.fetch_all(&mut *txn) .fetch_all(&mut *txn)
.await .await
.map_err(db_error)?; .map_err(db_error)?;
@@ -397,23 +399,17 @@ impl CategoryRepository for SqliteCategoryRepository {
async fn create_category( async fn create_category(
&self, &self,
txn: &mut SqliteConnection, txn: &mut SqliteConnection,
list_id: i64,
name: String, name: String,
) -> DomainResult<i64> { ) -> DomainResult<i64> {
let position: i64 = sqlx::query( let position: i64 = sqlx::query("SELECT COALESCE(MAX(position), -1) + 1 FROM categories")
"SELECT COALESCE(MAX(position), -1) + 1
FROM categories WHERE list_id = ?1",
)
.bind(list_id)
.fetch_one(&mut *txn) .fetch_one(&mut *txn)
.await .await
.map_err(db_error)? .map_err(db_error)?
.get(0); .get(0);
let result = sqlx::query( let result = sqlx::query(
"INSERT INTO categories (list_id, name, position, created_at) "INSERT INTO categories (name, position, created_at)
VALUES (?1, ?2, ?3, ?4)", VALUES (?1, ?2, ?3)",
) )
.bind(list_id)
.bind(&name) .bind(&name)
.bind(position) .bind(position)
.bind(now()) .bind(now())
@@ -424,7 +420,32 @@ impl CategoryRepository for SqliteCategoryRepository {
Err(error) if is_unique_violation(&error) => return Err(DomainError::Conflict), Err(error) if is_unique_violation(&error) => return Err(DomainError::Conflict),
Err(error) => return Err(db_error(error)), Err(error) => return Err(db_error(error)),
} }
bump_revision(txn, list_id).await let id = sqlx::query("SELECT last_insert_rowid()")
.fetch_one(&mut *txn)
.await
.map_err(db_error)?
.get::<i64, _>(0);
Ok(id)
}
async fn category_by_name(
&self,
txn: &mut SqliteConnection,
name: String,
) -> DomainResult<Option<Category>> {
let row = sqlx::query(
"SELECT id, name
FROM categories
WHERE name = ?1 COLLATE NOCASE",
)
.bind(&name)
.fetch_optional(&mut *txn)
.await
.map_err(db_error)?;
Ok(row.map(|row| Category {
id: row.get(0),
name: row.get(1),
}))
} }
} }
@@ -468,7 +489,7 @@ impl ItemRepository for SqliteItemRepository {
note: String, note: String,
category_id: Option<i64>, category_id: Option<i64>,
) -> DomainResult<i64> { ) -> DomainResult<i64> {
ensure_category(txn, list_id, category_id).await?; ensure_category(txn, category_id).await?;
let position: i64 = let position: i64 =
sqlx::query("SELECT COALESCE(MAX(position), -1) + 1 FROM items WHERE list_id = ?1") sqlx::query("SELECT COALESCE(MAX(position), -1) + 1 FROM items WHERE list_id = ?1")
.bind(list_id) .bind(list_id)
@@ -530,7 +551,7 @@ impl ItemRepository for SqliteItemRepository {
note: String, note: String,
category_id: Option<i64>, category_id: Option<i64>,
) -> DomainResult<i64> { ) -> DomainResult<i64> {
ensure_category(txn, list_id, category_id).await?; ensure_category(txn, category_id).await?;
let changed = sqlx::query( let changed = sqlx::query(
"UPDATE items "UPDATE items
SET name = ?1, quantity = ?2, note = ?3, category_id = ?4, SET name = ?1, quantity = ?2, note = ?3, category_id = ?4,
@@ -641,15 +662,13 @@ impl InvitationRepository for SqliteInvitationRepository {
async fn ensure_category( async fn ensure_category(
txn: &mut SqliteConnection, txn: &mut SqliteConnection,
list_id: i64,
category_id: Option<i64>, category_id: Option<i64>,
) -> DomainResult<()> { ) -> DomainResult<()> {
let Some(category_id) = category_id else { let Some(category_id) = category_id else {
return Ok(()); return Ok(());
}; };
let row = sqlx::query("SELECT 1 FROM categories WHERE id = ?1 AND list_id = ?2") let row = sqlx::query("SELECT 1 FROM categories WHERE id = ?1")
.bind(category_id) .bind(category_id)
.bind(list_id)
.fetch_optional(&mut *txn) .fetch_optional(&mut *txn)
.await .await
.map_err(db_error)?; .map_err(db_error)?;
@@ -782,11 +801,11 @@ mod tests {
.unwrap() .unwrap()
} }
async fn get_categories(db: &SqliteDatabase, list_id: i64) -> Vec<Category> { async fn get_categories(db: &SqliteDatabase) -> Vec<Category> {
let categories = SqliteCategoryRepository; let categories = SqliteCategoryRepository;
db.run(move |txn| { db.run(move |txn| {
let categories = categories.clone(); let categories = categories.clone();
Box::pin(async move { categories.categories(txn, list_id).await }) Box::pin(async move { categories.categories(txn).await })
}) })
.await .await
.unwrap() .unwrap()
@@ -985,12 +1004,15 @@ mod tests {
// ---- ListRepository ---- // ---- ListRepository ----
#[tokio::test] #[tokio::test]
async fn create_list_seeds_default_categories() { async fn create_list_does_not_seed_categories() {
let db = setup().await; let db = setup().await;
// Default categories are seeded globally at startup.
let before = get_categories(&db).await.len();
let list = create_list(&db, "Weekly shop").await; let list = create_list(&db, "Weekly shop").await;
assert!(list.id > 0); assert!(list.id > 0);
assert_eq!(list.revision, 0); assert_eq!(list.revision, 0);
assert_eq!(get_categories(&db, list.id).await.len(), 6); // Categories are global now; creating a list must not add any.
assert_eq!(get_categories(&db).await.len(), before);
} }
#[tokio::test] #[tokio::test]
@@ -1038,39 +1060,36 @@ mod tests {
// ---- CategoryRepository ---- // ---- CategoryRepository ----
#[tokio::test] #[tokio::test]
async fn create_category_adds_and_bumps_revision() { async fn create_category_is_global_and_returns_id() {
let db = setup().await; let db = setup().await;
let list = create_list(&db, "Weekly shop").await;
let categories = SqliteCategoryRepository; let categories = SqliteCategoryRepository;
let revision = db let id = db
.run(move |txn| { .run(move |txn| {
let categories = categories.clone(); let categories = categories.clone();
Box::pin(async move { Box::pin(async move {
categories categories
.create_category(txn, list.id, "Bakery".into()) .create_category(txn, "Bakery".into())
.await .await
}) })
}) })
.await .await
.unwrap(); .unwrap();
assert_eq!(revision, 1); assert!(id > 0);
let cats = get_categories(&db, list.id).await; let cats = get_categories(&db).await;
assert_eq!(cats.len(), 7);
assert!(cats.iter().any(|c| c.name == "Bakery")); assert!(cats.iter().any(|c| c.name == "Bakery"));
} }
#[tokio::test] #[tokio::test]
async fn create_duplicate_category_conflicts() { async fn create_duplicate_category_conflicts() {
let db = setup().await; let db = setup().await;
let list = create_list(&db, "Weekly shop").await;
let categories = SqliteCategoryRepository; let categories = SqliteCategoryRepository;
let result = db let result = db
.run(move |txn| { .run(move |txn| {
let categories = categories.clone(); let categories = categories.clone();
Box::pin(async move { Box::pin(async move {
categories categories
.create_category(txn, list.id, "Produce".into()) .create_category(txn, "Produce".into())
.await .await
}) })
}) })
@@ -1078,6 +1097,24 @@ mod tests {
assert!(matches!(result, Err(DomainError::Conflict))); assert!(matches!(result, Err(DomainError::Conflict)));
} }
#[tokio::test]
async fn category_by_name_resolves_globally() {
let db = setup().await;
let categories = SqliteCategoryRepository;
let found = db
.run(move |txn| {
let categories = categories.clone();
Box::pin(async move {
categories
.category_by_name(txn, "produce".into())
.await
})
})
.await
.unwrap();
assert_eq!(found.unwrap().name, "Produce");
}
// ---- ItemRepository ---- // ---- ItemRepository ----
#[tokio::test] #[tokio::test]
+4 -17
View File
@@ -175,7 +175,7 @@ pub fn list_page(
} }
aside class="side-column" { aside class="side-column" {
(presence_panel(presence, false)) (presence_panel(presence, false))
(categories_panel(list, categories, csrf_token, false)) (categories_panel(categories, csrf_token, false))
section class="panel tip-panel" { section class="panel tip-panel" {
span class="tip-label" { "TIP" } span class="tip-label" { "TIP" }
p { "Check items off as you go. Everyone viewing this list will see it instantly." } p { "Check items off as you go. Everyone viewing this list will see it instantly." }
@@ -395,7 +395,6 @@ fn category_options(categories: &[Category], selected: Option<i64>) -> Markup {
} }
pub fn categories_panel( pub fn categories_panel(
list: &GroceryList,
categories: &[Category], categories: &[Category],
csrf_token: &str, csrf_token: &str,
out_of_band: bool, out_of_band: bool,
@@ -407,10 +406,10 @@ pub fn categories_panel(
} }
p { "Organize items by aisle or shopping area." } p { "Organize items by aisle or shopping area." }
form form
hx-post=(format!("/lists/{}/categories", list.id)) hx-post="/categories"
hx-target="#category-result" hx-target="#category-result"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-on::after-request="if (event.detail.successful) this.reset()" hx-on::after-request="if (event.detail.successful) window.location.reload()"
class="category-form" class="category-form"
{ {
input type="hidden" name="csrf" value=(csrf_token); input type="hidden" name="csrf" value=(csrf_token);
@@ -448,19 +447,7 @@ pub fn live_list_fragments(
) -> Markup { ) -> Markup {
html! { html! {
(list_content_fragment(list, items, categories, csrf_token, true)) (list_content_fragment(list, items, categories, csrf_token, true))
(categories_panel(list, categories, csrf_token, true)) (categories_panel(categories, csrf_token, true))
}
}
pub fn category_created(
list: &GroceryList,
items: &[Item],
categories: &[Category],
csrf_token: &str,
) -> Markup {
html! {
p class="category-success" { "Category added." }
(live_list_fragments(list, items, categories, csrf_token))
} }
} }