1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-08-02 20:15:24 +02:00

feat: Recipe Actions (#3448)

Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
This commit is contained in:
Michael Genson 2024-05-01 02:20:52 -05:00 committed by GitHub
parent ee87a14401
commit 3807778e2f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 860 additions and 10 deletions

View file

@ -70,6 +70,7 @@
print: true,
printPreferences: true,
share: loggedIn,
recipeActions: true,
}"
@print="$emit('print')"
/>

View file

@ -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>
@ -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";
@ -134,6 +155,7 @@ export interface ContextMenuIncludes {
print: boolean;
printPreferences: boolean;
share: boolean;
recipeActions: boolean;
}
export interface ContextMenuItem {
@ -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
@ -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"));
} else {
alert.error(i18n.tc("events.something-went-wrong"));
}
}
}
async function deleteRecipe() {
await api.recipes.deleteOne(props.slug);
@ -437,6 +473,8 @@ export default defineComponent({
...toRefs(state),
recipeRef,
recipeRefWithScale,
executeRecipeAction,
recipeActions: groupRecipeActionsStore.recipeActions,
shoppingLists,
duplicateRecipe,
contextMenuEventHandler,

View file

@ -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,
};
};

View file

@ -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)",
@ -160,6 +161,7 @@
"test": "Test",
"themes": "Themes",
"thursday": "Thursday",
"title": "Title",
"token": "Token",
"tuesday": "Tuesday",
"type": "Type",
@ -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",
@ -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",

View file

@ -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";
@ -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;
@ -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);

View file

@ -5,6 +5,9 @@
/* Do not modify it by hand - just update the pydantic models and then re-run the script
*/
export type RecipeActionType =
| "link"
| "post";
export type WebhookType = "mealplan";
export type SupportedMigrations =
| "nextcloud"
@ -26,6 +29,11 @@ export interface CreateGroupPreferences {
recipeDisableAmount?: boolean;
groupId: string;
}
export interface CreateGroupRecipeAction {
actionType: RecipeActionType;
title: string;
url: string;
}
export interface CreateInviteToken {
uses: number;
}
@ -191,6 +199,13 @@ export interface GroupEventNotifierUpdate {
options?: GroupEventNotifierOptions;
id: string;
}
export interface GroupRecipeActionOut {
actionType: RecipeActionType;
title: string;
url: string;
groupId: string;
id: string;
}
export interface GroupStatistics {
totalRecipes: number;
totalUsers: number;
@ -230,6 +245,12 @@ export interface ReadWebhook {
groupId: string;
id: string;
}
export interface SaveGroupRecipeAction {
actionType: RecipeActionType;
title: string;
url: string;
groupId: string;
}
export interface SaveInviteToken {
usesLeft: number;
groupId: string;

View file

@ -0,0 +1,14 @@
import { BaseCRUDAPI } from "../base/base-clients";
import { CreateGroupRecipeAction, GroupRecipeActionOut } from "~/lib/api/types/group";
const prefix = "/api";
const routes = {
groupRecipeActions: `${prefix}/groups/recipe-actions`,
groupRecipeActionsId: (id: string | number) => `${prefix}/groups/recipe-actions/${id}`,
};
export class GroupRecipeActionsAPI extends BaseCRUDAPI<CreateGroupRecipeAction, GroupRecipeActionOut> {
baseRoute = routes.groupRecipeActions;
itemRoute = routes.groupRecipeActionsId;
}

View file

@ -41,6 +41,7 @@ export default defineComponent({
const { i18n } = useContext();
const buttonLookup: { [key: string]: string } = {
recipes: i18n.tc("general.recipes"),
recipeActions: i18n.tc("recipe.recipe-actions"),
foods: i18n.tc("general.foods"),
units: i18n.tc("general.units"),
labels: i18n.tc("data-pages.labels.labels"),
@ -56,6 +57,11 @@ export default defineComponent({
text: i18n.t("general.recipes"),
value: "new",
to: "/group/data/recipes",
},
{
text: i18n.t("recipe.recipe-actions"),
value: "new",
to: "/group/data/recipe-actions",
divider: true,
},
{
@ -92,7 +98,13 @@ export default defineComponent({
]);
const buttonText = computed(() => {
const last = route.value.path.split("/").pop();
const last = route.value.path
.split("/")
.pop()
// convert hypenated-values to camelCase
?.replace(/-([a-z])/g, function (g) {
return g[1].toUpperCase();
})
if (last) {
return buttonLookup[last];

View file

@ -0,0 +1,265 @@
<template>
<div>
<!-- Create Dialog -->
<BaseDialog
v-model="state.createDialog"
:title="$t('data-pages.recipe-actions.new-recipe-action')"
:icon="$globals.icons.primary"
@submit="createAction"
>
<v-card-text>
<v-form ref="domNewActionForm">
<v-text-field
v-model="createTarget.title"
autofocus
:label="$t('general.title')"
:rules="[validators.required]"
/>
<v-text-field
v-model="createTarget.url"
:label="$t('general.url')"
:rules="[validators.required]"
/>
<v-select
v-model="createTarget.actionType"
:items="actionTypeOptions"
:label="$t('data-pages.recipe-actions.action-type')"
:rules="[validators.required]"
/>
</v-form>
</v-card-text>
</BaseDialog>
<!-- Edit Dialog -->
<BaseDialog
v-model="state.editDialog"
:icon="$globals.icons.primary"
:title="$t('data-pages.recipe-actions.edit-recipe-action')"
:submit-text="$tc('general.save')"
@submit="editSaveAction"
>
<v-card-text v-if="editTarget">
<div class="mt-4">
<v-text-field v-model="editTarget.title" :label="$t('general.title')"/>
</div>
<div class="mt-4">
<v-text-field v-model="editTarget.url" :label="$t('general.url')"/>
</div>
<div class="mt-4">
<v-select
v-model="editTarget.actionType"
:items="actionTypeOptions"
:label="$t('data-pages.recipe-actions.action-type')"
/>
</div>
</v-card-text>
</BaseDialog>
<!-- Delete Dialog -->
<BaseDialog
v-model="state.deleteDialog"
:title="$tc('general.confirm')"
:icon="$globals.icons.alertCircle"
color="error"
@confirm="deleteAction"
>
<v-card-text>
{{ $t("general.confirm-delete-generic") }}
<p v-if="deleteTarget" class="mt-4 ml-4">{{ deleteTarget.title }}</p>
</v-card-text>
</BaseDialog>
<!-- Bulk Delete Dialog -->
<BaseDialog
v-model="state.bulkDeleteDialog"
width="650px"
:title="$tc('general.confirm')"
:icon="$globals.icons.alertCircle"
color="error"
@confirm="deleteSelected"
>
<v-card-text>
<p class="h4">{{ $t('general.confirm-delete-generic-items') }}</p>
<v-card outlined>
<v-virtual-scroll height="400" item-height="25" :items="bulkDeleteTarget">
<template #default="{ item }">
<v-list-item class="pb-2">
<v-list-item-content>
<v-list-item-title>{{ item.name }}</v-list-item-title>
</v-list-item-content>
</v-list-item>
</template>
</v-virtual-scroll>
</v-card>
</v-card-text>
</BaseDialog>
<!-- Data Table -->
<BaseCardSectionTitle :icon="$globals.icons.primary" section :title="$tc('data-pages.recipe-actions.recipe-actions-data')"> </BaseCardSectionTitle>
<CrudTable
:table-config="tableConfig"
:headers.sync="tableHeaders"
:data="actions || []"
:bulk-actions="[{icon: $globals.icons.delete, text: $tc('general.delete'), event: 'delete-selected'}]"
@delete-one="deleteEventHandler"
@edit-one="editEventHandler"
@delete-selected="bulkDeleteEventHandler"
>
<template #button-row>
<BaseButton create @click="state.createDialog = true">{{ $t("general.create") }}</BaseButton>
</template>
<template #item.onHand="{ item }">
<v-icon :color="item.onHand ? 'success' : undefined">
{{ item.onHand ? $globals.icons.check : $globals.icons.close }}
</v-icon>
</template>
</CrudTable>
</div>
</template>
<script lang="ts">
import { defineComponent, reactive, ref, useContext } from "@nuxtjs/composition-api";
import { validators } from "~/composables/use-validators";
import { useGroupRecipeActions, useGroupRecipeActionData } from "~/composables/use-group-recipe-actions";
import { GroupRecipeActionOut } from "~/lib/api/types/group";
export default defineComponent({
setup() {
const { i18n } = useContext();
const tableConfig = {
hideColumns: true,
canExport: true,
};
const tableHeaders = [
{
text: i18n.t("general.id"),
value: "id",
show: false,
},
{
text: i18n.t("general.title"),
value: "title",
show: true,
},
{
text: i18n.t("general.url"),
value: "url",
show: true,
},
{
text: i18n.t("data-pages.recipe-actions.action-type"),
value: "actionType",
show: true,
},
];
const state = reactive({
createDialog: false,
editDialog: false,
deleteDialog: false,
bulkDeleteDialog: false,
});
const actionData = useGroupRecipeActionData();
const actionStore = useGroupRecipeActions(null, null);
const actionTypeOptions = ["link", "post"]
// ============================================================
// Create Action
async function createAction() {
// @ts-ignore groupId isn't required
await actionStore.actions.createOne({
actionType: actionData.data.actionType,
title: actionData.data.title,
url: actionData.data.url,
});
actionData.reset();
state.createDialog = false;
}
// ============================================================
// Edit Action
const editTarget = ref<GroupRecipeActionOut | null>(null);
function editEventHandler(item: GroupRecipeActionOut) {
state.editDialog = true;
editTarget.value = item;
}
async function editSaveAction() {
if (!editTarget.value) {
return;
}
await actionStore.actions.updateOne(editTarget.value);
state.editDialog = false;
}
// ============================================================
// Delete Action
const deleteTarget = ref<GroupRecipeActionOut | null>(null);
function deleteEventHandler(item: GroupRecipeActionOut) {
state.deleteDialog = true;
deleteTarget.value = item;
}
async function deleteAction() {
if (!deleteTarget.value || deleteTarget.value.id === undefined) {
return;
}
await actionStore.actions.deleteOne(deleteTarget.value.id);
state.deleteDialog = false;
}
// ============================================================
// Bulk Delete Action
const bulkDeleteTarget = ref<GroupRecipeActionOut[]>([]);
function bulkDeleteEventHandler(selection: GroupRecipeActionOut[]) {
bulkDeleteTarget.value = selection;
state.bulkDeleteDialog = true;
}
async function deleteSelected() {
for (const item of bulkDeleteTarget.value) {
await actionStore.actions.deleteOne(item.id);
}
bulkDeleteTarget.value = [];
}
return {
state,
tableConfig,
tableHeaders,
actionTypeOptions,
actions: actionStore.recipeActions,
validators,
// create
createTarget: actionData.data,
createAction,
// edit
editTarget,
editEventHandler,
editSaveAction,
// delete
deleteTarget,
deleteEventHandler,
deleteAction,
// bulk delete
bulkDeleteTarget,
bulkDeleteEventHandler,
deleteSelected,
};
},
});
</script>