@praxisui/metadata-editor 10.0.0-rc.38 → 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.
@@ -12875,6 +12875,119 @@ class CascadeRulesService {
12875
12875
  dependencyValuePath: rule.dependencyValuePath || inferredValueKey || 'id',
12876
12876
  };
12877
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
+ }
12878
12991
  normalizeDependencyFields(value) {
12879
12992
  if (Array.isArray(value)) {
12880
12993
  const deps = value
@@ -12903,6 +13016,7 @@ class CascadeManagerTabComponent {
12903
13016
  connections; // optional signal for conflicts
12904
13017
  apply = new EventEmitter();
12905
13018
  cancel = new EventEmitter();
13019
+ validChange = new EventEmitter();
12906
13020
  searchTerm = '';
12907
13021
  searchTimer = null;
12908
13022
  searchValue = '';
@@ -12924,6 +13038,7 @@ class CascadeManagerTabComponent {
12924
13038
  editMapRows = [];
12925
13039
  editInitialFilterMap = {};
12926
13040
  editInitialValuePath = 'id';
13041
+ lastEmittedValid = null;
12927
13042
  setFeedback(message, tone = 'info') {
12928
13043
  this.feedbackMessage = message;
12929
13044
  this.feedbackTone = tone;
@@ -12944,20 +13059,17 @@ class CascadeManagerTabComponent {
12944
13059
  this.selectedFieldSig.set(this.fields[0].name);
12945
13060
  this.startEdit(this.fields[0].name);
12946
13061
  }
13062
+ this.syncValidityState();
12947
13063
  }
12948
13064
  ngOnChanges(changes) {
12949
13065
  if (changes['fields'] && !changes['fields'].firstChange) {
12950
- // 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
12951
13067
  const incoming = this.fields || [];
12952
13068
  const next = new Map();
12953
- // keep existing for fields still present
12954
13069
  for (const f of incoming) {
12955
- const existing = this.rules().get(f.name);
12956
13070
  const hydrated = this.svc.hydrateRule(f);
12957
13071
  if (hydrated)
12958
13072
  next.set(f.name, hydrated);
12959
- else if (existing)
12960
- next.set(f.name, existing);
12961
13073
  }
12962
13074
  this.rules.set(next);
12963
13075
  // preserve selection if still valid
@@ -12965,29 +13077,114 @@ class CascadeManagerTabComponent {
12965
13077
  if (sel && !incoming.some((f) => f.name === sel)) {
12966
13078
  this.selectedFieldSig.set(incoming[0]?.name ?? null);
12967
13079
  }
13080
+ const activeSel = this.selectedField();
13081
+ if (activeSel) {
13082
+ this.startEdit(activeSel);
13083
+ }
13084
+ this.syncValidityState();
12968
13085
  }
12969
13086
  }
12970
- _logCount = 0;
12971
13087
  filteredFields() {
12972
13088
  const q = (this.searchValue || '').toLowerCase();
12973
13089
  const labelMap = this.svc.getFieldLabelMap(this.fields);
12974
13090
  const result = this.fields
12975
13091
  .filter((f) => !q || f.name.toLowerCase().includes(q) || (labelMap.get(f.name) || '').toLowerCase().includes(q))
12976
- .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
+ });
12977
13096
  return result;
12978
13097
  }
12979
13098
  labelFor(name) {
12980
13099
  return this.svc.getFieldLabelMap(this.fields).get(name) || name;
12981
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
+ }
12982
13178
  selectedField() {
12983
13179
  return this.selectedFieldSig();
12984
13180
  }
12985
13181
  selectField(name) {
12986
13182
  this.selectedFieldSig.set(name);
12987
13183
  this.startEdit(name);
13184
+ this.syncValidityState();
12988
13185
  }
12989
13186
  hasRule(name) {
12990
- return this.rules().has(name);
13187
+ return (this.rules().get(name)?.dependencyFields?.length ?? 0) > 0;
12991
13188
  }
12992
13189
  ruleFor(name) {
12993
13190
  return this.rules().get(name) || null;
@@ -13003,6 +13200,52 @@ class CascadeManagerTabComponent {
13003
13200
  return false;
13004
13201
  return this.connections.some((c) => c.toFieldName === name && (c.toInputPath || '').includes('filterCriteria'));
13005
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
+ }
13006
13249
  startEdit(name) {
13007
13250
  const existing = this.ruleFor(name);
13008
13251
  const base = existing || {
@@ -13031,12 +13274,24 @@ class CascadeManagerTabComponent {
13031
13274
  // Build mapping rows from dependencies
13032
13275
  this.buildMapRowsFromDeps();
13033
13276
  this.editing.set(true);
13277
+ this.syncValidityState();
13034
13278
  }
13035
13279
  edit(name) { this.startEdit(name); }
13036
- 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
+ }
13037
13291
  saveEdit(name) {
13038
- // Build rule from editor
13292
+ // Build rule from editor (preserving self-reference prevention)
13039
13293
  const deps = this.editDepList.filter((s) => !!s && s !== name);
13294
+ this.editDepList = [...deps];
13040
13295
  // The rows own the draft. The opening snapshot only preserves entry shape;
13041
13296
  // merging the original JSON after the rows would silently undo visual edits.
13042
13297
  const map = structuredClone(this.editInitialFilterMap);
@@ -13078,7 +13333,6 @@ class CascadeManagerTabComponent {
13078
13333
  delete valuePaths[dep];
13079
13334
  }
13080
13335
  }
13081
- this.setFeedback('');
13082
13336
  const rule = {
13083
13337
  targetField: name,
13084
13338
  dependencyFields: deps,
@@ -13092,18 +13346,60 @@ class CascadeManagerTabComponent {
13092
13346
  };
13093
13347
  const next = new Map(this.rules());
13094
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
+ }
13095
13362
  this.rules.set(next);
13096
13363
  this.emitPatch();
13097
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
+ }
13098
13388
  }
13099
13389
  remove(name) {
13100
13390
  const next = new Map(this.rules());
13101
13391
  next.delete(name);
13102
13392
  this.rules.set(next);
13393
+ if (this.selectedField() === name) {
13394
+ this.editDepList = [];
13395
+ this.editMapRows = [];
13396
+ }
13103
13397
  const patch = {
13104
13398
  [name]: this.svc.clearPatch(),
13105
13399
  };
13400
+ this.setFeedback('');
13106
13401
  this.apply.emit(patch);
13402
+ this.syncValidityState();
13107
13403
  }
13108
13404
  quickSet(name, action) {
13109
13405
  const current = this.ruleFor(name);
@@ -13118,6 +13414,7 @@ class CascadeManagerTabComponent {
13118
13414
  map.set(name, next);
13119
13415
  this.rules.set(map);
13120
13416
  this.emitPatch();
13417
+ this.syncValidityState();
13121
13418
  }
13122
13419
  emitPatch() {
13123
13420
  const out = {};
@@ -13185,14 +13482,26 @@ class CascadeManagerTabComponent {
13185
13482
  };
13186
13483
  next.set(field, vm);
13187
13484
  });
13485
+ const validation = this.svc.validateCascadeGraph(next, this.fields);
13188
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
+ }
13189
13496
  this.emitPatch();
13190
13497
  this.importMode = false;
13498
+ this.syncValidityState();
13191
13499
  }
13192
13500
  startTemplate(name, tpl) {
13193
13501
  const hasR = this.hasField('rotaId');
13194
13502
  const hasD = this.hasField('destinoReqId');
13195
- 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);
13196
13505
  this.editDepList = [...deps];
13197
13506
  this.editEnable = true;
13198
13507
  this.editReset = true;
@@ -13205,6 +13514,7 @@ class CascadeManagerTabComponent {
13205
13514
  this.editMapRows = [];
13206
13515
  this.buildMapRowsFromDeps();
13207
13516
  this.editing.set(true);
13517
+ this.syncValidityState();
13208
13518
  }
13209
13519
  buildMapRowsFromDeps() {
13210
13520
  const deps = this.editDepList;
@@ -13262,10 +13572,10 @@ class CascadeManagerTabComponent {
13262
13572
  }
13263
13573
  buildPreview() {
13264
13574
  try {
13265
- const deps = this.editDepList;
13266
13575
  const frag = {};
13267
13576
  this.editMapRows.forEach((r) => {
13268
- 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}>`;
13269
13579
  const parts = (r.key || r.dep).split('.');
13270
13580
  let ref = frag;
13271
13581
  for (let i = 0; i < parts.length - 1; i++) {
@@ -13290,10 +13600,14 @@ class CascadeManagerTabComponent {
13290
13600
  const labelMap = this.svc.getFieldLabelMap(this.fields);
13291
13601
  return this.fields
13292
13602
  .filter((f) => f.name !== current)
13293
- .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
+ });
13294
13607
  }
13295
13608
  onDepListChange() {
13296
13609
  this.buildMapRowsFromDeps();
13610
+ this.syncValidityState();
13297
13611
  }
13298
13612
  getKeySuggestions(dep) {
13299
13613
  const suggestions = [dep, `${dep}.id`, `${dep}.value`];
@@ -13313,7 +13627,7 @@ class CascadeManagerTabComponent {
13313
13627
  return [...new Set(suggestions)];
13314
13628
  }
13315
13629
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: CascadeManagerTabComponent, deps: [{ token: CascadeRulesService }], target: i0.ɵɵFactoryTarget.Component });
13316
- 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: `
13317
13631
  <div class="cm-root">
13318
13632
  <!-- Left: Fields list -->
13319
13633
  <div class="cm-left">
@@ -13323,14 +13637,29 @@ class CascadeManagerTabComponent {
13323
13637
  </mat-form-field>
13324
13638
  @if (fields.length <= 50) {
13325
13639
  <div>
13326
- @for (f of filteredFields(); track f) {
13327
- <div (click)="selectField(f.name)" class="field-item" [class.selected]="f.name===selectedField()" tabindex="0" (keydown)="onFieldKeydown($event, f.name)">
13328
- <div>
13329
- <div>{{ labelFor(f.name) }}</div>
13330
- <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>
13331
13657
  </div>
13332
- @if (hasRule(f.name)) {
13333
- <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)) {
13334
13663
  @if (isCascadeEnabled(f.name)) {
13335
13664
  <span class="rule-badge b-active">Ativo</span>
13336
13665
  }
@@ -13338,22 +13667,38 @@ class CascadeManagerTabComponent {
13338
13667
  <span class="rule-badge b-manual">Manual</span>
13339
13668
  }
13340
13669
  @if (loadOnChange(f.name)==='immediate') {
13341
- <span class="rule-badge b-immediate">Immediate</span>
13670
+ <span class="rule-badge b-immediate">Imediato</span>
13342
13671
  }
13343
- </div>
13344
- }
13672
+ }
13673
+ </div>
13345
13674
  </div>
13346
13675
  }
13347
13676
  </div>
13348
13677
  } @else {
13349
- <cdk-virtual-scroll-viewport itemSize="44" style="height: calc(100vh - 240px)">
13350
- <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)">
13351
- <div>
13352
- <div>{{ labelFor(f.name) }}</div>
13353
- <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>
13354
13696
  </div>
13355
- @if (hasRule(f.name)) {
13356
- <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)) {
13357
13702
  @if (isCascadeEnabled(f.name)) {
13358
13703
  <span class="rule-badge b-active">Ativo</span>
13359
13704
  }
@@ -13361,27 +13706,27 @@ class CascadeManagerTabComponent {
13361
13706
  <span class="rule-badge b-manual">Manual</span>
13362
13707
  }
13363
13708
  @if (loadOnChange(f.name)==='immediate') {
13364
- <span class="rule-badge b-immediate">Immediate</span>
13709
+ <span class="rule-badge b-immediate">Imediato</span>
13365
13710
  }
13366
- </div>
13367
- }
13711
+ }
13712
+ </div>
13368
13713
  </div>
13369
13714
  </cdk-virtual-scroll-viewport>
13370
13715
  }
13371
13716
  </div>
13372
-
13717
+
13373
13718
  <!-- Right: Rules for selected field -->
13374
13719
  @if (selectedField(); as sel) {
13375
13720
  <div class="cm-right">
13376
13721
  <div class="cm-header">
13377
13722
  <div>
13378
- <h3 style="margin:0">{{ labelFor(sel) }}</h3>
13379
- <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>
13380
13725
  </div>
13381
13726
  <div>
13382
13727
  <button mat-stroked-button color="primary" (click)="exportRules()" style="margin-right:8px">Exportar</button>
13383
13728
  <button mat-stroked-button (click)="toggleImport()" style="margin-right:8px">Importar</button>
13384
- <button mat-flat-button color="primary" [matMenuTriggerFor]="addMenu" [disabled]="editing()">Adicionar</button>
13729
+ <button mat-flat-button color="primary" [matMenuTriggerFor]="addMenu">Modelos</button>
13385
13730
  <mat-menu #addMenu="matMenu">
13386
13731
  <button mat-menu-item (click)="startEdit(sel)"><mat-icon [praxisIcon]="'add'"></mat-icon><span>Em branco</span></button>
13387
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>
@@ -13389,9 +13734,45 @@ class CascadeManagerTabComponent {
13389
13734
  </mat-menu>
13390
13735
  </div>
13391
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
+
13392
13762
  @if (feedbackMessage) {
13393
13763
  <div class="cm-feedback" [class.cm-feedback--error]="feedbackTone === 'error'" role="status">{{ feedbackMessage }}</div>
13394
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
+
13395
13776
  <!-- Conflict banner (connections) -->
13396
13777
  @if (hasConflict(sel)) {
13397
13778
  <div class="banner">
@@ -13404,6 +13785,7 @@ class CascadeManagerTabComponent {
13404
13785
  <button mat-stroked-button color="accent" (click)="quickSet(sel,'manual')">Definir manual</button>
13405
13786
  </div>
13406
13787
  }
13788
+
13407
13789
  @if (importMode) {
13408
13790
  <div class="import-panel">
13409
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>
@@ -13414,33 +13796,45 @@ class CascadeManagerTabComponent {
13414
13796
  </div>
13415
13797
  </div>
13416
13798
  }
13417
- @if (!ruleFor(sel)) {
13799
+
13800
+ @if (!hasRule(sel)) {
13418
13801
  <div>
13419
- <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>
13420
13806
  </div>
13421
13807
  }
13422
- @if (ruleFor(sel); as rule) {
13808
+ @if (hasRule(sel) && ruleFor(sel); as rule) {
13423
13809
  <div>
13424
13810
  <div class="rule-row">
13425
13811
  <div>
13426
- <div><strong>Dependentes:</strong> {{ rule.dependencyFields.join(', ') || '—' }}</div>
13427
- <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>
13428
13819
  </div>
13429
13820
  <div class="rule-actions">
13430
- <button mat-icon-button matTooltip="Editar" (click)="startEdit(sel)"><mat-icon [praxisIcon]="'edit'"></mat-icon></button>
13431
- <button mat-icon-button matTooltip="Duplicar" disabled><mat-icon [praxisIcon]="'content_copy'"></mat-icon></button>
13432
- <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>
13433
13824
  </div>
13434
13825
  </div>
13435
13826
  </div>
13436
13827
  }
13828
+
13437
13829
  <!-- Editor com seções (sem acordeão duplo) -->
13438
13830
  <div class="panel-surface">
13439
13831
  <div class="panel-header" style="padding: 8px 4px; border-bottom: 1px solid var(--pfx-settings-panel-border, var(--md-sys-color-outline-variant));">
13440
13832
  <mat-icon [praxisIcon]="'rule'"></mat-icon>
13441
13833
  <div>
13442
13834
  <div>Cascata do campo</div>
13443
- <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>
13444
13838
  </div>
13445
13839
  </div>
13446
13840
  <div class="panel-stack">
@@ -13450,13 +13844,16 @@ class CascadeManagerTabComponent {
13450
13844
  <mat-panel-title class="panel-header"><mat-icon [praxisIcon]="'tune'"></mat-icon> Básico</mat-panel-title>
13451
13845
  </mat-expansion-panel-header>
13452
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>
13453
13850
  <div class="form-row">
13454
13851
  <mat-form-field appearance="outline" class="full">
13455
- <mat-label>Campos dependentes</mat-label>
13852
+ <mat-label>Este campo depende de (campos pais)</mat-label>
13456
13853
  <mat-select multiple [(ngModel)]="editDepList" (ngModelChange)="onDepListChange()">
13457
- @for (f of availableDependencyFields(); track f) {
13854
+ @for (f of availableDependencyFields(); track f.name) {
13458
13855
  <mat-option [value]="f.name">
13459
- {{ labelFor(f.name) }} ({{ f.name }})
13856
+ {{ disambiguatedLabelFor(f.name) }} ({{ f.name }}) · {{ fieldRoleBadge(f.name) }}
13460
13857
  </mat-option>
13461
13858
  }
13462
13859
  </mat-select>
@@ -13465,42 +13862,54 @@ class CascadeManagerTabComponent {
13465
13862
  matSuffix
13466
13863
  class="help-icon-button"
13467
13864
  type="button"
13468
- [matTooltip]="'Selecione os campos que controlam este valor'"
13865
+ [matTooltip]="'Selecione os campos pais (origem) cujo valor filtrará as opções deste campo (destino)'"
13469
13866
  matTooltipPosition="above"
13470
13867
  >
13471
13868
  <mat-icon [praxisIcon]="'help_outline'"></mat-icon>
13472
13869
  </button>
13473
13870
  </mat-form-field>
13474
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
+ }
13475
13877
  <div class="form-row">
13476
- <mat-checkbox [(ngModel)]="editEnable">Ativar cascata nativa</mat-checkbox>
13477
- <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>
13478
13884
  </div>
13479
13885
  </div>
13480
13886
  </mat-expansion-panel>
13481
13887
  <!-- Execução -->
13482
13888
  <mat-expansion-panel class="stack-panel">
13483
13889
  <mat-expansion-panel-header>
13484
- <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>
13485
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>
13486
13895
  <div class="form-row">
13487
13896
  <mat-form-field appearance="outline">
13488
- <mat-label>Carregar ao mudar</mat-label>
13897
+ <mat-label>Quando recarregar opções</mat-label>
13489
13898
  <mat-select [(ngModel)]="editLoadOn">
13490
- <mat-option value="respectLoadOn">Respeitar loadOn</mat-option>
13491
- <mat-option value="immediate">Imediato</mat-option>
13492
- <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>
13493
13902
  </mat-select>
13494
13903
  </mat-form-field>
13495
13904
  <mat-form-field appearance="outline">
13496
- <mat-label>Merge de filtros</mat-label>
13905
+ <mat-label>Como combinar filtros</mat-label>
13497
13906
  <mat-select [(ngModel)]="editMerge">
13498
- <mat-option value="merge">merge (padrão)</mat-option>
13499
- <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>
13500
13909
  </mat-select>
13501
13910
  </mat-form-field>
13502
13911
  <mat-form-field appearance="outline">
13503
- <mat-label>Atraso (ms)</mat-label>
13912
+ <mat-label>Atraso após mudança (ms)</mat-label>
13504
13913
  <input matInput type="number" [(ngModel)]="editDebounce">
13505
13914
  </mat-form-field>
13506
13915
  </div>
@@ -13508,18 +13917,21 @@ class CascadeManagerTabComponent {
13508
13917
  <!-- Extração de valor -->
13509
13918
  <mat-expansion-panel class="stack-panel">
13510
13919
  <mat-expansion-panel-header>
13511
- <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>
13512
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>
13513
13925
  <div class="form-row">
13514
13926
  <mat-form-field appearance="outline" class="full">
13515
- <mat-label>ValuePath padrão</mat-label>
13927
+ <mat-label>Propriedade padrão lida do campo pai (ValuePath)</mat-label>
13516
13928
  <input matInput [(ngModel)]="editValuePath" placeholder="id">
13517
13929
  <button
13518
13930
  mat-icon-button
13519
13931
  matSuffix
13520
13932
  class="help-icon-button"
13521
13933
  type="button"
13522
- [matTooltip]="'Preenchido automaticamente a partir de optionValueKey() ou \\'id\\''"
13934
+ [matTooltip]="'Preenchido automaticamente a partir de optionValueKey do campo pai ou \\'id\\''"
13523
13935
  matTooltipPosition="above"
13524
13936
  >
13525
13937
  <mat-icon [praxisIcon]="'help_outline'"></mat-icon>
@@ -13530,24 +13942,30 @@ class CascadeManagerTabComponent {
13530
13942
  <!-- Mapeamento de filtros (tabela) -->
13531
13943
  <mat-expansion-panel class="stack-panel">
13532
13944
  <mat-expansion-panel-header>
13533
- <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>
13534
13946
  </mat-expansion-panel-header>
13535
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>
13536
13951
  <table class="map-table">
13537
13952
  <thead>
13538
13953
  <tr>
13539
- <th>Dependente</th>
13540
- <th>Chave (dot‑path)</th>
13541
- <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>
13542
13957
  </tr>
13543
13958
  </thead>
13544
13959
  <tbody>
13545
- @for (row of editMapRows; track row; let i = $index) {
13960
+ @for (row of editMapRows; track row.dep; let i = $index) {
13546
13961
  <tr>
13547
- <td>{{ row.dep }}</td>
13962
+ <td>
13963
+ <div><strong>{{ disambiguatedLabelFor(row.dep) }}</strong></div>
13964
+ <small style="opacity:.75">{{ row.dep }}</small>
13965
+ </td>
13548
13966
  <td>
13549
13967
  <mat-form-field appearance="outline" class="full">
13550
- <mat-label>Chave</mat-label>
13968
+ <mat-label>Parâmetro no filtro</mat-label>
13551
13969
  <input matInput [(ngModel)]="row.key" [matAutocomplete]="autoKey" [placeholder]="row.dep">
13552
13970
  <mat-autocomplete #autoKey="matAutocomplete">
13553
13971
  @for (opt of getKeySuggestions(row.dep); track opt) {
@@ -13559,7 +13977,7 @@ class CascadeManagerTabComponent {
13559
13977
  matSuffix
13560
13978
  class="help-icon-button"
13561
13979
  type="button"
13562
- [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'"
13563
13981
  matTooltipPosition="above"
13564
13982
  >
13565
13983
  <mat-icon [praxisIcon]="'help_outline'"></mat-icon>
@@ -13568,7 +13986,7 @@ class CascadeManagerTabComponent {
13568
13986
  </td>
13569
13987
  <td>
13570
13988
  <mat-form-field appearance="outline" class="full">
13571
- <mat-label>ValuePath</mat-label>
13989
+ <mat-label>Propriedade do pai (ValuePath)</mat-label>
13572
13990
  <input matInput [(ngModel)]="row.valuePath" [matAutocomplete]="autoValue" [placeholder]="editValuePath||'id'">
13573
13991
  <mat-autocomplete #autoValue="matAutocomplete">
13574
13992
  @for (opt of getValuePathSuggestions(row.dep); track opt) {
@@ -13580,7 +13998,7 @@ class CascadeManagerTabComponent {
13580
13998
  matSuffix
13581
13999
  class="help-icon-button"
13582
14000
  type="button"
13583
- [matTooltip]="'Opcional'"
14001
+ [matTooltip]="'Opcional: sobrescreve a propriedade padrão lida apenas para este campo pai'"
13584
14002
  matTooltipPosition="above"
13585
14003
  >
13586
14004
  <mat-icon [praxisIcon]="'help_outline'"></mat-icon>
@@ -13592,29 +14010,32 @@ class CascadeManagerTabComponent {
13592
14010
  </tbody>
13593
14011
  </table>
13594
14012
  } @else {
13595
- <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>
13596
14014
  }
13597
14015
  </mat-expansion-panel>
13598
14016
  <!-- Preview -->
13599
14017
  <mat-expansion-panel class="stack-panel">
13600
14018
  <mat-expansion-panel-header>
13601
- <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>
13602
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>
13603
14024
  <pre class="preview-box">{{ buildPreview() }}</pre>
13604
14025
  <div style="display:flex; justify-content:flex-end; padding-top:6px">
13605
- <button mat-stroked-button (click)="copyPreview()">Copiar</button>
14026
+ <button mat-stroked-button (click)="copyPreview()">Copiar exemplo</button>
13606
14027
  </div>
13607
14028
  </mat-expansion-panel>
13608
14029
  </div>
13609
14030
  <div style="display:flex; gap:8px; justify-content:flex-end; padding:8px 12px;">
13610
- <button mat-stroked-button (click)="cancelEdit()">Cancelar</button>
13611
- <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>
13612
14033
  </div>
13613
14034
  </div>
13614
14035
  </div>
13615
14036
  }
13616
14037
  </div>
13617
- `, 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"] }] });
13618
14039
  }
13619
14040
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: CascadeManagerTabComponent, decorators: [{
13620
14041
  type: Component,
@@ -13645,14 +14066,29 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
13645
14066
  </mat-form-field>
13646
14067
  @if (fields.length <= 50) {
13647
14068
  <div>
13648
- @for (f of filteredFields(); track f) {
13649
- <div (click)="selectField(f.name)" class="field-item" [class.selected]="f.name===selectedField()" tabindex="0" (keydown)="onFieldKeydown($event, f.name)">
13650
- <div>
13651
- <div>{{ labelFor(f.name) }}</div>
13652
- <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>
13653
14086
  </div>
13654
- @if (hasRule(f.name)) {
13655
- <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)) {
13656
14092
  @if (isCascadeEnabled(f.name)) {
13657
14093
  <span class="rule-badge b-active">Ativo</span>
13658
14094
  }
@@ -13660,22 +14096,38 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
13660
14096
  <span class="rule-badge b-manual">Manual</span>
13661
14097
  }
13662
14098
  @if (loadOnChange(f.name)==='immediate') {
13663
- <span class="rule-badge b-immediate">Immediate</span>
14099
+ <span class="rule-badge b-immediate">Imediato</span>
13664
14100
  }
13665
- </div>
13666
- }
14101
+ }
14102
+ </div>
13667
14103
  </div>
13668
14104
  }
13669
14105
  </div>
13670
14106
  } @else {
13671
- <cdk-virtual-scroll-viewport itemSize="44" style="height: calc(100vh - 240px)">
13672
- <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)">
13673
- <div>
13674
- <div>{{ labelFor(f.name) }}</div>
13675
- <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>
13676
14125
  </div>
13677
- @if (hasRule(f.name)) {
13678
- <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)) {
13679
14131
  @if (isCascadeEnabled(f.name)) {
13680
14132
  <span class="rule-badge b-active">Ativo</span>
13681
14133
  }
@@ -13683,27 +14135,27 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
13683
14135
  <span class="rule-badge b-manual">Manual</span>
13684
14136
  }
13685
14137
  @if (loadOnChange(f.name)==='immediate') {
13686
- <span class="rule-badge b-immediate">Immediate</span>
14138
+ <span class="rule-badge b-immediate">Imediato</span>
13687
14139
  }
13688
- </div>
13689
- }
14140
+ }
14141
+ </div>
13690
14142
  </div>
13691
14143
  </cdk-virtual-scroll-viewport>
13692
14144
  }
13693
14145
  </div>
13694
-
14146
+
13695
14147
  <!-- Right: Rules for selected field -->
13696
14148
  @if (selectedField(); as sel) {
13697
14149
  <div class="cm-right">
13698
14150
  <div class="cm-header">
13699
14151
  <div>
13700
- <h3 style="margin:0">{{ labelFor(sel) }}</h3>
13701
- <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>
13702
14154
  </div>
13703
14155
  <div>
13704
14156
  <button mat-stroked-button color="primary" (click)="exportRules()" style="margin-right:8px">Exportar</button>
13705
14157
  <button mat-stroked-button (click)="toggleImport()" style="margin-right:8px">Importar</button>
13706
- <button mat-flat-button color="primary" [matMenuTriggerFor]="addMenu" [disabled]="editing()">Adicionar</button>
14158
+ <button mat-flat-button color="primary" [matMenuTriggerFor]="addMenu">Modelos</button>
13707
14159
  <mat-menu #addMenu="matMenu">
13708
14160
  <button mat-menu-item (click)="startEdit(sel)"><mat-icon [praxisIcon]="'add'"></mat-icon><span>Em branco</span></button>
13709
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>
@@ -13711,9 +14163,45 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
13711
14163
  </mat-menu>
13712
14164
  </div>
13713
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
+
13714
14191
  @if (feedbackMessage) {
13715
14192
  <div class="cm-feedback" [class.cm-feedback--error]="feedbackTone === 'error'" role="status">{{ feedbackMessage }}</div>
13716
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
+
13717
14205
  <!-- Conflict banner (connections) -->
13718
14206
  @if (hasConflict(sel)) {
13719
14207
  <div class="banner">
@@ -13726,6 +14214,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
13726
14214
  <button mat-stroked-button color="accent" (click)="quickSet(sel,'manual')">Definir manual</button>
13727
14215
  </div>
13728
14216
  }
14217
+
13729
14218
  @if (importMode) {
13730
14219
  <div class="import-panel">
13731
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>
@@ -13736,33 +14225,45 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
13736
14225
  </div>
13737
14226
  </div>
13738
14227
  }
13739
- @if (!ruleFor(sel)) {
14228
+
14229
+ @if (!hasRule(sel)) {
13740
14230
  <div>
13741
- <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>
13742
14235
  </div>
13743
14236
  }
13744
- @if (ruleFor(sel); as rule) {
14237
+ @if (hasRule(sel) && ruleFor(sel); as rule) {
13745
14238
  <div>
13746
14239
  <div class="rule-row">
13747
14240
  <div>
13748
- <div><strong>Dependentes:</strong> {{ rule.dependencyFields.join(', ') || '—' }}</div>
13749
- <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>
13750
14248
  </div>
13751
14249
  <div class="rule-actions">
13752
- <button mat-icon-button matTooltip="Editar" (click)="startEdit(sel)"><mat-icon [praxisIcon]="'edit'"></mat-icon></button>
13753
- <button mat-icon-button matTooltip="Duplicar" disabled><mat-icon [praxisIcon]="'content_copy'"></mat-icon></button>
13754
- <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>
13755
14253
  </div>
13756
14254
  </div>
13757
14255
  </div>
13758
14256
  }
14257
+
13759
14258
  <!-- Editor com seções (sem acordeão duplo) -->
13760
14259
  <div class="panel-surface">
13761
14260
  <div class="panel-header" style="padding: 8px 4px; border-bottom: 1px solid var(--pfx-settings-panel-border, var(--md-sys-color-outline-variant));">
13762
14261
  <mat-icon [praxisIcon]="'rule'"></mat-icon>
13763
14262
  <div>
13764
14263
  <div>Cascata do campo</div>
13765
- <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>
13766
14267
  </div>
13767
14268
  </div>
13768
14269
  <div class="panel-stack">
@@ -13772,13 +14273,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
13772
14273
  <mat-panel-title class="panel-header"><mat-icon [praxisIcon]="'tune'"></mat-icon> Básico</mat-panel-title>
13773
14274
  </mat-expansion-panel-header>
13774
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>
13775
14279
  <div class="form-row">
13776
14280
  <mat-form-field appearance="outline" class="full">
13777
- <mat-label>Campos dependentes</mat-label>
14281
+ <mat-label>Este campo depende de (campos pais)</mat-label>
13778
14282
  <mat-select multiple [(ngModel)]="editDepList" (ngModelChange)="onDepListChange()">
13779
- @for (f of availableDependencyFields(); track f) {
14283
+ @for (f of availableDependencyFields(); track f.name) {
13780
14284
  <mat-option [value]="f.name">
13781
- {{ labelFor(f.name) }} ({{ f.name }})
14285
+ {{ disambiguatedLabelFor(f.name) }} ({{ f.name }}) · {{ fieldRoleBadge(f.name) }}
13782
14286
  </mat-option>
13783
14287
  }
13784
14288
  </mat-select>
@@ -13787,42 +14291,54 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
13787
14291
  matSuffix
13788
14292
  class="help-icon-button"
13789
14293
  type="button"
13790
- [matTooltip]="'Selecione os campos que controlam este valor'"
14294
+ [matTooltip]="'Selecione os campos pais (origem) cujo valor filtrará as opções deste campo (destino)'"
13791
14295
  matTooltipPosition="above"
13792
14296
  >
13793
14297
  <mat-icon [praxisIcon]="'help_outline'"></mat-icon>
13794
14298
  </button>
13795
14299
  </mat-form-field>
13796
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
+ }
13797
14306
  <div class="form-row">
13798
- <mat-checkbox [(ngModel)]="editEnable">Ativar cascata nativa</mat-checkbox>
13799
- <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>
13800
14313
  </div>
13801
14314
  </div>
13802
14315
  </mat-expansion-panel>
13803
14316
  <!-- Execução -->
13804
14317
  <mat-expansion-panel class="stack-panel">
13805
14318
  <mat-expansion-panel-header>
13806
- <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>
13807
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>
13808
14324
  <div class="form-row">
13809
14325
  <mat-form-field appearance="outline">
13810
- <mat-label>Carregar ao mudar</mat-label>
14326
+ <mat-label>Quando recarregar opções</mat-label>
13811
14327
  <mat-select [(ngModel)]="editLoadOn">
13812
- <mat-option value="respectLoadOn">Respeitar loadOn</mat-option>
13813
- <mat-option value="immediate">Imediato</mat-option>
13814
- <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>
13815
14331
  </mat-select>
13816
14332
  </mat-form-field>
13817
14333
  <mat-form-field appearance="outline">
13818
- <mat-label>Merge de filtros</mat-label>
14334
+ <mat-label>Como combinar filtros</mat-label>
13819
14335
  <mat-select [(ngModel)]="editMerge">
13820
- <mat-option value="merge">merge (padrão)</mat-option>
13821
- <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>
13822
14338
  </mat-select>
13823
14339
  </mat-form-field>
13824
14340
  <mat-form-field appearance="outline">
13825
- <mat-label>Atraso (ms)</mat-label>
14341
+ <mat-label>Atraso após mudança (ms)</mat-label>
13826
14342
  <input matInput type="number" [(ngModel)]="editDebounce">
13827
14343
  </mat-form-field>
13828
14344
  </div>
@@ -13830,18 +14346,21 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
13830
14346
  <!-- Extração de valor -->
13831
14347
  <mat-expansion-panel class="stack-panel">
13832
14348
  <mat-expansion-panel-header>
13833
- <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>
13834
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>
13835
14354
  <div class="form-row">
13836
14355
  <mat-form-field appearance="outline" class="full">
13837
- <mat-label>ValuePath padrão</mat-label>
14356
+ <mat-label>Propriedade padrão lida do campo pai (ValuePath)</mat-label>
13838
14357
  <input matInput [(ngModel)]="editValuePath" placeholder="id">
13839
14358
  <button
13840
14359
  mat-icon-button
13841
14360
  matSuffix
13842
14361
  class="help-icon-button"
13843
14362
  type="button"
13844
- [matTooltip]="'Preenchido automaticamente a partir de optionValueKey() ou \\'id\\''"
14363
+ [matTooltip]="'Preenchido automaticamente a partir de optionValueKey do campo pai ou \\'id\\''"
13845
14364
  matTooltipPosition="above"
13846
14365
  >
13847
14366
  <mat-icon [praxisIcon]="'help_outline'"></mat-icon>
@@ -13852,24 +14371,30 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
13852
14371
  <!-- Mapeamento de filtros (tabela) -->
13853
14372
  <mat-expansion-panel class="stack-panel">
13854
14373
  <mat-expansion-panel-header>
13855
- <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>
13856
14375
  </mat-expansion-panel-header>
13857
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>
13858
14380
  <table class="map-table">
13859
14381
  <thead>
13860
14382
  <tr>
13861
- <th>Dependente</th>
13862
- <th>Chave (dot‑path)</th>
13863
- <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>
13864
14386
  </tr>
13865
14387
  </thead>
13866
14388
  <tbody>
13867
- @for (row of editMapRows; track row; let i = $index) {
14389
+ @for (row of editMapRows; track row.dep; let i = $index) {
13868
14390
  <tr>
13869
- <td>{{ row.dep }}</td>
14391
+ <td>
14392
+ <div><strong>{{ disambiguatedLabelFor(row.dep) }}</strong></div>
14393
+ <small style="opacity:.75">{{ row.dep }}</small>
14394
+ </td>
13870
14395
  <td>
13871
14396
  <mat-form-field appearance="outline" class="full">
13872
- <mat-label>Chave</mat-label>
14397
+ <mat-label>Parâmetro no filtro</mat-label>
13873
14398
  <input matInput [(ngModel)]="row.key" [matAutocomplete]="autoKey" [placeholder]="row.dep">
13874
14399
  <mat-autocomplete #autoKey="matAutocomplete">
13875
14400
  @for (opt of getKeySuggestions(row.dep); track opt) {
@@ -13881,7 +14406,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
13881
14406
  matSuffix
13882
14407
  class="help-icon-button"
13883
14408
  type="button"
13884
- [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'"
13885
14410
  matTooltipPosition="above"
13886
14411
  >
13887
14412
  <mat-icon [praxisIcon]="'help_outline'"></mat-icon>
@@ -13890,7 +14415,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
13890
14415
  </td>
13891
14416
  <td>
13892
14417
  <mat-form-field appearance="outline" class="full">
13893
- <mat-label>ValuePath</mat-label>
14418
+ <mat-label>Propriedade do pai (ValuePath)</mat-label>
13894
14419
  <input matInput [(ngModel)]="row.valuePath" [matAutocomplete]="autoValue" [placeholder]="editValuePath||'id'">
13895
14420
  <mat-autocomplete #autoValue="matAutocomplete">
13896
14421
  @for (opt of getValuePathSuggestions(row.dep); track opt) {
@@ -13902,7 +14427,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
13902
14427
  matSuffix
13903
14428
  class="help-icon-button"
13904
14429
  type="button"
13905
- [matTooltip]="'Opcional'"
14430
+ [matTooltip]="'Opcional: sobrescreve a propriedade padrão lida apenas para este campo pai'"
13906
14431
  matTooltipPosition="above"
13907
14432
  >
13908
14433
  <mat-icon [praxisIcon]="'help_outline'"></mat-icon>
@@ -13914,29 +14439,32 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
13914
14439
  </tbody>
13915
14440
  </table>
13916
14441
  } @else {
13917
- <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>
13918
14443
  }
13919
14444
  </mat-expansion-panel>
13920
14445
  <!-- Preview -->
13921
14446
  <mat-expansion-panel class="stack-panel">
13922
14447
  <mat-expansion-panel-header>
13923
- <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>
13924
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>
13925
14453
  <pre class="preview-box">{{ buildPreview() }}</pre>
13926
14454
  <div style="display:flex; justify-content:flex-end; padding-top:6px">
13927
- <button mat-stroked-button (click)="copyPreview()">Copiar</button>
14455
+ <button mat-stroked-button (click)="copyPreview()">Copiar exemplo</button>
13928
14456
  </div>
13929
14457
  </mat-expansion-panel>
13930
14458
  </div>
13931
14459
  <div style="display:flex; gap:8px; justify-content:flex-end; padding:8px 12px;">
13932
- <button mat-stroked-button (click)="cancelEdit()">Cancelar</button>
13933
- <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>
13934
14462
  </div>
13935
14463
  </div>
13936
14464
  </div>
13937
14465
  }
13938
14466
  </div>
13939
- `, 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"] }]
13940
14468
  }], ctorParameters: () => [{ type: CascadeRulesService }], propDecorators: { fields: [{
13941
14469
  type: Input
13942
14470
  }], connections: [{
@@ -13945,6 +14473,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
13945
14473
  type: Output
13946
14474
  }], cancel: [{
13947
14475
  type: Output
14476
+ }], validChange: [{
14477
+ type: Output
13948
14478
  }] } });
13949
14479
 
13950
14480
  /**