feat: puntos de extensión por cliente para el menú, las rutas del frontend y los permisos del proxy del Service Layer
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8ba845cc23
commit
3caf94609d
10 files changed
+178
-31
No files matched your search
@@ -0,0 +1,12 @@
|
||||
import type { MenuItem } from './menu.js';
|
||||
|
||||
/**
|
||||
* Pantallas que la rama de un cliente suma al menú (vacío en `main`), por id de módulo (`inventario`, `ventas`…): se
|
||||
* agregan al final de ese módulo. Cada ruta necesita su pantalla en `client-routes.ts` del frontend; si el recurso que
|
||||
* usa necesita permiso en el proxy del Service Layer, se declara en `sl-resources.extensions.ts` de la API.
|
||||
*
|
||||
* Ejemplo:
|
||||
*
|
||||
* inventario: [{ path: '/inventario/inmovilizar-lotes', label: 'Inmovilizar lotes' }],
|
||||
*/
|
||||
export const CLIENT_MENU_ITEMS: Readonly<Record<string, readonly MenuItem[]>> = {};
|
||||
@@ -1,26 +1,24 @@
|
||||
import { MENU, filterMenu, menuPaths } from './menu.js';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MENU, extendMenu } from './menu.js';
|
||||
|
||||
describe('MENU', () => {
|
||||
it('no repite rutas y todas son absolutas', () => {
|
||||
const paths = menuPaths();
|
||||
expect(new Set(paths).size).toBe(paths.length);
|
||||
expect(paths.every((p) => /^\/[a-z-]+\/[a-z-]+$/.test(p))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterMenu', () => {
|
||||
const ids = (allowed: string[]) => filterMenu(MENU, allowed).map((m) => m.id);
|
||||
const base = [
|
||||
{ id: 'a', label: 'A', items: [{ path: '/a/uno', label: 'Uno' }] },
|
||||
{ id: 'b', label: 'B', items: [] },
|
||||
];
|
||||
|
||||
it('"*" ve todo', () => expect(filterMenu(MENU, ['*'])).toHaveLength(MENU.length));
|
||||
it('sin permisos no ve nada', () => expect(filterMenu(MENU, [])).toEqual([]));
|
||||
describe('extendMenu', () => {
|
||||
it('suma las pantallas del cliente al final de su módulo y deja los demás igual', () => {
|
||||
const m = extendMenu(base, { a: [{ path: '/a/dos', label: 'Dos' }] });
|
||||
expect(m[0].items.map((i) => i.path)).toEqual(['/a/uno', '/a/dos']);
|
||||
expect(m[1]).toBe(base[1]);
|
||||
});
|
||||
|
||||
it('un módulo completo y una pantalla suelta de otro', () => {
|
||||
const result = filterMenu(MENU, ['/ventas/*', '/inventario/articulos']);
|
||||
expect(result.map((m) => m.id)).toEqual(['ventas', 'inventario']);
|
||||
expect(result[1].items.map((i) => i.path)).toEqual(['/inventario/articulos']);
|
||||
it('un módulo que no existe o una ruta repetida es un error', () => {
|
||||
expect(() => extendMenu(base, { zz: [{ path: '/zz/x', label: 'X' }] })).toThrow(/no existe el módulo/);
|
||||
expect(() => extendMenu(base, { b: [{ path: '/a/uno', label: 'Otra' }] })).toThrow(/ya existe/);
|
||||
});
|
||||
|
||||
it('omite los módulos sin pantallas permitidas', () => {
|
||||
expect(ids(['/socios/clientes'])).toEqual(['socios']);
|
||||
it('en main el menú es el del base (sin pantallas de cliente)', () => {
|
||||
expect(MENU.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CLIENT_MENU_ITEMS } from './menu.extensions.js';
|
||||
import { isPathAllowed } from './permissions.js';
|
||||
|
||||
export interface MenuItem {
|
||||
@@ -12,11 +13,8 @@ export interface MenuModule {
|
||||
items: readonly MenuItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Menú de la aplicación: única fuente de rutas. El frontend lo dibuja filtrado
|
||||
* por permisos y el backend (registro del proxy SL) usa las mismas rutas.
|
||||
*/
|
||||
export const MENU: readonly MenuModule[] = [
|
||||
/** Menú del proyecto base; cada cliente suma sus pantallas en `menu.extensions.ts` (el resultado es `MENU`). */
|
||||
const BASE_MENU: readonly MenuModule[] = [
|
||||
{
|
||||
id: 'ventas',
|
||||
label: 'Ventas',
|
||||
@@ -101,6 +99,32 @@ export const MENU: readonly MenuModule[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Suma las pantallas de un cliente al final de los módulos del menú base (por id de módulo). Un módulo que no existe o
|
||||
* una ruta repetida es un error: un descuido no debe pasar en silencio.
|
||||
*/
|
||||
export function extendMenu(base: readonly MenuModule[], extra: Readonly<Record<string, readonly MenuItem[]>>): readonly MenuModule[] {
|
||||
for (const id of Object.keys(extra)) {
|
||||
if (!base.some((m) => m.id === id)) throw new Error(`menu.extensions: no existe el módulo «${id}»`);
|
||||
}
|
||||
const taken = new Set(base.flatMap((m) => m.items.map((i) => i.path)));
|
||||
return base.map((m) => {
|
||||
const more = extra[m.id];
|
||||
if (!more?.length) return m;
|
||||
for (const item of more) {
|
||||
if (taken.has(item.path)) throw new Error(`menu.extensions: la ruta «${item.path}» ya existe`);
|
||||
taken.add(item.path);
|
||||
}
|
||||
return { ...m, items: [...m.items, ...more] };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Menú de la aplicación: única fuente de rutas. El frontend lo dibuja filtrado
|
||||
* por permisos y el backend (registro del proxy SL) usa las mismas rutas.
|
||||
*/
|
||||
export const MENU: readonly MenuModule[] = extendMenu(BASE_MENU, CLIENT_MENU_ITEMS);
|
||||
|
||||
/** Deja solo lo que el usuario puede ver; los módulos sin pantallas visibles se omiten. */
|
||||
export function filterMenu(menu: readonly MenuModule[], allowedPaths: readonly string[]): MenuModule[] {
|
||||
return menu
|
||||
|
||||
Reference in new issue
Block a user