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:
- A classe estende
Pagee recebePageOptionsnosuper(...) asRoute()retorna umaRoutecomcomponent: PageContainer+data: { page: this }PageContainermonta o header (título, descrição, actions) e renderizapage.body.componentviaNgComponentOutlet- Modais listados em
options.modalsviram child routes controladas porfragmentda URL options.childrenanexam sub-rotas (ex::idpara 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,Viewcustomizado) — não reinvente telas inlineinputsopcionais viram props viaNgComponentOutlet- Se o component exporta um
@Input() refreshKey,PageContaineratualiza 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,PageContainerincrementarefreshKeyno 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.tscomcomponent: SuaPage - Não tem header automático — monta com
<hg-page-layout>+<hg-page-header>se precisar - Declara
ModalServicee 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→ atribuipagee rodapage.init(route.snapshot) - Observa
route.fragment→ abre/fecha modais declarados emoptions.modals - Observa
ModalService.modal$→ abre/fecha modais programáticos - Injeta
MODAL_CONTEXTcomonCloseamarrado ao ciclo correto (fragment vs programático) - Rastreia
refreshKeypara 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:
PascalCasematching 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@Componentdireto 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.modalscompath,title,component - Botão "Novo/Editar" em
options.actionsusabutton.fragmentem vez de handler JS - Component filho de modal injeta
MODAL_CONTEXTe chamaonClose(result)(ouonClose()se não há mudança) - Sub-rotas (
:id, editores, seções) emoptions.children - Rota registrada em
app.routes.tsvianew Sites().asRoute() - Sem lógica de domínio na Page — mantém fina, delega para actions