@praxisui/metadata-editor 10.0.0-rc.37 → 10.0.0-rc.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@ import * as i3$2 from '@angular/common';
6
6
  import { CommonModule } from '@angular/common';
7
7
  import { isValidCssColor, DynamicFieldLoaderDirective, normalizeCssColorToRgb, providePraxisDynamicFieldsI18n } from '@praxisui/dynamic-fields';
8
8
  import * as i2$2 from '@praxisui/core';
9
- import { providePraxisI18n, PraxisI18nService, PraxisIconDirective, FieldControlType, INLINE_FILTER_CONTROL_TYPES, resolveInlineFilterControlType, ResourceDiscoveryService, normalizeControlTypeToken, providePraxisI18nConfig, RESOURCE_DISCOVERY_I18N_CONFIG, normalizeReactiveDeterminations, ComponentMetadataRegistry } from '@praxisui/core';
9
+ import { providePraxisI18n, PraxisI18nService, PraxisIconDirective, FieldControlType, INLINE_FILTER_CONTROL_TYPES, resolveInlineFilterControlType, ResourceDiscoveryService, mapCatalogFieldsToMetadata, humanizeCatalogFieldName, normalizeControlTypeToken, hasSemanticFieldCalibrationDiff, calibrateFieldMetadataFromSemantics, providePraxisI18nConfig, RESOURCE_DISCOVERY_I18N_CONFIG, normalizeReactiveDeterminations, ComponentMetadataRegistry } from '@praxisui/core';
10
10
  import * as i5$1 from '@angular/material/expansion';
11
11
  import { MatExpansionModule } from '@angular/material/expansion';
12
12
  import * as i2$1 from '@angular/material/icon';
@@ -653,6 +653,8 @@ const METADATA_EDITOR_EN_US = {
653
653
  'praxis.metadataEditor.colorPalette.moveDown': 'Move down',
654
654
  'praxis.metadataEditor.colorPalette.remove': 'Remove',
655
655
  'praxis.metadataEditor.colorPalette.invalid': 'Enter a valid CSS color or var(--token).',
656
+ 'praxis.metadataEditor.smartCalibrate.action': 'Calibrate from schema',
657
+ 'praxis.metadataEditor.smartCalibrate.tooltip': 'Automatically infers control type (Currency, CPF/CNPJ, Date, Email, Phone, Select, Textarea), humanized label, and corporate defaults from field name and metadata.',
656
658
  };
657
659
 
658
660
  const METADATA_EDITOR_PT_BR = {
@@ -905,6 +907,8 @@ const METADATA_EDITOR_PT_BR = {
905
907
  'praxis.metadataEditor.colorPalette.moveDown': 'Mover para baixo',
906
908
  'praxis.metadataEditor.colorPalette.remove': 'Remover',
907
909
  'praxis.metadataEditor.colorPalette.invalid': 'Informe uma cor CSS válida ou var(--token).',
910
+ 'praxis.metadataEditor.smartCalibrate.action': 'Calibrar pelo schema',
911
+ 'praxis.metadataEditor.smartCalibrate.tooltip': 'Infere automaticamente o tipo de controle (Moeda, CPF/CNPJ, Data, E-mail, Telefone, Seleção, Texto longo), rótulo humanizado e padrões corporativos a partir do nome e metadados do campo.',
908
912
  };
909
913
 
910
914
  function createPraxisMetadataEditorI18nConfig(options = {}) {
@@ -10925,6 +10929,7 @@ class FieldMetadataEditorComponent {
10925
10929
  renderedProps = [];
10926
10930
  initialSnapshot = null;
10927
10931
  initialControlType;
10932
+ initialSeed;
10928
10933
  baselineReady = false;
10929
10934
  formRebuild$ = new Subject();
10930
10935
  hostApplyPatch;
@@ -11104,17 +11109,86 @@ class FieldMetadataEditorComponent {
11104
11109
  if (!option?.responseFields?.length) {
11105
11110
  continue;
11106
11111
  }
11107
- const key = this.normalizeResourceCacheKey(option.value);
11108
- if (key && !this.resourceSchemaFieldsByPath.has(key)) {
11109
- this.resourceSchemaFieldsByPath.set(key, option.responseFields
11110
- .filter((field) => Boolean(field?.name && String(field.name).trim()))
11111
- .map((field) => ({
11112
- name: String(field.name).trim(),
11113
- label: String(field.label ?? '').trim() || String(field.name).trim(),
11114
- type: String(field.type ?? field.controlType ?? 'string').trim() || 'string',
11115
- controlType: String(field.controlType ?? '').trim() || undefined,
11112
+ const mappedMetas = mapCatalogFieldsToMetadata(option.responseFields);
11113
+ const metaByName = new Map(mappedMetas.map((meta) => [meta.name, meta]));
11114
+ const definitions = option.responseFields
11115
+ .filter((field) => Boolean(field?.name && String(field.name).trim()))
11116
+ .map((field) => {
11117
+ const name = String(field.name).trim();
11118
+ const rawLabel = String(field.label ?? '').trim();
11119
+ const label = rawLabel && rawLabel !== name
11120
+ ? rawLabel
11121
+ : humanizeCatalogFieldName(name);
11122
+ const mapped = metaByName.get(name);
11123
+ return {
11124
+ name,
11125
+ label,
11126
+ type: String(field.type ?? field.controlType ?? 'string').trim() ||
11127
+ 'string',
11128
+ controlType: String(field.controlType ?? mapped?.controlType ?? '').trim() ||
11129
+ undefined,
11116
11130
  description: String(field.description ?? '').trim() || undefined,
11117
- })));
11131
+ ...(typeof field.format === 'string' && field.format.trim()
11132
+ ? { format: field.format.trim() }
11133
+ : {}),
11134
+ ...(typeof field.required === 'boolean'
11135
+ ? { required: field.required }
11136
+ : {}),
11137
+ ...(typeof field.minimum === 'number'
11138
+ ? { minimum: field.minimum }
11139
+ : {}),
11140
+ ...(typeof field.maximum === 'number'
11141
+ ? { maximum: field.maximum }
11142
+ : {}),
11143
+ ...(typeof field.minLength === 'number'
11144
+ ? { minLength: field.minLength }
11145
+ : {}),
11146
+ ...(typeof field.maxLength === 'number'
11147
+ ? { maxLength: field.maxLength }
11148
+ : {}),
11149
+ ...(typeof field.pattern === 'string' && field.pattern.trim()
11150
+ ? { pattern: field.pattern.trim() }
11151
+ : {}),
11152
+ ...(Array.isArray(field.enumValues)
11153
+ ? { enumValues: field.enumValues }
11154
+ : {}),
11155
+ ...(mapped?.options?.length
11156
+ ? {
11157
+ options: mapped.options.map((opt) => {
11158
+ const optLabel = String(opt.label ?? opt.text ?? opt.value);
11159
+ return {
11160
+ key: String(opt.value),
11161
+ value: String(opt.value),
11162
+ label: optLabel,
11163
+ text: opt.text ?? optLabel,
11164
+ };
11165
+ }),
11166
+ }
11167
+ : {}),
11168
+ };
11169
+ });
11170
+ if (!definitions.length) {
11171
+ continue;
11172
+ }
11173
+ const candidateKeys = new Set();
11174
+ for (const candidate of [option.value, option.path, option.resourceKey]) {
11175
+ const raw = String(candidate ?? '').trim();
11176
+ if (!raw)
11177
+ continue;
11178
+ const normalized = this.normalizeResourceCacheKey(raw);
11179
+ if (normalized)
11180
+ candidateKeys.add(normalized);
11181
+ const stripped = raw.replace(/^\/+|\/+$/g, '');
11182
+ if (stripped)
11183
+ candidateKeys.add(stripped);
11184
+ const withoutApi = stripped.replace(/^api\/+/i, '');
11185
+ if (withoutApi)
11186
+ candidateKeys.add(withoutApi);
11187
+ }
11188
+ for (const cacheKey of candidateKeys) {
11189
+ if (!this.resourceSchemaFieldsByPath.has(cacheKey)) {
11190
+ this.resourceSchemaFieldsByPath.set(cacheKey, definitions);
11191
+ }
11118
11192
  }
11119
11193
  }
11120
11194
  }
@@ -11256,6 +11330,9 @@ class FieldMetadataEditorComponent {
11256
11330
  if (this.initialControlType === undefined) {
11257
11331
  this.initialControlType = ct;
11258
11332
  }
11333
+ if (this.initialSeed === undefined) {
11334
+ this.initialSeed = this.cloneSnapshot(this.seed ?? {});
11335
+ }
11259
11336
  this.controlType = ct;
11260
11337
  let props = this.registry.getProperties(this.controlType);
11261
11338
  const controlTypeToken = normalizeControlTypeToken(this.controlType);
@@ -11999,6 +12076,9 @@ class FieldMetadataEditorComponent {
11999
12076
  }
12000
12077
  }
12001
12078
  reset() {
12079
+ if (this.initialSeed !== undefined) {
12080
+ this.seed = this.cloneSnapshot(this.initialSeed);
12081
+ }
12002
12082
  if (this.initialControlType !== undefined && this.controlType !== this.initialControlType) {
12003
12083
  this.rebuildEditorForType(this.initialControlType);
12004
12084
  }
@@ -12019,6 +12099,79 @@ class FieldMetadataEditorComponent {
12019
12099
  if (value != null)
12020
12100
  this.onControlTypeChange(value);
12021
12101
  }
12102
+ tx(key, fallback) {
12103
+ const namespacedKey = key.startsWith('praxis.metadataEditor.')
12104
+ ? key
12105
+ : `praxis.metadataEditor.${key}`;
12106
+ return this.i18n?.t(namespacedKey, undefined, fallback) ?? fallback;
12107
+ }
12108
+ buildCurrentSemanticFieldSnapshot() {
12109
+ const currentRaw = this.form?.getRawValue?.() ?? {};
12110
+ const merged = {
12111
+ ...(this.seed || {}),
12112
+ };
12113
+ for (const [k, v] of Object.entries(currentRaw)) {
12114
+ if (v !== undefined && v !== null && v !== '') {
12115
+ merged[k] = v;
12116
+ }
12117
+ }
12118
+ merged['name'] = String(currentRaw?.name ?? this.seed?.name ?? '').trim();
12119
+ merged['controlType'] = this.controlType;
12120
+ return merged;
12121
+ }
12122
+ canCalibrateFromSemantics() {
12123
+ const snapshot = this.buildCurrentSemanticFieldSnapshot();
12124
+ return hasSemanticFieldCalibrationDiff(snapshot);
12125
+ }
12126
+ calibrateFromSemantics() {
12127
+ const mergedField = this.buildCurrentSemanticFieldSnapshot();
12128
+ if (!hasSemanticFieldCalibrationDiff(mergedField)) {
12129
+ return;
12130
+ }
12131
+ const calibrated = calibrateFieldMetadataFromSemantics(mergedField);
12132
+ this.seed = {
12133
+ ...(this.seed || {}),
12134
+ ...calibrated,
12135
+ };
12136
+ const nextControlType = (calibrated.controlType ?? this.controlType);
12137
+ if (nextControlType && nextControlType !== this.controlType) {
12138
+ this.rebuildEditorForType(nextControlType);
12139
+ }
12140
+ const getByPath = (obj, path) => {
12141
+ if (!obj || typeof obj !== 'object' || !path)
12142
+ return undefined;
12143
+ return path.split('.').reduce((acc, part) => (acc == null ? undefined : acc[part]), obj);
12144
+ };
12145
+ const formPatch = {};
12146
+ for (const prop of this.normalizedProps || []) {
12147
+ const propName = String(prop?.name ?? '').trim();
12148
+ if (!propName)
12149
+ continue;
12150
+ const rawVal = getByPath(calibrated, propName);
12151
+ if (rawVal === undefined)
12152
+ continue;
12153
+ const controlVal = this.toEditorControlValue(prop, rawVal);
12154
+ if (controlVal !== undefined && controlVal !== null) {
12155
+ try {
12156
+ this.factory.setValueByPath(this.form, propName, controlVal);
12157
+ }
12158
+ catch { }
12159
+ if (!propName.includes('.') && this.form?.get(propName)) {
12160
+ formPatch[propName] = controlVal;
12161
+ }
12162
+ }
12163
+ }
12164
+ if (calibrated.options !== undefined &&
12165
+ !this.form?.get('options') &&
12166
+ this.form?.get('providerOptions')) {
12167
+ const prop = this.normalizedProps?.find((p) => p?.name === 'providerOptions');
12168
+ formPatch['providerOptions'] = this.toEditorControlValue(prop, calibrated.options);
12169
+ }
12170
+ if (Object.keys(formPatch).length > 0) {
12171
+ this.form.patchValue(formPatch, { emitEvent: true });
12172
+ }
12173
+ this.isDirty$.next(true);
12174
+ }
12022
12175
  withSharedFieldMetadataProperties(props) {
12023
12176
  const propNames = new Set(props.map((prop) => prop.name));
12024
12177
  const sharedProps = [...fieldPresentationProperties, ...fieldSubmitProperties];
@@ -12323,6 +12476,7 @@ class FieldMetadataEditorComponent {
12323
12476
  const statusKey = findExact([
12324
12477
  'status',
12325
12478
  'situacao',
12479
+ 'situacaofuncional',
12326
12480
  'ativo',
12327
12481
  'active',
12328
12482
  'estado',
@@ -12460,6 +12614,20 @@ class FieldMetadataEditorComponent {
12460
12614
  ], ngImport: i0, template: `
12461
12615
  @if (form) {
12462
12616
  <div class="p-3">
12617
+ @if (canCalibrateFromSemantics()) {
12618
+ <div class="d-flex justify-content-end mb-2">
12619
+ <button
12620
+ mat-stroked-button
12621
+ type="button"
12622
+ data-testid="field-metadata-smart-calibrate-btn"
12623
+ (click)="calibrateFromSemantics()"
12624
+ [matTooltip]="tx('smartCalibrate.tooltip', 'Infere automaticamente o tipo de controle, rótulo humanizado e padrões corporativos a partir do nome e metadados do campo.')"
12625
+ >
12626
+ <mat-icon aria-hidden="true">auto_fix_high</mat-icon>
12627
+ {{ tx('smartCalibrate.action', 'Calibrar pelo schema') }}
12628
+ </button>
12629
+ </div>
12630
+ }
12463
12631
  <praxis-dynamic-editor-renderer
12464
12632
  [properties]="renderedProps"
12465
12633
  [form]="form"
@@ -12472,7 +12640,7 @@ class FieldMetadataEditorComponent {
12472
12640
  }
12473
12641
  </div>
12474
12642
  }
12475
- `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "component", type: DynamicEditorRendererComponent, selector: "praxis-dynamic-editor-renderer", inputs: ["properties", "form"] }, { kind: "component", type: ArrayItemSchemaFieldsEditorComponent, selector: "praxis-array-item-schema-fields-editor", inputs: ["control", "arrayControl"] }] });
12643
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i1$2.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: i2$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i3$1.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: DynamicEditorRendererComponent, selector: "praxis-dynamic-editor-renderer", inputs: ["properties", "form"] }, { kind: "component", type: ArrayItemSchemaFieldsEditorComponent, selector: "praxis-array-item-schema-fields-editor", inputs: ["control", "arrayControl"] }] });
12476
12644
  }
