@memberjunction/ng-entity-viewer 6.1.3 → 6.2.0-edge.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.
Files changed (40) hide show
  1. package/dist/lib/aggregate-panel/aggregate-panel.component.d.ts +18 -2
  2. package/dist/lib/aggregate-panel/aggregate-panel.component.d.ts.map +1 -1
  3. package/dist/lib/aggregate-panel/aggregate-panel.component.js +47 -18
  4. package/dist/lib/aggregate-panel/aggregate-panel.component.js.map +1 -1
  5. package/dist/lib/entity-cards/entity-cards.component.d.ts +1 -0
  6. package/dist/lib/entity-cards/entity-cards.component.d.ts.map +1 -1
  7. package/dist/lib/entity-cards/entity-cards.component.js +11 -2
  8. package/dist/lib/entity-cards/entity-cards.component.js.map +1 -1
  9. package/dist/lib/entity-data-grid/entity-data-grid.component.d.ts +18 -0
  10. package/dist/lib/entity-data-grid/entity-data-grid.component.d.ts.map +1 -1
  11. package/dist/lib/entity-data-grid/entity-data-grid.component.js +71 -25
  12. package/dist/lib/entity-data-grid/entity-data-grid.component.js.map +1 -1
  13. package/dist/lib/entity-record-detail-panel/entity-record-detail-panel.component.d.ts.map +1 -1
  14. package/dist/lib/entity-record-detail-panel/entity-record-detail-panel.component.js +10 -8
  15. package/dist/lib/entity-record-detail-panel/entity-record-detail-panel.component.js.map +1 -1
  16. package/dist/lib/entity-viewer/entity-viewer.component.d.ts +11 -0
  17. package/dist/lib/entity-viewer/entity-viewer.component.d.ts.map +1 -1
  18. package/dist/lib/entity-viewer/entity-viewer.component.js +29 -0
  19. package/dist/lib/entity-viewer/entity-viewer.component.js.map +1 -1
  20. package/dist/lib/utils/aggregate-field.util.d.ts +21 -0
  21. package/dist/lib/utils/aggregate-field.util.d.ts.map +1 -0
  22. package/dist/lib/utils/aggregate-field.util.js +40 -0
  23. package/dist/lib/utils/aggregate-field.util.js.map +1 -0
  24. package/dist/lib/view-config-panel/view-config-panel.component.d.ts +5 -1
  25. package/dist/lib/view-config-panel/view-config-panel.component.d.ts.map +1 -1
  26. package/dist/lib/view-config-panel/view-config-panel.component.js +15 -8
  27. package/dist/lib/view-config-panel/view-config-panel.component.js.map +1 -1
  28. package/dist/lib/view-types/renderers/grid-view-renderer.component.d.ts +93 -3
  29. package/dist/lib/view-types/renderers/grid-view-renderer.component.d.ts.map +1 -1
  30. package/dist/lib/view-types/renderers/grid-view-renderer.component.js +432 -10
  31. package/dist/lib/view-types/renderers/grid-view-renderer.component.js.map +1 -1
  32. package/dist/module.d.ts +5 -4
  33. package/dist/module.d.ts.map +1 -1
  34. package/dist/module.js +8 -1
  35. package/dist/module.js.map +1 -1
  36. package/dist/public-api.d.ts +1 -0
  37. package/dist/public-api.d.ts.map +1 -1
  38. package/dist/public-api.js +1 -0
  39. package/dist/public-api.js.map +1 -1
  40. package/package.json +20 -19
@@ -1,23 +1,92 @@
1
1
  import { Component, Input, Output, EventEmitter, ViewEncapsulation, ViewChild, ChangeDetectorRef, inject } from '@angular/core';
2
- import { LogError, CompositeKey } from '@memberjunction/core';
2
+ import { LogError, CompositeKey, RunView, RecordMergeRequest } from '@memberjunction/core';
3
+ import { RecordComparisonService, } from '@memberjunction/ng-record-merge';
3
4
  import { UUIDsEqual } from '@memberjunction/global';
4
5
  import { BaseAngularComponent } from '@memberjunction/ng-base-types';
5
6
  import { buildPkString, buildCompositeKey } from '../../utils/record.util';
6
7
  import * as i0 from "@angular/core";
7
8
  import * as i1 from "@memberjunction/ng-list-management";
8
- import * as i2 from "../../entity-data-grid/entity-data-grid.component";
9
- import * as i3 from "../../confirm-dialog/confirm-dialog.component";
9
+ import * as i2 from "@memberjunction/ng-record-merge";
10
+ import * as i3 from "@memberjunction/ng-ui-components";
11
+ import * as i4 from "../../entity-data-grid/entity-data-grid.component";
12
+ import * as i5 from "../../confirm-dialog/confirm-dialog.component";
10
13
  const _c0 = ["grid"];
11
14
  const _c1 = () => ({});
12
15
  function GridViewRendererComponent_Conditional_2_Template(rf, ctx) { if (rf & 1) {
13
16
  const _r1 = i0.ɵɵgetCurrentView();
14
- i0.ɵɵelementStart(0, "mj-list-management-dialog", 4);
17
+ i0.ɵɵelementStart(0, "mj-list-management-dialog", 6);
15
18
  i0.ɵɵlistener("complete", function GridViewRendererComponent_Conditional_2_Template_mj_list_management_dialog_complete_0_listener($event) { i0.ɵɵrestoreView(_r1); const ctx_r1 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r1.onListManagementComplete($event)); })("cancel", function GridViewRendererComponent_Conditional_2_Template_mj_list_management_dialog_cancel_0_listener() { i0.ɵɵrestoreView(_r1); const ctx_r1 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r1.onListManagementCancel()); });
16
19
  i0.ɵɵelementEnd();
17
20
  } if (rf & 2) {
18
21
  const ctx_r1 = i0.ɵɵnextContext();
19
22
  i0.ɵɵproperty("Provider", ctx_r1.Provider)("visible", ctx_r1.showListManagementDialog)("config", ctx_r1.listManagementConfig);
20
23
  } }
