Añadir soporte al mega buscador en el portal de cliente

Cómo añadir secciones al buscador global del Portal de Cliente desde tu plugin

El buscador global del portal (el icono de lupa de la cabecera, que abre el "mega buscador" con resultados agrupados por secciones) se alimenta de la clase FacturaScripts\Dinamic\Lib\PortalSearch. Cualquier plugin puede registrar sus propias secciones de búsqueda desde su Init.php, sin tocar el código de PortalCliente.

PortalSearch es el hermano de PortalMenu: mientras PortalMenu decide qué aparece en el menú lateral, PortalSearch decide en qué modelos y campos busca el buscador, y reutiliza los permisos del menú si la sección no declara los suyos propios.

Todo esto vive en Plugins/PortalCliente/Lib/PortalSearch.php, así que solo existe cuando el plugin PortalCliente está activo — registra tus secciones dentro de if (Plugins::isEnabled('PortalCliente')) { ... } en tu Init.php.


1. Las dos formas de registrar una sección

PortalSearch::add(string $name, array $config) admite dos modos, mutuamente excluyentes:

  1. Basada en modelo (la más habitual): declaras model, fields, filter y map, y

PortalSearch construye la consulta (Where::xlike) y pagina los resultados por ti.

  1. Basada en resolver: le pasas un callable que devuelve los resultados ya construidos,

para secciones que no encajan en "un modelo con un where" (por ejemplo, buscar entre las propias entradas del menú, o combinar varias fuentes).

1.1. Sección basada en modelo

use FacturaScripts\Core\Tools;
use FacturaScripts\Core\Where;
use FacturaScripts\Dinamic\Lib\PortalMenu;
use FacturaScripts\Dinamic\Lib\PortalSearch;
use FacturaScripts\Dinamic\Model\Contacto;

PortalSearch::add('ListMiSeccion', [
    'model' => 'MiModelo',
    'fields' => ['codigo', 'descripcion'],
    'numeric_fields' => ['idmimodelo'],
    'order_by' => ['fecha' => 'DESC'],
    'limit' => 5,
    'filter' => function (Contacto $contact) {
        // filtro de propiedad OBLIGATORIO: qué registros puede ver este contacto.
        // un array vacío significa "esta sección no aplica a este contacto",
        // nunca "sin restricción"
        return empty($contact->codcliente)
            ? []
            : [Where::eq('codcliente', $contact->codcliente)];
    },
    'map' => function ($registro, Contacto $contact) {
        return [
            'title' => $registro->codigo,
            'subtitle' => Tools::date($registro->fecha),
            'badge' => Tools::money($registro->total, $registro->coddivisa),
            'url' => $registro->url('public'),
            'icon' => 'fa-solid fa-star',
        ];
    },
]);

1.2. Sección basada en resolver Para casos que no son "un modelo con un where", por ejemplo saltar directamente a una sección del menú escribiendo su nombre:

PortalSearch::add('mi-resolver', [
    'title' => 'mi-seccion',
    'icon' => 'fa-solid fa-star',
    'order' => 15,
    'permission' => function (Contacto $contact) {
        return $contact->exists();
    },
    'resolver' => function (Contacto $contact, string $query, int $limit) {
        $items = [];

        // tu propia lógica de búsqueda, no ligada a un modelo concreto
        foreach ($this->buscarLoQueSea($contact, $query) as $resultado) {
            $items[] = PortalSearch::item([
                'title' => $resultado['titulo'],
                'url' => $resultado['url'],
                'icon' => 'fa-solid fa-star',
            ]);

            if (count($items) >= $limit) {
                break;
            }
        }

        return $items;
    },
]);

Cuando usas resolver, no hace falta declarar model, fields, filter ni map: el resolver recibe el contacto, la consulta ya saneada y el límite de resultados, y debe devolver directamente el array de resultados (usa PortalSearch::item() para darles el formato correcto).

  1. Referencia de las claves de $config

