diff --git a/Dockerfile b/Dockerfile index d665e254b..85d39981e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,6 +28,8 @@ COPY --from=builder /app/apps/server/package.json /app/apps/server/package.json # Copy packages COPY --from=builder /app/packages/editor-ext/dist /app/packages/editor-ext/dist COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json +COPY --from=builder /app/packages/base-formula/dist /app/packages/base-formula/dist +COPY --from=builder /app/packages/base-formula/package.json /app/packages/base-formula/package.json # Copy root package files COPY --from=builder /app/package.json /app/package.json diff --git a/apps/client/package.json b/apps/client/package.json index bd6585f1f..86b20d9d0 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -18,6 +18,7 @@ "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.1.0", "@atlaskit/pragmatic-drag-and-drop-live-region": "1.3.4", "@casl/react": "5.0.1", + "@docmost/base-formula": "workspace:*", "@docmost/editor-ext": "workspace:*", "@excalidraw/excalidraw": "0.18.0-3a5ef40", "@mantine/core": "8.3.18", @@ -32,7 +33,8 @@ "@slidoapp/emoji-mart-react": "1.1.5", "@tabler/icons-react": "3.40.0", "@tanstack/react-query": "5.90.17", - "@tanstack/react-virtual": "3.13.24", + "@tanstack/react-table": "8.21.3", + "@tanstack/react-virtual": "3.14.2", "alfaaz": "1.1.0", "axios": "1.16.0", "blueimp-load-image": "5.16.0", diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 278021657..f72ac2855 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1085,5 +1085,41 @@ "Added {{name}} to favorites": "Added {{name}} to favorites", "Removed {{name}} from favorites": "Removed {{name}} from favorites", "Page menu for {{name}}": "Page menu for {{name}}", - "Create subpage of {{name}}": "Create subpage of {{name}}" + "Create subpage of {{name}}": "Create subpage of {{name}}", + "Apply": "Apply", + "Cells that aren't already a page reference will be cleared.": "Cells that aren't already a page reference will be cleared.", + "Cells that aren't a valid URL will be cleared.": "Cells that aren't a valid URL will be cleared.", + "Cells that aren't a valid email address will be cleared.": "Cells that aren't a valid email address will be cleared.", + "Cells that can't be parsed as a date will be cleared.": "Cells that can't be parsed as a date will be cleared.", + "Cells that can't be parsed as a number will be cleared.": "Cells that can't be parsed as a number will be cleared.", + "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).": "Cells will be coerced (yes/true/1 become checked; everything else becomes unchecked or cleared).", + "Cells will be reinterpreted under the new type.": "Cells will be reinterpreted under the new type.", + "Cells will be replaced with a comma-separated list of file names.": "Cells will be replaced with a comma-separated list of file names.", + "Cells will be replaced with a comma-separated list of option names.": "Cells will be replaced with a comma-separated list of option names.", + "Cells will be replaced with the option name.": "Cells will be replaced with the option name.", + "Cells will be replaced with the page title.": "Cells will be replaced with the page title.", + "Cells will be replaced with the person's name.": "Cells will be replaced with the person's name.", + "Change type": "Change type", + "Change type to {{label}}?": "Change type to {{label}}?", + "Converting…": "Converting…", + "Existing values become single-item lists. No data is lost.": "Existing values become single-item lists. No data is lost.", + "Only the first selected item per row will be kept; the rest will be discarded.": "Only the first selected item per row will be kept; the rest will be discarded.", + "Previous record": "Previous record", + "Next record": "Next record", + "Record actions": "Record actions", + "Delete record": "Delete record", + "Delete record?": "Delete record?", + "This action cannot be undone.": "This action cannot be undone.", + "to navigate": "to navigate", + "to close": "to close", + "Expand row {{number}}": "Expand row {{number}}", + "Saving…": "Saving…", + "Read-only": "Read-only", + "Loading…": "Loading…", + "Updated {{when}}": "Updated {{when}}", + "Add property": "Add property", + "Create property": "Create property", + "Hide properties": "Hide properties", + "Find a property type": "Find a property type", + "Properties": "Properties" } diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 789b48601..ab291ffea 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -38,6 +38,7 @@ import SpaceTrash from "@/pages/space/space-trash.tsx"; import UserApiKeys from "@/ee/api-key/pages/user-api-keys"; import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys"; import AiSettings from "@/ee/ai/pages/ai-settings.tsx"; +import BasePage from "@/ee/base/pages/base-page.tsx"; import AuditLogs from "@/ee/audit/pages/audit-logs.tsx"; import VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx"; import TemplateList from "@/ee/template/pages/template-list"; @@ -106,6 +107,8 @@ export default function App() { element={} /> + } /> + } /> {icon}; + } + return ( + + {isBase ? : } + + ); +} diff --git a/apps/client/src/components/common/recent-changes.tsx b/apps/client/src/components/common/recent-changes.tsx index f37b26f67..00b3146e2 100644 --- a/apps/client/src/components/common/recent-changes.tsx +++ b/apps/client/src/components/common/recent-changes.tsx @@ -4,15 +4,15 @@ import { UnstyledButton, Badge, Table, - ThemeIcon, Button, } from "@mantine/core"; import { Link } from "react-router-dom"; import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx"; -import { buildPageUrl } from "@/features/page/page.utils.ts"; +import { buildPageUrl, getPageTitle } from "@/features/page/page.utils.ts"; import { formattedDate } from "@/lib/time.ts"; import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts"; -import { IconFileDescription, IconFiles } from "@tabler/icons-react"; +import { PageListIcon } from "@/components/common/page-list-icon"; +import { IconFiles } from "@tabler/icons-react"; import { EmptyState } from "@/components/ui/empty-state.tsx"; import { getSpaceUrl } from "@/lib/config.ts"; import { useTranslation } from "react-i18next"; @@ -50,14 +50,10 @@ export default function RecentChanges({ spaceId }: Props) { to={buildPageUrl(page?.space.slug, page.slugId, page.title)} > - {page.icon || ( - - - - )} + - {page.title || t("Untitled")} + {getPageTitle(page.title, page.isBase, t)} diff --git a/apps/client/src/components/ui/destination-picker/page-row.tsx b/apps/client/src/components/ui/destination-picker/page-row.tsx index a8f63b394..a9e068f70 100644 --- a/apps/client/src/components/ui/destination-picker/page-row.tsx +++ b/apps/client/src/components/ui/destination-picker/page-row.tsx @@ -3,6 +3,7 @@ import { ActionIcon } from "@mantine/core"; import { IconChevronRight, IconFileDescription } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { IPage } from "@/features/page/types/page.types"; +import { getPageTitle } from "@/features/page/page.utils"; import { PageChildren } from "./page-children"; import classes from "./destination-picker.module.css"; @@ -95,7 +96,7 @@ export function PageRow({
- {page.title || t("Untitled")} + {getPageTitle(page.title, page.isBase, t)}
diff --git a/apps/client/src/ee/base/atoms/base-atoms.ts b/apps/client/src/ee/base/atoms/base-atoms.ts new file mode 100644 index 000000000..213553fac --- /dev/null +++ b/apps/client/src/ee/base/atoms/base-atoms.ts @@ -0,0 +1,43 @@ +import { atom } from "jotai"; +import { atomFamily } from "jotai/utils"; +import { EditingCell } from "@/ee/base/types/base.types"; + +// Atoms are scoped per-base via `pageId` so that two BaseTable instances on +// the same page don't share UI state. + +export const activeViewIdAtomFamily = atomFamily((_pageId: string) => + atom(null), +); + +export const editingCellAtomFamily = atomFamily((_pageId: string) => + atom(null), +); + +export type FormulaEditorTarget = { + propertyId: string; + rowId: string | null; +} | null; + +export const activeFormulaEditorAtomFamily = atomFamily((_pageId: string) => + atom(null), +); + +export const activePropertyMenuAtomFamily = atomFamily((_pageId: string) => + atom(null), +); + +export const propertyMenuDirtyAtomFamily = atomFamily((_pageId: string) => + atom(false), +); + +export const propertyMenuCloseRequestAtomFamily = atomFamily((_pageId: string) => + atom(0), +); + +export const selectedRowIdsAtomFamily = atomFamily((_pageId: string) => + atom>(new Set()), +); + +export const lastToggledRowIndexAtomFamily = atomFamily((_pageId: string) => + atom(null), +); diff --git a/apps/client/src/ee/base/atoms/formula-recompute-atom.ts b/apps/client/src/ee/base/atoms/formula-recompute-atom.ts new file mode 100644 index 000000000..320c89beb --- /dev/null +++ b/apps/client/src/ee/base/atoms/formula-recompute-atom.ts @@ -0,0 +1,3 @@ +import { atom } from "jotai"; + +export const formulaRecomputeAtom = atom>({}); diff --git a/apps/client/src/ee/base/atoms/reference-store-atom.ts b/apps/client/src/ee/base/atoms/reference-store-atom.ts new file mode 100644 index 000000000..26bcdf8b6 --- /dev/null +++ b/apps/client/src/ee/base/atoms/reference-store-atom.ts @@ -0,0 +1,20 @@ +import { atom } from "jotai"; +import { atomFamily } from "jotai/utils"; +import type { RowReferences } from "@/ee/base/types/base.types"; + +// Per-base normalized store of resolved reference entities, hydrated from each +// rows-page `references`. Keyed by pageId, matching base-atoms.ts. +export const referenceStoreAtomFamily = atomFamily((_pageId: string) => + atom({ users: {}, pages: {} }), +); + +export function mergeReferences( + prev: RowReferences, + next: RowReferences | undefined, +): RowReferences { + if (!next) return prev; + return { + users: { ...prev.users, ...next.users }, + pages: { ...prev.pages, ...next.pages }, + }; +} diff --git a/apps/client/src/ee/base/atoms/view-draft-atom.ts b/apps/client/src/ee/base/atoms/view-draft-atom.ts new file mode 100644 index 000000000..83482cf86 --- /dev/null +++ b/apps/client/src/ee/base/atoms/view-draft-atom.ts @@ -0,0 +1,21 @@ +import { atomFamily, atomWithStorage } from "jotai/utils"; +import { BaseViewDraft } from "@/ee/base/types/base.types"; + +export type ViewDraftKey = { + userId: string; + pageId: string; + viewId: string; +}; + +export const viewDraftStorageKey = (k: ViewDraftKey) => + `docmost:base-view-draft:v1:${k.userId}:${k.pageId}:${k.viewId}`; + +// atomWithStorage handles JSON serialization and cross-tab sync. The custom +// comparator ensures the same userId/pageId/viewId triple resolves to the +// same atom instance, so Jotai's identity-equality cache hits still work. +export const viewDraftAtomFamily = atomFamily( + (k: ViewDraftKey) => + atomWithStorage(viewDraftStorageKey(k), null), + (a, b) => + a.userId === b.userId && a.pageId === b.pageId && a.viewId === b.viewId, +); diff --git a/apps/client/src/ee/base/components/base-embed-title.tsx b/apps/client/src/ee/base/components/base-embed-title.tsx new file mode 100644 index 000000000..7b4a69260 --- /dev/null +++ b/apps/client/src/ee/base/components/base-embed-title.tsx @@ -0,0 +1,91 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useDebouncedCallback } from "@mantine/hooks"; +import { + usePageQuery, + useUpdateTitlePageMutation, + updatePageData, +} from "@/features/page/queries/page-query"; +import { useQueryEmit } from "@/features/websocket/use-query-emit"; +import { UpdateEvent } from "@/features/websocket/types"; +import localEmitter from "@/lib/local-emitter"; +import classes from "@/ee/base/styles/grid.module.css"; + +// Editable base name for the inline embed. Follows the TitleEditor convention +// (updatePageData + localEmitter + websocket emit) so the sidebar and other +// clients stay in sync. Standalone pages use the page TitleEditor instead. +export function BaseEmbedTitle({ pageId }: { pageId: string }) { + const { t } = useTranslation(); + const { data: page } = usePageQuery({ pageId }); + const { mutateAsync: updateTitleAsync } = useUpdateTitlePageMutation(); + const emit = useQueryEmit(); + const [value, setValue] = useState(""); + const focusedRef = useRef(false); + + // Keep in sync with the persisted title but never clobber active user input. + useEffect(() => { + if (!focusedRef.current) setValue(page?.title ?? ""); + }, [page?.title]); + + const commit = useCallback(() => { + const trimmed = value.trim(); + if (!page || trimmed === (page.title ?? "")) return; + updateTitleAsync({ pageId, title: trimmed }).then((updated) => { + if (updated.title !== trimmed) return; + const event: UpdateEvent = { + operation: "updateOne", + spaceId: updated.spaceId, + entity: ["pages"], + id: updated.id, + payload: { + title: updated.title, + slugId: updated.slugId, + parentPageId: updated.parentPageId, + icon: updated.icon, + }, + }; + updatePageData(updated); + localEmitter.emit("message", event); + emit(event); + }); + }, [value, page, pageId, updateTitleAsync, emit]); + + const debouncedCommit = useDebouncedCallback(commit, 500); + + // Force-save any pending edit on unmount (e.g. navigating away mid-type). + const commitRef = useRef(commit); + useEffect(() => { + commitRef.current = commit; + }, [commit]); + useEffect(() => () => commitRef.current(), []); + + return ( + { + setValue(e.currentTarget.value); + debouncedCommit(); + }} + onFocus={() => { + focusedRef.current = true; + }} + onBlur={() => { + focusedRef.current = false; + commit(); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + e.currentTarget.blur(); + } + if (e.key === "Escape") { + setValue(page?.title ?? ""); + e.currentTarget.blur(); + } + }} + /> + ); +} diff --git a/apps/client/src/ee/base/components/base-table-skeleton.tsx b/apps/client/src/ee/base/components/base-table-skeleton.tsx new file mode 100644 index 000000000..cd7dfc6fd --- /dev/null +++ b/apps/client/src/ee/base/components/base-table-skeleton.tsx @@ -0,0 +1,92 @@ +import { Skeleton } from "@mantine/core"; +import gridClasses from "@/ee/base/styles/grid.module.css"; +import classes from "@/ee/base/styles/base-table-skeleton.module.css"; + +const ROW_NUMBER_WIDTH = 64; +const COLUMN_WIDTH = 180; +const DEFAULT_COLUMN_COUNT = 6; +const DEFAULT_ROW_COUNT = 10; + +// Deterministic widths prevent flicker between renders. +const CELL_WIDTH_RATIOS = [0.78, 0.62, 0.84, 0.55, 0.71, 0.66]; +const HEADER_WIDTH_RATIOS = [0.42, 0.58, 0.5, 0.64, 0.46, 0.54]; + +type BaseTableSkeletonProps = { + // Match the eventual content shape to avoid a jarring size jump on swap. + rows?: number; + columns?: number; +}; + +export function BaseTableSkeleton({ + rows = DEFAULT_ROW_COUNT, + columns = DEFAULT_COLUMN_COUNT, +}: BaseTableSkeletonProps = {}) { + const gridTemplateColumns = [ + `${ROW_NUMBER_WIDTH}px`, + ...Array.from({ length: columns }, () => `${COLUMN_WIDTH}px`), + ].join(" "); + + return ( +
+
+
+ + + +
+
+ + + + +
+
+ +
+
+
+
+ +
+
+ {Array.from({ length: columns }).map((_, colIndex) => ( +
+
+ + +
+
+ ))} + + {Array.from({ length: rows }).map((_, rowIndex) => ( +
+
+
+ +
+
+ {Array.from({ length: columns }).map((_, colIndex) => ( +
+
+ +
+
+ ))} +
+ ))} +
+
+
+ ); +} diff --git a/apps/client/src/ee/base/components/base-table.tsx b/apps/client/src/ee/base/components/base-table.tsx new file mode 100644 index 000000000..9bef830a9 --- /dev/null +++ b/apps/client/src/ee/base/components/base-table.tsx @@ -0,0 +1,70 @@ +import { GridContainer } from "@/ee/base/components/grid/grid-container"; +import { Table } from "@tanstack/react-table"; +import { + IBase, + IBaseRow, + IBaseView, +} from "@/ee/base/types/base.types"; + +type BaseTableProps = { + base: IBase; + rows: IBaseRow[]; + effectiveView: IBaseView | undefined; + table: Table; + pageId: string; + embedded?: boolean; + isFiltered: boolean; + hasNextPage: boolean; + isFetchingNextPage: boolean; + onFetchNextPage: () => void; + onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void; + onAddRow: () => void; + onColumnReorder: (columnId: string, finishIndex: number) => void; + onResizeEnd: () => void; + onRowReorder: ( + rowId: string, + targetRowId: string, + dropPosition: "above" | "below", + ) => void; + persistViewConfig: () => void; + scrollportRef: React.RefObject; + aboveBand?: React.ReactNode; +}; + +export function BaseTable({ + base, + rows: _rows, + table, + pageId, + embedded, + isFiltered, + hasNextPage, + isFetchingNextPage, + onFetchNextPage, + onCellUpdate, + onAddRow, + onColumnReorder, + onResizeEnd, + onRowReorder, + scrollportRef, + aboveBand, +}: BaseTableProps) { + return ( + + ); +} diff --git a/apps/client/src/ee/base/components/base-toolbar.tsx b/apps/client/src/ee/base/components/base-toolbar.tsx new file mode 100644 index 000000000..578eaa574 --- /dev/null +++ b/apps/client/src/ee/base/components/base-toolbar.tsx @@ -0,0 +1,298 @@ +import { useState, useCallback, useMemo } from "react"; +import { ActionIcon, Tooltip, Badge } from "@mantine/core"; +import { Table } from "@tanstack/react-table"; +import { + IconSortAscending, + IconFilter, + IconEye, + IconDownload, + IconArrowsDiagonal, + IconLayoutColumns, + IconAdjustments, +} from "@tabler/icons-react"; +import { notifications } from "@mantine/notifications"; +import { + IBase, + IBaseRow, + IBaseView, + ViewSortConfig, + FilterCondition, + FilterGroup, +} from "@/ee/base/types/base.types"; +import { exportBaseToCsv } from "@/ee/base/services/base-service"; +import { useBaseEditable } from "@/ee/base/context/base-editable"; +import { ViewTabs } from "@/ee/base/components/views/view-tabs"; +import { ViewSortConfigPopover } from "@/ee/base/components/views/view-sort-config"; +import { ViewFilterConfigPopover } from "@/ee/base/components/views/view-filter-config"; +import { ViewPropertyVisibility } from "@/ee/base/components/views/view-property-visibility"; +import { KanbanGroupByPicker } from "@/ee/base/components/kanban/kanban-group-by-picker"; +import { KanbanCardProperties } from "@/ee/base/components/kanban/kanban-card-properties"; +import { useTranslation } from "react-i18next"; +import classes from "@/ee/base/styles/grid.module.css"; +import toolbarClasses from "@/ee/base/styles/base-toolbar.module.css"; + +type BaseToolbarProps = { + base: IBase; + activeView: IBaseView | undefined; + views: IBaseView[]; + table?: Table; + onViewChange: (viewId: string) => void; + onAddView?: () => void; + canAddView?: boolean; + onPersistViewConfig: () => void; + onDraftSortsChange: (sorts: ViewSortConfig[] | undefined) => void; + onDraftFiltersChange: (filter: FilterGroup | undefined) => void; + onExpand?: () => void; + getViewShareUrl?: (viewId: string) => string | null; +}; + +export function BaseToolbar({ + base, + activeView, + views, + table, + onViewChange, + onAddView, + canAddView, + onPersistViewConfig, + onDraftSortsChange, + onDraftFiltersChange, + onExpand, + getViewShareUrl, +}: BaseToolbarProps) { + const { t } = useTranslation(); + const editable = useBaseEditable(); + const [sortOpened, setSortOpened] = useState(false); + const [filterOpened, setFilterOpened] = useState(false); + const [propertiesOpened, setPropertiesOpened] = useState(false); + const [cardPropertiesOpened, setCardPropertiesOpened] = useState(false); + const [exporting, setExporting] = useState(false); + + const isKanban = activeView?.type === "kanban"; + + const handleExport = useCallback(async () => { + if (exporting) return; + setExporting(true); + try { + await exportBaseToCsv(base.id); + } catch (err) { + notifications.show({ + color: "red", + message: t("Failed to export CSV"), + }); + } finally { + setExporting(false); + } + }, [base.id, exporting, t]); + + const openToolbar = useCallback((panel: "sort" | "filter" | "properties") => { + setSortOpened(panel === "sort" ? (v) => !v : false); + setFilterOpened(panel === "filter" ? (v) => !v : false); + setPropertiesOpened(panel === "properties" ? (v) => !v : false); + }, []); + + const sorts = activeView?.config?.sorts ?? []; + const conditions = useMemo(() => { + const filter = activeView?.config?.filter; + if (!filter || filter.op !== "and") return []; + return filter.children.filter( + (c): c is FilterCondition => !("children" in c), + ); + }, [activeView?.config?.filter]); + + const hiddenPropertyCount = useMemo(() => { + if (!table) return 0; + 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[]) => { + onDraftSortsChange(newSorts.length > 0 ? newSorts : undefined); + }, + [onDraftSortsChange], + ); + + const handleFiltersChange = useCallback( + (newConditions: FilterCondition[]) => { + const filter: FilterGroup | undefined = + newConditions.length > 0 + ? { op: "and", children: newConditions } + : undefined; + onDraftFiltersChange(filter); + }, + [onDraftFiltersChange], + ); + + return ( +
+ + +
+ {editable && ( + + + + + + )} + + setFilterOpened(false)} + conditions={conditions} + properties={base.properties} + onChange={handleFiltersChange} + > + + 0 ? "blue" : "gray"} + onClick={() => openToolbar("filter")} + > + + {conditions.length > 0 && ( + + {conditions.length} + + )} + + + + + {isKanban && activeView && ( + <> + + + + + + + + + setCardPropertiesOpened(false)} + base={base} + view={activeView} + pageId={base.id} + > + + setCardPropertiesOpened((v) => !v)} + > + + + + + + )} + + {!isKanban && ( + <> + setSortOpened(false)} + sorts={sorts} + properties={base.properties} + onChange={handleSortsChange} + > + + 0 ? "blue" : "gray"} + onClick={() => openToolbar("sort")} + > + + {sorts.length > 0 && ( + + {sorts.length} + + )} + + + + + {table && ( + setPropertiesOpened(false)} + table={table} + properties={base.properties} + onPersist={onPersistViewConfig} + > + + 0 ? "blue" : "gray"} + onClick={() => openToolbar("properties")} + > + + {hiddenPropertyCount > 0 && ( + + {hiddenPropertyCount} + + )} + + + + )} + + )} + + {onExpand && ( + + + + + + )} +
+
+ ); +} diff --git a/apps/client/src/ee/base/components/base-view-draft-banner.tsx b/apps/client/src/ee/base/components/base-view-draft-banner.tsx new file mode 100644 index 000000000..cda3c9f15 --- /dev/null +++ b/apps/client/src/ee/base/components/base-view-draft-banner.tsx @@ -0,0 +1,45 @@ +import { Group, Button, Tooltip } from "@mantine/core"; +import { useTranslation } from "react-i18next"; + +type BaseViewDraftBannerProps = { + isDirty: boolean; + canSave: boolean; + onReset: () => void; + onSave: () => void; + saving: boolean; +}; + +export function BaseViewDraftBanner({ + isDirty, + canSave, + onReset, + onSave, + saving, +}: BaseViewDraftBannerProps) { + const { t } = useTranslation(); + if (!isDirty) return null; + return ( + + + {canSave && ( + + + + )} + + ); +} diff --git a/apps/client/src/ee/base/components/base-view.tsx b/apps/client/src/ee/base/components/base-view.tsx new file mode 100644 index 000000000..69d692ecd --- /dev/null +++ b/apps/client/src/ee/base/components/base-view.tsx @@ -0,0 +1,526 @@ +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { Text, Stack } from "@mantine/core"; +import { useAtom } from "jotai"; +import { IconDatabase } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { notifications } from "@mantine/notifications"; +import { reorder } from "@atlaskit/pragmatic-drag-and-drop/reorder"; +import { generateJitteredKeyBetween } from "fractional-indexing-jittered"; +import { useBaseQuery } from "@/ee/base/queries/base-query"; +import { useBaseSocket } from "@/ee/base/hooks/use-base-socket"; +import { + FilterGroup, + ViewSortConfig, + EditingCell, + IBaseProperty, +} from "@/ee/base/types/base.types"; +import { + useBaseRowsQuery, + flattenRows, + useCreateRowMutation, + useUpdateRowMutation, + useReorderRowMutation, +} from "@/ee/base/queries/base-row-query"; +import { useUpdateViewMutation } from "@/ee/base/queries/base-view-query"; +import { + activeViewIdAtomFamily, + editingCellAtomFamily, +} from "@/ee/base/atoms/base-atoms"; +import { useBaseTable } from "@/ee/base/hooks/use-base-table"; +import { isSystemPropertyType } from "@/ee/base/property-types/property-type.registry"; +import { useRowSelection } from "@/ee/base/hooks/use-row-selection"; +import useCurrentUser from "@/features/user/hooks/use-current-user"; +import { useHydrateCurrentUser } from "@/ee/base/reference/reference-store"; +import { useViewDraft } from "@/ee/base/hooks/use-view-draft"; +import { BaseToolbar } from "@/ee/base/components/base-toolbar"; +import { BaseViewDraftBanner } from "@/ee/base/components/base-view-draft-banner"; +import { BaseEmbedTitle } from "@/ee/base/components/base-embed-title"; +import { BaseTableSkeleton } from "@/ee/base/components/base-table-skeleton"; +import { ViewRenderer } from "@/ee/base/components/views/view-renderer"; +import { RowDetailModal } from "@/ee/base/components/row-detail-modal/row-detail-modal"; +import { useRowDetailModal } from "@/ee/base/hooks/use-row-detail-modal"; +import { BaseEditableProvider } from "@/ee/base/context/base-editable"; +import { RowExpandProvider } from "@/ee/base/context/row-expand"; +import { usePageQuery } from "@/features/page/queries/page-query"; +import { buildPageUrl } from "@/features/page/page.utils"; +import { getAppUrl } from "@/lib/config.ts"; +import { useNavigate } from "react-router-dom"; +import classes from "@/ee/base/styles/grid.module.css"; +import viewClasses from "@/ee/base/styles/base-view.module.css"; +import kanbanClasses from "@/ee/base/styles/kanban.module.css"; + +type BaseViewProps = { + pageId: string; + embedded?: boolean; + /** False makes the view read-only. Standalone passes page.permissions.canEdit; + * embedded ANDs that with the host editor's editability. */ + editable?: boolean; + titleSlot?: React.ReactNode; +}; + +export function BaseView({ pageId, embedded, editable = true, titleSlot }: BaseViewProps) { + const { t } = useTranslation(); + // Subscribe so other clients' edits, schema changes, and async-job completions reconcile into cache. + useBaseSocket(pageId); + const { data: base, isLoading: baseLoading, error: baseError } = + useBaseQuery(pageId); + + const navigate = useNavigate(); + const { data: page } = usePageQuery({ pageId }); + const handleExpand = useCallback(() => { + if (!page) return; + navigate(buildPageUrl(page.space?.slug, page.slugId, page.title)); + }, [navigate, page]); + + // Share URL for a specific view; always points at the standalone page where ?view= is honored. + const getViewShareUrl = useCallback( + (viewId: string) => + page + ? `${getAppUrl()}${buildPageUrl(page.space?.slug, page.slugId, page.title)}?view=${encodeURIComponent(viewId)}` + : null, + [page], + ); + + const [activeViewId, setActiveViewId] = useAtom( + activeViewIdAtomFamily(pageId), + ) as unknown as [string | null, (val: string | null) => void]; + + const [, setEditingCell] = useAtom( + editingCellAtomFamily(pageId), + ) as unknown as [EditingCell, (val: EditingCell) => void]; + + const views = useMemo( + () => + [...(base?.views ?? [])].sort((a, b) => + a.position < b.position ? -1 : a.position > b.position ? 1 : 0, + ), + [base?.views], + ); + const activeView = useMemo(() => { + if (!views.length) return undefined; + return views.find((v) => v.id === activeViewId) ?? views[0]; + }, [views, activeViewId]); + + const { data: currentUser } = useCurrentUser(); + useHydrateCurrentUser(pageId); + const { + effectiveFilter, + effectiveSorts, + isDirty, + setFilter: setDraftFilter, + setSorts: setDraftSorts, + reset: resetDraft, + buildPromotedConfig, + } = useViewDraft({ + userId: currentUser?.user.id, + pageId, + viewId: activeView?.id, + baselineFilter: activeView?.config?.filter, + baselineSorts: activeView?.config?.sorts, + }); + + // Baseline merged with local draft. Used for table state and toolbar badge counts. + // The real activeView remains the auto-persist baseline so drafts can't leak into layout writes. + const effectiveView = useMemo( + () => + activeView + ? { + ...activeView, + config: { + ...activeView.config, + filter: effectiveFilter, + sorts: effectiveSorts, + }, + } + : undefined, + [activeView, effectiveFilter, effectiveSorts], + ); + + const activeFilter = effectiveFilter; + const activeSorts = effectiveSorts; + + const canSave = editable; + + // Gate on base to avoid a "bland" list request before the active view's + // config resolves, which would double network traffic for sorted/filtered views. + const isKanban = activeView?.type === "kanban"; + + const { + data: rowsData, + isLoading: rowsLoading, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useBaseRowsQuery(base && !isKanban ? pageId : undefined, activeFilter, activeSorts); + + const updateRowMutation = useUpdateRowMutation(); + const createRowMutation = useCreateRowMutation(); + const reorderRowMutation = useReorderRowMutation(); + const updateViewMutation = useUpdateViewMutation(); + + useEffect(() => { + if (activeView && activeViewId !== activeView.id) { + setActiveViewId(activeView.id); + } + }, [activeView, activeViewId, setActiveViewId]); + + // Deep link: apply ?view= once after views load; skip if the id is + // unrecognised so we fall back to the default without fighting a later tab switch. + const appliedViewParamRef = useRef(false); + useEffect(() => { + if (appliedViewParamRef.current || views.length === 0) return; + const viewParam = new URLSearchParams(window.location.search).get("view"); + if (viewParam && views.some((v) => v.id === viewParam)) { + setActiveViewId(viewParam); + } + appliedViewParamRef.current = true; + }, [views, setActiveViewId]); + + const { clear: clearSelection } = useRowSelection(pageId); + useEffect(() => { + clearSelection(); + }, [pageId, activeView?.id, clearSelection]); + + const scrollportRef = useRef(null); + + const rows = useMemo(() => { + const flat = flattenRows(rowsData); + // With an active sort the server returns rows in sort order via keyset + // pagination; re-sorting by position on the client would break it as more + // pages load. Position sort only applies when no view sort is active. + if (activeSorts && activeSorts.length > 0) { + return flat; + } + return flat.sort((a, b) => + a.position < b.position ? -1 : a.position > b.position ? 1 : 0, + ); + }, [rowsData, activeSorts]); + const rowsRef = useRef(rows); + rowsRef.current = rows; + + const { table, persistViewConfig } = useBaseTable(base, rows, effectiveView); + + const guardedPersistViewConfig = useCallback(() => { + if (!editable) return; + persistViewConfig(); + }, [editable, persistViewConfig]); + + // Mutation result objects change identity every render; only .mutate is + // stable. Rows are memoized on these callbacks' identities, so they must + // not churn with unrelated re-renders. + const updateRow = updateRowMutation.mutate; + const handleCellUpdate = useCallback( + (rowId: string, propertyId: string, value: unknown) => { + if (!editable) return; + updateRow({ + rowId, + pageId, + cells: { [propertyId]: value }, + }); + }, + [editable, pageId, updateRow], + ); + + const handleAddRow = useCallback(() => { + if (!editable) return; + createRowMutation.mutate( + { pageId }, + { + onSuccess: (newRow) => { + const firstEditable = table.getVisibleLeafColumns().find((col) => { + if (col.id === "__row_number") return false; + const prop = col.columnDef.meta?.property as + | IBaseProperty + | undefined; + return ( + !!prop && + prop.type !== "checkbox" && + !isSystemPropertyType(prop.type) + ); + }); + const propertyId = ( + firstEditable?.columnDef.meta?.property as IBaseProperty | undefined + )?.id; + if (propertyId) { + setEditingCell({ rowId: newRow.id, propertyId }); + } + }, + }, + ); + }, [editable, pageId, createRowMutation, table, setEditingCell]); + + const handleViewChange = useCallback( + (viewId: string) => { + setActiveViewId(viewId); + }, + [setActiveViewId], + ); + + const handleColumnReorder = useCallback( + (columnId: string, finishIndex: number) => { + const order = table.getState().columnOrder; + const startIndex = order.indexOf(columnId); + if (startIndex === -1 || startIndex === finishIndex) return; + table.setColumnOrder(reorder({ list: order, startIndex, finishIndex })); + guardedPersistViewConfig(); + }, + [table, guardedPersistViewConfig], + ); + + const handleResizeEnd = useCallback(() => { + guardedPersistViewConfig(); + }, [guardedPersistViewConfig]); + + const handleDraftSortsChange = useCallback( + (sorts: ViewSortConfig[] | undefined) => { + setDraftSorts(sorts && sorts.length > 0 ? sorts : undefined); + }, + [setDraftSorts], + ); + + const handleDraftFiltersChange = useCallback( + (filter: FilterGroup | undefined) => { + setDraftFilter(filter); + }, + [setDraftFilter], + ); + + const handleSaveDraft = useCallback(async () => { + if (!activeView || !base) return; + // Preserves non-draft baseline fields (widths/order/visibility), overwrites only filter/sorts. + const config = buildPromotedConfig(activeView.config); + try { + await updateViewMutation.mutateAsync({ + viewId: activeView.id, + pageId: base.id, + config, + }); + resetDraft(); + notifications.show({ message: t("View updated for everyone") }); + } catch { + // useUpdateViewMutation shows a toast and rolls back; keep the draft so the user can retry. + } + }, [ + activeView, + base, + buildPromotedConfig, + resetDraft, + t, + updateViewMutation, + ]); + + const { openRowId, openRow, closeRow } = useRowDetailModal(pageId); + // openRow's identity tracks searchParams; rows subscribe to the expand + // context, so hand them a stable wrapper instead. + const openRowRef = useRef(openRow); + openRowRef.current = openRow; + const handleExpandRow = useCallback((rowId: string) => { + openRowRef.current(rowId); + }, []); + const handleRowNavigate = useCallback((rowId: string) => { + openRowRef.current(rowId, { replace: true }); + }, []); + + const reorderRow = reorderRowMutation.mutate; + const handleRowReorder = useCallback( + (rowId: string, targetRowId: string, dropPosition: "above" | "below") => { + if (!editable) return; + const remainingRows = rowsRef.current.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); + } + reorderRow({ rowId, pageId, position: newPosition }); + } catch { + // Position computation failed; skip silently. + } + }, + [editable, pageId, reorderRow], + ); + + if (baseLoading || (!isKanban && rowsLoading)) { + return ; + } + if (baseError) { + return ( + + + {t("Failed to load base")} + + ); + } + if (!base) return null; + + // Ghost rows are an "empty database" affordance, not a "filter matched nothing" state. + const isFiltered = (activeFilter?.children?.length ?? 0) > 0; + + const banner = ( + + ); + + const toolbar = ( + + ); + + const kanbanBand = ( +
+ {embedded ? null : titleSlot} + {banner} + {toolbar} + {embedded ? : null} +
+ ); + + const viewRenderer = (folded: React.ReactNode) => ( + + ); + + if (embedded) { + if (isKanban) { + return ( + + + {kanbanBand} + {viewRenderer(null)} + + + + ); + } + + // Banner and toolbar go into aboveBand so they scroll with the host document; + // only the column-header row stays pinned (via --sticky-band-top). + return ( + + + {viewRenderer( + <> + {banner} + {toolbar} + + , + )} + + + + ); + } + + if (isKanban) { + return ( + +
+ + {kanbanBand} + {viewRenderer(null)} + +
+ +
+ ); + } + + // Standalone: title, banner, and toolbar go in aboveBand inside the scroll + // container so they scroll away; only the column-header row stays pinned. + return ( + +
+
+ + {viewRenderer( + <> + {titleSlot} + {banner} + {toolbar} + , + )} + +
+
+ +
+ ); +} diff --git a/apps/client/src/ee/base/components/cells/badge-overflow.tsx b/apps/client/src/ee/base/components/cells/badge-overflow.tsx new file mode 100644 index 000000000..278b84472 --- /dev/null +++ b/apps/client/src/ee/base/components/cells/badge-overflow.tsx @@ -0,0 +1,100 @@ +import { ReactElement, useLayoutEffect, useRef, useState } from "react"; +import { Tooltip } from "@mantine/core"; +import cellClasses from "@/ee/base/styles/cells.module.css"; + +export function computeVisibleBadgeCount( + itemWidths: number[], + gap: number, + available: number, + badgeWidth: number, +): number { + const count = itemWidths.length; + if (count === 0) return 0; + if (available <= 0) return count; + + let lineWidth = 0; + for (let i = 0; i < count; i++) { + lineWidth += itemWidths[i] + (i > 0 ? gap : 0); + } + if (lineWidth <= available) return count; + + let used = 0; + let fit = 0; + for (let i = 0; i < count; i++) { + const advance = itemWidths[i] + (i > 0 ? gap : 0); + if (used + advance + gap + badgeWidth <= available) { + used += advance; + fit = i + 1; + } else { + break; + } + } + return Math.max(fit, 1); +} + +const BADGE_GAP = 4; + +type BadgeOverflowListProps = { + chips: ReactElement[]; + measureKey: string; + tooltipLabel?: string; +}; + +export function BadgeOverflowList({ + chips, + measureKey, + tooltipLabel, +}: BadgeOverflowListProps) { + const containerRef = useRef(null); + const measureRef = useRef(null); + const [visibleCount, setVisibleCount] = useState(chips.length); + + useLayoutEffect(() => { + const container = containerRef.current; + const measure = measureRef.current; + if (!container || !measure) return; + + const recompute = () => { + const nodes = Array.from(measure.children) as HTMLElement[]; + const chipWidths = nodes.slice(0, -1).map((n) => n.offsetWidth); + const badgeWidth = nodes[nodes.length - 1]?.offsetWidth ?? 0; + setVisibleCount( + computeVisibleBadgeCount( + chipWidths, + BADGE_GAP, + container.clientWidth, + badgeWidth, + ), + ); + }; + + recompute(); + const observer = new ResizeObserver(recompute); + observer.observe(container); + return () => observer.disconnect(); + }, [measureKey]); + + const visible = chips.slice(0, visibleCount); + const overflow = chips.length - visibleCount; + + return ( + +
+
+ {chips} + +{chips.length} +
+ {visible} + {overflow > 0 && ( + +{overflow} + )} +
+
+ ); +} diff --git a/apps/client/src/ee/base/components/cells/cell-checkbox.tsx b/apps/client/src/ee/base/components/cells/cell-checkbox.tsx new file mode 100644 index 000000000..163359dbd --- /dev/null +++ b/apps/client/src/ee/base/components/cells/cell-checkbox.tsx @@ -0,0 +1,44 @@ +import { useCallback } from "react"; +import { Checkbox } from "@mantine/core"; +import { IBaseProperty } from "@/ee/base/types/base.types"; +import cellClasses from "@/ee/base/styles/cells.module.css"; + +type CellCheckboxProps = { + value: unknown; + property: IBaseProperty; + rowId: string; + isEditing: boolean; + readOnly?: boolean; + onCommit: (value: unknown) => void; + onCancel: () => void; +}; + +export function CellCheckbox({ value, readOnly, onCommit }: CellCheckboxProps) { + const checked = value === true; + + const handleChange = useCallback(() => { + if (readOnly) return; + onCommit(!checked); + }, [readOnly, checked, onCommit]); + + return ( +
+ {}} + size="xs" + tabIndex={-1} + styles={{ + input: { + cursor: readOnly ? "default" : "pointer", + pointerEvents: "none", + }, + }} + /> +
+ ); +} diff --git a/apps/client/src/ee/base/components/cells/cell-created-at.tsx b/apps/client/src/ee/base/components/cells/cell-created-at.tsx new file mode 100644 index 000000000..21286843c --- /dev/null +++ b/apps/client/src/ee/base/components/cells/cell-created-at.tsx @@ -0,0 +1,22 @@ +import { IBaseProperty } from "@/ee/base/types/base.types"; +import { formatTimestamp } from "@/ee/base/formatters/cell-formatters"; +import cellClasses from "@/ee/base/styles/cells.module.css"; + +type CellCreatedAtProps = { + value: unknown; + property: IBaseProperty; + rowId: string; + isEditing: boolean; + onCommit: (value: unknown) => void; + onCancel: () => void; +}; + +export function CellCreatedAt({ value }: CellCreatedAtProps) { + const formatted = formatTimestamp(typeof value === "string" ? value : null); + + if (!formatted) { + return ; + } + + return {formatted}; +} diff --git a/apps/client/src/ee/base/components/cells/cell-date.tsx b/apps/client/src/ee/base/components/cells/cell-date.tsx new file mode 100644 index 000000000..a4af25a15 --- /dev/null +++ b/apps/client/src/ee/base/components/cells/cell-date.tsx @@ -0,0 +1,146 @@ +import { useCallback } from "react"; +import { Popover } from "@mantine/core"; +import { DatePicker } from "@mantine/dates"; +import { + IBaseProperty, + DateTypeOptions, +} from "@/ee/base/types/base.types"; +import cellClasses from "@/ee/base/styles/cells.module.css"; + +type CellDateProps = { + value: unknown; + property: IBaseProperty; + rowId: string; + isEditing: boolean; + onCommit: (value: unknown) => void; + onCancel: () => void; +}; + +export 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 ( + { + if (!o) onCancel(); + }} + onClose={onCancel} + position="bottom-start" + width="auto" + trapFocus + closeOnClickOutside + closeOnEscape + > + +
+ + {formatDateDisplay(dateStr, typeOptions)} + +
+
+ + + +
+ ); + } + + if (!dateStr) { + return ; + } + + return ( + + {formatDateDisplay(dateStr, typeOptions)} + + ); +} diff --git a/apps/client/src/ee/base/components/cells/cell-email.tsx b/apps/client/src/ee/base/components/cells/cell-email.tsx new file mode 100644 index 000000000..15982698c --- /dev/null +++ b/apps/client/src/ee/base/components/cells/cell-email.tsx @@ -0,0 +1,52 @@ +import { IBaseProperty } from "@/ee/base/types/base.types"; +import { Tooltip } from "@mantine/core"; +import { useEditableTextCell } from "@/ee/base/hooks/use-editable-text-cell"; +import cellClasses from "@/ee/base/styles/cells.module.css"; + +type CellEmailProps = { + value: unknown; + property: IBaseProperty; + rowId: string; + isEditing: boolean; + onCommit: (value: unknown) => void; + onCancel: () => void; +}; + +const toDraft = (value: unknown) => (typeof value === "string" ? value : ""); +const parse = (draft: string) => draft || null; + +export function CellEmail({ value, isEditing, onCommit, onCancel }: CellEmailProps) { + const { draft, setDraft, inputRef, handleKeyDown, handleBlur } = + useEditableTextCell({ value, isEditing, onCommit, onCancel, toDraft, parse }); + + if (isEditing) { + return ( + setDraft(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + /> + ); + } + + const displayValue = toDraft(value); + if (!displayValue) { + return ; + } + return ( + + e.stopPropagation()} + > + {displayValue} + + + ); +} diff --git a/apps/client/src/ee/base/components/cells/cell-file.tsx b/apps/client/src/ee/base/components/cells/cell-file.tsx new file mode 100644 index 000000000..6b6af00a6 --- /dev/null +++ b/apps/client/src/ee/base/components/cells/cell-file.tsx @@ -0,0 +1,235 @@ +import { useState, useRef, useCallback } from "react"; +import { Popover, ActionIcon, Text, UnstyledButton } from "@mantine/core"; +import { + IconPaperclip, + IconUpload, + IconFile, + IconX, +} from "@tabler/icons-react"; +import { IBaseProperty } from "@/ee/base/types/base.types"; +import cellClasses from "@/ee/base/styles/cells.module.css"; +import { uploadFile } from "@/features/page/services/page-service"; +import { getFileUrl } from "@/lib/config"; + +export type FileValue = { + id: string; + fileName: string; + mimeType?: string; + fileSize?: number; + url?: string; +}; + +function buildFileUrl(file: Pick): string { + return file.url ?? `/api/files/${file.id}/${encodeURIComponent(file.fileName)}`; +} + +type CellFileProps = { + value: unknown; + property: IBaseProperty; + rowId: string; + isEditing: boolean; + readOnly?: boolean; + onCommit: (value: unknown) => void; + onCancel: () => void; +}; + +function formatFileSize(bytes?: number): string { + if (!bytes) return ""; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function parseFiles(value: unknown): FileValue[] { + if (!Array.isArray(value)) return []; + return value.filter( + (f): f is FileValue => + f && typeof f === "object" && "id" in f && "fileName" in f, + ); +} + +export function CellFile({ + value, + property, + isEditing, + readOnly, + onCommit, + onCancel, +}: CellFileProps) { + const files = parseFiles(value); + const fileInputRef = useRef(null); + const [uploading, setUploading] = useState(false); + + const handleRemove = useCallback( + (fileId: string) => { + if (readOnly) return; + const updated = files.filter((f) => f.id !== fileId); + onCommit(updated.length > 0 ? updated : null); + }, + [readOnly, files, onCommit], + ); + + const handleUpload = useCallback( + async (fileList: FileList | null) => { + if (!fileList || fileList.length === 0) return; + setUploading(true); + + const newFiles: FileValue[] = [...files]; + + // Reuse the page-attachment upload pipeline: the base's pageId is passed + // to the standard /files/upload endpoint, which enforces the same edit + // access check as any other page attachment. + for (const file of Array.from(fileList)) { + try { + const attachment = await uploadFile(file, property.pageId); + newFiles.push({ + id: attachment.id, + fileName: attachment.fileName, + mimeType: attachment.mimeType, + fileSize: attachment.fileSize, + url: `/api/files/${attachment.id}/${encodeURIComponent(attachment.fileName)}`, + }); + } catch (err) { + console.error("File upload failed:", err); + } + } + + setUploading(false); + onCommit(newFiles.length > 0 ? newFiles : null); + }, + [files, property.pageId, onCommit], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onCancel(); + } + }, + [onCancel], + ); + + const MAX_VISIBLE = 2; + + if (isEditing) { + return ( + { + if (!o) onCancel(); + }} + onClose={onCancel} + position="bottom-start" + width={280} + trapFocus + closeOnClickOutside + closeOnEscape + > + +
+ +
+
+ + {!readOnly && files.length === 0 && !uploading && ( + + No files attached + + )} + + {files.map((file) => ( + + ))} + + {!readOnly && ( + <> + { + handleUpload(e.target.files); + e.target.value = ""; + }} + /> + + fileInputRef.current?.click()} + disabled={uploading} + className={cellClasses.fileUploadBtn} + style={{ + color: uploading + ? "var(--mantine-color-gray-5)" + : "var(--mantine-color-blue-6)", + }} + > + + {uploading ? "Uploading..." : "Add file"} + + + )} + +
+ ); + } + + if (files.length === 0) { + return ; + } + + return ; +} + +function FileList({ + files, + maxVisible, +}: { + files: FileValue[]; + maxVisible: number; +}) { + const visible = files.slice(0, maxVisible); + const overflow = files.length - maxVisible; + + return ( +
+ {visible.map((file) => ( + + + {file.fileName} + + ))} + {overflow > 0 && ( + +{overflow} + )} +
+ ); +} diff --git a/apps/client/src/ee/base/components/cells/cell-formula.tsx b/apps/client/src/ee/base/components/cells/cell-formula.tsx new file mode 100644 index 000000000..bdaaf11cf --- /dev/null +++ b/apps/client/src/ee/base/components/cells/cell-formula.tsx @@ -0,0 +1,38 @@ +import { Badge, Tooltip } from "@mantine/core"; +import { + IBaseProperty, + isFormulaErrorCell, +} from "@/ee/base/types/base.types"; +import { CellText } from "./cell-text"; +import { CellNumber } from "./cell-number"; +import { CellCheckbox } from "./cell-checkbox"; +import { CellDate } from "./cell-date"; + +type Props = { + value: unknown; + property: IBaseProperty; + rowId: string; + isEditing: boolean; + onCommit: (value: unknown) => void; + onCancel: () => void; +}; + +export function CellFormula(props: Props) { + const { value, property } = props; + if (isFormulaErrorCell(value)) { + return ( + + + #ERROR + + + ); + } + const opts = (property.typeOptions ?? {}) as { resultType?: string }; + const resultType = opts.resultType ?? "null"; + const readOnlyProps = { ...props, isEditing: false }; + if (resultType === "number") return ; + if (resultType === "boolean") return ; + if (resultType === "date") return ; + return ; +} diff --git a/apps/client/src/ee/base/components/cells/cell-last-edited-at.tsx b/apps/client/src/ee/base/components/cells/cell-last-edited-at.tsx new file mode 100644 index 000000000..efad990de --- /dev/null +++ b/apps/client/src/ee/base/components/cells/cell-last-edited-at.tsx @@ -0,0 +1,22 @@ +import { IBaseProperty } from "@/ee/base/types/base.types"; +import { formatTimestamp } from "@/ee/base/formatters/cell-formatters"; +import cellClasses from "@/ee/base/styles/cells.module.css"; + +type CellLastEditedAtProps = { + value: unknown; + property: IBaseProperty; + rowId: string; + isEditing: boolean; + onCommit: (value: unknown) => void; + onCancel: () => void; +}; + +export function CellLastEditedAt({ value }: CellLastEditedAtProps) { + const formatted = formatTimestamp(typeof value === "string" ? value : null); + + if (!formatted) { + return ; + } + + return {formatted}; +} diff --git a/apps/client/src/ee/base/components/cells/cell-last-edited-by.tsx b/apps/client/src/ee/base/components/cells/cell-last-edited-by.tsx new file mode 100644 index 000000000..b6dc90c52 --- /dev/null +++ b/apps/client/src/ee/base/components/cells/cell-last-edited-by.tsx @@ -0,0 +1,41 @@ +import { Group, Tooltip } from "@mantine/core"; +import { IBaseProperty } from "@/ee/base/types/base.types"; +import { useReferenceStore } from "@/ee/base/reference/reference-store"; +import { CustomAvatar } from "@/components/ui/custom-avatar"; +import cellClasses from "@/ee/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, property }: CellLastEditedByProps) { + const userId = typeof value === "string" ? value : null; + + const store = useReferenceStore(property.pageId); + const user = userId ? store.users[userId] ?? null : null; + + if (!userId) { + return ; + } + + const name = user?.name ?? userId.substring(0, 8); + + return ( + + + + {name} + + + ); +} diff --git a/apps/client/src/ee/base/components/cells/cell-long-text.tsx b/apps/client/src/ee/base/components/cells/cell-long-text.tsx new file mode 100644 index 000000000..756ec09f7 --- /dev/null +++ b/apps/client/src/ee/base/components/cells/cell-long-text.tsx @@ -0,0 +1,145 @@ +import { useEffect, useRef, useState } from "react"; +import { Popover, Textarea, Group, CloseButton, Tooltip } from "@mantine/core"; +import { useDebouncedCallback } from "@mantine/hooks"; +import { IBaseProperty } from "@/ee/base/types/base.types"; +import { formatLongTextPreview } from "@/ee/base/formatters/cell-formatters"; +import cellClasses from "@/ee/base/styles/cells.module.css"; + +type CellLongTextProps = { + value: unknown; + property: IBaseProperty; + rowId: string; + isEditing: boolean; + onCommit: (value: unknown) => void; + onValueChange: (value: unknown) => void; + onCancel: () => void; +}; + +const toText = (value: unknown) => (typeof value === "string" ? value : ""); +const normalize = (s: string) => { + const trimmed = s.trim(); + return trimmed.length ? trimmed : null; +}; + +export function CellLongText({ + value, + isEditing, + onCommit, + onValueChange, + onCancel, +}: CellLongTextProps) { + const [draft, setDraft] = useState(() => toText(value)); + const cancelledRef = useRef(false); + const committedRef = useRef(false); + const wasEditingRef = useRef(false); + const textareaRef = useRef(null); + + // Seed draft and focus on the false->true editing transition only; ignore + // value changes mid-edit so the user's typing is not clobbered. + useEffect(() => { + if (isEditing && !wasEditingRef.current) { + cancelledRef.current = false; + committedRef.current = false; + setDraft(toText(value)); + requestAnimationFrame(() => { + const el = textareaRef.current; + if (!el) return; + el.focus(); + el.setSelectionRange(el.value.length, el.value.length); + }); + } + wasEditingRef.current = isEditing; + }, [isEditing, value]); + + // Autosave after a typing pause; commit/cancel clear the pending fire so + // a closed editor can never write a stale or discarded draft. + const debouncedAutosave = useDebouncedCallback(() => { + onValueChange(normalize(draft)); + }, 10_000); + + const commit = () => { + if (committedRef.current) return; + committedRef.current = true; + debouncedAutosave.cancel(); + onCommit(normalize(draft)); + }; + const cancel = () => { + cancelledRef.current = true; + debouncedAutosave.cancel(); + onCancel(); + }; + + const preview = formatLongTextPreview(toText(value)); + + return ( + { + if (opened) return; + // Programmatic close after cancel must not re-commit. + if (cancelledRef.current) { + cancelledRef.current = false; + return; + } + commit(); + }} + position="bottom-start" + width={320} + shadow="md" + withinPortal + closeOnClickOutside + closeOnEscape={false} + trapFocus + > + +
+ {preview ? ( + + {preview} + + ) : ( + + )} +
+
+ e.stopPropagation()} + className={cellClasses.longTextDropdown} + > + {isEditing && ( + <> + + + +