Skip to content

Forms and Editors

This guide covers entity editing -- from simple single-form editors to complex multi-part editors with tabs, custom designers, verification, and audit trail remarks.

EntityEditor Overview

EntityEditor<T> is the abstract base class that the editor controller talks to. The runtime calls its getters polymorphically, so subclasses can change behavior without the controller knowing. Two concrete subclasses ship with the framework:

SubclassUse when
StandardEntityEditor<T>The entity does not require e-signature verification or remarks. Plain Save + Cancel.
SignatureDrivenEditor<T>The entity is regulated. Verification and remarks requirements are resolved at runtime by a LifecycleResolver callback supplied by the app layer.

Both subclasses share the same parts/transformer/display surface — the only difference is how the Save action behaves. You almost never subclass EntityEditor directly.

Key responsibilities of an editor:

  • Single-part or multi-part editing interfaces (one or more parts in parts: () => [...])
  • Data transformation between entity and form formats (EntityTransformer<T>)
  • Display mode (panel, fullWidth, adaptive)
  • Draft support (allowDrafts: true)
  • Verification + remarks (only on SignatureDrivenEditor, resolved at runtime)

Simple Form Editor

The most common pattern uses StandardEntityEditor with a single FormEditorPart:

dart
final courseConfig = EntityConfiguration<Course>(
  // ...
  editor: StandardEntityEditor<Course>(
    transformer: DefaultEntityTransformer<Course>(
      fromJson: Course.fromJson,
    ),
    parts: () => [
      FormEditorPart<Course>(
        title: 'Details',
        icon: FluentIcons.edit_24_regular,
        identifier: 'details',
        getForm: ({entityId, isDraft, initialValues}) => vf.Form(
          title: 'Course',
          items: [
            vf.TextField(
              name: 'name',
              title: 'Course Name',
              isRequired: true,
            ),
            vf.TextArea(
              name: 'description',
              title: 'Description',
              maxLines: 4,
            ),
            vf.Dropdown(
              name: 'level',
              title: 'Level',
              options: ['beginner', 'intermediate', 'advanced'],
            ),
            vf.NumberField(
              name: 'duration_minutes',
              title: 'Duration (minutes)',
            ),
            vf.Dropdown(
              name: 'status',
              title: 'Status',
              options: ['draft', 'published', 'archived'],
              // Only editable for new entities or drafts
              enabled: entityId == null || isDraft,
            ),
          ],
        ),
      ),
    ],
    displayMode: EditorDisplayMode.panel,
  ),
  // ...
);

Why a parts: () => [...] callback?

The factory is called every time the editor opens to produce fresh part instances. This guarantees clean state — no leftover form values, no lingering subscriptions, no controller leaks between editing sessions. Treat it as a constructor for the part list, not a getter.

getForm Parameters

The getForm factory receives context about the editing session:

ParameterTypePurpose
entityIdString?null for create, entity ID for edit
isDraftbooltrue when editing a draft entity
initialValuesMap<String, dynamic>?Entity data as JSON for pre-populating fields

Use these to conditionally enable/disable fields:

dart
getForm: ({entityId, isDraft, initialValues}) {
  final isNew = entityId == null;

  return vf.Form(
    title: 'Course',
    items: [
      vf.TextField(
        name: 'code',
        title: 'Course Code',
        isRequired: true,
        // Code is immutable after creation (unless editing a draft)
        enabled: isNew || isDraft,
      ),
      // ...
    ],
  );
}

EditorDisplayMode

ModeBehavior
EditorDisplayMode.panelEditor appears in a side panel (default)
EditorDisplayMode.fullWidthEditor takes the full screen
EditorDisplayMode.adaptiveAdapts based on screen size and complexity

EntityTransformer

EntityTransformer<T> converts between entity objects and form field values. The default implementation (DefaultEntityTransformer) passes data through without transformation.

dart
abstract class EntityTransformer<T extends EntityBase> {
  /// Convert entity to form field values
  Map<String, dynamic> from(T entity);

  /// Convert form field values back to entity
  T to(Map<String, dynamic> formData, T? existingEntity);
}

DefaultEntityTransformer

For most entities, the default transformer works out of the box:

dart
DefaultEntityTransformer<Course>(fromJson: Course.fromJson)

It uses entity.toJson() for the from direction, and merges form data with existing entity JSON for the to direction.

Custom Transformer

When the form representation differs from the entity representation:

