diff --git a/src/assets/icons/Clear.tsx b/src/assets/icons/Clear.tsx new file mode 100644 index 00000000..44baee02 --- /dev/null +++ b/src/assets/icons/Clear.tsx @@ -0,0 +1,9 @@ +import SvgIcon, { SvgIconProps } from "@mui/material/SvgIcon"; + +export function Clear(props: SvgIconProps) { + return ( + + + + ); +} diff --git a/src/assets/icons/index.ts b/src/assets/icons/index.ts index 60bee5a4..db947a38 100644 --- a/src/assets/icons/index.ts +++ b/src/assets/icons/index.ts @@ -83,6 +83,7 @@ import { CarBrakeAlert } from "mdi-material-ui"; export { CarBrakeAlert as Critical }; export * from "./AddRow"; +export * from "./Clear"; export * from "./ConnectTable"; export * from "./Copy"; export * from "./CopyCells"; diff --git a/src/atoms/tableScope/ui.ts b/src/atoms/tableScope/ui.ts index a247c177..6fcb15dd 100644 --- a/src/atoms/tableScope/ui.ts +++ b/src/atoms/tableScope/ui.ts @@ -100,11 +100,12 @@ export const tableModalAtom = atomWithHash< /** Store side drawer open state */ export const sideDrawerOpenAtom = atom(false); -/** Store selected cell in table */ -export const selectedCellAtom = atom<{ - path: string; - columnKey: string; -} | null>(null); +export type SelectedCell = { path: string; columnKey: string }; +/** Store selected cell in table. Used in side drawer and context menu */ +export const selectedCellAtom = atom(null); + +/** Store context menu target atom for positioning. If not null, menu open. */ +export const contextMenuTargetAtom = atom(null); export type CloudLogFilters = { type: "webhook" | "functions" | "audit" | "build"; diff --git a/src/components/Table/ContextMenu/ContextMenu.tsx b/src/components/Table/ContextMenu/ContextMenu.tsx new file mode 100644 index 00000000..10383d28 --- /dev/null +++ b/src/components/Table/ContextMenu/ContextMenu.tsx @@ -0,0 +1,34 @@ +import { useAtom } from "jotai"; + +import { Menu } from "@mui/material"; +import MenuContents from "./MenuContents"; + +import { tableScope, contextMenuTargetAtom } from "@src/atoms/tableScope"; + +export default function ContextMenu() { + const [contextMenuTarget, setContextMenuTarget] = useAtom( + contextMenuTargetAtom, + tableScope + ); + + const handleClose = () => setContextMenuTarget(null); + + return ( + + + + ); +} diff --git a/src/components/Table/ContextMenu/MenuContents.tsx b/src/components/Table/ContextMenu/MenuContents.tsx new file mode 100644 index 00000000..bf4261a1 --- /dev/null +++ b/src/components/Table/ContextMenu/MenuContents.tsx @@ -0,0 +1,160 @@ +import { useAtom, useSetAtom } from "jotai"; +import { getFieldProp } from "@src/components/fields"; +import { find } from "lodash-es"; + +import { Divider } from "@mui/material"; +import { + CopyCells as DuplicateIcon, + Clear as ClearIcon, +} from "@src/assets/icons"; +import DeleteIcon from "@mui/icons-material/DeleteOutlined"; + +import MenuItem from "./MenuItem"; + +import { + globalScope, + altPressAtom, + tableAddRowIdTypeAtom, + confirmDialogAtom, +} from "@src/atoms/globalScope"; +import { + tableScope, + tableSettingsAtom, + tableSchemaAtom, + tableRowsAtom, + selectedCellAtom, + addRowAtom, + deleteRowAtom, + updateFieldAtom, +} from "@src/atoms/tableScope"; +import { IContextMenuItem } from "./MenuItem"; +import { FieldType } from "@src/constants/fields"; + +interface IMenuContentsProps { + onClose: () => void; +} + +export default function MenuContents({ onClose }: IMenuContentsProps) { + const [altPress] = useAtom(altPressAtom, globalScope); + const [addRowIdType] = useAtom(tableAddRowIdTypeAtom, globalScope); + const confirm = useSetAtom(confirmDialogAtom, globalScope); + const [tableSettings] = useAtom(tableSettingsAtom, tableScope); + const [tableSchema] = useAtom(tableSchemaAtom, tableScope); + const [tableRows] = useAtom(tableRowsAtom, tableScope); + const [selectedCell] = useAtom(selectedCellAtom, tableScope); + const addRow = useSetAtom(addRowAtom, tableScope); + const deleteRow = useSetAtom(deleteRowAtom, tableScope); + const updateField = useSetAtom(updateFieldAtom, tableScope); + + if (!tableSchema.columns || !selectedCell) return null; + + const selectedColumn = tableSchema.columns[selectedCell.columnKey]; + const menuActions = getFieldProp("contextMenuActions", selectedColumn.type); + + const actionGroups: IContextMenuItem[][] = []; + + // Field type actions + const fieldTypeActions = menuActions + ? menuActions(selectedCell, onClose) + : []; + if (fieldTypeActions.length > 0) actionGroups.push(fieldTypeActions); + + if (selectedColumn.type === FieldType.derivative) { + const renderedFieldMenuActions = getFieldProp( + "contextMenuActions", + selectedColumn.config?.renderFieldType + ); + if (renderedFieldMenuActions) { + actionGroups.push(renderedFieldMenuActions(selectedCell, onClose)); + } + } + + // Cell actions + // TODO: Add copy and paste here + const handleClearValue = () => + updateField({ + path: selectedCell.path, + fieldName: selectedColumn.fieldName, + value: null, + deleteField: true, + }); + const cellActions = [ + { + label: altPress ? "Clear value" : "Clear value…", + color: "error", + icon: , + onClick: altPress + ? handleClearValue + : () => { + confirm({ + title: "Clear cell value?", + body: "The cell’s value cannot be recovered after", + confirm: "Delete", + confirmColor: "error", + handleConfirm: handleClearValue, + }); + onClose(); + }, + }, + ]; + actionGroups.push(cellActions); + + // Row actions + const row = find(tableRows, ["_rowy_ref.path", selectedCell.path]); + if (row) { + const handleDelete = () => deleteRow(row._rowy_ref.path); + const rowActions = [ + { + label: "Duplicate row", + icon: , + disabled: tableSettings.tableType === "collectionGroup", + onClick: () => { + addRow({ + row, + setId: addRowIdType === "custom" ? "decrement" : addRowIdType, + }); + onClose(); + }, + }, + { + label: altPress ? "Delete row" : "Delete row…", + color: "error", + icon: , + onClick: altPress + ? handleDelete + : () => { + confirm({ + title: "Delete row?", + body: ( + <> + Row path: +
+ + {row._rowy_ref.path} + + + ), + confirm: "Delete", + confirmColor: "error", + handleConfirm: handleDelete, + }); + onClose(); + }, + }, + ]; + actionGroups.push(rowActions); + } + + return ( + <> + {actionGroups.map((items, groupIndex) => ( + <> + {groupIndex > 0 && } + {items.map((item, index: number) => ( + + ))} + + ))} + + ); +} diff --git a/src/components/Table/ContextMenu/MenuItem.tsx b/src/components/Table/ContextMenu/MenuItem.tsx new file mode 100644 index 00000000..d502d235 --- /dev/null +++ b/src/components/Table/ContextMenu/MenuItem.tsx @@ -0,0 +1,36 @@ +import { + ListItemIcon, + ListItemText, + MenuItem, + MenuItemProps, + Typography, +} from "@mui/material"; + +export interface IContextMenuItem extends Partial { + onClick: () => void; + icon?: React.ReactNode; + label: string; + disabled?: boolean; + hotkeyLabel?: string; +} + +export default function ContextMenuItem({ + onClick, + icon, + label, + disabled, + hotkeyLabel, + ...props +}: IContextMenuItem) { + return ( + + {icon} + {label} + {hotkeyLabel && ( + + {hotkeyLabel} + + )} + + ); +} diff --git a/src/components/Table/ContextMenu/index.ts b/src/components/Table/ContextMenu/index.ts new file mode 100644 index 00000000..0b83f120 --- /dev/null +++ b/src/components/Table/ContextMenu/index.ts @@ -0,0 +1,2 @@ +export * from "./ContextMenu"; +export { default } from "./ContextMenu"; diff --git a/src/components/Table/EmptyTable.tsx b/src/components/Table/EmptyTable.tsx index e3033e1a..31fa90c4 100644 --- a/src/components/Table/EmptyTable.tsx +++ b/src/components/Table/EmptyTable.tsx @@ -13,7 +13,6 @@ import { import { APP_BAR_HEIGHT } from "@src/layouts/Navigation"; // FIXME: -// import ColumnMenu from "./ColumnMenu"; // import ImportWizard from "@src/components/Wizards/ImportWizard"; // import ImportCSV from "@src/components/TableToolbar/ImportCsv"; diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index 287dfaa6..d433500f 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from "react"; +import React, { useMemo, useState, Suspense } from "react"; import { useAtom, useSetAtom } from "jotai"; import { useDebouncedCallback, useThrottledCallback } from "use-debounce"; import { DndProvider } from "react-dnd"; @@ -15,7 +15,6 @@ import { LinearProgress } from "@mui/material"; import TableContainer, { OUT_OF_ORDER_MARGIN } from "./TableContainer"; import ColumnHeader, { COLUMN_HEADER_HEIGHT } from "./ColumnHeader"; -// import ContextMenu from "./ContextMenu"; import FinalColumnHeader from "./FinalColumnHeader"; import FinalColumn from "./formatters/FinalColumn"; import TableRow from "./TableRow"; @@ -23,6 +22,8 @@ import EmptyState from "@src/components/EmptyState"; // import BulkActions from "./BulkActions"; import AddRow from "@src/components/TableToolbar/AddRow"; import { AddRow as AddRowIcon } from "@src/assets/icons"; +import Loading from "@src/components/Loading"; +import ContextMenu from "./ContextMenu"; import { globalScope, @@ -211,10 +212,8 @@ export default function Table({ ); return ( - <> - {/* }> - - */} + }> + {/* */} {showLeftScrollDivider &&
} @@ -268,7 +267,7 @@ export default function Table({ // onRowsChange={() => { //console.log('onRowsChange',rows) // }} - // FIXME: onFill={(e) => { + // TODO: onFill={(e) => { // console.log("onFill", e); // const { columnKey, sourceRow, targetRows } = e; // if (updateCell) @@ -277,7 +276,8 @@ export default function Table({ // ); // return []; // }} - onPaste={(e) => { + onPaste={(e, ...args) => { + console.log("onPaste", e, ...args); const value = e.sourceRow[e.sourceColumnKey]; updateField({ path: e.targetRow._rowy_ref.path, @@ -324,7 +324,8 @@ export default function Table({ {tableNextPage.loading && } - {/* + + {/* */} - + ); } diff --git a/src/components/Table/TableRow.tsx b/src/components/Table/TableRow.tsx index d52b6b08..0ecd229a 100644 --- a/src/components/Table/TableRow.tsx +++ b/src/components/Table/TableRow.tsx @@ -1,17 +1,16 @@ -// FIXME: -// import { useSetAnchorEle } from "@src/atoms/ContextMenu"; import { Fragment } from "react"; +import { useSetAtom } from "jotai"; import { Row, RowRendererProps } from "react-data-grid"; import OutOfOrderIndicator from "./OutOfOrderIndicator"; +import { tableScope, contextMenuTargetAtom } from "@src/atoms/tableScope"; + export default function TableRow(props: RowRendererProps) { - // const { setAnchorEle } = useSetAnchorEle(); - const handleContextMenu = ( - e: React.MouseEvent - ) => { + const setContextMenuTarget = useSetAtom(contextMenuTargetAtom, tableScope); + const handleContextMenu = (e: React.MouseEvent) => { e.preventDefault(); - // setAnchorEle?.(e?.target as HTMLElement); + setContextMenuTarget(e?.target as HTMLElement); }; if (props.row._rowy_outOfOrder) diff --git a/src/components/fields/Derivative/ContextMenuActions.tsx.disabled b/src/components/fields/Derivative/ContextMenuActions.tsx similarity index 54% rename from src/components/fields/Derivative/ContextMenuActions.tsx.disabled rename to src/components/fields/Derivative/ContextMenuActions.tsx index 5ee24459..194185f6 100644 --- a/src/components/fields/Derivative/ContextMenuActions.tsx.disabled +++ b/src/components/fields/Derivative/ContextMenuActions.tsx @@ -1,11 +1,19 @@ +import { useAtom } from "jotai"; import { find, get } from "lodash-es"; +import { useSnackbar } from "notistack"; import ReEvalIcon from "@mui/icons-material/Replay"; import EvalIcon from "@mui/icons-material/PlayCircle"; -import { useProjectContext } from "@src/contexts/ProjectContext"; -import { useSnackbar } from "notistack"; -import { SelectedCell } from "@src/atoms/ContextMenu"; +import { globalScope, rowyRunAtom } from "@src/atoms/globalScope"; +import { + tableScope, + tableSettingsAtom, + tableSchemaAtom, + tableRowsAtom, +} from "@src/atoms/tableScope"; +import { getTableSchemaPath } from "@src/utils/table"; +import { IFieldConfig } from "@src/components/fields/types"; import { runRoutes } from "@src/constants/runRoutes"; export interface IContextMenuActions { @@ -14,40 +22,41 @@ export interface IContextMenuActions { onClick: () => void; } -export default function ContextMenuActions( - selectedCell: SelectedCell, - reset: () => void | Promise -): IContextMenuActions[] { - const { tableState, rowyRun } = useProjectContext(); +export const ContextMenuActions: IFieldConfig["contextMenuActions"] = ( + selectedCell, + reset +) => { + const [rowyRun] = useAtom(rowyRunAtom, globalScope); + const [tableSettings] = useAtom(tableSettingsAtom, tableScope); + const [tableSchema] = useAtom(tableSchemaAtom, tableScope); + const [tableRows] = useAtom(tableRowsAtom, tableScope); const { enqueueSnackbar, closeSnackbar } = useSnackbar(); - const columns = tableState?.columns; - const rows = tableState?.rows; - const selectedRowIndex = selectedCell.rowIndex as number; - const selectedColIndex = selectedCell?.colIndex; - const selectedCol = find(columns, { index: selectedColIndex }); + + const selectedCol = tableSchema.columns?.[selectedCell.columnKey]; if (!selectedCol) return []; - // don't show evalute button if function has external dependency + + const selectedRow = find(tableRows, ["_rowy_ref.path", selectedCell.path]); + const cellValue = get(selectedRow, selectedCol.fieldName); + + if (!selectedCol) return []; + + // don't show evaluate button if function has external dependency const code = - selectedCol.config.derivativeFn ?? selectedCol.config.script ?? ""; + selectedCol.config?.derivativeFn ?? selectedCol.config?.script ?? ""; if (code.includes("require(")) return []; - const selectedRow = rows?.[selectedRowIndex]; - const cellValue = get(selectedRow, selectedCol.key); - const handleClose = async () => await reset?.(); const handleEvaluate = async () => { try { if (!selectedCol || !rowyRun || !selectedRow) return; - handleClose(); + reset(); const evaluatingSnackKey = enqueueSnackbar("Evaluating...", { variant: "info", }); const result = await rowyRun({ route: runRoutes.evaluateDerivative, body: { - ref: { - path: selectedRow.ref.path, - }, - schemaDocPath: tableState?.config.tableConfig.path, + ref: { path: selectedCell.path }, + schemaDocPath: getTableSchemaPath(tableSettings), columnKey: selectedCol.key, }, }); @@ -70,4 +79,6 @@ export default function ContextMenuActions( ]; return contextMenuActions; -} +}; + +export default ContextMenuActions; diff --git a/src/components/fields/Derivative/index.tsx b/src/components/fields/Derivative/index.tsx index 467fb8bc..a42f2f8c 100644 --- a/src/components/fields/Derivative/index.tsx +++ b/src/components/fields/Derivative/index.tsx @@ -5,7 +5,7 @@ import { Derivative as DerivativeIcon } from "@src/assets/icons"; import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull"; import NullEditor from "@src/components/Table/editors/NullEditor"; import Settings, { settingsValidator } from "./Settings"; -// import ContextMenuActions from "./ContextMenuActions"; +import ContextMenuActions from "./ContextMenuActions"; export const config: IFieldConfig = { type: FieldType.derivative, @@ -20,7 +20,7 @@ export const config: IFieldConfig = { TableCell: withBasicCell(BasicCell), TableEditor: NullEditor as any, SideDrawerField: BasicCell as any, - // FIXME: contextMenuActions: ContextMenuActions, + contextMenuActions: ContextMenuActions, settings: Settings, settingsValidator, requireConfiguration: true, diff --git a/src/components/fields/_BasicCell/BasicCellContextMenuActions.tsx b/src/components/fields/_BasicCell/BasicCellContextMenuActions.tsx index fe0b2bdf..bb0ae81c 100644 --- a/src/components/fields/_BasicCell/BasicCellContextMenuActions.tsx +++ b/src/components/fields/_BasicCell/BasicCellContextMenuActions.tsx @@ -1,42 +1,36 @@ import { useAtom, useSetAtom } from "jotai"; -import { get } from "lodash-es"; +import { useSnackbar } from "notistack"; +import { get, find } from "lodash-es"; -import Cut from "@mui/icons-material/ContentCut"; +// import Cut from "@mui/icons-material/ContentCut"; import { CopyCells } from "@src/assets/icons"; import Paste from "@mui/icons-material/ContentPaste"; import { tableScope, - tableColumnsOrderedAtom, + tableSchemaAtom, tableRowsAtom, updateFieldAtom, } from "@src/atoms/tableScope"; -import { useSnackbar } from "notistack"; -// import { SelectedCell } from "@src/atoms/ContextMenu"; import { getFieldProp, getFieldType } from "@src/components/fields"; +import { IFieldConfig } from "@src/components/fields/types"; -export interface IContextMenuActions { - label: string; - icon: React.ReactNode; - onClick: () => void; -} - -export default function BasicContextMenuActions( - selectedCell: any, - reset: () => void | Promise -): IContextMenuActions[] { +// TODO: Remove this and add `handlePaste` function to column config +export const BasicContextMenuActions: IFieldConfig["contextMenuActions"] = ( + selectedCell, + reset +) => { const { enqueueSnackbar } = useSnackbar(); - // TODO: Remove these useAtom calls that cause re-render - const [tableColumnsOrdered] = useAtom(tableColumnsOrderedAtom, tableScope); + const [tableSchema] = useAtom(tableSchemaAtom, tableScope); const [tableRows] = useAtom(tableRowsAtom, tableScope); const updateField = useSetAtom(updateFieldAtom, tableScope); - const selectedCol = tableColumnsOrdered[selectedCell?.colIndex]; + const selectedCol = tableSchema.columns?.[selectedCell.columnKey]; if (!selectedCol) return []; - const selectedRow = tableRows[selectedCell.rowIndex]; - const cellValue = get(selectedRow, selectedCol.key); + const selectedRow = find(tableRows, ["_rowy_ref.path", selectedCell.path]); + const cellValue = get(selectedRow, selectedCol.fieldName); const handleClose = async () => await reset?.(); @@ -50,21 +44,21 @@ export default function BasicContextMenuActions( handleClose(); }; - const handleCut = async () => { - try { - await navigator.clipboard.writeText(cellValue); - if (typeof cellValue !== "undefined") - updateField({ - path: selectedRow._rowy_ref.path, - fieldName: selectedCol.fieldName, - value: undefined, - deleteField: true, - }); - } catch (error) { - enqueueSnackbar(`Failed to cut: ${error}`, { variant: "error" }); - } - handleClose(); - }; + // const handleCut = async () => { + // try { + // await navigator.clipboard.writeText(cellValue); + // if (typeof cellValue !== "undefined") + // updateField({ + // path: selectedCell.path, + // fieldName: selectedCol.fieldName, + // value: undefined, + // deleteField: true, + // }); + // } catch (error) { + // enqueueSnackbar(`Failed to cut: ${error}`, { variant: "error" }); + // } + // handleClose(); + // }; const handlePaste = async () => { try { @@ -85,7 +79,7 @@ export default function BasicContextMenuActions( break; } updateField({ - path: selectedRow._rowy_ref.path, + path: selectedCell.path, fieldName: selectedCol.fieldName, value: parsed, }); @@ -97,10 +91,12 @@ export default function BasicContextMenuActions( }; const contextMenuActions = [ - { label: "Cut", icon: , onClick: handleCut }, + // { label: "Cut", icon: , onClick: handleCut }, { label: "Copy", icon: , onClick: handleCopy }, { label: "Paste", icon: , onClick: handlePaste }, ]; return contextMenuActions; -} +}; + +export default BasicContextMenuActions; diff --git a/src/components/fields/types.ts b/src/components/fields/types.ts index f231b12d..4fd75acf 100644 --- a/src/components/fields/types.ts +++ b/src/components/fields/types.ts @@ -4,8 +4,8 @@ import { Control, UseFormReturn } from "react-hook-form"; import { PopoverProps } from "@mui/material"; import { WhereFilterOp } from "firebase/firestore"; import { ColumnConfig, TableRow, TableRowRef } from "@src/types/table"; -import { selectedCellAtom } from "@src/atoms/tableScope"; -import { IContextMenuActions } from "./_BasicCell/BasicCellContextMenuActions"; +import { SelectedCell } from "@src/atoms/tableScope"; +import { IContextMenuItem } from "@src/components/Table/ContextMenu/MenuItem"; export { FieldType }; @@ -21,9 +21,9 @@ export interface IFieldConfig { description?: string; setupGuideLink?: string; contextMenuActions?: ( - selectedCell: ReturnType, - reset: () => Promise - ) => IContextMenuActions[]; + selectedCell: SelectedCell, + reset: () => void + ) => IContextMenuItem[]; TableCell: React.ComponentType>; TableEditor: React.ComponentType>; SideDrawerField: React.ComponentType; diff --git a/src/index.tsx b/src/index.tsx index f4d7b2c9..5d545f47 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -8,11 +8,11 @@ import reportWebVitals from "./reportWebVitals"; const container = document.getElementById("root")!; const root = createRoot(container); root.render( - // - - - - // + + + + + ); // If you want to start measuring performance in your app, pass a function