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:
| Subclass | Use 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:
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:
| Parameter | Type | Purpose |
|---|---|---|
entityId | String? | null for create, entity ID for edit |
isDraft | bool | true when editing a draft entity |
initialValues | Map<String, dynamic>? | Entity data as JSON for pre-populating fields |
Use these to conditionally enable/disable fields:
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
| Mode | Behavior |
|---|---|
EditorDisplayMode.panel | Editor appears in a side panel (default) |
EditorDisplayMode.fullWidth | Editor takes the full screen |
EditorDisplayMode.adaptive | Adapts 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.
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:
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:
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.
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
initialize(context)-- Creates the form with entity data baked in viagetForm(initialValues:)build(context, partContext)-- Renders the form usingvyuh.content.buildContent()validate()-- Runs synchronous form validationvalidateAsync()-- Runs async validations (e.g., server-side uniqueness checks)getData()-- Returns the form's saved values as aMap<String, dynamic>
CustomEditorPart
For interfaces that go beyond standard forms (workflow designers, visual editors, etc.), use CustomEditorPart<T>:
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:
// 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.
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
- All parts are validated (sync, then async)
getData()is called on each part- All data maps are merged into a single
Map<String, dynamic> - The transformer converts the merged data into an entity
- The API creates or updates the entity
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:
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:
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
requiresVerificationorrequiresRemarksistrueand there are no approvals, the save action runs the signature dialog and threads any remarks through toapi.create()/api.update()as theremarksparameter. - If
hasApprovalsistrue, 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
| Role | Purpose |
|---|---|
SignatureRole.doer | Standard single-signer (default) |
SignatureRole.witness | Multi-step: witness signs first, then doer |
SignatureRole.reviewer | Standard 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
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
| Option | Default | Purpose |
|---|---|---|
allowDrafts | false | Enable "Save as Draft" button |
showTitle | true | Show title in the editor AppBar |
hideCancel | false | Hide the Cancel button |
stayOpenAfterSave | false | Keep editor open after successful save |
title | null | Custom 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
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:
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:
.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
.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
- Drafts and Versioning -- enabling draft workflows and version history
- Building UI -- using
EntityListViewandEntityViewfor display - CRUD Operations -- the API operations that editors call on save