feat: warn when deleting foods used in recipes (#7117)

Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com>
This commit is contained in:
Zachary Schaffter
2026-05-31 08:42:13 -07:00
committed by GitHub
parent 364af97060
commit 262b531add
3 changed files with 76 additions and 30 deletions

View File

@@ -59,9 +59,10 @@
> >
<v-card-text> <v-card-text>
{{ $t("general.confirm-delete-generic") }} {{ $t("general.confirm-delete-generic") }}
<p v-if="deleteTarget" class="mt-4 ml-4"> <p v-if="deleteTarget" class="mt-4 mb-0 font-weight-bold">
{{ deleteTarget.name || deleteTarget.title || deleteTarget.id }} {{ deleteTarget.name || deleteTarget.title || deleteTarget.id }}
</p> </p>
<slot name="delete-dialog-bottom" />
</v-card-text> </v-card-text>
</BaseDialog> </BaseDialog>
@@ -88,6 +89,7 @@
</template> </template>
</v-virtual-scroll> </v-virtual-scroll>
</v-card> </v-card>
<slot name="delete-dialog-bottom" />
</v-card-text> </v-card-text>
</BaseDialog> </BaseDialog>
@@ -151,7 +153,7 @@ const createDialog = defineModel("createDialog", { type: Boolean, default: false
const editForm = defineModel<{ items: AutoFormItems; data: Record<string, any> }>("editForm", { required: true }); const editForm = defineModel<{ items: AutoFormItems; data: Record<string, any> }>("editForm", { required: true });
const editDialog = defineModel("editDialog", { type: Boolean, default: false }); const editDialog = defineModel("editDialog", { type: Boolean, default: false });
defineProps({ const props = defineProps({
icon: { icon: {
type: String, type: String,
required: true, required: true,
@@ -185,6 +187,10 @@ defineProps({
type: String, type: String,
default: "name", default: "name",
}, },
onDeleteDialogOpen: {
type: Function as PropType<(items: any[]) => Promise<void>>,
default: null,
},
}); });
// ============================================================ // ============================================================
@@ -212,8 +218,11 @@ const editEventHandler = (item: any) => {
const deleteTarget = ref<any>(null); const deleteTarget = ref<any>(null);
const deleteDialog = ref(false); const deleteDialog = ref(false);
function deleteEventHandler(item: any) { async function deleteEventHandler(item: any) {
deleteTarget.value = item; deleteTarget.value = item;
if (props.onDeleteDialogOpen) {
await props.onDeleteDialogOpen([item]);
}
deleteDialog.value = true; deleteDialog.value = true;
} }
@@ -222,8 +231,11 @@ function deleteEventHandler(item: any) {
const bulkDeleteTarget = ref<Array<any>>([]); const bulkDeleteTarget = ref<Array<any>>([]);
const bulkDeleteDialog = ref(false); const bulkDeleteDialog = ref(false);
function bulkDeleteEventHandler(items: Array<any>) { async function bulkDeleteEventHandler(items: Array<any>) {
bulkDeleteTarget.value = items; bulkDeleteTarget.value = items;
if (props.onDeleteDialogOpen) {
await props.onDeleteDialogOpen(items);
}
bulkDeleteDialog.value = true; bulkDeleteDialog.value = true;
console.log("Bulk Delete Event Handler", items); console.log("Bulk Delete Event Handler", items);
} }

View File

@@ -1144,6 +1144,8 @@
}, },
"data-pages": { "data-pages": {
"foods": { "foods": {
"delete-affects-recipes": "Warning: this food is used in {count} recipe(s). Deleting it will leave an empty ingredient in the recipe(s).",
"delete-affects-recipes-more": "View all {count} recipes",
"merge-dialog-text": "Combining the selected foods will merge the source food and target food into a single food. The source food will be deleted and all of the references to the source food will be updated to point to the target food.", "merge-dialog-text": "Combining the selected foods will merge the source food and target food into a single food. The source food will be deleted and all of the references to the source food will be updated to point to the target food.",
"merge-food-example": "Merging {food1} into {food2}", "merge-food-example": "Merging {food1} into {food2}",
"seed-dialog-text": "Seed the database with foods based on your local language. This will create ~2700 common foods that can be used to organize your database. Foods are translated via a community effort.", "seed-dialog-text": "Seed the database with foods based on your local language. This will create ~2700 common foods that can be used to organize your database. Foods are translated via a community effort.",

View File

@@ -69,11 +69,7 @@
</template> </template>
</v-autocomplete> </v-autocomplete>
<v-alert <v-alert v-if="foods && foods.length > 0" type="error" class="mb-0 text-body-2">
v-if="foods && foods.length > 0"
type="error"
class="mb-0 text-body-2"
>
{{ $t("data-pages.foods.seed-dialog-warning") }} {{ $t("data-pages.foods.seed-dialog-warning") }}
</v-alert> </v-alert>
</v-card-text> </v-card-text>
@@ -112,11 +108,7 @@
:label="$t('data-pages.foods.food-label')" :label="$t('data-pages.foods.food-label')"
/> />
<v-card variant="outlined"> <v-card variant="outlined">
<v-virtual-scroll <v-virtual-scroll height="400" item-height="25" :items="bulkAssignTarget">
height="400"
item-height="25"
:items="bulkAssignTarget"
>
<template #default="{ item }"> <template #default="{ item }">
<v-list-item class="pb-2"> <v-list-item class="pb-2">
<v-list-item-title>{{ item.name }}</v-list-item-title> <v-list-item-title>{{ item.name }}</v-list-item-title>
@@ -141,6 +133,7 @@
]" ]"
:create-form="createForm" :create-form="createForm"
:edit-form="editForm" :edit-form="editForm"
:on-delete-dialog-open="onDeleteDialogOpen"
@create-one="handleCreate" @create-one="handleCreate"
@edit-one="handleEdit" @edit-one="handleEdit"
@delete-one="foodStore.actions.deleteOne" @delete-one="foodStore.actions.deleteOne"
@@ -151,15 +144,12 @@
<template #icon> <template #icon>
{{ $globals.icons.externalLink }} {{ $globals.icons.externalLink }}
</template> </template>
{{ $t('data-pages.combine') }} {{ $t("data-pages.combine") }}
</BaseButton> </BaseButton>
</template> </template>
<template #[`item.label`]="{ item }"> <template #[`item.label`]="{ item }">
<MultiPurposeLabel <MultiPurposeLabel v-if="item.label" :label="item.label">
v-if="item.label"
:label="item.label"
>
{{ item.label.name }} {{ item.label.name }}
</MultiPurposeLabel> </MultiPurposeLabel>
</template> </template>
@@ -171,7 +161,7 @@
</template> </template>
<template #[`item.createdAt`]="{ item }"> <template #[`item.createdAt`]="{ item }">
{{ item.createdAt ? $d(new Date(item.createdAt)) : '' }} {{ item.createdAt ? $d(new Date(item.createdAt)) : "" }}
</template> </template>
<template #table-button-bottom> <template #table-button-bottom>
@@ -179,18 +169,33 @@
<template #icon> <template #icon>
{{ $globals.icons.database }} {{ $globals.icons.database }}
</template> </template>
{{ $t('data-pages.seed') }} {{ $t("data-pages.seed") }}
</BaseButton> </BaseButton>
</template> </template>
<template #edit-dialog-custom-action> <template #edit-dialog-custom-action>
<BaseButton <BaseButton edit @click="aliasManagerDialog = true">
edit {{ $t("data-pages.manage-aliases") }}
@click="aliasManagerDialog = true"
>
{{ $t('data-pages.manage-aliases') }}
</BaseButton> </BaseButton>
</template> </template>
<template #delete-dialog-bottom>
<v-alert v-if="affectedRecipes.length > 0" type="warning" density="compact" class="mt-4 mb-0">
{{ $t("data-pages.foods.delete-affects-recipes", { count: affectedRecipesTotal }) }}
<ul class="mt-1 pl-5 mb-0">
<li v-for="recipe in affectedRecipes.slice(0, 5)" :key="recipe.slug">
<NuxtLink :to="recipe.url" class="text-white">{{ recipe.name }}</NuxtLink>
</li>
</ul>
<NuxtLink
v-if="affectedRecipesTotal > 5"
:to="affectedRecipesMoreLink"
class="text-white d-inline-block mt-1"
>
{{ $t("data-pages.foods.delete-affects-recipes-more", { count: affectedRecipesTotal }) }}
</NuxtLink>
</v-alert>
</template>
</GroupDataPage> </GroupDataPage>
</div> </div>
</template> </template>
@@ -218,6 +223,7 @@ interface CreateIngredientFoodWithOnHand extends CreateIngredientFood {
interface IngredientFoodWithOnHand extends IngredientFood { interface IngredientFoodWithOnHand extends IngredientFood {
onHand: boolean; onHand: boolean;
} }
const userApi = useUserApi(); const userApi = useUserApi();
const i18n = useI18n(); const i18n = useI18n();
const auth = useMealieAuth(); const auth = useMealieAuth();
@@ -274,11 +280,14 @@ const tableHeaders: TableHeaders[] = [
]; ];
const userHousehold = computed(() => auth.user.value?.householdSlug || ""); const userHousehold = computed(() => auth.user.value?.householdSlug || "");
const userGroup = computed(() => auth.user.value?.groupSlug || "");
const foodStore = useFoodStore(); const foodStore = useFoodStore();
const foods = computed(() => foodStore.store.value.map((food) => { const foods = computed(() =>
foodStore.store.value.map((food) => {
const onHand = food.householdsWithIngredientFood?.includes(userHousehold.value) || false; const onHand = food.householdsWithIngredientFood?.includes(userHousehold.value) || false;
return { ...food, onHand } as IngredientFoodWithOnHand; return { ...food, onHand } as IngredientFoodWithOnHand;
})); }),
);
// ============================================================ // ============================================================
// Labels // Labels
@@ -383,6 +392,9 @@ async function handleBulkAction(event: string, items: IngredientFoodWithOnHand[]
if (event === "delete-selected") { if (event === "delete-selected") {
const ids = items.map(item => item.id); const ids = items.map(item => item.id);
await foodStore.actions.deleteMany(ids); await foodStore.actions.deleteMany(ids);
affectedRecipes.value = [];
affectedRecipesTotal.value = 0;
affectedRecipesMoreLink.value = "";
} }
else if (event === "assign-selected") { else if (event === "assign-selected") {
bulkAssignEventHandler(items); bulkAssignEventHandler(items);
@@ -401,6 +413,26 @@ function updateFoodAlias(newAliases: IngredientFoodAlias[]) {
aliasManagerDialog.value = false; aliasManagerDialog.value = false;
} }
// ============================================================
// Delete Foods
// fetch affected recipes before confirming deletion
const affectedRecipes = ref<{ name: string; slug: string; url: string }[]>([]);
const affectedRecipesTotal = ref(0);
const affectedRecipesMoreLink = ref("");
async function onDeleteDialogOpen(items: IngredientFoodWithOnHand[]) {
const ids = items.map(item => item.id);
const { data } = await userApi.recipes.search({ foods: ids, perPage: 5 });
affectedRecipes.value = (data?.items ?? []).map(r => ({
name: r.name ?? "",
slug: r.slug ?? "",
url: `/g/${userGroup.value}/r/${r.slug}`,
}));
affectedRecipesTotal.value = data?.total ?? 0;
affectedRecipesMoreLink.value = `/g/${userGroup.value}?${ids.map(id => `foods=${id}`).join("&")}`;
}
// ============================================================ // ============================================================
// Merge Foods // Merge Foods