Skip to content

Dashboards & Widgets

Dashboards are per-user, configurable grids of widgets. A widget is a small, self-contained Angular component (a KPI tile, a chart, a picker…). A filter is a dashboard-wide control (a user picker, a date range…) whose value widgets can read to scope their data, and widgets can also talk to each other over a small per-dashboard event bus.

Like the rest of the admin, the system is metadata-driven: which widgets exist, what they're called, who may use them, and which filters a dashboard exposes all live as data in the platform (syntec_* tables) — the Angular code only supplies the rendering for each widget's component_key. That split is what lets a module ship its own widgets and filters without forking core.

This page is the developer/agent guide to the model and a step-by-step for adding your own widget and filter from a module.


The moving parts

syntec_dashboard (a saved layout)         ── GET /api/v1/dashboard/startup
   └─ components[] (tiles: {x,y,cols,rows, widget})
        └─ widget.component_key ───────────┐
                                           ▼
DashboardView (<syntec-dashboard>)   provides  DashboardContext  (filter state + event bus)
   ├─ gridster tiles → WidgetHost → LazyOutlet → *your component* (resolved by component_key)
   └─ FilterSidebar + FilterBreadcrumb  ── GET /api/v1/dashboards/{guid}/filters
                                          (definitions; options via /api/v1/filters/{cid}/options)

DashboardWidgetRegistry  ◄── DASHBOARD_WIDGETS (multi)  = BUILTIN_DASHBOARD_WIDGETS + module maps
   (component_key → lazy import())

Backend (syntec-one-core), the metadata + data:

