diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 860a8ad31d2c..07f191b03c54 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -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}}" diff --git a/frontend/components/Domain/Recipe/RecipeList.vue b/frontend/components/Domain/Recipe/RecipeList.vue index 4aa2958d2319..d8a36c15a858 100644 --- a/frontend/components/Domain/Recipe/RecipeList.vue +++ b/frontend/components/Domain/Recipe/RecipeList.vue @@ -7,7 +7,7 @@ :class="attrs.class.sheet" :style="tile ? 'max-width: 100%; width: fit-content;' : 'width: 100%;'" > - + {{ $globals.icons.primary }} @@ -56,6 +56,10 @@ export default defineComponent({ type: Boolean, default: false, }, + disabled: { + type: Boolean, + default: false, + } }, setup(props) { const { $auth } = useContext(); diff --git a/frontend/components/Domain/ShoppingList/ShoppingListItem.vue b/frontend/components/Domain/ShoppingList/ShoppingListItem.vue index 0565e24ffb6b..31b23e622d1a 100644 --- a/frontend/components/Domain/ShoppingList/ShoppingListItem.vue +++ b/frontend/components/Domain/ShoppingList/ShoppingListItem.vue @@ -69,7 +69,7 @@ - + @@ -135,7 +135,11 @@ export default defineComponent({ recipes: { type: Map, default: undefined, - } + }, + isOffline: { + type: Boolean, + default: false, + }, }, setup(props, context) { const { i18n } = useContext(); diff --git a/frontend/components/global/BannerExperimental.vue b/frontend/components/global/BannerExperimental.vue index 895e551f66d4..35254833fa2d 100644 --- a/frontend/components/global/BannerExperimental.vue +++ b/frontend/components/global/BannerExperimental.vue @@ -1,11 +1,12 @@ diff --git a/frontend/composables/use-shopping-list-item-actions.ts b/frontend/composables/use-shopping-list-item-actions.ts new file mode 100644 index 000000000000..f1ff15339c3a --- /dev/null +++ b/frontend/composables/use-shopping-list-item-actions.ts @@ -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, + 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, + }; +} diff --git a/frontend/lang/messages/en-US.json b/frontend/lang/messages/en-US.json index 226207109f19..761890a2beb9 100644 --- a/frontend/lang/messages/en-US.json +++ b/frontend/lang/messages/en-US.json @@ -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?" diff --git a/frontend/lib/api/user/group-shopping-lists.ts b/frontend/lib/api/user/group-shopping-lists.ts index a298b49c053b..f7584e7def0c 100644 --- a/frontend/lib/api/user/group-shopping-lists.ts +++ b/frontend/lib/api/user/group-shopping-lists.ts @@ -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); } diff --git a/frontend/pages/shopping-lists/_id.vue b/frontend/pages/shopping-lists/_id.vue index 5c04e4cead7b..8cea41faf0c9 100644 --- a/frontend/pages/shopping-lists/_id.vue +++ b/frontend/pages/shopping-lists/_id.vue @@ -27,6 +27,11 @@ +
@@ -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 @@
{{ $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) }}
- +