mirror of
https://github.com/docmost/docmost.git
synced 2026-07-27 18:14:44 +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,225 @@
|
||||
import { memo, useCallback } from "react";
|
||||
import { Cell } from "@tanstack/react-table";
|
||||
import { Popover, Tooltip } from "@mantine/core";
|
||||
import { IconArrowsDiagonal } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { IBaseRow, EditingCell } from "@/ee/base/types/base.types";
|
||||
import {
|
||||
editingCellAtomFamily,
|
||||
activeFormulaEditorAtomFamily,
|
||||
FormulaEditorTarget,
|
||||
} from "@/ee/base/atoms/base-atoms";
|
||||
import { FormulaPropertyEditor } from "@/ee/base/components/formula/formula-property-editor";
|
||||
import {
|
||||
isSystemPropertyType,
|
||||
getDescriptor,
|
||||
} from "@/ee/base/property-types/property-type.registry";
|
||||
import { cellValuesEqual } from "@/ee/base/components/cells/cell-value-equal";
|
||||
import { useBaseEditable } from "@/ee/base/context/base-editable";
|
||||
import { useRowExpand } from "@/ee/base/context/row-expand";
|
||||
import { RowNumberCell } from "./row-number-cell";
|
||||
import classes from "@/ee/base/styles/grid.module.css";
|
||||
|
||||
type GridCellProps = {
|
||||
cell: Cell<IBaseRow, unknown>;
|
||||
rowIndex: number;
|
||||
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
|
||||
pageId: string;
|
||||
};
|
||||
|
||||
export const GridCell = memo(function GridCell({
|
||||
cell,
|
||||
rowIndex,
|
||||
onCellUpdate,
|
||||
pageId,
|
||||
}: 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(editingCellAtomFamily(pageId)) as unknown as [EditingCell, (val: EditingCell) => void];
|
||||
const [activeFormulaEditor, setActiveFormulaEditor] = useAtom(
|
||||
activeFormulaEditorAtomFamily(pageId),
|
||||
) as unknown as [FormulaEditorTarget, (val: FormulaEditorTarget) => void];
|
||||
|
||||
const { t } = useTranslation();
|
||||
const editable = useBaseEditable();
|
||||
const readOnly = !editable;
|
||||
const onExpandRow = useRowExpand();
|
||||
|
||||
const rowId = cell.row.id;
|
||||
const isEditing =
|
||||
editingCell?.rowId === rowId &&
|
||||
editingCell?.propertyId === property?.id &&
|
||||
(editable || property?.type === "file");
|
||||
|
||||
const handleDoubleClick = useCallback(() => {
|
||||
if (!property || isRowNumber) return;
|
||||
if (property.type === "checkbox") return;
|
||||
if (readOnly) {
|
||||
// Read-only: only the file cell opens (a download-only popover) so
|
||||
// attachments stay reachable.
|
||||
if (property.type === "file") {
|
||||
setEditingCell({ rowId, propertyId: property.id });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (property.type === "formula") {
|
||||
setActiveFormulaEditor({ propertyId: property.id, rowId });
|
||||
return;
|
||||
}
|
||||
if (isSystemPropertyType(property.type)) return;
|
||||
setEditingCell({ rowId, propertyId: property.id });
|
||||
}, [property, isRowNumber, rowId, readOnly, setEditingCell, setActiveFormulaEditor]);
|
||||
|
||||
const closeFormulaEditor = useCallback(
|
||||
() => setActiveFormulaEditor(null),
|
||||
[setActiveFormulaEditor],
|
||||
);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(value: unknown) => {
|
||||
if (!property) return;
|
||||
if (!cellValuesEqual(value, cell.getValue())) {
|
||||
onCellUpdate(rowId, property.id, value);
|
||||
}
|
||||
},
|
||||
[property, rowId, cell, onCellUpdate],
|
||||
);
|
||||
|
||||
const handleCommit = useCallback(
|
||||
(value: unknown) => {
|
||||
handleValueChange(value);
|
||||
setEditingCell(null);
|
||||
},
|
||||
[handleValueChange, setEditingCell],
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setEditingCell(null);
|
||||
}, [setEditingCell]);
|
||||
|
||||
if (isRowNumber) {
|
||||
return (
|
||||
<RowNumberCell
|
||||
rowId={rowId}
|
||||
rowIndex={rowIndex}
|
||||
isPinned={Boolean(isPinned)}
|
||||
pinOffset={pinOffset}
|
||||
pageId={pageId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!property) return null;
|
||||
|
||||
const CellComponent = getDescriptor(property.type)?.cellComponent;
|
||||
if (!CellComponent) return null;
|
||||
|
||||
const value = cell.getValue();
|
||||
|
||||
const cellInner = (
|
||||
<div
|
||||
className={`${classes.cell} ${isPinned ? classes.cellPinned : ""} ${isEditing ? classes.cellEditing : ""} ${property.isPrimary ? classes.primaryCell : ""}`}
|
||||
style={
|
||||
isPinned
|
||||
? ({ "--pin-offset": `${pinOffset}px` } as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
<CellComponent
|
||||
value={value}
|
||||
property={property}
|
||||
rowId={rowId}
|
||||
isEditing={isEditing}
|
||||
readOnly={readOnly}
|
||||
onCommit={handleCommit}
|
||||
onValueChange={handleValueChange}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
{property.isPrimary && onExpandRow && !isEditing && (
|
||||
<span className={classes.rowExpandAnchor}>
|
||||
<Tooltip label={t("Expand")} position="bottom" openDelay={400}>
|
||||
<button
|
||||
type="button"
|
||||
className={classes.rowExpandButton}
|
||||
onClick={() => onExpandRow(rowId)}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
aria-label={t("Expand row {{number}}", { number: rowIndex + 1 })}
|
||||
>
|
||||
<IconArrowsDiagonal size={13} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (property.type !== "formula") return cellInner;
|
||||
|
||||
const formulaEditorOpen =
|
||||
activeFormulaEditor?.propertyId === property.id &&
|
||||
activeFormulaEditor?.rowId === rowId;
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={formulaEditorOpen}
|
||||
onChange={(o) => {
|
||||
if (!o) closeFormulaEditor();
|
||||
}}
|
||||
position="bottom-start"
|
||||
width={460}
|
||||
shadow="md"
|
||||
withinPortal
|
||||
closeOnClickOutside
|
||||
closeOnEscape={false}
|
||||
trapFocus
|
||||
>
|
||||
<Popover.Target>{cellInner}</Popover.Target>
|
||||
<Popover.Dropdown
|
||||
p={0}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
closeFormulaEditor();
|
||||
}
|
||||
}}
|
||||
style={{ maxWidth: "calc(100vw - 32px)" }}
|
||||
>
|
||||
{formulaEditorOpen && (
|
||||
<FormulaPropertyEditor
|
||||
property={property}
|
||||
pageId={pageId}
|
||||
onClose={closeFormulaEditor}
|
||||
/>
|
||||
)}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
},
|
||||
gridCellPropsEqual);
|
||||
|
||||
// Cell instances are re-created whenever the table data identity changes;
|
||||
// compare by coordinates + value so unchanged cells skip re-rendering.
|
||||
function gridCellPropsEqual(prev: GridCellProps, next: GridCellProps) {
|
||||
if (
|
||||
prev.rowIndex !== next.rowIndex ||
|
||||
prev.pageId !== next.pageId ||
|
||||
prev.onCellUpdate !== next.onCellUpdate
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (prev.cell === next.cell) return true;
|
||||
return (
|
||||
prev.cell.row.id === next.cell.row.id &&
|
||||
prev.cell.column.id === next.cell.column.id &&
|
||||
prev.cell.column.columnDef.meta?.property ===
|
||||
next.cell.column.columnDef.meta?.property &&
|
||||
cellValuesEqual(prev.cell.getValue(), next.cell.getValue())
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user