diff --git a/src/actionbar/TreeActionBar.tsx b/src/actionbar/TreeActionBar.tsx index d2113101..1549ca4f 100644 --- a/src/actionbar/TreeActionBar.tsx +++ b/src/actionbar/TreeActionBar.tsx @@ -43,6 +43,7 @@ import { import { ActionBarSeparator } from "./ActionBarSeparator"; import { ShareUrlButton } from "./ShareUrlButton"; import { useErrorNotification } from "@/hooks/useErrorNotification"; +import { ACTION_TYPE_REPORT } from "@/models/constants"; type Props = { parentContext?: any; @@ -241,7 +242,7 @@ function TreeActionBarComponent({ id: -1, model: currentModel, report_name: "printscreen.list", - type: "ir.actions.report.xml", + type: ACTION_TYPE_REPORT, datas: { model: currentModel, ids: idsToExport, diff --git a/src/context/ContentRootContext.tsx b/src/context/ContentRootContext.tsx index f5d1500d..555686c8 100644 --- a/src/context/ContentRootContext.tsx +++ b/src/context/ContentRootContext.tsx @@ -22,6 +22,11 @@ import { transformPlainMany2Ones, stringFormat } from "@/helpers/formHelper"; import { useFeatureData } from "./ConfigContext"; import { ErpFeatureKeys } from "@/models/erpFeature"; import { useNetworkRequest } from "@/hooks/useNetworkRequest"; +import { + ACTION_TYPE_REPORT, + ACTION_TYPE_WINDOW, + ACTION_TYPE_URL, +} from "@/models/constants"; export type ContentRootContextType = { processAction: ({ @@ -31,8 +36,8 @@ export type ContentRootContextType = { context, }: { actionData: any; - fields: any; - values: any; + fields?: any; + values?: any; context?: any; onRefreshParentValues?: () => void; }) => Promise; @@ -116,7 +121,7 @@ const ContentRootProvider = ( id: reportId, } = reportData; - if (type !== "ir.actions.report.xml") { + if (type !== ACTION_TYPE_REPORT) { showErrorNotification({ type: "error", title: "Error", @@ -205,8 +210,8 @@ const ContentRootProvider = ( onRefreshParentValues: onRefreshParentValuesFn, }: { actionData: any; - fields: any; - values: any; + fields?: any; + values?: any; context?: any; onRefreshParentValues?: any; }) { @@ -215,16 +220,16 @@ const ContentRootProvider = ( onRefreshParentValues.current.push(onRefreshParentValuesFn); } - if (type === "ir.actions.report.xml") { + if (type === ACTION_TYPE_REPORT) { return await generateReport({ reportData: actionData, fields, values, context, }); - } else if (type === "ir.actions.act_window") { + } else if (type === ACTION_TYPE_WINDOW) { return await runAction({ actionData, fields, values, context }); - } else if (type === "ir.actions.act_url") { + } else if (type === ACTION_TYPE_URL) { window.open( stringFormat(actionData.url, { ...values, context }), "_blank", @@ -255,7 +260,7 @@ const ContentRootProvider = ( if (!_actionData.res_model) { actionData = ( await ConnectionProvider.getHandler().readObjects({ - model: "ir.actions.act_window", + model: ACTION_TYPE_WINDOW, ids: [parseInt(_actionData.id)], context, }) diff --git a/src/models/constants.ts b/src/models/constants.ts index fc140304..5b201c33 100644 --- a/src/models/constants.ts +++ b/src/models/constants.ts @@ -1 +1,9 @@ export const DEFAULT_SEARCH_LIMIT = 80; + +export const ACTION_TYPE_WINDOW = "ir.actions.act_window"; +export const ACTION_TYPE_WINDOW_CLOSE = "ir.actions.act_window_close"; +export const ACTION_TYPE_WIZARD = "ir.actions.wizard"; +export const ACTION_TYPE_REPORT = "ir.actions.report.xml"; +export const ACTION_TYPE_URL = "ir.actions.act_url"; + +export const MODEL_ACTIONS = "ir.actions.actions"; diff --git a/src/ui/FavouriteButton.tsx b/src/ui/FavouriteButton.tsx index e30d9207..f3cd3bd9 100644 --- a/src/ui/FavouriteButton.tsx +++ b/src/ui/FavouriteButton.tsx @@ -19,6 +19,7 @@ import { DropdownMenuGroup, DropdownMenuItem, } from "@gisce/react-formiga-components"; +import { ACTION_TYPE_WIZARD } from "@/models/constants"; const { useToken } = theme; export type ShortcutApi = { @@ -119,7 +120,7 @@ const FavouriteButton = (props: Props) => { function handleMenuClick(item: DropdownMenuItem) { const shortcut = item as ShortcutApi; - if (shortcut?.action_type === "ir.actions.wizard") { + if (shortcut?.action_type === ACTION_TYPE_WIZARD) { return; } openShortcut(shortcut); diff --git a/src/views/RootView.tsx b/src/views/RootView.tsx index 4f8a115e..3e23f6f3 100644 --- a/src/views/RootView.tsx +++ b/src/views/RootView.tsx @@ -19,7 +19,11 @@ import { transformPlainMany2Ones } from "@/helpers/formHelper"; import { nanoid } from "nanoid"; import { useLocale } from "@gisce/react-formiga-components"; import { useConfigContext, useFeatureData } from "@/context/ConfigContext"; -import { DEFAULT_SEARCH_LIMIT } from "@/models/constants"; +import { + DEFAULT_SEARCH_LIMIT, + ACTION_TYPE_WINDOW, + ACTION_TYPE_WIZARD, +} from "@/models/constants"; import { filterAllowedValues } from "@/helpers/shareUrlHelper"; import { ErpFeatureKeys } from "@/models/erpFeature"; import { useNetworkRequest } from "@/hooks/useNetworkRequest"; @@ -215,7 +219,7 @@ function RootView(props: RootViewProps, ref: any) { return await openAction({ action_id: -1, - action_type: "ir.actions.act_window", + action_type: ACTION_TYPE_WINDOW, model, views: [[view.view_id, "form"]], context: rootContext, @@ -249,7 +253,7 @@ function RootView(props: RootViewProps, ref: any) { context: rootContext, }); - if (dataForAction.type === "ir.actions.wizard") { + if (dataForAction.type === ACTION_TYPE_WIZARD) { showErrorNotification({ type: "error", title: "Error", @@ -721,7 +725,7 @@ function RootView(props: RootViewProps, ref: any) { return await openAction({ action_id: -1, - action_type: "ir.actions.act_window", + action_type: ACTION_TYPE_WINDOW, model, views: finalViews, context: rootContext, diff --git a/src/widgets/WidgetFactory.tsx b/src/widgets/WidgetFactory.tsx index 036055d7..89fc94ba 100644 --- a/src/widgets/WidgetFactory.tsx +++ b/src/widgets/WidgetFactory.tsx @@ -44,6 +44,7 @@ import { FiberGrid } from "./custom/FiberGrid"; import { Timeline } from "./custom/Timeline"; import { Indicator } from "./custom/Indicator"; import { Tags } from "./custom/Tags"; +import { ActionButtons } from "./custom/ActionButtons"; import { createElement } from "react"; const getWidgetType = (type: string) => { @@ -139,6 +140,8 @@ const getWidgetType = (type: string) => { return Carousel; case "colorPicker": return ColorPicker; + case "action_buttons": + return ActionButtons; default: return undefined; } diff --git a/src/widgets/base/many2one/Many2oneSuffix.tsx b/src/widgets/base/many2one/Many2oneSuffix.tsx index 35626ac5..d28531d3 100644 --- a/src/widgets/base/many2one/Many2oneSuffix.tsx +++ b/src/widgets/base/many2one/Many2oneSuffix.tsx @@ -19,6 +19,7 @@ import { import { useNetworkRequest } from "@/hooks/useNetworkRequest"; import { useFeatureIsEnabled } from "@/context/ConfigContext"; import { ErpFeatureKeys } from "@/models/erpFeature"; +import { ACTION_TYPE_WINDOW } from "@/models/constants"; type Props = { id: number; @@ -155,7 +156,7 @@ export const Many2oneSuffix = (props: Props) => { target: "current", initialView: { type: "form" }, action_id: -1, - action_type: "ir.actions.act_window", + action_type: ACTION_TYPE_WINDOW, }); break; case "action": diff --git a/src/widgets/custom/ActionButtons.tsx b/src/widgets/custom/ActionButtons.tsx new file mode 100644 index 00000000..61b2027e --- /dev/null +++ b/src/widgets/custom/ActionButtons.tsx @@ -0,0 +1,261 @@ +import React, { + useState, + useMemo, + useCallback, + useEffect, + useContext, +} from "react"; +import { Button as AntButton, Space } from "antd"; +import { LoadingOutlined } from "@ant-design/icons"; +import Field from "@/common/Field"; +import { iconMapper, Icon, FieldSet } from "@gisce/react-formiga-components"; +import { WidgetProps } from "@/types"; +import ErrorBoundary from "antd/es/alert/ErrorBoundary"; +import { Field as FieldOoui } from "@gisce/ooui"; +import ConnectionProvider from "@/ConnectionProvider"; +import { useNetworkRequest } from "@/hooks/useNetworkRequest"; +import { ACTION_TYPE_WINDOW } from "@/models/constants"; +import { + ContentRootContext, + ContentRootContextType, +} from "@/context/ContentRootContext"; +import { FormContext, FormContextType } from "@/context/FormContext"; + +export interface ActionButtonAction { + id?: number; + name?: string; + type?: string; + res_model?: string; + view_id?: number; + view_type?: string; + view_mode?: string; + res_id?: number; + [key: string]: any; +} + +export interface ActionButtonMethod { + name: string; + res_model: string; + args?: any[]; +} + +export interface ActionButtonItem { + name: string; + icon?: string; + action?: ActionButtonAction; + method?: ActionButtonMethod; +} + +type ActionButtonsProps = WidgetProps & { + ooui: FieldOoui; +}; + +type ActionButtonsInputProps = ActionButtonsProps & { + value?: any; +}; + +export const ActionButtons = (props: ActionButtonsProps) => { + const { ooui } = props; + + return ( + + + + + + ); +}; + +const ActionButtonsInput = (props: ActionButtonsInputProps) => { + const { value, ooui } = props; + const contentRootContext = useContext( + ContentRootContext, + ) as ContentRootContextType; + const formContext = useContext(FormContext) as FormContextType; + + const buttons: ActionButtonItem[] = useMemo(() => { + if (!value) { + console.warn("ActionButtons: No value provided"); + return []; + } + try { + const parsed = typeof value === "string" ? JSON.parse(value) : value; + return Array.isArray(parsed) ? parsed : []; + } catch (e) { + console.error( + "Failed to parse action buttons value:", + e, + "Value:", + value, + ); + return []; + } + }, [value]); + + const icon: React.ElementType | undefined = useMemo( + () => iconMapper(ooui.raw_props?.icon || ""), + [ooui.raw_props?.icon], + ); + + const shouldShowFieldSet = useMemo( + () => !!ooui.label || !!icon, + [ooui.label, icon], + ); + + const isDisabled = useMemo( + () => !ooui.activated || ooui.readOnly, + [ooui.activated, ooui.readOnly], + ); + + if (buttons.length === 0) { + return null; + } + + // Get col from parsedWidgetProps or raw_props (number of columns for button layout) + const col = parseInt( + ooui.parsedWidgetProps?.col || ooui.raw_props?.col || "1", + 10, + ); + + const content = ( +
+ {buttons.map((button, index) => ( + + ))} +
+ ); + + return ( +
+ {shouldShowFieldSet ? ( +
+ {content} +
+ ) : ( + content + )} +
+ ); +}; + +type ActionButtonProps = { + button: ActionButtonItem; + processAction: ContentRootContextType["processAction"]; + formContext: FormContextType; + disabled?: boolean; +}; + +const ActionButton = ({ + button, + processAction, + formContext, + disabled, +}: ActionButtonProps) => { + const [isLoading, setIsLoading] = useState(false); + const [getActionDataRequest, cancelGetActionDataRequest] = useNetworkRequest( + ConnectionProvider.getHandler().getActionData, + ); + const [executeMethodRequest, cancelExecuteMethodRequest] = useNetworkRequest( + ConnectionProvider.getHandler().execute, + ); + + useEffect(() => { + return () => { + cancelGetActionDataRequest(); + cancelExecuteMethodRequest(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleClick = useCallback(async () => { + setIsLoading(true); + try { + const context = await formContext.getContext(); + + if (button.action) { + let action = button.action; + + // If action has an id, read it from the server and merge + if (action.id && !action.type) { + try { + const actionData = await getActionDataRequest({ + action: `${ACTION_TYPE_WINDOW},${action.id}`, + context, + }); + // Merge server action with provided values (like res_id, view_mode, etc.) + action = { ...actionData, ...action }; + } catch (error) { + console.error("Failed to read action from server:", error); + setIsLoading(false); + return; + } + } + + await processAction({ + actionData: action, + context, + }); + } else if (button.method) { + const { name, res_model, args = [] } = button.method; + await executeMethodRequest({ + action: name, + model: res_model, + payload: args, + context, + }); + } + } catch (error) { + console.error("Failed to execute action:", error); + } finally { + setIsLoading(false); + } + }, [ + button.action, + button.method, + processAction, + getActionDataRequest, + executeMethodRequest, + formContext, + ]); + + const buttonIcon = useMemo( + () => (button.icon ? : undefined), + [button.icon], + ); + + return ( + : buttonIcon} + disabled={disabled || isLoading} + style={{ + whiteSpace: "normal", + height: "auto", + paddingTop: "3px", + paddingBottom: "3px", + }} + > + {button.name} + + ); +}; diff --git a/src/widgets/views/Dashboard/dashboardHelper.ts b/src/widgets/views/Dashboard/dashboardHelper.ts index f48fbbfa..6186be2c 100644 --- a/src/widgets/views/Dashboard/dashboardHelper.ts +++ b/src/widgets/views/Dashboard/dashboardHelper.ts @@ -1,6 +1,7 @@ import ConnectionProvider from "@/ConnectionProvider"; import { parseContext } from "@gisce/ooui"; import { nanoid } from "nanoid"; +import { ACTION_TYPE_WINDOW } from "@/models/constants"; export async function fetchAction({ actionId, @@ -11,7 +12,7 @@ export async function fetchAction({ rootContext?: any; globalValues?: any; }): Promise { - const actionType = "ir.actions.act_window"; + const actionType = ACTION_TYPE_WINDOW; const action = `${actionType},${actionId}`; const dataForAction = await ConnectionProvider.getHandler().getActionData({ diff --git a/src/widgets/views/Form.tsx b/src/widgets/views/Form.tsx index c60f9fe6..f2723e60 100644 --- a/src/widgets/views/Form.tsx +++ b/src/widgets/views/Form.tsx @@ -57,6 +57,7 @@ import { FieldMessage, FieldMessageType, } from "../../hooks/useFieldMessages"; +import { ACTION_TYPE_WINDOW_CLOSE, MODEL_ACTIONS } from "@/models/constants"; export type FormProps = { model: string; @@ -1064,10 +1065,7 @@ function Form(props: FormProps, ref: any) { insideButtonModal ) { onSubmitSucceed?.(getCurrentId(), getValues(), getFormValues()); - } else if ( - response.type && - response.type === "ir.actions.act_window_close" - ) { + } else if (response.type && response.type === ACTION_TYPE_WINDOW_CLOSE) { onSubmitSucceed?.(getCurrentId(), getValues(), getFormValues()); } else if (response.type) { let responseContext = {}; @@ -1112,7 +1110,7 @@ function Form(props: FormProps, ref: any) { }) { const actionData = ( await ConnectionProvider.getHandler().readObjects({ - model: "ir.actions.actions", + model: MODEL_ACTIONS, ids: [parseInt(action)], context: parentContext, })