Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Recipe Actions #3448

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
ac53257
added alembic revision task
michael-genson Apr 7, 2024
7564c5f
added recipe actions db model
michael-genson Apr 7, 2024
4061e23
added pydantic models
michael-genson Apr 7, 2024
daca746
added repo/routes
michael-genson Apr 7, 2024
a1ca75a
added backend tests
michael-genson Apr 7, 2024
27a7b7a
added frontend API lib
michael-genson Apr 7, 2024
e9bc9a3
added recipe actions to recipe page
michael-genson Apr 9, 2024
5c99a54
add data management page for recipe actions
michael-genson Apr 10, 2024
a29cb31
visual tweaks
michael-genson Apr 10, 2024
6423f22
lint
michael-genson Apr 10, 2024
104505c
added post type
michael-genson Apr 10, 2024
d805466
bypass preflight OPTIONS request
michael-genson Apr 10, 2024
660bcb6
CSS tweaks
michael-genson Apr 10, 2024
7d4688f
added integration docs
michael-genson Apr 10, 2024
0093dc6
lint
michael-genson Apr 10, 2024
7a207a8
Merge branch 'mealie-next' into feat/recipe-actions
michael-genson Apr 10, 2024
5d47c4d
add recipe JSON to POST request
michael-genson Apr 11, 2024
4425581
updated docs to mention the request body
michael-genson Apr 11, 2024
e3a0f14
Merge branch 'mealie-next' into feat/recipe-actions
michael-genson Apr 12, 2024
9665bd0
removed alembic task in favor of better one from upstream
michael-genson Apr 12, 2024
4523ce0
Merge remote-tracking branch 'upstream/mealie-next' into feat/recipe-…
michael-genson Apr 12, 2024
6446d1a
update revision tree
michael-genson Apr 12, 2024
6ca6468
Merge branch 'mealie-next' into feat/recipe-actions
michael-genson Apr 16, 2024
e115551
Merge branch 'mealie-next' into feat/recipe-actions
boc-the-git Apr 22, 2024
be70d88
Merge branch 'mealie-next' into feat/recipe-actions
michael-genson Apr 30, 2024
aa25c9b
Update frontend/pages/group/data.vue
michael-genson Apr 30, 2024
3722255
Update tests/integration_tests/user_group_tests/test_group_recipe_act…
michael-genson Apr 30, 2024
a261531
Update frontend/components/Domain/Recipe/RecipeContextMenu.vue
michael-genson Apr 30, 2024
b3c554c
removed unused break
michael-genson Apr 30, 2024
4a56346
add error message to post if response isn't 2xx
michael-genson Apr 30, 2024
3fa412c
docs
michael-genson Apr 30, 2024
d509bc2
lint
michael-genson Apr 30, 2024
f6e8478
Merge branch 'mealie-next' into feat/recipe-actions
Kuchenpirat May 1, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""add group recipe actions

Revision ID: 7788478a0338
Revises: d7c6efd2de42
Create Date: 2024-04-07 01:05:20.816270

