mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 14:43:06 +08:00
Compare commits
152 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 123771e841 | |||
| 8c21675a75 | |||
| 02a78b2ec7 | |||
| dbc1eb539c | |||
| 38cd94b2d7 | |||
| 4437dcbb62 | |||
| 568d94be1f | |||
| f12a0675ea | |||
| 838d8892f0 | |||
| 08711791d6 | |||
| b04bcb5b0c | |||
| 709d927544 | |||
| 5b96dfe6c9 | |||
| 17db634029 | |||
| 5ebab5cd9e | |||
| 2d9e060d9e | |||
| b2ed8f9936 | |||
| 7192b4bacb | |||
| cfc50b7cae | |||
| f819f633c9 | |||
| db1b1464e2 | |||
| cc47a6d65c | |||
| 378d17350c | |||
| eea989260a | |||
| fc08cffd37 | |||
| fde0ccb3c7 | |||
| e663d7eecf | |||
| 96e875f1de | |||
| 6544ff6d38 | |||
| 7ca712c9ab | |||
| a798397af0 | |||
| 9ba6459427 | |||
| 14827ec6a0 | |||
| c931fa5ec9 | |||
| 7e07d77510 | |||
| 02c3bdf028 | |||
| 55feb01249 | |||
| 4636af3870 | |||
| c9adf84260 | |||
| 4f38c61725 | |||
| df22efb290 | |||
| 7534b44e6e | |||
| cf6b48cd58 | |||
| 45000bbd8b | |||
| 91ad3de258 | |||
| b28597125d | |||
| a9db3ef008 | |||
| 574c5316f0 | |||
| 3af2db7a8b | |||
| f181c6d9e8 | |||
| 8ac4c97c98 | |||
| abd42fd007 | |||
| eb0f37bfe5 | |||
| 4c0348e46a | |||
| cac4774641 | |||
| c4d8b6c300 | |||
| 95d0457a7e | |||
| 83d28a8505 | |||
| f9bbbc7ebf | |||
| d9e2d7ba3d | |||
| 44ec2dbe88 | |||
| a6e9e66bbd | |||
| a9ea2a99b4 | |||
| 2f6bad141c | |||
| fd1257f61c | |||
| 321184394d | |||
| b01f6e9af9 | |||
| 93b1fc534b | |||
| 1aa92b1bb5 | |||
| d385099eb1 | |||
| d4fe0e0a69 | |||
| ab9b00f91c | |||
| 64dafe5ac0 | |||
| 097b1c76d4 | |||
| 2c1f66b603 | |||
| f812162a26 | |||
| b88c060df8 | |||
| 97cd88405d | |||
| 5de9a69130 | |||
| 83d55d9bd3 | |||
| 9c71a90637 | |||
| c6f993b610 | |||
| c331e0ffd3 | |||
| 53ee685874 | |||
| 082a32faa0 | |||
| 5c11e59128 | |||
| 5a4d10081d | |||
| 18668c7bcf | |||
| f119d728a8 | |||
| 66f9194e96 | |||
| 19b3f26cbb | |||
| 56c57afff3 | |||
| d84aadadbb | |||
| da0321b468 | |||
| db6f82ff7a | |||
| 207c74427d | |||
| c53d70b64e | |||
| 9a1cbc8ea9 | |||
| 8b343d25f0 | |||
| 2d47ffb25a | |||
| b6882d774b | |||
| 4dc6d32e49 | |||
| 8994575437 | |||
| 3f52e54207 | |||
| b6b6e1809a | |||
| d8adcd44c2 | |||
| 6a230b14ca | |||
| 05406640f0 | |||
| 4c4bbe9b15 | |||
| 3fca962c9f | |||
| fda163311a | |||
| 0d824dcd24 | |||
| 8d793ec26b | |||
| 0bbcc7ee30 | |||
| e017209d76 | |||
| fc734475df | |||
| a7f9d66778 | |||
| 4a9e891582 | |||
| 65c5bb11b8 | |||
| 1466d95078 | |||
| 901445305d | |||
| 5985238b4b | |||
| 10ee8d0c85 | |||
| d2f19b2aa0 | |||
| 493915a0c3 | |||
| da49ffc332 | |||
| b95f3033d1 | |||
| 88c906cdcd | |||
| 836a25cdbf | |||
| b02b2cd5d8 | |||
| bb398bb7d6 | |||
| 4cefa40f5b | |||
| 2ca27f16a1 | |||
| f8edb587e4 | |||
| 0f4a819ec5 | |||
| ede1a799f2 | |||
| 845b49968e | |||
| b244f831da | |||
| 2ececc8203 | |||
| 6d9107b727 | |||
| 5ae49cab49 | |||
| 89638fb11d | |||
| f5b19316af | |||
| 081bb67239 | |||
| eb0538b856 | |||
| 084746e65a | |||
| 4ff13cef62 | |||
| 2a6e604bf8 | |||
| 674b0ec64a | |||
| ac03a54ae6 | |||
| 2cf7958dac | |||
| 94ee1e80fb |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.80.1",
|
||||
"version": "0.80.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
@@ -11,6 +11,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@casl/react": "^5.0.1",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@docmost/editor-ext": "workspace:*",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
@@ -23,7 +27,9 @@
|
||||
"@mantine/notifications": "^8.3.18",
|
||||
"@mantine/spotlight": "^8.3.18",
|
||||
"@tabler/icons-react": "^3.40.0",
|
||||
"@tanstack/react-query": "5.90.17",
|
||||
"@tanstack/react-query": "5.99.1",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"alfaaz": "^1.1.0",
|
||||
"axios": "1.15.0",
|
||||
"blueimp-load-image": "^5.16.0",
|
||||
@@ -31,8 +37,8 @@
|
||||
"emoji-mart": "^5.6.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"highlightjs-sap-abap": "^0.3.0",
|
||||
"i18next": "25.10.1",
|
||||
"i18next-http-backend": "3.0.6",
|
||||
"i18next": "^25.10.1",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"jotai": "^2.18.1",
|
||||
"jotai-optics": "^0.4.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
@@ -42,7 +48,7 @@
|
||||
"mantine-form-zod-resolver": "^1.3.0",
|
||||
"mermaid": "^11.13.0",
|
||||
"mitt": "^3.0.1",
|
||||
"posthog-js": "1.372.2",
|
||||
"posthog-js": "1.363.1",
|
||||
"react": "^18.3.1",
|
||||
"react-arborist": "3.4.0",
|
||||
"react-clear-modal": "^2.0.18",
|
||||
@@ -50,7 +56,7 @@
|
||||
"react-drawio": "^1.0.7",
|
||||
"react-error-boundary": "^6.1.1",
|
||||
"react-helmet-async": "^3.0.0",
|
||||
"react-i18next": "16.5.8",
|
||||
"react-i18next": "^16.5.8",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"semver": "^7.7.4",
|
||||
"socket.io-client": "^4.8.3",
|
||||
@@ -74,7 +80,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^15.13.0",
|
||||
"optics-ts": "^2.4.1",
|
||||
"postcss": "^8.5.12",
|
||||
"postcss": "^8.5.8",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"prettier": "^3.8.1",
|
||||
|
||||
@@ -38,6 +38,7 @@ import SpaceTrash from "@/pages/space/space-trash.tsx";
|
||||
import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
|
||||
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
|
||||
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
|
||||
import BasePage from "@/pages/base/base-page.tsx";
|
||||
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
|
||||
import VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx";
|
||||
import TemplateList from "@/ee/template/pages/template-list";
|
||||
@@ -104,6 +105,8 @@ export default function App() {
|
||||
element={<Page />}
|
||||
/>
|
||||
|
||||
<Route path={"/base/:baseId"} element={<BasePage />} />
|
||||
|
||||
<Route path={"/settings"}>
|
||||
<Route path={"account/profile"} element={<AccountSettings />} />
|
||||
<Route
|
||||
|
||||
@@ -8,7 +8,6 @@ export const Feature = {
|
||||
AI: 'ai',
|
||||
CONFLUENCE_IMPORT: 'import:confluence',
|
||||
DOCX_IMPORT: 'import:docx',
|
||||
PDF_IMPORT: 'import:pdf',
|
||||
ATTACHMENT_INDEXING: 'attachment:indexing',
|
||||
SECURITY_SETTINGS: 'security:settings',
|
||||
MCP: 'mcp',
|
||||
|
||||
@@ -140,7 +140,7 @@ export function PagePermissionList({
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<ScrollArea.Autosize mah={400} viewportRef={viewportRef}>
|
||||
<ScrollArea mah={250} viewportRef={viewportRef}>
|
||||
{sortedMembers.map((member) => (
|
||||
<PagePermissionItem
|
||||
key={`${member.type}-${member.id}`}
|
||||
@@ -158,7 +158,7 @@ export function PagePermissionList({
|
||||
<Loader size="xs" />
|
||||
</Center>
|
||||
)}
|
||||
</ScrollArea.Autosize>
|
||||
</ScrollArea>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { atom } from "jotai";
|
||||
import { EditingCell } from "@/features/base/types/base.types";
|
||||
|
||||
export const activeViewIdAtom = atom<string | null>(null);
|
||||
|
||||
export const editingCellAtom = atom<EditingCell>(null);
|
||||
|
||||
export const activePropertyMenuAtom = atom<string | null>(null);
|
||||
|
||||
export const propertyMenuDirtyAtom = atom<boolean>(false);
|
||||
|
||||
export const propertyMenuCloseRequestAtom = atom<number>(0);
|
||||
|
||||
export const selectedRowIdsAtom = atom<Set<string>>(new Set<string>());
|
||||
export const lastToggledRowIndexAtom = atom<number | null>(null);
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Skeleton } from "@mantine/core";
|
||||
import gridClasses from "@/features/base/styles/grid.module.css";
|
||||
import classes from "@/features/base/styles/base-table-skeleton.module.css";
|
||||
|
||||
const ROW_NUMBER_WIDTH = 64;
|
||||
const COLUMN_WIDTH = 180;
|
||||
const COLUMN_COUNT = 6;
|
||||
const ROW_COUNT = 10;
|
||||
|
||||
// Deterministic per-cell widths so the skeleton doesn't flicker between
|
||||
// renders. Values are rough normal distribution around 55-85 % of cell.
|
||||
const CELL_WIDTH_RATIOS = [0.78, 0.62, 0.84, 0.55, 0.71, 0.66];
|
||||
const HEADER_WIDTH_RATIOS = [0.42, 0.58, 0.5, 0.64, 0.46, 0.54];
|
||||
|
||||
export function BaseTableSkeleton() {
|
||||
const gridTemplateColumns = [
|
||||
`${ROW_NUMBER_WIDTH}px`,
|
||||
...Array.from({ length: COLUMN_COUNT }, () => `${COLUMN_WIDTH}px`),
|
||||
].join(" ");
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<div className={classes.toolbar}>
|
||||
<div className={classes.toolbarTabs}>
|
||||
<Skeleton height={22} width={44} radius="sm" />
|
||||
<Skeleton height={22} width={64} radius="sm" />
|
||||
<Skeleton height={22} width={48} radius="sm" />
|
||||
</div>
|
||||
<div className={classes.toolbarActions}>
|
||||
<Skeleton height={22} width={22} circle />
|
||||
<Skeleton height={22} width={22} circle />
|
||||
<Skeleton height={22} width={22} circle />
|
||||
<Skeleton height={22} width={22} circle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={classes.gridWrapper}>
|
||||
<div className={classes.grid} style={{ gridTemplateColumns }}>
|
||||
<div className={gridClasses.headerCell}>
|
||||
<div className={classes.headerCellInner}>
|
||||
<Skeleton height={14} width={14} circle />
|
||||
</div>
|
||||
</div>
|
||||
{Array.from({ length: COLUMN_COUNT }).map((_, colIndex) => (
|
||||
<div key={`h-${colIndex}`} className={gridClasses.headerCell}>
|
||||
<div className={classes.headerCellInner}>
|
||||
<Skeleton height={14} width={14} circle />
|
||||
<Skeleton
|
||||
height={10}
|
||||
width={`${HEADER_WIDTH_RATIOS[colIndex] * 100}%`}
|
||||
radius="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{Array.from({ length: ROW_COUNT }).map((_, rowIndex) => (
|
||||
<div key={`row-${rowIndex}`} style={{ display: "contents" }}>
|
||||
<div className={gridClasses.cell}>
|
||||
<div className={classes.cellInner}>
|
||||
<Skeleton height={10} width={18} radius="sm" />
|
||||
</div>
|
||||
</div>
|
||||
{Array.from({ length: COLUMN_COUNT }).map((_, colIndex) => (
|
||||
<div
|
||||
key={`cell-${rowIndex}-${colIndex}`}
|
||||
className={gridClasses.cell}
|
||||
>
|
||||
<div className={classes.cellInner}>
|
||||
<Skeleton
|
||||
height={10}
|
||||
width={`${CELL_WIDTH_RATIOS[(rowIndex + colIndex) % CELL_WIDTH_RATIOS.length] * 100}%`}
|
||||
radius="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { Text, Stack } from "@mantine/core";
|
||||
import { useAtom } from "jotai";
|
||||
import { IconDatabase } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
|
||||
import { useBaseQuery } from "@/features/base/queries/base-query";
|
||||
import { useBaseSocket } from "@/features/base/hooks/use-base-socket";
|
||||
import {
|
||||
useBaseRowsQuery,
|
||||
flattenRows,
|
||||
} from "@/features/base/queries/base-row-query";
|
||||
import { useUpdateRowMutation } from "@/features/base/queries/base-row-query";
|
||||
import { useCreateRowMutation } from "@/features/base/queries/base-row-query";
|
||||
import { useReorderRowMutation } from "@/features/base/queries/base-row-query";
|
||||
import { useCreateViewMutation } from "@/features/base/queries/base-view-query";
|
||||
import { activeViewIdAtom } from "@/features/base/atoms/base-atoms";
|
||||
import { useBaseTable } from "@/features/base/hooks/use-base-table";
|
||||
import { useRowSelection } from "@/features/base/hooks/use-row-selection";
|
||||
import { GridContainer } from "@/features/base/components/grid/grid-container";
|
||||
import { BaseToolbar } from "@/features/base/components/base-toolbar";
|
||||
import { BaseTableSkeleton } from "@/features/base/components/base-table-skeleton";
|
||||
import classes from "@/features/base/styles/grid.module.css";
|
||||
|
||||
type BaseTableProps = {
|
||||
baseId: string;
|
||||
};
|
||||
|
||||
export function BaseTable({ baseId }: BaseTableProps) {
|
||||
const { t } = useTranslation();
|
||||
// Subscribe to the base's realtime room so other clients' edits,
|
||||
// schema changes, and async-job completions reconcile into our cache.
|
||||
useBaseSocket(baseId);
|
||||
const { data: base, isLoading: baseLoading, error: baseError } = useBaseQuery(baseId);
|
||||
|
||||
const [activeViewId, setActiveViewId] = useAtom(activeViewIdAtom) as unknown as [string | null, (val: string | null) => void];
|
||||
|
||||
const views = base?.views ?? [];
|
||||
const activeView = useMemo(() => {
|
||||
if (!views.length) return undefined;
|
||||
return views.find((v) => v.id === activeViewId) ?? views[0];
|
||||
}, [views, activeViewId]);
|
||||
|
||||
const activeFilter = activeView?.config?.filter;
|
||||
const activeSorts = activeView?.config?.sorts;
|
||||
// Hold the rows query until `base` has loaded. Otherwise the query
|
||||
// fires once with `activeFilter` / `activeSorts` still undefined
|
||||
// (a "bland" list request), then fires a second time as soon as the
|
||||
// active view's config resolves — doubling network traffic on every
|
||||
// base open for any view that has sort or filter.
|
||||
const { data: rowsData, isLoading: rowsLoading, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
||||
useBaseRowsQuery(base ? baseId : undefined, activeFilter, activeSorts);
|
||||
|
||||
const updateRowMutation = useUpdateRowMutation();
|
||||
const createRowMutation = useCreateRowMutation();
|
||||
const reorderRowMutation = useReorderRowMutation();
|
||||
const createViewMutation = useCreateViewMutation();
|
||||
|
||||
useEffect(() => {
|
||||
if (activeView && activeViewId !== activeView.id) {
|
||||
setActiveViewId(activeView.id);
|
||||
}
|
||||
}, [activeView, activeViewId, setActiveViewId]);
|
||||
|
||||
const { clear: clearSelection } = useRowSelection();
|
||||
useEffect(() => {
|
||||
clearSelection();
|
||||
}, [baseId, activeView?.id, clearSelection]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const flat = flattenRows(rowsData);
|
||||
// When a sort is active, the server returns rows in the requested
|
||||
// sort order via keyset pagination. Re-sorting by `position` on the
|
||||
// client would override that with fractional-index order — visibly
|
||||
// breaking the sort as more pages load. Only apply the position
|
||||
// sort when no view sort is active (where it keeps
|
||||
// optimistically-created and ws-pushed rows in place without a
|
||||
// refetch).
|
||||
if (activeSorts && activeSorts.length > 0) {
|
||||
return flat;
|
||||
}
|
||||
return flat.sort((a, b) =>
|
||||
a.position < b.position ? -1 : a.position > b.position ? 1 : 0,
|
||||
);
|
||||
}, [rowsData, activeSorts]);
|
||||
|
||||
const { table, persistViewConfig } = useBaseTable(base, rows, activeView);
|
||||
|
||||
const handleCellUpdate = useCallback(
|
||||
(rowId: string, propertyId: string, value: unknown) => {
|
||||
updateRowMutation.mutate({
|
||||
rowId,
|
||||
baseId,
|
||||
cells: { [propertyId]: value },
|
||||
});
|
||||
},
|
||||
[baseId, updateRowMutation],
|
||||
);
|
||||
|
||||
const handleAddRow = useCallback(() => {
|
||||
createRowMutation.mutate({ baseId });
|
||||
}, [baseId, createRowMutation]);
|
||||
|
||||
const handleViewChange = useCallback(
|
||||
(viewId: string) => {
|
||||
setActiveViewId(viewId);
|
||||
},
|
||||
[setActiveViewId],
|
||||
);
|
||||
|
||||
const handleAddView = useCallback(() => {
|
||||
createViewMutation.mutate({
|
||||
baseId,
|
||||
name: t("New view"),
|
||||
type: "table",
|
||||
});
|
||||
}, [baseId, createViewMutation, t]);
|
||||
|
||||
const handleColumnReorder = useCallback(
|
||||
(activeId: string, overId: string) => {
|
||||
const currentOrder = table.getState().columnOrder;
|
||||
const oldIndex = currentOrder.indexOf(activeId);
|
||||
const newIndex = currentOrder.indexOf(overId);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
|
||||
const newOrder = arrayMove(currentOrder, oldIndex, newIndex);
|
||||
table.setColumnOrder(newOrder);
|
||||
persistViewConfig();
|
||||
},
|
||||
[table, persistViewConfig],
|
||||
);
|
||||
|
||||
const handleResizeEnd = useCallback(() => {
|
||||
persistViewConfig();
|
||||
}, [persistViewConfig]);
|
||||
|
||||
const handleRowReorder = useCallback(
|
||||
(rowId: string, targetRowId: string, dropPosition: "above" | "below") => {
|
||||
const remainingRows = rows.filter((r) => r.id !== rowId);
|
||||
const targetIndex = remainingRows.findIndex((r) => r.id === targetRowId);
|
||||
if (targetIndex === -1) return;
|
||||
|
||||
let lowerPos: string | null = null;
|
||||
let upperPos: string | null = null;
|
||||
|
||||
if (dropPosition === "above") {
|
||||
lowerPos = targetIndex > 0 ? remainingRows[targetIndex - 1]?.position : null;
|
||||
upperPos = remainingRows[targetIndex]?.position ?? null;
|
||||
} else {
|
||||
lowerPos = remainingRows[targetIndex]?.position ?? null;
|
||||
upperPos = targetIndex < remainingRows.length - 1 ? remainingRows[targetIndex + 1]?.position : null;
|
||||
}
|
||||
|
||||
try {
|
||||
let newPosition: string;
|
||||
if (lowerPos && upperPos && lowerPos === upperPos) {
|
||||
newPosition = generateJitteredKeyBetween(lowerPos, null);
|
||||
} else {
|
||||
newPosition = generateJitteredKeyBetween(lowerPos, upperPos);
|
||||
}
|
||||
|
||||
reorderRowMutation.mutate({
|
||||
rowId,
|
||||
baseId,
|
||||
position: newPosition,
|
||||
});
|
||||
} catch {
|
||||
// Position computation failed — skip silently
|
||||
}
|
||||
},
|
||||
[rows, baseId, reorderRowMutation],
|
||||
);
|
||||
|
||||
if (baseLoading || rowsLoading) {
|
||||
return <BaseTableSkeleton />;
|
||||
}
|
||||
|
||||
if (baseError) {
|
||||
return (
|
||||
<Stack align="center" gap="sm" p="xl">
|
||||
<IconDatabase size={40} color="var(--mantine-color-gray-5)" />
|
||||
<Text c="dimmed">{t("Failed to load base")}</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (!base) return null;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<BaseToolbar
|
||||
base={base}
|
||||
activeView={activeView}
|
||||
views={views}
|
||||
table={table}
|
||||
onViewChange={handleViewChange}
|
||||
onAddView={handleAddView}
|
||||
onPersistViewConfig={persistViewConfig}
|
||||
/>
|
||||
<GridContainer
|
||||
table={table}
|
||||
properties={base.properties}
|
||||
onCellUpdate={handleCellUpdate}
|
||||
onAddRow={handleAddRow}
|
||||
baseId={baseId}
|
||||
onColumnReorder={handleColumnReorder}
|
||||
onResizeEnd={handleResizeEnd}
|
||||
onRowReorder={handleRowReorder}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onFetchNextPage={fetchNextPage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
|
||||
import { ActionIcon, Tooltip, Badge } from "@mantine/core";
|
||||
import { Table } from "@tanstack/react-table";
|
||||
import {
|
||||
IconSortAscending,
|
||||
IconFilter,
|
||||
IconEye,
|
||||
IconDownload,
|
||||
} from "@tabler/icons-react";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
IBase,
|
||||
IBaseRow,
|
||||
IBaseView,
|
||||
ViewSortConfig,
|
||||
FilterCondition,
|
||||
FilterGroup,
|
||||
} from "@/features/base/types/base.types";
|
||||
import { useUpdateViewMutation } from "@/features/base/queries/base-view-query";
|
||||
import { buildViewConfigFromTable } from "@/features/base/hooks/use-base-table";
|
||||
import { exportBaseToCsv } from "@/features/base/services/base-service";
|
||||
import { ViewTabs } from "@/features/base/components/views/view-tabs";
|
||||
import { ViewSortConfigPopover } from "@/features/base/components/views/view-sort-config";
|
||||
import { ViewFilterConfigPopover } from "@/features/base/components/views/view-filter-config";
|
||||
import { ViewFieldVisibility } from "@/features/base/components/views/view-field-visibility";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classes from "@/features/base/styles/grid.module.css";
|
||||
|
||||
type BaseToolbarProps = {
|
||||
base: IBase;
|
||||
activeView: IBaseView | undefined;
|
||||
views: IBaseView[];
|
||||
table: Table<IBaseRow>;
|
||||
onViewChange: (viewId: string) => void;
|
||||
onAddView?: () => void;
|
||||
onPersistViewConfig: () => void;
|
||||
};
|
||||
|
||||
export function BaseToolbar({
|
||||
base,
|
||||
activeView,
|
||||
views,
|
||||
table,
|
||||
onViewChange,
|
||||
onAddView,
|
||||
onPersistViewConfig,
|
||||
}: BaseToolbarProps) {
|
||||
const { t } = useTranslation();
|
||||
const [sortOpened, setSortOpened] = useState(false);
|
||||
const [filterOpened, setFilterOpened] = useState(false);
|
||||
const [fieldsOpened, setFieldsOpened] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const toolbarRightRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Mantine `<Popover>`'s built-in dismiss handlers don't fire reliably
|
||||
// for the toolbar popovers (same issue that drove the property menu to
|
||||
// use custom listeners in `grid-container.tsx`). Close any open toolbar
|
||||
// popover on outside mousedown AND on ESC.
|
||||
useEffect(() => {
|
||||
if (!sortOpened && !filterOpened && !fieldsOpened) return;
|
||||
const closeAll = () => {
|
||||
setSortOpened(false);
|
||||
setFilterOpened(false);
|
||||
setFieldsOpened(false);
|
||||
};
|
||||
const mouseHandler = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target) return;
|
||||
if (toolbarRightRef.current?.contains(target)) return;
|
||||
// Ignore clicks that land inside any Mantine popover dropdown
|
||||
// (role=dialog), any Select/Combobox dropdown (role=listbox, the
|
||||
// container; option elements have role=option), or anything
|
||||
// rendered into Mantine's shared portal node. Without these, a
|
||||
// nested Select inside the popover would close the parent.
|
||||
if (target.closest('[role="dialog"]')) return;
|
||||
if (target.closest('[role="listbox"]')) return;
|
||||
if (target.closest('[role="option"]')) return;
|
||||
if (target.closest("[data-mantine-shared-portal-node]")) return;
|
||||
closeAll();
|
||||
};
|
||||
const keyHandler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") closeAll();
|
||||
};
|
||||
const id = setTimeout(() => {
|
||||
document.addEventListener("mousedown", mouseHandler);
|
||||
}, 0);
|
||||
document.addEventListener("keydown", keyHandler);
|
||||
return () => {
|
||||
clearTimeout(id);
|
||||
document.removeEventListener("mousedown", mouseHandler);
|
||||
document.removeEventListener("keydown", keyHandler);
|
||||
};
|
||||
}, [sortOpened, filterOpened, fieldsOpened]);
|
||||
|
||||
const handleExport = useCallback(async () => {
|
||||
if (exporting) return;
|
||||
setExporting(true);
|
||||
try {
|
||||
await exportBaseToCsv(base.id);
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: t("Failed to export CSV"),
|
||||
});
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}, [base.id, exporting, t]);
|
||||
|
||||
const openToolbar = useCallback((panel: "sort" | "filter" | "fields") => {
|
||||
setSortOpened(panel === "sort" ? (v) => !v : false);
|
||||
setFilterOpened(panel === "filter" ? (v) => !v : false);
|
||||
setFieldsOpened(panel === "fields" ? (v) => !v : false);
|
||||
}, []);
|
||||
|
||||
const updateViewMutation = useUpdateViewMutation();
|
||||
|
||||
const sorts = activeView?.config?.sorts ?? [];
|
||||
// Stored view config uses the engine's filter tree. The popover edits
|
||||
// an AND-only flat list; we unwrap the top-level group's children when
|
||||
// reading and rewrap on save.
|
||||
const conditions = useMemo<FilterCondition[]>(() => {
|
||||
const filter = activeView?.config?.filter;
|
||||
if (!filter || filter.op !== "and") return [];
|
||||
return filter.children.filter(
|
||||
(c): c is FilterCondition => !("children" in c),
|
||||
);
|
||||
}, [activeView?.config?.filter]);
|
||||
|
||||
const hiddenFieldCount = useMemo(() => {
|
||||
const cols = table.getAllLeafColumns().filter((col) => col.id !== "__row_number");
|
||||
return cols.filter((col) => col.getCanHide() && !col.getIsVisible()).length;
|
||||
}, [table, table.getState().columnVisibility]);
|
||||
|
||||
const handleSortsChange = useCallback(
|
||||
(newSorts: ViewSortConfig[]) => {
|
||||
if (!activeView) return;
|
||||
const config = buildViewConfigFromTable(table, activeView.config, {
|
||||
sorts: newSorts,
|
||||
});
|
||||
updateViewMutation.mutate({
|
||||
viewId: activeView.id,
|
||||
baseId: base.id,
|
||||
config,
|
||||
});
|
||||
},
|
||||
[activeView, base.id, table, updateViewMutation],
|
||||
);
|
||||
|
||||
const handleFiltersChange = useCallback(
|
||||
(newConditions: FilterCondition[]) => {
|
||||
if (!activeView) return;
|
||||
const filter: FilterGroup | undefined =
|
||||
newConditions.length > 0
|
||||
? { op: "and", children: newConditions }
|
||||
: undefined;
|
||||
// `filter: undefined` in overrides removes the filter key; the helper's
|
||||
// spread-then-overrides order means `undefined` wins over any base filter.
|
||||
const config = buildViewConfigFromTable(table, activeView.config, {
|
||||
filter,
|
||||
});
|
||||
updateViewMutation.mutate({
|
||||
viewId: activeView.id,
|
||||
baseId: base.id,
|
||||
config,
|
||||
});
|
||||
},
|
||||
[activeView, base.id, table, updateViewMutation],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={classes.toolbar}>
|
||||
<ViewTabs
|
||||
views={views}
|
||||
activeViewId={activeView?.id}
|
||||
baseId={base.id}
|
||||
onViewChange={onViewChange}
|
||||
onAddView={onAddView}
|
||||
/>
|
||||
|
||||
<div className={classes.toolbarRight} ref={toolbarRightRef}>
|
||||
<Tooltip label={t("Export CSV")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
color="gray"
|
||||
loading={exporting}
|
||||
onClick={handleExport}
|
||||
>
|
||||
<IconDownload size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<ViewFilterConfigPopover
|
||||
opened={filterOpened}
|
||||
onClose={() => setFilterOpened(false)}
|
||||
conditions={conditions}
|
||||
properties={base.properties}
|
||||
onChange={handleFiltersChange}
|
||||
>
|
||||
<Tooltip label={t("Filter")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
color={conditions.length > 0 ? "blue" : "gray"}
|
||||
onClick={() => openToolbar("filter")}
|
||||
>
|
||||
<IconFilter size={16} />
|
||||
{conditions.length > 0 && (
|
||||
<Badge
|
||||
size="xs"
|
||||
circle
|
||||
color="blue"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -2,
|
||||
right: -2,
|
||||
padding: 0,
|
||||
width: 14,
|
||||
height: 14,
|
||||
minWidth: 14,
|
||||
fontSize: 9,
|
||||
}}
|
||||
>
|
||||
{conditions.length}
|
||||
</Badge>
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</ViewFilterConfigPopover>
|
||||
|
||||
<ViewSortConfigPopover
|
||||
opened={sortOpened}
|
||||
onClose={() => setSortOpened(false)}
|
||||
sorts={sorts}
|
||||
properties={base.properties}
|
||||
onChange={handleSortsChange}
|
||||
>
|
||||
<Tooltip label={t("Sort")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
color={sorts.length > 0 ? "blue" : "gray"}
|
||||
onClick={() => openToolbar("sort")}
|
||||
>
|
||||
<IconSortAscending size={16} />
|
||||
{sorts.length > 0 && (
|
||||
<Badge
|
||||
size="xs"
|
||||
circle
|
||||
color="blue"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -2,
|
||||
right: -2,
|
||||
padding: 0,
|
||||
width: 14,
|
||||
height: 14,
|
||||
minWidth: 14,
|
||||
fontSize: 9,
|
||||
}}
|
||||
>
|
||||
{sorts.length}
|
||||
</Badge>
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</ViewSortConfigPopover>
|
||||
|
||||
<ViewFieldVisibility
|
||||
opened={fieldsOpened}
|
||||
onClose={() => setFieldsOpened(false)}
|
||||
table={table}
|
||||
properties={base.properties}
|
||||
onPersist={onPersistViewConfig}
|
||||
>
|
||||
<Tooltip label={t("Hide fields")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
color={hiddenFieldCount > 0 ? "blue" : "gray"}
|
||||
onClick={() => openToolbar("fields")}
|
||||
>
|
||||
<IconEye size={16} />
|
||||
{hiddenFieldCount > 0 && (
|
||||
<Badge
|
||||
size="xs"
|
||||
circle
|
||||
color="blue"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -2,
|
||||
right: -2,
|
||||
padding: 0,
|
||||
width: 14,
|
||||
height: 14,
|
||||
minWidth: 14,
|
||||
fontSize: 9,
|
||||
}}
|
||||
>
|
||||
{hiddenFieldCount}
|
||||
</Badge>
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</ViewFieldVisibility>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useCallback } from "react";
|
||||
import { Checkbox } from "@mantine/core";
|
||||
import { IBaseProperty } from "@/features/base/types/base.types";
|
||||
import cellClasses from "@/features/base/styles/cells.module.css";
|
||||
|
||||
type CellCheckboxProps = {
|
||||
value: unknown;
|
||||
property: IBaseProperty;
|
||||
rowId: string;
|
||||
isEditing: boolean;
|
||||
onCommit: (value: unknown) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export function CellCheckbox({
|
||||
value,
|
||||
onCommit,
|
||||
}: CellCheckboxProps) {
|
||||
const checked = value === true;
|
||||
|
||||
const handleChange = useCallback(() => {
|
||||
onCommit(!checked);
|
||||
}, [checked, onCommit]);
|
||||
|
||||
return (
|
||||
<div className={cellClasses.checkboxCell} onClick={handleChange}>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onChange={() => {}}
|
||||
size="xs"
|
||||
tabIndex={-1}
|
||||
styles={{ input: { cursor: "pointer", pointerEvents: "none" } }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { IBaseProperty } from "@/features/base/types/base.types";
|
||||
import cellClasses from "@/features/base/styles/cells.module.css";
|
||||
|
||||
type CellCreatedAtProps = {
|
||||
value: unknown;
|
||||
property: IBaseProperty;
|
||||
rowId: string;
|
||||
isEditing: boolean;
|
||||
onCommit: (value: unknown) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
function formatTimestamp(val: unknown): string {
|
||||
if (typeof val !== "string" || !val) return "";
|
||||
const date = new Date(val);
|
||||
if (isNaN(date.getTime())) return "";
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function CellCreatedAt({ value }: CellCreatedAtProps) {
|
||||
const formatted = formatTimestamp(value);
|
||||
|
||||
if (!formatted) {
|
||||
return <span className={cellClasses.emptyValue} />;
|
||||
}
|
||||
|
||||
return <span className={cellClasses.dateValue}>{formatted}</span>;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useCallback } from "react";
|
||||
import { Popover } from "@mantine/core";
|
||||
import { DatePicker } from "@mantine/dates";
|
||||
import {
|
||||
IBaseProperty,
|
||||
DateTypeOptions,
|
||||
} from "@/features/base/types/base.types";
|
||||
import cellClasses from "@/features/base/styles/cells.module.css";
|
||||
|
||||
type CellDateProps = {
|
||||
value: unknown;
|
||||
property: IBaseProperty;
|
||||
rowId: string;
|
||||
isEditing: boolean;
|
||||
onCommit: (value: unknown) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
function formatDateDisplay(
|
||||
dateStr: string | null | undefined,
|
||||
options: DateTypeOptions | undefined,
|
||||
): string {
|
||||
if (!dateStr) return "";
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return "";
|
||||
|
||||
const months = [
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||
];
|
||||
const month = months[date.getMonth()];
|
||||
const day = date.getDate();
|
||||
const year = date.getFullYear();
|
||||
|
||||
let result = `${month} ${day}, ${year}`;
|
||||
|
||||
if (options?.includeTime) {
|
||||
if (options.timeFormat === "24h") {
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
result += ` ${hours}:${minutes}`;
|
||||
} else {
|
||||
let hours = date.getHours();
|
||||
const ampm = hours >= 12 ? "PM" : "AM";
|
||||
hours = hours % 12 || 12;
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
result += ` ${hours}:${minutes} ${ampm}`;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function toISODateString(dateStr: string | null): string | null {
|
||||
if (!dateStr) return null;
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return null;
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function CellDate({
|
||||
value,
|
||||
property,
|
||||
isEditing,
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: CellDateProps) {
|
||||
const typeOptions = property.typeOptions as DateTypeOptions | undefined;
|
||||
const dateStr = typeof value === "string" ? value : null;
|
||||
const pickerValue = toISODateString(dateStr);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(selected: string | null) => {
|
||||
if (selected) {
|
||||
const date = new Date(selected);
|
||||
onCommit(date.toISOString());
|
||||
} else {
|
||||
onCommit(null);
|
||||
}
|
||||
},
|
||||
[onCommit],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
},
|
||||
[onCancel],
|
||||
);
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<Popover
|
||||
opened
|
||||
onClose={onCancel}
|
||||
position="bottom-start"
|
||||
width="auto"
|
||||
trapFocus
|
||||
>
|
||||
<Popover.Target>
|
||||
<div style={{ width: "100%", height: "100%" }}>
|
||||
<span className={cellClasses.dateValue}>
|
||||
{formatDateDisplay(dateStr, typeOptions)}
|
||||
</span>
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p="xs" onKeyDown={handleKeyDown}>
|
||||
<DatePicker
|
||||
value={pickerValue}
|
||||
onChange={handleChange}
|
||||
size="sm"
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
if (!dateStr) {
|
||||
return <span className={cellClasses.emptyValue} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={cellClasses.dateValue}>
|
||||
{formatDateDisplay(dateStr, typeOptions)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { IBaseProperty } from "@/features/base/types/base.types";
|
||||
import cellClasses from "@/features/base/styles/cells.module.css";
|
||||
|
||||
type CellEmailProps = {
|
||||
value: unknown;
|
||||
property: IBaseProperty;
|
||||
rowId: string;
|
||||
isEditing: boolean;
|
||||
onCommit: (value: unknown) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export function CellEmail({
|
||||
value,
|
||||
isEditing,
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: CellEmailProps) {
|
||||
const displayValue = typeof value === "string" ? value : "";
|
||||
const [draft, setDraft] = useState(displayValue);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const committedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
committedRef.current = false;
|
||||
setDraft(displayValue);
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
});
|
||||
}
|
||||
}, [isEditing, displayValue]);
|
||||
|
||||
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(draft || null);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
},
|
||||
[draft, commitOnce, onCancel],
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
commitOnce(draft || null);
|
||||
}, [draft, commitOnce]);
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="email"
|
||||
className={cellClasses.cellInput}
|
||||
value={draft}
|
||||
placeholder="email@example.com"
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!displayValue) {
|
||||
return <span className={cellClasses.emptyValue} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
className={cellClasses.emailLink}
|
||||
href={`mailto:${displayValue}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{displayValue}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { Popover, ActionIcon, Text, UnstyledButton } from "@mantine/core";
|
||||
import {
|
||||
IconPaperclip,
|
||||
IconUpload,
|
||||
IconFile,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { IBaseProperty } from "@/features/base/types/base.types";
|
||||
import cellClasses from "@/features/base/styles/cells.module.css";
|
||||
import api from "@/lib/api-client";
|
||||
|
||||
export type FileValue = {
|
||||
id: string;
|
||||
fileName: string;
|
||||
mimeType?: string;
|
||||
fileSize?: number;
|
||||
filePath?: string;
|
||||
};
|
||||
|
||||
type CellFileProps = {
|
||||
value: unknown;
|
||||
property: IBaseProperty;
|
||||
rowId: string;
|
||||
isEditing: boolean;
|
||||
onCommit: (value: unknown) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
function formatFileSize(bytes?: number): string {
|
||||
if (!bytes) return "";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function parseFiles(value: unknown): FileValue[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter(
|
||||
(f): f is FileValue =>
|
||||
f && typeof f === "object" && "id" in f && "fileName" in f,
|
||||
);
|
||||
}
|
||||
|
||||
export function CellFile({
|
||||
value,
|
||||
property,
|
||||
isEditing,
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: CellFileProps) {
|
||||
const files = parseFiles(value);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(fileId: string) => {
|
||||
const updated = files.filter((f) => f.id !== fileId);
|
||||
onCommit(updated.length > 0 ? updated : null);
|
||||
},
|
||||
[files, onCommit],
|
||||
);
|
||||
|
||||
const handleUpload = useCallback(
|
||||
async (fileList: FileList | null) => {
|
||||
if (!fileList || fileList.length === 0) return;
|
||||
setUploading(true);
|
||||
|
||||
const newFiles: FileValue[] = [...files];
|
||||
|
||||
for (const file of Array.from(fileList)) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("baseId", property.baseId);
|
||||
|
||||
const res = await api.post<FileValue>(
|
||||
"/bases/files/upload",
|
||||
formData,
|
||||
{
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
},
|
||||
);
|
||||
|
||||
const attachment = res as unknown as FileValue;
|
||||
newFiles.push({
|
||||
id: attachment.id,
|
||||
fileName: attachment.fileName,
|
||||
mimeType: attachment.mimeType,
|
||||
fileSize: attachment.fileSize,
|
||||
filePath: attachment.filePath,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("File upload failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
setUploading(false);
|
||||
onCommit(newFiles.length > 0 ? newFiles : null);
|
||||
},
|
||||
[files, property.baseId, onCommit],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
},
|
||||
[onCancel],
|
||||
);
|
||||
|
||||
const MAX_VISIBLE = 2;
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<Popover
|
||||
opened
|
||||
onClose={onCancel}
|
||||
position="bottom-start"
|
||||
width={280}
|
||||
trapFocus
|
||||
>
|
||||
<Popover.Target>
|
||||
<div style={{ width: "100%", height: "100%" }}>
|
||||
<FileList files={files} maxVisible={MAX_VISIBLE} />
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p={8} onKeyDown={handleKeyDown}>
|
||||
{files.length === 0 && !uploading && (
|
||||
<Text size="xs" c="dimmed" mb={8}>
|
||||
No files attached
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{files.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "4px 0",
|
||||
borderBottom:
|
||||
"1px solid var(--mantine-color-default-border)",
|
||||
}}
|
||||
>
|
||||
<IconFile
|
||||
size={14}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
color: "var(--mantine-color-gray-6)",
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="xs" truncate="end" fw={500}>
|
||||
{file.fileName}
|
||||
</Text>
|
||||
{file.fileSize != null && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatFileSize(file.fileSize)}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="xs"
|
||||
onClick={() => handleRemove(file.id)}
|
||||
>
|
||||
<IconX size={12} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
handleUpload(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
|
||||
<UnstyledButton
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "6px 0",
|
||||
marginTop: 4,
|
||||
fontSize: "var(--mantine-font-size-xs)",
|
||||
color: uploading
|
||||
? "var(--mantine-color-gray-5)"
|
||||
: "var(--mantine-color-blue-6)",
|
||||
}}
|
||||
>
|
||||
<IconUpload size={14} />
|
||||
{uploading ? "Uploading..." : "Add file"}
|
||||
</UnstyledButton>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return <span className={cellClasses.emptyValue} />;
|
||||
}
|
||||
|
||||
return <FileList files={files} maxVisible={MAX_VISIBLE} />;
|
||||
}
|
||||
|
||||
function FileList({
|
||||
files,
|
||||
maxVisible,
|
||||
}: {
|
||||
files: FileValue[];
|
||||
maxVisible: number;
|
||||
}) {
|
||||
const visible = files.slice(0, maxVisible);
|
||||
const overflow = files.length - maxVisible;
|
||||
|
||||
return (
|
||||
<div className={cellClasses.fileGroup}>
|
||||
{visible.map((file) => (
|
||||
<span key={file.id} className={cellClasses.fileBadge}>
|
||||
<IconPaperclip size={12} />
|
||||
{file.fileName}
|
||||
</span>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<span className={cellClasses.overflowCount}>+{overflow}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { IBaseProperty } from "@/features/base/types/base.types";
|
||||
import cellClasses from "@/features/base/styles/cells.module.css";
|
||||
|
||||
type CellLastEditedAtProps = {
|
||||
value: unknown;
|
||||
property: IBaseProperty;
|
||||
rowId: string;
|
||||
isEditing: boolean;
|
||||
onCommit: (value: unknown) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
function formatTimestamp(val: unknown): string {
|
||||
if (typeof val !== "string" || !val) return "";
|
||||
const date = new Date(val);
|
||||
if (isNaN(date.getTime())) return "";
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function CellLastEditedAt({ value }: CellLastEditedAtProps) {
|
||||
const formatted = formatTimestamp(value);
|
||||
|
||||
if (!formatted) {
|
||||
return <span className={cellClasses.emptyValue} />;
|
||||
}
|
||||
|
||||
return <span className={cellClasses.dateValue}>{formatted}</span>;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useMemo } from "react";
|
||||
import { Group } from "@mantine/core";
|
||||
import { IBaseProperty } from "@/features/base/types/base.types";
|
||||
import { useWorkspaceMembersQuery } from "@/features/workspace/queries/workspace-query";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||
import cellClasses from "@/features/base/styles/cells.module.css";
|
||||
|
||||
type CellLastEditedByProps = {
|
||||
value: unknown;
|
||||
property: IBaseProperty;
|
||||
rowId: string;
|
||||
isEditing: boolean;
|
||||
onCommit: (value: unknown) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export function CellLastEditedBy({ value }: CellLastEditedByProps) {
|
||||
const userId = typeof value === "string" ? value : null;
|
||||
|
||||
const { data: membersData } = useWorkspaceMembersQuery({ limit: 100 });
|
||||
|
||||
const user = useMemo(() => {
|
||||
if (!userId || !membersData?.items) return null;
|
||||
return membersData.items.find((u) => u.id === userId) ?? null;
|
||||
}, [userId, membersData?.items]);
|
||||
|
||||
if (!userId) {
|
||||
return <span className={cellClasses.emptyValue} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap" style={{ overflow: "hidden" }}>
|
||||
<CustomAvatar
|
||||
avatarUrl={user?.avatarUrl ?? ""}
|
||||
name={user?.name ?? ""}
|
||||
size={20}
|
||||
radius="xl"
|
||||
/>
|
||||
{user?.name && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "var(--mantine-font-size-sm)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{user.name}
|
||||
</span>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
||||
import { Popover, TextInput } from "@mantine/core";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
IBaseProperty,
|
||||
SelectTypeOptions,
|
||||
Choice,
|
||||
} from "@/features/base/types/base.types";
|
||||
import { choiceColor } from "@/features/base/components/cells/choice-color";
|
||||
import { useUpdatePropertyMutation } from "@/features/base/queries/base-property-query";
|
||||
import { v7 as uuid7 } from "uuid";
|
||||
import cellClasses from "@/features/base/styles/cells.module.css";
|
||||
import { useListKeyboardNav } from "@/features/base/hooks/use-list-keyboard-nav";
|
||||
|
||||
const CHOICE_COLORS = [
|
||||
"gray", "red", "pink", "grape", "violet", "indigo",
|
||||
"blue", "cyan", "teal", "green", "lime", "yellow", "orange",
|
||||
];
|
||||
|
||||
type NavItem =
|
||||
| { kind: "choice"; choice: Choice }
|
||||
| { kind: "add" };
|
||||
|
||||
type CellMultiSelectProps = {
|
||||
value: unknown;
|
||||
property: IBaseProperty;
|
||||
rowId: string;
|
||||
isEditing: boolean;
|
||||
onCommit: (value: unknown) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export function CellMultiSelect({
|
||||
value,
|
||||
property,
|
||||
isEditing,
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: CellMultiSelectProps) {
|
||||
const typeOptions = property.typeOptions as SelectTypeOptions | undefined;
|
||||
const choices = typeOptions?.choices ?? [];
|
||||
const selectedIds = Array.isArray(value) ? (value as string[]) : [];
|
||||
const selectedSet = new Set(selectedIds);
|
||||
|
||||
const selectedChoices = choices.filter((c) => selectedSet.has(c.id));
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
setSearch("");
|
||||
requestAnimationFrame(() => searchRef.current?.focus());
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const filteredChoices = search
|
||||
? choices.filter((c) => c.name.toLowerCase().includes(search.toLowerCase()))
|
||||
: choices;
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(choice: Choice) => {
|
||||
const newIds = selectedSet.has(choice.id)
|
||||
? selectedIds.filter((id) => id !== choice.id)
|
||||
: [...selectedIds, choice.id];
|
||||
onCommit(newIds);
|
||||
},
|
||||
[selectedIds, selectedSet, onCommit],
|
||||
);
|
||||
|
||||
const updatePropertyMutation = useUpdatePropertyMutation();
|
||||
|
||||
const trimmedSearch = search.trim();
|
||||
const hasExactMatch = useMemo(
|
||||
() =>
|
||||
trimmedSearch.length > 0 &&
|
||||
choices.some((c) => c.name.toLowerCase() === trimmedSearch.toLowerCase()),
|
||||
[choices, trimmedSearch],
|
||||
);
|
||||
const showAddOption = trimmedSearch.length > 0 && !hasExactMatch;
|
||||
|
||||
const addOptionColor = useMemo(
|
||||
() => CHOICE_COLORS[choices.length % CHOICE_COLORS.length],
|
||||
[choices.length],
|
||||
);
|
||||
|
||||
const navItems = useMemo<NavItem[]>(
|
||||
() => [
|
||||
...filteredChoices.map((c) => ({ kind: "choice" as const, choice: c })),
|
||||
...(showAddOption ? [{ kind: "add" as const }] : []),
|
||||
],
|
||||
[filteredChoices, showAddOption],
|
||||
);
|
||||
|
||||
const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
|
||||
useListKeyboardNav(navItems.length, [search, isEditing, showAddOption]);
|
||||
|
||||
const handleAddOption = useCallback(() => {
|
||||
if (!trimmedSearch) return;
|
||||
const newChoice: Choice = {
|
||||
id: uuid7(),
|
||||
name: trimmedSearch,
|
||||
color: addOptionColor,
|
||||
};
|
||||
const newChoices = [...choices, newChoice];
|
||||
updatePropertyMutation.mutate({
|
||||
propertyId: property.id,
|
||||
baseId: property.baseId,
|
||||
typeOptions: {
|
||||
...typeOptions,
|
||||
choices: newChoices,
|
||||
choiceOrder: newChoices.map((c) => c.id),
|
||||
},
|
||||
});
|
||||
onCommit([...selectedIds, newChoice.id]);
|
||||
setSearch("");
|
||||
}, [trimmedSearch, addOptionColor, choices, typeOptions, property, updatePropertyMutation, selectedIds, onCommit]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
if (handleNavKey(e)) return;
|
||||
if (e.key === "Enter") {
|
||||
if (activeIndex >= 0 && activeIndex < navItems.length) {
|
||||
e.preventDefault();
|
||||
const item = navItems[activeIndex];
|
||||
if (item.kind === "choice") handleToggle(item.choice);
|
||||
else handleAddOption();
|
||||
return;
|
||||
}
|
||||
if (showAddOption) {
|
||||
e.preventDefault();
|
||||
handleAddOption();
|
||||
}
|
||||
}
|
||||
},
|
||||
[onCancel, handleNavKey, activeIndex, navItems, handleToggle, handleAddOption, showAddOption],
|
||||
);
|
||||
|
||||
const MAX_VISIBLE = 3;
|
||||
|
||||
if (isEditing) {
|
||||
const addOptionIdx = filteredChoices.length;
|
||||
return (
|
||||
<Popover
|
||||
opened
|
||||
onClose={onCancel}
|
||||
position="bottom-start"
|
||||
width={220}
|
||||
trapFocus
|
||||
>
|
||||
<Popover.Target>
|
||||
<div style={{ width: "100%", height: "100%" }}>
|
||||
<BadgeList choices={selectedChoices} maxVisible={MAX_VISIBLE} />
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p={4}>
|
||||
<TextInput
|
||||
ref={searchRef}
|
||||
size="xs"
|
||||
placeholder="Search..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
mb={4}
|
||||
/>
|
||||
<div className={cellClasses.selectDropdown}>
|
||||
{filteredChoices.map((choice, idx) => {
|
||||
const isSelected = selectedSet.has(choice.id);
|
||||
return (
|
||||
<div
|
||||
key={choice.id}
|
||||
ref={setOptionRef(idx)}
|
||||
className={clsx(
|
||||
cellClasses.selectOption,
|
||||
isSelected && cellClasses.selectOptionActive,
|
||||
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
|
||||
)}
|
||||
onMouseEnter={() => setActiveIndex(idx)}
|
||||
onMouseDown={(e) => {
|
||||
// Keep focus on the search input so click doesn't blur + close popover.
|
||||
e.preventDefault();
|
||||
}}
|
||||
onClick={() => handleToggle(choice)}
|
||||
>
|
||||
<span
|
||||
className={cellClasses.badge}
|
||||
style={choiceColor(choice.color)}
|
||||
>
|
||||
{choice.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{showAddOption && (
|
||||
<div
|
||||
ref={setOptionRef(addOptionIdx)}
|
||||
className={clsx(
|
||||
cellClasses.addOptionRow,
|
||||
addOptionIdx === activeIndex && cellClasses.selectOptionKeyboardActive,
|
||||
)}
|
||||
onMouseEnter={() => setActiveIndex(addOptionIdx)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
onClick={handleAddOption}
|
||||
>
|
||||
<span className={cellClasses.addOptionLabel}>Add option:</span>
|
||||
<span
|
||||
className={cellClasses.badge}
|
||||
style={choiceColor(addOptionColor)}
|
||||
>
|
||||
{trimmedSearch}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedChoices.length === 0) {
|
||||
return <span className={cellClasses.emptyValue} />;
|
||||
}
|
||||
|
||||
return <BadgeList choices={selectedChoices} maxVisible={MAX_VISIBLE} />;
|
||||
}
|
||||
|
||||
function BadgeList({
|
||||
choices,
|
||||
maxVisible,
|
||||
}: {
|
||||
choices: Choice[];
|
||||
maxVisible: number;
|
||||
}) {
|
||||
const visible = choices.slice(0, maxVisible);
|
||||
const overflow = choices.length - maxVisible;
|
||||
|
||||
return (
|
||||
<div className={cellClasses.badgeGroup}>
|
||||
{visible.map((choice) => (
|
||||
<span
|
||||
key={choice.id}
|
||||
className={cellClasses.badge}
|
||||
style={choiceColor(choice.color)}
|
||||
>
|
||||
{choice.name}
|
||||
</span>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<span className={cellClasses.overflowCount}>+{overflow}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import {
|
||||
IBaseProperty,
|
||||
NumberTypeOptions,
|
||||
} from "@/features/base/types/base.types";
|
||||
import cellClasses from "@/features/base/styles/cells.module.css";
|
||||
|
||||
type CellNumberProps = {
|
||||
value: unknown;
|
||||
property: IBaseProperty;
|
||||
rowId: string;
|
||||
isEditing: boolean;
|
||||
onCommit: (value: unknown) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
function formatNumber(
|
||||
val: number | null | undefined,
|
||||
options: NumberTypeOptions | undefined,
|
||||
): string {
|
||||
if (val == null) return "";
|
||||
const precision = options?.precision ?? 0;
|
||||
const format = options?.format ?? "plain";
|
||||
|
||||
switch (format) {
|
||||
case "currency":
|
||||
return `${options?.currencySymbol ?? "$"}${val.toFixed(precision)}`;
|
||||
case "percent":
|
||||
return `${val.toFixed(precision)}%`;
|
||||
case "progress":
|
||||
return `${Math.min(100, Math.max(0, val)).toFixed(0)}%`;
|
||||
default:
|
||||
return precision > 0 ? val.toFixed(precision) : String(val);
|
||||
}
|
||||
}
|
||||
|
||||
export function CellNumber({
|
||||
value,
|
||||
property,
|
||||
isEditing,
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: CellNumberProps) {
|
||||
const numValue = typeof value === "number" ? value : null;
|
||||
const typeOptions = property.typeOptions as NumberTypeOptions | undefined;
|
||||
const [draft, setDraft] = useState(numValue != null ? String(numValue) : "");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const committedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
committedRef.current = false;
|
||||
setDraft(numValue != null ? String(numValue) : "");
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
});
|
||||
}
|
||||
}, [isEditing, numValue]);
|
||||
|
||||
const parseDraft = useCallback(() => {
|
||||
const parsed = draft === "" ? null : Number(draft);
|
||||
return parsed != null && isNaN(parsed) ? null : parsed;
|
||||
}, [draft]);
|
||||
|
||||
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(parseDraft());
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
},
|
||||
[parseDraft, commitOnce, onCancel],
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
commitOnce(parseDraft());
|
||||
}, [parseDraft, commitOnce]);
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className={cellClasses.cellInput}
|
||||
style={{ textAlign: "right" }}
|
||||
value={draft}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v === "" || v === "-" || /^-?\d*\.?\d*$/.test(v)) {
|
||||
setDraft(v);
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (numValue == null) {
|
||||
return <span className={cellClasses.emptyValue} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={cellClasses.numberValue}>
|
||||
{formatNumber(numValue, typeOptions)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
||||
import { Popover } from "@mantine/core";
|
||||
import { IconX } from "@tabler/icons-react";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
IBaseProperty,
|
||||
PersonTypeOptions,
|
||||
} from "@/features/base/types/base.types";
|
||||
import { useWorkspaceMembersQuery } from "@/features/workspace/queries/workspace-query";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar";
|
||||
import cellClasses from "@/features/base/styles/cells.module.css";
|
||||
import { useListKeyboardNav } from "@/features/base/hooks/use-list-keyboard-nav";
|
||||
|
||||
type CellPersonProps = {
|
||||
value: unknown;
|
||||
property: IBaseProperty;
|
||||
rowId: string;
|
||||
isEditing: boolean;
|
||||
onCommit: (value: unknown) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export function CellPerson({
|
||||
value,
|
||||
property,
|
||||
isEditing,
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: CellPersonProps) {
|
||||
const allowMultiple =
|
||||
(property.typeOptions as PersonTypeOptions)?.allowMultiple !== false;
|
||||
|
||||
const personIds = Array.isArray(value)
|
||||
? (value as string[])
|
||||
: typeof value === "string"
|
||||
? [value]
|
||||
: [];
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
setSearch("");
|
||||
requestAnimationFrame(() => searchRef.current?.focus());
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const { data: membersData } = useWorkspaceMembersQuery({ limit: 100 });
|
||||
const members = membersData?.items ?? [];
|
||||
const memberMap = useMemo(() => {
|
||||
const map = new Map<string, (typeof members)[0]>();
|
||||
for (const m of members) map.set(m.id, m);
|
||||
return map;
|
||||
}, [members]);
|
||||
|
||||
const filteredMembers = search
|
||||
? members.filter(
|
||||
(m) =>
|
||||
m.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(m.email && m.email.toLowerCase().includes(search.toLowerCase())),
|
||||
)
|
||||
: members;
|
||||
|
||||
const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
|
||||
useListKeyboardNav(filteredMembers.length, [search, isEditing]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(memberId: string) => {
|
||||
if (allowMultiple) {
|
||||
// Multi mode: toggle add/remove
|
||||
if (personIds.includes(memberId)) {
|
||||
const newIds = personIds.filter((id) => id !== memberId);
|
||||
onCommit(newIds.length > 0 ? newIds : null);
|
||||
} else {
|
||||
onCommit([...personIds, memberId]);
|
||||
}
|
||||
} else {
|
||||
// Single mode: replace or clear
|
||||
if (personIds.includes(memberId)) {
|
||||
onCommit(null);
|
||||
} else {
|
||||
onCommit(memberId);
|
||||
}
|
||||
}
|
||||
},
|
||||
[allowMultiple, personIds, onCommit],
|
||||
);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(memberId: string) => {
|
||||
if (allowMultiple) {
|
||||
const newIds = personIds.filter((id) => id !== memberId);
|
||||
onCommit(newIds.length > 0 ? newIds : null);
|
||||
} else {
|
||||
onCommit(null);
|
||||
}
|
||||
},
|
||||
[allowMultiple, personIds, onCommit],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
if (handleNavKey(e)) return;
|
||||
if (e.key === "Enter") {
|
||||
if (activeIndex < 0 || activeIndex >= filteredMembers.length) return;
|
||||
e.preventDefault();
|
||||
handleSelect(filteredMembers[activeIndex].id);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Backspace" && search === "" && personIds.length > 0) {
|
||||
e.preventDefault();
|
||||
handleRemove(personIds[personIds.length - 1]);
|
||||
}
|
||||
},
|
||||
[onCancel, handleNavKey, activeIndex, filteredMembers, handleSelect, search, personIds, handleRemove],
|
||||
);
|
||||
|
||||
const selectedSet = new Set(personIds);
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<Popover
|
||||
opened
|
||||
onClose={onCancel}
|
||||
position="bottom-start"
|
||||
width={300}
|
||||
trapFocus
|
||||
>
|
||||
<Popover.Target>
|
||||
<div style={{ width: "100%", height: "100%" }}>
|
||||
<PersonReadList personIds={personIds} memberMap={memberMap} />
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p={0}>
|
||||
{/* Tag input area */}
|
||||
<div className={cellClasses.personTagArea}>
|
||||
{personIds.map((id) => {
|
||||
const member = memberMap.get(id);
|
||||
const name = member?.name ?? id.substring(0, 8);
|
||||
return (
|
||||
<span key={id} className={cellClasses.personTag}>
|
||||
<CustomAvatar
|
||||
avatarUrl={member?.avatarUrl ?? ""}
|
||||
name={name}
|
||||
size={18}
|
||||
radius="xl"
|
||||
/>
|
||||
<span className={cellClasses.personTagName}>{name}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={cellClasses.personTagRemove}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemove(id);
|
||||
}}
|
||||
>
|
||||
<IconX size={10} />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<input
|
||||
ref={searchRef}
|
||||
className={cellClasses.personTagInput}
|
||||
placeholder={personIds.length === 0 ? "Search for a person..." : ""}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dropdown */}
|
||||
<div className={cellClasses.personDropdownDivider} />
|
||||
{allowMultiple && (
|
||||
<div className={cellClasses.personDropdownHint}>
|
||||
Select as many as you like
|
||||
</div>
|
||||
)}
|
||||
<div className={cellClasses.selectDropdown}>
|
||||
{filteredMembers.map((member, idx) => {
|
||||
const isSelected = selectedSet.has(member.id);
|
||||
return (
|
||||
<div
|
||||
key={member.id}
|
||||
ref={setOptionRef(idx)}
|
||||
className={clsx(
|
||||
cellClasses.selectOption,
|
||||
isSelected && cellClasses.selectOptionActive,
|
||||
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
|
||||
)}
|
||||
onMouseEnter={() => setActiveIndex(idx)}
|
||||
onMouseDown={(e) => {
|
||||
// Keep focus on the search input so click doesn't blur + close popover.
|
||||
e.preventDefault();
|
||||
}}
|
||||
onClick={() => handleSelect(member.id)}
|
||||
>
|
||||
<CustomAvatar
|
||||
avatarUrl={member.avatarUrl}
|
||||
name={member.name}
|
||||
size={24}
|
||||
radius="xl"
|
||||
/>
|
||||
<span className={cellClasses.personOptionName}>
|
||||
{member.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{filteredMembers.length === 0 && (
|
||||
<div className={cellClasses.personDropdownHint}>
|
||||
No members found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
if (personIds.length === 0) {
|
||||
return <span className={cellClasses.emptyValue} />;
|
||||
}
|
||||
|
||||
return <PersonReadList personIds={personIds} memberMap={memberMap} />;
|
||||
}
|
||||
|
||||
function PersonReadList({
|
||||
personIds,
|
||||
memberMap,
|
||||
}: {
|
||||
personIds: string[];
|
||||
memberMap: Map<
|
||||
string,
|
||||
{ id: string; name: string; email?: string; avatarUrl?: string }
|
||||
>;
|
||||
}) {
|
||||
return (
|
||||
<div className={cellClasses.personGroup}>
|
||||
{personIds.map((id) => {
|
||||
const member = memberMap.get(id);
|
||||
const name = member?.name ?? id.substring(0, 8);
|
||||
return (
|
||||
<div key={id} className={cellClasses.personRow}>
|
||||
<CustomAvatar
|
||||
avatarUrl={member?.avatarUrl ?? ""}
|
||||
name={name}
|
||||
size={20}
|
||||
radius="xl"
|
||||
/>
|
||||
<span className={cellClasses.personName}>{name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
||||
import { Popover, TextInput } from "@mantine/core";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
IBaseProperty,
|
||||
SelectTypeOptions,
|
||||
Choice,
|
||||
} from "@/features/base/types/base.types";
|
||||
import { choiceColor } from "@/features/base/components/cells/choice-color";
|
||||
import { useUpdatePropertyMutation } from "@/features/base/queries/base-property-query";
|
||||
import { v7 as uuid7 } from "uuid";
|
||||
import cellClasses from "@/features/base/styles/cells.module.css";
|
||||
import { useListKeyboardNav } from "@/features/base/hooks/use-list-keyboard-nav";
|
||||
|
||||
const CHOICE_COLORS = [
|
||||
"gray", "red", "pink", "grape", "violet", "indigo",
|
||||
"blue", "cyan", "teal", "green", "lime", "yellow", "orange",
|
||||
];
|
||||
|
||||
type NavItem =
|
||||
| { kind: "choice"; choice: Choice }
|
||||
| { kind: "add" };
|
||||
|
||||
type CellSelectProps = {
|
||||
value: unknown;
|
||||
property: IBaseProperty;
|
||||
rowId: string;
|
||||
isEditing: boolean;
|
||||
onCommit: (value: unknown) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export function CellSelect({
|
||||
value,
|
||||
property,
|
||||
isEditing,
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: CellSelectProps) {
|
||||
const typeOptions = property.typeOptions as SelectTypeOptions | undefined;
|
||||
const choices = typeOptions?.choices ?? [];
|
||||
const selectedId = typeof value === "string" ? value : null;
|
||||
const selectedChoice = choices.find((c) => c.id === selectedId);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
setSearch("");
|
||||
requestAnimationFrame(() => searchRef.current?.focus());
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const filteredChoices = search
|
||||
? choices.filter((c) =>
|
||||
c.name.toLowerCase().includes(search.toLowerCase()),
|
||||
)
|
||||
: choices;
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(choice: Choice) => {
|
||||
onCommit(choice.id === selectedId ? null : choice.id);
|
||||
},
|
||||
[selectedId, onCommit],
|
||||
);
|
||||
|
||||
const updatePropertyMutation = useUpdatePropertyMutation();
|
||||
|
||||
const trimmedSearch = search.trim();
|
||||
const hasExactMatch = useMemo(
|
||||
() =>
|
||||
trimmedSearch.length > 0 &&
|
||||
choices.some((c) => c.name.toLowerCase() === trimmedSearch.toLowerCase()),
|
||||
[choices, trimmedSearch],
|
||||
);
|
||||
const showAddOption = trimmedSearch.length > 0 && !hasExactMatch;
|
||||
|
||||
const addOptionColor = useMemo(
|
||||
() => CHOICE_COLORS[choices.length % CHOICE_COLORS.length],
|
||||
[choices.length],
|
||||
);
|
||||
|
||||
const navItems = useMemo<NavItem[]>(
|
||||
() => [
|
||||
...filteredChoices.map((c) => ({ kind: "choice" as const, choice: c })),
|
||||
...(showAddOption ? [{ kind: "add" as const }] : []),
|
||||
],
|
||||
[filteredChoices, showAddOption],
|
||||
);
|
||||
|
||||
const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
|
||||
useListKeyboardNav(navItems.length, [search, isEditing, showAddOption]);
|
||||
|
||||
const handleAddOption = useCallback(() => {
|
||||
if (!trimmedSearch) return;
|
||||
const newChoice: Choice = {
|
||||
id: uuid7(),
|
||||
name: trimmedSearch,
|
||||
color: addOptionColor,
|
||||
};
|
||||
const newChoices = [...choices, newChoice];
|
||||
updatePropertyMutation.mutate({
|
||||
propertyId: property.id,
|
||||
baseId: property.baseId,
|
||||
typeOptions: {
|
||||
...typeOptions,
|
||||
choices: newChoices,
|
||||
choiceOrder: newChoices.map((c) => c.id),
|
||||
},
|
||||
});
|
||||
onCommit(newChoice.id);
|
||||
}, [trimmedSearch, addOptionColor, choices, typeOptions, property, updatePropertyMutation, onCommit]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
if (handleNavKey(e)) return;
|
||||
if (e.key === "Enter") {
|
||||
if (activeIndex >= 0 && activeIndex < navItems.length) {
|
||||
e.preventDefault();
|
||||
const item = navItems[activeIndex];
|
||||
if (item.kind === "choice") handleSelect(item.choice);
|
||||
else handleAddOption();
|
||||
return;
|
||||
}
|
||||
if (showAddOption) {
|
||||
e.preventDefault();
|
||||
handleAddOption();
|
||||
}
|
||||
}
|
||||
},
|
||||
[onCancel, handleNavKey, activeIndex, navItems, handleSelect, handleAddOption, showAddOption],
|
||||
);
|
||||
|
||||
if (isEditing) {
|
||||
const addOptionIdx = filteredChoices.length;
|
||||
return (
|
||||
<Popover
|
||||
opened
|
||||
onClose={onCancel}
|
||||
position="bottom-start"
|
||||
width={220}
|
||||
trapFocus
|
||||
>
|
||||
<Popover.Target>
|
||||
<div style={{ width: "100%", height: "100%" }}>
|
||||
{selectedChoice ? (
|
||||
<span
|
||||
className={cellClasses.badge}
|
||||
style={choiceColor(selectedChoice.color)}
|
||||
>
|
||||
{selectedChoice.name}
|
||||
</span>
|
||||
) : (
|
||||
<span className={cellClasses.emptyValue} />
|
||||
)}
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p={4}>
|
||||
<TextInput
|
||||
ref={searchRef}
|
||||
size="xs"
|
||||
placeholder="Search..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
mb={4}
|
||||
/>
|
||||
<div className={cellClasses.selectDropdown}>
|
||||
{filteredChoices.map((choice, idx) => {
|
||||
const isSelected = choice.id === selectedId;
|
||||
return (
|
||||
<div
|
||||
key={choice.id}
|
||||
ref={setOptionRef(idx)}
|
||||
className={clsx(
|
||||
cellClasses.selectOption,
|
||||
isSelected && cellClasses.selectOptionActive,
|
||||
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
|
||||
)}
|
||||
onMouseEnter={() => setActiveIndex(idx)}
|
||||
onMouseDown={(e) => {
|
||||
// Keep focus on the search input so click doesn't blur + close popover.
|
||||
e.preventDefault();
|
||||
}}
|
||||
onClick={() => handleSelect(choice)}
|
||||
>
|
||||
<span
|
||||
className={cellClasses.badge}
|
||||
style={choiceColor(choice.color)}
|
||||
>
|
||||
{choice.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{showAddOption && (
|
||||
<div
|
||||
ref={setOptionRef(addOptionIdx)}
|
||||
className={clsx(
|
||||
cellClasses.addOptionRow,
|
||||
addOptionIdx === activeIndex && cellClasses.selectOptionKeyboardActive,
|
||||
)}
|
||||
onMouseEnter={() => setActiveIndex(addOptionIdx)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
onClick={handleAddOption}
|
||||
>
|
||||
<span className={cellClasses.addOptionLabel}>Add option:</span>
|
||||
<span
|
||||
className={cellClasses.badge}
|
||||
style={choiceColor(addOptionColor)}
|
||||
>
|
||||
{trimmedSearch}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedChoice) {
|
||||
return <span className={cellClasses.emptyValue} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cellClasses.badge}
|
||||
style={choiceColor(selectedChoice.color)}
|
||||
>
|
||||
{selectedChoice.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
||||
import { Popover, TextInput } from "@mantine/core";
|
||||
import {
|
||||
IBaseProperty,
|
||||
SelectTypeOptions,
|
||||
Choice,
|
||||
} from "@/features/base/types/base.types";
|
||||
import { choiceColor } from "@/features/base/components/cells/choice-color";
|
||||
import cellClasses from "@/features/base/styles/cells.module.css";
|
||||
import clsx from "clsx";
|
||||
import { useListKeyboardNav } from "@/features/base/hooks/use-list-keyboard-nav";
|
||||
|
||||
type CellStatusProps = {
|
||||
value: unknown;
|
||||
property: IBaseProperty;
|
||||
rowId: string;
|
||||
isEditing: boolean;
|
||||
onCommit: (value: unknown) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
type CategoryGroup = {
|
||||
label: string;
|
||||
choices: Choice[];
|
||||
};
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
todo: "To Do",
|
||||
inProgress: "In Progress",
|
||||
complete: "Complete",
|
||||
};
|
||||
|
||||
export function CellStatus({
|
||||
value,
|
||||
property,
|
||||
isEditing,
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: CellStatusProps) {
|
||||
const typeOptions = property.typeOptions as SelectTypeOptions | undefined;
|
||||
const choices = typeOptions?.choices ?? [];
|
||||
const selectedId = typeof value === "string" ? value : null;
|
||||
const selectedChoice = choices.find((c) => c.id === selectedId);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
setSearch("");
|
||||
requestAnimationFrame(() => searchRef.current?.focus());
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const filtered = search
|
||||
? choices.filter((c) =>
|
||||
c.name.toLowerCase().includes(search.toLowerCase()),
|
||||
)
|
||||
: choices;
|
||||
|
||||
const grouped: Record<string, Choice[]> = {};
|
||||
for (const choice of filtered) {
|
||||
const cat = choice.category ?? "todo";
|
||||
if (!grouped[cat]) grouped[cat] = [];
|
||||
grouped[cat].push(choice);
|
||||
}
|
||||
|
||||
const result: CategoryGroup[] = [];
|
||||
for (const key of ["todo", "inProgress", "complete"]) {
|
||||
if (grouped[key]?.length) {
|
||||
result.push({ label: categoryLabels[key] ?? key, choices: grouped[key] });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [choices, search]);
|
||||
|
||||
const flatChoices = useMemo(
|
||||
() => groups.flatMap((g) => g.choices),
|
||||
[groups],
|
||||
);
|
||||
const choiceIdxMap = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
flatChoices.forEach((c, i) => m.set(c.id, i));
|
||||
return m;
|
||||
}, [flatChoices]);
|
||||
|
||||
const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
|
||||
useListKeyboardNav(flatChoices.length, [search, isEditing]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(choice: Choice) => {
|
||||
onCommit(choice.id === selectedId ? null : choice.id);
|
||||
},
|
||||
[selectedId, onCommit],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
if (handleNavKey(e)) return;
|
||||
if (e.key === "Enter") {
|
||||
if (activeIndex < 0 || activeIndex >= flatChoices.length) return;
|
||||
e.preventDefault();
|
||||
handleSelect(flatChoices[activeIndex]);
|
||||
}
|
||||
},
|
||||
[onCancel, handleNavKey, activeIndex, flatChoices, handleSelect],
|
||||
);
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<Popover
|
||||
opened
|
||||
onClose={onCancel}
|
||||
position="bottom-start"
|
||||
width={220}
|
||||
trapFocus
|
||||
>
|
||||
<Popover.Target>
|
||||
<div style={{ width: "100%", height: "100%" }}>
|
||||
{selectedChoice ? (
|
||||
<span
|
||||
className={cellClasses.badge}
|
||||
style={choiceColor(selectedChoice.color)}
|
||||
>
|
||||
{selectedChoice.name}
|
||||
</span>
|
||||
) : (
|
||||
<span className={cellClasses.emptyValue} />
|
||||
)}
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p={4}>
|
||||
<TextInput
|
||||
ref={searchRef}
|
||||
size="xs"
|
||||
placeholder="Search..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
mb={4}
|
||||
/>
|
||||
<div className={cellClasses.selectDropdown}>
|
||||
{groups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<div className={cellClasses.selectCategoryLabel}>
|
||||
{group.label}
|
||||
</div>
|
||||
{group.choices.map((choice) => {
|
||||
const idx = choiceIdxMap.get(choice.id) ?? -1;
|
||||
const isSelected = choice.id === selectedId;
|
||||
return (
|
||||
<div
|
||||
key={choice.id}
|
||||
ref={setOptionRef(idx)}
|
||||
className={clsx(
|
||||
cellClasses.selectOption,
|
||||
isSelected && cellClasses.selectOptionActive,
|
||||
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
|
||||
)}
|
||||
onMouseEnter={() => setActiveIndex(idx)}
|
||||
onMouseDown={(e) => {
|
||||
// Keep focus on the search input so click doesn't blur + close popover.
|
||||
e.preventDefault();
|
||||
}}
|
||||
onClick={() => handleSelect(choice)}
|
||||
>
|
||||
<span
|
||||
className={cellClasses.badge}
|
||||
style={choiceColor(choice.color)}
|
||||
>
|
||||
{choice.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedChoice) {
|
||||
return <span className={cellClasses.emptyValue} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cellClasses.badge}
|
||||
style={choiceColor(selectedChoice.color)}
|
||||
>
|
||||
{selectedChoice.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { IBaseProperty } from "@/features/base/types/base.types";
|
||||
import cellClasses from "@/features/base/styles/cells.module.css";
|
||||
import gridClasses from "@/features/base/styles/grid.module.css";
|
||||
|
||||
type CellTextProps = {
|
||||
value: unknown;
|
||||
property: IBaseProperty;
|
||||
rowId: string;
|
||||
isEditing: boolean;
|
||||
onCommit: (value: unknown) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export function CellText({
|
||||
value,
|
||||
isEditing,
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: CellTextProps) {
|
||||
const displayValue = typeof value === "string" ? value : "";
|
||||
const [draft, setDraft] = useState(displayValue);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const committedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
committedRef.current = false;
|
||||
setDraft(displayValue);
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
});
|
||||
}
|
||||
}, [isEditing, displayValue]);
|
||||
|
||||
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(draft);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
},
|
||||
[draft, commitOnce, onCancel],
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
commitOnce(draft);
|
||||
}, [draft, commitOnce]);
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className={cellClasses.cellInput}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!displayValue) {
|
||||
return <span className={cellClasses.emptyValue} />;
|
||||
}
|
||||
|
||||
return <span className={gridClasses.cellContent}>{displayValue}</span>;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { IBaseProperty } from "@/features/base/types/base.types";
|
||||
import cellClasses from "@/features/base/styles/cells.module.css";
|
||||
|
||||
type CellUrlProps = {
|
||||
value: unknown;
|
||||
property: IBaseProperty;
|
||||
rowId: string;
|
||||
isEditing: boolean;
|
||||
onCommit: (value: unknown) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export function CellUrl({
|
||||
value,
|
||||
isEditing,
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: CellUrlProps) {
|
||||
const displayValue = typeof value === "string" ? value : "";
|
||||
const [draft, setDraft] = useState(displayValue);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const committedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
committedRef.current = false;
|
||||
setDraft(displayValue);
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
});
|
||||
}
|
||||
}, [isEditing, displayValue]);
|
||||
|
||||
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(draft || null);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
},
|
||||
[draft, commitOnce, onCancel],
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
commitOnce(draft || null);
|
||||
}, [draft, commitOnce]);
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="url"
|
||||
className={cellClasses.cellInput}
|
||||
value={draft}
|
||||
placeholder="https://..."
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!displayValue) {
|
||||
return <span className={cellClasses.emptyValue} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
className={cellClasses.urlLink}
|
||||
href={displayValue}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{displayValue}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { CSSProperties } from "react";
|
||||
|
||||
const colorMap: Record<string, { bg: string; bgDark: string; text: string; textDark: string }> = {
|
||||
gray: { bg: "#f1f3f5", bgDark: "#373a40", text: "#495057", textDark: "#ced4da" },
|
||||
red: { bg: "#ffe3e3", bgDark: "#4a1a1a", text: "#c92a2a", textDark: "#ffa8a8" },
|
||||
pink: { bg: "#ffdeeb", bgDark: "#4a1a2e", text: "#a61e4d", textDark: "#faa2c1" },
|
||||
grape: { bg: "#f3d9fa", bgDark: "#3b1a4a", text: "#862e9c", textDark: "#e599f7" },
|
||||
violet: { bg: "#e5dbff", bgDark: "#2b1a4a", text: "#5f3dc4", textDark: "#b197fc" },
|
||||
indigo: { bg: "#dbe4ff", bgDark: "#1a2b4a", text: "#364fc7", textDark: "#91a7ff" },
|
||||
blue: { bg: "#d0ebff", bgDark: "#1a2e4a", text: "#1971c2", textDark: "#74c0fc" },
|
||||
cyan: { bg: "#c3fae8", bgDark: "#1a3a3a", text: "#0c8599", textDark: "#66d9e8" },
|
||||
teal: { bg: "#c3fae8", bgDark: "#1a3a2e", text: "#087f5b", textDark: "#63e6be" },
|
||||
green: { bg: "#d3f9d8", bgDark: "#1a3a1a", text: "#2b8a3e", textDark: "#69db7c" },
|
||||
lime: { bg: "#e9fac8", bgDark: "#2e3a1a", text: "#5c940d", textDark: "#a9e34b" },
|
||||
yellow: { bg: "#fff3bf", bgDark: "#3a351a", text: "#e67700", textDark: "#ffd43b" },
|
||||
orange: { bg: "#ffe8cc", bgDark: "#3a2a1a", text: "#d9480f", textDark: "#ffa94d" },
|
||||
};
|
||||
|
||||
export function choiceColor(color: string): CSSProperties {
|
||||
const c = colorMap[color] ?? colorMap.gray;
|
||||
return {
|
||||
backgroundColor: `light-dark(${c.bg}, ${c.bgDark})`,
|
||||
color: `light-dark(${c.text}, ${c.textDark})`,
|
||||
};
|
||||
}
|
||||
@@ -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,149 @@
|
||||
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 { RowNumberCell } from "./row-number-cell";
|
||||
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;
|
||||
orderedRowIds?: string[];
|
||||
};
|
||||
|
||||
export const GridCell = memo(function GridCell({
|
||||
cell,
|
||||
rowIndex,
|
||||
onCellUpdate,
|
||||
rowDragProps,
|
||||
orderedRowIds,
|
||||
}: 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 (
|
||||
<RowNumberCell
|
||||
rowId={rowId}
|
||||
rowIndex={rowIndex}
|
||||
orderedRowIds={orderedRowIds ?? []}
|
||||
isPinned={Boolean(isPinned)}
|
||||
pinOffset={pinOffset}
|
||||
rowDragProps={rowDragProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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,306 @@
|
||||
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, IBaseProperty, 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 { useRowSelection } from "@/features/base/hooks/use-row-selection";
|
||||
import { useDeleteSelectedRows } from "@/features/base/hooks/use-delete-selected-rows";
|
||||
import { GridHeader } from "./grid-header";
|
||||
import { GridRow } from "./grid-row";
|
||||
import { AddRowButton } from "./add-row-button";
|
||||
import { SelectionActionBar } from "./selection-action-bar";
|
||||
import classes from "@/features/base/styles/grid.module.css";
|
||||
|
||||
const ROW_HEIGHT = 36;
|
||||
const OVERSCAN = 10;
|
||||
|
||||
type GridContainerProps = {
|
||||
table: Table<IBaseRow>;
|
||||
properties: IBaseProperty[];
|
||||
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
|
||||
onAddRow?: () => void;
|
||||
baseId?: string;
|
||||
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,
|
||||
properties,
|
||||
onCellUpdate,
|
||||
onAddRow,
|
||||
baseId,
|
||||
onColumnReorder,
|
||||
onResizeEnd,
|
||||
onRowReorder,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
onFetchNextPage,
|
||||
}: GridContainerProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const lastTriggeredRowsLenRef = useRef(0);
|
||||
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);
|
||||
|
||||
const { selectionCount, clear: clearSelection } = useRowSelection();
|
||||
const { deleteSelected } = useDeleteSelectedRows(baseId ?? "");
|
||||
|
||||
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) return;
|
||||
if (rows.length <= lastTriggeredRowsLenRef.current) return;
|
||||
lastTriggeredRowsLenRef.current = rows.length;
|
||||
onFetchNextPage();
|
||||
}, [virtualItems, rows.length, hasNextPage, isFetchingNextPage, onFetchNextPage]);
|
||||
|
||||
useEffect(() => {
|
||||
// When the underlying row set shrinks (filter changed, sort toggled,
|
||||
// view switched) or resets to zero, we're on a fresh pagination
|
||||
// sequence — un-gate the trigger so the first page triggers a
|
||||
// potential next fetch correctly.
|
||||
if (rows.length === 0 || rows.length < lastTriggeredRowsLenRef.current) {
|
||||
lastTriggeredRowsLenRef.current = 0;
|
||||
}
|
||||
}, [rows.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || !baseId) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (editingCell) return;
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (!active || !el.contains(active)) return;
|
||||
const tag = active.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || active.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape" && selectionCount > 0) {
|
||||
clearSelection();
|
||||
return;
|
||||
}
|
||||
if ((e.key === "Delete" || e.key === "Backspace") && selectionCount > 0) {
|
||||
e.preventDefault();
|
||||
void deleteSelected();
|
||||
}
|
||||
};
|
||||
el.addEventListener("keydown", handler);
|
||||
return () => el.removeEventListener("keydown", handler);
|
||||
}, [editingCell, selectionCount, clearSelection, deleteSelected, baseId]);
|
||||
|
||||
const gridTemplateColumns = useMemo(() => {
|
||||
const visibleColumns = table.getVisibleLeafColumns();
|
||||
const columnWidths = visibleColumns.map((col) => `${col.getSize()}px`);
|
||||
return columnWidths.join(" ") + (baseId ? " 40px" : "");
|
||||
}, [table, table.getState().columnSizing, table.getState().columnVisibility, table.getState().columnOrder, baseId]);
|
||||
|
||||
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 handlePropertyCreated = useCallback(() => {
|
||||
// Wait for React to re-render with the new column, then scroll to it
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
scrollRef.current?.scrollTo({
|
||||
left: scrollRef.current.scrollWidth,
|
||||
behavior: "smooth",
|
||||
});
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
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}
|
||||
baseId={baseId}
|
||||
columnOrder={table.getState().columnOrder}
|
||||
columnVisibility={table.getState().columnVisibility}
|
||||
properties={properties}
|
||||
loadedRowIds={rowIds}
|
||||
onPropertyCreated={handlePropertyCreated}
|
||||
/>
|
||||
</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}
|
||||
orderedRowIds={rowIds}
|
||||
columnVisibility={table.getState().columnVisibility}
|
||||
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} />
|
||||
{baseId && <SelectionActionBar baseId={baseId} />}
|
||||
</div>
|
||||
</div>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { memo, useCallback, useEffect, 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, propertyMenuCloseRequestAtom, 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 { RowNumberHeaderCell } from "./row-number-header-cell";
|
||||
import { useRowSelection } from "@/features/base/hooks/use-row-selection";
|
||||
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>;
|
||||
property: IBaseProperty | undefined;
|
||||
loadedRowIds: string[];
|
||||
};
|
||||
|
||||
export const GridHeaderCell = memo(function GridHeaderCell({
|
||||
header,
|
||||
property,
|
||||
loadedRowIds,
|
||||
}: GridHeaderCellProps) {
|
||||
const isRowNumber = header.column.id === "__row_number";
|
||||
const isPinned = header.column.getIsPinned();
|
||||
const pinOffset = isPinned ? header.column.getStart("left") : undefined;
|
||||
const { selectionCount } = useRowSelection();
|
||||
const hasSelection = selectionCount > 0;
|
||||
|
||||
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 [closeRequest, setCloseRequest] = useAtom(propertyMenuCloseRequestAtom) as unknown as [number, (val: number) => 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]);
|
||||
|
||||
// Mantine's built-in `closeOnEscape` only fires when focus is inside the
|
||||
// dropdown, but opening the property menu (clicking the header) leaves
|
||||
// focus on the header itself. Mirror the click-outside path: when dirty,
|
||||
// bump `propertyMenuCloseRequestAtom` so property-menu shows its
|
||||
// "Unsaved changes" confirmation panel; otherwise close directly.
|
||||
useEffect(() => {
|
||||
if (!menuOpened) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape") return;
|
||||
if (propertyMenuDirty) {
|
||||
setCloseRequest(closeRequest + 1);
|
||||
} else {
|
||||
handleMenuClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [menuOpened, propertyMenuDirty, closeRequest, setCloseRequest, handleMenuClose]);
|
||||
|
||||
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 : ""} ${hasSelection ? classes.hasSelection : ""}`}
|
||||
style={{
|
||||
...(isPinned ? { left: pinOffset } : {}),
|
||||
...(isRowNumber ? {} : { cursor: "pointer" }),
|
||||
...sortableStyle,
|
||||
}}
|
||||
onClick={handleHeaderClick}
|
||||
{...(isSortableDisabled ? {} : attributes)}
|
||||
{...(isSortableDisabled ? {} : listeners)}
|
||||
>
|
||||
{isRowNumber ? (
|
||||
<RowNumberHeaderCell loadedRowIds={loadedRowIds} />
|
||||
) : (
|
||||
<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,56 @@
|
||||
import { memo, useMemo } from "react";
|
||||
import { Table, ColumnOrderState, VisibilityState } from "@tanstack/react-table";
|
||||
import { IBaseRow, IBaseProperty } from "@/features/base/types/base.types";
|
||||
import { GridHeaderCell } from "./grid-header-cell";
|
||||
import { CreatePropertyPopover } from "@/features/base/components/property/create-property-popover";
|
||||
import classes from "@/features/base/styles/grid.module.css";
|
||||
|
||||
type GridHeaderProps = {
|
||||
table: Table<IBaseRow>;
|
||||
baseId?: string;
|
||||
// Passed explicitly to break memo when columns change
|
||||
// (table ref is stable from useReactTable, so memo won't fire without these)
|
||||
columnOrder: ColumnOrderState;
|
||||
columnVisibility: VisibilityState;
|
||||
properties: IBaseProperty[];
|
||||
loadedRowIds: string[];
|
||||
onPropertyCreated?: () => void;
|
||||
};
|
||||
|
||||
export const GridHeader = memo(function GridHeader({
|
||||
table,
|
||||
baseId,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
columnOrder: _columnOrder,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
columnVisibility: _columnVisibility,
|
||||
properties,
|
||||
loadedRowIds,
|
||||
onPropertyCreated,
|
||||
}: GridHeaderProps) {
|
||||
const headerGroups = table.getHeaderGroups();
|
||||
const propertyById = useMemo(() => {
|
||||
const map = new Map<string, IBaseProperty>();
|
||||
for (const p of properties) map.set(p.id, p);
|
||||
return map;
|
||||
}, [properties]);
|
||||
|
||||
return (
|
||||
<div className={classes.headerRow} role="row">
|
||||
{headerGroups[0]?.headers.map((header) => (
|
||||
<GridHeaderCell
|
||||
key={header.id}
|
||||
header={header}
|
||||
property={propertyById.get(header.column.id)}
|
||||
loadedRowIds={loadedRowIds}
|
||||
/>
|
||||
))}
|
||||
{baseId && (
|
||||
<CreatePropertyPopover
|
||||
baseId={baseId}
|
||||
onPropertyCreated={onPropertyCreated}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { memo, useCallback } from "react";
|
||||
import { Row, VisibilityState } from "@tanstack/react-table";
|
||||
import { IBaseRow } from "@/features/base/types/base.types";
|
||||
import { useRowSelection } from "@/features/base/hooks/use-row-selection";
|
||||
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;
|
||||
orderedRowIds: string[];
|
||||
columnVisibility: VisibilityState;
|
||||
};
|
||||
|
||||
export const GridRow = memo(function GridRow({
|
||||
row,
|
||||
rowIndex,
|
||||
onCellUpdate,
|
||||
dragHandlers,
|
||||
orderedRowIds,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
columnVisibility: _columnVisibility,
|
||||
}: GridRowProps) {
|
||||
const isSelected = useRowSelection().isSelected(row.id);
|
||||
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} ${isSelected ? classes.rowSelected : ""}`}
|
||||
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}
|
||||
orderedRowIds={orderedRowIds}
|
||||
rowDragProps={
|
||||
isRowNumber && dragHandlers
|
||||
? {
|
||||
draggable: true,
|
||||
onDragStart: handleDragStart,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { memo, useCallback } from "react";
|
||||
import { Checkbox } from "@mantine/core";
|
||||
import { IconGripVertical } from "@tabler/icons-react";
|
||||
import { useRowSelection } from "@/features/base/hooks/use-row-selection";
|
||||
import classes from "@/features/base/styles/grid.module.css";
|
||||
|
||||
type RowDragProps = {
|
||||
draggable: boolean;
|
||||
onDragStart: (e: React.DragEvent) => void;
|
||||
};
|
||||
|
||||
type RowNumberCellProps = {
|
||||
rowId: string;
|
||||
rowIndex: number;
|
||||
orderedRowIds: string[];
|
||||
isPinned: boolean;
|
||||
pinOffset?: number;
|
||||
rowDragProps?: RowDragProps;
|
||||
};
|
||||
|
||||
export const RowNumberCell = memo(function RowNumberCell({
|
||||
rowId,
|
||||
rowIndex,
|
||||
orderedRowIds,
|
||||
isPinned,
|
||||
pinOffset,
|
||||
rowDragProps,
|
||||
}: RowNumberCellProps) {
|
||||
const { isSelected, toggle } = useRowSelection();
|
||||
const selected = isSelected(rowId);
|
||||
|
||||
const handleCheckboxChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const nativeEvent = e.nativeEvent as MouseEvent;
|
||||
toggle(rowId, {
|
||||
shiftKey: nativeEvent.shiftKey === true,
|
||||
rowIndex,
|
||||
orderedRowIds,
|
||||
});
|
||||
},
|
||||
[rowId, rowIndex, orderedRowIds, toggle],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${classes.cell} ${classes.rowNumberCell} ${isPinned ? classes.cellPinned : ""}`}
|
||||
style={isPinned ? { left: pinOffset } : undefined}
|
||||
>
|
||||
<div className={classes.rowNumberCellInner}>
|
||||
<span
|
||||
className={classes.rowNumberDragHandle}
|
||||
draggable={rowDragProps?.draggable}
|
||||
onDragStart={rowDragProps?.onDragStart}
|
||||
aria-label="Drag row"
|
||||
>
|
||||
<IconGripVertical size={12} />
|
||||
</span>
|
||||
<span className={classes.rowNumberCheckbox}>
|
||||
<Checkbox
|
||||
size="xs"
|
||||
checked={selected}
|
||||
onChange={handleCheckboxChange}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
</span>
|
||||
<span className={classes.rowNumberIndex}>{rowIndex + 1}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { memo, useMemo } from "react";
|
||||
import { Checkbox, Tooltip } from "@mantine/core";
|
||||
import { useRowSelection } from "@/features/base/hooks/use-row-selection";
|
||||
import classes from "@/features/base/styles/grid.module.css";
|
||||
|
||||
type RowNumberHeaderCellProps = {
|
||||
loadedRowIds: string[];
|
||||
};
|
||||
|
||||
export const RowNumberHeaderCell = memo(function RowNumberHeaderCell({
|
||||
loadedRowIds,
|
||||
}: RowNumberHeaderCellProps) {
|
||||
const { selectedIds, toggleAll } = useRowSelection();
|
||||
|
||||
const { checked, indeterminate } = useMemo(() => {
|
||||
if (loadedRowIds.length === 0) {
|
||||
return { checked: false, indeterminate: false };
|
||||
}
|
||||
const selectedInLoaded = loadedRowIds.reduce(
|
||||
(acc, id) => (selectedIds.has(id) ? acc + 1 : acc),
|
||||
0,
|
||||
);
|
||||
return {
|
||||
checked: selectedInLoaded === loadedRowIds.length,
|
||||
indeterminate:
|
||||
selectedInLoaded > 0 && selectedInLoaded < loadedRowIds.length,
|
||||
};
|
||||
}, [loadedRowIds, selectedIds]);
|
||||
|
||||
if (loadedRowIds.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={classes.rowNumberHeaderInner}>
|
||||
<span className={classes.rowNumberHeaderHash}>#</span>
|
||||
<span className={classes.rowNumberHeaderCheckbox}>
|
||||
<Tooltip label="Select all loaded rows" withinPortal>
|
||||
<Checkbox
|
||||
size="xs"
|
||||
checked={checked}
|
||||
indeterminate={indeterminate}
|
||||
onChange={() => toggleAll(loadedRowIds)}
|
||||
aria-label="Select all loaded rows"
|
||||
/>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { memo } from "react";
|
||||
import { Transition } from "@mantine/core";
|
||||
import { IconTrash, IconX } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRowSelection } from "@/features/base/hooks/use-row-selection";
|
||||
import { useDeleteSelectedRows } from "@/features/base/hooks/use-delete-selected-rows";
|
||||
import classes from "@/features/base/styles/grid.module.css";
|
||||
|
||||
type SelectionActionBarProps = {
|
||||
baseId: string;
|
||||
};
|
||||
|
||||
export const SelectionActionBar = memo(function SelectionActionBar({
|
||||
baseId,
|
||||
}: SelectionActionBarProps) {
|
||||
const { t } = useTranslation();
|
||||
const { selectionCount, clear } = useRowSelection();
|
||||
const { deleteSelected, isPending } = useDeleteSelectedRows(baseId);
|
||||
|
||||
const isOpen = selectionCount > 0;
|
||||
|
||||
return (
|
||||
<Transition mounted={isOpen} transition="slide-up" duration={150}>
|
||||
{(styles) => (
|
||||
<div className={classes.selectionActionBarWrapper} style={styles}>
|
||||
<div className={classes.selectionActionBar} role="toolbar">
|
||||
<span className={classes.selectionActionBarCount}>
|
||||
{t("{{count}} selected", { count: selectionCount })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={classes.selectionActionBarDelete}
|
||||
disabled={isPending}
|
||||
onClick={() => void deleteSelected()}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
{t("Delete")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={classes.selectionActionBarClose}
|
||||
onClick={clear}
|
||||
aria-label={t("Clear selection")}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Transition>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,547 @@
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
||||
import {
|
||||
TextInput,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Button,
|
||||
Popover,
|
||||
SimpleGrid,
|
||||
UnstyledButton,
|
||||
CloseButton,
|
||||
Divider,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconPlus,
|
||||
IconGripVertical,
|
||||
IconArrowsSort,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
arrayMove,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Choice } from "@/features/base/types/base.types";
|
||||
import { choiceColor } from "@/features/base/components/cells/choice-color";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { v7 as uuid7 } from "uuid";
|
||||
|
||||
const CHOICE_COLORS = [
|
||||
"gray", "red", "pink", "grape", "violet", "indigo",
|
||||
"blue", "cyan", "teal", "green", "lime", "yellow", "orange",
|
||||
];
|
||||
|
||||
const STATUS_CATEGORIES = [
|
||||
{ value: "todo", label: "To Do" },
|
||||
{ value: "inProgress", label: "In Progress" },
|
||||
{ value: "complete", label: "Complete" },
|
||||
] as const;
|
||||
|
||||
type ChoiceEditorProps = {
|
||||
initialChoices: Choice[];
|
||||
onSave: (choices: Choice[]) => void;
|
||||
onClose: () => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
showCategories?: boolean;
|
||||
hideButtons?: boolean;
|
||||
};
|
||||
|
||||
export function ChoiceEditor({
|
||||
initialChoices,
|
||||
onSave,
|
||||
onClose,
|
||||
onDirtyChange,
|
||||
showCategories = false,
|
||||
hideButtons = false,
|
||||
}: ChoiceEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [draft, setDraft] = useState<Choice[]>(initialChoices);
|
||||
const [focusChoiceId, setFocusChoiceId] = useState<string | null>(null);
|
||||
|
||||
// Sync from parent only when not in live mode (hideButtons = create flow)
|
||||
useEffect(() => {
|
||||
if (!hideButtons) {
|
||||
setDraft(initialChoices);
|
||||
}
|
||||
}, [initialChoices, hideButtons]);
|
||||
|
||||
// In live mode, propagate draft changes to parent immediately
|
||||
const onSaveRef = useRef(onSave);
|
||||
onSaveRef.current = onSave;
|
||||
|
||||
useEffect(() => {
|
||||
if (hideButtons) {
|
||||
onSaveRef.current(draft.filter((c) => c.name.trim()));
|
||||
}
|
||||
}, [hideButtons, draft]);
|
||||
|
||||
const isDirty = useMemo(() => {
|
||||
if (draft.length !== initialChoices.length) return true;
|
||||
return draft.some((d, i) => {
|
||||
const o = initialChoices[i];
|
||||
return d.id !== o.id || d.name !== o.name || d.color !== o.color || d.category !== o.category;
|
||||
});
|
||||
}, [draft, initialChoices]);
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(isDirty);
|
||||
}, [isDirty, onDirtyChange]);
|
||||
|
||||
const hasEmptyNames = draft.some((c) => !c.name.trim());
|
||||
|
||||
const handleRename = useCallback((choiceId: string, name: string) => {
|
||||
setDraft((prev) => prev.map((c) => (c.id === choiceId ? { ...c, name } : c)));
|
||||
}, []);
|
||||
|
||||
const handleColorChange = useCallback((choiceId: string, color: string) => {
|
||||
setDraft((prev) => prev.map((c) => (c.id === choiceId ? { ...c, color } : c)));
|
||||
}, []);
|
||||
|
||||
const handleRemove = useCallback((choiceId: string) => {
|
||||
setDraft((prev) => prev.filter((c) => c.id !== choiceId));
|
||||
}, []);
|
||||
|
||||
const handleAdd = useCallback((category?: "todo" | "inProgress" | "complete") => {
|
||||
const id = uuid7();
|
||||
setDraft((prev) => {
|
||||
const colorIndex = prev.length % CHOICE_COLORS.length;
|
||||
const newChoice: Choice = {
|
||||
id,
|
||||
name: "",
|
||||
color: CHOICE_COLORS[colorIndex],
|
||||
...(category ? { category } : {}),
|
||||
};
|
||||
return [...prev, newChoice];
|
||||
});
|
||||
setFocusChoiceId(id);
|
||||
}, []);
|
||||
|
||||
const handleAlphabetize = useCallback(() => {
|
||||
setDraft((prev) => [...prev].sort((a, b) => a.name.localeCompare(b.name)));
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const cleaned = draft.filter((c) => c.name.trim());
|
||||
onSave(cleaned);
|
||||
onClose();
|
||||
}, [draft, onSave, onClose]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setDraft(initialChoices);
|
||||
onDirtyChange?.(false);
|
||||
onClose();
|
||||
}, [initialChoices, onDirtyChange, onClose]);
|
||||
|
||||
const handleReorder = useCallback((activeId: string, overId: string) => {
|
||||
setDraft((prev) => {
|
||||
const oldIndex = prev.findIndex((c) => c.id === activeId);
|
||||
const newIndex = prev.findIndex((c) => c.id === overId);
|
||||
if (oldIndex === -1 || newIndex === -1) return prev;
|
||||
return arrayMove(prev, oldIndex, newIndex);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleCategoryReorder = useCallback(
|
||||
(category: string, activeId: string, overId: string) => {
|
||||
setDraft((prev) => {
|
||||
const catChoices = prev.filter((c) => (c.category ?? "todo") === category);
|
||||
const oldIndex = catChoices.findIndex((c) => c.id === activeId);
|
||||
const newIndex = catChoices.findIndex((c) => c.id === overId);
|
||||
if (oldIndex === -1 || newIndex === -1) return prev;
|
||||
const reordered = arrayMove(catChoices, oldIndex, newIndex);
|
||||
const result: Choice[] = [];
|
||||
for (const cat of ["todo", "inProgress", "complete"]) {
|
||||
if (cat === category) {
|
||||
result.push(...reordered);
|
||||
} else {
|
||||
result.push(...prev.filter((c) => (c.category ?? "todo") === cat));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" fw={600}>
|
||||
{t("Options")}
|
||||
</Text>
|
||||
<UnstyledButton onClick={handleAlphabetize} style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
<IconArrowsSort size={14} color="var(--mantine-color-dimmed)" />
|
||||
<Text size="xs" c="dimmed">{t("Alphabetize")}</Text>
|
||||
</UnstyledButton>
|
||||
</Group>
|
||||
|
||||
{showCategories ? (
|
||||
<StatusChoiceList
|
||||
draft={draft}
|
||||
focusChoiceId={focusChoiceId}
|
||||
onFocused={() => setFocusChoiceId(null)}
|
||||
onRename={handleRename}
|
||||
onColorChange={handleColorChange}
|
||||
onRemove={handleRemove}
|
||||
onAdd={handleAdd}
|
||||
onCategoryReorder={handleCategoryReorder}
|
||||
/>
|
||||
) : (
|
||||
<FlatChoiceList
|
||||
draft={draft}
|
||||
focusChoiceId={focusChoiceId}
|
||||
onFocused={() => setFocusChoiceId(null)}
|
||||
onRename={handleRename}
|
||||
onColorChange={handleColorChange}
|
||||
onRemove={handleRemove}
|
||||
onAdd={handleAdd}
|
||||
onReorder={handleReorder}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!hideButtons && (
|
||||
<>
|
||||
<Divider />
|
||||
|
||||
<Group justify="flex-end" gap="xs">
|
||||
<Button variant="default" size="xs" onClick={handleCancel}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button size="xs" onClick={handleSave} disabled={!isDirty || hasEmptyNames}>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function FlatChoiceList({
|
||||
draft,
|
||||
focusChoiceId,
|
||||
onFocused,
|
||||
onRename,
|
||||
onColorChange,
|
||||
onRemove,
|
||||
onAdd,
|
||||
onReorder,
|
||||
}: {
|
||||
draft: Choice[];
|
||||
focusChoiceId: string | null;
|
||||
onFocused: () => void;
|
||||
onRename: (id: string, name: string) => void;
|
||||
onColorChange: (id: string, color: string) => void;
|
||||
onRemove: (id: string) => void;
|
||||
onAdd: () => void;
|
||||
onReorder: (activeId: string, overId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const choiceIds = useMemo(() => draft.map((c) => c.id), [draft]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
onReorder(active.id as string, over.id as string);
|
||||
},
|
||||
[onReorder],
|
||||
);
|
||||
|
||||
const modifiers = useMemo(() => [restrictToVerticalAxis], []);
|
||||
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
modifiers={modifiers}
|
||||
>
|
||||
<SortableContext items={choiceIds} strategy={verticalListSortingStrategy}>
|
||||
{draft.map((choice) => (
|
||||
<SortableChoiceRow
|
||||
key={choice.id}
|
||||
choice={choice}
|
||||
autoFocus={choice.id === focusChoiceId}
|
||||
onFocused={onFocused}
|
||||
onRename={onRename}
|
||||
onColorChange={onColorChange}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
<UnstyledButton
|
||||
onClick={() => onAdd()}
|
||||
style={{ display: "flex", alignItems: "center", gap: 6, padding: "4px 0" }}
|
||||
>
|
||||
<IconPlus size={14} color="var(--mantine-color-dimmed)" />
|
||||
<Text size="xs" c="dimmed">{t("Add option")}</Text>
|
||||
</UnstyledButton>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusChoiceList({
|
||||
draft,
|
||||
focusChoiceId,
|
||||
onFocused,
|
||||
onRename,
|
||||
onColorChange,
|
||||
onRemove,
|
||||
onAdd,
|
||||
onCategoryReorder,
|
||||
}: {
|
||||
draft: Choice[];
|
||||
focusChoiceId: string | null;
|
||||
onFocused: () => void;
|
||||
onRename: (id: string, name: string) => void;
|
||||
onColorChange: (id: string, color: string) => void;
|
||||
onRemove: (id: string) => void;
|
||||
onAdd: (category: "todo" | "inProgress" | "complete") => void;
|
||||
onCategoryReorder: (category: string, activeId: string, overId: string) => void;
|
||||
}) {
|
||||
const grouped = useMemo(() => {
|
||||
const groups: Record<string, Choice[]> = { todo: [], inProgress: [], complete: [] };
|
||||
for (const choice of draft) {
|
||||
const cat = choice.category ?? "todo";
|
||||
(groups[cat] ?? groups.todo).push(choice);
|
||||
}
|
||||
return groups;
|
||||
}, [draft]);
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{STATUS_CATEGORIES.map(({ value: category, label }) => (
|
||||
<CategorySection
|
||||
key={category}
|
||||
category={category as "todo" | "inProgress" | "complete"}
|
||||
label={label}
|
||||
choices={grouped[category] ?? []}
|
||||
focusChoiceId={focusChoiceId}
|
||||
onFocused={onFocused}
|
||||
onRename={onRename}
|
||||
onColorChange={onColorChange}
|
||||
onRemove={onRemove}
|
||||
onAdd={onAdd}
|
||||
onReorder={onCategoryReorder}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function CategorySection({
|
||||
category,
|
||||
label,
|
||||
choices,
|
||||
focusChoiceId,
|
||||
onFocused,
|
||||
onRename,
|
||||
onColorChange,
|
||||
onRemove,
|
||||
onAdd,
|
||||
onReorder,
|
||||
}: {
|
||||
category: "todo" | "inProgress" | "complete";
|
||||
label: string;
|
||||
choices: Choice[];
|
||||
focusChoiceId: string | null;
|
||||
onFocused: () => void;
|
||||
onRename: (id: string, name: string) => void;
|
||||
onColorChange: (id: string, color: string) => void;
|
||||
onRemove: (id: string) => void;
|
||||
onAdd: (category: "todo" | "inProgress" | "complete") => void;
|
||||
onReorder: (category: string, activeId: string, overId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const choiceIds = useMemo(() => choices.map((c) => c.id), [choices]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
onReorder(category, active.id as string, over.id as string);
|
||||
},
|
||||
[category, onReorder],
|
||||
);
|
||||
|
||||
const modifiers = useMemo(() => [restrictToVerticalAxis], []);
|
||||
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{t(label)}
|
||||
</Text>
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
modifiers={modifiers}
|
||||
>
|
||||
<SortableContext items={choiceIds} strategy={verticalListSortingStrategy}>
|
||||
{choices.map((choice) => (
|
||||
<SortableChoiceRow
|
||||
key={choice.id}
|
||||
choice={choice}
|
||||
autoFocus={choice.id === focusChoiceId}
|
||||
onFocused={onFocused}
|
||||
onRename={onRename}
|
||||
onColorChange={onColorChange}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
<UnstyledButton
|
||||
onClick={() => onAdd(category)}
|
||||
style={{ display: "flex", alignItems: "center", gap: 6, padding: "4px 0" }}
|
||||
>
|
||||
<IconPlus size={14} color="var(--mantine-color-dimmed)" />
|
||||
<Text size="xs" c="dimmed">{t("Add option")}</Text>
|
||||
</UnstyledButton>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function SortableChoiceRow({
|
||||
choice,
|
||||
autoFocus,
|
||||
onFocused,
|
||||
onRename,
|
||||
onColorChange,
|
||||
onRemove,
|
||||
}: {
|
||||
choice: Choice;
|
||||
autoFocus?: boolean;
|
||||
onFocused?: () => void;
|
||||
onRename: (id: string, name: string) => void;
|
||||
onColorChange: (id: string, color: string) => void;
|
||||
onRemove: (id: string) => void;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: choice.id });
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus) {
|
||||
inputRef.current?.focus();
|
||||
onFocused?.();
|
||||
}
|
||||
}, [autoFocus, onFocused]);
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform ? { ...transform, scaleX: 1, scaleY: 1 } : null),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
zIndex: isDragging ? 10 : undefined,
|
||||
};
|
||||
|
||||
const hasError = !choice.name.trim();
|
||||
|
||||
return (
|
||||
<Group ref={setNodeRef} style={style} gap={6} wrap="nowrap" align="center">
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
style={{ flexShrink: 0, cursor: "grab", display: "flex", alignItems: "center" }}
|
||||
>
|
||||
<IconGripVertical size={14} style={{ opacity: 0.4 }} />
|
||||
</div>
|
||||
<ColorDot color={choice.color} onChange={(c) => onColorChange(choice.id, c)} />
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
size="xs"
|
||||
value={choice.name}
|
||||
onChange={(e) => onRename(choice.id, e.currentTarget.value)}
|
||||
style={{ flex: 1 }}
|
||||
error={hasError}
|
||||
styles={hasError ? { input: { borderColor: "var(--mantine-color-red-6)" } } : undefined}
|
||||
/>
|
||||
<CloseButton size="sm" onClick={() => onRemove(choice.id)} />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorDot({
|
||||
color,
|
||||
onChange,
|
||||
}: {
|
||||
color: string;
|
||||
onChange: (color: string) => void;
|
||||
}) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const colors = choiceColor(color);
|
||||
|
||||
return (
|
||||
<Popover opened={opened} onChange={setOpened} position="bottom" shadow="sm" withinPortal>
|
||||
<Popover.Target>
|
||||
<UnstyledButton
|
||||
onClick={() => setOpened((o) => !o)}
|
||||
style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: colors.backgroundColor as string,
|
||||
border: `2px solid ${colors.color as string}`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p={8}>
|
||||
<SimpleGrid cols={5} spacing={6}>
|
||||
{CHOICE_COLORS.map((c) => {
|
||||
const dotColors = choiceColor(c);
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={c}
|
||||
onClick={() => {
|
||||
onChange(c);
|
||||
setOpened(false);
|
||||
}}
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: dotColors.backgroundColor as string,
|
||||
border: c === color
|
||||
? `2px solid ${dotColors.color as string}`
|
||||
: "2px solid transparent",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||
import {
|
||||
Popover,
|
||||
Portal,
|
||||
TextInput,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Divider,
|
||||
UnstyledButton,
|
||||
Text,
|
||||
ScrollArea,
|
||||
} from "@mantine/core";
|
||||
import { IconPlus, IconChevronRight } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
BasePropertyType,
|
||||
IBaseProperty,
|
||||
TypeOptions,
|
||||
} from "@/features/base/types/base.types";
|
||||
import { useCreatePropertyMutation } from "@/features/base/queries/base-property-query";
|
||||
import { PropertyTypePicker, propertyTypes } from "./property-type-picker";
|
||||
import { PropertyOptions } from "./property-options";
|
||||
import classes from "@/features/base/styles/grid.module.css";
|
||||
|
||||
type CreatePropertyPopoverProps = {
|
||||
baseId: string;
|
||||
onPropertyCreated?: () => void;
|
||||
};
|
||||
|
||||
type Panel = "typePicker" | "configure" | "confirmDiscard";
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
// Keep in sync with the switch cases in PropertyOptions
|
||||
const typesWithOptions = new Set<BasePropertyType>([
|
||||
"select",
|
||||
"multiSelect",
|
||||
"status",
|
||||
"number",
|
||||
"date",
|
||||
"person",
|
||||
]);
|
||||
|
||||
export function CreatePropertyPopover({ baseId, onPropertyCreated }: CreatePropertyPopoverProps) {
|
||||
const { t } = useTranslation();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [panel, setPanel] = useState<Panel>("typePicker");
|
||||
const [selectedType, setSelectedType] = useState<BasePropertyType | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [typeOptions, setTypeOptions] = useState<Record<string, unknown>>({});
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const createPropertyMutation = useCreatePropertyMutation();
|
||||
|
||||
const selectedTypeDef = useMemo(
|
||||
() => propertyTypes.find((pt) => pt.type === selectedType),
|
||||
[selectedType],
|
||||
);
|
||||
const selectedTypeLabel = selectedTypeDef ? t(selectedTypeDef.labelKey) : "";
|
||||
const selectedTypeIcon = selectedTypeDef?.icon;
|
||||
|
||||
const hasContent = useMemo(() => {
|
||||
return name.trim().length > 0 || Object.keys(typeOptions).length > 0;
|
||||
}, [name, typeOptions]);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setPanel("typePicker");
|
||||
setSelectedType(null);
|
||||
setName("");
|
||||
setTypeOptions({});
|
||||
}, []);
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
resetState();
|
||||
setOpened(true);
|
||||
}, [resetState]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpened(false);
|
||||
resetState();
|
||||
}, [resetState]);
|
||||
|
||||
const attemptClose = useCallback(() => {
|
||||
if (panel === "configure" && hasContent) {
|
||||
setPanel("confirmDiscard");
|
||||
} else {
|
||||
handleClose();
|
||||
}
|
||||
}, [panel, hasContent, handleClose]);
|
||||
|
||||
const handleConfirmDiscard = useCallback(() => {
|
||||
handleClose();
|
||||
}, [handleClose]);
|
||||
|
||||
const handleCancelDiscard = useCallback(() => {
|
||||
setPanel("configure");
|
||||
}, []);
|
||||
|
||||
const handleTypeSelect = useCallback((type: BasePropertyType) => {
|
||||
setSelectedType(type);
|
||||
setTypeOptions({});
|
||||
setPanel("configure");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (panel === "configure") {
|
||||
setTimeout(() => nameInputRef.current?.focus(), 0);
|
||||
}
|
||||
}, [panel]);
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
if (!selectedType) return;
|
||||
const finalName = name.trim() || selectedTypeLabel;
|
||||
createPropertyMutation.mutate(
|
||||
{
|
||||
baseId,
|
||||
name: finalName,
|
||||
type: selectedType,
|
||||
typeOptions: Object.keys(typeOptions).length > 0
|
||||
? typeOptions as TypeOptions
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onPropertyCreated?.();
|
||||
},
|
||||
},
|
||||
);
|
||||
handleClose();
|
||||
}, [selectedType, name, selectedTypeLabel, typeOptions, baseId, createPropertyMutation, handleClose, onPropertyCreated]);
|
||||
|
||||
const handleBackToTypePicker = useCallback(() => {
|
||||
setPanel("typePicker");
|
||||
setTypeOptions({});
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (panel === "confirmDiscard") {
|
||||
handleCancelDiscard();
|
||||
} else if (panel === "configure") {
|
||||
handleBackToTypePicker();
|
||||
} else {
|
||||
handleClose();
|
||||
}
|
||||
}
|
||||
},
|
||||
[panel, handleBackToTypePicker, handleClose, handleCancelDiscard],
|
||||
);
|
||||
|
||||
const handleNameKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleCreate();
|
||||
}
|
||||
},
|
||||
[handleCreate],
|
||||
);
|
||||
|
||||
const handleOptionsUpdate = useCallback(
|
||||
(newTypeOptions: Record<string, unknown>) => {
|
||||
setTypeOptions(newTypeOptions);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const syntheticProperty: IBaseProperty = useMemo(() => ({
|
||||
id: "",
|
||||
baseId,
|
||||
name: name || "",
|
||||
type: selectedType ?? "text",
|
||||
position: "",
|
||||
typeOptions: typeOptions as TypeOptions,
|
||||
isPrimary: false,
|
||||
workspaceId: "",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
}), [baseId, name, selectedType, typeOptions]);
|
||||
|
||||
const TypeIcon = selectedTypeIcon;
|
||||
const showOptions = selectedType && typesWithOptions.has(selectedType);
|
||||
|
||||
return (
|
||||
<>
|
||||
{opened && (
|
||||
<Portal>
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 299,
|
||||
}}
|
||||
onClick={attemptClose}
|
||||
/>
|
||||
</Portal>
|
||||
)}
|
||||
<Popover
|
||||
opened={opened}
|
||||
onClose={noop}
|
||||
position="bottom-start"
|
||||
shadow="md"
|
||||
width={320}
|
||||
withinPortal
|
||||
>
|
||||
<Popover.Target>
|
||||
<div
|
||||
className={classes.addColumnButton}
|
||||
onClick={handleOpen}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<IconPlus size={16} />
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown
|
||||
p={0}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{ zIndex: 300 }}
|
||||
>
|
||||
{panel === "typePicker" && (
|
||||
<Stack gap={0} p={4}>
|
||||
<PropertyTypePicker
|
||||
onSelect={handleTypeSelect}
|
||||
showSearch
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
{(panel === "configure" || panel === "confirmDiscard") && (
|
||||
<Stack gap={0} p="sm" style={panel === "confirmDiscard" ? { display: "none" } : undefined}>
|
||||
<TextInput
|
||||
ref={nameInputRef}
|
||||
size="xs"
|
||||
placeholder={selectedTypeLabel}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.currentTarget.value)}
|
||||
onKeyDown={handleNameKeyDown}
|
||||
mb="xs"
|
||||
/>
|
||||
<UnstyledButton
|
||||
onClick={handleBackToTypePicker}
|
||||
py={6}
|
||||
px={0}
|
||||
mb={showOptions ? "xs" : 0}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
{TypeIcon && <TypeIcon size={14} />}
|
||||
<Text size="sm" style={{ flex: 1 }}>
|
||||
{selectedTypeLabel}
|
||||
</Text>
|
||||
<IconChevronRight size={14} />
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
|
||||
{showOptions && (
|
||||
<>
|
||||
<Divider mb="xs" />
|
||||
<ScrollArea.Autosize mah={300} scrollbarSize={6} offsetScrollbars>
|
||||
<PropertyOptions
|
||||
property={syntheticProperty}
|
||||
onUpdate={handleOptionsUpdate}
|
||||
onClose={noop}
|
||||
onDirtyChange={noop}
|
||||
hideButtons
|
||||
/>
|
||||
</ScrollArea.Autosize>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider my="xs" />
|
||||
<Group gap="xs" justify="flex-end">
|
||||
<Button variant="default" size="xs" onClick={attemptClose}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button size="xs" onClick={handleCreate}>
|
||||
{t("Create field")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
{panel === "confirmDiscard" && (
|
||||
<Stack gap="xs" p="sm">
|
||||
<Text size="sm" fw={600}>
|
||||
{t("Unsaved changes")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("You have unsaved changes. Do you want to discard them?")}
|
||||
</Text>
|
||||
<Group gap="xs" justify="flex-end">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={handleCancelDiscard}
|
||||
>
|
||||
{t("Keep editing")}
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
size="xs"
|
||||
onClick={handleConfirmDiscard}
|
||||
>
|
||||
{t("Discard")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import {
|
||||
UnstyledButton,
|
||||
TextInput,
|
||||
Button,
|
||||
Stack,
|
||||
Text,
|
||||
Group,
|
||||
ActionIcon,
|
||||
Divider,
|
||||
ScrollArea,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconTrash,
|
||||
IconPencil,
|
||||
IconChevronRight,
|
||||
IconSettings,
|
||||
} from "@tabler/icons-react";
|
||||
import { IBaseProperty } from "@/features/base/types/base.types";
|
||||
import { useAtom } from "jotai";
|
||||
import { propertyMenuCloseRequestAtom } from "@/features/base/atoms/base-atoms";
|
||||
import {
|
||||
useUpdatePropertyMutation,
|
||||
useDeletePropertyMutation,
|
||||
} from "@/features/base/queries/base-property-query";
|
||||
import { propertyTypes } from "./property-type-picker";
|
||||
import { PropertyOptions } from "./property-options";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isSystemPropertyType } from "@/features/base/hooks/use-base-table";
|
||||
import cellClasses from "@/features/base/styles/cells.module.css";
|
||||
|
||||
type PropertyMenuContentProps = {
|
||||
property: IBaseProperty;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
};
|
||||
|
||||
type MenuPanel = "main" | "rename" | "options" | "confirmDelete" | "confirmDiscard";
|
||||
|
||||
export function PropertyMenuContent({
|
||||
property,
|
||||
opened,
|
||||
onClose,
|
||||
onDirtyChange,
|
||||
}: PropertyMenuContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const [panel, setPanel] = useState<MenuPanel>("main");
|
||||
const [renameValue, setRenameValue] = useState(property.name);
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
const [optionsDirty, setOptionsDirty] = useState(false);
|
||||
const pendingActionRef = useRef<"back" | "close" | null>(null);
|
||||
const sourcePanelRef = useRef<"rename" | "options" | null>(null);
|
||||
const [closeRequest] = useAtom(propertyMenuCloseRequestAtom) as unknown as [number];
|
||||
const closeRequestRef = useRef(closeRequest);
|
||||
|
||||
const renameDirty = renameValue !== property.name;
|
||||
|
||||
const updatePropertyMutation = useUpdatePropertyMutation();
|
||||
const deletePropertyMutation = useDeletePropertyMutation();
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setPanel("main");
|
||||
setRenameValue(property.name);
|
||||
setOptionsDirty(false);
|
||||
}
|
||||
}, [opened, property.name]);
|
||||
|
||||
useEffect(() => {
|
||||
if (panel === "rename") {
|
||||
setTimeout(() => renameInputRef.current?.select(), 0);
|
||||
}
|
||||
}, [panel]);
|
||||
|
||||
const handleOptionsDirtyChange = useCallback((dirty: boolean) => {
|
||||
setOptionsDirty(dirty);
|
||||
}, []);
|
||||
|
||||
// Single dirty signal to the outside — reflects whichever panel is
|
||||
// currently accumulating unsaved work. Keeps rename and options in
|
||||
// lockstep with the `propertyMenuDirtyAtom` so the grid-container's
|
||||
// outside-click handler and the header's ESC handler both prompt
|
||||
// "Unsaved changes" consistently.
|
||||
useEffect(() => {
|
||||
const dirty =
|
||||
(panel === "rename" && renameDirty) ||
|
||||
(panel === "options" && optionsDirty);
|
||||
onDirtyChange?.(dirty);
|
||||
}, [panel, renameDirty, optionsDirty, onDirtyChange]);
|
||||
|
||||
const commitRename = useCallback(() => {
|
||||
const trimmed = renameValue.trim();
|
||||
if (trimmed && trimmed !== property.name) {
|
||||
updatePropertyMutation.mutate({
|
||||
propertyId: property.id,
|
||||
baseId: property.baseId,
|
||||
name: trimmed,
|
||||
});
|
||||
}
|
||||
}, [renameValue, property, updatePropertyMutation]);
|
||||
|
||||
const handleRenameAndClose = useCallback(() => {
|
||||
commitRename();
|
||||
onClose();
|
||||
}, [commitRename, onClose]);
|
||||
|
||||
const requestClose = useCallback(() => {
|
||||
if (panel === "rename" && renameDirty) {
|
||||
sourcePanelRef.current = "rename";
|
||||
pendingActionRef.current = "close";
|
||||
setPanel("confirmDiscard");
|
||||
} else if (panel === "options" && optionsDirty) {
|
||||
sourcePanelRef.current = "options";
|
||||
pendingActionRef.current = "close";
|
||||
setPanel("confirmDiscard");
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}, [panel, renameDirty, optionsDirty, onClose]);
|
||||
|
||||
const handleRenameKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleRenameAndClose();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
requestClose();
|
||||
}
|
||||
},
|
||||
[handleRenameAndClose, requestClose],
|
||||
);
|
||||
|
||||
const handleOptionsUpdate = useCallback(
|
||||
(typeOptions: Record<string, unknown>) => {
|
||||
updatePropertyMutation.mutate({
|
||||
propertyId: property.id,
|
||||
baseId: property.baseId,
|
||||
typeOptions,
|
||||
});
|
||||
setOptionsDirty(false);
|
||||
},
|
||||
[property, updatePropertyMutation],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
deletePropertyMutation.mutate({
|
||||
propertyId: property.id,
|
||||
baseId: property.baseId,
|
||||
});
|
||||
onClose();
|
||||
}, [property, deletePropertyMutation, onClose]);
|
||||
|
||||
const handleOptionsBack = useCallback(() => {
|
||||
if (optionsDirty) {
|
||||
sourcePanelRef.current = "options";
|
||||
pendingActionRef.current = "back";
|
||||
setPanel("confirmDiscard");
|
||||
} else {
|
||||
setPanel("main");
|
||||
}
|
||||
}, [optionsDirty]);
|
||||
|
||||
useEffect(() => {
|
||||
if (closeRequest !== closeRequestRef.current) {
|
||||
closeRequestRef.current = closeRequest;
|
||||
if (opened) {
|
||||
requestClose();
|
||||
}
|
||||
}
|
||||
}, [closeRequest, opened, requestClose]);
|
||||
|
||||
const handleConfirmDiscard = useCallback(() => {
|
||||
setOptionsDirty(false);
|
||||
setRenameValue(property.name);
|
||||
const action = pendingActionRef.current;
|
||||
pendingActionRef.current = null;
|
||||
sourcePanelRef.current = null;
|
||||
if (action === "back") {
|
||||
setPanel("main");
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}, [property.name, onClose]);
|
||||
|
||||
const handleCancelDiscard = useCallback(() => {
|
||||
const source = sourcePanelRef.current ?? "options";
|
||||
pendingActionRef.current = null;
|
||||
sourcePanelRef.current = null;
|
||||
setPanel(source);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{panel === "main" && (
|
||||
<MainPanel
|
||||
property={property}
|
||||
onRename={() => setPanel("rename")}
|
||||
onOptions={() => setPanel("options")}
|
||||
onDelete={() => setPanel("confirmDelete")}
|
||||
/>
|
||||
)}
|
||||
{panel === "rename" && (
|
||||
<Stack gap="xs" p="sm">
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{t("Rename property")}
|
||||
</Text>
|
||||
<TextInput
|
||||
ref={renameInputRef}
|
||||
size="xs"
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.currentTarget.value)}
|
||||
onKeyDown={handleRenameKeyDown}
|
||||
/>
|
||||
<Divider />
|
||||
<Group justify="flex-end" gap="xs">
|
||||
<Button variant="default" size="xs" onClick={requestClose}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={handleRenameAndClose}
|
||||
disabled={!renameValue.trim() || renameValue.trim() === property.name}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
{(panel === "options" || panel === "confirmDiscard") && (
|
||||
<Stack gap="xs" p="sm" style={panel === "confirmDiscard" ? { display: "none" } : undefined}>
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="xs"
|
||||
onClick={handleOptionsBack}
|
||||
>
|
||||
<IconChevronRight
|
||||
size={14}
|
||||
style={{ transform: "rotate(180deg)" }}
|
||||
/>
|
||||
</ActionIcon>
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{t("Property options")}
|
||||
</Text>
|
||||
</Group>
|
||||
<ScrollArea.Autosize mah={400} scrollbarSize={6} offsetScrollbars>
|
||||
<PropertyOptions
|
||||
property={property}
|
||||
onUpdate={handleOptionsUpdate}
|
||||
onClose={onClose}
|
||||
onDirtyChange={handleOptionsDirtyChange}
|
||||
/>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
)}
|
||||
{panel === "confirmDelete" && (
|
||||
<Stack gap="xs" p="sm">
|
||||
<Text size="sm" fw={600}>
|
||||
{t("Delete property")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("Are you sure you want to delete")} <b>{property.name}</b>?{" "}
|
||||
{t("All data in this column will be lost.")}
|
||||
</Text>
|
||||
<Group gap="xs" justify="flex-end">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={() => setPanel("main")}
|
||||
>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
size="xs"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{t("Delete")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
{panel === "confirmDiscard" && (
|
||||
<Stack gap="xs" p="sm">
|
||||
<Text size="sm" fw={600}>
|
||||
{t("Unsaved changes")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("You have unsaved changes. Do you want to discard them?")}
|
||||
</Text>
|
||||
<Group gap="xs" justify="flex-end">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={handleCancelDiscard}
|
||||
>
|
||||
{t("Keep editing")}
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
size="xs"
|
||||
onClick={handleConfirmDiscard}
|
||||
>
|
||||
{t("Discard")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Expose requestClose for use by parent (grid-header-cell)
|
||||
PropertyMenuContent.displayName = "PropertyMenuContent";
|
||||
|
||||
function MenuItem({
|
||||
icon,
|
||||
label,
|
||||
rightIcon,
|
||||
color,
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
rightIcon?: React.ReactNode;
|
||||
color?: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<UnstyledButton
|
||||
className={cellClasses.menuItem}
|
||||
onClick={onClick}
|
||||
style={{ color: color ? `var(--mantine-color-${color}-6)` : undefined }}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
|
||||
{icon}
|
||||
<Text size="sm">{label}</Text>
|
||||
</Group>
|
||||
{rightIcon}
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
|
||||
function MainPanel({
|
||||
property,
|
||||
onRename,
|
||||
onOptions,
|
||||
onDelete,
|
||||
}: {
|
||||
property: IBaseProperty;
|
||||
onRename: () => void;
|
||||
onOptions: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isSystem = isSystemPropertyType(property.type);
|
||||
|
||||
const hasOptions =
|
||||
!isSystem &&
|
||||
(property.type === "select" ||
|
||||
property.type === "multiSelect" ||
|
||||
property.type === "status" ||
|
||||
property.type === "number" ||
|
||||
property.type === "date");
|
||||
|
||||
const typeDef = propertyTypes.find((pt) => pt.type === property.type);
|
||||
const TypeIcon = typeDef?.icon;
|
||||
|
||||
return (
|
||||
<Stack gap={0} p={4}>
|
||||
<MenuItem
|
||||
icon={<IconPencil size={14} />}
|
||||
label={t("Rename")}
|
||||
onClick={onRename}
|
||||
/>
|
||||
{!isSystem && (
|
||||
<Stack gap={4} px="sm" py={6}>
|
||||
<Text size="xs" c="dimmed">{t("Type")}</Text>
|
||||
<TextInput
|
||||
size="xs"
|
||||
value={typeDef ? t(typeDef.labelKey) : property.type}
|
||||
disabled
|
||||
leftSection={TypeIcon ? <TypeIcon size={14} /> : null}
|
||||
readOnly
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
{hasOptions && (
|
||||
<MenuItem
|
||||
icon={<IconSettings size={14} />}
|
||||
label={t("Options")}
|
||||
rightIcon={<IconChevronRight size={14} />}
|
||||
onClick={onOptions}
|
||||
/>
|
||||
)}
|
||||
{!property.isPrimary && (
|
||||
<>
|
||||
<Divider my={4} />
|
||||
<MenuItem
|
||||
icon={<IconTrash size={14} />}
|
||||
label={t("Delete property")}
|
||||
color="red"
|
||||
onClick={onDelete}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import { useCallback } from "react";
|
||||
import { Stack, NumberInput, Select, Switch, Text } from "@mantine/core";
|
||||
import {
|
||||
IBaseProperty,
|
||||
SelectTypeOptions,
|
||||
NumberTypeOptions,
|
||||
DateTypeOptions,
|
||||
PersonTypeOptions,
|
||||
Choice,
|
||||
} from "@/features/base/types/base.types";
|
||||
import { ChoiceEditor } from "./choice-editor";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type PropertyOptionsProps = {
|
||||
property: IBaseProperty;
|
||||
onUpdate: (typeOptions: Record<string, unknown>) => void;
|
||||
onClose: () => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
hideButtons?: boolean;
|
||||
};
|
||||
|
||||
export function PropertyOptions({ property, onUpdate, onClose, onDirtyChange, hideButtons }: PropertyOptionsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
switch (property.type) {
|
||||
case "select":
|
||||
case "multiSelect":
|
||||
return (
|
||||
<SelectOptions
|
||||
property={property}
|
||||
onUpdate={onUpdate}
|
||||
onClose={onClose}
|
||||
onDirtyChange={onDirtyChange}
|
||||
hideButtons={hideButtons}
|
||||
/>
|
||||
);
|
||||
case "status":
|
||||
return (
|
||||
<StatusOptions
|
||||
property={property}
|
||||
onUpdate={onUpdate}
|
||||
onClose={onClose}
|
||||
onDirtyChange={onDirtyChange}
|
||||
hideButtons={hideButtons}
|
||||
/>
|
||||
);
|
||||
case "number":
|
||||
return (
|
||||
<NumberOptions
|
||||
property={property}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
);
|
||||
case "date":
|
||||
return (
|
||||
<DateOptions
|
||||
property={property}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
);
|
||||
case "person":
|
||||
return (
|
||||
<PersonOptions
|
||||
property={property}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("No options for this property type")}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function SelectOptions({
|
||||
property,
|
||||
onUpdate,
|
||||
onClose,
|
||||
onDirtyChange,
|
||||
hideButtons,
|
||||
}: {
|
||||
property: IBaseProperty;
|
||||
onUpdate: (typeOptions: Record<string, unknown>) => void;
|
||||
onClose: () => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
hideButtons?: boolean;
|
||||
}) {
|
||||
const options = property.typeOptions as SelectTypeOptions | undefined;
|
||||
const choices = options?.choices ?? [];
|
||||
|
||||
const handleSave = useCallback(
|
||||
(newChoices: Choice[]) => {
|
||||
onUpdate({
|
||||
...property.typeOptions,
|
||||
choices: newChoices,
|
||||
choiceOrder: newChoices.map((c) => c.id),
|
||||
});
|
||||
},
|
||||
[property.typeOptions, onUpdate],
|
||||
);
|
||||
|
||||
return (
|
||||
<ChoiceEditor
|
||||
initialChoices={choices}
|
||||
onSave={handleSave}
|
||||
onClose={onClose}
|
||||
onDirtyChange={onDirtyChange}
|
||||
showCategories={false}
|
||||
hideButtons={hideButtons}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusOptions({
|
||||
property,
|
||||
onUpdate,
|
||||
onClose,
|
||||
onDirtyChange,
|
||||
hideButtons,
|
||||
}: {
|
||||
property: IBaseProperty;
|
||||
onUpdate: (typeOptions: Record<string, unknown>) => void;
|
||||
onClose: () => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
hideButtons?: boolean;
|
||||
}) {
|
||||
const options = property.typeOptions as SelectTypeOptions | undefined;
|
||||
const choices = options?.choices ?? [];
|
||||
|
||||
const handleSave = useCallback(
|
||||
(newChoices: Choice[]) => {
|
||||
onUpdate({
|
||||
...property.typeOptions,
|
||||
choices: newChoices,
|
||||
choiceOrder: newChoices.map((c) => c.id),
|
||||
});
|
||||
},
|
||||
[property.typeOptions, onUpdate],
|
||||
);
|
||||
|
||||
return (
|
||||
<ChoiceEditor
|
||||
initialChoices={choices}
|
||||
onSave={handleSave}
|
||||
onClose={onClose}
|
||||
onDirtyChange={onDirtyChange}
|
||||
showCategories
|
||||
hideButtons={hideButtons}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberOptions({
|
||||
property,
|
||||
onUpdate,
|
||||
}: {
|
||||
property: IBaseProperty;
|
||||
onUpdate: (typeOptions: Record<string, unknown>) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const options = property.typeOptions as NumberTypeOptions | undefined;
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Select
|
||||
size="xs"
|
||||
label={t("Format")}
|
||||
allowDeselect={false}
|
||||
data={[
|
||||
{ value: "plain", label: t("Number") },
|
||||
{ value: "currency", label: t("Currency") },
|
||||
{ value: "percent", label: t("Percent") },
|
||||
{ value: "progress", label: t("Progress") },
|
||||
]}
|
||||
value={options?.format ?? "plain"}
|
||||
onChange={(val) =>
|
||||
onUpdate({ ...property.typeOptions, format: val ?? "plain" })
|
||||
}
|
||||
/>
|
||||
<NumberInput
|
||||
size="xs"
|
||||
label={t("Decimal places")}
|
||||
min={0}
|
||||
max={8}
|
||||
value={options?.precision ?? 0}
|
||||
onChange={(val) =>
|
||||
onUpdate({ ...property.typeOptions, precision: val })
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function DateOptions({
|
||||
property,
|
||||
onUpdate,
|
||||
}: {
|
||||
property: IBaseProperty;
|
||||
onUpdate: (typeOptions: Record<string, unknown>) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const options = property.typeOptions as DateTypeOptions | undefined;
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Switch
|
||||
size="xs"
|
||||
label={t("Include time")}
|
||||
checked={options?.includeTime ?? false}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
...property.typeOptions,
|
||||
includeTime: e.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{options?.includeTime && (
|
||||
<Select
|
||||
size="xs"
|
||||
label={t("Time format")}
|
||||
allowDeselect={false}
|
||||
data={[
|
||||
{ value: "12h", label: "12-hour" },
|
||||
{ value: "24h", label: "24-hour" },
|
||||
]}
|
||||
value={options?.timeFormat ?? "12h"}
|
||||
onChange={(val) =>
|
||||
onUpdate({ ...property.typeOptions, timeFormat: val ?? "12h" })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonOptions({
|
||||
property,
|
||||
onUpdate,
|
||||
}: {
|
||||
property: IBaseProperty;
|
||||
onUpdate: (typeOptions: Record<string, unknown>) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const options = property.typeOptions as PersonTypeOptions | undefined;
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Switch
|
||||
size="xs"
|
||||
label={t("Allow multiple people")}
|
||||
checked={options?.allowMultiple !== false}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
...property.typeOptions,
|
||||
allowMultiple: e.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { UnstyledButton, Group, Text, TextInput } from "@mantine/core";
|
||||
import {
|
||||
IconLetterT,
|
||||
IconHash,
|
||||
IconCircleDot,
|
||||
IconProgressCheck,
|
||||
IconTags,
|
||||
IconCalendar,
|
||||
IconUser,
|
||||
IconPaperclip,
|
||||
IconCheckbox,
|
||||
IconLink,
|
||||
IconMail,
|
||||
IconClockPlus,
|
||||
IconClockEdit,
|
||||
IconUserEdit,
|
||||
IconCheck,
|
||||
IconSearch,
|
||||
} from "@tabler/icons-react";
|
||||
import { BasePropertyType } from "@/features/base/types/base.types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import classes from "@/features/base/styles/cells.module.css";
|
||||
|
||||
const propertyTypes: {
|
||||
type: BasePropertyType;
|
||||
icon: typeof IconLetterT;
|
||||
labelKey: string;
|
||||
}[] = [
|
||||
{ type: "text", icon: IconLetterT, labelKey: "Text" },
|
||||
{ type: "number", icon: IconHash, labelKey: "Number" },
|
||||
{ type: "select", icon: IconCircleDot, labelKey: "Select" },
|
||||
{ type: "status", icon: IconProgressCheck, labelKey: "Status" },
|
||||
{ type: "multiSelect", icon: IconTags, labelKey: "Multi-select" },
|
||||
{ type: "date", icon: IconCalendar, labelKey: "Date" },
|
||||
{ type: "person", icon: IconUser, labelKey: "Person" },
|
||||
{ type: "file", icon: IconPaperclip, labelKey: "File" },
|
||||
{ type: "checkbox", icon: IconCheckbox, labelKey: "Checkbox" },
|
||||
{ type: "url", icon: IconLink, labelKey: "URL" },
|
||||
{ type: "email", icon: IconMail, labelKey: "Email" },
|
||||
{ type: "createdAt", icon: IconClockPlus, labelKey: "Created at" },
|
||||
{ type: "lastEditedAt", icon: IconClockEdit, labelKey: "Last edited at" },
|
||||
{ type: "lastEditedBy", icon: IconUserEdit, labelKey: "Last edited by" },
|
||||
];
|
||||
|
||||
type PropertyTypePickerProps = {
|
||||
onSelect: (type: BasePropertyType) => void;
|
||||
currentType?: BasePropertyType;
|
||||
excludeTypes?: Set<BasePropertyType>;
|
||||
showSearch?: boolean;
|
||||
};
|
||||
|
||||
export function PropertyTypePicker({
|
||||
onSelect,
|
||||
currentType,
|
||||
excludeTypes,
|
||||
showSearch,
|
||||
}: PropertyTypePickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState("");
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (showSearch) {
|
||||
setTimeout(() => searchRef.current?.focus(), 0);
|
||||
}
|
||||
}, [showSearch]);
|
||||
|
||||
const types = propertyTypes
|
||||
.filter(({ type }) => !excludeTypes?.has(type))
|
||||
.filter(({ labelKey }) =>
|
||||
!search || t(labelKey).toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showSearch && (
|
||||
<TextInput
|
||||
ref={searchRef}
|
||||
size="xs"
|
||||
placeholder={t("Find a field type")}
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
mx="sm"
|
||||
mt="sm"
|
||||
mb={4}
|
||||
/>
|
||||
)}
|
||||
{types.map(({ type, icon: Icon, labelKey }) => (
|
||||
<UnstyledButton
|
||||
key={type}
|
||||
className={classes.menuItem}
|
||||
onClick={() => onSelect(type)}
|
||||
style={{
|
||||
fontWeight: type === currentType ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
|
||||
<Icon size={14} />
|
||||
<Text size="sm">{t(labelKey)}</Text>
|
||||
</Group>
|
||||
{type === currentType && <IconCheck size={14} />}
|
||||
</UnstyledButton>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { propertyTypes };
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { Popover, Switch, Stack, Text, Group, Divider, UnstyledButton } from "@mantine/core";
|
||||
import { IconEye, IconEyeOff } from "@tabler/icons-react";
|
||||
import { Table } from "@tanstack/react-table";
|
||||
import { IBaseRow, IBaseProperty } from "@/features/base/types/base.types";
|
||||
import { propertyTypes } from "@/features/base/components/property/property-type-picker";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import cellClasses from "@/features/base/styles/cells.module.css";
|
||||
|
||||
type ViewFieldVisibilityProps = {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
table: Table<IBaseRow>;
|
||||
properties: IBaseProperty[];
|
||||
onPersist: () => void;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function ViewFieldVisibility({
|
||||
opened,
|
||||
onClose,
|
||||
table,
|
||||
properties,
|
||||
onPersist,
|
||||
children,
|
||||
}: ViewFieldVisibilityProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return table
|
||||
.getAllLeafColumns()
|
||||
.filter((col) => col.id !== "__row_number");
|
||||
}, [table, properties]);
|
||||
|
||||
const allVisible = columns.every((col) => col.getIsVisible());
|
||||
const noneVisible = columns.filter((col) => col.getCanHide()).every((col) => !col.getIsVisible());
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(columnId: string, visible: boolean) => {
|
||||
const col = table.getColumn(columnId);
|
||||
if (!col) return;
|
||||
col.toggleVisibility(visible);
|
||||
onPersist();
|
||||
},
|
||||
[table, onPersist],
|
||||
);
|
||||
|
||||
const handleShowAll = useCallback(() => {
|
||||
columns.forEach((col) => {
|
||||
if (col.getCanHide()) {
|
||||
col.toggleVisibility(true);
|
||||
}
|
||||
});
|
||||
onPersist();
|
||||
}, [columns, onPersist]);
|
||||
|
||||
const handleHideAll = useCallback(() => {
|
||||
columns.forEach((col) => {
|
||||
if (col.getCanHide()) {
|
||||
col.toggleVisibility(false);
|
||||
}
|
||||
});
|
||||
onPersist();
|
||||
}, [columns, onPersist]);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
position="bottom-end"
|
||||
shadow="md"
|
||||
width={260}
|
||||
trapFocus
|
||||
closeOnEscape
|
||||
closeOnClickOutside
|
||||
withinPortal
|
||||
>
|
||||
<Popover.Target>{children}</Popover.Target>
|
||||
<Popover.Dropdown p="xs">
|
||||
<Stack gap={4}>
|
||||
<Group justify="space-between" px={4} py={2}>
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{t("Fields")}
|
||||
</Text>
|
||||
<Group gap={8}>
|
||||
<UnstyledButton
|
||||
onClick={handleShowAll}
|
||||
disabled={allVisible}
|
||||
style={{ opacity: allVisible ? 0.4 : 1 }}
|
||||
>
|
||||
<Text size="xs" c="blue">
|
||||
{t("Show all")}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
<UnstyledButton
|
||||
onClick={handleHideAll}
|
||||
disabled={noneVisible}
|
||||
style={{ opacity: noneVisible ? 0.4 : 1 }}
|
||||
>
|
||||
<Text size="xs" c="blue">
|
||||
{t("Hide all")}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Stack gap={0}>
|
||||
{columns.map((col) => {
|
||||
const property = col.columnDef.meta?.property as IBaseProperty | undefined;
|
||||
if (!property) return null;
|
||||
|
||||
const canHide = col.getCanHide();
|
||||
const isVisible = col.getIsVisible();
|
||||
const typeConfig = propertyTypes.find((pt) => pt.type === property.type);
|
||||
const TypeIcon = typeConfig?.icon;
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={col.id}
|
||||
className={cellClasses.menuItem}
|
||||
onClick={() => {
|
||||
if (canHide) {
|
||||
handleToggle(col.id, !isVisible);
|
||||
}
|
||||
}}
|
||||
style={{ opacity: canHide ? 1 : 0.5 }}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
|
||||
{TypeIcon && <TypeIcon size={14} style={{ flexShrink: 0 }} />}
|
||||
<Text size="sm" style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{property.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Switch
|
||||
size="xs"
|
||||
checked={isVisible}
|
||||
disabled={!canHide}
|
||||
onChange={() => {}}
|
||||
// Mantine's Switch spreads `onClick` onto its hidden
|
||||
// <input>. When the user clicks the visible track, the
|
||||
// label's default action synthesizes a second click on
|
||||
// that input — both clicks bubble to the parent
|
||||
// UnstyledButton and fire handleToggle twice (hide then
|
||||
// immediately unhide, net zero). stopPropagation here
|
||||
// blocks ONLY the synthesized input click from reaching
|
||||
// UnstyledButton; the original track click still bubbles
|
||||
// normally, so handleToggle fires exactly once.
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
styles={{ track: { cursor: canHide ? "pointer" : "not-allowed" } }}
|
||||
/>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Popover,
|
||||
Stack,
|
||||
Group,
|
||||
Select,
|
||||
TextInput,
|
||||
ActionIcon,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
Button,
|
||||
} from "@mantine/core";
|
||||
import { IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import {
|
||||
IBaseProperty,
|
||||
SelectTypeOptions,
|
||||
FilterCondition,
|
||||
FilterOperator,
|
||||
} from "@/features/base/types/base.types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/*
|
||||
* Operator metadata for the filter popover. Values use the server
|
||||
* engine's operator set (`core/base/engine/schema.zod.ts`); labels are
|
||||
* i18n-translated display strings.
|
||||
*/
|
||||
const OPERATORS: { value: FilterOperator; labelKey: string }[] = [
|
||||
{ value: "eq", labelKey: "Equals" },
|
||||
{ value: "neq", labelKey: "Not equals" },
|
||||
{ value: "contains", labelKey: "Contains" },
|
||||
{ value: "ncontains", labelKey: "Not contains" },
|
||||
{ value: "isEmpty", labelKey: "Is empty" },
|
||||
{ value: "isNotEmpty", labelKey: "Is not empty" },
|
||||
{ value: "gt", labelKey: "Greater than" },
|
||||
{ value: "lt", labelKey: "Less than" },
|
||||
{ value: "before", labelKey: "Before" },
|
||||
{ value: "after", labelKey: "After" },
|
||||
{ value: "any", labelKey: "Any of" },
|
||||
{ value: "none", labelKey: "None of" },
|
||||
];
|
||||
|
||||
const NO_VALUE_OPERATORS: FilterOperator[] = ["isEmpty", "isNotEmpty"];
|
||||
|
||||
function getOperatorsForType(type: string): FilterOperator[] {
|
||||
switch (type) {
|
||||
case "text":
|
||||
case "email":
|
||||
case "url":
|
||||
return ["eq", "neq", "contains", "ncontains", "isEmpty", "isNotEmpty"];
|
||||
case "number":
|
||||
return ["eq", "neq", "gt", "lt", "isEmpty", "isNotEmpty"];
|
||||
case "date":
|
||||
case "createdAt":
|
||||
case "lastEditedAt":
|
||||
return ["eq", "neq", "before", "after", "isEmpty", "isNotEmpty"];
|
||||
case "select":
|
||||
case "status":
|
||||
return ["eq", "neq", "any", "none", "isEmpty", "isNotEmpty"];
|
||||
case "multiSelect":
|
||||
return ["any", "none", "isEmpty", "isNotEmpty"];
|
||||
case "checkbox":
|
||||
return ["eq", "isEmpty", "isNotEmpty"];
|
||||
case "person":
|
||||
case "lastEditedBy":
|
||||
return ["eq", "neq", "any", "none", "isEmpty", "isNotEmpty"];
|
||||
case "file":
|
||||
return ["isEmpty", "isNotEmpty"];
|
||||
default:
|
||||
return ["eq", "neq", "isEmpty", "isNotEmpty"];
|
||||
}
|
||||
}
|
||||
|
||||
function FilterValueInput({
|
||||
condition,
|
||||
property,
|
||||
onChange,
|
||||
t,
|
||||
}: {
|
||||
condition: FilterCondition;
|
||||
property: IBaseProperty | undefined;
|
||||
onChange: (value: string) => void;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
if (!property) {
|
||||
return (
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder={t("Value")}
|
||||
value={(condition.value as string) ?? ""}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
w={100}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const type = property.type;
|
||||
|
||||
if (type === "select" || type === "status" || type === "multiSelect") {
|
||||
const typeOptions = property.typeOptions as SelectTypeOptions | undefined;
|
||||
const choices = typeOptions?.choices ?? [];
|
||||
const choiceOptions = choices.map((c) => ({ value: c.id, label: c.name }));
|
||||
return (
|
||||
<Select
|
||||
size="xs"
|
||||
data={choiceOptions}
|
||||
value={(condition.value as string) ?? null}
|
||||
onChange={(val) => onChange(val ?? "")}
|
||||
w={120}
|
||||
placeholder={t("Select")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "number") {
|
||||
return (
|
||||
<TextInput
|
||||
size="xs"
|
||||
type="number"
|
||||
placeholder={t("Value")}
|
||||
value={(condition.value as string) ?? ""}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
w={100}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "checkbox") {
|
||||
return (
|
||||
<Select
|
||||
size="xs"
|
||||
data={[
|
||||
{ value: "true", label: t("True") },
|
||||
{ value: "false", label: t("False") },
|
||||
]}
|
||||
value={(condition.value as string) ?? null}
|
||||
onChange={(val) => onChange(val ?? "")}
|
||||
w={100}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder={t("Value")}
|
||||
value={(condition.value as string) ?? ""}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
w={100}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type ViewFilterConfigProps = {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
conditions: FilterCondition[];
|
||||
properties: IBaseProperty[];
|
||||
onChange: (conditions: FilterCondition[]) => void;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function ViewFilterConfigPopover({
|
||||
opened,
|
||||
onClose,
|
||||
conditions,
|
||||
properties,
|
||||
onChange,
|
||||
children,
|
||||
}: ViewFilterConfigProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const propertyOptions = properties.map((p) => ({
|
||||
value: p.id,
|
||||
label: p.name,
|
||||
}));
|
||||
|
||||
const [draft, setDraft] = useState<FilterCondition | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) setDraft(null);
|
||||
}, [opened]);
|
||||
|
||||
const handleStartDraft = useCallback(() => {
|
||||
const firstProperty = properties[0];
|
||||
if (!firstProperty) return;
|
||||
const validOperators = getOperatorsForType(firstProperty.type);
|
||||
const defaultOperator = validOperators.includes("contains")
|
||||
? ("contains" as FilterOperator)
|
||||
: validOperators[0];
|
||||
setDraft({ propertyId: firstProperty.id, op: defaultOperator });
|
||||
}, [properties]);
|
||||
|
||||
const handleSaveDraft = useCallback(() => {
|
||||
if (!draft) return;
|
||||
onChange([...conditions, draft]);
|
||||
setDraft(null);
|
||||
}, [draft, conditions, onChange]);
|
||||
|
||||
const handleCancelDraft = useCallback(() => {
|
||||
setDraft(null);
|
||||
}, []);
|
||||
|
||||
const handleDraftPropertyChange = useCallback(
|
||||
(propertyId: string | null) => {
|
||||
if (!propertyId || !draft) return;
|
||||
const newProperty = properties.find((p) => p.id === propertyId);
|
||||
if (!newProperty) {
|
||||
setDraft({ ...draft, propertyId });
|
||||
return;
|
||||
}
|
||||
const validOperators = getOperatorsForType(newProperty.type);
|
||||
const currentOperatorValid = validOperators.includes(draft.op);
|
||||
setDraft({
|
||||
...draft,
|
||||
propertyId,
|
||||
op: currentOperatorValid ? draft.op : validOperators[0],
|
||||
value: currentOperatorValid ? draft.value : undefined,
|
||||
});
|
||||
},
|
||||
[draft, properties],
|
||||
);
|
||||
|
||||
const handleDraftOperatorChange = useCallback(
|
||||
(operator: string | null) => {
|
||||
if (!operator || !draft) return;
|
||||
const op = operator as FilterOperator;
|
||||
const needsValue = !NO_VALUE_OPERATORS.includes(op);
|
||||
setDraft({ ...draft, op, value: needsValue ? draft.value : undefined });
|
||||
},
|
||||
[draft],
|
||||
);
|
||||
|
||||
const handleDraftValueChange = useCallback(
|
||||
(value: string) => {
|
||||
if (!draft) return;
|
||||
setDraft({ ...draft, value: value || undefined });
|
||||
},
|
||||
[draft],
|
||||
);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(index: number) => {
|
||||
onChange(conditions.filter((_, i) => i !== index));
|
||||
},
|
||||
[conditions, onChange],
|
||||
);
|
||||
|
||||
const handlePropertyChange = useCallback(
|
||||
(index: number, propertyId: string | null) => {
|
||||
if (!propertyId) return;
|
||||
const newProperty = properties.find((p) => p.id === propertyId);
|
||||
onChange(
|
||||
conditions.map((f, i) => {
|
||||
if (i !== index) return f;
|
||||
if (newProperty) {
|
||||
const validOperators = getOperatorsForType(newProperty.type);
|
||||
const currentOperatorValid = validOperators.includes(f.op);
|
||||
return {
|
||||
...f,
|
||||
propertyId,
|
||||
op: currentOperatorValid ? f.op : validOperators[0],
|
||||
value: currentOperatorValid ? f.value : undefined,
|
||||
};
|
||||
}
|
||||
return { ...f, propertyId };
|
||||
}),
|
||||
);
|
||||
},
|
||||
[conditions, properties, onChange],
|
||||
);
|
||||
|
||||
const handleOperatorChange = useCallback(
|
||||
(index: number, operator: string | null) => {
|
||||
if (!operator) return;
|
||||
const op = operator as FilterOperator;
|
||||
const needsValue = !NO_VALUE_OPERATORS.includes(op);
|
||||
onChange(
|
||||
conditions.map((f, i) =>
|
||||
i === index
|
||||
? {
|
||||
...f,
|
||||
op,
|
||||
value: needsValue ? f.value : undefined,
|
||||
}
|
||||
: f,
|
||||
),
|
||||
);
|
||||
},
|
||||
[conditions, onChange],
|
||||
);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(index: number, value: string) => {
|
||||
onChange(
|
||||
conditions.map((f, i) =>
|
||||
i === index ? { ...f, value: value || undefined } : f,
|
||||
),
|
||||
);
|
||||
},
|
||||
[conditions, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
position="bottom-end"
|
||||
shadow="md"
|
||||
width={440}
|
||||
trapFocus
|
||||
withinPortal
|
||||
>
|
||||
<Popover.Target>{children}</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{t("Filter by")}
|
||||
</Text>
|
||||
|
||||
{conditions.length === 0 && !draft && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("No filters applied")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{conditions.map((condition, index) => {
|
||||
const needsValue = !NO_VALUE_OPERATORS.includes(condition.op);
|
||||
const property = properties.find(
|
||||
(p) => p.id === condition.propertyId,
|
||||
);
|
||||
const validOperators = property
|
||||
? getOperatorsForType(property.type)
|
||||
: OPERATORS.map((op) => op.value);
|
||||
const operatorOptions = OPERATORS.filter((op) =>
|
||||
validOperators.includes(op.value),
|
||||
).map((op) => ({
|
||||
value: op.value,
|
||||
label: t(op.labelKey),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Group key={index} gap="xs" wrap="nowrap">
|
||||
<Select
|
||||
size="xs"
|
||||
data={propertyOptions}
|
||||
value={condition.propertyId}
|
||||
onChange={(val) => handlePropertyChange(index, val)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Select
|
||||
size="xs"
|
||||
data={operatorOptions}
|
||||
value={condition.op}
|
||||
onChange={(val) => handleOperatorChange(index, val)}
|
||||
w={130}
|
||||
/>
|
||||
{needsValue && (
|
||||
<FilterValueInput
|
||||
condition={condition}
|
||||
property={property}
|
||||
onChange={(val) => handleValueChange(index, val)}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() => handleRemove(index)}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
|
||||
{draft && (() => {
|
||||
const needsValue = !NO_VALUE_OPERATORS.includes(draft.op);
|
||||
const property = properties.find((p) => p.id === draft.propertyId);
|
||||
const validOperators = property
|
||||
? getOperatorsForType(property.type)
|
||||
: OPERATORS.map((op) => op.value);
|
||||
const operatorOptions = OPERATORS.filter((op) =>
|
||||
validOperators.includes(op.value),
|
||||
).map((op) => ({ value: op.value, label: t(op.labelKey) }));
|
||||
|
||||
return (
|
||||
<Stack gap={6}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Select
|
||||
size="xs"
|
||||
data={propertyOptions}
|
||||
value={draft.propertyId}
|
||||
onChange={handleDraftPropertyChange}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Select
|
||||
size="xs"
|
||||
data={operatorOptions}
|
||||
value={draft.op}
|
||||
onChange={handleDraftOperatorChange}
|
||||
w={130}
|
||||
/>
|
||||
{needsValue && (
|
||||
<FilterValueInput
|
||||
condition={draft}
|
||||
property={property}
|
||||
onChange={handleDraftValueChange}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
<Group justify="flex-end" gap="xs">
|
||||
<Button variant="default" size="xs" onClick={handleCancelDraft}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button size="xs" onClick={handleSaveDraft}>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
})()}
|
||||
|
||||
{!draft && (
|
||||
<UnstyledButton
|
||||
onClick={handleStartDraft}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "4px 0",
|
||||
fontSize: "var(--mantine-font-size-xs)",
|
||||
color: "var(--mantine-color-blue-6)",
|
||||
}}
|
||||
>
|
||||
<IconPlus size={14} />
|
||||
{t("Add filter")}
|
||||
</UnstyledButton>
|
||||
)}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Popover,
|
||||
Stack,
|
||||
Group,
|
||||
Select,
|
||||
ActionIcon,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
Button,
|
||||
} from "@mantine/core";
|
||||
import { IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import {
|
||||
IBaseProperty,
|
||||
ViewSortConfig,
|
||||
} from "@/features/base/types/base.types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type ViewSortConfigProps = {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
sorts: ViewSortConfig[];
|
||||
properties: IBaseProperty[];
|
||||
onChange: (sorts: ViewSortConfig[]) => void;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function ViewSortConfigPopover({
|
||||
opened,
|
||||
onClose,
|
||||
sorts,
|
||||
properties,
|
||||
onChange,
|
||||
children,
|
||||
}: ViewSortConfigProps) {
|
||||
const { t } = useTranslation();
|
||||
const [draft, setDraft] = useState<ViewSortConfig | null>(null);
|
||||
|
||||
// Discard any half-configured draft when the popover closes.
|
||||
useEffect(() => {
|
||||
if (!opened) setDraft(null);
|
||||
}, [opened]);
|
||||
|
||||
const propertyOptions = properties.map((p) => ({
|
||||
value: p.id,
|
||||
label: p.name,
|
||||
}));
|
||||
|
||||
const directionOptions = [
|
||||
{ value: "asc", label: t("Ascending") },
|
||||
{ value: "desc", label: t("Descending") },
|
||||
];
|
||||
|
||||
const handleStartDraft = useCallback(() => {
|
||||
const usedIds = new Set(sorts.map((s) => s.propertyId));
|
||||
const available = properties.find((p) => !usedIds.has(p.id));
|
||||
if (!available) return;
|
||||
setDraft({ propertyId: available.id, direction: "asc" });
|
||||
}, [sorts, properties]);
|
||||
|
||||
const handleSaveDraft = useCallback(() => {
|
||||
if (!draft) return;
|
||||
onChange([...sorts, draft]);
|
||||
setDraft(null);
|
||||
}, [draft, sorts, onChange]);
|
||||
|
||||
const handleCancelDraft = useCallback(() => {
|
||||
setDraft(null);
|
||||
}, []);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(index: number) => {
|
||||
onChange(sorts.filter((_, i) => i !== index));
|
||||
},
|
||||
[sorts, onChange],
|
||||
);
|
||||
|
||||
const handlePropertyChange = useCallback(
|
||||
(index: number, propertyId: string | null) => {
|
||||
if (!propertyId) return;
|
||||
onChange(
|
||||
sorts.map((s, i) => (i === index ? { ...s, propertyId } : s)),
|
||||
);
|
||||
},
|
||||
[sorts, onChange],
|
||||
);
|
||||
|
||||
const handleDirectionChange = useCallback(
|
||||
(index: number, direction: string | null) => {
|
||||
if (!direction) return;
|
||||
onChange(
|
||||
sorts.map((s, i) =>
|
||||
i === index
|
||||
? { ...s, direction: direction as "asc" | "desc" }
|
||||
: s,
|
||||
),
|
||||
);
|
||||
},
|
||||
[sorts, onChange],
|
||||
);
|
||||
|
||||
const canAddMore = properties.length > sorts.length + (draft ? 1 : 0);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
position="bottom-end"
|
||||
shadow="md"
|
||||
width={340}
|
||||
trapFocus
|
||||
withinPortal
|
||||
>
|
||||
<Popover.Target>{children}</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{t("Sort by")}
|
||||
</Text>
|
||||
|
||||
{sorts.length === 0 && !draft && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("No sorts applied")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{sorts.map((sort, index) => (
|
||||
<Group key={index} gap="xs" wrap="nowrap">
|
||||
<Select
|
||||
size="xs"
|
||||
data={propertyOptions}
|
||||
value={sort.propertyId}
|
||||
onChange={(val) => handlePropertyChange(index, val)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Select
|
||||
size="xs"
|
||||
data={directionOptions}
|
||||
value={sort.direction}
|
||||
onChange={(val) => handleDirectionChange(index, val)}
|
||||
w={110}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() => handleRemove(index)}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
|
||||
{draft && (
|
||||
<Stack gap={6}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Select
|
||||
size="xs"
|
||||
data={propertyOptions}
|
||||
value={draft.propertyId}
|
||||
onChange={(val) =>
|
||||
val && setDraft({ ...draft, propertyId: val })
|
||||
}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Select
|
||||
size="xs"
|
||||
data={directionOptions}
|
||||
value={draft.direction}
|
||||
onChange={(val) =>
|
||||
val &&
|
||||
setDraft({
|
||||
...draft,
|
||||
direction: val as "asc" | "desc",
|
||||
})
|
||||
}
|
||||
w={110}
|
||||
/>
|
||||
</Group>
|
||||
<Group justify="flex-end" gap="xs">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={handleCancelDraft}
|
||||
>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button size="xs" onClick={handleSaveDraft}>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{!draft && canAddMore && (
|
||||
<UnstyledButton
|
||||
onClick={handleStartDraft}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "4px 0",
|
||||
fontSize: "var(--mantine-font-size-xs)",
|
||||
color: "var(--mantine-color-blue-6)",
|
||||
}}
|
||||
>
|
||||
<IconPlus size={14} />
|
||||
{t("Add sort")}
|
||||
</UnstyledButton>
|
||||
)}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import {
|
||||
Group,
|
||||
UnstyledButton,
|
||||
Text,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
TextInput,
|
||||
Popover,
|
||||
Stack,
|
||||
Divider,
|
||||
} from "@mantine/core";
|
||||
import { IconPlus, IconPencil, IconTrash, IconTable } from "@tabler/icons-react";
|
||||
import { IBaseView } from "@/features/base/types/base.types";
|
||||
import {
|
||||
useUpdateViewMutation,
|
||||
useDeleteViewMutation,
|
||||
} from "@/features/base/queries/base-view-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import cellClasses from "@/features/base/styles/cells.module.css";
|
||||
|
||||
type ViewTabsProps = {
|
||||
views: IBaseView[];
|
||||
activeViewId: string | undefined;
|
||||
baseId: string;
|
||||
onViewChange: (viewId: string) => void;
|
||||
onAddView?: () => void;
|
||||
};
|
||||
|
||||
export function ViewTabs({
|
||||
views,
|
||||
activeViewId,
|
||||
baseId,
|
||||
onViewChange,
|
||||
onAddView,
|
||||
}: ViewTabsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [editingViewId, setEditingViewId] = useState<string | null>(null);
|
||||
const [editingName, setEditingName] = useState("");
|
||||
|
||||
const updateViewMutation = useUpdateViewMutation();
|
||||
const deleteViewMutation = useDeleteViewMutation();
|
||||
|
||||
const handleRenameStart = useCallback(
|
||||
(view: IBaseView) => {
|
||||
setEditingViewId(view.id);
|
||||
setEditingName(view.name);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleRenameCommit = useCallback(() => {
|
||||
if (!editingViewId) return;
|
||||
const trimmed = editingName.trim();
|
||||
const view = views.find((v) => v.id === editingViewId);
|
||||
if (trimmed && view && trimmed !== view.name) {
|
||||
updateViewMutation.mutate({
|
||||
viewId: editingViewId,
|
||||
baseId,
|
||||
name: trimmed,
|
||||
});
|
||||
}
|
||||
setEditingViewId(null);
|
||||
}, [editingViewId, editingName, views, baseId, updateViewMutation]);
|
||||
|
||||
const handleRenameKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleRenameCommit();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setEditingViewId(null);
|
||||
}
|
||||
},
|
||||
[handleRenameCommit],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(viewId: string) => {
|
||||
if (views.length <= 1) return;
|
||||
deleteViewMutation.mutate({ viewId, baseId });
|
||||
if (viewId === activeViewId && views.length > 1) {
|
||||
const remaining = views.filter((v) => v.id !== viewId);
|
||||
onViewChange(remaining[0].id);
|
||||
}
|
||||
},
|
||||
[views, baseId, activeViewId, deleteViewMutation, onViewChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<Group gap={4}>
|
||||
{views.map((view) => (
|
||||
<ViewTab
|
||||
key={view.id}
|
||||
view={view}
|
||||
isActive={view.id === activeViewId}
|
||||
isEditing={view.id === editingViewId}
|
||||
editingName={editingName}
|
||||
canDelete={views.length > 1}
|
||||
onClick={() => onViewChange(view.id)}
|
||||
onRenameStart={() => handleRenameStart(view)}
|
||||
onRenameChange={setEditingName}
|
||||
onRenameCommit={handleRenameCommit}
|
||||
onRenameKeyDown={handleRenameKeyDown}
|
||||
onDelete={() => handleDelete(view.id)}
|
||||
/>
|
||||
))}
|
||||
{onAddView && (
|
||||
<Tooltip label={t("Add view")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
color="gray"
|
||||
onClick={onAddView}
|
||||
>
|
||||
<IconPlus size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewTab({
|
||||
view,
|
||||
isActive,
|
||||
isEditing,
|
||||
editingName,
|
||||
canDelete,
|
||||
onClick,
|
||||
onRenameStart,
|
||||
onRenameChange,
|
||||
onRenameCommit,
|
||||
onRenameKeyDown,
|
||||
onDelete,
|
||||
}: {
|
||||
view: IBaseView;
|
||||
isActive: boolean;
|
||||
isEditing: boolean;
|
||||
editingName: string;
|
||||
canDelete: boolean;
|
||||
onClick: () => void;
|
||||
onRenameStart: () => void;
|
||||
onRenameChange: (name: string) => void;
|
||||
onRenameCommit: () => void;
|
||||
onRenameKeyDown: (e: React.KeyboardEvent) => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [menuOpened, setMenuOpened] = useState(false);
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<TextInput
|
||||
size="xs"
|
||||
w={120}
|
||||
value={editingName}
|
||||
onChange={(e) => onRenameChange(e.currentTarget.value)}
|
||||
onBlur={onRenameCommit}
|
||||
onKeyDown={onRenameKeyDown}
|
||||
autoFocus
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={menuOpened}
|
||||
onClose={() => setMenuOpened(false)}
|
||||
position="bottom-start"
|
||||
shadow="md"
|
||||
width={180}
|
||||
withinPortal
|
||||
>
|
||||
<Popover.Target>
|
||||
<UnstyledButton
|
||||
onClick={onClick}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setMenuOpened(true);
|
||||
}}
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
borderRadius: "var(--mantine-radius-sm)",
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<IconTable size={14} opacity={0.5} />
|
||||
<Text
|
||||
size="sm"
|
||||
c={isActive ? undefined : "dimmed"}
|
||||
>
|
||||
{view.name}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p={4}>
|
||||
<Stack gap={0}>
|
||||
<UnstyledButton
|
||||
className={cellClasses.menuItem}
|
||||
onClick={() => {
|
||||
setMenuOpened(false);
|
||||
onRenameStart();
|
||||
}}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<IconPencil size={14} />
|
||||
<Text size="sm">{t("Rename")}</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
{canDelete && (
|
||||
<>
|
||||
<Divider my={4} />
|
||||
<UnstyledButton
|
||||
className={cellClasses.menuItem}
|
||||
onClick={() => {
|
||||
setMenuOpened(false);
|
||||
onDelete();
|
||||
}}
|
||||
style={{ color: "var(--mantine-color-red-6)" }}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<IconTrash size={14} />
|
||||
<Text size="sm">{t("Delete view")}</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
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 {
|
||||
IBaseProperty,
|
||||
IBaseRow,
|
||||
IBaseView,
|
||||
} from "@/features/base/types/base.types";
|
||||
import { selectedRowIdsAtom } from "@/features/base/atoms/base-atoms";
|
||||
import { IPagination } from "@/lib/types";
|
||||
|
||||
type BaseRowCreated = {
|
||||
operation: "base:row:created";
|
||||
baseId: string;
|
||||
row: IBaseRow;
|
||||
requestId?: string | null;
|
||||
};
|
||||
|
||||
type BaseRowUpdated = {
|
||||
operation: "base:row:updated";
|
||||
baseId: string;
|
||||
rowId: string;
|
||||
updatedCells: Record<string, unknown>;
|
||||
requestId?: string | null;
|
||||
};
|
||||
|
||||
type BaseRowDeleted = {
|
||||
operation: "base:row:deleted";
|
||||
baseId: string;
|
||||
rowId: string;
|
||||
requestId?: string | null;
|
||||
};
|
||||
|
||||
type BaseRowsDeleted = {
|
||||
operation: "base:rows:deleted";
|
||||
baseId: string;
|
||||
rowIds: string[];
|
||||
requestId?: string | null;
|
||||
};
|
||||
|
||||
type BaseRowReordered = {
|
||||
operation: "base:row:reordered";
|
||||
baseId: string;
|
||||
rowId: string;
|
||||
position: string;
|
||||
requestId?: string | null;
|
||||
};
|
||||
|
||||
type BasePropertyEvent = {
|
||||
operation:
|
||||
| "base:property:created"
|
||||
| "base:property:updated"
|
||||
| "base:property:deleted"
|
||||
| "base:property:reordered";
|
||||
baseId: string;
|
||||
property?: IBaseProperty;
|
||||
propertyId?: string;
|
||||
requestId?: string | null;
|
||||
};
|
||||
|
||||
type BaseViewEvent = {
|
||||
operation:
|
||||
| "base:view:created"
|
||||
| "base:view:updated"
|
||||
| "base:view:deleted";
|
||||
baseId: string;
|
||||
view?: IBaseView;
|
||||
viewId?: string;
|
||||
};
|
||||
|
||||
type BaseInboundEvent =
|
||||
| BaseRowCreated
|
||||
| BaseRowUpdated
|
||||
| BaseRowDeleted
|
||||
| BaseRowsDeleted
|
||||
| BaseRowReordered
|
||||
| BasePropertyEvent
|
||||
| BaseViewEvent
|
||||
| { operation: string; baseId: string };
|
||||
|
||||
/*
|
||||
* Module-level set of requestIds we've just sent to the server. When the
|
||||
* socket echoes back the mutation as a `base:row:*` / `base:property:*`
|
||||
* event with a matching `requestId`, the socket handler drops it because
|
||||
* the local mutation already updated the cache. Bounded so it can't grow
|
||||
* unbounded on a long-lived tab.
|
||||
*/
|
||||
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 server's `base-{baseId}`
|
||||
* room on mount, leaves on unmount, and reconciles the React Query caches
|
||||
* (`["base-rows", baseId, ...]` and `["bases", baseId]`) when events
|
||||
* arrive from other clients.
|
||||
*/
|
||||
export function useBaseSocket(baseId: string | undefined): void {
|
||||
const socket = useAtomValue(socketAtom);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket || !baseId) return;
|
||||
|
||||
socket.emit("message", { operation: "base:subscribe", baseId });
|
||||
|
||||
const handler = (raw: unknown) => {
|
||||
if (!raw || typeof raw !== "object") return;
|
||||
const event = raw as BaseInboundEvent;
|
||||
if (event.baseId !== baseId) 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;
|
||||
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
|
||||
{ queryKey: ["base-rows", baseId] },
|
||||
(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;
|
||||
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
|
||||
{ queryKey: ["base-rows", baseId] },
|
||||
(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", baseId] },
|
||||
(old) =>
|
||||
!old
|
||||
? old
|
||||
: {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.filter((row) => row.id !== e.rowId),
|
||||
})),
|
||||
},
|
||||
);
|
||||
const store = getDefaultStore();
|
||||
const current = store.get(selectedRowIdsAtom);
|
||||
if (current.has(e.rowId)) {
|
||||
const next = new Set(current);
|
||||
next.delete(e.rowId);
|
||||
store.set(selectedRowIdsAtom, 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", baseId] },
|
||||
(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 current = store.get(selectedRowIdsAtom);
|
||||
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(selectedRowIdsAtom, next);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "base:row:reordered": {
|
||||
const e = event as BaseRowReordered;
|
||||
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
|
||||
{ queryKey: ["base-rows", baseId] },
|
||||
(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: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 touch the base's `properties` /
|
||||
// `views`, not the cell data — so we invalidate just
|
||||
// `["bases", baseId]` here. Row reconciliation is handled
|
||||
// per-event by the row cases above.
|
||||
queryClient.invalidateQueries({ queryKey: ["bases", baseId] });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
socket.on("message", handler);
|
||||
|
||||
return () => {
|
||||
socket.off("message", handler);
|
||||
socket.emit("message", { operation: "base:unsubscribe", baseId });
|
||||
};
|
||||
}, [socket, baseId, queryClient]);
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
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 = 64;
|
||||
|
||||
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: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Serializes the live react-table state into a persisted ViewConfig.
|
||||
// Sort/filter toolbar mutations and the debounced `persistViewConfig`
|
||||
// both go through this so a direct mutation (e.g. adding a sort) can't
|
||||
// clobber a pending hide/reorder/resize by reading stale `activeView.config`.
|
||||
export function buildViewConfigFromTable(
|
||||
table: Table<IBaseRow>,
|
||||
base: ViewConfig | undefined,
|
||||
overrides: Partial<ViewConfig> = {},
|
||||
): ViewConfig {
|
||||
// Guard against corrupted persisted configs — if `base` ever comes
|
||||
// back as something other than a plain object (e.g. a jsonb-stored
|
||||
// string `"{}"` from a buggy seed), spreading it would iterate its
|
||||
// characters into keys `0`, `1`, … and poison the config forever.
|
||||
const safeBase =
|
||||
base && typeof base === "object" && !Array.isArray(base) ? base : {};
|
||||
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);
|
||||
|
||||
return {
|
||||
...safeBase,
|
||||
sorts,
|
||||
propertyWidths,
|
||||
propertyOrder,
|
||||
hiddenPropertyIds,
|
||||
visiblePropertyIds: undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
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 (debounce scheduled OR mutation in
|
||||
// flight), the reconcile effect preserves local state so we don't
|
||||
// stomp the user's in-flight toggle. When no local edit is pending,
|
||||
// the effect adopts server state — that's what makes remote updates
|
||||
// (another client hiding a column) actually show up on this client.
|
||||
const [hasPendingEdit, setHasPendingEdit] = useState(false);
|
||||
|
||||
// `base?.properties ?? []` minted a fresh `[]` every render while the
|
||||
// base query was loading, which invalidated every downstream memo and
|
||||
// tripped the setState-in-useEffect pairs below → "Maximum update
|
||||
// depth exceeded". Memoize so the identity is stable.
|
||||
const properties = useMemo(() => base?.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);
|
||||
|
||||
// Re-seed from server only when the user switches views. Within the same
|
||||
// view, local state is the source of truth — the debounced persist flushes
|
||||
// it. Without this guard, any ws-driven `invalidateQueries(["bases", baseId])`
|
||||
// or concurrent view mutation lands a new `derivedColumnVisibility`
|
||||
// reference and the effect would overwrite a pending hide/reorder toggle
|
||||
// before `persistViewConfig` has a chance to flush it.
|
||||
const lastSyncedViewIdRef = useRef<string | undefined>(activeView?.id);
|
||||
useEffect(() => {
|
||||
const currentViewId = activeView?.id;
|
||||
|
||||
// View switch → full re-seed from the server's stored config.
|
||||
if (currentViewId !== lastSyncedViewIdRef.current) {
|
||||
lastSyncedViewIdRef.current = currentViewId;
|
||||
setColumnOrder(derivedColumnOrder);
|
||||
setColumnVisibility(derivedColumnVisibility);
|
||||
return;
|
||||
}
|
||||
|
||||
// Same view. If a local edit is pending (user just toggled and
|
||||
// the debounce hasn't flushed yet, or the mutation is in flight),
|
||||
// preserve local state — only reconcile the id set so that newly
|
||||
// created columns show up and deleted columns drop out without
|
||||
// stomping the user's toggle. If nothing local is pending, adopt
|
||||
// the server's state — this is what lets remote updates from
|
||||
// other clients show up here.
|
||||
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;
|
||||
});
|
||||
} else {
|
||||
setColumnOrder(derivedColumnOrder);
|
||||
setColumnVisibility(derivedColumnVisibility);
|
||||
}
|
||||
}, [
|
||||
activeView?.id,
|
||||
derivedColumnOrder,
|
||||
derivedColumnVisibility,
|
||||
properties,
|
||||
hasPendingEdit,
|
||||
]);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
setHasPendingEdit(true);
|
||||
|
||||
persistTimerRef.current = setTimeout(() => {
|
||||
persistTimerRef.current = null;
|
||||
const config = buildViewConfigFromTable(table, activeView.config);
|
||||
updateViewMutation.mutate(
|
||||
{ viewId: activeView.id, baseId: base.id, config },
|
||||
{
|
||||
onSettled: () => {
|
||||
// Don't clear if the user has already scheduled another
|
||||
// debounce while this one was 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 "@/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,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 "@/features/base/hooks/use-row-selection";
|
||||
import { useDeleteRowsMutation } from "@/features/base/queries/base-row-query";
|
||||
|
||||
const BATCH_SIZE = 500;
|
||||
|
||||
export function useDeleteSelectedRows(baseId: string) {
|
||||
const { t } = useTranslation();
|
||||
const { selectedIds, clear } = useRowSelection();
|
||||
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({ baseId, rowIds: chunk });
|
||||
}
|
||||
notifications.show({
|
||||
message: t("{{count}} rows deleted", { count: ids.length }),
|
||||
});
|
||||
clear();
|
||||
} catch {
|
||||
// mutation onError already shows notification
|
||||
}
|
||||
},
|
||||
[baseId, 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,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,65 @@
|
||||
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>>([]);
|
||||
|
||||
// Reset highlight when filter/open-state changes. resetDeps is intentional.
|
||||
useEffect(() => {
|
||||
setActiveIndex(-1);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, 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,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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useCallback } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
selectedRowIdsAtom,
|
||||
lastToggledRowIndexAtom,
|
||||
} from "@/features/base/atoms/base-atoms";
|
||||
|
||||
type ToggleOpts = {
|
||||
shiftKey: boolean;
|
||||
rowIndex: number;
|
||||
orderedRowIds: string[];
|
||||
};
|
||||
|
||||
export function useRowSelection() {
|
||||
const [selectedIds, setSelectedIds] = useAtom(selectedRowIdsAtom) as unknown as [
|
||||
Set<string>,
|
||||
(val: Set<string> | ((prev: Set<string>) => Set<string>)) => void,
|
||||
];
|
||||
const [lastToggledIndex, setLastToggledIndex] = useAtom(
|
||||
lastToggledRowIndexAtom,
|
||||
) 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,164 @@
|
||||
import { InfiniteData, useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
createProperty,
|
||||
updateProperty,
|
||||
deleteProperty,
|
||||
reorderProperty,
|
||||
} from "@/features/base/services/base-service";
|
||||
import {
|
||||
IBase,
|
||||
IBaseProperty,
|
||||
IBaseRow,
|
||||
CreatePropertyInput,
|
||||
UpdatePropertyInput,
|
||||
DeletePropertyInput,
|
||||
ReorderPropertyInput,
|
||||
UpdatePropertyResult,
|
||||
} from "@/features/base/types/base.types";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { queryClient } from "@/main";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IPagination } from "@/lib/types";
|
||||
|
||||
export function useCreatePropertyMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<IBaseProperty, Error, CreatePropertyInput>({
|
||||
mutationFn: (data) => createProperty(data),
|
||||
onSuccess: (newProperty) => {
|
||||
queryClient.setQueryData<IBase>(
|
||||
["bases", newProperty.baseId],
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
properties: [...old.properties, newProperty],
|
||||
};
|
||||
},
|
||||
);
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
message: t("Failed to create property"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdatePropertyMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<UpdatePropertyResult, Error, UpdatePropertyInput>({
|
||||
mutationFn: (data) => updateProperty(data),
|
||||
onSuccess: (result, variables) => {
|
||||
queryClient.setQueryData<IBase>(
|
||||
["bases", variables.baseId],
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
properties: old.properties.map((p) =>
|
||||
p.id === result.property.id ? result.property : p,
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
message: t("Failed to update property"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeletePropertyMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<void, Error, DeletePropertyInput>({
|
||||
mutationFn: (data) => deleteProperty(data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.setQueryData<IBase>(
|
||||
["bases", variables.baseId],
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
properties: old.properties.filter(
|
||||
(p) => p.id !== variables.propertyId,
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
|
||||
{ queryKey: ["base-rows", variables.baseId] },
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((row) => {
|
||||
if (!(variables.propertyId in row.cells)) return row;
|
||||
const { [variables.propertyId]: _, ...rest } = row.cells;
|
||||
return { ...row, cells: rest };
|
||||
}),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
message: t("Failed to delete property"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useReorderPropertyMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<void, Error, ReorderPropertyInput, { previous: IBase | undefined }>({
|
||||
mutationFn: (data) => reorderProperty(data),
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["bases", variables.baseId],
|
||||
});
|
||||
|
||||
const previous = queryClient.getQueryData<IBase>([
|
||||
"bases",
|
||||
variables.baseId,
|
||||
]);
|
||||
|
||||
queryClient.setQueryData<IBase>(
|
||||
["bases", variables.baseId],
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
properties: old.properties.map((p) =>
|
||||
p.id === variables.propertyId
|
||||
? { ...p, position: variables.position }
|
||||
: p,
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return { previous };
|
||||
},
|
||||
onError: (_, variables, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(
|
||||
["bases", variables.baseId],
|
||||
context.previous,
|
||||
);
|
||||
}
|
||||
notifications.show({
|
||||
message: t("Failed to reorder property"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
createBase,
|
||||
getBaseInfo,
|
||||
updateBase,
|
||||
deleteBase,
|
||||
} from "@/features/base/services/base-service";
|
||||
import {
|
||||
IBase,
|
||||
CreateBaseInput,
|
||||
UpdateBaseInput,
|
||||
} from "@/features/base/types/base.types";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { queryClient } from "@/main";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function useBaseQuery(
|
||||
baseId: string | undefined,
|
||||
): UseQueryResult<IBase, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["bases", baseId],
|
||||
queryFn: () => getBaseInfo(baseId!),
|
||||
enabled: !!baseId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateBaseMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<IBase, Error, CreateBaseInput>({
|
||||
mutationFn: (data) => createBase(data),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["bases", "list", data.spaceId],
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
message: t("Failed to create base"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateBaseMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<IBase, Error, UpdateBaseInput>({
|
||||
mutationFn: (data) => updateBase(data),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData<IBase>(["bases", data.id], (old) => {
|
||||
if (!old) return old;
|
||||
return { ...old, ...data };
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
message: t("Failed to update base"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteBaseMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<void, Error, { baseId: string; spaceId: string }>({
|
||||
mutationFn: ({ baseId }) => deleteBase(baseId),
|
||||
onSuccess: (_, { baseId, spaceId }) => {
|
||||
queryClient.removeQueries({ queryKey: ["bases", baseId] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["bases", "list", spaceId],
|
||||
});
|
||||
notifications.show({ message: t("Base deleted") });
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
message: t("Failed to delete base"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
InfiniteData,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
createRow,
|
||||
updateRow,
|
||||
deleteRow,
|
||||
deleteRows,
|
||||
listRows,
|
||||
reorderRow,
|
||||
} from "@/features/base/services/base-service";
|
||||
import {
|
||||
IBaseRow,
|
||||
CreateRowInput,
|
||||
UpdateRowInput,
|
||||
DeleteRowInput,
|
||||
DeleteRowsInput,
|
||||
ReorderRowInput,
|
||||
FilterNode,
|
||||
SearchSpec,
|
||||
ViewSortConfig,
|
||||
} from "@/features/base/types/base.types";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { queryClient } from "@/main";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IPagination } from "@/lib/types";
|
||||
import { markRequestIdOutbound } from "@/features/base/hooks/use-base-socket";
|
||||
|
||||
type RowCacheContext = {
|
||||
snapshots: [readonly unknown[], InfiniteData<IPagination<IBaseRow>> | undefined][];
|
||||
};
|
||||
|
||||
// Generate a fresh requestId and pre-register it as outbound so the
|
||||
// incoming socket echo is suppressed by `useBaseSocket`.
|
||||
function newRequestId(): string {
|
||||
const id =
|
||||
typeof crypto !== "undefined" &&
|
||||
typeof (crypto as any).randomUUID === "function"
|
||||
? (crypto as any).randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
markRequestIdOutbound(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function useBaseRowsQuery(
|
||||
baseId: string | undefined,
|
||||
filter?: FilterNode,
|
||||
sorts?: ViewSortConfig[],
|
||||
search?: SearchSpec,
|
||||
) {
|
||||
const activeFilter = filter ?? undefined;
|
||||
const activeSorts = sorts?.length ? sorts : undefined;
|
||||
const activeSearch = search?.query ? search : undefined;
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["base-rows", baseId, activeFilter, activeSorts, activeSearch],
|
||||
queryFn: ({ pageParam }) =>
|
||||
listRows(baseId!, {
|
||||
cursor: pageParam,
|
||||
limit: 100,
|
||||
filter: activeFilter,
|
||||
sorts: activeSorts,
|
||||
search: activeSearch,
|
||||
}),
|
||||
enabled: !!baseId,
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage: IPagination<IBaseRow>) =>
|
||||
lastPage.meta?.nextCursor ?? undefined,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function flattenRows(
|
||||
data: InfiniteData<IPagination<IBaseRow>> | undefined,
|
||||
): IBaseRow[] {
|
||||
if (!data) return [];
|
||||
return data.pages.flatMap((page) => page.items);
|
||||
}
|
||||
|
||||
export function useCreateRowMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<IBaseRow, Error, CreateRowInput>({
|
||||
mutationFn: (data) => createRow({ ...data, requestId: newRequestId() }),
|
||||
onSuccess: (newRow) => {
|
||||
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
|
||||
{ queryKey: ["base-rows", newRow.baseId] },
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
const lastPageIndex = old.pages.length - 1;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page, index) => {
|
||||
if (index === lastPageIndex) {
|
||||
return { ...page, items: [...page.items, newRow] };
|
||||
}
|
||||
return page;
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
message: t("Failed to create row"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateRowMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<IBaseRow, Error, UpdateRowInput, RowCacheContext>({
|
||||
mutationFn: (data) => updateRow({ ...data, requestId: newRequestId() }),
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["base-rows", variables.baseId],
|
||||
});
|
||||
|
||||
const snapshots = queryClient.getQueriesData<
|
||||
InfiniteData<IPagination<IBaseRow>>
|
||||
>({ queryKey: ["base-rows", variables.baseId] });
|
||||
|
||||
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
|
||||
{ queryKey: ["base-rows", variables.baseId] },
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((row) =>
|
||||
row.id === variables.rowId
|
||||
? {
|
||||
...row,
|
||||
cells: { ...row.cells, ...variables.cells },
|
||||
}
|
||||
: row,
|
||||
),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return { snapshots };
|
||||
},
|
||||
onError: (_, variables, context) => {
|
||||
if (context?.snapshots) {
|
||||
for (const [key, data] of context.snapshots) {
|
||||
queryClient.setQueryData(key, data);
|
||||
}
|
||||
}
|
||||
notifications.show({
|
||||
message: t("Failed to update row"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
onSuccess: (updatedRow) => {
|
||||
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
|
||||
{ queryKey: ["base-rows", updatedRow.baseId] },
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((row) =>
|
||||
row.id === updatedRow.id
|
||||
? { ...row, ...updatedRow, cells: { ...row.cells, ...updatedRow.cells } }
|
||||
: row,
|
||||
),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteRowMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<void, Error, DeleteRowInput, RowCacheContext>({
|
||||
mutationFn: (data) => deleteRow({ ...data, requestId: newRequestId() }),
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["base-rows", variables.baseId],
|
||||
});
|
||||
|
||||
const snapshots = queryClient.getQueriesData<
|
||||
InfiniteData<IPagination<IBaseRow>>
|
||||
>({ queryKey: ["base-rows", variables.baseId] });
|
||||
|
||||
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
|
||||
{ queryKey: ["base-rows", variables.baseId] },
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.filter((row) => row.id !== variables.rowId),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return { snapshots };
|
||||
},
|
||||
onError: (_, variables, context) => {
|
||||
if (context?.snapshots) {
|
||||
for (const [key, data] of context.snapshots) {
|
||||
queryClient.setQueryData(key, data);
|
||||
}
|
||||
}
|
||||
notifications.show({
|
||||
message: t("Failed to delete row"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteRowsMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<void, Error, DeleteRowsInput, RowCacheContext>({
|
||||
mutationFn: (data) => deleteRows({ ...data, requestId: newRequestId() }),
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["base-rows", variables.baseId],
|
||||
});
|
||||
|
||||
const snapshots = queryClient.getQueriesData<
|
||||
InfiniteData<IPagination<IBaseRow>>
|
||||
>({ queryKey: ["base-rows", variables.baseId] });
|
||||
|
||||
const removeSet = new Set(variables.rowIds);
|
||||
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
|
||||
{ queryKey: ["base-rows", variables.baseId] },
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.filter((row) => !removeSet.has(row.id)),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return { snapshots };
|
||||
},
|
||||
onError: (_, __, context) => {
|
||||
if (context?.snapshots) {
|
||||
for (const [key, data] of context.snapshots) {
|
||||
queryClient.setQueryData(key, data);
|
||||
}
|
||||
}
|
||||
notifications.show({
|
||||
message: t("Failed to delete rows"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useReorderRowMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<void, Error, ReorderRowInput, RowCacheContext>({
|
||||
mutationFn: (data) => reorderRow({ ...data, requestId: newRequestId() }),
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["base-rows", variables.baseId],
|
||||
});
|
||||
|
||||
const snapshots = queryClient.getQueriesData<
|
||||
InfiniteData<IPagination<IBaseRow>>
|
||||
>({ queryKey: ["base-rows", variables.baseId] });
|
||||
|
||||
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
|
||||
{ queryKey: ["base-rows", variables.baseId] },
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
pages: old.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.map((row) =>
|
||||
row.id === variables.rowId
|
||||
? { ...row, position: variables.position }
|
||||
: row,
|
||||
),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return { snapshots };
|
||||
},
|
||||
onError: (_, variables, context) => {
|
||||
if (context?.snapshots) {
|
||||
for (const [key, data] of context.snapshots) {
|
||||
queryClient.setQueryData(key, data);
|
||||
}
|
||||
}
|
||||
notifications.show({
|
||||
message: t("Failed to reorder row"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
createView,
|
||||
updateView,
|
||||
deleteView,
|
||||
} from "@/features/base/services/base-service";
|
||||
import {
|
||||
IBase,
|
||||
IBaseView,
|
||||
CreateViewInput,
|
||||
UpdateViewInput,
|
||||
DeleteViewInput,
|
||||
} from "@/features/base/types/base.types";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { queryClient } from "@/main";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function useCreateViewMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<IBaseView, Error, CreateViewInput>({
|
||||
mutationFn: (data) => createView(data),
|
||||
onSuccess: (newView) => {
|
||||
queryClient.setQueryData<IBase>(
|
||||
["bases", newView.baseId],
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
views: [...old.views, newView],
|
||||
};
|
||||
},
|
||||
);
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
message: t("Failed to create view"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateViewMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<IBaseView, Error, UpdateViewInput, { previous: IBase | undefined }>({
|
||||
mutationFn: (data) => updateView(data),
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["bases", variables.baseId],
|
||||
});
|
||||
|
||||
const previous = queryClient.getQueryData<IBase>([
|
||||
"bases",
|
||||
variables.baseId,
|
||||
]);
|
||||
|
||||
queryClient.setQueryData<IBase>(
|
||||
["bases", variables.baseId],
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
views: old.views.map((v) =>
|
||||
v.id === variables.viewId
|
||||
? {
|
||||
...v,
|
||||
...(variables.name !== undefined && {
|
||||
name: variables.name,
|
||||
}),
|
||||
...(variables.type !== undefined && {
|
||||
type: variables.type,
|
||||
}),
|
||||
...(variables.config !== undefined && {
|
||||
config: variables.config,
|
||||
}),
|
||||
}
|
||||
: v,
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return { previous };
|
||||
},
|
||||
onError: (_, variables, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(
|
||||
["bases", variables.baseId],
|
||||
context.previous,
|
||||
);
|
||||
}
|
||||
notifications.show({
|
||||
message: t("Failed to update view"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
onSuccess: (updatedView) => {
|
||||
queryClient.setQueryData<IBase>(
|
||||
["bases", updatedView.baseId],
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
views: old.views.map((v) =>
|
||||
v.id === updatedView.id ? updatedView : v,
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteViewMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<void, Error, DeleteViewInput>({
|
||||
mutationFn: (data) => deleteView(data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.setQueryData<IBase>(
|
||||
["bases", variables.baseId],
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
views: old.views.filter((v) => v.id !== variables.viewId),
|
||||
};
|
||||
},
|
||||
);
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
message: t("Failed to delete view"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import api from "@/lib/api-client";
|
||||
import { saveAs } from "file-saver";
|
||||
import {
|
||||
IBase,
|
||||
IBaseProperty,
|
||||
IBaseRow,
|
||||
IBaseView,
|
||||
CreateBaseInput,
|
||||
UpdateBaseInput,
|
||||
CreatePropertyInput,
|
||||
UpdatePropertyInput,
|
||||
DeletePropertyInput,
|
||||
ReorderPropertyInput,
|
||||
CreateRowInput,
|
||||
UpdateRowInput,
|
||||
DeleteRowInput,
|
||||
DeleteRowsInput,
|
||||
ReorderRowInput,
|
||||
CreateViewInput,
|
||||
UpdateViewInput,
|
||||
DeleteViewInput,
|
||||
UpdatePropertyResult,
|
||||
FilterNode,
|
||||
SearchSpec,
|
||||
ViewSortConfig,
|
||||
} from "@/features/base/types/base.types";
|
||||
import { IPagination } from "@/lib/types";
|
||||
|
||||
// --- Bases ---
|
||||
|
||||
export async function createBase(data: CreateBaseInput): Promise<IBase> {
|
||||
const req = await api.post<IBase>("/bases/create", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getBaseInfo(baseId: string): Promise<IBase> {
|
||||
const req = await api.post<IBase>("/bases/info", { baseId });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function updateBase(data: UpdateBaseInput): Promise<IBase> {
|
||||
const req = await api.post<IBase>("/bases/update", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function deleteBase(baseId: string): Promise<void> {
|
||||
await api.post("/bases/delete", { baseId });
|
||||
}
|
||||
|
||||
export async function exportBaseToCsv(baseId: string): Promise<void> {
|
||||
const req = await api.post(
|
||||
"/bases/export-csv",
|
||||
{ baseId },
|
||||
{ responseType: "blob" },
|
||||
);
|
||||
|
||||
const header = (req?.headers?.["content-disposition"] as string) ?? "";
|
||||
const utf8Match = header.match(/filename\*=UTF-8''([^;]+)/i);
|
||||
const plainMatch = header.match(/filename="?([^";]+)"?/i);
|
||||
let fileName = utf8Match?.[1] ?? plainMatch?.[1] ?? "base.csv";
|
||||
try {
|
||||
fileName = decodeURIComponent(fileName);
|
||||
} catch {
|
||||
// fallback to raw filename
|
||||
}
|
||||
|
||||
saveAs(req.data, fileName);
|
||||
}
|
||||
|
||||
export async function listBases(
|
||||
spaceId: string,
|
||||
params?: { cursor?: string; limit?: number },
|
||||
): Promise<IPagination<IBase>> {
|
||||
const req = await api.post("/bases", { spaceId, ...params });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
// --- Properties ---
|
||||
|
||||
export async function createProperty(
|
||||
data: CreatePropertyInput,
|
||||
): Promise<IBaseProperty> {
|
||||
const req = await api.post<IBaseProperty>("/bases/properties/create", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function updateProperty(
|
||||
data: UpdatePropertyInput,
|
||||
): Promise<UpdatePropertyResult> {
|
||||
const req = await api.post<UpdatePropertyResult>(
|
||||
"/bases/properties/update",
|
||||
data,
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function deleteProperty(data: DeletePropertyInput): Promise<void> {
|
||||
await api.post("/bases/properties/delete", data);
|
||||
}
|
||||
|
||||
export async function reorderProperty(
|
||||
data: ReorderPropertyInput,
|
||||
): Promise<void> {
|
||||
await api.post("/bases/properties/reorder", data);
|
||||
}
|
||||
|
||||
// --- Rows ---
|
||||
|
||||
export async function createRow(data: CreateRowInput): Promise<IBaseRow> {
|
||||
const req = await api.post<IBaseRow>("/bases/rows/create", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getRowInfo(
|
||||
rowId: string,
|
||||
baseId: string,
|
||||
): Promise<IBaseRow> {
|
||||
const req = await api.post<IBaseRow>("/bases/rows/info", { rowId, baseId });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function updateRow(data: UpdateRowInput): Promise<IBaseRow> {
|
||||
const req = await api.post<IBaseRow>("/bases/rows/update", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function deleteRow(data: DeleteRowInput): Promise<void> {
|
||||
await api.post("/bases/rows/delete", data);
|
||||
}
|
||||
|
||||
export async function deleteRows(data: DeleteRowsInput): Promise<void> {
|
||||
await api.post("/bases/rows/delete-many", data);
|
||||
}
|
||||
|
||||
export async function listRows(
|
||||
baseId: string,
|
||||
params?: {
|
||||
viewId?: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
filter?: FilterNode;
|
||||
sorts?: ViewSortConfig[];
|
||||
search?: SearchSpec;
|
||||
},
|
||||
): Promise<IPagination<IBaseRow>> {
|
||||
const req = await api.post("/bases/rows", { baseId, ...params });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function reorderRow(data: ReorderRowInput): Promise<void> {
|
||||
await api.post("/bases/rows/reorder", data);
|
||||
}
|
||||
|
||||
// --- Views ---
|
||||
|
||||
export async function createView(data: CreateViewInput): Promise<IBaseView> {
|
||||
const req = await api.post<IBaseView>("/bases/views/create", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function updateView(data: UpdateViewInput): Promise<IBaseView> {
|
||||
const req = await api.post<IBaseView>("/bases/views/update", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function deleteView(data: DeleteViewInput): Promise<void> {
|
||||
await api.post("/bases/views/delete", data);
|
||||
}
|
||||
|
||||
export async function listViews(baseId: string): Promise<IBaseView[]> {
|
||||
const req = await api.post<IBaseView[]>("/bases/views", { baseId });
|
||||
return req.data;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--mantine-spacing-xs);
|
||||
padding: var(--mantine-spacing-xs) 0;
|
||||
margin-bottom: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
.toolbarTabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.toolbarActions {
|
||||
display: flex;
|
||||
gap: var(--mantine-spacing-xs);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.gridWrapper {
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
border-top: 1px solid
|
||||
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.cellInner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.headerCellInner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 0 8px;
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
.cellInput {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.cellInput::placeholder {
|
||||
color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3));
|
||||
}
|
||||
|
||||
.numberValue {
|
||||
text-align: right;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.badgeGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.overflowCount {
|
||||
font-size: 11px;
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.checkboxCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.urlLink {
|
||||
color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.urlLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.emailLink {
|
||||
color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.emailLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.dateValue {
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
}
|
||||
|
||||
.emptyValue {
|
||||
color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
|
||||
}
|
||||
|
||||
/* Person cell — read mode (vertical list like Notion) */
|
||||
|
||||
.personGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.personRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.personName {
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Person cell — edit mode (tag input + dropdown) */
|
||||
|
||||
.personTagArea {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
.personTag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 4px 2px 2px;
|
||||
border-radius: 3px;
|
||||
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
white-space: nowrap;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.personTagName {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.personTagRemove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-2));
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.personTagRemove:hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||
}
|
||||
|
||||
.personTagInput {
|
||||
flex: 1;
|
||||
min-width: 60px;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.personTagInput::placeholder {
|
||||
color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3));
|
||||
}
|
||||
|
||||
.personDropdownDivider {
|
||||
height: 1px;
|
||||
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||
}
|
||||
|
||||
.personDropdownHint {
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
}
|
||||
|
||||
.personOptionName {
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.fileGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fileBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
font-size: 11px;
|
||||
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
white-space: nowrap;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.selectDropdown {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.selectOption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
transition: background-color 100ms ease;
|
||||
}
|
||||
|
||||
.selectOption:hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||
}
|
||||
|
||||
.selectOptionActive {
|
||||
background-color: light-dark(var(--mantine-color-blue-0), var(--mantine-color-blue-9));
|
||||
}
|
||||
|
||||
.selectOptionKeyboardActive {
|
||||
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||
}
|
||||
|
||||
.selectOptionActive.selectOptionKeyboardActive {
|
||||
background-color: light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8));
|
||||
}
|
||||
|
||||
.selectCategoryLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
padding: 8px 8px 4px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
|
||||
cursor: pointer;
|
||||
transition: background-color 100ms ease;
|
||||
}
|
||||
|
||||
.menuItem:hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
.addOptionRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
transition: background-color 100ms ease;
|
||||
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.addOptionRow:hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
.addOptionLabel {
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
.gridWrapper {
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
overflow-anchor: none;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
min-width: max-content;
|
||||
border: 1px solid
|
||||
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
}
|
||||
|
||||
.headerRow {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: subgrid;
|
||||
}
|
||||
|
||||
.headerCell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 34px;
|
||||
padding: 0 8px;
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
font-weight: 600;
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-0),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
border-bottom: 1px solid
|
||||
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||
border-right: 1px solid
|
||||
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.headerCell:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.headerCellPinned {
|
||||
position: sticky;
|
||||
z-index: 2;
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-0),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
}
|
||||
|
||||
.headerCellContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.headerCellName {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.headerTypeIcon {
|
||||
flex-shrink: 0;
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
}
|
||||
|
||||
.headerConvertingSpinner {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.resizeHandle {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.resizeHandle::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
width: 2px;
|
||||
border-radius: 1px;
|
||||
background-color: transparent;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.resizeHandle:hover::after,
|
||||
.resizeHandleActive::after {
|
||||
background-color: var(--mantine-color-blue-5);
|
||||
}
|
||||
|
||||
.rowContainer {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: subgrid;
|
||||
}
|
||||
|
||||
.row:hover .cell {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-0),
|
||||
var(--mantine-color-dark-7)
|
||||
);
|
||||
}
|
||||
|
||||
.cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 36px;
|
||||
padding: 0 8px;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-white),
|
||||
var(--mantine-color-dark-7)
|
||||
);
|
||||
border-bottom: 1px solid
|
||||
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||
border-right: 1px solid
|
||||
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||
overflow: hidden;
|
||||
cursor: default;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cell:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.cellPinned {
|
||||
position: sticky;
|
||||
z-index: 1;
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-white),
|
||||
var(--mantine-color-dark-7)
|
||||
);
|
||||
}
|
||||
|
||||
.row:hover .cellPinned {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-0),
|
||||
var(--mantine-color-dark-7)
|
||||
);
|
||||
}
|
||||
|
||||
.cellEditing {
|
||||
outline: 2px solid var(--mantine-color-blue-5);
|
||||
outline-offset: -2px;
|
||||
z-index: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.cellContent {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rowNumberCell {
|
||||
justify-content: center;
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
}
|
||||
|
||||
.rowNumberDraggable {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.rowNumberDraggable:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.rowDragging .cell {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.rowDropAbove .cell {
|
||||
box-shadow: inset 0 2px 0 0 var(--mantine-color-blue-5);
|
||||
}
|
||||
|
||||
.rowDropBelow .cell {
|
||||
box-shadow: inset 0 -2px 0 0 var(--mantine-color-blue-5);
|
||||
}
|
||||
|
||||
.addRowButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 34px;
|
||||
padding: 0 8px;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
border-top: 1px dashed
|
||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background-color 150ms ease;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.addRowButton:hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-0),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
}
|
||||
|
||||
.addColumnButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 34px;
|
||||
min-width: 40px;
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-0),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
border-bottom: 1px solid
|
||||
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.addColumnButton:hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-5)
|
||||
);
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--mantine-spacing-md);
|
||||
padding: var(--mantine-spacing-xl) var(--mantine-spacing-md);
|
||||
grid-column: 1 / -1;
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--mantine-spacing-xs);
|
||||
padding: var(--mantine-spacing-xs) 0;
|
||||
margin-bottom: var(--mantine-spacing-xs);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbarRight {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
.primaryCell {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rowNumberCellInner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.rowNumberIndex,
|
||||
.rowNumberCheckbox {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.rowNumberCheckbox {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rowNumberDragHandle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: -6px;
|
||||
transform: translateY(-50%);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.rowNumberDragHandle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* On row hover: swap index for checkbox, reveal drag handle */
|
||||
.row:hover .rowNumberIndex {
|
||||
display: none;
|
||||
}
|
||||
.row:hover .rowNumberCheckbox,
|
||||
.row:hover .rowNumberDragHandle {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* Selected row: checkbox always visible, drag handle still only on hover */
|
||||
.rowSelected .rowNumberIndex {
|
||||
display: none;
|
||||
}
|
||||
.rowSelected .rowNumberCheckbox {
|
||||
display: inline-flex;
|
||||
}
|
||||
.rowSelected .cell {
|
||||
background: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
.row.rowSelected:hover .cell,
|
||||
.row.rowSelected:hover .cellPinned {
|
||||
background-color: light-dark(var(--mantine-color-blue-1), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
.rowNumberHeaderInner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.rowNumberHeaderHash {
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
}
|
||||
|
||||
.rowNumberHeaderCheckbox {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.headerCell:hover .rowNumberHeaderHash,
|
||||
.hasSelection .rowNumberHeaderHash {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.headerCell:hover .rowNumberHeaderCheckbox,
|
||||
.hasSelection .rowNumberHeaderCheckbox {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.selectionActionBarWrapper {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 24px;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.selectionActionBar {
|
||||
pointer-events: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 6px 6px 14px;
|
||||
border-radius: 999px;
|
||||
box-shadow:
|
||||
0 10px 30px rgba(0, 0, 0, 0.25),
|
||||
0 2px 8px rgba(0, 0, 0, 0.18);
|
||||
background: light-dark(
|
||||
var(--mantine-color-dark-8),
|
||||
var(--mantine-color-dark-5)
|
||||
);
|
||||
color: var(--mantine-color-white);
|
||||
border: 1px solid light-dark(
|
||||
var(--mantine-color-dark-9),
|
||||
var(--mantine-color-dark-4)
|
||||
);
|
||||
}
|
||||
|
||||
.selectionActionBarCount {
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
font-weight: 500;
|
||||
color: var(--mantine-color-white);
|
||||
padding-right: 10px;
|
||||
margin-right: 2px;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.selectionActionBarDelete {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--mantine-color-red-4);
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
font-weight: 500;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
|
||||
.selectionActionBarDelete:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.selectionActionBarDelete:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.selectionActionBarClose {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--mantine-color-gray-3);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.selectionActionBarClose:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--mantine-color-white);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
export type BasePropertyType =
|
||||
| 'text'
|
||||
| 'number'
|
||||
| 'select'
|
||||
| 'status'
|
||||
| 'multiSelect'
|
||||
| 'date'
|
||||
| 'person'
|
||||
| 'file'
|
||||
| 'checkbox'
|
||||
| 'url'
|
||||
| 'email'
|
||||
| 'createdAt'
|
||||
| 'lastEditedAt'
|
||||
| 'lastEditedBy';
|
||||
|
||||
export type Choice = {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
category?: 'todo' | 'inProgress' | 'complete';
|
||||
};
|
||||
|
||||
export type SelectTypeOptions = {
|
||||
choices: Choice[];
|
||||
choiceOrder: string[];
|
||||
disableColors?: boolean;
|
||||
defaultValue?: string | string[] | null;
|
||||
};
|
||||
|
||||
export type NumberTypeOptions = {
|
||||
format?: 'plain' | 'currency' | 'percent' | 'progress';
|
||||
precision?: number;
|
||||
currencySymbol?: string;
|
||||
defaultValue?: number | null;
|
||||
};
|
||||
|
||||
export type DateTypeOptions = {
|
||||
dateFormat?: string;
|
||||
timeFormat?: '12h' | '24h';
|
||||
includeTime?: boolean;
|
||||
defaultValue?: string | null;
|
||||
};
|
||||
|
||||
export type TextTypeOptions = {
|
||||
richText?: boolean;
|
||||
defaultValue?: string | null;
|
||||
};
|
||||
|
||||
export type CheckboxTypeOptions = {
|
||||
defaultValue?: boolean;
|
||||
};
|
||||
|
||||
export type UrlTypeOptions = {
|
||||
defaultValue?: string | null;
|
||||
};
|
||||
|
||||
export type EmailTypeOptions = {
|
||||
defaultValue?: string | null;
|
||||
};
|
||||
|
||||
export type PersonTypeOptions = {
|
||||
allowMultiple?: boolean;
|
||||
};
|
||||
|
||||
export type TypeOptions =
|
||||
| SelectTypeOptions
|
||||
| NumberTypeOptions
|
||||
| DateTypeOptions
|
||||
| TextTypeOptions
|
||||
| CheckboxTypeOptions
|
||||
| UrlTypeOptions
|
||||
| EmailTypeOptions
|
||||
| PersonTypeOptions
|
||||
| Record<string, unknown>;
|
||||
|
||||
export type IBaseProperty = {
|
||||
id: string;
|
||||
baseId: string;
|
||||
name: string;
|
||||
type: BasePropertyType;
|
||||
position: string;
|
||||
typeOptions: TypeOptions;
|
||||
// Set while a background type-conversion job is rewriting cells. The
|
||||
// live `type` stays on the old kind until the job commits, so cells
|
||||
// render correctly; the column header shows a "Converting…" badge.
|
||||
pendingType?: BasePropertyType | null;
|
||||
pendingTypeOptions?: TypeOptions | null;
|
||||
isPrimary: boolean;
|
||||
workspaceId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type IBaseRow = {
|
||||
id: string;
|
||||
baseId: string;
|
||||
cells: Record<string, unknown>;
|
||||
position: string;
|
||||
creatorId: string;
|
||||
lastUpdatedById: string | null;
|
||||
workspaceId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type ViewSortConfig = {
|
||||
propertyId: string;
|
||||
direction: 'asc' | 'desc';
|
||||
};
|
||||
|
||||
// Matches the server's engine operator set (core/base/engine/schema.zod.ts).
|
||||
export type FilterOperator =
|
||||
| 'eq'
|
||||
| 'neq'
|
||||
| 'gt'
|
||||
| 'gte'
|
||||
| 'lt'
|
||||
| 'lte'
|
||||
| 'contains'
|
||||
| 'ncontains'
|
||||
| 'startsWith'
|
||||
| 'endsWith'
|
||||
| 'isEmpty'
|
||||
| 'isNotEmpty'
|
||||
| 'before'
|
||||
| 'after'
|
||||
| 'onOrBefore'
|
||||
| 'onOrAfter'
|
||||
| 'any'
|
||||
| 'none'
|
||||
| 'all';
|
||||
|
||||
export type FilterCondition = {
|
||||
propertyId: string;
|
||||
op: FilterOperator;
|
||||
value?: unknown;
|
||||
};
|
||||
|
||||
export type FilterGroup = {
|
||||
op: 'and' | 'or';
|
||||
children: Array<FilterCondition | FilterGroup>;
|
||||
};
|
||||
|
||||
export type FilterNode = FilterCondition | FilterGroup;
|
||||
|
||||
export type SearchSpec = {
|
||||
query: string;
|
||||
mode?: 'trgm' | 'fts';
|
||||
};
|
||||
|
||||
export type ViewConfig = {
|
||||
sorts?: ViewSortConfig[];
|
||||
filter?: FilterGroup;
|
||||
visiblePropertyIds?: string[];
|
||||
hiddenPropertyIds?: string[];
|
||||
propertyWidths?: Record<string, number>;
|
||||
propertyOrder?: string[];
|
||||
};
|
||||
|
||||
export type IBaseView = {
|
||||
id: string;
|
||||
baseId: string;
|
||||
name: string;
|
||||
type: 'table' | 'kanban' | 'calendar';
|
||||
config: ViewConfig;
|
||||
workspaceId: string;
|
||||
creatorId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type IBase = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
pageId?: string;
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
creatorId: string;
|
||||
properties: IBaseProperty[];
|
||||
views: IBaseView[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type EditingCell = {
|
||||
rowId: string;
|
||||
propertyId: string;
|
||||
} | null;
|
||||
|
||||
export type CreateBaseInput = {
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
pageId?: string;
|
||||
spaceId: string;
|
||||
};
|
||||
|
||||
export type UpdateBaseInput = {
|
||||
baseId: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
export type CreatePropertyInput = {
|
||||
baseId: string;
|
||||
name: string;
|
||||
type: BasePropertyType;
|
||||
typeOptions?: TypeOptions;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export type UpdatePropertyInput = {
|
||||
propertyId: string;
|
||||
baseId: string;
|
||||
name?: string;
|
||||
typeOptions?: TypeOptions;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export type DeletePropertyInput = {
|
||||
propertyId: string;
|
||||
baseId: string;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export type ReorderPropertyInput = {
|
||||
propertyId: string;
|
||||
baseId: string;
|
||||
position: string;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export type CreateRowInput = {
|
||||
baseId: string;
|
||||
cells?: Record<string, unknown>;
|
||||
afterRowId?: string;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export type UpdateRowInput = {
|
||||
rowId: string;
|
||||
baseId: string;
|
||||
cells: Record<string, unknown>;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export type DeleteRowInput = {
|
||||
rowId: string;
|
||||
baseId: string;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export type DeleteRowsInput = {
|
||||
baseId: string;
|
||||
rowIds: string[];
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export type ReorderRowInput = {
|
||||
rowId: string;
|
||||
baseId: string;
|
||||
position: string;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export type CreateViewInput = {
|
||||
baseId: string;
|
||||
name: string;
|
||||
type?: 'table' | 'kanban' | 'calendar';
|
||||
config?: ViewConfig;
|
||||
};
|
||||
|
||||
export type UpdateViewInput = {
|
||||
viewId: string;
|
||||
baseId: string;
|
||||
name?: string;
|
||||
type?: 'table' | 'kanban' | 'calendar';
|
||||
config?: ViewConfig;
|
||||
};
|
||||
|
||||
export type DeleteViewInput = {
|
||||
viewId: string;
|
||||
baseId: string;
|
||||
};
|
||||
|
||||
export type UpdatePropertyResult = {
|
||||
property: IBaseProperty;
|
||||
// Non-null when the property change kicked off a BullMQ type-conversion
|
||||
// job (select/multiSelect/person/file → anything, or any → system type).
|
||||
// Client can listen for `base:schema:bumped` on the base room to know
|
||||
// when the job finished migrating cells.
|
||||
jobId: string | null;
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import "@tanstack/react-table";
|
||||
import { IBaseProperty } from "@/features/base/types/base.types";
|
||||
|
||||
declare module "@tanstack/react-table" {
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
property?: IBaseProperty;
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,7 @@ export const uploadAttachmentAction = handleAttachmentUpload({
|
||||
},
|
||||
validateFn: (file, allowMedia: boolean) => {
|
||||
if (
|
||||
(file.type.includes("image/") ||
|
||||
file.type.includes("video/") ||
|
||||
file.type === "application/pdf") &&
|
||||
(file.type.includes("image/") || file.type.includes("video/")) &&
|
||||
!allowMedia
|
||||
) {
|
||||
return false;
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
IconCheck,
|
||||
IconFileCode,
|
||||
IconFileTypeDocx,
|
||||
IconFileTypePdf,
|
||||
IconFileTypeZip,
|
||||
IconMarkdown,
|
||||
IconX,
|
||||
@@ -91,14 +90,12 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
||||
const markdownFileRef = useRef<() => void>(null);
|
||||
const htmlFileRef = useRef<() => void>(null);
|
||||
const docxFileRef = useRef<() => void>(null);
|
||||
const pdfFileRef = useRef<() => void>(null);
|
||||
const notionFileRef = useRef<() => void>(null);
|
||||
const confluenceFileRef = useRef<() => void>(null);
|
||||
const zipFileRef = useRef<() => void>(null);
|
||||
|
||||
const canUseConfluence = useHasFeature(Feature.CONFLUENCE_IMPORT);
|
||||
const canUseDocx = useHasFeature(Feature.DOCX_IMPORT);
|
||||
const canUsePdf = useHasFeature(Feature.PDF_IMPORT);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
|
||||
const handleZipUpload = async (selectedFile: File, source: string) => {
|
||||
@@ -247,7 +244,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
||||
}, 3000);
|
||||
}, [fileTaskId]);
|
||||
|
||||
const maxSingleFileSize = bytes("30mb");
|
||||
const maxSingleFileSize = bytes("20mb");
|
||||
|
||||
const handleFileUpload = async (selectedFiles: File[]) => {
|
||||
if (!selectedFiles) {
|
||||
@@ -301,7 +298,6 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
||||
if (markdownFileRef.current) markdownFileRef.current();
|
||||
if (htmlFileRef.current) htmlFileRef.current();
|
||||
if (docxFileRef.current) docxFileRef.current();
|
||||
if (pdfFileRef.current) pdfFileRef.current();
|
||||
|
||||
const pageCountText =
|
||||
pageCount === 1 ? `1 ${t("page")}` : `${pageCount} ${t("pages")}`;
|
||||
@@ -382,30 +378,6 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
||||
)}
|
||||
</FileButton>
|
||||
|
||||
<FileButton
|
||||
onChange={handleFileUpload}
|
||||
accept=".pdf"
|
||||
multiple
|
||||
resetRef={pdfFileRef}
|
||||
>
|
||||
{(props) => (
|
||||
<Tooltip
|
||||
label={upgradeLabel}
|
||||
disabled={canUsePdf}
|
||||
>
|
||||
<Button
|
||||
disabled={!canUsePdf}
|
||||
justify="start"
|
||||
variant="default"
|
||||
leftSection={<IconFileTypePdf size={18} />}
|
||||
{...props}
|
||||
>
|
||||
PDF
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</FileButton>
|
||||
|
||||
<FileButton
|
||||
onChange={(file) => handleZipUpload(file, "notion")}
|
||||
accept="application/zip"
|
||||
|
||||
@@ -10,7 +10,11 @@ const api: AxiosInstance = axios.create({
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
// we need the response headers for these endpoints
|
||||
const exemptEndpoints = ["/api/pages/export", "/api/spaces/export"];
|
||||
const exemptEndpoints = [
|
||||
"/api/pages/export",
|
||||
"/api/spaces/export",
|
||||
"/api/bases/export-csv",
|
||||
];
|
||||
if (response.request.responseURL) {
|
||||
const path = new URL(response.request.responseURL)?.pathname;
|
||||
if (path && exemptEndpoints.includes(path)) {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Container, Title, Text, Stack } from "@mantine/core";
|
||||
import { BaseTable } from "@/features/base/components/base-table";
|
||||
import { useBaseQuery } from "@/features/base/queries/base-query";
|
||||
|
||||
export default function BasePage() {
|
||||
const { baseId } = useParams<{ baseId: string }>();
|
||||
const { data: base } = useBaseQuery(baseId);
|
||||
|
||||
if (!baseId) {
|
||||
return (
|
||||
<Stack align="center" p="xl">
|
||||
<Text c="dimmed">No base ID provided</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container
|
||||
fluid
|
||||
p="md"
|
||||
style={{ height: "calc(100vh - 60px)", display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
{base && (
|
||||
<Title order={3} mb="xs">
|
||||
{base.icon ? `${base.icon} ` : ""}{base.name}
|
||||
</Title>
|
||||
)}
|
||||
<BaseTable baseId={baseId} />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
+20
-22
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "0.80.1",
|
||||
"version": "0.80.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -33,14 +33,14 @@
|
||||
"@ai-sdk/google": "^3.0.52",
|
||||
"@ai-sdk/openai": "^3.0.47",
|
||||
"@ai-sdk/openai-compatible": "^2.0.37",
|
||||
"@aws-sdk/client-s3": "3.1037.0",
|
||||
"@aws-sdk/lib-storage": "3.1037.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.1037.0",
|
||||
"@aws-sdk/client-s3": "3.1014.0",
|
||||
"@aws-sdk/lib-storage": "3.1014.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.1014.0",
|
||||
"@clickhouse/client": "^1.18.2",
|
||||
"@docmost/pdf-inspector": "1.9.4",
|
||||
"@duckdb/node-api": "1.5.2-r.1",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/multipart": "^10.0.0",
|
||||
"@fastify/static": "^9.1.3",
|
||||
"@fastify/multipart": "^9.4.0",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@keyv/redis": "^5.1.6",
|
||||
"@langchain/core": "1.1.39",
|
||||
"@langchain/textsplitters": "1.0.1",
|
||||
@@ -49,19 +49,19 @@
|
||||
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
|
||||
"@nestjs/bullmq": "^11.0.4",
|
||||
"@nestjs/cache-manager": "^3.1.0",
|
||||
"@nestjs/common": "^11.1.19",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.1.19",
|
||||
"@nestjs/common": "^11.1.18",
|
||||
"@nestjs/config": "^4.0.3",
|
||||
"@nestjs/core": "^11.1.18",
|
||||
"@nestjs/event-emitter": "^3.0.1",
|
||||
"@nestjs/jwt": "11.0.2",
|
||||
"@nestjs/mapped-types": "^2.1.1",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-fastify": "^11.1.19",
|
||||
"@nestjs/platform-socket.io": "^11.1.19",
|
||||
"@nestjs/schedule": "^6.1.3",
|
||||
"@nestjs/platform-fastify": "^11.1.18",
|
||||
"@nestjs/platform-socket.io": "^11.1.18",
|
||||
"@nestjs/schedule": "^6.1.1",
|
||||
"@nestjs/terminus": "^11.1.1",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@nestjs/websockets": "^11.1.19",
|
||||
"@nestjs/websockets": "^11.1.18",
|
||||
"@node-saml/passport-saml": "^5.1.0",
|
||||
"@react-email/components": "1.0.10",
|
||||
"@react-email/render": "2.0.4",
|
||||
@@ -70,12 +70,13 @@
|
||||
"ai-sdk-ollama": "^3.8.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bowser": "^2.14.1",
|
||||
"bullmq": "^5.76.0",
|
||||
"bullmq": "^5.71.0",
|
||||
"cache-manager": "^7.2.8",
|
||||
"cheerio": "^1.2.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"cookie": "^1.1.1",
|
||||
"csv-stringify": "^6",
|
||||
"fast-bm25": "0.0.5",
|
||||
"fastify-ip": "^2.0.0",
|
||||
"fs-extra": "^11.3.4",
|
||||
@@ -101,6 +102,7 @@
|
||||
"p-limit": "^7.3.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pdfjs-dist": "^5.5.207",
|
||||
"pg-tsquery": "^8.4.2",
|
||||
"pgvector": "^0.2.1",
|
||||
"pino-http": "^11.0.0",
|
||||
@@ -110,23 +112,22 @@
|
||||
"react": "^18.3.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"sanitize-filename": "1.6.3",
|
||||
"sanitize-filename-ts": "1.0.2",
|
||||
"socket.io": "^4.8.3",
|
||||
"stripe": "^17.7.0",
|
||||
"tlds": "^1.261.0",
|
||||
"tmp-promise": "^3.0.3",
|
||||
"tseep": "^1.3.1",
|
||||
"typesense": "^3.0.5",
|
||||
"undici": "7.24.0",
|
||||
"ws": "^8.20.0",
|
||||
"yauzl": "^3.2.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.28.0",
|
||||
"@nestjs/cli": "^11.0.21",
|
||||
"@nestjs/cli": "^11.0.18",
|
||||
"@nestjs/schematics": "^11.0.10",
|
||||
"@nestjs/testing": "^11.1.19",
|
||||
"@nestjs/testing": "^11.1.18",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/debounce": "^1.2.4",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
@@ -166,9 +167,6 @@
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"transformIgnorePatterns": [
|
||||
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked)(@|/))"
|
||||
],
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
|
||||
@@ -15,4 +15,26 @@ export enum EventName {
|
||||
WORKSPACE_CREATED = 'workspace.created',
|
||||
WORKSPACE_UPDATED = 'workspace.updated',
|
||||
WORKSPACE_DELETED = 'workspace.deleted',
|
||||
|
||||
BASE_CREATED = 'base.created',
|
||||
BASE_UPDATED = 'base.updated',
|
||||
BASE_DELETED = 'base.deleted',
|
||||
|
||||
BASE_ROW_CREATED = 'base.row.created',
|
||||
BASE_ROW_UPDATED = 'base.row.updated',
|
||||
BASE_ROW_DELETED = 'base.row.deleted',
|
||||
BASE_ROWS_DELETED = 'base.rows.deleted',
|
||||
BASE_ROW_RESTORED = 'base.row.restored',
|
||||
BASE_ROW_REORDERED = 'base.row.reordered',
|
||||
|
||||
BASE_PROPERTY_CREATED = 'base.property.created',
|
||||
BASE_PROPERTY_UPDATED = 'base.property.updated',
|
||||
BASE_PROPERTY_DELETED = 'base.property.deleted',
|
||||
BASE_PROPERTY_REORDERED = 'base.property.reordered',
|
||||
|
||||
BASE_VIEW_CREATED = 'base.view.created',
|
||||
BASE_VIEW_UPDATED = 'base.view.updated',
|
||||
BASE_VIEW_DELETED = 'base.view.deleted',
|
||||
|
||||
BASE_SCHEMA_BUMPED = 'base.schema.bumped',
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ export const Feature = {
|
||||
AI: 'ai',
|
||||
CONFLUENCE_IMPORT: 'import:confluence',
|
||||
DOCX_IMPORT: 'import:docx',
|
||||
PDF_IMPORT: 'import:pdf',
|
||||
ATTACHMENT_INDEXING: 'attachment:indexing',
|
||||
SECURITY_SETTINGS: 'security:settings',
|
||||
MCP: 'mcp',
|
||||
|
||||
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
import * as path from 'path';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import sanitize = require('sanitize-filename');
|
||||
import { sanitize } from 'sanitize-filename-ts';
|
||||
import { FastifyRequest } from 'fastify';
|
||||
import { Readable, Transform } from 'stream';
|
||||
|
||||
@@ -72,33 +72,11 @@ export function extractDateFromUuid7(uuid7: string) {
|
||||
return new Date(timestamp);
|
||||
}
|
||||
|
||||
export type SanitizeFileNameOptions = {
|
||||
/** Keep spaces and `#` instead of replacing them with `_`. Useful for
|
||||
* download filenames where readability matters. Defaults to false. */
|
||||
preserveSpaces?: boolean;
|
||||
};
|
||||
|
||||
export function sanitizeFileName(
|
||||
fileName: string,
|
||||
options: SanitizeFileNameOptions = {},
|
||||
): string {
|
||||
// Decode percent-encoded sequences so that bypasses like "..%2F" reach
|
||||
// sanitize() as literal "../" and get stripped. sanitize-filename only
|
||||
// strips literal characters and won't catch encoded path separators
|
||||
// on its own.
|
||||
const decoded = fileName.replace(/%[0-9a-fA-F]{2}/g, (m) => {
|
||||
try {
|
||||
return decodeURIComponent(m);
|
||||
} catch {
|
||||
return m;
|
||||
}
|
||||
});
|
||||
|
||||
const sanitized = sanitize(decoded);
|
||||
if (options.preserveSpaces) {
|
||||
return sanitized;
|
||||
}
|
||||
return sanitized.replace(/ /g, '_').replace(/#/g, '_');
|
||||
export function sanitizeFileName(fileName: string): string {
|
||||
const sanitizedFilename = sanitize(fileName)
|
||||
.replace(/ /g, '_')
|
||||
.replace(/#/g, '_');
|
||||
return sanitizedFilename.slice(0, 255);
|
||||
}
|
||||
|
||||
export function removeAccent(str: string): string {
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
} from '../casl/interfaces/workspace-ability.type';
|
||||
import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { BaseRepo } from '@docmost/db/repos/base/base.repo';
|
||||
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
|
||||
import { validate as isValidUUID } from 'uuid';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
@@ -71,6 +72,7 @@ export class AttachmentController {
|
||||
private readonly workspaceAbility: WorkspaceAbilityFactory,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly baseRepo: BaseRepo,
|
||||
private readonly attachmentRepo: AttachmentRepo,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly tokenService: TokenService,
|
||||
@@ -163,6 +165,87 @@ export class AttachmentController {
|
||||
}
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('bases/files/upload')
|
||||
@UseInterceptors(FileInterceptor)
|
||||
async uploadBaseFile(
|
||||
@Req() req: any,
|
||||
@Res() res: FastifyReply,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const maxFileSize = bytes(this.environmentService.getFileUploadSizeLimit());
|
||||
|
||||
let file = null;
|
||||
try {
|
||||
file = await req.file({
|
||||
limits: { fileSize: maxFileSize, fields: 3, files: 1 },
|
||||
});
|
||||
} catch (err: any) {
|
||||
this.logger.error(err.message);
|
||||
if (err?.statusCode === 413) {
|
||||
throw new BadRequestException(
|
||||
`File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
throw new BadRequestException('Failed to upload file');
|
||||
}
|
||||
|
||||
const baseId = file.fields?.baseId?.value;
|
||||
|
||||
if (!baseId) {
|
||||
throw new BadRequestException('baseId is required');
|
||||
}
|
||||
|
||||
if (!isValidUUID(baseId)) {
|
||||
throw new BadRequestException('Invalid baseId');
|
||||
}
|
||||
|
||||
const base = await this.baseRepo.findById(baseId);
|
||||
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
const spaceId = base.spaceId;
|
||||
|
||||
const spaceAbilityCheck = await this.spaceAbility.createForUser(
|
||||
user,
|
||||
spaceId,
|
||||
);
|
||||
if (
|
||||
spaceAbilityCheck.cannot(
|
||||
SpaceCaslAction.Edit,
|
||||
SpaceCaslSubject.Base,
|
||||
)
|
||||
) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
try {
|
||||
const fileResponse = await this.attachmentService.uploadFile({
|
||||
filePromise: file,
|
||||
spaceId: spaceId,
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
return res.send(fileResponse);
|
||||
} catch (err: any) {
|
||||
if (err?.statusCode === 413) {
|
||||
const errMessage = `File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`;
|
||||
this.logger.error(errMessage);
|
||||
throw new BadRequestException(errMessage);
|
||||
}
|
||||
this.logger.error(err);
|
||||
throw new BadRequestException('Error processing file upload.');
|
||||
}
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('/files/:fileId/:fileName')
|
||||
async getFile(
|
||||
@@ -356,19 +439,9 @@ export class AttachmentController {
|
||||
throw new BadRequestException('Invalid image attachment type');
|
||||
}
|
||||
|
||||
if (!fileName) {
|
||||
throw new BadRequestException('Invalid file name');
|
||||
}
|
||||
|
||||
const ext = path.extname(fileName);
|
||||
const filenameWithoutExt = path.basename(fileName, ext);
|
||||
|
||||
if (
|
||||
!ext ||
|
||||
!isValidUUID(filenameWithoutExt) ||
|
||||
`${filenameWithoutExt}${ext}` !== fileName
|
||||
) {
|
||||
throw new BadRequestException('Invalid file name');
|
||||
const filenameWithoutExt = path.basename(fileName, path.extname(fileName));
|
||||
if (!isValidUUID(filenameWithoutExt)) {
|
||||
throw new BadRequestException('Invalid file id');
|
||||
}
|
||||
|
||||
const filePath = `${getAttachmentFolderPath(attachmentType, workspace.id)}/${fileName}`;
|
||||
|
||||
@@ -43,7 +43,7 @@ export class AttachmentService {
|
||||
|
||||
async uploadFile(opts: {
|
||||
filePromise: Promise<MultipartFile>;
|
||||
pageId: string;
|
||||
pageId?: string;
|
||||
userId: string;
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { BaseController } from './controllers/base.controller';
|
||||
import { BasePropertyController } from './controllers/base-property.controller';
|
||||
import { BaseRowController } from './controllers/base-row.controller';
|
||||
import { BaseViewController } from './controllers/base-view.controller';
|
||||
import { BaseService } from './services/base.service';
|
||||
import { BasePropertyService } from './services/base-property.service';
|
||||
import { BaseRowService } from './services/base-row.service';
|
||||
import { BaseViewService } from './services/base-view.service';
|
||||
import { BaseCsvExportService } from './services/base-csv-export.service';
|
||||
import { BaseQueueProcessor } from './processors/base-queue.processor';
|
||||
import { BaseWsService } from './realtime/base-ws.service';
|
||||
import { BaseWsConsumers } from './realtime/base-ws-consumers';
|
||||
import { BasePresenceService } from './realtime/base-presence.service';
|
||||
import { QueueName } from '../../integrations/queue/constants';
|
||||
import { QueryCacheModule } from './query-cache/query-cache.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
BullModule.registerQueue({ name: QueueName.BASE_QUEUE }),
|
||||
QueryCacheModule,
|
||||
],
|
||||
controllers: [
|
||||
BaseController,
|
||||
BasePropertyController,
|
||||
BaseRowController,
|
||||
BaseViewController,
|
||||
],
|
||||
providers: [
|
||||
BaseService,
|
||||
BasePropertyService,
|
||||
BaseRowService,
|
||||
BaseViewService,
|
||||
BaseCsvExportService,
|
||||
BaseQueueProcessor,
|
||||
BasePresenceService,
|
||||
BaseWsService,
|
||||
BaseWsConsumers,
|
||||
],
|
||||
exports: [
|
||||
BaseService,
|
||||
BasePropertyService,
|
||||
BaseRowService,
|
||||
BaseViewService,
|
||||
BaseWsService,
|
||||
BasePresenceService,
|
||||
],
|
||||
})
|
||||
export class BaseModule {}
|
||||
@@ -0,0 +1,381 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const BasePropertyType = {
|
||||
TEXT: 'text',
|
||||
NUMBER: 'number',
|
||||
SELECT: 'select',
|
||||
STATUS: 'status',
|
||||
MULTI_SELECT: 'multiSelect',
|
||||
DATE: 'date',
|
||||
PERSON: 'person',
|
||||
FILE: 'file',
|
||||
CHECKBOX: 'checkbox',
|
||||
URL: 'url',
|
||||
EMAIL: 'email',
|
||||
CREATED_AT: 'createdAt',
|
||||
LAST_EDITED_AT: 'lastEditedAt',
|
||||
LAST_EDITED_BY: 'lastEditedBy',
|
||||
} as const;
|
||||
|
||||
const SYSTEM_PROPERTY_TYPES: Set<string> = new Set([
|
||||
BasePropertyType.CREATED_AT,
|
||||
BasePropertyType.LAST_EDITED_AT,
|
||||
BasePropertyType.LAST_EDITED_BY,
|
||||
]);
|
||||
|
||||
export function isSystemPropertyType(type: string): boolean {
|
||||
return SYSTEM_PROPERTY_TYPES.has(type);
|
||||
}
|
||||
|
||||
export type BasePropertyTypeValue =
|
||||
(typeof BasePropertyType)[keyof typeof BasePropertyType];
|
||||
|
||||
export const BASE_PROPERTY_TYPES = Object.values(BasePropertyType);
|
||||
|
||||
export const choiceSchema = z.object({
|
||||
id: z.uuid(),
|
||||
name: z.string().min(1),
|
||||
color: z.string(),
|
||||
category: z.enum(['todo', 'inProgress', 'complete']).optional(),
|
||||
});
|
||||
|
||||
export const selectTypeOptionsSchema = z
|
||||
.object({
|
||||
choices: z.array(choiceSchema).default([]),
|
||||
choiceOrder: z.array(z.uuid()).default([]),
|
||||
disableColors: z.boolean().optional(),
|
||||
defaultValue: z
|
||||
.union([z.uuid(), z.array(z.uuid())])
|
||||
.nullable()
|
||||
.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const numberTypeOptionsSchema = z
|
||||
.object({
|
||||
format: z
|
||||
.enum(['plain', 'currency', 'percent', 'progress'])
|
||||
.optional()
|
||||
.default('plain'),
|
||||
precision: z.number().int().min(0).max(10).optional(),
|
||||
currencySymbol: z.string().max(5).optional(),
|
||||
defaultValue: z.number().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const dateTypeOptionsSchema = z
|
||||
.object({
|
||||
dateFormat: z.string().optional(),
|
||||
timeFormat: z.enum(['12h', '24h']).optional(),
|
||||
includeTime: z.boolean().optional(),
|
||||
defaultValue: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const textTypeOptionsSchema = z
|
||||
.object({
|
||||
richText: z.boolean().optional(),
|
||||
defaultValue: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const checkboxTypeOptionsSchema = z
|
||||
.object({
|
||||
defaultValue: z.boolean().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const urlTypeOptionsSchema = z
|
||||
.object({
|
||||
defaultValue: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const emailTypeOptionsSchema = z
|
||||
.object({
|
||||
defaultValue: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const personTypeOptionsSchema = z
|
||||
.object({
|
||||
allowMultiple: z.boolean().default(true),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const emptyTypeOptionsSchema = z.object({}).passthrough();
|
||||
|
||||
const typeOptionsSchemaMap: Record<BasePropertyTypeValue, z.ZodType> = {
|
||||
[BasePropertyType.TEXT]: textTypeOptionsSchema,
|
||||
[BasePropertyType.NUMBER]: numberTypeOptionsSchema,
|
||||
[BasePropertyType.SELECT]: selectTypeOptionsSchema,
|
||||
[BasePropertyType.STATUS]: selectTypeOptionsSchema,
|
||||
[BasePropertyType.MULTI_SELECT]: selectTypeOptionsSchema,
|
||||
[BasePropertyType.DATE]: dateTypeOptionsSchema,
|
||||
[BasePropertyType.PERSON]: personTypeOptionsSchema,
|
||||
[BasePropertyType.FILE]: emptyTypeOptionsSchema,
|
||||
[BasePropertyType.CHECKBOX]: checkboxTypeOptionsSchema,
|
||||
[BasePropertyType.URL]: urlTypeOptionsSchema,
|
||||
[BasePropertyType.EMAIL]: emailTypeOptionsSchema,
|
||||
[BasePropertyType.CREATED_AT]: emptyTypeOptionsSchema,
|
||||
[BasePropertyType.LAST_EDITED_AT]: emptyTypeOptionsSchema,
|
||||
[BasePropertyType.LAST_EDITED_BY]: emptyTypeOptionsSchema,
|
||||
};
|
||||
|
||||
export function validateTypeOptions(
|
||||
type: BasePropertyTypeValue,
|
||||
typeOptions: unknown,
|
||||
): z.ZodSafeParseResult<unknown> {
|
||||
const schema = typeOptionsSchemaMap[type];
|
||||
if (!schema) {
|
||||
return { success: false, error: new z.ZodError([{ code: 'custom', message: `Unknown property type: ${type}`, path: ['type'] }]) } as z.ZodSafeParseError<unknown>;
|
||||
}
|
||||
return schema.safeParse(typeOptions ?? {});
|
||||
}
|
||||
|
||||
export function parseTypeOptions(
|
||||
type: BasePropertyTypeValue,
|
||||
typeOptions: unknown,
|
||||
): unknown {
|
||||
const result = validateTypeOptions(type, typeOptions);
|
||||
if (!result.success) {
|
||||
throw result.error;
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
const cellValueSchemaMap: Partial<Record<BasePropertyTypeValue, z.ZodType>> = {
|
||||
[BasePropertyType.TEXT]: z.string(),
|
||||
[BasePropertyType.NUMBER]: z.number(),
|
||||
[BasePropertyType.SELECT]: z.uuid(),
|
||||
[BasePropertyType.STATUS]: z.uuid(),
|
||||
[BasePropertyType.MULTI_SELECT]: z.array(z.uuid()),
|
||||
[BasePropertyType.DATE]: z.string(),
|
||||
[BasePropertyType.PERSON]: z.union([z.uuid(), z.array(z.uuid())]),
|
||||
[BasePropertyType.FILE]: z.array(z.object({
|
||||
id: z.uuid(),
|
||||
fileName: z.string(),
|
||||
mimeType: z.string().optional(),
|
||||
fileSize: z.number().optional(),
|
||||
filePath: z.string().optional(),
|
||||
})),
|
||||
[BasePropertyType.CHECKBOX]: z.boolean(),
|
||||
[BasePropertyType.URL]: z.url(),
|
||||
[BasePropertyType.EMAIL]: z.email(),
|
||||
};
|
||||
|
||||
export function getCellValueSchema(
|
||||
type: BasePropertyTypeValue,
|
||||
): z.ZodType | undefined {
|
||||
return cellValueSchemaMap[type];
|
||||
}
|
||||
|
||||
export function validateCellValue(
|
||||
type: BasePropertyTypeValue,
|
||||
value: unknown,
|
||||
): z.ZodSafeParseResult<unknown> {
|
||||
const schema = cellValueSchemaMap[type];
|
||||
if (!schema) {
|
||||
return { success: false, error: new z.ZodError([{ code: 'custom', message: `Unknown property type: ${type}`, path: [] }]) } as z.ZodSafeParseError<unknown>;
|
||||
}
|
||||
return schema.safeParse(value);
|
||||
}
|
||||
|
||||
/*
|
||||
* Resolution context for conversions where the source type stores IDs
|
||||
* (select / multiSelect: choice uuid; person: user uuid; file: attachment
|
||||
* uuid). Callers must always supply this — the only invoker is the
|
||||
* `BASE_TYPE_CONVERSION` BullMQ worker, which builds the context per
|
||||
* chunk of rows (see `tasks/base-type-conversion.task.ts`).
|
||||
*/
|
||||
export type CellConversionContext = {
|
||||
fromTypeOptions?: unknown;
|
||||
userNames?: Map<string, string>;
|
||||
attachmentNames?: Map<string, string>;
|
||||
};
|
||||
|
||||
function resolveChoiceName(
|
||||
typeOptions: unknown,
|
||||
id: unknown,
|
||||
): string | undefined {
|
||||
if (!typeOptions || typeof typeOptions !== 'object') return undefined;
|
||||
const choices = (typeOptions as any).choices;
|
||||
if (!Array.isArray(choices)) return undefined;
|
||||
const match = choices.find((c: any) => c?.id === String(id));
|
||||
return typeof match?.name === 'string' ? match.name : undefined;
|
||||
}
|
||||
|
||||
export function attemptCellConversion(
|
||||
fromType: BasePropertyTypeValue,
|
||||
toType: BasePropertyTypeValue,
|
||||
value: unknown,
|
||||
ctx: CellConversionContext,
|
||||
): { converted: boolean; value: unknown } {
|
||||
if (value === null || value === undefined) {
|
||||
return { converted: true, value: null };
|
||||
}
|
||||
|
||||
// Resolve IDs to display strings before any direct parse. `select → text`
|
||||
// and `multiSelect → text` would otherwise short-circuit on z.string()
|
||||
// parsing the UUID itself and return the raw UUID instead of the name.
|
||||
if (toType === BasePropertyType.TEXT) {
|
||||
if (
|
||||
fromType === BasePropertyType.SELECT ||
|
||||
fromType === BasePropertyType.STATUS
|
||||
) {
|
||||
const name = resolveChoiceName(ctx.fromTypeOptions, value);
|
||||
return { converted: true, value: name ?? '' };
|
||||
}
|
||||
if (fromType === BasePropertyType.MULTI_SELECT && Array.isArray(value)) {
|
||||
const parts = value
|
||||
.map((v) => resolveChoiceName(ctx.fromTypeOptions, v))
|
||||
.filter((v): v is string => typeof v === 'string' && v.length > 0);
|
||||
return { converted: true, value: parts.join(', ') };
|
||||
}
|
||||
if (fromType === BasePropertyType.PERSON && ctx.userNames) {
|
||||
const ids = Array.isArray(value) ? value : [value];
|
||||
const parts = ids
|
||||
.map((v) => ctx.userNames!.get(String(v)))
|
||||
.filter((v): v is string => typeof v === 'string' && v.length > 0);
|
||||
return { converted: true, value: parts.join(', ') };
|
||||
}
|
||||
if (fromType === BasePropertyType.FILE && Array.isArray(value)) {
|
||||
const parts = value
|
||||
.map((f: any) => {
|
||||
if (f && typeof f === 'object') {
|
||||
if (typeof f.fileName === 'string') return f.fileName;
|
||||
if (typeof f.id === 'string' && ctx.attachmentNames) {
|
||||
return ctx.attachmentNames.get(f.id);
|
||||
}
|
||||
}
|
||||
if (typeof f === 'string' && ctx.attachmentNames) {
|
||||
return ctx.attachmentNames.get(f);
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
.filter((v): v is string => typeof v === 'string' && v.length > 0);
|
||||
return { converted: true, value: parts.join(', ') };
|
||||
}
|
||||
}
|
||||
|
||||
const targetSchema = cellValueSchemaMap[toType];
|
||||
if (!targetSchema) {
|
||||
return { converted: false, value: null };
|
||||
}
|
||||
|
||||
const directResult = targetSchema.safeParse(value);
|
||||
if (directResult.success) {
|
||||
return { converted: true, value: directResult.data };
|
||||
}
|
||||
|
||||
if (toType === BasePropertyType.TEXT) {
|
||||
return { converted: true, value: String(value) };
|
||||
}
|
||||
|
||||
if (toType === BasePropertyType.NUMBER && typeof value === 'string') {
|
||||
const num = Number(value);
|
||||
if (!isNaN(num)) {
|
||||
return { converted: true, value: num };
|
||||
}
|
||||
}
|
||||
|
||||
if (toType === BasePropertyType.CHECKBOX) {
|
||||
if (typeof value === 'string') {
|
||||
const lower = value.toLowerCase();
|
||||
if (lower === 'true' || lower === '1' || lower === 'yes') {
|
||||
return { converted: true, value: true };
|
||||
}
|
||||
if (lower === 'false' || lower === '0' || lower === 'no' || lower === '') {
|
||||
return { converted: true, value: false };
|
||||
}
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return { converted: true, value: value !== 0 };
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
toType === BasePropertyType.MULTI_SELECT &&
|
||||
fromType === BasePropertyType.SELECT &&
|
||||
typeof value === 'string'
|
||||
) {
|
||||
return { converted: true, value: [value] };
|
||||
}
|
||||
|
||||
if (
|
||||
toType === BasePropertyType.SELECT &&
|
||||
fromType === BasePropertyType.MULTI_SELECT &&
|
||||
Array.isArray(value) &&
|
||||
value.length > 0
|
||||
) {
|
||||
return { converted: true, value: value[0] };
|
||||
}
|
||||
|
||||
return { converted: false, value: null };
|
||||
}
|
||||
|
||||
export const viewSortSchema = z.object({
|
||||
propertyId: z.uuid(),
|
||||
direction: z.enum(['asc', 'desc']),
|
||||
});
|
||||
|
||||
/*
|
||||
* View-stored filter shape matches the engine's predicate tree (see
|
||||
* `core/base/engine/schema.zod.ts`). No legacy flat-array / operator-name
|
||||
* variants are accepted — stored view configs use `op` (eq / neq / gt /
|
||||
* lt / contains / ncontains / ...) and nested and/or groups.
|
||||
*/
|
||||
const viewFilterConditionSchema = z.object({
|
||||
propertyId: z.uuid(),
|
||||
op: z.enum([
|
||||
'eq',
|
||||
'neq',
|
||||
'gt',
|
||||
'gte',
|
||||
'lt',
|
||||
'lte',
|
||||
'contains',
|
||||
'ncontains',
|
||||
'startsWith',
|
||||
'endsWith',
|
||||
'isEmpty',
|
||||
'isNotEmpty',
|
||||
'before',
|
||||
'after',
|
||||
'onOrBefore',
|
||||
'onOrAfter',
|
||||
'any',
|
||||
'none',
|
||||
'all',
|
||||
]),
|
||||
value: z.unknown().optional(),
|
||||
});
|
||||
|
||||
type ViewFilterCondition = z.infer<typeof viewFilterConditionSchema>;
|
||||
type ViewFilterGroup = {
|
||||
op: 'and' | 'or';
|
||||
children: Array<ViewFilterCondition | ViewFilterGroup>;
|
||||
};
|
||||
|
||||
const viewFilterNodeSchema: z.ZodType<ViewFilterCondition | ViewFilterGroup> =
|
||||
z.lazy(() => z.union([viewFilterConditionSchema, viewFilterGroupSchema]));
|
||||
|
||||
const viewFilterGroupSchema: z.ZodType<ViewFilterGroup> = z.lazy(() =>
|
||||
z.object({
|
||||
op: z.enum(['and', 'or']),
|
||||
children: z.array(viewFilterNodeSchema),
|
||||
}),
|
||||
);
|
||||
|
||||
export const viewConfigSchema = z
|
||||
.object({
|
||||
sorts: z.array(viewSortSchema).optional(),
|
||||
filter: viewFilterGroupSchema.optional(),
|
||||
visiblePropertyIds: z.array(z.uuid()).optional(),
|
||||
hiddenPropertyIds: z.array(z.uuid()).optional(),
|
||||
propertyWidths: z.record(z.string(), z.number().positive()).optional(),
|
||||
propertyOrder: z.array(z.uuid()).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type ViewConfig = z.infer<typeof viewConfigSchema>;
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { BasePropertyService } from '../services/base-property.service';
|
||||
import { BaseRepo } from '@docmost/db/repos/base/base.repo';
|
||||
import { CreatePropertyDto } from '../dto/create-property.dto';
|
||||
import {
|
||||
UpdatePropertyDto,
|
||||
DeletePropertyDto,
|
||||
ReorderPropertyDto,
|
||||
} from '../dto/update-property.dto';
|
||||
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
|
||||
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from '../../casl/interfaces/space-ability.type';
|
||||
import SpaceAbilityFactory from '../../casl/abilities/space-ability.factory';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('bases/properties')
|
||||
export class BasePropertyController {
|
||||
constructor(
|
||||
private readonly basePropertyService: BasePropertyService,
|
||||
private readonly baseRepo: BaseRepo,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('create')
|
||||
async create(
|
||||
@Body() dto: CreatePropertyDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const base = await this.baseRepo.findById(dto.baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.basePropertyService.create(workspace.id, dto, user.id);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('update')
|
||||
async update(
|
||||
@Body() dto: UpdatePropertyDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const base = await this.baseRepo.findById(dto.baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.basePropertyService.update(dto, workspace.id, user.id);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('delete')
|
||||
async delete(
|
||||
@Body() dto: DeletePropertyDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const base = await this.baseRepo.findById(dto.baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
await this.basePropertyService.delete(dto, workspace.id, user.id);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('reorder')
|
||||
async reorder(
|
||||
@Body() dto: ReorderPropertyDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const base = await this.baseRepo.findById(dto.baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
await this.basePropertyService.reorder(dto, workspace.id, user.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { BaseRowService } from '../services/base-row.service';
|
||||
import { BaseRepo } from '@docmost/db/repos/base/base.repo';
|
||||
import { CreateRowDto } from '../dto/create-row.dto';
|
||||
import {
|
||||
UpdateRowDto,
|
||||
DeleteRowDto,
|
||||
DeleteRowsDto,
|
||||
RowIdDto,
|
||||
ListRowsDto,
|
||||
ReorderRowDto,
|
||||
} from '../dto/update-row.dto';
|
||||
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
|
||||
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from '../../casl/interfaces/space-ability.type';
|
||||
import SpaceAbilityFactory from '../../casl/abilities/space-ability.factory';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('bases/rows')
|
||||
export class BaseRowController {
|
||||
constructor(
|
||||
private readonly baseRowService: BaseRowService,
|
||||
private readonly baseRepo: BaseRepo,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('create')
|
||||
async create(
|
||||
@Body() dto: CreateRowDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const base = await this.baseRepo.findById(dto.baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.baseRowService.create(user.id, workspace.id, dto);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('info')
|
||||
async getRow(
|
||||
@Body() dto: RowIdDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const base = await this.baseRepo.findById(dto.baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.baseRowService.getRowInfo(dto.rowId, dto.baseId, workspace.id);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('update')
|
||||
async update(
|
||||
@Body() dto: UpdateRowDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const base = await this.baseRepo.findById(dto.baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.baseRowService.update(dto, workspace.id, user.id);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('delete')
|
||||
async delete(
|
||||
@Body() dto: DeleteRowDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const base = await this.baseRepo.findById(dto.baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
await this.baseRowService.delete(dto, workspace.id, user.id);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('delete-many')
|
||||
async deleteMany(
|
||||
@Body() dto: DeleteRowsDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const base = await this.baseRepo.findById(dto.baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
await this.baseRowService.deleteMany(dto, workspace.id, user.id);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post()
|
||||
async list(
|
||||
@Body() dto: ListRowsDto,
|
||||
@Body() pagination: PaginationOptions,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const base = await this.baseRepo.findById(dto.baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.baseRowService.list(dto, pagination, workspace.id);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('reorder')
|
||||
async reorder(
|
||||
@Body() dto: ReorderRowDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const base = await this.baseRepo.findById(dto.baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
await this.baseRowService.reorder(dto, workspace.id, user.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { BaseViewService } from '../services/base-view.service';
|
||||
import { BaseRepo } from '@docmost/db/repos/base/base.repo';
|
||||
import { CreateViewDto } from '../dto/create-view.dto';
|
||||
import { UpdateViewDto, DeleteViewDto } from '../dto/update-view.dto';
|
||||
import { BaseIdDto } from '../dto/base.dto';
|
||||
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
|
||||
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from '../../casl/interfaces/space-ability.type';
|
||||
import SpaceAbilityFactory from '../../casl/abilities/space-ability.factory';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('bases/views')
|
||||
export class BaseViewController {
|
||||
constructor(
|
||||
private readonly baseViewService: BaseViewService,
|
||||
private readonly baseRepo: BaseRepo,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('create')
|
||||
async create(
|
||||
@Body() dto: CreateViewDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const base = await this.baseRepo.findById(dto.baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.baseViewService.create(user.id, workspace.id, dto);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('update')
|
||||
async update(
|
||||
@Body() dto: UpdateViewDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const base = await this.baseRepo.findById(dto.baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.baseViewService.update(dto, workspace.id, user.id);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('delete')
|
||||
async delete(
|
||||
@Body() dto: DeleteViewDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const base = await this.baseRepo.findById(dto.baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
await this.baseViewService.delete(dto, workspace.id, user.id);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post()
|
||||
async list(
|
||||
@Body() dto: BaseIdDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const base = await this.baseRepo.findById(dto.baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.baseViewService.listByBaseId(dto.baseId, workspace.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Post,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { FastifyReply } from 'fastify';
|
||||
import { BaseService } from '../services/base.service';
|
||||
import { BaseCsvExportService } from '../services/base-csv-export.service';
|
||||
import { BaseRepo } from '@docmost/db/repos/base/base.repo';
|
||||
import { CreateBaseDto } from '../dto/create-base.dto';
|
||||
import { UpdateBaseDto } from '../dto/update-base.dto';
|
||||
import { BaseIdDto } from '../dto/base.dto';
|
||||
import { ExportBaseCsvDto } from '../dto/export-base.dto';
|
||||
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
|
||||
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from '../../casl/interfaces/space-ability.type';
|
||||
import SpaceAbilityFactory from '../../casl/abilities/space-ability.factory';
|
||||
import { SpaceIdDto } from '../../space/dto/space-id.dto';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('bases')
|
||||
export class BaseController {
|
||||
constructor(
|
||||
private readonly baseService: BaseService,
|
||||
private readonly baseCsvExportService: BaseCsvExportService,
|
||||
private readonly baseRepo: BaseRepo,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('create')
|
||||
async create(
|
||||
@Body() dto: CreateBaseDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const ability = await this.spaceAbility.createForUser(user, dto.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.baseService.create(user.id, workspace.id, dto);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('info')
|
||||
async getBase(@Body() dto: BaseIdDto, @AuthUser() user: User) {
|
||||
const base = await this.baseService.getBaseInfo(dto.baseId);
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('update')
|
||||
async update(@Body() dto: UpdateBaseDto, @AuthUser() user: User) {
|
||||
const base = await this.baseRepo.findById(dto.baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.baseService.update(dto);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('delete')
|
||||
async delete(@Body() dto: BaseIdDto, @AuthUser() user: User) {
|
||||
const base = await this.baseRepo.findById(dto.baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
await this.baseService.delete(dto.baseId);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post()
|
||||
async list(
|
||||
@Body() dto: SpaceIdDto,
|
||||
@Body() pagination: PaginationOptions,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
const ability = await this.spaceAbility.createForUser(user, dto.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.baseService.listBySpaceId(dto.spaceId, pagination);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('export-csv')
|
||||
async exportCsv(
|
||||
@Body() dto: ExportBaseCsvDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Res() res: FastifyReply,
|
||||
) {
|
||||
const base = await this.baseRepo.findById(dto.baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
await this.baseCsvExportService.streamBaseAsCsv(
|
||||
dto.baseId,
|
||||
workspace.id,
|
||||
res,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class BaseIdDto {
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateBaseDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
icon?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
pageId?: string;
|
||||
|
||||
@IsUUID()
|
||||
spaceId: string;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
import { BASE_PROPERTY_TYPES } from '../base.schemas';
|
||||
|
||||
export class CreatePropertyDto {
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@IsIn(BASE_PROPERTY_TYPES)
|
||||
type: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
typeOptions?: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { IsObject, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateRowDto {
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
cells?: Record<string, unknown>;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
afterRowId?: string;
|
||||
|
||||
// Echoed back in the socket event so the originating client can skip
|
||||
// replaying its own write.
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
requestId?: string;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateViewDto {
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['table', 'kanban', 'calendar'])
|
||||
type?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class ExportBaseCsvDto {
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class UpdateBaseDto {
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
icon?: string;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
IsEmpty,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdatePropertyDto {
|
||||
@IsUUID()
|
||||
propertyId: string;
|
||||
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name?: string;
|
||||
|
||||
/*
|
||||
* Type changes are intentionally not exposed via the API in v1. The
|
||||
* conversion engine in apps/server/src/core/base/engine/ and the
|
||||
* worker in tasks/base-type-conversion.task.ts remain intact for
|
||||
* a future v2 re-wire. Requests including `type` are rejected here
|
||||
* so the service's type-change branches stay unreachable.
|
||||
*/
|
||||
@IsEmpty()
|
||||
type?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
typeOptions?: Record<string, unknown>;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
export class DeletePropertyDto {
|
||||
@IsUUID()
|
||||
propertyId: string;
|
||||
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
export class ReorderPropertyDto {
|
||||
@IsUUID()
|
||||
propertyId: string;
|
||||
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
position: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
requestId?: string;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
IsArray,
|
||||
ArrayMinSize,
|
||||
ArrayMaxSize,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
// `filter` / `search` shapes are validated by the engine's Zod schemas
|
||||
// at the service boundary (`core/base/engine/schema.zod.ts`).
|
||||
|
||||
export class UpdateRowDto {
|
||||
@IsUUID()
|
||||
rowId: string;
|
||||
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
|
||||
@IsObject()
|
||||
cells: Record<string, unknown>;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
export class DeleteRowDto {
|
||||
@IsUUID()
|
||||
rowId: string;
|
||||
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
export class RowIdDto {
|
||||
@IsUUID()
|
||||
rowId: string;
|
||||
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
}
|
||||
|
||||
class SortDto {
|
||||
@IsUUID()
|
||||
propertyId: string;
|
||||
|
||||
@IsIn(['asc', 'desc'])
|
||||
direction: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export class ListRowsDto {
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
viewId?: string;
|
||||
|
||||
// Compound filter tree. Shape validated by the engine's Zod schema at
|
||||
// the service boundary.
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
filter?: unknown;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SortDto)
|
||||
sorts?: SortDto[];
|
||||
|
||||
// `{ query, mode? }` — Zod-validated at the service boundary.
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
search?: unknown;
|
||||
}
|
||||
|
||||
export class ReorderRowDto {
|
||||
@IsUUID()
|
||||
rowId: string;
|
||||
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
position: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
export class DeleteRowsDto {
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(500)
|
||||
@IsUUID('all', { each: true })
|
||||
rowIds: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
requestId?: string;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateViewDto {
|
||||
@IsUUID()
|
||||
viewId: string;
|
||||
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['table', 'kanban', 'calendar'])
|
||||
type?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class DeleteViewDto {
|
||||
@IsUUID()
|
||||
viewId: string;
|
||||
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { SortBuild, TailKey } from './sort';
|
||||
|
||||
type ValueType = 'numeric' | 'date' | 'bool' | 'text';
|
||||
|
||||
// Hard cap on decoded cursor size so a tampered cursor can't force a large
|
||||
// JSON parse. Real cursors are <1KB (a handful of field values).
|
||||
const MAX_CURSOR_DECODED_BYTES = 4096;
|
||||
|
||||
/*
|
||||
* Null-safe cursor encoder. The previous encoder used a literal string
|
||||
* sentinel `__null__` for NULLs, which could collide with real cell
|
||||
* values. This encoder never sees NULL because sort expressions are
|
||||
* sentinel-wrapped (see sort.ts). It also represents ±Infinity
|
||||
* explicitly so JSON round-tripping is lossless.
|
||||
*/
|
||||
|
||||
export function makeCursor(sorts: SortBuild[], tailKeys: TailKey[]) {
|
||||
const types = new Map<string, ValueType>();
|
||||
for (const s of sorts) types.set(s.key, s.valueType);
|
||||
for (const k of tailKeys) types.set(k, 'text');
|
||||
|
||||
return {
|
||||
encodeCursor(values: Array<[string, unknown]>): string {
|
||||
const payload: Record<string, string> = {};
|
||||
for (const [k, v] of values) {
|
||||
payload[k] = encodeValue(v, types.get(k) ?? 'text');
|
||||
}
|
||||
return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
||||
},
|
||||
|
||||
decodeCursor(
|
||||
cursor: string,
|
||||
fieldNames: string[],
|
||||
): Record<string, string> {
|
||||
let parsed: Record<string, string>;
|
||||
try {
|
||||
parsed = JSON.parse(
|
||||
Buffer.from(cursor, 'base64url').toString('utf8'),
|
||||
);
|
||||
} catch {
|
||||
throw new BadRequestException('Invalid cursor');
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
throw new BadRequestException('Invalid cursor payload');
|
||||
}
|
||||
const out: Record<string, string> = {};
|
||||
for (const name of fieldNames) {
|
||||
if (!(name in parsed)) {
|
||||
throw new BadRequestException(`Cursor missing field: ${name}`);
|
||||
}
|
||||
out[name] = parsed[name];
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
parseCursor(decoded: Record<string, string>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, raw] of Object.entries(decoded)) {
|
||||
out[k] = decodeValue(raw, types.get(k) ?? 'text');
|
||||
}
|
||||
return out;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function encodeValue(value: unknown, type: ValueType): string {
|
||||
if (type === 'numeric') {
|
||||
if (value === null || value === undefined) return '';
|
||||
const n = typeof value === 'number' ? value : parseFloat(String(value));
|
||||
if (n === Number.POSITIVE_INFINITY || String(value) === 'Infinity') {
|
||||
return 'inf';
|
||||
}
|
||||
if (n === Number.NEGATIVE_INFINITY || String(value) === '-Infinity') {
|
||||
return '-inf';
|
||||
}
|
||||
if (Number.isNaN(n)) return '';
|
||||
return String(n);
|
||||
}
|
||||
if (type === 'date') {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
const s = String(value);
|
||||
if (s === 'infinity') return 'inf';
|
||||
if (s === '-infinity') return '-inf';
|
||||
return s;
|
||||
}
|
||||
if (type === 'bool') {
|
||||
return value ? '1' : '0';
|
||||
}
|
||||
return value == null ? '' : String(value);
|
||||
}
|
||||
|
||||
function decodeValue(raw: string, type: ValueType): unknown {
|
||||
if (type === 'numeric') {
|
||||
if (raw === 'inf') return Number.POSITIVE_INFINITY;
|
||||
if (raw === '-inf') return Number.NEGATIVE_INFINITY;
|
||||
if (raw === '') return null;
|
||||
return parseFloat(raw);
|
||||
}
|
||||
if (type === 'date') {
|
||||
if (raw === 'inf') return 'infinity';
|
||||
if (raw === '-inf') return '-infinity';
|
||||
if (raw === '') return null;
|
||||
return raw;
|
||||
}
|
||||
if (type === 'bool') {
|
||||
return raw === '1';
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { SelectQueryBuilder } from 'kysely';
|
||||
import { DB } from '@docmost/db/types/db';
|
||||
import { BaseRow } from '@docmost/db/types/entity.types';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import {
|
||||
CursorPaginationResult,
|
||||
executeWithCursorPagination,
|
||||
} from '@docmost/db/pagination/cursor-pagination';
|
||||
import { FilterNode, SearchSpec, SortSpec } from './schema.zod';
|
||||
import { buildWhere, PropertySchema } from './predicate';
|
||||
import { buildSorts, CURSOR_TAIL_KEYS, SortBuild } from './sort';
|
||||
import { buildSearch } from './search';
|
||||
import { makeCursor } from './cursor';
|
||||
|
||||
export type EngineListOpts = {
|
||||
filter?: FilterNode;
|
||||
sorts?: SortSpec[];
|
||||
search?: SearchSpec;
|
||||
schema: PropertySchema;
|
||||
pagination: PaginationOptions;
|
||||
};
|
||||
|
||||
/*
|
||||
* Top-level orchestrator. Callers (repos, services) provide a base
|
||||
* Kysely query already scoped to the target base + workspace + alive
|
||||
* rows; this adds search/filter/sort clauses and runs cursor pagination.
|
||||
*/
|
||||
export async function runListQuery(
|
||||
base: SelectQueryBuilder<DB, 'baseRows', any>,
|
||||
opts: EngineListOpts,
|
||||
): Promise<CursorPaginationResult<BaseRow>> {
|
||||
let qb = base;
|
||||
|
||||
if (opts.search) {
|
||||
const spec = opts.search;
|
||||
qb = qb.where((eb) => buildSearch(eb, spec));
|
||||
}
|
||||
|
||||
if (opts.filter) {
|
||||
const filter = opts.filter;
|
||||
qb = qb.where((eb) => buildWhere(eb, filter, opts.schema));
|
||||
}
|
||||
|
||||
const sortBuilds: SortBuild[] =
|
||||
opts.sorts && opts.sorts.length > 0
|
||||
? buildSorts(opts.sorts, opts.schema)
|
||||
: [];
|
||||
|
||||
for (const sb of sortBuilds) {
|
||||
qb = qb.select(sb.expression.as(sb.key)) as SelectQueryBuilder<
|
||||
DB,
|
||||
'baseRows',
|
||||
any
|
||||
>;
|
||||
}
|
||||
|
||||
const cursor = makeCursor(sortBuilds, CURSOR_TAIL_KEYS);
|
||||
|
||||
const fields = [
|
||||
...sortBuilds.map((sb) => ({
|
||||
expression: sb.expression,
|
||||
direction: sb.direction,
|
||||
key: sb.key,
|
||||
})),
|
||||
{
|
||||
expression: 'position' as const,
|
||||
direction: 'asc' as const,
|
||||
key: 'position' as const,
|
||||
},
|
||||
{
|
||||
expression: 'id' as const,
|
||||
direction: 'asc' as const,
|
||||
key: 'id' as const,
|
||||
},
|
||||
];
|
||||
|
||||
return executeWithCursorPagination(qb as any, {
|
||||
perPage: opts.pagination.limit,
|
||||
cursor: opts.pagination.cursor,
|
||||
beforeCursor: opts.pagination.beforeCursor,
|
||||
fields: fields as any,
|
||||
encodeCursor: cursor.encodeCursor as any,
|
||||
decodeCursor: cursor.decodeCursor as any,
|
||||
parseCursor: cursor.parseCursor as any,
|
||||
}) as unknown as Promise<CursorPaginationResult<BaseRow>>;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { sql, RawBuilder } from 'kysely';
|
||||
|
||||
/*
|
||||
* Parameterised extractors wrapping the SQL helper functions installed
|
||||
* by the bases-hardening migration. PropertyId always binds as a
|
||||
* parameter — never string-interpolated. These replace every
|
||||
* `sql.raw('cells->>...')` site in the old repo.
|
||||
*/
|
||||
|
||||
export function textCell(propertyId: string): RawBuilder<string> {
|
||||
return sql<string>`base_cell_text(cells, ${propertyId}::uuid)`;
|
||||
}
|
||||
|
||||
export function numericCell(propertyId: string): RawBuilder<number> {
|
||||
return sql<number>`base_cell_numeric(cells, ${propertyId}::uuid)`;
|
||||
}
|
||||
|
||||
export function dateCell(propertyId: string): RawBuilder<Date> {
|
||||
return sql<Date>`base_cell_timestamptz(cells, ${propertyId}::uuid)`;
|
||||
}
|
||||
|
||||
export function boolCell(propertyId: string): RawBuilder<boolean> {
|
||||
return sql<boolean>`base_cell_bool(cells, ${propertyId}::uuid)`;
|
||||
}
|
||||
|
||||
export function arrayCell(propertyId: string): RawBuilder<unknown> {
|
||||
return sql<unknown>`base_cell_array(cells, ${propertyId}::uuid)`;
|
||||
}
|
||||
|
||||
export function escapeIlike(value: string): string {
|
||||
return value.replace(/[%_\\]/g, '\\$&');
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export {
|
||||
MAX_FILTER_DEPTH,
|
||||
MAX_FILTER_NODES,
|
||||
MAX_SORTS,
|
||||
conditionSchema,
|
||||
filterGroupSchema,
|
||||
filterNodeSchema,
|
||||
listQuerySchema,
|
||||
operatorSchema,
|
||||
searchSchema,
|
||||
sortSpecSchema,
|
||||
sortsSchema,
|
||||
validateFilterTree,
|
||||
} from './schema.zod';
|
||||
export type {
|
||||
Condition,
|
||||
FilterGroup,
|
||||
FilterNode,
|
||||
ListQuery,
|
||||
Operator,
|
||||
SearchSpec,
|
||||
SortSpec,
|
||||
} from './schema.zod';
|
||||
|
||||
export {
|
||||
PropertyKind,
|
||||
SYSTEM_COLUMN,
|
||||
isSystemType,
|
||||
propertyKind,
|
||||
} from './kinds';
|
||||
export type { PropertyKindValue } from './kinds';
|
||||
|
||||
export { buildWhere } from './predicate';
|
||||
export type { PropertySchema } from './predicate';
|
||||
|
||||
export { buildSorts, CURSOR_TAIL_KEYS } from './sort';
|
||||
export type { SortBuild, TailKey } from './sort';
|
||||
|
||||
export { makeCursor } from './cursor';
|
||||
|
||||
export { buildSearch } from './search';
|
||||
|
||||
export { runListQuery } from './engine';
|
||||
export type { EngineListOpts } from './engine';
|
||||
@@ -0,0 +1,57 @@
|
||||
import { BasePropertyType } from '../base.schemas';
|
||||
|
||||
export const PropertyKind = {
|
||||
TEXT: 'text',
|
||||
NUMERIC: 'numeric',
|
||||
DATE: 'date',
|
||||
BOOL: 'bool',
|
||||
SELECT: 'select',
|
||||
MULTI: 'multi',
|
||||
PERSON: 'person',
|
||||
FILE: 'file',
|
||||
SYS_USER: 'sys_user',
|
||||
} as const;
|
||||
|
||||
export type PropertyKindValue = (typeof PropertyKind)[keyof typeof PropertyKind];
|
||||
|
||||
export function propertyKind(type: string): PropertyKindValue | null {
|
||||
switch (type) {
|
||||
case BasePropertyType.TEXT:
|
||||
case BasePropertyType.URL:
|
||||
case BasePropertyType.EMAIL:
|
||||
return PropertyKind.TEXT;
|
||||
case BasePropertyType.NUMBER:
|
||||
return PropertyKind.NUMERIC;
|
||||
case BasePropertyType.DATE:
|
||||
case BasePropertyType.CREATED_AT:
|
||||
case BasePropertyType.LAST_EDITED_AT:
|
||||
return PropertyKind.DATE;
|
||||
case BasePropertyType.CHECKBOX:
|
||||
return PropertyKind.BOOL;
|
||||
case BasePropertyType.SELECT:
|
||||
case BasePropertyType.STATUS:
|
||||
return PropertyKind.SELECT;
|
||||
case BasePropertyType.MULTI_SELECT:
|
||||
return PropertyKind.MULTI;
|
||||
case BasePropertyType.PERSON:
|
||||
return PropertyKind.PERSON;
|
||||
case BasePropertyType.FILE:
|
||||
return PropertyKind.FILE;
|
||||
case BasePropertyType.LAST_EDITED_BY:
|
||||
return PropertyKind.SYS_USER;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// System property type → camelCase column name on `base_rows`.
|
||||
// Kysely camel-case plugin maps to snake_case in SQL.
|
||||
export const SYSTEM_COLUMN: Record<string, 'createdAt' | 'updatedAt' | 'lastUpdatedById'> = {
|
||||
[BasePropertyType.CREATED_AT]: 'createdAt',
|
||||
[BasePropertyType.LAST_EDITED_AT]: 'updatedAt',
|
||||
[BasePropertyType.LAST_EDITED_BY]: 'lastUpdatedById',
|
||||
};
|
||||
|
||||
export function isSystemType(type: string): boolean {
|
||||
return type in SYSTEM_COLUMN;
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
import { Expression, ExpressionBuilder, sql, SqlBool } from 'kysely';
|
||||
import { DB } from '@docmost/db/types/db';
|
||||
import { BaseProperty } from '@docmost/db/types/entity.types';
|
||||
import { Condition, FilterNode } from './schema.zod';
|
||||
import { PropertyKind, propertyKind, SYSTEM_COLUMN } from './kinds';
|
||||
import {
|
||||
arrayCell,
|
||||
boolCell,
|
||||
dateCell,
|
||||
escapeIlike,
|
||||
numericCell,
|
||||
textCell,
|
||||
} from './extractors';
|
||||
|
||||
export type PropertySchema = Map<
|
||||
string,
|
||||
Pick<BaseProperty, 'id' | 'type' | 'typeOptions'>
|
||||
>;
|
||||
|
||||
type Eb = ExpressionBuilder<DB, 'baseRows'>;
|
||||
|
||||
const TRUE = sql<SqlBool>`TRUE`;
|
||||
const FALSE = sql<SqlBool>`FALSE`;
|
||||
|
||||
export function buildWhere(
|
||||
eb: Eb,
|
||||
node: FilterNode,
|
||||
schema: PropertySchema,
|
||||
): Expression<SqlBool> {
|
||||
if ('children' in node) {
|
||||
if (node.children.length === 0) return TRUE;
|
||||
const built = node.children.map((c) => buildWhere(eb, c, schema));
|
||||
return node.op === 'and' ? eb.and(built) : eb.or(built);
|
||||
}
|
||||
return buildCondition(eb, node, schema);
|
||||
}
|
||||
|
||||
function buildCondition(
|
||||
eb: Eb,
|
||||
cond: Condition,
|
||||
schema: PropertySchema,
|
||||
): Expression<SqlBool> {
|
||||
const prop = schema.get(cond.propertyId);
|
||||
if (!prop) return FALSE;
|
||||
|
||||
const sysCol = SYSTEM_COLUMN[prop.type];
|
||||
if (sysCol) return systemCondition(eb, sysCol, prop.type, cond);
|
||||
|
||||
const kind = propertyKind(prop.type);
|
||||
if (!kind) return FALSE;
|
||||
|
||||
switch (kind) {
|
||||
case PropertyKind.TEXT:
|
||||
return textCondition(eb, cond);
|
||||
case PropertyKind.NUMERIC:
|
||||
return numericCondition(eb, cond);
|
||||
case PropertyKind.DATE:
|
||||
return dateCondition(eb, cond);
|
||||
case PropertyKind.BOOL:
|
||||
return boolCondition(eb, cond);
|
||||
case PropertyKind.SELECT:
|
||||
return selectCondition(eb, cond);
|
||||
case PropertyKind.MULTI:
|
||||
return multiCondition(eb, cond);
|
||||
case PropertyKind.PERSON:
|
||||
return personCondition(eb, cond, prop);
|
||||
case PropertyKind.FILE:
|
||||
return arrayOfIdsCondition(eb, cond);
|
||||
default:
|
||||
return FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
// --- per-kind handlers ------------------------------------------------
|
||||
|
||||
function textCondition(eb: Eb, cond: Condition): Expression<SqlBool> {
|
||||
const expr = textCell(cond.propertyId);
|
||||
const val = cond.value;
|
||||
switch (cond.op) {
|
||||
case 'isEmpty':
|
||||
return eb.or([
|
||||
eb(expr as any, 'is', null),
|
||||
eb(expr as any, '=', ''),
|
||||
]);
|
||||
case 'isNotEmpty':
|
||||
return eb.and([
|
||||
eb(expr as any, 'is not', null),
|
||||
eb(expr as any, '!=', ''),
|
||||
]);
|
||||
case 'eq':
|
||||
return val == null ? FALSE : eb(expr as any, '=', String(val));
|
||||
case 'neq':
|
||||
return val == null
|
||||
? FALSE
|
||||
: eb.or([
|
||||
eb(expr as any, 'is', null),
|
||||
eb(expr as any, '!=', String(val)),
|
||||
]);
|
||||
case 'contains':
|
||||
return val == null
|
||||
? FALSE
|
||||
: eb(expr as any, 'ilike', `%${escapeIlike(String(val))}%`);
|
||||
case 'ncontains':
|
||||
return val == null
|
||||
? FALSE
|
||||
: eb.or([
|
||||
eb(expr as any, 'is', null),
|
||||
eb(expr as any, 'not ilike', `%${escapeIlike(String(val))}%`),
|
||||
]);
|
||||
case 'startsWith':
|
||||
return val == null
|
||||
? FALSE
|
||||
: eb(expr as any, 'ilike', `${escapeIlike(String(val))}%`);
|
||||
case 'endsWith':
|
||||
return val == null
|
||||
? FALSE
|
||||
: eb(expr as any, 'ilike', `%${escapeIlike(String(val))}`);
|
||||
default:
|
||||
return FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
function numericCondition(eb: Eb, cond: Condition): Expression<SqlBool> {
|
||||
const expr = numericCell(cond.propertyId);
|
||||
const raw = cond.value;
|
||||
const num = raw == null ? null : Number(raw);
|
||||
const bad = num == null || Number.isNaN(num);
|
||||
switch (cond.op) {
|
||||
case 'isEmpty':
|
||||
return eb(expr as any, 'is', null);
|
||||
case 'isNotEmpty':
|
||||
return eb(expr as any, 'is not', null);
|
||||
case 'eq':
|
||||
return bad ? FALSE : eb(expr as any, '=', num);
|
||||
case 'neq':
|
||||
return bad
|
||||
? FALSE
|
||||
: eb.or([eb(expr as any, 'is', null), eb(expr as any, '!=', num)]);
|
||||
case 'gt':
|
||||
return bad ? FALSE : eb(expr as any, '>', num);
|
||||
case 'gte':
|
||||
return bad ? FALSE : eb(expr as any, '>=', num);
|
||||
case 'lt':
|
||||
return bad ? FALSE : eb(expr as any, '<', num);
|
||||
case 'lte':
|
||||
return bad ? FALSE : eb(expr as any, '<=', num);
|
||||
default:
|
||||
return FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
function dateCondition(eb: Eb, cond: Condition): Expression<SqlBool> {
|
||||
const expr = dateCell(cond.propertyId);
|
||||
const raw = cond.value;
|
||||
const bad = raw == null || raw === '';
|
||||
switch (cond.op) {
|
||||
case 'isEmpty':
|
||||
return eb(expr as any, 'is', null);
|
||||
case 'isNotEmpty':
|
||||
return eb(expr as any, 'is not', null);
|
||||
case 'eq':
|
||||
return bad ? FALSE : eb(expr as any, '=', String(raw));
|
||||
case 'neq':
|
||||
return bad
|
||||
? FALSE
|
||||
: eb.or([
|
||||
eb(expr as any, 'is', null),
|
||||
eb(expr as any, '!=', String(raw)),
|
||||
]);
|
||||
case 'before':
|
||||
return bad ? FALSE : eb(expr as any, '<', String(raw));
|
||||
case 'after':
|
||||
return bad ? FALSE : eb(expr as any, '>', String(raw));
|
||||
case 'onOrBefore':
|
||||
return bad ? FALSE : eb(expr as any, '<=', String(raw));
|
||||
case 'onOrAfter':
|
||||
return bad ? FALSE : eb(expr as any, '>=', String(raw));
|
||||
default:
|
||||
return FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
function boolCondition(eb: Eb, cond: Condition): Expression<SqlBool> {
|
||||
const expr = boolCell(cond.propertyId);
|
||||
switch (cond.op) {
|
||||
case 'isEmpty':
|
||||
return eb(expr as any, 'is', null);
|
||||
case 'isNotEmpty':
|
||||
return eb(expr as any, 'is not', null);
|
||||
case 'eq':
|
||||
return cond.value == null
|
||||
? FALSE
|
||||
: eb(expr as any, '=', Boolean(cond.value));
|
||||
case 'neq':
|
||||
return cond.value == null
|
||||
? FALSE
|
||||
: eb.or([
|
||||
eb(expr as any, 'is', null),
|
||||
eb(expr as any, '!=', Boolean(cond.value)),
|
||||
]);
|
||||
default:
|
||||
return FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
function selectCondition(eb: Eb, cond: Condition): Expression<SqlBool> {
|
||||
// Cell stores a single option UUID as string. Use text extractor.
|
||||
const expr = textCell(cond.propertyId);
|
||||
const val = cond.value;
|
||||
switch (cond.op) {
|
||||
case 'isEmpty':
|
||||
return eb.or([
|
||||
eb(expr as any, 'is', null),
|
||||
eb(expr as any, '=', ''),
|
||||
]);
|
||||
case 'isNotEmpty':
|
||||
return eb.and([
|
||||
eb(expr as any, 'is not', null),
|
||||
eb(expr as any, '!=', ''),
|
||||
]);
|
||||
case 'eq':
|
||||
return val == null ? FALSE : eb(expr as any, '=', String(val));
|
||||
case 'neq':
|
||||
return val == null
|
||||
? FALSE
|
||||
: eb.or([
|
||||
eb(expr as any, 'is', null),
|
||||
eb(expr as any, '!=', String(val)),
|
||||
]);
|
||||
case 'any': {
|
||||
const arr = asStringArray(val);
|
||||
if (arr.length === 0) return FALSE;
|
||||
return eb(expr as any, 'in', arr);
|
||||
}
|
||||
case 'none': {
|
||||
const arr = asStringArray(val);
|
||||
if (arr.length === 0) return TRUE;
|
||||
return eb.or([
|
||||
eb(expr as any, 'is', null),
|
||||
eb(expr as any, 'not in', arr),
|
||||
]);
|
||||
}
|
||||
default:
|
||||
return FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
function multiCondition(eb: Eb, cond: Condition): Expression<SqlBool> {
|
||||
return arrayOfIdsCondition(eb, cond);
|
||||
}
|
||||
|
||||
function personCondition(
|
||||
eb: Eb,
|
||||
cond: Condition,
|
||||
prop: Pick<BaseProperty, 'id' | 'type' | 'typeOptions'>,
|
||||
): Expression<SqlBool> {
|
||||
// Person cells may be stored as a single uuid or an array of uuids depending
|
||||
// on the property's `allowMultiple` option. Normalise to array semantics via
|
||||
// `base_cell_array` when it's stored as an array, else text.
|
||||
const allowMultiple = !!(prop.typeOptions as any)?.allowMultiple;
|
||||
if (allowMultiple) return arrayOfIdsCondition(eb, cond);
|
||||
|
||||
const expr = textCell(cond.propertyId);
|
||||
const val = cond.value;
|
||||
switch (cond.op) {
|
||||
case 'isEmpty':
|
||||
return eb.or([
|
||||
eb(expr as any, 'is', null),
|
||||
eb(expr as any, '=', ''),
|
||||
]);
|
||||
case 'isNotEmpty':
|
||||
return eb.and([
|
||||
eb(expr as any, 'is not', null),
|
||||
eb(expr as any, '!=', ''),
|
||||
]);
|
||||
case 'eq':
|
||||
return val == null ? FALSE : eb(expr as any, '=', String(val));
|
||||
case 'neq':
|
||||
return val == null
|
||||
? FALSE
|
||||
: eb.or([
|
||||
eb(expr as any, 'is', null),
|
||||
eb(expr as any, '!=', String(val)),
|
||||
]);
|
||||
case 'any': {
|
||||
const arr = asStringArray(val);
|
||||
if (arr.length === 0) return FALSE;
|
||||
return eb(expr as any, 'in', arr);
|
||||
}
|
||||
default:
|
||||
return FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
function arrayOfIdsCondition(eb: Eb, cond: Condition): Expression<SqlBool> {
|
||||
const expr = arrayCell(cond.propertyId);
|
||||
const val = cond.value;
|
||||
switch (cond.op) {
|
||||
case 'isEmpty':
|
||||
return eb.or([
|
||||
eb(expr as any, 'is', null),
|
||||
sql<SqlBool>`jsonb_array_length(${expr}) = 0`,
|
||||
]);
|
||||
case 'isNotEmpty':
|
||||
return eb.and([
|
||||
eb(expr as any, 'is not', null),
|
||||
sql<SqlBool>`jsonb_array_length(${expr}) > 0`,
|
||||
]);
|
||||
case 'any': {
|
||||
const arr = asStringArray(val);
|
||||
if (arr.length === 0) return FALSE;
|
||||
return sql<SqlBool>`${expr} ?| ${arr}`;
|
||||
}
|
||||
case 'all': {
|
||||
const arr = asStringArray(val);
|
||||
if (arr.length === 0) return TRUE;
|
||||
// `::text::jsonb` because postgres.js auto-detects JSON-shaped strings
|
||||
// as jsonb and re-encodes them, producing a jsonb *string* instead of
|
||||
// an array. Without the text hop, the containment check never matches.
|
||||
return sql<SqlBool>`${expr} @> ${JSON.stringify(arr)}::text::jsonb`;
|
||||
}
|
||||
case 'none': {
|
||||
const arr = asStringArray(val);
|
||||
if (arr.length === 0) return TRUE;
|
||||
return eb.or([
|
||||
eb(expr as any, 'is', null),
|
||||
sql<SqlBool>`NOT (${expr} ?| ${arr})`,
|
||||
]);
|
||||
}
|
||||
default:
|
||||
return FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
function systemCondition(
|
||||
eb: Eb,
|
||||
column: 'createdAt' | 'updatedAt' | 'lastUpdatedById',
|
||||
propertyType: string,
|
||||
cond: Condition,
|
||||
): Expression<SqlBool> {
|
||||
const ref = eb.ref(column);
|
||||
const val = cond.value;
|
||||
|
||||
// lastEditedBy — UUID column; behaves like select (uuid equality, in, isEmpty).
|
||||
if (propertyType === 'lastEditedBy') {
|
||||
switch (cond.op) {
|
||||
case 'isEmpty':
|
||||
return eb(ref, 'is', null);
|
||||
case 'isNotEmpty':
|
||||
return eb(ref, 'is not', null);
|
||||
case 'eq':
|
||||
return val == null ? FALSE : eb(ref, '=', String(val));
|
||||
case 'neq':
|
||||
return val == null
|
||||
? FALSE
|
||||
: eb.or([eb(ref, 'is', null), eb(ref, '!=', String(val))]);
|
||||
case 'any': {
|
||||
const arr = asStringArray(val);
|
||||
if (arr.length === 0) return FALSE;
|
||||
return eb(ref, 'in', arr);
|
||||
}
|
||||
case 'none': {
|
||||
const arr = asStringArray(val);
|
||||
if (arr.length === 0) return TRUE;
|
||||
return eb.or([eb(ref, 'is', null), eb(ref, 'not in', arr)]);
|
||||
}
|
||||
default:
|
||||
return FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
// createdAt / updatedAt — timestamptz columns (NOT NULL).
|
||||
const bad = val == null || val === '';
|
||||
switch (cond.op) {
|
||||
case 'isEmpty':
|
||||
return FALSE;
|
||||
case 'isNotEmpty':
|
||||
return TRUE;
|
||||
case 'eq':
|
||||
return bad ? FALSE : eb(ref, '=', String(val));
|
||||
case 'neq':
|
||||
return bad ? FALSE : eb(ref, '!=', String(val));
|
||||
case 'before':
|
||||
return bad ? FALSE : eb(ref, '<', String(val));
|
||||
case 'after':
|
||||
return bad ? FALSE : eb(ref, '>', String(val));
|
||||
case 'onOrBefore':
|
||||
return bad ? FALSE : eb(ref, '<=', String(val));
|
||||
case 'onOrAfter':
|
||||
return bad ? FALSE : eb(ref, '>=', String(val));
|
||||
default:
|
||||
return FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
// --- utilities --------------------------------------------------------
|
||||
|
||||
function asStringArray(val: unknown): string[] {
|
||||
if (val == null) return [];
|
||||
if (Array.isArray(val)) return val.filter((v) => v != null).map(String);
|
||||
return [String(val)];
|
||||
}
|
||||
|
||||
export { TRUE as TRUE_EXPR, FALSE as FALSE_EXPR };
|
||||
@@ -0,0 +1,100 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const MAX_FILTER_DEPTH = 5;
|
||||
export const MAX_FILTER_NODES = 50;
|
||||
export const MAX_SORTS = 5;
|
||||
|
||||
const uuid = z.uuid();
|
||||
|
||||
export const operatorSchema = z.enum([
|
||||
'eq',
|
||||
'neq',
|
||||
'gt',
|
||||
'gte',
|
||||
'lt',
|
||||
'lte',
|
||||
'contains',
|
||||
'ncontains',
|
||||
'startsWith',
|
||||
'endsWith',
|
||||
'isEmpty',
|
||||
'isNotEmpty',
|
||||
'before',
|
||||
'after',
|
||||
'onOrBefore',
|
||||
'onOrAfter',
|
||||
'any',
|
||||
'none',
|
||||
'all',
|
||||
]);
|
||||
|
||||
export type Operator = z.infer<typeof operatorSchema>;
|
||||
|
||||
export const conditionSchema = z.object({
|
||||
propertyId: uuid,
|
||||
op: operatorSchema,
|
||||
value: z.unknown().optional(),
|
||||
});
|
||||
|
||||
export type Condition = z.infer<typeof conditionSchema>;
|
||||
|
||||
export type FilterNode = Condition | FilterGroup;
|
||||
export type FilterGroup = {
|
||||
op: 'and' | 'or';
|
||||
children: FilterNode[];
|
||||
};
|
||||
|
||||
// Recursive Zod schema for grouped filter trees.
|
||||
export const filterNodeSchema: z.ZodType<FilterNode> = z.lazy(() =>
|
||||
z.union([conditionSchema, filterGroupSchema]),
|
||||
);
|
||||
|
||||
export const filterGroupSchema: z.ZodType<FilterGroup> = z.lazy(() =>
|
||||
z.object({
|
||||
op: z.enum(['and', 'or']),
|
||||
children: z.array(filterNodeSchema),
|
||||
}),
|
||||
);
|
||||
|
||||
// Count nodes + max depth to prevent pathological trees from reaching SQL.
|
||||
export function validateFilterTree(node: FilterNode): void {
|
||||
let nodes = 0;
|
||||
const walk = (n: FilterNode, depth: number) => {
|
||||
if (depth > MAX_FILTER_DEPTH) {
|
||||
throw new Error(`Filter tree exceeds max depth ${MAX_FILTER_DEPTH}`);
|
||||
}
|
||||
nodes += 1;
|
||||
if (nodes > MAX_FILTER_NODES) {
|
||||
throw new Error(`Filter tree exceeds max node count ${MAX_FILTER_NODES}`);
|
||||
}
|
||||
if ('children' in n) {
|
||||
for (const c of n.children) walk(c, depth + 1);
|
||||
}
|
||||
};
|
||||
walk(node, 0);
|
||||
}
|
||||
|
||||
export const sortSpecSchema = z.object({
|
||||
propertyId: uuid,
|
||||
direction: z.enum(['asc', 'desc']),
|
||||
});
|
||||
|
||||
export type SortSpec = z.infer<typeof sortSpecSchema>;
|
||||
|
||||
export const sortsSchema = z.array(sortSpecSchema).max(MAX_SORTS);
|
||||
|
||||
export const searchSchema = z.object({
|
||||
query: z.string().min(1).max(500),
|
||||
mode: z.enum(['trgm', 'fts']).default('trgm'),
|
||||
});
|
||||
|
||||
export type SearchSpec = z.infer<typeof searchSchema>;
|
||||
|
||||
// Top-level request DTO shape. The row controller DTO composes this.
|
||||
export const listQuerySchema = z.object({
|
||||
filter: filterGroupSchema.optional(),
|
||||
sorts: sortsSchema.optional(),
|
||||
search: searchSchema.optional(),
|
||||
});
|
||||
|
||||
export type ListQuery = z.infer<typeof listQuerySchema>;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Expression, ExpressionBuilder, sql, SqlBool } from 'kysely';
|
||||
import { DB } from '@docmost/db/types/db';
|
||||
import { SearchSpec } from './schema.zod';
|
||||
|
||||
type Eb = ExpressionBuilder<DB, 'baseRows'>;
|
||||
|
||||
/*
|
||||
* `search_text` and `search_tsv` are maintained by the base_rows search
|
||||
* trigger installed in the bases-hardening migration. Both columns are
|
||||
* indexed — pg_trgm GIN for ILIKE and standard GIN for tsvector.
|
||||
*/
|
||||
|
||||
export function buildSearch(eb: Eb, spec: SearchSpec): Expression<SqlBool> {
|
||||
const q = spec.query.trim();
|
||||
if (!q) return sql<SqlBool>`TRUE`;
|
||||
|
||||
if (spec.mode === 'fts') {
|
||||
// Accent-insensitive match via f_unaccent (same helper the search
|
||||
// trigger uses when populating search_tsv / search_text).
|
||||
return sql<SqlBool>`search_tsv @@ plainto_tsquery('english', f_unaccent(${q}))`;
|
||||
}
|
||||
|
||||
// trigram ILIKE mode (default). escape %/_/\\ in user input so wildcards
|
||||
// can't be injected.
|
||||
const escaped = q.replace(/[%_\\]/g, '\\$&');
|
||||
return sql<SqlBool>`search_text ILIKE ${'%' + escaped + '%'}`;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { RawBuilder, sql } from 'kysely';
|
||||
import { BaseProperty } from '@docmost/db/types/entity.types';
|
||||
import { SortSpec } from './schema.zod';
|
||||
import { PropertyKind, SYSTEM_COLUMN, propertyKind } from './kinds';
|
||||
import {
|
||||
boolCell,
|
||||
dateCell,
|
||||
numericCell,
|
||||
textCell,
|
||||
} from './extractors';
|
||||
import { PropertySchema } from './predicate';
|
||||
|
||||
/*
|
||||
* Builds sort expressions with sentinel wrapping so NULLs compare
|
||||
* deterministically at the end of the sort order. This avoids the
|
||||
* `__null__` string sentinel bug in the old cursor encoder: because the
|
||||
* sort expression never returns NULL, the cursor simply stores the
|
||||
* extracted value and keyset comparisons work natively.
|
||||
*/
|
||||
|
||||
export type SortBuild = {
|
||||
key: string; // alias used in cursor (s0, s1, ...)
|
||||
expression: RawBuilder<any>; // COALESCE-wrapped expression with sentinel
|
||||
direction: 'asc' | 'desc';
|
||||
valueType: 'numeric' | 'date' | 'text' | 'bool';
|
||||
};
|
||||
|
||||
export type TailKey = 'position' | 'id';
|
||||
|
||||
export const CURSOR_TAIL_KEYS: TailKey[] = ['position', 'id'];
|
||||
|
||||
export function buildSorts(
|
||||
sorts: SortSpec[],
|
||||
schema: PropertySchema,
|
||||
): SortBuild[] {
|
||||
const out: SortBuild[] = [];
|
||||
for (let i = 0; i < sorts.length; i++) {
|
||||
const s = sorts[i];
|
||||
const prop = schema.get(s.propertyId);
|
||||
if (!prop) continue;
|
||||
|
||||
const key = `s${i}`;
|
||||
const dir = s.direction;
|
||||
|
||||
const sysCol = SYSTEM_COLUMN[prop.type];
|
||||
if (sysCol) {
|
||||
out.push({
|
||||
key,
|
||||
expression: sql`${sql.ref(sysCol)}`,
|
||||
direction: dir,
|
||||
valueType: prop.type === 'lastEditedBy' ? 'text' : 'date',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const kind = propertyKind(prop.type);
|
||||
if (!kind) continue;
|
||||
|
||||
out.push(wrapWithSentinel(s.propertyId, kind, dir, key));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function wrapWithSentinel(
|
||||
propertyId: string,
|
||||
kind: Exclude<ReturnType<typeof propertyKind>, null>,
|
||||
direction: 'asc' | 'desc',
|
||||
key: string,
|
||||
): SortBuild {
|
||||
if (kind === PropertyKind.NUMERIC) {
|
||||
const sentinel =
|
||||
direction === 'asc'
|
||||
? sql`'Infinity'::numeric`
|
||||
: sql`'-Infinity'::numeric`;
|
||||
return {
|
||||
key,
|
||||
expression: sql`COALESCE(${numericCell(propertyId)}, ${sentinel})`,
|
||||
direction,
|
||||
valueType: 'numeric',
|
||||
};
|
||||
}
|
||||
if (kind === PropertyKind.DATE) {
|
||||
const sentinel =
|
||||
direction === 'asc'
|
||||
? sql`'infinity'::timestamptz`
|
||||
: sql`'-infinity'::timestamptz`;
|
||||
return {
|
||||
key,
|
||||
expression: sql`COALESCE(${dateCell(propertyId)}, ${sentinel})`,
|
||||
direction,
|
||||
valueType: 'date',
|
||||
};
|
||||
}
|
||||
if (kind === PropertyKind.BOOL) {
|
||||
// false < true. ASC NULLS LAST => null → true; DESC NULLS LAST => null → false.
|
||||
const sentinel = direction === 'asc' ? sql`TRUE` : sql`FALSE`;
|
||||
return {
|
||||
key,
|
||||
expression: sql`COALESCE(${boolCell(propertyId)}, ${sentinel})`,
|
||||
direction,
|
||||
valueType: 'bool',
|
||||
};
|
||||
}
|
||||
// TEXT / SELECT / MULTI / PERSON / FILE — sort by raw extracted text.
|
||||
const sentinel = direction === 'asc' ? sql`chr(1114111)` : sql`''`;
|
||||
return {
|
||||
key,
|
||||
expression: sql`COALESCE(${textCell(propertyId)}, ${sentinel})`,
|
||||
direction,
|
||||
valueType: 'text',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { BaseProperty, BaseRow, BaseView } from '@docmost/db/types/entity.types';
|
||||
|
||||
/*
|
||||
* Domain event payloads emitted by the base services after each mutation
|
||||
* commits. `base-ws-consumers.ts` picks these up and fans them out onto
|
||||
* the appropriate socket.io room. `requestId` lets the originating client
|
||||
* skip replaying its own echo.
|
||||
*/
|
||||
|
||||
type BaseEventBase = {
|
||||
baseId: string;
|
||||
workspaceId: string;
|
||||
actorId?: string | null;
|
||||
requestId?: string | null;
|
||||
};
|
||||
|
||||
export type BaseRowCreatedEvent = BaseEventBase & { row: BaseRow };
|
||||
export type BaseRowUpdatedEvent = BaseEventBase & {
|
||||
rowId: string;
|
||||
patch: Record<string, unknown>;
|
||||
updatedCells: Record<string, unknown>;
|
||||
};
|
||||
export type BaseRowDeletedEvent = BaseEventBase & { rowId: string };
|
||||
export type BaseRowsDeletedEvent = BaseEventBase & { rowIds: string[] };
|
||||
export type BaseRowRestoredEvent = BaseEventBase & { rowId: string };
|
||||
export type BaseRowReorderedEvent = BaseEventBase & {
|
||||
rowId: string;
|
||||
position: string;
|
||||
};
|
||||
|
||||
export type BasePropertyCreatedEvent = BaseEventBase & {
|
||||
property: BaseProperty;
|
||||
};
|
||||
export type BasePropertyUpdatedEvent = BaseEventBase & {
|
||||
property: BaseProperty;
|
||||
schemaVersion: number;
|
||||
};
|
||||
export type BasePropertyDeletedEvent = BaseEventBase & { propertyId: string };
|
||||
export type BasePropertyReorderedEvent = BaseEventBase & {
|
||||
propertyId: string;
|
||||
position: string;
|
||||
};
|
||||
|
||||
export type BaseViewCreatedEvent = BaseEventBase & { view: BaseView };
|
||||
export type BaseViewUpdatedEvent = BaseEventBase & { view: BaseView };
|
||||
export type BaseViewDeletedEvent = BaseEventBase & { viewId: string };
|
||||
|
||||
export type BaseSchemaBumpedEvent = BaseEventBase & { schemaVersion: number };
|
||||
@@ -0,0 +1,90 @@
|
||||
import { serializeCellForCsv } from './cell-csv-serializer';
|
||||
import { BasePropertyType } from '../base.schemas';
|
||||
|
||||
const p = (type: string, typeOptions: unknown = {}) => ({
|
||||
id: 'prop-1',
|
||||
type: type as any,
|
||||
typeOptions,
|
||||
});
|
||||
|
||||
describe('serializeCellForCsv', () => {
|
||||
const userNames = new Map([
|
||||
['u1', 'Alice'],
|
||||
['u2', 'Bob'],
|
||||
]);
|
||||
|
||||
it('returns empty string for null/undefined', () => {
|
||||
expect(serializeCellForCsv(p(BasePropertyType.TEXT), null, {})).toBe('');
|
||||
expect(serializeCellForCsv(p(BasePropertyType.NUMBER), undefined, {})).toBe('');
|
||||
});
|
||||
|
||||
it('stringifies text/url/email as-is', () => {
|
||||
expect(serializeCellForCsv(p(BasePropertyType.TEXT), 'hi', {})).toBe('hi');
|
||||
expect(serializeCellForCsv(p(BasePropertyType.URL), 'https://x', {})).toBe('https://x');
|
||||
expect(serializeCellForCsv(p(BasePropertyType.EMAIL), 'a@b.com', {})).toBe('a@b.com');
|
||||
});
|
||||
|
||||
it('stringifies number', () => {
|
||||
expect(serializeCellForCsv(p(BasePropertyType.NUMBER), 42, {})).toBe('42');
|
||||
expect(serializeCellForCsv(p(BasePropertyType.NUMBER), 0, {})).toBe('0');
|
||||
});
|
||||
|
||||
it('renders checkbox as true/false', () => {
|
||||
expect(serializeCellForCsv(p(BasePropertyType.CHECKBOX), true, {})).toBe('true');
|
||||
expect(serializeCellForCsv(p(BasePropertyType.CHECKBOX), false, {})).toBe('false');
|
||||
});
|
||||
|
||||
it('resolves select/status choice name', () => {
|
||||
const prop = p(BasePropertyType.SELECT, {
|
||||
choices: [
|
||||
{ id: 'c1', name: 'Red', color: 'red' },
|
||||
{ id: 'c2', name: 'Green', color: 'green' },
|
||||
],
|
||||
});
|
||||
expect(serializeCellForCsv(prop, 'c1', {})).toBe('Red');
|
||||
expect(serializeCellForCsv(prop, 'unknown', {})).toBe('');
|
||||
});
|
||||
|
||||
it('joins multiSelect names with "; " preserving order', () => {
|
||||
const prop = p(BasePropertyType.MULTI_SELECT, {
|
||||
choices: [
|
||||
{ id: 'c1', name: 'A', color: 'red' },
|
||||
{ id: 'c2', name: 'B', color: 'blue' },
|
||||
],
|
||||
});
|
||||
expect(serializeCellForCsv(prop, ['c2', 'c1'], {})).toBe('B; A');
|
||||
});
|
||||
|
||||
it('resolves person scalar and array', () => {
|
||||
const prop = p(BasePropertyType.PERSON);
|
||||
expect(serializeCellForCsv(prop, 'u1', { userNames })).toBe('Alice');
|
||||
expect(serializeCellForCsv(prop, ['u1', 'u2', 'missing'], { userNames })).toBe(
|
||||
'Alice; Bob',
|
||||
);
|
||||
});
|
||||
|
||||
it('joins file names from cell payload', () => {
|
||||
const prop = p(BasePropertyType.FILE);
|
||||
expect(
|
||||
serializeCellForCsv(
|
||||
prop,
|
||||
[
|
||||
{ id: 'f1', fileName: 'a.pdf' },
|
||||
{ id: 'f2', fileName: 'b.png' },
|
||||
],
|
||||
{},
|
||||
),
|
||||
).toBe('a.pdf; b.png');
|
||||
});
|
||||
|
||||
it('dates pass through as ISO strings', () => {
|
||||
const iso = '2026-04-18T12:00:00.000Z';
|
||||
expect(serializeCellForCsv(p(BasePropertyType.DATE), iso, {})).toBe(iso);
|
||||
});
|
||||
|
||||
it('lastEditedBy resolves via userNames', () => {
|
||||
const prop = p(BasePropertyType.LAST_EDITED_BY);
|
||||
expect(serializeCellForCsv(prop, 'u2', { userNames })).toBe('Bob');
|
||||
expect(serializeCellForCsv(prop, 'missing', { userNames })).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { BasePropertyType, BasePropertyTypeValue } from '../base.schemas';
|
||||
|
||||
export type CellCsvContext = {
|
||||
userNames?: Map<string, string>;
|
||||
};
|
||||
|
||||
type PropertyLike = {
|
||||
id: string;
|
||||
type: BasePropertyTypeValue | string;
|
||||
typeOptions?: unknown;
|
||||
};
|
||||
|
||||
function resolveChoiceName(typeOptions: unknown, id: unknown): string {
|
||||
if (!typeOptions || typeof typeOptions !== 'object') return '';
|
||||
const choices = (typeOptions as any).choices;
|
||||
if (!Array.isArray(choices)) return '';
|
||||
const match = choices.find((c: any) => c?.id === id);
|
||||
return typeof match?.name === 'string' ? match.name : '';
|
||||
}
|
||||
|
||||
function resolveUser(id: unknown, ctx: CellCsvContext): string {
|
||||
if (typeof id !== 'string') return '';
|
||||
return ctx.userNames?.get(id) ?? '';
|
||||
}
|
||||
|
||||
export function serializeCellForCsv(
|
||||
property: PropertyLike,
|
||||
value: unknown,
|
||||
ctx: CellCsvContext,
|
||||
): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
|
||||
switch (property.type) {
|
||||
case BasePropertyType.TEXT:
|
||||
case BasePropertyType.URL:
|
||||
case BasePropertyType.EMAIL:
|
||||
return String(value);
|
||||
|
||||
case BasePropertyType.NUMBER:
|
||||
return typeof value === 'number' ? String(value) : String(value ?? '');
|
||||
|
||||
case BasePropertyType.CHECKBOX:
|
||||
return value === true ? 'true' : 'false';
|
||||
|
||||
case BasePropertyType.DATE:
|
||||
case BasePropertyType.CREATED_AT:
|
||||
case BasePropertyType.LAST_EDITED_AT:
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
return String(value);
|
||||
|
||||
case BasePropertyType.SELECT:
|
||||
case BasePropertyType.STATUS:
|
||||
return resolveChoiceName(property.typeOptions, value);
|
||||
|
||||
case BasePropertyType.MULTI_SELECT:
|
||||
if (!Array.isArray(value)) return '';
|
||||
return value
|
||||
.map((v) => resolveChoiceName(property.typeOptions, v))
|
||||
.filter((s) => s.length > 0)
|
||||
.join('; ');
|
||||
|
||||
case BasePropertyType.PERSON: {
|
||||
const ids = Array.isArray(value) ? value : [value];
|
||||
return ids
|
||||
.map((id) => resolveUser(id, ctx))
|
||||
.filter((s) => s.length > 0)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
case BasePropertyType.FILE:
|
||||
if (!Array.isArray(value)) return '';
|
||||
return value
|
||||
.map((f: any) =>
|
||||
f && typeof f === 'object' && typeof f.fileName === 'string'
|
||||
? f.fileName
|
||||
: '',
|
||||
)
|
||||
.filter((s) => s.length > 0)
|
||||
.join('; ');
|
||||
|
||||
case BasePropertyType.LAST_EDITED_BY:
|
||||
return resolveUser(value, ctx);
|
||||
|
||||
default:
|
||||
return typeof value === 'object' ? JSON.stringify(value) : String(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { Logger, OnModuleDestroy } from '@nestjs/common';
|
||||
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { Job } from 'bullmq';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { BaseRowRepo } from '@docmost/db/repos/base/base-row.repo';
|
||||
import { BasePropertyRepo } from '@docmost/db/repos/base/base-property.repo';
|
||||
import { BaseRepo } from '@docmost/db/repos/base/base.repo';
|
||||
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
||||
import {
|
||||
IBaseCellGcJob,
|
||||
IBaseTypeConversionJob,
|
||||
} from '../../../integrations/queue/constants/queue.interface';
|
||||
import { processBaseTypeConversion } from '../tasks/base-type-conversion.task';
|
||||
import { processBaseCellGc } from '../tasks/base-cell-gc.task';
|
||||
import { EventName } from '../../../common/events/event.contants';
|
||||
import {
|
||||
BasePropertyUpdatedEvent,
|
||||
BaseSchemaBumpedEvent,
|
||||
} from '../events/base-events';
|
||||
|
||||
@Processor(QueueName.BASE_QUEUE)
|
||||
export class BaseQueueProcessor
|
||||
extends WorkerHost
|
||||
implements OnModuleDestroy
|
||||
{
|
||||
private readonly logger = new Logger(BaseQueueProcessor.name);
|
||||
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly baseRowRepo: BaseRowRepo,
|
||||
private readonly basePropertyRepo: BasePropertyRepo,
|
||||
private readonly baseRepo: BaseRepo,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async process(job: Job): Promise<unknown> {
|
||||
switch (job.name) {
|
||||
case QueueJob.BASE_TYPE_CONVERSION: {
|
||||
const data = job.data as IBaseTypeConversionJob;
|
||||
// Cell rewrite + pending→live swap + schema_version bump share one
|
||||
// transaction so readers never see cells already in the new format
|
||||
// under a still-pending type (or vice versa).
|
||||
const { summary, schemaVersion } = await executeTx(
|
||||
this.db,
|
||||
async (trx) => {
|
||||
const s = await processBaseTypeConversion(
|
||||
this.db,
|
||||
this.baseRowRepo,
|
||||
data,
|
||||
{
|
||||
trx,
|
||||
progress: (processed) => job.updateProgress({ processed }),
|
||||
},
|
||||
);
|
||||
await this.basePropertyRepo.commitPendingTypeChange(
|
||||
data.propertyId,
|
||||
trx,
|
||||
);
|
||||
await this.basePropertyRepo.bumpSchemaVersion(data.propertyId, trx);
|
||||
const v = await this.baseRepo.bumpSchemaVersion(data.baseId, trx);
|
||||
return { summary: s, schemaVersion: v };
|
||||
},
|
||||
);
|
||||
|
||||
// Emit the property:updated first so clients drop the "Converting…"
|
||||
// badge and repaint headers with the new type, then schema:bumped
|
||||
// so they invalidate row caches to pick up migrated cells.
|
||||
const updated = await this.basePropertyRepo.findById(data.propertyId);
|
||||
if (updated) {
|
||||
const event: BasePropertyUpdatedEvent = {
|
||||
baseId: data.baseId,
|
||||
workspaceId: data.workspaceId,
|
||||
actorId: data.actorId ?? null,
|
||||
requestId: null,
|
||||
property: updated,
|
||||
schemaVersion: updated.schemaVersion,
|
||||
};
|
||||
this.eventEmitter.emit(EventName.BASE_PROPERTY_UPDATED, event);
|
||||
}
|
||||
this.emitSchemaBumped(
|
||||
data.baseId,
|
||||
data.workspaceId,
|
||||
schemaVersion,
|
||||
data.actorId,
|
||||
);
|
||||
return summary;
|
||||
}
|
||||
case QueueJob.BASE_CELL_GC: {
|
||||
const data = job.data as IBaseCellGcJob;
|
||||
await processBaseCellGc(
|
||||
this.db,
|
||||
this.baseRowRepo,
|
||||
this.basePropertyRepo,
|
||||
data,
|
||||
);
|
||||
const schemaVersion = await this.baseRepo.bumpSchemaVersion(
|
||||
data.baseId,
|
||||
);
|
||||
this.emitSchemaBumped(data.baseId, data.workspaceId, schemaVersion);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
this.logger.warn(`Unknown job: ${job.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
private emitSchemaBumped(
|
||||
baseId: string,
|
||||
workspaceId: string,
|
||||
schemaVersion: number,
|
||||
actorId?: string,
|
||||
): void {
|
||||
const event: BaseSchemaBumpedEvent = {
|
||||
baseId,
|
||||
workspaceId,
|
||||
actorId: actorId ?? null,
|
||||
requestId: null,
|
||||
schemaVersion,
|
||||
};
|
||||
this.eventEmitter.emit(EventName.BASE_SCHEMA_BUMPED, event);
|
||||
}
|
||||
|
||||
@OnWorkerEvent('active')
|
||||
onActive(job: Job) {
|
||||
this.logger.debug(`Processing ${job.name} job ${job.id}`);
|
||||
}
|
||||
|
||||
@OnWorkerEvent('failed')
|
||||
async onError(job: Job) {
|
||||
this.logger.error(
|
||||
`Error processing ${job.name} job ${job.id}. Reason: ${job.failedReason}`,
|
||||
);
|
||||
|
||||
// Clean up a stuck conversion so the column doesn't wedge in
|
||||
// "Converting…" forever. Cells remain under the original type because
|
||||
// the rewrite transaction rolled back.
|
||||
if (job.name === QueueJob.BASE_TYPE_CONVERSION) {
|
||||
const data = job.data as IBaseTypeConversionJob;
|
||||
try {
|
||||
await this.basePropertyRepo.clearPendingTypeChange(data.propertyId);
|
||||
const reverted = await this.basePropertyRepo.findById(data.propertyId);
|
||||
if (reverted) {
|
||||
const event: BasePropertyUpdatedEvent = {
|
||||
baseId: data.baseId,
|
||||
workspaceId: data.workspaceId,
|
||||
actorId: data.actorId ?? null,
|
||||
requestId: null,
|
||||
property: reverted,
|
||||
schemaVersion: reverted.schemaVersion,
|
||||
};
|
||||
this.eventEmitter.emit(EventName.BASE_PROPERTY_UPDATED, event);
|
||||
}
|
||||
} catch (cleanupErr) {
|
||||
this.logger.error(
|
||||
`Failed to clear pending type change on property ${data.propertyId}`,
|
||||
cleanupErr as Error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OnWorkerEvent('completed')
|
||||
onCompleted(job: Job) {
|
||||
this.logger.debug(`Completed ${job.name} job ${job.id}`);
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
if (this.worker) {
|
||||
await this.worker.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user