Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add recipe as ingredient #4800

Open
wants to merge 23 commits into
base: mealie-next
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
1a2670c
Initial recipe linking changes
parumpum Dec 29, 2024
2112494
Add db migration to create recipe associations table
parumpum Dec 29, 2024
8dac75a
Formatting
parumpum Dec 29, 2024
c16e263
Data model fixes for recipe associations
parumpum Dec 29, 2024
64cbee7
Add sub_recipe field to recipes ingredients
parumpum Dec 29, 2024
3a8ce5f
Use referenced_recipe on ingredient
parumpum Dec 30, 2024
9a8361a
Put linked recipe in its own section with ingredients
parumpum Dec 30, 2024
4fc09dd
Change how recipe as ingredient is viewed
parumpum Dec 30, 2024
00573dd
Add sub-recipe ingredients to shopping list
parumpum Dec 30, 2024
0ce95ef
Merge branch 'mealie-recipes:mealie-next' into feat/recipe-links
parumpum Dec 30, 2024
a9926a6
Add recipe reference button text
parumpum Dec 30, 2024
44bccee
Formatting, fix test
parumpum Dec 30, 2024
e234d62
Merge branch 'mealie-next' into feat/recipe-links
parumpum Dec 30, 2024
4ad3228
Merge branch 'mealie-next' into feat/recipe-links
parumpum Dec 31, 2024
9d8c0de
Fix dialog setup
parumpum Dec 31, 2024
4d51794
Show image on sub recipe search dialog
parumpum Dec 31, 2024
4afbcd3
Place sub-recipe ingredients in a Section, add select all and none to…
parumpum Dec 31, 2024
b02a1ea
List child recipes on Last Made dialog to optionally update that field
parumpum Dec 31, 2024
eff1e94
Merge branch 'mealie-next' into feat/recipe-links
parumpum Dec 31, 2024
1aab4bf
Add child recipes to timeline without image and comment
parumpum Dec 31, 2024
1c0615e
Print option to expand child recipe ingredients
parumpum Dec 31, 2024
6b4c818
Merge branch 'mealie-next' into feat/recipe-links
parumpum Jan 4, 2025
3725c24
Move linked recipe test
parumpum Jan 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 169 additions & 0 deletions frontend/components/Domain/Recipe/RecipeDialogAddSubRecipe.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
<template>
<div>
<slot v-bind="{ open, close }"> </slot>
<v-dialog v-model="dialog" max-width="988px" content-class="top-dialog" :scrollable="false">
<v-app-bar sticky dark color="primary lighten-1" :rounded="!$vuetify.breakpoint.xs">
<v-text-field
id="arrow-search"
v-model="search.query.value"
autofocus
solo
flat
autocomplete="off"
background-color="primary lighten-1"
color="white"
dense
class="mx-2 arrow-search"
hide-details
single-line
:placeholder="$t('search.search')"
:prepend-inner-icon="$globals.icons.search"
></v-text-field>

<v-btn v-if="$vuetify.breakpoint.xs" x-small fab light @click="dialog = false">
<v-icon>
{{ $globals.icons.close }}
</v-icon>
</v-btn>
</v-app-bar>
<v-card class="mt-1 pa-1 scroll" max-height="700px" relative :loading="loading">
<v-card-actions>
<div class="mr-auto">
{{ $t("search.results") }}
</div>
</v-card-actions>
<RecipeList :recipes="search.data.value" show-description show-image :disabled="$nuxt.isOffline">
<template v-for="(recipe) in search.data.value" #[`actions-${recipe.id}`]>
<v-list-item-action :key="'item-actions-increase' + recipe.id">
<v-btn icon :disabled="$nuxt.isOffline" @click.prevent="addRecipeReferenceToRecipe(recipe)">
<v-icon color="grey lighten-1">{{ $globals.icons.createAlt }}</v-icon>
</v-btn>
</v-list-item-action>
</template>
</RecipeList>

