Skip to content
V
For developers
Architecture, conventions, the core and security
UI layer / Dashboard widgets

Dashboard widgets#

The admin dashboard (:Admin:System:Page:dashboard) is a widget board. The page template renders only {control adminSystemDashboardWidgets}; everything else is done by the DashboardWidgets orchestrator and the widgets of the individual modules. Code: app/UI/Admin/System/Components/DashboardWidgetsComponent/.

How the board is put together#

Part Role
DashboardWidgets (component) the layout, the widget card (header with icon, title, move, "…" menu), the "Add widget" panel, the signals
widget (BaseWidget) the content of one card, reads data through the managers of its module
DashboardWidgetType the type declaration: key, texts, privileges, sizes, settings
DashboardWidgetRegistry collects the types from all widgets in DI
DashboardLayoutGrid pure grid functions: collisions, free cells, settings normalisation
cms_system_dashboard_widgets the stored layout
dashboard-widgets.js dragging, the panel, the settings step, batched content loading

A layout belongs to the pair (admin page, signed-in user). One row of cms_system_dashboard_widgets is one widget: page_id, user_id, widget_type, the cell grid_col / grid_row, the size col_span / row_span and the settings as JSON (settings). The grid has 4 columns (DashboardLayoutGrid::COLUMNS); below 1200 px the widgets reflow into 2 and 1 column. A user without a saved layout sees an empty board with 4 rows of free "+" cells; there is no default set of widgets. One board holds at most MAX_WIDGETS (30) widgets.

The server trusts nothing the browser sends

Every signal (saveLayout!, addWidget!, removeWidget!, saveSettings!) is checked again against the stored layout: the type exists and the user holds its privileges (on adding too), the size is within the type's range, the widget lies inside the 4 columns and overlaps no other one, the ids belong to the user's own layout, the widget count and the JSON length and shape are within limits. One invalid part rejects the whole request and nothing is written. The request does not carry the owner at all; the Api takes it from the identity. The writing signals are POST + AJAX only.

A widget the user has lost the privilege for is not rendered, loaded, offered or accepted, but it stays stored in the layout (visible: false). Every save keeps it, and once the privilege returns it reappears in its place.

Loading the content#

