From 22b2d1612ff6e490292e102631aeabb9a3ba893d Mon Sep 17 00:00:00 2001 From: David Date: Fri, 9 Oct 2026 10:23:55 +0200 Subject: [PATCH] Estado de la barra lateral --- docs/componentes-de-interfaz.md | 7 ++ modules/companies/package.json | 2 +- .../view/helpers/sidebar-preference.test.ts | 85 +++++++++++++++++++ .../view/helpers/sidebar-preference.ts | 24 ++++++ .../workspace/view/ui/workspace-sidebar.tsx | 10 ++- 5 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 modules/companies/src/web/workspace/view/helpers/sidebar-preference.test.ts create mode 100644 modules/companies/src/web/workspace/view/helpers/sidebar-preference.ts diff --git a/docs/componentes-de-interfaz.md b/docs/componentes-de-interfaz.md index 864be3c..18adb57 100644 --- a/docs/componentes-de-interfaz.md +++ b/docs/componentes-de-interfaz.md @@ -185,6 +185,13 @@ de ayuda también para compañías y subcatálogos. La navegación se desplaza p separado, manteniendo Configuración y el control de anchura en el pie visible. En móvil se conserva la navegación horizontal existente. +El plegado se conserva entre recargas y sesiones mediante la clave +`metis-desk:sidebar-collapsed` de `localStorage`, compartida por las sesiones de +ese navegador y origen, no por cuenta ni mediante el servidor. Solo `true` inicia +la barra plegada; valores ausentes o inválidos mantienen el estado desplegado. +Se guarda desde la acción de plegar/desplegar. Si el navegador bloquea la lectura +o la escritura, la barra sigue funcionando en memoria. No cambia el layout móvil. + El menú expandido y su pie comparten márgenes horizontales de 20px mediante `--spacing-shell-sidebar-inset` y la utilidad `px-shell-sidebar-inset`. El header global usa `bg-shell-header` (#FFFFFF), con un borde inferior `border-shell-border`, sobre el contenido #F7F9FB. diff --git a/modules/companies/package.json b/modules/companies/package.json index f805e50..3543f13 100644 --- a/modules/companies/package.json +++ b/modules/companies/package.json @@ -10,7 +10,7 @@ }, "scripts": { "typecheck": "tsc -p tsconfig.json", - "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/companies/shared/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/shared/access.test.tsx src/web/mail-configurations/shared/mail.test.tsx src/api/domain/work-profile.test.ts src/web/work-profiles/shared/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printer-configurations/shared/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/application-configurations/shared/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificate-configurations/shared/certificate.test.tsx src/web/work-environments/shared/work-environment.test.tsx src/api/domain/telework-authorization.test.ts src/web/telework-authorizations/shared/telework.test.tsx src/api/domain/remote-connection.test.ts src/api/domain/process.test.ts src/web/remote-connections/shared/remote.test.tsx src/web/processes/shared/processes.test.tsx src/api/domain/ticket.test.ts src/web/tickets/shared/tickets.test.tsx src/api/domain/intervention-template.test.ts src/web/intervention-templates/shared/templates.test.tsx src/api/domain/intervention.test.ts src/web/interventions/shared/interventions.test.tsx src/web/shared/initials-avatar.test.tsx src/api/infrastructure/company-logo-processor.test.ts src/web/companies/shared/company-avatar.test.tsx src/web/shared/client-contracts.test.ts src/web/workspace/shared/workspace-routes.test.ts" + "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/companies/shared/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/shared/access.test.tsx src/web/mail-configurations/shared/mail.test.tsx src/api/domain/work-profile.test.ts src/web/work-profiles/shared/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printer-configurations/shared/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/application-configurations/shared/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificate-configurations/shared/certificate.test.tsx src/web/work-environments/shared/work-environment.test.tsx src/api/domain/telework-authorization.test.ts src/web/telework-authorizations/shared/telework.test.tsx src/api/domain/remote-connection.test.ts src/api/domain/process.test.ts src/web/remote-connections/shared/remote.test.tsx src/web/processes/shared/processes.test.tsx src/api/domain/ticket.test.ts src/web/tickets/shared/tickets.test.tsx src/api/domain/intervention-template.test.ts src/web/intervention-templates/shared/templates.test.tsx src/api/domain/intervention.test.ts src/web/interventions/shared/interventions.test.tsx src/web/shared/initials-avatar.test.tsx src/api/infrastructure/company-logo-processor.test.ts src/web/companies/shared/company-avatar.test.tsx src/web/shared/client-contracts.test.ts src/web/workspace/shared/workspace-routes.test.ts src/web/workspace/view/helpers/sidebar-preference.test.ts" }, "dependencies": { "@metis/core": "workspace:*", diff --git a/modules/companies/src/web/workspace/view/helpers/sidebar-preference.test.ts b/modules/companies/src/web/workspace/view/helpers/sidebar-preference.test.ts new file mode 100644 index 0000000..1b7f70e --- /dev/null +++ b/modules/companies/src/web/workspace/view/helpers/sidebar-preference.test.ts @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { readSidebarCollapsed, saveSidebarCollapsed } from "./sidebar-preference"; + +function withBrowser( + browser: { readonly localStorage: Pick }, + verify: () => void +) { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, "window"); + Object.defineProperty(globalThis, "window", { value: browser, configurable: true }); + try { + verify(); + } finally { + if (descriptor) { + Object.defineProperty(globalThis, "window", descriptor); + } else { + Reflect.deleteProperty(globalThis, "window"); + } + } +} + +test("la barra comienza desplegada sin navegador o sin una preferencia válida", () => { + assert.equal(readSidebarCollapsed(), false); + assert.doesNotThrow(() => saveSidebarCollapsed(true)); + for (const value of [null, "false", "TRUE", "1", "invalid", ""]) { + withBrowser({ localStorage: { getItem: () => value, setItem: () => undefined } }, () => { + assert.equal(readSidebarCollapsed(), false); + }); + } + withBrowser({ localStorage: { getItem: () => "true", setItem: () => undefined } }, () => { + assert.equal(readSidebarCollapsed(), true); + }); +}); + +test("plegar y desplegar persisten la preferencia sin modificar otras claves", () => { + const values = new Map([["another-preference", "keep"]]); + const localStorage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { + values.set(key, value); + }, + }; + withBrowser({ localStorage }, () => { + saveSidebarCollapsed(true); + assert.equal(readSidebarCollapsed(), true); + saveSidebarCollapsed(false); + assert.equal(readSidebarCollapsed(), false); + assert.equal(values.get("another-preference"), "keep"); + assert.equal(values.size, 2); + }); + withBrowser({ localStorage }, () => { + assert.equal(readSidebarCollapsed(), false); + }); +}); + +test("un fallo de lectura, escritura o acceso a localStorage no bloquea la barra", () => { + withBrowser( + { + localStorage: { + getItem: () => { + throw new Error("Storage unavailable"); + }, + setItem: () => { + throw new Error("Quota exceeded"); + }, + }, + }, + () => { + assert.equal(readSidebarCollapsed(), false); + assert.doesNotThrow(() => saveSidebarCollapsed(true)); + } + ); + withBrowser( + { + get localStorage(): never { + throw new Error("Storage access denied"); + }, + }, + () => { + assert.equal(readSidebarCollapsed(), false); + assert.doesNotThrow(() => saveSidebarCollapsed(false)); + } + ); +}); diff --git a/modules/companies/src/web/workspace/view/helpers/sidebar-preference.ts b/modules/companies/src/web/workspace/view/helpers/sidebar-preference.ts new file mode 100644 index 0000000..dffab1d --- /dev/null +++ b/modules/companies/src/web/workspace/view/helpers/sidebar-preference.ts @@ -0,0 +1,24 @@ +const SIDEBAR_COLLAPSED_KEY = "metis-desk:sidebar-collapsed"; + +export function readSidebarCollapsed(): boolean { + if (typeof window === "undefined") { + return false; + } + try { + return window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "true"; + } catch { + // Es una preferencia opcional: si el navegador bloquea el almacenamiento, empezar desplegado. + return false; + } +} + +export function saveSidebarCollapsed(collapsed: boolean): void { + if (typeof window === "undefined") { + return; + } + try { + window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(collapsed)); + } catch { + // La barra sigue funcionando en memoria aunque no se pueda persistir la preferencia. + } +} diff --git a/modules/companies/src/web/workspace/view/ui/workspace-sidebar.tsx b/modules/companies/src/web/workspace/view/ui/workspace-sidebar.tsx index ff38c9f..2096a20 100644 --- a/modules/companies/src/web/workspace/view/ui/workspace-sidebar.tsx +++ b/modules/companies/src/web/workspace/view/ui/workspace-sidebar.tsx @@ -18,6 +18,7 @@ import { CompanyAvatar } from "../../../companies"; import { WorkspaceNavigationButton } from "../../shared"; import { companySectionHref, workspaceHref } from "../../shared/helpers"; import type { GlobalCatalog, WorkspaceLocation } from "../../shared/types"; +import { readSidebarCollapsed, saveSidebarCollapsed } from "../helpers/sidebar-preference"; export function WorkspaceSidebar({ companies, @@ -35,7 +36,12 @@ export function WorkspaceSidebar({ const { t: resourceTranslation } = useTranslation("resources"); const { t: assetTranslation } = useTranslation("assets"); const { t: accessTranslation } = useTranslation("access"); - const [collapsed, setCollapsed] = useState(false); + const [collapsed, setCollapsed] = useState(readSidebarCollapsed); + const toggleCollapsed = () => { + const nextCollapsed = !collapsed; + setCollapsed(nextCollapsed); + saveSidebarCollapsed(nextCollapsed); + }; return (