From 94ee1e80fb926adc71050a3571baa9fd2af19219 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sun, 8 Mar 2026 00:56:24 +0000 Subject: [PATCH] feat: bases - WIP --- apps/client/package.json | 6 + apps/client/src/App.tsx | 3 + .../src/features/base/atoms/base-atoms.ts | 12 + .../features/base/components/base-table.tsx | 198 +++++++ .../features/base/components/base-toolbar.tsx | 214 +++++++ .../base/components/cells/cell-checkbox.tsx | 36 ++ .../base/components/cells/cell-created-at.tsx | 34 ++ .../base/components/cells/cell-date.tsx | 141 +++++ .../base/components/cells/cell-email.tsx | 90 +++ .../base/components/cells/cell-file.tsx | 47 ++ .../components/cells/cell-last-edited-at.tsx | 34 ++ .../components/cells/cell-last-edited-by.tsx | 53 ++ .../components/cells/cell-multi-select.tsx | 152 +++++ .../base/components/cells/cell-number.tsx | 122 ++++ .../base/components/cells/cell-person.tsx | 46 ++ .../base/components/cells/cell-select.tsx | 133 +++++ .../base/components/cells/cell-status.tsx | 170 ++++++ .../base/components/cells/cell-text.tsx | 82 +++ .../base/components/cells/cell-url.tsx | 92 +++ .../base/components/cells/choice-color.ts | 25 + .../base/components/grid/add-row-button.tsx | 26 + .../base/components/grid/grid-cell.tsx | 148 +++++ .../base/components/grid/grid-container.tsx | 239 ++++++++ .../base/components/grid/grid-header-cell.tsx | 189 +++++++ .../base/components/grid/grid-header.tsx | 40 ++ .../base/components/grid/grid-row.tsx | 84 +++ .../components/property/choice-editor.tsx | 528 ++++++++++++++++++ .../components/property/property-menu.tsx | 443 +++++++++++++++ .../components/property/property-options.tsx | 217 +++++++ .../property/property-type-picker.tsx | 83 +++ .../views/view-field-visibility.tsx | 146 +++++ .../components/views/view-filter-config.tsx | 204 +++++++ .../components/views/view-sort-config.tsx | 153 +++++ .../base/components/views/view-tabs.tsx | 237 ++++++++ .../src/features/base/hooks/use-base-table.ts | 304 ++++++++++ .../features/base/hooks/use-column-resize.ts | 26 + .../base/hooks/use-grid-keyboard-nav.ts | 117 ++++ .../src/features/base/hooks/use-row-drag.ts | 115 ++++ .../base/queries/base-property-query.ts | 154 +++++ .../src/features/base/queries/base-query.ts | 87 +++ .../features/base/queries/base-row-query.ts | 240 ++++++++ .../features/base/queries/base-view-query.ts | 137 +++++ .../features/base/services/base-service.ts | 137 +++++ .../src/features/base/styles/cells.module.css | 182 ++++++ .../src/features/base/styles/grid.module.css | 293 ++++++++++ .../src/features/base/types/base.types.ts | 254 +++++++++ .../src/features/base/types/react-table.d.ts | 8 + .../space/components/space-home-tabs.tsx | 3 + apps/client/src/pages/base/base-page.tsx | 32 ++ apps/server/package.json | 3 +- apps/server/src/core/base/base.module.ts | 21 + apps/server/src/core/base/base.schemas.ts | 270 +++++++++ .../controllers/base-property.controller.ts | 105 ++++ .../base/controllers/base-row.controller.ts | 144 +++++ .../base/controllers/base-view.controller.ts | 102 ++++ .../core/base/controllers/base.controller.ts | 111 ++++ apps/server/src/core/base/dto/base.dto.ts | 6 + .../src/core/base/dto/create-base.dto.ts | 22 + .../src/core/base/dto/create-property.dto.ts | 25 + .../src/core/base/dto/create-row.dto.ts | 14 + .../src/core/base/dto/create-view.dto.ts | 25 + .../src/core/base/dto/update-base.dto.ts | 19 + .../src/core/base/dto/update-property.dto.ts | 50 ++ .../src/core/base/dto/update-row.dto.ts | 49 ++ .../src/core/base/dto/update-view.dto.ts | 37 ++ .../base/services/base-property.service.ts | 216 +++++++ .../core/base/services/base-row.service.ts | 162 ++++++ .../core/base/services/base-view.service.ts | 95 ++++ .../src/core/base/services/base.service.ts | 115 ++++ .../casl/abilities/space-ability.factory.ts | 3 + .../casl/interfaces/space-ability.type.ts | 4 +- apps/server/src/core/core.module.ts | 2 + apps/server/src/database/database.module.ts | 12 + .../migrations/20260218T120000-bases.ts | 154 +++++ .../database/repos/base/base-property.repo.ts | 91 +++ .../src/database/repos/base/base-row.repo.ts | 172 ++++++ .../src/database/repos/base/base-view.repo.ts | 104 ++++ .../src/database/repos/base/base.repo.ts | 142 +++++ apps/server/src/database/types/db.d.ts | 57 ++ .../server/src/database/types/db.interface.ts | 8 + .../server/src/database/types/entity.types.ts | 24 + apps/server/src/scripts/seed-base-rows.ts | 212 +++++++ pnpm-lock.yaml | 189 +++++-- 83 files changed, 9243 insertions(+), 38 deletions(-) create mode 100644 apps/client/src/features/base/atoms/base-atoms.ts create mode 100644 apps/client/src/features/base/components/base-table.tsx create mode 100644 apps/client/src/features/base/components/base-toolbar.tsx create mode 100644 apps/client/src/features/base/components/cells/cell-checkbox.tsx create mode 100644 apps/client/src/features/base/components/cells/cell-created-at.tsx create mode 100644 apps/client/src/features/base/components/cells/cell-date.tsx create mode 100644 apps/client/src/features/base/components/cells/cell-email.tsx create mode 100644 apps/client/src/features/base/components/cells/cell-file.tsx create mode 100644 apps/client/src/features/base/components/cells/cell-last-edited-at.tsx create mode 100644 apps/client/src/features/base/components/cells/cell-last-edited-by.tsx create mode 100644 apps/client/src/features/base/components/cells/cell-multi-select.tsx create mode 100644 apps/client/src/features/base/components/cells/cell-number.tsx create mode 100644 apps/client/src/features/base/components/cells/cell-person.tsx create mode 100644 apps/client/src/features/base/components/cells/cell-select.tsx create mode 100644 apps/client/src/features/base/components/cells/cell-status.tsx create mode 100644 apps/client/src/features/base/components/cells/cell-text.tsx create mode 100644 apps/client/src/features/base/components/cells/cell-url.tsx create mode 100644 apps/client/src/features/base/components/cells/choice-color.ts create mode 100644 apps/client/src/features/base/components/grid/add-row-button.tsx create mode 100644 apps/client/src/features/base/components/grid/grid-cell.tsx create mode 100644 apps/client/src/features/base/components/grid/grid-container.tsx create mode 100644 apps/client/src/features/base/components/grid/grid-header-cell.tsx create mode 100644 apps/client/src/features/base/components/grid/grid-header.tsx create mode 100644 apps/client/src/features/base/components/grid/grid-row.tsx create mode 100644 apps/client/src/features/base/components/property/choice-editor.tsx create mode 100644 apps/client/src/features/base/components/property/property-menu.tsx create mode 100644 apps/client/src/features/base/components/property/property-options.tsx create mode 100644 apps/client/src/features/base/components/property/property-type-picker.tsx create mode 100644 apps/client/src/features/base/components/views/view-field-visibility.tsx create mode 100644 apps/client/src/features/base/components/views/view-filter-config.tsx create mode 100644 apps/client/src/features/base/components/views/view-sort-config.tsx create mode 100644 apps/client/src/features/base/components/views/view-tabs.tsx create mode 100644 apps/client/src/features/base/hooks/use-base-table.ts create mode 100644 apps/client/src/features/base/hooks/use-column-resize.ts create mode 100644 apps/client/src/features/base/hooks/use-grid-keyboard-nav.ts create mode 100644 apps/client/src/features/base/hooks/use-row-drag.ts create mode 100644 apps/client/src/features/base/queries/base-property-query.ts create mode 100644 apps/client/src/features/base/queries/base-query.ts create mode 100644 apps/client/src/features/base/queries/base-row-query.ts create mode 100644 apps/client/src/features/base/queries/base-view-query.ts create mode 100644 apps/client/src/features/base/services/base-service.ts create mode 100644 apps/client/src/features/base/styles/cells.module.css create mode 100644 apps/client/src/features/base/styles/grid.module.css create mode 100644 apps/client/src/features/base/types/base.types.ts create mode 100644 apps/client/src/features/base/types/react-table.d.ts create mode 100644 apps/client/src/pages/base/base-page.tsx create mode 100644 apps/server/src/core/base/base.module.ts create mode 100644 apps/server/src/core/base/base.schemas.ts create mode 100644 apps/server/src/core/base/controllers/base-property.controller.ts create mode 100644 apps/server/src/core/base/controllers/base-row.controller.ts create mode 100644 apps/server/src/core/base/controllers/base-view.controller.ts create mode 100644 apps/server/src/core/base/controllers/base.controller.ts create mode 100644 apps/server/src/core/base/dto/base.dto.ts create mode 100644 apps/server/src/core/base/dto/create-base.dto.ts create mode 100644 apps/server/src/core/base/dto/create-property.dto.ts create mode 100644 apps/server/src/core/base/dto/create-row.dto.ts create mode 100644 apps/server/src/core/base/dto/create-view.dto.ts create mode 100644 apps/server/src/core/base/dto/update-base.dto.ts create mode 100644 apps/server/src/core/base/dto/update-property.dto.ts create mode 100644 apps/server/src/core/base/dto/update-row.dto.ts create mode 100644 apps/server/src/core/base/dto/update-view.dto.ts create mode 100644 apps/server/src/core/base/services/base-property.service.ts create mode 100644 apps/server/src/core/base/services/base-row.service.ts create mode 100644 apps/server/src/core/base/services/base-view.service.ts create mode 100644 apps/server/src/core/base/services/base.service.ts create mode 100644 apps/server/src/database/migrations/20260218T120000-bases.ts create mode 100644 apps/server/src/database/repos/base/base-property.repo.ts create mode 100644 apps/server/src/database/repos/base/base-row.repo.ts create mode 100644 apps/server/src/database/repos/base/base-view.repo.ts create mode 100644 apps/server/src/database/repos/base/base.repo.ts create mode 100644 apps/server/src/scripts/seed-base-rows.ts diff --git a/apps/client/package.json b/apps/client/package.json index 617bf447..ede9d8de 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -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", diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 438ffde8..ea2f6ca1 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -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={} /> + } /> + } /> (null); + +export const editingCellAtom = atom(null); + +export const activePropertyMenuAtom = atom(null); + +export const propertyMenuDirtyAtom = atom(false); + +export const propertyMenuCloseRequestAtom = atom(0); diff --git a/apps/client/src/features/base/components/base-table.tsx b/apps/client/src/features/base/components/base-table.tsx new file mode 100644 index 00000000..bfd2b439 --- /dev/null +++ b/apps/client/src/features/base/components/base-table.tsx @@ -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 ( +
+ +
+ ); + } + + if (baseError) { + return ( + + + {t("Failed to load base")} + + ); + } + + if (!base) return null; + + return ( +
+ + +
+ ); +} diff --git a/apps/client/src/features/base/components/base-toolbar.tsx b/apps/client/src/features/base/components/base-toolbar.tsx new file mode 100644 index 00000000..e56a49e6 --- /dev/null +++ b/apps/client/src/features/base/components/base-toolbar.tsx @@ -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; + 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 ( +
+ + +
+ setFilterOpened(false)} + filters={filters} + properties={base.properties} + onChange={handleFiltersChange} + > + + 0 ? "blue" : "gray"} + onClick={() => openToolbar("filter")} + > + + {filters.length > 0 && ( + + {filters.length} + + )} + + + + + setSortOpened(false)} + sorts={sorts} + properties={base.properties} + onChange={handleSortsChange} + > + + 0 ? "blue" : "gray"} + onClick={() => openToolbar("sort")} + > + + {sorts.length > 0 && ( + + {sorts.length} + + )} + + + + + setFieldsOpened(false)} + table={table} + onPersist={onPersistViewConfig} + > + + 0 ? "blue" : "gray"} + onClick={() => openToolbar("fields")} + > + + {hiddenFieldCount > 0 && ( + + {hiddenFieldCount} + + )} + + + +
+
+ ); +} diff --git a/apps/client/src/features/base/components/cells/cell-checkbox.tsx b/apps/client/src/features/base/components/cells/cell-checkbox.tsx new file mode 100644 index 00000000..ac320a80 --- /dev/null +++ b/apps/client/src/features/base/components/cells/cell-checkbox.tsx @@ -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 ( +
+ {}} + size="xs" + tabIndex={-1} + styles={{ input: { cursor: "pointer", pointerEvents: "none" } }} + /> +
+ ); +} diff --git a/apps/client/src/features/base/components/cells/cell-created-at.tsx b/apps/client/src/features/base/components/cells/cell-created-at.tsx new file mode 100644 index 00000000..7552a9f1 --- /dev/null +++ b/apps/client/src/features/base/components/cells/cell-created-at.tsx @@ -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 ; + } + + return {formatted}; +} diff --git a/apps/client/src/features/base/components/cells/cell-date.tsx b/apps/client/src/features/base/components/cells/cell-date.tsx new file mode 100644 index 00000000..f245d3f8 --- /dev/null +++ b/apps/client/src/features/base/components/cells/cell-date.tsx @@ -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 ( + + +
+ + {formatDateDisplay(dateStr, typeOptions)} + +
+
+ + + +
+ ); + } + + if (!dateStr) { + return ; + } + + return ( + + {formatDateDisplay(dateStr, typeOptions)} + + ); +} diff --git a/apps/client/src/features/base/components/cells/cell-email.tsx b/apps/client/src/features/base/components/cells/cell-email.tsx new file mode 100644 index 00000000..364d5e9e --- /dev/null +++ b/apps/client/src/features/base/components/cells/cell-email.tsx @@ -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(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 ( + setDraft(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + /> + ); + } + + if (!displayValue) { + return ; + } + + return ( + e.stopPropagation()} + > + {displayValue} + + ); +} diff --git a/apps/client/src/features/base/components/cells/cell-file.tsx b/apps/client/src/features/base/components/cells/cell-file.tsx new file mode 100644 index 00000000..878758e6 --- /dev/null +++ b/apps/client/src/features/base/components/cells/cell-file.tsx @@ -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 ; + } + + const MAX_VISIBLE = 2; + const visible = files.slice(0, MAX_VISIBLE); + const overflow = files.length - MAX_VISIBLE; + + return ( +
+ {visible.map((file) => ( + + + {file.name} + + ))} + {overflow > 0 && ( + +{overflow} + )} +
+ ); +} diff --git a/apps/client/src/features/base/components/cells/cell-last-edited-at.tsx b/apps/client/src/features/base/components/cells/cell-last-edited-at.tsx new file mode 100644 index 00000000..93d46779 --- /dev/null +++ b/apps/client/src/features/base/components/cells/cell-last-edited-at.tsx @@ -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 ; + } + + return {formatted}; +} diff --git a/apps/client/src/features/base/components/cells/cell-last-edited-by.tsx b/apps/client/src/features/base/components/cells/cell-last-edited-by.tsx new file mode 100644 index 00000000..18535187 --- /dev/null +++ b/apps/client/src/features/base/components/cells/cell-last-edited-by.tsx @@ -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 ; + } + + return ( + + + {user?.name && ( + + {user.name} + + )} + + ); +} diff --git a/apps/client/src/features/base/components/cells/cell-multi-select.tsx b/apps/client/src/features/base/components/cells/cell-multi-select.tsx new file mode 100644 index 00000000..f76366f0 --- /dev/null +++ b/apps/client/src/features/base/components/cells/cell-multi-select.tsx @@ -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(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 ( + + +
+ +
+
+ + setSearch(e.currentTarget.value)} + onKeyDown={handleKeyDown} + mb={4} + /> +
+ {filteredChoices.map((choice) => ( +
handleToggle(choice)} + > + + {choice.name} + +
+ ))} +
+
+
+ ); + } + + if (selectedChoices.length === 0) { + return ; + } + + return ; +} + +function BadgeList({ + choices, + maxVisible, +}: { + choices: Choice[]; + maxVisible: number; +}) { + const visible = choices.slice(0, maxVisible); + const overflow = choices.length - maxVisible; + + return ( +
+ {visible.map((choice) => ( + + {choice.name} + + ))} + {overflow > 0 && ( + +{overflow} + )} +
+ ); +} diff --git a/apps/client/src/features/base/components/cells/cell-number.tsx b/apps/client/src/features/base/components/cells/cell-number.tsx new file mode 100644 index 00000000..6ef61af7 --- /dev/null +++ b/apps/client/src/features/base/components/cells/cell-number.tsx @@ -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(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 ( + { + const v = e.target.value; + if (v === "" || v === "-" || /^-?\d*\.?\d*$/.test(v)) { + setDraft(v); + } + }} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + /> + ); + } + + if (numValue == null) { + return ; + } + + return ( + + {formatNumber(numValue, typeOptions)} + + ); +} diff --git a/apps/client/src/features/base/components/cells/cell-person.tsx b/apps/client/src/features/base/components/cells/cell-person.tsx new file mode 100644 index 00000000..b53b0c9b --- /dev/null +++ b/apps/client/src/features/base/components/cells/cell-person.tsx @@ -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 ; + } + + const MAX_VISIBLE = 4; + const visible = personIds.slice(0, MAX_VISIBLE); + const overflow = personIds.length - MAX_VISIBLE; + + return ( +
+ {visible.map((id) => ( +
+ {getInitials(id)} +
+ ))} + {overflow > 0 && ( + +{overflow} + )} +
+ ); +} diff --git a/apps/client/src/features/base/components/cells/cell-select.tsx b/apps/client/src/features/base/components/cells/cell-select.tsx new file mode 100644 index 00000000..38c6361c --- /dev/null +++ b/apps/client/src/features/base/components/cells/cell-select.tsx @@ -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(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 ( + + +
+ {selectedChoice ? ( + + {selectedChoice.name} + + ) : ( + + )} +
+
+ + setSearch(e.currentTarget.value)} + onKeyDown={handleKeyDown} + mb={4} + /> +
+ {filteredChoices.map((choice) => ( +
handleSelect(choice)} + > + + {choice.name} + +
+ ))} +
+
+
+ ); + } + + if (!selectedChoice) { + return ; + } + + return ( + + {selectedChoice.name} + + ); +} diff --git a/apps/client/src/features/base/components/cells/cell-status.tsx b/apps/client/src/features/base/components/cells/cell-status.tsx new file mode 100644 index 00000000..c21efd88 --- /dev/null +++ b/apps/client/src/features/base/components/cells/cell-status.tsx @@ -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 = { + 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(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 = {}; + 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 ( + + +
+ {selectedChoice ? ( + + {selectedChoice.name} + + ) : ( + + )} +
+
+ + setSearch(e.currentTarget.value)} + onKeyDown={handleKeyDown} + mb={4} + /> +
+ {groups.map((group) => ( +
+
+ {group.label} +
+ {group.choices.map((choice) => ( +
handleSelect(choice)} + > + + {choice.name} + +
+ ))} +
+ ))} +
+
+
+ ); + } + + if (!selectedChoice) { + return ; + } + + return ( + + {selectedChoice.name} + + ); +} diff --git a/apps/client/src/features/base/components/cells/cell-text.tsx b/apps/client/src/features/base/components/cells/cell-text.tsx new file mode 100644 index 00000000..d352d7f7 --- /dev/null +++ b/apps/client/src/features/base/components/cells/cell-text.tsx @@ -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(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 ( + setDraft(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + /> + ); + } + + if (!displayValue) { + return ; + } + + return {displayValue}; +} diff --git a/apps/client/src/features/base/components/cells/cell-url.tsx b/apps/client/src/features/base/components/cells/cell-url.tsx new file mode 100644 index 00000000..a69c443a --- /dev/null +++ b/apps/client/src/features/base/components/cells/cell-url.tsx @@ -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(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 ( + setDraft(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + /> + ); + } + + if (!displayValue) { + return ; + } + + return ( + e.stopPropagation()} + > + {displayValue} + + ); +} diff --git a/apps/client/src/features/base/components/cells/choice-color.ts b/apps/client/src/features/base/components/cells/choice-color.ts new file mode 100644 index 00000000..f4c9b5cf --- /dev/null +++ b/apps/client/src/features/base/components/cells/choice-color.ts @@ -0,0 +1,25 @@ +import { CSSProperties } from "react"; + +const colorMap: Record = { + 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})`, + }; +} diff --git a/apps/client/src/features/base/components/grid/add-row-button.tsx b/apps/client/src/features/base/components/grid/add-row-button.tsx new file mode 100644 index 00000000..a6e74e88 --- /dev/null +++ b/apps/client/src/features/base/components/grid/add-row-button.tsx @@ -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 ( +
+ + {t("New row")} +
+ ); +}); diff --git a/apps/client/src/features/base/components/grid/grid-cell.tsx b/apps/client/src/features/base/components/grid/grid-cell.tsx new file mode 100644 index 00000000..f8ff3ddc --- /dev/null +++ b/apps/client/src/features/base/components/grid/grid-cell.tsx @@ -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 +> = { + 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; + 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 ( +
+ {rowIndex + 1} +
+ ); + } + + if (!property) return null; + + const CellComponent = cellComponents[property.type]; + if (!CellComponent) return null; + + const value = cell.getValue(); + + return ( +
+ +
+ ); +}); diff --git a/apps/client/src/features/base/components/grid/grid-container.tsx b/apps/client/src/features/base/components/grid/grid-container.tsx new file mode 100644 index 00000000..e6a0988b --- /dev/null +++ b/apps/client/src/features/base/components/grid/grid-container.tsx @@ -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; + 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(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 ( + +
+
+ + + + + {paddingTop > 0 && ( +
+ )} + + {virtualItems.map((virtualRow) => { + const row = rows[virtualRow.index]; + if (!row) return null; + return ( + + ); + })} + + {paddingBottom > 0 && ( +
+ )} + + +
+
+ + ); +} diff --git a/apps/client/src/features/base/components/grid/grid-header-cell.tsx b/apps/client/src/features/base/components/grid/grid-header-cell.tsx new file mode 100644 index 00000000..9d54c80b --- /dev/null +++ b/apps/client/src/features/base/components/grid/grid-header-cell.tsx @@ -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 = { + 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; +}; + +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(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).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 ( +
+ {isRowNumber ? ( + flexRender(header.column.columnDef.header, header.getContext()) + ) : ( +
+ {TypeIcon && ( + + )} + + {flexRender(header.column.columnDef.header, header.getContext())} + +
+ )} + {header.column.getCanResize() && ( +
{ + e.stopPropagation(); + header.getResizeHandler()(e); + }} + onTouchStart={(e) => { + e.stopPropagation(); + header.getResizeHandler()(e); + }} + onPointerDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + /> + )} + {property && !isRowNumber && ( + + +
+ + e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + + + + )} +
+ ); +}); diff --git a/apps/client/src/features/base/components/grid/grid-header.tsx b/apps/client/src/features/base/components/grid/grid-header.tsx new file mode 100644 index 00000000..f418df07 --- /dev/null +++ b/apps/client/src/features/base/components/grid/grid-header.tsx @@ -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; + onAddColumn?: () => void; +}; + +export const GridHeader = memo(function GridHeader({ + table, + onAddColumn, +}: GridHeaderProps) { + const headerGroups = table.getHeaderGroups(); + + const handleAddColumn = useCallback(() => { + onAddColumn?.(); + }, [onAddColumn]); + + return ( +
+ {headerGroups[0]?.headers.map((header) => ( + + ))} + {onAddColumn && ( +
+ +
+ )} +
+ ); +}); diff --git a/apps/client/src/features/base/components/grid/grid-row.tsx b/apps/client/src/features/base/components/grid/grid-row.tsx new file mode 100644 index 00000000..75c6b352 --- /dev/null +++ b/apps/client/src/features/base/components/grid/grid-row.tsx @@ -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; + 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 ( +
{ + e.preventDefault(); + dragHandlers?.onDragEnd(); + }} + onDragLeave={dragHandlers?.onDragLeave} + > + {row.getVisibleCells().map((cell) => { + const isRowNumber = cell.column.id === "__row_number"; + return ( + + ); + })} +
+ ); +}); diff --git a/apps/client/src/features/base/components/property/choice-editor.tsx b/apps/client/src/features/base/components/property/choice-editor.tsx new file mode 100644 index 00000000..5fbe976c --- /dev/null +++ b/apps/client/src/features/base/components/property/choice-editor.tsx @@ -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(initialChoices); + const [focusChoiceId, setFocusChoiceId] = useState(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 ( + + + + {t("Options")} + + + + {t("Alphabetize")} + + + + {showCategories ? ( + setFocusChoiceId(null)} + onRename={handleRename} + onColorChange={handleColorChange} + onRemove={handleRemove} + onAdd={handleAdd} + onCategoryReorder={handleCategoryReorder} + /> + ) : ( + setFocusChoiceId(null)} + onRename={handleRename} + onColorChange={handleColorChange} + onRemove={handleRemove} + onAdd={handleAdd} + onReorder={handleReorder} + /> + )} + + + + + + + + + ); +} + +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 ( + + + + {draft.map((choice) => ( + + ))} + + + + onAdd()} + style={{ display: "flex", alignItems: "center", gap: 6, padding: "4px 0" }} + > + + {t("Add option")} + + + ); +} + +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 = { todo: [], inProgress: [], complete: [] }; + for (const choice of draft) { + const cat = choice.category ?? "todo"; + (groups[cat] ?? groups.todo).push(choice); + } + return groups; + }, [draft]); + + return ( + + {STATUS_CATEGORIES.map(({ value: category, label }) => ( + + ))} + + ); +} + +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 ( + + + {t(label)} + + + + + {choices.map((choice) => ( + + ))} + + + + onAdd(category)} + style={{ display: "flex", alignItems: "center", gap: 6, padding: "4px 0" }} + > + + {t("Add option")} + + + ); +} + +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(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 ( + +
+ +
+ onColorChange(choice.id, c)} /> + onRename(choice.id, e.currentTarget.value)} + style={{ flex: 1 }} + error={hasError} + styles={hasError ? { input: { borderColor: "var(--mantine-color-red-6)" } } : undefined} + /> + onRemove(choice.id)} /> +
+ ); +} + +function ColorDot({ + color, + onChange, +}: { + color: string; + onChange: (color: string) => void; +}) { + const [opened, setOpened] = useState(false); + const colors = choiceColor(color); + + return ( + + + setOpened((o) => !o)} + style={{ + width: 20, + height: 20, + borderRadius: "50%", + backgroundColor: colors.backgroundColor as string, + border: `2px solid ${colors.color as string}`, + flexShrink: 0, + }} + /> + + + + {CHOICE_COLORS.map((c) => { + const dotColors = choiceColor(c); + return ( + { + 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", + }} + /> + ); + })} + + + + ); +} diff --git a/apps/client/src/features/base/components/property/property-menu.tsx b/apps/client/src/features/base/components/property/property-menu.tsx new file mode 100644 index 00000000..4bcfadcb --- /dev/null +++ b/apps/client/src/features/base/components/property/property-menu.tsx @@ -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("main"); + const [renameValue, setRenameValue] = useState(property.name); + const renameInputRef = useRef(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) => { + 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" && ( + setPanel("rename")} + onChangeType={() => setPanel("changeType")} + onOptions={() => setPanel("options")} + onDelete={() => setPanel("confirmDelete")} + /> + )} + {panel === "rename" && ( + + + {t("Rename property")} + + setRenameValue(e.currentTarget.value)} + onKeyDown={handleRenameKeyDown} + onBlur={commitRename} + /> + + )} + {panel === "changeType" && ( + setPanel("main")} + /> + )} + {(panel === "options" || panel === "confirmDiscard") && ( + + + + + + + {t("Property options")} + + + + + + + )} + {panel === "confirmDelete" && ( + + + {t("Delete property")} + + + {t("Are you sure you want to delete")} {property.name}?{" "} + {t("All data in this column will be lost.")} + + + + + + + )} + {panel === "confirmDiscard" && ( + + + {t("Unsaved changes")} + + + {t("You have unsaved changes. Do you want to discard them?")} + + + + + + + )} + + ); +} + +// 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 ( + + + {icon} + {label} + + {rightIcon} + + ); +} + +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 ( + + } + label={t("Rename")} + onClick={onRename} + /> + {!isSystem && ( + } + label={t("Change type")} + rightIcon={} + onClick={onChangeType} + /> + )} + {hasOptions && ( + } + label={t("Options")} + rightIcon={} + onClick={onOptions} + /> + )} + {!property.isPrimary && ( + <> + + } + label={t("Delete property")} + color="red" + onClick={onDelete} + /> + + )} + + ); +} + +function TypePanel({ + currentType, + onSelect, + onBack, +}: { + currentType: BasePropertyType; + onSelect: (type: BasePropertyType) => void; + onBack: () => void; +}) { + const { t } = useTranslation(); + + return ( + + + + + + + {t("Change type")} + + + + + + + ); +} diff --git a/apps/client/src/features/base/components/property/property-options.tsx b/apps/client/src/features/base/components/property/property-options.tsx new file mode 100644 index 00000000..7a34e69b --- /dev/null +++ b/apps/client/src/features/base/components/property/property-options.tsx @@ -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) => 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 ( + + ); + case "status": + return ( + + ); + case "number": + return ( + + ); + case "date": + return ( + + ); + default: + return ( + + {t("No options for this property type")} + + ); + } +} + +function SelectOptions({ + property, + onUpdate, + onClose, + onDirtyChange, +}: { + property: IBaseProperty; + onUpdate: (typeOptions: Record) => 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 ( + + ); +} + +function StatusOptions({ + property, + onUpdate, + onClose, + onDirtyChange, +}: { + property: IBaseProperty; + onUpdate: (typeOptions: Record) => 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 ( + + ); +} + +function NumberOptions({ + property, + onUpdate, +}: { + property: IBaseProperty; + onUpdate: (typeOptions: Record) => void; +}) { + const { t } = useTranslation(); + const options = property.typeOptions as NumberTypeOptions | undefined; + + return ( + + + onUpdate({ ...property.typeOptions, timeFormat: val }) + } + /> + )} + + ); +} diff --git a/apps/client/src/features/base/components/property/property-type-picker.tsx b/apps/client/src/features/base/components/property/property-type-picker.tsx new file mode 100644 index 00000000..5d7008ab --- /dev/null +++ b/apps/client/src/features/base/components/property/property-type-picker.tsx @@ -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; +}; + +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 }) => ( + onSelect(type)} + style={{ + fontWeight: type === currentType ? 600 : 400, + }} + > + + + {t(labelKey)} + + {type === currentType && } + + ))} + + ); +} + +export { propertyTypes }; diff --git a/apps/client/src/features/base/components/views/view-field-visibility.tsx b/apps/client/src/features/base/components/views/view-field-visibility.tsx new file mode 100644 index 00000000..9cfe8ee1 --- /dev/null +++ b/apps/client/src/features/base/components/views/view-field-visibility.tsx @@ -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; + 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 ( + + {children} + + + + + {t("Fields")} + + + + + {t("Show all")} + + + + + {t("Hide all")} + + + + + + + + + {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 ( + { + if (canHide) { + handleToggle(col.id, !isVisible); + } + }} + style={{ opacity: canHide ? 1 : 0.5 }} + > + + {TypeIcon && } + + {property.name} + + + {}} + styles={{ track: { cursor: canHide ? "pointer" : "not-allowed" } }} + /> + + ); + })} + + + + + ); +} diff --git a/apps/client/src/features/base/components/views/view-filter-config.tsx b/apps/client/src/features/base/components/views/view-filter-config.tsx new file mode 100644 index 00000000..28b91e52 --- /dev/null +++ b/apps/client/src/features/base/components/views/view-filter-config.tsx @@ -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 ( + + {children} + + + + {t("Filter by")} + + + {filters.length === 0 && ( + + {t("No filters applied")} + + )} + + {filters.map((filter, index) => { + const needsValue = !NO_VALUE_OPERATORS.includes(filter.operator); + + return ( + + handleOperatorChange(index, val)} + w={130} + /> + {needsValue && ( + + handleValueChange(index, e.currentTarget.value) + } + w={100} + /> + )} + handleRemove(index)} + > + + + + ); + })} + + + + {t("Add filter")} + + + + + ); +} diff --git a/apps/client/src/features/base/components/views/view-sort-config.tsx b/apps/client/src/features/base/components/views/view-sort-config.tsx new file mode 100644 index 00000000..5043213e --- /dev/null +++ b/apps/client/src/features/base/components/views/view-sort-config.tsx @@ -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 ( + + {children} + + + + {t("Sort by")} + + + {sorts.length === 0 && ( + + {t("No sorts applied")} + + )} + + {sorts.map((sort, index) => ( + + handleDirectionChange(index, val)} + w={110} + /> + handleRemove(index)} + > + + + + ))} + + + + {t("Add sort")} + + + + + ); +} diff --git a/apps/client/src/features/base/components/views/view-tabs.tsx b/apps/client/src/features/base/components/views/view-tabs.tsx new file mode 100644 index 00000000..4f9ef769 --- /dev/null +++ b/apps/client/src/features/base/components/views/view-tabs.tsx @@ -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(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 ( + + {views.map((view) => ( + 1} + onClick={() => onViewChange(view.id)} + onRenameStart={() => handleRenameStart(view)} + onRenameChange={setEditingName} + onRenameCommit={handleRenameCommit} + onRenameKeyDown={handleRenameKeyDown} + onDelete={() => handleDelete(view.id)} + /> + ))} + {onAddView && ( + + + + + + )} + + ); +} + +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 ( + onRenameChange(e.currentTarget.value)} + onBlur={onRenameCommit} + onKeyDown={onRenameKeyDown} + autoFocus + /> + ); + } + + return ( + setMenuOpened(false)} + position="bottom-start" + shadow="md" + width={180} + withinPortal + > + + { + e.preventDefault(); + setMenuOpened(true); + }} + style={{ + padding: "4px 10px", + borderRadius: "var(--mantine-radius-sm)", + fontWeight: isActive ? 600 : 400, + }} + > + + + + {view.name} + + + + + + + { + setMenuOpened(false); + onRenameStart(); + }} + > + + + {t("Rename")} + + + {canDelete && ( + <> + + { + setMenuOpened(false); + onDelete(); + }} + style={{ color: "var(--mantine-color-red-6)" }} + > + + + {t("Delete view")} + + + + )} + + + + ); +} diff --git a/apps/client/src/features/base/hooks/use-base-table.ts b/apps/client/src/features/base/hooks/use-base-table.ts new file mode 100644 index 00000000..963a95e1 --- /dev/null +++ b/apps/client/src/features/base/hooks/use-base-table.ts @@ -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(); + +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[] { + 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; + persistViewConfig: () => void; +}; + +export function useBaseTable( + base: IBase | undefined, + rows: IBaseRow[], + activeView: IBaseView | undefined, +): UseBaseTableResult { + const updateViewMutation = useUpdateViewMutation(); + const persistTimerRef = useRef | 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(derivedColumnOrder); + const [columnVisibility, setColumnVisibility] = useState(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 = {}; + 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 }; +} diff --git a/apps/client/src/features/base/hooks/use-column-resize.ts b/apps/client/src/features/base/hooks/use-column-resize.ts new file mode 100644 index 00000000..4c373700 --- /dev/null +++ b/apps/client/src/features/base/hooks/use-column-resize.ts @@ -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, + 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, + }; +} diff --git a/apps/client/src/features/base/hooks/use-grid-keyboard-nav.ts b/apps/client/src/features/base/hooks/use-grid-keyboard-nav.ts new file mode 100644 index 00000000..12f92ab5 --- /dev/null +++ b/apps/client/src/features/base/hooks/use-grid-keyboard-nav.ts @@ -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; + editingCell: EditingCell; + setEditingCell: (cell: EditingCell) => void; + containerRef: React.RefObject; +}; + +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]); +} diff --git a/apps/client/src/features/base/hooks/use-row-drag.ts b/apps/client/src/features/base/hooks/use-row-drag.ts new file mode 100644 index 00000000..820f7f38 --- /dev/null +++ b/apps/client/src/features/base/hooks/use-row-drag.ts @@ -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({ + dragRowId: null, + dropTargetRowId: null, + dropPosition: null, + }); + + const dragRowIdRef = useRef(null); + const dropTargetRef = useRef(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, + }; +} diff --git a/apps/client/src/features/base/queries/base-property-query.ts b/apps/client/src/features/base/queries/base-property-query.ts new file mode 100644 index 00000000..a4531ee8 --- /dev/null +++ b/apps/client/src/features/base/queries/base-property-query.ts @@ -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({ + mutationFn: (data) => createProperty(data), + onSuccess: (newProperty) => { + queryClient.setQueryData( + ["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({ + mutationFn: (data) => updateProperty(data), + onSuccess: (result, variables) => { + queryClient.setQueryData( + ["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({ + mutationFn: (data) => deleteProperty(data), + onSuccess: (_, variables) => { + queryClient.setQueryData( + ["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({ + mutationFn: (data) => reorderProperty(data), + onMutate: async (variables) => { + await queryClient.cancelQueries({ + queryKey: ["bases", variables.baseId], + }); + + const previous = queryClient.getQueryData([ + "bases", + variables.baseId, + ]); + + queryClient.setQueryData( + ["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", + }); + }, + }); +} diff --git a/apps/client/src/features/base/queries/base-query.ts b/apps/client/src/features/base/queries/base-query.ts new file mode 100644 index 00000000..95be08c4 --- /dev/null +++ b/apps/client/src/features/base/queries/base-query.ts @@ -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 { + return useQuery({ + queryKey: ["bases", baseId], + queryFn: () => getBaseInfo(baseId!), + enabled: !!baseId, + staleTime: 5 * 60 * 1000, + }); +} + +export function useCreateBaseMutation() { + const { t } = useTranslation(); + return useMutation({ + 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({ + mutationFn: (data) => updateBase(data), + onSuccess: (data) => { + queryClient.setQueryData(["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({ + 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", + }); + }, + }); +} diff --git a/apps/client/src/features/base/queries/base-row-query.ts b/apps/client/src/features/base/queries/base-row-query.ts new file mode 100644 index 00000000..f856e52d --- /dev/null +++ b/apps/client/src/features/base/queries/base-row-query.ts @@ -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> | 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) => + lastPage.meta?.nextCursor ?? undefined, + staleTime: 5 * 60 * 1000, + }); +} + +export function flattenRows( + data: InfiniteData> | undefined, +): IBaseRow[] { + if (!data) return []; + return data.pages.flatMap((page) => page.items); +} + +export function useCreateRowMutation() { + const { t } = useTranslation(); + return useMutation({ + mutationFn: (data) => createRow(data), + onSuccess: (newRow) => { + queryClient.setQueryData>>( + ["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({ + mutationFn: (data) => updateRow(data), + onMutate: async (variables) => { + await queryClient.cancelQueries({ + queryKey: ["base-rows", variables.baseId], + }); + + const previous = queryClient.getQueryData< + InfiniteData> + >(["base-rows", variables.baseId]); + + queryClient.setQueryData>>( + ["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>>( + ["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({ + mutationFn: (data) => deleteRow(data), + onMutate: async (variables) => { + await queryClient.cancelQueries({ + queryKey: ["base-rows", variables.baseId], + }); + + const previous = queryClient.getQueryData< + InfiniteData> + >(["base-rows", variables.baseId]); + + queryClient.setQueryData>>( + ["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({ + mutationFn: (data) => reorderRow(data), + onMutate: async (variables) => { + await queryClient.cancelQueries({ + queryKey: ["base-rows", variables.baseId], + }); + + const previous = queryClient.getQueryData< + InfiniteData> + >(["base-rows", variables.baseId]); + + queryClient.setQueryData>>( + ["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", + }); + }, + }); +} diff --git a/apps/client/src/features/base/queries/base-view-query.ts b/apps/client/src/features/base/queries/base-view-query.ts new file mode 100644 index 00000000..2ef124df --- /dev/null +++ b/apps/client/src/features/base/queries/base-view-query.ts @@ -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({ + mutationFn: (data) => createView(data), + onSuccess: (newView) => { + queryClient.setQueryData( + ["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({ + mutationFn: (data) => updateView(data), + onMutate: async (variables) => { + await queryClient.cancelQueries({ + queryKey: ["bases", variables.baseId], + }); + + const previous = queryClient.getQueryData([ + "bases", + variables.baseId, + ]); + + queryClient.setQueryData( + ["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( + ["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({ + mutationFn: (data) => deleteView(data), + onSuccess: (_, variables) => { + queryClient.setQueryData( + ["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", + }); + }, + }); +} diff --git a/apps/client/src/features/base/services/base-service.ts b/apps/client/src/features/base/services/base-service.ts new file mode 100644 index 00000000..b00ad7f9 --- /dev/null +++ b/apps/client/src/features/base/services/base-service.ts @@ -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 { + const req = await api.post("/bases/create", data); + return req.data; +} + +export async function getBaseInfo(baseId: string): Promise { + const req = await api.post("/bases/info", { baseId }); + return req.data; +} + +export async function updateBase(data: UpdateBaseInput): Promise { + const req = await api.post("/bases/update", data); + return req.data; +} + +export async function deleteBase(baseId: string): Promise { + await api.post("/bases/delete", { baseId }); +} + +export async function listBases( + spaceId: string, + params?: { cursor?: string; limit?: number }, +): Promise> { + const req = await api.post("/bases/list", { spaceId, ...params }); + return req.data; +} + +// --- Properties --- + +export async function createProperty( + data: CreatePropertyInput, +): Promise { + const req = await api.post("/bases/properties/create", data); + return req.data; +} + +export async function updateProperty( + data: UpdatePropertyInput, +): Promise { + const req = await api.post( + "/bases/properties/update", + data, + ); + return req.data; +} + +export async function deleteProperty(data: DeletePropertyInput): Promise { + await api.post("/bases/properties/delete", data); +} + +export async function reorderProperty( + data: ReorderPropertyInput, +): Promise { + await api.post("/bases/properties/reorder", data); +} + +// --- Rows --- + +export async function createRow(data: CreateRowInput): Promise { + const req = await api.post("/bases/rows/create", data); + return req.data; +} + +export async function getRowInfo( + rowId: string, + baseId: string, +): Promise { + const req = await api.post("/bases/rows/info", { rowId, baseId }); + return req.data; +} + +export async function updateRow(data: UpdateRowInput): Promise { + const req = await api.post("/bases/rows/update", data); + return req.data; +} + +export async function deleteRow(data: DeleteRowInput): Promise { + await api.post("/bases/rows/delete", data); +} + +export async function listRows( + baseId: string, + params?: { viewId?: string; cursor?: string; limit?: number }, +): Promise> { + const req = await api.post("/bases/rows/list", { baseId, ...params }); + return req.data; +} + +export async function reorderRow(data: ReorderRowInput): Promise { + await api.post("/bases/rows/reorder", data); +} + +// --- Views --- + +export async function createView(data: CreateViewInput): Promise { + const req = await api.post("/bases/views/create", data); + return req.data; +} + +export async function updateView(data: UpdateViewInput): Promise { + const req = await api.post("/bases/views/update", data); + return req.data; +} + +export async function deleteView(data: DeleteViewInput): Promise { + await api.post("/bases/views/delete", data); +} + +export async function listViews(baseId: string): Promise { + const req = await api.post("/bases/views/list", { baseId }); + return req.data; +} diff --git a/apps/client/src/features/base/styles/cells.module.css b/apps/client/src/features/base/styles/cells.module.css new file mode 100644 index 00000000..6212a6d0 --- /dev/null +++ b/apps/client/src/features/base/styles/cells.module.css @@ -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)); +} diff --git a/apps/client/src/features/base/styles/grid.module.css b/apps/client/src/features/base/styles/grid.module.css new file mode 100644 index 00000000..3ca1aa66 --- /dev/null +++ b/apps/client/src/features/base/styles/grid.module.css @@ -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; +} diff --git a/apps/client/src/features/base/types/base.types.ts b/apps/client/src/features/base/types/base.types.ts new file mode 100644 index 00000000..8e29024c --- /dev/null +++ b/apps/client/src/features/base/types/base.types.ts @@ -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; + +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; + 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; + 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; + afterRowId?: string; +}; + +export type UpdateRowInput = { + rowId: string; + baseId: string; + cells: Record; +}; + +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; +}; diff --git a/apps/client/src/features/base/types/react-table.d.ts b/apps/client/src/features/base/types/react-table.d.ts new file mode 100644 index 00000000..a2f10d49 --- /dev/null +++ b/apps/client/src/features/base/types/react-table.d.ts @@ -0,0 +1,8 @@ +import "@tanstack/react-table"; +import { IBaseProperty } from "@/features/base/types/base.types"; + +declare module "@tanstack/react-table" { + interface ColumnMeta { + property?: IBaseProperty; + } +} diff --git a/apps/client/src/features/space/components/space-home-tabs.tsx b/apps/client/src/features/space/components/space-home-tabs.tsx index 922d5cc8..77215f77 100644 --- a/apps/client/src/features/space/components/space-home-tabs.tsx +++ b/apps/client/src/features/space/components/space-home-tabs.tsx @@ -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?.id && } diff --git a/apps/client/src/pages/base/base-page.tsx b/apps/client/src/pages/base/base-page.tsx new file mode 100644 index 00000000..fd54664c --- /dev/null +++ b/apps/client/src/pages/base/base-page.tsx @@ -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 ( + + No base ID provided + + ); + } + + return ( + + {base && ( + + {base.icon ? `${base.icon} ` : ""}{base.name} + + )} + + + ); +} diff --git a/apps/server/package.json b/apps/server/package.json index 65cb8e59..b3ec5295 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -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", diff --git a/apps/server/src/core/base/base.module.ts b/apps/server/src/core/base/base.module.ts new file mode 100644 index 00000000..13e2f8e6 --- /dev/null +++ b/apps/server/src/core/base/base.module.ts @@ -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 {} diff --git a/apps/server/src/core/base/base.schemas.ts b/apps/server/src/core/base/base.schemas.ts new file mode 100644 index 00000000..9ecff634 --- /dev/null +++ b/apps/server/src/core/base/base.schemas.ts @@ -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 = 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 = { + [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 { + 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; + } + 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> = { + [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 { + const schema = cellValueSchemaMap[type]; + if (!schema) { + return { success: false, error: new z.ZodError([{ code: 'custom', message: `Unknown property type: ${type}`, path: [] }]) } as z.SafeParseError; + } + 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; diff --git a/apps/server/src/core/base/controllers/base-property.controller.ts b/apps/server/src/core/base/controllers/base-property.controller.ts new file mode 100644 index 00000000..1b04d1ef --- /dev/null +++ b/apps/server/src/core/base/controllers/base-property.controller.ts @@ -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); + } +} diff --git a/apps/server/src/core/base/controllers/base-row.controller.ts b/apps/server/src/core/base/controllers/base-row.controller.ts new file mode 100644 index 00000000..b65d6060 --- /dev/null +++ b/apps/server/src/core/base/controllers/base-row.controller.ts @@ -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); + } +} diff --git a/apps/server/src/core/base/controllers/base-view.controller.ts b/apps/server/src/core/base/controllers/base-view.controller.ts new file mode 100644 index 00000000..28159a74 --- /dev/null +++ b/apps/server/src/core/base/controllers/base-view.controller.ts @@ -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); + } +} diff --git a/apps/server/src/core/base/controllers/base.controller.ts b/apps/server/src/core/base/controllers/base.controller.ts new file mode 100644 index 00000000..df09538b --- /dev/null +++ b/apps/server/src/core/base/controllers/base.controller.ts @@ -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); + } +} diff --git a/apps/server/src/core/base/dto/base.dto.ts b/apps/server/src/core/base/dto/base.dto.ts new file mode 100644 index 00000000..14adf785 --- /dev/null +++ b/apps/server/src/core/base/dto/base.dto.ts @@ -0,0 +1,6 @@ +import { IsUUID } from 'class-validator'; + +export class BaseIdDto { + @IsUUID() + baseId: string; +} diff --git a/apps/server/src/core/base/dto/create-base.dto.ts b/apps/server/src/core/base/dto/create-base.dto.ts new file mode 100644 index 00000000..ed2fce2f --- /dev/null +++ b/apps/server/src/core/base/dto/create-base.dto.ts @@ -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; +} diff --git a/apps/server/src/core/base/dto/create-property.dto.ts b/apps/server/src/core/base/dto/create-property.dto.ts new file mode 100644 index 00000000..6cde2fe7 --- /dev/null +++ b/apps/server/src/core/base/dto/create-property.dto.ts @@ -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; +} diff --git a/apps/server/src/core/base/dto/create-row.dto.ts b/apps/server/src/core/base/dto/create-row.dto.ts new file mode 100644 index 00000000..875b9c72 --- /dev/null +++ b/apps/server/src/core/base/dto/create-row.dto.ts @@ -0,0 +1,14 @@ +import { IsObject, IsOptional, IsString, IsUUID } from 'class-validator'; + +export class CreateRowDto { + @IsUUID() + baseId: string; + + @IsOptional() + @IsObject() + cells?: Record; + + @IsOptional() + @IsString() + afterRowId?: string; +} diff --git a/apps/server/src/core/base/dto/create-view.dto.ts b/apps/server/src/core/base/dto/create-view.dto.ts new file mode 100644 index 00000000..2de07292 --- /dev/null +++ b/apps/server/src/core/base/dto/create-view.dto.ts @@ -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; +} diff --git a/apps/server/src/core/base/dto/update-base.dto.ts b/apps/server/src/core/base/dto/update-base.dto.ts new file mode 100644 index 00000000..803056a0 --- /dev/null +++ b/apps/server/src/core/base/dto/update-base.dto.ts @@ -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; +} diff --git a/apps/server/src/core/base/dto/update-property.dto.ts b/apps/server/src/core/base/dto/update-property.dto.ts new file mode 100644 index 00000000..4088dfde --- /dev/null +++ b/apps/server/src/core/base/dto/update-property.dto.ts @@ -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; +} + +export class DeletePropertyDto { + @IsUUID() + propertyId: string; + + @IsUUID() + baseId: string; +} + +export class ReorderPropertyDto { + @IsUUID() + propertyId: string; + + @IsUUID() + baseId: string; + + @IsString() + @IsNotEmpty() + position: string; +} diff --git a/apps/server/src/core/base/dto/update-row.dto.ts b/apps/server/src/core/base/dto/update-row.dto.ts new file mode 100644 index 00000000..1f8aa1be --- /dev/null +++ b/apps/server/src/core/base/dto/update-row.dto.ts @@ -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; +} + +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; +} diff --git a/apps/server/src/core/base/dto/update-view.dto.ts b/apps/server/src/core/base/dto/update-view.dto.ts new file mode 100644 index 00000000..97e5876c --- /dev/null +++ b/apps/server/src/core/base/dto/update-view.dto.ts @@ -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; +} + +export class DeleteViewDto { + @IsUUID() + viewId: string; + + @IsUUID() + baseId: string; +} diff --git a/apps/server/src/core/base/services/base-property.service.ts b/apps/server/src/core/base/services/base-property.service.ts new file mode 100644 index 00000000..c1aa1dc9 --- /dev/null +++ b/apps/server/src/core/base/services/base-property.service.ts @@ -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 }> = []; + + for (const row of rows) { + const cells = row.cells as Record; + 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 }> = []; + + for (const row of rows) { + const cells = row.cells as Record; + 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 }; + } +} diff --git a/apps/server/src/core/base/services/base-row.service.ts b/apps/server/src/core/base/services/base-row.service.ts new file mode 100644 index 00000000..5dbec2a4 --- /dev/null +++ b/apps/server/src/core/base/services/base-row.service.ts @@ -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 = {}; + 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, + properties: BaseProperty[], + ): Record { + const propertyMap = new Map(properties.map((p) => [p.id, p])); + const validatedCells: Record = {}; + 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; + } +} diff --git a/apps/server/src/core/base/services/base-view.service.ts b/apps/server/src/core/base/services/base-view.service.ts new file mode 100644 index 00000000..1a940705 --- /dev/null +++ b/apps/server/src/core/base/services/base-view.service.ts @@ -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); + } +} diff --git a/apps/server/src/core/base/services/base.service.ts b/apps/server/src/core/base/services/base.service.ts new file mode 100644 index 00000000..a3ed057f --- /dev/null +++ b/apps/server/src/core/base/services/base.service.ts @@ -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); + } +} diff --git a/apps/server/src/core/casl/abilities/space-ability.factory.ts b/apps/server/src/core/casl/abilities/space-ability.factory.ts index 53a57a0c..6837fb94 100644 --- a/apps/server/src/core/casl/abilities/space-ability.factory.ts +++ b/apps/server/src/core/casl/abilities/space-ability.factory.ts @@ -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(); } diff --git a/apps/server/src/core/casl/interfaces/space-ability.type.ts b/apps/server/src/core/casl/interfaces/space-ability.type.ts index d7801cab..aa32648c 100644 --- a/apps/server/src/core/casl/interfaces/space-ability.type.ts +++ b/apps/server/src/core/casl/interfaces/space-ability.type.ts @@ -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]; diff --git a/apps/server/src/core/core.module.ts b/apps/server/src/core/core.module.ts index f8b75cd0..54c33596 100644 --- a/apps/server/src/core/core.module.ts +++ b/apps/server/src/core/core.module.ts @@ -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 { diff --git a/apps/server/src/database/database.module.ts b/apps/server/src/database/database.module.ts index 6272ead1..b7baf43a 100644 --- a/apps/server/src/database/database.module.ts +++ b/apps/server/src/database/database.module.ts @@ -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 diff --git a/apps/server/src/database/migrations/20260218T120000-bases.ts b/apps/server/src/database/migrations/20260218T120000-bases.ts new file mode 100644 index 00000000..5e1d7110 --- /dev/null +++ b/apps/server/src/database/migrations/20260218T120000-bases.ts @@ -0,0 +1,154 @@ +import { type Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + 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): Promise { + 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(); +} diff --git a/apps/server/src/database/repos/base/base-property.repo.ts b/apps/server/src/database/repos/base/base-property.repo.ts new file mode 100644 index 00000000..46158172 --- /dev/null +++ b/apps/server/src/database/repos/base/base-property.repo.ts @@ -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 { + const db = dbOrTx(this.db, opts?.trx); + return db + .selectFrom('baseProperties') + .selectAll() + .where('id', '=', propertyId) + .executeTakeFirst() as Promise; + } + + async findByBaseId( + baseId: string, + opts?: { trx?: KyselyTransaction }, + ): Promise { + const db = dbOrTx(this.db, opts?.trx); + return db + .selectFrom('baseProperties') + .selectAll() + .where('baseId', '=', baseId) + .orderBy('position', 'asc') + .execute() as Promise; + } + + async getLastPosition( + baseId: string, + trx?: KyselyTransaction, + ): Promise { + 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 { + const db = dbOrTx(this.db, trx); + return db + .insertInto('baseProperties') + .values(property) + .returningAll() + .executeTakeFirstOrThrow() as Promise; + } + + async updateProperty( + propertyId: string, + data: UpdatableBaseProperty, + trx?: KyselyTransaction, + ): Promise { + 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 { + const db = dbOrTx(this.db, trx); + await db + .deleteFrom('baseProperties') + .where('id', '=', propertyId) + .execute(); + } +} diff --git a/apps/server/src/database/repos/base/base-row.repo.ts b/apps/server/src/database/repos/base/base-row.repo.ts new file mode 100644 index 00000000..2f5d59a8 --- /dev/null +++ b/apps/server/src/database/repos/base/base-row.repo.ts @@ -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 { + const db = dbOrTx(this.db, opts?.trx); + return db + .selectFrom('baseRows') + .selectAll() + .where('id', '=', rowId) + .where('deletedAt', 'is', null) + .executeTakeFirst() as Promise; + } + + 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 { + 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 { + const db = dbOrTx(this.db, trx); + return db + .insertInto('baseRows') + .values(row) + .returningAll() + .executeTakeFirstOrThrow() as Promise; + } + + async updateCells( + rowId: string, + cells: Record, + userId?: string, + trx?: KyselyTransaction, + ): Promise { + 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 { + 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 { + 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 { + 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 { + const db = dbOrTx(this.db, trx); + return db + .selectFrom('baseRows') + .selectAll() + .where('baseId', '=', baseId) + .where('deletedAt', 'is', null) + .execute() as Promise; + } + + async batchUpdateCells( + updates: Array<{ id: string; cells: Record }>, + trx?: KyselyTransaction, + ): Promise { + 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(); + } + } +} diff --git a/apps/server/src/database/repos/base/base-view.repo.ts b/apps/server/src/database/repos/base/base-view.repo.ts new file mode 100644 index 00000000..f936ca7c --- /dev/null +++ b/apps/server/src/database/repos/base/base-view.repo.ts @@ -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 { + const db = dbOrTx(this.db, opts?.trx); + return db + .selectFrom('baseViews') + .selectAll() + .where('id', '=', viewId) + .executeTakeFirst() as Promise; + } + + async findByBaseId( + baseId: string, + opts?: { trx?: KyselyTransaction }, + ): Promise { + const db = dbOrTx(this.db, opts?.trx); + return db + .selectFrom('baseViews') + .selectAll() + .where('baseId', '=', baseId) + .orderBy('position', 'asc') + .execute() as Promise; + } + + async countByBaseId( + baseId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + const result = await db + .selectFrom('baseViews') + .select((eb) => eb.fn.countAll().as('count')) + .where('baseId', '=', baseId) + .executeTakeFirstOrThrow(); + return Number(result.count); + } + + async getLastPosition( + baseId: string, + trx?: KyselyTransaction, + ): Promise { + 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 { + const db = dbOrTx(this.db, trx); + return db + .insertInto('baseViews') + .values(view) + .returningAll() + .executeTakeFirstOrThrow() as Promise; + } + + async updateView( + viewId: string, + data: UpdatableBaseView, + trx?: KyselyTransaction, + ): Promise { + 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 { + const db = dbOrTx(this.db, trx); + await db + .deleteFrom('baseViews') + .where('id', '=', viewId) + .execute(); + } +} diff --git a/apps/server/src/database/repos/base/base.repo.ts b/apps/server/src/database/repos/base/base.repo.ts new file mode 100644 index 00000000..43ecebcf --- /dev/null +++ b/apps/server/src/database/repos/base/base.repo.ts @@ -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 = [ + 'id', + 'name', + 'description', + 'icon', + 'pageId', + 'spaceId', + 'workspaceId', + 'creatorId', + 'createdAt', + 'updatedAt', + 'deletedAt', + ]; + + async findById( + baseId: string, + opts?: { + includeProperties?: boolean; + includeViews?: boolean; + trx?: KyselyTransaction; + }, + ): Promise { + 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; + } + + 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 { + const db = dbOrTx(this.db, trx); + return db + .insertInto('bases') + .values(base) + .returningAll() + .executeTakeFirstOrThrow() as Promise; + } + + async updateBase( + baseId: string, + data: UpdatableBase, + trx?: KyselyTransaction, + ): Promise { + 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 { + const db = dbOrTx(this.db, trx); + await db + .updateTable('bases') + .set({ deletedAt: new Date() }) + .where('id', '=', baseId) + .execute(); + } + + private withProperties(eb: ExpressionBuilder) { + return jsonArrayFrom( + eb + .selectFrom('baseProperties') + .selectAll('baseProperties') + .whereRef('baseProperties.baseId', '=', 'bases.id') + .orderBy('baseProperties.position', 'asc'), + ).as('properties'); + } + + private withViews(eb: ExpressionBuilder) { + return jsonArrayFrom( + eb + .selectFrom('baseViews') + .selectAll('baseViews') + .whereRef('baseViews.baseId', '=', 'bases.id') + .orderBy('baseViews.position', 'asc'), + ).as('views'); + } +} diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index 6668398b..90e20849 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -390,9 +390,66 @@ export interface Watchers { createdAt: Generated; } +export interface Bases { + id: Generated; + name: string; + description: string | null; + icon: string | null; + pageId: string | null; + spaceId: string; + workspaceId: string; + creatorId: string | null; + createdAt: Generated; + updatedAt: Generated; + deletedAt: Timestamp | null; +} + +export interface BaseProperties { + id: Generated; + baseId: string; + name: string; + type: string; + position: string; + typeOptions: Json | null; + isPrimary: Generated; + workspaceId: string; + createdAt: Generated; + updatedAt: Generated; +} + +export interface BaseRows { + id: Generated; + baseId: string; + cells: Generated; + position: string; + creatorId: string | null; + lastUpdatedById: string | null; + workspaceId: string; + createdAt: Generated; + updatedAt: Generated; + deletedAt: Timestamp | null; +} + +export interface BaseViews { + id: Generated; + baseId: string; + name: string; + type: Generated; + position: string; + config: Generated; + workspaceId: string; + creatorId: string | null; + createdAt: Generated; + updatedAt: Generated; +} + export interface DB { apiKeys: ApiKeys; attachments: Attachments; + baseProperties: BaseProperties; + baseRows: BaseRows; + baseViews: BaseViews; + bases: Bases; authAccounts: AuthAccounts; authProviders: AuthProviders; backlinks: Backlinks; diff --git a/apps/server/src/database/types/db.interface.ts b/apps/server/src/database/types/db.interface.ts index 58146b9e..47950386 100644 --- a/apps/server/src/database/types/db.interface.ts +++ b/apps/server/src/database/types/db.interface.ts @@ -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; diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts index 65e1024a..f2714474 100644 --- a/apps/server/src/database/types/entity.types.ts +++ b/apps/server/src/database/types/entity.types.ts @@ -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>; export type Watcher = Selectable; export type InsertableWatcher = Insertable; export type UpdatableWatcher = Updateable>; + +// Base +export type Base = Selectable; +export type InsertableBase = Insertable; +export type UpdatableBase = Updateable>; + +// Base Property +export type BaseProperty = Selectable; +export type InsertableBaseProperty = Insertable; +export type UpdatableBaseProperty = Updateable>; + +// Base Row +export type BaseRow = Selectable; +export type InsertableBaseRow = Insertable; +export type UpdatableBaseRow = Updateable>; + +// Base View +export type BaseView = Selectable; +export type InsertableBaseView = Insertable; +export type UpdatableBaseView = Updateable>; diff --git a/apps/server/src/scripts/seed-base-rows.ts b/apps/server/src/scripts/seed-base-rows.ts new file mode 100644 index 00000000..1596b0d8 --- /dev/null +++ b/apps/server/src/scripts/seed-base-rows.ts @@ -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({ + 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 = {}; + 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)); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 161aa6f1..cf57a344 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: