Время прочтения — 10 минут

Admiral. Как мы делаем бейджи в меню

Всем привет! Мы продолжаем делать Admiral — решение с открытым исходным кодом на React. С ним можно быстро разрабатывать красивые CRUDы в админ-панелях и создавать полностью кастомные интерфейсы. От разработчиков для разработчиков с любовью.

Проект доступен по ссылке — https://github.com/dev-family/admiral
Будем рады вашим оценкам!

В прошлой статье мы рассказывали про построение динамического меню на backend для Admiral. Сегодня разберем работу Badges (счетчиков) рядом с пунктами меню, и что мы делаем с ними на стороне backend.
В прошлой статье мы говорили о полях, которые присутствуют в меню — одно из них badge. Оно и отвечает за вывод значения возле пункта меню.

Backend

Давайте разберем, что делать на стороне backend в Laravel. Так выглядит файл упрощенного MenuService, который занимается генерацией динамического меню для Admiral. Чтобы его вызвать, нужен метод UserMenu с параметром "пользователь", для которого это меню и строится (если есть необходимость разделения меню по ролям).

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,
        };
    }
}
Рассмотрим основные компоненты нашего меню: класс Menu, представляющий собой каркас, и класс MenuItem, представляющий отдельные пункты меню.

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();
    }
}
Ничего особенного, простой объект с простыми методами:
  • items — для пунктов меню
  • addItem() — для добавления нового пункта

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 выглядит уже поинтереснее. Здесь есть параметры title, icon, to. Думаю, понятно, какую роль каждый выполняет.

Давайте обратим внимание, как идет формирование пункта меню. Это метод toArray. Но еще больше нас интересует строка формирования Badge, и что делает метод get.

php
'badge' => $badge->get($this->key),
Разберем подробнее, что делает класс Badge.

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);
    }
}
В данном классе мы собираем информацию о бейджах. Конкретно в этом случае — для пункта меню Products. Я формирую и выполняю один общий запрос на получение информации.
Если необходимо добавить новый запрос, например, появился пункт меню "Заказы", - необходимо добавить в метод get строчку:

php
MenuKey::ORDERS => $this->findByType(MenuKey::ORDERS),
А также в метод query добавить запрос:

php
MenuKey::ORDERS => $this->ordersQuery($user),
Потом написать функцию productQuery, которая будет формировать запрос на получение заказов по определенным параметрам. Если необходимо, можно кэшировать эти данные.
Со стороны Admiral в файле menu.tsx MenuItemLink должен выглядеть так. Где в Badge: count — значение, а status — цвет пункта меню.

typescript
<MenuItemLink  
   key={`${i}`}  
   icon={menu.icon}  
   name={menu.name}  
   to={menu.to}  
   badge={{ count: menu.badge, status: 'error' }}  
/>
Готово. Теперь у вас должно появиться число рядом с пунктом меню, если оно удовлетворяет запросу.
Если у вас возникли вопросы, пожалуйста, свяжитесь с нами по адресу: admiral@dev.family Мы всегда рады вашим отзывам!