1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-07-24 23:59:45 +02:00

feat: Offline Shopping List (#3760)

Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
This commit is contained in:
Michael Genson 2024-06-29 04:58:58 -05:00 committed by GitHub
parent 63a180ef2c
commit f4827abc1d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 347 additions and 82 deletions

View file

@ -27,6 +27,11 @@
</template>
<template #title> {{ shoppingList.name }} </template>
</BasePageTitle>
<BannerWarning
v-if="isOffline"
:title="$tc('shopping-list.you-are-offline')"
:description="$tc('shopping-list.you-are-offline-description')"
/>
<!-- Viewer -->
<section v-if="!edit" class="py-2">
@ -41,6 +46,7 @@
:units="allUnits || []"
:foods="allFoods || []"
:recipes="recipeMap"
:is-offline="isOffline"
@checked="saveListItem"
@save="saveListItem"
@delete="deleteListItem(item)"
@ -69,6 +75,7 @@
:units="allUnits || []"
:foods="allFoods || []"
:recipes="recipeMap"
:is-offline="isOffline"
@checked="saveListItem"
@save="saveListItem"
@delete="deleteListItem(item)"
@ -125,6 +132,7 @@
:labels="allLabels || []"
:units="allUnits || []"
:foods="allFoods || []"
:is-offline="isOffline"
@delete="createEditorOpen = false"
@cancel="createEditorOpen = false"
@save="createListItem"
@ -133,6 +141,7 @@
<div v-else class="mt-4 d-flex justify-end">
<BaseButton
v-if="preferences.viewByLabel" edit class="mr-2"
:disabled="isOffline"
@click="toggleReorderLabelsDialog">
<template #icon> {{ $globals.icons.tags }} </template>
{{ $t('shopping-list.reorder-labels') }}
@ -212,6 +221,7 @@
:labels="allLabels || []"
:units="allUnits || []"
:foods="allFoods || []"
:is-offline="isOffline"
@checked="saveListItem"
@save="saveListItem"
@delete="deleteListItem(item)"
@ -234,10 +244,10 @@
{{ $tc('shopping-list.linked-recipes-count', shoppingList.recipeReferences ? shoppingList.recipeReferences.length : 0) }}
</div>
<v-divider class="my-4"></v-divider>
<RecipeList :recipes="Array.from(recipeMap.values())" show-description>
<RecipeList :recipes="Array.from(recipeMap.values())" show-description :disabled="isOffline">
<template v-for="(recipe, index) in recipeMap.values()" #[`actions-${recipe.id}`]>
<v-list-item-action :key="'item-actions-decrease' + recipe.id">
<v-btn icon @click.prevent="removeRecipeReferenceToList(recipe.id)">
<v-btn icon :disabled="isOffline" @click.prevent="removeRecipeReferenceToList(recipe.id)">
<v-icon color="grey lighten-1">{{ $globals.icons.minus }}</v-icon>
</v-btn>
</v-list-item-action>
@ -245,7 +255,7 @@
{{ shoppingList.recipeReferences[index].recipeQuantity }}
</div>
<v-list-item-action :key="'item-actions-increase' + recipe.id">
<v-btn icon @click.prevent="addRecipeReferenceToList(recipe.id)">
<v-btn icon :disabled="isOffline" @click.prevent="addRecipeReferenceToList(recipe.id)">
<v-icon color="grey lighten-1">{{ $globals.icons.createAlt }}</v-icon>
</v-btn>
</v-list-item-action>
@ -256,7 +266,11 @@
<v-lazy>
<div class="d-flex justify-end">
<BaseButton edit @click="toggleSettingsDialog">
<BaseButton
edit
:disabled="isOffline"
@click="toggleSettingsDialog"
>
<template #icon> {{ $globals.icons.cog }} </template>
{{ $t('general.settings') }}
</BaseButton>
@ -264,8 +278,12 @@
</v-lazy>
<v-lazy>
<div class="d-flex justify-end mt-10">
<ButtonLink :to="`/group/data/labels`" :text="$tc('shopping-list.manage-labels')" :icon="$globals.icons.tags" />
<div v-if="!isOffline" class="d-flex justify-end mt-10">
<ButtonLink
:to="`/group/data/labels`"
:text="$tc('shopping-list.manage-labels')"
:icon="$globals.icons.tags"
/>
</div>
</v-lazy>
</v-container>
@ -280,12 +298,14 @@ import { useCopyList } from "~/composables/use-copy";
import { useUserApi } from "~/composables/api";
import MultiPurposeLabelSection from "~/components/Domain/ShoppingList/MultiPurposeLabelSection.vue"
import ShoppingListItem from "~/components/Domain/ShoppingList/ShoppingListItem.vue";
import { ShoppingListItemCreate, ShoppingListItemOut, ShoppingListMultiPurposeLabelOut, ShoppingListOut } from "~/lib/api/types/group";
import { ShoppingListItemOut, ShoppingListMultiPurposeLabelOut, ShoppingListOut } from "~/lib/api/types/group";
import { UserSummary } from "~/lib/api/types/user";
import RecipeList from "~/components/Domain/Recipe/RecipeList.vue";
import ShoppingListItemEditor from "~/components/Domain/ShoppingList/ShoppingListItemEditor.vue";
import { useFoodStore, useLabelStore, useUnitStore } from "~/composables/store";
import { useShoppingListItemActions } from "~/composables/use-shopping-list-item-actions";
import { useShoppingListPreferences } from "~/composables/use-users/preferences";
import { uuid4 } from "~/composables/use-utils";
type CopyTypes = "plain" | "markdown";
@ -320,6 +340,7 @@ export default defineComponent({
const route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
const id = route.value.params.id;
const shoppingListItemActions = useShoppingListItemActions(id);
const state = reactive({
checkAllDialog: false,
@ -332,13 +353,25 @@ export default defineComponent({
const shoppingList = ref<ShoppingListOut | null>(null);
async function fetchShoppingList() {
const { data } = await userApi.shopping.lists.getOne(id);
const data = await shoppingListItemActions.getList();
return data;
}
async function refresh() {
loadingCounter.value += 1;
const newListValue = await fetchShoppingList();
try {
await shoppingListItemActions.process();
} catch (error) {
console.error(error);
}
let newListValue = null
try {
newListValue = await fetchShoppingList();
} catch (error) {
console.error(error);
}
loadingCounter.value -= 1;
// only update the list with the new value if we're not loading, to prevent UI jitter
@ -346,18 +379,21 @@ export default defineComponent({
return;
}
shoppingList.value = newListValue;
// if we're not connected to the network, this will be null, so we don't want to clear the list
if (newListValue) {
shoppingList.value = newListValue;
}
updateListItemOrder();
}
function updateListItemOrder() {
if (!preserveItemOrder.value) {
groupAndSortListItemsByFood();
updateItemsByLabel();
} else {
sortListItems();
updateItemsByLabel();
}
updateItemsByLabel();
}
// constantly polls for changes
@ -391,11 +427,13 @@ export default defineComponent({
// start polling
loadingCounter.value -= 1;
const pollFrequency = 5000;
pollForChanges(); // populate initial list
// max poll time = pollFrequency * maxAttempts = 24 hours
// we use a long max poll time since polling stops when the user is idle anyway
const pollFrequency = 5000;
const maxAttempts = 17280;
let attempts = 0;
const maxAttempts = 3;
const pollTimer: ReturnType<typeof setInterval> = setInterval(() => { pollForChanges() }, pollFrequency);
onUnmounted(() => {
@ -655,6 +693,14 @@ export default defineComponent({
items: ShoppingListItemOut[];
}
function sortItems(a: ShoppingListItemOut | ListItemGroup, b: ShoppingListItemOut | ListItemGroup) {
return (
((a.position || 0) > (b.position || 0)) ||
((a.createdAt || "") < (b.createdAt || ""))
? 1 : -1
);
}
function groupAndSortListItemsByFood() {
if (!shoppingList.value?.listItems?.length) {
return;
@ -678,16 +724,14 @@ export default defineComponent({
}
});
// sort group items by position ascending, then createdAt descending
const listItemGroups = Array.from(listItemGroupsMap.values());
listItemGroups.sort((a, b) => (a.position > b.position || a.createdAt < b.createdAt ? 1 : -1));
listItemGroups.sort(sortItems);
// sort group items by position ascending, then createdAt descending, and aggregate them
// sort group items, then aggregate them
const sortedItems: ShoppingListItemOut[] = [];
let nextPosition = 0;
listItemGroups.forEach((listItemGroup) => {
// @ts-ignore none of these fields are undefined
listItemGroup.items.sort((a, b) => (a.position > b.position || a.createdAt < b.createdAt ? 1 : -1));
listItemGroup.items.sort(sortItems);
listItemGroup.items.forEach((item) => {
item.position = nextPosition;
nextPosition += 1;
@ -703,9 +747,7 @@ export default defineComponent({
return;
}
// sort by position ascending, then createdAt descending
// @ts-ignore none of these fields are undefined
shoppingList.value.listItems.sort((a, b) => (a.position > b.position || a.createdAt < b.createdAt ? 1 : -1))
shoppingList.value.listItems.sort(sortItems)
}
function updateItemsByLabel() {
@ -812,7 +854,7 @@ export default defineComponent({
* checked it will also append that item to the end of the list so that the unchecked items
* are at the top of the list.
*/
async function saveListItem(item: ShoppingListItemOut) {
function saveListItem(item: ShoppingListItemOut) {
if (!shoppingList.value) {
return;
}
@ -839,38 +881,34 @@ export default defineComponent({
}
updateListItemOrder();
loadingCounter.value += 1;
const { data } = await userApi.shopping.items.updateOne(item.id, item);
loadingCounter.value -= 1;
if (data) {
refresh();
}
shoppingListItemActions.updateItem(item);
refresh();
}
async function deleteListItem(item: ShoppingListItemOut) {
function deleteListItem(item: ShoppingListItemOut) {
if (!shoppingList.value) {
return;
}
loadingCounter.value += 1;
const { data } = await userApi.shopping.items.deleteOne(item.id);
loadingCounter.value -= 1;
shoppingListItemActions.deleteItem(item);
if (data) {
refresh();
// remove the item from the list immediately so the user sees the change
if (shoppingList.value.listItems) {
shoppingList.value.listItems = shoppingList.value.listItems.filter((itm) => itm.id !== item.id);
}
refresh();
}
// =====================================
// Create New Item
const createEditorOpen = ref(false);
const createListItemData = ref<ShoppingListItemCreate>(listItemFactory());
const createListItemData = ref<ShoppingListItemOut>(listItemFactory());
function listItemFactory(isFood = false): ShoppingListItemCreate {
function listItemFactory(isFood = false): ShoppingListItemOut {
return {
id: uuid4(),
shoppingListId: id,
checked: false,
position: shoppingList.value?.listItems?.length || 1,
@ -883,7 +921,7 @@ export default defineComponent({
};
}
async function createListItem() {
function createListItem() {
if (!shoppingList.value) {
return;
}
@ -899,13 +937,22 @@ export default defineComponent({
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);
createListItemData.value.createdAt = new Date().toISOString();
createListItemData.value.updateAt = createListItemData.value.createdAt;
updateListItemOrder();
shoppingListItemActions.createItem(createListItemData.value);
loadingCounter.value -= 1;
if (data) {
createListItemData.value = listItemFactory(createListItemData.value.isFood || false);
refresh();
}
if (shoppingList.value.listItems) {
// add the item to the list immediately so the user sees the change
shoppingList.value.listItems.push(createListItemData.value);
updateListItemOrder();
}
createListItemData.value = listItemFactory(createListItemData.value.isFood || false);
refresh();
}
function updateIndexUnchecked(uncheckedItems: ShoppingListItemOut[]) {
@ -941,21 +988,24 @@ export default defineComponent({
return updateIndexUnchecked(allUncheckedItems);
}
async function deleteListItems(items: ShoppingListItemOut[]) {
function deleteListItems(items: ShoppingListItemOut[]) {
if (!shoppingList.value) {
return;
}
loadingCounter.value += 1;
const { data } = await userApi.shopping.items.deleteMany(items);
loadingCounter.value -= 1;
if (data) {
refresh();
items.forEach((item) => {
shoppingListItemActions.deleteItem(item);
});
// remove the items from the list immediately so the user sees the change
if (shoppingList.value?.listItems) {
const deletedItems = new Set(items.map(item => item.id));
shoppingList.value.listItems = shoppingList.value.listItems.filter((itm) => !deletedItems.has(itm.id));
}
refresh();
}
async function updateListItems() {
function updateListItems() {
if (!shoppingList.value?.listItems) {
return;
}
@ -966,13 +1016,10 @@ export default defineComponent({
return itm;
});
loadingCounter.value += 1;
const { data } = await userApi.shopping.items.updateMany(shoppingList.value.listItems);
loadingCounter.value -= 1;
if (data) {
refresh();
}
shoppingList.value.listItems.forEach((item) => {
shoppingListItemActions.updateItem(item);
});
refresh();
}
// ===============================================================
@ -1026,6 +1073,7 @@ export default defineComponent({
getLabelColor,
groupSlug,
itemsByLabel,
isOffline: shoppingListItemActions.isOffline,
listItems,
loadingCounter,
preferences,