mirror of
https://github.com/hay-kot/mealie.git
synced 2025-08-22 06:23:34 -07:00
refactor shopping list page with ~~vibes~~
This commit is contained in:
parent
3609685931
commit
f5b8a592d3
9 changed files with 999 additions and 785 deletions
|
@ -0,0 +1,56 @@
|
|||
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[] = [];
|
||||
|
||||
// Copy text into subsections based on label
|
||||
Object.entries(itemsByLabel).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)));
|
||||
});
|
||||
|
||||
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,285 @@
|
|||
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(isFood = false): ShoppingListItemOut {
|
||||
return {
|
||||
id: uuid4(),
|
||||
shoppingListId: shoppingList.value?.id || "",
|
||||
checked: false,
|
||||
position: shoppingList.value?.listItems?.length || 1,
|
||||
isFood,
|
||||
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();
|
||||
}
|
||||
|
||||
// Individual item operations
|
||||
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(createListItemData.value.isFood || false);
|
||||
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",
|
||||
setIngredient: "setIngredient",
|
||||
};
|
||||
|
||||
const contextMenu = [
|
||||
{ title: t("general.delete"), action: contextActions.delete },
|
||||
{ title: t("recipe.ingredient"), action: contextActions.setIngredient },
|
||||
];
|
||||
|
||||
function contextMenuAction(action: string, item: ShoppingListItemOut, idx: number) {
|
||||
if (!shoppingList.value?.listItems) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case contextActions.delete:
|
||||
shoppingList.value.listItems = shoppingList.value?.listItems.filter(itm => itm.id !== item.id);
|
||||
break;
|
||||
case contextActions.setIngredient:
|
||||
shoppingList.value.listItems[idx].isFood = !shoppingList.value.listItems[idx].isFood;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
createListItemData,
|
||||
localLabels,
|
||||
listItemFactory,
|
||||
checkAllItems,
|
||||
uncheckAllItems,
|
||||
deleteCheckedItems,
|
||||
saveListItem,
|
||||
deleteListItem,
|
||||
deleteListItems,
|
||||
createListItem,
|
||||
updateUncheckedListItems,
|
||||
updateLabelOrder,
|
||||
cancelLabelOrder,
|
||||
saveLabelOrder,
|
||||
toggleReorderLabelsDialog,
|
||||
contextActions,
|
||||
contextMenu,
|
||||
contextMenuAction,
|
||||
};
|
||||
}
|
|
@ -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.isFood && 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,67 @@
|
|||
import type { ShoppingListOut, ShoppingListItemOut } from "~/lib/api/types/household";
|
||||
|
||||
/**
|
||||
* Composable for managing shopping list state and reactive data
|
||||
*/
|
||||
export function useShoppingListState(listId: string) {
|
||||
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])),
|
||||
);
|
||||
|
||||
return {
|
||||
shoppingList,
|
||||
loadingCounter,
|
||||
recipeReferenceLoading,
|
||||
preserveItemOrder,
|
||||
edit,
|
||||
threeDot,
|
||||
reorderLabelsDialog,
|
||||
createEditorOpen,
|
||||
state,
|
||||
listItems,
|
||||
recipeMap,
|
||||
sortCheckedItems,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,209 @@
|
|||
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(listId);
|
||||
const {
|
||||
shoppingList,
|
||||
loadingCounter,
|
||||
recipeReferenceLoading,
|
||||
preserveItemOrder,
|
||||
listItems,
|
||||
sortCheckedItems,
|
||||
recipeMap,
|
||||
} = 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 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 {
|
||||
// State - flatten the dialog state for easier access
|
||||
...state.state,
|
||||
shoppingList,
|
||||
loadingCounter,
|
||||
recipeReferenceLoading,
|
||||
preserveItemOrder,
|
||||
edit: state.edit,
|
||||
threeDot: state.threeDot,
|
||||
reorderLabelsDialog: state.reorderLabelsDialog,
|
||||
createEditorOpen: state.createEditorOpen,
|
||||
listItems,
|
||||
recipeMap,
|
||||
itemsByLabel,
|
||||
isOffline,
|
||||
|
||||
// Labels
|
||||
...labels,
|
||||
|
||||
// CRUD operations
|
||||
...crud,
|
||||
|
||||
// Recipe management
|
||||
...recipes,
|
||||
|
||||
// Specialized functions
|
||||
updateIndexUncheckedByLabel,
|
||||
copyListItems,
|
||||
|
||||
// Dialog actions
|
||||
openCheckAll,
|
||||
openUncheckAll,
|
||||
openDeleteChecked,
|
||||
checkAll,
|
||||
uncheckAll,
|
||||
deleteChecked,
|
||||
|
||||
// Label management
|
||||
toggleReorderLabelsDialog,
|
||||
saveLabelOrder,
|
||||
|
||||
// Data refresh
|
||||
refresh,
|
||||
};
|
||||
}
|
|
@ -367,26 +367,14 @@
|
|||
|
||||
<script lang="ts">
|
||||
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 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 ShoppingListItemEditor from "~/components/Domain/ShoppingList/ShoppingListItemEditor.vue";
|
||||
import { useFoodStore, useLabelStore, useUnitStore } from "~/composables/store";
|
||||
import { useShoppingListItemActions } from "~/composables/use-shopping-list-item-actions";
|
||||
import { useShoppingListPreferences } from "~/composables/use-users/preferences";
|
||||
import { getTextColor } from "~/composables/use-text-color";
|
||||
import { uuid4 } from "~/composables/use-utils";
|
||||
|
||||
type CopyTypes = "plain" | "markdown";
|
||||
|
||||
interface PresentLabel {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
import { useShoppingListPage } from "~/composables/shopping-list-page/use-shopping-list-page";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
|
@ -396,804 +384,37 @@ export default defineNuxtComponent({
|
|||
RecipeList,
|
||||
ShoppingListItemEditor,
|
||||
},
|
||||
// middleware: "sidebase-auth",
|
||||
setup() {
|
||||
const { mdAndUp } = useDisplay();
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const preferences = useShoppingListPreferences();
|
||||
|
||||
const isOffline = computed(() => useOnline().value === false);
|
||||
|
||||
useSeoMeta({
|
||||
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 groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
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[] = [];
|
||||
|
||||
// Copy text into subsections based on label
|
||||
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)));
|
||||
});
|
||||
|
||||
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",
|
||||
setIngredient: "setIngredient",
|
||||
};
|
||||
|
||||
const contextMenu = [
|
||||
{ title: i18n.t("general.delete"), action: contextActions.delete },
|
||||
{ title: i18n.t("recipe.ingredient"), action: contextActions.setIngredient },
|
||||
];
|
||||
|
||||
function contextMenuAction(action: string, item: ShoppingListItemOut, idx: number) {
|
||||
if (!shoppingList.value?.listItems) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case contextActions.delete:
|
||||
shoppingList.value.listItems = shoppingList.value?.listItems.filter(itm => itm.id !== item.id);
|
||||
break;
|
||||
case contextActions.setIngredient:
|
||||
shoppingList.value.listItems[idx].isFood = !shoppingList.value.listItems[idx].isFood;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================
|
||||
// Labels, Units, Foods
|
||||
// TODO: Extract to Composable
|
||||
|
||||
const localLabels = ref<ShoppingListMultiPurposeLabelOut[]>();
|
||||
// Use the main shopping list page composable
|
||||
const shoppingListPage = useShoppingListPage(id);
|
||||
|
||||
// Get stores for labels, units, and foods
|
||||
const { store: allLabels } = useLabelStore();
|
||||
const { store: allUnits } = useUnitStore();
|
||||
const { store: allFoods } = useFoodStore();
|
||||
|
||||
function getLabelColor(item: ShoppingListItemOut | null) {
|
||||
return item?.label?.color;
|
||||
}
|
||||
|
||||
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.isFood && 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(isFood = false): ShoppingListItemOut {
|
||||
return {
|
||||
id: uuid4(),
|
||||
shoppingListId: id,
|
||||
checked: false,
|
||||
position: shoppingList.value?.listItems?.length || 1,
|
||||
isFood,
|
||||
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(createListItemData.value.isFood || false);
|
||||
refresh();
|
||||
}
|
||||
|
||||
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,
|
||||
contextMenuAction,
|
||||
copyListItems,
|
||||
createEditorOpen,
|
||||
createListItem,
|
||||
createListItemData,
|
||||
deleteChecked,
|
||||
openDeleteChecked,
|
||||
deleteListItem,
|
||||
edit,
|
||||
threeDot,
|
||||
getLabelColor,
|
||||
groupSlug,
|
||||
itemsByLabel,
|
||||
listItems,
|
||||
loadingCounter,
|
||||
preferences,
|
||||
presentLabels,
|
||||
recipeMap,
|
||||
removeRecipeReferenceToList,
|
||||
reorderLabelsDialog,
|
||||
toggleReorderLabelsDialog,
|
||||
localLabels,
|
||||
updateLabelOrder,
|
||||
cancelLabelOrder,
|
||||
saveLabelOrder,
|
||||
saveListItem,
|
||||
shoppingList,
|
||||
showChecked,
|
||||
labelOpenState,
|
||||
toggleShowLabel,
|
||||
toggleShowChecked,
|
||||
uncheckAll,
|
||||
openUncheckAll,
|
||||
checkAll,
|
||||
openCheckAll,
|
||||
updateIndexUncheckedByLabel,
|
||||
allLabels,
|
||||
allUnits,
|
||||
allFoods,
|
||||
getTextColor,
|
||||
isOffline,
|
||||
mdAndUp,
|
||||
...shoppingListPage,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue