mirror of
https://github.com/docmost/docmost.git
synced 2026-05-10 00:13:36 +08:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf75cc9c74 | |||
| 5c8ce178e5 | |||
| cfb02766e2 | |||
| ae595c51ed | |||
| 6c44354403 | |||
| 184fa25d3e | |||
| 6740912adf | |||
| f524243da1 | |||
| d5093da863 | |||
| 196afc21d4 | |||
| 9b5e3783dd | |||
| 4ae941c5c4 | |||
| 8bfa0aaf7e | |||
| 58a47893a6 | |||
| 2d91817602 | |||
| 9ecf88511b | |||
| 30988c1959 |
File diff suppressed because it is too large
Load Diff
@@ -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<string, never>;
|
||||
```
|
||||
|
||||
### 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<string, string>;
|
||||
attachmentNames?: Map<string, string>;
|
||||
pageTitles?: Map<string, string>; // 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<string, ResolvedPage | null>
|
||||
```
|
||||
|
||||
- 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<uuid,title> })` → 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 `<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`
|
||||
- `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.
|
||||
@@ -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 `<BaseToolbar />` 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 `<Paper withBorder radius="sm" px="md" py="xs">` 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 `<Group gap="sm">`):
|
||||
- `<Button variant="subtle" color="gray" size="xs">{t("Reset")}</Button>` — underline-on-hover "text link" feel; wipes the draft.
|
||||
- `<Button variant="filled" size="xs">{t("Save for everyone")}</Button>` — 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 `<Transition mounted={isDirty} transition="slide-down" duration={120}>` 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<BaseViewDraft | null>(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 (
|
||||
<Paper withBorder radius="sm" px="md" py="xs" /* soft bg per theme */>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<IconInfoCircle size={16} />
|
||||
<Text size="sm">{t("Filter and sort changes are visible only to you.")}</Text>
|
||||
</Group>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Button variant="subtle" color="gray" size="xs" onClick={onReset}>{t("Reset")}</Button>
|
||||
{canSave && (
|
||||
<Button size="xs" onClick={onSave} loading={saving}>{t("Save for everyone")}</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Wiring in [base-table.tsx](../../../apps/client/src/features/base/components/base-table.tsx), inserted between the existing page chrome and `<BaseToolbar />`:
|
||||
|
||||
```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 (
|
||||
<div style={{...}}>
|
||||
<BaseViewDraftBanner
|
||||
isDirty={isDirty}
|
||||
canSave={canSave}
|
||||
onReset={reset}
|
||||
onSave={handleSaveDraft}
|
||||
saving={updateViewMutation.isPending}
|
||||
/>
|
||||
<BaseToolbar ... />
|
||||
<GridContainer ... />
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,22 @@
|
||||
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<BaseViewDraft | null>(viewDraftStorageKey(k), null),
|
||||
(a, b) =>
|
||||
a.userId === b.userId && a.baseId === b.baseId && a.viewId === b.viewId,
|
||||
);
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { Text, Stack } from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useAtom } from "jotai";
|
||||
import { IconDatabase } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -7,6 +8,10 @@ import { arrayMove } from "@dnd-kit/sortable";
|
||||
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
|
||||
import { useBaseQuery } from "@/features/base/queries/base-query";
|
||||
import { useBaseSocket } from "@/features/base/hooks/use-base-socket";
|
||||
import {
|
||||
FilterGroup,
|
||||
ViewSortConfig,
|
||||
} from "@/features/base/types/base.types";
|
||||
import {
|
||||
useBaseRowsQuery,
|
||||
flattenRows,
|
||||
@@ -14,12 +19,24 @@ import {
|
||||
import { useUpdateRowMutation } from "@/features/base/queries/base-row-query";
|
||||
import { useCreateRowMutation } from "@/features/base/queries/base-row-query";
|
||||
import { useReorderRowMutation } from "@/features/base/queries/base-row-query";
|
||||
import { useCreateViewMutation } from "@/features/base/queries/base-view-query";
|
||||
import {
|
||||
useCreateViewMutation,
|
||||
useUpdateViewMutation,
|
||||
} from "@/features/base/queries/base-view-query";
|
||||
import { activeViewIdAtom } from "@/features/base/atoms/base-atoms";
|
||||
import { useBaseTable } from "@/features/base/hooks/use-base-table";
|
||||
import { useRowSelection } from "@/features/base/hooks/use-row-selection";
|
||||
import useCurrentUser from "@/features/user/hooks/use-current-user";
|
||||
import { useViewDraft } from "@/features/base/hooks/use-view-draft";
|
||||
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 { GridContainer } from "@/features/base/components/grid/grid-container";
|
||||
import { BaseToolbar } from "@/features/base/components/base-toolbar";
|
||||
import { BaseViewDraftBanner } from "@/features/base/components/base-view-draft-banner";
|
||||
import { BaseTableSkeleton } from "@/features/base/components/base-table-skeleton";
|
||||
import classes from "@/features/base/styles/grid.module.css";
|
||||
|
||||
@@ -42,8 +59,59 @@ export function BaseTable({ baseId }: BaseTableProps) {
|
||||
return views.find((v) => v.id === activeViewId) ?? views[0];
|
||||
}, [views, activeViewId]);
|
||||
|
||||
const activeFilter = activeView?.config?.filter;
|
||||
const activeSorts = activeView?.config?.sorts;
|
||||
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],
|
||||
);
|
||||
|
||||
// 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;
|
||||
|
||||
// `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,
|
||||
);
|
||||
|
||||
// Hold the rows query until `base` has loaded. Otherwise the query
|
||||
// fires once with `activeFilter` / `activeSorts` still undefined
|
||||
// (a "bland" list request), then fires a second time as soon as the
|
||||
@@ -56,6 +124,7 @@ export function BaseTable({ baseId }: BaseTableProps) {
|
||||
const createRowMutation = useCreateRowMutation();
|
||||
const reorderRowMutation = useReorderRowMutation();
|
||||
const createViewMutation = useCreateViewMutation();
|
||||
const updateViewMutation = useUpdateViewMutation();
|
||||
|
||||
useEffect(() => {
|
||||
if (activeView && activeViewId !== activeView.id) {
|
||||
@@ -85,7 +154,9 @@ export function BaseTable({ baseId }: BaseTableProps) {
|
||||
);
|
||||
}, [rowsData, activeSorts]);
|
||||
|
||||
const { table, persistViewConfig } = useBaseTable(base, rows, activeView);
|
||||
const { table, persistViewConfig } = useBaseTable(base, rows, effectiveView, {
|
||||
baselineConfig: activeView?.config,
|
||||
});
|
||||
|
||||
const handleCellUpdate = useCallback(
|
||||
(rowId: string, propertyId: string, value: unknown) => {
|
||||
@@ -135,6 +206,48 @@ export function BaseTable({ baseId }: BaseTableProps) {
|
||||
persistViewConfig();
|
||||
}, [persistViewConfig]);
|
||||
|
||||
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;
|
||||
// `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,
|
||||
]);
|
||||
|
||||
const handleRowReorder = useCallback(
|
||||
(rowId: string, targetRowId: string, dropPosition: "above" | "below") => {
|
||||
const remainingRows = rows.filter((r) => r.id !== rowId);
|
||||
@@ -189,14 +302,23 @@ export function BaseTable({ baseId }: BaseTableProps) {
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<BaseViewDraftBanner
|
||||
isDirty={isDirty}
|
||||
canSave={canSave}
|
||||
onReset={resetDraft}
|
||||
onSave={handleSaveDraft}
|
||||
saving={updateViewMutation.isPending}
|
||||
/>
|
||||
<BaseToolbar
|
||||
base={base}
|
||||
activeView={activeView}
|
||||
activeView={effectiveView}
|
||||
views={views}
|
||||
table={table}
|
||||
onViewChange={handleViewChange}
|
||||
onAddView={handleAddView}
|
||||
onPersistViewConfig={persistViewConfig}
|
||||
onDraftSortsChange={handleDraftSortsChange}
|
||||
onDraftFiltersChange={handleDraftFiltersChange}
|
||||
/>
|
||||
<GridContainer
|
||||
table={table}
|
||||
|
||||
@@ -16,8 +16,6 @@ import {
|
||||
FilterCondition,
|
||||
FilterGroup,
|
||||
} from "@/features/base/types/base.types";
|
||||
import { useUpdateViewMutation } from "@/features/base/queries/base-view-query";
|
||||
import { buildViewConfigFromTable } from "@/features/base/hooks/use-base-table";
|
||||
import { exportBaseToCsv } from "@/features/base/services/base-service";
|
||||
import { ViewTabs } from "@/features/base/components/views/view-tabs";
|
||||
import { ViewSortConfigPopover } from "@/features/base/components/views/view-sort-config";
|
||||
@@ -28,12 +26,18 @@ import classes from "@/features/base/styles/grid.module.css";
|
||||
|
||||
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<IBaseRow>;
|
||||
onViewChange: (viewId: string) => void;
|
||||
onAddView?: () => void;
|
||||
onPersistViewConfig: () => void;
|
||||
onDraftSortsChange: (sorts: ViewSortConfig[] | undefined) => void;
|
||||
onDraftFiltersChange: (filter: FilterGroup | undefined) => void;
|
||||
};
|
||||
|
||||
export function BaseToolbar({
|
||||
@@ -44,6 +48,8 @@ export function BaseToolbar({
|
||||
onViewChange,
|
||||
onAddView,
|
||||
onPersistViewConfig,
|
||||
onDraftSortsChange,
|
||||
onDraftFiltersChange,
|
||||
}: BaseToolbarProps) {
|
||||
const { t } = useTranslation();
|
||||
const [sortOpened, setSortOpened] = useState(false);
|
||||
@@ -113,8 +119,6 @@ export function BaseToolbar({
|
||||
setFieldsOpened(panel === "fields" ? (v) => !v : false);
|
||||
}, []);
|
||||
|
||||
const updateViewMutation = useUpdateViewMutation();
|
||||
|
||||
const sorts = activeView?.config?.sorts ?? [];
|
||||
// Stored view config uses the engine's filter tree. The popover edits
|
||||
// an AND-only flat list; we unwrap the top-level group's children when
|
||||
@@ -134,38 +138,24 @@ export function BaseToolbar({
|
||||
|
||||
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,
|
||||
});
|
||||
// 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);
|
||||
},
|
||||
[activeView, base.id, table, updateViewMutation],
|
||||
[onDraftSortsChange],
|
||||
);
|
||||
|
||||
const handleFiltersChange = useCallback(
|
||||
(newConditions: FilterCondition[]) => {
|
||||
if (!activeView) return;
|
||||
// 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;
|
||||
// `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,
|
||||
});
|
||||
onDraftFiltersChange(filter);
|
||||
},
|
||||
[activeView, base.id, table, updateViewMutation],
|
||||
[onDraftFiltersChange],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -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 (
|
||||
<Group justify="flex-end" gap="xs" px="md" py={6} wrap="nowrap">
|
||||
<Button variant="subtle" color="gray" size="xs" onClick={onReset}>
|
||||
{t("Reset")}
|
||||
</Button>
|
||||
{canSave && (
|
||||
<Tooltip
|
||||
label={t("Filter and sort changes are visible only to you")}
|
||||
position="bottom"
|
||||
withArrow
|
||||
>
|
||||
<Button
|
||||
variant="light"
|
||||
color="orange"
|
||||
size="xs"
|
||||
onClick={onSave}
|
||||
loading={saving}
|
||||
>
|
||||
{t("Save for everyone")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
||||
import { Popover, ActionIcon, Text } from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { IconX, IconFileDescription } from "@tabler/icons-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import clsx from "clsx";
|
||||
import { IBaseProperty } from "@/features/base/types/base.types";
|
||||
import { useResolvedPages } from "@/features/base/queries/base-page-resolver-query";
|
||||
import { useBaseQuery } from "@/features/base/queries/base-query";
|
||||
import { searchSuggestions } from "@/features/search/services/search-service";
|
||||
import { buildPageUrl } from "@/features/page/page.utils";
|
||||
import { useListKeyboardNav } from "@/features/base/hooks/use-list-keyboard-nav";
|
||||
import cellClasses from "@/features/base/styles/cells.module.css";
|
||||
|
||||
type CellPageProps = {
|
||||
value: unknown;
|
||||
property: IBaseProperty;
|
||||
rowId: string;
|
||||
isEditing: boolean;
|
||||
onCommit: (value: unknown) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
type PageSuggestion = {
|
||||
id: string;
|
||||
slugId: string;
|
||||
title: string | null;
|
||||
icon: string | null;
|
||||
spaceId: string;
|
||||
space?: { id: string; slug: string; name: string } | null;
|
||||
};
|
||||
|
||||
function parsePageId(value: unknown): string | null {
|
||||
if (typeof value === "string" && value.length > 0) return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function CellPage({
|
||||
value,
|
||||
property,
|
||||
isEditing,
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: CellPageProps) {
|
||||
const pageId = parsePageId(value);
|
||||
const { data: base } = useBaseQuery(property.baseId);
|
||||
|
||||
const ids = useMemo(() => (pageId ? [pageId] : []), [pageId]);
|
||||
const { pages } = useResolvedPages(ids);
|
||||
const resolvedPage = pageId ? pages.get(pageId) : undefined;
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<PagePicker
|
||||
pageId={pageId}
|
||||
resolvedPage={resolvedPage ?? null}
|
||||
spaceId={base?.spaceId}
|
||||
onCommit={onCommit}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!pageId) {
|
||||
return <span className={cellClasses.emptyValue} />;
|
||||
}
|
||||
|
||||
if (resolvedPage === undefined) {
|
||||
// Still resolving — render an empty pill-shaped placeholder to avoid
|
||||
// the "Page not found" flicker on initial load.
|
||||
return <span className={cellClasses.emptyValue} />;
|
||||
}
|
||||
|
||||
if (resolvedPage === null) {
|
||||
return (
|
||||
<span className={cellClasses.pageMissing}>
|
||||
<IconFileDescription size={14} />
|
||||
<span>Page not found</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <PagePill page={resolvedPage} />;
|
||||
}
|
||||
|
||||
type PillPage = {
|
||||
slugId: string;
|
||||
title: string | null;
|
||||
icon: string | null;
|
||||
space: { slug: string } | null;
|
||||
};
|
||||
|
||||
function PagePill({ page }: { page: PillPage }) {
|
||||
const title = page.title || "Untitled";
|
||||
const spaceSlug = page.space?.slug ?? "";
|
||||
const url = buildPageUrl(spaceSlug, page.slugId, title);
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={url}
|
||||
className={cellClasses.pagePill}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{page.icon ? (
|
||||
<span className={cellClasses.pagePillIcon}>{page.icon}</span>
|
||||
) : (
|
||||
<IconFileDescription size={14} className={cellClasses.pagePillIconFallback} />
|
||||
)}
|
||||
<span className={cellClasses.pagePillText}>{title}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
type PagePickerProps = {
|
||||
pageId: string | null;
|
||||
resolvedPage: { id: string; slugId: string; title: string | null; icon: string | null; space: { id: string; slug: string; name: string } | null } | null;
|
||||
spaceId?: string;
|
||||
onCommit: (value: unknown) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
function PagePicker({
|
||||
pageId,
|
||||
resolvedPage,
|
||||
spaceId,
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: PagePickerProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch] = useDebouncedValue(search, 250);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => searchRef.current?.focus());
|
||||
}, []);
|
||||
|
||||
const trimmed = debouncedSearch.trim();
|
||||
const { data: suggestions = [] } = useQuery({
|
||||
queryKey: ["bases", "pages", "search", trimmed, spaceId ?? ""],
|
||||
queryFn: async () => {
|
||||
const res = await searchSuggestions({
|
||||
query: trimmed,
|
||||
includePages: true,
|
||||
spaceId,
|
||||
limit: trimmed ? 25 : 5,
|
||||
});
|
||||
return (res.pages ?? []) as PageSuggestion[];
|
||||
},
|
||||
staleTime: 15_000,
|
||||
});
|
||||
|
||||
const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } =
|
||||
useListKeyboardNav(suggestions.length, [debouncedSearch]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
onCommit(id === pageId ? null : id);
|
||||
},
|
||||
[pageId, onCommit],
|
||||
);
|
||||
|
||||
const handleRemove = useCallback(() => {
|
||||
onCommit(null);
|
||||
}, [onCommit]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
if (handleNavKey(e)) return;
|
||||
if (e.key === "Enter") {
|
||||
if (activeIndex < 0 || activeIndex >= suggestions.length) return;
|
||||
e.preventDefault();
|
||||
handleSelect(suggestions[activeIndex].id);
|
||||
}
|
||||
},
|
||||
[onCancel, handleNavKey, activeIndex, suggestions, handleSelect],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover opened onClose={onCancel} position="bottom-start" width={320} trapFocus>
|
||||
<Popover.Target>
|
||||
<div style={{ width: "100%", height: "100%" }}>
|
||||
{resolvedPage ? <PagePill page={resolvedPage} /> : <span className={cellClasses.emptyValue} />}
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p={0}>
|
||||
<div className={cellClasses.personTagArea}>
|
||||
{pageId && resolvedPage && (
|
||||
<span className={cellClasses.personTag}>
|
||||
{resolvedPage.icon ? (
|
||||
<span>{resolvedPage.icon}</span>
|
||||
) : (
|
||||
<IconFileDescription size={14} />
|
||||
)}
|
||||
<span className={cellClasses.personTagName}>
|
||||
{resolvedPage.title || "Untitled"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={cellClasses.personTagRemove}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemove();
|
||||
}}
|
||||
>
|
||||
<IconX size={10} />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
ref={searchRef}
|
||||
className={cellClasses.personTagInput}
|
||||
placeholder={pageId ? "" : "Search for a page..."}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={cellClasses.personDropdownDivider} />
|
||||
<div className={cellClasses.selectDropdown}>
|
||||
{suggestions.length === 0 && (
|
||||
<div className={cellClasses.personDropdownHint}>
|
||||
{trimmed ? "No pages found" : "No pages yet"}
|
||||
</div>
|
||||
)}
|
||||
{suggestions.map((page, idx) => {
|
||||
const isSelected = page.id === pageId;
|
||||
return (
|
||||
<div
|
||||
key={page.id}
|
||||
ref={setOptionRef(idx)}
|
||||
className={clsx(
|
||||
cellClasses.selectOption,
|
||||
isSelected && cellClasses.selectOptionActive,
|
||||
idx === activeIndex && cellClasses.selectOptionKeyboardActive,
|
||||
)}
|
||||
onMouseEnter={() => setActiveIndex(idx)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => handleSelect(page.id)}
|
||||
>
|
||||
{page.icon ? (
|
||||
<span>{page.icon}</span>
|
||||
) : (
|
||||
<IconFileDescription size={14} />
|
||||
)}
|
||||
<span className={cellClasses.personOptionName}>
|
||||
{page.title || "Untitled"}
|
||||
</span>
|
||||
{page.space?.name && (
|
||||
<Text size="xs" c="dimmed" ml="auto" truncate>
|
||||
{page.space.name}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { CellUrl } from "@/features/base/components/cells/cell-url";
|
||||
import { CellEmail } from "@/features/base/components/cells/cell-email";
|
||||
import { CellPerson } from "@/features/base/components/cells/cell-person";
|
||||
import { CellFile } from "@/features/base/components/cells/cell-file";
|
||||
import { CellPage } from "@/features/base/components/cells/cell-page";
|
||||
import { CellCreatedAt } from "@/features/base/components/cells/cell-created-at";
|
||||
import { CellLastEditedAt } from "@/features/base/components/cells/cell-last-edited-at";
|
||||
import { CellLastEditedBy } from "@/features/base/components/cells/cell-last-edited-by";
|
||||
@@ -45,6 +46,7 @@ const cellComponents: Record<
|
||||
email: CellEmail,
|
||||
person: CellPerson,
|
||||
file: CellFile,
|
||||
page: CellPage,
|
||||
createdAt: CellCreatedAt,
|
||||
lastEditedAt: CellLastEditedAt,
|
||||
lastEditedBy: CellLastEditedBy,
|
||||
|
||||
@@ -236,6 +236,7 @@ export function CreatePropertyPopover({ baseId, onPropertyCreated }: CreatePrope
|
||||
<TextInput
|
||||
ref={nameInputRef}
|
||||
size="xs"
|
||||
label={t("Name")}
|
||||
placeholder={selectedTypeLabel}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.currentTarget.value)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Stack, NumberInput, Select, Switch, Text } from "@mantine/core";
|
||||
import {
|
||||
IBaseProperty,
|
||||
@@ -88,7 +88,7 @@ function SelectOptions({
|
||||
hideButtons?: boolean;
|
||||
}) {
|
||||
const options = property.typeOptions as SelectTypeOptions | undefined;
|
||||
const choices = options?.choices ?? [];
|
||||
const choices = useMemo(() => options?.choices ?? [], [options?.choices]);
|
||||
|
||||
const handleSave = useCallback(
|
||||
(newChoices: Choice[]) => {
|
||||
@@ -127,7 +127,7 @@ function StatusOptions({
|
||||
hideButtons?: boolean;
|
||||
}) {
|
||||
const options = property.typeOptions as SelectTypeOptions | undefined;
|
||||
const choices = options?.choices ?? [];
|
||||
const choices = useMemo(() => options?.choices ?? [], [options?.choices]);
|
||||
|
||||
const handleSave = useCallback(
|
||||
(newChoices: Choice[]) => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
IconCalendar,
|
||||
IconUser,
|
||||
IconPaperclip,
|
||||
IconFileDescription,
|
||||
IconCheckbox,
|
||||
IconLink,
|
||||
IconMail,
|
||||
@@ -35,6 +36,7 @@ const propertyTypes: {
|
||||
{ type: "date", icon: IconCalendar, labelKey: "Date" },
|
||||
{ type: "person", icon: IconUser, labelKey: "Person" },
|
||||
{ type: "file", icon: IconPaperclip, labelKey: "File" },
|
||||
{ type: "page", icon: IconFileDescription, labelKey: "Page" },
|
||||
{ type: "checkbox", icon: IconCheckbox, labelKey: "Checkbox" },
|
||||
{ type: "url", icon: IconLink, labelKey: "URL" },
|
||||
{ type: "email", icon: IconMail, labelKey: "Email" },
|
||||
|
||||
@@ -65,6 +65,8 @@ function getOperatorsForType(type: string): FilterOperator[] {
|
||||
return ["eq", "neq", "any", "none", "isEmpty", "isNotEmpty"];
|
||||
case "file":
|
||||
return ["isEmpty", "isNotEmpty"];
|
||||
case "page":
|
||||
return ["isEmpty", "isNotEmpty"];
|
||||
default:
|
||||
return ["eq", "neq", "isEmpty", "isNotEmpty"];
|
||||
}
|
||||
|
||||
@@ -41,7 +41,11 @@ export function ViewSortConfigPopover({
|
||||
if (!opened) setDraft(null);
|
||||
}, [opened]);
|
||||
|
||||
const propertyOptions = properties.map((p) => ({
|
||||
// Page properties store a UUID; sorting by raw UUID is unhelpful and
|
||||
// title-based sort would require a join. Hide until we support it properly.
|
||||
const sortableProperties = properties.filter((p) => p.type !== "page");
|
||||
|
||||
const propertyOptions = sortableProperties.map((p) => ({
|
||||
value: p.id,
|
||||
label: p.name,
|
||||
}));
|
||||
@@ -53,10 +57,10 @@ export function ViewSortConfigPopover({
|
||||
|
||||
const handleStartDraft = useCallback(() => {
|
||||
const usedIds = new Set(sorts.map((s) => s.propertyId));
|
||||
const available = properties.find((p) => !usedIds.has(p.id));
|
||||
const available = sortableProperties.find((p) => !usedIds.has(p.id));
|
||||
if (!available) return;
|
||||
setDraft({ propertyId: available.id, direction: "asc" });
|
||||
}, [sorts, properties]);
|
||||
}, [sorts, sortableProperties]);
|
||||
|
||||
const handleSaveDraft = useCallback(() => {
|
||||
if (!draft) return;
|
||||
@@ -99,7 +103,8 @@ export function ViewSortConfigPopover({
|
||||
[sorts, onChange],
|
||||
);
|
||||
|
||||
const canAddMore = properties.length > sorts.length + (draft ? 1 : 0);
|
||||
const canAddMore =
|
||||
sortableProperties.length > sorts.length + (draft ? 1 : 0);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
|
||||
@@ -221,10 +221,21 @@ export type UseBaseTableResult = {
|
||||
persistViewConfig: () => void;
|
||||
};
|
||||
|
||||
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 {
|
||||
const updateViewMutation = useUpdateViewMutation();
|
||||
const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -379,7 +390,15 @@ export function useBaseTable(
|
||||
|
||||
persistTimerRef.current = setTimeout(() => {
|
||||
persistTimerRef.current = null;
|
||||
const config = buildViewConfigFromTable(table, activeView.config);
|
||||
// `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,
|
||||
});
|
||||
updateViewMutation.mutate(
|
||||
{ viewId: activeView.id, baseId: base.id, config },
|
||||
{
|
||||
@@ -393,7 +412,7 @@ export function useBaseTable(
|
||||
},
|
||||
);
|
||||
}, 300);
|
||||
}, [activeView, base, table, updateViewMutation]);
|
||||
}, [activeView, base, table, updateViewMutation, opts.baselineConfig]);
|
||||
|
||||
return { table, persistViewConfig };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import api from "@/lib/api-client";
|
||||
|
||||
export type ResolvedPage = {
|
||||
id: string;
|
||||
slugId: string;
|
||||
title: string | null;
|
||||
icon: string | null;
|
||||
spaceId: string;
|
||||
space: { id: string; slug: string; name: string } | null;
|
||||
};
|
||||
|
||||
async function resolvePages(pageIds: string[]): Promise<ResolvedPage[]> {
|
||||
if (pageIds.length === 0) return [];
|
||||
const res = await api.post<{ items: ResolvedPage[] }>(
|
||||
"/bases/pages/resolve",
|
||||
{ pageIds },
|
||||
);
|
||||
return res.data.items;
|
||||
}
|
||||
|
||||
// Stable, sorted, deduped list so the query key is consistent across renders
|
||||
// no matter what order the caller hands us the ids in.
|
||||
function normalize(ids: (string | null | undefined)[]): string[] {
|
||||
const set = new Set<string>();
|
||||
for (const id of ids) {
|
||||
if (typeof id === "string" && id.length > 0) set.add(id);
|
||||
}
|
||||
return Array.from(set).sort();
|
||||
}
|
||||
|
||||
export type PageResolution = {
|
||||
// Map distinguishes three states via lookup:
|
||||
// - key absent → id not requested
|
||||
// - value undefined → still resolving (query pending, or stale fetch in flight)
|
||||
// - value null → resolved and not accessible (deleted, restricted, cross-workspace)
|
||||
// - value ResolvedPage → resolved and accessible
|
||||
pages: Map<string, ResolvedPage | null | undefined>;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
export function useResolvedPages(
|
||||
pageIds: (string | null | undefined)[],
|
||||
): PageResolution {
|
||||
const normalized = useMemo(() => normalize(pageIds), [pageIds]);
|
||||
|
||||
const { data, isSuccess, isLoading } = useQuery({
|
||||
queryKey: ["bases", "pages", "resolve", normalized],
|
||||
queryFn: () => resolvePages(normalized),
|
||||
enabled: normalized.length > 0,
|
||||
staleTime: 30_000,
|
||||
gcTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const pages = useMemo(() => {
|
||||
const map = new Map<string, ResolvedPage | null | undefined>();
|
||||
// Seed with undefined (= "still resolving") until the fetch succeeds.
|
||||
for (const id of normalized) map.set(id, isSuccess ? null : undefined);
|
||||
for (const item of data ?? []) map.set(item.id, item);
|
||||
return map;
|
||||
}, [normalized, data, isSuccess]);
|
||||
|
||||
return { pages, isLoading };
|
||||
}
|
||||
@@ -295,3 +295,48 @@
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pagePill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-4));
|
||||
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
|
||||
text-decoration: none;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pagePill:hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||
}
|
||||
|
||||
.pagePillIcon {
|
||||
display: inline-flex;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pagePillIconFallback {
|
||||
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pagePillText {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pageMissing {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export type BasePropertyType =
|
||||
| 'date'
|
||||
| 'person'
|
||||
| 'file'
|
||||
| 'page'
|
||||
| 'checkbox'
|
||||
| 'url'
|
||||
| 'email'
|
||||
@@ -63,6 +64,8 @@ export type PersonTypeOptions = {
|
||||
allowMultiple?: boolean;
|
||||
};
|
||||
|
||||
export type PageTypeOptions = Record<string, never>;
|
||||
|
||||
export type TypeOptions =
|
||||
| SelectTypeOptions
|
||||
| NumberTypeOptions
|
||||
@@ -72,6 +75,7 @@ export type TypeOptions =
|
||||
| UrlTypeOptions
|
||||
| EmailTypeOptions
|
||||
| PersonTypeOptions
|
||||
| PageTypeOptions
|
||||
| Record<string, unknown>;
|
||||
|
||||
export type IBaseProperty = {
|
||||
@@ -295,3 +299,14 @@ export type UpdatePropertyResult = {
|
||||
// when the job finished migrating cells.
|
||||
jobId: string | null;
|
||||
};
|
||||
|
||||
// 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;
|
||||
};
|
||||
|
||||
@@ -9,9 +9,11 @@ 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.Page]
|
||||
| [SpaceCaslAction, SpaceCaslSubject.Base];
|
||||
|
||||
@@ -9,6 +9,7 @@ import { BasePropertyService } from './services/base-property.service';
|
||||
import { BaseRowService } from './services/base-row.service';
|
||||
import { BaseViewService } from './services/base-view.service';
|
||||
import { BaseCsvExportService } from './services/base-csv-export.service';
|
||||
import { BasePageResolverService } from './services/base-page-resolver.service';
|
||||
import { BaseQueueProcessor } from './processors/base-queue.processor';
|
||||
import { BaseWsService } from './realtime/base-ws.service';
|
||||
import { BaseWsConsumers } from './realtime/base-ws-consumers';
|
||||
@@ -29,6 +30,7 @@ import { QueueName } from '../../integrations/queue/constants';
|
||||
BaseRowService,
|
||||
BaseViewService,
|
||||
BaseCsvExportService,
|
||||
BasePageResolverService,
|
||||
BaseQueueProcessor,
|
||||
BasePresenceService,
|
||||
BaseWsService,
|
||||
|
||||
@@ -9,6 +9,7 @@ export const BasePropertyType = {
|
||||
DATE: 'date',
|
||||
PERSON: 'person',
|
||||
FILE: 'file',
|
||||
PAGE: 'page',
|
||||
CHECKBOX: 'checkbox',
|
||||
URL: 'url',
|
||||
EMAIL: 'email',
|
||||
@@ -114,6 +115,7 @@ const typeOptionsSchemaMap: Record<BasePropertyTypeValue, z.ZodType> = {
|
||||
[BasePropertyType.DATE]: dateTypeOptionsSchema,
|
||||
[BasePropertyType.PERSON]: personTypeOptionsSchema,
|
||||
[BasePropertyType.FILE]: emptyTypeOptionsSchema,
|
||||
[BasePropertyType.PAGE]: emptyTypeOptionsSchema,
|
||||
[BasePropertyType.CHECKBOX]: checkboxTypeOptionsSchema,
|
||||
[BasePropertyType.URL]: urlTypeOptionsSchema,
|
||||
[BasePropertyType.EMAIL]: emailTypeOptionsSchema,
|
||||
@@ -159,6 +161,7 @@ const cellValueSchemaMap: Partial<Record<BasePropertyTypeValue, z.ZodType>> = {
|
||||
fileSize: z.number().optional(),
|
||||
filePath: z.string().optional(),
|
||||
})),
|
||||
[BasePropertyType.PAGE]: z.uuid(),
|
||||
[BasePropertyType.CHECKBOX]: z.boolean(),
|
||||
[BasePropertyType.URL]: z.url(),
|
||||
[BasePropertyType.EMAIL]: z.email(),
|
||||
@@ -192,6 +195,7 @@ export type CellConversionContext = {
|
||||
fromTypeOptions?: unknown;
|
||||
userNames?: Map<string, string>;
|
||||
attachmentNames?: Map<string, string>;
|
||||
pageTitles?: Map<string, string>;
|
||||
};
|
||||
|
||||
function resolveChoiceName(
|
||||
@@ -256,6 +260,16 @@ export function attemptCellConversion(
|
||||
.filter((v): v is string => typeof v === 'string' && v.length > 0);
|
||||
return { converted: true, value: parts.join(', ') };
|
||||
}
|
||||
if (fromType === BasePropertyType.PAGE && typeof value === 'string') {
|
||||
const title = ctx.pageTitles?.get(value);
|
||||
return { converted: true, value: title ?? '' };
|
||||
}
|
||||
}
|
||||
|
||||
// Page cells only accept a page UUID. Free text / other IDs can't be
|
||||
// coerced into a valid page reference — drop to null.
|
||||
if (toType === BasePropertyType.PAGE && fromType !== BasePropertyType.PAGE) {
|
||||
return { converted: true, value: null };
|
||||
}
|
||||
|
||||
const targetSchema = cellValueSchemaMap[toType];
|
||||
|
||||
@@ -12,11 +12,13 @@ import {
|
||||
import { FastifyReply } from 'fastify';
|
||||
import { BaseService } from '../services/base.service';
|
||||
import { BaseCsvExportService } from '../services/base-csv-export.service';
|
||||
import { BasePageResolverService } from '../services/base-page-resolver.service';
|
||||
import { BaseRepo } from '@docmost/db/repos/base/base.repo';
|
||||
import { CreateBaseDto } from '../dto/create-base.dto';
|
||||
import { UpdateBaseDto } from '../dto/update-base.dto';
|
||||
import { BaseIdDto } from '../dto/base.dto';
|
||||
import { ExportBaseCsvDto } from '../dto/export-base.dto';
|
||||
import { ResolvePagesDto } from '../dto/resolve-pages.dto';
|
||||
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
|
||||
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
|
||||
@@ -35,6 +37,7 @@ export class BaseController {
|
||||
constructor(
|
||||
private readonly baseService: BaseService,
|
||||
private readonly baseCsvExportService: BaseCsvExportService,
|
||||
private readonly basePageResolverService: BasePageResolverService,
|
||||
private readonly baseRepo: BaseRepo,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
) {}
|
||||
@@ -138,4 +141,19 @@ export class BaseController {
|
||||
res,
|
||||
);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('pages/resolve')
|
||||
async resolvePages(
|
||||
@Body() dto: ResolvePagesDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const items = await this.basePageResolverService.resolvePages(
|
||||
dto.pageIds,
|
||||
workspace.id,
|
||||
user.id,
|
||||
);
|
||||
return { items };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ArrayMaxSize, ArrayMinSize, IsArray, IsUUID } from 'class-validator';
|
||||
|
||||
export class ResolvePagesDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(100)
|
||||
@IsUUID('all', { each: true })
|
||||
pageIds: string[];
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export const PropertyKind = {
|
||||
MULTI: 'multi',
|
||||
PERSON: 'person',
|
||||
FILE: 'file',
|
||||
PAGE: 'page',
|
||||
SYS_USER: 'sys_user',
|
||||
} as const;
|
||||
|
||||
@@ -37,6 +38,8 @@ export function propertyKind(type: string): PropertyKindValue | null {
|
||||
return PropertyKind.PERSON;
|
||||
case BasePropertyType.FILE:
|
||||
return PropertyKind.FILE;
|
||||
case BasePropertyType.PAGE:
|
||||
return PropertyKind.PAGE;
|
||||
case BasePropertyType.LAST_EDITED_BY:
|
||||
return PropertyKind.SYS_USER;
|
||||
default:
|
||||
|
||||
@@ -66,6 +66,8 @@ function buildCondition(
|
||||
return personCondition(eb, cond, prop);
|
||||
case PropertyKind.FILE:
|
||||
return arrayOfIdsCondition(eb, cond);
|
||||
case PropertyKind.PAGE:
|
||||
return pageCondition(eb, cond);
|
||||
default:
|
||||
return FALSE;
|
||||
}
|
||||
@@ -292,6 +294,48 @@ function personCondition(
|
||||
}
|
||||
}
|
||||
|
||||
function pageCondition(eb: Eb, cond: Condition): Expression<SqlBool> {
|
||||
// Page cells store a single page uuid as text. Shape matches selectCondition.
|
||||
const expr = textCell(cond.propertyId);
|
||||
const val = cond.value;
|
||||
switch (cond.op) {
|
||||
case 'isEmpty':
|
||||
return eb.or([
|
||||
eb(expr as any, 'is', null),
|
||||
eb(expr as any, '=', ''),
|
||||
]);
|
||||
case 'isNotEmpty':
|
||||
return eb.and([
|
||||
eb(expr as any, 'is not', null),
|
||||
eb(expr as any, '!=', ''),
|
||||
]);
|
||||
case 'eq':
|
||||
return val == null ? FALSE : eb(expr as any, '=', String(val));
|
||||
case 'neq':
|
||||
return val == null
|
||||
? FALSE
|
||||
: eb.or([
|
||||
eb(expr as any, 'is', null),
|
||||
eb(expr as any, '!=', String(val)),
|
||||
]);
|
||||
case 'any': {
|
||||
const arr = asStringArray(val);
|
||||
if (arr.length === 0) return FALSE;
|
||||
return eb(expr as any, 'in', arr);
|
||||
}
|
||||
case 'none': {
|
||||
const arr = asStringArray(val);
|
||||
if (arr.length === 0) return TRUE;
|
||||
return eb.or([
|
||||
eb(expr as any, 'is', null),
|
||||
eb(expr as any, 'not in', arr),
|
||||
]);
|
||||
}
|
||||
default:
|
||||
return FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
function arrayOfIdsCondition(eb: Eb, cond: Condition): Expression<SqlBool> {
|
||||
const expr = arrayCell(cond.propertyId);
|
||||
const val = cond.value;
|
||||
|
||||
@@ -87,4 +87,16 @@ describe('serializeCellForCsv', () => {
|
||||
expect(serializeCellForCsv(prop, 'u2', { userNames })).toBe('Bob');
|
||||
expect(serializeCellForCsv(prop, 'missing', { userNames })).toBe('');
|
||||
});
|
||||
|
||||
it('page resolves via pageTitles', () => {
|
||||
const pageTitles = new Map([
|
||||
['p1', 'Launch plan'],
|
||||
['p2', 'Retro notes'],
|
||||
]);
|
||||
const prop = p(BasePropertyType.PAGE);
|
||||
expect(serializeCellForCsv(prop, 'p1', { pageTitles })).toBe('Launch plan');
|
||||
expect(serializeCellForCsv(prop, 'missing', { pageTitles })).toBe('');
|
||||
expect(serializeCellForCsv(prop, 'p1', {})).toBe('');
|
||||
expect(serializeCellForCsv(prop, 123, { pageTitles })).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BasePropertyType, BasePropertyTypeValue } from '../base.schemas';
|
||||
|
||||
export type CellCsvContext = {
|
||||
userNames?: Map<string, string>;
|
||||
pageTitles?: Map<string, string>;
|
||||
};
|
||||
|
||||
type PropertyLike = {
|
||||
@@ -81,6 +82,10 @@ export function serializeCellForCsv(
|
||||
case BasePropertyType.LAST_EDITED_BY:
|
||||
return resolveUser(value, ctx);
|
||||
|
||||
case BasePropertyType.PAGE:
|
||||
if (typeof value !== 'string') return '';
|
||||
return ctx.pageTitles?.get(value) ?? '';
|
||||
|
||||
default:
|
||||
return typeof value === 'object' ? JSON.stringify(value) : String(value);
|
||||
}
|
||||
|
||||
@@ -138,40 +138,68 @@ export class BaseCsvExportService {
|
||||
chunk: Array<{ cells: unknown; lastUpdatedById: string | null }>,
|
||||
properties: Array<{ id: string; type: string }>,
|
||||
): Promise<CellCsvContext> {
|
||||
const ctx: CellCsvContext = {};
|
||||
|
||||
const needsUsers = properties.some(
|
||||
(p) =>
|
||||
p.type === BasePropertyType.PERSON ||
|
||||
p.type === BasePropertyType.LAST_EDITED_BY,
|
||||
);
|
||||
if (!needsUsers) return {};
|
||||
|
||||
const userIds = new Set<string>();
|
||||
const personPropIds = properties
|
||||
.filter((p) => p.type === BasePropertyType.PERSON)
|
||||
.map((p) => p.id);
|
||||
if (needsUsers) {
|
||||
const userIds = new Set<string>();
|
||||
const personPropIds = properties
|
||||
.filter((p) => p.type === BasePropertyType.PERSON)
|
||||
.map((p) => p.id);
|
||||
|
||||
for (const row of chunk) {
|
||||
if (row.lastUpdatedById) userIds.add(row.lastUpdatedById);
|
||||
const cells = (row.cells ?? {}) as Record<string, unknown>;
|
||||
for (const pid of personPropIds) {
|
||||
const v = cells[pid];
|
||||
if (typeof v === 'string') userIds.add(v);
|
||||
else if (Array.isArray(v)) {
|
||||
for (const id of v) if (typeof id === 'string') userIds.add(id);
|
||||
for (const row of chunk) {
|
||||
if (row.lastUpdatedById) userIds.add(row.lastUpdatedById);
|
||||
const cells = (row.cells ?? {}) as Record<string, unknown>;
|
||||
for (const pid of personPropIds) {
|
||||
const v = cells[pid];
|
||||
if (typeof v === 'string') userIds.add(v);
|
||||
else if (Array.isArray(v)) {
|
||||
for (const id of v) if (typeof id === 'string') userIds.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (userIds.size > 0) {
|
||||
const rows = await this.db
|
||||
.selectFrom('users')
|
||||
.select(['id', 'name', 'email'])
|
||||
.where('id', 'in', Array.from(userIds))
|
||||
.execute();
|
||||
ctx.userNames = new Map(
|
||||
rows.map((u) => [u.id, u.name || u.email || '']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (userIds.size === 0) return {};
|
||||
const pagePropIds = properties
|
||||
.filter((p) => p.type === BasePropertyType.PAGE)
|
||||
.map((p) => p.id);
|
||||
|
||||
const rows = await this.db
|
||||
.selectFrom('users')
|
||||
.select(['id', 'name', 'email'])
|
||||
.where('id', 'in', Array.from(userIds))
|
||||
.execute();
|
||||
if (pagePropIds.length > 0) {
|
||||
const pageIds = new Set<string>();
|
||||
for (const row of chunk) {
|
||||
const cells = (row.cells ?? {}) as Record<string, unknown>;
|
||||
for (const pid of pagePropIds) {
|
||||
const v = cells[pid];
|
||||
if (typeof v === 'string' && v.length > 0) pageIds.add(v);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
userNames: new Map(rows.map((u) => [u.id, u.name || u.email || ''])),
|
||||
};
|
||||
if (pageIds.size > 0) {
|
||||
const rows = await this.db
|
||||
.selectFrom('pages')
|
||||
.select(['id', 'title'])
|
||||
.where('id', 'in', Array.from(pageIds))
|
||||
.execute();
|
||||
ctx.pageTitles = new Map(rows.map((p) => [p.id, p.title ?? '']));
|
||||
}
|
||||
}
|
||||
|
||||
return ctx;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
|
||||
export type ResolvedPage = {
|
||||
id: string;
|
||||
slugId: string;
|
||||
title: string | null;
|
||||
icon: string | null;
|
||||
spaceId: string;
|
||||
space: { id: string; slug: string; name: string } | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class BasePageResolverService {
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
) {}
|
||||
|
||||
async resolvePages(
|
||||
pageIds: string[],
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
): Promise<ResolvedPage[]> {
|
||||
const unique = Array.from(new Set(pageIds));
|
||||
if (unique.length === 0) return [];
|
||||
|
||||
const rows = await this.db
|
||||
.selectFrom('pages')
|
||||
.select([
|
||||
'pages.id',
|
||||
'pages.slugId',
|
||||
'pages.title',
|
||||
'pages.icon',
|
||||
'pages.spaceId',
|
||||
])
|
||||
.select((eb) =>
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('spaces')
|
||||
.select(['spaces.id', 'spaces.name', 'spaces.slug'])
|
||||
.whereRef('spaces.id', '=', 'pages.spaceId'),
|
||||
).as('space'),
|
||||
)
|
||||
.where('pages.id', 'in', unique)
|
||||
.where('pages.workspaceId', '=', workspaceId)
|
||||
.where('pages.deletedAt', 'is', null)
|
||||
.execute();
|
||||
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const accessible = await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||
pageIds: rows.map((r) => r.id),
|
||||
userId,
|
||||
});
|
||||
const accessibleSet = new Set(accessible);
|
||||
|
||||
return rows.filter((r) => accessibleSet.has(r.id));
|
||||
}
|
||||
}
|
||||
@@ -159,6 +159,16 @@ async function buildCtx(
|
||||
.execute();
|
||||
ctx.attachmentNames = new Map(rows.map((a) => [a.id, a.fileName]));
|
||||
}
|
||||
} else if (fromType === BasePropertyType.PAGE) {
|
||||
const ids = collectIds(chunk, propertyId);
|
||||
if (ids.size > 0) {
|
||||
const rows = await db
|
||||
.selectFrom('pages')
|
||||
.select(['id', 'title'])
|
||||
.where('id', 'in', Array.from(ids))
|
||||
.execute();
|
||||
ctx.pageTitles = new Map(rows.map((p) => [p.id, p.title ?? '']));
|
||||
}
|
||||
}
|
||||
|
||||
return ctx;
|
||||
|
||||
Reference in New Issue
Block a user