@processpuzzle/base-entity
v1.2.2
Published
 [](https://so
Downloads
1,066
Readme
@processpuzzle/base-entity
Introduction
@processpuzzle/base-entity is a run-time form and table generator for Angular, driven by two descriptor objects: a Base Entity Descriptor that describes the entity (and its related entities), and Base Entity Attribute Descriptors that specify how each attribute is presented.
From these Inputs:
- Entity Descriptor – describes the subject entity and (optionally) its linked entities.
- Entity Attribute Descriptors – specify the presentation style of each attribute.
the library generates these Outputs:
- A Reactive Angular Form built dynamically at run-time.
- An Angular Material Table built dynamically at run-time.
- A descriptor-aware RSQL search with an assisted advanced query editor (see RSQL search).
- A client-side PDF export of the table, driven by the same descriptors (see PDF export).
The diagram below shows the main classes the library exposes:
- BaseEntity – interface your custom entity implements (only requires an
id). - BaseEntityRestService / BaseEntityFirestoreService – concrete CRUD services to extend; both implement the
BaseEntityServicecontract. - BaseEntityMapper – interface for translating between your DTO and your entity.
SimpleEntityMappercovers the trivial case. - BaseEntityStore –
@ngrx/signalsstore feature backing the generated components. Composes withBaseEntityTabsStore,BaseEntityContainerStore, and the singletonBaseFormNavigatorStore. - BaseEntityFacade – optional one-stop class that wires entity, mapper, service, store, and descriptor together.
- BaseEntityContainerComponent – host component that renders the table and the form together.
- BaseEntityListComponent – Angular Material table of your entities.
- BaseEntityFormComponent – reactive form for CRUD on a single entity.
Usage
To plug the library into your application you provide an entity class, a mapper, a service, a store, and a descriptor. Each step is a small amount of configuration — most of the behavior comes from the base classes.
The snippets below are taken from the TestEntity sample in the testbed.
1. Define your entity
BaseEntity only requires id: string. Anything else is yours to model freely.
export class TestEntity implements BaseEntity {
readonly id: string;
private name: string;
private description: string | undefined;
private boolean: boolean;
private number: number;
private date: Date;
private lookup: string;
private enumValue: TestEnum;
private artifact?: ArtifactAttr;
private tags?: Array<string>;
private components?: Array<TestEntityComponent>;
constructor(id?: string, name?: string /* … */) {
this.id = id ?? uuidv4();
// …
}
}2. Provide a mapper
A mapper translates between the DTO returned by the backend and your entity class. SimpleEntityMapper works when no translation is needed; otherwise implement BaseEntityMapper<Entity> and use the exported getEnumKeyByValue / getEnumValueByKey helpers for enum round-tripping. Child entities are mapped by delegating to their own mappers.
@Injectable({ providedIn: 'root' })
export class TestEntityMapper implements BaseEntityMapper<TestEntity> {
private readonly componentMapper = inject(TestEntityComponentMapper);
fromDto(dto: any): TestEntity {
return new TestEntity(
dto.id, dto.name, dto.description, dto.boolean, dto.number, dto.date, dto.lookup,
getEnumKeyByValue<TestEnum>(TestEnum, dto.enumValue),
dto.artifact,
dto.tags,
dto.components?.map((c: any) => this.componentMapper.fromDto(c)),
);
}
toDto(entity: TestEntity): any {
const dto = { ...entity } as any;
return { ...dto, enumValue: getEnumValueByKey<TestEnum>(TestEnum, dto.enumValue) };
}
}3. Extend the data service
Pick BaseEntityRestService for an HTTP backend or BaseEntityFirestoreService for Firestore. Pass the mapper, a configuration key that resolves to the backend root URL, and the resource path.
@Injectable({ providedIn: 'root' })
export class TestEntityService extends BaseEntityRestService<TestEntity> {
constructor(protected override entityMapper: TestEntityMapper) {
super(entityMapper, 'BACKEND_SERVICE_ROOT', 'test-entity');
}
}You can add custom data-access methods on top of the inherited CRUD operations.
4. Compose a signal store
The generated components read and write through an @ngrx/signals store. BaseEntityStore provides CRUD state and the Material table data source; the other features add tab state, container filtering, and form-navigation behavior.
export const TestEntityStore = signalStore(
{ providedIn: 'root' },
BaseEntityStore<TestEntity>(TestEntity, () => inject(TestEntityService)),
BaseEntityTabsStore(),
BaseEntityContainerStore(),
);BaseFormNavigatorStore is a singleton that you do not add per entity — inject it where you need navigation between list and details views.
5. Describe the attributes
Each visible field gets a BaseEntityAttrDescriptor whose FormControlType picks the control (full list and behavior in Control types). Use FlexboxDescriptor to lay attributes out in rows and columns, and linkedEntityType (a string — the related entity's name) to point at a related entity (used by LOOKUP, FOREIGN_KEY, and nested COMPONENTS). The descriptor is resolved at runtime through BASE_ENTITY_FACADE_REGISTRY.
function createTestEntityAttrDescriptors(): AbstractAttrDescriptor[] {
const nameAttr = new BaseEntityAttrDescriptor('name', FormControlType.TEXT_BOX, 'Name', undefined, true);
nameAttr.required = true;
const descriptionAttr = new BaseEntityAttrDescriptor('description', FormControlType.TEXTAREA, 'Description');
const booleanAttr = new BaseEntityAttrDescriptor('boolean', FormControlType.CHECKBOX, 'Boolean');
const numberAttr = new BaseEntityAttrDescriptor('number', FormControlType.TEXT_BOX, 'Number', undefined, false, { inputType: 'number' });
const dateAttr = new BaseEntityAttrDescriptor('date', FormControlType.DATE, 'Date', undefined, false, { inputType: 'date' });
const lookupAttr = new BaseEntityAttrDescriptor('lookup', FormControlType.LOOKUP, 'Lookup');
const enumAttr = new BaseEntityAttrDescriptor('enumValue', FormControlType.DROPDOWN, 'Enum', selectables);
const componentsAttr = new BaseEntityAttrDescriptor('components', FormControlType.COMPONENTS, 'Components');
lookupAttr.linkedEntityType = 'Trunk Data';
componentsAttr.linkedEntityType = 'Test Entity Component';
const column1 = new FlexboxDescriptor([nameAttr, descriptionAttr, booleanAttr], FlexDirection.COLUMN);
const column2 = new FlexboxDescriptor([numberAttr, dateAttr, lookupAttr, enumAttr, componentsAttr], FlexDirection.COLUMN);
const layout = new FlexboxDescriptor([column1, column2], FlexDirection.CONTAINER);
layout.style = { 'column-gap': '20px' };
return [layout];
}
export function createTestEntityDescriptor(): BaseEntityDescriptor {
return new BaseEntityDescriptor({ entityName: 'Test Entity', attrDescriptors: createTestEntityAttrDescriptors() });
}A few notes:
linkedEntityTypeis just the name of the related entity. The actualBaseEntityDescriptoris resolved at runtime through the facade registered inBASE_ENTITY_FACADE_REGISTRY.BaseEntityDescriptortakes an options object ({ entityName, attrDescriptors, store?, entityTitle? });storeandentityTitleare usually set later in the host component.
6. Render the container
Pass the descriptor to BaseEntityContainerComponent and attach the store. The container then drives both the list and the form views.
@Component({
selector: 'test-entity-container',
standalone: true,
imports: [BaseEntityContainerComponent],
template: `<base-entity-container [entityDescriptor]="baseEntityDescriptor"></base-entity-container>`,
})
export class TestEntityContainerComponent {
private store = inject(TestEntityStore);
baseEntityDescriptor: BaseEntityDescriptor;
constructor() {
this.baseEntityDescriptor = createTestEntityDescriptor();
this.baseEntityDescriptor.store = this.store;
this.baseEntityDescriptor.entityTitle = () => this.store.currentEntity()?.name ?? '';
}
}Optional: bundle everything in a Facade
When you have many entities, BaseEntityFacade centralises the wiring (mapper, service, store, descriptor) so a single token can drive routed views. Extend it and declare entityType, entityName, and attrDescriptors; override the create… hooks to return your concrete classes.
@Injectable()
export class TestEntityFacade extends BaseEntityFacade<TestEntity> {
readonly entityType = TestEntity;
readonly entityName = 'Test Entity';
readonly attrDescriptors = createTestEntityDescriptor().attrDescriptors;
private readonly mapperRef = inject(TestEntityMapper);
private readonly serviceRef = inject(TestEntityService);
protected override createMapper() { return this.mapperRef; }
protected override createService() { return this.serviceRef; }
protected override createStoreClass(): Type<unknown> { return TestEntityStore; }
}The facade can then be provided through the ACTIVE_ENTITY_FACADE token, and BaseEntityContainerComponent will resolve its descriptor automatically — no per-entity container component required.
Control types
Every attribute's FormControlType selects the component that renders it in the generated form. The builder creates a reactive FormControl under attrName for each descriptor and instantiates the mapped component; an unmapped type throws Undefined form control type.
| Type | Renders | Value shape | Required props | Notes |
| --- | --- | --- | --- | --- |
| TEXT_BOX | matInput text field | string | label | options.inputType sets the HTML input type (e.g. number). |
| TEXTAREA | matInput textarea | string | label | lines sets the row count; placeholder the hint. |
| CHECKBOX | mat-checkbox | boolean | label | |
| DATE | matInput + mat-datepicker | Date | label | Format is governed by the ambient Material date adapter. |
| DROPDOWN | mat-select | selected option's value | selectables | Options come from selectables (array or () => Selectable[]). |
| RADIO | native radio group | selectable key | selectables | Reactive; stores the key of the chosen option. |
| TITLE | <h2> section heading (display only) | — | label | Renders label as a form section title. |
| TAGS | mat-chip-grid | string[] | label | ENTER/COMMA add a chip; chips are editable/removable. |
| LABEL | <h3>/<p> (display only) | string | label | isHeading renders a heading instead of a paragraph. |
| FLEX_BOX | layout container | — (no control) | uses FlexboxDescriptor | Grouping only; recurses into children. See below. |
| LOOKUP | autocomplete over a lookup table | lookup key (string) | linkedEntityType | Tricky — see below. |
| FOREIGN_KEY | read-only field + Select/link buttons | related entity id (string) | linkedEntityType | To-one reference; picks via the navigator. |
| COMPONENTS | list of related-entity rows | array of related ids | linkedEntityType, referenceIdField | To-many reference. |
| ARTIFACT | file thumbnail/icon + upload/delete | ArtifactAttr (or null) | label, showThumbnail | Tricky — see below. |
| ADDITIONAL_PROPERTIES | editable key/value list | Record<string,string> | label | Free-form string map. |
linkedEntityType is mandatory for LOOKUP, FOREIGN_KEY, and COMPONENTS — it names the related entity, whose descriptor is resolved at runtime through BASE_ENTITY_FACADE_REGISTRY; omitting it throws.
FLEX_BOX (layout)
FLEX_BOX uses a FlexboxDescriptor (not a BaseEntityAttrDescriptor) and carries no value. It nests child descriptors and a FlexDirection (CONTAINER / COLUMN / ROW); the builder recurses, rendering the children into the same form group. Use it to arrange fields in rows and columns (see the createTestEntityAttrDescriptors example above).
The tricky ones
LOOKUPuses a two-control design: the visible autocomplete text is a privatedisplayControldecoupled from the realFormControl, which stores the lookup key. Aneffect()keeps them in sync. The lookup table ({ key, value, description? }) is loaded on init from the store resolved vialinkedEntityType; the display showsvalue, the form holdskey. The link icon navigates to the related entity.FOREIGN_KEYshows the related entity's identifying text (read-only) with the real id in a hidden control. The Select button snapshots the form and navigates to the related list (SELECT_OR_CREATE); on return the chosen id is written back to both the entity and the control. A link icon navigates to the current reference.COMPONENTSis the to-many analogue: it normalizes heterogeneous items (strings/objects) into{ id }usingreferenceIdField, appends via the same navigator round-trip, and each row has a link and a delete button.ARTIFACTbinds anArtifactAttr({ bucket, objectId, name, mimeType }). It fetches a thumbnail only forimage/*types whenshowThumbnail !== false, otherwise shows a MIME-type icon; upload delegates toArtifactSelectorComponentand delete actually removes the stored object (after a confirmation dialog). WithisHeadingit degrades to a plain heading. Requires anObjectStoreServiceprovider.
These value-editing controls (ARTIFACT, LOOKUP, FOREIGN_KEY, COMPONENTS, TAGS, ADDITIONAL_PROPERTIES) write to their FormControl imperatively (setValue + markAsDirty), which is why the form's Save button reacts to form.events rather than a plain dirty binding.
Note.
DATEdisplay/parse format follows Angular Material's ambient date adapter (MAT_DATE_FORMATS), configured app-wide rather than per field, so the descriptor'sformatproperty is not applied here.
Per-field styling
Two descriptor properties give a consumer per-attribute style control, applied via [ngClass]:
styleClass— class(es) on the field wrapper (fieldset/mat-form-field/ the radio container).labelClass— class(es) on the field label (legend/mat-label/ checkbox label).
There is also a style property bound with [ngStyle] for one-off inline styles.
const expr = new BaseEntityAttrDescriptor('expression', FormControlType.TEXTAREA, 'Expression');
expr.styleClass = 'monospace full-width';
expr.labelClass = 'muted';These classes must be defined in a global stylesheet (your app's
styles.scss/ thestylesarray), not in a component's encapsulated.css. The classes land on the library's DOM elements; Angular's emulated view encapsulation scopes a component's own styles to that component's elements, so a component-scoped.monospacerule would be applied to the field but never match it. Global styles (orViewEncapsulation.None) are the reliable seam — prefer this over reaching into the library's internal tags, which are not a stable contract.
Internationalisation
Entity names (shown in the tabs) and attribute labels (column headers and form labels) can be translated by the consuming application through Transloco. The library follows convention over configuration: the key root is derived from the entityName, and you only provide the matching translation files — no per-field wiring. When a key is missing, the raw entityName / label from the descriptor is used as the fallback, so translation is entirely opt-in.
1. Key root is derived from the entity name
The transloco key root is snake_underscore(entityName) — "Trunk Data" → trunk_data, "OrderLine" → order_line. No configuration is needed; just name the Transloco scope to match. Set i18nScope on the descriptor only to override the derived root (e.g. when the registered scope name differs from the convention, or when the derived root would collide with the library's own base_entity scope).
new BaseEntityDescriptor({
entityName: 'Trunk Data',
attrDescriptors: createTrunkDataAttrDescriptors(),
// key root → 'trunk_data' (derived); pass i18nScope only to override
});2. Provide the Transloco scope on the route
The scope must be provided where the components render (typically the routed feature). Always set alias explicitly — Transloco camelCases the default alias, which silently breaks names containing - or _.
{
path: 'trunk_data',
providers: [provideTranslocoScope({ scope: 'trunk_data', alias: 'trunk_data' })],
// …
}3. Add the translation files
Transloco loads scoped files from assets/i18n/<scope>/<lang>.json. Because the scope (= derived key root) already identifies the entity, keys are rooted at the scope (Transloco prepends the scope automatically, so the file content is flat — no entity wrapper):
- Entity name →
<scope>._self(the reserved_selfavoids colliding with the attribute keys) - Attribute label →
<scope>.<attrName>
where <attrName> is the attribute's attrName (a code identifier — no spaces, no display strings). An attribute's labelKey overrides its own segment (replaces attrName); use plain identifiers — spaces are not valid in Transloco keys.
// assets/i18n/trunk_data/en.json → keys trunk_data._self, trunk_data.key, …
{
"_self": "Trunk Data", // entity name in the tabs
"key": "Reference Key", // attribute labels, keyed by attrName
"description": "Description",
"value": "Value"
}The library's own strings
Everything the library renders that is not derived from a descriptor — toolbar labels and tooltips, PDF-export
and RSQL dialogs, and the list/details tab captions — lives in the base_entity scope shipped with the package
(assets/i18n/base_entity/<lang>.json, copied into your build). Provide it once on the route that hosts the
entity screens:
providers: [provideTranslocoScope({ scope: 'base_entity', alias: 'base_entity' })];The tab captions take the resolved entity name as a parameter, so translators control the word order:
"tabs": {
"list": "{{ entity }} - list", // → "Trunk Data - list"
"details": "{{ entity }} - details"
}Status-bar title
The status bar labels the selected entity with the value of one identifying attribute. That attribute
is titleKey on the descriptor, defaulting to the isLinkToDetails attribute (the same one
componentIdentification() returns). Set titleKey only to point at a different attribute; set the
entityTitle string/function to override the displayed text entirely.
new BaseEntityDescriptor({ entityName: 'Order Line', attrDescriptors, titleKey: 'productName' });
// status bar shows currentEntity().productNameRSQL search
The list toolbar carries two independent search inputs:
- Filter — a client-side, case-insensitive substring match over the rows already loaded into the Material table. Instant, but limited to the current page of data.
- Query — a server-side RSQL/FIQL expression. On Enter (or the ▶ button) the toolbar calls
store.load({ query }); the REST service forwards it to the backend as awhere=<rsql>request parameter (BaseEntityFirestoreServiceapplies the equivalent constraints). Clearing the query reloads the unfiltered list.
Query syntax
RSQL combines comparisons with logical operators:
| Kind | Tokens |
|------|--------|
| Comparison | == != =gt= =ge= =lt= =le= =in= =out= =like= |
| Logic | ; (AND) , (OR) ( … ) (grouping) |
| Values | unquoted, 'single'/"double" quoted, or a list field=in=(a,b,c) |
status==active;createdAt=gt='2026-01-01'
name=like='*foo*',priority=ge=3Advanced editor
The ✎ (edit_note) button in the query field opens the advanced query editor dialog — a CodeMirror-based editor (RsqlEditorDialog → RsqlQueryEditorComponent) that provides:
- Syntax highlighting and bracket matching for RSQL.
- Autocomplete of field names, the operators valid for each field's type, and enum values.
- Live linting — structural validation (paren balance, clause ordering, unterminated strings) plus semantic checks (unknown field, operator not allowed for the field's type). Apply stays disabled while the expression is invalid.
- A sample query inserted on Tab in an empty editor, plus a matching placeholder.
Both the autocomplete and the linter are driven by field metadata derived from the entity's descriptor by DescriptorBackedFieldMetadataProvider: each BaseEntityAttrDescriptor maps to an RSQL field whose type (and therefore its allowed operators) comes from the FormControlType — CHECKBOX→boolean, DATE→date, DROPDOWN/RADIO→enum (enum values taken from the attribute's selectables), a numeric/date TEXT_BOX input type→number/date, everything else→string. Presentation-only controls (ARTIFACT, COMPONENTS, FLEX_BOX, LABEL, TITLE) are excluded from the searchable fields.
To use the editor outside the entity toolbar, provide your own RsqlFieldMetadataProvider and bind RsqlQueryEditorComponent through a reactive FormControl (see query-editor/example-usage.ts).
PDF export
The list toolbar can export the current entities to a PDF entirely on the client — no backend round-trip. When the list view is active and the store holds at least one entity, BaseEntityToolbarComponent shows a PDF action (the familiar picture_as_pdf icon). It appears both as a toolbar button and, on small screens, as a menu item.
Clicking it opens a small options dialog (orientation, page size, page-footer toggle) — deliberately not a full layout editor — and then generates and downloads the file.
What ends up in the PDF is derived from the very same descriptors that drive the table:
- Columns come from the entity's
BaseEntityAttrDescriptors, flattened through nestedFlexboxDescriptors. Any attribute markedhideInTable = trueis dropped — so a field hidden from the list is also absent from the PDF. - Cell rendering is chosen from each attribute's
FormControlType:CHECKBOXbecomes✓/✗(centered),DATEis formatted as a locale date,TAGSare joined, andARTIFACTshows the artifact name. - The document title uses the descriptor's
entityTitle(falling back toentityName), with a record-count subtitle and page footers.
The heavy jspdf / jspdf-autotable dependencies are lazy-loaded on first export, so they never enter the initial bundle.
Programmatic use
The export is also usable outside the toolbar. The public API exposes PdfExportService, the entityDescriptorToPdfColumns mapper, the PdfExportOptionsDialog, and the PdfColumnDefinition / PdfExportOptions / PdfExportResult types.
private readonly pdfExport = inject(PdfExportService);
async export(descriptor: BaseEntityDescriptor, entities: BaseEntity[]) {
const columns = entityDescriptorToPdfColumns(descriptor.attrDescriptors);
const result = await this.pdfExport.export(entities as Record<string, unknown>[], columns, {
title: 'Test Entities',
filename: 'test-entity-export',
orientation: 'landscape',
});
// result: { success, filename, rowCount, error? }
}Column headers, cell text, and dialog labels are translated through the base_entity Transloco scope (keys under pdf_export).
