Skip to content

Batch Operations

The Vyuh Entity System supports multi-selection on entity lists and bulk operations on the resulting set. This page covers CollectionAction<T>, the selection model, the batch action bar, and Authorize-aware gating.

Multi-Selection in Collection Layouts

The stock EntityTableConfig<T> supports multi-select. Toggle it on when constructing the layout:

dart
final courseTableLayout = EntityTableConfig<Course>(
  identifier: 'table',
  title: 'Table',
  icon: FluentIcons.table_24_regular,
  columns: [
    EntityTableColumn<Course>(fieldDef: CourseFields.name),
    EntityTableColumn<Course>(fieldDef: CourseFields.level),
    EntityTableColumn<Course>(fieldDef: CourseFields.status),
  ],
  enableMultiSelect: true,
);

Selection state lives on the EntityListController<T> that backs the list. You read it through EntityProvider:

dart
final controller = EntityProvider.listControllerOf<Course>(context);
final selected = controller.selectedEntities;   // Set<Course>
final isMultiSelect = controller.multiSelectMode;

When the user picks one or more rows, the workspace replaces the single detail panel with the batch action bar.

CollectionAction

CollectionAction<T> represents an action that operates on the collection (or a selected subset). It is the same type used for header actions and for batch actions:

dart
class CollectionAction<T extends EntityBase> {
  final IconData icon;
  final String title;
  final Future<void> Function(BuildContext context) handler;
  final Future<bool> Function(BuildContext context)? isAvailable;
  final Authorize? authorize;          // Declarative gate
  final ButtonStyle? style;
  final Color? iconColor;
  final bool isDestructive;
  final bool isPrimary;
  final List<CollectionAction<T>> splitMenuItems;
  final Widget Function(BuildContext context)? widgetBuilder;
}

Key Properties

PropertyPurpose
icon / titleButton visual + accessibility label
handlerAsync callback executed on click
authorizeDeclarative gate via the Authorize DSL
isAvailableCustom async availability check (mutually exclusive with authorize)
isDestructiveRenders with destructive styling
isPrimaryRenders as a filled button instead of an icon button
splitMenuItemsTurns the button into a split-button with a dropdown
widgetBuilderEscape hatch — renders a verbatim widget instead of the standard button

WARNING

authorize and isAvailable are mutually exclusive. Pick one or the other.

Defining Collection Actions

dart
final exportAction = CollectionAction<Course>(
  icon: FluentIcons.arrow_download_24_regular,
  title: 'Export Courses',
  authorize: Authorize.permission('lms.courses.export'),
  handler: (context) async {
    final controller = EntityProvider.listControllerOf<Course>(context);
    final entities = controller.selectedEntities.isNotEmpty
        ? controller.selectedEntities
        : controller.entities;
    await CourseExporter.exportToCsv(entities);
  },
);

final bulkPublishAction = CollectionAction<Course>(
  icon: FluentIcons.checkmark_circle_24_regular,
  title: 'Publish All Drafts',
  isPrimary: true,
  authorize: Authorize.permission('lms.courses.publish'),
  handler: (context) async {
    final ok = await ConfirmationDialog.confirm(
      context: context,
      title: 'Publish all draft courses?',
      message: 'They will become visible to participants.',
      confirmLabel: 'Publish All',
    );
    if (ok != true) return;

    await courseApi.publishAllDrafts();
    vyuh.entity?.services.queryCache?.invalidateEntity('courses');
  },
);

final bulkDeleteAction = CollectionAction<Course>(
  icon: FluentIcons.delete_24_regular,
  title: 'Delete Selected',
  isDestructive: true,
  authorize: Authorize.permission('lms.courses.delete'),
  handler: (context) async {
    final controller = EntityProvider.listControllerOf<Course>(context);
    final selected = controller.selectedEntities.toList();

    final ok = await ConfirmationDialog.destructive(
      context: context,
      title: 'Delete ${selected.length} courses?',
      message: 'This cannot be undone.',
    );
    if (ok != true) return;

    for (final c in selected) {
      await courseApi.delete(c.id);
    }
    controller.clearSelection();
  },
);

Registering Collection Actions

Collection actions live on EntityActions<T>:

