diff --git a/browser/data-browser/src/chunks/TableEditor/Cell.tsx b/browser/data-browser/src/chunks/TableEditor/Cell.tsx index d53251f76..742c19521 100644 --- a/browser/data-browser/src/chunks/TableEditor/Cell.tsx +++ b/browser/data-browser/src/chunks/TableEditor/Cell.tsx @@ -45,6 +45,9 @@ export interface CellProps { interface IndexCellProps extends CellProps { onExpand: (rowIndex: number) => void; + /** Optional selection control (a checkbox) shown to the left of the + * open-resource button. Rendered by the table when bulk selection is on. */ + selector?: React.ReactNode; } export function Cell({ @@ -283,6 +286,7 @@ export function Cell({ export function IndexCell({ children, onExpand, + selector, ...props }: React.PropsWithChildren): JSX.Element { const { markings } = useTableEditorContext(); @@ -290,7 +294,13 @@ export function IndexCell({ const marking = markings.get(props.rowIndex); return ( - + + {selector && {selector}} onExpand(props.rowIndex)} @@ -304,14 +314,41 @@ export function IndexCell({ const IndexNumber = styled.span``; -const StyledIndexCell = styled(Cell)<{ hasMarking: boolean }>` +/** Wraps the selection checkbox. Clicks here toggle the row, and must not + * bubble to the cell's mouse handlers (which would start a cell selection). */ +const SelectorSlot = styled.span` + display: flex; + align-items: center; +`; + +const StyledIndexCell = styled(Cell)<{ + hasMarking: boolean; + hasSelector: boolean; +}>` justify-content: flex-end !important; + gap: 0.35rem; color: ${p => p.theme.colors.textLight}; & button { display: none; } + /* The checkbox is hidden at rest, but stays visible once its row is checked + * (via :has) or while the row is hovered/focused, so a selection is always + * legible without cluttering every idle row. */ + & ${SelectorSlot} { + display: none; + } + + &:hover + ${SelectorSlot}, + &:focus-within + ${SelectorSlot}, + &:has(input:checked) + ${SelectorSlot} { + display: flex; + } + &:hover ${IndexNumber}, &:focus-within ${IndexNumber} { display: none; } diff --git a/browser/data-browser/src/chunks/TableEditor/TableEditor.tsx b/browser/data-browser/src/chunks/TableEditor/TableEditor.tsx index e09597e93..0a6c3350f 100644 --- a/browser/data-browser/src/chunks/TableEditor/TableEditor.tsx +++ b/browser/data-browser/src/chunks/TableEditor/TableEditor.tsx @@ -80,6 +80,12 @@ interface FancyTableProps { * about how many cells a row has. */ FooterComponent?: React.ComponentType<{ columns: T[] }>; + /** Renders the per-row selection control (a checkbox) inside the index + * column. When provided, the index column widens to fit it and the grid is + * treated as selectable. */ + renderRowSelector?: (rowIndex: number) => React.ReactNode; + /** Select-all control shown in the index column header. */ + headerSelector?: React.ReactNode; ref?: React.RefObject; } @@ -126,6 +132,8 @@ function FancyTableInner({ HeadingComponent, NewColumnButtonComponent, FooterComponent, + renderRowSelector, + headerSelector, }: FancyTableProps): JSX.Element { const ariaUsageId = useId(); const scrollerRef = useRef(null); @@ -148,6 +156,7 @@ function FancyTableInner({ columnSizes, columns, onCellResize, + !!renderRowSelector, ); const handleClickOutside = useCallback(() => { @@ -251,7 +260,12 @@ function FancyTableInner({ role='row' aria-rowindex={index + 2} > - + {index + 1} {children({ index })} @@ -259,7 +273,7 @@ function FancyTableInner({ ); }, - [children, onRowExpand], + [children, onRowExpand, renderRowSelector], ); const rowProps = useMemo(() => ({}), []); @@ -336,6 +350,7 @@ function FancyTableInner({ onColumnReorder={onColumnReorder} HeadingComponent={HeadingComponent} NewColumnButtonComponent={NewColumnButtonComponent} + headerSelector={headerSelector} /> diff --git a/browser/data-browser/src/chunks/TableEditor/TableHeader.tsx b/browser/data-browser/src/chunks/TableEditor/TableHeader.tsx index c26239aef..5be9436cc 100644 --- a/browser/data-browser/src/chunks/TableEditor/TableHeader.tsx +++ b/browser/data-browser/src/chunks/TableEditor/TableHeader.tsx @@ -36,6 +36,9 @@ export interface TableHeaderProps { HeadingComponent: TableHeadingComponent; NewColumnButtonComponent: React.ComponentType; headerRef: React.Ref; + /** Optional control (a select-all checkbox) shown in the index column + * header instead of the `#` label. */ + headerSelector?: React.ReactNode; } /** The entire first row of an Editable Table. */ @@ -47,6 +50,7 @@ export function TableHeader({ HeadingComponent, NewColumnButtonComponent, headerRef, + headerSelector, }: TableHeaderProps): JSX.Element { const [activeIndex, setActiveIndex] = useState(); @@ -96,7 +100,7 @@ export function TableHeader({
- # + {headerSelector ?? '#'} {columns.map((column, index) => ( { try { @@ -21,6 +24,9 @@ export function useCellSizes( externalSizes: number[] | undefined, columns: T[], onSizesChange: (sizes: number[]) => void, + /** Widen the index column to fit a row-selection checkbox next to the + * open-resource button. */ + selectable = false, ) { // CSS values for column sizes const [sizes, setSizes] = useState( @@ -87,10 +93,14 @@ export function useCellSizes( ? toPixels(externalSizes) : Array(columns.length).fill(DEFAULT_SIZE_STR); - const templateColumns = `${INDEX_CELL_WIDTH} ${effectiveSizes.join( + const indexCellWidth = selectable + ? SELECTABLE_INDEX_CELL_WIDTH + : INDEX_CELL_WIDTH; + + const templateColumns = `${indexCellWidth} ${effectiveSizes.join( ' ', )} minmax(50px, 1fr)`; - const contentRowWidth = `calc(${INDEX_CELL_WIDTH} + ${effectiveSizes.join( + const contentRowWidth = `calc(${indexCellWidth} + ${effectiveSizes.join( ' + ', )})`; diff --git a/browser/data-browser/src/chunks/TablePage/RowSelectCheckbox.tsx b/browser/data-browser/src/chunks/TablePage/RowSelectCheckbox.tsx new file mode 100644 index 000000000..4b02a0183 --- /dev/null +++ b/browser/data-browser/src/chunks/TablePage/RowSelectCheckbox.tsx @@ -0,0 +1,48 @@ +import { + Collection, + unknownSubject, + useMemberFromCollection, +} from '@tomic/react'; +import { type JSX } from 'react'; +import { Checkbox } from '@components/forms/Checkbox'; + +interface RowSelectCheckboxProps { + collection: Collection; + index: number; + isSelected: (subject: string) => boolean; + onToggle: (subject: string) => void; +} + +/** + * The per-row selection checkbox shown in the index column. Resolves its own + * subject from the collection (indices are all the grid hands down) so the + * checkbox reflects and toggles selection by stable subject. + */ +export function RowSelectCheckbox({ + collection, + index, + isSelected, + onToggle, +}: RowSelectCheckboxProps): JSX.Element | null { + const resource = useMemberFromCollection(collection, index); + const subject = resource.subject; + + // Until the row's subject resolves there's nothing to select. + if (subject === unknownSubject) { + return null; + } + + return ( + onToggle(subject)} + // Keep the click from reaching the cell, which would otherwise start a + // cell selection / active-cell move. + onMouseDown={e => e.stopPropagation()} + onClick={e => e.stopPropagation()} + /> + ); +} diff --git a/browser/data-browser/src/chunks/TablePage/TableBulkActionsBar.tsx b/browser/data-browser/src/chunks/TablePage/TableBulkActionsBar.tsx new file mode 100644 index 000000000..5e9c39694 --- /dev/null +++ b/browser/data-browser/src/chunks/TablePage/TableBulkActionsBar.tsx @@ -0,0 +1,122 @@ +import { JSONValue, Property } from '@tomic/react'; +import { useState, type JSX } from 'react'; +import { styled } from 'styled-components'; +import { FaPen, FaTrash, FaXmark } from 'react-icons/fa6'; +import { Button } from '@components/Button'; +import { + ConfirmationDialog, + ConfirmationDialogTheme, +} from '@components/ConfirmationDialog'; +import { TableSetPropertyDialog } from './TableSetPropertyDialog'; + +interface TableBulkActionsBarProps { + count: number; + /** Properties (columns) that can be bulk-set. */ + properties: Property[]; + onSetProperty: ( + propertySubject: string, + value: JSONValue | undefined, + ) => void; + onDelete: () => void; + onClear: () => void; +} + +/** + * Appears above the grid whenever one or more rows are selected. Offers the + * bulk actions — set a property on every selected row, or delete them all — + * plus a way to clear the selection. + */ +export function TableBulkActionsBar({ + count, + properties, + onSetProperty, + onDelete, + onClear, +}: TableBulkActionsBarProps): JSX.Element | null { + const [showSetProperty, setShowSetProperty] = useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + if (count === 0) { + return null; + } + + return ( + + + {count} {count === 1 ? 'row' : 'rows'} selected + + + + + + + + +

+ This permanently deletes the {count} selected{' '} + {count === 1 ? 'row' : 'rows'}. This cannot be undone from here. +

+
+
+ ); +} + +const Bar = styled.div` + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + margin-bottom: 0.5rem; + border-radius: ${p => p.theme.radius}; + background-color: ${p => p.theme.colors.bg1}; + border: 1px solid ${p => p.theme.colors.bg2}; +`; + +const Count = styled.span` + font-weight: bold; + margin-right: 0.5rem; +`; + +const Spacer = styled.span` + flex: 1; +`; diff --git a/browser/data-browser/src/chunks/TablePage/TableResource.tsx b/browser/data-browser/src/chunks/TablePage/TableResource.tsx index c13ec6dd3..9a946e54c 100644 --- a/browser/data-browser/src/chunks/TablePage/TableResource.tsx +++ b/browser/data-browser/src/chunks/TablePage/TableResource.tsx @@ -7,6 +7,7 @@ import { useStore, type DataBrowser, type ExpressionFilter, + type JSONValue, type Property, type PropVal, type Resource, @@ -21,6 +22,8 @@ import { useHandlePaste } from '@chunks/TablePage/helpers/useHandlePaste'; import { useTableHistory, createResourceDeletedHistoryItem, + createValueChangedHistoryItem, + type HistoryItemBatch, } from '@chunks/TablePage/helpers/useTableHistory'; import { TablePageContext, @@ -62,6 +65,10 @@ import { toAggregation } from './tableAggregates'; import { stringToSlug } from '@helpers/stringToSlug'; import { orderColumns, reorderColumnKeys } from './columnOrder'; import { TableSummaryBar } from './TableSummaryBar'; +import { useRowSelection } from './useRowSelection'; +import { RowSelectCheckbox } from './RowSelectCheckbox'; +import { TableBulkActionsBar } from './TableBulkActionsBar'; +import { Checkbox } from '@components/forms/Checkbox'; import type { GroupGranularity } from './tableAggregates'; import type { AggregateTarget } from './tablePageContext'; import type { DerivedColumnSpec } from './derivedColumns'; @@ -931,6 +938,96 @@ export const TableResource: React.FC = ({ ], ); + // Bulk row selection (by subject, so it survives sort/filter/paging). + const selection = useRowSelection(collection); + const { clear: clearSelection, deselect: deselectRow } = selection; + + // A filter/sort/view change rebuilds the row set; a selection made against + // the old one no longer means anything, so drop it. + useEffect(() => { + clearSelection(); + }, [queryKey, clearSelection]); + + const handleBulkDelete = useCallback(async () => { + const subjects = selection.selectedList.filter(s => !s.startsWith('_new:')); + + if (subjects.length === 0) { + return; + } + + const batch: HistoryItemBatch = []; + let failures = 0; + + for (const subject of subjects) { + try { + const rowResource = store.getResourceLoading(subject); + batch.push(createResourceDeletedHistoryItem(rowResource)); + await rowResource.destroy(); + decrementMemberCount(); + } catch (e) { + failures++; + console.error('Failed to delete row', subject, e); + } + } + + addItemsToHistoryStack(batch); + clearSelection(); + + if (failures > 0) { + toast.error(`Failed to delete ${failures} of ${subjects.length} rows`); + } else { + toast.success( + `Deleted ${subjects.length} ${subjects.length === 1 ? 'row' : 'rows'}`, + ); + } + }, [ + selection.selectedList, + store, + addItemsToHistoryStack, + decrementMemberCount, + clearSelection, + ]); + + const handleBulkSetProperty = useCallback( + async (propertySubject: string, value: JSONValue | undefined) => { + const subjects = selection.selectedList.filter( + s => !s.startsWith('_new:'), + ); + + if (subjects.length === 0) { + return; + } + + const batch: HistoryItemBatch = []; + let failures = 0; + + // Sequential: keeps a burst of commits off the shared server, and mirrors + // how the clear-cells helper writes. + for (const subject of subjects) { + try { + const res = await store.getResource(subject); + batch.push(createValueChangedHistoryItem(res, propertySubject)); + await res.set(propertySubject, value, true); + await res.save(); + } catch (e) { + failures++; + console.error('Failed to set property on row', subject, e); + } + } + + addItemsToHistoryStack(batch); + + if (failures > 0) { + toast.error(`Failed to update ${failures} of ${subjects.length} rows`); + } else { + toast.success( + `Updated ${subjects.length} ${subjects.length === 1 ? 'row' : 'rows'}`, + ); + } + }, + [selection.selectedList, store, addItemsToHistoryStack], + ); + const handleDeleteRow = useCallback( async (index: number) => { // Resolve the row by the SAME index→row mapping the grid renders with: @@ -947,6 +1044,9 @@ export const TableResource: React.FC = ({ return; } + // Keep a deleted row from lingering in the selection set. + deselectRow(subject); + // Drop a session row from the render list immediately (optimistic). if (!isMember) { setNewRowSubjects(prev => prev.filter(s => s !== subject)); @@ -1057,6 +1157,43 @@ export const TableResource: React.FC = ({ ], ); + // Selection controls are writer-only: selecting rows to bulk-edit or delete + // is pointless without the rights to do either. + const { isSelected, toggle: toggleSelected } = selection; + const renderRowSelector = useCallback( + (rowIndex: number) => { + // Session/new rows have no persisted resource to act on. + if (rowIndex >= memberCount) { + return null; + } + + return ( + + ); + }, + [collection, memberCount, isSelected, toggleSelected], + ); + + const headerSelector = ( + + selection.allSelected ? clearSelection() : void selection.selectAll() + } + onMouseDown={e => e.stopPropagation()} + onClick={e => e.stopPropagation()} + /> + ); + return ( @@ -1142,10 +1279,21 @@ export const TableResource: React.FC = ({ onEntryCreated={notifyEntryCreated} /> )} + {canWrite && ( + + )} void; + /** Writes `value` to `propertySubject` on every selected row. */ + onApply: (propertySubject: string, value: JSONValue | undefined) => void; +} + +/** + * Bulk "set property" editor: pick one of the view's columns and a value, then + * write it to every selected row. The value is captured on a throwaway + * in-memory resource so we can reuse the normal per-datatype inputs + * (`InputSwitcher`) and read the typed value back on confirm. + */ +export function TableSetPropertyDialog({ + properties, + count, + show, + bindShow, + onApply, +}: TableSetPropertyDialogProps): JSX.Element | null { + const store = useStore(); + const propertySelectId = useId(); + const valueInputId = useId(); + // A throwaway subject to hold the value being entered. Never saved. + const [scratchSubject] = useState(() => store.createSubject()); + const scratch = useResource(scratchSubject); + + const [selectedSubject, setSelectedSubject] = useState( + properties[0]?.subject ?? '', + ); + + // Keep the selection valid if the column set changes while open. + useEffect(() => { + if ( + properties.length > 0 && + !properties.some(p => p.subject === selectedSubject) + ) { + setSelectedSubject(properties[0].subject); + } + }, [properties, selectedSubject]); + + const selectedProperty = useMemo( + () => properties.find(p => p.subject === selectedSubject), + [properties, selectedSubject], + ); + + if (properties.length === 0) { + return null; + } + + const handleConfirm = () => { + if (!selectedProperty) { + return; + } + + onApply(selectedProperty.subject, scratch.get(selectedProperty.subject)); + }; + + return ( + + + + + setSelectedSubject(e.target.value)} + > + {properties.map(property => ( + + ))} + + + {selectedProperty && ( + + + + + )} + + + ); +} + +const Fields = styled.div` + display: flex; + flex-direction: column; + gap: 1rem; +`; + +const Field = styled.div` + display: flex; + flex-direction: column; + gap: 0.25rem; + + & > label { + color: ${p => p.theme.colors.textLight}; + } +`; diff --git a/browser/data-browser/src/chunks/TablePage/useRowSelection.ts b/browser/data-browser/src/chunks/TablePage/useRowSelection.ts new file mode 100644 index 000000000..54c587849 --- /dev/null +++ b/browser/data-browser/src/chunks/TablePage/useRowSelection.ts @@ -0,0 +1,95 @@ +import { Collection } from '@tomic/react'; +import { useCallback, useMemo, useRef, useState } from 'react'; + +export interface RowSelection { + /** The subjects of the currently selected rows. */ + selected: ReadonlySet; + selectedList: string[]; + count: number; + /** Every member matching the current filter is selected. */ + allSelected: boolean; + /** Some, but not all, members are selected (for an indeterminate look). */ + someSelected: boolean; + isSelected: (subject: string) => boolean; + toggle: (subject: string) => void; + deselect: (subject: string) => void; + clear: () => void; + /** Select every member matching the current filter (all pages). */ + selectAll: () => Promise; +} + +/** + * Tracks which rows (by subject) are selected for bulk actions. Selection is + * keyed by subject rather than grid index so it survives sorting, filtering + * and deletion. "Select all" pulls every member of the collection — i.e. + * everything matching the current filter, across all pages — not just the rows + * currently rendered. + */ +export function useRowSelection(collection: Collection): RowSelection { + const [selected, setSelected] = useState>(() => new Set()); + + // Read the latest count without making callbacks depend on it (which would + // re-create them, and with them the render slots, on every page load). + const totalMembers = collection.totalMembers; + const totalMembersRef = useRef(totalMembers); + totalMembersRef.current = totalMembers; + + const isSelected = useCallback( + (subject: string) => selected.has(subject), + [selected], + ); + + const toggle = useCallback((subject: string) => { + setSelected(prev => { + const next = new Set(prev); + + if (next.has(subject)) { + next.delete(subject); + } else { + next.add(subject); + } + + return next; + }); + }, []); + + const deselect = useCallback((subject: string) => { + setSelected(prev => { + if (!prev.has(subject)) { + return prev; + } + + const next = new Set(prev); + next.delete(subject); + + return next; + }); + }, []); + + const clear = useCallback(() => { + setSelected(prev => (prev.size === 0 ? prev : new Set())); + }, []); + + const selectAll = useCallback(async () => { + const members = await collection.getAllMembers(); + setSelected(new Set(members)); + }, [collection]); + + const selectedList = useMemo(() => Array.from(selected), [selected]); + const count = selected.size; + const allSelected = totalMembers > 0 && count >= totalMembers; + const someSelected = count > 0 && !allSelected; + + return { + selected, + selectedList, + count, + allSelected, + someSelected, + isSelected, + toggle, + deselect, + clear, + selectAll, + }; +} diff --git a/browser/e2e/tests/table-bulk-actions.spec.ts b/browser/e2e/tests/table-bulk-actions.spec.ts new file mode 100644 index 000000000..3ea5e2848 --- /dev/null +++ b/browser/e2e/tests/table-bulk-actions.spec.ts @@ -0,0 +1,132 @@ +import { test, expect, Page } from '@playwright/test'; +import { before, timestamp } from './test-utils'; + +/** + * Create a table via the drive's quick-create button, type `names` as rows + * (fast entry), then reload so the rows are collection members. Row-selection + * checkboxes only render on persisted members, not on this-session draft rows, + * so the reload is what makes them selectable. + */ +async function createTableWithRows( + page: Page, + tableName: string, + names: string[], +) { + await page.getByTitle('New Table').first().click(); + await page.getByPlaceholder('New Table').fill(tableName); + await page.locator('dialog[open] button:has-text("Create")').click(); + await page.waitForURL(url => url.pathname.startsWith('/app/show'), { + timeout: 15000, + }); + await expect(page.getByTestId('editable-title').first()).toBeVisible({ + timeout: 15000, + }); + // Leave the auto-focused title editor so keyboard entry lands in the grid. + await page.keyboard.press('Escape'); + + const firstCell = page.getByRole('gridcell').first(); + await expect(firstCell).toBeVisible({ timeout: 15000 }); + await firstCell.click({ force: true }); + await page.waitForTimeout(300); + + for (const name of names) { + await page.keyboard.press('Enter'); + await page.waitForTimeout(100); + await page.keyboard.type(name, { delay: 30 }); + await page.waitForTimeout(100); + } + + await page.keyboard.press('Escape'); + await page.waitForFunction( + () => window.store.getSyncStatus().pendingDirtyCount === 0, + undefined, + { timeout: 10000 }, + ); + + await page.reload(); + await expect(page.getByTestId('editable-title').first()).toBeVisible({ + timeout: 15000, + }); + await expect( + page.getByRole('gridcell', { name: names[0], exact: true }), + ).toBeVisible({ timeout: 15000 }); +} + +/** Reveal (on hover) and click the selection checkbox of a data row. */ +async function selectRow(page: Page, rowIndex: number) { + const row = page.locator(`[aria-rowindex="${rowIndex}"]`); + // Checkbox is hidden until the index cell is hovered (or the row is checked). + await row.getByRole('rowheader').hover(); + const checkbox = row.getByTestId('row-select-checkbox'); + await expect(checkbox).toBeVisible(); + await checkbox.click(); + await expect(checkbox).toBeChecked(); +} + +test.describe('table bulk actions', () => { + test.beforeEach(before); + + test('select individual rows and bulk delete', async ({ page }) => { + test.slow(); + await createTableWithRows(page, `Bulk Delete ${timestamp()}`, [ + 'alpha', + 'beta', + 'gamma', + ]); + + // Select the first two data rows individually (rowindex 2 and 3). + await selectRow(page, 2); + await selectRow(page, 3); + + await expect(page.getByTestId('bulk-selected-count')).toContainText('2'); + + // Delete them via the bulk bar + confirmation dialog. + await page.getByTestId('bulk-delete-button').click(); + const dialog = page.locator('dialog[open]').last(); + await expect(dialog).toBeVisible(); + // The confirm button is the last footer action (Cancel is first). + await dialog.locator('footer button').last().click(); + await expect(dialog).toBeHidden(); + + // The two selected rows are gone; the third remains. + await expect( + page.getByRole('gridcell', { name: 'alpha', exact: true }), + ).toHaveCount(0); + await expect( + page.getByRole('gridcell', { name: 'beta', exact: true }), + ).toHaveCount(0); + await expect( + page.getByRole('gridcell', { name: 'gamma', exact: true }), + ).toBeVisible(); + + // Selection is emptied, so the bulk bar goes away. + await expect(page.getByTestId('table-bulk-actions')).toHaveCount(0); + }); + + test('select all and bulk set a property', async ({ page }) => { + test.slow(); + await createTableWithRows(page, `Bulk Set ${timestamp()}`, [ + 'one', + 'two', + 'three', + ]); + + // Select every row via the header checkbox. + await page.getByTestId('select-all-checkbox').click(); + await expect(page.getByTestId('bulk-selected-count')).toContainText('3'); + + // Open the set-property dialog; the "name" column is the default target. + await page.getByTestId('bulk-set-property-button').click(); + const dialog = page.locator('dialog[open]').last(); + await expect(dialog).toBeVisible(); + await dialog.getByRole('textbox').fill('Done'); + // Apply is the last footer action. + await dialog.locator('footer button').last().click(); + await expect(dialog).toBeHidden(); + + // All three rows now show the new value. + await expect( + page.getByRole('gridcell', { name: 'Done', exact: true }), + ).toHaveCount(3, { timeout: 15000 }); + }); +});