Clave Tipo Obligatoria Descripción model string sí, salvo con resolver Nombre del modelo, sin namespace (se resuelve como Dinamic\Model\<model>). fields string[] sí, salvo con resolver Campos de texto sobre los que se busca (Where::xlike, busca todas las palabras). numeric_fields string[] no Campos numéricos; solo se usan si la consulta escrita es un número (ctype_digit). filter callable(Contacto): Where[] sí, salvo con resolver Filtro de propiedad/visibilidad. Obligatorio: sin condiciones no se ejecuta ninguna consulta. Un array vacío excluye la sección para ese contacto; nunca uses un filtro "abierto". map callable(ModelClass, Contacto): array sí, salvo con resolver Convierte cada registro en ['title', 'subtitle', 'badge', 'url', 'icon']. order_by array no Orden de la consulta, igual que ModelClass::all(). limit int no (por defecto 5) Resultados de esta sección; se recorta entre 1 y PortalSearch::MAX_LIMIT (20). permission callable(Contacto): bool no Si no se indica, se hereda del permiso de la misma entrada en PortalMenu. title, icon, order mixed no Si no se indican, se heredan de la entrada de PortalMenu con el mismo nombre. resolver callable(Contacto, string, int): array alternativa a model/fields/filter/map Resuelve la sección por tu cuenta en vez de con un modelo y un where. Reglas importantes:

Si no pasas resolver, model y fields son obligatorios; si además no defines filter o map, PortalSearch::add() registra un error crítico (Tools::log()->critical(...)) y no añade la sección. Revisa el log si tu sección no aparece. filter es la única barrera de seguridad de una sección basada en modelo: nunca devuelvas un where vacío como "sin restricción"; un array vacío significa "esta sección no aplica a este contacto" y la búsqueda se omite para él.

  1. Cómo hereda el permiso de PortalMenu

Para no declarar el permiso dos veces, PortalSearch::sections($contact) sigue esta regla:

Si la sección declara su propio permission, manda sobre el menú (se usa ese callable, exista o no una entrada de menú con el mismo nombre). Si la sección no declara permission, se usa el permiso implícito del menú: el contacto solo puede buscar en ella si ve la entrada correspondiente en PortalMenu::items($contact). Si no hay ninguna entrada de menú con ese nombre, la sección queda excluida. Nunca al revés: la ausencia de permiso nunca concede acceso. En la práctica, esto significa que si ya registraste una entrada en PortalMenu::add('ListMiSeccion', ...) con su propio callable de permiso (ver la guía anterior sobre controladores anidados), no hace falta que repitas la condición en PortalSearch::add() — basta con usar el mismo name en ambas llamadas:

PortalMenu::add('ListMiSeccion', 'mi-seccion', 'fa-solid fa-star', 150, function (Contacto $contact) {
    return (bool)$contact->pc_allow_show_mi_seccion;
});

PortalSearch::add('ListMiSeccion', [
    'model' => 'MiModelo',
    'fields' => ['codigo', 'descripcion'],
    'filter' => function (Contacto $contact) {
        return empty($contact->codcliente) ? [] : [Where::eq('codcliente', $contact->codcliente)];
    },
    'map' => function ($registro) {
        return ['title' => $registro->codigo, 'url' => $registro->url('public')];
    },
    // sin 'permission', 'title', 'icon' ni 'order': se heredan de PortalMenu::add() de arriba
]);
  1. Regístralo en tu Init.php
<?php

namespace FacturaScripts\Plugins\MiPlugin;

use FacturaScripts\Core\Plugins;
use FacturaScripts\Core\Template\InitClass;
use FacturaScripts\Core\Tools;
use FacturaScripts\Core\Where;
use FacturaScripts\Dinamic\Lib\PortalMenu;
use FacturaScripts\Dinamic\Lib\PortalSearch;
use FacturaScripts\Dinamic\Model\Contacto;

class Init extends InitClass
{
    public function init(): void
    {
        if (Plugins::isEnabled('PortalCliente')) {
            $this->addPortalSearch();
        }

        // resto de tu init()...
    }

