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>
<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>
<p>
{{ $t("new-recipe.paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list") }}
</p>
<v-textarea v-model="inputText"> </v-textarea>
<v-textarea
v-model="inputText"
outlined
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-divider></v-divider>
<v-card-actions>
<BaseButton cancel @click="dialog = false"> </BaseButton>
<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>
</v-dialog>
</div>
</template>
<script>
<script lang="ts">
import { reactive, toRefs } from "@nuxtjs/composition-api";
export default {
data() {
return {
setup(_, context) {
const state = reactive({
dialog: false,
inputText: "",
};
},
methods: {
splitText() {
const split = this.inputText.split("\n");
});
split.forEach((element, index) => {
if ((element === "\n") | (element === false)) {
split.splice(index, 1);
}
function splitText() {
return state.inputText.split("\n").filter((line) => !(line === "\n" || !line));
}
function trimAllLines() {
const splitLintes = splitText();
splitLintes.forEach((element: string, index: number) => {
splitLintes[index] = element.trim();
});
return split;
},
save() {
this.$emit("bulk-data", this.splitText());
this.dialog = false;
},
state.inputText = splitLintes.join("\n");
}
function save() {
context.emit("bulk-data", splitText());
state.dialog = false;
}
return {
splitText,
trimAllLines,
save,
...toRefs(state),
};
},
};
</script>

View File

@ -84,7 +84,7 @@
</v-tooltip>
</template>
<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>
</v-text-field>
</v-col>

View File

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

View File

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

View File

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

View File

@ -1,7 +1,11 @@
import enum
from typing import Optional, Union
from uuid import UUID, uuid4
from fastapi_camelcase import CamelModel
from pydantic import Field
uuid4()
class CreateIngredientFood(CamelModel):
@ -36,6 +40,11 @@ class RecipeIngredient(CamelModel):
disable_amount: bool = True
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:
orm_mode = True