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 theerrorstate 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 isnotfound). - 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") withDashboardCountManager::at(); withnew 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.
Info boxes and quick links#
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 ofDashboardAttentionCards::definitions()(orders→ typeattentionOrders). The type's privilege is the privilege of the target listing; zero is shown as a calm state. - Quick links (
System/Widgets/TilesWidget/Tiles, typetiles) wraps theDashboardTilescomponent, which checks the privilege of every tile. The user sets the choice, order and colours of the links in the widget (thelinkssetting). Without an own choice it shows the project-wide tiles (thedashboard_tile/dashboard_ordercolumns 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#
- Add
self::define(key, privilege, icon, tone, params)toDashboardAttentionCards::definitions().privilegeis the key of the target listing (typically…:show) and also the link target. The array order is the order of urgency. - Pick
toneby meaning:urgent(someone waits for money or goods),review(waits for an administrator's decision),notice(an operational notice). - If the target grid has a matching filter, pass it in
paramsas['grid-filters' => ['approved' => 0]]; the card then opens only what needs handling. For a condition over several columns usePresetSelectFilterin the grid (Working with tables). - Add the count to the
matchinDashboardAttentionManager::countCard()($this->countBy($manager, [filters])) and the manager to its constructor. - Translations in all languages:
admin.system.components.dashboardAttention.cards.<key>with three forms ('{1} … |[2,4] … |]4,Inf[ …') andadmin.system.widgets.infoBox.types.attention<Key>.title/.description(.keywords). - 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#
- Folder.
app/UI/Admin/<Module>/Widgets/<Name>Widget/. Copy a similar widget: a listEshop/Widgets/OrdersToShipWidget, a chart over timeEshop/Widgets/OrdersOverTimeWidget, moneyEshop/Widgets/RevenueWidget. - Class
<Name> extends BaseWidget. It takes the managers of its module in the constructor and callsparent::__construct(). Inrender()it prepares the data and renderssetTemplateFile(__DIR__, 'Style1.latte'). - Factory
<Name>Factory extends BaseWidgetFactory;create()returns$this->initialized(new <Name>(…)). - Type in
buildTypes()(table above). The key must be unique across the registry. Always fill inpurposeandpreview; the registry requires them for every type. - Data only through the module's managers, aggregates over time through
MetricManager. Log a failure (logWidgetFailure()) and returnnull, never an exception. - Template
Templates/Default/Style1.latteis rendered inside.dwg__body. Start with{import $dwgBlocks}and use the shared blocks (dwgRows, the empty and error states). - Link to the listing: return it from
resolveOpenLink()viabuildOpenLink(':Admin:…:show', $args). Open a prefiltered listing with['grid-filters' => […]]using the same condition the widget counts. - Privileges. The
privilegekey (and for statisticsstatisticsPrivilege) must exist incms_system_user_role_privileges; add a new key with an SQL migration. - Translations in all languages in
app/Locale/<module>/<language>/admin.<locale>.neonunder<module>.widgets.<name>:title,description, optionallykeywords, style labels and template texts. - Tests. Add the class to
WIDGET_CLASSESand the type toSINGLE_TYPES(for statistics also toSTATISTICS_TYPES) intests/Unit/Admin/DashboardWidgetRegistryTest.php. The test checks the folder, translations in all languages, unique names and the privilege keys in the database. - 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