Skip to content

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

dart
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:

dart
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 pass entityType: 'courses', schemaType: 'lms.course', pathBuilder: EndpointBuilder(prefix: 'courses'), fromJson: Course.fromJson directly.

Field Definitions

The generator already emits CourseFields from the @Field annotations. You can extend it with computed columns when needed:

dart
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

dart
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

dart
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

dart
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

dart
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

dart
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

dart
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

dart
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

RoutePage
/lms/coursesList with table/grid switcher, filter chips, search
/lms/courses/newCreate form
/lms/courses/:idDetail with Details / Participants / Versions / Audit tabs
/lms/courses/:id?tab=participantsDetail opened on the Participants tab
/lms/courses/:id/editEdit form
/lms/courses/dashboardCourse dashboard (when configured)

Next Steps