ngx-dynamic-entity 1.6.0 → 1.8.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;
@@ -1017,12 +1263,12 @@ class DateFieldComponent {
1017
1263
  formatDate(value) {
1018
1264
  if (!value)
1019
1265
  return '—';
1020
- try {
1021
- return new Date(value).toLocaleDateString();
1022
- }
1023
- catch {
1024
- return value;
1025
- }
1266
+ // `new Date('nonsense')` does not throw and `toLocaleDateString()` returns the *string*
1267
+ // "Invalid Date", so the try/catch this replaced never fired and readers saw that text
1268
+ // instead of their data. Records outlive schemas — a field retyped from text to date can
1269
+ // hold anything — so an unparseable value is shown as stored.
1270
+ const parsed = new Date(value);
1271
+ return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleDateString();
1026
1272
  }
1027
1273
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DateFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1028
1274
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: DateFieldComponent, isStandalone: true, selector: "ngx-date-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
@@ -1910,7 +2156,11 @@ class RadioFieldComponent {
1910
2156
  * into an id attribute.
1911
2157
  */
1912
2158
  getRadioId(option) {
1913
- const text = String(this.getOptVal(option) ?? 'opt');
2159
+ // `??` cannot fire here: `resolveOptionValue` returns '' for an empty option, not null,
2160
+ // so every valueless option produced the same id — and duplicate ids break the `for`
2161
+ // that ties each label to its input.
2162
+ const raw = String(this.getOptVal(option) ?? '').trim();
2163
+ const text = raw || 'opt';
1914
2164
  return `${this.field.id}-${text.trim().replace(/\s+/g, '_').toLowerCase()}`;
1915
2165
  }
1916
2166
  getOptVal(option) {
@@ -3694,6 +3944,7 @@ function builtInFieldTypes() {
3694
3944
  return {
3695
3945
  text: TextFieldComponent,
3696
3946
  textarea: TextareaFieldComponent,
3947
+ markdown: MarkdownFieldComponent,
3697
3948
  number: NumberFieldComponent,
3698
3949
  currency: CurrencyFieldComponent,
3699
3950
  email: EmailFieldComponent,
@@ -4086,11 +4337,50 @@ class DynamicFormComponent {
4086
4337
  }
4087
4338
  if (changes['config'] || changes['initialData']) {
4088
4339
  this.buildForm();
4089
- if (this.visibleTabs.length > 0 && !this.activeTab()) {
4090
- this.setActiveTab(this.visibleTabs[0].id);
4340
+ // Clear *before* the guard below, not after it.
4341
+ //
4342
+ // The reset used to sit behind `if (visibleTabs.length === 0) return`, so a swap that
4343
+ // arrived while no tab was visible — a config still being applied, or every tab hidden
4344
+ // by a rule at that instant — skipped it entirely and the previous record's tab
4345
+ // survived into the next one. Clearing is unconditional and cheap, and safe on its own
4346
+ // because `activeTabConfig` already falls back to the first visible tab when nothing
4347
+ // is selected. Selecting the tab is what needs tabs to exist; forgetting the old one
4348
+ // does not.
4349
+ if (changes['config'] || this.isRecordSwap(changes['initialData'])) {
4350
+ this.activeTab.set('');
4351
+ this.activeSubTab.set('');
4352
+ }
4353
+ if (this.visibleTabs.length === 0)
4354
+ return;
4355
+ // The first tab used to be chosen only when no tab was active yet, and nothing ever
4356
+ // cleared `activeTab` — so a form that stays mounted while `initialData` is swapped
4357
+ // opened the next record on the tab the previous one was left on. The demo escaped it
4358
+ // by destroying the form between records; a host that keeps it mounted did not.
4359
+ //
4360
+ // Resetting here is consistent with what already happens: `buildForm` assigns a brand
4361
+ // new FormGroup, so a changed `initialData` has already discarded every control and
4362
+ // all validation state. The tab was the one thing pretending nothing had changed.
4363
+ if (!this.activeTab()) {
4364
+ // No focus steal: the panel gains focus when a *user* picks a tab, not when a host
4365
+ // swaps the record underneath them.
4366
+ this.setActiveTab(this.visibleTabs[0].id, { focusPanel: false });
4091
4367
  }
4092
4368
  }
4093
4369
  }
4370
+ /**
4371
+ * Whether an `initialData` change represents loading a different record.
4372
+ *
4373
+ * Not every change does. A template binding like `[initialData]="record() || {}"` yields a
4374
+ * fresh object literal on each evaluation, so `ngOnChanges` fires with two empty objects
4375
+ * and nothing has actually been loaded — resetting there would drag someone back to the
4376
+ * first tab while they were filling in a new record on the third.
4377
+ */
4378
+ isRecordSwap(change) {
4379
+ if (!change || change.isFirstChange())
4380
+ return false;
4381
+ const empty = (v) => v === null || v === undefined || (typeof v === 'object' && Object.keys(v).length === 0);
4382
+ return !(empty(change.previousValue) && empty(change.currentValue));
4383
+ }
4094
4384
  ngOnDestroy() {
4095
4385
  this.valueSub?.unsubscribe();
4096
4386
  this.statusSub?.unsubscribe();
@@ -5371,5 +5661,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
5371
5661
  * Generated bundle index. Do not edit.
5372
5662
  */
5373
5663
 
5374
- 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 };
5664
+ 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 };
5375
5665
  //# sourceMappingURL=ngx-dynamic-entity.mjs.map