ngx-dynamic-entity 1.5.0 → 1.7.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.
@@ -1,5 +1,5 @@
1
1
  import * as i0 from '@angular/core';
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';
2
+ import { InjectionToken, makeEnvironmentProviders, inject, Injectable, signal, Input, ChangeDetectionStrategy, Component, ChangeDetectorRef, DestroyRef, Injector, isDevMode, ViewContainerRef, ViewChild, forwardRef, 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
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';
@@ -152,6 +152,25 @@ const SYSTEM_DEFAULT_CAN_EDIT = new InjectionToken('SYSTEM_DEFAULT_CAN_EDIT');
152
152
  * Enables cross-entity field referencing (`isReferenced`, `referencedEntityKey`, `referencedFieldId`, `hasDrift`).
153
153
  */
154
154
  const CONFIG_SOURCE = new InjectionToken('CONFIG_SOURCE');
155
+ /**
156
+ * Turns the markdown a `markdown` field stores into HTML for display.
157
+ *
158
+ * Optional, and deliberately not bundled: these packages declare no runtime dependencies
159
+ * beyond `tslib`, and a markdown parser is a large one to force on every consumer. Without
160
+ * a renderer the field still works — it stores and shows the source text, with line breaks
161
+ * preserved and nothing interpreted.
162
+ *
163
+ * The returned HTML is bound through `[innerHTML]`, so Angular's sanitizer strips scripts
164
+ * and event handlers before it reaches the DOM. That is a backstop, not a licence: a
165
+ * renderer should still be configured to escape raw HTML in its input, because sanitizing
166
+ * removes the dangerous parts silently rather than telling the author their content was
167
+ * altered.
168
+ *
169
+ * @example
170
+ * import { marked } from 'marked';
171
+ * { provide: MARKDOWN_RENDERER, useValue: (src: string) => marked.parse(src) as string }
172
+ */
173
+ const MARKDOWN_RENDERER = new InjectionToken('MARKDOWN_RENDERER');
155
174
 
156
175
  /**
157
176
  * Provider function for ngx-dynamic-entity.
@@ -426,6 +445,233 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
426
445
  type: Input
427
446
  }] } });
428
447
 
448
+ /**
449
+ * Markdown field: a long-form input that stores **markdown source**, never HTML.
450
+ *
451
+ * Storing the source rather than rendered HTML is the whole design. The record stays plain
452
+ * text — diffable, portable, safe to log, and impossible to turn into stored XSS by writing
453
+ * it into a database. Rendering happens at display time and only when a consumer asks for
454
+ * it, via `MARKDOWN_RENDERER`.
455
+ *
456
+ * Without a renderer this degrades to exactly what `textarea` does, plus preserved line
457
+ * breaks: the source is shown as text with nothing interpreted. That is the default because
458
+ * these packages declare no runtime dependencies beyond `tslib`, and a markdown parser is a
459
+ * large thing to force on a consumer who wanted a form library.
460
+ *
461
+ * With a renderer, its HTML is bound through `[innerHTML]`, which Angular sanitizes — see
462
+ * the token's own note on why that is a backstop rather than a licence.
463
+ */
464
+ class MarkdownFieldComponent {
465
+ field;
466
+ control;
467
+ language = 'en';
468
+ readonly = false;
469
+ masked = false;
470
+ /** Optional — the field is fully usable without one. */
471
+ render = inject(MARKDOWN_RENDERER, { optional: true });
472
+ cdr = inject(ChangeDetectorRef);
473
+ destroyRef = inject(DestroyRef);
474
+ previewing = signal(false, ...(ngDevMode ? [{ debugName: "previewing" }] : /* istanbul ignore next */ []));
475
+ valueSub;
476
+ /**
477
+ * Re-render when the value changes from outside this component.
478
+ *
479
+ * `rendered()` reads `control.value`, which under OnPush is neither an input nor a
480
+ * template event: typing in the textarea updates through `formControl`, but a host
481
+ * calling `patchValue`, a rule, or an `autoPatch` mapping does not mark this component
482
+ * dirty. The preview and the read-only view would keep showing the previous document
483
+ * while the record already held a different one.
484
+ */
485
+ ngOnChanges(changes) {
486
+ if (!changes['control'])
487
+ return;
488
+ this.valueSub?.unsubscribe();
489
+ this.valueSub = this.control?.valueChanges?.subscribe(() => this.cdr.markForCheck());
490
+ if (this.valueSub)
491
+ this.destroyRef.onDestroy(() => this.valueSub?.unsubscribe());
492
+ }
493
+ /** A Preview tab that can only ever show the source back is worse than no tab. */
494
+ get canPreview() {
495
+ return !!this.render;
496
+ }
497
+ get label() {
498
+ return resolveLabel(this.field?.label, this.language);
499
+ }
500
+ get placeholder() {
501
+ return resolveLabel(this.field?.placeholder, this.language);
502
+ }
503
+ /**
504
+ * Rendered HTML, or null when there is no renderer or nothing to render.
505
+ *
506
+ * A renderer is consumer code and may throw on input it does not like. Letting that
507
+ * escape would take down the whole form over one malformed field, so it falls back to the
508
+ * source text — which is what the field would have shown with no renderer at all.
509
+ */
510
+ rendered() {
511
+ const source = this.control?.value;
512
+ if (!this.render || typeof source !== 'string' || source === '')
513
+ return null;
514
+ try {
515
+ return this.render(source);
516
+ }
517
+ catch {
518
+ return null;
519
+ }
520
+ }
521
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: MarkdownFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
522
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: MarkdownFieldComponent, isStandalone: true, selector: "ngx-markdown-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, usesOnChanges: true, ngImport: i0, template: `
523
+ <div
524
+ class="ngx-field ngx-field--markdown"
525
+ [attr.data-testid]="'field-' + field.id"
526
+ [attr.data-field-type]="field.type"
527
+ [class.ngx-field--readonly]="readonly"
528
+ [class.ngx-field--masked]="masked"
529
+ >
530
+ <label class="ngx-field__label" [attr.for]="field.id">{{ label }}</label>
531
+
532
+ @if (masked) {
533
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
534
+ } @else if (readonly) {
535
+ @if (rendered(); as html) {
536
+ <div class="ngx-field__value ngx-field__markdown" [attr.data-testid]="'field-' + field.id + '-value'" [innerHTML]="html"></div>
537
+ } @else {
538
+ <!-- No renderer: the source is the display. The stylesheet keeps its line
539
+ breaks with white-space: pre-wrap, and interpolation escapes it. -->
540
+ <span class="ngx-field__value ngx-field__markdown--source" [attr.data-testid]="'field-' + field.id + '-value'">{{ control.value }}</span>
541
+ }
542
+ } @else {
543
+ <div class="ngx-field__markdown-editor">
544
+ @if (canPreview) {
545
+ <div class="ngx-field__markdown-tabs" role="group" [attr.aria-label]="label + ' editor mode'">
546
+ <button
547
+ type="button"
548
+ class="ngx-field__markdown-tab"
549
+ [attr.data-testid]="'field-' + field.id + '-write'"
550
+ [attr.aria-pressed]="!previewing()"
551
+ (click)="previewing.set(false)"
552
+ >
553
+ Write
554
+ </button>
555
+ <button
556
+ type="button"
557
+ class="ngx-field__markdown-tab"
558
+ [attr.data-testid]="'field-' + field.id + '-preview'"
559
+ [attr.aria-pressed]="previewing()"
560
+ (click)="previewing.set(true)"
561
+ >
562
+ Preview
563
+ </button>
564
+ </div>
565
+ }
566
+
567
+ @if (previewing()) {
568
+ <div class="ngx-field__markdown ngx-field__markdown--preview" [attr.data-testid]="'field-' + field.id + '-preview-body'" [innerHTML]="rendered()"></div>
569
+ } @else {
570
+ <textarea
571
+ [id]="field.id"
572
+ class="ngx-field__input ngx-field__input--markdown"
573
+ [attr.data-testid]="'field-' + field.id + '-input'"
574
+ [formControl]="$any(control)"
575
+ [placeholder]="placeholder"
576
+ [attr.disabled]="field.disabled ? true : null"
577
+ rows="6"
578
+ ></textarea>
579
+ }
580
+ </div>
581
+
582
+ @if (control.invalid && control.touched) {
583
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">This field has an error</span>
584
+ }
585
+ }
586
+ </div>
587
+ `, isInline: true, 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.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
588
+ }
589
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: MarkdownFieldComponent, decorators: [{
590
+ type: Component,
591
+ args: [{
592
+ changeDetection: ChangeDetectionStrategy.OnPush,
593
+ selector: 'ngx-markdown-field',
594
+ standalone: true,
595
+ imports: [ReactiveFormsModule],
596
+ template: `
597
+ <div
598
+ class="ngx-field ngx-field--markdown"
599
+ [attr.data-testid]="'field-' + field.id"
600
+ [attr.data-field-type]="field.type"
601
+ [class.ngx-field--readonly]="readonly"
602
+ [class.ngx-field--masked]="masked"
603
+ >
604
+ <label class="ngx-field__label" [attr.for]="field.id">{{ label }}</label>
605
+
606
+ @if (masked) {
607
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
608
+ } @else if (readonly) {
609
+ @if (rendered(); as html) {
610
+ <div class="ngx-field__value ngx-field__markdown" [attr.data-testid]="'field-' + field.id + '-value'" [innerHTML]="html"></div>
611
+ } @else {
612
+ <!-- No renderer: the source is the display. The stylesheet keeps its line
613
+ breaks with white-space: pre-wrap, and interpolation escapes it. -->
614
+ <span class="ngx-field__value ngx-field__markdown--source" [attr.data-testid]="'field-' + field.id + '-value'">{{ control.value }}</span>
615
+ }
616
+ } @else {
617
+ <div class="ngx-field__markdown-editor">
618
+ @if (canPreview) {
619
+ <div class="ngx-field__markdown-tabs" role="group" [attr.aria-label]="label + ' editor mode'">
620
+ <button
621
+ type="button"
622
+ class="ngx-field__markdown-tab"
623
+ [attr.data-testid]="'field-' + field.id + '-write'"
624
+ [attr.aria-pressed]="!previewing()"
625
+ (click)="previewing.set(false)"
626
+ >
627
+ Write
628
+ </button>
629
+ <button
630
+ type="button"
631
+ class="ngx-field__markdown-tab"
632
+ [attr.data-testid]="'field-' + field.id + '-preview'"
633
+ [attr.aria-pressed]="previewing()"
634
+ (click)="previewing.set(true)"
635
+ >
636
+ Preview
637
+ </button>
638
+ </div>
639
+ }
640
+
641
+ @if (previewing()) {
642
+ <div class="ngx-field__markdown ngx-field__markdown--preview" [attr.data-testid]="'field-' + field.id + '-preview-body'" [innerHTML]="rendered()"></div>
643
+ } @else {
644
+ <textarea
645
+ [id]="field.id"
646
+ class="ngx-field__input ngx-field__input--markdown"
647
+ [attr.data-testid]="'field-' + field.id + '-input'"
648
+ [formControl]="$any(control)"
649
+ [placeholder]="placeholder"
650
+ [attr.disabled]="field.disabled ? true : null"
651
+ rows="6"
652
+ ></textarea>
653
+ }
654
+ </div>
655
+
656
+ @if (control.invalid && control.touched) {
657
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">This field has an error</span>
658
+ }
659
+ }
660
+ </div>
661
+ `,
662
+ }]
663
+ }], propDecorators: { field: [{
664
+ type: Input
665
+ }], control: [{
666
+ type: Input
667
+ }], language: [{
668
+ type: Input
669
+ }], readonly: [{
670
+ type: Input
671
+ }], masked: [{
672
+ type: Input
673
+ }] } });
674
+
429
675
  class NumberFieldComponent {
430
676
  messages = inject(ValidationMessagesService);
431
677
  field;
@@ -3694,6 +3940,7 @@ function builtInFieldTypes() {
3694
3940
  return {
3695
3941
  text: TextFieldComponent,
3696
3942
  textarea: TextareaFieldComponent,
3943
+ markdown: MarkdownFieldComponent,
3697
3944
  number: NumberFieldComponent,
3698
3945
  currency: CurrencyFieldComponent,
3699
3946
  email: EmailFieldComponent,
@@ -4140,12 +4387,19 @@ class DynamicFormComponent {
4140
4387
  */
4141
4388
  isFieldReadonly(field) {
4142
4389
  return (this.readonly ||
4390
+ // `permissions.edit` gated the Save button and nothing else, so a role without edit
4391
+ // rights received a fully editable form: it could type into every field, and only
4392
+ // discovered the record was not theirs to change when no Save button appeared. The
4393
+ // permission means the record may not be edited, so the fields say so.
4394
+ !this.permissions.canEdit ||
4143
4395
  !!field.readonly ||
4144
4396
  this.readOnlyFields.includes(field.id) ||
4145
4397
  this.isFieldLocked(field));
4146
4398
  }
4147
4399
  toggleFieldLock(field) {
4148
- if (!field.criticalField || this.readonly)
4400
+ // Unlocking is an edit. Without the permission check a viewer could open the lock on a
4401
+ // critical field, which is the one control that exists to make editing deliberate.
4402
+ if (!field.criticalField || this.readonly || !this.permissions.canEdit)
4149
4403
  return;
4150
4404
  const next = new Set(this.unlockedFields());
4151
4405
  if (next.has(field.id))
@@ -4854,6 +5108,7 @@ class DynamicRecordFormComponent {
4854
5108
  sectionSave = new EventEmitter();
4855
5109
  dynamicFormComp;
4856
5110
  rulesEvaluation = inject(RulesEvaluationService);
5111
+ rbacService = inject(RbacService);
4857
5112
  host = inject(ElementRef);
4858
5113
  injector = inject(Injector);
4859
5114
  currentData = signal({}, ...(ngDevMode ? [{ debugName: "currentData" }] : /* istanbul ignore next */ []));
@@ -4883,9 +5138,21 @@ class DynamicRecordFormComponent {
4883
5138
  dismissInfoBanner(fieldId) {
4884
5139
  this.dismissed.set(new Set(this.dismissed()).add(fieldId));
4885
5140
  }
5141
+ /**
5142
+ * Whether the current roles may edit this record.
5143
+ *
5144
+ * `userRoles` was declared as an input here and then read by nothing at all — this
5145
+ * component computed no permissions of any kind, so the record view was fully editable
5146
+ * for every role while the plain form at least hid its Save button. Cached on the same
5147
+ * terms as the sibling component: it depends only on `config` and `userRoles`.
5148
+ */
5149
+ permissionsCache = null;
5150
+ get permissions() {
5151
+ return (this.permissionsCache ??= this.rbacService.getPermissions(this.config, this.userRoles));
5152
+ }
4886
5153
  /** True when nothing in the record may be edited. */
4887
5154
  get recordReadOnly() {
4888
- return this.readonly || this.isReadOnly;
5155
+ return this.readonly || this.isReadOnly || !this.permissions.canEdit;
4889
5156
  }
4890
5157
  // ─── Inline array-row editing ───────────────────────────────────────────────
4891
5158
  /**
@@ -5097,6 +5364,11 @@ class DynamicRecordFormComponent {
5097
5364
  * after the first keystroke.
5098
5365
  */
5099
5366
  ngOnChanges(changes) {
5367
+ // Permissions depend on these two and nothing else; a stale cache would keep a record
5368
+ // editable after a role switch.
5369
+ if (changes['config'] || changes['userRoles']) {
5370
+ this.permissionsCache = null;
5371
+ }
5100
5372
  if (changes['initialData'] || changes['config']) {
5101
5373
  this.originalBaseline.set({ ...(this.initialData ?? {}) });
5102
5374
  this.currentData.set({ ...(this.initialData ?? {}) });
@@ -5346,5 +5618,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
5346
5618
  * Generated bundle index. Do not edit.
5347
5619
  */
5348
5620
 
5349
- export { ASYNC_VALIDATOR_REGISTRY, ArrayFieldComponent, BooleanFieldComponent, COMMON_MODULES_REGISTRY, CONFIG_SOURCE, CascadeDataService, CheckboxFieldComponent, ConfigSourceService, CurrencyFieldComponent, DEFAULT_VALIDATION_MESSAGES, DateFieldComponent, DateTimeFieldComponent, DropdownFieldComponent, DynamicFieldComponent, DynamicFormComponent, DynamicRecordFormComponent, ENTITY_REF_CACHE_STORE, ENTITY_REF_REGISTRY, EmailFieldComponent, EntityRefFieldComponent, EntityRefRegistryService, EntityRefSelectionService, EntityReferenceService, FIELD_TYPE_REGISTRY, FIELD_TYPE_SETS, FieldRegistryService, FileFieldComponent, GroupFieldComponent, HOOK_REGISTRY, HookRegistryService, ImageFieldComponent, InMemoryEntityRefCacheStore, LOOKUP_REGISTRY, LookupRegistryService, MASKED_ROLES, MonthYearFieldComponent, MultiSelectFieldComponent, NumberFieldComponent, PasswordFieldComponent, RECORD_MIGRATIONS, RadioFieldComponent, RbacService, RulesEvaluationService, SYSTEM_DEFAULT_CAN_EDIT, TextFieldComponent, TextareaFieldComponent, TimeFieldComponent, UPLOAD_HANDLER, VALIDATION_MESSAGES, VALIDATOR_REGISTRY, ValidationMessagesService, ValidatorRegistryService, builtInFieldTypes, provideBuiltInFieldTypes, provideFieldTypes, provideNgxDynamicEntity, refreshChoiceOptions };
5621
+ export { ASYNC_VALIDATOR_REGISTRY, ArrayFieldComponent, BooleanFieldComponent, COMMON_MODULES_REGISTRY, CONFIG_SOURCE, CascadeDataService, CheckboxFieldComponent, ConfigSourceService, CurrencyFieldComponent, DEFAULT_VALIDATION_MESSAGES, DateFieldComponent, DateTimeFieldComponent, DropdownFieldComponent, DynamicFieldComponent, DynamicFormComponent, DynamicRecordFormComponent, ENTITY_REF_CACHE_STORE, ENTITY_REF_REGISTRY, EmailFieldComponent, EntityRefFieldComponent, EntityRefRegistryService, EntityRefSelectionService, EntityReferenceService, FIELD_TYPE_REGISTRY, FIELD_TYPE_SETS, FieldRegistryService, FileFieldComponent, GroupFieldComponent, HOOK_REGISTRY, HookRegistryService, ImageFieldComponent, InMemoryEntityRefCacheStore, LOOKUP_REGISTRY, LookupRegistryService, MARKDOWN_RENDERER, MASKED_ROLES, MarkdownFieldComponent, MonthYearFieldComponent, MultiSelectFieldComponent, NumberFieldComponent, PasswordFieldComponent, RECORD_MIGRATIONS, RadioFieldComponent, RbacService, RulesEvaluationService, SYSTEM_DEFAULT_CAN_EDIT, TextFieldComponent, TextareaFieldComponent, TimeFieldComponent, UPLOAD_HANDLER, VALIDATION_MESSAGES, VALIDATOR_REGISTRY, ValidationMessagesService, ValidatorRegistryService, builtInFieldTypes, provideBuiltInFieldTypes, provideFieldTypes, provideNgxDynamicEntity, refreshChoiceOptions };
5350
5622
  //# sourceMappingURL=ngx-dynamic-entity.mjs.map