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:
miah
2026-05-04 11:15:01 -05:00
committed by GitHub
parent 41a9a1e018
commit e71b31e9cc
8 changed files with 331 additions and 167 deletions

View File

@@ -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>

View File

@@ -145,6 +145,7 @@
:labels="labels"
:units="units"
:foods="foods"
class="ma-2"
@save="save"
@cancel="toggleEdit(false)"
@delete="$emit('delete')"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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<{