From e24e28ae0391ca80e2239e2a4718fa627458ccbc Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Mon, 21 Aug 2023 09:54:02 -0500 Subject: [PATCH 01/14] feat: Improved recipeYield Parsing For Fractions and Decimals (#2507) * improved recipeYield parsing for fracs/decimals * added fix for edgecase with weird fractions * made typescript happy * lint * extracted yield calculation into composable * fixed some gross edgecases * added tests * made bare return clearer --------- Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com> --- .../RecipePageParts/RecipePageScale.vue | 24 +--- .../use-extract-recipe-yield.test.ts | 111 +++++++++++++++ .../recipe-page/use-extract-recipe-yield.ts | 132 ++++++++++++++++++ 3 files changed, 247 insertions(+), 20 deletions(-) create mode 100644 frontend/composables/recipe-page/use-extract-recipe-yield.test.ts create mode 100644 frontend/composables/recipe-page/use-extract-recipe-yield.ts diff --git a/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageScale.vue b/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageScale.vue index 6db9d01662d..d42dc5d5675 100644 --- a/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageScale.vue +++ b/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageScale.vue @@ -33,6 +33,8 @@ import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue"; import { NoUndefinedField } from "~/lib/api/types/non-generated"; import { Recipe } from "~/lib/api/types/recipe"; import { usePageState } from "~/composables/recipe-page/shared-state"; +import { useExtractRecipeYield } from "~/composables/recipe-page/use-extract-recipe-yield"; + export default defineComponent({ components: { RecipeScaleEditButton, @@ -65,29 +67,11 @@ export default defineComponent({ }); const scaledYield = computed(() => { - const regMatchNum = /\d+/; - const yieldString = props.recipe.recipeYield; - const num = yieldString?.match(regMatchNum); - - if (num && num?.length > 0) { - const yieldAsInt = parseInt(num[0]); - return yieldString?.replace(num[0], String(yieldAsInt * scaleValue.value)); - } - - return props.recipe.recipeYield; + return useExtractRecipeYield(props.recipe.recipeYield, scaleValue.value); }); const basicYield = computed(() => { - const regMatchNum = /\d+/; - const yieldString = props.recipe.recipeYield; - const num = yieldString?.match(regMatchNum); - - if (num && num?.length > 0) { - const yieldAsInt = parseInt(num[0]); - return yieldString?.replace(num[0], String(yieldAsInt)); - } - - return props.recipe.recipeYield; + return useExtractRecipeYield(props.recipe.recipeYield, 1); }); return { diff --git a/frontend/composables/recipe-page/use-extract-recipe-yield.test.ts b/frontend/composables/recipe-page/use-extract-recipe-yield.test.ts new file mode 100644 index 00000000000..3bc8e7996e8 --- /dev/null +++ b/frontend/composables/recipe-page/use-extract-recipe-yield.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from "vitest"; +import { useExtractRecipeYield } from "./use-extract-recipe-yield"; + +describe("test use extract recipe yield", () => { + test("when text empty return empty", () => { + const result = useExtractRecipeYield(null, 1); + expect(result).toStrictEqual(""); + }); + + test("when text matches nothing return text", () => { + const val = "this won't match anything"; + const result = useExtractRecipeYield(val, 1); + expect(result).toStrictEqual(val); + + const resultScaled = useExtractRecipeYield(val, 5); + expect(resultScaled).toStrictEqual(val); + }); + + test("when text matches a mixed fraction, return a scaled fraction", () => { + const val = "10 1/2 units"; + const result = useExtractRecipeYield(val, 1); + expect(result).toStrictEqual(val); + + const resultScaled = useExtractRecipeYield(val, 3); + expect(resultScaled).toStrictEqual("31 1/2 units"); + + const resultScaledPartial = useExtractRecipeYield(val, 2.5); + expect(resultScaledPartial).toStrictEqual("26 1/4 units"); + + const resultScaledInt = useExtractRecipeYield(val, 4); + expect(resultScaledInt).toStrictEqual("42 units"); + }); + + test("when text matches a fraction, return a scaled fraction", () => { + const val = "1/3 plates"; + const result = useExtractRecipeYield(val, 1); + expect(result).toStrictEqual(val); + + const resultScaled = useExtractRecipeYield(val, 2); + expect(resultScaled).toStrictEqual("2/3 plates"); + + const resultScaledInt = useExtractRecipeYield(val, 3); + expect(resultScaledInt).toStrictEqual("1 plates"); + + const resultScaledPartial = useExtractRecipeYield(val, 2.5); + expect(resultScaledPartial).toStrictEqual("5/6 plates"); + + const resultScaledMixed = useExtractRecipeYield(val, 4); + expect(resultScaledMixed).toStrictEqual("1 1/3 plates"); + }); + + test("when text matches a decimal, return a scaled, rounded decimal", () => { + const val = "1.25 parts"; + const result = useExtractRecipeYield(val, 1); + expect(result).toStrictEqual(val); + + const resultScaled = useExtractRecipeYield(val, 2); + expect(resultScaled).toStrictEqual("2.5 parts"); + + const resultScaledInt = useExtractRecipeYield(val, 4); + expect(resultScaledInt).toStrictEqual("5 parts"); + + const resultScaledPartial = useExtractRecipeYield(val, 2.5); + expect(resultScaledPartial).toStrictEqual("3.125 parts"); + + const roundedVal = "1.33333333333333333333 parts"; + const resultScaledRounded = useExtractRecipeYield(roundedVal, 2); + expect(resultScaledRounded).toStrictEqual("2.667 parts"); + }); + + test("when text matches an int, return a scaled int", () => { + const val = "5 bowls"; + const result = useExtractRecipeYield(val, 1); + expect(result).toStrictEqual(val); + + const resultScaled = useExtractRecipeYield(val, 2); + expect(resultScaled).toStrictEqual("10 bowls"); + + const resultScaledPartial = useExtractRecipeYield(val, 2.5); + expect(resultScaledPartial).toStrictEqual("12.5 bowls"); + + const resultScaledLarge = useExtractRecipeYield(val, 10); + expect(resultScaledLarge).toStrictEqual("50 bowls"); + }); + + test("when text contains an invalid fraction, return the original string", () => { + const valDivZero = "3/0 servings"; + const resultDivZero = useExtractRecipeYield(valDivZero, 3); + expect(resultDivZero).toStrictEqual(valDivZero); + + const valDivZeroMixed = "2 4/0 servings"; + const resultDivZeroMixed = useExtractRecipeYield(valDivZeroMixed, 6); + expect(resultDivZeroMixed).toStrictEqual(valDivZeroMixed); + }); + + test("when text contains a weird or small fraction, return the original string", () => { + const valWeird = "2323231239087/134527431962272135 servings"; + const resultWeird = useExtractRecipeYield(valWeird, 5); + expect(resultWeird).toStrictEqual(valWeird); + + const valSmall = "1/20230225 lovable servings"; + const resultSmall = useExtractRecipeYield(valSmall, 12); + expect(resultSmall).toStrictEqual(valSmall); + }); + + test("when text contains multiple numbers, the first is parsed as the servings amount", () => { + const val = "100 sets of 55 bowls"; + const result = useExtractRecipeYield(val, 3); + expect(result).toStrictEqual("300 sets of 55 bowls"); + }) +}); diff --git a/frontend/composables/recipe-page/use-extract-recipe-yield.ts b/frontend/composables/recipe-page/use-extract-recipe-yield.ts new file mode 100644 index 00000000000..53d17b264b9 --- /dev/null +++ b/frontend/composables/recipe-page/use-extract-recipe-yield.ts @@ -0,0 +1,132 @@ +import { useFraction } from "~/composables/recipes"; + +const matchMixedFraction = /(?:\d*\s\d*\d*|0)\/\d*\d*/; +const matchFraction = /(?:\d*\d*|0)\/\d*\d*/; +const matchDecimal = /(\d+.\d+)|(.\d+)/; +const matchInt = /\d+/; + + + +function extractServingsFromMixedFraction(fractionString: string): number | undefined { + const mixedSplit = fractionString.split(/\s/); + const wholeNumber = parseInt(mixedSplit[0]); + const fraction = mixedSplit[1]; + + const fractionSplit = fraction.split("/"); + const numerator = parseInt(fractionSplit[0]); + const denominator = parseInt(fractionSplit[1]); + + if (denominator === 0) { + return undefined; // if the denominator is zero, just give up + } + else { + return wholeNumber + (numerator / denominator); + } +} + +function extractServingsFromFraction(fractionString: string): number | undefined { + const fractionSplit = fractionString.split("/"); + const numerator = parseInt(fractionSplit[0]); + const denominator = parseInt(fractionSplit[1]); + + if (denominator === 0) { + return undefined; // if the denominator is zero, just give up + } + else { + return numerator / denominator; + } +} + + + +function findMatch(yieldString: string): [matchString: string, servings: number, isFraction: boolean] | null { + if (!yieldString) { + return null; + } + + const mixedFractionMatch = yieldString.match(matchMixedFraction); + if (mixedFractionMatch?.length) { + const match = mixedFractionMatch[0]; + const servings = extractServingsFromMixedFraction(match); + + // if the denominator is zero, return no match + if (servings === undefined) { + return null; + } else { + return [match, servings, true]; + } + } + + const fractionMatch = yieldString.match(matchFraction); + if (fractionMatch?.length) { + const match = fractionMatch[0] + const servings = extractServingsFromFraction(match); + + // if the denominator is zero, return no match + if (servings === undefined) { + return null; + } else { + return [match, servings, true]; + } + } + + const decimalMatch = yieldString.match(matchDecimal); + if (decimalMatch?.length) { + const match = decimalMatch[0]; + return [match, parseFloat(match), false]; + } + + const intMatch = yieldString.match(matchInt); + if (intMatch?.length) { + const match = intMatch[0]; + return [match, parseInt(match), false]; + } + + return null; +} + +function formatServings(servings: number, scale: number, isFraction: boolean): string { + const val = servings * scale; + if (Number.isInteger(val)) { + return val.toString(); + } else if (!isFraction) { + return (Math.round(val * 1000) / 1000).toString(); + } + + // convert val into a fraction string + const { frac } = useFraction(); + + let valString = ""; + const fraction = frac(val, 10, true); + + if (fraction[0] !== undefined && fraction[0] > 0) { + valString += fraction[0]; + } + + if (fraction[1] > 0) { + valString += ` ${fraction[1]}/${fraction[2]}`; + } + + return valString.trim(); +} + + +export function useExtractRecipeYield(yieldString: string | null, scale: number): string { + if (!yieldString) { + return ""; + } + + const match = findMatch(yieldString); + if (!match) { + return yieldString; + } + + const [matchString, servings, isFraction] = match; + + const formattedServings = formatServings(servings, scale, isFraction); + if (!formattedServings) { + return yieldString // this only happens with very weird or small fractions + } else { + return yieldString.replace(matchString, formatServings(servings, scale, isFraction)); + } +} From 2151451634dd598f44b99a08402f4cad84907a92 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Mon, 21 Aug 2023 10:00:37 -0500 Subject: [PATCH 02/14] feat: Timeline Image Uploader Improvements (#2494) * improved UI responsiveness and added image preview * added global image cropper component * added image cropper to last made dialog * style tweaks * added more specific text for creating event * mopped up some slop * renamed height and width vars --------- Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com> --- .../Domain/Recipe/RecipeLastMade.vue | 49 +++++- .../components/global/AppButtonUpload.vue | 10 +- frontend/components/global/ImageCropper.vue | 152 ++++++++++++++++++ frontend/lang/messages/en-US.json | 4 +- frontend/lib/api/user/recipes/recipe.ts | 4 +- frontend/lib/icons/icons.ts | 8 + frontend/package.json | 1 + frontend/types/components.d.ts | 2 + frontend/yarn.lock | 24 +++ 9 files changed, 246 insertions(+), 8 deletions(-) create mode 100644 frontend/components/global/ImageCropper.vue diff --git a/frontend/components/Domain/Recipe/RecipeLastMade.vue b/frontend/components/Domain/Recipe/RecipeLastMade.vue index 4b054e43d66..56b5d954151 100644 --- a/frontend/components/Domain/Recipe/RecipeLastMade.vue +++ b/frontend/components/Domain/Recipe/RecipeLastMade.vue @@ -5,7 +5,7 @@ v-model="madeThisDialog" :icon="$globals.icons.chefHat" :title="$tc('recipe.made-this')" - :submit-text="$tc('general.save')" + :submit-text="$tc('recipe.add-to-timeline')" @submit="createTimelineEvent" > @@ -49,6 +49,7 @@ + + {{ $globals.icons.close }} + {{ $i18n.tc('recipe.remove-image') }} + + + + + + @@ -120,7 +139,9 @@ export default defineComponent({ timestamp: undefined, recipeId: props.recipe?.id || "", }); - const newTimelineEventImage = ref(); + const newTimelineEventImage = ref(); + const newTimelineEventImageName = ref(""); + const newTimelineEventImagePreviewUrl = ref(); const newTimelineEventTimestamp = ref(); whenever( @@ -133,8 +154,21 @@ export default defineComponent({ } ); + function clearImage() { + newTimelineEventImage.value = undefined; + newTimelineEventImageName.value = ""; + newTimelineEventImagePreviewUrl.value = undefined; + } + function uploadImage(fileObject: File) { newTimelineEventImage.value = fileObject; + newTimelineEventImageName.value = fileObject.name; + newTimelineEventImagePreviewUrl.value = URL.createObjectURL(fileObject); + } + + function updateUploadedImage(fileObject: Blob) { + newTimelineEventImage.value = fileObject; + newTimelineEventImagePreviewUrl.value = URL.createObjectURL(fileObject); } const state = reactive({datePickerMenu: false}); @@ -166,7 +200,11 @@ export default defineComponent({ // update the image, if provided if (newTimelineEventImage.value && newEvent) { - const imageResponse = await userApi.recipes.updateTimelineEventImage(newEvent.id, newTimelineEventImage.value); + const imageResponse = await userApi.recipes.updateTimelineEventImage( + newEvent.id, + newTimelineEventImage.value, + newTimelineEventImageName.value, + ); if (imageResponse.data) { // @ts-ignore the image response data will always match a value of TimelineEventImage newEvent.image = imageResponse.data.image; @@ -176,7 +214,7 @@ export default defineComponent({ // reset form newTimelineEvent.value.eventMessage = ""; newTimelineEvent.value.timestamp = undefined; - newTimelineEventImage.value = undefined; + clearImage(); madeThisDialog.value = false; domMadeThisForm.value?.reset(); @@ -189,9 +227,12 @@ export default defineComponent({ madeThisDialog, newTimelineEvent, newTimelineEventImage, + newTimelineEventImagePreviewUrl, newTimelineEventTimestamp, createTimelineEvent, + clearImage, uploadImage, + updateUploadedImage, }; }, }); diff --git a/frontend/components/global/AppButtonUpload.vue b/frontend/components/global/AppButtonUpload.vue index 77a9e7ebf65..5ca87d22c83 100644 --- a/frontend/components/global/AppButtonUpload.vue +++ b/frontend/components/global/AppButtonUpload.vue @@ -2,7 +2,7 @@ - + {{ effIcon }} {{ text ? text : defaultText }} @@ -50,6 +50,14 @@ export default defineComponent({ type: String, default: "", }, + color: { + type: String, + default: "info", + }, + disabled: { + type: Boolean, + default: false, + } }, setup(props, context) { const file = ref(null); diff --git a/frontend/components/global/ImageCropper.vue b/frontend/components/global/ImageCropper.vue new file mode 100644 index 00000000000..b36e471b836 --- /dev/null +++ b/frontend/components/global/ImageCropper.vue @@ -0,0 +1,152 @@ + + + diff --git a/frontend/lang/messages/en-US.json b/frontend/lang/messages/en-US.json index 1b2f90fc858..e41cc45c001 100644 --- a/frontend/lang/messages/en-US.json +++ b/frontend/lang/messages/en-US.json @@ -456,6 +456,7 @@ "date-format-hint-yyyy-mm-dd": "YYYY-MM-DD format", "add-to-list": "Add to List", "add-to-plan": "Add to Plan", + "add-to-timeline": "Add to Timeline", "recipe-added-to-list": "Recipe added to list", "recipe-added-to-mealplan": "Recipe added to mealplan", "failed-to-add-recipe-to-mealplan": "Failed to add recipe to mealplan", @@ -529,7 +530,8 @@ "tree-view": "Tree View", "recipe-yield": "Recipe Yield", "unit": "Unit", - "upload-image": "Upload image" + "upload-image": "Upload image", + "remove-image": "Remove image" }, "search": { "advanced-search": "Advanced Search", diff --git a/frontend/lib/api/user/recipes/recipe.ts b/frontend/lib/api/user/recipes/recipe.ts index eef097671fe..fadfb41ff3c 100644 --- a/frontend/lib/api/user/recipes/recipe.ts +++ b/frontend/lib/api/user/recipes/recipe.ts @@ -196,10 +196,10 @@ export class RecipeAPI extends BaseCRUDAPI { ); } - async updateTimelineEventImage(eventId: string, fileObject: File) { + async updateTimelineEventImage(eventId: string, fileObject: Blob | File, fileName: string) { const formData = new FormData(); formData.append("image", fileObject); - formData.append("extension", fileObject.name.split(".").pop() ?? ""); + formData.append("extension", fileName.split(".").pop() ?? ""); return await this.requests.put(routes.recipesTimelineEventIdImage(eventId), formData); } diff --git a/frontend/lib/icons/icons.ts b/frontend/lib/icons/icons.ts index ffaec84d2ce..1b0d5e20cd4 100644 --- a/frontend/lib/icons/icons.ts +++ b/frontend/lib/icons/icons.ts @@ -135,6 +135,10 @@ import { mdiDockTop, mdiDockBottom, mdiCheckboxOutline, + mdiFlipHorizontal, + mdiFlipVertical, + mdiRotateLeft, + mdiRotateRight, } from "@mdi/js"; export const icons = { @@ -200,6 +204,8 @@ export const icons = { fileImage: mdiFileImage, filePDF: mdiFilePdfBox, filter: mdiFilter, + flipHorizontal: mdiFlipHorizontal, + flipVertical: mdiFlipVertical, folderOutline: mdiFolderOutline, food: mdiFood, formatColorFill: mdiFormatColorFill, @@ -226,6 +232,8 @@ export const icons = { printerSettings: mdiPrinterPosCog, refreshCircle: mdiRefreshCircle, robot: mdiRobot, + rotateLeft: mdiRotateLeft, + rotateRight: mdiRotateRight, search: mdiMagnify, shareVariant: mdiShareVariant, shuffleVariant: mdiShuffleVariant, diff --git a/frontend/package.json b/frontend/package.json index 3746039f03e..fdb73b48e76 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,6 +30,7 @@ "isomorphic-dompurify": "^1.0.0", "nuxt": "^2.16.0", "v-jsoneditor": "^1.4.5", + "vue-advanced-cropper": "^1.11.6", "vuedraggable": "^2.24.3", "vuetify": "^2.6.13" }, diff --git a/frontend/types/components.d.ts b/frontend/types/components.d.ts index 85725e4dd3b..8806e6da888 100644 --- a/frontend/types/components.d.ts +++ b/frontend/types/components.d.ts @@ -21,6 +21,7 @@ import DevDumpJson from "@/components/global/DevDumpJson.vue"; import DocLink from "@/components/global/DocLink.vue"; import DropZone from "@/components/global/DropZone.vue"; import HelpIcon from "@/components/global/HelpIcon.vue"; +import ImageCropper from "@/components/global/ImageCropper.vue"; import InputColor from "@/components/global/InputColor.vue"; import InputLabelType from "@/components/global/InputLabelType.vue"; import InputQuantity from "@/components/global/InputQuantity.vue"; @@ -61,6 +62,7 @@ declare module "vue" { DocLink: typeof DocLink; DropZone: typeof DropZone; HelpIcon: typeof HelpIcon; + ImageCropper: typeof ImageCropper; InputColor: typeof InputColor; InputLabelType: typeof InputLabelType; InputQuantity: typeof InputQuantity; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index f3447ac77d7..883d9d35891 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -3735,6 +3735,11 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" +classnames@^2.2.6: + version "2.3.2" + resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924" + integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw== + clean-css@^4.2.1, clean-css@^4.2.3: version "4.2.4" resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.4.tgz#733bf46eba4e607c6891ea57c24a989356831178" @@ -4277,6 +4282,11 @@ de-indent@^1.0.2: resolved "https://registry.yarnpkg.com/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d" integrity sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg== +debounce@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5" + integrity sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug== + debug@2.6.9, debug@^2.2.0, debug@^2.3.3: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" @@ -4584,6 +4594,11 @@ eastasianwidth@^0.2.0: resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== +easy-bem@^1.0.2: + version "1.1.1" + resolved "https://registry.yarnpkg.com/easy-bem/-/easy-bem-1.1.1.tgz#1bfcc10425498090bcfddc0f9c000aba91399e03" + integrity sha512-GJRqdiy2h+EXy6a8E6R+ubmqUM08BK0FWNq41k24fup6045biQ8NXxoXimiwegMQvFFV3t1emADdGNL1TlS61A== + ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" @@ -11032,6 +11047,15 @@ vm-browserify@^1.0.1: resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0" integrity sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ== +vue-advanced-cropper@^1.11.6: + version "1.11.6" + resolved "https://registry.yarnpkg.com/vue-advanced-cropper/-/vue-advanced-cropper-1.11.6.tgz#38f824e515747d749168e20de6d5eeea1a8d508b" + integrity sha512-S/3VXfnvq/8C3Js6OaxfPN709l7mrWRqI4GRklGM08glyXF147Nl74EkfyVNv7zhuNLM4stPvaQB7XUvRH9/iA== + dependencies: + classnames "^2.2.6" + debounce "^1.2.0" + easy-bem "^1.0.2" + vue-client-only@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/vue-client-only/-/vue-client-only-2.1.0.tgz#1a67a47b8ecacfa86d75830173fffee3bf8a4ee3" From 50a92c165cc93f35b941fac5e903287a72499cc7 Mon Sep 17 00:00:00 2001 From: Hugo van Rijswijk Date: Mon, 21 Aug 2023 17:32:09 +0200 Subject: [PATCH 03/14] 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 --- .../Domain/Recipe/RecipeContextMenu.vue | 14 +++-- .../Recipe/RecipeIngredientListItem.vue | 58 +++++++++++++++++++ .../Domain/Recipe/RecipeIngredients.vue | 20 +++---- .../Domain/ShoppingList/ShoppingListItem.vue | 5 +- frontend/composables/recipes/index.ts | 2 +- .../recipes/use-recipe-ingredients.test.ts | 42 ++++++++++++++ .../recipes/use-recipe-ingredients.ts | 24 ++++++-- 7 files changed, 140 insertions(+), 25 deletions(-) create mode 100644 frontend/components/Domain/Recipe/RecipeIngredientListItem.vue create mode 100644 frontend/composables/recipes/use-recipe-ingredients.test.ts diff --git a/frontend/components/Domain/Recipe/RecipeContextMenu.vue b/frontend/components/Domain/Recipe/RecipeContextMenu.vue index a5016bd753d..32ad0fb1e4f 100644 --- a/frontend/components/Domain/Recipe/RecipeContextMenu.vue +++ b/frontend/components/Domain/Recipe/RecipeContextMenu.vue @@ -114,7 +114,10 @@ color="secondary" /> - + @@ -168,13 +171,13 @@ + diff --git a/frontend/components/Domain/Recipe/RecipeIngredients.vue b/frontend/components/Domain/Recipe/RecipeIngredients.vue index 18120934d01..f69d6ab2c73 100644 --- a/frontend/components/Domain/Recipe/RecipeIngredients.vue +++ b/frontend/components/Domain/Recipe/RecipeIngredients.vue @@ -11,7 +11,7 @@ - + @@ -21,12 +21,12 @@ " }); + + expect(parseIngredientText(ingredient, false)).not.toContain(" diff --git a/frontend/components/Domain/ShoppingList/ShoppingListItem.vue b/frontend/components/Domain/ShoppingList/ShoppingListItem.vue index b7d71cac38c..8934ae1898f 100644 --- a/frontend/components/Domain/ShoppingList/ShoppingListItem.vue +++ b/frontend/components/Domain/ShoppingList/ShoppingListItem.vue @@ -26,11 +26,37 @@
@@ -38,14 +64,14 @@ - - - {{ $globals.icons.edit }} - -
+ + + + +
@@ -75,7 +101,8 @@ import ShoppingListItemEditor from "./ShoppingListItemEditor.vue"; import MultiPurposeLabel from "./MultiPurposeLabel.vue"; import { ShoppingListItemOut } from "~/lib/api/types/group"; import { MultiPurposeLabelOut, MultiPurposeLabelSummary } from "~/lib/api/types/labels"; -import { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe"; +import { IngredientFood, IngredientUnit, RecipeSummary } from "~/lib/api/types/recipe"; +import RecipeList from "~/components/Domain/Recipe/RecipeList.vue"; interface actions { text: string; @@ -105,10 +132,15 @@ export default defineComponent({ type: Array as () => IngredientFood[], required: true, }, + recipes: { + type: Map, + default: undefined, + } }, setup(props, context) { const { i18n } = useContext(); - const itemLabelCols = ref(props.value.checked ? "auto" : props.showLabel ? "6" : "8"); + const displayRecipeRefs = ref(false); + const itemLabelCols = ref(props.value.checked ? "auto" : props.showLabel ? "4" : "6"); const contextMenu: actions[] = [ { @@ -190,16 +222,34 @@ export default defineComponent({ return undefined; }); + const recipeList = computed(() => { + const recipeList: RecipeSummary[] = []; + if (!listItem.value.recipeReferences) { + return recipeList; + } + + listItem.value.recipeReferences.forEach((ref) => { + const recipe = props.recipes.get(ref.recipeId) + if (recipe) { + recipeList.push(recipe); + } + }); + + return recipeList; + }); + return { updatedLabels, save, contextHandler, + displayRecipeRefs, edit, contextMenu, itemLabelCols, listItem, localListItem, label, + recipeList, toggleEdit, }; }, diff --git a/frontend/lib/api/types/group.ts b/frontend/lib/api/types/group.ts index a0dd0517e00..42fb45a8546 100644 --- a/frontend/lib/api/types/group.ts +++ b/frontend/lib/api/types/group.ts @@ -359,6 +359,7 @@ export interface ShoppingListItemRecipeRefCreate { recipeId: string; recipeQuantity?: number; recipeScale?: number; + recipeNote?: string; } export interface ShoppingListItemOut { quantity?: number; @@ -387,6 +388,7 @@ export interface ShoppingListItemRecipeRefOut { recipeId: string; recipeQuantity?: number; recipeScale?: number; + recipeNote?: string; id: string; shoppingListItemId: string; } @@ -394,6 +396,7 @@ export interface ShoppingListItemRecipeRefUpdate { recipeId: string; recipeQuantity?: number; recipeScale?: number; + recipeNote?: string; id: string; shoppingListItemId: string; } diff --git a/frontend/pages/shopping-lists/_id.vue b/frontend/pages/shopping-lists/_id.vue index f80f1ff1a9d..7452ecc8fe3 100644 --- a/frontend/pages/shopping-lists/_id.vue +++ b/frontend/pages/shopping-lists/_id.vue @@ -19,6 +19,7 @@ :labels="allLabels || []" :units="allUnits || []" :foods="allFoods || []" + :recipes="recipeMap" @checked="saveListItem" @save="saveListItem" @delete="deleteListItem(item)" @@ -46,6 +47,7 @@ :labels="allLabels || []" :units="allUnits || []" :foods="allFoods || []" + :recipes="recipeMap" @checked="saveListItem" @save="saveListItem" @delete="deleteListItem(item)" @@ -175,8 +177,8 @@ {{ $tc('shopping-list.linked-recipes-count', shoppingList.recipeReferences ? shoppingList.recipeReferences.length : 0) }}
- -