npm.io
19.0.1 • Published 2d ago

tuain-ng-forms-lib

Licence
MIT
Version
19.0.1
Deps
0
Vulns
0
Weekly
0

tuain-ng-forms-lib

UI-agnostic core of the Tuain platform's dynamic forms framework for Angular 17+.

It lets you build form-driven applications by rendering complete forms —fields, actions, tables and sections— from JSON definitions sent by the server, using a reactive programming model: each form is a class that declares how it reacts to UI events through callbacks, while the library takes care of instantiating the model, keeping the view in sync and orchestrating backend communication.

This library provides the domain model, the abstract base components and the services. It does not impose a UI library: concrete widgets are supplied by UI packages (e.g. tuain-ng-forms-ant with ng-zorro, tuain-ng-forms-ionic with Ionic) or by the application itself, by extending the base components.

Table of contents

  1. Purpose and architecture
  2. Services
  3. Forms API and the reactive approach
  4. Form lifecycle
  5. Form composition and element access
  6. Model objects vs. view components
  7. Using the library in an Angular project

1. Purpose and architecture

The goal is to leverage the construction of form-based applications: instead of hand-coding every screen, the server describes the form (fields, actions, tables, sections, states) as JSON and the library materializes it as a graph of live objects with reactive behavior. The application only writes the form's business logic (what to do when a field is validated, an action is executed, etc.).

The architecture is organized in layers:

┌──────────────────────────────────────────────────────────────────┐
│  PRESENTATION LAYER (concrete UI)                                 │
│  tuain-ng-forms-ant (ng-zorro) · tuain-ng-forms-ionic (Ionic)·App │
│  Widgets that extend the base components and supply the template  │
├──────────────────────────────────────────────────────────────────┤
│  BASE COMPONENTS LAYER (abstract, standalone)                     │
│  FieldComponent · ActionComponent · LibTableComponent · …         │
│  Sync model→view via signals; translate UI events                 │
├──────────────────────────────────────────────────────────────────┤
│  MODEL LAYER (domain)                                             │
│  FormStructureAndData · FieldDescriptor · FormAction · RecordTable │
│  Form structure + data + state (live objects)                     │
├──────────────────────────────────────────────────────────────────┤
│  SERVICES LAYER                                                   │
│  FormManager · EventManager · FileManagement · IconResolver · SSE │
│  Server definition/actions, navigation, events, files             │
└──────────────────────────────────────────────────────────────────┘
                              │
                              ▼
                    Angular 17+ · RxJS

Model hierarchy (each level adds capabilities):

FormPiece                 visibility, enablement, states, customAttributes
  └─ FormPiecePropagate   reactive attribute propagation (BehaviorSubject)
      ├─ FormElement      elementType + setAttr() with propagation
      │   ├─ FieldDescriptor   field: value, validation, options, errors
      │   ├─ FormAction        action: backend, inProgress, restrictions
      │   └─ RecordTable       table: columns, records, paging, filters
      ├─ RecordFormSection     section (activation/navigation)
      └─ RecordFormSubSection  subsection (groups elements)

FormStructureAndData      form container (fields + actions + tables + state)
  └─ BasicFormComponent   Angular component: lifecycle + callbacks + server

Every public entity also has a contract interface (IForm, IField, IAction, ITable, ITableColumn, ITableAction, ISection, …) implemented by the classes; applications may type against the contracts instead of the concrete classes.


2. Services

Services decouple the core from each application's specifics. They are provided via DI; some are abstract by design (the app implements them).

Service / Token Role App implements?
LibFormManagerService Fetches the form definition (getFormDefinition), runs server actions (execServerAction), navigates between forms and manages the navigation stack (openForm, backTo, stack/unstack). Yes (overrides the server/navigation methods)
LibEventManagerService The app's event bus (Subject / BehaviorSubject / ReplaySubject) for communication between forms and components. Instantiated with its set of events
LibFileManagementService File handling (openFile, saveFile, saveFileFromURL, printPdfFile) per platform (web/mobile). Yes
BaseIconResolverService + ICON_RESOLVER Extensible, multi-collection icon resolution (each UI registers its collections). Optional (extend/register)
SseLiveConnectionService + SSE_LIVE_CONNECTION_CONFIG Server→client event channel over Server-Sent Events (native reconnection). All integration is provided through the config token. Provides the config