12477
12645
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: FieldMetadataEditorComponent, decorators: [{
12478
12646
  type: Component,
@@ -12481,6 +12649,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
12481
12649
  standalone: true,
12482
12650
  imports: [
12483
12651
  ReactiveFormsModule,
12652
+ MatButtonModule,
12653
+ MatIconModule,
12654
+ MatTooltipModule,
12484
12655
  DynamicEditorRendererComponent,
12485
12656
  ArrayItemSchemaFieldsEditorComponent
12486
12657
  ],
@@ -12492,6 +12663,20 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
12492
12663
  template: `
12493
12664
  @if (form) {
12494
12665
  <div class="p-3">
12666
+ @if (canCalibrateFromSemantics()) {
12667
+ <div class="d-flex justify-content-end mb-2">
12668
+ <button
12669
+ mat-stroked-button
12670
+ type="button"
12671
+ data-testid="field-metadata-smart-calibrate-btn"
12672
+ (click)="calibrateFromSemantics()"
12673
+ [matTooltip]="tx('smartCalibrate.tooltip', 'Infere automaticamente o tipo de controle, rótulo humanizado e padrões corporativos a partir do nome e metadados do campo.')"
12674
+ >
12675
+ <mat-icon aria-hidden="true">auto_fix_high</mat-icon>
12676
+ {{ tx('smartCalibrate.action', 'Calibrar pelo schema') }}
12677
+ </button>
12678
+ </div>
12679
+ }
12495
12680
  <praxis-dynamic-editor-renderer
12496
12681
  [properties]="renderedProps"
12497
12682
  [form]="form"
@@ -12677,16 +12862,132 @@ class CascadeRulesService {
12677
12862
  dependencyLoadOnChange: null,
12678
12863
  };
12679
12864
  }
12680
- /** Suggest a basic mapping dep->dep and a default valuePath */
12865
+ /** Fill missing mappings and value paths without replacing authored values. */
12681
12866
  suggest(rule, inferredValueKey) {
12682
- const map = {};
12683
- (rule.dependencyFields || []).forEach((dep) => (map[dep] = dep));
12867
+ const map = { ...rule.dependencyFilterMap };
12868
+ (rule.dependencyFields || []).forEach((dep) => {
12869
+ if (!Object.prototype.hasOwnProperty.call(map, dep))
12870
+ map[dep] = dep;
12871
+ });
12684
12872
  return {
12685
12873
  ...rule,
12686
12874
  dependencyFilterMap: Object.keys(map).length ? map : rule.dependencyFilterMap,
12687
12875
  dependencyValuePath: rule.dependencyValuePath || inferredValueKey || 'id',
12688
12876
  };
12689
12877
  }
12878
+ /**
12879
+ * Validates the cascade dependency graph directly from an array of FieldMetadata/FieldDefinition.
12880
+ */
12881
+ validateFieldsCascadeGraph(fields) {
12882
+ const rules = new Map();
12883
+ for (const field of fields || []) {
12884
+ const vm = this.hydrateRule(field);
12885
+ if (vm) {
12886
+ rules.set(field.name, vm);
12887
+ }
12888
+ }
12889
+ return this.validateCascadeGraph(rules, fields);
12890
+ }
12891
+ /**
12892
+ * Detects self-references, direct cycles (A -> B -> A) and indirect cycles (A -> B -> C -> A),
12893
+ * including graphs where fields have multiple parent dependencies.
12894
+ */
12895
+ validateCascadeGraph(rules, fields = []) {
12896
+ const ruleEntries = rules instanceof Map ? Array.from(rules.entries()) : Object.entries(rules || {});
12897
+ const labelMap = this.getFieldLabelMap([...fields]);
12898
+ const formatNode = (name) => {
12899
+ const label = labelMap.get(name);
12900
+ return label && label !== name ? `${label} (${name})` : name;
12901
+ };
12902
+ const adjacency = new Map();
12903
+ for (const [target, rule] of ruleEntries) {
12904
+ if (!rule || rule.enableDependencyCascade === false)
12905
+ continue;
12906
+ const deps = (rule.dependencyFields || [])
12907
+ .filter((dep) => typeof dep === 'string')
12908
+ .map((dep) => dep.trim())
12909
+ .filter(Boolean);
12910
+ if (deps.length > 0) {
12911
+ adjacency.set(target, Array.from(new Set(deps)));
12912
+ }
12913
+ }
12914
+ const cycles = [];
12915
+ const fieldsInCycles = new Set();
12916
+ const recordedCycleSignatures = new Set();
12917
+ const canonicalizeCycle = (cyclePath) => {
12918
+ const loop = cyclePath.slice(0, -1);
12919
+ if (loop.length <= 1)
12920
+ return loop.join('->');
12921
+ let minIdx = 0;
12922
+ for (let i = 1; i < loop.length; i++) {
12923
+ if (loop[i].localeCompare(loop[minIdx]) < 0) {
12924
+ minIdx = i;
12925
+ }
12926
+ }
12927
+ const rotated = [...loop.slice(minIdx), ...loop.slice(0, minIdx)];
12928
+ return rotated.join('->');
12929
+ };
12930
+ const recordCycle = (rawCyclePath) => {
12931
+ const signature = canonicalizeCycle(rawCyclePath);
12932
+ if (recordedCycleSignatures.has(signature))
12933
+ return;
12934
+ recordedCycleSignatures.add(signature);
12935
+ const loop = rawCyclePath.slice(0, -1);
12936
+ loop.forEach((f) => fieldsInCycles.add(f));
12937
+ const isSelfReference = loop.length === 1;
12938
+ const formattedPath = rawCyclePath.map(formatNode).join(' → ');
12939
+ const lastParent = rawCyclePath[rawCyclePath.length - 2];
12940
+ const closingField = rawCyclePath[rawCyclePath.length - 1];
12941
+ const message = isSelfReference
12942
+ ? `Autorreferência inválida: o campo ${formatNode(closingField)} não pode depender de si mesmo. Remova "${closingField}" da lista de campos pais.`
12943
+ : `Dependência circular detectada: ${formattedPath}. Para corrigir, remova a dependência entre ${formatNode(lastParent)} e ${formatNode(closingField)} em um dos campos do ciclo.`;
12944
+ cycles.push({
12945
+ path: rawCyclePath,
12946
+ formattedPath,
12947
+ isSelfReference,
12948
+ message,
12949
+ });
12950
+ };
12951
+ const sortedNodes = Array.from(adjacency.keys()).sort((a, b) => a.localeCompare(b));
12952
+ const globallyVisited = new Set();
12953
+ for (const startNode of sortedNodes) {
12954
+ const stack = [];
12955
+ const onStack = new Set();
12956
+ const dfs = (current) => {
12957
+ stack.push(current);
12958
+ onStack.add(current);
12959
+ const neighbors = adjacency.get(current) || [];
12960
+ for (const dep of neighbors) {
12961
+ if (dep === current) {
12962
+ recordCycle([current, current]);
12963
+ continue;
12964
+ }
12965
+ if (onStack.has(dep)) {
12966
+ const cycleStartIndex = stack.indexOf(dep);
12967
+ if (cycleStartIndex >= 0) {
12968
+ const cyclePath = [...stack.slice(cycleStartIndex), dep];
12969
+ recordCycle(cyclePath);
12970
+ }
12971
+ continue;
12972
+ }
12973
+ if (!globallyVisited.has(dep) && adjacency.has(dep)) {
12974
+ dfs(dep);
12975
+ }
12976
+ }
12977
+ stack.pop();
12978
+ onStack.delete(current);
12979
+ globallyVisited.add(current);
12980
+ };
12981
+ if (!globallyVisited.has(startNode)) {
12982
+ dfs(startNode);
12983
+ }
12984
+ }
12985
+ return {
12986
+ valid: cycles.length === 0,
12987
+ cycles,
12988
+ fieldsInCycles,
12989
+ };
12990
+ }
12690
12991
  normalizeDependencyFields(value) {
12691
12992
  if (Array.isArray(value)) {
12692
12993
  const deps = value
@@ -12715,6 +13016,7 @@ class CascadeManagerTabComponent {
12715
13016
  connections; // optional signal for conflicts
12716
13017
  apply = new EventEmitter();
12717
13018
  cancel = new EventEmitter();
13019
+ validChange = new EventEmitter();
12718
13020
  searchTerm = '';
12719
13021
  searchTimer = null;
12720
13022
  searchValue = '';
@@ -12729,12 +13031,14 @@ class CascadeManagerTabComponent {
12729
13031
  editLoadOn = 'respectLoadOn';
12730
13032
  editDebounce = 150;
12731
13033
  editValuePath = '';
12732
- editFilterMapJson = '';
12733
13034
  importMode = false;
12734
13035
  importJson = '';
12735
13036
  feedbackMessage = '';
12736
13037
  feedbackTone = 'info';
12737
13038
  editMapRows = [];
13039
+ editInitialFilterMap = {};
13040
+ editInitialValuePath = 'id';
13041
+ lastEmittedValid = null;
12738
13042
  setFeedback(message, tone = 'info') {
12739
13043
  this.feedbackMessage = message;
12740
13044
  this.feedbackTone = tone;
@@ -12755,20 +13059,17 @@ class CascadeManagerTabComponent {
12755
13059
  this.selectedFieldSig.set(this.fields[0].name);
12756
13060
  this.startEdit(this.fields[0].name);
12757
13061
  }
13062
+ this.syncValidityState();
12758
13063
  }
12759
13064
  ngOnChanges(changes) {
12760
13065
  if (changes['fields'] && !changes['fields'].firstChange) {
12761
- // Re-hydrate rules from incoming fields while preserving existing ones when possible
13066
+ // Re-hydrate rules from incoming fields; incoming fields own the canonical state
12762
13067
  const incoming = this.fields || [];
12763
13068
  const next = new Map();
12764
- // keep existing for fields still present
12765
13069
  for (const f of incoming) {
12766
- const existing = this.rules().get(f.name);
12767
13070
  const hydrated = this.svc.hydrateRule(f);
12768
13071
  if (hydrated)
12769
13072
  next.set(f.name, hydrated);
12770
- else if (existing)
12771
- next.set(f.name, existing);
12772
13073
  }
12773
13074
  this.rules.set(next);
12774
13075
  // preserve selection if still valid
@@ -12776,29 +13077,114 @@ class CascadeManagerTabComponent {
12776
13077
  if (sel && !incoming.some((f) => f.name === sel)) {
12777
13078
  this.selectedFieldSig.set(incoming[0]?.name ?? null);
12778
13079
  }
13080
+ const activeSel = this.selectedField();
13081
+ if (activeSel) {
13082
+ this.startEdit(activeSel);
13083
+ }
13084
+ this.syncValidityState();
12779
13085
  }
12780
13086
  }
12781
- _logCount = 0;
12782
13087
  filteredFields() {
12783
13088
  const q = (this.searchValue || '').toLowerCase();
12784
13089
  const labelMap = this.svc.getFieldLabelMap(this.fields);
12785
13090
  const result = this.fields
12786
13091
  .filter((f) => !q || f.name.toLowerCase().includes(q) || (labelMap.get(f.name) || '').toLowerCase().includes(q))
12787
- .sort((a, b) => (labelMap.get(a.name) || a.name).localeCompare(labelMap.get(b.name) || b.name));
13092
+ .sort((a, b) => {
13093
+ const labelCmp = (labelMap.get(a.name) || a.name).localeCompare(labelMap.get(b.name) || b.name);
13094
+ return labelCmp !== 0 ? labelCmp : a.name.localeCompare(b.name);
13095
+ });
12788
13096
  return result;
12789
13097
  }
12790
13098
  labelFor(name) {
12791
13099
  return this.svc.getFieldLabelMap(this.fields).get(name) || name;
12792
13100
  }
13101
+ /**
13102
+ * Disambiguates fields that share the same display label (e.g. "Cargo" for both `cargoId` and `cargoNome`).
13103
+ */
13104
+ disambiguatedLabelFor(name) {
13105
+ const baseLabel = this.labelFor(name);
13106
+ const duplicateCount = this.fields.filter((f) => (f.label || f.name).trim().toLowerCase() === baseLabel.trim().toLowerCase()).length;
13107
+ if (duplicateCount <= 1) {
13108
+ return baseLabel;
13109
+ }
13110
+ const roleHint = this.hasRemoteOptionSource(name) || /id$/i.test(name)
13111
+ ? 'Selecionável · ID'
13112
+ : 'Texto / Nome';
13113
+ return `${baseLabel} — ${roleHint}`;
13114
+ }
13115
+ hasRemoteOptionSource(name) {
13116
+ const f = this.fields.find((item) => item.name === name);
13117
+ if (!f)
13118
+ return false;
13119
+ return Boolean(f.resourcePath ||
13120
+ f.endpoint ||
13121
+ f.optionSource?.resourcePath ||
13122
+ f.optionSource?.key ||
13123
+ f.controlType === 'async-select' ||
13124
+ f.controlType === 'entity-lookup' ||
13125
+ f.controlType === 'select' ||
13126
+ f.controlType === 'multi-select');
13127
+ }
13128
+ fieldRoleBadge(name) {
13129
+ const f = this.fields.find((item) => item.name === name);
13130
+ if (!f)
13131
+ return 'Campo';
13132
+ const hasEndpoint = Boolean(f.resourcePath || f.endpoint || f.optionSource?.resourcePath || f.optionSource?.key);
13133
+ if (hasEndpoint) {
13134
+ return 'Opções remotas';
13135
+ }
13136
+ if (f.controlType === 'select' || f.controlType === 'async-select' || f.controlType === 'multi-select' || /id$/i.test(name)) {
13137
+ return 'Selecionável';
13138
+ }
13139
+ return 'Texto / Exibição';
13140
+ }
13141
+ fieldRoleSummary(name) {
13142
+ const f = this.fields.find((item) => item.name === name);
13143
+ if (!f)
13144
+ return 'Campo do formulário';
13145
+ const resourcePath = f.resourcePath || f.optionSource?.resourcePath || f.endpoint;
13146
+ if (resourcePath) {
13147
+ return `Carrega opções de "${resourcePath}"`;
13148
+ }
13149
+ return `${this.fieldRoleBadge(name)} (${f.controlType || 'input'})`;
13150
+ }
13151
+ formatParentList(deps) {
13152
+ if (!deps || !deps.length)
13153
+ return '—';
13154
+ return deps.map((d) => `${this.disambiguatedLabelFor(d)} (${d})`).join(', ');
13155
+ }
13156
+ formatRelationshipSummary(targetField, deps) {
13157
+ if (!deps || !deps.length) {
13158
+ return `Nenhum campo pai selecionado para ${this.disambiguatedLabelFor(targetField)} (${targetField}).`;
13159
+ }
13160
+ const parents = deps.map((d) => `${this.disambiguatedLabelFor(d)} (${d})`).join(' + ');
13161
+ return `${parents} → filtra opções de ${this.disambiguatedLabelFor(targetField)} (${targetField})`;
13162
+ }
13163
+ formatLoadOnLabel(policy) {
13164
+ switch (policy) {
13165
+ case 'immediate':
13166
+ return 'Imediato ao mudar o pai (immediate)';
13167
+ case 'manual':
13168
+ return 'Manual / sob demanda (manual)';
13169
+ default:
13170
+ return 'Seguir configuração do campo (respectLoadOn)';
13171
+ }
13172
+ }
13173
+ formatMergeStrategyLabel(strategy) {
13174
+ return strategy === 'replace'
13175
+ ? 'Substituir filtros (replace)'
13176
+ : 'Mesclar filtros (merge)';
13177
+ }
12793
13178
  selectedField() {
12794
13179
  return this.selectedFieldSig();
12795
13180
  }
12796
13181
  selectField(name) {
12797
13182
  this.selectedFieldSig.set(name);
12798
13183
  this.startEdit(name);
13184
+ this.syncValidityState();
12799
13185
  }
12800
13186
  hasRule(name) {
12801
- return this.rules().has(name);
13187
+ return (this.rules().get(name)?.dependencyFields?.length ?? 0) > 0;
12802
13188
  }
12803
13189
  ruleFor(name) {
12804
13190
  return this.rules().get(name) || null;
@@ -12814,6 +13200,52 @@ class CascadeManagerTabComponent {
12814
13200
  return false;
12815
13201
  return this.connections.some((c) => c.toFieldName === name && (c.toInputPath || '').includes('filterCriteria'));
12816
13202
  }
13203
+ /**
13204
+ * Computes graph validation combining saved rules with any active draft in the inline editor.
13205
+ */
13206
+ validateCurrentGraph(includeDraft = true) {
13207
+ const effectiveRules = new Map(this.rules());
13208
+ const sel = this.selectedField();
13209
+ if (includeDraft && this.editing() && sel) {
13210
+ const draftDeps = (this.editDepList || []).filter(Boolean);
13211
+ if (draftDeps.length > 0) {
13212
+ effectiveRules.set(sel, {
13213
+ targetField: sel,
13214
+ dependencyFields: draftDeps,
13215
+ enableDependencyCascade: this.editEnable,
13216
+ resetOnDependentChange: this.editReset,
13217
+ dependencyFilterMap: {},
13218
+ dependencyValuePath: this.editValuePath || 'id',
13219
+ dependencyMergeStrategy: this.editMerge,
13220
+ dependencyDebounceMs: this.editDebounce ?? 150,
13221
+ dependencyLoadOnChange: this.editLoadOn,
13222
+ });
13223
+ }
13224
+ else {
13225
+ effectiveRules.delete(sel);
13226
+ }
13227
+ }
13228
+ return this.svc.validateCascadeGraph(effectiveRules, this.fields);
13229
+ }
13230
+ activeCycleDiagnostics() {
13231
+ return this.validateCurrentGraph(true).cycles;
13232
+ }
13233
+ isFieldInCycle(name) {
13234
+ return this.validateCurrentGraph(true).fieldsInCycles.has(name);
13235
+ }
13236
+ hasBlockingErrors() {
13237
+ return !this.validateCurrentGraph(true).valid;
13238
+ }
13239
+ syncValidityState() {
13240
+ const isValid = this.validateCurrentGraph(true).valid;
13241
+ if (this.lastEmittedValid !== isValid) {
13242
+ this.lastEmittedValid = isValid;
13243
+ this.validChange.emit(isValid);
13244
+ }
13245
+ }
13246
+ onDraftControlChange() {
13247
+ this.syncValidityState();
13248
+ }
12817
13249
  startEdit(name) {
12818
13250
  const existing = this.ruleFor(name);
12819
13251
  const base = existing || {
@@ -12836,63 +13268,138 @@ class CascadeManagerTabComponent {
12836
13268
  this.editLoadOn = suggested.dependencyLoadOnChange || 'respectLoadOn';
12837
13269
  this.editDebounce = suggested.dependencyDebounceMs ?? 150;
12838
13270
  this.editValuePath = (typeof suggested.dependencyValuePath === 'string' ? suggested.dependencyValuePath : 'id') || '';
12839
- try {
12840
- this.editFilterMapJson = suggested.dependencyFilterMap ? JSON.stringify(suggested.dependencyFilterMap, null, 2) : '';
12841
- }
12842
- catch {
12843
- this.editFilterMapJson = '';
12844
- }
13271
+ this.editInitialFilterMap = structuredClone(suggested.dependencyFilterMap || {});
13272
+ this.editInitialValuePath = structuredClone(suggested.dependencyValuePath || 'id');
13273
+ this.editMapRows = [];
12845
13274
  // Build mapping rows from dependencies
12846
13275
  this.buildMapRowsFromDeps();
12847
13276
  this.editing.set(true);
13277
+ this.syncValidityState();
12848
13278
  }
12849
13279
  edit(name) { this.startEdit(name); }
12850
- cancelEdit() { this.editing.set(false); }
13280
+ cancelEdit() {
13281
+ const sel = this.selectedField();
13282
+ if (sel) {
13283
+ // Restore draft from committed rule before closing inline edit
13284
+ const existing = this.ruleFor(sel);
13285
+ this.editDepList = existing ? [...existing.dependencyFields] : [];
13286
+ this.editEnable = existing ? existing.enableDependencyCascade : true;
13287
+ }
13288
+ this.editing.set(false);
13289
+ this.syncValidityState();
13290
+ }
12851
13291
  saveEdit(name) {
12852
- // Build rule from editor
13292
+ // Build rule from editor (preserving self-reference prevention)
12853
13293
  const deps = this.editDepList.filter((s) => !!s && s !== name);
12854
- // Build map from rows; fallback to JSON when provided
12855
- let map = {};
12856
- this.editMapRows.forEach((r) => { if (!r.key && r.dep)
12857
- r.key = r.dep; if (r.dep)
12858
- map[r.dep] = r.valuePath ? { key: r.key || r.dep, valuePath: r.valuePath } : (r.key || r.dep); });
12859
- const raw = (this.editFilterMapJson || '').trim();
12860
- if (raw) {
12861
- try {
12862
- const fromJson = JSON.parse(raw);
12863
- map = { ...map, ...fromJson };
13294
+ this.editDepList = [...deps];
13295
+ // The rows own the draft. The opening snapshot only preserves entry shape;
13296
+ // merging the original JSON after the rows would silently undo visual edits.
13297
+ const map = structuredClone(this.editInitialFilterMap);
13298
+ const dependenciesRemoved = (this.ruleFor(name)?.dependencyFields || [])
13299
+ .some((dep) => !deps.includes(dep));
13300
+ if (dependenciesRemoved) {
13301
+ for (const dep of Object.keys(map)) {
13302
+ if (!deps.includes(dep))
13303
+ delete map[dep];
12864
13304
  }
12865
- catch {
12866
- this.setFeedback('Mapeamento de filtros inválido (JSON).', 'error');
12867
- return;
13305
+ }
13306
+ for (const row of this.editMapRows) {
13307
+ if (!deps.includes(row.dep))
13308
+ continue;
13309
+ const initial = this.editInitialFilterMap[row.dep];
13310
+ const initialPath = this.initialRowValuePath(row.dep);
13311
+ if (typeof initial === 'object' && initial !== null) {
13312
+ const entry = { ...initial, key: row.key || row.dep };
13313
+ if (row.valuePath !== initialPath) {
13314
+ if (row.valuePath)
13315
+ entry.valuePath = row.valuePath;
13316
+ else
13317
+ delete entry.valuePath;
13318
+ }
13319
+ map[row.dep] = entry;
13320
+ }
13321
+ else {
13322
+ map[row.dep] = row.valuePath && row.valuePath !== initialPath
13323
+ ? { key: row.key || row.dep, valuePath: row.valuePath }
13324
+ : row.key || row.dep;
13325
+ }
13326
+ }
13327
+ const valuePaths = this.buildValuePathOverrides(deps);
13328
+ // Removed dependencies must lose their extraction overrides as well as their
13329
+ // filter mappings, otherwise re-adding one resurrects stale extraction state.
13330
+ if (dependenciesRemoved && typeof valuePaths === 'object') {
13331
+ for (const dep of Object.keys(valuePaths)) {
13332
+ if (!deps.includes(dep))
13333
+ delete valuePaths[dep];
12868
13334
  }
12869
13335
  }
12870
- this.setFeedback('');
12871
13336
  const rule = {
12872
13337
  targetField: name,
12873
13338
  dependencyFields: deps,
12874
13339
  enableDependencyCascade: this.editEnable,
12875
13340
  resetOnDependentChange: this.editReset,
12876
13341
  dependencyFilterMap: map,
12877
- dependencyValuePath: this.buildValuePathOverrides(deps),
13342
+ dependencyValuePath: valuePaths,
12878
13343
  dependencyMergeStrategy: this.editMerge,
12879
13344
  dependencyDebounceMs: this.editDebounce ?? 150,
12880
13345
  dependencyLoadOnChange: this.editLoadOn,
12881
13346
  };
12882
13347
  const next = new Map(this.rules());
12883
13348
  next.set(name, rule);
13349
+ const validation = this.svc.validateCascadeGraph(next, this.fields);
13350
+ if (validation.fieldsInCycles.has(name)) {
13351
+ const fieldCycle = validation.cycles.find((c) => c.path.includes(name)) || validation.cycles[0];
13352
+ this.setFeedback(fieldCycle.message, 'error');
13353
+ this.syncValidityState();
13354
+ return;
13355
+ }
13356
+ if (!validation.valid) {
13357
+ this.setFeedback(validation.cycles[0].message, 'error');
13358
+ }
13359
+ else {
13360
+ this.setFeedback('');
13361
+ }
12884
13362
  this.rules.set(next);
12885
13363
  this.emitPatch();
12886
13364
  this.editing.set(false);
13365
+ this.syncValidityState();
13366
+ }
13367
+ /**
13368
+ * Removes cyclic dependencies from the given field's draft (and saved rule if present).
13369
+ */
13370
+ breakCycleForField(name) {
13371
+ const currentValidation = this.validateCurrentGraph(true);
13372
+ if (!currentValidation.fieldsInCycles.has(name)) {
13373
+ return;
13374
+ }
13375
+ const cyclicTargets = new Set([name]);
13376
+ for (const cycle of currentValidation.cycles) {
13377
+ for (let i = 0; i < cycle.path.length - 1; i++) {
13378
+ if (cycle.path[i] === name) {
13379
+ cyclicTargets.add(cycle.path[i + 1]);
13380
+ }
13381
+ }
13382
+ }
13383
+ this.editDepList = (this.editDepList || []).filter((dep) => !cyclicTargets.has(dep));
13384
+ this.onDepListChange();
13385
+ if (this.rules().has(name)) {
13386
+ this.saveEdit(name);
13387
+ }
12887
13388
  }
12888
13389
  remove(name) {
12889
13390
  const next = new Map(this.rules());
12890
13391
  next.delete(name);
12891
13392
  this.rules.set(next);
13393
+ if (this.selectedField() === name) {
13394
+ this.editDepList = [];
13395
+ this.editMapRows = [];
13396
+ }
12892
13397
  const patch = {
12893
13398
  [name]: this.svc.clearPatch(),
12894
13399
  };
13400
+ this.setFeedback('');
12895
13401
  this.apply.emit(patch);
13402
+ this.syncValidityState();
12896
13403
  }
12897
13404
  quickSet(name, action) {
12898
13405
  const current = this.ruleFor(name);
@@ -12907,6 +13414,7 @@ class CascadeManagerTabComponent {
12907
13414
  map.set(name, next);
12908
13415
  this.rules.set(map);
12909
13416
  this.emitPatch();
13417
+ this.syncValidityState();
12910
13418
  }
12911
13419
  emitPatch() {
12912
13420
  const out = {};
@@ -12974,14 +13482,26 @@ class CascadeManagerTabComponent {
12974
13482
  };
12975
13483
  next.set(field, vm);
12976
13484
  });
13485
+ const validation = this.svc.validateCascadeGraph(next, this.fields);
12977
13486
  this.rules.set(next);
13487
+ const sel = this.selectedField();
13488
+ if (sel && next.has(sel)) {
13489
+ this.startEdit(sel);
13490
+ }
13491
+ if (!validation.valid) {
13492
+ this.setFeedback(validation.cycles[0].message, 'error');
13493
+ this.syncValidityState();
13494
+ return;
13495
+ }
12978
13496
  this.emitPatch();
12979
13497
  this.importMode = false;
13498
+ this.syncValidityState();
12980
13499
  }
12981
13500
  startTemplate(name, tpl) {
12982
13501
  const hasR = this.hasField('rotaId');
12983
13502
  const hasD = this.hasField('destinoReqId');
12984
- const deps = tpl === 'rota-destino' ? (hasR ? ['rotaId'] : []) : (hasR && hasD ? ['rotaId', 'destinoReqId'] : []);
13503
+ const deps = (tpl === 'rota-destino' ? (hasR ? ['rotaId'] : []) : (hasR && hasD ? ['rotaId', 'destinoReqId'] : []))
13504
+ .filter((dep) => dep !== name);
12985
13505
  this.editDepList = [...deps];
12986
13506
  this.editEnable = true;
12987
13507
  this.editReset = true;
@@ -12989,40 +13509,73 @@ class CascadeManagerTabComponent {
12989
13509
  this.editLoadOn = 'respectLoadOn';
12990
13510
  this.editDebounce = 150;
12991
13511
  this.editValuePath = 'id';
13512
+ this.editInitialFilterMap = {};
13513
+ this.editInitialValuePath = 'id';
13514
+ this.editMapRows = [];
12992
13515
  this.buildMapRowsFromDeps();
12993
13516
  this.editing.set(true);
13517
+ this.syncValidityState();
12994
13518
  }
12995
13519
  buildMapRowsFromDeps() {
12996
13520
  const deps = this.editDepList;
12997
- const existing = (() => { try {
12998
- return JSON.parse(this.editFilterMapJson || '{}');
12999
- }
13000
- catch {
13001
- return {};
13002
- } })();
13521
+ const current = new Map(this.editMapRows.map((row) => [row.dep, row]));
13003
13522
  this.editMapRows = deps.map((dep) => {
13004
- const entry = existing[dep];
13005
- if (!entry)
13006
- return { dep, key: dep };
13007
- if (typeof entry === 'string')
13008
- return { dep, key: entry };
13009
- return { dep, key: entry.key || dep, valuePath: entry.valuePath };
13523
+ const edited = current.get(dep);
13524
+ if (edited)
13525
+ return edited;
13526
+ const entry = this.editInitialFilterMap[dep];
13527
+ const key = typeof entry === 'string' ? entry : entry?.key || dep;
13528
+ return { dep, key, valuePath: this.initialRowValuePath(dep) };
13010
13529
  });
13011
13530
  }
13531
+ initialRowValuePath(dep) {
13532
+ const entry = this.editInitialFilterMap[dep];
13533
+ if (typeof entry === 'object' && entry?.valuePath)
13534
+ return entry.valuePath;
13535
+ return typeof this.editInitialValuePath === 'object'
13536
+ ? this.editInitialValuePath[dep]
13537
+ : undefined;
13538
+ }
13012
13539
  buildValuePathOverrides(deps) {
13013
- // If any row defines a valuePath, return a record; else return default string
13014
- const record = {};
13015
- this.editMapRows.forEach((r) => { if (r.valuePath)
13016
- record[r.dep] = r.valuePath; });
13017
- const hasAny = Object.keys(record).length > 0;
13018
- return hasAny ? record : (this.editValuePath || 'id');
13540
+ const initialDefault = typeof this.editInitialValuePath === 'string'
13541
+ ? this.editInitialValuePath
13542
+ : 'id';
13543
+ const defaultChanged = this.editValuePath !== initialDefault;
13544
+ const pathsChanged = this.editMapRows.some((row) => row.valuePath !== this.initialRowValuePath(row.dep));
13545
+ if (!defaultChanged && !pathsChanged)
13546
+ return structuredClone(this.editInitialValuePath);
13547
+ const wasRecord = typeof this.editInitialValuePath === 'object';
13548
+ if (!pathsChanged && !wasRecord)
13549
+ return this.editValuePath || 'id';
13550
+ // A specific extraction path must not erase the default/overrides used by
13551
+ // other dependencies. Unchanged documents keep their original shape above.
13552
+ const record = typeof this.editInitialValuePath === 'object'
13553
+ ? { ...this.editInitialValuePath }
13554
+ : {};
13555
+ for (const dep of deps) {
13556
+ const row = this.editMapRows.find((candidate) => candidate.dep === dep);
13557
+ if (row && row.valuePath !== this.initialRowValuePath(dep)) {
13558
+ record[dep] = row.valuePath || this.editValuePath || 'id';
13559
+ }
13560
+ else if (typeof this.editInitialValuePath === 'object') {
13561
+ const existing = this.editInitialValuePath[dep];
13562
+ if (existing !== undefined)
13563
+ record[dep] = existing;
13564
+ else if (defaultChanged)
13565
+ record[dep] = this.editValuePath || 'id';
13566
+ }
13567
+ else {
13568
+ record[dep] = this.editValuePath || 'id';
13569
+ }
13570
+ }
13571
+ return record;
13019
13572
  }
13020
13573
  buildPreview() {
13021
13574
  try {
13022
- const deps = this.editDepList;
13023
13575
  const frag = {};
13024
13576
  this.editMapRows.forEach((r) => {
13025
- const example = r.valuePath ? 123 : 123; // simple placeholder id
13577
+ const extractedProp = r.valuePath || this.editValuePath || 'id';
13578
+ const example = `<valor de ${r.dep}.${extractedProp}>`;
13026
13579
  const parts = (r.key || r.dep).split('.');
13027
13580
  let ref = frag;
13028
13581
  for (let i = 0; i < parts.length - 1; i++) {
@@ -13047,10 +13600,14 @@ class CascadeManagerTabComponent {
13047
13600
  const labelMap = this.svc.getFieldLabelMap(this.fields);
13048
13601
  return this.fields
13049
13602
  .filter((f) => f.name !== current)
13050
- .sort((a, b) => (labelMap.get(a.name) || a.name).localeCompare(labelMap.get(b.name) || b.name));
13603
+ .sort((a, b) => {
13604
+ const labelCmp = (labelMap.get(a.name) || a.name).localeCompare(labelMap.get(b.name) || b.name);
13605
+ return labelCmp !== 0 ? labelCmp : a.name.localeCompare(b.name);
13606
+ });
13051
13607
  }
13052
13608
  onDepListChange() {
13053
13609
  this.buildMapRowsFromDeps();
13610
+ this.syncValidityState();
13054
13611
  }
13055
13612
  getKeySuggestions(dep) {
13056
13613
  const suggestions = [dep, `${dep}.id`, `${dep}.value`];
@@ -13070,7 +13627,7 @@ class CascadeManagerTabComponent {
13070
13627
  return [...new Set(suggestions)];
13071
13628
  }
13072
13629
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: CascadeManagerTabComponent, deps: [{ token: CascadeRulesService }], target: i0.ɵɵFactoryTarget.Component });
13073
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.14", type: CascadeManagerTabComponent, isStandalone: true, selector: "praxis-cascade-manager-tab", inputs: { fields: "fields", connections: "connections" }, outputs: { apply: "apply", cancel: "cancel" }, usesOnChanges: true, ngImport: i0, template: `
13630
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.14", type: CascadeManagerTabComponent, isStandalone: true, selector: "praxis-cascade-manager-tab", inputs: { fields: "fields", connections: "connections" }, outputs: { apply: "apply", cancel: "cancel", validChange: "validChange" }, usesOnChanges: true, ngImport: i0, template: `
13074
13631
  <div class="cm-root">
13075
13632
  <!-- Left: Fields list -->
13076
13633
  <div class="cm-left">
@@ -13080,14 +13637,29 @@ class CascadeManagerTabComponent {
13080
13637
  </mat-form-field>
13081
13638
  @if (fields.length <= 50) {
13082
13639
  <div>
13083
- @for (f of filteredFields(); track f) {
13084
- <div (click)="selectField(f.name)" class="field-item" [class.selected]="f.name===selectedField()" tabindex="0" (keydown)="onFieldKeydown($event, f.name)">
13085
- <div>
13086
- <div>{{ labelFor(f.name) }}</div>
13087
- <small style="opacity:.7">{{ f.name }}</small>
13640
+ @for (f of filteredFields(); track f.name) {
13641
+ <div
13642
+ (click)="selectField(f.name)"
13643
+ class="field-item"
13644
+ [class.selected]="f.name===selectedField()"
13645
+ [class.has-cycle]="isFieldInCycle(f.name)"
13646
+ tabindex="0"
13647
+ (keydown)="onFieldKeydown($event, f.name)"
13648
+ >
13649
+ <div class="field-item-main">
13650
+ <div class="field-item-title">{{ disambiguatedLabelFor(f.name) }}</div>
13651
+ <div class="field-item-meta">
13652
+ <span>{{ f.name }}</span>
13653
+ <span class="field-role-pill" [class.field-role-pill--remote]="hasRemoteOptionSource(f.name)">
13654
+ {{ fieldRoleBadge(f.name) }}
13655
+ </span>
13656
+ </div>
13088
13657
  </div>
13089
- @if (hasRule(f.name)) {
13090
- <div class="rule-badges">
13658
+ <div class="rule-badges">
13659
+ @if (isFieldInCycle(f.name)) {
13660
+ <span class="rule-badge b-conflict">Ciclo</span>
13661
+ }
13662
+ @if (hasRule(f.name)) {
13091
13663
  @if (isCascadeEnabled(f.name)) {
13092
13664
  <span class="rule-badge b-active">Ativo</span>
13093
13665
  }
@@ -13095,22 +13667,38 @@ class CascadeManagerTabComponent {
13095
13667
  <span class="rule-badge b-manual">Manual</span>
13096
13668
  }
13097
13669
  @if (loadOnChange(f.name)==='immediate') {
13098
- <span class="rule-badge b-immediate">Immediate</span>
13670
+ <span class="rule-badge b-immediate">Imediato</span>
13099
13671
  }
13100
- </div>
13101
- }
13672
+ }
13673
+ </div>
13102
13674
  </div>
13103
13675
  }
13104
13676
  </div>
13105
13677
  } @else {
13106
- <cdk-virtual-scroll-viewport itemSize="44" style="height: calc(100vh - 240px)">
13107
- <div *cdkVirtualFor="let f of filteredFields()" (click)="selectField(f.name)" class="field-item" [class.selected]="f.name===selectedField()" tabindex="0" (keydown)="onFieldKeydown($event, f.name)">
13108
- <div>
13109
- <div>{{ labelFor(f.name) }}</div>
13110
- <small style="opacity:.7">{{ f.name }}</small>
13678
+ <cdk-virtual-scroll-viewport itemSize="48" style="height: calc(100vh - 240px)">
13679
+ <div
13680
+ *cdkVirtualFor="let f of filteredFields()"
13681
+ (click)="selectField(f.name)"
13682
+ class="field-item"
13683
+ [class.selected]="f.name===selectedField()"
13684
+ [class.has-cycle]="isFieldInCycle(f.name)"
13685
+ tabindex="0"
13686
+ (keydown)="onFieldKeydown($event, f.name)"
13687
+ >
13688
+ <div class="field-item-main">
13689
+ <div class="field-item-title">{{ disambiguatedLabelFor(f.name) }}</div>
13690
+ <div class="field-item-meta">
13691
+ <span>{{ f.name }}</span>
13692
+ <span class="field-role-pill" [class.field-role-pill--remote]="hasRemoteOptionSource(f.name)">
13693
+ {{ fieldRoleBadge(f.name) }}
13694
+ </span>
13695
+ </div>
13111
13696
  </div>
13112
- @if (hasRule(f.name)) {
13113
- <div class="rule-badges">
13697
+ <div class="rule-badges">
13698
+ @if (isFieldInCycle(f.name)) {
13699
+ <span class="rule-badge b-conflict">Ciclo</span>
13700
+ }
13701
+ @if (hasRule(f.name)) {
13114
13702
  @if (isCascadeEnabled(f.name)) {
13115
13703
  <span class="rule-badge b-active">Ativo</span>
13116
13704
  }
@@ -13118,27 +13706,27 @@ class CascadeManagerTabComponent {
13118
13706
  <span class="rule-badge b-manual">Manual</span>
13119
13707
  }
13120
13708
  @if (loadOnChange(f.name)==='immediate') {
13121
- <span class="rule-badge b-immediate">Immediate</span>
13709
+ <span class="rule-badge b-immediate">Imediato</span>
13122
13710
  }
13123
- </div>
13124
- }
13711
+ }
13712
+ </div>
13125
13713
  </div>
13126
13714
  </cdk-virtual-scroll-viewport>
13127
13715
  }
13128
13716
  </div>
13129
-
13717
+
13130
13718
  <!-- Right: Rules for selected field -->
13131
13719
  @if (selectedField(); as sel) {
13132
13720
  <div class="cm-right">
13133
13721
  <div class="cm-header">
13134
13722
  <div>
13135
- <h3 style="margin:0">{{ labelFor(sel) }}</h3>
13136
- <div style="opacity:.7">{{ sel }}</div>
13723
+ <h3 style="margin:0">{{ disambiguatedLabelFor(sel) }}</h3>
13724
+ <div style="opacity:.75; font-size:0.85rem">{{ sel }} · {{ fieldRoleSummary(sel) }}</div>
13137
13725
  </div>
13138
13726
  <div>
13139
13727
  <button mat-stroked-button color="primary" (click)="exportRules()" style="margin-right:8px">Exportar</button>
13140
13728
  <button mat-stroked-button (click)="toggleImport()" style="margin-right:8px">Importar</button>
13141
- <button mat-flat-button color="primary" [matMenuTriggerFor]="addMenu" [disabled]="editing()">Adicionar</button>
13729
+ <button mat-flat-button color="primary" [matMenuTriggerFor]="addMenu">Modelos</button>
13142
13730
  <mat-menu #addMenu="matMenu">
13143
13731
  <button mat-menu-item (click)="startEdit(sel)"><mat-icon [praxisIcon]="'add'"></mat-icon><span>Em branco</span></button>
13144
13732
  <button mat-menu-item (click)="startTemplate(sel, 'rota-destino')" [disabled]="!hasField('rotaId') || !hasField('destinoReqId')"><mat-icon [praxisIcon]="'bolt'"></mat-icon><span>Rota → Destino (id)</span></button>
@@ -13146,9 +13734,45 @@ class CascadeManagerTabComponent {
13146
13734
  </mat-menu>
13147
13735
  </div>
13148
13736
  </div>
13737
+
13738
+ @if (activeCycleDiagnostics().length > 0) {
13739
+ <div class="cm-cycle-alert" role="alert" data-testid="cascade-cycle-diagnostic">
13740
+ <mat-icon [praxisIcon]="'error'"></mat-icon>
13741
+ <div class="cm-cycle-alert-body">
13742
+ <strong>Dependência circular detectada — não é possível aplicar enquanto houver ciclo</strong>
13743
+ @for (cycle of activeCycleDiagnostics(); track cycle.formattedPath) {
13744
+ <div>{{ cycle.message }}</div>
13745
+ }
13746
+ @if (isFieldInCycle(sel)) {
13747
+ <div style="margin-top: 6px;">
13748
+ <button
13749
+ mat-stroked-button
13750
+ type="button"
13751
+ data-testid="cascade-break-cycle-button"
13752
+ (click)="breakCycleForField(sel)"
13753
+ >
13754
+ Remover dependência circular deste campo
13755
+ </button>
13756
+ </div>
13757
+ }
13758
+ </div>
13759
+ </div>
13760
+ }
13761
+
13149
13762
  @if (feedbackMessage) {
13150
13763
  <div class="cm-feedback" [class.cm-feedback--error]="feedbackTone === 'error'" role="status">{{ feedbackMessage }}</div>
13151
13764
  }
13765
+
13766
+ @if (!hasRemoteOptionSource(sel)) {
13767
+ <div class="cm-source-notice" role="note" data-testid="cascade-no-remote-source-notice">
13768
+ <mat-icon [praxisIcon]="'info'"></mat-icon>
13769
+ <div>
13770
+ Este campo ({{ fieldRoleBadge(sel) }}) não declara fonte de opções remota (<code class="kbd">resourcePath</code> / <code class="kbd">optionSource</code>).
13771
+ Campos de texto ou nomes resolvidos exibem valores derivados; para recarregar opções em cascata, selecione o campo selecionável (ex.: ID) correspondente.
13772
+ </div>
13773
+ </div>
13774
+ }
13775
+
13152
13776
  <!-- Conflict banner (connections) -->
13153
13777
  @if (hasConflict(sel)) {
13154
13778
  <div class="banner">
@@ -13161,6 +13785,7 @@ class CascadeManagerTabComponent {
13161
13785
  <button mat-stroked-button color="accent" (click)="quickSet(sel,'manual')">Definir manual</button>
13162
13786
  </div>
13163
13787
  }
13788
+
13164
13789
  @if (importMode) {
13165
13790
  <div class="import-panel">
13166
13791
  <div style="display:flex; gap:8px; align-items:center; margin-bottom:8px"><mat-icon [praxisIcon]="'upload'"></mat-icon><strong>Importar regras (JSON)</strong></div>
@@ -13171,33 +13796,45 @@ class CascadeManagerTabComponent {
13171
13796
  </div>
13172
13797
  </div>
13173
13798
  }
13174
- @if (!ruleFor(sel)) {
13799
+
13800
+ @if (!hasRule(sel)) {
13175
13801
  <div>
13176
- <div class="rules-empty">Nenhuma cascata configurada. Clique em “Adicionar” para montar a dependência.</div>
13802
+ <div class="rules-empty" data-testid="cascade-empty-guidance">
13803
+ Nenhuma cascata salva para <strong>{{ disambiguatedLabelFor(sel) }}</strong>.
13804
+ Selecione abaixo em <strong>“Este campo depende de (campos pais)”</strong> quais campos controlam as opções deste campo e clique em <strong>Salvar</strong>.
13805
+ </div>
13177
13806
  </div>
13178
13807
  }
13179
- @if (ruleFor(sel); as rule) {
13808
+ @if (hasRule(sel) && ruleFor(sel); as rule) {
13180
13809
  <div>
13181
13810
  <div class="rule-row">
13182
13811
  <div>
13183
- <div><strong>Dependentes:</strong> {{ rule.dependencyFields.join(', ') || '—' }}</div>
13184
- <div style="opacity:.7">Load on change: {{ rule.dependencyLoadOnChange || 'respectLoadOn' }} | Merge: {{ rule.dependencyMergeStrategy || 'merge' }}</div>
13812
+ <div><strong>Depende de (campos pais):</strong> {{ formatParentList(rule.dependencyFields) }}</div>
13813
+ <div class="rule-summary-direction">
13814
+ <strong>Fluxo:</strong> {{ formatRelationshipSummary(sel, rule.dependencyFields) }}
13815
+ </div>
13816
+ <div class="rule-summary-policies">
13817
+ Quando recarregar: {{ formatLoadOnLabel(rule.dependencyLoadOnChange) }} | Como combinar filtros: {{ formatMergeStrategyLabel(rule.dependencyMergeStrategy) }}
13818
+ </div>
13185
13819
  </div>
13186
13820
  <div class="rule-actions">
13187
- <button mat-icon-button matTooltip="Editar" (click)="startEdit(sel)"><mat-icon [praxisIcon]="'edit'"></mat-icon></button>
13188
- <button mat-icon-button matTooltip="Duplicar" disabled><mat-icon [praxisIcon]="'content_copy'"></mat-icon></button>
13189
- <button mat-icon-button matTooltip="Remover" (click)="remove(sel)"><mat-icon [praxisIcon]="'delete'"></mat-icon></button>
13821
+ <button mat-icon-button type="button" aria-label="Editar regra de cascata" matTooltip="Editar" (click)="startEdit(sel)"><mat-icon [praxisIcon]="'edit'"></mat-icon></button>
13822
+ <button mat-icon-button type="button" aria-label="Duplicar regra de cascata" matTooltip="Duplicar" disabled><mat-icon [praxisIcon]="'content_copy'"></mat-icon></button>
13823
+ <button mat-icon-button type="button" aria-label="Remover regra de cascata" matTooltip="Remover" (click)="remove(sel)"><mat-icon [praxisIcon]="'delete'"></mat-icon></button>
13190
13824
  </div>
13191
13825
  </div>
13192
13826
  </div>
13193
13827
  }
13828
+
13194
13829
  <!-- Editor com seções (sem acordeão duplo) -->
13195
13830
  <div class="panel-surface">
13196
13831
  <div class="panel-header" style="padding: 8px 4px; border-bottom: 1px solid var(--pfx-settings-panel-border, var(--md-sys-color-outline-variant));">
13197
13832
  <mat-icon [praxisIcon]="'rule'"></mat-icon>
13198
13833
  <div>
13199
13834
  <div>Cascata do campo</div>
13200
- <small style="color: var(--md-sys-color-on-surface-variant);">Defina dependentes, quando recarregar e como mapear filtros</small>
13835
+ <small style="color: var(--md-sys-color-on-surface-variant);">
13836
+ Defina de quais campos pais este campo depende, quando recarregar opções e qual filtro enviar ao serviço
13837
+ </small>
13201
13838
  </div>
13202
13839
  </div>
13203
13840
  <div class="panel-stack">
@@ -13207,13 +13844,16 @@ class CascadeManagerTabComponent {
13207
13844
  <mat-panel-title class="panel-header"><mat-icon [praxisIcon]="'tune'"></mat-icon> Básico</mat-panel-title>
13208
13845
  </mat-expansion-panel-header>
13209
13846
  <div class="form-grid">
13847
+ <p class="section-caption">
13848
+ Quando o usuário alterar qualquer um dos campos pais escolhidos abaixo, as opções disponíveis em <strong>{{ disambiguatedLabelFor(sel) }}</strong> serão filtradas.
13849
+ </p>
13210
13850
  <div class="form-row">
13211
13851
  <mat-form-field appearance="outline" class="full">
13212
- <mat-label>Campos dependentes</mat-label>
13852
+ <mat-label>Este campo depende de (campos pais)</mat-label>
13213
13853
  <mat-select multiple [(ngModel)]="editDepList" (ngModelChange)="onDepListChange()">
13214
- @for (f of availableDependencyFields(); track f) {
13854
+ @for (f of availableDependencyFields(); track f.name) {
13215
13855
  <mat-option [value]="f.name">
13216
- {{ labelFor(f.name) }} ({{ f.name }})
13856
+ {{ disambiguatedLabelFor(f.name) }} ({{ f.name }}) · {{ fieldRoleBadge(f.name) }}
13217
13857
  </mat-option>
13218
13858
  }
13219
13859
  </mat-select>
@@ -13222,42 +13862,54 @@ class CascadeManagerTabComponent {
13222
13862
  matSuffix
13223
13863
  class="help-icon-button"
13224
13864
  type="button"
13225
- [matTooltip]="'Selecione os campos que controlam este valor'"
13865
+ [matTooltip]="'Selecione os campos pais (origem) cujo valor filtrará as opções deste campo (destino)'"
13226
13866
  matTooltipPosition="above"
13227
13867
  >
13228
13868
  <mat-icon [praxisIcon]="'help_outline'"></mat-icon>
13229
13869
  </button>
13230
13870
  </mat-form-field>
13231
13871
  </div>
13872
+ @if (editDepList.length > 0) {
13873
+ <div class="section-caption" data-testid="cascade-draft-direction-summary">
13874
+ <strong>Resumo da relação:</strong> {{ formatRelationshipSummary(sel, editDepList) }}
13875
+ </div>
13876
+ }
13232
13877
  <div class="form-row">
13233
- <mat-checkbox [(ngModel)]="editEnable">Ativar cascata nativa</mat-checkbox>
13234
- <mat-checkbox [(ngModel)]="editReset">Resetar ao mudar</mat-checkbox>
13878
+ <mat-checkbox [(ngModel)]="editEnable" (ngModelChange)="onDraftControlChange()">
13879
+ Ativar cascata automática neste campo
13880
+ </mat-checkbox>
13881
+ <mat-checkbox [(ngModel)]="editReset" (ngModelChange)="onDraftControlChange()">
13882
+ Limpar seleção deste campo quando o campo pai mudar
13883
+ </mat-checkbox>
13235
13884
  </div>
13236
13885
  </div>
13237
13886
  </mat-expansion-panel>
13238
13887
  <!-- Execução -->
13239
13888
  <mat-expansion-panel class="stack-panel">
13240
13889
  <mat-expansion-panel-header>
13241
- <mat-panel-title class="panel-header"><mat-icon [praxisIcon]="'schedule'"></mat-icon> Execução</mat-panel-title>
13890
+ <mat-panel-title class="panel-header"><mat-icon [praxisIcon]="'schedule'"></mat-icon> Quando recarregar e combinar filtros</mat-panel-title>
13242
13891
  </mat-expansion-panel-header>
13892
+ <p class="section-caption">
13893
+ Controle o momento em que a consulta de opções é disparada e como o filtro do campo pai convive com outros filtros do campo.
13894
+ </p>
13243
13895
  <div class="form-row">
13244
13896
  <mat-form-field appearance="outline">
13245
- <mat-label>Carregar ao mudar</mat-label>
13897
+ <mat-label>Quando recarregar opções</mat-label>
13246
13898
  <mat-select [(ngModel)]="editLoadOn">
13247
- <mat-option value="respectLoadOn">Respeitar loadOn</mat-option>
13248
- <mat-option value="immediate">Imediato</mat-option>
13249
- <mat-option value="manual">Manual</mat-option>
13899
+ <mat-option value="respectLoadOn">Seguir configuração do campo (respectLoadOn)</mat-option>
13900
+ <mat-option value="immediate">Recarregar imediatamente ao mudar o pai (immediate)</mat-option>
13901
+ <mat-option value="manual">Somente sob comando manual / abertura (manual)</mat-option>
13250
13902
  </mat-select>
13251
13903
  </mat-form-field>
13252
13904
  <mat-form-field appearance="outline">
13253
- <mat-label>Merge de filtros</mat-label>
13905
+ <mat-label>Como combinar filtros</mat-label>
13254
13906
  <mat-select [(ngModel)]="editMerge">
13255
- <mat-option value="merge">merge (padrão)</mat-option>
13256
- <mat-option value="replace">replace</mat-option>
13907
+ <mat-option value="merge">Mesclar com filtros existentes (merge — padrão)</mat-option>
13908
+ <mat-option value="replace">Substituir filtros anteriores (replace)</mat-option>
13257
13909
  </mat-select>
13258
13910
  </mat-form-field>
13259
13911
  <mat-form-field appearance="outline">
13260
- <mat-label>Atraso (ms)</mat-label>
13912
+ <mat-label>Atraso após mudança (ms)</mat-label>
13261
13913
  <input matInput type="number" [(ngModel)]="editDebounce">
13262
13914
  </mat-form-field>
13263
13915
  </div>
@@ -13265,18 +13917,21 @@ class CascadeManagerTabComponent {
13265
13917
  <!-- Extração de valor -->
13266
13918
  <mat-expansion-panel class="stack-panel">
13267
13919
  <mat-expansion-panel-header>
13268
- <mat-panel-title class="panel-header"><mat-icon [praxisIcon]="'call_split'"></mat-icon> Extração de valor</mat-panel-title>
13920
+ <mat-panel-title class="panel-header"><mat-icon [praxisIcon]="'call_split'"></mat-icon> Qual propriedade ler do campo pai</mat-panel-title>
13269
13921
  </mat-expansion-panel-header>
13922
+ <p class="section-caption">
13923
+ Se o campo pai guardar um objeto completo (ex.: <code class="kbd">&#123; id: 10, nome: "TI" &#125;</code>), informe qual propriedade deve ser extraída para envio no filtro (padrão: <code class="kbd">id</code>).
13924
+ </p>
13270
13925
  <div class="form-row">
13271
13926
  <mat-form-field appearance="outline" class="full">
13272
- <mat-label>ValuePath padrão</mat-label>
13927
+ <mat-label>Propriedade padrão lida do campo pai (ValuePath)</mat-label>
13273
13928
  <input matInput [(ngModel)]="editValuePath" placeholder="id">
13274
13929
  <button
13275
13930
  mat-icon-button
13276
13931
  matSuffix
13277
13932
  class="help-icon-button"
13278
13933
  type="button"
13279
- [matTooltip]="'Preenchido automaticamente a partir de optionValueKey() ou \\'id\\''"
13934
+ [matTooltip]="'Preenchido automaticamente a partir de optionValueKey do campo pai ou \\'id\\''"
13280
13935
  matTooltipPosition="above"
13281
13936
  >
13282
13937
  <mat-icon [praxisIcon]="'help_outline'"></mat-icon>
@@ -13287,24 +13942,30 @@ class CascadeManagerTabComponent {
13287
13942
  <!-- Mapeamento de filtros (tabela) -->
13288
13943
  <mat-expansion-panel class="stack-panel">
13289
13944
  <mat-expansion-panel-header>
13290
- <mat-panel-title class="panel-header"><mat-icon [praxisIcon]="'filter_alt'"></mat-icon> Mapeamento de filtros</mat-panel-title>
13945
+ <mat-panel-title class="panel-header"><mat-icon [praxisIcon]="'filter_alt'"></mat-icon> Mapeamento de filtros enviados ao serviço</mat-panel-title>
13291
13946
  </mat-expansion-panel-header>
13292
13947
  @if (editMapRows.length) {
13948
+ <p class="section-caption">
13949
+ Para cada campo pai, defina o nome do parâmetro esperado pelo serviço de opções deste campo e, se necessário, a propriedade específica a extrair.
13950
+ </p>
13293
13951
  <table class="map-table">
13294
13952
  <thead>
13295
13953
  <tr>
13296
- <th>Dependente</th>
13297
- <th>Chave (dot‑path)</th>
13298
- <th>ValuePath (opcional)</th>
13954
+ <th>Campo pai (origem)</th>
13955
+ <th>Parâmetro enviado ao filtro (dot‑path)</th>
13956
+ <th>Propriedade lida do pai (opcional)</th>
13299
13957
  </tr>
13300
13958
  </thead>
13301
13959
  <tbody>
13302
- @for (row of editMapRows; track row; let i = $index) {
13960
+ @for (row of editMapRows; track row.dep; let i = $index) {
13303
13961
  <tr>
13304
- <td>{{ row.dep }}</td>
13962
+ <td>
13963
+ <div><strong>{{ disambiguatedLabelFor(row.dep) }}</strong></div>
13964
+ <small style="opacity:.75">{{ row.dep }}</small>
13965
+ </td>
13305
13966
  <td>
13306
13967
  <mat-form-field appearance="outline" class="full">
13307
- <mat-label>Chave</mat-label>
13968
+ <mat-label>Parâmetro no filtro</mat-label>
13308
13969
  <input matInput [(ngModel)]="row.key" [matAutocomplete]="autoKey" [placeholder]="row.dep">
13309
13970
  <mat-autocomplete #autoKey="matAutocomplete">
13310
13971
  @for (opt of getKeySuggestions(row.dep); track opt) {
@@ -13316,7 +13977,7 @@ class CascadeManagerTabComponent {
13316
13977
  matSuffix
13317
13978
  class="help-icon-button"
13318
13979
  type="button"
13319
- [matTooltip]="'Use dot-notation (e.g. ' + row.dep + '.id) for nested objects'"
13980
+ [matTooltip]="'Use notação com ponto (ex.: ' + row.dep + '.id) quando o backend esperar filtro aninhado'"
13320
13981
  matTooltipPosition="above"
13321
13982
  >
13322
13983
  <mat-icon [praxisIcon]="'help_outline'"></mat-icon>
@@ -13325,7 +13986,7 @@ class CascadeManagerTabComponent {
13325
13986
  </td>
13326
13987
  <td>
13327
13988
  <mat-form-field appearance="outline" class="full">
13328
- <mat-label>ValuePath</mat-label>
13989
+ <mat-label>Propriedade do pai (ValuePath)</mat-label>
13329
13990
  <input matInput [(ngModel)]="row.valuePath" [matAutocomplete]="autoValue" [placeholder]="editValuePath||'id'">
13330
13991
  <mat-autocomplete #autoValue="matAutocomplete">
13331
13992
  @for (opt of getValuePathSuggestions(row.dep); track opt) {
@@ -13337,7 +13998,7 @@ class CascadeManagerTabComponent {
13337
13998
  matSuffix
13338
13999
  class="help-icon-button"
13339
14000
  type="button"
13340
- [matTooltip]="'Opcional'"
14001
+ [matTooltip]="'Opcional: sobrescreve a propriedade padrão lida apenas para este campo pai'"
13341
14002
  matTooltipPosition="above"
13342
14003
  >
13343
14004
  <mat-icon [praxisIcon]="'help_outline'"></mat-icon>
@@ -13349,29 +14010,32 @@ class CascadeManagerTabComponent {
13349
14010
  </tbody>
13350
14011
  </table>
13351
14012
  } @else {
13352
- <div class="rules-empty">Defina dependentes para sugerir o mapeamento.</div>
14013
+ <div class="rules-empty">Selecione um ou mais campos pais em “Este campo depende de” para configurar o mapeamento de filtros.</div>
13353
14014
  }
13354
14015
  </mat-expansion-panel>
13355
14016
  <!-- Preview -->
13356
14017
  <mat-expansion-panel class="stack-panel">
13357
14018
  <mat-expansion-panel-header>
13358
- <mat-panel-title class="panel-header"><mat-icon [praxisIcon]="'visibility'"></mat-icon> Preview</mat-panel-title>
14019
+ <mat-panel-title class="panel-header"><mat-icon [praxisIcon]="'visibility'"></mat-icon> Exemplo ilustrativo do filtro enviado (Preview)</mat-panel-title>
13359
14020
  </mat-expansion-panel-header>
14021
+ <p class="section-caption" data-testid="cascade-preview-explanation">
14022
+ Simulação ilustrativa (sem consulta ao servidor): mostra a estrutura JSON de filtro que será enviada ao carregar opções de <strong>{{ disambiguatedLabelFor(sel) }}</strong>.
14023
+ </p>
13360
14024
  <pre class="preview-box">{{ buildPreview() }}</pre>
13361
14025
  <div style="display:flex; justify-content:flex-end; padding-top:6px">
13362
- <button mat-stroked-button (click)="copyPreview()">Copiar</button>
14026
+ <button mat-stroked-button (click)="copyPreview()">Copiar exemplo</button>
13363
14027
  </div>
13364
14028
  </mat-expansion-panel>
13365
14029
  </div>
13366
14030
  <div style="display:flex; gap:8px; justify-content:flex-end; padding:8px 12px;">
13367
- <button mat-stroked-button (click)="cancelEdit()">Cancelar</button>
13368
- <button mat-flat-button color="primary" (click)="saveEdit(sel)">Salvar</button>
14031
+ <button mat-stroked-button type="button" (click)="cancelEdit()">Cancelar</button>
14032
+ <button mat-flat-button color="primary" type="button" (click)="saveEdit(sel)">Salvar</button>
13369
14033
  </div>
13370
14034
  </div>
13371
14035
  </div>
13372
14036
  }
13373
14037
  </div>
13374
- `, isInline: true, styles: [":host{display:block;container-type:inline-size}.cm-root{display:grid;grid-template-columns:minmax(220px,280px) minmax(0,1fr);gap:16px;min-height:420px}.cm-left{border-right:1px solid var(--pfx-settings-panel-border, var(--md-sys-color-outline-variant));padding-right:8px;min-width:0}.cm-search{width:100%;margin-bottom:8px}.cm-right{padding-left:8px;min-width:0}@container (max-width: 720px){.cm-root{grid-template-columns:1fr}.cm-left{border-right:none;border-bottom:1px solid var(--pfx-settings-panel-border, var(--md-sys-color-outline-variant));padding-right:0;padding-bottom:12px}.cm-right{padding-left:0}}.field-item{display:flex;align-items:center;justify-content:space-between;padding:8px 6px;cursor:pointer;border-radius:10px;transition:background .12s ease,border .12s ease;border:1px solid transparent}.field-item:hover{background:var(--md-sys-color-surface-container-high)}.field-item.selected{background:var(--md-sys-color-surface-container-highest);border-color:var(--md-sys-color-outline-variant)}.rule-badges{display:inline-flex;gap:6px}.rule-badge{font-size:11px;padding:2px 8px;border-radius:999px;font-weight:600}.b-active{background:var(--md-sys-color-primary-container);color:var(--md-sys-color-on-primary-container)}.b-manual{background:var(--md-sys-color-surface-container-high);color:var(--md-sys-color-on-surface-variant)}.b-immediate{background:var(--md-sys-color-secondary-container);color:var(--md-sys-color-on-secondary-container)}.b-conflict{background:var(--md-sys-color-error-container);color:var(--md-sys-color-on-error-container)}.cm-header{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:8px}.cm-feedback{padding:8px 12px;border-radius:8px;font-size:.85rem;margin-bottom:10px;border:1px solid var(--md-sys-color-outline-variant);background:var(--md-sys-color-secondary-container);color:var(--md-sys-color-on-secondary-container)}.cm-feedback--error{border-color:var(--md-sys-color-error);background:var(--md-sys-color-error-container);color:var(--md-sys-color-on-error-container)}.rules-empty{opacity:.8;font-style:italic;padding:8px 0;color:var(--md-sys-color-on-surface-variant)}.rule-row{display:grid;grid-template-columns:1fr auto;align-items:center;padding:10px 0;border-bottom:1px dashed var(--md-sys-color-outline-variant)}.rule-actions button{margin-left:4px}.banner{background:var(--md-sys-color-tertiary-container);color:var(--md-sys-color-on-tertiary-container);padding:10px 12px;border:1px solid var(--md-sys-color-outline-variant);border-radius:8px;margin-bottom:10px;display:flex;gap:8px;align-items:center}.import-panel{background:var(--pfx-settings-panel-surface, var(--md-sys-color-surface-container-high));border:1px solid var(--pfx-settings-panel-border, var(--md-sys-color-outline-variant));border-radius:8px;padding:10px;margin-bottom:10px}.import-textarea{width:100%;box-sizing:border-box;padding:8px 10px;border-radius:8px;border:1px solid var(--pfx-settings-panel-border, var(--md-sys-color-outline-variant));background:var(--pfx-settings-panel-surface-elevated, var(--md-sys-color-surface-container-low));color:var(--pfx-settings-panel-fg, var(--md-sys-color-on-surface));font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:.85rem}.full{width:100%}.form-row{display:flex;gap:12px;align-items:center;flex-wrap:wrap}.form-grid{display:grid;gap:12px;padding:8px 0}.map-table{width:100%;border-collapse:collapse}.map-table th,.map-table td{padding:8px;border-bottom:1px dashed var(--md-sys-color-outline-variant)}.kbd{font-family:monospace;background:var(--md-sys-color-surface-container-highest);padding:0 6px;border-radius:4px;color:var(--md-sys-color-on-surface-variant)}.panel-surface{padding-top:8px;margin-bottom:12px}.panel-stack{display:grid;gap:12px;margin:12px 0}.stack-panel{background:var(--pfx-settings-panel-surface, var(--md-sys-color-surface-container-high));border:1px solid var(--pfx-settings-panel-border, var(--md-sys-color-outline-variant));border-radius:10px;box-shadow:none}.stack-panel .mat-expansion-panel-content{padding:0 16px 12px}.panel-header{display:flex;align-items:center;gap:8px}.preview-box{background:var(--md-sys-color-surface-container-low);border:1px solid var(--md-sys-color-outline-variant);border-radius:8px;padding:8px;color:var(--md-sys-color-on-surface)}.help-icon-button{--mdc-icon-button-state-layer-size: 28px;--mdc-icon-button-icon-size: 18px;width:28px;height:28px;padding:0;display:inline-flex;align-items:center;justify-content:center;margin-right:-4px}.help-icon-button mat-icon{font-size:18px;width:18px;height:18px}.mat-mdc-form-field-icon-suffix{align-self:center}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i2$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i1$2.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: "component", type: i1$2.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i3$1.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i5.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "component", type: i3.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i3.MatLabel, selector: "mat-label" }, { kind: "directive", type: i3.MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { kind: "ngmodule", type: MatChipsModule }, { kind: "ngmodule", type: MatListModule }, { kind: "ngmodule", type: MatDividerModule }, { kind: "ngmodule", type: MatSelectModule }, { kind: "component", type: i6.MatSelect, selector: "mat-select", inputs: ["aria-describedby", "panelClass", "disabled", "disableRipple", "tabIndex", "hideSingleSelectionIndicator", "placeholder", "required", "multiple", "disableOptionCentering", "compareWith", "value", "aria-label", "aria-labelledby", "errorStateMatcher", "typeaheadDebounceInterval", "sortComparator", "id", "panelWidth", "canSelectNullableOptions"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "component", type: i6.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "ngmodule", type: MatCheckboxModule }, { kind: "component", type: i9.MatCheckbox, selector: "mat-checkbox", inputs: ["aria-label", "aria-labelledby", "aria-describedby", "aria-expanded", "aria-controls", "aria-owns", "id", "required", "labelPosition", "name", "value", "disableRipple", "tabIndex", "color", "disabledInteractive", "checked", "disabled", "indeterminate"], outputs: ["change", "indeterminateChange"], exportAs: ["matCheckbox"] }, { kind: "ngmodule", type: MatExpansionModule }, { kind: "component", type: i5$1.MatExpansionPanel, selector: "mat-expansion-panel", inputs: ["hideToggle", "togglePosition"], outputs: ["afterExpand", "afterCollapse"], exportAs: ["matExpansionPanel"] }, { kind: "component", type: i5$1.MatExpansionPanelHeader, selector: "mat-expansion-panel-header", inputs: ["expandedHeight", "collapsedHeight", "tabIndex"] }, { kind: "directive", type: i5$1.MatExpansionPanelTitle, selector: "mat-panel-title" }, { kind: "ngmodule", type: MatMenuModule }, { kind: "component", type: i11.MatMenu, selector: "mat-menu", inputs: ["backdropClass", "aria-label", "aria-labelledby", "aria-describedby", "xPosition", "yPosition", "overlapTrigger", "hasBackdrop", "class", "classList"], outputs: ["closed", "close"], exportAs: ["matMenu"] }, { kind: "component", type: i11.MatMenuItem, selector: "[mat-menu-item]", inputs: ["role", "disabled", "disableRipple"], exportAs: ["matMenuItem"] }, { kind: "directive", type: i11.MatMenuTrigger, selector: "[mat-menu-trigger-for], [matMenuTriggerFor]", inputs: ["mat-menu-trigger-for", "matMenuTriggerFor", "matMenuTriggerData", "matMenuTriggerRestoreFocus"], outputs: ["menuOpened", "onMenuOpen", "menuClosed", "onMenuClose"], exportAs: ["matMenuTrigger"] }, { kind: "ngmodule", type: MatAutocompleteModule }, { kind: "component", type: i12.MatAutocomplete, selector: "mat-autocomplete", inputs: ["aria-label", "aria-labelledby", "displayWith", "autoActiveFirstOption", "autoSelectActiveOption", "requireSelection", "panelWidth", "disableRipple", "class", "hideSingleSelectionIndicator"], outputs: ["optionSelected", "opened", "closed", "optionActivated"], exportAs: ["matAutocomplete"] }, { kind: "directive", type: i12.MatAutocompleteTrigger, selector: "input[matAutocomplete], textarea[matAutocomplete]", inputs: ["matAutocomplete", "matAutocompletePosition", "matAutocompleteConnectedTo", "autocomplete", "matAutocompleteDisabled"], exportAs: ["matAutocompleteTrigger"] }, { kind: "ngmodule", type: ScrollingModule }, { kind: "directive", type: i13.CdkFixedSizeVirtualScroll, selector: "cdk-virtual-scroll-viewport[itemSize]", inputs: ["itemSize", "minBufferPx", "maxBufferPx"] }, { kind: "directive", type: i13.CdkVirtualForOf, selector: "[cdkVirtualFor][cdkVirtualForOf]", inputs: ["cdkVirtualForOf", "cdkVirtualForTrackBy", "cdkVirtualForTemplate", "cdkVirtualForTemplateCacheSize"] }, { kind: "component", type: i13.CdkVirtualScrollViewport, selector: "cdk-virtual-scroll-viewport", inputs: ["orientation", "appendOnly"], outputs: ["scrolledIndexChange"] }, { kind: "directive", type: PraxisIconDirective, selector: "mat-icon[praxisIcon]", inputs: ["praxisIcon"] }] });
14038
+ `, isInline: true, styles: [":host{display:block;container-type:inline-size}.cm-root{display:grid;grid-template-columns:minmax(220px,280px) minmax(0,1fr);gap:16px;min-height:420px}.cm-left{border-right:1px solid var(--pfx-settings-panel-border, var(--md-sys-color-outline-variant));padding-right:8px;min-width:0}.cm-search{width:100%;margin-bottom:8px}.cm-right{padding-left:8px;min-width:0}@container (max-width: 720px){.cm-root{grid-template-columns:1fr}.cm-left{border-right:none;border-bottom:1px solid var(--pfx-settings-panel-border, var(--md-sys-color-outline-variant));padding-right:0;padding-bottom:12px}.cm-right{padding-left:0}}.field-item{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px;cursor:pointer;border-radius:10px;transition:background .12s ease,border .12s ease;border:1px solid transparent}.field-item:hover{background:var(--md-sys-color-surface-container-high)}.field-item.selected{background:var(--md-sys-color-surface-container-highest);border-color:var(--md-sys-color-outline-variant)}.field-item.has-cycle{border-color:var(--md-sys-color-error)}.field-item-main{min-width:0;flex:1}.field-item-title{font-weight:500;word-break:break-word}.field-item-meta{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-top:2px;font-size:.75rem;color:var(--md-sys-color-on-surface-variant)}.field-role-pill{font-size:.7rem;padding:1px 6px;border-radius:999px;background:var(--md-sys-color-surface-container-high);color:var(--md-sys-color-on-surface-variant)}.field-role-pill--remote{background:color-mix(in srgb,var(--md-sys-color-primary-container) 65%,transparent);color:var(--md-sys-color-on-primary-container)}.rule-badges{display:inline-flex;gap:6px;flex-wrap:wrap;justify-content:flex-end}.rule-badge{font-size:11px;padding:2px 8px;border-radius:999px;font-weight:600;white-space:nowrap}.b-active{background:var(--md-sys-color-primary-container);color:var(--md-sys-color-on-primary-container)}.b-manual{background:var(--md-sys-color-surface-container-high);color:var(--md-sys-color-on-surface-variant)}.b-immediate{background:var(--md-sys-color-secondary-container);color:var(--md-sys-color-on-secondary-container)}.b-conflict{background:var(--md-sys-color-error-container);color:var(--md-sys-color-on-error-container)}.cm-header{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:8px}.cm-feedback{padding:8px 12px;border-radius:8px;font-size:.85rem;margin-bottom:10px;border:1px solid var(--md-sys-color-outline-variant);background:var(--md-sys-color-secondary-container);color:var(--md-sys-color-on-secondary-container)}.cm-feedback--error{border-color:var(--md-sys-color-error);background:var(--md-sys-color-error-container);color:var(--md-sys-color-on-error-container)}.cm-cycle-alert{padding:10px 12px;border-radius:8px;font-size:.85rem;margin-bottom:10px;border:1px solid var(--md-sys-color-error);background:var(--md-sys-color-error-container);color:var(--md-sys-color-on-error-container);display:flex;gap:10px;align-items:flex-start}.cm-cycle-alert mat-icon{color:var(--md-sys-color-error);flex-shrink:0;margin-top:2px}.cm-cycle-alert-body{display:grid;gap:4px;flex:1}.cm-source-notice{padding:8px 12px;border-radius:8px;font-size:.82rem;margin-bottom:10px;border:1px solid var(--md-sys-color-outline-variant);background:var(--md-sys-color-surface-container-low);color:var(--md-sys-color-on-surface-variant);display:flex;gap:8px;align-items:center}.rules-empty{opacity:.88;padding:8px 0;font-size:.85rem;color:var(--md-sys-color-on-surface-variant)}.rule-row{display:grid;grid-template-columns:1fr auto;align-items:center;padding:10px 0;border-bottom:1px dashed var(--md-sys-color-outline-variant);gap:8px}.rule-summary-direction{font-size:.85rem;margin-top:2px}.rule-summary-policies{font-size:.8rem;opacity:.78;margin-top:2px}.rule-actions button{margin-left:4px}.banner{background:var(--md-sys-color-tertiary-container);color:var(--md-sys-color-on-tertiary-container);padding:10px 12px;border:1px solid var(--md-sys-color-outline-variant);border-radius:8px;margin-bottom:10px;display:flex;gap:8px;align-items:center}.import-panel{background:var(--pfx-settings-panel-surface, var(--md-sys-color-surface-container-high));border:1px solid var(--pfx-settings-panel-border, var(--md-sys-color-outline-variant));border-radius:8px;padding:10px;margin-bottom:10px}.import-textarea{width:100%;box-sizing:border-box;padding:8px 10px;border-radius:8px;border:1px solid var(--pfx-settings-panel-border, var(--md-sys-color-outline-variant));background:var(--pfx-settings-panel-surface-elevated, var(--md-sys-color-surface-container-low));color:var(--pfx-settings-panel-fg, var(--md-sys-color-on-surface));font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:.85rem}.full{width:100%}.form-row{display:flex;gap:12px;align-items:center;flex-wrap:wrap}.form-grid{display:grid;gap:12px;padding:8px 0}.section-caption{margin:0 0 6px;font-size:.82rem;color:var(--md-sys-color-on-surface-variant)}.map-table{width:100%;border-collapse:collapse}.map-table th,.map-table td{padding:8px;border-bottom:1px dashed var(--md-sys-color-outline-variant);text-align:left;vertical-align:middle}.kbd{font-family:monospace;background:var(--md-sys-color-surface-container-highest);padding:0 6px;border-radius:4px;color:var(--md-sys-color-on-surface-variant)}.panel-surface{padding-top:8px;margin-bottom:12px}.panel-stack{display:grid;gap:12px;margin:12px 0}.stack-panel{background:var(--pfx-settings-panel-surface, var(--md-sys-color-surface-container-high));border:1px solid var(--pfx-settings-panel-border, var(--md-sys-color-outline-variant));border-radius:10px;box-shadow:none}.stack-panel .mat-expansion-panel-content{padding:0 16px 12px}.panel-header{display:flex;align-items:center;gap:8px}.preview-box{background:var(--md-sys-color-surface-container-low);border:1px solid var(--md-sys-color-outline-variant);border-radius:8px;padding:8px;color:var(--md-sys-color-on-surface);margin:4px 0 0}.help-icon-button{--mdc-icon-button-state-layer-size: 28px;--mdc-icon-button-icon-size: 18px;width:28px;height:28px;padding:0;display:inline-flex;align-items:center;justify-content:center;margin-right:-4px}.help-icon-button mat-icon{font-size:18px;width:18px;height:18px}.mat-mdc-form-field-icon-suffix{align-self:center}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i2$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i1$2.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: "component", type: i1$2.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i3$1.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i5.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "component", type: i3.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i3.MatLabel, selector: "mat-label" }, { kind: "directive", type: i3.MatSuffix, selector: "[matSuffix], [matIconSuffix], [matTextSuffix]", inputs: ["matTextSuffix"] }, { kind: "ngmodule", type: MatChipsModule }, { kind: "ngmodule", type: MatListModule }, { kind: "ngmodule", type: MatDividerModule }, { kind: "ngmodule", type: MatSelectModule }, { kind: "component", type: i6.MatSelect, selector: "mat-select", inputs: ["aria-describedby", "panelClass", "disabled", "disableRipple", "tabIndex", "hideSingleSelectionIndicator", "placeholder", "required", "multiple", "disableOptionCentering", "compareWith", "value", "aria-label", "aria-labelledby", "errorStateMatcher", "typeaheadDebounceInterval", "sortComparator", "id", "panelWidth", "canSelectNullableOptions"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "component", type: i6.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "ngmodule", type: MatCheckboxModule }, { kind: "component", type: i9.MatCheckbox, selector: "mat-checkbox", inputs: ["aria-label", "aria-labelledby", "aria-describedby", "aria-expanded", "aria-controls", "aria-owns", "id", "required", "labelPosition", "name", "value", "disableRipple", "tabIndex", "color", "disabledInteractive", "checked", "disabled", "indeterminate"], outputs: ["change", "indeterminateChange"], exportAs: ["matCheckbox"] }, { kind: "ngmodule", type: MatExpansionModule }, { kind: "component", type: i5$1.MatExpansionPanel, selector: "mat-expansion-panel", inputs: ["hideToggle", "togglePosition"], outputs: ["afterExpand", "afterCollapse"], exportAs: ["matExpansionPanel"] }, { kind: "component", type: i5$1.MatExpansionPanelHeader, selector: "mat-expansion-panel-header", inputs: ["expandedHeight", "collapsedHeight", "tabIndex"] }, { kind: "directive", type: i5$1.MatExpansionPanelTitle, selector: "mat-panel-title" }, { kind: "ngmodule", type: MatMenuModule }, { kind: "component", type: i11.MatMenu, selector: "mat-menu", inputs: ["backdropClass", "aria-label", "aria-labelledby", "aria-describedby", "xPosition", "yPosition", "overlapTrigger", "hasBackdrop", "class", "classList"], outputs: ["closed", "close"], exportAs: ["matMenu"] }, { kind: "component", type: i11.MatMenuItem, selector: "[mat-menu-item]", inputs: ["role", "disabled", "disableRipple"], exportAs: ["matMenuItem"] }, { kind: "directive", type: i11.MatMenuTrigger, selector: "[mat-menu-trigger-for], [matMenuTriggerFor]", inputs: ["mat-menu-trigger-for", "matMenuTriggerFor", "matMenuTriggerData", "matMenuTriggerRestoreFocus"], outputs: ["menuOpened", "onMenuOpen", "menuClosed", "onMenuClose"], exportAs: ["matMenuTrigger"] }, { kind: "ngmodule", type: MatAutocompleteModule }, { kind: "component", type: i12.MatAutocomplete, selector: "mat-autocomplete", inputs: ["aria-label", "aria-labelledby", "displayWith", "autoActiveFirstOption", "autoSelectActiveOption", "requireSelection", "panelWidth", "disableRipple", "class", "hideSingleSelectionIndicator"], outputs: ["optionSelected", "opened", "closed", "optionActivated"], exportAs: ["matAutocomplete"] }, { kind: "directive", type: i12.MatAutocompleteTrigger, selector: "input[matAutocomplete], textarea[matAutocomplete]", inputs: ["matAutocomplete", "matAutocompletePosition", "matAutocompleteConnectedTo", "autocomplete", "matAutocompleteDisabled"], exportAs: ["matAutocompleteTrigger"] }, { kind: "ngmodule", type: ScrollingModule }, { kind: "directive", type: i13.CdkFixedSizeVirtualScroll, selector: "cdk-virtual-scroll-viewport[itemSize]", inputs: ["itemSize", "minBufferPx", "maxBufferPx"] }, { kind: "directive", type: i13.CdkVirtualForOf, selector: "[cdkVirtualFor][cdkVirtualForOf]", inputs: ["cdkVirtualForOf", "cdkVirtualForTrackBy", "cdkVirtualForTemplate", "cdkVirtualForTemplateCacheSize"] }, { kind: "component", type: i13.CdkVirtualScrollViewport, selector: "cdk-virtual-scroll-viewport", inputs: ["orientation", "appendOnly"], outputs: ["scrolledIndexChange"] }, { kind: "directive", type: PraxisIconDirective, selector: "mat-icon[praxisIcon]", inputs: ["praxisIcon"] }] });
13375
14039
  }
13376
14040
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: CascadeManagerTabComponent, decorators: [{
13377
14041
  type: Component,
@@ -13402,14 +14066,29 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
13402
14066
  </mat-form-field>
13403
14067
  @if (fields.length <= 50) {
13404
14068
  <div>
13405
- @for (f of filteredFields(); track f) {
13406
- <div (click)="selectField(f.name)" class="field-item" [class.selected]="f.name===selectedField()" tabindex="0" (keydown)="onFieldKeydown($event, f.name)">
13407
- <div>
13408
- <div>{{ labelFor(f.name) }}</div>
13409
- <small style="opacity:.7">{{ f.name }}</small>
14069
+ @for (f of filteredFields(); track f.name) {
14070
+ <div
14071
+ (click)="selectField(f.name)"
14072
+ class="field-item"
14073
+ [class.selected]="f.name===selectedField()"
14074
+ [class.has-cycle]="isFieldInCycle(f.name)"
14075
+ tabindex="0"
14076
+ (keydown)="onFieldKeydown($event, f.name)"
14077
+ >
14078
+ <div class="field-item-main">
14079
+ <div class="field-item-title">{{ disambiguatedLabelFor(f.name) }}</div>
14080
+ <div class="field-item-meta">
14081
+ <span>{{ f.name }}</span>
14082
+ <span class="field-role-pill" [class.field-role-pill--remote]="hasRemoteOptionSource(f.name)">
14083
+ {{ fieldRoleBadge(f.name) }}
14084
+ </span>
14085
+ </div>
13410
14086
  </div>
13411
- @if (hasRule(f.name)) {
13412
- <div class="rule-badges">
14087
+ <div class="rule-badges">
14088
+ @if (isFieldInCycle(f.name)) {
14089
+ <span class="rule-badge b-conflict">Ciclo</span>
14090
+ }
14091
+ @if (hasRule(f.name)) {
13413
14092
  @if (isCascadeEnabled(f.name)) {
13414
14093
  <span class="rule-badge b-active">Ativo</span>
13415
14094
  }
@@ -13417,22 +14096,38 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
13417
14096
  <span class="rule-badge b-manual">Manual</span>
13418
14097
  }
13419
14098
  @if (loadOnChange(f.name)==='immediate') {
13420
- <span class="rule-badge b-immediate">Immediate</span>
14099
+ <span class="rule-badge b-immediate">Imediato</span>
13421
14100
  }
13422
- </div>
13423
- }
14101
+ }
14102
+ </div>
13424
14103
  </div>
13425
14104
  }
13426
14105
  </div>
13427
14106
  } @else {
13428
- <cdk-virtual-scroll-viewport itemSize="44" style="height: calc(100vh - 240px)">
13429
- <div *cdkVirtualFor="let f of filteredFields()" (click)="selectField(f.name)" class="field-item" [class.selected]="f.name===selectedField()" tabindex="0" (keydown)="onFieldKeydown($event, f.name)">
13430
- <div>
13431
- <div>{{ labelFor(f.name) }}</div>
13432
- <small style="opacity:.7">{{ f.name }}</small>
14107
+ <cdk-virtual-scroll-viewport itemSize="48" style="height: calc(100vh - 240px)">
14108
+ <div
14109
+ *cdkVirtualFor="let f of filteredFields()"
14110
+ (click)="selectField(f.name)"
14111
+ class="field-item"
14112
+ [class.selected]="f.name===selectedField()"
14113
+ [class.has-cycle]="isFieldInCycle(f.name)"
14114
+ tabindex="0"
14115
+ (keydown)="onFieldKeydown($event, f.name)"
14116
+ >
14117
+ <div class="field-item-main">
14118
+ <div class="field-item-title">{{ disambiguatedLabelFor(f.name) }}</div>
14119
+ <div class="field-item-meta">
14120
+ <span>{{ f.name }}</span>
14121
+ <span class="field-role-pill" [class.field-role-pill--remote]="hasRemoteOptionSource(f.name)">
14122
+ {{ fieldRoleBadge(f.name) }}
14123
+ </span>
14124
+ </div>
13433
14125
  </div>
13434
- @if (hasRule(f.name)) {
13435
- <div class="rule-badges">
14126
+ <div class="rule-badges">
14127
+ @if (isFieldInCycle(f.name)) {
14128
+ <span class="rule-badge b-conflict">Ciclo</span>
14129
+ }
14130
+ @if (hasRule(f.name)) {
13436
14131
  @if (isCascadeEnabled(f.name)) {
13437
14132
  <span class="rule-badge b-active">Ativo</span>
13438
14133
  }
@@ -13440,27 +14135,27 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
13440
14135
  <span class="rule-badge b-manual">Manual</span>
13441
14136
  }
13442
14137
  @if (loadOnChange(f.name)==='immediate') {
13443
- <span class="rule-badge b-immediate">Immediate</span>
14138
+ <span class="rule-badge b-immediate">Imediato</span>
13444
14139
  }
13445
- </div>
13446
- }
14140
+ }
14141
+ </div>
13447
14142
  </div>
13448
14143
  </cdk-virtual-scroll-viewport>
13449
14144
  }
13450
14145
  </div>
13451
-
14146
+
13452
14147
  <!-- Right: Rules for selected field -->
13453
14148
  @if (selectedField(); as sel) {
13454
14149
  <div class="cm-right">
13455
14150
  <div class="cm-header">
13456
14151
  <div>
13457
- <h3 style="margin:0">{{ labelFor(sel) }}</h3>
13458
- <div style="opacity:.7">{{ sel }}</div>
14152
+ <h3 style="margin:0">{{ disambiguatedLabelFor(sel) }}</h3>
14153
+ <div style="opacity:.75; font-size:0.85rem">{{ sel }} · {{ fieldRoleSummary(sel) }}</div>
13459
14154
  </div>
13460
14155
  <div>
13461
14156
  <button mat-stroked-button color="primary" (click)="exportRules()" style="margin-right:8px">Exportar</button>
13462
14157
  <button mat-stroked-button (click)="toggleImport()" style="margin-right:8px">Importar</button>
13463
- <button mat-flat-button color="primary" [matMenuTriggerFor]="addMenu" [disabled]="editing()">Adicionar</button>
14158
+ <button mat-flat-button color="primary" [matMenuTriggerFor]="addMenu">Modelos</button>
13464
14159
  <mat-menu #addMenu="matMenu">
13465
14160
  <button mat-menu-item (click)="startEdit(sel)"><mat-icon [praxisIcon]="'add'"></mat-icon><span>Em branco</span></button>
13466
14161
  <button mat-menu-item (click)="startTemplate(sel, 'rota-destino')" [disabled]="!hasField('rotaId') || !hasField('destinoReqId')"><mat-icon [praxisIcon]="'bolt'"></mat-icon><span>Rota → Destino (id)</span></button>
@@ -13468,9 +14163,45 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
13468
14163
  </mat-menu>
13469
14164
  </div>
13470
14165
  </div>
14166
+
14167
+ @if (activeCycleDiagnostics().length > 0) {
14168
+ <div class="cm-cycle-alert" role="alert" data-testid="cascade-cycle-diagnostic">
14169
+ <mat-icon [praxisIcon]="'error'"></mat-icon>
14170
+ <div class="cm-cycle-alert-body">
14171
+ <strong>Dependência circular detectada — não é possível aplicar enquanto houver ciclo</strong>
14172
+ @for (cycle of activeCycleDiagnostics(); track cycle.formattedPath) {
14173
+ <div>{{ cycle.message }}</div>
14174
+ }
14175
+ @if (isFieldInCycle(sel)) {
14176
+ <div style="margin-top: 6px;">
14177
+ <button
14178
+ mat-stroked-button
14179
+ type="button"
14180
+ data-testid="cascade-break-cycle-button"
14181
+ (click)="breakCycleForField(sel)"
14182
+ >
14183
+ Remover dependência circular deste campo
14184
+ </button>
14185
+ </div>
14186
+ }
14187
+ </div>
14188
+ </div>
14189
+ }
14190
+
13471
14191
  @if (feedbackMessage) {
13472
14192
  <div class="cm-feedback" [class.cm-feedback--error]="feedbackTone === 'error'" role="status">{{ feedbackMessage }}</div>
13473
14193
  }
14194
+
14195
+ @if (!hasRemoteOptionSource(sel)) {
14196
+ <div class="cm-source-notice" role="note" data-testid="cascade-no-remote-source-notice">
14197
+ <mat-icon [praxisIcon]="'info'"></mat-icon>
14198
+ <div>
14199
+ Este campo ({{ fieldRoleBadge(sel) }}) não declara fonte de opções remota (<code class="kbd">resourcePath</code> / <code class="kbd">optionSource</code>).
14200
+ Campos de texto ou nomes resolvidos exibem valores derivados; para recarregar opções em cascata, selecione o campo selecionável (ex.: ID) correspondente.
14201
+ </div>
14202
+ </div>
14203
+ }
14204
+
13474
14205
  <!-- Conflict banner (connections) -->
13475
14206
  @if (hasConflict(sel)) {
13476
14207
  <div class="banner">
@@ -13483,6 +14214,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
13483
14214
  <button mat-stroked-button color="accent" (click)="quickSet(sel,'manual')">Definir manual</button>
13484
14215
  </div>
13485
14216
  }
14217
+
13486
14218
  @if (importMode) {
13487
14219
  <div class="import-panel">
13488
14220
  <div style="display:flex; gap:8px; align-items:center; margin-bottom:8px"><mat-icon [praxisIcon]="'upload'"></mat-icon><strong>Importar regras (JSON)</strong></div>
@@ -13493,33 +14225,45 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
13493
14225
  </div>
13494
14226
  </div>
13495
14227
  }
13496
- @if (!ruleFor(sel)) {
14228
+
14229
+ @if (!hasRule(sel)) {
13497
14230
  <div>
13498
- <div class="rules-empty">Nenhuma cascata configurada. Clique em “Adicionar” para montar a dependência.</div>
14231
+ <div class="rules-empty" data-testid="cascade-empty-guidance">
14232
+ Nenhuma cascata salva para <strong>{{ disambiguatedLabelFor(sel) }}</strong>.
14233
+ Selecione abaixo em <strong>“Este campo depende de (campos pais)”</strong> quais campos controlam as opções deste campo e clique em <strong>Salvar</strong>.
14234
+ </div>
13499
14235
  </div>
13500
14236
  }
13501
- @if (ruleFor(sel); as rule) {
14237
+ @if (hasRule(sel) && ruleFor(sel); as rule) {
13502
14238
  <div>
13503
14239
  <div class="rule-row">
13504
14240
  <div>
13505
- <div><strong>Dependentes:</strong> {{ rule.dependencyFields.join(', ') || '—' }}</div>
13506
- <div style="opacity:.7">Load on change: {{ rule.dependencyLoadOnChange || 'respectLoadOn' }} | Merge: {{ rule.dependencyMergeStrategy || 'merge' }}</div>
14241
+ <div><strong>Depende de (campos pais):</strong> {{ formatParentList(rule.dependencyFields) }}</div>
14242
+ <div class="rule-summary-direction">
14243
+ <strong>Fluxo:</strong> {{ formatRelationshipSummary(sel, rule.dependencyFields) }}
14244
+ </div>
14245
+ <div class="rule-summary-policies">
14246
+ Quando recarregar: {{ formatLoadOnLabel(rule.dependencyLoadOnChange) }} | Como combinar filtros: {{ formatMergeStrategyLabel(rule.dependencyMergeStrategy) }}
14247
+ </div>
13507
14248
  </div>
13508
14249
  <div class="rule-actions">
13509
- <button mat-icon-button matTooltip="Editar" (click)="startEdit(sel)"><mat-icon [praxisIcon]="'edit'"></mat-icon></button>
13510
- <button mat-icon-button matTooltip="Duplicar" disabled><mat-icon [praxisIcon]="'content_copy'"></mat-icon></button>
13511
- <button mat-icon-button matTooltip="Remover" (click)="remove(sel)"><mat-icon [praxisIcon]="'delete'"></mat-icon></button>
14250
+ <button mat-icon-button type="button" aria-label="Editar regra de cascata" matTooltip="Editar" (click)="startEdit(sel)"><mat-icon [praxisIcon]="'edit'"></mat-icon></button>
14251
+ <button mat-icon-button type="button" aria-label="Duplicar regra de cascata" matTooltip="Duplicar" disabled><mat-icon [praxisIcon]="'content_copy'"></mat-icon></button>
14252
+ <button mat-icon-button type="button" aria-label="Remover regra de cascata" matTooltip="Remover" (click)="remove(sel)"><mat-icon [praxisIcon]="'delete'"></mat-icon></button>
13512
14253
  </div>
13513
14254
  </div>
13514
14255
  </div>
13515
14256
  }
14257
+
13516
14258
  <!-- Editor com seções (sem acordeão duplo) -->
13517
14259
  <div class="panel-surface">
13518
14260
  <div class="panel-header" style="padding: 8px 4px; border-bottom: 1px solid var(--pfx-settings-panel-border, var(--md-sys-color-outline-variant));">
13519
14261
  <mat-icon [praxisIcon]="'rule'"></mat-icon>
13520
14262
  <div>
13521
14263
  <div>Cascata do campo</div>
13522
- <small style="color: var(--md-sys-color-on-surface-variant);">Defina dependentes, quando recarregar e como mapear filtros</small>
14264
+ <small style="color: var(--md-sys-color-on-surface-variant);">
14265
+ Defina de quais campos pais este campo depende, quando recarregar opções e qual filtro enviar ao serviço
14266
+ </small>
13523
14267
  </div>
13524
14268
  </div>
13525
14269
  <div class="panel-stack">
@@ -13529,13 +14273,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
13529
14273
  <mat-panel-title class="panel-header"><mat-icon [praxisIcon]="'tune'"></mat-icon> Básico</mat-panel-title>
13530
14274
  </mat-expansion-panel-header>
13531
14275
  <div class="form-grid">
14276
+ <p class="section-caption">
14277
+ Quando o usuário alterar qualquer um dos campos pais escolhidos abaixo, as opções disponíveis em <strong>{{ disambiguatedLabelFor(sel) }}</strong> serão filtradas.
14278
+ </p>
13532
14279
  <div class="form-row">
13533
14280
  <mat-form-field appearance="outline" class="full">
13534
- <mat-label>Campos dependentes</mat-label>
14281
+ <mat-label>Este campo depende de (campos pais)</mat-label>
13535
14282
  <mat-select multiple [(ngModel)]="editDepList" (ngModelChange)="onDepListChange()">
13536
- @for (f of availableDependencyFields(); track f) {
14283
+ @for (f of availableDependencyFields(); track f.name) {
13537
14284
  <mat-option [value]="f.name">
13538
- {{ labelFor(f.name) }} ({{ f.name }})
14285
+ {{ disambiguatedLabelFor(f.name) }} ({{ f.name }}) · {{ fieldRoleBadge(f.name) }}
13539
14286
  </mat-option>
13540
14287
  }
13541
14288
  </mat-select>
@@ -13544,42 +14291,54 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
13544
14291
  matSuffix
13545
14292
  class="help-icon-button"
13546
14293
  type="button"
13547
- [matTooltip]="'Selecione os campos que controlam este valor'"
14294
+ [matTooltip]="'Selecione os campos pais (origem) cujo valor filtrará as opções deste campo (destino)'"
13548
14295
  matTooltipPosition="above"
13549
14296
  >
13550
14297
  <mat-icon [praxisIcon]="'help_outline'"></mat-icon>
13551
14298
  </button>
13552
14299
  </mat-form-field>
13553
14300
  </div>
14301
+ @if (editDepList.length > 0) {
14302
+ <div class="section-caption" data-testid="cascade-draft-direction-summary">
14303
+ <strong>Resumo da relação:</strong> {{ formatRelationshipSummary(sel, editDepList) }}
14304
+ </div>
14305
+ }
13554
14306
  <div class="form-row">
13555
- <mat-checkbox [(ngModel)]="editEnable">Ativar cascata nativa</mat-checkbox>
13556
- <mat-checkbox [(ngModel)]="editReset">Resetar ao mudar</mat-checkbox>
14307
+ <mat-checkbox [(ngModel)]="editEnable" (ngModelChange)="onDraftControlChange()">
14308
+ Ativar cascata automática neste campo
14309
+ </mat-checkbox>
14310
+ <mat-checkbox [(ngModel)]="editReset" (ngModelChange)="onDraftControlChange()">
14311
+ Limpar seleção deste campo quando o campo pai mudar
14312
+ </mat-checkbox>
13557
14313
  </div>
13558
14314
  </div>
13559
14315
  </mat-expansion-panel>
13560
14316
  <!-- Execução -->
13561
14317
  <mat-expansion-panel class="stack-panel">
13562
14318
  <mat-expansion-panel-header>
13563
- <mat-panel-title class="panel-header"><mat-icon [praxisIcon]="'schedule'"></mat-icon> Execução</mat-panel-title>
14319
+ <mat-panel-title class="panel-header"><mat-icon [praxisIcon]="'schedule'"></mat-icon> Quando recarregar e combinar filtros</mat-panel-title>
13564
14320
  </mat-expansion-panel-header>
14321
+ <p class="section-caption">
14322
+ Controle o momento em que a consulta de opções é disparada e como o filtro do campo pai convive com outros filtros do campo.
14323
+ </p>
13565
14324
  <div class="form-row">
13566
14325
  <mat-form-field appearance="outline">
13567
- <mat-label>Carregar ao mudar</mat-label>
14326
+ <mat-label>Quando recarregar opções</mat-label>
13568
14327
  <mat-select [(ngModel)]="editLoadOn">
13569
- <mat-option value="respectLoadOn">Respeitar loadOn</mat-option>
13570
- <mat-option value="immediate">Imediato</mat-option>
13571
- <mat-option value="manual">Manual</mat-option>
14328
+ <mat-option value="respectLoadOn">Seguir configuração do campo (respectLoadOn)</mat-option>
14329
+ <mat-option value="immediate">Recarregar imediatamente ao mudar o pai (immediate)</mat-option>
14330
+ <mat-option value="manual">Somente sob comando manual / abertura (manual)</mat-option>
13572
14331
  </mat-select>
13573
14332
  </mat-form-field>
13574
14333
  <mat-form-field appearance="outline">
13575
- <mat-label>Merge de filtros</mat-label>
14334
+ <mat-label>Como combinar filtros</mat-label>
13576
14335
  <mat-select [(ngModel)]="editMerge">
13577
- <mat-option value="merge">merge (padrão)</mat-option>
13578
- <mat-option value="replace">replace</mat-option>
14336
+ <mat-option value="merge">Mesclar com filtros existentes (merge — padrão)</mat-option>
14337
+ <mat-option value="replace">Substituir filtros anteriores (replace)</mat-option>
13579
14338
  </mat-select>
13580
14339
  </mat-form-field>
13581
14340
  <mat-form-field appearance="outline">
13582
- <mat-label>Atraso (ms)</mat-label>
14341
+ <mat-label>Atraso após mudança (ms)</mat-label>
13583
14342
  <input matInput type="number" [(ngModel)]="editDebounce">
13584
14343
  </mat-form-field>
13585
14344
  </div>
@@ -13587,18 +14346,21 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
13587
14346
  <!-- Extração de valor -->
13588
14347
  <mat-expansion-panel class="stack-panel">
13589
14348
  <mat-expansion-panel-header>
13590
- <mat-panel-title class="panel-header"><mat-icon [praxisIcon]="'call_split'"></mat-icon> Extração de valor</mat-panel-title>
14349
+ <mat-panel-title class="panel-header"><mat-icon [praxisIcon]="'call_split'"></mat-icon> Qual propriedade ler do campo pai</mat-panel-title>
13591
14350
  </mat-expansion-panel-header>
14351
+ <p class="section-caption">
14352
+ Se o campo pai guardar um objeto completo (ex.: <code class="kbd">&#123; id: 10, nome: "TI" &#125;</code>), informe qual propriedade deve ser extraída para envio no filtro (padrão: <code class="kbd">id</code>).
14353
+ </p>
13592
14354
  <div class="form-row">
13593
14355
  <mat-form-field appearance="outline" class="full">
13594
- <mat-label>ValuePath padrão</mat-label>
14356
+ <mat-label>Propriedade padrão lida do campo pai (ValuePath)</mat-label>
13595
14357
  <input matInput [(ngModel)]="editValuePath" placeholder="id">
13596
14358
  <button
13597
14359
  mat-icon-button
13598
14360
  matSuffix
13599
14361
  class="help-icon-button"
13600
14362
  type="button"
13601
- [matTooltip]="'Preenchido automaticamente a partir de optionValueKey() ou \\'id\\''"
14363
+ [matTooltip]="'Preenchido automaticamente a partir de optionValueKey do campo pai ou \\'id\\''"
13602
14364
  matTooltipPosition="above"
13603
14365
  >
13604
14366
  <mat-icon [praxisIcon]="'help_outline'"></mat-icon>
@@ -13609,24 +14371,30 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
13609
14371
  <!-- Mapeamento de filtros (tabela) -->
13610
14372
  <mat-expansion-panel class="stack-panel">
13611
14373
  <mat-expansion-panel-header>
13612
- <mat-panel-title class="panel-header"><mat-icon [praxisIcon]="'filter_alt'"></mat-icon> Mapeamento de filtros</mat-panel-title>
14374
+ <mat-panel-title class="panel-header"><mat-icon [praxisIcon]="'filter_alt'"></mat-icon> Mapeamento de filtros enviados ao serviço</mat-panel-title>
13613
14375
  </mat-expansion-panel-header>
13614
14376
  @if (editMapRows.length) {
14377
+ <p class="section-caption">
14378
+ Para cada campo pai, defina o nome do parâmetro esperado pelo serviço de opções deste campo e, se necessário, a propriedade específica a extrair.
14379
+ </p>
13615
14380
  <table class="map-table">
13616
14381
  <thead>
13617
14382
  <tr>
13618
- <th>Dependente</th>
13619
- <th>Chave (dot‑path)</th>
13620
- <th>ValuePath (opcional)</th>
14383
+ <th>Campo pai (origem)</th>
14384
+ <th>Parâmetro enviado ao filtro (dot‑path)</th>
14385
+ <th>Propriedade lida do pai (opcional)</th>
13621
14386
  </tr>
13622
14387
  </thead>
13623
14388
  <tbody>
13624
- @for (row of editMapRows; track row; let i = $index) {
14389
+ @for (row of editMapRows; track row.dep; let i = $index) {
13625
14390
  <tr>
13626
- <td>{{ row.dep }}</td>
14391
+ <td>
14392
+ <div><strong>{{ disambiguatedLabelFor(row.dep) }}</strong></div>
14393
+ <small style="opacity:.75">{{ row.dep }}</small>
14394
+ </td>
13627
14395
  <td>
13628
14396
  <mat-form-field appearance="outline" class="full">
13629
- <mat-label>Chave</mat-label>
14397
+ <mat-label>Parâmetro no filtro</mat-label>
13630
14398
  <input matInput [(ngModel)]="row.key" [matAutocomplete]="autoKey" [placeholder]="row.dep">
13631
14399
  <mat-autocomplete #autoKey="matAutocomplete">
13632
14400
  @for (opt of getKeySuggestions(row.dep); track opt) {
@@ -13638,7 +14406,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
13638
14406
  matSuffix
13639
14407
  class="help-icon-button"
13640
14408
  type="button"
13641
- [matTooltip]="'Use dot-notation (e.g. ' + row.dep + '.id) for nested objects'"
14409
+ [matTooltip]="'Use notação com ponto (ex.: ' + row.dep + '.id) quando o backend esperar filtro aninhado'"
13642
14410
  matTooltipPosition="above"
13643
14411
  >
13644
14412
  <mat-icon [praxisIcon]="'help_outline'"></mat-icon>
@@ -13647,7 +14415,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
13647
14415
  </td>
13648
14416
  <td>
13649
14417
  <mat-form-field appearance="outline" class="full">
13650
- <mat-label>ValuePath</mat-label>
14418
+ <mat-label>Propriedade do pai (ValuePath)</mat-label>
13651
14419
  <input matInput [(ngModel)]="row.valuePath" [matAutocomplete]="autoValue" [placeholder]="editValuePath||'id'">
13652
14420
  <mat-autocomplete #autoValue="matAutocomplete">
13653
14421
  @for (opt of getValuePathSuggestions(row.dep); track opt) {
@@ -13659,7 +14427,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
13659
14427
  matSuffix
13660
14428
  class="help-icon-button"
13661
14429
  type="button"
13662
- [matTooltip]="'Opcional'"
14430
+ [matTooltip]="'Opcional: sobrescreve a propriedade padrão lida apenas para este campo pai'"
13663
14431
  matTooltipPosition="above"
13664
14432
  >
13665
14433
  <mat-icon [praxisIcon]="'help_outline'"></mat-icon>
@@ -13671,29 +14439,32 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
13671
14439
  </tbody>
13672
14440
  </table>
13673
14441
  } @else {
13674
- <div class="rules-empty">Defina dependentes para sugerir o mapeamento.</div>
14442
+ <div class="rules-empty">Selecione um ou mais campos pais em “Este campo depende de” para configurar o mapeamento de filtros.</div>
13675
14443
  }
13676
14444
  </mat-expansion-panel>
13677
14445
  <!-- Preview -->
13678
14446
  <mat-expansion-panel class="stack-panel">
13679
14447
  <mat-expansion-panel-header>
13680
- <mat-panel-title class="panel-header"><mat-icon [praxisIcon]="'visibility'"></mat-icon> Preview</mat-panel-title>
14448
+ <mat-panel-title class="panel-header"><mat-icon [praxisIcon]="'visibility'"></mat-icon> Exemplo ilustrativo do filtro enviado (Preview)</mat-panel-title>
13681
14449
  </mat-expansion-panel-header>
14450
+ <p class="section-caption" data-testid="cascade-preview-explanation">
14451
+ Simulação ilustrativa (sem consulta ao servidor): mostra a estrutura JSON de filtro que será enviada ao carregar opções de <strong>{{ disambiguatedLabelFor(sel) }}</strong>.
14452
+ </p>
13682
14453
  <pre class="preview-box">{{ buildPreview() }}</pre>
13683
14454
  <div style="display:flex; justify-content:flex-end; padding-top:6px">
13684
- <button mat-stroked-button (click)="copyPreview()">Copiar</button>
14455
+ <button mat-stroked-button (click)="copyPreview()">Copiar exemplo</button>
13685
14456
  </div>
13686
14457
  </mat-expansion-panel>
13687
14458
  </div>
13688
14459
  <div style="display:flex; gap:8px; justify-content:flex-end; padding:8px 12px;">
13689
- <button mat-stroked-button (click)="cancelEdit()">Cancelar</button>
13690
- <button mat-flat-button color="primary" (click)="saveEdit(sel)">Salvar</button>
14460
+ <button mat-stroked-button type="button" (click)="cancelEdit()">Cancelar</button>
14461
+ <button mat-flat-button color="primary" type="button" (click)="saveEdit(sel)">Salvar</button>
13691
14462
  </div>
13692
14463
  </div>
13693
14464
  </div>
13694
14465
  }
13695
14466
  </div>
13696
- `, styles: [":host{display:block;container-type:inline-size}.cm-root{display:grid;grid-template-columns:minmax(220px,280px) minmax(0,1fr);gap:16px;min-height:420px}.cm-left{border-right:1px solid var(--pfx-settings-panel-border, var(--md-sys-color-outline-variant));padding-right:8px;min-width:0}.cm-search{width:100%;margin-bottom:8px}.cm-right{padding-left:8px;min-width:0}@container (max-width: 720px){.cm-root{grid-template-columns:1fr}.cm-left{border-right:none;border-bottom:1px solid var(--pfx-settings-panel-border, var(--md-sys-color-outline-variant));padding-right:0;padding-bottom:12px}.cm-right{padding-left:0}}.field-item{display:flex;align-items:center;justify-content:space-between;padding:8px 6px;cursor:pointer;border-radius:10px;transition:background .12s ease,border .12s ease;border:1px solid transparent}.field-item:hover{background:var(--md-sys-color-surface-container-high)}.field-item.selected{background:var(--md-sys-color-surface-container-highest);border-color:var(--md-sys-color-outline-variant)}.rule-badges{display:inline-flex;gap:6px}.rule-badge{font-size:11px;padding:2px 8px;border-radius:999px;font-weight:600}.b-active{background:var(--md-sys-color-primary-container);color:var(--md-sys-color-on-primary-container)}.b-manual{background:var(--md-sys-color-surface-container-high);color:var(--md-sys-color-on-surface-variant)}.b-immediate{background:var(--md-sys-color-secondary-container);color:var(--md-sys-color-on-secondary-container)}.b-conflict{background:var(--md-sys-color-error-container);color:var(--md-sys-color-on-error-container)}.cm-header{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:8px}.cm-feedback{padding:8px 12px;border-radius:8px;font-size:.85rem;margin-bottom:10px;border:1px solid var(--md-sys-color-outline-variant);background:var(--md-sys-color-secondary-container);color:var(--md-sys-color-on-secondary-container)}.cm-feedback--error{border-color:var(--md-sys-color-error);background:var(--md-sys-color-error-container);color:var(--md-sys-color-on-error-container)}.rules-empty{opacity:.8;font-style:italic;padding:8px 0;color:var(--md-sys-color-on-surface-variant)}.rule-row{display:grid;grid-template-columns:1fr auto;align-items:center;padding:10px 0;border-bottom:1px dashed var(--md-sys-color-outline-variant)}.rule-actions button{margin-left:4px}.banner{background:var(--md-sys-color-tertiary-container);color:var(--md-sys-color-on-tertiary-container);padding:10px 12px;border:1px solid var(--md-sys-color-outline-variant);border-radius:8px;margin-bottom:10px;display:flex;gap:8px;align-items:center}.import-panel{background:var(--pfx-settings-panel-surface, var(--md-sys-color-surface-container-high));border:1px solid var(--pfx-settings-panel-border, var(--md-sys-color-outline-variant));border-radius:8px;padding:10px;margin-bottom:10px}.import-textarea{width:100%;box-sizing:border-box;padding:8px 10px;border-radius:8px;border:1px solid var(--pfx-settings-panel-border, var(--md-sys-color-outline-variant));background:var(--pfx-settings-panel-surface-elevated, var(--md-sys-color-surface-container-low));color:var(--pfx-settings-panel-fg, var(--md-sys-color-on-surface));font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:.85rem}.full{width:100%}.form-row{display:flex;gap:12px;align-items:center;flex-wrap:wrap}.form-grid{display:grid;gap:12px;padding:8px 0}.map-table{width:100%;border-collapse:collapse}.map-table th,.map-table td{padding:8px;border-bottom:1px dashed var(--md-sys-color-outline-variant)}.kbd{font-family:monospace;background:var(--md-sys-color-surface-container-highest);padding:0 6px;border-radius:4px;color:var(--md-sys-color-on-surface-variant)}.panel-surface{padding-top:8px;margin-bottom:12px}.panel-stack{display:grid;gap:12px;margin:12px 0}.stack-panel{background:var(--pfx-settings-panel-surface, var(--md-sys-color-surface-container-high));border:1px solid var(--pfx-settings-panel-border, var(--md-sys-color-outline-variant));border-radius:10px;box-shadow:none}.stack-panel .mat-expansion-panel-content{padding:0 16px 12px}.panel-header{display:flex;align-items:center;gap:8px}.preview-box{background:var(--md-sys-color-surface-container-low);border:1px solid var(--md-sys-color-outline-variant);border-radius:8px;padding:8px;color:var(--md-sys-color-on-surface)}.help-icon-button{--mdc-icon-button-state-layer-size: 28px;--mdc-icon-button-icon-size: 18px;width:28px;height:28px;padding:0;display:inline-flex;align-items:center;justify-content:center;margin-right:-4px}.help-icon-button mat-icon{font-size:18px;width:18px;height:18px}.mat-mdc-form-field-icon-suffix{align-self:center}\n"] }]
14467
+ `, styles: [":host{display:block;container-type:inline-size}.cm-root{display:grid;grid-template-columns:minmax(220px,280px) minmax(0,1fr);gap:16px;min-height:420px}.cm-left{border-right:1px solid var(--pfx-settings-panel-border, var(--md-sys-color-outline-variant));padding-right:8px;min-width:0}.cm-search{width:100%;margin-bottom:8px}.cm-right{padding-left:8px;min-width:0}@container (max-width: 720px){.cm-root{grid-template-columns:1fr}.cm-left{border-right:none;border-bottom:1px solid var(--pfx-settings-panel-border, var(--md-sys-color-outline-variant));padding-right:0;padding-bottom:12px}.cm-right{padding-left:0}}.field-item{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px;cursor:pointer;border-radius:10px;transition:background .12s ease,border .12s ease;border:1px solid transparent}.field-item:hover{background:var(--md-sys-color-surface-container-high)}.field-item.selected{background:var(--md-sys-color-surface-container-highest);border-color:var(--md-sys-color-outline-variant)}.field-item.has-cycle{border-color:var(--md-sys-color-error)}.field-item-main{min-width:0;flex:1}.field-item-title{font-weight:500;word-break:break-word}.field-item-meta{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-top:2px;font-size:.75rem;color:var(--md-sys-color-on-surface-variant)}.field-role-pill{font-size:.7rem;padding:1px 6px;border-radius:999px;background:var(--md-sys-color-surface-container-high);color:var(--md-sys-color-on-surface-variant)}.field-role-pill--remote{background:color-mix(in srgb,var(--md-sys-color-primary-container) 65%,transparent);color:var(--md-sys-color-on-primary-container)}.rule-badges{display:inline-flex;gap:6px;flex-wrap:wrap;justify-content:flex-end}.rule-badge{font-size:11px;padding:2px 8px;border-radius:999px;font-weight:600;white-space:nowrap}.b-active{background:var(--md-sys-color-primary-container);color:var(--md-sys-color-on-primary-container)}.b-manual{background:var(--md-sys-color-surface-container-high);color:var(--md-sys-color-on-surface-variant)}.b-immediate{background:var(--md-sys-color-secondary-container);color:var(--md-sys-color-on-secondary-container)}.b-conflict{background:var(--md-sys-color-error-container);color:var(--md-sys-color-on-error-container)}.cm-header{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:8px}.cm-feedback{padding:8px 12px;border-radius:8px;font-size:.85rem;margin-bottom:10px;border:1px solid var(--md-sys-color-outline-variant);background:var(--md-sys-color-secondary-container);color:var(--md-sys-color-on-secondary-container)}.cm-feedback--error{border-color:var(--md-sys-color-error);background:var(--md-sys-color-error-container);color:var(--md-sys-color-on-error-container)}.cm-cycle-alert{padding:10px 12px;border-radius:8px;font-size:.85rem;margin-bottom:10px;border:1px solid var(--md-sys-color-error);background:var(--md-sys-color-error-container);color:var(--md-sys-color-on-error-container);display:flex;gap:10px;align-items:flex-start}.cm-cycle-alert mat-icon{color:var(--md-sys-color-error);flex-shrink:0;margin-top:2px}.cm-cycle-alert-body{display:grid;gap:4px;flex:1}.cm-source-notice{padding:8px 12px;border-radius:8px;font-size:.82rem;margin-bottom:10px;border:1px solid var(--md-sys-color-outline-variant);background:var(--md-sys-color-surface-container-low);color:var(--md-sys-color-on-surface-variant);display:flex;gap:8px;align-items:center}.rules-empty{opacity:.88;padding:8px 0;font-size:.85rem;color:var(--md-sys-color-on-surface-variant)}.rule-row{display:grid;grid-template-columns:1fr auto;align-items:center;padding:10px 0;border-bottom:1px dashed var(--md-sys-color-outline-variant);gap:8px}.rule-summary-direction{font-size:.85rem;margin-top:2px}.rule-summary-policies{font-size:.8rem;opacity:.78;margin-top:2px}.rule-actions button{margin-left:4px}.banner{background:var(--md-sys-color-tertiary-container);color:var(--md-sys-color-on-tertiary-container);padding:10px 12px;border:1px solid var(--md-sys-color-outline-variant);border-radius:8px;margin-bottom:10px;display:flex;gap:8px;align-items:center}.import-panel{background:var(--pfx-settings-panel-surface, var(--md-sys-color-surface-container-high));border:1px solid var(--pfx-settings-panel-border, var(--md-sys-color-outline-variant));border-radius:8px;padding:10px;margin-bottom:10px}.import-textarea{width:100%;box-sizing:border-box;padding:8px 10px;border-radius:8px;border:1px solid var(--pfx-settings-panel-border, var(--md-sys-color-outline-variant));background:var(--pfx-settings-panel-surface-elevated, var(--md-sys-color-surface-container-low));color:var(--pfx-settings-panel-fg, var(--md-sys-color-on-surface));font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:.85rem}.full{width:100%}.form-row{display:flex;gap:12px;align-items:center;flex-wrap:wrap}.form-grid{display:grid;gap:12px;padding:8px 0}.section-caption{margin:0 0 6px;font-size:.82rem;color:var(--md-sys-color-on-surface-variant)}.map-table{width:100%;border-collapse:collapse}.map-table th,.map-table td{padding:8px;border-bottom:1px dashed var(--md-sys-color-outline-variant);text-align:left;vertical-align:middle}.kbd{font-family:monospace;background:var(--md-sys-color-surface-container-highest);padding:0 6px;border-radius:4px;color:var(--md-sys-color-on-surface-variant)}.panel-surface{padding-top:8px;margin-bottom:12px}.panel-stack{display:grid;gap:12px;margin:12px 0}.stack-panel{background:var(--pfx-settings-panel-surface, var(--md-sys-color-surface-container-high));border:1px solid var(--pfx-settings-panel-border, var(--md-sys-color-outline-variant));border-radius:10px;box-shadow:none}.stack-panel .mat-expansion-panel-content{padding:0 16px 12px}.panel-header{display:flex;align-items:center;gap:8px}.preview-box{background:var(--md-sys-color-surface-container-low);border:1px solid var(--md-sys-color-outline-variant);border-radius:8px;padding:8px;color:var(--md-sys-color-on-surface);margin:4px 0 0}.help-icon-button{--mdc-icon-button-state-layer-size: 28px;--mdc-icon-button-icon-size: 18px;width:28px;height:28px;padding:0;display:inline-flex;align-items:center;justify-content:center;margin-right:-4px}.help-icon-button mat-icon{font-size:18px;width:18px;height:18px}.mat-mdc-form-field-icon-suffix{align-self:center}\n"] }]
13697
14468
  }], ctorParameters: () => [{ type: CascadeRulesService }], propDecorators: { fields: [{
13698
14469
  type: Input
13699
14470
  }], connections: [{
@@ -13702,6 +14473,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
13702
14473
  type: Output
13703
14474
  }], cancel: [{
13704
14475
  type: Output
14476
+ }], validChange: [{
14477
+ type: Output
13705
14478
  }] } });
13706
14479
 
13707
14480
  /**