Skip to content

Writing an editor (page)

Copy of sumaris-app/.agents/rules/editor.md (rule written for AI coding agents, always applied). Do not edit here: run npm run site:sync-rules to update it.

An editor is a full-page component that loads, displays, and saves one entity, orchestrating several child forms and tables. It owns the data lifecycle (route → load → setValue → getValue → save).

  • Use the file suffix .page.<extension> (see common rules);
  • Use changeDetection: ChangeDetectionStrategy.OnPush;
  • An editor extends one of the editor base classes below, according to the entity kind.

Class hierarchy

From the most generic to the most specialized — pick the lowest class that fits:

  1. AppEditor<T, ID, O> (@sumaris-net/ngx-components): base editor. Save/load/error lifecycle, child-form aggregation, toolbar, navigation. Key members:

    • abstract load(id, opts), abstract save(event, opts), abstract reload(), abstract get isNewData();
    • addForm(path, form) / addForms(forms) / removeForm(form);
    • markAsSaving() / markAsSaved(), saving, savingSubject, submitted;
    • cancel(), unload(), goBack(), saveIfDirtyAndConfirm(), saveDirtyChildren(), scrollToTop(), showToast().
  2. AppTabEditor<T, ID, O>: adds tab support (MatTabGroup). Members:

    • selectedTabIndex (get/set), selectedTabIndexChange, tabGroup, tabCount, enableSwipe;
    • setSelectedTabIndex(), onTabChange(), onSwipeTab(), realignInkBar(), updateRoute();
    • protected abstract getFirstInvalidTabIndex() — used to jump to the first tab in error on save.
  3. AppEntityEditor<T, S, ID, ...>: generic entity editor backed by an IEntityService. This is where the data lifecycle lives. Members:

    • data: T, service, dataType, usageMode, isOnFieldMode, isNewData, titleSubject, onUpdateView;
    • load(id, opts), save(event, opts), saveAndClose(), delete(), reload(), close();
    • updateView(data, opts), updateViewState(), updateTabAndRoute(), getValue(), setError(), resetError();
    • Override hooks (see below): onNewEntity, onEntityLoaded, onEntitySaved, onEntityDeleted, computeUsageMode, canUserWrite, getJsonValueToSave;
    • Abstract members to implement: computeTitle(data), get form(), getFirstInvalidTabIndex(), registerForms(), setValue(data, opts).
  4. AppDataEntityEditor<T, S, ID, ..., ST, C> (src/app/data/form/data-editor.class.ts): adds program / strategy / PMFM resolution and quality context for data entities. Adds:

    • Injected services: programRefService, strategyRefService, referentialRefService, configService, personService, context, etc.;
    • RxState properties + observables: program/program$, programLabel/programLabel$, strategy/strategy$, acquisitionLevel, requiredStrategy, strategyFilter, pmfms/pmfms$, appliedProgram;
    • Hooks: setProgram(program), setStrategy(strategy), loadStrategy(), onConfigLoaded(), translateFormPath(), _reloadProgram / _reloadStrategy;
    • Constructor options carry acquisitionLevel, strategyResolution, strategyAcquisitionLevels, etc.
    • The state type extends AppDataEditorState.
  5. AppRootDataEntityEditor<T, S, ID, ..., ST, C> (src/app/data/form/root-data-editor.class.ts): for root data entities (Trip, ObservedLocation, Landing…) that have their own quality lifecycle (control/terminate/validate), program control, observers, and offline synchronization. Adds:

    • network, programControl, startListenProgramChanges();
    • computeUsageMode() derives FIELD mode from synchronizationStatus === 'DIRTY';
    • canUserWrite() returns false once validationDate is set; getValue() re-adds the (possibly disabled) program control;
    • The state type extends RootDataEntityEditorState.

Note: child entities (e.g. Operation) extend AppDataEntityEditor; root entities extend AppRootDataEntityEditor.

@Component declaration

