mirror of
https://github.com/docmost/docmost.git
synced 2026-07-25 05:04:42 +10:00
feat(ee): bases
Table and kanban UI, formula engine package, and the base-embed editor extension
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import { memo } from "react";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classes from "@/ee/base/styles/grid.module.css";
|
||||
|
||||
type AddRowButtonProps = {
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export const AddRowButton = memo(function AddRowButton({
|
||||
onClick,
|
||||
}: AddRowButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classes.addRowButton}
|
||||
onClick={onClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<IconPlus size={14} />
|
||||
<span>{t("New row")}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
|
||||
import classes from "@/ee/base/styles/grid.module.css";
|
||||
|
||||
type Props = {
|
||||
edge: Edge;
|
||||
};
|
||||
|
||||
export function BaseDropEdgeIndicator({ edge }: Props) {
|
||||
return <div className={classes.dropEdgeLine} data-edge={edge} aria-hidden />;
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { memo, useCallback } from "react";
|
||||
import { Cell } from "@tanstack/react-table";
|
||||
import { Popover, Tooltip } from "@mantine/core";
|
||||
import { IconArrowsDiagonal } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { IBaseRow, EditingCell } from "@/ee/base/types/base.types";
|
||||
import {
|
||||
editingCellAtomFamily,
|
||||
activeFormulaEditorAtomFamily,
|
||||
FormulaEditorTarget,
|
||||
} from "@/ee/base/atoms/base-atoms";
|
||||
import { FormulaPropertyEditor } from "@/ee/base/components/formula/formula-property-editor";
|
||||
import {
|
||||
isSystemPropertyType,
|
||||
getDescriptor,
|
||||
} from "@/ee/base/property-types/property-type.registry";
|
||||
import { cellValuesEqual } from "@/ee/base/components/cells/cell-value-equal";
|
||||
import { useBaseEditable } from "@/ee/base/context/base-editable";
|
||||
import { useRowExpand } from "@/ee/base/context/row-expand";
|
||||
import { RowNumberCell } from "./row-number-cell";
|
||||
import classes from "@/ee/base/styles/grid.module.css";
|
||||
|
||||
type GridCellProps = {
|
||||
cell: Cell<IBaseRow, unknown>;
|
||||
rowIndex: number;
|
||||
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
|
||||
pageId: string;
|
||||
};
|
||||
|
||||
export const GridCell = memo(function GridCell({
|
||||
cell,
|
||||
rowIndex,
|
||||
onCellUpdate,
|
||||
pageId,
|
||||
}: GridCellProps) {
|
||||
const property = cell.column.columnDef.meta?.property;
|
||||
const isRowNumber = cell.column.id === "__row_number";
|
||||
const isPinned = cell.column.getIsPinned();
|
||||
const pinOffset = isPinned ? cell.column.getStart("left") : undefined;
|
||||
|
||||
const [editingCell, setEditingCell] = useAtom(editingCellAtomFamily(pageId)) as unknown as [EditingCell, (val: EditingCell) => void];
|
||||
const [activeFormulaEditor, setActiveFormulaEditor] = useAtom(
|
||||
activeFormulaEditorAtomFamily(pageId),
|
||||
) as unknown as [FormulaEditorTarget, (val: FormulaEditorTarget) => void];
|
||||
|
||||
const { t } = useTranslation();
|
||||
const editable = useBaseEditable();
|
||||
const readOnly = !editable;
|
||||
const onExpandRow = useRowExpand();
|
||||
|
||||
const rowId = cell.row.id;
|
||||
const isEditing =
|
||||
editingCell?.rowId === rowId &&
|
||||
editingCell?.propertyId === property?.id &&
|
||||
(editable || property?.type === "file");
|
||||
|
||||
const handleDoubleClick = useCallback(() => {
|
||||
if (!property || isRowNumber) return;
|
||||
if (property.type === "checkbox") return;
|
||||
if (readOnly) {
|
||||
// Read-only: only the file cell opens (a download-only popover) so
|
||||
// attachments stay reachable.
|
||||
if (property.type === "file") {
|
||||
setEditingCell({ rowId, propertyId: property.id });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (property.type === "formula") {
|
||||
setActiveFormulaEditor({ propertyId: property.id, rowId });
|
||||
return;
|
||||
}
|
||||
if (isSystemPropertyType(property.type)) return;
|
||||
setEditingCell({ rowId, propertyId: property.id });
|
||||
}, [property, isRowNumber, rowId, readOnly, setEditingCell, setActiveFormulaEditor]);
|
||||
|
||||
const closeFormulaEditor = useCallback(
|
||||
() => setActiveFormulaEditor(null),
|
||||
[setActiveFormulaEditor],
|
||||
);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(value: unknown) => {
|
||||
if (!property) return;
|
||||
if (!cellValuesEqual(value, cell.getValue())) {
|
||||
onCellUpdate(rowId, property.id, value);
|
||||
}
|
||||
},
|
||||
[property, rowId, cell, onCellUpdate],
|
||||
);
|
||||
|
||||
const handleCommit = useCallback(
|
||||
(value: unknown) => {
|
||||
handleValueChange(value);
|
||||
setEditingCell(null);
|
||||
},
|
||||
[handleValueChange, setEditingCell],
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setEditingCell(null);
|
||||
}, [setEditingCell]);
|
||||
|
||||
if (isRowNumber) {
|
||||
return (
|
||||
<RowNumberCell
|
||||
rowId={rowId}
|
||||
rowIndex={rowIndex}
|
||||
isPinned={Boolean(isPinned)}
|
||||
pinOffset={pinOffset}
|
||||
pageId={pageId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!property) return null;
|
||||
|
||||
const CellComponent = getDescriptor(property.type)?.cellComponent;
|
||||
if (!CellComponent) return null;
|
||||
|
||||
const value = cell.getValue();
|
||||
|
||||
const cellInner = (
|
||||
<div
|
||||
className={`${classes.cell} ${isPinned ? classes.cellPinned : ""} ${isEditing ? classes.cellEditing : ""} ${property.isPrimary ? classes.primaryCell : ""}`}
|
||||
style={
|
||||
isPinned
|
||||
? ({ "--pin-offset": `${pinOffset}px` } as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
<CellComponent
|
||||
value={value}
|
||||
property={property}
|
||||
rowId={rowId}
|
||||
isEditing={isEditing}
|
||||
readOnly={readOnly}
|
||||
onCommit={handleCommit}
|
||||
onValueChange={handleValueChange}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
{property.isPrimary && onExpandRow && !isEditing && (
|
||||
<span className={classes.rowExpandAnchor}>
|
||||
<Tooltip label={t("Expand")} position="bottom" openDelay={400}>
|
||||
<button
|
||||
type="button"
|
||||
className={classes.rowExpandButton}
|
||||
onClick={() => onExpandRow(rowId)}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
aria-label={t("Expand row {{number}}", { number: rowIndex + 1 })}
|
||||
>
|
||||
<IconArrowsDiagonal size={13} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (property.type !== "formula") return cellInner;
|
||||
|
||||
const formulaEditorOpen =
|
||||
activeFormulaEditor?.propertyId === property.id &&
|
||||
activeFormulaEditor?.rowId === rowId;
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={formulaEditorOpen}
|
||||
onChange={(o) => {
|
||||
if (!o) closeFormulaEditor();
|
||||
}}
|
||||
position="bottom-start"
|
||||
width={460}
|
||||
shadow="md"
|
||||
withinPortal
|
||||
closeOnClickOutside
|
||||
closeOnEscape={false}
|
||||
trapFocus
|
||||
>
|
||||
<Popover.Target>{cellInner}</Popover.Target>
|
||||
<Popover.Dropdown
|
||||
p={0}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
closeFormulaEditor();
|
||||
}
|
||||
}}
|
||||
style={{ maxWidth: "calc(100vw - 32px)" }}
|
||||
>
|
||||
{formulaEditorOpen && (
|
||||
<FormulaPropertyEditor
|
||||
property={property}
|
||||
pageId={pageId}
|
||||
onClose={closeFormulaEditor}
|
||||
/>
|
||||
)}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
},
|
||||
gridCellPropsEqual);
|
||||
|
||||
// Cell instances are re-created whenever the table data identity changes;
|
||||
// compare by coordinates + value so unchanged cells skip re-rendering.
|
||||
function gridCellPropsEqual(prev: GridCellProps, next: GridCellProps) {
|
||||
if (
|
||||
prev.rowIndex !== next.rowIndex ||
|
||||
prev.pageId !== next.pageId ||
|
||||
prev.onCellUpdate !== next.onCellUpdate
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (prev.cell === next.cell) return true;
|
||||
return (
|
||||
prev.cell.row.id === next.cell.row.id &&
|
||||
prev.cell.column.id === next.cell.column.id &&
|
||||
prev.cell.column.columnDef.meta?.property ===
|
||||
next.cell.column.columnDef.meta?.property &&
|
||||
cellValuesEqual(prev.cell.getValue(), next.cell.getValue())
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
import { useRef, useMemo, useCallback, useEffect, useState, useLayoutEffect } from "react";
|
||||
import { Table } from "@tanstack/react-table";
|
||||
import {
|
||||
observeWindowOffset,
|
||||
observeWindowRect,
|
||||
useVirtualizer,
|
||||
windowScroll,
|
||||
} from "@tanstack/react-virtual";
|
||||
import { useAtom } from "jotai";
|
||||
import { IBaseRow, IBaseProperty, EditingCell } from "@/ee/base/types/base.types";
|
||||
import { editingCellAtomFamily } from "@/ee/base/atoms/base-atoms";
|
||||
import { useColumnResize } from "@/ee/base/hooks/use-column-resize";
|
||||
import { useGridKeyboardNav } from "@/ee/base/hooks/use-grid-keyboard-nav";
|
||||
import { useRowAutoScroll } from "@/ee/base/hooks/use-row-autoscroll";
|
||||
import { useRowSelection } from "@/ee/base/hooks/use-row-selection";
|
||||
import { useDeleteSelectedRows } from "@/ee/base/hooks/use-delete-selected-rows";
|
||||
import { useHorizontalScrollSync } from "@/ee/base/hooks/use-horizontal-scroll-sync";
|
||||
import { useGridAutoScroll } from "@/ee/base/hooks/use-grid-autoscroll";
|
||||
import { GridHeader } from "./grid-header";
|
||||
import { GridRow } from "./grid-row";
|
||||
import { AddRowButton } from "./add-row-button";
|
||||
import { GridGhostRows } from "./grid-ghost-rows";
|
||||
import { SelectionActionBar } from "./selection-action-bar";
|
||||
import { useBaseEditable } from "@/ee/base/context/base-editable";
|
||||
import { GridRowOrderProvider } from "@/ee/base/context/grid-row-order";
|
||||
import classes from "@/ee/base/styles/grid.module.css";
|
||||
|
||||
// Row box = 36px cell content + 1px row border-bottom. CSS pins .row to
|
||||
// var(--base-row-height) from this constant so the rendered height can
|
||||
// never drift from the virtualizer estimate.
|
||||
const ROW_HEIGHT = 37;
|
||||
const OVERSCAN = 25;
|
||||
|
||||
const GRID_ROOT_STYLE = {
|
||||
"--base-row-height": `${ROW_HEIGHT}px`,
|
||||
} as React.CSSProperties;
|
||||
|
||||
const ADD_COLUMN_TRACK_WIDTH = 40;
|
||||
|
||||
// Hoisted to module scope to avoid allocating a fresh options object on
|
||||
// every GridContainer render. The function refs from virtual-core are
|
||||
// stable; only the wrapper object identity matters for downstream
|
||||
// memoization inside useVirtualizer.
|
||||
const WINDOW_SCROLL_OPTIONS = {
|
||||
observeElementRect: observeWindowRect as never,
|
||||
observeElementOffset: observeWindowOffset as never,
|
||||
scrollToFn: windowScroll as never,
|
||||
} as const;
|
||||
|
||||
type GridContainerProps = {
|
||||
table: Table<IBaseRow>;
|
||||
properties: IBaseProperty[];
|
||||
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
|
||||
onAddRow?: () => void;
|
||||
pageId: string;
|
||||
onColumnReorder?: (columnId: string, finishIndex: number) => void;
|
||||
onResizeEnd?: () => void;
|
||||
onRowReorder?: (rowId: string, targetRowId: string, position: "above" | "below") => void;
|
||||
hasNextPage?: boolean;
|
||||
isFetchingNextPage?: boolean;
|
||||
onFetchNextPage?: () => void;
|
||||
/** true when a view filter with at least one condition is active; suppresses ghost rows */
|
||||
isFiltered?: boolean;
|
||||
/**
|
||||
* What the virtualizer measures and what the StickyBand sticks to.
|
||||
* Standalone passes a ref into the .tableScrollport wrapper; inline
|
||||
* passes `window` since the page itself is the scroll container.
|
||||
*/
|
||||
scrollElement: HTMLElement | Window | null;
|
||||
/**
|
||||
* Rendered inside `[role=grid]` but ABOVE the sticky band, so it scrolls
|
||||
* with the content while only the column-header row stays pinned. In
|
||||
* inline mode BaseTable injects banner + toolbar here; standalone passes
|
||||
* null (they render outside the scrollport instead).
|
||||
*/
|
||||
aboveBand?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function GridContainer({
|
||||
table,
|
||||
properties,
|
||||
onCellUpdate,
|
||||
onAddRow,
|
||||
pageId,
|
||||
onColumnReorder,
|
||||
onResizeEnd,
|
||||
onRowReorder,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
onFetchNextPage,
|
||||
isFiltered,
|
||||
scrollElement,
|
||||
aboveBand,
|
||||
}: GridContainerProps) {
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
const rowsContainerRef = useRef<HTMLDivElement>(null);
|
||||
useHorizontalScrollSync(bodyRef, headerRef);
|
||||
useGridAutoScroll(bodyRef, pageId);
|
||||
useRowAutoScroll(scrollElement, pageId);
|
||||
const lastTriggeredRowsLenRef = useRef(0);
|
||||
const rows = table.getRowModel().rows;
|
||||
const rowIds = useMemo(() => rows.map((r) => r.id), [rows]);
|
||||
const rowIdsRef = useRef(rowIds);
|
||||
rowIdsRef.current = rowIds;
|
||||
const getOrderedRowIds = useCallback(() => rowIdsRef.current, []);
|
||||
const editable = useBaseEditable();
|
||||
|
||||
const [editingCell, setEditingCell] = useAtom(editingCellAtomFamily(pageId)) as unknown as [EditingCell, (val: EditingCell) => void];
|
||||
const editingCellRef = useRef(editingCell);
|
||||
editingCellRef.current = editingCell;
|
||||
|
||||
const { selectionCount, clear: clearSelection } = useRowSelection(pageId);
|
||||
const { deleteSelected } = useDeleteSelectedRows(pageId);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
// Only act while an inline cell editor is open. Popover-based cells
|
||||
// (select/status/person/page/date/file) self-dismiss via Mantine onChange.
|
||||
// This handler's sole job is to commit an inline input editor
|
||||
// (text/number/url/email) when the user clicks elsewhere, since clicking
|
||||
// a non-focusable cell does not natively blur the input. Gating on
|
||||
// editingCell also stops it from stealing focus from unrelated inputs.
|
||||
if (!editingCellRef.current) return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest(`.${classes.headerCell}`)) return;
|
||||
if (target.closest("[role=\"dialog\"]")) return;
|
||||
if (target.closest("[role=\"listbox\"]")) return;
|
||||
if (target.closest("[data-mantine-shared-portal-node]")) return;
|
||||
if (target.closest(`.${classes.cellEditing}`)) return;
|
||||
// Blurring the input fires its onBlur -> commitOnce -> handleCommit,
|
||||
// which also clears editingCell. No setEditingCell(null) needed here.
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (active && active !== document.body && typeof active.blur === "function") {
|
||||
active.blur();
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleMouseDown);
|
||||
return () => document.removeEventListener("mousedown", handleMouseDown);
|
||||
}, []);
|
||||
|
||||
useColumnResize(table, onResizeEnd ?? (() => {}));
|
||||
|
||||
useGridKeyboardNav({
|
||||
table,
|
||||
editingCell,
|
||||
setEditingCell,
|
||||
containerRef: bodyRef,
|
||||
});
|
||||
|
||||
// When the scroll container is the window (inline embed mode), the default
|
||||
// Element-mode observers read scrollTop/scrollLeft, which Window does not
|
||||
// have. Swap in the Window-mode observers so the virtualizer reads
|
||||
// scrollY/scrollX instead. The Element-narrowed type is satisfied by an
|
||||
// upcast on getScrollElement; virtual-core's runtime accepts Window when
|
||||
// the observers do.
|
||||
const isWindowScroll =
|
||||
typeof window !== "undefined" && scrollElement === window;
|
||||
const windowScrollOptions = isWindowScroll ? WINDOW_SCROLL_OPTIONS : {};
|
||||
|
||||
// Rows are positioned inside .rowsContainer, which sits below the sticky
|
||||
// band (and aboveBand content) within the scroll content. scrollMargin =
|
||||
// the container's offset from the scroll content top, in both modes, so
|
||||
// virtual indexing lines up with what is actually on screen.
|
||||
const [scrollMargin, setScrollMargin] = useState(0);
|
||||
useLayoutEffect(() => {
|
||||
const el = rowsContainerRef.current;
|
||||
if (!el || !scrollElement) return;
|
||||
const update = () => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (isWindowScroll) {
|
||||
setScrollMargin(rect.top + window.scrollY);
|
||||
} else {
|
||||
const scrollport = scrollElement as HTMLElement;
|
||||
setScrollMargin(
|
||||
rect.top -
|
||||
scrollport.getBoundingClientRect().top +
|
||||
scrollport.scrollTop,
|
||||
);
|
||||
}
|
||||
};
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
// Outer page reflows (sidebar collapse, viewport resize) can move the
|
||||
// grid without resizing it, so listen to window resize too.
|
||||
window.addEventListener("resize", update);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
window.removeEventListener("resize", update);
|
||||
};
|
||||
}, [isWindowScroll, scrollElement]);
|
||||
|
||||
// Stable row-id keys: the direct-update element cache and measurement
|
||||
// cache are keyed by item key, so index keys would go stale whenever rows
|
||||
// are inserted or reordered above the viewport.
|
||||
const getItemKey = useCallback(
|
||||
(index: number) => rowIds[index] ?? index,
|
||||
[rowIds],
|
||||
);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => scrollElement as Element | null,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
overscan: OVERSCAN,
|
||||
scrollMargin,
|
||||
getItemKey,
|
||||
directDomUpdates: true,
|
||||
// 'position' (writes `top`), not 'transform': a transform on the row
|
||||
// creates a containing block that breaks the position:sticky pinned
|
||||
// cells inside it.
|
||||
directDomUpdatesMode: "position",
|
||||
...windowScrollOptions,
|
||||
// virtual-core bug: on first attach _willUpdate calls
|
||||
// _scrollToOffset(getScrollOffset()), which returns undefined when no
|
||||
// initialOffset is provided. windowScroll then computes undefined + 0 = NaN,
|
||||
// browsers coerce it to 0, and scrollY snaps to 0 when the embed mounts
|
||||
// mid-page. Seeding initialOffset to the current scroll position makes
|
||||
// the first _scrollToOffset a no-op.
|
||||
initialOffset: isWindowScroll
|
||||
? () => window.scrollY
|
||||
: () =>
|
||||
scrollElement instanceof HTMLElement ? scrollElement.scrollTop : 0,
|
||||
});
|
||||
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasNextPage || isFetchingNextPage || !onFetchNextPage) return;
|
||||
const lastItem = virtualItems[virtualItems.length - 1];
|
||||
if (!lastItem) return;
|
||||
if (lastItem.index < rows.length - OVERSCAN * 2) return;
|
||||
if (rows.length <= lastTriggeredRowsLenRef.current) return;
|
||||
lastTriggeredRowsLenRef.current = rows.length;
|
||||
onFetchNextPage();
|
||||
}, [virtualItems, rows.length, hasNextPage, isFetchingNextPage, onFetchNextPage]);
|
||||
|
||||
useEffect(() => {
|
||||
// When the row set shrinks (filter/sort/view change) or resets to zero,
|
||||
// un-gate the trigger so the first page can trigger the next fetch correctly.
|
||||
if (rows.length === 0 || rows.length < lastTriggeredRowsLenRef.current) {
|
||||
lastTriggeredRowsLenRef.current = 0;
|
||||
}
|
||||
}, [rows.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = bodyRef.current;
|
||||
if (!el || !pageId) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (editingCell) return;
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (!active || !el.contains(active)) return;
|
||||
const tag = active.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || active.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape" && selectionCount > 0) {
|
||||
clearSelection();
|
||||
return;
|
||||
}
|
||||
if ((e.key === "Delete" || e.key === "Backspace") && selectionCount > 0) {
|
||||
e.preventDefault();
|
||||
void deleteSelected();
|
||||
}
|
||||
};
|
||||
el.addEventListener("keydown", handler);
|
||||
return () => el.removeEventListener("keydown", handler);
|
||||
}, [editingCell, selectionCount, clearSelection, deleteSelected, pageId]);
|
||||
|
||||
const gridTemplateColumns = useMemo(() => {
|
||||
const visibleColumns = table.getVisibleLeafColumns();
|
||||
const columnWidths = visibleColumns.map((col) => `${col.getSize()}px`);
|
||||
return (
|
||||
columnWidths.join(" ") +
|
||||
(pageId && editable ? ` ${ADD_COLUMN_TRACK_WIDTH}px` : "")
|
||||
);
|
||||
}, [table, table.getState().columnSizing, table.getState().columnVisibility, table.getState().columnOrder, pageId, editable]);
|
||||
|
||||
const totalColumnsWidth = useMemo(
|
||||
() =>
|
||||
table
|
||||
.getVisibleLeafColumns()
|
||||
.reduce((sum, col) => sum + col.getSize(), 0) +
|
||||
(pageId && editable ? ADD_COLUMN_TRACK_WIDTH : 0),
|
||||
[table, table.getState().columnSizing, table.getState().columnVisibility, table.getState().columnOrder, pageId, editable],
|
||||
);
|
||||
|
||||
const showGhostRows = rows.length === 0 && !isFiltered;
|
||||
// Append a flexible trailing track so every row spans the full width.
|
||||
// minmax(0, 1fr) collapses to 0 when columns overflow the viewport and
|
||||
// fills remaining width otherwise. The header grid keeps the plain template.
|
||||
const bodyGridTemplateColumns = `${gridTemplateColumns} minmax(0, 1fr)`;
|
||||
|
||||
const handleAddRow = useCallback(() => {
|
||||
onAddRow?.();
|
||||
}, [onAddRow]);
|
||||
|
||||
const handlePropertyCreated = useCallback(() => {
|
||||
// Wait for React to re-render with the new column, then scroll to it
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
bodyRef.current?.scrollTo({
|
||||
left: bodyRef.current.scrollWidth,
|
||||
behavior: "smooth",
|
||||
});
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getColumnOrder = useCallback(
|
||||
() => table.getState().columnOrder,
|
||||
[table],
|
||||
);
|
||||
|
||||
return (
|
||||
<div role="grid" style={GRID_ROOT_STYLE}>
|
||||
{aboveBand}
|
||||
<div className={classes.stickyBand}>
|
||||
<div
|
||||
className={classes.headerGrid}
|
||||
ref={headerRef}
|
||||
style={{ gridTemplateColumns }}
|
||||
role="row"
|
||||
>
|
||||
<GridHeader
|
||||
table={table}
|
||||
pageId={pageId}
|
||||
columnOrder={table.getState().columnOrder}
|
||||
columnVisibility={table.getState().columnVisibility}
|
||||
properties={properties}
|
||||
loadedRowIds={rowIds}
|
||||
onPropertyCreated={handlePropertyCreated}
|
||||
getColumnOrder={getColumnOrder}
|
||||
onColumnReorder={onColumnReorder}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<GridRowOrderProvider value={getOrderedRowIds}>
|
||||
<div
|
||||
className={classes.bodyGrid}
|
||||
ref={bodyRef}
|
||||
tabIndex={0}
|
||||
style={
|
||||
{
|
||||
"--base-grid-cols": bodyGridTemplateColumns,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={classes.rowsContainer}
|
||||
ref={(node) => {
|
||||
rowsContainerRef.current = node;
|
||||
virtualizer.containerRef(node);
|
||||
}}
|
||||
role="rowgroup"
|
||||
style={{ width: totalColumnsWidth, minWidth: "100%" }}
|
||||
>
|
||||
{virtualItems.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index];
|
||||
if (!row) return null;
|
||||
return (
|
||||
<GridRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
rowIndex={virtualRow.index}
|
||||
measureRef={virtualizer.measureElement}
|
||||
onCellUpdate={onCellUpdate}
|
||||
properties={properties}
|
||||
columnVisibility={table.getState().columnVisibility}
|
||||
columnOrder={table.getState().columnOrder}
|
||||
pageId={pageId}
|
||||
onRowReorder={onRowReorder}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{showGhostRows && (
|
||||
<GridGhostRows
|
||||
count={3}
|
||||
columnCount={table.getVisibleLeafColumns().length}
|
||||
onCreate={editable ? handleAddRow : undefined}
|
||||
/>
|
||||
)}
|
||||
{editable && <AddRowButton onClick={handleAddRow} />}
|
||||
{pageId && <SelectionActionBar pageId={pageId} />}
|
||||
</div>
|
||||
</GridRowOrderProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import classes from "@/ee/base/styles/grid.module.css";
|
||||
|
||||
type GridGhostRowsProps = {
|
||||
/** how many placeholder rows to render */
|
||||
count: number;
|
||||
/** number of visible leaf columns (incl. the row-number column) */
|
||||
columnCount: number;
|
||||
/** create the first real row (clicking any ghost cell); omit when read-only */
|
||||
onCreate?: () => void;
|
||||
};
|
||||
|
||||
// Empty-state ghost rows shown when no data rows exist and no filter is active.
|
||||
// Clicking any ghost row creates the first real row; cells align via subgrid.
|
||||
export function GridGhostRows({ count, columnCount, onCreate }: GridGhostRowsProps) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: count }).map((_, rowIdx) => (
|
||||
<div
|
||||
key={rowIdx}
|
||||
className={`${classes.row} ${classes.ghostRow}`}
|
||||
role={onCreate ? "button" : undefined}
|
||||
aria-label={onCreate ? "Create first row" : undefined}
|
||||
onClick={onCreate}
|
||||
>
|
||||
{Array.from({ length: columnCount }).map((_, colIdx) => (
|
||||
<div key={colIdx} className={classes.cell} aria-hidden="true" />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Header, flexRender } from "@tanstack/react-table";
|
||||
import { Badge, Popover } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
import {
|
||||
draggable,
|
||||
dropTargetForElements,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import {
|
||||
attachClosestEdge,
|
||||
extractClosestEdge,
|
||||
type Edge,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
|
||||
import { getReorderDestinationIndex } from "@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index";
|
||||
import { triggerPostMoveFlash } from "@atlaskit/pragmatic-drag-and-drop-flourish/trigger-post-move-flash";
|
||||
import * as liveRegion from "@atlaskit/pragmatic-drag-and-drop-live-region";
|
||||
import { IBaseRow, IBaseProperty, EditingCell } from "@/ee/base/types/base.types";
|
||||
import {
|
||||
activePropertyMenuAtomFamily,
|
||||
propertyMenuDirtyAtomFamily,
|
||||
propertyMenuCloseRequestAtomFamily,
|
||||
editingCellAtomFamily,
|
||||
activeFormulaEditorAtomFamily,
|
||||
FormulaEditorTarget,
|
||||
} from "@/ee/base/atoms/base-atoms";
|
||||
import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
|
||||
import { PropertyMenuContent } from "@/ee/base/components/property/property-menu";
|
||||
import { FormulaPropertyEditor } from "@/ee/base/components/formula/formula-property-editor";
|
||||
import { RowNumberHeaderCell } from "./row-number-header-cell";
|
||||
import { BaseDropEdgeIndicator } from "./base-drop-edge-indicator";
|
||||
import { useRowSelection } from "@/ee/base/hooks/use-row-selection";
|
||||
import { useBaseEditable } from "@/ee/base/context/base-editable";
|
||||
import classes from "@/ee/base/styles/grid.module.css";
|
||||
|
||||
export const COLUMN_DRAG_TYPE = "base-column";
|
||||
|
||||
type GridHeaderCellProps = {
|
||||
header: Header<IBaseRow, unknown>;
|
||||
property: IBaseProperty | undefined;
|
||||
loadedRowIds: string[];
|
||||
pageId: string;
|
||||
getColumnOrder: () => string[];
|
||||
onColumnReorder?: (columnId: string, finishIndex: number) => void;
|
||||
};
|
||||
|
||||
export const GridHeaderCell = memo(function GridHeaderCell({
|
||||
header,
|
||||
property,
|
||||
loadedRowIds,
|
||||
pageId,
|
||||
getColumnOrder,
|
||||
onColumnReorder,
|
||||
}: GridHeaderCellProps) {
|
||||
const { t } = useTranslation();
|
||||
const isRowNumber = header.column.id === "__row_number";
|
||||
const isPinned = header.column.getIsPinned();
|
||||
const pinOffset = isPinned ? header.column.getStart("left") : undefined;
|
||||
const { selectionCount } = useRowSelection(pageId);
|
||||
const hasSelection = selectionCount > 0;
|
||||
const editable = useBaseEditable();
|
||||
|
||||
const [activePropertyMenu, setActivePropertyMenu] = useAtom(activePropertyMenuAtomFamily(pageId)) as unknown as [string | null, (val: string | null) => void];
|
||||
const menuOpened = activePropertyMenu === header.column.id;
|
||||
const cellRef = useRef<HTMLDivElement>(null);
|
||||
const [propertyMenuDirty, setPropertyMenuDirty] = useAtom(propertyMenuDirtyAtomFamily(pageId)) as unknown as [boolean, (val: boolean) => void];
|
||||
const [closeRequest, setCloseRequest] = useAtom(propertyMenuCloseRequestAtomFamily(pageId)) as unknown as [number, (val: number) => void];
|
||||
const [, setEditingCell] = useAtom(editingCellAtomFamily(pageId)) as unknown as [EditingCell, (val: EditingCell) => void];
|
||||
const [activeFormulaEditor, setActiveFormulaEditor] = useAtom(
|
||||
activeFormulaEditorAtomFamily(pageId),
|
||||
) as unknown as [FormulaEditorTarget, (val: FormulaEditorTarget) => void];
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
|
||||
|
||||
const resizeIntentRef = useRef(false);
|
||||
|
||||
const handleDirtyChange = useCallback((dirty: boolean) => {
|
||||
setPropertyMenuDirty(dirty);
|
||||
}, [setPropertyMenuDirty]);
|
||||
|
||||
const isSortableDisabled = isRowNumber || !!isPinned || !editable;
|
||||
|
||||
// onColumnReorder ultimately depends on React Query result objects
|
||||
// (activeView, base) via persistViewConfig, and their identity changes on
|
||||
// every cache invalidation (every WS-driven collab refresh). Holding the
|
||||
// callback in a ref keeps it out of the DnD effect's dep array so we don't
|
||||
// tear down and re-register the pragmatic-dnd adapter on every header cell
|
||||
// each time another user edits the base.
|
||||
const onColumnReorderRef = useRef(onColumnReorder);
|
||||
useLayoutEffect(() => {
|
||||
onColumnReorderRef.current = onColumnReorder;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const el = cellRef.current;
|
||||
if (!el || isSortableDisabled) return;
|
||||
return combine(
|
||||
draggable({
|
||||
element: el,
|
||||
canDrag: () => !resizeIntentRef.current,
|
||||
getInitialData: () => ({
|
||||
type: COLUMN_DRAG_TYPE,
|
||||
columnId: header.column.id,
|
||||
pageId,
|
||||
}),
|
||||
onDragStart: () => setIsDragging(true),
|
||||
onDrop: () => setIsDragging(false),
|
||||
}),
|
||||
dropTargetForElements({
|
||||
element: el,
|
||||
canDrop: ({ source }) =>
|
||||
source.data.type === COLUMN_DRAG_TYPE &&
|
||||
source.data.columnId !== header.column.id,
|
||||
getData: ({ input, element }) =>
|
||||
attachClosestEdge(
|
||||
{ columnId: header.column.id },
|
||||
{ input, element, allowedEdges: ["left", "right"] },
|
||||
),
|
||||
onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)),
|
||||
onDragLeave: () => setClosestEdge(null),
|
||||
onDrop: ({ source, self }) => {
|
||||
setClosestEdge(null);
|
||||
const edge = extractClosestEdge(self.data);
|
||||
if (!edge) return;
|
||||
const order = getColumnOrder();
|
||||
const startIndex = order.indexOf(source.data.columnId as string);
|
||||
const indexOfTarget = order.indexOf(header.column.id);
|
||||
if (startIndex === -1 || indexOfTarget === -1) return;
|
||||
const finishIndex = getReorderDestinationIndex({
|
||||
startIndex,
|
||||
indexOfTarget,
|
||||
closestEdgeOfTarget: edge,
|
||||
axis: "horizontal",
|
||||
});
|
||||
if (finishIndex === startIndex) return;
|
||||
onColumnReorderRef.current?.(source.data.columnId as string, finishIndex);
|
||||
triggerPostMoveFlash(el);
|
||||
liveRegion.announce(`Moved column to position ${finishIndex + 1}`);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, [header.column.id, isSortableDisabled, getColumnOrder]);
|
||||
|
||||
const handleHeaderClick = useCallback(() => {
|
||||
if (resizeIntentRef.current) {
|
||||
resizeIntentRef.current = false;
|
||||
return;
|
||||
}
|
||||
setEditingCell(null);
|
||||
if (!editable) return;
|
||||
if (!isRowNumber && property && !isDragging) {
|
||||
if (propertyMenuDirty && !menuOpened) return;
|
||||
setActivePropertyMenu(menuOpened ? null : header.column.id);
|
||||
}
|
||||
}, [editable, isRowNumber, property, isDragging, header.column.id, menuOpened, propertyMenuDirty, setActivePropertyMenu, setEditingCell]);
|
||||
|
||||
const handleMenuClose = useCallback(() => {
|
||||
setActivePropertyMenu(null);
|
||||
}, [setActivePropertyMenu]);
|
||||
|
||||
const handleEditFormula = useCallback(() => {
|
||||
if (!property) return;
|
||||
handleMenuClose();
|
||||
setActiveFormulaEditor({ propertyId: property.id, rowId: null });
|
||||
}, [property, handleMenuClose, setActiveFormulaEditor]);
|
||||
|
||||
const closeFormulaEditor = useCallback(
|
||||
() => setActiveFormulaEditor(null),
|
||||
[setActiveFormulaEditor],
|
||||
);
|
||||
|
||||
const formulaEditorOpen =
|
||||
!!property &&
|
||||
activeFormulaEditor?.propertyId === property.id &&
|
||||
activeFormulaEditor?.rowId === null;
|
||||
|
||||
// A closed property menu can never hold unsaved changes. Saving a rename
|
||||
// must clear propertyMenuDirty; otherwise it stays stuck true and
|
||||
// handleHeaderClick refuses to reopen any property menu, making the menu
|
||||
// appear dead after the first save. Reset only on the open-to-closed
|
||||
// transition so a sibling header cell can't clear the flag while another
|
||||
// column's menu is mid-edit.
|
||||
const wasMenuOpenedRef = useRef(menuOpened);
|
||||
useEffect(() => {
|
||||
if (wasMenuOpenedRef.current && !menuOpened) {
|
||||
setPropertyMenuDirty(false);
|
||||
}
|
||||
wasMenuOpenedRef.current = menuOpened;
|
||||
}, [menuOpened, setPropertyMenuDirty]);
|
||||
|
||||
const handleMenuOpenChange = useCallback(
|
||||
(next: boolean) => {
|
||||
if (next) return; // opening is driven by the atom, not Mantine
|
||||
if (propertyMenuDirty) {
|
||||
// Veto the close and route through the discard-confirm flow.
|
||||
setCloseRequest(closeRequest + 1);
|
||||
} else {
|
||||
handleMenuClose();
|
||||
}
|
||||
},
|
||||
[propertyMenuDirty, closeRequest, setCloseRequest, handleMenuClose],
|
||||
);
|
||||
|
||||
const TypeIcon = property ? getDescriptor(property.type)?.icon : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cellRef}
|
||||
className={`${classes.headerCell} ${isPinned ? classes.headerCellPinned : ""} ${hasSelection ? classes.hasSelection : ""}`}
|
||||
style={{
|
||||
...(isPinned
|
||||
? ({ "--pin-offset": `${pinOffset}px` } as React.CSSProperties)
|
||||
: {}),
|
||||
...(isRowNumber || !editable ? {} : { cursor: "pointer" }),
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
}}
|
||||
onPointerDown={() => {
|
||||
resizeIntentRef.current = false;
|
||||
}}
|
||||
onClick={handleHeaderClick}
|
||||
data-dragging={isDragging || undefined}
|
||||
>
|
||||
{isRowNumber ? (
|
||||
<RowNumberHeaderCell loadedRowIds={loadedRowIds} pageId={pageId} />
|
||||
) : (
|
||||
<div className={classes.headerCellContent}>
|
||||
{TypeIcon && (
|
||||
<TypeIcon size={14} className={classes.headerTypeIcon} />
|
||||
)}
|
||||
<span className={classes.headerCellName}>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</span>
|
||||
{property?.pendingType && (
|
||||
<Badge size="xs" color="gray" variant="light" ml={6}>
|
||||
{t("Converting…")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{editable && header.column.getCanResize() && (
|
||||
<div
|
||||
className={`${classes.resizeHandle} ${
|
||||
header.column.getIsResizing() ? classes.resizeHandleActive : ""
|
||||
}`}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
header.getResizeHandler()(e);
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.stopPropagation();
|
||||
header.getResizeHandler()(e);
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
resizeIntentRef.current = true;
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
{closestEdge && <BaseDropEdgeIndicator edge={closestEdge} />}
|
||||
{editable && property && !isRowNumber && (
|
||||
<Popover
|
||||
opened={menuOpened}
|
||||
onChange={handleMenuOpenChange}
|
||||
onClose={handleMenuClose}
|
||||
position="bottom-start"
|
||||
shadow="md"
|
||||
width={260}
|
||||
trapFocus
|
||||
withinPortal
|
||||
closeOnClickOutside
|
||||
closeOnEscape
|
||||
>
|
||||
<Popover.Target>
|
||||
<div className={classes.popoverAnchor} />
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown
|
||||
p={0}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<PropertyMenuContent
|
||||
property={property}
|
||||
opened={menuOpened}
|
||||
onClose={handleMenuClose}
|
||||
onDirtyChange={handleDirtyChange}
|
||||
onEditFormula={
|
||||
property.type === "formula" ? handleEditFormula : undefined
|
||||
}
|
||||
pageId={pageId}
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)}
|
||||
{property && !isRowNumber && property.type === "formula" && (
|
||||
<Popover
|
||||
opened={formulaEditorOpen}
|
||||
onChange={(o) => {
|
||||
if (!o) closeFormulaEditor();
|
||||
}}
|
||||
position="bottom-start"
|
||||
width={460}
|
||||
shadow="md"
|
||||
withinPortal
|
||||
closeOnClickOutside
|
||||
closeOnEscape={false}
|
||||
trapFocus
|
||||
>
|
||||
<Popover.Target>
|
||||
<div className={classes.popoverAnchor} />
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown
|
||||
p={0}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
closeFormulaEditor();
|
||||
}
|
||||
}}
|
||||
style={{ maxWidth: "calc(100vw - 32px)" }}
|
||||
>
|
||||
{formulaEditorOpen && (
|
||||
<FormulaPropertyEditor
|
||||
property={property}
|
||||
pageId={pageId}
|
||||
onClose={closeFormulaEditor}
|
||||
/>
|
||||
)}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { memo, useMemo } from "react";
|
||||
import { Table, ColumnOrderState, VisibilityState } from "@tanstack/react-table";
|
||||
import { IBaseRow, IBaseProperty } from "@/ee/base/types/base.types";
|
||||
import { GridHeaderCell } from "./grid-header-cell";
|
||||
import { CreatePropertyPopover } from "@/ee/base/components/property/create-property-popover";
|
||||
import { useBaseEditable } from "@/ee/base/context/base-editable";
|
||||
import classes from "@/ee/base/styles/grid.module.css";
|
||||
|
||||
type GridHeaderProps = {
|
||||
table: Table<IBaseRow>;
|
||||
pageId: string;
|
||||
columnOrder: ColumnOrderState;
|
||||
columnVisibility: VisibilityState;
|
||||
properties: IBaseProperty[];
|
||||
loadedRowIds: string[];
|
||||
onPropertyCreated?: () => void;
|
||||
getColumnOrder: () => string[];
|
||||
onColumnReorder?: (columnId: string, finishIndex: number) => void;
|
||||
};
|
||||
|
||||
export const GridHeader = memo(function GridHeader({
|
||||
table,
|
||||
pageId,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
columnOrder: _columnOrder,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
columnVisibility: _columnVisibility,
|
||||
properties,
|
||||
loadedRowIds,
|
||||
onPropertyCreated,
|
||||
getColumnOrder,
|
||||
onColumnReorder,
|
||||
}: GridHeaderProps) {
|
||||
const headerGroups = table.getHeaderGroups();
|
||||
const editable = useBaseEditable();
|
||||
const propertyById = useMemo(() => {
|
||||
const map = new Map<string, IBaseProperty>();
|
||||
for (const p of properties) map.set(p.id, p);
|
||||
return map;
|
||||
}, [properties]);
|
||||
|
||||
return (
|
||||
<div className={classes.headerRow} role="row">
|
||||
{headerGroups[0]?.headers.map((header) => (
|
||||
<GridHeaderCell
|
||||
key={header.id}
|
||||
header={header}
|
||||
property={propertyById.get(header.column.id)}
|
||||
loadedRowIds={loadedRowIds}
|
||||
pageId={pageId}
|
||||
getColumnOrder={getColumnOrder}
|
||||
onColumnReorder={onColumnReorder}
|
||||
/>
|
||||
))}
|
||||
{editable && (
|
||||
<CreatePropertyPopover
|
||||
pageId={pageId}
|
||||
properties={properties}
|
||||
onPropertyCreated={onPropertyCreated}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Row, VisibilityState } from "@tanstack/react-table";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
import {
|
||||
draggable,
|
||||
dropTargetForElements,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
|
||||
import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview";
|
||||
import {
|
||||
attachClosestEdge,
|
||||
extractClosestEdge,
|
||||
type Edge,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
|
||||
import { triggerPostMoveFlash } from "@atlaskit/pragmatic-drag-and-drop-flourish/trigger-post-move-flash";
|
||||
import * as liveRegion from "@atlaskit/pragmatic-drag-and-drop-live-region";
|
||||
import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
|
||||
import { useRowSelection } from "@/ee/base/hooks/use-row-selection";
|
||||
import { GridCell } from "./grid-cell";
|
||||
import classes from "@/ee/base/styles/grid.module.css";
|
||||
|
||||
export const ROW_DRAG_TYPE = "base-row";
|
||||
|
||||
type GridRowProps = {
|
||||
row: Row<IBaseRow>;
|
||||
rowIndex: number;
|
||||
measureRef: (node: Element | null) => void;
|
||||
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
|
||||
onRowReorder?: (
|
||||
rowId: string,
|
||||
targetRowId: string,
|
||||
position: "above" | "below",
|
||||
) => void;
|
||||
properties: IBaseProperty[];
|
||||
columnVisibility: VisibilityState;
|
||||
columnOrder: string[];
|
||||
pageId: string;
|
||||
};
|
||||
|
||||
export const GridRow = memo(function GridRow({
|
||||
row,
|
||||
rowIndex,
|
||||
measureRef,
|
||||
onCellUpdate,
|
||||
onRowReorder,
|
||||
pageId,
|
||||
}: GridRowProps) {
|
||||
const rowId = row.id;
|
||||
const isSelected = useRowSelection(pageId).isSelected(rowId);
|
||||
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
|
||||
|
||||
const setRowEl = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
rowRef.current = node;
|
||||
measureRef(node);
|
||||
},
|
||||
[measureRef],
|
||||
);
|
||||
|
||||
// onRowReorder ultimately depends on React Query result objects (activeView,
|
||||
// base) via persistViewConfig, and its identity changes on every WS-driven
|
||||
// cache invalidation. Holding it in a ref keeps it out of the DnD effect's
|
||||
// dep array so we don't tear down and re-register every row's pragmatic-dnd
|
||||
// adapter each time another user edits the base. Same pattern as the column
|
||||
// header's onColumnReorderRef.
|
||||
const onRowReorderRef = useRef(onRowReorder);
|
||||
useLayoutEffect(() => {
|
||||
onRowReorderRef.current = onRowReorder;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const rowEl = rowRef.current;
|
||||
if (!rowEl || !onRowReorder) return;
|
||||
// The whole row is the draggable element (full-row native preview).
|
||||
// dragHandle limits initiation to the grip, leaving cell clicks and
|
||||
// inline editing untouched.
|
||||
const handle = rowEl.querySelector<HTMLElement>(
|
||||
`.${classes.rowNumberDragHandle}`,
|
||||
);
|
||||
if (!handle) return;
|
||||
return combine(
|
||||
draggable({
|
||||
element: rowEl,
|
||||
dragHandle: handle,
|
||||
getInitialData: () => ({ type: ROW_DRAG_TYPE, rowId, pageId }),
|
||||
onGenerateDragPreview: ({ nativeSetDragImage }) => {
|
||||
// Native preview of the full-width sticky subgrid row rasterizes
|
||||
// garbled (it pulls in surrounding page paint, e.g. the sidebar).
|
||||
// Render a compact card that clones just the title cell instead.
|
||||
const titleCell =
|
||||
rowEl.querySelector<HTMLElement>(`.${classes.primaryCell}`) ??
|
||||
rowEl.querySelector<HTMLElement>(`.${classes.cell}`);
|
||||
if (!titleCell) return;
|
||||
const width = titleCell.getBoundingClientRect().width;
|
||||
setCustomNativeDragPreview({
|
||||
nativeSetDragImage,
|
||||
getOffset: pointerOutsideOfPreview({ x: "12px", y: "8px" }),
|
||||
render: ({ container }) => {
|
||||
const card = document.createElement("div");
|
||||
card.className = classes.rowDragPreview;
|
||||
card.style.width = `${width}px`;
|
||||
const clone = titleCell.cloneNode(true) as HTMLElement;
|
||||
clone.style.position = "static";
|
||||
clone.style.left = "auto";
|
||||
clone.style.width = "100%";
|
||||
clone.style.opacity = "1";
|
||||
clone.style.borderRight = "none";
|
||||
card.appendChild(clone);
|
||||
container.appendChild(card);
|
||||
},
|
||||
});
|
||||
},
|
||||
onDragStart: () => setIsDragging(true),
|
||||
onDrop: () => setIsDragging(false),
|
||||
}),
|
||||
dropTargetForElements({
|
||||
element: rowEl,
|
||||
canDrop: ({ source }) =>
|
||||
source.data.type === ROW_DRAG_TYPE &&
|
||||
source.data.pageId === pageId &&
|
||||
source.data.rowId !== rowId,
|
||||
getData: ({ input, element }) =>
|
||||
attachClosestEdge(
|
||||
{ rowId },
|
||||
{ input, element, allowedEdges: ["top", "bottom"] },
|
||||
),
|
||||
onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)),
|
||||
onDragLeave: () => setClosestEdge(null),
|
||||
onDrop: ({ source, self }) => {
|
||||
setClosestEdge(null);
|
||||
const edge = extractClosestEdge(self.data);
|
||||
if (!edge) return;
|
||||
onRowReorderRef.current?.(
|
||||
source.data.rowId as string,
|
||||
rowId,
|
||||
edge === "top" ? "above" : "below",
|
||||
);
|
||||
triggerPostMoveFlash(rowEl);
|
||||
liveRegion.announce("Moved row");
|
||||
},
|
||||
}),
|
||||
);
|
||||
// onRowReorder is read through onRowReorderRef; only its presence gates
|
||||
// registration, and that does not change across a row's mounted life.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rowId, pageId]);
|
||||
|
||||
const dropIndicatorClass = closestEdge
|
||||
? closestEdge === "top"
|
||||
? classes.rowDropAbove
|
||||
: classes.rowDropBelow
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setRowEl}
|
||||
data-index={rowIndex}
|
||||
className={`${classes.row} ${classes.virtualRow} ${isDragging ? classes.rowDragging : ""} ${dropIndicatorClass} ${isSelected ? classes.rowSelected : ""}`}
|
||||
role="row"
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<GridCell
|
||||
key={cell.id}
|
||||
cell={cell}
|
||||
rowIndex={rowIndex}
|
||||
onCellUpdate={onCellUpdate}
|
||||
pageId={pageId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
gridRowPropsEqual);
|
||||
|
||||
// row compares by row.original: React Query structural sharing keeps
|
||||
// unchanged rows reference-stable, while TanStack re-instantiates Row/Cell
|
||||
// wrappers on every data change. properties/columnVisibility/columnOrder are
|
||||
// layout busters — schema or column-state changes must re-render rows.
|
||||
function gridRowPropsEqual(prev: GridRowProps, next: GridRowProps) {
|
||||
return (
|
||||
prev.row.id === next.row.id &&
|
||||
prev.row.original === next.row.original &&
|
||||
prev.rowIndex === next.rowIndex &&
|
||||
prev.pageId === next.pageId &&
|
||||
prev.onCellUpdate === next.onCellUpdate &&
|
||||
prev.onRowReorder === next.onRowReorder &&
|
||||
prev.measureRef === next.measureRef &&
|
||||
prev.properties === next.properties &&
|
||||
prev.columnVisibility === next.columnVisibility &&
|
||||
prev.columnOrder === next.columnOrder
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { memo, useCallback } from "react";
|
||||
import { Checkbox } from "@mantine/core";
|
||||
import { IconGripVertical } from "@tabler/icons-react";
|
||||
import { useRowSelection } from "@/ee/base/hooks/use-row-selection";
|
||||
import { useBaseEditable } from "@/ee/base/context/base-editable";
|
||||
import { useGridRowOrder } from "@/ee/base/context/grid-row-order";
|
||||
import classes from "@/ee/base/styles/grid.module.css";
|
||||
|
||||
type RowNumberCellProps = {
|
||||
rowId: string;
|
||||
rowIndex: number;
|
||||
isPinned: boolean;
|
||||
pinOffset?: number;
|
||||
pageId: string;
|
||||
};
|
||||
|
||||
export const RowNumberCell = memo(function RowNumberCell({
|
||||
rowId,
|
||||
rowIndex,
|
||||
isPinned,
|
||||
pinOffset,
|
||||
pageId,
|
||||
}: RowNumberCellProps) {
|
||||
const { isSelected, toggle } = useRowSelection(pageId);
|
||||
const selected = isSelected(rowId);
|
||||
const editable = useBaseEditable();
|
||||
const getOrderedRowIds = useGridRowOrder();
|
||||
|
||||
const handleCheckboxChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const nativeEvent = e.nativeEvent as MouseEvent;
|
||||
toggle(rowId, {
|
||||
shiftKey: nativeEvent.shiftKey === true,
|
||||
rowIndex,
|
||||
orderedRowIds: getOrderedRowIds(),
|
||||
});
|
||||
},
|
||||
[rowId, rowIndex, getOrderedRowIds, toggle],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${classes.cell} ${classes.rowNumberCell} ${isPinned ? classes.cellPinned : ""}`}
|
||||
style={
|
||||
isPinned
|
||||
? ({ "--pin-offset": `${pinOffset ?? 0}px` } as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className={classes.rowNumberCellInner}>
|
||||
{editable && (
|
||||
<span className={classes.rowNumberDragHandle} aria-label="Drag row">
|
||||
<IconGripVertical size={12} />
|
||||
</span>
|
||||
)}
|
||||
{editable && (
|
||||
<span className={classes.rowNumberCheckbox}>
|
||||
<Checkbox
|
||||
size="xs"
|
||||
checked={selected}
|
||||
onChange={handleCheckboxChange}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<span className={classes.rowNumberIndex}>{rowIndex + 1}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { memo, useMemo } from "react";
|
||||
import { Checkbox, Tooltip } from "@mantine/core";
|
||||
import { useRowSelection } from "@/ee/base/hooks/use-row-selection";
|
||||
import classes from "@/ee/base/styles/grid.module.css";
|
||||
|
||||
type RowNumberHeaderCellProps = {
|
||||
loadedRowIds: string[];
|
||||
pageId: string;
|
||||
};
|
||||
|
||||
export const RowNumberHeaderCell = memo(function RowNumberHeaderCell({
|
||||
loadedRowIds,
|
||||
pageId,
|
||||
}: RowNumberHeaderCellProps) {
|
||||
const { selectedIds, toggleAll } = useRowSelection(pageId);
|
||||
|
||||
const { checked, indeterminate } = useMemo(() => {
|
||||
if (loadedRowIds.length === 0) {
|
||||
return { checked: false, indeterminate: false };
|
||||
}
|
||||
const selectedInLoaded = loadedRowIds.reduce(
|
||||
(acc, id) => (selectedIds.has(id) ? acc + 1 : acc),
|
||||
0,
|
||||
);
|
||||
return {
|
||||
checked: selectedInLoaded === loadedRowIds.length,
|
||||
indeterminate:
|
||||
selectedInLoaded > 0 && selectedInLoaded < loadedRowIds.length,
|
||||
};
|
||||
}, [loadedRowIds, selectedIds]);
|
||||
|
||||
if (loadedRowIds.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={classes.rowNumberHeaderInner}>
|
||||
<span className={classes.rowNumberHeaderHash}>#</span>
|
||||
<span className={classes.rowNumberHeaderCheckbox}>
|
||||
<Tooltip label="Select all loaded rows" withinPortal>
|
||||
<Checkbox
|
||||
size="xs"
|
||||
checked={checked}
|
||||
indeterminate={indeterminate}
|
||||
onChange={() => toggleAll(loadedRowIds)}
|
||||
aria-label="Select all loaded rows"
|
||||
/>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { memo } from "react";
|
||||
import { Transition } from "@mantine/core";
|
||||
import { IconTrash, IconX } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRowSelection } from "@/ee/base/hooks/use-row-selection";
|
||||
import { useDeleteSelectedRows } from "@/ee/base/hooks/use-delete-selected-rows";
|
||||
import classes from "@/ee/base/styles/grid.module.css";
|
||||
|
||||
type SelectionActionBarProps = {
|
||||
pageId: string;
|
||||
};
|
||||
|
||||
export const SelectionActionBar = memo(function SelectionActionBar({
|
||||
pageId,
|
||||
}: SelectionActionBarProps) {
|
||||
const { t } = useTranslation();
|
||||
const { selectionCount, clear } = useRowSelection(pageId);
|
||||
const { deleteSelected, isPending } = useDeleteSelectedRows(pageId);
|
||||
|
||||
const isOpen = selectionCount > 0;
|
||||
|
||||
return (
|
||||
<Transition mounted={isOpen} transition="slide-up" duration={150}>
|
||||
{(styles) => (
|
||||
<div className={classes.selectionActionBarWrapper} style={styles}>
|
||||
<div className={classes.selectionActionBar} role="toolbar">
|
||||
<span className={classes.selectionActionBarCount}>
|
||||
{t("{{count}} selected", { count: selectionCount })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={classes.selectionActionBarDelete}
|
||||
disabled={isPending}
|
||||
onClick={() => void deleteSelected()}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
{t("Delete")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={classes.selectionActionBarClose}
|
||||
onClick={clear}
|
||||
aria-label={t("Clear selection")}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Transition>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user