# Adding a Dashboard Widget

https://dev.wisecp.com/es/adding-a-dashboard-widget

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](https://dev.wisecp.com/en/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

| Hook | Call site | Your return |
| --- | --- | --- |
| `register:admin.dashboard_widgets` | `Hook::run`, no arguments | one descriptor becomes one card; `false` registers nothing |
| `filter:admin.dashboard.widgets` | `Hook::runRefs`, `$widgets` by reference | edit in place: reorder, drop, retitle |
| `filter:admin.dashboard.statistics` | `Hook::run`, `$result` | a non-empty return **replaces the whole array** |
| `ui:admin.dashboard.top` | `Hook::run`, no arguments | a string above the figures strip |
| `ui:admin.dashboard.statistics.after` and `ui:admin.dashboard.bottom` | same, no arguments | a 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

```php
$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

```php
public function get_widget_content(Operation $operation): bool;
```

```php
// 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

```php
$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.

```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;
});
```

```php
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;
}
```

```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.

```php
$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.

## Related Articles

- [Registering Hooks from a Module](https://dev.wisecp.com/en/registering-hooks-from-a-module)
- [Adding an Admin Page](https://dev.wisecp.com/en/adding-an-admin-page)
- [The Hook Catalog](https://dev.wisecp.com/en/hook-domains)
- [Interface Components](https://dev.wisecp.com/en/interface-components)
- [The Module System](https://dev.wisecp.com/en/the-module-system)
- [Building Links and Routes](https://dev.wisecp.com/en/building-links-and-routes)
