Docs
Edit and act

Bulk Actions

Confirmation flow and callback contract for copy, delete, edit, and export

Use the root TableConfig.presentation option for a shared view/create/edit/bulk surface, with an optional mobile choice. The default is a right drawer on every viewport. See shared record presentation for configuration and migration.

Field-aware bulk editing

The following complete example builds on the quick start and shared recipe files. The reference below explains individual options and integration fragments.

admin-config.ts
import { defineTableConfig } from "@/components/ui/yayaw-table/config/helpers";
import { productConfig } from "./product-config";

export const adminConfig = defineTableConfig({
  ...productConfig,
  table: {
    ...productConfig.table,
    layoutPreset: "admin",
    defaultPageSize: 2,
    pageSizeOptions: [2, 10, 20],
    enableRowSelection: true,
    enableMultiRowSelection: true,
    preserveSelectionOnQuery: true,
    allowEdit: true,
    allowBulkEdit: true,
    canEditRow: (row) => row.status !== "archived",
    canSelectRow: (row) => row.status !== "archived",
    bulkExport: true,
  },
  form: { editFormType: "products" },
});
admin-config.ts
import { defineTableConfig } from "@/components/ui/yayaw-table-vue/config";
import { productConfig } from "./product-config";

export const adminConfig = defineTableConfig({
  ...productConfig,
  table: {
    ...productConfig.table,
    layoutPreset: "admin",
    defaultPageSize: 2,
    pageSizeOptions: [2, 10, 20],
    enableRowSelection: true,
    enableMultiRowSelection: true,
    preserveSelectionOnQuery: true,
    allowEdit: true,
    allowBulkEdit: true,
    canEditRow: (row) => row.status !== "archived",
    canSelectRow: (row) => row.status !== "archived",
    bulkExport: true,
  },
  form: { editFormType: "products" },
});

Bulk Actions

Select a range with Shift-click

In React and Vue, enable row selection and multi-selection to use range selection in table mode:

// Inside defineTableConfig({ table: { ... } }).
enableRowSelection: true,
enableMultiRowSelection: true,

Click a row checkbox to set the starting point, then hold Shift and click another row checkbox. Both endpoints and the selectable rows between them follow the target checkbox: checking selects the range; unchecking clears it. Existing selections outside the range stay selected.

The range follows the visible row order on the current page. Group headers, collapsed rows, disabled rows, and rows restricted to single selection are excluded. Sorting, filtering, pagination, or collapsing a group can change that order; the next Shift-click then behaves like a normal click and sets a new starting point. Without a starting point or with enableMultiRowSelection: false, Shift-click remains a normal checkbox toggle.

Range selection applies to table checkboxes and gallery cards. The keyboard controls below select all matching records across pages.

Confirmation flow

copy and delete use a confirmation dialog.

Current behavior:

  1. User clicks Copy or Delete.

  2. Dialog opens and stores the pending action.

  3. Outside clicks are ignored while confirmation is open.

  4. Confirm executes the pending action exactly once.

  5. Menu closing follows the callback result (closeMenu).

This prevents a no-op scenario where outside-click events would reset state while the portal dialog was open.

Deterministic action behavior

  • edit: calls onBulkEdit immediately when provided; otherwise opens the catalogue bulk editor.

  • export: calls onBulkExport immediately when provided; otherwise, with table.export enabled (the default), opens the Export screen with the selection already chosen; with table.export: false, writes a CSV of the selection immediately.

  • copy: always requires confirmation.

  • delete: always requires confirmation.

Custom bulk actions

Use customBulkActions on DataTable when selected rows need app-specific operations such as publish, archive, approve, or sync. The prop accepts either a static array or a callback that receives the current selection context.

Custom actions render after the built-in export action and before the built-in delete action. They use the same execution result contract as built-in bulk callbacks.

import { Archive, Send } from "lucide-react";
import { DataTable } from "@/components/ui/yayaw-table";

<DataTable
  tableType="entries"
  customBulkActions={(ctx) => [
    {
      id: "publish-selected",
      label: "Publish",
      icon: Send,
      disabled: ctx.selectedCount === 0,
      onClick: async () => {
        await publishEntries(ctx.selectedOriginalRows);

        return {
          success: true,
          closeMenu: true,
          clearSelection: true,
          message: `Published ${ctx.selectedCount} entries`,
        };
      },
    },
    {
      id: "archive-selected",
      label: "Archive",
      icon: Archive,
      variant: "destructive",
      confirm: {
        title: "Archive selected entries?",
        description: `Archive ${ctx.selectedCount} selected entries.`,
        confirmLabel: "Archive",
      },
      onClick: async () => {
        await archiveEntries(ctx.selectedOriginalRows);

        return {
          success: true,
          closeMenu: true,
          clearSelection: true,
        };
      },
    },
  ]}
/>

The selection context contains:

type BulkActionContext<TData> = {
  selectedRows: Row<TData>[];
  selectedOriginalRows: TData[];
  selectedCount: number;
};

The action definition is:

type BulkAction<TData> = {
  id: string;
  label: string;
  icon: ComponentType<{ className?: string; size?: number }>;
  onClick: (ctx: BulkActionContext<TData>) =>
    | BulkActionResult
    | void
    | Promise<BulkActionResult | void>;
  disabled?: boolean | ((ctx: BulkActionContext<TData>) => boolean);
  variant?: "default" | "destructive";
  confirm?: {
    title?: string | ((ctx: BulkActionContext<TData>) => string);
    description?: string | ((ctx: BulkActionContext<TData>) => string);
    confirmLabel?: string | ((ctx: BulkActionContext<TData>) => string);
    cancelLabel?: string | ((ctx: BulkActionContext<TData>) => string);
  };
};

