diff --git a/app/packages/core/src/plugins/SchemaIO/components/OperatorExecutionButtonView.tsx b/app/packages/core/src/plugins/SchemaIO/components/OperatorExecutionButtonView.tsx
new file mode 100644
index 00000000000..12c5aba68c9
--- /dev/null
+++ b/app/packages/core/src/plugins/SchemaIO/components/OperatorExecutionButtonView.tsx
@@ -0,0 +1,161 @@
+import React from "react";
+import { MuiIconFont } from "@fiftyone/components";
+import { OperatorExecutionButton } from "@fiftyone/operators";
+import { usePanelId } from "@fiftyone/spaces";
+import { isNullish } from "@fiftyone/utilities";
+import { Box, ButtonProps, Typography } from "@mui/material";
+import { getColorByCode, getComponentProps, getDisabledColors } from "../utils";
+import { ViewPropsType } from "../utils/types";
+import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
+import TooltipProvider from "./TooltipProvider";
+
+export default function OperatorExecutionButtonView(props: ViewPropsType) {
+ const { schema, path, onClick } = props;
+ const { view = {} } = schema;
+ const {
+ description,
+ icon,
+ icon_position = "right",
+ label,
+ operator,
+ params = {},
+ prompt,
+ title,
+ disabled = false,
+ } = view;
+ const panelId = usePanelId();
+ const variant = getVariant(props);
+ const computedParams = { ...params, path, panel_id: panelId };
+
+ const Icon = icon ? (
+
+ ) : (
+
+ );
+
+ return (
+
+
+ onClick?.(e, computedParams, props)}
+ color="primary"
+ disabled={disabled}
+ startIcon={icon_position === "left" ? Icon : undefined}
+ endIcon={icon_position === "right" ? Icon : undefined}
+ title={description}
+ {...getComponentProps(props, "button", getButtonProps(props))}
+ >
+ {label}
+
+
+
+ );
+}
+
+function getButtonProps(props: ViewPropsType): ButtonProps {
+ const { label, variant, color, disabled } = props.schema.view;
+ const baseProps: ButtonProps = getCommonProps(props);
+ if (isNullish(label)) {
+ baseProps.sx["& .MuiButton-startIcon"] = { mr: 0, ml: 0 };
+ baseProps.sx.minWidth = "auto";
+ baseProps.sx.p = "6px";
+ }
+ if (variant === "round") {
+ baseProps.sx.borderRadius = "1rem";
+ baseProps.sx.p = "3.5px 10.5px";
+ }
+ if (variant === "square") {
+ baseProps.sx.borderRadius = "3px 3px 0 0";
+ baseProps.sx.backgroundColor = (theme) => theme.palette.background.field;
+ baseProps.sx.borderBottom = "1px solid";
+ baseProps.sx.paddingBottom = "5px";
+ baseProps.sx.borderColor = (theme) => theme.palette.primary.main;
+ }
+ if (variant === "outlined") {
+ baseProps.sx.p = "5px";
+ }
+ if ((variant === "square" || variant === "outlined") && isNullish(color)) {
+ const borderColor =
+ "rgba(var(--fo-palette-common-onBackgroundChannel) / 0.23)";
+ baseProps.sx.borderColor = borderColor;
+ baseProps.sx.borderBottomColor = borderColor;
+ }
+ if (isNullish(variant)) {
+ baseProps.variant = "contained";
+ baseProps.color = "tertiary";
+ baseProps.sx["&:hover"] = {
+ backgroundColor: (theme) => theme.palette.tertiary.hover,
+ };
+ }
+
+ if (disabled) {
+ const [bgColor, textColor] = getDisabledColors();
+ baseProps.sx["&.Mui-disabled"] = {
+ backgroundColor: variant === "outlined" ? "inherit" : bgColor,
+ color: textColor,
+ };
+ if (["square", "outlined"].includes(variant)) {
+ baseProps.sx["&.Mui-disabled"].backgroundColor = (theme) =>
+ theme.palette.background.field;
+ }
+ }
+
+ return baseProps;
+}
+
+function getIconProps(props: ViewPropsType): ButtonProps {
+ return getCommonProps(props);
+}
+
+function getCommonProps(props: ViewPropsType): ButtonProps {
+ const color = getColor(props);
+ const disabled = props.schema.view?.disabled || false;
+
+ return {
+ sx: {
+ color,
+ fontSize: "1rem",
+ fontWeight: "bold",
+ borderColor: color,
+ "&:hover": {
+ borderColor: color,
+ },
+ ...(disabled
+ ? {
+ opacity: 0.5,
+ }
+ : {}),
+ },
+ };
+}
+
+function getColor(props: ViewPropsType) {
+ const {
+ schema: { view = {} },
+ } = props;
+ const { color } = view;
+ if (color) {
+ return getColorByCode(color);
+ }
+ const variant = getVariant(props);
+ return (theme) => {
+ return variant === "contained"
+ ? theme.palette.common.white
+ : theme.palette.secondary.main;
+ };
+}
+
+const defaultVariant = ["contained", "outlined"];
+
+function getVariant(pros: ViewPropsType) {
+ const variant = pros.schema.view.variant;
+ if (defaultVariant.includes(variant)) return variant;
+ if (variant === "round") return "contained";
+ return "contained";
+}
diff --git a/app/packages/core/src/plugins/SchemaIO/components/index.ts b/app/packages/core/src/plugins/SchemaIO/components/index.ts
index 6494eddc110..01dfc7b5e22 100644
--- a/app/packages/core/src/plugins/SchemaIO/components/index.ts
+++ b/app/packages/core/src/plugins/SchemaIO/components/index.ts
@@ -38,6 +38,7 @@ export { default as ModalView } from "./ModalView";
export { default as NativeModelEvaluationView } from "./NativeModelEvaluationView";
export { default as ObjectView } from "./ObjectView";
export { default as OneOfView } from "./OneOfView";
+export { default as OperatorExecutionButtonView } from "./OperatorExecutionButtonView";
export { default as PillBadgeView } from "./PillBadgeView";
export { default as PlotlyView } from "./PlotlyView";
export { default as PrimitiveView } from "./PrimitiveView";
diff --git a/app/packages/operators/src/components/OperatorExecutionButton/index.tsx b/app/packages/operators/src/components/OperatorExecutionButton/index.tsx
new file mode 100644
index 00000000000..53fa289a68f
--- /dev/null
+++ b/app/packages/operators/src/components/OperatorExecutionButton/index.tsx
@@ -0,0 +1,54 @@
+import { Button } from "@mui/material";
+import { OperatorExecutionTrigger } from "../OperatorExecutionTrigger";
+import React from "react";
+import {
+ ExecutionCallback,
+ ExecutionErrorCallback,
+} from "../../types-internal";
+import { OperatorExecutionOption } from "../../state";
+
+/**
+ * Button which acts as a trigger for opening an `OperatorExecutionMenu`.
+ *
+ * @param operatorUri Operator URI
+ * @param onSuccess Callback for successful operator execution
+ * @param onError Callback for operator execution error
+ * @param executionParams Parameters to provide to the operator's execute call
+ * @param onOptionSelected Callback for execution option selection
+ * @param disabled If true, disables the button and context menu
+ */
+export const OperatorExecutionButton = ({
+ operatorUri,
+ onSuccess,
+ onError,
+ executionParams,
+ onOptionSelected,
+ disabled,
+ children,
+ ...props
+}: {
+ operatorUri: string;
+ onSuccess?: ExecutionCallback;
+ onError?: ExecutionErrorCallback;
+ executionParams?: object;
+ onOptionSelected?: (option: OperatorExecutionOption) => void;
+ disabled?: boolean;
+ children: React.ReactNode;
+}) => {
+ return (
+
+
+
+ );
+};
+
+export default OperatorExecutionButton;
diff --git a/app/packages/operators/src/components/OperatorExecutionMenu/index.tsx b/app/packages/operators/src/components/OperatorExecutionMenu/index.tsx
new file mode 100644
index 00000000000..a0477764936
--- /dev/null
+++ b/app/packages/operators/src/components/OperatorExecutionMenu/index.tsx
@@ -0,0 +1,51 @@
+import { Menu, MenuItem, Stack, Typography } from "@mui/material";
+import React from "react";
+import { OperatorExecutionOption } from "../../state";
+
+/**
+ * Component which provides a context menu for executing an operator using a
+ * specified execution target.
+ *
+ * @param anchor Element to use as context menu anchor
+ * @param open If true, context menu will be visible
+ * @param onClose Callback for context menu close events
+ * @param executionOptions List of operator execution options
+ * @param onClick Callback for an option being clicked
+ */
+export const OperatorExecutionMenu = ({
+ anchor,
+ open,
+ onClose,
+ executionOptions,
+ onOptionClick,
+}: {
+ anchor?: Element | null;
+ open: boolean;
+ onClose: () => void;
+ executionOptions: OperatorExecutionOption[];
+ onOptionClick?: (option: OperatorExecutionOption) => void;
+}) => {
+ return (
+
+ );
+};
+
+export default OperatorExecutionMenu;
diff --git a/app/packages/operators/src/components/OperatorExecutionTrigger/index.tsx b/app/packages/operators/src/components/OperatorExecutionTrigger/index.tsx
new file mode 100644
index 00000000000..88a8114da81
--- /dev/null
+++ b/app/packages/operators/src/components/OperatorExecutionTrigger/index.tsx
@@ -0,0 +1,118 @@
+import React, { useCallback, useMemo, useRef, useState } from "react";
+import { Box } from "@mui/material";
+import { OperatorExecutionMenu } from "../OperatorExecutionMenu";
+import {
+ ExecutionCallback,
+ ExecutionErrorCallback,
+ OperatorExecutorOptions,
+} from "../../types-internal";
+import {
+ OperatorExecutionOption,
+ useOperatorExecutionOptions,
+ useOperatorExecutor,
+} from "../../state";
+
+/**
+ * Component which acts as a trigger for opening an `OperatorExecutionMenu`.
+ *
+ * This component is meant to act as a wrapper around the interactable
+ * component. For example, if you wanted to add operator execution to a button,
+ *
+ * ```tsx
+ *
+ *
+ *
+ * ```
+ *
+ *
+ * This component registers a click handler which will manage the
+ * `OperatorExecutionMenu` lifecycle.
+ *
+ * @param operatorUri Operator URI
+ * @param onClick Callback for click events
+ * @param onSuccess Callback for successful operator execution
+ * @param onError Callback for operator execution error
+ * @param executionParams Parameters to provide to the operator's execute call
+ * @param executorOptions Operator executor options
+ * @param onOptionSelected Callback for execution option selection
+ * @param disabled If true, context menu will never open
+ */
+export const OperatorExecutionTrigger = ({
+ operatorUri,
+ onClick,
+ onSuccess,
+ onError,
+ executionParams,
+ executorOptions,
+ onOptionSelected,
+ disabled,
+ children,
+ ...props
+}: {
+ operatorUri: string;
+ children: React.ReactNode;
+ onClick?: () => void;
+ onSuccess?: ExecutionCallback;
+ onError?: ExecutionErrorCallback;
+ executionParams?: object;
+ executorOptions?: OperatorExecutorOptions;
+ onOptionSelected?: (option: OperatorExecutionOption) => void;
+ disabled?: boolean;
+}) => {
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+ // Anchor to use for context menu
+ const containerRef = useRef(null);
+
+ // Pass onSuccess and onError through to the operator executor.
+ // These will be invoked on operator completion.
+ const operatorHandlers = useMemo(() => {
+ return { onSuccess, onError };
+ }, [onSuccess, onError]);
+ const operator = useOperatorExecutor(operatorUri, operatorHandlers);
+
+ // This callback will be invoked when an execution target option is clicked
+ const onExecute = useCallback(
+ (options?: OperatorExecutorOptions) => {
+ const resolvedOptions = {
+ ...executorOptions,
+ ...options,
+ };
+
+ return operator.execute(executionParams ?? {}, resolvedOptions);
+ },
+ [executorOptions, operator, executionParams]
+ );
+
+ const { executionOptions } = useOperatorExecutionOptions({
+ operatorUri,
+ onExecute,
+ });
+
+ // Click handler controls the state of the context menu.
+ const clickHandler = useCallback(() => {
+ if (disabled) {
+ setIsMenuOpen(false);
+ } else {
+ onClick?.();
+ setIsMenuOpen(true);
+ }
+ }, [setIsMenuOpen, onClick, disabled]);
+
+ return (
+ <>
+
+ {children}
+
+
+ setIsMenuOpen(false)}
+ onOptionClick={onOptionSelected}
+ executionOptions={executionOptions}
+ />
+ >
+ );
+};
+
+export default OperatorExecutionTrigger;
diff --git a/app/packages/operators/src/index.ts b/app/packages/operators/src/index.ts
index 2adaba5f049..aa0e917e2cb 100644
--- a/app/packages/operators/src/index.ts
+++ b/app/packages/operators/src/index.ts
@@ -4,6 +4,7 @@ export { default as OperatorBrowser } from "./OperatorBrowser";
export { default as OperatorCore } from "./OperatorCore";
export { default as OperatorInvocationRequestExecutor } from "./OperatorInvocationRequestExecutor";
export { default as OperatorIO } from "./OperatorIO";
+export { default as OperatorExecutionButton } from "./components/OperatorExecutionButton";
export {
OperatorPlacementWithErrorBoundary,
default as OperatorPlacements,
diff --git a/app/packages/operators/src/state.ts b/app/packages/operators/src/state.ts
index a1742ea1d1d..f7ec14248c4 100644
--- a/app/packages/operators/src/state.ts
+++ b/app/packages/operators/src/state.ts
@@ -225,13 +225,29 @@ function useExecutionOptions(operatorURI, ctx, isRemote) {
return { isLoading, executionOptions, fetch };
}
+/**
+ * Type representing an operator execution target.
+ */
+export type OperatorExecutionOption = {
+ label: string;
+ id: string;
+ description: string;
+ onClick: () => void;
+ isDelegated: boolean;
+ choiceLabel?: string;
+ tag?: string;
+ default?: boolean;
+ selected?: boolean;
+ onSelect?: () => void;
+};
+
const useOperatorPromptSubmitOptions = (
operatorURI,
execDetails,
- execute,
+ execute: (options?: OperatorExecutorOptions) => void,
promptView?: OperatorPromptType["promptView"]
) => {
- const options = [];
+ const options: OperatorExecutionOption[] = [];
const persistUnderKey = `operator-prompt-${operatorURI}`;
const availableOrchestrators =
execDetails.executionOptions?.availableOrchestrators || [];
@@ -260,6 +276,7 @@ const useOperatorPromptSubmitOptions = (
onClick() {
execute();
},
+ isDelegated: false,
});
}
if (
@@ -277,6 +294,7 @@ const useOperatorPromptSubmitOptions = (
onClick() {
execute({ requestDelegation: true });
},
+ isDelegated: true,
});
}
@@ -300,6 +318,7 @@ const useOperatorPromptSubmitOptions = (
requestDelegation: true,
});
},
+ isDelegated: true,
});
}
}
@@ -311,8 +330,15 @@ const useOperatorPromptSubmitOptions = (
return 0;
});
+ const fallbackId = executionOptions.allowImmediateExecution
+ ? "execute"
+ : "schedule";
+
const defaultID =
- options.find((option) => option.default)?.id || options[0]?.id || "execute";
+ options.find((option) => option.default)?.id ||
+ options[0]?.id ||
+ fallbackId;
+
let [selectedID, setSelectedID] = fos.useBrowserStorage(
persistUnderKey,
defaultID
@@ -320,8 +346,13 @@ const useOperatorPromptSubmitOptions = (
const selectedOption = options.find((option) => option.id === selectedID);
useEffect(() => {
+ const selectedOptionExists = !!options.find((o) => o.id === selectedID);
if (options.length === 1) {
setSelectedID(options[0].id);
+ } else if (!selectedOptionExists) {
+ const nextSelectedID =
+ options.find((option) => option.default)?.id || options[0]?.id;
+ setSelectedID(nextSelectedID);
}
}, [options]);
@@ -351,6 +382,32 @@ const useOperatorPromptSubmitOptions = (
};
};
+/**
+ * Hook which provides state management for operator option enumeration.
+ */
+export const useOperatorExecutionOptions = ({
+ operatorUri,
+ onExecute,
+}: {
+ operatorUri: string;
+ onExecute: (opts: OperatorExecutorOptions) => void;
+}): {
+ executionOptions: OperatorExecutionOption[];
+} => {
+ const ctx = useExecutionContext(operatorUri);
+ const { isRemote } = getLocalOrRemoteOperator(operatorUri);
+ const execDetails = useExecutionOptions(operatorUri, ctx, isRemote);
+ const submitOptions = useOperatorPromptSubmitOptions(
+ operatorUri,
+ execDetails,
+ onExecute
+ );
+
+ return {
+ executionOptions: submitOptions.options,
+ };
+};
+
export const useOperatorPrompt = () => {
const [promptingOperator, setPromptingOperator] = useRecoilState(
promptingOperatorState
@@ -485,6 +542,7 @@ export const useOperatorPrompt = () => {
return;
}
executor.execute(promptingOperator.params, {
+ ...options,
...promptingOperator.options,
});
},
@@ -970,7 +1028,9 @@ export function useOperatorExecutor(uri, handlers: any = {}) {
}
}
} catch (e) {
- callback?.(new OperatorResult(operator, null, ctx.executor, e, false), {ctx});
+ callback?.(new OperatorResult(operator, null, ctx.executor, e, false), {
+ ctx,
+ });
const isAbortError =
e.name === "AbortError" || e instanceof DOMException;
const msg = e.message || "Failed to execute an operation";