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:
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:
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:
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
| Property | Purpose |
|---|---|
icon / title | Button visual + accessibility label |
handler | Async callback executed on click |
authorize | Declarative gate via the Authorize DSL |
isAvailable | Custom async availability check (mutually exclusive with authorize) |
isDestructive | Renders with destructive styling |
isPrimary | Renders as a filled button instead of an icon button |
splitMenuItems | Turns the button into a split-button with a dropdown |
widgetBuilder | Escape 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
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>:
final courseConfig = EntityConfiguration<Course>(
// ...
actions: EntityActions<Course>(
inline: StandardEntityActions.inline<Course>(),
header: StandardEntityActions.header<Course>(),
collection: [
exportAction,
bulkPublishAction,
bulkDeleteAction,
],
),
);Where Each Slot Renders
| Slot | Surface | Purpose |
|---|---|---|
inline | Per-row dropdown / detail header | Per-entity actions (Edit, Deactivate) — EntityAction<T> |
header | List page header | Collection-wide primary buttons (Create, Import) |
collection | Batch action bar (appears with selection) | Operations on the selected set |
menu | Dropdown groups | Grouped 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:
- The selection count ("3 selected")
- Authorized collection actions
- 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.
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.:
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
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
- Master-Detail — selection model and panel modes
- Permissions — the
AuthorizeDSL - Best Practices — action design rules