meal eaten checkbox
This commit is contained in:
@@ -96,3 +96,31 @@ test("removing a meal from a list removes its ingredients", async ({ page }) =>
|
|||||||
await expect(page.locator(".item-row").filter({ hasText: "Tomato" })).toHaveCount(0);
|
await expect(page.locator(".item-row").filter({ hasText: "Tomato" })).toHaveCount(0);
|
||||||
await expect(row).toHaveCount(0);
|
await expect(row).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a meal can be checked off without removing it from the list", async ({ page }) => {
|
||||||
|
await registerAndLogin(page, "alice@example.com");
|
||||||
|
await createMealWithIngredients(page, "Spaghetti Bolognese", [{ name: "Penne" }]);
|
||||||
|
await createList(page, "Weekly shop");
|
||||||
|
|
||||||
|
await page.click(".add-meal-button");
|
||||||
|
const picker = page.locator(".meal-picker-backdrop");
|
||||||
|
await picker.locator(".meal-picker-button").filter({ hasText: "Spaghetti Bolognese" }).click();
|
||||||
|
|
||||||
|
const panel = page.locator("#list-meals-panel");
|
||||||
|
const row = panel.locator(".list-meal-row").filter({ hasText: "Spaghetti Bolognese" });
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await expect(row.locator(".list-meal-check-button")).not.toHaveClass(/is-.*checked/);
|
||||||
|
|
||||||
|
// Check the meal off as eaten.
|
||||||
|
await row.locator(".list-meal-check-button").click();
|
||||||
|
await expect(row).toHaveClass(/is-checked/);
|
||||||
|
await expect(row.locator(".list-meal-check-button")).toHaveText("✓");
|
||||||
|
// The meal is still on the list, not removed.
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await expect(page.locator(".item-row").filter({ hasText: "Penne" })).toBeVisible();
|
||||||
|
|
||||||
|
// Unchecking restores it.
|
||||||
|
await row.locator(".list-meal-check-button").click();
|
||||||
|
await expect(row).not.toHaveClass(/is-checked/);
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE list_meals ADD COLUMN checked INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -103,6 +103,8 @@ pub struct ListMeal {
|
|||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub meal_id: Option<i64>,
|
pub meal_id: Option<i64>,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
/// Whether the meal has been eaten (checked off) on this list.
|
||||||
|
pub checked: bool,
|
||||||
/// When the meal was added to the list.
|
/// When the meal was added to the list.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub created_at: i64,
|
pub created_at: i64,
|
||||||
|
|||||||
+25
@@ -133,6 +133,10 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
"/lists/{list_id}/meals/{list_meal_id}/remove",
|
"/lists/{list_id}/meals/{list_meal_id}/remove",
|
||||||
post(remove_meal_from_list),
|
post(remove_meal_from_list),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/lists/{list_id}/meals/{list_meal_id}/check",
|
||||||
|
post(check_list_meal),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/lists/{list_id}/carry",
|
"/lists/{list_id}/carry",
|
||||||
get(carry_over_modal).post(carry_meals),
|
get(carry_over_modal).post(carry_meals),
|
||||||
@@ -1177,6 +1181,27 @@ async fn remove_meal_from_list(
|
|||||||
list_fragment_response(&state, &user, list_id).await
|
list_fragment_response(&state, &user, list_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Marks a meal instance on a list as eaten (or not) without removing it.
|
||||||
|
async fn check_list_meal(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: CurrentUser,
|
||||||
|
Path((list_id, list_meal_id)): Path<(i64, i64)>,
|
||||||
|
LoggedForm(form): LoggedForm<CheckForm>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
verify_csrf(&user, &form.csrf)?;
|
||||||
|
require_mutable_list(&state, list_id).await?;
|
||||||
|
let checked = match form.checked.as_str() {
|
||||||
|
"1" | "true" => true,
|
||||||
|
"0" | "false" => false,
|
||||||
|
_ => return Err(AppError::BadRequest("Invalid checked value.".into())),
|
||||||
|
};
|
||||||
|
state
|
||||||
|
.meals
|
||||||
|
.set_list_meal_checked(list_id, list_meal_id, checked)
|
||||||
|
.await?;
|
||||||
|
list_fragment_response(&state, &user, list_id).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Renders the carry-over modal: a picker of active source lists (newest first,
|
/// Renders the carry-over modal: a picker of active source lists (newest first,
|
||||||
/// excluding the current list) that the user can import meals from.
|
/// excluding the current list) that the user can import meals from.
|
||||||
async fn carry_over_modal(
|
async fn carry_over_modal(
|
||||||
|
|||||||
@@ -195,6 +195,16 @@ pub trait ListMealRepository: Send + Sync {
|
|||||||
list_id: i64,
|
list_id: i64,
|
||||||
list_meal_id: i64,
|
list_meal_id: i64,
|
||||||
) -> DomainResult<i64>;
|
) -> DomainResult<i64>;
|
||||||
|
/// Marks a meal instance as eaten (or not) on a list, bumping the list's
|
||||||
|
/// revision exactly once. Like items, the meal stays in the list so it can
|
||||||
|
/// be toggled back.
|
||||||
|
async fn set_list_meal_checked(
|
||||||
|
&self,
|
||||||
|
txn: &mut SqliteConnection,
|
||||||
|
list_id: i64,
|
||||||
|
list_meal_id: i64,
|
||||||
|
checked: bool,
|
||||||
|
) -> DomainResult<i64>;
|
||||||
/// Copies the given meal instances from one list to another without
|
/// Copies the given meal instances from one list to another without
|
||||||
/// expanding their ingredients into items (they were already purchased).
|
/// expanding their ingredients into items (they were already purchased).
|
||||||
/// Returns the new rows and the destination list's bumped revision.
|
/// Returns the new rows and the destination list's bumped revision.
|
||||||
|
|||||||
@@ -582,6 +582,29 @@ impl MealService {
|
|||||||
Ok(revision)
|
Ok(revision)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Marks a meal instance on a list as eaten (or not), keeping it in the list
|
||||||
|
/// and bumping the list revision exactly once.
|
||||||
|
pub async fn set_list_meal_checked(
|
||||||
|
&self,
|
||||||
|
list_id: i64,
|
||||||
|
list_meal_id: i64,
|
||||||
|
checked: bool,
|
||||||
|
) -> DomainResult<i64> {
|
||||||
|
let list_meals = Arc::clone(&self.list_meals);
|
||||||
|
let revision = self
|
||||||
|
.db
|
||||||
|
.run(move |txn| {
|
||||||
|
Box::pin(async move {
|
||||||
|
list_meals
|
||||||
|
.set_list_meal_checked(txn, list_id, list_meal_id, checked)
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
self.realtime.publish_list_changed(list_id, revision).await;
|
||||||
|
Ok(revision)
|
||||||
|
}
|
||||||
|
|
||||||
/// Copies the given meal instances from a source list into the destination
|
/// Copies the given meal instances from a source list into the destination
|
||||||
/// list without re-expanding their ingredients into items (they were already
|
/// list without re-expanding their ingredients into items (they were already
|
||||||
/// purchased). The source list is left untouched. Publishes a realtime update
|
/// purchased). The source list is left untouched. Publishes a realtime update
|
||||||
|
|||||||
+103
-2
@@ -822,7 +822,7 @@ impl ListMealRepository for SqliteListMealRepository {
|
|||||||
list_id: i64,
|
list_id: i64,
|
||||||
) -> DomainResult<Vec<ListMeal>> {
|
) -> DomainResult<Vec<ListMeal>> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT id, meal_id, name, created_at
|
"SELECT id, meal_id, name, checked, created_at
|
||||||
FROM list_meals
|
FROM list_meals
|
||||||
WHERE list_id = ?1
|
WHERE list_id = ?1
|
||||||
ORDER BY created_at ASC, id ASC",
|
ORDER BY created_at ASC, id ASC",
|
||||||
@@ -837,7 +837,8 @@ impl ListMealRepository for SqliteListMealRepository {
|
|||||||
id: row.get(0),
|
id: row.get(0),
|
||||||
meal_id: row.get(1),
|
meal_id: row.get(1),
|
||||||
name: row.get(2),
|
name: row.get(2),
|
||||||
created_at: row.get(3),
|
checked: row.get::<i64, _>(3) != 0,
|
||||||
|
created_at: row.get(4),
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -887,6 +888,31 @@ impl ListMealRepository for SqliteListMealRepository {
|
|||||||
bump_revision(txn, list_id).await
|
bump_revision(txn, list_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn set_list_meal_checked(
|
||||||
|
&self,
|
||||||
|
txn: &mut SqliteConnection,
|
||||||
|
list_id: i64,
|
||||||
|
list_meal_id: i64,
|
||||||
|
checked: bool,
|
||||||
|
) -> DomainResult<i64> {
|
||||||
|
let changed = sqlx::query(
|
||||||
|
"UPDATE list_meals
|
||||||
|
SET checked = ?1
|
||||||
|
WHERE id = ?2 AND list_id = ?3",
|
||||||
|
)
|
||||||
|
.bind(checked as i64)
|
||||||
|
.bind(list_meal_id)
|
||||||
|
.bind(list_id)
|
||||||
|
.execute(&mut *txn)
|
||||||
|
.await
|
||||||
|
.map_err(db_error)?
|
||||||
|
.rows_affected();
|
||||||
|
if changed == 0 {
|
||||||
|
return Err(DomainError::NotFound);
|
||||||
|
}
|
||||||
|
bump_revision(txn, list_id).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn copy_meals_to_list(
|
async fn copy_meals_to_list(
|
||||||
&self,
|
&self,
|
||||||
txn: &mut SqliteConnection,
|
txn: &mut SqliteConnection,
|
||||||
@@ -915,6 +941,7 @@ impl ListMealRepository for SqliteListMealRepository {
|
|||||||
id,
|
id,
|
||||||
meal_id,
|
meal_id,
|
||||||
name,
|
name,
|
||||||
|
checked: false,
|
||||||
created_at: now(),
|
created_at: now(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2784,6 +2811,7 @@ mod tests {
|
|||||||
id,
|
id,
|
||||||
meal_id: Some(meal.id),
|
meal_id: Some(meal.id),
|
||||||
name: meal.name.clone(),
|
name: meal.name.clone(),
|
||||||
|
checked: false,
|
||||||
created_at: 0,
|
created_at: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2879,6 +2907,79 @@ mod tests {
|
|||||||
assert!(matches!(result, Err(DomainError::NotFound)));
|
assert!(matches!(result, Err(DomainError::NotFound)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_meal_checked_state_is_set_not_toggled_and_bumps_revision() {
|
||||||
|
let db = setup().await;
|
||||||
|
let list = create_list(&db, "Weekly shop").await;
|
||||||
|
let meal = create_meal(&db, "Pasta").await;
|
||||||
|
let list_meal = add_meal_to_list(&db, list.id, &meal).await;
|
||||||
|
|
||||||
|
let list_meals = SqliteListMealRepository;
|
||||||
|
let revision = db
|
||||||
|
.run(move |txn| {
|
||||||
|
let list_meals = list_meals;
|
||||||
|
Box::pin(async move {
|
||||||
|
list_meals
|
||||||
|
.set_list_meal_checked(txn, list.id, list_meal.id, true)
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(revision, 1);
|
||||||
|
|
||||||
|
// The meal is flagged as eaten but remains in the list.
|
||||||
|
let meals = db
|
||||||
|
.run(move |txn| {
|
||||||
|
let list_meals = list_meals;
|
||||||
|
Box::pin(async move { list_meals.list_meals(txn, list.id).await })
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(meals.len(), 1);
|
||||||
|
assert!(meals[0].checked);
|
||||||
|
|
||||||
|
// Unchecking sets the state back without removing the meal.
|
||||||
|
let revision = db
|
||||||
|
.run(move |txn| {
|
||||||
|
let list_meals = list_meals;
|
||||||
|
Box::pin(async move {
|
||||||
|
list_meals
|
||||||
|
.set_list_meal_checked(txn, list.id, list_meal.id, false)
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(revision, 2);
|
||||||
|
let meals = db
|
||||||
|
.run(move |txn| {
|
||||||
|
let list_meals = list_meals;
|
||||||
|
Box::pin(async move { list_meals.list_meals(txn, list.id).await })
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(!meals[0].checked);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checking_an_unknown_list_meal_fails() {
|
||||||
|
let db = setup().await;
|
||||||
|
let list = create_list(&db, "Weekly shop").await;
|
||||||
|
let list_meals = SqliteListMealRepository;
|
||||||
|
let result = db
|
||||||
|
.run(move |txn| {
|
||||||
|
let list_meals = list_meals;
|
||||||
|
Box::pin(async move {
|
||||||
|
list_meals
|
||||||
|
.set_list_meal_checked(txn, list.id, 9999, true)
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
assert!(matches!(result, Err(DomainError::NotFound)));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn copying_meals_to_another_list_does_not_add_items() {
|
async fn copying_meals_to_another_list_does_not_add_items() {
|
||||||
let db = setup().await;
|
let db = setup().await;
|
||||||
|
|||||||
+20
-1
@@ -1178,7 +1178,26 @@ pub fn list_meals_panel(
|
|||||||
} @else {
|
} @else {
|
||||||
div class="list-meals" {
|
div class="list-meals" {
|
||||||
@for meal in list_meals {
|
@for meal in list_meals {
|
||||||
div class="list-meal-row" {
|
div
|
||||||
|
class=(if meal.checked { "list-meal-row is-checked" } else { "list-meal-row" })
|
||||||
|
id=(format!("list-meal-{}", meal.id))
|
||||||
|
{
|
||||||
|
@if editable {
|
||||||
|
form
|
||||||
|
class="list-meal-check"
|
||||||
|
hx-post=(format!("/lists/{}/meals/{}/check", list_id, meal.id))
|
||||||
|
hx-target="#list-items"
|
||||||
|
hx-swap="morph:outerHTML"
|
||||||
|
{
|
||||||
|
input type="hidden" name="csrf" value=(csrf_token);
|
||||||
|
input type="hidden" name="checked" value=(if meal.checked { "0" } else { "1" });
|
||||||
|
button type="submit" class="check-button list-meal-check-button" aria-label=(if meal.checked { format!("Mark {} as not eaten", meal.name) } else { format!("Mark {} as eaten", meal.name) }) {
|
||||||
|
@if meal.checked { "✓" } @else { "" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} @else if meal.checked {
|
||||||
|
span class="check-button check-button-static list-meal-check-button" { "✓" }
|
||||||
|
}
|
||||||
span class="list-meal-icon" { "🍽" }
|
span class="list-meal-icon" { "🍽" }
|
||||||
span class="list-meal-name" { (meal.name) }
|
span class="list-meal-name" { (meal.name) }
|
||||||
@if editable {
|
@if editable {
|
||||||
|
|||||||
@@ -259,7 +259,11 @@ textarea:focus { border-color: var(--deep-sage); box-shadow: 0 0 0 4px rgba(85,
|
|||||||
.list-meals { display: grid; gap: 8px; }
|
.list-meals { display: grid; gap: 8px; }
|
||||||
.list-meal-row { display: flex; align-items: center; gap: 10px; padding: 6px 4px; border-bottom: 1px solid #edf0e6; }
|
.list-meal-row { display: flex; align-items: center; gap: 10px; padding: 6px 4px; border-bottom: 1px solid #edf0e6; }
|
||||||
.list-meal-row:last-child { border-bottom: 0; }
|
.list-meal-row:last-child { border-bottom: 0; }
|
||||||
|
.list-meal-check { margin: 0; flex: 0 0 auto; }
|
||||||
|
.list-meal-check .check-button { width: 26px; height: 26px; }
|
||||||
|
.is-checked .list-meal-name { color: var(--muted); text-decoration: line-through; }
|
||||||
.list-meal-icon { display: grid; place-items: center; flex: 0 0 auto; width: 30px; height: 30px; border-radius: 10px; color: var(--deep-sage); background: #eef4e9; font-size: .95rem; }
|
.list-meal-icon { display: grid; place-items: center; flex: 0 0 auto; width: 30px; height: 30px; border-radius: 10px; color: var(--deep-sage); background: #eef4e9; font-size: .95rem; }
|
||||||
|
.is-checked .list-meal-icon { filter: grayscale(.4); opacity: .7; }
|
||||||
.list-meal-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .9rem; font-weight: 700; }
|
.list-meal-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .9rem; font-weight: 700; }
|
||||||
.list-meal-remove { margin: 0; flex: 0 0 auto; }
|
.list-meal-remove { margin: 0; flex: 0 0 auto; }
|
||||||
.list-meal-remove-button { padding: 2px 7px; border: 0; border-radius: 7px; color: var(--muted); background: transparent; cursor: pointer; font-size: .8rem; line-height: 1; }
|
.list-meal-remove-button { padding: 2px 7px; border: 0; border-radius: 7px; color: var(--muted); background: transparent; cursor: pointer; font-size: .8rem; line-height: 1; }
|
||||||
|
|||||||
Reference in New Issue
Block a user