đź“Ś Add to a frontend library a custom Cadmus model (part) with its editor.

  1. app
  2. libraries
  3. parts
  4. fragments (optional)

Adding Part

(1) in your library, under src/lib, add the part model file, named <PARTNAME>.ts (e.g. cod-bindings-part.ts), like this:

import { Part } from "@myrmidon/cadmus-core";

/**
 * The __NAME__ part model.
 */
export interface __NAME__Part extends Part {
  // TODO: add properties
}

/**
 * The type ID used to identify the __NAME__Part type.
 */
export const __NAME___PART_TYPEID = "it.vedph.__PRJ__.__NAME__";

/**
 * JSON schema for the __NAME__ part.
 * You can use the JSON schema tool at https://jsonschema.net/.
 */
export const __NAME___PART_SCHEMA = {
  $schema: "http://json-schema.org/draft-07/schema#",
  $id:
    "www.vedph.it/cadmus/parts/__PRJ__/__LIB__/" +
    __NAME___PART_TYPEID +
    ".json",
  type: "object",
  title: "__NAME__Part",
  required: [
    "id",
    "itemId",
    "typeId",
    "timeCreated",
    "creatorId",
    "timeModified",
    "userId",
    // TODO: add other required properties here...
  ],
  properties: {
    timeCreated: {
      type: "string",
      pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}.\\d+Z$",
    },
    creatorId: {
      type: "string",
    },
    timeModified: {
      type: "string",
      pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}.\\d+Z$",
    },
    userId: {
      type: "string",
    },
    id: {
      type: "string",
      pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
    },
    itemId: {
      type: "string",
      pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
    },
    typeId: {
      type: "string",
      pattern: "^[a-z][-0-9a-z._]*$",
    },
    roleId: {
      type: ["string", "null"],
      pattern: "^([a-z][-0-9a-z._]*)?$",
    },

    // TODO: add properties and fill the "required" array as needed
  },
};

If you want to infer a schema in the JSON schema tool, which is usually the quickest way of writing the schema, you can use this JSON template, adding your model’s properties to it:

{
  "id": "009dcbd9-b1f1-4dc2-845d-1d9c88c83269",
  "itemId": "2c2eadb7-1972-4415-9a43-b8036b6fa685",
  "typeId": "it.vedph.thetype",
  "roleId": null,
  "timeCreated": "2019-11-29T16:48:49.694Z",
  "creatorId": "zeus",
  "timeModified": "2019-11-29T16:48:49.694Z",
  "userId": "zeus",
  "TODO": "add properties here"
}

(2) add the new file to the exports of the “barrel” public-api.ts file in the module, like export * from './lib/<NAME>-part';.

Adding UI Editor

This is the part editor UI, a dumb component which essentially uses a form to represent the data of a part’s model. These data are adapted to the form when loading them, and converted back to the part’s model when saving.

(1) in src/lib, add a part editor dumb component named after the part (e.g. ng g component note-part for NotePartComponent after NotePart), and extending ModelEditorComponentBase<T> where T is the part’s type. Here we usually have two cases: a generic part, or a part consisting only of a list of entities. Two different templates are provided here.

Generic Part Editor Template

(1) write code and HTML template:

// NAME-part.component.ts

import { Component, OnInit } from '@angular/core';
import {
  FormControl,
  FormBuilder,
  Validators,
  FormGroup,
  UntypedFormGroup,
} from '@angular/forms';

import { AuthJwtService } from '@myrmidon/auth-jwt-login';
import { ThesauriSet, ThesaurusEntry } from '@myrmidon/cadmus-core';
import { EditedObject, ModelEditorComponentBase } from '@myrmidon/cadmus-ui';

import { __NAME__Part, __NAME___PART_TYPEID } from '../__NAME__-part';

/**
 * __NAME__ part editor component.
 * Thesauri: ...TODO list of thesauri IDs...
 */
