Skip to content

Commit

Permalink
Merge pull request #5099 from voxel51/feat/operator-exec-ctx-menu
Browse files Browse the repository at this point in the history
Feat/operator exec ctx menu
  • Loading branch information
ritch authored Nov 14, 2024
2 parents f624f6c + 58602ea commit 5dc1966
Show file tree
Hide file tree
Showing 7 changed files with 450 additions and 4 deletions.
Original file line number Diff line number Diff line change
@@ -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 ? (
<MuiIconFont
name={icon}
{...getComponentProps(props, "icon", getIconProps(props))}
/>
) : (
<ExpandMoreIcon />
);

return (
<Box {...getComponentProps(props, "container")}>
<TooltipProvider title={title} {...getComponentProps(props, "tooltip")}>
<OperatorExecutionButton
operatorUri={operator}
executionParams={computedParams}
variant={variant}
onClick={(e) => 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))}
>
<Typography>{label}</Typography>
</OperatorExecutionButton>
</TooltipProvider>
</Box>
);
}

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";
}
1 change: 1 addition & 0 deletions app/packages/core/src/plugins/SchemaIO/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<OperatorExecutionTrigger
operatorUri={operatorUri}
onSuccess={onSuccess}
onError={onError}
executionParams={executionParams}
onOptionSelected={onOptionSelected}
disabled={disabled}
>
<Button disabled={disabled} {...props}>
{children}
</Button>
</OperatorExecutionTrigger>
);
};

export default OperatorExecutionButton;
Original file line number Diff line number Diff line change
@@ -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 (
<Menu anchorEl={anchor} open={open} onClose={onClose}>
{executionOptions.map((target) => (
<MenuItem
key={target.id}
onClick={() => {
onClose?.();
onOptionClick?.(target);
target.onClick();
}}
>
<Stack direction="column" spacing={1}>
<Typography fontWeight="bold">
{target.choiceLabel ?? target.label}
</Typography>
<Typography color="secondary">{target.description}</Typography>
</Stack>
</MenuItem>
))}
</Menu>
);
};

export default OperatorExecutionMenu;
Original file line number Diff line number Diff line change
@@ -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
* <OperatorExecutionTrigger {...props}>
* <Button>Execute operator</Button>
* </OperatorExecutionTrigger>
* ```
*
*
* 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 (
<>
<Box ref={containerRef} onClick={clickHandler} {...props}>
{children}
</Box>

<OperatorExecutionMenu
anchor={containerRef.current}
open={isMenuOpen && !disabled}
onClose={() => setIsMenuOpen(false)}
onOptionClick={onOptionSelected}
executionOptions={executionOptions}
/>
</>
);
};

export default OperatorExecutionTrigger;
Loading

0 comments on commit 5dc1966

Please sign in to comment.