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 ( + + {executionOptions.map((target) => ( + { + onClose?.(); + onOptionClick?.(target); + target.onClick(); + }} + > + + + {target.choiceLabel ?? target.label} + + {target.description} + + + ))} + + ); +}; + +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";