mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-11-04 03:28:28 -05:00 
			
		
		
		
	* tweaked dialogs to make grammatical sense * refactor ingredient rendering on recipe shopping list dialog
		
			
				
	
	
		
			356 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
			
		
		
	
	
			356 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
<template>
 | 
						|
  <div v-if="dialog">
 | 
						|
    <BaseDialog v-if="shoppingListDialog" v-model="dialog" :title="$t('recipe.add-to-list')" :icon="$globals.icons.cartCheck">
 | 
						|
      <v-card-text>
 | 
						|
        <v-card
 | 
						|
          v-for="list in shoppingLists"
 | 
						|
          :key="list.id"
 | 
						|
          hover
 | 
						|
          class="my-2 left-border"
 | 
						|
          @click="openShoppingListIngredientDialog(list)"
 | 
						|
        >
 | 
						|
          <v-card-title class="py-2">
 | 
						|
            {{ list.name }}
 | 
						|
          </v-card-title>
 | 
						|
        </v-card>
 | 
						|
      </v-card-text>
 | 
						|
    </BaseDialog>
 | 
						|
    <BaseDialog
 | 
						|
      v-if="shoppingListIngredientDialog"
 | 
						|
      v-model="dialog"
 | 
						|
      :title="selectedShoppingList ? selectedShoppingList.name : $t('recipe.add-to-list')"
 | 
						|
      :icon="$globals.icons.cartCheck"
 | 
						|
      width="70%"
 | 
						|
      :submit-text="$tc('recipe.add-to-list')"
 | 
						|
      @submit="addRecipesToList()"
 | 
						|
    >
 | 
						|
      <div style="max-height: 70vh;  overflow-y: auto">
 | 
						|
        <v-card
 | 
						|
          v-for="(recipeSection, recipeSectionIndex) in recipeIngredientSections" :key="recipeSection.recipeId + recipeSectionIndex"
 | 
						|
          elevation="0"
 | 
						|
          height="fit-content"
 | 
						|
          width="100%"
 | 
						|
        >
 | 
						|
          <v-divider v-if="recipeSectionIndex > 0" class="mt-3" />
 | 
						|
          <v-card-title
 | 
						|
            v-if="recipeIngredientSections.length > 1"
 | 
						|
            class="justify-center text-h5"
 | 
						|
            width="100%"
 | 
						|
          >
 | 
						|
            <v-container style="width: 100%;">
 | 
						|
              <v-row no-gutters class="ma-0 pa-0">
 | 
						|
                <v-col cols="12" align-self="center" class="text-center">
 | 
						|
                  {{ recipeSection.recipeName }}
 | 
						|
                </v-col>
 | 
						|
              </v-row>
 | 
						|
              <v-row v-if="recipeSection.recipeScale > 1" no-gutters class="ma-0 pa-0">
 | 
						|
                <!-- TODO: make this editable in the dialog and visible on single-recipe lists -->
 | 
						|
                <v-col cols="12" align-self="center" class="text-center">
 | 
						|
                  ({{ $tc("recipe.quantity") }}: {{ recipeSection.recipeScale }})
 | 
						|
                </v-col>
 | 
						|
              </v-row>
 | 
						|
            </v-container>
 | 
						|
          </v-card-title>
 | 
						|
          <div>
 | 
						|
            <div
 | 
						|
              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>
 | 
						|
              <div
 | 
						|
                :class="$vuetify.breakpoint.smAndDown ? '' : 'ingredient-grid'"
 | 
						|
                :style="$vuetify.breakpoint.smAndDown ? '' : { gridTemplateRows: `repeat(${Math.ceil(ingredientSection.ingredients.length / 2)}, min-content)` }"
 | 
						|
              >
 | 
						|
                <v-list-item
 | 
						|
                  v-for="(ingredientData, i) in ingredientSection.ingredients"
 | 
						|
                  :key="recipeSection.recipeId + recipeSectionIndex + ingredientSectionIndex + i"
 | 
						|
                  dense
 | 
						|
                  @click="recipeIngredientSections[recipeSectionIndex]
 | 
						|
                    .ingredientSections[ingredientSectionIndex]
 | 
						|
                    .ingredients[i].checked = !recipeIngredientSections[recipeSectionIndex]
 | 
						|
                    .ingredientSections[ingredientSectionIndex]
 | 
						|
                    .ingredients[i]
 | 
						|
                    .checked"
 | 
						|
                >
 | 
						|
                  <v-checkbox
 | 
						|
                    hide-details
 | 
						|
                    :input-value="ingredientData.checked"
 | 
						|
                    class="pt-0 my-auto py-auto"
 | 
						|
                    color="secondary"
 | 
						|
                  />
 | 
						|
                  <v-list-item-content :key="ingredientData.ingredient.quantity">
 | 
						|
                    <RecipeIngredientListItem
 | 
						|
                      :ingredient="ingredientData.ingredient"
 | 
						|
                      :disable-amount="ingredientData.disableAmount"
 | 
						|
                      :scale="recipeSection.recipeScale" />
 | 
						|
                  </v-list-item-content>
 | 
						|
                </v-list-item>
 | 
						|
              </div>
 | 
						|
            </div>
 | 
						|
          </div>
 | 
						|
        </v-card>
 | 
						|
      </div>
 | 
						|
      <div class="d-flex justify-end mb-4 mt-2">
 | 
						|
        <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)"
 | 
						|
          @check="bulkCheckIngredients(true)"
 | 
						|
        />
 | 
						|
      </div>
 | 
						|
    </BaseDialog>
 | 
						|
  </div>
 | 
						|