@Component({
  selector: 'cadmus-__NAME__-part',
  templateUrl: './__NAME__-part.component.html',
  styleUrls: ['./__NAME__-part.component.css'],
})
export class __NAME__PartComponent
  extends ModelEditorComponentBase<__NAME__Part>
  implements OnInit
{
  // TODO: add your form controls here, e.g.:
  // public tag: FormControl<string | null>;
  // public text: FormControl<string | null>;

  // TODO: add your thesauri entries here, e.g.:
  // public tagEntries?: ThesaurusEntry[];

  constructor(authService: AuthJwtService, formBuilder: FormBuilder) {
    super(authService, formBuilder);
    // form
    // TODO: create your form controls (but NOT the form itself), e.g.:
    // this.tag = formBuilder.control(null, Validators.maxLength(100));
    // this.text = formBuilder.control(null, Validators.required);
  }

  public override ngOnInit(): void {
    super.ngOnInit();
  }

  protected buildForm(formBuilder: FormBuilder): FormGroup | UntypedFormGroup {
    return formBuilder.group({
      // TODO: assign your created form controls to the form returned here, e.g.:
      // tag: this.tag,
      // text: this.text,
    });
  }

  private updateThesauri(thesauri: ThesauriSet): void {
    // TODO: setup thesauri entries here, e.g.:
    // const key = 'note-tags';
    // if (this.hasThesaurus(key)) {
    //  this.tagEntries = thesauri[key].entries;
    // } else {
    //  this.tagEntries = undefined;
    // }
  }

  private updateForm(part?: __NAME__Part | null): void {
    if (!part) {
      this.form.reset();
      return;
    }
    // TODO: set values of your form controls, e.g.:
    // this.tag.setValue(part.tag || null);
    // this.text.setValue(part.text);
    this.form.markAsPristine();
  }

  protected override onDataSet(data?: EditedObject<__NAME__Part>): void {
    // thesauri
    if (data?.thesauri) {
      this.updateThesauri(data.thesauri);
    }

    // form
    this.updateForm(data?.value);
  }

  protected getValue(): __NAME__Part {
    let part = this.getEditedPart(__NAME___PART_TYPEID) as __NAME__Part;
    // TODO: assign values to your part properties from form controls, e.g.:
    // part.tag = this.tag.value || undefined;
    // part.text = this.text.value?.trim() || '';
    return part;
  }
}

HTML template:

<!-- NAME-part.component.html -->

<form [formGroup]="form" (submit)="save()">
  <mat-card>
    <mat-card-header>
      <div mat-card-avatar>
        <mat-icon>picture_in_picture</mat-icon>
      </div>
      <mat-card-title>__NAME__ Part</mat-card-title>
    </mat-card-header>
    <mat-card-content> TODO: your template here... </mat-card-content>
    <mat-card-actions>
      <cadmus-close-save-buttons
        [form]="form"
        [noSave]="userLevel < 2"
        (closeRequest)="close()"
      ></cadmus-close-save-buttons>
    </mat-card-actions>
  </mat-card>
</form>

(2) ensure the component has been added to the public-api.ts barrel file, and, if using modules, to the library module’s declarations and exports. The latter is not required with standalone components.

List Part Editor Template

(1) write code and HTML template:

// NAME-part.component.ts

import { Component, OnInit } from '@angular/core';
import {
  FormControl,
  FormBuilder,
  FormGroup,
  UntypedFormGroup,
} from '@angular/forms';
import { take } from 'rxjs/operators';

import { NgToolsValidators } from '@myrmidon/ng-tools';
import { DialogService } from '@myrmidon/ng-mat-tools';
import { AuthJwtService } from '@myrmidon/auth-jwt-login';
import { EditedObject, ModelEditorComponentBase } from '@myrmidon/cadmus-ui';
import { ThesauriSet, ThesaurusEntry } from '@myrmidon/cadmus-core';

