UNPKG

@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
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