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.WidgetHostrenders 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 causedNG0303. Read data via DI, not inputs.
Anatomy of a widget
A widget is two things that share a key:
- A standalone Angular component registered under a
component_key. - A
syntec_widgetmetadata row whosecomponent_keymatches, plus asyntec_widget_rolegrant (so it shows in the "Configure" palette) and asyntec_widget_lnglabel.
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'ssyntec_user_select). Served byGET /api/v1/filters/{cid}/options?q=(typeahead-aware, role-gated).source_kind = 'resolver',source = '<key>'— a module-registeredFilterOptionsResolver(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
- Seed the
syntec_filter(in your package):cid(e.g.crm_stage),filter_type(list/date/number),is_multiple, and forlistasource_kind/source. Add asyntec_filter_lnglabel. (Core's base ships generic primitives you can reuse:user,date_range,number_range.) Optionally addsyntec_filter_rolerows to restrict access — with no role rows, access is allow-by-default. - Link it to your widget — a
syntec_widget_filterrow (syntec_widget_id⨯syntec_filter_id, plussorter,syntec_package_id,provenance). This is what makes the filter appear in the sidebar when your widget is on the dashboard. - Consume it in the component with
filteredResource(orctx.filters()[cid]) as above. - For
listoptions, ensure thesyntec_sqlsource query exists and returns{id, name}(a<something>_selectquery). A bare:p IS NULLin 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 fromDestroyRef.onDestroy(orngOnDestroy) or you leak a listener.- Events are transient (fire-and-forget) —
emitjust 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>(bothisDevMode()-guarded, stripped from production; they sit under Chrome's Verbose level). Persisted state is also inspectable with Angular DevTools (select the widget → its signals) orng.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 provideen; other languages fall back to it. - UI strings in your own widget templates use the
tpipe against a translation cid:{{ 'crm.widget.pipeline.title' | t }}. Seed the cid insyntec_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-picker → country-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 triggersNG0303). - Namespace
component_keyby module (crm.*) — the registry is a flat shared map. - No role grant = invisible — a widget without a
syntec_widget_rolerow 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_idand use guid-stable upserts + a fixed timestamp, or the package export won't be byte-idempotent / rows won't be attributed to your package.