diff --git a/.changeset/quiet-footers-rise.md b/.changeset/quiet-footers-rise.md index b130fa138..c93743462 100644 --- a/.changeset/quiet-footers-rise.md +++ b/.changeset/quiet-footers-rise.md @@ -2,27 +2,33 @@ "@tailor-platform/app-shell": minor --- -Add bulk actions to `DataTable`. Pass `selectionActions` to `useDataTable` and, while rows are selected, `DataTable.Footer` becomes a bulk-action bar: the selection count, your actions, and a Clear button, with pagination kept alongside. Give an action `canApply` to scope it to the selected rows it can act on — the bar shows that count, disables the action at zero, and hands only those rows to `onClick`. Return the promise from `onClick` and the bar waits for it: actions are disabled with a spinner meanwhile, and the selection clears once it resolves (set `keepSelection` for actions such as an export). An action that confirms first can hand its later request back through the `run` helper — `run(deleteRows(rows))` from the dialog's confirm button — and get the same handling. Clear is disabled while a request is pending. +Add ID-based bulk actions to `DataTable`. Pass `selectionActions` to `useDataTable` and, while rows are selected, `DataTable.Footer` becomes a bulk-action bar: the selection count, your actions, and a Clear button, with pagination kept alongside. `onClick` receives a click-time copy of all selected ids, including ids whose rows have not been loaded. Consumers own eligibility checks, required fetching, confirmation UI, and execution; selection actions do not have row-based `canApply` or automatic eligible counts/disabled states. + +Return the promise from `onClick` for pending-state tracking: actions and Clear are disabled with a spinner meanwhile. A confirmation dialog can hand a later request back through `helpers.run`. Neither success nor rejection changes selection, and there is no `keepSelection` option. Use controlled `rowSelection` to remove only ids actually changed from the current selection, preserving ineligible records and newer selections. The explicit `clearSelection` helper remains available for intentional clearing of every page. ```tsx +const [selectedIds, setSelectedIds] = useState([]); const table = useDataTable({ columns, data, control, + rowSelection: { selectedIds, onChange: setSelectedIds }, selectionActions: [ { id: "activate", label: "Activate", - canApply: (vendor) => vendor.status === "inactive", - onClick: (vendors) => activate(vendors), + onClick: async (ids) => { + const activatedIds = new Set(await activate(ids)); + setSelectedIds((current) => current.filter((id) => !activatedIds.has(id))); + }, }, ], }); ``` -`RowAction` and `SelectionAction` now share a base type, `DataTableAction` (`id`, `label`, `icon`, `variant`, `canApply`), so one definition can be spread into both `rowActions` and `selectionActions`. `RowAction` gains `canApply`; its `isDisabled`, which reads the other way round, is deprecated but still honoured. +`RowAction` and the non-generic `SelectionAction` share the non-generic presentation base `DataTableAction` (`id`, `label`, `icon`, `variant`). `RowAction` gains row-based `canApply`; its `isDisabled`, which reads the other way round, is deprecated but still honoured. Bulk eligibility belongs in the consumer's ID-based workflow. -Selection now also remembers the rows it holds across pages (`selectedRows`), and a non-empty `selectionActions` enables selection on its own. The first three actions render as buttons and the rest collapse into a "More actions" menu. On a page-scrolling table the bar sticks to the bottom of the viewport until the table's end scrolls into view. +Selection now also remembers loaded selected rows across pages (`selectedRows`), a partial cache rather than the bulk-operation payload. A non-empty `selectionActions` enables selection on its own. The first three actions render as buttons and the rest collapse into a "More actions" menu. On a page-scrolling table the bar sticks to the bottom of the viewport until the table's end scrolls into view. **Behavior change:** the header checkbox is now page-scoped in both directions. Checking it adds the current page's rows to the selection instead of replacing it, and unchecking it removes only the current page's rows instead of clearing every page. `clearSelection` still empties everything (and is now a no-op when nothing is selected), and the new `deselectAllRows` is the page-scoped counterpart of `selectAllRows`. diff --git a/decisions/data-table-selection-footer-actions.md b/decisions/data-table-selection-footer-actions.md index 2609379bd..766437f48 100644 --- a/decisions/data-table-selection-footer-actions.md +++ b/decisions/data-table-selection-footer-actions.md @@ -3,6 +3,23 @@ > Status: **Decided — built into DataTable as `selectionActions` (Option A below). Implemented by PR #496.** > Scope: where multi-select bulk actions render, and the `useDataTable` API that declares them. Ticket: tailor-inc/platform-planning#1738. +## Operation contract update after #604 + +The footer placement and interaction design below remain unchanged. The original +row-based bulk-operation contract is superseded by the +[follow-up proposal on #604](https://github.com/tailor-platform/app-shell/pull/604#issuecomment-6160412655): + +- `SelectionAction.onClick` receives a click-time copy of every selected ID, + including IDs whose rows have never loaded. +- Selection actions have no `canApply`, automatic eligible counts, or + `keepSelection`. Row actions retain their row-based `canApply` predicate. +- Neither returned promises nor `helpers.run` clear selection. They only track + pending work; consumers own eligibility, confirmation, execution, and selection + updates based on actual results. `clearSelection` remains an explicit command. +- `selectedRows` remains a partial cache, not a complete operation target. + +The original rationale is preserved below as historical context. + ## Context Multi-select already worked: `onSelectionChange` added the checkbox column, and the footer read "N of M row(s) selected". AppShell had no settled answer for **where the bulk actions go** once rows are selected. The `interaction/multi-select` pattern prescribed a floating bottom bar built on raw `Table.Root` with native checkboxes, which predated DataTable's own selection. diff --git a/docs-manifest.json b/docs-manifest.json index beccfd5b0..fbe0d2b97 100644 --- a/docs-manifest.json +++ b/docs-manifest.json @@ -447,10 +447,10 @@ "withURLCollectionState" ], "hashes": { - "typeSurface": "d8d9fe5ef1459ad6", - "outline": "30e9c33a9fddd652", + "typeSurface": "0e0e081a20ed4f14", + "outline": "598fad95e7fba24c", "snapshot": null, - "outputMd": "a0ec09ea656fcc7a", + "outputMd": "c0177dbc181bf641", "examples": null } }, @@ -948,10 +948,10 @@ "symbols": [], "hashes": { "typeSurface": null, - "outline": "8d704967c8c882fd", + "outline": "a3d8ae4dca5c6eeb", "snapshot": null, - "outputMd": "770b33ada6b9317f", - "examples": "04c7dc51378c04c4" + "outputMd": "d43dad72f2584803", + "examples": "0ecdf5402f6e073c" } }, "interaction-toast": { @@ -1711,7 +1711,7 @@ "packages/core/skills/app-shell-patterns/references/components/combobox.md": "dfb775c7c4307409", "packages/core/skills/app-shell-patterns/references/components/command-palette.md": "7e5bc675a593791a", "packages/core/skills/app-shell-patterns/references/components/csv-importer.md": "53d3c795ca21b9dc", - "packages/core/skills/app-shell-patterns/references/components/data-table.md": "a0ec09ea656fcc7a", + "packages/core/skills/app-shell-patterns/references/components/data-table.md": "c0177dbc181bf641", "packages/core/skills/app-shell-patterns/references/components/date-picker.md": "bc69f14829004f9d", "packages/core/skills/app-shell-patterns/references/components/default-header.md": "375e55f4b12fa2be", "packages/core/skills/app-shell-patterns/references/components/default-sidebar.md": "3422388a0cc78571", @@ -1771,7 +1771,7 @@ "packages/core/skills/app-shell-patterns/references/patterns/form-single-page.md": "10a0f4d91a120752", "packages/core/skills/app-shell-patterns/references/patterns/form-wizard.md": "851afb4672da6673", "packages/core/skills/app-shell-patterns/references/patterns/interaction-confirm.md": "2db70f423c6fdf97", - "packages/core/skills/app-shell-patterns/references/patterns/interaction-multi-select.md": "770b33ada6b9317f", + "packages/core/skills/app-shell-patterns/references/patterns/interaction-multi-select.md": "d43dad72f2584803", "packages/core/skills/app-shell-patterns/references/patterns/interaction-toast.md": "f68bdab855730f48", "packages/core/skills/app-shell-patterns/references/patterns/list-dense-scan.md": "916baa3cd2b9cbf1", "packages/core/skills/app-shell-patterns/references/pages/document-detail.md": "717ef4e7ae835aff", diff --git a/docs-src/components/data-table.docs.outline.md b/docs-src/components/data-table.docs.outline.md index 94624d033..9a29a764f 100644 --- a/docs-src/components/data-table.docs.outline.md +++ b/docs-src/components/data-table.docs.outline.md @@ -411,52 +411,65 @@ const table = useDataTable({ Keep the controlled/uncontrolled mode stable while mounted. Callbacks notify user-driven selection changes, not mounting or external `selectedIds` updates. `onSelectionChange` is deprecated but remains supported for compatibility. Replace `onSelectionChange: handleSelectionChange` with `rowSelection: { onChange: handleSelectionChange }` to keep uncontrolled notification behavior. `rowSelection.onChange` takes precedence when both callbacks are supplied, and `onSelectionChange` is used when the new configuration has no callback. -Selection spans pages. The header checkbox adds or removes only the current page's rows, preserving ids selected on other pages; `clearSelection()` clears every page. `selectedRows` contains current-page rows in their latest version and off-page rows as last loaded. Selected ids whose rows have never been loaded are omitted from `selectedRows` until seen, so the selection count can exceed the number of available row objects. Bulk actions operate only on those available rows. +Selection spans pages. The header checkbox adds or removes only the current page's rows, preserving ids selected on other pages; `clearSelection()` clears every page. `selectedRows` is a partial cache: it contains current-page rows in their latest version and off-page rows as last loaded. Selected ids whose rows have never been loaded are omitted until seen, so the selection count can exceed the number of available row objects. This cache is not the operation target: bulk actions receive all selected ids, including ids whose rows have not been loaded. ## Selection actions Pass `selectionActions` to `useDataTable` and, while at least one row is selected, `DataTable.Footer` becomes a bulk-action bar: the selection count, your actions, and a **Clear** button, with the footer's own children (usually `DataTable.Pagination`) kept alongside. A non-empty array also enables row selection, so `rowSelection` and `onSelectionChange` are optional. There is nothing new to compose in JSX — just keep `DataTable.Footer` in the tree. ```tsx +const [selectedIds, setSelectedIds] = useState([]); +const [deleteTargetIds, setDeleteTargetIds] = useState(null); + const table = useDataTable({ columns, data, control, + rowSelection: { selectedIds, onChange: setSelectedIds }, selectionActions: [ { - id: "activate", - label: "Activate", - icon: , - canApply: (vendor) => vendor.status === "inactive", - // Returning the promise: the bar waits, then clears the selection. - onClick: (vendors) => activateVendors(vendors.map((vendor) => vendor.id)), - }, - { - id: "export", - label: "Export", - icon: , - keepSelection: true, // exporting doesn't change the rows - onClick: (vendors) => exportCsv(vendors), + id: "delete", + label: "Delete", + variant: "destructive", + onClick: (ids) => setDeleteTargetIds([...ids]), }, ], }); - - - - - -; +return ( + <> + + + + + + + + {deleteTargetIds !== null && ( + setDeleteTargetIds(null)} + onDeleted={(deletedIds: string[]) => { + const deleted = new Set(deletedIds); + setSelectedIds((current) => current.filter((id) => !deleted.has(id))); + setDeleteTargetIds(null); + }} + /> + )} + +); ``` -- **`canApply` scopes an action to part of the selection.** The button shows how many selected rows qualify — `Activate (6)` — is disabled when none do, and `onClick` receives only those rows. Omit it for actions that apply to every selected row; no count is shown then. -- **Return the promise from `onClick`.** While it is pending, the bar disables every action and shows a spinner on the running one, so a slow request can't be fired twice. When it resolves, the selection is cleared — the rows may have changed, and copies remembered from other pages can't refresh themselves. Set `keepSelection: true` for actions that don't change the rows, such as an export. If the promise rejects, the selection stays so the action can be retried, and the error is logged as `[DataTable] Selection action "…" failed`; showing it to the user is up to your `onClick`. -- **Confirm dialogs hand the request back with `run`.** An `onClick` that only opens a confirm dialog returns nothing, so there is nothing to wait on yet. Keep the `run` helper and call it from the dialog's confirm button — `run(deleteRows(rows))` — and the bar shows the same pending state and clears on success. Clear is disabled while a request is pending, and the pending state is kept by the table, so it survives the bar closing and reopening. A synchronous handler that never calls `run` leaves the selection alone; call `clearSelection` when it should. -- **Selection spans pages.** The table remembers each selected row as it was last loaded, so counts and `onClick` cover rows selected on other pages too. Rows on the current page are always their latest version. The header checkbox adds or removes only the current page; **Clear** empties everything. +`DeleteVendorsDialog` is a consumer-owned component, not an AppShell component. It can fetch the target records, explain eligibility (for example, “Three of five selected vendors can be deleted; two are in use”), confirm, and execute. Backend validation remains authoritative; `onDeleted` reports only ids actually deleted. Removing those ids from the **current** selection preserves ineligible records and records selected after the dialog opened. Cancellation or failure leaves selection unchanged. + +- **`onClick` receives a click-time snapshot of all selected ids.** The array is a copy, in selection order, including ids whose rows have not been loaded. Page visits and `selectedRows` cache availability do not change the target. Selection actions have no row-based `canApply`, automatic eligible count, or eligibility-based disabled state; the consumer owns eligibility checks, required fetching, confirmation UI, and execution. Row actions can still use `canApply` for their concrete row. +- **Return the promise from `onClick`.** While it is pending, the bar disables every action and shows a spinner on the running one, so a slow request can't be fired twice. Neither success nor rejection changes selection; there is no `keepSelection` option. A rejection is logged as `[DataTable] Selection action "…" failed`; showing it to the user is up to your handler. +- **Confirm dialogs can hand a later request back with `run`.** An `onClick` that only opens a dialog returns nothing. Keep the `run` helper and call it from the dialog's confirm button — `await run(deleteVendors(ids))` — to track that request in the bar. This only tracks pending state; it does not clear selection. Clear is disabled while a request is pending, and pending state survives the bar closing and reopening. Use controlled `rowSelection` to remove only successful ids, or call the explicit `clearSelection` helper if clearing every page is intentional. +- **Selection spans pages.** The header checkbox adds or removes only the current page; **Clear** empties everything. - **Three actions stay inline.** The fourth onward collapse into a **More actions** menu, in array order. Put the most frequent first; a destructive action placed last sits safely in the menu. - **Confirm destructive actions.** `variant: "destructive"` only styles the action. Open a confirm dialog from `onClick` before deleting — see the [confirm pattern](../patterns/interaction-confirm.md). - **The bar stays reachable.** In `` the footer is already pinned. On a page that scrolls, the bar sticks to the bottom of the viewport while rows are selected and settles back into place at the end of the table. It sticks to the nearest scrolling container: inside a scrolling drawer or panel it rides that container instead, and inside a wrapper that clips its overflow (such as a card with `overflow: hidden`) it simply stays in the footer. -- **Share definitions with `rowActions`.** `SelectionAction` and `RowAction` both extend [`DataTableAction`](#datatableaction), so an action defined once — label, icon, `canApply` — can be spread into both arrays with a different `onClick`. +- **Share presentation with `rowActions`.** Both action types extend the non-generic [`DataTableAction`](#datatableaction) (`id`, `label`, `icon`, `variant`). Keep row predicates on `RowAction` and bulk business rules in the consumer's ID-based handler. ### Accessibility @@ -489,7 +502,7 @@ const table = useDataTable({ | `rowActions` | `RowAction[]` | Per-row action items rendered in a kebab-menu column. The column is omitted when empty or not provided. | | `rowSelection` | `RowSelectionOptions` | Controlled `selectedIds` + `onChange`, or uncontrolled `defaultSelectedIds` + optional `onChange`. Providing even `{}` enables checkboxes. See [Row selection](#row-selection). | | `onSelectionChange` | `(ids: string[]) => void` | **Deprecated:** use `rowSelection.onChange`. Still supported for compatibility; enables checkboxes and is used when the new callback is absent. | -| `selectionActions` | `SelectionAction[]` | Bulk actions shown in `DataTable.Footer` while rows are selected. A non-empty array also enables selection. See [Selection actions](#selection-actions). | +| `selectionActions` | `SelectionAction[]` | Bulk actions shown in `DataTable.Footer` while rows are selected. A non-empty array also enables selection. See [Selection actions](#selection-actions). | | `rowExpansion` | `RowExpansionOptions` | Expandable detail rows: `render`, plus optional `canExpand` / `getLabel`, and `expandedIds` + `onChange` together for controlled mode. See [Expandable rows](#expandable-rows). | | `sort` | `false \| { multiple?: boolean }` | Sort behaviour. `false` disables sorting entirely. `{ multiple: true }` enables multi-column sorting. Omit or pass `{}` for single-column sort (default). | @@ -896,7 +909,7 @@ When `caseSensitive` is omitted or `false`, the filter is case-insensitive. When ## `RowAction` -A row action in the kebab-menu column. Extends [`DataTableAction`](#datatableaction) (`id`, `label`, `icon`, `variant`, `canApply`). +A row action in the kebab-menu column. `RowAction` extends the non-generic [`DataTableAction`](#datatableaction) (`id`, `label`, `icon`, `variant`) and adds row-based behavior. | Property | Type | Description | | ------------ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------- | @@ -906,38 +919,42 @@ A row action in the kebab-menu column. Extends [`DataTableAction`](#datatableact ## `SelectionAction` -A bulk action for the rows currently selected. Extends [`DataTableAction`](#datatableaction). See [Selection actions](#selection-actions). +A non-generic bulk action for selected ids. Extends [`DataTableAction`](#datatableaction). See [Selection actions](#selection-actions). + +| Property | Type | Description | +| --------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `onClick` | `(ids: string[], helpers: SelectionActionHelpers) => void \| Promise` | Receives a copy of all selected ids at click time, including unloaded ids. Return a promise, or pass a later request to `helpers.run`, for pending-state tracking only. Selection is never cleared automatically. | -| Property | Type | Description | -| --------------- | ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `canApply` | `(row: TRow) => boolean` | Scopes the action to the selected rows it can act on: shows their count, disables at zero, and passes only those rows to `onClick`. | -| `onClick` | `(rows: TRow[], helpers: { clearSelection, run }) => void \| Promise` | Called with the selected rows the action can act on, including rows selected on other pages. Return a promise — or pass a later one to `run`, e.g. from a confirm dialog — to get the pending state and clear-on-success; see [Selection actions](#selection-actions). | -| `keepSelection` | `boolean` | Keep the selection after the promise resolves — for actions that don't change the rows, such as an export. | +`SelectionActionHelpers` provides `clearSelection(): void` to explicitly clear every page and `run(request: PromiseLike): PromiseLike` to track a later request without changing selection. Eligibility, fetching, confirmation, execution, and result-based selection updates belong to the consumer. ## `DataTableAction` -What `RowAction` and `SelectionAction` share. Define an action once and spread it into both arrays — only `onClick` differs. +Non-generic presentation shared by `RowAction` and `SelectionAction`. Share these fields, not eligibility rules: row actions can evaluate a concrete row, while selection actions operate on ids. -| Property | Type | Description | -| ---------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| `id` | `string` | Stable identifier for the action. | -| `label` | `string` | Display label — the menu item for a row action, the button (or **More actions** item) for a selection action. | -| `icon` | `ReactNode` | Optional icon shown beside the label. | -| `variant` | `"default" \| "destructive"` | Visual style. A destructive action still needs its own confirmation step. | -| `canApply` | `(row: TRow) => boolean` | Which rows the action can act on. A row action is disabled where it returns `false`; a selection action counts the rows where it returns `true`. | +| Property | Type | Description | +| --------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `id` | `string` | Stable identifier for the action. | +| `label` | `string` | Display label — the menu item for a row action, the button (or **More actions** item) for a selection action. | +| `icon` | `ReactNode` | Optional icon shown beside the label. | +| `variant` | `"default" \| "destructive"` | Visual style. A destructive action still needs its own confirmation step. | ```tsx -const archive: DataTableAction = { +const archive: DataTableAction = { id: "archive", label: "Archive", - canApply: (order) => order.status !== "Archived", }; -useDataTable({ +useDataTable({ columns, data, - rowActions: [{ ...archive, onClick: (order) => archiveOrders([order]) }], - selectionActions: [{ ...archive, onClick: (orders) => archiveOrders(orders) }], + rowActions: [ + { + ...archive, + canApply: (order) => order.status !== "Archived", + onClick: (order) => archiveOrders([order.id]), + }, + ], + selectionActions: [{ ...archive, onClick: (ids) => archiveOrders(ids) }], }); ``` diff --git a/docs-src/patterns/interaction-multi-select.docs.examples.tsx b/docs-src/patterns/interaction-multi-select.docs.examples.tsx index 2d6d09284..a9112fcb6 100644 --- a/docs-src/patterns/interaction-multi-select.docs.examples.tsx +++ b/docs-src/patterns/interaction-multi-select.docs.examples.tsx @@ -30,59 +30,76 @@ const columns: Column[] = [ { label: "Total", render: (order) => `$${order.total.toLocaleString()}` }, ]; -type PendingCancel = { orders: Order[]; run: SelectionActionHelpers["run"] }; +type PendingCancel = { ids: string[]; run: SelectionActionHelpers["run"] }; -// Stand-ins for real mutations. -const cancelOrders = async (orders: Order[]) => { - window.alert(`Cancelling ${orders.length} order(s)`); +// Stand-ins for backend operations over the complete dataset, not the table's row cache. +const cancelOrders = async (ids: string[]) => { + const cancelledIds = mockOrders + .filter((order) => ids.includes(order.id) && order.status !== "Shipped") + .map((order) => order.id); + window.alert(`Cancelling ${cancelledIds.length} order(s)`); + return cancelledIds; }; -const confirmOrders = async (orders: Order[]) => { - window.alert(`Confirming ${orders.length} order(s)`); +const confirmOrders = async (ids: string[]) => { + const confirmedIds = mockOrders + .filter((order) => ids.includes(order.id) && order.status === "Open") + .map((order) => order.id); + window.alert(`Confirming ${confirmedIds.length} order(s)`); + return confirmedIds; }; -const exportOrders = async (orders: Order[]) => { - window.alert(`Exporting ${orders.length} order(s)`); +const exportOrders = async (ids: string[]) => { + window.alert(`Exporting ${ids.length} order(s)`); }; export function InteractionMultiSelect() { + const [selectedIds, setSelectedIds] = useState([]); const [pendingCancel, setPendingCancel] = useState(null); + const [cancelling, setCancelling] = useState(false); + const [cancelError, setCancelError] = useState(null); - const selectionActions: SelectionAction[] = [ + const removeCompletedIds = (ids: string[]) => { + const completed = new Set(ids); + setSelectedIds((current) => current.filter((id) => !completed.has(id))); + }; + + const selectionActions: SelectionAction[] = [ { id: "confirm", label: "Confirm", - // Only open orders can be confirmed: the bar shows "Confirm (n)". - canApply: (order) => order.status === "Open", - // Return the promise: the bar disables its actions while it runs, then - // clears the selection. - onClick: (orders) => confirmOrders(orders), + // The operation returns actual successes; never clear ineligible or newer selections. + onClick: async (ids) => removeCompletedIds(await confirmOrders(ids)), }, { id: "export", label: "Export", - // Exporting doesn't change the rows, so keep them selected. - keepSelection: true, - onClick: (orders) => exportOrders(orders), + onClick: (ids) => exportOrders(ids), }, { id: "assign", label: "Assign owner", - onClick: async (orders) => window.alert(`Assigning ${orders.length} order(s)`), + onClick: async (ids) => window.alert(`Assigning ${ids.length} order(s)`), }, { // 4th action: lands in the More actions menu. id: "cancel", label: "Cancel", variant: "destructive", - canApply: (order) => order.status !== "Shipped", - // Destructive: confirm first (interaction/confirm). Keep `run` and hand - // the request back from the dialog's confirm button. - onClick: (orders, { run }) => setPendingCancel({ orders, run }), + onClick: (ids, { run }) => { + setCancelError(null); + setPendingCancel({ ids: [...ids], run }); + }, }, ]; + // In a real app, fetch eligibility for these snapshot ids before showing this explanation. + const cancellableCount = mockOrders.filter( + (order) => pendingCancel?.ids.includes(order.id) && order.status !== "Shipped", + ).length; + const table = useDataTable({ columns, data: { rows: mockOrders, total: mockOrders.length }, + rowSelection: { selectedIds, onChange: setSelectedIds }, selectionActions, }); @@ -98,25 +115,41 @@ export function InteractionMultiSelect() { { - if (!open) setPendingCancel(null); + if (!open && !cancelling) setPendingCancel(null); }} > - Cancel {pendingCancel?.orders.length} order(s)? - Cancelled orders can't be reopened. + Cancel selected orders? + + {cancellableCount} of {pendingCancel?.ids.length ?? 0} selected orders can be + cancelled; shipped orders are excluded. Cancelled orders can't be reopened. + + {cancelError &&

