@memberjunction/ng-shared 5.51.0 → 6.1.0-edge.1

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 (44) hide show
  1. package/LICENSE +7 -0
  2. package/dist/lib/base-dashboard.d.ts +16 -0
  3. package/dist/lib/base-dashboard.d.ts.map +1 -1
  4. package/dist/lib/base-dashboard.js +36 -5
  5. package/dist/lib/base-dashboard.js.map +1 -1
  6. package/dist/lib/base-resource-component.d.ts +33 -3
  7. package/dist/lib/base-resource-component.d.ts.map +1 -1
  8. package/dist/lib/base-resource-component.js +65 -11
  9. package/dist/lib/base-resource-component.js.map +1 -1
  10. package/dist/lib/explorer-breakpoint.service.d.ts +39 -0
  11. package/dist/lib/explorer-breakpoint.service.d.ts.map +1 -0
  12. package/dist/lib/explorer-breakpoint.service.js +57 -0
  13. package/dist/lib/explorer-breakpoint.service.js.map +1 -0
  14. package/dist/lib/home-pin.service.d.ts.map +1 -1
  15. package/dist/lib/home-pin.service.js +0 -2
  16. package/dist/lib/home-pin.service.js.map +1 -1
  17. package/dist/lib/navigation.interfaces.d.ts +13 -0
  18. package/dist/lib/navigation.interfaces.d.ts.map +1 -1
  19. package/dist/lib/navigation.interfaces.js.map +1 -1
  20. package/dist/lib/navigation.service.d.ts +70 -8
  21. package/dist/lib/navigation.service.d.ts.map +1 -1
  22. package/dist/lib/navigation.service.js +356 -50
  23. package/dist/lib/navigation.service.js.map +1 -1
  24. package/dist/lib/record-open-style.d.ts +88 -0
  25. package/dist/lib/record-open-style.d.ts.map +1 -0
  26. package/dist/lib/record-open-style.js +124 -0
  27. package/dist/lib/record-open-style.js.map +1 -0
  28. package/dist/lib/record-tab-icon.d.ts +10 -0
  29. package/dist/lib/record-tab-icon.d.ts.map +1 -0
  30. package/dist/lib/record-tab-icon.js +15 -0
  31. package/dist/lib/record-tab-icon.js.map +1 -0
  32. package/dist/lib/safe-detect-changes.d.ts +18 -0
  33. package/dist/lib/safe-detect-changes.d.ts.map +1 -0
  34. package/dist/lib/safe-detect-changes.js +29 -0
  35. package/dist/lib/safe-detect-changes.js.map +1 -0
  36. package/dist/lib/shared.service.d.ts +5 -1
  37. package/dist/lib/shared.service.d.ts.map +1 -1
  38. package/dist/lib/shared.service.js +16 -14
  39. package/dist/lib/shared.service.js.map +1 -1
  40. package/dist/public-api.d.ts +4 -0
  41. package/dist/public-api.d.ts.map +1 -1
  42. package/dist/public-api.js +4 -0
  43. package/dist/public-api.js.map +1 -1
  44. package/package.json +22 -20
@@ -1,4 +1,6 @@
1
1
  import { Injectable } from '@angular/core';
2
+ import { IsRecordTabsStyle, RECORDS_RESOURCE_TYPE, IsRecordsTabConfiguration } from './record-open-style';
3
+ import { CompositeKey } from '@memberjunction/core';
2
4
  import { fromEvent, BehaviorSubject, Subject } from 'rxjs';
3
5
  import { map, distinctUntilChanged } from 'rxjs/operators';
4
6
  import { UUIDsEqual } from '@memberjunction/global';
@@ -348,24 +350,57 @@ export class NavigationService {
348
350
  return tabId;
349
351
  }
350
352
  /**
351
- * Open an entity record view
352
- * Uses Home app if available, otherwise falls back to active app or system app
353
+ * Open an entity record view. Behavior forks on the deployment's
354
+ * record-open style (see record-open-style.ts):
355
+ * - 'records' (default): the record ALWAYS opens as its own tab, assigned
356
+ * to the app the user is standing in (no Home reassignment, no nav flip).
357
+ * Re-opening a record that's already open focuses its existing tab,
358
+ * browser-style. The shell's Records pill + strip are the visible UI.
359
+ * - 'classic': the original Home-first assignment and tab semantics.
360
+ * Both styles capture the source-app trail into the tab configuration for
361
+ * context UI and diagnostics.
353
362
  */