import {
  __NAME__,
  __NAME__sPart,
  __NAME__S_PART_TYPEID,
} from '../__NAME__s-part';

/**
 * __NAME__sPart editor component.
 * Thesauri: ...TODO list of thesauri IDs...
 */
@Component({
  selector: 'cadmus-__NAME__s-part',
  templateUrl: './__NAME__s-part.component.html',
  styleUrls: ['./__NAME__s-part.component.css'],
})
export class __NAME__sPartComponent
  extends ModelEditorComponentBase<__NAME__sPart>
  implements OnInit
{
  private _editedIndex: number;

  public tabIndex: number;
  public edited: __NAME__ | undefined;

  // TODO: add your thesauri entries here, e.g.:
  // cod-binding-tags
  // public tagEntries: ThesaurusEntry[] | undefined;

  public entries: FormControl<__NAME__[]>;

  constructor(
    authService: AuthJwtService,
    formBuilder: FormBuilder,
    private _dialogService: DialogService
  ) {
    super(authService, formBuilder);
    this._editedIndex = -1;
    this.tabIndex = 0;
    // form
    this.entries = formBuilder.control([], {
      // at least 1 entry
      validators: NgToolsValidators.strictMinLengthValidator(1),
      nonNullable: true,
    });
  }

  public override ngOnInit(): void {
    super.ngOnInit();
  }

  protected buildForm(formBuilder: FormBuilder): FormGroup | UntypedFormGroup {
    return formBuilder.group({
      entries: this.entries,
    });
  }

  private updateThesauri(thesauri: ThesauriSet): void {
    // TODO setup your thesauri entries here, e.g.:
    // let key = 'cod-binding-tags';
    // if (this.hasThesaurus(key)) {
    //   this.tagEntries = thesauri[key].entries;
    // } else {
    //   this.tagEntries = undefined;
    // }
  }

  private updateForm(part?: __NAME__sPart | null): void {
    if (!part) {
      this.form.reset();
      return;
    }
    this.entries.setValue(part.__NAME__s || []);
    this.form.markAsPristine();
  }

  protected override onDataSet(data?: EditedObject<__NAME__sPart>): void {
    // thesauri
    if (data?.thesauri) {
      this.updateThesauri(data.thesauri);
    }

    // form
    this.updateForm(data?.value);
  }

  protected getValue(): __NAME__sPart {
    let part = this.getEditedPart(__NAME__S_PART_TYPEID) as __NAME__sPart;
    part.__NAME__s = this.entries.value || [];
    return part;
  }

  public add__NAME__(): void {
    const entry: __NAME__ = {
      // TODO: set your entry default properties...
    };
    this.edit__NAME__(entry, -1);
  }

  public edit__NAME__(entry: __NAME__, index: number): void {
    this._editedIndex = index;
    this.edited = entry;
    setTimeout(() => {
      this.tabIndex = 1;
    });
  }

  public close__NAME__(): void {
    this._editedIndex = -1;
    this.edited = undefined;
    setTimeout(() => {
      this.tabIndex = 0;
    });
  }

  public save__NAME__(entry: __NAME__): void {
    const entries = [...this.entries.value];
    if (this._editedIndex === -1) {
      entries.push(entry);
    } else {
      entries.splice(this._editedIndex, 1, entry);
    }
    this.entries.setValue(entries);
    this.entries.markAsDirty();
    this.entries.updateValueAndValidity();
    this.close__NAME__();
  }

  public delete__NAME__(index: number): void {
    this._dialogService
      .confirm('Confirmation', 'Delete __NAME__?')
      .subscribe((yes: boolean | undefined) => {
        if (yes) {
          if (this._editedIndex === index) {
            this.close__NAME__();
          }
          const entries = [...this.entries.value];
          entries.splice(index, 1);
          this.entries.setValue(entries);
          this.entries.markAsDirty();
          this.entries.updateValueAndValidity();
        }
      });
  }

  public move__NAME__Up(index: number): void {
    if (index < 1) {
      return;
    }
    const entry = this.entries.value[index];
    const entries = [...this.entries.value];
    entries.splice(index, 1);
    entries.splice(index - 1, 0, entry);
    this.entries.setValue(entries);
    this.entries.markAsDirty();
    this.entries.updateValueAndValidity();
  }

  public move__NAME__Down(index: number): void {
    if (index + 1 >= this.entries.value.length) {
      return;
    }
    const entry = this.entries.value[index];
    const entries = [...this.entries.value];
    entries.splice(index, 1);
    entries.splice(index + 1, 0, entry);
    this.entries.setValue(entries);
    this.entries.markAsDirty();
    this.entries.updateValueAndValidity();
  }
}