    public function uninstall(): void {}

    public function update(): void
    {
        \FacturaScripts\Core\Plugins::deploy(true, true);
        \FacturaScripts\Core\Cache::clear();
    }

    private function addPortalSearch(): void
    {
        PortalSearch::add('ListMiSeccion', [
            'model' => 'MiModelo',
            'fields' => ['codigo', 'descripcion'],
            'order_by' => ['fecha' => 'DESC'],
            'filter' => function (Contacto $contact) {
                return empty($contact->codcliente)
                    ? []
                    : [Where::eq('codcliente', $contact->codcliente)];
            },
            'map' => function ($registro) {
                return [
                    'title' => $registro->codigo,
                    'subtitle' => Tools::date($registro->fecha),
                    'url' => $registro->url('public'),
                ];
            },
        ]);
    }
}

Igual que con el menú y los controladores anidados: como Dinamic\Lib\PortalSearch solo existe cuando PortalCliente está activo, todo el registro debe quedar dentro de if (Plugins::isEnabled('PortalCliente')).

  1. Cómo se consume (por si necesitas depurarlo)

El buscador de la cabecera llama por AJAX a Controller/PortalSearch.php (GET /PortalSearch?query=...§ion=...&limit=...), que delega toda la lógica en PortalSearch::search($contact, $query, $only, $limit):

query se sanea con PortalSearch::sanitizeQuery() (quita comodines %/_, colapsa espacios, recorta a MAX_QUERY_LEN caracteres y MAX_WORDS palabras). Si tras sanear queda más corta que MIN_QUERY_LEN (2 caracteres), no se busca nada. section ($only) permite pedir resultados de una única sección (por ejemplo al pulsar "ver más" en una sección concreta); se valida contra PortalSearch::sections($contact), así que nunca se puede colar un nombre de sección al que el contacto no tenga acceso. El resultado JSON tiene la forma:

{
  "query": "factura",
  "total": 3,
  "sections": [
    {
      "name": "ListMiSeccion",
      "title": "Mi sección",
      "icon": "fa-solid fa-star",
      "count": 3,
      "more": false,
      "results": [
        { "title": "...", "subtitle": "...", "badge": "...", "url": "...", "icon": "..." }
      ]
    }
  ]
}

Cada resultado pasa siempre por PortalSearch::item(), que solo deja salir las claves title, subtitle, badge, url e icon (con Tools::noHtml() aplicado a los textos), así que nunca se filtra el modelo completo ni columnas que el portal no deba mostrar.

Sin contacto identificado y activo (pc_active), PortalSearch::sections() devuelve un array vacío y el endpoint responde 401, aunque haya un usuario del ERP identificado.

  1. Quitar una sección

\FacturaScripts\Dinamic\Lib\PortalSearch::remove('ListMiSeccion'); Útil si tu plugin necesita retirar una sección que había registrado otro plugin (o la tuya propia bajo cierta condición), por ejemplo dentro de una extensión.

  1. Referencias del código fuente

Plugins/PortalCliente/Lib/PortalSearch.php — clase completa: add(), sections(), search(), searchModel(), sanitizeQuery(), item(). Plugins/PortalCliente/Lib/PortalMenu.php — de donde se heredan permiso, título, icono y orden cuando la sección no los declara. Plugins/PortalCliente/Controller/PortalSearch.php — endpoint AJAX que expone PortalSearch::search() en JSON. Plugins/PortalCliente/Init.php — método addPortalSearch() (líneas ~253-380): ejemplos reales, incluida la sección 'pages' con resolver (busca entre las propias entradas del menú) y las cuatro secciones de documentos de venta compartiendo la misma configuración.

Cookies
Usamos cookies necesarias para el funcionamiento del sitio y cookies opcionales para recordar tus preferencias y mejorar tu experiencia. Puedes aceptarlas todas, rechazarlas o configurar tus preferencias

Soporte