feat(ee): bases

Table and kanban UI, formula engine package, and the base-embed editor extension
This commit is contained in:
Philipinho
2026-06-14 01:29:06 +01:00
parent d86d51c27e
commit 4e5bff6d55
233 changed files with 22278 additions and 141 deletions
@@ -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>
);
});