The core never assumes a concrete implementation: it defines the contract and the app satisfies it via providers.


3. Forms API and the reactive approach

An application form is a class that extends BasicFormComponent. The central idea of the reactive approach is that you don't program the flow imperatively; instead you declare how the form reacts to UI events, registering callbacks that the library invokes at the right moment.

UI components don't call business logic directly: they translate the UI event into a notification on the model object, and BasicFormComponent —subscribed to that object— fires the callbacks the app registered.

  User types in the input
        │  (template) (ngModelChange)
        ▼
  FieldComponent.inputChanged()
        ├─ updateObject()      → field.setValue(...)      (view → model)
        └─ onChangeContent()   → field.notifyEditionFinish()
                                      │  emits on editionFinish (Subject)
                                      ▼
            BasicFormComponent.startFieldValidation(code)
                                      │
                                      ▼
            callbacks registered with onFieldValidationStart(...)
                                      │  (if ok and field.backend)
                                      ▼
            requestFormAction('VALIDATE')  → updateFormWithServerData()
                                      ▼
            callbacks registered with onFieldValidationFinish(...)

Conversely, when the model changes (by code or by a server response) the view updates itself thanks to reactive propagation:

  field.setValue(x)  →  setAttr()  →  propagateAttribute('value', x)
        │                                   │  _attributeChange.next(...)  (BehaviorSubject)
        │                                   ▼
        │                    FieldComponent (subscribed to field.attributeChange)
        │                                   ▼
        │                         this.value.set(x)   (signal)  →  template
Registering callbacks (how the form "reacts")

Inside start() (or preStart()) the app registers the handlers:

// Actions
this.onActionStart('save', (action) => this.validateBeforeSaving());   // false cancels
this.onActionFinish('save', (action, result) => this.notifySaved());

// Fields
this.onFieldValidationStart('email', (field) => this.preValidateEmail(field));
this.onFieldValidationFinish('email', (field) => this.afterValidateEmail(field));
this.onFieldInput('phone', (field) => this.formatWhileTyping(field));

// Tables
this.onTableActionStart('items', 'edit', (detail) => this.openEdition(detail));
this.onTableGetDataFinish('items', (detail, result) => this.afterPaging(detail));

// Sections and form
this.onSectionActivation('data', (section) => this.onEnterSection(section));
this.onFormChange((state) => this.onStateChanged(state));

// Server errors
this.onActionServerError((action) => this.showError());

Every callback that runs before the backend (...Start) can return false to cancel the flow. The ...Finish ones run after the server part.


4. Form lifecycle

 1. Angular creates the form component (a BasicFormComponent subclass)
 2. ngOnInit() ─────────────► preStart()            (config hook: formCode, route)
 3. The app calls formInit(params):
      a. processInputParams()            extracts token / subject / state from the stack
      b. getFormDefinition(name)         requests the JSON from the server (FormManager)
      c. loadDefinition(json) ──────────►  instantiates FieldDescriptor / FormAction /
                                            RecordTable / RecordFormSection  (live model)
      d. subscribeSectionActivation()
         subscribeFieldsSubjects()        the library subscribes to each model object's
         subscribeActionSubjects()        events and connects its state to the form's
         subscribeTableSubjects()         (connectWithParentForm)
      e. changeState(initialState)        state machine (visibility/editability)
      f. requestFormAction('GETDATA')     initial data load (if loadInitialData)
      g. start()                          app hook: callbacks are registered HERE
 4. Operation: the UI fires events → notifications on the model → callbacks
 5. Angular destroys the component → takeUntilDestroyed cleans up the subscriptions

