36 KiB
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
findByIdsmethod
After the existing findById method, add:
async findByIds(
rowIds: string[],
opts: WorkspaceOpts,
): Promise<BaseRow[]> {
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
softDeleteManymethod
After the existing softDelete method, add:
async softDeleteMany(
rowIds: string[],
opts: {
baseId: string;
workspaceId: string;
trx?: KyselyTransaction;
},
): Promise<void> {
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
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:
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
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:
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:
export type BaseRowsDeletedEvent = BaseEventBase & { rowIds: string[] };
- Step 3: Build server
Run: pnpm nx run server:build
Expected: build succeeds.
- Step 4: Commit
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
DeleteRowsDtois imported from../dto/update-row.dto. -
Ensure
BaseRowsDeletedEventis imported from../events/base-events. -
Step 2: Add
deleteManymethod after existingdelete
async deleteMany(
dto: DeleteRowsDto,
workspaceId: string,
userId?: string,
): Promise<void> {
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
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):
@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
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):
@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
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
DeleteRowsInputtype
In base.types.ts, find the existing DeleteRowInput type and add just below it:
export type DeleteRowsInput = {
baseId: string;
rowIds: string[];
requestId?: string;
};
- Step 2: Add
deleteRowsservice function
In base-service.ts, add DeleteRowsInput to the type imports from @/features/base/types/base.types, then add after the existing deleteRow:
export async function deleteRows(data: DeleteRowsInput): Promise<void> {
await api.post("/bases/rows/delete-many", data);
}
- Step 3: Build client
Run: pnpm nx run client:build
Expected: build succeeds.
- Step 4: Commit
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
export function useDeleteRowsMutation() {
const { t } = useTranslation();
return useMutation<void, Error, DeleteRowsInput, RowCacheContext>({
mutationFn: (data) => deleteRows({ ...data, requestId: newRequestId() }),
onMutate: async (variables) => {
await queryClient.cancelQueries({
queryKey: ["base-rows", variables.baseId],
});
const snapshots = queryClient.getQueriesData<
InfiniteData<IPagination<IBaseRow>>
>({ queryKey: ["base-rows", variables.baseId] });
const removeSet = new Set(variables.rowIds);
queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
{ 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
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:
export const selectedRowIdsAtom = atom<Set<string>>(new Set<string>());
export const lastToggledRowIndexAtom = atom<number | null>(null);
- Step 2: Commit
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
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
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:
.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
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<HTMLInputElement>) => {
const nativeEvent = e.nativeEvent as MouseEvent;
toggle(rowId, {
shiftKey: nativeEvent.shiftKey === true,
rowIndex,
orderedRowIds,
});
},
[rowId, rowIndex, orderedRowIds, toggle],
);
return (
<div
className={`${classes.cell} ${classes.rowNumberCell} ${isPinned ? classes.cellPinned : ""}`}
style={isPinned ? { left: pinOffset } : undefined}
>
<div className={classes.rowNumberCellInner}>
<span
className={classes.rowNumberDragHandle}
draggable={rowDragProps?.draggable}
onDragStart={rowDragProps?.onDragStart}
aria-label="Drag row"
>
<IconGripVertical size={12} />
</span>
<span className={classes.rowNumberCheckbox}>
<Checkbox
size="xs"
checked={selected}
onChange={handleCheckboxChange}
aria-label="Select row"
/>
</span>
<span className={classes.rowNumberIndex}>{rowIndex + 1}</span>
</div>
</div>
);
});
- Step 3: Update
grid-cell.tsxto use the new component
Replace the if (isRowNumber) { ... } block (lines 108–121) with a render of <RowNumberCell>. Add orderedRowIds?: string[] to GridCellProps and pass it through. Import RowNumberCell. The final branch becomes:
if (isRowNumber) {
return (
<RowNumberCell
rowId={rowId}
rowIndex={rowIndex}
orderedRowIds={orderedRowIds ?? []}
isPinned={isPinned}
pinOffset={pinOffset}
rowDragProps={rowDragProps}
/>
);
}
- Step 4: Propagate
orderedRowIdsthroughGridRow
Modify apps/client/src/features/base/components/grid/grid-row.tsx:
-
Add
orderedRowIds: string[]toGridRowProps. -
Pass it through to
<GridCell>. -
Apply
classes.rowSelectedto the root<div>whenuseRowSelection().isSelected(row.id). -
Step 5: Build client
Run: pnpm nx run client:build
Expected: build succeeds.
- Step 6: Commit
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:
.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
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 (
<div className={classes.rowNumberHeaderInner}>
<span className={classes.rowNumberHeaderHash}>#</span>
<span className={classes.rowNumberHeaderCheckbox}>
<Tooltip label="Select all loaded rows" withinPortal>
<Checkbox
size="xs"
checked={checked}
indeterminate={indeterminate}
onChange={() => toggleAll(loadedRowIds)}
aria-label="Select all loaded rows"
/>
</Tooltip>
</span>
</div>
);
});
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:
isRowNumber ? (
<RowNumberHeaderCell loadedRowIds={loadedRowIds} />
) : (
// 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:
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
loadedRowIdsthroughGridHeader→GridHeaderCell
In grid-header.tsx, add loadedRowIds: string[] as a required prop on GridHeaderProps. Pass it to each rendered <GridHeaderCell>.
In grid-container.tsx, reuse the existing rowIds memo (rows.map((r) => r.id)) and pass it as loadedRowIds={rowIds} to <GridHeader>.
- Step 5: Build client
Run: pnpm nx run client:build
Expected: build succeeds.
- Step 6: Commit
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:
.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
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
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 (
<Transition mounted={isOpen} transition="slide-up" duration={150}>
{(styles) => (
<div className={classes.selectionActionBarWrapper} style={styles}>
<div className={classes.selectionActionBar}>
<span className={classes.selectionActionBarCount}>
{t("{{count}} selected", { count: selectionCount })}
</span>
<Button
size="xs"
color="red"
variant="light"
leftSection={<IconTrash size={14} />}
loading={isPending}
onClick={() => void deleteSelected()}
>
{t("Delete")}
</Button>
<ActionIcon
size="sm"
variant="subtle"
onClick={clear}
aria-label={t("Clear selection")}
>
<IconX size={14} />
</ActionIcon>
</div>
</div>
)}
</Transition>
);
});
- Step 4: Mount in
grid-container.tsx
In grid-container.tsx:
-
Add
import { SelectionActionBar } from "./selection-action-bar"; -
Render
<SelectionActionBar baseId={baseId!} />directly after the<AddRowButton ... />line, inside the<div className={classes.grid}>container. Skip if!baseId. -
Step 5: Build client
Run: pnpm nx run client:build
Expected: build succeeds.
- Step 6: Commit
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:
editingCellis nulldocument.activeElementis contained byscrollRef.current(i.e. focus inside the grid)- Not typing in an input / textarea / contenteditable
Behavior:
Escape→clear()ifselectionCount > 0.DeleteorBackspace→ ifselectionCount > 0, calldeleteSelected()fromuseDeleteSelectedRows.
Add imports for useRowSelection and useDeleteSelectedRows, then:
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
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:
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
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:
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:
- Skips if
requestIdis marked outbound (existing suppression helper handles this). - Removes all rowIds from all
["base-rows", msg.baseId]infinite-query pages in one pass (same pattern as existingbase:row:deleted, but withnew Set(msg.rowIds)). - Prunes them from
selectedRowIdsAtomvia 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:
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<InfiniteData<IPagination<IBaseRow>>>(
{ 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:deletedcase
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
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:
- Hover a row → drag handle + checkbox appear, index hides.
- Click checkbox → row highlights, row stays checked on blur.
- Shift-click a later row → range selected.
- Esc → selection clears.
- Header checkbox → select-all-loaded; click again → clear.
- Floating bar shows
N selectedwith Delete and X buttons. - Click Delete → rows disappear, toast
N rows deleted, selection clears. - Delete/Backspace key with grid focused deletes selected rows.
- Switch views / apply filter → selection clears.
- 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
deleteManypatterned afterapps/server/src/core/auth/services/auth.service.spec.tsand a hook test foruse-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.