1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-08-02 12:05:21 +02:00

Feature: More Shopping List Improvements (#2164)

* added color back to labels

* improved mobile view
refactored layout to use grid
allowed text wrapping on item labels
removed label overflow
added completion date on checked items

* sort checked items by last updated

* made checking an item off more responsive

* optimized moving checked items
removed unnecessary updateAll call
removed jitter when shopping list refreshes
This commit is contained in:
Michael Genson 2023-02-26 13:12:53 -06:00 committed by GitHub
parent 2e6ad5da8e
commit 8ca0fe42de
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 85 additions and 50 deletions

View file

@ -1,6 +1,8 @@
<template> <template>
<v-chip v-bind="$attrs" label :color="label.color || undefined" :text-color="textColor"> <v-chip v-bind="$attrs" label :color="label.color || undefined" :text-color="textColor">
{{ label.name }} <span style="max-width: 100%; overflow: hidden; text-overflow: ellipsis;">
{{ label.name }}
</span>
</v-chip> </v-chip>
</template> </template>

View file

@ -2,7 +2,7 @@
<div class="d-flex justify-space-between align-center mx-2"> <div class="d-flex justify-space-between align-center mx-2">
<div class="handle"> <div class="handle">
<span class="mr-2"> <span class="mr-2">
<v-icon> <v-icon :color="labelColor">
{{ $globals.icons.tags }} {{ $globals.icons.tags }}
</v-icon> </v-icon>
</span> </span>
@ -28,7 +28,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, useContext } from "@nuxtjs/composition-api"; import { defineComponent, ref, useContext } from "@nuxtjs/composition-api";
import { ShoppingListMultiPurposeLabelOut } from "~/lib/api/types/group"; import { ShoppingListMultiPurposeLabelOut } from "~/lib/api/types/group";
interface actions { interface actions {
@ -42,9 +42,15 @@ export default defineComponent({
type: Object as () => ShoppingListMultiPurposeLabelOut, type: Object as () => ShoppingListMultiPurposeLabelOut,
required: true, required: true,
}, },
useColor: {
type: Boolean,
default: false,
}
}, },
setup(props, context) { setup(props, context) {
const { i18n } = useContext(); const { i18n } = useContext();
const labelColor = ref<string | undefined>(props.useColor ? props.value.label.color : undefined);
const contextMenu: actions[] = [ const contextMenu: actions[] = [
{ {
text: i18n.t("general.transfer") as string, text: i18n.t("general.transfer") as string,
@ -59,6 +65,7 @@ export default defineComponent({
return { return {
contextHandler, contextHandler,
contextMenu, contextMenu,
labelColor,
}; };
}, },
}); });

View file

@ -1,43 +1,59 @@
<template> <template>
<div v-if="!edit" class="d-flex justify-space-between align-center"> <v-container v-if="!edit" class="pa-0">
<v-checkbox <v-row no-gutters class="flex-nowrap align-center">
v-model="listItem.checked" <v-col :cols="itemLabelCols">
class="mt-0" <v-checkbox
color="null" v-model="listItem.checked"
hide-details class="mt-0"
dense color="null"
:label="listItem.note" hide-details
@change="$emit('checked', listItem)" dense
> :label="listItem.note"
<template #label> @change="$emit('checked', listItem)"
<div :class="listItem.checked ? 'strike-through' : ''"> >
{{ listItem.display }} <template #label>
</div> <div :class="listItem.checked ? 'strike-through' : ''">
</template> {{ listItem.display }}
</v-checkbox> </div>
<MultiPurposeLabel v-if="label && showLabel" :label="label" class="ml-auto" small /> </template>
<div style="min-width: 72px"> </v-checkbox>
<v-menu offset-x left min-width="125px"> </v-col>
<template #activator="{ on, attrs }"> <v-spacer />
<v-btn small class="ml-2 handle" icon v-bind="attrs" v-on="on"> <v-col v-if="label && showLabel" cols="3" class="text-right">
<MultiPurposeLabel :label="label" small />
</v-col>
<v-col cols="auto" class="text-right">
<div v-if="!listItem.checked" style="min-width: 72px">
<v-menu offset-x left min-width="125px">
<template #activator="{ on, attrs }">
<v-btn small class="ml-2 handle" icon v-bind="attrs" v-on="on">
<v-icon>
{{ $globals.icons.arrowUpDown }}
</v-icon>
</v-btn>
</template>
<v-list dense>
<v-list-item v-for="action in contextMenu" :key="action.event" dense @click="contextHandler(action.event)">
<v-list-item-title>{{ action.text }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-btn small class="ml-2 handle" icon @click="toggleEdit(true)">
<v-icon> <v-icon>
{{ $globals.icons.arrowUpDown }} {{ $globals.icons.edit }}
</v-icon> </v-icon>
</v-btn> </v-btn>
</template> </div>
<v-list dense> </v-col>
<v-list-item v-for="action in contextMenu" :key="action.event" dense @click="contextHandler(action.event)"> </v-row>
<v-list-item-title>{{ action.text }}</v-list-item-title> <v-row v-if="listItem.checked" no-gutters class="mb-2">
</v-list-item> <v-col cols="auto">
</v-list> <div class="text-caption font-weight-light font-italic">
</v-menu> {{ $t("shopping-list.completed-on", {date: new Date(listItem.updateAt+"Z").toLocaleDateString($i18n.locale)}) }}
<v-btn small class="ml-2 handle" icon @click="toggleEdit(true)"> </div>
<v-icon> </v-col>
{{ $globals.icons.edit }} </v-row>
</v-icon> </v-container>
</v-btn>
</div>
</div>
<div v-else class="mb-1 mt-6"> <div v-else class="mb-1 mt-6">
<ShoppingListItemEditor <ShoppingListItemEditor
v-model="localListItem" v-model="localListItem"
@ -91,6 +107,7 @@ export default defineComponent({
}, },
setup(props, context) { setup(props, context) {
const { i18n } = useContext(); const { i18n } = useContext();
const itemLabelCols = ref<string>(props.value.checked ? "auto" : props.showLabel ? "6" : "8");
const contextMenu: actions[] = [ const contextMenu: actions[] = [
{ {
@ -174,6 +191,7 @@ export default defineComponent({
contextHandler, contextHandler,
edit, edit,
contextMenu, contextMenu,
itemLabelCols,
listItem, listItem,
localListItem, localListItem,
label, label,

View file

@ -608,7 +608,8 @@
"check-all-items": "Check All Items", "check-all-items": "Check All Items",
"linked-recipes-count": "No Linked Recipes|One Linked Recipe|{count} Linked Recipes", "linked-recipes-count": "No Linked Recipes|One Linked Recipe|{count} Linked Recipes",
"items-checked-count": "No items checked|One item checked|{count} items checked", "items-checked-count": "No items checked|One item checked|{count} items checked",
"no-label": "No Label" "no-label": "No Label",
"completed-on": "Completed on {date}"
}, },
"sidebar": { "sidebar": {
"all-recipes": "All Recipes", "all-recipes": "All Recipes",

View file

@ -32,7 +32,7 @@
<div v-for="(value, key, idx) in itemsByLabel" :key="key" class="mb-6"> <div v-for="(value, key, idx) in itemsByLabel" :key="key" class="mb-6">
<div @click="toggleShowChecked()"> <div @click="toggleShowChecked()">
<span v-if="idx || key !== $tc('shopping-list.no-label')"> <span v-if="idx || key !== $tc('shopping-list.no-label')">
<v-icon> <v-icon :color="value[0].label.color">
{{ $globals.icons.tags }} {{ $globals.icons.tags }}
</v-icon> </v-icon>
</span> </span>
@ -60,7 +60,7 @@
<v-card height="fit-content" max-height="70vh" style="overflow-y: auto;"> <v-card height="fit-content" max-height="70vh" style="overflow-y: auto;">
<draggable :value="shoppingList.labelSettings" handle=".handle" class="my-2" @start="loadingCounter += 1" @end="loadingCounter -= 1" @input="updateLabelOrder"> <draggable :value="shoppingList.labelSettings" handle=".handle" class="my-2" @start="loadingCounter += 1" @end="loadingCounter -= 1" @input="updateLabelOrder">
<div v-for="(labelSetting, index) in shoppingList.labelSettings" :key="labelSetting.id"> <div v-for="(labelSetting, index) in shoppingList.labelSettings" :key="labelSetting.id">
<MultiPurposeLabelSection v-model="shoppingList.labelSettings[index]" /> <MultiPurposeLabelSection v-model="shoppingList.labelSettings[index]" use-color />
</div> </div>
</draggable> </draggable>
</v-card> </v-card>
@ -319,8 +319,11 @@ export default defineComponent({
const listItems = computed(() => { const listItems = computed(() => {
return { return {
checked: shoppingList.value?.listItems?.filter((item) => item.checked) ?? [],
unchecked: shoppingList.value?.listItems?.filter((item) => !item.checked) ?? [], unchecked: shoppingList.value?.listItems?.filter((item) => !item.checked) ?? [],
checked: shoppingList.value?.listItems
?.filter((item) => item.checked)
.sort((a, b) => (a.updateAt < b.updateAt ? 1 : -1))
?? [],
}; };
}); });
@ -467,9 +470,7 @@ export default defineComponent({
function updateItemsByLabel() { function updateItemsByLabel() {
const items: { [prop: string]: ShoppingListItemOut[] } = {}; const items: { [prop: string]: ShoppingListItemOut[] } = {};
const noLabelText = i18n.tc("shopping-list.no-label"); const noLabelText = i18n.tc("shopping-list.no-label");
const noLabel = [] as ShoppingListItemOut[]; const noLabel = [] as ShoppingListItemOut[];
shoppingList.value?.listItems?.forEach((item) => { shoppingList.value?.listItems?.forEach((item) => {
@ -515,7 +516,7 @@ export default defineComponent({
watch(shoppingList, () => { watch(shoppingList, () => {
updateItemsByLabel(); updateItemsByLabel();
}); }, {deep: true});
async function refreshLabels() { async function refreshLabels() {
const { data } = await userApi.multiPurposeLabels.getAll(); const { data } = await userApi.multiPurposeLabels.getAll();
@ -579,19 +580,25 @@ export default defineComponent({
return; return;
} }
loadingCounter.value += 1;
if (item.checked && shoppingList.value.listItems) { if (item.checked && shoppingList.value.listItems) {
const lst = shoppingList.value.listItems.filter((itm) => itm.id !== item.id); const lst = shoppingList.value.listItems.filter((itm) => itm.id !== item.id);
lst.push(item); lst.push(item);
updateListItems();
// make sure the item is at the end of the list with the other checked items
item.position = shoppingList.value.listItems.length;
// set a temporary updatedAt timestamp so it appears at the top of the checked items in the UI
item.updateAt = new Date().toISOString();
item.updateAt = item.updateAt.substring(0, item.updateAt.length-1);
} }
loadingCounter.value += 1;
const { data } = await userApi.shopping.items.updateOne(item.id, item); const { data } = await userApi.shopping.items.updateOne(item.id, item);
loadingCounter.value -= 1; loadingCounter.value -= 1;
if (data) { if (data) {
refresh(); refresh();
} }
} }
async function deleteListItem(item: ShoppingListItemOut) { async function deleteListItem(item: ShoppingListItemOut) {
@ -694,7 +701,7 @@ export default defineComponent({
} }
// Set Position // Set Position
shoppingList.value.listItems = shoppingList.value.listItems.map((itm: ShoppingListItemOut, idx: number) => { shoppingList.value.listItems = listItems.value.unchecked.concat(listItems.value.checked).map((itm: ShoppingListItemOut, idx: number) => {
itm.position = idx; itm.position = idx;
return itm; return itm;
}); });