dart
class CourseTransformer extends EntityTransformer<Course> {
  @override
  Map<String, dynamic> from(Course entity) {
    final data = entity.toJson();
    // Convert duration from minutes to hours for the form
    if (data['duration_minutes'] != null) {
      data['duration_hours'] = (data['duration_minutes'] as int) / 60.0;
      data.remove('duration_minutes');
    }
    return data;
  }

  @override
  Course to(Map<String, dynamic> formData, Course? existingEntity) {
    final data = Map<String, dynamic>.from(formData);
    // Convert duration from hours back to minutes
    if (data['duration_hours'] != null) {
      data['duration_minutes'] =
          ((data['duration_hours'] as double) * 60).round();
      data.remove('duration_hours');
    }

    if (existingEntity != null) {
      final existing = existingEntity.toJson();
      return Course.fromJson({...existing, ...data});
    }
    return Course.fromJson({...data, 'id': ''});
  }
}

FormEditorPart

FormEditorPart<T> is the built-in editor part that wraps a Vyuh form. Add it to the parts: () => [...] callback of any StandardEntityEditor (or SignatureDrivenEditor) — single-part editors and multi-part editors are just different lengths of the same list.

dart
FormEditorPart<Course>(
  title: 'Details',
  icon: Icons.info,
  identifier: 'details',
  getForm: ({entityId, isDraft, initialValues}) => vf.Form(
    title: 'Course Details',
    items: [
      vf.TextField(name: 'name', title: 'Name', isRequired: true),
      vf.TextArea(name: 'description', title: 'Description'),
    ],
  ),
  watchFields: ['status'], // Publish changes to other parts
)

The watchFields parameter enables cross-part communication. When a watched field changes, an EditorPartEvent is published on the shared event bus, allowing other editor parts to react.

Lifecycle

  1. initialize(context) -- Creates the form with entity data baked in via getForm(initialValues:)
  2. build(context, partContext) -- Renders the form using vyuh.content.buildContent()
  3. validate() -- Runs synchronous form validation
  4. validateAsync() -- Runs async validations (e.g., server-side uniqueness checks)
  5. getData() -- Returns the form's saved values as a Map<String, dynamic>

CustomEditorPart

For interfaces that go beyond standard forms (workflow designers, visual editors, etc.), use CustomEditorPart<T>:

dart
CustomEditorPart<Course>(
  title: 'Curriculum',
  icon: Icons.account_tree,
  identifier: 'curriculum',

  onInitialize: (context) {
    final store = CurriculumDesignerStore();
    if (context.entity != null) {
      store.loadFromJson(context.entity!.toJson()['curriculum']);
    }
    context.setCustomData('store', store);
  },

  builder: (ctx, partContext) {
    final store = partContext.getCustomData<CurriculumDesignerStore>('store')!;
    return CurriculumDesigner(store: store);
  },

  getDataCallback: (partContext) {
    final store = partContext.getCustomData<CurriculumDesignerStore>('store')!;
    return {'curriculum': store.toJson()};
  },

  validateCallback: (partContext) {
    final store = partContext.getCustomData<CurriculumDesignerStore>('store')!;
    return store.isValid;
  },
)

Custom Data Storage

EntityEditorPartContext<T> provides a typed key-value store for sharing data between callbacks:

dart
// Store data
context.setCustomData('store', myStore);

// Retrieve data
final store = partContext.getCustomData<MyStore>('store')!;

Multi-Part Editor

A multi-part editor is just a StandardEntityEditor whose parts callback returns more than one part. Each part becomes a tab in the editor. There is no separate EntityEditor.multiPart factory — single and multi-part editors share the same surface.

dart
editor: StandardEntityEditor<Course>(
  transformer: DefaultEntityTransformer<Course>(
    fromJson: Course.fromJson,
  ),
  parts: () => [
    FormEditorPart<Course>(
      title: 'Settings',
      icon: FluentIcons.settings_24_regular,
      identifier: 'settings',
      getForm: _getCourseSettingsForm,
    ),
    CustomEditorPart<Course>(
      title: 'Curriculum',
      icon: FluentIcons.flowchart_24_regular,
      identifier: 'curriculum',
      onInitialize: _initCurriculum,
      builder: _buildCurriculum,
      getDataCallback: _getCurriculumData,
    ),
    FormEditorPart<Course>(
      title: 'Scheduling',
      icon: FluentIcons.calendar_24_regular,
      identifier: 'scheduling',
      getForm: _getSchedulingForm,
    ),
  ],
  displayMode: EditorDisplayMode.fullWidth,
  showTitle: true,
),

