fix(frontend): 🐛 fix section titles carrying over on deleted items (#765)

* fix(frontend): 🐛 fix section titles carrying over on deleted items

Added a UUID generator to generate unique id's and prevent list changes from causing proper virtual dom re-renders.

* lazy load json editor

* fix ingredient rendering error

* move text to input

* update settings styling

* improve mobile view

Co-authored-by: Hayden <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-10-31 14:46:46 -08:00 committed by GitHub
parent 909bc85205
commit 40462a95f1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 195 additions and 88 deletions

View File

@ -8,50 +8,71 @@
</template> </template>
<v-card> <v-card>
<v-card-title class="headline"> {{ $t("new-recipe.bulk-add") }} </v-card-title> <v-app-bar dark color="primary" class="mt-n1 mb-3">
<v-icon large left>
{{ $globals.icons.createAlt }}
</v-icon>
<v-toolbar-title class="headline"> {{ $t("new-recipe.bulk-add") }}</v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<v-card-text> <v-card-text>
<p> <v-textarea
{{ $t("new-recipe.paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list") }} v-model="inputText"
</p> outlined
<v-textarea v-model="inputText"> </v-textarea> rows="10"
:placeholder="$t('new-recipe.paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list')"
>
</v-textarea>
<v-btn outlined color="info" small @click="trimAllLines"> Trim Whitespace </v-btn>
</v-card-text> </v-card-text>
<v-divider></v-divider> <v-divider></v-divider>
<v-card-actions> <v-card-actions>
<BaseButton cancel @click="dialog = false"> </BaseButton>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-btn color="success" text @click="save"> {{ $t("general.save") }} </v-btn> <BaseButton save color="success" @click="save"> </BaseButton>
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-dialog> </v-dialog>
</div> </div>
</template> </template>
<script> <script lang="ts">
import { reactive, toRefs } from "@nuxtjs/composition-api";
export default { export default {
data() { setup(_, context) {
return { const state = reactive({
dialog: false, dialog: false,
inputText: "", inputText: "",
};
},
methods: {
splitText() {
const split = this.inputText.split("\n");
split.forEach((element, index) => {
if ((element === "\n") | (element === false)) {
split.splice(index, 1);
}
}); });
return split; function splitText() {
}, return state.inputText.split("\n").filter((line) => !(line === "\n" || !line));
save() { }
this.$emit("bulk-data", this.splitText());
this.dialog = false; function trimAllLines() {
}, const splitLintes = splitText();
splitLintes.forEach((element: string, index: number) => {
splitLintes[index] = element.trim();
});
state.inputText = splitLintes.join("\n");
}
function save() {
context.emit("bulk-data", splitText());
state.dialog = false;
}
return {
splitText,
trimAllLines,
save,
...toRefs(state),
};
}, },
}; };
</script> </script>

View File

@ -84,7 +84,7 @@
</v-tooltip> </v-tooltip>
</template> </template>
<template slot="append-outer"> <template slot="append-outer">
<v-icon class="handle">{{ $globals.icons.arrowUpDown }}</v-icon> <v-icon class="handle mt-1">{{ $globals.icons.arrowUpDown }}</v-icon>
</template> </template>
</v-text-field> </v-text-field>
</v-col> </v-col>

View File

@ -96,6 +96,7 @@ export default {
default: true, default: true,
}, },
}, },
data() { data() {
return { return {
disabledSteps: [], disabledSteps: [],

View File

@ -16,14 +16,14 @@
</div> </div>
</v-card-title> </v-card-title>
<v-divider class="mx-2"></v-divider> <v-divider class="mx-2"></v-divider>
<v-card-text class="mt-n5"> <v-card-text class="mt-n5 pt-6 pb-2">
<v-switch <v-switch
v-for="(itemValue, key) in value" v-for="(itemValue, key) in value"
:key="key" :key="key"
v-model="value[key]" v-model="value[key]"
xs
dense dense
flat class="my-1"
inset
:label="labels[key]" :label="labels[key]"
hide-details hide-details
></v-switch> ></v-switch>

View File

@ -0,0 +1,22 @@
<template>
<VJsoneditor v-model="value" height="1500px" :options="options" :attrs="$attrs"></VJsoneditor>
</template>
<script>
import VJsoneditor from "v-jsoneditor";
export default {
components: { VJsoneditor },
props: {
value: {
type: Object,
default: () => ({}),
},
options: {
type: Object,
default: () => ({}),
},
},
};
</script>

View File

@ -14,9 +14,17 @@ export const useRecipeContext = function () {
}, slug); }, slug);
loading.value = false; loading.value = false;
return recipe; return recipe;
} }
async function fetchRecipe(slug: string) {
loading.value = true;
const { data } = await api.recipes.getOne(slug);
loading.value = false;
return data;
}
async function deleteRecipe(slug: string) { async function deleteRecipe(slug: string) {
loading.value = true; loading.value = true;
const { data } = await api.recipes.deleteOne(slug); const { data } = await api.recipes.deleteOne(slug);
@ -31,5 +39,5 @@ export const useRecipeContext = function () {
return data; return data;
} }
return { loading, getBySlug, deleteRecipe, updateRecipe }; return { loading, getBySlug, deleteRecipe, updateRecipe, fetchRecipe };
}; };

