mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 02:03:35 -04:00 
			
		
		
		
	feat: improve readability of ingredients list (#2502)
* feat: improve readability of notes in ingredients list Makes the notes in the ingredients list more readable by making them slightly opaque. This creates a better visual separation between the notes and the rest of the ingredient. * Use server display if available * Move note to newline and make quantity more distinct * Use safeMarkdown for shopping list * Use component * Wrap unit in accent color * Update RecipeIngredientListItem to set food in bold
This commit is contained in:
		
				
					committed by
					
						 GitHub
						GitHub
					
				
			
			
				
	
			
			
			
						parent
						
							2151451634
						
					
				
				
					commit
					50a92c165c
				
			| @@ -114,7 +114,10 @@ | |||||||
|             color="secondary" |             color="secondary" | ||||||
|           /> |           /> | ||||||
|           <v-list-item-content :key="ingredientData.ingredient.quantity"> |           <v-list-item-content :key="ingredientData.ingredient.quantity"> | ||||||
|             <SafeMarkdown class="ma-0 pa-0 text-subtitle-1 dense-markdown" :source="ingredientData.display" /> |             <RecipeIngredientListItem | ||||||
|  |               :ingredient="ingredientData.ingredient" | ||||||
|  |               :disable-amount="ingredientData.disableAmount" | ||||||
|  |               :scale="recipeScale" /> | ||||||
|           </v-list-item-content> |           </v-list-item-content> | ||||||
|         </v-list-item> |         </v-list-item> | ||||||
|       </v-card> |       </v-card> | ||||||
| @@ -168,13 +171,13 @@ | |||||||
|  |  | ||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
| import { defineComponent, reactive, toRefs, useContext, useRouter, ref } from "@nuxtjs/composition-api"; | import { defineComponent, reactive, toRefs, useContext, useRouter, ref } from "@nuxtjs/composition-api"; | ||||||
|  | import RecipeIngredientListItem from "./RecipeIngredientListItem.vue"; | ||||||
| import RecipeDialogPrintPreferences from "./RecipeDialogPrintPreferences.vue"; | import RecipeDialogPrintPreferences from "./RecipeDialogPrintPreferences.vue"; | ||||||
| import RecipeDialogShare from "./RecipeDialogShare.vue"; | import RecipeDialogShare from "./RecipeDialogShare.vue"; | ||||||
| import { useUserApi } from "~/composables/api"; | import { useUserApi } from "~/composables/api"; | ||||||
| import { alert } from "~/composables/use-toast"; | import { alert } from "~/composables/use-toast"; | ||||||
| import { usePlanTypeOptions } from "~/composables/use-group-mealplan"; | import { usePlanTypeOptions } from "~/composables/use-group-mealplan"; | ||||||
| import { Recipe, RecipeIngredient } from "~/lib/api/types/recipe"; | import { Recipe, RecipeIngredient } from "~/lib/api/types/recipe"; | ||||||
| import { parseIngredientText } from "~/composables/recipes"; |  | ||||||
| import { ShoppingListSummary } from "~/lib/api/types/group"; | import { ShoppingListSummary } from "~/lib/api/types/group"; | ||||||
| import { PlanEntryType } from "~/lib/api/types/meal-plan"; | import { PlanEntryType } from "~/lib/api/types/meal-plan"; | ||||||
| import { useAxiosDownloader } from "~/composables/api/use-axios-download"; | import { useAxiosDownloader } from "~/composables/api/use-axios-download"; | ||||||
| @@ -203,7 +206,8 @@ export default defineComponent({ | |||||||
|   components: { |   components: { | ||||||
|     RecipeDialogPrintPreferences, |     RecipeDialogPrintPreferences, | ||||||
|     RecipeDialogShare, |     RecipeDialogShare, | ||||||
|   }, |     RecipeIngredientListItem | ||||||
|  | }, | ||||||
|   props: { |   props: { | ||||||
|     useItems: { |     useItems: { | ||||||
|       type: Object as () => ContextMenuIncludes, |       type: Object as () => ContextMenuIncludes, | ||||||
| @@ -384,7 +388,7 @@ export default defineComponent({ | |||||||
|     const shoppingLists = ref<ShoppingListSummary[]>(); |     const shoppingLists = ref<ShoppingListSummary[]>(); | ||||||
|     const selectedShoppingList = ref<ShoppingListSummary>(); |     const selectedShoppingList = ref<ShoppingListSummary>(); | ||||||
|     const recipeRef = ref<Recipe>(props.recipe); |     const recipeRef = ref<Recipe>(props.recipe); | ||||||
|     const recipeIngredients = ref<{ checked: boolean; ingredient: RecipeIngredient; display: string }[]>([]); |     const recipeIngredients = ref<{ checked: boolean; ingredient: RecipeIngredient, disableAmount: boolean }[]>([]); | ||||||
|  |  | ||||||
|     async function getShoppingLists() { |     async function getShoppingLists() { | ||||||
|       const { data } = await api.shopping.lists.getAll(); |       const { data } = await api.shopping.lists.getAll(); | ||||||
| @@ -411,7 +415,7 @@ export default defineComponent({ | |||||||
|           return { |           return { | ||||||
|             checked: true, |             checked: true, | ||||||
|             ingredient, |             ingredient, | ||||||
|             display: parseIngredientText(ingredient, recipeRef.value?.settings?.disableAmount || false, props.recipeScale), |             disableAmount: recipeRef.value.settings?.disableAmount || false | ||||||
|           }; |           }; | ||||||
|         }); |         }); | ||||||
|       } |       } | ||||||
|   | |||||||
| @@ -0,0 +1,58 @@ | |||||||
|  | <template> | ||||||
|  |   <div class="ma-0 pa-0 text-subtitle-1 dense-markdown ingredient-item"> | ||||||
|  |     <SafeMarkdown v-if="quantity" class="d-inline" :source="quantity" /> | ||||||
|  |     <template v-if="unit">{{ unit }} </template> | ||||||
|  |     <SafeMarkdown v-if="note && !name" class="text-bold d-inline" :source="note" /> | ||||||
|  |     <template v-else> | ||||||
|  |       <SafeMarkdown v-if="name" class="text-bold d-inline" :source="name" /> | ||||||
|  |       <SafeMarkdown v-if="note" class="note" :source="note" /> | ||||||
|  |     </template> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  | <script lang="ts"> | ||||||
|  | import { defineComponent } from "@nuxtjs/composition-api"; | ||||||
|  | import { RecipeIngredient } from "~/lib/api/types/group"; | ||||||
|  | import { useParsedIngredientText } from "~/composables/recipes"; | ||||||
|  |  | ||||||
|  | export default defineComponent({ | ||||||
|  |   props: { | ||||||
|  |     ingredient: { | ||||||
|  |       type: Object as () => RecipeIngredient, | ||||||
|  |       required: true, | ||||||
|  |     }, | ||||||
|  |     disableAmount: { | ||||||
|  |       type: Boolean, | ||||||
|  |       default: false, | ||||||
|  |     }, | ||||||
|  |     scale: { | ||||||
|  |       type: Number, | ||||||
|  |       default: 1, | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   setup(props) { | ||||||
|  |     const parsed = useParsedIngredientText(props.ingredient, props.disableAmount, props.scale); | ||||||
|  |     return { | ||||||
|  |       ...parsed, | ||||||
|  |     }; | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  | </script> | ||||||
|  | <style> | ||||||
|  | .ingredient-item { | ||||||
|  |   .d-inline { | ||||||
|  |     & > p { | ||||||
|  |       display: inline; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .text-bold { | ||||||
|  |     font-weight: bold; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .note { | ||||||
|  |   line-height: 0.8em; | ||||||
|  |   font-size: 0.8em; | ||||||
|  |   opacity: 0.7; | ||||||
|  | } | ||||||
|  | </style> | ||||||
| @@ -11,7 +11,7 @@ | |||||||
|         <v-list-item dense @click="toggleChecked(index)"> |         <v-list-item dense @click="toggleChecked(index)"> | ||||||
|           <v-checkbox hide-details :value="checked[index]" class="pt-0 my-auto py-auto" color="secondary" /> |           <v-checkbox hide-details :value="checked[index]" class="pt-0 my-auto py-auto" color="secondary" /> | ||||||
|           <v-list-item-content :key="ingredient.quantity"> |           <v-list-item-content :key="ingredient.quantity"> | ||||||
|             <SafeMarkdown class="ma-0 pa-0 text-subtitle-1 dense-markdown" :source="ingredientDisplay[index]" /> |             <RecipeIngredientListItem :ingredient="ingredient" :disable-amount="disableAmount" :scale="scale" /> | ||||||
|           </v-list-item-content> |           </v-list-item-content> | ||||||
|         </v-list-item> |         </v-list-item> | ||||||
|       </div> |       </div> | ||||||
| @@ -21,12 +21,12 @@ | |||||||
|  |  | ||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
| import { computed, defineComponent, reactive, toRefs } from "@nuxtjs/composition-api"; | import { computed, defineComponent, reactive, toRefs } from "@nuxtjs/composition-api"; | ||||||
| // @ts-ignore vue-markdown has no types | import RecipeIngredientListItem from "./RecipeIngredientListItem.vue"; | ||||||
| import { parseIngredientText } from "~/composables/recipes"; | import { parseIngredientText } from "~/composables/recipes"; | ||||||
| import { RecipeIngredient } from "~/lib/api/types/recipe"; | import { RecipeIngredient } from "~/lib/api/types/recipe"; | ||||||
|  |  | ||||||
| export default defineComponent({ | export default defineComponent({ | ||||||
|   components: {}, |   components: { RecipeIngredientListItem }, | ||||||
|   props: { |   props: { | ||||||
|     value: { |     value: { | ||||||
|       type: Array as () => RecipeIngredient[], |       type: Array as () => RecipeIngredient[], | ||||||
| @@ -52,7 +52,11 @@ export default defineComponent({ | |||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     const ingredientCopyText = computed(() => { |     const ingredientCopyText = computed(() => { | ||||||
|       return ingredientDisplay.value.join("\n"); |       return props.value | ||||||
|  |         .map((ingredient) => { | ||||||
|  |           return `${parseIngredientText(ingredient, props.disableAmount, props.scale)}`; | ||||||
|  |         }) | ||||||
|  |         .join("\n"); | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     function toggleChecked(index: number) { |     function toggleChecked(index: number) { | ||||||
| @@ -61,16 +65,8 @@ export default defineComponent({ | |||||||
|       state.checked.splice(index, 1, !state.checked[index]); |       state.checked.splice(index, 1, !state.checked[index]); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     const ingredientDisplay = computed(() => { |  | ||||||
|       return props.value.map((ingredient) => { |  | ||||||
|         return `${parseIngredientText(ingredient, props.disableAmount, props.scale)}`; |  | ||||||
|       }); |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     return { |     return { | ||||||
|       ingredientDisplay, |  | ||||||
|       ...toRefs(state), |       ...toRefs(state), | ||||||
|       parseIngredientText, |  | ||||||
|       ingredientCopyText, |       ingredientCopyText, | ||||||
|       toggleChecked, |       toggleChecked, | ||||||
|     }; |     }; | ||||||
|   | |||||||
| @@ -13,7 +13,7 @@ | |||||||
|         > |         > | ||||||
|           <template #label> |           <template #label> | ||||||
|             <div :class="listItem.checked ? 'strike-through' : ''"> |             <div :class="listItem.checked ? 'strike-through' : ''"> | ||||||
|               {{ listItem.display }} |               <RecipeIngredientListItem :ingredient="listItem" :disable-amount="!(listItem.quantity && (listItem.isFood || listItem.quantity !== 1))" /> | ||||||
|             </div> |             </div> | ||||||
|           </template> |           </template> | ||||||
|         </v-checkbox> |         </v-checkbox> | ||||||
| @@ -70,6 +70,7 @@ | |||||||
|  |  | ||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
| import { defineComponent, computed, ref, useContext } from "@nuxtjs/composition-api"; | import { defineComponent, computed, ref, useContext } from "@nuxtjs/composition-api"; | ||||||
|  | import RecipeIngredientListItem from "../Recipe/RecipeIngredientListItem.vue"; | ||||||
| import ShoppingListItemEditor from "./ShoppingListItemEditor.vue"; | import ShoppingListItemEditor from "./ShoppingListItemEditor.vue"; | ||||||
| import MultiPurposeLabel from "./MultiPurposeLabel.vue"; | import MultiPurposeLabel from "./MultiPurposeLabel.vue"; | ||||||
| import { ShoppingListItemOut } from "~/lib/api/types/group"; | import { ShoppingListItemOut } from "~/lib/api/types/group"; | ||||||
| @@ -82,7 +83,7 @@ interface actions { | |||||||
| } | } | ||||||
|  |  | ||||||
| export default defineComponent({ | export default defineComponent({ | ||||||
|   components: { ShoppingListItemEditor, MultiPurposeLabel }, |   components: { ShoppingListItemEditor, MultiPurposeLabel, RecipeIngredientListItem }, | ||||||
|   props: { |   props: { | ||||||
|     value: { |     value: { | ||||||
|       type: Object as () => ShoppingListItemOut, |       type: Object as () => ShoppingListItemOut, | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| export { useFraction } from "./use-fraction"; | export { useFraction } from "./use-fraction"; | ||||||
| export { useRecipe } from "./use-recipe"; | export { useRecipe } from "./use-recipe"; | ||||||
| export { useRecipes, recentRecipes, allRecipes, useLazyRecipes } from "./use-recipes"; | export { useRecipes, recentRecipes, allRecipes, useLazyRecipes } from "./use-recipes"; | ||||||
| export { parseIngredientText } from "./use-recipe-ingredients"; | export { parseIngredientText, useParsedIngredientText } from "./use-recipe-ingredients"; | ||||||
| export { useTools } from "./use-recipe-tools"; | export { useTools } from "./use-recipe-tools"; | ||||||
| export { useRecipeMeta } from "./use-recipe-meta"; | export { useRecipeMeta } from "./use-recipe-meta"; | ||||||
|   | |||||||
							
								
								
									
										42
									
								
								frontend/composables/recipes/use-recipe-ingredients.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								frontend/composables/recipes/use-recipe-ingredients.test.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,42 @@ | |||||||
|  | import { describe, test, expect } from "vitest"; | ||||||
|  | import { parseIngredientText } from "./use-recipe-ingredients"; | ||||||
|  | import { RecipeIngredient } from "~/lib/api/types/recipe"; | ||||||
|  |  | ||||||
|  | describe(parseIngredientText.name, () => { | ||||||
|  |   const createRecipeIngredient = (overrides: Partial<RecipeIngredient>): RecipeIngredient => ({ | ||||||
|  |     quantity: 1, | ||||||
|  |     food: { | ||||||
|  |       id: "1", | ||||||
|  |       name: "Item 1", | ||||||
|  |     }, | ||||||
|  |     unit: { | ||||||
|  |       id: "1", | ||||||
|  |       name: "cup", | ||||||
|  |     }, | ||||||
|  |     ...overrides, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   test("uses ingredient note if disableAmount: true", () => { | ||||||
|  |     const ingredient = createRecipeIngredient({ note: "foo" }); | ||||||
|  |  | ||||||
|  |     expect(parseIngredientText(ingredient, true)).toEqual("foo"); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   test("adds note section if note present", () => { | ||||||
|  |     const ingredient = createRecipeIngredient({ note: "custom note" }); | ||||||
|  |  | ||||||
|  |     expect(parseIngredientText(ingredient, false)).toContain("custom note"); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   test("ingredient text with fraction", () => { | ||||||
|  |     const ingredient = createRecipeIngredient({ quantity: 1.5, unit: { fraction: true, id: "1", name: "cup" } }); | ||||||
|  |  | ||||||
|  |     expect(parseIngredientText(ingredient, false)).contain("1 <sup>1</sup>").and.to.contain("<sub>2</sub>"); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   test("sanitizes html", () => { | ||||||
|  |     const ingredient = createRecipeIngredient({ note: "<script>alert('foo')</script>" }); | ||||||
|  |  | ||||||
|  |     expect(parseIngredientText(ingredient, false)).not.toContain("<script>"); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
| @@ -10,11 +10,14 @@ function sanitizeIngredientHTML(rawHtml: string) { | |||||||
|   }); |   }); | ||||||
| } | } | ||||||
|  |  | ||||||
| export function parseIngredientText(ingredient: RecipeIngredient, disableAmount: boolean, scale = 1): string { | export function useParsedIngredientText(ingredient: RecipeIngredient, disableAmount: boolean, scale = 1) { | ||||||
|   // TODO: the backend now supplies a "display" property which does this for us, so we don't need this function |  | ||||||
|  |  | ||||||
|   if (disableAmount) { |   if (disableAmount) { | ||||||
|     return ingredient.note || ""; |     return { | ||||||
|  |       name: ingredient.note ? sanitizeIngredientHTML(ingredient.note) : undefined, | ||||||
|  |       quantity: undefined, | ||||||
|  |       unit: undefined, | ||||||
|  |       note: undefined, | ||||||
|  |     }; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   const { quantity, food, unit, note } = ingredient; |   const { quantity, food, unit, note } = ingredient; | ||||||
| @@ -43,6 +46,17 @@ export function parseIngredientText(ingredient: RecipeIngredient, disableAmount: | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   const text = `${returnQty} ${unitDisplay || " "}  ${food?.name || " "} ${note || " "}`.replace(/ {2,}/g, " "); |   return { | ||||||
|  |     quantity: returnQty ? sanitizeIngredientHTML(returnQty) : undefined, | ||||||
|  |     unit: unitDisplay ? sanitizeIngredientHTML(unitDisplay) : undefined, | ||||||
|  |     name: food?.name ? sanitizeIngredientHTML(food.name) : undefined, | ||||||
|  |     note: note ? sanitizeIngredientHTML(note) : undefined, | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function parseIngredientText(ingredient: RecipeIngredient, disableAmount: boolean, scale = 1): string { | ||||||
|  |   const { quantity, unit, name, note } = useParsedIngredientText(ingredient, disableAmount, scale); | ||||||
|  |  | ||||||
|  |   const text = `${quantity || ""} ${unit || ""} ${name || ""} ${note || ""}`.replace(/ {2,}/g, " ").trim(); | ||||||
|   return sanitizeIngredientHTML(text); |   return sanitizeIngredientHTML(text); | ||||||
| } | } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user