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
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
Table remota: filtrar, seleccionar y exportar
TreeTable lazy: errores y reintento
El backend de demostración falla la primera solicitud de hijos. Usa Reintentar en la fila.
Acepta o rechaza propuestas de cierre de Dialog
Navegación, tema y dirección
Neural
La navegación conserva los ajustes de la aplicación.
Prueba HTTP, propuestas y cleanup
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');
});
});