15 KiB
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())])+ anallowMultipletype option later, with zero data migration (see "Future extension" below). - Sorting by page title. Would require a JOIN against
pagesin 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 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 prioritizesspaceIdresults. - 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 tobuildPageUrl(space.slug, slugId, title)using the helper that 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: exclude
pageproperties from the sortable set. - view-filter-config.tsx: filter editor branch for
pagewith operatorsisEmpty,isNotEmpty,any,none. The value picker reuses the same search dropdown from the cell picker.
Data model
Cell value
- Stored shape:
string(page UUID) ornull. Parallelspersonin single mode. - Example:
{ "01998b7e-...": "01998b80-..." }— property UUID → page UUID.
Property type options
- v1: empty
{}(reuseemptyTypeOptionsSchema). - Future:
{ allowMultiple?: boolean }.
Schema additions
Server — base.schemas.ts:
export const BasePropertyType = {
// ...existing entries...
PAGE: 'page',
} as const;
// typeOptionsSchemaMap
[BasePropertyType.PAGE]: emptyTypeOptionsSchema,
// cellValueSchemaMap
[BasePropertyType.PAGE]: z.uuid(),
Client — base.types.ts:
export type BasePropertyType = ... | 'page';
export type PageTypeOptions = Record<string, never>;
Property kind & engine
export const PropertyKind = {
// ...existing...
PAGE: 'page',
} as const;
// propertyKind()
case BasePropertyType.PAGE:
return PropertyKind.PAGE;
engine/predicate.ts: new pageCondition() handler — shape follows selectCondition() (single UUID stored as text):
isEmpty/isNotEmpty→textCellis null or emptyeq/neq→ text equality / inequality (null-safe forneq)any→textCell IN (...)none→textCell NOT IN (...)or null
Wired into the switch (kind) in buildCondition:
case PropertyKind.PAGE:
return pageCondition(eb, cond);
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: add a new field:
export type CellConversionContext = {
fromTypeOptions?: unknown;
userNames?: Map<string, string>;
attachmentNames?: Map<string, string>;
pageTitles?: Map<string, string>; // NEW
};
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: resolvectx.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:
{ pageIds: string[] } // 1 <= length <= 100, enforced server-side; 400 on violation
Response:
{
items: Array<{
id: string;
slugId: string;
title: string | null;
icon: string | null;
spaceId: string;
space: { id: string; slug: string; name: string };
}>;
}
Behavior
- Deduplicate input IDs.
- Select from
pageswhereid IN (...)ANDdeletedAt IS NULLANDworkspaceId = current. - Filter the result set through
pagePermissionRepo.filterAccessiblePageIds({ pageIds, userId })— same mechanism used by search.service.ts:131-139. - Join
spacesto includespace.slugandspace.namefor navigation. - Silently omit any ID the user can't see (deleted, restricted, cross-workspace). The client treats any requested ID missing from
itemsas "Page not found".
Code layout
- Controller: add method to base.controller.ts at path
@Post('pages/resolve'). Guarded by the sameJwtAuthGuard+ workspace check the rest of/bases/*uses. - Service: new file
apps/server/src/core/base/services/base-page-resolver.service.tswithresolvePagesForBase(pageIds, workspaceId, userId). Keeps the coupling toPageRepo+PagePermissionRepoisolated to this one file. - Module: wire the new service into base.module.ts.
PageRepo+PagePermissionRepoare already shared modules.
Client: cell component & resolver
Batch resolver hook
New file apps/client/src/features/base/queries/base-page-resolver-query.ts:
export function useResolvedPages(pageIds: string[]): Map<string, ResolvedPage | null>
- Deduplicate + sort IDs to form a stable React Query key.
- Fetch
POST /bases/pages/resolvewith{ pageIds }. - Return a
Mapkeyed by every requested ID —nullfor 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:
type CellPageProps = {
value: unknown;
property: IBaseProperty;
rowId: string;
isEditing: boolean;
onCommit: (value: unknown) => void;
onCancel: () => void;
};
Behavior:
- Parse value: accept
stringonly (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:
const cellComponents = {
// ...existing...
page: CellPage,
};
Property type picker
property-type-picker.tsx: append one entry (after file):
{ type: "page", icon: IconFileDescription, labelKey: "Page" },
Filter editor
view-filter-config.tsx: new branch for page:
- Operators:
isEmpty,isNotEmpty,any,none. - Value picker for
any/none: reuses the samesearchSuggestions-backed search dropdown from the cell picker — user picks one or more pages as filter operands.
Sort editor
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; withnull→ passes (null = empty). - Conversion:
attemptCellConversion('page', 'text', uuid, { pageTitles: Map<uuid,title> })→ resolved title.- Same call with empty
pageTitles→"". page → number/date/select/…→{converted: true, value: null}.text → pagewith 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
- valid IDs in an accessible space → present in
- Row CRUD: create a property of type
page, write a cell with a UUID, read back → round-trip shape isstring. - 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
<a>to the computed URL - view mode with unresolved page (null in resolver map) → renders greyed "Page not found", no
<a> - double-click opens picker
- Enter on highlighted result commits
pageId - Esc cancels
- Remove tag button commits
null
- view mode with resolved page → renders pill with icon + title and an
base-page-resolver-query.test.ts:- dedupes IDs
- stable query key across re-renders with same set
- missing IDs render as
nullin 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 → textpopulates cells with page titles. - Conversion
text → pagewipes 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
staleTimeto0and 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:
- Widen cell-value schema:
z.uuid()→z.union([z.uuid(), z.array(z.uuid())]). Existing single-UUID cells continue to validate. - Add
allowMultipleboolean topageTypeOptionsSchema(defaultfalsefor existing properties). - In predicate.ts, branch
pageConditiononallowMultiple:true→ reusearrayOfIdsCondition;false→ keep the current text-based path. - Client cell normalizes on read (
Array.isArray(value) ? value : typeof value === 'string' ? [value] : []), mirrors cell-person.tsx:33. - No data writes required for existing cells.
This spec leaves room for that change without locking the storage shape.