@Component({
  selector: 'app-my-entity-page',
  templateUrl: './my-entity.page.html',
  styleUrls: ['./my-entity.page.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
  animations: [fadeInOutAnimation],
  providers: [
    { provide: APP_DATA_ENTITY_EDITOR, useExisting: forwardRef(() => MyEntityPage) },
    RxState,
    { provide: ContextService, useExisting: MyEntityContextService },
  ],
})
export class MyEntityPage
  extends AppRootDataEntityEditor<MyEntity, MyEntityService, number, /* LO, SO */, MyEntityPageState, MyEntityContextService>
  implements OnInit, OnDestroy { /* ... */ }
  • Declare a MyEntityPageState interface (extending RootDataEntityEditorState or AppDataEditorState) above the class for dynamic attributes;
  • Reference child components with @ViewChild('formRef', { static: true }) form: MyEntityForm;.

Constructor

Pass the entity class, the data service, and an options object to super():

constructor(injector: Injector, /* extra services */) {
  super(injector, MyEntity, injector.get(MyEntityService), {
    pathIdAttribute: 'myEntityId',   // route param holding the id
    tabCount: 2,                     // number of tabs
    i18nPrefix: 'MY_ENTITY.',        // i18n key prefix / i18nContext
    acquisitionLevel: AcquisitionLevelCodes.MY_ENTITY,
    strategyAcquisitionLevels: [/* ... */],
    settingsId: MyEntityPageSettingsEnum.PAGE_ID,
    enableListenChanges: true,
  });
  this.defaultBackHref = '/my-entities';
}

Key methods to implement / override

  • registerForms() (required): register every child form/table so the editor tracks their dirty/valid state:
    protected registerForms() {
      this.addForms([this.myEntityForm, this.measurementsForm, this.childTable]);
    }
    
  • get form() (required): return the main FormGroup (usually this.myEntityForm.form);
  • setValue(data) (required): dispatch the loaded entity into each child form/table. Await async children with Promise.all([...]);
  • getValue(): call await super.getValue() then merge values pulled from child forms/tables back into the entity;
  • getJsonValueToSave(): call await super.getJsonValueToSave() then patch the JSON sent to the server (e.g. nested sub-entities);
  • computeTitle(data) (required): return the page title (translate MY_ENTITY.NEW.TITLE for new data, MY_ENTITY.EDIT.TITLE otherwise);
  • getFirstInvalidTabIndex() (required): return the index of the first tab whose form/table is invalid (or -1);
  • onNewEntity(data, opts): set defaults for a new entity (dates, program, vessel from the search filter/context, tab visibility), then markAsReady() or markAsDirty();
  • onEntityLoaded(data, opts): post-load setup (state connections, permissions);
  • updateViewState(data, opts): call super, then show/hide tabs (updateTabsState(data));
  • canUserWrite(data, opts): permission check (delegates to the data service).

Lifecycle

  • ngOnInit(): call super.ngOnInit(), then wire reactive state via this._state.connect('key', source$) (e.g. from child form value changes) and this._state.hold(...) for side effects;
  • The base class drives the flow automatically: route id → load() → onNewEntity() / onEntityLoaded() → setValue() → updateView(). On save: getValue() → getJsonValueToSave() → service.save() → onEntitySaved();
  • ngOnDestroy(): call super.ngOnDestroy() and clean up custom subscriptions.

Quality lifecycle

For root entities, the control/terminate/validate logic lives in the service (see service-quality rules); the page exposes the toolbar buttons that call it. Some child editors implement IDataEntityQualityService<T> directly.

Reference examples

  • Root data editor: src/app/trip/trip/trip.page.ts, src/app/trip/observedlocation/observed-location.page.ts, src/app/trip/landing/landing.page.ts;
  • Data (child) editor: src/app/trip/operation/operation.page.ts;
  • Base classes: src/app/data/form/data-editor.class.ts, src/app/data/form/root-data-editor.class.ts.