</v-card>

</v-dialog>
</div>
</template>

<script lang="ts">
import { computed, defineComponent, toRefs, reactive, ref, watch, useContext, useRoute } from "@nuxtjs/composition-api";
import RecipeList from "~/components/Domain/Recipe/RecipeList.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import { Recipe, RecipeSummary } from "~/lib/api/types/recipe";

Check warning on line 55 in frontend/components/Domain/Recipe/RecipeDialogAddSubRecipe.vue

View workflow job for this annotation

GitHub Actions / Frontend and End-to-End Tests / lint

'RecipeSummary' is defined but never used
import { useUserApi } from "~/composables/api";
import { useRecipeSearch } from "~/composables/recipes/use-recipe-search";
import { usePublicExploreApi } from "~/composables/api/api-client";

export default defineComponent({
components: {
RecipeList,
},


setup(_, context) {
const { $auth } = useContext();
const state = reactive({
loading: false,
selectedIndex: -1,
});
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
// ===========================================================================
// Dialog State Management
const dialog = ref(false);

// Reset or Grab Recipes on Change
watch(dialog, (val) => {
if (!val) {
search.query.value = "";
state.selectedIndex = -1;
search.data.value = [];
}
});

// ===========================================================================
// Event Handlers

function addRecipeReferenceToRecipe(recipe: Recipe) {
context.emit("recipe-selected", recipe);
close();
}

function selectRecipe() {
const recipeCards = document.getElementsByClassName("arrow-nav");
if (recipeCards) {
if (state.selectedIndex < 0) {
state.selectedIndex = -1;
document.getElementById("arrow-search")?.focus();
return;
}

if (state.selectedIndex >= recipeCards.length) {
state.selectedIndex = recipeCards.length - 1;
}

(recipeCards[state.selectedIndex] as HTMLElement).focus();
}
}

function onUpDown(e: KeyboardEvent) {
if (e.key === "Enter") {
console.log(document.activeElement);
// (document.activeElement as HTMLElement).click();
} else if (e.key === "ArrowUp") {
e.preventDefault();
state.selectedIndex--;
} else if (e.key === "ArrowDown") {
e.preventDefault();
state.selectedIndex++;
} else {
return;
}
selectRecipe();
}

watch(dialog, (val) => {
if (!val) {
document.removeEventListener("keyup", onUpDown);
} else {
document.addEventListener("keyup", onUpDown);
}
});


const route = useRoute();
watch(route, close);

function open() {
dialog.value = true;
}
function close() {
dialog.value = false;
}

// ===========================================================================
// Basic Search
const { isOwnGroup } = useLoggedInState();
const api = isOwnGroup.value ? useUserApi() : usePublicExploreApi(groupSlug.value).explore;
const search = useRecipeSearch(api);


return {
...toRefs(state),
dialog,
open,
close,
search,
addRecipeReferenceToRecipe,
};
},
});
</script>

