feat(web): las extensiones de cliente pueden ocultar campos y columnas y dejarlos de solo lectura (documentos y maestros)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-16 11:14:52 -05:00
1 parent f077a61d08
commit 8dc4a8eaab
20 files changed
+374 -38

No files matched your search

@@ -5,6 +5,10 @@ import type { MasterExtensions } from '../master-extensions';
* lleva campos estándar de SAP. La rama `cliente/<nombre>` lo completa, por ruta de pantalla, con campos
* estándar adicionales o de usuario (`U_*`) y no toca ningún otro archivo.
*
* Además de agregar, puede ocultar campos (`hideFields`, `hideListColumns`) y dejarlos de solo lectura
* (`readOnlyFields`: se ven pero no se editan). Un nombre que no exista, o ocultar un campo obligatorio, lanza
* un error al arrancar.
*
* Ejemplo (los campos son de ejemplo):
*
* '/inventario/articulos': {
@@ -41,3 +41,43 @@ describe('extensiones por cliente de los maestros', () => {
expect(() => applyMasterExtensions(BASE_MASTER_SCREENS, { '/socios/clientes': { tabs: { nada: { left: [] } } } })).toThrow(/no tiene la pestaña/);
});
});
describe('ocultar y dejar de solo lectura en los maestros', () => {
const apply = (e: MasterExtensions) => applyMasterExtensions(BASE_MASTER_SCREENS, e);
const keys = (out: readonly { path: string }[], path: string) => masterKeysOf(by(out, path).layout);
it('oculta campos de cualquier pestaña y de la cabecera: no se muestran ni se envían', () => {
const out = apply({ '/socios/clientes': { hideFields: ['Website', 'Frozen'] } });
expect(keys(out, '/socios/clientes')).not.toContain('Website');
expect(keys(out, '/socios/clientes')).not.toContain('Frozen');
expect(keys(out, '/socios/clientes')).toContain('EmailAddress');
expect(keys(out, '/socios/proveedores')).toContain('Website');
});
it('oculta columnas de la lista', () => {
const out = apply({ '/socios/clientes': { hideListColumns: ['EmailAddress'] } });
expect(by(out, '/socios/clientes').columns.map((c) => c.key)).not.toContain('EmailAddress');
expect(by(out, '/socios/clientes').columns.map((c) => c.key)).toContain('Phone1');
});
it('un campo de solo lectura queda marcado y sigue siendo parte del registro', () => {
const out = apply({ '/inventario/articulos': { readOnlyFields: ['BarCode', 'MinInventory'] } });
const l = by(out, '/inventario/articulos').layout;
expect(l.lockedKeys).toEqual(['BarCode', 'MinInventory']);
expect(masterKeysOf(l)).toEqual(expect.arrayContaining(['BarCode', 'MinInventory']));
});
it('puede ocultar un campo del base y agregar el suyo', () => {
const out = apply({ '/socios/clientes': { hideFields: ['Website'], tabs: { general: { right: [{ type: 'text', key: 'U_Zona', label: 'Zona' }] } } } });
expect(keys(out, '/socios/clientes')).toEqual(expect.arrayContaining(['U_Zona']));
expect(keys(out, '/socios/clientes')).not.toContain('Website');
});
it('no se puede ocultar lo obligatorio y un nombre que no existe es un error', () => {
expect(() => apply({ '/socios/clientes': { hideFields: ['CardCode'] } })).toThrow(/obligatorio/);
expect(() => apply({ '/inventario/articulos': { hideFields: ['ItemName'] } })).toThrow(/obligatorio/);
expect(() => apply({ '/socios/clientes': { hideFields: ['Nada'] } })).toThrow(/no tiene el campo/);
expect(() => apply({ '/socios/clientes': { readOnlyFields: ['Nada'] } })).toThrow(/no tiene el campo/);
expect(() => apply({ '/socios/clientes': { hideListColumns: ['Nada'] } })).toThrow(/no tiene la columna de lista/);
});
});
@@ -1,5 +1,5 @@
import type { ListColumn } from '../../shared/list/list.types';
import type { FieldColumns, HeaderField } from '../documents/document-layouts';
import { type FieldColumns, type HeaderField, isRequired, keysOfFields } from '../documents/document-layouts';
import type { MasterScreen, MasterTab } from './master-types';
/** Campos que se agregan al final de las dos columnas de una cabecera o pestaña. */
@@ -9,8 +9,9 @@ export interface FieldAdditions {
}
/**
* Lo que una rama de cliente agrega a la pantalla de un maestro (campos estándar adicionales o de
* usuario `U_*`) sin tocar las pantallas base. Solo suma: no quita ni cambia lo que trae el base.
* Lo que una rama de cliente cambia en la pantalla de un maestro sin tocar las pantallas base: agrega campos
* estándar adicionales o de usuario (`U_*`), oculta lo que no usa o lo deja de solo lectura. Un nombre que no
* exista, o ocultar un campo obligatorio, es un error.
*/
export interface MasterExtension {
/** Campos al final de la cabecera (código y nombre). */
@@ -21,6 +22,12 @@ export interface MasterExtension {
newTabs?: readonly MasterTab[];
/** Columnas nuevas de la lista, al final. */
listColumns?: readonly ListColumn[];
/** Campos (de la cabecera o de cualquier pestaña) que no se muestran ni se envían, por su clave de SAP. */
hideFields?: readonly string[];
/** Campos que se ven pero no se editan. */
readOnlyFields?: readonly string[];
/** Columnas de la lista que no se muestran. */
hideListColumns?: readonly ListColumn['key'][];
}
/** Extensiones por ruta de la pantalla (`/socios/clientes`). */
@@ -31,6 +38,16 @@ const add = (base: FieldColumns, extra?: FieldAdditions): FieldColumns => ({
right: [...base.right, ...(extra?.right ?? [])],
});
const allFields = (s: MasterScreen): HeaderField[] => [
...s.layout.header.left,
...s.layout.header.right,
...s.layout.tabs.flatMap((t) => [...t.left, ...t.right]),
];
function checkNames(path: string, what: string, wanted: readonly string[] | undefined, known: readonly string[]): void {
for (const n of wanted ?? []) if (!known.includes(n)) throw new Error(`master-extensions: «${path}» no tiene ${what} «${n}»`);
}
/**
* Aplica las extensiones a las pantallas base. Una ruta que no existe, o una pestaña que no existe,
* es un error (un typo no debe pasar en silencio); las pantallas sin extensión quedan idénticas.
@@ -45,11 +62,33 @@ export function applyMasterExtensions(screens: readonly MasterScreen[], extensio
for (const id of Object.keys(ext.tabs ?? {})) {
if (!s.layout.tabs.some((t) => t.id === id)) throw new Error(`master-extensions: «${s.path}» no tiene la pestaña «${id}»`);
}
const tabs = s.layout.tabs.map((t) => (ext.tabs?.[t.id] ? { ...t, ...add(t, ext.tabs[t.id]) } : t));
// --- validar lo que se oculta o se bloquea contra lo que trae el base
const fields = allFields(s);
const names = fields.flatMap((f) => keysOfFields([f]));
checkNames(s.path, 'el campo', [...(ext.hideFields ?? []), ...(ext.readOnlyFields ?? [])], names);
checkNames(s.path, 'la columna de lista', ext.hideListColumns, s.columns.map((c) => c.key));
const hide = new Set(ext.hideFields ?? []);
for (const f of fields) {
if (isRequired(f) && keysOfFields([f]).some((k) => hide.has(k))) throw new Error(`master-extensions: «${s.path}» no puede ocultar el campo obligatorio «${f.label}»`);
}
const visible = (cols: FieldColumns): FieldColumns => ({
left: cols.left.filter((f) => !keysOfFields([f]).some((k) => hide.has(k))),
right: cols.right.filter((f) => !keysOfFields([f]).some((k) => hide.has(k))),
});
const tabs = s.layout.tabs.map((t) => {
const base = { ...t, ...visible(t) };
return ext.tabs?.[t.id] ? { ...base, ...add(base, ext.tabs[t.id]) } : base;
});
const lockedKeys = [...new Set([...(s.layout.lockedKeys ?? []), ...(ext.readOnlyFields ?? [])])];
return {
...s,
columns: [...s.columns, ...(ext.listColumns ?? [])],
layout: { ...s.layout, header: add(s.layout.header, ext.header), tabs: [...tabs, ...(ext.newTabs ?? [])] },
columns: [...s.columns.filter((c) => !(ext.hideListColumns ?? []).includes(c.key)), ...(ext.listColumns ?? [])],
layout: {
...s.layout,
header: add(visible(s.layout.header), ext.header),
tabs: [...tabs, ...(ext.newTabs ?? [])],
...(lockedKeys.length ? { lockedKeys } : {}),
},
};
});
}
@@ -12,12 +12,12 @@
<div class="sbo-hdr">
<div class="sbo-col">
@for (f of layout.header.left; track $index) {
<sbo-master-field [field]="f" [value]="value(keyOf(f))" [saved]="!isNew()" (changed)="setValue(keyOf(f), $event)" />
<sbo-master-field [field]="f" [value]="value(keyOf(f))" [saved]="!isNew()" [readOnly]="locked(keyOf(f))" (changed)="setValue(keyOf(f), $event)" />
}
</div>
<div class="sbo-col">
@for (f of layout.header.right; track $index) {
<sbo-master-field [field]="f" [value]="value(keyOf(f))" [saved]="!isNew()" (changed)="setValue(keyOf(f), $event)" />
<sbo-master-field [field]="f" [value]="value(keyOf(f))" [saved]="!isNew()" [readOnly]="locked(keyOf(f))" (changed)="setValue(keyOf(f), $event)" />
}
</div>
</div>
@@ -28,12 +28,12 @@
<div class="sbo-hdr">
<div class="sbo-col">
@for (f of tab.left; track $index) {
<sbo-master-field [field]="f" [value]="value(keyOf(f))" [saved]="!isNew()" (changed)="setValue(keyOf(f), $event)" />
<sbo-master-field [field]="f" [value]="value(keyOf(f))" [saved]="!isNew()" [readOnly]="locked(keyOf(f))" (changed)="setValue(keyOf(f), $event)" />
}
</div>
<div class="sbo-col">
@for (f of tab.right; track $index) {
<sbo-master-field [field]="f" [value]="value(keyOf(f))" [saved]="!isNew()" (changed)="setValue(keyOf(f), $event)" />
<sbo-master-field [field]="f" [value]="value(keyOf(f))" [saved]="!isNew()" [readOnly]="locked(keyOf(f))" (changed)="setValue(keyOf(f), $event)" />
}
</div>
</div>
@@ -6,6 +6,7 @@ import { of } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MasterFormPage } from './master-form.page';
import { BASE_MASTER_SCREENS } from './master-screens';
import { applyMasterExtensions } from './master-extensions';
const customer = (over: Record<string, unknown> = {}) => ({
'odata.etag': 'W/"3"',
@@ -257,4 +258,25 @@ describe('MasterFormPage', () => {
await tick();
expect(el().querySelector('.status')?.textContent).toContain('El registro no existe');
});
it('un campo oculto no aparece y uno de solo lectura se ve pero no se escribe', async () => {
const screen = applyMasterExtensions(BASE_MASTER_SCREENS, {
'/socios/clientes': { hideFields: ['Website'], readOnlyFields: ['EmailAddress'] },
}).find((s) => s.path === '/socios/clientes')!;
const route = { snapshot: { data: { screen, title: 'Cliente' }, paramMap: convertToParamMap({ key: 'C100' }) }, paramMap: of(convertToParamMap({ key: 'C100' })) };
await TestBed.configureTestingModule({
imports: [MasterFormPage],
providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting(), { provide: ActivatedRoute, useValue: route }],
}).compileComponents();
ctrl = TestBed.inject(HttpTestingController);
fixture = TestBed.createComponent(MasterFormPage);
await tick();
ctrl.expectOne('/api/sl/BusinessPartners/C100').flush(customer());
await tick();
expect(labels()).not.toContain('Sitio web');
const email = [...el().querySelectorAll<HTMLInputElement>('input')].find((i) => i.value === 'ventas@acme.cl')!;
expect(email.readOnly).toBe(true);
const phone = [...el().querySelectorAll<HTMLInputElement>('input')].find((i) => i.value === '2 2345 6789')!;
expect(phone.readOnly).toBe(false);
});
});
@@ -116,6 +116,13 @@ export class MasterFormPage {
return 'key' in f ? f.key : '';
}
private readonly lockedKeys = new Set(this.layout.lockedKeys ?? []);
/** El campo se ve pero no se edita (una extensión de cliente lo dejó de solo lectura). */
protected locked(key: string): boolean {
return this.lockedKeys.has(key);
}
protected value(key: string): unknown {
return this.header()[key];
}
@@ -21,6 +21,8 @@ export interface MasterLayout {
newHeader: () => DocRecord;
/** Valores que se envían siempre al crear (ej. `CardType: 'cCustomer'`). */
fixed?: DocRecord;
/** Campos que se ven pero no se editan (una extensión de cliente los dejó de solo lectura). */
lockedKeys?: readonly string[];
}
/** Pantalla de un maestro: lista + formulario (nuevo / ver-editar) de un recurso de clave de texto. */
@@ -5,6 +5,10 @@ import type { MasterExtensions } from '../master-extensions';
* lleva campos estándar de SAP. La rama `cliente/<nombre>` lo completa, por ruta de pantalla, con campos
* estándar adicionales o de usuario (`U_*`) y no toca ningún otro archivo.
*
* Además de agregar, puede ocultar campos (`hideFields`, `hideListColumns`) y dejarlos de solo lectura
* (`readOnlyFields`: se ven pero no se editan). Un nombre que no exista, o ocultar un campo obligatorio, lanza
* un error al arrancar.
*
* Ejemplo (los campos son de ejemplo):
*
* '/socios/clientes': {