1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-08-02 12:05:21 +02:00

feat: "I Made This" Dialog (#1801)

* added chef hat

* removed unnecessary log

* modified recipe and recipe timeline event schema
changed timeline event "message" -> "event_message"
added "last made" timestamp to recipe

* added "I made this" dialog to recipe action menu

* added missing field and re-ran code-gen

* moved dialog out of context menu and refactored
removed references in action menu and context menu
refactored dialog to be triggered by a button instead
added route to update recipe last made timestamp
added visual for last made timestamp to recipe header and title

* added sorting by last made

* switched event type to comment

* replaced alter column with pydantic alias

* added tests for event message alias
This commit is contained in:
Michael Genson 2022-11-13 17:12:53 -06:00 committed by GitHub
parent f0e6496001
commit a2dcdc1adf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 275 additions and 9 deletions

View file

@ -0,0 +1,28 @@
"""added recipe last made timestamp
Revision ID: 1923519381ad
Revises: 2ea7a807915c
Create Date: 2022-11-03 13:10:24.811134
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "1923519381ad"
down_revision = "2ea7a807915c"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("recipes", sa.Column("last_made", sa.DateTime(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("recipes", "last_made")
# ### end Alembic commands ###

View file

@ -49,6 +49,12 @@
</v-icon>
<v-list-item-title>{{ $t("general.updated") }}</v-list-item-title>
</v-list-item>
<v-list-item @click="sortRecipes(EVENTS.lastMade)">
<v-icon left>
{{ $globals.icons.chefHat }}
</v-icon>
<v-list-item-title>{{ "Last Made" }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<ContextMenu
@ -186,6 +192,7 @@ export default defineComponent({
rating: "rating",
created: "created",
updated: "updated",
lastMade: "lastMade",
shuffle: "shuffle",
};
@ -303,6 +310,9 @@ export default defineComponent({
case EVENTS.updated:
setter("update_at", $globals.icons.sortClockAscending, $globals.icons.sortClockDescending);
break;
case EVENTS.lastMade:
setter("last_made", $globals.icons.sortCalendarAscending, $globals.icons.sortCalendarDescending);
break;
default:
console.log("Unknown Event", sortType);
return;

View file

@ -40,6 +40,7 @@
readonly
v-on="on"
></v-text-field>
</template>
<v-date-picker v-model="newMealdate" no-title @input="pickerMenu = false"></v-date-picker>
</v-menu>

View file

@ -84,7 +84,6 @@ export default defineComponent({
return props.value;
},
set: (val) => {
console.log(val);
context.emit("input", val);
},
});

View file

@ -0,0 +1,143 @@
<template>
<div>
<div>
<BaseDialog
v-model="madeThisDialog"
:icon="$globals.icons.chefHat"
title="I Made This"
:submit-text="$tc('general.save')"
@submit="createTimelineEvent"
>
<v-card-text>
<v-form ref="domMadeThisForm">
<v-textarea
v-model="newTimelineEvent.eventMessage"
autofocus
label="Comment"
hint="How did it turn out?"
persistent-hint
rows="4"
></v-textarea>
<v-menu
v-model="datePickerMenu"
:close-on-content-click="false"
transition="scale-transition"
offset-y
max-width="290px"
min-width="auto"
>
<template #activator="{ on, attrs }">
<v-text-field
v-model="newTimelineEvent.timestamp"
:prepend-icon="$globals.icons.calendar"
v-bind="attrs"
readonly
v-on="on"
></v-text-field>
</template>
<v-date-picker v-model="newTimelineEvent.timestamp" no-title @input="datePickerMenu = false"></v-date-picker>
</v-menu>
</v-form>
</v-card-text>
</BaseDialog>
</div>
<div>
<v-chip
label
color="accent custom-transparent"
class="ma-1"
style="height:100%;"
>
<v-icon left>
{{ $globals.icons.calendar }}
</v-icon>
Last Made {{ value ? new Date(value).toLocaleDateString($i18n.locale) : "Never" }}
</v-chip>
<BaseButton @click="madeThisDialog = true">
<template #icon> {{ $globals.icons.chefHat }} </template>
I Made This
</BaseButton>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, reactive, ref, toRefs, useContext, } from "@nuxtjs/composition-api";
import { whenever } from "@vueuse/core";
import { VForm } from "~/types/vuetify";
import { useUserApi } from "~/composables/api";
import { RecipeTimelineEventIn } from "~/lib/api/types/recipe";
export default defineComponent({
props: {
value: {
type: String,
default: null,
},
recipeSlug: {
type: String,
required: true,
},
},
setup(props, context) {
const madeThisDialog = ref(false);
const userApi = useUserApi();
const { $auth } = useContext();
const domMadeThisForm = ref<VForm>();
const newTimelineEvent = ref<RecipeTimelineEventIn>({
// @ts-expect-error - TS doesn't like the $auth global user attribute
// eslint-disable-next-line
subject: `${$auth.user.fullName} made this`,
eventType: "comment",
eventMessage: "",
timestamp: "",
});
const state = reactive({datePickerMenu: false});
whenever(
() => madeThisDialog.value,
() => {
// Set timestamp to now
newTimelineEvent.value.timestamp = new Date().toISOString().substring(0, 10);
}
);
async function createTimelineEvent() {
if (!newTimelineEvent.value.timestamp) {
return;
}
const actions: Promise<any>[] = []
// the user only selects the date, so we set the time to noon
newTimelineEvent.value.timestamp += "T12:00:00";
actions.push(userApi.recipes.createTimelineEvent(props.recipeSlug, newTimelineEvent.value));
// we also update the recipe's last made value
if (!props.value || newTimelineEvent.value.timestamp > props.value) {
const payload = {lastMade: newTimelineEvent.value.timestamp};
actions.push(userApi.recipes.patchOne(props.recipeSlug, payload));
// update recipe in parent so the user can see it
context.emit("input", newTimelineEvent.value.timestamp);
}
await Promise.allSettled(actions)
// reset form
newTimelineEvent.value.eventMessage = "";
madeThisDialog.value = false;
domMadeThisForm.value?.reset();
}
return {
...toRefs(state),
domMadeThisForm,
madeThisDialog,
newTimelineEvent,
createTimelineEvent,
};
},
});
</script>

View file

@ -10,6 +10,14 @@
<v-divider class="my-2"></v-divider>
<SafeMarkdown :source="recipe.description" />
<v-divider></v-divider>
<div v-if="user.id" class="d-flex justify-center mt-5">
<RecipeLastMade
v-model="recipe.lastMade"
:recipe-slug="recipe.slug"
class="d-flex justify-center flex-wrap"
:class="true ? undefined : 'force-bottom'"
/>
</div>
<div class="d-flex justify-center mt-5">
<RecipeTimeCard
class="d-flex justify-center flex-wrap"
@ -58,6 +66,7 @@
<script lang="ts">
import { defineComponent, useContext, computed, ref, watch, useRouter } from "@nuxtjs/composition-api";
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
import RecipeLastMade from "~/components/Domain/Recipe/RecipeLastMade.vue";
import RecipeActionMenu from "~/components/Domain/Recipe/RecipeActionMenu.vue";
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
import { useStaticRoutes } from "~/composables/api";
@ -69,6 +78,7 @@ export default defineComponent({
RecipeTimeCard,
RecipeActionMenu,
RecipeRating,
RecipeLastMade,
},
props: {
recipe: {

View file

@ -5,6 +5,14 @@
{{ recipe.name }}
</v-card-title>
<SafeMarkdown :source="recipe.description" />
<div v-if="user.id" class="pb-2 d-flex justify-center flex-wrap">
<RecipeLastMade
v-model="recipe.lastMade"
:recipe-slug="recipe.slug"
class="d-flex justify-center flex-wrap"
:class="true ? undefined : 'force-bottom'"
/>
</div>
<div class="pb-2 d-flex justify-center flex-wrap">
<RecipeTimeCard
class="d-flex justify-center flex-wrap"
@ -48,11 +56,13 @@ import { NoUndefinedField } from "~/lib/api/types/non-generated";
import { Recipe } from "~/lib/api/types/recipe";
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
import RecipeLastMade from "~/components/Domain/Recipe/RecipeLastMade.vue";
export default defineComponent({
components: {
RecipeRating,
RecipeTimeCard,
RecipeLastMade,
},
props: {
recipe: {

View file

@ -103,6 +103,7 @@ export interface RecipeSummary {
dateUpdated?: string;
createdAt?: string;
updateAt?: string;
lastMade?: string;
}
export interface RecipeCategory {
id?: string;

View file

@ -88,6 +88,7 @@ export interface RecipeSummary {
dateUpdated?: string;
createdAt?: string;
updateAt?: string;
lastMade?: string;
}
export interface RecipeCategory {
id?: string;

View file

@ -396,6 +396,7 @@ export interface RecipeSummary {
dateUpdated?: string;
createdAt?: string;
updateAt?: string;
lastMade?: string;
}
export interface RecipeCategory {
id?: string;

View file

@ -118,6 +118,7 @@ export interface RecipeSummary {
dateUpdated?: string;
createdAt?: string;
updateAt?: string;
lastMade?: string;
}
export interface RecipeCategory {
id?: string;

View file

@ -211,6 +211,7 @@ export interface Recipe {
dateUpdated?: string;
createdAt?: string;
updateAt?: string;
lastMade?: string;
recipeInstructions?: RecipeStep[];
nutrition?: Nutrition;
settings?: RecipeSettings;
@ -286,6 +287,7 @@ export interface RecipeSummary {
dateUpdated?: string;
createdAt?: string;
updateAt?: string;
lastMade?: string;
}
export interface RecipeCommentCreate {
recipeId: string;
@ -345,7 +347,7 @@ export interface RecipeTimelineEventCreate {
userId: string;
subject: string;
eventType: TimelineEventType;
message?: string;
eventMessage?: string;
image?: string;
timestamp?: string;
recipeId: string;
@ -354,7 +356,7 @@ export interface RecipeTimelineEventIn {
userId?: string;
subject: string;
eventType: TimelineEventType;
message?: string;
eventMessage?: string;
image?: string;
timestamp?: string;
}
@ -362,7 +364,7 @@ export interface RecipeTimelineEventOut {
userId: string;
subject: string;
eventType: TimelineEventType;
message?: string;
eventMessage?: string;
image?: string;
timestamp?: string;
recipeId: string;
@ -372,7 +374,7 @@ export interface RecipeTimelineEventOut {
}
export interface RecipeTimelineEventUpdate {
subject: string;
message?: string;
eventMessage?: string;
image?: string;
}
export interface RecipeToolCreate {

View file

@ -192,6 +192,7 @@ export interface RecipeSummary {
dateUpdated?: string;
createdAt?: string;
updateAt?: string;
lastMade?: string;
}
export interface RecipeCategory {
id?: string;

View file

@ -10,6 +10,7 @@ import {
ParsedIngredient,
UpdateImageResponse,
RecipeZipTokenResponse,
RecipeTimelineEventIn,
} from "~/lib/api/types/recipe";
import { ApiRequestInstance } from "~/lib/api/types/non-generated";
@ -44,6 +45,9 @@ const routes = {
recipesSlugComments: (slug: string) => `${prefix}/recipes/${slug}/comments`,
recipesSlugCommentsId: (slug: string, id: number) => `${prefix}/recipes/${slug}/comments/${id}`,
recipesSlugTimelineEvent: (slug: string) => `${prefix}/recipes/${slug}/timeline/events`,
recipesSlugTimelineEventId: (slug: string, id: number) => `${prefix}/recipes/${slug}/timeline/events/${id}`,
};
export class RecipeAPI extends BaseCRUDAPI<CreateRecipe, Recipe, Recipe> {
@ -126,4 +130,8 @@ export class RecipeAPI extends BaseCRUDAPI<CreateRecipe, Recipe, Recipe> {
return await this.requests.post(routes.recipesCreateFromOcr, formData);
}
async createTimelineEvent(recipeSlug: string, payload: RecipeTimelineEventIn) {
return await this.requests.post(routes.recipesSlugTimelineEvent(recipeSlug), payload);
}
}

View file

@ -122,6 +122,7 @@ import {
mdiCursorMove,
mdiText,
mdiTextBoxOutline,
mdiChefHat,
} from "@mdi/js";
export const icons = {
@ -157,6 +158,7 @@ export const icons = {
check: mdiCheck,
checkboxBlankOutline: mdiCheckboxBlankOutline,
checkboxMarkedCircle: mdiCheckboxMarkedCircle,
chefHat: mdiChefHat,
clipboardCheck: mdiClipboardCheck,
clockOutline: mdiClockTimeFourOutline,
codeBraces: mdiCodeJson,

View file

@ -99,6 +99,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
# Time Stamp Properties
date_added = sa.Column(sa.Date, default=datetime.date.today)
date_updated = sa.Column(sa.DateTime)
last_made = sa.Column(sa.DateTime)
# Shopping List Refs
shopping_list_refs = orm.relationship(

View file

@ -98,6 +98,7 @@ class RecipeSummary(MealieModel):
created_at: datetime.datetime | None
update_at: datetime.datetime | None
last_made: datetime.datetime | None
class Config:
orm_mode = True

View file

@ -1,7 +1,7 @@
from datetime import datetime
from enum import Enum
from pydantic import UUID4
from pydantic import UUID4, Field
from mealie.schema._mealie.mealie_model import MealieModel
from mealie.schema.response.pagination import PaginationBase
@ -20,7 +20,7 @@ class RecipeTimelineEventIn(MealieModel):
subject: str
event_type: TimelineEventType
message: str | None = None
message: str | None = Field(alias="eventMessage")
image: str | None = None
timestamp: datetime = datetime.now()
@ -36,7 +36,7 @@ class RecipeTimelineEventCreate(RecipeTimelineEventIn):
class RecipeTimelineEventUpdate(MealieModel):
subject: str
message: str | None = None
message: str | None = Field(alias="eventMessage")
image: str | None = None

View file

@ -176,6 +176,52 @@ def test_delete_timeline_event(api_client: TestClient, unique_user: TestUser, re
assert event_response.status_code == 404
def test_timeline_event_message_alias(api_client: TestClient, unique_user: TestUser, recipes: list[Recipe]):
# create an event using aliases
recipe = recipes[0]
new_event_data = {
"userId": unique_user.user_id,
"subject": random_string(),
"eventType": "info",
"eventMessage": random_string(), # eventMessage is the correct alias for the message
}
event_response = api_client.post(
api_routes.recipes_slug_timeline_events(recipe.slug),
json=new_event_data,
headers=unique_user.token,
)
new_event = RecipeTimelineEventOut.parse_obj(event_response.json())
assert str(new_event.user_id) == new_event_data["userId"]
assert str(new_event.event_type) == new_event_data["eventType"]
assert new_event.message == new_event_data["eventMessage"]
# fetch the new event
event_response = api_client.get(
api_routes.recipes_slug_timeline_events_item_id(recipe.slug, new_event.id), headers=unique_user.token
)
assert event_response.status_code == 200
event = RecipeTimelineEventOut.parse_obj(event_response.json())
assert event == new_event
# update the event message
new_subject = random_string()
new_message = random_string()
updated_event_data = {"subject": new_subject, "eventMessage": new_message}
event_response = api_client.put(
api_routes.recipes_slug_timeline_events_item_id(recipe.slug, new_event.id),
json=updated_event_data,
headers=unique_user.token,
)
assert event_response.status_code == 200
updated_event = RecipeTimelineEventOut.parse_obj(event_response.json())
assert updated_event.subject == new_subject
assert updated_event.message == new_message
def test_create_recipe_with_timeline_event(api_client: TestClient, unique_user: TestUser, recipes: list[Recipe]):
# make sure when the recipes fixture was created that all recipes have at least one event
for recipe in recipes:

View file

@ -4,7 +4,7 @@ from mealie.core.config import get_app_settings
from mealie.services.backups_v2.alchemy_exporter import AlchemyExporter
ALEMBIC_VERSIONS = [
{"version_num": "2ea7a807915c"},
{"version_num": "1923519381ad"},
]