# Base Row Selection & Bulk Delete 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 Airtable-style multi-row selection (checkbox in row-number cell) + bulk delete via a floating action bar, backed by a new batch-delete endpoint. **Architecture:** Client stores selection in a jotai atom scoped to the active base/view. Row-number cell swaps between row-index and drag-handle+checkbox based on hover/selection state. A floating action bar fires `POST /bases/rows/delete-many`. Server adds a single-UPDATE soft-delete path that emits one `BASE_ROWS_DELETED` event relayed to clients over the existing base socket room. **Tech Stack:** NestJS + Kysely (server), React + TanStack Table + jotai + Mantine (client). No new dependencies. **Spec:** `docs/superpowers/specs/2026-04-18-base-row-selection-design.md` --- ## Build order Server endpoint first (so client mutations have something to hit), then client state + mutation, then UI, then realtime reconciliation. --- ### Task 1: Add `softDeleteMany` + `findByIds` to base-row repo **Files:** - Modify: `apps/server/src/database/repos/base/base-row.repo.ts` Pattern matches the existing `findById` and `softDelete` methods in the same file. - [ ] **Step 1: Add `findByIds` method** After the existing `findById` method, add: ```ts async findByIds( rowIds: string[], opts: WorkspaceOpts, ): Promise { if (rowIds.length === 0) return []; const db = dbOrTx(this.db, opts.trx); return (await db .selectFrom('baseRows') .select(BASE_ROW_COLUMNS) .where('id', 'in', rowIds) .where('workspaceId', '=', opts.workspaceId) .where('deletedAt', 'is', null) .execute()) as BaseRow[]; } ``` - [ ] **Step 2: Add `softDeleteMany` method** After the existing `softDelete` method, add: ```ts async softDeleteMany( rowIds: string[], opts: { baseId: string; workspaceId: string; trx?: KyselyTransaction; }, ): Promise { if (rowIds.length === 0) return; const db = dbOrTx(this.db, opts.trx); await db .updateTable('baseRows') .set({ deletedAt: new Date() }) .where('id', 'in', rowIds) .where('baseId', '=', opts.baseId) .where('workspaceId', '=', opts.workspaceId) .where('deletedAt', 'is', null) .execute(); } ``` - [ ] **Step 3: Build server** Run: `pnpm nx run server:build` Expected: build succeeds. - [ ] **Step 4: Commit** ```bash git add apps/server/src/database/repos/base/base-row.repo.ts git commit -m "feat(base): add findByIds and softDeleteMany to base-row repo" ``` --- ### Task 2: Add `DeleteRowsDto` for batch delete **Files:** - Modify: `apps/server/src/core/base/dto/update-row.dto.ts` - [ ] **Step 1: Add DTO** Append to the end of the file: ```ts export class DeleteRowsDto { @IsUUID() baseId: string; @IsArray() @ArrayMinSize(1) @ArrayMaxSize(500) @IsUUID('all', { each: true }) rowIds: string[]; @IsOptional() @IsString() requestId?: string; } ``` Ensure imports at the top of the file include `IsArray`, `ArrayMinSize`, `ArrayMaxSize` from `class-validator` (add any missing ones to the existing import line). - [ ] **Step 2: Build server** Run: `pnpm nx run server:build` Expected: build succeeds. - [ ] **Step 3: Commit** ```bash git add apps/server/src/core/base/dto/update-row.dto.ts git commit -m "feat(base): add DeleteRowsDto for batch row delete" ``` --- ### Task 3: Add `BASE_ROWS_DELETED` event name + payload type **Files:** - Modify: `apps/server/src/common/events/event.contants.ts` - Modify: `apps/server/src/core/base/events/base-events.ts` - [ ] **Step 1: Add event name constant** In `apps/server/src/common/events/event.contants.ts`, add a new enum member near the existing `BASE_ROW_DELETED`: ```ts BASE_ROWS_DELETED = 'base.rows.deleted', ``` - [ ] **Step 2: Add event payload type** In `apps/server/src/core/base/events/base-events.ts`, add after `BaseRowDeletedEvent`: ```ts export type BaseRowsDeletedEvent = BaseEventBase & { rowIds: string[] }; ``` - [ ] **Step 3: Build server** Run: `pnpm nx run server:build` Expected: build succeeds. - [ ] **Step 4: Commit** ```bash git add apps/server/src/common/events/event.contants.ts apps/server/src/core/base/events/base-events.ts git commit -m "feat(base): add BASE_ROWS_DELETED event type" ``` --- ### Task 4: Add `deleteMany` service method **Files:** - Modify: `apps/server/src/core/base/services/base-row.service.ts` - [ ] **Step 1: Add import for new DTO and event** In the imports block: - Ensure `DeleteRowsDto` is imported from `../dto/update-row.dto`. - Ensure `BaseRowsDeletedEvent` is imported from `../events/base-events`. - [ ] **Step 2: Add `deleteMany` method after existing `delete`** ```ts async deleteMany( dto: DeleteRowsDto, workspaceId: string, userId?: string, ): Promise { const rows = await this.baseRowRepo.findByIds(dto.rowIds, { workspaceId }); if (rows.length !== dto.rowIds.length) { throw new NotFoundException('One or more rows not found'); } if (rows.some((r) => r.baseId !== dto.baseId)) { throw new NotFoundException('Row does not belong to base'); } await this.baseRowRepo.softDeleteMany(dto.rowIds, { baseId: dto.baseId, workspaceId, }); const event: BaseRowsDeletedEvent = { baseId: dto.baseId, workspaceId, actorId: userId ?? null, requestId: dto.requestId ?? null, rowIds: dto.rowIds, }; this.eventEmitter.emit(EventName.BASE_ROWS_DELETED, event); } ``` - [ ] **Step 3: Build server** Run: `pnpm nx run server:build` Expected: build succeeds. - [ ] **Step 4: Commit** ```bash git add apps/server/src/core/base/services/base-row.service.ts git commit -m "feat(base): add deleteMany service method for batch row delete" ``` --- ### Task 5: Add `delete-many` controller endpoint **Files:** - Modify: `apps/server/src/core/base/controllers/base-row.controller.ts` - [ ] **Step 1: Import `DeleteRowsDto`** Add `DeleteRowsDto` to the existing `update-row.dto` import line. - [ ] **Step 2: Add endpoint handler** Insert after the existing `@Post('delete')` handler (i.e. after the `delete` method that ends at line 119): ```ts @HttpCode(HttpStatus.OK) @Post('delete-many') async deleteMany( @Body() dto: DeleteRowsDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace, ) { 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.Edit, SpaceCaslSubject.Base)) { throw new ForbiddenException(); } await this.baseRowService.deleteMany(dto, workspace.id, user.id); } ``` - [ ] **Step 3: Build server** Run: `pnpm nx run server:build` Expected: build succeeds. - [ ] **Step 4: Commit** ```bash git add apps/server/src/core/base/controllers/base-row.controller.ts git commit -m "feat(base): add POST /bases/rows/delete-many endpoint" ``` --- ### Task 6: Realtime consumer for batch row delete **Files:** - Modify: `apps/server/src/core/base/realtime/base-ws-consumers.ts` - [ ] **Step 1: Import the new event type** Add `BaseRowsDeletedEvent` to the import from `../events/base-events`. - [ ] **Step 2: Add the event handler** Insert after the existing `onRowDeleted` handler (approx line 64): ```ts @OnEvent(EventName.BASE_ROWS_DELETED) onRowsDeleted(e: BaseRowsDeletedEvent) { this.ws.emitToBase(e.baseId, { operation: 'base:rows:deleted', baseId: e.baseId, rowIds: e.rowIds, actorId: e.actorId ?? null, requestId: e.requestId ?? null, }); } ``` - [ ] **Step 3: Build server** Run: `pnpm nx run server:build` Expected: build succeeds. - [ ] **Step 4: Commit** ```bash git add apps/server/src/core/base/realtime/base-ws-consumers.ts git commit -m "feat(base): emit base:rows:deleted websocket event" ``` --- ### Task 7: Client service + types for batch delete **Files:** - Modify: `apps/client/src/features/base/types/base.types.ts` - Modify: `apps/client/src/features/base/services/base-service.ts` - [ ] **Step 1: Add `DeleteRowsInput` type** In `base.types.ts`, find the existing `DeleteRowInput` type and add just below it: ```ts export type DeleteRowsInput = { baseId: string; rowIds: string[]; requestId?: string; }; ``` - [ ] **Step 2: Add `deleteRows` service function** In `base-service.ts`, add `DeleteRowsInput` to the type imports from `@/features/base/types/base.types`, then add after the existing `deleteRow`: ```ts export async function deleteRows(data: DeleteRowsInput): Promise { await api.post("/bases/rows/delete-many", data); } ``` - [ ] **Step 3: Build client** Run: `pnpm nx run client:build` Expected: build succeeds. - [ ] **Step 4: Commit** ```bash git add apps/client/src/features/base/types/base.types.ts apps/client/src/features/base/services/base-service.ts git commit -m "feat(base): add deleteRows client service + type" ``` --- ### Task 8: `useDeleteRowsMutation` with optimistic update **Files:** - Modify: `apps/client/src/features/base/queries/base-row-query.ts` - [ ] **Step 1: Add imports** Add `deleteRows` to the imports from `@/features/base/services/base-service` and `DeleteRowsInput` to the type imports. Note: `RowCacheContext` is already defined at the top of this file (used by `useDeleteRowMutation`); reuse it — no new import or local type needed. - [ ] **Step 2: Add the mutation hook after `useDeleteRowMutation`** ```ts export function useDeleteRowsMutation() { const { t } = useTranslation(); return useMutation({ mutationFn: (data) => deleteRows({ ...data, requestId: newRequestId() }), onMutate: async (variables) => { await queryClient.cancelQueries({ queryKey: ["base-rows", variables.baseId], }); const snapshots = queryClient.getQueriesData< InfiniteData> >({ queryKey: ["base-rows", variables.baseId] }); const removeSet = new Set(variables.rowIds); queryClient.setQueriesData>>( { queryKey: ["base-rows", variables.baseId] }, (old) => { if (!old) return old; return { ...old, pages: old.pages.map((page) => ({ ...page, items: page.items.filter((row) => !removeSet.has(row.id)), })), }; }, ); return { snapshots }; }, onError: (_, __, context) => { if (context?.snapshots) { for (const [key, data] of context.snapshots) { queryClient.setQueryData(key, data); } } notifications.show({ message: t("Failed to delete rows"), color: "red", }); }, }); } ``` - [ ] **Step 3: Build client** Run: `pnpm nx run client:build` Expected: build succeeds. - [ ] **Step 4: Commit** ```bash git add apps/client/src/features/base/queries/base-row-query.ts git commit -m "feat(base): add useDeleteRowsMutation with optimistic update" ``` --- ### Task 9: Selection atoms **Files:** - Modify: `apps/client/src/features/base/atoms/base-atoms.ts` - [ ] **Step 1: Add selection atoms** Append: ```ts export const selectedRowIdsAtom = atom>(new Set()); export const lastToggledRowIndexAtom = atom(null); ``` - [ ] **Step 2: Commit** ```bash git add apps/client/src/features/base/atoms/base-atoms.ts git commit -m "feat(base): add row selection atoms" ``` --- ### Task 10: `use-row-selection` hook **Files:** - Create: `apps/client/src/features/base/hooks/use-row-selection.ts` - [ ] **Step 1: Create the hook** ```ts import { useCallback } from "react"; import { useAtom } from "jotai"; import { selectedRowIdsAtom, lastToggledRowIndexAtom, } from "@/features/base/atoms/base-atoms"; type ToggleOpts = { shiftKey: boolean; rowIndex: number; orderedRowIds: string[]; }; export function useRowSelection() { const [selectedIds, setSelectedIds] = useAtom(selectedRowIdsAtom); const [lastToggledIndex, setLastToggledIndex] = useAtom( lastToggledRowIndexAtom, ); const isSelected = useCallback( (rowId: string) => selectedIds.has(rowId), [selectedIds], ); const toggle = useCallback( (rowId: string, opts: ToggleOpts) => { const { shiftKey, rowIndex, orderedRowIds } = opts; const next = new Set(selectedIds); if (shiftKey && lastToggledIndex !== null && lastToggledIndex !== rowIndex) { const start = Math.min(lastToggledIndex, rowIndex); const end = Math.max(lastToggledIndex, rowIndex); const anchorId = orderedRowIds[lastToggledIndex]; const turnOn = anchorId ? next.has(anchorId) : true; for (let i = start; i <= end; i += 1) { const id = orderedRowIds[i]; if (!id) continue; if (turnOn) next.add(id); else next.delete(id); } } else { if (next.has(rowId)) next.delete(rowId); else next.add(rowId); } setSelectedIds(next); setLastToggledIndex(rowIndex); }, [selectedIds, lastToggledIndex, setSelectedIds, setLastToggledIndex], ); const toggleAll = useCallback( (loadedRowIds: string[]) => { if (loadedRowIds.length === 0) return; const allSelected = loadedRowIds.every((id) => selectedIds.has(id)); if (allSelected) { setSelectedIds(new Set()); } else { setSelectedIds(new Set(loadedRowIds)); } setLastToggledIndex(null); }, [selectedIds, setSelectedIds, setLastToggledIndex], ); const clear = useCallback(() => { setSelectedIds(new Set()); setLastToggledIndex(null); }, [setSelectedIds, setLastToggledIndex]); const removeIds = useCallback( (rowIds: string[]) => { if (rowIds.length === 0) return; setSelectedIds((prev) => { if (prev.size === 0) return prev; let changed = false; const next = new Set(prev); for (const id of rowIds) { if (next.delete(id)) changed = true; } return changed ? next : prev; }); }, [setSelectedIds], ); return { selectedIds, selectionCount: selectedIds.size, isSelected, toggle, toggleAll, clear, removeIds, }; } ``` - [ ] **Step 2: Build client** Run: `pnpm nx run client:build` Expected: build succeeds. - [ ] **Step 3: Commit** ```bash git add apps/client/src/features/base/hooks/use-row-selection.ts git commit -m "feat(base): add use-row-selection hook" ``` --- ### Task 11: Extract `RowNumberCell` with checkbox swap **Files:** - Create: `apps/client/src/features/base/components/grid/row-number-cell.tsx` - Modify: `apps/client/src/features/base/components/grid/grid-cell.tsx` - Modify: `apps/client/src/features/base/styles/grid.module.css` - [ ] **Step 1: Add CSS rules** Append to `grid.module.css`: ```css .rowNumberCellInner { display: flex; align-items: center; justify-content: center; gap: 4px; width: 100%; height: 100%; padding: 0 6px; } .rowNumberIndex { display: inline; } .rowNumberCheckbox, .rowNumberDragHandle { display: none; align-items: center; justify-content: center; } .rowNumberDragHandle { color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3)); cursor: grab; } .rowNumberDragHandle:active { cursor: grabbing; } /* On hover, hide the index and show drag handle + checkbox */ .row:hover .rowNumberIndex { display: none; } .row:hover .rowNumberCheckbox, .row:hover .rowNumberDragHandle { display: inline-flex; } /* When selected, checkbox always visible; index + drag handle hidden */ .rowSelected .rowNumberIndex, .rowSelected .rowNumberDragHandle { display: none; } .rowSelected .rowNumberCheckbox { display: inline-flex; } .rowSelected .cell { background: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-6)); } ``` - [ ] **Step 2: Create `row-number-cell.tsx`** ```tsx import { memo, useCallback } from "react"; import { Checkbox } from "@mantine/core"; import { IconGripVertical } from "@tabler/icons-react"; import { useRowSelection } from "@/features/base/hooks/use-row-selection"; import classes from "@/features/base/styles/grid.module.css"; type RowDragProps = { draggable: boolean; onDragStart: (e: React.DragEvent) => void; }; type RowNumberCellProps = { rowId: string; rowIndex: number; orderedRowIds: string[]; isPinned: boolean; pinOffset?: number; rowDragProps?: RowDragProps; }; export const RowNumberCell = memo(function RowNumberCell({ rowId, rowIndex, orderedRowIds, isPinned, pinOffset, rowDragProps, }: RowNumberCellProps) { const { isSelected, toggle } = useRowSelection(); const selected = isSelected(rowId); const handleCheckboxChange = useCallback( (e: React.ChangeEvent) => { const nativeEvent = e.nativeEvent as MouseEvent; toggle(rowId, { shiftKey: nativeEvent.shiftKey === true, rowIndex, orderedRowIds, }); }, [rowId, rowIndex, orderedRowIds, toggle], ); return (
{rowIndex + 1}
); }); ``` - [ ] **Step 3: Update `grid-cell.tsx` to use the new component** Replace the `if (isRowNumber) { ... }` block (lines 108–121) with a render of ``. Add `orderedRowIds?: string[]` to `GridCellProps` and pass it through. Import `RowNumberCell`. The final branch becomes: ```tsx if (isRowNumber) { return ( ); } ``` - [ ] **Step 4: Propagate `orderedRowIds` through `GridRow`** Modify `apps/client/src/features/base/components/grid/grid-row.tsx`: - Add `orderedRowIds: string[]` to `GridRowProps`. - Pass it through to ``. - Apply `classes.rowSelected` to the root `
` when `useRowSelection().isSelected(row.id)`. - [ ] **Step 5: Build client** Run: `pnpm nx run client:build` Expected: build succeeds. - [ ] **Step 6: Commit** ```bash git add apps/client/src/features/base/components/grid/row-number-cell.tsx apps/client/src/features/base/components/grid/grid-cell.tsx apps/client/src/features/base/components/grid/grid-row.tsx apps/client/src/features/base/styles/grid.module.css git commit -m "feat(base): row-number cell renders checkbox + drag handle on hover" ``` --- ### Task 12: `RowNumberHeaderCell` with tri-state select-all **Files:** - Create: `apps/client/src/features/base/components/grid/row-number-header-cell.tsx` - Modify: `apps/client/src/features/base/components/grid/grid-header-cell.tsx` - Modify: `apps/client/src/features/base/styles/grid.module.css` - [ ] **Step 1: Add header CSS** Append to `grid.module.css`: ```css .rowNumberHeaderInner { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; } .rowNumberHeaderHash { color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3)); font-size: var(--mantine-font-size-xs); } .rowNumberHeaderCheckbox { display: none; } .headerCell:hover .rowNumberHeaderHash, .hasSelection .rowNumberHeaderHash { display: none; } .headerCell:hover .rowNumberHeaderCheckbox, .hasSelection .rowNumberHeaderCheckbox { display: inline-flex; } ``` - [ ] **Step 2: Create the component** ```tsx import { memo, useMemo } from "react"; import { Checkbox, Tooltip } from "@mantine/core"; import { useRowSelection } from "@/features/base/hooks/use-row-selection"; import classes from "@/features/base/styles/grid.module.css"; type RowNumberHeaderCellProps = { loadedRowIds: string[]; }; export const RowNumberHeaderCell = memo(function RowNumberHeaderCell({ loadedRowIds, }: RowNumberHeaderCellProps) { const { selectedIds, toggleAll } = useRowSelection(); const { checked, indeterminate } = useMemo(() => { if (loadedRowIds.length === 0) { return { checked: false, indeterminate: false }; } const selectedInLoaded = loadedRowIds.reduce( (acc, id) => (selectedIds.has(id) ? acc + 1 : acc), 0, ); return { checked: selectedInLoaded === loadedRowIds.length, indeterminate: selectedInLoaded > 0 && selectedInLoaded < loadedRowIds.length, }; }, [loadedRowIds, selectedIds]); if (loadedRowIds.length === 0) return null; return (
# toggleAll(loadedRowIds)} aria-label="Select all loaded rows" />
); }); ``` Note: reading `selectedIds` from the hook ensures this re-renders on selection change. - [ ] **Step 3: Wire into `grid-header-cell.tsx`** In `grid-header-cell.tsx`, locate the `isRowNumber ? ( flexRender(...) ) : ( ... )` ternary in the JSX (the existing branch renders `#` via `flexRender(header.column.columnDef.header, header.getContext())`). Replace the `isRowNumber` branch with: ```tsx isRowNumber ? ( ) : ( // existing non-row-number branch unchanged ) ``` Add `loadedRowIds: string[]` as a required prop on `GridHeaderCellProps` (and thread it through — see Step 4). Also add the `classes.hasSelection` class to the header cell's root `div` (line 121 area) when `useRowSelection().selectionCount > 0`: ```tsx className={`${classes.headerCell} ${isPinned ? classes.headerCellPinned : ""} ${hasSelection ? classes.hasSelection : ""}`} ``` where `const { selectionCount } = useRowSelection(); const hasSelection = selectionCount > 0;` is added near the top of `GridHeaderCell`. - [ ] **Step 4: Thread `loadedRowIds` through `GridHeader` → `GridHeaderCell`** In `grid-header.tsx`, add `loadedRowIds: string[]` as a required prop on `GridHeaderProps`. Pass it to each rendered ``. In `grid-container.tsx`, reuse the existing `rowIds` memo (`rows.map((r) => r.id)`) and pass it as `loadedRowIds={rowIds}` to ``. - [ ] **Step 5: Build client** Run: `pnpm nx run client:build` Expected: build succeeds. - [ ] **Step 6: Commit** ```bash git add apps/client/src/features/base/components/grid/row-number-header-cell.tsx 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/styles/grid.module.css git commit -m "feat(base): header select-all with tri-state checkbox" ``` --- ### Task 13: `use-delete-selected-rows` hook + `SelectionActionBar` floating bar **Files:** - Create: `apps/client/src/features/base/hooks/use-delete-selected-rows.ts` - Create: `apps/client/src/features/base/components/grid/selection-action-bar.tsx` - Modify: `apps/client/src/features/base/components/grid/grid-container.tsx` - Modify: `apps/client/src/features/base/styles/grid.module.css` - [ ] **Step 1: Add CSS** Append: ```css .selectionActionBarWrapper { position: sticky; bottom: 16px; display: flex; justify-content: center; pointer-events: none; z-index: 5; grid-column: 1 / -1; } .selectionActionBar { pointer-events: auto; display: inline-flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: var(--mantine-radius-md); box-shadow: var(--mantine-shadow-lg); background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6)); border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); } .selectionActionBarCount { font-size: var(--mantine-font-size-sm); color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0)); } ``` - [ ] **Step 2: Create `use-delete-selected-rows.ts`** ```ts import { useCallback } from "react"; import { notifications } from "@mantine/notifications"; import { useTranslation } from "react-i18next"; import { useRowSelection } from "@/features/base/hooks/use-row-selection"; import { useDeleteRowsMutation } from "@/features/base/queries/base-row-query"; const BATCH_SIZE = 500; export function useDeleteSelectedRows(baseId: string) { const { t } = useTranslation(); const { selectedIds, clear } = useRowSelection(); const mutation = useDeleteRowsMutation(); const deleteSelected = useCallback(async () => { const ids = Array.from(selectedIds); if (ids.length === 0) return; const chunks: string[][] = []; for (let i = 0; i < ids.length; i += BATCH_SIZE) { chunks.push(ids.slice(i, i + BATCH_SIZE)); } try { for (const chunk of chunks) { await mutation.mutateAsync({ baseId, rowIds: chunk }); } notifications.show({ message: t("{{count}} rows deleted", { count: ids.length }), }); clear(); } catch { // mutation onError already shows notification } }, [baseId, selectedIds, mutation, clear, t]); return { deleteSelected, isPending: mutation.isPending }; } ``` - [ ] **Step 3: Create `selection-action-bar.tsx`** ```tsx import { memo } from "react"; import { ActionIcon, Button, Transition } from "@mantine/core"; import { IconTrash, IconX } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { useRowSelection } from "@/features/base/hooks/use-row-selection"; import { useDeleteSelectedRows } from "@/features/base/hooks/use-delete-selected-rows"; import classes from "@/features/base/styles/grid.module.css"; type SelectionActionBarProps = { baseId: string; }; export const SelectionActionBar = memo(function SelectionActionBar({ baseId, }: SelectionActionBarProps) { const { t } = useTranslation(); const { selectionCount, clear } = useRowSelection(); const { deleteSelected, isPending } = useDeleteSelectedRows(baseId); const isOpen = selectionCount > 0; return ( {(styles) => (
{t("{{count}} selected", { count: selectionCount })}
)}
); }); ``` - [ ] **Step 4: Mount in `grid-container.tsx`** In `grid-container.tsx`: - Add `import { SelectionActionBar } from "./selection-action-bar";` - Render `` directly after the `` line, inside the `
` container. Skip if `!baseId`. - [ ] **Step 5: Build client** Run: `pnpm nx run client:build` Expected: build succeeds. - [ ] **Step 6: Commit** ```bash git add apps/client/src/features/base/hooks/use-delete-selected-rows.ts apps/client/src/features/base/components/grid/selection-action-bar.tsx apps/client/src/features/base/components/grid/grid-container.tsx apps/client/src/features/base/styles/grid.module.css git commit -m "feat(base): floating selection action bar with bulk delete" ``` --- ### Task 14: Keyboard handler for Delete / Backspace / Esc **Files:** - Modify: `apps/client/src/features/base/components/grid/grid-container.tsx` - [ ] **Step 1: Add keyboard handler** In `grid-container.tsx`, add a `useEffect` that attaches a `keydown` listener to `scrollRef.current` (the grid wrapper). Guards: 1. `editingCell` is null 2. `document.activeElement` is contained by `scrollRef.current` (i.e. focus inside the grid) 3. Not typing in an input / textarea / contenteditable Behavior: - `Escape` → `clear()` if `selectionCount > 0`. - `Delete` or `Backspace` → if `selectionCount > 0`, call `deleteSelected()` from `useDeleteSelectedRows`. Add imports for `useRowSelection` and `useDeleteSelectedRows`, then: ```ts const { deleteSelected } = useDeleteSelectedRows(baseId ?? ""); const { selectionCount, clear } = useRowSelection(); useEffect(() => { const el = scrollRef.current; if (!el) return; const handler = (e: KeyboardEvent) => { if (editingCell) return; const active = document.activeElement as HTMLElement | null; if (!active || !el.contains(active)) return; const tag = active.tagName; if ( tag === "INPUT" || tag === "TEXTAREA" || active.isContentEditable ) { return; } if (e.key === "Escape" && selectionCount > 0) { clear(); return; } if ((e.key === "Delete" || e.key === "Backspace") && selectionCount > 0) { e.preventDefault(); void deleteSelected(); } }; el.addEventListener("keydown", handler); return () => el.removeEventListener("keydown", handler); }, [editingCell, selectionCount, clear, deleteSelected]); ``` Skip this effect entirely if `!baseId`. - [ ] **Step 2: Build client** Run: `pnpm nx run client:build` Expected: build succeeds. - [ ] **Step 3: Commit** ```bash git add apps/client/src/features/base/components/grid/grid-container.tsx git commit -m "feat(base): keyboard delete and esc to clear selection" ``` --- ### Task 15: Clear selection on view / filter / sort / base change **Files:** - Modify: `apps/client/src/features/base/components/base-table.tsx` - [ ] **Step 1: Add effect** Inside `BaseTable`, after the existing `useEffect` that syncs `activeViewId`, add: ```ts const { clear: clearSelection } = useRowSelection(); useEffect(() => { clearSelection(); // Clear whenever identity of base or active view changes. Filter and sort // changes flow through activeView.config, which re-renders the rows — // depending on activeView.id alone keeps this effect stable (object // identity of activeFilter / activeSorts may change every render). }, [baseId, activeView?.id, clearSelection]); ``` Import `useRowSelection` from `@/features/base/hooks/use-row-selection`. Note: the spec asks for selection to clear on filter/sort change within a single view too. For v1, clearing only on view/base change is sufficient — a user changing sort within the same view still sees the same row set re-ordered, and the selected rows remain valid. If the product later wants "clear on filter change within a view," add a filter-identity hash via `JSON.stringify(activeFilter)` as a dep. - [ ] **Step 2: Build client** Run: `pnpm nx run client:build` Expected: build succeeds. - [ ] **Step 3: Commit** ```bash git add apps/client/src/features/base/components/base-table.tsx git commit -m "feat(base): clear row selection on view/filter/sort/base change" ``` --- ### Task 16: Realtime reconciliation — handle `base:rows:deleted` + prune selection **Files:** - Modify: `apps/client/src/features/base/hooks/use-base-socket.ts` - [ ] **Step 1: Add new message type** Near the existing `BaseRowDeleted` type, add: ```ts type BaseRowsDeleted = { operation: "base:rows:deleted"; baseId: string; rowIds: string[]; requestId?: string | null; }; ``` Add `BaseRowsDeleted` to the union of socket message types used by the handler switch. - [ ] **Step 2: Handle the new event** Inside the socket handler switch/if tree, after the existing `base:row:deleted` case, add a case for `base:rows:deleted` that: 1. Skips if `requestId` is marked outbound (existing suppression helper handles this). 2. Removes all rowIds from all `["base-rows", msg.baseId]` infinite-query pages in one pass (same pattern as existing `base:row:deleted`, but with `new Set(msg.rowIds)`). 3. Prunes them from `selectedRowIdsAtom` via the jotai store. For (3): import `getDefaultStore` from `jotai` (or read via the atom's setter) — follow whatever pattern this file already uses. If atom access from outside components is not the pattern here, instead export a `removeSelectedRowIds` helper that `base-table.tsx` subscribes to via `useEffect` listening to a ref or event — BUT the cleaner path is to call `getDefaultStore().set(selectedRowIdsAtom, ...)` directly since socket handlers are not React components. Concrete code: ```ts import { getDefaultStore } from "jotai"; import { selectedRowIdsAtom } from "@/features/base/atoms/base-atoms"; // inside the handler for base:rows:deleted: const removeSet = new Set(msg.rowIds); queryClient.setQueriesData>>( { queryKey: ["base-rows", msg.baseId] }, (old) => { if (!old) return old; return { ...old, pages: old.pages.map((page) => ({ ...page, items: page.items.filter((row) => !removeSet.has(row.id)), })), }; }, ); const store = getDefaultStore(); const current = store.get(selectedRowIdsAtom); if (current.size > 0) { let changed = false; const next = new Set(current); for (const id of msg.rowIds) { if (next.delete(id)) changed = true; } if (changed) store.set(selectedRowIdsAtom, next); } ``` - [ ] **Step 3: Also prune selection in the existing `base:row:deleted` case** Add the same single-id `store.set(selectedRowIdsAtom, ...)` prune after the existing cache update for the single-row delete event. - [ ] **Step 4: Build client** Run: `pnpm nx run client:build` Expected: build succeeds. - [ ] **Step 5: Commit** ```bash git add apps/client/src/features/base/hooks/use-base-socket.ts git commit -m "feat(base): reconcile bulk delete over socket + prune selection" ``` --- ### Task 17: Full build + manual verification **Files:** none - [ ] **Step 1: Full build** Run: `pnpm build` Expected: both client and server build cleanly. - [ ] **Step 2: Manual smoke test (user-led, not Claude-led)** The user will run the dev environment. Verify: 1. Hover a row → drag handle + checkbox appear, index hides. 2. Click checkbox → row highlights, row stays checked on blur. 3. Shift-click a later row → range selected. 4. Esc → selection clears. 5. Header checkbox → select-all-loaded; click again → clear. 6. Floating bar shows `N selected` with Delete and X buttons. 7. Click Delete → rows disappear, toast `N rows deleted`, selection clears. 8. Delete/Backspace key with grid focused deletes selected rows. 9. Switch views / apply filter → selection clears. 10. Two browser windows: delete rows in one → other window's cache and selection update. - [ ] **Step 3: Final commit (if any lint/style tweaks needed after smoke test)** No-op if smoke test clean. --- ## Notes - No feature flag. No migration. - No automated tests in this plan. Base feature has no existing test harness; manual verification and build gate are sufficient per repo convention. If the user later requests tests, add a service spec for `deleteMany` patterned after `apps/server/src/core/auth/services/auth.service.spec.ts` and a hook test for `use-row-selection`. - Tasks 1–6 are server-only and can be reviewed independently; tasks 7–16 are client-only; task 17 is integration. - Tasks 11 and 12 both touch `grid.module.css`. Keep each task's CSS appended in order to avoid merge conflicts if parallelized.