feat: bases - WIP

This commit is contained in:
Philipinho
2026-03-08 00:56:24 +00:00
parent 0aeaa43112
commit 94ee1e80fb
83 changed files with 9243 additions and 38 deletions
+6
View File
@@ -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",
+3
View File
@@ -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;
};
+8
View File
@@ -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>
+32
View File
@@ -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>
);
}
+2 -1
View File
@@ -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",
+21
View File
@@ -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 {}
+270
View File
@@ -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];
+2
View File
@@ -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
View File
@@ -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'>>;
+212
View File
@@ -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));
});
+153 -36
View File
@@ -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: