Files
docmost/.claude/superpowers/plans/2026-04-20-base-view-draft.md
T

42 KiB

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.tsatomFamily(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.tsuseViewDraft 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 <BaseViewDraftBanner>; 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) 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 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
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:

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
pnpm nx run client:build

Expected: build still succeeds. (No existing caller consumes SpaceCaslSubject.Base yet, so this is purely additive.)

  • Step 4: Commit
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:

// 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:

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,
);
  • Step 3: Type-check
pnpm nx run client:build

Expected: build succeeds. Nothing consumes the atom yet; this is a scaffold for Task 3.

  • Step 4: Commit
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:

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
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:

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
pnpm nx run client:build

Expected: build succeeds. Hook has no consumers yet (Task 5 will wire it in).

  • Step 5: Commit
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:

export function useBaseTable(
  base: IBase | undefined,
  rows: IBaseRow[],
  activeView: IBaseView | undefined,
): UseBaseTableResult {

Replace with:

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):

const config = buildViewConfigFromTable(table, activeView.config);

After:

// `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
pnpm nx run client:build

Expected: build succeeds. Existing callers (just base-table.tsx so far) didn't pass opts, so they get baselineConfig: undefinedbaseline = activeView.config → behavior unchanged.

  • Step 4: Commit
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:

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:

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):

const activeFilter = activeView?.config?.filter;
const activeSorts = activeView?.config?.sorts;

After:

// 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):

const { table, persistViewConfig } = useBaseTable(base, rows, activeView);

After:

const { table, persistViewConfig } = useBaseTable(base, rows, effectiveView, {
  baselineConfig: activeView?.config,
});
  • Step 5: Type-check
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:

    // 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: "<ANY_PROP_UUID>", 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:

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
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 <BaseToolbar>, ~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:

type BaseToolbarProps = {
  base: IBase;
  activeView: IBaseView | undefined;
  views: IBaseView[];
  table: Table<IBaseRow>;
  onViewChange: (viewId: string) => void;
  onAddView?: () => void;
  onPersistViewConfig: () => void;
};

After:

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;
};

Destructure the two new props in the function signature:

Before (line 39-47):

export function BaseToolbar({
  base,
  activeView,
  views,
  table,
  onViewChange,
  onAddView,
  onPersistViewConfig,
}: BaseToolbarProps) {

After:

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:

    import { useUpdateViewMutation } from "@/features/base/queries/base-view-query";
    
  • The import at line 20:

    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:

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:

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:

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:

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:

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:

import {
  FilterGroup,
  ViewSortConfig,
} from "@/features/base/types/base.types";

Update the <BaseToolbar> JSX (currently at ~line 192-200) to pass effectiveView instead of activeView AND the two new callbacks:

Before:

<BaseToolbar
  base={base}
  activeView={activeView}
  views={views}
  table={table}
  onViewChange={handleViewChange}
  onAddView={handleAddView}
  onPersistViewConfig={persistViewConfig}
/>

After:

<BaseToolbar
  base={base}
  activeView={effectiveView}
  views={views}
  table={table}
  onViewChange={handleViewChange}
  onAddView={handleAddView}
  onPersistViewConfig={persistViewConfig}
  onDraftSortsChange={handleDraftSortsChange}
  onDraftFiltersChange={handleDraftFiltersChange}
/>
  • Step 5: Type-check
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
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:

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 (
    <Paper withBorder radius="sm" px="md" py="xs" bg="yellow.0">
      <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>
  );
}
  • Step 2: Type-check
pnpm nx run client:build

Expected: build succeeds. Component has no consumers yet.

  • Step 3: Commit
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 + useSpaceAbilitycanSave = 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.

  • <BaseViewDraftBanner> mounted between page chrome and <BaseToolbar>.

  • Step 1: Add imports

Add to the top of base-table.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:

// `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):

return (
  <div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
    <BaseToolbar ... />
    <GridContainer ... />
  </div>
);

After:

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={effectiveView}
      views={views}
      table={table}
      onViewChange={handleViewChange}
      onAddView={handleAddView}
      onPersistViewConfig={persistViewConfig}
      onDraftSortsChange={handleDraftSortsChange}
      onDraftFiltersChange={handleDraftFiltersChange}
    />
    <GridContainer
      table={table}
      properties={base.properties}
      onCellUpdate={handleCellUpdate}
      onAddRow={handleAddRow}
      baseId={baseId}
      onColumnReorder={handleColumnReorder}
      onResizeEnd={handleResizeEnd}
      onRowReorder={handleRowReorder}
      hasNextPage={hasNextPage}
      isFetchingNextPage={isFetchingNextPage}
      onFetchNextPage={fetchNextPage}
    />
  </div>
);

(The <BaseToolbar> / <GridContainer> 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
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
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.