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:
Hugo van Rijswijk 2023-08-21 17:32:09 +02:00 committed by GitHub
parent 2151451634
commit 50a92c165c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 140 additions and 25 deletions

View File

@ -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
}; };
}); });
} }

View File

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

View File

@ -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,
}; };

View File

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

View File

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

View 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>");
});
});

View File

@ -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);
} }