# A nav item with a counter (https://light.magerex.nl/md/developer/recipes/nav-counter.md)

> A nav item under the right section, and a counter that shows the same work everywhere.

A nav item takes the merchant to your screen; a counter on it says that work is waiting there. Light shows the same count on the nav item, on the app's pin, in the palette and on Home.

## When to use it

- Add a nav item under the section whose job your screen does ([Built for Light, rule 1](https://light.magerex.nl/md/developer/ux-guidelines.md#1-place-dont-add)).
- Add a counter only for work: requests to review, returns to handle. Return null at 0 and use `attention` unless something is broken or blocks a sale ([rule 3](https://light.magerex.nl/md/developer/ux-guidelines.md#3-counts-mean-work)).

## How a count reaches the page

Rendering a page never runs your counter. The nav reads the last counts from the cache type `mrx_light_badges`, and one background request after the page loads asks for the counts that are stale or missing:

```mermaid
sequenceDiagram
    autonumber
    actor Admin
    participant Nav as nav.phtml and Navigation\Builder
    participant Cache as Navigation\BadgeCache
    participant Pool as Navigation\BadgePool
    participant Store as cache type mrx_light_badges
    participant JS as Mrx_Light/js/nav-badges
    participant Ctrl as light/navigation/badges
    participant Yours as your BadgeSourceInterface
    Admin->>Nav: open a Light page in simple mode
    Nav->>Cache: peek(key, withRollup)
    Cache->>Pool: getProviders(key, withRollup)
    Cache->>Store: load(mrx_badge_{provider}_{locale})
    Store-->>Cache: a count, stale, or nothing
    Cache->>Pool: merge()
    Cache-->>Nav: the badge, and whether it needs a refresh
    Nav-->>Admin: HTML with data-mrx-badge-refresh
    JS->>Ctrl: GET keys[] and rollup[] of the stale counts
    Ctrl->>Cache: refresh(key, withRollup)
    Cache->>Pool: run(provider)
    Pool->>Yours: getBadgeValue()
    Yours-->>Pool: Badge or null
    Cache->>Store: save for 10 × ttl, tagged mrx_badge_{item}
    Ctrl-->>JS: items with count, display, tone and label
    JS-->>Admin: draw the counters, then dispatch mrx:badges
```

Home to-dos and the palette read the same count through `BadgeReaderInterface::get()`, which runs a stale provider at once. A pinned app shows the count of its `badge_from` nav item.

<Callout title="Counts mean work">
Return null at 0, and keep the tone `attention` unless something is broken or blocks a sale.
</Callout>

## Steps

The pilot bridge's files that this recipe quotes:

<Files>
<Folder name="app/code/Disrex/RequestAnAccountLight" defaultOpen>
  <Folder name="etc" defaultOpen>
    <Folder name="adminhtml" defaultOpen>
      <File name="di.xml" />
    </Folder>
  </Folder>
  <Folder name="Model" defaultOpen>
    <File name="PendingRequests.php" />
  </Folder>
  <Folder name="Controller" defaultOpen>
    <Folder name="Adminhtml" defaultOpen>
      <Folder name="Accountrequests" defaultOpen>
        <File name="MassStatus.php" />
      </Folder>
    </Folder>
  </Folder>
</Folder>
</Files>

<Steps>
<Step>

### 1. The nav item

A nav item (pool `nav_items`) in `etc/adminhtml/di.xml`, with the section as `parent`, your Light route, a `fallback_route` for when the Light page isn't there, and `match` so the item is highlighted on your pages:

```xml title="app/code/Disrex/RequestAnAccountLight/etc/adminhtml/di.xml"
<item name="account_requests" xsi:type="array">
    <item name="label" xsi:type="string" translate="true">Account requests</item>
    <item name="parent" xsi:type="string">customers</item>
    <item name="route" xsi:type="string">light/accountrequests/index</item>
    <item name="fallback_route" xsi:type="string">requestanaccount/request/index</item>
```

Nav items that depend on data, such as one item per warehouse, come from a class that implements `ItemProviderInterface`, registered in the `Navigation\Config` argument `providers` (pool `nav_providers`). Its items take the same keys, plus `url`: a ready admin URL that Light uses instead of `route`, and an unsafe one drops the item. Light calls each provider at most once per request, on every page with the nav, so read from a cache or one cheap query. A `di.xml` item with the same key wins over the provider's, and a provider that throws is logged and its items left out.

The doctor reads files and never calls a provider, so it can't see the keys a provider returns. Name them with `#[ProvidesNavItems]` on the class when a counter, a to-do or a child item points at one:

```php title="app/code/MageRex/Support/Model/Navigation/SupportItem.php"
#[RequiresModule('MageRex_Support')]
#[ProvidesNavItems('support')]
class SupportItem implements ItemProviderInterface
```

Leave out keys that only exist at runtime, such as one per warehouse. The doctor reads the attribute from the class and its parents, so a `\Proxy` carries it, and it skips a provider whose `#[RequiresModule]` names a module that is off.

</Step>
<Step>

### 2. The counter

A class that implements `BadgeSourceInterface` returns a `Badge` or null:

```php title="app/code/Mrx/Light/Api/Navigation/BadgeSourceInterface.php"
public function getBadgeValue(): ?Badge;
```

Light lets go of the admin session before it calls your provider, so badges, rows and search answers run in parallel: read the session, never write to it here; a write is lost.

The pilot counts the requests to review. The label is what a screen reader says, in the admin's language:

```php title="app/code/Disrex/RequestAnAccountLight/Model/PendingRequests.php"
if ($count <= 0) {
    return null;
}

return new Badge($count, 'attention', (string)($count === 1
    ? __('%1 account request to review', $count)
    : __('%1 account requests to review', $count)));
```

A badge has no URL: every place that shows the count links to its own target.

</Step>
<Step>

### 3. Register the counter

A `BadgePool` `providers` item (pool `nav_badge_providers`), keyed by a code of your own, names the nav item. `rollup` also counts on the parent while its section is closed:

```xml title="app/code/Disrex/RequestAnAccountLight/etc/adminhtml/di.xml"
<item name="disrex_account_requests" xsi:type="array">
    <item name="item" xsi:type="string">account_requests</item>
```

The keys are `item`, `provider`, `resource` (default: the nav item's), `module`, `rollup`, `ttl` (seconds a count stays fresh, default 60, at least 10) and `sort_order`:

<TypeTable
  type={{
    item: { description: "The key of the nav item that shows the count.", type: "string", required: true },
    provider: { description: "Your BadgeSourceInterface class, registered as \\Proxy.", type: "object", required: true },
    resource: { description: "Only admins with this ACL resource see the count.", type: "string", default: "the nav item's resource" },
    module: { description: "The count goes inert while this module is off.", type: "string | string[]" },
    rollup: { description: "Also count on the parent while its section is closed.", type: "boolean", default: "false" },
    ttl: { description: "Seconds a count stays fresh. Light keeps it for ten times as long.", type: "number", default: "60, at least 10" },
    sort_order: { description: "The order of the counters on one item.", type: "number", default: "100" },
  }}
/>

Core declares the same list in the catalogue:

```xml title="app/code/Mrx/Light/etc/di.xml"
<item name="keys" xsi:type="string">item provider resource module rollup ttl sort_order</item>
```

Several modules can count on one nav item: Light adds the counts, shows the most urgent tone and joins the labels.

</Step>
<Step>

### 4. Drop the count when the work is done

Light keeps each count in the cache type `mrx_light_badges` and refreshes it in the background at most once per `ttl`. When your code changes what you count, drop the cached count at once with `BadgeCacheInterface::invalidate()`. It works in every area, so a storefront observer, a REST call or a cron job can call it too. The pilot does it after a status change:

```php title="app/code/Disrex/RequestAnAccountLight/Controller/Adminhtml/Accountrequests/MassStatus.php"
$this->badgeCache->invalidate('account_requests');
```

</Step>
<Step>

### 5. The same count on pins, in the palette and on Home

- **Pins.** A pinned app shows the count of its claim's `nav_key`, or of `badge` for a declared app ([An app, its category and pins](https://light.magerex.nl/md/developer/recipes/app-and-pins.md)).
- **Palette.** The nav item's "Go to" entry shows the count by itself.
- **Home.** A to-do with `badge` shows the count while it is above 0 ([Home to-dos, setup steps and widgets](https://light.magerex.nl/md/developer/recipes/home.md)).

</Step>
</Steps>

## Moving from `badges` to `providers`

Before the 0.1.0 beta a counter implemented `BadgeProviderInterface` and was registered in the `BadgePool` argument `badges` (pool `nav_badges`), one per nav item:

<Tabs items={['providers, from 0.1.0', 'badges, before 0.1.0']}>
<Tab value="providers, from 0.1.0">

```php title="app/code/Mrx/Light/Api/Navigation/BadgeSourceInterface.php"
public function getBadgeValue(): ?Badge;
```

</Tab>
<Tab value="badges, before 0.1.0">

```php title="app/code/Mrx/Light/Api/Navigation/BadgeProviderInterface.php"
public function getBadge(): ?string;
```

</Tab>
</Tabs>

That still works: Light turns each entry into a provider with the nav item's resource. A string of digits becomes a count; other text shows only when it is the item's only counter. Move to `providers` to get a tone, a spoken label, roll-up and several counters per item. To retire an entry another module put in `badges`, set it to `null`, as Orders does for Light's own `orders` entry:

```xml title="app/code/Mrx/Orders/etc/adminhtml/di.xml"
<item name="orders" xsi:type="null"/>
```

## What the admin sees

<Tabs items={['Desktop', 'Phone']}>
<Tab value="Desktop">

![The navigation with its counters: Orders counts the orders to ship, and Customers the account requests to review, rolled up while the section is closed](https://light.magerex.nl/screenshots/recipes/nav-counter/nav-counters.webp)

</Tab>
<Tab value="Phone">

![The navigation on a phone, with the same counters](https://light.magerex.nl/screenshots/recipes/nav-counter/nav-mobile.webp)

</Tab>
</Tabs>

- **Simple mode.** "Customers 5" while Customers is closed (roll-up), "Account requests 5" inside it, the same count on the pin and under "Go to" in the palette, and "5 account requests to review" on Home. A fresh count appears a moment after the page loads, when the cache had none.
- **Advanced mode.** The stock menu, without Light counts.

## ACL

Each provider counts only for admins with its `resource`. A Staff role without the module's resource sees none of the counts.

## Check it

- `bin/magento mrx:light:doctor --module=<your module>`: `unknown_reference` with kind `parent` or `item` catches a misspelled key, or a provider's key that `#[ProvidesNavItems]` doesn't name, `nav_sort` and `nav_shortcut` a clash with another item.
- `bin/magento cache:clean mrx_light_badges` clears every count.
- `tests/playwright/pilot-request-an-account.spec.ts` checks the count everywhere.

## Pitfalls

- A count that shows late, or only after a reload: the cache type `mrx_light_badges` is disabled in Cache Management, so every page asks in the background ([Troubleshooting](https://light.magerex.nl/md/developer/troubleshooting.md#counters-load-late)).
- A counter class whose constructor needs another module's classes is registered as `\Proxy`, with `#[RequiresModule]`.
