Admiral. How we create badges in the menu

Dima K, lead backend developer
Dima K, lead backend developer
Feb 27, 2024
10 minutes
Hello everyone! We continue to work on Admiral - an open-source solution in React. With its help, you can quickly develop beautiful CRUDs in admin panels and create fully custom interfaces. From developers to developers with love.

The project is available at the following link – https://github.com/dev-family/admiral
We would appreciate your ⭐.

In the previous article, we discussed building a dynamic menu on the backend for Admiral. Today, we'll delve into the functionality of badges (counters) next to menu items and what we do with them on the backend.
In the previous article, we discussed the fields present in the menu, and one of them is "badge". It is responsible for displaying a value near the menu item.

Backend

Let's discuss what to do on the backend side in Laravel. This is what a simplified version of the MenuService file looks like, which is responsible for generating the dynamic menu for Admiral. To bring it up, you need the UserMenu method with the "user" parameter, for whom this menu is being built (if there is a need for menu separation by roles).

php
class MenuService
{
    function menu(): Menu
    {
        return Menu::make()
            ->addItem(MenuItem::make(MenuKey::PRODUCTS, 'Products', 'FiProduct', "/products"));
    
    function permissions(): array
    {
        $result = [];
        foreach ($this->menu()->items() as $menuItem) {
            if ($menuItem->isParent()) {
                $result[] = [
                    'value' => $menuItem->key()->value,
                    'label' => $menuItem->name(),
                ];
                continue;
            }
            foreach ($menuItem->children()->items() as $child) {
                $result[] = [
                    'value' => $child->key()->value,
                    'label' => $menuItem->name() . '->' . $child->name(),
                ];
            }
        }
        
        return $result;
    }
    
    public function userMenu(User $user): array
    {
        $menu = $this->menu();
        $permissions = $user->role?->permissions ?? [];
        
        if ($user->role?->title == Role::ADMIN) {
            $permissions = collect(MenuKey::cases())->pluck('value')->toArray();
        }
        
        $badge = new Badge($user, $permissions);
        
        return $menu->toArray($permissions, $badge);
    }
    
    public function toFromKey(string $permission): ?string
    {
        return match (MenuKey::tryFrom($permission)) {
            MenuKey::PRODUCTS => "/products",
            default => null,
        };
    }
}
Let's consider the main components of our menu: the Menu class, representing the framework, and the MenuItem class, representing individual menu items.

php
class Menu
{
    protected array $items = [];
    protected ?string $prefix = null;

    public static function make(): static
    {
        return new static();
    }

    /**
     * @return  array|MenuItem[]
     */
    public function items(): array
    {
        return $this->items;
    }

    public function addItem(MenuItem $item): static
    {
        $this->items[] = $item;

        return $this;
    }

    public function toArray(array $permissions, Badge $badge): array
    {
        return collect($this->items)
            ->map(fn (MenuItem|Menu $item) => $item->toArray($permissions, $badge))
            ->filter()
            ->values()
            ->toArray();
    }
}
Nothing special, a simple object with simple methods:
  • items – for menu items
  • addItem() – for adding a new items

php
class MenuItem
{
    protected ?Menu $children = null;
    protected ?string $to = null;

    public function __construct(
        private MenuKey $key,
        private string $title,
        private string $icon,
        string|Menu $value,
    ) {
        if (is_string($value)) {
            $this->to = $value;

            return;
        }

        $this->children = $value;
    }

    public static function make(...$args): static
    {
        return new static(...$args);
    }

    public function isParent(): bool
    {
        return !!$this->to;
    }

    public function name(): string
    {
        return $this->title;
    }

    public function to(): string
    {
        return $this->to;
    }

    public function key(): MenuKey
    {
        return $this->key;
    }

    public function children(): ?Menu
    {
        return $this->children;
    }

    public function toArray(array $permissions, Badge $badge): ?array
    {
        $data = [
            'name'  => $this->title,
            'icon'  => $this->icon,
            'badge' => $badge->get($this->key),
        ];

        if ($this->children) {
            $data['children'] = $this->children->toArray($permissions, $badge);

            if (!$data['children']) {
                return null;
            }

            return $data;
        }

        if (!in_array($this->key->value, $permissions)) {
            return null;
        }

        $data['to'] = $this->to;

        return $data;
    }
}
MenuItem looks more exciting. Here we have parameters like title, icon, to. I think it's clear what role each one plays.

Let's pay attention to how the menu item is being formed. It's the toArray method. But even more interesting is the line for forming the Badge, and what the get method does.

php
'badge' => $badge->get($this->key),

Let's take a closer look at what the Badge class does.

php
class Badge
{
    protected Collection $badges;
    
    public function __construct(User $user, array $permissions)
    {
        $this->badges = $this->getBadges($user, $permissions)->keyBy('type');
    }
    
    public function get(MenuKey $key): ?int
    {
        return match ($key) {
            MenuKey::PRODUCTS => $this->findByType(MenuKey::PRODUCTS),
            default => null,
        };
    }
    
    protected function findByTypes(array $types): ?int
    {
        $sum = 0;
        foreach ($types as $type) {
            $sum = $sum + $this->findByType($type);
        }
        
        return $sum > 0 ? $sum : null;
    }
    
    protected function findByType(MenuKey $key): ?int
    {
        return $this->badges->get($key->value)?->count;
    }
    
    private function getBadges(User $user, array $permissions): Collection
    {
        $queries = [];
        
        foreach ($permissions as $permission) {
            $q = MenuKey::tryFrom($permission) ? $this->query($user, MenuKey::tryFrom($permission)) : null;
            
            if ($q) {
                $queries[] = $q;
            }
        }
        
        if (!count($queries)) {
            return collect();
        }
        
        $badgeQuery = DB::query();
        
        foreach ($queries as $key => $query) {
            if ($key == 0) {
                $badgeQuery->fromSub($query, 'badge_count');
                continue;
            }
            
            $badgeQuery->unionAll($query);
        }
        
        return $badgeQuery->get();
    }
    
    protected function query(
        User $user,
        MenuKey $key
    ): \Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Builder|null {
        return match ($key) {
            MenuKey::PRODUCTS => $this->productsQuery($user),
            default => null,
        };
    }
    
    private function productsQuery(User $user): \Illuminate\Database\Eloquent\Builder
    {
        return Products::query()
            ->selectRaw("count(*) as count, '" . MenuKey::PRODUCTS->value . "' as type")
            ->where('status', ProductStatus::NEW);
    }
}
In this class, we collect information about badges. Specifically, in this case, for the "Products" menu item. I make one common query to obtain the information.

If it is necessary to add a new query, for example, if an "Orders" menu item appears, - it is necessary to add the following line to the get method:

php
MenuKey::ORDERS => $this->findByType(MenuKey::ORDERS),
And also add the query to the query method:

php
MenuKey::ORDERS => $this->ordersQuery($user),
Then write a productQuery function that will generate a query to receive orders based on specific parameters. If it's necessary, this data can be cached.

On the Admiral side, in the menu.tsx file, MenuItemLink should look like this, where in Badge, count represents the value, and status represents the color of the menu item.

typescript
<MenuItemLink  
   key={`${i}`}  
   icon={menu.icon}  
   name={menu.name}  
   to={menu.to}  
   badge={{ count: menu.badge, status: 'error' }}  
/>
Done. Now a number next to the menu item appears if it meets the query.
If you have any questions, please get in touch with us at: [email protected]. We are always happy to hear your feedback!