# Base View Draft (Local-First Filter & Sort) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Source spec:** `.claude/superpowers/specs/2026-04-20-base-view-draft-design.md` — jump back there for any design-level question. This plan does not re-debate decisions. **Goal:** Make filter and sort changes on a base view local-first. They apply instantly for the editing user, live in localStorage scoped to `(userId, baseId, viewId)`, and never touch the server baseline until the user clicks "Save for everyone". A banner at the top of the table surfaces the draft state with Reset / Save controls. **Architecture:** A new Jotai `atomFamily(atomWithStorage)` per `(user, base, view)` triple stores a `BaseViewDraft` JSON in localStorage. A `useViewDraft` hook wraps the atom and exposes `effectiveFilter` / `effectiveSorts` / `isDirty` / setters / `reset` / `buildPromotedConfig`. `base-table.tsx` wires the hook in, swaps the raw `activeView` for an "effective" view (baseline merged with draft) when seeding `useBaseTable` and the row query, and passes the draft setters down to the toolbar in place of the current direct mutation calls. A new `BaseViewDraftBanner` mounts between page chrome and the toolbar. `useBaseTable` gains an optional `baselineConfig` so `persistViewConfig` (column layout auto-save) can never bake draft filter/sort into the server baseline. **Tech Stack:** React 18, Jotai 2.18 (`atomWithStorage` + `atomFamily` + `RESET` sentinel from `jotai/utils`), Mantine v8 (`Paper`, `Group`, `Text`, `Button`), `@tabler/icons-react` (`IconInfoCircle`), CASL (client-side `SpaceAbility`), existing `useUpdateViewMutation` / `useSpaceQuery` / `useSpaceAbility` / `useCurrentUser` hooks. **Scope (v1):** Filter and sort only. Column layout (widths, order, visibility) continues to auto-persist through the existing debounced `persistViewConfig` path. No server-side drafts, no "save as new view", no conflict UI, no garbage collection. See spec "Non-goals" for the full list. **Testing note:** Per `CLAUDE.md`, `apps/client` has no `vitest` harness. Verification steps in this plan are a mix of `pnpm nx run client:build` type-checks (machine-checkable) and manual browser / DevTools checks (user-driven). No unit-test commands are invented for the client. --- ## File Structure **New files:** - `apps/client/src/features/base/atoms/view-draft-atom.ts` — `atomFamily(atomWithStorage)` pair keyed by `(userId, baseId, viewId)`; persists `BaseViewDraft | null` in localStorage under `docmost:base-view-draft:v1:{userId}:{baseId}:{viewId}`. - `apps/client/src/features/base/hooks/use-view-draft.ts` — `useViewDraft` hook; derives `effective*` values, `isDirty`, exposes `setFilter` / `setSorts` / `reset` / `buildPromotedConfig`. - `apps/client/src/features/base/components/base-view-draft-banner.tsx` — pure presentational banner shown when `isDirty === true`; "Reset" always, "Save for everyone" only if `canSave`. **Modified files:** - `apps/client/src/features/space/permissions/permissions.type.ts` — add `Base = "base"` to `SpaceCaslSubject`; widen `SpaceAbility` union with `[SpaceCaslAction, SpaceCaslSubject.Base]`. - `apps/client/src/features/base/types/base.types.ts` — add the `BaseViewDraft` type. - `apps/client/src/features/base/hooks/use-base-table.ts` — extend `useBaseTable` signature with optional `opts?: { baselineConfig?: ViewConfig }`; in `persistViewConfig` override `sorts` / `filter` with the baseline so draft values cannot leak into the layout auto-save. - `apps/client/src/features/base/components/base-toolbar.tsx` — drop internal `useUpdateViewMutation` use for filter/sort; accept new `onDraftSortsChange` / `onDraftFiltersChange` / `activeView` props that read the effective config for badge counts. - `apps/client/src/features/base/components/base-table.tsx` — wire `useViewDraft`; build `effectiveView` memo; pass effective view + baseline into `useBaseTable`; pass effective filter/sorts to `useBaseRowsQuery`; render ``; add `useSpaceQuery` + `useSpaceAbility` for `canSave`; wire `handleSaveDraft`. --- ## Task 1: Add `Base` to the client-side CASL enum **Files:** - Modify: `apps/client/src/features/space/permissions/permissions.type.ts` **Rationale:** The server enum ([space-ability.type.ts:14](apps/server/src/core/casl/interfaces/space-ability.type.ts)) already has `Base = 'base'` and its `ISpaceAbility` union includes it. The membership permissions returned by `useSpaceQuery` therefore contain `Base` rules today, but the client enum at [permissions.type.ts:8-12](apps/client/src/features/space/permissions/permissions.type.ts) is missing the value, so `spaceAbility.can(SpaceCaslAction.Edit, SpaceCaslSubject.Base)` doesn't type-check on the client. A ripgrep for `switch(.*SpaceCaslSubject` shows no exhaustive switches on the subject enum in the client, so adding a value is safe. - [ ] **Step 1: Type-check baseline** ```bash pnpm nx run client:build ``` Expected: build succeeds. This is the "no changes yet" baseline — if it already fails, stop and report. - [ ] **Step 2: Add `Base` to the enum and widen `SpaceAbility`** Replace the file contents: ```ts export enum SpaceCaslAction { Manage = "manage", Create = "create", Read = "read", Edit = "edit", Delete = "delete", } export enum SpaceCaslSubject { Settings = "settings", Member = "member", Page = "page", Base = "base", } export type SpaceAbility = | [SpaceCaslAction, SpaceCaslSubject.Settings] | [SpaceCaslAction, SpaceCaslSubject.Member] | [SpaceCaslAction, SpaceCaslSubject.Page] | [SpaceCaslAction, SpaceCaslSubject.Base]; ``` - [ ] **Step 3: Type-check after change** ```bash pnpm nx run client:build ``` Expected: build still succeeds. (No existing caller consumes `SpaceCaslSubject.Base` yet, so this is purely additive.) - [ ] **Step 4: Commit** ```bash git add apps/client/src/features/space/permissions/permissions.type.ts git commit -m "feat(base): add Base subject to client-side space CASL enum" ``` --- ## Task 2: Add `BaseViewDraft` type and the `viewDraftAtomFamily` **Files:** - Modify: `apps/client/src/features/base/types/base.types.ts` - Create: `apps/client/src/features/base/atoms/view-draft-atom.ts` - [ ] **Step 1: Append the `BaseViewDraft` type** Add to the end of `apps/client/src/features/base/types/base.types.ts`: ```ts // Local-first draft of filter / sort tweaks for a single view, stored in // localStorage scoped to (userId, baseId, viewId). An absent `filter` or // `sorts` field means "inherit the baseline for that axis". See // `.claude/superpowers/specs/2026-04-20-base-view-draft-design.md`. export type BaseViewDraft = { filter?: FilterGroup; sorts?: ViewSortConfig[]; // ISO timestamp written on each put; diagnostic only, not read by logic. updatedAt: string; }; ``` - [ ] **Step 2: Create the atom family** Create `apps/client/src/features/base/atoms/view-draft-atom.ts`: ```ts import { atomFamily, atomWithStorage } from "jotai/utils"; import { BaseViewDraft } from "@/features/base/types/base.types"; export type ViewDraftKey = { userId: string; baseId: string; viewId: string; }; export const viewDraftStorageKey = (k: ViewDraftKey) => `docmost:base-view-draft:v1:${k.userId}:${k.baseId}:${k.viewId}`; // `atomWithStorage` handles JSON serialization, cross-tab sync via the // `storage` event, and lazy first-read out of the box. `atomFamily`'s // comparator ensures the same triple resolves to the same atom instance // across renders, so identity-equality cache hits in Jotai still work. export const viewDraftAtomFamily = atomFamily( (k: ViewDraftKey) => atomWithStorage(viewDraftStorageKey(k), null), (a, b) => a.userId === b.userId && a.baseId === b.baseId && a.viewId === b.viewId, ); ``` - [ ] **Step 3: Type-check** ```bash pnpm nx run client:build ``` Expected: build succeeds. Nothing consumes the atom yet; this is a scaffold for Task 3. - [ ] **Step 4: Commit** ```bash git add apps/client/src/features/base/types/base.types.ts apps/client/src/features/base/atoms/view-draft-atom.ts git commit -m "feat(base): add BaseViewDraft type and view-draft atom family" ``` --- ## Task 3: Add `useViewDraft` hook **Files:** - Create: `apps/client/src/features/base/hooks/use-view-draft.ts` **Design contract (from spec):** 1. If any of `userId / baseId / viewId` is `undefined` → return a passthrough state: `draft=null`, `isDirty=false`, setters no-op, `effective*` fall through to baseline. 2. `setFilter(next)` / `setSorts(next)` compute `merged = { ...(draft ?? {}), [axis]: next, updatedAt: new Date().toISOString() }`. If both `filter` and `sorts` come out `undefined`, call `setDraft(RESET)` to remove the key. 3. `reset()` is `setDraft(RESET)`. 4. `isDirty` compares draft-vs-baseline per axis via `JSON.stringify` equality; `null/undefined` means "no divergence on that axis". 5. `buildPromotedConfig(baseline)` returns `{ ...baseline, filter: draft?.filter ?? baseline.filter, sorts: draft?.sorts ?? baseline.sorts }` — preserves all non-draft fields (widths, order, visibility). - [ ] **Step 1: Scaffold the file** Create `apps/client/src/features/base/hooks/use-view-draft.ts` with imports and exported types only — no logic yet. This is the "stub" in the scaffold → implement pattern: ```ts import { useCallback, useMemo } from "react"; import { useAtom } from "jotai"; import { RESET } from "jotai/utils"; import { BaseViewDraft, FilterGroup, ViewConfig, ViewSortConfig, } from "@/features/base/types/base.types"; import { viewDraftAtomFamily } from "@/features/base/atoms/view-draft-atom"; export type UseViewDraftArgs = { userId: string | undefined; baseId: string | undefined; viewId: string | undefined; baselineFilter: FilterGroup | undefined; baselineSorts: ViewSortConfig[] | undefined; }; export type ViewDraftState = { draft: BaseViewDraft | null; effectiveFilter: FilterGroup | undefined; effectiveSorts: ViewSortConfig[] | undefined; isDirty: boolean; setFilter: (filter: FilterGroup | undefined) => void; setSorts: (sorts: ViewSortConfig[] | undefined) => void; reset: () => void; buildPromotedConfig: (baseline: ViewConfig) => ViewConfig; }; // Passthrough shape returned when any of userId/baseId/viewId is undefined. // Guards the initial-load window where auth / activeView hasn't resolved. const PASSTHROUGH_DRAFT: BaseViewDraft | null = null; export function useViewDraft(_args: UseViewDraftArgs): ViewDraftState { // TODO(Task 3 step 2): implement. throw new Error("useViewDraft: not implemented"); } ``` - [ ] **Step 2: Verify the stub type-checks** ```bash pnpm nx run client:build ``` Expected: build succeeds (nothing imports the hook yet). - [ ] **Step 3: Implement the hook** Replace the stub body with the full implementation: ```ts import { useCallback, useMemo } from "react"; import { useAtom } from "jotai"; import { RESET } from "jotai/utils"; import { BaseViewDraft, FilterGroup, ViewConfig, ViewSortConfig, } from "@/features/base/types/base.types"; import { viewDraftAtomFamily } from "@/features/base/atoms/view-draft-atom"; export type UseViewDraftArgs = { userId: string | undefined; baseId: string | undefined; viewId: string | undefined; baselineFilter: FilterGroup | undefined; baselineSorts: ViewSortConfig[] | undefined; }; export type ViewDraftState = { draft: BaseViewDraft | null; effectiveFilter: FilterGroup | undefined; effectiveSorts: ViewSortConfig[] | undefined; isDirty: boolean; setFilter: (filter: FilterGroup | undefined) => void; setSorts: (sorts: ViewSortConfig[] | undefined) => void; reset: () => void; buildPromotedConfig: (baseline: ViewConfig) => ViewConfig; }; // JSON-stringify equality is good enough for FilterGroup (pure data tree) // and ViewSortConfig[] — V8 preserves non-numeric key insertion order so // the same object graph serializes identically. Avoids pulling in // lodash/fast-deep-equal for two known-shaped types. (Spec "Dirty check".) function filterEq(a: FilterGroup | undefined, b: FilterGroup | undefined) { return JSON.stringify(a ?? null) === JSON.stringify(b ?? null); } function sortsEq( a: ViewSortConfig[] | undefined, b: ViewSortConfig[] | undefined, ) { return JSON.stringify(a ?? null) === JSON.stringify(b ?? null); } export function useViewDraft(args: UseViewDraftArgs): ViewDraftState { const { userId, baseId, viewId, baselineFilter, baselineSorts } = args; const ready = !!(userId && baseId && viewId); // Always mount an atom with a stable shape so hook order is consistent. // When not ready we still feed a key, but we won't read/write it. const atomKey = useMemo( () => ({ userId: userId ?? "", baseId: baseId ?? "", viewId: viewId ?? "", }), [userId, baseId, viewId], ); const [storedDraft, setDraft] = useAtom(viewDraftAtomFamily(atomKey)); const draft = ready ? storedDraft : null; const setFilter = useCallback( (next: FilterGroup | undefined) => { if (!ready) return; const current = storedDraft ?? null; const mergedFilter = next; const mergedSorts = current?.sorts; if (mergedFilter === undefined && (mergedSorts === undefined || mergedSorts === null)) { setDraft(RESET); return; } setDraft({ filter: mergedFilter, sorts: mergedSorts, updatedAt: new Date().toISOString(), }); }, [ready, storedDraft, setDraft], ); const setSorts = useCallback( (next: ViewSortConfig[] | undefined) => { if (!ready) return; const current = storedDraft ?? null; const mergedFilter = current?.filter; const mergedSorts = next; if (mergedFilter === undefined && (mergedSorts === undefined || mergedSorts === null)) { setDraft(RESET); return; } setDraft({ filter: mergedFilter, sorts: mergedSorts, updatedAt: new Date().toISOString(), }); }, [ready, storedDraft, setDraft], ); const reset = useCallback(() => { if (!ready) return; setDraft(RESET); }, [ready, setDraft]); const effectiveFilter = useMemo( () => (draft?.filter !== undefined ? draft.filter : baselineFilter), [draft?.filter, baselineFilter], ); const effectiveSorts = useMemo( () => (draft?.sorts !== undefined ? draft.sorts : baselineSorts), [draft?.sorts, baselineSorts], ); const isDirty = useMemo(() => { if (!draft) return false; const filterDirty = draft.filter !== undefined && !filterEq(draft.filter, baselineFilter); const sortsDirty = draft.sorts !== undefined && !sortsEq(draft.sorts, baselineSorts); return filterDirty || sortsDirty; }, [draft, baselineFilter, baselineSorts]); const buildPromotedConfig = useCallback( (baseline: ViewConfig): ViewConfig => ({ ...baseline, filter: draft?.filter ?? baseline.filter, sorts: draft?.sorts ?? baseline.sorts, }), [draft], ); if (!ready) { return { draft: null, effectiveFilter: baselineFilter, effectiveSorts: baselineSorts, isDirty: false, setFilter: () => {}, setSorts: () => {}, reset: () => {}, buildPromotedConfig: (baseline) => baseline, }; } return { draft, effectiveFilter, effectiveSorts, isDirty, setFilter, setSorts, reset, buildPromotedConfig, }; } ``` - [ ] **Step 4: Type-check** ```bash pnpm nx run client:build ``` Expected: build succeeds. Hook has no consumers yet (Task 5 will wire it in). - [ ] **Step 5: Commit** ```bash git add apps/client/src/features/base/hooks/use-view-draft.ts git commit -m "feat(base): add useViewDraft hook for local filter/sort drafts" ``` --- ## Task 4: Extend `useBaseTable` with `baselineConfig` option **Files:** - Modify: `apps/client/src/features/base/hooks/use-base-table.ts` (signature ~line 224-228; `persistViewConfig` body ~line 371-396; exact block is `buildViewConfigFromTable(table, activeView.config)` at line 382) **Why:** Once `base-table.tsx` starts passing an `effectiveView` into `useBaseTable` (Task 5), the table's live `sorting` state will be seeded from the draft. The debounced `persistViewConfig` reads `state.sorting` (via `buildViewConfigFromTable`) and writes it back to the server — which would silently bake the draft into the server baseline every time the user resizes or reorders a column. The fix is: `persistViewConfig` overrides `sorts` and `filter` in the emitted config with the **real baseline** values, not the effective ones. To keep the hook's responsibilities tidy, existing callers stay unchanged — a new optional `opts.baselineConfig` parameter carries the baseline. - [ ] **Step 1: Change the signature** In `use-base-table.ts` find: ```ts export function useBaseTable( base: IBase | undefined, rows: IBaseRow[], activeView: IBaseView | undefined, ): UseBaseTableResult { ``` Replace with: ```ts export type UseBaseTableOptions = { // When provided, `persistViewConfig` uses this as the authoritative // filter/sorts for the server write. The table's live sorting state is // ignored for that axis so a locally-drafted sort/filter (kept in // `activeView.config` for rendering purposes) cannot leak into the // auto-persist column-layout path. Optional to preserve existing // callers that pass the real baseline as `activeView`. baselineConfig?: ViewConfig; }; export function useBaseTable( base: IBase | undefined, rows: IBaseRow[], activeView: IBaseView | undefined, opts: UseBaseTableOptions = {}, ): UseBaseTableResult { ``` - [ ] **Step 2: Use `baselineConfig` inside `persistViewConfig`** Find the `persistViewConfig` `useCallback` at ~line 371 and the `buildViewConfigFromTable(table, activeView.config)` call at ~line 382. Replace the inner body: Before (line 382): ```ts const config = buildViewConfigFromTable(table, activeView.config); ``` After: ```ts // `baseline` is the server-side-of-truth config. When the caller has // wrapped `activeView` with draft filter/sort values for render, they // pass the pre-wrap config here so we never round-trip drafts through // the column-layout auto-save path. const baseline = opts.baselineConfig ?? activeView.config; const config = buildViewConfigFromTable(table, baseline, { sorts: baseline?.sorts, filter: baseline?.filter, }); ``` Also update the `persistViewConfig` dependency list at line 396 from `[activeView, base, table, updateViewMutation]` to `[activeView, base, table, updateViewMutation, opts.baselineConfig]`. - [ ] **Step 3: Type-check** ```bash pnpm nx run client:build ``` Expected: build succeeds. Existing callers (just `base-table.tsx` so far) didn't pass `opts`, so they get `baselineConfig: undefined` → `baseline = activeView.config` → behavior unchanged. - [ ] **Step 4: Commit** ```bash git add apps/client/src/features/base/hooks/use-base-table.ts git commit -m "refactor(base): accept baselineConfig option in useBaseTable" ``` --- ## Task 5: Integrate `useViewDraft` into `base-table.tsx` (render path only) **Files:** - Modify: `apps/client/src/features/base/components/base-table.tsx` (imports near top; `activeView` memo ~line 40-43; rows query call ~line 52-53; `useBaseTable` call at line 88) **Scope:** Only the **render path**. At the end of this task: - The table renders from effective (draft-or-baseline) values. - The row query fetches using effective values. - Filters/sorts still auto-persist via the existing toolbar handlers (Task 6 rewires that). - No banner yet (Task 7/8). **Manual pre-test with DevTools:** At the end of the task, verify drafts render correctly by seeding localStorage directly. - [ ] **Step 1: Add imports** At the top of `base-table.tsx`, add: ```tsx import useCurrentUser from "@/features/user/hooks/use-current-user"; import { useViewDraft } from "@/features/base/hooks/use-view-draft"; ``` - [ ] **Step 2: Wire the draft hook and build `effectiveView`** Insert after the `activeView` memo (currently at line 40-43 — the `useMemo` that resolves `activeView` from `views` and `activeViewId`), before the `activeFilter` / `activeSorts` lines: ```tsx const { data: currentUser } = useCurrentUser(); const { draft: _draft, effectiveFilter, effectiveSorts, isDirty, setFilter: setDraftFilter, setSorts: setDraftSorts, reset: resetDraft, buildPromotedConfig, } = useViewDraft({ userId: currentUser?.user.id, baseId, viewId: activeView?.id, baselineFilter: activeView?.config?.filter, baselineSorts: activeView?.config?.sorts, }); // Render view: baseline merged with any local draft. Passed to // `useBaseTable` (for table state seeding) and to the toolbar (for badge // counts). The real `activeView` is still used as the auto-persist // baseline so drafts can't leak into column-layout writes. const effectiveView = useMemo( () => activeView ? { ...activeView, config: { ...activeView.config, filter: effectiveFilter, sorts: effectiveSorts, }, } : undefined, [activeView, effectiveFilter, effectiveSorts], ); ``` - [ ] **Step 3: Replace the `activeFilter` / `activeSorts` lines** Before (~line 45-46): ```tsx const activeFilter = activeView?.config?.filter; const activeSorts = activeView?.config?.sorts; ``` After: ```tsx // Effective values drive the row query and the client-side position // sort guard below. The old `activeView.config` reads are no longer the // source of truth once drafts are involved. const activeFilter = effectiveFilter; const activeSorts = effectiveSorts; ``` (Renaming is intentionally minimal — downstream usages of `activeSorts` / `activeFilter` at `useBaseRowsQuery(...)` and in the position-sort memo keep working without further edits.) - [ ] **Step 4: Pass `effectiveView` + `baselineConfig` into `useBaseTable`** Before (~line 88): ```tsx const { table, persistViewConfig } = useBaseTable(base, rows, activeView); ``` After: ```tsx const { table, persistViewConfig } = useBaseTable(base, rows, effectiveView, { baselineConfig: activeView?.config, }); ``` - [ ] **Step 5: Type-check** ```bash pnpm nx run client:build ``` Expected: build succeeds. `isDirty`, `setDraftFilter`, `setDraftSorts`, `resetDraft`, `buildPromotedConfig` are declared-but-unused at this step; that's intentional — Tasks 6 and 8 consume them. TypeScript `noUnusedLocals` is not enabled in the client's `tsconfig` (check with `pnpm nx run client:build` — if it errors on unused, prefix with `_`). - [ ] **Step 6: Manual DevTools verification — USER-DRIVEN** > Do not run `pnpm dev` as an agent. Hand off to the user. Ask the user to: 1. Run `pnpm dev` and open any base in the browser. 2. In DevTools → Application → Local Storage, note the current user id from the cookie or any existing `docmost:*` key, then note the base id from the URL and the active view id (visible in the `activeViewId` atom via React DevTools, or pick the first view's id from the `["bases", baseId]` query data). 3. In DevTools console: ```js // Seed a dummy draft that sorts by any existing propertyId desc. const key = `docmost:base-view-draft:v1:${USER_ID}:${BASE_ID}:${VIEW_ID}`; localStorage.setItem(key, JSON.stringify({ sorts: [{ propertyId: "", direction: "desc" }], updatedAt: new Date().toISOString(), })); location.reload(); ``` Expected after reload: - Rows appear sorted by the seeded propertyId descending. - The sort popover badge shows `1`. - The sort popover, when opened, shows the drafted sort entry. Then run: ```js localStorage.removeItem(key); location.reload(); ``` Expected: rows revert to the baseline order; badge clears. If either side fails, stop and diagnose. Do not proceed to Task 6. - [ ] **Step 7: Commit** ```bash git add apps/client/src/features/base/components/base-table.tsx git commit -m "feat(base): render table from effective (draft-or-baseline) view config" ``` --- ## Task 6: Rewire toolbar to write drafts instead of persisting immediately **Files:** - Modify: `apps/client/src/features/base/components/base-toolbar.tsx` (remove `useUpdateViewMutation` / `buildViewConfigFromTable` usage for sort/filter — lines 19, 20, 116, 135-148, 150-169; badge sources at 118, 122-128; props type at 29-37) - Modify: `apps/client/src/features/base/components/base-table.tsx` (pass the new callbacks + effective view into ``, ~line 192-200) **Why:** Today `handleSortsChange` and `handleFiltersChange` call `updateViewMutation.mutate(...)` directly — that's the "every change persists to everyone" behavior we're replacing. The toolbar gets two new callbacks (`onDraftSortsChange`, `onDraftFiltersChange`) from the parent and drops its internal mutation for these two axes. Badge counts must read from `effectiveView.config` so they reflect the user's draft, not the baseline. - [ ] **Step 1: Change the toolbar's props type** In `base-toolbar.tsx`, replace the `BaseToolbarProps` type (line 29-37): Before: ```ts type BaseToolbarProps = { base: IBase; activeView: IBaseView | undefined; views: IBaseView[]; table: Table; onViewChange: (viewId: string) => void; onAddView?: () => void; onPersistViewConfig: () => void; }; ``` After: ```ts type BaseToolbarProps = { base: IBase; // Effective view — baseline merged with any local draft. Badge counts // and sort/filter popover seed data read from this. The real baseline // only enters via `onDraftSortsChange` / `onDraftFiltersChange` // callbacks defined by the parent. activeView: IBaseView | undefined; views: IBaseView[]; table: Table; onViewChange: (viewId: string) => void; onAddView?: () => void; onPersistViewConfig: () => void; onDraftSortsChange: (sorts: ViewSortConfig[] | undefined) => void; onDraftFiltersChange: (filter: FilterGroup | undefined) => void; }; ``` Destructure the two new props in the function signature: Before (line 39-47): ```ts export function BaseToolbar({ base, activeView, views, table, onViewChange, onAddView, onPersistViewConfig, }: BaseToolbarProps) { ``` After: ```ts export function BaseToolbar({ base, activeView, views, table, onViewChange, onAddView, onPersistViewConfig, onDraftSortsChange, onDraftFiltersChange, }: BaseToolbarProps) { ``` - [ ] **Step 2: Remove the now-unused `updateViewMutation` and `buildViewConfigFromTable` imports/calls for sort/filter** The toolbar's `useUpdateViewMutation()` call at line 116 is only used by `handleSortsChange` and `handleFiltersChange`. Both are being rewritten. Delete: - The import at line 19: ```ts import { useUpdateViewMutation } from "@/features/base/queries/base-view-query"; ``` - The import at line 20: ```ts import { buildViewConfigFromTable } from "@/features/base/hooks/use-base-table"; ``` - The `const updateViewMutation = useUpdateViewMutation();` line at 116. - [ ] **Step 3: Rewrite the two handlers** Replace `handleSortsChange` (lines 135-148): Before: ```ts const handleSortsChange = useCallback( (newSorts: ViewSortConfig[]) => { if (!activeView) return; const config = buildViewConfigFromTable(table, activeView.config, { sorts: newSorts, }); updateViewMutation.mutate({ viewId: activeView.id, baseId: base.id, config, }); }, [activeView, base.id, table, updateViewMutation], ); ``` After: ```ts const handleSortsChange = useCallback( (newSorts: ViewSortConfig[]) => { // Normalize empty to undefined so the draft hook can drop the `sorts` // axis (and remove its localStorage entry when both axes go clean). onDraftSortsChange(newSorts.length > 0 ? newSorts : undefined); }, [onDraftSortsChange], ); ``` Replace `handleFiltersChange` (lines 150-169): Before: ```ts const handleFiltersChange = useCallback( (newConditions: FilterCondition[]) => { if (!activeView) return; const filter: FilterGroup | undefined = newConditions.length > 0 ? { op: "and", children: newConditions } : undefined; // `filter: undefined` in overrides removes the filter key; the helper's // spread-then-overrides order means `undefined` wins over any base filter. const config = buildViewConfigFromTable(table, activeView.config, { filter, }); updateViewMutation.mutate({ viewId: activeView.id, baseId: base.id, config, }); }, [activeView, base.id, table, updateViewMutation], ); ``` After: ```ts const handleFiltersChange = useCallback( (newConditions: FilterCondition[]) => { // Wrap the AND-flat popover output into the engine's FilterGroup shape. // Pass `undefined` to drop the filter axis from the draft entirely. const filter: FilterGroup | undefined = newConditions.length > 0 ? { op: "and", children: newConditions } : undefined; onDraftFiltersChange(filter); }, [onDraftFiltersChange], ); ``` - [ ] **Step 4: Wire the new callbacks + effective view in `base-table.tsx`** In `base-table.tsx`, near the existing callback block (before the `return`), add: ```tsx const handleDraftSortsChange = useCallback( (sorts: ViewSortConfig[] | undefined) => { setDraftSorts(sorts && sorts.length > 0 ? sorts : undefined); }, [setDraftSorts], ); const handleDraftFiltersChange = useCallback( (filter: FilterGroup | undefined) => { setDraftFilter(filter); }, [setDraftFilter], ); ``` Add the imports needed for the callback types: ```tsx import { FilterGroup, ViewSortConfig, } from "@/features/base/types/base.types"; ``` Update the `` JSX (currently at ~line 192-200) to pass `effectiveView` instead of `activeView` AND the two new callbacks: Before: ```tsx ``` After: ```tsx ``` - [ ] **Step 5: Type-check** ```bash pnpm nx run client:build ``` Expected: build succeeds. The toolbar's sort/filter badges now derive from `effectiveView.config` (because the parent passes `effectiveView` as `activeView`) — no further toolbar edits needed for the badge count issue flagged in the spec. - [ ] **Step 6: Manual verification — USER-DRIVEN** Ask the user to: 1. Open a base in the browser. 2. Open the filter popover, add a filter (pick any property → any op → any value). 3. Observe: the row list updates locally, filter badge shows `1`. 4. Open DevTools → Application → Local Storage → look for `docmost:base-view-draft:v1:...`. Expected: entry present with the filter in JSON. 5. Hard-refresh (cmd+shift+R). The filter still applies locally. 6. Open the base in an incognito window (same base URL) as a different user — or ask a teammate. Expected: no filter applied; the baseline view is unchanged. If step 5 or step 6 fails, stop and diagnose. - [ ] **Step 7: Commit** ```bash git add apps/client/src/features/base/components/base-toolbar.tsx apps/client/src/features/base/components/base-table.tsx git commit -m "feat(base): route toolbar sort/filter changes through local draft" ``` --- ## Task 7: Build the `BaseViewDraftBanner` component **Files:** - Create: `apps/client/src/features/base/components/base-view-draft-banner.tsx` Pure presentational. No data fetching, no state. Mounts the banner only when `isDirty === true` and shows "Reset" always, "Save for everyone" only when `canSave`. - [ ] **Step 1: Create the component file** Create `apps/client/src/features/base/components/base-view-draft-banner.tsx`: ```tsx import { Paper, Group, Text, Button } from "@mantine/core"; import { IconInfoCircle } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; type BaseViewDraftBannerProps = { isDirty: boolean; canSave: boolean; onReset: () => void; onSave: () => void; saving: boolean; }; export function BaseViewDraftBanner({ isDirty, canSave, onReset, onSave, saving, }: BaseViewDraftBannerProps) { const { t } = useTranslation(); if (!isDirty) return null; return ( {t("Filter and sort changes are visible only to you.")} {canSave && ( )} ); } ``` - [ ] **Step 2: Type-check** ```bash pnpm nx run client:build ``` Expected: build succeeds. Component has no consumers yet. - [ ] **Step 3: Commit** ```bash git add apps/client/src/features/base/components/base-view-draft-banner.tsx git commit -m "feat(base): add view draft banner component" ``` --- ## Task 8: Mount the banner and wire the save flow in `base-table.tsx` **Files:** - Modify: `apps/client/src/features/base/components/base-table.tsx` **What this task adds:** - `useSpaceQuery` + `useSpaceAbility` → `canSave = spaceAbility.can(SpaceCaslAction.Edit, SpaceCaslSubject.Base)`. - `useUpdateViewMutation` hook invocation at the page level. - `handleSaveDraft` callback that composes `buildPromotedConfig(activeView.config)` → `updateViewMutation.mutateAsync(...)` → `resetDraft()` → success toast. - `` mounted between page chrome and ``. - [ ] **Step 1: Add imports** Add to the top of `base-table.tsx`: ```tsx import { notifications } from "@mantine/notifications"; import { useSpaceQuery } from "@/features/space/queries/space-query"; import { useSpaceAbility } from "@/features/space/permissions/use-space-ability"; import { SpaceCaslAction, SpaceCaslSubject, } from "@/features/space/permissions/permissions.type"; import { useUpdateViewMutation } from "@/features/base/queries/base-view-query"; import { BaseViewDraftBanner } from "@/features/base/components/base-view-draft-banner"; ``` - [ ] **Step 2: Wire the space ability and the save handler** Insert after the `useViewDraft` / `effectiveView` block, before the `useBaseRowsQuery` call: ```tsx // `useSpaceQuery` is guarded by `enabled: !!spaceId` internally, so // passing `""` when `base` hasn't loaded yet is safe. See // use-history-restore.tsx for the same pattern. const { data: space } = useSpaceQuery(base?.spaceId ?? ""); const spaceAbility = useSpaceAbility(space?.membership?.permissions); const canSave = spaceAbility.can( SpaceCaslAction.Edit, SpaceCaslSubject.Base, ); const updateViewMutation = useUpdateViewMutation(); const handleSaveDraft = useCallback(async () => { if (!activeView || !base) return; // `buildPromotedConfig` preserves all non-draft baseline fields // (widths/order/visibility) and only overwrites filter/sorts when the // draft has divergent values. const config = buildPromotedConfig(activeView.config); try { await updateViewMutation.mutateAsync({ viewId: activeView.id, baseId: base.id, config, }); resetDraft(); notifications.show({ message: t("View updated for everyone") }); } catch { // `useUpdateViewMutation` already shows a red toast on error and // rolls back the optimistic cache; keep the draft so the user can // retry without re-typing. } }, [ activeView, base, buildPromotedConfig, resetDraft, t, updateViewMutation, ]); ``` - [ ] **Step 3: Mount the banner** Update the `return` block. Before (~line 190-215): ```tsx return (
); ``` After: ```tsx return (
); ``` (The `` / `` props are re-listed verbatim from Task 6 — no behavioral change to them here; this step only adds the banner above.) - [ ] **Step 4: Type-check** ```bash pnpm nx run client:build ``` Expected: build succeeds. The previously "declared-but-unused" `isDirty`, `resetDraft`, and `buildPromotedConfig` are now consumed. - [ ] **Step 5: Manual verification — USER-DRIVEN** Ask the user to: 1. Open a base. 2. Apply a filter via the popover. - Expected: the yellow banner appears with info icon + "Filter and sort changes are visible only to you." on the left; "Reset" and "Save for everyone" on the right. 3. Click **Reset**. - Expected: banner disappears; filter popover badge clears; rows revert to baseline. 4. Apply a filter again. Click **Save for everyone**. - Expected: banner disappears; notification "View updated for everyone" appears. 5. Hard-refresh the page. - Expected: filter is still applied (baseline has caught up). No banner. 6. Open the same base in a second browser profile / incognito as a different user. - Expected: that user sees the saved filter. 7. As a viewer (a user with Read but not Edit on Base): open the base, apply a filter. - Expected: banner appears but only shows "Reset" — no "Save for everyone" button. If any of these fails, stop and diagnose. - [ ] **Step 6: Commit** ```bash git add apps/client/src/features/base/components/base-table.tsx git commit -m "feat(base): mount draft banner and wire save-for-everyone flow" ``` --- ## Task 9: Final manual QA pass — USER-DRIVEN > Do not run `pnpm dev` as an agent. Ask the user to step through the spec's "Manual QA checklist" end-to-end. This is a verification task, not a commit task. If any check fails, open a sub-task to fix before continuing. Reference: spec section "Manual QA checklist" (`.claude/superpowers/specs/2026-04-20-base-view-draft-design.md` lines 425-451). - [ ] **Step 1: Single user, single tab** - Apply a filter → banner appears, row list updates locally. - Click Reset → banner disappears, filter popover reverts, rows revert. - Apply a filter and a sort → click Save for everyone → banner disappears → refresh → filter/sort is the new baseline. - Apply a filter then delete it via the popover → banner disappears → refresh → baseline unchanged (no deleted filter restored). - [ ] **Step 2: Single user, multiple tabs** - Open base in tab A and tab B. - In tab A, add a sort → tab B re-renders with the same sort (badge + row order) → tab B shows the banner. - In tab B, click Reset → tab A's banner disappears and sort reverts. - [ ] **Step 3: Multi-user baseline race** - User X (editor) opens base, applies a filter (draft). - User Y (another editor) saves a new baseline via their own Save flow. - User X's client receives the websocket `base:schema:bumped` → `["bases", baseId]` invalidates → `activeView.config` updates. - Expected: X's `effectiveFilter` still shows X's draft filter. Banner stays. No UI prompt. - X clicks Reset → X sees Y's new baseline. - [ ] **Step 4: Permission gating** - Log in as a space Viewer (Read but not Edit on `Base`). - Open base, apply a filter. - Expected: banner appears but shows only "Reset" — no "Save for everyone" button. - [ ] **Step 5: Reset with popover open** - Open the filter popover, add conditions. - Without closing the popover, click Reset (the banner is above the popover). - Expected: popover closes on outside-click; the next open shows baseline conditions. - [ ] **Step 6: Save clears draft + updates server** - Save. Banner vanishes. - In DevTools → Application → Local Storage: `docmost:base-view-draft:v1:{user}:{base}:{view}` is absent. - Open the base in incognito / second-account browser: the filter/sort is present from the server. - [ ] **Step 7: Browser storage cleared** - In DevTools, wipe `localStorage`. - Expected: base re-renders with baseline, banner gone. - [ ] **Step 8: Column layout still auto-saves (regression check)** - With a filter draft active, drag a column to reorder. - Wait ~1s for the debounce. - Expected: column order persists (open base in another tab; order matches) AND the filter draft remains a draft (baseline's filter on the server is still the pre-draft state). Verify via the server API or a second-account browser. --- ## Follow-ups (out of scope for v1) - Draft column layout (widths, order, visibility) — spec "Future extension #1". - Server-side per-user drafts for cross-device sync — spec "Future extension #2". - "Save as new view" split-button — spec "Future extension #3". - Baseline-changed hint inside the banner — spec "Future extension #4". - One-time in-product hint explaining the new draft-then-save behavior — spec "Rollout" mitigation note.