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} + > + + + + {actions.map((action) => { + const { name, onClick } = action; + return ( + { + handleClose(); + onClick?.(action, e); + }} + /> + ); + })} + + + ); +} + +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" ? ( + + ) : ( + + {Icon && {Icon}} + {label || name} + + ); +} + +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], }