Bulk callback contract

Recommended return shape:

type BulkActionResult = {
  success: boolean;
  closeMenu: boolean;
  clearSelection: boolean;
  message?: string;
};

Example (onBulkDelete)

onBulkDelete: async (rows) => {
  const ids = rows.map((row) => String((row.original as { id: string }).id));
  const response = await deleteMany(ids);

  return {
    success: response.success,
    closeMenu: response.success,
    clearSelection: response.success,
    message: response.success
      ? `Deleted ${ids.length} rows`
      : response.error ?? "Delete failed",
  };
};

Legacy compatibility

Yayaw Table still normalizes legacy callback returns, but the explicit object contract is strongly recommended for predictable behavior.

Catalogue bulk editor

When onBulkEdit is absent and actions.bulkUpdate(ids, patch) is available, React and Vue open the built-in catalogue editor. A supplied onBulkEdit callback keeps precedence.

Start with Add a field, search for a property, then enter its new value using the existing catalogue editor. Only added properties appear in the form. The remove button takes a property out of the update without changing its data. Clear value is offered for supported optional fields only when their schema accepts the empty value. Required fields cannot be cleared this way.

The confirmation button reads Apply to N rows and stays disabled until the prepared fields pass validation. After partial success, its count reflects only the remaining targets. The shared presentation defaults to a right drawer on desktop and mobile; its footer stays outside the scrolling fields. Custom onBulkEdit callbacks and Vue's explicit JSON compatibility mode keep their existing behavior.

The editor captures target IDs at opening, initializes values common to the selected records, and sends only added fields. Identity/timestamp fields, bulkEdit: false fields, forbidden rows, and fields hidden or disabled for any target are excluded. Records resolving to different form types cannot share one editor. Changing the table selection while the editor is open does not retarget the operation.

context.bulkEdit contains ids, rows, and added fields. Field and nested collection validation still applies. The root full-row schema is omitted because unselected required fields need not be present in a patch. A transform receives the added-field payload. Explicit clearing values such as false, 0, "", [], and null are preserved.

const bulkUpdate = async (ids, patch) => {
  const failedIds = await updateProductsIndependently(ids, patch);
  return failedIds.length
    ? { success: false, failedIds, error: "Some products could not be updated" }
    : { success: true };
};

failedIds must be the complete subset still needing an update. Successful IDs leave the selection; retry sends only failed IDs and retains the draft and added fields. A failure without failedIds retains every target. An invalid completion report never silently clears targets. This persistence result is distinct from the menu callback result described below.

Selection and export scope

Both editions retain selected records across page and page-size changes. Deselecting one row preserves other selections, including off-page rows. Changing search, filters, sorting, or grouping clears selection. Select-all respects selection permissions and ignores stale responses after the query or selection changes. Use stable IDs or getRowId for server pagination; off-page records retain their last loaded values until fetched again.

Bulk export includes selected rows. Export (in the Data menu) retrieves all matching rows with the captured filters and sorting. This is how users pick what to export: select rows, then click Export in the bulk actions bar — with table.export enabled, it opens the Data menu's Export screen with "Selected (n)" already chosen, and the user can still switch to "All in this view". Built-in CSV includes visible data columns in display order. Existing onExport and onBulkExport callbacks take precedence. See Export screen and Query integration for pagination and refresh behavior.

Select across result pages

Use the complete Admin recipe with editableActions. Select two products, add only Stock in the bulk editor and apply 24: names and prices must not change.

Copy this complete configuration beside product-config.ts. Use () => exampleConfig as React’s getTableConfig, or :config="exampleConfig" in Vue.

Expand ↗

Sample data. Changes stay in this preview.

Expand ↗

Sample data. Changes stay in this preview.

Offer selection without bulk editing

Keep export available for the current selection while disabling edits and deletes. This is useful for a read-only reporting screen.

Copy this complete configuration beside product-config.ts. Use () => exampleConfig as React’s getTableConfig, or :config="exampleConfig" in Vue.

Expand ↗

Sample data. Changes stay in this preview.

Expand ↗

Sample data. Changes stay in this preview.

Keyboard selection

Ctrl/Cmd+A selects all permitted records matching the current query, including server pages, without requiring an initial selection. Gallery cards support Ctrl/Cmd-click to toggle and Shift-click to extend the visible range without opening a preview or record. Table checkboxes retain their range behavior. A changed visible order resets the anchor. Kanban retains its checkbox controls and participates in table-wide select-all.

The active table receives shortcuts. A single visible table can also receive them while focus is on the page body. Inputs, editable content and open overlays keep their native behavior; unrelated controls and hidden tables do not capture shortcuts. Ctrl/Cmd+Z uses the existing activity undo handler, including deleted records when the application provides history.

Duplicate the selection

Ctrl/Cmd+D calls the existing actions.duplicate(id) for each selected record in the active table, gallery or Kanban. It requires table.allowDuplicate !== false and permission from table.canDuplicateRow for every selected record. Text editors, dialogs and menus keep native shortcuts. Repeated keydown and concurrent duplicate requests are ignored.

Return { success: true, data: createdRecord } with the new record ID so the created copies become selected after refresh. A failure stops the batch, keeps unprocessed originals selected alongside successful copies, and displays an error. A fully successful batch displays one localized notification with the correct singular or plural. The application owns actual duplication, unique identities, permissions, storage and quotas; the library never copies a record by calling create with its existing ID.

In React, pass getRowId={(row) => String(row.id)} (or your stable key resolver) so selected copies and off-page selections use the same identity as visible rows.