The page renders only the cards with a skeleton; the script fetches the content through the loadWidgets! signal (GET + AJAX, in batches of 4 cards, at most 8 per request). A slow or failing widget therefore neither holds up nor breaks the others:

  • An exception while rendering is written to log/dashboard.log; the card gets the error state with a "Try again" button (a batch of one card) and the other cards of the batch render on.
  • The request carries only row ids. Type, size and settings come from the stored row, and the privileges are checked again for every card (forbidden; someone else's or an unknown row is notfound).
  • Counts are shared by DashboardCountManager: the same query (manager + filters) is counted once per request, even when the same info box sits on the board twice. Nothing is cached across requests. Build time windows ("today", "7 days") with DashboardCountManager::at(); with new DateTime() two identical queries never meet because of the microseconds.

A widget is a component in the Widgets/ folder#

app/UI/Admin/<Module>/Widgets/<Name>Widget/
├── <Name>.php                  extends App\Core\Base\BaseWidget
├── <Name>Factory.php           extends App\Core\Base\BaseWidgetFactory
├── Traits/InitTrait.php        data reading (optional, for length)
└── Templates/Default/Style1.latte

DI search finds the factory like any component factory, and DashboardWidgetRegistry collects every subclass of BaseWidgetFactory. It derives the widget class from the factory name (<Name>Factory → <Name>) and reads its static buildTypes(). No config entry is needed.

BaseWidget is an ordinary component (setTemplateFile(__DIR__, …), subcomponents). On top of that:

Member Purpose
$translatorDomain the default domain follows the convention admin.<module>.widgets.<name> (OrdersToShip → admin.eshop.widgets.ordersToShip)
getInstance() the type, validated settings, size and title of the card
resolvePeriodDays(), resolveStyle(), resolveLimit(), resolveCurrencySetting() the instance setting, otherwise the type's default
resolveOpenLink() the main link of the card (first item of the "…" menu); build it with buildOpenLink()
resolveTone(), isCalm() the card's meaning for the current data (dwg__item--<tone>, calm state)
logWidgetFailure() writes to the dashboard log; never throws
$dwgBlocks, $dwgCommon in the template shared Latte blocks (Widgets/Templates/Blocks.latte: rows, empty state, sparkline…) and their texts

The orchestrator guards only the type's privileges

Before rendering a widget it checks the privilege and statisticsPrivilege of its type. When a widget links to other pages or counts data of another listing, it checks the target itself with isTargetAllowed(). buildOpenLink() does so and returns null for a target without the privilege.

A read failure is not zero

When reading fails, return null and show a dash or the error sentence in the template (dwgPeriodFailed). A zero would claim "nothing waits" when the data merely cannot be read.

The widget type: DashboardWidgetType#

A widget declares the types it can render in the static buildTypes(). Usually one; InfoBox declares several, one per card. A type is built only with DashboardWidgetType::create(...) (the constructor is private, otherwise DI search would try to autowire it). An inconsistent definition throws InvalidArgumentException as soon as the registry is collected.

public static function buildTypes(): array
{
    return [
        DashboardWidgetType::create(
            key: 'ordersToShip',
            titleKey: 'admin.eshop.widgets.ordersToShip.title',
            descriptionKey: 'admin.eshop.widgets.ordersToShip.description',
            privilege: ':Admin:Eshop:Order:show',
            minColSpan: 2, maxColSpan: 4, minRowSpan: 1, maxRowSpan: 3,
            defaultColSpan: 2, defaultRowSpan: 2,
            icon: 'fa-solid fa-box-open',
            tone: 'neutral',
            chipTone: 'red',
            position: 320,
            purpose: 'lists',
            preview: 'table',
            recommended: true,
            limits: [3, 5, 10],
            defaultLimit: 5,
        ),
    ];
}
Parameter Meaning
key unique type key (stored in widget_type), [A-Za-z][A-Za-z0-9_]*, at most 64 characters
titleKey, descriptionKey full translation keys; next to the title optionally ….keywords (synonyms for the panel search)
privilege admin key of the presenter whose data the widget reads (typically …:show); null only for a widget reading no data (heading, quick links)
statisticsPrivilege :Admin:<Module>:<Entity>:statistics for financial and statistical aggregates; with it the user needs both keys
minColSpan … defaultRowSpan allowed and default sizes in cells; width at most 4, height at most 4 (MAX_ROW_SPAN), the default inside the range
icon Font Awesome classes of the icon in the card header
tone the card's meaning: urgent, review, notice, neutral
chipTone the icon colour: green, blue, amber, red, violet, neutral
position order in the panel (ascending, then by key); info boxes start at 100
hasPeriod setting period (7, 30, 90, 365, custom) + days (at most 365)
hasText settings text (required, 80 characters) and description (160 characters); the card is then a heading without a body and must be one row high
styles style key => translation key of its label, at least two; the first is the default
limits, defaultLimit allowed list row counts (at least two, 1–100), default defaultLimit or the first
hasCurrency, defaultCurrency setting currency: auto (the currency with the highest total), all (each currency separately) or a currency code from the code list
hasLinks setting links: a list of admin menu items {id, fg?, bg?} in the user's order, colours #rrggbb, at most 24 (MAX_LINKS)
module the module in the panel; leave it null and the registry derives it from the privilege key (:Admin:Eshop:… → eshop), without a privilege from the widget namespace
purpose the panel group: waiting, stats, lists, actions, general
preview the miniature on the panel card: columns, area, sparkline, ranking, number, list, stack, donut, table, status, actions, heading, tiles, triple
recommended offer it in the "Recommended" block at the top of the panel
variant tells apart the types of one class (the card key for info boxes)

Instance settings are validated by DashboardLayoutGrid::normalizeSettings(): only the keys the type declares are allowed, and only values from the declaration. Anything else rejects the whole write. A stored size that stops being valid after a type change is clamped into the new range on read, so changing a declaration does not break stored layouts.

Privileges#

Key When What it guards
privilege every widget that reads data the same key as the listing the data comes from
statisticsPrivilege a widget with aggregates, charts and money :Admin:<Module>:<Entity>:statistics; the key has no page or action, only the dashboard reads it

The check is fail-closed: a key that does not exist in cms_system_user_role_privileges hides the widget from everyone, the superadmin included. DashboardWidgetRegistryTest therefore checks every declared key against the database. Add a new …:statistics key with an SQL migration and grant it only to roles that already see the corresponding numbers today (example 2026-10-29_24_metric_api_statistics_privileges.sql). Which key belongs to which source is answered by MetricSourceCatalog::getStatisticsPrivilege().

Chart data: the Metric API#

Charts over time, period totals and rankings are read by widgets through one path only: the admin MetricManager → Api api/system/metric/series and api/system/metric/total (:Api:System:Metric:series / :total). The actions have no action*(), so they cannot be called from outside.

Each module declares its data sources in its Api layer: app/UI/Api/<Module>/Metrics/<Module>MetricSources.php implements MetricSourceProviderInterface, and buildSources() returns MetricSource::create(...): the table, the date column, fixed conditions, summable values, the currency, dimensions for groupBy, filters and the Api read key (:Api:Eshop:Order:getAll). DI search registers the class and MetricSourceRegistry finds it by the interface. A request names only aliases from the source; column names never come from it.

MetricSource::create(
    key: 'comment',
    table: 'cms_mod_base_comments',
    tableAlias: 'c',
    dateColumn: 'c.insert_datetime',
    privilege: ':Api:Base:Comment:getAll',
    conditions: ['c.deleted = 0'],
    dimensions: [MetricDimension::create('approved', 'c.approved')],
    filters: ['approved' => 'c.approved'],
),

MetricQuery validates the query before it is sent: the metric count or sum:<alias>, granularity day / week / month / year, a period of at most 1830 days, limit 1–50. An invalid query is a programming error of the widget (MetricQueryException). A failure or a missing privilege is logged by MetricManager, which returns null.

Currencies are never added up

A monetary metric returns one series or item per currency; rankings are ordered within each currency. A money widget declares hasCurrency and reads through MoneySeriesWidgetTrait; counts over time go through MetricSeriesWidgetTrait.

The former fixed dashboard (the "What waits for action" band and the tiles) lives on as widgets:

  • Info box (System/Widgets/InfoBoxWidget/InfoBox) declares one type for each card of DashboardAttentionCards::definitions() (orders → type attentionOrders). The type's privilege is the privilege of the target listing; zero is shown as a calm state.
  • Quick links (System/Widgets/TilesWidget/Tiles, type tiles) wraps the DashboardTiles component, which checks the privilege of every tile. The user sets the choice, order and colours of the links in the widget (the links setting). Without an own choice it shows the project-wide tiles (the dashboard_tile / dashboard_order columns of admin menu items), and a new widget has them preselected. The separate "Dashboard tiles" screen has been removed.

Procedure: adding a "What waits for action" card#

  1. Add self::define(key, privilege, icon, tone, params) to DashboardAttentionCards::definitions(). privilege is the key of the target listing (typically …:show) and also the link target. The array order is the order of urgency.
  2. Pick tone by meaning: urgent (someone waits for money or goods), review (waits for an administrator's decision), notice (an operational notice).
  3. If the target grid has a matching filter, pass it in params as ['grid-filters' => ['approved' => 0]]; the card then opens only what needs handling. For a condition over several columns use PresetSelectFilter in the grid (Working with tables).
  4. Add the count to the match in DashboardAttentionManager::countCard() ($this->countBy($manager, [filters])) and the manager to its constructor.
  5. Translations in all languages: admin.system.components.dashboardAttention.cards.<key> with three forms ('{1} … |[2,4] … |]4,Inf[ …') and admin.system.widgets.infoBox.types.attention<Key>.title / .description (.keywords).
  6. Check with a role without the privilege for the target listing: the info box is missing from the panel and the query did not run.

An error in a card count shows only as a dash

countCard() writes the exception to the dashboard log and returns null so the dashboard does not crash. An info box that never shows anything may therefore be broken; look in log/dashboard.log.

The payment state on the dashboard is a copy of the code list

The admin does not touch Api enums, so DashboardAttentionManager::PAYMENT_STATUS_START is a copied value of PaymentStatus::START. Changing the number in the code list without changing the constant breaks the "stuck payments" card without any error message.

Procedure: adding a new widget#

  1. Folder. app/UI/Admin/<Module>/Widgets/<Name>Widget/. Copy a similar widget: a list Eshop/Widgets/OrdersToShipWidget, a chart over time Eshop/Widgets/OrdersOverTimeWidget, money Eshop/Widgets/RevenueWidget.
  2. Class <Name> extends BaseWidget. It takes the managers of its module in the constructor and calls parent::__construct(). In render() it prepares the data and renders setTemplateFile(__DIR__, 'Style1.latte').
  3. Factory <Name>Factory extends BaseWidgetFactory; create() returns $this->initialized(new <Name>(…)).
  4. Type in buildTypes() (table above). The key must be unique across the registry. Always fill in purpose and preview; the registry requires them for every type.
  5. Data only through the module's managers, aggregates over time through MetricManager. Log a failure (logWidgetFailure()) and return null, never an exception.
  6. Template Templates/Default/Style1.latte is rendered inside .dwg__body. Start with {import $dwgBlocks} and use the shared blocks (dwgRows, the empty and error states).
  7. Link to the listing: return it from resolveOpenLink() via buildOpenLink(':Admin:…:show', $args). Open a prefiltered listing with ['grid-filters' => […]] using the same condition the widget counts.
  8. Privileges. The privilege key (and for statistics statisticsPrivilege) must exist in cms_system_user_role_privileges; add a new key with an SQL migration.
  9. Translations in all languages in app/Locale/<module>/<language>/admin.<locale>.neon under <module>.widgets.<name>: title, description, optionally keywords, style labels and template texts.
  10. Tests. Add the class to WIDGET_CLASSES and the type to SINGLE_TYPES (for statistics also to STATISTICS_TYPES) in tests/Unit/Admin/DashboardWidgetRegistryTest.php. The test checks the folder, translations in all languages, unique names and the privilege keys in the database.
  11. Verify after deleting temp/cache (the DI container and RBAC are cached): the widget is in the panel, can be added, dragged, resized and configured. With a role without the privilege it is missing from the panel.

A new widget does not exist without clearing temp/cache

The registry reads the factories from the DI container. Until the container is regenerated the widget is not in the panel, and saving its type is rejected as an unknown type.

Where to look#

I want Where
the board orchestrator and signals app/UI/Admin/System/Components/DashboardWidgetsComponent/ (LayoutTrait, LayoutSaveTrait, WidgetLoadTrait)
the widget and factory base app/Core/Base/BaseWidget.php, BaseWidgetFactory.php
shared blocks and charts app/Core/Base/Widgets/Templates/, app/Core/Base/Widgets/Chart/
the type declaration app/UI/Admin/System/Models/Helpers/DashboardWidgetType.php
the grid and settings app/UI/Admin/System/Models/Helpers/DashboardLayoutGrid.php
the registry app/UI/Admin/System/Models/Helpers/DashboardWidgetRegistry.php
counts with shared memory app/UI/Admin/System/Models/Managers/DashboardCountManager.php
info box cards app/UI/Admin/System/Models/Helpers/DashboardAttentionCards.php, Models/Managers/DashboardAttentionManager.php
the Metric API app/Core/Metrics/, app/UI/Api/System/Presenters/MetricPresenter.php, app/UI/Api/<Module>/Metrics/
…:statistics keys app/UI/Admin/System/Models/Helpers/MetricSourceCatalog.php
shared chart traits app/Core/Traits/Admin/Components/MetricSeriesWidgetTrait.php, MoneySeriesWidgetTrait.php
the board front end www/themes/admin/*/desktop/assets/js/components/dashboard-widgets.js

Follow-up chapters: Admin presenter · Components · Working with tables · RBAC · Translations

Cookie settings