Course Management
A focused walkthrough of one entity end-to-end: model, API, fields, layouts, actions, status transitions, and the wired EntityConfiguration<Course>. The aim is to show every slot you might want to override on a real entity.
This example assumes you are using the annotation-driven path. The generator emits course.entity.dart containing $CourseBase, CourseFields, $CourseApiBase, the default table layout, the default form, and $courseConfig. Everything below either consumes or overrides those.
Course Model
import 'package:fluentui_system_icons/fluentui_system_icons.dart';
import 'package:flutter/material.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:vyuh_entity_annotations/vyuh_entity_annotations.dart';
import 'package:vyuh_entity_system_ui/vyuh_entity_system_ui.dart';
import 'trainer.dart';
part 'course.g.dart';
part 'course.entity.dart';
@Entity(
schema: 'lms',
identifier: 'courses',
name: 'Course',
pluralName: 'Courses',
description: 'Training courses for participants',
icon: FluentIcons.book_24_regular,
priority: 10,
category: lmsCategory,
menuCategory: lmsMenuCategory,
capabilities: {EntityCapability.draftable()},
visibility: EntityVisibility.all,
navigation: EntityNavigation(
prefix: '/lms/courses',
authorize: AuthorizeEntity(
list: AuthorizeRule(Authorize.permission('lms.courses.view')),
create: AuthorizeRule(Authorize.permission('lms.courses.create')),
view: AuthorizeRule(Authorize.permission('lms.courses.view')),
edit: AuthorizeRule(Authorize.permission('lms.courses.update')),
),
),
)
@JsonSerializable(fieldRename: FieldRename.snake, includeIfNull: false)
class Course extends $CourseBase {
@Field(label: 'Description', type: TextFieldType(lines: 3))
final String? description;
@Field(label: 'Level', filterable: true, sortable: true)
final String level;
@Field(label: 'Status', filterable: true, sortable: true)
final String status;
@Field(label: 'Duration (min)', sortable: true)
final int durationMinutes;
@Field(label: 'Max Participants')
final int maxParticipants;
@Field(label: 'Tags', filterable: true)
final List<String> tags;
@Field(
label: 'Instructor',
filterable: true,
type: ReferenceFieldType(
entityType: 'trainers',
subtitleFields: ['email'],
searchFields: ['name', 'email'],
),
)
final String? instructorId;
@JsonKey(name: 'instructor', includeToJson: false)
final EntityReference? instructor;
Course({
required super.id,
required super.name,
this.description,
this.level = 'beginner',
this.status = 'draft',
this.durationMinutes = 0,
this.maxParticipants = 30,
this.tags = const [],
this.instructorId,
this.instructor,
super.versionNumber,
super.isActive,
super.createdAt,
super.updatedAt,
super.createdBy,
super.updatedBy,
});
factory Course.fromJson(Map<String, dynamic> json) =>
_$CourseFromJson(json);
@override
Map<String, dynamic> toJson() => _$CourseToJson(this);
}Course API
The generator emits $CourseApiBase with the standard CRUD wiring. Extend it for custom endpoints:
class CourseApi extends $CourseApiBase {
const CourseApi();
@override
CachingPolicy get cachingPolicy => const CachingPolicy(
staleDuration: Duration(minutes: 3),
);
/// Update course status (publish, archive, ...)
Future<Course?> updateStatus(String id, String newStatus) async {
final uri = Uri.parse('${pathBuilder.one(id)}/status');
final res = await vyuh.network.patch(
uri,
headers: defaultHeaders,
body: '{"status": "$newStatus"}',
);
if (res.statusCode != 200) return null;
final cache = vyuh.entity?.services.queryCache;
cache?.invalidateById('courses', id);
cache?.invalidateOperation('courses', 'list');
final json = jsonDecode(res.body) as Map<String, dynamic>;
return Course.fromJson(json['data'] as Map<String, dynamic>);
}
Future<void> publishAllDrafts() async {
final uri = Uri.parse('${pathBuilder.collection()}/publish_drafts');
await vyuh.network.post(uri, headers: defaultHeaders);
vyuh.entity?.services.queryCache?.invalidateEntity('courses');
}
}
const courseApi = CourseApi();Without the generator, write
class CourseApi extends HttpEntityApi<Course>and passentityType: 'courses',schemaType: 'lms.course',pathBuilder: EndpointBuilder(prefix: 'courses'),fromJson: Course.fromJsondirectly.
Field Definitions
The generator already emits CourseFields from the @Field annotations. You can extend it with computed columns when needed:
class CourseFields extends $CourseFieldsBase {
const CourseFields._();
static const instance = CourseFields._();
// Custom enum field with explicit colors — overrides the generated one.
static final status = EnumFieldDef<Course>(
field: 'status',
label: 'Status',
filterable: true,
sortable: true,
options: const {
'draft': 'Draft',
'published': 'Published',
'archived': 'Archived',
},
colorMap: const {
'draft': Colors.grey,
'published': Colors.green,
'archived': Colors.blueGrey,
},
priority: ColumnPriority.high,
);
static final level = EnumFieldDef<Course>(
field: 'level',
label: 'Level',
filterable: true,
sortable: true,
options: const {
'beginner': 'Beginner',
'intermediate': 'Intermediate',
'advanced': 'Advanced',
},
colorMap: const {
'beginner': Colors.green,
'intermediate': Colors.orange,
'advanced': Colors.red,
},
priority: ColumnPriority.high,
);
}List Layouts
Table
final courseTableLayout = EntityTableConfig<Course>(
identifier: 'table',
title: 'Table',
icon: FluentIcons.table_24_regular,
columns: [
EntityTableColumn<Course>(
fieldDef: CourseFields.name,
width: 0.3,
cellBuilder: (context, course) => DraftableEntityNameCell<Course>(
entity: course,
name: course.name,
),
),
EntityTableColumn<Course>(fieldDef: CourseFields.level),
EntityTableColumn<Course>(fieldDef: CourseFields.status),
EntityTableColumn<Course>(
fieldDef: CourseFields.durationMinutes,
cellBuilder: (context, course) =>
Text('${course.durationMinutes} min'),
),
EntityTableColumn<Course>(
fieldDef: CourseFields.instructorId,
cellBuilder: (context, course) => course.instructorId == null
? const Text('-')
: EntityNameText<Trainer>(entityId: course.instructorId!),
),
],
defaultSortField: 'name',
enableMultiSelect: true,
);Grid
final courseGridLayout = EntityGridConfig<Course>(
identifier: 'grid',
title: 'Cards',
icon: FluentIcons.grid_24_regular,
defaultColumns: 3,
minColumns: 1,
maxColumns: 5,
userAdjustableColumns: true,
card: EntityCardLayout<Course>(
title: (c) => c.name,
subtitle: (c) => c.level,
leading: (context, c) => const Icon(FluentIcons.book_24_regular),
properties: [
(FluentIcons.tag_24_regular, 'Level', (c) => c.level),
(FluentIcons.checkmark_circle_24_regular, 'Status', (c) => c.status),
(FluentIcons.clock_24_regular, 'Duration',
(c) => '${c.durationMinutes} min'),
],
style: const EntityCardStyle(
variant: CardVariant.outlined,
padding: EdgeInsets.all(12),
),
),
);Detail Tabs
class CourseDetailLayout extends EntityDetailLayout<Course> {
const CourseDetailLayout()
: super(
identifier: 'details',
title: 'Details',
icon: FluentIcons.info_24_regular,
);
@override
Widget build(BuildContext context, Course course) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 12,
children: [
Text(course.name, style: Theme.of(context).textTheme.headlineSmall),
if (course.description != null) Text(course.description!),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
Chip(label: Text('Level: ${course.level}')),
Chip(label: Text('Status: ${course.status}')),
Chip(label: Text('${course.durationMinutes} min')),
Chip(label: Text('Max: ${course.maxParticipants}')),
],
),
if (course.instructorId != null)
ListTile(
leading: const Icon(FluentIcons.person_24_regular),
title: EntityNameText<Trainer>(entityId: course.instructorId!),
subtitle: const Text('Assigned instructor'),
),
],
),
);
}
}Enrolled Participants Tab
final courseParticipantsApi = StandardRelatedApi(
parentIdentifier: 'courses',
relationSegment: 'participants',
requestBodyKey: 'participant_ids',
);
class CourseParticipantsLayout extends EntityDetailLayout<Course> {
const CourseParticipantsLayout()
: super(
identifier: 'participants',
title: 'Participants',
icon: FluentIcons.people_24_regular,
authorize: Authorize.permission('lms.enrollments.view'),
);
@override
Widget build(BuildContext context, Course course) {
return RelatedEntitiesLayout<Course, Participant>(
entity: course,
relatedApi: const ParticipantApi(),
getFilterCriteria: (_) => const {},
fetchRelated: (course) async {
final rows = await courseParticipantsApi.list(course.id);
return rows.map((j) => Participant.fromJson(j)).toList();
},
itemBuilder: (context, p) => ListTile(
leading: CircleAvatar(child: Text(p.name[0])),
title: Text(p.name),
subtitle: Text(p.email),
trailing: Chip(label: Text(p.role)),
),
title: 'Enrolled Participants',
emptyMessage: 'No participants enrolled yet.',
actions: RelatedEntitiesActionsConfig<Participant>(
addConfig: EntityPickerConfig<Participant>(
onPicked: (selected) async {
await courseParticipantsApi.link(
course.id, selected.map((p) => p.id).toList(),
);
vyuh.entity?.services.queryCache?.invalidateById(
'courses', course.id,
);
},
fetchEntities: (page, pageSize, query) async {
final rows = await courseParticipantsApi.listAvailable(
course.id, query: query,
);
return rows.map((j) => Participant.fromJson(j)).toList();
},
),
),
);
}
}Status Transitions
Publish + Archive Actions
final publishAction = EntityAction<Course>(
icon: FluentIcons.share_24_regular,
title: 'Publish',
isVisible: (c) async => c.status == 'draft',
authorize: Authorize.permission('lms.courses.publish'),
handler: (context, course) async {
final ok = await ConfirmationDialog.confirm(
context: context,
title: 'Publish "${course.name}"?',
message: 'It will become visible to all participants.',
confirmLabel: 'Publish',
);
if (ok != true) return;
await courseApi.updateStatus(course.id, 'published');
},
);
final archiveAction = EntityAction<Course>(
icon: FluentIcons.archive_24_regular,
title: 'Archive',
isVisible: (c) async => c.status == 'published',
authorize: Authorize.permission('lms.courses.update'),
isDestructive: true,
handler: (context, course) async {
await courseApi.updateStatus(course.id, 'archived');
},
);Filter Presets
final coursePresets = FilterPresetHelper.createPresets(
fields: CourseFields.instance.all,
definitions: [
FilterPresetDefinition(
id: 'draft',
name: 'Drafts',
icon: FluentIcons.document_edit_24_regular,
builder: (s) => s.textField('status').equals('draft'),
),
FilterPresetDefinition(
id: 'published',
name: 'Published',
icon: FluentIcons.checkmark_circle_24_regular,
builder: (s) => s.textField('status').equals('published'),
),
],
);Complete Configuration
import 'course.entity.dart' as base show $courseConfig;
final EntityConfiguration<Course> courseConfig = base.$courseConfig.copyWith(
layouts: EntityLayouts<Course>(
list: [courseTableLayout, courseGridLayout],
details: [
const CourseDetailLayout(),
const CourseParticipantsLayout(),
EntityVersionLayout<Course>(
authorize: Authorize.permission('lms.courses.update'),
),
EntityAuditLayout<Course>(
authorize: Authorize.permission('lms.courses.update'),
),
],
),
actions: base.$courseConfig.actions.copyWith(
inline: [
...StandardEntityActions.inline<Course>(),
publishAction,
archiveAction,
],
collection: [
CollectionAction<Course>(
icon: FluentIcons.share_24_regular,
title: 'Publish All Drafts',
isPrimary: true,
authorize: Authorize.permission('lms.courses.publish'),
handler: (context) async {
await courseApi.publishAllDrafts();
},
),
],
),
filterPresets: coursePresets,
);Generated Routes
| Route | Page |
|---|---|
/lms/courses | List with table/grid switcher, filter chips, search |
/lms/courses/new | Create form |
/lms/courses/:id | Detail with Details / Participants / Versions / Audit tabs |
/lms/courses/:id?tab=participants | Detail opened on the Participants tab |
/lms/courses/:id/edit | Edit form |
/lms/courses/dashboard | Course dashboard (when configured) |
Next Steps
- Complete LMS Example — all five entity configurations
- Certification Flow — draft + approval + e-signature
- Batch Operations — the bulk-publish pattern in depth