The parts factory is called each time the editor opens to ensure clean state.

How Multi-Part Save Works

  1. All parts are validated (sync, then async)
  2. getData() is called on each part
  3. All data maps are merged into a single Map<String, dynamic>
  4. The transformer converts the merged data into an entity
  5. The API creates or updates the entity
  6. onSaveSuccess() is called on each part

Verification and Signatures

E-signature verification is provided by SignatureDrivenEditor, the regulated counterpart to StandardEntityEditor. Instead of declaring verification flags inline on the editor, you supply a LifecycleResolver callback that returns the requirements at runtime:

dart
editor: SignatureDrivenEditor<Course>(
  transformer: courseTransformer,
  parts: () => [
    FormEditorPart<Course>(
      title: 'Details',
      icon: FluentIcons.edit_24_regular,
      identifier: 'details',
      getForm: _getCourseForm,
    ),
  ],
  lifecycleResolver: lmsLifecycleResolver<Course>(),
  allowDrafts: true,
),

The resolver returns a LifecycleRequirements value:

dart
class LifecycleRequirements {
  final bool requiresVerification;
  final bool requiresRemarks;
  final bool hasApprovals;
  final SignatureRole signatureRole;
}

SignatureDrivenEditor.init() calls the resolver, then builds its Save action with the resulting requirements baked into the handler closure:

  • If requiresVerification or requiresRemarks is true and there are no approvals, the save action runs the signature dialog and threads any remarks through to api.create() / api.update() as the remarks parameter.
  • If hasApprovals is true, inline verification is skipped — all saves go to draft first, and verification happens later inside the approvals workflow.
  • If the resolver throws or returns LifecycleRequirements.fallback, the editor defaults to "verification required, no remarks, no approvals" — the safe choice.

Why a callback?

