docs(base): add working plans for recent base feature work

This commit is contained in:
Philipinho
2026-04-19 02:05:34 +01:00
parent cac4774641
commit 4c0348e46a
12 changed files with 4348 additions and 0 deletions
@@ -0,0 +1,803 @@
# Base CSV Export 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.
**Goal:** Add a server-streamed CSV export for a base's rows, triggered from a download button in the base toolbar.
**Architecture:** A new `/bases/export-csv` Nest controller route writes directly to the Fastify reply as `text/csv`. The service pipes `BaseRowRepo.streamByBaseId` (the existing keyset-paginated async generator used by type-conversion / cell-gc) through `csv-stringify` in streaming mode so memory stays flat for 100k-row bases. Cells are serialized per property type by a pure helper with a pre-built property index and a per-chunk user-name map (same pattern as `base-type-conversion.task.ts`). The client fetches the endpoint as a blob and hands it to `file-saver`, mirroring `exportPage` in `page-service.ts`.
**Tech Stack:** NestJS + Fastify (server), Kysely (db), `csv-stringify` (stream CSV encoder), React + Mantine (client), `file-saver` (download).
**Scope (v1):**
- Exports **all live rows** of the base (ignoring current view's filter / sort / column visibility). View-scoped export is a later follow-up.
- All properties (including hidden + system) in property-position order. Primary property first.
- Permission: same as reading the base (`SpaceCaslAction.Read` on `SpaceCaslSubject.Base`).
- UTF-8, RFC 4180 CSV, with BOM for Excel compatibility.
- Filename: `{sanitize(base.name)}.csv`.
**Non-goals (v1):** row selection export, filtered/sorted export, choosing columns, alternative formats (xlsx), background jobs. Only the current synchronous streamed export.
---
## File Structure
**New files:**
- `apps/server/src/core/base/export/cell-csv-serializer.ts` — pure per-type cell → string function.
- `apps/server/src/core/base/export/cell-csv-serializer.spec.ts` — unit tests for the serializer.
- `apps/server/src/core/base/services/base-csv-export.service.ts` — orchestrates streaming from repo → CSV stringifier → Fastify reply. Builds per-chunk user-name map for PERSON / LAST_EDITED_BY.
- `apps/server/src/core/base/dto/export-base.dto.ts``{ baseId: string }` DTO.
**Modified files:**
- `apps/server/src/core/base/controllers/base.controller.ts` — new `POST /bases/export-csv` handler (returns `FastifyReply`).
- `apps/server/src/core/base/base.module.ts` — register `BaseCsvExportService` as provider.
- `apps/server/package.json` — add `csv-stringify` dependency.
- `apps/client/src/features/base/services/base-service.ts` — new `exportBaseToCsv(baseId)` function.
- `apps/client/src/features/base/components/base-toolbar.tsx` — new `IconDownload` button that calls the export service.
---
## Task 1: Add `csv-stringify` dependency
**Files:**
- Modify: `apps/server/package.json`
- [ ] **Step 1: Install the dependency**
Run from repo root:
```bash
pnpm --filter server add csv-stringify@^6
```
Expected: `csv-stringify` appears under `dependencies` in `apps/server/package.json` (latest v6 or newer).
- [ ] **Step 2: Verify server still builds**
```bash
pnpm nx run server:build
```
Expected: build succeeds.
- [ ] **Step 3: Commit**
```bash
git add apps/server/package.json pnpm-lock.yaml
git commit -m "chore(server): add csv-stringify dependency"
```
---
## Task 2: Pure cell-to-CSV serializer — failing test first
**Files:**
- Create: `apps/server/src/core/base/export/cell-csv-serializer.spec.ts`
Contract the serializer must satisfy:
| Property type | Input | Output |
|---|---|---|
| `text`, `url`, `email` | `"hi"` | `"hi"` |
| `number` | `42` | `"42"` |
| `checkbox` | `true` / `false` / `null` | `"true"` / `"false"` / `""` |
| `date` | `"2026-04-18T12:00:00Z"` | same ISO string |
| `select` / `status` | choice-uuid | choice name from `typeOptions.choices` |
| `multiSelect` | `[uuid1, uuid2]` | `"Name 1; Name 2"` (in given order) |
| `person` | `uuid` or `[uuid, ...]` | `"Alice; Bob"` from `userNames` map; fallback to `""` when missing |
| `file` | `[{fileName: "a.pdf"}, {fileName: "b.png"}]` | `"a.pdf; b.png"` |
| `createdAt` | `row.createdAt` (ISO) | same |
| `lastEditedAt` | `row.updatedAt` (ISO) | same |
| `lastEditedBy` | `row.lastUpdatedById` | resolved name from map or `""` |
| any | `null` / `undefined` | `""` |
- [ ] **Step 1: Write the failing spec**
Create `apps/server/src/core/base/export/cell-csv-serializer.spec.ts`:
```ts
import { serializeCellForCsv } from './cell-csv-serializer';
import { BasePropertyType } from '../base.schemas';
const p = (type: string, typeOptions: unknown = {}) => ({
id: 'prop-1',
type: type as any,
typeOptions,
});
describe('serializeCellForCsv', () => {
const userNames = new Map([
['u1', 'Alice'],
['u2', 'Bob'],
]);
it('returns empty string for null/undefined', () => {
expect(serializeCellForCsv(p(BasePropertyType.TEXT), null, {})).toBe('');
expect(serializeCellForCsv(p(BasePropertyType.NUMBER), undefined, {})).toBe('');
});
it('stringifies text/url/email as-is', () => {
expect(serializeCellForCsv(p(BasePropertyType.TEXT), 'hi', {})).toBe('hi');
expect(serializeCellForCsv(p(BasePropertyType.URL), 'https://x', {})).toBe('https://x');
expect(serializeCellForCsv(p(BasePropertyType.EMAIL), 'a@b.com', {})).toBe('a@b.com');
});
it('stringifies number', () => {
expect(serializeCellForCsv(p(BasePropertyType.NUMBER), 42, {})).toBe('42');
expect(serializeCellForCsv(p(BasePropertyType.NUMBER), 0, {})).toBe('0');
});
it('renders checkbox as true/false', () => {
expect(serializeCellForCsv(p(BasePropertyType.CHECKBOX), true, {})).toBe('true');
expect(serializeCellForCsv(p(BasePropertyType.CHECKBOX), false, {})).toBe('false');
});
it('resolves select/status choice name', () => {
const prop = p(BasePropertyType.SELECT, {
choices: [
{ id: 'c1', name: 'Red', color: 'red' },
{ id: 'c2', name: 'Green', color: 'green' },
],
});
expect(serializeCellForCsv(prop, 'c1', {})).toBe('Red');
expect(serializeCellForCsv(prop, 'unknown', {})).toBe('');
});
it('joins multiSelect names with "; " preserving order', () => {
const prop = p(BasePropertyType.MULTI_SELECT, {
choices: [
{ id: 'c1', name: 'A', color: 'red' },
{ id: 'c2', name: 'B', color: 'blue' },
],
});
expect(serializeCellForCsv(prop, ['c2', 'c1'], {})).toBe('B; A');
});
it('resolves person scalar and array', () => {
const prop = p(BasePropertyType.PERSON);
expect(serializeCellForCsv(prop, 'u1', { userNames })).toBe('Alice');
expect(serializeCellForCsv(prop, ['u1', 'u2', 'missing'], { userNames })).toBe(
'Alice; Bob',
);
});
it('joins file names from cell payload', () => {
const prop = p(BasePropertyType.FILE);
expect(
serializeCellForCsv(
prop,
[
{ id: 'f1', fileName: 'a.pdf' },
{ id: 'f2', fileName: 'b.png' },
],
{},
),
).toBe('a.pdf; b.png');
});
it('dates pass through as ISO strings', () => {
const iso = '2026-04-18T12:00:00.000Z';
expect(serializeCellForCsv(p(BasePropertyType.DATE), iso, {})).toBe(iso);
});
it('lastEditedBy resolves via userNames', () => {
const prop = p(BasePropertyType.LAST_EDITED_BY);
expect(serializeCellForCsv(prop, 'u2', { userNames })).toBe('Bob');
expect(serializeCellForCsv(prop, 'missing', { userNames })).toBe('');
});
});
```
- [ ] **Step 2: Run spec — verify it fails**
```bash
pnpm --filter server test -- cell-csv-serializer.spec
```
Expected: FAIL — `Cannot find module './cell-csv-serializer'`.
---
## Task 3: Implement `cell-csv-serializer.ts`
**Files:**
- Create: `apps/server/src/core/base/export/cell-csv-serializer.ts`
- [ ] **Step 1: Implement the serializer**
```ts
import { BasePropertyType, BasePropertyTypeValue } from '../base.schemas';
export type CellCsvContext = {
userNames?: Map<string, string>;
};
type PropertyLike = {
id: string;
type: BasePropertyTypeValue | string;
typeOptions?: unknown;
};
function resolveChoiceName(typeOptions: unknown, id: unknown): string {
if (!typeOptions || typeof typeOptions !== 'object') return '';
const choices = (typeOptions as any).choices;
if (!Array.isArray(choices)) return '';
const match = choices.find((c: any) => c?.id === id);
return typeof match?.name === 'string' ? match.name : '';
}
function resolveUser(id: unknown, ctx: CellCsvContext): string {
if (typeof id !== 'string') return '';
return ctx.userNames?.get(id) ?? '';
}
export function serializeCellForCsv(
property: PropertyLike,
value: unknown,
ctx: CellCsvContext,
): string {
if (value === null || value === undefined) return '';
switch (property.type) {
case BasePropertyType.TEXT:
case BasePropertyType.URL:
case BasePropertyType.EMAIL:
return String(value);
case BasePropertyType.NUMBER:
return typeof value === 'number' ? String(value) : String(value ?? '');
case BasePropertyType.CHECKBOX:
return value === true ? 'true' : 'false';
case BasePropertyType.DATE:
case BasePropertyType.CREATED_AT:
case BasePropertyType.LAST_EDITED_AT:
if (value instanceof Date) return value.toISOString();
return String(value);
case BasePropertyType.SELECT:
case BasePropertyType.STATUS:
return resolveChoiceName(property.typeOptions, value);
case BasePropertyType.MULTI_SELECT:
if (!Array.isArray(value)) return '';
return value
.map((v) => resolveChoiceName(property.typeOptions, v))
.filter((s) => s.length > 0)
.join('; ');
case BasePropertyType.PERSON: {
const ids = Array.isArray(value) ? value : [value];
return ids
.map((id) => resolveUser(id, ctx))
.filter((s) => s.length > 0)
.join('; ');
}
case BasePropertyType.FILE:
if (!Array.isArray(value)) return '';
return value
.map((f: any) =>
f && typeof f === 'object' && typeof f.fileName === 'string'
? f.fileName
: '',
)
.filter((s) => s.length > 0)
.join('; ');
case BasePropertyType.LAST_EDITED_BY:
return resolveUser(value, ctx);
default:
return typeof value === 'object' ? JSON.stringify(value) : String(value);
}
}
```
- [ ] **Step 2: Run spec — verify it passes**
```bash
pnpm --filter server test -- cell-csv-serializer.spec
```
Expected: PASS (11 tests).
- [ ] **Step 3: Commit**
```bash
git add apps/server/src/core/base/export/cell-csv-serializer.ts apps/server/src/core/base/export/cell-csv-serializer.spec.ts
git commit -m "feat(base): add csv cell serializer with per-type rules"
```
---
## Task 4: Export DTO
**Files:**
- Create: `apps/server/src/core/base/dto/export-base.dto.ts`
- [ ] **Step 1: Write the DTO**
```ts
import { IsUUID } from 'class-validator';
export class ExportBaseCsvDto {
@IsUUID()
baseId: string;
}
```
- [ ] **Step 2: Commit**
```bash
git add apps/server/src/core/base/dto/export-base.dto.ts
git commit -m "feat(base): add export base csv dto"
```
---
## Task 5: `BaseCsvExportService` — streams rows through `csv-stringify`
**Files:**
- Create: `apps/server/src/core/base/services/base-csv-export.service.ts`
**Design notes:**
- Sync values are pushed to a `csv-stringify` `Stringifier` stream. The stream is `pipe`d to `FastifyReply.raw` (Fastify's underlying Node http response).
- Per chunk (from `streamByBaseId`, chunkSize 1000):
1. Collect all user IDs referenced by PERSON cells + all `lastUpdatedById` values.
2. One `SELECT id, name, email FROM users WHERE id IN (...)` per chunk. Build `Map<userId, displayName>`.
3. For each row, for each property in order, run `serializeCellForCsv`. `push` the record array into the stringifier.
- Header row: property names in property-position order. Primary property is first because properties are already sorted by `position`.
- Column for system types (`createdAt` / `lastEditedAt` / `lastEditedBy`): value pulled from the row column, not cells.
- BOM: write `\ufeff` to the raw socket before the stream pipe so Excel auto-detects UTF-8.
- [ ] **Step 1: Implement the service**
```ts
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { BaseRepo } from '@docmost/db/repos/base/base.repo';
import { BasePropertyRepo } from '@docmost/db/repos/base/base-property.repo';
import { BaseRowRepo } from '@docmost/db/repos/base/base-row.repo';
import { stringify } from 'csv-stringify';
import { FastifyReply } from 'fastify';
import { PassThrough } from 'node:stream';
import { sanitize } from 'sanitize-filename-ts';
import {
BasePropertyType,
BasePropertyTypeValue,
} from '../base.schemas';
import {
CellCsvContext,
serializeCellForCsv,
} from '../export/cell-csv-serializer';
const CHUNK_SIZE = 1000;
@Injectable()
export class BaseCsvExportService {
private readonly logger = new Logger(BaseCsvExportService.name);
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly baseRepo: BaseRepo,
private readonly basePropertyRepo: BasePropertyRepo,
private readonly baseRowRepo: BaseRowRepo,
) {}
async streamBaseAsCsv(
baseId: string,
workspaceId: string,
reply: FastifyReply,
): Promise<void> {
const base = await this.baseRepo.findById(baseId);
if (!base || base.workspaceId !== workspaceId) {
throw new NotFoundException('Base not found');
}
const properties = await this.basePropertyRepo.findByBaseId(baseId);
const fileName = sanitize(base.name || 'base') + '.csv';
const stringifier = stringify({
header: true,
columns: properties.map((p) => ({ key: p.id, header: p.name })),
});
// Prepend UTF-8 BOM so Excel auto-detects encoding, then pipe the
// CSV stream through. Using a PassThrough instead of `reply.raw`
// keeps us inside Fastify's managed reply lifecycle — backpressure
// is handled by the pipe, matching the existing `/spaces/export`
// pattern (stream handed to `res.send`).
const out = new PassThrough();
out.write('\ufeff');
stringifier.on('error', (err) => {
this.logger.error('csv stringifier error', err);
out.destroy(err);
});
stringifier.pipe(out);
reply.headers({
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition':
'attachment; filename="' + encodeURIComponent(fileName) + '"',
});
reply.send(out);
try {
for await (const chunk of this.baseRowRepo.streamByBaseId(baseId, {
workspaceId,
chunkSize: CHUNK_SIZE,
})) {
const ctx = await this.buildCtx(chunk, properties);
for (const row of chunk) {
const record: Record<string, string> = {};
const cells = (row.cells ?? {}) as Record<string, unknown>;
for (const prop of properties) {
const type = prop.type as BasePropertyTypeValue;
let value: unknown;
if (type === BasePropertyType.CREATED_AT) {
value = row.createdAt;
} else if (type === BasePropertyType.LAST_EDITED_AT) {
value = row.updatedAt;
} else if (type === BasePropertyType.LAST_EDITED_BY) {
value = row.lastUpdatedById;
} else {
value = cells[prop.id];
}
record[prop.id] = serializeCellForCsv(prop, value, ctx);
}
// Pipe handles backpressure internally, but honor the
// stringifier's `write() === false` to avoid unbounded
// internal buffering on very large bases.
if (!stringifier.write(record)) {
await new Promise<void>((resolve) =>
stringifier.once('drain', resolve),
);
}
}
}
stringifier.end();
} catch (err) {
this.logger.error(`csv export failed base=${baseId}`, err);
stringifier.destroy(err as Error);
throw err;
}
}
private async buildCtx(
chunk: Array<{ cells: unknown; lastUpdatedById: string | null }>,
properties: Array<{ id: string; type: string }>,
): Promise<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);
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) return {};
const rows = await this.db
.selectFrom('users')
.select(['id', 'name', 'email'])
.where('id', 'in', Array.from(userIds))
.execute();
return {
userNames: new Map(rows.map((u) => [u.id, u.name || u.email || ''])),
};
}
}
```
- [ ] **Step 2: Commit**
```bash
git add apps/server/src/core/base/services/base-csv-export.service.ts
git commit -m "feat(base): add streaming csv export service"
```
---
## Task 6: Register service in module
**Files:**
- Modify: `apps/server/src/core/base/base.module.ts`
- [ ] **Step 1: Add `BaseCsvExportService` to providers**
Add import:
```ts
import { BaseCsvExportService } from './services/base-csv-export.service';
```
Add to `providers` array (no need to export — only the controller uses it).
- [ ] **Step 2: Commit**
```bash
git add apps/server/src/core/base/base.module.ts
git commit -m "feat(base): register csv export service in module"
```
---
## Task 7: Controller route `POST /bases/export-csv`
**Files:**
- Modify: `apps/server/src/core/base/controllers/base.controller.ts`
- [ ] **Step 1: Add handler**
Follow the exact precedent in `apps/server/src/integrations/export/export.controller.ts:47-109` (Fastify reply injection + permission check pattern) and the Read permission check pattern from the existing `info` handler in this controller.
```ts
// Add to constructor args:
private readonly baseCsvExportService: BaseCsvExportService,
// Imports:
import { FastifyReply } from 'fastify';
import { Res } from '@nestjs/common';
import { ExportBaseCsvDto } from '../dto/export-base.dto';
import { BaseCsvExportService } from '../services/base-csv-export.service';
import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
import { Workspace } from '@docmost/db/types/entity.types';
// Handler:
@HttpCode(HttpStatus.OK)
@Post('export-csv')
async exportCsv(
@Body() dto: ExportBaseCsvDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@Res() res: FastifyReply,
) {
const base = await this.baseRepo.findById(dto.baseId);
if (!base) {
throw new NotFoundException('Base not found');
}
const ability = await this.spaceAbility.createForUser(user, base.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Base)) {
throw new ForbiddenException();
}
await this.baseCsvExportService.streamBaseAsCsv(
dto.baseId,
workspace.id,
res,
);
}
```
- [ ] **Step 2: Build to verify wiring**
```bash
pnpm nx run server:build
```
Expected: build succeeds.
- [ ] **Step 3: Commit**
```bash
git add apps/server/src/core/base/controllers/base.controller.ts
git commit -m "feat(base): add csv export http endpoint"
```
---
## Task 8: Manual server smoke test — USER-DRIVEN
> ⚠️ **Do not run `pnpm dev` as an agent.** Per `CLAUDE.md`, the agent builds but does not launch. Hand off to the user with the instructions below; resume the plan at Task 9 after the user confirms the curl succeeds.
- [ ] **Step 1: Ask the user to start the dev servers (`pnpm dev`) and open the app**
- [ ] **Step 2: Ask the user to run this curl, replacing `<BASE_ID>` and `<TOKEN>`**
Get a session cookie by logging into the client at http://localhost:3000, then grab `authToken` from DevTools → Application → Cookies.
```bash
curl -v -X POST http://localhost:3001/api/bases/export-csv \
-H "Content-Type: application/json" \
-H "Cookie: authToken=<TOKEN>" \
-d '{"baseId": "<BASE_ID>"}' \
--output /tmp/base-export.csv
head -5 /tmp/base-export.csv
wc -l /tmp/base-export.csv
```
Expected:
- `Content-Disposition: attachment; filename="..."` in response headers.
- First bytes of the file are the UTF-8 BOM (`efbbbf`) — check with `xxd /tmp/base-export.csv | head -1`.
- Header row contains property names.
- Line count ≈ live row count + 1.
- Opens cleanly in `less /tmp/base-export.csv` (no JSON blobs, no raw UUIDs for select / person / file).
If the base has a PERSON column, confirm it renders the user's name, not a UUID.
---
## Task 9: Client service function
**Files:**
- Modify: `apps/client/src/features/base/services/base-service.ts`
- [ ] **Step 1: Add `exportBaseToCsv`**
Mirror `exportPage` in `apps/client/src/features/page/services/page-service.ts:116-135`:
```ts
import { saveAs } from "file-saver";
export async function exportBaseToCsv(baseId: string): Promise<void> {
const req = await api.post(
"/bases/export-csv",
{ baseId },
{ responseType: "blob" },
);
const header = req?.headers["content-disposition"] ?? "";
const match = header.match(/filename="?([^"]+)"?/);
let fileName = match ? match[1] : "base.csv";
try {
fileName = decodeURIComponent(fileName);
} catch {
// fallback to raw filename
}
saveAs(req.data, fileName);
}
```
- [ ] **Step 2: Commit**
```bash
git add apps/client/src/features/base/services/base-service.ts
git commit -m "feat(base): add client csv export service call"
```
---
## Task 10: Toolbar export button
**Files:**
- Modify: `apps/client/src/features/base/components/base-toolbar.tsx`
- [ ] **Step 1: Wire the button**
Add imports:
```tsx
import { IconDownload } from "@tabler/icons-react";
import { notifications } from "@mantine/notifications";
import { exportBaseToCsv } from "@/features/base/services/base-service";
```
Add a handler inside the component:
```tsx
const [exporting, setExporting] = useState(false);
const handleExport = useCallback(async () => {
if (exporting) return;
setExporting(true);
try {
await exportBaseToCsv(base.id);
} catch (err) {
notifications.show({
color: "red",
message: t("Failed to export CSV"),
});
} finally {
setExporting(false);
}
}, [base.id, exporting, t]);
```
Insert the button inside `<div className={classes.toolbarRight}>` — place it before the filter/sort/fields group (leftmost of the right cluster):
```tsx
<Tooltip label={t("Export CSV")}>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
loading={exporting}
onClick={handleExport}
>
<IconDownload size={16} />
</ActionIcon>
</Tooltip>
```
- [ ] **Step 2: Build client**
```bash
pnpm nx run client:build
```
Expected: build succeeds.
- [ ] **Step 3: Commit**
```bash
git add apps/client/src/features/base/components/base-toolbar.tsx
git commit -m "feat(base): add csv export button to base toolbar"
```
---
## Task 11: End-to-end UI smoke test — USER-DRIVEN
> ⚠️ **Do not run `pnpm dev` as an agent.** Ask the user to verify in their own browser session; resume at Task 12 after confirmation.
- [ ] **Step 1: Ask the user to open a base in the browser (dev server already running from Task 8)**
Navigate to a base with ≥ 1 row of each property type (or create cells manually: text, select, multi-select, person, file, checkbox, number, date).
- [ ] **Step 2: Click the download icon in the toolbar**
Expected:
- Button shows loading spinner briefly.
- Browser downloads a `.csv` named after the base.
- CSV opens in Excel / Numbers / Google Sheets with correct column headers.
- Select, multi-select, person, and file columns render names (not UUIDs).
- No console errors in either tab.
- [ ] **Step 3: Test an empty base**
Open a brand-new base (only primary property, no rows). Click export.
Expected: file contains just the header row + BOM. No error.
- [ ] **Step 4: Test permission**
As a user without access to the space, hit the endpoint (or simulate by setting user in session). Expected: 403.
---
## Task 12: Final commit + handoff
- [ ] **Step 1: Verify branch is clean and all commits are on the branch**
```bash
git status
git log --oneline main..HEAD
```
Expected: clean working tree, 8 commits (Tasks 1, 3, 4, 5, 6, 7, 9, 10).
- [ ] **Step 2: Open the `superpowers:finishing-a-development-branch` skill to decide on merge/PR**
---
## Follow-ups (out of scope for v1)
- Export respecting current view (filter / sort / column visibility / column order).
- Export only selected rows (wired into `selection-action-bar.tsx`).
- Format-aware number rendering (currency, percent, progress).
- Configurable date format (respect view/base locale).
- Large-export BullMQ job + email download link for very large bases.
- Alternative formats: JSON, XLSX.
@@ -0,0 +1,342 @@
# Base Table Skeleton Loading State 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.
**Goal:** Replace the centered Mantine `<Loader>` that currently renders while a base is loading with a layout-matching skeleton of the toolbar + grid built from Mantine `<Skeleton>` shimmers, so there is no layout shift when real data lands.
**Architecture:** A new self-contained component that renders the same DOM skeleton as the real table (toolbar row + header row + N body rows) using Mantine's `Skeleton` primitive, styled with the existing grid CSS module so tracks and heights match 1:1. `BaseTable` swaps its loading branch from `<Loader>` to `<BaseTableSkeleton />`.
**Tech Stack:** React 18, Mantine v8 `Skeleton`, existing CSS module (`grid.module.css`).
---
## Background
Current loading branch in [`base-table.tsx:157-163`](apps/client/src/features/base/components/base-table.tsx:157):
```tsx
if (baseLoading || rowsLoading) {
return (
<div className={classes.loadingOverlay}>
<Loader size="md" />
</div>
);
}
```
`.loadingOverlay` ([`grid.module.css:290-295`](apps/client/src/features/base/styles/grid.module.css:290)) is a centered flex container. Only used here.
Real table structure (for reference so the skeleton matches):
- **Toolbar row** — view tabs on the left (each is a ~32px-wide pill), four `ActionIcon`s on the right (16px icons).
- **Header row** — subgrid. Pinned row-number column (64px). Primary column pinned. Each header cell is 34px tall, has a 14px type icon, and a short property-name label.
- **Body rows** — subgrid, 36px min-height, cells separated by 1px borders.
Matching the real layout 1:1 means:
- Same `display: grid` + `grid-template-columns` on the outer container.
- Same `.headerRow` / `.row` / `.cell` classes from `grid.module.css` so padding, borders, and heights line up.
- When the real data lands, the only visual change is `<Skeleton>` → real content — no reflow, no column-width jump.
**Skeleton dimensions (tuned for a neutral default, since we don't yet know the view's column widths):**
- 6 columns, 180px each (matches `DEFAULT_COLUMN_WIDTH` in [`use-base-table.ts:25`](apps/client/src/features/base/hooks/use-base-table.ts:25)).
- Row-number column: 64px (matches `ROW_NUMBER_COLUMN_WIDTH`).
- 10 body rows.
- Toolbar: 3 view tab pills (44px each), 4 action icons (22px each).
Varying the per-cell skeleton width within each column (between ~50% and ~85% of the cell width) adds realism — otherwise every cell skeleton is identical and screams "fake".
---
## File Structure
**New files:**
- `apps/client/src/features/base/components/base-table-skeleton.tsx` — the skeleton component.
- `apps/client/src/features/base/styles/base-table-skeleton.module.css` — minimal additional styles (the skeleton cell wrapper needs width-constrained `<Skeleton>` children that center vertically in the 36px cell).
**Modified files:**
- `apps/client/src/features/base/components/base-table.tsx` — swap the loading branch to render `<BaseTableSkeleton />`; drop the now-unused `Loader` import.
- `apps/client/src/features/base/styles/grid.module.css` — remove `.loadingOverlay` (dead).
No new deps — `Skeleton` is already exported from `@mantine/core`.
---
## Task 1: Build the skeleton component
**Files:**
- Create: `apps/client/src/features/base/components/base-table-skeleton.tsx`
- Create: `apps/client/src/features/base/styles/base-table-skeleton.module.css`
- [ ] **Step 1: Create the CSS module**
`apps/client/src/features/base/styles/base-table-skeleton.module.css`:
```css
.toolbar {
display: flex;
align-items: center;
gap: var(--mantine-spacing-xs);
padding: var(--mantine-spacing-xs) 0;
margin-bottom: var(--mantine-spacing-xs);
}
.toolbarTabs {
display: flex;
gap: 6px;
flex: 1;
}
.toolbarActions {
display: flex;
gap: var(--mantine-spacing-xs);
margin-left: auto;
}
.gridWrapper {
overflow: hidden;
flex: 1;
border-top: 1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
}
.grid {
display: grid;
}
.cellInner {
display: flex;
align-items: center;
height: 100%;
width: 100%;
padding: 0 8px;
}
.headerCellInner {
display: flex;
align-items: center;
gap: 6px;
height: 100%;
width: 100%;
padding: 0 8px;
}
```
- [ ] **Step 2: Create the skeleton component**
`apps/client/src/features/base/components/base-table-skeleton.tsx`:
```tsx
import { Skeleton } from "@mantine/core";
import gridClasses from "@/features/base/styles/grid.module.css";
import classes from "@/features/base/styles/base-table-skeleton.module.css";
const ROW_NUMBER_WIDTH = 64;
const COLUMN_WIDTH = 180;
const COLUMN_COUNT = 6;
const ROW_COUNT = 10;
// Pseudo-random but deterministic widths so the skeleton doesn't flicker
// between renders. Values are a rough normal distribution around
// 60-85 % of the cell width.
const CELL_WIDTH_RATIOS = [0.78, 0.62, 0.84, 0.55, 0.71, 0.66];
const HEADER_WIDTH_RATIOS = [0.42, 0.58, 0.5, 0.64, 0.46, 0.54];
export function BaseTableSkeleton() {
const gridTemplateColumns = [
`${ROW_NUMBER_WIDTH}px`,
...Array.from({ length: COLUMN_COUNT }, () => `${COLUMN_WIDTH}px`),
].join(" ");
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div className={classes.toolbar}>
<div className={classes.toolbarTabs}>
<Skeleton height={22} width={44} radius="sm" />
<Skeleton height={22} width={64} radius="sm" />
<Skeleton height={22} width={48} radius="sm" />
</div>
<div className={classes.toolbarActions}>
<Skeleton height={22} width={22} circle />
<Skeleton height={22} width={22} circle />
<Skeleton height={22} width={22} circle />
<Skeleton height={22} width={22} circle />
</div>
</div>
<div className={classes.gridWrapper}>
<div className={classes.grid} style={{ gridTemplateColumns }}>
{/* Header row */}
<div className={gridClasses.headerCell}>
<div className={classes.headerCellInner}>
<Skeleton height={14} width={14} circle />
</div>
</div>
{Array.from({ length: COLUMN_COUNT }).map((_, colIndex) => (
<div key={`h-${colIndex}`} className={gridClasses.headerCell}>
<div className={classes.headerCellInner}>
<Skeleton height={14} width={14} circle />
<Skeleton
height={10}
width={`${HEADER_WIDTH_RATIOS[colIndex] * 100}%`}
radius="sm"
/>
</div>
</div>
))}
{/* Body rows */}
{Array.from({ length: ROW_COUNT }).map((_, rowIndex) => (
<div
key={`row-${rowIndex}`}
style={{ display: "contents" }}
>
<div className={gridClasses.cell}>
<div className={classes.cellInner}>
<Skeleton height={10} width={18} radius="sm" />
</div>
</div>
{Array.from({ length: COLUMN_COUNT }).map((_, colIndex) => (
<div
key={`cell-${rowIndex}-${colIndex}`}
className={gridClasses.cell}
>
<div className={classes.cellInner}>
<Skeleton
height={10}
width={`${CELL_WIDTH_RATIOS[(rowIndex + colIndex) % CELL_WIDTH_RATIOS.length] * 100}%`}
radius="sm"
/>
</div>
</div>
))}
</div>
))}
</div>
</div>
</div>
);
}
```
Key points the implementer must not change:
- `gridClasses.headerCell` and `gridClasses.cell` come from the REAL table's CSS module so borders, heights, and hover semantics match exactly. Don't reinvent them.
- The `style={{ display: "contents" }}` row wrapper is intentional — the outer `.grid` is a single CSS grid, and each "row" is just a flattened sequence of cells that span the grid columns via `display: contents`. This mirrors how the real table flattens rows (see `.row` with `grid-column: 1 / -1; grid-template-columns: subgrid;` in [`grid.module.css:119-123`](apps/client/src/features/base/styles/grid.module.css:119)). We use `contents` instead of subgrid because the skeleton's outer grid is not a subgrid.
- Using `Skeleton` with `circle` prop for the row-number placeholder and type-icon placeholders — these match the real UI's round/small icon presence.
- The `CELL_WIDTH_RATIOS[(rowIndex + colIndex) % ...]` gives each cell a deterministic-but-varied skeleton width so it doesn't look like a stamped pattern.
- [ ] **Step 3: Build to verify TypeScript compiles**
```bash
pnpm nx run client:build
```
Expected: success.
- [ ] **Step 4: Commit**
```bash
git add \
apps/client/src/features/base/components/base-table-skeleton.tsx \
apps/client/src/features/base/styles/base-table-skeleton.module.css
git commit -m "feat(base): add layout-matching skeleton loading component"
```
---
## Task 2: Swap the loader for the skeleton in BaseTable
**Files:**
- Modify: `apps/client/src/features/base/components/base-table.tsx`
- Modify: `apps/client/src/features/base/styles/grid.module.css`
- [ ] **Step 1: Replace the loading branch**
In `base-table.tsx`:
Drop `Loader` from the `@mantine/core` import (line 2). Leave `Text` and `Stack` — they're still used by the error branch.
Add near the other imports:
```tsx
import { BaseTableSkeleton } from "@/features/base/components/base-table-skeleton";
```
Change lines 157-163:
Before:
```tsx
if (baseLoading || rowsLoading) {
return (
<div className={classes.loadingOverlay}>
<Loader size="md" />
</div>
);
}
```
After:
```tsx
if (baseLoading || rowsLoading) {
return <BaseTableSkeleton />;
}
```
- [ ] **Step 2: Remove the dead `.loadingOverlay` class**
In `apps/client/src/features/base/styles/grid.module.css`, delete lines 290-295 (the `.loadingOverlay { ... }` block — exact content):
```css
.loadingOverlay {
display: flex;
align-items: center;
justify-content: center;
padding: var(--mantine-spacing-xl);
}
```
- [ ] **Step 3: Build**
```bash
pnpm nx run client:build
```
Expected: success with no "unused" warnings from the removed class.
- [ ] **Step 4: Commit**
```bash
git add \
apps/client/src/features/base/components/base-table.tsx \
apps/client/src/features/base/styles/grid.module.css
git commit -m "feat(base): show table skeleton instead of centered loader on load"
```
---
## Task 3: USER smoke test
> ⚠️ **Do not run `pnpm dev` as an agent.** Hand off.
Ask the user to:
- [ ] **Fresh load.** Open a base fresh (full tab reload). The skeleton should render immediately, then transition cleanly to the real table. No jarring jump, no centered spinner.
- [ ] **Throttled load.** DevTools → Network tab → throttle to "Slow 3G" → reload. The skeleton should stay visible for the duration of the slow request, shimmer visible the whole time.
- [ ] **Dark mode.** Toggle to dark mode. Skeleton colors should render appropriately (Mantine's `Skeleton` handles this automatically via light-dark tokens).
- [ ] **Window resize during load.** Resize the browser window while the skeleton is showing. Skeleton's CSS grid should stretch the columns proportionally — no layout break.
- [ ] **Error state still works.** Hard to trigger; if you can, disable network entirely and reload. You should see the existing "Failed to load base" message, NOT the skeleton stuck forever.
- [ ] **No console errors / CSS warnings during transition from skeleton → real table.**
If all pass, the swap is done.
---
## Out of scope
- Matching the exact column count and widths the view ends up rendering. The skeleton is a neutral placeholder; a perfect match would require knowing the view config, which we don't have before the base query resolves. A 6-column, 180px default is "close enough to not flash".
- Skeleton for `GridContainer` inside an already-loaded base (e.g., when switching views of the same base, where we already have properties). Out of scope — this plan only addresses the initial load path.
- Progressive hydration (render the toolbar first, then skeleton rows as they stream in). Overkill for a small base query.
@@ -0,0 +1,776 @@
# Base Cell Dropdown Keyboard Navigation 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.
**Goal:** Add keyboard navigation (ArrowUp/Down/Home/End/Enter) to all four base cell dropdowns — `CellPerson`, `CellSelect`, `CellMultiSelect`, `CellStatus` — so users can pick values without a mouse, matching Mantine `MultiSelect`'s keyboard UX.
**Architecture:** All four cells use the same custom `Popover` + HTML dropdown pattern (not `useCombobox`). Instead of editing each in isolation, factor the shared logic (activeIndex, arrow/Home/End handling, reset-on-filter, scroll-into-view, option ref tracking) into one hook `useListKeyboardNav`. Each cell flattens its visible items into a single linear list (including the "Add option" row for select/multi, and flattening across status categories), passes the count to the hook, and wires up Enter selection locally.
**Tech Stack:** React 18, TypeScript, Mantine v8 `Popover` / `TextInput`, CSS Modules.
---
## Scope
**In scope:**
- `cell-person.tsx` — members, tag input with Backspace-removes-tag behavior.
- `cell-select.tsx` — single choice, optional "Add option" row.
- `cell-multi-select.tsx` — multi choice, optional "Add option" row.
- `cell-status.tsx` — single choice, grouped by category (flattened for nav).
- Shared hook `use-list-keyboard-nav.ts`.
- One new CSS class for keyboard-active highlight.
**Out of scope:**
- Swapping any cell to Mantine `MultiSelect` / `useCombobox` — too disruptive; all four have deliberate custom UIs.
- Automated tests — this codebase has no existing unit tests for base cells, and a harness just for this is scope creep. Task 7 is a manual QUX walkthrough.
- Other editors in the base feature (e.g., toolbar pickers, filter UIs) — out of scope unless they hit the same bug.
---
## File Structure
**Create:**
- `apps/client/src/features/base/hooks/use-list-keyboard-nav.ts` — shared hook.
**Modify:**
- `apps/client/src/features/base/styles/cells.module.css` — add `.selectOptionKeyboardActive`.
- `apps/client/src/features/base/components/cells/cell-person.tsx`
- `apps/client/src/features/base/components/cells/cell-select.tsx`
- `apps/client/src/features/base/components/cells/cell-multi-select.tsx`
- `apps/client/src/features/base/components/cells/cell-status.tsx`
**No other files touched.**
---
## Design Notes (read before coding)
### Why a hook and not copy-paste per cell
Four cells would get the same 40-line block. C-9 ("SHOULD NOT extract unless reused") is satisfied here — the logic is reused in 4 places, and diverging across them later would be a bug farm. The hook owns only the *navigation* concern (activeIndex, arrow keys, scroll). Each cell still owns its own Enter semantics, Escape, Backspace, and filter computation, because those diverge.
### Why `activeIndex: -1` initially
No highlight on open. First ArrowDown moves to 0. Enter at `-1` is a no-op — we don't guess, we don't commit. This matches Mantine MultiSelect behavior.
### Why a new CSS class instead of reusing `selectOptionActive`
`selectOptionActive` means "this item is currently selected" (blue). Keyboard nav needs a *separate* "Enter will land here" state or users can't tell which unselected option is focused. Add `selectOptionKeyboardActive` and stack it with `selectOptionActive` when both apply (selected + keyboard-focused uses a slightly darker blue).
### Flattening the nav list
- **cell-person:** `filteredMembers` (already flat).
- **cell-select / cell-multi-select:** `filteredChoices` plus one trailing "Add option" virtual entry when `showAddOption`. Arrow nav must include it; Enter on that index calls `handleAddOption()`. Represent as a discriminated union so Enter can dispatch correctly.
- **cell-status:** flatten `groups.flatMap(g => g.choices)`. Build a `Map<choiceId, flatIndex>` once per render and use it to attach refs and compute highlighting inside the grouped render loop.
### Mouse + keyboard sync
Mouse hover on an option sets `activeIndex` to that index. Prevents the "mouse hover shows A, keyboard focus is B, Enter selects B" mismatch. Applies to all four cells.
### `onMouseDown.preventDefault` on options
Without it, clicking an option can blur the input before `onClick` fires; in some browsers, Mantine's `trapFocus` + popover close sequence then cancels the selection. Mantine's `useCombobox` hides this — we don't use it, so add the guard. Applies to all four cells.
### Reset triggers
Reset `activeIndex` to `-1` whenever:
- the search string changes (filter changed → stale index)
- `isEditing` flips (reopening the editor)
- for cell-select/multi: whether the "Add option" row is visible, since that also changes the list length
Pass all three as `resetDeps` to the hook.
### Scroll into view
Dropdowns are capped at `max-height: 240px` with `overflow-y: auto` ([cells.module.css:219-222](apps/client/src/features/base/styles/cells.module.css:219)). Use `scrollIntoView({ block: "nearest" })` on the active option when `activeIndex` changes.
### Preserve existing behaviors
- **cell-person:** Escape cancels, Backspace on empty search removes last tag — keep both.
- **cell-select:** Escape cancels, Enter with `showAddOption` and no active index adds — keep the Enter-adds-when-no-nav fallback. Priority: if `activeIndex >= 0`, Enter uses that (which may itself be the add-option virtual entry). Else fall through to the existing `handleAddOption()` call if `showAddOption`.
- **cell-multi-select:** same as cell-select.
- **cell-status:** Escape cancels. No Add-option row. Enter with `activeIndex >= 0` selects.
---
## Task 1: Add keyboard-active CSS class
**Files:**
- Modify: `apps/client/src/features/base/styles/cells.module.css`
- [ ] **Step 1: Append after the existing `.selectOptionActive` rule (ending at line 240)**
```css
.selectOptionKeyboardActive {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5));
}
.selectOptionActive.selectOptionKeyboardActive {
background-color: light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8));
}
```
First rule: unselected + keyboard-focused (matches hover shade). Second: selected + keyboard-focused (slightly darker blue than plain selected, distinguishable).
- [ ] **Step 2: Commit**
```bash
git add apps/client/src/features/base/styles/cells.module.css
git commit -m "style(base): add keyboard-active option style for cell dropdowns"
```
---
## Task 2: Create the `useListKeyboardNav` hook
**Files:**
- Create: `apps/client/src/features/base/hooks/use-list-keyboard-nav.ts`
- [ ] **Step 1: Write the hook**
```tsx
import { useCallback, useEffect, useRef, useState } from "react";
type UseListKeyboardNavResult = {
activeIndex: number;
setActiveIndex: (idx: number) => void;
handleNavKey: (e: React.KeyboardEvent) => boolean;
setOptionRef: (idx: number) => (el: HTMLElement | null) => void;
};
export function useListKeyboardNav(
itemCount: number,
resetDeps: ReadonlyArray<unknown>,
): UseListKeyboardNavResult {
const [activeIndex, setActiveIndex] = useState(-1);
const optionRefs = useRef<Array<HTMLElement | null>>([]);
// Reset highlight when filter/open-state changes. resetDeps is intentional.
useEffect(() => {
setActiveIndex(-1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, resetDeps);
useEffect(() => {
if (activeIndex < 0) return;
const el = optionRefs.current[activeIndex];
if (el) el.scrollIntoView({ block: "nearest" });
}, [activeIndex]);
const setOptionRef = useCallback(
(idx: number) => (el: HTMLElement | null) => {
optionRefs.current[idx] = el;
},
[],
);
const handleNavKey = useCallback(
(e: React.KeyboardEvent): boolean => {
if (itemCount === 0) return false;
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((idx) => (idx < itemCount - 1 ? idx + 1 : 0));
return true;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((idx) => (idx <= 0 ? itemCount - 1 : idx - 1));
return true;
}
if (e.key === "Home") {
e.preventDefault();
setActiveIndex(0);
return true;
}
if (e.key === "End") {
e.preventDefault();
setActiveIndex(itemCount - 1);
return true;
}
return false;
},
[itemCount],
);
return { activeIndex, setActiveIndex, handleNavKey, setOptionRef };
}
```
Notes:
- `handleNavKey` returns `true` if it handled the key, so callers can `if (nav.handleNavKey(e)) return;` before their own Enter/Escape/Backspace branches.
- Wrap-around on both ends.
- `resetDeps` uses an eslint-disable because it's a variadic dep array by design — the hook name and the `resetDeps` argument make the intent clear without a comment inside the body beyond the one-liner. This is the C-7-approved kind of caveat comment.
- [ ] **Step 2: Build verification**
Run: `pnpm nx run client:build`.
Expected: success, no TypeScript errors.
- [ ] **Step 3: Commit**
```bash
git add apps/client/src/features/base/hooks/use-list-keyboard-nav.ts
git commit -m "feat(base): add useListKeyboardNav hook for dropdown keyboard nav"
```
---
## Task 3: Wire keyboard nav into `CellPerson`
**Files:**
- Modify: `apps/client/src/features/base/components/cells/cell-person.tsx`
- [ ] **Step 1: Import the hook**
Add to the imports at the top:
```tsx
import { useListKeyboardNav } from "@/features/base/hooks/use-list-keyboard-nav";
```
- [ ] **Step 2: Instantiate the hook**
After the `filteredMembers` declaration (currently ending line 61), add:
```tsx
const nav = useListKeyboardNav(filteredMembers.length, [search, isEditing]);
```
- [ ] **Step 3: Extend `handleKeyDown`**
Replace the existing `handleKeyDown` `useCallback` (lines 97109) with:
```tsx
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
return;
}
if (nav.handleNavKey(e)) return;
if (e.key === "Enter") {
if (nav.activeIndex < 0 || nav.activeIndex >= filteredMembers.length) return;
e.preventDefault();
handleSelect(filteredMembers[nav.activeIndex].id);
return;
}
if (e.key === "Backspace" && search === "" && personIds.length > 0) {
e.preventDefault();
handleRemove(personIds[personIds.length - 1]);
}
},
[onCancel, nav, filteredMembers, handleSelect, search, personIds, handleRemove],
);
```
- [ ] **Step 4: Render the keyboard-active highlight, ref, hover sync, and mousedown guard**
Replace the filtered-members map (currently lines 173191) with:
```tsx
{filteredMembers.map((member, idx) => {
const isSelected = selectedSet.has(member.id);
const isKeyboardActive = idx === nav.activeIndex;
const className = [
cellClasses.selectOption,
isSelected ? cellClasses.selectOptionActive : "",
isKeyboardActive ? cellClasses.selectOptionKeyboardActive : "",
]
.filter(Boolean)
.join(" ");
return (
<div
key={member.id}
ref={nav.setOptionRef(idx)}
className={className}
onMouseEnter={() => nav.setActiveIndex(idx)}
onMouseDown={(e) => {
// Keep focus on the search input so click doesn't blur + close popover.
e.preventDefault();
}}
onClick={() => handleSelect(member.id)}
>
<CustomAvatar
avatarUrl={member.avatarUrl}
name={member.name}
size={24}
radius="xl"
/>
<span className={cellClasses.personOptionName}>
{member.name}
</span>
</div>
);
})}
```
- [ ] **Step 5: Build verification**
Run: `pnpm nx run client:build`.
Expected: success.
- [ ] **Step 6: Commit**
```bash
git add apps/client/src/features/base/components/cells/cell-person.tsx
git commit -m "feat(base): keyboard navigation for person cell dropdown"
```
---
## Task 4: Wire keyboard nav into `CellSelect`
**Files:**
- Modify: `apps/client/src/features/base/components/cells/cell-select.tsx`
Recall: this cell has `filteredChoices` plus a conditional "Add option" row when `showAddOption === true`. Both must be navigable. Enter on a choice selects it; Enter on the add-option virtual entry calls `handleAddOption`.
- [ ] **Step 1: Import the hook**
```tsx
import { useListKeyboardNav } from "@/features/base/hooks/use-list-keyboard-nav";
```
- [ ] **Step 2: Build the nav item list and instantiate the hook**
After the `showAddOption` declaration (currently line 71), add:
```tsx
type NavItem =
| { kind: "choice"; choice: Choice }
| { kind: "add" };
const navItems: NavItem[] = useMemo(
() => [
...filteredChoices.map((c) => ({ kind: "choice" as const, choice: c })),
...(showAddOption ? [{ kind: "add" as const }] : []),
],
[filteredChoices, showAddOption],
);
const nav = useListKeyboardNav(navItems.length, [search, isEditing, showAddOption]);
```
- [ ] **Step 3: Replace `handleKeyDown`**
Replace the existing `handleKeyDown` `useCallback` (currently lines 98110) with:
```tsx
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
return;
}
if (nav.handleNavKey(e)) return;
if (e.key === "Enter") {
if (nav.activeIndex >= 0 && nav.activeIndex < navItems.length) {
e.preventDefault();
const item = navItems[nav.activeIndex];
if (item.kind === "choice") handleSelect(item.choice);
else handleAddOption();
return;
}
if (showAddOption) {
e.preventDefault();
handleAddOption();
}
}
},
[onCancel, nav, navItems, handleSelect, handleAddOption, showAddOption],
);
```
- [ ] **Step 4: Update the choices render loop**
Replace the `filteredChoices.map` block (currently lines 146161) with:
```tsx
{filteredChoices.map((choice, idx) => {
const isSelected = choice.id === selectedId;
const isKeyboardActive = idx === nav.activeIndex;
const className = [
cellClasses.selectOption,
isSelected ? cellClasses.selectOptionActive : "",
isKeyboardActive ? cellClasses.selectOptionKeyboardActive : "",
]
.filter(Boolean)
.join(" ");
return (
<div
key={choice.id}
ref={nav.setOptionRef(idx)}
className={className}
onMouseEnter={() => nav.setActiveIndex(idx)}
onMouseDown={(e) => {
e.preventDefault();
}}
onClick={() => handleSelect(choice)}
>
<span
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
</div>
);
})}
```
- [ ] **Step 5: Update the "Add option" row**
Replace the `showAddOption && (...)` block (currently lines 162175) with:
```tsx
{showAddOption && (() => {
const idx = filteredChoices.length;
const isKeyboardActive = idx === nav.activeIndex;
return (
<div
ref={nav.setOptionRef(idx)}
className={`${cellClasses.addOptionRow} ${
isKeyboardActive ? cellClasses.selectOptionKeyboardActive : ""
}`}
onMouseEnter={() => nav.setActiveIndex(idx)}
onMouseDown={(e) => {
e.preventDefault();
}}
onClick={handleAddOption}
>
<span className={cellClasses.addOptionLabel}>Add option:</span>
<span
className={cellClasses.badge}
style={choiceColor(addOptionColor)}
>
{trimmedSearch}
</span>
</div>
);
})()}
```
The IIFE is the least-disruptive way to introduce a local `idx` binding without restructuring the parent JSX.
- [ ] **Step 6: Build verification**
Run: `pnpm nx run client:build`.
Expected: success.
- [ ] **Step 7: Commit**
```bash
git add apps/client/src/features/base/components/cells/cell-select.tsx
git commit -m "feat(base): keyboard navigation for single-select cell dropdown"
```
---
## Task 5: Wire keyboard nav into `CellMultiSelect`
**Files:**
- Modify: `apps/client/src/features/base/components/cells/cell-multi-select.tsx`
This mirrors Task 4. Only differences: `handleSelect` is named `handleToggle`, selected check uses `selectedSet.has(...)`, and `handleAddOption` commits `[...selectedIds, newChoice.id]` rather than replacing.
- [ ] **Step 1: Import the hook**
```tsx
import { useListKeyboardNav } from "@/features/base/hooks/use-list-keyboard-nav";
```
- [ ] **Step 2: Build the nav item list and instantiate the hook**
After `showAddOption` (currently line 74), add:
```tsx
type NavItem =
| { kind: "choice"; choice: Choice }
| { kind: "add" };
const navItems: NavItem[] = useMemo(
() => [
...filteredChoices.map((c) => ({ kind: "choice" as const, choice: c })),
...(showAddOption ? [{ kind: "add" as const }] : []),
],
[filteredChoices, showAddOption],
);
const nav = useListKeyboardNav(navItems.length, [search, isEditing, showAddOption]);
```
- [ ] **Step 3: Replace `handleKeyDown`**
Replace lines 102114 with:
```tsx
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
return;
}
if (nav.handleNavKey(e)) return;
if (e.key === "Enter") {
if (nav.activeIndex >= 0 && nav.activeIndex < navItems.length) {
e.preventDefault();
const item = navItems[nav.activeIndex];
if (item.kind === "choice") handleToggle(item.choice);
else handleAddOption();
return;
}
if (showAddOption) {
e.preventDefault();
handleAddOption();
}
}
},
[onCancel, nav, navItems, handleToggle, handleAddOption, showAddOption],
);
```
- [ ] **Step 4: Update the choices render loop**
Replace `filteredChoices.map(...)` (currently lines 143160) with:
```tsx
{filteredChoices.map((choice, idx) => {
const isSelected = selectedSet.has(choice.id);
const isKeyboardActive = idx === nav.activeIndex;
const className = [
cellClasses.selectOption,
isSelected ? cellClasses.selectOptionActive : "",
isKeyboardActive ? cellClasses.selectOptionKeyboardActive : "",
]
.filter(Boolean)
.join(" ");
return (
<div
key={choice.id}
ref={nav.setOptionRef(idx)}
className={className}
onMouseEnter={() => nav.setActiveIndex(idx)}
onMouseDown={(e) => {
e.preventDefault();
}}
onClick={() => handleToggle(choice)}
>
<span
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
</div>
);
})}
```
- [ ] **Step 5: Update the "Add option" row**
Replace `showAddOption && (...)` (currently lines 161174) with the same IIFE pattern as Task 4:
```tsx
{showAddOption && (() => {
const idx = filteredChoices.length;
const isKeyboardActive = idx === nav.activeIndex;
return (
<div
ref={nav.setOptionRef(idx)}
className={`${cellClasses.addOptionRow} ${
isKeyboardActive ? cellClasses.selectOptionKeyboardActive : ""
}`}
onMouseEnter={() => nav.setActiveIndex(idx)}
onMouseDown={(e) => {
e.preventDefault();
}}
onClick={handleAddOption}
>
<span className={cellClasses.addOptionLabel}>Add option:</span>
<span
className={cellClasses.badge}
style={choiceColor(addOptionColor)}
>
{trimmedSearch}
</span>
</div>
);
})()}
```
- [ ] **Step 6: Build verification**
Run: `pnpm nx run client:build`.
Expected: success.
- [ ] **Step 7: Commit**
```bash
git add apps/client/src/features/base/components/cells/cell-multi-select.tsx
git commit -m "feat(base): keyboard navigation for multi-select cell dropdown"
```
---
## Task 6: Wire keyboard nav into `CellStatus`
**Files:**
- Modify: `apps/client/src/features/base/components/cells/cell-status.tsx`
This cell renders choices grouped by category. Flatten the groups for nav indexing while keeping the grouped rendering.
- [ ] **Step 1: Import the hook**
```tsx
import { useListKeyboardNav } from "@/features/base/hooks/use-list-keyboard-nav";
```
- [ ] **Step 2: Flatten and instantiate the hook**
After the `groups` declaration (currently ending line 74), add:
```tsx
const flatChoices = useMemo(
() => groups.flatMap((g) => g.choices),
[groups],
);
const choiceIdxMap = useMemo(() => {
const m = new Map<string, number>();
flatChoices.forEach((c, i) => m.set(c.id, i));
return m;
}, [flatChoices]);
const nav = useListKeyboardNav(flatChoices.length, [search, isEditing]);
```
- [ ] **Step 3: Replace `handleKeyDown`**
Replace lines 8391 with:
```tsx
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
return;
}
if (nav.handleNavKey(e)) return;
if (e.key === "Enter") {
if (nav.activeIndex < 0 || nav.activeIndex >= flatChoices.length) return;
e.preventDefault();
handleSelect(flatChoices[nav.activeIndex]);
}
},
[onCancel, nav, flatChoices, handleSelect],
);
```
- [ ] **Step 4: Update the choice render inside groups**
Replace the inner `group.choices.map(...)` block (currently lines 132149) with:
```tsx
{group.choices.map((choice) => {
const idx = choiceIdxMap.get(choice.id) ?? -1;
const isSelected = choice.id === selectedId;
const isKeyboardActive = idx === nav.activeIndex;
const className = [
cellClasses.selectOption,
isSelected ? cellClasses.selectOptionActive : "",
isKeyboardActive ? cellClasses.selectOptionKeyboardActive : "",
]
.filter(Boolean)
.join(" ");
return (
<div
key={choice.id}
ref={nav.setOptionRef(idx)}
className={className}
onMouseEnter={() => nav.setActiveIndex(idx)}
onMouseDown={(e) => {
e.preventDefault();
}}
onClick={() => handleSelect(choice)}
>
<span
className={cellClasses.badge}
style={choiceColor(choice.color)}
>
{choice.name}
</span>
</div>
);
})}
```
- [ ] **Step 5: Build verification**
Run: `pnpm nx run client:build`.
Expected: success.
- [ ] **Step 6: Commit**
```bash
git add apps/client/src/features/base/components/cells/cell-status.tsx
git commit -m "feat(base): keyboard navigation for status cell dropdown"
```
---
## Task 7: Manual QUX verification
No automated tests exist for cell components. Verify manually in a running dev client (only if the user asks — per CLAUDE.md, do not launch the client yourself).
For **each** of the four cells (person, select, multi-select, status), walk this checklist in the base grid:
**Golden path:**
1. Click the cell → popover opens, search focused, no initial highlight.
2. ArrowDown → first option highlights.
3. Repeat ArrowDown → highlight moves; dropdown scrolls past viewport.
4. Enter on highlight → that value is selected/toggled.
5. ArrowUp from index 0 → wraps to last item.
6. ArrowDown from last → wraps to first.
**Search + keyboard:**
7. Type a partial string → list filters, highlight resets.
8. ArrowDown → lands on first *filtered* option, not a stale index.
9. Clear the search → list expands, highlight resets.
**Mouse + keyboard interplay:**
10. Hover an option with mouse → that option becomes keyboard-active.
11. Move mouse away, press ArrowDown → nav continues from hovered index.
12. Click an option → selects cleanly, popover does not flicker-close (validates `onMouseDown.preventDefault`).
**Edge cases:**
13. Empty filter result → ArrowUp/Down/Home/End/Enter are no-ops; Escape still closes; cell-person's Backspace-removes-tag still works.
14. Home / End → jump to first / last item.
15. Escape at any time → popover closes, no commit.
**Cell-specific:**
- **cell-person** (multi mode): Backspace on empty search removes the last tag (existing behavior preserved).
- **cell-person** (single mode, `allowMultiple: false`): Enter still selects; selecting an already-selected person clears it.
- **cell-select** with typed new value: the "Add option" row appears as the last navigable item; ArrowDown reaches it and Enter triggers `handleAddOption`. Enter with no active index (user typed and hasn't pressed ArrowDown) still triggers `handleAddOption` (fallback preserved).
- **cell-multi-select**: same as cell-select for add-option behavior.
- **cell-status**: navigation crosses category boundaries seamlessly (To Do → In Progress → Complete).
**Visual:**
- Selected-only → blue.
- Keyboard-focused-only → gray.
- Both → darker blue (distinguishable from plain selected).
- [ ] **Step 1: Walk the checklist per cell. If any scenario fails, fix and re-verify that cell before moving on.**
---
## Remember
- Exact file paths above; don't grep for them at edit time.
- Preserve existing Escape/Backspace/Enter-adds-new behaviors verbatim.
- Don't swap to `useCombobox` — scope creep.
- One CSS class, one hook, four cell edits — that's the whole change.
- Commit after each task (GH-1, Conventional Commits).
- No Anthropic/Claude attribution in commits (undercover mode).
@@ -0,0 +1,527 @@
# Draft-Then-Save Flow for New Sort / Filter Entries 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.
**Goal:** Clicking "Add sort" / "Add filter" no longer persists an incomplete entry to the view config; instead it opens a local draft row with Save / Cancel buttons so the user sets everything up in one go and commits once.
**Architecture:** Each popover (`ViewSortConfigPopover`, `ViewFilterConfigPopover`) gets local `draft` state. When non-null, a draft row renders at the bottom with property/direction (or property/operator/value) selects and Save/Cancel buttons. Save appends the draft to the existing `sorts` / `conditions` array via the existing `onChange` callback — a single mutation round-trip. Cancel clears the draft. Existing rows keep their current inline auto-save behavior (out of scope — they're already workable).
**Tech Stack:** React 18, Mantine v8 (`Popover`, `Select`, `Button`), existing `onChange` contract on both popovers (no changes to parents).
---
## Background
Current sort flow ([`view-sort-config.tsx:47-52`](apps/client/src/features/base/components/views/view-sort-config.tsx:47)):
```ts
const handleAdd = useCallback(() => {
const usedIds = new Set(sorts.map((s) => s.propertyId));
const available = properties.find((p) => !usedIds.has(p.id));
if (!available) return;
onChange([...sorts, { propertyId: available.id, direction: "asc" }]);
}, [sorts, properties, onChange]);
```
Clicking "Add sort" immediately calls `onChange` with a default-populated entry — that fires a mutation. The user then picks property → mutation. Picks direction → mutation. Four round-trips for one configured sort.
Filter is the same shape ([`view-filter-config.tsx:176-187`](apps/client/src/features/base/components/views/view-filter-config.tsx:176)).
## Desired behavior
1. Click "Add sort" / "Add filter" → draft row appears locally, NOT persisted yet.
2. User picks property, direction/operator, value in the draft row.
3. Click **Save** → draft is appended to `sorts` / `conditions` via `onChange` (one mutation). Draft clears.
4. Click **Cancel** → draft clears, nothing persisted.
5. While a draft is open, the "Add" button is hidden (or shown as "+ Save draft first").
6. Closing the popover (outside click / ESC) with a draft open: the draft is discarded silently. (Matches how Notion / Airtable treat incomplete-and-abandoned filter drafts.)
7. Existing entries continue to auto-save on inline edit — unchanged.
## File Structure
**Modified:**
- `apps/client/src/features/base/components/views/view-sort-config.tsx`
- `apps/client/src/features/base/components/views/view-filter-config.tsx`
No new files, no new deps, no server changes.
---
## Task 1: Sort popover — draft-then-save
**File:** `apps/client/src/features/base/components/views/view-sort-config.tsx`
### Design
Add local state:
```ts
const [draft, setDraft] = useState<ViewSortConfig | null>(null);
```
- On "Add sort" click: compute a sensible default (first unused property, `"asc"`) and call `setDraft(...)`. Do NOT call `onChange`.
- Render the draft row after the committed rows when `draft !== null`. It looks identical to a committed row but the property/direction selects bind to draft state via `setDraft`, and there are **Save** and **Cancel** buttons instead of a Trash icon.
- Save: `onChange([...sorts, draft]); setDraft(null);`
- Cancel: `setDraft(null);`
- When the popover closes (`opened` transitions `true → false`), auto-clear the draft via effect.
- Hide the "Add sort" button while a draft is open.
### Step 1: Add `useState` + `useEffect` imports
The file currently imports `useCallback` only. Replace with `useCallback, useEffect, useState`:
```ts
import { useCallback, useEffect, useState } from "react";
```
### Step 2: Import `Button`
Add `Button` to the existing `@mantine/core` import block so the draft can show Save/Cancel buttons.
### Step 3: Replace the component body
Read the current component carefully (lines 27-153) and replace it with:
```tsx
export function ViewSortConfigPopover({
opened,
onClose,
sorts,
properties,
onChange,
children,
}: ViewSortConfigProps) {
const { t } = useTranslation();
const [draft, setDraft] = useState<ViewSortConfig | null>(null);
// Discard any half-configured draft when the popover closes.
useEffect(() => {
if (!opened) setDraft(null);
}, [opened]);
const propertyOptions = properties.map((p) => ({
value: p.id,
label: p.name,
}));
const directionOptions = [
{ value: "asc", label: t("Ascending") },
{ value: "desc", label: t("Descending") },
];
const handleStartDraft = useCallback(() => {
const usedIds = new Set(sorts.map((s) => s.propertyId));
const available = properties.find((p) => !usedIds.has(p.id));
if (!available) return;
setDraft({ propertyId: available.id, direction: "asc" });
}, [sorts, properties]);
const handleSaveDraft = useCallback(() => {
if (!draft) return;
onChange([...sorts, draft]);
setDraft(null);
}, [draft, sorts, onChange]);
const handleCancelDraft = useCallback(() => {
setDraft(null);
}, []);
const handleRemove = useCallback(
(index: number) => {
onChange(sorts.filter((_, i) => i !== index));
},
[sorts, onChange],
);
const handlePropertyChange = useCallback(
(index: number, propertyId: string | null) => {
if (!propertyId) return;
onChange(
sorts.map((s, i) => (i === index ? { ...s, propertyId } : s)),
);
},
[sorts, onChange],
);
const handleDirectionChange = useCallback(
(index: number, direction: string | null) => {
if (!direction) return;
onChange(
sorts.map((s, i) =>
i === index
? { ...s, direction: direction as "asc" | "desc" }
: s,
),
);
},
[sorts, onChange],
);
const canAddMore = properties.length > sorts.length + (draft ? 1 : 0);
return (
<Popover
opened={opened}
onClose={onClose}
position="bottom-end"
shadow="md"
width={340}
trapFocus
withinPortal
>
<Popover.Target>{children}</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs">
<Text size="xs" fw={600} c="dimmed">
{t("Sort by")}
</Text>
{sorts.length === 0 && !draft && (
<Text size="xs" c="dimmed">
{t("No sorts applied")}
</Text>
)}
{sorts.map((sort, index) => (
<Group key={index} gap="xs" wrap="nowrap">
<Select
size="xs"
data={propertyOptions}
value={sort.propertyId}
onChange={(val) => handlePropertyChange(index, val)}
style={{ flex: 1 }}
/>
<Select
size="xs"
data={directionOptions}
value={sort.direction}
onChange={(val) => handleDirectionChange(index, val)}
w={110}
/>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={() => handleRemove(index)}
>
<IconTrash size={14} />
</ActionIcon>
</Group>
))}
{draft && (
<Stack gap={6}>
<Group gap="xs" wrap="nowrap">
<Select
size="xs"
data={propertyOptions}
value={draft.propertyId}
onChange={(val) =>
val && setDraft({ ...draft, propertyId: val })
}
style={{ flex: 1 }}
/>
<Select
size="xs"
data={directionOptions}
value={draft.direction}
onChange={(val) =>
val &&
setDraft({
...draft,
direction: val as "asc" | "desc",
})
}
w={110}
/>
</Group>
<Group justify="flex-end" gap="xs">
<Button
variant="default"
size="xs"
onClick={handleCancelDraft}
>
{t("Cancel")}
</Button>
<Button size="xs" onClick={handleSaveDraft}>
{t("Save")}
</Button>
</Group>
</Stack>
)}
{!draft && canAddMore && (
<UnstyledButton
onClick={handleStartDraft}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "4px 0",
fontSize: "var(--mantine-font-size-xs)",
color: "var(--mantine-color-blue-6)",
}}
>
<IconPlus size={14} />
{t("Add sort")}
</UnstyledButton>
)}
</Stack>
</Popover.Dropdown>
</Popover>
);
}
```
### Step 4: Build
```bash
pnpm nx run client:build
```
Expected: success. `IconSortAscending` import becomes unused — remove it from the import line.
### Step 5: Commit
```bash
git add apps/client/src/features/base/components/views/view-sort-config.tsx
git commit -m "feat(base): draft flow with save and cancel for new view sorts"
```
---
## Task 2: Filter popover — draft-then-save
**File:** `apps/client/src/features/base/components/views/view-filter-config.tsx`
### Design
Same shape as sort, with additional operator / value fields in the draft row. The `FilterValueInput` sub-component works as-is since it takes a `FilterCondition` and an `onChange(value)` — we pass the draft as the condition and a setter that updates the draft.
Edge case: when the user changes the draft's **property**, the valid operator set changes. Mirror the existing `handlePropertyChange` logic: keep the current operator if still valid, otherwise reset to the first valid operator and clear the value.
### Step 1: Add `useState, useEffect` to the react import
Current: `import { useCallback } from "react";`. Change to:
```ts
import { useCallback, useEffect, useState } from "react";
```
### Step 2: Add `Button` to the `@mantine/core` import
### Step 3: Add three draft helpers inside the component
After the existing `propertyOptions` declaration and before `handleAdd`, add:
```ts
const [draft, setDraft] = useState<FilterCondition | null>(null);
useEffect(() => {
if (!opened) setDraft(null);
}, [opened]);
const handleStartDraft = useCallback(() => {
const firstProperty = properties[0];
if (!firstProperty) return;
const validOperators = getOperatorsForType(firstProperty.type);
const defaultOperator = validOperators.includes("contains")
? ("contains" as FilterOperator)
: validOperators[0];
setDraft({ propertyId: firstProperty.id, op: defaultOperator });
}, [properties]);
const handleSaveDraft = useCallback(() => {
if (!draft) return;
onChange([...conditions, draft]);
setDraft(null);
}, [draft, conditions, onChange]);
const handleCancelDraft = useCallback(() => {
setDraft(null);
}, []);
const handleDraftPropertyChange = useCallback(
(propertyId: string | null) => {
if (!propertyId || !draft) return;
const newProperty = properties.find((p) => p.id === propertyId);
if (!newProperty) {
setDraft({ ...draft, propertyId });
return;
}
const validOperators = getOperatorsForType(newProperty.type);
const currentOperatorValid = validOperators.includes(draft.op);
setDraft({
...draft,
propertyId,
op: currentOperatorValid ? draft.op : validOperators[0],
value: currentOperatorValid ? draft.value : undefined,
});
},
[draft, properties],
);
const handleDraftOperatorChange = useCallback(
(operator: string | null) => {
if (!operator || !draft) return;
const op = operator as FilterOperator;
const needsValue = !NO_VALUE_OPERATORS.includes(op);
setDraft({ ...draft, op, value: needsValue ? draft.value : undefined });
},
[draft],
);
const handleDraftValueChange = useCallback(
(value: string) => {
if (!draft) return;
setDraft({ ...draft, value: value || undefined });
},
[draft],
);
```
### Step 4: Replace the existing `handleAdd` function
Delete the existing `handleAdd` declaration entirely — `handleStartDraft` replaces it.
### Step 5: Render the draft row and hide the Add button when drafting
Find the `<UnstyledButton onClick={handleAdd} ...>` at the bottom of the dropdown (around line 325-338). Replace it with:
```tsx
{draft && (() => {
const needsValue = !NO_VALUE_OPERATORS.includes(draft.op);
const property = properties.find((p) => p.id === draft.propertyId);
const validOperators = property
? getOperatorsForType(property.type)
: OPERATORS.map((op) => op.value);
const operatorOptions = OPERATORS.filter((op) =>
validOperators.includes(op.value),
).map((op) => ({ value: op.value, label: t(op.labelKey) }));
return (
<Stack gap={6}>
<Group gap="xs" wrap="nowrap">
<Select
size="xs"
data={propertyOptions}
value={draft.propertyId}
onChange={handleDraftPropertyChange}
style={{ flex: 1 }}
/>
<Select
size="xs"
data={operatorOptions}
value={draft.op}
onChange={handleDraftOperatorChange}
w={130}
/>
{needsValue && (
<FilterValueInput
condition={draft}
property={property}
onChange={handleDraftValueChange}
t={t}
/>
)}
</Group>
<Group justify="flex-end" gap="xs">
<Button variant="default" size="xs" onClick={handleCancelDraft}>
{t("Cancel")}
</Button>
<Button size="xs" onClick={handleSaveDraft}>
{t("Save")}
</Button>
</Group>
</Stack>
);
})()}
{!draft && (
<UnstyledButton
onClick={handleStartDraft}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "4px 0",
fontSize: "var(--mantine-font-size-xs)",
color: "var(--mantine-color-blue-6)",
}}
>
<IconPlus size={14} />
{t("Add filter")}
</UnstyledButton>
)}
```
### Step 6: Update the empty-state text conditional
The existing `{conditions.length === 0 && <Text ...>No filters applied</Text>}` should also hide when a draft is open — change to:
```tsx
{conditions.length === 0 && !draft && (
<Text size="xs" c="dimmed">
{t("No filters applied")}
</Text>
)}
```
### Step 7: Build
```bash
pnpm nx run client:build
```
Expected: success.
### Step 8: Commit
```bash
git add apps/client/src/features/base/components/views/view-filter-config.tsx
git commit -m "feat(base): draft flow with save and cancel for new view filters"
```
---
## Task 3: USER smoke test
> ⚠️ **Do not run `pnpm dev` as an agent.** Hand off.
Ask the user to:
- [ ] **Sort: draft flow works.**
1. Open the Sort popover. Click "Add sort".
2. A draft row appears with Save / Cancel. The "Add sort" button is hidden.
3. Pick a property, pick direction.
4. Network tab stays quiet (no mutation yet).
5. Click Save. A single `POST /bases/views/update` fires. The sort appears as a committed row; Save/Cancel row is gone; the "Add sort" button reappears.
- [ ] **Sort: cancel discards without mutation.**
1. Click "Add sort". Configure something in the draft.
2. Click Cancel. No mutation fires. Draft disappears.
- [ ] **Sort: closing popover with open draft discards it.**
1. Click "Add sort". Click outside the popover (or press ESC).
2. Popover closes. Re-open it — no draft, no committed new sort, no mutation was fired.
- [ ] **Sort: existing rows still auto-save.**
1. After committing a sort, change its direction via its Select. The usual mutation fires as before.
- [ ] **Sort: max-reached hides Add button.**
1. Add sorts until every property is used. The "Add sort" button should disappear (`canAddMore` is false).
- [ ] **Filter: repeat the same five checks.** Also verify:
- Changing the draft's property resets the operator when incompatible (e.g., start with `contains` on text, switch property to a number → operator becomes `eq`).
- "Is empty" / "Is not empty" operators hide the value field in the draft.
- [ ] **Regression: existing sorts/filters still load correctly on page load.**
Report back if any step misbehaves.
---
## Out of scope
- Edit-mode for existing rows (also behind Save/Cancel). User didn't ask for this; existing inline auto-save is fine.
- Batching multiple quick edits on existing rows into a single mutation (a debounce like the hide flow has). Separate optimization.
- Adding a "reorder sorts" UI — unrelated.
- Any server-side change. The `onChange` contract is unchanged from the popovers' parents' perspective (`base-toolbar.tsx`).
@@ -0,0 +1,152 @@
# Stop Client from Overriding Server 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.
**Goal:** When a view has a sort applied, stop the client from re-sorting the fetched rows by `position` (the fractional-index column), which silently overwrites the server's sort order and visibly corrupts the list as more pages are loaded.
**Architecture:** One-line conditional in the `rows` `useMemo` in `base-table.tsx`: if a sort is active, pass through the server-ordered rows unchanged; otherwise keep the existing position-based sort (which is useful when no sort is active so that optimistically-created rows and ws-pushed rows land in their natural order).
**Tech Stack:** React 18, TanStack Query v5 (infinite), the existing server-side keyset pagination on `(sort keys..., position, id)`.
---
## Background
Reported symptom: user applies "Title ascending" in the sort popover. Initial view looks plausible. Scroll down — further pages load in title order. Scroll back up — the top of the list is now a random-looking mess (screenshots confirm `Update Proposal Sierra`, `Echo November ...` at the top instead of `Alpha ...`).
### Why it happens
Row fetch uses an infinite query, keyed by sort/filter/search:
```ts
// apps/client/src/features/base/queries/base-row-query.ts:57-72
return useInfiniteQuery({
queryKey: ["base-rows", baseId, activeFilter, activeSorts, activeSearch],
queryFn: ({ pageParam }) =>
listRows(baseId!, { cursor: pageParam, limit: 100, filter, sorts, search }),
...
});
```
Server-side, [`base-row.repo.ts:list`](apps/server/src/database/repos/base/base-row.repo.ts) takes a different path when sort/filter/search is present: `runListQuery(base, opts)` builds a keyset-paginated SELECT ordered by the requested sort keys (with `position, id` as stable tie-breakers). Page N+1's cursor picks up exactly where page N left off. Server pages are correct.
Client-side, [`base-table.tsx:66-69`](apps/client/src/features/base/components/base-table.tsx:66):
```ts
const rows = useMemo(() => {
const flat = flattenRows(rowsData);
return flat.sort((a, b) => (a.position < b.position ? -1 : a.position > b.position ? 1 : 0));
}, [rowsData]);
```
This flattens all fetched pages and then re-sorts them by `position` — the fractional-index that the server uses only as a tie-breaker. That completely discards the server's sort-key ordering and re-orders the list by an unrelated key. Each additional page fetched adds more "small position" rows near the top, progressively clobbering whatever made sense before.
### Why the sort-by-position exists in the first place
When NO sort is active, the server's fast path returns rows ordered by `position` (see `base-row.repo.ts:99-120`). Optimistic row creates (from `useCreateRowMutation.onSuccess`) and ws-pushed rows from other clients append to the last page in cache. In the no-sort case, the client-side position sort puts those appended rows into the correct visual slot without waiting for a refetch — a real benefit.
So we can't remove the sort unconditionally. We just need to skip it when the server already sorted the rows for us.
---
## File Structure
**Modified:** `apps/client/src/features/base/components/base-table.tsx` — one `useMemo`.
No new files, no new deps, no server changes.
---
## Task 1: Conditionally skip the client-side position sort
**File:** `apps/client/src/features/base/components/base-table.tsx`
- [ ] **Step 1: Replace the rows memo**
Find the existing memo at roughly lines 66-69:
```ts
const rows = useMemo(() => {
const flat = flattenRows(rowsData);
return flat.sort((a, b) => (a.position < b.position ? -1 : a.position > b.position ? 1 : 0));
}, [rowsData]);
```
Replace with:
```ts
const rows = useMemo(() => {
const flat = flattenRows(rowsData);
// When a sort is active, the server returns rows in the requested
// sort order via keyset pagination. Re-sorting by `position` on the
// client would override that with fractional-index order — visibly
// breaking the sort as more pages load. Only apply the position
// sort when no view sort is active (where it keeps
// optimistically-created and ws-pushed rows in place without a
// refetch).
if (activeSorts && activeSorts.length > 0) {
return flat;
}
return flat.sort((a, b) =>
a.position < b.position ? -1 : a.position > b.position ? 1 : 0,
);
}, [rowsData, activeSorts]);
```
`activeSorts` is already in scope above this memo (line 46: `const activeSorts = activeView?.config?.sorts;`). Adding it to the dep array is safe.
- [ ] **Step 2: Build**
```bash
pnpm nx run client:build
```
Expected: success.
- [ ] **Step 3: Commit**
```bash
git add apps/client/src/features/base/components/base-table.tsx
git commit -m "fix(base): don't override server sort with client-side position sort"
```
---
## Task 2: USER smoke test
> ⚠️ **Do not run `pnpm dev` as an agent.** Hand off to the user.
Ask the user to verify on the 1K or 10K seed base:
- [ ] **Sort by Title ascending, scroll to bottom, scroll back to top.**
- Top of the list should still be the lowest-title rows (e.g. `Alpha ...`).
- Bottom of the list should be the highest-title rows (e.g. `Zulu ...`).
- No re-ordering after scrolling.
- [ ] **Sort by Estimate descending.**
- Highest numeric estimates at the top. Same stability check after scrolling.
- [ ] **Remove all sorts.**
- Rows appear in insert/position order (the default). Same as before the fix — this path shouldn't regress.
- [ ] **Add a new row with no sort active.**
- New row appears at its natural position without waiting for a refetch (the position-sort path still protects this).
- [ ] **Add a new row WITH a sort active.**
- Row appears at the end of the current list (known limitation — see Out of scope). This is acceptable for now.
- [ ] **Two-sort case.** Sort by Status ascending, then by Title ascending.
- Rows group by Status (all "Not Started" first, then "In Progress", etc.); within each group, sorted by Title. Scrolling doesn't break it.
- [ ] **Regression: Virtualization still works.**
- Scroll through the 10K base. Smooth, no crashes.
If any step misbehaves, report back with which one.
---
## Out of scope
- **New rows landing in the right slot when a sort is active.** Fixing that cleanly would require a binary insertion into the cached pages based on the sort keys, which depends on per-cell comparators (select/multi-select resolve via choice order, person via display name, etc.). Separate work. For now, users with a sort active see newly-created rows at the bottom until the next refetch.
- **ws-pushed rows with a sort active.** Same limitation — they append to the last page.
- **Removing the client-side `position` sort entirely.** Keeping it in the no-sort path preserves the good behavior for optimistic creates.
@@ -0,0 +1,128 @@
# Hide-Fields Popover Dismiss 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.
**Goal:** Make the hide-fields popover in the base toolbar dismiss on ESC and on outside click, matching the sort and filter popovers.
**Architecture:** One-line fix — the hide popover is missing `trapFocus` that the other two toolbar popovers already have. Mantine `Popover` only honours `closeOnEscape` when focus is trapped inside the dropdown, so without `trapFocus` the ESC key is never captured. We also make `closeOnClickOutside` and `closeOnEscape` explicit so the intent is clear and immune to future default changes.
**Tech Stack:** React, Mantine v8 `Popover`.
---
## Background
[apps/client/src/features/base/components/views/view-sort-config.tsx:86-93](apps/client/src/features/base/components/views/view-sort-config.tsx:86):
```tsx
<Popover
opened={opened}
onClose={onClose}
position="bottom-end"
shadow="md"
width={340}
trapFocus // ← present
withinPortal
>
```
[apps/client/src/features/base/components/views/view-filter-config.tsx:252-259](apps/client/src/features/base/components/views/view-filter-config.tsx:252) — same, has `trapFocus`.
[apps/client/src/features/base/components/views/view-field-visibility.tsx:65-72](apps/client/src/features/base/components/views/view-field-visibility.tsx:65):
```tsx
<Popover
opened={opened}
onClose={onClose}
position="bottom-end"
shadow="md"
width={260}
withinPortal // ← no trapFocus
>
```
Mantine v8 defaults: `closeOnClickOutside=true`, `closeOnEscape=true`, `trapFocus=false`. ESC handling is wired to the trapped focus context; without `trapFocus` the ESC never fires `onClose`.
---
## File Structure
**Modified files:**
- `apps/client/src/features/base/components/views/view-field-visibility.tsx` — one `<Popover>` props edit.
No new files, no new deps, no tests (pure UI behaviour wiring; identical change pattern to the other two popovers already in production).
---
## Task 1: Enable focus trap + explicit dismiss flags
**Files:**
- Modify: `apps/client/src/features/base/components/views/view-field-visibility.tsx:65-72`
- [ ] **Step 1: Edit the Popover props**
Before:
```tsx
<Popover
opened={opened}
onClose={onClose}
position="bottom-end"
shadow="md"
width={260}
withinPortal
>
```
After:
```tsx
<Popover
opened={opened}
onClose={onClose}
position="bottom-end"
shadow="md"
width={260}
trapFocus
closeOnEscape
closeOnClickOutside
withinPortal
>
```
- [ ] **Step 2: Build the client**
```bash
pnpm nx run client:build
```
Expected: success.
- [ ] **Step 3: Commit**
```bash
git add apps/client/src/features/base/components/views/view-field-visibility.tsx
git commit -m "fix(base): dismiss hide-fields popover on escape and outside click"
```
---
## Task 2: USER smoke test
> ⚠️ **Do not run `pnpm dev` as an agent.** Hand off to the user.
Ask the user, with the dev server already running, to:
- [ ] Open a base → click the "Hide fields" (eye icon) button. Popover opens.
- [ ] Press ESC. Popover closes. ✓
- [ ] Open it again → click anywhere outside the popover (e.g. on the grid). Popover closes. ✓
- [ ] Open it → click a Switch inside. Column toggles. Popover stays open. ✓
- [ ] Open it → tab through with keyboard. Focus stays inside. ESC closes. ✓
- [ ] Regression: the sort and filter popovers still dismiss correctly on ESC + outside click.
If any step fails, report back; otherwise the fix is complete.
---
## Out of scope
- No refactor of the three popovers into a shared wrapper — `trapFocus` alone is the only behaviour diff, and the existing components are small and focused. Not worth abstracting three instances.
- No test added — Mantine `Popover` dismiss behaviour isn't something we own; testing it here would be testing the library.
@@ -0,0 +1,404 @@
# Hide-Property Persistence Bug Fix 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.
**Goal:** Stop hidden column toggles from silently reappearing when another view-config mutation or a websocket-driven refetch lands between the toggle and the 300 ms debounced persist.
**Architecture:** Two narrow fixes in the client — nothing server-side. (1) The blind `setColumnVisibility(derivedColumnVisibility)` sync effect in [`use-base-table.ts`](apps/client/src/features/base/hooks/use-base-table.ts) is scoped to run only when the user switches views, so refetches of the same view can't overwrite pending local toggles. (2) The sort/filter mutations in [`base-toolbar.tsx`](apps/client/src/features/base/components/base-toolbar.tsx) merge in the live table state (visibility / order / widths) instead of spreading stale `activeView.config` — so saving a sort no longer clobbers a not-yet-persisted hide.
**Tech Stack:** React 18, TanStack Table v8, TanStack Query v5, Mantine, TypeScript.
---
## Background — why this happens
`useBaseTable` keeps `columnVisibility` as local React state, seeded from the persisted view config via a `useMemo`-derived value (`derivedColumnVisibility`). Two effects currently do an unconditional re-sync:
```ts
// apps/client/src/features/base/hooks/use-base-table.ts:223-229
useEffect(() => {
setColumnOrder(derivedColumnOrder);
}, [derivedColumnOrder]);
useEffect(() => {
setColumnVisibility(derivedColumnVisibility);
}, [derivedColumnVisibility]);
```
Any mutation that calls `queryClient.setQueryData(["bases", baseId], ...)` — or `queryClient.invalidateQueries` at [`use-base-socket.ts:254`](apps/client/src/features/base/hooks/use-base-socket.ts:254) on ws `base:property:*` / `base:view:*` events — minted a new `activeView.config` reference. That recomputes `derivedColumnVisibility`, and the effect slams the local state back to whatever the server currently has.
Persistence is debounced 300 ms by `persistViewConfig`. During that window, any of these triggers a stomp:
1. **A concurrent sort / filter mutation from the same user.** `handleSortsChange` / `handleFiltersChange` in [`base-toolbar.tsx:93-120`](apps/client/src/features/base/components/base-toolbar.tsx:93) spread `activeView.config` (stale — still has old `hiddenPropertyIds`) and `mutate` immediately, without going through the debounced path. The `onMutate` optimistic write, the `onSuccess` server write, and the ws echo's `invalidateQueries` each re-trigger the effect.
2. **Another client sending any `base:property:*` / `base:view:*` ws event.**
3. **The optimistic / success writes from our OWN `persistViewConfig` mutation when the server-side state hasn't persisted yet** (e.g., response races).
Symptom: user toggles a column hidden → column disappears → column reappears within ~instant to a few hundred ms → reload confirms the toggle was never saved.
**Deterministic repro (no second client needed):**
1. Open a base with ≥ 3 non-primary columns and no sorts.
2. In the toolbar, open "Hide fields" and toggle column X off. Column vanishes.
3. Immediately (well under 300 ms) open the Sort popover and add a sort.
4. Column X reappears.
5. Reload: X is visible; sort is saved; hide was lost.
`hiddenPropertyIds` wins semantics check: [`use-base-table.ts:117-143`](apps/client/src/features/base/hooks/use-base-table.ts:117) — correct (hidden list is checked first, then legacy `visiblePropertyIds`, then default all-visible). That isn't the bug.
---
## File Structure
**Modified files:**
- `apps/client/src/features/base/hooks/use-base-table.ts` — gate the re-sync effect behind a view-id ref; export a new `buildViewConfigFromTable` helper used by both the debounced persist and the toolbar's direct mutations.
- `apps/client/src/features/base/components/base-toolbar.tsx``handleSortsChange` / `handleFiltersChange` build the full config from live table state (not `activeView.config`) before `mutate`.
No new files. No server-side changes. No new deps.
---
## Task 1: Verify the repro against the current build
- [ ] **Step 1: Build the client** to make sure the branch is clean before changing anything.
```bash
pnpm nx run client:build
```
Expected: build succeeds.
- [ ] **Step 2: Ask the user to run the deterministic repro above.**
(Per `CLAUDE.md` the agent must not run `pnpm dev`.) User should confirm:
- Column X disappears, then reappears within ~300 ms of adding the sort.
- Reload shows X visible, hide lost.
If the repro does NOT reproduce for the user, stop the plan here and ask for actual repro steps — the fixes below target this specific chain.
---
## Task 2: Extract `buildViewConfigFromTable` helper
**Files:**
- Modify: `apps/client/src/features/base/hooks/use-base-table.ts`
- [ ] **Step 1: Add the helper near the other build functions** (above `useBaseTable`, after `buildColumnPinning`)
```ts
// Serializes the live react-table state into a persisted ViewConfig.
// Sort/filter toolbar mutations and the debounced `persistViewConfig`
// both go through this so a direct mutation (e.g. adding a sort) can't
// clobber a pending hide/reorder/resize by reading stale `activeView.config`.
export function buildViewConfigFromTable(
table: Table<IBaseRow>,
base: ViewConfig | undefined,
overrides: Partial<ViewConfig> = {},
): ViewConfig {
const state = table.getState();
const sorts = state.sorting.map((s) => ({
propertyId: s.id,
direction: (s.desc ? "desc" : "asc") as "asc" | "desc",
}));
const propertyWidths: Record<string, number> = {};
Object.entries(state.columnSizing).forEach(([id, width]) => {
if (id !== "__row_number") propertyWidths[id] = width;
});
const propertyOrder = state.columnOrder.filter((id) => id !== "__row_number");
const hiddenPropertyIds = Object.entries(state.columnVisibility)
.filter(([id, visible]) => id !== "__row_number" && !visible)
.map(([id]) => id);
return {
...base,
sorts,
propertyWidths,
propertyOrder,
hiddenPropertyIds,
visiblePropertyIds: undefined,
...overrides,
};
}
```
- [ ] **Step 2: Replace the inline serialization inside `persistViewConfig`** (lines roughly 267-297 of the current file) with a call to the helper.
Before:
```ts
persistTimerRef.current = setTimeout(() => {
const state = table.getState();
const sorts = state.sorting.map((s) => ({ ... }));
// ...lots of inline serialization...
const config: ViewConfig = {
...activeView.config,
sorts,
propertyWidths,
propertyOrder,
hiddenPropertyIds,
visiblePropertyIds: undefined,
};
updateViewMutation.mutate({ viewId: activeView.id, baseId: base.id, config });
}, 300);
```
After:
```ts
persistTimerRef.current = setTimeout(() => {
const config = buildViewConfigFromTable(table, activeView.config);
updateViewMutation.mutate({ viewId: activeView.id, baseId: base.id, config });
}, 300);
```
- [ ] **Step 3: Build**
```bash
pnpm nx run client:build
```
Expected: success.
- [ ] **Step 4: Commit**
```bash
git add apps/client/src/features/base/hooks/use-base-table.ts
git commit -m "refactor(base): extract buildViewConfigFromTable helper"
```
---
## Task 3: Route sort/filter mutations through the helper
**Files:**
- Modify: `apps/client/src/features/base/components/base-toolbar.tsx`
`handleSortsChange` and `handleFiltersChange` currently read `activeView.config` directly, which reflects the server-persisted state, not the user's pending local changes. They must build the new config from `table.getState()` + the new sort or filter.
- [ ] **Step 1: Import the helper and accept the full view config for merging filters**
Add at the top of the file:
```ts
import { buildViewConfigFromTable } from "@/features/base/hooks/use-base-table";
```
- [ ] **Step 2: Rewrite `handleSortsChange`**
Before:
```ts
const handleSortsChange = useCallback(
(newSorts: ViewSortConfig[]) => {
if (!activeView) return;
updateViewMutation.mutate({
viewId: activeView.id,
baseId: base.id,
config: { ...activeView.config, sorts: newSorts },
});
},
[activeView, base.id, updateViewMutation],
);
```
After:
```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],
);
```
- [ ] **Step 3: Rewrite `handleFiltersChange` the same way**
Before:
```ts
const handleFiltersChange = useCallback(
(newConditions: FilterCondition[]) => {
if (!activeView) return;
const filter: FilterGroup | undefined =
newConditions.length > 0
? { op: "and", children: newConditions }
: undefined;
const { filter: _drop, ...rest } = activeView.config ?? {};
updateViewMutation.mutate({
viewId: activeView.id,
baseId: base.id,
config: filter ? { ...rest, filter } : rest,
});
},
[activeView, base.id, updateViewMutation],
);
```
After:
```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],
);
```
- [ ] **Step 4: Build**
```bash
pnpm nx run client:build
```
Expected: success.
- [ ] **Step 5: Commit**
```bash
git add apps/client/src/features/base/components/base-toolbar.tsx
git commit -m "fix(base): merge live table state into sort and filter mutations"
```
---
## Task 4: Gate the sync effect behind view-id change
**Files:**
- Modify: `apps/client/src/features/base/hooks/use-base-table.ts`
Within the same view, local state is the source of truth and the debounced persist flushes it. The sync effect must only run when the user switches to a different view (where the server's config is authoritative).
- [ ] **Step 1: Replace both sync effects**
Before (around lines 220-229):
```ts
const [columnOrder, setColumnOrder] = useState<ColumnOrderState>(derivedColumnOrder);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(derivedColumnVisibility);
useEffect(() => {
setColumnOrder(derivedColumnOrder);
}, [derivedColumnOrder]);
useEffect(() => {
setColumnVisibility(derivedColumnVisibility);
}, [derivedColumnVisibility]);
```
After:
```ts
const [columnOrder, setColumnOrder] = useState<ColumnOrderState>(derivedColumnOrder);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(derivedColumnVisibility);
// Re-seed from server only when the user switches views. Within the same
// view, local state is the source of truth — the debounced persist flushes
// it. Without this guard, any ws-driven `invalidateQueries(["bases", baseId])`
// or concurrent view mutation lands a new `derivedColumnVisibility`
// reference and the effect would overwrite a pending hide/reorder toggle
// before `persistViewConfig` has a chance to flush it.
const lastSyncedViewIdRef = useRef<string | undefined>(activeView?.id);
useEffect(() => {
const currentViewId = activeView?.id;
if (currentViewId !== lastSyncedViewIdRef.current) {
lastSyncedViewIdRef.current = currentViewId;
setColumnOrder(derivedColumnOrder);
setColumnVisibility(derivedColumnVisibility);
}
}, [activeView?.id, derivedColumnOrder, derivedColumnVisibility]);
```
Note: `derivedColumnOrder` and `derivedColumnVisibility` remain in the dependency array so the effect reads the current derived values when a view switch happens. The ref-guarded branch only fires on id change.
- [ ] **Step 2: Build**
```bash
pnpm nx run client:build
```
Expected: success.
- [ ] **Step 3: Commit**
```bash
git add apps/client/src/features/base/hooks/use-base-table.ts
git commit -m "fix(base): only re-seed column state when view identity changes"
```
---
## Task 5: USER manual verification — scripted steps
> ⚠️ **Do not run `pnpm dev` as an agent.** Hand off to the user with the cases below.
Ask the user to run through these. Each should now behave correctly:
- [ ] **Repro from Task 1 — hide then add sort**
1. Open a base with ≥ 3 non-primary columns.
2. Hide column X via the popover.
3. Within 300 ms, add a sort via the Sort popover.
4. Expected: column X stays hidden. Sort indicator shows. Reload: both persisted.
- [ ] **Hide then change filter**
1. Hide column Y.
2. Immediately add a filter condition.
3. Expected: column Y stays hidden, filter applied. Reload: both persisted.
- [ ] **Hide all / show all**
1. Click "Hide all" in the popover. All non-primary columns disappear.
2. Reload. Expected: same state.
3. Click "Show all". Reload. Expected: all columns visible.
- [ ] **View switch**
1. Hide column Z on view A.
2. Switch to view B (no hide). Expected: Z visible in view B.
3. Switch back to view A. Expected: Z hidden.
- [ ] **WebSocket reconcile doesn't stomp**
1. Hide column W.
2. From another browser / incognito session on the same base, add a new property.
3. In the first window, the new property appears visible, W stays hidden.
- [ ] **Primary property can't be hidden** (regression check)
1. Open the popover — the primary column's switch is disabled (already enforced by `enableHiding: !property.isPrimary` at `use-base-table.ts:87`). Confirm.
---
## Task 6: Final commit check + handoff
- [ ] **Step 1: Confirm branch state**
```bash
git status
git log --oneline main..HEAD
```
Expected: clean tree, 3 new commits atop the CSV-export branch (refactor, fix sort/filter merge, fix sync effect).
- [ ] **Step 2: Trigger `superpowers:finishing-a-development-branch`** to choose merge/PR.
---
## Out of scope (explicitly not fixing here)
- **`hiddenFieldCount` badge dep** in `base-toolbar.tsx:88-91` — works by accident today (re-renders produce fresh `getState()` references). Low-impact cosmetic risk; leave alone unless it manifests.
- **Legacy `visiblePropertyIds` migration.** Views created before `hiddenPropertyIds` existed may show new properties as hidden by default in `buildColumnVisibility`. No reports of this; migration would be a separate plan.
- **Batching `handleHideAll`/`handleShowAll`** into a single `table.setColumnVisibility(map)` call instead of iterating `toggleVisibility`. React 18 batches these anyway; not a bug.
- **Server-side zod schema.** `viewConfigSchema` already accepts `hiddenPropertyIds: []` correctly; no change needed.
@@ -0,0 +1,224 @@
# Hide Property Still Broken — Diagnose & Fix 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.
**Goal:** Identify and fix the reason that toggling a property in the hide-fields popover no longer ends up in the POST payload sent to `/bases/views/update`.
**Architecture:** Instrument the toggle → state → debounced persist pipeline, have the user reproduce, read the logs, fix the exact bug. Do NOT blindly apply a "defensive" fix before the bug is pinpointed — we've burned several "fixes" on this already and need ground truth first.
**Tech Stack:** React 18, TanStack Table v8 (controlled `state.columnVisibility` + `onColumnVisibilityChange`), TanStack Query v5.
---
## What we know so far
1. **Server is fine.** Direct API call to `POST /api/bases/views/update` with `{config: {hiddenPropertyIds: [...]}}` persists exactly what's sent (verified against the user's base `019c69a5-1d84-7985-a7f6-8ee2871d8669`).
2. **Client outgoing payload is wrong.** User observed: existing `hiddenPropertyIds = [A, B]`. They toggled a new column C via the hide popover. The outgoing POST payload still contained `hiddenPropertyIds: [A, B]` — C never made it in.
3. **`buildViewConfigFromTable`** (at [`use-base-table.ts:179-211`](apps/client/src/features/base/hooks/use-base-table.ts:179)) reads `table.getState().columnVisibility` and derives `hiddenPropertyIds` by filtering entries where `visible === false`.
4. **Only three code paths modify `columnVisibility` state** (confirmed by grep):
- `use-base-table.ts:275` — view-switch full re-seed (only fires when `activeView?.id` changes).
- `use-base-table.ts:297` — reconcile branch (function updater, preserves `prev` for existing ids).
- `use-base-table.ts:336``onColumnVisibilityChange: setColumnVisibility` passed to react-table.
5. **react-table v8 `useReactTable`** ([verified from node_modules source](node_modules/.pnpm/@tanstack+react-table@8.21.3_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@tanstack/react-table/build/lib/index.mjs:47-70)) returns a STABLE `table` reference held in `useState(() => ({current: createTable(...)}))`. Every render it calls `setOptions` to merge fresh options and state. So the stale-`table`-closure hypothesis from an earlier investigation is LIKELY wrong — `table.getState()` at setTimeout time should return current state.
6. **`col.toggleVisibility(value)`** at [`@tanstack/table-core/.../ColumnVisibility.js:30`](node_modules/.pnpm/@tanstack+table-core@8.21.3/node_modules/@tanstack/table-core/build/lib/features/ColumnVisibility.js) uses a function updater: `table.setColumnVisibility(old => ({...old, [id]: value}))`. Which forwards to our `setColumnVisibility` — React queues the function updater; the next render commits new state.
Given all of that, the toggle SHOULD reach `state.columnVisibility` and SHOULD land in the debounced payload. Something is interfering that we can't see by reading.
## Hypotheses to rule in or out (the diagnostic is designed to distinguish them)
- **H1: The handler-to-setState path is broken.** `handleToggle` fires but `setColumnVisibility` never commits the toggle (e.g., the setter is somehow stale, the function updater sees a stale `prev`, or react-table's intermediate logic drops the call).
- **H2: Something immediately stomps the toggle.** The reconcile effect fires between the toggle and the debounce, with a `prev` that doesn't yet reflect the toggle, and writes an updated map that "preserves" a stale value for the toggled id.
- **H3: Debounce timer fires with a fresh closure that reads a different `table` instance.** Contradicts what we saw in the react-table source, but worth falsifying.
- **H4: The popover's `col` reference comes from a STALE `columns` memo.** If `table.getAllLeafColumns()` was captured when an older version of the table existed, `col.toggleVisibility` would call a stale `table.setColumnVisibility`.
- **H5: The state IS updated but `buildViewConfigFromTable` reads a different state shape.** E.g., internal react-table state defeats our controlled state for `columnVisibility`.
---
## File Structure
**Modified (instrumentation task — revert before shipping):**
- `apps/client/src/features/base/hooks/use-base-table.ts` — add `console.log` calls at four checkpoints.
- `apps/client/src/features/base/components/views/view-field-visibility.tsx` — log the `handleToggle` call site.
**Modified (fix task — depends on findings):**
- Whatever the diagnostic reveals.
---
## Task 1: Instrument the pipeline
**Files:**
- Modify: `apps/client/src/features/base/hooks/use-base-table.ts`
- Modify: `apps/client/src/features/base/components/views/view-field-visibility.tsx`
- [ ] **Step 1: Add a logging helper at the top of `use-base-table.ts`**
Right after the existing imports, add:
```ts
const DEBUG_HIDE = true;
function hideLog(label: string, data: unknown) {
if (DEBUG_HIDE) console.log(`[hide-debug] ${label}`, data);
}
```
- [ ] **Step 2: Log inside the view-switch re-seed branch**
In the effect at ~line 268, inside the `if (currentViewId !== lastSyncedViewIdRef.current) { ... }` branch, add:
```ts
hideLog("VIEW_SWITCH_RESEED", {
viewId: currentViewId,
newOrder: derivedColumnOrder,
newVisibility: derivedColumnVisibility,
});
```
- [ ] **Step 3: Log inside the reconcile branch's setColumnVisibility updater**
In the reconcile `setColumnVisibility((prev) => ...)` callback (around line 297), at the VERY START, log:
```ts
hideLog("RECONCILE_ENTER", { prev, derivedColumnVisibility });
```
And right before `return changed ? next : prev;`, log:
```ts
hideLog("RECONCILE_EXIT", { changed, next, returning: changed ? next : prev });
```
- [ ] **Step 4: Wrap `setColumnVisibility` to log every call**
In `useReactTable`, replace:
```ts
onColumnVisibilityChange: setColumnVisibility,
```
with an instrumented passthrough:
```ts
onColumnVisibilityChange: (updater) => {
hideLog("RT_onColumnVisibilityChange", {
updaterType: typeof updater,
applied:
typeof updater === "function"
? updater(columnVisibility)
: updater,
});
setColumnVisibility(updater as Parameters<typeof setColumnVisibility>[0]);
},
```
(Apply the same pattern to `onColumnOrderChange` if you want symmetry — optional.)
- [ ] **Step 5: Log inside the debounced persist**
Inside the `setTimeout(() => { ... }, 300)` callback in `persistViewConfig`, BEFORE the `buildViewConfigFromTable` call, log:
```ts
const liveState = table.getState();
hideLog("PERSIST_TICK", {
viewId: activeView.id,
stateColumnVisibility: liveState.columnVisibility,
stateColumnOrder: liveState.columnOrder,
});
```
AFTER `buildViewConfigFromTable`, log:
```ts
hideLog("PERSIST_OUTGOING", { config });
```
- [ ] **Step 6: Log inside `handleToggle` in the popover**
In `apps/client/src/features/base/components/views/view-field-visibility.tsx`, modify `handleToggle`:
```ts
const handleToggle = useCallback(
(columnId: string, visible: boolean) => {
const col = table.getColumn(columnId);
console.log("[hide-debug] HANDLE_TOGGLE", {
columnId,
visibleRequested: visible,
canHide: col?.getCanHide(),
currentlyVisible: col?.getIsVisible(),
});
if (!col) return;
col.toggleVisibility(visible);
onPersist();
},
[table, onPersist],
);
```
- [ ] **Step 7: Build (do not commit — this is throwaway instrumentation)**
```bash
pnpm nx run client:build
```
Expected: success.
---
## Task 2: USER reproduces and shares logs
> ⚠️ **Do not run `pnpm dev` as an agent.** User runs dev; user hard-reloads; user interacts and copies the console output.
Hand off to the user with this script:
1. Open DevTools Console, clear it. Keep it open.
2. Open the base. Open the "Hide fields" popover.
3. Toggle ONE property that is currently visible → hidden. Do not click anything else.
4. Wait ~1 second (debounce fires).
5. Copy EVERY `[hide-debug] ...` line from the console, in order, and paste them back here. Also paste the resulting Network POST `/api/bases/views/update` request payload (Network tab → the one `update` request that fires 300 ms after the click → Payload → Request Payload).
The interesting sequence, if everything is working, is:
```
HANDLE_TOGGLE { columnId: "X", visibleRequested: false, canHide: true, currentlyVisible: true }
RT_onColumnVisibilityChange { updaterType: "function", applied: { ..., X: false } }
PERSIST_TICK { stateColumnVisibility: { ..., X: false } }
PERSIST_OUTGOING { config: { hiddenPropertyIds: [..., "X"] } }
```
If the bug is present, one of those lines will be missing or wrong. The exact position of the deviation pinpoints which hypothesis (H1H5) is correct.
---
## Task 3: Fix based on findings
Do NOT pre-write the fix. Tasks 3a-3c below are the dispatch table — exactly ONE applies.
### Task 3a: If `RT_onColumnVisibilityChange` never logs, or `applied` doesn't include the toggled id → H1
react-table isn't calling our setter, OR the updater resolves to the wrong value. This points to:
- a stale `col` object (columns memo invalidation issue)
- or a react-table options propagation bug
Investigate `col.toggleVisibility` step-by-step (add logs inside `toggleVisibility` via a wrapped column accessor), ensure `columns` useMemo dep list includes everything that affects `getCanHide`/`getIsVisible`.
### Task 3b: If `RT_onColumnVisibilityChange` logs `applied: {X: false}` but `PERSIST_TICK` shows `stateColumnVisibility` without `X: false` → H2 or H3
The setter was called correctly but state didn't commit. Check if `RECONCILE_ENTER` fired between them and what `prev` it saw.
- If `RECONCILE_ENTER.prev` has `X: false` and `RECONCILE_EXIT.returning` does NOT → bug in the reconcile logic.
- If `RECONCILE_ENTER.prev` does NOT have `X: false` → React batching issue; the toggle's setState ran AFTER the effect's setState. Fix by using a ref to latest `derivedColumnVisibility` so the reconcile effect can safely be a no-op except on view-id change (same-view drift will be covered by `columns` prop going through react-table's internal columnVisibility seeding).
### Task 3c: If `PERSIST_TICK.stateColumnVisibility` has `X: false` but `PERSIST_OUTGOING.config.hiddenPropertyIds` doesn't include `X` → bug in `buildViewConfigFromTable`
This would be surprising given the code at [`use-base-table.ts:198-200`](apps/client/src/features/base/hooks/use-base-table.ts:198), but check type coercion and filter predicate.
---
## Task 4: Remove instrumentation, commit fix, hand back to user
- [ ] **Step 1:** Remove all `[hide-debug]` logs and the `DEBUG_HIDE` / `hideLog` helper.
- [ ] **Step 2:** Build + self-verify by thinking through the fix with the log evidence in hand.
- [ ] **Step 3:**
```bash
pnpm nx run client:build
git add <changed files>
git commit -m "fix(base): <concrete description based on the real root cause>"
```
- [ ] **Step 4:** User smoke test: hide a column, verify payload contains the id, verify the column stays hidden after refresh.
---
## Anti-goals
- **No "defensive" fixes.** We've cycled through "wrap in useRef" / "gate the effect" / "merge table state" — each touched a real issue but none hit this particular bug. A plausible-sounding fix is worse than silence: it burns trust when it doesn't work.
- **No code edits without the log evidence.** Task 3 only runs after Task 2 returns concrete data.
@@ -0,0 +1,197 @@
# New Property Not In View 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.
**Goal:** When a user creates a new property, it lands in the grid's local column state immediately (appended to the end, visible by default) so the grid can size and scroll to reveal it.
**Architecture:** Single-file fix in [`apps/client/src/features/base/hooks/use-base-table.ts`](apps/client/src/features/base/hooks/use-base-table.ts). Extend the existing gated re-seed effect so that on property add / remove (within the same view) it reconciles local `columnOrder` and `columnVisibility` instead of ignoring the change. Existing per-column user toggles (hide, reorder) are preserved; new columns are appended; deleted columns are dropped.
**Tech Stack:** React 18, TanStack Table v8, TanStack Query v5.
---
## Background
Earlier in this branch (commit `c6f993b6`) the sync effect that copied `derivedColumnOrder` / `derivedColumnVisibility` into local state was gated behind a view-id ref to stop ws-driven base refetches from stomping the user's pending hide-column toggles. Current code:
```ts
// apps/client/src/features/base/hooks/use-base-table.ts:267-281
const lastSyncedViewIdRef = useRef<string | undefined>(activeView?.id);
useEffect(() => {
const currentViewId = activeView?.id;
if (currentViewId !== lastSyncedViewIdRef.current) {
lastSyncedViewIdRef.current = currentViewId;
setColumnOrder(derivedColumnOrder);
setColumnVisibility(derivedColumnVisibility);
}
}, [activeView?.id, derivedColumnOrder, derivedColumnVisibility]);
```
**What goes wrong with a new property in the same view:**
1. User opens `CreatePropertyPopover`, submits → `useCreatePropertyMutation.onSuccess` appends the new property to `["bases", baseId].properties`.
2. `base.properties` has a new reference → `properties` useMemo re-runs → new array reference.
3. `derivedColumnOrder` recomputes — includes the new property id.
4. The gated effect sees `currentViewId === lastSyncedViewIdRef.current`, so it **does nothing**. Local `columnOrder` state still lists the OLD column ids.
5. `columns` prop to `useReactTable` is rebuilt (it memos on `[properties]`), so react-table does know the new column def exists.
6. But state passed via `state={{columnOrder}}` still references only the old columns → `table.getState().columnOrder` → stale → `gridTemplateColumns` (which depends on `table.getVisibleLeafColumns()` + `table.getState().columnOrder` in [`grid-container.tsx:149`](apps/client/src/features/base/components/grid/grid-container.tsx:149)) doesn't get a track for the new column.
7. Result: the new column renders in the DOM (react-table still yields it as visible), but the grid wrapper's `scrollWidth` doesn't extend to contain it, so `handlePropertyCreated`'s [`scrollRef.current.scrollTo({left: scrollWidth})`](apps/client/src/features/base/components/grid/grid-container.tsx:183-192) ends at the OLD scrollWidth — the new column stays clipped at the edge.
The same mechanism also breaks property **deletion** within the same view (local state keeps a dead id) — not the filed symptom, but worth fixing in the same patch.
The same mechanism does NOT break **rename**, because rename changes `property.name` but not property IDs; order + visibility maps key on id, so they stay correct. Rename is already working after the earlier memo-prop-threading fix.
---
## File Structure
**Modified files:**
- `apps/client/src/features/base/hooks/use-base-table.ts` — extend the sync effect.
No new files, no new deps, nothing server-side.
---
## Task 1: Reconcile local column state on property add / remove
**Files:**
- Modify: `apps/client/src/features/base/hooks/use-base-table.ts:260-281`
- [ ] **Step 1: Replace the effect**
Before (current):
```ts
const lastSyncedViewIdRef = useRef<string | undefined>(activeView?.id);
useEffect(() => {
const currentViewId = activeView?.id;
if (currentViewId !== lastSyncedViewIdRef.current) {
lastSyncedViewIdRef.current = currentViewId;
setColumnOrder(derivedColumnOrder);
setColumnVisibility(derivedColumnVisibility);
}
}, [activeView?.id, derivedColumnOrder, derivedColumnVisibility]);
```
After:
```ts
const lastSyncedViewIdRef = useRef<string | undefined>(activeView?.id);
useEffect(() => {
const currentViewId = activeView?.id;
// View switch → full re-seed from the server's stored config.
if (currentViewId !== lastSyncedViewIdRef.current) {
lastSyncedViewIdRef.current = currentViewId;
setColumnOrder(derivedColumnOrder);
setColumnVisibility(derivedColumnVisibility);
return;
}
// Same view — preserve user toggles, but reconcile the id set:
// append properties that were just created, drop properties that
// were deleted. Without this, creating a new column leaves it
// invisible to `table.getState().columnOrder` / `gridTemplateColumns`,
// and the grid's scrollWidth never grows to include it.
const validIds = new Set<string>(["__row_number"]);
for (const p of properties) validIds.add(p.id);
setColumnOrder((prev) => {
const prevSet = new Set(prev);
const kept = prev.filter((id) => validIds.has(id));
const appended = derivedColumnOrder.filter(
(id) => !prevSet.has(id) && validIds.has(id),
);
if (appended.length === 0 && kept.length === prev.length) return prev;
return [...kept, ...appended];
});
setColumnVisibility((prev) => {
let changed = false;
const next: VisibilityState = {};
for (const [id, visible] of Object.entries(prev)) {
if (validIds.has(id)) {
next[id] = visible;
} else {
changed = true;
}
}
for (const id of derivedColumnOrder) {
if (!(id in next)) {
next[id] = derivedColumnVisibility[id] ?? true;
changed = true;
}
}
return changed ? next : prev;
});
}, [
activeView?.id,
derivedColumnOrder,
derivedColumnVisibility,
properties,
]);
```
Notes for the implementer:
- `VisibilityState` is already imported from `@tanstack/react-table` — no new import needed.
- `properties` is already declared as a memoized value at the top of this hook, so adding it to the dep list is safe.
- The two `setX((prev) => ...)` updaters both short-circuit (return `prev`) when nothing actually changed, which matters because `derivedColumnOrder` / `derivedColumnVisibility` have a new identity every time `properties` does — without the short-circuit the set would fire every render in the same view and blow away user toggles.
- `kept.length === prev.length` is a proxy for "no deletions" and is safe because `prev` can't contain duplicates (react-table enforces id uniqueness, and our own `derivedColumnOrder` is also unique).
- [ ] **Step 2: Build**
```bash
pnpm nx run client:build
```
Expected: success.
- [ ] **Step 3: Commit**
```bash
git add apps/client/src/features/base/hooks/use-base-table.ts
git commit -m "fix(base): include new properties in local column state so the grid can scroll to them"
```
---
## Task 2: USER smoke test
> ⚠️ **Do not run `pnpm dev` as an agent.** Hand off to the user.
After a hard reload:
- [ ] **Create a new property — grid sizes for it and scroll reaches it.**
1. Open a base with enough columns that horizontal scrolling is already active.
2. Click the "+" / create-property button, pick a type, submit.
3. The grid should scroll right automatically; the new column is fully visible; the horizontal scrollbar extends.
- [ ] **New property is visible in the Hide fields popover.**
1. Open the eye icon (Hide fields).
2. The new property appears in the list, toggle ON.
- [ ] **Existing toggles are preserved.**
1. Hide column X.
2. Create a new column Y. Column X stays hidden; Y appears at the end, visible.
- [ ] **Delete a property.**
1. From a property's menu, click Delete.
2. Column disappears from the grid; grid scrollWidth contracts. No stale column left.
- [ ] **View switch still works cleanly.**
1. Switch to a different view; then switch back.
2. Hidden / reordered state for that view loads correctly.
- [ ] **Rename still works (regression check).**
1. Rename a property; the header text updates without reload.
- [ ] **Hide + concurrent sort mutation (regression for the original hide bug).**
1. Hide a column, then add a sort within 300 ms. Column stays hidden; sort applies.
If any step fails, report back with the specific case.
---
## Out of scope
- Scrolling behavior on row add (orthogonal, not broken).
- The two `rAF` delay in `handlePropertyCreated` — it already waits long enough once state reconciliation happens in the same render cycle.
- `columnSizing` reconciliation — a new column uses its defined size automatically via react-table's `initialState`, and a deleted column's entry in the sizing state is harmless.
@@ -0,0 +1,375 @@
# Property Rename Not Reflecting 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.
**Goal:** Make property (column) rename reflect immediately in the grid header and the hide-fields popover, both for the editing user and other clients in the same base room — without a tab reload.
**Architecture:** Three small frontend changes. The server path is already correct (rename persists, emits `base:property:updated` ws event, and `useBaseSocket` invalidates `["bases", baseId]`). The cache updates too. The bug is purely client-side memoization: `GridHeader`, `GridHeaderCell`, and `ViewFieldVisibility` memo on `[table]` / their prop object — and the `table` reference returned by `useReactTable` is STABLE across renders. So when properties change under the hood (new column defs, new `meta.property` objects, new header strings), memo'd consumers never re-render. Fix: thread `properties` / `property` down as explicit props so shallow-compare catches the change.
**Tech Stack:** React 18, TanStack Table v8, TanStack Query v5.
---
## Background — the trace that explains the bug
1. User renames property "Email" → "Mail" from the header's property menu.
2. `updatePropertyMutation.mutate` fires. Server persists, returns `{property: {...name: "Mail"}}`.
3. `onSuccess` in [`base-property-query.ts:52-65`](apps/client/src/features/base/queries/base-property-query.ts:52) calls `queryClient.setQueryData(["bases", baseId], old => ({...old, properties: old.properties.map(p => p.id === result.property.id ? result.property : p)}))`. The renamed property is a new object reference; the rest of the array reuses old refs.
4. `useBaseQuery` returns a new `IBase` object with a new `properties` array.
5. In [`use-base-table.ts`](apps/client/src/features/base/hooks/use-base-table.ts), `properties = useMemo(() => base?.properties ?? [], [base?.properties])` picks up the new array. `columns = useMemo(() => buildColumns(properties), [properties])` rebuilds all column defs, including new `meta.property` objects and new `header: property.name` values.
6. `useReactTable({columns, ...})` receives the new columns array. Internally TanStack Table updates its column state.
7. The `table` instance returned by `useReactTable` is the SAME reference it was before — it's memoized for stability.
8. `<GridHeader table={table} columnOrder={...} />` is wrapped in `React.memo`. Its props: `table` (stable), `columnOrder` (unchanged — rename doesn't reorder), `loadedRowIds` (unchanged). Memo shallow-compare says "no change" → **no re-render**.
9. Even if `GridHeader` did re-render, each `<GridHeaderCell key={header.id} header={header} />` is also `memo`'d. `header` is reused by TanStack Table across renders for the same column id, so same ref → memo skips → **no re-render**.
10. [`view-field-visibility.tsx:27-31`](apps/client/src/features/base/components/views/view-field-visibility.tsx:27): `const columns = useMemo(() => table.getAllLeafColumns().filter(...), [table])`. `table` is stable → memo never invalidates → shows stale names.
The rename only becomes visible after a full mount (tab reload), which recomputes everything from scratch.
## Files
**Modified:**
- `apps/client/src/features/base/components/grid/grid-header.tsx` — accept `properties: IBaseProperty[]` prop; pass `property={...}` to each `GridHeaderCell`. Memo picks up the change.
- `apps/client/src/features/base/components/grid/grid-header-cell.tsx` — accept `property` as an explicit prop instead of deriving from `header.column.columnDef.meta?.property`. Use it for header rendering (and anywhere else in this file that currently reads it through the meta).
- `apps/client/src/features/base/components/grid/grid-container.tsx` — accept `properties` prop; pass to `<GridHeader>`.
- `apps/client/src/features/base/components/base-table.tsx` — pass `base?.properties` to `<GridContainer>`.
- `apps/client/src/features/base/components/base-toolbar.tsx` — pass `properties={base.properties}` to `<ViewFieldVisibility>`.
- `apps/client/src/features/base/components/views/view-field-visibility.tsx` — accept `properties` prop; include it in the `useMemo([table, properties])` dep list.
No new files. No server changes. No new deps.
---
## Task 1: `GridHeaderCell` — accept `property` as a prop
**Files:**
- Modify: `apps/client/src/features/base/components/grid/grid-header-cell.tsx`
- [ ] **Step 1: Add `property` to `GridHeaderCellProps`**
```tsx
type GridHeaderCellProps = {
header: Header<IBaseRow, unknown>;
property: IBaseProperty | undefined;
loadedRowIds: string[];
};
```
- [ ] **Step 2: Replace the internal property derivation with the prop**
Find the line near the top of the component:
```tsx
const property = header.column.columnDef.meta?.property as
| IBaseProperty
| undefined;
```
Remove it. Add `property` to the function parameter destructuring:
```tsx
export const GridHeaderCell = memo(function GridHeaderCell({
header,
property,
loadedRowIds,
}: GridHeaderCellProps) {
```
Everything else in the file continues to reference the same `property` variable, now a prop. No further changes needed in this file.
- [ ] **Step 3: Build**
```bash
pnpm nx run client:build
```
Build will FAIL because `GridHeader` doesn't yet pass `property`. That's fine — fixed in the next task. Do not commit yet.
---
## Task 2: `GridHeader` — thread `properties` / `property` through
**Files:**
- Modify: `apps/client/src/features/base/components/grid/grid-header.tsx`
- [ ] **Step 1: Add `properties` prop and use it to look up per-cell property**
Before:
```tsx
type GridHeaderProps = {
table: Table<IBaseRow>;
baseId?: string;
// Passed explicitly to break memo when columns change
// (table ref is stable from useReactTable, so memo won't fire without this)
columnOrder: ColumnOrderState;
loadedRowIds: string[];
onPropertyCreated?: () => void;
};
export const GridHeader = memo(function GridHeader({
table,
baseId,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
columnOrder: _columnOrder,
loadedRowIds,
onPropertyCreated,
}: GridHeaderProps) {
const headerGroups = table.getHeaderGroups();
return (
<div className={classes.headerRow} role="row">
{headerGroups[0]?.headers.map((header) => (
<GridHeaderCell key={header.id} header={header} loadedRowIds={loadedRowIds} />
))}
...
```
After:
```tsx
import { IBaseProperty, IBaseRow } from "@/features/base/types/base.types";
type GridHeaderProps = {
table: Table<IBaseRow>;
baseId?: string;
// Passed explicitly to break memo when columns change
// (table ref is stable from useReactTable, so memo won't fire without these)
columnOrder: ColumnOrderState;
properties: IBaseProperty[];
loadedRowIds: string[];
onPropertyCreated?: () => void;
};
export const GridHeader = memo(function GridHeader({
table,
baseId,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
columnOrder: _columnOrder,
properties,
loadedRowIds,
onPropertyCreated,
}: GridHeaderProps) {
const headerGroups = table.getHeaderGroups();
const propertyById = useMemo(() => {
const map = new Map<string, IBaseProperty>();
for (const p of properties) map.set(p.id, p);
return map;
}, [properties]);
return (
<div className={classes.headerRow} role="row">
{headerGroups[0]?.headers.map((header) => (
<GridHeaderCell
key={header.id}
header={header}
property={propertyById.get(header.column.id)}
loadedRowIds={loadedRowIds}
/>
))}
...
```
Make sure `useMemo` is imported from react (it already imports `memo`; add `useMemo` alongside).
- [ ] **Step 2: Build**
```bash
pnpm nx run client:build
```
Still expected to fail — `GridContainer` doesn't yet pass `properties`. Next task.
---
## Task 3: `GridContainer` — accept and forward `properties`
**Files:**
- Modify: `apps/client/src/features/base/components/grid/grid-container.tsx`
- [ ] **Step 1: Add `properties` to props**
Near the existing `GridContainerProps` type (look near the top of the file), add:
```tsx
properties: IBaseProperty[];
```
Add `IBaseProperty` to the existing `@/features/base/types/base.types` import at the top of the file if it's not already imported.
Destructure it in the component signature.
- [ ] **Step 2: Pass it to `<GridHeader>`**
Find the `<GridHeader ... />` JSX at roughly line 239-245 and add:
```tsx
<GridHeader
table={table}
baseId={baseId}
columnOrder={table.getState().columnOrder}
properties={properties}
loadedRowIds={rowIds}
onPropertyCreated={handlePropertyCreated}
/>
```
- [ ] **Step 3: Build**
Still expected to fail — `BaseTable` doesn't yet pass `properties` to `GridContainer`. Next task.
---
## Task 4: `BaseTable` — pass `base.properties` to `GridContainer`
**Files:**
- Modify: `apps/client/src/features/base/components/base-table.tsx`
- [ ] **Step 1: Add the prop**
Find the `<GridContainer ... />` JSX at line 187. Add:
```tsx
<GridContainer
table={table}
properties={base.properties}
onCellUpdate={handleCellUpdate}
...
/>
```
`base` is already guaranteed non-null at this point — line 174 has `if (!base) return null;`.
- [ ] **Step 2: Build**
```bash
pnpm nx run client:build
```
Should succeed now — grid path is complete.
- [ ] **Step 3: Commit the grid-side changes as one unit**
```bash
git add \
apps/client/src/features/base/components/grid/grid-header-cell.tsx \
apps/client/src/features/base/components/grid/grid-header.tsx \
apps/client/src/features/base/components/grid/grid-container.tsx \
apps/client/src/features/base/components/base-table.tsx
git commit -m "fix(base): refresh grid headers when a property is renamed"
```
The four files have to land together or the build is broken — one commit.
---
## Task 5: `ViewFieldVisibility` — accept `properties` and include it in the memo
**Files:**
- Modify: `apps/client/src/features/base/components/views/view-field-visibility.tsx`
- [ ] **Step 1: Add `properties` to the props type**
```tsx
type ViewFieldVisibilityProps = {
opened: boolean;
onClose: () => void;
table: Table<IBaseRow>;
properties: IBaseProperty[];
onPersist: () => void;
children: React.ReactNode;
};
```
- [ ] **Step 2: Add `properties` to the `useMemo` dep list**
Change:
```tsx
const columns = useMemo(() => {
return table
.getAllLeafColumns()
.filter((col) => col.id !== "__row_number");
}, [table]);
```
To:
```tsx
const columns = useMemo(() => {
return table
.getAllLeafColumns()
.filter((col) => col.id !== "__row_number");
}, [table, properties]);
```
We still derive columns from `table` (that's where `col.getIsVisible()` / `col.getCanHide()` live), but `properties` is added as a dep so the memo invalidates whenever properties change — forcing a re-read of `table.getAllLeafColumns()` which by then reflects the renamed metadata.
Also destructure `properties` in the function signature.
- [ ] **Step 3: Build**
Expected to fail until the toolbar passes `properties`.
---
## Task 6: `BaseToolbar` — pass `base.properties` to `ViewFieldVisibility`
**Files:**
- Modify: `apps/client/src/features/base/components/base-toolbar.tsx`
- [ ] **Step 1: Pass the prop**
Find `<ViewFieldVisibility ... />` (near the bottom of the file). Add:
```tsx
<ViewFieldVisibility
opened={fieldsOpened}
onClose={() => setFieldsOpened(false)}
table={table}
properties={base.properties}
onPersist={onPersistViewConfig}
>
```
`base` is already a prop on `BaseToolbar` — no other plumbing needed.
- [ ] **Step 2: Build**
```bash
pnpm nx run client:build
```
Should succeed.
- [ ] **Step 3: Commit the popover-side changes**
```bash
git add \
apps/client/src/features/base/components/views/view-field-visibility.tsx \
apps/client/src/features/base/components/base-toolbar.tsx
git commit -m "fix(base): refresh hide-fields popover when a property is renamed"
```
---
## Task 7: USER smoke test
> ⚠️ **Do not run `pnpm dev` as an agent.** Hand off to the user.
Ask the user to run through:
- [ ] **Local rename updates the grid header instantly.**
1. Open a base.
2. Click a column header → Rename → type a new name → press Enter.
3. The column header text updates immediately — no reload.
- [ ] **Local rename updates the hide-fields popover instantly.**
1. After renaming, open the Hide fields popover.
2. The property's entry in the list shows the new name.
- [ ] **Remote rename (other client) updates without reload.**
1. Open the same base in two browsers / tabs (A and B).
2. In A, rename a property.
3. In B, within a second, the header text (and hide-fields popover) show the new name.
- [ ] **Regression: column resize, reorder, hide, sort, filter all still work.**
If all pass, the fix is complete. Otherwise report back with which case failed.
---
## Out of scope
- `ViewSortConfigPopover` / `ViewFilterConfigPopover` also show property names, but they read from `base.properties` directly (they already get `base` as a prop and re-render when it changes), so they weren't broken by this bug. Not touching them.
- Property icons (`property.type` change). A type change already bumps `schemaVersion` on the base, which invalidates and refetches — that path works. Out of scope here.
- Server-side — rename already persists + broadcasts correctly.
@@ -0,0 +1,231 @@
# Infinite Scroll Fetch Loop Fix v2 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.
**Goal:** Kill the pagination loop that still fires extra `POST /bases/rows` requests as the user scrolls continuously through a 10K+ base, even after the v1 rows-length guard.
**Architecture:** Replace the `virtualItems`-polling effect with a scroll-event-driven trigger plus a **synchronous** pending-fetch ref. Two independent dedup mechanisms: (a) only consider firing after a real scroll event (debounced), (b) never dispatch a second `fetchNextPage` until the previous one's resulting page has been committed to the cache. The previous v1 fix relied on `rows.length` growing after each commit, which under sustained scrolling lets react-query schedule multiple fetches before its own `isFetchingNextPage` observed-true state propagates to our render.
**Tech Stack:** React 18, `@tanstack/react-virtual`, `@tanstack/react-query` v5.
---
## Background
### What v1 fixed and what it missed
v1 ([grid-container.tsx, commit `c4d8b6c3`](apps/client/src/features/base/components/grid/grid-container.tsx)) added `lastTriggeredRowsLenRef` — "don't fire again until `rows.length` grows past the last value we triggered on." That breaks the *idle-at-bottom* loop (where the effect was firing every render on the same `rows.length`).
It still leaks under sustained scrolling:
1. User scrolls rapidly. Effect runs. Condition holds. Ref updates to N. `onFetchNextPage()` dispatched.
2. react-query enqueues a fetch. But the internal state object mutations reaching our `isFetchingNextPage` snapshot happen across a microtask boundary; within the SAME React render commit there's a window where `isFetchingNextPage` is still `false` from React's perspective.
3. The effect dep list includes `virtualItems`, which `virtualizer.getVirtualItems()` mints fresh every render. Any other state change (scroll position, virtual measurements, React 18 batching flushes) re-fires the effect.
4. Our `rows.length <= ref` gate blocks further fires AT THE SAME LENGTH. But because the user is SCROLLING, as soon as a page DOES commit, `rows.length` jumps, and on that same commit render the effect can fire repeatedly (e.g., due to scroll-driven re-measures) because the `isFetchingNextPage` false window can overlap with the new render.
5. If enough fetches pile up, react-query dispatches each in sequence. Requests are only partly deduped — each call while a fetch is in flight returns the same promise, BUT if the commit has already landed and `isFetchingNextPage` flipped briefly to false before our render observed it, that call dispatches a NEW fetch. Net: 10× more dispatches than pages committed.
Reported symptom: on the 10K base the loop kicks in after a while of scrolling. Network panel shows `1176 requests` initiated, only `124` loaded — ~10× over-dispatch.
### The fix shape
Two orthogonal locks that together make over-dispatch impossible:
**Lock A — synchronous pending flag.** A `pendingFetchRef` set to `true` SYNCHRONOUSLY right before `onFetchNextPage()` is called. Cleared in a separate effect that watches `isFetchingNextPage` transitioning back to `false` (i.e., the fetch that we started has resolved). While the ref is set, the trigger never re-fires. This eliminates the same-render-double-fire window that the v1 length guard couldn't cover.
**Lock B — real scroll events, not render-driven polling.** Attach a listener to `scrollRef`'s `scroll` event, debounced ~50 ms. On each debounced scroll, compute "am I near the bottom" from raw `scrollTop + clientHeight >= scrollHeight - threshold`. This removes `virtualItems` from the trigger path entirely — no effect is running on every render.
With both locks, the worst-case dispatch cadence is *one per debounced scroll tick where you're near the bottom*. Combined with Lock A's pending gate, you get *at most one request in flight at a time*, committing sequentially as the user scrolls.
Keep the v1 `lastTriggeredRowsLenRef` guard as a safety net (it's cheap and prevents re-trigger against stale row-set data).
---
## File Structure
**Modified:**
- `apps/client/src/features/base/components/grid/grid-container.tsx` — replace the current trigger effect; add a pending ref + a reset effect.
No new files, no new deps, nothing server-side.
---
## Task 1: Replace the trigger with a scroll-driven + pending-guarded version
**File:** `apps/client/src/features/base/components/grid/grid-container.tsx`
### Step 1: Add a pending ref next to the existing `lastTriggeredRowsLenRef`
Inside `GridContainer`, right after `const lastTriggeredRowsLenRef = useRef(0);` (around line 70), add:
```ts
// Synchronous guard: set to true the moment we dispatch `onFetchNextPage`,
// cleared only after `isFetchingNextPage` has transitioned back to false.
// This closes the "React 18 batching / snapshot staleness" window where
// `isFetchingNextPage` from the hook is still observed false even though
// a dispatch is already in flight — which is how the v1 length-only
// guard still permits over-dispatch during sustained scrolling.
const pendingFetchRef = useRef(false);
```
### Step 2: DELETE the existing trigger effect
Locate the effect at roughly lines 122-130:
```ts
useEffect(() => {
if (!hasNextPage || isFetchingNextPage || !onFetchNextPage) return;
const lastItem = virtualItems[virtualItems.length - 1];
if (!lastItem) return;
if (lastItem.index < rows.length - OVERSCAN * 2) return;
if (rows.length <= lastTriggeredRowsLenRef.current) return;
lastTriggeredRowsLenRef.current = rows.length;
onFetchNextPage();
}, [virtualItems, rows.length, hasNextPage, isFetchingNextPage, onFetchNextPage]);
```
Delete it entirely. We're replacing render-polling with scroll-event-driven.
### Step 3: Add a new scroll-event-driven effect in its place
Insert this (same location):
```ts
// Scroll-event-driven pagination trigger. Previously this was a
// render-effect that polled `virtualItems` — which runs on every render
// (virtualItems has fresh identity each call) and over-dispatches when
// React's `isFetchingNextPage` snapshot is stale relative to react-query's
// in-flight state. A plain scroll event with a small debounce and a
// synchronous pending ref fires at most once per scroll pulse AND
// at most one in-flight request at a time.
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const NEAR_BOTTOM_PX = ROW_HEIGHT * OVERSCAN * 2;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const maybeFetch = () => {
if (!onFetchNextPage) return;
if (!hasNextPage) return;
if (isFetchingNextPage) return;
if (pendingFetchRef.current) return;
const node = scrollRef.current;
if (!node) return;
const distanceFromBottom =
node.scrollHeight - (node.scrollTop + node.clientHeight);
if (distanceFromBottom > NEAR_BOTTOM_PX) return;
if (rows.length <= lastTriggeredRowsLenRef.current) return;
pendingFetchRef.current = true;
lastTriggeredRowsLenRef.current = rows.length;
onFetchNextPage();
};
const onScroll = () => {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(maybeFetch, 50);
};
// Also evaluate once on mount / when deps change — covers the case
// where the user hasn't scrolled yet but the viewport is already
// past the near-bottom threshold (e.g. first page is short).
maybeFetch();
el.addEventListener("scroll", onScroll, { passive: true });
return () => {
el.removeEventListener("scroll", onScroll);
if (debounceTimer) clearTimeout(debounceTimer);
};
}, [rows.length, hasNextPage, isFetchingNextPage, onFetchNextPage]);
```
Notes the implementer must not change:
- The `maybeFetch()` call BEFORE adding the scroll listener is intentional — it handles "viewport already past threshold on mount/commit" without requiring a scroll.
- `NEAR_BOTTOM_PX = ROW_HEIGHT * OVERSCAN * 2` keeps the trigger threshold equivalent to the old `lastItem.index >= rows.length - OVERSCAN * 2` rule (20 rows * 36 px = 720 px).
- `pendingFetchRef.current = true` is set BEFORE `onFetchNextPage()` so a synchronous re-entry can't slip through.
- `passive: true` on the listener is performance-critical on large lists.
### Step 4: Add an effect that clears `pendingFetchRef` when a fetch resolves
Immediately after the effect from Step 3, add:
```ts
useEffect(() => {
if (!isFetchingNextPage) {
// react-query's fetch we triggered has resolved (data committed +
// isFetchingNextPage back to false). Release the pending gate.
pendingFetchRef.current = false;
}
}, [isFetchingNextPage]);
```
This is the counterpart to Step 3's `pendingFetchRef.current = true`. The flag lifecycle:
- `false` initially
- set `true` synchronously just before `onFetchNextPage()`
- set back to `false` as soon as `isFetchingNextPage` observed goes back to `false`
Between those, no new dispatch is allowed.
### Step 5: Keep the existing reset effect
The effect at roughly lines 132-140 that resets `lastTriggeredRowsLenRef` to 0 when `rows.length` drops (view/filter/sort switch) must stay as-is. Do NOT delete it.
### Step 6: Build
```bash
pnpm nx run client:build
```
Expected: success.
### Step 7: Commit
```bash
git add apps/client/src/features/base/components/grid/grid-container.tsx
git commit -m "fix(base): drive pagination from scroll events with in-flight gate to kill dispatch loop"
```
---
## Task 2: USER smoke test
> ⚠️ **Do not run `pnpm dev` as an agent.** Hand off.
On the 10K base (should also work on 100K):
- [ ] **Rapid continuous scroll to bottom.**
1. DevTools → Network → filter to `Fetch/XHR` → clear.
2. Scroll the scrollbar smoothly from top to bottom of the 10K base, no pauses.
3. Expected: roughly one `POST /bases/rows` per page (~100 total for 10K rows / 100 per page). NO "n requests in flight with only k loaded" state — completed count should track initiated count closely.
- [ ] **Idle at bottom for 30 s.**
1. After reaching the bottom, wait.
2. Zero additional requests fire.
- [ ] **Scroll up and back down.**
1. Scroll up to row 5000, then back to bottom.
2. No refetch of already-cached pages; only new pages (if any remain) fire.
- [ ] **Sort change mid-scroll.**
1. While at row 5000, change the sort from Title-asc to Title-desc.
2. Pagination resets cleanly. Fetches the first page of the new sort order; scrolling continues to fetch normally.
- [ ] **Filter that narrows to few hundred rows.**
1. Apply a filter producing ~200 matches.
2. Scroll to bottom. Exactly ~2 fetches (first page was already loaded + next one). Stops cleanly.
- [ ] **Regression: unsorted base.**
1. Remove sort/filter. Scroll through. Fetches still fire correctly.
- [ ] **Regression: create row at top of scroll.**
1. Scroll to top, add a new row. It appears. No extra pagination fires.
If any step shows over-dispatch (initiated » loaded) or misses a page, report which with approximate counts.
---
## Out of scope
- Swapping to an `IntersectionObserver` sentinel. Scroll-event + debounce achieves the same dedup without the complexity of maintaining a sentinel element inside a virtualized grid.
- Measuring real row heights via `measureElement`. Unrelated to the dispatch loop; useful later for pixel-perfect scrolling.
- A visible "loading more…" indicator during fetch. UX only.
@@ -0,0 +1,189 @@
# Infinite Scroll Fetch Loop Fix 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.
**Goal:** Stop `fetchNextPage` from firing in a tight re-render loop when the user is near the bottom of a large sorted base — currently produces 900+ redundant page fetches and only stops when the user scrolls back up.
**Architecture:** The current trigger is a `useEffect` whose dependency list includes `virtualItems` (a fresh array from `virtualizer.getVirtualItems()` on every render). The effect re-runs on every render, and once the "near bottom" condition is satisfied it re-fires until the virtualizer's computed visible range moves away from the end. Fix: gate the trigger with a ref that records the `rows.length` at which we last fetched — guarantees at most one fetch per new page of data until the user actually scrolls further down.
**Tech Stack:** React 18, `@tanstack/react-virtual`, `@tanstack/react-query` v5 `useInfiniteQuery`.
---
## Background
Screenshot from the field shows the Network panel accumulating `POST /bases/rows` requests — roughly 1 request per ~40 ms — for the 10K-row seed base while a sort is active. Status stays at "50 / 971 requests" and climbing. The loop ends only when the user scrolls back up.
### Why the loop happens
Current trigger at [grid-container.tsx:114-121](apps/client/src/features/base/components/grid/grid-container.tsx:114):
```ts
useEffect(() => {
if (!hasNextPage || isFetchingNextPage || !onFetchNextPage) return;
const lastItem = virtualItems[virtualItems.length - 1];
if (!lastItem) return;
if (lastItem.index >= rows.length - OVERSCAN * 2) {
onFetchNextPage();
}
}, [virtualItems, rows.length, hasNextPage, isFetchingNextPage, onFetchNextPage]);
```
Three compounding issues:
1. **`virtualItems` is a fresh array on every render.** `virtualizer.getVirtualItems()` returns a newly-constructed array each call, so React sees a changed dep every render. The effect executes on every render.
2. **After a fetch resolves, rows grow but `lastItem.index` can still satisfy the "near bottom" threshold** — particularly when the user was scrolled all the way to the bottom. The virtualizer preserves `scrollTop`, so the previously-rendered last row is now near the middle-end of the new (larger) virtual list, and its index is right inside the `rows.length - OVERSCAN * 2` window.
3. **Heights are estimated via `estimateSize: () => 36`.** When `.cell`'s actual rendered height exceeds 36 (min-height only), the virtualizer re-measures, which shifts the reported `virtualItems` by a few indices each render — enough to keep `lastItem.index` hovering inside the trigger zone indefinitely.
So on each render: `virtualItems` has new identity → effect runs → `isFetchingNextPage` just transitioned to `false` after the previous page's commit → condition still holds → fire again. The loop can only break when the user scrolls far enough up that `lastItem.index < rows.length - OVERSCAN * 2` on the next render.
### The fix shape
Add a ref `lastTriggeredRowsLenRef` that records the `rows.length` value at which we last fired `onFetchNextPage`. The trigger is allowed only when `rows.length > lastTriggeredRowsLenRef.current` — i.e., the previous fetch has committed new rows, AND we haven't yet fired against this new length. When a page arrives, `rows.length` grows, the guard permits one fire, we update the ref, and subsequent re-renders at the same `rows.length` are no-ops.
This pattern is standard for effect-based infinite scroll and is exactly what's missing here.
---
## File Structure
**Modified:**
- `apps/client/src/features/base/components/grid/grid-container.tsx` — one ref + one guard line added to the existing effect.
No new files, no new deps, nothing server-side.
---
## Task 1: Guard the fetch trigger against repeat fires at the same rows.length
**File:** `apps/client/src/features/base/components/grid/grid-container.tsx`
- [ ] **Step 1: Add `useRef` to the existing react import**
The file currently imports `{ useCallback, useEffect, useMemo, useRef, useState }` — confirm `useRef` is already present (it almost certainly is, given `scrollRef` exists). If not, add it.
- [ ] **Step 2: Declare the ref next to the other refs inside `GridContainer`**
Find where `scrollRef` is declared near the top of the component. Add immediately after it:
```ts
// Records the `rows.length` at which we last triggered a page fetch.
// The trigger effect re-runs on every render (its `virtualItems` dep
// has a new identity each call) and can't rely on `isFetchingNextPage`
// alone: once a page commits, `isFetchingNextPage` flips to false for
// one render, the "near bottom" condition still holds because the
// virtualizer anchors on the old scroll position, and we'd fire again.
// Gating on `rows.length` guarantees at most one fire per new page.
const lastTriggeredRowsLenRef = useRef(0);
```
- [ ] **Step 3: Add the guard to the trigger effect**
Replace the existing effect block at roughly lines 114-121:
Before:
```ts
useEffect(() => {
if (!hasNextPage || isFetchingNextPage || !onFetchNextPage) return;
const lastItem = virtualItems[virtualItems.length - 1];
if (!lastItem) return;
if (lastItem.index >= rows.length - OVERSCAN * 2) {
onFetchNextPage();
}
}, [virtualItems, rows.length, hasNextPage, isFetchingNextPage, onFetchNextPage]);
```
After:
```ts
useEffect(() => {
if (!hasNextPage || isFetchingNextPage || !onFetchNextPage) return;
const lastItem = virtualItems[virtualItems.length - 1];
if (!lastItem) return;
if (lastItem.index < rows.length - OVERSCAN * 2) return;
if (rows.length <= lastTriggeredRowsLenRef.current) return;
lastTriggeredRowsLenRef.current = rows.length;
onFetchNextPage();
}, [virtualItems, rows.length, hasNextPage, isFetchingNextPage, onFetchNextPage]);
```
Why `<=` and not `<`: after a fire, the ref holds the pre-fetch `rows.length`. When the page commits, `rows.length` grows, so `rows.length > ref` and the next fire is allowed (exactly once, since the ref then captures the new length).
- [ ] **Step 4: Reset the ref when the row set resets (new base / view / sort / filter)**
Different query-key means `rowsData` is discarded and pagination starts over from page 1. We need to reset our guard too, or it'll block the first fetch of the next query.
Find the existing effect (around line 62 in the parent `BaseTable`, or an equivalent in `GridContainer`) that reacts to `baseId` / activeView id changing, OR use the infinite query's data being discarded. In `GridContainer`, the `rows` prop comes in fresh when the parent re-runs the infinite query. When `rows.length` becomes `0` (reset) OR becomes *smaller* than what we'd last seen, the ref must reset. Add this effect AFTER the trigger effect from Step 3:
```ts
useEffect(() => {
// When the underlying row set shrinks (filter changed, sort toggled,
// view switched) or resets to zero, we're on a fresh pagination
// sequence — un-gate the trigger so the first page triggers a
// potential next fetch correctly.
if (rows.length === 0 || rows.length < lastTriggeredRowsLenRef.current) {
lastTriggeredRowsLenRef.current = 0;
}
}, [rows.length]);
```
- [ ] **Step 5: Build**
```bash
pnpm nx run client:build
```
Expected: success.
- [ ] **Step 6: Commit**
```bash
git add apps/client/src/features/base/components/grid/grid-container.tsx
git commit -m "fix(base): stop infinite fetch loop when sorted list scrolled to bottom"
```
---
## Task 2: USER smoke test
> ⚠️ **Do not run `pnpm dev` as an agent.** Hand off.
On the 10K seed base:
- [ ] **Sorted scroll to bottom, count requests.**
1. Open the 10K base. Apply sort by Title ascending.
2. Open DevTools → Network → filter to `Fetch/XHR`.
3. Clear the Network log.
4. Scroll to the very bottom using the scrollbar.
5. Count the `bases/rows` requests. Expected: roughly 10K / 100 = **~100**, not 900+. It's fine if it's slightly over 100 due to pre-fetch, but it should cleanly stop within a few seconds of reaching the bottom.
- [ ] **Dwell at the bottom without scrolling.**
1. Stay at the bottom for 10 seconds.
2. No additional `bases/rows` requests should fire. (Before the fix: would be dozens per second.)
- [ ] **Scroll back up, then back down.**
1. Scroll to top. Verify sort order is still correct (the earlier fix for client-side position sort should still hold).
2. Scroll back to bottom. Should not refetch already-cached pages.
- [ ] **Change the sort.**
1. Remove the current sort and add a different one.
2. Query resets. Pagination restarts from page 1. No lingering loop.
- [ ] **Unsorted base (regression).**
1. Remove all sorts.
2. Scroll through. Fetching still works and stops correctly.
- [ ] **Filter applied.**
1. Add a filter that returns ~2,000 matches.
2. Scroll. Should fetch ~20 pages and stop cleanly.
If any step misbehaves — especially if requests still loop — capture the Network count and which filter/sort was active, and report back.
---
## Out of scope
- Switching from the effect-based trigger to an `IntersectionObserver` sentinel. Cleaner pattern but larger diff; the ref guard is the surgical fix for the reported bug.
- Making the virtualizer measure actual row heights via `measureElement` (would remove one of the three compounding causes). Worth it later; not required to fix the loop.
- Showing a visible "loading more…" indicator at the bottom during fetch. Orthogonal UX improvement.