Skip to content
Guide

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

  1. Use an existing Resource and protected read API from Create a Resource.
  2. Return a DashboardWidget from DashboardWidgetContribution, with a language-neutral component spec and a query reference.
  3. Publish the matching DashboardQuerySource, including permission, subject population, and either complete model invalidation or pollingOnly: true. The backing endpoint must independently authorize every read.
  4. 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:

Code example
PHP
<?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:

Code example
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:

Code example
TypeScript
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:

Code example
TSX
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:

Code example
Shell
task artisan -- nexia-runtime:generate-app-map
task artisan -- nexia-runtime:refresh-runtime-caches
task artisan -- nexia-apps:activate-package-app people --build-assets

The 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:

Code example
Shell
docker compose exec -T app vendor/bin/pest tests/Unit/Dashboard/DashboardWidgetRegistryTest.php tests/Unit/Dashboard/PersonalWidgetFamilyResolverTest.php --compact

Then verify the rendered path:

CheckActionExpected result
DiscoveryOpen the Dashboard widget pickerpeople.my_assignments is available to an authorized actor
LocalizationSwitch between en, ko, and zhThe title changes; no catalog key is displayed
DataPlace the widgetAssignment rows come from people.worker_profile.read
Parked tabSwitch to another Work Tab and inspect network activityThe widget keeps its state but stops steady polling until visible again
Initial sizeAdd the widget to a boardThe new cell settles to its natural content height without resizing an established placement later
RevocationRemove the read grant and reloadThe widget/data path is unavailable rather than leaking rows
RenderingInspect the browser consoleNo 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:

TabWhat it does
Widget catalogPlaces Core/App widgets; Resource-bound widgets may use bespoke query and renderer.
ReportsAdds 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:

  1. Publish active ResourceDescriptor, translated labelKey, and publicForBuilder: true.
  2. Keep its App operational with standard SQL authorization so Core authorizes each source query.
  3. Keep direct public_id as the Resource identity and publish each safe business field in fieldSchema with label_key plus explicit composition.selectable, groupable, aggregations, and time buckets as appropriate. A field without a resolved label is not a usable builder field.
  4. Declare supported Exact filters in resourceListFields() with catalog label keys. Filters are independent of output: a field also needs explicit composition metadata to be selectable. Enums publish their raw values and enum_labels for translated choices.
  5. Publish relationship evidence:
    • for row results, declare a direct resource_reference field with accepted target Resource keys and matching storage topology;
    • Party counts need canonical party_public_id on both Resources and proven Party foreign-key topology.

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.

  1. GET /api/dashboards/resource-composition/catalog returns actor-discoverable Resources, server-issued relationships, and active limits.
  2. POST /api/dashboards/resource-composition/preview accepts an explicit legal_entity_public_id, optional operating_unit_public_id, and a strict composition_spec. It returns the canonical spec, descriptor fingerprint, server-issued output columns, and a bounded result with shape, rows, and meta.
  3. 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.

Code example
JSON
{
  "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.

Source of truth: docs/developers/content/en/platform-extensions/add-dashboard-widget.md