Compliance configuration that determines whether a particular operation needs a signature lives in the app layer (e.g., flutter_lms's lifecycle config tables), not in the entity declaration. Resolving it through a callback at runtime keeps the entity layer ignorant of the regulated-vs-unregulated distinction and lets the same entity declaration work in apps with very different compliance regimes.

SignatureRole

RolePurpose
SignatureRole.doerStandard single-signer (default)
SignatureRole.witnessMulti-step: witness signs first, then doer
SignatureRole.reviewerStandard flow with reviewer badge

The role is returned by the resolver as part of LifecycleRequirements.

Remarks for Audit Trail

Remarks are part of the same LifecycleRequirements value. When the resolver returns requiresRemarks: true, the verification dialog shows a required remarks field, and the entered text is passed as the remarks parameter to api.create() / api.update() and stored in the audit trail. There are no separate remarksCreate / remarksUpdate flags on the editor.

Drafts never require verification or remarks, regardless of what the resolver returns — the framework treats Save as Draft as an explicit intermediate state and pushes regulatory checks to the submission step.

LMS Example: Course Editor with Tabs

dart
final courseConfig = EntityConfiguration<Course>(
  metadata: EntityMetadata(
    identifier: 'courses',
    name: 'Course',
    pluralName: 'Courses',
    icon: Icons.menu_book,
  ),
  api: courseApi,
  routing: EntityRouting(
    path: NavigationPathBuilder.collection(prefix: '/lms/courses'),
    builder: StandardRouteBuilder<Course>(),
  ),
  layouts: courseLayouts,
  actions: EntityActions<Course>(
    inline: StandardEntityActions.inline<Course>(),
    header: StandardEntityActions.header<Course>(),
  ),
  // Use SignatureDrivenEditor when this entity is regulated and needs
  // verification/remarks. Otherwise StandardEntityEditor.
  editor: StandardEntityEditor<Course>(
    transformer: DefaultEntityTransformer<Course>(
      fromJson: Course.fromJson,
    ),
    parts: () => [
      // Tab 1: Basic course information
      FormEditorPart<Course>(
        title: 'Details',
        icon: FluentIcons.info_24_regular,
        identifier: 'details',
        getForm: ({entityId, isDraft, initialValues}) => vf.Form(
          title: 'Course Details',
          items: [
            vf.TextField(name: 'name', title: 'Name', isRequired: true),
            vf.TextArea(name: 'description', title: 'Description'),
            vf.Dropdown(
              name: 'level',
              title: 'Level',
              options: ['beginner', 'intermediate', 'advanced'],
            ),
            vf.NumberField(
              name: 'duration_minutes',
              title: 'Duration (minutes)',
            ),
          ],
        ),
      ),

      // Tab 2: Scheduling and instructor assignment
      FormEditorPart<Course>(
        title: 'Scheduling',
        icon: FluentIcons.calendar_24_regular,
        identifier: 'scheduling',
        getForm: ({entityId, isDraft, initialValues}) => vf.Form(
          title: 'Schedule',
          items: [
            vf.DateField(name: 'start_date', title: 'Start Date'),
            vf.DateField(name: 'end_date', title: 'End Date'),
            vf.TextField(name: 'instructor_id', title: 'Instructor ID'),
          ],
        ),
      ),

      // Tab 3: Custom curriculum builder
      CustomEditorPart<Course>(
        title: 'Curriculum',
        icon: FluentIcons.flowchart_24_regular,
        identifier: 'curriculum',
        onInitialize: (context) {
          final store = CurriculumStore();
          if (context.entity != null) {
            store.load(context.entity!.toJson());
          }
          context.setCustomData('store', store);
        },
        builder: (ctx, partContext) => CurriculumBuilder(
          store: partContext.getCustomData<CurriculumStore>('store')!,
        ),
        getDataCallback: (partContext) => {
          'curriculum': partContext
              .getCustomData<CurriculumStore>('store')!
              .toJson(),
        },
      ),
    ],
    displayMode: EditorDisplayMode.fullWidth,
    allowDrafts: false,
  ),
);

Additional Editor Options

OptionDefaultPurpose
allowDraftsfalseEnable "Save as Draft" button
showTitletrueShow title in the editor AppBar
hideCancelfalseHide the Cancel button
stayOpenAfterSavefalseKeep editor open after successful save
titlenullCustom editor title (overrides auto-generated title)

vyuh_feature_forms Integration

FormEditorPart wraps a vf.Form declaratively built from vyuh_feature_forms. The form package provides the field primitives (text, number, dropdown, date, file, slider, formula, repeating section, …), a fluent FormBuilder DSL, sync + async validation, conditional visibility rules, and a step-form layout. Anything you can express with vf.Form you can drop into a FormEditorPart.

FormBuilder DSL

dart
import 'package:vyuh_feature_forms/vyuh_feature_forms.dart' as vf;

final form = vf.FormBuilder('Course')
    .text('name', title: 'Course Name')
        .required()
        .minLength(3)
    .text('description', title: 'Description')
        .multiline(maxLines: 4)
    .dropdown('level', title: 'Level',
        options: ['beginner', 'intermediate', 'advanced'])
    .number('duration_minutes', title: 'Duration (min)')
        .min(1)
    .build();

Entity Reference Field

vyuh_entity_system extends FormBuilder with an entityRef helper backed by EntityReferenceProvider:

dart
import 'package:vyuh_entity_system/vyuh_entity_system.dart';

final form = vf.FormBuilder('Equipment')
    .text('name', title: 'Name').required()
    .entityRef('area_id',
        title: 'Area',
        entityType: 'areas',
        placeholder: 'Select an area')
        .required()
    .entityRef('parent_equipment_id',
        title: 'Parent Equipment',
        entityType: 'equipment',
        excludeEntityId: entityId,  // exclude self when editing
        dependsOn: {'area_id': 'area_id'})  // filter by selected area
    .build();

Async Validation

Server-side validations (e.g., uniqueness checks) wire into the part's validateAsync() flow:

dart
.text('code', title: 'Code')
    .required()
    .asyncValidator((value) async {
      final exists = await api.codeExists(value);
      return exists ? 'Code already in use' : null;
    })

FormEditorPart.validateAsync() calls _form.validateAllAsync() which runs all registered async validators and surfaces errors via getValidationErrors().

Field Visibility Rules

dart
.dropdown('shift_type', title: 'Shift Type',
    options: ['day', 'night', 'rotating'])
.dropdown('rotation_pattern', title: 'Rotation Pattern',
    options: ['weekly', 'monthly'])
    .visibleWhen(vf.When.fieldEquals('shift_type', 'rotating'))

See the vyuh_feature_forms package docs for the full field catalog, validators, and rule system.

Next Steps