diff --git a/.claude/superpowers/plans/2026-04-20-base-view-draft.md b/.claude/superpowers/plans/2026-04-20-base-view-draft.md new file mode 100644 index 000000000..284620f49 --- /dev/null +++ b/.claude/superpowers/plans/2026-04-20-base-view-draft.md @@ -0,0 +1,1204 @@ +# Base View Draft (Local-First Filter & Sort) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Source spec:** `.claude/superpowers/specs/2026-04-20-base-view-draft-design.md` — jump back there for any design-level question. This plan does not re-debate decisions. + +**Goal:** Make filter and sort changes on a base view local-first. They apply instantly for the editing user, live in localStorage scoped to `(userId, baseId, viewId)`, and never touch the server baseline until the user clicks "Save for everyone". A banner at the top of the table surfaces the draft state with Reset / Save controls. + +**Architecture:** A new Jotai `atomFamily(atomWithStorage)` per `(user, base, view)` triple stores a `BaseViewDraft` JSON in localStorage. A `useViewDraft` hook wraps the atom and exposes `effectiveFilter` / `effectiveSorts` / `isDirty` / setters / `reset` / `buildPromotedConfig`. `base-table.tsx` wires the hook in, swaps the raw `activeView` for an "effective" view (baseline merged with draft) when seeding `useBaseTable` and the row query, and passes the draft setters down to the toolbar in place of the current direct mutation calls. A new `BaseViewDraftBanner` mounts between page chrome and the toolbar. `useBaseTable` gains an optional `baselineConfig` so `persistViewConfig` (column layout auto-save) can never bake draft filter/sort into the server baseline. + +**Tech Stack:** React 18, Jotai 2.18 (`atomWithStorage` + `atomFamily` + `RESET` sentinel from `jotai/utils`), Mantine v8 (`Paper`, `Group`, `Text`, `Button`), `@tabler/icons-react` (`IconInfoCircle`), CASL (client-side `SpaceAbility`), existing `useUpdateViewMutation` / `useSpaceQuery` / `useSpaceAbility` / `useCurrentUser` hooks. + +**Scope (v1):** Filter and sort only. Column layout (widths, order, visibility) continues to auto-persist through the existing debounced `persistViewConfig` path. No server-side drafts, no "save as new view", no conflict UI, no garbage collection. See spec "Non-goals" for the full list. + +**Testing note:** Per `CLAUDE.md`, `apps/client` has no `vitest` harness. Verification steps in this plan are a mix of `pnpm nx run client:build` type-checks (machine-checkable) and manual browser / DevTools checks (user-driven). No unit-test commands are invented for the client. + +--- + +## File Structure + +**New files:** +- `apps/client/src/features/base/atoms/view-draft-atom.ts` — `atomFamily(atomWithStorage)` pair keyed by `(userId, baseId, viewId)`; persists `BaseViewDraft | null` in localStorage under `docmost:base-view-draft:v1:{userId}:{baseId}:{viewId}`. +- `apps/client/src/features/base/hooks/use-view-draft.ts` — `useViewDraft` hook; derives `effective*` values, `isDirty`, exposes `setFilter` / `setSorts` / `reset` / `buildPromotedConfig`. +- `apps/client/src/features/base/components/base-view-draft-banner.tsx` — pure presentational banner shown when `isDirty === true`; "Reset" always, "Save for everyone" only if `canSave`. + +**Modified files:** +- `apps/client/src/features/space/permissions/permissions.type.ts` — add `Base = "base"` to `SpaceCaslSubject`; widen `SpaceAbility` union with `[SpaceCaslAction, SpaceCaslSubject.Base]`. +- `apps/client/src/features/base/types/base.types.ts` — add the `BaseViewDraft` type. +- `apps/client/src/features/base/hooks/use-base-table.ts` — extend `useBaseTable` signature with optional `opts?: { baselineConfig?: ViewConfig }`; in `persistViewConfig` override `sorts` / `filter` with the baseline so draft values cannot leak into the layout auto-save. +- `apps/client/src/features/base/components/base-toolbar.tsx` — drop internal `useUpdateViewMutation` use for filter/sort; accept new `onDraftSortsChange` / `onDraftFiltersChange` / `activeView` props that read the effective config for badge counts. +- `apps/client/src/features/base/components/base-table.tsx` — wire `useViewDraft`; build `effectiveView` memo; pass effective view + baseline into `useBaseTable`; pass effective filter/sorts to `useBaseRowsQuery`; render ``; add `useSpaceQuery` + `useSpaceAbility` for `canSave`; wire `handleSaveDraft`. + +--- + +## Task 1: Add `Base` to the client-side CASL enum + +**Files:** +- Modify: `apps/client/src/features/space/permissions/permissions.type.ts` + +**Rationale:** The server enum ([space-ability.type.ts:14](apps/server/src/core/casl/interfaces/space-ability.type.ts)) already has `Base = 'base'` and its `ISpaceAbility` union includes it. The membership permissions returned by `useSpaceQuery` therefore contain `Base` rules today, but the client enum at [permissions.type.ts:8-12](apps/client/src/features/space/permissions/permissions.type.ts) is missing the value, so `spaceAbility.can(SpaceCaslAction.Edit, SpaceCaslSubject.Base)` doesn't type-check on the client. A ripgrep for `switch(.*SpaceCaslSubject` shows no exhaustive switches on the subject enum in the client, so adding a value is safe. + +- [ ] **Step 1: Type-check baseline** + +```bash +pnpm nx run client:build +``` + +Expected: build succeeds. This is the "no changes yet" baseline — if it already fails, stop and report. + +- [ ] **Step 2: Add `Base` to the enum and widen `SpaceAbility`** + +Replace the file contents: + +```ts +export enum SpaceCaslAction { + Manage = "manage", + Create = "create", + Read = "read", + Edit = "edit", + Delete = "delete", +} +export enum SpaceCaslSubject { + Settings = "settings", + Member = "member", + Page = "page", + Base = "base", +} + +export type SpaceAbility = + | [SpaceCaslAction, SpaceCaslSubject.Settings] + | [SpaceCaslAction, SpaceCaslSubject.Member] + | [SpaceCaslAction, SpaceCaslSubject.Page] + | [SpaceCaslAction, SpaceCaslSubject.Base]; +``` + +- [ ] **Step 3: Type-check after change** + +```bash +pnpm nx run client:build +``` + +Expected: build still succeeds. (No existing caller consumes `SpaceCaslSubject.Base` yet, so this is purely additive.) + +- [ ] **Step 4: Commit** + +```bash +git add apps/client/src/features/space/permissions/permissions.type.ts +git commit -m "feat(base): add Base subject to client-side space CASL enum" +``` + +--- + +## Task 2: Add `BaseViewDraft` type and the `viewDraftAtomFamily` + +**Files:** +- Modify: `apps/client/src/features/base/types/base.types.ts` +- Create: `apps/client/src/features/base/atoms/view-draft-atom.ts` + +- [ ] **Step 1: Append the `BaseViewDraft` type** + +Add to the end of `apps/client/src/features/base/types/base.types.ts`: + +```ts +// Local-first draft of filter / sort tweaks for a single view, stored in +// localStorage scoped to (userId, baseId, viewId). An absent `filter` or +// `sorts` field means "inherit the baseline for that axis". See +// `.claude/superpowers/specs/2026-04-20-base-view-draft-design.md`. +export type BaseViewDraft = { + filter?: FilterGroup; + sorts?: ViewSortConfig[]; + // ISO timestamp written on each put; diagnostic only, not read by logic. + updatedAt: string; +}; +``` + +- [ ] **Step 2: Create the atom family** + +Create `apps/client/src/features/base/atoms/view-draft-atom.ts`: + +```ts +import { atomFamily, atomWithStorage } from "jotai/utils"; +import { BaseViewDraft } from "@/features/base/types/base.types"; + +export type ViewDraftKey = { + userId: string; + baseId: string; + viewId: string; +}; + +export const viewDraftStorageKey = (k: ViewDraftKey) => + `docmost:base-view-draft:v1:${k.userId}:${k.baseId}:${k.viewId}`; + +// `atomWithStorage` handles JSON serialization, cross-tab sync via the +// `storage` event, and lazy first-read out of the box. `atomFamily`'s +// comparator ensures the same triple resolves to the same atom instance +// across renders, so identity-equality cache hits in Jotai still work. +export const viewDraftAtomFamily = atomFamily( + (k: ViewDraftKey) => + atomWithStorage(viewDraftStorageKey(k), null), + (a, b) => + a.userId === b.userId && a.baseId === b.baseId && a.viewId === b.viewId, +); +``` + +- [ ] **Step 3: Type-check** + +```bash +pnpm nx run client:build +``` + +Expected: build succeeds. Nothing consumes the atom yet; this is a scaffold for Task 3. + +- [ ] **Step 4: Commit** + +```bash +git add apps/client/src/features/base/types/base.types.ts apps/client/src/features/base/atoms/view-draft-atom.ts +git commit -m "feat(base): add BaseViewDraft type and view-draft atom family" +``` + +--- + +## Task 3: Add `useViewDraft` hook + +**Files:** +- Create: `apps/client/src/features/base/hooks/use-view-draft.ts` + +**Design contract (from spec):** +1. If any of `userId / baseId / viewId` is `undefined` → return a passthrough state: `draft=null`, `isDirty=false`, setters no-op, `effective*` fall through to baseline. +2. `setFilter(next)` / `setSorts(next)` compute `merged = { ...(draft ?? {}), [axis]: next, updatedAt: new Date().toISOString() }`. If both `filter` and `sorts` come out `undefined`, call `setDraft(RESET)` to remove the key. +3. `reset()` is `setDraft(RESET)`. +4. `isDirty` compares draft-vs-baseline per axis via `JSON.stringify` equality; `null/undefined` means "no divergence on that axis". +5. `buildPromotedConfig(baseline)` returns `{ ...baseline, filter: draft?.filter ?? baseline.filter, sorts: draft?.sorts ?? baseline.sorts }` — preserves all non-draft fields (widths, order, visibility). + +- [ ] **Step 1: Scaffold the file** + +Create `apps/client/src/features/base/hooks/use-view-draft.ts` with imports and exported types only — no logic yet. This is the "stub" in the scaffold → implement pattern: + +```ts +import { useCallback, useMemo } from "react"; +import { useAtom } from "jotai"; +import { RESET } from "jotai/utils"; +import { + BaseViewDraft, + FilterGroup, + ViewConfig, + ViewSortConfig, +} from "@/features/base/types/base.types"; +import { viewDraftAtomFamily } from "@/features/base/atoms/view-draft-atom"; + +export type UseViewDraftArgs = { + userId: string | undefined; + baseId: string | undefined; + viewId: string | undefined; + baselineFilter: FilterGroup | undefined; + baselineSorts: ViewSortConfig[] | undefined; +}; + +export type ViewDraftState = { + draft: BaseViewDraft | null; + effectiveFilter: FilterGroup | undefined; + effectiveSorts: ViewSortConfig[] | undefined; + isDirty: boolean; + setFilter: (filter: FilterGroup | undefined) => void; + setSorts: (sorts: ViewSortConfig[] | undefined) => void; + reset: () => void; + buildPromotedConfig: (baseline: ViewConfig) => ViewConfig; +}; + +// Passthrough shape returned when any of userId/baseId/viewId is undefined. +// Guards the initial-load window where auth / activeView hasn't resolved. +const PASSTHROUGH_DRAFT: BaseViewDraft | null = null; + +export function useViewDraft(_args: UseViewDraftArgs): ViewDraftState { + // TODO(Task 3 step 2): implement. + throw new Error("useViewDraft: not implemented"); +} +``` + +- [ ] **Step 2: Verify the stub type-checks** + +```bash +pnpm nx run client:build +``` + +Expected: build succeeds (nothing imports the hook yet). + +- [ ] **Step 3: Implement the hook** + +Replace the stub body with the full implementation: + +```ts +import { useCallback, useMemo } from "react"; +import { useAtom } from "jotai"; +import { RESET } from "jotai/utils"; +import { + BaseViewDraft, + FilterGroup, + ViewConfig, + ViewSortConfig, +} from "@/features/base/types/base.types"; +import { viewDraftAtomFamily } from "@/features/base/atoms/view-draft-atom"; + +export type UseViewDraftArgs = { + userId: string | undefined; + baseId: string | undefined; + viewId: string | undefined; + baselineFilter: FilterGroup | undefined; + baselineSorts: ViewSortConfig[] | undefined; +}; + +export type ViewDraftState = { + draft: BaseViewDraft | null; + effectiveFilter: FilterGroup | undefined; + effectiveSorts: ViewSortConfig[] | undefined; + isDirty: boolean; + setFilter: (filter: FilterGroup | undefined) => void; + setSorts: (sorts: ViewSortConfig[] | undefined) => void; + reset: () => void; + buildPromotedConfig: (baseline: ViewConfig) => ViewConfig; +}; + +// JSON-stringify equality is good enough for FilterGroup (pure data tree) +// and ViewSortConfig[] — V8 preserves non-numeric key insertion order so +// the same object graph serializes identically. Avoids pulling in +// lodash/fast-deep-equal for two known-shaped types. (Spec "Dirty check".) +function filterEq(a: FilterGroup | undefined, b: FilterGroup | undefined) { + return JSON.stringify(a ?? null) === JSON.stringify(b ?? null); +} +function sortsEq( + a: ViewSortConfig[] | undefined, + b: ViewSortConfig[] | undefined, +) { + return JSON.stringify(a ?? null) === JSON.stringify(b ?? null); +} + +export function useViewDraft(args: UseViewDraftArgs): ViewDraftState { + const { userId, baseId, viewId, baselineFilter, baselineSorts } = args; + const ready = !!(userId && baseId && viewId); + + // Always mount an atom with a stable shape so hook order is consistent. + // When not ready we still feed a key, but we won't read/write it. + const atomKey = useMemo( + () => ({ + userId: userId ?? "", + baseId: baseId ?? "", + viewId: viewId ?? "", + }), + [userId, baseId, viewId], + ); + const [storedDraft, setDraft] = useAtom(viewDraftAtomFamily(atomKey)); + + const draft = ready ? storedDraft : null; + + const setFilter = useCallback( + (next: FilterGroup | undefined) => { + if (!ready) return; + const current = storedDraft ?? null; + const mergedFilter = next; + const mergedSorts = current?.sorts; + if (mergedFilter === undefined && (mergedSorts === undefined || mergedSorts === null)) { + setDraft(RESET); + return; + } + setDraft({ + filter: mergedFilter, + sorts: mergedSorts, + updatedAt: new Date().toISOString(), + }); + }, + [ready, storedDraft, setDraft], + ); + + const setSorts = useCallback( + (next: ViewSortConfig[] | undefined) => { + if (!ready) return; + const current = storedDraft ?? null; + const mergedFilter = current?.filter; + const mergedSorts = next; + if (mergedFilter === undefined && (mergedSorts === undefined || mergedSorts === null)) { + setDraft(RESET); + return; + } + setDraft({ + filter: mergedFilter, + sorts: mergedSorts, + updatedAt: new Date().toISOString(), + }); + }, + [ready, storedDraft, setDraft], + ); + + const reset = useCallback(() => { + if (!ready) return; + setDraft(RESET); + }, [ready, setDraft]); + + const effectiveFilter = useMemo( + () => (draft?.filter !== undefined ? draft.filter : baselineFilter), + [draft?.filter, baselineFilter], + ); + const effectiveSorts = useMemo( + () => (draft?.sorts !== undefined ? draft.sorts : baselineSorts), + [draft?.sorts, baselineSorts], + ); + + const isDirty = useMemo(() => { + if (!draft) return false; + const filterDirty = + draft.filter !== undefined && !filterEq(draft.filter, baselineFilter); + const sortsDirty = + draft.sorts !== undefined && !sortsEq(draft.sorts, baselineSorts); + return filterDirty || sortsDirty; + }, [draft, baselineFilter, baselineSorts]); + + const buildPromotedConfig = useCallback( + (baseline: ViewConfig): ViewConfig => ({ + ...baseline, + filter: draft?.filter ?? baseline.filter, + sorts: draft?.sorts ?? baseline.sorts, + }), + [draft], + ); + + if (!ready) { + return { + draft: null, + effectiveFilter: baselineFilter, + effectiveSorts: baselineSorts, + isDirty: false, + setFilter: () => {}, + setSorts: () => {}, + reset: () => {}, + buildPromotedConfig: (baseline) => baseline, + }; + } + + return { + draft, + effectiveFilter, + effectiveSorts, + isDirty, + setFilter, + setSorts, + reset, + buildPromotedConfig, + }; +} +``` + +- [ ] **Step 4: Type-check** + +```bash +pnpm nx run client:build +``` + +Expected: build succeeds. Hook has no consumers yet (Task 5 will wire it in). + +- [ ] **Step 5: Commit** + +```bash +git add apps/client/src/features/base/hooks/use-view-draft.ts +git commit -m "feat(base): add useViewDraft hook for local filter/sort drafts" +``` + +--- + +## Task 4: Extend `useBaseTable` with `baselineConfig` option + +**Files:** +- Modify: `apps/client/src/features/base/hooks/use-base-table.ts` (signature ~line 224-228; `persistViewConfig` body ~line 371-396; exact block is `buildViewConfigFromTable(table, activeView.config)` at line 382) + +**Why:** Once `base-table.tsx` starts passing an `effectiveView` into `useBaseTable` (Task 5), the table's live `sorting` state will be seeded from the draft. The debounced `persistViewConfig` reads `state.sorting` (via `buildViewConfigFromTable`) and writes it back to the server — which would silently bake the draft into the server baseline every time the user resizes or reorders a column. The fix is: `persistViewConfig` overrides `sorts` and `filter` in the emitted config with the **real baseline** values, not the effective ones. To keep the hook's responsibilities tidy, existing callers stay unchanged — a new optional `opts.baselineConfig` parameter carries the baseline. + +- [ ] **Step 1: Change the signature** + +In `use-base-table.ts` find: + +```ts +export function useBaseTable( + base: IBase | undefined, + rows: IBaseRow[], + activeView: IBaseView | undefined, +): UseBaseTableResult { +``` + +Replace with: + +```ts +export type UseBaseTableOptions = { + // When provided, `persistViewConfig` uses this as the authoritative + // filter/sorts for the server write. The table's live sorting state is + // ignored for that axis so a locally-drafted sort/filter (kept in + // `activeView.config` for rendering purposes) cannot leak into the + // auto-persist column-layout path. Optional to preserve existing + // callers that pass the real baseline as `activeView`. + baselineConfig?: ViewConfig; +}; + +export function useBaseTable( + base: IBase | undefined, + rows: IBaseRow[], + activeView: IBaseView | undefined, + opts: UseBaseTableOptions = {}, +): UseBaseTableResult { +``` + +- [ ] **Step 2: Use `baselineConfig` inside `persistViewConfig`** + +Find the `persistViewConfig` `useCallback` at ~line 371 and the `buildViewConfigFromTable(table, activeView.config)` call at ~line 382. Replace the inner body: + +Before (line 382): + +```ts +const config = buildViewConfigFromTable(table, activeView.config); +``` + +After: + +```ts +// `baseline` is the server-side-of-truth config. When the caller has +// wrapped `activeView` with draft filter/sort values for render, they +// pass the pre-wrap config here so we never round-trip drafts through +// the column-layout auto-save path. +const baseline = opts.baselineConfig ?? activeView.config; +const config = buildViewConfigFromTable(table, baseline, { + sorts: baseline?.sorts, + filter: baseline?.filter, +}); +``` + +Also update the `persistViewConfig` dependency list at line 396 from `[activeView, base, table, updateViewMutation]` to `[activeView, base, table, updateViewMutation, opts.baselineConfig]`. + +- [ ] **Step 3: Type-check** + +```bash +pnpm nx run client:build +``` + +Expected: build succeeds. Existing callers (just `base-table.tsx` so far) didn't pass `opts`, so they get `baselineConfig: undefined` → `baseline = activeView.config` → behavior unchanged. + +- [ ] **Step 4: Commit** + +```bash +git add apps/client/src/features/base/hooks/use-base-table.ts +git commit -m "refactor(base): accept baselineConfig option in useBaseTable" +``` + +--- + +## Task 5: Integrate `useViewDraft` into `base-table.tsx` (render path only) + +**Files:** +- Modify: `apps/client/src/features/base/components/base-table.tsx` (imports near top; `activeView` memo ~line 40-43; rows query call ~line 52-53; `useBaseTable` call at line 88) + +**Scope:** Only the **render path**. At the end of this task: +- The table renders from effective (draft-or-baseline) values. +- The row query fetches using effective values. +- Filters/sorts still auto-persist via the existing toolbar handlers (Task 6 rewires that). +- No banner yet (Task 7/8). + +**Manual pre-test with DevTools:** At the end of the task, verify drafts render correctly by seeding localStorage directly. + +- [ ] **Step 1: Add imports** + +At the top of `base-table.tsx`, add: + +```tsx +import useCurrentUser from "@/features/user/hooks/use-current-user"; +import { useViewDraft } from "@/features/base/hooks/use-view-draft"; +``` + +- [ ] **Step 2: Wire the draft hook and build `effectiveView`** + +Insert after the `activeView` memo (currently at line 40-43 — the `useMemo` that resolves `activeView` from `views` and `activeViewId`), before the `activeFilter` / `activeSorts` lines: + +```tsx +const { data: currentUser } = useCurrentUser(); +const { + draft: _draft, + effectiveFilter, + effectiveSorts, + isDirty, + setFilter: setDraftFilter, + setSorts: setDraftSorts, + reset: resetDraft, + buildPromotedConfig, +} = useViewDraft({ + userId: currentUser?.user.id, + baseId, + viewId: activeView?.id, + baselineFilter: activeView?.config?.filter, + baselineSorts: activeView?.config?.sorts, +}); + +// Render view: baseline merged with any local draft. Passed to +// `useBaseTable` (for table state seeding) and to the toolbar (for badge +// counts). The real `activeView` is still used as the auto-persist +// baseline so drafts can't leak into column-layout writes. +const effectiveView = useMemo( + () => + activeView + ? { + ...activeView, + config: { + ...activeView.config, + filter: effectiveFilter, + sorts: effectiveSorts, + }, + } + : undefined, + [activeView, effectiveFilter, effectiveSorts], +); +``` + +- [ ] **Step 3: Replace the `activeFilter` / `activeSorts` lines** + +Before (~line 45-46): + +```tsx +const activeFilter = activeView?.config?.filter; +const activeSorts = activeView?.config?.sorts; +``` + +After: + +```tsx +// Effective values drive the row query and the client-side position +// sort guard below. The old `activeView.config` reads are no longer the +// source of truth once drafts are involved. +const activeFilter = effectiveFilter; +const activeSorts = effectiveSorts; +``` + +(Renaming is intentionally minimal — downstream usages of `activeSorts` / `activeFilter` at `useBaseRowsQuery(...)` and in the position-sort memo keep working without further edits.) + +- [ ] **Step 4: Pass `effectiveView` + `baselineConfig` into `useBaseTable`** + +Before (~line 88): + +```tsx +const { table, persistViewConfig } = useBaseTable(base, rows, activeView); +``` + +After: + +```tsx +const { table, persistViewConfig } = useBaseTable(base, rows, effectiveView, { + baselineConfig: activeView?.config, +}); +``` + +- [ ] **Step 5: Type-check** + +```bash +pnpm nx run client:build +``` + +Expected: build succeeds. `isDirty`, `setDraftFilter`, `setDraftSorts`, `resetDraft`, `buildPromotedConfig` are declared-but-unused at this step; that's intentional — Tasks 6 and 8 consume them. TypeScript `noUnusedLocals` is not enabled in the client's `tsconfig` (check with `pnpm nx run client:build` — if it errors on unused, prefix with `_`). + +- [ ] **Step 6: Manual DevTools verification — USER-DRIVEN** + +> Do not run `pnpm dev` as an agent. Hand off to the user. + +Ask the user to: + +1. Run `pnpm dev` and open any base in the browser. +2. In DevTools → Application → Local Storage, note the current user id from the cookie or any existing `docmost:*` key, then note the base id from the URL and the active view id (visible in the `activeViewId` atom via React DevTools, or pick the first view's id from the `["bases", baseId]` query data). +3. In DevTools console: + + ```js + // Seed a dummy draft that sorts by any existing propertyId desc. + const key = `docmost:base-view-draft:v1:${USER_ID}:${BASE_ID}:${VIEW_ID}`; + localStorage.setItem(key, JSON.stringify({ + sorts: [{ propertyId: "", direction: "desc" }], + updatedAt: new Date().toISOString(), + })); + location.reload(); + ``` + +Expected after reload: +- Rows appear sorted by the seeded propertyId descending. +- The sort popover badge shows `1`. +- The sort popover, when opened, shows the drafted sort entry. + +Then run: + +```js +localStorage.removeItem(key); +location.reload(); +``` + +Expected: rows revert to the baseline order; badge clears. + +If either side fails, stop and diagnose. Do not proceed to Task 6. + +- [ ] **Step 7: Commit** + +```bash +git add apps/client/src/features/base/components/base-table.tsx +git commit -m "feat(base): render table from effective (draft-or-baseline) view config" +``` + +--- + +## Task 6: Rewire toolbar to write drafts instead of persisting immediately + +**Files:** +- Modify: `apps/client/src/features/base/components/base-toolbar.tsx` (remove `useUpdateViewMutation` / `buildViewConfigFromTable` usage for sort/filter — lines 19, 20, 116, 135-148, 150-169; badge sources at 118, 122-128; props type at 29-37) +- Modify: `apps/client/src/features/base/components/base-table.tsx` (pass the new callbacks + effective view into ``, ~line 192-200) + +**Why:** Today `handleSortsChange` and `handleFiltersChange` call `updateViewMutation.mutate(...)` directly — that's the "every change persists to everyone" behavior we're replacing. The toolbar gets two new callbacks (`onDraftSortsChange`, `onDraftFiltersChange`) from the parent and drops its internal mutation for these two axes. Badge counts must read from `effectiveView.config` so they reflect the user's draft, not the baseline. + +- [ ] **Step 1: Change the toolbar's props type** + +In `base-toolbar.tsx`, replace the `BaseToolbarProps` type (line 29-37): + +Before: + +```ts +type BaseToolbarProps = { + base: IBase; + activeView: IBaseView | undefined; + views: IBaseView[]; + table: Table; + onViewChange: (viewId: string) => void; + onAddView?: () => void; + onPersistViewConfig: () => void; +}; +``` + +After: + +```ts +type BaseToolbarProps = { + base: IBase; + // Effective view — baseline merged with any local draft. Badge counts + // and sort/filter popover seed data read from this. The real baseline + // only enters via `onDraftSortsChange` / `onDraftFiltersChange` + // callbacks defined by the parent. + activeView: IBaseView | undefined; + views: IBaseView[]; + table: Table; + onViewChange: (viewId: string) => void; + onAddView?: () => void; + onPersistViewConfig: () => void; + onDraftSortsChange: (sorts: ViewSortConfig[] | undefined) => void; + onDraftFiltersChange: (filter: FilterGroup | undefined) => void; +}; +``` + +Destructure the two new props in the function signature: + +Before (line 39-47): + +```ts +export function BaseToolbar({ + base, + activeView, + views, + table, + onViewChange, + onAddView, + onPersistViewConfig, +}: BaseToolbarProps) { +``` + +After: + +```ts +export function BaseToolbar({ + base, + activeView, + views, + table, + onViewChange, + onAddView, + onPersistViewConfig, + onDraftSortsChange, + onDraftFiltersChange, +}: BaseToolbarProps) { +``` + +- [ ] **Step 2: Remove the now-unused `updateViewMutation` and `buildViewConfigFromTable` imports/calls for sort/filter** + +The toolbar's `useUpdateViewMutation()` call at line 116 is only used by `handleSortsChange` and `handleFiltersChange`. Both are being rewritten. Delete: + +- The import at line 19: + ```ts + import { useUpdateViewMutation } from "@/features/base/queries/base-view-query"; + ``` +- The import at line 20: + ```ts + import { buildViewConfigFromTable } from "@/features/base/hooks/use-base-table"; + ``` +- The `const updateViewMutation = useUpdateViewMutation();` line at 116. + +- [ ] **Step 3: Rewrite the two handlers** + +Replace `handleSortsChange` (lines 135-148): + +Before: + +```ts +const handleSortsChange = useCallback( + (newSorts: ViewSortConfig[]) => { + if (!activeView) return; + const config = buildViewConfigFromTable(table, activeView.config, { + sorts: newSorts, + }); + updateViewMutation.mutate({ + viewId: activeView.id, + baseId: base.id, + config, + }); + }, + [activeView, base.id, table, updateViewMutation], +); +``` + +After: + +```ts +const handleSortsChange = useCallback( + (newSorts: ViewSortConfig[]) => { + // Normalize empty to undefined so the draft hook can drop the `sorts` + // axis (and remove its localStorage entry when both axes go clean). + onDraftSortsChange(newSorts.length > 0 ? newSorts : undefined); + }, + [onDraftSortsChange], +); +``` + +Replace `handleFiltersChange` (lines 150-169): + +Before: + +```ts +const handleFiltersChange = useCallback( + (newConditions: FilterCondition[]) => { + if (!activeView) return; + const filter: FilterGroup | undefined = + newConditions.length > 0 + ? { op: "and", children: newConditions } + : undefined; + // `filter: undefined` in overrides removes the filter key; the helper's + // spread-then-overrides order means `undefined` wins over any base filter. + const config = buildViewConfigFromTable(table, activeView.config, { + filter, + }); + updateViewMutation.mutate({ + viewId: activeView.id, + baseId: base.id, + config, + }); + }, + [activeView, base.id, table, updateViewMutation], +); +``` + +After: + +```ts +const handleFiltersChange = useCallback( + (newConditions: FilterCondition[]) => { + // Wrap the AND-flat popover output into the engine's FilterGroup shape. + // Pass `undefined` to drop the filter axis from the draft entirely. + const filter: FilterGroup | undefined = + newConditions.length > 0 + ? { op: "and", children: newConditions } + : undefined; + onDraftFiltersChange(filter); + }, + [onDraftFiltersChange], +); +``` + +- [ ] **Step 4: Wire the new callbacks + effective view in `base-table.tsx`** + +In `base-table.tsx`, near the existing callback block (before the `return`), add: + +```tsx +const handleDraftSortsChange = useCallback( + (sorts: ViewSortConfig[] | undefined) => { + setDraftSorts(sorts && sorts.length > 0 ? sorts : undefined); + }, + [setDraftSorts], +); + +const handleDraftFiltersChange = useCallback( + (filter: FilterGroup | undefined) => { + setDraftFilter(filter); + }, + [setDraftFilter], +); +``` + +Add the imports needed for the callback types: + +```tsx +import { + FilterGroup, + ViewSortConfig, +} from "@/features/base/types/base.types"; +``` + +Update the `` JSX (currently at ~line 192-200) to pass `effectiveView` instead of `activeView` AND the two new callbacks: + +Before: + +```tsx + +``` + +After: + +```tsx + +``` + +- [ ] **Step 5: Type-check** + +```bash +pnpm nx run client:build +``` + +Expected: build succeeds. The toolbar's sort/filter badges now derive from `effectiveView.config` (because the parent passes `effectiveView` as `activeView`) — no further toolbar edits needed for the badge count issue flagged in the spec. + +- [ ] **Step 6: Manual verification — USER-DRIVEN** + +Ask the user to: + +1. Open a base in the browser. +2. Open the filter popover, add a filter (pick any property → any op → any value). +3. Observe: the row list updates locally, filter badge shows `1`. +4. Open DevTools → Application → Local Storage → look for `docmost:base-view-draft:v1:...`. Expected: entry present with the filter in JSON. +5. Hard-refresh (cmd+shift+R). The filter still applies locally. +6. Open the base in an incognito window (same base URL) as a different user — or ask a teammate. Expected: no filter applied; the baseline view is unchanged. + +If step 5 or step 6 fails, stop and diagnose. + +- [ ] **Step 7: Commit** + +```bash +git add apps/client/src/features/base/components/base-toolbar.tsx apps/client/src/features/base/components/base-table.tsx +git commit -m "feat(base): route toolbar sort/filter changes through local draft" +``` + +--- + +## Task 7: Build the `BaseViewDraftBanner` component + +**Files:** +- Create: `apps/client/src/features/base/components/base-view-draft-banner.tsx` + +Pure presentational. No data fetching, no state. Mounts the banner only when `isDirty === true` and shows "Reset" always, "Save for everyone" only when `canSave`. + +- [ ] **Step 1: Create the component file** + +Create `apps/client/src/features/base/components/base-view-draft-banner.tsx`: + +```tsx +import { Paper, Group, Text, Button } from "@mantine/core"; +import { IconInfoCircle } from "@tabler/icons-react"; +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 ( + + + + + + {t("Filter and sort changes are visible only to you.")} + + + + + {canSave && ( + + )} + + + + ); +} +``` + +- [ ] **Step 2: Type-check** + +```bash +pnpm nx run client:build +``` + +Expected: build succeeds. Component has no consumers yet. + +- [ ] **Step 3: Commit** + +```bash +git add apps/client/src/features/base/components/base-view-draft-banner.tsx +git commit -m "feat(base): add view draft banner component" +``` + +--- + +## Task 8: Mount the banner and wire the save flow in `base-table.tsx` + +**Files:** +- Modify: `apps/client/src/features/base/components/base-table.tsx` + +**What this task adds:** +- `useSpaceQuery` + `useSpaceAbility` → `canSave = spaceAbility.can(SpaceCaslAction.Edit, SpaceCaslSubject.Base)`. +- `useUpdateViewMutation` hook invocation at the page level. +- `handleSaveDraft` callback that composes `buildPromotedConfig(activeView.config)` → `updateViewMutation.mutateAsync(...)` → `resetDraft()` → success toast. +- `` mounted between page chrome and ``. + +- [ ] **Step 1: Add imports** + +Add to the top of `base-table.tsx`: + +```tsx +import { notifications } from "@mantine/notifications"; +import { useSpaceQuery } from "@/features/space/queries/space-query"; +import { useSpaceAbility } from "@/features/space/permissions/use-space-ability"; +import { + SpaceCaslAction, + SpaceCaslSubject, +} from "@/features/space/permissions/permissions.type"; +import { useUpdateViewMutation } from "@/features/base/queries/base-view-query"; +import { BaseViewDraftBanner } from "@/features/base/components/base-view-draft-banner"; +``` + +- [ ] **Step 2: Wire the space ability and the save handler** + +Insert after the `useViewDraft` / `effectiveView` block, before the `useBaseRowsQuery` call: + +```tsx +// `useSpaceQuery` is guarded by `enabled: !!spaceId` internally, so +// passing `""` when `base` hasn't loaded yet is safe. See +// use-history-restore.tsx for the same pattern. +const { data: space } = useSpaceQuery(base?.spaceId ?? ""); +const spaceAbility = useSpaceAbility(space?.membership?.permissions); +const canSave = spaceAbility.can( + SpaceCaslAction.Edit, + SpaceCaslSubject.Base, +); + +const updateViewMutation = useUpdateViewMutation(); + +const handleSaveDraft = useCallback(async () => { + if (!activeView || !base) return; + // `buildPromotedConfig` preserves all non-draft baseline fields + // (widths/order/visibility) and only overwrites filter/sorts when the + // draft has divergent values. + const config = buildPromotedConfig(activeView.config); + try { + await updateViewMutation.mutateAsync({ + viewId: activeView.id, + baseId: base.id, + config, + }); + resetDraft(); + notifications.show({ message: t("View updated for everyone") }); + } catch { + // `useUpdateViewMutation` already shows a red toast on error and + // rolls back the optimistic cache; keep the draft so the user can + // retry without re-typing. + } +}, [ + activeView, + base, + buildPromotedConfig, + resetDraft, + t, + updateViewMutation, +]); +``` + +- [ ] **Step 3: Mount the banner** + +Update the `return` block. Before (~line 190-215): + +```tsx +return ( +
+ + +
+); +``` + +After: + +```tsx +return ( +
+ + + +
+); +``` + +(The `` / `` props are re-listed verbatim from Task 6 — no behavioral change to them here; this step only adds the banner above.) + +- [ ] **Step 4: Type-check** + +```bash +pnpm nx run client:build +``` + +Expected: build succeeds. The previously "declared-but-unused" `isDirty`, `resetDraft`, and `buildPromotedConfig` are now consumed. + +- [ ] **Step 5: Manual verification — USER-DRIVEN** + +Ask the user to: + +1. Open a base. +2. Apply a filter via the popover. + - Expected: the yellow banner appears with info icon + "Filter and sort changes are visible only to you." on the left; "Reset" and "Save for everyone" on the right. +3. Click **Reset**. + - Expected: banner disappears; filter popover badge clears; rows revert to baseline. +4. Apply a filter again. Click **Save for everyone**. + - Expected: banner disappears; notification "View updated for everyone" appears. +5. Hard-refresh the page. + - Expected: filter is still applied (baseline has caught up). No banner. +6. Open the same base in a second browser profile / incognito as a different user. + - Expected: that user sees the saved filter. +7. As a viewer (a user with Read but not Edit on Base): open the base, apply a filter. + - Expected: banner appears but only shows "Reset" — no "Save for everyone" button. + +If any of these fails, stop and diagnose. + +- [ ] **Step 6: Commit** + +```bash +git add apps/client/src/features/base/components/base-table.tsx +git commit -m "feat(base): mount draft banner and wire save-for-everyone flow" +``` + +--- + +## Task 9: Final manual QA pass — USER-DRIVEN + +> Do not run `pnpm dev` as an agent. Ask the user to step through the spec's "Manual QA checklist" end-to-end. This is a verification task, not a commit task. If any check fails, open a sub-task to fix before continuing. + +Reference: spec section "Manual QA checklist" (`.claude/superpowers/specs/2026-04-20-base-view-draft-design.md` lines 425-451). + +- [ ] **Step 1: Single user, single tab** + - Apply a filter → banner appears, row list updates locally. + - Click Reset → banner disappears, filter popover reverts, rows revert. + - Apply a filter and a sort → click Save for everyone → banner disappears → refresh → filter/sort is the new baseline. + - Apply a filter then delete it via the popover → banner disappears → refresh → baseline unchanged (no deleted filter restored). + +- [ ] **Step 2: Single user, multiple tabs** + - Open base in tab A and tab B. + - In tab A, add a sort → tab B re-renders with the same sort (badge + row order) → tab B shows the banner. + - In tab B, click Reset → tab A's banner disappears and sort reverts. + +- [ ] **Step 3: Multi-user baseline race** + - User X (editor) opens base, applies a filter (draft). + - User Y (another editor) saves a new baseline via their own Save flow. + - User X's client receives the websocket `base:schema:bumped` → `["bases", baseId]` invalidates → `activeView.config` updates. + - Expected: X's `effectiveFilter` still shows X's draft filter. Banner stays. No UI prompt. + - X clicks Reset → X sees Y's new baseline. + +- [ ] **Step 4: Permission gating** + - Log in as a space Viewer (Read but not Edit on `Base`). + - Open base, apply a filter. + - Expected: banner appears but shows only "Reset" — no "Save for everyone" button. + +- [ ] **Step 5: Reset with popover open** + - Open the filter popover, add conditions. + - Without closing the popover, click Reset (the banner is above the popover). + - Expected: popover closes on outside-click; the next open shows baseline conditions. + +- [ ] **Step 6: Save clears draft + updates server** + - Save. Banner vanishes. + - In DevTools → Application → Local Storage: `docmost:base-view-draft:v1:{user}:{base}:{view}` is absent. + - Open the base in incognito / second-account browser: the filter/sort is present from the server. + +- [ ] **Step 7: Browser storage cleared** + - In DevTools, wipe `localStorage`. + - Expected: base re-renders with baseline, banner gone. + +- [ ] **Step 8: Column layout still auto-saves (regression check)** + - With a filter draft active, drag a column to reorder. + - Wait ~1s for the debounce. + - Expected: column order persists (open base in another tab; order matches) AND the filter draft remains a draft (baseline's filter on the server is still the pre-draft state). Verify via the server API or a second-account browser. + +--- + +## Follow-ups (out of scope for v1) + +- Draft column layout (widths, order, visibility) — spec "Future extension #1". +- Server-side per-user drafts for cross-device sync — spec "Future extension #2". +- "Save as new view" split-button — spec "Future extension #3". +- Baseline-changed hint inside the banner — spec "Future extension #4". +- One-time in-product hint explaining the new draft-then-save behavior — spec "Rollout" mitigation note. diff --git a/.claude/superpowers/specs/2026-04-20-base-page-property-design.md b/.claude/superpowers/specs/2026-04-20-base-page-property-design.md new file mode 100644 index 000000000..4f9ced972 --- /dev/null +++ b/.claude/superpowers/specs/2026-04-20-base-page-property-design.md @@ -0,0 +1,309 @@ +# Base `page` Property Type — Design Spec + +**Date:** 2026-04-20 +**Status:** Draft +**Feature area:** `apps/server/src/core/base`, `apps/client/src/features/base`, `apps/server/src/core/page` + +## Goal + +Add a new base property type `page` that lets a user search for and link **one existing page** per cell. Modeled on how the editor's `@` page-mention works — the picker searches existing pages workspace-wide (with current-space prioritized) and the cell renders a live pill with the page's icon and title. No page is auto-created from the picker; users can only link pages that already exist. + +Why: today users who want a page-reference column would have to paste a URL into a `url` cell, which loses the icon + title and doesn't validate. We also want to avoid the Focalboard-style pattern of auto-creating a page-row per table row, which would bloat the pages tree. + +## Non-goals (v1) + +- **Multiple pages per cell.** Single page only. Forward-compatible: the schema widens trivially to `z.union([z.uuid(), z.array(z.uuid())])` + an `allowMultiple` type option later, with zero data migration (see "Future extension" below). +- **Sorting by page title.** Would require a JOIN against `pages` in the row-list query; skip in v1. Filter suffices. +- **Creating pages from within the picker.** +- **Cross-workspace page linking.** +- **Rich previews / hover cards** showing page excerpts — pill-only. +- **Confluence-style section grouping** in the property type picker (e.g. the "Page and live doc" section in the screenshot). Flat list for v1; grouping is a separate polish task. + +## UX overview + +### Picker (edit mode) + +- Popover modeled on [cell-person.tsx](../../../apps/client/src/features/base/components/cells/cell-person.tsx) but stripped for single-select. `width=300`, `position="bottom-start"`, `trapFocus`. +- Top: search input, auto-focused. If a page is currently linked, a removable "tag" for it sits above the search (same shape as `personTag`). +- Body: results list (max 25), fed by `searchSuggestions({ query, includePages: true, spaceId: base.spaceId, limit: 25 })` — reuses the existing suggestion endpoint, which prioritizes `spaceId` results. +- Each row: `{icon or IconFileDescription} {title}` + muted space name on the right (so cross-space picks are visually distinct). +- Empty-query state: if pulling recent-pages is easy to plug in, show recent pages; otherwise "Type to search…" hint. +- Click or Enter on a highlighted row → `onCommit(pageId)`, popover closes. +- Esc / click-outside → `onCancel`. +- Clicking the "Remove" affordance on the current tag → `onCommit(null)`. +- Keyboard: reuse `useListKeyboardNav`. + +### View mode + +- Empty cell → empty placeholder (same class as `cellClasses.emptyValue`). +- Resolved page → pill `{icon or IconFileDescription} {title}`, anchor that navigates to `buildPageUrl(space.slug, slugId, title)` using the helper that [mention-view.tsx](../../../apps/client/src/features/editor/components/mention/mention-view.tsx) already uses. +- Unresolved (deleted or viewer has no access) → greyed pill "Page not found", no link, `aria-disabled`. +- Single click on the pill = navigate. Double-click on the cell = open picker (same rule grid-cell applies to other types). + +### Sort / filter UI + +- [view-sort-config.tsx](../../../apps/client/src/features/base/components/views/view-sort-config.tsx): exclude `page` properties from the sortable set. +- [view-filter-config.tsx](../../../apps/client/src/features/base/components/views/view-filter-config.tsx): filter editor branch for `page` with operators `isEmpty`, `isNotEmpty`, `any`, `none`. The value picker reuses the same search dropdown from the cell picker. + +## Data model + +### Cell value + +- **Stored shape:** `string` (page UUID) or `null`. Parallels `person` in single mode. +- **Example:** `{ "01998b7e-...": "01998b80-..." }` — property UUID → page UUID. + +### Property type options + +- **v1:** empty `{}` (reuse `emptyTypeOptionsSchema`). +- **Future:** `{ allowMultiple?: boolean }`. + +### Schema additions + +**Server — [base.schemas.ts](../../../apps/server/src/core/base/base.schemas.ts):** + +```ts +export const BasePropertyType = { + // ...existing entries... + PAGE: 'page', +} as const; + +// typeOptionsSchemaMap +[BasePropertyType.PAGE]: emptyTypeOptionsSchema, + +// cellValueSchemaMap +[BasePropertyType.PAGE]: z.uuid(), +``` + +**Client — [base.types.ts](../../../apps/client/src/features/base/types/base.types.ts):** + +```ts +export type BasePropertyType = ... | 'page'; +export type PageTypeOptions = Record; +``` + +### Property kind & engine + +**[engine/kinds.ts](../../../apps/server/src/core/base/engine/kinds.ts):** + +```ts +export const PropertyKind = { + // ...existing... + PAGE: 'page', +} as const; + +// propertyKind() +case BasePropertyType.PAGE: + return PropertyKind.PAGE; +``` + +**[engine/predicate.ts](../../../apps/server/src/core/base/engine/predicate.ts):** new `pageCondition()` handler — shape follows `selectCondition()` (single UUID stored as text): + +- `isEmpty` / `isNotEmpty` → `textCell` is null or empty +- `eq` / `neq` → text equality / inequality (null-safe for `neq`) +- `any` → `textCell IN (...)` +- `none` → `textCell NOT IN (...)` or null + +Wired into the `switch (kind)` in `buildCondition`: +```ts +case PropertyKind.PAGE: + return pageCondition(eb, cond); +``` + +**[engine/sort.ts](../../../apps/server/src/core/base/engine/sort.ts):** no new branch. `page` falls into the default text-sentinel path (sorts by raw UUID string, which is unhelpful but harmless — the sort UI won't expose this type in v1). + +### Type conversion + +**[base.schemas.ts `CellConversionContext`](../../../apps/server/src/core/base/base.schemas.ts:191):** add a new field: + +```ts +export type CellConversionContext = { + fromTypeOptions?: unknown; + userNames?: Map; + attachmentNames?: Map; + pageTitles?: Map; // NEW +}; +``` + +**[base-type-conversion.task.ts](../../../apps/server/src/core/base/tasks/base-type-conversion.task.ts):** when `fromType === 'page'`, batch-load titles via the same page repo path used by the new resolver endpoint (see below) and populate `ctx.pageTitles`. + +**`attemptCellConversion` branches:** +- `page → text`: resolve `ctx.pageTitles.get(uuid)` → title (or `""` if missing). +- `page → *` (anything else): return `{converted: true, value: null}`. +- `* → page`: return `{converted: true, value: null}` (free text or other IDs can't be coerced to a valid page UUID). + +## Server: page resolver endpoint + +New endpoint for cell hydration on the client. Reusing `/pages/info` is inappropriate — it returns full page content and is one-at-a-time. + +### `POST /bases/pages/resolve` + +**Request:** +```ts +{ pageIds: string[] } // 1 <= length <= 100, enforced server-side; 400 on violation +``` + +**Response:** +```ts +{ + items: Array<{ + id: string; + slugId: string; + title: string | null; + icon: string | null; + spaceId: string; + space: { id: string; slug: string; name: string }; + }>; +} +``` + +### Behavior + +1. Deduplicate input IDs. +2. Select from `pages` where `id IN (...)` AND `deletedAt IS NULL` AND `workspaceId = current`. +3. Filter the result set through `pagePermissionRepo.filterAccessiblePageIds({ pageIds, userId })` — same mechanism used by [search.service.ts:131-139](../../../apps/server/src/core/search/search.service.ts). +4. Join `spaces` to include `space.slug` and `space.name` for navigation. +5. Silently omit any ID the user can't see (deleted, restricted, cross-workspace). The client treats any requested ID missing from `items` as "Page not found". + +### Code layout + +- **Controller:** add method to [base.controller.ts](../../../apps/server/src/core/base/controllers/base.controller.ts) at path `@Post('pages/resolve')`. Guarded by the same `JwtAuthGuard` + workspace check the rest of `/bases/*` uses. +- **Service:** new file `apps/server/src/core/base/services/base-page-resolver.service.ts` with `resolvePagesForBase(pageIds, workspaceId, userId)`. Keeps the coupling to `PageRepo` + `PagePermissionRepo` isolated to this one file. +- **Module:** wire the new service into [base.module.ts](../../../apps/server/src/core/base/base.module.ts). `PageRepo` + `PagePermissionRepo` are already shared modules. + +## Client: cell component & resolver + +### Batch resolver hook + +New file `apps/client/src/features/base/queries/base-page-resolver-query.ts`: + +```ts +export function useResolvedPages(pageIds: string[]): Map +``` + +- Deduplicate + sort IDs to form a stable React Query key. +- Fetch `POST /bases/pages/resolve` with `{ pageIds }`. +- Return a `Map` keyed by every requested ID — `null` for any ID absent from the server response. +- `staleTime: 30_000`, `gcTime: 5 * 60_000`. +- Realtime invalidation: listen for existing page-level websocket events (rename, delete) and invalidate the query when a touched ID intersects our key. Exact event names to be surveyed during plan writing. + +### Cell component + +New file `apps/client/src/features/base/components/cells/cell-page.tsx`: + +```ts +type CellPageProps = { + value: unknown; + property: IBaseProperty; + rowId: string; + isEditing: boolean; + onCommit: (value: unknown) => void; + onCancel: () => void; +}; +``` + +**Behavior:** +- Parse value: accept `string` only (ignore arrays — they'd be from a future multi mode that we drop until upgraded). +- `useResolvedPages([value])` — yes even for single lookups; the hook dedupes internally so multiple cells sharing the same page ID hit one request. +- View mode: resolved → pill with icon+title, anchor to `buildPageUrl`. Unresolved → greyed "Page not found". +- Edit mode: popover picker (see UX overview). Search via existing `searchSuggestions`. + +Wire into [grid-cell.tsx](../../../apps/client/src/features/base/components/grid/grid-cell.tsx): + +```ts +const cellComponents = { + // ...existing... + page: CellPage, +}; +``` + +### Property type picker + +[property-type-picker.tsx](../../../apps/client/src/features/base/components/property/property-type-picker.tsx): append one entry (after `file`): + +```ts +{ type: "page", icon: IconFileDescription, labelKey: "Page" }, +``` + +### Filter editor + +[view-filter-config.tsx](../../../apps/client/src/features/base/components/views/view-filter-config.tsx): new branch for `page`: +- Operators: `isEmpty`, `isNotEmpty`, `any`, `none`. +- Value picker for `any`/`none`: reuses the same `searchSuggestions`-backed search dropdown from the cell picker — user picks one or more pages as filter operands. + +### Sort editor + +[view-sort-config.tsx](../../../apps/client/src/features/base/components/views/view-sort-config.tsx): exclude `page` from the list of sortable property types. + +## Testing + +### Server — unit + +- **Schema:** `validateCellValue('page', uuid)` passes; with garbage string / number → fails; with `null` → passes (null = empty). +- **Conversion:** + - `attemptCellConversion('page', 'text', uuid, { pageTitles: Map })` → resolved title. + - Same call with empty `pageTitles` → `""`. + - `page → number/date/select/…` → `{converted: true, value: null}`. + - `text → page` with any string input → `{converted: true, value: null}`. +- **Predicate:** for each operator (`isEmpty`, `isNotEmpty`, `eq`, `neq`, `any`, `none`), `pageCondition()` returns the expected Kysely expression shape. + +### Server — integration + +- **Resolver endpoint `POST /bases/pages/resolve`:** + - valid IDs in an accessible space → present in `items` + - deleted pages (trash) → absent + - pages in a space the user isn't a member of → absent + - pages in another workspace → absent + - empty array → 400 + - array length > 100 → 400 +- **Row CRUD:** create a property of type `page`, write a cell with a UUID, read back → round-trip shape is `string`. +- **View filter:** create a view config with `{ op: 'any', propertyId, value: [uuidA, uuidB] }`, hit row-list, verify only matching rows returned. + +### Client — unit (Vitest + React Testing Library) + +- `cell-page.test.tsx`: + - view mode with resolved page → renders pill with icon + title and an `` to the computed URL + - view mode with unresolved page (null in resolver map) → renders greyed "Page not found", no `` + - double-click opens picker + - Enter on highlighted result commits `pageId` + - Esc cancels + - Remove tag button commits `null` +- `base-page-resolver-query.test.ts`: + - dedupes IDs + - stable query key across re-renders with same set + - missing IDs render as `null` in the returned map + +### Manual QA checklist + +- Link a page in the same space. +- Link a page in another space → pill shows, picker shows muted space-name hint. +- Remove link → cell empties. +- Delete linked page (via trash) → cell flips to "Page not found" on next resolver refetch. +- Viewer loses space access → same "Page not found" fallback. +- Rename linked page → within ≤30s (staleTime) the pill reflects the new title; realtime event should also trigger refetch. +- Filter: `isEmpty`, `isNotEmpty`, `any` (multi-select), `none`. +- Conversion `page → text` populates cells with page titles. +- Conversion `text → page` wipes cells. + +## Rollout + +- **No DB migration.** All changes are code-only: new enum value, new cell-value validator entry, new engine kind branch, new endpoint. +- **No feature flag.** The type appears in the picker as soon as the build ships. Backwards-compatible since `'page'` is a new type identifier. +- Existing bases continue to work unchanged. + +## Risks & open questions + +- **30s staleTime.** Renames take up to 30s to propagate without realtime invalidation. The realtime hook should shrink this to near-zero in practice; verify in QA. If it feels slow, drop `staleTime` to `0` and rely solely on realtime + refetch-on-window-focus. +- **"Page not found" label.** i18n-friendly; run through the translation pipeline. Consider whether to differentiate deleted vs. restricted — current answer: no, one label covers both and matches Confluence's behavior. +- **Cross-space name exposure.** The picker surfaces the space name of pages the user can access cross-space. This is already exposed via the existing page-mention flow, so no new exposure, but flag in review. + +## Future extension (multiple pages per cell) + +When `allowMultiple` lands: + +1. Widen cell-value schema: `z.uuid()` → `z.union([z.uuid(), z.array(z.uuid())])`. Existing single-UUID cells continue to validate. +2. Add `allowMultiple` boolean to `pageTypeOptionsSchema` (default `false` for existing properties). +3. In [predicate.ts](../../../apps/server/src/core/base/engine/predicate.ts), branch `pageCondition` on `allowMultiple`: `true` → reuse `arrayOfIdsCondition`; `false` → keep the current text-based path. +4. Client cell normalizes on read (`Array.isArray(value) ? value : typeof value === 'string' ? [value] : []`), mirrors [cell-person.tsx:33](../../../apps/client/src/features/base/components/cells/cell-person.tsx). +5. No data writes required for existing cells. + +This spec leaves room for that change without locking the storage shape. diff --git a/.claude/superpowers/specs/2026-04-20-base-view-draft-design.md b/.claude/superpowers/specs/2026-04-20-base-view-draft-design.md new file mode 100644 index 000000000..017bda17e --- /dev/null +++ b/.claude/superpowers/specs/2026-04-20-base-view-draft-design.md @@ -0,0 +1,479 @@ +# Base View Draft (Local-First Filter & Sort) — Design Spec + +**Date:** 2026-04-20 +**Status:** Draft +**Feature area:** `apps/client/src/features/base` (client-only) + +## Goal + +Make filter and sort changes on a base view **local-first**: they apply instantly for the editing user, are scoped to their own browser/profile, and never touch the server baseline until the user explicitly clicks "Save for everyone". A banner at the top of the table surfaces the draft state and lets the user either promote the draft to the shared baseline or discard it. + +This removes the current Notion-unlike behavior where every filter/sort tweak is auto-persisted and immediately inflicted on every teammate viewing the same view. + +## Non-goals (v1) + +- **Column layout in draft mode.** Column visibility, order, and widths continue to flow through the existing debounced `persistViewConfig` path in [use-base-table.ts:371-396](../../../apps/client/src/features/base/hooks/use-base-table.ts). No draft behavior for them. (Listed as a future extension.) +- **Server-side per-user drafts.** localStorage only. A user clearing their browser storage, switching devices, or using a different browser profile loses drafts — by design. +- **"Save as new view".** The screenshot hints at a dropdown caret next to the Save button for a "save as new view" split-action. Not in v1. +- **Kanban / calendar.** Only the `table` view type exists today; spec scopes to it but the hook is type-agnostic and will apply trivially when other view types land. +- **Automatic garbage collection of stale drafts.** Drafts persist indefinitely until the user resets or saves. No TTL, no eager cleanup when baseline values match the draft. +- **Conflict UI.** If another user writes a new baseline while I have local drafts, my draft silently wins on my client. No "baseline changed" warning. + +## UX overview + +### Draft banner + +Placement: **between** the page title and [BaseToolbar](../../../apps/client/src/features/base/components/base-toolbar.tsx), inside [base-table.tsx](../../../apps/client/src/features/base/components/base-table.tsx) above the `` node (around [base-table.tsx:192](../../../apps/client/src/features/base/components/base-table.tsx)). The banner is part of the table's own layout, not a workspace-level chrome element, because it's tied to a specific view. + +Render condition: `isDirty === true` (see "Dirty check"). + +Layout (match the reference screenshot): + +- Mantine `` with a soft background (`bg="yellow.0"` or `bg="orange.0"` depending on theme palette — pick whichever tolerates dark mode) and a small info icon on the left. +- Left region: short message — `t("Filter and sort changes are visible only to you.")`. +- Right region (a ``): + - `` — underline-on-hover "text link" feel; wipes the draft. + - `` — primary accent (project's default theme color — orange in the screenshot maps to Mantine's configured `primaryColor`, so `color` is omitted and the theme default is used). +- The "Save for everyone" button is **omitted entirely** for users without edit permission (see "Permission gating"). "Reset" always shows. +- The banner never animates in/out on every keystroke — it only appears/disappears when `isDirty` flips. Add a Mantine `` wrap if the flip is jarring; otherwise mount unconditionally with a `{isDirty && ...}` guard. + +### Filter/sort editors in draft mode + +No UI affordance changes inside the filter or sort popovers themselves. They keep the same open-on-click, add/remove/edit flow. The only behavioral change is that their `onChange` callback writes to the draft store rather than firing `updateView` — completely transparent to the editor components. + +### Reset behavior + +Click Reset → the draft hook removes its localStorage entry → the table re-renders reading filter/sorts from `activeView.config` (the server baseline). Any currently-open filter/sort popover closes on outside click as usual; if it's open when the user clicks Reset, the next render shows the baseline values. No notification — the banner disappearing is sufficient feedback. + +### Save for everyone + +Click Save → call the existing `useUpdateViewMutation` from [base-view-query.ts:43-112](../../../apps/client/src/features/base/queries/base-view-query.ts) with `{ viewId, baseId, config: { ...serverBaseline, filter: draft.filter, sorts: draft.sorts } }`. On success, clear the localStorage key and show a Mantine notification `t("View updated for everyone")`. On error, keep the draft; the mutation already wires the error toast. + +### Permission gating + +A user can edit this base iff their space membership grants `SpaceCaslAction.Edit, SpaceCaslSubject.Base` — the same check the server enforces in [base-view.controller.ts:68](../../../apps/server/src/core/base/controllers/base-view.controller.ts). Viewers still get local drafts (the entire point is that local changes don't require edit permission), but their "Save for everyone" button is hidden. + +**Client caveat:** [permissions.type.ts](../../../apps/client/src/features/space/permissions/permissions.type.ts) currently only exports `Settings`, `Member`, and `Page` subjects. The server enum has `Base` but the client enum doesn't. The spec adds `Base = "base"` to `SpaceCaslSubject` and widens the `SpaceAbility` union — that's a one-line change plus import fix. + +## Data model + +### localStorage key + +``` +docmost:base-view-draft:v1:{userId}:{baseId}:{viewId} +``` + +- Namespace prefix `docmost:base-view-draft:` keeps us from colliding with other consumers. +- `v1` is the schema version so a future breaking change can shed old entries by skipping. +- `{userId}` scopes drafts so a shared-device login-swap doesn't leak drafts across accounts. `userId` comes from the existing `useCurrentUser()` hook (returns `{ data: ICurrentUser }` — read `user?.user.id`), the same helper used by other authenticated client code. +- `{baseId}` and `{viewId}` together uniquely identify which table state the draft applies to. + +### Value shape + +```ts +// apps/client/src/features/base/types/base.types.ts (additive) +export type BaseViewDraft = { + filter?: FilterGroup; + sorts?: ViewSortConfig[]; + updatedAt: string; // ISO timestamp, written on each put — used only for diagnostics +}; +``` + +Both `filter` and `sorts` are optional, independently. An absent field means "inherit baseline for that axis". That matters because a user who's only dirtied sorts but not filters should see the baseline filter unchanged if the baseline's filter later shifts. + +Serialized as JSON by Jotai's `atomWithStorage` (which JSON-stringifies on write and parses on read). No schema validation on read — if the parse fails or the shape looks wrong, Jotai yields `null` and the hook falls back to baseline. + +## Client architecture + +### Storage atom family + +**File:** `apps/client/src/features/base/atoms/view-draft-atom.ts` + +Follow the existing Jotai storage pattern in [home-tab-atom.ts](../../../apps/client/src/features/home/atoms/home-tab-atom.ts) and [auth-tokens-atom.ts](../../../apps/client/src/features/auth/atoms/auth-tokens-atom.ts) — `atomWithStorage` is the codebase convention for localStorage-backed state. Since our key is dynamic per (user, base, view), pair it with `atomFamily` from `jotai/utils`: + +```ts +import { atomFamily, atomWithStorage } from "jotai/utils"; +import { BaseViewDraft } from "@/features/base/types/base.types"; + +export type ViewDraftKey = { + userId: string; + baseId: string; + viewId: string; +}; + +const keyFor = (k: ViewDraftKey) => + `docmost:base-view-draft:v1:${k.userId}:${k.baseId}:${k.viewId}`; + +export const viewDraftAtomFamily = atomFamily( + (k: ViewDraftKey) => + atomWithStorage(keyFor(k), null), + (a, b) => + a.userId === b.userId && a.baseId === b.baseId && a.viewId === b.viewId, +); +``` + +`atomWithStorage` handles JSON serialization, cross-tab sync via the `storage` event, and SSR-safe lazy reads out of the box — no hand-rolled `localStorage.getItem/setItem` or `window.addEventListener("storage", ...)` needed. The comparator passed as `atomFamily`'s second argument ensures the same (user, base, view) triple always resolves to the same atom instance, so React Query-style object identity issues don't cause atoms to be recreated per render. + +### Hook: `useViewDraft` + +**File:** `apps/client/src/features/base/hooks/use-view-draft.ts` + +Thin wrapper that binds the atom family to the rendering layer, adds the passthrough-when-undefined guard, and derives `effectiveFilter` / `effectiveSorts` / `isDirty` / `buildPromotedConfig` from the atom's value: + +```ts +export type ViewDraftState = { + draft: BaseViewDraft | null; + effectiveFilter: FilterGroup | undefined; + effectiveSorts: ViewSortConfig[] | undefined; + isDirty: boolean; + setFilter: (filter: FilterGroup | undefined) => void; + setSorts: (sorts: ViewSortConfig[] | undefined) => void; + reset: () => void; + buildPromotedConfig: (baseline: ViewConfig) => ViewConfig; +}; + +export function useViewDraft(args: { + userId: string | undefined; + baseId: string | undefined; + viewId: string | undefined; + baselineFilter: FilterGroup | undefined; + baselineSorts: ViewSortConfig[] | undefined; +}): ViewDraftState; +``` + +**Behavior:** + +1. If any of `userId / baseId / viewId` is undefined → return a passthrough state (`draft=null`, `isDirty=false`, setters no-op, `effective*` fall through to baseline). Guards the initial-load window where auth / activeView hasn't resolved yet. +2. Otherwise, `useAtom(viewDraftAtomFamily({ userId, baseId, viewId }))` gives `[draft, setDraft]`. Jotai reads from localStorage on first access and writes on every set. +3. `setFilter(next)` and `setSorts(next)` compute `merged = { ...(draft ?? {}), [axis]: next, updatedAt: new Date().toISOString() }`. If the result has both `filter` and `sorts` back to `undefined` (the user cleared all local divergence), call `setDraft(RESET)` instead of writing an empty object. (`RESET` is `jotai/utils`' sentinel — it removes the key from localStorage.) This keeps "orphan" drafts from lingering. +4. `reset()` is `setDraft(RESET)`. +5. `isDirty` is `draft !== null && (!shallowEqualFilter(draft.filter, baselineFilter) || !shallowEqualSorts(draft.sorts, baselineSorts))`. Note the per-axis `??` fallback doesn't appear here because `null/undefined` is the "no local divergence" signal for that axis; only a defined-and-different value counts as dirty. +6. `buildPromotedConfig(baseline)` returns `{ ...baseline, filter: draft?.filter ?? baseline.filter, sorts: draft?.sorts ?? baseline.sorts }`. Preserves all non-draft config fields (widths, order, visibility) and only overwrites the two axes that may have diverged. + +**Return composition:** + +- `effectiveFilter = draft?.filter ?? baselineFilter` +- `effectiveSorts = draft?.sorts ?? baselineSorts` + +**Cross-tab sync is free.** `atomWithStorage` subscribes to the `storage` event internally — a filter change in tab A triggers a re-render in tab B with no extra code. No manual listener required. + +### Integration into `useBaseTable` and `base-table.tsx` + +`useBaseTable` at [use-base-table.ts:224](../../../apps/client/src/features/base/hooks/use-base-table.ts) currently derives the table's initial sort from `activeView.config.sorts`. In the new world the table's sort/filter state must come from the **effective** values (draft-or-baseline), not the raw `activeView.config`. + +Two cut options were considered: + +**Option A (chosen): drive from effective values via props.** `useBaseTable` takes an additional `effectiveConfig?: ViewConfig` parameter (or, cleaner, the caller passes a shallow-merged `activeView` whose `config` is `{ ...activeView.config, filter: effective.filter, sorts: effective.sorts }`). `buildSortingState` and the row query already read from `activeView.config`, so the cleanest shape is to mutate the config the hook receives, not to introduce a new parameter. + +**Option B (rejected): thread draft deep into `useBaseTable`.** Adds the concept of drafts to a hook that only cares about the rendered state. Muddies responsibilities. + +Going with A. In [base-table.tsx](../../../apps/client/src/features/base/components/base-table.tsx): + +```ts +// NEW: wire the draft hook +const { data: user } = useCurrentUser(); +const { draft, effectiveFilter, effectiveSorts, isDirty, setFilter, setSorts, reset, buildPromotedConfig } = + useViewDraft({ + userId: user?.user.id, + baseId, + viewId: activeView?.id, + baselineFilter: activeView?.config?.filter, + baselineSorts: activeView?.config?.sorts, + }); + +// Swap the raw `activeView` for a view with effective config so the table and row query see drafts. +const effectiveView = useMemo( + () => + activeView + ? { ...activeView, config: { ...activeView.config, filter: effectiveFilter, sorts: effectiveSorts } } + : undefined, + [activeView, effectiveFilter, effectiveSorts], +); + +// Row query reads effective filter/sorts. +const { data: rowsData, ... } = useBaseRowsQuery( + base ? baseId : undefined, + effectiveFilter, + effectiveSorts, +); + +// Table is seeded from effectiveView for rendering, but the auto-persist +// write-path uses the real `activeView.config` as the baseline so draft +// filter/sort values can never leak into a column-layout save. +// See "Filter & sort write-path changes" below for the exact mechanism. +const { table, persistViewConfig } = useBaseTable(base, rows, effectiveView, { + baselineConfig: activeView?.config, +}); +``` + +The server-roundtrip `persistViewConfig` keeps being called for column layout changes. It reads from `baselineConfig` — never from the effective/draft state — so a pending layout write cannot bake draft filter/sort values into the server baseline. See the next subsection for the exact implementation. + +### Filter & sort write-path changes + +Today, filter/sort editors feed `BaseToolbar`'s handlers: + +- [base-toolbar.tsx:135-148](../../../apps/client/src/features/base/components/base-toolbar.tsx) `handleSortsChange` → builds config via `buildViewConfigFromTable(table, activeView.config, { sorts: newSorts })` → `updateViewMutation.mutate(...)`. +- [base-toolbar.tsx:150-169](../../../apps/client/src/features/base/components/base-toolbar.tsx) `handleFiltersChange` → same pattern with `{ filter }`. + +Both write directly to the server. That's the exact site to branch. + +**New `base-toolbar.tsx`:** accept two new callbacks from `base-table.tsx`: + +```ts +onDraftSortsChange: (sorts: ViewSortConfig[]) => void; +onDraftFiltersChange: (filter: FilterGroup | undefined) => void; +``` + +The toolbar drops its internal `updateViewMutation.mutate` calls for sort/filter (retains them for view tabs / view type flip if any exists elsewhere). `handleSortsChange` becomes: + +```ts +const handleSortsChange = useCallback( + (newSorts: ViewSortConfig[]) => { + onDraftSortsChange(newSorts); // writes to useViewDraft via base-table + }, + [onDraftSortsChange], +); +``` + +Same for filters — the FilterCondition[]→FilterGroup wrapping logic at [base-toolbar.tsx:152-157](../../../apps/client/src/features/base/components/base-toolbar.tsx) stays; only the final dispatch target changes. + +**`base-table.tsx`** wires those callbacks to the draft hook: + +```ts +const handleDraftSortsChange = useCallback( + (sorts: ViewSortConfig[]) => setSorts(sorts.length ? sorts : undefined), + [setSorts], +); +const handleDraftFiltersChange = useCallback( + (filter: FilterGroup | undefined) => setFilter(filter), + [setFilter], +); +``` + +The "normalize empty to undefined" rule is how we let the draft go clean after the user deletes every filter — the draft hook's "remove key if both axes are undefined" rule then kicks in. + +**Toolbar badge counts:** [base-toolbar.tsx:118-128](../../../apps/client/src/features/base/components/base-toolbar.tsx) currently derives `sorts` and `conditions` from `activeView.config`. Switch these to read from the **effective** config (`effectiveView.config`) so the toolbar badges reflect the draft's count, not the baseline. The toolbar already accepts `activeView` — pass it `effectiveView` instead, since everything the toolbar reads from `activeView` (name, sorts, filter) should be in the effective form. + +**The `buildViewConfigFromTable` call site in `handleColumnReorder` / `handleResizeEnd` / field-visibility:** these continue reading from `activeView.config` (the real baseline) and going through `updateViewMutation`. They do **not** read from the draft. This is deliberate — column layout stays auto-persisted. + +However: `buildViewConfigFromTable` currently spreads its `base` argument and emits `sorts` from the live table state. For the debounced `persistViewConfig` call at [use-base-table.ts:382](../../../apps/client/src/features/base/hooks/use-base-table.ts), the `base` arg is the effective config (because we pass `effectiveView` into `useBaseTable`), but the emitted `sorts` comes from the table's live state — which was seeded from effective. That means if the user drafts a sort and then reorders a column, the debounced persist would write `{ ...effectiveConfig, sorts: draftSorts }` back to the server. **Bug.** + +Fix: when building the config for the auto-persist path in `persistViewConfig`, override the emitted `sorts` and `filter` with the **baseline** values, not the effective ones. Concretely, change [use-base-table.ts:382](../../../apps/client/src/features/base/hooks/use-base-table.ts) to + +```ts +const config = buildViewConfigFromTable(table, activeView.config, { + sorts: activeView.config?.sorts, + filter: activeView.config?.filter, +}); +``` + +where `activeView` in that callsite is the **real** activeView (not the effective one). So `useBaseTable` needs both: the effective view for seeding and rendering, and the real baseline for the persist path. + +Simplest refactor: give `useBaseTable` an optional `baselineConfig?: ViewConfig` argument. If omitted (existing callers), behave as today. If provided, `persistViewConfig` uses `baselineConfig` for sort/filter overrides. `base-table.tsx` passes `activeView.config` as the baseline and the effective-wrapped view as the active. + +This keeps `useBaseTable`'s own responsibilities tidy and makes the "drafts don't leak into the layout write-path" rule explicit. + +**Note on `useBaseTable`'s re-seed effect:** A draft edit changes `effectiveView.config.filter/sorts`, which propagates through the `derivedColumnOrder` / `derivedColumnVisibility` memos and re-fires the sync effect at [use-base-table.ts:280](../../../apps/client/src/features/base/hooks/use-base-table.ts). This is harmless because (a) `activeView.id` is unchanged, so the full re-seed branch doesn't trigger, and (b) the `hasPendingEdit` branch preserves live column state when no layout mutation is pending, and adopts derived values otherwise — those derived values are still driven by the same `properties`, so they're content-equal. No action required, but worth naming so the implementer doesn't chase a non-issue. + +## Banner component + +**File:** `apps/client/src/features/base/components/base-view-draft-banner.tsx` + +```ts +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 ( + + + + + {t("Filter and sort changes are visible only to you.")} + + + + {canSave && ( + + )} + + + + ); +} +``` + +Wiring in [base-table.tsx](../../../apps/client/src/features/base/components/base-table.tsx), inserted between the existing page chrome and ``: + +```ts +const { data: space } = useSpaceQuery(base?.spaceId ?? ""); +const spaceAbility = useSpaceAbility(space?.membership?.permissions); +const canSave = spaceAbility.can(SpaceCaslAction.Edit, SpaceCaslSubject.Base); +const updateViewMutation = useUpdateViewMutation(); +const handleSaveDraft = useCallback(async () => { + if (!activeView || !base) return; + const config = buildPromotedConfig(activeView.config); + await updateViewMutation.mutateAsync({ viewId: activeView.id, baseId: base.id, config }); + reset(); + notifications.show({ message: t("View updated for everyone") }); +}, [activeView, base, buildPromotedConfig, reset, updateViewMutation, t]); + +return ( +
+ + + +
+); +``` + +The `useSpaceQuery`/`useSpaceAbility` pair follows the same pattern as [use-history-restore.tsx:35-41](../../../apps/client/src/features/page-history/hooks/use-history-restore.tsx). + +## Cross-tab sync + +Inherited from `atomWithStorage`. Its internal subscription to the `storage` event re-notifies any Jotai-connected component on other tabs when the matching localStorage key changes, triggering a re-render with the new draft value. No hand-rolled listener in `useViewDraft`. + +React Query's row cache is keyed by `(baseId, filter, sorts, search)` — when the updated draft flows through `effectiveFilter` / `effectiveSorts` on the other tab, the row query refetches as a fresh infinite query via the normal path. + +Edge case: two tabs editing simultaneously — both writes land in localStorage, last-write-wins (same-user scope, acceptable). + +## Save flow (pseudocode) + +```ts +async function onSaveForEveryone() { + if (!activeView || !base) return; + // 1. Compose the promoted config from the server baseline + draft values. + // baseline is activeView.config (NOT effectiveView.config) because the + // baseline might include layout fields (propertyWidths, propertyOrder, + // hiddenPropertyIds, visiblePropertyIds) that we must preserve verbatim. + const config: ViewConfig = { + ...activeView.config, + filter: draft.filter ?? activeView.config.filter, + sorts: draft.sorts ?? activeView.config.sorts, + }; + // 2. Fire the existing mutation. `updateViewMutation` already: + // - optimistically updates the ["bases", baseId] query cache + // - rolls back on error + // - writes the server response back on success + await updateViewMutation.mutateAsync({ viewId: activeView.id, baseId: base.id, config }); + // 3. Clear the draft. Because the baseline has now caught up to what the + // draft said, isDirty flips to false and the banner unmounts. + reset(); + notifications.show({ message: t("View updated for everyone") }); +} +``` + +Error handling: `useUpdateViewMutation` already shows a red toast and rolls back the optimistic cache update on failure. We do *not* call `reset()` in that case — the draft stays, the banner stays, the user can retry. + +## Dirty check + +`isDirty` lives inside `useViewDraft`. Returns `true` iff the draft file exists AND at least one of these is true: + +- `draft.filter !== undefined` AND `!deepEqualFilter(draft.filter, baselineFilter)` +- `draft.sorts !== undefined` AND `!deepEqualSorts(draft.sorts, baselineSorts)` + +**Deep equality:** the codebase has no `lodash` or `fast-deep-equal` in [client package.json](../../../apps/client/package.json). Options: + +1. **`JSON.stringify` both sides and compare strings.** Trivially correct for `FilterGroup` (a pure data tree) and `ViewSortConfig[]`. Key ordering inside objects is deterministic in V8+ for non-numeric keys, which is the case here. Pick this — it's 4 lines and good enough for this shape. +2. Hand-written structural compare — overkill for two types with known finite shapes. + +Go with option 1. Helpers live in `use-view-draft.ts`: + +```ts +function filterEq(a: FilterGroup | undefined, b: FilterGroup | undefined) { + return JSON.stringify(a ?? null) === JSON.stringify(b ?? null); +} +function sortsEq(a: ViewSortConfig[] | undefined, b: ViewSortConfig[] | undefined) { + return JSON.stringify(a ?? null) === JSON.stringify(b ?? null); +} +``` + +**Orphan suppression.** The agreed rule: when the draft's values equal the baseline, the banner hides. The dirty check above already does that — a draft with `filter: X` where baseline is also `X` yields `filterEq === true` for that axis, and if the sorts axis is also equal (or absent), `isDirty === false`. The key stays in localStorage (no eager GC), but the banner is invisible until the user next diverges or another tab updates the baseline. + +## Testing + +Per [CLAUDE.md](../../../CLAUDE.md), the client has no test infrastructure (no `vitest` in the workspace). This spec does not block on adding one. Testing is primarily manual QA + optional unit tests if Vitest is introduced alongside this feature. + +### Unit tests (proposed, Vitest — gated on harness being added) + +`use-view-draft.test.ts`: + +- **Initialize with no stored value.** Hook returns `draft=null`, `isDirty=false`, effective values fall through to baseline. +- **`setFilter` writes to localStorage and updates state.** After `setFilter(X)`, `localStorage.getItem(key)` parses back to `{ filter: X, updatedAt: ... }`, `draft.filter === X`, `isDirty === true`. +- **`setSorts` writes independently.** `draft.filter` stays undefined even after `setSorts(...)`, and vice versa. +- **`setFilter(undefined)` then `setSorts(undefined)` removes the key.** After both axes are cleared, `localStorage.getItem(key)` is null. +- **`reset` clears both state and storage.** +- **Draft values equal to baseline → `isDirty === false` without clearing storage.** Set baseline to `B`, set draft filter to `B`, assert `isDirty === false` and `localStorage.getItem(key)` is still non-null (no eager GC). +- **Baseline change while draft exists.** Baseline shifts from `B1` to `B2`, draft filter is `X`. Effective filter stays `X`, `isDirty` stays `true`. Then baseline shifts again to `X` — `isDirty` flips to `false` without draft being cleared. +- **Cross-tab propagation (integration-level, not strictly a unit test).** `atomWithStorage` handles the `storage` event internally; the only thing our hook contributes is the derivation of `effectiveFilter` / `effectiveSorts` / `isDirty` from the atom value. A single assertion that writing to the atom value in one `Provider` context reflects in another suffices. +- **Malformed storage value.** Seed localStorage with garbage under the computed key → `atomWithStorage` yields `null`, hook reports `draft=null`, `isDirty=false`, table receives baseline. +- **`userId` missing → passthrough.** All setters are no-ops, `isDirty=false`, effective = baseline. + +### Manual QA checklist + +**Single user, single tab.** +- Apply a filter. Banner appears. Row list updates locally. +- Click Reset. Banner disappears. Filter in the popover reverts to baseline. Row list reverts. +- Apply a filter and a sort. Click Save for everyone. Banner disappears. Refresh the page — the filter/sort is now the new baseline (i.e. came back from the server). +- Apply a filter, then manually delete it via the filter popover. Banner disappears. Subsequent refresh does not restore the deleted filter (baseline untouched). + +**Single user, multiple tabs.** +- Open base in tab A and tab B. In tab A, add a sort. Tab B re-renders with the same sort applied (verified by checking the sort popover badge and the row order). Tab B shows the banner. +- In tab B, click Reset. Tab A's banner disappears and sort reverts. + +**Multi-user baseline race.** +- User X (editor) opens base. Applies a filter (draft). User Y (editor) in another session saves a brand-new baseline via their own Save flow. User X's client receives the websocket `base:schema:bumped` → `["bases", baseId]` invalidates → `activeView.config` updates. User X's `effectiveFilter` still shows X's draft filter (draft wins). Banner stays. No UI prompt. If X now clicks Reset, they see Y's new baseline. + +**Permission gating.** +- As a space Viewer (who has Read but not Edit on `Base`): open base, apply a filter. Banner appears but shows only "Reset" — no "Save for everyone" button. +- Server check: attempting Save as a viewer would have been blocked by [base-view.controller.ts:68](../../../apps/server/src/core/base/controllers/base-view.controller.ts) anyway; the UI gate is belt-and-suspenders. + +**Reset with popover open.** +- Open the filter popover and add conditions. Without closing the popover, click Reset (the banner is visible behind the popover dropdown — it's positioned above). Popover closes on outside-click, baseline conditions show next open. + +**Save clears draft + updates server.** +- Save. Banner vanishes. localStorage key for `{user,base,view}` is absent. Re-open the base in an incognito/second-account browser — the filter/sort shows too (from the server). + +**Browser storage cleared.** +- In DevTools, wipe `localStorage`. Base re-renders with baseline. Banner gone. Expected. + +## Rollout + +- **No DB migration.** No server change. +- **No feature flag.** Behavior change ships as-is. +- **No data migration.** Existing users have no drafts; the system starts empty. +- **Behavioral change vs. today.** Existing users' muscle memory is "touch a filter → auto-saves for everyone". After this ships, that becomes "touch a filter → only I see it until I hit Save for everyone". This is the entire point of the feature but will surprise power users on day one. + - Mitigation: none in v1. A one-time popover/tooltip pointing at the banner ("New: filter and sort changes are now a draft until you save") is worth doing, but falls squarely in YAGNI territory for the first ship. + - **Followup:** consider a dismissible one-time in-product hint the first time a user diverges from baseline after the deploy. Flag this as a follow-up task; do not ship with v1. + +## Risks & open questions + +- **localStorage quota.** `FilterGroup` + `ViewSortConfig[]` is tiny — a realistic draft is under 2KB. A worst-case malicious user with thousands of views could hit the 5–10MB per-origin cap, but practically negligible. No cleanup logic needed. +- **Users losing drafts via browser data clear.** Expected. The banner is a live indicator, not a durable source of truth. Flagged in non-goals. +- **Multi-device divergence.** Same user on laptop and phone: drafts don't sync. Expected and flagged. +- **Dropdown caret ("Save as new view") in the screenshot.** Explicitly out of scope for v1. If we add it, the caret menu would include: + 1. "Save for everyone" (current behavior) + 2. "Save as new view" (creates a new `IBaseView` with draft values baked into `config`) +- **Baseline layout fields overriding draft.** Save flow does `{ ...activeView.config, filter: X, sorts: Y }`. If another user changed column widths right before Save, those widths land in the Save's payload (we already read the latest optimistic cache). Acceptable — the alternative (send a sparse patch with only `{filter, sorts}`) would require a server-side partial-update endpoint we don't have. +- **Invalid draft for stale schema.** If a property is deleted while a user's draft references it by id, the predicate/sort engine on the server silently drops unknown property ids. Client-side, the sort/filter popover shows the condition with a missing-property label (existing behavior — the toolbar already does `properties.find((p) => p.id === …)` and tolerates the `undefined` case). No special handling needed here; the draft just falls away when the user next edits and doesn't re-add the dead condition. +- **`SpaceCaslSubject.Base` missing from client enum.** Single-line fix at [permissions.type.ts:12](../../../apps/client/src/features/space/permissions/permissions.type.ts). Flagged so reviewers notice. + +## Future extension + +1. **Draft column layout.** Extend the draft shape to carry `propertyWidths`, `propertyOrder`, `hiddenPropertyIds`, `visiblePropertyIds`. Column reorder / hide / resize call the draft hook instead of `persistViewConfig`. `useBaseTable` then seeds column state from effective values. Mechanically identical to filter/sort — the hook already takes arbitrary ViewConfig fragments. The only reason this isn't in v1 is to minimize behavioral change surface and keep the spec scope narrow. +2. **Server-side per-user drafts.** For cross-device sync, add a `base_view_drafts` table keyed by `(userId, viewId)` storing the same shape. The client hook swaps localStorage for a paired mutation + query. The banner UX stays identical. +3. **Split-button save.** Dropdown caret next to "Save for everyone" offering "Save as new view" — creates an `IBaseView` via `createView` with the effective config. Deepens the Notion parallel. +4. **Draft conflict hint.** When baseline changes while I have drafts, show a subtle "Baseline has changed since your last edit" line inside the banner with a "Discard draft and load latest" affordance. Expected to be low value in practice — flag once real users report it. 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/de-DE/translation.json b/apps/client/public/locales/de-DE/translation.json index 0368ad429..3b72cb8dd 100644 --- a/apps/client/public/locales/de-DE/translation.json +++ b/apps/client/public/locales/de-DE/translation.json @@ -1084,5 +1084,23 @@ "Added {{name}} to favorites": "{{name}} zu Favoriten hinzugefügt", "Removed {{name}} from favorites": "{{name}} aus Favoriten entfernt", "Page menu for {{name}}": "Seitenmenü für {{name}}", - "Create subpage of {{name}}": "Unterseite von {{name}} erstellen" + "Create subpage of {{name}}": "Unterseite von {{name}} erstellen", + "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." } 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/public/locales/es-ES/translation.json b/apps/client/public/locales/es-ES/translation.json index 131977738..4deec9629 100644 --- a/apps/client/public/locales/es-ES/translation.json +++ b/apps/client/public/locales/es-ES/translation.json @@ -1084,5 +1084,23 @@ "Added {{name}} to favorites": "Se agregó {{name}} a favoritos", "Removed {{name}} from favorites": "Se quitó {{name}} de favoritos", "Page menu for {{name}}": "Menú de página para {{name}}", - "Create subpage of {{name}}": "Crear subpágina de {{name}}" + "Create subpage of {{name}}": "Crear subpágina de {{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." } diff --git a/apps/client/public/locales/fr-FR/translation.json b/apps/client/public/locales/fr-FR/translation.json index 20f354421..6b543e0e4 100644 --- a/apps/client/public/locales/fr-FR/translation.json +++ b/apps/client/public/locales/fr-FR/translation.json @@ -1084,5 +1084,23 @@ "Added {{name}} to favorites": "{{name}} a été ajouté aux favoris", "Removed {{name}} from favorites": "{{name}} a été retiré des favoris", "Page menu for {{name}}": "Menu de la page pour {{name}}", - "Create subpage of {{name}}": "Créer une sous-page de {{name}}" + "Create subpage of {{name}}": "Créer une sous-page de {{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." } diff --git a/apps/client/public/locales/it-IT/translation.json b/apps/client/public/locales/it-IT/translation.json index 441c90f69..9407bf1c1 100644 --- a/apps/client/public/locales/it-IT/translation.json +++ b/apps/client/public/locales/it-IT/translation.json @@ -1084,5 +1084,23 @@ "Added {{name}} to favorites": "{{name}} aggiunto ai preferiti", "Removed {{name}} from favorites": "{{name}} rimosso dai preferiti", "Page menu for {{name}}": "Menu della pagina per {{name}}", - "Create subpage of {{name}}": "Crea sottopagina di {{name}}" + "Create subpage of {{name}}": "Crea sottopagina di {{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." } diff --git a/apps/client/public/locales/ja-JP/translation.json b/apps/client/public/locales/ja-JP/translation.json index 492868cf2..0e7cccbd3 100644 --- a/apps/client/public/locales/ja-JP/translation.json +++ b/apps/client/public/locales/ja-JP/translation.json @@ -1084,5 +1084,23 @@ "Added {{name}} to favorites": "{{name}} をお気に入りに追加しました", "Removed {{name}} from favorites": "{{name}} をお気に入りから削除しました", "Page menu for {{name}}": "{{name}} のページメニュー", - "Create subpage of {{name}}": "{{name}} のサブページを作成" + "Create subpage of {{name}}": "{{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." } diff --git a/apps/client/public/locales/ko-KR/translation.json b/apps/client/public/locales/ko-KR/translation.json index 5847ab19a..ac0000e34 100644 --- a/apps/client/public/locales/ko-KR/translation.json +++ b/apps/client/public/locales/ko-KR/translation.json @@ -1084,5 +1084,23 @@ "Added {{name}} to favorites": "{{name}} 즐겨찾기에 추가됨", "Removed {{name}} from favorites": "{{name}} 즐겨찾기에서 제거됨", "Page menu for {{name}}": "{{name}}의 페이지 메뉴", - "Create subpage of {{name}}": "{{name}}의 하위 페이지 만들기" + "Create subpage of {{name}}": "{{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." } diff --git a/apps/client/public/locales/nl-NL/translation.json b/apps/client/public/locales/nl-NL/translation.json index 591c96746..810c95a81 100644 --- a/apps/client/public/locales/nl-NL/translation.json +++ b/apps/client/public/locales/nl-NL/translation.json @@ -1084,5 +1084,23 @@ "Added {{name}} to favorites": "{{name}} toegevoegd aan favorieten", "Removed {{name}} from favorites": "{{name}} verwijderd uit favorieten", "Page menu for {{name}}": "Paginamenu voor {{name}}", - "Create subpage of {{name}}": "Subpagina van {{name}} maken" + "Create subpage of {{name}}": "Subpagina van {{name}} maken", + "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." } diff --git a/apps/client/public/locales/pt-BR/translation.json b/apps/client/public/locales/pt-BR/translation.json index e2788f1fe..79137d237 100644 --- a/apps/client/public/locales/pt-BR/translation.json +++ b/apps/client/public/locales/pt-BR/translation.json @@ -1084,5 +1084,23 @@ "Added {{name}} to favorites": "{{name}} adicionado aos favoritos", "Removed {{name}} from favorites": "{{name}} removido dos favoritos", "Page menu for {{name}}": "Menu da página de {{name}}", - "Create subpage of {{name}}": "Criar subpágina de {{name}}" + "Create subpage of {{name}}": "Criar subpágina de {{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." } diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index 3d93ec67d..c15e27086 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -1084,5 +1084,23 @@ "Added {{name}} to favorites": "{{name}} добавлено в избранное", "Removed {{name}} from favorites": "{{name}} удалено из избранного", "Page menu for {{name}}": "Меню страницы для {{name}}", - "Create subpage of {{name}}": "Создать подстраницу для {{name}}" + "Create subpage of {{name}}": "Создать подстраницу для {{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." } diff --git a/apps/client/public/locales/uk-UA/translation.json b/apps/client/public/locales/uk-UA/translation.json index 6144df469..59a3a8022 100644 --- a/apps/client/public/locales/uk-UA/translation.json +++ b/apps/client/public/locales/uk-UA/translation.json @@ -1084,5 +1084,23 @@ "Added {{name}} to favorites": "{{name}} додано до обраного", "Removed {{name}} from favorites": "{{name}} видалено з обраного", "Page menu for {{name}}": "Меню сторінки для {{name}}", - "Create subpage of {{name}}": "Створити підсторінку для {{name}}" + "Create subpage of {{name}}": "Створити підсторінку для {{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." } diff --git a/apps/client/public/locales/zh-CN/translation.json b/apps/client/public/locales/zh-CN/translation.json index 40cd5c1ba..b5f5c8b0b 100644 --- a/apps/client/public/locales/zh-CN/translation.json +++ b/apps/client/public/locales/zh-CN/translation.json @@ -1084,5 +1084,23 @@ "Added {{name}} to favorites": "已将 {{name}} 添加到收藏", "Removed {{name}} from favorites": "已将 {{name}} 从收藏中移除", "Page menu for {{name}}": "{{name}} 的页面菜单", - "Create subpage of {{name}}": "创建 {{name}} 的子页面" + "Create subpage of {{name}}": "创建 {{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." } 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..935b6862e --- /dev/null +++ b/apps/client/src/ee/base/components/base-toolbar.tsx @@ -0,0 +1,251 @@ +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, +} 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 { 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; + // Effective view (baseline merged with local draft). Badge counts and popover + // seed data read from this; the real baseline only enters via the draft callbacks. + activeView: IBaseView | undefined; + views: IBaseView[]; + table: Table; + onViewChange: (viewId: string) => void; + onAddView?: () => void; + 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, + 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 [exporting, setExporting] = useState(false); + + 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 ?? []; + // The stored filter is a tree; the popover edits an AND-only flat list. + // Unwrap the top-level group's children when reading, rewrap on save. + 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(() => { + 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[]) => { + // Normalize empty to undefined so the draft hook drops the sorts axis. + onDraftSortsChange(newSorts.length > 0 ? newSorts : undefined); + }, + [onDraftSortsChange], + ); + + const handleFiltersChange = useCallback( + (newConditions: FilterCondition[]) => { + // Wrap the AND-flat list into the engine's FilterGroup shape; undefined drops the axis. + 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} + + )} + + + + + setSortOpened(false)} + sorts={sorts} + properties={base.properties} + onChange={handleSortsChange} + > + + 0 ? "blue" : "gray"} + onClick={() => openToolbar("sort")} + > + + {sorts.length > 0 && ( + + {sorts.length} + + )} + + + + + 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..3a963cf1e --- /dev/null +++ b/apps/client/src/ee/base/components/base-view.tsx @@ -0,0 +1,508 @@ +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, + useBaseRowsCountQuery, + flattenRows, + useCreateRowMutation, + useUpdateRowMutation, + useReorderRowMutation, +} from "@/ee/base/queries/base-row-query"; +import { + useCreateViewMutation, + 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"; + +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 { + data: rowsData, + isLoading: rowsLoading, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useBaseRowsQuery(base ? pageId : undefined, activeFilter, activeSorts); + + // Warm the count cache alongside the rows query. Gate on currentUser so + // useViewDraft has hydrated from localStorage before the count fires. + const canFetchCount = !!base && !!currentUser; + useBaseRowsCountQuery(canFetchCount ? pageId : undefined, activeFilter); + + const updateRowMutation = useUpdateRowMutation(); + const createRowMutation = useCreateRowMutation(); + const reorderRowMutation = useReorderRowMutation(); + const createViewMutation = useCreateViewMutation(); + 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 handleAddView = useCallback(() => { + if (!editable) return; + createViewMutation.mutate({ + pageId, + name: t("New view"), + type: "table", + }); + }, [editable, pageId, createViewMutation, t]); + + 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(); + // 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 || 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 = ( + + ); + + if (embedded) { + // 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 ( + + + + {banner} + {toolbar} + + + } + /> + + + + ); + } + + // Standalone: title, banner, and toolbar go in aboveBand inside the scroll + // container so they scroll away; only the column-header row stays pinned. + return ( + +
+
+ + + {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..9d0f93225 --- /dev/null +++ b/apps/client/src/ee/base/components/cells/cell-created-at.tsx @@ -0,0 +1,34 @@ +import { IBaseProperty } from "@/ee/base/types/base.types"; +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; +}; + +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/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..325701db8 --- /dev/null +++ b/apps/client/src/ee/base/components/cells/cell-last-edited-at.tsx @@ -0,0 +1,34 @@ +import { IBaseProperty } from "@/ee/base/types/base.types"; +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; +}; + +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/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..b2c421f37 --- /dev/null +++ b/apps/client/src/ee/base/components/cells/cell-long-text.tsx @@ -0,0 +1,144 @@ +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 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 = toText(value).replace(/\s+/g, " ").trim(); + + 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 && ( + <> + + + +