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>
<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>
</template>

View File

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

View File

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

View File

@ -608,7 +608,8 @@
"check-all-items": "Check All Items",
"linked-recipes-count": "No Linked Recipes|One Linked Recipe|{count} Linked Recipes",
"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": {
"all-recipes": "All Recipes",

View File

@ -32,7 +32,7 @@
<div v-for="(value, key, idx) in itemsByLabel" :key="key" class="mb-6">
<div @click="toggleShowChecked()">
<span v-if="idx || key !== $tc('shopping-list.no-label')">
<v-icon>
<v-icon :color="value[0].label.color">
{{ $globals.icons.tags }}
</v-icon>
</span>
@ -60,7 +60,7 @@
<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">
<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>
</draggable>
</v-card>
@ -319,8 +319,11 @@ export default defineComponent({
const listItems = computed(() => {
return {
checked: 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() {
const items: { [prop: string]: ShoppingListItemOut[] } = {};
const noLabelText = i18n.tc("shopping-list.no-label");
const noLabel = [] as ShoppingListItemOut[];
shoppingList.value?.listItems?.forEach((item) => {
@ -515,7 +516,7 @@ export default defineComponent({
watch(shoppingList, () => {
updateItemsByLabel();
});
}, {deep: true});
async function refreshLabels() {
const { data } = await userApi.multiPurposeLabels.getAll();
@ -579,19 +580,25 @@ export default defineComponent({
return;
}
loadingCounter.value += 1;
if (item.checked && shoppingList.value.listItems) {
const lst = shoppingList.value.listItems.filter((itm) => itm.id !== item.id);
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);
loadingCounter.value -= 1;
if (data) {
refresh();
}
}
}
async function deleteListItem(item: ShoppingListItemOut) {
@ -694,7 +701,7 @@ export default defineComponent({
}
// 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;
return itm;
});