{
+ resizeIntentRef.current = false;
+ }}
+ onClick={handleHeaderClick}
+ data-dragging={isDragging || undefined}
+ >
+ {isRowNumber ? (
+
+ ) : (
+
+ {TypeIcon && (
+
+ )}
+
+ {flexRender(header.column.columnDef.header, header.getContext())}
+
+ {property?.pendingType && (
+
+ {t("Converting…")}
+
+ )}
+
+ )}
+ {editable && header.column.getCanResize() && (
+
{
+ e.stopPropagation();
+ header.getResizeHandler()(e);
+ }}
+ onTouchStart={(e) => {
+ e.stopPropagation();
+ header.getResizeHandler()(e);
+ }}
+ onPointerDown={(e) => {
+ resizeIntentRef.current = true;
+ e.stopPropagation();
+ }}
+ onClick={(e) => e.stopPropagation()}
+ />
+ )}
+ {closestEdge &&
}
+ {editable && property && !isRowNumber && (
+
+
+
+
+ e.stopPropagation()}
+ onKeyDown={(e) => e.stopPropagation()}
+ >
+
+
+
+ )}
+ {property && !isRowNumber && property.type === "formula" && (
+
{
+ if (!o) closeFormulaEditor();
+ }}
+ position="bottom-start"
+ width={460}
+ shadow="md"
+ withinPortal
+ closeOnClickOutside
+ closeOnEscape={false}
+ trapFocus
+ >
+
+
+
+ e.stopPropagation()}
+ onKeyDown={(e) => {
+ e.stopPropagation();
+ if (e.key === "Escape") {
+ e.preventDefault();
+ closeFormulaEditor();
+ }
+ }}
+ style={{ maxWidth: "calc(100vw - 32px)" }}
+ >
+ {formulaEditorOpen && (
+
+ )}
+
+
+ )}
+
+ );
+});
diff --git a/apps/client/src/ee/base/components/grid/grid-header.tsx b/apps/client/src/ee/base/components/grid/grid-header.tsx
new file mode 100644
index 000000000..152858d68
--- /dev/null
+++ b/apps/client/src/ee/base/components/grid/grid-header.tsx
@@ -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
;
+ 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();
+ for (const p of properties) map.set(p.id, p);
+ return map;
+ }, [properties]);
+
+ return (
+
+ {headerGroups[0]?.headers.map((header) => (
+
+ ))}
+ {editable && (
+
+ )}
+
+ );
+});
diff --git a/apps/client/src/ee/base/components/grid/grid-row.tsx b/apps/client/src/ee/base/components/grid/grid-row.tsx
new file mode 100644
index 000000000..a9eed6c7b
--- /dev/null
+++ b/apps/client/src/ee/base/components/grid/grid-row.tsx
@@ -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;
+ 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(null);
+ const [isDragging, setIsDragging] = useState(false);
+ const [closestEdge, setClosestEdge] = useState(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(
+ `.${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(`.${classes.primaryCell}`) ??
+ rowEl.querySelector(`.${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 (
+
+ {row.getVisibleCells().map((cell) => (
+
+ ))}
+
+ );
+},
+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
+ );
+}
diff --git a/apps/client/src/ee/base/components/grid/row-number-cell.tsx b/apps/client/src/ee/base/components/grid/row-number-cell.tsx
new file mode 100644
index 000000000..56a8b21da
--- /dev/null
+++ b/apps/client/src/ee/base/components/grid/row-number-cell.tsx
@@ -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) => {
+ const nativeEvent = e.nativeEvent as MouseEvent;
+ toggle(rowId, {
+ shiftKey: nativeEvent.shiftKey === true,
+ rowIndex,
+ orderedRowIds: getOrderedRowIds(),
+ });
+ },
+ [rowId, rowIndex, getOrderedRowIds, toggle],
+ );
+
+ return (
+
+
+ {editable && (
+
+
+
+ )}
+ {editable && (
+
+
+
+ )}
+ {rowIndex + 1}
+
+
+ );
+});
diff --git a/apps/client/src/ee/base/components/grid/row-number-header-cell.tsx b/apps/client/src/ee/base/components/grid/row-number-header-cell.tsx
new file mode 100644
index 000000000..4a2130c2b
--- /dev/null
+++ b/apps/client/src/ee/base/components/grid/row-number-header-cell.tsx
@@ -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 (
+
+ #
+
+
+ toggleAll(loadedRowIds)}
+ aria-label="Select all loaded rows"
+ />
+
+
+
+ );
+});
diff --git a/apps/client/src/ee/base/components/grid/selection-action-bar.tsx b/apps/client/src/ee/base/components/grid/selection-action-bar.tsx
new file mode 100644
index 000000000..c804c4d6a
--- /dev/null
+++ b/apps/client/src/ee/base/components/grid/selection-action-bar.tsx
@@ -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 (
+
+ {(styles) => (
+
+
+
+ {t("{{count}} selected", { count: selectionCount })}
+
+ void deleteSelected()}
+ >
+
+ {t("Delete")}
+
+
+
+
+
+
+ )}
+
+ );
+});
diff --git a/apps/client/src/ee/base/components/kanban/base-kanban.tsx b/apps/client/src/ee/base/components/kanban/base-kanban.tsx
new file mode 100644
index 000000000..2ad2df93b
--- /dev/null
+++ b/apps/client/src/ee/base/components/kanban/base-kanban.tsx
@@ -0,0 +1,213 @@
+import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
+import { useTranslation } from "react-i18next";
+import clsx from "clsx";
+import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
+import { 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 { reorder } from "@atlaskit/pragmatic-drag-and-drop/reorder";
+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 { IBase, IBaseRow, IBaseView, FilterGroup, KANBAN_CARD_DRAG_TYPE, KANBAN_COLUMN_DRAG_TYPE } from "@/ee/base/types/base.types";
+import { useKanbanColumns } from "@/ee/base/hooks/use-kanban-columns";
+import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
+import { useKanbanMoveCardMutation } from "@/ee/base/queries/base-row-query";
+import { buildColumnFilter } from "@/ee/base/services/kanban-column-filter";
+import { resolveCardDrop } from "@/ee/base/hooks/use-kanban-card-drop";
+import { useKanbanBoardAutoScroll } from "@/ee/base/hooks/use-kanban-autoscroll";
+import { useRowDetailModal } from "@/ee/base/hooks/use-row-detail-modal";
+import { KanbanColumn } from "@/ee/base/components/kanban/kanban-column";
+import { KanbanEmptyState } from "@/ee/base/components/kanban/kanban-empty-state";
+import classes from "@/ee/base/styles/kanban.module.css";
+
+type BaseKanbanProps = {
+ base: IBase;
+ view: IBaseView;
+ pageId: string;
+ embedded?: boolean;
+ editable: boolean;
+ viewFilter: FilterGroup | undefined;
+};
+
+export function BaseKanban({ base, view, pageId, embedded, editable, viewFilter }: BaseKanbanProps) {
+ const { t } = useTranslation();
+ const { groupByPropertyId, columns, hasValidGroupBy } = useKanbanColumns(base, view);
+ const updateView = useUpdateViewMutation();
+ const moveCard = useKanbanMoveCardMutation();
+ const { openRow } = useRowDetailModal(pageId);
+
+ const openRowRef = useRef(openRow);
+ useLayoutEffect(() => { openRowRef.current = openRow; });
+ const handleOpenRow = useCallback((id: string) => openRowRef.current(id), []);
+
+ const boardRef = useRef(null);
+ useKanbanBoardAutoScroll(boardRef, pageId);
+
+ const cardRefs = useRef>(new Map());
+
+ const registerCardRef = useCallback((rowId: string, columnKey: string, el: HTMLDivElement | null) => {
+ if (el) {
+ cardRefs.current.set(rowId, { columnKey, el });
+ } else {
+ cardRefs.current.delete(rowId);
+ }
+ }, []);
+
+ const columnRows = useRef>(new Map());
+
+ const registerColumnRows = useCallback((key: string, rows: IBaseRow[]) => {
+ columnRows.current.set(key, rows);
+ }, []);
+
+ const hideColumn = useCallback(
+ (key: string) => {
+ const next = Array.from(new Set([...(view.config?.hiddenChoiceIds ?? []), key]));
+ updateView.mutate({ viewId: view.id, pageId, config: { hiddenChoiceIds: next } });
+ },
+ [updateView, view.id, view.config?.hiddenChoiceIds, pageId],
+ );
+
+ const onCardDropRef = useRef<(args: {
+ draggedRowId: string;
+ sourceColumnKey: string;
+ targetColumnKey: string;
+ targetRowId: string | null;
+ edge: Edge | null;
+ }) => void>(() => {});
+ useLayoutEffect(() => {
+ onCardDropRef.current = ({ draggedRowId, sourceColumnKey, targetColumnKey, targetRowId, edge }) => {
+ if (!groupByPropertyId) return;
+ const targetColumnRows = columnRows.current.get(targetColumnKey) ?? [];
+ const result = resolveCardDrop({
+ draggedRowId,
+ targetRowId,
+ edge: edge === "left" || edge === "right" ? null : edge,
+ targetColumnKey,
+ sourceColumnKey,
+ targetColumnRows,
+ });
+ if (!result) return;
+ const sourceFilter = buildColumnFilter(viewFilter, groupByPropertyId, sourceColumnKey);
+ const destFilter = buildColumnFilter(viewFilter, groupByPropertyId, targetColumnKey);
+ moveCard.mutate({
+ pageId,
+ rowId: draggedRowId,
+ sourceColumnFilter: sourceFilter,
+ destColumnFilter: destFilter,
+ columnChanged: result.columnChanged,
+ groupByPropertyId,
+ destChoiceValue: result.destChoiceValue,
+ position: result.position,
+ });
+ const el = cardRefs.current.get(draggedRowId)?.el;
+ if (el) triggerPostMoveFlash(el);
+ const targetColumnName = columns.find((c) => c.key === targetColumnKey)?.name ?? "";
+ liveRegion.announce(t("Moved card to {{column}}", { column: targetColumnName }));
+ };
+ });
+
+ useEffect(() => {
+ return monitorForElements({
+ canMonitor: ({ source }) =>
+ source.data?.type === KANBAN_CARD_DRAG_TYPE && source.data?.pageId === pageId,
+ onDrop: ({ location, source }) => {
+ const target = location.current.dropTargets[0];
+ if (!target) return;
+ const draggedRowId = source.data.rowId as string;
+ const sourceColumnKey = source.data.columnKey as string;
+ const targetColumnKey = target.data.columnKey as string;
+ const isColumnBody = target.data.isColumnBody === true;
+ const targetRowId = isColumnBody ? null : (target.data.rowId as string);
+ const edge = isColumnBody ? null : extractClosestEdge(target.data);
+ onCardDropRef.current({ draggedRowId, sourceColumnKey, targetColumnKey, targetRowId, edge });
+ },
+ });
+ }, [pageId]);
+
+ const onColumnDropRef = useRef<(args: {
+ sourceColumnKey: string;
+ targetColumnKey: string;
+ edge: Edge | null;
+ }) => void>(() => {});
+ useLayoutEffect(() => {
+ onColumnDropRef.current = ({ sourceColumnKey, targetColumnKey, edge }) => {
+ const fullOrder: string[] = view.config?.choiceOrder?.length
+ ? view.config.choiceOrder
+ : columns.map((c) => c.key);
+
+ const startIndex = fullOrder.indexOf(sourceColumnKey);
+ const indexOfTarget = fullOrder.indexOf(targetColumnKey);
+
+ if (startIndex === -1 || indexOfTarget === -1) {
+ const visibleKeys = columns.map((c) => c.key);
+ const visStart = visibleKeys.indexOf(sourceColumnKey);
+ const visTarget = visibleKeys.indexOf(targetColumnKey);
+ if (visStart === -1 || visTarget === -1) return;
+ const finishIndex = getReorderDestinationIndex({
+ startIndex: visStart,
+ indexOfTarget: visTarget,
+ closestEdgeOfTarget: edge,
+ axis: "horizontal",
+ });
+ if (finishIndex === visStart) return;
+ const reorderedVisible = reorder({ list: visibleKeys, startIndex: visStart, finishIndex });
+ updateView.mutate({ viewId: view.id, pageId, config: { choiceOrder: [...reorderedVisible, ...(view.config?.hiddenChoiceIds ?? [])] } });
+ } else {
+ const finishIndex = getReorderDestinationIndex({
+ startIndex,
+ indexOfTarget,
+ closestEdgeOfTarget: edge,
+ axis: "horizontal",
+ });
+ if (finishIndex === startIndex) return;
+ const newChoiceOrder = reorder({ list: fullOrder, startIndex, finishIndex });
+ updateView.mutate({ viewId: view.id, pageId, config: { choiceOrder: newChoiceOrder } });
+ }
+
+ const targetColumnName = columns.find((c) => c.key === targetColumnKey)?.name ?? "";
+ liveRegion.announce(t("Moved column to {{column}}", { column: targetColumnName }));
+ };
+ });
+
+ useEffect(() => {
+ return monitorForElements({
+ canMonitor: ({ source }) =>
+ source.data?.type === KANBAN_COLUMN_DRAG_TYPE && source.data?.pageId === pageId,
+ onDrop: ({ location, source }) => {
+ const target = location.current.dropTargets[0];
+ if (!target) return;
+ const sourceColumnKey = source.data.columnKey as string;
+ const targetColumnKey = target.data.columnKey as string;
+ const edge = extractClosestEdge(target.data);
+ onColumnDropRef.current({ sourceColumnKey, targetColumnKey, edge });
+ },
+ });
+ }, [pageId]);
+
+ if (!hasValidGroupBy) {
+ return ;
+ }
+
+ return (
+
+ {columns.map((column) => (
+
+ ))}
+
+ );
+}
diff --git a/apps/client/src/ee/base/components/kanban/card-field/card-field.tsx b/apps/client/src/ee/base/components/kanban/card-field/card-field.tsx
new file mode 100644
index 000000000..15ee9b73c
--- /dev/null
+++ b/apps/client/src/ee/base/components/kanban/card-field/card-field.tsx
@@ -0,0 +1,339 @@
+import { Text, Badge, Tooltip, Group } from "@mantine/core";
+import { IconCheck, IconFileDescription } from "@tabler/icons-react";
+import { Link } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import { sanitizeUrl } from "@docmost/editor-ext";
+import {
+ IBaseProperty,
+ SelectTypeOptions,
+ NumberTypeOptions,
+ DateTypeOptions,
+ isFormulaErrorCell,
+} from "@/ee/base/types/base.types";
+import { choiceColor } from "@/ee/base/components/cells/choice-color";
+import { ChoiceBadge } from "@/ee/base/components/cells/choice-badge";
+import { BadgeOverflowList } from "@/ee/base/components/cells/badge-overflow";
+import { PersonReadList } from "@/ee/base/components/cells/person-read-list";
+import { CustomAvatar } from "@/components/ui/custom-avatar";
+import { useReferenceStore, useResolvePage } from "@/ee/base/reference/reference-store";
+import {
+ formatNumber,
+ formatDateDisplay,
+ formatTimestamp,
+ formatLongTextPreview,
+} from "@/ee/base/formatters/cell-formatters";
+import { buildPageUrl, getPageTitle } from "@/features/page/page.utils";
+import { FileValue } from "@/ee/base/components/cells/cell-file";
+import cellClasses from "@/ee/base/styles/cells.module.css";
+
+type CardFieldProps = {
+ property: IBaseProperty;
+ value: unknown;
+ pageId: string;
+};
+
+export function CardField({ property, value, pageId }: CardFieldProps) {
+ if (value === null || value === undefined || value === "") return null;
+ if (Array.isArray(value) && value.length === 0) return null;
+
+ switch (property.type) {
+ case "text":
+ return ;
+ case "longText":
+ return ;
+ case "number":
+ return ;
+ case "select":
+ case "status":
+ return ;
+ case "multiSelect":
+ return ;
+ case "date":
+ return ;
+ case "createdAt":
+ case "lastEditedAt":
+ return ;
+ case "person":
+ return ;
+ case "lastEditedBy":
+ return ;
+ case "file":
+ return ;
+ case "page":
+ return ;
+ case "checkbox":
+ return ;
+ case "url":
+ return ;
+ case "email":
+ return ;
+ case "formula":
+ return ;
+ default:
+ return (
+
+ {String(value)}
+
+ );
+ }
+}
+
+function TextField({ value }: { value: unknown }) {
+ const text = typeof value === "string" ? value : String(value);
+ if (!text) return null;
+ return (
+
+ {text}
+
+ );
+}
+
+function LongTextField({ value }: { value: unknown }) {
+ const preview = formatLongTextPreview(typeof value === "string" ? value : undefined);
+ if (!preview) return null;
+ return (
+
+ {preview}
+
+ );
+}
+
+function NumberField({ value, property }: { value: unknown; property: IBaseProperty }) {
+ const num = typeof value === "number" ? value : null;
+ if (num === null) return null;
+ const formatted = formatNumber(num, property.typeOptions as NumberTypeOptions | undefined);
+ if (!formatted) return null;
+ return {formatted} ;
+}
+
+function SelectField({ value, property }: { value: unknown; property: IBaseProperty }) {
+ const choices = (property.typeOptions as SelectTypeOptions | undefined)?.choices ?? [];
+ const selectedId = typeof value === "string" ? value : null;
+ const choice = choices.find((c) => c.id === selectedId);
+ if (!choice) return null;
+ return (
+
+ );
+}
+
+function MultiSelectField({ value, property }: { value: unknown; property: IBaseProperty }) {
+ const choices = (property.typeOptions as SelectTypeOptions | undefined)?.choices ?? [];
+ const selectedIds = Array.isArray(value) ? (value as string[]) : [];
+ const selectedChoices = choices.filter((c) => selectedIds.includes(c.id));
+ if (selectedChoices.length === 0) return null;
+ const chips = selectedChoices.map((choice) => (
+
+ {choice.name}
+
+ ));
+ return (
+ `${c.id}:${c.name}`).join("|")}
+ tooltipLabel={selectedChoices.map((c) => c.name).join(", ")}
+ />
+ );
+}
+
+function DateField({ value, property }: { value: unknown; property: IBaseProperty }) {
+ const dateStr = typeof value === "string" ? value : null;
+ const formatted = formatDateDisplay(dateStr, property.typeOptions as DateTypeOptions | undefined);
+ if (!formatted) return null;
+ return (
+
+ {formatted}
+
+ );
+}
+
+function TimestampField({ value }: { value: unknown }) {
+ const formatted = formatTimestamp(typeof value === "string" ? value : null);
+ if (!formatted) return null;
+ return (
+
+ {formatted}
+
+ );
+}
+
+function PersonField({ value, pageId }: { value: unknown; pageId: string }) {
+ const store = useReferenceStore(pageId);
+ const personIds = Array.isArray(value)
+ ? (value as string[])
+ : typeof value === "string"
+ ? [value]
+ : [];
+ if (personIds.length === 0) return null;
+ return ;
+}
+
+function LastEditedByField({ value, pageId }: { value: unknown; pageId: string }) {
+ const userId = typeof value === "string" ? value : null;
+ const store = useReferenceStore(pageId);
+ if (!userId) return null;
+ const user = store.users[userId] ?? null;
+ const name = user?.name ?? userId.substring(0, 8);
+ return (
+
+
+
+
+ {name}
+
+
+
+ );
+}
+
+function FileField({ value }: { value: unknown }) {
+ const files = Array.isArray(value)
+ ? (value as FileValue[]).filter((f) => f && typeof f === "object" && "id" in f && "fileName" in f)
+ : [];
+ if (files.length === 0) return null;
+ const maxVisible = 2;
+ const visible = files.slice(0, maxVisible);
+ const overflow = files.length - maxVisible;
+ return (
+
+ {visible.map((file) => (
+
+ {file.fileName}
+
+ ))}
+ {overflow > 0 && +{overflow} }
+
+ );
+}
+
+function PageField({
+ value,
+ basePageId,
+ propertyPageId,
+}: {
+ value: unknown;
+ basePageId: string;
+ propertyPageId: string;
+}) {
+ const { t } = useTranslation();
+ const pageId = typeof value === "string" && value.length > 0 ? value : null;
+ const resolvedPage = useResolvePage(propertyPageId, pageId);
+
+ if (!pageId) return null;
+ if (resolvedPage === undefined) return null;
+
+ if (resolvedPage === null) {
+ return (
+
+
+ Page not found
+
+ );
+ }
+
+ const title = getPageTitle(resolvedPage.title, undefined, t);
+ const spaceSlug = resolvedPage.space?.slug ?? "";
+ const url = buildPageUrl(spaceSlug, resolvedPage.slugId, title);
+
+ return (
+
+ e.stopPropagation()}
+ onDoubleClick={(e) => e.stopPropagation()}
+ >
+ {resolvedPage.icon ? (
+ {resolvedPage.icon}
+ ) : (
+
+ )}
+ {title}
+
+
+ );
+}
+
+function CheckboxField({ value }: { value: unknown }) {
+ if (value !== true) return null;
+ return ;
+}
+
+function UrlField({ value }: { value: unknown }) {
+ const displayValue = typeof value === "string" ? value : "";
+ if (!displayValue) return null;
+ const safeHref = sanitizeUrl(displayValue);
+ if (!safeHref) {
+ return (
+
+ {displayValue}
+
+ );
+ }
+ return (
+
+ e.stopPropagation()}
+ style={{ fontSize: "var(--mantine-font-size-xs)" }}
+ >
+ {displayValue}
+
+
+ );
+}
+
+function EmailField({ value }: { value: unknown }) {
+ const displayValue = typeof value === "string" ? value : "";
+ if (!displayValue) return null;
+ return (
+
+ e.stopPropagation()}
+ style={{ fontSize: "var(--mantine-font-size-xs)" }}
+ >
+ {displayValue}
+
+
+ );
+}
+
+function FormulaField({ value, property }: { value: unknown; property: IBaseProperty }) {
+ if (isFormulaErrorCell(value)) {
+ return (
+
+
+ #ERROR
+
+
+ );
+ }
+
+ const opts = (property.typeOptions ?? {}) as { resultType?: string };
+ const resultType = opts.resultType ?? "null";
+
+ if (resultType === "number") {
+ return ;
+ }
+ if (resultType === "boolean") {
+ return ;
+ }
+ if (resultType === "date") {
+ return ;
+ }
+
+ const text = typeof value === "string" ? value : value != null ? String(value) : null;
+ if (!text) return null;
+ return (
+
+ {text}
+
+ );
+}
diff --git a/apps/client/src/ee/base/components/kanban/kanban-add-card-button.tsx b/apps/client/src/ee/base/components/kanban/kanban-add-card-button.tsx
new file mode 100644
index 000000000..a23e833ca
--- /dev/null
+++ b/apps/client/src/ee/base/components/kanban/kanban-add-card-button.tsx
@@ -0,0 +1,28 @@
+import { useTranslation } from "react-i18next";
+import { IconPlus } from "@tabler/icons-react";
+import classes from "@/ee/base/styles/kanban.module.css";
+
+type KanbanAddCardButtonProps = {
+ onAddCard: () => void;
+};
+
+export function KanbanAddCardButton({ onAddCard }: KanbanAddCardButtonProps) {
+ const { t } = useTranslation();
+ return (
+ {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ onAddCard();
+ }
+ }}
+ >
+
+ {t("New row")}
+
+ );
+}
diff --git a/apps/client/src/ee/base/components/kanban/kanban-card-properties.tsx b/apps/client/src/ee/base/components/kanban/kanban-card-properties.tsx
new file mode 100644
index 000000000..cd3021f93
--- /dev/null
+++ b/apps/client/src/ee/base/components/kanban/kanban-card-properties.tsx
@@ -0,0 +1,251 @@
+import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
+import { Popover, Switch, Stack, Text, Group, UnstyledButton, ScrollArea } from "@mantine/core";
+import { IconGripVertical, type IconLetterT } from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import { IBase, IBaseProperty, IBaseView } from "@/ee/base/types/base.types";
+import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
+import { propertyTypes } from "@/ee/base/components/property/property-type-picker";
+import { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-indicator";
+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 { reorder } from "@atlaskit/pragmatic-drag-and-drop/reorder";
+import cellClasses from "@/ee/base/styles/cells.module.css";
+import propClasses from "@/ee/base/styles/property.module.css";
+
+const DRAG_TYPE = "base-card-property";
+
+type KanbanCardPropertiesProps = {
+ opened: boolean;
+ onClose: () => void;
+ base: IBase;
+ view: IBaseView;
+ pageId: string;
+ children: React.ReactNode;
+};
+
+export function KanbanCardProperties({
+ opened,
+ onClose,
+ base,
+ view,
+ pageId,
+ children,
+}: KanbanCardPropertiesProps) {
+ const { t } = useTranslation();
+ const updateView = useUpdateViewMutation();
+
+ const nonPrimaryProperties = base.properties.filter((p) => !p.isPrimary);
+ const visibleIds = view.config?.visiblePropertyIds ?? [];
+
+ const savedOrder = view.config?.propertyOrder ?? [];
+ const orderedProperties = [
+ ...savedOrder
+ .map((id) => nonPrimaryProperties.find((p) => p.id === id))
+ .filter((p): p is IBaseProperty => p !== undefined),
+ ...nonPrimaryProperties.filter((p) => !savedOrder.includes(p.id)),
+ ];
+
+ const primaryProperty = base.properties.find((p) => p.isPrimary);
+ const PrimaryIcon = primaryProperty
+ ? propertyTypes.find((pt) => pt.type === primaryProperty.type)?.icon
+ : undefined;
+
+ const handleToggle = useCallback(
+ (propertyId: string, checked: boolean) => {
+ const next = checked
+ ? [...visibleIds, propertyId]
+ : visibleIds.filter((id) => id !== propertyId);
+ updateView.mutate({ viewId: view.id, pageId, config: { visiblePropertyIds: next } });
+ },
+ [updateView, view.id, visibleIds, pageId],
+ );
+
+ const handleReorder = useCallback(
+ (activeId: string, targetId: string, edge: Edge) => {
+ const startIndex = orderedProperties.findIndex((p) => p.id === activeId);
+ const indexOfTarget = orderedProperties.findIndex((p) => p.id === targetId);
+ if (startIndex === -1 || indexOfTarget === -1) return;
+ const finishIndex = getReorderDestinationIndex({
+ startIndex,
+ indexOfTarget,
+ closestEdgeOfTarget: edge,
+ axis: "vertical",
+ });
+ if (finishIndex === startIndex) return;
+ const reordered = reorder({ list: orderedProperties, startIndex, finishIndex });
+ updateView.mutate({
+ viewId: view.id,
+ pageId,
+ config: { propertyOrder: reordered.map((p) => p.id) },
+ });
+ },
+ [orderedProperties, updateView, view.id, pageId],
+ );
+
+ return (
+ {
+ if (!o) onClose();
+ }}
+ onClose={onClose}
+ position="bottom-end"
+ shadow="md"
+ width={260}
+ trapFocus
+ closeOnEscape
+ closeOnClickOutside
+ withinPortal
+ >
+ {children}
+
+
+
+
+ {t("Card properties")}
+
+
+
+
+ {primaryProperty && (
+
+
+
+
+
+ {PrimaryIcon && }
+
+ {primaryProperty.name}
+
+
+
{}}
+ styles={{ track: { cursor: "default" } }}
+ />
+
+ )}
+ {orderedProperties.map((p) => {
+ const isVisible = visibleIds.includes(p.id);
+ const typeConfig = propertyTypes.find((pt) => pt.type === p.type);
+ const TypeIcon = typeConfig?.icon;
+ return (
+
+ );
+ })}
+
+
+
+
+
+ );
+}
+
+type SortablePropertyRowProps = {
+ property: IBaseProperty;
+ isVisible: boolean;
+ TypeIcon: typeof IconLetterT | undefined;
+ onToggle: (propertyId: string, checked: boolean) => void;
+ onReorder: (activeId: string, targetId: string, edge: Edge) => void;
+};
+
+function SortablePropertyRow({
+ property,
+ isVisible,
+ TypeIcon,
+ onToggle,
+ onReorder,
+}: SortablePropertyRowProps) {
+ const rowRef = useRef(null);
+ const handleRef = useRef(null);
+
+ const [isDragging, setIsDragging] = useState(false);
+ const [closestEdge, setClosestEdge] = useState(null);
+
+ const onReorderRef = useRef(onReorder);
+ useLayoutEffect(() => {
+ onReorderRef.current = onReorder;
+ });
+
+ useEffect(() => {
+ const row = rowRef.current;
+ const handle = handleRef.current;
+ if (!row || !handle) return;
+ return combine(
+ draggable({
+ element: row,
+ dragHandle: handle,
+ getInitialData: () => ({ type: DRAG_TYPE, propertyId: property.id }),
+ onDragStart: () => setIsDragging(true),
+ onDrop: () => setIsDragging(false),
+ }),
+ dropTargetForElements({
+ element: row,
+ canDrop: ({ source }) =>
+ source.data.type === DRAG_TYPE && source.data.propertyId !== property.id,
+ getData: ({ input, element }) =>
+ attachClosestEdge(
+ { propertyId: property.id },
+ { 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;
+ onReorderRef.current(source.data.propertyId as string, property.id, edge);
+ },
+ }),
+ );
+ }, [property.id]);
+
+ return (
+
+
onToggle(property.id, !isVisible)}
+ style={{ paddingLeft: 4 }}
+ >
+ e.stopPropagation()}>
+
+
+
+ {TypeIcon && }
+
+ {property.name}
+
+
+ {}}
+ onClick={(e) => e.stopPropagation()}
+ styles={{ track: { cursor: "pointer" } }}
+ />
+
+ {closestEdge &&
}
+
+ );
+}
diff --git a/apps/client/src/ee/base/components/kanban/kanban-card.tsx b/apps/client/src/ee/base/components/kanban/kanban-card.tsx
new file mode 100644
index 000000000..32993e94d
--- /dev/null
+++ b/apps/client/src/ee/base/components/kanban/kanban-card.tsx
@@ -0,0 +1,85 @@
+import { forwardRef, useCallback, useRef } from "react";
+import clsx from "clsx";
+import { useTranslation } from "react-i18next";
+import { IBase, IBaseRow, IBaseView } from "@/ee/base/types/base.types";
+import { CardField } from "@/ee/base/components/kanban/card-field/card-field";
+import { useKanbanCardDnd } from "@/ee/base/hooks/use-kanban-card-dnd";
+import { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-indicator";
+import classes from "@/ee/base/styles/kanban.module.css";
+
+type KanbanCardProps = {
+ base: IBase;
+ view: IBaseView;
+ row: IBaseRow;
+ columnKey: string;
+ onOpen: (rowId: string) => void;
+};
+
+export const KanbanCard = forwardRef(
+ function KanbanCard({ base, view, row, columnKey, onOpen }, ref) {
+ const { t } = useTranslation();
+ const primary = base.properties.find((p) => p.isPrimary);
+ const title = primary ? (row.cells[primary.id] as string | undefined) : undefined;
+
+ const visibleIds = view.config?.visiblePropertyIds ?? [];
+ const propertyOrder = view.config?.propertyOrder;
+
+ const cardProps = base.properties.filter(
+ (p) => visibleIds.includes(p.id) && !p.isPrimary,
+ );
+
+ if (propertyOrder) {
+ cardProps.sort(
+ (a, b) => propertyOrder.indexOf(a.id) - propertyOrder.indexOf(b.id),
+ );
+ }
+
+ const cardRef = useRef(null);
+
+ const setCardEl = useCallback(
+ (node: HTMLDivElement | null) => {
+ cardRef.current = node;
+ if (typeof ref === "function") ref(node);
+ else if (ref) ref.current = node;
+ },
+ [ref],
+ );
+
+ const { closestEdge, isDragging } = useKanbanCardDnd({
+ cardRef,
+ rowId: row.id,
+ columnKey,
+ pageId: base.id,
+ });
+
+ return (
+ onOpen(row.id)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ onOpen(row.id);
+ }
+ }}
+ >
+ {closestEdge === "top" &&
}
+
+ {title || t("Untitled")}
+
+ {cardProps.map((property) => (
+
+ ))}
+ {closestEdge === "bottom" &&
}
+
+ );
+ },
+);
diff --git a/apps/client/src/ee/base/components/kanban/kanban-column-header.tsx b/apps/client/src/ee/base/components/kanban/kanban-column-header.tsx
new file mode 100644
index 000000000..3e53080f5
--- /dev/null
+++ b/apps/client/src/ee/base/components/kanban/kanban-column-header.tsx
@@ -0,0 +1,76 @@
+import { useRef } from "react";
+import { useTranslation } from "react-i18next";
+import { ActionIcon, Menu, Text } from "@mantine/core";
+import { IconDots, IconPlus, IconGripVertical } from "@tabler/icons-react";
+import clsx from "clsx";
+import { KanbanColumn } from "@/ee/base/types/base.types";
+import { choiceColor } from "@/ee/base/components/cells/choice-color";
+import { useKanbanColumnDnd } from "@/ee/base/hooks/use-kanban-column-dnd";
+import { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-indicator";
+import classes from "@/ee/base/styles/kanban.module.css";
+
+type KanbanColumnHeaderProps = {
+ column: KanbanColumn;
+ pageId: string;
+ count?: string;
+ canEdit: boolean;
+ onHide: () => void;
+ onAddCard: () => void;
+};
+
+export function KanbanColumnHeader({ column, pageId, count, canEdit, onHide, onAddCard }: KanbanColumnHeaderProps) {
+ const { t } = useTranslation();
+ const dotColor = column.color
+ ? choiceColor(column.color).color as string
+ : "light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3))";
+
+ const headerRef = useRef(null);
+ const handleRef = useRef(null);
+ const { closestEdge, isDragging } = useKanbanColumnDnd({
+ headerRef,
+ handleRef,
+ columnKey: column.key,
+ pageId,
+ });
+
+ return (
+
+ {canEdit && (
+
+
+
+ )}
+
+
+ {column.isNoValue ? t("No value") : column.name}
+
+ {count !== undefined &&
{count} }
+ {canEdit && (
+ <>
+
+
+
+
+
+
+
+ {t("Hide group")}
+
+
+
+
+
+ >
+ )}
+ {closestEdge &&
}
+
+ );
+}
diff --git a/apps/client/src/ee/base/components/kanban/kanban-column.tsx b/apps/client/src/ee/base/components/kanban/kanban-column.tsx
new file mode 100644
index 000000000..95c1a1858
--- /dev/null
+++ b/apps/client/src/ee/base/components/kanban/kanban-column.tsx
@@ -0,0 +1,163 @@
+import { useCallback, useEffect, useMemo, useRef } from "react";
+import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
+import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
+import { type IBase, type IBaseRow, type IBaseView, type FilterGroup, type KanbanColumn as KanbanColumnType, KANBAN_CARD_DRAG_TYPE } from "@/ee/base/types/base.types";
+import { buildColumnFilter } from "@/ee/base/services/kanban-column-filter";
+import { formatKanbanCount } from "@/ee/base/services/format-kanban-count";
+import { useKanbanColumnAutoScroll } from "@/ee/base/hooks/use-kanban-autoscroll";
+import { useBaseRowsQuery } from "@/ee/base/queries/base-row-query";
+import { useKanbanCreateCardMutation } from "@/ee/base/queries/base-row-query";
+import { KanbanColumnHeader } from "@/ee/base/components/kanban/kanban-column-header";
+import { KanbanAddCardButton } from "@/ee/base/components/kanban/kanban-add-card-button";
+import { KanbanCard } from "@/ee/base/components/kanban/kanban-card";
+import classes from "@/ee/base/styles/kanban.module.css";
+
+type KanbanColumnProps = {
+ base: IBase;
+ view: IBaseView;
+ pageId: string;
+ column: KanbanColumnType;
+ viewFilter: FilterGroup | undefined;
+ groupByPropertyId: string;
+ canEdit: boolean;
+ onOpenRow: (rowId: string) => void;
+ onHide: (columnKey: string) => void;
+ registerCardRef: (rowId: string, columnKey: string, el: HTMLDivElement | null) => void;
+ registerColumnRows: (columnKey: string, rows: IBaseRow[]) => void;
+};
+
+export function KanbanColumn({
+ base,
+ view,
+ pageId,
+ column,
+ viewFilter,
+ groupByPropertyId,
+ canEdit,
+ onOpenRow,
+ onHide,
+ registerCardRef,
+ registerColumnRows,
+}: KanbanColumnProps) {
+ const filter = useMemo(
+ () => buildColumnFilter(viewFilter, groupByPropertyId, column.key),
+ [viewFilter, groupByPropertyId, column.key],
+ );
+
+ const rowsQuery = useBaseRowsQuery(pageId, filter, undefined);
+ const createCard = useKanbanCreateCardMutation();
+
+ const rows = useMemo(() => {
+ const pages = rowsQuery.data?.pages ?? [];
+ const seen = new Set();
+ const flat: IBaseRow[] = [];
+ for (const page of pages) {
+ for (const row of page.items) {
+ if (!seen.has(row.id)) {
+ seen.add(row.id);
+ flat.push(row);
+ }
+ }
+ }
+ return flat.slice().sort((a, b) =>
+ a.position < b.position ? -1 : a.position > b.position ? 1 : 0,
+ );
+ }, [rowsQuery.data]);
+
+ const count = rowsQuery.isSuccess
+ ? formatKanbanCount(rows.length, rowsQuery.hasNextPage ?? false)
+ : undefined;
+
+ useEffect(() => {
+ registerColumnRows(column.key, rows);
+ }, [column.key, rows, registerColumnRows]);
+
+ const listRef = useRef(null);
+ useKanbanColumnAutoScroll(listRef, pageId);
+
+ const pendingScrollRef = useRef<"top" | "bottom" | null>(null);
+
+ useEffect(() => {
+ const placement = pendingScrollRef.current;
+ if (!placement) return;
+ pendingScrollRef.current = null;
+ const el = listRef.current;
+ if (!el) return;
+ el.scrollTop = placement === "top" ? 0 : el.scrollHeight;
+ }, [rows]);
+
+ useEffect(() => {
+ const listEl = listRef.current;
+ if (!listEl) return;
+ return dropTargetForElements({
+ element: listEl,
+ canDrop: ({ source }) =>
+ source.data.type === KANBAN_CARD_DRAG_TYPE && source.data.pageId === pageId,
+ getData: () => ({ columnKey: column.key, isColumnBody: true }),
+ });
+ }, [column.key, pageId]);
+
+ const onScroll = useCallback(() => {
+ const el = listRef.current;
+ if (!el) return;
+ const { scrollHeight, scrollTop, clientHeight } = el;
+ if (
+ scrollHeight - scrollTop - clientHeight < 200 &&
+ rowsQuery.hasNextPage &&
+ !rowsQuery.isFetchingNextPage
+ ) {
+ rowsQuery.fetchNextPage();
+ }
+ }, [rowsQuery.hasNextPage, rowsQuery.isFetchingNextPage, rowsQuery.fetchNextPage]);
+
+ const addCard = useCallback(
+ (placement: "top" | "bottom") => {
+ let position: string | undefined;
+ try {
+ position =
+ placement === "top"
+ ? generateJitteredKeyBetween(null, rows[0]?.position ?? null)
+ : generateJitteredKeyBetween(rows[rows.length - 1]?.position ?? null, null);
+ } catch {
+ position = undefined;
+ }
+ createCard.mutate(
+ { pageId, destColumnFilter: filter, groupByPropertyId, columnKey: column.key, position },
+ {
+ onSuccess: (newRow) => {
+ pendingScrollRef.current = placement;
+ onOpenRow(newRow.id);
+ },
+ },
+ );
+ },
+ [createCard, pageId, filter, groupByPropertyId, column.key, onOpenRow, rows],
+ );
+
+ return (
+
+
onHide(column.key)}
+ onAddCard={() => addCard("top")}
+ />
+
+ {rows.map((row) => (
+ registerCardRef(row.id, column.key, el)}
+ />
+ ))}
+ {canEdit && addCard("bottom")} />}
+
+
+ );
+}
diff --git a/apps/client/src/ee/base/components/kanban/kanban-empty-state.tsx b/apps/client/src/ee/base/components/kanban/kanban-empty-state.tsx
new file mode 100644
index 000000000..c62f0aae5
--- /dev/null
+++ b/apps/client/src/ee/base/components/kanban/kanban-empty-state.tsx
@@ -0,0 +1,99 @@
+import { useCallback } from "react";
+import { Stack, Text, Select, Button } from "@mantine/core";
+import { v7 as uuid7 } from "uuid";
+import { useTranslation } from "react-i18next";
+import { IBase, IBaseView } from "@/ee/base/types/base.types";
+import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
+import { useCreatePropertyMutation } from "@/ee/base/queries/base-property-query";
+
+type KanbanEmptyStateProps = {
+ base: IBase;
+ view: IBaseView;
+ pageId: string;
+ editable: boolean;
+};
+
+export function KanbanEmptyState({ base, view, pageId, editable }: KanbanEmptyStateProps) {
+ const { t } = useTranslation();
+ const updateView = useUpdateViewMutation();
+ const createProperty = useCreatePropertyMutation();
+
+ const groupableProperties = base.properties.filter(
+ (p) => p.type === "select" || p.type === "status",
+ );
+
+ const selectData = groupableProperties.map((p) => ({
+ value: p.id,
+ label: p.name,
+ }));
+
+ const handleSelect = useCallback(
+ (value: string | null) => {
+ if (!value) return;
+ updateView.mutate({ viewId: view.id, pageId, config: { groupByPropertyId: value } });
+ },
+ [updateView, view.id, pageId],
+ );
+
+ const handleCreateStatus = useCallback(() => {
+ const todoId = uuid7();
+ const inProgressId = uuid7();
+ const completeId = uuid7();
+ createProperty.mutate(
+ {
+ pageId,
+ name: t("Status"),
+ type: "status",
+ typeOptions: {
+ choices: [
+ { id: todoId, name: t("Not started"), color: "gray", category: "todo" },
+ { id: inProgressId, name: t("In progress"), color: "blue", category: "inProgress" },
+ { id: completeId, name: t("Done"), color: "green", category: "complete" },
+ ],
+ choiceOrder: [todoId, inProgressId, completeId],
+ },
+ },
+ {
+ onSuccess: (newProperty) => {
+ updateView.mutate({
+ viewId: view.id,
+ pageId,
+ config: { groupByPropertyId: newProperty.id },
+ });
+ },
+ },
+ );
+ }, [createProperty, updateView, view.id, pageId, t]);
+
+ if (!editable) {
+ return (
+
+ {t("This board has no grouping property yet.")}
+
+ );
+ }
+
+ return (
+
+ {t("Group this board by a select or status property.")}
+ {groupableProperties.length > 0 ? (
+
+ ) : (
+
+ {t("Create a status property")}
+
+ )}
+
+ );
+}
diff --git a/apps/client/src/ee/base/components/kanban/kanban-group-by-picker.tsx b/apps/client/src/ee/base/components/kanban/kanban-group-by-picker.tsx
new file mode 100644
index 000000000..56fe903b6
--- /dev/null
+++ b/apps/client/src/ee/base/components/kanban/kanban-group-by-picker.tsx
@@ -0,0 +1,115 @@
+import { Popover, Select, Stack, Text, Switch, Group, UnstyledButton } from "@mantine/core";
+import { useTranslation } from "react-i18next";
+import { IBase, IBaseView } from "@/ee/base/types/base.types";
+import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
+import { useKanbanColumns } from "@/ee/base/hooks/use-kanban-columns";
+import { choiceColor } from "@/ee/base/components/cells/choice-color";
+import cellClasses from "@/ee/base/styles/cells.module.css";
+
+type KanbanGroupByPickerProps = {
+ base: IBase;
+ view: IBaseView;
+ pageId: string;
+ children: React.ReactNode;
+};
+
+export function KanbanGroupByPicker({ base, view, pageId, children }: KanbanGroupByPickerProps) {
+ const { t } = useTranslation();
+ const updateView = useUpdateViewMutation();
+ const { allGroups, hasValidGroupBy } = useKanbanColumns(base, view);
+
+ const data = base.properties
+ .filter((p) => p.type === "select" || p.type === "status")
+ .map((p) => ({ value: p.id, label: p.name }));
+
+ const handleChange = (value: string | null) => {
+ updateView.mutate({
+ viewId: view.id,
+ pageId,
+ config: { groupByPropertyId: value ?? null },
+ });
+ };
+
+ const toggleGroup = (key: string, currentlyHidden: boolean) => {
+ const current = view.config?.hiddenChoiceIds ?? [];
+ const next = currentlyHidden
+ ? current.filter((k) => k !== key)
+ : [...current, key];
+ updateView.mutate({ viewId: view.id, pageId, config: { hiddenChoiceIds: next } });
+ };
+
+ return (
+
+ {children}
+
+
+
+ {t("Group by")}
+
+
+ {hasValidGroupBy && allGroups.length > 0 && (
+
+
+ {t("Groups")}
+
+
+ {allGroups.map((g) => {
+ const dotColor = g.color
+ ? (choiceColor(g.color).color as string)
+ : "light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3))";
+ return (
+ toggleGroup(g.key, g.hidden)}
+ >
+
+
+
+ {g.isNoValue ? t("No value") : g.name}
+
+
+ {}}
+ onClick={(e) => e.stopPropagation()}
+ styles={{ track: { cursor: "pointer" } }}
+ />
+
+ );
+ })}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/client/src/ee/base/components/property/choice-editor.tsx b/apps/client/src/ee/base/components/property/choice-editor.tsx
new file mode 100644
index 000000000..f019baec7
--- /dev/null
+++ b/apps/client/src/ee/base/components/property/choice-editor.tsx
@@ -0,0 +1,673 @@
+import { useState, useCallback, useMemo, useEffect, useRef, useLayoutEffect } from "react";
+import {
+ TextInput,
+ Group,
+ Stack,
+ Text,
+ Button,
+ Popover,
+ SimpleGrid,
+ UnstyledButton,
+ CloseButton,
+ Divider,
+} from "@mantine/core";
+import {
+ IconPlus,
+ IconGripVertical,
+ IconArrowsSort,
+} from "@tabler/icons-react";
+import classes from "@/ee/base/styles/property.module.css";
+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 { reorder } from "@atlaskit/pragmatic-drag-and-drop/reorder";
+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 { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-indicator";
+import { Choice } from "@/ee/base/types/base.types";
+import { choiceColor } from "@/ee/base/components/cells/choice-color";
+import { useTranslation } from "react-i18next";
+import { v7 as uuid7 } from "uuid";
+import { DefaultValuePicker } from "./default-value-picker";
+
+const CHOICE_COLORS = [
+ "gray", "red", "pink", "grape", "violet", "indigo",
+ "blue", "cyan", "teal", "green", "lime", "yellow", "orange",
+];
+
+const STATUS_CATEGORIES = [
+ { value: "todo", label: "To Do" },
+ { value: "inProgress", label: "In Progress" },
+ { value: "complete", label: "Complete" },
+] as const;
+
+// Default choices for a new status property, one per category.
+export function defaultStatusChoices(): Choice[] {
+ return [
+ { id: uuid7(), name: "Not started", color: "gray", category: "todo" },
+ { id: uuid7(), name: "In progress", color: "blue", category: "inProgress" },
+ { id: uuid7(), name: "Done", color: "green", category: "complete" },
+ ];
+}
+
+function pruneDefault(
+ value: string | string[] | null,
+ choices: Choice[],
+): string | string[] | null {
+ if (value === null) return null;
+ const ids = new Set(choices.map((c) => c.id));
+ if (Array.isArray(value)) {
+ const live = value.filter((id) => ids.has(id));
+ return live.length ? live : null;
+ }
+ return ids.has(value) ? value : null;
+}
+
+function defaultsEqual(
+ a: string | string[] | null,
+ b: string | string[] | null,
+): boolean {
+ if (Array.isArray(a) && Array.isArray(b)) {
+ return a.length === b.length && a.every((v, i) => v === b[i]);
+ }
+ return a === b;
+}
+
+type ChoiceEditorProps = {
+ initialChoices: Choice[];
+ onSave: (choices: Choice[], defaultValue: string | string[] | null) => void;
+ onClose: () => void;
+ onDirtyChange?: (dirty: boolean) => void;
+ showCategories?: boolean;
+ hideButtons?: boolean;
+ initialDefaultValue?: string | string[] | null;
+ multiDefault?: boolean;
+ /**
+ * Where the per-choice color-picker popover portals. Pass the enclosing
+ * property-menu dropdown node so the picker renders INSIDE that subtree —
+ * otherwise a color click registers as "outside" and closes the menu.
+ */
+ dropdownPortalTarget?: HTMLElement | null;
+};
+
+export function ChoiceEditor({
+ initialChoices,
+ onSave,
+ onClose,
+ onDirtyChange,
+ showCategories = false,
+ hideButtons = false,
+ initialDefaultValue = null,
+ multiDefault = false,
+ dropdownPortalTarget,
+}: ChoiceEditorProps) {
+ const { t } = useTranslation();
+ const [draft, setDraft] = useState(initialChoices);
+ const [focusChoiceId, setFocusChoiceId] = useState(null);
+ const [defaultDraft, setDefaultDraft] = useState(
+ initialDefaultValue,
+ );
+
+ useEffect(() => {
+ if (!hideButtons) {
+ setDraft(initialChoices);
+ setDefaultDraft(initialDefaultValue);
+ }
+ }, [initialChoices, initialDefaultValue, hideButtons]);
+
+ const onSaveRef = useRef(onSave);
+ onSaveRef.current = onSave;
+
+ useEffect(() => {
+ if (hideButtons) {
+ const cleaned = draft.filter((c) => c.name.trim());
+ onSaveRef.current(cleaned, pruneDefault(defaultDraft, cleaned));
+ }
+ }, [hideButtons, draft, defaultDraft]);
+
+ const isDirty = useMemo(() => {
+ if (!defaultsEqual(defaultDraft, initialDefaultValue)) return true;
+ if (draft.length !== initialChoices.length) return true;
+ return draft.some((d, i) => {
+ const o = initialChoices[i];
+ return d.id !== o.id || d.name !== o.name || d.color !== o.color || d.category !== o.category;
+ });
+ }, [draft, initialChoices, defaultDraft, initialDefaultValue]);
+
+ useEffect(() => {
+ onDirtyChange?.(isDirty);
+ }, [isDirty, onDirtyChange]);
+
+ const hasEmptyNames = draft.some((c) => !c.name.trim());
+
+ const handleRename = useCallback((choiceId: string, name: string) => {
+ setDraft((prev) => prev.map((c) => (c.id === choiceId ? { ...c, name } : c)));
+ }, []);
+
+ const handleColorChange = useCallback((choiceId: string, color: string) => {
+ setDraft((prev) => prev.map((c) => (c.id === choiceId ? { ...c, color } : c)));
+ }, []);
+
+ const handleRemove = useCallback((choiceId: string) => {
+ setDraft((prev) => prev.filter((c) => c.id !== choiceId));
+ setDefaultDraft((prev) => {
+ if (prev === null) return prev;
+ if (Array.isArray(prev)) {
+ const next = prev.filter((id) => id !== choiceId);
+ return next.length ? next : null;
+ }
+ return prev === choiceId ? null : prev;
+ });
+ }, []);
+
+ const handleAdd = useCallback((category?: "todo" | "inProgress" | "complete") => {
+ const id = uuid7();
+ setDraft((prev) => {
+ const colorIndex = prev.length % CHOICE_COLORS.length;
+ const newChoice: Choice = {
+ id,
+ name: "",
+ color: CHOICE_COLORS[colorIndex],
+ ...(category ? { category } : {}),
+ };
+ return [...prev, newChoice];
+ });
+ setFocusChoiceId(id);
+ }, []);
+
+ const handleAlphabetize = useCallback(() => {
+ setDraft((prev) => [...prev].sort((a, b) => a.name.localeCompare(b.name)));
+ }, []);
+
+ const handleSave = useCallback(() => {
+ const cleaned = draft.filter((c) => c.name.trim());
+ onSave(cleaned, pruneDefault(defaultDraft, cleaned));
+ onClose();
+ }, [draft, defaultDraft, onSave, onClose]);
+
+ const handleCancel = useCallback(() => {
+ setDraft(initialChoices);
+ setDefaultDraft(initialDefaultValue);
+ onDirtyChange?.(false);
+ onClose();
+ }, [initialChoices, initialDefaultValue, onDirtyChange, onClose]);
+
+ const handleReorder = useCallback(
+ (activeId: string, targetId: string, edge: Edge) => {
+ setDraft((prev) => {
+ const startIndex = prev.findIndex((c) => c.id === activeId);
+ const indexOfTarget = prev.findIndex((c) => c.id === targetId);
+ if (startIndex === -1 || indexOfTarget === -1) return prev;
+ const finishIndex = getReorderDestinationIndex({
+ startIndex,
+ indexOfTarget,
+ closestEdgeOfTarget: edge,
+ axis: "vertical",
+ });
+ if (finishIndex === startIndex) return prev;
+ return reorder({ list: prev, startIndex, finishIndex });
+ });
+ },
+ [],
+ );
+
+ const handleCategoryReorder = useCallback(
+ (category: string, activeId: string, targetId: string, edge: Edge) => {
+ setDraft((prev) => {
+ const catChoices = prev.filter((c) => (c.category ?? "todo") === category);
+ const startIndex = catChoices.findIndex((c) => c.id === activeId);
+ const indexOfTarget = catChoices.findIndex((c) => c.id === targetId);
+ if (startIndex === -1 || indexOfTarget === -1) return prev;
+ const finishIndex = getReorderDestinationIndex({
+ startIndex,
+ indexOfTarget,
+ closestEdgeOfTarget: edge,
+ axis: "vertical",
+ });
+ if (finishIndex === startIndex) return prev;
+ const reordered = reorder({
+ list: catChoices,
+ startIndex,
+ finishIndex,
+ });
+ const result: Choice[] = [];
+ for (const cat of ["todo", "inProgress", "complete"]) {
+ if (cat === category) {
+ result.push(...reordered);
+ } else {
+ result.push(...prev.filter((c) => (c.category ?? "todo") === cat));
+ }
+ }
+ return result;
+ });
+ },
+ [],
+ );
+
+ return (
+
+
+
+ {t("Options")}
+
+
+
+ {t("Alphabetize")}
+
+
+
+ {showCategories ? (
+ setFocusChoiceId(null)}
+ onRename={handleRename}
+ onColorChange={handleColorChange}
+ onRemove={handleRemove}
+ onAdd={handleAdd}
+ onCategoryReorder={handleCategoryReorder}
+ dropdownPortalTarget={dropdownPortalTarget}
+ />
+ ) : (
+ setFocusChoiceId(null)}
+ onRename={handleRename}
+ onColorChange={handleColorChange}
+ onRemove={handleRemove}
+ onAdd={handleAdd}
+ onReorder={handleReorder}
+ dropdownPortalTarget={dropdownPortalTarget}
+ />
+ )}
+
+ c.name.trim())}
+ value={defaultDraft}
+ multiple={multiDefault}
+ onChange={setDefaultDraft}
+ dropdownPortalTarget={dropdownPortalTarget}
+ />
+
+ {!hideButtons && (
+ <>
+
+
+
+
+ {t("Cancel")}
+
+
+ {t("Save")}
+
+
+ >
+ )}
+
+ );
+}
+
+function FlatChoiceList({
+ draft,
+ focusChoiceId,
+ onFocused,
+ onRename,
+ onColorChange,
+ onRemove,
+ onAdd,
+ onReorder,
+ dropdownPortalTarget,
+}: {
+ draft: Choice[];
+ focusChoiceId: string | null;
+ onFocused: () => void;
+ onRename: (id: string, name: string) => void;
+ onColorChange: (id: string, color: string) => void;
+ onRemove: (id: string) => void;
+ onAdd: () => void;
+ onReorder: (activeId: string, targetId: string, edge: Edge) => void;
+ dropdownPortalTarget?: HTMLElement | null;
+}) {
+ const { t } = useTranslation();
+
+ return (
+
+ {draft.map((choice) => (
+
+ ))}
+
+ onAdd()}
+ className={classes.addOptionBtn}
+ >
+
+ {t("Add option")}
+
+
+ );
+}
+
+function StatusChoiceList({
+ draft,
+ focusChoiceId,
+ onFocused,
+ onRename,
+ onColorChange,
+ onRemove,
+ onAdd,
+ onCategoryReorder,
+ dropdownPortalTarget,
+}: {
+ draft: Choice[];
+ focusChoiceId: string | null;
+ onFocused: () => void;
+ onRename: (id: string, name: string) => void;
+ onColorChange: (id: string, color: string) => void;
+ onRemove: (id: string) => void;
+ onAdd: (category: "todo" | "inProgress" | "complete") => void;
+ onCategoryReorder: (category: string, activeId: string, targetId: string, edge: Edge) => void;
+ dropdownPortalTarget?: HTMLElement | null;
+}) {
+ const grouped = useMemo(() => {
+ const groups: Record = { todo: [], inProgress: [], complete: [] };
+ for (const choice of draft) {
+ const cat = choice.category ?? "todo";
+ (groups[cat] ?? groups.todo).push(choice);
+ }
+ return groups;
+ }, [draft]);
+
+ return (
+
+ {STATUS_CATEGORIES.map(({ value: category, label }) => (
+
+ ))}
+
+ );
+}
+
+function CategorySection({
+ category,
+ label,
+ choices,
+ focusChoiceId,
+ onFocused,
+ onRename,
+ onColorChange,
+ onRemove,
+ onAdd,
+ onReorder,
+ dropdownPortalTarget,
+}: {
+ category: "todo" | "inProgress" | "complete";
+ label: string;
+ choices: Choice[];
+ focusChoiceId: string | null;
+ onFocused: () => void;
+ onRename: (id: string, name: string) => void;
+ onColorChange: (id: string, color: string) => void;
+ onRemove: (id: string) => void;
+ onAdd: (category: "todo" | "inProgress" | "complete") => void;
+ onReorder: (
+ category: string,
+ activeId: string,
+ targetId: string,
+ edge: Edge,
+ ) => void;
+ dropdownPortalTarget?: HTMLElement | null;
+}) {
+ const { t } = useTranslation();
+
+ const handleRowReorder = useCallback(
+ (activeId: string, targetId: string, edge: Edge) => {
+ onReorder(category, activeId, targetId, edge);
+ },
+ [category, onReorder],
+ );
+
+ return (
+
+
+ {t(label)}
+
+
+ {choices.map((choice) => (
+
+ ))}
+
+ onAdd(category)}
+ className={classes.addOptionBtn}
+ >
+
+ {t("Add option")}
+
+
+ );
+}
+
+function SortableChoiceRow({
+ choice,
+ dragType,
+ autoFocus,
+ onFocused,
+ onRename,
+ onColorChange,
+ onRemove,
+ onReorder,
+ dropdownPortalTarget,
+}: {
+ choice: Choice;
+ dragType: string;
+ autoFocus?: boolean;
+ onFocused?: () => void;
+ onRename: (id: string, name: string) => void;
+ onColorChange: (id: string, color: string) => void;
+ onRemove: (id: string) => void;
+ onReorder: (activeId: string, targetId: string, edge: Edge) => void;
+ dropdownPortalTarget?: HTMLElement | null;
+}) {
+ const inputRef = useRef(null);
+ const rowRef = useRef(null);
+ const handleRef = useRef(null);
+
+ const [isDragging, setIsDragging] = useState(false);
+ const [closestEdge, setClosestEdge] = useState(null);
+
+ // Stable ref so the DnD effect doesn't re-register on every parent render.
+ const onReorderRef = useRef(onReorder);
+ useLayoutEffect(() => {
+ onReorderRef.current = onReorder;
+ });
+
+ useEffect(() => {
+ if (autoFocus) {
+ inputRef.current?.focus();
+ onFocused?.();
+ }
+ }, [autoFocus, onFocused]);
+
+ useEffect(() => {
+ const row = rowRef.current;
+ const handle = handleRef.current;
+ if (!row || !handle) return;
+ return combine(
+ draggable({
+ element: row,
+ dragHandle: handle,
+ getInitialData: () => ({ type: dragType, choiceId: choice.id }),
+ onDragStart: () => setIsDragging(true),
+ onDrop: () => setIsDragging(false),
+ }),
+ dropTargetForElements({
+ element: row,
+ canDrop: ({ source }) =>
+ source.data.type === dragType &&
+ source.data.choiceId !== choice.id,
+ getData: ({ input, element }) =>
+ attachClosestEdge(
+ { choiceId: choice.id },
+ { 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;
+ onReorderRef.current(
+ source.data.choiceId as string,
+ choice.id,
+ edge,
+ );
+ triggerPostMoveFlash(row);
+ liveRegion.announce("Moved option");
+ },
+ }),
+ );
+ }, [choice.id, dragType]);
+
+ const hasError = !choice.name.trim();
+
+ return (
+
+
+
+
+ onColorChange(choice.id, c)}
+ dropdownPortalTarget={dropdownPortalTarget}
+ />
+ onRename(choice.id, e.currentTarget.value)}
+ style={{ flex: 1 }}
+ error={hasError}
+ styles={hasError ? { input: { borderColor: "var(--mantine-color-red-6)" } } : undefined}
+ />
+ onRemove(choice.id)} />
+ {closestEdge && }
+
+ );
+}
+
+function ColorDot({
+ color,
+ onChange,
+ dropdownPortalTarget,
+}: {
+ color: string;
+ onChange: (color: string) => void;
+ dropdownPortalTarget?: HTMLElement | null;
+}) {
+ const [opened, setOpened] = useState(false);
+ const colors = choiceColor(color);
+
+ return (
+
+
+ setOpened((o) => !o)}
+ style={{
+ width: 20,
+ height: 20,
+ borderRadius: "50%",
+ backgroundColor: colors.backgroundColor as string,
+ border: `2px solid ${colors.color as string}`,
+ flexShrink: 0,
+ }}
+ />
+
+
+
+ {CHOICE_COLORS.map((c) => {
+ const dotColors = choiceColor(c);
+ return (
+ {
+ onChange(c);
+ setOpened(false);
+ }}
+ style={{
+ width: 24,
+ height: 24,
+ borderRadius: "50%",
+ backgroundColor: dotColors.backgroundColor as string,
+ border: c === color
+ ? `2px solid ${dotColors.color as string}`
+ : "2px solid transparent",
+ }}
+ />
+ );
+ })}
+
+
+
+ );
+}
diff --git a/apps/client/src/ee/base/components/property/conversion-warning.ts b/apps/client/src/ee/base/components/property/conversion-warning.ts
new file mode 100644
index 000000000..034d30585
--- /dev/null
+++ b/apps/client/src/ee/base/components/property/conversion-warning.ts
@@ -0,0 +1,127 @@
+import type { BasePropertyType } from "@/ee/base/types/base.types";
+
+export const NON_USER_TARGET_TYPES = new Set([
+ "createdAt",
+ "lastEditedAt",
+ "lastEditedBy",
+ "formula",
+]);
+
+type ConversionInfo = {
+ // i18n source key (translation files key them by their exact text).
+ message: string;
+ // True when cells can be cleared, discarded, truncated, or have their
+ // structured value flattened, i.e. the change is not safely reversible.
+ // Drives the destructive (red) "Apply" button in the confirm panel.
+ lossy: boolean;
+};
+
+// Buckets ordered most-specific first; default covers safe reinterpretations.
+function describeConversion(
+ from: BasePropertyType,
+ to: BasePropertyType,
+): ConversionInfo {
+ if (to === "text" || to === "longText") {
+ if (from === "longText" && to === "text") {
+ return {
+ message:
+ "Cells longer than the Text limit will be truncated and the extra content permanently lost.",
+ lossy: true,
+ };
+ }
+ if (from === "select" || from === "status") {
+ return { message: "Cells will be replaced with the option name.", lossy: true };
+ }
+ if (from === "multiSelect") {
+ return {
+ message:
+ "Cells will be replaced with a comma-separated list of option names.",
+ lossy: true,
+ };
+ }
+ if (from === "person") {
+ return { message: "Cells will be replaced with the person's name.", lossy: true };
+ }
+ if (from === "file") {
+ return {
+ message:
+ "Cells will be replaced with a comma-separated list of file names.",
+ lossy: true,
+ };
+ }
+ if (from === "page") {
+ return { message: "Cells will be replaced with the page title.", lossy: true };
+ }
+ }
+
+ if (to === "select" && from === "multiSelect") {
+ return {
+ message:
+ "Only the first selected item per row will be kept; the rest will be discarded.",
+ lossy: true,
+ };
+ }
+
+ if (to === "multiSelect" && from === "select") {
+ return {
+ message: "Existing values become single-item lists. No data is lost.",
+ lossy: false,
+ };
+ }
+
+ if (to === "page") {
+ return {
+ message: "Cells that aren't already a page reference will be cleared.",
+ lossy: true,
+ };
+ }
+
+ if (to === "number" && from !== "number") {
+ return {
+ message: "Cells that can't be parsed as a number will be cleared.",
+ lossy: true,
+ };
+ }
+
+ if (to === "date" && from !== "date") {
+ return {
+ message: "Cells that can't be parsed as a date will be cleared.",
+ lossy: true,
+ };
+ }
+
+ if (to === "checkbox" && from !== "checkbox") {
+ return {
+ message:
+ "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).",
+ lossy: true,
+ };
+ }
+
+ if ((to === "url" || to === "email") && from !== to) {
+ return {
+ message:
+ to === "url"
+ ? "Cells that aren't a valid URL will be cleared."
+ : "Cells that aren't a valid email address will be cleared.",
+ lossy: true,
+ };
+ }
+
+ return { message: "Cells will be reinterpreted under the new type.", lossy: false };
+}
+
+export function conversionWarning(
+ from: BasePropertyType,
+ to: BasePropertyType,
+): string {
+ return describeConversion(from, to).message;
+}
+
+// Whether the type change can lose data, used to make "Apply" destructive.
+export function isLossyConversion(
+ from: BasePropertyType,
+ to: BasePropertyType,
+): boolean {
+ return describeConversion(from, to).lossy;
+}
diff --git a/apps/client/src/ee/base/components/property/create-property-popover.tsx b/apps/client/src/ee/base/components/property/create-property-popover.tsx
new file mode 100644
index 000000000..4c4394f51
--- /dev/null
+++ b/apps/client/src/ee/base/components/property/create-property-popover.tsx
@@ -0,0 +1,388 @@
+import { useState, useCallback, useRef, useEffect, useMemo } from "react";
+import {
+ Popover,
+ TextInput,
+ Button,
+ Group,
+ Stack,
+ Divider,
+ UnstyledButton,
+ Text,
+ ScrollArea,
+} from "@mantine/core";
+import { IconPlus, IconChevronRight } from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import {
+ BasePropertyType,
+ IBaseProperty,
+ TypeOptions,
+} from "@/ee/base/types/base.types";
+import { useCreatePropertyMutation } from "@/ee/base/queries/base-property-query";
+import { PropertyTypePicker, propertyTypes } from "./property-type-picker";
+import { PropertyOptions } from "./property-options";
+import {
+ getDescriptor,
+ defaultTypeOptionsFor,
+} from "@/ee/base/property-types/property-type.registry";
+import { FormulaEditor } from "../formula/formula-editor";
+import classes from "@/ee/base/styles/grid.module.css";
+
+type CreatePropertyPopoverProps = {
+ pageId: string;
+ properties?: IBaseProperty[];
+ onPropertyCreated?: (property: IBaseProperty) => void;
+ /** Custom trigger; must return a ref-forwarding element for Popover.Target.
+ * Defaults to the grid's + column button. */
+ renderTarget?: (open: () => void) => React.ReactElement;
+};
+
+type Panel = "typePicker" | "configure" | "confirmDiscard";
+
+const noop = () => {};
+
+export function CreatePropertyPopover({ pageId, properties, onPropertyCreated, renderTarget }: CreatePropertyPopoverProps) {
+ const { t } = useTranslation();
+ const [opened, setOpened] = useState(false);
+ const [panel, setPanel] = useState("typePicker");
+ const [selectedType, setSelectedType] = useState(null);
+ const [name, setName] = useState("");
+ const [typeOptions, setTypeOptions] = useState>({});
+ // Portal target for nested Select dropdowns to avoid triggering closeOnClickOutside.
+ const [dropdownNode, setDropdownNode] = useState(null);
+ const nameInputRef = useRef(null);
+
+ const createPropertyMutation = useCreatePropertyMutation();
+
+ const selectedTypeDef = useMemo(
+ () => propertyTypes.find((pt) => pt.type === selectedType),
+ [selectedType],
+ );
+ const selectedTypeLabel = selectedTypeDef ? t(selectedTypeDef.labelKey) : "";
+ const selectedTypeIcon = selectedTypeDef?.icon;
+
+ const hasContent = useMemo(() => {
+ return name.trim().length > 0 || Object.keys(typeOptions).length > 0;
+ }, [name, typeOptions]);
+
+ const nameTaken = useMemo(() => {
+ const trimmed = name.trim().toLowerCase();
+ if (!trimmed) return false;
+ return (properties ?? []).some(
+ (p) => p.name.trim().toLowerCase() === trimmed,
+ );
+ }, [name, properties]);
+
+ // Fall back to the type label when Name is blank, suffixing a counter if taken.
+ const fallbackName = useMemo(() => {
+ const base = selectedTypeLabel || "Property";
+ const existing = new Set(
+ (properties ?? []).map((p) => p.name.trim().toLowerCase()),
+ );
+ if (!existing.has(base.toLowerCase())) return base;
+ for (let i = 1; i < 1000; i++) {
+ const candidate = `${base} ${i}`;
+ if (!existing.has(candidate.toLowerCase())) return candidate;
+ }
+ return `${base} ${Date.now()}`;
+ }, [selectedTypeLabel, properties]);
+
+ const resetState = useCallback(() => {
+ setPanel("typePicker");
+ setSelectedType(null);
+ setName("");
+ setTypeOptions({});
+ }, []);
+
+ const handleOpen = useCallback(() => {
+ resetState();
+ setOpened(true);
+ }, [resetState]);
+
+ const handleClose = useCallback(() => {
+ // Don't reset state here: resetting mid-close flashes the type picker.
+ // handleOpen resets on the next open instead.
+ setOpened(false);
+ }, []);
+
+ const attemptClose = useCallback(() => {
+ if (panel === "configure" && hasContent) {
+ setPanel("confirmDiscard");
+ } else {
+ handleClose();
+ }
+ }, [panel, hasContent, handleClose]);
+
+ const handleConfirmDiscard = useCallback(() => {
+ handleClose();
+ }, [handleClose]);
+
+ const handleCancelDiscard = useCallback(() => {
+ setPanel("configure");
+ }, []);
+
+ const handleTypeSelect = useCallback((type: BasePropertyType) => {
+ setSelectedType(type);
+ setTypeOptions(defaultTypeOptionsFor(type));
+ setPanel("configure");
+ }, []);
+
+ useEffect(() => {
+ if (panel === "configure") {
+ setTimeout(() => nameInputRef.current?.focus(), 0);
+ }
+ }, [panel]);
+
+ const handleCreate = useCallback(() => {
+ if (!selectedType || nameTaken) return;
+ const finalName = name.trim() || fallbackName;
+ createPropertyMutation.mutate(
+ {
+ pageId,
+ name: finalName,
+ type: selectedType,
+ typeOptions: Object.keys(typeOptions).length > 0
+ ? typeOptions as TypeOptions
+ : undefined,
+ },
+ {
+ onSuccess: (created) => {
+ onPropertyCreated?.(created);
+ },
+ },
+ );
+ handleClose();
+ }, [selectedType, nameTaken, name, fallbackName, typeOptions, pageId, createPropertyMutation, handleClose, onPropertyCreated]);
+
+ const handleBackToTypePicker = useCallback(() => {
+ setPanel("typePicker");
+ setTypeOptions({});
+ }, []);
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === "Escape") {
+ e.preventDefault();
+ e.stopPropagation();
+ if (panel === "confirmDiscard") {
+ handleCancelDiscard();
+ } else if (panel === "configure") {
+ handleBackToTypePicker();
+ } else {
+ handleClose();
+ }
+ }
+ },
+ [panel, handleBackToTypePicker, handleClose, handleCancelDiscard],
+ );
+
+ const handleNameKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ handleCreate();
+ }
+ },
+ [handleCreate],
+ );
+
+ const handleOptionsUpdate = useCallback(
+ (newTypeOptions: Record) => {
+ setTypeOptions(newTypeOptions);
+ },
+ [],
+ );
+
+ const syntheticProperty: IBaseProperty = useMemo(() => ({
+ id: "",
+ pageId,
+ name: name || "",
+ type: selectedType ?? "text",
+ position: "",
+ typeOptions: typeOptions as TypeOptions,
+ isPrimary: false,
+ workspaceId: "",
+ createdAt: "",
+ updatedAt: "",
+ }), [pageId, name, selectedType, typeOptions]);
+
+ const TypeIcon = selectedTypeIcon;
+ const showOptions = !!selectedType && (getDescriptor(selectedType)?.hasOptions ?? false);
+
+ return (
+ <>
+ {
+ if (!o) attemptClose();
+ }}
+ position="bottom-start"
+ shadow="md"
+ closeOnClickOutside
+ closeOnEscape={false}
+ withinPortal
+ >
+
+ {renderTarget ? (
+ renderTarget(handleOpen)
+ ) : (
+
+
+
+ )}
+
+ e.stopPropagation()}
+ onKeyDown={handleKeyDown}
+ style={{
+ zIndex: 300,
+ width: selectedType === "formula" ? 460 : undefined,
+ minWidth: selectedType === "formula" ? undefined : 320,
+ maxWidth: "calc(100vw - 32px)",
+ }}
+ >
+ {panel === "typePicker" && (
+
+
+
+
+
+ )}
+ {panel === "configure" && selectedType === "formula" && (
+
+ setName(e.currentTarget.value)}
+ error={nameTaken ? t("A property with this name already exists") : undefined}
+ />
+ {
+ if (nameTaken) return;
+ createPropertyMutation.mutate(
+ {
+ pageId,
+ name: name.trim() || fallbackName,
+ type: "formula",
+ typeOptions: {
+ source,
+ ast,
+ resultType,
+ dependencies,
+ astVersion: 1,
+ } as TypeOptions,
+ },
+ { onSuccess: (created) => onPropertyCreated?.(created) },
+ );
+ handleClose();
+ }}
+ />
+
+ )}
+ {(panel === "configure" || panel === "confirmDiscard") && selectedType !== "formula" && (
+
+ setName(e.currentTarget.value)}
+ onKeyDown={handleNameKeyDown}
+ error={nameTaken ? t("A property with this name already exists") : undefined}
+ mb="xs"
+ />
+
+
+ {TypeIcon && }
+
+ {selectedTypeLabel}
+
+
+
+
+
+ {showOptions && (
+ <>
+
+
+
+
+ >
+ )}
+
+
+
+
+ {t("Cancel")}
+
+
+ {t("Create property")}
+
+
+
+ )}
+ {panel === "confirmDiscard" && (
+
+
+ {t("Unsaved changes")}
+
+
+ {t("You have unsaved changes. Do you want to discard them?")}
+
+
+
+ {t("Keep editing")}
+
+
+ {t("Discard")}
+
+
+
+ )}
+
+
+ >
+ );
+}
diff --git a/apps/client/src/ee/base/components/property/default-value-picker.tsx b/apps/client/src/ee/base/components/property/default-value-picker.tsx
new file mode 100644
index 000000000..52a99cf32
--- /dev/null
+++ b/apps/client/src/ee/base/components/property/default-value-picker.tsx
@@ -0,0 +1,98 @@
+import { Group, MultiSelect, Select, Text } from "@mantine/core";
+import { IconCheck } from "@tabler/icons-react";
+import { Choice } from "@/ee/base/types/base.types";
+import { choiceColor } from "@/ee/base/components/cells/choice-color";
+import { useTranslation } from "react-i18next";
+import type { ComboboxItem } from "@mantine/core";
+
+type DefaultValuePickerProps = {
+ choices: Choice[];
+ value: string | string[] | null;
+ multiple?: boolean;
+ onChange: (value: string | string[] | null) => void;
+ dropdownPortalTarget?: HTMLElement | null;
+};
+
+export function DefaultValuePicker({
+ choices,
+ value,
+ multiple,
+ onChange,
+ dropdownPortalTarget,
+}: DefaultValuePickerProps) {
+ const { t } = useTranslation();
+ const data = choices.map((c) => ({ value: c.id, label: c.name }));
+ const comboboxProps = {
+ portalProps: { target: dropdownPortalTarget ?? undefined },
+ };
+
+ const renderOption = ({
+ option,
+ checked,
+ }: {
+ option: ComboboxItem;
+ checked?: boolean;
+ }) => {
+ const choice = choices.find((c) => c.id === option.value);
+ const colors = choice ? choiceColor(choice.color) : undefined;
+ return (
+
+
+ {colors && (
+
+ )}
+ {option.label}
+
+ {checked && (
+
+ )}
+
+ );
+ };
+
+ if (multiple) {
+ const selected = (
+ Array.isArray(value) ? value : value ? [value] : []
+ ).filter((id) => choices.some((c) => c.id === id));
+ return (
+ onChange(vals.length ? vals : null)}
+ clearable
+ comboboxProps={comboboxProps}
+ renderOption={renderOption}
+ />
+ );
+ }
+
+ const single =
+ typeof value === "string" && choices.some((c) => c.id === value)
+ ? value
+ : null;
+ return (
+ onChange(val)}
+ clearable
+ comboboxProps={comboboxProps}
+ renderOption={renderOption}
+ />
+ );
+}
diff --git a/apps/client/src/ee/base/components/property/property-menu.tsx b/apps/client/src/ee/base/components/property/property-menu.tsx
new file mode 100644
index 000000000..1dba1a722
--- /dev/null
+++ b/apps/client/src/ee/base/components/property/property-menu.tsx
@@ -0,0 +1,564 @@
+import { useState, useCallback, useRef, useEffect } from "react";
+import {
+ UnstyledButton,
+ TextInput,
+ Button,
+ Stack,
+ Text,
+ Group,
+ ActionIcon,
+ Divider,
+ ScrollArea,
+ Loader,
+} from "@mantine/core";
+import {
+ IconTrash,
+ IconPencil,
+ IconChevronRight,
+ IconSettings,
+ IconMathFunction,
+} from "@tabler/icons-react";
+import {
+ IBaseProperty,
+ BasePropertyType,
+} from "@/ee/base/types/base.types";
+import { useAtom } from "jotai";
+import { propertyMenuCloseRequestAtomFamily } from "@/ee/base/atoms/base-atoms";
+import {
+ useUpdatePropertyMutation,
+ useDeletePropertyMutation,
+} from "@/ee/base/queries/base-property-query";
+import { PropertyTypePicker, propertyTypes } from "./property-type-picker";
+import { PropertyOptions } from "./property-options";
+import {
+ conversionWarning,
+ isLossyConversion,
+ NON_USER_TARGET_TYPES,
+} from "./conversion-warning";
+import { useTranslation } from "react-i18next";
+import { isSystemPropertyType } from "@/ee/base/property-types/property-type.registry";
+import cellClasses from "@/ee/base/styles/cells.module.css";
+import classes from "@/ee/base/styles/property.module.css";
+
+type PropertyMenuContentProps = {
+ property: IBaseProperty;
+ opened: boolean;
+ onClose: () => void;
+ onDirtyChange?: (dirty: boolean) => void;
+ onEditFormula?: () => void;
+ pageId: string;
+};
+
+type MenuPanel =
+ | "main"
+ | "rename"
+ | "options"
+ | "changeType"
+ | "confirmTypeChange"
+ | "confirmDelete"
+ | "confirmDiscard";
+
+export function PropertyMenuContent({
+ property,
+ opened,
+ onClose,
+ onDirtyChange,
+ onEditFormula,
+ pageId,
+}: PropertyMenuContentProps) {
+ const { t } = useTranslation();
+ const [panel, setPanel] = useState("main");
+ const [renameValue, setRenameValue] = useState(property.name);
+ const renameInputRef = useRef(null);
+ const [optionsDirty, setOptionsDirty] = useState(false);
+ // Portal target for nested Select dropdowns to avoid triggering closeOnClickOutside.
+ const [optionsAnchor, setOptionsAnchor] = useState(null);
+ const [pendingTargetType, setPendingTargetType] = useState(null);
+ const pendingActionRef = useRef<"back" | "close" | null>(null);
+ const sourcePanelRef = useRef<"rename" | "options" | null>(null);
+ const [closeRequest] = useAtom(propertyMenuCloseRequestAtomFamily(pageId)) as unknown as [number];
+ const closeRequestRef = useRef(closeRequest);
+
+ const renameDirty = renameValue !== property.name;
+
+ const updatePropertyMutation = useUpdatePropertyMutation();
+ const deletePropertyMutation = useDeletePropertyMutation();
+
+ useEffect(() => {
+ if (opened) {
+ setPanel("main");
+ setRenameValue(property.name);
+ setOptionsDirty(false);
+ setPendingTargetType(null);
+ }
+ }, [opened, property.name]);
+
+ useEffect(() => {
+ if (panel === "rename") {
+ setTimeout(() => renameInputRef.current?.select(), 0);
+ }
+ }, [panel]);
+
+ const handleOptionsDirtyChange = useCallback((dirty: boolean) => {
+ setOptionsDirty(dirty);
+ }, []);
+
+ useEffect(() => {
+ const dirty =
+ (panel === "rename" && renameDirty) ||
+ (panel === "options" && optionsDirty);
+ onDirtyChange?.(dirty);
+ }, [panel, renameDirty, optionsDirty, onDirtyChange]);
+
+ const commitRename = useCallback(() => {
+ const trimmed = renameValue.trim();
+ if (trimmed && trimmed !== property.name) {
+ updatePropertyMutation.mutate({
+ propertyId: property.id,
+ pageId: property.pageId,
+ name: trimmed,
+ });
+ }
+ }, [renameValue, property, updatePropertyMutation]);
+
+ const handleRenameAndClose = useCallback(() => {
+ commitRename();
+ onClose();
+ }, [commitRename, onClose]);
+
+ const requestClose = useCallback(() => {
+ if (panel === "rename" && renameDirty) {
+ sourcePanelRef.current = "rename";
+ pendingActionRef.current = "close";
+ setPanel("confirmDiscard");
+ } else if (panel === "options" && optionsDirty) {
+ sourcePanelRef.current = "options";
+ pendingActionRef.current = "close";
+ setPanel("confirmDiscard");
+ } else {
+ onClose();
+ }
+ }, [panel, renameDirty, optionsDirty, onClose]);
+
+ const handleRenameKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ e.stopPropagation();
+ if (e.key === "Enter") {
+ e.preventDefault();
+ handleRenameAndClose();
+ }
+ if (e.key === "Escape") {
+ e.preventDefault();
+ requestClose();
+ }
+ },
+ [handleRenameAndClose, requestClose],
+ );
+
+ const handleOptionsUpdate = useCallback(
+ (typeOptions: Record) => {
+ updatePropertyMutation.mutate({
+ propertyId: property.id,
+ pageId: property.pageId,
+ typeOptions,
+ });
+ setOptionsDirty(false);
+ },
+ [property, updatePropertyMutation],
+ );
+
+ const handleTypeSelect = useCallback(
+ (type: BasePropertyType) => {
+ if (type === property.type) {
+ onClose();
+ return;
+ }
+ setPendingTargetType(type);
+ setPanel("confirmTypeChange");
+ },
+ [property.type, onClose],
+ );
+
+ const handleApplyTypeChange = useCallback(() => {
+ if (!pendingTargetType) return;
+ updatePropertyMutation.mutate({
+ propertyId: property.id,
+ pageId: property.pageId,
+ type: pendingTargetType,
+ typeOptions: {},
+ });
+ onClose();
+ }, [
+ pendingTargetType,
+ property.id,
+ property.pageId,
+ updatePropertyMutation,
+ onClose,
+ ]);
+
+ const handleDelete = useCallback(() => {
+ deletePropertyMutation.mutate({
+ propertyId: property.id,
+ pageId: property.pageId,
+ });
+ onClose();
+ }, [property, deletePropertyMutation, onClose]);
+
+ const handleOptionsBack = useCallback(() => {
+ if (optionsDirty) {
+ sourcePanelRef.current = "options";
+ pendingActionRef.current = "back";
+ setPanel("confirmDiscard");
+ } else {
+ setPanel("main");
+ }
+ }, [optionsDirty]);
+
+ useEffect(() => {
+ if (closeRequest !== closeRequestRef.current) {
+ closeRequestRef.current = closeRequest;
+ if (opened) {
+ requestClose();
+ }
+ }
+ }, [closeRequest, opened, requestClose]);
+
+ const handleConfirmDiscard = useCallback(() => {
+ setOptionsDirty(false);
+ setRenameValue(property.name);
+ const action = pendingActionRef.current;
+ pendingActionRef.current = null;
+ sourcePanelRef.current = null;
+ if (action === "back") {
+ setPanel("main");
+ } else {
+ onClose();
+ }
+ }, [property.name, onClose]);
+
+ const handleCancelDiscard = useCallback(() => {
+ const source = sourcePanelRef.current ?? "options";
+ pendingActionRef.current = null;
+ sourcePanelRef.current = null;
+ setPanel(source);
+ }, []);
+
+ return (
+ <>
+ {panel === "main" && (
+ setPanel("rename")}
+ onChangeType={() => setPanel("changeType")}
+ onOptions={() => setPanel("options")}
+ onDelete={() => setPanel("confirmDelete")}
+ onEditFormula={onEditFormula}
+ />
+ )}
+ {panel === "rename" && (
+
+
+ {t("Rename property")}
+
+ setRenameValue(e.currentTarget.value)}
+ onKeyDown={handleRenameKeyDown}
+ />
+
+
+
+ {t("Cancel")}
+
+
+ {t("Save")}
+
+
+
+ )}
+ {panel === "changeType" && (
+
+
+ setPanel("main")}
+ >
+
+
+
+ {t("Change type")}
+
+
+
+
+
+
+ )}
+ {panel === "confirmTypeChange" && pendingTargetType && (
+
+
+ {t("Change type to {{label}}?", {
+ label: t(
+ propertyTypes.find((pt) => pt.type === pendingTargetType)
+ ?.labelKey ?? pendingTargetType,
+ ),
+ })}
+
+
+ {t(conversionWarning(property.type, pendingTargetType))}
+
+
+ setPanel("main")}
+ >
+ {t("Cancel")}
+
+
+ {t("Apply")}
+
+
+
+ )}
+ {(panel === "options" || panel === "confirmDiscard") && (
+
+
+
+
+
+
+ {t("Property options")}
+
+
+
+
+
+
+ )}
+ {panel === "confirmDelete" && (
+
+
+ {t("Delete property")}
+
+
+ {t("Are you sure you want to delete")} {property.name} ?{" "}
+ {t("All data in this column will be lost.")}
+
+
+ setPanel("main")}
+ >
+ {t("Cancel")}
+
+
+ {t("Delete")}
+
+
+
+ )}
+ {panel === "confirmDiscard" && (
+
+
+ {t("Unsaved changes")}
+
+
+ {t("You have unsaved changes. Do you want to discard them?")}
+
+
+
+ {t("Keep editing")}
+
+
+ {t("Discard")}
+
+
+
+ )}
+ >
+ );
+}
+
+PropertyMenuContent.displayName = "PropertyMenuContent";
+
+function MenuItem({
+ icon,
+ label,
+ rightIcon,
+ color,
+ onClick,
+}: {
+ icon: React.ReactNode;
+ label: string;
+ rightIcon?: React.ReactNode;
+ color?: string;
+ onClick: () => void;
+}) {
+ return (
+
+
+ {icon}
+ {label}
+
+ {rightIcon}
+
+ );
+}
+
+function MainPanel({
+ property,
+ onRename,
+ onChangeType,
+ onOptions,
+ onDelete,
+ onEditFormula,
+}: {
+ property: IBaseProperty;
+ onRename: () => void;
+ onChangeType: () => void;
+ onOptions: () => void;
+ onDelete: () => void;
+ onEditFormula?: () => void;
+}) {
+ const { t } = useTranslation();
+
+ const isSystem = isSystemPropertyType(property.type);
+ const isPending = property.pendingType != null;
+
+ const hasOptions =
+ !isSystem &&
+ !isPending &&
+ (property.type === "select" ||
+ property.type === "multiSelect" ||
+ property.type === "status" ||
+ property.type === "number" ||
+ property.type === "date" ||
+ property.type === "text" ||
+ property.type === "longText" ||
+ property.type === "checkbox" ||
+ property.type === "url" ||
+ property.type === "email");
+
+ const typeDef = propertyTypes.find((pt) => pt.type === property.type);
+ const TypeIcon = typeDef?.icon;
+
+ return (
+
+ }
+ label={t("Rename")}
+ onClick={onRename}
+ />
+ {property.type === "formula" && !isPending && onEditFormula && (
+ }
+ label={t("Edit formula")}
+ onClick={onEditFormula}
+ />
+ )}
+ {isPending && (
+
+
+
+ {t("Converting…")}
+
+
+ )}
+ {!isSystem && !isPending && !property.isPrimary && (
+
+
+ {TypeIcon ? : null}
+
+ {typeDef ? t(typeDef.labelKey) : property.type}
+
+
+
+
+ )}
+ {hasOptions && (
+ }
+ label={t("Options")}
+ rightIcon={ }
+ onClick={onOptions}
+ />
+ )}
+ {!property.isPrimary && !isPending && (
+ <>
+
+ }
+ label={t("Delete property")}
+ color="red"
+ onClick={onDelete}
+ />
+ >
+ )}
+
+ );
+}
+
diff --git a/apps/client/src/ee/base/components/property/property-options.tsx b/apps/client/src/ee/base/components/property/property-options.tsx
new file mode 100644
index 000000000..8d7837205
--- /dev/null
+++ b/apps/client/src/ee/base/components/property/property-options.tsx
@@ -0,0 +1,596 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import {
+ Stack,
+ NumberInput,
+ Select,
+ Switch,
+ Text,
+ Button,
+ Group,
+ Divider,
+ TextInput,
+ Textarea,
+} from "@mantine/core";
+import {
+ IBaseProperty,
+ SelectTypeOptions,
+ NumberTypeOptions,
+ DateTypeOptions,
+ PersonTypeOptions,
+ Choice,
+} from "@/ee/base/types/base.types";
+import { ChoiceEditor } from "./choice-editor";
+import { FilterPersonInput } from "@/ee/base/components/views/filter-person-input";
+import {
+ CURRENCIES,
+ DEFAULT_CURRENCY_CODE,
+} from "@/ee/base/constants/currencies";
+import { useTranslation } from "react-i18next";
+
+type PropertyOptionsProps = {
+ property: IBaseProperty;
+ onUpdate: (typeOptions: Record) => void;
+ onClose: () => void;
+ onDirtyChange?: (dirty: boolean) => void;
+ hideButtons?: boolean;
+ // Portal target for nested Select dropdowns; must be inside the host popover, outside ScrollArea.
+ dropdownPortalTarget?: HTMLElement | null;
+};
+
+export function PropertyOptions({
+ property,
+ onUpdate,
+ onClose,
+ onDirtyChange,
+ hideButtons,
+ dropdownPortalTarget,
+}: PropertyOptionsProps) {
+ const { t } = useTranslation();
+
+ switch (property.type) {
+ case "select":
+ case "multiSelect":
+ return (
+
+ );
+ case "status":
+ return (
+
+ );
+ case "number":
+ return (
+
+ );
+ case "date":
+ return (
+
+ );
+ case "person":
+ return (
+
+ );
+ case "text":
+ case "longText":
+ case "url":
+ case "email":
+ return (
+
+ );
+ case "checkbox":
+ return (
+
+ );
+ default:
+ return (
+
+ {t("No options for this property type")}
+
+ );
+ }
+}
+
+type OptionEditorProps = {
+ property: IBaseProperty;
+ onUpdate: (typeOptions: Record) => void;
+ onClose: () => void;
+ onDirtyChange?: (dirty: boolean) => void;
+ hideButtons?: boolean;
+ dropdownPortalTarget?: HTMLElement | null;
+};
+
+const EMPTY_OPTIONS: Record = {};
+
+function optionsEqual(
+ a: Record,
+ b: Record,
+): boolean {
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
+ for (const key of keys) {
+ const av = a[key];
+ const bv = b[key];
+ if (Array.isArray(av) && Array.isArray(bv)) {
+ if (av.length !== bv.length || av.some((v, i) => v !== bv[i])) {
+ return false;
+ }
+ } else if (av !== bv) {
+ return false;
+ }
+ }
+ return true;
+}
+
+// Draft hook for non-choice option editors: live in create flow, staged in edit menu.
+function useEditableTypeOptions(
+ initialRaw: Record | undefined,
+ {
+ onUpdate,
+ onClose,
+ onDirtyChange,
+ hideButtons,
+ }: {
+ onUpdate: (opts: Record) => void;
+ onClose: () => void;
+ onDirtyChange?: (dirty: boolean) => void;
+ hideButtons?: boolean;
+ },
+) {
+ const initial = initialRaw ?? EMPTY_OPTIONS;
+ const [draft, setDraft] = useState>(initial);
+
+ useEffect(() => {
+ if (!hideButtons) setDraft(initial);
+ }, [initial, hideButtons]);
+
+ const onUpdateRef = useRef(onUpdate);
+ onUpdateRef.current = onUpdate;
+ useEffect(() => {
+ if (hideButtons) onUpdateRef.current(draft);
+ }, [hideButtons, draft]);
+
+ const isDirty = useMemo(() => !optionsEqual(draft, initial), [draft, initial]);
+ useEffect(() => {
+ onDirtyChange?.(isDirty);
+ }, [isDirty, onDirtyChange]);
+
+ const update = useCallback(
+ (patch: Record) =>
+ setDraft((prev) => ({ ...prev, ...patch })),
+ [],
+ );
+ const save = useCallback(() => {
+ onUpdate(draft);
+ onClose();
+ }, [draft, onUpdate, onClose]);
+ const cancel = useCallback(() => {
+ setDraft(initial);
+ onDirtyChange?.(false);
+ onClose();
+ }, [initial, onClose, onDirtyChange]);
+
+ return { draft, update, isDirty, save, cancel };
+}
+
+function OptionsFooter({
+ isDirty,
+ onCancel,
+ onSave,
+}: {
+ isDirty: boolean;
+ onCancel: () => void;
+ onSave: () => void;
+}) {
+ const { t } = useTranslation();
+ return (
+ <>
+
+
+
+ {t("Cancel")}
+
+
+ {t("Save")}
+
+
+ >
+ );
+}
+
+function SelectOptions({
+ property,
+ onUpdate,
+ onClose,
+ onDirtyChange,
+ hideButtons,
+ dropdownPortalTarget,
+}: OptionEditorProps) {
+ const options = property.typeOptions as SelectTypeOptions | undefined;
+ const choices = useMemo(() => options?.choices ?? [], [options?.choices]);
+
+ const handleSave = useCallback(
+ (newChoices: Choice[], defaultValue: string | string[] | null) => {
+ onUpdate({
+ ...property.typeOptions,
+ choices: newChoices,
+ choiceOrder: newChoices.map((c) => c.id),
+ defaultValue,
+ });
+ },
+ [property.typeOptions, onUpdate],
+ );
+
+ return (
+
+ );
+}
+
+function StatusOptions({
+ property,
+ onUpdate,
+ onClose,
+ onDirtyChange,
+ hideButtons,
+ dropdownPortalTarget,
+}: OptionEditorProps) {
+ const options = property.typeOptions as SelectTypeOptions | undefined;
+ const choices = useMemo(() => options?.choices ?? [], [options?.choices]);
+
+ const handleSave = useCallback(
+ (newChoices: Choice[], defaultValue: string | string[] | null) => {
+ onUpdate({
+ ...property.typeOptions,
+ choices: newChoices,
+ choiceOrder: newChoices.map((c) => c.id),
+ defaultValue,
+ });
+ },
+ [property.typeOptions, onUpdate],
+ );
+
+ return (
+
+ );
+}
+
+function NumberOptions({
+ property,
+ onUpdate,
+ onClose,
+ onDirtyChange,
+ hideButtons,
+ dropdownPortalTarget,
+}: OptionEditorProps) {
+ const { t } = useTranslation();
+ const { draft, update, isDirty, save, cancel } = useEditableTypeOptions(
+ property.typeOptions as Record | undefined,
+ { onUpdate, onClose, onDirtyChange, hideButtons },
+ );
+ const options = draft as NumberTypeOptions;
+
+ return (
+
+ update({ format: val ?? "plain" })}
+ />
+ {options.format === "currency" && (
+ ({
+ value: c.code,
+ label: `${c.name} (${c.code})`,
+ }))}
+ value={options.currencyCode ?? DEFAULT_CURRENCY_CODE}
+ onChange={(val) =>
+ update({ currencyCode: val ?? DEFAULT_CURRENCY_CODE })
+ }
+ />
+ )}
+ update({ precision: val })}
+ />
+
+ update({ defaultValue: typeof val === "number" ? val : undefined })
+ }
+ />
+ {!hideButtons && (
+
+ )}
+
+ );
+}
+
+function DateOptions({
+ property,
+ onUpdate,
+ onClose,
+ onDirtyChange,
+ hideButtons,
+ dropdownPortalTarget,
+}: OptionEditorProps) {
+ const { t } = useTranslation();
+ const { draft, update, isDirty, save, cancel } = useEditableTypeOptions(
+ property.typeOptions as Record | undefined,
+ { onUpdate, onClose, onDirtyChange, hideButtons },
+ );
+ const options = draft as DateTypeOptions;
+
+ return (
+
+ update({ includeTime: e.currentTarget.checked })}
+ />
+ {options.includeTime && (
+ update({ timeFormat: val ?? "12h" })}
+ />
+ )}
+ {!hideButtons && (
+
+ )}
+
+ );
+}
+
+function PersonOptions({
+ property,
+ onUpdate,
+ onClose,
+ onDirtyChange,
+ hideButtons,
+ dropdownPortalTarget,
+}: OptionEditorProps) {
+ const { t } = useTranslation();
+ const { draft, update, isDirty, save, cancel } = useEditableTypeOptions(
+ property.typeOptions as Record | undefined,
+ { onUpdate, onClose, onDirtyChange, hideButtons },
+ );
+ const options = draft as PersonTypeOptions;
+ const allowMultiple = options.allowMultiple === true;
+
+ const handleAllowMultipleChange = (toMulti: boolean) => {
+ const dv = options.defaultValue;
+ const ids = Array.isArray(dv) ? dv : dv ? [dv] : [];
+ update({
+ allowMultiple: toMulti,
+ defaultValue: toMulti ? (ids.length ? ids : undefined) : ids[0],
+ });
+ };
+
+ return (
+
+ handleAllowMultipleChange(e.currentTarget.checked)}
+ />
+
+ update({ defaultValue: value as string | string[] | undefined })
+ }
+ placeholder={t("None")}
+ label={t("Default value")}
+ w="100%"
+ portalTarget={dropdownPortalTarget}
+ />
+ {!hideButtons && (
+
+ )}
+
+ );
+}
+
+const EMAIL_FORMAT = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
+function TextDefaultOptions({
+ property,
+ onUpdate,
+ onClose,
+ onDirtyChange,
+ hideButtons,
+}: OptionEditorProps) {
+ const { t } = useTranslation();
+ const { draft, update, isDirty, save, cancel } = useEditableTypeOptions(
+ property.typeOptions as Record | undefined,
+ { onUpdate, onClose, onDirtyChange, hideButtons },
+ );
+ const defaultValue =
+ typeof draft.defaultValue === "string" ? draft.defaultValue : "";
+ const defaultValueError =
+ defaultValue && property.type === "url" && !URL.canParse(defaultValue)
+ ? t("Please enter a valid url")
+ : defaultValue &&
+ property.type === "email" &&
+ !EMAIL_FORMAT.test(defaultValue)
+ ? t("Please enter a valid email")
+ : null;
+
+ return (
+
+ {property.type === "longText" ? (
+
+ );
+}
+
+function CheckboxOptions({
+ property,
+ onUpdate,
+ onClose,
+ onDirtyChange,
+ hideButtons,
+}: OptionEditorProps) {
+ const { t } = useTranslation();
+ const { draft, update, isDirty, save, cancel } = useEditableTypeOptions(
+ property.typeOptions as Record | undefined,
+ { onUpdate, onClose, onDirtyChange, hideButtons },
+ );
+
+ return (
+
+
+ update({ defaultValue: e.currentTarget.checked ? true : undefined })
+ }
+ />
+ {!hideButtons && (
+
+ )}
+
+ );
+}
diff --git a/apps/client/src/ee/base/components/property/property-type-picker.tsx b/apps/client/src/ee/base/components/property/property-type-picker.tsx
new file mode 100644
index 000000000..cb2e650da
--- /dev/null
+++ b/apps/client/src/ee/base/components/property/property-type-picker.tsx
@@ -0,0 +1,81 @@
+import { UnstyledButton, Group, Text, TextInput } from "@mantine/core";
+import { IconCheck, IconSearch } from "@tabler/icons-react";
+import { BasePropertyType } from "@/ee/base/types/base.types";
+import {
+ PROPERTY_PICKER_ORDER,
+ getDescriptor,
+} from "@/ee/base/property-types/property-type.registry";
+import { useTranslation } from "react-i18next";
+import { useState, useRef, useEffect } from "react";
+import classes from "@/ee/base/styles/cells.module.css";
+
+const propertyTypes = PROPERTY_PICKER_ORDER.map((type) => {
+ const d = getDescriptor(type)!;
+ return { type, icon: d.icon, labelKey: d.labelKey };
+});
+
+type PropertyTypePickerProps = {
+ onSelect: (type: BasePropertyType) => void;
+ currentType?: BasePropertyType;
+ excludeTypes?: Set;
+ showSearch?: boolean;
+};
+
+export function PropertyTypePicker({
+ onSelect,
+ currentType,
+ excludeTypes,
+ showSearch,
+}: PropertyTypePickerProps) {
+ const { t } = useTranslation();
+ const [search, setSearch] = useState("");
+ const searchRef = useRef(null);
+
+ useEffect(() => {
+ if (showSearch) {
+ setTimeout(() => searchRef.current?.focus(), 0);
+ }
+ }, [showSearch]);
+
+ const types = propertyTypes
+ .filter(({ type }) => !excludeTypes?.has(type))
+ .filter(({ labelKey }) =>
+ !search || t(labelKey).toLowerCase().includes(search.toLowerCase())
+ );
+
+ return (
+ <>
+ {showSearch && (
+ }
+ value={search}
+ onChange={(e) => setSearch(e.currentTarget.value)}
+ mx="sm"
+ mt="sm"
+ mb={4}
+ />
+ )}
+ {types.map(({ type, icon: Icon, labelKey }) => (
+ onSelect(type)}
+ style={{
+ fontWeight: type === currentType ? 600 : 400,
+ }}
+ >
+
+
+ {t(labelKey)}
+
+ {type === currentType && }
+
+ ))}
+ >
+ );
+}
+
+export { propertyTypes };
diff --git a/apps/client/src/ee/base/components/row-detail-modal/fields/detail-field.tsx b/apps/client/src/ee/base/components/row-detail-modal/fields/detail-field.tsx
new file mode 100644
index 000000000..c3e8d8e60
--- /dev/null
+++ b/apps/client/src/ee/base/components/row-detail-modal/fields/detail-field.tsx
@@ -0,0 +1,142 @@
+import { forwardRef } from "react";
+import { Checkbox } from "@mantine/core";
+import { IconLock } from "@tabler/icons-react";
+import clsx from "clsx";
+import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
+import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
+import { FieldText } from "./field-text";
+import { FieldLongText } from "./field-long-text";
+import { FieldNumber } from "./field-number";
+import { FieldDate } from "./field-date";
+import { FieldChoice } from "./field-choice";
+import { FieldCellAdapter } from "./field-cell-adapter";
+import classes from "@/ee/base/styles/row-detail-modal.module.css";
+
+export type FieldProps = {
+ property: IBaseProperty;
+ value: unknown;
+ rowId: string;
+ readOnly: boolean;
+ onChange: (value: unknown) => void;
+};
+
+type FieldShellProps = {
+ /** Visual + cursor treatment: text caret, pointer (opens a picker), or none. */
+ cursor?: "text" | "pointer" | "default";
+ /** Popover open — keeps the focus ring while focus is in the portal. */
+ active?: boolean;
+ locked?: boolean;
+ alignTop?: boolean;
+ children?: React.ReactNode;
+} & React.HTMLAttributes;
+
+// forwardRef is load-bearing: Popover.Target anchors its dropdown through a
+// ref injected into this element; without it the picker renders at (0,0).
+export const FieldShell = forwardRef(
+ function FieldShell(
+ { cursor = "default", active, locked, alignTop, className, children, ...rest },
+ ref,
+ ) {
+ return (
+
+ {locked && }
+ {children}
+
+ );
+ },
+);
+
+function FieldCheckbox({ value, readOnly, onChange }: FieldProps) {
+ const checked = value === true;
+ return (
+
+ onChange(!checked)}
+ />
+
+ );
+}
+
+function FieldReadonlyCell({ property, value, rowId }: FieldProps) {
+ const CellComponent = getDescriptor(property.type)?.cellComponent;
+ return (
+
+
+ {CellComponent && (
+ {}}
+ onValueChange={() => {}}
+ onCancel={() => {}}
+ />
+ )}
+
+
+ );
+}
+
+type DetailFieldProps = {
+ property: IBaseProperty;
+ row: IBaseRow;
+ readOnly: boolean;
+ onUpdate: (propertyId: string, value: unknown) => void;
+};
+
+export function DetailField({ property, row, readOnly, onUpdate }: DetailFieldProps) {
+ const descriptor = getDescriptor(property.type);
+ const value = descriptor?.systemAccessor
+ ? descriptor.systemAccessor(row)
+ : (row.cells ?? {})[property.id];
+ const fieldProps: FieldProps = {
+ property,
+ value,
+ rowId: row.id,
+ readOnly,
+ onChange: (next: unknown) => onUpdate(property.id, next),
+ };
+
+ switch (property.type) {
+ case "text":
+ case "url":
+ case "email":
+ return ;
+ case "longText":
+ return ;
+ case "number":
+ return ;
+ case "checkbox":
+ return ;
+ case "date":
+ return ;
+ case "select":
+ case "status":
+ case "multiSelect":
+ return ;
+ case "person":
+ case "file":
+ case "page":
+ return ;
+ default:
+ // createdAt, lastEditedAt, lastEditedBy, formula and future types.
+ return ;
+ }
+}
diff --git a/apps/client/src/ee/base/components/row-detail-modal/fields/field-cell-adapter.tsx b/apps/client/src/ee/base/components/row-detail-modal/fields/field-cell-adapter.tsx
new file mode 100644
index 000000000..d5b12e886
--- /dev/null
+++ b/apps/client/src/ee/base/components/row-detail-modal/fields/field-cell-adapter.tsx
@@ -0,0 +1,80 @@
+import { useCallback, useRef, useState } from "react";
+import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
+import { FieldProps, FieldShell } from "./detail-field";
+import classes from "@/ee/base/styles/row-detail-modal.module.css";
+
+/** Person, file and page editors are popover pickers owned by their cell
+ * components; the shell supplies modal styling and click-anywhere
+ * activation while the cell keeps its picker behavior. */
+export function FieldCellAdapter({
+ property,
+ value,
+ rowId,
+ readOnly,
+ onChange,
+}: FieldProps) {
+ const [editing, setEditing] = useState(false);
+ // Whether the picker was open when the current gesture's mousedown fired.
+ const editingAtMouseDownRef = useRef(false);
+ const CellComponent = getDescriptor(property.type)?.cellComponent;
+
+ // Files stay openable read-only (download-only popover), matching the grid.
+ const canActivate = !readOnly || property.type === "file";
+
+ // Activate on click, not mousedown: opening on mousedown mounts the cell's
+ // picker mid-dispatch, and its document-level outside-mousedown listener
+ // then catches the same still-bubbling event and instantly closes it. By
+ // click time the mousedown has fully finished. The ref keeps toggle-close
+ // working: when the gesture started with the picker open, the picker's own
+ // outside-close already handled it and we must not reopen.
+ const handleMouseDown = useCallback(() => {
+ editingAtMouseDownRef.current = editing;
+ }, [editing]);
+
+ const handleClick = useCallback(() => {
+ if (!canActivate || editingAtMouseDownRef.current || editing) return;
+ setEditing(true);
+ }, [canActivate, editing]);
+
+ const handleCommit = useCallback(
+ (next: unknown) => {
+ setEditing(false);
+ onChange(next);
+ },
+ [onChange],
+ );
+ const handleCancel = useCallback(() => setEditing(false), []);
+
+ if (!CellComponent) return ;
+
+ return (
+ {
+ if (canActivate && !editing && (e.key === "Enter" || e.key === " ")) {
+ e.preventDefault();
+ setEditing(true);
+ }
+ }}
+ >
+
+
+
+
+ );
+}
diff --git a/apps/client/src/ee/base/components/row-detail-modal/fields/field-choice.tsx b/apps/client/src/ee/base/components/row-detail-modal/fields/field-choice.tsx
new file mode 100644
index 000000000..d82112b27
--- /dev/null
+++ b/apps/client/src/ee/base/components/row-detail-modal/fields/field-choice.tsx
@@ -0,0 +1,103 @@
+import { useCallback, useState } from "react";
+import { Popover } from "@mantine/core";
+import { Choice, SelectTypeOptions } from "@/ee/base/types/base.types";
+import { choiceColor } from "@/ee/base/components/cells/choice-color";
+import { ChoicePicker } from "@/ee/base/components/cells/choice-picker";
+import { FieldProps, FieldShell } from "./detail-field";
+import classes from "@/ee/base/styles/row-detail-modal.module.css";
+import cellClasses from "@/ee/base/styles/cells.module.css";
+
+export function FieldChoice({ property, value, readOnly, onChange }: FieldProps) {
+ const [opened, setOpened] = useState(false);
+ const multiple = property.type === "multiSelect";
+ const choices =
+ (property.typeOptions as SelectTypeOptions | undefined)?.choices ?? [];
+
+ const selectedIds = multiple
+ ? Array.isArray(value)
+ ? (value as string[])
+ : []
+ : typeof value === "string"
+ ? [value]
+ : [];
+ const selectedChoices = choices.filter((c) => selectedIds.includes(c.id));
+
+ const handleToggle = useCallback(
+ (choice: Choice) => {
+ if (multiple) {
+ const next = selectedIds.includes(choice.id)
+ ? selectedIds.filter((id) => id !== choice.id)
+ : [...selectedIds, choice.id];
+ onChange(next.length > 0 ? next : null);
+ } else {
+ onChange(choice.id === selectedIds[0] ? null : choice.id);
+ setOpened(false);
+ }
+ },
+ [multiple, selectedIds, onChange],
+ );
+
+ const chips = selectedChoices.map((choice) => (
+
+ {choice.name}
+
+ ));
+
+ if (readOnly) {
+ return (
+
+ {chips}
+
+ );
+ }
+
+ return (
+
+
+ setOpened((o) => !o)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ setOpened((o) => !o);
+ }
+ }}
+ >
+ {chips}
+
+
+
+ {opened && (
+ setOpened(false)}
+ />
+ )}
+
+
+ );
+}
diff --git a/apps/client/src/ee/base/components/row-detail-modal/fields/field-date.tsx b/apps/client/src/ee/base/components/row-detail-modal/fields/field-date.tsx
new file mode 100644
index 000000000..7c3f0f0d5
--- /dev/null
+++ b/apps/client/src/ee/base/components/row-detail-modal/fields/field-date.tsx
@@ -0,0 +1,75 @@
+import { useState } from "react";
+import { Popover } from "@mantine/core";
+import { DatePicker } from "@mantine/dates";
+import { DateTypeOptions } from "@/ee/base/types/base.types";
+import { formatDateDisplay } from "@/ee/base/components/cells/cell-date";
+import { FieldProps, FieldShell } from "./detail-field";
+import classes from "@/ee/base/styles/row-detail-modal.module.css";
+
+function toISODateString(dateStr: string | null): string | null {
+ if (!dateStr) return null;
+ const date = new Date(dateStr);
+ if (isNaN(date.getTime())) return null;
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, "0");
+ const day = String(date.getDate()).padStart(2, "0");
+ return `${year}-${month}-${day}`;
+}
+
+export function FieldDate({ property, value, readOnly, onChange }: FieldProps) {
+ const [opened, setOpened] = useState(false);
+ const typeOptions = property.typeOptions as DateTypeOptions | undefined;
+ const dateStr = typeof value === "string" ? value : null;
+ const display = formatDateDisplay(dateStr, typeOptions);
+
+ if (readOnly) {
+ return (
+
+ {display}
+
+ );
+ }
+
+ return (
+
+
+ setOpened((o) => !o)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ setOpened((o) => !o);
+ }
+ }}
+ >
+ {display}
+
+
+
+ {
+ onChange(selected ? new Date(selected).toISOString() : null);
+ setOpened(false);
+ }}
+ size="sm"
+ />
+
+
+ );
+}
diff --git a/apps/client/src/ee/base/components/row-detail-modal/fields/field-long-text.tsx b/apps/client/src/ee/base/components/row-detail-modal/fields/field-long-text.tsx
new file mode 100644
index 000000000..89501ed65
--- /dev/null
+++ b/apps/client/src/ee/base/components/row-detail-modal/fields/field-long-text.tsx
@@ -0,0 +1,69 @@
+import { useEffect, useRef, useState } from "react";
+import { Textarea } from "@mantine/core";
+import { FieldProps, FieldShell } from "./detail-field";
+import classes from "@/ee/base/styles/row-detail-modal.module.css";
+
+const toText = (value: unknown) => (typeof value === "string" ? value : "");
+const normalize = (s: string) => {
+ const trimmed = s.trim();
+ return trimmed.length ? trimmed : null;
+};
+
+export function FieldLongText({ property, value, readOnly, onChange }: FieldProps) {
+ const text = toText(value);
+ const [draft, setDraft] = useState(text);
+ const [focused, setFocused] = useState(false);
+ // Esc sets this; blur() then runs commit synchronously with the stale
+ // draft, so the revert must be decided here, not via setDraft.
+ const cancelRef = useRef(false);
+
+ useEffect(() => {
+ if (!focused) setDraft(text);
+ }, [text, focused]);
+
+ const commit = () => {
+ setFocused(false);
+ if (cancelRef.current) {
+ cancelRef.current = false;
+ setDraft(text);
+ return;
+ }
+ if (normalize(draft) !== normalize(text)) onChange(normalize(draft));
+ };
+
+ if (readOnly) {
+ return (
+
+ {text}
+
+ );
+ }
+
+ return (
+
+
+ );
+}
diff --git a/apps/client/src/ee/base/components/row-detail-modal/fields/field-number.tsx b/apps/client/src/ee/base/components/row-detail-modal/fields/field-number.tsx
new file mode 100644
index 000000000..73922acdc
--- /dev/null
+++ b/apps/client/src/ee/base/components/row-detail-modal/fields/field-number.tsx
@@ -0,0 +1,79 @@
+import { useEffect, useRef, useState } from "react";
+import { NumberTypeOptions } from "@/ee/base/types/base.types";
+import { formatNumber } from "@/ee/base/components/cells/cell-number";
+import { FieldProps, FieldShell } from "./detail-field";
+import classes from "@/ee/base/styles/row-detail-modal.module.css";
+
+const toDraft = (value: unknown) =>
+ typeof value === "number" ? String(value) : "";
+
+const parse = (draft: string) => {
+ const parsed = draft === "" ? null : Number(draft);
+ return parsed != null && isNaN(parsed) ? null : parsed;
+};
+
+export function FieldNumber({ property, value, readOnly, onChange }: FieldProps) {
+ const typeOptions = property.typeOptions as NumberTypeOptions | undefined;
+ const numValue = typeof value === "number" ? value : null;
+ const [draft, setDraft] = useState(toDraft(value));
+ const [focused, setFocused] = useState(false);
+ // Esc sets this; blur() then runs commit synchronously with the stale
+ // draft, so the revert must be decided here, not via setDraft.
+ const cancelRef = useRef(false);
+
+ useEffect(() => {
+ if (!focused) setDraft(toDraft(value));
+ }, [value, focused]);
+
+ const formatted = formatNumber(numValue, typeOptions);
+
+ if (readOnly) {
+ return (
+
+ {formatted}
+
+ );
+ }
+
+ const commit = () => {
+ setFocused(false);
+ if (cancelRef.current) {
+ cancelRef.current = false;
+ setDraft(toDraft(value));
+ return;
+ }
+ if (parse(draft) !== numValue) onChange(parse(draft));
+ };
+
+ return (
+
+ {
+ setDraft(toDraft(value));
+ setFocused(true);
+ }}
+ onChange={(e) => {
+ const v = e.target.value;
+ if (v === "" || v === "-" || /^-?\d*\.?\d*$/.test(v)) {
+ setDraft(v);
+ }
+ }}
+ onBlur={commit}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ e.currentTarget.blur();
+ } else if (e.key === "Escape") {
+ cancelRef.current = true;
+ e.currentTarget.blur();
+ }
+ }}
+ aria-label={property.name}
+ />
+
+ );
+}
diff --git a/apps/client/src/ee/base/components/row-detail-modal/fields/field-text.tsx b/apps/client/src/ee/base/components/row-detail-modal/fields/field-text.tsx
new file mode 100644
index 000000000..e211563a9
--- /dev/null
+++ b/apps/client/src/ee/base/components/row-detail-modal/fields/field-text.tsx
@@ -0,0 +1,89 @@
+import { useEffect, useRef, useState } from "react";
+import { IconExternalLink, IconMail } from "@tabler/icons-react";
+import { FieldProps, FieldShell } from "./detail-field";
+import classes from "@/ee/base/styles/row-detail-modal.module.css";
+
+const toText = (value: unknown) => (typeof value === "string" ? value : "");
+
+export function FieldText({ property, value, readOnly, onChange }: FieldProps) {
+ const text = toText(value);
+ const [draft, setDraft] = useState(text);
+ const [focused, setFocused] = useState(false);
+ // Esc sets this; blur() then runs commit synchronously with the stale
+ // draft, so the revert must be decided here, not via setDraft.
+ const cancelRef = useRef(false);
+
+ // Track remote/navigation updates while not typing.
+ useEffect(() => {
+ if (!focused) setDraft(text);
+ }, [text, focused]);
+
+ const commit = () => {
+ setFocused(false);
+ if (cancelRef.current) {
+ cancelRef.current = false;
+ setDraft(text);
+ return;
+ }
+ if (draft !== text) onChange(draft);
+ };
+
+ if (readOnly) {
+ return (
+
+ {text}
+
+ );
+ }
+
+ const linkHref =
+ !focused && text
+ ? property.type === "email"
+ ? text.includes("@")
+ ? `mailto:${text}`
+ : null
+ : property.type === "url" && /^https?:\/\//i.test(text)
+ ? text
+ : null
+ : null;
+
+ return (
+
+ setFocused(true)}
+ onChange={(e) => setDraft(e.currentTarget.value)}
+ onBlur={commit}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ e.currentTarget.blur();
+ } else if (e.key === "Escape") {
+ cancelRef.current = true;
+ e.currentTarget.blur();
+ }
+ }}
+ aria-label={property.name}
+ />
+ {linkHref && (
+ e.stopPropagation()}
+ aria-label={property.type === "email" ? `Email ${text}` : `Open ${text}`}
+ >
+ {property.type === "email" ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/apps/client/src/ee/base/components/row-detail-modal/property-row.tsx b/apps/client/src/ee/base/components/row-detail-modal/property-row.tsx
new file mode 100644
index 000000000..d5a6a723e
--- /dev/null
+++ b/apps/client/src/ee/base/components/row-detail-modal/property-row.tsx
@@ -0,0 +1,117 @@
+import { useCallback, useEffect, useRef } from "react";
+import clsx from "clsx";
+import { Popover } from "@mantine/core";
+import { IconChevronDown } from "@tabler/icons-react";
+import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
+import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
+import { PropertyMenuContent } from "@/ee/base/components/property/property-menu";
+import { useBaseEditable } from "@/ee/base/context/base-editable";
+import { DetailField } from "./fields/detail-field";
+import classes from "@/ee/base/styles/row-detail-modal.module.css";
+
+type PropertyRowProps = {
+ property: IBaseProperty;
+ row: IBaseRow;
+ pageId: string;
+ menuOpened: boolean;
+ onMenuOpenChange: (opened: boolean) => void;
+ onMenuDirtyChange: (dirty: boolean) => void;
+ onUpdate: (propertyId: string, value: unknown) => void;
+ autoFocusValue?: boolean;
+ onAutoFocused?: () => void;
+};
+
+export function PropertyRow({
+ property,
+ row,
+ pageId,
+ menuOpened,
+ onMenuOpenChange,
+ onMenuDirtyChange,
+ onUpdate,
+ autoFocusValue,
+ onAutoFocused,
+}: PropertyRowProps) {
+ const canEdit = useBaseEditable();
+ const rowRef = useRef(null);
+ const focusedRef = useRef(false);
+
+ useEffect(() => {
+ if (!autoFocusValue || focusedRef.current) return;
+ focusedRef.current = true;
+ const el = rowRef.current;
+ if (el) {
+ el.scrollIntoView({ block: "nearest" });
+ el.querySelector("input, textarea")?.focus();
+ }
+ onAutoFocused?.();
+ }, [autoFocusValue, onAutoFocused]);
+
+ const handleLabelClick = useCallback(() => {
+ onMenuOpenChange(!menuOpened);
+ }, [menuOpened, onMenuOpenChange]);
+
+ const handleMenuClose = useCallback(() => {
+ onMenuOpenChange(false);
+ }, [onMenuOpenChange]);
+
+ const Icon = getDescriptor(property.type)?.icon;
+
+ const label = (
+ <>
+ {Icon && }
+ {property.name}
+ >
+ );
+
+ return (
+
+ {canEdit ? (
+
+
+
+ {label}
+
+
+
+ e.stopPropagation()}
+ onKeyDown={(e) => e.stopPropagation()}
+ >
+
+
+
+ ) : (
+
{label}
+ )}
+
+
+ );
+}
diff --git a/apps/client/src/ee/base/components/row-detail-modal/row-detail-modal.tsx b/apps/client/src/ee/base/components/row-detail-modal/row-detail-modal.tsx
new file mode 100644
index 000000000..32a8d8440
--- /dev/null
+++ b/apps/client/src/ee/base/components/row-detail-modal/row-detail-modal.tsx
@@ -0,0 +1,438 @@
+import { Menu, Modal, Skeleton, Text, Tooltip } from "@mantine/core";
+import { useWindowEvent } from "@mantine/hooks";
+import { notifications } from "@mantine/notifications";
+import { modals } from "@mantine/modals";
+import {
+ IconChevronDown,
+ IconChevronUp,
+ IconDotsVertical,
+ IconLink,
+ IconLock,
+ IconPlus,
+ IconTrash,
+ IconX,
+} from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import { useAtom } from "jotai";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { IBase, IBaseRow } from "@/ee/base/types/base.types";
+import {
+ useBaseRowQuery,
+ useDeleteRowMutation,
+ useUpdateRowMutation,
+} from "@/ee/base/queries/base-row-query";
+import { propertyMenuCloseRequestAtomFamily } from "@/ee/base/atoms/base-atoms";
+import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
+import { useBaseEditable } from "@/ee/base/context/base-editable";
+import { useClipboard } from "@/hooks/use-clipboard";
+import { CreatePropertyPopover } from "@/ee/base/components/property/create-property-popover";
+import { RowDetailTitle } from "./row-detail-title";
+import { PropertyRow } from "./property-row";
+import classes from "@/ee/base/styles/row-detail-modal.module.css";
+
+type RowDetailModalProps = {
+ base: IBase;
+ rows: IBaseRow[];
+ openRowId: string | null;
+ onClose: () => void;
+ onNavigate: (rowId: string) => void;
+};
+
+export function RowDetailModal({
+ base,
+ rows,
+ openRowId,
+ onClose,
+ onNavigate,
+}: RowDetailModalProps) {
+ const { t } = useTranslation();
+ const canEdit = useBaseEditable();
+ const updateRowMutation = useUpdateRowMutation();
+ const deleteRowMutation = useDeleteRowMutation();
+ const clipboard = useClipboard({ timeout: 500 });
+
+ const rowIndex = useMemo(
+ () => (openRowId ? rows.findIndex((r) => r.id === openRowId) : -1),
+ [openRowId, rows],
+ );
+ const rowFromList = rowIndex >= 0 ? rows[rowIndex] : undefined;
+ // Deep links (?row=) can target rows outside the loaded pages or filtered
+ // out of the active view — fetch by id instead of closing. Close only
+ // when the server confirms the row is gone.
+ const rowQuery = useBaseRowQuery(base.id, openRowId ?? undefined, {
+ enabled: !!openRowId && !rowFromList,
+ });
+ const row = rowFromList ?? rowQuery.data;
+ const primaryProperty = useMemo(
+ () => base.properties.find((p) => p.isPrimary),
+ [base.properties],
+ );
+
+ const rowMissing = !!openRowId && !rowFromList && rowQuery.isError;
+ useEffect(() => {
+ if (rowMissing) onClose();
+ }, [rowMissing, onClose]);
+
+ const isSaving = updateRowMutation.isPending;
+ const opened = !!openRowId;
+
+ // One field menu open at a time, mirroring the grid header's semantics.
+ // The shared closeRequest atom asks an open dirty PropertyMenuContent to
+ // run its discard-confirm flow instead of being torn down mid-edit.
+ const [openMenuId, setOpenMenuId] = useState(null);
+ const [newPropertyId, setNewPropertyId] = useState(null);
+ const clearNewProperty = useCallback(() => setNewPropertyId(null), []);
+ const menuDirtyRef = useRef(false);
+ const [closeRequest, setCloseRequest] = useAtom(
+ propertyMenuCloseRequestAtomFamily(base.id),
+ ) as unknown as [number, (val: number) => void];
+
+ useEffect(() => {
+ setOpenMenuId(null);
+ menuDirtyRef.current = false;
+ }, [openRowId]);
+
+ const handleMenuDirtyChange = useCallback((dirty: boolean) => {
+ menuDirtyRef.current = dirty;
+ }, []);
+
+ const requestMenuClose = useCallback(() => {
+ if (menuDirtyRef.current) {
+ setCloseRequest(closeRequest + 1);
+ } else {
+ setOpenMenuId(null);
+ }
+ }, [closeRequest, setCloseRequest]);
+
+ const handleMenuOpenChange = useCallback(
+ (propertyId: string, nextOpened: boolean) => {
+ if (!nextOpened) {
+ setOpenMenuId(null);
+ menuDirtyRef.current = false;
+ return;
+ }
+ if (openMenuId && openMenuId !== propertyId && menuDirtyRef.current) {
+ setCloseRequest(closeRequest + 1);
+ return;
+ }
+ setOpenMenuId(propertyId);
+ },
+ [openMenuId, closeRequest, setCloseRequest],
+ );
+
+ useEffect(() => {
+ if (!openMenuId) return;
+ const handler = (e: MouseEvent) => {
+ const target = e.target as HTMLElement;
+ if (target.closest("[data-position]")) return;
+ if (target.closest("[data-property-menu-target]")) return;
+ requestMenuClose();
+ };
+ document.addEventListener("mousedown", handler);
+ return () => document.removeEventListener("mousedown", handler);
+ }, [openMenuId, requestMenuClose]);
+
+ const hasPrev = rowIndex > 0;
+ const hasNext = rowIndex >= 0 && rowIndex < rows.length - 1;
+ const navigate = useCallback(
+ (delta: number) => {
+ if (rowIndex === -1) return;
+ const next = rows[rowIndex + delta];
+ if (next) onNavigate(next.id);
+ },
+ [rows, rowIndex, onNavigate],
+ );
+
+ const handleCopyLink = useCallback(() => {
+ clipboard.copy(window.location.href);
+ notifications.show({ message: t("Link copied") });
+ }, [clipboard, t]);
+
+ const handleDeleteRecord = useCallback(() => {
+ if (!row) return;
+ const rowId = row.id;
+ modals.openConfirmModal({
+ title: t("Delete record?"),
+ centered: true,
+ children: {t("This action cannot be undone.")} ,
+ labels: { confirm: t("Delete"), cancel: t("Cancel") },
+ confirmProps: { color: "red" },
+ onConfirm: () => {
+ deleteRowMutation.mutate({ rowId, pageId: base.id });
+ onClose();
+ },
+ });
+ }, [row, base.id, deleteRowMutation, onClose, t]);
+
+ // Mantine's closeOnEscape runs a capture-phase window listener that fires
+ // before inner popovers and inputs see the key, so we manage Esc ourselves
+ // and yield to: nested dialogs (delete confirm), open popovers
+ // ([data-position]) and editable elements. Arrows step records under the
+ // same yield rules. Mantine puts role="dialog" and our content class on
+ // the same element, which distinguishes this modal from nested ones.
+ const handleKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ const isEscape = event.key === "Escape";
+ const isArrow = event.key === "ArrowUp" || event.key === "ArrowDown";
+ if ((!isEscape && !isArrow) || event.isComposing || !opened) return;
+ const target = event.target as HTMLElement | null;
+ if (target) {
+ const dialog = target.closest('[role="dialog"]');
+ if (dialog && !dialog.classList.contains(classes.modalContent)) {
+ return;
+ }
+ if (
+ target.closest("[data-position]") ||
+ target.matches("input, textarea, select, [contenteditable='true']")
+ ) {
+ return;
+ }
+ }
+ if (isEscape) {
+ if (openMenuId) {
+ requestMenuClose();
+ return;
+ }
+ onClose();
+ return;
+ }
+ if (openMenuId) return;
+ event.preventDefault();
+ navigate(event.key === "ArrowUp" ? -1 : 1);
+ },
+ [opened, openMenuId, requestMenuClose, onClose, navigate],
+ );
+ useWindowEvent("keydown", handleKeyDown, { capture: true });
+
+ return (
+
+ {row ? (
+ <>
+
+
+
+ navigate(-1)}
+ disabled={!hasPrev}
+ aria-label={t("Previous record")}
+ >
+
+
+
+
+ navigate(1)}
+ disabled={!hasNext}
+ aria-label={t("Next record")}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ onClick={handleCopyLink}
+ >
+ {t("Copy link")}
+
+ {canEdit && (
+ <>
+
+ }
+ onClick={handleDeleteRecord}
+ >
+ {t("Delete record")}
+
+ >
+ )}
+
+
+
+
+
+
+
+
+ {
+ if (!primaryProperty) return;
+ updateRowMutation.mutate({
+ rowId: row.id,
+ pageId: base.id,
+ cells: { [primaryProperty.id]: value },
+ });
+ }}
+ />
+
+
+
+ {base.properties
+ .filter((p) => !p.isPrimary)
+ .map((property) => (
+
+ handleMenuOpenChange(property.id, nextOpened)
+ }
+ onMenuDirtyChange={handleMenuDirtyChange}
+ onUpdate={(propertyId, value) => {
+ updateRowMutation.mutate({
+ rowId: row.id,
+ pageId: base.id,
+ cells: { [propertyId]: value },
+ });
+ }}
+ />
+ ))}
+
+ {canEdit && (
+
setNewPropertyId(p.id)}
+ renderTarget={(open) => (
+
+
+
+ {t("Add property")}
+
+
+ )}
+ />
+ )}
+
+
+
+
+ {!canEdit ? (
+
+
+ {t("Read-only")}
+
+ ) : isSaving ? (
+ <>
+
+ {t("Saving…")}
+ >
+ ) : null}
+
+
+ {rowIndex >= 0 && rows.length > 1 && (
+ <>
+ ↑
+ ↓
+ {t("to navigate")}
+
+ >
+ )}
+ Esc
+ {t("to close")}
+
+
+ >
+ ) : (
+
+ )}
+
+ );
+}
+
+/** Hydration state for deep-linked rows: the schema is already loaded, so
+ * render the real labels and shimmer only the unknown values. Matching the
+ * final layout avoids a size jump when the row arrives. */
+function RowDetailSkeleton({ base }: { base: IBase }) {
+ return (
+ <>
+
+
+
+
+ {base.properties
+ .filter((p) => !p.isPrimary)
+ .map((property) => {
+ const Icon = getDescriptor(property.type)?.icon;
+ return (
+
+
+ {Icon && (
+
+ )}
+
+ {property.name}
+
+
+
+
+ );
+ })}
+
+
+ >
+ );
+}
diff --git a/apps/client/src/ee/base/components/row-detail-modal/row-detail-title.tsx b/apps/client/src/ee/base/components/row-detail-modal/row-detail-title.tsx
new file mode 100644
index 000000000..71d8402bc
--- /dev/null
+++ b/apps/client/src/ee/base/components/row-detail-modal/row-detail-title.tsx
@@ -0,0 +1,73 @@
+import { useEffect, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
+import { timeAgo } from "@/lib/time.ts";
+import classes from "@/ee/base/styles/row-detail-modal.module.css";
+
+type RowDetailTitleProps = {
+ row: IBaseRow;
+ primaryProperty: IBaseProperty | undefined;
+ canEdit: boolean;
+ onCommit: (value: string) => void;
+};
+
+export function RowDetailTitle({
+ row,
+ primaryProperty,
+ canEdit,
+ onCommit,
+}: RowDetailTitleProps) {
+ const { t } = useTranslation();
+ const initial = primaryProperty
+ ? (((row.cells ?? {})[primaryProperty.id] as string) ?? "")
+ : "";
+ const [value, setValue] = useState(initial);
+ const inputRef = useRef(null);
+ const didAutofocusRef = useRef(false);
+
+ // Re-sync when the row changes underneath us (navigation or remote edit).
+ useEffect(() => {
+ setValue(initial);
+ }, [initial]);
+
+ useEffect(() => {
+ if (didAutofocusRef.current || !canEdit || initial) return;
+ didAutofocusRef.current = true;
+ inputRef.current?.focus();
+ }, [canEdit, initial]);
+
+ const updatedAgo = row.updatedAt ? timeAgo(new Date(row.updatedAt)) : "";
+
+ return (
+
+ );
+}
diff --git a/apps/client/src/ee/base/components/views/filter-date-input.tsx b/apps/client/src/ee/base/components/views/filter-date-input.tsx
new file mode 100644
index 000000000..8e9ce3e01
--- /dev/null
+++ b/apps/client/src/ee/base/components/views/filter-date-input.tsx
@@ -0,0 +1,201 @@
+import { useState } from "react";
+import {
+ Popover,
+ InputBase,
+ Input,
+ SegmentedControl,
+} from "@mantine/core";
+import { DatePicker } from "@mantine/dates";
+import { IconChevronDown } from "@tabler/icons-react";
+import clsx from "clsx";
+import { useTranslation } from "react-i18next";
+import type {
+ DateFilterValue,
+ FilterOperator,
+} from "@/ee/base/types/base.types";
+import {
+ DATE_ANCHOR_PRESETS,
+ DATE_RANGE_PRESETS,
+ ANCHOR_VALUES,
+ RANGE_VALUES,
+} from "./relative-date-presets";
+import cellClasses from "@/ee/base/styles/cells.module.css";
+
+type FilterDateInputProps = {
+ op: FilterOperator;
+ value: unknown;
+ onChange: (value: unknown) => void;
+};
+
+type Mode = "exact" | "relative";
+
+const ANCHOR_LABEL: Record = Object.fromEntries(
+ DATE_ANCHOR_PRESETS.map((p) => [p.value, p.labelKey]),
+);
+const RANGE_LABEL: Record = Object.fromEntries(
+ DATE_RANGE_PRESETS.map((p) => [p.value, p.labelKey]),
+);
+
+function asDateValue(value: unknown): DateFilterValue | null {
+ if (!value || typeof value !== "object") return null;
+ return value as DateFilterValue;
+}
+
+function toISODate(d: string | null): string | null {
+ if (!d) return null;
+ // Already a date-only ISO string (Mantine v8 emits these) — pass through to
+ // avoid a UTC-parse + local-getter round-trip that shifts the day west of UTC.
+ if (/^\d{4}-\d{2}-\d{2}$/.test(d)) return d;
+ const date = new Date(d);
+ if (isNaN(date.getTime())) return null;
+ const y = date.getFullYear();
+ const m = String(date.getMonth() + 1).padStart(2, "0");
+ const day = String(date.getDate()).padStart(2, "0");
+ return `${y}-${m}-${day}`;
+}
+
+export function FilterDateInput({ op, value, onChange }: FilterDateInputProps) {
+ const { t } = useTranslation();
+ const current = asDateValue(value);
+
+ const [opened, setOpened] = useState(false);
+ const [localMode, setLocalMode] = useState("exact");
+
+ const exactDate = current?.mode === "exact" ? toISODate(current.date) : null;
+ const anchor =
+ current?.mode === "relative" && ANCHOR_VALUES.has(current.preset)
+ ? current.preset
+ : null;
+ const range =
+ current?.mode === "range" && RANGE_VALUES.has(current.preset)
+ ? current.preset
+ : null;
+
+ const valueMode: Mode | null =
+ current?.mode === "relative"
+ ? "relative"
+ : current?.mode === "exact"
+ ? "exact"
+ : null;
+ const mode: Mode = valueMode ?? localMode;
+
+ let triggerLabel: string | null = null;
+ if (op === "isWithin") triggerLabel = range ? t(RANGE_LABEL[range]) : null;
+ else if (exactDate) triggerLabel = exactDate;
+ else if (anchor) triggerLabel = t(ANCHOR_LABEL[anchor]);
+
+ // Consume Escape locally so the outer filter popover (bubble handler) keeps
+ // the panel open and only this picker closes.
+ const handleEscape = (e: React.KeyboardEvent) => {
+ if (e.key === "Escape") {
+ e.preventDefault();
+ e.stopPropagation();
+ setOpened(false);
+ }
+ };
+
+ const presetRow = (
+ selected: boolean,
+ label: string,
+ onClick: () => void,
+ key: string,
+ ) => (
+
+ {label}
+
+ );
+
+ return (
+
+
+ }
+ rightSectionPointerEvents="none"
+ onClick={() => setOpened((o) => !o)}
+ onKeyDown={handleEscape}
+ >
+ {triggerLabel ?? {t("Select")} }
+
+
+
+ {op === "isWithin" ? (
+
+ {DATE_RANGE_PRESETS.map((p) =>
+ presetRow(
+ range === p.value,
+ t(p.labelKey),
+ () => {
+ onChange({ mode: "range", preset: p.value });
+ setOpened(false);
+ },
+ p.value,
+ ),
+ )}
+
+ ) : (
+ <>
+ {
+ setLocalMode(m as Mode);
+ onChange(undefined);
+ }}
+ data={[
+ { value: "exact", label: t("Date") },
+ { value: "relative", label: t("Relative") },
+ ]}
+ />
+ {mode === "exact" ? (
+ {
+ const iso = toISODate(d);
+ onChange(iso ? { mode: "exact", date: iso } : undefined);
+ setOpened(false);
+ }}
+ size="sm"
+ />
+ ) : (
+
+ {DATE_ANCHOR_PRESETS.map((p) =>
+ presetRow(
+ anchor === p.value,
+ t(p.labelKey),
+ () => {
+ onChange({ mode: "relative", preset: p.value });
+ setOpened(false);
+ },
+ p.value,
+ ),
+ )}
+
+ )}
+ >
+ )}
+
+
+ );
+}
diff --git a/apps/client/src/ee/base/components/views/filter-person-input.tsx b/apps/client/src/ee/base/components/views/filter-person-input.tsx
new file mode 100644
index 000000000..5f06c0e55
--- /dev/null
+++ b/apps/client/src/ee/base/components/views/filter-person-input.tsx
@@ -0,0 +1,246 @@
+import { useState, useRef, useEffect, useCallback } from "react";
+import { Popover, InputBase, Input } from "@mantine/core";
+import { IconX, IconChevronDown } from "@tabler/icons-react";
+import clsx from "clsx";
+import {
+ usePersonSearch,
+ type PersonSuggestion,
+} from "@/ee/base/hooks/use-person-search";
+import {
+ useReferenceStore,
+ useHydrateUsers,
+} from "@/ee/base/reference/reference-store";
+import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav";
+import { CustomAvatar } from "@/components/ui/custom-avatar";
+import cellClasses from "@/ee/base/styles/cells.module.css";
+
+type FilterPersonInputProps = {
+ pageId: string;
+ multiple: boolean;
+ value: unknown;
+ onChange: (value: unknown) => void;
+ placeholder: string;
+ label?: string;
+ w?: number | string;
+ portalTarget?: HTMLElement | null;
+};
+
+function toIds(value: unknown): string[] {
+ if (Array.isArray(value)) return value.filter((v): v is string => !!v);
+ if (typeof value === "string" && value) return [value];
+ return [];
+}
+
+export function FilterPersonInput({
+ pageId,
+ multiple,
+ value,
+ onChange,
+ placeholder,
+ label,
+ w,
+ portalTarget,
+}: FilterPersonInputProps) {
+ const ids = toIds(value);
+ const selectedSet = new Set(ids);
+
+ const [opened, setOpened] = useState(false);
+ const [search, setSearch] = useState("");
+ const searchRef = useRef(null);
+
+ const store = useReferenceStore(pageId);
+ const hydrateUsers = useHydrateUsers(pageId);
+ const suggestions = usePersonSearch(search, opened);
+
+ useEffect(() => {
+ if (opened) requestAnimationFrame(() => searchRef.current?.focus());
+ else setSearch("");
+ }, [opened]);
+
+ const filtered: PersonSuggestion[] = multiple
+ ? suggestions.filter((s) => !selectedSet.has(s.id))
+ : suggestions;
+
+ const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
+ useListKeyboardNav(filtered.length, [search, opened]);
+
+ const emit = useCallback(
+ (nextIds: string[]) => {
+ if (multiple) onChange(nextIds.length > 0 ? nextIds : undefined);
+ else onChange(nextIds[0] ?? undefined);
+ },
+ [multiple, onChange],
+ );
+
+ const handleSelect = useCallback(
+ (id: string) => {
+ const picked = suggestions.find((s) => s.id === id);
+ if (picked)
+ hydrateUsers([
+ { id: picked.id, name: picked.name, avatarUrl: picked.avatarUrl },
+ ]);
+ if (multiple) {
+ emit(ids.includes(id) ? ids.filter((x) => x !== id) : [...ids, id]);
+ } else {
+ emit([id]);
+ setOpened(false);
+ }
+ setSearch("");
+ },
+ [suggestions, hydrateUsers, multiple, ids, emit],
+ );
+
+ const handleRemove = useCallback(
+ (id: string) => emit(ids.filter((x) => x !== id)),
+ [emit, ids],
+ );
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === "Escape") {
+ e.preventDefault();
+ e.stopPropagation();
+ setOpened(false);
+ return;
+ }
+ if (handleNavKey(e)) return;
+ if (e.key === "Enter") {
+ if (activeIndex < 0 || activeIndex >= filtered.length) return;
+ e.preventDefault();
+ handleSelect(filtered[activeIndex].id);
+ return;
+ }
+ if (e.key === "Backspace" && search === "" && ids.length > 0) {
+ e.preventDefault();
+ handleRemove(ids[ids.length - 1]);
+ }
+ },
+ [handleNavKey, activeIndex, filtered, handleSelect, search, ids, handleRemove],
+ );
+
+ return (
+
+
+ }
+ rightSectionPointerEvents="none"
+ onClick={() => setOpened((o) => !o)}
+ >
+ {ids.length === 0 ? (
+ {placeholder}
+ ) : (
+
+ {ids.map((id) => {
+ const user = store.users[id];
+ const name = user?.name ?? id.substring(0, 8);
+ return (
+
+
+
+ {name}
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+ {multiple &&
+ ids.map((id) => {
+ const user = store.users[id];
+ const name = user?.name ?? id.substring(0, 8);
+ return (
+
+
+ {name}
+ {
+ e.stopPropagation();
+ handleRemove(id);
+ }}
+ >
+
+
+
+ );
+ })}
+ setSearch(e.currentTarget.value)}
+ onKeyDown={handleKeyDown}
+ />
+
+
+
+ {filtered.map((member, idx) => (
+
setActiveIndex(idx)}
+ onClick={() => handleSelect(member.id)}
+ >
+
+
+
+ {member.name ?? ""}
+
+ {member.email && (
+
+ {member.email}
+
+ )}
+
+
+ ))}
+ {filtered.length === 0 && (
+
No users found
+ )}
+
+
+
+ );
+}
diff --git a/apps/client/src/ee/base/components/views/relative-date-presets.ts b/apps/client/src/ee/base/components/views/relative-date-presets.ts
new file mode 100644
index 000000000..5e3357511
--- /dev/null
+++ b/apps/client/src/ee/base/components/views/relative-date-presets.ts
@@ -0,0 +1,35 @@
+import type {
+ DateFilterAnchor,
+ DateFilterRange,
+} from "@/ee/base/types/base.types";
+
+export const DATE_ANCHOR_PRESETS: { value: DateFilterAnchor; labelKey: string }[] =
+ [
+ { value: "today", labelKey: "Today" },
+ { value: "tomorrow", labelKey: "Tomorrow" },
+ { value: "yesterday", labelKey: "Yesterday" },
+ { value: "oneWeekAgo", labelKey: "One week ago" },
+ { value: "oneWeekFromNow", labelKey: "One week from now" },
+ { value: "oneMonthAgo", labelKey: "One month ago" },
+ { value: "oneMonthFromNow", labelKey: "One month from now" },
+ ];
+
+export const DATE_RANGE_PRESETS: { value: DateFilterRange; labelKey: string }[] =
+ [
+ { value: "pastWeek", labelKey: "Past week" },
+ { value: "pastMonth", labelKey: "Past month" },
+ { value: "pastYear", labelKey: "Past year" },
+ { value: "thisWeek", labelKey: "This week" },
+ { value: "thisMonth", labelKey: "This month" },
+ { value: "thisYear", labelKey: "This year" },
+ { value: "nextWeek", labelKey: "Next week" },
+ { value: "nextMonth", labelKey: "Next month" },
+ { value: "nextYear", labelKey: "Next year" },
+ ];
+
+export const ANCHOR_VALUES = new Set(
+ DATE_ANCHOR_PRESETS.map((p) => p.value),
+);
+export const RANGE_VALUES = new Set(
+ DATE_RANGE_PRESETS.map((p) => p.value),
+);
diff --git a/apps/client/src/ee/base/components/views/view-create-menu.tsx b/apps/client/src/ee/base/components/views/view-create-menu.tsx
new file mode 100644
index 000000000..52387171b
--- /dev/null
+++ b/apps/client/src/ee/base/components/views/view-create-menu.tsx
@@ -0,0 +1,140 @@
+import { useState, useCallback, useEffect, useRef } from "react";
+import { useAtom } from "jotai";
+import { Menu, ActionIcon, Tooltip } from "@mantine/core";
+import { IconPlus, IconTable, IconLayoutKanban, IconArrowLeft } from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import { IBase } from "@/ee/base/types/base.types";
+import { useCreateViewMutation } from "@/ee/base/queries/base-view-query";
+import { activeViewIdAtomFamily } from "@/ee/base/atoms/base-atoms";
+import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
+
+type Panel = "types" | "groupBy";
+
+type ViewCreateMenuProps = {
+ base: IBase;
+ pageId: string;
+};
+
+export function ViewCreateMenu({ base, pageId }: ViewCreateMenuProps) {
+ const { t } = useTranslation();
+ const [opened, setOpened] = useState(false);
+ const [panel, setPanel] = useState("types");
+ const dropdownRef = useRef(null);
+ const createViewMutation = useCreateViewMutation();
+ const [, setActiveViewId] = useAtom(
+ activeViewIdAtomFamily(pageId),
+ ) as unknown as [string | null, (val: string | null) => void];
+
+ const groupable = base.properties.filter(
+ (p) => p.type === "select" || p.type === "status",
+ );
+
+ const close = useCallback(() => {
+ setOpened(false);
+ setPanel("types");
+ }, []);
+
+ const submitView = useCallback(
+ (input: { name: string; type: "table" | "kanban"; config?: Record }) => {
+ createViewMutation.mutate(
+ { pageId, ...input },
+ { onSuccess: (created) => setActiveViewId(created.id) },
+ );
+ close();
+ },
+ [pageId, createViewMutation, setActiveViewId, close],
+ );
+
+ const handleCreateTable = useCallback(() => {
+ submitView({ name: t("Table"), type: "table" });
+ }, [submitView, t]);
+
+ const handleBoardClick = useCallback(() => {
+ if (groupable.length <= 1) {
+ const config =
+ groupable.length === 1
+ ? { groupByPropertyId: groupable[0].id }
+ : undefined;
+ submitView({ name: t("Kanban"), type: "kanban", config });
+ } else {
+ setPanel("groupBy");
+ }
+ }, [groupable, submitView, t]);
+
+ const handleGroupByPick = useCallback(
+ (propertyId: string) => {
+ submitView({
+ name: t("Kanban"),
+ type: "kanban",
+ config: { groupByPropertyId: propertyId },
+ });
+ },
+ [submitView, t],
+ );
+
+ useEffect(() => {
+ const raf = requestAnimationFrame(() => {
+ dropdownRef.current
+ ?.querySelector("[data-menu-item]:not([data-disabled])")
+ ?.focus();
+ });
+ return () => cancelAnimationFrame(raf);
+ }, [panel]);
+
+ return (
+ {
+ setOpened(o);
+ if (!o) setPanel("types");
+ }}
+ position="bottom-start"
+ shadow="md"
+ width={200}
+ withinPortal
+ closeOnItemClick={false}
+ >
+
+
+
+
+
+
+
+
+
+ {panel === "types" && (
+ <>
+ } onClick={handleCreateTable}>
+ {t("Table")}
+
+ } onClick={handleBoardClick}>
+ {t("Kanban")}
+
+ >
+ )}
+
+ {panel === "groupBy" && (
+ <>
+ } onClick={() => setPanel("types")}>
+ {t("Group by")}
+
+
+ {groupable.map((p) => {
+ const Icon = getDescriptor(p.type)?.icon;
+ return (
+ : undefined}
+ onClick={() => handleGroupByPick(p.id)}
+ >
+ {p.name}
+
+ );
+ })}
+ >
+ )}
+
+
+ );
+}
diff --git a/apps/client/src/ee/base/components/views/view-filter-config.tsx b/apps/client/src/ee/base/components/views/view-filter-config.tsx
new file mode 100644
index 000000000..200f7224f
--- /dev/null
+++ b/apps/client/src/ee/base/components/views/view-filter-config.tsx
@@ -0,0 +1,497 @@
+import { useCallback, useEffect, useState } from "react";
+import {
+ Popover,
+ Stack,
+ Group,
+ Select,
+ TextInput,
+ ActionIcon,
+ Text,
+ UnstyledButton,
+ Button,
+} from "@mantine/core";
+import { IconPlus, IconTrash } from "@tabler/icons-react";
+import {
+ IBaseProperty,
+ SelectTypeOptions,
+ FilterCondition,
+ FilterOperator,
+} from "@/ee/base/types/base.types";
+import { useTranslation } from "react-i18next";
+import {
+ getDescriptor,
+ DEFAULT_FILTER_OPERATORS,
+} from "@/ee/base/property-types/property-type.registry";
+import { FilterPersonInput } from "./filter-person-input";
+import { FilterDateInput } from "./filter-date-input";
+import viewClasses from "@/ee/base/styles/views.module.css";
+
+const OPERATORS: { value: FilterOperator; labelKey: string }[] = [
+ { value: "eq", labelKey: "Is" },
+ { value: "neq", labelKey: "Is not" },
+ { value: "contains", labelKey: "Contains" },
+ { value: "ncontains", labelKey: "Doesn't contain" },
+ { value: "any", labelKey: "Is any of" },
+ { value: "none", labelKey: "Is none of" },
+ { value: "before", labelKey: "Is before" },
+ { value: "after", labelKey: "Is after" },
+ { value: "onOrBefore", labelKey: "Is on or before" },
+ { value: "onOrAfter", labelKey: "Is on or after" },
+ { value: "isWithin", labelKey: "Is within" },
+ { value: "gt", labelKey: "Greater than" },
+ { value: "lt", labelKey: "Less than" },
+ { value: "isEmpty", labelKey: "Is empty" },
+ { value: "isNotEmpty", labelKey: "Is not empty" },
+];
+
+const NO_VALUE_OPERATORS: FilterOperator[] = ["isEmpty", "isNotEmpty"];
+
+// Two operators share a value control only if they share a value class.
+// Switching across classes (e.g. eq→any, exact-date→isWithin) must reset the
+// stored value so a stale shape isn't sent to the engine.
+function valueClass(op: FilterOperator, inputKind: string): string {
+ if (NO_VALUE_OPERATORS.includes(op)) return "none";
+ if (inputKind === "person") {
+ return op === "any" || op === "none" ? "personMulti" : "personSingle";
+ }
+ if (inputKind === "date") {
+ return op === "isWithin" ? "dateRange" : "dateInstant";
+ }
+ return "scalar";
+}
+
+function inputKindForProperty(property: IBaseProperty | undefined): string {
+ return getDescriptor(property?.type ?? "")?.filterInput ?? "text";
+}
+
+function getOperatorsForType(type: string): FilterOperator[] {
+ return (getDescriptor(type)?.filterOperators ??
+ DEFAULT_FILTER_OPERATORS) as FilterOperator[];
+}
+
+function FilterValueInput({
+ condition,
+ property,
+ onChange,
+ t,
+}: {
+ condition: FilterCondition;
+ property: IBaseProperty | undefined;
+ onChange: (value: unknown) => void;
+ t: (key: string) => string;
+}) {
+ if (!property) {
+ return (
+ onChange(e.currentTarget.value)}
+ w={100}
+ />
+ );
+ }
+
+ const kind = getDescriptor(property.type)?.filterInput ?? "text";
+
+ if (kind === "person") {
+ return (
+
+ );
+ }
+
+ if (kind === "date") {
+ return (
+
+ );
+ }
+
+ if (kind === "choices") {
+ const typeOptions = property.typeOptions as SelectTypeOptions | undefined;
+ const choices = typeOptions?.choices ?? [];
+ const choiceOptions = choices.map((c) => ({ value: c.id, label: c.name }));
+ return (
+ onChange(val ?? "")}
+ w={120}
+ placeholder={t("Select")}
+ />
+ );
+ }
+
+ if (kind === "number") {
+ return (
+ onChange(e.currentTarget.value)}
+ w={100}
+ />
+ );
+ }
+
+ if (kind === "boolean") {
+ return (
+ onChange(val ?? "")}
+ w={100}
+ />
+ );
+ }
+
+ return (
+ onChange(e.currentTarget.value)}
+ w={100}
+ />
+ );
+}
+
+type ViewFilterConfigProps = {
+ opened: boolean;
+ onClose: () => void;
+ conditions: FilterCondition[];
+ properties: IBaseProperty[];
+ onChange: (conditions: FilterCondition[]) => void;
+ children: React.ReactNode;
+};
+
+export function ViewFilterConfigPopover({
+ opened,
+ onClose,
+ conditions,
+ properties,
+ onChange,
+ children,
+}: ViewFilterConfigProps) {
+ const { t } = useTranslation();
+
+ const propertyOptions = properties.map((p) => ({
+ value: p.id,
+ label: p.name,
+ }));
+
+ const [draft, setDraft] = useState(null);
+
+ useEffect(() => {
+ if (!opened) setDraft(null);
+ }, [opened]);
+
+ const handleStartDraft = useCallback(() => {
+ const firstProperty = properties[0];
+ if (!firstProperty) return;
+ const validOperators = getOperatorsForType(firstProperty.type);
+ const defaultOperator = validOperators.includes("contains")
+ ? ("contains" as FilterOperator)
+ : validOperators[0];
+ setDraft({ propertyId: firstProperty.id, op: defaultOperator });
+ }, [properties]);
+
+ const handleSaveDraft = useCallback(() => {
+ if (!draft) return;
+ onChange([...conditions, draft]);
+ setDraft(null);
+ }, [draft, conditions, onChange]);
+
+ const handleCancelDraft = useCallback(() => {
+ setDraft(null);
+ }, []);
+
+ const handleDraftPropertyChange = useCallback(
+ (propertyId: string | null) => {
+ if (!propertyId || !draft) return;
+ const newProperty = properties.find((p) => p.id === propertyId);
+ if (!newProperty) {
+ setDraft({ ...draft, propertyId });
+ return;
+ }
+ const validOperators = getOperatorsForType(newProperty.type);
+ const currentOperatorValid = validOperators.includes(draft.op);
+ const sameKind =
+ inputKindForProperty(
+ properties.find((p) => p.id === draft.propertyId),
+ ) === inputKindForProperty(newProperty);
+ setDraft({
+ ...draft,
+ propertyId,
+ op: currentOperatorValid ? draft.op : validOperators[0],
+ value: currentOperatorValid && sameKind ? draft.value : undefined,
+ });
+ },
+ [draft, properties],
+ );
+
+ const handleDraftOperatorChange = useCallback(
+ (operator: string | null) => {
+ if (!operator || !draft) return;
+ const op = operator as FilterOperator;
+ const kind = inputKindForProperty(
+ properties.find((p) => p.id === draft.propertyId),
+ );
+ const keep = valueClass(draft.op, kind) === valueClass(op, kind);
+ setDraft({ ...draft, op, value: keep ? draft.value : undefined });
+ },
+ [draft, properties],
+ );
+
+ const handleDraftValueChange = useCallback(
+ (value: unknown) => {
+ if (!draft) return;
+ setDraft({ ...draft, value });
+ },
+ [draft],
+ );
+
+ const handleRemove = useCallback(
+ (index: number) => {
+ onChange(conditions.filter((_, i) => i !== index));
+ },
+ [conditions, onChange],
+ );
+
+ const handlePropertyChange = useCallback(
+ (index: number, propertyId: string | null) => {
+ if (!propertyId) return;
+ const newProperty = properties.find((p) => p.id === propertyId);
+ onChange(
+ conditions.map((f, i) => {
+ if (i !== index) return f;
+ if (newProperty) {
+ const validOperators = getOperatorsForType(newProperty.type);
+ const currentOperatorValid = validOperators.includes(f.op);
+ const sameKind =
+ inputKindForProperty(
+ properties.find((p) => p.id === f.propertyId),
+ ) === inputKindForProperty(newProperty);
+ return {
+ ...f,
+ propertyId,
+ op: currentOperatorValid ? f.op : validOperators[0],
+ value: currentOperatorValid && sameKind ? f.value : undefined,
+ };
+ }
+ return { ...f, propertyId };
+ }),
+ );
+ },
+ [conditions, properties, onChange],
+ );
+
+ const handleOperatorChange = useCallback(
+ (index: number, operator: string | null) => {
+ if (!operator) return;
+ const op = operator as FilterOperator;
+ onChange(
+ conditions.map((f, i) => {
+ if (i !== index) return f;
+ const kind = inputKindForProperty(
+ properties.find((p) => p.id === f.propertyId),
+ );
+ const keep = valueClass(f.op, kind) === valueClass(op, kind);
+ return { ...f, op, value: keep ? f.value : undefined };
+ }),
+ );
+ },
+ [conditions, properties, onChange],
+ );
+
+ const handleValueChange = useCallback(
+ (index: number, value: unknown) => {
+ onChange(
+ conditions.map((f, i) => (i === index ? { ...f, value } : f)),
+ );
+ },
+ [conditions, onChange],
+ );
+
+ return (
+ {
+ if (!o) onClose();
+ }}
+ onClose={onClose}
+ position="bottom-end"
+ shadow="md"
+ width={520}
+ trapFocus
+ closeOnEscape={false}
+ closeOnClickOutside
+ withinPortal
+ >
+ {children}
+ {
+ // Mantine's built-in closeOnEscape uses a capture-phase handler that
+ // would fire before a nested picker can consume Escape, closing the
+ // whole panel. Handle it on bubble instead so an open inner picker
+ // (which preventDefaults Escape) keeps the panel open.
+ if (e.key === "Escape" && !e.defaultPrevented) onClose();
+ }}
+ >
+
+
+ {t("Filter by")}
+
+
+ {conditions.length === 0 && !draft && (
+
+ {t("No filters applied")}
+
+ )}
+
+ {conditions.map((condition, index) => {
+ const needsValue = !NO_VALUE_OPERATORS.includes(condition.op);
+ const property = properties.find(
+ (p) => p.id === condition.propertyId,
+ );
+ const validOperators = property
+ ? getOperatorsForType(property.type)
+ : OPERATORS.map((op) => op.value);
+ const operatorOptions = OPERATORS.filter((op) =>
+ validOperators.includes(op.value),
+ ).map((op) => ({
+ value: op.value,
+ label: t(op.labelKey),
+ }));
+
+ return (
+
+ handlePropertyChange(index, val)}
+ style={{ flex: 1 }}
+ />
+ handleOperatorChange(index, val)}
+ w={130}
+ />
+ {needsValue && (
+ handleValueChange(index, val)}
+ t={t}
+ />
+ )}
+ handleRemove(index)}
+ >
+
+
+
+ );
+ })}
+
+ {draft && (() => {
+ const needsValue = !NO_VALUE_OPERATORS.includes(draft.op);
+ const property = properties.find((p) => p.id === draft.propertyId);
+ const validOperators = property
+ ? getOperatorsForType(property.type)
+ : OPERATORS.map((op) => op.value);
+ const operatorOptions = OPERATORS.filter((op) =>
+ validOperators.includes(op.value),
+ ).map((op) => ({ value: op.value, label: t(op.labelKey) }));
+
+ return (
+
+
+
+
+ {needsValue && (
+
+ )}
+
+
+
+ {t("Cancel")}
+
+
+ {t("Save")}
+
+
+
+ );
+ })()}
+
+ {!draft && (
+
+
+ {t("Add filter")}
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/client/src/ee/base/components/views/view-property-visibility.tsx b/apps/client/src/ee/base/components/views/view-property-visibility.tsx
new file mode 100644
index 000000000..96af8410e
--- /dev/null
+++ b/apps/client/src/ee/base/components/views/view-property-visibility.tsx
@@ -0,0 +1,158 @@
+import { useMemo, useCallback } from "react";
+import { Popover, Switch, Stack, Text, Group, Divider, UnstyledButton } from "@mantine/core";
+import { Table } from "@tanstack/react-table";
+import { IBaseRow, IBaseProperty } from "@/ee/base/types/base.types";
+import { propertyTypes } from "@/ee/base/components/property/property-type-picker";
+import { useTranslation } from "react-i18next";
+import cellClasses from "@/ee/base/styles/cells.module.css";
+import viewClasses from "@/ee/base/styles/views.module.css";
+
+type ViewPropertyVisibilityProps = {
+ opened: boolean;
+ onClose: () => void;
+ table: Table;
+ properties: IBaseProperty[];
+ onPersist: () => void;
+ children: React.ReactNode;
+};
+
+export function ViewPropertyVisibility({
+ opened,
+ onClose,
+ table,
+ properties,
+ onPersist,
+ children,
+}: ViewPropertyVisibilityProps) {
+ const { t } = useTranslation();
+
+ const columns = useMemo(() => {
+ return table
+ .getAllLeafColumns()
+ .filter((col) => col.id !== "__row_number");
+ }, [table, properties]);
+
+ const allVisible = columns.every((col) => col.getIsVisible());
+ const noneVisible = columns.filter((col) => col.getCanHide()).every((col) => !col.getIsVisible());
+
+ const handleToggle = useCallback(
+ (columnId: string, visible: boolean) => {
+ const col = table.getColumn(columnId);
+ if (!col) return;
+ col.toggleVisibility(visible);
+ onPersist();
+ },
+ [table, onPersist],
+ );
+
+ const handleShowAll = useCallback(() => {
+ columns.forEach((col) => {
+ if (col.getCanHide()) {
+ col.toggleVisibility(true);
+ }
+ });
+ onPersist();
+ }, [columns, onPersist]);
+
+ const handleHideAll = useCallback(() => {
+ columns.forEach((col) => {
+ if (col.getCanHide()) {
+ col.toggleVisibility(false);
+ }
+ });
+ onPersist();
+ }, [columns, onPersist]);
+
+ return (
+ {
+ if (!o) onClose();
+ }}
+ onClose={onClose}
+ position="bottom-end"
+ shadow="md"
+ width={260}
+ trapFocus
+ closeOnEscape
+ closeOnClickOutside
+ withinPortal
+ >
+ {children}
+
+
+
+
+ {t("Properties")}
+
+
+
+
+ {t("Show all")}
+
+
+
+
+ {t("Hide all")}
+
+
+
+
+
+
+
+
+ {columns.map((col) => {
+ const property = col.columnDef.meta?.property as IBaseProperty | undefined;
+ if (!property) return null;
+
+ const canHide = col.getCanHide();
+ const isVisible = col.getIsVisible();
+ const typeConfig = propertyTypes.find((pt) => pt.type === property.type);
+ const TypeIcon = typeConfig?.icon;
+
+ return (
+ {
+ if (canHide) {
+ handleToggle(col.id, !isVisible);
+ }
+ }}
+ style={{ opacity: canHide ? 1 : 0.5 }}
+ >
+
+ {TypeIcon && }
+
+ {property.name}
+
+
+ {}}
+ // Clicking the track synthesizes a second click on the hidden input which bubbles
+ // to UnstyledButton, firing handleToggle twice. stopPropagation blocks only that
+ // synthetic input click so handleToggle fires exactly once.
+ onClick={(e) => e.stopPropagation()}
+ styles={{ track: { cursor: canHide ? "pointer" : "not-allowed" } }}
+ />
+
+ );
+ })}
+
+
+
+
+ );
+}
diff --git a/apps/client/src/ee/base/components/views/view-renderer.tsx b/apps/client/src/ee/base/components/views/view-renderer.tsx
new file mode 100644
index 000000000..c7761d75b
--- /dev/null
+++ b/apps/client/src/ee/base/components/views/view-renderer.tsx
@@ -0,0 +1,59 @@
+import { Table } from "@tanstack/react-table";
+import {
+ IBase,
+ IBaseRow,
+ IBaseView,
+ FilterGroup,
+} from "@/ee/base/types/base.types";
+import { BaseTable } from "@/ee/base/components/base-table";
+import { BaseKanban } from "@/ee/base/components/kanban/base-kanban";
+
+type ViewRendererProps = {
+ base: IBase;
+ rows: IBaseRow[];
+ effectiveView: IBaseView | undefined;
+ table: Table;
+ pageId: string;
+ embedded?: boolean;
+ editable: boolean;
+ isFiltered: boolean;
+ hasNextPage: boolean;
+ isFetchingNextPage: boolean;
+ onFetchNextPage: () => void;
+ onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
+ onAddRow: () => void;
+ onColumnReorder: (columnId: string, finishIndex: number) => void;
+ onResizeEnd: () => void;
+ onRowReorder: (
+ rowId: string,
+ targetRowId: string,
+ dropPosition: "above" | "below",
+ ) => void;
+ persistViewConfig: () => void;
+ scrollportRef: React.RefObject;
+ aboveBand?: React.ReactNode;
+ kanbanFilter?: FilterGroup | undefined;
+};
+
+export function ViewRenderer(props: ViewRendererProps) {
+ const viewType = props.effectiveView?.type ?? "table";
+
+ if (viewType === "kanban") {
+ return (
+
+ );
+ }
+
+ if (viewType === "table") {
+ return ;
+ }
+
+ return ;
+}
diff --git a/apps/client/src/ee/base/components/views/view-sort-config.tsx b/apps/client/src/ee/base/components/views/view-sort-config.tsx
new file mode 100644
index 000000000..a2751279c
--- /dev/null
+++ b/apps/client/src/ee/base/components/views/view-sort-config.tsx
@@ -0,0 +1,221 @@
+import { useCallback, useEffect, useState } from "react";
+import {
+ Popover,
+ Stack,
+ Group,
+ Select,
+ ActionIcon,
+ Text,
+ UnstyledButton,
+ Button,
+} from "@mantine/core";
+import { IconPlus, IconTrash } from "@tabler/icons-react";
+import {
+ IBaseProperty,
+ ViewSortConfig,
+} from "@/ee/base/types/base.types";
+import { useTranslation } from "react-i18next";
+import viewClasses from "@/ee/base/styles/views.module.css";
+
+type ViewSortConfigProps = {
+ opened: boolean;
+ onClose: () => void;
+ sorts: ViewSortConfig[];
+ properties: IBaseProperty[];
+ onChange: (sorts: ViewSortConfig[]) => void;
+ children: React.ReactNode;
+};
+
+export function ViewSortConfigPopover({
+ opened,
+ onClose,
+ sorts,
+ properties,
+ onChange,
+ children,
+}: ViewSortConfigProps) {
+ const { t } = useTranslation();
+ const [draft, setDraft] = useState(null);
+
+ useEffect(() => {
+ if (!opened) setDraft(null);
+ }, [opened]);
+
+ // Page props sort by raw UUID; hide until title-based sort is supported.
+ const sortableProperties = properties.filter((p) => p.type !== "page");
+
+ const propertyOptions = sortableProperties.map((p) => ({
+ value: p.id,
+ label: p.name,
+ }));
+
+ const directionOptions = [
+ { value: "asc", label: t("Ascending") },
+ { value: "desc", label: t("Descending") },
+ ];
+
+ const handleStartDraft = useCallback(() => {
+ const usedIds = new Set(sorts.map((s) => s.propertyId));
+ const available = sortableProperties.find((p) => !usedIds.has(p.id));
+ if (!available) return;
+ setDraft({ propertyId: available.id, direction: "asc" });
+ }, [sorts, sortableProperties]);
+
+ const handleSaveDraft = useCallback(() => {
+ if (!draft) return;
+ onChange([...sorts, draft]);
+ setDraft(null);
+ }, [draft, sorts, onChange]);
+
+ const handleCancelDraft = useCallback(() => {
+ setDraft(null);
+ }, []);
+
+ const handleRemove = useCallback(
+ (index: number) => {
+ onChange(sorts.filter((_, i) => i !== index));
+ },
+ [sorts, onChange],
+ );
+
+ const handlePropertyChange = useCallback(
+ (index: number, propertyId: string | null) => {
+ if (!propertyId) return;
+ onChange(
+ sorts.map((s, i) => (i === index ? { ...s, propertyId } : s)),
+ );
+ },
+ [sorts, onChange],
+ );
+
+ const handleDirectionChange = useCallback(
+ (index: number, direction: string | null) => {
+ if (!direction) return;
+ onChange(
+ sorts.map((s, i) =>
+ i === index
+ ? { ...s, direction: direction as "asc" | "desc" }
+ : s,
+ ),
+ );
+ },
+ [sorts, onChange],
+ );
+
+ const canAddMore =
+ sortableProperties.length > sorts.length + (draft ? 1 : 0);
+
+ return (
+ {
+ if (!o) onClose();
+ }}
+ onClose={onClose}
+ position="bottom-end"
+ shadow="md"
+ width={340}
+ trapFocus
+ closeOnEscape
+ closeOnClickOutside
+ withinPortal
+ >
+ {children}
+
+
+
+ {t("Sort by")}
+
+
+ {sorts.length === 0 && !draft && (
+
+ {t("No sorts applied")}
+
+ )}
+
+ {sorts.map((sort, index) => (
+
+ handlePropertyChange(index, val)}
+ style={{ flex: 1 }}
+ />
+ handleDirectionChange(index, val)}
+ w={110}
+ />
+ handleRemove(index)}
+ >
+
+
+
+ ))}
+
+ {draft && (
+
+
+
+ val && setDraft({ ...draft, propertyId: val })
+ }
+ style={{ flex: 1 }}
+ />
+
+ val &&
+ setDraft({
+ ...draft,
+ direction: val as "asc" | "desc",
+ })
+ }
+ w={110}
+ />
+
+
+
+ {t("Cancel")}
+
+
+ {t("Save")}
+
+
+
+ )}
+
+ {!draft && canAddMore && (
+
+
+ {t("Add sort")}
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/client/src/ee/base/components/views/view-tabs.tsx b/apps/client/src/ee/base/components/views/view-tabs.tsx
new file mode 100644
index 000000000..fb5119773
--- /dev/null
+++ b/apps/client/src/ee/base/components/views/view-tabs.tsx
@@ -0,0 +1,398 @@
+import {
+ useState,
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+} from "react";
+import {
+ Group,
+ UnstyledButton,
+ Text,
+ TextInput,
+ Popover,
+ Stack,
+ Divider,
+} from "@mantine/core";
+import {
+ IconPencil,
+ IconTrash,
+ IconTable,
+ IconLink,
+ IconLayoutKanban,
+} from "@tabler/icons-react";
+import { notifications } from "@mantine/notifications";
+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 { generateJitteredKeyBetween } from "fractional-indexing-jittered";
+import { IBase, IBaseView } from "@/ee/base/types/base.types";
+import { ViewCreateMenu } from "@/ee/base/components/views/view-create-menu";
+import {
+ useUpdateViewMutation,
+ useDeleteViewMutation,
+} from "@/ee/base/queries/base-view-query";
+import { useTranslation } from "react-i18next";
+import cellClasses from "@/ee/base/styles/cells.module.css";
+import { useBaseEditable } from "@/ee/base/context/base-editable";
+import { BaseDropEdgeIndicator } from "@/ee/base/components/grid/base-drop-edge-indicator";
+
+const VIEW_DRAG_TYPE = "base-view";
+
+type ViewTabsProps = {
+ views: IBaseView[];
+ activeViewId: string | undefined;
+ pageId: string;
+ onViewChange: (viewId: string) => void;
+ onAddView?: () => void;
+ base?: IBase;
+ canAddView?: boolean;
+ /** Standalone base-page link for a view, used by "Copy link to view". */
+ getViewShareUrl?: (viewId: string) => string | null;
+};
+
+export function ViewTabs({
+ views,
+ activeViewId,
+ pageId,
+ onViewChange,
+ onAddView,
+ base,
+ canAddView,
+ getViewShareUrl,
+}: ViewTabsProps) {
+ const { t } = useTranslation();
+ const editable = useBaseEditable();
+ const [editingViewId, setEditingViewId] = useState(null);
+ const [editingName, setEditingName] = useState("");
+
+ const updateViewMutation = useUpdateViewMutation();
+ const deleteViewMutation = useDeleteViewMutation();
+
+ const orderedViews = useMemo(
+ () =>
+ [...views].sort((a, b) =>
+ a.position < b.position ? -1 : a.position > b.position ? 1 : 0,
+ ),
+ [views],
+ );
+
+ const handleReorder = useCallback(
+ (sourceId: string, targetId: string, edge: Edge) => {
+ if (sourceId === targetId) return;
+ const remaining = orderedViews.filter((v) => v.id !== sourceId);
+ const targetIndex = remaining.findIndex((v) => v.id === targetId);
+ if (targetIndex === -1) return;
+
+ let lowerPos: string | null = null;
+ let upperPos: string | null = null;
+ if (edge === "left") {
+ lowerPos =
+ targetIndex > 0 ? remaining[targetIndex - 1]?.position : null;
+ upperPos = remaining[targetIndex]?.position ?? null;
+ } else {
+ lowerPos = remaining[targetIndex]?.position ?? null;
+ upperPos =
+ targetIndex < remaining.length - 1
+ ? remaining[targetIndex + 1]?.position
+ : null;
+ }
+
+ try {
+ const position =
+ lowerPos && upperPos && lowerPos === upperPos
+ ? generateJitteredKeyBetween(lowerPos, null)
+ : generateJitteredKeyBetween(lowerPos, upperPos);
+ updateViewMutation.mutate({ viewId: sourceId, pageId, position });
+ } catch {
+ // Position computation failed; skip the reorder.
+ }
+ },
+ [orderedViews, pageId, updateViewMutation],
+ );
+
+ const handleRenameStart = useCallback(
+ (view: IBaseView) => {
+ setEditingViewId(view.id);
+ setEditingName(view.name);
+ },
+ [],
+ );
+
+ const handleRenameCommit = useCallback(() => {
+ if (!editingViewId) return;
+ const trimmed = editingName.trim();
+ const view = views.find((v) => v.id === editingViewId);
+ if (trimmed && view && trimmed !== view.name) {
+ updateViewMutation.mutate({
+ viewId: editingViewId,
+ pageId,
+ name: trimmed,
+ });
+ }
+ setEditingViewId(null);
+ }, [editingViewId, editingName, views, pageId, updateViewMutation]);
+
+ const handleRenameKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ handleRenameCommit();
+ }
+ if (e.key === "Escape") {
+ e.preventDefault();
+ setEditingViewId(null);
+ }
+ },
+ [handleRenameCommit],
+ );
+
+ const handleDelete = useCallback(
+ (viewId: string) => {
+ if (orderedViews.length <= 1) return;
+ deleteViewMutation.mutate({ viewId, pageId });
+ if (viewId === activeViewId) {
+ const remaining = orderedViews.filter((v) => v.id !== viewId);
+ onViewChange(remaining[0].id);
+ }
+ },
+ [orderedViews, pageId, activeViewId, deleteViewMutation, onViewChange],
+ );
+
+ return (
+
+ {orderedViews.map((view) => (
+ 1}
+ reorderEnabled={editable && orderedViews.length > 1}
+ onReorder={handleReorder}
+ onClick={() => onViewChange(view.id)}
+ onRenameStart={() => handleRenameStart(view)}
+ onRenameChange={setEditingName}
+ onRenameCommit={handleRenameCommit}
+ onRenameKeyDown={handleRenameKeyDown}
+ onDelete={() => handleDelete(view.id)}
+ getViewShareUrl={getViewShareUrl}
+ />
+ ))}
+ {canAddView && base && (
+
+ )}
+
+ );
+}
+
+function ViewTab({
+ view,
+ isActive,
+ isEditing,
+ editingName,
+ canDelete,
+ reorderEnabled,
+ onReorder,
+ onClick,
+ onRenameStart,
+ onRenameChange,
+ onRenameCommit,
+ onRenameKeyDown,
+ onDelete,
+ getViewShareUrl,
+}: {
+ view: IBaseView;
+ isActive: boolean;
+ isEditing: boolean;
+ editingName: string;
+ canDelete: boolean;
+ reorderEnabled: boolean;
+ onReorder: (sourceId: string, targetId: string, edge: Edge) => void;
+ onClick: () => void;
+ onRenameStart: () => void;
+ onRenameChange: (name: string) => void;
+ onRenameCommit: () => void;
+ onRenameKeyDown: (e: React.KeyboardEvent) => void;
+ onDelete: () => void;
+ getViewShareUrl?: (viewId: string) => string | null;
+}) {
+ const { t } = useTranslation();
+ const [menuOpened, setMenuOpened] = useState(false);
+ const editable = useBaseEditable();
+ const tabRef = useRef(null);
+ const [isDragging, setIsDragging] = useState(false);
+ const [closestEdge, setClosestEdge] = useState(null);
+
+ const onReorderRef = useRef(onReorder);
+ useLayoutEffect(() => {
+ onReorderRef.current = onReorder;
+ });
+
+ useEffect(() => {
+ const el = tabRef.current;
+ if (!el || !reorderEnabled || isEditing) return;
+ return combine(
+ draggable({
+ element: el,
+ getInitialData: () => ({ type: VIEW_DRAG_TYPE, viewId: view.id }),
+ onDragStart: () => setIsDragging(true),
+ onDrop: () => setIsDragging(false),
+ }),
+ dropTargetForElements({
+ element: el,
+ canDrop: ({ source }) =>
+ source.data.type === VIEW_DRAG_TYPE &&
+ source.data.viewId !== view.id,
+ getData: ({ input, element }) =>
+ attachClosestEdge(
+ { viewId: view.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;
+ onReorderRef.current(source.data.viewId as string, view.id, edge);
+ },
+ }),
+ );
+ }, [view.id, reorderEnabled, isEditing]);
+
+ const handleTabClick = useCallback(() => {
+ if (isActive) {
+ setMenuOpened((o) => !o);
+ } else {
+ onClick();
+ }
+ }, [isActive, onClick]);
+
+ const handleCopyLink = useCallback(() => {
+ setMenuOpened(false);
+ const url = getViewShareUrl?.(view.id);
+ if (!url) return;
+ void navigator.clipboard.writeText(url);
+ notifications.show({ message: t("Link copied to clipboard") });
+ }, [getViewShareUrl, view.id, t]);
+
+ if (isEditing) {
+ return (
+ onRenameChange(e.currentTarget.value)}
+ onBlur={onRenameCommit}
+ onKeyDown={onRenameKeyDown}
+ autoFocus
+ />
+ );
+ }
+
+ return (
+
+
+
+
+
+ {view.type === "kanban" ? (
+
+ ) : (
+
+ )}
+
+ {view.name}
+
+
+
+
+
+
+ {editable && (
+ {
+ setMenuOpened(false);
+ onRenameStart();
+ }}
+ >
+
+
+ {t("Rename")}
+
+
+ )}
+ {getViewShareUrl && (
+
+
+
+ {t("Copy link to view")}
+
+
+ )}
+ {editable && canDelete && (
+ <>
+
+ {
+ setMenuOpened(false);
+ onDelete();
+ }}
+ style={{ color: "var(--mantine-color-red-6)" }}
+ >
+
+
+ {t("Delete view")}
+
+
+ >
+ )}
+
+
+
+ {closestEdge &&
}
+
+ );
+}
diff --git a/apps/client/src/ee/base/constants/currencies.ts b/apps/client/src/ee/base/constants/currencies.ts
new file mode 100644
index 000000000..5dceaddf3
--- /dev/null
+++ b/apps/client/src/ee/base/constants/currencies.ts
@@ -0,0 +1,39 @@
+export type Currency = { code: string; name: string };
+
+// Most-used first; order drives the dropdown.
+export const CURRENCIES: Currency[] = [
+ { code: "USD", name: "US Dollar" },
+ { code: "EUR", name: "Euro" },
+ { code: "GBP", name: "Pound" },
+ { code: "CAD", name: "Canadian dollar" },
+ { code: "AUD", name: "Australian dollar" },
+ { code: "SGD", name: "Singapore dollar" },
+ { code: "JPY", name: "Yen" },
+ { code: "CNY", name: "Chinese Yuan" },
+];
+
+export const DEFAULT_CURRENCY_CODE = "USD";
+
+const CURRENCY_CODES = new Set(CURRENCIES.map((c) => c.code));
+
+// Renders value with locale symbol and grouping. Falls back to USD for unknown codes,
+// plain string if Intl throws. precision overrides the currency's natural decimal places.
+export function formatCurrency(
+ value: number,
+ code: string | undefined,
+ precision: number | undefined,
+): string {
+ const currency =
+ code && CURRENCY_CODES.has(code) ? code : DEFAULT_CURRENCY_CODE;
+ try {
+ return new Intl.NumberFormat(undefined, {
+ style: "currency",
+ currency,
+ ...(precision != null
+ ? { minimumFractionDigits: precision, maximumFractionDigits: precision }
+ : {}),
+ }).format(value);
+ } catch {
+ return String(value);
+ }
+}
diff --git a/apps/client/src/ee/base/context/base-editable.tsx b/apps/client/src/ee/base/context/base-editable.tsx
new file mode 100644
index 000000000..b4d1b2b80
--- /dev/null
+++ b/apps/client/src/ee/base/context/base-editable.tsx
@@ -0,0 +1,22 @@
+import { createContext, useContext, type ReactNode } from "react";
+
+const BaseEditableContext = createContext(true);
+
+export function BaseEditableProvider({
+ editable,
+ children,
+}: {
+ editable: boolean;
+ children: ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+/** Whether the current base subtree is editable. Defaults to true outside a provider. */
+export function useBaseEditable(): boolean {
+ return useContext(BaseEditableContext);
+}
diff --git a/apps/client/src/ee/base/context/grid-row-order.tsx b/apps/client/src/ee/base/context/grid-row-order.tsx
new file mode 100644
index 000000000..b812d85c6
--- /dev/null
+++ b/apps/client/src/ee/base/context/grid-row-order.tsx
@@ -0,0 +1,12 @@
+import { createContext, useContext } from "react";
+
+// Row order is only needed at interaction time (shift-select range math), so
+// rows subscribe to a stable getter instead of the array itself — appending a
+// page must not re-render every mounted row.
+const GridRowOrderContext = createContext<() => string[]>(() => []);
+
+export const GridRowOrderProvider = GridRowOrderContext.Provider;
+
+export function useGridRowOrder(): () => string[] {
+ return useContext(GridRowOrderContext);
+}
diff --git a/apps/client/src/ee/base/context/row-expand.tsx b/apps/client/src/ee/base/context/row-expand.tsx
new file mode 100644
index 000000000..a737b1ea1
--- /dev/null
+++ b/apps/client/src/ee/base/context/row-expand.tsx
@@ -0,0 +1,11 @@
+import { createContext, useContext } from "react";
+
+// Rows only need the handler at click time; a stable context value keeps the
+// expand affordance out of every GridRow/GridCell memo equality check.
+const RowExpandContext = createContext<((rowId: string) => void) | null>(null);
+
+export const RowExpandProvider = RowExpandContext.Provider;
+
+export function useRowExpand(): ((rowId: string) => void) | null {
+ return useContext(RowExpandContext);
+}
diff --git a/apps/client/src/ee/base/formatters/cell-formatters.ts b/apps/client/src/ee/base/formatters/cell-formatters.ts
new file mode 100644
index 000000000..363e8a110
--- /dev/null
+++ b/apps/client/src/ee/base/formatters/cell-formatters.ts
@@ -0,0 +1,22 @@
+import { formatNumber } from "@/ee/base/components/cells/cell-number";
+import { formatDateDisplay } from "@/ee/base/components/cells/cell-date";
+
+export { formatNumber, formatDateDisplay };
+
+export function formatTimestamp(value: string | null | undefined): string {
+ if (typeof value !== "string" || !value) return "";
+ const date = new Date(value);
+ if (isNaN(date.getTime())) return "";
+ return date.toLocaleDateString(undefined, {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ });
+}
+
+export function formatLongTextPreview(value: string | null | undefined): string {
+ if (typeof value !== "string") return "";
+ return value.replace(/\s+/g, " ").trim();
+}
diff --git a/apps/client/src/ee/base/hooks/use-base-socket.ts b/apps/client/src/ee/base/hooks/use-base-socket.ts
new file mode 100644
index 000000000..5f57263f9
--- /dev/null
+++ b/apps/client/src/ee/base/hooks/use-base-socket.ts
@@ -0,0 +1,382 @@
+import { useEffect } from "react";
+import { useAtomValue, getDefaultStore } from "jotai";
+import { useQueryClient, InfiniteData } from "@tanstack/react-query";
+import { socketAtom } from "@/features/websocket/atoms/socket-atom";
+import {
+ IBase,
+ IBaseProperty,
+ IBaseRow,
+ IBaseView,
+} from "@/ee/base/types/base.types";
+import { selectedRowIdsAtomFamily } from "@/ee/base/atoms/base-atoms";
+import { formulaRecomputeAtom } from "@/ee/base/atoms/formula-recompute-atom";
+import { IPagination } from "@/lib/types";
+import { invalidateBaseRows } from "@/ee/base/queries/base-row-query";
+
+type BaseRowCreated = {
+ operation: "base:row:created";
+ pageId: string;
+ row: IBaseRow;
+ requestId?: string | null;
+};
+
+type BaseRowUpdated = {
+ operation: "base:row:updated";
+ pageId: string;
+ rowId: string;
+ updatedCells: Record;
+ requestId?: string | null;
+};
+
+type BaseRowDeleted = {
+ operation: "base:row:deleted";
+ pageId: string;
+ rowId: string;
+ requestId?: string | null;
+};
+
+type BaseRowsDeleted = {
+ operation: "base:rows:deleted";
+ pageId: string;
+ rowIds: string[];
+ requestId?: string | null;
+};
+
+type BaseRowReordered = {
+ operation: "base:row:reordered";
+ pageId: string;
+ rowId: string;
+ position: string;
+ requestId?: string | null;
+};
+
+type BasePropertyEvent = {
+ operation:
+ | "base:property:created"
+ | "base:property:updated"
+ | "base:property:deleted"
+ | "base:property:reordered";
+ pageId: string;
+ property?: IBaseProperty;
+ propertyId?: string;
+ requestId?: string | null;
+};
+
+type BaseViewEvent = {
+ operation:
+ | "base:view:created"
+ | "base:view:updated"
+ | "base:view:deleted";
+ pageId: string;
+ view?: IBaseView;
+ viewId?: string;
+};
+
+type BaseRowsUpdated = {
+ operation: "base:rows:updated";
+ pageId: string;
+ rowIds: string[];
+ propertyIds: string[];
+ requestId?: string | null;
+};
+
+type BaseFormulaRecomputeStarted = {
+ operation: "base:formula:recompute:started";
+ pageId: string;
+ propertyIds: string[];
+ jobId: string;
+};
+
+type BaseFormulaRecomputeCompleted = {
+ operation: "base:formula:recompute:completed";
+ pageId: string;
+ propertyIds: string[];
+ jobId: string;
+ processed: number;
+ errored: number;
+};
+
+type BaseSchemaBumped = {
+ operation: "base:schema:bumped";
+ pageId: string;
+ schemaVersion: number;
+};
+
+type BaseSubscribed = {
+ operation: "base:subscribed";
+ pageId: string;
+ schemaVersion: number;
+};
+
+type BaseInboundEvent =
+ | BaseRowCreated
+ | BaseRowUpdated
+ | BaseRowDeleted
+ | BaseRowsDeleted
+ | BaseRowReordered
+ | BaseRowsUpdated
+ | BaseFormulaRecomputeStarted
+ | BaseFormulaRecomputeCompleted
+ | BaseSchemaBumped
+ | BaseSubscribed
+ | BasePropertyEvent
+ | BaseViewEvent
+ | { operation: string; pageId: string };
+
+// Module-level set of requestIds we've just sent. When the socket echoes back
+// a mutation with a matching requestId we drop it, as the local mutation
+// already updated the cache. Bounded to prevent unbounded growth on long tabs.
+const outboundRequestIds = new Set();
+const OUTBOUND_MAX = 256;
+
+export function markRequestIdOutbound(requestId: string): void {
+ outboundRequestIds.add(requestId);
+ if (outboundRequestIds.size > OUTBOUND_MAX) {
+ const oldest = outboundRequestIds.values().next().value;
+ if (oldest) outboundRequestIds.delete(oldest);
+ }
+}
+
+// Realtime bridge for a single base. Joins the base-{pageId} room on mount,
+// leaves on unmount, and reconciles React Query caches on inbound events.
+export function useBaseSocket(pageId: string | undefined): void {
+ const socket = useAtomValue(socketAtom);
+ const queryClient = useQueryClient();
+
+ useEffect(() => {
+ if (!socket || !pageId) return;
+
+ socket.emit("message", { operation: "base:subscribe", pageId });
+
+ const handler = (raw: unknown) => {
+ if (!raw || typeof raw !== "object") return;
+ const event = raw as BaseInboundEvent;
+ if (event.pageId !== pageId) return;
+
+ const requestId = (event as any).requestId as string | undefined;
+ if (requestId && outboundRequestIds.has(requestId)) {
+ outboundRequestIds.delete(requestId);
+ return;
+ }
+
+ switch (event.operation) {
+ case "base:row:created": {
+ const e = event as BaseRowCreated;
+ const baseForCreate = queryClient.getQueryData(["bases", pageId]);
+ const hasKanbanForCreate = (baseForCreate?.views ?? []).some((v) => v.type === "kanban");
+ if (hasKanbanForCreate) {
+ invalidateBaseRows(pageId);
+ } else {
+ queryClient.setQueriesData>>(
+ { queryKey: ["base-rows", pageId] },
+ (old) => {
+ if (!old) return old;
+ const lastPageIndex = old.pages.length - 1;
+ return {
+ ...old,
+ pages: old.pages.map((page, index) =>
+ index === lastPageIndex
+ ? { ...page, items: [...page.items, e.row] }
+ : page,
+ ),
+ };
+ },
+ );
+ }
+ break;
+ }
+ case "base:row:updated": {
+ const e = event as BaseRowUpdated;
+ const baseForUpdate = queryClient.getQueryData(["bases", pageId]);
+ const hasKanbanForUpdate = (baseForUpdate?.views ?? []).some((v) => v.type === "kanban");
+ if (hasKanbanForUpdate) {
+ invalidateBaseRows(pageId);
+ } else {
+ queryClient.setQueriesData>>(
+ { queryKey: ["base-rows", pageId] },
+ (old) =>
+ !old
+ ? old
+ : {
+ ...old,
+ pages: old.pages.map((page) => ({
+ ...page,
+ items: page.items.map((row) =>
+ row.id === e.rowId
+ ? {
+ ...row,
+ cells: { ...row.cells, ...e.updatedCells },
+ }
+ : row,
+ ),
+ })),
+ },
+ );
+ }
+ break;
+ }
+ case "base:row:deleted": {
+ const e = event as BaseRowDeleted;
+ queryClient.setQueriesData>>(
+ { queryKey: ["base-rows", pageId] },
+ (old) =>
+ !old
+ ? old
+ : {
+ ...old,
+ pages: old.pages.map((page) => ({
+ ...page,
+ items: page.items.filter((row) => row.id !== e.rowId),
+ })),
+ },
+ );
+ const store = getDefaultStore();
+ const selectedIdsAtom = selectedRowIdsAtomFamily(pageId);
+ const current = store.get(selectedIdsAtom);
+ if (current.has(e.rowId)) {
+ const next = new Set(current);
+ next.delete(e.rowId);
+ store.set(selectedIdsAtom, next);
+ }
+ break;
+ }
+ case "base:rows:deleted": {
+ const e = event as BaseRowsDeleted;
+ const removeSet = new Set(e.rowIds);
+ queryClient.setQueriesData>>(
+ { queryKey: ["base-rows", pageId] },
+ (old) => {
+ if (!old) return old;
+ return {
+ ...old,
+ pages: old.pages.map((page) => ({
+ ...page,
+ items: page.items.filter((row) => !removeSet.has(row.id)),
+ })),
+ };
+ },
+ );
+ const store = getDefaultStore();
+ const selectedIdsAtom = selectedRowIdsAtomFamily(pageId);
+ const current = store.get(selectedIdsAtom);
+ if (current.size > 0) {
+ let changed = false;
+ const next = new Set(current);
+ for (const id of e.rowIds) {
+ if (next.delete(id)) changed = true;
+ }
+ if (changed) store.set(selectedIdsAtom, next);
+ }
+ break;
+ }
+ case "base:row:reordered": {
+ const e = event as BaseRowReordered;
+ const baseForReorder = queryClient.getQueryData(["bases", pageId]);
+ const hasKanbanForReorder = (baseForReorder?.views ?? []).some((v) => v.type === "kanban");
+ if (hasKanbanForReorder) {
+ invalidateBaseRows(pageId);
+ } else {
+ queryClient.setQueriesData>>(
+ { queryKey: ["base-rows", pageId] },
+ (old) =>
+ !old
+ ? old
+ : {
+ ...old,
+ pages: old.pages.map((page) => ({
+ ...page,
+ items: page.items.map((row) =>
+ row.id === e.rowId
+ ? { ...row, position: e.position }
+ : row,
+ ),
+ })),
+ },
+ );
+ }
+ break;
+ }
+ case "base:rows:updated": {
+ const e = event as BaseRowsUpdated;
+ // Only refetch if the batch touches rows currently in cache; formula
+ // backfills emit one event per 500 rows so this avoids redundant fetches.
+ const updatedIds = new Set(e.rowIds);
+ const caches = queryClient.getQueriesData<
+ InfiniteData>
+ >({ queryKey: ["base-rows", pageId] });
+ let touchesCache = false;
+ outer: for (const [, data] of caches) {
+ if (!data) continue;
+ for (const page of data.pages) {
+ for (const row of page.items) {
+ if (updatedIds.has(row.id)) {
+ touchesCache = true;
+ break outer;
+ }
+ }
+ }
+ }
+ if (touchesCache) {
+ queryClient.invalidateQueries({ queryKey: ["base-rows", pageId] });
+ }
+ break;
+ }
+ case "base:schema:bumped": {
+ // Worker committed a type conversion or cell GC; re-fetch under the new schema.
+ queryClient.invalidateQueries({ queryKey: ["base-rows", pageId] });
+ queryClient.invalidateQueries({ queryKey: ["bases", pageId] });
+ break;
+ }
+ case "base:subscribed": {
+ const e = event as BaseSubscribed;
+ const cached = queryClient.getQueryData(["bases", pageId]);
+ if (cached && cached.baseSchemaVersion !== e.schemaVersion) {
+ queryClient.invalidateQueries({ queryKey: ["base-rows", pageId] });
+ queryClient.invalidateQueries({ queryKey: ["bases", pageId] });
+ }
+ break;
+ }
+ case "base:formula:recompute:started": {
+ const e = event as BaseFormulaRecomputeStarted;
+ const store = getDefaultStore();
+ store.set(formulaRecomputeAtom, {
+ ...store.get(formulaRecomputeAtom),
+ [e.jobId]: e.propertyIds,
+ });
+ break;
+ }
+ case "base:formula:recompute:completed": {
+ const e = event as BaseFormulaRecomputeCompleted;
+ const store = getDefaultStore();
+ const current = store.get(formulaRecomputeAtom);
+ if (e.jobId in current) {
+ const next = { ...current };
+ delete next[e.jobId];
+ store.set(formulaRecomputeAtom, next);
+ }
+ break;
+ }
+ case "base:property:created":
+ case "base:property:updated":
+ case "base:property:deleted":
+ case "base:property:reordered":
+ case "base:view:created":
+ case "base:view:updated":
+ case "base:view:deleted": {
+ // Schema/metadata events only affect properties/views, not cell data.
+ queryClient.invalidateQueries({ queryKey: ["bases", pageId] });
+ break;
+ }
+ default:
+ break;
+ }
+ };
+
+ socket.on("message", handler);
+
+ return () => {
+ socket.off("message", handler);
+ socket.emit("message", { operation: "base:unsubscribe", pageId });
+ };
+ }, [socket, pageId, queryClient]);
+}
diff --git a/apps/client/src/ee/base/hooks/use-base-table.ts b/apps/client/src/ee/base/hooks/use-base-table.ts
new file mode 100644
index 000000000..787babd81
--- /dev/null
+++ b/apps/client/src/ee/base/hooks/use-base-table.ts
@@ -0,0 +1,376 @@
+import { useMemo, useCallback, useRef, useState, useEffect } from "react";
+import { useMediaQuery } from "@mantine/hooks";
+import {
+ useReactTable,
+ getCoreRowModel,
+ getSortedRowModel,
+ getFilteredRowModel,
+ createColumnHelper,
+ ColumnDef,
+ SortingState,
+ ColumnSizingState,
+ VisibilityState,
+ ColumnOrderState,
+ ColumnPinningState,
+ Table,
+} from "@tanstack/react-table";
+import {
+ IBase,
+ IBaseProperty,
+ IBaseRow,
+ IBaseView,
+ ViewConfig,
+ ViewConfigPatch,
+} from "@/ee/base/types/base.types";
+import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query";
+import { systemAccessorFor } from "@/ee/base/property-types/property-type.registry";
+
+const DEFAULT_COLUMN_WIDTH = 180;
+const MIN_COLUMN_WIDTH = 80;
+const MAX_COLUMN_WIDTH = 600;
+const ROW_NUMBER_COLUMN_WIDTH = 64;
+
+const columnHelper = createColumnHelper();
+
+function buildColumns(properties: IBaseProperty[]): ColumnDef[] {
+ const rowNumberColumn = columnHelper.display({
+ id: "__row_number",
+ header: "#",
+ size: ROW_NUMBER_COLUMN_WIDTH,
+ minSize: ROW_NUMBER_COLUMN_WIDTH,
+ maxSize: ROW_NUMBER_COLUMN_WIDTH,
+ enableResizing: false,
+ enableSorting: false,
+ enableHiding: false,
+ });
+
+ const propertyColumns = properties.map((property) => {
+ const sysAccessor = systemAccessorFor(property.type);
+ if (sysAccessor) {
+ return columnHelper.accessor(sysAccessor, {
+ id: property.id,
+ header: property.name,
+ size: DEFAULT_COLUMN_WIDTH,
+ minSize: MIN_COLUMN_WIDTH,
+ maxSize: MAX_COLUMN_WIDTH,
+ enableResizing: true,
+ enableSorting: false,
+ enableHiding: !property.isPrimary,
+ meta: { property },
+ });
+ }
+
+ return columnHelper.accessor((row) => row.cells[property.id], {
+ id: property.id,
+ header: property.name,
+ size: DEFAULT_COLUMN_WIDTH,
+ minSize: MIN_COLUMN_WIDTH,
+ maxSize: MAX_COLUMN_WIDTH,
+ enableResizing: true,
+ enableSorting: true,
+ enableHiding: !property.isPrimary,
+ meta: { property },
+ });
+ });
+
+ return [rowNumberColumn, ...propertyColumns];
+}
+
+function buildSortingState(config: ViewConfig | undefined): SortingState {
+ if (!config?.sorts?.length) return [];
+ return config.sorts.map((sort) => ({
+ id: sort.propertyId,
+ desc: sort.direction === "desc",
+ }));
+}
+
+function buildColumnSizing(
+ config: ViewConfig | undefined,
+): ColumnSizingState {
+ const sizing: ColumnSizingState = {
+ __row_number: ROW_NUMBER_COLUMN_WIDTH,
+ };
+ if (config?.propertyWidths) {
+ Object.entries(config.propertyWidths).forEach(([id, width]) => {
+ sizing[id] = width;
+ });
+ }
+ return sizing;
+}
+
+function buildColumnVisibility(
+ config: ViewConfig | undefined,
+ properties: IBaseProperty[],
+): VisibilityState {
+ const visibility: VisibilityState = { __row_number: true };
+
+ if (config?.hiddenPropertyIds) {
+ const hiddenSet = new Set(config.hiddenPropertyIds);
+ properties.forEach((p) => {
+ visibility[p.id] = !hiddenSet.has(p.id);
+ });
+ return visibility;
+ }
+
+ if (config?.visiblePropertyIds?.length) {
+ const visibleSet = new Set(config.visiblePropertyIds);
+ properties.forEach((p) => {
+ visibility[p.id] = visibleSet.has(p.id);
+ });
+ return visibility;
+ }
+
+ properties.forEach((p) => {
+ visibility[p.id] = true;
+ });
+ return visibility;
+}
+
+function buildColumnOrder(
+ config: ViewConfig | undefined,
+ properties: IBaseProperty[],
+): ColumnOrderState {
+ if (config?.propertyOrder?.length) {
+ const orderSet = new Set(config.propertyOrder);
+ const missing = properties
+ .filter((p) => !orderSet.has(p.id))
+ .sort((a, b) => (a.position < b.position ? -1 : a.position > b.position ? 1 : 0))
+ .map((p) => p.id);
+ return ["__row_number", ...config.propertyOrder, ...missing];
+ }
+ const sorted = [...properties].sort((a, b) => {
+ if (a.isPrimary) return -1;
+ if (b.isPrimary) return 1;
+ return a.position < b.position ? -1 : a.position > b.position ? 1 : 0;
+ });
+ return ["__row_number", ...sorted.map((p) => p.id)];
+}
+
+function buildColumnPinning(
+ properties: IBaseProperty[],
+ pinPrimary: boolean,
+): ColumnPinningState {
+ const primary = pinPrimary ? properties.find((p) => p.isPrimary) : undefined;
+ return {
+ left: primary ? ["__row_number", primary.id] : ["__row_number"],
+ right: [],
+ };
+}
+
+export function buildLayoutConfigPatch(table: Table): ViewConfigPatch {
+ const state = table.getState();
+
+ const propertyWidths: Record = {};
+ Object.entries(state.columnSizing).forEach(([id, width]) => {
+ if (id !== "__row_number") {
+ // Resize state can hold the raw drag value below minSize; rendering
+ // clamps via getSize(), so persist the clamped value too.
+ propertyWidths[id] = Math.min(
+ MAX_COLUMN_WIDTH,
+ Math.max(MIN_COLUMN_WIDTH, width),
+ );
+ }
+ });
+
+ const propertyOrder = state.columnOrder.filter((id) => id !== "__row_number");
+
+ const hiddenPropertyIds = Object.entries(state.columnVisibility)
+ .filter(([id, visible]) => id !== "__row_number" && !visible)
+ .map(([id]) => id);
+
+ return {
+ propertyWidths,
+ propertyOrder,
+ hiddenPropertyIds,
+ visiblePropertyIds: null,
+ };
+}
+
+export type UseBaseTableResult = {
+ table: Table;
+ persistViewConfig: () => void;
+};
+
+export function useBaseTable(
+ base: IBase | undefined,
+ rows: IBaseRow[],
+ activeView: IBaseView | undefined,
+): UseBaseTableResult {
+ const updateViewMutation = useUpdateViewMutation();
+ const persistTimerRef = useRef | null>(null);
+ // While a local edit is pending the reconcile effect preserves local state
+ // to avoid stomping in-flight toggles. When idle it adopts server state so
+ // remote updates from other clients (e.g. hiding a column) show up here.
+ const [hasPendingEdit, setHasPendingEdit] = useState(false);
+
+ const properties = useMemo(() => base?.properties ?? [], [base?.properties]);
+ const viewConfig = activeView?.config;
+
+ const columns = useMemo(
+ () => buildColumns(properties),
+ [properties],
+ );
+
+ const initialSorting = useMemo(
+ () => buildSortingState(viewConfig),
+ [viewConfig],
+ );
+
+ const derivedColumnSizing = useMemo(
+ () => buildColumnSizing(viewConfig),
+ [viewConfig],
+ );
+
+ const derivedColumnOrder = useMemo(
+ () => buildColumnOrder(viewConfig, properties),
+ [viewConfig, properties],
+ );
+
+ const derivedColumnVisibility = useMemo(
+ () => buildColumnVisibility(viewConfig, properties),
+ [viewConfig, properties],
+ );
+
+ const [columnOrder, setColumnOrder] = useState(derivedColumnOrder);
+ const [columnVisibility, setColumnVisibility] = useState(derivedColumnVisibility);
+ const [columnSizing, setColumnSizing] = useState(derivedColumnSizing);
+
+ // Re-seed from the server only on view switch. Within the same view local
+ // state is the source of truth. Without this guard, any ws-driven
+ // invalidateQueries would land a new derivedColumnVisibility reference and
+ // overwrite a pending toggle before persistViewConfig flushes it.
+ const lastSyncedViewIdRef = useRef(activeView?.id);
+ useEffect(() => {
+ const currentViewId = activeView?.id;
+
+ if (currentViewId !== lastSyncedViewIdRef.current) {
+ lastSyncedViewIdRef.current = currentViewId;
+ setColumnOrder(derivedColumnOrder);
+ setColumnVisibility(derivedColumnVisibility);
+ setColumnSizing(derivedColumnSizing);
+ return;
+ }
+
+ // Same view: if a local edit is pending, reconcile only the id set so
+ // new/deleted columns appear without stomping the user's toggle.
+ // If no edit is pending, adopt server state so remote updates show up.
+ const validIds = new Set(["__row_number"]);
+ for (const p of properties) validIds.add(p.id);
+
+ if (hasPendingEdit) {
+ setColumnOrder((prev) => {
+ const prevSet = new Set(prev);
+ const kept = prev.filter((id) => validIds.has(id));
+ const appended = derivedColumnOrder.filter(
+ (id) => !prevSet.has(id) && validIds.has(id),
+ );
+ if (appended.length === 0 && kept.length === prev.length) return prev;
+ return [...kept, ...appended];
+ });
+
+ setColumnVisibility((prev) => {
+ let changed = false;
+ const next: VisibilityState = {};
+ for (const [id, visible] of Object.entries(prev)) {
+ if (validIds.has(id)) {
+ next[id] = visible;
+ } else {
+ changed = true;
+ }
+ }
+ for (const id of derivedColumnOrder) {
+ if (!(id in next)) {
+ next[id] = derivedColumnVisibility[id] ?? true;
+ changed = true;
+ }
+ }
+ return changed ? next : prev;
+ });
+
+ setColumnSizing((prev) => {
+ let changed = false;
+ const next: ColumnSizingState = {};
+ for (const [id, width] of Object.entries(prev)) {
+ if (validIds.has(id)) {
+ next[id] = width;
+ } else {
+ changed = true;
+ }
+ }
+ return changed ? next : prev;
+ });
+ } else {
+ setColumnOrder(derivedColumnOrder);
+ setColumnVisibility(derivedColumnVisibility);
+ setColumnSizing(derivedColumnSizing);
+ }
+ }, [
+ activeView?.id,
+ derivedColumnOrder,
+ derivedColumnVisibility,
+ derivedColumnSizing,
+ properties,
+ hasPendingEdit,
+ ]);
+
+ const isMobile = useMediaQuery("(max-width: 48em)", false, {
+ getInitialValueInEffect: false,
+ });
+ const columnPinning = useMemo(
+ () => buildColumnPinning(properties, !isMobile),
+ [properties, isMobile],
+ );
+
+ const table = useReactTable({
+ data: rows,
+ columns,
+ state: {
+ columnPinning,
+ columnOrder,
+ columnVisibility,
+ columnSizing,
+ },
+ onColumnOrderChange: setColumnOrder,
+ onColumnVisibilityChange: setColumnVisibility,
+ onColumnSizingChange: setColumnSizing,
+ initialState: {
+ sorting: initialSorting,
+ },
+ getCoreRowModel: getCoreRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ columnResizeMode: "onChange",
+ enableColumnResizing: true,
+ enableSorting: true,
+ enableHiding: true,
+ getRowId: (row) => row.id,
+ });
+
+ const persistViewConfig = useCallback(() => {
+ if (!activeView || !base) return;
+
+ if (persistTimerRef.current) {
+ clearTimeout(persistTimerRef.current);
+ }
+
+ setHasPendingEdit(true);
+
+ persistTimerRef.current = setTimeout(() => {
+ persistTimerRef.current = null;
+ const config = buildLayoutConfigPatch(table);
+ updateViewMutation.mutate(
+ { viewId: activeView.id, pageId: base.id, config },
+ {
+ onSettled: () => {
+ // Only clear if no new debounce was scheduled while in flight.
+ if (persistTimerRef.current === null) {
+ setHasPendingEdit(false);
+ }
+ },
+ },
+ );
+ }, 300);
+ }, [activeView, base, table, updateViewMutation]);
+
+ return { table, persistViewConfig };
+}
diff --git a/apps/client/src/ee/base/hooks/use-column-resize.ts b/apps/client/src/ee/base/hooks/use-column-resize.ts
new file mode 100644
index 000000000..eea46e3e2
--- /dev/null
+++ b/apps/client/src/ee/base/hooks/use-column-resize.ts
@@ -0,0 +1,26 @@
+import { useEffect, useRef, useCallback } from "react";
+import { Table } from "@tanstack/react-table";
+import { IBaseRow } from "@/ee/base/types/base.types";
+
+export function useColumnResize(
+ table: Table,
+ onResizeEnd: () => void,
+) {
+ const wasResizingRef = useRef(false);
+
+ const checkResizeEnd = useCallback(() => {
+ const isResizing = table.getState().columnSizingInfo.isResizingColumn;
+ if (wasResizingRef.current && !isResizing) {
+ onResizeEnd();
+ }
+ wasResizingRef.current = !!isResizing;
+ }, [table, onResizeEnd]);
+
+ useEffect(() => {
+ checkResizeEnd();
+ });
+
+ return {
+ isResizing: !!table.getState().columnSizingInfo.isResizingColumn,
+ };
+}
diff --git a/apps/client/src/ee/base/hooks/use-delete-selected-rows.tsx b/apps/client/src/ee/base/hooks/use-delete-selected-rows.tsx
new file mode 100644
index 000000000..a554fbc9b
--- /dev/null
+++ b/apps/client/src/ee/base/hooks/use-delete-selected-rows.tsx
@@ -0,0 +1,55 @@
+import { useCallback } from "react";
+import { notifications } from "@mantine/notifications";
+import { modals } from "@mantine/modals";
+import { Text } from "@mantine/core";
+import { useTranslation } from "react-i18next";
+import { useRowSelection } from "@/ee/base/hooks/use-row-selection";
+import { useDeleteRowsMutation } from "@/ee/base/queries/base-row-query";
+
+const BATCH_SIZE = 500;
+
+export function useDeleteSelectedRows(pageId: string) {
+ const { t } = useTranslation();
+ const { selectedIds, clear } = useRowSelection(pageId);
+ const mutation = useDeleteRowsMutation();
+
+ const runDelete = useCallback(
+ async (ids: string[]) => {
+ const chunks: string[][] = [];
+ for (let i = 0; i < ids.length; i += BATCH_SIZE) {
+ chunks.push(ids.slice(i, i + BATCH_SIZE));
+ }
+ try {
+ for (const chunk of chunks) {
+ await mutation.mutateAsync({ pageId, rowIds: chunk });
+ }
+ notifications.show({
+ message: t("{{count}} rows deleted", { count: ids.length }),
+ });
+ clear();
+ } catch {
+ // mutation onError already shows notification
+ }
+ },
+ [pageId, mutation, clear, t],
+ );
+
+ const deleteSelected = useCallback(() => {
+ const ids = Array.from(selectedIds);
+ if (ids.length === 0) return;
+ modals.openConfirmModal({
+ title: t("Delete {{count}} rows?", { count: ids.length }),
+ centered: true,
+ children: (
+
+ {t("This action cannot be undone.")}
+
+ ),
+ labels: { confirm: t("Delete"), cancel: t("Cancel") },
+ confirmProps: { color: "red" },
+ onConfirm: () => void runDelete(ids),
+ });
+ }, [selectedIds, runDelete, t]);
+
+ return { deleteSelected, isPending: mutation.isPending };
+}
diff --git a/apps/client/src/ee/base/hooks/use-editable-text-cell.ts b/apps/client/src/ee/base/hooks/use-editable-text-cell.ts
new file mode 100644
index 000000000..996d5a3ad
--- /dev/null
+++ b/apps/client/src/ee/base/hooks/use-editable-text-cell.ts
@@ -0,0 +1,77 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+
+export type UseEditableTextCellParams = {
+ value: unknown;
+ isEditing: boolean;
+ onCommit: (value: unknown) => void;
+ onCancel: () => void;
+ /** value -> the draft string shown in the input when editing begins */
+ toDraft: (value: unknown) => string;
+ /** draft string -> the value passed to onCommit */
+ parse: (draft: string) => unknown;
+};
+
+export type EditableTextCell = {
+ draft: string;
+ setDraft: (draft: string) => void;
+ inputRef: React.RefObject;
+ handleKeyDown: (e: React.KeyboardEvent) => void;
+ handleBlur: () => void;
+};
+
+export function useEditableTextCell({
+ value,
+ isEditing,
+ onCommit,
+ onCancel,
+ toDraft,
+ parse,
+}: UseEditableTextCellParams): EditableTextCell {
+ const [draft, setDraft] = useState(() => toDraft(value));
+ const inputRef = useRef(null);
+ const committedRef = useRef(false);
+ const wasEditingRef = useRef(false);
+ const toDraftRef = useRef(toDraft);
+ toDraftRef.current = toDraft;
+
+ useEffect(() => {
+ if (isEditing && !wasEditingRef.current) {
+ committedRef.current = false;
+ setDraft(toDraftRef.current(value));
+ requestAnimationFrame(() => {
+ inputRef.current?.focus();
+ inputRef.current?.select();
+ });
+ }
+ wasEditingRef.current = isEditing;
+ }, [isEditing, value]);
+
+ const commitOnce = useCallback(
+ (val: unknown) => {
+ if (committedRef.current) return;
+ committedRef.current = true;
+ onCommit(val);
+ },
+ [onCommit],
+ );
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ commitOnce(parse(draft));
+ } else if (e.key === "Escape") {
+ e.preventDefault();
+ committedRef.current = true;
+ onCancel();
+ }
+ },
+ [draft, parse, commitOnce, onCancel],
+ );
+
+ const handleBlur = useCallback(() => {
+ commitOnce(parse(draft));
+ }, [draft, parse, commitOnce]);
+
+ return { draft, setDraft, inputRef, handleKeyDown, handleBlur };
+}
diff --git a/apps/client/src/ee/base/hooks/use-formula-parser.ts b/apps/client/src/ee/base/hooks/use-formula-parser.ts
new file mode 100644
index 000000000..19b581860
--- /dev/null
+++ b/apps/client/src/ee/base/hooks/use-formula-parser.ts
@@ -0,0 +1,107 @@
+import { useEffect, useMemo, useState } from "react";
+import {
+ parseRaw,
+ resolve,
+ typecheck,
+ BaseFormulaGraph,
+ type FormulaResultType,
+ type FormulaFn,
+} from "@docmost/base-formula/client";
+import type { IBaseProperty } from "@/ee/base/types/base.types";
+
+type ParseState =
+ | { state: "idle" }
+ | {
+ state: "ok";
+ resultType: FormulaResultType;
+ ast: unknown;
+ dependencies: string[];
+ }
+ | {
+ state: "error";
+ code: string;
+ message: string;
+ span?: { start: number; end: number };
+ };
+
+export function useFormulaParser(
+ source: string,
+ properties: IBaseProperty[],
+ editingPropertyId: string | null,
+ registryForTypecheck: ReadonlyMap,
+): ParseState {
+ const [state, setState] = useState({ state: "idle" });
+
+ const deps = useMemo(
+ () => ({ source, properties, editingPropertyId, registryForTypecheck }),
+ [source, properties, editingPropertyId, registryForTypecheck],
+ );
+
+ useEffect(() => {
+ const handle = setTimeout(() => {
+ if (!source.trim()) {
+ setState({ state: "idle" });
+ return;
+ }
+ try {
+ const nameToId = new Map(properties.map((p) => [p.name, p.id]));
+ const raw = parseRaw(source);
+ const resolved = resolve(raw, nameToId);
+ const typeMap = new Map(
+ properties.map((p) => [p.id, clientResultTypeOf(p.type)]),
+ );
+ const tc = typecheck(resolved.ast, typeMap, registryForTypecheck);
+ const candidate = {
+ id: editingPropertyId ?? "pending",
+ type: "formula" as const,
+ typeOptions: { dependencies: resolved.dependencies },
+ };
+ const others = editingPropertyId
+ ? properties.filter((p) => p.id !== editingPropertyId)
+ : properties;
+ const graph = new BaseFormulaGraph([...others, candidate as any]);
+ const cycle = graph.detectCycle(candidate as any);
+ if (cycle) {
+ setState({
+ state: "error",
+ code: "CYCLE",
+ message: `Cycle: ${cycle.join(" \u2192 ")}`,
+ });
+ return;
+ }
+ setState({
+ state: "ok",
+ resultType: tc.resultType,
+ ast: resolved.ast,
+ dependencies: resolved.dependencies,
+ });
+ } catch (e: any) {
+ const first = e?.errors?.[0];
+ setState({
+ state: "error",
+ code: first?.code ?? "PARSE_ERROR",
+ message: first?.message ?? e?.message ?? String(e),
+ span: first?.span,
+ });
+ }
+ }, 150);
+ return () => clearTimeout(handle);
+ }, [deps]);
+
+ return state;
+}
+
+function clientResultTypeOf(type: string): FormulaResultType {
+ if (type === "number") return "number";
+ if (
+ type === "text" ||
+ type === "url" ||
+ type === "email" ||
+ type === "longText"
+ )
+ return "string";
+ if (type === "checkbox") return "boolean";
+ if (type === "date" || type === "createdAt" || type === "lastEditedAt")
+ return "date";
+ return "null";
+}
diff --git a/apps/client/src/ee/base/hooks/use-grid-autoscroll.ts b/apps/client/src/ee/base/hooks/use-grid-autoscroll.ts
new file mode 100644
index 000000000..324c195b6
--- /dev/null
+++ b/apps/client/src/ee/base/hooks/use-grid-autoscroll.ts
@@ -0,0 +1,120 @@
+import { type RefObject, useEffect } from "react";
+import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
+import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
+import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
+import { unsafeOverflowAutoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/unsafe-overflow/element";
+import { COLUMN_DRAG_TYPE } from "@/ee/base/components/grid/grid-header-cell";
+import { ROW_DRAG_TYPE } from "@/ee/base/components/grid/grid-row";
+
+const HEADER_BAND_REACH_PX = 60;
+const EDGE_OUTWARD_REACH_PX = 80;
+
+const EARLY_PAN_MARGIN_PX = 100;
+const MIN_PAN_SPEED_PX = 3;
+const MAX_PAN_SPEED_PX = 16;
+
+export function useGridAutoScroll(
+ bodyRef: RefObject,
+ pageId: string,
+): void {
+ useEffect(() => {
+ const element = bodyRef.current;
+ if (!element) return;
+
+ let rafId = 0;
+ let pointerX: number | null = null;
+ // Captured once at drag start: cursor-to-column-left-edge distance and column width.
+ let grabOffsetX = 0;
+ let columnWidth = 0;
+
+ let lockedScrollLeft: number | null = null;
+ const keepHorizontalScroll = () => {
+ if (lockedScrollLeft !== null && element.scrollLeft !== lockedScrollLeft) {
+ element.scrollLeft = lockedScrollLeft;
+ }
+ };
+
+ function speedForDepth(distanceFromEdge: number): number {
+ const depth = Math.min(1, (EARLY_PAN_MARGIN_PX - distanceFromEdge) / EARLY_PAN_MARGIN_PX);
+ return MIN_PAN_SPEED_PX + (MAX_PAN_SPEED_PX - MIN_PAN_SPEED_PX) * depth;
+ }
+
+ function pan() {
+ if (pointerX === null) {
+ rafId = 0;
+ return;
+ }
+ const rect = element.getBoundingClientRect();
+ const columnLeft = pointerX - grabOffsetX;
+ const columnRight = columnLeft + columnWidth;
+ const fromLeft = columnLeft - rect.left;
+ const fromRight = rect.right - columnRight;
+ let delta = 0;
+ if (fromLeft < EARLY_PAN_MARGIN_PX) {
+ delta = -speedForDepth(fromLeft);
+ } else if (fromRight < EARLY_PAN_MARGIN_PX) {
+ delta = speedForDepth(fromRight);
+ }
+ if (delta !== 0) element.scrollLeft += delta;
+ rafId = requestAnimationFrame(pan);
+ }
+
+ return combine(
+ autoScrollForElements({
+ element,
+ canScroll: ({ source }) =>
+ source.data?.type === COLUMN_DRAG_TYPE &&
+ source.data?.pageId === pageId,
+ getAllowedAxis: () => "horizontal" as const,
+ }),
+ unsafeOverflowAutoScrollForElements({
+ element,
+ canScroll: ({ source }) =>
+ source.data?.type === COLUMN_DRAG_TYPE &&
+ source.data?.pageId === pageId,
+ getAllowedAxis: () => "horizontal" as const,
+ getOverflow: () => ({
+ forLeftEdge: { left: EDGE_OUTWARD_REACH_PX, top: HEADER_BAND_REACH_PX },
+ forRightEdge: { right: EDGE_OUTWARD_REACH_PX, top: HEADER_BAND_REACH_PX },
+ }),
+ }),
+ monitorForElements({
+ canMonitor: ({ source }) =>
+ source.data?.type === COLUMN_DRAG_TYPE && source.data?.pageId === pageId,
+ onDragStart: ({ location, source }) => {
+ const cr = source.element.getBoundingClientRect();
+ grabOffsetX = location.current.input.clientX - cr.left;
+ columnWidth = cr.width;
+ pointerX = location.current.input.clientX;
+ if (rafId === 0) rafId = requestAnimationFrame(pan);
+ },
+ onDrag: ({ location }) => {
+ pointerX = location.current.input.clientX;
+ },
+ onDrop: () => {
+ pointerX = null;
+ if (rafId !== 0) {
+ cancelAnimationFrame(rafId);
+ rafId = 0;
+ }
+ },
+ }),
+ monitorForElements({
+ canMonitor: ({ source }) =>
+ source.data?.type === ROW_DRAG_TYPE && source.data?.pageId === pageId,
+ onDragStart: () => {
+ lockedScrollLeft = element.scrollLeft;
+ element.addEventListener("scroll", keepHorizontalScroll);
+ },
+ onDrop: () => {
+ element.removeEventListener("scroll", keepHorizontalScroll);
+ lockedScrollLeft = null;
+ },
+ }),
+ () => {
+ if (rafId !== 0) cancelAnimationFrame(rafId);
+ element.removeEventListener("scroll", keepHorizontalScroll);
+ },
+ );
+ }, [bodyRef, pageId]);
+}
diff --git a/apps/client/src/ee/base/hooks/use-grid-keyboard-nav.ts b/apps/client/src/ee/base/hooks/use-grid-keyboard-nav.ts
new file mode 100644
index 000000000..0c4efde5c
--- /dev/null
+++ b/apps/client/src/ee/base/hooks/use-grid-keyboard-nav.ts
@@ -0,0 +1,120 @@
+import { useCallback, useEffect } from "react";
+import { Table } from "@tanstack/react-table";
+import { IBaseRow, EditingCell } from "@/ee/base/types/base.types";
+
+type UseGridKeyboardNavOptions = {
+ table: Table;
+ editingCell: EditingCell;
+ setEditingCell: (cell: EditingCell) => void;
+ containerRef: React.RefObject;
+};
+
+export function useGridKeyboardNav({
+ table,
+ editingCell,
+ setEditingCell,
+ containerRef,
+}: UseGridKeyboardNavOptions) {
+ const getNavigableColumns = useCallback(() => {
+ return table
+ .getVisibleLeafColumns()
+ .filter((col) => col.id !== "__row_number")
+ .map((col) => col.id);
+ }, [table]);
+
+ const getRowIds = useCallback(() => {
+ return table.getRowModel().rows.map((row) => row.id);
+ }, [table]);
+
+ const navigate = useCallback(
+ (rowDelta: number, colDelta: number) => {
+ if (!editingCell) return;
+
+ const columns = getNavigableColumns();
+ const rowIds = getRowIds();
+
+ const currentColIndex = columns.indexOf(editingCell.propertyId);
+ const currentRowIndex = rowIds.indexOf(editingCell.rowId);
+
+ if (currentColIndex === -1 || currentRowIndex === -1) return;
+
+ let nextColIndex = currentColIndex + colDelta;
+ let nextRowIndex = currentRowIndex + rowDelta;
+
+ if (nextColIndex < 0) {
+ nextColIndex = columns.length - 1;
+ nextRowIndex -= 1;
+ } else if (nextColIndex >= columns.length) {
+ nextColIndex = 0;
+ nextRowIndex += 1;
+ }
+
+ if (nextRowIndex < 0 || nextRowIndex >= rowIds.length) return;
+
+ // Blur fires onBlur->commit before React unmounts the input
+ (document.activeElement as HTMLElement | null)?.blur();
+
+ setEditingCell({
+ rowId: rowIds[nextRowIndex],
+ propertyId: columns[nextColIndex],
+ });
+ },
+ [editingCell, getNavigableColumns, getRowIds, setEditingCell],
+ );
+
+ const handleKeyDown = useCallback(
+ (e: KeyboardEvent) => {
+ if (!editingCell) return;
+
+ const target = e.target as HTMLElement;
+ const isInputActive =
+ target.tagName === "INPUT" ||
+ target.tagName === "TEXTAREA" ||
+ target.isContentEditable;
+
+ switch (e.key) {
+ case "ArrowUp":
+ if (!isInputActive) {
+ e.preventDefault();
+ navigate(-1, 0);
+ }
+ break;
+ case "ArrowDown":
+ if (!isInputActive) {
+ e.preventDefault();
+ navigate(1, 0);
+ }
+ break;
+ case "ArrowLeft":
+ if (!isInputActive) {
+ e.preventDefault();
+ navigate(0, -1);
+ }
+ break;
+ case "ArrowRight":
+ if (!isInputActive) {
+ e.preventDefault();
+ navigate(0, 1);
+ }
+ break;
+ case "Tab":
+ e.preventDefault();
+ navigate(0, e.shiftKey ? -1 : 1);
+ break;
+ case "Escape":
+ e.preventDefault();
+ setEditingCell(null);
+ break;
+ }
+ },
+ [editingCell, navigate, setEditingCell],
+ );
+
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+
+ container.addEventListener("keydown", handleKeyDown);
+ return () => container.removeEventListener("keydown", handleKeyDown);
+ }, [containerRef, handleKeyDown]);
+}
diff --git a/apps/client/src/ee/base/hooks/use-horizontal-scroll-sync.ts b/apps/client/src/ee/base/hooks/use-horizontal-scroll-sync.ts
new file mode 100644
index 000000000..e559c23c5
--- /dev/null
+++ b/apps/client/src/ee/base/hooks/use-horizontal-scroll-sync.ts
@@ -0,0 +1,53 @@
+import { type RefObject, useEffect } from "react";
+
+// Keeps the header's scrollLeft in lockstep with the body's. Also converts
+// vertical wheel events on the header into horizontal scroll on the body.
+// Generic so callers can pass useRef(null) without a cast.
+export function useHorizontalScrollSync<
+ TBody extends HTMLElement,
+ THeader extends HTMLElement,
+>(
+ bodyRef: RefObject,
+ headerRef: RefObject,
+): void {
+ useEffect(() => {
+ const body = bodyRef.current;
+ const header = headerRef.current;
+ if (!body || !header) return;
+
+ let rafId = 0;
+
+ const sync = () => {
+ rafId = 0;
+ header.scrollLeft = body.scrollLeft;
+ };
+
+ const onBodyScroll = () => {
+ if (rafId !== 0) return;
+ rafId = requestAnimationFrame(sync);
+ };
+
+ const onHeaderWheel = (e: WheelEvent) => {
+ // Trackpad horizontal-dominant gestures deliver deltaX; let those
+ // flow naturally. Convert vertical ticks into horizontal pan.
+ if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return;
+ if (e.deltaY === 0) return;
+ // preventDefault suppresses the default vertical scroll (requires
+ // non-passive listener, configured below).
+ e.preventDefault();
+ body.scrollLeft += e.deltaY;
+ };
+
+ body.addEventListener("scroll", onBodyScroll, { passive: true });
+ header.addEventListener("wheel", onHeaderWheel, { passive: false });
+
+ // Initial sync in case the body is already scrolled when the hook mounts.
+ header.scrollLeft = body.scrollLeft;
+
+ return () => {
+ body.removeEventListener("scroll", onBodyScroll);
+ header.removeEventListener("wheel", onHeaderWheel);
+ if (rafId !== 0) cancelAnimationFrame(rafId);
+ };
+ }, [bodyRef, headerRef]);
+}
diff --git a/apps/client/src/ee/base/hooks/use-kanban-autoscroll.ts b/apps/client/src/ee/base/hooks/use-kanban-autoscroll.ts
new file mode 100644
index 000000000..c067fc301
--- /dev/null
+++ b/apps/client/src/ee/base/hooks/use-kanban-autoscroll.ts
@@ -0,0 +1,70 @@
+import { type RefObject, useEffect } from "react";
+import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
+import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
+import { unsafeOverflowAutoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/unsafe-overflow/element";
+import { KANBAN_CARD_DRAG_TYPE, KANBAN_COLUMN_DRAG_TYPE } from "@/ee/base/types/base.types";
+
+const HEADER_BAND_REACH_PX = 60;
+const EDGE_OUTWARD_REACH_PX = 80;
+
+export function useKanbanBoardAutoScroll(
+ boardRef: RefObject,
+ pageId: string,
+): void {
+ useEffect(() => {
+ const element = boardRef.current;
+ if (!element) return;
+
+ const canScroll = ({ source }: { source: { data: Record } }) =>
+ (source.data?.type === KANBAN_CARD_DRAG_TYPE ||
+ source.data?.type === KANBAN_COLUMN_DRAG_TYPE) &&
+ source.data?.pageId === pageId;
+
+ return combine(
+ autoScrollForElements({
+ element,
+ canScroll,
+ getAllowedAxis: () => "horizontal" as const,
+ }),
+ unsafeOverflowAutoScrollForElements({
+ element,
+ canScroll,
+ getAllowedAxis: () => "horizontal" as const,
+ getOverflow: () => ({
+ forLeftEdge: { left: EDGE_OUTWARD_REACH_PX, top: HEADER_BAND_REACH_PX },
+ forRightEdge: { right: EDGE_OUTWARD_REACH_PX, top: HEADER_BAND_REACH_PX },
+ }),
+ }),
+ );
+ }, [boardRef, pageId]);
+}
+
+export function useKanbanColumnAutoScroll(
+ listRef: RefObject,
+ pageId: string,
+): void {
+ useEffect(() => {
+ const element = listRef.current;
+ if (!element) return;
+
+ const canScroll = ({ source }: { source: { data: Record } }) =>
+ source.data?.type === KANBAN_CARD_DRAG_TYPE && source.data?.pageId === pageId;
+
+ return combine(
+ autoScrollForElements({
+ element,
+ canScroll,
+ getAllowedAxis: () => "vertical" as const,
+ }),
+ unsafeOverflowAutoScrollForElements({
+ element,
+ canScroll,
+ getAllowedAxis: () => "vertical" as const,
+ getOverflow: () => ({
+ forTopEdge: { top: EDGE_OUTWARD_REACH_PX },
+ forBottomEdge: { bottom: EDGE_OUTWARD_REACH_PX },
+ }),
+ }),
+ );
+ }, [listRef, pageId]);
+}
diff --git a/apps/client/src/ee/base/hooks/use-kanban-card-dnd.ts b/apps/client/src/ee/base/hooks/use-kanban-card-dnd.ts
new file mode 100644
index 000000000..26a04e1dd
--- /dev/null
+++ b/apps/client/src/ee/base/hooks/use-kanban-card-dnd.ts
@@ -0,0 +1,80 @@
+import { type RefObject, useEffect, useState } from "react";
+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 { KANBAN_CARD_DRAG_TYPE } from "@/ee/base/types/base.types";
+import classes from "@/ee/base/styles/kanban.module.css";
+
+export function useKanbanCardDnd({
+ cardRef,
+ rowId,
+ columnKey,
+ pageId,
+}: {
+ cardRef: RefObject;
+ rowId: string;
+ columnKey: string;
+ pageId: string;
+}): { closestEdge: Edge | null; isDragging: boolean } {
+ const [isDragging, setIsDragging] = useState(false);
+ const [closestEdge, setClosestEdge] = useState(null);
+
+ useEffect(() => {
+ const cardEl = cardRef.current;
+ if (!cardEl) return;
+ return combine(
+ draggable({
+ element: cardEl,
+ getInitialData: () => ({
+ type: KANBAN_CARD_DRAG_TYPE,
+ rowId,
+ columnKey,
+ pageId,
+ }),
+ onGenerateDragPreview: ({ nativeSetDragImage }) => {
+ const width = cardEl.getBoundingClientRect().width;
+ setCustomNativeDragPreview({
+ nativeSetDragImage,
+ getOffset: pointerOutsideOfPreview({ x: "12px", y: "8px" }),
+ render: ({ container }) => {
+ const card = document.createElement("div");
+ card.className = classes.cardDragPreview;
+ card.style.width = `${width}px`;
+ const clone = cardEl.cloneNode(true) as HTMLElement;
+ clone.style.opacity = "1";
+ card.appendChild(clone);
+ container.appendChild(card);
+ },
+ });
+ },
+ onDragStart: () => setIsDragging(true),
+ onDrop: () => setIsDragging(false),
+ }),
+ dropTargetForElements({
+ element: cardEl,
+ canDrop: ({ source }) =>
+ source.data.type === KANBAN_CARD_DRAG_TYPE &&
+ source.data.pageId === pageId,
+ getData: ({ input, element }) =>
+ attachClosestEdge(
+ { rowId, columnKey },
+ { input, element, allowedEdges: ["top", "bottom"] },
+ ),
+ onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)),
+ onDragLeave: () => setClosestEdge(null),
+ onDrop: () => setClosestEdge(null),
+ }),
+ );
+ }, [cardRef, rowId, columnKey, pageId]);
+
+ return { closestEdge, isDragging };
+}
diff --git a/apps/client/src/ee/base/hooks/use-kanban-card-drop.ts b/apps/client/src/ee/base/hooks/use-kanban-card-drop.ts
new file mode 100644
index 000000000..ffb1865f9
--- /dev/null
+++ b/apps/client/src/ee/base/hooks/use-kanban-card-drop.ts
@@ -0,0 +1,34 @@
+import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
+import { NO_VALUE_CHOICE_ID, type IBaseRow } from "@/ee/base/types/base.types";
+
+export function resolveCardDrop(args: {
+ draggedRowId: string;
+ targetRowId: string | null;
+ edge: "top" | "bottom" | null;
+ targetColumnKey: string;
+ sourceColumnKey: string;
+ targetColumnRows: IBaseRow[];
+}): { columnChanged: boolean; destChoiceValue: string | null; position: string } | null {
+ const { draggedRowId, targetRowId, edge, targetColumnKey, sourceColumnKey, targetColumnRows } = args;
+ const columnChanged = sourceColumnKey !== targetColumnKey;
+ if (!columnChanged && draggedRowId === targetRowId) return null;
+ const destChoiceValue = targetColumnKey === NO_VALUE_CHOICE_ID ? null : targetColumnKey;
+ const rows = targetColumnRows.filter((r) => r.id !== draggedRowId);
+ let position: string;
+ if (!targetRowId || edge === null) {
+ const last = rows[rows.length - 1];
+ position = generateJitteredKeyBetween(last?.position ?? null, null);
+ } else {
+ const idx = rows.findIndex((r) => r.id === targetRowId);
+ if (idx === -1) {
+ const last = rows[rows.length - 1];
+ position = generateJitteredKeyBetween(last?.position ?? null, null);
+ } else {
+ const neighbor = edge === "top" ? idx - 1 : idx + 1;
+ const lower = edge === "top" ? rows[neighbor]?.position ?? null : rows[idx].position;
+ const upper = edge === "top" ? rows[idx].position : rows[neighbor]?.position ?? null;
+ position = generateJitteredKeyBetween(lower, upper);
+ }
+ }
+ return { columnChanged, destChoiceValue, position };
+}
diff --git a/apps/client/src/ee/base/hooks/use-kanban-column-dnd.ts b/apps/client/src/ee/base/hooks/use-kanban-column-dnd.ts
new file mode 100644
index 000000000..2c1cd1fd0
--- /dev/null
+++ b/apps/client/src/ee/base/hooks/use-kanban-column-dnd.ts
@@ -0,0 +1,63 @@
+import { type RefObject, useEffect, useState } from "react";
+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 { KANBAN_COLUMN_DRAG_TYPE } from "@/ee/base/types/base.types";
+
+export function useKanbanColumnDnd({
+ headerRef,
+ handleRef,
+ columnKey,
+ pageId,
+}: {
+ headerRef: RefObject;
+ handleRef: RefObject;
+ columnKey: string;
+ pageId: string;
+}): { closestEdge: Edge | null; isDragging: boolean } {
+ const [isDragging, setIsDragging] = useState(false);
+ const [closestEdge, setClosestEdge] = useState(null);
+
+ useEffect(() => {
+ const headerEl = headerRef.current;
+ const handleEl = handleRef.current;
+ if (!headerEl || !handleEl) return;
+ return combine(
+ draggable({
+ element: headerEl,
+ dragHandle: handleEl,
+ getInitialData: () => ({
+ type: KANBAN_COLUMN_DRAG_TYPE,
+ columnKey,
+ pageId,
+ }),
+ onDragStart: () => setIsDragging(true),
+ onDrop: () => setIsDragging(false),
+ }),
+ dropTargetForElements({
+ element: headerEl,
+ canDrop: ({ source }) =>
+ source.data.type === KANBAN_COLUMN_DRAG_TYPE &&
+ source.data.pageId === pageId &&
+ source.data.columnKey !== columnKey,
+ getData: ({ input, element }) =>
+ attachClosestEdge(
+ { columnKey },
+ { input, element, allowedEdges: ["left", "right"] },
+ ),
+ onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)),
+ onDragLeave: () => setClosestEdge(null),
+ onDrop: () => setClosestEdge(null),
+ }),
+ );
+ }, [headerRef, handleRef, columnKey, pageId]);
+
+ return { closestEdge, isDragging };
+}
diff --git a/apps/client/src/ee/base/hooks/use-kanban-columns.ts b/apps/client/src/ee/base/hooks/use-kanban-columns.ts
new file mode 100644
index 000000000..493da5ab8
--- /dev/null
+++ b/apps/client/src/ee/base/hooks/use-kanban-columns.ts
@@ -0,0 +1,52 @@
+import { useMemo } from "react";
+import { IBase, IBaseView, KanbanColumn, NO_VALUE_CHOICE_ID, SelectTypeOptions } from "@/ee/base/types/base.types";
+
+export type KanbanGroup = KanbanColumn & { hidden: boolean };
+
+export function useKanbanColumns(
+ base: IBase | undefined,
+ view: IBaseView | undefined,
+): {
+ groupByPropertyId: string | undefined;
+ columns: KanbanColumn[];
+ allGroups: KanbanGroup[];
+ hasValidGroupBy: boolean;
+} {
+ return useMemo(() => {
+ const groupByPropertyId = view?.config?.groupByPropertyId;
+ const prop = groupByPropertyId ? base?.properties.find((p) => p.id === groupByPropertyId) : undefined;
+ const groupable = prop && (prop.type === "select" || prop.type === "status");
+
+ if (!groupable || !prop || !view) {
+ return { groupByPropertyId, columns: [], allGroups: [], hasValidGroupBy: false };
+ }
+
+ const typeOptions = prop.typeOptions as SelectTypeOptions;
+ const choices = typeOptions?.choices ?? [];
+ const choiceMap = new Map(choices.map((c) => [c.id, c]));
+ const validKeys = new Set([NO_VALUE_CHOICE_ID, ...choices.map((c) => c.id)]);
+
+ const config = view.config;
+ const configChoiceOrder: string[] = config.choiceOrder?.length
+ ? config.choiceOrder.filter((k) => validKeys.has(k))
+ : [...(typeOptions?.choiceOrder ?? choices.map((c) => c.id)), NO_VALUE_CHOICE_ID];
+
+ const inOrder = new Set(configChoiceOrder);
+ const baseOrder = [
+ ...configChoiceOrder,
+ ...choices.map((c) => c.id).filter((id) => !inOrder.has(id)),
+ ];
+
+ const hidden = new Set(config.hiddenChoiceIds ?? []);
+ const allGroups: KanbanGroup[] = baseOrder.map((k) => {
+ if (k === NO_VALUE_CHOICE_ID) {
+ return { key: k, name: "No value", color: undefined, isNoValue: true, hidden: hidden.has(k) };
+ }
+ const choice = choiceMap.get(k);
+ return { key: k, name: choice?.name ?? k, color: choice?.color, isNoValue: false, hidden: hidden.has(k) };
+ });
+ const columns: KanbanColumn[] = allGroups.filter((g) => !g.hidden);
+
+ return { groupByPropertyId, columns, allGroups, hasValidGroupBy: true };
+ }, [base, view]);
+}
diff --git a/apps/client/src/ee/base/hooks/use-list-keyboard-nav.ts b/apps/client/src/ee/base/hooks/use-list-keyboard-nav.ts
new file mode 100644
index 000000000..f25d8b4a8
--- /dev/null
+++ b/apps/client/src/ee/base/hooks/use-list-keyboard-nav.ts
@@ -0,0 +1,62 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+
+type UseListKeyboardNavResult = {
+ activeIndex: number;
+ setActiveIndex: (idx: number) => void;
+ handleNavKey: (e: React.KeyboardEvent) => boolean;
+ setOptionRef: (idx: number) => (el: HTMLElement | null) => void;
+};
+
+export function useListKeyboardNav(
+ itemCount: number,
+ resetDeps: ReadonlyArray,
+): UseListKeyboardNavResult {
+ const [activeIndex, setActiveIndex] = useState(-1);
+ const optionRefs = useRef>([]);
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ useEffect(() => { setActiveIndex(-1); }, resetDeps);
+
+ useEffect(() => {
+ if (activeIndex < 0) return;
+ const el = optionRefs.current[activeIndex];
+ if (el) el.scrollIntoView({ block: "nearest" });
+ }, [activeIndex]);
+
+ const setOptionRef = useCallback(
+ (idx: number) => (el: HTMLElement | null) => {
+ optionRefs.current[idx] = el;
+ },
+ [],
+ );
+
+ const handleNavKey = useCallback(
+ (e: React.KeyboardEvent): boolean => {
+ if (itemCount === 0) return false;
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ setActiveIndex((idx) => (idx < itemCount - 1 ? idx + 1 : 0));
+ return true;
+ }
+ if (e.key === "ArrowUp") {
+ e.preventDefault();
+ setActiveIndex((idx) => (idx <= 0 ? itemCount - 1 : idx - 1));
+ return true;
+ }
+ if (e.key === "Home") {
+ e.preventDefault();
+ setActiveIndex(0);
+ return true;
+ }
+ if (e.key === "End") {
+ e.preventDefault();
+ setActiveIndex(itemCount - 1);
+ return true;
+ }
+ return false;
+ },
+ [itemCount],
+ );
+
+ return { activeIndex, setActiveIndex, handleNavKey, setOptionRef };
+}
diff --git a/apps/client/src/ee/base/hooks/use-person-search.ts b/apps/client/src/ee/base/hooks/use-person-search.ts
new file mode 100644
index 000000000..d08ced710
--- /dev/null
+++ b/apps/client/src/ee/base/hooks/use-person-search.ts
@@ -0,0 +1,32 @@
+import { useQuery } from "@tanstack/react-query";
+import { useDebouncedValue } from "@mantine/hooks";
+import { searchSuggestions } from "@/features/search/services/search-service";
+
+export type PersonSuggestion = {
+ id: string;
+ name: string | null;
+ email: string | null;
+ avatarUrl: string | null;
+};
+
+export function usePersonSearch(
+ search: string,
+ enabled: boolean,
+): PersonSuggestion[] {
+ const [debounced] = useDebouncedValue(search, 250);
+ const trimmed = debounced.trim();
+ const { data = [] } = useQuery({
+ queryKey: ["bases", "persons", "search", trimmed],
+ queryFn: async () => {
+ const res = await searchSuggestions({
+ query: trimmed,
+ includeUsers: true,
+ limit: trimmed ? 25 : 10,
+ });
+ return (res.users ?? []) as PersonSuggestion[];
+ },
+ enabled,
+ staleTime: 15_000,
+ });
+ return data;
+}
diff --git a/apps/client/src/ee/base/hooks/use-row-autoscroll.ts b/apps/client/src/ee/base/hooks/use-row-autoscroll.ts
new file mode 100644
index 000000000..b1ce9c4e2
--- /dev/null
+++ b/apps/client/src/ee/base/hooks/use-row-autoscroll.ts
@@ -0,0 +1,28 @@
+import { useEffect } from "react";
+import {
+ autoScrollForElements,
+ autoScrollWindowForElements,
+} from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
+import { ROW_DRAG_TYPE } from "@/ee/base/components/grid/grid-row";
+
+export function useRowAutoScroll(
+ scrollElement: HTMLElement | Window | null,
+ pageId: string,
+): void {
+ useEffect(() => {
+ if (!scrollElement) return;
+ if (scrollElement === window) {
+ return autoScrollWindowForElements({
+ canScroll: ({ source }) =>
+ source.data?.type === ROW_DRAG_TYPE && source.data?.pageId === pageId,
+ getAllowedAxis: () => "vertical" as const,
+ });
+ }
+ return autoScrollForElements({
+ element: scrollElement as HTMLElement,
+ canScroll: ({ source }) =>
+ source.data?.type === ROW_DRAG_TYPE && source.data?.pageId === pageId,
+ getAllowedAxis: () => "vertical" as const,
+ });
+ }, [scrollElement, pageId]);
+}
diff --git a/apps/client/src/ee/base/hooks/use-row-detail-modal.ts b/apps/client/src/ee/base/hooks/use-row-detail-modal.ts
new file mode 100644
index 000000000..a42e2ea41
--- /dev/null
+++ b/apps/client/src/ee/base/hooks/use-row-detail-modal.ts
@@ -0,0 +1,33 @@
+import { useCallback } from "react";
+import { useSearchParams } from "react-router-dom";
+
+const PARAM = "row";
+const BASE_PARAM = "rowBase";
+
+export function useRowDetailModal(baseId: string) {
+ const [searchParams, setSearchParams] = useSearchParams();
+ const rowParam = searchParams.get(PARAM);
+ const openRowId =
+ rowParam && searchParams.get(BASE_PARAM) === baseId ? rowParam : null;
+
+ const openRow = useCallback(
+ (rowId: string, options?: { replace?: boolean }) => {
+ const next = new URLSearchParams(searchParams);
+ next.set(PARAM, rowId);
+ next.set(BASE_PARAM, baseId);
+ // Prev/next inside the modal replaces the entry so Back leaves the
+ // modal instead of replaying every visited record.
+ setSearchParams(next, { replace: options?.replace ?? false });
+ },
+ [searchParams, setSearchParams, baseId],
+ );
+
+ const closeRow = useCallback(() => {
+ const next = new URLSearchParams(searchParams);
+ next.delete(PARAM);
+ next.delete(BASE_PARAM);
+ setSearchParams(next, { replace: false });
+ }, [searchParams, setSearchParams]);
+
+ return { openRowId, openRow, closeRow };
+}
diff --git a/apps/client/src/ee/base/hooks/use-row-selection.ts b/apps/client/src/ee/base/hooks/use-row-selection.ts
new file mode 100644
index 000000000..e5d9d0bf7
--- /dev/null
+++ b/apps/client/src/ee/base/hooks/use-row-selection.ts
@@ -0,0 +1,101 @@
+import { useCallback } from "react";
+import { useAtom } from "jotai";
+import {
+ selectedRowIdsAtomFamily,
+ lastToggledRowIndexAtomFamily,
+} from "@/ee/base/atoms/base-atoms";
+
+type ToggleOpts = {
+ shiftKey: boolean;
+ rowIndex: number;
+ orderedRowIds: string[];
+};
+
+export function useRowSelection(pageId: string) {
+ const [selectedIds, setSelectedIds] = useAtom(
+ selectedRowIdsAtomFamily(pageId),
+ ) as unknown as [
+ Set,
+ (val: Set | ((prev: Set) => Set)) => void,
+ ];
+ const [lastToggledIndex, setLastToggledIndex] = useAtom(
+ lastToggledRowIndexAtomFamily(pageId),
+ ) as unknown as [number | null, (val: number | null) => void];
+
+ const isSelected = useCallback(
+ (rowId: string) => selectedIds.has(rowId),
+ [selectedIds],
+ );
+
+ const toggle = useCallback(
+ (rowId: string, opts: ToggleOpts) => {
+ const { shiftKey, rowIndex, orderedRowIds } = opts;
+ const next = new Set(selectedIds);
+
+ if (shiftKey && lastToggledIndex !== null && lastToggledIndex !== rowIndex) {
+ const start = Math.min(lastToggledIndex, rowIndex);
+ const end = Math.max(lastToggledIndex, rowIndex);
+ const anchorId = orderedRowIds[lastToggledIndex];
+ const turnOn = anchorId ? next.has(anchorId) : true;
+ for (let i = start; i <= end; i += 1) {
+ const id = orderedRowIds[i];
+ if (!id) continue;
+ if (turnOn) next.add(id);
+ else next.delete(id);
+ }
+ } else {
+ if (next.has(rowId)) next.delete(rowId);
+ else next.add(rowId);
+ }
+
+ setSelectedIds(next);
+ setLastToggledIndex(rowIndex);
+ },
+ [selectedIds, lastToggledIndex, setSelectedIds, setLastToggledIndex],
+ );
+
+ const toggleAll = useCallback(
+ (loadedRowIds: string[]) => {
+ if (loadedRowIds.length === 0) return;
+ const allSelected = loadedRowIds.every((id) => selectedIds.has(id));
+ if (allSelected) {
+ setSelectedIds(new Set());
+ } else {
+ setSelectedIds(new Set(loadedRowIds));
+ }
+ setLastToggledIndex(null);
+ },
+ [selectedIds, setSelectedIds, setLastToggledIndex],
+ );
+
+ const clear = useCallback(() => {
+ setSelectedIds(new Set());
+ setLastToggledIndex(null);
+ }, [setSelectedIds, setLastToggledIndex]);
+
+ const removeIds = useCallback(
+ (rowIds: string[]) => {
+ if (rowIds.length === 0) return;
+ setSelectedIds((prev) => {
+ if (prev.size === 0) return prev;
+ let changed = false;
+ const next = new Set(prev);
+ for (const id of rowIds) {
+ if (next.delete(id)) changed = true;
+ }
+ return changed ? next : prev;
+ });
+ },
+ [setSelectedIds],
+ );
+
+ return {
+ selectedIds,
+ selectionCount: selectedIds.size,
+ isSelected,
+ toggle,
+ toggleAll,
+ clear,
+ removeIds,
+ };
+}
diff --git a/apps/client/src/ee/base/hooks/use-view-draft.ts b/apps/client/src/ee/base/hooks/use-view-draft.ts
new file mode 100644
index 000000000..1f94372fc
--- /dev/null
+++ b/apps/client/src/ee/base/hooks/use-view-draft.ts
@@ -0,0 +1,167 @@
+import { useCallback, useMemo } from "react";
+import { useAtom } from "jotai";
+import { RESET } from "jotai/utils";
+import {
+ BaseViewDraft,
+ FilterGroup,
+ ViewConfig,
+ ViewConfigPatch,
+ ViewSortConfig,
+} from "@/ee/base/types/base.types";
+import { viewDraftAtomFamily } from "@/ee/base/atoms/view-draft-atom";
+
+export type UseViewDraftArgs = {
+ userId: string | undefined;
+ pageId: string | undefined;
+ viewId: string | undefined;
+ baselineFilter: FilterGroup | undefined;
+ baselineSorts: ViewSortConfig[] | undefined;
+};
+
+export type ViewDraftState = {
+ draft: BaseViewDraft | null;
+ effectiveFilter: FilterGroup | undefined;
+ effectiveSorts: ViewSortConfig[] | undefined;
+ isDirty: boolean;
+ setFilter: (filter: FilterGroup | undefined) => void;
+ setSorts: (sorts: ViewSortConfig[] | undefined) => void;
+ reset: () => void;
+ buildPromotedConfig: (baseline: ViewConfig) => ViewConfigPatch;
+};
+
+// JSON-stringify equality suffices for FilterGroup and ViewSortConfig[]: both
+// are pure data trees with stable insertion order, so the same graph always
+// serializes identically. Avoids a deep-equal dependency for two simple types.
+function filterEq(a: FilterGroup | undefined, b: FilterGroup | undefined) {
+ return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
+}
+function sortsEq(
+ a: ViewSortConfig[] | undefined,
+ b: ViewSortConfig[] | undefined,
+) {
+ return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
+}
+
+export function useViewDraft(args: UseViewDraftArgs): ViewDraftState {
+ const { userId, pageId, viewId, baselineFilter, baselineSorts } = args;
+ const ready = !!(userId && pageId && viewId);
+
+ // Always mount with a stable key so hook order is consistent. Not read/written when not ready.
+ const atomKey = useMemo(
+ () => ({
+ userId: userId ?? "",
+ pageId: pageId ?? "",
+ viewId: viewId ?? "",
+ }),
+ [userId, pageId, viewId],
+ );
+ const [storedDraft, setDraft] = useAtom(viewDraftAtomFamily(atomKey));
+
+ const draft = ready ? storedDraft : null;
+
+ const setFilter = useCallback(
+ (next: FilterGroup | undefined) => {
+ if (!ready) return;
+ const current = storedDraft ?? null;
+ // If a baseline filter exists, clearing to undefined would fall back to it
+ // in effectiveFilter. Persist an empty AND-group to explicitly override it.
+ const mergedFilter =
+ next === undefined && baselineFilter !== undefined
+ ? ({ op: "and", children: [] } as FilterGroup)
+ : next;
+ const mergedSorts = current?.sorts;
+ if (mergedFilter === undefined && (mergedSorts === undefined || mergedSorts === null)) {
+ setDraft(RESET);
+ return;
+ }
+ setDraft({
+ filter: mergedFilter,
+ sorts: mergedSorts,
+ updatedAt: new Date().toISOString(),
+ });
+ },
+ [ready, storedDraft, setDraft, baselineFilter],
+ );
+
+ const setSorts = useCallback(
+ (next: ViewSortConfig[] | undefined) => {
+ if (!ready) return;
+ const current = storedDraft ?? null;
+ const mergedFilter = current?.filter;
+ // If baseline sorts exist, clearing to undefined would fall back to them.
+ // Persist an empty array to explicitly override with no sorts.
+ const mergedSorts =
+ next === undefined && baselineSorts !== undefined && baselineSorts.length > 0
+ ? []
+ : next;
+ if (mergedFilter === undefined && (mergedSorts === undefined || mergedSorts === null)) {
+ setDraft(RESET);
+ return;
+ }
+ setDraft({
+ filter: mergedFilter,
+ sorts: mergedSorts,
+ updatedAt: new Date().toISOString(),
+ });
+ },
+ [ready, storedDraft, setDraft, baselineSorts],
+ );
+
+ const reset = useCallback(() => {
+ if (!ready) return;
+ setDraft(RESET);
+ }, [ready, setDraft]);
+
+ const effectiveFilter = useMemo(
+ () => (draft?.filter !== undefined ? draft.filter : baselineFilter),
+ [draft?.filter, baselineFilter],
+ );
+ const effectiveSorts = useMemo(
+ () => (draft?.sorts !== undefined ? draft.sorts : baselineSorts),
+ [draft?.sorts, baselineSorts],
+ );
+
+ const isDirty = useMemo(() => {
+ if (!draft) return false;
+ const filterDirty =
+ draft.filter !== undefined && !filterEq(draft.filter, baselineFilter);
+ const sortsDirty =
+ draft.sorts !== undefined && !sortsEq(draft.sorts, baselineSorts);
+ return filterDirty || sortsDirty;
+ }, [draft, baselineFilter, baselineSorts]);
+
+ const buildPromotedConfig = useCallback(
+ (baseline: ViewConfig): ViewConfigPatch => ({
+ filter: draft?.filter ?? baseline.filter ?? null,
+ sorts: draft?.sorts ?? baseline.sorts ?? null,
+ }),
+ [draft],
+ );
+
+ if (!ready) {
+ return {
+ draft: null,
+ effectiveFilter: baselineFilter,
+ effectiveSorts: baselineSorts,
+ isDirty: false,
+ setFilter: () => {},
+ setSorts: () => {},
+ reset: () => {},
+ buildPromotedConfig: (baseline) => ({
+ filter: baseline.filter ?? null,
+ sorts: baseline.sorts ?? null,
+ }),
+ };
+ }
+
+ return {
+ draft,
+ effectiveFilter,
+ effectiveSorts,
+ isDirty,
+ setFilter,
+ setSorts,
+ reset,
+ buildPromotedConfig,
+ };
+}
diff --git a/apps/client/src/ee/base/pages/base-page.tsx b/apps/client/src/ee/base/pages/base-page.tsx
new file mode 100644
index 000000000..7f315ee7f
--- /dev/null
+++ b/apps/client/src/ee/base/pages/base-page.tsx
@@ -0,0 +1,35 @@
+import { useParams } from "react-router-dom";
+import { Container, Title, Text, Stack } from "@mantine/core";
+import { BaseView } from "@/ee/base/components/base-view";
+import { useBaseQuery } from "@/ee/base/queries/base-query";
+import { useHasFeature } from "@/ee/hooks/use-feature";
+import { Feature } from "@/ee/features";
+
+export default function BasePage() {
+ const { pageId } = useParams<{ pageId: string }>();
+ const hasBases = useHasFeature(Feature.BASES);
+ const { data: base } = useBaseQuery(pageId ?? "");
+
+ if (!pageId) {
+ return (
+
+ No base ID provided
+
+ );
+ }
+
+ return (
+
+ {base && (
+
+ {base.icon ? `${base.icon} ` : ""}{base.name}
+
+ )}
+
+
+ );
+}
diff --git a/apps/client/src/ee/base/property-types/property-type.descriptor.ts b/apps/client/src/ee/base/property-types/property-type.descriptor.ts
new file mode 100644
index 000000000..28e9fc275
--- /dev/null
+++ b/apps/client/src/ee/base/property-types/property-type.descriptor.ts
@@ -0,0 +1,41 @@
+import type React from 'react';
+import type { IconLetterT } from '@tabler/icons-react';
+import type {
+ BasePropertyType,
+ IBaseProperty,
+ IBaseRow,
+ TypeOptions,
+} from '@/ee/base/types/base.types';
+
+export type CellComponentProps = {
+ value: unknown;
+ property: IBaseProperty;
+ rowId: string;
+ isEditing: boolean;
+ /** When true the cell must not write; reveal content read-only. */
+ readOnly?: boolean;
+ onCommit: (value: unknown) => void;
+ onValueChange: (value: unknown) => void;
+ onCancel: () => void;
+};
+
+export type FilterInputKind =
+ | 'choices'
+ | 'number'
+ | 'boolean'
+ | 'text'
+ | 'person'
+ | 'date';
+
+export type ClientPropertyTypeDescriptor = {
+ type: BasePropertyType;
+ cellComponent: React.ComponentType;
+ icon: typeof IconLetterT;
+ labelKey: string;
+ filterOperators: string[];
+ filterInput: FilterInputKind;
+ isSystem: boolean;
+ hasOptions: boolean;
+ systemAccessor?: (row: IBaseRow) => unknown;
+ defaultTypeOptions?: () => TypeOptions;
+};
diff --git a/apps/client/src/ee/base/property-types/property-type.registry.tsx b/apps/client/src/ee/base/property-types/property-type.registry.tsx
new file mode 100644
index 000000000..7536177d5
--- /dev/null
+++ b/apps/client/src/ee/base/property-types/property-type.registry.tsx
@@ -0,0 +1,253 @@
+import {
+ IconLetterT,
+ IconAlignLeft,
+ IconHash,
+ IconCircleDot,
+ IconProgressCheck,
+ IconTags,
+ IconCalendar,
+ IconUser,
+ IconPaperclip,
+ IconFileDescription,
+ IconCheckbox,
+ IconLink,
+ IconMail,
+ IconClockPlus,
+ IconClockEdit,
+ IconUserEdit,
+ IconMathFunction,
+} from "@tabler/icons-react";
+import type {
+ BasePropertyType,
+ TypeOptions,
+} from "@/ee/base/types/base.types";
+import { CellText } from "@/ee/base/components/cells/cell-text";
+import { CellLongText } from "@/ee/base/components/cells/cell-long-text";
+import { CellNumber } from "@/ee/base/components/cells/cell-number";
+import { CellSelect } from "@/ee/base/components/cells/cell-select";
+import { CellStatus } from "@/ee/base/components/cells/cell-status";
+import { CellMultiSelect } from "@/ee/base/components/cells/cell-multi-select";
+import { CellDate } from "@/ee/base/components/cells/cell-date";
+import { CellCheckbox } from "@/ee/base/components/cells/cell-checkbox";
+import { CellUrl } from "@/ee/base/components/cells/cell-url";
+import { CellEmail } from "@/ee/base/components/cells/cell-email";
+import { CellPerson } from "@/ee/base/components/cells/cell-person";
+import { CellFile } from "@/ee/base/components/cells/cell-file";
+import { CellPage } from "@/ee/base/components/cells/cell-page";
+import { CellCreatedAt } from "@/ee/base/components/cells/cell-created-at";
+import { CellLastEditedAt } from "@/ee/base/components/cells/cell-last-edited-at";
+import { CellLastEditedBy } from "@/ee/base/components/cells/cell-last-edited-by";
+import { CellFormula } from "@/ee/base/components/cells/cell-formula";
+import { defaultStatusChoices } from "@/ee/base/components/property/choice-editor";
+import type { ClientPropertyTypeDescriptor } from "./property-type.descriptor";
+
+export const PROPERTY_TYPE_REGISTRY: Record<
+ BasePropertyType,
+ ClientPropertyTypeDescriptor
+> = {
+ text: {
+ type: "text",
+ cellComponent: CellText,
+ icon: IconLetterT,
+ labelKey: "Text",
+ filterOperators: ["eq", "neq", "contains", "ncontains", "isEmpty", "isNotEmpty"],
+ filterInput: "text",
+ isSystem: false,
+ hasOptions: true,
+ },
+ longText: {
+ type: "longText",
+ cellComponent: CellLongText,
+ icon: IconAlignLeft,
+ labelKey: "Long text",
+ filterOperators: ["eq", "neq", "contains", "ncontains", "isEmpty", "isNotEmpty"],
+ filterInput: "text",
+ isSystem: false,
+ hasOptions: true,
+ },
+ number: {
+ type: "number",
+ cellComponent: CellNumber,
+ icon: IconHash,
+ labelKey: "Number",
+ filterOperators: ["eq", "neq", "gt", "lt", "isEmpty", "isNotEmpty"],
+ filterInput: "number",
+ isSystem: false,
+ hasOptions: true,
+ },
+ select: {
+ type: "select",
+ cellComponent: CellSelect,
+ icon: IconCircleDot,
+ labelKey: "Select",
+ filterOperators: ["eq", "neq", "any", "none", "isEmpty", "isNotEmpty"],
+ filterInput: "choices",
+ isSystem: false,
+ hasOptions: true,
+ },
+ status: {
+ type: "status",
+ cellComponent: CellStatus,
+ icon: IconProgressCheck,
+ labelKey: "Status",
+ filterOperators: ["eq", "neq", "any", "none", "isEmpty", "isNotEmpty"],
+ filterInput: "choices",
+ isSystem: false,
+ hasOptions: true,
+ defaultTypeOptions: () => {
+ const choices = defaultStatusChoices();
+ return { choices, choiceOrder: choices.map((c) => c.id) };
+ },
+ },
+ multiSelect: {
+ type: "multiSelect",
+ cellComponent: CellMultiSelect,
+ icon: IconTags,
+ labelKey: "Multi-select",
+ filterOperators: ["any", "none", "isEmpty", "isNotEmpty"],
+ filterInput: "choices",
+ isSystem: false,
+ hasOptions: true,
+ },
+ date: {
+ type: "date",
+ cellComponent: CellDate,
+ icon: IconCalendar,
+ labelKey: "Date",
+ filterOperators: ["eq", "before", "after", "onOrBefore", "onOrAfter", "isWithin", "isEmpty", "isNotEmpty"],
+ filterInput: "date",
+ isSystem: false,
+ hasOptions: true,
+ },
+ person: {
+ type: "person",
+ cellComponent: CellPerson,
+ icon: IconUser,
+ labelKey: "Person",
+ filterOperators: ["eq", "neq", "any", "none", "isEmpty", "isNotEmpty"],
+ filterInput: "person",
+ isSystem: false,
+ hasOptions: true,
+ },
+ file: {
+ type: "file",
+ cellComponent: CellFile,
+ icon: IconPaperclip,
+ labelKey: "File",
+ filterOperators: ["isEmpty", "isNotEmpty"],
+ filterInput: "text",
+ isSystem: false,
+ hasOptions: false,
+ },
+ formula: {
+ type: "formula",
+ cellComponent: CellFormula,
+ icon: IconMathFunction,
+ labelKey: "Formula",
+ filterOperators: ["eq", "neq", "isEmpty", "isNotEmpty"],
+ filterInput: "text",
+ isSystem: true,
+ hasOptions: false,
+ },
+ page: {
+ type: "page",
+ cellComponent: CellPage,
+ icon: IconFileDescription,
+ labelKey: "Page",
+ filterOperators: ["isEmpty", "isNotEmpty"],
+ filterInput: "text",
+ isSystem: false,
+ hasOptions: false,
+ },
+ checkbox: {
+ type: "checkbox",
+ cellComponent: CellCheckbox,
+ icon: IconCheckbox,
+ labelKey: "Checkbox",
+ filterOperators: ["eq", "isEmpty", "isNotEmpty"],
+ filterInput: "boolean",
+ isSystem: false,
+ hasOptions: true,
+ },
+ url: {
+ type: "url",
+ cellComponent: CellUrl,
+ icon: IconLink,
+ labelKey: "URL",
+ filterOperators: ["eq", "neq", "contains", "ncontains", "isEmpty", "isNotEmpty"],
+ filterInput: "text",
+ isSystem: false,
+ hasOptions: true,
+ },
+ email: {
+ type: "email",
+ cellComponent: CellEmail,
+ icon: IconMail,
+ labelKey: "Email",
+ filterOperators: ["eq", "neq", "contains", "ncontains", "isEmpty", "isNotEmpty"],
+ filterInput: "text",
+ isSystem: false,
+ hasOptions: true,
+ },
+ createdAt: {
+ type: "createdAt",
+ cellComponent: CellCreatedAt,
+ icon: IconClockPlus,
+ labelKey: "Created at",
+ filterOperators: ["eq", "before", "after", "onOrBefore", "onOrAfter", "isWithin", "isEmpty", "isNotEmpty"],
+ filterInput: "date",
+ isSystem: true,
+ hasOptions: false,
+ systemAccessor: (row) => row.createdAt,
+ },
+ lastEditedAt: {
+ type: "lastEditedAt",
+ cellComponent: CellLastEditedAt,
+ icon: IconClockEdit,
+ labelKey: "Last edited at",
+ filterOperators: ["eq", "before", "after", "onOrBefore", "onOrAfter", "isWithin", "isEmpty", "isNotEmpty"],
+ filterInput: "date",
+ isSystem: true,
+ hasOptions: false,
+ systemAccessor: (row) => row.updatedAt,
+ },
+ lastEditedBy: {
+ type: "lastEditedBy",
+ cellComponent: CellLastEditedBy,
+ icon: IconUserEdit,
+ labelKey: "Last edited by",
+ filterOperators: ["eq", "neq", "any", "none", "isEmpty", "isNotEmpty"],
+ filterInput: "person",
+ isSystem: true,
+ hasOptions: false,
+ systemAccessor: (row) => row.lastUpdatedById ?? row.creatorId,
+ },
+};
+
+export function getDescriptor(type: string): ClientPropertyTypeDescriptor | undefined {
+ return (PROPERTY_TYPE_REGISTRY as Record)[type];
+}
+
+export const SYSTEM_PROPERTY_TYPES: ReadonlySet = new Set(
+ Object.values(PROPERTY_TYPE_REGISTRY).filter((d) => d.isSystem).map((d) => d.type),
+);
+
+export function isSystemPropertyType(type: string): boolean {
+ return SYSTEM_PROPERTY_TYPES.has(type);
+}
+
+export const DEFAULT_FILTER_OPERATORS = ["eq", "neq", "isEmpty", "isNotEmpty"];
+
+export const PROPERTY_PICKER_ORDER: BasePropertyType[] = [
+ "text", "longText", "number", "select", "status", "multiSelect", "date",
+ "person", "file", "formula", "page", "checkbox", "url", "email",
+ "createdAt", "lastEditedAt", "lastEditedBy",
+];
+
+export function systemAccessorFor(type: string) {
+ return getDescriptor(type)?.systemAccessor;
+}
+
+export function defaultTypeOptionsFor(type: string): TypeOptions {
+ return getDescriptor(type)?.defaultTypeOptions?.() ?? ({} as TypeOptions);
+}
diff --git a/apps/client/src/ee/base/queries/base-page-resolver-query.ts b/apps/client/src/ee/base/queries/base-page-resolver-query.ts
new file mode 100644
index 000000000..691e2997f
--- /dev/null
+++ b/apps/client/src/ee/base/queries/base-page-resolver-query.ts
@@ -0,0 +1,61 @@
+import { useQuery } from "@tanstack/react-query";
+import { useMemo } from "react";
+import { expandPagesBatched } from "./page-expand-loader";
+
+export type ResolvedPage = {
+ id: string;
+ slugId: string;
+ title: string | null;
+ icon: string | null;
+ spaceId: string;
+ space: { id: string; slug: string; name: string } | null;
+};
+
+async function resolvePages(pageIds: string[]): Promise {
+ if (pageIds.length === 0) return [];
+ const map = await expandPagesBatched(pageIds);
+ const out: ResolvedPage[] = [];
+ for (const id of pageIds) {
+ const p = map.get(id);
+ if (p) out.push(p);
+ }
+ return out;
+}
+
+// Stable, sorted, deduped list so the query key is consistent regardless of caller ordering.
+function normalize(ids: (string | null | undefined)[]): string[] {
+ const set = new Set();
+ for (const id of ids) {
+ if (typeof id === "string" && id.length > 0) set.add(id);
+ }
+ return Array.from(set).sort();
+}
+
+export type PageResolution = {
+ // Map lookup states: key absent = not requested, undefined = resolving, null = inaccessible, ResolvedPage = accessible.
+ pages: Map;
+ isLoading: boolean;
+};
+
+export function useResolvedPages(
+ pageIds: (string | null | undefined)[],
+): PageResolution {
+ const normalized = useMemo(() => normalize(pageIds), [pageIds]);
+
+ const { data, isSuccess, isLoading } = useQuery({
+ queryKey: ["bases", "pages", "expand", normalized],
+ queryFn: () => resolvePages(normalized),
+ enabled: normalized.length > 0,
+ staleTime: 30_000,
+ gcTime: 5 * 60_000,
+ });
+
+ const pages = useMemo(() => {
+ const map = new Map();
+ for (const id of normalized) map.set(id, isSuccess ? null : undefined);
+ for (const item of data ?? []) map.set(item.id, item);
+ return map;
+ }, [normalized, data, isSuccess]);
+
+ return { pages, isLoading };
+}
diff --git a/apps/client/src/ee/base/queries/base-property-query.ts b/apps/client/src/ee/base/queries/base-property-query.ts
new file mode 100644
index 000000000..dd0ba85bf
--- /dev/null
+++ b/apps/client/src/ee/base/queries/base-property-query.ts
@@ -0,0 +1,170 @@
+import { InfiniteData, useMutation } from "@tanstack/react-query";
+import {
+ createProperty,
+ updateProperty,
+ deleteProperty,
+ reorderProperty,
+} from "@/ee/base/services/base-service";
+import {
+ IBase,
+ IBaseProperty,
+ IBaseRow,
+ CreatePropertyInput,
+ UpdatePropertyInput,
+ DeletePropertyInput,
+ ReorderPropertyInput,
+ UpdatePropertyResult,
+} from "@/ee/base/types/base.types";
+import { notifications } from "@mantine/notifications";
+import { queryClient } from "@/main";
+import { useTranslation } from "react-i18next";
+import { IPagination } from "@/lib/types";
+
+export function useCreatePropertyMutation() {
+ const { t } = useTranslation();
+ return useMutation({
+ mutationFn: (data) => createProperty(data),
+ onSuccess: (newProperty) => {
+ queryClient.setQueryData(
+ ["bases", newProperty.pageId],
+ (old) => {
+ if (!old) return old;
+ return {
+ ...old,
+ properties: [...old.properties, newProperty],
+ };
+ },
+ );
+ },
+ onError: () => {
+ notifications.show({
+ message: t("Failed to create property"),
+ color: "red",
+ });
+ },
+ });
+}
+
+export function useUpdatePropertyMutation() {
+ const { t } = useTranslation();
+ return useMutation({
+ mutationFn: (data) => updateProperty(data),
+ onSuccess: (result, variables) => {
+ queryClient.setQueryData(
+ ["bases", variables.pageId],
+ (old) => {
+ if (!old) return old;
+ return {
+ ...old,
+ properties: old.properties.map((p) =>
+ p.id === result.property.id ? result.property : p,
+ ),
+ };
+ },
+ );
+
+ if (variables.type && !result.jobId) {
+ queryClient.invalidateQueries({
+ queryKey: ["base-rows", variables.pageId],
+ });
+ }
+ },
+ onError: () => {
+ notifications.show({
+ message: t("Failed to update property"),
+ color: "red",
+ });
+ },
+ });
+}
+
+export function useDeletePropertyMutation() {
+ const { t } = useTranslation();
+ return useMutation({
+ mutationFn: (data) => deleteProperty(data),
+ onSuccess: (_, variables) => {
+ queryClient.setQueryData(
+ ["bases", variables.pageId],
+ (old) => {
+ if (!old) return old;
+ return {
+ ...old,
+ properties: old.properties.filter(
+ (p) => p.id !== variables.propertyId,
+ ),
+ };
+ },
+ );
+
+ queryClient.setQueriesData>>(
+ { queryKey: ["base-rows", variables.pageId] },
+ (old) => {
+ if (!old) return old;
+ return {
+ ...old,
+ pages: old.pages.map((page) => ({
+ ...page,
+ items: page.items.map((row) => {
+ if (!(variables.propertyId in row.cells)) return row;
+ const { [variables.propertyId]: _, ...rest } = row.cells;
+ return { ...row, cells: rest };
+ }),
+ })),
+ };
+ },
+ );
+ },
+ onError: () => {
+ notifications.show({
+ message: t("Failed to delete property"),
+ color: "red",
+ });
+ },
+ });
+}
+
+export function useReorderPropertyMutation() {
+ const { t } = useTranslation();
+ return useMutation({
+ mutationFn: (data) => reorderProperty(data),
+ onMutate: async (variables) => {
+ await queryClient.cancelQueries({
+ queryKey: ["bases", variables.pageId],
+ });
+
+ const previous = queryClient.getQueryData([
+ "bases",
+ variables.pageId,
+ ]);
+
+ queryClient.setQueryData(
+ ["bases", variables.pageId],
+ (old) => {
+ if (!old) return old;
+ return {
+ ...old,
+ properties: old.properties.map((p) =>
+ p.id === variables.propertyId
+ ? { ...p, position: variables.position }
+ : p,
+ ),
+ };
+ },
+ );
+
+ return { previous };
+ },
+ onError: (_, variables, context) => {
+ if (context?.previous) {
+ queryClient.setQueryData(
+ ["bases", variables.pageId],
+ context.previous,
+ );
+ }
+ notifications.show({
+ message: t("Failed to reorder property"),
+ color: "red",
+ });
+ },
+ });
+}
diff --git a/apps/client/src/ee/base/queries/base-query.ts b/apps/client/src/ee/base/queries/base-query.ts
new file mode 100644
index 000000000..d45af1f49
--- /dev/null
+++ b/apps/client/src/ee/base/queries/base-query.ts
@@ -0,0 +1,126 @@
+import {
+ useMutation,
+ useQuery,
+ UseQueryResult,
+} from "@tanstack/react-query";
+import {
+ createBase,
+ getBaseInfo,
+ updateBase,
+ deleteBase,
+ convertPageToBase,
+} from "@/ee/base/services/base-service";
+import {
+ IBase,
+ CreateBaseInput,
+ UpdateBaseInput,
+} from "@/ee/base/types/base.types";
+import { notifications } from "@mantine/notifications";
+import { queryClient } from "@/main";
+import { useTranslation } from "react-i18next";
+import { useAtom } from "jotai";
+import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
+import { treeModel } from "@/features/page/tree/model/tree-model";
+import { SpaceTreeNode } from "@/features/page/tree/types";
+import { socketAtom } from "@/features/websocket/atoms/socket-atom";
+
+export function useBaseQuery(
+ pageId: string | undefined,
+): UseQueryResult {
+ return useQuery({
+ queryKey: ["bases", pageId],
+ queryFn: () => getBaseInfo(pageId!),
+ enabled: !!pageId,
+ staleTime: 5 * 60 * 1000,
+ });
+}
+
+export function useCreateBaseMutation() {
+ const { t } = useTranslation();
+ return useMutation({
+ mutationFn: (data) => createBase(data),
+ onSuccess: (data) => {
+ queryClient.invalidateQueries({
+ queryKey: ["bases", "list", data.spaceId],
+ });
+ },
+ onError: () => {
+ notifications.show({
+ message: t("Failed to create base"),
+ color: "red",
+ });
+ },
+ });
+}
+
+export function useConvertPageToBaseMutation() {
+ const { t } = useTranslation();
+ const [, setTreeData] = useAtom(treeDataAtom);
+ const [socket] = useAtom(socketAtom);
+
+ return useMutation({
+ mutationFn: ({ pageId, template }) => convertPageToBase(pageId, template),
+ onSuccess: (base) => {
+ queryClient.invalidateQueries({ queryKey: ["pages"] });
+ queryClient.invalidateQueries({
+ queryKey: ["root-sidebar-pages", base.spaceId],
+ });
+ queryClient.invalidateQueries({ queryKey: ["sidebar-pages"] });
+ setTreeData((prev) =>
+ treeModel.update(prev, base.id, { isBase: true } as Partial),
+ );
+ socket?.emit("message", {
+ operation: "updateOne",
+ spaceId: base.spaceId,
+ entity: ["pages"],
+ id: base.id,
+ payload: { isBase: true },
+ });
+ },
+ onError: () => {
+ notifications.show({
+ message: t("Failed to create base"),
+ color: "red",
+ });
+ },
+ });
+}
+
+export function useUpdateBaseMutation() {
+ const { t } = useTranslation();
+ return useMutation({
+ mutationFn: (data) => updateBase(data),
+ onSuccess: (data) => {
+ queryClient.setQueryData(["bases", data.id], (old) => {
+ if (!old) return old;
+ return { ...old, ...data };
+ });
+ },
+ onError: () => {
+ notifications.show({
+ message: t("Failed to update base"),
+ color: "red",
+ });
+ },
+ });
+}
+
+export function useDeleteBaseMutation() {
+ const { t } = useTranslation();
+ return useMutation({
+ mutationFn: ({ pageId }) => deleteBase(pageId),
+ onSuccess: (_, { pageId, spaceId }) => {
+ queryClient.removeQueries({ queryKey: ["bases", pageId] });
+ queryClient.invalidateQueries({
+ queryKey: ["bases", "list", spaceId],
+ });
+ notifications.show({ message: t("Base deleted") });
+ },
+ onError: () => {
+ notifications.show({
+ message: t("Failed to delete base"),
+ color: "red",
+ });
+ },
+ });
+}
diff --git a/apps/client/src/ee/base/queries/base-row-query.ts b/apps/client/src/ee/base/queries/base-row-query.ts
new file mode 100644
index 000000000..0df2781e1
--- /dev/null
+++ b/apps/client/src/ee/base/queries/base-row-query.ts
@@ -0,0 +1,566 @@
+import { useMemo } from "react";
+import {
+ useInfiniteQuery,
+ useMutation,
+ useQuery,
+ InfiniteData,
+} from "@tanstack/react-query";
+import {
+ createRow,
+ updateRow,
+ deleteRow,
+ deleteRows,
+ getRowInfo,
+ listRows,
+ reorderRow,
+ IBaseRowsPage,
+} from "@/ee/base/services/base-service";
+import {
+ IBase,
+ IBaseRow,
+ CreateRowInput,
+ UpdateRowInput,
+ DeleteRowInput,
+ DeleteRowsInput,
+ ReorderRowInput,
+ FilterNode,
+ ViewSortConfig,
+ RowReferences,
+ NO_VALUE_CHOICE_ID,
+} from "@/ee/base/types/base.types";
+import { notifications } from "@mantine/notifications";
+import { queryClient } from "@/main";
+import { useTranslation } from "react-i18next";
+import { useHydrateReferences } from "@/ee/base/reference/reference-store";
+import { markRequestIdOutbound } from "@/ee/base/hooks/use-base-socket";
+import { v7 as uuid7 } from "uuid";
+
+type RowCacheContext = {
+ snapshots: [readonly unknown[], InfiniteData | undefined][];
+};
+
+// An empty group filter is the draft-layer's "no predicates" marker (see use-view-draft.ts).
+// Strip it at the query boundary to keep request payloads clean and cache keys stable.
+export function normalizeFilter(filter: FilterNode | undefined): FilterNode | undefined {
+ if (!filter) return undefined;
+ if ('children' in filter && filter.children.length === 0) return undefined;
+ return filter;
+}
+
+// Pre-register the requestId as outbound so the socket echo is suppressed by useBaseSocket.
+function newRequestId(): string {
+ const id = uuid7();
+ markRequestIdOutbound(id);
+ return id;
+}
+
+export function baseRowsQueryKey(
+ pageId: string | undefined,
+ filter: FilterNode | undefined,
+ sorts: ViewSortConfig[] | undefined,
+) {
+ return [
+ "base-rows",
+ pageId,
+ normalizeFilter(filter),
+ sorts?.length ? sorts : undefined,
+ ] as const;
+}
+
+
+export function findRowInInfinite(
+ data: InfiniteData | undefined,
+ rowId: string,
+): IBaseRow | undefined {
+ if (!data) return undefined;
+ for (const page of data.pages) {
+ const row = page.items.find((r) => r.id === rowId);
+ if (row) return row;
+ }
+ return undefined;
+}
+
+export function useBaseRowsQuery(
+ pageId: string | undefined,
+ filter?: FilterNode,
+ sorts?: ViewSortConfig[],
+) {
+ const activeFilter = normalizeFilter(filter);
+ const activeSorts = sorts?.length ? sorts : undefined;
+
+ const query = useInfiniteQuery({
+ queryKey: baseRowsQueryKey(pageId, filter, sorts),
+ queryFn: ({ pageParam }) =>
+ listRows(pageId!, {
+ cursor: pageParam,
+ limit: 100,
+ filter: activeFilter,
+ sorts: activeSorts,
+ }),
+ enabled: !!pageId,
+ initialPageParam: undefined as string | undefined,
+ getNextPageParam: (lastPage: IBaseRowsPage) =>
+ lastPage.meta?.nextCursor ?? undefined,
+ staleTime: 5 * 60 * 1000,
+ });
+
+ const refPages = useMemo(
+ () =>
+ (query.data?.pages ?? [])
+ .map((p) => p.references)
+ .filter(Boolean) as RowReferences[],
+ [query.data],
+ );
+ useHydrateReferences(pageId, refPages);
+
+ return query;
+}
+
+export function flattenRows(
+ data: InfiniteData | undefined,
+): IBaseRow[] {
+ if (!data) return [];
+ return data.pages.flatMap((page) => page.items);
+}
+
+export function useCreateRowMutation() {
+ const { t } = useTranslation();
+ return useMutation({
+ mutationFn: (data) => createRow({ ...data, requestId: newRequestId() }),
+ onSuccess: (newRow) => {
+ queryClient.setQueriesData>(
+ { queryKey: ["base-rows", newRow.pageId] },
+ (old) => {
+ if (!old) return old;
+ const lastPageIndex = old.pages.length - 1;
+ return {
+ ...old,
+ pages: old.pages.map((page, index) => {
+ if (index === lastPageIndex) {
+ return { ...page, items: [...page.items, newRow] };
+ }
+ return page;
+ }),
+ };
+ },
+ );
+ const base = queryClient.getQueryData(["bases", newRow.pageId]);
+ if ((base?.views ?? []).some((v) => v.type === "kanban")) {
+ invalidateBaseRows(newRow.pageId);
+ }
+ },
+ onError: () => {
+ notifications.show({
+ message: t("Failed to create row"),
+ color: "red",
+ });
+ },
+ });
+}
+
+/** Single row by id — for deep links (?row=) pointing outside the loaded
+ * pages or the active view's filter. No retry: an error means the row is
+ * gone and the caller should close. */
+export function useBaseRowQuery(
+ pageId: string | undefined,
+ rowId: string | undefined,
+ options?: { enabled?: boolean },
+) {
+ return useQuery({
+ queryKey: ["base-row", pageId, rowId],
+ queryFn: () => getRowInfo(rowId!, pageId!),
+ enabled: !!pageId && !!rowId && (options?.enabled ?? true),
+ retry: false,
+ });
+}
+
+export function useUpdateRowMutation() {
+ const { t } = useTranslation();
+ return useMutation({
+ mutationFn: (data) => updateRow({ ...data, requestId: newRequestId() }),
+ onMutate: async (variables) => {
+ await queryClient.cancelQueries({
+ queryKey: ["base-rows", variables.pageId],
+ });
+
+ const snapshots = queryClient.getQueriesData<
+ InfiniteData
+ >({ queryKey: ["base-rows", variables.pageId] });
+
+ queryClient.setQueriesData>(
+ { queryKey: ["base-rows", variables.pageId] },
+ (old) => {
+ if (!old) return old;
+ return {
+ ...old,
+ pages: old.pages.map((page) => ({
+ ...page,
+ items: page.items.map((row) =>
+ row.id === variables.rowId
+ ? {
+ ...row,
+ cells: { ...row.cells, ...variables.cells },
+ ...(variables.position !== undefined && {
+ position: variables.position,
+ }),
+ }
+ : row,
+ ),
+ })),
+ };
+ },
+ );
+
+ queryClient.setQueryData(
+ ["base-row", variables.pageId, variables.rowId],
+ (old) =>
+ old
+ ? { ...old, cells: { ...old.cells, ...variables.cells } }
+ : old,
+ );
+
+ return { snapshots };
+ },
+ onError: (_, variables, context) => {
+ if (context?.snapshots) {
+ for (const [key, data] of context.snapshots) {
+ queryClient.setQueryData(key, data);
+ }
+ }
+ queryClient.invalidateQueries({
+ queryKey: ["base-row", variables.pageId, variables.rowId],
+ });
+ notifications.show({
+ message: t("Failed to update row"),
+ color: "red",
+ });
+ },
+ onSuccess: (updatedRow, variables) => {
+ queryClient.setQueriesData>(
+ { queryKey: ["base-rows", updatedRow.pageId] },
+ (old) => {
+ if (!old) return old;
+ return {
+ ...old,
+ pages: old.pages.map((page) => ({
+ ...page,
+ items: page.items.map((row) =>
+ row.id === updatedRow.id
+ ? { ...row, ...updatedRow, cells: { ...row.cells, ...updatedRow.cells } }
+ : row,
+ ),
+ })),
+ };
+ },
+ );
+ queryClient.setQueryData(
+ ["base-row", updatedRow.pageId, updatedRow.id],
+ (old) =>
+ old
+ ? { ...old, ...updatedRow, cells: { ...old.cells, ...updatedRow.cells } }
+ : old,
+ );
+
+ const base = queryClient.getQueryData(["bases", variables.pageId]);
+ const kanbanGroupByIds = new Set(
+ (base?.views ?? [])
+ .filter((v) => v.type === "kanban")
+ .map((v) => v.config?.groupByPropertyId)
+ .filter(Boolean) as string[],
+ );
+ const changedPropertyIds = Object.keys(variables.cells ?? {});
+ if (changedPropertyIds.some((id) => kanbanGroupByIds.has(id))) {
+ invalidateBaseRows(variables.pageId);
+ }
+ },
+ });
+}
+
+export function useDeleteRowMutation() {
+ const { t } = useTranslation();
+ return useMutation({
+ mutationFn: (data) => deleteRow({ ...data, requestId: newRequestId() }),
+ onMutate: async (variables) => {
+ await queryClient.cancelQueries({
+ queryKey: ["base-rows", variables.pageId],
+ });
+
+ const snapshots = queryClient.getQueriesData<
+ InfiniteData
+ >({ queryKey: ["base-rows", variables.pageId] });
+
+ queryClient.setQueriesData>(
+ { queryKey: ["base-rows", variables.pageId] },
+ (old) => {
+ if (!old) return old;
+ return {
+ ...old,
+ pages: old.pages.map((page) => ({
+ ...page,
+ items: page.items.filter((row) => row.id !== variables.rowId),
+ })),
+ };
+ },
+ );
+
+ return { snapshots };
+ },
+ onError: (_, variables, context) => {
+ if (context?.snapshots) {
+ for (const [key, data] of context.snapshots) {
+ queryClient.setQueryData(key, data);
+ }
+ }
+ notifications.show({
+ message: t("Failed to delete row"),
+ color: "red",
+ });
+ },
+ });
+}
+
+export function useDeleteRowsMutation() {
+ const { t } = useTranslation();
+ return useMutation({
+ mutationFn: (data) => deleteRows({ ...data, requestId: newRequestId() }),
+ onMutate: async (variables) => {
+ await queryClient.cancelQueries({
+ queryKey: ["base-rows", variables.pageId],
+ });
+
+ const snapshots = queryClient.getQueriesData<
+ InfiniteData