mirror of
https://github.com/hay-kot/mealie.git
synced 2025-08-22 06:23:34 -07:00
feat: Remove Not-Sort-By-Label and Refactor Shopping List Page (#5866)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
This commit is contained in:
parent
51ca65e3c3
commit
31530a68e1
13 changed files with 1030 additions and 932 deletions
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<v-list :class="tile ? 'd-flex flex-wrap background' : 'background'">
|
<v-list :class="tile ? 'd-flex flex-wrap background' : 'background'" style="background-color: transparent;">
|
||||||
<v-sheet
|
<v-sheet
|
||||||
v-for="recipe, index in recipes"
|
v-for="recipe, index in recipes"
|
||||||
:key="recipe.id"
|
:key="recipe.id"
|
||||||
|
|
|
@ -20,6 +20,7 @@
|
||||||
<template #activator="{ props }">
|
<template #activator="{ props }">
|
||||||
<v-btn
|
<v-btn
|
||||||
size="small"
|
size="small"
|
||||||
|
variant="text"
|
||||||
class="ml-2 handle"
|
class="ml-2 handle"
|
||||||
icon
|
icon
|
||||||
v-bind="props"
|
v-bind="props"
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
v-model="listItem.checked"
|
v-model="listItem.checked"
|
||||||
hide-details
|
hide-details
|
||||||
density="compact"
|
density="compact"
|
||||||
class="mt-0"
|
class="mt-0 flex-shrink-0"
|
||||||
color="null"
|
color="null"
|
||||||
@change="$emit('checked', listItem)"
|
@change="$emit('checked', listItem)"
|
||||||
/>
|
/>
|
||||||
|
@ -27,16 +27,6 @@
|
||||||
</div>
|
</div>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-col
|
|
||||||
v-if="label && showLabel"
|
|
||||||
cols="3"
|
|
||||||
class="text-right"
|
|
||||||
>
|
|
||||||
<MultiPurposeLabel
|
|
||||||
:label="label"
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
<v-col
|
<v-col
|
||||||
cols="auto"
|
cols="auto"
|
||||||
class="text-right"
|
class="text-right"
|
||||||
|
@ -75,27 +65,6 @@
|
||||||
</template>
|
</template>
|
||||||
<span>Toggle Recipes</span>
|
<span>Toggle Recipes</span>
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
<!-- Dummy button so the spacing is consistent when labels are enabled -->
|
|
||||||
<v-btn
|
|
||||||
v-else
|
|
||||||
size="small"
|
|
||||||
variant="text"
|
|
||||||
class="ml-2"
|
|
||||||
icon
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
|
|
||||||
<v-btn
|
|
||||||
size="small"
|
|
||||||
variant="text"
|
|
||||||
class="ml-2 handle"
|
|
||||||
icon
|
|
||||||
v-bind="props"
|
|
||||||
>
|
|
||||||
<v-icon>
|
|
||||||
{{ $globals.icons.arrowUpDown }}
|
|
||||||
</v-icon>
|
|
||||||
</v-btn>
|
|
||||||
<v-btn
|
<v-btn
|
||||||
size="small"
|
size="small"
|
||||||
variant="text"
|
variant="text"
|
||||||
|
@ -107,6 +76,17 @@
|
||||||
{{ $globals.icons.edit }}
|
{{ $globals.icons.edit }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
class="handle"
|
||||||
|
icon
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<v-icon>
|
||||||
|
{{ $globals.icons.arrowUpDown }}
|
||||||
|
</v-icon>
|
||||||
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
<v-list density="compact">
|
<v-list density="compact">
|
||||||
<v-list-item
|
<v-list-item
|
||||||
|
@ -177,7 +157,6 @@
|
||||||
import { useOnline } from "@vueuse/core";
|
import { useOnline } from "@vueuse/core";
|
||||||
import RecipeIngredientListItem from "../Recipe/RecipeIngredientListItem.vue";
|
import RecipeIngredientListItem from "../Recipe/RecipeIngredientListItem.vue";
|
||||||
import ShoppingListItemEditor from "./ShoppingListItemEditor.vue";
|
import ShoppingListItemEditor from "./ShoppingListItemEditor.vue";
|
||||||
import MultiPurposeLabel from "./MultiPurposeLabel.vue";
|
|
||||||
import type { ShoppingListItemOut } from "~/lib/api/types/household";
|
import type { ShoppingListItemOut } from "~/lib/api/types/household";
|
||||||
import type { MultiPurposeLabelOut, MultiPurposeLabelSummary } from "~/lib/api/types/labels";
|
import type { MultiPurposeLabelOut, MultiPurposeLabelSummary } from "~/lib/api/types/labels";
|
||||||
import type { IngredientFood, IngredientUnit, RecipeSummary } from "~/lib/api/types/recipe";
|
import type { IngredientFood, IngredientUnit, RecipeSummary } from "~/lib/api/types/recipe";
|
||||||
|
@ -189,16 +168,12 @@ interface actions {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
export default defineNuxtComponent({
|
||||||
components: { ShoppingListItemEditor, MultiPurposeLabel, RecipeList, RecipeIngredientListItem },
|
components: { ShoppingListItemEditor, RecipeList, RecipeIngredientListItem },
|
||||||
props: {
|
props: {
|
||||||
modelValue: {
|
modelValue: {
|
||||||
type: Object as () => ShoppingListItemOut,
|
type: Object as () => ShoppingListItemOut,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
showLabel: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
labels: {
|
labels: {
|
||||||
type: Array as () => MultiPurposeLabelOut[],
|
type: Array as () => MultiPurposeLabelOut[],
|
||||||
required: true,
|
required: true,
|
||||||
|
@ -220,7 +195,7 @@ export default defineNuxtComponent({
|
||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const displayRecipeRefs = ref(false);
|
const displayRecipeRefs = ref(false);
|
||||||
const itemLabelCols = ref<string>(props.modelValue.checked ? "auto" : props.showLabel ? "4" : "6");
|
const itemLabelCols = ref<string>(props.modelValue.checked ? "auto" : "6");
|
||||||
const isOffline = computed(() => useOnline().value === false);
|
const isOffline = computed(() => useOnline().value === false);
|
||||||
|
|
||||||
const contextMenu: actions[] = [
|
const contextMenu: actions[] = [
|
||||||
|
@ -305,7 +280,7 @@ export default defineNuxtComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
listItem.value.recipeReferences.forEach((ref) => {
|
listItem.value.recipeReferences.forEach((ref) => {
|
||||||
const recipe = props.recipes.get(ref.recipeId);
|
const recipe = props.recipes?.get(ref.recipeId);
|
||||||
if (recipe) {
|
if (recipe) {
|
||||||
recipeList.push(recipe);
|
recipeList.push(recipe);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,50 @@
|
||||||
|
import type { ShoppingListItemOut } from "~/lib/api/types/household";
|
||||||
|
import { useCopyList } from "~/composables/use-copy";
|
||||||
|
|
||||||
|
type CopyTypes = "plain" | "markdown";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for managing shopping list copy functionality
|
||||||
|
*/
|
||||||
|
export function useShoppingListCopy() {
|
||||||
|
const copy = useCopyList();
|
||||||
|
|
||||||
|
function copyListItems(itemsByLabel: { [key: string]: ShoppingListItemOut[] }, copyType: CopyTypes) {
|
||||||
|
const text: string[] = [];
|
||||||
|
Object.entries(itemsByLabel).forEach(([label, items], idx) => {
|
||||||
|
if (idx) {
|
||||||
|
text.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
text.push(formatCopiedLabelHeading(copyType, label));
|
||||||
|
items.forEach(item => text.push(formatCopiedListItem(copyType, item)));
|
||||||
|
});
|
||||||
|
|
||||||
|
copy.copyPlain(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCopiedListItem(copyType: CopyTypes, item: ShoppingListItemOut): string {
|
||||||
|
const display = item.display || "";
|
||||||
|
switch (copyType) {
|
||||||
|
case "markdown":
|
||||||
|
return `- [ ] ${display}`;
|
||||||
|
default:
|
||||||
|
return display;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCopiedLabelHeading(copyType: CopyTypes, label: string): string {
|
||||||
|
switch (copyType) {
|
||||||
|
case "markdown":
|
||||||
|
return `# ${label}`;
|
||||||
|
default:
|
||||||
|
return `[${label}]`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
copyListItems,
|
||||||
|
formatCopiedListItem,
|
||||||
|
formatCopiedLabelHeading,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,263 @@
|
||||||
|
import type { ShoppingListOut, ShoppingListItemOut, ShoppingListMultiPurposeLabelOut } from "~/lib/api/types/household";
|
||||||
|
import { useUserApi } from "~/composables/api";
|
||||||
|
import { uuid4 } from "~/composables/use-utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for managing shopping list item CRUD operations
|
||||||
|
*/
|
||||||
|
export function useShoppingListCrud(
|
||||||
|
shoppingList: Ref<ShoppingListOut | null>,
|
||||||
|
loadingCounter: Ref<number>,
|
||||||
|
listItems: { unchecked: ShoppingListItemOut[]; checked: ShoppingListItemOut[] },
|
||||||
|
shoppingListItemActions: any,
|
||||||
|
refresh: () => void,
|
||||||
|
sortCheckedItems: (a: ShoppingListItemOut, b: ShoppingListItemOut) => number,
|
||||||
|
updateListItemOrder: () => void,
|
||||||
|
) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const userApi = useUserApi();
|
||||||
|
|
||||||
|
const createListItemData = ref<ShoppingListItemOut>(listItemFactory());
|
||||||
|
const localLabels = ref<ShoppingListMultiPurposeLabelOut[]>();
|
||||||
|
|
||||||
|
function listItemFactory(): ShoppingListItemOut {
|
||||||
|
return {
|
||||||
|
id: uuid4(),
|
||||||
|
shoppingListId: shoppingList.value?.id || "",
|
||||||
|
checked: false,
|
||||||
|
position: shoppingList.value?.listItems?.length || 1,
|
||||||
|
quantity: 0,
|
||||||
|
note: "",
|
||||||
|
labelId: undefined,
|
||||||
|
unitId: undefined,
|
||||||
|
foodId: undefined,
|
||||||
|
} as ShoppingListItemOut;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check/Uncheck All operations
|
||||||
|
function checkAllItems() {
|
||||||
|
let hasChanged = false;
|
||||||
|
shoppingList.value?.listItems?.forEach((item) => {
|
||||||
|
if (!item.checked) {
|
||||||
|
hasChanged = true;
|
||||||
|
item.checked = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (hasChanged) {
|
||||||
|
updateUncheckedListItems();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function uncheckAllItems() {
|
||||||
|
let hasChanged = false;
|
||||||
|
shoppingList.value?.listItems?.forEach((item) => {
|
||||||
|
if (item.checked) {
|
||||||
|
hasChanged = true;
|
||||||
|
item.checked = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (hasChanged) {
|
||||||
|
listItems.unchecked = [...listItems.unchecked, ...listItems.checked];
|
||||||
|
listItems.checked = [];
|
||||||
|
updateUncheckedListItems();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteCheckedItems() {
|
||||||
|
const checked = shoppingList.value?.listItems?.filter(item => item.checked);
|
||||||
|
|
||||||
|
if (!checked || checked?.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingCounter.value += 1;
|
||||||
|
deleteListItems(checked);
|
||||||
|
loadingCounter.value -= 1;
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveListItem(item: ShoppingListItemOut) {
|
||||||
|
if (!shoppingList.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// set a temporary updatedAt timestamp prior to refresh so it appears at the top of the checked items
|
||||||
|
item.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
// make updates reflect immediately
|
||||||
|
if (shoppingList.value.listItems) {
|
||||||
|
shoppingList.value.listItems.forEach((oldListItem: ShoppingListItemOut, idx: number) => {
|
||||||
|
if (oldListItem.id === item.id && shoppingList.value?.listItems) {
|
||||||
|
shoppingList.value.listItems[idx] = item;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Immediately update checked/unchecked arrays for UI
|
||||||
|
listItems.unchecked = shoppingList.value.listItems.filter(i => !i.checked);
|
||||||
|
listItems.checked = shoppingList.value.listItems.filter(i => i.checked)
|
||||||
|
.sort(sortCheckedItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the item if it's checked, otherwise updateUncheckedListItems will handle it
|
||||||
|
if (item.checked) {
|
||||||
|
shoppingListItemActions.updateItem(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateListItemOrder();
|
||||||
|
updateUncheckedListItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteListItem(item: ShoppingListItemOut) {
|
||||||
|
if (!shoppingList.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
shoppingListItemActions.deleteItem(item);
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteListItems(items: ShoppingListItemOut[]) {
|
||||||
|
if (!shoppingList.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createListItem() {
|
||||||
|
if (!shoppingList.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!createListItemData.value.foodId && !createListItemData.value.note) {
|
||||||
|
// don't create an empty item
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingCounter.value += 1;
|
||||||
|
|
||||||
|
// make sure it's inserted into the end of the list, which may have been updated
|
||||||
|
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;
|
||||||
|
|
||||||
|
createListItemData.value.createdAt = new Date().toISOString();
|
||||||
|
createListItemData.value.updatedAt = createListItemData.value.createdAt;
|
||||||
|
|
||||||
|
updateListItemOrder();
|
||||||
|
|
||||||
|
shoppingListItemActions.createItem(createListItemData.value);
|
||||||
|
loadingCounter.value -= 1;
|
||||||
|
|
||||||
|
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();
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUncheckedListItems() {
|
||||||
|
if (!shoppingList.value?.listItems) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set position for unchecked items
|
||||||
|
listItems.unchecked.forEach((item: ShoppingListItemOut, idx: number) => {
|
||||||
|
item.position = idx;
|
||||||
|
shoppingListItemActions.updateItem(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label management
|
||||||
|
function updateLabelOrder(labelSettings: ShoppingListMultiPurposeLabelOut[]) {
|
||||||
|
if (!shoppingList.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
labelSettings.forEach((labelSetting, index) => {
|
||||||
|
labelSetting.position = index;
|
||||||
|
return labelSetting;
|
||||||
|
});
|
||||||
|
|
||||||
|
localLabels.value = labelSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelLabelOrder() {
|
||||||
|
loadingCounter.value -= 1;
|
||||||
|
if (!shoppingList.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// restore original state
|
||||||
|
localLabels.value = shoppingList.value.labelSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLabelOrder(updateItemsByLabel: () => void) {
|
||||||
|
if (!shoppingList.value || !localLabels.value || (localLabels.value === shoppingList.value.labelSettings)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingCounter.value += 1;
|
||||||
|
const { data } = await userApi.shopping.lists.updateLabelSettings(shoppingList.value.id, localLabels.value);
|
||||||
|
loadingCounter.value -= 1;
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
// update shoppingList labels using the API response
|
||||||
|
shoppingList.value.labelSettings = (data as ShoppingListOut).labelSettings;
|
||||||
|
updateItemsByLabel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleReorderLabelsDialog(reorderLabelsDialog: Ref<boolean>) {
|
||||||
|
// stop polling and populate localLabels
|
||||||
|
loadingCounter.value += 1;
|
||||||
|
reorderLabelsDialog.value = !reorderLabelsDialog.value;
|
||||||
|
localLabels.value = shoppingList.value?.labelSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context menu actions
|
||||||
|
const contextActions = {
|
||||||
|
delete: "delete",
|
||||||
|
};
|
||||||
|
|
||||||
|
const contextMenu = [
|
||||||
|
{ title: t("general.delete"), action: contextActions.delete },
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
createListItemData,
|
||||||
|
localLabels,
|
||||||
|
listItemFactory,
|
||||||
|
checkAllItems,
|
||||||
|
uncheckAllItems,
|
||||||
|
deleteCheckedItems,
|
||||||
|
saveListItem,
|
||||||
|
deleteListItem,
|
||||||
|
deleteListItems,
|
||||||
|
createListItem,
|
||||||
|
updateUncheckedListItems,
|
||||||
|
updateLabelOrder,
|
||||||
|
cancelLabelOrder,
|
||||||
|
saveLabelOrder,
|
||||||
|
toggleReorderLabelsDialog,
|
||||||
|
contextActions,
|
||||||
|
contextMenu,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,117 @@
|
||||||
|
import { useOnline, useIdle } from "@vueuse/core";
|
||||||
|
import type { ShoppingListOut } from "~/lib/api/types/household";
|
||||||
|
import { useShoppingListItemActions } from "~/composables/use-shopping-list-item-actions";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for managing shopping list data fetching and polling
|
||||||
|
*/
|
||||||
|
export function useShoppingListData(listId: string, shoppingList: Ref<ShoppingListOut | null>, loadingCounter: Ref<number>) {
|
||||||
|
const isOffline = computed(() => useOnline().value === false);
|
||||||
|
const { idle } = useIdle(5 * 60 * 1000); // 5 minutes
|
||||||
|
const shoppingListItemActions = useShoppingListItemActions(listId);
|
||||||
|
|
||||||
|
async function fetchShoppingList() {
|
||||||
|
const data = await shoppingListItemActions.getList();
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh(updateListItemOrder: () => void) {
|
||||||
|
loadingCounter.value += 1;
|
||||||
|
try {
|
||||||
|
await shoppingListItemActions.process();
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
let newListValue: typeof shoppingList.value = 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
|
||||||
|
if (loadingCounter.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent overwriting local changes with stale backend data when offline
|
||||||
|
if (isOffline.value) {
|
||||||
|
// Do not update shoppingList.value from backend when offline
|
||||||
|
updateListItemOrder();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateListItemOrder();
|
||||||
|
}
|
||||||
|
|
||||||
|
// constantly polls for changes
|
||||||
|
async function pollForChanges(updateListItemOrder: () => void) {
|
||||||
|
// pause polling if the user isn't active or we're busy
|
||||||
|
if (idle.value || loadingCounter.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await refresh(updateListItemOrder);
|
||||||
|
|
||||||
|
if (shoppingList.value) {
|
||||||
|
attempts = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the refresh was unsuccessful, the shopping list will be null, so we increment the attempt counter
|
||||||
|
attempts++;
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
attempts++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if we hit too many errors, stop polling
|
||||||
|
if (attempts >= maxAttempts) {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// start polling
|
||||||
|
loadingCounter.value -= 1;
|
||||||
|
|
||||||
|
// 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 pollTimer: ReturnType<typeof setInterval>;
|
||||||
|
|
||||||
|
function startPolling(updateListItemOrder: () => void) {
|
||||||
|
pollForChanges(updateListItemOrder); // populate initial list
|
||||||
|
|
||||||
|
pollTimer = setInterval(() => {
|
||||||
|
pollForChanges(updateListItemOrder);
|
||||||
|
}, pollFrequency);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (pollTimer) {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isOffline,
|
||||||
|
fetchShoppingList,
|
||||||
|
refresh,
|
||||||
|
startPolling,
|
||||||
|
stopPolling,
|
||||||
|
shoppingListItemActions,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { useToggle } from "@vueuse/core";
|
||||||
|
import type { ShoppingListOut, ShoppingListItemOut } from "~/lib/api/types/household";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for managing shopping list label state and operations
|
||||||
|
*/
|
||||||
|
export function useShoppingListLabels(shoppingList: Ref<ShoppingListOut | null>) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const labelOpenState = ref<{ [key: string]: boolean }>({});
|
||||||
|
const [showChecked, toggleShowChecked] = useToggle(false);
|
||||||
|
|
||||||
|
const initializeLabelOpenStates = () => {
|
||||||
|
if (!shoppingList.value?.listItems) return;
|
||||||
|
|
||||||
|
const existingLabels = new Set(Object.keys(labelOpenState.value));
|
||||||
|
let hasChanges = false;
|
||||||
|
|
||||||
|
for (const item of shoppingList.value.listItems) {
|
||||||
|
const labelName = item.label?.name || t("shopping-list.no-label");
|
||||||
|
if (!existingLabels.has(labelName) && !(labelName in labelOpenState.value)) {
|
||||||
|
labelOpenState.value[labelName] = true;
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasChanges) {
|
||||||
|
labelOpenState.value = { ...labelOpenState.value };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelNames = computed(() => {
|
||||||
|
return new Set(
|
||||||
|
shoppingList.value?.listItems
|
||||||
|
?.map(item => item.label?.name || t("shopping-list.no-label"))
|
||||||
|
.filter(Boolean) ?? [],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(labelNames, initializeLabelOpenStates, { immediate: true });
|
||||||
|
|
||||||
|
function toggleShowLabel(key: string) {
|
||||||
|
labelOpenState.value[key] = !labelOpenState.value[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLabelColor(item: ShoppingListItemOut | null) {
|
||||||
|
return item?.label?.color;
|
||||||
|
}
|
||||||
|
|
||||||
|
const presentLabels = computed(() => {
|
||||||
|
const labels: Array<{ id: string; name: string }> = [];
|
||||||
|
|
||||||
|
shoppingList.value?.listItems?.forEach((item) => {
|
||||||
|
if (item.labelId && item.label) {
|
||||||
|
labels.push({
|
||||||
|
name: item.label.name,
|
||||||
|
id: item.labelId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return labels;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
labelOpenState,
|
||||||
|
showChecked,
|
||||||
|
toggleShowChecked,
|
||||||
|
toggleShowLabel,
|
||||||
|
getLabelColor,
|
||||||
|
presentLabels,
|
||||||
|
initializeLabelOpenStates,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,51 @@
|
||||||
|
import type { ShoppingListOut } from "~/lib/api/types/household";
|
||||||
|
import { useUserApi } from "~/composables/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for managing shopping list recipe references
|
||||||
|
*/
|
||||||
|
export function useShoppingListRecipes(
|
||||||
|
shoppingList: Ref<ShoppingListOut | null>,
|
||||||
|
loadingCounter: Ref<number>,
|
||||||
|
recipeReferenceLoading: Ref<boolean>,
|
||||||
|
refresh: () => void,
|
||||||
|
) {
|
||||||
|
const userApi = useUserApi();
|
||||||
|
|
||||||
|
async function addRecipeReferenceToList(recipeId: string) {
|
||||||
|
if (!shoppingList.value || recipeReferenceLoading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingCounter.value += 1;
|
||||||
|
recipeReferenceLoading.value = true;
|
||||||
|
const { data } = await userApi.shopping.lists.addRecipes(shoppingList.value.id, [{ recipeId }]);
|
||||||
|
recipeReferenceLoading.value = false;
|
||||||
|
loadingCounter.value -= 1;
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeRecipeReferenceToList(recipeId: string) {
|
||||||
|
if (!shoppingList.value || recipeReferenceLoading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingCounter.value += 1;
|
||||||
|
recipeReferenceLoading.value = true;
|
||||||
|
const { data } = await userApi.shopping.lists.removeRecipe(shoppingList.value.id, recipeId);
|
||||||
|
recipeReferenceLoading.value = false;
|
||||||
|
loadingCounter.value -= 1;
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
addRecipeReferenceToList,
|
||||||
|
removeRecipeReferenceToList,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,135 @@
|
||||||
|
import type { ShoppingListOut, ShoppingListItemOut } from "~/lib/api/types/household";
|
||||||
|
|
||||||
|
interface ListItemGroup {
|
||||||
|
position: number;
|
||||||
|
createdAt: string;
|
||||||
|
items: ShoppingListItemOut[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for managing shopping list item sorting and organization
|
||||||
|
*/
|
||||||
|
export function useShoppingListSorting() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
function sortItems(a: ShoppingListItemOut | ListItemGroup, b: ShoppingListItemOut | ListItemGroup) {
|
||||||
|
// Sort by position ASC, then by createdAt ASC
|
||||||
|
const posA = a.position ?? 0;
|
||||||
|
const posB = b.position ?? 0;
|
||||||
|
if (posA !== posB) {
|
||||||
|
return posA - posB;
|
||||||
|
}
|
||||||
|
const createdA = a.createdAt ?? "";
|
||||||
|
const createdB = b.createdAt ?? "";
|
||||||
|
if (createdA !== createdB) {
|
||||||
|
return createdA < createdB ? -1 : 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupAndSortListItemsByFood(shoppingList: ShoppingListOut) {
|
||||||
|
if (!shoppingList?.listItems?.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkedItemKey = "__checkedItem";
|
||||||
|
const listItemGroupsMap = new Map<string, ListItemGroup>();
|
||||||
|
listItemGroupsMap.set(checkedItemKey, { position: Number.MAX_SAFE_INTEGER, createdAt: "", items: [] });
|
||||||
|
|
||||||
|
// group items by checked status, food, or note
|
||||||
|
shoppingList.listItems.forEach((item) => {
|
||||||
|
const key = item.checked
|
||||||
|
? checkedItemKey
|
||||||
|
: item.food?.name
|
||||||
|
? item.food.name
|
||||||
|
: item.note || "";
|
||||||
|
|
||||||
|
const group = listItemGroupsMap.get(key);
|
||||||
|
if (!group) {
|
||||||
|
listItemGroupsMap.set(key, { position: item.position || 0, createdAt: item.createdAt || "", items: [item] });
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
group.items.push(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const listItemGroups = Array.from(listItemGroupsMap.values());
|
||||||
|
listItemGroups.sort(sortItems);
|
||||||
|
|
||||||
|
// sort group items, then aggregate them
|
||||||
|
const sortedItems: ShoppingListItemOut[] = [];
|
||||||
|
let nextPosition = 0;
|
||||||
|
listItemGroups.forEach((listItemGroup) => {
|
||||||
|
listItemGroup.items.sort(sortItems);
|
||||||
|
listItemGroup.items.forEach((item) => {
|
||||||
|
item.position = nextPosition;
|
||||||
|
nextPosition += 1;
|
||||||
|
sortedItems.push(item);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
shoppingList.listItems = sortedItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortListItems(shoppingList: ShoppingListOut) {
|
||||||
|
if (!shoppingList?.listItems?.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
shoppingList.listItems.sort(sortItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateItemsByLabel(shoppingList: ShoppingListOut) {
|
||||||
|
const items: { [prop: string]: ShoppingListItemOut[] } = {};
|
||||||
|
const noLabelText = t("shopping-list.no-label");
|
||||||
|
const noLabel = [] as ShoppingListItemOut[];
|
||||||
|
|
||||||
|
shoppingList?.listItems?.forEach((item) => {
|
||||||
|
if (item.checked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.labelId) {
|
||||||
|
if (item.label && item.label.name in items) {
|
||||||
|
items[item.label.name].push(item);
|
||||||
|
}
|
||||||
|
else if (item.label) {
|
||||||
|
items[item.label.name] = [item];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
noLabel.push(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (noLabel.length > 0) {
|
||||||
|
items[noLabelText] = noLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// sort the map by label order
|
||||||
|
const orderedLabelNames = shoppingList?.labelSettings?.map(labelSetting => labelSetting.label.name);
|
||||||
|
if (!orderedLabelNames) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemsSorted: { [prop: string]: ShoppingListItemOut[] } = {};
|
||||||
|
if (noLabelText in items) {
|
||||||
|
itemsSorted[noLabelText] = items[noLabelText];
|
||||||
|
}
|
||||||
|
|
||||||
|
orderedLabelNames.forEach((labelName) => {
|
||||||
|
if (labelName in items) {
|
||||||
|
itemsSorted[labelName] = items[labelName];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return itemsSorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sortItems,
|
||||||
|
groupAndSortListItemsByFood,
|
||||||
|
sortListItems,
|
||||||
|
updateItemsByLabel,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,70 @@
|
||||||
|
import type { ShoppingListOut, ShoppingListItemOut } from "~/lib/api/types/household";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for managing shopping list state and reactive data
|
||||||
|
*/
|
||||||
|
export function useShoppingListState() {
|
||||||
|
const shoppingList = ref<ShoppingListOut | null>(null);
|
||||||
|
const loadingCounter = ref(1);
|
||||||
|
const recipeReferenceLoading = ref(false);
|
||||||
|
const preserveItemOrder = ref(false);
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
const edit = ref(false);
|
||||||
|
const threeDot = ref(false);
|
||||||
|
const reorderLabelsDialog = ref(false);
|
||||||
|
const createEditorOpen = ref(false);
|
||||||
|
|
||||||
|
// Dialog states
|
||||||
|
const state = reactive({
|
||||||
|
checkAllDialog: false,
|
||||||
|
uncheckAllDialog: false,
|
||||||
|
deleteCheckedDialog: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hydrate listItems from shoppingList.value?.listItems
|
||||||
|
const listItems = reactive({
|
||||||
|
unchecked: [] as ShoppingListItemOut[],
|
||||||
|
checked: [] as ShoppingListItemOut[],
|
||||||
|
});
|
||||||
|
|
||||||
|
function sortCheckedItems(a: ShoppingListItemOut, b: ShoppingListItemOut) {
|
||||||
|
if (a.updatedAt! === b.updatedAt!) {
|
||||||
|
return ((a.position || 0) > (b.position || 0)) ? -1 : 1;
|
||||||
|
}
|
||||||
|
return a.updatedAt! < b.updatedAt! ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => shoppingList.value?.listItems,
|
||||||
|
(items) => {
|
||||||
|
listItems.unchecked = (items?.filter(item => !item.checked) ?? []);
|
||||||
|
listItems.checked = (items?.filter(item => item.checked)
|
||||||
|
.sort(sortCheckedItems) ?? []);
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const recipeMap = computed(() => new Map(
|
||||||
|
(shoppingList.value?.recipeReferences?.map(ref => ref.recipe) ?? [])
|
||||||
|
.map(recipe => [recipe.id || "", recipe])),
|
||||||
|
);
|
||||||
|
|
||||||
|
const recipeList = computed(() => Array.from(recipeMap.value.values()));
|
||||||
|
|
||||||
|
return {
|
||||||
|
shoppingList,
|
||||||
|
loadingCounter,
|
||||||
|
recipeReferenceLoading,
|
||||||
|
preserveItemOrder,
|
||||||
|
edit,
|
||||||
|
threeDot,
|
||||||
|
reorderLabelsDialog,
|
||||||
|
createEditorOpen,
|
||||||
|
state,
|
||||||
|
listItems,
|
||||||
|
recipeMap,
|
||||||
|
recipeList,
|
||||||
|
sortCheckedItems,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,194 @@
|
||||||
|
import type { ShoppingListItemOut } from "~/lib/api/types/household";
|
||||||
|
import { useShoppingListState } from "~/composables/shopping-list-page/sub-composables/use-shopping-list-state";
|
||||||
|
import { useShoppingListData } from "~/composables/shopping-list-page/sub-composables/use-shopping-list-data";
|
||||||
|
import { useShoppingListSorting } from "~/composables/shopping-list-page/sub-composables/use-shopping-list-sorting";
|
||||||
|
import { useShoppingListLabels } from "~/composables/shopping-list-page/sub-composables/use-shopping-list-labels";
|
||||||
|
import { useShoppingListCopy } from "~/composables/shopping-list-page/sub-composables/use-shopping-list-copy";
|
||||||
|
import { useShoppingListCrud } from "~/composables/shopping-list-page/sub-composables/use-shopping-list-crud";
|
||||||
|
import { useShoppingListRecipes } from "~/composables/shopping-list-page/sub-composables/use-shopping-list-recipes";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main composable that orchestrates all shopping list page functionality
|
||||||
|
*/
|
||||||
|
export function useShoppingListPage(listId: string) {
|
||||||
|
// Initialize state
|
||||||
|
const state = useShoppingListState();
|
||||||
|
const {
|
||||||
|
shoppingList,
|
||||||
|
loadingCounter,
|
||||||
|
recipeReferenceLoading,
|
||||||
|
preserveItemOrder,
|
||||||
|
listItems,
|
||||||
|
sortCheckedItems,
|
||||||
|
} = state;
|
||||||
|
|
||||||
|
// Initialize sorting functionality
|
||||||
|
const sorting = useShoppingListSorting();
|
||||||
|
const { groupAndSortListItemsByFood, sortListItems, updateItemsByLabel } = sorting;
|
||||||
|
|
||||||
|
// Track items organized by label
|
||||||
|
const itemsByLabel = ref<{ [key: string]: ShoppingListItemOut[] }>({});
|
||||||
|
|
||||||
|
function updateListItemOrder() {
|
||||||
|
if (!shoppingList.value) return;
|
||||||
|
|
||||||
|
if (!preserveItemOrder.value) {
|
||||||
|
groupAndSortListItemsByFood(shoppingList.value);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
sortListItems(shoppingList.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const labeledItems = updateItemsByLabel(shoppingList.value);
|
||||||
|
if (labeledItems) {
|
||||||
|
itemsByLabel.value = labeledItems;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize data management
|
||||||
|
const dataManager = useShoppingListData(listId, shoppingList, loadingCounter);
|
||||||
|
const { isOffline, refresh: baseRefresh, startPolling, stopPolling, shoppingListItemActions } = dataManager;
|
||||||
|
|
||||||
|
const refresh = () => baseRefresh(updateListItemOrder);
|
||||||
|
|
||||||
|
// Initialize shopping list labels
|
||||||
|
const labels = useShoppingListLabels(shoppingList);
|
||||||
|
|
||||||
|
// Initialize copy functionality
|
||||||
|
const copyManager = useShoppingListCopy();
|
||||||
|
|
||||||
|
// Initialize CRUD operations
|
||||||
|
const crud = useShoppingListCrud(
|
||||||
|
shoppingList,
|
||||||
|
loadingCounter,
|
||||||
|
listItems,
|
||||||
|
shoppingListItemActions,
|
||||||
|
refresh,
|
||||||
|
sortCheckedItems,
|
||||||
|
updateListItemOrder,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initialize recipe management
|
||||||
|
const recipes = useShoppingListRecipes(
|
||||||
|
shoppingList,
|
||||||
|
loadingCounter,
|
||||||
|
recipeReferenceLoading,
|
||||||
|
refresh,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle item reordering by label
|
||||||
|
function updateIndexUncheckedByLabel(labelName: string, labeledUncheckedItems: ShoppingListItemOut[]) {
|
||||||
|
if (!itemsByLabel.value[labelName]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// update this label's item order
|
||||||
|
itemsByLabel.value[labelName] = labeledUncheckedItems;
|
||||||
|
|
||||||
|
// reset list order of all items
|
||||||
|
const allUncheckedItems: ShoppingListItemOut[] = [];
|
||||||
|
for (const labelKey in itemsByLabel.value) {
|
||||||
|
allUncheckedItems.push(...itemsByLabel.value[labelKey]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// since the user has manually reordered the list, we should preserve this order
|
||||||
|
preserveItemOrder.value = true;
|
||||||
|
|
||||||
|
// save changes
|
||||||
|
listItems.unchecked = allUncheckedItems;
|
||||||
|
listItems.checked = shoppingList.value?.listItems?.filter(item => item.checked) || [];
|
||||||
|
crud.updateUncheckedListItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dialog helpers
|
||||||
|
function openCheckAll() {
|
||||||
|
if (shoppingList.value?.listItems?.some(item => !item.checked)) {
|
||||||
|
state.state.checkAllDialog = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUncheckAll() {
|
||||||
|
if (shoppingList.value?.listItems?.some(item => item.checked)) {
|
||||||
|
state.state.uncheckAllDialog = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDeleteChecked() {
|
||||||
|
if (shoppingList.value?.listItems?.some(item => item.checked)) {
|
||||||
|
state.state.deleteCheckedDialog = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkAll() {
|
||||||
|
state.state.checkAllDialog = false;
|
||||||
|
crud.checkAllItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
function uncheckAll() {
|
||||||
|
state.state.uncheckAllDialog = false;
|
||||||
|
crud.uncheckAllItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteChecked() {
|
||||||
|
state.state.deleteCheckedDialog = false;
|
||||||
|
crud.deleteCheckedItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy functionality wrapper
|
||||||
|
function copyListItems(copyType: "plain" | "markdown") {
|
||||||
|
copyManager.copyListItems(itemsByLabel.value, copyType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label reordering helpers
|
||||||
|
function toggleReorderLabelsDialog() {
|
||||||
|
crud.toggleReorderLabelsDialog(state.reorderLabelsDialog);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLabelOrder() {
|
||||||
|
await crud.saveLabelOrder(() => {
|
||||||
|
const labeledItems = updateItemsByLabel(shoppingList.value!);
|
||||||
|
if (labeledItems) {
|
||||||
|
itemsByLabel.value = labeledItems;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lifecycle management
|
||||||
|
onMounted(() => {
|
||||||
|
startPolling(updateListItemOrder);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopPolling();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
itemsByLabel,
|
||||||
|
isOffline,
|
||||||
|
|
||||||
|
// Sub-composables
|
||||||
|
...state,
|
||||||
|
...labels,
|
||||||
|
...crud,
|
||||||
|
...recipes,
|
||||||
|
|
||||||
|
// Specialized functions
|
||||||
|
updateIndexUncheckedByLabel,
|
||||||
|
copyListItems,
|
||||||
|
|
||||||
|
// Dialog actions
|
||||||
|
openCheckAll,
|
||||||
|
openUncheckAll,
|
||||||
|
openDeleteChecked,
|
||||||
|
checkAll,
|
||||||
|
uncheckAll,
|
||||||
|
deleteChecked,
|
||||||
|
|
||||||
|
// Label management
|
||||||
|
toggleReorderLabelsDialog,
|
||||||
|
saveLabelOrder,
|
||||||
|
|
||||||
|
// Data refresh
|
||||||
|
refresh,
|
||||||
|
};
|
||||||
|
}
|
|
@ -33,7 +33,6 @@ export interface UserRecipePreferences {
|
||||||
|
|
||||||
export interface UserShoppingListPreferences {
|
export interface UserShoppingListPreferences {
|
||||||
viewAllLists: boolean;
|
viewAllLists: boolean;
|
||||||
viewByLabel: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserTimelinePreferences {
|
export interface UserTimelinePreferences {
|
||||||
|
@ -129,7 +128,6 @@ export function useShoppingListPreferences(): Ref<UserShoppingListPreferences> {
|
||||||
"shopping-list-preferences",
|
"shopping-list-preferences",
|
||||||
{
|
{
|
||||||
viewAllLists: false,
|
viewAllLists: false,
|
||||||
viewByLabel: true,
|
|
||||||
},
|
},
|
||||||
{ mergeDefaults: true },
|
{ mergeDefaults: true },
|
||||||
// we cast to a Ref because by default it will return an optional type ref
|
// we cast to a Ref because by default it will return an optional type ref
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
class="md-container"
|
class="md-container"
|
||||||
>
|
>
|
||||||
<BaseDialog
|
<BaseDialog
|
||||||
v-model="checkAllDialog"
|
v-model="state.checkAllDialog"
|
||||||
:title="$t('general.confirm')"
|
:title="$t('general.confirm')"
|
||||||
can-confirm
|
can-confirm
|
||||||
@confirm="checkAll"
|
@confirm="checkAll"
|
||||||
|
@ -15,7 +15,7 @@
|
||||||
</BaseDialog>
|
</BaseDialog>
|
||||||
|
|
||||||
<BaseDialog
|
<BaseDialog
|
||||||
v-model="uncheckAllDialog"
|
v-model="state.uncheckAllDialog"
|
||||||
:title="$t('general.confirm')"
|
:title="$t('general.confirm')"
|
||||||
can-confirm
|
can-confirm
|
||||||
@confirm="uncheckAll"
|
@confirm="uncheckAll"
|
||||||
|
@ -26,7 +26,7 @@
|
||||||
</BaseDialog>
|
</BaseDialog>
|
||||||
|
|
||||||
<BaseDialog
|
<BaseDialog
|
||||||
v-model="deleteCheckedDialog"
|
v-model="state.deleteCheckedDialog"
|
||||||
:title="$t('general.confirm')"
|
:title="$t('general.confirm')"
|
||||||
can-confirm
|
can-confirm
|
||||||
@confirm="deleteChecked"
|
@confirm="deleteChecked"
|
||||||
|
@ -90,11 +90,6 @@
|
||||||
text: '',
|
text: '',
|
||||||
event: 'three-dot',
|
event: 'three-dot',
|
||||||
children: [
|
children: [
|
||||||
{
|
|
||||||
icon: $globals.icons.tags,
|
|
||||||
text: $t('shopping-list.toggle-label-sort'),
|
|
||||||
event: 'sort-by-labels',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
icon: $globals.icons.tags,
|
icon: $globals.icons.tags,
|
||||||
text: $t('shopping-list.reorder-labels'),
|
text: $t('shopping-list.reorder-labels'),
|
||||||
|
@ -111,7 +106,6 @@
|
||||||
@edit="edit = true"
|
@edit="edit = true"
|
||||||
@three-dot="threeDot = true"
|
@three-dot="threeDot = true"
|
||||||
@check="openCheckAll"
|
@check="openCheckAll"
|
||||||
@sort-by-labels="sortByLabels"
|
|
||||||
@copy-plain="copyListItems('plain')"
|
@copy-plain="copyListItems('plain')"
|
||||||
@copy-markdown="copyListItems('markdown')"
|
@copy-markdown="copyListItems('markdown')"
|
||||||
@reorder-labels="toggleReorderLabelsDialog()"
|
@reorder-labels="toggleReorderLabelsDialog()"
|
||||||
|
@ -159,40 +153,6 @@
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- View without Label grouping -->
|
|
||||||
<div v-if="!preferences.viewByLabel">
|
|
||||||
<VueDraggable
|
|
||||||
v-model="listItems.unchecked"
|
|
||||||
handle=".handle"
|
|
||||||
:delay="250"
|
|
||||||
:delay-on-touch-only="true"
|
|
||||||
@start="loadingCounter += 1"
|
|
||||||
@end="loadingCounter -= 1"
|
|
||||||
@update:model-value="updateIndexUnchecked"
|
|
||||||
>
|
|
||||||
<v-lazy
|
|
||||||
v-for="(item, index) in listItems.unchecked"
|
|
||||||
:key="item.id"
|
|
||||||
class="my-2"
|
|
||||||
>
|
|
||||||
<ShoppingListItem
|
|
||||||
v-model="listItems.unchecked[index]"
|
|
||||||
class="my-2 my-sm-0"
|
|
||||||
:show-label="true"
|
|
||||||
:labels="allLabels || []"
|
|
||||||
:units="allUnits || []"
|
|
||||||
:foods="allFoods || []"
|
|
||||||
:recipes="recipeMap"
|
|
||||||
@checked="saveListItem"
|
|
||||||
@save="saveListItem"
|
|
||||||
@delete="deleteListItem(item)"
|
|
||||||
/>
|
|
||||||
</v-lazy>
|
|
||||||
</VueDraggable>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- View By Label -->
|
|
||||||
<div v-else>
|
|
||||||
<div
|
<div
|
||||||
v-for="(value, key) in itemsByLabel"
|
v-for="(value, key) in itemsByLabel"
|
||||||
:key="key"
|
:key="key"
|
||||||
|
@ -230,7 +190,6 @@
|
||||||
>
|
>
|
||||||
<ShoppingListItem
|
<ShoppingListItem
|
||||||
v-model="value[index]"
|
v-model="value[index]"
|
||||||
:show-label="false"
|
|
||||||
:labels="allLabels || []"
|
:labels="allLabels || []"
|
||||||
:units="allUnits || []"
|
:units="allUnits || []"
|
||||||
:foods="allFoods || []"
|
:foods="allFoods || []"
|
||||||
|
@ -244,7 +203,6 @@
|
||||||
</div>
|
</div>
|
||||||
</v-expand-transition>
|
</v-expand-transition>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Reorder Labels -->
|
<!-- Reorder Labels -->
|
||||||
<BaseDialog
|
<BaseDialog
|
||||||
|
@ -359,12 +317,12 @@
|
||||||
</div>
|
</div>
|
||||||
<v-divider class="my-4" />
|
<v-divider class="my-4" />
|
||||||
<RecipeList
|
<RecipeList
|
||||||
:recipes="Array.from(recipeMap.values())"
|
:recipes="recipeList"
|
||||||
show-description
|
show-description
|
||||||
:disabled="isOffline"
|
:disabled="isOffline"
|
||||||
>
|
>
|
||||||
<template
|
<template
|
||||||
v-for="(recipe, index) in recipeMap.values()"
|
v-for="(recipe, index) in recipeList"
|
||||||
#[`actions-${recipe.id}`]
|
#[`actions-${recipe.id}`]
|
||||||
:key="'item-actions-decrease' + recipe.id"
|
:key="'item-actions-decrease' + recipe.id"
|
||||||
>
|
>
|
||||||
|
@ -408,26 +366,14 @@
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { VueDraggable } from "vue-draggable-plus";
|
import { VueDraggable } from "vue-draggable-plus";
|
||||||
import { useIdle, useOnline, useToggle } from "@vueuse/core";
|
|
||||||
import { useCopyList } from "~/composables/use-copy";
|
|
||||||
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 type { ShoppingListItemOut, ShoppingListMultiPurposeLabelOut, ShoppingListOut } from "~/lib/api/types/household";
|
|
||||||
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 { getTextColor } from "~/composables/use-text-color";
|
import { getTextColor } from "~/composables/use-text-color";
|
||||||
import { uuid4 } from "~/composables/use-utils";
|
import { useShoppingListPage } from "~/composables/shopping-list-page/use-shopping-list-page";
|
||||||
|
|
||||||
type CopyTypes = "plain" | "markdown";
|
|
||||||
|
|
||||||
interface PresentLabel {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
export default defineNuxtComponent({
|
||||||
components: {
|
components: {
|
||||||
|
@ -437,809 +383,34 @@ export default defineNuxtComponent({
|
||||||
RecipeList,
|
RecipeList,
|
||||||
ShoppingListItemEditor,
|
ShoppingListItemEditor,
|
||||||
},
|
},
|
||||||
// middleware: "sidebase-auth",
|
|
||||||
setup() {
|
setup() {
|
||||||
const { mdAndUp } = useDisplay();
|
const { mdAndUp } = useDisplay();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const $auth = useMealieAuth();
|
const $auth = useMealieAuth();
|
||||||
const preferences = useShoppingListPreferences();
|
const preferences = useShoppingListPreferences();
|
||||||
|
|
||||||
const isOffline = computed(() => useOnline().value === false);
|
|
||||||
|
|
||||||
useSeoMeta({
|
useSeoMeta({
|
||||||
title: i18n.t("shopping-list.shopping-list"),
|
title: i18n.t("shopping-list.shopping-list"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { idle } = useIdle(5 * 60 * 1000); // 5 minutes
|
|
||||||
const loadingCounter = ref(1);
|
|
||||||
const recipeReferenceLoading = ref(false);
|
|
||||||
const userApi = useUserApi();
|
|
||||||
|
|
||||||
const edit = ref(false);
|
|
||||||
const threeDot = ref(false);
|
|
||||||
const reorderLabelsDialog = ref(false);
|
|
||||||
const preserveItemOrder = ref(false);
|
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||||
const id = route.params.id as string;
|
const id = route.params.id as string;
|
||||||
const shoppingListItemActions = useShoppingListItemActions(id);
|
|
||||||
|
|
||||||
const state = reactive({
|
|
||||||
checkAllDialog: false,
|
|
||||||
uncheckAllDialog: false,
|
|
||||||
deleteCheckedDialog: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// ===============================================================
|
|
||||||
// Shopping List Actions
|
|
||||||
|
|
||||||
const shoppingList = ref<ShoppingListOut | null>(null);
|
|
||||||
async function fetchShoppingList() {
|
|
||||||
const data = await shoppingListItemActions.getList();
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refresh() {
|
|
||||||
loadingCounter.value += 1;
|
|
||||||
try {
|
|
||||||
await shoppingListItemActions.process();
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
let newListValue: typeof shoppingList.value = 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
|
|
||||||
if (loadingCounter.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prevent overwriting local changes with stale backend data when offline
|
|
||||||
if (isOffline.value) {
|
|
||||||
// Do not update shoppingList.value from backend when offline
|
|
||||||
updateListItemOrder();
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateListItemOrder();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateListItemOrder() {
|
|
||||||
if (!preserveItemOrder.value) {
|
|
||||||
groupAndSortListItemsByFood();
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
sortListItems();
|
|
||||||
}
|
|
||||||
updateItemsByLabel();
|
|
||||||
}
|
|
||||||
|
|
||||||
// constantly polls for changes
|
|
||||||
async function pollForChanges() {
|
|
||||||
// pause polling if the user isn't active or we're busy
|
|
||||||
if (idle.value || loadingCounter.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await refresh();
|
|
||||||
|
|
||||||
if (shoppingList.value) {
|
|
||||||
attempts = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if the refresh was unsuccessful, the shopping list will be null, so we increment the attempt counter
|
|
||||||
attempts++;
|
|
||||||
}
|
|
||||||
|
|
||||||
catch {
|
|
||||||
attempts++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if we hit too many errors, stop polling
|
|
||||||
if (attempts >= maxAttempts) {
|
|
||||||
clearInterval(pollTimer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// start polling
|
|
||||||
loadingCounter.value -= 1;
|
|
||||||
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 pollTimer: ReturnType<typeof setInterval> = setInterval(() => {
|
|
||||||
pollForChanges();
|
|
||||||
}, pollFrequency);
|
|
||||||
onUnmounted(() => {
|
|
||||||
clearInterval(pollTimer);
|
|
||||||
});
|
|
||||||
|
|
||||||
// =====================================
|
|
||||||
// List Item CRUD
|
|
||||||
|
|
||||||
// Hydrate listItems from shoppingList.value?.listItems
|
|
||||||
const listItems = reactive({
|
|
||||||
unchecked: [] as ShoppingListItemOut[],
|
|
||||||
checked: [] as ShoppingListItemOut[],
|
|
||||||
});
|
|
||||||
|
|
||||||
function sortCheckedItems(a: ShoppingListItemOut, b: ShoppingListItemOut) {
|
|
||||||
if (a.updatedAt! === b.updatedAt!) {
|
|
||||||
return ((a.position || 0) > (b.position || 0)) ? -1 : 1;
|
|
||||||
}
|
|
||||||
return a.updatedAt! < b.updatedAt! ? 1 : -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => shoppingList.value?.listItems,
|
|
||||||
(items) => {
|
|
||||||
listItems.unchecked = (items?.filter(item => !item.checked) ?? []);
|
|
||||||
listItems.checked = (items?.filter(item => item.checked)
|
|
||||||
.sort(sortCheckedItems) ?? []);
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
// =====================================
|
|
||||||
// Collapsable Labels
|
|
||||||
const labelOpenState = ref<{ [key: string]: boolean }>({});
|
|
||||||
|
|
||||||
const initializeLabelOpenStates = () => {
|
|
||||||
if (!shoppingList.value?.listItems) return;
|
|
||||||
|
|
||||||
const existingLabels = new Set(Object.keys(labelOpenState.value));
|
|
||||||
let hasChanges = false;
|
|
||||||
|
|
||||||
for (const item of shoppingList.value.listItems) {
|
|
||||||
const labelName = item.label?.name || i18n.t("shopping-list.no-label");
|
|
||||||
if (!existingLabels.has(labelName) && !(labelName in labelOpenState.value)) {
|
|
||||||
labelOpenState.value[labelName] = true;
|
|
||||||
hasChanges = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasChanges) {
|
|
||||||
labelOpenState.value = { ...labelOpenState.value };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const labelNames = computed(() => {
|
|
||||||
return new Set(
|
|
||||||
shoppingList.value?.listItems
|
|
||||||
?.map(item => item.label?.name || i18n.t("shopping-list.no-label"))
|
|
||||||
.filter(Boolean) ?? [],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(labelNames, initializeLabelOpenStates, { immediate: true });
|
|
||||||
|
|
||||||
function toggleShowLabel(key: string) {
|
|
||||||
labelOpenState.value[key] = !labelOpenState.value[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
const [showChecked, toggleShowChecked] = useToggle(false);
|
|
||||||
|
|
||||||
// =====================================
|
|
||||||
// Copy List Items
|
|
||||||
|
|
||||||
const copy = useCopyList();
|
|
||||||
|
|
||||||
function copyListItems(copyType: CopyTypes) {
|
|
||||||
const text: string[] = [];
|
|
||||||
|
|
||||||
if (preferences.value.viewByLabel) {
|
|
||||||
// if we're sorting by label, we want the copied text in subsections
|
|
||||||
Object.entries(itemsByLabel.value).forEach(([label, items], idx) => {
|
|
||||||
// for every group except the first, add a blank line
|
|
||||||
if (idx) {
|
|
||||||
text.push("");
|
|
||||||
}
|
|
||||||
|
|
||||||
// add an appropriate heading for the label depending on the copy format
|
|
||||||
text.push(formatCopiedLabelHeading(copyType, label));
|
|
||||||
|
|
||||||
// now add the appropriately formatted list items with the given label
|
|
||||||
items.forEach(item => text.push(formatCopiedListItem(copyType, item)));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// labels are toggled off, so just copy in the order they come in
|
|
||||||
const items = shoppingList.value?.listItems?.filter(item => !item.checked);
|
|
||||||
|
|
||||||
items?.forEach((item) => {
|
|
||||||
text.push(formatCopiedListItem(copyType, item));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
copy.copyPlain(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCopiedListItem(copyType: CopyTypes, item: ShoppingListItemOut): string {
|
|
||||||
const display = item.display || "";
|
|
||||||
switch (copyType) {
|
|
||||||
case "markdown":
|
|
||||||
return `- [ ] ${display}`;
|
|
||||||
default:
|
|
||||||
return display;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCopiedLabelHeading(copyType: CopyTypes, label: string): string {
|
|
||||||
switch (copyType) {
|
|
||||||
case "markdown":
|
|
||||||
return `# ${label}`;
|
|
||||||
default:
|
|
||||||
return `[${label}]`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// =====================================
|
|
||||||
// Check / Uncheck All
|
|
||||||
function openCheckAll() {
|
|
||||||
if (shoppingList.value?.listItems?.some(item => !item.checked)) {
|
|
||||||
state.checkAllDialog = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkAll() {
|
|
||||||
state.checkAllDialog = false;
|
|
||||||
let hasChanged = false;
|
|
||||||
shoppingList.value?.listItems?.forEach((item) => {
|
|
||||||
if (!item.checked) {
|
|
||||||
hasChanged = true;
|
|
||||||
item.checked = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (hasChanged) {
|
|
||||||
updateUncheckedListItems();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openUncheckAll() {
|
|
||||||
if (shoppingList.value?.listItems?.some(item => item.checked)) {
|
|
||||||
state.uncheckAllDialog = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function uncheckAll() {
|
|
||||||
state.uncheckAllDialog = false;
|
|
||||||
let hasChanged = false;
|
|
||||||
shoppingList.value?.listItems?.forEach((item) => {
|
|
||||||
if (item.checked) {
|
|
||||||
hasChanged = true;
|
|
||||||
item.checked = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (hasChanged) {
|
|
||||||
listItems.unchecked = [...listItems.unchecked, ...listItems.checked];
|
|
||||||
listItems.checked = [];
|
|
||||||
updateUncheckedListItems();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openDeleteChecked() {
|
|
||||||
if (shoppingList.value?.listItems?.some(item => item.checked)) {
|
|
||||||
state.deleteCheckedDialog = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteChecked() {
|
|
||||||
const checked = shoppingList.value?.listItems?.filter(item => item.checked);
|
|
||||||
|
|
||||||
if (!checked || checked?.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadingCounter.value += 1;
|
|
||||||
deleteListItems(checked);
|
|
||||||
|
|
||||||
loadingCounter.value -= 1;
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
// =====================================
|
|
||||||
// List Item Context Menu
|
|
||||||
|
|
||||||
const contextActions = {
|
|
||||||
delete: "delete",
|
|
||||||
};
|
|
||||||
|
|
||||||
const contextMenu = [
|
|
||||||
{ title: i18n.t("general.delete"), action: contextActions.delete },
|
|
||||||
];
|
|
||||||
|
|
||||||
// =====================================
|
|
||||||
// Labels, Units, Foods
|
|
||||||
// TODO: Extract to Composable
|
|
||||||
|
|
||||||
const localLabels = ref<ShoppingListMultiPurposeLabelOut[]>();
|
|
||||||
|
|
||||||
|
const shoppingListPage = useShoppingListPage(id);
|
||||||
const { store: allLabels } = useLabelStore();
|
const { store: allLabels } = useLabelStore();
|
||||||
const { store: allUnits } = useUnitStore();
|
const { store: allUnits } = useUnitStore();
|
||||||
const { store: allFoods } = useFoodStore();
|
const { store: allFoods } = useFoodStore();
|
||||||
|
|
||||||
function getLabelColor(item: ShoppingListItemOut | null) {
|
|
||||||
return item?.label?.color;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortByLabels() {
|
|
||||||
preferences.value.viewByLabel = !preferences.value.viewByLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleReorderLabelsDialog() {
|
|
||||||
// stop polling and populate localLabels
|
|
||||||
loadingCounter.value += 1;
|
|
||||||
reorderLabelsDialog.value = !reorderLabelsDialog.value;
|
|
||||||
localLabels.value = shoppingList.value?.labelSettings;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateLabelOrder(labelSettings: ShoppingListMultiPurposeLabelOut[]) {
|
|
||||||
if (!shoppingList.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
labelSettings.forEach((labelSetting, index) => {
|
|
||||||
labelSetting.position = index;
|
|
||||||
return labelSetting;
|
|
||||||
});
|
|
||||||
|
|
||||||
localLabels.value = labelSettings;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelLabelOrder() {
|
|
||||||
loadingCounter.value -= 1;
|
|
||||||
if (!shoppingList.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// restore original state
|
|
||||||
localLabels.value = shoppingList.value.labelSettings;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveLabelOrder() {
|
|
||||||
if (!shoppingList.value || !localLabels.value || (localLabels.value === shoppingList.value.labelSettings)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadingCounter.value += 1;
|
|
||||||
const { data } = await userApi.shopping.lists.updateLabelSettings(shoppingList.value.id, localLabels.value);
|
|
||||||
loadingCounter.value -= 1;
|
|
||||||
|
|
||||||
if (data) {
|
|
||||||
// update shoppingList labels using the API response
|
|
||||||
shoppingList.value.labelSettings = (data as ShoppingListOut).labelSettings;
|
|
||||||
updateItemsByLabel();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const presentLabels = computed(() => {
|
|
||||||
const labels: PresentLabel[] = [];
|
|
||||||
|
|
||||||
shoppingList.value?.listItems?.forEach((item) => {
|
|
||||||
if (item.labelId && item.label) {
|
|
||||||
labels.push({
|
|
||||||
name: item.label.name,
|
|
||||||
id: item.labelId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return labels;
|
|
||||||
});
|
|
||||||
|
|
||||||
const itemsByLabel = ref<{ [key: string]: ShoppingListItemOut[] }>({});
|
|
||||||
|
|
||||||
interface ListItemGroup {
|
|
||||||
position: number;
|
|
||||||
createdAt: string;
|
|
||||||
items: ShoppingListItemOut[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortItems(a: ShoppingListItemOut | ListItemGroup, b: ShoppingListItemOut | ListItemGroup) {
|
|
||||||
// Sort by position ASC, then by createdAt ASC
|
|
||||||
const posA = a.position ?? 0;
|
|
||||||
const posB = b.position ?? 0;
|
|
||||||
if (posA !== posB) {
|
|
||||||
return posA - posB;
|
|
||||||
}
|
|
||||||
const createdA = a.createdAt ?? "";
|
|
||||||
const createdB = b.createdAt ?? "";
|
|
||||||
if (createdA !== createdB) {
|
|
||||||
return createdA < createdB ? -1 : 1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function groupAndSortListItemsByFood() {
|
|
||||||
if (!shoppingList.value?.listItems?.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkedItemKey = "__checkedItem";
|
|
||||||
const listItemGroupsMap = new Map<string, ListItemGroup>();
|
|
||||||
listItemGroupsMap.set(checkedItemKey, { position: Number.MAX_SAFE_INTEGER, createdAt: "", items: [] });
|
|
||||||
|
|
||||||
// group items by checked status, food, or note
|
|
||||||
shoppingList.value.listItems.forEach((item) => {
|
|
||||||
const key = item.checked
|
|
||||||
? checkedItemKey
|
|
||||||
: item.food?.name
|
|
||||||
? item.food.name
|
|
||||||
: item.note || "";
|
|
||||||
|
|
||||||
const group = listItemGroupsMap.get(key);
|
|
||||||
if (!group) {
|
|
||||||
listItemGroupsMap.set(key, { position: item.position || 0, createdAt: item.createdAt || "", items: [item] });
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
group.items.push(item);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const listItemGroups = Array.from(listItemGroupsMap.values());
|
|
||||||
listItemGroups.sort(sortItems);
|
|
||||||
|
|
||||||
// sort group items, then aggregate them
|
|
||||||
const sortedItems: ShoppingListItemOut[] = [];
|
|
||||||
let nextPosition = 0;
|
|
||||||
listItemGroups.forEach((listItemGroup) => {
|
|
||||||
listItemGroup.items.sort(sortItems);
|
|
||||||
listItemGroup.items.forEach((item) => {
|
|
||||||
item.position = nextPosition;
|
|
||||||
nextPosition += 1;
|
|
||||||
sortedItems.push(item);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
shoppingList.value.listItems = sortedItems;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortListItems() {
|
|
||||||
if (!shoppingList.value?.listItems?.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
shoppingList.value.listItems.sort(sortItems);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateItemsByLabel() {
|
|
||||||
const items: { [prop: string]: ShoppingListItemOut[] } = {};
|
|
||||||
const noLabelText = i18n.t("shopping-list.no-label");
|
|
||||||
const noLabel = [] as ShoppingListItemOut[];
|
|
||||||
|
|
||||||
shoppingList.value?.listItems?.forEach((item) => {
|
|
||||||
if (item.checked) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.labelId) {
|
|
||||||
if (item.label && item.label.name in items) {
|
|
||||||
items[item.label.name].push(item);
|
|
||||||
}
|
|
||||||
else if (item.label) {
|
|
||||||
items[item.label.name] = [item];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
noLabel.push(item);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (noLabel.length > 0) {
|
|
||||||
items[noLabelText] = noLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
// sort the map by label order
|
|
||||||
const orderedLabelNames = shoppingList.value?.labelSettings?.map(labelSetting => labelSetting.label.name);
|
|
||||||
if (!orderedLabelNames) {
|
|
||||||
itemsByLabel.value = items;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const itemsSorted: { [prop: string]: ShoppingListItemOut[] } = {};
|
|
||||||
if (noLabelText in items) {
|
|
||||||
itemsSorted[noLabelText] = items[noLabelText];
|
|
||||||
}
|
|
||||||
|
|
||||||
orderedLabelNames.forEach((labelName) => {
|
|
||||||
if (labelName in items) {
|
|
||||||
itemsSorted[labelName] = items[labelName];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
itemsByLabel.value = itemsSorted;
|
|
||||||
}
|
|
||||||
|
|
||||||
// =====================================
|
|
||||||
// Add/Remove Recipe References
|
|
||||||
|
|
||||||
const recipeMap = computed(() => new Map(
|
|
||||||
(shoppingList.value?.recipeReferences?.map(ref => ref.recipe) ?? [])
|
|
||||||
.map(recipe => [recipe.id || "", recipe])),
|
|
||||||
);
|
|
||||||
|
|
||||||
async function addRecipeReferenceToList(recipeId: string) {
|
|
||||||
if (!shoppingList.value || recipeReferenceLoading.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadingCounter.value += 1;
|
|
||||||
recipeReferenceLoading.value = true;
|
|
||||||
const { data } = await userApi.shopping.lists.addRecipes(shoppingList.value.id, [{ recipeId }]);
|
|
||||||
recipeReferenceLoading.value = false;
|
|
||||||
loadingCounter.value -= 1;
|
|
||||||
|
|
||||||
if (data) {
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeRecipeReferenceToList(recipeId: string) {
|
|
||||||
if (!shoppingList.value || recipeReferenceLoading.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadingCounter.value += 1;
|
|
||||||
recipeReferenceLoading.value = true;
|
|
||||||
const { data } = await userApi.shopping.lists.removeRecipe(shoppingList.value.id, recipeId);
|
|
||||||
recipeReferenceLoading.value = false;
|
|
||||||
loadingCounter.value -= 1;
|
|
||||||
|
|
||||||
if (data) {
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// =====================================
|
|
||||||
// List Item CRUD
|
|
||||||
|
|
||||||
/*
|
|
||||||
* saveListItem updates and update on the backend server. Additionally, if the item is
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
function saveListItem(item: ShoppingListItemOut) {
|
|
||||||
if (!shoppingList.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// set a temporary updatedAt timestamp prior to refresh so it appears at the top of the checked items
|
|
||||||
item.updatedAt = new Date().toISOString();
|
|
||||||
|
|
||||||
// make updates reflect immediately
|
|
||||||
if (shoppingList.value.listItems) {
|
|
||||||
shoppingList.value.listItems.forEach((oldListItem: ShoppingListItemOut, idx: number) => {
|
|
||||||
if (oldListItem.id === item.id && shoppingList.value?.listItems) {
|
|
||||||
shoppingList.value.listItems[idx] = item;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Immediately update checked/unchecked arrays for UI
|
|
||||||
listItems.unchecked = shoppingList.value.listItems.filter(i => !i.checked);
|
|
||||||
listItems.checked = shoppingList.value.listItems.filter(i => i.checked)
|
|
||||||
.sort(sortCheckedItems);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the item if it's checked, otherwise updateUncheckedListItems will handle it
|
|
||||||
if (item.checked) {
|
|
||||||
shoppingListItemActions.updateItem(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateListItemOrder();
|
|
||||||
updateUncheckedListItems();
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteListItem(item: ShoppingListItemOut) {
|
|
||||||
if (!shoppingList.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
shoppingListItemActions.deleteItem(item);
|
|
||||||
|
|
||||||
// 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<ShoppingListItemOut>(listItemFactory());
|
|
||||||
|
|
||||||
function listItemFactory(): ShoppingListItemOut {
|
|
||||||
return {
|
return {
|
||||||
id: uuid4(),
|
|
||||||
shoppingListId: id,
|
|
||||||
checked: false,
|
|
||||||
position: shoppingList.value?.listItems?.length || 1,
|
|
||||||
quantity: 0,
|
|
||||||
note: "",
|
|
||||||
labelId: undefined,
|
|
||||||
unitId: undefined,
|
|
||||||
foodId: undefined,
|
|
||||||
} as ShoppingListItemOut;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* const newMeal = reactive({
|
|
||||||
date: "",
|
|
||||||
title: "",
|
|
||||||
text: "",
|
|
||||||
recipeId: undefined as string | undefined,
|
|
||||||
entryType: "dinner" as PlanEntryType,
|
|
||||||
existing: false,
|
|
||||||
id: 0,
|
|
||||||
groupId: "",
|
|
||||||
userId: $auth.user.value?.id || "",
|
|
||||||
}); */
|
|
||||||
|
|
||||||
function createListItem() {
|
|
||||||
if (!shoppingList.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!createListItemData.value.foodId && !createListItemData.value.note) {
|
|
||||||
// don't create an empty item
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadingCounter.value += 1;
|
|
||||||
|
|
||||||
// make sure it's inserted into the end of the list, which may have been updated
|
|
||||||
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;
|
|
||||||
|
|
||||||
createListItemData.value.createdAt = new Date().toISOString();
|
|
||||||
createListItemData.value.updatedAt = createListItemData.value.createdAt;
|
|
||||||
|
|
||||||
updateListItemOrder();
|
|
||||||
|
|
||||||
shoppingListItemActions.createItem(createListItemData.value);
|
|
||||||
loadingCounter.value -= 1;
|
|
||||||
|
|
||||||
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();
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateIndexUnchecked(uncheckedItems: ShoppingListItemOut[]) {
|
|
||||||
listItems.unchecked = uncheckedItems;
|
|
||||||
listItems.checked = shoppingList.value?.listItems?.filter(item => item.checked) || [];
|
|
||||||
|
|
||||||
// since the user has manually reordered the list, we should preserve this order
|
|
||||||
preserveItemOrder.value = true;
|
|
||||||
|
|
||||||
updateUncheckedListItems();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateIndexUncheckedByLabel(labelName: string, labeledUncheckedItems: ShoppingListItemOut[]) {
|
|
||||||
if (!itemsByLabel.value[labelName]) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// update this label's item order
|
|
||||||
itemsByLabel.value[labelName] = labeledUncheckedItems;
|
|
||||||
|
|
||||||
// reset list order of all items
|
|
||||||
const allUncheckedItems: ShoppingListItemOut[] = [];
|
|
||||||
for (labelName in itemsByLabel.value) {
|
|
||||||
allUncheckedItems.push(...itemsByLabel.value[labelName]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// since the user has manually reordered the list, we should preserve this order
|
|
||||||
preserveItemOrder.value = true;
|
|
||||||
|
|
||||||
// save changes
|
|
||||||
listItems.unchecked = allUncheckedItems;
|
|
||||||
listItems.checked = shoppingList.value?.listItems?.filter(item => item.checked) || [];
|
|
||||||
updateUncheckedListItems();
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteListItems(items: ShoppingListItemOut[]) {
|
|
||||||
if (!shoppingList.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateUncheckedListItems() {
|
|
||||||
if (!shoppingList.value?.listItems) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set position for unchecked items
|
|
||||||
listItems.unchecked.forEach((item: ShoppingListItemOut, idx: number) => {
|
|
||||||
item.position = idx;
|
|
||||||
shoppingListItemActions.updateItem(item);
|
|
||||||
});
|
|
||||||
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...toRefs(state),
|
|
||||||
addRecipeReferenceToList,
|
|
||||||
allLabels,
|
|
||||||
contextMenu,
|
|
||||||
copyListItems,
|
|
||||||
createEditorOpen,
|
|
||||||
createListItem,
|
|
||||||
createListItemData,
|
|
||||||
deleteChecked,
|
|
||||||
openDeleteChecked,
|
|
||||||
deleteListItem,
|
|
||||||
edit,
|
|
||||||
threeDot,
|
|
||||||
getLabelColor,
|
|
||||||
groupSlug,
|
groupSlug,
|
||||||
itemsByLabel,
|
|
||||||
listItems,
|
|
||||||
loadingCounter,
|
|
||||||
preferences,
|
preferences,
|
||||||
presentLabels,
|
allLabels,
|
||||||
recipeMap,
|
|
||||||
removeRecipeReferenceToList,
|
|
||||||
reorderLabelsDialog,
|
|
||||||
toggleReorderLabelsDialog,
|
|
||||||
localLabels,
|
|
||||||
updateLabelOrder,
|
|
||||||
cancelLabelOrder,
|
|
||||||
saveLabelOrder,
|
|
||||||
saveListItem,
|
|
||||||
shoppingList,
|
|
||||||
showChecked,
|
|
||||||
sortByLabels,
|
|
||||||
labelOpenState,
|
|
||||||
toggleShowLabel,
|
|
||||||
toggleShowChecked,
|
|
||||||
uncheckAll,
|
|
||||||
openUncheckAll,
|
|
||||||
checkAll,
|
|
||||||
openCheckAll,
|
|
||||||
updateIndexUnchecked,
|
|
||||||
updateIndexUncheckedByLabel,
|
|
||||||
allUnits,
|
allUnits,
|
||||||
allFoods,
|
allFoods,
|
||||||
getTextColor,
|
getTextColor,
|
||||||
isOffline,
|
|
||||||
mdAndUp,
|
mdAndUp,
|
||||||
|
...shoppingListPage,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue