From 1fa1aa053178deb5e399a8d1278534c3a1e8b07d Mon Sep 17 00:00:00 2001 From: Abderrahim Darghal Belkacemi Date: Thu, 2 Jul 2026 12:27:01 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(UI):=20n=C3=BAcleo=20del=20sistema=20d?= =?UTF-8?q?e=20componentes=20en=20Core/Lib/UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Core/Lib/UI/Binding/ModelBinder.php | 95 +++++++ Core/Lib/UI/Contract/HandlesQueries.php | 42 ++++ Core/Lib/UI/Event/UIEvent.php | 76 ++++++ Core/Lib/UI/Event/UIResponse.php | 214 ++++++++++++++++ Core/Lib/UI/Field.php | 78 ++++++ Core/Lib/UI/Field/AutocompleteField.php | 52 ++++ Core/Lib/UI/Field/CheckboxField.php | 60 +++++ Core/Lib/UI/Field/DateField.php | 60 +++++ Core/Lib/UI/Field/HiddenField.php | 35 +++ Core/Lib/UI/Field/NumberField.php | 107 ++++++++ Core/Lib/UI/Field/SelectField.php | 255 +++++++++++++++++++ Core/Lib/UI/Field/TextField.php | 51 ++++ Core/Lib/UI/Field/TextareaField.php | 48 ++++ Core/Lib/UI/UIButton.php | 186 ++++++++++++++ Core/Lib/UI/UICard.php | 63 +++++ Core/Lib/UI/UIComponent.php | 205 +++++++++++++++ Core/Lib/UI/UIContainer.php | 160 ++++++++++++ Core/Lib/UI/UIController.php | 247 ++++++++++++++++++ Core/Lib/UI/UIDropdown.php | 128 ++++++++++ Core/Lib/UI/UIField.php | 321 ++++++++++++++++++++++++ Core/Lib/UI/UIForm.php | 291 +++++++++++++++++++++ Core/Lib/UI/UIGroup.php | 66 +++++ Core/Lib/UI/UIHtml.php | 48 ++++ Core/Lib/UI/UIInfoBox.php | 89 +++++++ Core/Lib/UI/UIModal.php | 82 ++++++ Core/Lib/UI/UIPage.php | 113 +++++++++ Core/Lib/UI/UITab.php | 80 ++++++ Core/Lib/UI/UITabs.php | 67 +++++ Core/Lib/UI/Validation/ErrorBag.php | 72 ++++++ Core/Lib/UI/Validation/RuleEngine.php | 127 ++++++++++ 30 files changed, 3518 insertions(+) create mode 100644 Core/Lib/UI/Binding/ModelBinder.php create mode 100644 Core/Lib/UI/Contract/HandlesQueries.php create mode 100644 Core/Lib/UI/Event/UIEvent.php create mode 100644 Core/Lib/UI/Event/UIResponse.php create mode 100644 Core/Lib/UI/Field.php create mode 100644 Core/Lib/UI/Field/AutocompleteField.php create mode 100644 Core/Lib/UI/Field/CheckboxField.php create mode 100644 Core/Lib/UI/Field/DateField.php create mode 100644 Core/Lib/UI/Field/HiddenField.php create mode 100644 Core/Lib/UI/Field/NumberField.php create mode 100644 Core/Lib/UI/Field/SelectField.php create mode 100644 Core/Lib/UI/Field/TextField.php create mode 100644 Core/Lib/UI/Field/TextareaField.php create mode 100644 Core/Lib/UI/UIButton.php create mode 100644 Core/Lib/UI/UICard.php create mode 100644 Core/Lib/UI/UIComponent.php create mode 100644 Core/Lib/UI/UIContainer.php create mode 100644 Core/Lib/UI/UIController.php create mode 100644 Core/Lib/UI/UIDropdown.php create mode 100644 Core/Lib/UI/UIField.php create mode 100644 Core/Lib/UI/UIForm.php create mode 100644 Core/Lib/UI/UIGroup.php create mode 100644 Core/Lib/UI/UIHtml.php create mode 100644 Core/Lib/UI/UIInfoBox.php create mode 100644 Core/Lib/UI/UIModal.php create mode 100644 Core/Lib/UI/UIPage.php create mode 100644 Core/Lib/UI/UITab.php create mode 100644 Core/Lib/UI/UITabs.php create mode 100644 Core/Lib/UI/Validation/ErrorBag.php create mode 100644 Core/Lib/UI/Validation/RuleEngine.php diff --git a/Core/Lib/UI/Binding/ModelBinder.php b/Core/Lib/UI/Binding/ModelBinder.php new file mode 100644 index 0000000000..38f34a5f45 --- /dev/null +++ b/Core/Lib/UI/Binding/ModelBinder.php @@ -0,0 +1,95 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Lib\UI\Binding; + +use FacturaScripts\Core\Lib\UI\UIForm; + +/** + * Mapea valores entre los campos de un UIForm y uno o varios modelos. + * + * fill(): modelos → campos (render inicial). apply(): campos → modelos (tras + * validar, cuando el handler lo pide explícitamente). Nunca llama a save(). + * + * Resolución del nombre de propiedad, por prioridad: bindTo() del campo, + * entrada en $map del bind(), y por último el propio nombre del campo. + * + * @author Abderrahim Darghal Belkacemi + */ +final class ModelBinder +{ + /** @var array */ + private array $bindings = []; + + /** + * @param object $model instancia del modelo + * @param array $map ['campoForm' => 'propiedadModelo'] + * @param string[]|null $only limitar a estos campos del form; null = todos los que existan en el modelo + */ + public function add(object $model, array $map = [], ?array $only = null): self + { + $this->bindings[] = ['model' => $model, 'map' => $map, 'only' => $only]; + return $this; + } + + /** Copia los valores de los modelos a los campos del form. */ + public function fill(UIForm $form): void + { + foreach ($this->bindings as $binding) { + foreach ($form->fields() as $field) { + if (!$this->applies($binding, $field->name())) { + continue; + } + $property = $this->property($binding, $field); + if (property_exists($binding['model'], $property)) { + $field->setValue($binding['model']->{$property}); + } + } + } + } + + /** Escribe los valores actuales de los campos en los modelos. NO llama a save(). */ + public function apply(UIForm $form): void + { + foreach ($this->bindings as $binding) { + foreach ($form->fields() as $field) { + if (!$this->applies($binding, $field->name())) { + continue; + } + $property = $this->property($binding, $field); + if (property_exists($binding['model'], $property)) { + $binding['model']->{$property} = $field->value(); + } + } + } + } + + private function applies(array $binding, string $fieldName): bool + { + return $binding['only'] === null || in_array($fieldName, $binding['only'], true); + } + + private function property(array $binding, $field): string + { + if ($field->bindProperty() !== $field->name()) { + return $field->bindProperty(); + } + return $binding['map'][$field->name()] ?? $field->name(); + } +} diff --git a/Core/Lib/UI/Contract/HandlesQueries.php b/Core/Lib/UI/Contract/HandlesQueries.php new file mode 100644 index 0000000000..531d3d4d7e --- /dev/null +++ b/Core/Lib/UI/Contract/HandlesQueries.php @@ -0,0 +1,42 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Lib\UI\Contract; + +use FacturaScripts\Core\Request; + +/** + * Contrato para componentes que responden consultas de datos propias + * (?_ui_query=accion&_ui_target=path): select2 remoto, autocomplete, pickers… + * + * La petición llega por la URL del propio controlador, por lo que está + * protegida por la sesión y los permisos de la página. + * + * @author Abderrahim Darghal Belkacemi + */ +interface HandlesQueries +{ + /** + * Responde una consulta de datos del componente. + * + * @param string $action nombre de la acción ('search' por defecto) + * @return array respuesta serializable a JSON (p.ej. formato select2 {results: [{id, text}]}) + */ + public function handleQuery(string $action, Request $request): array; +} diff --git a/Core/Lib/UI/Event/UIEvent.php b/Core/Lib/UI/Event/UIEvent.php new file mode 100644 index 0000000000..f8eb9bd652 --- /dev/null +++ b/Core/Lib/UI/Event/UIEvent.php @@ -0,0 +1,76 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Lib\UI\Event; + +use FacturaScripts\Core\Lib\UI\UIForm; +use FacturaScripts\Core\Lib\UI\UIPage; +use FacturaScripts\Core\Request; + +/** + * Contexto que recibe un handler de evento del sistema de componentes. + * + * Si el evento pertenece a un form, form() devuelve el formulario ya hidratado + * con los valores del POST (y validado, si el evento lo requería). + * + * @author Abderrahim Darghal Belkacemi + */ +final class UIEvent +{ + public function __construct( + private readonly string $name, + private readonly ?UIForm $form, + private readonly UIPage $page, + private readonly Request $request + ) { + } + + public function name(): string + { + return $this->name; + } + + /** Form del scope del evento, hidratado. Null en eventos de página. */ + public function form(): ?UIForm + { + return $this->form; + } + + public function page(): UIPage + { + return $this->page; + } + + public function request(): Request + { + return $this->request; + } + + /** Atajo de form()->value(): valor actual de un campo del form del evento. */ + public function value(string $field): mixed + { + return $this->form?->value($field); + } + + /** @return array */ + public function values(): array + { + return $this->form?->values() ?? []; + } +} diff --git a/Core/Lib/UI/Event/UIResponse.php b/Core/Lib/UI/Event/UIResponse.php new file mode 100644 index 0000000000..e08e2b8a5e --- /dev/null +++ b/Core/Lib/UI/Event/UIResponse.php @@ -0,0 +1,214 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Lib\UI\Event; + +use FacturaScripts\Core\Base\MiniLog; +use FacturaScripts\Core\Lib\UI\UIComponent; +use FacturaScripts\Core\Lib\UI\UIPage; +use FacturaScripts\Core\Lib\UI\Validation\ErrorBag; +use FacturaScripts\Core\Tools; + +/** + * Respuesta que construye un handler de evento. + * + * Se serializa al envelope JSON del protocolo HTML-over-the-wire: + * { protocol, ok, fragments: [{id, html, mode}], errors: {campo: [msgs]}, + * notices: [{level, message}], actions: [{type, ...}] } + * + * Orden de aplicación en el cliente: redirect → fragments → errors → notices → actions. + * + * En peticiones sin JS el controlador usa redirectUrl() y hace render completo + * de la página en su lugar. + * + * @author Abderrahim Darghal Belkacemi + */ +final class UIResponse +{ + public const PROTOCOL_VERSION = 1; + + private bool $ok = true; + + /** @var array componentes o paths a re-renderizar */ + private array $rerenderTargets = []; + + /** @var array '{form}.{campo}' → mensajes */ + private array $errors = []; + + /** @var array acciones declarativas para el cliente */ + private array $actions = []; + + private string $redirectUrl = ''; + + public static function make(): self + { + return new self(); + } + + public function setOk(bool $ok): self + { + $this->ok = $ok; + return $this; + } + + public function isOk(): bool + { + return $this->ok; + } + + /** Re-renderiza estos componentes (o paths) y los intercambia en el DOM. */ + public function rerender(UIComponent|string ...$targets): self + { + foreach ($targets as $target) { + $this->rerenderTargets[] = $target; + } + return $this; + } + + /** Mensaje informativo. Se registra en el log para que el render completo también lo muestre. */ + public function notice(string $message, array $params = []): self + { + Tools::log()->notice($message, $params); + return $this; + } + + public function warning(string $message, array $params = []): self + { + Tools::log()->warning($message, $params); + return $this; + } + + public function error(string $message, array $params = []): self + { + Tools::log()->error($message, $params); + $this->ok = false; + return $this; + } + + /** Añade los errores de validación de un form al mapa del envelope. */ + public function fieldErrors(ErrorBag $errors, string $formName): self + { + foreach ($errors->all() as $field => $messages) { + $this->errors[$formName . '.' . $field] = $messages; + } + if (!$errors->isEmpty()) { + $this->ok = false; + } + return $this; + } + + public function redirect(string $url): self + { + $this->redirectUrl = $url; + return $this; + } + + public function redirectUrl(): string + { + return $this->redirectUrl; + } + + /** Recarga la página completa en el cliente. */ + public function reload(): self + { + return $this->action('reload'); + } + + /** Pone el foco en un campo tras aplicar los fragmentos. */ + public function focus(UIComponent|string $target): self + { + return $this->action('focus', ['target' => $this->targetId($target)]); + } + + /** Activa una pestaña de un UITabs. */ + public function activateTab(UIComponent|string $target): self + { + return $this->action('tab', ['target' => $this->targetId($target)]); + } + + /** Hace scroll hasta un componente. */ + public function scrollTo(UIComponent|string $target): self + { + return $this->action('scroll', ['target' => $this->targetId($target)]); + } + + /** Renderiza el UIModal indicado (fragmento) y ordena mostrarlo. */ + public function openModal(UIComponent|string $modal): self + { + $this->rerender($modal); + return $this->action('modal', ['target' => $this->targetId($modal), 'action' => 'show']); + } + + public function closeModal(UIComponent|string $modal): self + { + return $this->action('modal', ['target' => $this->targetId($modal), 'action' => 'hide']); + } + + /** Añade una acción declarativa arbitraria del protocolo. */ + public function action(string $type, array $data = []): self + { + $this->actions[] = array_merge(['type' => $type], $data); + return $this; + } + + /** + * Serializa la respuesta al envelope JSON, renderizando los fragmentos + * contra el árbol actual de la página. + */ + public function toEnvelope(UIPage $page): array + { + $fragments = []; + foreach ($this->rerenderTargets as $target) { + $component = is_string($target) ? $page->find($target) : $target; + if ($component === null) { + Tools::log()->warning('ui-fragment-not-found', ['%fragment%' => (string)$target]); + continue; + } + $fragments[] = [ + 'id' => $component->domId(), + 'html' => $component->render(), + 'mode' => 'replace', + ]; + } + + $notices = []; + $levels = ['info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency']; + foreach (MiniLog::read('', $levels) as $entry) { + $notices[] = ['level' => $entry['level'], 'message' => $entry['message']]; + } + + if ($this->redirectUrl !== '') { + array_unshift($this->actions, ['type' => 'redirect', 'url' => $this->redirectUrl]); + } + + return [ + 'protocol' => self::PROTOCOL_VERSION, + 'ok' => $this->ok, + 'fragments' => $fragments, + 'errors' => $this->errors, + 'notices' => $notices, + 'actions' => $this->actions, + ]; + } + + private function targetId(UIComponent|string $target): string + { + return $target instanceof UIComponent ? $target->domId() : $target; + } +} diff --git a/Core/Lib/UI/Field.php b/Core/Lib/UI/Field.php new file mode 100644 index 0000000000..32f3b0cffb --- /dev/null +++ b/Core/Lib/UI/Field.php @@ -0,0 +1,78 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Lib\UI; + +use FacturaScripts\Core\Lib\UI\Field\AutocompleteField; +use FacturaScripts\Core\Lib\UI\Field\CheckboxField; +use FacturaScripts\Core\Lib\UI\Field\DateField; +use FacturaScripts\Core\Lib\UI\Field\HiddenField; +use FacturaScripts\Core\Lib\UI\Field\NumberField; +use FacturaScripts\Core\Lib\UI\Field\SelectField; +use FacturaScripts\Core\Lib\UI\Field\TextareaField; +use FacturaScripts\Core\Lib\UI\Field\TextField; + +/** + * Fábrica estática de campos, azúcar sintáctico para buildUI(): + * Field::text('nombre'), Field::number('precio'), Field::select('pais')… + * + * @author Abderrahim Darghal Belkacemi + */ +final class Field +{ + public static function text(string $name): TextField + { + return TextField::make($name); + } + + public static function number(string $name): NumberField + { + return NumberField::make($name); + } + + public static function date(string $name): DateField + { + return DateField::make($name); + } + + public static function textarea(string $name): TextareaField + { + return TextareaField::make($name); + } + + public static function checkbox(string $name): CheckboxField + { + return CheckboxField::make($name); + } + + public static function select(string $name): SelectField + { + return SelectField::make($name); + } + + public static function hidden(string $name): HiddenField + { + return HiddenField::make($name); + } + + public static function autocomplete(string $name): AutocompleteField + { + return AutocompleteField::make($name); + } +} diff --git a/Core/Lib/UI/Field/AutocompleteField.php b/Core/Lib/UI/Field/AutocompleteField.php new file mode 100644 index 0000000000..c410aac3b5 --- /dev/null +++ b/Core/Lib/UI/Field/AutocompleteField.php @@ -0,0 +1,52 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Lib\UI\Field; + +/** + * Autocompletado con búsqueda en servidor: un SelectField remoto que además + * admite valores libres (select2 tags). Sustituye al antiguo WidgetAutocomplete + * sin depender de jQuery UI. + * + * @author Abderrahim Darghal Belkacemi + */ +class AutocompleteField extends SelectField +{ + /** true → el usuario puede introducir valores que no estén en la lista. */ + protected bool $allowCustom = true; + + public function __construct(string $name) + { + parent::__construct($name); + $this->remote = true; + $this->addEmpty = true; + } + + /** false → modo estricto: solo valores devueltos por la búsqueda. */ + public function allowCustom(bool $allow = true): static + { + $this->allowCustom = $allow; + return $this; + } + + public function isAllowCustom(): bool + { + return $this->allowCustom; + } +} diff --git a/Core/Lib/UI/Field/CheckboxField.php b/Core/Lib/UI/Field/CheckboxField.php new file mode 100644 index 0000000000..00e702ee6e --- /dev/null +++ b/Core/Lib/UI/Field/CheckboxField.php @@ -0,0 +1,60 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Lib\UI\Field; + +use FacturaScripts\Core\Lib\UI\UIField; +use FacturaScripts\Core\Tools; + +/** + * Casilla de verificación. Valor interno bool. + * + * La plantilla emite un antes del checkbox para + * que los desmarcados también viajen en el POST (funciona con y sin JS). + * + * @author Abderrahim Darghal Belkacemi + */ +class CheckboxField extends UIField +{ + protected function defaultTemplate(): string + { + return 'UI/Field/Checkbox.html.twig'; + } + + public function colClass(): string + { + return $this->cols <= 0 ? 'col-12 col-sm-auto' : parent::colClass(); + } + + protected function castFromRequest(mixed $raw): mixed + { + // el hidden envía '0'; el checkbox marcado lo sobrescribe con '1' + return in_array($raw, ['1', 'TRUE', 'true', 1, true], true); + } + + public function isChecked(): bool + { + return in_array($this->value, ['1', 'TRUE', 'true', 1, true], true); + } + + public function displayValue(): string + { + return $this->isChecked() ? Tools::lang()->trans('yes') : Tools::lang()->trans('no'); + } +} diff --git a/Core/Lib/UI/Field/DateField.php b/Core/Lib/UI/Field/DateField.php new file mode 100644 index 0000000000..4b19ac4e68 --- /dev/null +++ b/Core/Lib/UI/Field/DateField.php @@ -0,0 +1,60 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Lib\UI\Field; + +use FacturaScripts\Core\Lib\UI\UIField; +use FacturaScripts\Core\Tools; + +/** + * Campo de fecha (). El valor interno es 'Y-m-d' o null. + * + * @author Abderrahim Darghal Belkacemi + */ +class DateField extends UIField +{ + protected function defaultTemplate(): string + { + return 'UI/Field/Date.html.twig'; + } + + protected function castFromRequest(mixed $raw): mixed + { + if ($raw === null || $raw === '' || !is_string($raw)) { + return null; + } + $time = strtotime($raw); + return $time === false ? null : date('Y-m-d', $time); + } + + /** El input type=date exige formato Y-m-d; normaliza valores venidos del modelo. */ + public function valueAttr(): string + { + if (empty($this->value)) { + return ''; + } + $time = strtotime((string)$this->value); + return $time === false ? '' : date('Y-m-d', $time); + } + + public function displayValue(): string + { + return empty($this->value) ? '-' : Tools::date($this->value); + } +} diff --git a/Core/Lib/UI/Field/HiddenField.php b/Core/Lib/UI/Field/HiddenField.php new file mode 100644 index 0000000000..06b0880993 --- /dev/null +++ b/Core/Lib/UI/Field/HiddenField.php @@ -0,0 +1,35 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Lib\UI\Field; + +use FacturaScripts\Core\Lib\UI\UIField; + +/** + * Campo oculto (). Sin etiqueta ni wrapper de columna. + * + * @author Abderrahim Darghal Belkacemi + */ +class HiddenField extends UIField +{ + protected function defaultTemplate(): string + { + return 'UI/Field/Hidden.html.twig'; + } +} diff --git a/Core/Lib/UI/Field/NumberField.php b/Core/Lib/UI/Field/NumberField.php new file mode 100644 index 0000000000..64bae79c26 --- /dev/null +++ b/Core/Lib/UI/Field/NumberField.php @@ -0,0 +1,107 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Lib\UI\Field; + +use FacturaScripts\Core\Lib\UI\UIField; +use FacturaScripts\Core\Tools; + +/** + * Campo numérico (). El valor hidratado es float, int o null. + * + * @author Abderrahim Darghal Belkacemi + */ +class NumberField extends UIField +{ + protected int $decimals = 2; + protected ?float $min = null; + protected ?float $max = null; + protected ?float $step = null; + + protected function defaultTemplate(): string + { + return 'UI/Field/Number.html.twig'; + } + + public function decimals(int $decimals): static + { + $this->decimals = $decimals; + return $this; + } + + public function min(float $min): static + { + $this->min = $min; + $this->rule('min_val:' . $min); + return $this; + } + + public function max(float $max): static + { + $this->max = $max; + $this->rule('max_val:' . $max); + return $this; + } + + public function step(float $step): static + { + $this->step = $step; + return $this; + } + + public function getDecimals(): int + { + return $this->decimals; + } + + /** Atributo step= del input: explícito, o derivado de los decimales. */ + public function stepAttr(): string + { + if ($this->step !== null) { + return (string)$this->step; + } + return $this->decimals > 0 ? '0.' . str_repeat('0', $this->decimals - 1) . '1' : '1'; + } + + public function minAttr(): string + { + return $this->min === null ? '' : (string)$this->min; + } + + public function maxAttr(): string + { + return $this->max === null ? '' : (string)$this->max; + } + + protected function castFromRequest(mixed $raw): mixed + { + if ($raw === null || $raw === '' || !is_numeric($raw)) { + return null; + } + return $this->decimals > 0 ? round((float)$raw, $this->decimals) : (int)$raw; + } + + public function displayValue(): string + { + if ($this->value === null) { + return '-'; + } + return Tools::number($this->value, $this->decimals); + } +} diff --git a/Core/Lib/UI/Field/SelectField.php b/Core/Lib/UI/Field/SelectField.php new file mode 100644 index 0000000000..0cad247a07 --- /dev/null +++ b/Core/Lib/UI/Field/SelectField.php @@ -0,0 +1,255 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Lib\UI\Field; + +use FacturaScripts\Core\Base\DataBase\DataBaseWhere; +use FacturaScripts\Core\Lib\AssetManager; +use FacturaScripts\Core\Lib\UI\Contract\HandlesQueries; +use FacturaScripts\Core\Lib\UI\UIField; +use FacturaScripts\Core\Model\CodeModel; +use FacturaScripts\Core\Request; +use FacturaScripts\Core\Tools; + +/** + * Selector potenciado por select2, con tres modos de datos: + * + * - options([...]) → opciones estáticas declaradas en PHP. + * - fromCodeModel($source, $code, $title) → precarga todas las filas al renderizar. + * - searchable($source, $code, $title) → select2 remoto: busca contra el + * endpoint _ui_query del propio componente (HandlesQueries). + * + * Cascadas: dependsOn('pais') declara que este select depende de otro campo del + * mismo form. En modo precargado, el padre re-renderiza este fragmento al + * cambiar (evento builtin _refresh); en modo remoto, el valor del padre viaja + * como parámetro 'parent' de cada búsqueda. + * + * @author Abderrahim Darghal Belkacemi + */ +class SelectField extends UIField implements HandlesQueries +{ + /** @var array */ + protected array $options = []; + + protected string $source = ''; + protected string $fieldcode = 'id'; + protected string $fieldtitle = ''; + + /** true → select2 con búsqueda AJAX contra _ui_query. */ + protected bool $remote = false; + + protected bool $addEmpty = true; + protected bool $translate = false; + + /** Nombre lógico del campo del mismo form del que depende este select. */ + protected string $parentField = ''; + + /** Columna de BD por la que filtra el valor del padre. Vacío = mismo nombre que el campo padre. */ + protected string $filterColumn = ''; + + protected function defaultTemplate(): string + { + return 'UI/Field/Select.html.twig'; + } + + // ------------------------------------------------------------------ + // Modos de datos + // ------------------------------------------------------------------ + + /** + * Opciones estáticas: array asociativo valor => título, o lista de + * ['value' => ..., 'title' => ...]. + */ + public function options(array $options): static + { + $this->options = []; + foreach ($options as $key => $item) { + if (is_array($item)) { + $this->options[] = ['value' => $item['value'] ?? '', 'title' => (string)($item['title'] ?? '')]; + } else { + $this->options[] = ['value' => $key, 'title' => (string)$item]; + } + } + return $this; + } + + /** Precarga todas las filas de la tabla/modelo al renderizar. */ + public function fromCodeModel(string $source, string $fieldcode, string $fieldtitle = '', bool $translate = false): static + { + $this->source = $source; + $this->fieldcode = $fieldcode; + $this->fieldtitle = $fieldtitle ?: $fieldcode; + $this->translate = $translate; + $this->remote = false; + return $this; + } + + /** select2 remoto: busca contra el endpoint _ui_query de este componente. */ + public function searchable(string $source, string $fieldcode, string $fieldtitle = ''): static + { + $this->source = $source; + $this->fieldcode = $fieldcode; + $this->fieldtitle = $fieldtitle ?: $fieldcode; + $this->remote = true; + return $this; + } + + /** Este select depende de otro campo del mismo form (cascada). */ + public function dependsOn(string $parentField, string $filterColumn = ''): static + { + $this->parentField = $parentField; + $this->filterColumn = $filterColumn ?: $parentField; + return $this; + } + + public function allowEmpty(bool $addEmpty = true): static + { + $this->addEmpty = $addEmpty; + return $this; + } + + // ------------------------------------------------------------------ + // Datos resueltos para el render + // ------------------------------------------------------------------ + + /** @return array opciones a renderizar */ + public function values(): array + { + if ($this->source === '') { + return $this->options; + } + + if ($this->remote) { + // solo la opción seleccionada; el resto llega por AJAX + if ($this->value === null || $this->value === '') { + return $this->addEmpty ? [['value' => '', 'title' => '------']] : []; + } + $description = (new CodeModel())->getDescription( + $this->source, $this->fieldcode, $this->value, $this->fieldtitle + ); + return [['value' => $this->value, 'title' => $description]]; + } + + $result = []; + foreach (CodeModel::all($this->source, $this->fieldcode, $this->fieldtitle, $this->addEmpty, $this->parentWhere()) as $row) { + $result[] = [ + 'value' => $row->code, + 'title' => $this->translate ? Tools::lang()->trans($row->description) : $row->description, + ]; + } + return $result; + } + + public function isSelected(mixed $optionValue): bool + { + if ($this->value === null || $this->value === '') { + return $optionValue === '' || $optionValue === null; + } + return (string)$optionValue === (string)$this->value; + } + + public function isRemote(): bool + { + return $this->remote; + } + + /** name= HTML del campo padre, para que el JS lea su valor en las búsquedas remotas. */ + public function parentInputName(): string + { + if ($this->parentField === '') { + return ''; + } + return $this->form()?->field($this->parentField)?->inputName() ?? ''; + } + + /** + * Paths de los selects del mismo form que dependen de este campo. Cuando no + * está vacío, la plantilla añade el trigger de cambio que re-renderiza los + * hijos (evento builtin _refresh). + */ + public function dependentTargets(): string + { + $form = $this->form(); + if ($form === null) { + return ''; + } + $targets = []; + foreach ($form->fields() as $field) { + if ($field instanceof self && $field->parentField === $this->name && !$field->isRemote()) { + $targets[] = $field->path(); + } + } + return implode(',', $targets); + } + + public function displayValue(): string + { + foreach ($this->values() as $option) { + if ($this->isSelected($option['value'])) { + return $option['title']; + } + } + return $this->value === null ? '-' : (string)$this->value; + } + + // ------------------------------------------------------------------ + // Endpoint de datos (_ui_query) + // ------------------------------------------------------------------ + + public function handleQuery(string $action, Request $request): array + { + if ($action !== 'search' || $this->source === '') { + return ['results' => []]; + } + + $term = $request->queryOrInput('term', '') ?? ''; + $rows = CodeModel::search($this->source, $this->fieldcode, $this->fieldtitle, $term, $this->parentWhere($request)); + + $results = []; + foreach ($rows as $row) { + $results[] = ['id' => $row->code, 'text' => $row->description]; + } + return ['results' => $results]; + } + + /** @return DataBaseWhere[] filtro por el valor del campo padre, si lo hay */ + protected function parentWhere(?Request $request = null): array + { + if ($this->parentField === '') { + return []; + } + + $parentValue = $request !== null + ? $request->queryOrInput('parent', '') + : $this->form()?->value($this->parentField); + + if ($parentValue === null || $parentValue === '') { + return []; + } + return [new DataBaseWhere($this->filterColumn, $parentValue)]; + } + + public function registerAssets(): void + { + $route = Tools::config('route'); + AssetManager::addCss($route . '/node_modules/select2/dist/css/select2.min.css', 2); + AssetManager::addCss($route . '/node_modules/select2-bootstrap-5-theme/dist/select2-bootstrap-5-theme.min.css', 2); + AssetManager::addJs($route . '/node_modules/select2/dist/js/select2.min.js', 2); + } +} diff --git a/Core/Lib/UI/Field/TextField.php b/Core/Lib/UI/Field/TextField.php new file mode 100644 index 0000000000..ff68b4ca8b --- /dev/null +++ b/Core/Lib/UI/Field/TextField.php @@ -0,0 +1,51 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Lib\UI\Field; + +use FacturaScripts\Core\Lib\UI\UIField; + +/** + * Campo de texto de una línea (). + * + * @author Abderrahim Darghal Belkacemi + */ +class TextField extends UIField +{ + protected int $maxlength = 0; + + protected function defaultTemplate(): string + { + return 'UI/Field/Text.html.twig'; + } + + public function maxLength(int $maxlength): static + { + $this->maxlength = $maxlength; + if ($maxlength > 0) { + $this->rule('max:' . $maxlength); + } + return $this; + } + + public function getMaxLength(): int + { + return $this->maxlength; + } +} diff --git a/Core/Lib/UI/Field/TextareaField.php b/Core/Lib/UI/Field/TextareaField.php new file mode 100644 index 0000000000..9b4cb75543 --- /dev/null +++ b/Core/Lib/UI/Field/TextareaField.php @@ -0,0 +1,48 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Lib\UI\Field; + +use FacturaScripts\Core\Lib\UI\UIField; + +/** + * Área de texto multilínea ( +{% endblock %} diff --git a/Core/View/UI/Field/_wrapper.html.twig b/Core/View/UI/Field/_wrapper.html.twig new file mode 100644 index 0000000000..88fe22bc1f --- /dev/null +++ b/Core/View/UI/Field/_wrapper.html.twig @@ -0,0 +1,31 @@ +{# Wrapper común de los campos: identidad de fragmento + label + input + errores + ayuda. + Las plantillas concretas extienden esta y definen el bloque 'input'. #} +
+
+ + {% if c.getIcon() %} +
+ + {{ block('input') }} + {% for error in c.errors() %} +
{{ error }}
+ {% endfor %} +
+ {% else %} + {{ block('input') }} + {% for error in c.errors() %} +
{{ error }}
+ {% endfor %} + {% endif %} + {% if c.descriptionText() %} + {{ c.descriptionText() }} + {% endif %} +
+
diff --git a/Core/View/UI/Form.html.twig b/Core/View/UI/Form.html.twig new file mode 100644 index 0000000000..21fac3e298 --- /dev/null +++ b/Core/View/UI/Form.html.twig @@ -0,0 +1,15 @@ +
+ {{ formToken() }} + {% if c.titleText() %} + + {% if c.getIcon() %}{% endif %} + {{ c.titleText() }} + + {% endif %} +
+ {% for child in c.children() %} + {{ child.render()|raw }} + {% endfor %} +
+
diff --git a/Core/View/UI/Group.html.twig b/Core/View/UI/Group.html.twig new file mode 100644 index 0000000000..03e7f45878 --- /dev/null +++ b/Core/View/UI/Group.html.twig @@ -0,0 +1,10 @@ +
+ {% if c.titleText() %} + {{ c.titleText() }} + {% endif %} +
+ {% for child in c.children() %} + {{ child.render()|raw }} + {% endfor %} +
+
diff --git a/Core/View/UI/Html.html.twig b/Core/View/UI/Html.html.twig new file mode 100644 index 0000000000..09d9eee09b --- /dev/null +++ b/Core/View/UI/Html.html.twig @@ -0,0 +1,3 @@ +
+ {{ c.contentHtml()|raw }} +
diff --git a/Core/View/UI/InfoBox.html.twig b/Core/View/UI/InfoBox.html.twig new file mode 100644 index 0000000000..21f19e97eb --- /dev/null +++ b/Core/View/UI/InfoBox.html.twig @@ -0,0 +1,15 @@ +
+
+
+ {% if c.getIcon() %} + + {% endif %} +
+ {% if c.titleText() %} +
{{ c.titleText() }}
+ {% endif %} +
{{ c.getText()|raw }}
+
+
+
+
diff --git a/Core/View/UI/Modal.html.twig b/Core/View/UI/Modal.html.twig new file mode 100644 index 0000000000..b6a6596b97 --- /dev/null +++ b/Core/View/UI/Modal.html.twig @@ -0,0 +1,20 @@ +
+ +
diff --git a/Core/View/UI/Page.html.twig b/Core/View/UI/Page.html.twig new file mode 100644 index 0000000000..fd317056db --- /dev/null +++ b/Core/View/UI/Page.html.twig @@ -0,0 +1,5 @@ +
+ {% for child in c.children() %} + {{ child.render()|raw }} + {% endfor %} +
diff --git a/Core/View/UI/Tab.html.twig b/Core/View/UI/Tab.html.twig new file mode 100644 index 0000000000..f20210cfeb --- /dev/null +++ b/Core/View/UI/Tab.html.twig @@ -0,0 +1,8 @@ +
+
+ {% for child in c.children() %} + {{ child.render()|raw }} + {% endfor %} +
+
diff --git a/Core/View/UI/Tabs.html.twig b/Core/View/UI/Tabs.html.twig new file mode 100644 index 0000000000..bc392d97c5 --- /dev/null +++ b/Core/View/UI/Tabs.html.twig @@ -0,0 +1,19 @@ +
+ +
+ {% for tab in c.tabs() %} + {{ tab.render()|raw }} + {% endfor %} +
+
From 2af70e45b37f7299a9a9194a4d1daa6c57d1a9b6 Mon Sep 17 00:00:00 2001 From: Abderrahim Darghal Belkacemi Date: Thu, 2 Jul 2026 12:27:01 +0200 Subject: [PATCH 3/4] feat(UI): motor UIEngine.js con fragmentos, behaviors y eventos AJAX --- Core/Assets/JS/UIEngine.js | 479 +++++++++++++++++++++++++++++++++++++ 1 file changed, 479 insertions(+) create mode 100644 Core/Assets/JS/UIEngine.js diff --git a/Core/Assets/JS/UIEngine.js b/Core/Assets/JS/UIEngine.js new file mode 100644 index 0000000000..f555d9662a --- /dev/null +++ b/Core/Assets/JS/UIEngine.js @@ -0,0 +1,479 @@ +/** + * UIEngine.js — motor genérico HTML-over-the-wire del sistema de componentes UI. + * + * El servidor (PHP/Twig) es la única fuente de verdad del HTML: este motor solo + * localiza, serializa, envía e intercambia fragmentos. Sin lógica de negocio. + * + * Atributos declarativos que emite el servidor: + * data-ui-form
interceptable; scope de serialización + * data-ui-on="click|change|input" trigger que dispara un evento al servidor + * data-ui-event="form:evento" identificador que viaja en _ui_event + * data-ui-scope="none" no serializar ningún form (eventos de página) + * data-ui-confirm="¿Seguro?" confirm() previo + * data-ui-debounce="400" debounce en ms para triggers input + * data-ui-behavior="nombre" behavior a (re)inicializar tras cada swap + * data-ui-panel="nombre" tab-content con persistencia de pestaña activa + * + * Envelope JSON de respuesta (UIResponse::toEnvelope): + * { protocol, ok, fragments: [{id, html, mode}], errors: {"form.campo": [msgs]}, + * notices: [{level, message}], actions: [{type, ...}] } + * Orden de aplicación: redirect → fragments → errors → notices → actions. + * + * API pública para plugins: window.UI.behaviors.register(name, {init(el)}), + * window.UI.send(...), window.UI.initBehaviors(root). + */ +(function () { + 'use strict'; + + // ------------------------------------------------------------------ + // csrf: token base + sufijo contador (MultiRequestProtection admite + // incrementar la parte aleatoria en cliente; evita el rechazo por + // token duplicado en envíos AJAX consecutivos) + // ------------------------------------------------------------------ + var tokenCounter = 0; + + function nextToken(scopeEl) { + var el = (scopeEl || document).querySelector('[name="multireqtoken"]') + || document.querySelector('[name="multireqtoken"]'); + if (!el) return ''; + tokenCounter++; + return el.value + 'n' + tokenCounter; + } + + // ------------------------------------------------------------------ + // serializer + // ------------------------------------------------------------------ + function serializeScope(formEl, fd) { + if (!formEl) return; + new FormData(formEl).forEach(function (value, key) { + fd.append(key, value); + }); + } + + // ------------------------------------------------------------------ + // transport: "última gana" por scope con AbortController + // ------------------------------------------------------------------ + var inflight = new Map(); + + function sendEvent(eventId, scopeEl, triggerEl, extraParams) { + var fd = new FormData(); + fd.append('_ui_event', eventId); + if (triggerEl && triggerEl.dataset.uiPath) { + fd.append('_ui_source', triggerEl.dataset.uiPath); + } + Object.keys(extraParams || {}).forEach(function (key) { + fd.append(key, extraParams[key]); + }); + serializeScope(scopeEl, fd); + // set() tras serializar: sustituye el token base del form por el token + // con sufijo contador (un token por petición, anti-doble-submit del servidor) + fd.set('multireqtoken', nextToken(scopeEl)); + + var key = eventId.split(':')[0]; + var previous = inflight.get(key); + if (previous) previous.abort(); + var controller = new AbortController(); + inflight.set(key, controller); + + startLoading(triggerEl, scopeEl); + + return fetch(window.location.href, { + method: 'POST', + headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }, + body: fd, + signal: controller.signal + }) + .then(function (r) { + if (!r.ok) throw new Error('HTTP ' + r.status); + return r.json(); + }) + .then(function (envelope) { + applyEnvelope(envelope, scopeEl); + return envelope; + }) + .catch(function (err) { + if (err.name === 'AbortError') return null; + console.error('[UIEngine] fetch error', err); + toast('error', (window.UI.texts.requestError || 'Error de red') + ': ' + err.message); + return null; + }) + .finally(function () { + if (inflight.get(key) === controller) inflight.delete(key); + stopLoading(triggerEl, scopeEl); + }); + } + + // ------------------------------------------------------------------ + // loading + // ------------------------------------------------------------------ + function startLoading(triggerEl, scopeEl) { + if (triggerEl && triggerEl.tagName === 'BUTTON') { + triggerEl.dataset.uiOriginalHtml = triggerEl.innerHTML; + triggerEl.disabled = true; + triggerEl.innerHTML = ''; + } + if (scopeEl) scopeEl.setAttribute('aria-busy', 'true'); + } + + function stopLoading(triggerEl, scopeEl) { + if (triggerEl && triggerEl.tagName === 'BUTTON' && triggerEl.isConnected) { + triggerEl.disabled = false; + if (triggerEl.dataset.uiOriginalHtml) { + triggerEl.innerHTML = triggerEl.dataset.uiOriginalHtml; + delete triggerEl.dataset.uiOriginalHtml; + } + } + if (scopeEl && scopeEl.isConnected) scopeEl.removeAttribute('aria-busy'); + } + + // ------------------------------------------------------------------ + // applier + // ------------------------------------------------------------------ + function resolveTarget(id) { + return document.getElementById(id) + || document.querySelector('[data-ui-path="' + id + '"]'); + } + + function captureFocus() { + var el = document.activeElement; + if (!el || !el.name) return null; + return { + name: el.name, + start: typeof el.selectionStart === 'number' ? el.selectionStart : null, + end: typeof el.selectionEnd === 'number' ? el.selectionEnd : null + }; + } + + function restoreFocus(state) { + if (!state) return; + if (document.activeElement && document.activeElement.name === state.name) return; + var el = document.querySelector('[name="' + CSS.escape(state.name) + '"]'); + if (!el) return; + el.focus(); + if (state.start !== null && typeof el.setSelectionRange === 'function') { + try { el.setSelectionRange(state.start, state.end); } catch (_) {} + } + } + + function swap(fragment) { + var target = resolveTarget(fragment.id); + if (!target) { + console.warn('[UIEngine] fragment target not found:', fragment.id); + return; + } + var focusState = captureFocus(); + + if (fragment.mode === 'inner') { + target.innerHTML = fragment.html; + initBehaviors(target); + } else if (fragment.mode === 'append') { + var tpl = document.createElement('template'); + tpl.innerHTML = fragment.html; + Array.prototype.slice.call(tpl.content.children).forEach(function (child) { + target.appendChild(child); + initBehaviors(child); + }); + } else { // replace + // preservar el estado activo de los tab-pane: el servidor siempre + // renderiza la primera pestaña como activa, pero el usuario puede + // estar viendo otra + var wasActivePane = target.classList.contains('tab-pane') && target.classList.contains('active'); + var wasInactivePane = target.classList.contains('tab-pane') && !target.classList.contains('active'); + + target.outerHTML = fragment.html; + var replacement = resolveTarget(fragment.id); + if (replacement) { + if (wasActivePane) replacement.classList.add('show', 'active'); + if (wasInactivePane) replacement.classList.remove('show', 'active'); + initBehaviors(replacement); + } + } + + restoreFocus(focusState); + document.dispatchEvent(new CustomEvent('ui:swapped', { detail: { id: fragment.id } })); + } + + function applyErrors(errors) { + var keys = Object.keys(errors || {}); + if (!keys.length) return; + // los errores llegan ya renderizados dentro de los fragmentos; este mapa + // solo sirve para llevar al usuario hasta el primer campo erróneo + var first = keys[0].split('.'); // 'form.campo' + var formEl = document.getElementById('ui-' + first[0]) || resolveTarget(first[0]); + var input = formEl + ? formEl.querySelector('[name="' + CSS.escape(first[0] + '[' + first[1] + ']') + '"]') + : null; + if (input) { + input.scrollIntoView({ behavior: 'smooth', block: 'center' }); + input.focus(); + } + } + + function toast(level, message) { + var container = document.getElementById('ui-toasts'); + if (!container) { + alert(message); + return; + } + var color = level === 'error' || level === 'critical' ? 'danger' + : level === 'warning' ? 'warning' + : level === 'info' ? 'info' + : 'success'; + var el = document.createElement('div'); + el.className = 'toast align-items-center text-bg-' + color + ' border-0'; + el.setAttribute('role', 'alert'); + el.innerHTML = '
' + + '
'; + el.querySelector('.toast-body').innerHTML = message; + container.appendChild(el); + var toastObj = new bootstrap.Toast(el, { delay: 5000 }); + el.addEventListener('hidden.bs.toast', function () { el.remove(); }); + toastObj.show(); + } + + function applyActions(actions) { + (actions || []).forEach(function (action) { + var target = action.target ? resolveTarget(action.target) : null; + switch (action.type) { + case 'redirect': + window.location.assign(action.url); + break; + case 'reload': + window.location.reload(); + break; + case 'focus': + if (target) { + var input = target.matches('input,select,textarea') ? target + : target.querySelector('input,select,textarea'); + if (input) input.focus(); + } + break; + case 'scroll': + if (target) target.scrollIntoView({ behavior: 'smooth', block: 'center' }); + break; + case 'tab': + if (target) { + var btn = document.querySelector('[data-bs-target="#' + target.id + '"]'); + if (btn) new bootstrap.Tab(btn).show(); + } + break; + case 'modal': + if (target) { + var modalEl = target.matches('.modal') ? target : target.querySelector('.modal'); + if (modalEl) { + var instance = bootstrap.Modal.getOrCreateInstance(modalEl); + action.action === 'hide' ? instance.hide() : instance.show(); + } + } + break; + } + }); + } + + function applyEnvelope(envelope, scopeEl) { + if (!envelope) return; + + var redirect = (envelope.actions || []).find(function (a) { return a.type === 'redirect'; }); + if (redirect) { + window.location.assign(redirect.url); + return; + } + + (envelope.fragments || []).forEach(swap); + applyErrors(envelope.errors); + (envelope.notices || []).forEach(function (n) { toast(n.level, n.message); }); + applyActions((envelope.actions || []).filter(function (a) { return a.type !== 'redirect'; })); + } + + // ------------------------------------------------------------------ + // behaviors: re-inicializables tras cada swap + // ------------------------------------------------------------------ + var behaviors = {}; + + function registerBehavior(name, def) { + behaviors[name] = def; + } + + function initBehaviors(root) { + if (!root || !root.querySelectorAll) return; + var nodes = Array.prototype.slice.call(root.querySelectorAll('[data-ui-behavior]')); + if (root.matches && root.matches('[data-ui-behavior]')) nodes.unshift(root); + + nodes.forEach(function (el) { + el.dataset.uiBehavior.split(/\s+/).forEach(function (name) { + var def = behaviors[name]; + if (!def) return; + var mark = 'uiInit' + name.replace(/[^a-z0-9]/gi, ''); + if (el.dataset[mark]) return; + el.dataset[mark] = '1'; + def.init(el); + }); + }); + } + + // ------------------------------------------------------------------ + // dispatcher: delegación a nivel document (sobrevive a los swaps) + // ------------------------------------------------------------------ + function resolveScope(el) { + if (el.dataset.uiScope === 'none') return null; + if (el.dataset.uiScope && el.dataset.uiScope !== 'closest') { + return document.querySelector(el.dataset.uiScope); + } + return el.closest('[data-ui-form]'); + } + + function trigger(el) { + var eventId = el.dataset.uiEvent + || (el.form && el.form.dataset ? el.form.dataset.uiEvent : null); + if (!eventId) return; + if (el.dataset.uiConfirm && !window.confirm(el.dataset.uiConfirm)) return; + var extra = {}; + if (el.dataset.uiTargets) extra['_ui_targets'] = el.dataset.uiTargets; + sendEvent(eventId, resolveScope(el), el, extra); + } + + document.addEventListener('click', function (e) { + var el = e.target.closest('[data-ui-on~="click"]'); + if (!el) return; + e.preventDefault(); + trigger(el); + }); + + document.addEventListener('change', function (e) { + var el = e.target.closest('[data-ui-on~="change"]'); + if (!el) return; + trigger(el); + }); + + var debounceTimers = new WeakMap(); + document.addEventListener('input', function (e) { + var el = e.target.closest('[data-ui-on~="input"]'); + if (!el) return; + var delay = parseInt(el.dataset.uiDebounce || '400', 10); + clearTimeout(debounceTimers.get(el)); + debounceTimers.set(el, setTimeout(function () { trigger(el); }, delay)); + }); + + // submit del form: intercepta y envía por AJAX el evento del submitter + // (o el submit por defecto del form). Sin JS el POST nativo sigue funcionando. + document.addEventListener('submit', function (e) { + var form = e.target.closest('form[data-ui-form]'); + if (!form) return; + e.preventDefault(); + + var submitter = e.submitter; + var eventId = (submitter && submitter.value && submitter.name === '_ui_event') + ? submitter.value + : form.dataset.uiEvent; + if (!eventId) return; + if (submitter && submitter.dataset.uiConfirm && !window.confirm(submitter.dataset.uiConfirm)) return; + sendEvent(eventId, form, submitter || form); + }); + + // los botones data-ui-on="click" dentro de forms son type=submit para la + // degradación sin JS; con JS el listener de click ya los gestiona, así que + // evitamos el doble disparo marcándolos gestionados en el listener de click + // (preventDefault en click impide el submit nativo). + + // ------------------------------------------------------------------ + // behaviors integrados: select2 (estático y remoto) + // ------------------------------------------------------------------ + function select2BaseOptions(el) { + return { + theme: 'bootstrap-5', + width: '100%', + placeholder: el.dataset.uiPlaceholder || undefined, + dropdownParent: window.jQuery(el.closest('.modal') || document.body) + }; + } + + // select2 dispara el change de jQuery, no el nativo: lo re-emitimos para que + // la delegación nativa del dispatcher (cascadas) funcione. e.originalEvent + // evita el bucle cuando el change ya es nativo. + function bridgeNativeChange(el) { + window.jQuery(el).on('change', function (e) { + if (e.originalEvent) return; + el.dispatchEvent(new Event('change', { bubbles: true })); + }); + } + + registerBehavior('select2', { + init: function (el) { + if (!window.jQuery || !window.jQuery.fn.select2) return; + window.jQuery(el).select2(select2BaseOptions(el)); + bridgeNativeChange(el); + } + }); + + registerBehavior('select2-query', { + init: function (el) { + if (!window.jQuery || !window.jQuery.fn.select2) return; + var options = select2BaseOptions(el); + options.minimumInputLength = parseInt(el.dataset.uiQueryMin || '1', 10); + options.ajax = { + url: window.location.pathname, + dataType: 'json', + delay: 250, + data: function (params) { + var data = { + _ui_query: 'search', + _ui_target: el.dataset.uiQueryTarget, + term: params.term || '' + }; + // cascada remota: incluye el valor actual del campo padre + if (el.dataset.uiParentName && el.form) { + var parent = el.form.elements[el.dataset.uiParentName]; + if (parent) data.parent = parent.value; + } + return data; + } + }; + if (el.dataset.uiTags) { + options.tags = true; + } + window.jQuery(el).select2(options); + bridgeNativeChange(el); + } + }); + + // ------------------------------------------------------------------ + // behavior integrado: persistencia de pestaña activa + // ------------------------------------------------------------------ + registerBehavior('tab-persist', { + init: function (content) { + var key = 'ui_tab_' + content.dataset.uiPanel; + var savedId; + try { savedId = sessionStorage.getItem(key); } catch (_) {} + if (savedId && document.getElementById(savedId)) { + var btn = document.querySelector('[data-bs-target="#' + savedId + '"]'); + if (btn) new bootstrap.Tab(btn).show(); + } + content.addEventListener('shown.bs.tab', saveActive, true); + document.querySelectorAll('[data-bs-target^="#' + content.id + '"]').forEach(function (b) { + b.addEventListener('shown.bs.tab', saveActive); + }); + + function saveActive() { + var active = content.querySelector('.tab-pane.active'); + if (active) { + try { sessionStorage.setItem(key, active.id); } catch (_) {} + } + } + } + }); + + // ------------------------------------------------------------------ + // API pública + arranque + // ------------------------------------------------------------------ + window.UI = { + behaviors: { register: registerBehavior }, + initBehaviors: initBehaviors, + send: sendEvent, + toast: toast, + texts: {} + }; + + document.addEventListener('DOMContentLoaded', function () { + initBehaviors(document.body); + }); +})(); From 5859a88316f83f6f964124405efebfa487295343 Mon Sep 17 00:00:00 2001 From: Abderrahim Darghal Belkacemi Date: Thu, 2 Jul 2026 12:27:01 +0200 Subject: [PATCH 4/4] feat(NewDashboardUI): demo del sistema de componentes UI --- Core/Controller/NewDashboardUI.php | 310 +++++++++++++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 Core/Controller/NewDashboardUI.php diff --git a/Core/Controller/NewDashboardUI.php b/Core/Controller/NewDashboardUI.php new file mode 100644 index 0000000000..d51c5fcd43 --- /dev/null +++ b/Core/Controller/NewDashboardUI.php @@ -0,0 +1,310 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Core\Controller; + +use FacturaScripts\Core\Lib\UI\Event\UIEvent; +use FacturaScripts\Core\Lib\UI\Event\UIResponse; +use FacturaScripts\Core\Lib\UI\Field; +use FacturaScripts\Core\Lib\UI\UIButton; +use FacturaScripts\Core\Lib\UI\UICard; +use FacturaScripts\Core\Lib\UI\UIController; +use FacturaScripts\Core\Lib\UI\UIDropdown; +use FacturaScripts\Core\Lib\UI\UIForm; +use FacturaScripts\Core\Lib\UI\UIGroup; +use FacturaScripts\Core\Lib\UI\UIInfoBox; +use FacturaScripts\Core\Lib\UI\UIModal; +use FacturaScripts\Core\Lib\UI\UIPage; +use FacturaScripts\Core\Lib\UI\UITabs; +use FacturaScripts\Core\Lib\UI\Validation\ErrorBag; +use FacturaScripts\Core\Tools; + +/** + * Página de demostración del sistema de UI Components. + * + * Muestra varios formularios independientes (cada uno con su propio ciclo de + * submit/validación AJAX), pestañas, grupos, selects con carga remota, una + * cascada país→provincia, validación cross-field, un modal server-rendered y + * eventos de página que actualizan fragmentos. + * + * No persiste datos — los eventos emiten un notice de confirmación. + * + * @author Abderrahim Darghal Belkacemi + */ +class NewDashboardUI extends UIController +{ + public function getPageData(): array + { + $data = parent::getPageData(); + $data['menu'] = 'admin'; + $data['title'] = 'ui-components-demo'; + $data['icon'] = 'fa-solid fa-flask'; + return $data; + } + + protected function buildUI(UIPage $page): void + { + // --- Formulario de cabecera, independiente del panel de pestañas --- + $page->add( + UIForm::make('header')->title('demo-header-fields') + ->add( + Field::text('titulo')->label('title')->placeholder('Escribe un título de prueba')->setCols(6), + Field::number('cantidad')->label('quantity')->decimals(0)->min(0)->setCols(3), + Field::date('fecha')->label('date')->setCols(3), + UIButton::submit('save') + ) + ->onSubmit(function (UIEvent $e, UIResponse $r) { + $this->logValues($e, ['titulo', 'cantidad', 'fecha']); + $r->rerender($e->form()); + }) + ); + + // --- Panel con sub-pestañas dentro de una tarjeta --- + $tabs = UITabs::make('demo'); + $page->add( + UICard::make('panel')->title('Panel con sub-pestañas')->icon('fa-solid fa-table-columns')->add($tabs) + ); + + $this->buildGeneralTab($tabs); + $this->buildOptionsTab($tabs); + $this->buildNotesTab($tabs); + $this->buildActionsTab($page, $tabs); + } + + private function buildGeneralTab(UITabs $tabs): void + { + $general = $tabs->tab('general', 'General', 'fa-solid fa-circle-info'); + + // cada bloque con guardado propio es un form independiente; el nombre + // de campo solo debe ser único dentro de su form + $general->add( + UIForm::make('identification')->title('Identificación') + ->add( + Field::text('nombre')->label('name')->required()->setCols(5), + Field::text('codigo')->label('code')->setCols(3), + Field::number('precio')->label('price')->decimals(2)->setCols(2), + UIButton::submit('save')->color('outline-primary')->setCols(2) + ) + ->addCheck(function (UIForm $form, ErrorBag $errors) { + if ((float)$form->value('precio') > 0 && empty($form->value('codigo'))) { + $errors->add('codigo', 'El código es obligatorio cuando hay precio.'); + } + }) + ->onSubmit(function (UIEvent $e, UIResponse $r) { + $this->logValues($e, ['nombre', 'codigo', 'precio']); + $r->rerender($e->form()); + }) + ); + + $general->add( + UIForm::make('dates')->title('Fechas y ubicación') + ->add( + Field::date('fecha_inicio')->label('start-date')->setCols(3), + Field::date('fecha_fin')->label('end-date')->setCols(3), + // cascada: al cambiar el país se re-renderiza el select de provincia + Field::select('pais')->label('country') + ->fromCodeModel('Pais', 'codpais', 'nombre')->setCols(2), + Field::select('provincia')->label('province') + ->fromCodeModel('Provincia', 'idprovincia', 'provincia') + ->dependsOn('pais', 'codpais')->setCols(2), + UIButton::submit('save')->color('outline-primary')->setCols(2) + ) + ->addCheck(function (UIForm $form, ErrorBag $errors) { + $inicio = $form->value('fecha_inicio'); + $fin = $form->value('fecha_fin'); + if (!empty($inicio) && !empty($fin) && $fin < $inicio) { + $errors->add('fecha_fin', 'La fecha final no puede ser anterior a la inicial.'); + } + }) + ->onSubmit(function (UIEvent $e, UIResponse $r) { + $this->logValues($e, ['fecha_inicio', 'fecha_fin', 'pais', 'provincia']); + $r->rerender($e->form()); + }) + ); + } + + private function buildOptionsTab(UITabs $tabs): void + { + $opciones = $tabs->tab('opciones', 'Opciones', 'fa-solid fa-sliders'); + + $opciones->add( + UIForm::make('settings')->title('Ajustes de visibilidad') + ->add( + UIGroup::make('checks')->alignBottom()->add( + Field::checkbox('activo')->label('active'), + Field::checkbox('destacado')->label('featured'), + Field::checkbox('visible')->label('visible')->setValue(true), + UIButton::submit('save')->color('outline-primary') + ) + ) + ->onSubmit(function (UIEvent $e, UIResponse $r) { + $this->logValues($e, ['activo', 'destacado', 'visible']); + $r->rerender($e->form()); + }) + ); + + $opciones->add( + UIForm::make('classification')->title('Clasificación') + ->add( + Field::select('tipo')->label('type')->options([ + 'A' => 'Tipo A', + 'B' => 'Tipo B', + 'C' => 'Tipo C', + ])->setCols(4), + Field::select('estado')->label('status')->options([ + 'borrador' => 'Borrador', + 'publicado' => 'Publicado', + 'archivado' => 'Archivado', + ])->setCols(4), + // select2 remoto: busca contra el endpoint _ui_query del componente + Field::select('pais')->label('country') + ->searchable('Pais', 'codpais', 'nombre') + ->placeholder('Buscar país…')->setCols(2), + UIButton::submit('save')->color('outline-primary')->setCols(2) + ) + ->onSubmit(function (UIEvent $e, UIResponse $r) { + $this->logValues($e, ['tipo', 'estado', 'pais']); + $r->rerender($e->form()); + }) + ); + } + + private function buildNotesTab(UITabs $tabs): void + { + $notas = $tabs->tab('notas', 'Notas', 'fa-solid fa-note-sticky'); + + $form = UIForm::make('notes')->title('Texto libre') + ->add( + Field::textarea('observaciones')->label('observations')->rows(4)->setCols(12), + Field::textarea('notas_internas')->label('internal-notes')->rows(3)->setCols(12), + UIButton::submit('save')->color('outline-primary'), + UIButton::make('clear')->label('Limpiar')->icon('fa-solid fa-eraser') + ->color('outline-danger')->action('clear') + ->confirm('¿Borrar el contenido de las notas?') + ) + ->onSubmit(function (UIEvent $e, UIResponse $r) { + $this->logValues($e, ['observaciones', 'notas_internas']); + $r->rerender($e->form()); + }); + + // evento sin validación: vacía los campos y re-renderiza el form + $form->on('clear', function (UIEvent $e, UIResponse $r) { + $e->form()->field('observaciones')->setValue(null); + $e->form()->field('notas_internas')->setValue(null); + $r->notice('Notas vaciadas.')->rerender($e->form()); + }); + + $notas->add($form); + } + + private function buildActionsTab(UIPage $page, UITabs $tabs): void + { + $acciones = $tabs->tab('acciones', 'Acciones', 'fa-solid fa-bolt'); + + // infobox actualizable por evento de página (fragmento) + $counterBox = UIInfoBox::make('info_counter') + ->title('Documentos pendientes') + ->text((string)random_int(10, 99)) + ->icon('fa-solid fa-file-invoice') + ->color('primary') + ->setCols(4); + + $acciones->add( + UIGroup::make('info_cards')->title('Tarjetas informativas')->add( + UIInfoBox::make('info_ok') + ->title('Sistema operativo') + ->text('Todos los servicios funcionan correctamente.') + ->icon('fa-solid fa-circle-check')->color('success')->setCols(4), + UIInfoBox::make('info_warn') + ->title('Aviso de mantenimiento') + ->text('Se realizará mantenimiento el próximo domingo.') + ->icon('fa-solid fa-triangle-exclamation')->color('warning')->setCols(4), + $counterBox + ), + UIGroup::make('action_buttons')->title('Botones de acción')->add( + UIButton::make('btn_refresh')->label('Actualizar contador') + ->icon('fa-solid fa-rotate')->color('primary') + ->pageAction('refresh_counter'), + UIButton::make('btn_modal')->label('Abrir modal') + ->icon('fa-solid fa-window-restore')->color('outline-info') + ->pageAction('open_contact'), + UIButton::make('btn_link')->label('Ver documentación') + ->icon('fa-solid fa-book')->color('outline-secondary') + ->link('https://facturascripts.com/comunidad') + ), + UIGroup::make('dropdown_actions')->title('Desplegables')->add( + UIDropdown::make('drop_export')->label('Exportar') + ->icon('fa-solid fa-file-export')->color('secondary') + ->item('CSV', '#', 'fa-solid fa-file-csv') + ->item('PDF', '#', 'fa-solid fa-file-pdf') + ->divider() + ->item('Excel', '#', 'fa-solid fa-file-excel'), + UIDropdown::make('drop_ops')->label('Operaciones') + ->icon('fa-solid fa-gears')->color('outline-primary') + ->itemPageAction('Duplicar', 'demo_duplicate', 'fa-solid fa-copy') + ->itemPageAction('Archivar', 'demo_archive', 'fa-solid fa-box-archive') + ) + ); + + // modal server-rendered con su propio form + $page->add( + UIModal::make('contact_modal')->title('Contacto rápido')->icon('fa-solid fa-address-card') + ->add( + UIForm::make('contact') + ->add( + Field::text('nombre')->label('name')->required()->setCols(12), + Field::text('email')->label('email')->rule('email')->setCols(12), + UIButton::submit('save')->setCols(12) + ) + ->onSubmit(function (UIEvent $e, UIResponse $r) { + $this->logValues($e, ['nombre', 'email']); + $r->closeModal('contact_modal'); + }) + ) + ); + + // eventos de página + $page->on('refresh_counter', function (UIEvent $e, UIResponse $r) use ($counterBox) { + $counterBox->text((string)random_int(10, 99)); + $r->notice('Contador actualizado.')->rerender($counterBox); + }); + + $page->on('open_contact', function (UIEvent $e, UIResponse $r) { + $r->openModal('contact_modal'); + }); + + $page->on('demo_duplicate', fn(UIEvent $e, UIResponse $r) => $r->notice('Acción duplicar ejecutada.')); + $page->on('demo_archive', fn(UIEvent $e, UIResponse $r) => $r->notice('Acción archivar ejecutada.')); + } + + /** Emite un notice con los valores actuales de los campos indicados del form del evento. */ + private function logValues(UIEvent $event, array $fields): void + { + $parts = []; + foreach ($fields as $name) { + $field = $event->form()?->field($name); + if ($field !== null) { + $parts[] = $field->labelText() . ': ' . htmlspecialchars($field->displayValue()) . ''; + } + } + Tools::log()->notice( + empty($parts) ? 'Guardado sin valores.' : 'Guardado — ' . implode('  |  ', $parts) + ); + } +}