dart
final courseConfig = EntityConfiguration<Course>(
  // ...
  actions: EntityActions<Course>(
    inline: StandardEntityActions.inline<Course>(),
    header: StandardEntityActions.header<Course>(),
    collection: [
      exportAction,
      bulkPublishAction,
      bulkDeleteAction,
    ],
  ),
);

Where Each Slot Renders

SlotSurfacePurpose
inlinePer-row dropdown / detail headerPer-entity actions (Edit, Deactivate) — EntityAction<T>
headerList page headerCollection-wide primary buttons (Create, Import)
collectionBatch action bar (appears with selection)Operations on the selected set
menuDropdown groupsGrouped advanced actions — ActionGroup<T>

StandardEntityActions.inline<T>() ships the default Edit + Deactivate buttons. StandardEntityActions.header<T>() ships the Create button, and auto-derives its Authorize gate from the entity's routing.permissions.create so it stays hidden for actors who lack the declared create permission.

The Batch Action Bar

When the controller has at least one selected entity, the workspace replaces the detail pane with a batch action bar showing:

  1. The selection count ("3 selected")
  2. Authorized collection actions
  3. A clear-selection control

Authorization-Aware Actions

CollectionAction.authorize resolves through the entity system's AuthorizationProvider. Actions whose expression evaluates to false are hidden from the bar entirely.

dart
final approveAction = CollectionAction<Certification>(
  icon: FluentIcons.checkmark_seal_24_regular,
  title: 'Approve Selected',
  authorize: Authorize.all([
    Authorize.permission('lms.certifications.approve'),
    Authorize.role('quality_reviewer'),
  ]),
  handler: (context) async {
    // Only reaches here if both checks pass.
  },
);

Use isAvailable for non-authorization logic — a feature-flag check, an async lookup of "is the document closed?", etc.:

dart
final certifyAction = CollectionAction<Course>(
  icon: FluentIcons.ribbon_24_regular,
  title: 'Issue Certificates',
  isAvailable: (context) async {
    return await featureFlags.isEnabled('lms.auto_certificates');
  },
  handler: (context) async {
    await certificationApi.issueForCompletedCourses();
  },
);

LMS Example: Bulk Publish

dart
final bulkPublishAction = CollectionAction<Course>(
  icon: FluentIcons.checkmark_circle_24_regular,
  title: 'Publish Selected',
  isPrimary: true,
  authorize: Authorize.permission('lms.courses.publish'),
  handler: (context) async {
    final controller = EntityProvider.listControllerOf<Course>(context);
    final selected = controller.selectedEntities
        .where((c) => c.status == 'draft')
        .toList();

    if (selected.isEmpty) {
      vyuh.entity?.services.errorDisplay.showValidationError(
        context,
        'No draft courses in the selection.',
        title: 'Nothing to publish',
      );
      return;
    }

    final ok = await ConfirmationDialog.confirm(
      context: context,
      title: 'Publish ${selected.length} drafts?',
      message: 'They will become visible to all participants.',
      confirmLabel: 'Publish',
    );
    if (ok != true) return;

    for (final c in selected) {
      await courseApi.updateStatus(c.id, 'published');
    }

    vyuh.entity?.services.queryCache?.invalidateEntity('courses');
    controller.clearSelection();
  },
);

final courseConfig = EntityConfiguration<Course>(
  metadata: EntityMetadata(
    identifier: 'courses',
    name: 'Course',
    pluralName: 'Courses',
    icon: FluentIcons.book_24_regular,
  ),
  api: const CourseApi(),
  routing: EntityRouting<Course>(
    path: NavigationPathBuilder.collection(prefix: '/lms/courses'),
    builder: StandardRouteBuilder<Course>(),
  ),
  layouts: EntityLayouts<Course>(
    list: [
      EntityTableConfig<Course>(
        identifier: 'table',
        title: 'Table',
        icon: FluentIcons.table_24_regular,
        columns: [
          EntityTableColumn<Course>(fieldDef: CourseFields.name),
          EntityTableColumn<Course>(fieldDef: CourseFields.level),
          EntityTableColumn<Course>(fieldDef: CourseFields.status),
        ],
        enableMultiSelect: true,
      ),
    ],
  ),
  actions: EntityActions<Course>(
    inline: StandardEntityActions.inline<Course>(),
    header: StandardEntityActions.header<Course>(),
    collection: [bulkPublishAction],
  ),
);

Next Steps