mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-08-05 13:35:23 +02:00
Feature: Shopping List Label Section Improvements (#2090)
* added backend for shopping list label config * updated codegen * refactored shopping list ops to service removed unique contraint removed label settings from main route/schema added new route for label settings * codegen * made sure label settings output in position order * implemented submenu for label order drag and drop * removed redundant label and tweaked formatting * added view by label to user preferences * made items draggable within each label section * moved reorder labels to its own button * made dialog scrollable * fixed broken model * refactored labels to use a service moved shopping list label logic to service modified label seeder to use service * added tests * fix for first label missing the tag icon * fixed wrong mapped type * added statement to create existing relationships * fix restore test, maybe
This commit is contained in:
parent
e14851531d
commit
a6c46a7420
22 changed files with 715 additions and 61 deletions
|
@ -0,0 +1,65 @@
|
|||
<template>
|
||||
<div class="d-flex justify-space-between align-center mx-2">
|
||||
<div class="handle">
|
||||
<span class="mr-2">
|
||||
<v-icon>
|
||||
{{ $globals.icons.tags }}
|
||||
</v-icon>
|
||||
</span>
|
||||
{{ value.label.name }}
|
||||
</div>
|
||||
<div style="min-width: 72px" class="ml-auto text-right">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, useContext } from "@nuxtjs/composition-api";
|
||||
import { ShoppingListMultiPurposeLabelOut } from "~/lib/api/types/group";
|
||||
|
||||
interface actions {
|
||||
text: string;
|
||||
event: string;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
value: {
|
||||
type: Object as () => ShoppingListMultiPurposeLabelOut,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props, context) {
|
||||
const { i18n } = useContext();
|
||||
const contextMenu: actions[] = [
|
||||
{
|
||||
text: i18n.t("general.transfer") as string,
|
||||
event: "transfer",
|
||||
},
|
||||
];
|
||||
|
||||
function contextHandler(event: string) {
|
||||
context.emit(event);
|
||||
}
|
||||
|
||||
return {
|
||||
contextHandler,
|
||||
contextMenu,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -2,6 +2,7 @@
|
|||
<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
|
||||
|
@ -14,11 +15,11 @@
|
|||
</div>
|
||||
</template>
|
||||
</v-checkbox>
|
||||
<MultiPurposeLabel v-if="label" :label="label" class="ml-auto mt-2" small />
|
||||
<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 mt-2 handle" icon v-bind="attrs" v-on="on">
|
||||
<v-btn small class="ml-2 handle" icon v-bind="attrs" v-on="on">
|
||||
<v-icon>
|
||||
{{ $globals.icons.arrowUpDown }}
|
||||
</v-icon>
|
||||
|
@ -30,7 +31,7 @@
|
|||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<v-btn small class="ml-2 mt-2 handle" icon @click="toggleEdit(true)">
|
||||
<v-btn small class="ml-2 handle" icon @click="toggleEdit(true)">
|
||||
<v-icon>
|
||||
{{ $globals.icons.edit }}
|
||||
</v-icon>
|
||||
|
@ -71,6 +72,10 @@ export default defineComponent({
|
|||
type: Object as () => ShoppingListItemOut,
|
||||
required: true,
|
||||
},
|
||||
showLabel: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
labels: {
|
||||
type: Array as () => MultiPurposeLabelOut[],
|
||||
required: true,
|
||||
|
|
|
@ -21,6 +21,10 @@ export interface UserRecipePreferences {
|
|||
useMobileCards: boolean;
|
||||
}
|
||||
|
||||
export interface UserShoppingListPreferences {
|
||||
viewByLabel: boolean;
|
||||
}
|
||||
|
||||
export function useUserPrintPreferences(): Ref<UserPrintPreferences> {
|
||||
const fromStorage = useLocalStorage(
|
||||
"recipe-print-preferences",
|
||||
|
@ -56,3 +60,18 @@ export function useUserSortPreferences(): Ref<UserRecipePreferences> {
|
|||
|
||||
return fromStorage;
|
||||
}
|
||||
|
||||
|
||||
export function useShoppingListPreferences(): Ref<UserShoppingListPreferences> {
|
||||
const fromStorage = useLocalStorage(
|
||||
"shopping-list-preferences",
|
||||
{
|
||||
viewByLabel: false,
|
||||
},
|
||||
{ mergeDefaults: true }
|
||||
// we cast to a Ref because by default it will return an optional type ref
|
||||
// but since we pass defaults we know all properties are set.
|
||||
) as unknown as Ref<UserShoppingListPreferences>;
|
||||
|
||||
return fromStorage;
|
||||
}
|
||||
|
|
|
@ -603,6 +603,7 @@
|
|||
"copy-as-markdown": "Copy as Markdown",
|
||||
"delete-checked": "Delete Checked",
|
||||
"toggle-label-sort": "Toggle Label Sort",
|
||||
"reorder-labels": "Reorder Labels",
|
||||
"uncheck-all-items": "Uncheck All Items",
|
||||
"check-all-items": "Check All Items",
|
||||
"linked-recipes-count": "No Linked Recipes|One Linked Recipe|{count} Linked Recipes",
|
||||
|
|
|
@ -399,6 +399,24 @@ export interface ShoppingListItemsCollectionOut {
|
|||
updatedItems?: ShoppingListItemOut[];
|
||||
deletedItems?: ShoppingListItemOut[];
|
||||
}
|
||||
export interface ShoppingListMultiPurposeLabelCreate {
|
||||
shoppingListId: string;
|
||||
labelId: string;
|
||||
position?: number;
|
||||
}
|
||||
export interface ShoppingListMultiPurposeLabelOut {
|
||||
shoppingListId: string;
|
||||
labelId: string;
|
||||
position?: number;
|
||||
id: string;
|
||||
label: MultiPurposeLabelSummary;
|
||||
}
|
||||
export interface ShoppingListMultiPurposeLabelUpdate {
|
||||
shoppingListId: string;
|
||||
labelId: string;
|
||||
position?: number;
|
||||
id: string;
|
||||
}
|
||||
export interface ShoppingListOut {
|
||||
name?: string;
|
||||
extras?: {
|
||||
|
@ -410,6 +428,7 @@ export interface ShoppingListOut {
|
|||
id: string;
|
||||
listItems?: ShoppingListItemOut[];
|
||||
recipeReferences: ShoppingListRecipeRefOut[];
|
||||
labelSettings: ShoppingListMultiPurposeLabelOut[];
|
||||
}
|
||||
export interface ShoppingListRecipeRefOut {
|
||||
id: string;
|
||||
|
@ -479,6 +498,8 @@ export interface ShoppingListSummary {
|
|||
updateAt?: string;
|
||||
groupId: string;
|
||||
id: string;
|
||||
recipeReferences: ShoppingListRecipeRefOut[];
|
||||
labelSettings: ShoppingListMultiPurposeLabelOut[];
|
||||
}
|
||||
export interface ShoppingListUpdate {
|
||||
name?: string;
|
||||
|
|
|
@ -6,6 +6,7 @@ import {
|
|||
ShoppingListItemCreate,
|
||||
ShoppingListItemOut,
|
||||
ShoppingListItemUpdateBulk,
|
||||
ShoppingListMultiPurposeLabelUpdate,
|
||||
ShoppingListOut,
|
||||
ShoppingListUpdate,
|
||||
} from "~/lib/api/types/group";
|
||||
|
@ -17,6 +18,7 @@ const routes = {
|
|||
shoppingListsId: (id: string) => `${prefix}/groups/shopping/lists/${id}`,
|
||||
shoppingListIdAddRecipe: (id: string, recipeId: string) => `${prefix}/groups/shopping/lists/${id}/recipe/${recipeId}`,
|
||||
shoppingListIdRemoveRecipe: (id: string, recipeId: string) => `${prefix}/groups/shopping/lists/${id}/recipe/${recipeId}/delete`,
|
||||
shoppingListIdUpdateLabelSettings: (id: string) => `${prefix}/groups/shopping/lists/${id}/label-settings`,
|
||||
|
||||
shoppingListItems: `${prefix}/groups/shopping/items`,
|
||||
shoppingListItemsId: (id: string) => `${prefix}/groups/shopping/items/${id}`,
|
||||
|
@ -33,6 +35,10 @@ export class ShoppingListsApi extends BaseCRUDAPI<ShoppingListCreate, ShoppingLi
|
|||
async removeRecipe(itemId: string, recipeId: string, recipeDecrementQuantity = 1) {
|
||||
return await this.requests.post(routes.shoppingListIdRemoveRecipe(itemId, recipeId), { recipeDecrementQuantity });
|
||||
}
|
||||
|
||||
async updateLabelSettings(itemId: string, listSettings: ShoppingListMultiPurposeLabelUpdate[]) {
|
||||
return await this.requests.put(routes.shoppingListIdUpdateLabelSettings(itemId), listSettings);
|
||||
}
|
||||
}
|
||||
|
||||
export class ShoppingListItemsApi extends BaseCRUDAPI<
|
||||
|
|
|
@ -8,6 +8,7 @@ import {
|
|||
mdiContentSaveEdit,
|
||||
mdiSquareEditOutline,
|
||||
mdiClose,
|
||||
mdiTagArrowUpOutline,
|
||||
mdiTagMultipleOutline,
|
||||
mdiBookOutline,
|
||||
mdiAccountCog,
|
||||
|
@ -268,6 +269,7 @@ export const icons = {
|
|||
|
||||
// Organization
|
||||
tags: mdiTagMultipleOutline,
|
||||
tagArrowUp: mdiTagArrowUpOutline,
|
||||
pages: mdiBookOutline,
|
||||
|
||||
// Admin
|
||||
|
|
|
@ -9,12 +9,13 @@
|
|||
|
||||
<!-- Viewer -->
|
||||
<section v-if="!edit" class="py-2">
|
||||
<div v-if="!byLabel">
|
||||
<div v-if="!preferences.viewByLabel">
|
||||
<draggable :value="listItems.unchecked" handle=".handle" @start="loadingCounter += 1" @end="loadingCounter -= 1" @input="updateIndexUnchecked">
|
||||
<v-lazy v-for="(item, index) in listItems.unchecked" :key="item.id">
|
||||
<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 || []"
|
||||
|
@ -28,29 +29,43 @@
|
|||
|
||||
<!-- View By Label -->
|
||||
<div v-else>
|
||||
<div v-for="(value, key) in itemsByLabel" :key="key" class="mb-6">
|
||||
<div v-for="(value, key, idx) in itemsByLabel" :key="key" class="mb-6">
|
||||
<div @click="toggleShowChecked()">
|
||||
<span>
|
||||
<span v-if="idx || key !== $tc('shopping-list.no-label')">
|
||||
<v-icon>
|
||||
{{ $globals.icons.tags }}
|
||||
</v-icon>
|
||||
</span>
|
||||
{{ key }}
|
||||
</div>
|
||||
<v-lazy v-for="(item, index) in value" :key="item.id">
|
||||
<ShoppingListItem
|
||||
v-model="value[index]"
|
||||
:labels="allLabels || []"
|
||||
:units="allUnits || []"
|
||||
:foods="allFoods || []"
|
||||
@checked="saveListItem"
|
||||
@save="saveListItem"
|
||||
@delete="deleteListItem(item)"
|
||||
/>
|
||||
</v-lazy>
|
||||
<draggable :value="value" handle=".handle" @start="loadingCounter += 1" @end="loadingCounter -= 1" @input="updateIndexUncheckedByLabel(key, $event)">
|
||||
<v-lazy v-for="(item, index) in value" :key="item.id" class="ml-2 my-2">
|
||||
<ShoppingListItem
|
||||
v-model="value[index]"
|
||||
:show-label=false
|
||||
:labels="allLabels || []"
|
||||
:units="allUnits || []"
|
||||
:foods="allFoods || []"
|
||||
@checked="saveListItem"
|
||||
@save="saveListItem"
|
||||
@delete="deleteListItem(item)"
|
||||
/>
|
||||
</v-lazy>
|
||||
</draggable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reorder Labels -->
|
||||
<BaseDialog v-model="reorderLabelsDialog" :icon="$globals.icons.tagArrowUp" :title="$t('shopping-list.reorder-labels')">
|
||||
<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]" />
|
||||
</div>
|
||||
</draggable>
|
||||
</v-card>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Create Item -->
|
||||
<div v-if="createEditorOpen">
|
||||
<ShoppingListItemEditor
|
||||
|
@ -65,6 +80,10 @@
|
|||
/>
|
||||
</div>
|
||||
<div v-else class="mt-4 d-flex justify-end">
|
||||
<BaseButton v-if="preferences.viewByLabel" color="info" class="mr-2" @click="reorderLabelsDialog = true">
|
||||
<template #icon> {{ $globals.icons.tags }} </template>
|
||||
{{ $t('shopping-list.reorder-labels') }}
|
||||
</BaseButton>
|
||||
<BaseButton create @click="createEditorOpen = true" />
|
||||
</div>
|
||||
|
||||
|
@ -192,11 +211,13 @@ import { useIdle, useToggle } from "@vueuse/core";
|
|||
import { useCopyList } from "~/composables/use-copy";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { useAsyncKey } from "~/composables/use-utils";
|
||||
import MultiPurposeLabelSection from "~/components/Domain/ShoppingList/MultiPurposeLabelSection.vue"
|
||||
import ShoppingListItem from "~/components/Domain/ShoppingList/ShoppingListItem.vue";
|
||||
import { ShoppingListItemCreate, ShoppingListItemOut } from "~/lib/api/types/group";
|
||||
import { ShoppingListItemCreate, ShoppingListItemOut, ShoppingListMultiPurposeLabelOut } from "~/lib/api/types/group";
|
||||
import RecipeList from "~/components/Domain/Recipe/RecipeList.vue";
|
||||
import ShoppingListItemEditor from "~/components/Domain/ShoppingList/ShoppingListItemEditor.vue";
|
||||
import { useFoodStore, useLabelStore, useUnitStore } from "~/composables/store";
|
||||
import { useShoppingListPreferences } from "~/composables/use-users/preferences";
|
||||
|
||||
type CopyTypes = "plain" | "markdown";
|
||||
|
||||
|
@ -208,18 +229,21 @@ interface PresentLabel {
|
|||
export default defineComponent({
|
||||
components: {
|
||||
draggable,
|
||||
MultiPurposeLabelSection,
|
||||
ShoppingListItem,
|
||||
RecipeList,
|
||||
ShoppingListItemEditor,
|
||||
},
|
||||
setup() {
|
||||
const preferences = useShoppingListPreferences();
|
||||
|
||||
const { idle } = useIdle(5 * 60 * 1000) // 5 minutes
|
||||
const loadingCounter = ref(1);
|
||||
const recipeReferenceLoading = ref(false);
|
||||
const userApi = useUserApi();
|
||||
|
||||
const edit = ref(false);
|
||||
const byLabel = ref(false);
|
||||
const reorderLabelsDialog = ref(false);
|
||||
|
||||
const route = useRoute();
|
||||
const id = route.value.params.id;
|
||||
|
@ -395,7 +419,33 @@ export default defineComponent({
|
|||
const { foods: allFoods } = useFoodStore();
|
||||
|
||||
function sortByLabels() {
|
||||
byLabel.value = !byLabel.value;
|
||||
preferences.value.viewByLabel = !preferences.value.viewByLabel;
|
||||
}
|
||||
|
||||
function toggleReorderLabelsDialog() {
|
||||
reorderLabelsDialog.value = !reorderLabelsDialog.value
|
||||
}
|
||||
|
||||
async function updateLabelOrder(labelSettings: ShoppingListMultiPurposeLabelOut[]) {
|
||||
if (!shoppingList.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
labelSettings.forEach((labelSetting, index) => {
|
||||
labelSetting.position = index;
|
||||
return labelSetting;
|
||||
});
|
||||
|
||||
// setting this doesn't have any effect on the data since it's refreshed automatically, but it makes the ux feel smoother
|
||||
shoppingList.value.labelSettings = labelSettings;
|
||||
|
||||
loadingCounter.value += 1;
|
||||
const { data } = await userApi.shopping.lists.updateLabelSettings(shoppingList.value.id, labelSettings);
|
||||
loadingCounter.value -= 1;
|
||||
|
||||
if (data) {
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
const presentLabels = computed(() => {
|
||||
|
@ -442,7 +492,25 @@ export default defineComponent({
|
|||
items[noLabelText] = noLabel;
|
||||
}
|
||||
|
||||
itemsByLabel.value = items;
|
||||
// sort the map by label order
|
||||
const orderedLabelNames = shoppingList.value?.labelSettings?.map((labelSetting) => { return 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;
|
||||
}
|
||||
|
||||
watch(shoppingList, () => {
|
||||
|
@ -588,6 +656,24 @@ export default defineComponent({
|
|||
updateListItems();
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
// save changes
|
||||
return updateIndexUnchecked(allUncheckedItems);
|
||||
}
|
||||
|
||||
async function deleteListItems(items: ShoppingListItemOut[]) {
|
||||
if (!shoppingList.value) {
|
||||
return;
|
||||
|
@ -626,7 +712,6 @@ export default defineComponent({
|
|||
addRecipeReferenceToList,
|
||||
updateListItems,
|
||||
allLabels,
|
||||
byLabel,
|
||||
contextMenu,
|
||||
contextMenuAction,
|
||||
copyListItems,
|
||||
|
@ -640,8 +725,12 @@ export default defineComponent({
|
|||
listItems,
|
||||
listRecipes,
|
||||
loadingCounter,
|
||||
preferences,
|
||||
presentLabels,
|
||||
removeRecipeReferenceToList,
|
||||
reorderLabelsDialog,
|
||||
toggleReorderLabelsDialog,
|
||||
updateLabelOrder,
|
||||
saveListItem,
|
||||
shoppingList,
|
||||
showChecked,
|
||||
|
@ -649,6 +738,7 @@ export default defineComponent({
|
|||
toggleShowChecked,
|
||||
uncheckAll,
|
||||
updateIndexUnchecked,
|
||||
updateIndexUncheckedByLabel,
|
||||
allUnits,
|
||||
allFoods,
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue