Ga naar inhoud

De frontend

De frontend van je module is een Angular-library die Tabbr tijdens het draaien laadt (Native Federation). Het vertrekpunt is één klasse: de moduleklasse in src/app/module/module.ts.

Je klasse erft van SimpleModuleBase uit @tabbr/module-sdk. In de constructor geef je je module-id en je routes mee; via signals vertel je Tabbr wat je module verder bijdraagt.

src/app/module/module.ts
import { computed, signal } from '@angular/core';
import { FullDetailContribution, MenuContribution, SimpleModuleBase, WidgetDefinition } from '@tabbr/module-sdk';
import { I18N_BUNDLES } from './i18n-bundles';
import { MODULE_ID, MODULE_PATH } from './module-config';
export class Module extends SimpleModuleBase {
static override readonly i18nBundles = I18N_BUNDLES;
/** een widget op het dashboard */
readonly widgetDefinitions = signal<WidgetDefinition[]>([{
moduleSource: MODULE_ID,
name: 'Mijn bevoegdheden',
defaultStyleClasses: 'w-full sm:w-100',
componentType: () => import('./widget/my-widget.component').then(m => m.MyWidgetComponent),
}]);
constructor() {
// de pagina's van je module
super(MODULE_ID, [{
path: MODULE_PATH,
loadComponent: () => import('./page/page.component').then(m => m.PageComponent),
}]);
// je instellingenpagina, onder Instellingen › <je module>
this.settingsRoutes = signal([{
path: MODULE_ID,
loadComponent: () => import('./settings/settings.component').then(m => m.SettingsComponent),
}]);
// een item in het menu
this.menu = computed<MenuContribution[]>(() => [{
moduleId: MODULE_ID,
order: 56,
createNavigationItemsForMenu: (menu: any) => menu.addRootMenuItem({
id: `${MODULE_ID}.page`, title: 'Bevoegdheden', type: 'basic',
icon: 'heroicons_outline:identification', link: `/${MODULE_PATH}`,
}),
}]);
}
/** een sectie op de detailpagina van een persoon (en dus ook op het eigen profiel) */
getFullDetailContribution(entityName: string, entity: any): FullDetailContribution[] {
if (entityName !== 'person' || !entity?.id) return [];
return [{
id: MODULE_ID, title: 'Bevoegdheden & competenties', order: 40, inputs: { entityName, entity },
component: import('./profile/person-section.component').then(m => m.PersonSectionComponent),
}];
}
}
Lid Wat het doet
routes (constructor) De pagina’s van je module, onder de URL van de club.
settingsRoutes Je eigen instellingenpagina in Instellingen.
menu Menu-items, via createNavigationItemsForMenu(menu) met addRootMenuItem, addMenuItem of addAdminMenuItem.
widgetDefinitions Widgets voor het dashboard.
getFullDetailContribution() Secties op de detailpagina van een record — op een persoon verschijnen ze ook op het profiel.
messageRecipients(search) Ontvangers in het e-mailscherm, bv. “Groepen (module Groepen)”.
userPrompts Vragen aan de gebruiker: bij het openen van de app of als banner (API 1.3).
provides De frontend van een contract dat je module aanbiedt (API 1.3).
static i18nBundles Je vertalingen, zodat Tabbr je titel en beschrijving kan tonen.

Alles wat je module van Tabbr nodig heeft, injecteer je via tokens uit @tabbr/module-sdk.

Token Wat het geeft
TABBR_API_HOST De basis-URL van de API.
ACTIVE_CLUB_CODE, ACTIVE_LANGUAGE De club en de taal van de gebruiker, als signals.
SETTINGS_PAGE_HOST Voor je instellingenpagina: bindForm({ dirty, valid, save, cancel }) toont Opslaan en Annuleren in de header.
PAGE_HEADER_ACTIONS Zet de acties van je scherm in de header van de app (zie hieronder).
MESSAGING_COMPOSER Het e-mailscherm van Tabbr openen: compose({ to, subject }).
FILTER_EDITOR_COMPONENT, SCRIPT_EDITOR_COMPONENT De filter- en scripteditor van Tabbr, in je eigen scherm.
MODULE_CONTRACTS Contracten van andere modules: get(name).
PERSON_PUBLIC_API, FLIGHT_PUBLIC_API, AIRPLANE_PUBLIC_API Bv. personNow(): de ingelogde persoon.

Injecteer de tokens die niet in elke context bestaan (de dev preview, een oudere Tabbr) altijd optioneel:

private readonly composer = inject(MESSAGING_COMPOSER, { optional: true });

Op elk scherm van Tabbr staan de acties — Nieuw, Importeren, Opslaan, Annuleren… — bovenaan, in de header van de app, nooit onderaan een formulier. Je module doet hetzelfde met PAGE_HEADER_ACTIONS. Elke module van Tabbr heeft daarvoor een kleine directive:

src/app/module/parts/header-actions.directive.ts
import { Directive, inject, OnDestroy, OnInit, TemplateRef, ViewContainerRef } from '@angular/core';
import { PAGE_HEADER_ACTIONS } from '@tabbr/module-sdk';
/** <div *grpHeaderActions>…knoppen…</div>: in de header van de app; zonder header blijven ze ter plaatse. */
@Directive({ selector: '[grpHeaderActions]' })
export class HeaderActionsDirective implements OnInit, OnDestroy {
private readonly template = inject(TemplateRef);
private readonly viewContainer = inject(ViewContainerRef);
private readonly header = inject(PAGE_HEADER_ACTIONS, { optional: true });
private remove: (() => void) | null = null;
ngOnInit(): void {
if (this.header) this.remove = this.header.show(this.template);
else this.viewContainer.createEmbeddedView(this.template);
}
ngOnDestroy(): void {
this.remove?.();
}
}
<div *grpHeaderActions>
<button mat-flat-button (click)="newGroup()">Nieuwe groep</button>
</div>

Toon Opslaan en Annuleren alleen zolang er niet-bewaarde wijzigingen zijn. Een dialoog houdt zijn eigen knoppen.

injectModuleEntity(naam) geeft je toegang tot een entiteit — van je eigen module of van Tabbr — met de toegangsregels van de club:

import { injectModuleEntity } from '@tabbr/module-sdk';
interface Group { id: string; name: string; parent?: { id: string } }
export class GroupsStore {
private readonly groups = injectModuleEntity<Group>('group');
async rootGroups() {
return this.groups.queryAll({ where: [{ field: 'parent', operator: 'IS_EMPTY' }] });
}
async rename(id: string, name: string) {
await this.groups.update(id, { name });
}
}
Methode Wat ze doet
query({ where, anyOf, q, page }) Eén pagina (50 records), met filters, OF-groepen en vrije zoektekst.
queryAll(…), count(…), countBy(field) Alles, het aantal, het aantal per waarde.
getById(id), create(…), update(id, …), delete(id) Eén record.
relatedFilter(field, query) Een filter op een gerelateerd record.
fieldMetadata() De velden — ook de eigen velden van de club.

Operators: EQ, NEQ, GT, LT, GTE, LTE, IS_EMPTY, IS_NOT_EMPTY. Een datum mag today() zijn.

Instellingen van je module bewaar je met injectModuleApi(moduleId): loadSettings(), saveSettings(…), en trigger(naam) om een trigger van je backend met de hand te starten.

Een lange lijst laadt pagina per pagina terwijl je scrolt. De SDK doet het werk:

import { incrementalList, loadMoreWhenVisible } from '@tabbr/module-sdk';
readonly list = incrementalList(() => {
const status = this.status(); // de lijst begint opnieuw als dit signal verandert
return (page) => this.consents.query({ where: [{ field: 'status', value: status }], page });
});
private readonly end = viewChild<ElementRef>('end');
constructor() {
loadMoreWhenVisible(this.list, this.end);
}
@for (row of list.rows(); track row.id) { … }
<div #end class="h-5"></div>

incrementalList geeft rows, total, loading, loadingMore, hasMore, error, loadMore() en reload().

Je vertalingen staan in src/app/module/i18n/nl.ts (en en.ts, …), genest onder je module-id. Voor je routes geef je ze mee met provideModuleI18n({ bundles }) uit @tabbr/module-sdk/i18n, in de providers van de route.