HTML template:

<!-- NAME-part.component.html -->

<form [formGroup]="form" (submit)="save()">
  <mat-card>
    <mat-card-header>
      <div mat-card-avatar>
        <mat-icon>picture_in_picture</mat-icon>
      </div>
      <mat-card-title>__NAME__s Part</mat-card-title>
    </mat-card-header>
    <mat-card-content>
      <mat-tab-group [(selectedIndex)]="tabIndex">
        <mat-tab label="__NAME__s">
          <div>
            <button
              type="button"
              mat-flat-button
              color="primary"
              (click)="add__NAME__()"
            >
              <mat-icon>add_circle</mat-icon> __NAME__
            </button>
          </div>
          @if (entries.value.length) {
          <table>
            <thead>
              <tr>
                <th></th>
                TODO: add model properties
              </tr>
            </thead>
            <tbody>
              @for (entry of entries.value; track entry; let i = $index; let first =
          $first; let last = $last) {
              <tr>
                <td class="fit-width">
                  <button
                    type="button"
                    mat-icon-button
                    color="primary"
                    matTooltip="Edit this __NAME__"
                    (click)="edit__NAME__(entry, i)"
                  >
                    <mat-icon class="mat-primary">edit</mat-icon>
                  </button>
                  <button
                    type="button"
                    mat-icon-button
                    matTooltip="Move this __NAME__ up"
                    [disabled]="first"
                    (click)="move__NAME__Up(i)"
                  >
                    <mat-icon>arrow_upward</mat-icon>
                  </button>
                  <button
                    type="button"
                    mat-icon-button
                    matTooltip="Move this __NAME__ down"
                    [disabled]="last"
                    (click)="move__NAME__Down(i)"
                  >
                    <mat-icon>arrow_downward</mat-icon>
                  </button>
                  <button
                    type="button"
                    mat-icon-button
                    color="warn"
                    matTooltip="Delete this __NAME__"
                    (click)="delete__NAME__(i)"
                  >
                    <mat-icon class="mat-warn">remove_circle</mat-icon>
                  </button>
                </td>
                TODO: td's for properties
              </tr>
              }
            </tbody>
          </table>
          }
        </mat-tab>

        <mat-tab label="__NAME__" *ngIf="edited">
          TODO: editor control with: [model]="edited"
          (modelChange)="save__NAME__($event)"
          (editorClose)="close__NAME__()"
        </mat-tab>
      </mat-tab-group>
    </mat-card-content>
    <mat-card-actions>
      <cadmus-close-save-buttons
        [form]="form"
        [noSave]="userLevel < 2"
        (closeRequest)="close()"
      ></cadmus-close-save-buttons>
    </mat-card-actions>
  </mat-card>
</form>

CSS styles:

td.fit-width {
  width: 1px;
  white-space: nowrap;
}

Typically you should edit each single entry in a component (generated with ng g component <NAME>-editor where NAME is the model’s name, e.g. cod-binding-editor for the cod-bindings-part component - remember to export it both from the library’s module and from its barrel public-api.ts file), similar to the following template (rename model as you prefer):

import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import {
  FormArray,
  FormBuilder,
  FormControl,
  FormGroup,
  Validators,
} from '@angular/forms';
import { ThesaurusEntry } from '@myrmidon/cadmus-core';

