Actions
Actions são a unidade fundamental de UI dos apps web do Hypergestor. Toda interação com um domínio (listar, filtrar, abrir, editar, excluir, ativar) é implementada como uma Action.
Esta doc é de leitura obrigatória antes de criar ou alterar qualquer action. O padrão é opinado e qualquer desvio tende a gerar retrabalho.
Anatomia
Uma Action tem dois lados:
- Classe de estado (vem de
@hg/ui/action):Form,Search,View,Simple. Encapsula validação, fetch, paginação, processamento, tratamento de erro — tudo que não é renderização. - Component Angular (no app web, em
domains/{domain}/actions/{type}/): monta a classe de estado, liga ao template, delega submit/load ao método canônico da classe. Nunca chama API diretamente via.subscribe().
O template usa um Container correspondente:
| Classe | Container | Seletor | Uso |
|---|---|---|---|
Form | FormContainer | hg-action-form | create / edit / filtros |
Search | SearchContainer | hg-action-search | listagem com filtros/sort/paginação |
View | ViewContainer | hg-action-view | detalhe / visualização com loading state |
Simple | ActionButton / ActionButtonGroup | hg-action-button | botão de ação (item action, batch action, navegação com confirmação) |
Estrutura de pastas
{app}/web/src/app/domains/{domain}/
├── {domain}-service.ts # extends DomainService<T>, factory de actions
├── repositories/ # opcional — HTTP + transformações
└── actions/
├── search/
│ ├── search.ts # @Component class Search
│ └── search.html
├── view/
│ ├── view.ts
│ └── view.html
├── edit/
│ ├── edit.ts
│ └── edit.html
└── list/ # opcional — alternativa a search quando não há filtros
├── list.ts
└── list.html
Naming obrigatório:
- Pasta = nome do arquivo = nome do template (ex:
edit/edit.ts+edit/edit.html) - Classe:
Edit,Search,View,List(sem sufixo) - Seletor:
app-domains-{domain}-action-{type}(ex:app-domains-catalog-action-edit) - Host:
host: { class: 'contents' }— nunca deixa wrapper DOM - Sub-entidade: pasta intermediária dentro do domínio pai. Ex:
domains/run/actions/item/search/search.ts→ seletorapp-domains-run-action-item-search - Auxiliares não-action ficam em
domains/{domain}/partials/
Form Action
Usar quando há submit: criar, editar, filtros, confirmações com campos.
Regra de ouro
Submit sempre passa por form.process(). Esse método centraliza validação, estado isProcessing, disable do grupo durante request, toast de sucesso/erro, form.error, e sincronização com FormContainer. Pular isso quebra loading do botão, exibição de erros por campo e feedback ao usuário.
Padrão canônico
// adm/web/src/app/domains/catalog/actions/edit/edit.ts
import { ChangeDetectionStrategy, Component, inject, input, OnInit } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import { Form, FormBuilder, FormContainer } from '@hg/ui/action';
import { FormField } from '@hg/ui/form';
import { MODAL_CONTEXT } from '@hg/ui/page';
@Component({
selector: 'app-domains-catalog-action-edit',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReactiveFormsModule, FormContainer, FormField],
templateUrl: './edit.html',
host: { class: 'contents' },
})
export class Edit implements OnInit {
catalog = input<Catalog | null>(null);
form!: Form;
private service = inject(CatalogService);
private fb = inject(FormBuilder);
private modalContext = inject(MODAL_CONTEXT, { optional: true });
ngOnInit() {
const existing = this.catalog();
this.form = this.fb.build({
process: async (_f, values) => {
const data = values as Partial<Catalog>;
return existing
? firstValueFrom(this.service.update(existing.id, data))
: firstValueFrom(this.service.create(data));
},
alert: {
success: existing ? 'Modelo atualizado.' : 'Modelo criado.',
error: existing ? 'Erro ao atualizar.' : 'Erro ao criar.',
},
});
this.form.title = existing ? 'Editar Modelo' : 'Novo Modelo';
this.form.addField(
this.fb.buildField({
name: 'name',
type: 'text',
label: 'Nome',
required: true,
value: existing?.name ?? null,
}),
);
// ...outros fields
this.form.isLoading = false;
}
close(result?: any) {
this.modalContext?.onClose(result);
}
async save() {
const success = await this.form.process();
if (success) this.close(this.form.result);
}
}
<!-- edit.html -->
<hg-action-form [form]="form" [formGroup]="form.group" modal modalWidth="32rem" (hgSubmit)="save()" (hgClose)="close()">
<hg-form-field formControlName="name" label="Nome" type="text" [required]="true"></hg-form-field>
<!-- ... -->
</hg-action-form>
FormContainer (hg-action-form)
Inputs principais:
[form](signalinput<Form>) — liga a instância; sem isso o container não conecta estado e o botão nunca sai do estado inicial[formGroup]="form.group"— diretiva reativa do Angular (obrigatória quando há fields)modal(bool) — setrue, container envolve o conteúdo em<hg-modal>com<hg-modal-footer>projetando os botões corretamentemodalOpen(bool, defaulttrue) — controla visibilidade quandomodalestá ativo (útil para modal aninhado:[modalOpen]="!childModalOpen()")modalWidth— ex:'32rem','md'title— sobrescreveform.titlesubmitLabel,submitColor,submitFitcancelable(defaulttrue, exceto quandoisSearch),cancelLabelclearable+clearLabelisSearch— troca label default para "Filtrar" e remove cancelar
Outputs: hgSubmit, hgClose, hgClear.
Form (classe)
Métodos-chave:
addField(field, validators?)— adiciona field ao grupo.FormFieldcarreganame,type,label,required,value,options(select),mask,depends,dependsAnypatch(item)— popula valores a partir de um objeto existenteprocess()— validação + execução deoptions.process. RetornaPromise<boolean>(sucesso)setFieldOptions(name, dataSource)— atualiza options de um select em runtime (ex: após criar credencial nova)crossValidate(['a', 'b'], validator)— validação cruzada entre camposclear()— reseta fields e subscriptions
Propriedades observadas pelo container:
isLoading— bloqueia UI com skeletonisProcessing— botão loading + grupo disabledcanSubmit— habilita/desabilita submiterror— render inline no topo do formwasSubmitedOnce— flag que destrava render de erros nos campos
FormOptions.process(form, values): Promise<any> é onde a request acontece. Use firstValueFrom para converter Observable em Promise. O valor retornado vira form.result.
FormOptions.alert.success/alert.error disparam Toast automaticamente após process.
Grupos visuais num form único
Quando há múltiplas seções (URLs, Configurações, Credencial), não crie múltiplos Forms. Use um form único com fields prefixados e desagrupe em process:
const URL_PREFIX = 'url__';
const CONFIG_PREFIX = 'config__';
this.form = this.fb.build({
process: async (_f, values) => {
const urls: Record<string, unknown> = {};
const config: Record<string, unknown> = {};
for (const key of Object.keys(values)) {
if (key.startsWith(URL_PREFIX)) urls[key.slice(URL_PREFIX.length)] = values[key];
else if (key.startsWith(CONFIG_PREFIX)) config[key.slice(CONFIG_PREFIX.length)] = values[key];
}
return firstValueFrom(this.service.save({ urls, config, ...rest }));
},
});
No template, seções ficam em <hg-panel> separados dentro do mesmo <hg-action-form>:
<hg-action-form [form]="form" [formGroup]="form.group" modal ...>
<hg-panel heading="Identificação" [open]="true">
<hg-form-field-group [cols]="2">
<hg-form-field formControlName="label" ...></hg-form-field>
<hg-form-field formControlName="environment" ...></hg-form-field>
</hg-form-field-group>
</hg-panel>
@if (hasUrlFields()) {
<hg-panel heading="URLs" [open]="true">
<hg-form-field-group [cols]="2">
@for (f of urlsDef(); track f.name) {
<hg-form-field [formControlName]="urlPrefix + f.name" [label]="f.label"></hg-form-field>
}
</hg-form-field-group>
</hg-panel>
}
</hg-action-form>
Referência: hub/web/src/app/domains/config/actions/override/override.ts.
Anti-padrões
- ❌
form.group.invalid→returnmanual noonSave— pulasyncAllFieldErrorseisProcessing - ❌
form.wasSubmitedOnce = trueseguido demarkAllAsTouchedmanual —process()já faz isso - ❌
service.create(body).subscribe(() => this.hgClose.emit())— nunca assine direto no submit - ❌ Múltiplos
Formparalelos num mesmo component — consolida num único e usa prefixos - ❌ Envolver
<hg-action-form>num<hg-modal>externo — usemodalno próprio container para que o footer seja projetado em<hg-modal-footer> - ❌ Esquecer
[form]no<hg-action-form>— sem isso o container não conecta estado e o botão nunca refleteisProcessing/canSubmit
Search Action
Usar para listagens com filtros, sort, paginação e item actions.
Padrão canônico
// adm/web/src/app/domains/catalog/actions/search/search.ts
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { Search as SearchAction, SearchContainer } from '@hg/ui/action';
import { ColumnDirective, DataTable } from '@hg/ui/data';
import { FillHeight } from '@hg/ui/utils';
@Component({
selector: 'app-domains-catalog-action-search',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SearchContainer, DataTable, ColumnDirective, FillHeight],
templateUrl: './search.html',
host: { class: 'contents' },
})
export class Search {
searchAction: SearchAction;
private router = inject(Router);
constructor() {
this.searchAction = inject(CatalogService).searchAction('search');
}
onRowClick(item: any) {
this.router.navigate(['/cms/catalog', item.id]);
}
}
<!-- search.html -->
<hg-action-search [action]="searchAction" [hgFillHeight]="1.75">
<hg-data-table [action]="searchAction" height="fill" (rowClick)="onRowClick($event)">
<ng-template dataColumn="status" let-item="item">
<hg-badge [color]="item.status === 'active' ? 'success-soft' : 'gray-soft'" [dot]="true">
{{ item.status === 'active' ? 'Ativo' : 'Rascunho' }}
</hg-badge>
</ng-template>
</hg-data-table>
</hg-action-search>
Como o Search é construído
Normalmente via DomainService.searchAction('search', options?), que usa config.actionSourceOptions.search (columns, sort default, paginable, searchable) e resolve o fetch automaticamente — server-side (via config.domain + apiService.query) ou repo local.
Para casos fora do padrão CRUD, pode-se construir manualmente via SearchBuilder:
this.searchAction = this.searchBuilder.build<Run>({
columns: RUN_COLUMNS,
fetch: async (params) => firstValueFrom(this.repo.search(params)),
itemActions: this.service.action({ delete: { ... }, retry: { ... } }, { repo: this.repo }),
});
SearchContainer (hg-action-search)
- Sincroniza automaticamente
page,sort,search,date,filters,mode,viewcomqueryParamsda URL - Mudanças na URL triggeram
source.fetch(params)— o fetch deve ser idempotente - Inputs:
[action],[modes],[views],[defaultView] - Output:
filterClick <ng-template dataColumn="key" let-item="item">customiza render de coluna
Source
A Search encapsula uma Source<T> com signals: items, total, isLoading, page, perPage, search, period, sortBy, sortDir, filters. Acessível via searchAction.source. Use source.refresh() para refetch manual (ex: após SSE event).
View Action
Usar para telas de detalhe com loading state assíncrono.
Padrão canônico simples
import { ChangeDetectionStrategy, Component, inject, input, output } from '@angular/core';
import { ViewContainer } from '@hg/ui/action';
@Component({
selector: 'app-domains-run-action-view',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ViewContainer],
templateUrl: './view.html',
host: { class: 'contents' },
})
export class View {
run = input.required<Run>();
close = output<void>();
loading = signal(true);
items = signal<Item[]>([]);
constructor() {
queueMicrotask(() => this.loadDetails());
}
private async loadDetails() {
this.items.set(await firstValueFrom(this.repo.items(this.run().id)));
this.loading.set(false);
}
}
<hg-action-view modal modalWidth="max(700px, 50vw)" [loading]="loading()" (hgClose)="close.emit()">
<!-- conteúdo -->
</hg-action-view>
Quando usar a classe View (de @hg/ui/action)
Para casos formais com retry/error state:
this.viewAction = this.viewBuilder.build<Site>({
fetch: () => this.repo.get(siteId),
});
await this.viewAction.fetch();
// this.viewAction.data(), .loading(), .error() — todos signals
ViewContainer (hg-action-view)
- Inputs:
[action](opcional — lêaction.loading/action.form.isLoading),[loading],modal,modalWidth,modalSidepanel,modalMinimizable,modalMaximizable,modalControls,modalShowPadding,modalShowHeader,modalBackdropAction - Content projection:
[header-start],[header-end],[footer] - Delay de 300ms antes de mostrar conteúdo em modal (evita glitch de animação) — se testar, use
fakeAsync + tick(300)
Simple Action (item actions e botões)
Usar para ações pontuais (delete, retry, ativar, navegar) — com ou sem confirmação, single ou batch.
Construção via DomainService
const actions = this.service.action(
{
edit: {
label: 'Editar',
icon: 'pencil',
button: { routerLink: ['.'], fragment: 'modal=edit' },
},
delete: {
label: 'Excluir',
icon: 'trash',
color: 'danger',
endpoint: 'remove',
alert: {
confirmation: 'Excluir este item?',
success: 'Item excluído.',
error: 'Erro ao excluir.',
},
},
},
{ repo: this.repo, source: this.searchAction.source },
);
Quando endpoint + context.repo, o SimpleBuilder monta automaticamente: repo[endpoint](item.id) → toast → source.refresh().
Renderização
<hg-action-button [action]="actions.delete" [item]="row"></hg-action-button>
<hg-action-button-group [actions]="[actions.edit, actions.delete]" [item]="row"></hg-action-button-group>
ActionButton respeita action.can(item) para visibilidade, action.disabled (bool ou function), e renderiza dropdown se action.options existe. Confirmação usa hg-confirm singleton no body.
hg-action-button vs hg-button
hg-action-button— quando tem context (item, batch), confirmação, dropdown de opçõeshg-button— botão estático de header/footer sem lógica de item
DomainService
Classe base @hg/ui/domain#DomainService<T> que serve de factory central para actions do domínio.
Config
const CONFIG: DomainServiceConfig<Catalog> = {
domain: 'catalog', // backend API domain
realtimeConfig: { enabled: false, fields: {} },
fieldMeta: { /* metadados por field */ },
dictionaryConfig: {
status: {
active: { label: 'Ativo', color: 'success-soft' },
draft: { label: 'Rascunho', color: 'gray-soft' },
},
},
actionSourceOptions: {
search: {
columns: COLUMNS,
defaultSortBy: 'name',
defaultSortDir: 'asc',
},
},
actionMeta: {
search: { icon: 'car', empty: 'Nenhum modelo cadastrado' },
},
actionTitles: { edit: 'Editar Modelo' },
actionAlerts: { delete: { success: 'Excluído.' } },
};
@Injectable({ providedIn: 'root' })
export class CatalogService extends DomainService<Catalog> {
constructor() { super(CONFIG); }
// métodos específicos do domínio
get(id: string) { return this.http.get(...); }
create(body: Partial<Catalog>) { return this.http.post(...); }
}
API
form(action, options?, overrides?, only?)— constróiFormcom fields defieldMetaaplicando overridesformField(action, name, overrides?)— field individualsearchAction(action, options?)— constróiSearchcomSourcepré-configuradaviewAction(action, options)— constróiViewfilterForm(action, overrides?)— form de filtrosaction(specs, context?)— delega aoSimpleBuilder.buildManydictionary(field, value)— resolve label/color/icon
Integração com backend
Se config.domain está setado, fetch de search vai em apiService.query(domain, action, params) com page, per_page, sort_by, sort_dir, search, date_from, date_to, filtros customizados. Retorno esperado: { data: T[], total, page, per_page }.
Se há this.repo local (setado pela subclasse), usa repo.fetch(params).
Pages
Actions são montadas em Pages — classes que estendem @hg/ui/page#Page e geram a rota do router. Pages declaram o body (geralmente um Search), modais filhos (fragment-based, ex: #modal=new) e sub-rotas de detalhe.
Referência completa em docs/0-common/7-pages.md. Os pontos que afetam actions diretamente:
- Body sempre é um action component (
Search,List,View). Não reimplemente listagens inline na Page. - Modais de CRUD são declarados em
options.modals: [{ path, title, component }]e abertos viafragment=modal={path}. O component filho injetaMODAL_CONTEXTe chamaonClose(result?)para fechar e sinalizar refetch ao body. - Botões do header (
options.actions) usambutton.fragment: 'modal=new'em vez de click handler. Confirmações e disabled funcionam viaSimplenormalmente.
Checklist antes de implementar
- Pasta e arquivos seguem naming
{type}/{type}.ts+{type}.html - Seletor é
app-domains-{domain}-action-{type} - Component tem
standalone: true,OnPush,host: { class: 'contents' } - Para Form: existe um
Formcomoptions.process+options.alert, template passa[form]e[formGroup]="form.group",save()só chamaawait form.process() - Para Search: usa
DomainService.searchAction()ouSearchBuilder, template tem<hg-action-search [action]="...">+<hg-data-table [action]="..."> - Para View: usa
ViewContainereloadingsignal/input - Item actions são
Simpleconstruídas viaservice.action({...}, context), renderizadas comhg-action-button[-group] - Sem
.subscribe()direto em submit, semmarkAllAsTouchedmanual, sem wrapping de<hg-action-form>em<hg-modal>externo