24
+ function GridViewRendererComponent_Conditional_4_Conditional_11_Template(rf, ctx) { if (rf & 1) {
25
+ i0.ɵɵelementStart(0, "p", 12);
26
+ i0.ɵɵtext(1);
27
+ i0.ɵɵelementEnd();
28
+ } if (rf & 2) {
29
+ const ctx_r1 = i0.ɵɵnextContext(2);
30
+ i0.ɵɵadvance();
31
+ i0.ɵɵtextInterpolate(ctx_r1.mergeState.DependencyNote);
32
+ } }
33
+ function GridViewRendererComponent_Conditional_4_Template(rf, ctx) { if (rf & 1) {
34
+ const _r3 = i0.ɵɵgetCurrentView();
35
+ i0.ɵɵelementStart(0, "mj-dialog", 7);
36
+ i0.ɵɵlistener("Close", function GridViewRendererComponent_Conditional_4_Template_mj_dialog_Close_0_listener() { i0.ɵɵrestoreView(_r3); const ctx_r1 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r1.onMergeCancelled()); });
37
+ i0.ɵɵelementStart(1, "div", 8)(2, "span");
38
+ i0.ɵɵtext(3, "Keep");
39
+ i0.ɵɵelementEnd();
40
+ i0.ɵɵelementStart(4, "button", 9);
41
+ i0.ɵɵlistener("click", function GridViewRendererComponent_Conditional_4_Template_button_click_4_listener() { i0.ɵɵrestoreView(_r3); const ctx_r1 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r1.onSurvivorChange("left")); });
42
+ i0.ɵɵtext(5);
43
+ i0.ɵɵelementEnd();
44
+ i0.ɵɵelementStart(6, "button", 9);
45
+ i0.ɵɵlistener("click", function GridViewRendererComponent_Conditional_4_Template_button_click_6_listener() { i0.ɵɵrestoreView(_r3); const ctx_r1 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r1.onSurvivorChange("right")); });
46
+ i0.ɵɵtext(7);
47
+ i0.ɵɵelementEnd();
48
+ i0.ɵɵelementStart(8, "span", 10);
49
+ i0.ɵɵtext(9);
50
+ i0.ɵɵelementEnd()();
51
+ i0.ɵɵelementStart(10, "mj-record-merge-panel", 11);
52
+ i0.ɵɵlistener("MergeConfirmed", function GridViewRendererComponent_Conditional_4_Template_mj_record_merge_panel_MergeConfirmed_10_listener($event) { i0.ɵɵrestoreView(_r3); const ctx_r1 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r1.onMergeConfirmed($event)); })("MergeCancelled", function GridViewRendererComponent_Conditional_4_Template_mj_record_merge_panel_MergeCancelled_10_listener() { i0.ɵɵrestoreView(_r3); const ctx_r1 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r1.onMergeCancelled()); });
53
+ i0.ɵɵelementEnd();
54
+ i0.ɵɵconditionalCreate(11, GridViewRendererComponent_Conditional_4_Conditional_11_Template, 2, 1, "p", 12);
55
+ i0.ɵɵelementEnd();
56
+ } if (rf & 2) {
57
+ const ctx_r1 = i0.ɵɵnextContext();
58
+ i0.ɵɵproperty("Visible", true)("Closeable", !ctx_r1.mergeState.IsMerging);
59
+ i0.ɵɵadvance(4);
60
+ i0.ɵɵproperty("variant", ctx_r1.mergeState.Config.SurvivorSide === "left" ? "primary" : "secondary")("disabled", ctx_r1.mergeState.IsMerging);
61
+ i0.ɵɵattribute("aria-checked", ctx_r1.mergeState.Config.SurvivorSide === "left");
62
+ i0.ɵɵadvance();
63
+ i0.ɵɵtextInterpolate(ctx_r1.mergeState.Config.LeftLabel);
64
+ i0.ɵɵadvance();
65
+ i0.ɵɵproperty("variant", ctx_r1.mergeState.Config.SurvivorSide === "right" ? "primary" : "secondary")("disabled", ctx_r1.mergeState.IsMerging);
66
+ i0.ɵɵattribute("aria-checked", ctx_r1.mergeState.Config.SurvivorSide === "right");
67
+ i0.ɵɵadvance();
68
+ i0.ɵɵtextInterpolate(ctx_r1.mergeState.Config.RightLabel);
69
+ i0.ɵɵadvance(2);
70
+ i0.ɵɵtextInterpolate1("", ctx_r1.mergeLoserLabel, " will be deleted; its linked records move to the record you keep.");
71
+ i0.ɵɵadvance();
72
+ i0.ɵɵproperty("Fields", ctx_r1.mergeState.Fields)("Config", ctx_r1.mergeState.Config)("MergeEnabled", !ctx_r1.mergeState.IsMerging)("IsMerging", ctx_r1.mergeState.IsMerging);
73
+ i0.ɵɵadvance();
74
+ i0.ɵɵconditional(ctx_r1.mergeState.DependencyNote ? 11 : -1);
75
+ } }
76
+ function GridViewRendererComponent_Conditional_5_Template(rf, ctx) { if (rf & 1) {
77
+ const _r4 = i0.ɵɵgetCurrentView();
78
+ i0.ɵɵelementStart(0, "div", 5)(1, "span", 13);
79
+ i0.ɵɵtext(2);
80
+ i0.ɵɵelementEnd();
81
+ i0.ɵɵelementStart(3, "button", 14);
82
+ i0.ɵɵlistener("click", function GridViewRendererComponent_Conditional_5_Template_button_click_3_listener() { i0.ɵɵrestoreView(_r4); const ctx_r1 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r1.onDismissMergeNotice()); });
83
+ i0.ɵɵelement(4, "i", 15);
84
+ i0.ɵɵelementEnd()();
85
+ } if (rf & 2) {
86
+ const ctx_r1 = i0.ɵɵnextContext();
87
+ i0.ɵɵadvance(2);
88
+ i0.ɵɵtextInterpolate(ctx_r1.mergeNotice);
89
+ } }
21
90
  /**
22
91
  * GridViewRendererComponent
23
92
  * -------------------------
@@ -438,6 +507,310 @@ export class GridViewRendererComponent extends BaseAngularComponent {
438
507
  this.cdr.detectChanges();
439
508
  }
440
509
  // ================================================================
510
+ // Record merge
511
+ // ================================================================
512
+ /**
513
+ * The in-flight merge, or null when the panel is closed. Holds everything the panel needs
514
+ * plus the dependency preview, so the panel itself stays a pure presentation component.
515
+ */
516
+ mergeState = null;
517
+ /** A one-line status shown under the grid — why a merge was refused, or how one ended. */
518
+ mergeNotice = null;
519
+ /** Auto-dismiss timer for {@link mergeNotice}; cleared whenever the notice is replaced. */
520
+ mergeNoticeTimer = null;
521
+ /**
522
+ * How long a merge notice stays up before dismissing itself. The banner is fixed-position over
523
+ * the whole application, so leaving it until the next merge pins it across navigation and every
524
+ * other record the user visits afterwards.
525
+ */
526
+ static MERGE_NOTICE_TIMEOUT_MS = 8000;
527
+ comparison = inject(RecordComparisonService);
528
+ ngOnDestroy() {
529
+ // The notice's auto-dismiss outlives the view otherwise, and fires detectChanges on a
530
+ // destroyed component.
531
+ if (this.mergeNoticeTimer) {
532
+ clearTimeout(this.mergeNoticeTimer);
533
+ this.mergeNoticeTimer = null;
534
+ }
535
+ }
536
+ /** Dismiss the merge notice from its own close button. */
537
+ onDismissMergeNotice() {
538
+ this.setMergeNotice(null);
539
+ this.cdr.detectChanges();
540
+ }
541
+ /**
542
+ * Set (or clear) the merge notice, replacing any pending auto-dismiss. Every write to
543
+ * `mergeNotice` goes through here so a notice cannot outlive its timer or strand an old one.
544
+ */
545
+ setMergeNotice(message) {
546
+ if (this.mergeNoticeTimer) {
547
+ clearTimeout(this.mergeNoticeTimer);
548
+ this.mergeNoticeTimer = null;
549
+ }
550
+ this.mergeNotice = message;
551
+ if (message) {
552
+ this.mergeNoticeTimer = setTimeout(() => {
553
+ this.mergeNoticeTimer = null;
554
+ this.mergeNotice = null;
555
+ this.cdr.detectChanges();
556
+ }, GridViewRendererComponent.MERGE_NOTICE_TIMEOUT_MS);
557
+ }
558
+ }
559
+ /**
560
+ * Show the Merge button only where merging can actually succeed: the entity opts in and the
561
+ * user can both update the survivor and delete the loser.
562
+ */
563
+ get effectiveShowMergeButton() {
564
+ const entity = this.entity;
565
+ const user = this.ProviderToUse?.CurrentUser;
566
+ if (!entity?.AllowRecordMerge || !user)
567
+ return false;
568
+ const permissions = entity.GetUserPermisions(user);
569
+ return !!permissions && permissions.CanUpdate && permissions.CanDelete;
570
+ }
571
+ /** The record that will be deleted, named so the user sees the choice before confirming. */
572
+ get mergeLoserLabel() {
573
+ const config = this.mergeState?.Config;
574
+ if (!config)
575
+ return '';
576
+ return config.SurvivorSide === 'left' ? config.RightLabel : config.LeftLabel;
577
+ }
578
+ /**
579
+ * The grid asked to merge the selected rows. Compare them, preview what will move, and open
580
+ * the panel — nothing is written until the user confirms.
581
+ */
582
+ async onMergeRequested(event) {
583
+ this.setMergeNotice(null);
584
+ try {
585
+ await this.prepareMerge(event);
586
+ }
587
+ catch (err) {
588
+ // A transport failure in any pre-flight query must not leave the user with nothing.
589
+ this.setMergeNotice(`Could not prepare the merge: ${err instanceof Error ? err.message : String(err)}`);
590
+ this.cdr.detectChanges();
591
+ }
592
+ }
593
+ /** The user chose which record survives; differing fields default to that record's values. */
594
+ onSurvivorChange(side) {
595
+ if (!this.mergeState || this.mergeState.IsMerging)
596
+ return;
597
+ this.mergeState.Config = { ...this.mergeState.Config, SurvivorSide: side };
598
+ for (const field of this.mergeState.Fields) {
599
+ if (!field.IsReadOnly)
600
+ field.SelectedSide = side;
601
+ }
602
+ this.cdr.detectChanges();
603
+ }
604
+ async prepareMerge(event) {
605
+ const entity = event.entityInfo ?? this.entity;
606
+ if (!entity)
607
+ return;
608
+ // The merge panel is two-sided by construction, so n > 2 has nowhere to render.
609
+ if (event.records.length !== 2) {
610
+ this.setMergeNotice('Select exactly two records to merge.');
611
+ this.cdr.detectChanges();
612
+ return;
613
+ }
614
+ // Composite-key safe: the grid renders arbitrary entities, so never assume one key column.
615
+ const keys = event.records.map(r => CompositeKey.FromEntityRecord(entity, r));
616
+ if (keys.some(k => k.KeyValuePairs.length === 0 || k.KeyValuePairs.some(kv => kv.Value == null || kv.Value === ''))) {
617
+ this.setMergeNotice('Could not read the primary key of the selected records.');
618
+ this.cdr.detectChanges();
619
+ return;
620
+ }
621
+ // The merge panel's config is string-keyed, so keys travel through it as compact segments.
622
+ const [left, right] = keys.map(k => k.ToCompactURLSegment());
623
+ // Subtype records: the merge re-points only the keys that target this entity, then the loser's
624
+ // delete follows the shared key into the parent row, whose own references never moved.
625
+ if (entity.ParentID) {
626
+ this.setMergeNotice(`${entity.DisplayName} records extend a parent type; merging subtype records is not supported yet.`);
627
+ this.cdr.detectChanges();
628
+ return;
629
+ }
630
+ const childRows = await this.hasIsAChildRows(entity, keys);
631
+ if (childRows === null) {
632
+ this.setMergeNotice('Could not verify whether the selected records have subtype rows, so the merge was not started.');
633
+ this.cdr.detectChanges();
634
+ return;
635
+ }
636
+ if (childRows) {
637
+ this.setMergeNotice(`${entity.DisplayName} records that another app extends (shared-key subtype rows) cannot be merged yet — ` +
638
+ 'the merge would collide on the shared key. Merge them from the extending app instead.');
639
+ this.cdr.detectChanges();
640
+ return;
641
+ }
642
+ const result = await this.comparison.GetRecordComparison({
643
+ EntityName: entity.Name,
644
+ Keys: keys.map(k => ({
645
+ KeyValuePairs: k.KeyValuePairs.map(kv => ({ FieldName: kv.FieldName, Value: String(kv.Value) })),
646
+ })),
647
+ }, this.ProviderToUse);
648
+ if (!result.Success || !result.Output) {
649
+ this.setMergeNotice(result.ErrorMessage ?? 'Could not compare the selected records.');
650
+ this.cdr.detectChanges();
651
+ return;
652
+ }
653
+ this.mergeState = {
654
+ Config: {
655
+ EntityName: entity.Name,
656
+ LeftRecordID: left,
657
+ RightRecordID: right,
658
+ SurvivorSide: 'left',
659
+ LeftLabel: this.labelFor(event.records[0], entity, keys[0]),
660
+ RightLabel: this.labelFor(event.records[1], entity, keys[1]),
661
+ },
662
+ Fields: result.Output.Fields.map(delta => ({
663
+ FieldName: delta.FieldName,
664
+ DisplayLabel: delta.DisplayName,
665
+ LeftValue: delta.Cells[0]?.Value,
666
+ RightValue: delta.Cells[1]?.Value,
667
+ HasConflict: delta.Differs,
668
+ SelectedSide: 'left',
669
+ // What the ORM will refuse to write — keys, AllowUpdateAPI = 0, timestamps. A field the
670
+ // metadata does not know cannot be written either.
671
+ IsReadOnly: entity.FieldByName(delta.FieldName)?.ReadOnly ?? true,
672
+ DataType: 'string',
673
+ })),
674
+ IsMerging: false,
675
+ DependencyNote: await this.describeDependencies(entity, keys),
676
+ Entity: entity,
677
+ Keys: keys,
678
+ };
679
+ this.cdr.detectChanges();
680
+ }
681
+ /** The user confirmed: merge, then reload the page so the loser disappears from the grid. */
682
+ async onMergeConfirmed(event) {
683
+ if (!this.mergeState)
684
+ return;
685
+ this.mergeState.IsMerging = true;
686
+ this.cdr.detectChanges();
687
+ const survivorIsLeft = event.Config.SurvivorSide === 'left';
688
+ const [leftKey, rightKey] = this.mergeState.Keys;
689
+ const survivor = survivorIsLeft ? leftKey : rightKey;
690
+ const loser = survivorIsLeft ? rightKey : leftKey;
691
+ const request = new RecordMergeRequest();
692
+ request.EntityName = event.Config.EntityName;
693
+ request.SurvivingRecordCompositeKey = survivor;
694
+ request.RecordsToMerge = [loser];
695
+ request.FieldMap = this.buildFieldMap(event, survivorIsLeft);
696
+ try {
697
+ const result = await this.ProviderToUse.MergeRecords(request);
698
+ this.mergeState = null;
699
+ this.setMergeNotice(result.Success ? 'Records merged.' : `Merge failed: ${result.OverallStatus}`);
700
+ if (result.Success) {
701
+ this.dataRequest.emit(this.currentPageDataRequest());
702
+ }
703
+ }
704
+ catch (err) {
705
+ this.mergeState = null;
706
+ this.setMergeNotice(`Merge failed: ${err instanceof Error ? err.message : String(err)}`);
707
+ }
708
+ this.cdr.detectChanges();
709
+ }
710
+ /** Merge cancelled → close the panel, write nothing. */
711
+ onMergeCancelled() {
712
+ this.mergeState = null;
713
+ this.cdr.detectChanges();
714
+ }
715
+ /**
716
+ * Fields whose resolution has to travel, and only those: the survivor already holds the value
717
+ * for any field resolved to its own side.
718
+ *
719
+ * The distinction that matters is "the user did not change this" versus "the user chose the
720
+ * blank side". Both arrive as an absent value, so deciding from the value alone drops a real
721
+ * resolution — the survivor silently keeps its old value on the one operation where someone is
722
+ * reconciling two records field by field, and the loser is gone afterwards. So the skip is
723
+ * decided from WHICH SIDE was chosen, and a chosen blank is written through as null.
724
+ */
725
+ buildFieldMap(event, survivorIsLeft) {
726
+ const map = [];
727
+ for (const field of event.ResolvedFields) {
728
+ if (field.IsReadOnly || !field.HasConflict)
729
+ continue;
730
+ // Resolved to the survivor's own side — nothing to write, whatever the value is.
731
+ if (field.SelectedSide !== 'custom' && (field.SelectedSide === 'left') === survivorIsLeft)
732
+ continue;
733
+ const chosen = field.SelectedSide === 'custom'
734
+ ? field.CustomValue
735
+ : (survivorIsLeft ? field.RightValue : field.LeftValue);
736
+ // A chosen blank is a clear, not a no-op. Normalize undefined to null so the transport
737
+ // carries an explicit value rather than an absent one.
738
+ map.push({ FieldName: field.FieldName, Value: chosen === undefined ? null : chosen });
739
+ }
740
+ return map;
741
+ }
742
+ /**
743
+ * True when any of these records has a row in an IS-A child entity, null when that could not be
744
+ * determined (a child view the user cannot read answers `Success: false`, which must not pass
745
+ * for "no rows"). `MergeRecords` has no subtype handling: the child rows share the parent's key,
746
+ * so merging would collide. Refuse rather than half-merge.
747
+ */
748
+ async hasIsAChildRows(entity, keys) {
749
+ const children = entity.ChildEntities;
750
+ if (children.length === 0)
751
+ return false;
752
+ // An IS-A child shares its parent's key SEMANTICALLY; nothing requires it to share the column
753
+ // NAME. `CompositeKey.ToWhereClause()` emits the parent's names, so filtering a child view
754
+ // with it fails wherever the names differ — and a failed probe refuses the merge, which would
755
+ // disable merge for that entity permanently. Pair each key's values onto the child's own PK
756
+ // columns instead, the way the platform's own child discovery does
757
+ // (SQLServerDataProvider.BuildChildDiscoverySQL).
758
+ const probes = children
759
+ .map(child => ({ child, filter: this.buildChildKeyFilter(child, keys) }))
760
+ .filter((probe) => probe.filter !== null);
761
+ if (probes.length === 0)
762
+ return false;
763
+ const results = await RunView.FromMetadataProvider(this.ProviderToUse).RunViews(probes.map(({ child, filter }) => ({
764
+ EntityName: child.Name,
765
+ ExtraFilter: filter,
766
+ Fields: child.PrimaryKeys.map(f => f.Name),
767
+ ResultType: 'simple',
768
+ MaxRows: 1,
769
+ })));
770
+ if (results.some(r => !r.Success))
771
+ return null;
772
+ return results.some(r => r.Results.length > 0);
773
+ }
774
+ /**
775
+ * `(childPk = v1) OR (childPk = v2)` against one child entity, built from the parent's key
776
+ * VALUES and the child's own key COLUMN NAMES, paired positionally because an IS-A child shares
777
+ * its parent's key by design. Null when the child's key arity differs, which means it does not
778
+ * share the key and cannot hold a subtype row for these records.
779
+ */
780
+ buildChildKeyFilter(child, keys) {
781
+ const childPkFields = child.PrimaryKeys;
782
+ if (childPkFields.length === 0 || keys.some(k => k.KeyValuePairs.length !== childPkFields.length)) {
783
+ return null;
784
+ }
785
+ const clauses = keys.map(key => childPkFields
786
+ .map((field, i) => `[${field.Name}] = '${String(key.KeyValuePairs[i].Value ?? '').replace(/'/g, "''")}'`)
787
+ .join(' AND '));
788
+ return clauses.map(c => `(${c})`).join(' OR ');
789
+ }
790
+ /**
791
+ * How many linked records will move to the survivor — the thing users most want to know.
792
+ *
793
+ * Indexes `counts[0]` / `counts[1]` directly: `onMergeRequested` refuses anything other than
794
+ * exactly two records before reaching here, and the merge panel's config is two-sided by
795
+ * construction, so this is only ever called with a pair. Widen both together.
796
+ */
797
+ async describeDependencies(entity, keys) {
798
+ try {
799
+ const counts = await Promise.all(keys.map(key => this.ProviderToUse.GetRecordDependencies(entity.Name, key)));
800
+ return `Linked records that would move to the survivor: ${counts[0].length} on the left, ${counts[1].length} on the right.`;
801
+ }
802
+ catch (err) {
803
+ LogError(`Could not load merge dependencies for ${entity.Name}: ${err instanceof Error ? err.message : String(err)}`);
804
+ return null;
805
+ }
806
+ }
807
+ /** A human label for a row — its name field when it has one, else its key. */
808
+ labelFor(row, entity, key) {
809
+ const nameField = entity.NameField?.Name;
810
+ const name = nameField ? String(row[nameField] ?? '') : '';
811
+ return name || key.ToCompactURLSegment();
812
+ }
813
+ // ================================================================
441
814
  // Helpers
