Saltar al contenido principal
Neural UIv2.0.3Documentación
Ver v1 GitHub

Recetas empresariales

Recetas funcionales de Angular 22: formularios HTTP, Table remota, TreeTable lazy, Dialog controlado y un espacio con temas.

Transporte y entorno de pruebas

Estas recetas ejecutan HttpClient, pero no ofrecen una API empresarial alojada. La documentación instala un interceptor de demostración para /api/recipes/*; en producción debes sustituirlo por un backend autenticado. No se guarda ningún perfil real. Copia la receta y sus dependencias mostradas, configura HttpClient y proporciona los mismos contratos de respuesta. Los tests usan HttpTestingController en lugar del interceptor.

example.ts
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { recipeBackend } from './recipe-backend';

// Demo only. Production: provideHttpClient() with your authentication policy
// and a real backend implementing the response contracts in these recipes.
export const appConfig: ApplicationConfig = {
  providers: [provideHttpClient(withInterceptors([recipeBackend]))],
};

Formulario de perfil con validación HTTP

El nombre es obligatorio y tiene longitud mínima. El correo comprueba el formato y su disponibilidad con validateHttp; prueba taken@example.com. Los campos pendientes o inválidos bloquean el envío. Un POST controla el estado de guardado y deshabilita el formulario hasta terminar. Restablecer limpia valores, touched y dirty; la aplicación puede sustituir el objeto de datos completo sin otra capa de controles.

Formulario de perfil con validación HTTP

Table remota: filtrar, seleccionar y exportar

El padre acepta las propuestas de consulta y selección. Cada dataRequest ejecuta HTTP con la consulta completa y cancela la solicitud anterior. Solo la respuesta más reciente actualiza filas y metadatos, conservando el identificador y la revisión correspondientes. El backend de demostración implementa búsqueda de clientes y paginación; añade ordenación y validación de filtros en servidor para tus columnas. El CSV exporta solo las filas cargadas, no todos los resultados remotos. La selección pertenece al padre entre páginas.

Table remota: filtrar, seleccionar y exportar
Cliente
Importe (€)
Región
No hay pedidos

TreeTable lazy: errores y reintento

Las raíces y sus relaciones de hijos se cargan por separado mediante HTTP. Expande Diseño cuando lleguen las raíces. El backend de demostración falla intencionadamente la primera solicitud de hijos; Reintentar en la fila genera otro dataRequest. El padre aplica relaciones loading, error y loaded con sus revisiones. Las solicitudes de una misma relación se cancelan antes de sustituirse y todas las activas se cancelan al destruirse.

TreeTable lazy: errores y reintento

El backend de demostración falla la primera solicitud de hijos. Usa Reintentar en la fila.

Tarea Horas
No hay tareas

Acepta o rechaza propuestas de cierre de Dialog

El cierre es una propuesta. Escape o el botón de cierre mantienen Dialog abierto mientras existan cambios sin guardar. Guardar limpia dirty y acepta el cierre. Prueba aceptación y rechazo; no supongas que closeRequested modifica open por sí mismo. El foco vuelve al activador tras aceptar el cierre.

Acepta o rechaza propuestas de cierre de Dialog

Navegación, tema y dirección

Este espacio pequeño conserva los ajustes en signals del padre al alternar resumen y configuración. El tema y la dirección son independientes; activar RTL no cambia el idioma seleccionado. El ámbito data-theme limita el cambio visual a la receta, sin modificar la documentación. Usa RouterLink en páginas de aplicación que necesiten una URL propia.

Navegación, tema y dirección

Neural

La navegación conserva los ajustes de la aplicación.

Prueba HTTP, propuestas y cleanup

Los specs completos de Vitest compilan los mismos componentes mostrados arriba. Responden a HTTP con éxito y errores, comprueban validación asíncrona, cancelación, rechazo de propuestas y reintento. Mantén provideHttpClientTesting después de provideHttpClient y llama a verify tras cada test. Las pruebas de foco, descarga e hidratación en navegador complementan estos tests; TestBed solo no certifica SSR.

testing-enterprise.spec.ts
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { By } from '@angular/platform-browser';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { EnterpriseFormExample } from '../foundation-examples/enterprise-form.example';
import { EnterpriseTableExample } from '../foundation-examples/enterprise-table.example';
import { EnterpriseTreeExample } from '../foundation-examples/enterprise-tree.example';
import { EnterpriseDialogExample } from '../foundation-examples/enterprise-dialog.example';
import { EnterpriseShellExample } from '../foundation-examples/enterprise-shell.example';
import { NeuDialogComponent } from '@neural-ui/core/modal';
import { type NeuTableDataRequest } from '@neural-ui/core/table';
import { type OrderField, emptyOrderQuery, ORDERS } from '../examples/table-data';

describe('Enterprise HTTP recipes against the installed public package', () => {
  let http: HttpTestingController;
  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [provideHttpClient(), provideHttpClientTesting()],
    });
    http = TestBed.inject(HttpTestingController);
  });
  afterEach(() => http.verify({ ignoreCancelled: true }));
  it('validates availability, rejects a taken address, posts valid data and resets interaction state', async () => {
    const f = TestBed.createComponent(EnterpriseFormExample);
    f.detectChanges();
    f.componentInstance.data.set({ name: 'Pedro', email: 'taken@example.com', updates: true });
    f.detectChanges();
    await Promise.resolve();
    TestBed.tick();
    http.expectOne('/api/recipes/email?value=taken%40example.com').flush({ available: false });
    await f.whenStable();
    expect(
      f.componentInstance.fields
        .email()
        .errors()
        .some((error) => error.kind === 'taken'),
    ).toBe(true);
    f.componentInstance.save();
    http.expectNone('/api/recipes/profile');
    f.componentInstance.data.update((data) => ({ ...data, email: 'free@example.com' }));
    f.detectChanges();
    await Promise.resolve();
    TestBed.tick();
    http.expectOne('/api/recipes/email?value=free%40example.com').flush({ available: true });
    await f.whenStable();
    f.componentInstance.save();
    expect(f.componentInstance.saving()).toBe(true);
    const post = http.expectOne('/api/recipes/profile');
    expect(post.request.method).toBe('POST');
    expect(post.request.body.email).toBe('free@example.com');
    post.flush({ saved: true });
    await f.whenStable();
    expect(f.componentInstance.saving()).toBe(false);
    f.componentInstance.reset();
    await f.whenStable();
    expect(f.componentInstance.data()).toEqual({ name: '', email: '', updates: false });
    expect(f.componentInstance.fields.email().touched()).toBe(false);
    expect(f.componentInstance.fields.email().dirty()).toBe(false);
  });
  it('cancels replaced Table queries and applies only the current response metadata', async () => {
    const f = TestBed.createComponent(EnterpriseTableExample);
    // No render: explicit requests isolate HTTP from the initial dataRequest.
    const request = {
      requestId: 40,
      queryRevision: 3,
      query: emptyOrderQuery(),
      reason: 'query',
    } as NeuTableDataRequest<OrderField>;
    f.componentInstance.load(request);
    const first = http.expectOne((req) => req.url === '/api/recipes/orders');
    f.componentInstance.load({
      ...request,
      requestId: 41,
      queryRevision: 4,
      query: { ...request.query, search: 'Atlas' },
    });
    expect(first.cancelled).toBe(true);
    const second = http.expectOne((req) => req.url === '/api/recipes/orders');
    expect(JSON.parse(second.request.params.get('query')!).search).toBe('Atlas');
    second.flush({ rows: [ORDERS[0]], total: 1 });
    expect(f.componentInstance.rows()).toEqual([ORDERS[0]]);
    expect(f.componentInstance.metadata()).toMatchObject({
      loading: false,
      queryRevision: 4,
      appliedRequestId: 41,
      totalItems: 1,
    });
    f.componentInstance.load({ ...request, requestId: 42 });
    const pending = http.expectOne((req) => req.url === '/api/recipes/orders');
    f.destroy();
    expect(pending.cancelled).toBe(true);
  });
  it('applies TreeTable child errors, retries the relation and cancels requests on destroy', () => {
    const f = TestBed.createComponent(EnterpriseTreeExample);
    const roots = {
      kind: 'roots',
      requestId: 1,
      queryRevision: 0,
      relationRevision: 0,
    } as Parameters<EnterpriseTreeExample['load']>[0];
    f.componentInstance.load(roots);
    http.expectOne((req) => req.params.get('parent') === '__roots__').flush({ keys: ['design'] });
    const children = {
      ...roots,
      kind: 'children',
      parentKey: 'design',
      requestId: 2,
    } as Parameters<EnterpriseTreeExample['load']>[0];
    f.componentInstance.load(children);
    http
      .expectOne((req) => req.params.get('parent') === 'design')
      .flush('temporary error', { status: 503, statusText: 'Unavailable' });
    expect(
      f.componentInstance.hierarchy().children.find((child) => child.parentKey === 'design')
        ?.relation.status,
    ).toBe('error');
    f.componentInstance.load({ ...children, requestId: 3, relationRevision: 1 });
    http.expectOne((req) => req.params.get('parent') === 'design').flush({ keys: ['research'] });
    expect(
      f.componentInstance.hierarchy().children.find((child) => child.parentKey === 'design')
        ?.relation,
    ).toMatchObject({ status: 'loaded', keys: ['research'], appliedRequestId: 3 });
    f.componentInstance.load({
      ...children,
      kind: 'children',
      parentKey: 'research',
      requestId: 4,
    } as Parameters<EnterpriseTreeExample['load']>[0]);
    const pending = http.expectOne((req) => req.params.get('parent') === 'research');
    f.destroy();
    expect(pending.cancelled).toBe(true);
  });
  it('requests children from the rendered expansion gesture and retries its row error', async () => {
    const f = TestBed.createComponent(EnterpriseTreeExample);
    await f.whenStable();
    http
      .expectOne((req) => req.params.get('parent') === '__roots__')
      .flush({ keys: ['design', 'delivery'] });
    await f.whenStable();
    const expand = f.nativeElement.querySelector(
      'button[aria-expanded="false"]',
    ) as HTMLButtonElement;
    expect(expand).not.toBeNull();
    expand.click();
    await f.whenStable();
    http
      .expectOne((req) => req.params.get('parent') === 'design')
      .flush('error', { status: 503, statusText: 'Unavailable' });
    await f.whenStable();
    const retry = [...f.nativeElement.querySelectorAll('button')].find((button: any) =>
      button.textContent.includes('Retry'),
    ) as HTMLButtonElement;
    expect(retry).toBeDefined();
    retry.click();
    await f.whenStable();
    http.expectOne((req) => req.params.get('parent') === 'design').flush({ keys: ['research'] });
    await f.whenStable();
    expect(f.nativeElement.textContent).toContain('Research');
  });
  it('rejects dirty Dialog close proposals and accepts after saving', async () => {
    const f = TestBed.createComponent(EnterpriseDialogExample);
    await f.whenStable();
    f.componentInstance.open.set(true);
    await f.whenStable();
    const dialog = f.debugElement.query(By.directive(NeuDialogComponent))
      .componentInstance as NeuDialogComponent;
    dialog.requestClose('escape', new KeyboardEvent('keydown', { key: 'Escape' }));
    await f.whenStable();
    expect(f.componentInstance.open()).toBe(true);
    expect(f.componentInstance.rejected()).toBe(true);
    f.componentInstance.dirty.set(false);
    dialog.requestClose('escape', new KeyboardEvent('keydown', { key: 'Escape' }));
    await f.whenStable();
    expect(f.componentInstance.open()).toBe(false);
  });
  it('preserves settings across workspace navigation independently of language and direction', async () => {
    const f = TestBed.createComponent(EnterpriseShellExample);
    await f.whenStable();
    f.componentRef.setInput('language', 'es');
    f.componentInstance.page.set('settings');
    f.componentInstance.name.set('Equipo');
    f.componentInstance.dark.set(true);
    f.componentInstance.rtl.set(true);
    await f.whenStable();
    expect(f.nativeElement.querySelector('.workspace').getAttribute('dir')).toBe('rtl');
    expect(f.nativeElement.querySelector('.workspace').getAttribute('data-theme')).toBe('dark');
    f.componentInstance.page.set('overview');
    await f.whenStable();
    expect(f.nativeElement.querySelector('h3').textContent).toBe('Equipo');
    expect(f.componentInstance.language()).toBe('es');
  });
});