Table Holds
syntec_widget one row per widget kind: cid, component_key, icon, color, default_span, is_default
syntec_widget_lng the widget's translatable display label, per language
syntec_widget_role which roles may see/place the widget (the palette inner-joins this)
syntec_dashboard (+_component) a saved dashboard and its widget-tile tree
syntec_filter (+_lng, _role) a filter: cid, filter_type (list/date/number), is_multiple, source_kind, source; translatable label; access roles
syntec_widget_filter links a widget to the filters it supports (a dashboard's filter set = the union across its widgets)

Frontend (syntec-one-admin):

Symbol (@syntec/one-admin) Role
DASHBOARD_WIDGETS (token, multi) contribute a { component_key → () => import() } loader map
DashboardWidgetRegistry resolves a component_key to its lazy component
DashboardContext / injectDashboardContext() per-dashboard filter-state signals + the event bus
filteredResource(fetchFn) a signal that re-fetches whenever the active filters change
BUILTIN_DASHBOARD_WIDGETS core's own widgets (user-profile, system-info, weather, …)

The render path is DashboardView → gridster tile → WidgetHost → LazyOutlet → your component. WidgetHost looks up widget.component_key in the registry and lazy-loads it; an unknown key shows an inline "Unknown widget" fallback.

Widgets take no @Inputs. WidgetHost renders your component with no bound inputs — a widget receives everything through dependency injection (inject(SYNTEC_CLIENT), injectDashboardContext(), your own services). Declaring an @Input() won't be populated, and historically forwarding one caused NG0303. Read data via DI, not inputs.


Anatomy of a widget

A widget is two things that share a key:

  1. A standalone Angular component registered under a component_key.
  2. A syntec_widget metadata row whose component_key matches, plus a syntec_widget_role grant (so it shows in the "Configure" palette) and a syntec_widget_lng label.

Component conventions (match the rest of the app):

  • standalone: true, changeDetection: ChangeDetectionStrategy.OnPush.
  • Its own folder with separate .ts / .html / .scss (never inline template/styles).
  • State in signals; data loaded via DI (SDK client / DashboardContext), not @Inputs.
  • Use the opt-in widget style kit (below) for a consistent, theme-aware card.

A minimal SDK-backed widget (this is essentially core's system-info widget):

// my-kpi-widget.ts
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { SYNTEC_CLIENT } from '@syntec/one-admin';

@Component({
  selector: 'crm-my-kpi-widget',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  templateUrl: './my-kpi-widget.html',
  styleUrl: './my-kpi-widget.scss',
})
export class MyKpiWidget {
  private readonly client = inject(SYNTEC_CLIENT);
  readonly data = signal<{ total: number } | null>(null);
  readonly failed = signal(false);

  constructor() {
    // e.g. a module endpoint, or client.records('crm_deal').list()
    this.client.call('GET', '/api/v1/crm/kpi')
      .then((r) => this.data.set(r as { total: number }))
      .catch(() => this.failed.set(true));
  }
}
<!-- my-kpi-widget.html -->
<div class="sx-widget">
  <h4 class="sx-widget__title">Pipeline</h4>
  <div aria-live="polite" [attr.aria-busy]="!data() && !failed()">
    @if (failed()) {
      <p class="sx-widget__error">Unable to load.</p>
    } @else if (data(); as d) {
      <dl class="sx-widget__facts"><dt>Open deals</dt><dd>{{ d.total }}</dd></dl>
    } @else {
      <p class="sx-widget__muted">Loading…</p>
    }
  </div>
</div>

The widget style kit (opt-in)

Global, token-based classes shipped in @syntec/one-admin/styles.css. A widget may use them for a consistent card that adapts to light/dark + customer skins automatically — nothing is required.

Class Purpose
.sx-widget the card: border, radius, padding, var(--surface)/var(--text), fills the tile
.sx-widget__title header row (icon/avatar/logo + heading)
.sx-widget__facts a <dl> label/value grid; labels are bold, full-strength (readable in dark mode)
.sx-widget__error var(--danger)
.sx-widget__muted var(--text-muted)
.sx-widget__avatar round icon/avatar holder
.sx-widget__badge small colored badge (a lightweight "logo")

Build any custom styling on the design tokens (--surface, --text, --text-muted, --border, --radius, --space, --danger, --accent, brand colors) — never hardcode colors or use opacity for muted text (that reads as unreadable grey in dark mode). Dark mode is driven by [data-theme="dark"] on the root; using the tokens means you get it for free.


Tutorial — add a widget from your module

A module owns two repos (the Angular admin extension and the core package). A widget needs a change in each: register the component (admin) and seed the metadata (core/package).

1. Write the component

Create it in your module's admin extension (own folder, conventions above). Namespace the component_key by module to avoid collisions, e.g. crm.pipeline_summary.

2. Register the lazy loader

Contribute a DASHBOARD_WIDGETS multi provider — a map of component_key → () => import() — via provideSyntecAdmin's moduleDashboardWidgets:

import { DASHBOARD_WIDGETS, provideSyntecAdmin } from '@syntec/one-admin';

bootstrapApplication(App, {
  providers: provideSyntecAdmin({
    apiBaseUrl: '…',
    moduleDashboardWidgets: [
      {
        provide: DASHBOARD_WIDGETS,
        multi: true,
        useValue: {
          'crm.pipeline_summary': () =>
            import('./widgets/pipeline-summary/pipeline-summary-widget').then((m) => m.PipelineSummaryWidget),
        },
      },
    ],
  }),
});

The registry merges every DASHBOARD_WIDGETS map (core's BUILTIN_DASHBOARD_WIDGETS + each module's), so your component_key is now resolvable. The () => import(...) keeps the widget in its own lazy chunk — it's only downloaded when actually placed on a dashboard.

3. Seed the metadata (core / your module's package)

The component won't appear in the "Configure" palette until a syntec_widget row with the matching component_key exists and is granted to a role. Two ways:

a. Structure MCP / SQL (quick, for dev) — insert a syntec_widget row (cid == component_key by convention), a syntec_widget_lng label, and a syntec_widget_role grant (e.g. to public so any authenticated user can place it, or a specific role), then syntec:package:export <pkg>.

b. A Bootstrap seed (reproducible) — mirror core's Bootstrap::seedDefaultWidgets() / seedWidgetRow(): guid-stable ON CONFLICT (guid) upserts + a fixed seed timestamp so the package export stays byte-idempotent. Fields: cid/component_key (same value), icon, default_span (1–12 grid columns), is_default = false, syntec_package_id = your package.

Role grant cheat-sheet (syntec_widget_role): the dashboard palette inner-joins this table, so no grant = the widget is invisible. Grant to public for "any authenticated user", or to a specific role for restricted widgets (core's system-info/changepointer are admin-only this way).

4. Translatable label

The widget's display name lives in syntec_widget_lng.label (per language). In the admin, the widget form has a translatable Label field (a text component with col_key: "syntec_widget_lng.label") — edit it per language there, or seed the rows directly. Keep an English (en) label at minimum; missing languages fall back to it.

5. Propagate & verify

Rebuild the admin lib, refresh it into the running app, and re-seed/import the package so the syntec_widget row is live. Then: open a dashboard → Configure → your widget appears in the palette (for a role you were granted) → place it → it renders. (See Propagation in the platform docs for the cache/rebuild steps.)


Filters

A filter is a dashboard-wide control rendered in the collapsible filter sidebar. Its value lives in DashboardContext and any widget can read it to scope its data. A dashboard's filter set is the DISTINCT union of the filters its placed widgets declare support for (via syntec_widget_filter) — so a filter only appears when a widget that uses it is on the board.

Three filter types (the FilterDefinition.filter_type):

Type Control Value shape in ctx.filters()[cid]
list multi/single-select dropdown (lazy options) unknown[] (selected ids)
date from / to date inputs { from: string \| null, to: string \| null }
number min / max number inputs { min: number \| null, max: number \| null }

list options come from a source on the syntec_filter row:

  • source_kind = 'sql', source = '<named syntec_sql cid>' — a reusable SQL query returning { id, name } rows (e.g. core's syntec_user_select). Served by GET /api/v1/filters/{cid}/options?q= (typeahead-aware, role-gated).
  • source_kind = 'resolver', source = '<key>' — a module-registered FilterOptionsResolver (backend), for options that aren't a plain query.

date/number need no source.

How a widget reads filters

Inject the context and read the filters signal, or use filteredResource to auto-refetch:

import { Component, inject } from '@angular/core';
import { SYNTEC_CLIENT, injectDashboardContext, filteredResource } from '@syntec/one-admin';

export class PipelineSummaryWidget {
  private readonly ctx = injectDashboardContext();
  private readonly client = inject(SYNTEC_CLIENT);

  // Re-runs the fetch every time any active filter changes:
  readonly rows = filteredResource((filters) =>
    this.client.call('POST', '/api/v1/crm/pipeline', { filters }) as Promise<Row[]>);

  // …or read a specific filter directly:
  // const owner = this.ctx.filters()['user'] as string[] | undefined;
}

filters is a Record<string, unknown> keyed by filter cid. Read a specific one with a type assertion matching the table above. filteredResource(fn) returns a Signal<T | undefined> that calls fn(currentFilters) whenever the filter state changes (and once on init) — the ergonomic way to make a data widget filter-aware.

Tutorial — add a filter & make a widget react

  1. Seed the syntec_filter (in your package): cid (e.g. crm_stage), filter_type (list/date/number), is_multiple, and for list a source_kind/source. Add a syntec_filter_lng label. (Core's base ships generic primitives you can reuse: user, date_range, number_range.) Optionally add syntec_filter_role rows to restrict access — with no role rows, access is allow-by-default.
  2. Link it to your widget — a syntec_widget_filter row (syntec_widget_idsyntec_filter_id, plus sorter, syntec_package_id, provenance). This is what makes the filter appear in the sidebar when your widget is on the dashboard.
  3. Consume it in the component with filteredResource (or ctx.filters()[cid]) as above.
  4. For list options, ensure the syntec_sql source query exists and returns {id, name} (a <something>_select query). A bare :p IS NULL in that SQL is type-ambiguous to Postgres — cast it (:p::uuid IS NULL).

The cross-widget event bus

DashboardContext also carries a tiny, typed, per-dashboard event bus (plain callbacks — no RxJS). One widget emits; any widget on the same dashboard can listen. They agree only on a type string and a payload shape — no direct references between widgets.

// Emitter (e.g. a country picker):
this.ctx.emit({ type: 'country-selected', payload: { cca3, name } });

// Consumer (e.g. a country-detail widget) — register the unsubscribe with DestroyRef:
constructor() {
  const off = this.ctx.on('country-selected', (payload) => {
    const cca3 = (payload as { cca3?: unknown })?.cca3;
    if (typeof cca3 === 'string') this.load(cca3);
  });
  inject(DestroyRef).onDestroy(off);   // ← always: on() returns an unsubscribe fn
}

Rules of the road:

  • on(type, handler) returns an unsubscribe function — always call it from DestroyRef.onDestroy (or ngOnDestroy) or you leak a listener.
  • Events are transient (fire-and-forget) — emit just calls current listeners; nothing is stored. If a widget needs to display the last event, copy the payload into a signal.
  • Guard the payload — it's unknown. Narrow it (typeof/in) before use; don't blind-cast.
  • Dev visibility: in dev builds the context logs every emit and every filter change to the console — [DashboardBus] <type> → N listener(s) and [DashboardFilters] <action> → <state> (both isDevMode()-guarded, stripped from production; they sit under Chrome's Verbose level). Persisted state is also inspectable with Angular DevTools (select the widget → its signals) or ng.getComponent($0) in the console.

Internationalization

  • Widget/filter labels are translatable data — syntec_widget_lng.label / syntec_filter_lng.label, one row per language. Edit them via the widget/filter form's translatable Label field, or seed them. Always provide en; other languages fall back to it.
  • UI strings in your own widget templates use the t pipe against a translation cid: {{ 'crm.widget.pipeline.title' | t }}. Seed the cid in syntec_translation (+_lng) for each language. The pipe is impure, so labels update live on a language switch.
  • A dashboard re-fetches its filter definitions when the UI language changes, so filter names in the sidebar follow the language without a reload.

Reference

SDK methods (@syntec/one-sdk, via SYNTEC_CLIENT):

Method Endpoint
dashboardStartup() GET /api/v1/dashboard/startup (auto-provisioned on first use)
dashboards() / dashboard(guid) list / fetch a dashboard
widgets() GET /api/v1/widgets — role-authorized palette
dashboardFilters(guid) GET /api/v1/dashboards/{guid}/filters — a dashboard's filter definitions
filterOptions(cid, q?) GET /api/v1/filters/{cid}/options — options for a list filter
saveDashboardTree(guid, components) PUT /api/v1/dashboards/{guid}/tree — builder save
records(entity).list()/get() generic metadata-driven records API (reuse for reference data)

Key admin symbols: DASHBOARD_WIDGETS, DashboardWidgetRegistry, DashboardContext, injectDashboardContext, filteredResource, ComponentLoader, BUILTIN_DASHBOARD_WIDGETS (all from @syntec/one-admin).

Core reference implementations (read these when in doubt): user-profile (DI-only), system-info (SDK-backed + badges/logo), weather (reacts to the date_range filter via an effect), country-pickercountry-detail (the event-bus pair), filter-demo (reflects filter state + consumes events).

Gotchas

  • No @Inputs on widgets — data comes via DI; a bound input isn't populated (and forwarding one triggers NG0303).
  • Namespace component_key by module (crm.*) — the registry is a flat shared map.
  • No role grant = invisible — a widget without a syntec_widget_role row never shows in the palette.
  • Effects that write signals should do it after an await (or only read signals they don't write) to stay clear of change-detection loops.
  • Package seeds must set syntec_package_id and use guid-stable upserts + a fixed timestamp, or the package export won't be byte-idempotent / rows won't be attributed to your package.