ngx-dynamic-entity 1.4.0 → 1.6.0

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.
@@ -2,7 +2,7 @@ import * as i0 from '@angular/core';
2
2
  import { InjectionToken, makeEnvironmentProviders, inject, Injectable, signal, Input, ChangeDetectionStrategy, Component, DestroyRef, Injector, isDevMode, ViewContainerRef, ViewChild, forwardRef, ChangeDetectorRef, Optional, Inject, EventEmitter, computed, HostListener, ViewChildren, Output, ElementRef, afterNextRender } from '@angular/core';
3
3
  import * as i1 from '@angular/forms';
4
4
  import { ReactiveFormsModule, Validators, FormBuilder, FormArray, FormGroup } from '@angular/forms';
5
- import { resolveLabel, formatDisplayValue, normalizeLookupValues, lookupValuesToOptions, valuesMatch, getOptionStoredValue, resolveOptionLabel, resolveOptionValue, buildReferenceCacheKey, normalizeReferenceOptions, entityKeyFromCacheKey, applyCascadeFilter, resolveEffectiveMask, shouldMaskField, findTab, fileRefName, evaluateFormRules, filterRulesForTab, evaluateFieldVisibility, normalizeConfigOptions, migrateRecord, getTabData, getValueByPath, setTabData, setValueByPath, normalizeArrayStructures, ambiguousFieldIds, toRefToken, collectFieldScopes, fieldRefFor, refOf, getTabPath, applyAutoPatch, applyPatchOnTrue } from '@dynamic-entity/core';
5
+ import { resolveLabel, formatDisplayValue, normalizeLookupValues, lookupValuesToOptions, valuesMatch, getOptionStoredValue, resolveOptionLabel, resolveOptionValue, buildReferenceCacheKey, normalizeReferenceOptions, entityKeyFromCacheKey, applyCascadeFilter, resolveEffectiveMask, shouldMaskField, findTab, fileRefName, evaluateFormRules, filterRulesForTab, evaluateFieldVisibility, normalizeConfigOptions, migrateRecord, getTabData, getValueByPath, setTabData, setValueByPath, normalizeArrayStructures, ambiguousFieldIds, parseFieldRef, toRefToken, collectFieldScopes, fieldRefFor, refOf, getTabPath, applyAutoPatch, applyPatchOnTrue } from '@dynamic-entity/core';
6
6
  export { formatDisplayValue, normalizeConfig, normalizeOption, resolveLabel, resolveOptionLabel, resolveOptionValue, valuesMatch } from '@dynamic-entity/core';
7
7
  import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
8
8
  import { Subject, isObservable, firstValueFrom } from 'rxjs';
@@ -4096,12 +4096,20 @@ class DynamicFormComponent {
4096
4096
  this.statusSub?.unsubscribe();
4097
4097
  this.selectionSub?.unsubscribe();
4098
4098
  }
