feat: campos de usuario por su definición en SAP (valores válidos y tabla de usuario enlazada): endpoint sapdb/user-fields y campo udfField en cabeceras de 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-20 15:46:00 -05:00
1 parent 3caf94609d
commit 0629ce4068
17 files changed
+517 -11

No files matched your search

+1
View File
@@ -4,3 +4,4 @@ export { SboField } from './sbo-field';
// Atajos con etiqueta (un control por fila)
export { CheckboxField, DateField, NumberField, SelectField, TextField, TextareaField } from './fields';
export { LookupField } from './lookup-field';
export { UdfField } from './udf-field';
@@ -0,0 +1,89 @@
import { Component, signal } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { type ComponentFixture, TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it } from 'vitest';
import { UdfField } from './udf-field';
@Component({
imports: [UdfField],
template: '<sbo-udf-field label="Campo" [table]="table" [alias]="alias" [value]="value()" (valueChange)="value.set($event)" />',
})
class Host {
table = 'OBTN';
alias = 'SIS_X';
value = signal<unknown>('');
}
const base = { table: 'OBTN', alias: 'SIS_X', description: 'Campo X', size: 10, validValues: [], linkedTable: null, linkedRows: [], linkedTruncated: false };
describe('campo de usuario de SAP', () => {
let fixture: ComponentFixture<Host>;
let ctrl: HttpTestingController;
const el = () => fixture.nativeElement as HTMLElement;
const tick = async () => {
fixture.detectChanges();
await new Promise((r) => setTimeout(r));
fixture.detectChanges();
};
async function open(answer: object, value: unknown = '') {
TestBed.resetTestingModule();
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
ctrl = TestBed.inject(HttpTestingController);
fixture = TestBed.createComponent(Host);
fixture.componentInstance.value.set(value);
await tick();
const req = ctrl.expectOne('/api/sapdb/user-fields/OBTN/SIS_X');
if (answer && typeof answer === 'object' && 'fail' in answer) req.flush({ message: 'x' }, { status: 503, statusText: 'Service Unavailable' });
else req.flush(answer as Record<string, unknown>);
await tick();
}
beforeEach(() => TestBed.resetTestingModule());
it('con valores válidos se pide en una lista desplegable con ellos', async () => {
await open({ ...base, validValues: [{ value: '101', description: 'Cuarentena' }, { value: '102', description: 'Aprobado' }] }, '102');
const select = el().querySelector('select') as HTMLSelectElement;
expect([...select.options].map((o) => o.textContent?.trim())).toEqual(expect.arrayContaining(['101 · Cuarentena', '102 · Aprobado']));
expect(select.value).toBe('102');
select.value = '101';
select.dispatchEvent(new Event('change'));
await tick();
expect(fixture.componentInstance.value()).toBe('101');
});
it('enlazado a una tabla de usuario, muestra su nombre junto al código y abre su lista', async () => {
await open({ ...base, linkedTable: 'SIS_REGSAN', linkedRows: [{ code: '10001', name: 'DM2462E' }, { code: '10002', name: 'OTRO' }] }, '10001');
await tick(); // el nombre se resuelve de las filas ya cargadas
const inputs = [...el().querySelectorAll<HTMLInputElement>('input')].map((i) => i.value);
expect(inputs).toEqual(expect.arrayContaining(['10001', 'DM2462E']));
expect(el().querySelector('button.sbo-btn')).not.toBeNull();
});
it('un campo numérico guarda número, no texto', async () => {
await open({ ...base, type: 'N', validValues: [{ value: '1', description: 'Uno' }, { value: '2', description: 'Dos' }] }, 1);
const select = el().querySelector('select') as HTMLSelectElement;
select.value = '2';
select.dispatchEvent(new Event('change'));
await tick();
expect(fixture.componentInstance.value()).toBe(2);
});
it('sin base de datos configurada o sin el campo, se pide como texto', async () => {
await open({ fail: true }, 'abc');
expect(el().querySelector('select')).toBeNull();
const input = el().querySelector('input') as HTMLInputElement;
expect(input.value).toBe('abc');
input.value = 'xyz';
input.dispatchEvent(new Event('input'));
await tick();
expect(fixture.componentInstance.value()).toBe('xyz');
});
it('un campo sin valores válidos ni tabla enlazada también es texto', async () => {
await open({ ...base });
expect(el().querySelector('select')).toBeNull();
expect(el().querySelector('input')).not.toBeNull();
});
});
+72
View File
@@ -0,0 +1,72 @@
import { ChangeDetectionStrategy, Component, computed, input, model, resource } from '@angular/core';
import { UserFieldsApi, type UserFieldInfo } from '../../core/sl/user-fields';
import type { VhLookup } from '../value-help/value-help.types';
import { inject } from '@angular/core';
import { SelectField, TextField } from './fields';
import { FieldBase } from './field-base';
import { LookupField } from './lookup-field';
/**
* Campo de usuario de SAP (`U_*`) que se pide según como lo definió SAP: lista desplegable si tiene valores válidos,
* ventana de selección si está enlazado a una tabla de usuario, texto en otro caso (o mientras carga, o si la base de datos
* no está configurada). El tipo numérico se guarda como número.
*/
@Component({
selector: 'sbo-udf-field',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [LookupField, SelectField, TextField],
template: `
@if (kind() === 'select') {
<sbo-select-field [label]="label()" [dbName]="dbName()" [required]="required()" [readonly]="readOnly()" [error]="error()" [options]="options()" placeholder="" [value]="str() || null" (valueChange)="emit($event)" />
} @else if (kind() === 'lookup') {
<sbo-lookup-field [label]="label()" [dbName]="dbName()" [required]="required()" [readonly]="readOnly()" [error]="error()" [lookup]="lookup()" [value]="str()" (valueChange)="emit($event)" resolveOnLeave />
} @else {
<sbo-text-field [label]="label()" [dbName]="dbName()" [required]="required()" [readonly]="readOnly()" [error]="error()" [value]="str()" (valueChange)="emit($event)" />
}
`,
styles: ':host { display: block; }',
})
export class UdfField extends FieldBase {
private readonly api = inject(UserFieldsApi);
/** Tabla del campo (`OBTN`, `OWTQ`, `@MI_TABLA`) y alias sin el `U_` (`SIS_ESTLOT`). */
readonly table = input.required<string>();
readonly alias = input.required<string>();
readonly value = model<unknown>('');
protected readonly info = resource({
params: () => ({ table: this.table(), alias: this.alias() }),
loader: ({ params }) => this.api.get(params.table, params.alias),
});
private readonly def = computed<UserFieldInfo | null>(() => (this.info.hasValue() ? this.info.value() : null));
protected readonly kind = computed<'select' | 'lookup' | 'text'>(() => {
const d = this.def();
return d?.validValues.length ? 'select' : d?.linkedRows.length ? 'lookup' : 'text';
});
protected readonly str = computed(() => {
const v = this.value();
return v === null || v === undefined ? '' : String(v);
});
protected readonly options = computed(() => (this.def()?.validValues ?? []).map((v) => ({ value: v.value, label: v.description ? `${v.value} · ${v.description}` : v.value })));
protected readonly lookup = computed<VhLookup>(() => {
const d = this.def()!;
return {
title: d.description || d.linkedTable || 'Valores',
resource: d.linkedTable ?? 'UserTable',
columns: [
{ key: 'code', title: 'Código', width: '140px', mono: true, searchable: true },
{ key: 'name', title: 'Nombre', searchable: true },
],
rows: d.linkedRows.map((r) => ({ code: r.code, name: r.name })),
valueKey: 'code',
nameKey: 'name',
codeWidth: '140px',
};
});
protected emit(v: string | number | null): void {
const s = v === null ? '' : String(v);
this.value.set(this.def()?.type === 'N' && s.trim() !== '' && Number.isFinite(Number(s)) ? Number(s) : s);
}
}