mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-05-24 01:12:54 -04: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:
parent
63a180ef2c
commit
f4827abc1d
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
@ -49,7 +49,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@ -63,7 +63,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
@ -76,6 +76,6 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
@ -7,7 +7,7 @@
|
||||
:class="attrs.class.sheet"
|
||||
:style="tile ? 'max-width: 100%; width: fit-content;' : 'width: 100%;'"
|
||||
>
|
||||
<v-list-item :to="'/g/' + groupSlug + '/r/' + recipe.slug" :class="attrs.class.listItem">
|
||||
<v-list-item :to="disabled ? '' : '/g/' + groupSlug + '/r/' + recipe.slug" :class="attrs.class.listItem">
|
||||
<v-list-item-avatar :class="attrs.class.avatar">
|
||||
<v-icon :class="attrs.class.icon" dark :small="small"> {{ $globals.icons.primary }} </v-icon>
|
||||
</v-list-item-avatar>
|
||||
@ -56,6 +56,10 @@ export default defineComponent({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const { $auth } = useContext();
|
||||
|
@ -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" small tile />
|
||||
<RecipeList :recipes="recipeList" :list-item="listItem" :disabled="isOffline" small tile />
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="listItem.checked" no-gutters class="mb-2">
|
||||
@ -135,7 +135,11 @@ export default defineComponent({
|
||||
recipes: {
|
||||
type: Map<string, RecipeSummary>,
|
||||
default: undefined,
|
||||
}
|
||||
},
|
||||
isOffline: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props, context) {
|
||||
const { i18n } = useContext();
|
||||
|
@ -1,11 +1,12 @@
|
||||
<template>
|
||||
<v-alert border="left" colored-border type="warning" elevation="2" :icon="$globals.icons.alert">
|
||||
<b>{{ $t("banner-experimental.title") }}</b>
|
||||
<div>{{ $t("banner-experimental.description") }}</div>
|
||||
<div v-if="issue != ''" class="py-2">
|
||||
<BannerWarning
|
||||
:title="$tc('banner-experimental.title')"
|
||||
:description="$tc('banner-experimental.description')"
|
||||
>
|
||||
<template v-if="issue" #default>
|
||||
<a :href="issue" target="_blank">{{ $t("banner-experimental.issue-link-text") }}</a>
|
||||
</div>
|
||||
</v-alert>
|
||||
</template>
|
||||
</BannerWarning>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
|
26
frontend/components/global/BannerWarning.vue
Normal file
26
frontend/components/global/BannerWarning.vue
Normal file
@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<v-alert border="left" colored-border type="warning" elevation="2" :icon="$globals.icons.alert">
|
||||
<b v-if="title">{{ title }}</b>
|
||||
<div v-if="description">{{ description }}</div>
|
||||
<div v-if="$slots.default" class="py-2">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</v-alert>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "",
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
164
frontend/composables/use-shopping-list-item-actions.ts
Normal file
164
frontend/composables/use-shopping-list-item-actions.ts
Normal file
@ -0,0 +1,164 @@
|
||||
import { computed, ref } from "@nuxtjs/composition-api";
|
||||
import { useLocalStorage } from "@vueuse/core";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { ShoppingListItemOut } from "~/lib/api/types/group";
|
||||
|
||||
const localStorageKey = "shopping-list-queue";
|
||||
const queueTimeout = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
type ItemQueueType = "create" | "update" | "delete";
|
||||
|
||||
interface ShoppingListQueue {
|
||||
create: ShoppingListItemOut[];
|
||||
update: ShoppingListItemOut[];
|
||||
delete: ShoppingListItemOut[];
|
||||
|
||||
lastUpdate: number;
|
||||
}
|
||||
|
||||
interface Storage {
|
||||
[key: string]: ShoppingListQueue;
|
||||
}
|
||||
|
||||
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 queueEmpty = computed(() => !queue.create.length && !queue.update.length && !queue.delete.length);
|
||||
if (queueEmpty.value) {
|
||||
queue.lastUpdate = Date.now();
|
||||
}
|
||||
|
||||
const isOffline = ref(false);
|
||||
|
||||
function removeFromQueue(queue: ShoppingListItemOut[], item: ShoppingListItemOut): boolean {
|
||||
const index = queue.findIndex(i => i.id === item.id);
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
queue.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function getList() {
|
||||
const response = await api.shopping.lists.getOne(shoppingListId);
|
||||
handleResponse(response);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
function createItem(item: ShoppingListItemOut) {
|
||||
removeFromQueue(queue.create, item);
|
||||
queue.create.push(item);
|
||||
}
|
||||
|
||||
function updateItem(item: ShoppingListItemOut) {
|
||||
const removedFromCreate = removeFromQueue(queue.create, item);
|
||||
if (removedFromCreate) {
|
||||
// this item hasn't been created yet, so we don't need to update it
|
||||
queue.create.push(item);
|
||||
return;
|
||||
}
|
||||
|
||||
removeFromQueue(queue.update, item);
|
||||
queue.update.push(item);
|
||||
}
|
||||
|
||||
function deleteItem(item: ShoppingListItemOut) {
|
||||
const removedFromCreate = removeFromQueue(queue.create, item);
|
||||
if (removedFromCreate) {
|
||||
// this item hasn't been created yet, so we don't need to delete it
|
||||
return;
|
||||
}
|
||||
|
||||
removeFromQueue(queue.update, item);
|
||||
removeFromQueue(queue.delete, item);
|
||||
queue.delete.push(item);
|
||||
}
|
||||
|
||||
function getQueueItems(itemQueueType: ItemQueueType) {
|
||||
return queue[itemQueueType];
|
||||
}
|
||||
|
||||
function clearQueueItems(itemQueueType: ItemQueueType | "all", itemIds: string[] | null = null) {
|
||||
if (itemQueueType === "create" || itemQueueType === "all") {
|
||||
queue.create = itemIds ? queue.create.filter(item => !itemIds.includes(item.id)) : [];
|
||||
}
|
||||
if (itemQueueType === "update" || itemQueueType === "all") {
|
||||
queue.update = itemIds ? queue.update.filter(item => !itemIds.includes(item.id)) : [];
|
||||
}
|
||||
if (itemQueueType === "delete" || itemQueueType === "all") {
|
||||
queue.delete = itemIds ? queue.delete.filter(item => !itemIds.includes(item.id)) : [];
|
||||
}
|
||||
|
||||
// Set the storage value explicitly so changes are saved in the browser.
|
||||
storage.value[shoppingListId] = { ...queue };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the response from the backend and sets the isOffline flag if necessary.
|
||||
*/
|
||||
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>,
|
||||
itemQueueType: ItemQueueType,
|
||||
) {
|
||||
const queueItems = getQueueItems(itemQueueType);
|
||||
if (!queueItems.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const itemsToProcess = [...queueItems];
|
||||
await action(itemsToProcess)
|
||||
.then((response) => {
|
||||
handleResponse(response);
|
||||
if (!isOffline.value) {
|
||||
clearQueueItems(itemQueueType, itemsToProcess.map(item => item.id));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function process() {
|
||||
if(
|
||||
!queue.create.length &&
|
||||
!queue.update.length &&
|
||||
!queue.delete.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await getList();
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
// If we're online, the queue is fully processed, so we're up to date
|
||||
if (!isOffline.value) {
|
||||
queue.lastUpdate = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isOffline,
|
||||
getList,
|
||||
createItem,
|
||||
updateItem,
|
||||
deleteItem,
|
||||
process,
|
||||
};
|
||||
}
|
@ -810,6 +810,8 @@
|
||||
"items-checked-count": "No items checked|One item checked|{count} items checked",
|
||||
"no-label": "No Label",
|
||||
"completed-on": "Completed on {date}",
|
||||
"you-are-offline": "You are offline",
|
||||
"you-are-offline-description": "Not all features are available while offline. You can still add, modify, and remove items, but you will not be able to sync your changes to the server until you are back online.",
|
||||
"are-you-sure-you-want-to-check-all-items": "Are you sure you want to check all items?",
|
||||
"are-you-sure-you-want-to-uncheck-all-items": "Are you sure you want to uncheck all items?",
|
||||
"are-you-sure-you-want-to-delete-checked-items": "Are you sure you want to delete all checked items?"
|
||||
|
@ -21,6 +21,7 @@ const routes = {
|
||||
shoppingListIdUpdateLabelSettings: (id: string) => `${prefix}/groups/shopping/lists/${id}/label-settings`,
|
||||
|
||||
shoppingListItems: `${prefix}/groups/shopping/items`,
|
||||
shoppingListItemsCreateBulk: `${prefix}/groups/shopping/items/create-bulk`,
|
||||
shoppingListItemsId: (id: string) => `${prefix}/groups/shopping/items/${id}`,
|
||||
};
|
||||
|
||||
@ -49,6 +50,10 @@ export class ShoppingListItemsApi extends BaseCRUDAPI<
|
||||
baseRoute = routes.shoppingListItems;
|
||||
itemRoute = routes.shoppingListItemsId;
|
||||
|
||||
async createMany(items: ShoppingListItemCreate[]) {
|
||||
return await this.requests.post(routes.shoppingListItemsCreateBulk, items);
|
||||
}
|
||||
|
||||
async updateMany(items: ShoppingListItemOut[]) {
|
||||
return await this.requests.put(routes.shoppingListItems, items);
|
||||
}
|
||||
|
@ -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,
|
||||
|
2
frontend/types/components.d.ts
vendored
2
frontend/types/components.d.ts
vendored
@ -6,6 +6,7 @@ import AppLoader from "@/components/global/AppLoader.vue";
|
||||
import AppToolbar from "@/components/global/AppToolbar.vue";
|
||||
import AutoForm from "@/components/global/AutoForm.vue";
|
||||
import BannerExperimental from "@/components/global/BannerExperimental.vue";
|
||||
import BannerWarning from "@/components/global/BannerWarning.vue";
|
||||
import BaseButton from "@/components/global/BaseButton.vue";
|
||||
import BaseButtonGroup from "@/components/global/BaseButtonGroup.vue";
|
||||
import BaseCardSectionTitle from "@/components/global/BaseCardSectionTitle.vue";
|
||||
@ -45,6 +46,7 @@ declare module "vue" {
|
||||
AppToolbar: typeof AppToolbar;
|
||||
AutoForm: typeof AutoForm;
|
||||
BannerExperimental: typeof BannerExperimental;
|
||||
BannerWarning: typeof BannerWarning;
|
||||
BaseButton: typeof BaseButton;
|
||||
BaseButtonGroup: typeof BaseButtonGroup;
|
||||
BaseCardSectionTitle: typeof BaseCardSectionTitle;
|
||||
|
@ -15,10 +15,9 @@ from .._model_utils import GUID, auto_init
|
||||
from ..recipe.ingredient import IngredientFoodModel, IngredientUnitModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from group import Group
|
||||
from users import User
|
||||
|
||||
from ..recipe import RecipeModel
|
||||
from ..users import User
|
||||
from .group import Group
|
||||
|
||||
|
||||
class ShoppingListItemRecipeReference(BaseMixins, SqlAlchemyBase):
|
||||
@ -73,7 +72,7 @@ class ShoppingListItem(SqlAlchemyBase, BaseMixins):
|
||||
recipe_references: Mapped[list[ShoppingListItemRecipeReference]] = orm.relationship(
|
||||
ShoppingListItemRecipeReference, cascade="all, delete, delete-orphan"
|
||||
)
|
||||
model_config = ConfigDict(exclude={"id", "label", "food", "unit"})
|
||||
model_config = ConfigDict(exclude={"label", "food", "unit"})
|
||||
|
||||
@api_extras
|
||||
@auto_init()
|
||||
|
@ -136,11 +136,7 @@ class ShoppingListItemController(BaseCrudController):
|
||||
def delete_many(self, ids: list[UUID4] = Query(None)):
|
||||
items = self.service.bulk_delete_items(ids)
|
||||
publish_list_item_events(self.publish_event, items)
|
||||
|
||||
message = (
|
||||
f"Successfully deleted {len(items.deleted_items)} {'item' if len(items.deleted_items) == 1 else 'items'}"
|
||||
)
|
||||
return SuccessResponse.respond(message=message)
|
||||
return SuccessResponse.respond()
|
||||
|
||||
@item_router.delete("/{item_id}", response_model=SuccessResponse)
|
||||
def delete_one(self, item_id: UUID4):
|
||||
|
@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import UUID4, ConfigDict, field_validator, model_validator
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
@ -75,8 +76,21 @@ class ShoppingListItemBase(RecipeIngredientBase):
|
||||
|
||||
|
||||
class ShoppingListItemCreate(ShoppingListItemBase):
|
||||
id: UUID4 | None = None
|
||||
"""The unique id of the item to create. If not supplied, one will be generated."""
|
||||
recipe_references: list[ShoppingListItemRecipeRefCreate] = []
|
||||
|
||||
@field_validator("id", mode="before")
|
||||
def validate_id(cls, v):
|
||||
v = v or None
|
||||
if not v or isinstance(v, UUID):
|
||||
return v
|
||||
|
||||
try:
|
||||
return UUID(v)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
class ShoppingListItemUpdate(ShoppingListItemBase):
|
||||
recipe_references: list[ShoppingListItemRecipeRefCreate | ShoppingListItemRecipeRefUpdate] = []
|
||||
|
@ -22,7 +22,7 @@ class SuccessResponse(BaseModel):
|
||||
error: bool = False
|
||||
|
||||
@classmethod
|
||||
def respond(cls, message: str) -> dict:
|
||||
def respond(cls, message: str = "") -> dict:
|
||||
"""
|
||||
This method is an helper to create an object and convert to a dictionary
|
||||
in the same call, for use while providing details to a HTTPException
|
||||
|
Loading…
x
Reference in New Issue
Block a user