mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
feat: bases - WIP
This commit is contained in:
@@ -11,6 +11,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@casl/react": "^4.0.0",
|
||||
"@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",
|
||||
@@ -24,6 +28,8 @@
|
||||
"@mantine/spotlight": "^8.3.14",
|
||||
"@tabler/icons-react": "^3.36.1",
|
||||
"@tanstack/react-query": "^5.90.17",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.18",
|
||||
"alfaaz": "^1.1.0",
|
||||
"axios": "^1.13.5",
|
||||
"blueimp-load-image": "^5.16.0",
|
||||
|
||||
@@ -37,6 +37,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";
|
||||
|
||||
export default function App() {
|
||||
const { t } = useTranslation();
|
||||
@@ -86,6 +87,8 @@ export default function App() {
|
||||
element={<Page />}
|
||||
/>
|
||||
|
||||
<Route path={"/base/:baseId"} element={<BasePage />} />
|
||||
|
||||
<Route path={"/settings"}>
|
||||
<Route path={"account/profile"} element={<AccountSettings />} />
|
||||
<Route
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
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);
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { Loader, 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 {
|
||||
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 { useCreatePropertyMutation } from "@/features/base/queries/base-property-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 { GridContainer } from "@/features/base/components/grid/grid-container";
|
||||
import { BaseToolbar } from "@/features/base/components/base-toolbar";
|
||||
import classes from "@/features/base/styles/grid.module.css";
|
||||
|
||||
type BaseTableProps = {
|
||||
baseId: string;
|
||||
};
|
||||
|
||||
export function BaseTable({ baseId }: BaseTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: base, isLoading: baseLoading, error: baseError } = useBaseQuery(baseId);
|
||||
const { data: rowsData, isLoading: rowsLoading, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
||||
useBaseRowsQuery(baseId);
|
||||
|
||||
const updateRowMutation = useUpdateRowMutation();
|
||||
const createRowMutation = useCreateRowMutation();
|
||||
const reorderRowMutation = useReorderRowMutation();
|
||||
const createPropertyMutation = useCreatePropertyMutation();
|
||||
const createViewMutation = useCreateViewMutation();
|
||||
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeView && activeViewId !== activeView.id) {
|
||||
setActiveViewId(activeView.id);
|
||||
}
|
||||
}, [activeView, activeViewId, setActiveViewId]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const flat = flattenRows(rowsData);
|
||||
return flat.sort((a, b) => (a.position < b.position ? -1 : a.position > b.position ? 1 : 0));
|
||||
}, [rowsData]);
|
||||
|
||||
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 handleAddColumn = useCallback(() => {
|
||||
createPropertyMutation.mutate({
|
||||
baseId,
|
||||
name: t("New property"),
|
||||
type: "text",
|
||||
});
|
||||
}, [baseId, createPropertyMutation, t]);
|
||||
|
||||
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 (
|
||||
<div className={classes.loadingOverlay}>
|
||||
<Loader size="md" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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}
|
||||
onCellUpdate={handleCellUpdate}
|
||||
onAddRow={handleAddRow}
|
||||
onAddColumn={handleAddColumn}
|
||||
onColumnReorder={handleColumnReorder}
|
||||
onResizeEnd={handleResizeEnd}
|
||||
onRowReorder={handleRowReorder}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onFetchNextPage={fetchNextPage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { ActionIcon, Tooltip, Badge } from "@mantine/core";
|
||||
import { Table } from "@tanstack/react-table";
|
||||
import {
|
||||
IconSortAscending,
|
||||
IconFilter,
|
||||
IconEye,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
IBase,
|
||||
IBaseRow,
|
||||
IBaseView,
|
||||
ViewSortConfig,
|
||||
ViewFilterConfig,
|
||||
} from "@/features/base/types/base.types";
|
||||
import { useUpdateViewMutation } from "@/features/base/queries/base-view-query";
|
||||
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 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 ?? [];
|
||||
const filters = activeView?.config?.filters ?? [];
|
||||
|
||||
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;
|
||||
updateViewMutation.mutate({
|
||||
viewId: activeView.id,
|
||||
baseId: base.id,
|
||||
config: { ...activeView.config, sorts: newSorts },
|
||||
});
|
||||
},
|
||||
[activeView, base.id, updateViewMutation],
|
||||
);
|
||||
|
||||
const handleFiltersChange = useCallback(
|
||||
(newFilters: ViewFilterConfig[]) => {
|
||||
if (!activeView) return;
|
||||
updateViewMutation.mutate({
|
||||
viewId: activeView.id,
|
||||
baseId: base.id,
|
||||
config: { ...activeView.config, filters: newFilters },
|
||||
});
|
||||
},
|
||||
[activeView, base.id, updateViewMutation],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={classes.toolbar}>
|
||||
<ViewTabs
|
||||
views={views}
|
||||
activeViewId={activeView?.id}
|
||||
baseId={base.id}
|
||||
onViewChange={onViewChange}
|
||||
onAddView={onAddView}
|
||||
/>
|
||||
|
||||
<div className={classes.toolbarRight}>
|
||||
<ViewFilterConfigPopover
|
||||
opened={filterOpened}
|
||||
onClose={() => setFilterOpened(false)}
|
||||
filters={filters}
|
||||
properties={base.properties}
|
||||
onChange={handleFiltersChange}
|
||||
>
|
||||
<Tooltip label={t("Filter")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
color={filters.length > 0 ? "blue" : "gray"}
|
||||
onClick={() => openToolbar("filter")}
|
||||
>
|
||||
<IconFilter size={16} />
|
||||
{filters.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,
|
||||
}}
|
||||
>
|
||||
{filters.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}
|
||||
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,47 @@
|
||||
import { IconPaperclip } from "@tabler/icons-react";
|
||||
import { IBaseProperty } from "@/features/base/types/base.types";
|
||||
import cellClasses from "@/features/base/styles/cells.module.css";
|
||||
|
||||
type FileValue = {
|
||||
id: string;
|
||||
name: string;
|
||||
url?: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
type CellFileProps = {
|
||||
value: unknown;
|
||||
property: IBaseProperty;
|
||||
rowId: string;
|
||||
isEditing: boolean;
|
||||
onCommit: (value: unknown) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export function CellFile({
|
||||
value,
|
||||
}: CellFileProps) {
|
||||
const files = Array.isArray(value) ? (value as FileValue[]) : [];
|
||||
|
||||
if (files.length === 0) {
|
||||
return <span className={cellClasses.emptyValue} />;
|
||||
}
|
||||
|
||||
const MAX_VISIBLE = 2;
|
||||
const visible = files.slice(0, MAX_VISIBLE);
|
||||
const overflow = files.length - MAX_VISIBLE;
|
||||
|
||||
return (
|
||||
<div className={cellClasses.fileGroup}>
|
||||
{visible.map((file) => (
|
||||
<span key={file.id} className={cellClasses.fileBadge}>
|
||||
<IconPaperclip size={12} />
|
||||
{file.name}
|
||||
</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,152 @@
|
||||
import { useState, useRef, useEffect, useCallback } 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";
|
||||
|
||||
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 handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
},
|
||||
[onCancel],
|
||||
);
|
||||
|
||||
const MAX_VISIBLE = 3;
|
||||
|
||||
if (isEditing) {
|
||||
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) => (
|
||||
<div
|
||||
key={choice.id}
|
||||
className={`${cellClasses.selectOption} ${
|
||||
selectedSet.has(choice.id)
|
||||
? cellClasses.selectOptionActive
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => handleToggle(choice)}
|
||||
>
|
||||
<span
|
||||
className={cellClasses.badge}
|
||||
style={choiceColor(choice.color)}
|
||||
>
|
||||
{choice.name}
|
||||
</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,46 @@
|
||||
import { IBaseProperty } from "@/features/base/types/base.types";
|
||||
import cellClasses from "@/features/base/styles/cells.module.css";
|
||||
|
||||
type CellPersonProps = {
|
||||
value: unknown;
|
||||
property: IBaseProperty;
|
||||
rowId: string;
|
||||
isEditing: boolean;
|
||||
onCommit: (value: unknown) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
function getInitials(id: string): string {
|
||||
return id.substring(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export function CellPerson({
|
||||
value,
|
||||
}: CellPersonProps) {
|
||||
const personIds = Array.isArray(value)
|
||||
? (value as string[])
|
||||
: typeof value === "string"
|
||||
? [value]
|
||||
: [];
|
||||
|
||||
if (personIds.length === 0) {
|
||||
return <span className={cellClasses.emptyValue} />;
|
||||
}
|
||||
|
||||
const MAX_VISIBLE = 4;
|
||||
const visible = personIds.slice(0, MAX_VISIBLE);
|
||||
const overflow = personIds.length - MAX_VISIBLE;
|
||||
|
||||
return (
|
||||
<div className={cellClasses.personGroup}>
|
||||
{visible.map((id) => (
|
||||
<div key={id} className={cellClasses.personAvatar}>
|
||||
{getInitials(id)}
|
||||
</div>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<span className={cellClasses.overflowCount}>+{overflow}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useState, useRef, useEffect, useCallback } 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";
|
||||
|
||||
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 handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
},
|
||||
[onCancel],
|
||||
);
|
||||
|
||||
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}>
|
||||
{filteredChoices.map((choice) => (
|
||||
<div
|
||||
key={choice.id}
|
||||
className={`${cellClasses.selectOption} ${
|
||||
choice.id === selectedId ? cellClasses.selectOptionActive : ""
|
||||
}`}
|
||||
onClick={() => handleSelect(choice)}
|
||||
>
|
||||
<span
|
||||
className={cellClasses.badge}
|
||||
style={choiceColor(choice.color)}
|
||||
>
|
||||
{choice.name}
|
||||
</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,170 @@
|
||||
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";
|
||||
|
||||
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 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();
|
||||
}
|
||||
},
|
||||
[onCancel],
|
||||
);
|
||||
|
||||
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) => (
|
||||
<div
|
||||
key={choice.id}
|
||||
className={`${cellClasses.selectOption} ${
|
||||
choice.id === selectedId
|
||||
? cellClasses.selectOptionActive
|
||||
: ""
|
||||
}`}
|
||||
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,148 @@
|
||||
import { memo, useCallback } from "react";
|
||||
import { Cell } from "@tanstack/react-table";
|
||||
import { useAtom } from "jotai";
|
||||
import { IBaseRow, IBaseProperty, EditingCell } from "@/features/base/types/base.types";
|
||||
import { editingCellAtom } from "@/features/base/atoms/base-atoms";
|
||||
import { isSystemPropertyType } from "@/features/base/hooks/use-base-table";
|
||||
import { CellText } from "@/features/base/components/cells/cell-text";
|
||||
import { CellNumber } from "@/features/base/components/cells/cell-number";
|
||||
import { CellSelect } from "@/features/base/components/cells/cell-select";
|
||||
import { CellStatus } from "@/features/base/components/cells/cell-status";
|
||||
import { CellMultiSelect } from "@/features/base/components/cells/cell-multi-select";
|
||||
import { CellDate } from "@/features/base/components/cells/cell-date";
|
||||
import { CellCheckbox } from "@/features/base/components/cells/cell-checkbox";
|
||||
import { CellUrl } from "@/features/base/components/cells/cell-url";
|
||||
import { CellEmail } from "@/features/base/components/cells/cell-email";
|
||||
import { CellPerson } from "@/features/base/components/cells/cell-person";
|
||||
import { CellFile } from "@/features/base/components/cells/cell-file";
|
||||
import { CellCreatedAt } from "@/features/base/components/cells/cell-created-at";
|
||||
import { CellLastEditedAt } from "@/features/base/components/cells/cell-last-edited-at";
|
||||
import { CellLastEditedBy } from "@/features/base/components/cells/cell-last-edited-by";
|
||||
import classes from "@/features/base/styles/grid.module.css";
|
||||
|
||||
type CellComponentProps = {
|
||||
value: unknown;
|
||||
property: IBaseProperty;
|
||||
rowId: string;
|
||||
isEditing: boolean;
|
||||
onCommit: (value: unknown) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
const cellComponents: Record<
|
||||
string,
|
||||
React.ComponentType<CellComponentProps>
|
||||
> = {
|
||||
text: CellText,
|
||||
number: CellNumber,
|
||||
select: CellSelect,
|
||||
status: CellStatus,
|
||||
multiSelect: CellMultiSelect,
|
||||
date: CellDate,
|
||||
checkbox: CellCheckbox,
|
||||
url: CellUrl,
|
||||
email: CellEmail,
|
||||
person: CellPerson,
|
||||
file: CellFile,
|
||||
createdAt: CellCreatedAt,
|
||||
lastEditedAt: CellLastEditedAt,
|
||||
lastEditedBy: CellLastEditedBy,
|
||||
};
|
||||
|
||||
type RowDragProps = {
|
||||
draggable: boolean;
|
||||
onDragStart: (e: React.DragEvent) => void;
|
||||
};
|
||||
|
||||
type GridCellProps = {
|
||||
cell: Cell<IBaseRow, unknown>;
|
||||
rowIndex: number;
|
||||
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
|
||||
rowDragProps?: RowDragProps;
|
||||
};
|
||||
|
||||
export const GridCell = memo(function GridCell({
|
||||
cell,
|
||||
rowIndex,
|
||||
onCellUpdate,
|
||||
rowDragProps,
|
||||
}: GridCellProps) {
|
||||
const property = cell.column.columnDef.meta?.property;
|
||||
const isRowNumber = cell.column.id === "__row_number";
|
||||
const isPinned = cell.column.getIsPinned();
|
||||
const pinOffset = isPinned ? cell.column.getStart("left") : undefined;
|
||||
|
||||
const [editingCell, setEditingCell] = useAtom(editingCellAtom) as unknown as [EditingCell, (val: EditingCell) => void];
|
||||
|
||||
const rowId = cell.row.id;
|
||||
const isEditing =
|
||||
editingCell?.rowId === rowId &&
|
||||
editingCell?.propertyId === property?.id;
|
||||
|
||||
const handleDoubleClick = useCallback(() => {
|
||||
if (!property || isRowNumber) return;
|
||||
if (property.type === "checkbox") return;
|
||||
if (isSystemPropertyType(property.type)) return;
|
||||
setEditingCell({ rowId, propertyId: property.id });
|
||||
}, [property, isRowNumber, rowId, setEditingCell]);
|
||||
|
||||
const handleCommit = useCallback(
|
||||
(value: unknown) => {
|
||||
if (!property) return;
|
||||
const currentValue = cell.getValue();
|
||||
const hasChanged = value !== currentValue
|
||||
&& !(value === "" && (currentValue === null || currentValue === undefined))
|
||||
&& !(value === null && (currentValue === null || currentValue === undefined));
|
||||
if (hasChanged) {
|
||||
onCellUpdate(rowId, property.id, value);
|
||||
}
|
||||
setEditingCell(null);
|
||||
},
|
||||
[property, rowId, cell, onCellUpdate, setEditingCell],
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setEditingCell(null);
|
||||
}, [setEditingCell]);
|
||||
|
||||
if (isRowNumber) {
|
||||
return (
|
||||
<div
|
||||
className={`${classes.cell} ${classes.rowNumberCell} ${isPinned ? classes.cellPinned : ""} ${rowDragProps ? classes.rowNumberDraggable : ""}`}
|
||||
style={{
|
||||
...(isPinned ? { left: pinOffset } : {}),
|
||||
}}
|
||||
draggable={rowDragProps?.draggable}
|
||||
onDragStart={rowDragProps?.onDragStart}
|
||||
>
|
||||
{rowIndex + 1}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!property) return null;
|
||||
|
||||
const CellComponent = cellComponents[property.type];
|
||||
if (!CellComponent) return null;
|
||||
|
||||
const value = cell.getValue();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${classes.cell} ${isPinned ? classes.cellPinned : ""} ${isEditing ? classes.cellEditing : ""} ${property.isPrimary ? classes.primaryCell : ""}`}
|
||||
style={{
|
||||
...(isPinned ? { left: pinOffset } : {}),
|
||||
}}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
<CellComponent
|
||||
value={value}
|
||||
property={property}
|
||||
rowId={rowId}
|
||||
isEditing={isEditing}
|
||||
onCommit={handleCommit}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import { useRef, useMemo, useCallback, useEffect } from "react";
|
||||
import { Table } from "@tanstack/react-table";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
horizontalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { restrictToHorizontalAxis } from "@dnd-kit/modifiers";
|
||||
import { IBaseRow, EditingCell } from "@/features/base/types/base.types";
|
||||
import { editingCellAtom, activePropertyMenuAtom, propertyMenuDirtyAtom, propertyMenuCloseRequestAtom } from "@/features/base/atoms/base-atoms";
|
||||
import { useColumnResize } from "@/features/base/hooks/use-column-resize";
|
||||
import { useGridKeyboardNav } from "@/features/base/hooks/use-grid-keyboard-nav";
|
||||
import { useRowDrag } from "@/features/base/hooks/use-row-drag";
|
||||
import { GridHeader } from "./grid-header";
|
||||
import { GridRow } from "./grid-row";
|
||||
import { AddRowButton } from "./add-row-button";
|
||||
import classes from "@/features/base/styles/grid.module.css";
|
||||
|
||||
const ROW_HEIGHT = 36;
|
||||
const OVERSCAN = 10;
|
||||
|
||||
type GridContainerProps = {
|
||||
table: Table<IBaseRow>;
|
||||
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
|
||||
onAddRow?: () => void;
|
||||
onAddColumn?: () => void;
|
||||
onColumnReorder?: (columnId: string, overColumnId: string) => void;
|
||||
onResizeEnd?: () => void;
|
||||
onRowReorder?: (rowId: string, targetRowId: string, position: "above" | "below") => void;
|
||||
hasNextPage?: boolean;
|
||||
isFetchingNextPage?: boolean;
|
||||
onFetchNextPage?: () => void;
|
||||
};
|
||||
|
||||
export function GridContainer({
|
||||
table,
|
||||
onCellUpdate,
|
||||
onAddRow,
|
||||
onAddColumn,
|
||||
onColumnReorder,
|
||||
onResizeEnd,
|
||||
onRowReorder,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
onFetchNextPage,
|
||||
}: GridContainerProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const rows = table.getRowModel().rows;
|
||||
|
||||
const [editingCell, setEditingCell] = useAtom(editingCellAtom) as unknown as [EditingCell, (val: EditingCell) => void];
|
||||
const [, setActivePropertyMenu] = useAtom(activePropertyMenuAtom) as unknown as [string | null, (val: string | null) => void];
|
||||
const [propertyMenuDirty] = useAtom(propertyMenuDirtyAtom) as unknown as [boolean];
|
||||
const [, setCloseRequest] = useAtom(propertyMenuCloseRequestAtom) as unknown as [number, (val: number) => void];
|
||||
const propertyMenuDirtyRef = useRef(propertyMenuDirty);
|
||||
propertyMenuDirtyRef.current = propertyMenuDirty;
|
||||
const closeRequestCounterRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest(`.${classes.headerCell}`)) return;
|
||||
if (target.closest("[role=\"dialog\"]")) return;
|
||||
if (target.closest("[role=\"listbox\"]")) return;
|
||||
if (target.closest("[data-mantine-shared-portal-node]")) return;
|
||||
if (target.closest(`.${classes.cellEditing}`)) return;
|
||||
if (propertyMenuDirtyRef.current) {
|
||||
closeRequestCounterRef.current += 1;
|
||||
setCloseRequest(closeRequestCounterRef.current);
|
||||
} else {
|
||||
setActivePropertyMenu(null);
|
||||
}
|
||||
setEditingCell(null);
|
||||
};
|
||||
document.addEventListener("mousedown", handleMouseDown);
|
||||
return () => document.removeEventListener("mousedown", handleMouseDown);
|
||||
}, [setActivePropertyMenu, setEditingCell, setCloseRequest]);
|
||||
|
||||
useColumnResize(table, onResizeEnd ?? (() => {}));
|
||||
|
||||
useGridKeyboardNav({
|
||||
table,
|
||||
editingCell,
|
||||
setEditingCell,
|
||||
containerRef: scrollRef,
|
||||
});
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
overscan: OVERSCAN,
|
||||
});
|
||||
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasNextPage || isFetchingNextPage || !onFetchNextPage) return;
|
||||
const lastItem = virtualItems[virtualItems.length - 1];
|
||||
if (!lastItem) return;
|
||||
if (lastItem.index >= rows.length - OVERSCAN * 2) {
|
||||
onFetchNextPage();
|
||||
}
|
||||
}, [virtualItems, rows.length, hasNextPage, isFetchingNextPage, onFetchNextPage]);
|
||||
|
||||
const gridTemplateColumns = useMemo(() => {
|
||||
const visibleColumns = table.getVisibleLeafColumns();
|
||||
const columnWidths = visibleColumns.map((col) => `${col.getSize()}px`);
|
||||
return columnWidths.join(" ") + (onAddColumn ? " 40px" : "");
|
||||
}, [table, table.getState().columnSizing, table.getState().columnVisibility, onAddColumn]);
|
||||
|
||||
const totalHeight = virtualizer.getTotalSize();
|
||||
|
||||
const paddingTop =
|
||||
virtualItems.length > 0 ? virtualItems[0]?.start ?? 0 : 0;
|
||||
const paddingBottom =
|
||||
virtualItems.length > 0
|
||||
? totalHeight - (virtualItems[virtualItems.length - 1]?.end ?? 0)
|
||||
: 0;
|
||||
|
||||
const rowIds = useMemo(() => rows.map((r) => r.id), [rows]);
|
||||
|
||||
const handleRowReorder = useCallback(
|
||||
(rowId: string, targetRowId: string, position: "above" | "below") => {
|
||||
onRowReorder?.(rowId, targetRowId, position);
|
||||
},
|
||||
[onRowReorder],
|
||||
);
|
||||
|
||||
const {
|
||||
dragState: rowDragState,
|
||||
handleDragStart: handleRowDragStart,
|
||||
handleDragOver: handleRowDragOver,
|
||||
handleDragEnd: handleRowDragEnd,
|
||||
handleDragLeave: handleRowDragLeave,
|
||||
} = useRowDrag({ rowIds, onReorder: handleRowReorder });
|
||||
|
||||
const handleAddRow = useCallback(() => {
|
||||
onAddRow?.();
|
||||
}, [onAddRow]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 8 },
|
||||
}),
|
||||
useSensor(KeyboardSensor),
|
||||
);
|
||||
|
||||
const sortableColumnIds = useMemo(() => {
|
||||
return table
|
||||
.getVisibleLeafColumns()
|
||||
.filter((col) => col.id !== "__row_number")
|
||||
.map((col) => col.id);
|
||||
}, [table, table.getState().columnOrder]);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
onColumnReorder?.(active.id as string, over.id as string);
|
||||
},
|
||||
[onColumnReorder],
|
||||
);
|
||||
|
||||
const modifiers = useMemo(() => [restrictToHorizontalAxis], []);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
modifiers={modifiers}
|
||||
>
|
||||
<div
|
||||
className={classes.gridWrapper}
|
||||
ref={scrollRef}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div
|
||||
className={classes.grid}
|
||||
style={{ gridTemplateColumns }}
|
||||
role="grid"
|
||||
>
|
||||
<SortableContext
|
||||
items={sortableColumnIds}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
<GridHeader table={table} onAddColumn={onAddColumn} />
|
||||
</SortableContext>
|
||||
|
||||
{paddingTop > 0 && (
|
||||
<div style={{ height: paddingTop, gridColumn: "1 / -1" }} />
|
||||
)}
|
||||
|
||||
{virtualItems.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index];
|
||||
if (!row) return null;
|
||||
return (
|
||||
<GridRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
rowIndex={virtualRow.index}
|
||||
onCellUpdate={onCellUpdate}
|
||||
dragHandlers={
|
||||
onRowReorder
|
||||
? {
|
||||
onDragStart: handleRowDragStart,
|
||||
onDragOver: handleRowDragOver,
|
||||
onDragEnd: handleRowDragEnd,
|
||||
onDragLeave: handleRowDragLeave,
|
||||
isDragging: rowDragState.dragRowId === row.id,
|
||||
isDropTarget: rowDragState.dropTargetRowId === row.id,
|
||||
dropPosition: rowDragState.dropTargetRowId === row.id ? rowDragState.dropPosition : null,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{paddingBottom > 0 && (
|
||||
<div style={{ height: paddingBottom, gridColumn: "1 / -1" }} />
|
||||
)}
|
||||
|
||||
<AddRowButton onClick={handleAddRow} />
|
||||
</div>
|
||||
</div>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { memo, useCallback, useRef } from "react";
|
||||
import { Header, flexRender } from "@tanstack/react-table";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Popover } from "@mantine/core";
|
||||
import { useAtom } from "jotai";
|
||||
import { IBaseRow, IBaseProperty, EditingCell } from "@/features/base/types/base.types";
|
||||
import { activePropertyMenuAtom, propertyMenuDirtyAtom, editingCellAtom } from "@/features/base/atoms/base-atoms";
|
||||
import {
|
||||
IconLetterT,
|
||||
IconHash,
|
||||
IconCircleDot,
|
||||
IconProgressCheck,
|
||||
IconTags,
|
||||
IconCalendar,
|
||||
IconUser,
|
||||
IconPaperclip,
|
||||
IconCheckbox,
|
||||
IconLink,
|
||||
IconMail,
|
||||
IconClockPlus,
|
||||
IconClockEdit,
|
||||
IconUserEdit,
|
||||
} from "@tabler/icons-react";
|
||||
import { PropertyMenuContent } from "@/features/base/components/property/property-menu";
|
||||
import classes from "@/features/base/styles/grid.module.css";
|
||||
|
||||
const typeIcons: Record<string, typeof IconLetterT> = {
|
||||
text: IconLetterT,
|
||||
number: IconHash,
|
||||
select: IconCircleDot,
|
||||
status: IconProgressCheck,
|
||||
multiSelect: IconTags,
|
||||
date: IconCalendar,
|
||||
person: IconUser,
|
||||
file: IconPaperclip,
|
||||
checkbox: IconCheckbox,
|
||||
url: IconLink,
|
||||
email: IconMail,
|
||||
createdAt: IconClockPlus,
|
||||
lastEditedAt: IconClockEdit,
|
||||
lastEditedBy: IconUserEdit,
|
||||
};
|
||||
|
||||
type GridHeaderCellProps = {
|
||||
header: Header<IBaseRow, unknown>;
|
||||
};
|
||||
|
||||
export const GridHeaderCell = memo(function GridHeaderCell({
|
||||
header,
|
||||
}: GridHeaderCellProps) {
|
||||
const property = header.column.columnDef.meta?.property as
|
||||
| IBaseProperty
|
||||
| undefined;
|
||||
const isRowNumber = header.column.id === "__row_number";
|
||||
const isPinned = header.column.getIsPinned();
|
||||
const pinOffset = isPinned ? header.column.getStart("left") : undefined;
|
||||
|
||||
const [activePropertyMenu, setActivePropertyMenu] = useAtom(activePropertyMenuAtom) as unknown as [string | null, (val: string | null) => void];
|
||||
const menuOpened = activePropertyMenu === header.column.id;
|
||||
const cellRef = useRef<HTMLDivElement>(null);
|
||||
const [propertyMenuDirty, setPropertyMenuDirty] = useAtom(propertyMenuDirtyAtom) as unknown as [boolean, (val: boolean) => void];
|
||||
const [, setEditingCell] = useAtom(editingCellAtom) as unknown as [EditingCell, (val: EditingCell) => void];
|
||||
|
||||
const handleDirtyChange = useCallback((dirty: boolean) => {
|
||||
setPropertyMenuDirty(dirty);
|
||||
}, [setPropertyMenuDirty]);
|
||||
|
||||
const isSortableDisabled = isRowNumber || isPinned === "left";
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: header.column.id,
|
||||
disabled: isSortableDisabled,
|
||||
});
|
||||
|
||||
const combinedRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
setNodeRef(node);
|
||||
(cellRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||
},
|
||||
[setNodeRef],
|
||||
);
|
||||
|
||||
const handleHeaderClick = useCallback(() => {
|
||||
setEditingCell(null);
|
||||
if (!isRowNumber && property && !isDragging) {
|
||||
if (propertyMenuDirty && !menuOpened) return;
|
||||
setActivePropertyMenu(menuOpened ? null : header.column.id);
|
||||
}
|
||||
}, [isRowNumber, property, isDragging, header.column.id, menuOpened, propertyMenuDirty, setActivePropertyMenu, setEditingCell]);
|
||||
|
||||
const handleMenuClose = useCallback(() => {
|
||||
setActivePropertyMenu(null);
|
||||
}, [setActivePropertyMenu]);
|
||||
|
||||
const TypeIcon = property ? typeIcons[property.type] : undefined;
|
||||
|
||||
const sortableStyle = transform
|
||||
? {
|
||||
transform: CSS.Transform.toString({
|
||||
...transform,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
}),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
zIndex: isDragging ? 10 : undefined,
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={combinedRef}
|
||||
className={`${classes.headerCell} ${isPinned ? classes.headerCellPinned : ""}`}
|
||||
style={{
|
||||
...(isPinned ? { left: pinOffset } : {}),
|
||||
...(isRowNumber ? {} : { cursor: "pointer" }),
|
||||
...sortableStyle,
|
||||
}}
|
||||
onClick={handleHeaderClick}
|
||||
{...(isSortableDisabled ? {} : attributes)}
|
||||
{...(isSortableDisabled ? {} : listeners)}
|
||||
>
|
||||
{isRowNumber ? (
|
||||
flexRender(header.column.columnDef.header, header.getContext())
|
||||
) : (
|
||||
<div className={classes.headerCellContent}>
|
||||
{TypeIcon && (
|
||||
<TypeIcon size={14} className={classes.headerTypeIcon} />
|
||||
)}
|
||||
<span className={classes.headerCellName}>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{header.column.getCanResize() && (
|
||||
<div
|
||||
className={`${classes.resizeHandle} ${
|
||||
header.column.getIsResizing() ? classes.resizeHandleActive : ""
|
||||
}`}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
header.getResizeHandler()(e);
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.stopPropagation();
|
||||
header.getResizeHandler()(e);
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
{property && !isRowNumber && (
|
||||
<Popover
|
||||
opened={menuOpened}
|
||||
onClose={handleMenuClose}
|
||||
position="bottom-start"
|
||||
shadow="md"
|
||||
width={260}
|
||||
withinPortal
|
||||
closeOnClickOutside={false}
|
||||
>
|
||||
<Popover.Target>
|
||||
<div style={{ position: "absolute", inset: 0, pointerEvents: "none" }} />
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown
|
||||
p={0}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<PropertyMenuContent
|
||||
property={property}
|
||||
opened={menuOpened}
|
||||
onClose={handleMenuClose}
|
||||
onDirtyChange={handleDirtyChange}
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { memo, useCallback } from "react";
|
||||
import { Table } from "@tanstack/react-table";
|
||||
import { IBaseRow } from "@/features/base/types/base.types";
|
||||
import { GridHeaderCell } from "./grid-header-cell";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
import classes from "@/features/base/styles/grid.module.css";
|
||||
|
||||
type GridHeaderProps = {
|
||||
table: Table<IBaseRow>;
|
||||
onAddColumn?: () => void;
|
||||
};
|
||||
|
||||
export const GridHeader = memo(function GridHeader({
|
||||
table,
|
||||
onAddColumn,
|
||||
}: GridHeaderProps) {
|
||||
const headerGroups = table.getHeaderGroups();
|
||||
|
||||
const handleAddColumn = useCallback(() => {
|
||||
onAddColumn?.();
|
||||
}, [onAddColumn]);
|
||||
|
||||
return (
|
||||
<div className={classes.headerRow} role="row">
|
||||
{headerGroups[0]?.headers.map((header) => (
|
||||
<GridHeaderCell key={header.id} header={header} />
|
||||
))}
|
||||
{onAddColumn && (
|
||||
<div
|
||||
className={classes.addColumnButton}
|
||||
onClick={handleAddColumn}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<IconPlus size={16} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { memo, useCallback } from "react";
|
||||
import { Row } from "@tanstack/react-table";
|
||||
import { IBaseRow } from "@/features/base/types/base.types";
|
||||
import { GridCell } from "./grid-cell";
|
||||
import classes from "@/features/base/styles/grid.module.css";
|
||||
|
||||
type RowDragHandlers = {
|
||||
onDragStart: (rowId: string) => void;
|
||||
onDragOver: (rowId: string, e: React.DragEvent) => void;
|
||||
onDragEnd: () => void;
|
||||
onDragLeave: () => void;
|
||||
isDragging: boolean;
|
||||
isDropTarget: boolean;
|
||||
dropPosition: "above" | "below" | null;
|
||||
};
|
||||
|
||||
type GridRowProps = {
|
||||
row: Row<IBaseRow>;
|
||||
rowIndex: number;
|
||||
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
|
||||
dragHandlers?: RowDragHandlers;
|
||||
};
|
||||
|
||||
export const GridRow = memo(function GridRow({
|
||||
row,
|
||||
rowIndex,
|
||||
onCellUpdate,
|
||||
dragHandlers,
|
||||
}: GridRowProps) {
|
||||
const handleDragStart = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", row.id);
|
||||
dragHandlers?.onDragStart(row.id);
|
||||
},
|
||||
[row.id, dragHandlers],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
dragHandlers?.onDragOver(row.id, e);
|
||||
},
|
||||
[row.id, dragHandlers],
|
||||
);
|
||||
|
||||
const dropIndicatorClass = dragHandlers?.isDropTarget
|
||||
? dragHandlers.dropPosition === "above"
|
||||
? classes.rowDropAbove
|
||||
: classes.rowDropBelow
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${classes.row} ${dragHandlers?.isDragging ? classes.rowDragging : ""} ${dropIndicatorClass}`}
|
||||
role="row"
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
dragHandlers?.onDragEnd();
|
||||
}}
|
||||
onDragLeave={dragHandlers?.onDragLeave}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const isRowNumber = cell.column.id === "__row_number";
|
||||
return (
|
||||
<GridCell
|
||||
key={cell.id}
|
||||
cell={cell}
|
||||
rowIndex={rowIndex}
|
||||
onCellUpdate={onCellUpdate}
|
||||
rowDragProps={
|
||||
isRowNumber && dragHandlers
|
||||
? {
|
||||
draggable: true,
|
||||
onDragStart: handleDragStart,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,528 @@
|
||||
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;
|
||||
};
|
||||
|
||||
export function ChoiceEditor({
|
||||
initialChoices,
|
||||
onSave,
|
||||
onClose,
|
||||
onDirtyChange,
|
||||
showCategories = false,
|
||||
}: ChoiceEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [draft, setDraft] = useState<Choice[]>(initialChoices);
|
||||
const [focusChoiceId, setFocusChoiceId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(initialChoices);
|
||||
}, [initialChoices]);
|
||||
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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,443 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import {
|
||||
UnstyledButton,
|
||||
TextInput,
|
||||
Button,
|
||||
Stack,
|
||||
Text,
|
||||
Group,
|
||||
ActionIcon,
|
||||
Divider,
|
||||
ScrollArea,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconTrash,
|
||||
IconPencil,
|
||||
IconChevronRight,
|
||||
IconTransform,
|
||||
IconSettings,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
IBaseProperty,
|
||||
BasePropertyType,
|
||||
} 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 { PropertyTypePicker } 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" | "changeType" | "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 [closeRequest] = useAtom(propertyMenuCloseRequestAtom) as unknown as [number];
|
||||
const closeRequestRef = useRef(closeRequest);
|
||||
|
||||
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);
|
||||
onDirtyChange?.(dirty);
|
||||
},
|
||||
[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 handleRenameKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleRenameAndClose();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[handleRenameAndClose, onClose],
|
||||
);
|
||||
|
||||
const handleTypeChange = useCallback(
|
||||
(type: BasePropertyType) => {
|
||||
if (type !== property.type) {
|
||||
updatePropertyMutation.mutate({
|
||||
propertyId: property.id,
|
||||
baseId: property.baseId,
|
||||
type,
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
[property, updatePropertyMutation, onClose],
|
||||
);
|
||||
|
||||
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) {
|
||||
pendingActionRef.current = "back";
|
||||
setPanel("confirmDiscard");
|
||||
} else {
|
||||
setPanel("main");
|
||||
}
|
||||
}, [optionsDirty]);
|
||||
|
||||
const requestClose = useCallback(() => {
|
||||
if (panel === "options" && optionsDirty) {
|
||||
pendingActionRef.current = "close";
|
||||
setPanel("confirmDiscard");
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}, [panel, optionsDirty, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (closeRequest !== closeRequestRef.current) {
|
||||
closeRequestRef.current = closeRequest;
|
||||
if (opened) {
|
||||
requestClose();
|
||||
}
|
||||
}
|
||||
}, [closeRequest, opened, requestClose]);
|
||||
|
||||
const handleConfirmDiscard = useCallback(() => {
|
||||
setOptionsDirty(false);
|
||||
onDirtyChange?.(false);
|
||||
const action = pendingActionRef.current;
|
||||
pendingActionRef.current = null;
|
||||
if (action === "back") {
|
||||
setPanel("main");
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}, [onClose, onDirtyChange]);
|
||||
|
||||
const handleCancelDiscard = useCallback(() => {
|
||||
pendingActionRef.current = null;
|
||||
setPanel("options");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{panel === "main" && (
|
||||
<MainPanel
|
||||
property={property}
|
||||
onRename={() => setPanel("rename")}
|
||||
onChangeType={() => setPanel("changeType")}
|
||||
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}
|
||||
onBlur={commitRename}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
{panel === "changeType" && (
|
||||
<TypePanel
|
||||
currentType={property.type}
|
||||
onSelect={handleTypeChange}
|
||||
onBack={() => setPanel("main")}
|
||||
/>
|
||||
)}
|
||||
{(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,
|
||||
onChangeType,
|
||||
onOptions,
|
||||
onDelete,
|
||||
}: {
|
||||
property: IBaseProperty;
|
||||
onRename: () => void;
|
||||
onChangeType: () => 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");
|
||||
|
||||
return (
|
||||
<Stack gap={0} p={4}>
|
||||
<MenuItem
|
||||
icon={<IconPencil size={14} />}
|
||||
label={t("Rename")}
|
||||
onClick={onRename}
|
||||
/>
|
||||
{!isSystem && (
|
||||
<MenuItem
|
||||
icon={<IconTransform size={14} />}
|
||||
label={t("Change type")}
|
||||
rightIcon={<IconChevronRight size={14} />}
|
||||
onClick={onChangeType}
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
function TypePanel({
|
||||
currentType,
|
||||
onSelect,
|
||||
onBack,
|
||||
}: {
|
||||
currentType: BasePropertyType;
|
||||
onSelect: (type: BasePropertyType) => void;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap={0} p={4}>
|
||||
<Group gap="xs" px="sm" py={6}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="xs"
|
||||
onClick={onBack}
|
||||
>
|
||||
<IconChevronRight
|
||||
size={14}
|
||||
style={{ transform: "rotate(180deg)" }}
|
||||
/>
|
||||
</ActionIcon>
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{t("Change type")}
|
||||
</Text>
|
||||
</Group>
|
||||
<ScrollArea.Autosize mah={300}>
|
||||
<PropertyTypePicker
|
||||
onSelect={onSelect}
|
||||
currentType={currentType}
|
||||
/>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useCallback } from "react";
|
||||
import { Stack, NumberInput, Select, Switch, Text } from "@mantine/core";
|
||||
import {
|
||||
IBaseProperty,
|
||||
SelectTypeOptions,
|
||||
NumberTypeOptions,
|
||||
DateTypeOptions,
|
||||
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;
|
||||
};
|
||||
|
||||
export function PropertyOptions({ property, onUpdate, onClose, onDirtyChange }: PropertyOptionsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
switch (property.type) {
|
||||
case "select":
|
||||
case "multiSelect":
|
||||
return (
|
||||
<SelectOptions
|
||||
property={property}
|
||||
onUpdate={onUpdate}
|
||||
onClose={onClose}
|
||||
onDirtyChange={onDirtyChange}
|
||||
/>
|
||||
);
|
||||
case "status":
|
||||
return (
|
||||
<StatusOptions
|
||||
property={property}
|
||||
onUpdate={onUpdate}
|
||||
onClose={onClose}
|
||||
onDirtyChange={onDirtyChange}
|
||||
/>
|
||||
);
|
||||
case "number":
|
||||
return (
|
||||
<NumberOptions
|
||||
property={property}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
);
|
||||
case "date":
|
||||
return (
|
||||
<DateOptions
|
||||
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,
|
||||
}: {
|
||||
property: IBaseProperty;
|
||||
onUpdate: (typeOptions: Record<string, unknown>) => void;
|
||||
onClose: () => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}) {
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusOptions({
|
||||
property,
|
||||
onUpdate,
|
||||
onClose,
|
||||
onDirtyChange,
|
||||
}: {
|
||||
property: IBaseProperty;
|
||||
onUpdate: (typeOptions: Record<string, unknown>) => void;
|
||||
onClose: () => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}) {
|
||||
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
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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")}
|
||||
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 })
|
||||
}
|
||||
/>
|
||||
<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")}
|
||||
data={[
|
||||
{ value: "12h", label: "12-hour" },
|
||||
{ value: "24h", label: "24-hour" },
|
||||
]}
|
||||
value={options?.timeFormat ?? "12h"}
|
||||
onChange={(val) =>
|
||||
onUpdate({ ...property.typeOptions, timeFormat: val })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { UnstyledButton, Group, Text } from "@mantine/core";
|
||||
import {
|
||||
IconLetterT,
|
||||
IconHash,
|
||||
IconCircleDot,
|
||||
IconProgressCheck,
|
||||
IconTags,
|
||||
IconCalendar,
|
||||
IconUser,
|
||||
IconPaperclip,
|
||||
IconCheckbox,
|
||||
IconLink,
|
||||
IconMail,
|
||||
IconClockPlus,
|
||||
IconClockEdit,
|
||||
IconUserEdit,
|
||||
IconCheck,
|
||||
} from "@tabler/icons-react";
|
||||
import { BasePropertyType } from "@/features/base/types/base.types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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>;
|
||||
};
|
||||
|
||||
export function PropertyTypePicker({
|
||||
onSelect,
|
||||
currentType,
|
||||
excludeTypes,
|
||||
}: PropertyTypePickerProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const types = excludeTypes
|
||||
? propertyTypes.filter(({ type }) => !excludeTypes.has(type))
|
||||
: propertyTypes;
|
||||
|
||||
return (
|
||||
<>
|
||||
{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,146 @@
|
||||
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>;
|
||||
onPersist: () => void;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function ViewFieldVisibility({
|
||||
opened,
|
||||
onClose,
|
||||
table,
|
||||
onPersist,
|
||||
children,
|
||||
}: ViewFieldVisibilityProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return table
|
||||
.getAllLeafColumns()
|
||||
.filter((col) => col.id !== "__row_number");
|
||||
}, [table]);
|
||||
|
||||
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}
|
||||
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={() => {}}
|
||||
styles={{ track: { cursor: canHide ? "pointer" : "not-allowed" } }}
|
||||
/>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
Popover,
|
||||
Stack,
|
||||
Group,
|
||||
Select,
|
||||
TextInput,
|
||||
ActionIcon,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import {
|
||||
IBaseProperty,
|
||||
ViewFilterConfig,
|
||||
ViewFilterOperator,
|
||||
} from "@/features/base/types/base.types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const OPERATORS: { value: ViewFilterOperator; labelKey: string }[] = [
|
||||
{ value: "equals", labelKey: "Equals" },
|
||||
{ value: "notEquals", labelKey: "Not equals" },
|
||||
{ value: "contains", labelKey: "Contains" },
|
||||
{ value: "notContains", labelKey: "Not contains" },
|
||||
{ value: "isEmpty", labelKey: "Is empty" },
|
||||
{ value: "isNotEmpty", labelKey: "Is not empty" },
|
||||
{ value: "greaterThan", labelKey: "Greater than" },
|
||||
{ value: "lessThan", labelKey: "Less than" },
|
||||
{ value: "before", labelKey: "Before" },
|
||||
{ value: "after", labelKey: "After" },
|
||||
];
|
||||
|
||||
const NO_VALUE_OPERATORS: ViewFilterOperator[] = ["isEmpty", "isNotEmpty"];
|
||||
|
||||
type ViewFilterConfigProps = {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
filters: ViewFilterConfig[];
|
||||
properties: IBaseProperty[];
|
||||
onChange: (filters: ViewFilterConfig[]) => void;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function ViewFilterConfigPopover({
|
||||
opened,
|
||||
onClose,
|
||||
filters,
|
||||
properties,
|
||||
onChange,
|
||||
children,
|
||||
}: ViewFilterConfigProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const propertyOptions = properties.map((p) => ({
|
||||
value: p.id,
|
||||
label: p.name,
|
||||
}));
|
||||
|
||||
const operatorOptions = OPERATORS.map((op) => ({
|
||||
value: op.value,
|
||||
label: t(op.labelKey),
|
||||
}));
|
||||
|
||||
const handleAdd = useCallback(() => {
|
||||
const firstProperty = properties[0];
|
||||
if (!firstProperty) return;
|
||||
onChange([
|
||||
...filters,
|
||||
{ propertyId: firstProperty.id, operator: "contains" },
|
||||
]);
|
||||
}, [filters, properties, onChange]);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(index: number) => {
|
||||
onChange(filters.filter((_, i) => i !== index));
|
||||
},
|
||||
[filters, onChange],
|
||||
);
|
||||
|
||||
const handlePropertyChange = useCallback(
|
||||
(index: number, propertyId: string | null) => {
|
||||
if (!propertyId) return;
|
||||
onChange(
|
||||
filters.map((f, i) => (i === index ? { ...f, propertyId } : f)),
|
||||
);
|
||||
},
|
||||
[filters, onChange],
|
||||
);
|
||||
|
||||
const handleOperatorChange = useCallback(
|
||||
(index: number, operator: string | null) => {
|
||||
if (!operator) return;
|
||||
const op = operator as ViewFilterOperator;
|
||||
const needsValue = !NO_VALUE_OPERATORS.includes(op);
|
||||
onChange(
|
||||
filters.map((f, i) =>
|
||||
i === index
|
||||
? {
|
||||
...f,
|
||||
operator: op,
|
||||
value: needsValue ? f.value : undefined,
|
||||
}
|
||||
: f,
|
||||
),
|
||||
);
|
||||
},
|
||||
[filters, onChange],
|
||||
);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(index: number, value: string) => {
|
||||
onChange(
|
||||
filters.map((f, i) =>
|
||||
i === index ? { ...f, value: value || undefined } : f,
|
||||
),
|
||||
);
|
||||
},
|
||||
[filters, 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>
|
||||
|
||||
{filters.length === 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("No filters applied")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{filters.map((filter, index) => {
|
||||
const needsValue = !NO_VALUE_OPERATORS.includes(filter.operator);
|
||||
|
||||
return (
|
||||
<Group key={index} gap="xs" wrap="nowrap">
|
||||
<Select
|
||||
size="xs"
|
||||
data={propertyOptions}
|
||||
value={filter.propertyId}
|
||||
onChange={(val) => handlePropertyChange(index, val)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Select
|
||||
size="xs"
|
||||
data={operatorOptions}
|
||||
value={filter.operator}
|
||||
onChange={(val) => handleOperatorChange(index, val)}
|
||||
w={130}
|
||||
/>
|
||||
{needsValue && (
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder={t("Value")}
|
||||
value={(filter.value as string) ?? ""}
|
||||
onChange={(e) =>
|
||||
handleValueChange(index, e.currentTarget.value)
|
||||
}
|
||||
w={100}
|
||||
/>
|
||||
)}
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() => handleRemove(index)}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
|
||||
<UnstyledButton
|
||||
onClick={handleAdd}
|
||||
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,153 @@
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
Popover,
|
||||
Stack,
|
||||
Group,
|
||||
Select,
|
||||
ActionIcon,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { IconPlus, IconTrash, IconSortAscending } 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 propertyOptions = properties.map((p) => ({
|
||||
value: p.id,
|
||||
label: p.name,
|
||||
}));
|
||||
|
||||
const directionOptions = [
|
||||
{ value: "asc", label: t("Ascending") },
|
||||
{ value: "desc", label: t("Descending") },
|
||||
];
|
||||
|
||||
const handleAdd = useCallback(() => {
|
||||
const usedIds = new Set(sorts.map((s) => s.propertyId));
|
||||
const available = properties.find((p) => !usedIds.has(p.id));
|
||||
if (!available) return;
|
||||
onChange([...sorts, { propertyId: available.id, direction: "asc" }]);
|
||||
}, [sorts, properties, onChange]);
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
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 && (
|
||||
<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>
|
||||
))}
|
||||
|
||||
<UnstyledButton
|
||||
onClick={handleAdd}
|
||||
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,304 @@
|
||||
import { useMemo, useCallback, useRef, useState, useEffect } from "react";
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
getFilteredRowModel,
|
||||
createColumnHelper,
|
||||
ColumnDef,
|
||||
SortingState,
|
||||
ColumnSizingState,
|
||||
VisibilityState,
|
||||
ColumnOrderState,
|
||||
ColumnPinningState,
|
||||
Table,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
IBase,
|
||||
IBaseProperty,
|
||||
IBaseRow,
|
||||
IBaseView,
|
||||
ViewConfig,
|
||||
} from "@/features/base/types/base.types";
|
||||
import { useUpdateViewMutation } from "@/features/base/queries/base-view-query";
|
||||
|
||||
const DEFAULT_COLUMN_WIDTH = 180;
|
||||
const MIN_COLUMN_WIDTH = 80;
|
||||
const MAX_COLUMN_WIDTH = 600;
|
||||
const ROW_NUMBER_COLUMN_WIDTH = 50;
|
||||
|
||||
export const SYSTEM_PROPERTY_TYPES = new Set(["createdAt", "lastEditedAt", "lastEditedBy"]);
|
||||
|
||||
export function isSystemPropertyType(type: string): boolean {
|
||||
return SYSTEM_PROPERTY_TYPES.has(type);
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<IBaseRow>();
|
||||
|
||||
function getSystemAccessor(type: string): ((row: IBaseRow) => unknown) | null {
|
||||
switch (type) {
|
||||
case "createdAt":
|
||||
return (row) => row.createdAt;
|
||||
case "lastEditedAt":
|
||||
return (row) => row.updatedAt;
|
||||
case "lastEditedBy":
|
||||
return (row) => row.lastUpdatedById ?? row.creatorId;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildColumns(properties: IBaseProperty[]): ColumnDef<IBaseRow, unknown>[] {
|
||||
const rowNumberColumn = columnHelper.display({
|
||||
id: "__row_number",
|
||||
header: "#",
|
||||
size: ROW_NUMBER_COLUMN_WIDTH,
|
||||
minSize: ROW_NUMBER_COLUMN_WIDTH,
|
||||
maxSize: ROW_NUMBER_COLUMN_WIDTH,
|
||||
enableResizing: false,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
});
|
||||
|
||||
const propertyColumns = properties.map((property) => {
|
||||
const sysAccessor = getSystemAccessor(property.type);
|
||||
if (sysAccessor) {
|
||||
return columnHelper.accessor(sysAccessor, {
|
||||
id: property.id,
|
||||
header: property.name,
|
||||
size: DEFAULT_COLUMN_WIDTH,
|
||||
minSize: MIN_COLUMN_WIDTH,
|
||||
maxSize: MAX_COLUMN_WIDTH,
|
||||
enableResizing: true,
|
||||
enableSorting: false,
|
||||
enableHiding: !property.isPrimary,
|
||||
meta: { property },
|
||||
});
|
||||
}
|
||||
|
||||
return columnHelper.accessor((row) => row.cells[property.id], {
|
||||
id: property.id,
|
||||
header: property.name,
|
||||
size: DEFAULT_COLUMN_WIDTH,
|
||||
minSize: MIN_COLUMN_WIDTH,
|
||||
maxSize: MAX_COLUMN_WIDTH,
|
||||
enableResizing: true,
|
||||
enableSorting: true,
|
||||
enableHiding: !property.isPrimary,
|
||||
meta: { property },
|
||||
});
|
||||
});
|
||||
|
||||
return [rowNumberColumn, ...propertyColumns];
|
||||
}
|
||||
|
||||
function buildSortingState(config: ViewConfig | undefined): SortingState {
|
||||
if (!config?.sorts?.length) return [];
|
||||
return config.sorts.map((sort) => ({
|
||||
id: sort.propertyId,
|
||||
desc: sort.direction === "desc",
|
||||
}));
|
||||
}
|
||||
|
||||
function buildColumnSizing(
|
||||
config: ViewConfig | undefined,
|
||||
): ColumnSizingState {
|
||||
const sizing: ColumnSizingState = {
|
||||
__row_number: ROW_NUMBER_COLUMN_WIDTH,
|
||||
};
|
||||
if (config?.propertyWidths) {
|
||||
Object.entries(config.propertyWidths).forEach(([id, width]) => {
|
||||
sizing[id] = width;
|
||||
});
|
||||
}
|
||||
return sizing;
|
||||
}
|
||||
|
||||
function buildColumnVisibility(
|
||||
config: ViewConfig | undefined,
|
||||
properties: IBaseProperty[],
|
||||
): VisibilityState {
|
||||
const visibility: VisibilityState = { __row_number: true };
|
||||
|
||||
if (config?.hiddenPropertyIds) {
|
||||
const hiddenSet = new Set(config.hiddenPropertyIds);
|
||||
properties.forEach((p) => {
|
||||
visibility[p.id] = !hiddenSet.has(p.id);
|
||||
});
|
||||
return visibility;
|
||||
}
|
||||
|
||||
if (config?.visiblePropertyIds?.length) {
|
||||
const visibleSet = new Set(config.visiblePropertyIds);
|
||||
properties.forEach((p) => {
|
||||
visibility[p.id] = visibleSet.has(p.id);
|
||||
});
|
||||
return visibility;
|
||||
}
|
||||
|
||||
properties.forEach((p) => {
|
||||
visibility[p.id] = true;
|
||||
});
|
||||
return visibility;
|
||||
}
|
||||
|
||||
function buildColumnOrder(
|
||||
config: ViewConfig | undefined,
|
||||
properties: IBaseProperty[],
|
||||
): ColumnOrderState {
|
||||
if (config?.propertyOrder?.length) {
|
||||
const orderSet = new Set(config.propertyOrder);
|
||||
const missing = properties
|
||||
.filter((p) => !orderSet.has(p.id))
|
||||
.sort((a, b) => (a.position < b.position ? -1 : a.position > b.position ? 1 : 0))
|
||||
.map((p) => p.id);
|
||||
return ["__row_number", ...config.propertyOrder, ...missing];
|
||||
}
|
||||
const sorted = [...properties].sort((a, b) => {
|
||||
if (a.isPrimary) return -1;
|
||||
if (b.isPrimary) return 1;
|
||||
return a.position < b.position ? -1 : a.position > b.position ? 1 : 0;
|
||||
});
|
||||
return ["__row_number", ...sorted.map((p) => p.id)];
|
||||
}
|
||||
|
||||
function buildColumnPinning(
|
||||
properties: IBaseProperty[],
|
||||
): ColumnPinningState {
|
||||
const primary = properties.find((p) => p.isPrimary);
|
||||
return {
|
||||
left: primary ? ["__row_number", primary.id] : ["__row_number"],
|
||||
right: [],
|
||||
};
|
||||
}
|
||||
|
||||
export type UseBaseTableResult = {
|
||||
table: Table<IBaseRow>;
|
||||
persistViewConfig: () => void;
|
||||
};
|
||||
|
||||
export function useBaseTable(
|
||||
base: IBase | undefined,
|
||||
rows: IBaseRow[],
|
||||
activeView: IBaseView | undefined,
|
||||
): UseBaseTableResult {
|
||||
const updateViewMutation = useUpdateViewMutation();
|
||||
const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const properties = base?.properties ?? [];
|
||||
const viewConfig = activeView?.config;
|
||||
|
||||
const columns = useMemo(
|
||||
() => buildColumns(properties),
|
||||
[properties],
|
||||
);
|
||||
|
||||
const initialSorting = useMemo(
|
||||
() => buildSortingState(viewConfig),
|
||||
[viewConfig],
|
||||
);
|
||||
|
||||
const initialColumnSizing = useMemo(
|
||||
() => buildColumnSizing(viewConfig),
|
||||
[viewConfig],
|
||||
);
|
||||
|
||||
const derivedColumnOrder = useMemo(
|
||||
() => buildColumnOrder(viewConfig, properties),
|
||||
[viewConfig, properties],
|
||||
);
|
||||
|
||||
const derivedColumnVisibility = useMemo(
|
||||
() => buildColumnVisibility(viewConfig, properties),
|
||||
[viewConfig, properties],
|
||||
);
|
||||
|
||||
const [columnOrder, setColumnOrder] = useState<ColumnOrderState>(derivedColumnOrder);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(derivedColumnVisibility);
|
||||
|
||||
useEffect(() => {
|
||||
setColumnOrder(derivedColumnOrder);
|
||||
}, [derivedColumnOrder]);
|
||||
|
||||
useEffect(() => {
|
||||
setColumnVisibility(derivedColumnVisibility);
|
||||
}, [derivedColumnVisibility]);
|
||||
|
||||
const columnPinning = useMemo(
|
||||
() => buildColumnPinning(properties),
|
||||
[properties],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
columns,
|
||||
state: {
|
||||
columnPinning,
|
||||
columnOrder,
|
||||
columnVisibility,
|
||||
},
|
||||
onColumnOrderChange: setColumnOrder,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
initialState: {
|
||||
sorting: initialSorting,
|
||||
columnSizing: initialColumnSizing,
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
columnResizeMode: "onChange",
|
||||
enableColumnResizing: true,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
getRowId: (row) => row.id,
|
||||
});
|
||||
|
||||
const persistViewConfig = useCallback(() => {
|
||||
if (!activeView || !base) return;
|
||||
|
||||
if (persistTimerRef.current) {
|
||||
clearTimeout(persistTimerRef.current);
|
||||
}
|
||||
|
||||
persistTimerRef.current = setTimeout(() => {
|
||||
const state = table.getState();
|
||||
|
||||
const sorts = state.sorting.map((s) => ({
|
||||
propertyId: s.id,
|
||||
direction: (s.desc ? "desc" : "asc") as "asc" | "desc",
|
||||
}));
|
||||
|
||||
const propertyWidths: Record<string, number> = {};
|
||||
Object.entries(state.columnSizing).forEach(([id, width]) => {
|
||||
if (id !== "__row_number") {
|
||||
propertyWidths[id] = width;
|
||||
}
|
||||
});
|
||||
|
||||
const propertyOrder = state.columnOrder.filter(
|
||||
(id) => id !== "__row_number",
|
||||
);
|
||||
|
||||
const hiddenPropertyIds = Object.entries(state.columnVisibility)
|
||||
.filter(([id, visible]) => id !== "__row_number" && !visible)
|
||||
.map(([id]) => id);
|
||||
|
||||
const config: ViewConfig = {
|
||||
...activeView.config,
|
||||
sorts,
|
||||
propertyWidths,
|
||||
propertyOrder,
|
||||
hiddenPropertyIds,
|
||||
visiblePropertyIds: undefined,
|
||||
};
|
||||
|
||||
updateViewMutation.mutate({
|
||||
viewId: activeView.id,
|
||||
baseId: base.id,
|
||||
config,
|
||||
});
|
||||
}, 300);
|
||||
}, [activeView, base, table, updateViewMutation]);
|
||||
|
||||
return { table, persistViewConfig };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { Table } from "@tanstack/react-table";
|
||||
import { IBaseRow } from "@/features/base/types/base.types";
|
||||
|
||||
export function useColumnResize(
|
||||
table: Table<IBaseRow>,
|
||||
onResizeEnd: () => void,
|
||||
) {
|
||||
const wasResizingRef = useRef(false);
|
||||
|
||||
const checkResizeEnd = useCallback(() => {
|
||||
const isResizing = table.getState().columnSizingInfo.isResizingColumn;
|
||||
if (wasResizingRef.current && !isResizing) {
|
||||
onResizeEnd();
|
||||
}
|
||||
wasResizingRef.current = !!isResizing;
|
||||
}, [table, onResizeEnd]);
|
||||
|
||||
useEffect(() => {
|
||||
checkResizeEnd();
|
||||
});
|
||||
|
||||
return {
|
||||
isResizing: !!table.getState().columnSizingInfo.isResizingColumn,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { Table } from "@tanstack/react-table";
|
||||
import { IBaseRow, EditingCell } from "@/features/base/types/base.types";
|
||||
|
||||
type UseGridKeyboardNavOptions = {
|
||||
table: Table<IBaseRow>;
|
||||
editingCell: EditingCell;
|
||||
setEditingCell: (cell: EditingCell) => void;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
export function useGridKeyboardNav({
|
||||
table,
|
||||
editingCell,
|
||||
setEditingCell,
|
||||
containerRef,
|
||||
}: UseGridKeyboardNavOptions) {
|
||||
const getNavigableColumns = useCallback(() => {
|
||||
return table
|
||||
.getVisibleLeafColumns()
|
||||
.filter((col) => col.id !== "__row_number")
|
||||
.map((col) => col.id);
|
||||
}, [table]);
|
||||
|
||||
const getRowIds = useCallback(() => {
|
||||
return table.getRowModel().rows.map((row) => row.id);
|
||||
}, [table]);
|
||||
|
||||
const navigate = useCallback(
|
||||
(rowDelta: number, colDelta: number) => {
|
||||
if (!editingCell) return;
|
||||
|
||||
const columns = getNavigableColumns();
|
||||
const rowIds = getRowIds();
|
||||
|
||||
const currentColIndex = columns.indexOf(editingCell.propertyId);
|
||||
const currentRowIndex = rowIds.indexOf(editingCell.rowId);
|
||||
|
||||
if (currentColIndex === -1 || currentRowIndex === -1) return;
|
||||
|
||||
let nextColIndex = currentColIndex + colDelta;
|
||||
let nextRowIndex = currentRowIndex + rowDelta;
|
||||
|
||||
if (nextColIndex < 0) {
|
||||
nextColIndex = columns.length - 1;
|
||||
nextRowIndex -= 1;
|
||||
} else if (nextColIndex >= columns.length) {
|
||||
nextColIndex = 0;
|
||||
nextRowIndex += 1;
|
||||
}
|
||||
|
||||
if (nextRowIndex < 0 || nextRowIndex >= rowIds.length) return;
|
||||
|
||||
setEditingCell({
|
||||
rowId: rowIds[nextRowIndex],
|
||||
propertyId: columns[nextColIndex],
|
||||
});
|
||||
},
|
||||
[editingCell, getNavigableColumns, getRowIds, setEditingCell],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (!editingCell) return;
|
||||
|
||||
const target = e.target as HTMLElement;
|
||||
const isInputActive =
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.isContentEditable;
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowUp":
|
||||
if (!isInputActive) {
|
||||
e.preventDefault();
|
||||
navigate(-1, 0);
|
||||
}
|
||||
break;
|
||||
case "ArrowDown":
|
||||
if (!isInputActive) {
|
||||
e.preventDefault();
|
||||
navigate(1, 0);
|
||||
}
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
if (!isInputActive) {
|
||||
e.preventDefault();
|
||||
navigate(0, -1);
|
||||
}
|
||||
break;
|
||||
case "ArrowRight":
|
||||
if (!isInputActive) {
|
||||
e.preventDefault();
|
||||
navigate(0, 1);
|
||||
}
|
||||
break;
|
||||
case "Tab":
|
||||
e.preventDefault();
|
||||
navigate(0, e.shiftKey ? -1 : 1);
|
||||
break;
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
setEditingCell(null);
|
||||
break;
|
||||
}
|
||||
},
|
||||
[editingCell, navigate, setEditingCell],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
container.addEventListener("keydown", handleKeyDown);
|
||||
return () => container.removeEventListener("keydown", handleKeyDown);
|
||||
}, [containerRef, handleKeyDown]);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
|
||||
type RowDragState = {
|
||||
dragRowId: string | null;
|
||||
dropTargetRowId: string | null;
|
||||
dropPosition: "above" | "below" | null;
|
||||
};
|
||||
|
||||
type UseRowDragOptions = {
|
||||
rowIds: string[];
|
||||
onReorder: (rowId: string, targetRowId: string, position: "above" | "below") => void;
|
||||
};
|
||||
|
||||
export function useRowDrag({ rowIds, onReorder }: UseRowDragOptions) {
|
||||
const [dragState, setDragState] = useState<RowDragState>({
|
||||
dragRowId: null,
|
||||
dropTargetRowId: null,
|
||||
dropPosition: null,
|
||||
});
|
||||
|
||||
const dragRowIdRef = useRef<string | null>(null);
|
||||
const dropTargetRef = useRef<string | null>(null);
|
||||
const dropPositionRef = useRef<"above" | "below" | null>(null);
|
||||
const onReorderRef = useRef(onReorder);
|
||||
onReorderRef.current = onReorder;
|
||||
|
||||
const handleDragStart = useCallback((rowId: string) => {
|
||||
dragRowIdRef.current = rowId;
|
||||
dropTargetRef.current = null;
|
||||
dropPositionRef.current = null;
|
||||
setDragState({
|
||||
dragRowId: rowId,
|
||||
dropTargetRowId: null,
|
||||
dropPosition: null,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(targetRowId: string, e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (!dragRowIdRef.current || dragRowIdRef.current === targetRowId) return;
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const midY = rect.top + rect.height / 2;
|
||||
const position: "above" | "below" = e.clientY < midY ? "above" : "below";
|
||||
|
||||
if (dropTargetRef.current === targetRowId && dropPositionRef.current === position) {
|
||||
return;
|
||||
}
|
||||
|
||||
dropTargetRef.current = targetRowId;
|
||||
dropPositionRef.current = position;
|
||||
|
||||
setDragState({
|
||||
dragRowId: dragRowIdRef.current,
|
||||
dropTargetRowId: targetRowId,
|
||||
dropPosition: position,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
const dragRowId = dragRowIdRef.current;
|
||||
const dropTargetRowId = dropTargetRef.current;
|
||||
const dropPosition = dropPositionRef.current;
|
||||
|
||||
if (dragRowId && dropTargetRowId && dropPosition && dragRowId !== dropTargetRowId) {
|
||||
onReorderRef.current(dragRowId, dropTargetRowId, dropPosition);
|
||||
}
|
||||
|
||||
dragRowIdRef.current = null;
|
||||
dropTargetRef.current = null;
|
||||
dropPositionRef.current = null;
|
||||
setDragState({
|
||||
dragRowId: null,
|
||||
dropTargetRowId: null,
|
||||
dropPosition: null,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
dropTargetRef.current = null;
|
||||
dropPositionRef.current = null;
|
||||
setDragState((prev) => ({
|
||||
...prev,
|
||||
dropTargetRowId: null,
|
||||
dropPosition: null,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleGlobalDragEnd = () => {
|
||||
dragRowIdRef.current = null;
|
||||
dropTargetRef.current = null;
|
||||
dropPositionRef.current = null;
|
||||
setDragState({
|
||||
dragRowId: null,
|
||||
dropTargetRowId: null,
|
||||
dropPosition: null,
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("dragend", handleGlobalDragEnd);
|
||||
return () => document.removeEventListener("dragend", handleGlobalDragEnd);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
dragState,
|
||||
handleDragStart,
|
||||
handleDragOver,
|
||||
handleDragEnd,
|
||||
handleDragLeave,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
createProperty,
|
||||
updateProperty,
|
||||
deleteProperty,
|
||||
reorderProperty,
|
||||
} from "@/features/base/services/base-service";
|
||||
import {
|
||||
IBase,
|
||||
IBaseProperty,
|
||||
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";
|
||||
|
||||
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,
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
if (result.conversionSummary || variables.type) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["base-rows", variables.baseId],
|
||||
});
|
||||
}
|
||||
},
|
||||
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.invalidateQueries({
|
||||
queryKey: ["base-rows", variables.baseId],
|
||||
});
|
||||
},
|
||||
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,240 @@
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
InfiniteData,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
createRow,
|
||||
updateRow,
|
||||
deleteRow,
|
||||
listRows,
|
||||
reorderRow,
|
||||
} from "@/features/base/services/base-service";
|
||||
import {
|
||||
IBaseRow,
|
||||
CreateRowInput,
|
||||
UpdateRowInput,
|
||||
DeleteRowInput,
|
||||
ReorderRowInput,
|
||||
} 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";
|
||||
|
||||
type RowCacheContext = {
|
||||
previous: InfiniteData<IPagination<IBaseRow>> | undefined;
|
||||
};
|
||||
|
||||
export function useBaseRowsQuery(baseId: string | undefined) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["base-rows", baseId],
|
||||
queryFn: ({ pageParam }) =>
|
||||
listRows(baseId!, { cursor: pageParam, limit: 100 }),
|
||||
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),
|
||||
onSuccess: (newRow) => {
|
||||
queryClient.setQueryData<InfiniteData<IPagination<IBaseRow>>>(
|
||||
["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),
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["base-rows", variables.baseId],
|
||||
});
|
||||
|
||||
const previous = queryClient.getQueryData<
|
||||
InfiniteData<IPagination<IBaseRow>>
|
||||
>(["base-rows", variables.baseId]);
|
||||
|
||||
queryClient.setQueryData<InfiniteData<IPagination<IBaseRow>>>(
|
||||
["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 { previous };
|
||||
},
|
||||
onError: (_, variables, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(
|
||||
["base-rows", variables.baseId],
|
||||
context.previous,
|
||||
);
|
||||
}
|
||||
notifications.show({
|
||||
message: t("Failed to update row"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
onSuccess: (updatedRow) => {
|
||||
queryClient.setQueryData<InfiniteData<IPagination<IBaseRow>>>(
|
||||
["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),
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["base-rows", variables.baseId],
|
||||
});
|
||||
|
||||
const previous = queryClient.getQueryData<
|
||||
InfiniteData<IPagination<IBaseRow>>
|
||||
>(["base-rows", variables.baseId]);
|
||||
|
||||
queryClient.setQueryData<InfiniteData<IPagination<IBaseRow>>>(
|
||||
["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 { previous };
|
||||
},
|
||||
onError: (_, variables, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(
|
||||
["base-rows", variables.baseId],
|
||||
context.previous,
|
||||
);
|
||||
}
|
||||
notifications.show({
|
||||
message: t("Failed to delete row"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useReorderRowMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<void, Error, ReorderRowInput, RowCacheContext>({
|
||||
mutationFn: (data) => reorderRow(data),
|
||||
onMutate: async (variables) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["base-rows", variables.baseId],
|
||||
});
|
||||
|
||||
const previous = queryClient.getQueryData<
|
||||
InfiniteData<IPagination<IBaseRow>>
|
||||
>(["base-rows", variables.baseId]);
|
||||
|
||||
queryClient.setQueryData<InfiniteData<IPagination<IBaseRow>>>(
|
||||
["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 { previous };
|
||||
},
|
||||
onError: (_, variables, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(
|
||||
["base-rows", variables.baseId],
|
||||
context.previous,
|
||||
);
|
||||
}
|
||||
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,137 @@
|
||||
import api from "@/lib/api-client";
|
||||
import {
|
||||
IBase,
|
||||
IBaseProperty,
|
||||
IBaseRow,
|
||||
IBaseView,
|
||||
CreateBaseInput,
|
||||
UpdateBaseInput,
|
||||
CreatePropertyInput,
|
||||
UpdatePropertyInput,
|
||||
DeletePropertyInput,
|
||||
ReorderPropertyInput,
|
||||
CreateRowInput,
|
||||
UpdateRowInput,
|
||||
DeleteRowInput,
|
||||
ReorderRowInput,
|
||||
CreateViewInput,
|
||||
UpdateViewInput,
|
||||
DeleteViewInput,
|
||||
UpdatePropertyResult,
|
||||
} 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 listBases(
|
||||
spaceId: string,
|
||||
params?: { cursor?: string; limit?: number },
|
||||
): Promise<IPagination<IBase>> {
|
||||
const req = await api.post("/bases/list", { 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 listRows(
|
||||
baseId: string,
|
||||
params?: { viewId?: string; cursor?: string; limit?: number },
|
||||
): Promise<IPagination<IBaseRow>> {
|
||||
const req = await api.post("/bases/rows/list", { 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/list", { baseId });
|
||||
return req.data;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
.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));
|
||||
}
|
||||
|
||||
.personGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.personAvatar {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||
}
|
||||
|
||||
.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-0), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
.selectOptionActive {
|
||||
background-color: light-dark(var(--mantine-color-blue-0), var(--mantine-color-blue-9));
|
||||
}
|
||||
|
||||
.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));
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
.gridWrapper {
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.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));
|
||||
}
|
||||
|
||||
.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;
|
||||
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);
|
||||
}
|
||||
|
||||
.loadingOverlay {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--mantine-spacing-xl);
|
||||
}
|
||||
|
||||
.primaryCell {
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
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 TypeOptions =
|
||||
| SelectTypeOptions
|
||||
| NumberTypeOptions
|
||||
| DateTypeOptions
|
||||
| TextTypeOptions
|
||||
| CheckboxTypeOptions
|
||||
| UrlTypeOptions
|
||||
| EmailTypeOptions
|
||||
| Record<string, unknown>;
|
||||
|
||||
export type IBaseProperty = {
|
||||
id: string;
|
||||
baseId: string;
|
||||
name: string;
|
||||
type: BasePropertyType;
|
||||
position: string;
|
||||
typeOptions: TypeOptions;
|
||||
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';
|
||||
};
|
||||
|
||||
export type ViewFilterOperator =
|
||||
| 'equals'
|
||||
| 'notEquals'
|
||||
| 'contains'
|
||||
| 'notContains'
|
||||
| 'isEmpty'
|
||||
| 'isNotEmpty'
|
||||
| 'greaterThan'
|
||||
| 'lessThan'
|
||||
| 'before'
|
||||
| 'after';
|
||||
|
||||
export type ViewFilterConfig = {
|
||||
propertyId: string;
|
||||
operator: ViewFilterOperator;
|
||||
value?: unknown;
|
||||
};
|
||||
|
||||
export type ViewConfig = {
|
||||
sorts?: ViewSortConfig[];
|
||||
filters?: ViewFilterConfig[];
|
||||
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;
|
||||
};
|
||||
|
||||
export type UpdatePropertyInput = {
|
||||
propertyId: string;
|
||||
baseId: string;
|
||||
name?: string;
|
||||
type?: BasePropertyType;
|
||||
typeOptions?: TypeOptions;
|
||||
};
|
||||
|
||||
export type DeletePropertyInput = {
|
||||
propertyId: string;
|
||||
baseId: string;
|
||||
};
|
||||
|
||||
export type ReorderPropertyInput = {
|
||||
propertyId: string;
|
||||
baseId: string;
|
||||
position: string;
|
||||
};
|
||||
|
||||
export type CreateRowInput = {
|
||||
baseId: string;
|
||||
cells?: Record<string, unknown>;
|
||||
afterRowId?: string;
|
||||
};
|
||||
|
||||
export type UpdateRowInput = {
|
||||
rowId: string;
|
||||
baseId: string;
|
||||
cells: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type DeleteRowInput = {
|
||||
rowId: string;
|
||||
baseId: string;
|
||||
};
|
||||
|
||||
export type ReorderRowInput = {
|
||||
rowId: string;
|
||||
baseId: string;
|
||||
position: 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 ConversionSummary = {
|
||||
converted: number;
|
||||
cleared: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type UpdatePropertyResult = {
|
||||
property: IBaseProperty;
|
||||
conversionSummary: ConversionSummary | 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;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import RecentChanges from "@/components/common/recent-changes.tsx";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BaseTable } from "@/features/base/components/base-table.tsx";
|
||||
|
||||
export default function SpaceHomeTabs() {
|
||||
const { t } = useTranslation();
|
||||
@@ -22,6 +23,8 @@ export default function SpaceHomeTabs() {
|
||||
|
||||
<Space my="md" />
|
||||
|
||||
<BaseTable baseId={"019c69a5-1d84-7985-a7f6-8ee2871d8669"}/>
|
||||
|
||||
<Tabs.Panel value="recent">
|
||||
{space?.id && <RecentChanges spaceId={space.id} />}
|
||||
</Tabs.Panel>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -106,7 +106,8 @@
|
||||
"tseep": "^1.3.1",
|
||||
"typesense": "^2.1.0",
|
||||
"ws": "^8.19.0",
|
||||
"yauzl": "^3.2.0"
|
||||
"yauzl": "^3.2.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.20.0",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
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';
|
||||
|
||||
@Module({
|
||||
controllers: [
|
||||
BaseController,
|
||||
BasePropertyController,
|
||||
BaseRowController,
|
||||
BaseViewController,
|
||||
],
|
||||
providers: [BaseService, BasePropertyService, BaseRowService, BaseViewService],
|
||||
exports: [BaseService, BasePropertyService, BaseRowService, BaseViewService],
|
||||
})
|
||||
export class BaseModule {}
|
||||
@@ -0,0 +1,270 @@
|
||||
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.string().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.string().uuid()).default([]),
|
||||
disableColors: z.boolean().optional(),
|
||||
defaultValue: z
|
||||
.union([z.string().uuid(), z.array(z.string().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 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]: emptyTypeOptionsSchema,
|
||||
[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.SafeParseReturnType<unknown, 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.SafeParseError<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.string().uuid(),
|
||||
[BasePropertyType.STATUS]: z.string().uuid(),
|
||||
[BasePropertyType.MULTI_SELECT]: z.array(z.string().uuid()),
|
||||
[BasePropertyType.DATE]: z.string(),
|
||||
[BasePropertyType.PERSON]: z.array(z.string().uuid()),
|
||||
[BasePropertyType.FILE]: z.array(z.string().uuid()),
|
||||
[BasePropertyType.CHECKBOX]: z.boolean(),
|
||||
[BasePropertyType.URL]: z.string().url(),
|
||||
[BasePropertyType.EMAIL]: z.string().email(),
|
||||
};
|
||||
|
||||
export function getCellValueSchema(
|
||||
type: BasePropertyTypeValue,
|
||||
): z.ZodType | undefined {
|
||||
return cellValueSchemaMap[type];
|
||||
}
|
||||
|
||||
export function validateCellValue(
|
||||
type: BasePropertyTypeValue,
|
||||
value: unknown,
|
||||
): z.SafeParseReturnType<unknown, unknown> {
|
||||
const schema = cellValueSchemaMap[type];
|
||||
if (!schema) {
|
||||
return { success: false, error: new z.ZodError([{ code: 'custom', message: `Unknown property type: ${type}`, path: [] }]) } as z.SafeParseError<unknown>;
|
||||
}
|
||||
return schema.safeParse(value);
|
||||
}
|
||||
|
||||
export function attemptCellConversion(
|
||||
fromType: BasePropertyTypeValue,
|
||||
toType: BasePropertyTypeValue,
|
||||
value: unknown,
|
||||
): { converted: boolean; value: unknown } {
|
||||
if (value === null || value === undefined) {
|
||||
return { converted: true, value: null };
|
||||
}
|
||||
|
||||
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.string().uuid(),
|
||||
direction: z.enum(['asc', 'desc']),
|
||||
});
|
||||
|
||||
export const viewFilterSchema = z.object({
|
||||
propertyId: z.string().uuid(),
|
||||
operator: z.enum([
|
||||
'equals',
|
||||
'notEquals',
|
||||
'contains',
|
||||
'notContains',
|
||||
'isEmpty',
|
||||
'isNotEmpty',
|
||||
'greaterThan',
|
||||
'lessThan',
|
||||
'before',
|
||||
'after',
|
||||
]),
|
||||
value: z.unknown().optional(),
|
||||
});
|
||||
|
||||
export const viewConfigSchema = z
|
||||
.object({
|
||||
sorts: z.array(viewSortSchema).optional(),
|
||||
filters: z.array(viewFilterSchema).optional(),
|
||||
visiblePropertyIds: z.array(z.string().uuid()).optional(),
|
||||
hiddenPropertyIds: z.array(z.string().uuid()).optional(),
|
||||
propertyWidths: z.record(z.string(), z.number().positive()).optional(),
|
||||
propertyOrder: z.array(z.string().uuid()).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type ViewConfig = z.infer<typeof viewConfigSchema>;
|
||||
@@ -0,0 +1,105 @@
|
||||
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);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('update')
|
||||
async update(@Body() dto: UpdatePropertyDto, @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.basePropertyService.update(dto);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('delete')
|
||||
async delete(@Body() dto: DeletePropertyDto, @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();
|
||||
}
|
||||
|
||||
await this.basePropertyService.delete(dto);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('reorder')
|
||||
async reorder(@Body() dto: ReorderPropertyDto, @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();
|
||||
}
|
||||
|
||||
await this.basePropertyService.reorder(dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
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,
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('update')
|
||||
async update(@Body() dto: UpdateRowDto, @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.baseRowService.update(dto, user.id);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('delete')
|
||||
async delete(@Body() dto: DeleteRowDto, @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();
|
||||
}
|
||||
|
||||
await this.baseRowService.delete(dto.rowId, dto.baseId);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('list')
|
||||
async list(
|
||||
@Body() dto: ListRowsDto,
|
||||
@Body() pagination: PaginationOptions,
|
||||
@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.Read, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.baseRowService.list(dto, pagination);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('reorder')
|
||||
async reorder(@Body() dto: ReorderRowDto, @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();
|
||||
}
|
||||
|
||||
await this.baseRowService.reorder(dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('delete')
|
||||
async delete(@Body() dto: DeleteViewDto, @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();
|
||||
}
|
||||
|
||||
await this.baseViewService.delete(dto);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('list')
|
||||
async list(@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.Read, SpaceCaslSubject.Base)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.baseViewService.listByBaseId(dto.baseId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { BaseService } from '../services/base.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 { 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 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('list')
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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,14 @@
|
||||
import { IsObject, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateRowDto {
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
cells?: Record<string, unknown>;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
afterRowId?: 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,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,50 @@
|
||||
import {
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
import { BASE_PROPERTY_TYPES } from '../base.schemas';
|
||||
|
||||
export class UpdatePropertyDto {
|
||||
@IsUUID()
|
||||
propertyId: string;
|
||||
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(BASE_PROPERTY_TYPES)
|
||||
type?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
typeOptions?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class DeletePropertyDto {
|
||||
@IsUUID()
|
||||
propertyId: string;
|
||||
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
}
|
||||
|
||||
export class ReorderPropertyDto {
|
||||
@IsUUID()
|
||||
propertyId: string;
|
||||
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
position: string;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { IsNotEmpty, IsObject, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class UpdateRowDto {
|
||||
@IsUUID()
|
||||
rowId: string;
|
||||
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
|
||||
@IsObject()
|
||||
cells: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class DeleteRowDto {
|
||||
@IsUUID()
|
||||
rowId: string;
|
||||
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
}
|
||||
|
||||
export class RowIdDto {
|
||||
@IsUUID()
|
||||
rowId: string;
|
||||
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
}
|
||||
|
||||
export class ListRowsDto {
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
viewId?: string;
|
||||
}
|
||||
|
||||
export class ReorderRowDto {
|
||||
@IsUUID()
|
||||
rowId: string;
|
||||
|
||||
@IsUUID()
|
||||
baseId: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
position: 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,216 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { BasePropertyRepo } from '@docmost/db/repos/base/base-property.repo';
|
||||
import { BaseRowRepo } from '@docmost/db/repos/base/base-row.repo';
|
||||
import { CreatePropertyDto } from '../dto/create-property.dto';
|
||||
import {
|
||||
UpdatePropertyDto,
|
||||
DeletePropertyDto,
|
||||
ReorderPropertyDto,
|
||||
} from '../dto/update-property.dto';
|
||||
import {
|
||||
BasePropertyTypeValue,
|
||||
parseTypeOptions,
|
||||
attemptCellConversion,
|
||||
validateTypeOptions,
|
||||
isSystemPropertyType,
|
||||
} from '../base.schemas';
|
||||
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
||||
|
||||
@Injectable()
|
||||
export class BasePropertyService {
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly basePropertyRepo: BasePropertyRepo,
|
||||
private readonly baseRowRepo: BaseRowRepo,
|
||||
) {}
|
||||
|
||||
async create(workspaceId: string, dto: CreatePropertyDto) {
|
||||
const type = dto.type as BasePropertyTypeValue;
|
||||
let validatedTypeOptions = null;
|
||||
|
||||
if (dto.typeOptions) {
|
||||
validatedTypeOptions = parseTypeOptions(type, dto.typeOptions);
|
||||
} else {
|
||||
validatedTypeOptions = parseTypeOptions(type, {});
|
||||
}
|
||||
|
||||
const lastPosition = await this.basePropertyRepo.getLastPosition(
|
||||
dto.baseId,
|
||||
);
|
||||
const position = generateJitteredKeyBetween(lastPosition, null);
|
||||
|
||||
return this.basePropertyRepo.insertProperty({
|
||||
baseId: dto.baseId,
|
||||
name: dto.name,
|
||||
type: dto.type,
|
||||
position,
|
||||
typeOptions: validatedTypeOptions as any,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
async update(dto: UpdatePropertyDto) {
|
||||
const property = await this.basePropertyRepo.findById(dto.propertyId);
|
||||
if (!property) {
|
||||
throw new NotFoundException('Property not found');
|
||||
}
|
||||
|
||||
if (property.baseId !== dto.baseId) {
|
||||
throw new BadRequestException('Property does not belong to this base');
|
||||
}
|
||||
|
||||
const isTypeChange = dto.type && dto.type !== property.type;
|
||||
const newType = (dto.type ?? property.type) as BasePropertyTypeValue;
|
||||
|
||||
let validatedTypeOptions = property.typeOptions;
|
||||
if (dto.typeOptions !== undefined) {
|
||||
validatedTypeOptions = parseTypeOptions(newType, dto.typeOptions) as any;
|
||||
} else if (isTypeChange) {
|
||||
const result = validateTypeOptions(newType, {});
|
||||
validatedTypeOptions = result.success ? (result.data as any) : null;
|
||||
}
|
||||
|
||||
let conversionSummary: {
|
||||
converted: number;
|
||||
cleared: number;
|
||||
total: number;
|
||||
} | null = null;
|
||||
|
||||
if (isTypeChange) {
|
||||
const involvesSystem =
|
||||
isSystemPropertyType(property.type) || isSystemPropertyType(newType);
|
||||
|
||||
if (involvesSystem) {
|
||||
conversionSummary = await this.clearCellValues(
|
||||
dto.baseId,
|
||||
dto.propertyId,
|
||||
);
|
||||
} else {
|
||||
conversionSummary = await this.convertCellValues(
|
||||
dto.baseId,
|
||||
dto.propertyId,
|
||||
property.type as BasePropertyTypeValue,
|
||||
newType,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.basePropertyRepo.updateProperty(dto.propertyId, {
|
||||
...(dto.name !== undefined && { name: dto.name }),
|
||||
...(dto.type !== undefined && { type: dto.type }),
|
||||
typeOptions: validatedTypeOptions,
|
||||
});
|
||||
|
||||
const updatedProperty = await this.basePropertyRepo.findById(
|
||||
dto.propertyId,
|
||||
);
|
||||
|
||||
return { property: updatedProperty, conversionSummary };
|
||||
}
|
||||
|
||||
async delete(dto: DeletePropertyDto) {
|
||||
const property = await this.basePropertyRepo.findById(dto.propertyId);
|
||||
if (!property) {
|
||||
throw new NotFoundException('Property not found');
|
||||
}
|
||||
|
||||
if (property.baseId !== dto.baseId) {
|
||||
throw new BadRequestException('Property does not belong to this base');
|
||||
}
|
||||
|
||||
if (property.isPrimary) {
|
||||
throw new BadRequestException('Cannot delete the primary property');
|
||||
}
|
||||
|
||||
await executeTx(this.db, async (trx) => {
|
||||
await this.basePropertyRepo.deleteProperty(dto.propertyId, trx);
|
||||
await this.baseRowRepo.removeCellKey(dto.baseId, dto.propertyId, trx);
|
||||
});
|
||||
}
|
||||
|
||||
async reorder(dto: ReorderPropertyDto) {
|
||||
const property = await this.basePropertyRepo.findById(dto.propertyId);
|
||||
if (!property) {
|
||||
throw new NotFoundException('Property not found');
|
||||
}
|
||||
|
||||
if (property.baseId !== dto.baseId) {
|
||||
throw new BadRequestException('Property does not belong to this base');
|
||||
}
|
||||
|
||||
await this.basePropertyRepo.updateProperty(dto.propertyId, {
|
||||
position: dto.position,
|
||||
});
|
||||
}
|
||||
|
||||
private async clearCellValues(
|
||||
baseId: string,
|
||||
propertyId: string,
|
||||
): Promise<{ converted: number; cleared: number; total: number }> {
|
||||
const rows = await this.baseRowRepo.findAllByBaseId(baseId);
|
||||
const updates: Array<{ id: string; cells: Record<string, unknown> }> = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const cells = row.cells as Record<string, unknown>;
|
||||
if (propertyId in cells) {
|
||||
updates.push({ id: row.id, cells: { [propertyId]: null } });
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.length > 0) {
|
||||
await executeTx(this.db, async (trx) => {
|
||||
await this.baseRowRepo.batchUpdateCells(updates, trx);
|
||||
});
|
||||
}
|
||||
|
||||
return { converted: 0, cleared: updates.length, total: updates.length };
|
||||
}
|
||||
|
||||
private async convertCellValues(
|
||||
baseId: string,
|
||||
propertyId: string,
|
||||
fromType: BasePropertyTypeValue,
|
||||
toType: BasePropertyTypeValue,
|
||||
): Promise<{ converted: number; cleared: number; total: number }> {
|
||||
const rows = await this.baseRowRepo.findAllByBaseId(baseId);
|
||||
let converted = 0;
|
||||
let cleared = 0;
|
||||
let total = 0;
|
||||
|
||||
const updates: Array<{ id: string; cells: Record<string, unknown> }> = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const cells = row.cells as Record<string, unknown>;
|
||||
if (!(propertyId in cells)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
total++;
|
||||
const currentValue = cells[propertyId];
|
||||
const result = attemptCellConversion(fromType, toType, currentValue);
|
||||
|
||||
if (result.converted) {
|
||||
converted++;
|
||||
updates.push({ id: row.id, cells: { [propertyId]: result.value } });
|
||||
} else {
|
||||
cleared++;
|
||||
updates.push({ id: row.id, cells: { [propertyId]: null } });
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.length > 0) {
|
||||
await executeTx(this.db, async (trx) => {
|
||||
await this.baseRowRepo.batchUpdateCells(updates, trx);
|
||||
});
|
||||
}
|
||||
|
||||
return { converted, cleared, total };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { BaseRowRepo } from '@docmost/db/repos/base/base-row.repo';
|
||||
import { BasePropertyRepo } from '@docmost/db/repos/base/base-property.repo';
|
||||
import { BaseViewRepo } from '@docmost/db/repos/base/base-view.repo';
|
||||
import { CreateRowDto } from '../dto/create-row.dto';
|
||||
import {
|
||||
UpdateRowDto,
|
||||
ListRowsDto,
|
||||
ReorderRowDto,
|
||||
} from '../dto/update-row.dto';
|
||||
import {
|
||||
BasePropertyTypeValue,
|
||||
validateCellValue,
|
||||
isSystemPropertyType,
|
||||
} from '../base.schemas';
|
||||
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { BaseProperty } from '@docmost/db/types/entity.types';
|
||||
|
||||
@Injectable()
|
||||
export class BaseRowService {
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly baseRowRepo: BaseRowRepo,
|
||||
private readonly basePropertyRepo: BasePropertyRepo,
|
||||
private readonly baseViewRepo: BaseViewRepo,
|
||||
) {}
|
||||
|
||||
async create(userId: string, workspaceId: string, dto: CreateRowDto) {
|
||||
let position: string;
|
||||
|
||||
if (dto.afterRowId) {
|
||||
const afterRow = await this.baseRowRepo.findById(dto.afterRowId);
|
||||
if (!afterRow || afterRow.baseId !== dto.baseId) {
|
||||
throw new BadRequestException('Invalid afterRowId');
|
||||
}
|
||||
position = generateJitteredKeyBetween(afterRow.position, null);
|
||||
} else {
|
||||
const lastPosition = await this.baseRowRepo.getLastPosition(dto.baseId);
|
||||
position = generateJitteredKeyBetween(lastPosition, null);
|
||||
}
|
||||
|
||||
let validatedCells: Record<string, unknown> = {};
|
||||
if (dto.cells && Object.keys(dto.cells).length > 0) {
|
||||
const properties = await this.basePropertyRepo.findByBaseId(dto.baseId);
|
||||
validatedCells = this.validateCells(dto.cells, properties);
|
||||
}
|
||||
|
||||
return this.baseRowRepo.insertRow({
|
||||
baseId: dto.baseId,
|
||||
cells: validatedCells as any,
|
||||
position,
|
||||
creatorId: userId,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
async getRowInfo(rowId: string, baseId: string) {
|
||||
const row = await this.baseRowRepo.findById(rowId);
|
||||
if (!row || row.baseId !== baseId) {
|
||||
throw new NotFoundException('Row not found');
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
async update(dto: UpdateRowDto, userId?: string) {
|
||||
const row = await this.baseRowRepo.findById(dto.rowId);
|
||||
if (!row || row.baseId !== dto.baseId) {
|
||||
throw new NotFoundException('Row not found');
|
||||
}
|
||||
|
||||
const properties = await this.basePropertyRepo.findByBaseId(dto.baseId);
|
||||
const validatedCells = this.validateCells(dto.cells, properties);
|
||||
|
||||
await this.baseRowRepo.updateCells(dto.rowId, validatedCells, userId);
|
||||
|
||||
return this.baseRowRepo.findById(dto.rowId);
|
||||
}
|
||||
|
||||
async delete(rowId: string, baseId: string) {
|
||||
const row = await this.baseRowRepo.findById(rowId);
|
||||
if (!row || row.baseId !== baseId) {
|
||||
throw new NotFoundException('Row not found');
|
||||
}
|
||||
|
||||
await this.baseRowRepo.softDelete(rowId);
|
||||
}
|
||||
|
||||
async list(dto: ListRowsDto, pagination: PaginationOptions) {
|
||||
return this.baseRowRepo.findByBaseId(dto.baseId, pagination);
|
||||
}
|
||||
|
||||
async reorder(dto: ReorderRowDto) {
|
||||
const row = await this.baseRowRepo.findById(dto.rowId);
|
||||
if (!row || row.baseId !== dto.baseId) {
|
||||
throw new NotFoundException('Row not found');
|
||||
}
|
||||
|
||||
try {
|
||||
generateJitteredKeyBetween(dto.position, null);
|
||||
} catch {
|
||||
throw new BadRequestException('Invalid position value');
|
||||
}
|
||||
|
||||
await this.baseRowRepo.updatePosition(dto.rowId, dto.position);
|
||||
}
|
||||
|
||||
private validateCells(
|
||||
cells: Record<string, unknown>,
|
||||
properties: BaseProperty[],
|
||||
): Record<string, unknown> {
|
||||
const propertyMap = new Map(properties.map((p) => [p.id, p]));
|
||||
const validatedCells: Record<string, unknown> = {};
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const [propertyId, value] of Object.entries(cells)) {
|
||||
const property = propertyMap.get(propertyId);
|
||||
if (!property) {
|
||||
errors.push(`Unknown property: ${propertyId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isSystemPropertyType(property.type)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
validatedCells[propertyId] = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = validateCellValue(
|
||||
property.type as BasePropertyTypeValue,
|
||||
value,
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
errors.push(
|
||||
`Invalid value for property "${property.name}" (${property.type}): ${result.error.issues[0]?.message}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
validatedCells[propertyId] = result.data;
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new BadRequestException({
|
||||
message: 'Cell validation failed',
|
||||
errors,
|
||||
});
|
||||
}
|
||||
|
||||
return validatedCells;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { BaseViewRepo } from '@docmost/db/repos/base/base-view.repo';
|
||||
import { CreateViewDto } from '../dto/create-view.dto';
|
||||
import { UpdateViewDto, DeleteViewDto } from '../dto/update-view.dto';
|
||||
import { viewConfigSchema } from '../base.schemas';
|
||||
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
||||
|
||||
@Injectable()
|
||||
export class BaseViewService {
|
||||
constructor(private readonly baseViewRepo: BaseViewRepo) {}
|
||||
|
||||
async create(userId: string, workspaceId: string, dto: CreateViewDto) {
|
||||
let validatedConfig = {};
|
||||
if (dto.config) {
|
||||
const result = viewConfigSchema.safeParse(dto.config);
|
||||
if (!result.success) {
|
||||
throw new BadRequestException({
|
||||
message: 'Invalid view config',
|
||||
errors: result.error.issues.map((i) => i.message),
|
||||
});
|
||||
}
|
||||
validatedConfig = result.data;
|
||||
}
|
||||
|
||||
const lastPosition = await this.baseViewRepo.getLastPosition(dto.baseId);
|
||||
const position = generateJitteredKeyBetween(lastPosition, null);
|
||||
|
||||
return this.baseViewRepo.insertView({
|
||||
baseId: dto.baseId,
|
||||
name: dto.name,
|
||||
type: dto.type ?? 'table',
|
||||
position,
|
||||
config: validatedConfig as any,
|
||||
workspaceId,
|
||||
creatorId: userId,
|
||||
});
|
||||
}
|
||||
|
||||
async update(dto: UpdateViewDto) {
|
||||
const view = await this.baseViewRepo.findById(dto.viewId);
|
||||
if (!view) {
|
||||
throw new NotFoundException('View not found');
|
||||
}
|
||||
|
||||
if (view.baseId !== dto.baseId) {
|
||||
throw new BadRequestException('View does not belong to this base');
|
||||
}
|
||||
|
||||
let validatedConfig = undefined;
|
||||
if (dto.config !== undefined) {
|
||||
const result = viewConfigSchema.safeParse(dto.config);
|
||||
if (!result.success) {
|
||||
throw new BadRequestException({
|
||||
message: 'Invalid view config',
|
||||
errors: result.error.issues.map((i) => i.message),
|
||||
});
|
||||
}
|
||||
validatedConfig = result.data;
|
||||
}
|
||||
|
||||
await this.baseViewRepo.updateView(dto.viewId, {
|
||||
...(dto.name !== undefined && { name: dto.name }),
|
||||
...(dto.type !== undefined && { type: dto.type }),
|
||||
...(validatedConfig !== undefined && { config: validatedConfig as any }),
|
||||
});
|
||||
|
||||
return this.baseViewRepo.findById(dto.viewId);
|
||||
}
|
||||
|
||||
async delete(dto: DeleteViewDto) {
|
||||
const view = await this.baseViewRepo.findById(dto.viewId);
|
||||
if (!view) {
|
||||
throw new NotFoundException('View not found');
|
||||
}
|
||||
|
||||
if (view.baseId !== dto.baseId) {
|
||||
throw new BadRequestException('View does not belong to this base');
|
||||
}
|
||||
|
||||
const viewCount = await this.baseViewRepo.countByBaseId(dto.baseId);
|
||||
if (viewCount <= 1) {
|
||||
throw new BadRequestException('Cannot delete the last view');
|
||||
}
|
||||
|
||||
await this.baseViewRepo.deleteView(dto.viewId);
|
||||
}
|
||||
|
||||
async listByBaseId(baseId: string) {
|
||||
return this.baseViewRepo.findByBaseId(baseId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { BaseRepo } from '@docmost/db/repos/base/base.repo';
|
||||
import { BasePropertyRepo } from '@docmost/db/repos/base/base-property.repo';
|
||||
import { BaseViewRepo } from '@docmost/db/repos/base/base-view.repo';
|
||||
import { CreateBaseDto } from '../dto/create-base.dto';
|
||||
import { UpdateBaseDto } from '../dto/update-base.dto';
|
||||
import { BasePropertyType } from '../base.schemas';
|
||||
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
|
||||
@Injectable()
|
||||
export class BaseService {
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly baseRepo: BaseRepo,
|
||||
private readonly basePropertyRepo: BasePropertyRepo,
|
||||
private readonly baseViewRepo: BaseViewRepo,
|
||||
) {}
|
||||
|
||||
async create(userId: string, workspaceId: string, dto: CreateBaseDto) {
|
||||
return executeTx(this.db, async (trx) => {
|
||||
const base = await this.baseRepo.insertBase(
|
||||
{
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
icon: dto.icon,
|
||||
pageId: dto.pageId,
|
||||
spaceId: dto.spaceId,
|
||||
workspaceId,
|
||||
creatorId: userId,
|
||||
},
|
||||
trx,
|
||||
);
|
||||
|
||||
const firstPosition = generateJitteredKeyBetween(null, null);
|
||||
|
||||
await this.basePropertyRepo.insertProperty(
|
||||
{
|
||||
baseId: base.id,
|
||||
name: 'Title',
|
||||
type: BasePropertyType.TEXT,
|
||||
position: firstPosition,
|
||||
isPrimary: true,
|
||||
workspaceId,
|
||||
},
|
||||
trx,
|
||||
);
|
||||
|
||||
await this.baseViewRepo.insertView(
|
||||
{
|
||||
baseId: base.id,
|
||||
name: 'Table View 1',
|
||||
type: 'table',
|
||||
position: firstPosition,
|
||||
workspaceId,
|
||||
creatorId: userId,
|
||||
},
|
||||
trx,
|
||||
);
|
||||
|
||||
return this.baseRepo.findById(base.id, {
|
||||
includeProperties: true,
|
||||
includeViews: true,
|
||||
trx,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getBaseInfo(baseId: string) {
|
||||
const base = await this.baseRepo.findById(baseId, {
|
||||
includeProperties: true,
|
||||
includeViews: true,
|
||||
});
|
||||
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
async update(dto: UpdateBaseDto) {
|
||||
const base = await this.baseRepo.findById(dto.baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
await this.baseRepo.updateBase(dto.baseId, {
|
||||
...(dto.name !== undefined && { name: dto.name }),
|
||||
...(dto.description !== undefined && { description: dto.description }),
|
||||
...(dto.icon !== undefined && { icon: dto.icon }),
|
||||
});
|
||||
|
||||
return this.baseRepo.findById(dto.baseId);
|
||||
}
|
||||
|
||||
async delete(baseId: string) {
|
||||
const base = await this.baseRepo.findById(baseId);
|
||||
if (!base) {
|
||||
throw new NotFoundException('Base not found');
|
||||
}
|
||||
|
||||
await this.baseRepo.softDelete(baseId);
|
||||
}
|
||||
|
||||
async listBySpaceId(spaceId: string, pagination: PaginationOptions) {
|
||||
return this.baseRepo.findBySpaceId(spaceId, pagination);
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ function buildSpaceAdminAbility() {
|
||||
can(SpaceCaslAction.Manage, SpaceCaslSubject.Member);
|
||||
can(SpaceCaslAction.Manage, SpaceCaslSubject.Page);
|
||||
can(SpaceCaslAction.Manage, SpaceCaslSubject.Share);
|
||||
can(SpaceCaslAction.Manage, SpaceCaslSubject.Base);
|
||||
return build();
|
||||
}
|
||||
|
||||
@@ -57,6 +58,7 @@ function buildSpaceWriterAbility() {
|
||||
can(SpaceCaslAction.Read, SpaceCaslSubject.Member);
|
||||
can(SpaceCaslAction.Manage, SpaceCaslSubject.Page);
|
||||
can(SpaceCaslAction.Manage, SpaceCaslSubject.Share);
|
||||
can(SpaceCaslAction.Manage, SpaceCaslSubject.Base);
|
||||
return build();
|
||||
}
|
||||
|
||||
@@ -68,5 +70,6 @@ function buildSpaceReaderAbility() {
|
||||
can(SpaceCaslAction.Read, SpaceCaslSubject.Member);
|
||||
can(SpaceCaslAction.Read, SpaceCaslSubject.Page);
|
||||
can(SpaceCaslAction.Read, SpaceCaslSubject.Share);
|
||||
can(SpaceCaslAction.Read, SpaceCaslSubject.Base);
|
||||
return build();
|
||||
}
|
||||
|
||||
@@ -10,10 +10,12 @@ export enum SpaceCaslSubject {
|
||||
Member = 'member',
|
||||
Page = 'page',
|
||||
Share = 'share',
|
||||
Base = 'base',
|
||||
}
|
||||
|
||||
export type ISpaceAbility =
|
||||
| [SpaceCaslAction, SpaceCaslSubject.Settings]
|
||||
| [SpaceCaslAction, SpaceCaslSubject.Member]
|
||||
| [SpaceCaslAction, SpaceCaslSubject.Page]
|
||||
| [SpaceCaslAction, SpaceCaslSubject.Share];
|
||||
| [SpaceCaslAction, SpaceCaslSubject.Share]
|
||||
| [SpaceCaslAction, SpaceCaslSubject.Base];
|
||||
|
||||
@@ -18,6 +18,7 @@ import { DomainMiddleware } from '../common/middlewares/domain.middleware';
|
||||
import { ShareModule } from './share/share.module';
|
||||
import { NotificationModule } from './notification/notification.module';
|
||||
import { WatcherModule } from './watcher/watcher.module';
|
||||
import { BaseModule } from './base/base.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -34,6 +35,7 @@ import { WatcherModule } from './watcher/watcher.module';
|
||||
ShareModule,
|
||||
NotificationModule,
|
||||
WatcherModule,
|
||||
BaseModule,
|
||||
],
|
||||
})
|
||||
export class CoreModule implements NestModule {
|
||||
|
||||
@@ -27,6 +27,10 @@ import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
|
||||
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { PageListener } from '@docmost/db/listeners/page.listener';
|
||||
import { BaseRepo } from '@docmost/db/repos/base/base.repo';
|
||||
import { BasePropertyRepo } from '@docmost/db/repos/base/base-property.repo';
|
||||
import { BaseRowRepo } from '@docmost/db/repos/base/base-row.repo';
|
||||
import { BaseViewRepo } from '@docmost/db/repos/base/base-view.repo';
|
||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||
import * as postgres from 'postgres';
|
||||
import { normalizePostgresUrl } from '../common/helpers';
|
||||
@@ -85,6 +89,10 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
NotificationRepo,
|
||||
WatcherRepo,
|
||||
PageListener,
|
||||
BaseRepo,
|
||||
BasePropertyRepo,
|
||||
BaseRowRepo,
|
||||
BaseViewRepo,
|
||||
],
|
||||
exports: [
|
||||
WorkspaceRepo,
|
||||
@@ -102,6 +110,10 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
ShareRepo,
|
||||
NotificationRepo,
|
||||
WatcherRepo,
|
||||
BaseRepo,
|
||||
BasePropertyRepo,
|
||||
BaseRowRepo,
|
||||
BaseViewRepo,
|
||||
],
|
||||
})
|
||||
export class DatabaseModule
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { type Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('bases')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('name', 'varchar', (col) => col.notNull())
|
||||
.addColumn('description', 'varchar')
|
||||
.addColumn('icon', 'varchar')
|
||||
.addColumn('page_id', 'uuid', (col) =>
|
||||
col.references('pages.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('space_id', 'uuid', (col) =>
|
||||
col.references('spaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('creator_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('deleted_at', 'timestamptz')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_bases_space_id')
|
||||
.on('bases')
|
||||
.column('space_id')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createTable('base_properties')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('base_id', 'uuid', (col) =>
|
||||
col.references('bases.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('name', 'varchar', (col) => col.notNull())
|
||||
.addColumn('type', 'varchar', (col) => col.notNull())
|
||||
.addColumn('position', 'varchar', (col) => col.notNull())
|
||||
.addColumn('type_options', 'jsonb')
|
||||
.addColumn('is_primary', 'boolean', (col) =>
|
||||
col.notNull().defaultTo(false),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_base_properties_base_id')
|
||||
.on('base_properties')
|
||||
.column('base_id')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createTable('base_rows')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('base_id', 'uuid', (col) =>
|
||||
col.references('bases.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('cells', 'jsonb', (col) =>
|
||||
col.notNull().defaultTo(sql`'{}'::jsonb`),
|
||||
)
|
||||
.addColumn('position', 'varchar', (col) => col.notNull())
|
||||
.addColumn('creator_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('last_updated_by_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('deleted_at', 'timestamptz')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_base_rows_base_id')
|
||||
.on('base_rows')
|
||||
.column('base_id')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_base_rows_cells_gin')
|
||||
.on('base_rows')
|
||||
.using('gin')
|
||||
.column('cells')
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createTable('base_views')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('base_id', 'uuid', (col) =>
|
||||
col.references('bases.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('name', 'varchar', (col) => col.notNull())
|
||||
.addColumn('type', 'varchar', (col) => col.notNull().defaultTo('table'))
|
||||
.addColumn('position', 'varchar', (col) => col.notNull())
|
||||
.addColumn('config', 'jsonb', (col) =>
|
||||
col.notNull().defaultTo(sql`'{}'::jsonb`),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('creator_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_base_views_base_id')
|
||||
.on('base_views')
|
||||
.column('base_id')
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable('base_views').execute();
|
||||
await db.schema.dropTable('base_rows').execute();
|
||||
await db.schema.dropTable('base_properties').execute();
|
||||
await db.schema.dropTable('bases').execute();
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
|
||||
import { dbOrTx } from '../../utils';
|
||||
import {
|
||||
BaseProperty,
|
||||
InsertableBaseProperty,
|
||||
UpdatableBaseProperty,
|
||||
} from '@docmost/db/types/entity.types';
|
||||
import { sql } from 'kysely';
|
||||
|
||||
@Injectable()
|
||||
export class BasePropertyRepo {
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
async findById(
|
||||
propertyId: string,
|
||||
opts?: { trx?: KyselyTransaction },
|
||||
): Promise<BaseProperty | undefined> {
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
return db
|
||||
.selectFrom('baseProperties')
|
||||
.selectAll()
|
||||
.where('id', '=', propertyId)
|
||||
.executeTakeFirst() as Promise<BaseProperty | undefined>;
|
||||
}
|
||||
|
||||
async findByBaseId(
|
||||
baseId: string,
|
||||
opts?: { trx?: KyselyTransaction },
|
||||
): Promise<BaseProperty[]> {
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
return db
|
||||
.selectFrom('baseProperties')
|
||||
.selectAll()
|
||||
.where('baseId', '=', baseId)
|
||||
.orderBy('position', 'asc')
|
||||
.execute() as Promise<BaseProperty[]>;
|
||||
}
|
||||
|
||||
async getLastPosition(
|
||||
baseId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<string | null> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
const result = await db
|
||||
.selectFrom('baseProperties')
|
||||
.select('position')
|
||||
.where('baseId', '=', baseId)
|
||||
.orderBy(sql`position COLLATE "C"`, sql`DESC`)
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
return result?.position ?? null;
|
||||
}
|
||||
|
||||
async insertProperty(
|
||||
property: InsertableBaseProperty,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<BaseProperty> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.insertInto('baseProperties')
|
||||
.values(property)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow() as Promise<BaseProperty>;
|
||||
}
|
||||
|
||||
async updateProperty(
|
||||
propertyId: string,
|
||||
data: UpdatableBaseProperty,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.updateTable('baseProperties')
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where('id', '=', propertyId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteProperty(
|
||||
propertyId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.deleteFrom('baseProperties')
|
||||
.where('id', '=', propertyId)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
|
||||
import { dbOrTx } from '../../utils';
|
||||
import {
|
||||
BaseRow,
|
||||
InsertableBaseRow,
|
||||
} from '@docmost/db/types/entity.types';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { sql } from 'kysely';
|
||||
|
||||
@Injectable()
|
||||
export class BaseRowRepo {
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
async findById(
|
||||
rowId: string,
|
||||
opts?: { trx?: KyselyTransaction },
|
||||
): Promise<BaseRow | undefined> {
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
return db
|
||||
.selectFrom('baseRows')
|
||||
.selectAll()
|
||||
.where('id', '=', rowId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.executeTakeFirst() as Promise<BaseRow | undefined>;
|
||||
}
|
||||
|
||||
async findByBaseId(
|
||||
baseId: string,
|
||||
pagination: PaginationOptions,
|
||||
opts?: { trx?: KyselyTransaction },
|
||||
) {
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
|
||||
const query = db
|
||||
.selectFrom('baseRows')
|
||||
.selectAll()
|
||||
.where('baseId', '=', baseId)
|
||||
.where('deletedAt', 'is', null);
|
||||
|
||||
return executeWithCursorPagination(query, {
|
||||
perPage: pagination.limit,
|
||||
cursor: pagination.cursor,
|
||||
beforeCursor: pagination.beforeCursor,
|
||||
fields: [
|
||||
{ expression: 'position', direction: 'asc' },
|
||||
{ expression: 'id', direction: 'asc' },
|
||||
],
|
||||
parseCursor: (cursor) => ({
|
||||
position: cursor.position,
|
||||
id: cursor.id,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async getLastPosition(
|
||||
baseId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<string | null> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
const result = await db
|
||||
.selectFrom('baseRows')
|
||||
.select('position')
|
||||
.where('baseId', '=', baseId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.orderBy(sql`position COLLATE "C"`, sql`DESC`)
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
return result?.position ?? null;
|
||||
}
|
||||
|
||||
async insertRow(
|
||||
row: InsertableBaseRow,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<BaseRow> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.insertInto('baseRows')
|
||||
.values(row)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow() as Promise<BaseRow>;
|
||||
}
|
||||
|
||||
async updateCells(
|
||||
rowId: string,
|
||||
cells: Record<string, unknown>,
|
||||
userId?: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.updateTable('baseRows')
|
||||
.set({
|
||||
cells: sql`cells || ${cells}`,
|
||||
updatedAt: new Date(),
|
||||
lastUpdatedById: userId ?? null,
|
||||
})
|
||||
.where('id', '=', rowId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async updatePosition(
|
||||
rowId: string,
|
||||
position: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.updateTable('baseRows')
|
||||
.set({ position, updatedAt: new Date() })
|
||||
.where('id', '=', rowId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async softDelete(rowId: string, trx?: KyselyTransaction): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.updateTable('baseRows')
|
||||
.set({ deletedAt: new Date() })
|
||||
.where('id', '=', rowId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async removeCellKey(
|
||||
baseId: string,
|
||||
propertyId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.updateTable('baseRows')
|
||||
.set({
|
||||
cells: sql`cells - ${propertyId}`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where('baseId', '=', baseId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async findAllByBaseId(
|
||||
baseId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<BaseRow[]> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.selectFrom('baseRows')
|
||||
.selectAll()
|
||||
.where('baseId', '=', baseId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.execute() as Promise<BaseRow[]>;
|
||||
}
|
||||
|
||||
async batchUpdateCells(
|
||||
updates: Array<{ id: string; cells: Record<string, unknown> }>,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
for (const update of updates) {
|
||||
await db
|
||||
.updateTable('baseRows')
|
||||
.set({
|
||||
cells: sql`cells || ${update.cells}`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where('id', '=', update.id)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
|
||||
import { dbOrTx } from '../../utils';
|
||||
import {
|
||||
BaseView,
|
||||
InsertableBaseView,
|
||||
UpdatableBaseView,
|
||||
} from '@docmost/db/types/entity.types';
|
||||
import { sql } from 'kysely';
|
||||
|
||||
@Injectable()
|
||||
export class BaseViewRepo {
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
async findById(
|
||||
viewId: string,
|
||||
opts?: { trx?: KyselyTransaction },
|
||||
): Promise<BaseView | undefined> {
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
return db
|
||||
.selectFrom('baseViews')
|
||||
.selectAll()
|
||||
.where('id', '=', viewId)
|
||||
.executeTakeFirst() as Promise<BaseView | undefined>;
|
||||
}
|
||||
|
||||
async findByBaseId(
|
||||
baseId: string,
|
||||
opts?: { trx?: KyselyTransaction },
|
||||
): Promise<BaseView[]> {
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
return db
|
||||
.selectFrom('baseViews')
|
||||
.selectAll()
|
||||
.where('baseId', '=', baseId)
|
||||
.orderBy('position', 'asc')
|
||||
.execute() as Promise<BaseView[]>;
|
||||
}
|
||||
|
||||
async countByBaseId(
|
||||
baseId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<number> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
const result = await db
|
||||
.selectFrom('baseViews')
|
||||
.select((eb) => eb.fn.countAll<number>().as('count'))
|
||||
.where('baseId', '=', baseId)
|
||||
.executeTakeFirstOrThrow();
|
||||
return Number(result.count);
|
||||
}
|
||||
|
||||
async getLastPosition(
|
||||
baseId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<string | null> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
const result = await db
|
||||
.selectFrom('baseViews')
|
||||
.select('position')
|
||||
.where('baseId', '=', baseId)
|
||||
.orderBy(sql`position COLLATE "C"`, sql`DESC`)
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
return result?.position ?? null;
|
||||
}
|
||||
|
||||
async insertView(
|
||||
view: InsertableBaseView,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<BaseView> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.insertInto('baseViews')
|
||||
.values(view)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow() as Promise<BaseView>;
|
||||
}
|
||||
|
||||
async updateView(
|
||||
viewId: string,
|
||||
data: UpdatableBaseView,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.updateTable('baseViews')
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where('id', '=', viewId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteView(
|
||||
viewId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.deleteFrom('baseViews')
|
||||
.where('id', '=', viewId)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
|
||||
import { dbOrTx } from '../../utils';
|
||||
import {
|
||||
Base,
|
||||
InsertableBase,
|
||||
UpdatableBase,
|
||||
} from '@docmost/db/types/entity.types';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { ExpressionBuilder } from 'kysely';
|
||||
import { DB } from '@docmost/db/types/db';
|
||||
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
||||
|
||||
@Injectable()
|
||||
export class BaseRepo {
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
private baseFields: Array<keyof Base> = [
|
||||
'id',
|
||||
'name',
|
||||
'description',
|
||||
'icon',
|
||||
'pageId',
|
||||
'spaceId',
|
||||
'workspaceId',
|
||||
'creatorId',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'deletedAt',
|
||||
];
|
||||
|
||||
async findById(
|
||||
baseId: string,
|
||||
opts?: {
|
||||
includeProperties?: boolean;
|
||||
includeViews?: boolean;
|
||||
trx?: KyselyTransaction;
|
||||
},
|
||||
): Promise<Base | undefined> {
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
|
||||
let query = db
|
||||
.selectFrom('bases')
|
||||
.select(this.baseFields)
|
||||
.where('id', '=', baseId)
|
||||
.where('deletedAt', 'is', null);
|
||||
|
||||
if (opts?.includeProperties) {
|
||||
query = query.select((eb) => this.withProperties(eb));
|
||||
}
|
||||
|
||||
if (opts?.includeViews) {
|
||||
query = query.select((eb) => this.withViews(eb));
|
||||
}
|
||||
|
||||
return query.executeTakeFirst() as Promise<Base | undefined>;
|
||||
}
|
||||
|
||||
async findBySpaceId(
|
||||
spaceId: string,
|
||||
pagination: PaginationOptions,
|
||||
opts?: { trx?: KyselyTransaction },
|
||||
) {
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
|
||||
const query = db
|
||||
.selectFrom('bases')
|
||||
.select(this.baseFields)
|
||||
.where('spaceId', '=', spaceId)
|
||||
.where('deletedAt', 'is', null);
|
||||
|
||||
return executeWithCursorPagination(query, {
|
||||
perPage: pagination.limit,
|
||||
cursor: pagination.cursor,
|
||||
beforeCursor: pagination.beforeCursor,
|
||||
fields: [
|
||||
{ expression: 'createdAt', direction: 'desc' },
|
||||
{ expression: 'id', direction: 'desc' },
|
||||
],
|
||||
parseCursor: (cursor) => ({
|
||||
createdAt: new Date(cursor.createdAt),
|
||||
id: cursor.id,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async insertBase(
|
||||
base: InsertableBase,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<Base> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.insertInto('bases')
|
||||
.values(base)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow() as Promise<Base>;
|
||||
}
|
||||
|
||||
async updateBase(
|
||||
baseId: string,
|
||||
data: UpdatableBase,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.updateTable('bases')
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where('id', '=', baseId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async softDelete(baseId: string, trx?: KyselyTransaction): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.updateTable('bases')
|
||||
.set({ deletedAt: new Date() })
|
||||
.where('id', '=', baseId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
private withProperties(eb: ExpressionBuilder<DB, 'bases'>) {
|
||||
return jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('baseProperties')
|
||||
.selectAll('baseProperties')
|
||||
.whereRef('baseProperties.baseId', '=', 'bases.id')
|
||||
.orderBy('baseProperties.position', 'asc'),
|
||||
).as('properties');
|
||||
}
|
||||
|
||||
private withViews(eb: ExpressionBuilder<DB, 'bases'>) {
|
||||
return jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('baseViews')
|
||||
.selectAll('baseViews')
|
||||
.whereRef('baseViews.baseId', '=', 'bases.id')
|
||||
.orderBy('baseViews.position', 'asc'),
|
||||
).as('views');
|
||||
}
|
||||
}
|
||||
+57
@@ -390,9 +390,66 @@ export interface Watchers {
|
||||
createdAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface Bases {
|
||||
id: Generated<string>;
|
||||
name: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
pageId: string | null;
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
creatorId: string | null;
|
||||
createdAt: Generated<Timestamp>;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
deletedAt: Timestamp | null;
|
||||
}
|
||||
|
||||
export interface BaseProperties {
|
||||
id: Generated<string>;
|
||||
baseId: string;
|
||||
name: string;
|
||||
type: string;
|
||||
position: string;
|
||||
typeOptions: Json | null;
|
||||
isPrimary: Generated<boolean>;
|
||||
workspaceId: string;
|
||||
createdAt: Generated<Timestamp>;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface BaseRows {
|
||||
id: Generated<string>;
|
||||
baseId: string;
|
||||
cells: Generated<Json>;
|
||||
position: string;
|
||||
creatorId: string | null;
|
||||
lastUpdatedById: string | null;
|
||||
workspaceId: string;
|
||||
createdAt: Generated<Timestamp>;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
deletedAt: Timestamp | null;
|
||||
}
|
||||
|
||||
export interface BaseViews {
|
||||
id: Generated<string>;
|
||||
baseId: string;
|
||||
name: string;
|
||||
type: Generated<string>;
|
||||
position: string;
|
||||
config: Generated<Json>;
|
||||
workspaceId: string;
|
||||
creatorId: string | null;
|
||||
createdAt: Generated<Timestamp>;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface DB {
|
||||
apiKeys: ApiKeys;
|
||||
attachments: Attachments;
|
||||
baseProperties: BaseProperties;
|
||||
baseRows: BaseRows;
|
||||
baseViews: BaseViews;
|
||||
bases: Bases;
|
||||
authAccounts: AuthAccounts;
|
||||
authProviders: AuthProviders;
|
||||
backlinks: Backlinks;
|
||||
|
||||
@@ -4,6 +4,10 @@ import {
|
||||
AuthAccounts,
|
||||
AuthProviders,
|
||||
Backlinks,
|
||||
BaseProperties,
|
||||
BaseRows,
|
||||
BaseViews,
|
||||
Bases,
|
||||
Billing,
|
||||
Comments,
|
||||
FileTasks,
|
||||
@@ -27,6 +31,10 @@ import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
|
||||
export interface DbInterface {
|
||||
attachments: Attachments;
|
||||
authAccounts: AuthAccounts;
|
||||
baseProperties: BaseProperties;
|
||||
baseRows: BaseRows;
|
||||
baseViews: BaseViews;
|
||||
bases: Bases;
|
||||
authProviders: AuthProviders;
|
||||
backlinks: Backlinks;
|
||||
billing: Billing;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Insertable, Selectable, Updateable } from 'kysely';
|
||||
import {
|
||||
Attachments,
|
||||
BaseProperties,
|
||||
BaseRows,
|
||||
BaseViews,
|
||||
Bases,
|
||||
Comments,
|
||||
Groups,
|
||||
Notifications,
|
||||
@@ -143,3 +147,23 @@ export type UpdatableNotification = Updateable<Omit<Notifications, 'id'>>;
|
||||
export type Watcher = Selectable<Watchers>;
|
||||
export type InsertableWatcher = Insertable<Watchers>;
|
||||
export type UpdatableWatcher = Updateable<Omit<Watchers, 'id'>>;
|
||||
|
||||
// Base
|
||||
export type Base = Selectable<Bases>;
|
||||
export type InsertableBase = Insertable<Bases>;
|
||||
export type UpdatableBase = Updateable<Omit<Bases, 'id'>>;
|
||||
|
||||
// Base Property
|
||||
export type BaseProperty = Selectable<BaseProperties>;
|
||||
export type InsertableBaseProperty = Insertable<BaseProperties>;
|
||||
export type UpdatableBaseProperty = Updateable<Omit<BaseProperties, 'id'>>;
|
||||
|
||||
// Base Row
|
||||
export type BaseRow = Selectable<BaseRows>;
|
||||
export type InsertableBaseRow = Insertable<BaseRows>;
|
||||
export type UpdatableBaseRow = Updateable<Omit<BaseRows, 'id'>>;
|
||||
|
||||
// Base View
|
||||
export type BaseView = Selectable<BaseViews>;
|
||||
export type InsertableBaseView = Insertable<BaseViews>;
|
||||
export type UpdatableBaseView = Updateable<Omit<BaseViews, 'id'>>;
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import * as path from 'path';
|
||||
import * as dotenv from 'dotenv';
|
||||
import { Kysely, sql } from 'kysely';
|
||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import { v7 as uuid7 } from 'uuid';
|
||||
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
||||
|
||||
const BASE_ID = '019c69a5-1d84-7985-a7f6-8ee2871d8669';
|
||||
const TOTAL_ROWS = 100_000;
|
||||
const BATCH_SIZE = 2000;
|
||||
|
||||
const envFilePath = path.resolve(process.cwd(), '..', '..', '.env');
|
||||
dotenv.config({ path: envFilePath });
|
||||
|
||||
function normalizePostgresUrl(url: string): string {
|
||||
const parsed = new URL(url);
|
||||
const newParams = new URLSearchParams();
|
||||
for (const [key, value] of parsed.searchParams) {
|
||||
if (key === 'sslmode' && value === 'no-verify') continue;
|
||||
if (key === 'schema') continue;
|
||||
newParams.append(key, value);
|
||||
}
|
||||
parsed.search = newParams.toString();
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
const db = new Kysely<any>({
|
||||
dialect: new PostgresJSDialect({
|
||||
postgres: postgres(normalizePostgresUrl(process.env.DATABASE_URL!)),
|
||||
}),
|
||||
});
|
||||
|
||||
const SKIP_TYPES = new Set([
|
||||
'createdAt',
|
||||
'lastEditedAt',
|
||||
'lastEditedBy',
|
||||
'person',
|
||||
'file',
|
||||
]);
|
||||
|
||||
const WORDS = [
|
||||
'Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', 'Golf',
|
||||
'Hotel', 'India', 'Juliet', 'Kilo', 'Lima', 'Mike', 'November',
|
||||
'Oscar', 'Papa', 'Quebec', 'Romeo', 'Sierra', 'Tango', 'Uniform',
|
||||
'Victor', 'Whiskey', 'X-ray', 'Yankee', 'Zulu', 'Report', 'Analysis',
|
||||
'Summary', 'Review', 'Update', 'Draft', 'Final', 'Proposal', 'Budget',
|
||||
'Timeline', 'Milestone', 'Objective', 'Strategy', 'Initiative',
|
||||
];
|
||||
|
||||
function randomWords(min: number, max: number): string {
|
||||
const count = min + Math.floor(Math.random() * (max - min + 1));
|
||||
const result: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
result.push(WORDS[Math.floor(Math.random() * WORDS.length)]);
|
||||
}
|
||||
return result.join(' ');
|
||||
}
|
||||
|
||||
type CellGenerator = () => unknown;
|
||||
|
||||
function buildCellGenerator(property: any): CellGenerator | null {
|
||||
if (SKIP_TYPES.has(property.type)) return null;
|
||||
|
||||
const typeOptions = property.type_options;
|
||||
|
||||
switch (property.type) {
|
||||
case 'text':
|
||||
return () => randomWords(2, 6);
|
||||
|
||||
case 'number':
|
||||
return () => Math.round(Math.random() * 10000 * 100) / 100;
|
||||
|
||||
case 'select':
|
||||
case 'status': {
|
||||
const choices = typeOptions?.choices ?? [];
|
||||
if (choices.length === 0) return null;
|
||||
return () => choices[Math.floor(Math.random() * choices.length)].id;
|
||||
}
|
||||
|
||||
case 'multiSelect': {
|
||||
const choices = typeOptions?.choices ?? [];
|
||||
if (choices.length === 0) return () => [];
|
||||
return () => {
|
||||
const count = 1 + Math.floor(Math.random() * Math.min(3, choices.length));
|
||||
const shuffled = [...choices].sort(() => Math.random() - 0.5);
|
||||
return shuffled.slice(0, count).map((c: any) => c.id);
|
||||
};
|
||||
}
|
||||
|
||||
case 'date': {
|
||||
const start = new Date(2020, 0, 1).getTime();
|
||||
const range = new Date(2026, 0, 1).getTime() - start;
|
||||
return () => new Date(start + Math.random() * range).toISOString();
|
||||
}
|
||||
|
||||
case 'checkbox':
|
||||
return () => Math.random() > 0.5;
|
||||
|
||||
case 'url':
|
||||
return () => `https://example.com/page/${Math.floor(Math.random() * 100000)}`;
|
||||
|
||||
case 'email':
|
||||
return () => `user${Math.floor(Math.random() * 100000)}@example.com`;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Seeding ${TOTAL_ROWS.toLocaleString()} rows for base ${BASE_ID}\n`);
|
||||
|
||||
const base = await db
|
||||
.selectFrom('bases')
|
||||
.selectAll()
|
||||
.where('id', '=', BASE_ID)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const workspaceId = base.workspace_id;
|
||||
console.log(`Workspace: ${workspaceId}`);
|
||||
|
||||
const user = await db
|
||||
.selectFrom('users')
|
||||
.select('id')
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
|
||||
const creatorId = user?.id ?? null;
|
||||
console.log(`Creator: ${creatorId ?? '(none)'}`);
|
||||
|
||||
const properties = await db
|
||||
.selectFrom('base_properties')
|
||||
.selectAll()
|
||||
.where('base_id', '=', BASE_ID)
|
||||
.execute();
|
||||
|
||||
console.log(`Properties: ${properties.length}`);
|
||||
for (const p of properties) {
|
||||
console.log(` - ${p.name} (${p.type})${SKIP_TYPES.has(p.type) ? ' [skipped]' : ''}`);
|
||||
}
|
||||
|
||||
const generators: Array<{ propertyId: string; generate: CellGenerator }> = [];
|
||||
for (const prop of properties) {
|
||||
const gen = buildCellGenerator(prop);
|
||||
if (gen) {
|
||||
generators.push({ propertyId: prop.id, generate: gen });
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nGenerating ${TOTAL_ROWS.toLocaleString()} positions...`);
|
||||
|
||||
const lastRow = await db
|
||||
.selectFrom('base_rows')
|
||||
.select('position')
|
||||
.where('base_id', '=', BASE_ID)
|
||||
.where('deleted_at', 'is', null)
|
||||
.orderBy(sql`position COLLATE "C"`, sql`desc`)
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
|
||||
let lastPosition: string | null = lastRow?.position ?? null;
|
||||
const positions: string[] = new Array(TOTAL_ROWS);
|
||||
for (let i = 0; i < TOTAL_ROWS; i++) {
|
||||
lastPosition = generateJitteredKeyBetween(lastPosition, null);
|
||||
positions[i] = lastPosition;
|
||||
}
|
||||
console.log(`Positions generated (last: ${positions[positions.length - 1]})\n`);
|
||||
|
||||
const startTime = Date.now();
|
||||
const totalBatches = Math.ceil(TOTAL_ROWS / BATCH_SIZE);
|
||||
|
||||
for (let batchStart = 0; batchStart < TOTAL_ROWS; batchStart += BATCH_SIZE) {
|
||||
const batchEnd = Math.min(batchStart + BATCH_SIZE, TOTAL_ROWS);
|
||||
const rows: any[] = [];
|
||||
|
||||
for (let i = batchStart; i < batchEnd; i++) {
|
||||
const cells: Record<string, unknown> = {};
|
||||
for (const { propertyId, generate } of generators) {
|
||||
cells[propertyId] = generate();
|
||||
}
|
||||
|
||||
rows.push({
|
||||
id: uuid7(),
|
||||
base_id: BASE_ID,
|
||||
cells,
|
||||
position: positions[i],
|
||||
creator_id: creatorId,
|
||||
workspace_id: workspaceId,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
await db.insertInto('base_rows').values(rows).execute();
|
||||
|
||||
const batchNum = Math.floor(batchStart / BATCH_SIZE) + 1;
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
console.log(`Batch ${batchNum}/${totalBatches} inserted (${batchEnd.toLocaleString()} rows, ${elapsed}s elapsed)`);
|
||||
}
|
||||
|
||||
const totalElapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
console.log(`\nDone. Inserted ${TOTAL_ROWS.toLocaleString()} rows in ${totalElapsed}s`);
|
||||
|
||||
await db.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Seed script failed:', err);
|
||||
db.destroy().finally(() => process.exit(1));
|
||||
});
|
||||
Generated
+153
-36
@@ -239,6 +239,18 @@ importers:
|
||||
'@casl/react':
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0(@casl/ability@6.8.0)(react@18.3.1)
|
||||
'@dnd-kit/core':
|
||||
specifier: ^6.3.1
|
||||
version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@dnd-kit/modifiers':
|
||||
specifier: ^9.0.0
|
||||
version: 9.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
|
||||
'@dnd-kit/sortable':
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
|
||||
'@dnd-kit/utilities':
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2(react@18.3.1)
|
||||
'@docmost/editor-ext':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/editor-ext
|
||||
@@ -278,6 +290,12 @@ importers:
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.90.17
|
||||
version: 5.90.17(react@18.3.1)
|
||||
'@tanstack/react-table':
|
||||
specifier: ^8.21.3
|
||||
version: 8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@tanstack/react-virtual':
|
||||
specifier: ^3.13.18
|
||||
version: 3.13.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
alfaaz:
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0
|
||||
@@ -449,13 +467,13 @@ importers:
|
||||
dependencies:
|
||||
'@ai-sdk/google':
|
||||
specifier: ^3.0.29
|
||||
version: 3.0.29(zod@4.3.6)
|
||||
version: 3.0.29(zod@3.25.76)
|
||||
'@ai-sdk/openai':
|
||||
specifier: ^3.0.29
|
||||
version: 3.0.29(zod@4.3.6)
|
||||
version: 3.0.29(zod@3.25.76)
|
||||
'@ai-sdk/openai-compatible':
|
||||
specifier: ^2.0.30
|
||||
version: 2.0.30(zod@4.3.6)
|
||||
version: 2.0.30(zod@3.25.76)
|
||||
'@aws-sdk/client-s3':
|
||||
specifier: 3.982.0
|
||||
version: 3.982.0
|
||||
@@ -476,10 +494,10 @@ importers:
|
||||
version: 9.0.0
|
||||
'@langchain/core':
|
||||
specifier: 1.1.18
|
||||
version: 1.1.18(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@4.3.6))
|
||||
version: 1.1.18(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@3.25.76))
|
||||
'@langchain/textsplitters':
|
||||
specifier: 1.0.1
|
||||
version: 1.0.1(@langchain/core@1.1.18(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@4.3.6)))
|
||||
version: 1.0.1(@langchain/core@1.1.18(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@3.25.76)))
|
||||
'@nestjs-labs/nestjs-ioredis':
|
||||
specifier: ^11.0.4
|
||||
version: 11.0.4(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(ioredis@5.4.1)
|
||||
@@ -536,10 +554,10 @@ importers:
|
||||
version: 8.3.0(socket.io-adapter@2.5.4)
|
||||
ai:
|
||||
specifier: ^6.0.86
|
||||
version: 6.0.86(zod@4.3.6)
|
||||
version: 6.0.86(zod@3.25.76)
|
||||
ai-sdk-ollama:
|
||||
specifier: ^3.7.0
|
||||
version: 3.7.0(ai@6.0.86(zod@4.3.6))(zod@4.3.6)
|
||||
version: 3.7.0(ai@6.0.86(zod@3.25.76))(zod@3.25.76)
|
||||
bcrypt:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
@@ -678,6 +696,9 @@ importers:
|
||||
yauzl:
|
||||
specifier: ^3.2.0
|
||||
version: 3.2.0
|
||||
zod:
|
||||
specifier: ^3.25.76
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@eslint/js':
|
||||
specifier: ^9.20.0
|
||||
@@ -1793,6 +1814,34 @@ packages:
|
||||
resolution: {integrity: sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1':
|
||||
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/core@6.3.1':
|
||||
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/modifiers@9.0.0':
|
||||
resolution: {integrity: sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==}
|
||||
peerDependencies:
|
||||
'@dnd-kit/core': ^6.3.0
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/sortable@10.0.0':
|
||||
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
|
||||
peerDependencies:
|
||||
'@dnd-kit/core': ^6.3.0
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/utilities@3.2.2':
|
||||
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@emnapi/core@1.2.0':
|
||||
resolution: {integrity: sha512-E7Vgw78I93we4ZWdYCb4DGAwRROGkMIXk7/y87UmANR+J6qsWusmC3gLt0H+O0KOt5e6O38U8oJamgbudrES/w==}
|
||||
|
||||
@@ -4489,6 +4538,26 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^18 || ^19
|
||||
|
||||
'@tanstack/react-table@8.21.3':
|
||||
resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==}
|
||||
engines: {node: '>=12'}
|
||||
peerDependencies:
|
||||
react: '>=16.8'
|
||||
react-dom: '>=16.8'
|
||||
|
||||
'@tanstack/react-virtual@3.13.18':
|
||||
resolution: {integrity: sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@tanstack/table-core@8.21.3':
|
||||
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@tanstack/virtual-core@3.13.18':
|
||||
resolution: {integrity: sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==}
|
||||
|
||||
'@tiptap/core@3.17.1':
|
||||
resolution: {integrity: sha512-f8hB9MzXqsuXoF9qXEDEH5Fb3VgwhEFMBMfk9EKN88l5adri6oM8mt2XOWVxVVssjpEW0177zXSLPKWzoS/vrw==}
|
||||
peerDependencies:
|
||||
@@ -10473,37 +10542,37 @@ snapshots:
|
||||
|
||||
'@adobe/css-tools@4.3.3': {}
|
||||
|
||||
'@ai-sdk/gateway@3.0.46(zod@4.3.6)':
|
||||
'@ai-sdk/gateway@3.0.46(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 3.0.8
|
||||
'@ai-sdk/provider-utils': 4.0.15(zod@4.3.6)
|
||||
'@ai-sdk/provider-utils': 4.0.15(zod@3.25.76)
|
||||
'@vercel/oidc': 3.1.0
|
||||
zod: 4.3.6
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/google@3.0.29(zod@4.3.6)':
|
||||
'@ai-sdk/google@3.0.29(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 3.0.8
|
||||
'@ai-sdk/provider-utils': 4.0.15(zod@4.3.6)
|
||||
zod: 4.3.6
|
||||
'@ai-sdk/provider-utils': 4.0.15(zod@3.25.76)
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/openai-compatible@2.0.30(zod@4.3.6)':
|
||||
'@ai-sdk/openai-compatible@2.0.30(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 3.0.8
|
||||
'@ai-sdk/provider-utils': 4.0.15(zod@4.3.6)
|
||||
zod: 4.3.6
|
||||
'@ai-sdk/provider-utils': 4.0.15(zod@3.25.76)
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/openai@3.0.29(zod@4.3.6)':
|
||||
'@ai-sdk/openai@3.0.29(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 3.0.8
|
||||
'@ai-sdk/provider-utils': 4.0.15(zod@4.3.6)
|
||||
zod: 4.3.6
|
||||
'@ai-sdk/provider-utils': 4.0.15(zod@3.25.76)
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/provider-utils@4.0.15(zod@4.3.6)':
|
||||
'@ai-sdk/provider-utils@4.0.15(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 3.0.8
|
||||
'@standard-schema/spec': 1.1.0
|
||||
eventsource-parser: 3.0.6
|
||||
zod: 4.3.6
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/provider@3.0.8':
|
||||
dependencies:
|
||||
@@ -11972,6 +12041,38 @@ snapshots:
|
||||
|
||||
'@csstools/css-tokenizer@3.0.3': {}
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1(react@18.3.1)':
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@dnd-kit/accessibility': 3.1.1(react@18.3.1)
|
||||
'@dnd-kit/utilities': 3.2.2(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/modifiers@9.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@dnd-kit/utilities': 3.2.2(react@18.3.1)
|
||||
react: 18.3.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@dnd-kit/utilities': 3.2.2(react@18.3.1)
|
||||
react: 18.3.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/utilities@3.2.2(react@18.3.1)':
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@emnapi/core@1.2.0':
|
||||
dependencies:
|
||||
'@emnapi/wasi-threads': 1.0.1
|
||||
@@ -12869,14 +12970,14 @@ snapshots:
|
||||
|
||||
'@keyv/serialize@1.1.1': {}
|
||||
|
||||
'@langchain/core@1.1.18(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@4.3.6))':
|
||||
'@langchain/core@1.1.18(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@3.25.76))':
|
||||
dependencies:
|
||||
'@cfworker/json-schema': 4.1.1
|
||||
ansi-styles: 5.2.0
|
||||
camelcase: 6.3.0
|
||||
decamelize: 1.2.0
|
||||
js-tiktoken: 1.0.21
|
||||
langsmith: 0.4.12(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@4.3.6))
|
||||
langsmith: 0.4.12(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@3.25.76))
|
||||
mustache: 4.2.0
|
||||
p-queue: 6.6.2
|
||||
uuid: 10.0.0
|
||||
@@ -12887,9 +12988,9 @@ snapshots:
|
||||
- '@opentelemetry/sdk-trace-base'
|
||||
- openai
|
||||
|
||||
'@langchain/textsplitters@1.0.1(@langchain/core@1.1.18(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@4.3.6)))':
|
||||
'@langchain/textsplitters@1.0.1(@langchain/core@1.1.18(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@3.25.76)))':
|
||||
dependencies:
|
||||
'@langchain/core': 1.1.18(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@4.3.6))
|
||||
'@langchain/core': 1.1.18(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@3.25.76))
|
||||
js-tiktoken: 1.0.21
|
||||
|
||||
'@lifeomic/attempt@3.0.3': {}
|
||||
@@ -14895,6 +14996,22 @@ snapshots:
|
||||
'@tanstack/query-core': 5.90.17
|
||||
react: 18.3.1
|
||||
|
||||
'@tanstack/react-table@8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@tanstack/table-core': 8.21.3
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
'@tanstack/react-virtual@3.13.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.13.18
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
'@tanstack/table-core@8.21.3': {}
|
||||
|
||||
'@tanstack/virtual-core@3.13.18': {}
|
||||
|
||||
'@tiptap/core@3.17.1(@tiptap/pm@3.17.1)':
|
||||
dependencies:
|
||||
'@tiptap/pm': 3.17.1
|
||||
@@ -15973,23 +16090,23 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
ai-sdk-ollama@3.7.0(ai@6.0.86(zod@4.3.6))(zod@4.3.6):
|
||||
ai-sdk-ollama@3.7.0(ai@6.0.86(zod@3.25.76))(zod@3.25.76):
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 3.0.8
|
||||
'@ai-sdk/provider-utils': 4.0.15(zod@4.3.6)
|
||||
ai: 6.0.86(zod@4.3.6)
|
||||
'@ai-sdk/provider-utils': 4.0.15(zod@3.25.76)
|
||||
ai: 6.0.86(zod@3.25.76)
|
||||
jsonrepair: 3.13.2
|
||||
ollama: 0.6.3
|
||||
transitivePeerDependencies:
|
||||
- zod
|
||||
|
||||
ai@6.0.86(zod@4.3.6):
|
||||
ai@6.0.86(zod@3.25.76):
|
||||
dependencies:
|
||||
'@ai-sdk/gateway': 3.0.46(zod@4.3.6)
|
||||
'@ai-sdk/gateway': 3.0.46(zod@3.25.76)
|
||||
'@ai-sdk/provider': 3.0.8
|
||||
'@ai-sdk/provider-utils': 4.0.15(zod@4.3.6)
|
||||
'@ai-sdk/provider-utils': 4.0.15(zod@3.25.76)
|
||||
'@opentelemetry/api': 1.9.0
|
||||
zod: 4.3.6
|
||||
zod: 3.25.76
|
||||
|
||||
ajv-formats@2.1.1(ajv@8.17.1):
|
||||
optionalDependencies:
|
||||
@@ -18801,7 +18918,7 @@ snapshots:
|
||||
vscode-languageserver-textdocument: 1.0.12
|
||||
vscode-uri: 3.0.8
|
||||
|
||||
langsmith@0.4.12(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@4.3.6)):
|
||||
langsmith@0.4.12(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@6.2.0(ws@8.19.0)(zod@3.25.76)):
|
||||
dependencies:
|
||||
'@types/uuid': 10.0.0
|
||||
chalk: 4.1.2
|
||||
@@ -18812,7 +18929,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0)
|
||||
openai: 6.2.0(ws@8.19.0)(zod@4.3.6)
|
||||
openai: 6.2.0(ws@8.19.0)(zod@3.25.76)
|
||||
|
||||
layout-base@1.0.2: {}
|
||||
|
||||
@@ -19361,10 +19478,10 @@ snapshots:
|
||||
is-docker: 2.2.1
|
||||
is-wsl: 2.2.0
|
||||
|
||||
openai@6.2.0(ws@8.19.0)(zod@4.3.6):
|
||||
openai@6.2.0(ws@8.19.0)(zod@3.25.76):
|
||||
optionalDependencies:
|
||||
ws: 8.19.0
|
||||
zod: 4.3.6
|
||||
zod: 3.25.76
|
||||
optional: true
|
||||
|
||||
openid-client@5.7.1:
|
||||
|
||||
Reference in New Issue
Block a user