mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-07-09 03:04:54 -04:00
fix: Offline Shopping List Fixes V2 - Electric Boogaloo (#3837)
Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com>
This commit is contained in:
parent
30b2776f3c
commit
9d58f9b266
@ -69,7 +69,7 @@
|
||||
</v-row>
|
||||
<v-row v-if="!listItem.checked && recipeList && recipeList.length && displayRecipeRefs" no-gutters class="mb-2">
|
||||
<v-col cols="auto" style="width: 100%;">
|
||||
<RecipeList :recipes="recipeList" :list-item="listItem" :disabled="isOffline" small tile />
|
||||
<RecipeList :recipes="recipeList" :list-item="listItem" :disabled="$nuxt.isOffline" small tile />
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="listItem.checked" no-gutters class="mb-2">
|
||||
@ -136,10 +136,6 @@ export default defineComponent({
|
||||
type: Map<string, RecipeSummary>,
|
||||
default: undefined,
|
||||
},
|
||||
isOffline: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props, context) {
|
||||
const { i18n } = useContext();
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { computed, ref } from "@nuxtjs/composition-api";
|
||||
import { computed, reactive, watch } from "@nuxtjs/composition-api";
|
||||
import { useLocalStorage } from "@vueuse/core";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { ShoppingListItemOut } from "~/lib/api/types/group";
|
||||
import { ShoppingListItemOut, ShoppingListOut } from "~/lib/api/types/group";
|
||||
import { RequestResponse } from "~/lib/api/types/non-generated";
|
||||
|
||||
const localStorageKey = "shopping-list-queue";
|
||||
const queueTimeout = 5 * 60 * 1000; // 5 minutes
|
||||
@ -23,27 +24,73 @@ interface Storage {
|
||||
export function useShoppingListItemActions(shoppingListId: string) {
|
||||
const api = useUserApi();
|
||||
const storage = useLocalStorage(localStorageKey, {} as Storage, { deep: true });
|
||||
const queue = storage.value[shoppingListId] ||= { create: [], update: [], delete: [], lastUpdate: Date.now()};
|
||||
const queue = reactive(getQueue());
|
||||
const queueEmpty = computed(() => !queue.create.length && !queue.update.length && !queue.delete.length);
|
||||
if (queueEmpty.value) {
|
||||
queue.lastUpdate = Date.now();
|
||||
}
|
||||
|
||||
const isOffline = ref(false);
|
||||
storage.value[shoppingListId] = { ...queue }
|
||||
watch(
|
||||
() => queue,
|
||||
(value) => {
|
||||
storage.value[shoppingListId] = { ...value }
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
immediate: true,
|
||||
},
|
||||
)
|
||||
|
||||
function removeFromQueue(queue: ShoppingListItemOut[], item: ShoppingListItemOut): boolean {
|
||||
const index = queue.findIndex(i => i.id === item.id);
|
||||
function isValidQueueObject(obj: any): obj is ShoppingListQueue {
|
||||
if (typeof obj !== "object" || obj === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasRequiredProps = "create" in obj && "update" in obj && "delete" in obj && "lastUpdate" in obj;
|
||||
if (!hasRequiredProps) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const arraysValid = Array.isArray(obj.create) && Array.isArray(obj.update) && Array.isArray(obj.delete);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
const lastUpdateValid = typeof obj.lastUpdate === "number" && !isNaN(new Date(obj.lastUpdate).getTime());
|
||||
|
||||
return arraysValid && lastUpdateValid;
|
||||
}
|
||||
|
||||
function createEmptyQueue(): ShoppingListQueue {
|
||||
const newQueue = { create: [], update: [], delete: [], lastUpdate: Date.now() };
|
||||
return newQueue;
|
||||
}
|
||||
|
||||
function getQueue(): ShoppingListQueue {
|
||||
try {
|
||||
const fetchedQueue = storage.value[shoppingListId];
|
||||
if (!isValidQueueObject(fetchedQueue)) {
|
||||
console.log("Invalid queue object in local storage; resetting queue.");
|
||||
return createEmptyQueue();
|
||||
} else {
|
||||
return fetchedQueue;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error validating queue object in local storage; resetting queue.", error);
|
||||
return createEmptyQueue();
|
||||
}
|
||||
}
|
||||
|
||||
function removeFromQueue(itemQueue: ShoppingListItemOut[], item: ShoppingListItemOut): boolean {
|
||||
const index = itemQueue.findIndex(i => i.id === item.id);
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
queue.splice(index, 1);
|
||||
itemQueue.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function getList() {
|
||||
const response = await api.shopping.lists.getOne(shoppingListId);
|
||||
handleResponse(response);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@ -90,44 +137,59 @@ export function useShoppingListItemActions(shoppingListId: string) {
|
||||
if (itemQueueType === "delete" || itemQueueType === "all") {
|
||||
queue.delete = itemIds ? queue.delete.filter(item => !itemIds.includes(item.id)) : [];
|
||||
}
|
||||
if (queueEmpty.value) {
|
||||
queue.lastUpdate = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
// Set the storage value explicitly so changes are saved in the browser.
|
||||
storage.value[shoppingListId] = { ...queue };
|
||||
function checkUpdateState(list: ShoppingListOut) {
|
||||
const cutoffDate = new Date(queue.lastUpdate + queueTimeout).toISOString();
|
||||
if (list.updateAt && list.updateAt > cutoffDate) {
|
||||
// If the queue is too far behind the shopping list to reliably do updates, we clear the queue
|
||||
console.log("Out of sync with server; clearing queue");
|
||||
clearQueueItems("all");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the response from the backend and sets the isOffline flag if necessary.
|
||||
* Processes the queue items and returns whether the processing was successful.
|
||||
*/
|
||||
function handleResponse(response: any) {
|
||||
// TODO: is there a better way of checking for network errors?
|
||||
isOffline.value = response.error?.message?.includes("Network Error") || false;
|
||||
}
|
||||
|
||||
async function processQueueItems(
|
||||
action: (items: ShoppingListItemOut[]) => Promise<any>,
|
||||
action: (items: ShoppingListItemOut[]) => Promise<RequestResponse<any>>,
|
||||
itemQueueType: ItemQueueType,
|
||||
) {
|
||||
const queueItems = getQueueItems(itemQueueType);
|
||||
if (!queueItems.length) {
|
||||
return;
|
||||
): Promise<boolean> {
|
||||
let queueItems: ShoppingListItemOut[];
|
||||
try {
|
||||
queueItems = getQueueItems(itemQueueType);
|
||||
if (!queueItems.length) {
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Error fetching queue items of type ${itemQueueType}:`, error);
|
||||
clearQueueItems(itemQueueType);
|
||||
return false;
|
||||
}
|
||||
|
||||
const itemsToProcess = [...queueItems];
|
||||
await action(itemsToProcess)
|
||||
.then((response) => {
|
||||
handleResponse(response);
|
||||
if (!isOffline.value) {
|
||||
clearQueueItems(itemQueueType, itemsToProcess.map(item => item.id));
|
||||
}
|
||||
});
|
||||
try {
|
||||
const itemsToProcess = [...queueItems];
|
||||
await action(itemsToProcess)
|
||||
.then(() => {
|
||||
if (window.$nuxt.isOnline) {
|
||||
clearQueueItems(itemQueueType, itemsToProcess.map(item => item.id));
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(`Error processing queue items of type ${itemQueueType}:`, error);
|
||||
clearQueueItems(itemQueueType);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function process() {
|
||||
if(
|
||||
!queue.create.length &&
|
||||
!queue.update.length &&
|
||||
!queue.delete.length
|
||||
) {
|
||||
if(queueEmpty.value) {
|
||||
queue.lastUpdate = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -135,26 +197,23 @@ export function useShoppingListItemActions(shoppingListId: string) {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
checkUpdateState(data);
|
||||
|
||||
const cutoffDate = new Date(queue.lastUpdate + queueTimeout).toISOString();
|
||||
if (data.updateAt && data.updateAt > cutoffDate) {
|
||||
// If the queue is too far behind the shopping list to reliably do updates, we clear the queue
|
||||
clearQueueItems("all");
|
||||
} else {
|
||||
// We send each bulk request one at a time, since the backend may merge items
|
||||
await processQueueItems((items) => api.shopping.items.deleteMany(items), "delete");
|
||||
await processQueueItems((items) => api.shopping.items.updateMany(items), "update");
|
||||
await processQueueItems((items) => api.shopping.items.createMany(items), "create");
|
||||
}
|
||||
// We send each bulk request one at a time, since the backend may merge items
|
||||
// "failures" here refers to an actual error, rather than failing to reach the backend
|
||||
let failures = 0;
|
||||
if (!(await processQueueItems((items) => api.shopping.items.deleteMany(items), "delete"))) failures++;
|
||||
if (!(await processQueueItems((items) => api.shopping.items.updateMany(items), "update"))) failures++;
|
||||
if (!(await processQueueItems((items) => api.shopping.items.createMany(items), "create"))) failures++;
|
||||
|
||||
// If we're online, the queue is fully processed, so we're up to date
|
||||
if (!isOffline.value) {
|
||||
// If we're online, or the queue is empty, the queue is fully processed, so we're up to date
|
||||
// Otherwise, if all three queue processes failed, we've already reset the queue, so we need to reset the date
|
||||
if (window.$nuxt.isOnline || queueEmpty.value || failures === 3) {
|
||||
queue.lastUpdate = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isOffline,
|
||||
getList,
|
||||
createItem,
|
||||
updateItem,
|
||||
|
@ -28,7 +28,7 @@
|
||||
<template #title> {{ shoppingList.name }} </template>
|
||||
</BasePageTitle>
|
||||
<BannerWarning
|
||||
v-if="isOffline"
|
||||
v-if="$nuxt.isOffline"
|
||||
:title="$tc('shopping-list.you-are-offline')"
|
||||
:description="$tc('shopping-list.you-are-offline-description')"
|
||||
/>
|
||||
@ -46,7 +46,6 @@
|
||||
:units="allUnits || []"
|
||||
:foods="allFoods || []"
|
||||
:recipes="recipeMap"
|
||||
:is-offline="isOffline"
|
||||
@checked="saveListItem"
|
||||
@save="saveListItem"
|
||||
@delete="deleteListItem(item)"
|
||||
@ -75,7 +74,6 @@
|
||||
:units="allUnits || []"
|
||||
:foods="allFoods || []"
|
||||
:recipes="recipeMap"
|
||||
:is-offline="isOffline"
|
||||
@checked="saveListItem"
|
||||
@save="saveListItem"
|
||||
@delete="deleteListItem(item)"
|
||||
@ -132,7 +130,6 @@
|
||||
:labels="allLabels || []"
|
||||
:units="allUnits || []"
|
||||
:foods="allFoods || []"
|
||||
:is-offline="isOffline"
|
||||
@delete="createEditorOpen = false"
|
||||
@cancel="createEditorOpen = false"
|
||||
@save="createListItem"
|
||||
@ -141,7 +138,7 @@
|
||||
<div v-else class="mt-4 d-flex justify-end">
|
||||
<BaseButton
|
||||
v-if="preferences.viewByLabel" edit class="mr-2"
|
||||
:disabled="isOffline"
|
||||
:disabled="$nuxt.isOffline"
|
||||
@click="toggleReorderLabelsDialog">
|
||||
<template #icon> {{ $globals.icons.tags }} </template>
|
||||
{{ $t('shopping-list.reorder-labels') }}
|
||||
@ -221,7 +218,6 @@
|
||||
:labels="allLabels || []"
|
||||
:units="allUnits || []"
|
||||
:foods="allFoods || []"
|
||||
:is-offline="isOffline"
|
||||
@checked="saveListItem"
|
||||
@save="saveListItem"
|
||||
@delete="deleteListItem(item)"
|
||||
@ -244,10 +240,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 :disabled="isOffline">
|
||||
<RecipeList :recipes="Array.from(recipeMap.values())" show-description :disabled="$nuxt.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 :disabled="isOffline" @click.prevent="removeRecipeReferenceToList(recipe.id)">
|
||||
<v-btn icon :disabled="$nuxt.isOffline" @click.prevent="removeRecipeReferenceToList(recipe.id)">
|
||||
<v-icon color="grey lighten-1">{{ $globals.icons.minus }}</v-icon>
|
||||
</v-btn>
|
||||
</v-list-item-action>
|
||||
@ -255,7 +251,7 @@
|
||||
{{ shoppingList.recipeReferences[index].recipeQuantity }}
|
||||
</div>
|
||||
<v-list-item-action :key="'item-actions-increase' + recipe.id">
|
||||
<v-btn icon :disabled="isOffline" @click.prevent="addRecipeReferenceToList(recipe.id)">
|
||||
<v-btn icon :disabled="$nuxt.isOffline" @click.prevent="addRecipeReferenceToList(recipe.id)">
|
||||
<v-icon color="grey lighten-1">{{ $globals.icons.createAlt }}</v-icon>
|
||||
</v-btn>
|
||||
</v-list-item-action>
|
||||
@ -268,7 +264,7 @@
|
||||
<div class="d-flex justify-end">
|
||||
<BaseButton
|
||||
edit
|
||||
:disabled="isOffline"
|
||||
:disabled="$nuxt.isOffline"
|
||||
@click="toggleSettingsDialog"
|
||||
>
|
||||
<template #icon> {{ $globals.icons.cog }} </template>
|
||||
@ -278,7 +274,7 @@
|
||||
</v-lazy>
|
||||
|
||||
<v-lazy>
|
||||
<div v-if="!isOffline" class="d-flex justify-end mt-10">
|
||||
<div v-if="$nuxt.isOnline" class="d-flex justify-end mt-10">
|
||||
<ButtonLink
|
||||
:to="`/group/data/labels`"
|
||||
:text="$tc('shopping-list.manage-labels')"
|
||||
@ -1072,7 +1068,6 @@ export default defineComponent({
|
||||
getLabelColor,
|
||||
groupSlug,
|
||||
itemsByLabel,
|
||||
isOffline: shoppingListItemActions.isOffline,
|
||||
listItems,
|
||||
loadingCounter,
|
||||
preferences,
|
||||
|
Loading…
x
Reference in New Issue
Block a user