diff --git a/package.json b/package.json index a3bceebf..5a99e942 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@mui/x-date-pickers": "^5.0.0-alpha.4", "@rowy/form-builder": "^0.7.0", "@rowy/multiselect": "^0.4.1", + "@tanstack/react-table": "^8.5.15", "@tinymce/tinymce-react": "^3", "@uiw/react-md-editor": "^3.14.1", "algoliasearch": "^4.13.1", @@ -46,7 +47,6 @@ "react": "^18.0.0", "react-beautiful-dnd": "^13.1.0", "react-color-palette": "^6.2.0", - "react-data-grid": "7.0.0-beta.5", "react-detect-offline": "^2.4.5", "react-div-100vh": "^0.7.0", "react-dnd": "^16.0.1", @@ -64,6 +64,7 @@ "react-router-hash-link": "^2.4.3", "react-scripts": "^5.0.0", "react-usestateref": "^1.0.8", + "react-virtual": "^2.10.4", "remark-gfm": "^3.0.1", "seedrandom": "^3.0.5", "stream-browserify": "^3.0.0", @@ -91,7 +92,7 @@ "typedoc": "typedoc src/atoms/projectScope/index.ts src/atoms/tableScope/index.ts --out typedoc" }, "engines": { - "node": ">=16" + "node": "^16" }, "eslintConfig": { "plugins": [ diff --git a/src/App.tsx b/src/App.tsx index 79162f2d..ea283a01 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import { lazy, Suspense } from "react"; import { Routes, Route, Navigate } from "react-router-dom"; import { useAtom } from "jotai"; +import { Backdrop } from "@mui/material"; import Loading from "@src/components/Loading"; import ProjectSourceFirebase from "@src/sources/ProjectSourceFirebase"; import MembersSourceFirebase from "@src/sources/MembersSourceFirebase"; @@ -116,7 +117,21 @@ export default function App() { } /> } + element={ + + + + } + > + + + } /> diff --git a/src/assets/icons/index.ts b/src/assets/icons/index.ts index 3503e99b..372af8ff 100644 --- a/src/assets/icons/index.ts +++ b/src/assets/icons/index.ts @@ -103,6 +103,9 @@ export { FileTableBoxOutline as Project }; import { TableColumn } from "mdi-material-ui"; export { TableColumn }; +import { DragVertical } from "mdi-material-ui"; +export { DragVertical }; + export * from "./AddRow"; export * from "./AddRowTop"; export * from "./ChevronDown"; diff --git a/src/atoms/tableScope/columnActions.ts b/src/atoms/tableScope/columnActions.ts index c0be303a..2f3926e8 100644 --- a/src/atoms/tableScope/columnActions.ts +++ b/src/atoms/tableScope/columnActions.ts @@ -97,8 +97,6 @@ export const updateColumnAtom = atom( }); } - console.log(tableColumnsOrdered); - // Reduce array into single object with updated indexes const updatedColumns = tableColumnsOrdered.reduce(tableColumnsReducer, {}); await updateTableSchema({ columns: updatedColumns }); diff --git a/src/atoms/tableScope/ui.ts b/src/atoms/tableScope/ui.ts index b5aaf882..3d11a081 100644 --- a/src/atoms/tableScope/ui.ts +++ b/src/atoms/tableScope/ui.ts @@ -130,7 +130,11 @@ export const sideDrawerAtom = atomWithHash<"table-information" | null>( /** Store side drawer open state */ export const sideDrawerOpenAtom = atom(false); -export type SelectedCell = { path: string; columnKey: string }; +export type SelectedCell = { + path: string | "_rowy_header"; + columnKey: string | "_rowy_row_actions"; + focusInside: boolean; +}; /** Store selected cell in table. Used in side drawer and context menu */ export const selectedCellAtom = atom(null); diff --git a/src/components/ColumnMenu/ColumnMenu.tsx b/src/components/ColumnMenu/ColumnMenu.tsx index 09c17e23..cf025217 100644 --- a/src/components/ColumnMenu/ColumnMenu.tsx +++ b/src/components/ColumnMenu/ColumnMenu.tsx @@ -29,11 +29,10 @@ import SettingsIcon from "@mui/icons-material/SettingsOutlined"; import EvalIcon from "@mui/icons-material/PlayCircleOutline"; import MenuContents, { IMenuContentsProps } from "./MenuContents"; -import ColumnHeader from "@src/components/Table/Column"; +import ColumnHeader from "@src/components/Table/Mock/Column"; import { projectScope, - userRolesAtom, userSettingsAtom, updateUserSettingsAtom, confirmDialogAtom, @@ -80,8 +79,17 @@ export interface IMenuModalProps { ) => void; } -export default function ColumnMenu() { - const [userRoles] = useAtom(userRolesAtom, projectScope); +export interface IColumnMenuProps { + canAddColumns: boolean; + canEditColumns: boolean; + canDeleteColumns: boolean; +} + +export default function ColumnMenu({ + canAddColumns, + canEditColumns, + canDeleteColumns, +}: IColumnMenuProps) { const [userSettings] = useAtom(userSettingsAtom, projectScope); const [updateUserSettings] = useAtom(updateUserSettingsAtom, projectScope); const confirm = useSetAtom(confirmDialogAtom, projectScope); @@ -248,7 +256,7 @@ export default function ColumnMenu() { }); handleClose(); }, - active: !column.editable, + active: column.editable === false, }, { label: "Disable resize", @@ -387,6 +395,7 @@ export default function ColumnMenu() { openColumnModal({ type: "new", index: column.index - 1 }); handleClose(); }, + disabled: !canAddColumns, }, { label: "Insert to the right…", @@ -396,6 +405,7 @@ export default function ColumnMenu() { openColumnModal({ type: "new", index: column.index + 1 }); handleClose(); }, + disabled: !canAddColumns, }, { label: `Delete column${altPress ? "" : "…"}`, @@ -450,16 +460,19 @@ export default function ColumnMenu() { }); }, color: "error" as "error", + disabled: !canDeleteColumns, }, ]; let menuItems = [...localViewActions]; - if (userRoles.includes("ADMIN") || userRoles.includes("OPS")) { + if (canEditColumns) { menuItems.push.apply(menuItems, configActions); if (column.type === FieldType.derivative) { menuItems.push.apply(menuItems, derivativeActions); } + } + if (canAddColumns || canDeleteColumns) { menuItems.push.apply(menuItems, columnActions); } diff --git a/src/components/SideDrawer/MemoizedField.tsx b/src/components/SideDrawer/MemoizedField.tsx index 8333631e..0fb62f62 100644 --- a/src/components/SideDrawer/MemoizedField.tsx +++ b/src/components/SideDrawer/MemoizedField.tsx @@ -53,7 +53,7 @@ export const MemoizedField = memo( // Should not reach this state if (isEmpty(fieldComponent)) { - // console.error('Could not find SideDrawerField component', field); + console.error("Could not find SideDrawerField component", field); return null; } @@ -78,10 +78,6 @@ export const MemoizedField = memo( }, onSubmit: handleSubmit, disabled, - // TODO: Remove - control: {} as any, - useFormMethods: {} as any, - docRef: _rowy_ref, })} ); diff --git a/src/components/SideDrawer/SideDrawer.tsx b/src/components/SideDrawer/SideDrawer.tsx index bc9017c5..ef676bd1 100644 --- a/src/components/SideDrawer/SideDrawer.tsx +++ b/src/components/SideDrawer/SideDrawer.tsx @@ -1,10 +1,8 @@ import { useEffect } from "react"; -import useMemoValue from "use-memo-value"; import clsx from "clsx"; import { useAtom } from "jotai"; -import { find, findIndex, isEqual } from "lodash-es"; +import { find, findIndex } from "lodash-es"; import { ErrorBoundary } from "react-error-boundary"; -import { DataGridHandle } from "react-data-grid"; import { TransitionGroup } from "react-transition-group"; import { Fab, Fade } from "@mui/material"; @@ -16,34 +14,20 @@ import ErrorFallback from "@src/components/ErrorFallback"; import StyledDrawer from "./StyledDrawer"; import SideDrawerFields from "./SideDrawerFields"; -import { projectScope, userSettingsAtom } from "@src/atoms/projectScope"; import { tableScope, - tableIdAtom, - tableColumnsOrderedAtom, tableRowsAtom, sideDrawerOpenAtom, selectedCellAtom, } from "@src/atoms/tableScope"; import { analytics, logEvent } from "@src/analytics"; -import { formatSubTableName } from "@src/utils/table"; export const DRAWER_WIDTH = 512; export const DRAWER_COLLAPSED_WIDTH = 36; -export default function SideDrawer({ - dataGridRef, -}: { - dataGridRef?: React.MutableRefObject; -}) { - const [userSettings] = useAtom(userSettingsAtom, projectScope); - const [tableId] = useAtom(tableIdAtom, tableScope); - const [tableColumnsOrdered] = useAtom(tableColumnsOrderedAtom, tableScope); +export default function SideDrawer() { const [tableRows] = useAtom(tableRowsAtom, tableScope); - const userDocHiddenFields = - userSettings.tables?.[formatSubTableName(tableId)]?.hiddenFields ?? []; - const [cell, setCell] = useAtom(selectedCellAtom, tableScope); const [open, setOpen] = useAtom(sideDrawerOpenAtom, tableScope); const selectedRow = find(tableRows, ["_rowy_ref.path", cell?.path]); @@ -51,26 +35,6 @@ export default function SideDrawer({ "_rowy_ref.path", cell?.path, ]); - // Memo a list of visible column keys for useEffect dependencies - const visibleColumnKeys = useMemoValue( - tableColumnsOrdered - .filter((col) => !userDocHiddenFields.includes(col.key)) - .map((col) => col.key), - isEqual - ); - - // When side drawer is opened, select the cell in the table - // in case we’ve scrolled and selected cell was reset - useEffect(() => { - if (open) { - const columnIndex = visibleColumnKeys.indexOf(cell?.columnKey || ""); - if (columnIndex === -1 || selectedCellRowIndex === -1) return; - dataGridRef?.current?.selectCell( - { rowIdx: selectedCellRowIndex, idx: columnIndex }, - false - ); - } - }, [open, visibleColumnKeys, selectedCellRowIndex, cell, dataGridRef]); const handleNavigate = (direction: "up" | "down") => () => { if (!tableRows || !cell) return; @@ -79,13 +43,11 @@ export default function SideDrawer({ if (direction === "down" && rowIndex < tableRows.length - 1) rowIndex += 1; const newPath = tableRows[rowIndex]._rowy_ref.path; - setCell((cell) => ({ columnKey: cell!.columnKey, path: newPath })); - - const columnIndex = visibleColumnKeys.indexOf(cell!.columnKey || ""); - dataGridRef?.current?.selectCell( - { rowIdx: rowIndex, idx: columnIndex }, - false - ); + setCell((cell) => ({ + columnKey: cell!.columnKey, + path: newPath, + focusInside: false, + })); }; // const [urlDocState, dispatchUrlDoc] = useDoc({}); @@ -109,7 +71,7 @@ export default function SideDrawer({ // } // }, [cell]); - const disabled = !open && !cell; // && !urlDocState.doc; + const disabled = (!open && !cell) || selectedCellRowIndex <= -1; // && !urlDocState.doc; useEffect(() => { if (disabled && setOpen) setOpen(false); }, [disabled, setOpen]); @@ -143,6 +105,7 @@ export default function SideDrawer({ {!!cell && (
{ if (setOpen) diff --git a/src/components/Table/BreadcrumbsSubTable.tsx b/src/components/Table/Breadcrumbs/BreadcrumbsSubTable.tsx similarity index 100% rename from src/components/Table/BreadcrumbsSubTable.tsx rename to src/components/Table/Breadcrumbs/BreadcrumbsSubTable.tsx diff --git a/src/components/Table/BreadcrumbsTableRoot.tsx b/src/components/Table/Breadcrumbs/BreadcrumbsTableRoot.tsx similarity index 97% rename from src/components/Table/BreadcrumbsTableRoot.tsx rename to src/components/Table/Breadcrumbs/BreadcrumbsTableRoot.tsx index dd15526d..676408e3 100644 --- a/src/components/Table/BreadcrumbsTableRoot.tsx +++ b/src/components/Table/Breadcrumbs/BreadcrumbsTableRoot.tsx @@ -1,5 +1,5 @@ import { useAtom } from "jotai"; -import { useLocation, useParams, Link as RouterLink } from "react-router-dom"; +import { useParams, Link as RouterLink } from "react-router-dom"; import { find, camelCase, uniq } from "lodash-es"; import { diff --git a/src/components/Table/CellValidation.tsx b/src/components/Table/CellValidation.tsx deleted file mode 100644 index ac6a9764..00000000 --- a/src/components/Table/CellValidation.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { styled } from "@mui/material/styles"; -import ErrorIcon from "@mui/icons-material/ErrorOutline"; -import WarningIcon from "@mui/icons-material/WarningAmber"; - -import RichTooltip from "@src/components/RichTooltip"; - -const Root = styled("div", { shouldForwardProp: (prop) => prop !== "error" })( - ({ theme, ...props }) => ({ - width: "100%", - height: "100%", - padding: "var(--cell-padding)", - position: "relative", - - overflow: "hidden", - contain: "strict", - display: "flex", - alignItems: "center", - - ...((props as any).error - ? { - ".rdg-cell:not([aria-selected=true]) &": { - boxShadow: `inset 0 0 0 2px ${theme.palette.error.main}`, - }, - } - : {}), - }) -); - -const Dot = styled("div")(({ theme }) => ({ - position: "absolute", - right: -5, - top: "50%", - transform: "translateY(-50%)", - zIndex: 1, - - width: 12, - height: 12, - - borderRadius: "50%", - backgroundColor: theme.palette.error.main, - - boxShadow: `0 0 0 4px var(--background-color)`, - ".rdg-row:hover &": { - boxShadow: `0 0 0 4px var(--row-hover-background-color)`, - }, -})); - -export interface ICellValidationProps - extends React.DetailedHTMLProps< - React.HTMLAttributes, - HTMLDivElement - > { - value: any; - required?: boolean; - validationRegex?: string; -} - -export default function CellValidation({ - value, - required, - validationRegex, - children, -}: ICellValidationProps) { - const isInvalid = validationRegex && !new RegExp(validationRegex).test(value); - const isMissing = required && value === undefined; - - if (isInvalid) - return ( - <> - } - title="Invalid data" - message="This row will not be saved until all the required fields contain valid data" - placement="right" - render={({ openTooltip }) => } - /> - - {children} - - ); - - if (isMissing) - return ( - <> - } - title="Required field" - message="This row will not be saved until all the required fields contain valid data" - placement="right" - render={({ openTooltip }) => } - /> - - {children} - - ); - - return {children}; -} diff --git a/src/components/Table/ColumnHeader/ColumnHeader.tsx b/src/components/Table/ColumnHeader/ColumnHeader.tsx index a1652c04..40e5c5a1 100644 --- a/src/components/Table/ColumnHeader/ColumnHeader.tsx +++ b/src/components/Table/ColumnHeader/ColumnHeader.tsx @@ -1,82 +1,86 @@ -import { useRef } from "react"; +import { memo, useRef } from "react"; import { useAtom, useSetAtom } from "jotai"; -import { useDrag, useDrop } from "react-dnd"; +import type { Header } from "@tanstack/react-table"; +import type { + DraggableProvided, + DraggableStateSnapshot, +} from "react-beautiful-dnd"; import { - styled, - alpha, Tooltip, - TooltipProps, - tooltipClasses, Fade, - Grid, + StackProps, IconButton, Typography, } from "@mui/material"; import DropdownIcon from "@mui/icons-material/MoreHoriz"; import LockIcon from "@mui/icons-material/LockOutlined"; -import ColumnHeaderSort from "./ColumnHeaderSort"; - import { - projectScope, - userRolesAtom, - altPressAtom, -} from "@src/atoms/projectScope"; + StyledColumnHeader, + StyledColumnHeaderNameTooltip, +} from "@src/components/Table/Styled/StyledColumnHeader"; +import ColumnHeaderSort, { SORT_STATES } from "./ColumnHeaderSort"; +import ColumnHeaderDragHandle from "./ColumnHeaderDragHandle"; +import ColumnHeaderResizer from "./ColumnHeaderResizer"; + +import { projectScope, altPressAtom } from "@src/atoms/projectScope"; import { tableScope, - updateColumnAtom, + selectedCellAtom, columnMenuAtom, + tableSortsAtom, } from "@src/atoms/tableScope"; import { getFieldProp } from "@src/components/fields"; -import { COLUMN_HEADER_HEIGHT } from "@src/components/Table/Column"; -import { ColumnConfig } from "@src/types/table"; +import { FieldType } from "@src/constants/fields"; +import { COLUMN_HEADER_HEIGHT } from "@src/components/Table/Mock/Column"; +import type { ColumnConfig } from "@src/types/table"; +import type { TableRow } from "@src/types/table"; export { COLUMN_HEADER_HEIGHT }; -const LightTooltip = styled(({ className, ...props }: TooltipProps) => ( - -))(({ theme }) => ({ - [`& .${tooltipClasses.tooltip}`]: { - backgroundColor: theme.palette.background.default, - color: theme.palette.text.primary, - - margin: `-${COLUMN_HEADER_HEIGHT - 1 - 2}px 0 0 !important`, - padding: 0, - paddingRight: theme.spacing(1.5), - }, -})); - -export interface IDraggableHeaderRendererProps { +export interface IColumnHeaderProps + extends Partial> { + header: Header; column: ColumnConfig; + + provided: DraggableProvided; + snapshot: DraggableStateSnapshot; + + width: number; + isSelectedCell: boolean; + focusInsideCell: boolean; + canEditColumns: boolean; + isLastFrozen: boolean; } -export default function DraggableHeaderRenderer({ +/** + * Renders UI components for each column header, including accessibility + * attributes. Memoized to prevent re-render when resizing or reordering other + * columns. + * + * Renders: + * - Drag handle (accessible) + * - Field type icon + click to copy field key + * - Field name + hover to view full name if cut off + * - Sort button + * - Resize handle (not accessible) + */ +export const ColumnHeader = memo(function ColumnHeader({ + header, column, -}: IDraggableHeaderRendererProps) { - const [userRoles] = useAtom(userRolesAtom, projectScope); - const updateColumn = useSetAtom(updateColumnAtom, tableScope); + provided, + snapshot, + width, + isSelectedCell, + focusInsideCell, + canEditColumns, + isLastFrozen, +}: IColumnHeaderProps) { const openColumnMenu = useSetAtom(columnMenuAtom, tableScope); + const setSelectedCell = useSetAtom(selectedCellAtom, tableScope); const [altPress] = useAtom(altPressAtom, projectScope); - - const [{ isDragging }, dragRef] = useDrag({ - type: "COLUMN_DRAG", - item: { key: column.key }, - collect: (monitor) => ({ - isDragging: monitor.isDragging(), - }), - }); - - const [{ isOver }, dropRef] = useDrop({ - accept: "COLUMN_DRAG", - drop: ({ key }: { key: string }) => { - updateColumn({ key, config: {}, index: column.index }); - }, - collect: (monitor) => ({ - isOver: monitor.isOver(), - canDrop: monitor.canDrop(), - }), - }); + const [tableSorts] = useAtom(tableSortsAtom, tableScope); const buttonRef = useRef(null); @@ -85,53 +89,69 @@ export default function DraggableHeaderRenderer({ openColumnMenu({ column, anchorEl: buttonRef.current }); }; + const _sortKey = getFieldProp("sortKey", (column as any).type); + const sortKey = _sortKey ? `${column.key}.${_sortKey}` : column.key; + const currentSort: typeof SORT_STATES[number] = + tableSorts[0]?.key !== sortKey + ? "none" + : tableSorts[0]?.direction || "none"; + return ( - { - dragRef(ref); - dropRef(ref); + ref={provided.innerRef} + {...provided.draggableProps} + data-row-id={"_rowy_header"} + data-col-id={header.id} + data-frozen={header.column.getIsPinned() || undefined} + data-frozen-last={isLastFrozen || undefined} + tabIndex={isSelectedCell ? 0 : -1} + aria-colindex={header.index + 1} + aria-readonly={!canEditColumns} + aria-selected={isSelectedCell} + aria-sort={ + currentSort === "none" + ? "none" + : currentSort === "asc" + ? "ascending" + : "descending" + } + style={{ + left: header.column.getIsPinned() + ? header.column.getStart() + : undefined, + zIndex: header.column.getIsPinned() ? 11 : 10, + ...provided.draggableProps.style, + width, + borderLeftStyle: snapshot.isDragging ? "solid" : undefined, }} - container - alignItems="center" - wrap="nowrap" onContextMenu={handleOpenMenu} - sx={[ - { - height: "100%", - "& svg, & button": { display: "block" }, - - color: "text.secondary", - transition: (theme) => - theme.transitions.create("color", { - duration: theme.transitions.duration.short, - }), - "&:hover": { color: "text.primary" }, - - cursor: "move", - - py: 0, - pr: 0.5, - pl: 1, - width: "100%", - }, - isDragging - ? { opacity: 0.5 } - : isOver - ? { - backgroundColor: (theme) => - alpha( - theme.palette.primary.main, - theme.palette.action.focusOpacity - ), - color: "primary.main", - } - : {}, - ]} - className="column-header" + onClick={(e) => { + setSelectedCell({ + path: "_rowy_header", + columnKey: header.id, + focusInside: false, + }); + (e.target as HTMLDivElement).focus(); + }} + onDoubleClick={(e) => { + setSelectedCell({ + path: "_rowy_header", + columnKey: header.id, + focusInside: true, + }); + (e.target as HTMLDivElement).focus(); + }} > - {(column.width as number) > 140 && ( + {provided.dragHandleProps && ( + + )} + + {width > 140 && ( @@ -144,94 +164,99 @@ export default function DraggableHeaderRenderer({ placement="bottom-start" arrow > - { navigator.clipboard.writeText(column.key); }} + style={{ position: "relative", zIndex: 2 }} > {column.editable === false ? ( ) : ( getFieldProp("icon", (column as any).type) )} - +
)} - - - {column.name as string} - - } - enterDelay={1000} - placement="bottom-start" - disableInteractive - TransitionComponent={Fade} - > + - {altPress ? ( - <> - {column.index} {column.fieldName} - - ) : ( - column.name - )} + {column.name as string} - - + } + enterDelay={1000} + placement="bottom-start" + disableInteractive + TransitionComponent={Fade} + sx={{ "& .MuiTooltip-tooltip": { marginTop: "-28px !important" } }} + > + - - + flexGrow: 1, + flexShrink: 1, + overflow: "hidden", + my: 0, + ml: 0.5, + mr: -30 / 8, + }} + component="div" + color="inherit" + > + {altPress ? ( + <> + {column.index} {column.fieldName} + + ) : ( + column.name + )} + + - - - - theme.transitions.create("color", { - duration: theme.transitions.duration.short, - }), + {column.type !== FieldType.id && ( + + )} - color: "text.disabled", - ".column-header:hover &": { color: "text.primary" }, - }} - > - - - - - + + + + + + + {header.column.getCanResize() && ( + + )} + ); -} +}); + +export default ColumnHeader; diff --git a/src/components/Table/ColumnHeader/ColumnHeaderDragHandle.tsx b/src/components/Table/ColumnHeader/ColumnHeaderDragHandle.tsx new file mode 100644 index 00000000..a7ae1da9 --- /dev/null +++ b/src/components/Table/ColumnHeader/ColumnHeaderDragHandle.tsx @@ -0,0 +1,58 @@ +import type { DraggableProvidedDragHandleProps } from "react-beautiful-dnd"; +import { DragVertical } from "@src/assets/icons"; + +export interface IColumnHeaderDragHandleProps { + dragHandleProps: DraggableProvidedDragHandleProps; + tabIndex: number; +} + +export default function ColumnHeaderDragHandle({ + dragHandleProps, + tabIndex, +}: IColumnHeaderDragHandleProps) { + return ( +
-1 ? dragHandleProps["aria-describedby"] : undefined + } + style={{ + position: "absolute", + inset: 0, + zIndex: 0, + display: "flex", + alignItems: "center", + outline: "none", + }} + className="column-drag-handle" + > + theme.transitions.create(["opacity"]), + "[role='columnheader']:hover &, [role='columnheader']:focus-within &": + { + opacity: 0.5, + }, + ".column-drag-handle:hover &": { + opacity: 1, + }, + ".column-drag-handle:active &": { + opacity: 1, + color: "primary.main", + }, + ".column-drag-handle:focus &": { + opacity: 1, + color: "primary.main", + outline: "2px solid", + outlineColor: "primary.main", + }, + }} + style={{ width: 8 }} + preserveAspectRatio="xMidYMid slice" + /> +
+ ); +} diff --git a/src/components/Table/ColumnHeader/ColumnHeaderResizer.tsx b/src/components/Table/ColumnHeader/ColumnHeaderResizer.tsx new file mode 100644 index 00000000..88b65232 --- /dev/null +++ b/src/components/Table/ColumnHeader/ColumnHeaderResizer.tsx @@ -0,0 +1,56 @@ +import { styled } from "@mui/material"; + +export interface IColumnHeaderResizerProps { + isResizing: boolean; +} + +export const ColumnHeaderResizer = styled("div", { + name: "ColumnHeaderResizer", + shouldForwardProp: (prop) => prop !== "isResizing", +})(({ theme, isResizing }) => ({ + position: "absolute", + zIndex: 5, + right: 0, + top: 0, + height: "100%", + width: 10, + + cursor: "col-resize", + userSelect: "none", + touchAction: "none", + + display: "flex", + justifyContent: "flex-end", + alignItems: "center", + + transition: theme.transitions.create("opacity", { + duration: theme.transitions.duration.shortest, + }), + opacity: isResizing ? 1 : 0, + "[role='columnheader']:hover &": { opacity: 0.33 }, + "[role='columnheader'] &:hover, [role='columnheader'] &:active": { + opacity: 1, + "&::before": { transform: "scaleY(1.25)" }, + }, + + "&::before": { + content: "''", + display: "block", + + height: "50%", + width: 4, + borderRadius: 2, + marginRight: 2, + + background: isResizing + ? theme.palette.primary.main + : theme.palette.action.active, + transition: theme.transitions.create("transform", { + duration: theme.transitions.duration.shortest, + }), + transform: isResizing ? "scaleY(1.5) !important" : undefined, + }, +})); +ColumnHeaderResizer.displayName = "ColumnHeaderResizer"; + +export default ColumnHeaderResizer; diff --git a/src/components/Table/ColumnHeader/ColumnHeaderSort.tsx b/src/components/Table/ColumnHeader/ColumnHeaderSort.tsx index deab488b..b9d1f6ac 100644 --- a/src/components/Table/ColumnHeader/ColumnHeaderSort.tsx +++ b/src/components/Table/ColumnHeader/ColumnHeaderSort.tsx @@ -1,4 +1,5 @@ -import { useAtom } from "jotai"; +import { memo } from "react"; +import { useSetAtom } from "jotai"; import { colord } from "colord"; import { Tooltip, IconButton } from "@mui/material"; @@ -8,27 +9,26 @@ import IconSlash, { } from "@src/components/IconSlash"; import { tableScope, tableSortsAtom } from "@src/atoms/tableScope"; -import { FieldType } from "@src/constants/fields"; -import { getFieldProp } from "@src/components/fields"; -import { ColumnConfig } from "@src/types/table"; - -const SORT_STATES = ["none", "desc", "asc"] as const; +export const SORT_STATES = ["none", "desc", "asc"] as const; export interface IColumnHeaderSortProps { - column: ColumnConfig; + sortKey: string; + currentSort: typeof SORT_STATES[number]; + tabIndex?: number; } -export default function ColumnHeaderSort({ column }: IColumnHeaderSortProps) { - const [tableSorts, setTableSorts] = useAtom(tableSortsAtom, tableScope); +/** + * Renders button with current sort state. + * On click, updates `tableSortsAtom` in `tableScope`. + */ +export const ColumnHeaderSort = memo(function ColumnHeaderSort({ + sortKey, + currentSort, + tabIndex, +}: IColumnHeaderSortProps) { + const setTableSorts = useSetAtom(tableSortsAtom, tableScope); - const _sortKey = getFieldProp("sortKey", (column as any).type); - const sortKey = _sortKey ? `${column.key}.${_sortKey}` : column.key; - - const currentSort: typeof SORT_STATES[number] = - tableSorts[0]?.key !== sortKey - ? "none" - : tableSorts[0]?.direction || "none"; const nextSort = SORT_STATES[SORT_STATES.indexOf(currentSort) + 1] ?? SORT_STATES[0]; @@ -37,20 +37,19 @@ export default function ColumnHeaderSort({ column }: IColumnHeaderSortProps) { else setTableSorts([{ key: sortKey, direction: nextSort }]); }; - if (column.type === FieldType.id) return null; - return ( colord(theme.palette.background.default) .mix( @@ -74,12 +73,6 @@ export default function ColumnHeaderSort({ column }: IColumnHeaderSortProps) { position: "relative", opacity: currentSort !== "none" ? 1 : 0, - ".column-header:hover &": { opacity: 1 }, - - transition: (theme) => - theme.transitions.create(["background-color", "opacity"], { - duration: theme.transitions.duration.short, - }), "& .arrow": { transition: (theme) => @@ -89,7 +82,7 @@ export default function ColumnHeaderSort({ column }: IColumnHeaderSortProps) { transform: currentSort === "asc" ? "rotate(180deg)" : "none", }, - "&:hover .arrow": { + "&:hover .arrow, &:focus .arrow": { transform: currentSort === "asc" || nextSort === "asc" ? "rotate(180deg)" @@ -100,7 +93,7 @@ export default function ColumnHeaderSort({ column }: IColumnHeaderSortProps) { strokeDashoffset: currentSort === "none" ? 0 : ICON_SLASH_STROKE_DASHOFFSET, }, - "&:hover .icon-slash": { + "&:hover .icon-slash, &:focus .icon-slash": { strokeDashoffset: nextSort === "none" ? 0 : ICON_SLASH_STROKE_DASHOFFSET, }, @@ -113,4 +106,6 @@ export default function ColumnHeaderSort({ column }: IColumnHeaderSortProps) { ); -} +}); + +export default ColumnHeaderSort; diff --git a/src/components/fields/_BasicCell/BasicCellContextMenuActions.tsx b/src/components/Table/ContextMenu/BasicCellContextMenuActions.tsx similarity index 100% rename from src/components/fields/_BasicCell/BasicCellContextMenuActions.tsx rename to src/components/Table/ContextMenu/BasicCellContextMenuActions.tsx diff --git a/src/components/Table/ContextMenu/ContextMenu.tsx b/src/components/Table/ContextMenu/ContextMenu.tsx index a8ad0725..9eaf4472 100644 --- a/src/components/Table/ContextMenu/ContextMenu.tsx +++ b/src/components/Table/ContextMenu/ContextMenu.tsx @@ -1,3 +1,4 @@ +import { useRef, useEffect } from "react"; import { useAtom } from "jotai"; import { ErrorBoundary } from "react-error-boundary"; import { NonFullScreenErrorFallback } from "@src/components/ErrorFallback"; @@ -8,10 +9,21 @@ import MenuContents from "./MenuContents"; import { tableScope, contextMenuTargetAtom } from "@src/atoms/tableScope"; export default function ContextMenu() { + const menuRef = useRef(null); const [contextMenuTarget, setContextMenuTarget] = useAtom( contextMenuTargetAtom, tableScope ); + const open = Boolean(contextMenuTarget); + + useEffect(() => { + setTimeout(() => { + if (open && menuRef.current) { + const firstMenuitem = menuRef.current.querySelector("[role=menuitem]"); + (firstMenuitem as HTMLElement)?.focus(); + } + }); + }, [open]); const handleClose = () => setContextMenuTarget(null); @@ -20,11 +32,12 @@ export default function ContextMenu() { id="cell-context-menu" aria-label="Cell context menu" anchorEl={contextMenuTarget as any} - open={Boolean(contextMenuTarget)} + open={open} onClose={handleClose} anchorOrigin={{ vertical: "bottom", horizontal: "left" }} transformOrigin={{ vertical: "top", horizontal: "left" }} sx={{ "& .MuiMenu-paper": { minWidth: 160 } }} + MenuListProps={{ ref: menuRef }} > diff --git a/src/components/Table/ContextMenu/ContextMenuItem.tsx b/src/components/Table/ContextMenu/ContextMenuItem.tsx index 777d1db1..8feb79cb 100644 --- a/src/components/Table/ContextMenu/ContextMenuItem.tsx +++ b/src/components/Table/ContextMenu/ContextMenuItem.tsx @@ -19,6 +19,7 @@ export interface IContextMenuItem extends Partial { disabled?: boolean; hotkeyLabel?: string; divider?: boolean; + subItems?: IContextMenuItem[]; } export interface IContextMenuItemProps extends IContextMenuItem { @@ -82,16 +83,20 @@ export default function ContextMenuItem({ ); } else { - return ( - - {icon} - {label} - {hotkeyLabel && ( - - {hotkeyLabel} - - )} - - ); + if (props.divider) { + return ; + } else { + return ( + + {icon} + {label} + {hotkeyLabel && ( + + {hotkeyLabel} + + )} + + ); + } } } diff --git a/src/components/Table/ContextMenu/MenuContents.tsx b/src/components/Table/ContextMenu/MenuContents.tsx index f4d36ef3..5a10aaef 100644 --- a/src/components/Table/ContextMenu/MenuContents.tsx +++ b/src/components/Table/ContextMenu/MenuContents.tsx @@ -62,182 +62,188 @@ export default function MenuContents({ onClose }: IMenuContentsProps) { if (!tableSchema.columns || !selectedCell) return null; const selectedColumn = tableSchema.columns[selectedCell.columnKey]; - const menuActions = getFieldProp("contextMenuActions", selectedColumn.type); + const row = find(tableRows, ["_rowy_ref.path", selectedCell.path]); + + if (!row) return null; const actionGroups: IContextMenuItem[][] = []; - const row = find(tableRows, ["_rowy_ref.path", selectedCell.path]); - // 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 cellValue = row?.[selectedCell.columnKey]; - const handleClearValue = () => - updateField({ - path: selectedCell.path, - fieldName: selectedColumn.fieldName, - value: null, - deleteField: true, + const handleDuplicate = () => { + addRow({ + row, + setId: addRowIdType === "custom" ? "decrement" : addRowIdType, }); - const columnFilters = getFieldProp("filter", selectedColumn.type); - const handleFilterValue = () => { - openTableFiltersPopover({ - defaultQuery: { - key: selectedColumn.fieldName, - operator: columnFilters!.operators[0]?.value || "==", - value: cellValue, - }, - }); - onClose(); }; - const cellActions = [ + const handleDelete = () => deleteRow(row._rowy_ref.path); + const rowActions: IContextMenuItem[] = [ { - label: altPress ? "Clear value" : "Clear value…", - color: "error", - icon: , + label: "Copy ID", + icon: , + onClick: () => { + navigator.clipboard.writeText(row._rowy_ref.id); + onClose(); + }, + }, + { + label: "Copy path", + icon: , + onClick: () => { + navigator.clipboard.writeText(row._rowy_ref.path); + onClose(); + }, + }, + { + label: "Open in Firebase Console", + icon: , + onClick: () => { + window.open( + `https://console.firebase.google.com/project/${projectId}/firestore/data/~2F${row._rowy_ref.path.replace( + /\//g, + "~2F" + )}` + ); + onClose(); + }, + }, + { label: "Divider", divider: true }, + { + label: "Duplicate", + icon: , disabled: - selectedColumn.editable === false || - !row || - cellValue === undefined || - getFieldProp("group", selectedColumn.type) === "Auditing", + tableSettings.tableType === "collectionGroup" || + (!userRoles.includes("ADMIN") && tableSettings.readOnly), onClick: altPress - ? handleClearValue + ? handleDuplicate : () => { confirm({ - title: "Clear cell value?", - body: "The cell’s value cannot be recovered after", - confirm: "Delete", - confirmColor: "error", - handleConfirm: handleClearValue, + title: "Duplicate row?", + body: ( + <> + Row path: +
+ + {row._rowy_ref.path} + + + ), + confirm: "Duplicate", + handleConfirm: handleDuplicate, }); onClose(); }, }, { - label: "Filter value", - icon: , - disabled: !columnFilters || cellValue === undefined, - onClick: handleFilterValue, + label: altPress ? "Delete" : "Delete…", + color: "error", + icon: , + disabled: !userRoles.includes("ADMIN") && tableSettings.readOnly, + onClick: altPress + ? handleDelete + : () => { + confirm({ + title: "Delete row?", + body: ( + <> + Row path: +
+ + {row._rowy_ref.path} + + + ), + confirm: "Delete", + confirmColor: "error", + handleConfirm: handleDelete, + }); + onClose(); + }, }, ]; - actionGroups.push(cellActions); - // Row actions - if (row) { - const handleDuplicate = () => { - addRow({ - row, - setId: addRowIdType === "custom" ? "decrement" : addRowIdType, + if (selectedColumn) { + const menuActions = getFieldProp( + "contextMenuActions", + selectedColumn?.type + ); + + // 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 cellValue = row?.[selectedCell.columnKey]; + const handleClearValue = () => + updateField({ + path: selectedCell.path, + fieldName: selectedColumn.fieldName, + value: null, + deleteField: true, }); + const columnFilters = getFieldProp("filter", selectedColumn?.type); + const handleFilterValue = () => { + openTableFiltersPopover({ + defaultQuery: { + key: selectedColumn.fieldName, + operator: columnFilters!.operators[0]?.value || "==", + value: cellValue, + }, + }); + onClose(); }; - const handleDelete = () => deleteRow(row._rowy_ref.path); - const rowActions = [ + const cellActions = [ + { + label: altPress ? "Clear value" : "Clear value…", + color: "error", + icon: , + disabled: + selectedColumn?.editable === false || + !row || + cellValue === undefined || + getFieldProp("group", selectedColumn?.type) === "Auditing", + onClick: altPress + ? handleClearValue + : () => { + confirm({ + title: "Clear cell value?", + body: "The cell’s value cannot be recovered after", + confirm: "Delete", + confirmColor: "error", + handleConfirm: handleClearValue, + }); + onClose(); + }, + }, + { + label: "Filter value", + icon: , + disabled: !columnFilters || cellValue === undefined, + onClick: handleFilterValue, + }, + ]; + actionGroups.push(cellActions); + + // Row actions as sub-menu + actionGroups.push([ { label: "Row", icon: , - subItems: [ - { - label: "Copy ID", - icon: , - onClick: () => { - navigator.clipboard.writeText(row._rowy_ref.id); - onClose(); - }, - }, - { - label: "Copy path", - icon: , - onClick: () => { - navigator.clipboard.writeText(row._rowy_ref.path); - onClose(); - }, - }, - { - label: "Open in Firebase Console", - icon: , - onClick: () => { - window.open( - `https://console.firebase.google.com/project/${projectId}/firestore/data/~2F${row._rowy_ref.path.replace( - /\//g, - "~2F" - )}` - ); - onClose(); - }, - }, - { label: "Divider", divider: true }, - { - label: "Duplicate", - icon: , - disabled: - tableSettings.tableType === "collectionGroup" || - (!userRoles.includes("ADMIN") && tableSettings.readOnly), - onClick: altPress - ? handleDuplicate - : () => { - confirm({ - title: "Duplicate row?", - body: ( - <> - Row path: -
- - {row._rowy_ref.path} - - - ), - confirm: "Duplicate", - handleConfirm: handleDuplicate, - }); - onClose(); - }, - }, - { - label: altPress ? "Delete" : "Delete…", - color: "error", - icon: , - disabled: !userRoles.includes("ADMIN") && tableSettings.readOnly, - onClick: altPress - ? handleDelete - : () => { - confirm({ - title: "Delete row?", - body: ( - <> - Row path: -
- - {row._rowy_ref.path} - - - ), - confirm: "Delete", - confirmColor: "error", - handleConfirm: handleDelete, - }); - onClose(); - }, - }, - ], + subItems: rowActions, }, - ]; + ]); + } else { actionGroups.push(rowActions); } diff --git a/src/components/Table/formatters/FinalColumn.tsx b/src/components/Table/FinalColumn/FinalColumn.tsx similarity index 65% rename from src/components/Table/formatters/FinalColumn.tsx rename to src/components/Table/FinalColumn/FinalColumn.tsx index 84dd4563..28b584e6 100644 --- a/src/components/Table/formatters/FinalColumn.tsx +++ b/src/components/Table/FinalColumn/FinalColumn.tsx @@ -1,9 +1,11 @@ +import { memo } from "react"; import { useAtom, useSetAtom } from "jotai"; -import type { FormatterProps } from "react-data-grid"; +import type { IRenderedTableCellProps } from "@src/components/Table/TableCell/withRenderTableCell"; import { Stack, Tooltip, IconButton, alpha } from "@mui/material"; import { CopyCells as CopyCellsIcon } from "@src/assets/icons"; import DeleteIcon from "@mui/icons-material/DeleteOutlined"; +import MenuIcon from "@mui/icons-material/MoreHoriz"; import { projectScope, @@ -17,10 +19,13 @@ import { tableSettingsAtom, addRowAtom, deleteRowAtom, + contextMenuTargetAtom, } from "@src/atoms/tableScope"; -import { TableRow } from "@src/types/table"; -export default function FinalColumn({ row }: FormatterProps) { +export const FinalColumn = memo(function FinalColumn({ + row, + focusInsideCell, +}: IRenderedTableCellProps) { const [userRoles] = useAtom(userRolesAtom, projectScope); const [addRowIdType] = useAtom(tableAddRowIdTypeAtom, projectScope); const confirm = useSetAtom(confirmDialogAtom, projectScope); @@ -28,12 +33,13 @@ export default function FinalColumn({ row }: FormatterProps) { const [tableSettings] = useAtom(tableSettingsAtom, tableScope); const addRow = useSetAtom(addRowAtom, tableScope); const deleteRow = useSetAtom(deleteRowAtom, tableScope); + const setContextMenuTarget = useSetAtom(contextMenuTargetAtom, tableScope); const [altPress] = useAtom(altPressAtom, projectScope); - const handleDelete = () => deleteRow(row._rowy_ref.path); + const handleDelete = () => deleteRow(row.original._rowy_ref.path); const handleDuplicate = () => { addRow({ - row, + row: row.original, setId: addRowIdType === "custom" ? "decrement" : addRowIdType, }); }; @@ -42,7 +48,26 @@ export default function FinalColumn({ row }: FormatterProps) { return null; return ( - + + + { + setContextMenuTarget(e.target as HTMLElement); + }} + className="row-hover-iconButton" + tabIndex={focusInsideCell ? 0 : -1} + > + + + + ) { - {row._rowy_ref.path} + {row.original._rowy_ref.path} ), @@ -70,8 +95,8 @@ export default function FinalColumn({ row }: FormatterProps) { }); } } - aria-label="Duplicate row" className="row-hover-iconButton" + tabIndex={focusInsideCell ? 0 : -1} > @@ -94,7 +119,7 @@ export default function FinalColumn({ row }: FormatterProps) { - {row._rowy_ref.path} + {row.original._rowy_ref.path} ), @@ -104,23 +129,25 @@ export default function FinalColumn({ row }: FormatterProps) { }); } } - aria-label={`Delete row${altPress ? "" : "…"}`} className="row-hover-iconButton" + tabIndex={focusInsideCell ? 0 : -1} sx={{ - ".rdg-row:hover .row-hover-iconButton&&": { - color: "error.main", - backgroundColor: (theme) => - alpha( - theme.palette.error.main, - theme.palette.action.hoverOpacity * 2 - ), - }, + "[role='row']:hover .row-hover-iconButton&&, .row-hover-iconButton&&:focus": + { + color: "error.main", + backgroundColor: (theme) => + alpha( + theme.palette.error.main, + theme.palette.action.hoverOpacity * 2 + ), + }, }} - disabled={!row._rowy_ref.path} + disabled={!row.original._rowy_ref.path} > ); -} +}); +export default FinalColumn; diff --git a/src/components/Table/FinalColumn/FinalColumnHeader.tsx b/src/components/Table/FinalColumn/FinalColumnHeader.tsx new file mode 100644 index 00000000..a1989f53 --- /dev/null +++ b/src/components/Table/FinalColumn/FinalColumnHeader.tsx @@ -0,0 +1,86 @@ +import { useAtom, useSetAtom } from "jotai"; + +import { Box, BoxProps, Button } from "@mui/material"; +import { AddColumn as AddColumnIcon } from "@src/assets/icons"; + +import { projectScope, userRolesAtom } from "@src/atoms/projectScope"; +import { tableScope, columnModalAtom } from "@src/atoms/tableScope"; +import { spreadSx } from "@src/utils/ui"; + +export interface IFinalColumnHeaderProps extends Partial { + focusInsideCell: boolean; + canAddColumns: boolean; +} + +export default function FinalColumnHeader({ + focusInsideCell, + canAddColumns, + ...props +}: IFinalColumnHeaderProps) { + const [userRoles] = useAtom(userRolesAtom, projectScope); + const openColumnModal = useSetAtom(columnModalAtom, tableScope); + + if (!userRoles.includes("ADMIN")) + return ( + `1px solid ${theme.palette.divider}`, + borderLeft: "none", + borderTopRightRadius: (theme) => theme.shape.borderRadius, + borderBottomRightRadius: (theme) => theme.shape.borderRadius, + + display: "flex", + alignItems: "center", + + width: 32 * 3 + 4 * 2 + 10 * 2, + px: 1.5, + color: "text.secondary", + }, + ...spreadSx(props.sx), + ]} + className="column-header" + > + Actions + + ); + + return ( + `1px solid ${theme.palette.divider}`, + borderLeft: "none", + borderTopRightRadius: (theme) => theme.shape.borderRadius, + borderBottomRightRadius: (theme) => theme.shape.borderRadius, + + display: "flex", + alignItems: "center", + + width: 32 * 3 + 4 * 2 + 10 * 2, + overflow: "visible", + px: 0.75, + }, + ...spreadSx(props.sx), + ]} + className="column-header" + > + + + ); +} diff --git a/src/components/Table/FinalColumnHeader.tsx b/src/components/Table/FinalColumnHeader.tsx deleted file mode 100644 index b425d1b1..00000000 --- a/src/components/Table/FinalColumnHeader.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useAtom, useSetAtom } from "jotai"; -import { Column } from "react-data-grid"; - -import { Button } from "@mui/material"; -import { AddColumn as AddColumnIcon } from "@src/assets/icons"; - -import { projectScope, userRolesAtom } from "@src/atoms/projectScope"; -import { tableScope, columnModalAtom } from "@src/atoms/tableScope"; - -const FinalColumnHeader: Column["headerRenderer"] = ({ column }) => { - const [userRoles] = useAtom(userRolesAtom, projectScope); - const openColumnModal = useSetAtom(columnModalAtom, tableScope); - - if (!userRoles.includes("ADMIN")) return null; - - return ( - - ); -}; - -export default FinalColumnHeader; diff --git a/src/components/Table/Cell.tsx b/src/components/Table/Mock/Cell.tsx similarity index 100% rename from src/components/Table/Cell.tsx rename to src/components/Table/Mock/Cell.tsx diff --git a/src/components/Table/Column.tsx b/src/components/Table/Mock/Column.tsx similarity index 84% rename from src/components/Table/Column.tsx rename to src/components/Table/Mock/Column.tsx index 71c73894..fc831d1d 100644 --- a/src/components/Table/Column.tsx +++ b/src/components/Table/Mock/Column.tsx @@ -1,8 +1,10 @@ +import { forwardRef } from "react"; import { Grid, GridProps, Typography } from "@mui/material"; import { alpha } from "@mui/material/styles"; import { FieldType } from "@src/constants/fields"; import { getFieldProp } from "@src/components/fields"; +import { spreadSx } from "@src/utils/ui"; export const COLUMN_HEADER_HEIGHT = 42; @@ -10,23 +12,30 @@ export interface IColumnProps extends Partial { label: string; type?: FieldType; secondaryItem?: React.ReactNode; + children?: React.ReactNode; active?: boolean; } -export default function Column({ - label, - type, - secondaryItem, +export const Column = forwardRef(function Column( + { + label, + type, + secondaryItem, + children, - active, - ...props -}: IColumnProps) { + active, + ...props + }: IColumnProps, + ref: React.ForwardedRef +) { return ( `1px solid ${theme.palette.divider}`, backgroundColor: "background.default", + position: "relative", py: 0, px: 1, @@ -68,6 +78,7 @@ export default function Column({ }, } : {}, + ...spreadSx(props.sx), ]} > {type && {getFieldProp("icon", type)}} @@ -104,6 +115,10 @@ export default function Column({ {secondaryItem} )} + + {children} ); -} +}); + +export default Column; diff --git a/src/components/Table/OutOfOrderIndicator.tsx b/src/components/Table/OutOfOrderIndicator.tsx index 337b3d50..907ec2e3 100644 --- a/src/components/Table/OutOfOrderIndicator.tsx +++ b/src/components/Table/OutOfOrderIndicator.tsx @@ -3,7 +3,6 @@ import { useAtom } from "jotai"; import { styled } from "@mui/material/styles"; import RichTooltip from "@src/components/RichTooltip"; import WarningIcon from "@mui/icons-material/WarningAmber"; -import { OUT_OF_ORDER_MARGIN } from "./TableContainer"; import { projectScope, @@ -24,15 +23,7 @@ const Dot = styled("div")(({ theme }) => ({ backgroundColor: theme.palette.warning.main, })); -export interface IOutOfOrderIndicatorProps { - top: number; - height: number; -} - -export default function OutOfOrderIndicator({ - top, - height, -}: IOutOfOrderIndicatorProps) { +export default function OutOfOrderIndicator() { const [dismissed, setDismissed] = useAtom( tableOutOfOrderDismissedAtom, projectScope @@ -42,11 +33,12 @@ export default function OutOfOrderIndicator({
({ + position: "relative", + display: "flex", + alignItems: "center", + lineHeight: "calc(var(--row-height) - 1px)", + whiteSpace: "nowrap", + "--cell-padding": theme.spacing(10 / 8), + + "& > .cell-contents": { + padding: "0 var(--cell-padding)", + width: "100%", + height: "100%", + contain: "strict", + overflow: "hidden", + + display: "flex", + alignItems: "center", + }, + + backgroundColor: "var(--cell-background-color)", + + border: `1px solid ${theme.palette.divider}`, + borderTop: "none", + "& + &": { borderLeft: "none" }, + + "[role='row']:hover &": { + backgroundColor: "var(--row-hover-background-color)", + }, + + "[data-out-of-order='true'] + [role='row'] &": { + borderTop: `1px solid ${theme.palette.divider}`, + }, + + "&[aria-invalid='true'] .cell-contents": { + outline: `2px dotted ${theme.palette.error.main}`, + outlineOffset: -2, + }, +})); +StyledCell.displayName = "StyledCell"; + +export default StyledCell; diff --git a/src/components/Table/Styled/StyledColumnHeader.tsx b/src/components/Table/Styled/StyledColumnHeader.tsx new file mode 100644 index 00000000..36791504 --- /dev/null +++ b/src/components/Table/Styled/StyledColumnHeader.tsx @@ -0,0 +1,58 @@ +import { + styled, + Tooltip, + TooltipProps, + tooltipClasses, + Stack, +} from "@mui/material"; +import { COLUMN_HEADER_HEIGHT } from "@src/components/Table/Mock/Column"; + +export const StyledColumnHeader = styled(Stack)(({ theme }) => ({ + position: "relative", + height: "100%", + border: `1px solid ${theme.palette.divider}`, + "& + &": { borderLeftStyle: "none" }, + + flexDirection: "row", + alignItems: "center", + padding: theme.spacing(0, 0.5, 0, 1), + "& svg, & button": { display: "block", zIndex: 1 }, + + backgroundColor: theme.palette.background.default, + color: theme.palette.text.secondary, + transition: theme.transitions.create("color", { + duration: theme.transitions.duration.short, + }), + "&:hover": { color: theme.palette.text.primary }, + + "& .MuiIconButton-root": { + color: theme.palette.text.disabled, + transition: theme.transitions.create( + ["background-color", "opacity", "color"], + { duration: theme.transitions.duration.short } + ), + }, + [`&:hover .MuiIconButton-root, + &:focus .MuiIconButton-root, + &:focus-within .MuiIconButton-root, + .MuiIconButton-root:focus`]: { + color: theme.palette.text.primary, + opacity: 1, + }, +})); +export default StyledColumnHeader; + +export const StyledColumnHeaderNameTooltip = styled( + ({ className, ...props }: TooltipProps) => ( + + ) +)(({ theme }) => ({ + [`& .${tooltipClasses.tooltip}`]: { + backgroundColor: theme.palette.background.default, + color: theme.palette.text.primary, + + margin: `-${COLUMN_HEADER_HEIGHT - 1 - 2}px 0 0 !important`, + padding: 0, + paddingRight: theme.spacing(1.5), + }, +})); diff --git a/src/components/Table/Styled/StyledDot.tsx b/src/components/Table/Styled/StyledDot.tsx new file mode 100644 index 00000000..bd942a8f --- /dev/null +++ b/src/components/Table/Styled/StyledDot.tsx @@ -0,0 +1,22 @@ +import { styled } from "@mui/material"; + +export const StyledDot = styled("div")(({ theme }) => ({ + position: "absolute", + right: -5, + top: "50%", + transform: "translateY(-50%)", + zIndex: 1, + + width: 12, + height: 12, + + borderRadius: "50%", + backgroundColor: theme.palette.error.main, + + boxShadow: `0 0 0 4px var(--cell-background-color)`, + "[role='row']:hover &": { + boxShadow: `0 0 0 4px var(--row-hover-background-color)`, + }, +})); + +export default StyledDot; diff --git a/src/components/Table/Styled/StyledRow.tsx b/src/components/Table/Styled/StyledRow.tsx new file mode 100644 index 00000000..9aeeb73f --- /dev/null +++ b/src/components/Table/Styled/StyledRow.tsx @@ -0,0 +1,53 @@ +import { styled, alpha } from "@mui/material"; + +export const StyledRow = styled("div")(({ theme }) => ({ + display: "flex", + position: "relative", + + "& > *": { + flexGrow: 0, + flexShrink: 0, + minWidth: 0, + }, + + "& [role='columnheader']": { + "&:first-of-type": { + borderTopLeftRadius: theme.shape.borderRadius, + }, + "&:last-of-type": { + borderTopRightRadius: theme.shape.borderRadius, + }, + }, + + "&:last-of-type": { + "& [role='gridcell']:first-of-type": { + borderBottomLeftRadius: theme.shape.borderRadius, + }, + "& [role='gridcell']:last-of-type": { + borderBottomRightRadius: theme.shape.borderRadius, + }, + }, + + "& .row-hover-iconButton, .row-hover-iconButton:focus": { + color: theme.palette.text.disabled, + transitionDuration: "0s", + + flexShrink: 0, + borderRadius: theme.shape.borderRadius, + padding: (32 - 20) / 2, + width: 32, + height: 32, + + "&.end": { marginRight: theme.spacing(0.5) }, + }, + "&:hover .row-hover-iconButton, .row-hover-iconButton:focus": { + color: theme.palette.text.primary, + backgroundColor: alpha( + theme.palette.action.hover, + theme.palette.action.hoverOpacity * 1.5 + ), + }, +})); +StyledRow.displayName = "StyledRow"; + +export default StyledRow; diff --git a/src/components/Table/Styled/StyledTable.tsx b/src/components/Table/Styled/StyledTable.tsx new file mode 100644 index 00000000..904520ba --- /dev/null +++ b/src/components/Table/Styled/StyledTable.tsx @@ -0,0 +1,48 @@ +import { styled } from "@mui/material"; +import { colord } from "colord"; + +export const StyledTable = styled("div")(({ theme }) => ({ + "--cell-background-color": + theme.palette.mode === "light" + ? theme.palette.background.paper + : colord(theme.palette.background.paper) + .mix("#fff", 0.04) + .alpha(1) + .toHslString(), + "--row-hover-background-color": colord(theme.palette.background.paper) + .mix(theme.palette.action.hover, theme.palette.action.hoverOpacity) + .alpha(1) + .toHslString(), + + ...(theme.typography.caption as any), + lineHeight: "inherit !important", + + "& [role='columnheader'], & [role='gridcell']": { + "&[aria-selected='true']": { + outline: `2px solid ${theme.palette.primary.main}`, + outlineOffset: "-2px", + }, + "&:focus": { + outlineWidth: "3px", + outlineOffset: "-3px", + }, + }, + + "& [data-frozen='left']": { + position: "sticky", + left: 0, + zIndex: 2, + + "&[data-frozen-last='true']": { + boxShadow: theme.shadows[2] + .replace(/, 0 (\d+px)/g, ", $1 0") + .split("),") + .slice(1) + .join("),"), + clipPath: "inset(0 -4px 0 0)", + }, + }, +})); +StyledTable.displayName = "StyledTable"; + +export default StyledTable; diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index 308e8767..412e0535 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -1,343 +1,286 @@ -import React, { useMemo, useState, Suspense } from "react"; +import { useMemo, useRef, useState, useEffect, useCallback } from "react"; +import useStateRef from "react-usestateref"; import { useAtom, useSetAtom } from "jotai"; -import { useDebouncedCallback, useThrottledCallback } from "use-debounce"; -import { DndProvider } from "react-dnd"; -import { HTML5Backend } from "react-dnd-html5-backend"; -import { findIndex } from "lodash-es"; +import { useThrottledCallback } from "use-debounce"; +import { + createColumnHelper, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { DropResult } from "react-beautiful-dnd"; +import { get } from "lodash-es"; -// import "react-data-grid/dist/react-data-grid.css"; -import DataGrid, { - Column, - DataGridHandle, - // SelectColumn as _SelectColumn, -} from "react-data-grid"; -import { LinearProgress } from "@mui/material"; - -import TableContainer, { OUT_OF_ORDER_MARGIN } from "./TableContainer"; -import ColumnHeader, { COLUMN_HEADER_HEIGHT } from "./ColumnHeader"; -import FinalColumnHeader from "./FinalColumnHeader"; -import FinalColumn from "./formatters/FinalColumn"; -import TableRow from "./TableRow"; -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 StyledTable from "./Styled/StyledTable"; +import TableHeader from "./TableHeader"; +import TableBody from "./TableBody"; +import FinalColumn from "./FinalColumn/FinalColumn"; import ContextMenu from "./ContextMenu"; -import { - projectScope, - userRolesAtom, - userSettingsAtom, -} from "@src/atoms/projectScope"; +import EmptyState from "@src/components/EmptyState"; +// import BulkActions from "./BulkActions"; + import { tableScope, - tableIdAtom, - tableSettingsAtom, tableSchemaAtom, tableColumnsOrderedAtom, tableRowsAtom, tableNextPageAtom, tablePageAtom, updateColumnAtom, - updateFieldAtom, - selectedCellAtom, } from "@src/atoms/tableScope"; import { getFieldType, getFieldProp } from "@src/components/fields"; -import { FieldType } from "@src/constants/fields"; -import { formatSubTableName } from "@src/utils/table"; -import { ColumnConfig } from "@src/types/table"; +import { TableRow, ColumnConfig } from "@src/types/table"; +import { useKeyboardNavigation } from "./useKeyboardNavigation"; +import { useSaveColumnSizing } from "./useSaveColumnSizing"; -export type DataGridColumn = ColumnConfig & Column & { isNew?: true }; export const DEFAULT_ROW_HEIGHT = 41; export const DEFAULT_COL_WIDTH = 150; -export const MAX_COL_WIDTH = 380; +export const MIN_COL_WIDTH = 80; +export const TABLE_PADDING = 16; +export const OUT_OF_ORDER_MARGIN = 8; +export const DEBOUNCE_DELAY = 500; -const rowKeyGetter = (row: any) => row.id; -const rowClass = (row: any) => (row._rowy_outOfOrder ? "out-of-order" : ""); -//const SelectColumn = { ..._SelectColumn, width: 42, maxWidth: 42 }; +declare module "@tanstack/table-core" { + /** The `column.meta` property contains the column config from tableSchema */ + interface ColumnMeta extends ColumnConfig {} +} +const columnHelper = createColumnHelper(); +const getRowId = (row: TableRow) => row._rowy_ref.path || row._rowy_ref.id; + +export interface ITableProps { + /** Determines if “Add column” button is displayed */ + canAddColumns: boolean; + /** Determines if columns can be rearranged */ + canEditColumns: boolean; + /** + * Determines if any cell can be edited. + * If false, `Table` only ever renders `EditorCell`. + */ + canEditCells: boolean; + /** The hidden columns saved to user settings */ + hiddenColumns?: string[]; + /** + * Displayed when `tableRows` is empty. + * Loading state handled by Suspense in parent component. + */ + emptyState?: React.ReactNode; +} + +/** + * Takes table schema and row data from `tableScope` and makes it compatible + * with TanStack Table. Renders table children and cell context menu. + * + * - Calls `useKeyboardNavigation` hook + * - Handles rearranging columns + * - Handles infinite scrolling + * - Stores local state for resizing columns, and asks admins if they want to + * save to table schema for all users + */ export default function Table({ - dataGridRef, -}: { - dataGridRef?: React.MutableRefObject; -}) { - const [userRoles] = useAtom(userRolesAtom, projectScope); - const [userSettings] = useAtom(userSettingsAtom, projectScope); - - const [tableId] = useAtom(tableIdAtom, tableScope); - const [tableSettings] = useAtom(tableSettingsAtom, tableScope); + canAddColumns, + canEditColumns, + canEditCells, + hiddenColumns, + emptyState, +}: ITableProps) { const [tableSchema] = useAtom(tableSchemaAtom, tableScope); const [tableColumnsOrdered] = useAtom(tableColumnsOrderedAtom, tableScope); const [tableRows] = useAtom(tableRowsAtom, tableScope); const [tableNextPage] = useAtom(tableNextPageAtom, tableScope); - const setTablePage = useSetAtom(tablePageAtom, tableScope); - const [selectedCell, setSelectedCell] = useAtom(selectedCellAtom, tableScope); + const [tablePage, setTablePage] = useAtom(tablePageAtom, tableScope); const updateColumn = useSetAtom(updateColumnAtom, tableScope); - const updateField = useSetAtom(updateFieldAtom, tableScope); - const userDocHiddenFields = - userSettings.tables?.[formatSubTableName(tableId)]?.hiddenFields; + // Store a **state** and reference to the container element + // so the state can re-render `TableBody`, preventing virtualization + // not detecting scroll if the container element was initially `null` + const [containerEl, setContainerEl, containerRef] = + useStateRef(null); + const gridRef = useRef(null); - // Get column configs from table schema and map them to DataGridColumns - // Also filter out hidden columns and add end column + // Get column defs from table schema + // Also add end column for admins (canAddColumns || canEditCells) const columns = useMemo(() => { - const _columns: DataGridColumn[] = tableColumnsOrdered - .filter((column) => { - if (column.hidden) return false; - if ( - Array.isArray(userDocHiddenFields) && - userDocHiddenFields.includes(column.key) - ) - return false; - return true; - }) - .map((column: any) => ({ - draggable: true, - resizable: true, - frozen: column.fixed, - headerRenderer: ColumnHeader, - formatter: - getFieldProp("TableCell", getFieldType(column)) ?? - function InDev() { - return null; - }, - editor: - getFieldProp("TableEditor", getFieldType(column)) ?? - function InDev() { - return null; - }, - ...column, - editable: - tableSettings.readOnly && !userRoles.includes("ADMIN") - ? false - : column.editable ?? true, - width: column.width ?? DEFAULT_COL_WIDTH, - })); + const _columns = tableColumnsOrdered + // Hide column for all users using table schema + .filter((column) => !column.hidden) + .map((columnConfig) => + columnHelper.accessor((row) => get(row, columnConfig.fieldName), { + id: columnConfig.fieldName, + meta: columnConfig, + size: columnConfig.width, + enableResizing: columnConfig.resizable !== false, + minSize: MIN_COL_WIDTH, + cell: getFieldProp("TableCell", getFieldType(columnConfig)), + }) + ); - if (userRoles.includes("ADMIN") || !tableSettings.readOnly) { - _columns.push({ - isNew: true, - key: "new", - fieldName: "_rowy_new", - name: "Add column", - type: FieldType.last, - index: _columns.length ?? 0, - width: 154, - headerRenderer: FinalColumnHeader, - headerCellClass: "final-column-header", - cellClass: "final-column-cell", - formatter: FinalColumn, - editable: false, - }); + if (canAddColumns || canEditCells) { + _columns.push( + columnHelper.display({ + id: "_rowy_column_actions", + cell: FinalColumn as any, + }) + ); } return _columns; - }, [ - tableColumnsOrdered, - userDocHiddenFields, - tableSettings.readOnly, - userRoles, - ]); - const selectedColumnIndex = useMemo(() => { - if (!selectedCell?.columnKey) return -1; - return findIndex(columns, ["key", selectedCell.columnKey]); - }, [selectedCell?.columnKey, columns]); + }, [tableColumnsOrdered, canAddColumns, canEditCells]); - // Handle columns with field names that use dot notation (nested fields) - const rows = - useMemo(() => { - // const columnsWithNestedFieldNames = columns - // .map((col) => col.fieldName) - // .filter((fieldName) => fieldName.includes(".")); + // Get user’s hidden columns from props and memoize into a `VisibilityState` + const columnVisibility = useMemo(() => { + if (!Array.isArray(hiddenColumns)) return {}; + return hiddenColumns.reduce((a, c) => ({ ...a, [c]: false }), {}); + }, [hiddenColumns]); - // if (columnsWithNestedFieldNames.length === 0) - return tableRows; + // Get frozen columns and memoize into a `ColumnPinningState` + const columnPinning = useMemo( + () => ({ + left: columns.filter((c) => c.meta?.fixed && c.id).map((c) => c.id!), + }), + [columns] + ); + const lastFrozen: string | undefined = + columnPinning.left[columnPinning.left.length - 1]; - // return tableRows.map((row) => - // columnsWithNestedFieldNames.reduce( - // (acc, fieldName) => ({ - // ...acc, - // [fieldName]: get(row, fieldName), - // }), - // { ...row } - // ) - // ); - }, [tableRows]) ?? []; + // Call TanStack Table + const table = useReactTable({ + data: tableRows, + columns, + getCoreRowModel: getCoreRowModel(), + getRowId, + columnResizeMode: "onChange", + }); - // const [selectedRowsSet, setSelectedRowsSet] = useState>(); - // const [selectedRows, setSelectedRows] = useState([]); + // Store local `columnSizing` state so we can save it to table schema + // in `useSaveColumnSizing`. This could be generalized by storing the + // entire table state. + const [columnSizing, setColumnSizing] = useState( + table.initialState.columnSizing + ); + table.setOptions((prev) => ({ + ...prev, + state: { ...prev.state, columnVisibility, columnPinning, columnSizing }, + onColumnSizingChange: setColumnSizing, + })); + // Get rows and columns for virtualization + const { rows } = table.getRowModel(); + const leafColumns = table.getVisibleLeafColumns(); - // Gets more rows when scrolled down. - // https://github.com/adazzle/react-data-grid/blob/ead05032da79d7e2b86e37cdb9af27f2a4d80b90/stories/demos/AllFeatures.tsx#L60 - const handleScroll = useThrottledCallback( - (event: React.UIEvent) => { - // Select corresponding header cell when scrolled to prevent jumping - dataGridRef?.current?.selectCell({ - idx: - selectedColumnIndex > -1 ? selectedColumnIndex : columns.length - 1, - rowIdx: -1, + // Handle keyboard navigation + const { handleKeyDown } = useKeyboardNavigation({ + gridRef, + tableRows, + leafColumns, + }); + + // Handle prompt to save local column sizes if user `canEditColumns` + useSaveColumnSizing(columnSizing, canEditColumns); + + const handleDropColumn = useCallback( + (result: DropResult) => { + if (result.destination?.index === undefined || !result.draggableId) + return; + + console.log(result.draggableId, result.destination.index); + + updateColumn({ + key: result.draggableId, + index: result.destination.index, + config: {}, }); - // console.log( - // "scroll", - // dataGridRef?.current, - // selectedColumnIndex, - // columns.length - // ); - - const target = event.target as HTMLDivElement; - - // TODO: - // if (navPinned && !columns[0].fixed) - // setShowLeftScrollDivider(target.scrollLeft > 16); - - const offset = 800; - const isAtBottom = - target.clientHeight + target.scrollTop >= target.scrollHeight - offset; - if (!isAtBottom) return; - // Call for the next page - setTablePage((p) => p + 1); }, - 250 + [updateColumn] ); - const [showLeftScrollDivider, setShowLeftScrollDivider] = useState(false); + const fetchMoreOnBottomReached = useThrottledCallback( + (containerElement?: HTMLDivElement | null) => { + if (!containerElement) return; - const rowHeight = tableSchema.rowHeight ?? DEFAULT_ROW_HEIGHT; - const handleResize = useDebouncedCallback( - (colIndex: number, width: number) => { - const column = columns[colIndex]; - if (!column.key) return; - updateColumn({ key: column.key, config: { width } }); + const { scrollHeight, scrollTop, clientHeight } = containerElement; + if (scrollHeight - scrollTop - clientHeight < 300) { + setTablePage((p) => p + 1); + } }, - 1000 + DEBOUNCE_DELAY ); + // Check on mount and after fetch to see if the table is at the bottom + // for large screen heights + useEffect(() => { + fetchMoreOnBottomReached(containerRef.current); + }, [ + fetchMoreOnBottomReached, + tablePage, + tableNextPage.loading, + containerRef, + ]); return ( - }> - {/* */} - - - {showLeftScrollDivider &&
} - - { - if (dataGridRef) dataGridRef.current = handle; - }} - rows={rows} - columns={columns} - // Increase row height of out of order rows to add margins - rowHeight={({ row }) => { - if (row._rowy_outOfOrder) - return rowHeight + OUT_OF_ORDER_MARGIN + 1; - - return rowHeight; - }} - headerRowHeight={DEFAULT_ROW_HEIGHT + 1} - className="rdg-light" // Handle dark mode in MUI theme - cellNavigationMode="LOOP_OVER_ROW" - rowRenderer={TableRow} - rowKeyGetter={rowKeyGetter} - rowClass={rowClass} - // selectedRows={selectedRowsSet} - // onSelectedRowsChange={(newSelectedSet) => { - // const newSelectedArray = newSelectedSet - // ? [...newSelectedSet] - // : []; - // const prevSelectedRowsArray = selectedRowsSet - // ? [...selectedRowsSet] - // : []; - // const addedSelections = difference( - // newSelectedArray, - // prevSelectedRowsArray - // ); - // const removedSelections = difference( - // prevSelectedRowsArray, - // newSelectedArray - // ); - // addedSelections.forEach((id) => { - // const newRow = find(rows, { id }); - // setSelectedRows([...selectedRows, newRow]); - // }); - // removedSelections.forEach((rowId) => { - // setSelectedRows(selectedRows.filter((row) => row.id !== rowId)); - // }); - // setSelectedRowsSet(newSelectedSet); - // }} - // onRowsChange={() => { - //console.log('onRowsChange',rows) - // }} - // TODO: onFill={(e) => { - // console.log("onFill", e); - // const { columnKey, sourceRow, targetRows } = e; - // if (updateCell) - // targetRows.forEach((row) => - // updateCell(row._rowy_ref, columnKey, sourceRow[columnKey]) - // ); - // return []; - // }} - onPaste={(e, ...args) => { - console.log("onPaste", e, ...args); - const value = e.sourceRow[e.sourceColumnKey]; - updateField({ - path: e.targetRow._rowy_ref.path, - fieldName: e.targetColumnKey, - value, - }); - }} - onSelectedCellChange={({ rowIdx, idx }) => { - if (!rows[rowIdx]?._rowy_ref) return; // May be the header row - - const path = rows[rowIdx]._rowy_ref.path; - if (!path) return; - - const columnKey = tableColumnsOrdered.filter((col) => - userDocHiddenFields - ? !userDocHiddenFields.includes(col.key) - : true - )[idx]?.key; - if (!columnKey) return; // May be the final column - - setSelectedCell({ path, columnKey }); - }} +
setContainerEl(el)} + onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)} + style={{ overflow: "auto", width: "100%", height: "100%" }} + > + +
+ - +
- {tableRows.length === 0 && ( - -
- -
- } - style={{ - position: "absolute", - inset: 0, - top: COLUMN_HEADER_HEIGHT, - height: "auto", - }} + {tableRows.length === 0 ? ( + emptyState ?? + ) : ( + )} - {tableNextPage.loading && } - + + +
+ Press Enter to edit. +
- {/* - { - setSelectedRowsSet(new Set()); - setSelectedRows([]); - }} - /> */} - +
); } diff --git a/src/components/Table/TableBody.tsx b/src/components/Table/TableBody.tsx new file mode 100644 index 00000000..74efe194 --- /dev/null +++ b/src/components/Table/TableBody.tsx @@ -0,0 +1,144 @@ +import { memo } from "react"; +import { useAtom } from "jotai"; +import type { Column, Row, ColumnSizingState } from "@tanstack/react-table"; + +import StyledRow from "./Styled/StyledRow"; +import OutOfOrderIndicator from "./OutOfOrderIndicator"; +import TableCell from "./TableCell"; +import { RowsSkeleton } from "./TableSkeleton"; + +import { + tableScope, + tableSchemaAtom, + selectedCellAtom, + tableNextPageAtom, +} from "@src/atoms/tableScope"; + +import { getFieldProp } from "@src/components/fields"; +import type { TableRow } from "@src/types/table"; +import useVirtualization from "./useVirtualization"; +import { DEFAULT_ROW_HEIGHT, OUT_OF_ORDER_MARGIN } from "./Table"; + +export interface ITableBodyProps { + /** + * Re-render this component when the container element changes, to fix a bug + * where virtualization doesn’t detect scrolls if `containerRef.current` was + * initially null + */ + containerEl: HTMLDivElement | null; + /** Used in `useVirtualization` */ + containerRef: React.RefObject; + /** Used in `useVirtualization` */ + leafColumns: Column[]; + /** Current table rows with context from TanStack Table state */ + rows: Row[]; + /** Determines if EditorCell can be displayed */ + canEditCells: boolean; + /** If specified, renders a shadow in the last frozen column */ + lastFrozen?: string; + /** + * Must pass this prop so that it re-renders when local column sizing changes */ + columnSizing: ColumnSizingState; +} + +/** + * Renders table body & data rows. + * Handles virtualization of rows & columns via `useVirtualization`. + * + * - Renders row out of order indicator + * - Renders next page loading UI (`RowsSkeleton`) + */ +export const TableBody = memo(function TableBody({ + containerRef, + leafColumns, + rows, + canEditCells, + lastFrozen, +}: ITableBodyProps) { + const [tableSchema] = useAtom(tableSchemaAtom, tableScope); + const [selectedCell] = useAtom(selectedCellAtom, tableScope); + const [tableNextPage] = useAtom(tableNextPageAtom, tableScope); + + const { + virtualRows, + virtualCols, + paddingTop, + paddingBottom, + paddingLeft, + paddingRight, + } = useVirtualization(containerRef, leafColumns); + + const rowHeight = tableSchema.rowHeight || DEFAULT_ROW_HEIGHT; + + return ( +
+ {paddingTop > 0 && ( +
+ )} + + {virtualRows.map((virtualRow) => { + const row = rows[virtualRow.index]; + const outOfOrder = row.original._rowy_outOfOrder; + + return ( + + {outOfOrder && } + + {virtualCols.map((virtualCell) => { + const cellIndex = virtualCell.index; + const cell = row.getVisibleCells()[cellIndex]; + + const isSelectedCell = + selectedCell?.path === row.original._rowy_ref.path && + selectedCell?.columnKey === cell.column.id; + + const fieldTypeGroup = getFieldProp( + "group", + cell.column.columnDef.meta?.type + ); + const isReadOnlyCell = + fieldTypeGroup === "Auditing" || fieldTypeGroup === "Metadata"; + + return ( + + ); + })} + + ); + })} + + {tableNextPage.loading && } + + {paddingBottom > 0 && ( +
+ )} +
+ ); +}); + +export default TableBody; diff --git a/src/components/Table/formatters/ChipList.tsx b/src/components/Table/TableCell/ChipList.tsx similarity index 65% rename from src/components/Table/formatters/ChipList.tsx rename to src/components/Table/TableCell/ChipList.tsx index 5a6945d2..42f06264 100644 --- a/src/components/Table/formatters/ChipList.tsx +++ b/src/components/Table/TableCell/ChipList.tsx @@ -1,14 +1,9 @@ -import { useAtom } from "jotai"; - import { Grid } from "@mui/material"; -import { tableScope, tableSchemaAtom } from "@src/atoms/tableScope"; -import { DEFAULT_ROW_HEIGHT } from "@src/components/Table"; - -export default function ChipList({ children }: React.PropsWithChildren<{}>) { - const [tableSchema] = useAtom(tableSchemaAtom, tableScope); - - const rowHeight = tableSchema.rowHeight ?? DEFAULT_ROW_HEIGHT; +export default function ChipList({ + children, + rowHeight, +}: React.PropsWithChildren<{ rowHeight: number }>) { const canWrap = rowHeight > 24 * 2 + 4; return ( diff --git a/src/components/Table/TableCell/EditorCellController.tsx b/src/components/Table/TableCell/EditorCellController.tsx new file mode 100644 index 00000000..a8978c8d --- /dev/null +++ b/src/components/Table/TableCell/EditorCellController.tsx @@ -0,0 +1,90 @@ +import { useEffect, useLayoutEffect } from "react"; +import useStateRef from "react-usestateref"; +import { useSetAtom } from "jotai"; +import { isEqual } from "lodash-es"; + +import { tableScope, updateFieldAtom } from "@src/atoms/tableScope"; +import type { + IDisplayCellProps, + IEditorCellProps, +} from "@src/components/fields/types"; + +interface IEditorCellControllerProps extends IDisplayCellProps { + EditorCellComponent: React.ComponentType; + parentRef: IEditorCellProps["parentRef"]; + saveOnUnmount: boolean; +} + +/** + * Stores a local state for the cell’s value, so that `EditorCell` doesn’t + * immediately update the database when the user quickly makes changes to the + * cell’s value (e.g. text input). + * + * Extracted from `withRenderTableCell()` so when the `DisplayCell` is + * rendered, an unnecessary extra state is not created. + * + * - Defines function to update the field in db + * - Tracks when the user has made the input “dirty” + * - By default, saves to db when the component is unmounted and the input + * is dirty + * - Has an effect to change the local value state when it receives an update + * from db and the field is not dirty. This is required to make inline + * `EditorCell` work when they haven’t been interacted with, but prevent the + * value changing while someone is editing a field, like Long Text. + */ +export default function EditorCellController({ + EditorCellComponent, + saveOnUnmount, + value, + ...props +}: IEditorCellControllerProps) { + // Store local value so we don’t immediately write to db when the user + // types in a textbox, for example + const [localValue, setLocalValue, localValueRef] = useStateRef(value); + // Mark if the user has interacted with this cell and hasn’t saved yet + const [isDirty, setIsDirty, isDirtyRef] = useStateRef(false); + const updateField = useSetAtom(updateFieldAtom, tableScope); + + // When this cell’s data has updated, update the local value if + // it’s not dirty and the value is different + useEffect(() => { + if (!isDirty && !isEqual(value, localValueRef.current)) + setLocalValue(value); + }, [isDirty, localValueRef, setLocalValue, value]); + + // This is where we update the documents + const handleSubmit = () => { + // props.disabled should always be false as withRenderTableCell would + // render DisplayCell instead of EditorCell + if (props.disabled || !isDirtyRef.current) return; + + updateField({ + path: props._rowy_ref.path, + fieldName: props.column.fieldName, + value: localValueRef.current, + deleteField: localValueRef.current === undefined, + }); + }; + + useLayoutEffect(() => { + return () => { + if (saveOnUnmount) handleSubmit(); + }; + // Warns that `saveOnUnmount` and `handleSubmit` should be included, but + // those don’t change across re-renders. We only want to run this on unmount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + setIsDirty(dirty ?? true)} + onChange={(v) => { + setIsDirty(true); + setLocalValue(v); + }} + onSubmit={handleSubmit} + /> + ); +} diff --git a/src/components/Table/TableCell/EditorCellTextField.tsx b/src/components/Table/TableCell/EditorCellTextField.tsx new file mode 100644 index 00000000..1f3cb155 --- /dev/null +++ b/src/components/Table/TableCell/EditorCellTextField.tsx @@ -0,0 +1,81 @@ +import type { IEditorCellProps } from "@src/components/fields/types"; +import { InputBase, InputBaseProps } from "@mui/material"; +import { spreadSx } from "@src/utils/ui"; + +export interface IEditorCellTextFieldProps extends IEditorCellProps { + InputProps?: Partial; +} + +export default function EditorCellTextField({ + column, + value, + onDirty, + onChange, + setFocusInsideCell, + InputProps = {}, +}: IEditorCellTextFieldProps) { + const maxLength = column.config?.maxLength; + + return ( + onDirty()} + onChange={(e) => onChange(e.target.value)} + fullWidth + autoFocus + onKeyDown={(e) => { + if ( + e.key === "ArrowLeft" || + e.key === "ArrowRight" || + e.key === "ArrowUp" || + e.key === "ArrowDown" + ) { + e.stopPropagation(); + } + // Escape prevents saving the new value + if (e.key === "Escape") { + // Setting isDirty to false prevents saving + onDirty(false); + // Stop propagation to prevent the table from closing the editor + e.stopPropagation(); + // Close the editor after isDirty is set to false again + setTimeout(() => setFocusInsideCell(false)); + } + if (e.key === "Enter" && !e.shiftKey) { + // Removes focus from inside cell, triggering save on unmount + setFocusInsideCell(false); + } + }} + onClick={(e) => e.stopPropagation()} + onDoubleClick={(e) => e.stopPropagation()} + {...InputProps} + inputProps={{ maxLength, ...InputProps.inputProps }} + sx={[ + { + width: "100%", + height: "calc(100% - 1px)", + marginTop: "1px", + padding: 0, + paddingBottom: "1px", + + backgroundColor: "var(--cell-background-color)", + outline: "inherit", + outlineOffset: "inherit", + + font: "inherit", // Prevent text jumping + letterSpacing: "inherit", // Prevent text jumping + + "& .MuiInputBase-input": { p: "var(--cell-padding)" }, + + "& textarea.MuiInputBase-input": { + lineHeight: (theme) => theme.typography.body2.lineHeight, + maxHeight: "100%", + boxSizing: "border-box", + py: 2 / 8, + }, + }, + ...spreadSx(InputProps.sx), + ]} + /> + ); +} diff --git a/src/components/Table/TableCell/TableCell.tsx b/src/components/Table/TableCell/TableCell.tsx new file mode 100644 index 00000000..4ea51433 --- /dev/null +++ b/src/components/Table/TableCell/TableCell.tsx @@ -0,0 +1,202 @@ +import { memo } from "react"; +import { useSetAtom } from "jotai"; +import { ErrorBoundary } from "react-error-boundary"; +import { flexRender } from "@tanstack/react-table"; +import type { Row, Cell } from "@tanstack/react-table"; + +import ErrorIcon from "@mui/icons-material/ErrorOutline"; +import WarningIcon from "@mui/icons-material/WarningAmber"; + +import StyledCell from "@src/components/Table/Styled/StyledCell"; +import { InlineErrorFallback } from "@src/components/ErrorFallback"; +import RichTooltip from "@src/components/RichTooltip"; +import StyledDot from "@src/components/Table/Styled/StyledDot"; + +import { + tableScope, + selectedCellAtom, + contextMenuTargetAtom, +} from "@src/atoms/tableScope"; +import { TABLE_PADDING } from "@src/components/Table"; +import type { TableRow } from "@src/types/table"; +import type { IRenderedTableCellProps } from "./withRenderTableCell"; + +export interface ITableCellProps { + /** Current row with context from TanStack Table state */ + row: Row; + /** Current cell with context from TanStack Table state */ + cell: Cell; + /** Virtual cell index (column index) */ + index: number; + /** User has clicked or navigated to this cell */ + isSelectedCell: boolean; + /** User has double-clicked or pressed Enter and this cell is selected */ + focusInsideCell: boolean; + /** + * Used to disable `aria-description` that says “Press Enter to edit” + * for Auditing and Metadata cells. Need to find another way to do this. + */ + isReadOnlyCell: boolean; + /** Determines if EditorCell can be displayed */ + canEditCells: boolean; + /** + * Pass current row height as a prop so we don’t access `tableSchema` here. + * If that atom is listened to here, all table cells will re-render whenever + * `tableSchema` changes, which is unnecessary. + */ + rowHeight: number; + /** If true, renders a shadow */ + isLastFrozen: boolean; + /** Pass width as a prop to get local column sizing state */ + width: number; + /** + * If provided, cell is pinned/frozen, and this value is used for + * `position: sticky`. + */ + left: number; + isPinned: boolean; +} + +/** + * Renders the container div for each cell with accessibility attributes for + * keyboard navigation. + * + * - Performs regex & missing value check and renders associated UI + * - Provides children with value from `cell.getValue()` so they can work with + * memoization + * - Provides helpers as props to aid with memoization, so children components + * don’t have to read atoms, which may cause unnecessary re-renders of many + * cell components + * - Renders `ErrorBoundary` + */ +export const TableCell = memo(function TableCell({ + row, + cell, + index, + isSelectedCell, + focusInsideCell, + isReadOnlyCell, + canEditCells, + rowHeight, + isLastFrozen, + width, + left, + isPinned, +}: ITableCellProps) { + const setSelectedCell = useSetAtom(selectedCellAtom, tableScope); + const setContextMenuTarget = useSetAtom(contextMenuTargetAtom, tableScope); + + const value = cell.getValue(); + const required = cell.column.columnDef.meta?.config?.required; + const validationRegex = cell.column.columnDef.meta?.config?.validationRegex; + + const isInvalid = validationRegex && !new RegExp(validationRegex).test(value); + const isMissing = required && value === undefined; + + let renderedValidationTooltip = null; + + if (isInvalid) { + renderedValidationTooltip = ( + } + title="Invalid data" + message="This row will not be saved until all the required fields contain valid data" + placement="right" + render={({ openTooltip }) => } + /> + ); + } else if (isMissing) { + renderedValidationTooltip = ( + } + title="Required field" + message="This row will not be saved until all the required fields contain valid data" + placement="right" + render={({ openTooltip }) => } + /> + ); + } + + const tableCellComponentProps: IRenderedTableCellProps = { + ...cell.getContext(), + value, + focusInsideCell, + setFocusInsideCell: (focusInside: boolean) => + setSelectedCell({ + path: row.original._rowy_ref.path, + columnKey: cell.column.id, + focusInside, + }), + disabled: !canEditCells || cell.column.columnDef.meta?.editable === false, + rowHeight, + }; + + return ( + { + setSelectedCell({ + path: row.original._rowy_ref.path, + columnKey: cell.column.id, + focusInside: false, + }); + (e.target as HTMLDivElement).focus(); + }} + onDoubleClick={(e) => { + setSelectedCell({ + path: row.original._rowy_ref.path, + columnKey: cell.column.id, + focusInside: true, + }); + (e.target as HTMLDivElement).focus(); + }} + onContextMenu={(e) => { + e.preventDefault(); + setSelectedCell({ + path: row.original._rowy_ref.path, + columnKey: cell.column.id, + focusInside: false, + }); + (e.target as HTMLDivElement).focus(); + setContextMenuTarget(e.target as HTMLElement); + }} + > + {renderedValidationTooltip} + + {flexRender(cell.column.columnDef.cell, tableCellComponentProps)} + + + ); +}); + +export default TableCell; diff --git a/src/components/Table/TableCell/index.ts b/src/components/Table/TableCell/index.ts new file mode 100644 index 00000000..f876b4de --- /dev/null +++ b/src/components/Table/TableCell/index.ts @@ -0,0 +1,2 @@ +export { default } from "./TableCell"; +export * from "./TableCell"; diff --git a/src/components/Table/TableCell/withRenderTableCell.tsx b/src/components/Table/TableCell/withRenderTableCell.tsx new file mode 100644 index 00000000..60935e07 --- /dev/null +++ b/src/components/Table/TableCell/withRenderTableCell.tsx @@ -0,0 +1,259 @@ +import { memo, Suspense, useState, useEffect, useRef } from "react"; +import { isEqual } from "lodash-es"; +import type { CellContext } from "@tanstack/react-table"; + +import { Popover, PopoverProps } from "@mui/material"; + +import EditorCellController from "./EditorCellController"; + +import { spreadSx } from "@src/utils/ui"; +import type { TableRow } from "@src/types/table"; +import type { + IDisplayCellProps, + IEditorCellProps, +} from "@src/components/fields/types"; + +export interface ICellOptions { + /** If the rest of the row’s data is used, set this to true for memoization */ + usesRowData?: boolean; + /** Handle padding inside the cell component */ + disablePadding?: boolean; + /** Set popover background to be transparent */ + transparentPopover?: boolean; + /** Props to pass to MUI Popover component */ + popoverProps?: Partial; +} + +/** Received from `TableCell` */ +export interface IRenderedTableCellProps + extends CellContext { + value: TValue; + focusInsideCell: boolean; + setFocusInsideCell: (focusInside: boolean) => void; + disabled: boolean; + rowHeight: number; +} + +/** + * Higher-order component to render each field type’s cell components. + * Handles when to render read-only `DisplayCell` and `EditorCell`. + * + * Memoized to re-render when value, column, focus, or disabled states change. + * Optionally re-renders when entire row updates. + * + * - Renders inline `EditorCell` after a timeout to improve scroll performance + * - Handles popovers + * - Renders Suspense for lazy-loaded `EditorCell` + * - Provides a `tabIndex` prop, so that interactive cell children (like + * buttons) cannot be interacted with unless the user has focused in the + * cell. Required for accessibility. + * + * @param DisplayCellComponent + * - The lighter cell component to display values. Also displayed when the + * column is disabled/read-only. + * + * - Keep these components lightweight, i.e. use base HTML or simple MUI + * components. Avoid `Tooltip`, which is heavy. + * - Avoid displaying disabled states (e.g. do not reduce opacity/grey out + * toggles). This improves the experience of read-only tables for non-admins + * - ⚠️ Make sure the disabled state does not render the buttons to open a + * popover `EditorCell` (like Single/Multi Select). + * - ⚠️ Make sure to use the `tabIndex` prop for buttons and other interactive + * elements. + * - {@link IDisplayCellProps} + * + * @param EditorCellComponent + * - The heavier cell component to edit values + * + * - `EditorCell` should use the `value` and `onChange` props for the + * rendered inputs. Avoid creating another local state here. + * - You can pass `null` to `withRenderTableCell()` to always display the + * `DisplayCell`. + * - ⚠️ If it’s displayed inline, you must call `onSubmit` to save the value + * to the database, because it never unmounts. + * - ✨ You can reuse your `SideDrawerField` as they take the same props. It + * should probably be displayed in a popover. + * - ⚠️ Make sure to use the `tabIndex` prop for buttons, text fields, and + * other interactive elements. + * - {@link IEditorCellProps} + * + * @param editorMode + * - When to display the `EditorCell` + * 1. **focus** (default): the user has focused on the cell by pressing Enter or + * double-clicking, + * 2. **inline**: always displayed if the cell is editable, or + * 3. **popover**: inside a popover when a user has focused on the cell + * (as above) or clicked a button rendered by `DisplayCell` + * + * @param options + * - Note this is OK to pass as an object since it’s not defined in runtime + * - {@link ICellOptions} + */ +export default function withRenderTableCell( + DisplayCellComponent: React.ComponentType, + EditorCellComponent: React.ComponentType | null, + editorMode: "focus" | "inline" | "popover" = "focus", + options: ICellOptions = {} +) { + return memo( + function RenderedTableCell({ + row, + column, + value, + focusInsideCell, + setFocusInsideCell, + disabled, + rowHeight, + }: IRenderedTableCellProps) { + // Render inline editor cell after timeout on mount + // to improve scroll performance + const [inlineEditorReady, setInlineEditorReady] = useState(false); + useEffect(() => { + if (editorMode === "inline") + setTimeout(() => setInlineEditorReady(true)); + }, []); + + // Store ref to rendered DisplayCell to get positioning for PopoverCell + const displayCellRef = useRef(null); + const parentRef = displayCellRef.current?.parentElement; + + // Store Popover open state here so we can add delay for close transition + const [popoverOpen, setPopoverOpen] = useState(false); + useEffect(() => { + if (focusInsideCell) setPopoverOpen(true); + }, [focusInsideCell]); + const showPopoverCell = (popover: boolean) => { + if (popover) { + setPopoverOpen(true); + // Need to call this after a timeout, since the cell’s `onClick` + // event is fired, which sets focusInsideCell false + setTimeout(() => setFocusInsideCell(true)); + } else { + setPopoverOpen(false); + // Call after a timeout to allow the close transition to finish + setTimeout(() => { + setFocusInsideCell(false); + // Focus the cell. Otherwise, it focuses the body. + parentRef?.focus(); + }, 300); + } + }; + + // Declare basicCell here so props can be reused by HeavyCellComponent + const basicCellProps: IDisplayCellProps = { + value, + name: column.columnDef.meta!.name, + type: column.columnDef.meta!.type, + row: row.original, + column: column.columnDef.meta!, + _rowy_ref: row.original._rowy_ref, + disabled, + tabIndex: focusInsideCell ? 0 : -1, + showPopoverCell, + setFocusInsideCell, + rowHeight, + }; + + // Show display cell, unless if editorMode is inline + const displayCell = ( +
+ +
+ ); + if (disabled || (editorMode !== "inline" && !focusInsideCell)) + return displayCell; + + // If the inline editor cell is not ready to be rendered, display nothing + if (editorMode === "inline" && !inlineEditorReady) return null; + + // Show displayCell as a fallback if intentionally null + const editorCell = EditorCellComponent ? ( + + + + ) : ( + displayCell + ); + + if (editorMode === "focus" && focusInsideCell) { + return editorCell; + } + + if (editorMode === "inline") { + return ( +
+ {editorCell} +
+ ); + } + + if (editorMode === "popover") + return ( + <> + {displayCell} + + showPopoverCell(false)} + anchorOrigin={{ horizontal: "center", vertical: "bottom" }} + transformOrigin={{ horizontal: "center", vertical: "top" }} + {...options.popoverProps} + sx={[ + { + "& .MuiPopover-paper": { + backgroundColor: options.transparentPopover + ? "transparent" + : undefined, + boxShadow: options.transparentPopover ? "none" : undefined, + minWidth: column.getSize(), + }, + }, + ...spreadSx(options.popoverProps?.sx), + ]} + onClick={(e) => e.stopPropagation()} + onDoubleClick={(e) => e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + onContextMenu={(e) => e.stopPropagation()} + > + {editorCell} + + + ); + + // Should not reach this line + return null; + }, + // Memo function + (prev, next) => { + const valueEqual = isEqual(prev.value, next.value); + const columnEqual = isEqual( + prev.column.columnDef.meta, + next.column.columnDef.meta + ); + const rowEqual = isEqual(prev.row.original, next.row.original); + const focusInsideCellEqual = + prev.focusInsideCell === next.focusInsideCell; + const disabledEqual = prev.disabled === next.disabled; + + const baseEqualities = + valueEqual && columnEqual && focusInsideCellEqual && disabledEqual; + + if (options?.usesRowData) return baseEqualities && rowEqual; + else return baseEqualities; + } + ); +} diff --git a/src/components/Table/TableContainer.tsx b/src/components/Table/TableContainer.tsx deleted file mode 100644 index eb372f3e..00000000 --- a/src/components/Table/TableContainer.tsx +++ /dev/null @@ -1,252 +0,0 @@ -import { colord } from "colord"; -import { styled, alpha, darken, lighten } from "@mui/material"; -import { TOP_BAR_HEIGHT } from "@src/layouts/Navigation/TopBar"; -import { TABLE_TOOLBAR_HEIGHT } from "@src/components/TableToolbar"; -import { - DRAWER_COLLAPSED_WIDTH, - DRAWER_WIDTH, -} from "@src/components/SideDrawer"; - -export const OUT_OF_ORDER_MARGIN = 8; - -export const TableContainer = styled("div", { - shouldForwardProp: (prop) => prop !== "rowHeight", -})<{ rowHeight: number }>(({ theme, rowHeight }) => ({ - display: "flex", - position: "relative", - flexDirection: "column", - height: `calc(100vh - ${TOP_BAR_HEIGHT}px - ${TABLE_TOOLBAR_HEIGHT}px)`, - - "& .left-scroll-divider": { - position: "absolute", - top: 0, - bottom: 0, - left: 0, - width: 1, - zIndex: 1, - - backgroundColor: colord(theme.palette.background.paper) - .mix(theme.palette.divider, 0.12) - .alpha(1) - .toHslString(), - }, - - "& > .rdg": { - width: `calc(100% - ${DRAWER_COLLAPSED_WIDTH}px)`, - flex: 1, - paddingBottom: `max(env(safe-area-inset-bottom), ${theme.spacing(2)})`, - }, - - [theme.breakpoints.down("sm")]: { width: "100%" }, - - "& .rdg": { - "--color": theme.palette.text.primary, - "--border-color": theme.palette.divider, - // "--summary-border-color": "#aaa", - "--background-color": - theme.palette.mode === "light" - ? theme.palette.background.paper - : colord(theme.palette.background.paper) - .mix("#fff", 0.04) - .alpha(1) - .toHslString(), - "--header-background-color": theme.palette.background.default, - "--row-hover-background-color": colord(theme.palette.background.paper) - .mix(theme.palette.action.hover, theme.palette.action.hoverOpacity) - .alpha(1) - .toHslString(), - "--row-selected-background-color": - theme.palette.mode === "light" - ? lighten(theme.palette.primary.main, 0.9) - : darken(theme.palette.primary.main, 0.8), - "--row-selected-hover-background-color": - theme.palette.mode === "light" - ? lighten(theme.palette.primary.main, 0.8) - : darken(theme.palette.primary.main, 0.7), - "--checkbox-color": theme.palette.primary.main, - "--checkbox-focus-color": theme.palette.primary.main, - "--checkbox-disabled-border-color": "#ccc", - "--checkbox-disabled-background-color": "#ddd", - "--selection-color": theme.palette.primary.main, - "--font-size": "0.75rem", - "--cell-padding": theme.spacing(0, 1.25), - - border: "none", - backgroundColor: "transparent", - - ...(theme.typography.caption as any), - // fontSize: "0.8125rem", - lineHeight: "inherit !important", - - "& .rdg-cell": { - display: "flex", - alignItems: "center", - padding: 0, - - overflow: "visible", - contain: "none", - position: "relative", - - lineHeight: "calc(var(--row-height) - 1px)", - }, - - "& .rdg-cell-frozen": { - position: "sticky", - }, - "& .rdg-cell-frozen-last": { - boxShadow: theme.shadows[2] - .replace(/, 0 (\d+px)/g, ", $1 0") - .split("),") - .slice(1) - .join("),"), - - "&[aria-selected=true]": { - boxShadow: - theme.shadows[2] - .replace(/, 0 (\d+px)/g, ", $1 0") - .split("),") - .slice(1) - .join("),") + ", inset 0 0 0 2px var(--selection-color)", - }, - }, - - "& .rdg-cell-copied": { - backgroundColor: - theme.palette.mode === "light" - ? lighten(theme.palette.primary.main, 0.7) - : darken(theme.palette.primary.main, 0.6), - }, - - "& .final-column-cell": { - backgroundColor: "var(--header-background-color)", - borderColor: "var(--header-background-color)", - color: theme.palette.text.disabled, - padding: "var(--cell-padding)", - }, - }, - - ".rdg-row, .rdg-header-row": { - marginLeft: `max(env(safe-area-inset-left), ${theme.spacing(2)})`, - marginRight: `max(env(safe-area-inset-right), ${DRAWER_WIDTH}px)`, - display: "inline-grid", // Fix Safari not showing margin-right - }, - - ".rdg-header-row .rdg-cell:first-of-type": { - borderTopLeftRadius: theme.shape.borderRadius, - }, - ".rdg-header-row .rdg-cell:last-of-type": { - borderTopRightRadius: theme.shape.borderRadius, - }, - - ".rdg-header-row .rdg-cell.final-column-header": { - border: "none", - padding: theme.spacing(0, 0.75), - borderBottomRightRadius: theme.shape.borderRadius, - - display: "flex", - alignItems: "center", - justifyContent: "flex-start", - - position: "relative", - "&::before": { - content: "''", - display: "block", - width: 88, - height: "100%", - - position: "absolute", - top: 0, - left: 0, - - border: "1px solid var(--border-color)", - borderLeftWidth: 0, - borderTopRightRadius: theme.shape.borderRadius, - borderBottomRightRadius: theme.shape.borderRadius, - }, - }, - - ".rdg-row .rdg-cell:first-of-type, .rdg-header-row .rdg-cell:first-of-type": { - borderLeft: "1px solid var(--border-color)", - }, - - ".rdg-row:last-of-type": { - borderBottomLeftRadius: theme.shape.borderRadius, - borderBottomRightRadius: theme.shape.borderRadius, - - "& .rdg-cell:first-of-type": { - borderBottomLeftRadius: theme.shape.borderRadius, - }, - "& .rdg-cell:nth-last-of-type(2)": { - borderBottomRightRadius: theme.shape.borderRadius, - }, - }, - - ".rdg-header-row .rdg-cell": { - borderTop: "1px solid var(--border-color)", - }, - - ".rdg-row:hover": { color: theme.palette.text.primary }, - - ".row-hover-iconButton": { - color: theme.palette.text.disabled, - transitionDuration: "0s", - }, - ".rdg-row:hover .row-hover-iconButton": { - color: theme.palette.text.primary, - backgroundColor: alpha( - theme.palette.action.hover, - theme.palette.action.hoverOpacity * 1.5 - ), - }, - - ".cell-collapse-padding": { - margin: theme.spacing(0, -1.25), - width: `calc(100% + ${theme.spacing(1.25 * 2)})`, - }, - - ".rdg-row.out-of-order": { - "--row-height": rowHeight + 1 + "px !important", - marginTop: -1, - marginBottom: OUT_OF_ORDER_MARGIN, - borderBottomLeftRadius: theme.shape.borderRadius, - - "& .rdg-cell:not(:last-of-type)": { - borderTop: `1px solid var(--border-color)`, - }, - "& .rdg-cell:first-of-type": { - borderBottomLeftRadius: theme.shape.borderRadius, - }, - "& .rdg-cell:nth-last-of-type(2)": { - borderBottomRightRadius: theme.shape.borderRadius, - }, - "&:not(:nth-of-type(4))": { - borderTopLeftRadius: theme.shape.borderRadius, - - "& .rdg-cell:first-of-type": { - borderTopLeftRadius: theme.shape.borderRadius, - }, - "& .rdg-cell:nth-last-of-type(2)": { - borderTopRightRadius: theme.shape.borderRadius, - }, - }, - - "& + .rdg-row:not(.out-of-order)": { - "--row-height": rowHeight + 1 + "px !important", - marginTop: -1, - borderTopLeftRadius: theme.shape.borderRadius, - - "& .rdg-cell:not(:last-of-type)": { - borderTop: `1px solid var(--border-color)`, - }, - "& .rdg-cell:first-of-type": { - borderTopLeftRadius: theme.shape.borderRadius, - }, - "& .rdg-cell:nth-last-of-type(2)": { - borderTopRightRadius: theme.shape.borderRadius, - }, - }, - }, -})); -TableContainer.displayName = "TableContainer"; - -export default TableContainer; diff --git a/src/components/Table/TableHeader.tsx b/src/components/Table/TableHeader.tsx new file mode 100644 index 00000000..ab813275 --- /dev/null +++ b/src/components/Table/TableHeader.tsx @@ -0,0 +1,130 @@ +import { memo, Fragment } from "react"; +import { useAtom } from "jotai"; +import { DragDropContext, Droppable, Draggable } from "react-beautiful-dnd"; +import type { DropResult } from "react-beautiful-dnd"; +import type { ColumnSizingState, HeaderGroup } from "@tanstack/react-table"; +import type { TableRow } from "@src/types/table"; + +import StyledRow from "./Styled/StyledRow"; +import ColumnHeader from "./ColumnHeader"; +import FinalColumnHeader from "./FinalColumn/FinalColumnHeader"; + +import { tableScope, selectedCellAtom } from "@src/atoms/tableScope"; +import { DEFAULT_ROW_HEIGHT } from "@src/components/Table"; + +export interface ITableHeaderProps { + /** Headers with context from TanStack Table state */ + headerGroups: HeaderGroup[]; + /** Called when a header is dropped in a new position */ + handleDropColumn: (result: DropResult) => void; + /** Passed to `FinalColumnHeader` */ + canAddColumns: boolean; + /** Determines if columns can be re-ordered */ + canEditColumns: boolean; + /** If specified, renders a shadow in the last frozen column */ + lastFrozen?: string; + /** + * Must pass this prop so that it re-renders when local column sizing changes */ + columnSizing: ColumnSizingState; +} + +/** + * Renders table header row. Memoized to only re-render when column definitions + * and sizes change. + * + * - Renders drag & drop components + */ +export const TableHeader = memo(function TableHeader({ + headerGroups, + handleDropColumn, + canAddColumns, + canEditColumns, + lastFrozen, +}: ITableHeaderProps) { + const [selectedCell] = useAtom(selectedCellAtom, tableScope); + const focusInside = selectedCell?.focusInside ?? false; + + return ( + + {headerGroups.map((headerGroup) => ( + + {(provided) => ( + + {headerGroup.headers.map((header, i) => { + const isSelectedCell = + (!selectedCell && header.index === 0) || + (selectedCell?.path === "_rowy_header" && + selectedCell?.columnKey === header.id); + + const isLastHeader = i === headerGroup.headers.length - 1; + + // Render later, after the drag & drop placeholder + if (header.id === "_rowy_column_actions") + return ( + + {provided.placeholder} + + + ); + + if (!header.column.columnDef.meta) return null; + + const draggableHeader = ( + + {(provided, snapshot) => ( + + )} + + ); + + if (isLastHeader) + return ( + + {draggableHeader} + {provided.placeholder} + + ); + else return draggableHeader; + })} + + )} + + ))} + + ); +}); + +export default TableHeader; diff --git a/src/components/Table/TableRow.tsx b/src/components/Table/TableRow.tsx deleted file mode 100644 index ca19ee77..00000000 --- a/src/components/Table/TableRow.tsx +++ /dev/null @@ -1,32 +0,0 @@ -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 setContextMenuTarget = useSetAtom(contextMenuTargetAtom, tableScope); - const handleContextMenu = (e: React.MouseEvent) => { - e.preventDefault(); - setContextMenuTarget(e?.target as HTMLElement); - }; - - if (props.row._rowy_outOfOrder) - return ( - - - - - ); - - return ( - - ); -} diff --git a/src/components/Table/TableSkeleton.tsx b/src/components/Table/TableSkeleton.tsx index e264fde7..6bfaa051 100644 --- a/src/components/Table/TableSkeleton.tsx +++ b/src/components/Table/TableSkeleton.tsx @@ -4,7 +4,7 @@ import { colord } from "colord"; import { Fade, Stack, Skeleton, Button } from "@mui/material"; import { AddColumn as AddColumnIcon } from "@src/assets/icons"; -import Column from "./Column"; +import Column from "./Mock/Column"; import { projectScope, userSettingsAtom } from "@src/atoms/projectScope"; import { @@ -13,7 +13,7 @@ import { tableSchemaAtom, tableColumnsOrderedAtom, } from "@src/atoms/tableScope"; -import { DEFAULT_ROW_HEIGHT, DEFAULT_COL_WIDTH } from "./Table"; +import { DEFAULT_ROW_HEIGHT, DEFAULT_COL_WIDTH, TABLE_PADDING } from "./Table"; import { COLLECTION_PAGE_SIZE } from "@src/config/db"; import { formatSubTableName } from "@src/utils/table"; @@ -129,17 +129,7 @@ export function RowsSkeleton() { div:first-of-type": { - borderBottomLeftRadius: (theme) => theme.shape.borderRadius, - }, - "&:last-of-type > div:last-of-type": { - borderBottomRightRadius: (theme) => theme.shape.borderRadius, - }, - }} + style={{ padding: `0 ${TABLE_PADDING}px`, marginTop: -1 }} > {columns.map((col, j) => ( > { - getInputNode = () => null; - getValue = () => null; - render = () => ( - - ); -} diff --git a/src/components/Table/editors/TextEditor.tsx b/src/components/Table/editors/TextEditor.tsx deleted file mode 100644 index 2652c330..00000000 --- a/src/components/Table/editors/TextEditor.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { useRef, useLayoutEffect } from "react"; -import { EditorProps } from "react-data-grid"; -import { useSetAtom } from "jotai"; -import { get } from "lodash-es"; - -import { TextField } from "@mui/material"; - -import { tableScope, updateFieldAtom } from "@src/atoms/tableScope"; -import { FieldType } from "@src/constants/fields"; -import { getFieldType } from "@src/components/fields"; - -/** WARNING: THIS DOES NOT WORK IN REACT 18 STRICT MODE */ -export default function TextEditor({ row, column }: EditorProps) { - const updateField = useSetAtom(updateFieldAtom, tableScope); - - const type = getFieldType(column as any); - - const cellValue = get(row, column.key); - const defaultValue = - type === FieldType.percentage && typeof cellValue === "number" - ? cellValue * 100 - : cellValue; - - const inputRef = useRef(null); - - // WARNING: THIS DOES NOT WORK IN REACT 18 STRICT MODE - useLayoutEffect(() => { - const inputElement = inputRef.current; - return () => { - const newValue = inputElement?.value; - let formattedValue: any = newValue; - if (newValue !== undefined) { - if (type === FieldType.number) { - formattedValue = Number(newValue); - } else if (type === FieldType.percentage) { - formattedValue = Number(newValue) / 100; - } - - updateField({ - path: row._rowy_ref.path, - fieldName: column.key, - value: formattedValue, - }); - } - }; - }, [column.key, row._rowy_ref.path, type, updateField]); - - let inputType = "text"; - switch (type) { - case FieldType.email: - inputType = "email"; - break; - case FieldType.phone: - inputType = "tel"; - break; - case FieldType.url: - inputType = "url"; - break; - case FieldType.number: - case FieldType.percentage: - inputType = "number"; - break; - - default: - break; - } - - const { maxLength } = (column as any).config; - - return ( - theme.typography.body2.lineHeight, - maxHeight: "100%", - boxSizing: "border-box", - py: 3 / 8, - }, - }} - InputProps={{ - endAdornment: - (column as any).type === FieldType.percentage ? "%" : undefined, - }} - autoFocus - onKeyDown={(e) => { - if (e.key === "ArrowLeft" || e.key === "ArrowRight") { - e.stopPropagation(); - } - - if (e.key === "Escape") { - (e.target as any).value = defaultValue; - } - }} - /> - ); -} diff --git a/src/components/Table/editors/styles.ts b/src/components/Table/editors/styles.ts deleted file mode 100644 index 0eb31315..00000000 --- a/src/components/Table/editors/styles.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createStyles } from "@mui/material"; - -export const styles = createStyles({ - "@global": { - ".rdg-editor-container": { display: "none" }, - }, -}); - -export default styles; diff --git a/src/components/Table/editors/withNullEditor.tsx b/src/components/Table/editors/withNullEditor.tsx deleted file mode 100644 index 7d8ed000..00000000 --- a/src/components/Table/editors/withNullEditor.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { get } from "lodash-es"; -import { EditorProps } from "react-data-grid"; -import { IHeavyCellProps } from "@src/components/fields/types"; - -/** - * Allow the cell to be editable, but disable react-data-grid’s default - * text editor to show. - * - * Hides the editor container so the cell below remains editable inline. - * - * Use for cells that have inline editing and don’t need to be double-clicked. - */ -export default function withNullEditor( - HeavyCell?: React.ComponentType -) { - return function NullEditor(props: EditorProps) { - const { row, column } = props; - - return HeavyCell ? ( -
- {}} - disabled={props.column.editable === false} - /> -
- ) : null; - }; -} diff --git a/src/components/Table/editors/withSideDrawerEditor.tsx b/src/components/Table/editors/withSideDrawerEditor.tsx deleted file mode 100644 index e7522f7e..00000000 --- a/src/components/Table/editors/withSideDrawerEditor.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { useEffect } from "react"; -import { useSetAtom } from "jotai"; -import { EditorProps } from "react-data-grid"; -import { get } from "lodash-es"; - -import { tableScope, sideDrawerOpenAtom } from "@src/atoms/tableScope"; -import { IHeavyCellProps } from "@src/components/fields/types"; - -/** - * Allow the cell to be editable, but disable react-data-grid’s default - * text editor to show. Opens the side drawer in the appropriate position. - * - * Displays the current HeavyCell or HeavyCell since it overwrites cell contents. - * - * Use for cells that do not support any type of in-cell editing. - */ -export default function withSideDrawerEditor( - HeavyCell?: React.ComponentType -) { - return function SideDrawerEditor(props: EditorProps) { - const { row, column } = props; - - const setSideDrawerOpen = useSetAtom(sideDrawerOpenAtom, tableScope); - useEffect(() => { - setSideDrawerOpen(true); - }, [setSideDrawerOpen]); - - return HeavyCell ? ( -
- {}} - disabled={props.column.editable === false} - /> -
- ) : null; - }; -} diff --git a/src/components/Table/useKeyboardNavigation.tsx b/src/components/Table/useKeyboardNavigation.tsx new file mode 100644 index 00000000..3353d23f --- /dev/null +++ b/src/components/Table/useKeyboardNavigation.tsx @@ -0,0 +1,154 @@ +import { useCallback } from "react"; +import { useSetAtom } from "jotai"; +import { Column } from "@tanstack/react-table"; + +import { tableScope, selectedCellAtom } from "@src/atoms/tableScope"; +import { TableRow } from "@src/types/table"; +import { COLLECTION_PAGE_SIZE } from "@src/config/db"; + +export interface IUseKeyboardNavigationProps { + gridRef: React.RefObject; + tableRows: TableRow[]; + leafColumns: Column[]; +} + +/** + * Implementation of accessibility standards for data grids + * - https://www.w3.org/WAI/ARIA/apg/patterns/grid/ + * - https://www.w3.org/WAI/ARIA/apg/example-index/grid/dataGrids + */ +export function useKeyboardNavigation({ + gridRef, + tableRows, + leafColumns, +}: IUseKeyboardNavigationProps) { + const setSelectedCell = useSetAtom(selectedCellAtom, tableScope); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + // Block default browser behavior for arrow keys (scroll) and other keys + const LISTENED_KEYS = [ + "ArrowUp", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + "Enter", + "Escape", + "Home", + "End", + "PageUp", + "PageDown", + ]; + if (LISTENED_KEYS.includes(e.key)) e.preventDefault(); + + // Esc: exit cell + if (e.key === "Escape") { + setSelectedCell((c) => ({ ...c!, focusInside: false })); + ( + gridRef.current?.querySelector( + "[aria-selected=true]" + ) as HTMLDivElement + )?.focus(); + return; + } + + // If event target is not a cell, ignore + const target = e.target as HTMLDivElement; + if ( + target.getAttribute("role") !== "columnheader" && + target.getAttribute("role") !== "gridcell" + ) + return; + + // If Tab, ignore so we can exit the table + if (e.key === "Tab") return; + + // Enter: enter cell + if (e.key === "Enter") { + setSelectedCell((c) => ({ ...c!, focusInside: true })); + (target.querySelector("[tabindex]") as HTMLElement)?.focus(); + return; + } + + const colIndex = Number(target.getAttribute("aria-colindex")) - 1; + const rowIndex = + Number(target.parentElement!.getAttribute("aria-rowindex")) - 2; + + let newColIndex = colIndex; + let newRowIndex = rowIndex; + + switch (e.key) { + case "ArrowUp": + if (e.ctrlKey || e.metaKey) newRowIndex = -1; + else if (rowIndex > -1) newRowIndex = rowIndex - 1; + break; + + case "ArrowDown": + if (e.ctrlKey || e.metaKey) newRowIndex = tableRows.length - 1; + else if (rowIndex < tableRows.length - 1) newRowIndex = rowIndex + 1; + break; + + case "ArrowLeft": + if (e.ctrlKey || e.metaKey) newColIndex = 0; + else if (colIndex > 0) newColIndex = colIndex - 1; + break; + + case "ArrowRight": + if (e.ctrlKey || e.metaKey) newColIndex = leafColumns.length - 1; + else if (colIndex < leafColumns.length - 1) + newColIndex = colIndex + 1; + break; + + case "PageUp": + newRowIndex = Math.max(0, rowIndex - COLLECTION_PAGE_SIZE); + break; + + case "PageDown": + newRowIndex = Math.min( + tableRows.length - 1, + rowIndex + COLLECTION_PAGE_SIZE + ); + break; + + case "Home": + newColIndex = 0; + if (e.ctrlKey || e.metaKey) newRowIndex = -1; + break; + + case "End": + newColIndex = leafColumns.length - 1; + if (e.ctrlKey || e.metaKey) newRowIndex = tableRows.length - 1; + break; + } + + // Get `path` and `columnKey` from `tableRows` and `leafColumns` respectively + const newSelectedCell = { + path: + newRowIndex > -1 + ? tableRows[newRowIndex]._rowy_ref.path + : "_rowy_header", + columnKey: leafColumns[newColIndex].id! || leafColumns[0].id!, + // When selected cell changes, exit current cell + focusInside: false, + }; + + // Store in selectedCellAtom + setSelectedCell(newSelectedCell); + + // Find matching DOM element for the cell + const newCellEl = gridRef.current?.querySelector( + `[aria-rowindex="${newRowIndex + 2}"] [aria-colindex="${ + newColIndex + 1 + }"]` + ); + + // Focus the cell + if (newCellEl) setTimeout(() => (newCellEl as HTMLDivElement).focus()); + }, + [gridRef, leafColumns, setSelectedCell, tableRows] + ); + + return { handleKeyDown } as const; +} + +export default useKeyboardNavigation; diff --git a/src/components/Table/useSaveColumnSizing.tsx b/src/components/Table/useSaveColumnSizing.tsx new file mode 100644 index 00000000..af1a6e37 --- /dev/null +++ b/src/components/Table/useSaveColumnSizing.tsx @@ -0,0 +1,105 @@ +import { useEffect, useState } from "react"; +import { useSetAtom } from "jotai"; +import { useSnackbar } from "notistack"; +import { useDebounce } from "use-debounce"; +import { isEqual, isEmpty } from "lodash-es"; + +import LoadingButton from "@mui/lab/LoadingButton"; +import CheckIcon from "@mui/icons-material/Check"; +import CircularProgressOptical from "@src/components/CircularProgressOptical"; + +import { + tableScope, + updateColumnAtom, + IUpdateColumnOptions, +} from "@src/atoms/tableScope"; +import { DEBOUNCE_DELAY } from "./Table"; +import { ColumnSizingState } from "@tanstack/react-table"; + +/** + * Debounces `columnSizing` and asks user if they want to save for all users, + * if they have the `canEditColumns` permission + */ +export function useSaveColumnSizing( + columnSizing: ColumnSizingState, + canEditColumns: boolean +) { + const { enqueueSnackbar, closeSnackbar } = useSnackbar(); + const updateColumn = useSetAtom(updateColumnAtom, tableScope); + + // Debounce for saving to schema + const [debouncedColumnSizing] = useDebounce(columnSizing, DEBOUNCE_DELAY, { + equalityFn: isEqual, + }); + // Offer to save when column sizing changes + useEffect(() => { + if (!canEditColumns || isEmpty(debouncedColumnSizing)) return; + + const snackbarId = enqueueSnackbar("Save column sizes for all users?", { + action: ( + + ), + anchorOrigin: { horizontal: "center", vertical: "top" }, + }); + + return () => closeSnackbar(snackbarId); + }, [ + debouncedColumnSizing, + canEditColumns, + enqueueSnackbar, + closeSnackbar, + updateColumn, + ]); + + return null; +} + +interface ISaveColumnSizingButtonProps { + debouncedColumnSizing: ColumnSizingState; + updateColumn: (update: IUpdateColumnOptions) => Promise; +} + +/** + * Make the button a component so it can have its own state, + * so we can display the loading state without showing a new snackbar + */ +function SaveColumnSizingButton({ + debouncedColumnSizing, + updateColumn, +}: ISaveColumnSizingButtonProps) { + const [state, setState] = useState<"" | "loading" | "success">(""); + + const handleSaveToSchema = async () => { + setState("loading"); + // Do this one by one for now to prevent race conditions. + // Need to support updating multiple columns in updateColumnAtom + // in the future. + for (const [key, value] of Object.entries(debouncedColumnSizing)) { + await updateColumn({ key, config: { width: value } }); + } + setState("success"); + }; + + return ( + + ) : ( + + ) + } + > + Save + + ); +} + +export default useSaveColumnSizing; diff --git a/src/components/Table/useVirtualization.tsx b/src/components/Table/useVirtualization.tsx new file mode 100644 index 00000000..90df0329 --- /dev/null +++ b/src/components/Table/useVirtualization.tsx @@ -0,0 +1,139 @@ +import { useEffect, useCallback } from "react"; +import { useAtom } from "jotai"; +import { useVirtual, defaultRangeExtractor } from "react-virtual"; +import type { Range } from "react-virtual"; + +import { + tableScope, + tableSchemaAtom, + tableRowsAtom, + selectedCellAtom, +} from "@src/atoms/tableScope"; +import { + TABLE_PADDING, + DEFAULT_ROW_HEIGHT, + OUT_OF_ORDER_MARGIN, + DEFAULT_COL_WIDTH, +} from "./Table"; +import { TableRow } from "@src/types/table"; +import { Column } from "@tanstack/react-table"; + +import { MIN_COL_WIDTH } from "./Table"; + +/** + * Virtualizes rows and columns, + * and scrolls to selected cell + */ +export function useVirtualization( + containerRef: React.RefObject, + leafColumns: Column[] +) { + const [tableSchema] = useAtom(tableSchemaAtom, tableScope); + const [tableRows] = useAtom(tableRowsAtom, tableScope); + const [selectedCell] = useAtom(selectedCellAtom, tableScope); + + // Virtualize rows + const { + virtualItems: virtualRows, + totalSize: totalHeight, + scrollToIndex: scrollToRowIndex, + } = useVirtual({ + parentRef: containerRef, + size: tableRows.length, + overscan: 5, + paddingEnd: TABLE_PADDING, + estimateSize: useCallback( + (index: number) => + (tableSchema.rowHeight || DEFAULT_ROW_HEIGHT) + + (tableRows[index]._rowy_outOfOrder ? OUT_OF_ORDER_MARGIN : 0), + [tableSchema.rowHeight, tableRows] + ), + }); + + // Virtualize columns + const { + virtualItems: virtualCols, + totalSize: totalWidth, + scrollToIndex: scrollToColIndex, + } = useVirtual({ + parentRef: containerRef, + horizontal: true, + size: leafColumns.length, + overscan: 5, + paddingStart: TABLE_PADDING, + paddingEnd: TABLE_PADDING, + estimateSize: useCallback( + (index: number) => + Math.max( + MIN_COL_WIDTH, + leafColumns[index].columnDef.size || DEFAULT_COL_WIDTH + ), + [leafColumns] + ), + rangeExtractor: useCallback( + (range: Range) => { + const defaultRange = defaultRangeExtractor(range); + const frozenColumns = leafColumns + .filter((c) => c.getIsPinned()) + .map((c) => c.getPinnedIndex()); + + const combinedRange = Array.from( + new Set([...defaultRange, ...frozenColumns]) + ).sort((a, b) => a - b); + + return combinedRange; + }, + [leafColumns] + ), + }); + + // Scroll to selected cell + useEffect(() => { + if (!selectedCell) return; + if (selectedCell.path) { + const rowIndex = tableRows.findIndex( + (row) => row._rowy_ref.path === selectedCell.path + ); + if (rowIndex > -1) scrollToRowIndex(rowIndex); + } + if (selectedCell.columnKey) { + const colIndex = leafColumns.findIndex( + (col) => col.id === selectedCell.columnKey + ); + if (colIndex > -1) scrollToColIndex(colIndex); + } + }, [ + selectedCell, + tableRows, + leafColumns, + scrollToRowIndex, + scrollToColIndex, + ]); + + const paddingTop = virtualRows.length > 0 ? virtualRows?.[0]?.start || 0 : 0; + const paddingBottom = + virtualRows.length > 0 + ? totalHeight - (virtualRows?.[virtualRows.length - 1]?.end || 0) + : 0; + + const paddingLeft = virtualCols.length > 0 ? virtualCols?.[0]?.start || 0 : 0; + const paddingRight = + virtualCols.length > 0 + ? totalWidth - (virtualCols?.[virtualCols.length - 1]?.end || 0) + : 0; + + return { + virtualRows, + totalHeight, + scrollToRowIndex, + virtualCols, + totalWidth, + scrollToColIndex, + paddingTop, + paddingBottom, + paddingLeft, + paddingRight, + }; +} + +export default useVirtualization; diff --git a/src/components/TableInformationDrawer/TableInformationDrawer.tsx b/src/components/TableInformationDrawer/TableInformationDrawer.tsx index f2074049..25b9a5e8 100644 --- a/src/components/TableInformationDrawer/TableInformationDrawer.tsx +++ b/src/components/TableInformationDrawer/TableInformationDrawer.tsx @@ -17,7 +17,7 @@ import CloseIcon from "@mui/icons-material/Close"; import { sideDrawerAtom, tableScope } from "@src/atoms/tableScope"; import { TOP_BAR_HEIGHT } from "@src/layouts/Navigation/TopBar"; -import { TABLE_TOOLBAR_HEIGHT } from "@src/components/TableToolbar/TableToolbar"; +import { TABLE_TOOLBAR_HEIGHT } from "@src/components/TableToolbar"; import ErrorFallback from "@src/components/ErrorFallback"; import Details from "./Details"; diff --git a/src/components/TableModals/ImportAirtableWizard/Step1Columns.tsx b/src/components/TableModals/ImportAirtableWizard/Step1Columns.tsx index ea86e2c4..06235545 100644 --- a/src/components/TableModals/ImportAirtableWizard/Step1Columns.tsx +++ b/src/components/TableModals/ImportAirtableWizard/Step1Columns.tsx @@ -22,7 +22,9 @@ import { TableColumn as TableColumnIcon } from "@src/assets/icons"; import { IStepProps } from "."; import { AirtableConfig } from "@src/components/TableModals/ImportAirtableWizard"; import FadeList from "@src/components/TableModals/ScrollableList"; -import Column, { COLUMN_HEADER_HEIGHT } from "@src/components/Table/Column"; +import Column, { + COLUMN_HEADER_HEIGHT, +} from "@src/components/Table/Mock/Column"; import ColumnSelect from "@src/components/Table/ColumnSelect"; import { diff --git a/src/components/TableModals/ImportAirtableWizard/Step2NewColumns.tsx b/src/components/TableModals/ImportAirtableWizard/Step2NewColumns.tsx index e3b81ece..105d6f58 100644 --- a/src/components/TableModals/ImportAirtableWizard/Step2NewColumns.tsx +++ b/src/components/TableModals/ImportAirtableWizard/Step2NewColumns.tsx @@ -6,8 +6,8 @@ import ChevronRightIcon from "@mui/icons-material/ChevronRight"; import { IStepProps } from "."; import ScrollableList from "@src/components/TableModals/ScrollableList"; -import Column from "@src/components/Table/Column"; -import Cell from "@src/components/Table/Cell"; +import Column from "@src/components/Table/Mock/Column"; +import Cell from "@src/components/Table/Mock/Cell"; import FieldsDropdown from "@src/components/ColumnModals/FieldsDropdown"; import { FieldType } from "@src/constants/fields"; import { SELECTABLE_TYPES } from "@src/components/TableModals/ImportExistingWizard/utils"; diff --git a/src/components/TableModals/ImportAirtableWizard/Step3Preview.tsx b/src/components/TableModals/ImportAirtableWizard/Step3Preview.tsx index ae5c0ee8..fa9f4d4a 100644 --- a/src/components/TableModals/ImportAirtableWizard/Step3Preview.tsx +++ b/src/components/TableModals/ImportAirtableWizard/Step3Preview.tsx @@ -2,8 +2,8 @@ import { useAtom } from "jotai"; import { find } from "lodash-es"; import { styled, Grid } from "@mui/material"; -import Column from "@src/components/Table/Column"; -import Cell from "@src/components/Table/Cell"; +import Column from "@src/components/Table/Mock/Column"; +import Cell from "@src/components/Table/Mock/Cell"; import { IStepProps } from "."; import { tableScope, tableSchemaAtom } from "@src/atoms/tableScope"; diff --git a/src/components/TableModals/ImportCsvWizard/Step1Columns.tsx b/src/components/TableModals/ImportCsvWizard/Step1Columns.tsx index 3189cf3e..684584ec 100644 --- a/src/components/TableModals/ImportCsvWizard/Step1Columns.tsx +++ b/src/components/TableModals/ImportCsvWizard/Step1Columns.tsx @@ -26,7 +26,9 @@ import { TableColumn as TableColumnIcon } from "@src/assets/icons"; import { IStepProps } from "."; import { CsvConfig } from "@src/components/TableModals/ImportCsvWizard"; import FadeList from "@src/components/TableModals/ScrollableList"; -import Column, { COLUMN_HEADER_HEIGHT } from "@src/components/Table/Column"; +import Column, { + COLUMN_HEADER_HEIGHT, +} from "@src/components/Table/Mock/Column"; import ColumnSelect from "@src/components/Table/ColumnSelect"; import { diff --git a/src/components/TableModals/ImportCsvWizard/Step2NewColumns.tsx b/src/components/TableModals/ImportCsvWizard/Step2NewColumns.tsx index b47f6afe..6bd3a264 100644 --- a/src/components/TableModals/ImportCsvWizard/Step2NewColumns.tsx +++ b/src/components/TableModals/ImportCsvWizard/Step2NewColumns.tsx @@ -7,8 +7,8 @@ import ChevronRightIcon from "@mui/icons-material/ChevronRight"; import { IStepProps } from "."; import ScrollableList from "@src/components/TableModals/ScrollableList"; -import Column from "@src/components/Table/Column"; -import Cell from "@src/components/Table/Cell"; +import Column from "@src/components/Table/Mock/Column"; +import Cell from "@src/components/Table/Mock/Cell"; import FieldsDropdown from "@src/components/ColumnModals/FieldsDropdown"; import { FieldType } from "@src/constants/fields"; diff --git a/src/components/TableModals/ImportCsvWizard/Step3Preview.tsx b/src/components/TableModals/ImportCsvWizard/Step3Preview.tsx index 8c1addb3..c17608fc 100644 --- a/src/components/TableModals/ImportCsvWizard/Step3Preview.tsx +++ b/src/components/TableModals/ImportCsvWizard/Step3Preview.tsx @@ -3,8 +3,8 @@ import { find } from "lodash-es"; import { parseJSON } from "date-fns"; import { styled, Grid } from "@mui/material"; -import Column from "@src/components/Table/Column"; -import Cell from "@src/components/Table/Cell"; +import Column from "@src/components/Table/Mock/Column"; +import Cell from "@src/components/Table/Mock/Cell"; import { IStepProps } from "."; import { tableScope, tableSchemaAtom } from "@src/atoms/tableScope"; diff --git a/src/components/TableModals/ImportExistingWizard/Step1Columns.tsx b/src/components/TableModals/ImportExistingWizard/Step1Columns.tsx index 97563c10..29d5903f 100644 --- a/src/components/TableModals/ImportExistingWizard/Step1Columns.tsx +++ b/src/components/TableModals/ImportExistingWizard/Step1Columns.tsx @@ -20,7 +20,7 @@ import DragHandleIcon from "@mui/icons-material/DragHandle"; import { AddColumn as AddColumnIcon } from "@src/assets/icons"; import ScrollableList from "@src/components/TableModals/ScrollableList"; -import Column from "@src/components/Table/Column"; +import Column from "@src/components/Table/Mock/Column"; import EmptyState from "@src/components/EmptyState"; import { tableScope, tableRowsAtom } from "@src/atoms/tableScope"; diff --git a/src/components/TableModals/ImportExistingWizard/Step2Rename.tsx b/src/components/TableModals/ImportExistingWizard/Step2Rename.tsx index 0b13ed11..d4bf3faf 100644 --- a/src/components/TableModals/ImportExistingWizard/Step2Rename.tsx +++ b/src/components/TableModals/ImportExistingWizard/Step2Rename.tsx @@ -14,7 +14,9 @@ import DoneIcon from "@mui/icons-material/Done"; import { IStepProps } from "."; import ScrollableList from "@src/components/TableModals/ScrollableList"; -import Column, { COLUMN_HEADER_HEIGHT } from "@src/components/Table/Column"; +import Column, { + COLUMN_HEADER_HEIGHT, +} from "@src/components/Table/Mock/Column"; export default function Step2Rename({ config, diff --git a/src/components/TableModals/ImportExistingWizard/Step3Types.tsx b/src/components/TableModals/ImportExistingWizard/Step3Types.tsx index 2cebf092..f4c9be37 100644 --- a/src/components/TableModals/ImportExistingWizard/Step3Types.tsx +++ b/src/components/TableModals/ImportExistingWizard/Step3Types.tsx @@ -6,8 +6,8 @@ import ChevronRightIcon from "@mui/icons-material/ChevronRight"; import { IStepProps } from "."; import ScrollableList from "@src/components/TableModals/ScrollableList"; -import Column from "@src/components/Table/Column"; -import Cell from "@src/components/Table/Cell"; +import Column from "@src/components/Table/Mock/Column"; +import Cell from "@src/components/Table/Mock/Cell"; import FieldsDropdown from "@src/components/ColumnModals/FieldsDropdown"; import { tableScope, tableRowsAtom } from "@src/atoms/tableScope"; diff --git a/src/components/TableModals/ImportExistingWizard/Step4Preview.tsx b/src/components/TableModals/ImportExistingWizard/Step4Preview.tsx index 3852f8a5..8fb9be9e 100644 --- a/src/components/TableModals/ImportExistingWizard/Step4Preview.tsx +++ b/src/components/TableModals/ImportExistingWizard/Step4Preview.tsx @@ -2,8 +2,8 @@ import { useAtom } from "jotai"; import { IStepProps } from "."; import { styled, Grid } from "@mui/material"; -import Column from "@src/components/Table/Column"; -import Cell from "@src/components/Table/Cell"; +import Column from "@src/components/Table/Mock/Column"; +import Cell from "@src/components/Table/Mock/Cell"; import { tableScope, tableRowsAtom } from "@src/atoms/tableScope"; diff --git a/src/components/TableToolbar/RowHeight.tsx b/src/components/TableToolbar/RowHeight.tsx index 01056284..88f56944 100644 --- a/src/components/TableToolbar/RowHeight.tsx +++ b/src/components/TableToolbar/RowHeight.tsx @@ -12,7 +12,7 @@ import { } from "@src/atoms/tableScope"; import { DEFAULT_ROW_HEIGHT } from "@src/components/Table"; -const ROW_HEIGHTS = [33, 41, 65, 97, 129, 161]; +const ROW_HEIGHTS = [32, 40, 64, 96, 128, 160].map((x) => x + 1); export default function RowHeight() { const theme = useTheme(); diff --git a/src/components/fields/Action/ActionFab.tsx b/src/components/fields/Action/ActionFab.tsx index 4f8c31cc..6f87fb15 100644 --- a/src/components/fields/Action/ActionFab.tsx +++ b/src/components/fields/Action/ActionFab.tsx @@ -51,7 +51,6 @@ const getStateIcon = (actionState: "undo" | "redo" | string, config: any) => { export interface IActionFabProps extends Partial { row: any; column: any; - onSubmit: (value: any) => void; value: any; disabled: boolean; } @@ -59,7 +58,6 @@ export interface IActionFabProps extends Partial { export default function ActionFab({ row, column, - onSubmit, value, disabled, ...props diff --git a/src/components/fields/Action/BasicCell.tsx b/src/components/fields/Action/BasicCell.tsx deleted file mode 100644 index 76630d8f..00000000 --- a/src/components/fields/Action/BasicCell.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { IBasicCellProps } from "@src/components/fields/types"; - -export default function Action({ name, value }: IBasicCellProps) { - return <>{value ? value.status : name}; -} diff --git a/src/components/fields/Action/DisplayCell.tsx b/src/components/fields/Action/DisplayCell.tsx new file mode 100644 index 00000000..264f55be --- /dev/null +++ b/src/components/fields/Action/DisplayCell.tsx @@ -0,0 +1,29 @@ +import { IDisplayCellProps } from "@src/components/fields/types"; +import { get } from "lodash-es"; +import { sanitiseCallableName, isUrl } from "./utils"; + +export const getActionName = (column: IDisplayCellProps["column"]) => { + const config = get(column, "config"); + if (!get(config, "customName.enabled")) { + return get(column, "name"); + } + return get(config, "customName.actionName") || get(column, "name"); +}; + +export default function Action({ value, column }: IDisplayCellProps) { + const hasRan = value && ![null, undefined].includes(value.status); + + return ( +
+ {hasRan && isUrl(value.status) ? ( + + {value.status} + + ) : hasRan ? ( + value.status + ) : ( + sanitiseCallableName(getActionName(column)) + )} +
+ ); +} diff --git a/src/components/fields/Action/TableCell.tsx b/src/components/fields/Action/EditorCell.tsx similarity index 63% rename from src/components/fields/Action/TableCell.tsx rename to src/components/fields/Action/EditorCell.tsx index 4c09fd61..35a04cb6 100644 --- a/src/components/fields/Action/TableCell.tsx +++ b/src/components/fields/Action/EditorCell.tsx @@ -1,33 +1,25 @@ -import { IHeavyCellProps } from "@src/components/fields/types"; +import { IEditorCellProps } from "@src/components/fields/types"; import { Stack } from "@mui/material"; import ActionFab from "./ActionFab"; import { sanitiseCallableName, isUrl } from "./utils"; -import { get } from "lodash-es"; - - -export const getActionName = (column: any) => { - const config = get(column, "config") - if (!get(config, "customName.enabled")) { return get(column, "name") } - return get(config, "customName.actionName") || get(column, "name"); -}; +import { getActionName } from "./DisplayCell"; export default function Action({ column, row, value, - onSubmit, disabled, -}: IHeavyCellProps) { + tabIndex, +}: IEditorCellProps) { const hasRan = value && ![null, undefined].includes(value.status); return (
{hasRan && isUrl(value.status) ? ( @@ -44,9 +36,9 @@ export default function Action({ ); diff --git a/src/components/fields/Action/Settings.tsx b/src/components/fields/Action/Settings.tsx index 9c2562a1..1933818c 100644 --- a/src/components/fields/Action/Settings.tsx +++ b/src/components/fields/Action/Settings.tsx @@ -303,7 +303,9 @@ const Settings = ({ config, onChange, fieldName }: ISettingsProps) => { aria-label="Action will run" name="isActionScript" value={ - config.isActionScript !== false ? "actionScript" : "cloudFunction" + config.isActionScript !== false + ? "actionScript" + : "cloudFunction" } onChange={(e) => onChange("isActionScript")( @@ -559,45 +561,45 @@ const Settings = ({ config, onChange, fieldName }: ISettingsProps) => { title: "Customization", content: ( <> - - + + onChange("customName.enabled")(e.target.checked) + } + name="customName.enabled" + /> + } + label="Customize label for action" + style={{ marginLeft: -11 }} + /> + {config.customName?.enabled && ( + - onChange("customName.enabled")(e.target.checked) + onChange("customName.actionName")(e.target.value) } - name="customName.enabled" - /> - } - label="Customize label for action" - style={{ marginLeft: -11 }} - /> - {config.customName?.enabled && ( - - onChange("customName.actionName")(e.target.value) - } - label="Action name:" - className="labelHorizontal" - inputProps={{ style: { width: "10ch" } }} - > - )} - - onChange("customIcons.enabled")(e.target.checked) - } - name="customIcons.enabled" - /> - } - label="Customize button icons with emoji" - style={{ marginLeft: -11 }} - /> + label="Action name:" + className="labelHorizontal" + inputProps={{ style: { width: "10ch" } }} + > + )} + + onChange("customIcons.enabled")(e.target.checked) + } + name="customIcons.enabled" + /> + } + label="Customize button icons with emoji" + style={{ marginLeft: -11 }} + /> {config.customIcons?.enabled && ( diff --git a/src/components/fields/Action/SideDrawerField.tsx b/src/components/fields/Action/SideDrawerField.tsx index ed734b70..200fc05f 100644 --- a/src/components/fields/Action/SideDrawerField.tsx +++ b/src/components/fields/Action/SideDrawerField.tsx @@ -10,14 +10,12 @@ import ActionFab from "./ActionFab"; import { tableScope, tableRowsAtom } from "@src/atoms/tableScope"; import { fieldSx, getFieldId } from "@src/components/SideDrawer/utils"; import { sanitiseCallableName, isUrl } from "./utils"; -import { getActionName } from "./TableCell" +import { getActionName } from "./DisplayCell"; export default function Action({ column, _rowy_ref, value, - onChange, - onSubmit, disabled, }: ISideDrawerFieldProps) { const [row] = useAtom( @@ -68,10 +66,6 @@ export default function Action({ { - onChange(value); - onSubmit(); - }} value={value} disabled={disabled} id={getFieldId(column.key)} diff --git a/src/components/fields/Action/index.tsx b/src/components/fields/Action/index.tsx index 6a2b6de6..e2e8c1bd 100644 --- a/src/components/fields/Action/index.tsx +++ b/src/components/fields/Action/index.tsx @@ -1,13 +1,12 @@ import { lazy } from "react"; import { IFieldConfig, FieldType } from "@src/components/fields/types"; -import withHeavyCell from "@src/components/fields/_withTableCell/withHeavyCell"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; import ActionIcon from "@mui/icons-material/TouchAppOutlined"; -import BasicCell from "./BasicCell"; -import NullEditor from "@src/components/Table/editors/NullEditor"; +import DisplayCell from "./DisplayCell"; -const TableCell = lazy( - () => import("./TableCell" /* webpackChunkName: "TableCell-Action" */) +const EditorCell = lazy( + () => import("./EditorCell" /* webpackChunkName: "EditorCell-Action" */) ); const SideDrawerField = lazy( () => @@ -25,8 +24,9 @@ export const config: IFieldConfig = { icon: , description: "Button with pre-defined action script or triggers a Cloud Function. Optionally supports Undo and Redo.", - TableCell: withHeavyCell(BasicCell, TableCell), - TableEditor: NullEditor as any, + TableCell: withRenderTableCell(DisplayCell, EditorCell, "inline", { + disablePadding: true, + }), SideDrawerField, settings: Settings, requireConfiguration: true, diff --git a/src/components/fields/Checkbox/DisplayCell.tsx b/src/components/fields/Checkbox/DisplayCell.tsx new file mode 100644 index 00000000..65af2709 --- /dev/null +++ b/src/components/fields/Checkbox/DisplayCell.tsx @@ -0,0 +1,41 @@ +import { IDisplayCellProps } from "@src/components/fields/types"; + +import { FormControlLabel, Switch } from "@mui/material"; + +export default function Checkbox({ column, value }: IDisplayCellProps) { + return ( + + } + label={column.name as string} + labelPlacement="start" + sx={{ + m: 0, + width: "100%", + alignItems: "center", + + cursor: "default", + + "& .MuiFormControlLabel-label": { + font: "inherit", + letterSpacing: "inherit", + flexGrow: 1, + overflowX: "hidden", + mt: "0 !important", + }, + + "& .MuiSwitch-root": { mr: -0.75 }, + }} + /> + ); +} diff --git a/src/components/fields/Checkbox/TableCell.tsx b/src/components/fields/Checkbox/EditorCell.tsx similarity index 84% rename from src/components/fields/Checkbox/TableCell.tsx rename to src/components/fields/Checkbox/EditorCell.tsx index 5cef0a3b..620a8e62 100644 --- a/src/components/fields/Checkbox/TableCell.tsx +++ b/src/components/fields/Checkbox/EditorCell.tsx @@ -1,4 +1,4 @@ -import { IHeavyCellProps } from "@src/components/fields/types"; +import { IEditorCellProps } from "@src/components/fields/types"; import { useSetAtom } from "jotai"; import { get } from "lodash-es"; @@ -15,9 +15,11 @@ export default function Checkbox({ row, column, value, + onChange, onSubmit, disabled, -}: IHeavyCellProps) { + tabIndex, +}: IEditorCellProps) { const confirm = useSetAtom(confirmDialogAtom, projectScope); const handleChange = () => { @@ -28,10 +30,14 @@ export default function Checkbox({ /\{\{(.*?)\}\}/g, replacer(row) ), - handleConfirm: () => onSubmit(!value), + handleConfirm: () => { + onChange(!value); + onSubmit(); + }, }); } else { - onSubmit(!value); + onChange(!value); + onSubmit(); } }; @@ -43,6 +49,7 @@ export default function Checkbox({ onChange={handleChange} disabled={disabled} color="success" + tabIndex={tabIndex} /> } label={column.name as string} diff --git a/src/components/fields/Checkbox/index.tsx b/src/components/fields/Checkbox/index.tsx index 848a52c0..24039351 100644 --- a/src/components/fields/Checkbox/index.tsx +++ b/src/components/fields/Checkbox/index.tsx @@ -1,13 +1,12 @@ import { lazy } from "react"; import { IFieldConfig, FieldType } from "@src/components/fields/types"; -import withHeavyCell from "@src/components/fields/_withTableCell/withHeavyCell"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; import CheckboxIcon from "@mui/icons-material/ToggleOnOutlined"; -import BasicCell from "@src/components/fields/_BasicCell/BasicCellName"; -import NullEditor from "@src/components/Table/editors/NullEditor"; +import DisplayCell from "./DisplayCell"; -const TableCell = lazy( - () => import("./TableCell" /* webpackChunkName: "TableCell-Checkbox" */) +const EditorCell = lazy( + () => import("./EditorCell" /* webpackChunkName: "EditorCell-Checkbox" */) ); const SideDrawerField = lazy( () => @@ -25,8 +24,9 @@ export const config: IFieldConfig = { initializable: true, icon: , description: "True/false value. Default: false.", - TableCell: withHeavyCell(BasicCell, TableCell), - TableEditor: NullEditor as any, + TableCell: withRenderTableCell(DisplayCell, EditorCell, "inline", { + usesRowData: true, + }), csvImportParser: (value: string) => { if (["YES", "TRUE", "1"].includes(value.toUpperCase())) return true; else if (["NO", "FALSE", "0"].includes(value.toUpperCase())) return false; diff --git a/src/components/fields/Code/BasicCell.tsx b/src/components/fields/Code/DisplayCell.tsx similarity index 58% rename from src/components/fields/Code/BasicCell.tsx rename to src/components/fields/Code/DisplayCell.tsx index 8b776896..dc9b5bd1 100644 --- a/src/components/fields/Code/BasicCell.tsx +++ b/src/components/fields/Code/DisplayCell.tsx @@ -1,23 +1,27 @@ -import { IBasicCellProps } from "@src/components/fields/types"; +import { IDisplayCellProps } from "@src/components/fields/types"; import { useTheme } from "@mui/material"; -export default function Code({ value }: IBasicCellProps) { +export default function Code({ value }: IDisplayCellProps) { const theme = useTheme(); + + if (typeof value !== "string") return null; + return (
- {value} + {value.substring(0, 1000)}
); } diff --git a/src/components/fields/Code/index.tsx b/src/components/fields/Code/index.tsx index 8db3ae97..d3ca01ec 100644 --- a/src/components/fields/Code/index.tsx +++ b/src/components/fields/Code/index.tsx @@ -1,10 +1,9 @@ import { lazy } from "react"; import { IFieldConfig, FieldType } from "@src/components/fields/types"; -import withBasicCell from "@src/components/fields/_withTableCell/withBasicCell"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; import CodeIcon from "@mui/icons-material/Code"; -import BasicCell from "./BasicCell"; -import withSideDrawerEditor from "@src/components/Table/editors/withSideDrawerEditor"; +import DisplayCell from "./DisplayCell"; const Settings = lazy( () => import("./Settings" /* webpackChunkName: "Settings-Code" */) @@ -24,8 +23,12 @@ export const config: IFieldConfig = { initializable: true, icon: , description: "Raw code edited with the Monaco Editor.", - TableCell: withBasicCell(BasicCell), - TableEditor: withSideDrawerEditor(BasicCell), + TableCell: withRenderTableCell(DisplayCell, SideDrawerField, "popover", { + popoverProps: { + anchorOrigin: { vertical: "top", horizontal: "center" }, + PaperProps: { sx: { borderRadius: 1 } }, + }, + }), SideDrawerField, settings: Settings, }; diff --git a/src/components/fields/Color/DisplayCell.tsx b/src/components/fields/Color/DisplayCell.tsx new file mode 100644 index 00000000..acdf325a --- /dev/null +++ b/src/components/fields/Color/DisplayCell.tsx @@ -0,0 +1,60 @@ +import { IDisplayCellProps } from "@src/components/fields/types"; + +import { ButtonBase, Box } from "@mui/material"; +import { ChevronDown } from "@src/assets/icons"; + +export default function Color({ + value, + showPopoverCell, + disabled, + tabIndex, +}: IDisplayCellProps) { + const rendered = ( +
+ {value?.hex && ( + `0 0 0 1px ${theme.palette.divider} inset`, + borderRadius: 0.5, + }} + /> + )} + + {value?.hex} +
+ ); + + if (disabled) return rendered; + + return ( + showPopoverCell(true)} + sx={{ + width: "100%", + height: "100%", + font: "inherit", + color: "inherit", + letterSpacing: "inherit", + justifyContent: "flex-start", + }} + tabIndex={tabIndex} + > + {rendered} + + + ); +} diff --git a/src/components/fields/Color/EditorCell.tsx b/src/components/fields/Color/EditorCell.tsx new file mode 100644 index 00000000..a0ce903b --- /dev/null +++ b/src/components/fields/Color/EditorCell.tsx @@ -0,0 +1,19 @@ +import { IEditorCellProps } from "@src/components/fields/types"; +import { ColorPicker, toColor } from "react-color-palette"; +import "react-color-palette/lib/css/styles.css"; + +import { Box } from "@mui/material"; + +export default function Color({ value, onChange }: IEditorCellProps) { + return ( + + + + ); +} diff --git a/src/components/fields/Color/InlineCell.tsx b/src/components/fields/Color/InlineCell.tsx deleted file mode 100644 index af36b90f..00000000 --- a/src/components/fields/Color/InlineCell.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { forwardRef } from "react"; -import { IPopoverInlineCellProps } from "@src/components/fields/types"; - -import { ButtonBase, Box } from "@mui/material"; - -export const Color = forwardRef(function Color( - { value, showPopoverCell, disabled }: IPopoverInlineCellProps, - ref: React.Ref -) { - return ( - showPopoverCell(true)} - ref={ref} - disabled={disabled} - className="cell-collapse-padding" - sx={{ - font: "inherit", - letterSpacing: "inherit", - p: "var(--cell-padding)", - justifyContent: "flex-start", - height: "100%", - }} - > - `0 0 0 1px ${theme.palette.divider} inset`, - borderRadius: 0.5, - }} - /> - - {value?.hex} - - ); -}); - -export default Color; diff --git a/src/components/fields/Color/PopoverCell.tsx b/src/components/fields/Color/PopoverCell.tsx deleted file mode 100644 index cfdbb219..00000000 --- a/src/components/fields/Color/PopoverCell.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { useEffect, useState } from "react"; -import { IPopoverCellProps } from "@src/components/fields/types"; -import { useDebouncedCallback } from "use-debounce"; -import { ColorPicker, toColor } from "react-color-palette"; -import "react-color-palette/lib/css/styles.css"; - -import { Box } from "@mui/material"; - -export default function Color({ value, onSubmit }: IPopoverCellProps) { - const [localValue, setLocalValue] = useState(value); - const handleChangeComplete = useDebouncedCallback((color) => { - onSubmit(color); - }, 400); - - useEffect(() => { - handleChangeComplete(localValue); - }, [localValue]); - - return ( - - - - ); -} diff --git a/src/components/fields/Color/SideDrawerField.tsx b/src/components/fields/Color/SideDrawerField.tsx index 2090b68c..8f9499e3 100644 --- a/src/components/fields/Color/SideDrawerField.tsx +++ b/src/components/fields/Color/SideDrawerField.tsx @@ -87,7 +87,9 @@ export default function Color({ { if (value && value.hex) { - return value.hex.toString() + return value.hex.toString(); } return ""; -}; \ No newline at end of file +}; diff --git a/src/components/fields/Color/index.tsx b/src/components/fields/Color/index.tsx index d8da5e50..77989604 100644 --- a/src/components/fields/Color/index.tsx +++ b/src/components/fields/Color/index.tsx @@ -1,16 +1,14 @@ import { lazy } from "react"; import { IFieldConfig, FieldType } from "@src/components/fields/types"; -import withPopoverCell from "@src/components/fields/_withTableCell/withPopoverCell"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; import { toColor } from "react-color-palette"; import ColorIcon from "@mui/icons-material/Colorize"; -import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull"; -import InlineCell from "./InlineCell"; -import NullEditor from "@src/components/Table/editors/NullEditor"; +import DisplayCell from "./DisplayCell"; import { filterOperators, valueFormatter } from "./filters"; -const PopoverCell = lazy( - () => import("./PopoverCell" /* webpackChunkName: "PopoverCell-Color" */) +const EditorCell = lazy( + () => import("./EditorCell" /* webpackChunkName: "EditorCell-Color" */) ); const SideDrawerField = lazy( () => @@ -27,15 +25,10 @@ export const config: IFieldConfig = { icon: , description: "Color stored as Hex, RGB, and HSV. Edited with a visual picker.", - TableCell: withPopoverCell(BasicCell, InlineCell, PopoverCell, { - anchorOrigin: { horizontal: "left", vertical: "bottom" }, + TableCell: withRenderTableCell(DisplayCell, EditorCell, "popover", { + disablePadding: true, }), - TableEditor: NullEditor as any, SideDrawerField, - filter: { - operators: filterOperators, - valueFormatter - }, csvImportParser: (value: string) => { try { const obj = JSON.parse(value); diff --git a/src/components/fields/ConnectService/DisplayCell.tsx b/src/components/fields/ConnectService/DisplayCell.tsx new file mode 100644 index 00000000..9e8177bf --- /dev/null +++ b/src/components/fields/ConnectService/DisplayCell.tsx @@ -0,0 +1,51 @@ +import { IDisplayCellProps } from "@src/components/fields/types"; + +import { ButtonBase, Grid, Chip } from "@mui/material"; +import { ChevronDown } from "@src/assets/icons"; + +import ChipList from "@src/components/Table/TableCell/ChipList"; +import { get } from "lodash-es"; + +export default function ConnectService({ + value, + showPopoverCell, + disabled, + column, + tabIndex, + rowHeight, +}: IDisplayCellProps) { + const config = column.config ?? {}; + const displayKey = config.titleKey ?? config.primaryKey; + + const rendered = ( + + {Array.isArray(value) && + value.map((snapshot) => ( + + + + ))} + + ); + + if (disabled) return rendered; + + return ( + showPopoverCell(true)} + style={{ + width: "100%", + height: "100%", + font: "inherit", + color: "inherit !important", + letterSpacing: "inherit", + textAlign: "inherit", + justifyContent: "flex-start", + }} + tabIndex={tabIndex} + > + {rendered} + + + ); +} diff --git a/src/components/fields/ConnectService/PopoverCell.tsx b/src/components/fields/ConnectService/EditorCell.tsx similarity index 81% rename from src/components/fields/ConnectService/PopoverCell.tsx rename to src/components/fields/ConnectService/EditorCell.tsx index cc79c729..6d506f9c 100644 --- a/src/components/fields/ConnectService/PopoverCell.tsx +++ b/src/components/fields/ConnectService/EditorCell.tsx @@ -1,26 +1,26 @@ -import { IPopoverCellProps } from "@src/components/fields/types"; +import { IEditorCellProps } from "@src/components/fields/types"; import ConnectServiceSelect from "./ConnectServiceSelect"; export default function ConnectService({ value, - onSubmit, + onChange, column, parentRef, showPopoverCell, disabled, - docRef, -}: IPopoverCellProps) { + _rowy_ref, +}: IEditorCellProps) { const config = column.config ?? {}; if (!config) return null; return ( -) { - const config = column.config ?? {}; - const displayKey = config.titleKey ?? config.primaryKey; - return ( - showPopoverCell(true)} - ref={ref} - disabled={disabled} - className="cell-collapse-padding" - sx={{ - height: "100%", - font: "inherit", - color: "inherit !important", - letterSpacing: "inherit", - textAlign: "inherit", - justifyContent: "flex-start", - }} - > - - {Array.isArray(value) && - value.map((snapshot) => ( - - - - ))} - - - {!disabled && ( - - )} - - ); -}); - -export default ConnectService; diff --git a/src/components/fields/ConnectService/index.tsx b/src/components/fields/ConnectService/index.tsx index fd34cda9..f58219ca 100644 --- a/src/components/fields/ConnectService/index.tsx +++ b/src/components/fields/ConnectService/index.tsx @@ -1,15 +1,13 @@ import { lazy } from "react"; import { IFieldConfig, FieldType } from "@src/components/fields/types"; -import withPopoverCell from "@src/components/fields/_withTableCell/withPopoverCell"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; import ConnectServiceIcon from "@mui/icons-material/Http"; -import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull"; -import InlineCell from "./InlineCell"; -import NullEditor from "@src/components/Table/editors/NullEditor"; +import DisplayCell from "./DisplayCell"; -const PopoverCell = lazy( +const EditorCell = lazy( () => - import("./PopoverCell" /* webpackChunkName: "PopoverCell-ConnectService" */) + import("./EditorCell" /* webpackChunkName: "EditorCell-ConnectService" */) ); const SideDrawerField = lazy( () => @@ -30,11 +28,10 @@ export const config: IFieldConfig = { icon: , description: "Connects to an external web service to fetch a list of results.", - TableCell: withPopoverCell(BasicCell, InlineCell, PopoverCell, { - anchorOrigin: { horizontal: "left", vertical: "bottom" }, - transparent: true, + TableCell: withRenderTableCell(DisplayCell, EditorCell, "popover", { + disablePadding: true, + transparentPopover: true, }), - TableEditor: NullEditor as any, SideDrawerField, requireConfiguration: true, settings: Settings, diff --git a/src/components/fields/ConnectTable/DisplayCell.tsx b/src/components/fields/ConnectTable/DisplayCell.tsx new file mode 100644 index 00000000..de750aa8 --- /dev/null +++ b/src/components/fields/ConnectTable/DisplayCell.tsx @@ -0,0 +1,62 @@ +import { IDisplayCellProps } from "@src/components/fields/types"; + +import { ButtonBase, Grid, Chip } from "@mui/material"; +import { ChevronDown } from "@src/assets/icons"; + +import ChipList from "@src/components/Table/TableCell/ChipList"; + +export default function ConnectTable({ + value, + showPopoverCell, + disabled, + column, + tabIndex, + rowHeight, +}: IDisplayCellProps) { + const config = column.config ?? {}; + + const rendered = ( + + {Array.isArray(value) ? ( + value.map((item: any) => ( + + item.snapshot[key]) + .join(" ")} + /> + + )) + ) : value ? ( + + value.snapshot[key]) + .join(" ")} + /> + + ) : null} + + ); + + if (disabled) return rendered; + + return ( + showPopoverCell(true)} + style={{ + width: "100%", + height: "100%", + font: "inherit", + color: "inherit !important", + letterSpacing: "inherit", + textAlign: "inherit", + justifyContent: "flex-start", + }} + tabIndex={tabIndex} + > + {rendered} + + + ); +} diff --git a/src/components/fields/ConnectTable/PopoverCell.tsx b/src/components/fields/ConnectTable/EditorCell.tsx similarity index 79% rename from src/components/fields/ConnectTable/PopoverCell.tsx rename to src/components/fields/ConnectTable/EditorCell.tsx index 41192ef0..e4a7af9f 100644 --- a/src/components/fields/ConnectTable/PopoverCell.tsx +++ b/src/components/fields/ConnectTable/EditorCell.tsx @@ -1,16 +1,17 @@ -import { IPopoverCellProps } from "@src/components/fields/types"; +import { IEditorCellProps } from "@src/components/fields/types"; import ConnectTableSelect from "./ConnectTableSelect"; export default function ConnectTable({ value, + onChange, onSubmit, column, parentRef, showPopoverCell, row, disabled, -}: IPopoverCellProps) { +}: IEditorCellProps) { const config = column.config ?? {}; if (!config || !config.primaryKeys) return null; @@ -19,7 +20,7 @@ export default function ConnectTable({ row={row} column={column} value={value} - onChange={onSubmit} + onChange={onChange} config={(config as any) ?? {}} disabled={disabled} TextFieldProps={{ @@ -33,7 +34,10 @@ export default function ConnectTable({ }, }, }} - onClose={() => showPopoverCell(false)} + onClose={() => { + showPopoverCell(false); + onSubmit(); + }} loadBeforeOpen /> ); diff --git a/src/components/fields/ConnectTable/InlineCell.tsx b/src/components/fields/ConnectTable/InlineCell.tsx deleted file mode 100644 index 72d886ad..00000000 --- a/src/components/fields/ConnectTable/InlineCell.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { forwardRef } from "react"; -import { IPopoverInlineCellProps } from "@src/components/fields/types"; - -import { ButtonBase, Grid, Chip } from "@mui/material"; -import { ChevronDown } from "@src/assets/icons"; - -import ChipList from "@src/components/Table/formatters/ChipList"; - -export const ConnectTable = forwardRef(function ConnectTable( - { value, showPopoverCell, disabled, column }: IPopoverInlineCellProps, - ref: React.Ref -) { - const config = column.config ?? {}; - - return ( - showPopoverCell(true)} - ref={ref} - disabled={disabled} - className="cell-collapse-padding" - sx={{ - height: "100%", - font: "inherit", - color: "inherit !important", - letterSpacing: "inherit", - textAlign: "inherit", - justifyContent: "flex-start", - }} - > - - {Array.isArray(value) ? ( - value.map((item: any) => ( - - item.snapshot[key]) - .join(" ")} - /> - - )) - ) : value ? ( - - value.snapshot[key]) - .join(" ")} - /> - - ) : null} - - - {!disabled && ( - - )} - - ); -}); - -export default ConnectTable; diff --git a/src/components/fields/ConnectTable/index.tsx b/src/components/fields/ConnectTable/index.tsx index 30fae9c4..f4a0ca38 100644 --- a/src/components/fields/ConnectTable/index.tsx +++ b/src/components/fields/ConnectTable/index.tsx @@ -1,15 +1,12 @@ import { lazy } from "react"; import { IFieldConfig, FieldType } from "@src/components/fields/types"; -import withPopoverCell from "@src/components/fields/_withTableCell/withPopoverCell"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; import { ConnectTable as ConnectTableIcon } from "@src/assets/icons"; -import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull"; -import InlineCell from "./InlineCell"; -import NullEditor from "@src/components/Table/editors/NullEditor"; +import DisplayCell from "./DisplayCell"; -const PopoverCell = lazy( - () => - import("./PopoverCell" /* webpackChunkName: "PopoverCell-ConnectTable" */) +const EditorCell = lazy( + () => import("./EditorCell" /* webpackChunkName: "EditorCell-ConnectTable" */) ); const SideDrawerField = lazy( () => @@ -31,11 +28,10 @@ export const config: IFieldConfig = { icon: , description: "Connects to an existing table to fetch a snapshot of values from a row. Requires Rowy Run and Algolia setup.", - TableCell: withPopoverCell(BasicCell, InlineCell, PopoverCell, { - anchorOrigin: { horizontal: "left", vertical: "bottom" }, - transparent: true, + TableCell: withRenderTableCell(DisplayCell, EditorCell, "popover", { + disablePadding: true, + transparentPopover: true, }), - TableEditor: NullEditor as any, SideDrawerField, settings: Settings, requireConfiguration: true, diff --git a/src/components/fields/Connector/DisplayCell.tsx b/src/components/fields/Connector/DisplayCell.tsx new file mode 100644 index 00000000..e31e3d18 --- /dev/null +++ b/src/components/fields/Connector/DisplayCell.tsx @@ -0,0 +1,49 @@ +import { IDisplayCellProps } from "@src/components/fields/types"; + +import { ButtonBase, Grid, Chip } from "@mui/material"; +import { ChevronDown } from "@src/assets/icons"; + +import ChipList from "@src/components/Table/TableCell/ChipList"; +import { get } from "lodash-es"; +import { getLabel } from "./utils"; + +export default function Connector({ + value, + showPopoverCell, + disabled, + column, + tabIndex, + rowHeight, +}: IDisplayCellProps) { + const rendered = ( + + {Array.isArray(value) && + value.map((item) => ( + + + + ))} + + ); + + if (disabled) return rendered; + + return ( + showPopoverCell(true)} + sx={{ + width: "100%", + height: "100%", + font: "inherit", + color: "inherit !important", + letterSpacing: "inherit", + textAlign: "inherit", + justifyContent: "flex-start", + }} + tabIndex={tabIndex} + > + {rendered} + + + ); +} diff --git a/src/components/fields/Connector/EditorCell.tsx b/src/components/fields/Connector/EditorCell.tsx new file mode 100644 index 00000000..e665af15 --- /dev/null +++ b/src/components/fields/Connector/EditorCell.tsx @@ -0,0 +1,25 @@ +import { Suspense } from "react"; +import { IEditorCellProps } from "@src/components/fields/types"; + +import PopupContents from "./Select/PopupContents"; +import Loading from "@src/components/Loading"; + +export default function Connector({ + value, + onChange, + column, + disabled, + _rowy_ref, +}: IEditorCellProps) { + return ( + }> + + + ); +} diff --git a/src/components/fields/Connector/InlineCell.tsx b/src/components/fields/Connector/InlineCell.tsx deleted file mode 100644 index bd99142e..00000000 --- a/src/components/fields/Connector/InlineCell.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { forwardRef } from "react"; -import { IPopoverInlineCellProps } from "@src/components/fields/types"; - -import { ButtonBase, Grid, Chip } from "@mui/material"; -import { ChevronDown } from "@src/assets/icons"; - -import ChipList from "@src/components/Table/formatters/ChipList"; -import { get } from "lodash-es"; -import { getLabel } from "./utils"; - -export const Connector = forwardRef(function Connector( - { value, showPopoverCell, disabled, column }: IPopoverInlineCellProps, - ref: React.Ref -) { - const config = column.config ?? {}; - const displayKey = config.titleKey ?? config.primaryKey; - return ( - showPopoverCell(true)} - ref={ref} - disabled={disabled} - className="cell-collapse-padding" - sx={{ - height: "100%", - font: "inherit", - color: "inherit !important", - letterSpacing: "inherit", - textAlign: "inherit", - justifyContent: "flex-start", - }} - > - - {Array.isArray(value) && - value.map((item) => ( - - - - ))} - - - {!disabled && ( - - )} - - ); -}); - -export default Connector; diff --git a/src/components/fields/Connector/PopoverCell.tsx b/src/components/fields/Connector/PopoverCell.tsx deleted file mode 100644 index e52e94ea..00000000 --- a/src/components/fields/Connector/PopoverCell.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { IPopoverCellProps } from "@src/components/fields/types"; - -import Selector from "./Select"; - -export default function ConnectService({ - value, - onSubmit, - column, - parentRef, - showPopoverCell, - disabled, - docRef, -}: IPopoverCellProps) { - return ( - showPopoverCell(false), - }, - }} - /> - ); -} diff --git a/src/components/fields/Connector/Select/PopupContents.tsx b/src/components/fields/Connector/Select/PopupContents.tsx index 29453471..941418c7 100644 --- a/src/components/fields/Connector/Select/PopupContents.tsx +++ b/src/components/fields/Connector/Select/PopupContents.tsx @@ -1,5 +1,4 @@ -import React, { useEffect, useState } from "react"; -import clsx from "clsx"; +import { useEffect, useState } from "react"; import { useDebouncedCallback } from "use-debounce"; import { get } from "lodash-es"; import { useAtom } from "jotai"; @@ -7,7 +6,6 @@ import { useAtom } from "jotai"; import { Button, Checkbox, - Divider, Grid, InputAdornment, List, @@ -21,7 +19,6 @@ import { import SearchIcon from "@mui/icons-material/Search"; import { IConnectorSelectProps } from "."; -import useStyles from "./styles"; import Loading from "@src/components/Loading"; import { getLabel } from "@src/components/fields/Connector/utils"; import { useSnackbar } from "notistack"; @@ -37,7 +34,7 @@ export default function PopupContents({ value = [], onChange, column, - docRef, + _rowy_ref, }: IPopupContentsProps) { const [rowyRun] = useAtom(rowyRunAtom, projectScope); const [tableSettings] = useAtom(tableSettingsAtom, tableScope); @@ -48,8 +45,6 @@ export default function PopupContents({ const elementId = config.elementId; const multiple = Boolean(config.multiple); - const { classes } = useStyles(); - // Webservice search query const [query, setQuery] = useState(""); // Webservice response @@ -75,7 +70,7 @@ export default function PopupContents({ columnKey: column.key, query: query, schemaDocPath: getTableSchemaPath(tableSettings), - rowDocPath: docRef.path, + rowDocPath: _rowy_ref.path, }, }); setResponse(resp); @@ -105,93 +100,88 @@ export default function PopupContents({ const clearSelection = () => onChange([]); return ( - - + + setQuery(e.target.value)} fullWidth variant="filled" - margin="dense" label="Search items" - className={classes.noMargins} + hiddenLabel + placeholder="Search items" InputProps={{ - endAdornment: ( - + startAdornment: ( + ), }} + InputLabelProps={{ className: "visually-hidden" }} onClick={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} /> - - + + {hits.map((hit) => { const isSelected = selectedValues.some((v) => v === hit[elementId]); return ( - - = config.max - } - > - - {multiple ? ( - - ) : ( - - )} - - - - - + = config.max} + disableGutters + style={{ margin: 0, width: "100%" }} + > + + {multiple ? ( + + ) : ( + + )} + + + ); })} {multiple && ( - + - + {value?.length} of {hits?.length} @@ -199,9 +189,9 @@ export default function PopupContents({ disabled={!value || value.length === 0} onClick={clearSelection} color="primary" - className={classes.selectAllButton} + variant="text" > - Clear selection + Clear diff --git a/src/components/fields/Connector/Select/index.tsx b/src/components/fields/Connector/Select/index.tsx index 9e09ba40..2025ec37 100644 --- a/src/components/fields/Connector/Select/index.tsx +++ b/src/components/fields/Connector/Select/index.tsx @@ -24,7 +24,7 @@ export interface IConnectorSelectProps { className?: string; /** Override any props of the root MUI `TextField` component */ TextFieldProps?: Partial; - docRef: TableRowRef; + _rowy_ref: TableRowRef; disabled?: boolean; } @@ -56,7 +56,11 @@ export default function ConnectorSelect({ // prop for this component to a comma-separated string MenuProps: { classes: { paper: classes.paper, list: classes.menuChild }, - MenuListProps: { disablePadding: true }, + MenuListProps: { + disablePadding: true, + style: { padding: 0 }, + component: "div", + } as any, anchorOrigin: { vertical: "bottom", horizontal: "center" }, transformOrigin: { vertical: "top", horizontal: "center" }, ...TextFieldProps.SelectProps?.MenuProps, diff --git a/src/components/fields/Connector/SideDrawerField.tsx b/src/components/fields/Connector/SideDrawerField.tsx index feccb0aa..b97f1491 100644 --- a/src/components/fields/Connector/SideDrawerField.tsx +++ b/src/components/fields/Connector/SideDrawerField.tsx @@ -11,7 +11,6 @@ export default function Connector({ column, _rowy_ref, value, - onDirty, onChange, onSubmit, disabled, @@ -32,7 +31,7 @@ export default function Connector({ column={column} value={value} onChange={onChange} - docRef={_rowy_ref as any} + _rowy_ref={_rowy_ref} TextFieldProps={{ label: "", hiddenLabel: true, @@ -50,7 +49,6 @@ export default function Connector({ {value.map((item) => { const key = get(item, config.elementId); - console.log(key, item); return ( - import("./PopoverCell" /* webpackChunkName: "PopoverCell-ConnectService" */) + import("./EditorCell" /* webpackChunkName: "EditorCell-ConnectService" */) ); const SideDrawerField = lazy( () => @@ -30,11 +29,9 @@ export const config: IFieldConfig = { icon: , description: "Connects to any table or API to fetch a list of results based on a text query or row data.", - TableCell: withPopoverCell(BasicCell, InlineCell, PopoverCell, { - anchorOrigin: { horizontal: "left", vertical: "bottom" }, - transparent: true, + TableCell: withRenderTableCell(DisplayCell, EditorCell, "popover", { + disablePadding: true, }), - TableEditor: NullEditor as any, SideDrawerField, requireConfiguration: true, settings: Settings, diff --git a/src/components/fields/UpdatedAt/TableCell.tsx b/src/components/fields/CreatedAt/DisplayCell.tsx similarity index 70% rename from src/components/fields/UpdatedAt/TableCell.tsx rename to src/components/fields/CreatedAt/DisplayCell.tsx index 52417bce..04b56517 100644 --- a/src/components/fields/UpdatedAt/TableCell.tsx +++ b/src/components/fields/CreatedAt/DisplayCell.tsx @@ -1,9 +1,9 @@ -import { IHeavyCellProps } from "@src/components/fields/types"; +import { IDisplayCellProps } from "@src/components/fields/types"; import { format } from "date-fns"; import { DATE_TIME_FORMAT } from "@src/constants/dates"; -export default function UpdatedAt({ column, value }: IHeavyCellProps) { +export default function CreatedAt({ column, value }: IDisplayCellProps) { if (!value) return null; const dateLabel = format( value.toDate ? value.toDate() : value, diff --git a/src/components/fields/CreatedAt/index.tsx b/src/components/fields/CreatedAt/index.tsx index 7f306509..cffa0ea8 100644 --- a/src/components/fields/CreatedAt/index.tsx +++ b/src/components/fields/CreatedAt/index.tsx @@ -1,14 +1,10 @@ import { lazy } from "react"; import { IFieldConfig, FieldType } from "@src/components/fields/types"; -import withHeavyCell from "@src/components/fields/_withTableCell/withHeavyCell"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; import { CreatedAt as CreatedAtIcon } from "@src/assets/icons"; -import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull"; -import withSideDrawerEditor from "@src/components/Table/editors/withSideDrawerEditor"; +import DisplayCell from "./DisplayCell"; -const TableCell = lazy( - () => import("./TableCell" /* webpackChunkName: "TableCell-CreatedAt" */) -); const SideDrawerField = lazy( () => import( @@ -28,8 +24,7 @@ export const config: IFieldConfig = { initialValue: null, icon: , description: "Displays the timestamp of when the row was created. Read-only.", - TableCell: withHeavyCell(BasicCell, TableCell), - TableEditor: withSideDrawerEditor(TableCell), + TableCell: withRenderTableCell(DisplayCell, null), SideDrawerField, settings: Settings, }; diff --git a/src/components/fields/CreatedBy/TableCell.tsx b/src/components/fields/CreatedBy/DisplayCell.tsx similarity index 85% rename from src/components/fields/CreatedBy/TableCell.tsx rename to src/components/fields/CreatedBy/DisplayCell.tsx index 69cc68e9..17ca0396 100644 --- a/src/components/fields/CreatedBy/TableCell.tsx +++ b/src/components/fields/CreatedBy/DisplayCell.tsx @@ -1,11 +1,11 @@ -import { IHeavyCellProps } from "@src/components/fields/types"; +import { IDisplayCellProps } from "@src/components/fields/types"; import { Tooltip, Stack, Avatar } from "@mui/material"; import { format } from "date-fns"; import { DATE_TIME_FORMAT } from "@src/constants/dates"; -export default function CreatedBy({ column, value }: IHeavyCellProps) { +export default function CreatedBy({ column, value }: IDisplayCellProps) { if (!value || !value.displayName || !value.timestamp) return null; const dateLabel = format( value.timestamp.toDate ? value.timestamp.toDate() : value.timestamp, diff --git a/src/components/fields/CreatedBy/index.tsx b/src/components/fields/CreatedBy/index.tsx index c844ec51..98fe8cb6 100644 --- a/src/components/fields/CreatedBy/index.tsx +++ b/src/components/fields/CreatedBy/index.tsx @@ -1,14 +1,10 @@ import { lazy } from "react"; import { IFieldConfig, FieldType } from "@src/components/fields/types"; -import withHeavyCell from "@src/components/fields/_withTableCell/withHeavyCell"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; import { CreatedBy as CreatedByIcon } from "@src/assets/icons"; -import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull"; -import withSideDrawerEditor from "@src/components/Table/editors/withSideDrawerEditor"; +import DisplayCell from "./DisplayCell"; -const TableCell = lazy( - () => import("./TableCell" /* webpackChunkName: "TableCell-CreatedBy" */) -); const SideDrawerField = lazy( () => import( @@ -29,8 +25,7 @@ export const config: IFieldConfig = { icon: , description: "Displays the user that created the row and timestamp. Read-only.", - TableCell: withHeavyCell(BasicCell, TableCell), - TableEditor: withSideDrawerEditor(TableCell), + TableCell: withRenderTableCell(DisplayCell, null), SideDrawerField, settings: Settings, }; diff --git a/src/components/fields/Date/BasicCell.tsx b/src/components/fields/Date/DisplayCell.tsx similarity index 51% rename from src/components/fields/Date/BasicCell.tsx rename to src/components/fields/Date/DisplayCell.tsx index 11181f9a..719f2801 100644 --- a/src/components/fields/Date/BasicCell.tsx +++ b/src/components/fields/Date/DisplayCell.tsx @@ -1,20 +1,24 @@ -import { IBasicCellProps } from "@src/components/fields/types"; +import { IDisplayCellProps } from "@src/components/fields/types"; import { isFunction, isDate } from "lodash-es"; import { format } from "date-fns"; import { DATE_FORMAT } from "@src/constants/dates"; -export default function Date_({ - value, - format: formatProp, -}: IBasicCellProps & { format?: string }) { +export default function Date_({ value, column }: IDisplayCellProps) { if ((!!value && isFunction(value.toDate)) || isDate(value)) { try { const formatted = format( isDate(value) ? value : value.toDate(), - formatProp || DATE_FORMAT + column.config?.format || DATE_FORMAT ); return ( - {formatted} +
+ {formatted} +
); } catch (e) { return null; diff --git a/src/components/fields/Date/TableCell.tsx b/src/components/fields/Date/EditorCell.tsx similarity index 60% rename from src/components/fields/Date/TableCell.tsx rename to src/components/fields/Date/EditorCell.tsx index 08295168..56de95c1 100644 --- a/src/components/fields/Date/TableCell.tsx +++ b/src/components/fields/Date/EditorCell.tsx @@ -1,5 +1,5 @@ import { useDebouncedCallback } from "use-debounce"; -import { IHeavyCellProps } from "@src/components/fields/types"; +import { IEditorCellProps } from "@src/components/fields/types"; import DatePicker from "@mui/lab/DatePicker"; import { TextField } from "@mui/material"; @@ -7,33 +7,25 @@ import { ChevronDown } from "@src/assets/icons"; import { transformValue, sanitizeValue } from "./utils"; import { DATE_FORMAT } from "@src/constants/dates"; -import BasicCell from "./BasicCell"; export default function Date_({ column, value, disabled, + onChange, onSubmit, -}: IHeavyCellProps) { + tabIndex, +}: IEditorCellProps) { const format = column.config?.format ?? DATE_FORMAT; const transformedValue = transformValue(value); const handleDateChange = useDebouncedCallback((date: Date | null) => { const sanitized = sanitizeValue(date); if (sanitized === undefined) return; - onSubmit(sanitized); + onChange(sanitized); + onSubmit(); }, 500); - if (disabled) - return ( - - ); - return ( ( @@ -43,24 +35,21 @@ export default function Date_({ label="" hiddenLabel aria-label={column.name as string} - className="cell-collapse-padding" sx={{ width: "100%", height: "100%", - "& .MuiInputBase-root": { + "&& .MuiInputBase-root": { height: "100%", font: "inherit", // Prevent text jumping letterSpacing: "inherit", // Prevent text jumping - ".rdg-cell &": { - background: "none !important", - boxShadow: "none", - borderRadius: 0, - padding: 0, + background: "none !important", + boxShadow: "none", + borderRadius: 0, + padding: 0, - "&::after": { width: "100%", left: 0 }, - }, + "&::after": { width: "100%", left: 0 }, }, "& .MuiInputBase-input": { height: "100%", @@ -68,19 +57,14 @@ export default function Date_({ letterSpacing: "inherit", // Prevent text jumping fontVariantNumeric: "tabular-nums", - ".rdg-cell &": { - padding: "var(--cell-padding)", - pr: 0, - pb: 1 / 8, - }, + padding: "0 var(--cell-padding)", + pr: 0, + pb: 1 / 8, }, "& .MuiInputAdornment-root": { m: 0 }, }} - // Prevent react-data-grid showing NullEditor, which unmounts this field - onDoubleClick={(e) => e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - // Touch mode: make the whole field clickable - onClick={props.inputProps?.onClick as any} + onClick={(e) => e.stopPropagation()} + inputProps={{ ...props.inputProps, tabIndex }} /> )} label={column.name} @@ -91,12 +75,14 @@ export default function Date_({ clearable OpenPickerButtonProps={{ size: "small", - className: "row-hover-iconButton", + className: "row-hover-iconButton end", edge: false, - sx: { mr: 3 / 8, width: 32, height: 32 }, + tabIndex, }} components={{ OpenPickerIcon: ChevronDown }} disableOpenPicker={false} + disabled={disabled} + PopperProps={{ onClick: (e) => e.stopPropagation() }} /> ); } diff --git a/src/components/fields/Date/index.tsx b/src/components/fields/Date/index.tsx index a4a6e353..7078e1f1 100644 --- a/src/components/fields/Date/index.tsx +++ b/src/components/fields/Date/index.tsx @@ -1,16 +1,15 @@ import { lazy } from "react"; import { IFieldConfig, FieldType } from "@src/components/fields/types"; -import withHeavyCell from "@src/components/fields/_withTableCell/withHeavyCell"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; import { parse, format } from "date-fns"; import { DATE_FORMAT } from "@src/constants/dates"; import DateIcon from "@mui/icons-material/TodayOutlined"; -import BasicCell from "./BasicCell"; -import NullEditor from "@src/components/Table/editors/NullEditor"; +import DisplayCell from "./DisplayCell"; import { filterOperators, valueFormatter } from "./filters"; -const TableCell = lazy( - () => import("./TableCell" /* webpackChunkName: "TableCell-Date" */) +const EditorCell = lazy( + () => import("./EditorCell" /* webpackChunkName: "EditorCell-Date" */) ); const SideDrawerField = lazy( () => @@ -29,8 +28,9 @@ export const config: IFieldConfig = { initializable: true, icon: , description: `Formatted date. Format is configurable, default: ${DATE_FORMAT}. Edited with a visual picker.`, - TableCell: withHeavyCell(BasicCell, TableCell), - TableEditor: NullEditor as any, + TableCell: withRenderTableCell(DisplayCell, EditorCell, "focus", { + disablePadding: true, + }), SideDrawerField, filter: { operators: filterOperators, valueFormatter }, settings: Settings, diff --git a/src/components/fields/DateTime/BasicCell.tsx b/src/components/fields/DateTime/DisplayCell.tsx similarity index 51% rename from src/components/fields/DateTime/BasicCell.tsx rename to src/components/fields/DateTime/DisplayCell.tsx index 6c4bac2f..f104d979 100644 --- a/src/components/fields/DateTime/BasicCell.tsx +++ b/src/components/fields/DateTime/DisplayCell.tsx @@ -1,20 +1,24 @@ -import { IBasicCellProps } from "@src/components/fields/types"; +import { IDisplayCellProps } from "@src/components/fields/types"; import { isFunction, isDate } from "lodash-es"; import { format } from "date-fns"; import { DATE_TIME_FORMAT } from "@src/constants/dates"; -export default function DateTime({ - value, - format: formatProp, -}: IBasicCellProps & { format?: string }) { +export default function DateTime({ value, column }: IDisplayCellProps) { if ((!!value && isFunction(value.toDate)) || isDate(value)) { try { const formatted = format( isDate(value) ? value : value.toDate(), - formatProp || DATE_TIME_FORMAT + column.config?.format || DATE_TIME_FORMAT ); return ( - {formatted} +
+ {formatted} +
); } catch (e) { return null; diff --git a/src/components/fields/DateTime/TableCell.tsx b/src/components/fields/DateTime/EditorCell.tsx similarity index 62% rename from src/components/fields/DateTime/TableCell.tsx rename to src/components/fields/DateTime/EditorCell.tsx index 53439ff0..5a8cf2f1 100644 --- a/src/components/fields/DateTime/TableCell.tsx +++ b/src/components/fields/DateTime/EditorCell.tsx @@ -1,5 +1,5 @@ import { useDebouncedCallback } from "use-debounce"; -import { IHeavyCellProps } from "@src/components/fields/types"; +import { IEditorCellProps } from "@src/components/fields/types"; import { setSeconds } from "date-fns"; import DateTimePicker from "@mui/lab/DateTimePicker"; @@ -11,34 +11,26 @@ import { sanitizeValue, } from "@src/components/fields/Date/utils"; import { DATE_TIME_FORMAT } from "@src/constants/dates"; -import BasicCell from "./BasicCell"; export default function DateTime({ column, value, disabled, + onChange, onSubmit, -}: IHeavyCellProps) { + tabIndex, +}: IEditorCellProps) { const transformedValue = transformValue(value); const handleDateChange = useDebouncedCallback((date: Date | null) => { const sanitized = sanitizeValue(date); if (!sanitized) return; // Temp disable setting it to null - onSubmit(sanitized); + onChange(sanitized); + onSubmit(); }, 500); const format = column.config?.format ?? DATE_TIME_FORMAT; - if (disabled) - return ( - - ); - return ( ( @@ -48,24 +40,21 @@ export default function DateTime({ label="" hiddenLabel aria-label={column.name as string} - className="cell-collapse-padding" sx={{ width: "100%", height: "100%", - "& .MuiInputBase-root": { + "&& .MuiInputBase-root": { height: "100%", font: "inherit", // Prevent text jumping letterSpacing: "inherit", // Prevent text jumping - ".rdg-cell &": { - background: "none !important", - boxShadow: "none", - borderRadius: 0, - padding: 0, + background: "none !important", + boxShadow: "none", + borderRadius: 0, + padding: 0, - "&::after": { width: "100%", left: 0 }, - }, + "&::after": { width: "100%", left: 0 }, }, "& .MuiInputBase-input": { height: "100%", @@ -73,35 +62,32 @@ export default function DateTime({ letterSpacing: "inherit", // Prevent text jumping fontVariantNumeric: "tabular-nums", - ".rdg-cell &": { - padding: "var(--cell-padding)", - pr: 0, - pb: 1 / 8, - }, + padding: "0 var(--cell-padding)", + pr: 0, + pb: 1 / 8, }, "& .MuiInputAdornment-root": { m: 0 }, }} - // Prevent react-data-grid showing NullEditor, which unmounts this field - onDoubleClick={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} - // Touch mode: make the whole field clickable - onClick={props.inputProps?.onClick as any} + onClick={(e) => e.stopPropagation()} + inputProps={{ ...props.inputProps, tabIndex }} /> )} label={column.name} value={transformedValue} onChange={(date) => handleDateChange(date ? setSeconds(date, 0) : null)} inputFormat={format} - mask={format.replace(/[A-Za-z]/g, "_")} clearable OpenPickerButtonProps={{ size: "small", - className: "row-hover-iconButton", + className: "row-hover-iconButton end", edge: false, - sx: { mr: 3 / 8, width: 32, height: 32 }, + tabIndex, }} components={{ OpenPickerIcon: ChevronDown }} disableOpenPicker={false} + disabled={disabled} + PopperProps={{ onClick: (e) => e.stopPropagation() }} /> ); } diff --git a/src/components/fields/DateTime/index.tsx b/src/components/fields/DateTime/index.tsx index 880557a0..d414fb6e 100644 --- a/src/components/fields/DateTime/index.tsx +++ b/src/components/fields/DateTime/index.tsx @@ -1,16 +1,15 @@ import { lazy } from "react"; import { IFieldConfig, FieldType } from "@src/components/fields/types"; -import withHeavyCell from "@src/components/fields/_withTableCell/withHeavyCell"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; import { parseJSON, format } from "date-fns"; import { DATE_TIME_FORMAT } from "@src/constants/dates"; import DateTimeIcon from "@mui/icons-material/AccessTime"; -import BasicCell from "./BasicCell"; -import NullEditor from "@src/components/Table/editors/NullEditor"; +import DisplayCell from "./DisplayCell"; import { filterOperators, valueFormatter } from "./filters"; -const TableCell = lazy( - () => import("./TableCell" /* webpackChunkName: "TableCell-DateTime" */) +const EditorCell = lazy( + () => import("./EditorCell" /* webpackChunkName: "EditorCell-DateTime" */) ); const SideDrawerField = lazy( () => @@ -37,8 +36,9 @@ export const config: IFieldConfig = { initializable: true, icon: , description: `Formatted date & time. Format is configurable, default: ${DATE_TIME_FORMAT}. Edited with a visual picker.`, - TableCell: withHeavyCell(BasicCell, TableCell), - TableEditor: NullEditor as any, + TableCell: withRenderTableCell(DisplayCell, EditorCell, "focus", { + disablePadding: true, + }), SideDrawerField, filter: { operators: filterOperators, diff --git a/src/components/fields/Derivative/index.tsx b/src/components/fields/Derivative/index.tsx index a42f2f8c..d046b4d3 100644 --- a/src/components/fields/Derivative/index.tsx +++ b/src/components/fields/Derivative/index.tsx @@ -1,9 +1,7 @@ import { IFieldConfig, FieldType } from "@src/components/fields/types"; -import withBasicCell from "@src/components/fields/_withTableCell/withBasicCell"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; 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"; @@ -17,9 +15,8 @@ export const config: IFieldConfig = { icon: , description: "Value derived from the rest of the row’s values. Displayed using any other field type. Requires Rowy Run set up.", - TableCell: withBasicCell(BasicCell), - TableEditor: NullEditor as any, - SideDrawerField: BasicCell as any, + TableCell: withRenderTableCell(() => null, null), + SideDrawerField: () => null as any, contextMenuActions: ContextMenuActions, settings: Settings, settingsValidator, diff --git a/src/components/fields/Duration/TableCell.tsx b/src/components/fields/Duration/DisplayCell.tsx similarity index 67% rename from src/components/fields/Duration/TableCell.tsx rename to src/components/fields/Duration/DisplayCell.tsx index 78be60c0..a5bade41 100644 --- a/src/components/fields/Duration/TableCell.tsx +++ b/src/components/fields/Duration/DisplayCell.tsx @@ -1,8 +1,8 @@ -import { IBasicCellProps } from "@src/components/fields/types"; +import { IDisplayCellProps } from "@src/components/fields/types"; import { getDurationString } from "./utils"; -export default function Duration({ value }: IBasicCellProps) { +export default function Duration({ value }: IDisplayCellProps) { if ( !value || !value.start || diff --git a/src/components/fields/Duration/index.tsx b/src/components/fields/Duration/index.tsx index 92d42ef9..225906ba 100644 --- a/src/components/fields/Duration/index.tsx +++ b/src/components/fields/Duration/index.tsx @@ -1,14 +1,10 @@ import { lazy } from "react"; import { IFieldConfig, FieldType } from "@src/components/fields/types"; -import withBasicCell from "@src/components/fields/_withTableCell/withBasicCell"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; import DurationIcon from "@mui/icons-material/TimerOutlined"; -import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull"; -import withSideDrawerEditor from "@src/components/Table/editors/withSideDrawerEditor"; +import DisplayCell from "./DisplayCell"; -const TableCell = lazy( - () => import("./TableCell" /* webpackChunkName: "TableCell-Duration" */) -); const SideDrawerField = lazy( () => import( @@ -24,8 +20,9 @@ export const config: IFieldConfig = { initialValue: {}, icon: , description: "Duration calculated from two timestamps.", - TableCell: withBasicCell(TableCell), - TableEditor: withSideDrawerEditor(TableCell), + TableCell: withRenderTableCell(DisplayCell, SideDrawerField, "popover", { + popoverProps: { PaperProps: { sx: { p: 1 } } }, + }), SideDrawerField, }; export default config; diff --git a/src/components/fields/Email/EditorCell.tsx b/src/components/fields/Email/EditorCell.tsx new file mode 100644 index 00000000..bdc0d823 --- /dev/null +++ b/src/components/fields/Email/EditorCell.tsx @@ -0,0 +1,6 @@ +import type { IEditorCellProps } from "@src/components/fields/types"; +import EditorCellTextField from "@src/components/Table/TableCell/EditorCellTextField"; + +export default function Email(props: IEditorCellProps) { + return ; +} diff --git a/src/components/fields/Email/index.tsx b/src/components/fields/Email/index.tsx index 87b3a07a..3ea395d8 100644 --- a/src/components/fields/Email/index.tsx +++ b/src/components/fields/Email/index.tsx @@ -1,12 +1,12 @@ import { lazy } from "react"; import { IFieldConfig, FieldType } from "@src/components/fields/types"; -import withBasicCell from "@src/components/fields/_withTableCell/withBasicCell"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; import EmailIcon from "@mui/icons-material/MailOutlined"; -import BasicCell from "@src/components/fields/_BasicCell/BasicCellValue"; -import TextEditor from "@src/components/Table/editors/TextEditor"; +import DisplayCell from "@src/components/fields/ShortText/DisplayCell"; +import EditorCell from "./EditorCell"; import { filterOperators } from "@src/components/fields/ShortText/Filter"; -import BasicContextMenuActions from "@src/components/fields/_BasicCell/BasicCellContextMenuActions"; +import BasicContextMenuActions from "@src/components/Table/ContextMenu/BasicCellContextMenuActions"; const SideDrawerField = lazy( () => @@ -23,8 +23,7 @@ export const config: IFieldConfig = { icon: , description: "Email address. Not validated.", contextMenuActions: BasicContextMenuActions, - TableCell: withBasicCell(BasicCell), - TableEditor: TextEditor, + TableCell: withRenderTableCell(DisplayCell, EditorCell), SideDrawerField, filter: { operators: filterOperators, diff --git a/src/components/fields/File/DisplayCell.tsx b/src/components/fields/File/DisplayCell.tsx new file mode 100644 index 00000000..f92d21e6 --- /dev/null +++ b/src/components/fields/File/DisplayCell.tsx @@ -0,0 +1,42 @@ +import { IDisplayCellProps } from "@src/components/fields/types"; + +import { Grid, Chip } from "@mui/material"; +import ChipList from "@src/components/Table/TableCell/ChipList"; + +import { FileIcon } from "."; +import { FileValue } from "@src/types/table"; + +export default function File_({ + value, + tabIndex, + rowHeight, +}: IDisplayCellProps) { + return ( + + {Array.isArray(value) && + value.map((file: FileValue) => ( + 1 ? { maxWidth: `calc(100% - 12px)` } : {} + } + > + } + label={file.name} + onClick={(e: any) => e.stopPropagation()} + component="a" + href={file.downloadURL} + target="_blank" + rel="noopener noreferrer" + clickable + style={{ width: "100%", cursor: "pointer" }} + tabIndex={tabIndex} + /> + + ))} + + ); +} diff --git a/src/components/fields/File/TableCell.tsx b/src/components/fields/File/EditorCell.tsx similarity index 79% rename from src/components/fields/File/TableCell.tsx rename to src/components/fields/File/EditorCell.tsx index d35a8c6f..247c99fa 100644 --- a/src/components/fields/File/TableCell.tsx +++ b/src/components/fields/File/EditorCell.tsx @@ -1,11 +1,12 @@ -import { IHeavyCellProps } from "@src/components/fields/types"; +import { useCallback } from "react"; +import { IEditorCellProps } from "@src/components/fields/types"; import { useSetAtom } from "jotai"; import { format } from "date-fns"; import { alpha, Stack, Grid, Tooltip, Chip, IconButton } from "@mui/material"; import { Upload as UploadIcon } from "@src/assets/icons"; -import ChipList from "@src/components/Table/formatters/ChipList"; +import ChipList from "@src/components/Table/TableCell/ChipList"; import CircularProgressOptical from "@src/components/CircularProgressOptical"; import { projectScope, confirmDialogAtom } from "@src/atoms/projectScope"; @@ -18,23 +19,24 @@ export default function File_({ column, value, disabled, - docRef, -}: IHeavyCellProps) { + _rowy_ref, + tabIndex, + rowHeight, +}: IEditorCellProps) { const confirm = useSetAtom(confirmDialogAtom, projectScope); const { loading, progress, handleDelete, localFiles, dropzoneState } = - useFileUpload(docRef, column.key, { multiple: true }); + useFileUpload(_rowy_ref, column.key, { multiple: true }); const { isDragActive, getRootProps, getInputProps } = dropzoneState; const dropzoneProps = getRootProps(); return ( - + {Array.isArray(value) && value.map((file: FileValue) => ( { - window.open(file.downloadURL); - e.stopPropagation(); - }} + onClick={(e: any) => e.stopPropagation()} + component="a" + href={file.downloadURL} + target="_blank" + rel="noopener noreferrer" + clickable onDelete={ disabled ? undefined @@ -92,13 +97,15 @@ export default function File_({ confirmColor: "error", }) } + tabIndex={tabIndex} + style={{ width: "100%", cursor: "pointer" }} /> ))} {localFiles && localFiles.map((file) => ( - + } label={file.name} @@ -119,8 +126,9 @@ export default function File_({ e.stopPropagation(); }} style={{ display: "flex" }} - className={docRef && "row-hover-iconButton"} - disabled={!docRef} + className={_rowy_ref && "row-hover-iconButton end"} + disabled={!_rowy_ref} + tabIndex={tabIndex} > @@ -136,7 +144,7 @@ export default function File_({
)} - +
); } diff --git a/src/components/fields/File/index.tsx b/src/components/fields/File/index.tsx index bdd0a323..33ab5021 100644 --- a/src/components/fields/File/index.tsx +++ b/src/components/fields/File/index.tsx @@ -1,13 +1,12 @@ import { lazy } from "react"; import { IFieldConfig, FieldType } from "@src/components/fields/types"; -import withHeavyCell from "@src/components/fields/_withTableCell/withHeavyCell"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; import FileIcon from "@mui/icons-material/AttachFile"; -import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull"; -import NullEditor from "@src/components/Table/editors/NullEditor"; +import DisplayCell from "./DisplayCell"; -const TableCell = lazy( - () => import("./TableCell" /* webpackChunkName: "TableCell-File" */) +const EditorCell = lazy( + () => import("./EditorCell" /* webpackChunkName: "EditorCell-File" */) ); const SideDrawerField = lazy( () => @@ -23,8 +22,9 @@ export const config: IFieldConfig = { initialValue: [], icon: , description: "File uploaded to Firebase Storage. Supports any file type.", - TableCell: withHeavyCell(BasicCell, TableCell), - TableEditor: NullEditor as any, + TableCell: withRenderTableCell(DisplayCell, EditorCell, "inline", { + disablePadding: true, + }), SideDrawerField, }; export default config; diff --git a/src/components/fields/File/useFileUpload.ts b/src/components/fields/File/useFileUpload.ts index 8f2d634d..d99ccf67 100644 --- a/src/components/fields/File/useFileUpload.ts +++ b/src/components/fields/File/useFileUpload.ts @@ -1,14 +1,14 @@ import { useCallback, useState } from "react"; import { useSetAtom } from "jotai"; import { some } from "lodash-es"; +import { DropzoneOptions, useDropzone } from "react-dropzone"; import { tableScope, updateFieldAtom } from "@src/atoms/tableScope"; import useUploader from "@src/hooks/useFirebaseStorageUploader"; -import { FileValue } from "@src/types/table"; -import { DropzoneOptions, useDropzone } from "react-dropzone"; +import type { FileValue, TableRowRef } from "@src/types/table"; export default function useFileUpload( - docRef: any, + docRef: TableRowRef, fieldName: string, dropzoneOptions: DropzoneOptions = {} ) { diff --git a/src/components/fields/GeoPoint/TableCell.tsx b/src/components/fields/GeoPoint/DisplayCell.tsx similarity index 90% rename from src/components/fields/GeoPoint/TableCell.tsx rename to src/components/fields/GeoPoint/DisplayCell.tsx index 2e57a835..f452c3fc 100644 --- a/src/components/fields/GeoPoint/TableCell.tsx +++ b/src/components/fields/GeoPoint/DisplayCell.tsx @@ -1,7 +1,7 @@ -import { IBasicCellProps } from "@src/components/fields/types"; +import { IDisplayCellProps } from "@src/components/fields/types"; import { Typography } from "@mui/material"; -export default function GeoPoint({ value }: IBasicCellProps) { +export default function GeoPoint({ value }: IDisplayCellProps) { if (!value) return null; const { latitude, longitude } = value; diff --git a/src/components/fields/GeoPoint/index.tsx b/src/components/fields/GeoPoint/index.tsx index 21b898b1..17dc20fb 100644 --- a/src/components/fields/GeoPoint/index.tsx +++ b/src/components/fields/GeoPoint/index.tsx @@ -1,14 +1,11 @@ import { lazy } from "react"; import { GeoPoint } from "firebase/firestore"; import { IFieldConfig, FieldType } from "@src/components/fields/types"; -import withBasicCell from "@src/components/fields/_withTableCell/withBasicCell"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; import GeoPointIcon from "@mui/icons-material/PinDropOutlined"; -import withSideDrawerEditor from "@src/components/Table/editors/withSideDrawerEditor"; +import DisplayCell from "./DisplayCell"; -const TableCell = lazy( - () => import("./TableCell" /* webpackChunkName: "TableCell-GeoPoint" */) -); const SideDrawerField = lazy( () => import( @@ -24,8 +21,9 @@ export const config: IFieldConfig = { initialValue: {}, icon: , description: "Geo point is represented as latitude/longitude pair.", - TableCell: withBasicCell(TableCell), - TableEditor: withSideDrawerEditor(TableCell), + TableCell: withRenderTableCell(DisplayCell, SideDrawerField, "popover", { + popoverProps: { PaperProps: { sx: { p: 1, pt: 0 } } }, + }), SideDrawerField, csvImportParser: (value: string) => { try { @@ -35,7 +33,7 @@ export const config: IFieldConfig = { } throw new Error(); } catch (e) { - console.error("Invalid Geopoint value"); + console.error("Invalid GeoPoint value"); return null; } }, diff --git a/src/components/fields/Id/TableCell.tsx b/src/components/fields/Id/DisplayCell.tsx similarity index 63% rename from src/components/fields/Id/TableCell.tsx rename to src/components/fields/Id/DisplayCell.tsx index 2ff1feff..7ca074aa 100644 --- a/src/components/fields/Id/TableCell.tsx +++ b/src/components/fields/Id/DisplayCell.tsx @@ -1,8 +1,8 @@ -import { IHeavyCellProps } from "@src/components/fields/types"; +import { IDisplayCellProps } from "@src/components/fields/types"; import { useTheme } from "@mui/material"; -export default function Id({ docRef }: IHeavyCellProps) { +export default function Id({ _rowy_ref }: IDisplayCellProps) { const theme = useTheme(); return ( @@ -13,7 +13,7 @@ export default function Id({ docRef }: IHeavyCellProps) { userSelect: "all", }} > - {docRef?.id} + {_rowy_ref?.id} ); } diff --git a/src/components/fields/Id/index.tsx b/src/components/fields/Id/index.tsx index c6222990..0215e7fe 100644 --- a/src/components/fields/Id/index.tsx +++ b/src/components/fields/Id/index.tsx @@ -1,14 +1,10 @@ import { lazy } from "react"; import { IFieldConfig, FieldType } from "@src/components/fields/types"; -import withHeavyCell from "@src/components/fields/_withTableCell/withHeavyCell"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; +import DisplayCell from "./DisplayCell"; import { Id as IdIcon } from "@src/assets/icons"; -import BasicCell from "@src/components/fields/_BasicCell/BasicCellValue"; -import withSideDrawerEditor from "@src/components/Table/editors/withSideDrawerEditor"; -const TableCell = lazy( - () => import("./TableCell" /* webpackChunkName: "TableCell-Id" */) -); const SideDrawerField = lazy( () => import("./SideDrawerField" /* webpackChunkName: "SideDrawerField-Id" */) ); @@ -21,8 +17,7 @@ export const config: IFieldConfig = { initialValue: "", icon: , description: "Displays the row’s ID. Read-only. Cannot be sorted.", - TableCell: withHeavyCell(BasicCell, TableCell), - TableEditor: withSideDrawerEditor(TableCell), + TableCell: withRenderTableCell(DisplayCell, null), SideDrawerField, }; export default config; diff --git a/src/components/fields/Image/DisplayCell.tsx b/src/components/fields/Image/DisplayCell.tsx new file mode 100644 index 00000000..0a4f955f --- /dev/null +++ b/src/components/fields/Image/DisplayCell.tsx @@ -0,0 +1,113 @@ +import { IDisplayCellProps } from "@src/components/fields/types"; +import { useAtom } from "jotai"; + +import { alpha, Theme, Stack, Grid, ButtonBase } from "@mui/material"; +import OpenIcon from "@mui/icons-material/OpenInNewOutlined"; + +import Thumbnail from "@src/components/Thumbnail"; + +import { tableSchemaAtom, tableScope } from "@src/atoms/tableScope"; +import { DEFAULT_ROW_HEIGHT } from "@src/components/Table"; +import { FileValue } from "@src/types/table"; + +// MULTIPLE +export const imgSx = (rowHeight: number) => ({ + position: "relative", + display: "flex", + + width: (theme: Theme) => `calc(${rowHeight}px - ${theme.spacing(1)} - 1px)`, + height: (theme: Theme) => `calc(${rowHeight}px - ${theme.spacing(1)} - 1px)`, + + backgroundSize: "contain", + backgroundPosition: "center center", + backgroundRepeat: "no-repeat", + + borderRadius: 1, +}); +export const thumbnailSx = { + position: "absolute", + top: 0, + left: 0, + width: "100%", + height: "100%", +}; +export const deleteImgHoverSx = { + position: "absolute", + top: 0, + left: 0, + bottom: 0, + right: 0, + + color: "text.secondary", + boxShadow: (theme: Theme) => `0 0 0 1px ${theme.palette.divider} inset`, + borderRadius: 1, + + transition: (theme: Theme) => + theme.transitions.create("background-color", { + duration: theme.transitions.duration.shortest, + }), + + "& *": { + opacity: 0, + transition: (theme: Theme) => + theme.transitions.create("opacity", { + duration: theme.transitions.duration.shortest, + }), + }, + + ".img:hover &, .img:focus &": { + backgroundColor: (theme: Theme) => + alpha(theme.palette.background.paper, 0.8), + "& *": { opacity: 1 }, + }, +}; + +export default function Image_({ value, tabIndex }: IDisplayCellProps) { + const [tableSchema] = useAtom(tableSchemaAtom, tableScope); + + const rowHeight = tableSchema.rowHeight ?? DEFAULT_ROW_HEIGHT; + let thumbnailSize = "100x100"; + if (rowHeight > 50) thumbnailSize = "200x200"; + if (rowHeight > 100) thumbnailSize = "400x400"; + + return ( + + + {Array.isArray(value) && + value.map((file: FileValue, i) => ( + + { + window.open(file.downloadURL, "_blank")} + tabIndex={tabIndex} + > + + + + + + } + + ))} + + + ); +} diff --git a/src/components/fields/Image/EditorCell.tsx b/src/components/fields/Image/EditorCell.tsx new file mode 100644 index 00000000..ceec4307 --- /dev/null +++ b/src/components/fields/Image/EditorCell.tsx @@ -0,0 +1,175 @@ +import { useMemo } from "react"; +import { IEditorCellProps } from "@src/components/fields/types"; +import { useAtom, useSetAtom } from "jotai"; +import { assignIn } from "lodash-es"; + +import { alpha, Box, Stack, Grid, IconButton, ButtonBase } from "@mui/material"; +import AddIcon from "@mui/icons-material/AddAPhotoOutlined"; +import DeleteIcon from "@mui/icons-material/DeleteOutlined"; + +import Thumbnail from "@src/components/Thumbnail"; +import CircularProgressOptical from "@src/components/CircularProgressOptical"; + +import { projectScope, confirmDialogAtom } from "@src/atoms/projectScope"; +import { tableSchemaAtom, tableScope } from "@src/atoms/tableScope"; +import { DEFAULT_ROW_HEIGHT } from "@src/components/Table"; +import { FileValue } from "@src/types/table"; +import useFileUpload from "@src/components/fields/File/useFileUpload"; +import { IMAGE_MIME_TYPES } from "./index"; +import { imgSx, thumbnailSx, deleteImgHoverSx } from "./DisplayCell"; + +export default function Image_({ + column, + value, + disabled, + _rowy_ref, + tabIndex, + rowHeight, +}: IEditorCellProps) { + const confirm = useSetAtom(confirmDialogAtom, projectScope); + + const { loading, progress, handleDelete, localFiles, dropzoneState } = + useFileUpload(_rowy_ref, column.key, { + multiple: true, + accept: IMAGE_MIME_TYPES, + }); + + const localImages = useMemo( + () => + localFiles.map((file) => + assignIn(file, { localURL: URL.createObjectURL(file) }) + ), + [localFiles] + ); + + const { getRootProps, getInputProps, isDragActive } = dropzoneState; + const dropzoneProps = getRootProps(); + + let thumbnailSize = "100x100"; + if (rowHeight > 50) thumbnailSize = "200x200"; + if (rowHeight > 100) thumbnailSize = "400x400"; + + return ( + + alpha( + theme.palette.primary.main, + theme.palette.action.hoverOpacity * 2 + ), + + "& .row-hover-iconButton": { color: "primary.main" }, + } + : {}, + ]} + alignItems="center" + {...dropzoneProps} + tabIndex={tabIndex} + onClick={undefined} + > +
+ + {Array.isArray(value) && + value.map((file: FileValue, i) => ( + + { + confirm({ + title: "Delete image?", + body: "This image cannot be recovered after", + confirm: "Delete", + confirmColor: "error", + handleConfirm: () => handleDelete(file), + }); + }} + disabled={disabled} + tabIndex={tabIndex} + > + + + + + + + ))} + + {localImages && + localImages.map((image) => ( + + + `0 0 0 1px ${theme.palette.divider} inset`, + }, + ]} + style={{ + backgroundImage: `url("${image.localURL}")`, + }} + /> + + ))} + +
+ + {!loading ? ( + !disabled && ( + { + dropzoneProps.onClick!(e); + e.stopPropagation(); + }} + style={{ display: "flex" }} + className={_rowy_ref && "row-hover-iconButton end"} + disabled={!_rowy_ref} + tabIndex={tabIndex} + > + + + ) + ) : ( +
+ +
+ )} + + +
+ ); +} diff --git a/src/components/fields/Image/TableCell.tsx b/src/components/fields/Image/TableCell.tsx deleted file mode 100644 index a903f955..00000000 --- a/src/components/fields/Image/TableCell.tsx +++ /dev/null @@ -1,265 +0,0 @@ -import { useMemo } from "react"; -import { IHeavyCellProps } from "@src/components/fields/types"; -import { useAtom, useSetAtom } from "jotai"; -import { assignIn } from "lodash-es"; - -import { - alpha, - Theme, - Box, - Stack, - Grid, - IconButton, - ButtonBase, - Tooltip, -} from "@mui/material"; -import AddIcon from "@mui/icons-material/AddAPhotoOutlined"; -import DeleteIcon from "@mui/icons-material/DeleteOutlined"; -import OpenIcon from "@mui/icons-material/OpenInNewOutlined"; - -import Thumbnail from "@src/components/Thumbnail"; -import CircularProgressOptical from "@src/components/CircularProgressOptical"; - -import { projectScope, confirmDialogAtom } from "@src/atoms/projectScope"; -import { tableSchemaAtom, tableScope } from "@src/atoms/tableScope"; -import { DEFAULT_ROW_HEIGHT } from "@src/components/Table"; -import { FileValue } from "@src/types/table"; -import useFileUpload from "@src/components/fields/File/useFileUpload"; -import { IMAGE_MIME_TYPES } from "./index"; - -// MULTIPLE -const imgSx = (rowHeight: number) => ({ - position: "relative", - display: "flex", - - width: (theme: Theme) => `calc(${rowHeight}px - ${theme.spacing(1)} - 1px)`, - height: (theme: Theme) => `calc(${rowHeight}px - ${theme.spacing(1)} - 1px)`, - - backgroundSize: "contain", - backgroundPosition: "center center", - backgroundRepeat: "no-repeat", - - borderRadius: 1, -}); -const thumbnailSx = { - position: "absolute", - top: 0, - left: 0, - width: "100%", - height: "100%", -}; -const deleteImgHoverSx = { - position: "absolute", - top: 0, - left: 0, - bottom: 0, - right: 0, - - color: "text.secondary", - boxShadow: (theme: Theme) => `0 0 0 1px ${theme.palette.divider} inset`, - borderRadius: 1, - - transition: (theme: Theme) => - theme.transitions.create("background-color", { - duration: theme.transitions.duration.shortest, - }), - - "& *": { - opacity: 0, - transition: (theme: Theme) => - theme.transitions.create("opacity", { - duration: theme.transitions.duration.shortest, - }), - }, - - ".img:hover &": { - backgroundColor: (theme: Theme) => - alpha(theme.palette.background.paper, 0.8), - "& *": { opacity: 1 }, - }, -}; - -export default function Image_({ - column, - value, - disabled, - docRef, -}: IHeavyCellProps) { - const confirm = useSetAtom(confirmDialogAtom, projectScope); - const [tableSchema] = useAtom(tableSchemaAtom, tableScope); - - const { loading, progress, handleDelete, localFiles, dropzoneState } = - useFileUpload(docRef, column.key, { - multiple: true, - accept: IMAGE_MIME_TYPES, - }); - - const localImages = useMemo( - () => - localFiles.map((file) => - assignIn(file, { localURL: URL.createObjectURL(file) }) - ), - [localFiles] - ); - - const { getRootProps, getInputProps, isDragActive } = dropzoneState; - const dropzoneProps = getRootProps(); - - const rowHeight = tableSchema.rowHeight ?? DEFAULT_ROW_HEIGHT; - let thumbnailSize = "100x100"; - if (rowHeight > 50) thumbnailSize = "200x200"; - if (rowHeight > 100) thumbnailSize = "400x400"; - - return ( - - alpha( - theme.palette.primary.main, - theme.palette.action.hoverOpacity * 2 - ), - - "& .row-hover-iconButton": { color: "primary.main" }, - } - : {}, - ]} - alignItems="center" - {...dropzoneProps} - onClick={undefined} - > -
- - {Array.isArray(value) && - value.map((image: FileValue) => ( - - {disabled ? ( - - window.open(image.downloadURL, "_blank")} - > - - - {disabled ? ( - - ) : ( - - )} - - - - ) : ( - -
- { - confirm({ - title: "Delete image?", - body: "This image cannot be recovered after", - confirm: "Delete", - confirmColor: "error", - handleConfirm: () => handleDelete(image), - }); - }} - > - - - - - -
-
- )} -
- ))} - - {localImages && - localImages.map((image) => ( - - - `0 0 0 1px ${theme.palette.divider} inset`, - }, - ]} - style={{ - backgroundImage: `url("${image.localURL}")`, - }} - /> - - ))} -
-
- - {!loading ? ( - !disabled && ( - { - dropzoneProps.onClick!(e); - e.stopPropagation(); - }} - style={{ display: "flex" }} - className={docRef && "row-hover-iconButton"} - disabled={!docRef} - > - - - ) - ) : ( -
- -
- )} - - -
- ); -} diff --git a/src/components/fields/Image/index.tsx b/src/components/fields/Image/index.tsx index b29a7dab..71d5a32d 100644 --- a/src/components/fields/Image/index.tsx +++ b/src/components/fields/Image/index.tsx @@ -1,14 +1,13 @@ import { lazy } from "react"; import { IFieldConfig, FieldType } from "@src/components/fields/types"; -import withHeavyCell from "@src/components/fields/_withTableCell/withHeavyCell"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; import { Image as ImageIcon } from "@src/assets/icons"; -import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull"; -import NullEditor from "@src/components/Table/editors/NullEditor"; +import DisplayCell from "./DisplayCell"; import ContextMenuActions from "./ContextMenuActions"; -const TableCell = lazy( - () => import("./TableCell" /* webpackChunkName: "TableCell-Image" */) +const EditorCell = lazy( + () => import("./EditorCell" /* webpackChunkName: "EditorCell-Image" */) ); const SideDrawerField = lazy( () => @@ -24,8 +23,9 @@ export const config: IFieldConfig = { icon: , description: "Image file uploaded to Firebase Storage. Supports JPEG, PNG, SVG, GIF, WebP, AVIF, JPEG XL.", - TableCell: withHeavyCell(BasicCell, TableCell), - TableEditor: NullEditor as any, + TableCell: withRenderTableCell(DisplayCell, EditorCell, "inline", { + disablePadding: true, + }), SideDrawerField, contextMenuActions: ContextMenuActions, }; diff --git a/src/components/fields/Json/BasicCell.tsx b/src/components/fields/Json/DisplayCell.tsx similarity index 67% rename from src/components/fields/Json/BasicCell.tsx rename to src/components/fields/Json/DisplayCell.tsx index ababa833..bc97afa0 100644 --- a/src/components/fields/Json/BasicCell.tsx +++ b/src/components/fields/Json/DisplayCell.tsx @@ -1,21 +1,21 @@ import stringify from "json-stable-stringify-without-jsonify"; -import { IBasicCellProps } from "@src/components/fields/types"; +import { IDisplayCellProps } from "@src/components/fields/types"; import { useTheme } from "@mui/material"; -export default function Json({ value }: IBasicCellProps) { +export default function Json({ value }: IDisplayCellProps) { const theme = useTheme(); if (!value) return null; - const formattedJson = stringify(value, { space: 2 }); + const formattedJson = stringify(value, { space: 2 }).substring(0, 1000); return (
diff --git a/src/components/fields/Json/index.tsx b/src/components/fields/Json/index.tsx index 8981d877..6804b7c6 100644 --- a/src/components/fields/Json/index.tsx +++ b/src/components/fields/Json/index.tsx @@ -1,10 +1,9 @@ import { lazy } from "react"; import { IFieldConfig, FieldType } from "@src/components/fields/types"; -import withBasicCell from "@src/components/fields/_withTableCell/withBasicCell"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; import { Json as JsonIcon } from "@src/assets/icons"; -import BasicCell from "./BasicCell"; -import withSideDrawerEditor from "@src/components/Table/editors/withSideDrawerEditor"; +import DisplayCell from "./DisplayCell"; import ContextMenuActions from "./ContextMenuActions"; const SideDrawerField = lazy( @@ -25,8 +24,9 @@ export const config: IFieldConfig = { initializable: true, icon: , description: "Object edited with a visual JSON editor.", - TableCell: withBasicCell(BasicCell), - TableEditor: withSideDrawerEditor(BasicCell), + TableCell: withRenderTableCell(DisplayCell, SideDrawerField, "popover", { + popoverProps: { PaperProps: { sx: { p: 1 } } }, + }), csvImportParser: (value) => { try { return JSON.parse(value); diff --git a/src/components/fields/LongText/BasicCell.tsx b/src/components/fields/LongText/DisplayCell.tsx similarity index 58% rename from src/components/fields/LongText/BasicCell.tsx rename to src/components/fields/LongText/DisplayCell.tsx index 14c6e23e..cc285625 100644 --- a/src/components/fields/LongText/BasicCell.tsx +++ b/src/components/fields/LongText/DisplayCell.tsx @@ -1,16 +1,15 @@ -import { IBasicCellProps } from "@src/components/fields/types"; +import { IDisplayCellProps } from "@src/components/fields/types"; import { useTheme } from "@mui/material"; -export default function LongText({ value }: IBasicCellProps) { +export default function LongText({ value }: IDisplayCellProps) { const theme = useTheme(); return (
) { + return ; +} diff --git a/src/components/fields/LongText/index.tsx b/src/components/fields/LongText/index.tsx index c4cec831..6b926126 100644 --- a/src/components/fields/LongText/index.tsx +++ b/src/components/fields/LongText/index.tsx @@ -1,13 +1,13 @@ import { lazy } from "react"; import { IFieldConfig, FieldType } from "@src/components/fields/types"; -import withBasicCell from "@src/components/fields/_withTableCell/withBasicCell"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; import LongTextIcon from "@mui/icons-material/Notes"; -import BasicCell from "./BasicCell"; -import TextEditor from "@src/components/Table/editors/TextEditor"; +import DisplayCell from "./DisplayCell"; +import EditorCell from "./EditorCell"; import { filterOperators } from "./Filter"; -import BasicContextMenuActions from "@src/components/fields/_BasicCell/BasicCellContextMenuActions"; +import BasicContextMenuActions from "@src/components/Table/ContextMenu/BasicCellContextMenuActions"; const SideDrawerField = lazy( () => @@ -15,7 +15,6 @@ const SideDrawerField = lazy( "./SideDrawerField" /* webpackChunkName: "SideDrawerField-LongText" */ ) ); - const Settings = lazy( () => import("./Settings" /* webpackChunkName: "Settings-LongText" */) ); @@ -30,8 +29,7 @@ export const config: IFieldConfig = { icon: , description: "Text displayed on multiple lines.", contextMenuActions: BasicContextMenuActions, - TableCell: withBasicCell(BasicCell), - TableEditor: TextEditor, + TableCell: withRenderTableCell(DisplayCell, EditorCell), SideDrawerField, settings: Settings, filter: { diff --git a/src/components/fields/Markdown/BasicCell.tsx b/src/components/fields/Markdown/DisplayCell.tsx similarity index 59% rename from src/components/fields/Markdown/BasicCell.tsx rename to src/components/fields/Markdown/DisplayCell.tsx index 37e825c1..9bf4c028 100644 --- a/src/components/fields/Markdown/BasicCell.tsx +++ b/src/components/fields/Markdown/DisplayCell.tsx @@ -1,10 +1,10 @@ -import { IBasicCellProps } from "@src/components/fields/types"; +import { IDisplayCellProps } from "@src/components/fields/types"; import { useTheme } from "@mui/material"; import MDEditor from "@uiw/react-md-editor"; -export default function Markdown({ value }: IBasicCellProps) { +export default function Markdown({ value, tabIndex }: IDisplayCellProps) { const theme = useTheme(); if (!value || typeof value !== "string") return null; @@ -13,6 +13,8 @@ export default function Markdown({ value }: IBasicCellProps) {
diff --git a/src/components/fields/Markdown/SideDrawerField.tsx b/src/components/fields/Markdown/SideDrawerField.tsx index 815c539d..2b51d9b1 100644 --- a/src/components/fields/Markdown/SideDrawerField.tsx +++ b/src/components/fields/Markdown/SideDrawerField.tsx @@ -34,7 +34,7 @@ export default function Markdown({ { display: "block", padding: 0, - "& .wmde-markdown-var": { boxShadow: "none" }, + "& .wmde-markdown-var": { boxShadow: (fieldSx as any)?.boxShadow }, }, ]} data-color-mode={theme.palette.mode} diff --git a/src/components/fields/Markdown/index.tsx b/src/components/fields/Markdown/index.tsx index 6053f093..26de107b 100644 --- a/src/components/fields/Markdown/index.tsx +++ b/src/components/fields/Markdown/index.tsx @@ -1,10 +1,9 @@ import { lazy } from "react"; import { IFieldConfig, FieldType } from "@src/components/fields/types"; -import withBasicCell from "@src/components/fields/_withTableCell/withBasicCell"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; import { Markdown as MarkdownIcon } from "@src/assets/icons"; -import BasicCell from "./BasicCell"; -import withSideDrawerEditor from "@src/components/Table/editors/withSideDrawerEditor"; +import DisplayCell from "./DisplayCell"; const SideDrawerField = lazy( () => @@ -22,8 +21,7 @@ export const config: IFieldConfig = { initializable: true, icon: , description: "Markdown editor with preview", - TableCell: withBasicCell(BasicCell), - TableEditor: withSideDrawerEditor(BasicCell), + TableCell: withRenderTableCell(DisplayCell, SideDrawerField, "popover"), SideDrawerField, }; export default config; diff --git a/src/components/fields/MultiSelect/ConvertStringToArray.tsx b/src/components/fields/MultiSelect/ConvertStringToArray.tsx deleted file mode 100644 index 9d3f3ce0..00000000 --- a/src/components/fields/MultiSelect/ConvertStringToArray.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { IPopoverInlineCellProps } from "@src/components/fields/types"; -import { Grid, Tooltip, Button } from "@mui/material"; - -export const ConvertStringToArray = ({ - value, - onSubmit, -}: Pick) => ( - - - {value} - - - - - - - -); diff --git a/src/components/fields/MultiSelect/DisplayCell.tsx b/src/components/fields/MultiSelect/DisplayCell.tsx new file mode 100644 index 00000000..fe7d6b87 --- /dev/null +++ b/src/components/fields/MultiSelect/DisplayCell.tsx @@ -0,0 +1,60 @@ +import { IDisplayCellProps } from "@src/components/fields/types"; + +import { ButtonBase, Grid, Tooltip } from "@mui/material"; +import WarningIcon from "@mui/icons-material/WarningAmber"; +import { ChevronDown } from "@src/assets/icons"; + +import { sanitiseValue } from "./utils"; +import ChipList from "@src/components/Table/TableCell/ChipList"; +import FormattedChip from "@src/components/FormattedChip"; + +export default function MultiSelect({ + value, + showPopoverCell, + disabled, + tabIndex, + rowHeight, +}: IDisplayCellProps) { + const rendered = + typeof value === "string" && value !== "" ? ( +
+ + + +   + {value} +
+ ) : ( + + {sanitiseValue(value).map( + (item) => + typeof item === "string" && ( + + + + ) + )} + + ); + + if (disabled) return rendered; + + return ( + showPopoverCell(true)} + style={{ + width: "100%", + height: "100%", + font: "inherit", + color: "inherit !important", + letterSpacing: "inherit", + textAlign: "inherit", + justifyContent: "flex-start", + }} + tabIndex={tabIndex} + > + {rendered} + + + ); +} diff --git a/src/components/fields/MultiSelect/EditorCell.tsx b/src/components/fields/MultiSelect/EditorCell.tsx new file mode 100644 index 00000000..46fa6837 --- /dev/null +++ b/src/components/fields/MultiSelect/EditorCell.tsx @@ -0,0 +1,74 @@ +import { IEditorCellProps } from "@src/components/fields/types"; + +import { Typography, Button } from "@mui/material"; +import WarningIcon from "@mui/icons-material/WarningAmber"; +import MultiSelectComponent from "@rowy/multiselect"; +import EmptyState from "@src/components/EmptyState"; + +import { sanitiseValue } from "./utils"; + +export default function MultiSelect({ + value, + onChange, + onSubmit, + column, + parentRef, + showPopoverCell, + disabled, +}: IEditorCellProps) { + const config = column.config ?? {}; + + if (typeof value === "string" && value !== "") + return ( + + This cell’s value is a string + + + } + sx={{ my: 3, width: column.width }} + /> + ); + + return ( + { + showPopoverCell(false); + onSubmit(); + }} + /> + ); +} diff --git a/src/components/fields/MultiSelect/InlineCell.tsx b/src/components/fields/MultiSelect/InlineCell.tsx deleted file mode 100644 index 8f27323c..00000000 --- a/src/components/fields/MultiSelect/InlineCell.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { forwardRef } from "react"; -import { IPopoverInlineCellProps } from "@src/components/fields/types"; - -import { ButtonBase, Grid } from "@mui/material"; -import { ChevronDown } from "@src/assets/icons"; - -import { sanitiseValue } from "./utils"; -import ChipList from "@src/components/Table/formatters/ChipList"; -import FormattedChip from "@src/components/FormattedChip"; -import { ConvertStringToArray } from "./ConvertStringToArray"; - -export const MultiSelect = forwardRef(function MultiSelect( - { value, showPopoverCell, disabled, onSubmit }: IPopoverInlineCellProps, - ref: React.Ref -) { - if (typeof value === "string" && value !== "") - return ; - - return ( - showPopoverCell(true)} - ref={ref} - disabled={disabled} - className="cell-collapse-padding" - sx={{ - height: "100%", - font: "inherit", - color: "inherit !important", - letterSpacing: "inherit", - textAlign: "inherit", - justifyContent: "flex-start", - }} - > - - {sanitiseValue(value).map( - (item) => - typeof item === "string" && ( - - - - ) - )} - - - {!disabled && ( - - )} - - ); -}); - -export default MultiSelect; diff --git a/src/components/fields/MultiSelect/SideDrawerField.tsx b/src/components/fields/MultiSelect/SideDrawerField.tsx index a0290625..99c260be 100644 --- a/src/components/fields/MultiSelect/SideDrawerField.tsx +++ b/src/components/fields/MultiSelect/SideDrawerField.tsx @@ -1,11 +1,12 @@ import { ISideDrawerFieldProps } from "@src/components/fields/types"; -import { Grid } from "@mui/material"; +import { Grid, Button, Tooltip } from "@mui/material"; +import WarningIcon from "@mui/icons-material/WarningAmber"; import MultiSelectComponent from "@rowy/multiselect"; import FormattedChip from "@src/components/FormattedChip"; +import { fieldSx } from "@src/components/SideDrawer/utils"; import { sanitiseValue } from "./utils"; -import { ConvertStringToArray } from "./ConvertStringToArray"; export default function MultiSelect({ column, @@ -24,7 +25,28 @@ export default function MultiSelect({ }; if (typeof value === "string" && value !== "") - return ; + return ( + + + + + +  {value} + + + + + + ); return ( <> diff --git a/src/components/fields/MultiSelect/index.tsx b/src/components/fields/MultiSelect/index.tsx index b6f3a853..19a5cecd 100644 --- a/src/components/fields/MultiSelect/index.tsx +++ b/src/components/fields/MultiSelect/index.tsx @@ -1,15 +1,13 @@ import { lazy } from "react"; import { IFieldConfig, FieldType } from "@src/components/fields/types"; -import withPopoverCell from "@src/components/fields/_withTableCell/withPopoverCell"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; import { MultiSelect as MultiSelectIcon } from "@src/assets/icons"; -import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull"; -import InlineCell from "./InlineCell"; -import NullEditor from "@src/components/Table/editors/NullEditor"; +import DisplayCell from "./DisplayCell"; import { filterOperators } from "./Filter"; -const PopoverCell = lazy( - () => - import("./PopoverCell" /* webpackChunkName: "PopoverCell-MultiSelect" */) + +const EditorCell = lazy( + () => import("./EditorCell" /* webpackChunkName: "EditorCell-MultiSelect" */) ); const SideDrawerField = lazy( () => @@ -34,11 +32,10 @@ export const config: IFieldConfig = { icon: , description: "Multiple values from predefined options. Options are searchable and users can optionally input custom values.", - TableCell: withPopoverCell(BasicCell, InlineCell, PopoverCell, { - anchorOrigin: { horizontal: "left", vertical: "bottom" }, - transparent: true, + TableCell: withRenderTableCell(DisplayCell, EditorCell, "popover", { + disablePadding: true, + transparentPopover: false, }), - TableEditor: NullEditor as any, SideDrawerField, settings: Settings, csvImportParser: (v) => { diff --git a/src/components/fields/Number/BasicCell.tsx b/src/components/fields/Number/BasicCell.tsx deleted file mode 100644 index 71d6be89..00000000 --- a/src/components/fields/Number/BasicCell.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { IBasicCellProps } from "@src/components/fields/types"; - -export default function Number_({ value }: IBasicCellProps) { - return <>{`${value ?? ""}`}; -} diff --git a/src/components/fields/Number/DisplayCell.tsx b/src/components/fields/Number/DisplayCell.tsx new file mode 100644 index 00000000..5256a741 --- /dev/null +++ b/src/components/fields/Number/DisplayCell.tsx @@ -0,0 +1,5 @@ +import { IDisplayCellProps } from "@src/components/fields/types"; + +export default function Number_({ value }: IDisplayCellProps) { + return <>{`${value ?? ""}`}; +} diff --git a/src/components/fields/Number/EditorCell.tsx b/src/components/fields/Number/EditorCell.tsx new file mode 100644 index 00000000..e3594eac --- /dev/null +++ b/src/components/fields/Number/EditorCell.tsx @@ -0,0 +1,12 @@ +import type { IEditorCellProps } from "@src/components/fields/types"; +import EditorCellTextField from "@src/components/Table/TableCell/EditorCellTextField"; + +export default function Number_(props: IEditorCellProps) { + return ( + props.onChange(Number(v))} + /> + ); +} diff --git a/src/components/fields/Number/index.tsx b/src/components/fields/Number/index.tsx index a7fdc94c..2f04cadd 100644 --- a/src/components/fields/Number/index.tsx +++ b/src/components/fields/Number/index.tsx @@ -1,12 +1,12 @@ import { lazy } from "react"; import { IFieldConfig, FieldType } from "@src/components/fields/types"; -import withBasicCell from "@src/components/fields/_withTableCell/withBasicCell"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; import { Number as NumberIcon } from "@src/assets/icons"; -import BasicCell from "./BasicCell"; -import TextEditor from "@src/components/Table/editors/TextEditor"; +import DisplayCell from "./DisplayCell"; +import EditorCell from "./EditorCell"; import { filterOperators } from "./Filter"; -import BasicContextMenuActions from "@src/components/fields/_BasicCell/BasicCellContextMenuActions"; +import BasicContextMenuActions from "@src/components/Table/ContextMenu/BasicCellContextMenuActions"; const SideDrawerField = lazy( () => import("./SideDrawerField" /* webpackChunkName: "SideDrawerField-Number" */) @@ -22,8 +22,7 @@ export const config: IFieldConfig = { icon: , description: "Numeric value.", contextMenuActions: BasicContextMenuActions, - TableCell: withBasicCell(BasicCell), - TableEditor: TextEditor, + TableCell: withRenderTableCell(DisplayCell, EditorCell), SideDrawerField, filter: { operators: filterOperators, diff --git a/src/components/fields/Percentage/BasicCell.tsx b/src/components/fields/Percentage/BasicCell.tsx deleted file mode 100644 index 5f6228aa..00000000 --- a/src/components/fields/Percentage/BasicCell.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { IBasicCellProps } from "@src/components/fields/types"; - -import { useTheme } from "@mui/material"; - -export default function Percentage({ value }: IBasicCellProps) { - const theme = useTheme(); - - if (value === null || value === undefined) return null; - - const percentage = typeof value === "number" ? value : 0; - return ( -
- {Math.round(percentage * 100)}% -
- ); -} diff --git a/src/components/fields/Percentage/TableCell.tsx b/src/components/fields/Percentage/DisplayCell.tsx similarity index 85% rename from src/components/fields/Percentage/TableCell.tsx rename to src/components/fields/Percentage/DisplayCell.tsx index 29174a39..532103e4 100644 --- a/src/components/fields/Percentage/TableCell.tsx +++ b/src/components/fields/Percentage/DisplayCell.tsx @@ -1,9 +1,9 @@ -import { IHeavyCellProps } from "@src/components/fields/types"; +import { IDisplayCellProps } from "@src/components/fields/types"; import { useTheme } from "@mui/material"; import { resultColorsScale } from "@src/utils/color"; -export default function Percentage({ column, value }: IHeavyCellProps) { +export default function Percentage({ column, value }: IDisplayCellProps) { const theme = useTheme(); const { colors } = (column as any).config; diff --git a/src/components/fields/Percentage/EditorCell.tsx b/src/components/fields/Percentage/EditorCell.tsx new file mode 100644 index 00000000..1e3a7e96 --- /dev/null +++ b/src/components/fields/Percentage/EditorCell.tsx @@ -0,0 +1,13 @@ +import type { IEditorCellProps } from "@src/components/fields/types"; +import EditorCellTextField from "@src/components/Table/TableCell/EditorCellTextField"; + +export default function Percentage(props: IEditorCellProps) { + return ( + props.onChange(Number(v) / 100)} + /> + ); +} diff --git a/src/components/fields/Percentage/index.tsx b/src/components/fields/Percentage/index.tsx index f353774f..b571b50f 100644 --- a/src/components/fields/Percentage/index.tsx +++ b/src/components/fields/Percentage/index.tsx @@ -1,19 +1,12 @@ import { lazy } from "react"; import { IFieldConfig, FieldType } from "@src/components/fields/types"; -import withHeavyCell from "@src/components/fields/_withTableCell/withHeavyCell"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; import { Percentage as PercentageIcon } from "@src/assets/icons"; -import TextEditor from "@src/components/Table/editors/TextEditor"; +import DisplayCell from "./DisplayCell"; +import EditorCell from "./EditorCell"; import { filterOperators } from "@src/components/fields/Number/Filter"; -import BasicContextMenuActions from "@src/components/fields/_BasicCell/BasicCellContextMenuActions"; - -const BasicCell = lazy( - () => import("./BasicCell" /* webpackChunkName: "BasicCell-Percentage" */) -); - -const TableCell = lazy( - () => import("./TableCell" /* webpackChunkName: "TableCell-Percentage" */) -); +import BasicContextMenuActions from "@src/components/Table/ContextMenu/BasicCellContextMenuActions"; const SideDrawerField = lazy( () => @@ -37,8 +30,7 @@ export const config: IFieldConfig = { requireConfiguration: true, description: "Percentage stored as a number between 0 and 1.", contextMenuActions: BasicContextMenuActions, - TableCell: withHeavyCell(BasicCell, TableCell), - TableEditor: TextEditor, + TableCell: withRenderTableCell(DisplayCell, EditorCell), SideDrawerField, settings: Settings, filter: { diff --git a/src/components/fields/Phone/EditorCell.tsx b/src/components/fields/Phone/EditorCell.tsx new file mode 100644 index 00000000..4db1bf0c --- /dev/null +++ b/src/components/fields/Phone/EditorCell.tsx @@ -0,0 +1,6 @@ +import type { IEditorCellProps } from "@src/components/fields/types"; +import EditorCellTextField from "@src/components/Table/TableCell/EditorCellTextField"; + +export default function Phone(props: IEditorCellProps) { + return ; +} diff --git a/src/components/fields/Phone/index.tsx b/src/components/fields/Phone/index.tsx index 96115973..0d72c31a 100644 --- a/src/components/fields/Phone/index.tsx +++ b/src/components/fields/Phone/index.tsx @@ -1,12 +1,12 @@ import { lazy } from "react"; import { IFieldConfig, FieldType } from "@src/components/fields/types"; -import withBasicCell from "@src/components/fields/_withTableCell/withBasicCell"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; import PhoneIcon from "@mui/icons-material/PhoneOutlined"; -import BasicCell from "@src/components/fields/_BasicCell/BasicCellValue"; -import TextEditor from "@src/components/Table/editors/TextEditor"; +import DisplayCell from "@src/components/fields/ShortText/DisplayCell"; +import EditorCell from "./EditorCell"; import { filterOperators } from "@src/components/fields/ShortText/Filter"; -import BasicContextMenuActions from "@src/components/fields/_BasicCell/BasicCellContextMenuActions"; +import BasicContextMenuActions from "@src/components/Table/ContextMenu/BasicCellContextMenuActions"; const SideDrawerField = lazy( () => @@ -23,8 +23,7 @@ export const config: IFieldConfig = { icon: , description: "Phone number stored as text. Not validated.", contextMenuActions: BasicContextMenuActions, - TableCell: withBasicCell(BasicCell), - TableEditor: TextEditor, + TableCell: withRenderTableCell(DisplayCell, EditorCell), SideDrawerField, filter: { operators: filterOperators, diff --git a/src/components/fields/Rating/DisplayCell.tsx b/src/components/fields/Rating/DisplayCell.tsx new file mode 100644 index 00000000..b5d28471 --- /dev/null +++ b/src/components/fields/Rating/DisplayCell.tsx @@ -0,0 +1,52 @@ +import React, { forwardRef } from "react"; +import { IDisplayCellProps } from "@src/components/fields/types"; + +import MuiRating, { RatingProps as MuiRatingProps } from "@mui/material/Rating"; +import Icon from "./Icon"; + +export const Rating = forwardRef(function Rating( + { + _rowy_ref, + column, + value, + disabled, + onChange, + }: IDisplayCellProps & Pick, + ref: React.Ref +) { + // Set max and precision from config + const { + max, + precision, + }: { + max: number; + precision: number; + } = { + max: 5, + precision: 1, + ...column.config, + }; + + return ( + e.stopPropagation()} + onKeyDown={(e) => { + if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(e.key)) + e.stopPropagation(); + }} + icon={} + emptyIcon={} + size="small" + readOnly={disabled} + max={max} + precision={precision} + sx={{ mx: -0.25 }} + /> + ); +}); + +export default Rating; diff --git a/src/components/fields/Rating/EditorCell.tsx b/src/components/fields/Rating/EditorCell.tsx new file mode 100644 index 00000000..89a48f2d --- /dev/null +++ b/src/components/fields/Rating/EditorCell.tsx @@ -0,0 +1,31 @@ +import { useRef, useEffect } from "react"; +import { IEditorCellProps } from "@src/components/fields/types"; +import DisplayCell from "./DisplayCell"; + +export default function Rating({ + onChange, + onSubmit, + tabIndex, + ...props +}: IEditorCellProps) { + const ref = useRef(null); + useEffect(() => { + const el = ref.current; + if (!el) return; + const inputs = el.querySelectorAll("input"); + for (const input of inputs) + input.setAttribute("tabindex", tabIndex.toString()); + }, [tabIndex]); + + return ( + { + onChange(newValue); + onSubmit(); + }} + ref={ref} + /> + ); +} diff --git a/src/components/fields/Rating/Icon.tsx b/src/components/fields/Rating/Icon.tsx index c0250c54..a34bdc24 100644 --- a/src/components/fields/Rating/Icon.tsx +++ b/src/components/fields/Rating/Icon.tsx @@ -1,28 +1,30 @@ import RatingIcon from "@mui/icons-material/Star"; -import RatingOutlineIcon from "@mui/icons-material/StarBorder" +import RatingOutlineIcon from "@mui/icons-material/StarBorder"; import { get } from "lodash-es"; - -export interface IIconProps{ - config: any, - isEmpty: boolean +export interface IIconProps { + config: any; + isEmpty: boolean; } -export default function Icon({config, isEmpty} : IIconProps) { -if (isEmpty) { - return getStateOutline(config) - } else { - return getStateIcon(config) +export default function Icon({ config, isEmpty }: IIconProps) { + if (isEmpty) { + return getStateOutline(config); + } else { + return getStateIcon(config); + } } -} const getStateIcon = (config: any) => { - // only use the config to get the custom rating icon if enabled via toggle - if (!get(config, "customIcons.enabled")) { return } - console.log(get(config, "customIcons.rating")) - return get(config, "customIcons.rating") || ; - }; + // only use the config to get the custom rating icon if enabled via toggle + if (!get(config, "customIcons.enabled")) { + return ; + } + return get(config, "customIcons.rating") || ; +}; const getStateOutline = (config: any) => { - if (!get(config, "customIcons.enabled")) { return } - return get(config, "customIcons.rating") || ; - } \ No newline at end of file + if (!get(config, "customIcons.enabled")) { + return ; + } + return get(config, "customIcons.rating") || ; +}; diff --git a/src/components/fields/Rating/Settings.tsx b/src/components/fields/Rating/Settings.tsx index 5bffda3d..7bf99d5b 100644 --- a/src/components/fields/Rating/Settings.tsx +++ b/src/components/fields/Rating/Settings.tsx @@ -1,11 +1,17 @@ import { ISettingsProps } from "@src/components/fields/types"; -import { InputLabel, TextField, Grid, FormControlLabel, Checkbox, Stack } from "@mui/material"; +import { + InputLabel, + TextField, + Grid, + FormControlLabel, + Checkbox, + Stack, +} from "@mui/material"; import ToggleButton from "@mui/material/ToggleButton"; import ToggleButtonGroup from "@mui/material/ToggleButtonGroup"; import MuiRating from "@mui/material/Rating"; import { get } from "lodash-es"; -import Icon from "./Icon" - +import Icon from "./Icon"; export default function Settings({ onChange, config }: ISettingsProps) { return ( @@ -18,8 +24,10 @@ export default function Settings({ onChange, config }: ISettingsProps) { fullWidth error={false} onChange={(e) => { - let input = parseInt(e.target.value) || 0 - if (input > 20) { input = 20 } + let input = parseInt(e.target.value) || 0; + if (input > 20) { + input = 20; + } onChange("max")(input); }} inputProps={{ min: 1, max: 20 }} @@ -69,15 +77,14 @@ export default function Settings({ onChange, config }: ISettingsProps) { - onChange("customIcons.rating")(e.target.value) - } + onChange={(e) => onChange("customIcons.rating")(e.target.value)} label="Custom icon preview:" className="labelHorizontal" inputProps={{ style: { width: "2ch" } }} /> - e.stopPropagation()} icon={} @@ -88,9 +95,8 @@ export default function Settings({ onChange, config }: ISettingsProps) { sx={{ pt: 0.5 }} /> - )} ); -} \ No newline at end of file +} diff --git a/src/components/fields/Rating/SideDrawerField.tsx b/src/components/fields/Rating/SideDrawerField.tsx index 8011bf8e..24f1a8d6 100644 --- a/src/components/fields/Rating/SideDrawerField.tsx +++ b/src/components/fields/Rating/SideDrawerField.tsx @@ -2,10 +2,8 @@ import { ISideDrawerFieldProps } from "@src/components/fields/types"; import { Grid } from "@mui/material"; import { Rating as MuiRating } from "@mui/material"; -import "@mui/lab"; import { fieldSx } from "@src/components/SideDrawer/utils"; -import Icon from "./Icon" - +import Icon from "./Icon"; export default function Rating({ column, @@ -25,11 +23,10 @@ export default function Rating({ value={typeof value === "number" ? value : 0} disabled={disabled} onChange={(_, newValue) => { - console.log("onChange", newValue); onChange(newValue); onSubmit(); }} - icon={} + icon={} emptyIcon={} size="small" max={max} diff --git a/src/components/fields/Rating/TableCell.tsx b/src/components/fields/Rating/TableCell.tsx deleted file mode 100644 index 368d8fbe..00000000 --- a/src/components/fields/Rating/TableCell.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { IHeavyCellProps } from "@src/components/fields/types"; - -import MuiRating from "@mui/material/Rating"; -import Icon from "./Icon" - - -export default function Rating({ - row, - column, - value, - onSubmit, - disabled, -}: IHeavyCellProps) { - // Set max and precision from config - const { - max, - precision, - }: { - max: number; - precision: number; - } = { - max: 5, - precision: 1, - ...column.config, - }; - - return ( - e.stopPropagation()} - icon={} - size="small" - disabled={disabled} - onChange={(_, newValue) => onSubmit(newValue)} - emptyIcon={} - max={max} - precision={precision} - sx={{ mx: -0.25 }} - /> - ); -} diff --git a/src/components/fields/Rating/index.tsx b/src/components/fields/Rating/index.tsx index 3cbc5108..3712ffe7 100644 --- a/src/components/fields/Rating/index.tsx +++ b/src/components/fields/Rating/index.tsx @@ -1,15 +1,12 @@ import { lazy } from "react"; import { IFieldConfig, FieldType } from "@src/components/fields/types"; -import withHeavyCell from "@src/components/fields/_withTableCell/withHeavyCell"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; import RatingIcon from "@mui/icons-material/StarBorder"; -import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull"; -import NullEditor from "@src/components/Table/editors/NullEditor"; +import DisplayCell from "./DisplayCell"; +import EditorCell from "./EditorCell"; import { filterOperators } from "@src/components/fields/Number/Filter"; -const TableCell = lazy( - () => import("./TableCell" /* webpackChunkName: "TableCell-Rating" */) -); const SideDrawerField = lazy( () => import("./SideDrawerField" /* webpackChunkName: "SideDrawerField-Rating" */) @@ -29,8 +26,7 @@ export const config: IFieldConfig = { requireConfiguration: true, description: "Rating displayed as stars. Max stars is configurable, default: 5 stars.", - TableCell: withHeavyCell(BasicCell, TableCell), - TableEditor: NullEditor as any, + TableCell: withRenderTableCell(DisplayCell, EditorCell, "inline"), settings: Settings, SideDrawerField, filter: { diff --git a/src/components/fields/Reference/BasicCell.tsx b/src/components/fields/Reference/DisplayCell.tsx similarity index 80% rename from src/components/fields/Reference/BasicCell.tsx rename to src/components/fields/Reference/DisplayCell.tsx index b75b62d2..98705d43 100644 --- a/src/components/fields/Reference/BasicCell.tsx +++ b/src/components/fields/Reference/DisplayCell.tsx @@ -1,12 +1,12 @@ import { useAtom } from "jotai"; -import { IBasicCellProps } from "@src/components/fields/types"; +import { IDisplayCellProps } from "@src/components/fields/types"; import { Stack, IconButton } from "@mui/material"; import LaunchIcon from "@mui/icons-material/Launch"; import { projectScope, projectIdAtom } from "@src/atoms/projectScope"; -export default function Reference({ value }: IBasicCellProps) { +export default function Reference({ value, tabIndex }: IDisplayCellProps) { const [projectId] = useAtom(projectIdAtom, projectScope); const path = value?.path ?? ""; @@ -17,8 +17,7 @@ export default function Reference({ value }: IBasicCellProps) { direction="row" alignItems="center" justifyContent="space-between" - className="cell-collapse-padding" - sx={{ p: "var(--cell-padding)", pr: 0.5 }} + sx={{ p: "var(--cell-padding)", pr: 0.5, width: "100%" }} >
{path}
@@ -33,6 +32,7 @@ export default function Reference({ value }: IBasicCellProps) { aria-label="Open in Firebase Console" className="row-hover-iconButton" style={{ flexShrink: 0 }} + tabIndex={tabIndex} > diff --git a/src/components/fields/Reference/EditorCell.tsx b/src/components/fields/Reference/EditorCell.tsx index 12c5e6b2..07115fb8 100644 --- a/src/components/fields/Reference/EditorCell.tsx +++ b/src/components/fields/Reference/EditorCell.tsx @@ -1,101 +1,56 @@ -import { useRef, useLayoutEffect } from "react"; -import { useAtom, useSetAtom } from "jotai"; -import { EditorProps } from "react-data-grid"; -import { get } from "lodash-es"; -import { useSnackbar } from "notistack"; +import { useState } from "react"; +import { useAtom } from "jotai"; +import { doc, deleteField } from "firebase/firestore"; -import { TextField } from "@mui/material"; +import type { IEditorCellProps } from "@src/components/fields/types"; +import EditorCellTextField from "@src/components/Table/TableCell/EditorCellTextField"; + +import { InputAdornment, Tooltip } from "@mui/material"; +import ErrorIcon from "@mui/icons-material/ErrorOutline"; import { projectScope } from "@src/atoms/projectScope"; import { firebaseDbAtom } from "@src/sources/ProjectSourceFirebase"; -import { tableScope, updateFieldAtom } from "@src/atoms/tableScope"; -import { doc, deleteField } from "firebase/firestore"; -/** WARNING: THIS DOES NOT WORK IN REACT 18 STRICT MODE */ -export default function TextEditor({ row, column }: EditorProps) { +export default function Reference({ + value, + ...props +}: IEditorCellProps>) { const [firebaseDb] = useAtom(firebaseDbAtom, projectScope); - const updateField = useSetAtom(updateFieldAtom, tableScope); - const { enqueueSnackbar } = useSnackbar(); - const inputRef = useRef(null); - - // WARNING: THIS DOES NOT WORK IN REACT 18 STRICT MODE - useLayoutEffect(() => { - const inputElement = inputRef.current; - return () => { - const newValue = inputElement?.value; - if (newValue !== undefined && newValue !== "") { - try { - const refValue = doc(firebaseDb, newValue); - - updateField({ - path: row._rowy_ref.path, - fieldName: column.key, - value: refValue, - }); - } catch (e: any) { - enqueueSnackbar(`Invalid path: ${e.message}`, { variant: "error" }); - } - } else { - updateField({ - path: row._rowy_ref.path, - fieldName: column.key, - value: deleteField(), - }); - } - }; - }, [column.key, row._rowy_ref.path, updateField]); - - const defaultValue = get(row, column.key)?.path ?? ""; - const { maxLength } = (column as any).config; + const [localValue, setLocalValue] = useState( + Boolean(value) && "path" in value && typeof value.path === "string" + ? value.path + : "" + ); + const [error, setError] = useState(""); return ( - theme.typography.body2.lineHeight, - maxHeight: "100%", - boxSizing: "border-box", - py: 3 / 8, - }, - }} - // InputProps={{ - // endAdornment: - // (column as any).type === FieldType.percentage ? "%" : undefined, - // }} - autoFocus - onKeyDown={(e) => { - if (e.key === "ArrowLeft" || e.key === "ArrowRight") { - e.stopPropagation(); + { + if (newValue !== undefined && newValue !== "") { + try { + const refValue = doc(firebaseDb, newValue); + props.onChange(refValue); + setError(""); + } catch (e: any) { + setError(e.message); + } + } else { + props.onChange(deleteField() as any); } - if (e.key === "Escape") { - (e.target as any).value = defaultValue; - } + setLocalValue(newValue); + }} + InputProps={{ + endAdornment: error && ( + + + + + + ), }} /> ); diff --git a/src/components/fields/Reference/index.tsx b/src/components/fields/Reference/index.tsx index 9d5b0f43..c4fdce52 100644 --- a/src/components/fields/Reference/index.tsx +++ b/src/components/fields/Reference/index.tsx @@ -1,24 +1,18 @@ import { lazy } from "react"; import { IFieldConfig, FieldType } from "@src/components/fields/types"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; import { Reference } from "@src/assets/icons"; -//import InlineCell from "./InlineCell"; -import BasicCell from "./BasicCell"; +import DisplayCell from "./DisplayCell"; +import EditorCell from "./EditorCell"; import { filterOperators } from "@src/components/fields/ShortText/Filter"; -import withBasicCell from "@src/components/fields/_withTableCell/withBasicCell"; -const EditorCell = lazy( - () => import("./EditorCell" /* webpackChunkName: "EditorCell-Reference" */) -); const SideDrawerField = lazy( () => import( "./SideDrawerField" /* webpackChunkName: "SideDrawerField-Reference" */ ) ); -// const Settings = lazy( -// () => import("./Settings" /* webpackChunkName: "Settings-Reference" */) -// ); export const config: IFieldConfig = { type: FieldType.reference, @@ -29,10 +23,10 @@ export const config: IFieldConfig = { initializable: true, icon: , description: "Firestore document reference", - TableCell: withBasicCell(BasicCell), - TableEditor: EditorCell, + TableCell: withRenderTableCell(DisplayCell, EditorCell, "focus", { + disablePadding: true, + }), SideDrawerField, - //settings: Settings, filter: { operators: filterOperators }, }; export default config; diff --git a/src/components/fields/RichText/DisplayCell.tsx b/src/components/fields/RichText/DisplayCell.tsx new file mode 100644 index 00000000..aceb4d5d --- /dev/null +++ b/src/components/fields/RichText/DisplayCell.tsx @@ -0,0 +1,103 @@ +import { IDisplayCellProps } from "@src/components/fields/types"; +// import { useAtom } from "jotai"; + +import { + // styled, + useTheme, + // Tooltip, + // TooltipProps, + // tooltipClasses, + // Fade, +} from "@mui/material"; +import RenderedHtml from "@src/components/RenderedHtml"; + +// import { tableScope, tableSchemaAtom } from "@src/atoms/tableScope"; +// import { DEFAULT_ROW_HEIGHT } from "@src/components/Table"; + +// type StylesProps = { width: number; rowHeight: number }; + +// const StyledTooltip = styled( +// ({ className, width, rowHeight, ...props }: TooltipProps & StylesProps) => ( +// +// ) +// )(({ theme, width, rowHeight }) => ({ +// [`& .${tooltipClasses.tooltip}`]: { +// margin: 0, +// marginTop: `-${rowHeight - 1}px !important`, +// padding: theme.spacing(3 / 8, 1.25), + +// width: width - 1, +// maxWidth: "none", +// minHeight: rowHeight - 1, +// overflowX: "hidden", + +// background: theme.palette.background.paper, +// borderRadius: 0, +// boxShadow: `0 0 0 1px ${theme.palette.divider}, ${theme.shadows[4]}`, +// color: theme.palette.text.primary, + +// display: "flex", +// alignItems: "center", +// }, +// })); + +export default function RichText({ value, tabIndex }: IDisplayCellProps) { + // const [tableSchema] = useAtom(tableSchemaAtom, tableScope); + + const theme = useTheme(); + + if (!value) return null; + + const content = ( + + ); + + // temp disable tooltip, which causes performance issues + return content; + + // return ( + // + //
+ // {content} + //
+ //
+ // ); +} diff --git a/src/components/fields/RichText/TableCell.tsx b/src/components/fields/RichText/TableCell.tsx deleted file mode 100644 index 354a9a76..00000000 --- a/src/components/fields/RichText/TableCell.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { IHeavyCellProps } from "@src/components/fields/types"; -import { useAtom } from "jotai"; - -import { - styled, - useTheme, - Tooltip, - TooltipProps, - tooltipClasses, - Fade, -} from "@mui/material"; -import RenderedHtml from "@src/components/RenderedHtml"; - -import { tableScope, tableSchemaAtom } from "@src/atoms/tableScope"; -import { DEFAULT_ROW_HEIGHT } from "@src/components/Table"; - -type StylesProps = { width: number; rowHeight: number }; - -const StyledTooltip = styled( - ({ className, width, rowHeight, ...props }: TooltipProps & StylesProps) => ( - - ) -)(({ theme, width, rowHeight }) => ({ - [`& .${tooltipClasses.tooltip}`]: { - margin: 0, - marginTop: `-${rowHeight - 1}px !important`, - padding: theme.spacing(3 / 8, 1.25), - - width: width - 1, - maxWidth: "none", - minHeight: rowHeight - 1, - overflowX: "hidden", - - background: theme.palette.background.paper, - borderRadius: 0, - boxShadow: `0 0 0 1px ${theme.palette.divider}, ${theme.shadows[4]}`, - color: theme.palette.text.primary, - - display: "flex", - alignItems: "center", - }, -})); - -export default function RichText({ column, value }: IHeavyCellProps) { - const [tableSchema] = useAtom(tableSchemaAtom, tableScope); - - const theme = useTheme(); - - if (!value) return null; - - const content = ( - - ); - - return ( - -
- {content} -
-
- ); -} diff --git a/src/components/fields/RichText/index.tsx b/src/components/fields/RichText/index.tsx index 890f1d68..c9c1ddbe 100644 --- a/src/components/fields/RichText/index.tsx +++ b/src/components/fields/RichText/index.tsx @@ -1,15 +1,11 @@ import { lazy } from "react"; import { IFieldConfig, FieldType } from "@src/components/fields/types"; -import withHeavyCell from "@src/components/fields/_withTableCell/withHeavyCell"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; import RichTextIcon from "@mui/icons-material/TextFormat"; -import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull"; -import withSideDrawerEditor from "@src/components/Table/editors/withSideDrawerEditor"; -import BasicContextMenuActions from "@src/components/fields/_BasicCell/BasicCellContextMenuActions"; +import DisplayCell from "./DisplayCell"; +import BasicContextMenuActions from "@src/components/Table/ContextMenu/BasicCellContextMenuActions"; -const TableCell = lazy( - () => import("./TableCell" /* webpackChunkName: "TableCell-RichText" */) -); const SideDrawerField = lazy( () => import( @@ -27,8 +23,7 @@ export const config: IFieldConfig = { icon: , description: "HTML edited with a rich text editor.", contextMenuActions: BasicContextMenuActions, - TableCell: withHeavyCell(BasicCell, TableCell), - TableEditor: withSideDrawerEditor(TableCell), + TableCell: withRenderTableCell(DisplayCell, SideDrawerField, "popover"), SideDrawerField, }; export default config; diff --git a/src/components/fields/ShortText/DisplayCell.tsx b/src/components/fields/ShortText/DisplayCell.tsx new file mode 100644 index 00000000..f29adca6 --- /dev/null +++ b/src/components/fields/ShortText/DisplayCell.tsx @@ -0,0 +1,6 @@ +import { IDisplayCellProps } from "@src/components/fields/types"; + +export default function DisplayCellValue({ value }: IDisplayCellProps) { + if (typeof value !== "string") return null; + return <>{value}; +} diff --git a/src/components/fields/ShortText/EditorCell.tsx b/src/components/fields/ShortText/EditorCell.tsx new file mode 100644 index 00000000..123d7892 --- /dev/null +++ b/src/components/fields/ShortText/EditorCell.tsx @@ -0,0 +1,6 @@ +import type { IEditorCellProps } from "@src/components/fields/types"; +import EditorCellTextField from "@src/components/Table/TableCell/EditorCellTextField"; + +export default function ShortText(props: IEditorCellProps) { + return ; +} diff --git a/src/components/fields/ShortText/index.tsx b/src/components/fields/ShortText/index.tsx index 88ef7a20..1288f9bb 100644 --- a/src/components/fields/ShortText/index.tsx +++ b/src/components/fields/ShortText/index.tsx @@ -1,13 +1,13 @@ import { lazy } from "react"; import { IFieldConfig, FieldType } from "@src/components/fields/types"; -import withBasicCell from "@src/components/fields/_withTableCell/withBasicCell"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; import ShortTextIcon from "@mui/icons-material/ShortText"; -import BasicCell from "@src/components/fields/_BasicCell/BasicCellValue"; -import TextEditor from "@src/components/Table/editors/TextEditor"; +import DisplayCell from "@src/components/fields/ShortText/DisplayCell"; +import EditorCell from "./EditorCell"; import { filterOperators } from "./Filter"; -import BasicContextMenuActions from "@src/components/fields/_BasicCell/BasicCellContextMenuActions"; +import BasicContextMenuActions from "@src/components/Table/ContextMenu/BasicCellContextMenuActions"; const SideDrawerField = lazy( () => @@ -30,8 +30,7 @@ export const config: IFieldConfig = { icon: , description: "Text displayed on a single line.", contextMenuActions: BasicContextMenuActions, - TableCell: withBasicCell(BasicCell), - TableEditor: TextEditor, + TableCell: withRenderTableCell(DisplayCell, EditorCell), SideDrawerField, settings: Settings, filter: { diff --git a/src/components/fields/SingleSelect/DisplayCell.tsx b/src/components/fields/SingleSelect/DisplayCell.tsx new file mode 100644 index 00000000..ece750d1 --- /dev/null +++ b/src/components/fields/SingleSelect/DisplayCell.tsx @@ -0,0 +1,46 @@ +import { IDisplayCellProps } from "@src/components/fields/types"; + +import { ButtonBase } from "@mui/material"; +import { ChevronDown } from "@src/assets/icons"; + +import { sanitiseValue } from "./utils"; + +export default function SingleSelect({ + value, + showPopoverCell, + disabled, + tabIndex, +}: IDisplayCellProps) { + const rendered = ( +
+ {sanitiseValue(value)} +
+ ); + + if (disabled) return rendered; + + return ( + showPopoverCell(true)} + style={{ + width: "100%", + height: "100%", + font: "inherit", + color: "inherit !important", + letterSpacing: "inherit", + textAlign: "inherit", + justifyContent: "flex-start", + }} + tabIndex={tabIndex} + > + {rendered} + + + ); +} diff --git a/src/components/fields/MultiSelect/PopoverCell.tsx b/src/components/fields/SingleSelect/EditorCell.tsx similarity index 55% rename from src/components/fields/MultiSelect/PopoverCell.tsx rename to src/components/fields/SingleSelect/EditorCell.tsx index 50c0ad05..0fd1c317 100644 --- a/src/components/fields/MultiSelect/PopoverCell.tsx +++ b/src/components/fields/SingleSelect/EditorCell.tsx @@ -1,25 +1,26 @@ -import { IPopoverCellProps } from "@src/components/fields/types"; +import { IEditorCellProps } from "@src/components/fields/types"; import MultiSelectComponent from "@rowy/multiselect"; import { sanitiseValue } from "./utils"; -export default function MultiSelect({ +export default function SingleSelect({ value, + onChange, onSubmit, column, parentRef, showPopoverCell, disabled, -}: IPopoverCellProps) { +}: IEditorCellProps) { const config = column.config ?? {}; return ( showPopoverCell(false)} + onClose={() => { + showPopoverCell(false); + onSubmit(); + }} /> ); } diff --git a/src/components/fields/SingleSelect/InlineCell.tsx b/src/components/fields/SingleSelect/InlineCell.tsx deleted file mode 100644 index d133b1cc..00000000 --- a/src/components/fields/SingleSelect/InlineCell.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { forwardRef } from "react"; -import { IPopoverInlineCellProps } from "@src/components/fields/types"; - -import { ButtonBase } from "@mui/material"; -import { ChevronDown } from "@src/assets/icons"; - -import { sanitiseValue } from "./utils"; - -export const SingleSelect = forwardRef(function SingleSelect( - { value, showPopoverCell, disabled }: IPopoverInlineCellProps, - ref: React.Ref -) { - return ( - showPopoverCell(true)} - ref={ref} - disabled={disabled} - className="cell-collapse-padding" - style={{ - padding: "var(--cell-padding)", - paddingRight: 0, - height: "100%", - - font: "inherit", - color: "inherit !important", - letterSpacing: "inherit", - textAlign: "inherit", - justifyContent: "flex-start", - }} - > -
- {sanitiseValue(value)} -
- - {!disabled && ( - - )} -
- ); -}); - -export default SingleSelect; diff --git a/src/components/fields/SingleSelect/PopoverCell.tsx b/src/components/fields/SingleSelect/PopoverCell.tsx deleted file mode 100644 index 4aafda6b..00000000 --- a/src/components/fields/SingleSelect/PopoverCell.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { IPopoverCellProps } from "@src/components/fields/types"; - -import MultiSelect_ from "@rowy/multiselect"; - -import { sanitiseValue } from "./utils"; - -export default function SingleSelect({ - value, - onSubmit, - column, - parentRef, - showPopoverCell, - disabled, -}: IPopoverCellProps) { - const config = column.config ?? {}; - - return ( - showPopoverCell(false)} - /> - ); -} diff --git a/src/components/fields/SingleSelect/Settings.tsx b/src/components/fields/SingleSelect/Settings.tsx index 103bb2cd..2fc908b9 100644 --- a/src/components/fields/SingleSelect/Settings.tsx +++ b/src/components/fields/SingleSelect/Settings.tsx @@ -93,7 +93,7 @@ export default function Settings({ onChange, config }: ISettingsProps) { onChange={(e) => { setNewOption(e.target.value); }} - onKeyPress={(e: any) => { + onKeyDown={(e: any) => { if (e.key === "Enter") { handleAdd(); } diff --git a/src/components/fields/SingleSelect/index.tsx b/src/components/fields/SingleSelect/index.tsx index 9d396280..83a23e3e 100644 --- a/src/components/fields/SingleSelect/index.tsx +++ b/src/components/fields/SingleSelect/index.tsx @@ -1,17 +1,12 @@ import { lazy } from "react"; import { IFieldConfig, FieldType } from "@src/components/fields/types"; -import withPopoverCell from "@src/components/fields/_withTableCell/withPopoverCell"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; import { SingleSelect as SingleSelectIcon } from "@src/assets/icons"; -import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull"; -import InlineCell from "./InlineCell"; -import NullEditor from "@src/components/Table/editors/NullEditor"; +import DisplayCell from "./DisplayCell"; +import EditorCell from "./EditorCell"; import { filterOperators } from "@src/components/fields/ShortText/Filter"; -const PopoverCell = lazy( - () => - import("./PopoverCell" /* webpackChunkName: "PopoverCell-SingleSelect" */) -); const SideDrawerField = lazy( () => import( @@ -32,11 +27,10 @@ export const config: IFieldConfig = { icon: , description: "Single value from predefined options. Options are searchable and users can optionally input custom values.", - TableCell: withPopoverCell(BasicCell, InlineCell, PopoverCell, { - anchorOrigin: { horizontal: "left", vertical: "bottom" }, - transparent: true, + TableCell: withRenderTableCell(DisplayCell, EditorCell, "popover", { + disablePadding: true, + transparentPopover: true, }), - TableEditor: NullEditor as any, SideDrawerField, settings: Settings, filter: { operators: filterOperators }, diff --git a/src/components/fields/Slider/TableCell.tsx b/src/components/fields/Slider/DisplayCell.tsx similarity index 89% rename from src/components/fields/Slider/TableCell.tsx rename to src/components/fields/Slider/DisplayCell.tsx index 80699449..ae8cc736 100644 --- a/src/components/fields/Slider/TableCell.tsx +++ b/src/components/fields/Slider/DisplayCell.tsx @@ -1,10 +1,10 @@ -import { IHeavyCellProps } from "@src/components/fields/types"; +import { IDisplayCellProps } from "@src/components/fields/types"; import { Grid, Box } from "@mui/material"; import { resultColorsScale } from "@src/utils/color"; -export default function Slider({ column, value }: IHeavyCellProps) { +export default function Slider({ column, value }: IDisplayCellProps) { const { max, min, diff --git a/src/components/fields/Slider/index.tsx b/src/components/fields/Slider/index.tsx index 80816fdf..06f7bce0 100644 --- a/src/components/fields/Slider/index.tsx +++ b/src/components/fields/Slider/index.tsx @@ -1,15 +1,11 @@ import { lazy } from "react"; import { IFieldConfig, FieldType } from "@src/components/fields/types"; -import withHeavyCell from "@src/components/fields/_withTableCell/withHeavyCell"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; import { Slider as SliderIcon } from "@src/assets/icons"; -import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull"; -import withSideDrawerEditor from "@src/components/Table/editors/withSideDrawerEditor"; +import DisplayCell from "./DisplayCell"; import { filterOperators } from "@src/components/fields/Number/Filter"; -const TableCell = lazy( - () => import("./TableCell" /* webpackChunkName: "TableCell-Slider" */) -); const SideDrawerField = lazy( () => import("./SideDrawerField" /* webpackChunkName: "SideDrawerField-Slider" */) @@ -28,8 +24,9 @@ export const config: IFieldConfig = { icon: , requireConfiguration: true, description: "Numeric value edited with a Slider. Range is configurable.", - TableCell: withHeavyCell(BasicCell, TableCell), - TableEditor: withSideDrawerEditor(TableCell), + TableCell: withRenderTableCell(DisplayCell, SideDrawerField, "popover", { + popoverProps: { PaperProps: { sx: { p: 1, pt: 5 } } }, + }), settings: Settings, filter: { operators: filterOperators, diff --git a/src/components/fields/Status/DisplayCell.tsx b/src/components/fields/Status/DisplayCell.tsx new file mode 100644 index 00000000..34e8dc7b --- /dev/null +++ b/src/components/fields/Status/DisplayCell.tsx @@ -0,0 +1,72 @@ +import { forwardRef, useMemo } from "react"; +import { IDisplayCellProps } from "@src/components/fields/types"; + +import { ButtonBase } from "@mui/material"; +import { ChevronDown } from "@src/assets/icons"; +import getLabel from "./utils/getLabelHelper"; + +export const StatusSingleSelect = forwardRef(function StatusSingleSelect({ + column, + value, + showPopoverCell, + disabled, + tabIndex, +}: IDisplayCellProps) { + const conditions = column.config?.conditions; + + const rendered = useMemo(() => { + const lowPriorityOperator = ["<", "<=", ">=", ">"]; + const otherOperator = (conditions ?? []).filter( + (c: any) => !lowPriorityOperator.includes(c.operator) + ); + + /**Revisit this */ + const sortLowPriorityList = (conditions ?? []) + .filter((c: any) => { + return lowPriorityOperator.includes(c.operator); + }) + .sort((a: any, b: any) => { + const aDistFromValue = Math.abs(value - a.value); + const bDistFromValue = Math.abs(value - b.value); + //return the smallest distance + return aDistFromValue - bDistFromValue; + }); + const sortedConditions = [...otherOperator, ...sortLowPriorityList]; + + return ( +
+ {value} + {getLabel(value, sortedConditions)} +
+ ); + }, [value, conditions]); + + if (disabled) return rendered; + + return ( + showPopoverCell(true)} + style={{ + width: "100%", + height: "100%", + font: "inherit", + color: "inherit !important", + letterSpacing: "inherit", + textAlign: "inherit", + justifyContent: "flex-start", + }} + tabIndex={tabIndex} + > + {rendered} + + + ); +}); + +export default StatusSingleSelect; diff --git a/src/components/fields/Status/PopoverCell.tsx b/src/components/fields/Status/EditorCell.tsx similarity index 67% rename from src/components/fields/Status/PopoverCell.tsx rename to src/components/fields/Status/EditorCell.tsx index 8ca1a63e..538aa4fc 100644 --- a/src/components/fields/Status/PopoverCell.tsx +++ b/src/components/fields/Status/EditorCell.tsx @@ -1,14 +1,15 @@ -import { IPopoverCellProps } from "@src/components/fields/types"; -import MultiSelect_ from "@rowy/multiselect"; +import { IEditorCellProps } from "@src/components/fields/types"; +import MultiSelectComponent from "@rowy/multiselect"; export default function StatusSingleSelect({ value, + onChange, onSubmit, column, parentRef, showPopoverCell, disabled, -}: IPopoverCellProps) { +}: IEditorCellProps) { const config = column.config ?? {}; const conditions = config.conditions ?? []; /**Revisit eventually, can we abstract or use a helper function to clean this? */ @@ -22,9 +23,9 @@ export default function StatusSingleSelect({ }); return ( // eslint-disable-next-line react/jsx-pascal-case - onSubmit(v)} + onChange={(v) => onChange(v)} options={conditions.length >= 1 ? reMappedConditions : []} // this handles when conditions are deleted multiple={false} freeText={config.freeText} @@ -37,12 +38,18 @@ export default function StatusSingleSelect({ open: true, MenuProps: { anchorEl: parentRef, - anchorOrigin: { vertical: "bottom", horizontal: "left" }, - transformOrigin: { vertical: "top", horizontal: "left" }, + anchorOrigin: { vertical: "bottom", horizontal: "center" }, + transformOrigin: { vertical: "top", horizontal: "center" }, + sx: { + "& .MuiPaper-root": { minWidth: `${column.width}px !important` }, + }, }, }, }} - onClose={() => showPopoverCell(false)} + onClose={() => { + showPopoverCell(false); + onSubmit(); + }} /> ); } diff --git a/src/components/fields/Status/InlineCell.tsx b/src/components/fields/Status/InlineCell.tsx deleted file mode 100644 index 35176e3d..00000000 --- a/src/components/fields/Status/InlineCell.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { forwardRef, useMemo } from "react"; -import { IPopoverInlineCellProps } from "@src/components/fields/types"; - -import { ButtonBase } from "@mui/material"; -import { ChevronDown } from "@src/assets/icons"; -import getLabel from "./utils/getLabelHelper"; - -export const StatusSingleSelect = forwardRef(function StatusSingleSelect( - { column, value, showPopoverCell, disabled }: IPopoverInlineCellProps, - ref: React.Ref -) { - const conditions = column.config?.conditions ?? []; - const lowPriorityOperator = ["<", "<=", ">=", ">"]; - const otherOperator = conditions.filter( - (c: any) => !lowPriorityOperator.includes(c.operator) - ); - - /**Revisit this */ - const sortLowPriorityList = conditions - .filter((c: any) => { - return lowPriorityOperator.includes(c.operator); - }) - .sort((a: any, b: any) => { - const aDistFromValue = Math.abs(value - a.value); - const bDistFromValue = Math.abs(value - b.value); - //return the smallest distance - return aDistFromValue - bDistFromValue; - }); - const sortedConditions = [...otherOperator, ...sortLowPriorityList]; - const label = useMemo( - () => getLabel(value, sortedConditions), - [value, sortedConditions] - ); - return ( - showPopoverCell(true)} - ref={ref} - disabled={disabled} - className="cell-collapse-padding" - style={{ - padding: "var(--cell-padding)", - paddingRight: 0, - height: "100%", - font: "inherit", - color: "inherit !important", - letterSpacing: "inherit", - textAlign: "inherit", - justifyContent: "flex-start", - }} - > -
{label}
- - {!disabled && ( - - )} -
- ); -}); - -export default StatusSingleSelect; diff --git a/src/components/fields/Status/index.tsx b/src/components/fields/Status/index.tsx index 16b1ecf8..420a5e11 100644 --- a/src/components/fields/Status/index.tsx +++ b/src/components/fields/Status/index.tsx @@ -1,13 +1,11 @@ import { lazy } from "react"; import { IFieldConfig, FieldType } from "@src/components/fields/types"; -import { Status as StatusIcon } from "@src/assets/icons"; -import NullEditor from "@src/components/Table/editors/NullEditor"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; +import { Status as StatusIcon } from "@src/assets/icons"; +import DisplayCell from "./DisplayCell"; +import EditorCell from "./EditorCell"; import { filterOperators } from "./Filter"; -import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull"; -import PopoverCell from "./PopoverCell"; -import InlineCell from "./InlineCell"; -import withPopoverCell from "@src/components/fields/_withTableCell/withPopoverCell"; const SideDrawerField = lazy( () => @@ -25,12 +23,11 @@ export const config: IFieldConfig = { initialValue: undefined, initializable: true, icon: , - description: "Displays field value as custom status text. Read-only. ", - TableCell: withPopoverCell(BasicCell, InlineCell, PopoverCell, { - anchorOrigin: { horizontal: "left", vertical: "bottom" }, - transparent: true, + description: "Displays field value as custom status text.", + TableCell: withRenderTableCell(DisplayCell, EditorCell, "popover", { + disablePadding: true, + transparentPopover: true, }), - TableEditor: NullEditor as any, settings: Settings, SideDrawerField, requireConfiguration: true, diff --git a/src/components/fields/SubTable/TableCell.tsx b/src/components/fields/SubTable/DisplayCell.tsx similarity index 60% rename from src/components/fields/SubTable/TableCell.tsx rename to src/components/fields/SubTable/DisplayCell.tsx index 72c2df42..f9c9c3e7 100644 --- a/src/components/fields/SubTable/TableCell.tsx +++ b/src/components/fields/SubTable/DisplayCell.tsx @@ -1,27 +1,31 @@ -import { IHeavyCellProps } from "@src/components/fields/types"; +import { IDisplayCellProps } from "@src/components/fields/types"; import { Link } from "react-router-dom"; import { Stack, IconButton } from "@mui/material"; -import LaunchIcon from "@mui/icons-material/Launch"; +import OpenIcon from "@mui/icons-material/OpenInBrowser"; import { useSubTableData } from "./utils"; -export default function SubTable({ column, row }: IHeavyCellProps) { +export default function SubTable({ + column, + row, + _rowy_ref, + tabIndex, +}: IDisplayCellProps) { const { documentCount, label, subTablePath } = useSubTableData( column as any, row, - row._rowy_ref + _rowy_ref ); - if (!row._rowy_ref) return null; + if (!_rowy_ref) return null; return (
{documentCount} {column.name as string}: {label} @@ -30,12 +34,12 @@ export default function SubTable({ column, row }: IHeavyCellProps) { - + ); diff --git a/src/components/fields/SubTable/SideDrawerField.tsx b/src/components/fields/SubTable/SideDrawerField.tsx index 32f2b1f8..abf8e1e0 100644 --- a/src/components/fields/SubTable/SideDrawerField.tsx +++ b/src/components/fields/SubTable/SideDrawerField.tsx @@ -6,7 +6,7 @@ import { ISideDrawerFieldProps } from "@src/components/fields/types"; import { Link } from "react-router-dom"; import { Box, Stack, IconButton } from "@mui/material"; -import LaunchIcon from "@mui/icons-material/Launch"; +import OpenIcon from "@mui/icons-material/OpenInBrowser"; import { tableScope, tableRowsAtom } from "@src/atoms/tableScope"; import { fieldSx, getFieldId } from "@src/components/SideDrawer/utils"; @@ -46,7 +46,7 @@ export default function SubTable({ column, _rowy_ref }: ISideDrawerFieldProps) { sx={{ ml: 1 }} disabled={!subTablePath} > - + ); diff --git a/src/components/fields/SubTable/index.tsx b/src/components/fields/SubTable/index.tsx index b7ce171e..7e153771 100644 --- a/src/components/fields/SubTable/index.tsx +++ b/src/components/fields/SubTable/index.tsx @@ -1,14 +1,10 @@ import { lazy } from "react"; import { IFieldConfig, FieldType } from "@src/components/fields/types"; -import withHeavyCell from "@src/components/fields/_withTableCell/withHeavyCell"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; import { SubTable as SubTableIcon } from "@src/assets/icons"; -import BasicCell from "@src/components/fields/_BasicCell/BasicCellName"; -import NullEditor from "@src/components/Table/editors/NullEditor"; +import DisplayCell from "./DisplayCell"; -const TableCell = lazy( - () => import("./TableCell" /* webpackChunkName: "TableCell-SubTable" */) -); const SideDrawerField = lazy( () => import( @@ -28,8 +24,10 @@ export const config: IFieldConfig = { settings: Settings, description: "Connects to a sub-table in the current row. Also displays number of rows inside the sub-table. Max sub-table depth: 100.", - TableCell: withHeavyCell(BasicCell, TableCell), - TableEditor: NullEditor as any, + TableCell: withRenderTableCell(DisplayCell, null, "focus", { + usesRowData: true, + disablePadding: true, + }), SideDrawerField, initializable: false, requireConfiguration: true, diff --git a/src/components/fields/SubTable/utils.ts b/src/components/fields/SubTable/utils.ts index e62e0a45..6f6c7746 100644 --- a/src/components/fields/SubTable/utils.ts +++ b/src/components/fields/SubTable/utils.ts @@ -6,7 +6,7 @@ import { ColumnConfig, TableRow, TableRowRef } from "@src/types/table"; export const useSubTableData = ( column: ColumnConfig, row: TableRow, - docRef: TableRowRef + _rowy_ref: TableRowRef ) => { const label = (column.config?.parentLabel ?? []).reduce((acc, curr) => { if (acc !== "") return `${acc} - ${row[curr]}`; @@ -24,7 +24,7 @@ export const useSubTableData = ( let subTablePath = [ rootTablePath, ROUTES.subTable, - encodeURIComponent(docRef.path), + encodeURIComponent(_rowy_ref.path), column.key, ].join("/"); diff --git a/src/components/fields/CreatedAt/TableCell.tsx b/src/components/fields/UpdatedAt/DisplayCell.tsx similarity index 70% rename from src/components/fields/CreatedAt/TableCell.tsx rename to src/components/fields/UpdatedAt/DisplayCell.tsx index b659618d..c6b29d9a 100644 --- a/src/components/fields/CreatedAt/TableCell.tsx +++ b/src/components/fields/UpdatedAt/DisplayCell.tsx @@ -1,9 +1,9 @@ -import { IHeavyCellProps } from "@src/components/fields/types"; +import { IDisplayCellProps } from "@src/components/fields/types"; import { format } from "date-fns"; import { DATE_TIME_FORMAT } from "@src/constants/dates"; -export default function CreatedAt({ column, value }: IHeavyCellProps) { +export default function UpdatedAt({ column, value }: IDisplayCellProps) { if (!value) return null; const dateLabel = format( value.toDate ? value.toDate() : value, diff --git a/src/components/fields/UpdatedAt/index.tsx b/src/components/fields/UpdatedAt/index.tsx index 3c2c3f28..d6e5eb92 100644 --- a/src/components/fields/UpdatedAt/index.tsx +++ b/src/components/fields/UpdatedAt/index.tsx @@ -1,14 +1,10 @@ import { lazy } from "react"; import { IFieldConfig, FieldType } from "@src/components/fields/types"; -import withHeavyCell from "@src/components/fields/_withTableCell/withHeavyCell"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; import { UpdatedAt as UpdatedAtIcon } from "@src/assets/icons"; -import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull"; -import withSideDrawerEditor from "@src/components/Table/editors/withSideDrawerEditor"; +import DisplayCell from "./DisplayCell"; -const TableCell = lazy( - () => import("./TableCell" /* webpackChunkName: "TableCell-UpdatedAt" */) -); const SideDrawerField = lazy( () => import( @@ -29,8 +25,7 @@ export const config: IFieldConfig = { icon: , description: "Displays the timestamp of the last update to the row. Read-only.", - TableCell: withHeavyCell(BasicCell, TableCell), - TableEditor: withSideDrawerEditor(TableCell), + TableCell: withRenderTableCell(DisplayCell, null), SideDrawerField, settings: Settings, }; diff --git a/src/components/fields/UpdatedBy/TableCell.tsx b/src/components/fields/UpdatedBy/DisplayCell.tsx similarity index 87% rename from src/components/fields/UpdatedBy/TableCell.tsx rename to src/components/fields/UpdatedBy/DisplayCell.tsx index 5484626f..1bf71be3 100644 --- a/src/components/fields/UpdatedBy/TableCell.tsx +++ b/src/components/fields/UpdatedBy/DisplayCell.tsx @@ -1,11 +1,11 @@ -import { IHeavyCellProps } from "@src/components/fields/types"; +import { IDisplayCellProps } from "@src/components/fields/types"; import { Tooltip, Stack, Avatar } from "@mui/material"; import { format } from "date-fns"; import { DATE_TIME_FORMAT } from "@src/constants/dates"; -export default function UpdatedBy({ column, value }: IHeavyCellProps) { +export default function UpdatedBy({ column, value }: IDisplayCellProps) { if (!value || !value.displayName || !value.timestamp) return null; const dateLabel = format( value.timestamp.toDate ? value.timestamp.toDate() : value.timestamp, diff --git a/src/components/fields/UpdatedBy/index.tsx b/src/components/fields/UpdatedBy/index.tsx index 09ca1e2f..4b1c3a42 100644 --- a/src/components/fields/UpdatedBy/index.tsx +++ b/src/components/fields/UpdatedBy/index.tsx @@ -1,14 +1,10 @@ import { lazy } from "react"; import { IFieldConfig, FieldType } from "@src/components/fields/types"; -import withHeavyCell from "@src/components/fields/_withTableCell/withHeavyCell"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; import { UpdatedBy as UpdatedByIcon } from "@src/assets/icons"; -import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull"; -import withSideDrawerEditor from "@src/components/Table/editors/withSideDrawerEditor"; +import DisplayCell from "./DisplayCell"; -const TableCell = lazy( - () => import("./TableCell" /* webpackChunkName: "TableCell-UpdatedBy" */) -); const SideDrawerField = lazy( () => import( @@ -30,8 +26,7 @@ export const config: IFieldConfig = { icon: , description: "Displays the user that last updated the row, timestamp, and updated field key. Read-only.", - TableCell: withHeavyCell(BasicCell, TableCell), - TableEditor: withSideDrawerEditor(TableCell), + TableCell: withRenderTableCell(DisplayCell, null), SideDrawerField, settings: Settings, }; diff --git a/src/components/fields/Url/TableCell.tsx b/src/components/fields/Url/DisplayCell.tsx similarity index 75% rename from src/components/fields/Url/TableCell.tsx rename to src/components/fields/Url/DisplayCell.tsx index 6c8affc3..20bd48dd 100644 --- a/src/components/fields/Url/TableCell.tsx +++ b/src/components/fields/Url/DisplayCell.tsx @@ -1,9 +1,9 @@ -import { IBasicCellProps } from "@src/components/fields/types"; +import { IDisplayCellProps } from "@src/components/fields/types"; import { Stack, IconButton } from "@mui/material"; import LaunchIcon from "@mui/icons-material/Launch"; -export default function Url({ value }: IBasicCellProps) { +export default function Url({ value, tabIndex }: IDisplayCellProps) { if (!value || typeof value !== "string") return null; const href = value.includes("http") ? value : `https://${value}`; @@ -13,8 +13,7 @@ export default function Url({ value }: IBasicCellProps) { direction="row" alignItems="center" justifyContent="space-between" - className="cell-collapse-padding" - sx={{ p: "var(--cell-padding)", pr: 0.5 }} + sx={{ p: "var(--cell-padding)", pr: 0.5, width: "100%" }} >
{value}
@@ -26,6 +25,7 @@ export default function Url({ value }: IBasicCellProps) { size="small" style={{ flexShrink: 0 }} aria-label="Open in new tab" + tabIndex={tabIndex} > diff --git a/src/components/fields/Url/EditorCell.tsx b/src/components/fields/Url/EditorCell.tsx new file mode 100644 index 00000000..d22c4880 --- /dev/null +++ b/src/components/fields/Url/EditorCell.tsx @@ -0,0 +1,6 @@ +import type { IEditorCellProps } from "@src/components/fields/types"; +import EditorCellTextField from "@src/components/Table/TableCell/EditorCellTextField"; + +export default function Url(props: IEditorCellProps) { + return ; +} diff --git a/src/components/fields/Url/index.tsx b/src/components/fields/Url/index.tsx index 95376b06..be276388 100644 --- a/src/components/fields/Url/index.tsx +++ b/src/components/fields/Url/index.tsx @@ -1,12 +1,12 @@ import { lazy } from "react"; import { IFieldConfig, FieldType } from "@src/components/fields/types"; -import withBasicCell from "@src/components/fields/_withTableCell/withBasicCell"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; import UrlIcon from "@mui/icons-material/Link"; -import TableCell from "./TableCell"; -import TextEditor from "@src/components/Table/editors/TextEditor"; +import DisplayCell from "./DisplayCell"; +import EditorCell from "./EditorCell"; import { filterOperators } from "@src/components/fields/ShortText/Filter"; -import BasicContextMenuActions from "@src/components/fields/_BasicCell/BasicCellContextMenuActions"; +import BasicContextMenuActions from "@src/components/Table/ContextMenu/BasicCellContextMenuActions"; const SideDrawerField = lazy( () => @@ -23,8 +23,9 @@ export const config: IFieldConfig = { icon: , description: "Web address. Not validated.", contextMenuActions: BasicContextMenuActions, - TableCell: withBasicCell(TableCell), - TableEditor: TextEditor, + TableCell: withRenderTableCell(DisplayCell, EditorCell, "focus", { + disablePadding: true, + }), SideDrawerField, filter: { operators: filterOperators, diff --git a/src/components/fields/User/TableCell.tsx b/src/components/fields/User/DisplayCell.tsx similarity index 83% rename from src/components/fields/User/TableCell.tsx rename to src/components/fields/User/DisplayCell.tsx index e1761826..d299b6c5 100644 --- a/src/components/fields/User/TableCell.tsx +++ b/src/components/fields/User/DisplayCell.tsx @@ -1,11 +1,11 @@ -import { IHeavyCellProps } from "@src/components/fields/types"; +import { IDisplayCellProps } from "@src/components/fields/types"; import { Tooltip, Stack, Avatar } from "@mui/material"; import { format } from "date-fns"; import { DATE_TIME_FORMAT } from "@src/constants/dates"; -export default function User({ value, column }: IHeavyCellProps) { +export default function User({ value, column }: IDisplayCellProps) { if (!value || !value.displayName) return null; const chip = ( diff --git a/src/components/fields/User/index.tsx b/src/components/fields/User/index.tsx index 70ec0a83..b06680df 100644 --- a/src/components/fields/User/index.tsx +++ b/src/components/fields/User/index.tsx @@ -1,14 +1,10 @@ import { lazy } from "react"; import { IFieldConfig, FieldType } from "@src/components/fields/types"; -import withHeavyCell from "@src/components/fields/_withTableCell/withHeavyCell"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; import UserIcon from "@mui/icons-material/PersonOutlined"; -import BasicCell from "@src/components/fields/_BasicCell/BasicCellNull"; -import withSideDrawerEditor from "@src/components/Table/editors/withSideDrawerEditor"; +import DisplayCell from "./DisplayCell"; -const TableCell = lazy( - () => import("./TableCell" /* webpackChunkName: "TableCell-User" */) -); const SideDrawerField = lazy( () => import("./SideDrawerField" /* webpackChunkName: "SideDrawerField-User" */) @@ -27,8 +23,7 @@ export const config: IFieldConfig = { initialValue: null, icon: , description: "User information and optionally, timestamp. Read-only.", - TableCell: withHeavyCell(BasicCell, TableCell), - TableEditor: withSideDrawerEditor(TableCell), + TableCell: withRenderTableCell(DisplayCell, null), SideDrawerField, settings: Settings, }; diff --git a/src/components/fields/_BasicCell/BasicCellName.tsx b/src/components/fields/_BasicCell/BasicCellName.tsx deleted file mode 100644 index 5d93cd7b..00000000 --- a/src/components/fields/_BasicCell/BasicCellName.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { IBasicCellProps } from "@src/components/fields/types"; - -export default function BasicCellName({ name }: IBasicCellProps) { - return <>{name}; -} diff --git a/src/components/fields/_BasicCell/BasicCellNull.tsx b/src/components/fields/_BasicCell/BasicCellNull.tsx deleted file mode 100644 index 43d91ccb..00000000 --- a/src/components/fields/_BasicCell/BasicCellNull.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function BasicCellNull() { - return null; -} diff --git a/src/components/fields/_BasicCell/BasicCellValue.tsx b/src/components/fields/_BasicCell/BasicCellValue.tsx deleted file mode 100644 index 8d8b0445..00000000 --- a/src/components/fields/_BasicCell/BasicCellValue.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { IBasicCellProps } from "@src/components/fields/types"; - -export default function BasicCellValue({ value }: IBasicCellProps) { - if (typeof value !== "string") return null; - return <>{value}; -} diff --git a/src/components/fields/_withTableCell/withBasicCell.tsx b/src/components/fields/_withTableCell/withBasicCell.tsx deleted file mode 100644 index 09d0bcb9..00000000 --- a/src/components/fields/_withTableCell/withBasicCell.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { get } from "lodash-es"; -import { FormatterProps } from "react-data-grid"; -import { ErrorBoundary } from "react-error-boundary"; -import { IBasicCellProps } from "@src/components/fields/types"; - -import { InlineErrorFallback } from "@src/components/ErrorFallback"; -import CellValidation from "@src/components/Table/CellValidation"; -import { FieldType } from "@src/constants/fields"; -import { TableRow } from "@src/types/table"; - -/** - * HOC to wrap around table cell components. - * Renders read-only BasicCell only. - * @param BasicCellComponent - The light cell component to display at all times - */ -export default function withBasicCell( - BasicCellComponent: React.ComponentType -) { - return function BasicCell(props: FormatterProps) { - const { name, key } = props.column; - const value = get(props.row, key); - - const { validationRegex, required } = (props.column as any).config; - - return ( - - - - - - ); - }; -} diff --git a/src/components/fields/_withTableCell/withHeavyCell.tsx b/src/components/fields/_withTableCell/withHeavyCell.tsx deleted file mode 100644 index 29eaf370..00000000 --- a/src/components/fields/_withTableCell/withHeavyCell.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { Suspense, useState, useEffect } from "react"; -import { useSetAtom } from "jotai"; -import { get } from "lodash-es"; -import { FormatterProps } from "react-data-grid"; -import { ErrorBoundary } from "react-error-boundary"; -import { IBasicCellProps, IHeavyCellProps } from "@src/components/fields/types"; - -import { InlineErrorFallback } from "@src/components/ErrorFallback"; -import CellValidation from "@src/components/Table/CellValidation"; - -import { tableScope, updateFieldAtom } from "@src/atoms/tableScope"; -import { FieldType } from "@src/constants/fields"; -import { TableRow } from "@src/types/table"; - -/** - * HOC to wrap table cell components. - * Renders read-only BasicCell while scrolling for better scroll performance. - * @param BasicCellComponent - The lighter cell component to display while scrolling - * @param HeavyCellComponent - The read/write cell component to display - * @param readOnly - Prevent the component from updating the cell value - */ -export default function withHeavyCell( - BasicCellComponent: React.ComponentType, - HeavyCellComponent: React.ComponentType, - readOnly: boolean = false -) { - return function HeavyCell(props: FormatterProps) { - const updateField = useSetAtom(updateFieldAtom, tableScope); - - const { validationRegex, required } = (props.column as any).config; - - // Initially display BasicCell to improve scroll performance - const [displayedComponent, setDisplayedComponent] = useState< - "basic" | "heavy" - >("basic"); - // Then switch to HeavyCell once completed - useEffect(() => { - setTimeout(() => { - setDisplayedComponent("heavy"); - }); - }, []); - - // TODO: Investigate if this still needs to be a state - const value = get(props.row, props.column.key); - const [localValue, setLocalValue] = useState(value); - useEffect(() => { - setLocalValue(value); - }, [value]); - - // Declare basicCell here so props can be reused by HeavyCellComponent - const basicCellProps = { - value: localValue, - name: props.column.name as string, - type: (props.column as any).type as FieldType, - }; - const basicCell = ; - - if (displayedComponent === "basic") - return ( - - - {basicCell} - - - ); - - const handleSubmit = (value: any) => { - if (readOnly) return; - updateField({ - path: props.row._rowy_ref.path, - fieldName: props.column.key, - value, - }); - setLocalValue(value); - }; - - if (displayedComponent === "heavy") - return ( - - - - - - - - ); - - // Should not reach this line - return null; - }; -} diff --git a/src/components/fields/_withTableCell/withPopoverCell.tsx b/src/components/fields/_withTableCell/withPopoverCell.tsx deleted file mode 100644 index 5da116ca..00000000 --- a/src/components/fields/_withTableCell/withPopoverCell.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import { Suspense, useState, useEffect, useRef } from "react"; -import { useSetAtom } from "jotai"; -import { find, get } from "lodash-es"; -import { FormatterProps } from "react-data-grid"; -import { - IBasicCellProps, - IPopoverInlineCellProps, - IPopoverCellProps, -} from "@src/components/fields/types"; -import { ErrorBoundary } from "react-error-boundary"; - -import { Popover, PopoverProps } from "@mui/material"; - -import { InlineErrorFallback } from "@src/components/ErrorFallback"; -import CellValidation from "@src/components/Table/CellValidation"; - -import { tableScope, updateFieldAtom } from "@src/atoms/tableScope"; -import { FieldType } from "@src/constants/fields"; - -export interface IPopoverCellOptions extends Partial { - transparent?: boolean; - readOnly?: boolean; -} - -/** - * HOC to wrap around table cell formatters. - * Renders read-only BasicCell while scrolling for better scroll performance. - * When the user clicks the heavier inline cell, it displays PopoverCell. - * @param BasicCellComponent - The lighter cell component to display while scrolling - * @param InlineCellComponent - The heavier cell component to display inline - * @param PopoverCellComponent - The heavy read/write cell component to display in Popover - * @param options - {@link IPopoverCellOptions} - */ -export default function withPopoverCell( - BasicCellComponent: React.ComponentType, - InlineCellComponent: React.ForwardRefExoticComponent< - IPopoverInlineCellProps & React.RefAttributes - >, - PopoverCellComponent: React.ComponentType, - options?: IPopoverCellOptions -) { - return function PopoverCell(props: FormatterProps) { - const { transparent, ...popoverProps } = options ?? {}; - - const updateField = useSetAtom(updateFieldAtom, tableScope); - - const { validationRegex, required } = (props.column as any).config; - - // Initially display BasicCell to improve scroll performance - const [displayedComponent, setDisplayedComponent] = useState< - "basic" | "inline" | "popover" - >("basic"); - // Then switch to heavier InlineCell once completed - useEffect(() => { - setTimeout(() => { - setDisplayedComponent("inline"); - }); - }, []); - - // Store Popover open state here so we can add delay for close transition - const [popoverOpen, setPopoverOpen] = useState(false); - - // Store ref to rendered InlineCell here to get positioning for PopoverCell - const inlineCellRef = useRef(null); - - // TODO: Investigate if this still needs to be a state - const value = get(props.row, props.column.key); - const [localValue, setLocalValue] = useState(value); - useEffect(() => { - setLocalValue(value); - }, [value]); - - // Declare basicCell here so props can be reused by HeavyCellComponent - const basicCellProps = { - value: localValue, - name: props.column.name as string, - type: (props.column as any).type as FieldType, - }; - - if (displayedComponent === "basic") - return ( - - - - - - ); - - // This is where we update the documents - const handleSubmit = (value: any) => { - if (options?.readOnly) return; - updateField({ - path: props.row._rowy_ref.path, - fieldName: props.column.key, - value, - deleteField: value === undefined, - }); - setLocalValue(value); - }; - const showPopoverCell: any = (popover: boolean) => { - if (popover) { - setPopoverOpen(true); - setDisplayedComponent("popover"); - } else { - setPopoverOpen(false); - setTimeout(() => setDisplayedComponent("inline"), 300); - } - }; - - // Declare inlineCell and props here so it can be reused later - const commonCellProps = { - ...props, - ...basicCellProps, - column: props.column, - onSubmit: handleSubmit, - disabled: props.column.editable === false, - docRef: props.row._rowy_ref, - showPopoverCell, - ref: inlineCellRef, - }; - const inlineCell = ( - - ); - - if (displayedComponent === "inline") - return ( - - - {inlineCell} - - - ); - - const parentRef = inlineCellRef.current?.parentElement; - - if (displayedComponent === "popover") - return ( - - - {inlineCell} - - - - showPopoverCell(false)} - {...popoverProps} - sx={ - transparent - ? { - "& .MuiPopover-paper": { backgroundColor: "transparent" }, - } - : {} - } - onClick={(e) => e.stopPropagation()} - onDoubleClick={(e) => e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - > - - - - - ); - - // Should not reach this line - return null; - }; -} diff --git a/src/components/fields/types.ts b/src/components/fields/types.ts index 66890472..63374d81 100644 --- a/src/components/fields/types.ts +++ b/src/components/fields/types.ts @@ -1,15 +1,14 @@ import { FieldType } from "@src/constants/fields"; -import { FormatterProps, EditorProps } from "react-data-grid"; -import { Control, UseFormReturn } from "react-hook-form"; -import { PopoverProps } from "@mui/material"; -import { +import { IRenderedTableCellProps } from "@src/components/Table/TableCell/withRenderTableCell"; +import type { PopoverProps } from "@mui/material"; +import type { ColumnConfig, TableRow, TableRowRef, TableFilter, } from "@src/types/table"; -import { SelectedCell } from "@src/atoms/tableScope"; -import { IContextMenuItem } from "@src/components/Table/ContextMenu/ContextMenuItem"; +import type { SelectedCell } from "@src/atoms/tableScope"; +import type { IContextMenuItem } from "@src/components/Table/ContextMenu/ContextMenuItem"; export { FieldType }; @@ -28,8 +27,7 @@ export interface IFieldConfig { selectedCell: SelectedCell, reset: () => void ) => IContextMenuItem[]; - TableCell: React.ComponentType>; - TableEditor: React.ComponentType>; + TableCell: React.ComponentType; SideDrawerField: React.ComponentType; settings?: React.ComponentType; settingsValidator?: (config: Record) => Record; @@ -44,52 +42,55 @@ export interface IFieldConfig { csvImportParser?: (value: string, config?: any) => any; } -export interface IBasicCellProps { - value: any; +/** See {@link IRenderedTableCellProps | `withRenderTableCell` } for guidance */ +export interface IDisplayCellProps { + value: T; type: FieldType; name: string; -} -export interface IHeavyCellProps - extends IBasicCellProps, - FormatterProps { - column: FormatterProps["column"] & { config?: Record }; - onSubmit: (value: any) => void; - docRef: TableRowRef; + row: TableRow; + column: ColumnConfig; + /** The row’s _rowy_ref object */ + _rowy_ref: TableRowRef; disabled: boolean; + /** + * ⚠️ Make sure to use the `tabIndex` prop for buttons and other interactive + * elements. + */ + tabIndex: number; + showPopoverCell: (value: boolean) => void; + setFocusInsideCell: (focusInside: boolean) => void; + rowHeight: number; } - -export interface IPopoverInlineCellProps extends IHeavyCellProps { - showPopoverCell: React.Dispatch>; -} -export interface IPopoverCellProps extends IPopoverInlineCellProps { +/** See {@link IRenderedTableCellProps | `withRenderTableCell` } for guidance */ +export interface IEditorCellProps extends IDisplayCellProps { + /** Call when the user has input but changes have not been saved */ + onDirty: (dirty?: boolean) => void; + /** Update the local value. Also calls onDirty */ + onChange: (value: T) => void; + /** Call when user input is ready to be saved (e.g. onBlur) */ + onSubmit: () => void; + /** Get parent element for popover positioning */ parentRef: PopoverProps["anchorEl"]; } /** Props to be passed to all SideDrawerFields */ -export interface ISideDrawerFieldProps { +export interface ISideDrawerFieldProps { /** The column config */ - column: FormatterProps["column"] & ColumnConfig; + column: ColumnConfig; /** The row’s _rowy_ref object */ _rowy_ref: TableRowRef; /** The field’s local value – synced with db when field is not dirty */ - value: any; + value: T; /** Call when the user has input but changes have not been saved */ - onDirty: () => void; + onDirty: (dirty?: boolean) => void; /** Update the local value. Also calls onDirty */ - onChange: (value: any) => void; + onChange: (T: any) => void; /** Call when user input is ready to be saved (e.g. onBlur) */ onSubmit: () => void; /** Field locked. Do NOT check `column.locked` */ disabled: boolean; - - /** @deprecated */ - docRef: TableRowRef; - /** @deprecated */ - control: Control; - /** @deprecated */ - useFormMethods: UseFormReturn; } export interface ISettingsProps { diff --git a/src/constants/routes.tsx b/src/constants/routes.tsx index 4b59b687..af23e2cd 100644 --- a/src/constants/routes.tsx +++ b/src/constants/routes.tsx @@ -1,5 +1,5 @@ import Logo from "@src/assets/Logo"; -import BreadcrumbsTableRoot from "@src/components/Table/BreadcrumbsTableRoot"; +import BreadcrumbsTableRoot from "@src/components/Table/Breadcrumbs/BreadcrumbsTableRoot"; import { FadeProps, Typography } from "@mui/material"; export enum ROUTES { diff --git a/src/hooks/useFirebaseStorageUploader.tsx b/src/hooks/useFirebaseStorageUploader.tsx index bad0ff46..2be914fd 100644 --- a/src/hooks/useFirebaseStorageUploader.tsx +++ b/src/hooks/useFirebaseStorageUploader.tsx @@ -1,7 +1,6 @@ import { useReducer } from "react"; import { useAtom } from "jotai"; import { useSnackbar } from "notistack"; -import type { DocumentReference } from "firebase/firestore"; import { ref, uploadBytesResumable, @@ -15,7 +14,7 @@ import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon"; import { projectScope } from "@src/atoms/projectScope"; import { firebaseStorageAtom } from "@src/sources/ProjectSourceFirebase"; import { WIKI_LINKS } from "@src/constants/externalLinks"; -import { FileValue } from "@src/types/table"; +import type { FileValue, TableRowRef } from "@src/types/table"; import { generateId } from "@src/utils/table"; export type UploadState = { @@ -50,7 +49,7 @@ const uploadReducer = ( }; export type UploadProps = { - docRef: DocumentReference; + docRef: TableRowRef; fieldName: string; files: File[]; onComplete?: ({ diff --git a/src/hooks/useSaveOnUnmount.ts b/src/hooks/useSaveOnUnmount.ts new file mode 100644 index 00000000..7807ce23 --- /dev/null +++ b/src/hooks/useSaveOnUnmount.ts @@ -0,0 +1,19 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import { useLayoutEffect } from "react"; +import useState from "react-usestateref"; + +export function useSaveOnUnmount( + initialValue: T, + onSave: (value: T) => void +) { + const [localValue, setLocalValue, localValueRef] = useState(initialValue); + + useLayoutEffect(() => { + return () => { + onSave(localValueRef.current); + }; + }, []); + + return [localValue, setLocalValue, localValueRef] as const; +} +export default useSaveOnUnmount; diff --git a/src/pages/Table/ProvidedSubTablePage.tsx b/src/pages/Table/ProvidedSubTablePage.tsx index 2b3e090b..e4659d33 100644 --- a/src/pages/Table/ProvidedSubTablePage.tsx +++ b/src/pages/Table/ProvidedSubTablePage.tsx @@ -1,4 +1,4 @@ -import { Suspense, useMemo } from "react"; +import { lazy, Suspense, useMemo } from "react"; import { useAtom, Provider } from "jotai"; import { selectAtom } from "jotai/utils"; import { DebugAtoms } from "@src/atoms/utils"; @@ -7,10 +7,9 @@ import { useLocation, useNavigate, useParams } from "react-router-dom"; import { find, isEqual } from "lodash-es"; import Modal from "@src/components/Modal"; -import BreadcrumbsSubTable from "@src/components/Table/BreadcrumbsSubTable"; +import BreadcrumbsSubTable from "@src/components/Table/Breadcrumbs/BreadcrumbsSubTable"; import ErrorFallback from "@src/components/ErrorFallback"; import TableSourceFirestore from "@src/sources/TableSourceFirestore"; -import TablePage from "./TablePage"; import TableToolbarSkeleton from "@src/components/TableToolbar/TableToolbarSkeleton"; import TableSkeleton from "@src/components/Table/TableSkeleton"; @@ -25,8 +24,16 @@ import { ROUTES } from "@src/constants/routes"; import { TOP_BAR_HEIGHT } from "@src/layouts/Navigation/TopBar"; import { TABLE_TOOLBAR_HEIGHT } from "@src/components/TableToolbar"; +// prettier-ignore +const TablePage = lazy(() => import("./TablePage" /* webpackChunkName: "TablePage" */)); + /** - * Wraps `TablePage` with the data for a top-level table. + * Wraps `TablePage` with the data for a sub-table. + * + * Differences to `ProvidedTablePage`: + * - Renders a `Modal` + * - When this is a child of `ProvidedTablePage`, the `TablePage` rendered for + * the root table has its modals disabled */ export default function ProvidedSubTablePage() { const location = useLocation(); @@ -108,6 +115,7 @@ export default function ProvidedSubTablePage() { disableBottomDivider: true, style: { "--dialog-spacing": 0, "--dialog-contents-spacing": 0 } as any, }} + BackdropProps={{ key: "sub-table-modal-backdrop" }} > import("./TablePage" /* webpackChunkName: "TablePage" */)); + /** * Wraps `TablePage` with the data for a top-level table. * `SubTablePage` is inserted in the outlet, alongside `TablePage`. + * + * Interfaces with `projectScope` atoms to find the correct table (or sub-table) + * settings and schema. + * + * - Renders the Jotai `Provider` with `tableScope` + * - Renders `TableSourceFirestore`, which queries Firestore and stores data in + * atoms in `tableScope` */ export default function ProvidedTablePage() { const { id } = useParams(); diff --git a/src/pages/Table/TablePage.tsx b/src/pages/Table/TablePage.tsx index 20671792..75f1fc28 100644 --- a/src/pages/Table/TablePage.tsx +++ b/src/pages/Table/TablePage.tsx @@ -1,10 +1,9 @@ -import { useRef, Suspense, lazy } from "react"; +import { Suspense, lazy } from "react"; import { useAtom } from "jotai"; -import { DataGridHandle } from "react-data-grid"; import { ErrorBoundary } from "react-error-boundary"; -import { isEmpty } from "lodash-es"; +import { isEmpty, intersection } from "lodash-es"; -import { Fade } from "@mui/material"; +import { Box, Fade } from "@mui/material"; import ErrorFallback, { InlineErrorFallback, } from "@src/components/ErrorFallback"; @@ -14,13 +13,23 @@ import TableSkeleton from "@src/components/Table/TableSkeleton"; import EmptyTable from "@src/components/Table/EmptyTable"; import TableToolbar from "@src/components/TableToolbar"; import Table from "@src/components/Table"; -import SideDrawer from "@src/components/SideDrawer"; +import SideDrawer, { DRAWER_WIDTH } from "@src/components/SideDrawer"; import ColumnMenu from "@src/components/ColumnMenu"; import ColumnModals from "@src/components/ColumnModals"; import TableModals from "@src/components/TableModals"; +import EmptyState from "@src/components/EmptyState"; +import AddRow from "@src/components/TableToolbar/AddRow"; +import { AddRow as AddRowIcon } from "@src/assets/icons"; +import { + projectScope, + userRolesAtom, + userSettingsAtom, +} from "@src/atoms/projectScope"; import { tableScope, + tableIdAtom, + tableSettingsAtom, tableSchemaAtom, columnModalAtom, tableModalAtom, @@ -28,12 +37,19 @@ import { import useBeforeUnload from "@src/hooks/useBeforeUnload"; import ActionParamsProvider from "@src/components/fields/Action/FormDialog/Provider"; import { useSnackLogContext } from "@src/contexts/SnackLogContext"; +import { TOP_BAR_HEIGHT } from "@src/layouts/Navigation/TopBar"; +import { TABLE_TOOLBAR_HEIGHT } from "@src/components/TableToolbar"; +import { DRAWER_COLLAPSED_WIDTH } from "@src/components/SideDrawer"; +import { formatSubTableName } from "@src/utils/table"; // prettier-ignore const BuildLogsSnack = lazy(() => import("@src/components/TableModals/CloudLogsModal/BuildLogs/BuildLogsSnack" /* webpackChunkName: "TableModals-BuildLogsSnack" */)); export interface ITablePageProps { - /** Disable modals on this table when a sub-table is open and it’s listening to URL state */ + /** + * Disable modals on this table when a sub-table is open and it’s listening + * to URL state + */ disableModals?: boolean; /** Disable side drawer */ disableSideDrawer?: boolean; @@ -42,21 +58,42 @@ export interface ITablePageProps { /** * TablePage renders all the UI for the table. * Must be wrapped by either `ProvidedTablePage` or `ProvidedSubTablePage`. + * + * Renders `Table`, `TableToolbar`, `SideDrawer`, `TableModals`, `ColumnMenu`, + * Suspense fallback UI. These components are all independent of each other. + * + * - Renders empty state if no columns + * - Defines empty state if no rows + * - Defines permissions `canAddColumns`, `canEditColumns`, `canEditCells` + * for `Table` using `userRolesAtom` in `projectScope` + * - Provides `Table` with hidden columns array from user settings */ export default function TablePage({ disableModals, disableSideDrawer, }: ITablePageProps) { + const [userRoles] = useAtom(userRolesAtom, projectScope); + const [userSettings] = useAtom(userSettingsAtom, projectScope); + const [tableId] = useAtom(tableIdAtom, tableScope); + const [tableSettings] = useAtom(tableSettingsAtom, tableScope); const [tableSchema] = useAtom(tableSchemaAtom, tableScope); const snackLogContext = useSnackLogContext(); + // Set permissions here so we can pass them to the `Table` component, which + // shouldn’t access `projectScope` at all, to separate concerns. + const canAddColumns = + userRoles.includes("ADMIN") || userRoles.includes("OPS"); + const canEditColumns = canAddColumns; + const canDeleteColumns = canAddColumns; + const canEditCells = + userRoles.includes("ADMIN") || + (!tableSettings.readOnly && + intersection(userRoles, tableSettings.roles).length > 0); + // Warn user about leaving when they have a table modal open useBeforeUnload(columnModalAtom, tableScope); useBeforeUnload(tableModalAtom, tableScope); - // A ref to the data grid. Contains data grid functions - const dataGridRef = useRef(null); - if (!(tableSchema as any)._rowy_ref) return ( <> @@ -94,13 +131,49 @@ export default function TablePage({ }> - + +
+
+ + + } + style={{ position: "absolute", inset: 0 }} + /> + } + /> + - {!disableSideDrawer && } + {!disableSideDrawer && } @@ -113,7 +186,11 @@ export default function TablePage({ {!disableModals && ( - + diff --git a/src/sources/TableSourceFirestore/TableSourceFirestore.tsx b/src/sources/TableSourceFirestore/TableSourceFirestore.tsx index 87949768..557fb75e 100644 --- a/src/sources/TableSourceFirestore/TableSourceFirestore.tsx +++ b/src/sources/TableSourceFirestore/TableSourceFirestore.tsx @@ -14,7 +14,7 @@ import { _updateRowDbAtom, _deleteRowDbAtom, tableNextPageAtom, - serverDocCountAtom + serverDocCountAtom, } from "@src/atoms/tableScope"; import useFirestoreDocWithAtom from "@src/hooks/useFirestoreDocWithAtom"; import useFirestoreCollectionWithAtom from "@src/hooks/useFirestoreCollectionWithAtom"; @@ -78,7 +78,7 @@ export const TableSourceFirestore = memo(function TableSourceFirestore() { updateDocAtom: _updateRowDbAtom, deleteDocAtom: _deleteRowDbAtom, nextPageAtom: tableNextPageAtom, - serverDocCountAtom: serverDocCountAtom + serverDocCountAtom: serverDocCountAtom, } ); diff --git a/src/theme/components.tsx b/src/theme/components.tsx index 9224d46d..374dc25b 100644 --- a/src/theme/components.tsx +++ b/src/theme/components.tsx @@ -680,6 +680,12 @@ export const components = (theme: Theme): ThemeOptions => { }, }, + MuiButtonBase: { + defaultProps: { + focusRipple: true, + }, + }, + MuiButton: { defaultProps: { variant: "outlined", diff --git a/src/types/json-stable-stringify-without-jsonify.d.ts b/src/types/json-stable-stringify-without-jsonify.d.ts index 3e1639a6..a0f5dc4b 100644 --- a/src/types/json-stable-stringify-without-jsonify.d.ts +++ b/src/types/json-stable-stringify-without-jsonify.d.ts @@ -1,4 +1,4 @@ declare module "json-stable-stringify-without-jsonify" { - const stringify: any; + const stringify: (...args: any) => string; export default stringify; } diff --git a/src/types/table.d.ts b/src/types/table.d.ts index f4e3bb96..b9753c41 100644 --- a/src/types/table.d.ts +++ b/src/types/table.d.ts @@ -137,16 +137,18 @@ export type ColumnConfig = { /** Prevent column resizability */ resizable?: boolean = true; - config?: { + config?: Partial<{ /** Set column to required */ - required?: boolean; + required: boolean; /** Set column default value */ - defaultValue?: { + defaultValue: { type: "undefined" | "null" | "static" | "dynamic"; value?: any; script?: string; dynamicValueFn?: string; }; + /** Regex used in CellValidation */ + validationRegex: string; /** FieldType to render for Derivative fields */ renderFieldType?: FieldType; /** Used in Derivative fields */ @@ -154,13 +156,13 @@ export type ColumnConfig = { /** Used in Derivative and Action fields */ requiredFields?: string[]; /** For sub-table fields */ - parentLabel?: string[]; + parentLabel: string[]; - primaryKeys?: string[]; + primaryKeys: string[]; /** Column-specific config */ [key: string]: any; - }; + }>; }; export type TableFilter = { diff --git a/yarn.lock b/yarn.lock index 91bc54a8..afcd3099 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2373,6 +2373,11 @@ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== +"@reach/observe-rect@^1.1.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@reach/observe-rect/-/observe-rect-1.2.0.tgz#d7a6013b8aafcc64c778a0ccb83355a11204d3b2" + integrity sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ== + "@react-dnd/asap@^4.0.0": version "4.0.1" resolved "https://registry.yarnpkg.com/@react-dnd/asap/-/asap-4.0.1.tgz#5291850a6b58ce6f2da25352a64f1b0674871aab" @@ -2698,6 +2703,18 @@ dependencies: "@jest/create-cache-key-function" "^27.4.2" +"@tanstack/react-table@^8.5.15": + version "8.5.15" + resolved "https://registry.yarnpkg.com/@tanstack/react-table/-/react-table-8.5.15.tgz#8179d24d7fdf909799a517e8897501c44e51284d" + integrity sha512-9rSvhIFeMpfXksFgQNTWnVoJbkae/U8CkHnHYGWAIB/O0Ca51IKap0Rjp5WkIUVBWxJ7Wfl2y13oY+aWcyM6Rg== + dependencies: + "@tanstack/table-core" "8.5.15" + +"@tanstack/table-core@8.5.15": + version "8.5.15" + resolved "https://registry.yarnpkg.com/@tanstack/table-core/-/table-core-8.5.15.tgz#e1e674135cd6c36f29a1562a2b846f824861149b" + integrity sha512-k+BcCOAYD610Cij6p1BPyEqjMQjZIdAnVDoIUKVnA/tfHbF4JlDP7pKAftXPBxyyX5Z1yQPurPnOdEY007Snyg== + "@testing-library/dom@^8.5.0": version "8.13.0" resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.13.0.tgz#bc00bdd64c7d8b40841e27a70211399ad3af46f5" @@ -9958,13 +9975,6 @@ react-color-palette@^6.2.0: resolved "https://registry.yarnpkg.com/react-color-palette/-/react-color-palette-6.2.0.tgz#aa3be88f6953d57502c00f4433692129ffbad3e7" integrity sha512-9rIboaRJNoeF8aCI2f3J8wgMyhl74SnGmZLDjor3bKf0iDBhP2EBv0/jGmm0hrj6OackGCqtWl5ZvM89XUc3sg== -react-data-grid@7.0.0-beta.5: - version "7.0.0-beta.5" - resolved "https://registry.yarnpkg.com/react-data-grid/-/react-data-grid-7.0.0-beta.5.tgz#bc39ce45b7a7f42ebfb66840e0ec1c8619d60f10" - integrity sha512-rtN4wnePrQ80UN6lYF/zUQqVVJMT3HW5bTLx9nR5XOKQiG72cGzX2d2+b+e82vUh23zTFBicEnuWSlN9Fa/83Q== - dependencies: - clsx "^1.1.1" - react-detect-offline@^2.4.5: version "2.4.5" resolved "https://registry.yarnpkg.com/react-detect-offline/-/react-detect-offline-2.4.5.tgz#3c242516c37b6789cf89102881031f87e70b80e6" @@ -10270,6 +10280,13 @@ react-usestateref@^1.0.8: resolved "https://registry.yarnpkg.com/react-usestateref/-/react-usestateref-1.0.8.tgz#b40519af0d6f3b3822c70eb5db80f7d47f1b1ff5" integrity sha512-whaE6H0XGarFKwZ3EYbpHBsRRCLZqdochzg/C7e+b6VFMTA3LS3K4ZfpI4NT40iy83jG89rGXrw70P9iDfOdsA== +react-virtual@^2.10.4: + version "2.10.4" + resolved "https://registry.yarnpkg.com/react-virtual/-/react-virtual-2.10.4.tgz#08712f0acd79d7d6f7c4726f05651a13b24d8704" + integrity sha512-Ir6+oPQZTVHfa6+JL9M7cvMILstFZH/H3jqeYeKI4MSUX+rIruVwFC6nGVXw9wqAw8L0Kg2KvfXxI85OvYQdpQ== + dependencies: + "@reach/observe-rect" "^1.1.0" + react@^18.0.0: version "18.0.0" resolved "https://registry.yarnpkg.com/react/-/react-18.0.0.tgz#b468736d1f4a5891f38585ba8e8fb29f91c3cb96"