Adding a Dashboard Widget

5 Aufrufe Markdown

Add a card to the admin dashboard by returning a descriptor from one registration hook, with no core file touched.

Overview

Dashboard widgets are not a module type: there is no widget module and no base class. get_widgets() builds them out of privilege checks, one template shows them, hooks extend them.

You return an array from register:admin.dashboard_widgets. The core gives it a rank, applies the operator's saved layout, and hands it to the shared card shell. Four more hooks reach the rest.

Prerequisites

  • A module with a working hooks.php; see Registering Hooks from a Module.
  • Your content as an HTML string; the body is echoed as is.
  • A privilege key if the card should be restricted.

Structure

controllers/admin/index.php get_widgets() builds the list and merges your hook's return; get_statistics() builds the figures strip.
templates/admin/index.php The dashboard shell: fires the injection hooks and the widget filter, then loops the list into cards.
inc/template-widget-item.php The card shell. An unknown name falls through to your content.
operations/AdminIndex.php get_widget_content, behind the refresh button.
js/home.js Lays the grid out with Packery: rank is only the order in the HTML, not where a card lands.

Walkthrough

Register the Card

  1. Listen on register:admin.dashboard_widgets from your hooks.php and return one descriptor array.
  2. Give it at least name, title and content; everything else has a default.
  3. Check the privilege inside the listener: return false when it fails, or set allowed to the result.

Build the Body

  1. Produce the HTML with heredoc and read your strings from the module's language file.
  2. Build it inside the listener, not in the file body, so a dashboard without your card costs nothing.
  3. Reserve the height of anything that fills in later.

Control Placement and Size

  1. Set rank for the order in the markup; left out, the core appends yours last.
  2. Set size to wide for a full-width card; any other value gives the standard half-width one.
  3. For the strip, the surrounding markup or someone else's cards, use the four other hooks below.

Reference

The Five Dashboard Hooks

HookCall siteYour return
register:admin.dashboard_widgetsHook::run, no argumentsone descriptor becomes one card; false registers nothing
filter:admin.dashboard.widgetsHook::runRefs, $widgets by referenceedit in place: reorder, drop, retitle
filter:admin.dashboard.statisticsHook::run, $resulta non-empty return replaces the whole array
ui:admin.dashboard.topHook::run, no argumentsa string above the figures strip
ui:admin.dashboard.statistics.after and ui:admin.dashboard.bottomsame, no argumentsa string after the strip, and after the grid
The statistics filter replaces, it does not merge

The call site keeps the last non-empty listener return and assigns it over the whole result. Change the key you care about and return the whole array; returning only your own key wipes the strip, which still looks plausible.

The Descriptor Array

name The identity: data-id, the saved-layout key, and the refresh argument. Default wt{rank}, never rely on it.
title The heading, shown as raw HTML. Default Untitled Widget; wrapped in a link when you set link.
content The card body, echoed as is. Empty shows the widget name instead, the symptom of forgetting it.
icon Bootstrap icon class for the disc beside the title. Default bi bi-box.
allowed Boolean gate, default true. False removes the card before it appears.
status open or close, default open. A closed card shows its header only.
rank Integer order in the markup. Default: one past the last registered widget.
size wide adds the full-width class; any other value keeps the standard width.
hidden Boolean. The card is produced but starts with display none, how the close button remembers itself.
link Turns the title into a link. Build it with the link generator, never a literal path.
buttons Header buttons: ['create' => ['name' => …, 'link' => …, 'icon' => …]]. create gets a plus icon; other keys use icon, or the name as text.
header_buttons Raw HTML before the refresh, collapse and close buttons.

What the Core Fills In

the merge
$hook = Hook::run("register:admin.dashboard_widgets");
if ($hook)
{
    $last = end($widgets);
    $rank = $last["rank"] ?? 10;

    foreach ($hook as $h)
    {
        $rank++;
        $wn = "wt" . $rank;

        if (isset($h["name"]) && $h["name"]) $wn = $h["name"];
        if (!isset($h["allowed"])) $h["allowed"] = true;
        if (!isset($h["status"])) $h["status"] = "open";
        if (!isset($h["rank"]))   $h["rank"]   = $rank;

        $widgets[$wn] = $h;                 // your name is the key: it can REPLACE a built-in card
    }
}

The Refresh Button

signature
public function get_widget_content(Operation $operation): bool;
the operation
// GET {dashboard}?operation=get_widget_content&name={your name}
$widgets = $this->get_widgets();                 // the WHOLE list is rebuilt, hook included
if (!isset($widgets[$wn])) throw new Exception("Invalid widget");

// One card re-rendered through the same shell, returned as an HTML string.
return $operation->output($this->view->chose("admin")->render("inc" . DS . "template-widget-item", [
    'widget' => $widgets[$wn],
], true));

Putting a Table in a Card

reusing a list preset
$t = new \WISECP\Components\Table("myAddonWidget", [
    'preset'      => 'invoiceList',      // row rendering comes from the MAIN list
    'hideActions' => true,
    'perPage'     => false,
    'search'      => false,
    'info'        => false,
    'pagination'  => false,
]);