{cancelError}

} - }>Keep orders + }> + Keep orders +
diff --git a/docs-src/patterns/interaction-multi-select.docs.outline.md b/docs-src/patterns/interaction-multi-select.docs.outline.md index 6386936ae..e20483b80 100644 --- a/docs-src/patterns/interaction-multi-select.docs.outline.md +++ b/docs-src/patterns/interaction-multi-select.docs.outline.md @@ -43,7 +43,7 @@ Pass `selectionActions` to `useDataTable`. The moment one row is selected, `Data | [ ] | row | row | row | row | | [x] | row | row | row | row | | DataTable.Footer, while rows are selected: | -| 3 of 240 selected | [Confirm (2)] [Export] [⋯] | Clear ‹ 1/10 › | +| 3 of 240 selected | [Confirm] [Export] [⋯] | Clear ‹ 1/10 › | +--------------------------------------------------------------------+ ``` @@ -53,13 +53,15 @@ Pass `selectionActions` to `useDataTable`. The moment one row is selected, `Data ## Constraints -- Declare bulk actions with `selectionActions` on `useDataTable` — do NOT hand-build the bar, a floating panel, or checkbox state; the footer renders the count, actions, overflow menu, and Clear +- Declare bulk actions with the non-generic `SelectionAction[]` on `useDataTable` — do NOT hand-build the bar, a floating panel, or checkboxes; the footer renders the selection count, actions, overflow menu, and Clear - Keep `DataTable.Footer` in the tree — it is where the bar renders -- Give an action `canApply` when it fits only some rows: the bar shows the eligible count, disables the action at 0, and passes only those rows to `onClick` -- Return the promise from `onClick` for async work: the bar disables its actions while it runs and clears the selection when it resolves; set `keepSelection: true` on actions that don't change rows (export) +- `onClick` receives a click-time copy of all selected ids (`string[]`), including unloaded ids. `selectedRows` is only a partial cache, not the operation payload +- The consumer owns eligibility checks, required fetching, confirmation UI, and execution; backend validation remains authoritative. Selection actions have no row-based `canApply`, automatic eligible count, or eligibility-based disabled state +- Return the promise from `onClick` for async work: the bar disables its actions and shows a spinner while it runs. Success and rejection both leave selection unchanged; there is no `keepSelection` option. Rejections are logged; show errors in the consumer UI +- Use controlled `rowSelection: { selectedIds, onChange }` to remove only ids actually changed, with a functional update against the current selection. Preserve ineligible ids and ids selected after the operation started; cancellation or failure does not clear selection - At most 3 actions render inline, in array order; the 4th onward move to the More actions menu — order by frequency and put a destructive action last -- Destructive bulk actions MUST open an `interaction/confirm` dialog from `onClick`; keep the `run` helper and pass it the request from the dialog's confirm button (`run(deleteRows(rows))`) so the bar shows its pending state and clears on success -- When an action also exists per row, define it once as a `DataTableAction` and spread it into both `rowActions` and `selectionActions` +- Destructive bulk actions MUST open an `interaction/confirm` dialog from `onClick`. Optionally keep the `run` helper and pass it the later request (`await run(deleteRows(ids))`) for pending tracking only. `clearSelection` explicitly clears every page when that is intentional +- Share only presentation fields (`id`, `label`, `icon`, `variant`) as a non-generic `DataTableAction`. `RowAction` retains `canApply` for its concrete row; bulk eligibility belongs in the ID-based consumer workflow - Selection persists across pagination, filter, and sort changes — do NOT clear it on those ## Anti-patterns @@ -67,7 +69,8 @@ Pass `selectionActions` to `useDataTable`. The moment one row is selected, `Data - Hand-rolled selection (`useState`, raw ``, `Table.Root`) when the list is a DataTable - A floating or fixed-position action bar over the table instead of the footer bar - Placing bulk-action buttons in the page header or the table toolbar -- Counting eligible rows per action by hand instead of `canApply` -- Fire-and-forget async `onClick` that doesn't return its promise — users can fire it twice, and the selection goes stale +- Treating loaded `selectedRows` as the full selection or deriving bulk eligibility from the table's partial cache +- Clearing the whole selection after partial success, or overwriting it with the click-time snapshot and losing newer selections +- Fire-and-forget async `onClick` that doesn't return its promise or use `run` — users can fire it twice - Firing destructive bulk actions without an interaction/confirm step - Per-row `Menu` actions as a substitute for bulk actions when selection > 0 diff --git a/docs/components/data-table.md b/docs/components/data-table.md index 9278e8bcc..3b40996f8 100644 --- a/docs/components/data-table.md +++ b/docs/components/data-table.md @@ -405,52 +405,65 @@ const table = useDataTable({ Keep the controlled/uncontrolled mode stable while mounted. Callbacks notify user-driven selection changes, not mounting or external `selectedIds` updates. `onSelectionChange` is deprecated but remains supported for compatibility. Replace `onSelectionChange: handleSelectionChange` with `rowSelection: { onChange: handleSelectionChange }` to keep uncontrolled notification behavior. `rowSelection.onChange` takes precedence when both callbacks are supplied, and `onSelectionChange` is used when the new configuration has no callback. -Selection spans pages. The header checkbox adds or removes only the current page's rows, preserving ids selected on other pages; `clearSelection()` clears every page. `selectedRows` contains current-page rows in their latest version and off-page rows as last loaded. Selected ids whose rows have never been loaded are omitted from `selectedRows` until seen, so the selection count can exceed the number of available row objects. Bulk actions operate only on those available rows. +Selection spans pages. The header checkbox adds or removes only the current page's rows, preserving ids selected on other pages; `clearSelection()` clears every page. `selectedRows` is a partial cache: it contains current-page rows in their latest version and off-page rows as last loaded. Selected ids whose rows have never been loaded are omitted until seen, so the selection count can exceed the number of available row objects. This cache is not the operation target: bulk actions receive all selected ids, including ids whose rows have not been loaded. ## Selection actions Pass `selectionActions` to `useDataTable` and, while at least one row is selected, `DataTable.Footer` becomes a bulk-action bar: the selection count, your actions, and a **Clear** button, with the footer's own children (usually `DataTable.Pagination`) kept alongside. A non-empty array also enables row selection, so `rowSelection` and `onSelectionChange` are optional. There is nothing new to compose in JSX — just keep `DataTable.Footer` in the tree. ```tsx +const [selectedIds, setSelectedIds] = useState([]); +const [deleteTargetIds, setDeleteTargetIds] = useState(null); + const table = useDataTable({ columns, data, control, + rowSelection: { selectedIds, onChange: setSelectedIds }, selectionActions: [ { - id: "activate", - label: "Activate", - icon: , - canApply: (vendor) => vendor.status === "inactive", - // Returning the promise: the bar waits, then clears the selection. - onClick: (vendors) => activateVendors(vendors.map((vendor) => vendor.id)), - }, - { - id: "export", - label: "Export", - icon: , - keepSelection: true, // exporting doesn't change the rows - onClick: (vendors) => exportCsv(vendors), + id: "delete", + label: "Delete", + variant: "destructive", + onClick: (ids) => setDeleteTargetIds([...ids]), }, ], }); - - - - - -; +return ( + <> + + + + + + + + {deleteTargetIds !== null && ( + setDeleteTargetIds(null)} + onDeleted={(deletedIds: string[]) => { + const deleted = new Set(deletedIds); + setSelectedIds((current) => current.filter((id) => !deleted.has(id))); + setDeleteTargetIds(null); + }} + /> + )} + +); ``` -- **`canApply` scopes an action to part of the selection.** The button shows how many selected rows qualify — `Activate (6)` — is disabled when none do, and `onClick` receives only those rows. Omit it for actions that apply to every selected row; no count is shown then. -- **Return the promise from `onClick`.** While it is pending, the bar disables every action and shows a spinner on the running one, so a slow request can't be fired twice. When it resolves, the selection is cleared — the rows may have changed, and copies remembered from other pages can't refresh themselves. Set `keepSelection: true` for actions that don't change the rows, such as an export. If the promise rejects, the selection stays so the action can be retried, and the error is logged as `[DataTable] Selection action "…" failed`; showing it to the user is up to your `onClick`. -- **Confirm dialogs hand the request back with `run`.** An `onClick` that only opens a confirm dialog returns nothing, so there is nothing to wait on yet. Keep the `run` helper and call it from the dialog's confirm button — `run(deleteRows(rows))` — and the bar shows the same pending state and clears on success. Clear is disabled while a request is pending, and the pending state is kept by the table, so it survives the bar closing and reopening. A synchronous handler that never calls `run` leaves the selection alone; call `clearSelection` when it should. -- **Selection spans pages.** The table remembers each selected row as it was last loaded, so counts and `onClick` cover rows selected on other pages too. Rows on the current page are always their latest version. The header checkbox adds or removes only the current page; **Clear** empties everything. +`DeleteVendorsDialog` is a consumer-owned component, not an AppShell component. It can fetch the target records, explain eligibility (for example, “Three of five selected vendors can be deleted; two are in use”), confirm, and execute. Backend validation remains authoritative; `onDeleted` reports only ids actually deleted. Removing those ids from the **current** selection preserves ineligible records and records selected after the dialog opened. Cancellation or failure leaves selection unchanged. + +- **`onClick` receives a click-time snapshot of all selected ids.** The array is a copy, in selection order, including ids whose rows have not been loaded. Page visits and `selectedRows` cache availability do not change the target. Selection actions have no row-based `canApply`, automatic eligible count, or eligibility-based disabled state; the consumer owns eligibility checks, required fetching, confirmation UI, and execution. Row actions can still use `canApply` for their concrete row. +- **Return the promise from `onClick`.** While it is pending, the bar disables every action and shows a spinner on the running one, so a slow request can't be fired twice. Neither success nor rejection changes selection; there is no `keepSelection` option. A rejection is logged as `[DataTable] Selection action "…" failed`; showing it to the user is up to your handler. +- **Confirm dialogs can hand a later request back with `run`.** An `onClick` that only opens a dialog returns nothing. Keep the `run` helper and call it from the dialog's confirm button — `await run(deleteVendors(ids))` — to track that request in the bar. This only tracks pending state; it does not clear selection. Clear is disabled while a request is pending, and pending state survives the bar closing and reopening. Use controlled `rowSelection` to remove only successful ids, or call the explicit `clearSelection` helper if clearing every page is intentional. +- **Selection spans pages.** The header checkbox adds or removes only the current page; **Clear** empties everything. - **Three actions stay inline.** The fourth onward collapse into a **More actions** menu, in array order. Put the most frequent first; a destructive action placed last sits safely in the menu. - **Confirm destructive actions.** `variant: "destructive"` only styles the action. Open a confirm dialog from `onClick` before deleting — see the [confirm pattern](../patterns/interaction-confirm.md). - **The bar stays reachable.** In `` the footer is already pinned. On a page that scrolls, the bar sticks to the bottom of the viewport while rows are selected and settles back into place at the end of the table. It sticks to the nearest scrolling container: inside a scrolling drawer or panel it rides that container instead, and inside a wrapper that clips its overflow (such as a card with `overflow: hidden`) it simply stays in the footer. -- **Share definitions with `rowActions`.** `SelectionAction` and `RowAction` both extend [`DataTableAction`](#datatableaction), so an action defined once — label, icon, `canApply` — can be spread into both arrays with a different `onClick`. +- **Share presentation with `rowActions`.** Both action types extend the non-generic [`DataTableAction`](#datatableaction) (`id`, `label`, `icon`, `variant`). Keep row predicates on `RowAction` and bulk business rules in the consumer's ID-based handler. ### Accessibility @@ -483,7 +496,7 @@ const table = useDataTable({ | `rowActions` | `RowAction[]` | Per-row action items rendered in a kebab-menu column. The column is omitted when empty or not provided. | | `rowSelection` | `RowSelectionOptions` | Controlled `selectedIds` + `onChange`, or uncontrolled `defaultSelectedIds` + optional `onChange`. Providing even `{}` enables checkboxes. See [Row selection](#row-selection). | | `onSelectionChange` | `(ids: string[]) => void` | **Deprecated:** use `rowSelection.onChange`. Still supported for compatibility; enables checkboxes and is used when the new callback is absent. | -| `selectionActions` | `SelectionAction[]` | Bulk actions shown in `DataTable.Footer` while rows are selected. A non-empty array also enables selection. See [Selection actions](#selection-actions). | +| `selectionActions` | `SelectionAction[]` | Bulk actions shown in `DataTable.Footer` while rows are selected. A non-empty array also enables selection. See [Selection actions](#selection-actions). | | `rowExpansion` | `RowExpansionOptions` | Expandable detail rows: `render`, plus optional `canExpand` / `getLabel`, and `expandedIds` + `onChange` together for controlled mode. See [Expandable rows](#expandable-rows). | | `sort` | `false \| { multiple?: boolean }` | Sort behaviour. `false` disables sorting entirely. `{ multiple: true }` enables multi-column sorting. Omit or pass `{}` for single-column sort (default). | @@ -890,7 +903,7 @@ When `caseSensitive` is omitted or `false`, the filter is case-insensitive. When ## `RowAction` -A row action in the kebab-menu column. Extends [`DataTableAction`](#datatableaction) (`id`, `label`, `icon`, `variant`, `canApply`). +A row action in the kebab-menu column. `RowAction` extends the non-generic [`DataTableAction`](#datatableaction) (`id`, `label`, `icon`, `variant`) and adds row-based behavior. | Property | Type | Description | | ------------ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------- | @@ -900,38 +913,42 @@ A row action in the kebab-menu column. Extends [`DataTableAction`](#datatableact ## `SelectionAction` -A bulk action for the rows currently selected. Extends [`DataTableAction`](#datatableaction). See [Selection actions](#selection-actions). +A non-generic bulk action for selected ids. Extends [`DataTableAction`](#datatableaction). See [Selection actions](#selection-actions). + +| Property | Type | Description | +| --------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `onClick` | `(ids: string[], helpers: SelectionActionHelpers) => void \| Promise` | Receives a copy of all selected ids at click time, including unloaded ids. Return a promise, or pass a later request to `helpers.run`, for pending-state tracking only. Selection is never cleared automatically. | -| Property | Type | Description | -| --------------- | ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `canApply` | `(row: TRow) => boolean` | Scopes the action to the selected rows it can act on: shows their count, disables at zero, and passes only those rows to `onClick`. | -| `onClick` | `(rows: TRow[], helpers: { clearSelection, run }) => void \| Promise` | Called with the selected rows the action can act on, including rows selected on other pages. Return a promise — or pass a later one to `run`, e.g. from a confirm dialog — to get the pending state and clear-on-success; see [Selection actions](#selection-actions). | -| `keepSelection` | `boolean` | Keep the selection after the promise resolves — for actions that don't change the rows, such as an export. | +`SelectionActionHelpers` provides `clearSelection(): void` to explicitly clear every page and `run(request: PromiseLike): PromiseLike` to track a later request without changing selection. Eligibility, fetching, confirmation, execution, and result-based selection updates belong to the consumer. ## `DataTableAction` -What `RowAction` and `SelectionAction` share. Define an action once and spread it into both arrays — only `onClick` differs. +Non-generic presentation shared by `RowAction` and `SelectionAction`. Share these fields, not eligibility rules: row actions can evaluate a concrete row, while selection actions operate on ids. -| Property | Type | Description | -| ---------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| `id` | `string` | Stable identifier for the action. | -| `label` | `string` | Display label — the menu item for a row action, the button (or **More actions** item) for a selection action. | -| `icon` | `ReactNode` | Optional icon shown beside the label. | -| `variant` | `"default" \| "destructive"` | Visual style. A destructive action still needs its own confirmation step. | -| `canApply` | `(row: TRow) => boolean` | Which rows the action can act on. A row action is disabled where it returns `false`; a selection action counts the rows where it returns `true`. | +| Property | Type | Description | +| --------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `id` | `string` | Stable identifier for the action. | +| `label` | `string` | Display label — the menu item for a row action, the button (or **More actions** item) for a selection action. | +| `icon` | `ReactNode` | Optional icon shown beside the label. | +| `variant` | `"default" \| "destructive"` | Visual style. A destructive action still needs its own confirmation step. | ```tsx -const archive: DataTableAction = { +const archive: DataTableAction = { id: "archive", label: "Archive", - canApply: (order) => order.status !== "Archived", }; -useDataTable({ +useDataTable({ columns, data, - rowActions: [{ ...archive, onClick: (order) => archiveOrders([order]) }], - selectionActions: [{ ...archive, onClick: (orders) => archiveOrders(orders) }], + rowActions: [ + { + ...archive, + canApply: (order) => order.status !== "Archived", + onClick: (order) => archiveOrders([order.id]), + }, + ], + selectionActions: [{ ...archive, onClick: (ids) => archiveOrders(ids) }], }); ``` diff --git a/docs/patterns/interaction-multi-select.md b/docs/patterns/interaction-multi-select.md index 654eeaa88..d30677ad4 100644 --- a/docs/patterns/interaction-multi-select.md +++ b/docs/patterns/interaction-multi-select.md @@ -29,7 +29,7 @@ Pass `selectionActions` to `useDataTable`. The moment one row is selected, `Data | [ ] | row | row | row | row | | [x] | row | row | row | row | | DataTable.Footer, while rows are selected: | -| 3 of 240 selected | [Confirm (2)] [Export] [⋯] | Clear ‹ 1/10 › | +| 3 of 240 selected | [Confirm] [Export] [⋯] | Clear ‹ 1/10 › | +--------------------------------------------------------------------+ ``` @@ -39,45 +39,54 @@ Pass `selectionActions` to `useDataTable`. The moment one row is selected, `Data ```tsx function InteractionMultiSelect() { + const [selectedIds, setSelectedIds] = useState([]); const [pendingCancel, setPendingCancel] = useState(null); + const [cancelling, setCancelling] = useState(false); + const [cancelError, setCancelError] = useState(null); - const selectionActions: SelectionAction[] = [ + const removeCompletedIds = (ids: string[]) => { + const completed = new Set(ids); + setSelectedIds((current) => current.filter((id) => !completed.has(id))); + }; + + const selectionActions: SelectionAction[] = [ { id: "confirm", label: "Confirm", - // Only open orders can be confirmed: the bar shows "Confirm (n)". - canApply: (order) => order.status === "Open", - // Return the promise: the bar disables its actions while it runs, then - // clears the selection. - onClick: (orders) => confirmOrders(orders), + // The operation returns actual successes; never clear ineligible or newer selections. + onClick: async (ids) => removeCompletedIds(await confirmOrders(ids)), }, { id: "export", label: "Export", - // Exporting doesn't change the rows, so keep them selected. - keepSelection: true, - onClick: (orders) => exportOrders(orders), + onClick: (ids) => exportOrders(ids), }, { id: "assign", label: "Assign owner", - onClick: async (orders) => window.alert(`Assigning ${orders.length} order(s)`), + onClick: async (ids) => window.alert(`Assigning ${ids.length} order(s)`), }, { // 4th action: lands in the More actions menu. id: "cancel", label: "Cancel", variant: "destructive", - canApply: (order) => order.status !== "Shipped", - // Destructive: confirm first (interaction/confirm). Keep `run` and hand - // the request back from the dialog's confirm button. - onClick: (orders, { run }) => setPendingCancel({ orders, run }), + onClick: (ids, { run }) => { + setCancelError(null); + setPendingCancel({ ids: [...ids], run }); + }, }, ]; + // In a real app, fetch eligibility for these snapshot ids before showing this explanation. + const cancellableCount = mockOrders.filter( + (order) => pendingCancel?.ids.includes(order.id) && order.status !== "Shipped", + ).length; + const table = useDataTable({ columns, data: { rows: mockOrders, total: mockOrders.length }, + rowSelection: { selectedIds, onChange: setSelectedIds }, selectionActions, }); @@ -93,25 +102,41 @@ function InteractionMultiSelect() { { - if (!open) setPendingCancel(null); + if (!open && !cancelling) setPendingCancel(null); }} > - Cancel {pendingCancel?.orders.length} order(s)? - Cancelled orders can't be reopened. + Cancel selected orders? + + {cancellableCount} of {pendingCancel?.ids.length ?? 0} selected orders can be + cancelled; shipped orders are excluded. Cancelled orders can't be reopened. + + {cancelError &&

{cancelError}

} - }>Keep orders + }> + Keep orders +
@@ -123,13 +148,15 @@ function InteractionMultiSelect() { ## Constraints -- Declare bulk actions with `selectionActions` on `useDataTable` — do NOT hand-build the bar, a floating panel, or checkbox state; the footer renders the count, actions, overflow menu, and Clear +- Declare bulk actions with the non-generic `SelectionAction[]` on `useDataTable` — do NOT hand-build the bar, a floating panel, or checkboxes; the footer renders the selection count, actions, overflow menu, and Clear - Keep `DataTable.Footer` in the tree — it is where the bar renders -- Give an action `canApply` when it fits only some rows: the bar shows the eligible count, disables the action at 0, and passes only those rows to `onClick` -- Return the promise from `onClick` for async work: the bar disables its actions while it runs and clears the selection when it resolves; set `keepSelection: true` on actions that don't change rows (export) +- `onClick` receives a click-time copy of all selected ids (`string[]`), including unloaded ids. `selectedRows` is only a partial cache, not the operation payload +- The consumer owns eligibility checks, required fetching, confirmation UI, and execution; backend validation remains authoritative. Selection actions have no row-based `canApply`, automatic eligible count, or eligibility-based disabled state +- Return the promise from `onClick` for async work: the bar disables its actions and shows a spinner while it runs. Success and rejection both leave selection unchanged; there is no `keepSelection` option. Rejections are logged; show errors in the consumer UI +- Use controlled `rowSelection: { selectedIds, onChange }` to remove only ids actually changed, with a functional update against the current selection. Preserve ineligible ids and ids selected after the operation started; cancellation or failure does not clear selection - At most 3 actions render inline, in array order; the 4th onward move to the More actions menu — order by frequency and put a destructive action last -- Destructive bulk actions MUST open an `interaction/confirm` dialog from `onClick`; keep the `run` helper and pass it the request from the dialog's confirm button (`run(deleteRows(rows))`) so the bar shows its pending state and clears on success -- When an action also exists per row, define it once as a `DataTableAction` and spread it into both `rowActions` and `selectionActions` +- Destructive bulk actions MUST open an `interaction/confirm` dialog from `onClick`. Optionally keep the `run` helper and pass it the later request (`await run(deleteRows(ids))`) for pending tracking only. `clearSelection` explicitly clears every page when that is intentional +- Share only presentation fields (`id`, `label`, `icon`, `variant`) as a non-generic `DataTableAction`. `RowAction` retains `canApply` for its concrete row; bulk eligibility belongs in the ID-based consumer workflow - Selection persists across pagination, filter, and sort changes — do NOT clear it on those ## Anti-patterns @@ -137,7 +164,8 @@ function InteractionMultiSelect() { - Hand-rolled selection (`useState`, raw ``, `Table.Root`) when the list is a DataTable - A floating or fixed-position action bar over the table instead of the footer bar - Placing bulk-action buttons in the page header or the table toolbar -- Counting eligible rows per action by hand instead of `canApply` -- Fire-and-forget async `onClick` that doesn't return its promise — users can fire it twice, and the selection goes stale +- Treating loaded `selectedRows` as the full selection or deriving bulk eligibility from the table's partial cache +- Clearing the whole selection after partial success, or overwriting it with the click-time snapshot and losing newer selections +- Fire-and-forget async `onClick` that doesn't return its promise or use `run` — users can fire it twice - Firing destructive bulk actions without an interaction/confirm step - Per-row `Menu` actions as a substitute for bulk actions when selection > 0 diff --git a/examples/vite-app/src/pages/dashboard/products/page.tsx b/examples/vite-app/src/pages/dashboard/products/page.tsx index 52c4b769a..caf0efe47 100644 --- a/examples/vite-app/src/pages/dashboard/products/page.tsx +++ b/examples/vite-app/src/pages/dashboard/products/page.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { DataTable, Layout, @@ -10,7 +11,7 @@ import { type SelectionAction, } from "@tailor-platform/app-shell"; import { Package } from "lucide-react"; -import { type Product, useProductsQuery } from "../../../mock-products"; +import { allProducts, type Product, useProductsQuery } from "../../../mock-products"; const productMetadata = { name: "product", @@ -72,14 +73,11 @@ const columns = [ }), ]; -// Defined once, used by both the row menu and the bulk-action bar — only -// `onClick` differs. `canApply` disables the row action for active products -// and scopes the bulk action to the selected rows it can delete. -const deleteProduct: DataTableAction = { +// Share presentation only; each operation owns its eligibility checks. +const deleteProduct: DataTableAction = { id: "delete", label: "Delete", variant: "destructive", - canApply: (row) => row.status !== "Active", }; const rowActions: RowAction[] = [ @@ -88,30 +86,42 @@ const rowActions: RowAction[] = [ label: "Edit", onClick: (row) => alert(`Edit: ${row.name}`), }, - { ...deleteProduct, onClick: (row) => alert(`Delete: ${row.name}`) }, + { + ...deleteProduct, + canApply: (row) => row.status !== "Active", + onClick: (row) => alert(`Delete: ${row.name}`), + }, ]; const names = (rows: Product[]) => rows.map((row) => row.name).join(", "); -// Bulk actions in the footer while rows are selected. `canApply` scopes each -// action to the selected rows it can act on and shows that count. -const selectionActions: SelectionAction[] = [ +// 🧪 Dummy Data: resolve IDs against the complete mock backend, not the current page. +const selectedProducts = (ids: string[]) => allProducts.filter((row) => ids.includes(row.id)); + +// Alert-only previews leave selection unchanged; a real mutation should report +// completed IDs and remove only those IDs from the current selection. +const selectionActions: SelectionAction[] = [ { id: "publish", label: "Publish", - canApply: (row) => row.status === "Draft", - onClick: (rows) => alert(`Publish: ${names(rows)}`), + onClick: (ids) => + alert(`Publish: ${names(selectedProducts(ids).filter((row) => row.status === "Draft"))}`), }, { id: "archive", label: "Archive", - canApply: (row) => row.status !== "Archived", - onClick: (rows) => alert(`Archive: ${names(rows)}`), + onClick: (ids) => + alert(`Archive: ${names(selectedProducts(ids).filter((row) => row.status !== "Archived"))}`), + }, + { + ...deleteProduct, + onClick: (ids) => + alert(`Delete: ${names(selectedProducts(ids).filter((row) => row.status !== "Active"))}`), }, - { ...deleteProduct, onClick: (rows) => alert(`Delete: ${names(rows)}`) }, ]; const ProductsPage = () => { + const [selectedIds, setSelectedIds] = useState([]); // Single composed hook: filter/sort/pagination state is persisted to the URL // (bookmarkable, back-button friendly) and the URL seeds the initial state // synchronously, so the first fetch already reflects the URL. Check the @@ -136,6 +146,7 @@ const ProductsPage = () => { control, rowActions, onClickRow: (row) => alert(`Clicked: ${row.name}`), + rowSelection: { selectedIds, onChange: setSelectedIds }, selectionActions, }); diff --git a/examples/vite-app/src/pages/showcase/data-table-selection/page.tsx b/examples/vite-app/src/pages/showcase/data-table-selection/page.tsx index 45bcffbb4..fd479a568 100644 --- a/examples/vite-app/src/pages/showcase/data-table-selection/page.tsx +++ b/examples/vite-app/src/pages/showcase/data-table-selection/page.tsx @@ -76,8 +76,7 @@ function makeVendors(count: number): Vendor[] { category: pick(CATEGORIES), owner: pick(OWNERS), region: pick(REGIONS), - // Weighted so a random multi-select usually spans two or three statuses — - // which is what makes the per-action counts in the footer interesting. + // Mixed statuses demonstrate consumer-owned eligibility checks. status: rand() > 0.45 ? "active" : rand() > 0.4 ? "inactive" : pick(STATUSES), spend: Math.round((rand() * 480_000 + 4_000) * 100) / 100, lastOrder: new Date(base + Math.floor(rand() * 240) * 86_400_000).toISOString().slice(0, 10), @@ -207,12 +206,14 @@ const columns = [ // ─── Page ──────────────────────────────────────────────────────────────────── -type PendingDelete = { rows: Vendor[]; run: SelectionActionHelpers["run"] }; +type PendingDelete = { ids: string[]; run: SelectionActionHelpers["run"] }; const DataTableSelectionPage = () => { const toast = useToast(); // 🧪 Dummy Data: local state stands in for mutations + a refetch. const [vendors, setVendors] = useState(INITIAL_VENDORS); + const [selectedIds, setSelectedIds] = useState([]); + const [deleting, setDeleting] = useState(false); // 🧪 Showcase control: compare the pinned footer with a page-scrolling table. const [fill, setFill] = useState(true); const [pendingDelete, setPendingDelete] = useState(null); @@ -220,48 +221,54 @@ const DataTableSelectionPage = () => { const { variables, control } = useCollectionVariables({ params: { pageSize: 25 } }); const data = useMemo(() => selectPage(vendors, variables), [vendors, variables]); - // 🧪 Dummy Data: a fake request, slow enough to see the bar's pending state. - const setStatus = async (rows: Vendor[], status: VendorStatus) => { + const removeCompletedIds = (ids: string[]) => { + const completed = new Set(ids); + setSelectedIds((current) => current.filter((id) => !completed.has(id))); + }; + + // 🧪 Dummy backend: validate against all vendors, not the table's page/cache. + const setStatus = async (ids: string[], status: "active" | "inactive") => { await new Promise((resolve) => setTimeout(resolve, 900)); - const ids = new Set(rows.map((row) => row.id)); - setVendors((prev) => prev.map((v) => (ids.has(v.id) ? { ...v, status } : v))); + const targets = new Set(ids); + const previousStatus = status === "active" ? "inactive" : "active"; + const completed = new Set( + vendors.filter((v) => targets.has(v.id) && v.status === previousStatus).map((v) => v.id), + ); + setVendors((prev) => prev.map((v) => (completed.has(v.id) ? { ...v, status } : v))); + return [...completed]; }; - // 🔽 Bulk actions. `canApply` gives each action its "(n)" count and passes - // only the eligible rows to `onClick`; the footer disables an action at 0. - // Returning the promise makes the bar wait: actions are disabled with a - // spinner meanwhile, and the selection clears once it resolves. - const selectionActions: SelectionAction[] = [ + // The bar receives every selected ID. Consumers decide eligibility and remove + // only completed IDs; returned promises track pending state without clearing. + const selectionActions: SelectionAction[] = [ { id: "activate", label: "Activate", icon: , - canApply: (v) => v.status === "inactive", - onClick: async (rows) => { - await setStatus(rows, "active"); - toast.success(`Activated ${rows.length} vendor(s)`); + onClick: async (ids) => { + const completed = await setStatus(ids, "active"); + removeCompletedIds(completed); + toast.success(`Activated ${completed.length} of ${ids.length} selected vendor(s)`); }, }, { id: "deactivate", label: "Deactivate", icon: , - canApply: (v) => v.status === "active", - onClick: async (rows) => { - await setStatus(rows, "inactive"); - toast.success(`Deactivated ${rows.length} vendor(s)`); + onClick: async (ids) => { + const completed = await setStatus(ids, "inactive"); + removeCompletedIds(completed); + toast.success(`Deactivated ${completed.length} of ${ids.length} selected vendor(s)`); }, }, { id: "export", label: "Export", icon: , - // No `canApply`: applies to every selected row, so no count is shown. - // `keepSelection`: exporting doesn't change the rows. - keepSelection: true, - onClick: async (rows) => { + onClick: async (ids) => { await new Promise((resolve) => setTimeout(resolve, 600)); - toast.success(`Exported ${rows.length} vendor(s)`); + const exported = vendors.filter((vendor) => ids.includes(vendor.id)); + toast.success(`Exported ${exported.length} vendor(s)`); }, }, { @@ -270,11 +277,9 @@ const DataTableSelectionPage = () => { label: "Delete", icon: , variant: "destructive", - canApply: (v) => v.status === "archived", - // Destructive: confirm first (interaction/confirm pattern). Opening the - // dialog returns nothing, so keep `run` and hand the request back on - // confirm — the bar then shows its pending state and clears on success. - onClick: (rows, { run }) => setPendingDelete({ rows, run }), + // Capture the click-time IDs for the consumer-owned confirmation dialog. + // `run` tracks its later request, but does not change the selection. + onClick: (ids, { run }) => setPendingDelete({ ids: [...ids], run }), }, ]; @@ -283,22 +288,38 @@ const DataTableSelectionPage = () => { data, loading: false, control, - // `selectionActions` alone turns on the checkbox column. + rowSelection: { selectedIds, onChange: setSelectedIds }, selectionActions, }); - // 🧪 Dummy Data: a fake slow delete request. - const deleteVendors = async (rows: Vendor[]) => { + const deletableVendors = pendingDelete + ? vendors.filter((v) => pendingDelete.ids.includes(v.id) && v.status === "archived") + : []; + + // 🧪 Dummy backend: only archived vendors may actually be deleted. + const deleteVendors = async (ids: string[]) => { await new Promise((resolve) => setTimeout(resolve, 900)); - const ids = new Set(rows.map((row) => row.id)); - setVendors((prev) => prev.filter((v) => !ids.has(v.id))); - toast.error(`Deleted ${rows.length} vendor(s)`); + const targets = new Set(ids); + const completed = new Set( + vendors.filter((v) => targets.has(v.id) && v.status === "archived").map((v) => v.id), + ); + setVendors((prev) => prev.filter((v) => !completed.has(v.id))); + return [...completed]; }; - const confirmDelete = () => { - if (!pendingDelete) return; - pendingDelete.run(deleteVendors(pendingDelete.rows)); - setPendingDelete(null); + const confirmDelete = async () => { + if (!pendingDelete || deleting) return; + setDeleting(true); + try { + const completed = await pendingDelete.run(deleteVendors(pendingDelete.ids)); + removeCompletedIds(completed); + toast.success(`Deleted ${completed.length} vendor(s)`); + setPendingDelete(null); + } catch { + toast.error("Could not delete vendors. Please try again."); + } finally { + setDeleting(false); + } }; return ( @@ -307,11 +328,13 @@ const DataTableSelectionPage = () => {

- 240 vendor records. Select a few rows: the footer becomes the bulk-action bar, and each - action counts the selected rows it applies to — Activate only inactive vendors,{" "} - Delete only archived ones — and disables at zero. Selection survives paging, - and the header checkbox only adds or removes the current page. In Page scroll, - the bar rides the bottom of the window until the table's end scrolls into view. + 240 vendor records. Select a few rows: the footer becomes the bulk-action bar. Each + action receives every selected ID; this page checks eligibility against the complete + vendor list — Activate only inactive vendors, Delete only archived + ones — and removes only successfully changed IDs from selection. Selection survives + paging, and the header checkbox only adds or removes the current page. In{" "} + Page scroll, the bar rides the bottom of the window until the table's end + scrolls into view.

{/* 🧪 Showcase control */}
@@ -344,20 +367,27 @@ const DataTableSelectionPage = () => { { - if (!open) setPendingDelete(null); + if (!open && !deleting) setPendingDelete(null); }} > - Delete {pendingDelete?.rows.length} archived vendor(s)? + Delete {deletableVendors.length} archived vendor(s)? - They will be removed from the vendor list. This action cannot be undone. + {deletableVendors.length} of {pendingDelete?.ids.length} selected vendors can be + deleted. The others will stay selected. This action cannot be undone. - }>Cancel - diff --git a/packages/core/src/components/data-table/data-table-context.tsx b/packages/core/src/components/data-table/data-table-context.tsx index 140dd667a..624d500b6 100644 --- a/packages/core/src/components/data-table/data-table-context.tsx +++ b/packages/core/src/components/data-table/data-table-context.tsx @@ -61,11 +61,11 @@ export interface DataTableContextValue> { onClickRow?: (row: TRow) => void; rowActions?: RowAction[]; /** Bulk actions `DataTable.Footer` shows while rows are selected. */ - selectionActions?: SelectionAction[]; + selectionActions?: SelectionAction[]; // Row selection // toggleRowSelection / selectAllRows / deselectAllRows / clearSelection are undefined - // when selection is not enabled (neither onSelectionChange nor selectionActions given). + // when selection is not enabled (no rowSelection, onSelectionChange, or selectionActions). // New members are optional for the hand-constructible reason noted under Row expansion. selectedIds: string[]; /** @@ -90,10 +90,7 @@ export interface DataTableContextValue> { /** Id of the selection action whose request is in flight, or `null`. */ pendingActionId?: string | null; /** See `UseDataTableReturn.runSelectionAction`. */ - runSelectionAction?: ( - action: { id: string; keepSelection?: boolean }, - request: PromiseLike, - ) => void; + runSelectionAction?: (action: { id: string }, request: PromiseLike) => void; isAllSelected: boolean; isIndeterminate: boolean; diff --git a/packages/core/src/components/data-table/data-table.test.tsx b/packages/core/src/components/data-table/data-table.test.tsx index 99f62b7ef..2d8025a91 100644 --- a/packages/core/src/components/data-table/data-table.test.tsx +++ b/packages/core/src/components/data-table/data-table.test.tsx @@ -2086,22 +2086,20 @@ describe("DataTable", () => { expect((await activateItemFor(0)).getAttribute("aria-disabled")).toBe("true"); }); - it("shares one action definition with selectionActions", () => { - // Type-level check that the shared base spreads into both arrays. - const activate = { - id: "activate", - label: "Activate", - canApply: (r: TestRow) => r.status !== "Active", - }; + it("shares presentation with selectionActions while keeping the row predicate separate", () => { + const activate = { id: "activate", label: "Activate" }; const options: UseDataTableOptions = { columns: testColumns, data: testData, - rowActions: [{ ...activate, onClick: (row) => void row }], - selectionActions: [{ ...activate, onClick: (rows) => void rows }], + rowActions: [ + { ...activate, canApply: (row) => row.status !== "Active", onClick: (row) => void row }, + ], + selectionActions: [{ ...activate, onClick: (ids) => void ids }], }; expectTypeOf(options.rowActions![0].canApply).toEqualTypeOf< ((row: TestRow) => boolean) | undefined >(); + expectTypeOf(options.selectionActions![0].onClick).parameter(0).toEqualTypeOf(); expect(options.selectionActions).toHaveLength(1); }); }); diff --git a/packages/core/src/components/data-table/data-table.tsx b/packages/core/src/components/data-table/data-table.tsx index fc5a0b6d2..1b5779069 100644 --- a/packages/core/src/components/data-table/data-table.tsx +++ b/packages/core/src/components/data-table/data-table.tsx @@ -1732,7 +1732,7 @@ function RowActionsMenu>({ /> {actions.map((action) => { - // `canApply` is the shared, positive form; the deprecated + // `canApply` is the positive form; the deprecated // `isDisabled` still counts, so either one can switch it off. const disabled = (action.canApply ? !action.canApply(row) : false) || diff --git a/packages/core/src/components/data-table/row-selection.test.tsx b/packages/core/src/components/data-table/row-selection.test.tsx index 1eb1c8c88..eb2437801 100644 --- a/packages/core/src/components/data-table/row-selection.test.tsx +++ b/packages/core/src/components/data-table/row-selection.test.tsx @@ -1,7 +1,15 @@ -import { act, cleanup, fireEvent, render, renderHook, screen } from "@testing-library/react"; +import { + act, + cleanup, + fireEvent, + render, + renderHook, + screen, + within, +} from "@testing-library/react"; import { useState } from "react"; import { afterEach, describe, expect, expectTypeOf, it, vi } from "vitest"; -import type { RowSelectionOptions } from "@/index"; +import type { RowSelectionOptions, SelectionAction } from "@/index"; import { createAppShellWrapper } from "../../../tests/test-utils"; import { DataTable } from "./data-table"; import { useDataTable } from "./use-data-table"; @@ -44,7 +52,7 @@ function ControlledTable({ selectedIds: string[]; data: DataTableData; onChange: (ids: string[]) => void; - onBulk: (rows: Row[]) => void; + onBulk: SelectionAction["onClick"]; }) { const table = useDataTable({ columns, @@ -236,7 +244,7 @@ describe("rowSelection", () => { }); it.each([{ selectedIds: [] }, { selectedIds: ["2"] }])( - "consults the latest selection and callback when an action settles: %j", + "does not change controlled selection or notify either owner when an action settles: %j", async ({ selectedIds }) => { const first = vi.fn(); const latest = vi.fn(); @@ -256,8 +264,8 @@ describe("rowSelection", () => { await request; }); expect(first).not.toHaveBeenCalled(); - if (selectedIds.length) expect(latest).toHaveBeenCalledExactlyOnceWith([]); - else expect(latest).not.toHaveBeenCalled(); + expect(latest).not.toHaveBeenCalled(); + expect(result.current.selectedIds).toEqual(selectedIds); expect(result.current.pendingActionId).toBeNull(); }, ); @@ -282,16 +290,59 @@ describe("rowSelection", () => { ); fireEvent.click(screen.getByRole("button", { name: "Archive" })); expect(onBulk).toHaveBeenCalledWith( - [pageA.rows[0]], + ["1"], expect.objectContaining({ clearSelection: expect.any(Function) }), ); rerender( , ); const archive = screen.getByRole("button", { name: "Archive" }); - expect(archive.hasAttribute("disabled")).toBe(true); + expect(archive.hasAttribute("disabled")).toBe(false); fireEvent.click(archive); - expect(onBulk).toHaveBeenCalledTimes(1); + expect(onBulk).toHaveBeenCalledTimes(2); + expect(onBulk).toHaveBeenLastCalledWith(["unseen"], expect.any(Object)); + }); + + it("lets a consumer remove only completed IDs while preserving ineligible and newly selected IDs", async () => { + let complete!: (ids: string[]) => void; + const request = new Promise((resolve) => { + complete = resolve; + }); + const targets = vi.fn(); + function Consumer() { + const [selectedIds, setSelectedIds] = useState(["1", "unseen"]); + return ( + { + targets(ids); + const completed = new Set(await request); + setSelectedIds((current) => current.filter((id) => !completed.has(id))); + }} + /> + ); + } + render(, { wrapper }); + fireEvent.click(screen.getByRole("button", { name: "Archive" })); + fireEvent.click(screen.getAllByLabelText("Select row")[1]); + expect(targets).toHaveBeenCalledExactlyOnceWith(["1", "unseen"]); + await act(async () => { + complete(["1"]); + await request; + }); + expect(screen.getAllByLabelText("Select row")[0].getAttribute("aria-checked")).toBe("false"); + expect(screen.getAllByLabelText("Select row")[1].getAttribute("aria-checked")).toBe("true"); + expect( + within(screen.getByRole("toolbar", { name: "Bulk actions" })).getByText("2 selected"), + ).toBeDefined(); + }); + + it("keeps row predicates and automatic selection cleanup out of the selection action API", () => { + expectTypeOf().parameter(0).toEqualTypeOf(); + expectTypeOf<"canApply">().not.toMatchTypeOf(); + expectTypeOf<"keepSelection">().not.toMatchTypeOf(); }); it("requires an owner callback for controlled ids and rejects mixing controlled/default ids", () => { diff --git a/packages/core/src/components/data-table/selection-bar.test.tsx b/packages/core/src/components/data-table/selection-bar.test.tsx index 594f1a1fa..354b70cab 100644 --- a/packages/core/src/components/data-table/selection-bar.test.tsx +++ b/packages/core/src/components/data-table/selection-bar.test.tsx @@ -1,24 +1,20 @@ import { afterEach, describe, it, expect, vi } from "vitest"; -import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { createAppShellWrapper } from "../../../tests/test-utils"; import type { CollectionControl } from "@/types/collection"; import { DataTable } from "./data-table"; import { useDataTable } from "./use-data-table"; -import type { Column, DataTableData, SelectionAction } from "./types"; +import type { Column, DataTableData, RowSelectionOptions, SelectionAction } from "./types"; -afterEach(() => { - cleanup(); -}); - -type Vendor = { id: string; name: string; status: "active" | "inactive" | "archived" }; +afterEach(() => cleanup()); +type Vendor = { id: string; name: string }; const vendors: Vendor[] = [ - { id: "1", name: "Acme", status: "active" }, - { id: "2", name: "Globex", status: "inactive" }, - { id: "3", name: "Initech", status: "archived" }, + { id: "1", name: "Acme" }, + { id: "2", name: "Globex" }, + { id: "3", name: "Initech" }, ]; - const columns: Column[] = [{ label: "Name", render: (row) => row.name }]; function makeControl(): CollectionControl { @@ -44,7 +40,7 @@ function makeControl(): CollectionControl { }; } -const archive = (onClick = vi.fn()): SelectionAction => ({ +const archive = (onClick: SelectionAction["onClick"] = vi.fn()): SelectionAction => ({ id: "archive", label: "Archive", onClick, @@ -53,19 +49,22 @@ const archive = (onClick = vi.fn()): SelectionAction => ({ function Harness({ selectionActions, onSelectionChange, + rowSelection, data = { rows: vendors, total: 3 }, footer = "pagination", }: { - selectionActions?: SelectionAction[]; + selectionActions?: SelectionAction[]; onSelectionChange?: (ids: string[]) => void; + rowSelection?: RowSelectionOptions; data?: DataTableData; footer?: "pagination" | "empty"; }) { - const table = useDataTable({ + const table = useDataTable({ columns, data, control: makeControl(), onSelectionChange, + rowSelection, selectionActions, }); return ( @@ -83,10 +82,10 @@ function Harness({ } const wrapper = createAppShellWrapper("en"); - const rowCheckbox = (index: number) => screen.getAllByLabelText("Select row")[index]; const queryBar = () => screen.queryByRole("toolbar", { name: "Bulk actions" }); const getBar = () => screen.getByRole("toolbar", { name: "Bulk actions" }); +const actionButton = () => within(getBar()).getByRole("button", { name: "Archive" }); const footerOf = (container: HTMLElement) => container.querySelector('[data-slot="data-table-footer"]')!; @@ -102,149 +101,118 @@ function deferred() { } describe("DataTable selection actions", () => { - it("shows no bar until a row is selected", () => { + it("turns the footer into the bar, owning the count, only while selected", () => { const { container } = render(, { wrapper }); - expect(queryBar()).toBeNull(); expect(footerOf(container).hasAttribute("data-selecting")).toBe(false); expect(screen.getByText("3 row(s)")).toBeDefined(); - }); - - it("turns the footer into the bar, owning the count, once a row is selected", () => { - const { container } = render(, { wrapper }); - fireEvent.click(rowCheckbox(0)); - - const bar = getBar(); - expect(within(bar).getByText("1 of 3 selected")).toBeDefined(); - expect(within(bar).getByRole("button", { name: "Archive" })).toBeDefined(); - expect(within(bar).getByRole("button", { name: "Clear selection" })).toBeDefined(); - const footer = footerOf(container); - expect(footer.hasAttribute("data-selecting")).toBe(true); - // Sticky, so a page-scrolling table keeps the bar on screen. - expect(footer.className).toContain("astw:sticky"); - // Pagination hides its own count while the bar shows it. + expect(within(getBar()).getByText("1 of 3 selected")).toBeDefined(); + expect(actionButton()).toHaveProperty("disabled", false); + expect(within(getBar()).getByRole("button", { name: "Clear selection" })).toBeDefined(); + expect(footerOf(container).hasAttribute("data-selecting")).toBe(true); + expect(footerOf(container).className).toContain("astw:sticky"); expect(screen.queryByText("3 row(s)")).toBeNull(); expect(screen.getByLabelText("Next page")).toBeDefined(); + fireEvent.click(rowCheckbox(0)); + expect(queryBar()).toBeNull(); }); it("falls back to the bare count when the backend returns no total", () => { render(, { wrapper }); - fireEvent.click(rowCheckbox(0)); - expect(within(getBar()).getByText("1 selected")).toBeDefined(); }); - it("counts the rows each narrowed action applies to and disables it at zero", () => { - const actions: SelectionAction[] = [ - { - id: "activate", - label: "Activate", - canApply: (v) => v.status === "inactive", - onClick: vi.fn(), - }, - { - id: "deactivate", - label: "Deactivate", - canApply: (v) => v.status === "active", - onClick: vi.fn(), - }, - archive(), - ]; - render(, { wrapper }); - - fireEvent.click(rowCheckbox(0)); // Acme — active - fireEvent.click(rowCheckbox(2)); // Initech — archived + it.each(["controlled", "uncontrolled"] as const)( + "passes all preselected IDs regardless of loaded rows in %s mode", + (mode) => { + const onClick = vi.fn(); + const ids = ["unseen", "2", "also-unseen"]; + const rowSelection = + mode === "controlled" + ? { selectedIds: ids, onChange: vi.fn() } + : { defaultSelectedIds: ids }; + const { rerender } = render( + , + { wrapper }, + ); + expect(actionButton()).toHaveProperty("disabled", false); + fireEvent.click(actionButton()); + expect(onClick).toHaveBeenLastCalledWith(ids, expect.any(Object)); + rerender( + , + ); + expect(actionButton()).toHaveProperty("disabled", false); + fireEvent.click(actionButton()); + expect(onClick).toHaveBeenLastCalledWith(ids, expect.any(Object)); + expect(within(getBar()).getByText("3 selected")).toBeDefined(); + }, + ); - const bar = getBar(); - expect(within(bar).getByRole("button", { name: "Activate (0)" })).toHaveProperty( - "disabled", - true, - ); - expect(within(bar).getByRole("button", { name: "Deactivate (1)" })).toHaveProperty( - "disabled", - false, + it("includes IDs selected on other pages", () => { + const onClick = vi.fn(); + const { rerender } = render(, { wrapper }); + fireEvent.click(rowCheckbox(0)); + rerender( + , ); - // No `canApply`: applies to every selected row, so no count is shown. - expect(within(bar).getByRole("button", { name: "Archive" })).toHaveProperty("disabled", false); + fireEvent.click(rowCheckbox(0)); + expect(within(getBar()).getByText("2 of 4 selected")).toBeDefined(); + fireEvent.click(actionButton()); + expect(onClick).toHaveBeenCalledExactlyOnceWith(["1", "4"], expect.any(Object)); }); - it("hands onClick only the eligible rows, plus a clearSelection helper", () => { - const onSelectionChange = vi.fn(); - const onClick = vi.fn((_rows: Vendor[], { clearSelection }: { clearSelection: () => void }) => - clearSelection(), - ); - const actions: SelectionAction[] = [ - { id: "deactivate", label: "Deactivate", canApply: (v) => v.status === "active", onClick }, - ]; - render(, { - wrapper, + it("gives each click a snapshot isolated from later selection and handler mutation", () => { + const onClick = vi.fn((ids: string[]) => { + ids.pop(); }); - - fireEvent.click(rowCheckbox(0)); // Acme — active - fireEvent.click(rowCheckbox(1)); // Globex — inactive - fireEvent.click(within(getBar()).getByRole("button", { name: "Deactivate (1)" })); - - expect(onClick).toHaveBeenCalledTimes(1); - expect(onClick.mock.calls[0][0]).toEqual([vendors[0]]); - expect(onSelectionChange).toHaveBeenLastCalledWith([]); - expect(queryBar()).toBeNull(); - }); - - it("includes rows selected on other pages", () => { - const onClick = vi.fn(); - const { rerender } = render(, { wrapper }); - - fireEvent.click(rowCheckbox(0)); // Acme, on the first page - const nextPage = { rows: [{ id: "4", name: "Hooli", status: "active" as const }], total: 4 }; - rerender(); - fireEvent.click(rowCheckbox(0)); // Hooli, on the second - - expect(within(getBar()).getByText("2 of 4 selected")).toBeDefined(); - fireEvent.click(within(getBar()).getByRole("button", { name: "Archive" })); - expect(onClick.mock.calls[0][0]).toEqual([vendors[0], nextPage.rows[0]]); + render(, { wrapper }); + fireEvent.click(rowCheckbox(0)); + fireEvent.click(rowCheckbox(1)); + fireEvent.click(actionButton()); + expect(within(getBar()).getByText("2 of 3 selected")).toBeDefined(); + fireEvent.click(rowCheckbox(2)); + expect(onClick.mock.calls[0][0]).toEqual(["1"]); + fireEvent.click(actionButton()); + expect(onClick.mock.calls[1][0]).toEqual(["1", "2"]); + expect(onClick.mock.calls[0][0]).not.toBe(onClick.mock.calls[1][0]); }); - it("moves actions past the third into a More actions menu", async () => { + it("moves actions past the third into a More actions menu with the complete ID payload", async () => { const user = userEvent.setup(); const onDelete = vi.fn(); - const actions: SelectionAction[] = [ + const actions: SelectionAction[] = [ { id: "a", label: "Activate", onClick: vi.fn() }, { id: "b", label: "Deactivate", onClick: vi.fn() }, { id: "c", label: "Export", onClick: vi.fn() }, - { - id: "delete", - label: "Delete", - variant: "destructive", - canApply: (v) => v.status === "archived", - onClick: onDelete, - }, + { id: "delete", label: "Delete", variant: "destructive", onClick: onDelete }, ]; - render(, { wrapper }); - - await user.click(rowCheckbox(2)); // Initech — archived - - const bar = getBar(); - expect(within(bar).queryByRole("button", { name: /Delete/ })).toBeNull(); - await user.click(within(bar).getByRole("button", { name: "More actions" })); - const item = await screen.findByRole("menuitem", { name: "Delete (1)" }); - await user.click(item); - - expect(onDelete).toHaveBeenCalledTimes(1); - expect(onDelete.mock.calls[0][0]).toEqual([vendors[2]]); + render( + , + { wrapper }, + ); + expect(within(getBar()).queryByRole("button", { name: "Delete" })).toBeNull(); + await user.click(within(getBar()).getByRole("button", { name: "More actions" })); + await user.click(await screen.findByRole("menuitem", { name: "Delete" })); + expect(onDelete).toHaveBeenCalledExactlyOnceWith(["unseen", "3"], expect.any(Object)); }); - it("Clear empties the selection and hands focus back to the header checkbox", async () => { + it("Clear empties selection and hands focus back to the header checkbox", async () => { const user = userEvent.setup(); const onSelectionChange = vi.fn(); render(, { wrapper, }); - await user.click(rowCheckbox(0)); await user.click(within(getBar()).getByRole("button", { name: "Clear selection" })); - expect(onSelectionChange).toHaveBeenLastCalledWith([]); expect(queryBar()).toBeNull(); expect(document.activeElement).toBe(screen.getByLabelText("Select all rows")); @@ -252,26 +220,21 @@ describe("DataTable selection actions", () => { it("supports arrow-key navigation between the bar's controls", async () => { const user = userEvent.setup(); - const actions: SelectionAction[] = [ - { id: "a", label: "Activate", onClick: vi.fn() }, - { id: "b", label: "Deactivate", onClick: vi.fn() }, - ]; - render(, { wrapper }); - + render( + , + { wrapper }, + ); await user.click(rowCheckbox(0)); - within(getBar()).getByRole("button", { name: "Activate" }).focus(); + actionButton().focus(); await user.keyboard("{ArrowRight}"); - - expect(document.activeElement).toBe( - within(getBar()).getByRole("button", { name: "Deactivate" }), - ); + expect(document.activeElement).toBe(within(getBar()).getByRole("button", { name: "Export" })); }); it("announces the count politely from the first selection on", () => { const { container } = render(, { wrapper }); const liveRegion = () => container.querySelector('[aria-live="polite"]'); - - // Mounted before anything is selected, so the first tick is announced. expect(liveRegion()?.textContent).toBe(""); fireEvent.click(rowCheckbox(0)); expect(liveRegion()?.textContent).toBe("1 of 3 selected"); @@ -279,240 +242,165 @@ describe("DataTable selection actions", () => { it("renders the bar in a footer without children", () => { render(, { wrapper }); - fireEvent.click(rowCheckbox(0)); - - expect(within(getBar()).getByRole("button", { name: "Archive" })).toBeDefined(); + expect(actionButton()).toBeDefined(); }); - it("leaves the footer as it was when no selectionActions are given", () => { + it("leaves the footer unchanged when no selectionActions are given", () => { const { container } = render(, { wrapper }); - fireEvent.click(rowCheckbox(0)); - expect(queryBar()).toBeNull(); expect(footerOf(container).hasAttribute("data-selecting")).toBe(false); expect(screen.getByText("1 of 3 row(s) selected")).toBeDefined(); expect(container.querySelector('[aria-live="polite"]')).toBeNull(); }); - it("closes the bar when the last selected row is deselected", async () => { - render(, { wrapper }); - - fireEvent.click(rowCheckbox(0)); - expect(queryBar()).not.toBeNull(); - fireEvent.click(rowCheckbox(0)); - - await waitFor(() => expect(queryBar()).toBeNull()); - }); - - // --------------------------------------------------------------------------- - // Actions that return a promise - // --------------------------------------------------------------------------- describe("async actions", () => { - it("disables every action and shows a spinner while the promise is pending", () => { + it("disables all actions, overflow and Clear with a spinner while pending, without clearing on success", async () => { const pending = deferred(); - const actions: SelectionAction[] = [ - { id: "activate", label: "Activate", onClick: () => pending.promise }, - { id: "export", label: "Export", onClick: vi.fn() }, - ]; - render(, { wrapper }); - - fireEvent.click(rowCheckbox(0)); - const activate = within(getBar()).getByRole("button", { name: "Activate" }); - fireEvent.click(activate); - - expect(getBar().getAttribute("aria-busy")).toBe("true"); - expect(activate).toHaveProperty("disabled", true); - expect(activate.querySelector('[data-slot="spinner"]')).not.toBeNull(); - expect(within(getBar()).getByRole("button", { name: "Export" })).toHaveProperty( - "disabled", - true, - ); - }); - - it("clears the selection once the promise resolves", async () => { + const onClick = vi.fn(() => pending.promise); const onSelectionChange = vi.fn(); - const pending = deferred(); - const actions: SelectionAction[] = [ - { id: "activate", label: "Activate", onClick: () => pending.promise }, - ]; - render(, { - wrapper, - }); - + render( + ({ + id: label, + label, + onClick: vi.fn(), + })), + ]} + onSelectionChange={onSelectionChange} + />, + { wrapper }, + ); fireEvent.click(rowCheckbox(0)); - fireEvent.click(within(getBar()).getByRole("button", { name: "Activate" })); - expect(queryBar()).not.toBeNull(); - + fireEvent.click(actionButton()); + expect(getBar().getAttribute("aria-busy")).toBe("true"); + expect(actionButton().querySelector('[data-slot="spinner"]')).not.toBeNull(); + for (const button of within(getBar()).getAllByRole("button")) + expect(button).toHaveProperty("disabled", true); + fireEvent.click(actionButton()); + expect(onClick).toHaveBeenCalledTimes(1); + fireEvent.click(rowCheckbox(1)); + expect(onClick.mock.calls[0]).toEqual([["1"], expect.any(Object)]); + onSelectionChange.mockClear(); await act(async () => { pending.resolve(); await pending.promise; }); - - expect(queryBar()).toBeNull(); - expect(onSelectionChange).toHaveBeenLastCalledWith([]); - }); - - it("keeps the selection after the promise when keepSelection is set", async () => { - const exportRows = vi.fn(() => Promise.resolve()); - const actions: SelectionAction[] = [ - { id: "export", label: "Export", keepSelection: true, onClick: exportRows }, - ]; - render(, { wrapper }); - - fireEvent.click(rowCheckbox(0)); - await act(async () => { - fireEvent.click(within(getBar()).getByRole("button", { name: "Export" })); - }); - - expect(exportRows).toHaveBeenCalledTimes(1); - expect(within(getBar()).getByRole("button", { name: "Export" })).toHaveProperty( - "disabled", - false, - ); + expect(within(getBar()).getByText("2 of 3 selected")).toBeDefined(); + expect(actionButton()).toHaveProperty("disabled", false); expect(getBar().hasAttribute("aria-busy")).toBe(false); + expect(onSelectionChange).not.toHaveBeenCalled(); }); - it("keeps the selection and re-enables the actions when the promise rejects", async () => { + it("keeps selection and re-enables actions on rejection", async () => { const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); const pending = deferred(); - const actions: SelectionAction[] = [ - { id: "activate", label: "Activate", onClick: () => pending.promise }, - ]; - render(, { wrapper }); - + const onSelectionChange = vi.fn(); + render( + pending.promise)]} + onSelectionChange={onSelectionChange} + />, + { wrapper }, + ); fireEvent.click(rowCheckbox(0)); - fireEvent.click(within(getBar()).getByRole("button", { name: "Activate" })); + fireEvent.click(actionButton()); + onSelectionChange.mockClear(); await act(async () => { pending.reject(new Error("network down")); await pending.promise.catch(() => {}); }); - - expect(within(getBar()).getByRole("button", { name: "Activate" })).toHaveProperty( - "disabled", - false, - ); + expect(actionButton()).toHaveProperty("disabled", false); + expect(within(getBar()).getByText("1 of 3 selected")).toBeDefined(); + expect(onSelectionChange).not.toHaveBeenCalled(); expect(consoleError).toHaveBeenCalledWith( - '[DataTable] Selection action "activate" failed:', + '[DataTable] Selection action "archive" failed:', expect.any(Error), ); consoleError.mockRestore(); }); - it("leaves the selection alone when onClick returns nothing", () => { - // e.g. an onClick that only opens a confirm dialog - const actions: SelectionAction[] = [ - { id: "delete", label: "Delete", onClick: vi.fn() }, - ]; - render(, { wrapper }); - + it("leaves selection alone when onClick returns nothing", () => { + render(, { wrapper }); fireEvent.click(rowCheckbox(0)); - fireEvent.click(within(getBar()).getByRole("button", { name: "Delete" })); - + fireEvent.click(actionButton()); expect(queryBar()).not.toBeNull(); expect(getBar().hasAttribute("aria-busy")).toBe(false); }); - it("fires a single onSelectionChange([]) when the action also clears itself", async () => { + it("allows an action to explicitly clear selection exactly once", async () => { const onSelectionChange = vi.fn(); - const actions: SelectionAction[] = [ - { - id: "activate", - label: "Activate", - onClick: async (_rows, { clearSelection }) => clearSelection(), - }, - ]; - render(, { - wrapper, - }); - + render( + { + expect(ids).toEqual(["1"]); + clearSelection(); + }), + ]} + onSelectionChange={onSelectionChange} + />, + { wrapper }, + ); fireEvent.click(rowCheckbox(0)); await act(async () => { - fireEvent.click(within(getBar()).getByRole("button", { name: "Activate" })); + fireEvent.click(actionButton()); }); - + expect(queryBar()).toBeNull(); expect(onSelectionChange.mock.calls.filter(([ids]) => ids.length === 0)).toHaveLength(1); }); - it("disables Clear while an action is pending", () => { - const pending = deferred(); - const actions: SelectionAction[] = [ - { id: "activate", label: "Activate", onClick: () => pending.promise }, - ]; - render(, { wrapper }); - - fireEvent.click(rowCheckbox(0)); - fireEvent.click(within(getBar()).getByRole("button", { name: "Activate" })); - - expect(within(getBar()).getByRole("button", { name: "Clear selection" })).toHaveProperty( - "disabled", - true, - ); - }); - - it("keeps the pending state when the bar unmounts and remounts mid-request", async () => { + it("keeps pending state when the bar unmounts and remounts mid-request", async () => { const pending = deferred(); - const actions: SelectionAction[] = [ - { id: "activate", label: "Activate", onClick: () => pending.promise }, - ]; - render(, { wrapper }); - + render( pending.promise)]} />, { wrapper }); fireEvent.click(rowCheckbox(0)); - fireEvent.click(within(getBar()).getByRole("button", { name: "Activate" })); - // Untick the only selected row — the bar unmounts — then tick it again. + fireEvent.click(actionButton()); fireEvent.click(rowCheckbox(0)); expect(queryBar()).toBeNull(); fireEvent.click(rowCheckbox(0)); - expect(getBar().getAttribute("aria-busy")).toBe("true"); - expect(within(getBar()).getByRole("button", { name: "Activate" })).toHaveProperty( - "disabled", - true, - ); - + expect(actionButton()).toHaveProperty("disabled", true); await act(async () => { pending.resolve(); await pending.promise; }); - expect(queryBar()).toBeNull(); + expect(actionButton()).toHaveProperty("disabled", false); + expect(within(getBar()).getByText("1 of 3 selected")).toBeDefined(); }); - it("tracks a request handed back through run, e.g. from a confirm dialog", async () => { - const onSelectionChange = vi.fn(); + it("tracks a request handed back later through run without changing selection", async () => { const pending = deferred(); + const onSelectionChange = vi.fn(); let confirm: (() => void) | undefined; - const actions: SelectionAction[] = [ - { - id: "delete", - label: "Delete", - // Opens a "dialog": nothing is returned, the request starts later. - onClick: (_rows, { run }) => { - confirm = () => void run(pending.promise); - }, - }, - ]; - render(, { - wrapper, - }); - + render( + { + confirm = () => void run(pending.promise); + }), + ]} + onSelectionChange={onSelectionChange} + />, + { wrapper }, + ); fireEvent.click(rowCheckbox(0)); - fireEvent.click(within(getBar()).getByRole("button", { name: "Delete" })); + fireEvent.click(actionButton()); expect(getBar().hasAttribute("aria-busy")).toBe(false); - act(() => confirm?.()); expect(getBar().getAttribute("aria-busy")).toBe("true"); expect(within(getBar()).getByRole("button", { name: "Clear selection" })).toHaveProperty( "disabled", true, ); - await act(async () => { pending.resolve(); await pending.promise; }); - expect(queryBar()).toBeNull(); - expect(onSelectionChange).toHaveBeenLastCalledWith([]); + expect(actionButton()).toHaveProperty("disabled", false); + expect(onSelectionChange).toHaveBeenCalledExactlyOnceWith(["1"]); }); }); }); diff --git a/packages/core/src/components/data-table/selection-bar.tsx b/packages/core/src/components/data-table/selection-bar.tsx index 415e86202..39c4232b9 100644 --- a/packages/core/src/components/data-table/selection-bar.tsx +++ b/packages/core/src/components/data-table/selection-bar.tsx @@ -36,14 +36,6 @@ function useSelectionCountText(): string { return t("selectionCount", { selected: selectedIds.length, total }); } -type ResolvedAction> = { - action: SelectionAction; - /** The selected rows this action can act on — what `onClick` receives. */ - rows: TRow[]; - /** Shown as "(n)" only when the action narrows the selection. */ - count: number | null; -}; - function isPromiseLike(value: unknown): value is PromiseLike { return typeof (value as PromiseLike | null | undefined)?.then === "function"; } @@ -55,7 +47,7 @@ function isPromiseLike(value: unknown): value is PromiseLike { export function DataTableSelectionBar>() { const { selectionActions = [], - selectedRows = [], + selectedIds, clearSelection, pendingActionId = null, runSelectionAction, @@ -87,19 +79,13 @@ export function DataTableSelectionBar>() { const busy = pendingId !== null; const clear = () => clearSelection?.(); - const resolved: ResolvedAction[] = selectionActions.map((action) => { - const { canApply } = action; - const rows = canApply ? selectedRows.filter((row) => canApply(row)) : selectedRows; - return { action, rows, count: canApply ? rows.length : null }; - }); - const inline = resolved.slice(0, MAX_INLINE_ACTIONS); - const overflow = resolved.slice(MAX_INLINE_ACTIONS); + const inline = selectionActions.slice(0, MAX_INLINE_ACTIONS); + const overflow = selectionActions.slice(MAX_INLINE_ACTIONS); - const run = ({ action, rows }: ResolvedAction) => { - if (busy || rows.length === 0) return; + const run = (action: SelectionAction) => { + if (busy || selectedIds.length === 0) return; // `run` lets a handler that only opens a confirm dialog hand the request - // back later, from the dialog's confirm button, and still get the pending - // state and clear-on-success. + // back later, from the dialog's confirm button, and still get pending state. const helpers: SelectionActionHelpers = { clearSelection: clear, run: (request) => { @@ -107,8 +93,7 @@ export function DataTableSelectionBar>() { return request; }, }; - const result = action.onClick(rows, helpers); - // A synchronous handler that never calls `run` owns the selection. + const result = action.onClick([...selectedIds], helpers); if (isPromiseLike(result)) runSelectionAction?.(action, result); }; @@ -127,19 +112,17 @@ export function DataTableSelectionBar>() { {countText} - {inline.map((resolvedAction) => { - const { action, rows, count } = resolvedAction; + {inline.map((action) => { return ( ); })} @@ -149,7 +132,7 @@ export function DataTableSelectionBar>() { disabled={busy} render={