mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
feat: bases - WIP
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
import { useMemo, useCallback, useRef, useState, useEffect } from "react";
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
getFilteredRowModel,
|
||||
createColumnHelper,
|
||||
ColumnDef,
|
||||
SortingState,
|
||||
ColumnSizingState,
|
||||
VisibilityState,
|
||||
ColumnOrderState,
|
||||
ColumnPinningState,
|
||||
Table,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
IBase,
|
||||
IBaseProperty,
|
||||
IBaseRow,
|
||||
IBaseView,
|
||||
ViewConfig,
|
||||
} from "@/features/base/types/base.types";
|
||||
import { useUpdateViewMutation } from "@/features/base/queries/base-view-query";
|
||||
|
||||
const DEFAULT_COLUMN_WIDTH = 180;
|
||||
const MIN_COLUMN_WIDTH = 80;
|
||||
const MAX_COLUMN_WIDTH = 600;
|
||||
const ROW_NUMBER_COLUMN_WIDTH = 50;
|
||||
|
||||
export const SYSTEM_PROPERTY_TYPES = new Set(["createdAt", "lastEditedAt", "lastEditedBy"]);
|
||||
|
||||
export function isSystemPropertyType(type: string): boolean {
|
||||
return SYSTEM_PROPERTY_TYPES.has(type);
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<IBaseRow>();
|
||||
|
||||
function getSystemAccessor(type: string): ((row: IBaseRow) => unknown) | null {
|
||||
switch (type) {
|
||||
case "createdAt":
|
||||
return (row) => row.createdAt;
|
||||
case "lastEditedAt":
|
||||
return (row) => row.updatedAt;
|
||||
case "lastEditedBy":
|
||||
return (row) => row.lastUpdatedById ?? row.creatorId;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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 = getSystemAccessor(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[],
|
||||
): ColumnPinningState {
|
||||
const primary = properties.find((p) => p.isPrimary);
|
||||
return {
|
||||
left: primary ? ["__row_number", primary.id] : ["__row_number"],
|
||||
right: [],
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
const properties = base?.properties ?? [];
|
||||
const viewConfig = activeView?.config;
|
||||
|
||||
const columns = useMemo(
|
||||
() => buildColumns(properties),
|
||||
[properties],
|
||||
);
|
||||
|
||||
const initialSorting = useMemo(
|
||||
() => buildSortingState(viewConfig),
|
||||
[viewConfig],
|
||||
);
|
||||
|
||||
const initialColumnSizing = 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);
|
||||
|
||||
useEffect(() => {
|
||||
setColumnOrder(derivedColumnOrder);
|
||||
}, [derivedColumnOrder]);
|
||||
|
||||
useEffect(() => {
|
||||
setColumnVisibility(derivedColumnVisibility);
|
||||
}, [derivedColumnVisibility]);
|
||||
|
||||
const columnPinning = useMemo(
|
||||
() => buildColumnPinning(properties),
|
||||
[properties],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
columns,
|
||||
state: {
|
||||
columnPinning,
|
||||
columnOrder,
|
||||
columnVisibility,
|
||||
},
|
||||
onColumnOrderChange: setColumnOrder,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
initialState: {
|
||||
sorting: initialSorting,
|
||||
columnSizing: initialColumnSizing,
|
||||
},
|
||||
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);
|
||||
}
|
||||
|
||||
persistTimerRef.current = setTimeout(() => {
|
||||
const state = table.getState();
|
||||
|
||||
const sorts = state.sorting.map((s) => ({
|
||||
propertyId: s.id,
|
||||
direction: (s.desc ? "desc" : "asc") as "asc" | "desc",
|
||||
}));
|
||||
|
||||
const propertyWidths: Record<string, number> = {};
|
||||
Object.entries(state.columnSizing).forEach(([id, width]) => {
|
||||
if (id !== "__row_number") {
|
||||
propertyWidths[id] = 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);
|
||||
|
||||
const config: ViewConfig = {
|
||||
...activeView.config,
|
||||
sorts,
|
||||
propertyWidths,
|
||||
propertyOrder,
|
||||
hiddenPropertyIds,
|
||||
visiblePropertyIds: undefined,
|
||||
};
|
||||
|
||||
updateViewMutation.mutate({
|
||||
viewId: activeView.id,
|
||||
baseId: base.id,
|
||||
config,
|
||||
});
|
||||
}, 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 "@/features/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,117 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { Table } from "@tanstack/react-table";
|
||||
import { IBaseRow, EditingCell } from "@/features/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;
|
||||
|
||||
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,115 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
|
||||
type RowDragState = {
|
||||
dragRowId: string | null;
|
||||
dropTargetRowId: string | null;
|
||||
dropPosition: "above" | "below" | null;
|
||||
};
|
||||
|
||||
type UseRowDragOptions = {
|
||||
rowIds: string[];
|
||||
onReorder: (rowId: string, targetRowId: string, position: "above" | "below") => void;
|
||||
};
|
||||
|
||||
export function useRowDrag({ rowIds, onReorder }: UseRowDragOptions) {
|
||||
const [dragState, setDragState] = useState<RowDragState>({
|
||||
dragRowId: null,
|
||||
dropTargetRowId: null,
|
||||
dropPosition: null,
|
||||
});
|
||||
|
||||
const dragRowIdRef = useRef<string | null>(null);
|
||||
const dropTargetRef = useRef<string | null>(null);
|
||||
const dropPositionRef = useRef<"above" | "below" | null>(null);
|
||||
const onReorderRef = useRef(onReorder);
|
||||
onReorderRef.current = onReorder;
|
||||
|
||||
const handleDragStart = useCallback((rowId: string) => {
|
||||
dragRowIdRef.current = rowId;
|
||||
dropTargetRef.current = null;
|
||||
dropPositionRef.current = null;
|
||||
setDragState({
|
||||
dragRowId: rowId,
|
||||
dropTargetRowId: null,
|
||||
dropPosition: null,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(targetRowId: string, e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (!dragRowIdRef.current || dragRowIdRef.current === targetRowId) return;
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const midY = rect.top + rect.height / 2;
|
||||
const position: "above" | "below" = e.clientY < midY ? "above" : "below";
|
||||
|
||||
if (dropTargetRef.current === targetRowId && dropPositionRef.current === position) {
|
||||
return;
|
||||
}
|
||||
|
||||
dropTargetRef.current = targetRowId;
|
||||
dropPositionRef.current = position;
|
||||
|
||||
setDragState({
|
||||
dragRowId: dragRowIdRef.current,
|
||||
dropTargetRowId: targetRowId,
|
||||
dropPosition: position,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
const dragRowId = dragRowIdRef.current;
|
||||
const dropTargetRowId = dropTargetRef.current;
|
||||
const dropPosition = dropPositionRef.current;
|
||||
|
||||
if (dragRowId && dropTargetRowId && dropPosition && dragRowId !== dropTargetRowId) {
|
||||
onReorderRef.current(dragRowId, dropTargetRowId, dropPosition);
|
||||
}
|
||||
|
||||
dragRowIdRef.current = null;
|
||||
dropTargetRef.current = null;
|
||||
dropPositionRef.current = null;
|
||||
setDragState({
|
||||
dragRowId: null,
|
||||
dropTargetRowId: null,
|
||||
dropPosition: null,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
dropTargetRef.current = null;
|
||||
dropPositionRef.current = null;
|
||||
setDragState((prev) => ({
|
||||
...prev,
|
||||
dropTargetRowId: null,
|
||||
dropPosition: null,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleGlobalDragEnd = () => {
|
||||
dragRowIdRef.current = null;
|
||||
dropTargetRef.current = null;
|
||||
dropPositionRef.current = null;
|
||||
setDragState({
|
||||
dragRowId: null,
|
||||
dropTargetRowId: null,
|
||||
dropPosition: null,
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("dragend", handleGlobalDragEnd);
|
||||
return () => document.removeEventListener("dragend", handleGlobalDragEnd);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
dragState,
|
||||
handleDragStart,
|
||||
handleDragOver,
|
||||
handleDragEnd,
|
||||
handleDragLeave,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user