diff --git a/app/packages/core/src/plugins/SchemaIO/components/ActionsMenu.tsx b/app/packages/core/src/plugins/SchemaIO/components/ActionsMenu.tsx
new file mode 100644
index 00000000000..86ea87ac65a
--- /dev/null
+++ b/app/packages/core/src/plugins/SchemaIO/components/ActionsMenu.tsx
@@ -0,0 +1,108 @@
+import { MuiIconFont } from "@fiftyone/components";
+import { MoreVert } from "@mui/icons-material";
+import {
+ Box,
+ Button,
+ IconButton,
+ ListItemIcon,
+ ListItemText,
+ Menu,
+ MenuItem,
+ Stack,
+} from "@mui/material";
+import React, { useCallback } from "react";
+
+const DEFAULT_MAX_INLINE = 1;
+
+export default function ActionsMenu(props: ActionsPropsType) {
+ const { actions, maxInline = DEFAULT_MAX_INLINE } = props;
+
+ if (actions.length === maxInline) {
+ return (
+
+ {actions.map((action) => (
+
+ ))}
+
+ );
+ }
+
+ return ;
+}
+
+function ActionsOverflowMenu(props: ActionsPropsType) {
+ const { actions } = props;
+ const [open, setOpen] = React.useState(false);
+ const anchorRef = React.useRef(null);
+
+ const handleClose = useCallback(() => {
+ setOpen(false);
+ }, []);
+
+ return (
+
+ {
+ setOpen(!open);
+ }}
+ ref={anchorRef}
+ >
+
+
+
+
+ );
+}
+
+function Action(props: ActionPropsType) {
+ const { label, name, onClick, icon, variant, mode } = props;
+
+ const Icon = icon ? : null;
+
+ const handleClick = useCallback(
+ (e: React.MouseEvent) => {
+ onClick?.(props, e);
+ },
+ [onClick, props]
+ );
+
+ return mode === "inline" ? (
+
+ ) : (
+
+ );
+}
+
+type ActionsPropsType = {
+ actions: Array;
+ maxInline?: number;
+};
+
+type ActionPropsType = {
+ name: string;
+ label: string;
+ onClick: (action: ActionPropsType, e: React.MouseEvent) => void;
+ icon: string;
+ variant: string;
+ mode: "inline" | "menu";
+};
diff --git a/app/packages/core/src/plugins/SchemaIO/components/TableView.tsx b/app/packages/core/src/plugins/SchemaIO/components/TableView.tsx
index 100cb1b28ef..8a1007cd463 100644
--- a/app/packages/core/src/plugins/SchemaIO/components/TableView.tsx
+++ b/app/packages/core/src/plugins/SchemaIO/components/TableView.tsx
@@ -8,23 +8,45 @@ import {
TableHead,
TableRow,
} from "@mui/material";
-import React from "react";
+import { isPlainObject } from "lodash";
+import React, { useCallback } from "react";
import { HeaderView } from ".";
-import EmptyState from "./EmptyState";
import { getComponentProps } from "../utils";
+import EmptyState from "./EmptyState";
+import ActionsMenu from "./ActionsMenu";
+import { usePanelEvent } from "@fiftyone/operators";
+import { usePanelId } from "@fiftyone/spaces";
+import { ViewPropsType } from "../utils/types";
-export default function TableView(props) {
- const { schema, data } = props;
- const { view = {}, default: defaultValue } = schema;
- const { columns } = view;
-
- const table = Array.isArray(data)
- ? data
- : Array.isArray(defaultValue)
- ? defaultValue
- : [];
-
- const dataMissing = table.length === 0;
+export default function TableView(props: ViewPropsType) {
+ const { path, schema } = props;
+ const { view = {} } = schema;
+ const {
+ columns,
+ row_actions = [],
+ on_click_cell,
+ on_click_row,
+ on_click_column,
+ } = view;
+ const { rows, selectedCells, selectedRows, selectedColumns } =
+ getTableData(props);
+ const dataMissing = rows.length === 0;
+ const hasRowActions = row_actions.length > 0;
+ const panelId = usePanelId();
+ const handleClick = usePanelEvent();
+ const getRowActions = useCallback((row) => {
+ return row_actions.map((action) => {
+ return {
+ ...action,
+ onClick: (action, e) => {
+ handleClick(panelId, {
+ operator: action.on_click,
+ params: { path, event: action.name, row },
+ });
+ },
+ };
+ });
+ }, []);
return (
@@ -47,23 +69,70 @@ export default function TableView(props) {
{label}
))}
+ {hasRowActions && }
- {table.map((item) => (
+ {rows.map((item, rowIndex) => (
- {columns.map(({ key }) => (
-
- {item[key]}
+ {columns.map(({ key }, columnIndex) => {
+ const coordinate = [rowIndex, columnIndex].join(",");
+ const isSelected =
+ selectedCells.has(coordinate) ||
+ selectedRows.has(rowIndex) ||
+ selectedColumns.has(columnIndex);
+ return (
+ {
+ if (on_click_cell) {
+ handleClick(panelId, {
+ operator: on_click_cell,
+ params: {
+ row: rowIndex,
+ column: columnIndex,
+ path,
+ event: "on_click_cell",
+ },
+ });
+ }
+ if (on_click_row) {
+ handleClick(panelId, {
+ operator: on_click_row,
+ params: {
+ row: rowIndex,
+ path,
+ event: "on_click_row",
+ },
+ });
+ }
+ if (on_click_column) {
+ handleClick(panelId, {
+ operator: on_click_column,
+ params: {
+ column: columnIndex,
+ path,
+ event: "on_click_column",
+ },
+ });
+ }
+ }}
+ {...getComponentProps(props, "tableBodyCell")}
+ >
+ {formatCellValue(item[key], props)}
+
+ );
+ })}
+ {hasRowActions && (
+
+
- ))}
+ )}
))}
@@ -73,3 +142,56 @@ export default function TableView(props) {
);
}
+
+function getTableData(props) {
+ const { schema, data } = props;
+ const defaultValue = schema?.default;
+
+ if (isAdvancedData(data)) {
+ return parseAdvancedData(data);
+ }
+ if (isAdvancedData(defaultValue)) {
+ return parseAdvancedData(defaultValue);
+ }
+ return {
+ rows: Array.isArray(data)
+ ? data
+ : Array.isArray(defaultValue)
+ ? defaultValue
+ : [],
+ };
+}
+
+function isAdvancedData(data) {
+ return (
+ isPlainObject(data) &&
+ Array.isArray(data?.rows) &&
+ Array.isArray(data?.columns)
+ );
+}
+
+function parseAdvancedData(data) {
+ const rows = data.rows.map((row) => {
+ return data.columns.reduce((cells, column, cellIndex) => {
+ cells[column] = row[cellIndex];
+ return cells;
+ }, {});
+ });
+ const selectedCellsRaw = data?.selectedCells || data?.selected_cells || [];
+ const selectedRowsRaw = data?.selectedRows || data?.selected_rows || [];
+ const selectedColumnsRaw =
+ data?.selectedColumns || data?.selected_columns || [];
+ const selectedCells = new Set(selectedCellsRaw.map((cell) => cell.join(",")));
+ const selectedRows = new Set(selectedRowsRaw);
+ const selectedColumns = new Set(selectedColumnsRaw);
+ return { rows, selectedCells, selectedRows, selectedColumns };
+}
+
+function formatCellValue(value: string, props: ViewPropsType) {
+ const round = props?.schema?.view?.round;
+ const valueAsFloat = parseFloat(value);
+ if (!isNaN(valueAsFloat) && typeof round === "number") {
+ return valueAsFloat.toFixed(round);
+ }
+ return value;
+}
diff --git a/fiftyone/operators/types.py b/fiftyone/operators/types.py
index e26191b4f77..3d611d48a98 100644
--- a/fiftyone/operators/types.py
+++ b/fiftyone/operators/types.py
@@ -1618,16 +1618,39 @@ def to_json(self):
return {**super().to_json(), "key": self.key}
+class Action(View):
+ """An action (currently supported only in a :class:`TableView`).
+
+ Args:
+ name: the name of the action
+ label (None): the label of the action
+ icon (None): the icon of the action
+ on_click: the operator to execute when the action is clicked
+ """
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+
+ def clone(self):
+ clone = Action(**self._kwargs)
+ return clone
+
+ def to_json(self):
+ return {**super().to_json()}
+
+
class TableView(View):
"""Displays a table.
Args:
columns (None): a list of :class:`Column` objects to display
+ row_actions (None): a list of :class:`Action` objects to display
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.columns = kwargs.get("columns", [])
+ self.row_actions = kwargs.get("row_actions", [])
def keys(self):
return [column.key for column in self.columns]
@@ -1637,15 +1660,24 @@ def add_column(self, key, **kwargs):
self.columns.append(column)
return column
+ def add_row_action(self, name, on_click, label=None, icon=None, **kwargs):
+ row_action = Action(
+ name=name, on_click=on_click, label=label, icon=icon, **kwargs
+ )
+ self.row_actions.append(row_action)
+ return row_action
+
def clone(self):
clone = super().clone()
clone.columns = [column.clone() for column in self.columns]
+ clone.row_actions = [action.clone() for action in self.row_actions]
return clone
def to_json(self):
return {
**super().to_json(),
"columns": [column.to_json() for column in self.columns],
+ "row_actions": [action.to_json() for action in self.row_actions],
}