mirror of
https://github.com/docmost/docmost.git
synced 2026-07-24 06:02:54 +10:00
feat(ee): bases
Table and kanban UI, formula engine package, and the base-embed editor extension
This commit is contained in:
@@ -0,0 +1,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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user