@Component({
  selector: 'cadmus-__PRJ__-__NAME__',
  templateUrl: './__NAME__.component.html',
  styleUrls: ['./__NAME__.component.css'],
})
export class __NAME__Component implements OnInit {
  // TODO rename model into something more specific
  private _model?: __TYPE__;

  @Input()
  public get model(): __TYPE__ | undefined | null {
    return this._model;
  }
  public set model(value: __TYPE__ | undefined | null) {
    if (this._model !== value) {
      this._model = value || undefined;
      this.updateForm(value);
    }
  }

  @Output()
  public readonly modelChange: EventEmitter<__TYPE__> =
    new EventEmitter<__TYPE__>();
  @Output()
  public readonly modelCancel: EventEmitter<void> =
    new EventEmitter<void>();

  // TODO: controls
  public form: FormGroup;

  constructor(formBuilder: FormBuilder) {
    // form
    // TODO: create controls

    this.form = formBuilder.group({
      // TODO: add controls to form model
    });
  }

  private updateForm(model: __TYPE__ | undefined | null): void {
    if (!model) {
      this.form.reset();
      return;
    }

    // TODO set controls values

    this.form.markAsPristine();
  }

  private getModel(): __TYPE__ {
    return {
      // TODO get values from controls
    };
  }

  public cancel(): void {
    this.modelCancel.emit();
  }

  public save(): void {
    if (this.form.invalid) {
      return;
    }
    this._model = this.getModel();
    this.modelChange.emit(this._model);
  }
}

HTML template:

<form [formGroup]="form" (submit)="save()">
  TODO
  <!-- buttons -->
  <div>
    <button
      type="button"
      mat-icon-button
      matTooltip="Discard changes"
      (click)="cancel()"
    >
      <mat-icon class="mat-warn">clear</mat-icon>
    </button>
    <button
      type="submit"
      mat-icon-button
      matTooltip="Accept changes"
      [disabled]="form.invalid || form.pristine"
    >
      <mat-icon class="mat-primary">check_circle</mat-icon>
    </button>
  </div>
</form>

(2) ensure the component has been added to its module’s declarations and exports, and to the public-api.ts barrel file.

Adding PG Editor Wrapper

Once you have the part editor, you need its wrapper page, which in turn is linked to a specific route in the context of the project. Such route is defined in the part’s library when using a granular approach, or in the general PG library when using a multiple-editors approach.

(1) under your library’s src/lib folder, add a part editor feature component named after the part (e.g. ng g component note-part-feature for NotePartFeatureComponent after NotePart).

(2) ensure that this component is exported from the public-api.ts barrel file, and from the module. If you are using standalone components, import them in the module imports, and export them from module’s exports. Otherwise, import them in the module declarations.

(3) add the corresponding route in the PG library’s module, e.g.:

export const RouterModuleForChild = RouterModule.forChild([
  // TODO your part route
  {
     path: `${__NAME___PART_TYPEID}/:pid`,
     pathMatch: 'full',
     component: __NAME__PartFeatureComponent,
     canDeactivate: [PendingChangesGuard]
  },
]);

@NgModule({
  declarations: [
    __NAME__PartFeatureComponent
  ],
  imports: [
    CommonModule,
    FormsModule,
    ReactiveFormsModule,
    // Cadmus
    RouterModuleForChild,
    // other imports here, unless using standalone:
    CadmusCoreModule,
    CadmusStateModule,
    CadmusUiModule,
    CadmusUiPgModule,
  ],
  exports: [
    __NAME__PartFeatureComponent
  ],
})
export class CadmusPart__PRJ__PgModule {}

(4) implement the feature editor component by making it extend EditPartFeatureBase, like in this code template:

4A: for a standalone component (which is now the default):

import { Component, OnInit } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router, ActivatedRoute } from '@angular/router';