</template>
 | 
						|
 | 
						|
<script lang="ts">
 | 
						|
import { computed, defineComponent, reactive, ref, useContext } from "@nuxtjs/composition-api";
 | 
						|
import { toRefs } from "@vueuse/core";
 | 
						|
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
 | 
						|
import { useUserApi } from "~/composables/api";
 | 
						|
import { alert } from "~/composables/use-toast";
 | 
						|
import { ShoppingListSummary } from "~/lib/api/types/group";
 | 
						|
import { Recipe, RecipeIngredient } from "~/lib/api/types/recipe";
 | 
						|
 | 
						|
export interface RecipeWithScale extends Recipe {
 | 
						|
  scale: number;
 | 
						|
}
 | 
						|
 | 
						|
export interface ShoppingListIngredient {
 | 
						|
  checked: boolean;
 | 
						|
  ingredient: RecipeIngredient;
 | 
						|
  disableAmount: boolean;
 | 
						|
}
 | 
						|
 | 
						|
export interface ShoppingListIngredientSection {
 | 
						|
  sectionName: string;
 | 
						|
  ingredients: ShoppingListIngredient[];
 | 
						|
}
 | 
						|
 | 
						|
export interface ShoppingListRecipeIngredientSection {
 | 
						|
  recipeId: string;
 | 
						|
  recipeName: string;
 | 
						|
  recipeScale: number;
 | 
						|
  ingredientSections: ShoppingListIngredientSection[];
 | 
						|
}
 | 
						|
 | 
						|