<style>
.scroll {
overflow-y: auto;
}
</style>
57 changes: 47 additions & 10 deletions frontend/components/Domain/Recipe/RecipeDialogAddToShoppingList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,24 @@
v-for="(ingredientSection, ingredientSectionIndex) in recipeSection.ingredientSections"
:key="recipeSection.recipeId + recipeSectionIndex + ingredientSectionIndex"
>
<v-card-title v-if="ingredientSection.sectionName" class="ingredient-title mt-2 pb-0 text-h6">
{{ ingredientSection.sectionName }}
<v-card-title v-if="ingredientSection.sectionName" class="ingredient-title mt-2 pb-0 text-h6 d-flex align-center justify-space-between">
<span>{{ ingredientSection.sectionName }}</span>
<BaseButtonGroup
:buttons="[
{
icon: $globals.icons.checkboxBlankOutline,
text: $tc('shopping-list.uncheck-all-items'),
event: 'uncheck',
},
{
icon: $globals.icons.checkboxOutline,
text: $tc('shopping-list.check-all-items'),
event: 'check',
},
]"
@uncheck="bulkCheckIngredients(false, ingredientSection.sectionName)"
@check="bulkCheckIngredients(true, ingredientSection.sectionName)"
/>
</v-card-title>
<div
:class="$vuetify.breakpoint.smAndDown ? '' : 'ingredient-grid'"
Expand Down Expand Up @@ -247,13 +263,32 @@ export default defineComponent({
continue;
}

const shoppingListIngredients: ShoppingListIngredient[] = recipe.recipeIngredient.map((ing) => {
return {
const shoppingListIngredients: ShoppingListIngredient[] = [];

recipe.recipeIngredient.forEach((ing) => {
if (ing.isRecipe && ing.referencedRecipe) {
// If ing is a recipe, add all its ingredients
ing.referencedRecipe.recipeIngredient?.forEach((subIng) => {
const calculatedQty = (ing.quantity || 1) * (subIng.quantity || 1);
shoppingListIngredients.push({
checked: !subIng.food?.onHand,
ingredient: {
...subIng,
quantity: calculatedQty,
title: ing.referencedRecipe?.name || "",
},
disableAmount: recipe.settings?.disableAmount || false,
});
});
} else {
// If ing is not a recipe, add it directly
shoppingListIngredients.push({
checked: !ing.food?.onHand,
ingredient: ing,
disableAmount: recipe.settings?.disableAmount || false,
}
});
});
}
});

let currentTitle = "";
const onHandIngs: ShoppingListIngredient[] = [];
Expand Down Expand Up @@ -325,12 +360,14 @@ export default defineComponent({
state.shoppingListShowAllToggled = true;
}

function bulkCheckIngredients(value = true) {
function bulkCheckIngredients(value = true, section?: string) {
recipeIngredientSections.value.forEach((recipeSection) => {
recipeSection.ingredientSections.forEach((ingSection) => {
ingSection.ingredients.forEach((ing) => {
ing.checked = value;
});
if (!section || ingSection.sectionName === section) {
ingSection.ingredients.forEach((ing) => {
ing.checked = value;
});
}
});
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
<v-switch v-model="preferences.showNutrition" hide-details :label="$tc('recipe.nutrition')" />
</v-row>
<v-row no-gutters>
<v-switch v-model="preferences.expandChildRecipes" hide-details :label="$tc('recipe.expand-child-recipe')" />
</v-row>
</v-col>
</v-row>
Expand Down
12 changes: 10 additions & 2 deletions frontend/components/Domain/Recipe/RecipeIngredientListItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,18 @@
<SafeMarkdown v-if="parsedIng.quantity" class="d-inline" :source="parsedIng.quantity" />
<template v-if="parsedIng.unit">{{ parsedIng.unit }} </template>
<SafeMarkdown v-if="parsedIng.note && !parsedIng.name" class="text-bold d-inline" :source="parsedIng.note" />
<template v-else-if="parsedIng.isRecipe">
<SafeMarkdown v-if="parsedIng.link" class="text-bold d-inline" :source="parsedIng.link" />
<SafeMarkdown v-if="parsedIng.note" class="note" :source="parsedIng.note" />
</template>
<template v-else>
<SafeMarkdown v-if="parsedIng.name" class="text-bold d-inline" :source="parsedIng.name" />
<SafeMarkdown v-if="parsedIng.note" class="note" :source="parsedIng.note" />
</template>
</div>
</template>
<script lang="ts">
import { computed, defineComponent } from "@nuxtjs/composition-api";
import { computed, defineComponent, useRoute, useContext } from "@nuxtjs/composition-api";
import { RecipeIngredient } from "~/lib/api/types/household";
import { useParsedIngredientText } from "~/composables/recipes";

Expand All @@ -30,8 +34,12 @@ export default defineComponent({
},
},
setup(props) {
const { $auth } = useContext();

const route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
const parsedIng = computed(() => {
return useParsedIngredientText(props.ingredient, props.disableAmount, props.scale);
return useParsedIngredientText(props.ingredient, props.disableAmount, props.scale, true, groupSlug.value);
});

return {
Expand Down
15 changes: 9 additions & 6 deletions frontend/components/Domain/Recipe/RecipeIngredients.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@
<h3 v-if="showTitleEditor[index]" class="mt-2">{{ ingredient.title }}</h3>
<v-divider v-if="showTitleEditor[index]"></v-divider>
</template>
<v-list-item dense @click.stop="toggleChecked(index)">
<v-checkbox hide-details :value="checked[index]" class="pt-0 my-auto py-auto" color="secondary" />
<v-list-item-content :key="ingredient.quantity">
<RecipeIngredientListItem :ingredient="ingredient" :disable-amount="disableAmount" :scale="scale" />
</v-list-item-content>
</v-list-item>

<v-list-item dense @click.stop="toggleChecked(index)">
<v-checkbox hide-details :value="checked[index]" class="pt-0 my-auto py-auto" color="secondary" />
<v-list-item-content :key="ingredient.quantity">
<RecipeIngredientListItem :ingredient="ingredient" :disable-amount="disableAmount" :scale="scale" />
</v-list-item-content>
</v-list-item>


</div>
</div>
</div>
Expand Down
49 changes: 49 additions & 0 deletions frontend/components/Domain/Recipe/RecipeLastMade.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,25 @@
persistent-hint
rows="4"
></v-textarea>
<div v-if="childRecipes && childRecipes.length > 0">
<v-subheader>{{ $tc('recipe.sub-recipes') }}</v-subheader>
<v-list dense>
<v-list-item
v-for="(childRecipe, i) in childRecipes"
:key="childRecipe.recipeId + i"
dense
@click="childRecipe.checked = !childRecipe.checked"
>
<v-checkbox
hide-details
:input-value="childRecipe.checked"
:label="childRecipe.name"
class="pt-0 my-auto py-auto"
color="secondary"
/>
</v-list-item>
</v-list>
</div>
<v-container>
<v-row>
<v-col cols="auto">
Expand Down Expand Up @@ -146,6 +165,20 @@ export default defineComponent({
const newTimelineEventImagePreviewUrl = ref<string>();
const newTimelineEventTimestamp = ref<string>();

const childRecipes = computed(() => {
return props.recipe.recipeIngredient?.map((ingredient) => {
if (ingredient.referencedRecipe) {
return {
checked: false, // Default value for checked
recipeId: ingredient.referencedRecipe.id || "", // Non-nullable recipeId
...ingredient.referencedRecipe // Spread the rest of the referencedRecipe properties
};
} else {
return undefined;
}
}).filter(recipe => recipe !== undefined); // Filter out undefined values
});

whenever(
() => madeThisDialog.value,
() => {
Expand Down Expand Up @@ -215,6 +248,21 @@ export default defineComponent({
}
}

// Update last made for any checked child recipes
if (childRecipes.value) {
for (const childRecipe of childRecipes.value) {
if (childRecipe.checked) {
newTimelineEvent.value.eventMessage = "";
clearImage();
newTimelineEvent.value.recipeId = childRecipe.recipeId;
await userApi.recipes.createTimelineEvent(newTimelineEvent.value);
if ((!props.value || newTimelineEvent.value.timestamp > props.value) && childRecipe.slug) {
await userApi.recipes.updateLastMade(childRecipe.slug, newTimelineEvent.value.timestamp);
}
}
}
}

// reset form
newTimelineEvent.value.eventMessage = "";
newTimelineEvent.value.timestamp = undefined;
Expand All @@ -238,6 +286,7 @@ export default defineComponent({
clearImage,
uploadImage,
updateUploadedImage,
childRecipes,
};
},
});
Expand Down
Loading
Loading