354
363
  OpenEntityRecord(entityName, recordPkey, options) {
355
- const appId = this.getDefaultApplicationId();
356
- const appColor = this.getDefaultAppColor();
357
- let forceNew = this.shouldForceNewTab(options);
364
+ const tabsMode = IsRecordTabsStyle();
365
+ const activeApp = this.appManager.GetActiveApp();
366
+ const appId = tabsMode && activeApp ? activeApp.ID : this.getDefaultApplicationId();
367
+ const appColor = tabsMode && activeApp ? activeApp.GetColor() : this.getDefaultAppColor();
358
368
  const recordId = recordPkey.ToURLSegment();
369
+ // Browser-style dedup: if this exact record is already open in a tab,
370
+ // focus that tab instead of opening a second copy
371
+ if (tabsMode) {
372
+ const existing = this.findOpenRecordTab(entityName, recordId);
373
+ if (existing) {
374
+ // RE-CAPTURE the origin: re-opening means "I came from somewhere
375
+ // NEW" — the crumb must point at where the user is standing for
376
+ // THIS open, not wherever they were when the tab was first created
377
+ // (possibly days ago, possibly under an older origin schema).
378
+ this.refreshSourceContext(existing.id, options);
379
+ // The activation assert applies to RE-opens too — the same-click
380
+ // stale-stomp that motivated it for fresh opens is equally possible
381
+ // here, and without it the symptom is maddening: opening a record
382
+ // works the FIRST time and silently fails every time after.
383
+ this.workspaceManager.SetActiveTab(existing.id);
384
+ this.assertRecordActivation(existing.id);
385
+ return existing.id;
386
+ }
387
+ }
388
+ let forceNew = tabsMode || this.shouldForceNewTab(options);
359
389
  const request = {
360
390
  ApplicationId: appId,
361
391
  Title: `${entityName} - ${recordId}`,
362
392
  Configuration: {
363
- resourceType: 'Records',
393
+ resourceType: RECORDS_RESOURCE_TYPE,
364
394
  Entity: entityName, // Must use 'Entity' (capital E) - expected by record-resource.component
365
- recordId: recordId // Also needed in Configuration for tab-container.component to populate ResourceRecordID
395
+ recordId: recordId, // Also needed in Configuration for tab-container.component to populate ResourceRecordID
396
+ ...this.resolveSourceContext(options)
366
397
  },
367
398
  ResourceRecordId: recordId,
368
- IsPinned: options?.pinTab || false
399
+ IsPinned: options?.pinTab || false,
400
+ // Records style: opening a record must not pin the nav tab (see
401
+ // TabRequest.PreservePinState) — a pinned nav tab forces the main tab
402
+ // bar visible on every nav page.
403
+ PreservePinState: tabsMode
369
404
  };
370
405
  // Handle transition from single-resource mode
371
406
  forceNew = this.handleSingleResourceModeTransition(forceNew, request);
@@ -376,8 +411,269 @@ export class NavigationService {
376
411
  else {
377
412
  tabId = this.workspaceManager.OpenTab(request, appColor);
378
413
  }
414
+ if (tabsMode) {
415
+ this.assertRecordActivation(tabId);
416
+ }
379
417
  return tabId;
380
418
  }
419
+ /**
420
+ * Opening a record means the record WINS the activation race — assert it
421
+ * after the synchronous open flow settles. Surfaces that both emit a
422
+ * record-open AND run their own internal navigation in the same click
423
+ * (e.g. a grid row's select handler updating query params from a config
424
+ * snapshot captured before the open) can otherwise stomp the activation
425
+ * with stale state, leaving the tab open but never shown. Skipped when the
426
+ * tab was closed in the meantime.
427
+ */
428
+ assertRecordActivation(tabId) {
429
+ setTimeout(() => {
430
+ const config = this.workspaceManager.GetConfiguration();
431
+ if (config?.tabs.some(t => t.id === tabId) && config.activeTabId !== tabId) {
432
+ this.workspaceManager.SetActiveTab(tabId);
433
+ }
434
+ }, 0);
435
+ }
436
+ /** Find an already-open tab showing this exact record (records-style dedup) */
437
+ findOpenRecordTab(entityName, recordId) {
438
+ const config = this.workspaceManager.GetConfiguration();
439
+ const match = config?.tabs.find(t => t.resourceRecordId === recordId &&
440
+ t.configuration?.['resourceType'] === RECORDS_RESOURCE_TYPE &&
441
+ t.configuration?.['Entity'] === entityName);
442
+ return match ? { id: match.id } : null;
443
+ }
444
+ /**
445
+ * Overwrite a record tab's origin with a freshly-resolved one (the dedup
446
+ * re-open path). All source keys are explicitly cleared first — a shallow
447
+ * config merge would otherwise let fields from the OLD origin shape leak
448
+ * through (e.g. a parent-record origin's sourceRecordEntity surviving a
449
+ * re-open from a nav page, or pre-schema captures keeping stale labels).
450
+ */
451
+ refreshSourceContext(tabId, options) {
452
+ this.workspaceManager.UpdateTabConfiguration(tabId, {
453
+ sourceAppId: undefined,
454
+ sourceAppName: undefined,
455
+ sourceNavLabel: undefined,
456
+ sourceNavItemName: undefined,
457
+ sourceTabId: undefined,
458
+ sourceLabel: undefined,
459
+ sourceQueryParams: undefined,
460
+ sourceRecordEntity: undefined,
461
+ sourceRecordId: undefined,
462
+ ...this.resolveSourceContext(options)
463
+ });
464
+ }
465
+ /**
466
+ * Resolve the source context to stamp into a record tab's configuration.
467
+ * Callers that are workspace tabs omit options.recordSource and get the
468
+ * active app/tab snapshot; overlay surfaces pass their own context;
469
+ * 'none' means no origin (no crumb).
470
+ */
471
+ resolveSourceContext(options) {
472
+ if (options?.recordSource === 'none') {
473
+ return {};
474
+ }
475
+ if (options?.recordSource) {
476
+ const src = options.recordSource;
477
+ const context = {};
478
+ if (src.sourceAppId)
479
+ context['sourceAppId'] = src.sourceAppId;
480
+ if (src.sourceAppName)
481
+ context['sourceAppName'] = src.sourceAppName;
482
+ if (src.sourceNavLabel)
483
+ context['sourceNavLabel'] = src.sourceNavLabel;
484
+ if (src.sourceTabId)
485
+ context['sourceTabId'] = src.sourceTabId;
486
+ if (src.sourceLabel)
487
+ context['sourceLabel'] = src.sourceLabel;
488
+ if (src.sourceQueryParams && Object.keys(src.sourceQueryParams).length > 0) {
489
+ context['sourceQueryParams'] = src.sourceQueryParams;
490
+ }
491
+ return context;
492
+ }
493
+ return this.captureSourceContext();
494
+ }
495
+ /**
496
+ * Snapshot of where the user was when the record was
497
+ * opened — the source app and (when the active tab belongs to that app and
498
+ * isn't itself a record) the nav page they were on. Rendered by the
499
+ * breadcrumb strip as "App › Page › Record".
500
+ */
501
+ captureSourceContext() {
502
+ const activeApp = this.appManager.GetActiveApp();
503
+ if (!activeApp) {
504
+ return {};
505
+ }
506
+ const context = {
507
+ sourceAppId: activeApp.ID,
508
+ sourceAppName: activeApp.Name
509
+ };
510
+ const config = this.workspaceManager.GetConfiguration();
511
+ const activeTab = config?.tabs.find(t => t.id === config.activeTabId);
512
+ if (!activeTab || !UUIDsEqual(activeTab.applicationId, activeApp.ID) || !activeTab.title) {
513
+ return context;
514
+ }
515
+ if (IsRecordsTabConfiguration(activeTab.configuration)) {
516
+ // Opened from INSIDE another record → the crumb points at the PARENT
517
+ // record (Matt's record→record design call). Identity, not just the
518
+ // tab id, so the return survives tab closure (re-open via dedup).
519
+ const parentEntity = activeTab.configuration?.['Entity'];
520
+ const parentRecordId = activeTab.resourceRecordId || activeTab.configuration?.['recordId'];
521
+ if (typeof parentEntity === 'string' && typeof parentRecordId === 'string' && parentRecordId) {
522
+ context['sourceTabId'] = activeTab.id;
523
+ context['sourceLabel'] = activeTab.title;
524
+ context['sourceRecordEntity'] = parentEntity;
525
+ context['sourceRecordId'] = parentRecordId;
526
+ }
527
+ return context;
528
+ }
529
+ context['sourceNavLabel'] = activeTab.title;
530
+ // Canonical nav identity alongside the display title: titles mutate
531
+ // (Data Explorer renames its tab to the drilled entity), so the return
532
+ // path verifies/resolves by navItemName and shows the label.
533
+ const navItemName = activeTab.configuration?.['navItemName'];
534
+ if (typeof navItemName === 'string' && navItemName.length > 0) {
535
+ context['sourceNavItemName'] = navItemName;
536
+ }
537
+ // The exact tab, not just its label — the origin crumb's return path
538
+ // prefers reactivating this tab (state intact) over re-resolving the
539
+ // nav item by label.
540
+ context['sourceTabId'] = activeTab.id;
541
+ // Snapshot the tab's query params too: dashboards keep their DRILL-IN
542
+ // state there (e.g. Data Explorer's entity section), and it can drift
543
+ // or be stomped between open and return. The crumb restores the page
544
+ // AS IT WAS, not just the tab.
545
+ const queryParams = activeTab.configuration?.['queryParams'];
546
+ if (queryParams && typeof queryParams === 'object' && Object.keys(queryParams).length > 0) {
547
+ context['sourceQueryParams'] = { ...queryParams };
548
+ }
549
+ return context;
550
+ }
551
+ /**
552
+ * Switch to an app's LANDING page — its default nav item in its BASE
553
+ * state (drifted section params cleared, reset force-delivered so the
554
+ * dashboard actually returns to its landing even if its state diverged).
555
+ * The origin crumb's APP segment uses this: classic breadcrumb semantics,
556
+ * each level goes to that level's home.
557
+ */
558
+ async SwitchToAppHome(appId) {
559
+ const app = this.appManager.GetAllApps().find(a => UUIDsEqual(a.ID, appId));
560
+ if (!app) {
561
+ return;
562
+ }
563
+ const navItems = await app.GetNavItems();
564
+ const home = navItems.find(i => i.isDefault) ?? navItems[0];
565
+ await this.SwitchToApp(appId, home?.Label);
566
+ const tabId = this.workspaceManager.GetActiveTabId();
567
+ if (!tabId) {
568
+ return;
569
+ }
570
+ const tab = this.workspaceManager.GetTab(tabId);
571
+ const current = (tab?.configuration?.['queryParams'] || {});
572
+ const clear = {};
573
+ for (const key of Object.keys(current)) {
574
+ clear[key] = null;
575
+ }
576
+ if (Object.keys(clear).length > 0) {
577
+ this.applyQueryParamsToTab(tabId, clear);
578
+ }
579
+ this.NotifyQueryParamsChanged(tabId, {}, true);
580
+ }
581
+ /**
582
+ * Take the user back to where they were when a record was opened (the
583
+ * origin crumb's click path). Fidelity ladder:
584
+ * 1. The exact source tab, if it's still open — reactivate it with all its
585
+ * state (query params, scroll, cached component) intact.
586
+ * 2. Otherwise re-resolve by app + nav label via SwitchToApp, which reuses
587
+ * a matching open tab or opens the nav item fresh.
588
+ * No-ops when the context has no sourceAppId (capture was best-effort).
589
+ */
590
+ async ReturnToRecordSource(origin) {
591
+ // Parent-RECORD origin (opened from inside another record): activate the
592
+ // parent's tab while it still shows that record, else RE-OPEN the parent
593
+ // — OpenEntityRecord's dedup makes the re-open land on (or recreate)
594
+ // exactly that record. No app flip either way: record tabs live in the
595
+ // records region regardless of app.
596
+ if (origin.sourceRecordEntity && origin.sourceRecordId) {
597
+ const config = this.workspaceManager.GetConfiguration();
598
+ const sourceTab = origin.sourceTabId ? config?.tabs.find(t => t.id === origin.sourceTabId) : undefined;
599
+ const stillParent = !!sourceTab &&
600
+ IsRecordsTabConfiguration(sourceTab.configuration) &&
601
+ sourceTab.configuration?.['Entity'] === origin.sourceRecordEntity &&
602
+ sourceTab.resourceRecordId === origin.sourceRecordId;
603
+ if (sourceTab && stillParent) {
604
+ this.workspaceManager.SetActiveTab(sourceTab.id);
605
+ return;
606
+ }
607
+ const parentKey = new CompositeKey();
608
+ parentKey.SimpleLoadFromURLSegment(origin.sourceRecordId);
609
+ this.OpenEntityRecord(origin.sourceRecordEntity, parentKey);
610
+ return;
611
+ }
612
+ if (!origin.sourceAppId) {
613
+ return;
614
+ }
615
+ if (origin.sourceTabId) {
616
+ const config = this.workspaceManager.GetConfiguration();
617
+ const sourceTab = config?.tabs.find(t => t.id === origin.sourceTabId);
618
+ // Tab ids are NOT stable identities for nav pages: the temp-tab
619
+ // replacement model (WorkspaceStateManager.OpenTab) reuses the same
620
+ // tab id when the user navigates elsewhere — the captured id can now
621
+ // hold a DIFFERENT page in a DIFFERENT app. Trust it only while the
622
+ // tab still shows the captured page; otherwise fall through to
623
+ // re-resolving by app + nav item below. Prefer navItemName for the
624
+ // page check — TITLES MUTATE (Data Explorer renames its tab to the
625
+ // drilled entity), and a mutated title must not disqualify the tab.
626
+ const tabNavItemName = sourceTab?.configuration?.['navItemName'];
627
+ const samePage = origin.sourceNavItemName && typeof tabNavItemName === 'string'
628
+ ? tabNavItemName === origin.sourceNavItemName
629
+ : (!origin.sourceNavLabel || sourceTab?.title === origin.sourceNavLabel);
630
+ const stillSourcePage = !!sourceTab &&
631
+ (!origin.sourceAppId || UUIDsEqual(sourceTab.applicationId, origin.sourceAppId)) &&
632
+ samePage;
633
+ if (sourceTab && stillSourcePage) {
634
+ // Restore the tab's query params to the capture-time snapshot BEFORE
635
+ // activating, so a cached resource component reattaches against the
636
+ // right config (see SwitchToApp's queryParams doc). REPLACE
637
+ // semantics: null out params the tab has gained since capture, then
638
+ // lay the snapshot over — "back" means the page as it was, and
639
+ // drill-in state (e.g. Data Explorer's entity section) lives here.
640
+ const currentParams = (sourceTab.configuration?.['queryParams'] || {});
641
+ const restore = {};
642
+ for (const key of Object.keys(currentParams)) {
643
+ restore[key] = null;
644
+ }
645
+ Object.assign(restore, origin.sourceQueryParams ?? {});
646
+ if (Object.keys(restore).length > 0) {
647
+ this.applyQueryParamsToTab(sourceTab.id, restore);
648
+ }
649
+ // FORCE delivery: the component's state may have drifted while its
650
+ // last-delivered params already equal the snapshot — the reactive
651
+ // path would drop the restore as a duplicate (crumb lands on the
652
+ // dashboard's home instead of the captured section).
653
+ if (origin.sourceQueryParams && Object.keys(origin.sourceQueryParams).length > 0) {
654
+ this.NotifyQueryParamsChanged(sourceTab.id, origin.sourceQueryParams, true);
655
+ }
656
+ // Set the app context first so the shell's nav renders the right
657
+ // app the moment the tab activates (activation alone would also
658
+ // flip it, but explicit sequencing avoids a visible two-step).
659
+ await this.appManager.SetActiveApp(sourceTab.applicationId);
660
+ this.workspaceManager.SetActiveTab(sourceTab.id);
661
+ return;
662
+ }
663
+ }
664
+ // Re-resolve by the CANONICAL nav-item name when we have it — the label
665
+ // is a display title that may have mutated into something SwitchToApp's
666
+ // nav-item lookup can't match (e.g. a drilled entity name).
667
+ await this.SwitchToApp(origin.sourceAppId, origin.sourceNavItemName ?? origin.sourceNavLabel, origin.sourceQueryParams);
668
+ // Same drift-proofing as the exact-tab path: force the delivery so the
669
+ // restore can't be deduped away.
670
+ if (origin.sourceQueryParams && Object.keys(origin.sourceQueryParams).length > 0) {
671
+ const targetTabId = this.workspaceManager.GetActiveTabId();
672
+ if (targetTabId) {
673
+ this.NotifyQueryParamsChanged(targetTabId, origin.sourceQueryParams, true);
674
+ }
675
+ }
676
+ }
381
677
  /**
382
678
  * Open a view
383
679
  * Uses Home app if available, otherwise falls back to active app or system app
@@ -434,34 +730,6 @@ export class NavigationService {
434
730
  return this.workspaceManager.OpenTab(request, appColor);
435
731
  }
436
732
  }
437
- /**
438
- * Open a report
439
- * Uses Home app if available, otherwise falls back to active app or system app
440
- */
441
- OpenReport(reportId, reportName, options) {
442
- const appId = this.getDefaultApplicationId();
443
- const appColor = this.getDefaultAppColor();
444
- let forceNew = this.shouldForceNewTab(options);
445
- const request = {
446
- ApplicationId: appId,
447
- Title: reportName,
448
- Configuration: {
449
- resourceType: 'Reports',
450
- reportId,
451
- recordId: reportId // Also needed in Configuration for tab-container.component to populate ResourceRecordID
452
- },
453
- ResourceRecordId: reportId,
454
- IsPinned: options?.pinTab || false
455
- };
456
- // Handle transition from single-resource mode
457
- forceNew = this.handleSingleResourceModeTransition(forceNew, request);
458
- if (forceNew) {
459
- return this.workspaceManager.OpenTabForced(request, appColor);
460
- }
461
- else {
462
- return this.workspaceManager.OpenTab(request, appColor);
463
- }
464
- }
465
733
  /**
466
734
  * Open an artifact
467
735
  * Artifacts are versioned content containers (reports, dashboards, UI components, etc.)
@@ -558,30 +826,45 @@ export class NavigationService {
558
826
  * @param options Navigation options including optional newRecordValues for pre-populating fields
559
827
  */
560
828
  OpenNewEntityRecord(entityName, options) {
561
- const appId = this.getDefaultApplicationId();
562
- const appColor = this.getDefaultAppColor();
563
- let forceNew = this.shouldForceNewTab(options);
829
+ // Records style: new-record tabs are RECORD tabs and get the same
830
+ // treatment as OpenEntityRecord — assigned to the ACTIVE app, always a
831
+ // new tab, nav pins preserved, origin captured. Without the forceNew,
832
+ // the non-forced OpenTab consumed the user's nav page as the
833
+ // replaceable temp tab and CONVERTED it into a records-region tab —
834
+ // silently destroying the page they were on.
835
+ const tabsMode = IsRecordTabsStyle();
836
+ const activeApp = this.appManager.GetActiveApp();
837
+ const appId = tabsMode && activeApp ? activeApp.ID : this.getDefaultApplicationId();
838
+ const appColor = tabsMode && activeApp ? activeApp.GetColor() : this.getDefaultAppColor();
839
+ let forceNew = tabsMode || this.shouldForceNewTab(options);
564
840
  const request = {
565
841
  ApplicationId: appId,
566
842
  Title: `New ${entityName}`,
567
843
  Configuration: {
568
- resourceType: 'Records',
844
+ resourceType: RECORDS_RESOURCE_TYPE,
569
845
  Entity: entityName, // Must use 'Entity' (capital E) - expected by record-resource.component
570
846
  recordId: '', // Empty recordId indicates new record
571
847
  isNew: true, // Flag to indicate this is a new record
572
- NewRecordValues: options?.newRecordValues // Pass through initial values if provided
848
+ NewRecordValues: options?.newRecordValues, // Pass through initial values if provided
849
+ ...this.resolveSourceContext(options)
573
850
  },
574
851
  ResourceRecordId: '', // Empty for new records
575
- IsPinned: options?.pinTab || false
852
+ IsPinned: options?.pinTab || false,
853
+ PreservePinState: tabsMode
576
854
  };
577
855
  // Handle transition from single-resource mode
578
856
  forceNew = this.handleSingleResourceModeTransition(forceNew, request);
857
+ let tabId;
579
858
  if (forceNew) {
580
- return this.workspaceManager.OpenTabForced(request, appColor);
859
+ tabId = this.workspaceManager.OpenTabForced(request, appColor);
581
860
  }
582
861
  else {
583
- return this.workspaceManager.OpenTab(request, appColor);
862
+ tabId = this.workspaceManager.OpenTab(request, appColor);
584
863
  }
864
+ if (tabsMode) {
865
+ this.assertRecordActivation(tabId);
866
+ }
867
+ return tabId;
585
868
  }
586
869
  /**
587
870
  * Open a universal search results tab for the given query.
@@ -680,15 +963,38 @@ export class NavigationService {
680
963
  if (!app) {
681
964
  return;
682
965
  }
683
- const appTabs = this.workspaceManager.GetAppTabs(appId);
966
+ // Records style: record tabs are ASSIGNED to apps but live in their own
967
+ // region — switching to an app must surface its NAV pages, so records
968
+ // don't count here. Without this filter, the active record tab (which
969
+ // belongs to the target app) satisfied the "already on this app's tab"
970
+ // check and the whole switch silently no-oped — the dead origin-crumb
971
+ // click when the crumb only knew the app. Records DOCKED to the
972
+ // workspace are excluded too (identity check, not region membership):
973
+ // switch-to-app must land on a page, never a record. Classic style
974
+ // keeps records as ordinary tabs.
975
+ const appTabs = IsRecordTabsStyle()
976
+ ? this.workspaceManager.GetAppTabs(appId).filter(t => !IsRecordsTabConfiguration(t.configuration))
977
+ : this.workspaceManager.GetAppTabs(appId);
684
978
  // If a specific nav item is requested
685
979
  if (navItemName) {
686
980
  const navItems = await app.GetNavItems();
687
981
  const navItem = navItems.find(item => item.Label === navItemName);
688
982
  if (navItem) {
689
- // Check if there's already a tab for this nav item
690
- const existingTab = appTabs.find(tab => tab.title === navItem.Label ||
691
- (tab.configuration?.['route'] === navItem.Route && navItem.Route));
983
+ // Check if there's already a tab for this nav item. Identity check
984
+ // uses configuration.navItemName when the tab carries it — TITLES
985
+ // LIE: same-driver nav switches reuse the live tab and can leave a
986
+ // stale title behind (e.g. Data Explorer's tab titled "Data" while
987
+ // its content and navItemName say "Dashboards"). Matching that
988
+ // chimera by title activated the WRONG page instead of reopening
989
+ // the requested nav item.
990
+ const existingTab = appTabs.find(tab => {
991
+ const tabNav = tab.configuration?.['navItemName'];
992
+ if (typeof tabNav === 'string' && tabNav.length > 0) {
993
+ return tabNav === navItem.Label;
994
+ }
995
+ return tab.title === navItem.Label ||
996
+ (tab.configuration?.['route'] === navItem.Route && !!navItem.Route);
997
+ });
692
998
  if (existingTab) {
693
999
  // Switch to existing tab
694
1000
  this.workspaceManager.SetActiveTab(existingTab.id);
@@ -795,8 +1101,8 @@ export class NavigationService {
795
1101
  * Called by the shell when back/forward navigation changes query params on the active tab.
796
1102
  * The notification includes the tab ID so only the component in that tab reacts.
797
1103
  */
798
- NotifyQueryParamsChanged(tabId, params) {
799
- this.queryParamChanged$.next({ TabId: tabId, Params: params });
1104
+ NotifyQueryParamsChanged(tabId, params, force = false) {
1105
+ this.queryParamChanged$.next({ TabId: tabId, Params: params, Force: force });
800
1106
  }
801
1107
  /**
802
1108
  * Reactively observe the query params for a specific tab.