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:
1 parent
3caf94609d
commit
0629ce4068
17 files changed
+517
-11
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user