442
815
  // ================================================================
443
816
  /** Build a {@link ViewDataRequest} that re-requests the host's current page/pageSize, if known. */
@@ -469,21 +842,27 @@ export class GridViewRendererComponent extends BaseAngularComponent {
469
842
  } if (rf & 2) {
470
843
  let _t;
471
844
  i0.ɵɵqueryRefresh(_t = i0.ɵɵloadQuery()) && (ctx.grid = _t.first);
472
- } }, inputs: { entity: "entity", records: "records", exportDataProvider: "exportDataProvider", selectedRecordId: "selectedRecordId", filterText: "filterText", config: "config", AutoLoadEntityActions: "AutoLoadEntityActions", totalRecordCount: "totalRecordCount", page: "page", pageSize: "pageSize", isLoading: "isLoading" }, outputs: { recordSelected: "recordSelected", recordOpened: "recordOpened", configChanged: "configChanged", dataRequest: "dataRequest", configureRequested: "configureRequested", openRelatedRecordRequested: "openRelatedRecordRequested", createRecordRequested: "createRecordRequested" }, standalone: false, features: [i0.ɵɵInheritDefinitionFeature], decls: 4, vars: 22, consts: [["grid", ""], [3, "AfterRowClick", "AfterRowDoubleClick", "AfterSort", "GridStateChanged", "SelectionChange", "NewButtonClick", "RefreshButtonClick", "DeleteButtonClick", "AddToListRequested", "ForeignKeyClick", "PageChange", "ManageColumnsRequested", "Provider", "Data", "ExportDataProvider", "Params", "FilterText", "GridState", "Height", "AllowLoad", "AutoLoadEntityActions", "ShowToolbar", "ShowSearch", "ToolbarConfig", "SelectionMode", "ShowAddToListButton", "ShowPager", "PageSize", "TotalRowCount", "PagerPageNumber"], [3, "Provider", "visible", "config"], ["Title", "Delete Records", "DetailMessage", "This action cannot be undone.", "ConfirmText", "Delete", "ConfirmStyle", "danger", "Icon", "fa-solid fa-trash", 3, "Confirmed", "Cancelled", "IsOpen", "Message"], [3, "complete", "cancel", "Provider", "visible", "config"]], template: function GridViewRendererComponent_Template(rf, ctx) { if (rf & 1) {
845
+ } }, inputs: { entity: "entity", records: "records", exportDataProvider: "exportDataProvider", selectedRecordId: "selectedRecordId", filterText: "filterText", config: "config", AutoLoadEntityActions: "AutoLoadEntityActions", totalRecordCount: "totalRecordCount", page: "page", pageSize: "pageSize", isLoading: "isLoading" }, outputs: { recordSelected: "recordSelected", recordOpened: "recordOpened", configChanged: "configChanged", dataRequest: "dataRequest", configureRequested: "configureRequested", openRelatedRecordRequested: "openRelatedRecordRequested", createRecordRequested: "createRecordRequested" }, standalone: false, features: [i0.ɵɵInheritDefinitionFeature], decls: 6, vars: 25, consts: [["grid", ""], [3, "AfterRowClick", "AfterRowDoubleClick", "AfterSort", "GridStateChanged", "SelectionChange", "NewButtonClick", "RefreshButtonClick", "DeleteButtonClick", "AddToListRequested", "ForeignKeyClick", "PageChange", "ManageColumnsRequested", "MergeRecordsRequested", "Provider", "Data", "ExportDataProvider", "Params", "FilterText", "GridState", "Height", "AllowLoad", "AutoLoadEntityActions", "ShowToolbar", "ShowSearch", "ToolbarConfig", "SelectionMode", "ShowAddToListButton", "ShowPager", "PageSize", "TotalRowCount", "PagerPageNumber", "ShowMergeButton"], [3, "Provider", "visible", "config"], ["Title", "Delete Records", "DetailMessage", "This action cannot be undone.", "ConfirmText", "Delete", "ConfirmStyle", "danger", "Icon", "fa-solid fa-trash", 3, "Confirmed", "Cancelled", "IsOpen", "Message"], ["Size", "lg", 3, "Visible", "Closeable"], ["role", "status", 1, "mj-ev-merge-notice"], [3, "complete", "cancel", "Provider", "visible", "config"], ["Size", "lg", 3, "Close", "Visible", "Closeable"], ["role", "radiogroup", "aria-label", "Record to keep", 1, "mj-ev-merge-survivor"], ["type", "button", "mjButton", "", "size", "sm", "role", "radio", 3, "click", "variant", "disabled"], [1, "mj-ev-merge-survivor-note"], [3, "MergeConfirmed", "MergeCancelled", "Fields", "Config", "MergeEnabled", "IsMerging"], [1, "mj-ev-merge-deps"], [1, "mj-ev-merge-notice-text"], ["type", "button", "title", "Dismiss", "aria-label", "Dismiss", 1, "mj-ev-merge-notice-dismiss", 3, "click"], [1, "fa-solid", "fa-xmark"]], template: function GridViewRendererComponent_Template(rf, ctx) { if (rf & 1) {
473
846
  i0.ɵɵelementStart(0, "mj-entity-data-grid", 1, 0);
474
- i0.ɵɵlistener("AfterRowClick", function GridViewRendererComponent_Template_mj_entity_data_grid_AfterRowClick_0_listener($event) { return ctx.onAfterRowClick($event); })("AfterRowDoubleClick", function GridViewRendererComponent_Template_mj_entity_data_grid_AfterRowDoubleClick_0_listener($event) { return ctx.onAfterRowDoubleClick($event); })("AfterSort", function GridViewRendererComponent_Template_mj_entity_data_grid_AfterSort_0_listener($event) { return ctx.onAfterSort($event); })("GridStateChanged", function GridViewRendererComponent_Template_mj_entity_data_grid_GridStateChanged_0_listener($event) { return ctx.onGridStateChanged($event); })("SelectionChange", function GridViewRendererComponent_Template_mj_entity_data_grid_SelectionChange_0_listener($event) { return ctx.onSelectionChange($event); })("NewButtonClick", function GridViewRendererComponent_Template_mj_entity_data_grid_NewButtonClick_0_listener() { return ctx.onNewButtonClick(); })("RefreshButtonClick", function GridViewRendererComponent_Template_mj_entity_data_grid_RefreshButtonClick_0_listener() { return ctx.onRefreshButtonClick(); })("DeleteButtonClick", function GridViewRendererComponent_Template_mj_entity_data_grid_DeleteButtonClick_0_listener($event) { return ctx.onDeleteButtonClick($event); })("AddToListRequested", function GridViewRendererComponent_Template_mj_entity_data_grid_AddToListRequested_0_listener($event) { return ctx.onAddToListRequested($event); })("ForeignKeyClick", function GridViewRendererComponent_Template_mj_entity_data_grid_ForeignKeyClick_0_listener($event) { return ctx.onForeignKeyClick($event); })("PageChange", function GridViewRendererComponent_Template_mj_entity_data_grid_PageChange_0_listener($event) { return ctx.onPageChange($event); })("ManageColumnsRequested", function GridViewRendererComponent_Template_mj_entity_data_grid_ManageColumnsRequested_0_listener() { return ctx.configureRequested.emit(); });
847
+ i0.ɵɵlistener("AfterRowClick", function GridViewRendererComponent_Template_mj_entity_data_grid_AfterRowClick_0_listener($event) { return ctx.onAfterRowClick($event); })("AfterRowDoubleClick", function GridViewRendererComponent_Template_mj_entity_data_grid_AfterRowDoubleClick_0_listener($event) { return ctx.onAfterRowDoubleClick($event); })("AfterSort", function GridViewRendererComponent_Template_mj_entity_data_grid_AfterSort_0_listener($event) { return ctx.onAfterSort($event); })("GridStateChanged", function GridViewRendererComponent_Template_mj_entity_data_grid_GridStateChanged_0_listener($event) { return ctx.onGridStateChanged($event); })("SelectionChange", function GridViewRendererComponent_Template_mj_entity_data_grid_SelectionChange_0_listener($event) { return ctx.onSelectionChange($event); })("NewButtonClick", function GridViewRendererComponent_Template_mj_entity_data_grid_NewButtonClick_0_listener() { return ctx.onNewButtonClick(); })("RefreshButtonClick", function GridViewRendererComponent_Template_mj_entity_data_grid_RefreshButtonClick_0_listener() { return ctx.onRefreshButtonClick(); })("DeleteButtonClick", function GridViewRendererComponent_Template_mj_entity_data_grid_DeleteButtonClick_0_listener($event) { return ctx.onDeleteButtonClick($event); })("AddToListRequested", function GridViewRendererComponent_Template_mj_entity_data_grid_AddToListRequested_0_listener($event) { return ctx.onAddToListRequested($event); })("ForeignKeyClick", function GridViewRendererComponent_Template_mj_entity_data_grid_ForeignKeyClick_0_listener($event) { return ctx.onForeignKeyClick($event); })("PageChange", function GridViewRendererComponent_Template_mj_entity_data_grid_PageChange_0_listener($event) { return ctx.onPageChange($event); })("ManageColumnsRequested", function GridViewRendererComponent_Template_mj_entity_data_grid_ManageColumnsRequested_0_listener() { return ctx.configureRequested.emit(); })("MergeRecordsRequested", function GridViewRendererComponent_Template_mj_entity_data_grid_MergeRecordsRequested_0_listener($event) { return ctx.onMergeRequested($event); });
475
848
  i0.ɵɵelementEnd();
476
849
  i0.ɵɵconditionalCreate(2, GridViewRendererComponent_Conditional_2_Template, 1, 3, "mj-list-management-dialog", 2);
477
850
  i0.ɵɵelementStart(3, "mj-ev-confirm-dialog", 3);
478
851
  i0.ɵɵlistener("Confirmed", function GridViewRendererComponent_Template_mj_ev_confirm_dialog_Confirmed_3_listener() { return ctx.onDeleteConfirmed(); })("Cancelled", function GridViewRendererComponent_Template_mj_ev_confirm_dialog_Cancelled_3_listener() { return ctx.onDeleteCancelled(); });
479
852
  i0.ɵɵelementEnd();
853
+ i0.ɵɵconditionalCreate(4, GridViewRendererComponent_Conditional_4_Template, 12, 16, "mj-dialog", 4);
854
+ i0.ɵɵconditionalCreate(5, GridViewRendererComponent_Conditional_5_Template, 5, 1, "div", 5);
480
855
  } if (rf & 2) {
481
- i0.ɵɵproperty("Provider", ctx.Provider)("Data", ctx.records)("ExportDataProvider", ctx.exportDataProvider)("Params", ctx.effectiveParams)("FilterText", ctx.filterText ?? "")("GridState", ctx.config.gridState ?? null)("Height", "auto")("AllowLoad", false)("AutoLoadEntityActions", ctx.AutoLoadEntityActions)("ShowToolbar", ctx.effectiveShowToolbar)("ShowSearch", ctx.effectiveShowSearch)("ToolbarConfig", ctx.config.toolbarConfig ?? i0.ɵɵpureFunction0(21, _c1))("SelectionMode", ctx.effectiveSelectionMode)("ShowAddToListButton", ctx.effectiveShowAddToListButton)("ShowPager", ctx.effectiveShowPager)("PageSize", ctx.effectivePageSize)("TotalRowCount", ctx.totalRecordCount ?? 0)("PagerPageNumber", ctx.page ?? 1);
856
+ i0.ɵɵproperty("Provider", ctx.Provider)("Data", ctx.records)("ExportDataProvider", ctx.exportDataProvider)("Params", ctx.effectiveParams)("FilterText", ctx.filterText ?? "")("GridState", ctx.config.gridState ?? null)("Height", "auto")("AllowLoad", false)("AutoLoadEntityActions", ctx.AutoLoadEntityActions)("ShowToolbar", ctx.effectiveShowToolbar)("ShowSearch", ctx.effectiveShowSearch)("ToolbarConfig", ctx.config.toolbarConfig ?? i0.ɵɵpureFunction0(24, _c1))("SelectionMode", ctx.effectiveSelectionMode)("ShowAddToListButton", ctx.effectiveShowAddToListButton)("ShowPager", ctx.effectiveShowPager)("PageSize", ctx.effectivePageSize)("TotalRowCount", ctx.totalRecordCount ?? 0)("PagerPageNumber", ctx.page ?? 1)("ShowMergeButton", ctx.effectiveShowMergeButton);
482
857
  i0.ɵɵadvance(2);
483
858
  i0.ɵɵconditional(ctx.listManagementConfig ? 2 : -1);
484
859
  i0.ɵɵadvance();
485
860
  i0.ɵɵproperty("IsOpen", ctx.showDeleteConfirm)("Message", ctx.deleteConfirmMessage);
486
- } }, dependencies: [i1.ListManagementDialogComponent, i2.EntityDataGridComponent, i3.ConfirmDialogComponent], styles: ["\n :host {\n display: block;\n height: 100%;\n }\n "], encapsulation: 2 });
861
+ i0.ɵɵadvance();
862
+ i0.ɵɵconditional(ctx.mergeState ? 4 : -1);
863
+ i0.ɵɵadvance();
864
+ i0.ɵɵconditional(ctx.mergeNotice ? 5 : -1);
865
+ } }, dependencies: [i1.ListManagementDialogComponent, i2.RecordMergePanelComponent, i3.MJDialogComponent, i3.MJButtonDirective, i4.EntityDataGridComponent, i5.ConfirmDialogComponent], styles: ["\n :host {\n display: block;\n height: 100%;\n }\n\n .mj-ev-merge-survivor {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 8px;\n margin-bottom: 12px;\n font-size: var(--mj-text-sm);\n color: var(--mj-text-secondary);\n }\n\n .mj-ev-merge-survivor-note {\n color: var(--mj-text-muted);\n }\n\n .mj-ev-merge-deps {\n margin: 0;\n font-size: var(--mj-text-xs);\n color: var(--mj-text-muted);\n }\n\n .mj-ev-merge-notice {\n position: fixed;\n bottom: 16px;\n left: 50%;\n transform: translateX(-50%);\n z-index: 1001;\n padding: 8px 14px;\n border: 1px solid var(--mj-border-subtle);\n border-radius: var(--mj-radius-md);\n background: var(--mj-bg-surface-elevated);\n color: var(--mj-text-primary);\n font-size: var(--mj-text-sm);\n box-shadow: var(--mj-shadow-md);\n display: flex;\n align-items: center;\n gap: 10px;\n }\n\n .mj-ev-merge-notice-dismiss {\n flex-shrink: 0;\n background: none;\n border: none;\n padding: 0 2px;\n cursor: pointer;\n color: var(--mj-text-muted);\n font-size: var(--mj-text-sm);\n line-height: 1;\n }\n\n .mj-ev-merge-notice-dismiss:hover {\n color: var(--mj-text-primary);\n }\n "], encapsulation: 2 });
487
866
  }
