Add a Dashboard Widget
Publish an App-owned Dashboard Widget, register its renderer, and verify its Resource, data, localization, and authorization contracts together.
Publish a placeable App widget backed by an authorized query. This guide uses People's people.my_assignments and reuses its employment presentation.
Connect the four pieces
- Use an existing Resource and protected read API from Create a Resource.
- Return a
DashboardWidgetfromDashboardWidgetContribution, with a language-neutral component spec and a query reference. - Publish the matching
DashboardQuerySource, including permission, subject population, and either complete model invalidation orpollingOnly: true. The backing endpoint must independently authorize every read. - Register the spec's renderer key in the App entry, refresh discovery, and build the changed App frontend using Create an App Package.
Confirm the result
An authorized actor can place the widget and receive current scoped data; revoked access stops the data path. Check loading, empty, error, and locale states with Test an App.
For a data widget assembled from declared Resources, the builder below may already cover the need without a custom renderer. It supports Table, metric, bar, line, donut, and pivot presentations. The following implementation is for a bespoke widget.
Reuse a saved report when no bespoke widget is needed
Do not add an App-specific report API or permission just to make declared Resource data reusable. The Shell Report library and Dashboard builder use the same Core composition contract. A report owns its definition and immutable revisions; a Dashboard placement pins one chosen revision and owns only layout. The current viewer is authorized again when either surface executes it, so sharing a report never grants the App's source-data permission. Agent calls, when enabled by Core, use that same pinned-revision query contract.
1. Publish the widget contribution
Implement DashboardWidgetContribution. Because that interface extends
ResourceCatalogContribution, the class must bind a stable Resource Key and its
model before returning widgets.
packages/people/src/Contribution/PeoplePersonalDashboardWidgets.php contains:
<?php
namespace Amuzcorp\Nexia\PeopleCore\Contribution;
use Amuzcorp\Nexia\PeopleCore\Models\Worker;
use Nexia\Dashboard\DashboardWidget;
use Nexia\Dashboard\Contracts\DashboardWidgetContribution;
use Nexia\Dashboard\DashboardWidgetKind;
use Nexia\Dashboard\RendererCapability;
final class PeoplePersonalDashboardWidgets implements DashboardWidgetContribution
{
private const TOOL = 'people.worker_profile.read';
public static function resourceKey(): string
{
return 'people.worker';
}
public static function resourceModelClass(): string
{
return Worker::class;
}
public static function dashboardWidgets(): array
{
return [
new DashboardWidget(
key: 'people.my_assignments',
titleKey: 'people.worker_profile.assignments.title',
description: 'Current Operating Unit placements, position details, and reporting line.',
spec: [
'component' => 'people.EmploymentSection',
'props' => [
'titleKey' => 'people.worker_profile.assignments.title',
'queryRef' => [
'tool' => self::TOOL,
'args' => [],
'refresh' => '60s',
],
'section' => 'assignments',
],
],
kind: DashboardWidgetKind::Table,
capability: RendererCapability::HumanAndAgent,
),
];
}
}key is permanent App-prefixed identity. titleKey is a locale-catalog key.
description is technical selection guidance, so it remains a literal English
string. The spec is language-neutral: it names the renderer and carries query
instructions, not fetched rows. Core localizes the title and resolves data at the
request boundary.
The production class factors repeated widget construction into a private helper; the expanded form above shows the complete contract for one widget.
2. Publish the authorized query source
The queryRef.tool value must resolve to a real Dashboard query source. People
publishes it from
packages/people/src/Contribution/PeoplePersonalDashboardQuerySources.php:
<?php
declare(strict_types=1);
namespace Amuzcorp\Nexia\PeopleCore\Contribution;
use Amuzcorp\Nexia\PeopleCore\Models\Employment;
use Amuzcorp\Nexia\PeopleCore\Models\EmploymentCategory;
use Amuzcorp\Nexia\PeopleCore\Models\EmploymentContract;
use Amuzcorp\Nexia\PeopleCore\Models\Grade;
use Amuzcorp\Nexia\PeopleCore\Models\Job;
use Amuzcorp\Nexia\PeopleCore\Models\Position;
use Amuzcorp\Nexia\PeopleCore\Models\PositionVersion;
use Amuzcorp\Nexia\PeopleCore\Models\Worker;
use Amuzcorp\Nexia\PeopleCore\Models\WorkerAssignment;
use Amuzcorp\Nexia\PeopleCore\Models\WorkerRelationship;
use Nexia\Dashboard\DashboardQuerySource;
use Nexia\Dashboard\Contracts\DashboardQuerySourceContribution;
use Nexia\Dashboard\RendererCapability;
use Nexia\Permission\SubjectPopulation;
final class PeoplePersonalDashboardQuerySources implements DashboardQuerySourceContribution
{
public static function dashboardQuerySources(): array
{
return [
new DashboardQuerySource(
key: 'people.worker_profile.read',
description: 'Signed-in worker employment summary across the worker’s own assignments.',
path: '/api/people/me',
permission: 'people.worker_profile.read',
subjectPopulation: SubjectPopulation::Self,
invalidatedModels: [
Worker::class,
WorkerRelationship::class,
Employment::class,
EmploymentContract::class,
WorkerAssignment::class,
Position::class,
PositionVersion::class,
EmploymentCategory::class,
Job::class,
Grade::class,
],
capability: RendererCapability::HumanAndAgent,
),
];
}
}The HTTP endpoint still enforces its own backend policy. The Dashboard permission
controls availability and discovery; it does not replace route authorization.
SubjectPopulation::Self is also substantive: this query describes the signed-in
worker, not an arbitrary worker selected by the browser.
List every model whose changes invalidate the projection. Omitting one does not make the initial response unauthorized, but it can leave a visible widget stale after that model changes.
3. Register the frontend renderer
The component name in the PHP spec must match the frontend registry exactly. Register the renderer from the App entry point, using the SDK host contract rather than a Core alias:
import { registerLazyAgentComponent } from "@nexia/sdk/host";
registerLazyAgentComponent(
"people.EmploymentSection",
() => import("./profile/PeopleEmploymentSectionDashboardRenderer")
.then((module) => ({
default: module.PeopleEmploymentSectionDashboardRenderer,
})),
);The real renderer receives AgentComponentRendererProps, resolves spec.props,
calls /agent/tool-data for the declared query reference, and renders the same
EmploymentSection presentation used elsewhere in People. Reusing the domain
presentation avoids a second Dashboard-only interpretation of employment data.
Use the host's shared data lifecycle and attach auto-height to the widget's
scroll container. The following hook excerpt belongs inside the renderer component; queryRef, preview, enabled, queryKey, queryFn, and spec come from its validated props and request adapter:
import { useQuery } from "@tanstack/react-query";
import { parseRefreshHint } from "@nexia/sdk";
import {
useAgentToolDataFreshness,
useAgentToolDataInvalidation,
useDashboardWidgetAutoHeight,
} from "@nexia/sdk/host";
const refreshMs = parseRefreshHint(queryRef.refresh);
useAgentToolDataInvalidation(!preview);
const freshness = useAgentToolDataFreshness({ enabled, refreshMs, preview });
const result = useQuery({ queryKey, queryFn, enabled, ...freshness });
const autoHeightRef = useDashboardWidgetAutoHeight(spec.props.widgetId);
return <div ref={autoHeightRef} className="flex h-full min-h-0 flex-col overflow-auto">…</div>;The host clamps fast hints to the dashboard-wide ten-minute minimum, honors
slower hints, and disables the timer while the surrounding Work Tab is parked.
useAgentToolDataInvalidation remains a compatibility no-op because one
shell-wide Reverb ingress owns invalidation. The auto-height ref is inert outside
a board and only lets a newly added board cell settle; later data changes do not
rearrange a widget the user has already placed.
Keep data fetching in the adapter and domain display in a reusable component. It lets a profile slot and a Dashboard placement share presentation while respecting different host prop contracts.
4. Refresh discovery and exercise the widget
Contribution classes are discovered through the generated App map. Refresh it and the runtime caches after adding a new contributor class, then rebuild the App frontend so the lazy renderer is available:
task artisan -- nexia-runtime:generate-app-map
task artisan -- nexia-runtime:refresh-runtime-caches
task artisan -- nexia-apps:activate-package-app people --build-assetsThe resulting catalog entry should use people.my_assignments, resolve
people.EmploymentSection, and fetch through people.worker_profile.read. Do not
place fetched rows directly inside spec; that would make discovery output
request-specific and bypass the query-source boundary.
Verify
Run the focused Dashboard registry and family-resolution contracts:
docker compose exec -T app vendor/bin/pest tests/Unit/Dashboard/DashboardWidgetRegistryTest.php tests/Unit/Dashboard/PersonalWidgetFamilyResolverTest.php --compactThen verify the rendered path:
| Check | Action | Expected result |
|---|---|---|
| Discovery | Open the Dashboard widget picker | people.my_assignments is available to an authorized actor |
| Localization | Switch between en, ko, and zh | The title changes; no catalog key is displayed |
| Data | Place the widget | Assignment rows come from people.worker_profile.read |
| Parked tab | Switch to another Work Tab and inspect network activity | The widget keeps its state but stops steady polling until visible again |
| Initial size | Add the widget to a board | The new cell settles to its natural content height without resizing an established placement later |
| Revocation | Remove the read grant and reload | The widget/data path is unavailable rather than leaking rows |
| Rendering | Inspect the browser console | No unknown-component error for people.EmploymentSection |
Common mistakes
The widget appears but renders as an unknown component. The PHP
spec.component and frontend registry key differ, or the App entry did not load.
Compare the two strings byte for byte and rebuild assets.
The renderer works only with hard-coded sample data. A component preview is
not a query contract. Publish a DashboardQuerySource, point queryRef.tool to
its key, and keep authorization on the backing endpoint.
Translated copy is placed in spec. Specs are shared across locales. Carry a
catalog key and let Core materialize locale-specific text at the payload boundary.
The widget names a model but the contributor does not bind its Resource.
DashboardWidgetContribution extends ResourceCatalogContribution; implement
both resourceKey() and resourceModelClass().
The Dashboard permission is treated as backend enforcement. Discovery and visibility are not authorization. The API or tool path must also identify the caller inside the tenant and enforce its own Policy.
Publish data for Reports
The Dashboard editor separates App widgets from saved Report references:
| Tab | What it does |
|---|---|
| Widget catalog | Places Core/App widgets; Resource-bound widgets may use bespoke query and renderer. |
| Reports | Adds an accessible saved Report revision. Create and edit the analysis in the standalone Report workspace. |
Report authoring is available to human and Agent authoring through the same server-validated composition and presentation contract. It reuses existing Resource declarations without an App-to-App dependency or a Dashboard-specific permission.
What an App developer must publish
To appear in Report authoring, a Resource needs:
- Publish active
ResourceDescriptor, translatedlabelKey, andpublicForBuilder: true. - Keep its App operational with standard SQL authorization so Core authorizes each source query.
- Keep direct
public_idas the Resource identity and publish each safe business field infieldSchemawithlabel_keyplus explicitcomposition.selectable,groupable, aggregations, and time buckets as appropriate. A field without a resolved label is not a usable builder field. - Declare supported
Exactfilters inresourceListFields()with catalog label keys. Filters are independent of output: a field also needs explicit composition metadata to be selectable. Enums publish their raw values andenum_labelsfor translated choices. - Publish relationship evidence:
- for row results, declare a direct
resource_referencefield with accepted target Resource keys and matching storage topology; - Party counts need canonical
party_public_idon both Resources and proven Party foreign-key topology.
- for row results, declare a direct
Core uses only catalog-bounded, result-safe graphs; never arbitrary joins.
For an App-owned scope, field redaction, or safe projection that the standard
Resource query cannot express, implement CompositionQueryContribution and
return an AuthorizedCompositionQuery from its provider. Core restores the
actor, request, Resource key, and exact organization targets; the provider
returns the already-authorized Eloquent query plus the only field aliases Core
may select, filter, bind, or group. Each published field declares its alias,
type, and supported capabilities; it may also carry a localized label, enum
labels, unit, or target Resource key. Reuse the Resource's read permission and
row predicate. Do not add a Dashboard permission, accept client SQL, or create
a chart-specific App endpoint.
Core derives and actor-filters the Resource and relationship catalog. Do not publish an App-pair join, cross-App import, reporting table, model, SQL, or physical column. See Create a Resource, Resource contracts, and Resource References.
GET /api/dashboards/resource-composition/catalogreturns actor-discoverable Resources, server-issued relationships, and active limits.POST /api/dashboards/resource-composition/previewaccepts an explicitlegal_entity_public_id, optionaloperating_unit_public_id, and a strictcomposition_spec. It returns the canonical spec, descriptor fingerprint, server-issued output columns, and a boundedresultwithshape,rows, andmeta.- Preview returns suitability for
Table, metric, bar, line, donut, and pivot. The editor saves one valid presentation mapping through/api/dashboards/data-widgets. Placements keep a component snapshot after saved-item deletion.
CompositionSpec carries semantic intent only and has one current schema,
schema_version: 1. It declares bounded source aliases, server-issued opaque
relationships with explicit source, target, and match behavior, and required
population semantics. Row results additionally declare row_source. Before
planning, Core verifies endpoints, topology, authorization, grain, and cost
against the current catalog. Specs select fields, typed where predicates,
dimensions, measures, and rows or aggregate; they never supply tables,
models, SQL, joins, or output paths. Read limits and fields from the catalog.
Displayed columns and filters
Displayed columns are result-Table columns, never relationships or filters. The host includes the resource identity and admits only current catalog fields declared selectable by the Resource or its authorized provider. It uses the published labels and reference decoration; it does not expose raw SQL aliases or turn filterability into output authority.
Filters constrain an owning Resource's authorized query. Supported Exact
entries become eq/in; descriptor enums plus enum_labels become translated
choices, booleans yes/no, and single date/time/numeric values native inputs.
Other strings, UUIDs, and unlisted multi-values use text. Filterability never
exposes a field in output.
A reusable data widget stores the canonical composition and a separately validated presentation snapshot. Presentation maps only previewed output paths: Table uses its ordered columns; metric maps one aggregate measure; bar/donut map one category plus measure; line maps a time bucket plus measure; and pivot maps two dimensions plus a measure. Choosing a presentation never grants fields, joins, or aggregation authority.
{
"component": "Table",
"title": "Assignments by party",
"columns": [{ "key": "dimension_label", "label": "Party" }],
"pageSize": 25,
"paginate": true
}Column keys and order exactly match preview output_columns; the server derives
formats. Saved items are owner-scoped: ready, needs_repair, or
source_unavailable. Discovery, preview, save, serialization, and every live
query re-check actor, explicit organization target, source-App lifecycle, and
the exact previewed descriptor fingerprint, then relationship, field
visibility, and row authorization. Disabled Apps, changed descriptors, or
revoked permission fail closed; old queries never replay.
Publishing additional or changed descriptor fields can change that fingerprint.
The saved widget then becomes needs_repair; retain its saved snapshot, open it
for a fresh preview, and repair its field or presentation selection before it
can run again. This release does not migrate saved widgets automatically.
app/ResourceComposition/SemanticCatalog.php,
app/ResourceComposition/RelationshipResolver.php, and
app/ResourceComposition/CompositionPlanner.php own eligibility/public fields,
relationship proof, and live authorization/planning.