import { ItemService, ThesaurusService } from '@myrmidon/cadmus-api';
import { EditPartFeatureBase, PartEditorService } from '@myrmidon/cadmus-state';
import { CadmusUiPgModule } from '@myrmidon/cadmus-ui-pg';

import { __NAME__PartComponent } from '@myrmidon/cadmus-lon-part-ui';

@Component({
  selector: 'cadmus-__NAME__-part-feature',
  standalone: true,
  imports: [CadmusUiPgModule, __NAME__PartComponent],
  templateUrl: './__NAME__-part-feature.component.html',
  styleUrl: './__NAME__-part-feature.component.css',
})
export class __NAME__PartFeatureComponent
  extends EditPartFeatureBase
  implements OnInit
{
  constructor(
    router: Router,
    route: ActivatedRoute,
    snackbar: MatSnackBar,
    itemService: ItemService,
    thesaurusService: ThesaurusService,
    editorService: PartEditorService
  ) {
    super(
      router,
      route,
      snackbar,
      itemService,
      thesaurusService,
      editorService
    );
  }

  protected override getReqThesauriIds(): string[] {
    // TODO: return the IDs of all the thesauri required by the wrapped editor, e.g.:
    return ['note-tags'];
    // or just avoid overriding the function if no thesaurus required
  }
}

4B: for a non-standalone component (this is for legacy code, new projects use standalone):

import { Component, OnInit } from '@angular/core';
import { Router, ActivatedRoute } from '@angular/router';
import { MatSnackBar } from '@angular/material/snack-bar';

import { EditPartFeatureBase, PartEditorService } from '@myrmidon/cadmus-state';
import { ItemService, ThesaurusService } from '@myrmidon/cadmus-api';

@Component({
  selector: 'cadmus-__NAME__-part-feature',
  templateUrl: './__NAME__-part-feature.component.html',
  styleUrl: './__NAME__-part-feature.component.css',
})
export class __NAME__PartFeatureComponent
  extends EditPartFeatureBase
  implements OnInit
{
  constructor(
    router: Router,
    route: ActivatedRoute,
    snackbar: MatSnackBar,
    itemService: ItemService,
    thesaurusService: ThesaurusService,
    editorService: PartEditorService
  ) {
    super(
      router,
      route,
      snackbar,
      itemService,
      thesaurusService,
      editorService
    );
  }

  protected override getReqThesauriIds(): string[] {
    // TODO: return the IDs of all the thesauri required by the wrapped editor, e.g.:
    return ['note-tags'];
    // or just avoid overriding the function if no thesaurus required
  }
}

The HTML template just wraps the UI editor preceded by a current-item bar:

<cadmus-current-item-bar></cadmus-current-item-bar>
<cadmus-__NAME__-part
  [identity]="identity"
  [data]="$any(data)"
  (dataChange)="save($event)"
  (editorClose)="close()"
  (dirtyChange)="onDirtyChange($event)"
/>

(5) in your app’s project part-editor-keys.ts, add the mapping for the part just created, like e.g.:

// this constant refers to the project-dependent portion of the route path
// (items/:iid/__PRJ__) in routes definitions
const ITINERA_LT = 'itinera_lt';

// itinera parts example
[PERSON_PART_TYPEID]: {
  part: ITINERA_LT
},

Here, the type ID of the part (from its model in the “ui” library) is mapped to the route prefix constant ITINERA_LT = itinera-lt, which is the root route to the “pg” library module for the app.

⚠️ Ensure that your app routes (usually app.routes.ts) include the PG library as a lazily loaded module, e.g.:

// cadmus - lon parts
{
  path: 'items/:iid/__PRJ__',
  loadChildren: () =>
    import('@myrmidon/cadmus-__PRJ__-part-pg').then(
      (module) => module.Cadmus__PRJ__PartPgModule
    ),
  canActivate: [AuthJwtGuardService],
},

🏠 developer’s home

▶️ next: fragments