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 00000000..4f9ced97 --- /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.