Pular para o conteúdo principal

Pages

Pages são o shell de roteamento dos apps web. Definem path, título, header, ações do topo, modais filhos e o component de body — e geram automaticamente a Route Angular correspondente.

Toda página do app deve estender Page de @hg/ui/page. Componentes @Component com rota própria são exceção, reservados a casos com orquestração programática de modais.

Modelo mental

Uma Page é uma configuração declarativa que o @hg/ui/page transforma num tree de Routes:

  1. A classe estende Page e recebe PageOptions no super(...)
  2. asRoute() retorna uma Route com component: PageContainer + data: { page: this }
  3. PageContainer monta o header (título, descrição, actions) e renderiza page.body.component via NgComponentOutlet
  4. Modais listados em options.modals viram child routes controladas por fragment da URL
  5. options.children anexam sub-rotas (ex: :id para detalhe)

Isso mantém Pages finas — só declaração de metadados e composição de actions. Lógica de domínio fica nos action components (ver 6-actions.md).


PageOptions

interface PageOptions {
path?: string; // segmento de rota
title?: string; // usado no header + title strategy
description?: string; // subtítulo do header
actions?: Action[]; // botões no canto direito do header
showHeader?: boolean; // default true
centered?: boolean; // default false — centraliza body
fillHeight?: boolean; // default false — body ocupa viewport
resolve?: ResolveData; // Angular ResolveFn map
body?: { component: Type; inputs?: {} };
modals?: Array<PageModal | PageModalOptions>;
children?: Route[]; // sub-rotas (detalhe, editor, etc.)
open?: (route: ActivatedRouteSnapshot) => any; // hook rodado em cada navegação
}

Padrão canônico

// cms/web/src/app/pages/sites.ts
import { Page } from '@hg/ui/page';
import { List } from '../domains/site/actions/list/list';
import { Edit } from '../domains/site/actions/edit/edit';

export class Sites extends Page {
constructor() {
super({
path: 'sites',
title: 'Sites',
description: 'Gerencie seus sites',
body: { component: List },
actions: [
{
label: 'Novo Site',
icon: 'plus',
color: 'primary',
button: { variant: 'plain', routerLink: ['/sites'], fragment: 'modal=new' },
},
],
modals: [{ path: 'new', title: 'Novo Site', component: Edit }],
children: [
{
path: ':siteId',
children: [new SiteDetail().asRoute()],
},
],
});
}
}

Registro no router do app:

// app.routes.ts
export const routes: Routes = [
new Sites().asRoute(),
new Catalogs().asRoute(),
// ...
];

Body

body: { component: SearchComponent, inputs?: { foo: 'bar' } }
  • component é sempre um action component (Search, List, View customizado) — não reinvente telas inline
  • inputs opcionais viram props via NgComponentOutlet
  • Se o component exporta um @Input() refreshKey, PageContainer atualiza o valor quando um modal filho fecha sinalizando mudança — útil para forçar refetch em listagens

Header actions

actions no PageOptions recebem Action[] (interface de @hg/ui/action). Na prática sempre são Simple — botões de "Novo", "Exportar", navegação, etc. Exemplos:

actions: [
// abre modal declarado em options.modals via fragment
{
label: 'Novo Site',
icon: 'plus',
color: 'primary',
button: { variant: 'plain', routerLink: ['/sites'], fragment: 'modal=new' },
},
// ação custom com process
{
label: 'Importar',
icon: 'upload',
process: async () => {
/* ... */
},
alert: { success: 'Importado.', error: 'Erro ao importar.' },
},
];

PageHeader renderiza cada action via <hg-action-button>, então confirmações, dropdowns, disabled e can() funcionam normalmente.


Modais (padrão fragment-based)

Pages declaram modais em options.modals. Cada modal vira child route do PageContainer mas só abre quando a URL tem #modal={path}. Parâmetros vão como &key=value no fragment.

Declarando

modals: [
{ path: 'new', title: 'Novo Site', component: Edit },
{
path: 'edit/:id',
title: 'Editar Site',
component: Edit,
inputs: (route) => ({ siteId: route.paramMap.get('id') }),
},
];

PageModalOptions:

interface PageModalOptions {
path: string;
title?: string;
component: Type<any>;
inputs?: Record<string, any> | ((route: ActivatedRouteSnapshot) => Record<string, any>);
resolve?: ResolveData;
open?: (route: ActivatedRouteSnapshot) => any;
}

Abrindo

Via link com fragment:

<a [routerLink]="['.']" fragment="modal=new">Novo</a>

Via action:

{ button: { routerLink: ['.'], fragment: 'modal=edit/123' } }

Programaticamente:

this.router.navigate([], { fragment: 'modal=edit/123', queryParamsHandling: 'preserve' });

Fechando (do component filho)

Injete MODAL_CONTEXT no component do modal e chame onClose(result?):

import { MODAL_CONTEXT } from '@hg/ui/page';

export class Edit {
private modalContext = inject(MODAL_CONTEXT, { optional: true });

close(result?: any) {
this.modalContext?.onClose(result);
}
}

onClose(result):

  • Se result != null, PageContainer incrementa refreshKey no body — listagem refetch automaticamente
  • Se result == null, fecha sem sinalizar mudança
  • Sempre limpa o fragment da URL via router.navigateByUrl(pageUrl)