"""

import sqlalchemy as sa

import mealie.db.migration_types
from alembic import op

# revision identifiers, used by Alembic.
revision = "7788478a0338"
down_revision = "d7c6efd2de42"
branch_labels = None
depends_on = None


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"recipe_actions",
sa.Column("id", mealie.db.migration_types.GUID(), nullable=False),
sa.Column("group_id", mealie.db.migration_types.GUID(), nullable=False),
sa.Column("action_type", sa.String(), nullable=False),
sa.Column("title", sa.String(), nullable=False),
sa.Column("url", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("update_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(
["group_id"],
["groups.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_recipe_actions_action_type"), "recipe_actions", ["action_type"], unique=False)
op.create_index(op.f("ix_recipe_actions_created_at"), "recipe_actions", ["created_at"], unique=False)
op.create_index(op.f("ix_recipe_actions_group_id"), "recipe_actions", ["group_id"], unique=False)
op.create_index(op.f("ix_recipe_actions_title"), "recipe_actions", ["title"], unique=False)
# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_recipe_actions_title"), table_name="recipe_actions")
op.drop_index(op.f("ix_recipe_actions_group_id"), table_name="recipe_actions")
op.drop_index(op.f("ix_recipe_actions_created_at"), table_name="recipe_actions")
op.drop_index(op.f("ix_recipe_actions_action_type"), table_name="recipe_actions")
op.drop_table("recipe_actions")
# ### end Alembic commands ###
54 changes: 51 additions & 3 deletions docs/docs/documentation/getting-started/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,60 @@ The meal planner has the concept of plan rules. These offer a flexible way to us

The shopping lists feature is a great way to keep track of what you need to buy for your next meal. You can add items directly to the shopping list or link a recipe and all of it's ingredients to track meals during the week.

!!! warning
At this time there isn't a tight integration between meal-plans and shopping lists; however, it's something we have planned for the future.


[Shopping List Demo](https://demo.mealie.io/shopping-lists){ .md-button .md-button--primary }

## Integrations

Mealie is designed to integrate with many different external services. There are several ways you can integrate with Mealie to achieve custom IoT automations, data synchronization, and anything else you can think of. [You can work directly with Mealie through the API](./api-usage.md), or leverage other services to make seamless integrations.

### Notifiers

Notifiers are event-driven notifications sent when specific actions are performed within Mealie. Some actions include:
- creating a recipe
- adding items to a shopping list
- creating a new mealplan

Notifiers use the [Apprise library](https://github.com/caronc/apprise/wiki), which integrates with a large number of notification services. In addition, certain custom notifiers send basic event data to the consumer (e.g. the `id` of the resource). These include:
- `form` and `forms`
- `json` and `jsons`
- `xml` and `xmls`

[Notifiers Demo](https://demo.mealie.io/group/notifiers){ .md-button .md-button--primary }

### Webhooks

Unlike notifiers, which are event-driven notifications, Webhooks allow you to send scheduled notifications to your desired endpoint. Webhooks are sent on the day of a scheduled mealplan, at the specified time, and contain the mealplan data in the request.

[Webhooks Demo](https://demo.mealie.io/group/webhooks){ .md-button .md-button--primary }

### Recipe Actions

Recipe Actions are custom actions you can add to all recipes in Mealie. This is a great way to add custom integrations that are fired manually. There are two types of recipe actions:
1. link - these actions will take you directly to an external page
2. post - these actions will send a `POST` request to the specified URL, with the recipe JSON in the request body. These can be used, for instance, to manually trigger a webhook in Home Assistant

Recipe Action URLs can include merge fields to inject the current recipe's data. For instance, you can use the following URL to include a Google search with the recipe's slug:
```
https://www.google.com/search?q=${slug}
```

When the action is clicked on, the `${slug}` field is replaced with the recipe's slug value. So, for example, it might take you to this URL on one of your recipes:
```
https://www.google.com/search?q=pasta-fagioli
```

A common use case for "link" recipe actions is to integrate with the Bring! shopping list. Simply add a Recipe Action with the following URL:
```
https://api.getbring.com/rest/bringrecipes/deeplink?url=${url}&source=web
```

Below is a list of all valid merge fields:
- ${id}
- ${slug}
- ${url}

To add, modify, or delete Recipe Actions, visit the Data Management page (more on that below).

## Data Management

Expand Down
2 changes: 1 addition & 1 deletion docs/docs/overrides/api.html

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions frontend/components/Domain/Recipe/RecipeActionMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
print: true,
printPreferences: true,
share: loggedIn,
recipeActions: true,
}"
@print="$emit('print')"
/>
Expand Down
40 changes: 39 additions & 1 deletion frontend/components/Domain/Recipe/RecipeContextMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,26 @@
</v-list-item-icon>
<v-list-item-title>{{ item.title }}</v-list-item-title>
</v-list-item>
<div v-if="useItems.recipeActions && recipeActions && recipeActions.length">
<v-divider />
<v-list-group @click.stop>
<template #activator>
<v-list-item-title>{{ $tc("recipe.recipe-actions") }}</v-list-item-title>
</template>
<v-list dense class="ma-0 pa-0">
<v-list-item
v-for="(action, index) in recipeActions"
:key="index"
class="pl-6"
@click="executeRecipeAction(action)"
>
<v-list-item-title>
{{ action.title }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-list-group>
</div>
</v-list>
</v-menu>
</div>
Expand All @@ -117,11 +137,12 @@ import RecipeDialogPrintPreferences from "./RecipeDialogPrintPreferences.vue";
import RecipeDialogShare from "./RecipeDialogShare.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import { useUserApi } from "~/composables/api";
import { useGroupRecipeActions } from "~/composables/use-group-recipe-actions";
import { useGroupSelf } from "~/composables/use-groups";
import { alert } from "~/composables/use-toast";
import { usePlanTypeOptions } from "~/composables/use-group-mealplan";
import { Recipe } from "~/lib/api/types/recipe";
import { ShoppingListSummary } from "~/lib/api/types/group";
import { GroupRecipeActionOut, ShoppingListSummary } from "~/lib/api/types/group";
import { PlanEntryType } from "~/lib/api/types/meal-plan";
import { useAxiosDownloader } from "~/composables/api/use-axios-download";

Expand All @@ -134,6 +155,7 @@ export interface ContextMenuIncludes {
print: boolean;
printPreferences: boolean;
share: boolean;
recipeActions: boolean;
}

export interface ContextMenuItem {
Expand Down Expand Up @@ -163,6 +185,7 @@ export default defineComponent({
print: true,
printPreferences: true,
share: true,
recipeActions: true,
}),
},
// Append items are added at the end of the useItems list
Expand Down Expand Up @@ -347,6 +370,19 @@ export default defineComponent({
}

const router = useRouter();
const groupRecipeActionsStore = useGroupRecipeActions();

async function executeRecipeAction(action: GroupRecipeActionOut) {
const response = await groupRecipeActionsStore.execute(action, props.recipe);

if (action.actionType === "post") {
if (!response || (response.status >= 200 && response.status < 300)) {
alert.success(i18n.tc("events.message-sent"));
michael-genson marked this conversation as resolved.
Show resolved Hide resolved
} else {
alert.error(i18n.tc("events.something-went-wrong"));
}
}
}

async function deleteRecipe() {
await api.recipes.deleteOne(props.slug);
Expand Down Expand Up @@ -437,6 +473,8 @@ export default defineComponent({
...toRefs(state),
recipeRef,
recipeRefWithScale,
executeRecipeAction,
recipeActions: groupRecipeActionsStore.recipeActions,
shoppingLists,
duplicateRecipe,
contextMenuEventHandler,
Expand Down
98 changes: 98 additions & 0 deletions frontend/composables/use-group-recipe-actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { computed, reactive, ref } from "@nuxtjs/composition-api";
import { useStoreActions } from "./partials/use-actions-factory";
import { useUserApi } from "~/composables/api";
import { GroupRecipeActionOut, RecipeActionType } from "~/lib/api/types/group";
import { Recipe } from "~/lib/api/types/recipe";

const groupRecipeActions = ref<GroupRecipeActionOut[] | null>(null);
const loading = ref(false);

export function useGroupRecipeActionData() {
const data = reactive({
id: "",
actionType: "link" as RecipeActionType,
title: "",
url: "",
});

function reset() {
data.id = "";
data.actionType = "link";
data.title = "";
data.url = "";
}

return {
data,
reset,
};
}

export const useGroupRecipeActions = function (
orderBy: string | null = "title",
orderDirection: string | null = "asc",
) {
const api = useUserApi();

async function refreshGroupRecipeActions() {
loading.value = true;
const { data } = await api.groupRecipeActions.getAll(1, -1, { orderBy, orderDirection });
groupRecipeActions.value = data?.items || null;
loading.value = false;
}

const recipeActions = computed<GroupRecipeActionOut[] | null>(() => {
return groupRecipeActions.value;
});

function parseRecipeActionUrl(url: string, recipe: Recipe): string {
/* eslint-disable no-template-curly-in-string */
return url
.replace("${url}", window.location.href)
.replace("${id}", recipe.id || "")
.replace("${slug}", recipe.slug || "")
/* eslint-enable no-template-curly-in-string */
};

async function execute(action: GroupRecipeActionOut, recipe: Recipe): Promise<void | Response> {
const url = parseRecipeActionUrl(action.url, recipe);

switch (action.actionType) {
case "link":
window.open(url, "_blank")?.focus();
break;
case "post":
return await fetch(url, {
method: "POST",
headers: {
// The "text/plain" content type header is used here to skip the CORS preflight request,
// since it may fail. This is fine, since we don't care about the response, we just want
// the request to get sent.
"Content-Type": "text/plain",
},
body: JSON.stringify(recipe),
}).catch((error) => {
console.error(error);
});
default:
break;
}
};

if (!groupRecipeActions.value && !loading.value) {
refreshGroupRecipeActions();
};

const actions = {
...useStoreActions<GroupRecipeActionOut>(api.groupRecipeActions, groupRecipeActions, loading),
flushStore() {
groupRecipeActions.value = [];
}
}

return {
actions,
execute,
recipeActions,
};
};
11 changes: 10 additions & 1 deletion frontend/lang/messages/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"something-went-wrong": "Something Went Wrong!",
"subscribed-events": "Subscribed Events",
"test-message-sent": "Test Message Sent",
"message-sent": "Message Sent",
"new-notification": "New Notification",
"event-notifiers": "Event Notifiers",
"apprise-url-skipped-if-blank": "Apprise URL (skipped if blank)",
Expand Down Expand Up @@ -160,6 +161,7 @@
"test": "Test",
"themes": "Themes",
"thursday": "Thursday",
"title": "Title",
"token": "Token",
"tuesday": "Tuesday",
"type": "Type",
Expand Down Expand Up @@ -582,7 +584,8 @@
"upload-image": "Upload image",
"screen-awake": "Keep Screen Awake",
"remove-image": "Remove image",
"nextStep": "Next step"
"nextStep": "Next step",
"recipe-actions": "Recipe Actions"
},
"search": {
"advanced-search": "Advanced Search",
Expand Down Expand Up @@ -1001,6 +1004,12 @@
"delete-recipes": "Delete Recipes",
"source-unit-will-be-deleted": "Source Unit will be deleted"
},
"recipe-actions": {
"recipe-actions-data": "Recipe Actions Data",
"new-recipe-action": "New Recipe Action",
"edit-recipe-action": "Edit Recipe Action",
"action-type": "Action Type"
},
"create-alias": "Create Alias",
"manage-aliases": "Manage Aliases",
"seed-data": "Seed Data",
Expand Down
3 changes: 3 additions & 0 deletions frontend/lib/api/client-user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { UtilsAPI } from "./user/utils";
import { FoodAPI } from "./user/recipe-foods";
import { UnitAPI } from "./user/recipe-units";
import { CookbookAPI } from "./user/group-cookbooks";
import { GroupRecipeActionsAPI } from "./user/group-recipe-actions";
import { WebhooksAPI } from "./user/group-webhooks";
import { RegisterAPI } from "./user/user-registration";
import { MealPlanAPI } from "./user/group-mealplan";
Expand Down Expand Up @@ -36,6 +37,7 @@ export class UserApiClient {
public foods: FoodAPI;
public units: UnitAPI;
public cookbooks: CookbookAPI;
public groupRecipeActions: GroupRecipeActionsAPI;
public groupWebhooks: WebhooksAPI;
public register: RegisterAPI;
public mealplans: MealPlanAPI;
Expand Down Expand Up @@ -65,6 +67,7 @@ export class UserApiClient {
this.users = new UserApi(requests);
this.groups = new GroupAPI(requests);
this.cookbooks = new CookbookAPI(requests);
this.groupRecipeActions = new GroupRecipeActionsAPI(requests);
this.groupWebhooks = new WebhooksAPI(requests);
this.register = new RegisterAPI(requests);
this.mealplans = new MealPlanAPI(requests);
Expand Down
Loading
Loading