mirror of
https://github.com/docmost/docmost.git
synced 2026-07-23 08:42:48 +10:00
feat: bases - WIP
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import { memo } from "react";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classes from "@/features/base/styles/grid.module.css";
|
||||
|
||||
type AddRowButtonProps = {
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export const AddRowButton = memo(function AddRowButton({
|
||||
onClick,
|
||||
}: AddRowButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classes.addRowButton}
|
||||
onClick={onClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<IconPlus size={14} />
|
||||
<span>{t("New row")}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { memo, useCallback } from "react";
|
||||
import { Cell } from "@tanstack/react-table";
|
||||
import { useAtom } from "jotai";
|
||||
import { IBaseRow, IBaseProperty, EditingCell } from "@/features/base/types/base.types";
|
||||
import { editingCellAtom } from "@/features/base/atoms/base-atoms";
|
||||
import { isSystemPropertyType } from "@/features/base/hooks/use-base-table";
|
||||
import { CellText } from "@/features/base/components/cells/cell-text";
|
||||
import { CellNumber } from "@/features/base/components/cells/cell-number";
|
||||
import { CellSelect } from "@/features/base/components/cells/cell-select";
|
||||
import { CellStatus } from "@/features/base/components/cells/cell-status";
|
||||
import { CellMultiSelect } from "@/features/base/components/cells/cell-multi-select";
|
||||
import { CellDate } from "@/features/base/components/cells/cell-date";
|
||||
import { CellCheckbox } from "@/features/base/components/cells/cell-checkbox";
|
||||
import { CellUrl } from "@/features/base/components/cells/cell-url";
|
||||
import { CellEmail } from "@/features/base/components/cells/cell-email";
|
||||
import { CellPerson } from "@/features/base/components/cells/cell-person";
|
||||
import { CellFile } from "@/features/base/components/cells/cell-file";
|
||||
import { CellCreatedAt } from "@/features/base/components/cells/cell-created-at";
|
||||
import { CellLastEditedAt } from "@/features/base/components/cells/cell-last-edited-at";
|
||||
import { CellLastEditedBy } from "@/features/base/components/cells/cell-last-edited-by";
|
||||
import classes from "@/features/base/styles/grid.module.css";
|
||||
|
||||
type CellComponentProps = {
|
||||
value: unknown;
|
||||
property: IBaseProperty;
|
||||
rowId: string;
|
||||
isEditing: boolean;
|
||||
onCommit: (value: unknown) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
const cellComponents: Record<
|
||||
string,
|
||||
React.ComponentType<CellComponentProps>
|
||||
> = {
|
||||
text: CellText,
|
||||
number: CellNumber,
|
||||
select: CellSelect,
|
||||
status: CellStatus,
|
||||
multiSelect: CellMultiSelect,
|
||||
date: CellDate,
|
||||
checkbox: CellCheckbox,
|
||||
url: CellUrl,
|
||||
email: CellEmail,
|
||||
person: CellPerson,
|
||||
file: CellFile,
|
||||
createdAt: CellCreatedAt,
|
||||
lastEditedAt: CellLastEditedAt,
|
||||
lastEditedBy: CellLastEditedBy,
|
||||
};
|
||||
|
||||
type RowDragProps = {
|
||||
draggable: boolean;
|
||||
onDragStart: (e: React.DragEvent) => void;
|
||||
};
|
||||
|
||||
type GridCellProps = {
|
||||
cell: Cell<IBaseRow, unknown>;
|
||||
rowIndex: number;
|
||||
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
|
||||
rowDragProps?: RowDragProps;
|
||||
};
|
||||
|
||||
export const GridCell = memo(function GridCell({
|
||||
cell,
|
||||
rowIndex,
|
||||
onCellUpdate,
|
||||
rowDragProps,
|
||||
}: GridCellProps) {
|
||||
const property = cell.column.columnDef.meta?.property;
|
||||
const isRowNumber = cell.column.id === "__row_number";
|
||||
const isPinned = cell.column.getIsPinned();
|
||||
const pinOffset = isPinned ? cell.column.getStart("left") : undefined;
|
||||
|
||||
const [editingCell, setEditingCell] = useAtom(editingCellAtom) as unknown as [EditingCell, (val: EditingCell) => void];
|
||||
|
||||
const rowId = cell.row.id;
|
||||
const isEditing =
|
||||
editingCell?.rowId === rowId &&
|
||||
editingCell?.propertyId === property?.id;
|
||||
|
||||
const handleDoubleClick = useCallback(() => {
|
||||
if (!property || isRowNumber) return;
|
||||
if (property.type === "checkbox") return;
|
||||
if (isSystemPropertyType(property.type)) return;
|
||||
setEditingCell({ rowId, propertyId: property.id });
|
||||
}, [property, isRowNumber, rowId, setEditingCell]);
|
||||
|
||||
const handleCommit = useCallback(
|
||||
(value: unknown) => {
|
||||
if (!property) return;
|
||||
const currentValue = cell.getValue();
|
||||
const hasChanged = value !== currentValue
|
||||
&& !(value === "" && (currentValue === null || currentValue === undefined))
|
||||
&& !(value === null && (currentValue === null || currentValue === undefined));
|
||||
if (hasChanged) {
|
||||
onCellUpdate(rowId, property.id, value);
|
||||
}
|
||||
setEditingCell(null);
|
||||
},
|
||||
[property, rowId, cell, onCellUpdate, setEditingCell],
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setEditingCell(null);
|
||||
}, [setEditingCell]);
|
||||
|
||||
if (isRowNumber) {
|
||||
return (
|
||||
<div
|
||||
className={`${classes.cell} ${classes.rowNumberCell} ${isPinned ? classes.cellPinned : ""} ${rowDragProps ? classes.rowNumberDraggable : ""}`}
|
||||
style={{
|
||||
...(isPinned ? { left: pinOffset } : {}),
|
||||
}}
|
||||
draggable={rowDragProps?.draggable}
|
||||
onDragStart={rowDragProps?.onDragStart}
|
||||
>
|
||||
{rowIndex + 1}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!property) return null;
|
||||
|
||||
const CellComponent = cellComponents[property.type];
|
||||
if (!CellComponent) return null;
|
||||
|
||||
const value = cell.getValue();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${classes.cell} ${isPinned ? classes.cellPinned : ""} ${isEditing ? classes.cellEditing : ""} ${property.isPrimary ? classes.primaryCell : ""}`}
|
||||
style={{
|
||||
...(isPinned ? { left: pinOffset } : {}),
|
||||
}}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
<CellComponent
|
||||
value={value}
|
||||
property={property}
|
||||
rowId={rowId}
|
||||
isEditing={isEditing}
|
||||
onCommit={handleCommit}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import { useRef, useMemo, useCallback, useEffect } from "react";
|
||||
import { Table } from "@tanstack/react-table";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
horizontalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { restrictToHorizontalAxis } from "@dnd-kit/modifiers";
|
||||
import { IBaseRow, EditingCell } from "@/features/base/types/base.types";
|
||||
import { editingCellAtom, activePropertyMenuAtom, propertyMenuDirtyAtom, propertyMenuCloseRequestAtom } from "@/features/base/atoms/base-atoms";
|
||||
import { useColumnResize } from "@/features/base/hooks/use-column-resize";
|
||||
import { useGridKeyboardNav } from "@/features/base/hooks/use-grid-keyboard-nav";
|
||||
import { useRowDrag } from "@/features/base/hooks/use-row-drag";
|
||||
import { GridHeader } from "./grid-header";
|
||||
import { GridRow } from "./grid-row";
|
||||
import { AddRowButton } from "./add-row-button";
|
||||
import classes from "@/features/base/styles/grid.module.css";
|
||||
|
||||
const ROW_HEIGHT = 36;
|
||||
const OVERSCAN = 10;
|
||||
|
||||
type GridContainerProps = {
|
||||
table: Table<IBaseRow>;
|
||||
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
|
||||
onAddRow?: () => void;
|
||||
onAddColumn?: () => void;
|
||||
onColumnReorder?: (columnId: string, overColumnId: string) => void;
|
||||
onResizeEnd?: () => void;
|
||||
onRowReorder?: (rowId: string, targetRowId: string, position: "above" | "below") => void;
|
||||
hasNextPage?: boolean;
|
||||
isFetchingNextPage?: boolean;
|
||||
onFetchNextPage?: () => void;
|
||||
};
|
||||
|
||||
export function GridContainer({
|
||||
table,
|
||||
onCellUpdate,
|
||||
onAddRow,
|
||||
onAddColumn,
|
||||
onColumnReorder,
|
||||
onResizeEnd,
|
||||
onRowReorder,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
onFetchNextPage,
|
||||
}: GridContainerProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const rows = table.getRowModel().rows;
|
||||
|
||||
const [editingCell, setEditingCell] = useAtom(editingCellAtom) as unknown as [EditingCell, (val: EditingCell) => void];
|
||||
const [, setActivePropertyMenu] = useAtom(activePropertyMenuAtom) as unknown as [string | null, (val: string | null) => void];
|
||||
const [propertyMenuDirty] = useAtom(propertyMenuDirtyAtom) as unknown as [boolean];
|
||||
const [, setCloseRequest] = useAtom(propertyMenuCloseRequestAtom) as unknown as [number, (val: number) => void];
|
||||
const propertyMenuDirtyRef = useRef(propertyMenuDirty);
|
||||
propertyMenuDirtyRef.current = propertyMenuDirty;
|
||||
const closeRequestCounterRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest(`.${classes.headerCell}`)) return;
|
||||
if (target.closest("[role=\"dialog\"]")) return;
|
||||
if (target.closest("[role=\"listbox\"]")) return;
|
||||
if (target.closest("[data-mantine-shared-portal-node]")) return;
|
||||
if (target.closest(`.${classes.cellEditing}`)) return;
|
||||
if (propertyMenuDirtyRef.current) {
|
||||
closeRequestCounterRef.current += 1;
|
||||
setCloseRequest(closeRequestCounterRef.current);
|
||||
} else {
|
||||
setActivePropertyMenu(null);
|
||||
}
|
||||
setEditingCell(null);
|
||||
};
|
||||
document.addEventListener("mousedown", handleMouseDown);
|
||||
return () => document.removeEventListener("mousedown", handleMouseDown);
|
||||
}, [setActivePropertyMenu, setEditingCell, setCloseRequest]);
|
||||
|
||||
useColumnResize(table, onResizeEnd ?? (() => {}));
|
||||
|
||||
useGridKeyboardNav({
|
||||
table,
|
||||
editingCell,
|
||||
setEditingCell,
|
||||
containerRef: scrollRef,
|
||||
});
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
overscan: OVERSCAN,
|
||||
});
|
||||
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasNextPage || isFetchingNextPage || !onFetchNextPage) return;
|
||||
const lastItem = virtualItems[virtualItems.length - 1];
|
||||
if (!lastItem) return;
|
||||
if (lastItem.index >= rows.length - OVERSCAN * 2) {
|
||||
onFetchNextPage();
|
||||
}
|
||||
}, [virtualItems, rows.length, hasNextPage, isFetchingNextPage, onFetchNextPage]);
|
||||
|
||||
const gridTemplateColumns = useMemo(() => {
|
||||
const visibleColumns = table.getVisibleLeafColumns();
|
||||
const columnWidths = visibleColumns.map((col) => `${col.getSize()}px`);
|
||||
return columnWidths.join(" ") + (onAddColumn ? " 40px" : "");
|
||||
}, [table, table.getState().columnSizing, table.getState().columnVisibility, onAddColumn]);
|
||||
|
||||
const totalHeight = virtualizer.getTotalSize();
|
||||
|
||||
const paddingTop =
|
||||
virtualItems.length > 0 ? virtualItems[0]?.start ?? 0 : 0;
|
||||
const paddingBottom =
|
||||
virtualItems.length > 0
|
||||
? totalHeight - (virtualItems[virtualItems.length - 1]?.end ?? 0)
|
||||
: 0;
|
||||
|
||||
const rowIds = useMemo(() => rows.map((r) => r.id), [rows]);
|
||||
|
||||
const handleRowReorder = useCallback(
|
||||
(rowId: string, targetRowId: string, position: "above" | "below") => {
|
||||
onRowReorder?.(rowId, targetRowId, position);
|
||||
},
|
||||
[onRowReorder],
|
||||
);
|
||||
|
||||
const {
|
||||
dragState: rowDragState,
|
||||
handleDragStart: handleRowDragStart,
|
||||
handleDragOver: handleRowDragOver,
|
||||
handleDragEnd: handleRowDragEnd,
|
||||
handleDragLeave: handleRowDragLeave,
|
||||
} = useRowDrag({ rowIds, onReorder: handleRowReorder });
|
||||
|
||||
const handleAddRow = useCallback(() => {
|
||||
onAddRow?.();
|
||||
}, [onAddRow]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 8 },
|
||||
}),
|
||||
useSensor(KeyboardSensor),
|
||||
);
|
||||
|
||||
const sortableColumnIds = useMemo(() => {
|
||||
return table
|
||||
.getVisibleLeafColumns()
|
||||
.filter((col) => col.id !== "__row_number")
|
||||
.map((col) => col.id);
|
||||
}, [table, table.getState().columnOrder]);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
onColumnReorder?.(active.id as string, over.id as string);
|
||||
},
|
||||
[onColumnReorder],
|
||||
);
|
||||
|
||||
const modifiers = useMemo(() => [restrictToHorizontalAxis], []);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
modifiers={modifiers}
|
||||
>
|
||||
<div
|
||||
className={classes.gridWrapper}
|
||||
ref={scrollRef}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div
|
||||
className={classes.grid}
|
||||
style={{ gridTemplateColumns }}
|
||||
role="grid"
|
||||
>
|
||||
<SortableContext
|
||||
items={sortableColumnIds}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
<GridHeader table={table} onAddColumn={onAddColumn} />
|
||||
</SortableContext>
|
||||
|
||||
{paddingTop > 0 && (
|
||||
<div style={{ height: paddingTop, gridColumn: "1 / -1" }} />
|
||||
)}
|
||||
|
||||
{virtualItems.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index];
|
||||
if (!row) return null;
|
||||
return (
|
||||
<GridRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
rowIndex={virtualRow.index}
|
||||
onCellUpdate={onCellUpdate}
|
||||
dragHandlers={
|
||||
onRowReorder
|
||||
? {
|
||||
onDragStart: handleRowDragStart,
|
||||
onDragOver: handleRowDragOver,
|
||||
onDragEnd: handleRowDragEnd,
|
||||
onDragLeave: handleRowDragLeave,
|
||||
isDragging: rowDragState.dragRowId === row.id,
|
||||
isDropTarget: rowDragState.dropTargetRowId === row.id,
|
||||
dropPosition: rowDragState.dropTargetRowId === row.id ? rowDragState.dropPosition : null,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{paddingBottom > 0 && (
|
||||
<div style={{ height: paddingBottom, gridColumn: "1 / -1" }} />
|
||||
)}
|
||||
|
||||
<AddRowButton onClick={handleAddRow} />
|
||||
</div>
|
||||
</div>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { memo, useCallback, useRef } from "react";
|
||||
import { Header, flexRender } from "@tanstack/react-table";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Popover } from "@mantine/core";
|
||||
import { useAtom } from "jotai";
|
||||
import { IBaseRow, IBaseProperty, EditingCell } from "@/features/base/types/base.types";
|
||||
import { activePropertyMenuAtom, propertyMenuDirtyAtom, editingCellAtom } from "@/features/base/atoms/base-atoms";
|
||||
import {
|
||||
IconLetterT,
|
||||
IconHash,
|
||||
IconCircleDot,
|
||||
IconProgressCheck,
|
||||
IconTags,
|
||||
IconCalendar,
|
||||
IconUser,
|
||||
IconPaperclip,
|
||||
IconCheckbox,
|
||||
IconLink,
|
||||
IconMail,
|
||||
IconClockPlus,
|
||||
IconClockEdit,
|
||||
IconUserEdit,
|
||||
} from "@tabler/icons-react";
|
||||
import { PropertyMenuContent } from "@/features/base/components/property/property-menu";
|
||||
import classes from "@/features/base/styles/grid.module.css";
|
||||
|
||||
const typeIcons: Record<string, typeof IconLetterT> = {
|
||||
text: IconLetterT,
|
||||
number: IconHash,
|
||||
select: IconCircleDot,
|
||||
status: IconProgressCheck,
|
||||
multiSelect: IconTags,
|
||||
date: IconCalendar,
|
||||
person: IconUser,
|
||||
file: IconPaperclip,
|
||||
checkbox: IconCheckbox,
|
||||
url: IconLink,
|
||||
email: IconMail,
|
||||
createdAt: IconClockPlus,
|
||||
lastEditedAt: IconClockEdit,
|
||||
lastEditedBy: IconUserEdit,
|
||||
};
|
||||
|
||||
type GridHeaderCellProps = {
|
||||
header: Header<IBaseRow, unknown>;
|
||||
};
|
||||
|
||||
export const GridHeaderCell = memo(function GridHeaderCell({
|
||||
header,
|
||||
}: GridHeaderCellProps) {
|
||||
const property = header.column.columnDef.meta?.property as
|
||||
| IBaseProperty
|
||||
| undefined;
|
||||
const isRowNumber = header.column.id === "__row_number";
|
||||
const isPinned = header.column.getIsPinned();
|
||||
const pinOffset = isPinned ? header.column.getStart("left") : undefined;
|
||||
|
||||
const [activePropertyMenu, setActivePropertyMenu] = useAtom(activePropertyMenuAtom) as unknown as [string | null, (val: string | null) => void];
|
||||
const menuOpened = activePropertyMenu === header.column.id;
|
||||
const cellRef = useRef<HTMLDivElement>(null);
|
||||
const [propertyMenuDirty, setPropertyMenuDirty] = useAtom(propertyMenuDirtyAtom) as unknown as [boolean, (val: boolean) => void];
|
||||
const [, setEditingCell] = useAtom(editingCellAtom) as unknown as [EditingCell, (val: EditingCell) => void];
|
||||
|
||||
const handleDirtyChange = useCallback((dirty: boolean) => {
|
||||
setPropertyMenuDirty(dirty);
|
||||
}, [setPropertyMenuDirty]);
|
||||
|
||||
const isSortableDisabled = isRowNumber || isPinned === "left";
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: header.column.id,
|
||||
disabled: isSortableDisabled,
|
||||
});
|
||||
|
||||
const combinedRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
setNodeRef(node);
|
||||
(cellRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||
},
|
||||
[setNodeRef],
|
||||
);
|
||||
|
||||
const handleHeaderClick = useCallback(() => {
|
||||
setEditingCell(null);
|
||||
if (!isRowNumber && property && !isDragging) {
|
||||
if (propertyMenuDirty && !menuOpened) return;
|
||||
setActivePropertyMenu(menuOpened ? null : header.column.id);
|
||||
}
|
||||
}, [isRowNumber, property, isDragging, header.column.id, menuOpened, propertyMenuDirty, setActivePropertyMenu, setEditingCell]);
|
||||
|
||||
const handleMenuClose = useCallback(() => {
|
||||
setActivePropertyMenu(null);
|
||||
}, [setActivePropertyMenu]);
|
||||
|
||||
const TypeIcon = property ? typeIcons[property.type] : undefined;
|
||||
|
||||
const sortableStyle = transform
|
||||
? {
|
||||
transform: CSS.Transform.toString({
|
||||
...transform,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
}),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
zIndex: isDragging ? 10 : undefined,
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={combinedRef}
|
||||
className={`${classes.headerCell} ${isPinned ? classes.headerCellPinned : ""}`}
|
||||
style={{
|
||||
...(isPinned ? { left: pinOffset } : {}),
|
||||
...(isRowNumber ? {} : { cursor: "pointer" }),
|
||||
...sortableStyle,
|
||||
}}
|
||||
onClick={handleHeaderClick}
|
||||
{...(isSortableDisabled ? {} : attributes)}
|
||||
{...(isSortableDisabled ? {} : listeners)}
|
||||
>
|
||||
{isRowNumber ? (
|
||||
flexRender(header.column.columnDef.header, header.getContext())
|
||||
) : (
|
||||
<div className={classes.headerCellContent}>
|
||||
{TypeIcon && (
|
||||
<TypeIcon size={14} className={classes.headerTypeIcon} />
|
||||
)}
|
||||
<span className={classes.headerCellName}>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{header.column.getCanResize() && (
|
||||
<div
|
||||
className={`${classes.resizeHandle} ${
|
||||
header.column.getIsResizing() ? classes.resizeHandleActive : ""
|
||||
}`}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
header.getResizeHandler()(e);
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.stopPropagation();
|
||||
header.getResizeHandler()(e);
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
{property && !isRowNumber && (
|
||||
<Popover
|
||||
opened={menuOpened}
|
||||
onClose={handleMenuClose}
|
||||
position="bottom-start"
|
||||
shadow="md"
|
||||
width={260}
|
||||
withinPortal
|
||||
closeOnClickOutside={false}
|
||||
>
|
||||
<Popover.Target>
|
||||
<div style={{ position: "absolute", inset: 0, pointerEvents: "none" }} />
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown
|
||||
p={0}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<PropertyMenuContent
|
||||
property={property}
|
||||
opened={menuOpened}
|
||||
onClose={handleMenuClose}
|
||||
onDirtyChange={handleDirtyChange}
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { memo, useCallback } from "react";
|
||||
import { Table } from "@tanstack/react-table";
|
||||
import { IBaseRow } from "@/features/base/types/base.types";
|
||||
import { GridHeaderCell } from "./grid-header-cell";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
import classes from "@/features/base/styles/grid.module.css";
|
||||
|
||||
type GridHeaderProps = {
|
||||
table: Table<IBaseRow>;
|
||||
onAddColumn?: () => void;
|
||||
};
|
||||
|
||||
export const GridHeader = memo(function GridHeader({
|
||||
table,
|
||||
onAddColumn,
|
||||
}: GridHeaderProps) {
|
||||
const headerGroups = table.getHeaderGroups();
|
||||
|
||||
const handleAddColumn = useCallback(() => {
|
||||
onAddColumn?.();
|
||||
}, [onAddColumn]);
|
||||
|
||||
return (
|
||||
<div className={classes.headerRow} role="row">
|
||||
{headerGroups[0]?.headers.map((header) => (
|
||||
<GridHeaderCell key={header.id} header={header} />
|
||||
))}
|
||||
{onAddColumn && (
|
||||
<div
|
||||
className={classes.addColumnButton}
|
||||
onClick={handleAddColumn}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<IconPlus size={16} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { memo, useCallback } from "react";
|
||||
import { Row } from "@tanstack/react-table";
|
||||
import { IBaseRow } from "@/features/base/types/base.types";
|
||||
import { GridCell } from "./grid-cell";
|
||||
import classes from "@/features/base/styles/grid.module.css";
|
||||
|
||||
type RowDragHandlers = {
|
||||
onDragStart: (rowId: string) => void;
|
||||
onDragOver: (rowId: string, e: React.DragEvent) => void;
|
||||
onDragEnd: () => void;
|
||||
onDragLeave: () => void;
|
||||
isDragging: boolean;
|
||||
isDropTarget: boolean;
|
||||
dropPosition: "above" | "below" | null;
|
||||
};
|
||||
|
||||
type GridRowProps = {
|
||||
row: Row<IBaseRow>;
|
||||
rowIndex: number;
|
||||
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
|
||||
dragHandlers?: RowDragHandlers;
|
||||
};
|
||||
|
||||
export const GridRow = memo(function GridRow({
|
||||
row,
|
||||
rowIndex,
|
||||
onCellUpdate,
|
||||
dragHandlers,
|
||||
}: GridRowProps) {
|
||||
const handleDragStart = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", row.id);
|
||||
dragHandlers?.onDragStart(row.id);
|
||||
},
|
||||
[row.id, dragHandlers],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
dragHandlers?.onDragOver(row.id, e);
|
||||
},
|
||||
[row.id, dragHandlers],
|
||||
);
|
||||
|
||||
const dropIndicatorClass = dragHandlers?.isDropTarget
|
||||
? dragHandlers.dropPosition === "above"
|
||||
? classes.rowDropAbove
|
||||
: classes.rowDropBelow
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${classes.row} ${dragHandlers?.isDragging ? classes.rowDragging : ""} ${dropIndicatorClass}`}
|
||||
role="row"
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
dragHandlers?.onDragEnd();
|
||||
}}
|
||||
onDragLeave={dragHandlers?.onDragLeave}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const isRowNumber = cell.column.id === "__row_number";
|
||||
return (
|
||||
<GridCell
|
||||
key={cell.id}
|
||||
cell={cell}
|
||||
rowIndex={rowIndex}
|
||||
onCellUpdate={onCellUpdate}
|
||||
rowDragProps={
|
||||
isRowNumber && dragHandlers
|
||||
? {
|
||||
draggable: true,
|
||||
onDragStart: handleDragStart,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user