mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-05-06 18:13:31 -04:00
feat: Improve add shopping list item form (#7091)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com> Co-authored-by: Michael Genson <genson.michael@gmail.com>
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<v-navigation-drawer
|
||||
permanent
|
||||
rounded="t-xl"
|
||||
location="bottom"
|
||||
class="pa-4 pt-2 mb-0"
|
||||
width="300"
|
||||
rail-width="85"
|
||||
:rail="rail"
|
||||
elevation="4"
|
||||
>
|
||||
<div class="d-flex flex-column ga-3">
|
||||
<v-card-actions class="pa-0">
|
||||
<InputLabelType
|
||||
v-model="listItem.food"
|
||||
v-model:item-id="listItem.foodId!"
|
||||
:items="foods"
|
||||
:label="rail ? $t('shopping-list.add-item') : $t('shopping-list.food')"
|
||||
:icon="$globals.icons.foods"
|
||||
:style="rail ? 'margin-inline: 3px;' : undefined"
|
||||
:search="rail"
|
||||
create
|
||||
@create="createAssignFood"
|
||||
@focus="rail = false"
|
||||
/>
|
||||
<BaseButtonGroup
|
||||
v-if="!rail"
|
||||
:buttons="[
|
||||
{
|
||||
icon: $globals.icons.close,
|
||||
text: $t('general.cancel'),
|
||||
event: 'cancel',
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.save,
|
||||
text: $t('general.save'),
|
||||
event: 'save',
|
||||
},
|
||||
]"
|
||||
@save="$emit('save')"
|
||||
@cancel="rail = true; $emit('cancel')"
|
||||
/>
|
||||
</v-card-actions>
|
||||
|
||||
<ShoppingListItemDetails
|
||||
v-if="!rail"
|
||||
v-model="listItem"
|
||||
:labels="labels"
|
||||
:units="units"
|
||||
@save="$emit('save')"
|
||||
/>
|
||||
</div>
|
||||
</v-navigation-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useShoppingListItemEditor } from "~/composables/shopping-list-page/use-shopping-list-item-editor";
|
||||
import type { ShoppingListItemCreate, ShoppingListItemOut } from "~/lib/api/types/household";
|
||||
import type { MultiPurposeLabelOut } from "~/lib/api/types/labels";
|
||||
import type { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
|
||||
import ShoppingListItemDetails from "./ShoppingListItemDetails.vue";
|
||||
|
||||
// modelValue as reactive v-model
|
||||
const listItem = defineModel<ShoppingListItemCreate | ShoppingListItemOut>({ required: true });
|
||||
|
||||
defineProps({
|
||||
labels: {
|
||||
type: Array as () => MultiPurposeLabelOut[],
|
||||
required: true,
|
||||
},
|
||||
units: {
|
||||
type: Array as () => IngredientUnit[],
|
||||
required: true,
|
||||
},
|
||||
foods: {
|
||||
type: Array as () => IngredientFood[],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits<{
|
||||
(e: "save" | "cancel" | "delete"): void;
|
||||
}>();
|
||||
|
||||
const { createAssignFood } = useShoppingListItemEditor(listItem);
|
||||
|
||||
watch(
|
||||
() => listItem.value.quantity,
|
||||
(newQty) => {
|
||||
if (!newQty) {
|
||||
listItem.value.quantity = 0;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => listItem.value.food,
|
||||
(newFood) => {
|
||||
listItem.value.label = newFood?.label || null;
|
||||
listItem.value.labelId = listItem.value.label?.id || null;
|
||||
},
|
||||
);
|
||||
|
||||
const rail = ref(true);
|
||||
</script>
|
||||
@@ -145,6 +145,7 @@
|
||||
:labels="labels"
|
||||
:units="units"
|
||||
:foods="foods"
|
||||
class="ma-2"
|
||||
@save="save"
|
||||
@cancel="toggleEdit(false)"
|
||||
@delete="$emit('delete')"
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div class="d-flex ga-3">
|
||||
<v-number-input
|
||||
v-model="listItem.quantity"
|
||||
hide-details
|
||||
:label="$t('form.quantity-label-abbreviated')"
|
||||
:min="0"
|
||||
:precision="null"
|
||||
control-variant="stacked"
|
||||
style="flex: 1"
|
||||
inset
|
||||
/>
|
||||
<InputLabelType
|
||||
v-model="listItem.unit"
|
||||
v-model:item-id="listItem.unitId!"
|
||||
:items="units"
|
||||
:label="$t('recipe.unit')"
|
||||
:icon="$globals.icons.units"
|
||||
style="flex: 3"
|
||||
create
|
||||
@create="createAssignUnit"
|
||||
/>
|
||||
</div>
|
||||
<v-textarea
|
||||
v-model="listItem.note"
|
||||
hide-details
|
||||
:label="$t('shopping-list.note')"
|
||||
rows="1"
|
||||
auto-grow
|
||||
@keypress="handleNoteKeyPress"
|
||||
/>
|
||||
<div class="d-flex flex-wrap align-end ga-3">
|
||||
<InputLabelType
|
||||
v-model="listItem.label"
|
||||
v-model:item-id="listItem.labelId!"
|
||||
:items="labels"
|
||||
:label="$t('shopping-list.label')"
|
||||
style="flex: 1 0 200px"
|
||||
/>
|
||||
<BaseButton
|
||||
v-if="listItem.labelId && listItem.food && listItem.labelId !== listItem.food.labelId"
|
||||
small
|
||||
color="info"
|
||||
:icon="$globals.icons.tagArrowRight"
|
||||
:text="$t('shopping-list.save-label')"
|
||||
class="mt-2 align-items-flex-start"
|
||||
style="flex-grow: 0"
|
||||
@click="assignLabelToFood"
|
||||
/>
|
||||
<v-spacer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useShoppingListItemEditor } from "~/composables/shopping-list-page/use-shopping-list-item-editor";
|
||||
import type { ShoppingListItemCreate, ShoppingListItemOut } from "~/lib/api/types/household";
|
||||
import type { MultiPurposeLabelOut } from "~/lib/api/types/labels";
|
||||
import type { IngredientUnit } from "~/lib/api/types/recipe";
|
||||
|
||||
// modelValue as reactive v-model
|
||||
const listItem = defineModel<ShoppingListItemCreate | ShoppingListItemOut>({ required: true });
|
||||
|
||||
defineProps({
|
||||
labels: {
|
||||
type: Array as () => MultiPurposeLabelOut[],
|
||||
required: true,
|
||||
},
|
||||
units: {
|
||||
type: Array as () => IngredientUnit[],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ (e: "save"): void }>();
|
||||
|
||||
const { assignLabelToFood, createAssignUnit } = useShoppingListItemEditor(listItem);
|
||||
|
||||
function handleNoteKeyPress(event: KeyboardEvent) {
|
||||
// Save on Enter
|
||||
if (!event.shiftKey && event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
emit("save");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,112 +1,60 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-card variant="outlined">
|
||||
<v-card-text class="pb-3 pt-1">
|
||||
<div class="d-md-flex align-center mb-2" style="gap: 20px">
|
||||
<div>
|
||||
<v-number-input
|
||||
v-model="listItem.quantity"
|
||||
hide-details
|
||||
:label="$t('form.quantity-label-abbreviated')"
|
||||
:min="0"
|
||||
:precision="null"
|
||||
control-variant="stacked"
|
||||
inset
|
||||
style="width: 100px;"
|
||||
/>
|
||||
</div>
|
||||
<InputLabelType
|
||||
v-model="listItem.unit"
|
||||
v-model:item-id="listItem.unitId!"
|
||||
:items="units"
|
||||
:label="$t('recipe.unit')"
|
||||
:icon="$globals.icons.units"
|
||||
create
|
||||
@create="createAssignUnit"
|
||||
/>
|
||||
<InputLabelType
|
||||
v-model="listItem.food"
|
||||
v-model:item-id="listItem.foodId!"
|
||||
:items="foods"
|
||||
:label="$t('shopping-list.food')"
|
||||
:icon="$globals.icons.foods"
|
||||
:autofocus="autoFocus === 'food'"
|
||||
create
|
||||
@create="createAssignFood"
|
||||
/>
|
||||
</div>
|
||||
<div class="d-md-flex align-center" style="gap: 20px">
|
||||
<v-textarea
|
||||
v-model="listItem.note"
|
||||
hide-details
|
||||
:label="$t('shopping-list.note')"
|
||||
rows="1"
|
||||
auto-grow
|
||||
:autofocus="autoFocus === 'note'"
|
||||
@keypress="handleNoteKeyPress"
|
||||
/>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap align-end" style="gap: 20px">
|
||||
<div class="d-flex align-end">
|
||||
<div style="max-width: 300px" class="mt-3 mr-auto">
|
||||
<InputLabelType
|
||||
v-model="listItem.label"
|
||||
v-model:item-id="listItem.labelId!"
|
||||
:items="labels"
|
||||
:label="$t('shopping-list.label')"
|
||||
width="250"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<BaseButton
|
||||
v-if="listItem.labelId && listItem.food && listItem.labelId !== listItem.food.labelId"
|
||||
small
|
||||
color="info"
|
||||
:icon="$globals.icons.tagArrowRight"
|
||||
:text="$t('shopping-list.save-label')"
|
||||
class="mt-2 align-items-flex-start"
|
||||
@click="assignLabelToFood"
|
||||
/>
|
||||
<v-spacer />
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions class="ma-0 pt-0 pb-1 justify-end">
|
||||
<BaseButtonGroup
|
||||
:buttons="[
|
||||
...(allowDelete
|
||||
? [
|
||||
{
|
||||
icon: $globals.icons.delete,
|
||||
text: $t('general.delete'),
|
||||
event: 'delete',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
icon: $globals.icons.close,
|
||||
text: $t('general.cancel'),
|
||||
event: 'cancel',
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.save,
|
||||
text: $t('general.save'),
|
||||
event: 'save',
|
||||
},
|
||||
]"
|
||||
@save="$emit('save')"
|
||||
@cancel="$emit('cancel')"
|
||||
@delete="$emit('delete')"
|
||||
/>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</div>
|
||||
<v-card variant="elevated" class="pa-2" border="primary s-lg opacity-100">
|
||||
<div class="d-flex flex-column ga-3">
|
||||
<InputLabelType
|
||||
v-model="listItem.food"
|
||||
v-model:item-id="listItem.foodId!"
|
||||
:items="foods"
|
||||
:label="$t('shopping-list.food')"
|
||||
:icon="$globals.icons.foods"
|
||||
:autofocus="autoFocus === 'food'"
|
||||
create
|
||||
@create="createAssignFood"
|
||||
/>
|
||||
<ShoppingListItemDetails
|
||||
v-model="listItem"
|
||||
:labels="labels"
|
||||
:units="units"
|
||||
@save="$emit('save')"
|
||||
/>
|
||||
</div>
|
||||
<v-card-actions class="justify-end pa-0">
|
||||
<BaseButtonGroup
|
||||
:buttons="[
|
||||
...(allowDelete
|
||||
? [
|
||||
{
|
||||
icon: $globals.icons.delete,
|
||||
text: $t('general.delete'),
|
||||
event: 'delete',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
icon: $globals.icons.close,
|
||||
text: $t('general.cancel'),
|
||||
event: 'cancel',
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.save,
|
||||
text: $t('general.save'),
|
||||
event: 'save',
|
||||
},
|
||||
]"
|
||||
@save="$emit('save')"
|
||||
@cancel="$emit('cancel')"
|
||||
@delete="$emit('delete')"
|
||||
/>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useShoppingListItemEditor } from "~/composables/shopping-list-page/use-shopping-list-item-editor";
|
||||
import type { ShoppingListItemCreate, ShoppingListItemOut } from "~/lib/api/types/household";
|
||||
import type { MultiPurposeLabelOut } from "~/lib/api/types/labels";
|
||||
import type { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
|
||||
import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store";
|
||||
import ShoppingListItemDetails from "./ShoppingListItemDetails.vue";
|
||||
|
||||
// modelValue as reactive v-model
|
||||
const listItem = defineModel<ShoppingListItemCreate | ShoppingListItemOut>({ required: true });
|
||||
@@ -132,16 +80,11 @@ defineProps({
|
||||
});
|
||||
|
||||
// const emit = defineEmits<["save", "cancel", "delete"]>();
|
||||
const emit = defineEmits<{
|
||||
(e: "save", item: ShoppingListItemOut): void;
|
||||
(e: "cancel" | "delete"): void;
|
||||
defineEmits<{
|
||||
(e: "save" | "cancel" | "delete"): void;
|
||||
}>();
|
||||
|
||||
const foodStore = useFoodStore();
|
||||
const foodData = useFoodData();
|
||||
|
||||
const unitStore = useUnitStore();
|
||||
const unitData = useUnitData();
|
||||
const { createAssignFood } = useShoppingListItemEditor(listItem);
|
||||
|
||||
watch(
|
||||
() => listItem.value.quantity,
|
||||
@@ -161,49 +104,4 @@ watch(
|
||||
);
|
||||
|
||||
const autoFocus = computed(() => (!listItem.value.food && listItem.value.note ? "note" : "food"));
|
||||
|
||||
async function createAssignFood(val: string) {
|
||||
// keep UI reactive
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
listItem.value.food ? (listItem.value.food.name = val) : (listItem.value.food = { name: val } as any);
|
||||
|
||||
foodData.data.name = val;
|
||||
const newFood = await foodStore.actions.createOne(foodData.data);
|
||||
if (newFood) {
|
||||
listItem.value.food = newFood;
|
||||
listItem.value.foodId = newFood.id;
|
||||
}
|
||||
foodData.reset();
|
||||
}
|
||||
|
||||
async function createAssignUnit(val: string) {
|
||||
// keep UI reactive
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
listItem.value.unit ? (listItem.value.unit.name = val) : (listItem.value.unit = { name: val } as any);
|
||||
|
||||
unitData.data.name = val;
|
||||
const newUnit = await unitStore.actions.createOne(unitData.data);
|
||||
if (newUnit) {
|
||||
listItem.value.unit = newUnit;
|
||||
listItem.value.unitId = newUnit.id;
|
||||
}
|
||||
unitData.reset();
|
||||
}
|
||||
|
||||
async function assignLabelToFood() {
|
||||
if (!(listItem.value.food && listItem.value.foodId && listItem.value.labelId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
listItem.value.food.labelId = listItem.value.labelId;
|
||||
await foodStore.actions.updateOne(listItem.value.food);
|
||||
}
|
||||
|
||||
function handleNoteKeyPress(event: KeyboardEvent) {
|
||||
const e = event as KeyboardEvent & { key: string; shiftKey: boolean };
|
||||
if (!e.shiftKey && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
emit("save");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -7,12 +7,15 @@
|
||||
item-title="name"
|
||||
return-object
|
||||
:items="filteredItems"
|
||||
:prepend-icon="icon || $globals.icons.tags"
|
||||
:prepend-inner-icon="icon || (search ? $globals.icons.search : $globals.icons.tags)"
|
||||
:menu-icon="search ? '' : undefined"
|
||||
:rounded="search ? true : '4px'"
|
||||
:custom-filter="() => true"
|
||||
:variant="search ? 'solo-filled' : undefined"
|
||||
color="primary"
|
||||
auto-select-first
|
||||
clearable
|
||||
color="primary"
|
||||
hide-details
|
||||
:custom-filter="() => true"
|
||||
@keyup.enter="emitCreate"
|
||||
>
|
||||
<template
|
||||
@@ -55,6 +58,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
search: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { ModelRef } from "vue";
|
||||
import type { ShoppingListItemOut, ShoppingListItemCreate } from "~/lib/api/types/household";
|
||||
import { useFoodData, useFoodStore, useUnitData, useUnitStore } from "../store";
|
||||
|
||||
export function useShoppingListItemEditor(listItem: ModelRef<ShoppingListItemOut | ShoppingListItemCreate, string, ShoppingListItemOut | ShoppingListItemCreate, ShoppingListItemOut | ShoppingListItemCreate>) {
|
||||
const foodStore = useFoodStore();
|
||||
const foodData = useFoodData();
|
||||
|
||||
const unitStore = useUnitStore();
|
||||
const unitData = useUnitData();
|
||||
|
||||
async function createAssignFood(val: string) {
|
||||
// keep UI reactive
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
listItem.value.food ? (listItem.value.food.name = val) : (listItem.value.food = { name: val } as any);
|
||||
|
||||
foodData.data.name = val;
|
||||
const newFood = await foodStore.actions.createOne(foodData.data);
|
||||
if (newFood) {
|
||||
listItem.value.food = newFood;
|
||||
listItem.value.foodId = newFood.id;
|
||||
}
|
||||
foodData.reset();
|
||||
}
|
||||
|
||||
async function createAssignUnit(val: string) {
|
||||
// keep UI reactive
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
listItem.value.unit ? (listItem.value.unit.name = val) : (listItem.value.unit = { name: val } as any);
|
||||
|
||||
unitData.data.name = val;
|
||||
const newUnit = await unitStore.actions.createOne(unitData.data);
|
||||
if (newUnit) {
|
||||
listItem.value.unit = newUnit;
|
||||
listItem.value.unitId = newUnit.id;
|
||||
}
|
||||
unitData.reset();
|
||||
}
|
||||
|
||||
async function assignLabelToFood() {
|
||||
if (!(listItem.value.food && listItem.value.foodId && listItem.value.labelId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
listItem.value.food.labelId = listItem.value.labelId;
|
||||
await foodStore.actions.updateOne(listItem.value.food);
|
||||
}
|
||||
|
||||
return {
|
||||
assignLabelToFood,
|
||||
createAssignFood,
|
||||
createAssignUnit,
|
||||
};
|
||||
}
|
||||
@@ -916,6 +916,7 @@
|
||||
"quantity": "Quantity: {0}",
|
||||
"shopping-list": "Shopping List",
|
||||
"shopping-lists": "Shopping Lists",
|
||||
"add-item": "Add item",
|
||||
"food": "Food",
|
||||
"note": "Note",
|
||||
"label": "Label",
|
||||
|
||||
@@ -160,8 +160,20 @@
|
||||
<!-- Viewer -->
|
||||
<section v-if="!edit" class="py-2 d-flex flex-column ga-4">
|
||||
<!-- Create Item -->
|
||||
<div v-if="createEditorOpen">
|
||||
<ShoppingListAddItemForm
|
||||
v-if="$vuetify.display.smAndDown"
|
||||
v-model="createListItemData"
|
||||
class="my-4"
|
||||
:labels="allLabels || []"
|
||||
:units="allUnits || []"
|
||||
:foods="allFoods || []"
|
||||
@cancel="createEditorOpen = false"
|
||||
@save="createListItem"
|
||||
/>
|
||||
|
||||
<div v-else>
|
||||
<ShoppingListItemEditor
|
||||
v-if="createEditorOpen"
|
||||
v-model="createListItemData"
|
||||
class="my-4"
|
||||
:labels="allLabels || []"
|
||||
@@ -172,14 +184,14 @@
|
||||
@cancel="createEditorOpen = false"
|
||||
@save="createListItem"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="d-flex justify-end">
|
||||
<BaseButton
|
||||
create
|
||||
@click="createEditorOpen = true"
|
||||
>
|
||||
{{ $t('general.add') }}
|
||||
</BaseButton>
|
||||
<InputLabelType
|
||||
v-else
|
||||
:items="allFoods"
|
||||
:label="$t('shopping-list.add-item')"
|
||||
:icon="$globals.icons.foods"
|
||||
search
|
||||
@focus="createEditorOpen = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TransitionGroup name="scroll-x-transition">
|
||||
@@ -338,6 +350,7 @@
|
||||
import { VueDraggable } from "vue-draggable-plus";
|
||||
import RecipeList from "~/components/Domain/Recipe/RecipeList.vue";
|
||||
import MultiPurposeLabelSection from "~/components/Domain/ShoppingList/MultiPurposeLabelSection.vue";
|
||||
import ShoppingListAddItemForm from "~/components/Domain/ShoppingList/ShoppingListAddItemForm.vue";
|
||||
import ShoppingListItem from "~/components/Domain/ShoppingList/ShoppingListItem.vue";
|
||||
import ShoppingListItemEditor from "~/components/Domain/ShoppingList/ShoppingListItemEditor.vue";
|
||||
import { useShoppingListPage } from "~/composables/shopping-list-page/use-shopping-list-page";
|
||||
|
||||
Reference in New Issue
Block a user