488
867
  (() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(GridViewRendererComponent, [{
489
868
  type: Component,
@@ -520,6 +899,8 @@ export class GridViewRendererComponent extends BaseAngularComponent {
520
899
  (ForeignKeyClick)="onForeignKeyClick($event)"
521
900
  (PageChange)="onPageChange($event)"
522
901
  (ManageColumnsRequested)="configureRequested.emit()"
902
+ [ShowMergeButton]="effectiveShowMergeButton"
903
+ (MergeRecordsRequested)="onMergeRequested($event)"
523
904
  >
524
905
  </mj-entity-data-grid>
525
906
 
@@ -552,7 +933,48 @@ export class GridViewRendererComponent extends BaseAngularComponent {
552
933
  (Cancelled)="onDeleteCancelled()"
553
934
  >
554
935
  </mj-ev-confirm-dialog>
555
- `, styles: ["\n :host {\n display: block;\n height: 100%;\n }\n "] }]
936
+
937
+ <!-- Self-contained merge panel (Generic) — owned by this wrapper, never bubbles up.
938
+ Lives inside the @if so the panel rebuilds its comparison (ngOnInit) per request. -->
939
+ @if (mergeState) {
940
+ <mj-dialog [Visible]="true" Size="lg" [Closeable]="!mergeState.IsMerging" (Close)="onMergeCancelled()">
941
+ <div class="mj-ev-merge-survivor" role="radiogroup" aria-label="Record to keep">
942
+ <span>Keep</span>
943
+ <button type="button" mjButton size="sm" role="radio"
944
+ [variant]="mergeState.Config.SurvivorSide === 'left' ? 'primary' : 'secondary'"
945
+ [attr.aria-checked]="mergeState.Config.SurvivorSide === 'left'"
946
+ [disabled]="mergeState.IsMerging"
947
+ (click)="onSurvivorChange('left')">{{ mergeState.Config.LeftLabel }}</button>
948
+ <button type="button" mjButton size="sm" role="radio"
949
+ [variant]="mergeState.Config.SurvivorSide === 'right' ? 'primary' : 'secondary'"
950
+ [attr.aria-checked]="mergeState.Config.SurvivorSide === 'right'"
951
+ [disabled]="mergeState.IsMerging"
952
+ (click)="onSurvivorChange('right')">{{ mergeState.Config.RightLabel }}</button>
953
+ <span class="mj-ev-merge-survivor-note">{{ mergeLoserLabel }} will be deleted; its linked records move to the record you keep.</span>
954
+ </div>
955
+ <mj-record-merge-panel
956
+ [Fields]="mergeState.Fields"
957
+ [Config]="mergeState.Config"
958
+ [MergeEnabled]="!mergeState.IsMerging"
959
+ [IsMerging]="mergeState.IsMerging"
960
+ (MergeConfirmed)="onMergeConfirmed($event)"
961
+ (MergeCancelled)="onMergeCancelled()"
962
+ >
963
+ </mj-record-merge-panel>
964
+ @if (mergeState.DependencyNote) {
965
+ <p class="mj-ev-merge-deps">{{ mergeState.DependencyNote }}</p>
966
+ }
967
+ </mj-dialog>
968
+ }
969
+ @if (mergeNotice) {
970
+ <div class="mj-ev-merge-notice" role="status">
971
+ <span class="mj-ev-merge-notice-text">{{ mergeNotice }}</span>
972
+ <button type="button" class="mj-ev-merge-notice-dismiss" (click)="onDismissMergeNotice()" title="Dismiss" aria-label="Dismiss">
973
+ <i class="fa-solid fa-xmark"></i>
974
+ </button>
975
+ </div>
976
+ }
977
+ `, styles: ["\n :host {\n display: block;\n height: 100%;\n }\n\n .mj-ev-merge-survivor {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 8px;\n margin-bottom: 12px;\n font-size: var(--mj-text-sm);\n color: var(--mj-text-secondary);\n }\n\n .mj-ev-merge-survivor-note {\n color: var(--mj-text-muted);\n }\n\n .mj-ev-merge-deps {\n margin: 0;\n font-size: var(--mj-text-xs);\n color: var(--mj-text-muted);\n }\n\n .mj-ev-merge-notice {\n position: fixed;\n bottom: 16px;\n left: 50%;\n transform: translateX(-50%);\n z-index: 1001;\n padding: 8px 14px;\n border: 1px solid var(--mj-border-subtle);\n border-radius: var(--mj-radius-md);\n background: var(--mj-bg-surface-elevated);\n color: var(--mj-text-primary);\n font-size: var(--mj-text-sm);\n box-shadow: var(--mj-shadow-md);\n display: flex;\n align-items: center;\n gap: 10px;\n }\n\n .mj-ev-merge-notice-dismiss {\n flex-shrink: 0;\n background: none;\n border: none;\n padding: 0 2px;\n cursor: pointer;\n color: var(--mj-text-muted);\n font-size: var(--mj-text-sm);\n line-height: 1;\n }\n\n .mj-ev-merge-notice-dismiss:hover {\n color: var(--mj-text-primary);\n }\n "] }]
556
978
  }], null, { entity: [{
557
979
  type: Input
558
980
  }], records: [{
@@ -593,7 +1015,7 @@ export class GridViewRendererComponent extends BaseAngularComponent {
593
1015
  type: ViewChild,
594
1016
  args: ['grid']
595
1017
  }] }); })();
596
- (() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassDebugInfo(GridViewRendererComponent, { className: "GridViewRendererComponent", filePath: "src/lib/view-types/renderers/grid-view-renderer.component.ts", lineNumber: 186 }); })();
1018
+ (() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassDebugInfo(GridViewRendererComponent, { className: "GridViewRendererComponent", filePath: "src/lib/view-types/renderers/grid-view-renderer.component.ts", lineNumber: 288 }); })();
597
1019
  /**
598
1020
  * Tree-shaking guard. Force-references this renderer so bundlers (ESBuild/Vite) don't drop the
599
1021
  * component in builds that only mount it dynamically via the ClassFactory/descriptor. Mirrors the