@praxisui/crud
Version:
CRUD building blocks for Praxis UI: integrates dynamic forms and tables with unified configuration and services.
1,010 lines (999 loc) • 53.8 kB
JavaScript
import * as i0 from '@angular/core';
import { Injectable, InjectionToken, inject, EventEmitter, ViewChild, Output, Input, Component, DestroyRef, Inject, ENVIRONMENT_INITIALIZER } from '@angular/core';
import { PraxisTable } from '@praxisui/table';
import { Router, RouterLink } from '@angular/router';
import * as i1 from '@angular/material/dialog';
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
export { MAT_DIALOG_DATA as DIALOG_DATA } from '@angular/material/dialog';
import * as i2 from '@praxisui/core';
import { CONFIG_STORAGE, GlobalConfigService, fillUndefined, createDefaultTableConfig, EmptyStateCardComponent, PraxisIconDirective, GenericCrudService, ComponentMetadataRegistry } from '@praxisui/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatSnackBar } from '@angular/material/snack-bar';
import * as i1$1 from '@angular/common';
import { CommonModule } from '@angular/common';
import * as i4 from '@angular/material/button';
import { MatButtonModule } from '@angular/material/button';
import * as i5 from '@angular/material/icon';
import { MatIconModule } from '@angular/material/icon';
import { PraxisDynamicForm } from '@praxisui/dynamic-form';
import { ConfirmDialogComponent } from '@praxisui/dynamic-fields';
import { filter } from 'rxjs/operators';
class DialogService {
matDialog;
zone;
constructor(matDialog, zone) {
this.matDialog = matDialog;
this.zone = zone;
}
open(component, config) {
return this.zone.run(() => this.matDialog.open(component, config));
}
async openAsync(loader, config) {
const component = await loader();
return this.zone.run(() => this.matDialog.open(component, config));
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: DialogService, deps: [{ token: i1.MatDialog }, { token: i0.NgZone }], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: DialogService, providedIn: 'root' });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: DialogService, decorators: [{
type: Injectable,
args: [{ providedIn: 'root' }]
}], ctorParameters: () => [{ type: i1.MatDialog }, { type: i0.NgZone }] });
const CRUD_DRAWER_ADAPTER = new InjectionToken('CRUD_DRAWER_ADAPTER');
class CrudLauncherService {
router = inject(Router);
dialog = inject(DialogService);
storage = inject(CONFIG_STORAGE);
global = inject(GlobalConfigService);
drawerAdapter = (() => {
try {
return inject(CRUD_DRAWER_ADAPTER);
}
catch {
return undefined;
}
})();
async launch(action, row, metadata) {
// Carregar overrides de CRUD (se houver) e mesclar em uma cópia local
const merged = this.mergeCrudOverrides(metadata, action);
const mode = this.resolveOpenMode(merged.action, merged.metadata);
console.debug('[CRUD:Launcher] mode=', mode, 'action=', action);
if (mode === 'route') {
if (!merged.action.route) {
throw new Error(`Route not provided for action ${merged.action.action}`);
}
const url = this.buildRoute(merged.action, row, merged.metadata);
await this.router.navigateByUrl(url);
return { mode };
}
if (mode === 'drawer' && this.drawerAdapter) {
const inputs = this.mapInputs(merged.action, row);
const idField = merged.metadata.resource?.idField ?? 'id';
if (row && inputs[idField] === undefined && row[idField] !== undefined) {
inputs[idField] = row[idField];
}
await Promise.resolve(this.drawerAdapter.open({ action: merged.action, metadata: merged.metadata, inputs }));
return { mode };
}
if (!merged.action.formId) {
throw new Error(`formId not provided for action ${merged.action.action}`);
}
const inputs = this.mapInputs(merged.action, row);
const idField = merged.metadata.resource?.idField ?? 'id';
if (row && inputs[idField] === undefined && row[idField] !== undefined) {
inputs[idField] = row[idField];
}
const modalCfg = { ...(merged.metadata.defaults?.modal || {}) };
console.debug('[CRUD:Launcher] opening dialog with:', {
action: merged.action.action,
formId: merged.action.formId,
inputs,
modalCfg,
resourcePath: merged.metadata.resource?.path ?? merged.metadata.table?.resourcePath,
});
const panelClasses = ['pfx-dialog-pane', 'pfx-dialog-frosted'];
if (modalCfg.panelClass) {
panelClasses.push(modalCfg.panelClass);
}
// Backdrop style presets
const backdropClasses = [];
const style = modalCfg.backdropStyle;
if (!style || style === 'blur') {
backdropClasses.push('pfx-blur-backdrop');
}
else if (style === 'dim') {
backdropClasses.push('cdk-overlay-dark-backdrop');
}
else if (style === 'transparent') {
backdropClasses.push('pfx-transparent-backdrop');
}
if (modalCfg.backdropClass) {
backdropClasses.push(modalCfg.backdropClass);
}
const ref = await this.dialog.openAsync(() => Promise.resolve().then(function () { return dynamicFormDialogHost_component; }).then((m) => m.DynamicFormDialogHostComponent), {
...modalCfg,
panelClass: panelClasses,
backdropClass: backdropClasses,
autoFocus: modalCfg.autoFocus ?? true,
restoreFocus: modalCfg.restoreFocus ?? true,
minWidth: '360px',
maxWidth: '95vw',
ariaLabelledBy: 'crudDialogTitle',
data: { action: merged.action, row, metadata: merged.metadata, inputs },
});
return { mode, ref };
}
resolveOpenMode(action, metadata) {
// Local precedence: action > metadata.defaults
const local = action.openMode ?? metadata.defaults?.openMode;
if (local)
return local;
// Global fallback (action-specific, then general), then hard fallback 'route'
try {
const globalCrud = this.global.getCrud();
const actionName = action.action;
const globalMode = (actionName && globalCrud?.actionDefaults?.[actionName]?.openMode) ?? globalCrud?.defaults?.openMode;
let resolved = globalMode ?? 'route';
// Safety: if modal/drawer but no formId, degrade to route to avoid runtime error
if ((resolved === 'modal' || resolved === 'drawer') && !action.formId) {
resolved = 'route';
}
return resolved;
}
catch {
return 'route';
}
}
buildRoute(action, row, metadata) {
let route = action.route;
const query = {};
action.params?.forEach((p) => {
const value = row?.[p.from];
if (p.to === 'routeParam') {
if (value === undefined || value === null) {
throw new Error(`Missing value for route param ${p.name}`);
}
const re = new RegExp(`:${p.name}\\b`, 'g');
route = route.replace(re, encodeURIComponent(String(value)));
}
else if (p.to === 'query' && value !== undefined && value !== null) {
query[p.name] = String(value);
}
});
const missing = route.match(/:[a-zA-Z0-9_-]+/g);
if (missing) {
throw new Error(`Missing route parameters for action "${action.action}": ${missing
.map((m) => m.slice(1))
.join(', ')}`);
}
// Ensure resource is present for routed forms so the formId is deterministic
try {
const resourcePath = metadata.resource?.path || metadata.table?.resourcePath;
if (resourcePath && query['resource'] === undefined) {
query['resource'] = resourcePath;
}
}
catch { }
// ReturnTo param for graceful back navigation on routed forms
const back = action.back || metadata.defaults?.back;
const returnTo = back?.returnTo || this.router.url;
if (returnTo) {
query['returnTo'] = returnTo;
}
const queryString = new URLSearchParams(query).toString();
return queryString ? `${route}?${queryString}` : route;
}
mapInputs(action, row) {
const inputs = {};
action.params?.forEach((p) => {
if (p.to === 'input') {
inputs[p.name] = row?.[p.from];
}
});
return inputs;
}
mergeCrudOverrides(metadata, action) {
try {
const tableId = metadata.resource?.path || metadata.table?.resourcePath || 'default';
const key = `crud-overrides:${tableId}`;
const saved = this.storage.loadConfig(key) || {};
const overDefaults = saved.defaults || {};
const overActions = saved.actions || {};
// Merge defaults (shallow for defaults, shallow for modal/back)
let mergedDefaults = {
...(metadata.defaults || {}),
...('openMode' in overDefaults ? { openMode: overDefaults.openMode } : {}),
modal: { ...(metadata.defaults?.modal || {}), ...(overDefaults.modal || {}) },
back: { ...(metadata.defaults?.back || {}), ...(overDefaults.back || {}) },
header: { ...(metadata.defaults?.header || {}), ...(overDefaults.header || {}) },
};
// Fill missing defaults from GlobalConfig (as last fallback)
try {
const globalCrud = this.global.getCrud();
if (globalCrud?.defaults) {
mergedDefaults = fillUndefined(mergedDefaults, globalCrud.defaults);
}
}
catch { }
// Merge metadata copy
const mergedMetadata = {
...metadata,
defaults: mergedDefaults,
};
// Action-level override
const actOv = overActions[action.action] || {};
const mergedAction = {
...action,
...('openMode' in actOv ? { openMode: actOv.openMode } : {}),
...('formId' in actOv ? { formId: actOv.formId } : {}),
...('route' in actOv ? { route: actOv.route } : {}),
};
return { metadata: mergedMetadata, action: mergedAction };
}
catch {
return { metadata, action };
}
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: CrudLauncherService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: CrudLauncherService, providedIn: 'root' });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: CrudLauncherService, decorators: [{
type: Injectable,
args: [{ providedIn: 'root' }]
}] });
function assertCrudMetadata(meta) {
meta.actions?.forEach((action) => {
const mode = action.openMode ?? meta.defaults?.openMode ?? 'route';
if (mode === 'route' && !action.route) {
throw new Error(`Route not provided for action ${action.action}`);
}
if ((mode === 'modal' || mode === 'drawer') && !action.formId) {
throw new Error(`formId not provided for action ${action.action}`);
}
action.params?.forEach((p) => {
if (!['routeParam', 'query', 'input'].includes(p.to)) {
throw new Error(`Invalid param mapping target: ${p.to}`);
}
});
});
}
class PraxisCrudComponent {
/** Habilita visual de debug para alinhamento/layouot */
debugLayout = false;
/** JSON inline ou chave/URL resolvida pelo MetadataResolver */
metadata;
context;
/** Encaminha o modo de edição de layout para a tabela interna */
editModeEnabled = false;
/** CTA: usado pelo Builder para abrir configuração de metadados quando vazio */
configureRequested = new EventEmitter();
afterOpen = new EventEmitter();
afterClose = new EventEmitter();
afterSave = new EventEmitter();
afterDelete = new EventEmitter();
error = new EventEmitter();
resolvedMetadata;
/** Configuração efetiva da tabela com melhorias automáticas (ex.: botão Adicionar). */
effectiveTableConfig;
/** Config passado ao PraxisTable — sempre definido (fallback seguro). */
tableConfigForBinding = createDefaultTableConfig();
launcher = inject(CrudLauncherService);
table;
storage = inject(CONFIG_STORAGE);
snack = inject(MatSnackBar);
global = (() => { try {
return inject(GlobalConfigService);
}
catch {
return undefined;
} })();
/**
* Stable CRUD context passed to PraxisTable.
* Previously this was created via a getter, producing a new object each CD tick
* and causing excessive re-renders. Now we compute it only when metadata changes.
*/
tableCrudContext;
onResetPreferences() {
try {
const id = this.resolvedMetadata?.resource?.path || 'default';
const key = `crud-overrides:${id}`;
this.storage.clearConfig(key);
this.snack.open('Overrides de CRUD redefinidos', undefined, { duration: 2000 });
}
catch { }
}
ngOnChanges(changes) {
if (changes['metadata']) {
try {
this.resolvedMetadata =
typeof this.metadata === 'string'
? JSON.parse(this.metadata)
: this.metadata;
assertCrudMetadata(this.resolvedMetadata);
this.effectiveTableConfig = this.buildEffectiveTableConfig(this.resolvedMetadata);
// Evitar passar undefined para [config], o que sobrescreve o default do PraxisTable
this.tableConfigForBinding =
this.effectiveTableConfig ||
(this.resolvedMetadata.table ??
createDefaultTableConfig());
// Build a stable table context when metadata changes
this.tableCrudContext = this.buildTableCrudContext(this.resolvedMetadata);
}
catch (err) {
this.error.emit(err);
}
}
}
async onAction(action, row) {
try {
document.activeElement?.blur();
let actionMeta = this.resolvedMetadata.actions?.find((a) => a.action === action);
// Fallback: if metadata.actions is missing or doesn't include the action,
// synthesize from table CRUD context or let overrides drive behavior.
if (!actionMeta) {
const ctxAction = this.tableCrudContext?.actions?.find((a) => a.action === action);
if (ctxAction) {
actionMeta = {
action: ctxAction.action,
openMode: ctxAction.openMode,
formId: ctxAction.formId,
route: ctxAction.route,
};
}
else {
// Minimal stub – CrudLauncherService will merge overrides/defaults
actionMeta = { action };
}
}
const effectiveAction = (actionMeta || { action });
const { mode, ref } = await this.launcher.launch(effectiveAction, row, this.resolvedMetadata);
this.afterOpen.emit({ mode, action: effectiveAction.action });
if (ref) {
const idField = this.getIdField();
ref
.afterClosed()
.pipe(takeUntilDestroyed())
.subscribe((result) => {
this.afterClose.emit();
if (result?.type === 'save') {
const data = result.data;
const id = data?.[idField];
this.afterSave.emit({ id, data: result.data });
this.refreshTable();
}
if (result?.type === 'delete') {
const data = result.data;
const id = data?.[idField];
this.afterDelete.emit({ id });
this.refreshTable();
}
});
}
}
catch (err) {
this.error.emit(err);
}
}
refreshTable() {
this.table.refetch();
}
getIdField() {
return this.resolvedMetadata?.resource?.idField || 'id';
}
onConfigureRequested() {
this.configureRequested.emit();
}
/**
* Constrói uma configuração de tabela efetiva a partir dos metadados,
* adicionando automaticamente a ação de "Adicionar" na toolbar quando aplicável.
* - Evita duplicidade se a toolbar já tiver ação equivalente.
* - Garante visibilidade da toolbar quando a ação é injetada.
*/
buildEffectiveTableConfig(meta) {
const base = meta.table;
// Clonar base ou criar default para permitir injeção de melhorias
const cfg = base
? JSON.parse(JSON.stringify(base))
: createDefaultTableConfig();
let changed = false;
const ensureToolbar = () => {
if (!cfg.toolbar) {
cfg.toolbar = { visible: true, position: 'top' };
}
return cfg.toolbar;
};
// 1) Toolbar: injetar ação "Adicionar" se metadata declara create/add e toolbar não tiver
const hasToolbarAdd = (cfg.toolbar?.actions || []).some((a) => this.isAddLike(a));
const addAction = (meta.actions || []).find((a) => this.isAddLike(a));
if (addAction) {
if (hasToolbarAdd) {
const tb = ensureToolbar();
tb.visible = true; // garantir visibilidade
}
else {
const tb = ensureToolbar();
tb.visible = true;
if (!tb.actions)
tb.actions = [];
const injected = {
id: addAction.id || 'add',
label: addAction.label || 'Adicionar',
icon: addAction.icon || 'add',
type: 'button',
color: 'primary',
position: 'end',
action: addAction.action || 'add',
};
tb.actions.push(injected);
}
changed = true;
}
// 2) Row actions automáticas (apenas quando host não definiu explicitamente)
const hostDefinedRow = !!(base && base.actions && base.actions.row);
const crudDefaults = this.global.get('crud.defaults') || {};
const autoRow = crudDefaults.autoRowActions !== false; // default true
if (!hostDefinedRow && autoRow) {
const acts = (meta.actions || []);
const hasView = acts.some((a) => String(a.action).toLowerCase() === 'view');
const hasEdit = acts.some((a) => String(a.action).toLowerCase() === 'edit');
const includeDelete = !!crudDefaults.includeDeleteInRow && acts.some((a) => String(a.action).toLowerCase() === 'delete');
const rowActions = [];
if (hasView)
rowActions.push({ id: 'view', label: 'Ver', icon: 'visibility', action: 'view', tooltip: 'Ver' });
if (hasEdit)
rowActions.push({ id: 'edit', label: 'Editar', icon: 'edit', action: 'edit', tooltip: 'Editar' });
if (includeDelete)
rowActions.push({ id: 'delete', label: 'Excluir', icon: 'delete', action: 'delete', tooltip: 'Excluir' });
if (rowActions.length) {
cfg.actions = cfg.actions || {};
cfg.actions.row = {
enabled: true,
position: 'end',
width: cfg.actions?.row?.width || '120px',
display: crudDefaults.rowActionsDisplay || 'icons',
trigger: cfg.actions?.row?.trigger || 'hover',
actions: rowActions,
header: { label: cfg.actions?.row?.header?.label || '' },
};
changed = true;
}
}
// Se nada foi alterado e havia base, não retornar para preservar semântica anterior
if (!changed) {
return base ? undefined : cfg;
}
return cfg;
}
/** Heurística leve para identificar ações do tipo "adicionar/criar" */
isAddLike(a) {
if (!a)
return false;
const normalize = (s) => String(s || '').trim().toLowerCase();
const id = normalize(a.action || a.id || a.code || a.key || a.name || a.type);
const icon = normalize(a.icon);
const label = normalize(a.label);
const addIds = new Set(['add', 'create', 'novo', 'new', 'incluir', 'inserir']);
if (addIds.has(id))
return true;
if (icon === 'add' || icon === 'add_circle' || icon === 'add_box')
return true;
if (label === 'adicionar' || label === 'novo' || label === 'criar' || label === 'incluir')
return true;
return false;
}
/** Builds a stable CRUD context object for PraxisTable based on metadata. */
buildTableCrudContext(meta) {
if (!meta)
return undefined;
// Provide a minimal default action set when metadata.actions is empty,
// so the editor can expose per-action overrides entirely via UI.
const baseActions = meta.actions && meta.actions.length
? meta.actions
: [{ action: 'create' }, { action: 'view' }, { action: 'edit' }];
return {
tableId: meta.resource?.path || meta.table?.resourcePath || 'default',
resourcePath: meta.resource?.path || meta.table?.resourcePath,
defaults: meta.defaults,
actions: baseActions.map((a) => ({
action: a.action,
label: a.label,
formId: a.formId,
route: a.route,
openMode: a.openMode,
})),
idField: meta.resource?.idField || 'id',
};
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: PraxisCrudComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.1.4", type: PraxisCrudComponent, isStandalone: true, selector: "praxis-crud", inputs: { debugLayout: "debugLayout", metadata: "metadata", context: "context", editModeEnabled: "editModeEnabled" }, outputs: { configureRequested: "configureRequested", afterOpen: "afterOpen", afterClose: "afterClose", afterSave: "afterSave", afterDelete: "afterDelete", error: "error" }, viewQueries: [{ propertyName: "table", first: true, predicate: PraxisTable, descendants: true }], usesOnChanges: true, ngImport: i0, template: `
@if ($any(resolvedMetadata)?.resource?.path) {
<praxis-table
[config]="tableConfigForBinding"
[resourcePath]="resolvedMetadata.resource?.path"
[tableId]="resolvedMetadata.resource?.path || 'default'"
[crudContext]="tableCrudContext"
[editModeEnabled]="editModeEnabled"
(rowAction)="onAction($event.action, $event.row)"
(toolbarAction)="onAction($event.action)"
(reset)="onResetPreferences()"
[debugLayout]="debugLayout"
></praxis-table>
} @else {
@if (editModeEnabled) {
<praxis-empty-state-card
icon="table_rows"
[title]="'Conecte o CRUD a um recurso'"
[description]="'Informe os metadados (resourcePath / schema) para habilitar a tabela e ações.'"
[primaryAction]="{ label: 'Configurar metadados', icon: 'bolt', action: onConfigureRequested.bind(this) }"
/>
}
}
`, isInline: true, dependencies: [{ kind: "component", type: PraxisTable, selector: "praxis-table", inputs: ["config", "resourcePath", "filterCriteria", "notifyIfOutdated", "snoozeMs", "autoOpenSettingsOnOutdated", "showToolbar", "toolbarV2", "autoDelete", "editModeEnabled", "dense", "tableId", "debugLayout", "horizontalScroll", "crudContext", "idField"], outputs: ["rowClick", "rowAction", "toolbarAction", "bulkAction", "rowDoubleClick", "schemaStatusChange", "metadataChange", "beforeDelete", "afterDelete", "deleteError", "beforeBulkDelete", "afterBulkDelete", "bulkDeleteError"] }, { kind: "component", type: EmptyStateCardComponent, selector: "praxis-empty-state-card", inputs: ["icon", "title", "description", "primaryAction", "secondaryActions", "inline"] }] });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: PraxisCrudComponent, decorators: [{
type: Component,
args: [{
selector: 'praxis-crud',
standalone: true,
imports: [PraxisTable, EmptyStateCardComponent],
template: `
@if ($any(resolvedMetadata)?.resource?.path) {
<praxis-table
[config]="tableConfigForBinding"
[resourcePath]="resolvedMetadata.resource?.path"
[tableId]="resolvedMetadata.resource?.path || 'default'"
[crudContext]="tableCrudContext"
[editModeEnabled]="editModeEnabled"
(rowAction)="onAction($event.action, $event.row)"
(toolbarAction)="onAction($event.action)"
(reset)="onResetPreferences()"
[debugLayout]="debugLayout"
></praxis-table>
} @else {
@if (editModeEnabled) {
<praxis-empty-state-card
icon="table_rows"
[title]="'Conecte o CRUD a um recurso'"
[description]="'Informe os metadados (resourcePath / schema) para habilitar a tabela e ações.'"
[primaryAction]="{ label: 'Configurar metadados', icon: 'bolt', action: onConfigureRequested.bind(this) }"
/>
}
}
`,
}]
}], propDecorators: { debugLayout: [{
type: Input
}], metadata: [{
type: Input,
args: [{ required: true }]
}], context: [{
type: Input
}], editModeEnabled: [{
type: Input
}], configureRequested: [{
type: Output
}], afterOpen: [{
type: Output
}], afterClose: [{
type: Output
}], afterSave: [{
type: Output
}], afterDelete: [{
type: Output
}], error: [{
type: Output
}], table: [{
type: ViewChild,
args: [PraxisTable]
}] } });
class DynamicFormDialogHostComponent {
dialogRef;
data;
dialogService;
crud;
configStorage;
formComp;
modal = {};
maximized = false;
initialSize = {};
rememberState = false;
stateKey;
destroyRef = inject(DestroyRef);
resourcePath;
resourceId;
mode = 'create';
backConfig;
idField = 'id';
texts = {
title: 'Formulário',
close: 'Fechar',
closeLabel: 'Fechar diálogo',
maximizeLabel: 'Maximizar',
restoreLabel: 'Restaurar',
discardTitle: 'Descartar alterações?',
discardMessage: 'Você tem alterações não salvas. Deseja fechar assim mesmo?',
discardConfirm: 'Descartar',
discardCancel: 'Cancelar',
};
constructor(dialogRef, data, dialogService, crud, configStorage) {
this.dialogRef = dialogRef;
this.data = data;
this.dialogService = dialogService;
this.crud = crud;
this.configStorage = configStorage;
this.dialogRef.disableClose = true;
// i18n
this.texts = {
...this.texts,
...(this.data.metadata?.i18n?.crudDialog || {}),
};
// defaults do modal (inclui canMaximize: true)
this.modal = {
canMaximize: true,
...(this.data.metadata?.defaults?.modal || {}),
};
this.rememberState = !!this.modal.rememberLastState;
this.stateKey = this.data.action?.formId
? `crud-dialog-state:${this.data.action.formId}`
: undefined;
// derivar path/id/mode
this.resourcePath =
this.data.metadata?.resource?.path ??
this.data.metadata?.table?.resourcePath;
if (this.resourcePath) {
this.crud.configure(this.resourcePath, this.data.metadata?.resource?.endpointKey);
}
this.idField = this.data.metadata?.resource?.idField ?? 'id';
this.resourceId = this.data.inputs?.[this.idField];
const act = this.data.action?.action;
this.mode = act === 'edit' ? 'edit' : act === 'view' ? 'view' : 'create';
// Back config: defaults from metadata/action, overridden by saved per-form config
const defaults = (this.data.action?.back || this.data.metadata?.defaults?.back) || {};
const saved = this.data.action?.formId
? this.configStorage.loadConfig(`crud-back:${this.data.action.formId}`)
: null;
this.backConfig = { ...defaults, ...(saved || {}) };
console.debug('[CRUD:Host] constructed', {
action: this.data?.action,
resourcePath: this.resourcePath,
resourceId: this.resourceId,
mode: this.mode,
modal: this.modal,
backConfig: this.backConfig,
});
// Esc
if (!this.modal.disableCloseOnEsc) {
this.dialogRef
.keydownEvents()
.pipe(filter((e) => e.key === 'Escape'), takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.onCancel());
}
// Backdrop
if (!this.modal.disableCloseOnBackdrop) {
this.dialogRef
.backdropClick()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.onCancel());
}
// Salvar estado ao fechar, se aplicável
this.dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.saveState());
}
ngOnInit() {
// Carregar estado salvo (se habilitado)
const saved = this.rememberState && this.stateKey
? this.configStorage.loadConfig(this.stateKey)
: null;
this.initialSize = {
width: saved?.width ?? this.modal.width,
height: saved?.height ?? this.modal.height,
};
console.debug('[CRUD:Host] ngOnInit', {
initialSize: this.initialSize,
startMaximized: this.modal.startMaximized,
fullscreenBreakpoint: this.modal.fullscreenBreakpoint,
});
let shouldMax = false;
if (saved && typeof saved.maximized === 'boolean') {
shouldMax = !!saved.maximized;
}
else {
shouldMax =
this.modal.startMaximized ||
(this.modal.fullscreenBreakpoint &&
window.innerWidth <= this.modal.fullscreenBreakpoint);
}
if (shouldMax) {
this.toggleMaximize(true);
}
else if (this.initialSize.width || this.initialSize.height) {
this.dialogRef.updateSize(this.initialSize.width, this.initialSize.height);
this.dialogRef.updatePosition();
}
}
onSave(result) {
if (this.modal.closeOnSave === false) {
// Não fechar: manter aberto e opcionalmente salvar estado atual
this.saveState();
return;
}
this.dialogRef.close({ type: 'save', data: result });
}
onCancel() {
const dirty = this.formComp?.form.dirty;
const backCfg = (this.data.action?.back || this.data.metadata?.defaults?.back) || {};
const confirm = backCfg.confirmOnDirty ?? true;
if (dirty && confirm) {
const ref = this.dialogService.open(ConfirmDialogComponent, {
data: {
title: this.texts.discardTitle,
message: this.texts.discardMessage,
confirmText: this.texts.discardConfirm,
cancelText: this.texts.discardCancel,
type: 'warning',
},
});
ref
.afterClosed()
.pipe(filter((confirmed) => !!confirmed), takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.dialogRef.close());
}
else {
this.dialogRef.close();
}
}
toggleMaximize(initial = false) {
this.maximized = initial ? true : !this.maximized;
const gap = this.maximized ? this.modal.edgeGap ?? 8 : undefined;
const width = this.maximized
? `calc(100vw - ${2 * (gap ?? 0)}px)`
: this.initialSize.width;
const height = this.maximized
? `calc(100dvh - ${2 * (gap ?? 0)}px)`
: this.initialSize.height;
this.dialogRef.updateSize(width, height);
this.dialogRef.updatePosition();
const pane = this.dialogRef
?._containerInstance?._elementRef?.nativeElement?.parentElement;
if (pane && pane.classList.contains('pfx-dialog-pane')) {
pane.style.margin = this.maximized ? `${gap}px` : '';
}
this.saveState();
}
saveState() {
if (!this.rememberState || !this.stateKey)
return;
const pane = this.dialogRef
?._containerInstance?._elementRef?.nativeElement?.parentElement;
const style = pane ? getComputedStyle(pane) : null;
const currentWidth = style?.width || this.initialSize.width;
const currentHeight = style?.height || this.initialSize.height;
this.configStorage.saveConfig(this.stateKey, {
maximized: this.maximized,
width: currentWidth,
height: currentHeight,
});
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: DynamicFormDialogHostComponent, deps: [{ token: MatDialogRef }, { token: MAT_DIALOG_DATA }, { token: DialogService }, { token: i2.GenericCrudService }, { token: CONFIG_STORAGE }], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.1.4", type: DynamicFormDialogHostComponent, isStandalone: true, selector: "praxis-dynamic-form-dialog-host", host: { properties: { "attr.data-density": "modal.density || \"default\"" }, classAttribute: "praxis-dialog" }, providers: [GenericCrudService], viewQueries: [{ propertyName: "formComp", first: true, predicate: PraxisDynamicForm, descendants: true }], ngImport: i0, template: `
<div class="dialog-header">
<h2 id="crudDialogTitle" class="dialog-title">
{{ data.action?.label || texts.title }}
</h2>
<span class="spacer"></span>
@if (modal.canMaximize) {
<button
mat-icon-button
type="button"
(click)="toggleMaximize()"
[attr.aria-label]="maximized ? texts.restoreLabel : texts.maximizeLabel"
>
<mat-icon [praxisIcon]="maximized ? 'close_fullscreen' : 'open_in_full'"></mat-icon>
</button>
}
<button
mat-icon-button
type="button"
(click)="onCancel()"
[attr.aria-label]="texts.closeLabel"
cdkFocusInitial
>
<mat-icon [praxisIcon]="'close'"></mat-icon>
</button>
</div>
<mat-dialog-content
class="dialog-content"
aria-labelledby="crudDialogTitle"
>
<praxis-dynamic-form
[formId]="data.action?.formId"
[resourcePath]="resourcePath"
[resourceId]="resourceId"
[mode]="mode"
[presentationModeGlobal]="mode === 'view' ? true : null"
[backConfig]="backConfig"
(formSubmit)="onSave($event)"
(formCancel)="onCancel()"
></praxis-dynamic-form>
</mat-dialog-content>
`, isInline: true, styles: [":host{--dlg-header-h: 56px;--dlg-footer-h: 56px;--dlg-pad: 16px;display:flex;flex-direction:column;height:100%;overflow:hidden}:host([data-density=\"compact\"]){--dlg-header-h: 44px;--dlg-footer-h: 44px;--dlg-pad: 12px}.dialog-header{position:sticky;top:0;z-index:1;display:flex;align-items:center;gap:var(--dlg-pad);padding:0 var(--dlg-pad);height:var(--dlg-header-h);background:var(--md-sys-color-surface-container-high);border-bottom:1px solid var(--md-sys-color-outline-variant);color:var(--md-sys-color-on-surface)}.dialog-title{margin:0;font:inherit;font-weight:600;color:var(--md-sys-color-on-surface)}.spacer{flex:1}.dialog-content{overflow:auto;padding:var(--dlg-pad);max-height:calc(100svh - var(--dlg-header-h) - 32px)}.dialog-header button.mat-icon-button{color:var(--md-sys-color-on-surface-variant)}.dialog-header button.mat-icon-button:hover{color:var(--md-sys-color-primary);background:color-mix(in srgb,var(--md-sys-color-primary),transparent 90%)}.dialog-footer{position:sticky;bottom:0;z-index:1;padding:var(--dlg-pad)}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: MatDialogModule }, { kind: "directive", type: i1.MatDialogContent, selector: "[mat-dialog-content], mat-dialog-content, [matDialogContent]" }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i4.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i5.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "directive", type: PraxisIconDirective, selector: "mat-icon[praxisIcon]", inputs: ["praxisIcon"] }, { kind: "component", type: PraxisDynamicForm, selector: "praxis-dynamic-form", inputs: ["resourcePath", "resourceId", "mode", "config", "schemaSource", "editModeEnabled", "formId", "layout", "backConfig", "hooks", "removeEmptyContainersOnSave", "reactiveValidation", "reactiveValidationDebounceMs", "notifyIfOutdated", "snoozeMs", "autoOpenSettingsOnOutdated", "readonlyModeGlobal", "disabledModeGlobal", "presentationModeGlobal", "visibleGlobal", "customEndpoints"], outputs: ["formSubmit", "formCancel", "formReset", "configChange", "formReady", "valueChange", "syncCompleted", "initializationError", "editModeEnabledChange", "customAction", "actionConfirmation", "schemaStatusChange"] }] });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: DynamicFormDialogHostComponent, decorators: [{
type: Component,
args: [{ selector: 'praxis-dynamic-form-dialog-host', standalone: true, imports: [
CommonModule,
MatDialogModule,
MatButtonModule,
MatIconModule,
PraxisIconDirective,
PraxisDynamicForm,
], providers: [GenericCrudService], host: {
class: 'praxis-dialog',
'[attr.data-density]': 'modal.density || "default"',
}, template: `
<div class="dialog-header">
<h2 id="crudDialogTitle" class="dialog-title">
{{ data.action?.label || texts.title }}
</h2>
<span class="spacer"></span>
@if (modal.canMaximize) {
<button
mat-icon-button
type="button"
(click)="toggleMaximize()"
[attr.aria-label]="maximized ? texts.restoreLabel : texts.maximizeLabel"
>
<mat-icon [praxisIcon]="maximized ? 'close_fullscreen' : 'open_in_full'"></mat-icon>
</button>
}
<button
mat-icon-button
type="button"
(click)="onCancel()"
[attr.aria-label]="texts.closeLabel"
cdkFocusInitial
>
<mat-icon [praxisIcon]="'close'"></mat-icon>
</button>
</div>
<mat-dialog-content
class="dialog-content"
aria-labelledby="crudDialogTitle"
>
<praxis-dynamic-form
[formId]="data.action?.formId"
[resourcePath]="resourcePath"
[resourceId]="resourceId"
[mode]="mode"
[presentationModeGlobal]="mode === 'view' ? true : null"
[backConfig]="backConfig"
(formSubmit)="onSave($event)"
(formCancel)="onCancel()"
></praxis-dynamic-form>
</mat-dialog-content>
`, styles: [":host{--dlg-header-h: 56px;--dlg-footer-h: 56px;--dlg-pad: 16px;display:flex;flex-direction:column;height:100%;overflow:hidden}:host([data-density=\"compact\"]){--dlg-header-h: 44px;--dlg-footer-h: 44px;--dlg-pad: 12px}.dialog-header{position:sticky;top:0;z-index:1;display:flex;align-items:center;gap:var(--dlg-pad);padding:0 var(--dlg-pad);height:var(--dlg-header-h);background:var(--md-sys-color-surface-container-high);border-bottom:1px solid var(--md-sys-color-outline-variant);color:var(--md-sys-color-on-surface)}.dialog-title{margin:0;font:inherit;font-weight:600;color:var(--md-sys-color-on-surface)}.spacer{flex:1}.dialog-content{overflow:auto;padding:var(--dlg-pad);max-height:calc(100svh - var(--dlg-header-h) - 32px)}.dialog-header button.mat-icon-button{color:var(--md-sys-color-on-surface-variant)}.dialog-header button.mat-icon-button:hover{color:var(--md-sys-color-primary);background:color-mix(in srgb,var(--md-sys-color-primary),transparent 90%)}.dialog-footer{position:sticky;bottom:0;z-index:1;padding:var(--dlg-pad)}\n"] }]
}], ctorParameters: () => [{ type: undefined, decorators: [{
type: Inject,
args: [MatDialogRef]
}] }, { type: undefined, decorators: [{
type: Inject,
args: [MAT_DIALOG_DATA]
}] }, { type: DialogService }, { type: i2.GenericCrudService }, { type: undefined, decorators: [{
type: Inject,
args: [CONFIG_STORAGE]
}] }], propDecorators: { formComp: [{
type: ViewChild,
args: [PraxisDynamicForm]
}] } });
var dynamicFormDialogHost_component = /*#__PURE__*/Object.freeze({
__proto__: null,
DynamicFormDialogHostComponent: DynamicFormDialogHostComponent
});
/** Metadata for PraxisCrudComponent */
const PRAXIS_CRUD_COMPONENT_METADATA = {
id: 'praxis-crud',
selector: 'praxis-crud',
component: PraxisCrudComponent,
friendlyName: 'Praxis CRUD',
description: 'Tabela com operações de CRUD via metadados.',
icon: 'table_chart',
inputs: [
{
name: 'metadata',
type: 'CrudMetadata | string',
description: 'Metadados de configuração do CRUD',
},
{
name: 'context',
type: 'Record<string, unknown>',
description: 'Contexto adicional para resoluções',
},
{
name: 'editModeEnabled',
type: 'boolean',
default: false,
description: 'Habilita modo de edição do layout',
},
],
outputs: [
{
name: 'afterOpen',
type: '{ mode: FormOpenMode; action: string }',
description: 'Emitido após abrir diálogo',
},
{
name: 'afterClose',
type: 'void',
description: 'Emitido após fechar diálogo',
},
{
name: 'afterSave',
type: '{ id: string | number; data: unknown }',
description: 'Emitido ao salvar',
},
{
name: 'afterDelete',
type: '{ id: string | number }',
description: 'Emitido ao deletar',
},
{ name: 'error', type: 'unknown', description: 'Emitido em erros' },
{
name: 'configureRequested',
type: 'void',
description: 'Emitido quando CTA de configuração é acionado em modo edição',
},
],
tags: ['widget', 'crud', 'configurable', 'hasWizard', 'stable'],
lib: '@praxisui/crud',
};
/** Provider para auto-registrar metadados do componente CRUD. */
function providePraxisCrudMetadata() {
return {
provide: ENVIRONMENT_INITIALIZER,
multi: true,
useFactory: (registry) => () => {
registry.register(PRAXIS_CRUD_COMPONENT_METADATA);
},
deps: [ComponentMetadataRegistry],
};
}
class CrudPageHeaderComponent {
title = '';
backLabel;
showBack = true;
variant = 'ghost';
sticky = true;
divider = true;
returnTo;
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: CrudPageHeaderComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.1.4", type: CrudPageHeaderComponent, isStandalone: true, selector: "praxis-crud-page-header", inputs: { title: "title", backLabel: "backLabel", showBack: "showBack", variant: "variant", sticky: "sticky", divider: "divider", returnTo: "returnTo" }, ngImport: i0, template: `
<header
class="crud-header"
[class.sticky]="sticky"
[class.with-divider]="divider"
>
<div class="left">
<a
*ngIf="showBack && returnTo"
class="back-btn"
[class.ghost]="variant === 'ghost'"
[class.tonal]="variant === 'tonal'"
[class.outlined]="variant === 'outlined'"
[routerLink]="returnTo"
[attr.aria-label]="backLabel || 'Voltar'"
mat-button
>
<mat-icon [praxisIcon]="'arrow_back'"></mat-icon>
<span class="label" [class.hide-on-narrow]="true">{{ backLabel || 'Voltar' }}</span>
</a>
<h2 class="title" [attr.title]="title">{{ title }}</h2>
</div>
<div class="right">
<ng-content></ng-content>
</div>
</header>
`, isInline: true, styles: [".crud-header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 0;background:var(--md-sys-color-surface, transparent)}.crud-header.sticky{position:sticky;top:0;z-index:10;-webkit-backdrop-filter:saturate(110%);backdrop-filter:saturate(110%)}.crud-header.with-divider{border-bottom:1px solid var(--md-sys-color-outline-variant, rgba(0,0,0,.08))}.left{display:flex;align-items:center;gap:8px;min-width:0}.right{display:flex;align-items:center;gap:8px}.title{margin:0;font-weight:600;color:var(--md-sys-color-on-surface, currentColor);font:var(--mdc-typography-title-large, 600 20px/28px system-ui);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.back-btn{display:inline-flex;align-items:center;gap:6px;text-decoration:none}.back-btn .mat-mdc-button{padding-left:0}.back-btn mat-icon{color:var(--md-sys-color-on-surface-variant, currentColor)}.back-btn .label{color:var(--md-sys-color-on-surface-variant, currentColor)}.back-btn.ghost{border-radius:20px;padding:4px 8px}.back-btn.ghost:hover{background:color-mix(in srgb,var(--md-sys-color-primary) 8%,transparent)}.back-btn.tonal{border-radius:20px;padding:4px 10px;background:var(--md-sys-color-surface-container, rgba(0,0,0,.02));box-shadow:inset 0 0 0 1px var(--md-sys-color-outline-variant, rgba(0,0,0,.06))}.back-btn.tonal:hover{background:color-mix(in srgb,var(--md-sys-color-primary) 10%,var(--md-sys-color-surface-container))}.back-btn.outlined{border-radius:20px;padding:4px 10px;border:1px solid var(--md-sys-color-outline-variant, rgba(0,0,0,.12));background:var(--md-sys-color-surface, transparent)}.back-btn.outlined:hover{border-color:color-mix(in srgb,var(--md-sys-color-primary) 40%,var(--md-sys-color-outline-variant));background:color-mix(in srgb,var(--md-sys-color-primary) 6%,transparent)}@media (max-width: 599px){.label.hide-on-narrow{display:none}}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1$1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i4.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i5.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "directive", type: PraxisIconDirective, selector: "mat-icon[praxisIcon]", inputs: ["praxisIcon"] }] });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: CrudPageHeaderC