Por que fragment e não query param?

Fragment (#modal=new) não re-triggera os resolvers da rota pai, então a listagem não refaz fetch toda vez que o modal abre. Query params re-carregariam o PageContainer.


Children routes

options.children anexa sub-rotas ao path da page. Use para telas de detalhe, editores, seções aninhadas:

children: [
{
path: ':siteId',
children: [
new SiteDetail().asRoute(), // /sites/:siteId
{ ...siteDetailRoute, path: 'pages' }, // /sites/:siteId/pages
{
path: 'pages/:pageId/editor',
loadComponent: () => import('./page-editor').then((m) => m.PageEditor),
title: 'Editor',
},
],
},
];

Sub-pages que também estendem Page entram via new OutraPage().asRoute().


Resolve

resolve: { key: ResolveFn } do Angular funciona normalmente. Os dados ficam disponíveis em route.snapshot.data[key] dentro do body e de hooks open.

resolve: {
site: (route) => inject(SiteService).get(route.paramMap.get('id')!),
}

Hook open

Roda toda vez que o PageContainer recebe dados da rota. Use para side effects cross-cutting (SSE subscribe, analytics, breadcrumb).

open: (route) => {
inject(TelemetryService).track('page_view', { path: route.routeConfig?.path });
};

Modais programáticos (ModalService)

Para casos onde o modal não é uma opção de fragment estático — ex: drilldown dinâmico vindo de evento (click num quadro do dashboard abrindo detalhe de queue).

import { ModalService } from '@hg/ui/page';

export class Dashboard {
private modalService = inject(ModalService);

async openQueue(queue: any) {
const { QueueDetail } = await import('../queue-detail/queue-detail');
this.modalService.open(QueueDetail, { queueName: queue.name }, `Fila: ${queue.name}`).subscribe((result) => {
/* handle close */
});
}
}

ModalService.open(component, inputs, title?) retorna Observable que emite quando o modal fecha. Dentro do component, MODAL_CONTEXT funciona igual; onClose(result) dispara emitClose(result).

Quando usar ModalService vs fragment:

  • fragment — fluxo navegável (deep link, back button, refresh mantém modal aberto). É o default; use sempre que possível.
  • ModalService — modal efêmero, não navegável, disparado por ação no próprio componente. Ex: dashboards, drilldowns, wizards internos.

Minimized modals

PageContainer suporta minimizar modais via MinimizedModalService. Quando um modal é minimizado, vira um pill na barra inferior e a URL volta ao path da page. Clicar no pill reabre (re-navega para o fragment).

Isso é transparente para o component do modal — basta o ViewContainer do @hg/ui/action ter modalMinimizable setado. Não há código adicional na Page.


@Component direto (exceção)

Pages que precisam orquestrar muitos modais programáticos (Monitor, editores Puck) podem ser @Component comum em vez de estender Page. Nesses casos:

  • Register a rota manualmente em app.routes.ts com component: SuaPage
  • Não tem header automático — monta com <hg-page-layout> + <hg-page-header> se precisar
  • Declara ModalService e gerencia o ciclo programaticamente

Evite esse caminho para CRUD padrão — só use quando o fluxo realmente foge do padrão declarativo.


PageContainer (interno, referência)

PageContainer é o component que Page.asRoute() amarra via component: PageContainer. Ele:

  • Observa route.data → atribui page e roda page.init(route.snapshot)
  • Observa route.fragment → abre/fecha modais declarados em options.modals
  • Observa ModalService.modal$ → abre/fecha modais programáticos
  • Injeta MODAL_CONTEXT com onClose amarrado ao ciclo correto (fragment vs programático)
  • Rastreia refreshKey para invalidar body quando modal filho fecha com resultado

Você não renderiza PageContainer manualmente — o asRoute() já faz o wiring.


Estrutura de pastas

{app}/web/src/app/pages/
├── sites.ts # class Sites extends Page
├── site-detail.ts # class SiteDetail extends Page
├── blocks.ts
├── page-editor.ts # @Component direto (exceção)
├── page-editor.html
└── app.routes.ts # new Sites().asRoute(), ...

Naming:

  • Arquivo: kebab-case.ts — ex: site-detail.ts
  • Classe: PascalCase matching o arquivo — ex: SiteDetail
  • Sem sufixo Page (já sabemos pelo extends)

Pages ficam em {app}/web/src/app/pages/. Não misture com domains/actions — Page é shell, action é conteúdo.


Checklist antes de implementar

  • Classe extends Page — só usa @Component direto se for caso de orquestração programática de modais
  • body.component é um action component existente (não inline nem reimplementado)
  • Modais de CRUD declarados em options.modals com path, title, component
  • Botão "Novo/Editar" em options.actions usa button.fragment em vez de handler JS
  • Component filho de modal injeta MODAL_CONTEXT e chama onClose(result) (ou onClose() se não há mudança)
  • Sub-rotas (:id, editores, seções) em options.children
  • Rota registrada em app.routes.ts via new Sites().asRoute()
  • Sem lógica de domínio na Page — mantém fina, delega para actions