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"
/>
<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>
</v-card>
@ -168,13 +171,13 @@
<script lang="ts">
import { defineComponent, reactive, toRefs, useContext, useRouter, ref } from "@nuxtjs/composition-api";
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
import RecipeDialogPrintPreferences from "./RecipeDialogPrintPreferences.vue";
import RecipeDialogShare from "./RecipeDialogShare.vue";
import { useUserApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
import { usePlanTypeOptions } from "~/composables/use-group-mealplan";
import { Recipe, RecipeIngredient } from "~/lib/api/types/recipe";
import { parseIngredientText } from "~/composables/recipes";
import { ShoppingListSummary } from "~/lib/api/types/group";
import { PlanEntryType } from "~/lib/api/types/meal-plan";
import { useAxiosDownloader } from "~/composables/api/use-axios-download";
@ -203,7 +206,8 @@ export default defineComponent({
components: {
RecipeDialogPrintPreferences,
RecipeDialogShare,
},
RecipeIngredientListItem
},
props: {
useItems: {
type: Object as () => ContextMenuIncludes,
@ -384,7 +388,7 @@ export default defineComponent({
const shoppingLists = ref<ShoppingListSummary[]>();
const selectedShoppingList = ref<ShoppingListSummary>();
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() {
const { data } = await api.shopping.lists.getAll();
@ -411,7 +415,7 @@ export default defineComponent({
return {
checked: true,
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-checkbox hide-details :value="checked[index]" class="pt-0 my-auto py-auto" color="secondary" />
<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>
</div>
@ -21,12 +21,12 @@
<script lang="ts">
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 { RecipeIngredient } from "~/lib/api/types/recipe";
export default defineComponent({
components: {},
components: { RecipeIngredientListItem },
props: {
value: {
type: Array as () => RecipeIngredient[],
@ -52,7 +52,11 @@ export default defineComponent({
});
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) {
@ -61,16 +65,8 @@ export default defineComponent({
state.checked.splice(index, 1, !state.checked[index]);
}
const ingredientDisplay = computed(() => {
return props.value.map((ingredient) => {
return `${parseIngredientText(ingredient, props.disableAmount, props.scale)}`;
});
});
return {
ingredientDisplay,
...toRefs(state),
parseIngredientText,
ingredientCopyText,
toggleChecked,
};

View File

@ -13,7 +13,7 @@
>
<template #label>
<div :class="listItem.checked ? 'strike-through' : ''">
{{ listItem.display }}
<RecipeIngredientListItem :ingredient="listItem" :disable-amount="!(listItem.quantity && (listItem.isFood || listItem.quantity !== 1))" />
</div>
</template>
</v-checkbox>
@ -70,6 +70,7 @@
<script lang="ts">
import { defineComponent, computed, ref, useContext } from "@nuxtjs/composition-api";
import RecipeIngredientListItem from "../Recipe/RecipeIngredientListItem.vue";
import ShoppingListItemEditor from "./ShoppingListItemEditor.vue";
import MultiPurposeLabel from "./MultiPurposeLabel.vue";
import { ShoppingListItemOut } from "~/lib/api/types/group";
@ -82,7 +83,7 @@ interface actions {
}
export default defineComponent({
components: { ShoppingListItemEditor, MultiPurposeLabel },
components: { ShoppingListItemEditor, MultiPurposeLabel, RecipeIngredientListItem },
props: {
value: {
type: Object as () => ShoppingListItemOut,

View File

@ -1,6 +1,6 @@
export { useFraction } from "./use-fraction";
export { useRecipe } from "./use-recipe";
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 { 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 {
// TODO: the backend now supplies a "display" property which does this for us, so we don't need this function
export function useParsedIngredientText(ingredient: RecipeIngredient, disableAmount: boolean, scale = 1) {
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;
@ -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);
}