mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-07-31 14:34:42 -04:00
feat: more shopping list enhancements (#2587)
* fix new position calculataion
* ensure consistent list item ordering
* fix recipe ref overflow on small screens
* added recipe ref elevation
* tweaked line height (for long notes)
* removed unused user dependency
* remove old shopping list items when there's >100
* 🤷
* cleaned up function generator
* fixed test
* fix potential type error
* made max position calc more efficient
This commit is contained in:
parent
f35bc77a7d
commit
b153ddf858
@ -54,7 +54,7 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.note {
|
.note {
|
||||||
line-height: 0.8em;
|
line-height: 1.25em;
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-list :class="tile ? 'd-flex flex-wrap background' : 'background'">
|
<v-list :class="tile ? 'd-flex flex-wrap background' : 'background'">
|
||||||
<v-sheet v-for="recipe, index in recipes" :key="recipe.id" :class="attrs.class.sheet" :style="tile ? 'width: fit-content;' : 'width: 100%;'">
|
<v-sheet
|
||||||
|
v-for="recipe, index in recipes"
|
||||||
|
:key="recipe.id"
|
||||||
|
:elevation="2"
|
||||||
|
:class="attrs.class.sheet"
|
||||||
|
:style="tile ? 'max-width: 100%; width: fit-content;' : 'width: 100%;'"
|
||||||
|
>
|
||||||
<v-list-item :to="'/recipe/' + recipe.slug" :class="attrs.class.listItem">
|
<v-list-item :to="'/recipe/' + recipe.slug" :class="attrs.class.listItem">
|
||||||
<v-list-item-avatar :class="attrs.class.avatar">
|
<v-list-item-avatar :class="attrs.class.avatar">
|
||||||
<v-icon :class="attrs.class.icon" dark :small="small"> {{ $globals.icons.primary }} </v-icon>
|
<v-icon :class="attrs.class.icon" dark :small="small"> {{ $globals.icons.primary }} </v-icon>
|
||||||
|
@ -268,6 +268,7 @@ export default defineComponent({
|
|||||||
// only update the list with the new value if we're not loading, to prevent UI jitter
|
// only update the list with the new value if we're not loading, to prevent UI jitter
|
||||||
if (!loadingCounter.value) {
|
if (!loadingCounter.value) {
|
||||||
shoppingList.value = newListValue;
|
shoppingList.value = newListValue;
|
||||||
|
sortListItems();
|
||||||
updateItemsByLabel();
|
updateItemsByLabel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -473,6 +474,15 @@ export default defineComponent({
|
|||||||
|
|
||||||
const itemsByLabel = ref<{ [key: string]: ShoppingListItemOut[] }>({});
|
const itemsByLabel = ref<{ [key: string]: ShoppingListItemOut[] }>({});
|
||||||
|
|
||||||
|
function sortListItems() {
|
||||||
|
if (!shoppingList.value?.listItems?.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// sort by position ascending, then createdAt descending
|
||||||
|
shoppingList.value.listItems.sort((a, b) => (a.position > b.position || a.createdAt < b.createdAt ? 1 : -1))
|
||||||
|
}
|
||||||
|
|
||||||
function updateItemsByLabel() {
|
function updateItemsByLabel() {
|
||||||
const items: { [prop: string]: ShoppingListItemOut[] } = {};
|
const items: { [prop: string]: ShoppingListItemOut[] } = {};
|
||||||
const noLabelText = i18n.tc("shopping-list.no-label");
|
const noLabelText = i18n.tc("shopping-list.no-label");
|
||||||
@ -603,6 +613,7 @@ export default defineComponent({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sortListItems();
|
||||||
updateItemsByLabel();
|
updateItemsByLabel();
|
||||||
|
|
||||||
loadingCounter.value += 1;
|
loadingCounter.value += 1;
|
||||||
@ -656,7 +667,9 @@ export default defineComponent({
|
|||||||
loadingCounter.value += 1;
|
loadingCounter.value += 1;
|
||||||
|
|
||||||
// make sure it's inserted into the end of the list, which may have been updated
|
// make sure it's inserted into the end of the list, which may have been updated
|
||||||
createListItemData.value.position = shoppingList.value?.listItems?.length || 1;
|
createListItemData.value.position = shoppingList.value?.listItems?.length
|
||||||
|
? (shoppingList.value.listItems.reduce((a, b) => (a.position || 0) > (b.position || 0) ? a : b).position || 0) + 1
|
||||||
|
: 0;
|
||||||
const { data } = await userApi.shopping.items.createOne(createListItemData.value);
|
const { data } = await userApi.shopping.items.createOne(createListItemData.value);
|
||||||
loadingCounter.value -= 1;
|
loadingCounter.value -= 1;
|
||||||
|
|
||||||
|
@ -57,6 +57,7 @@ async def start_scheduler():
|
|||||||
tasks.purge_password_reset_tokens,
|
tasks.purge_password_reset_tokens,
|
||||||
tasks.purge_group_data_exports,
|
tasks.purge_group_data_exports,
|
||||||
tasks.create_mealplan_timeline_events,
|
tasks.create_mealplan_timeline_events,
|
||||||
|
tasks.delete_old_checked_list_items,
|
||||||
)
|
)
|
||||||
|
|
||||||
SchedulerRegistry.register_minutely(
|
SchedulerRegistry.register_minutely(
|
||||||
|
@ -89,7 +89,7 @@ def publish_list_item_events(publisher: Callable, items_collection: ShoppingList
|
|||||||
class ShoppingListItemController(BaseCrudController):
|
class ShoppingListItemController(BaseCrudController):
|
||||||
@cached_property
|
@cached_property
|
||||||
def service(self):
|
def service(self):
|
||||||
return ShoppingListService(self.repos, self.user, self.group)
|
return ShoppingListService(self.repos, self.group)
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def repo(self):
|
def repo(self):
|
||||||
@ -154,7 +154,7 @@ router = APIRouter(prefix="/groups/shopping/lists", tags=["Group: Shopping Lists
|
|||||||
class ShoppingListController(BaseCrudController):
|
class ShoppingListController(BaseCrudController):
|
||||||
@cached_property
|
@cached_property
|
||||||
def service(self):
|
def service(self):
|
||||||
return ShoppingListService(self.repos, self.user, self.group)
|
return ShoppingListService(self.repos, self.group)
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def repo(self):
|
def repo(self):
|
||||||
|
@ -19,13 +19,12 @@ from mealie.schema.group.group_shopping_list import (
|
|||||||
)
|
)
|
||||||
from mealie.schema.recipe.recipe_ingredient import IngredientFood, IngredientUnit, RecipeIngredient
|
from mealie.schema.recipe.recipe_ingredient import IngredientFood, IngredientUnit, RecipeIngredient
|
||||||
from mealie.schema.response.pagination import OrderDirection, PaginationQuery
|
from mealie.schema.response.pagination import OrderDirection, PaginationQuery
|
||||||
from mealie.schema.user.user import GroupInDB, PrivateUser
|
from mealie.schema.user.user import GroupInDB
|
||||||
|
|
||||||
|
|
||||||
class ShoppingListService:
|
class ShoppingListService:
|
||||||
def __init__(self, repos: AllRepositories, user: PrivateUser, group: GroupInDB):
|
def __init__(self, repos: AllRepositories, group: GroupInDB):
|
||||||
self.repos = repos
|
self.repos = repos
|
||||||
self.user = user
|
|
||||||
self.group = group
|
self.group = group
|
||||||
self.shopping_lists = repos.group_shopping_lists
|
self.shopping_lists = repos.group_shopping_lists
|
||||||
self.list_items = repos.group_shopping_list_item
|
self.list_items = repos.group_shopping_list_item
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
from .create_timeline_events import create_mealplan_timeline_events
|
from .create_timeline_events import create_mealplan_timeline_events
|
||||||
|
from .delete_old_checked_shopping_list_items import delete_old_checked_list_items
|
||||||
from .post_webhooks import post_group_webhooks
|
from .post_webhooks import post_group_webhooks
|
||||||
from .purge_group_exports import purge_group_data_exports
|
from .purge_group_exports import purge_group_data_exports
|
||||||
from .purge_password_reset import purge_password_reset_tokens
|
from .purge_password_reset import purge_password_reset_tokens
|
||||||
@ -7,6 +8,7 @@ from .reset_locked_users import locked_user_reset
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"create_mealplan_timeline_events",
|
"create_mealplan_timeline_events",
|
||||||
|
"delete_old_checked_list_items",
|
||||||
"post_group_webhooks",
|
"post_group_webhooks",
|
||||||
"purge_password_reset_tokens",
|
"purge_password_reset_tokens",
|
||||||
"purge_group_data_exports",
|
"purge_group_data_exports",
|
||||||
|
@ -0,0 +1,70 @@
|
|||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
from pydantic import UUID4
|
||||||
|
|
||||||
|
from mealie.db.db_setup import session_context
|
||||||
|
from mealie.repos.all_repositories import get_repositories
|
||||||
|
from mealie.routes.groups.controller_shopping_lists import publish_list_item_events
|
||||||
|
from mealie.schema.response.pagination import OrderDirection, PaginationQuery
|
||||||
|
from mealie.schema.user.user import DEFAULT_INTEGRATION_ID
|
||||||
|
from mealie.services.event_bus_service.event_bus_service import EventBusService
|
||||||
|
from mealie.services.event_bus_service.event_types import EventDocumentDataBase, EventTypes
|
||||||
|
from mealie.services.group_services.shopping_lists import ShoppingListService
|
||||||
|
|
||||||
|
MAX_CHECKED_ITEMS = 100
|
||||||
|
|
||||||
|
|
||||||
|
def _create_publish_event(event_bus_service: EventBusService, group_id: UUID4):
|
||||||
|
def publish_event(event_type: EventTypes, document_data: EventDocumentDataBase, message: str = ""):
|
||||||
|
event_bus_service.dispatch(
|
||||||
|
integration_id=DEFAULT_INTEGRATION_ID,
|
||||||
|
group_id=group_id,
|
||||||
|
event_type=event_type,
|
||||||
|
document_data=document_data,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
|
||||||
|
return publish_event
|
||||||
|
|
||||||
|
|
||||||
|
def _trim_list_items(shopping_list_service: ShoppingListService, shopping_list_id: UUID4, event_publisher: Callable):
|
||||||
|
pagination = PaginationQuery(
|
||||||
|
page=1,
|
||||||
|
per_page=-1,
|
||||||
|
query_filter=f'shopping_list_id="{shopping_list_id}" AND checked=true',
|
||||||
|
order_by="update_at",
|
||||||
|
order_direction=OrderDirection.desc,
|
||||||
|
)
|
||||||
|
query = shopping_list_service.list_items.page_all(pagination)
|
||||||
|
if len(query.items) <= MAX_CHECKED_ITEMS:
|
||||||
|
return
|
||||||
|
|
||||||
|
items_to_delete = query.items[MAX_CHECKED_ITEMS:]
|
||||||
|
items_response = shopping_list_service.bulk_delete_items([item.id for item in items_to_delete])
|
||||||
|
publish_list_item_events(event_publisher, items_response)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_old_checked_list_items(group_id: UUID4 | None = None):
|
||||||
|
with session_context() as session:
|
||||||
|
repos = get_repositories(session)
|
||||||
|
if group_id is None:
|
||||||
|
# if not specified, we check all groups
|
||||||
|
groups = repos.groups.page_all(PaginationQuery(page=1, per_page=-1)).items
|
||||||
|
|
||||||
|
else:
|
||||||
|
group = repos.groups.get_one(group_id)
|
||||||
|
if not group:
|
||||||
|
raise Exception(f'Group not found: "{group_id}"')
|
||||||
|
|
||||||
|
groups = [group]
|
||||||
|
|
||||||
|
for group in groups:
|
||||||
|
event_bus_service = EventBusService(session=session, group_id=group.id)
|
||||||
|
shopping_list_service = ShoppingListService(repos, group)
|
||||||
|
shopping_list_data = repos.group_shopping_lists.by_group(group.id).page_all(
|
||||||
|
PaginationQuery(page=1, per_page=-1)
|
||||||
|
)
|
||||||
|
for shopping_list in shopping_list_data.items:
|
||||||
|
_trim_list_items(
|
||||||
|
shopping_list_service, shopping_list.id, _create_publish_event(event_bus_service, group.id)
|
||||||
|
)
|
@ -0,0 +1,90 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from mealie.repos.repository_factory import AllRepositories
|
||||||
|
from mealie.schema.group.group_shopping_list import ShoppingListItemCreate, ShoppingListItemOut, ShoppingListSave
|
||||||
|
from mealie.services.scheduler.tasks.delete_old_checked_shopping_list_items import (
|
||||||
|
MAX_CHECKED_ITEMS,
|
||||||
|
delete_old_checked_list_items,
|
||||||
|
)
|
||||||
|
from tests.utils.factories import random_int, random_string
|
||||||
|
from tests.utils.fixture_schemas import TestUser
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup(database: AllRepositories, unique_user: TestUser):
|
||||||
|
list_repo = database.group_shopping_lists.by_group(unique_user.group_id)
|
||||||
|
list_item_repo = database.group_shopping_list_item
|
||||||
|
|
||||||
|
shopping_list = list_repo.create(ShoppingListSave(name=random_string(), group_id=unique_user.group_id))
|
||||||
|
unchecked_items = list_item_repo.create_many(
|
||||||
|
[
|
||||||
|
ShoppingListItemCreate(note=random_string(), shopping_list_id=shopping_list.id)
|
||||||
|
for _ in range(random_int(MAX_CHECKED_ITEMS + 10, MAX_CHECKED_ITEMS + 20))
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# create them one at a time so the update timestamps are different
|
||||||
|
checked_items: list[ShoppingListItemOut] = []
|
||||||
|
for _ in range(random_int(MAX_CHECKED_ITEMS + 10, MAX_CHECKED_ITEMS + 20)):
|
||||||
|
new_item = list_item_repo.create(
|
||||||
|
ShoppingListItemCreate(note=random_string(), shopping_list_id=shopping_list.id)
|
||||||
|
)
|
||||||
|
new_item.checked = True
|
||||||
|
checked_items.append(list_item_repo.update(new_item.id, new_item))
|
||||||
|
|
||||||
|
# make sure we see all items
|
||||||
|
shopping_list = list_repo.get_one(shopping_list.id) # type: ignore
|
||||||
|
assert shopping_list
|
||||||
|
assert len(shopping_list.list_items) == len(unchecked_items) + len(checked_items)
|
||||||
|
for item in unchecked_items + checked_items:
|
||||||
|
assert item in shopping_list.list_items
|
||||||
|
|
||||||
|
checked_items.sort(key=lambda x: x.update_at or datetime.now(), reverse=True)
|
||||||
|
expected_kept_items = unchecked_items + checked_items[:MAX_CHECKED_ITEMS]
|
||||||
|
expected_deleted_items = checked_items[MAX_CHECKED_ITEMS:]
|
||||||
|
|
||||||
|
# make sure we only see the expected items
|
||||||
|
delete_old_checked_list_items()
|
||||||
|
shopping_list = list_repo.get_one(shopping_list.id) # type: ignore
|
||||||
|
assert shopping_list
|
||||||
|
assert len(shopping_list.list_items) == len(expected_kept_items)
|
||||||
|
for item in expected_kept_items:
|
||||||
|
assert item in shopping_list.list_items
|
||||||
|
for item in expected_deleted_items:
|
||||||
|
assert item not in shopping_list.list_items
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_cleanup(database: AllRepositories, unique_user: TestUser):
|
||||||
|
list_repo = database.group_shopping_lists.by_group(unique_user.group_id)
|
||||||
|
list_item_repo = database.group_shopping_list_item
|
||||||
|
|
||||||
|
shopping_list = list_repo.create(ShoppingListSave(name=random_string(), group_id=unique_user.group_id))
|
||||||
|
unchecked_items = list_item_repo.create_many(
|
||||||
|
[
|
||||||
|
ShoppingListItemCreate(note=random_string(), shopping_list_id=shopping_list.id)
|
||||||
|
for _ in range(MAX_CHECKED_ITEMS)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# create them one at a time so the update timestamps are different
|
||||||
|
checked_items: list[ShoppingListItemOut] = []
|
||||||
|
for _ in range(MAX_CHECKED_ITEMS):
|
||||||
|
new_item = list_item_repo.create(
|
||||||
|
ShoppingListItemCreate(note=random_string(), shopping_list_id=shopping_list.id)
|
||||||
|
)
|
||||||
|
new_item.checked = True
|
||||||
|
checked_items.append(list_item_repo.update(new_item.id, new_item))
|
||||||
|
|
||||||
|
# make sure we see all items
|
||||||
|
shopping_list = list_repo.get_one(shopping_list.id) # type: ignore
|
||||||
|
assert shopping_list
|
||||||
|
assert len(shopping_list.list_items) == len(unchecked_items) + len(checked_items)
|
||||||
|
for item in unchecked_items + checked_items:
|
||||||
|
assert item in shopping_list.list_items
|
||||||
|
|
||||||
|
# make sure we still see all items
|
||||||
|
delete_old_checked_list_items()
|
||||||
|
shopping_list = list_repo.get_one(shopping_list.id) # type: ignore
|
||||||
|
assert shopping_list
|
||||||
|
assert len(shopping_list.list_items) == len(unchecked_items) + len(checked_items)
|
||||||
|
for item in unchecked_items + checked_items:
|
||||||
|
assert item in shopping_list.list_items
|
Loading…
x
Reference in New Issue
Block a user