export default defineComponent({
 | 
						|
  components: {
 | 
						|
    RecipeIngredientListItem,
 | 
						|
  },
 | 
						|
  props: {
 | 
						|
    value: {
 | 
						|
      type: Boolean,
 | 
						|
      default: false,
 | 
						|
    },
 | 
						|
    recipes: {
 | 
						|
      type: Array as () => RecipeWithScale[],
 | 
						|
      default: undefined,
 | 
						|
    },
 | 
						|
    shoppingLists: {
 | 
						|
      type: Array as () => ShoppingListSummary[],
 | 
						|
      default: () => [],
 | 
						|
    },
 | 
						|
  },
 | 
						|
  setup(props, context) {
 | 
						|
    const { i18n } = useContext();
 | 
						|
    const api = useUserApi();
 | 
						|
 | 
						|
    // v-model support
 | 
						|
    const dialog = computed({
 | 
						|
      get: () => {
 | 
						|
        return props.value;
 | 
						|
      },
 | 
						|
      set: (val) => {
 | 
						|
        context.emit("input", val);
 | 
						|
        initState();
 | 
						|
      },
 | 
						|
    });
 | 
						|
 | 
						|
    const state = reactive({
 | 
						|
      shoppingListDialog: true,
 | 
						|
      shoppingListIngredientDialog: false,
 | 
						|
    });
 | 
						|
 | 
						|
    const recipeIngredientSections = ref<ShoppingListRecipeIngredientSection[]>([]);
 | 
						|
    const selectedShoppingList = ref<ShoppingListSummary | null>(null);
 | 
						|
 | 
						|
    async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
 | 
						|
      const recipeSectionMap = new Map<string, ShoppingListRecipeIngredientSection>();
 | 
						|
      for (const recipe of recipes) {
 | 
						|
        if (!recipe.slug) {
 | 
						|
          continue;
 | 
						|
        }
 | 
						|
 | 
						|
        if (recipeSectionMap.has(recipe.slug)) {
 | 
						|
          // @ts-ignore not undefined, see above
 | 
						|
          recipeSectionMap.get(recipe.slug).recipeScale += recipe.scale;
 | 
						|
          continue;
 | 
						|
        }
 | 
						|
 | 
						|
        if (!(recipe.id && recipe.name && recipe.recipeIngredient)) {
 | 
						|
          const { data } = await api.recipes.getOne(recipe.slug);
 | 
						|
          if (!data?.recipeIngredient?.length) {
 | 
						|
            continue;
 | 
						|
          }
 | 
						|
          recipe.id = data.id || "";
 | 
						|
          recipe.name = data.name || "";
 | 
						|
          recipe.recipeIngredient = data.recipeIngredient;
 | 
						|
        } else if (!recipe.recipeIngredient.length) {
 | 
						|
          continue;
 | 
						|
        }
 | 
						|
 | 
						|
        const shoppingListIngredients: ShoppingListIngredient[] = recipe.recipeIngredient.map((ing) => {
 | 
						|
          return {
 | 
						|
            checked: true,
 | 
						|
            ingredient: ing,
 | 
						|
            disableAmount: recipe.settings?.disableAmount || false,
 | 
						|
          }
 | 
						|
        });
 | 
						|
 | 
						|
        const shoppingListIngredientSections = shoppingListIngredients.reduce((sections, ing) => {
 | 
						|
          // if title append new section to the end of the array
 | 
						|
          if (ing.ingredient.title) {
 | 
						|
            sections.push({
 | 
						|
              sectionName: ing.ingredient.title,
 | 
						|
              ingredients: [ing],
 | 
						|
            });
 | 
						|
            return sections;
 | 
						|
          }
 | 
						|
 | 
						|
          // append new section if first
 | 
						|
          if (sections.length === 0) {
 | 
						|
            sections.push({
 | 
						|
              sectionName: "",
 | 
						|
              ingredients: [ing],
 | 
						|
            });
 | 
						|
            return sections;
 | 
						|
          }
 | 
						|
 | 
						|
          // otherwise add ingredient to last section in the array
 | 
						|
          sections[sections.length - 1].ingredients.push(ing);
 | 
						|
          return sections;
 | 
						|
        }, [] as ShoppingListIngredientSection[]);
 | 
						|
 | 
						|
        recipeSectionMap.set(recipe.slug, {
 | 
						|
          recipeId: recipe.id,
 | 
						|
          recipeName: recipe.name,
 | 
						|
          recipeScale: recipe.scale,
 | 
						|
          ingredientSections: shoppingListIngredientSections,
 | 
						|
        })
 | 
						|
      }
 | 
						|
 | 
						|
      recipeIngredientSections.value = Array.from(recipeSectionMap.values());
 | 
						|
    }
 | 
						|
 | 
						|
    function initState() {
 | 
						|
      state.shoppingListDialog = true;
 | 
						|
      state.shoppingListIngredientDialog = false;
 | 
						|
      recipeIngredientSections.value = [];
 | 
						|
      selectedShoppingList.value = null;
 | 
						|
    }
 | 
						|
 | 
						|
    initState();
 | 
						|
 | 
						|
    async function openShoppingListIngredientDialog(list: ShoppingListSummary) {
 | 
						|
      if (!props.recipes?.length) {
 | 
						|
        return;
 | 
						|
      }
 | 
						|
 | 
						|
      selectedShoppingList.value = list;
 | 
						|
      await consolidateRecipesIntoSections(props.recipes);
 | 
						|
      state.shoppingListDialog = false;
 | 
						|
      state.shoppingListIngredientDialog = true;
 | 
						|
    }
 | 
						|
 | 
						|
    function bulkCheckIngredients(value = true) {
 | 
						|
      recipeIngredientSections.value.forEach((recipeSection) => {
 | 
						|
        recipeSection.ingredientSections.forEach((ingSection) => {
 | 
						|
          ingSection.ingredients.forEach((ing) => {
 | 
						|
            ing.checked = value;
 | 
						|
          });
 | 
						|
        });
 | 
						|
      });
 | 
						|
    }
 | 
						|
 | 
						|
    async function addRecipesToList() {
 | 
						|
      const promises: Promise<any>[] = [];
 | 
						|
      recipeIngredientSections.value.forEach((section) => {
 | 
						|
        if (!selectedShoppingList.value) {
 | 
						|
          return;
 | 
						|
        }
 | 
						|
 | 
						|
        const ingredients: RecipeIngredient[] = [];
 | 
						|
        section.ingredientSections.forEach((ingSection) => {
 | 
						|
          ingSection.ingredients.forEach((ing) => {
 | 
						|
            if (ing.checked) {
 | 
						|
              ingredients.push(ing.ingredient);
 | 
						|
            }
 | 
						|
          });
 | 
						|
        });
 | 
						|
 | 
						|
        if (!ingredients.length) {
 | 
						|
          return;
 | 
						|
        }
 | 
						|
 | 
						|
        promises.push(api.shopping.lists.addRecipe(
 | 
						|
          selectedShoppingList.value.id,
 | 
						|
          section.recipeId,
 | 
						|
          section.recipeScale,
 | 
						|
          ingredients,
 | 
						|
        ));
 | 
						|
      });
 | 
						|
 | 
						|
      let success = true;
 | 
						|
      const results = await Promise.allSettled(promises);
 | 
						|
      results.forEach((result) => {
 | 
						|
        if (result.status === "rejected") {
 | 
						|
          success = false;
 | 
						|
        }
 | 
						|
      })
 | 
						|
 | 
						|
      const successMessage = promises.length === 1
 | 
						|
        ? i18n.t("recipe.successfully-added-to-list") as string
 | 
						|
        : i18n.t("recipe.failed-to-add-to-list") as string;
 | 
						|
 | 
						|
      success ? alert.success(successMessage)
 | 
						|
      : alert.error(i18n.t("failed-to-add-recipes-to-list") as string)
 | 
						|
 | 
						|
      state.shoppingListDialog = false;
 | 
						|
      state.shoppingListIngredientDialog = false;
 | 
						|
      dialog.value = false;
 | 
						|
    }
 | 
						|
 | 
						|
    return {
 | 
						|
      dialog,
 | 
						|
      ...toRefs(state),
 | 
						|
      addRecipesToList,
 | 
						|
      bulkCheckIngredients,
 | 
						|
      openShoppingListIngredientDialog,
 | 
						|
      recipeIngredientSections,
 | 
						|
      selectedShoppingList,
 | 
						|
    }
 | 
						|
  },
 | 
						|
})
 | 
						|
</script>
 | 
						|
 | 
						|
<style scoped lang="css">
 | 
						|
.ingredient-grid {
 | 
						|
  display: grid;
 | 
						|
  grid-auto-flow: column;
 | 
						|
  grid-template-columns: 1fr 1fr;
 | 
						|
  grid-gap: 0.5rem;
 | 
						|
}
 | 
						|
</style>
 |