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:
1 parent
4c8c499a72
commit
3d5a81e806
10 files changed
+962
No files matched your search
@@ -39,3 +39,37 @@ Tokens: `--app --shell --shell-ink --shell-quiet --win --panel --line --line-sof
|
|||||||
- `core/menu`: `MenuService` carga `GET /api/user/menu` una vez por sesión; el menú visible sale de `MENU` (`@basic-erp/sap-types`) filtrado por permisos.
|
- `core/menu`: `MenuService` carga `GET /api/user/menu` una vez por sesión; el menú visible sale de `MENU` (`@basic-erp/sap-types`) filtrado por permisos.
|
||||||
- `app.routes.ts` genera una ruta por pantalla del menú; las que aún no existen muestran "no disponible" (`ComingSoonPage`). Para construir una pantalla, se reemplaza su `loadComponent`.
|
- `app.routes.ts` genera una ruta por pantalla del menú; las que aún no existen muestran "no disponible" (`ComingSoonPage`). Para construir una pantalla, se reemplaza su `loadComponent`.
|
||||||
- Un 401 en cualquier llamada cierra la sesión local y lleva al login recordando la ruta (`?returnUrl=`, solo rutas internas).
|
- Un 401 en cualquier llamada cierra la sesión local y lleva al login recordando la ruta (`?returnUrl=`, solo rutas internas).
|
||||||
|
|
||||||
|
## Componentes compartidos (`src/app/shared`)
|
||||||
|
|
||||||
|
### `sbo-value-help` — lista de valores ("elegir de la lista")
|
||||||
|
|
||||||
|
Búsqueda por columna sobre un recurso del proxy `/api/sl`, selección única o múltiple y paginación del servidor.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<sbo-value-help
|
||||||
|
[open]="open()"
|
||||||
|
title="Socios de negocio"
|
||||||
|
resource="BusinessPartners"
|
||||||
|
[columns]="columns"
|
||||||
|
baseFilter="CardType eq 'cCustomer'"
|
||||||
|
orderby="CardCode"
|
||||||
|
selection="single"
|
||||||
|
(selected)="onPick($event)"
|
||||||
|
(closed)="open.set(false)" />
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
columns: VhColumn[] = [
|
||||||
|
{ key: 'CardCode', title: 'Código', width: '130px', mono: true },
|
||||||
|
{ key: 'CardName', title: 'Nombre' },
|
||||||
|
{ key: 'CurrentAccountBalance', title: 'Saldo', type: 'currency' },
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
- Agregar una columna = una entrada en `columns`; el `$select` se deriva de ahí.
|
||||||
|
- Columna activa de búsqueda: clic en el encabezado (solo columnas de texto, o `searchable: true`). Se aplica al pulsar **Buscar**/Enter con `contains()`, combinada con `baseFilter` entre paréntesis y con las comillas escapadas.
|
||||||
|
- `selection="single"`: un clic en la fila confirma y cierra. `selection="multiple"`: casillas, "seleccionar página" y **Confirmar (n)**; lo marcado se conserva al cambiar de página (`[initialSelection]` precarga filas).
|
||||||
|
- `type`: `text` (defecto), `number`, `currency` (2 decimales), `date` (dd/MM/yyyy); `format: (row) => string` para algo a medida.
|
||||||
|
- Teclado: Enter en la búsqueda, Enter sobre una fila, Esc para cerrar.
|
||||||
|
- Cada apertura reinicia búsqueda, página y selección. Las peticiones obsoletas se cancelan solas (`httpResource`).
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { formatDate, formatNumber } from './format';
|
||||||
|
|
||||||
|
describe('formatNumber', () => {
|
||||||
|
it('importes con decimales fijos y miles', () => {
|
||||||
|
expect(formatNumber(1234.5, 2)).toBe('1,234.50');
|
||||||
|
expect(formatNumber('8835750', 2)).toBe('8,835,750.00');
|
||||||
|
});
|
||||||
|
it('cantidades sin ceros sobrantes', () => {
|
||||||
|
expect(formatNumber(12)).toBe('12');
|
||||||
|
expect(formatNumber(0.25)).toBe('0.25');
|
||||||
|
});
|
||||||
|
it('valores vacíos o no numéricos dan cadena vacía', () => {
|
||||||
|
for (const v of [null, undefined, '', 'abc', NaN]) expect(formatNumber(v, 2)).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatDate', () => {
|
||||||
|
it('formatea dd/MM/yyyy sin desfase de zona horaria', () => {
|
||||||
|
expect(formatDate('2026-01-05')).toBe('05/01/2026');
|
||||||
|
expect(formatDate('2026-12-31T23:59:59Z')).toBe('31/12/2026');
|
||||||
|
});
|
||||||
|
it('valores inválidos dan cadena vacía', () => {
|
||||||
|
for (const v of [null, undefined, '', 'ayer', 20260105]) expect(formatDate(v)).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
const fixed = new Map<number, Intl.NumberFormat>();
|
||||||
|
const flexible = new Intl.NumberFormat('es-PE', { maximumFractionDigits: 3 });
|
||||||
|
|
||||||
|
function fixedFormat(decimals: number): Intl.NumberFormat {
|
||||||
|
let f = fixed.get(decimals);
|
||||||
|
if (!f) {
|
||||||
|
f = new Intl.NumberFormat('es-PE', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
||||||
|
fixed.set(decimals, f);
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `decimals` fijos (importes) o, sin él, hasta 3 decimales sin ceros sobrantes (cantidades). */
|
||||||
|
export function formatNumber(value: unknown, decimals?: number): string {
|
||||||
|
const n = typeof value === 'number' ? value : Number(value);
|
||||||
|
if (value === null || value === undefined || value === '' || !Number.isFinite(n)) return '';
|
||||||
|
return (decimals === undefined ? flexible : fixedFormat(decimals)).format(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `2026-01-05` o `2026-01-05T00:00:00Z` -> `05/01/2026`, sin pasar por Date (evita desfases de zona horaria). */
|
||||||
|
export function formatDate(value: unknown): string {
|
||||||
|
const m = typeof value === 'string' ? /^(\d{4})-(\d{2})-(\d{2})/.exec(value) : null;
|
||||||
|
return m ? `${m[3]}/${m[2]}/${m[1]}` : '';
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { combineFilters, containsFilter, escapeODataString } from './odata';
|
||||||
|
|
||||||
|
describe('odata', () => {
|
||||||
|
it('escapa las comillas del texto buscado', () => {
|
||||||
|
expect(escapeODataString("o'neil")).toBe("o''neil");
|
||||||
|
expect(containsFilter('CardName', "x') or ('a'='a")).toBe("contains(CardName,'x'') or (''a''=''a')");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rechaza nombres de campo que no sean identificadores', () => {
|
||||||
|
expect(() => containsFilter('CardName,1)--', 'a')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('combineFilters ignora vacíos y no agrega paréntesis a un solo filtro', () => {
|
||||||
|
expect(combineFilters(undefined, '', ' ')).toBeUndefined();
|
||||||
|
expect(combineFilters("CardType eq 'C'")).toBe("CardType eq 'C'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('combineFilters protege la precedencia de un "or"', () => {
|
||||||
|
expect(combineFilters("A eq 1 or B eq 2", "contains(N,'x')")).toBe("(A eq 1 or B eq 2) and (contains(N,'x'))");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
const IDENTIFIER = /^[A-Za-z0-9_]+$/;
|
||||||
|
|
||||||
|
/** Escapa una cadena para usarla entre comillas simples en un `$filter`. */
|
||||||
|
export const escapeODataString = (text: string): string => text.replace(/'/g, "''");
|
||||||
|
|
||||||
|
function assertIdentifier(field: string): void {
|
||||||
|
if (!IDENTIFIER.test(field)) throw new Error(`Nombre de campo inválido: ${field}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `contains(Campo,'texto')`; el backend añade B1S-CaseInsensitive cuando hay filtro. */
|
||||||
|
export function containsFilter(field: string, text: string): string {
|
||||||
|
assertIdentifier(field);
|
||||||
|
return `contains(${field},'${escapeODataString(text)}')`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Une filtros con `and`; entre paréntesis para que un `or` interno no cambie la precedencia. */
|
||||||
|
export function combineFilters(...parts: (string | undefined)[]): string | undefined {
|
||||||
|
const filters = parts.map((p) => p?.trim()).filter((p): p is string => !!p);
|
||||||
|
if (filters.length <= 1) return filters[0];
|
||||||
|
return filters.map((f) => `(${f})`).join(' and ');
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
Reference in new issue
Block a user