View File

@ -0,0 +1,12 @@
const max = 1000000;
export function uniqueId() {
return Date.now() + Math.random() * max;
}
export function uuid4() {
// @ts-ignore
return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) =>
(c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16)
);
}

View File

@ -1,16 +1,16 @@
<template> <template>
<v-container> <v-container
:class="{
'pa-0': $vuetify.breakpoint.smAndDown,
}"
>
<v-card v-if="skeleton" :color="`white ${false ? 'darken-2' : 'lighten-4'}`" class="pa-3"> <v-card v-if="skeleton" :color="`white ${false ? 'darken-2' : 'lighten-4'}`" class="pa-3">
<v-skeleton-loader class="mx-auto" height="700px" type="card"></v-skeleton-loader> <v-skeleton-loader class="mx-auto" height="700px" type="card"></v-skeleton-loader>
</v-card> </v-card>
<v-card v-else-if="recipe"> <v-card v-else-if="recipe">
<!-- Recipe Header -->
<div class="d-flex justify-end flex-wrap align-stretch"> <div class="d-flex justify-end flex-wrap align-stretch">
<v-card <v-card v-if="!enableLandscape" width="50%" flat class="d-flex flex-column justify-center align-center">
v-if="!recipe.settings.landscapeView"
width="50%"
flat
class="d-flex flex-column justify-center align-center"
>
<v-card-text> <v-card-text>
<v-card-title class="headline pa-0 flex-column align-center"> <v-card-title class="headline pa-0 flex-column align-center">
{{ recipe.name }} {{ recipe.name }}
@ -21,6 +21,7 @@
<v-divider></v-divider> <v-divider></v-divider>
<div class="d-flex justify-center mt-5"> <div class="d-flex justify-center mt-5">
<RecipeTimeCard <RecipeTimeCard
class="d-flex justify-center flex-wrap"
:class="true ? undefined : 'force-bottom'" :class="true ? undefined : 'force-bottom'"
:prep-time="recipe.prepTime" :prep-time="recipe.prepTime"
:total-time="recipe.totalTime" :total-time="recipe.totalTime"
@ -31,14 +32,14 @@
</v-card> </v-card>
<v-img <v-img
:key="imageKey" :key="imageKey"
:max-width="recipe.settings.landscapeView ? null : '50%'" :max-width="enableLandscape ? null : '50%'"
:height="hideImage ? '50' : imageHeight" :height="hideImage ? '50' : imageHeight"
:src="recipeImage(recipe.slug, imageKey)" :src="recipeImage(recipe.slug, imageKey)"
class="d-print-none" class="d-print-none"
@error="hideImage = true" @error="hideImage = true"
> >
<RecipeTimeCard <RecipeTimeCard
v-if="recipe.settings.landscapeView" v-if="enableLandscape"
:class="true ? undefined : 'force-bottom'" :class="true ? undefined : 'force-bottom'"
:prep-time="recipe.prepTime" :prep-time="recipe.prepTime"
:total-time="recipe.totalTime" :total-time="recipe.totalTime"
@ -54,8 +55,8 @@
:logged-in="$auth.loggedIn" :logged-in="$auth.loggedIn"
:open="form" :open="form"
class="ml-auto" class="ml-auto"
@close="form = false" @close="closeEditor"
@json="jsonEditor = !jsonEditor" @json="toggleJson"
@edit=" @edit="
jsonEditor = false; jsonEditor = false;
form = true; form = true;
@ -64,14 +65,20 @@
@delete="deleteRecipe(recipe.slug)" @delete="deleteRecipe(recipe.slug)"
/> />
<div> <!-- Editors -->
<v-card-text> <LazyRecipeJsonEditor v-if="jsonEditor" v-model="recipe" class="mt-10" :options="jsonEditorOptions" />
<div v-else>
<v-card-text
:class="{
'px-2': $vuetify.breakpoint.smAndDown,
}"
>
<div v-if="form" class="d-flex justify-start align-center"> <div v-if="form" class="d-flex justify-start align-center">
<RecipeImageUploadBtn class="my-1" :slug="recipe.slug" @upload="uploadImage" @refresh="imageKey++" /> <RecipeImageUploadBtn class="my-1" :slug="recipe.slug" @upload="uploadImage" @refresh="imageKey++" />
<RecipeSettingsMenu class="my-1 mx-1" :value="recipe.settings" @upload="uploadImage" /> <RecipeSettingsMenu class="my-1 mx-1" :value="recipe.settings" @upload="uploadImage" />
</div> </div>
<!-- Recipe Title Section --> <!-- Recipe Title Section -->
<template v-if="!form && recipe.settings.landscapeView"> <template v-if="!form && !enableLandscape">
<v-card-title class="pa-0 ma-0 headline"> <v-card-title class="pa-0 ma-0 headline">
{{ recipe.name }} {{ recipe.name }}
</v-card-title> </v-card-title>
@ -95,6 +102,7 @@
<v-textarea v-model="recipe.description" auto-grow min-height="100" :label="$t('recipe.description')"> <v-textarea v-model="recipe.description" auto-grow min-height="100" :label="$t('recipe.description')">
</v-textarea> </v-textarea>
<v-text-field v-model="recipe.recipeYield" dense :label="$t('recipe.servings')"> </v-text-field>
</template> </template>
<!-- Advanced Editor --> <!-- Advanced Editor -->
@ -103,10 +111,10 @@
<draggable v-model="recipe.recipeIngredient" handle=".handle"> <draggable v-model="recipe.recipeIngredient" handle=".handle">
<RecipeIngredientEditor <RecipeIngredientEditor
v-for="(ingredient, index) in recipe.recipeIngredient" v-for="(ingredient, index) in recipe.recipeIngredient"
:key="index + 'ing-editor'" :key="ingredient.ref"
v-model="recipe.recipeIngredient[index]" v-model="recipe.recipeIngredient[index]"
:disable-amount="recipe.settings.disableAmount" :disable-amount="recipe.settings.disableAmount"
@delete="removeByIndex(recipe.recipeIngredient, index)" @delete="recipe.recipeIngredient.splice(index, 1)"
/> />
</draggable> </draggable>
<div class="d-flex justify-end mt-2"> <div class="d-flex justify-end mt-2">
@ -115,9 +123,8 @@
<BaseButton @click="addIngredient"> {{ $t("general.new") }} </BaseButton> <BaseButton @click="addIngredient"> {{ $t("general.new") }} </BaseButton>
</div> </div>
</div> </div>
<div class="d-flex justify-space-between align-center pb-3"> <div class="d-flex justify-space-between align-center pb-3">
<v-tooltip small top color="secondary darken-1"> <v-tooltip v-if="!form" small top color="secondary darken-1">
<template #activator="{ on, attrs }"> <template #activator="{ on, attrs }">
<v-btn <v-btn
v-if="recipe.recipeYield" v-if="recipe.recipeYield"
@ -139,7 +146,7 @@
<span> Reset Scale </span> <span> Reset Scale </span>
</v-tooltip> </v-tooltip>
<template v-if="!recipe.settings.disableAmount"> <template v-if="!recipe.settings.disableAmount && !form">
<v-btn color="secondary darken-1" class="mx-1" small @click="scale > 1 ? scale-- : null"> <v-btn color="secondary darken-1" class="mx-1" small @click="scale > 1 ? scale-- : null">
<v-icon> <v-icon>
{{ $globals.icons.minus }} {{ $globals.icons.minus }}
@ -154,7 +161,7 @@
<v-spacer></v-spacer> <v-spacer></v-spacer>
<RecipeRating <RecipeRating
v-if="recipe.settings.landscapeView" v-if="!enableLandscape"
:key="recipe.slug" :key="recipe.slug"
:value="recipe.rating" :value="recipe.rating"
:name="recipe.name" :name="recipe.name"
@ -222,8 +229,9 @@
<v-col cols="12" sm="12" md="8" lg="8"> <v-col cols="12" sm="12" md="8" lg="8">
<RecipeInstructions v-model="recipe.recipeInstructions" :edit="form" /> <RecipeInstructions v-model="recipe.recipeInstructions" :edit="form" />
<div class="d-flex"> <div v-if="form" class="d-flex">
<BaseButton v-if="form" class="ml-auto my-2" @click="addStep()"> {{ $t("general.new") }}</BaseButton> <RecipeDialogBulkAdd class="ml-auto my-2 mr-1" @bulk-data="addStep" />
<BaseButton class="my-2" @click="addStep()"> {{ $t("general.new") }}</BaseButton>
</div> </div>
<RecipeNotes v-model="recipe.notes" :edit="form" /> <RecipeNotes v-model="recipe.notes" :edit="form" />
</v-col> </v-col>
@ -263,8 +271,8 @@ import {
computed, computed,
defineComponent, defineComponent,
reactive, reactive,
ref,
toRefs, toRefs,
useContext,
useMeta, useMeta,
useRoute, useRoute,
useRouter, useRouter,
@ -292,6 +300,7 @@ import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientE
import RecipeIngredientParserMenu from "~/components/Domain/Recipe/RecipeIngredientParserMenu.vue"; import RecipeIngredientParserMenu from "~/components/Domain/Recipe/RecipeIngredientParserMenu.vue";
import { Recipe } from "~/types/api-types/recipe"; import { Recipe } from "~/types/api-types/recipe";
import { useStaticRoutes } from "~/composables/api"; import { useStaticRoutes } from "~/composables/api";
import { uuid4 } from "~/composables/use-uuid";
export default defineComponent({ export default defineComponent({
components: { components: {
@ -318,21 +327,53 @@ export default defineComponent({
const router = useRouter(); const router = useRouter();
const slug = route.value.params.slug; const slug = route.value.params.slug;
const api = useApiSingleton(); const api = useApiSingleton();
const imageKey = ref(1);
const { getBySlug, loading } = useRecipeContext(); const state = reactive({
form: false,
scale: 1,
hideImage: false,
imageKey: 1,
loadFailed: false,
skeleton: false,
jsonEditor: false,
jsonEditorOptions: {
mode: "code",
search: false,
mainMenuBar: false,
},
});
const { getBySlug, loading, fetchRecipe } = useRecipeContext();
const { recipeImage } = useStaticRoutes(); const { recipeImage } = useStaticRoutes();
// @ts-ignore
const { $vuetify } = useContext();
const recipe = getBySlug(slug); const recipe = getBySlug(slug);
const form = ref<boolean>(false); // ===========================================================================
// Layout Helpers
useMeta(() => ({ title: recipe?.value?.name || "Recipe" })); const enableLandscape = computed(() => {
const preferLandscape = recipe?.value?.settings?.landscapeView;
const smallScreen = !$vuetify.breakpoint.smAndUp;
if (preferLandscape) {
return true;
} else if (smallScreen) {
return true;
}
return false;
});
// ===========================================================================
// Button Click Event Handlers
async function updateRecipe(slug: string, recipe: Recipe) { async function updateRecipe(slug: string, recipe: Recipe) {
const { data } = await api.recipes.updateOne(slug, recipe); const { data } = await api.recipes.updateOne(slug, recipe);
form.value = false; state.form = false;
if (data?.slug) { if (data?.slug) {
router.push("/recipe/" + data.slug); router.push("/recipe/" + data.slug);
} }
@ -345,6 +386,16 @@ export default defineComponent({
} }
} }
async function closeEditor() {
state.form = false;
state.jsonEditor = false;
recipe.value = await fetchRecipe(slug);
}
function toggleJson() {
state.jsonEditor = !state.jsonEditor;
}
const scaledYield = computed(() => { const scaledYield = computed(() => {
const regMatchNum = /\d+/; const regMatchNum = /\d+/;
const yieldString = recipe.value?.recipeYield; const yieldString = recipe.value?.recipeYield;
@ -366,11 +417,7 @@ export default defineComponent({
if (newVersion?.data?.version) { if (newVersion?.data?.version) {
recipe.value.image = newVersion.data.version; recipe.value.image = newVersion.data.version;
} }
imageKey.value++; state.imageKey++;
}
function removeByIndex(list: Array<any>, index: number) {
list.splice(index, 1);
} }
function addStep(steps: Array<string> | null = null) { function addStep(steps: Array<string> | null = null) {
@ -393,10 +440,11 @@ export default defineComponent({
if (ingredients?.length) { if (ingredients?.length) {
const newIngredients = ingredients.map((x) => { const newIngredients = ingredients.map((x) => {
return { return {
ref: uuid4(),
title: "", title: "",
note: x, note: x,
unit: {}, unit: null,
food: {}, food: null,
disableAmount: true, disableAmount: true,
quantity: 1, quantity: 1,
}; };
@ -407,24 +455,22 @@ export default defineComponent({
} }
} else { } else {
recipe?.value?.recipeIngredient?.push({ recipe?.value?.recipeIngredient?.push({
ref: uuid4(),
title: "", title: "",
note: "", note: "",
unit: {}, unit: null,
food: {}, food: null,
disableAmount: true, disableAmount: true,
quantity: 1, quantity: 1,
}); });
} }
} }
const state = reactive({
scale: 1,
});
// =============================================================== // ===============================================================
// Metadata // Metadata
const structuredData = computed(() => { const structuredData = computed(() => {
// TODO: Get this working with other scrapers, unsure why it isn't properly being delivered to clients.
return { return {
"@context": "http://schema.org", "@context": "http://schema.org",
"@type": "Recipe", "@type": "Recipe",
@ -450,34 +496,21 @@ export default defineComponent({
}); });
return { return {
enableLandscape,
scaledYield, scaledYield,
toggleJson,
...toRefs(state), ...toRefs(state),
imageKey,
recipe, recipe,
api, api,
form,
loading, loading,
addStep, addStep,
deleteRecipe, deleteRecipe,
closeEditor,
updateRecipe, updateRecipe,
uploadImage, uploadImage,
validators, validators,
recipeImage, recipeImage,
addIngredient, addIngredient,
removeByIndex,
};
},
data() {
return {
hideImage: false,
loadFailed: false,
skeleton: false,
jsonEditor: false,
jsonEditorOptions: {
mode: "code",
search: false,
mainMenuBar: false,
},
}; };
}, },
head: {}, head: {},

View File

@ -80,10 +80,11 @@ export interface Recipe {
comments?: CommentOut[]; comments?: CommentOut[];
} }
export interface RecipeIngredient { export interface RecipeIngredient {
ref: string;
title: string; title: string;
note: string; note: string;
unit: RecipeIngredientUnit; unit?: RecipeIngredientUnit | null;
food: RecipeIngredientFood; food?: RecipeIngredientFood | null;
disableAmount: boolean; disableAmount: boolean;
quantity: number; quantity: number;
} }

View File

@ -1,7 +1,11 @@
import enum import enum
from typing import Optional, Union from typing import Optional, Union
from uuid import UUID, uuid4
from fastapi_camelcase import CamelModel from fastapi_camelcase import CamelModel
from pydantic import Field
uuid4()
class CreateIngredientFood(CamelModel): class CreateIngredientFood(CamelModel):
@ -36,6 +40,11 @@ class RecipeIngredient(CamelModel):
disable_amount: bool = True disable_amount: bool = True
quantity: float = 1 quantity: float = 1
# Ref is used as a way to distinguish between an individual ingredient on the frontend
# It is required for the reorder and section titles to function properly because of how
# Vue handles reactivity. ref may serve another purpose in the future.
ref: UUID = Field(default_factory=uuid4)
class Config: class Config:
orm_mode = True orm_mode = True