feat(ee): bases

Table and kanban UI, formula engine package, and the base-embed editor extension
This commit is contained in:
Philipinho
2026-06-14 01:29:06 +01:00
parent d86d51c27e
commit 4e5bff6d55
233 changed files with 22278 additions and 141 deletions
@@ -0,0 +1,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<string, unknown>;
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<string>();
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<IBase>(["bases", pageId]);
const hasKanbanForCreate = (baseForCreate?.views ?? []).some((v) => v.type === "kanban");
if (hasKanbanForCreate) {
invalidateBaseRows(pageId);
} else {
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
{ 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<IBase>(["bases", pageId]);
const hasKanbanForUpdate = (baseForUpdate?.views ?? []).some((v) => v.type === "kanban");
if (hasKanbanForUpdate) {
invalidateBaseRows(pageId);
} else {
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
{ 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<InfiniteData<IPagination<IBaseRow>>>(
{ 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<InfiniteData<IPagination<IBaseRow>>>(
{ 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<IBase>(["bases", pageId]);
const hasKanbanForReorder = (baseForReorder?.views ?? []).some((v) => v.type === "kanban");
if (hasKanbanForReorder) {
invalidateBaseRows(pageId);
} else {
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
{ 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<IPagination<IBaseRow>>
>({ 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<IBase>(["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]);
}
@@ -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<IBaseRow>();
function buildColumns(properties: IBaseProperty[]): ColumnDef<IBaseRow, unknown>[] {
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<IBaseRow>): ViewConfigPatch {
const state = table.getState();
const propertyWidths: Record<string, number> = {};
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<IBaseRow>;
persistViewConfig: () => void;
};
export function useBaseTable(
base: IBase | undefined,
rows: IBaseRow[],
activeView: IBaseView | undefined,
): UseBaseTableResult {
const updateViewMutation = useUpdateViewMutation();
const persistTimerRef = useRef<ReturnType<typeof setTimeout> | 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<ColumnOrderState>(derivedColumnOrder);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(derivedColumnVisibility);
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>(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<string | undefined>(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<string>(["__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 };
}
@@ -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<IBaseRow>,
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,
};
}
@@ -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: (
<Text size="sm">
{t("This action cannot be undone.")}
</Text>
),
labels: { confirm: t("Delete"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => void runDelete(ids),
});
}, [selectedIds, runDelete, t]);
return { deleteSelected, isPending: mutation.isPending };
}
@@ -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<HTMLInputElement>;
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<HTMLInputElement>(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 };
}
@@ -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<string, FormulaFn>,
): ParseState {
const [state, setState] = useState<ParseState>({ 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<string, FormulaResultType>(
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";
}
@@ -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<T extends HTMLElement>(
bodyRef: RefObject<T | null>,
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]);
}
@@ -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<IBaseRow>;
editingCell: EditingCell;
setEditingCell: (cell: EditingCell) => void;
containerRef: React.RefObject<HTMLDivElement | null>;
};
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]);
}
@@ -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<HTMLDivElement>(null) without a cast.
export function useHorizontalScrollSync<
TBody extends HTMLElement,
THeader extends HTMLElement,
>(
bodyRef: RefObject<TBody | null>,
headerRef: RefObject<THeader | null>,
): 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]);
}
@@ -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<T extends HTMLElement>(
boardRef: RefObject<T | null>,
pageId: string,
): void {
useEffect(() => {
const element = boardRef.current;
if (!element) return;
const canScroll = ({ source }: { source: { data: Record<string, unknown> } }) =>
(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<T extends HTMLElement>(
listRef: RefObject<T | null>,
pageId: string,
): void {
useEffect(() => {
const element = listRef.current;
if (!element) return;
const canScroll = ({ source }: { source: { data: Record<string, unknown> } }) =>
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]);
}
@@ -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<HTMLDivElement | null>;
rowId: string;
columnKey: string;
pageId: string;
}): { closestEdge: Edge | null; isDragging: boolean } {
const [isDragging, setIsDragging] = useState(false);
const [closestEdge, setClosestEdge] = useState<Edge | null>(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 };
}
@@ -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 };
}
@@ -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<HTMLDivElement | null>;
handleRef: RefObject<HTMLDivElement | null>;
columnKey: string;
pageId: string;
}): { closestEdge: Edge | null; isDragging: boolean } {
const [isDragging, setIsDragging] = useState(false);
const [closestEdge, setClosestEdge] = useState<Edge | null>(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 };
}
@@ -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]);
}
@@ -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<unknown>,
): UseListKeyboardNavResult {
const [activeIndex, setActiveIndex] = useState(-1);
const optionRefs = useRef<Array<HTMLElement | null>>([]);
// 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 };
}
@@ -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;
}
@@ -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]);
}
@@ -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 };
}
@@ -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<string>,
(val: Set<string> | ((prev: Set<string>) => Set<string>)) => 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,
};
}
@@ -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,
};
}