foreach ($t->getColumns() as $k => $v) $t->setColumn($k, ['sortable' => false]);
$t->deleteColumn("selection");
$t->setRows($rows);

$html = $t->build();
Rows must carry every key the preset reads

A widget table borrows the main list's row builder, which reads far more keys than the columns you kept. A missing key shows an empty cell instead of failing. Derive the list from the preset file; a change there reaches your card too.

Example

A module's own queue: registered, gated, ranked and refreshable.

MyAddon/hooks.php
<?php

Modules::Load('Addons', 'MyAddon', true);
$my_config = Modules::Config('Addons', 'MyAddon') ?: [];

if (!($my_config['status'] ?? false)) return;

/*
 * One card on the dashboard. The listener takes no arguments and returns ONE descriptor;
 * it runs on every dashboard render and on every refresh of this card, so it stays cheap
 * and builds the module instance only after the privilege check has passed.
 */
Hook::add('register:admin.dashboard_widgets', 10, function () {
    if (!Admin::isPrivilege('TOOLS_ADDONS')) return false;

    $module = Modules::getInstance('Addons', 'MyAddon');

    return [
        'name'    => 'myaddon_queue',
        'title'   => $module->lang['widget-title'] ?? 'My Addon Queue',
        'icon'    => 'bi bi-list-check',
        'rank'    => 6,
        'status'  => 'open',
        'link'    => LinkGenerator::admin('tools-2', ['addons', 'MyAddon']),
        'buttons' => [
            'create' => [
                'name' => Language::gc('admin/index/button-create-a-new'),
                'link' => LinkGenerator::wQS(LinkGenerator::admin('tools-2', ['addons', 'MyAddon']), ['trigger' => 'create']),
            ],
        ],
        'content' => $module->render_dashboard_widget(),
    ];
});

/* A figure on the strip. Read what you were given, change one key, return ALL of it. */
Hook::add('filter:admin.dashboard.statistics', 10, function ($result) {
    if (!is_array($result)) return false;

    $result['static_blocks']['myaddon_pending'] = [
        'title' => 'Pending syncs',
        'value' => (int) WDB::select('COUNT(id) AS total')->from('MyAddon_queue')
            ->where('status', '=', 'pending')->build() ? (int) (WDB::getAssoc()['total'] ?? 0) : 0,
    ];

    return $result;
});
MyAddon.php, the body
public function render_dashboard_widget(): string
{
    $rows = '';

    foreach ($this->queue_preview(5) as $row) {
        $label = htmlspecialchars((string) ($row['label'] ?? ''), ENT_QUOTES);
        $state = htmlspecialchars((string) ($row['status'] ?? ''), ENT_QUOTES);

        $rows .= <<<HTML
        <li class="list-group-item d-flex justify-content-between align-items-center px-0">
            <span class="text-truncate">{$label}</span>
            <span class="badge text-bg-light">{$state}</span>
        </li>
        HTML;
    }

    if ($rows === '')
        $rows = '<li class="list-group-item px-0 text-body-secondary">' . ($this->lang['widget-empty'] ?? 'Nothing queued.') . '</li>';

    // The class carries the reserved height (see the stylesheet below), which is what stops
    // the whole grid from repacking once the list fills in.
    return <<<HTML
    <ul class="list-group list-group-flush myaddon-queue">{$rows}</ul>
    HTML;
}
assets/style/admin.css
/* The final height, declared before the content exists. Ship it through
   ui:admin.head.css from the same hooks.php that registers the card. */
.myaddon-queue { min-block-size: 220px; }

The reading side is the card shell: the fallback for a missing content, and why the body is not escaped.

the card shell
$w_name    = $widget["name"]  ?? "widget" . $w_rank;
$w_title   = ($widget["title"] ?? '') ?: 'Untitled Widget';
$w_icon    = ($widget["icon"]  ?? '') ?: 'bi bi-box';
$w_content = $widget["content"] ?? '';

if ($w_name == "orders_chart") {
    // ... a long chain of built-in names, one branch each
}
else
    echo $w_content ?: $w_name;      // your HTML, unescaped, or the name when you forgot it

Pitfalls

A card that grows after the first paint repacks the grid

The layout is masonry and re-measures every card on each pass. A body that fills in later changes its card's height, and unrelated cards slide across the screen. Measured: a chart box growing 54 pixels moved two other cards 733 pixels.

The operator's saved arrangement outranks your descriptor

A stored rank, collapsed state or hidden flag for your widget name beats the descriptor, and it travels in a cookie: a card registered open can be collapsed for one administrator, open for a colleague. A collapsed card's content is in the page, hidden by CSS.

Your listener runs on every dashboard load, not once

It also runs for each refresh of any card, because that operation rebuilds the entire list. That is what makes the refresh button work, and it means a query there is paid every time. Do the privilege check first and cache anything expensive.

Registering under an existing name replaces that card

The merge keys by name, so notes or tasks as your widget name takes over the built-in card. Prefix the name with your module.

War das hilfreich?

Vielen Dank für Ihre Rückmeldung!

Brauchen Sie weitere Hilfe?

Unser Support-Team ist rund um die Uhr für Sie da, wenn Sie oben nicht fündig werden.