Skip to main content
Neural UIv2.0.3Documentation
View v1 GitHub

Enterprise recipes

Working Angular 22 recipes: HTTP forms, remote Table, lazy TreeTable, controlled Dialog and a themed workspace.

Transport and test setup

These are executable HttpClient recipes, not a hosted business API. The documentation installs a demo-only interceptor for /api/recipes/*; production must replace it with an authenticated backend. No real profile is saved. Copy the recipe and its displayed dependencies, provide HttpClient, and supply the same response contracts. The tests use HttpTestingController instead of the 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]))],
};

Profile form with HTTP validation

Name is required with a minimum length. Email has synchronous format checks and validateHttp availability validation; try taken@example.com. Pending or invalid fields block submission. A POST owns the saving state and disables the form until it settles. Reset clears values, touched and dirty state; the application may replace the whole data object without a second control layer.

Profile form with HTTP validation

Remote Table: filter, select and export

The parent accepts query and selection proposals. Each dataRequest issues HTTP with the entire query and cancels the previous request. Only the latest response updates rows and metadata, preserving the matching request ID and query revision. The demo backend implements customer search and paging; add server-side sorting and filter-expression validation for your own columns. CSV exports only loaded rows, not all remote results. Selection remains parent-owned across pages.

Remote Table: filter, select and export
Customer
Amount (โ‚ฌ)
Region
No orders

Lazy TreeTable: errors and retry

Roots and child relations are loaded separately through HTTP. Expand Design after the roots arrive. The demo backend intentionally fails the first child request; the row Retry action creates another dataRequest. The parent applies loading, error and loaded relations with their revisions. Requests for the same relation are cancelled before replacement, and all active requests are cancelled on destroy.

Lazy TreeTable: errors and retry

The demo backend fails the first child request. Use Retry in the row.

Task Hours
No tasks

Accept or reject Dialog close proposals

Close is a proposal. Escape or the close button leaves the Dialog open while unsaved changes remain. Saving clears the dirty flag and accepts closure. Test both acceptance and rejection; do not assert that a closeRequested event itself changed open. Focus returns to the trigger after accepted closure.

Accept or reject Dialog close proposals

Navigation, theme and direction

This small workspace keeps settings in parent signals while switching between overview and settings. Theme and layout direction are independent; switching RTL never changes the selected language. The data-theme scope limits the visual change to the recipe instead of mutating the documentation shell. Use RouterLink for URL-addressable application pages.

Navigation, theme and direction

Neural

Navigation keeps the application-owned settings.

Test HTTP, proposals and cleanup

The complete Vitest specs below compile the same components shown above. They flush HTTP success and errors, verify async validation, cancellation, parent proposal rejection and retry state. Keep provideHttpClientTesting after provideHttpClient and call verify after each test. Browser focus, download and hydration checks complement these unit tests; TestBed alone does not certify 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');
  });
});