Key points:

  • preStart(): configure name (the form code) and route parameters.
  • start(): register the callbacks (on*). Runs once the model is already loaded.
  • changeState(state): the state machine defines, for each state, which fields/actions/tables are visible and editable (visibleStates / enabledStates). Changing state re-propagates visibility and enablement to the whole view.
  • requestFormAction(code): the single point of backend communication; its response enters through updateFormWithServerData() and updates the existing fields/actions/tables via updateFromServer().

5. Form composition and element access

FormStructureAndData (which BasicFormComponent extends) holds the form graph and exposes a rich API to access and manipulate its elements. Each element is obtained by its code and operated directly on the model object.

// Fields  → FieldDescriptor (IField)
const email = this.getField('email');
email.setValue('a@b.com');
email.required = true;
if (email.hasError()) { /* … */ }
this.getFieldValue('email');                 // shortcut
this.getRequiredEmptyFields(null, 'data');   // by section

// Actions → FormAction (IAction)
const save = this.getAction('save');
this.disableAction('save');
this.getHeaderActions();

// Tables  → RecordTable (ITable), with columns and table actions
const items = this.getTable('items');
items.setTableRecords(records);
items.getActions('INLINE');                  // ITableAction[]
items.columns;                               // ITableColumn[]
const rec = this.getTableRecord('items', id);// ITableRecord

// Sections → RecordFormSection (ISection) / RecordFormSubSection (ISubSection)
this.activateSection('data');
this.getSubSection('data', 'personal');

Element component relationship:

Element (model) Contract Base view component
FieldDescriptor IField FieldComponent
FormAction IAction ActionComponent
RecordTable ITable LibTableComponent
RecordTableColumn / TableAction ITableColumn / ITableAction cells in LibTableRecordFieldComponent / LibTableRecordActionComponent
RecordFormSection / RecordFormSubSection ISection / ISubSection SectionComponent / SubSectionComponent
FormStructureAndData IForm BasicFormComponent / FormHeaderComponent

Each component receives its model object via @Input() ([field], [action], [table], …), registers itself as that object's widget and subscribes to its changes to reflect them; and it translates user events into notifications on that same object. In other words, the component is a view of the model object, not its owner.


6. Model objects vs. view components

This is the most important distinction for using the library correctly:

   MODEL (lives for the whole form)            VIEW (ephemeral, created/destroyed by Angular)
   ┌───────────────────────────┐               ┌───────────────────────────┐
   │  FieldDescriptor 'email'  │◄── @Input ────│  FieldComponent           │
   │  (created in loadDefinition│   field.widget │  (created when rendered)  │
   │   lives until the form is  │◄── subscription│  signals: value(), …      │
   │   closed)                 │   attributeChange   destroyed when hidden   │
   └───────────────────────────┘               └───────────────────────────┘
        ▲ source of truth                             ▲ recreated without losing data
        │ getField('email') always returns the same object
  • Model objects (FieldDescriptor, FormAction, RecordTable, …) are created once in loadDefinition() and live for as long as the form exists. They hold the state: value, errors, visibility, selection, etc. getField('x') always returns the same instance.
  • Components (FieldComponent, etc.) are ephemeral: Angular creates and destroys them based on what is currently shown (section change, @if, table paging, virtual scroll…). When destroyed and recreated, nothing is lost: the new component re-subscribes to the model object and recovers the current state.

Practical consequences:

  • The form's business logic operates on the model objects (this.getField('x').setValue(...)), never on the components. It works even if the component is not mounted.
  • Don't keep state in the component expecting it to persist; state lives in the model.
  • A model object and its component are not the same thing: the component is a replaceable reactive projection of the object, whereas the object is the stable, unique entity.

7. Using the library in an Angular project

Installation
npm install tuain-ng-forms-lib
# or: yarn add tuain-ng-forms-lib

Peer dependencies: Angular ^17 (common, core, forms, router) and rxjs ^7.5.

a. Provide the services

Standalone apps register them in main.ts (or in an NgModule with providers):

import { bootstrapApplication } from '@angular/platform-browser';
import { LibFormManagerService, LibFileManagementService, LibEventManagerService } from 'tuain-ng-forms-lib';
import { AppFormManager } from './services/app-form-manager.service';   // your implementation
import { AppFileManager } from './services/app-file-manager.service';

bootstrapApplication(AppComponent, {
  providers: [
    { provide: LibFormManagerService, useClass: AppFormManager },
    { provide: LibFileManagementService, useClass: AppFileManager },
    { provide: LibEventManagerService, useValue: new LibEventManagerService(['formActivity', /* … */]) },
    // optional: ICON_RESOLVER, SSE_LIVE_CONNECTION_CONFIG
  ],
});

AppFormManager extends LibFormManagerService and implements the bridge to your backend:

@Injectable()
export class AppFormManager extends LibFormManagerService {
  override async getFormDefinition(formCode: string): Promise<any> { /* HTTP → JSON */ }
  override async execServerAction(detail: any): Promise<any> { /* HTTP → response */ }
  override goToForm(formCode: string, token: string, subject: string | null): void { /* router */ }
}
b. Define a form
import { Component } from '@angular/core';
import { BasicFormComponent, LibFormManagerService, LibEventManagerService, LibFileManagementService } from 'tuain-ng-forms-lib';
import { appFormConfig } from './app-form.config';   // your IFormConfig

@Component({ standalone: true, selector: 'app-customer-form', templateUrl: './customer-form.html' })
export class CustomerFormComponent extends BasicFormComponent {
  constructor(fm: LibFormManagerService, em: LibEventManagerService, fs: LibFileManagementService) {
    super(fm, em, fs);
    this.setConfig(appFormConfig);
  }

  override preStart(): void {
    this.name = 'CUSTOMER';                 // form code
    this.formInit({ /* route params */ });  // triggers load + initialization
  }

  override start(): void {
    // Declare HOW the form reacts:
    this.onFieldValidationStart('documentId', (f) => this.validateDocument(f));
    this.onActionStart('save', () => this.validateSectionConsistency('data'));
    this.onActionFinish('save', () => this.goBack());
  }

  private validateDocument(field /* IField */): boolean {
    if (field.empty) { field.setErrorMessage('Required'); return false; }
    return true;
  }
}
c. Render

The core ships abstract base components: to show concrete widgets you use a Tuain UI package (e.g. tuain-ng-forms-ant / tuain-ng-forms-ionic) or your own components that extend FieldComponent, ActionComponent, LibTableComponent, etc. and supply their template. In the form template you bind each component to its model object:

<lib-form-header [form]="form" (goBackEvent)="goBack()"></lib-form-header>

<app-field   *ngFor="let f of getFields()"  [field]="f"></app-field>
<app-action  *ngFor="let a of getHeaderActions()" [action]="a"></app-action>
<app-table   *ngFor="let t of getTables()"  [table]="t"></app-table>

Until the UI packages are available, the app defines its own field/action/table components by extending this library's base components and overriding start()/focus() and the template.


Public API

Exported (from tuain-ng-forms-lib):

  • Model: FormStructureAndData, FieldDescriptor, FormAction, RecordTable, RecordTableColumn, TableAction, TableRecordData, RecordFormSection, RecordFormSubSection, and the bases FormPiece/FormPiecePropagate/FormElement.
  • Contracts: IForm, IField, IAction, ITable, ITableColumn, ITableAction, ITableRecord, ISection, ISubSection, IFormPiece/IFormElement, IFormConfig and the definition/event types.
  • Base components (standalone): BasicFormComponent, FieldComponent, ActionComponent, LibTableComponent, LibTableRecordFieldComponent, LibTableRecordActionComponent, SectionComponent, SubSectionComponent, FormHeaderComponent, FormErrorComponent, ElementComponent, PieceComponent.
  • Services and tokens: LibFormManagerService, LibEventManagerService, LibFileManagementService, BaseIconResolverService + ICON_RESOLVER, SseLiveConnectionService + SSE_LIVE_CONNECTION_CONFIG.

License

MIT

Keywords