4099
- setActiveTab(tabId) {
4099
+ /**
4100
+ * `focusPanel: false` is for a caller that is about to focus something more specific.
4101
+ *
4102
+ * Activating a tab moves focus into its panel, which is right for a keyboard user pressing
4103
+ * a tab. A quick-jump also switches tabs, but then focuses the field it was aiming at — and
4104
+ * the panel focus runs on `requestAnimationFrame`, after `afterNextRender`, so it landed
4105
+ * second and took the focus back every time. Saying so is better than out-timing it.
4106
+ */
4107
+ setActiveTab(tabId, options) {
4100
4108
  const changed = this.activeTab() !== tabId;
4101
4109
  if (changed)
4102
4110
  this.activeTabChange.emit(tabId);
4103
4111
  this.activeTab.set(tabId);
4104
- if (changed)
4112
+ if (changed && options?.focusPanel !== false)
4105
4113
  this.focusActivePanel();
4106
4114
  const parent = findTab(this.tabs, tabId);
4107
4115
  if (parent?.children?.length) {
@@ -4111,10 +4119,10 @@ class DynamicFormComponent {
4111
4119
  this.activeSubTab.set('');
4112
4120
  }
4113
4121
  }
4114
- setActiveSubTab(subTabId) {
4122
+ setActiveSubTab(subTabId, options) {
4115
4123
  const changed = this.activeSubTab() !== subTabId;
4116
4124
  this.activeSubTab.set(subTabId);
4117
- if (changed)
4125
+ if (changed && options?.focusPanel !== false)
4118
4126
  this.focusActivePanel();
4119
4127
  }
4120
4128
  getFieldSpan(field) {
@@ -4132,12 +4140,19 @@ class DynamicFormComponent {
4132
4140
  */
4133
4141
  isFieldReadonly(field) {
4134
4142
  return (this.readonly ||
4143
+ // `permissions.edit` gated the Save button and nothing else, so a role without edit
4144
+ // rights received a fully editable form: it could type into every field, and only
4145
+ // discovered the record was not theirs to change when no Save button appeared. The
4146
+ // permission means the record may not be edited, so the fields say so.
4147
+ !this.permissions.canEdit ||
4135
4148
  !!field.readonly ||
4136
4149
  this.readOnlyFields.includes(field.id) ||
4137
4150
  this.isFieldLocked(field));
4138
4151
  }
4139
4152
  toggleFieldLock(field) {
4140
- if (!field.criticalField || this.readonly)
4153
+ // Unlocking is an edit. Without the permission check a viewer could open the lock on a
4154
+ // critical field, which is the one control that exists to make editing deliberate.
4155
+ if (!field.criticalField || this.readonly || !this.permissions.canEdit)
4141
4156
  return;
4142
4157
  const next = new Set(this.unlockedFields());
4143
4158
  if (next.has(field.id))
@@ -4411,9 +4426,8 @@ class DynamicFormComponent {
4411
4426
  * `flattenFormValues` keys by bare id, so one of the two silently wins and the rule reads
4412
4427
  * whichever landed there.
4413
4428
  *
4414
- * `validateConfig` catches this for `showWhen` and cascade parents, which live in the
4415
- * config. Rules do not: they arrive as a separate `@Input`, so the config alone cannot be
4416
- * checked for them and this is the only place both are in hand.
4429
+ * `validateConfig({ rules })` catches this statically. The check here remains because
4430
+ * consumers that never call the validator still get a signal in development.
4417
4431
  */
4418
4432
  warnAmbiguousRuleReferences() {
4419
4433
  if (!isDevMode() || !this.rules?.length || !this.config)
@@ -4434,14 +4448,18 @@ class DynamicFormComponent {
4434
4448
  referenced.add(target.id);
4435
4449
  }
4436
4450
  }
4437
- for (const id of referenced) {
4451
+ for (const raw of referenced) {
4452
+ const parsed = parseFieldRef(raw);
4453
+ if (parsed.kind === 'ref')
4454
+ continue;
4455
+ const id = parsed.value;
4438
4456
  const scopes = ambiguous.get(id);
4439
4457
  if (!scopes || DynamicFormComponent.warnedAmbiguousIds.has(id))
4440
4458
  continue;
4441
4459
  DynamicFormComponent.warnedAmbiguousIds.add(id);
4442
4460
  console.warn(`[ngx-dynamic-entity] A rule references field "${id}", which is defined in ${scopes.join(' and ')}. ` +
4443
- `Rules address a field by bare id, so the rule will read whichever one the form finds first. ` +
4444
- `Give one of them a distinct id.`);
4461
+ `A bare id cannot say which one is meant, so the rule will read whichever the form finds first. ` +
4462
+ `Name it by path instead, as [${scopes[0]}.${id}].`);
4445
4463
  }
4446
4464
  }
4447
4465
  static warnedAmbiguousIds = new Set();
@@ -4643,6 +4661,13 @@ class DynamicFormComponent {
4643
4661
  getControl(fieldId, currentTabId) {
4644
4662
  if (!this.form)
4645
4663
  return null;
4664
+ // A bracketed path names exactly one control, and the form nests by tab exactly as the
4665
+ // path does — so `form.get('work.address')` walks straight to it. Anything that names a
4666
+ // field can therefore use a path: `showWhen` keys, `patchOnTrue` mappings and `autoPatch`
4667
+ // targets, not only rules.
4668
+ const parsed = parseFieldRef(fieldId);
4669
+ if (parsed.kind === 'ref')
4670
+ return this.form.get(parsed.value);
4646
4671
  if (currentTabId) {
4647
4672
  const tabGrp = this.getTabGroup(currentTabId);
4648
4673
  const ctrl = tabGrp?.get(fieldId);
@@ -4836,6 +4861,7 @@ class DynamicRecordFormComponent {
4836
4861
  sectionSave = new EventEmitter();
4837
4862
  dynamicFormComp;
4838
4863
  rulesEvaluation = inject(RulesEvaluationService);
4864
+ rbacService = inject(RbacService);
4839
4865
  host = inject(ElementRef);
4840
4866
  injector = inject(Injector);
4841
4867
  currentData = signal({}, ...(ngDevMode ? [{ debugName: "currentData" }] : /* istanbul ignore next */ []));
@@ -4865,9 +4891,21 @@ class DynamicRecordFormComponent {
4865
4891
  dismissInfoBanner(fieldId) {
4866
4892
  this.dismissed.set(new Set(this.dismissed()).add(fieldId));
4867
4893
  }
4894
+ /**
4895
+ * Whether the current roles may edit this record.
4896
+ *
4897
+ * `userRoles` was declared as an input here and then read by nothing at all — this
4898
+ * component computed no permissions of any kind, so the record view was fully editable
4899
+ * for every role while the plain form at least hid its Save button. Cached on the same
4900
+ * terms as the sibling component: it depends only on `config` and `userRoles`.
4901
+ */
4902
+ permissionsCache = null;
4903
+ get permissions() {
4904
+ return (this.permissionsCache ??= this.rbacService.getPermissions(this.config, this.userRoles));
4905
+ }
4868
4906
  /** True when nothing in the record may be edited. */
4869
4907
  get recordReadOnly() {
4870
- return this.readonly || this.isReadOnly;
4908
+ return this.readonly || this.isReadOnly || !this.permissions.canEdit;
4871
4909
  }
4872
4910
  // ─── Inline array-row editing ───────────────────────────────────────────────
4873
4911
  /**
@@ -5079,6 +5117,11 @@ class DynamicRecordFormComponent {
5079
5117
  * after the first keystroke.
5080
5118
  */
5081
5119
  ngOnChanges(changes) {
5120
+ // Permissions depend on these two and nothing else; a stale cache would keep a record
5121
+ // editable after a role switch.
5122
+ if (changes['config'] || changes['userRoles']) {
5123
+ this.permissionsCache = null;
5124
+ }
5082
5125
  if (changes['initialData'] || changes['config']) {
5083
5126
  this.originalBaseline.set({ ...(this.initialData ?? {}) });
5084
5127
  this.currentData.set({ ...(this.initialData ?? {}) });
@@ -5259,12 +5302,14 @@ class DynamicRecordFormComponent {
5259
5302
  const location = this.locateField(fieldId);
5260
5303
  if (!location)
5261
5304
  return;
5262
- this.dynamicFormComp?.setActiveTab(location.tabId);
5305
+ // The panel must not take the focus back: this jump is going to focus the field itself.
5306
+ this.dynamicFormComp?.setActiveTab(location.tabId, { focusPanel: false });
5263
5307
  // `setActiveTab` resets to a tab's first child, so the sub-tab has to be selected after
5264
5308
  // it, not before. Without this the walk found sub-tab fields and then rendered the wrong
5265
5309
  // panel, so the element the callback below looks for never existed.
5266
- if (location.subTabId)
5267
- this.dynamicFormComp?.setActiveSubTab(location.subTabId);
5310
+ if (location.subTabId) {
5311
+ this.dynamicFormComp?.setActiveSubTab(location.subTabId, { focusPanel: false });
5312
+ }
5268
5313
  afterNextRender(() => {
5269
5314
  // The field id comes from config, so it never goes into a selector string: no
5270
5315
  // escaping to get wrong, and no need for `CSS.escape`, which jsdom and older