feat(web): value-help reutilizable (lista de valores) con selección única o múltiple

- sbo-value-help: búsqueda por columna con contains(), columnas declarativas
  (texto, número, importe, fecha, format a medida), paginación del servidor
  con total, baseFilter/orderby, Esc/Enter, errores con reintento.
- Selección única (un clic confirma) o múltiple (casillas, seleccionar
  página, Confirmar (n)). Mejoras sobre el sistema anterior: lo marcado se
  conserva entre páginas, el filtro base va entre paréntesis (un "or" ya no
  rompe la precedencia) y las comillas del texto buscado se escapan.
- Cambiar la columna elegida no consulta hasta pulsar Buscar.
- Peticiones con httpResource: las obsoletas se cancelan solas.
- Helpers reutilizables: core/sl/odata (escape, contains, combinar filtros)
  y core/format (importes y fechas sin desfase de zona horaria).
- Verificado en Chrome contra SAP real (253 clientes, 1209 artículos):
  búsqueda por Nombre, paginación, selección múltiple entre páginas y
  tema oscuro.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-13 19:26:27 -05:00
1 parent 4c8c499a72
commit 3d5a81e806
10 files changed
+962

No files matched your search

@@ -0,0 +1,188 @@
.overlay {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: var(--overlay);
}
.dlg {
display: flex;
flex-direction: column;
width: min(880px, 100%);
max-height: 85vh;
min-height: 360px;
background: var(--win);
border: 1px solid var(--line);
box-shadow: var(--shadow-pop);
}
header {
display: flex;
align-items: baseline;
gap: 10px;
padding: 8px 12px;
background: var(--panel);
border-bottom: 1px solid var(--line);
}
h2 {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.mode,
.count {
font-size: 12px;
color: var(--quiet);
}
.count {
margin-left: auto;
}
.search {
display: flex;
gap: 6px;
padding: 8px 12px;
border-bottom: 1px solid var(--line-soft);
}
.in {
flex: 1;
min-width: 0;
height: 26px;
padding: 1px 8px;
border: 1px solid var(--field-b);
border-radius: 2px;
background: var(--field);
}
.in:focus {
border-color: var(--focus);
outline: none;
box-shadow: 0 0 0 1px var(--focus);
}
.btn {
height: 26px;
min-width: 84px;
padding: 0 14px;
background: var(--btn);
border: 1px solid var(--btn-b);
border-radius: 3px;
cursor: pointer;
}
.btn:hover:not(:disabled) {
background: var(--btn-h);
}
.btn:disabled {
opacity: 0.45;
cursor: default;
}
.btn.main {
font-weight: 600;
border-color: var(--focus);
color: var(--focus);
}
.error {
margin: 8px 12px 0;
padding: 6px 10px;
border: 1px solid var(--err);
background: var(--err-bg);
color: var(--err);
font-size: 12px;
}
.link {
margin-left: 8px;
background: none;
border: none;
color: inherit;
text-decoration: underline;
cursor: pointer;
}
.tablewrap {
flex: 1;
min-height: 0;
overflow: auto;
margin: 8px 12px;
border: 1px solid var(--line);
background: var(--panel);
}
.tablewrap.busy {
opacity: 0.6;
}
.grid {
width: 100%;
border-collapse: collapse;
}
th {
position: sticky;
top: 0;
padding: 4px 8px;
background: var(--grid-head);
border-right: 1px solid var(--line);
border-bottom: 1px solid var(--line);
font-size: 12.5px;
font-weight: 600;
text-align: left;
white-space: nowrap;
}
th.pick {
cursor: pointer;
}
th.pick:hover {
background: var(--btn-h);
}
th.active {
box-shadow: inset 0 -2px 0 var(--focus);
}
td {
padding: 3px 8px;
border-right: 1px solid var(--line-soft);
border-bottom: 1px solid var(--line-soft);
white-space: nowrap;
}
tbody tr {
cursor: pointer;
}
tbody tr:nth-child(even) td {
background: var(--row-alt);
}
tbody tr:hover td,
tbody tr:focus td {
background: var(--row-sel);
}
tbody tr:focus {
outline: 1px solid var(--focus);
outline-offset: -1px;
}
tbody tr.sel td {
background: var(--row-sel);
}
tr.none td {
padding: 24px;
text-align: center;
color: var(--quiet);
cursor: default;
}
.mono {
font-family: ui-monospace, Menlo, Consolas, monospace;
}
.chk {
width: 30px;
text-align: center;
}
footer {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-top: 1px solid var(--line);
background: var(--panel);
}
.pager {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--quiet);
}
.sp {
flex: 1;
}
@@ -0,0 +1,107 @@
@if (open()) {
<div class="overlay">
<div class="dlg" role="dialog" aria-modal="true" aria-labelledby="vh-title" (keydown.escape)="close()">
<header>
<h2 id="vh-title">{{ title() }}</h2>
<span class="mode">{{ multiple() ? 'Selección múltiple' : 'Selección única' }}</span>
@if (multiple()) {
<span class="count">Seleccionados: {{ chosen().size }}</span>
}
</header>
<form class="search" (submit)="$event.preventDefault(); search()">
<input
#searchBox
class="in"
type="search"
name="q"
autocomplete="off"
[attr.aria-label]="'Buscar en ' + (activeColumn()?.title ?? '')"
[placeholder]="'Buscar en ' + (activeColumn()?.title ?? '')"
[value]="searchText()"
(input)="searchText.set($any($event.target).value)"
/>
<button class="btn main" type="submit">Buscar</button>
</form>
@if (errorMessage()) {
<div class="error" role="alert">
{{ errorMessage() }} <button class="link" type="button" (click)="retry()">Reintentar</button>
</div>
}
<div class="tablewrap" [class.busy]="loading()">
<table class="grid">
<thead>
<tr>
@if (multiple()) {
<th class="chk">
<input
type="checkbox"
aria-label="Seleccionar todas las filas de la página"
[checked]="allPageChosen()"
(change)="togglePage($any($event.target).checked)"
/>
</th>
}
@for (c of columns(); track c.key) {
<th
[style.width]="c.width"
[style.text-align]="align(c)"
[class.pick]="searchable(c)"
[class.active]="c.key === activeColumn()?.key"
[attr.title]="searchable(c) ? 'Buscar en esta columna' : null"
(click)="pickColumn(c)"
>
{{ c.title }}
</th>
}
</tr>
</thead>
<tbody>
@for (row of rows(); track idOf(row)) {
<tr tabindex="0" [class.sel]="isChosen(row)" (click)="rowActivated(row)" (keydown.enter)="rowActivated(row)">
@if (multiple()) {
<td class="chk">
<input
type="checkbox"
tabindex="-1"
[attr.aria-label]="'Seleccionar ' + idOf(row)"
[checked]="isChosen(row)"
(click)="$event.stopPropagation()"
(change)="toggle(row)"
/>
</td>
}
@for (c of columns(); track c.key) {
<td [style.text-align]="align(c)" [class.mono]="c.mono">{{ cell(row, c) }}</td>
}
</tr>
} @empty {
<tr class="none">
<td [attr.colspan]="columns().length + (multiple() ? 1 : 0)">
{{ loading() ? 'Cargando…' : errorMessage() ? '' : 'Sin resultados' }}
</td>
</tr>
}
</tbody>
</table>
</div>
<footer>
<div class="pager">
<button class="btn" type="button" [disabled]="!hasPrevious() || loading()" (click)="previous()">‹ Anterior</button>
<span>{{ rangeText() }}</span>
<button class="btn" type="button" [disabled]="!hasNext() || loading()" (click)="next()">Siguiente ›</button>
</div>
<span class="sp"></span>
<button class="btn" type="button" (click)="close()">Cancelar</button>
@if (multiple()) {
<button class="btn main" type="button" [disabled]="chosen().size === 0" (click)="confirm()">
Confirmar ({{ chosen().size }})
</button>
}
</footer>
</div>
</div>
}
@@ -0,0 +1,311 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, type TestRequest, provideHttpClientTesting } from '@angular/common/http/testing';
import { type ComponentFixture, TestBed } from '@angular/core/testing';
import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest';
import { ValueHelp } from './value-help';
import type { VhColumn, VhRow } from './value-help.types';
const COLUMNS: VhColumn[] = [
{ key: 'CardCode', title: 'Código' },
{ key: 'CardName', title: 'Nombre' },
{ key: 'Balance', title: 'Saldo', type: 'currency' },
{ key: 'CreateDate', title: 'Creado', type: 'date' },
];
const row = (n: number): VhRow => ({
CardCode: `C${n}`,
CardName: `Cliente ${n}`,
Balance: 1000 * n,
CreateDate: '2026-03-05T00:00:00Z',
});
const URL = '/api/sl/BusinessPartners';
describe('ValueHelp', () => {
let fixture: ComponentFixture<ValueHelp>;
let ctrl: HttpTestingController;
let el: HTMLElement;
let selected: Mock<(rows: VhRow[]) => void>;
let closed: Mock<() => void>;
// No se usa whenStable(): con una petición HTTP pendiente (aún sin responder) nunca termina.
const flush = async () => {
fixture.detectChanges();
await new Promise((resolve) => setTimeout(resolve));
fixture.detectChanges();
};
const request = (): TestRequest => ctrl.expectOne((r) => r.url === URL);
const reply = async (req: TestRequest, rows: VhRow[], count = rows.length) => {
req.flush({ value: rows, 'odata.count': String(count) });
await flush();
};
const open = async (rows: VhRow[] = [row(1), row(2), row(3)], count = rows.length) => {
fixture.componentRef.setInput('open', true);
await flush();
const req = request();
await reply(req, rows, count);
return req;
};
const q = <T extends HTMLElement>(selector: string) => el.querySelector<T>(selector)!;
const qa = (selector: string) => [...el.querySelectorAll<HTMLElement>(selector)];
const bodyRows = () => qa('tbody tr:not(.none)');
const type = async (value: string) => {
const input = q<HTMLInputElement>('input[name=q]');
input.value = value;
input.dispatchEvent(new Event('input'));
await flush();
};
const submitSearch = async () => {
q('form.search').dispatchEvent(new Event('submit', { cancelable: true }));
await flush();
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ValueHelp],
providers: [provideHttpClient(), provideHttpClientTesting()],
}).compileComponents();
ctrl = TestBed.inject(HttpTestingController);
fixture = TestBed.createComponent(ValueHelp);
el = fixture.nativeElement;
fixture.componentRef.setInput('open', false);
fixture.componentRef.setInput('title', 'Socios de negocio');
fixture.componentRef.setInput('resource', 'BusinessPartners');
fixture.componentRef.setInput('columns', COLUMNS);
selected = vi.fn<(rows: VhRow[]) => void>();
closed = vi.fn<() => void>();
fixture.componentInstance.selected.subscribe(selected);
fixture.componentInstance.closed.subscribe(closed);
});
it('cerrado no muestra nada ni consulta', async () => {
await flush();
expect(q('.dlg')).toBeNull();
ctrl.expectNone(URL);
});
it('al abrir consulta la primera página con $select, $top y $skip', async () => {
fixture.componentRef.setInput('orderby', 'CardCode');
const req = await open();
expect(req.request.params.get('$select')).toBe('CardCode,CardName,Balance,CreateDate');
expect(req.request.params.get('$top')).toBe('20');
expect(req.request.params.get('$skip')).toBe('0');
expect(req.request.params.get('$orderby')).toBe('CardCode');
expect(req.request.params.has('$filter')).toBe(false);
expect(bodyRows()).toHaveLength(3);
});
it('agrega la clave a $select aunque no sea una columna', async () => {
fixture.componentRef.setInput('columns', [{ key: 'CardName', title: 'Nombre' }]);
fixture.componentRef.setInput('rowKey', 'CardCode');
fixture.componentRef.setInput('open', true);
await flush();
expect(request().request.params.get('$select')).toBe('CardName,CardCode');
});
it('formatea importes, fechas y texto', async () => {
await open([row(2)]);
const cells = [...bodyRows()[0].querySelectorAll('td')].map((c) => c.textContent?.trim());
expect(cells).toEqual(['C2', 'Cliente 2', '2,000.00', '05/03/2026']);
});
describe('búsqueda', () => {
it('busca en la primera columna de texto con contains y vuelve a la página 1', async () => {
await open([row(1)], 45);
q('.pager button:last-child').click();
await flush();
await reply(request(), [row(21)], 45);
await type('ACME');
await submitSearch();
const req = request();
expect(req.request.params.get('$filter')).toBe("contains(CardCode,'ACME')");
expect(req.request.params.get('$skip')).toBe('0');
await reply(req, [row(1)]);
});
it('la columna activa se elige con un clic en el encabezado', async () => {
await open();
qa('th.pick')[1].click();
await flush();
expect(q<HTMLInputElement>('input[name=q]').placeholder).toBe('Buscar en Nombre');
await type('perez');
await submitSearch();
const req = request();
expect(req.request.params.get('$filter')).toBe("contains(CardName,'perez')");
await reply(req, []);
});
it('las columnas numéricas y de fecha no se pueden elegir para buscar', async () => {
await open();
expect(qa('th.pick').map((t) => t.textContent?.trim())).toEqual(['Código', 'Nombre']);
qa('th')[2].click();
await flush();
expect(q<HTMLInputElement>('input[name=q]').placeholder).toBe('Buscar en Código');
});
it('escapa las comillas y protege la precedencia del filtro base', async () => {
fixture.componentRef.setInput('baseFilter', "CardType eq 'cCustomer' or CardType eq 'cLid'");
fixture.componentRef.setInput('open', true);
await flush();
await reply(request(), []);
await type("o'neil");
await submitSearch();
const req = request();
expect(req.request.params.get('$filter')).toBe(
"(CardType eq 'cCustomer' or CardType eq 'cLid') and (contains(CardCode,'o''neil'))",
);
await reply(req, []);
});
it('sin resultados muestra el aviso', async () => {
await open([]);
expect(q('tr.none').textContent).toContain('Sin resultados');
});
});
describe('paginación', () => {
it('muestra el rango y avanza/retrocede con skip', async () => {
await open(Array.from({ length: 20 }, (_, i) => row(i + 1)), 45);
expect(q('.pager span').textContent).toBe('1–20 de 45');
expect(q<HTMLButtonElement>('.pager button:first-child').disabled).toBe(true);
q('.pager button:last-child').click();
await flush();
const next = request();
expect(next.request.params.get('$skip')).toBe('20');
await reply(next, Array.from({ length: 20 }, (_, i) => row(i + 21)), 45);
expect(q('.pager span').textContent).toBe('21–40 de 45');
q('.pager button:first-child').click();
await flush();
expect(request().request.params.get('$skip')).toBe('0');
});
it('en la última página no hay "Siguiente"', async () => {
await open([row(1), row(2)], 2);
expect(q<HTMLButtonElement>('.pager button:last-child').disabled).toBe(true);
});
});
describe('selección única', () => {
it('un clic en la fila confirma y cierra', async () => {
await open();
bodyRows()[1].click();
expect(selected).toHaveBeenCalledWith([row(2)]);
expect(closed).toHaveBeenCalledTimes(1);
});
it('Enter sobre la fila también elige', async () => {
await open();
bodyRows()[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
expect(selected).toHaveBeenCalledWith([row(1)]);
});
it('no muestra casillas ni "Confirmar"', async () => {
await open();
expect(qa('input[type=checkbox]')).toHaveLength(0);
expect(el.textContent).not.toContain('Confirmar');
});
});
describe('selección múltiple', () => {
beforeEach(() => fixture.componentRef.setInput('selection', 'multiple'));
it('marca filas, cuenta y confirma', async () => {
await open();
bodyRows()[0].click();
bodyRows()[2].click();
await flush();
expect(q('.count').textContent).toContain('Seleccionados: 2');
const confirm = qa('footer .btn.main')[0] as HTMLButtonElement;
expect(confirm.textContent).toContain('Confirmar (2)');
confirm.click();
expect(selected).toHaveBeenCalledWith([row(1), row(3)]);
expect(closed).toHaveBeenCalledTimes(1);
});
it('"Confirmar" está deshabilitado sin selección', async () => {
await open();
expect((qa('footer .btn.main')[0] as HTMLButtonElement).disabled).toBe(true);
});
it('conserva lo marcado al cambiar de página (el sistema anterior lo perdía)', async () => {
await open(Array.from({ length: 20 }, (_, i) => row(i + 1)), 40);
bodyRows()[0].click(); // C1 en la página 1
await flush();
q('.pager button:last-child').click();
await flush();
await reply(request(), Array.from({ length: 20 }, (_, i) => row(i + 21)), 40);
bodyRows()[0].click(); // C21 en la página 2
await flush();
(qa('footer .btn.main')[0] as HTMLButtonElement).click();
expect(selected).toHaveBeenCalledWith([row(1), row(21)]);
});
it('la casilla del encabezado marca y desmarca la página', async () => {
await open();
const header = q<HTMLInputElement>('thead input[type=checkbox]');
header.checked = true;
header.dispatchEvent(new Event('change'));
await flush();
expect(q('.count').textContent).toContain('Seleccionados: 3');
header.checked = false;
header.dispatchEvent(new Event('change'));
await flush();
expect(q('.count').textContent).toContain('Seleccionados: 0');
});
it('arranca con la selección inicial y permite desmarcarla', async () => {
fixture.componentRef.setInput('initialSelection', [row(2)]);
await open();
expect(q('.count').textContent).toContain('Seleccionados: 1');
expect(bodyRows()[1].classList.contains('sel')).toBe(true);
bodyRows()[1].click();
await flush();
expect(q('.count').textContent).toContain('Seleccionados: 0');
});
it('al reabrir se reinicia la búsqueda y la selección', async () => {
await open();
bodyRows()[0].click();
await type('algo');
fixture.componentRef.setInput('open', false);
await flush();
fixture.componentRef.setInput('open', true);
await flush();
const req = request();
expect(req.request.params.has('$filter')).toBe(false);
await reply(req, [row(1)]);
expect(q('.count').textContent).toContain('Seleccionados: 0');
expect(q<HTMLInputElement>('input[name=q]').value).toBe('');
});
});
it('Escape cierra', async () => {
await open();
q('.dlg').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(closed).toHaveBeenCalledTimes(1);
});
it('el botón Cancelar cierra sin elegir', async () => {
await open();
(qa('footer .btn').find((b) => b.textContent?.trim() === 'Cancelar') as HTMLButtonElement).click();
expect(closed).toHaveBeenCalledTimes(1);
expect(selected).not.toHaveBeenCalled();
});
it('muestra el error del backend y permite reintentar', async () => {
fixture.componentRef.setInput('open', true);
await flush();
request().flush({ message: 'Recurso no disponible: Foo' }, { status: 404, statusText: 'Not Found' });
await flush();
expect(q('.error').textContent).toContain('Recurso no disponible: Foo');
(q('.error .link') as HTMLButtonElement).click();
await flush();
await reply(request(), [row(1)]);
expect(el.querySelector('.error')).toBeNull();
expect(bodyRows()).toHaveLength(1);
});
});
@@ -0,0 +1,210 @@
import { httpResource } from '@angular/common/http';
import {
ChangeDetectionStrategy,
Component,
type ElementRef,
computed,
effect,
input,
output,
signal,
untracked,
viewChild,
} from '@angular/core';
import { type SlListResponse, slTotal } from '@basic-erp/sap-types';
import { API_URL, apiErrorMessage } from '../../core/api';
import { formatDate, formatNumber } from '../../core/format';
import { combineFilters, containsFilter } from '../../core/sl/odata';
import { type VhColumn, type VhRow, isSearchable } from './value-help.types';
/**
* Lista de valores ("elegir de la lista" de SBO): búsqueda por columna sobre un
* recurso del proxy `/api/sl`, con selección única o múltiple y paginación del servidor.
*
* El padre controla `open` y reacciona a `selected` / `closed`.
*/
@Component({
selector: 'sbo-value-help',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './value-help.html',
styleUrl: './value-help.css',
})
export class ValueHelp {
readonly open = input.required<boolean>();
readonly title = input.required<string>();
/** Recurso registrado en el proxy, ej. `BusinessPartners`. */
readonly resource = input.required<string>();
readonly columns = input.required<readonly VhColumn[]>();
readonly selection = input<'single' | 'multiple'>('single');
/** Filtro fijo (ej. `CardType eq 'cCustomer'`) que se combina con la búsqueda. */
readonly baseFilter = input<string>();
readonly orderby = input<string>();
/** Campo único de cada fila; por defecto, el de la primera columna. */
readonly rowKey = input<string>();
readonly pageSize = input(20);
/** Filas ya elegidas al abrir (solo selección múltiple). */
readonly initialSelection = input<readonly VhRow[]>([]);
readonly selected = output<VhRow[]>();
readonly closed = output<void>();
protected readonly searchText = signal('');
/** Búsqueda en vigor; cambiar la columna elegida no consulta hasta pulsar "Buscar". */
private readonly applied = signal<{ column: string; text: string } | null>(null);
protected readonly searchColumn = signal('');
private readonly skip = signal(0);
/** Las marcadas se guardan completas para no perderlas al cambiar de página. */
protected readonly chosen = signal<ReadonlyMap<string, VhRow>>(new Map());
private readonly searchBox = viewChild<ElementRef<HTMLInputElement>>('searchBox');
protected readonly multiple = computed(() => this.selection() === 'multiple');
protected readonly keyField = computed(() => this.rowKey() ?? this.columns()[0]?.key ?? '');
protected readonly activeColumn = computed(
() =>
this.columns().find((c) => c.key === this.searchColumn()) ?? this.columns().find((c) => isSearchable(c)),
);
private readonly list = httpResource<SlListResponse<VhRow>>(() => {
if (!this.open()) return undefined;
const fields = new Set(this.columns().map((c) => c.key));
fields.add(this.keyField());
const applied = this.applied();
return {
url: `${API_URL}/sl/${this.resource()}`,
params: {
$select: [...fields].join(','),
$top: this.pageSize(),
$skip: this.skip(),
...this.optional('$orderby', this.orderby()),
...this.optional('$filter', combineFilters(this.baseFilter(), applied ? containsFilter(applied.column, applied.text) : undefined)),
},
};
});
protected readonly rows = computed(() => (this.list.hasValue() ? this.list.value().value : []));
protected readonly total = computed(() => (this.list.hasValue() ? slTotal(this.list.value()) : 0));
protected readonly loading = computed(() => this.list.isLoading());
protected readonly errorMessage = computed(() => (this.list.error() ? apiErrorMessage(this.list.error()) : ''));
protected readonly rangeText = computed(() => {
const total = this.total();
if (total === 0) return '0 registros';
const from = this.skip() + 1;
return `${from}–${Math.min(this.skip() + this.rows().length, total)} de ${total}`;
});
protected readonly hasPrevious = computed(() => this.skip() > 0);
protected readonly hasNext = computed(() => this.skip() + this.pageSize() < this.total());
protected readonly allPageChosen = computed(
() => this.rows().length > 0 && this.rows().every((r) => this.chosen().has(this.idOf(r))),
);
constructor() {
effect(() => {
if (!this.open()) return;
untracked(() => {
this.searchText.set('');
this.applied.set(null);
this.skip.set(0);
this.searchColumn.set(this.columns().find((c) => isSearchable(c))?.key ?? '');
this.chosen.set(new Map(this.initialSelection().map((r) => [this.idOf(r), r])));
});
queueMicrotask(() => this.searchBox()?.nativeElement.focus());
});
}
protected idOf(row: VhRow): string {
return String(row[this.keyField()] ?? '');
}
protected isChosen(row: VhRow): boolean {
return this.chosen().has(this.idOf(row));
}
protected cell(row: VhRow, column: VhColumn): string {
if (column.format) return column.format(row);
const value = row[column.key];
switch (column.type) {
case 'number':
return formatNumber(value, column.decimals);
case 'currency':
return formatNumber(value, column.decimals ?? 2);
case 'date':
return formatDate(value);
default:
return value === null || value === undefined ? '' : String(value);
}
}
protected align(column: VhColumn): string {
return column.align ?? (column.type === 'number' || column.type === 'currency' ? 'right' : 'left');
}
protected searchable(column: VhColumn): boolean {
return isSearchable(column);
}
protected pickColumn(column: VhColumn): void {
if (isSearchable(column)) this.searchColumn.set(column.key);
}
protected search(): void {
const text = this.searchText().trim();
const column = this.activeColumn();
this.skip.set(0);
this.applied.set(text && column ? { column: column.key, text } : null);
}
protected previous(): void {
this.skip.update((s) => Math.max(0, s - this.pageSize()));
}
protected next(): void {
if (this.hasNext()) this.skip.update((s) => s + this.pageSize());
}
protected retry(): void {
this.list.reload();
}
protected rowActivated(row: VhRow): void {
if (this.multiple()) {
this.toggle(row);
} else {
this.selected.emit([row]);
this.closed.emit();
}
}
protected toggle(row: VhRow): void {
this.chosen.update((current) => {
const next = new Map(current);
const id = this.idOf(row);
if (!next.delete(id)) next.set(id, row);
return next;
});
}
protected togglePage(checked: boolean): void {
this.chosen.update((current) => {
const next = new Map(current);
for (const row of this.rows()) {
if (checked) next.set(this.idOf(row), row);
else next.delete(this.idOf(row));
}
return next;
});
}
protected confirm(): void {
this.selected.emit([...this.chosen().values()]);
this.closed.emit();
}
protected close(): void {
this.closed.emit();
}
private optional(name: string, value: string | undefined): Record<string, string> {
return value ? { [name]: value } : {};
}
}
@@ -0,0 +1,19 @@
export type VhRow = Record<string, unknown>;
export interface VhColumn {
/** Campo del Service Layer; también se usa en `$select` y, si es buscable, en `contains()`. */
key: string;
title: string;
width?: string;
type?: 'text' | 'number' | 'currency' | 'date';
/** Decimales fijos para `number` y `currency` (currency usa 2 por defecto). */
decimals?: number;
align?: 'left' | 'center' | 'right';
mono?: boolean;
/** Se puede elegir para buscar. Por defecto, solo las columnas de texto. */
searchable?: boolean;
/** Texto a mostrar en lugar del valor del campo. */
format?: (row: VhRow) => string;
}
export const isSearchable = (c: VhColumn): boolean => c.searchable ?? (c.type ?? 'text') === 'text';