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

@ -49,7 +49,7 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v2 uses: github/codeql-action/init@v3
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # 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). # 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) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@v2 uses: github/codeql-action/autobuild@v3
# Command-line programs to run using the OS shell. # 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 # 📚 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 # ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2 uses: github/codeql-action/analyze@v3
with: with:
category: "/language:${{matrix.language}}" category: "/language:${{matrix.language}}"

View File

@ -7,7 +7,7 @@
:class="attrs.class.sheet" :class="attrs.class.sheet"
:style="tile ? 'max-width: 100%; width: fit-content;' : 'width: 100%;'" :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-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>
</v-list-item-avatar> </v-list-item-avatar>
@ -56,6 +56,10 @@ export default defineComponent({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
disabled: {
type: Boolean,
default: false,
}
}, },
setup(props) { setup(props) {
const { $auth } = useContext(); const { $auth } = useContext();

View File

@ -69,7 +69,7 @@
</v-row> </v-row>
<v-row v-if="!listItem.checked && recipeList && recipeList.length && displayRecipeRefs" no-gutters class="mb-2"> <v-row v-if="!listItem.checked && recipeList && recipeList.length && displayRecipeRefs" no-gutters class="mb-2">
<v-col cols="auto" style="width: 100%;"> <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-col>
</v-row> </v-row>
<v-row v-if="listItem.checked" no-gutters class="mb-2"> <v-row v-if="listItem.checked" no-gutters class="mb-2">
@ -135,7 +135,11 @@ export default defineComponent({
recipes: { recipes: {
type: Map<string, RecipeSummary>, type: Map<string, RecipeSummary>,
default: undefined, default: undefined,
} },
isOffline: {
type: Boolean,
default: false,
},
}, },
setup(props, context) { setup(props, context) {
const { i18n } = useContext(); const { i18n } = useContext();

View File

@ -1,11 +1,12 @@
<template> <template>
<v-alert border="left" colored-border type="warning" elevation="2" :icon="$globals.icons.alert"> <BannerWarning
<b>{{ $t("banner-experimental.title") }}</b> :title="$tc('banner-experimental.title')"
<div>{{ $t("banner-experimental.description") }}</div> :description="$tc('banner-experimental.description')"
<div v-if="issue != ''" class="py-2"> >
<template v-if="issue" #default>
<a :href="issue" target="_blank">{{ $t("banner-experimental.issue-link-text") }}</a> <a :href="issue" target="_blank">{{ $t("banner-experimental.issue-link-text") }}</a>
</div> </template>
</v-alert> </BannerWarning>
</template> </template>
<script lang="ts"> <script lang="ts">

View 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>

View 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,
};
}

View File

@ -810,6 +810,8 @@
"items-checked-count": "No items checked|One item checked|{count} items checked", "items-checked-count": "No items checked|One item checked|{count} items checked",
"no-label": "No Label", "no-label": "No Label",
"completed-on": "Completed on {date}", "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-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-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?" "are-you-sure-you-want-to-delete-checked-items": "Are you sure you want to delete all checked items?"

View File

@ -21,6 +21,7 @@ const routes = {
shoppingListIdUpdateLabelSettings: (id: string) => `${prefix}/groups/shopping/lists/${id}/label-settings`, shoppingListIdUpdateLabelSettings: (id: string) => `${prefix}/groups/shopping/lists/${id}/label-settings`,
shoppingListItems: `${prefix}/groups/shopping/items`, shoppingListItems: `${prefix}/groups/shopping/items`,
shoppingListItemsCreateBulk: `${prefix}/groups/shopping/items/create-bulk`,
shoppingListItemsId: (id: string) => `${prefix}/groups/shopping/items/${id}`, shoppingListItemsId: (id: string) => `${prefix}/groups/shopping/items/${id}`,
}; };
@ -49,6 +50,10 @@ export class ShoppingListItemsApi extends BaseCRUDAPI<
baseRoute = routes.shoppingListItems; baseRoute = routes.shoppingListItems;
itemRoute = routes.shoppingListItemsId; itemRoute = routes.shoppingListItemsId;
async createMany(items: ShoppingListItemCreate[]) {
return await this.requests.post(routes.shoppingListItemsCreateBulk, items);
}
async updateMany(items: ShoppingListItemOut[]) { async updateMany(items: ShoppingListItemOut[]) {
return await this.requests.put(routes.shoppingListItems, items); return await this.requests.put(routes.shoppingListItems, items);
} }

View File

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

View File

@ -6,6 +6,7 @@ import AppLoader from "@/components/global/AppLoader.vue";
import AppToolbar from "@/components/global/AppToolbar.vue"; import AppToolbar from "@/components/global/AppToolbar.vue";
import AutoForm from "@/components/global/AutoForm.vue"; import AutoForm from "@/components/global/AutoForm.vue";
import BannerExperimental from "@/components/global/BannerExperimental.vue"; import BannerExperimental from "@/components/global/BannerExperimental.vue";
import BannerWarning from "@/components/global/BannerWarning.vue";
import BaseButton from "@/components/global/BaseButton.vue"; import BaseButton from "@/components/global/BaseButton.vue";
import BaseButtonGroup from "@/components/global/BaseButtonGroup.vue"; import BaseButtonGroup from "@/components/global/BaseButtonGroup.vue";
import BaseCardSectionTitle from "@/components/global/BaseCardSectionTitle.vue"; import BaseCardSectionTitle from "@/components/global/BaseCardSectionTitle.vue";
@ -45,6 +46,7 @@ declare module "vue" {
AppToolbar: typeof AppToolbar; AppToolbar: typeof AppToolbar;
AutoForm: typeof AutoForm; AutoForm: typeof AutoForm;
BannerExperimental: typeof BannerExperimental; BannerExperimental: typeof BannerExperimental;
BannerWarning: typeof BannerWarning;
BaseButton: typeof BaseButton; BaseButton: typeof BaseButton;
BaseButtonGroup: typeof BaseButtonGroup; BaseButtonGroup: typeof BaseButtonGroup;
BaseCardSectionTitle: typeof BaseCardSectionTitle; BaseCardSectionTitle: typeof BaseCardSectionTitle;

View File

@ -15,10 +15,9 @@ from .._model_utils import GUID, auto_init
from ..recipe.ingredient import IngredientFoodModel, IngredientUnitModel from ..recipe.ingredient import IngredientFoodModel, IngredientUnitModel
if TYPE_CHECKING: if TYPE_CHECKING:
from group import Group
from users import User
from ..recipe import RecipeModel from ..recipe import RecipeModel
from ..users import User
from .group import Group
class ShoppingListItemRecipeReference(BaseMixins, SqlAlchemyBase): class ShoppingListItemRecipeReference(BaseMixins, SqlAlchemyBase):
@ -73,7 +72,7 @@ class ShoppingListItem(SqlAlchemyBase, BaseMixins):
recipe_references: Mapped[list[ShoppingListItemRecipeReference]] = orm.relationship( recipe_references: Mapped[list[ShoppingListItemRecipeReference]] = orm.relationship(
ShoppingListItemRecipeReference, cascade="all, delete, delete-orphan" ShoppingListItemRecipeReference, cascade="all, delete, delete-orphan"
) )
model_config = ConfigDict(exclude={"id", "label", "food", "unit"}) model_config = ConfigDict(exclude={"label", "food", "unit"})
@api_extras @api_extras
@auto_init() @auto_init()

View File

@ -136,11 +136,7 @@ class ShoppingListItemController(BaseCrudController):
def delete_many(self, ids: list[UUID4] = Query(None)): def delete_many(self, ids: list[UUID4] = Query(None)):
items = self.service.bulk_delete_items(ids) items = self.service.bulk_delete_items(ids)
publish_list_item_events(self.publish_event, items) publish_list_item_events(self.publish_event, items)
return SuccessResponse.respond()
message = (
f"Successfully deleted {len(items.deleted_items)} {'item' if len(items.deleted_items) == 1 else 'items'}"
)
return SuccessResponse.respond(message=message)
@item_router.delete("/{item_id}", response_model=SuccessResponse) @item_router.delete("/{item_id}", response_model=SuccessResponse)
def delete_one(self, item_id: UUID4): def delete_one(self, item_id: UUID4):

View File

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from uuid import UUID
from pydantic import UUID4, ConfigDict, field_validator, model_validator from pydantic import UUID4, ConfigDict, field_validator, model_validator
from sqlalchemy.orm import joinedload, selectinload from sqlalchemy.orm import joinedload, selectinload
@ -75,8 +76,21 @@ class ShoppingListItemBase(RecipeIngredientBase):
class ShoppingListItemCreate(ShoppingListItemBase): 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] = [] 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): class ShoppingListItemUpdate(ShoppingListItemBase):
recipe_references: list[ShoppingListItemRecipeRefCreate | ShoppingListItemRecipeRefUpdate] = [] recipe_references: list[ShoppingListItemRecipeRefCreate | ShoppingListItemRecipeRefUpdate] = []

View File

@ -22,7 +22,7 @@ class SuccessResponse(BaseModel):
error: bool = False error: bool = False
@classmethod @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 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 in the same call, for use while providing details to a HTTPException