Pular para o conteúdo principal

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:

  1. 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.
  2. 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:

ClasseContainerSeletorUso
FormFormContainerhg-action-formcreate / edit / filtros
SearchSearchContainerhg-action-searchlistagem com filtros/sort/paginação
ViewViewContainerhg-action-viewdetalhe / visualização com loading state
SimpleActionButton / ActionButtonGrouphg-action-buttonbotã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 → seletor app-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] (signal input<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) — se true, container envolve o conteúdo em <hg-modal> com <hg-modal-footer> projetando os botões corretamente
  • modalOpen (bool, default true) — controla visibilidade quando modal está ativo (útil para modal aninhado: [modalOpen]="!childModalOpen()")
  • modalWidth — ex: '32rem', 'md'
  • title — sobrescreve form.title
  • submitLabel, submitColor, submitFit
  • cancelable (default true, exceto quando isSearch), cancelLabel
  • clearable + clearLabel
  • isSearch — troca label default para "Filtrar" e remove cancelar

Outputs: hgSubmit, hgClose, hgClear.

Form (classe)

Métodos-chave:

  • addField(field, validators?) — adiciona field ao grupo. FormField carrega name, type, label, required, value, options (select), mask, depends, dependsAny
  • patch(item) — popula valores a partir de um objeto existente
  • process() — validação + execução de options.process. Retorna Promise<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 campos
  • clear() — reseta fields e subscriptions

Propriedades observadas pelo container:

  • isLoading — bloqueia UI com skeleton
  • isProcessing — botão loading + grupo disabled
  • canSubmit — habilita/desabilita submit
  • error — render inline no topo do form
  • wasSubmitedOnce — 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.invalidreturn manual no onSave — pula syncAllFieldErrors e isProcessing
  • form.wasSubmitedOnce = true seguido de markAllAsTouched manual — process() já faz isso
  • service.create(body).subscribe(() => this.hgClose.emit()) — nunca assine direto no submit
  • ❌ Múltiplos Form paralelos num mesmo component — consolida num único e usa prefixos
  • ❌ Envolver <hg-action-form> num <hg-modal> externo — use modal no 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 reflete isProcessing/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 }),
});
  • Sincroniza automaticamente page, sort, search, date, filters, mode, view com queryParams da 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ções
  • hg-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ói Form com fields de fieldMeta aplicando overrides
  • formField(action, name, overrides?) — field individual
  • searchAction(action, options?) — constrói Search com Source pré-configurada
  • viewAction(action, options) — constrói View
  • filterForm(action, overrides?) — form de filtros
  • action(specs, context?) — delega ao SimpleBuilder.buildMany
  • dictionary(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 via fragment=modal={path}. O component filho injeta MODAL_CONTEXT e chama onClose(result?) para fechar e sinalizar refetch ao body.
  • Botões do header (options.actions) usam button.fragment: 'modal=new' em vez de click handler. Confirmações e disabled funcionam via Simple normalmente.

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 Form com options.process + options.alert, template passa [form] e [formGroup]="form.group", save() só chama await form.process()
  • Para Search: usa DomainService.searchAction() ou SearchBuilder, template tem <hg-action-search [action]="..."> + <hg-data-table [action]="...">
  • Para View: usa ViewContainer e loading signal/input
  • Item actions são Simple construídas via service.action({...}, context), renderizadas com hg-action-button[-group]
  • Sem .subscribe() direto em submit, sem markAllAsTouched manual, sem wrapping de <hg-action-form> em <hg-modal> externo