@memberjunction/ng-explorer-core 6.2.0-edge.5 → 6.2.0-edge.6

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 (52) hide show
  1. package/README.md +7 -2
  2. package/dist/app-routing.module.d.ts.map +1 -1
  3. package/dist/app-routing.module.js +12 -4
  4. package/dist/app-routing.module.js.map +1 -1
  5. package/dist/generated/lazy-feature-config.d.ts +1 -1
  6. package/dist/generated/lazy-feature-config.d.ts.map +1 -1
  7. package/dist/generated/lazy-feature-config.js +4 -2
  8. package/dist/generated/lazy-feature-config.js.map +1 -1
  9. package/dist/lib/resource-wrappers/record-form-mode.d.ts +44 -0
  10. package/dist/lib/resource-wrappers/record-form-mode.d.ts.map +1 -0
  11. package/dist/lib/resource-wrappers/record-form-mode.js +47 -0
  12. package/dist/lib/resource-wrappers/record-form-mode.js.map +1 -0
  13. package/dist/lib/resource-wrappers/record-resource.component.d.ts +56 -1
  14. package/dist/lib/resource-wrappers/record-resource.component.d.ts.map +1 -1
  15. package/dist/lib/resource-wrappers/record-resource.component.js +124 -9
  16. package/dist/lib/resource-wrappers/record-resource.component.js.map +1 -1
  17. package/dist/lib/resource-wrappers/standard-form-create.d.ts +4 -0
  18. package/dist/lib/resource-wrappers/standard-form-create.d.ts.map +1 -0
  19. package/dist/lib/resource-wrappers/standard-form-create.js +6 -0
  20. package/dist/lib/resource-wrappers/standard-form-create.js.map +1 -0
  21. package/dist/lib/resource-wrappers/view-resource.component.d.ts +18 -1
  22. package/dist/lib/resource-wrappers/view-resource.component.d.ts.map +1 -1
  23. package/dist/lib/resource-wrappers/view-resource.component.js +105 -39
  24. package/dist/lib/resource-wrappers/view-resource.component.js.map +1 -1
  25. package/dist/lib/shell/components/record-open/record-origin-crumb.component.d.ts.map +1 -1
  26. package/dist/lib/shell/components/record-open/record-origin-crumb.component.js +12 -8
  27. package/dist/lib/shell/components/record-open/record-origin-crumb.component.js.map +1 -1
  28. package/dist/lib/shell/components/tabs/component-cache-manager.d.ts +6 -0
  29. package/dist/lib/shell/components/tabs/component-cache-manager.d.ts.map +1 -1
  30. package/dist/lib/shell/components/tabs/component-cache-manager.js +18 -3
  31. package/dist/lib/shell/components/tabs/component-cache-manager.js.map +1 -1
  32. package/dist/lib/shell/components/tabs/tab-container.component.d.ts +2 -0
  33. package/dist/lib/shell/components/tabs/tab-container.component.d.ts.map +1 -1
  34. package/dist/lib/shell/components/tabs/tab-container.component.js +38 -10
  35. package/dist/lib/shell/components/tabs/tab-container.component.js.map +1 -1
  36. package/dist/lib/shell/shell-chrome-policy.d.ts +6 -0
  37. package/dist/lib/shell/shell-chrome-policy.d.ts.map +1 -1
  38. package/dist/lib/shell/shell-chrome-policy.js +1 -0
  39. package/dist/lib/shell/shell-chrome-policy.js.map +1 -1
  40. package/dist/lib/shell/shell.component.d.ts +23 -0
  41. package/dist/lib/shell/shell.component.d.ts.map +1 -1
  42. package/dist/lib/shell/shell.component.js +283 -71
  43. package/dist/lib/shell/shell.component.js.map +1 -1
  44. package/dist/lib/single-record/single-record.component.d.ts +27 -2
  45. package/dist/lib/single-record/single-record.component.d.ts.map +1 -1
  46. package/dist/lib/single-record/single-record.component.js +54 -8
  47. package/dist/lib/single-record/single-record.component.js.map +1 -1
  48. package/dist/lib/user-notifications/user-notifications.component.d.ts +10 -2
  49. package/dist/lib/user-notifications/user-notifications.component.d.ts.map +1 -1
  50. package/dist/lib/user-notifications/user-notifications.component.js +77 -22
  51. package/dist/lib/user-notifications/user-notifications.component.js.map +1 -1
  52. package/package.json +57 -57
@@ -1,11 +1,11 @@
1
1
  import { Component, ViewChild, HostListener, inject } from '@angular/core';
2
2
  import { AngularAdapterService } from '@memberjunction/ng-react';
3
- import { NavigationEnd } from '@angular/router';
3
+ import { NavigationEnd, NavigationError, NavigationCancel } from '@angular/router';
4
4
  import { combineLatest, Subject } from 'rxjs';
5
5
  import { filter, takeUntil } from 'rxjs/operators';
6
6
  import { LogStatus, LogError, StartupManager, CompositeKey, EncodeNewRecordValuesForURL, IsNewEntityRecordUrlId, NEW_ENTITY_RECORD_URL_ID, NEW_RECORD_VALUES_QUERY_PARAM, RecordUrlMatchesTab, ResourceUrlsEquivalent } from '@memberjunction/core';
7
7
  import { MJEventType, MJGlobal, uuidv4, UUIDsEqual } from '@memberjunction/global';
8
- import { EventCodes, SharedService, SYSTEM_APP_ID, SetRecordOpenStyle, IsRecordsRegionTab, IsRecordsTabConfiguration, NormalizeRecordIdForMatch } from '@memberjunction/ng-shared';
8
+ import { EventCodes, SharedService, SYSTEM_APP_ID, SetRecordOpenStyle, IsRecordsRegionTab, IsRecordsTabConfiguration, NormalizeRecordIdForMatch, FormatEntityRecordDisplayTitle, ResolveRecordTabDisplayTitle, FORM_MODE_QUERY_PARAM, ReadStandardFormQuery } from '@memberjunction/ng-shared';
9
9
  import { MJNotificationService } from '@memberjunction/ng-notifications';
10
10
  import { IsOmnibarAvailable, IsOmnibarEnabledForUser, OMNIBAR_PROMO_DISMISSED_KEY, OMNIBAR_USER_SETTING_KEY } from '../omnibar/omnibar-user-setting';
11
11
  import { GetOmnibarShortcutLabel } from '../omnibar/omnibar-shortcut';
@@ -928,6 +928,9 @@ export class ShellComponent extends BaseAngularComponent {
928
928
  // to 'auto' (compact under a handful of apps, launcher otherwise)
929
929
  appSwitcherStyle: rawStyle === 'launcher' || rawStyle === 'compact' ? rawStyle : 'auto',
930
930
  appNav: engine.GetBoolean('Shell.AppNav.Enabled', true),
931
+ // The same key explorer-app read directly before the overlay went through the policy;
932
+ // explorer-app now reads the resolved bit from ChromeFlags.
933
+ chatOverlay: engine.GetBoolean('Shell.ChatOverlay.Enabled', true),
931
934
  // Resolved (and pushed to collaborators) in resolveRecordOpenStyle()
932
935
  // during initializeShell — NEVER as a getter side effect; getters
933
936
  // run at change detection's whim and startup correctness must not
@@ -940,6 +943,16 @@ export class ShellComponent extends BaseAngularComponent {
940
943
  }
941
944
  return flags;
942
945
  }
946
+ /**
947
+ * The resolved chrome, for collaborators outside the shell. explorer-app reads `chatOverlay`
948
+ * from here (through its view query on this component) so the overlay follows the host's
949
+ * chrome policy with ONE policy instance and ONE `Changed`; the memoization above makes a
950
+ * read from a parent's change detection a field access. Read-only on purpose: this IS the
951
+ * memo, and a consumer writing to it would poison the shell's own chrome.
952
+ */
953
+ get ChromeFlags() {
954
+ return this.chromeFlags;
955
+ }
943
956
  /** The policy's answer may have changed: forget the cached flags and repaint. */
944
957
  onChromePolicyChanged() {
945
958
  this._chromeFlags = null;
@@ -1259,18 +1272,33 @@ export class ShellComponent extends BaseAngularComponent {
1259
1272
  }
1260
1273
  async ngOnInit() {
1261
1274
  try {
1275
+ let loggedIn = false;
1276
+ let shellInitialized = false;
1277
+ const initShellOnce = async () => {
1278
+ if (shellInitialized) {
1279
+ return;
1280
+ }
1281
+ shellInitialized = true;
1282
+ this.initialNavigationComplete = true;
1283
+ await this.InitializeShell();
1284
+ };
1285
+ // Subscribe to router settlement events (NavigationEnd, NavigationError on unmatched routes,
1286
+ // or NavigationCancel) before LoggedIn fires so we never miss a navigation that settled early
1287
+ // or hang forever when a bad URL triggers NavigationError and resets to '/'.
1288
+ this.subscriptions.push(this.router.events.pipe(filter((event) => event instanceof NavigationEnd ||
1289
+ event instanceof NavigationError ||
1290
+ event instanceof NavigationCancel), filter(() => !this.initialNavigationComplete)).subscribe(async () => {
1291
+ this.initialNavigationComplete = true;
1292
+ if (loggedIn) {
1293
+ await initShellOnce();
1294
+ }
1295
+ }));
1262
1296
  MJGlobal.Instance.GetEventListener(true).subscribe(async (loginEvent) => {
1263
1297
  if (loginEvent.event === MJEventType.LoggedIn) {
1264
- if (this.authBase.initialPath === "/") {
1265
- // Base route - no need to wait for NavigationEnd
1266
- await this.InitializeShell();
1267
- }
1268
- else {
1269
- // Deep link route - wait for NavigationEnd to ensure router URL is correct
1270
- this.router.events.pipe(filter((event) => event instanceof NavigationEnd), filter(() => !this.initialNavigationComplete)).subscribe(async () => {
1271
- this.initialNavigationComplete = true;
1272
- await this.InitializeShell();
1273
- });
1298
+ loggedIn = true;
1299
+ if (this.authBase.initialPath === "/" || this.initialNavigationComplete) {
1300
+ // Base route or initial router navigation already settled
1301
+ await initShellOnce();
1274
1302
  }
1275
1303
  }
1276
1304
  });
@@ -1406,23 +1434,31 @@ export class ShellComponent extends BaseAngularComponent {
1406
1434
  // already on the scoped app — fall through to normal handling below
1407
1435
  }
1408
1436
  }
1409
- // Check if URL specifies an app by parsing the browser URL
1437
+ // Check if URL specifies an app by parsing the browser URL (strip query string and fragment first)
1410
1438
  const currentUrl = this.router.url;
1411
- const appMatch = currentUrl.match(/\/app\/([^\/]+)/);
1439
+ const currentUrlPath = (currentUrl || '').split('#')[0].split('?')[0];
1440
+ const appMatch = currentUrlPath.match(/\/app\/([^\/?#]+)/);
1412
1441
  if (appMatch) {
1413
1442
  const routeAppPath = decodeURIComponent(appMatch[1]);
1414
1443
  // Find the app from the URL by Path (or Name for backwards compatibility)
1415
- const urlApp = this.appManager.GetAppByPath(routeAppPath);
1444
+ const urlApp = this.appManager.GetAppByPath(routeAppPath) || this.appManager.GetAppByName(routeAppPath);
1416
1445
  if (urlApp) {
1417
1446
  // Set the app from URL - takes precedence over workspace restoration
1418
1447
  await this.appManager.SetActiveApp(urlApp.ID);
1419
1448
  // If the URL is just /app/:appName (no nav item), create default tab
1420
- const hasNavItem = currentUrl.match(/\/app\/[^\/]+\/[^\/]+/);
1449
+ const hasNavItem = currentUrlPath.match(/\/app\/[^\/]+\/[^\/]+/);
1421
1450
  if (!hasNavItem) {
1422
1451
  const existingTabs = this.workspaceManager.GetAppTabs(urlApp.ID);
1423
1452
  if (existingTabs.length === 0) {
1424
1453
  const tabRequest = await urlApp.CreateDefaultTab();
1425
1454
  if (tabRequest) {
1455
+ const urlParams = this.extractQueryParamsFromUrl(currentUrl);
1456
+ if (Object.keys(urlParams).length > 0) {
1457
+ tabRequest.Configuration = {
1458
+ ...(tabRequest.Configuration || {}),
1459
+ queryParams: urlParams
1460
+ };
1461
+ }
1426
1462
  this.tabService.OpenTab(tabRequest);
1427
1463
  }
1428
1464
  }
@@ -1545,17 +1581,15 @@ export class ShellComponent extends BaseAngularComponent {
1545
1581
  // Only restore workspace state when the URL is bare root (/) or empty.
1546
1582
  const initialUrl = this.router.url.split('?')[0]; // ignore query params for this check
1547
1583
  const isDeepLink = initialUrl.length > 1 && initialUrl !== '/';
1548
- const initConfig = this.workspaceManager.GetConfiguration();
1584
+ let deepLinkResolved = false;
1549
1585
  if (isDeepLink) {
1550
1586
  // User navigated to a specific URL — sync workspace to match the URL,
1551
1587
  // not the other way around. The ResourceResolver will have created a tab
1552
1588
  // for this URL; we just need to activate it.
1553
- await this.syncWorkspaceWithUrl(this.router.url);
1589
+ deepLinkResolved = await this.syncWorkspaceWithUrl(this.router.url);
1554
1590
  }
1555
- else if (initConfig && initConfig.activeTabId) {
1556
- // Bare root URL — restore last workspace state
1557
- await this.syncActiveAppWithTab(initConfig);
1558
- this.syncUrlWithWorkspace(initConfig);
1591
+ if (!deepLinkResolved && !this.pendingAppPath) {
1592
+ await this.restoreOrOpenFallbackTab();
1559
1593
  }
1560
1594
  // Force change detection to sync Angular's expected values after all async
1561
1595
  // state changes (apps loaded, searchableEntities populated, etc.) to prevent
@@ -1656,10 +1690,11 @@ export class ShellComponent extends BaseAngularComponent {
1656
1690
  this.titleService.reset();
1657
1691
  return;
1658
1692
  }
1693
+ const resolvedTabTitle = ResolveRecordTabDisplayTitle(activeTab, this.ProviderToUse);
1659
1694
  // Get the tab's application ID
1660
1695
  const tabAppId = activeTab.applicationId;
1661
1696
  if (!tabAppId) {
1662
- this.titleService.setResourceName(activeTab.title || null);
1697
+ this.titleService.setResourceName(resolvedTabTitle);
1663
1698
  return;
1664
1699
  }
1665
1700
  // Check if this is a system tab (not associated with a registered app)
@@ -1668,7 +1703,7 @@ export class ShellComponent extends BaseAngularComponent {
1668
1703
  this.cdr.detectChanges();
1669
1704
  // Don't try to set active app - SYSTEM_APP_ID has no registered app
1670
1705
  // Update browser title with just the tab title (no app context)
1671
- this.titleService.setContext(null, activeTab.title || 'Explorer');
1706
+ this.titleService.setContext(null, resolvedTabTitle || 'Explorer');
1672
1707
  return;
1673
1708
  }
1674
1709
  // Not a system tab - clear the flag
@@ -1682,7 +1717,7 @@ export class ShellComponent extends BaseAngularComponent {
1682
1717
  // stays wherever the user is. Records DOCKED to the workspace fall
1683
1718
  // through: they are ordinary main-layout tabs and DO flip app context.
1684
1719
  if (this.RecordTabsStyle && IsRecordsRegionTab(activeTab.configuration)) {
1685
- this.titleService.setContext(this.ActiveApp?.Name || null, activeTab.title || null);
1720
+ this.titleService.setContext(this.ActiveApp?.Name || null, resolvedTabTitle);
1686
1721
  return;
1687
1722
  }
1688
1723
  // Check if active app needs to be updated
@@ -1696,7 +1731,7 @@ export class ShellComponent extends BaseAngularComponent {
1696
1731
  // (which can be stale when switching between apps in single-resource mode).
1697
1732
  const app = this.appManager.GetAppById(tabAppId);
1698
1733
  const appName = app?.Name || null;
1699
- const tabTitle = activeTab.configuration?.['navItemName'] || activeTab.title || null;
1734
+ const tabTitle = activeTab.configuration?.['navItemName'] || resolvedTabTitle || null;
1700
1735
  this.titleService.setContext(appName, tabTitle);
1701
1736
  }
1702
1737
  /**
@@ -1774,7 +1809,7 @@ export class ShellComponent extends BaseAngularComponent {
1774
1809
  async syncWorkspaceWithUrl(url, navigatedAt = this.lastNavigationAt) {
1775
1810
  const config = this.workspaceManager.GetConfiguration();
1776
1811
  if (!config?.tabs) {
1777
- return;
1812
+ return false;
1778
1813
  }
1779
1814
  // Find the tab that matches this URL (null when workspace has no tabs yet,
1780
1815
  // which falls through to handleMissingTabForUrl below so deep links still open).
@@ -1821,16 +1856,37 @@ export class ShellComponent extends BaseAngularComponent {
1821
1856
  // stays because it says what the comparison relies on, but removing it changes no behaviour and
1822
1857
  // breaks no test — do not read it as the thing that handles NaN.
1823
1858
  if (Number.isFinite(activatedAt) && activatedAt > navigatedAt && activatedAt <= Date.now()) {
1824
- return;
1859
+ return true;
1860
+ }
1861
+ // If the URL carries query params that differ from the matching tab's config, sync them
1862
+ // before activating so syncUrlWithWorkspace doesn't overwrite the URL with stale params.
1863
+ // When the URL is a bare app-level URL (/app/:appName) with no query params, preserve the
1864
+ // default tab's saved queryParams state (matching ApplyDeepLinkToRestoredWorkspace).
1865
+ const urlParams = this.extractQueryParamsFromUrl(url);
1866
+ const isBareAppOnlyUrl = /^\/app\/[^\/]+$/.test(url.split('?')[0]) && Object.keys(urlParams).length === 0;
1867
+ const tabParams = (matchingTab.configuration?.['queryParams'] || {});
1868
+ if (!isBareAppOnlyUrl && !this.queryParamsEqual(urlParams, tabParams) && typeof this.workspaceManager.UpdateTabConfiguration === 'function') {
1869
+ this.urlBasedNavigation = true;
1870
+ try {
1871
+ this.workspaceManager.UpdateTabConfiguration(matchingTab.id, {
1872
+ queryParams: Object.keys(urlParams).length > 0 ? urlParams : undefined
1873
+ });
1874
+ this.navigationService?.NotifyQueryParamsChanged?.(matchingTab.id, urlParams);
1875
+ }
1876
+ finally {
1877
+ this.urlBasedNavigation = false;
1878
+ }
1825
1879
  }
1826
1880
  // Activate the matching tab
1827
1881
  this.workspaceManager.SetActiveTab(matchingTab.id);
1882
+ return true;
1828
1883
  }
1829
1884
  else if (matchingTab && matchingTab.id === config.activeTabId) {
1830
1885
  // Same tab is already active, but query params may have changed (back/forward within nav item)
1831
1886
  const urlParams = this.extractQueryParamsFromUrl(url);
1887
+ const isBareAppOnlyUrl = /^\/app\/[^\/]+$/.test(url.split('?')[0]) && Object.keys(urlParams).length === 0;
1832
1888
  const tabParams = (matchingTab.configuration?.['queryParams'] || {});
1833
- if (!this.queryParamsEqual(urlParams, tabParams)) {
1889
+ if (!isBareAppOnlyUrl && !this.queryParamsEqual(urlParams, tabParams)) {
1834
1890
  // URL is source of truth during back/forward — update tab config to match.
1835
1891
  this.urlBasedNavigation = true;
1836
1892
  try {
@@ -1843,9 +1899,60 @@ export class ShellComponent extends BaseAngularComponent {
1843
1899
  this.urlBasedNavigation = false;
1844
1900
  }
1845
1901
  }
1902
+ return true;
1846
1903
  }
1847
1904
  else if (!matchingTab) {
1848
- await this.handleMissingTabForUrl(url);
1905
+ const handled = await this.handleMissingTabForUrl(url);
1906
+ return handled !== false;
1907
+ }
1908
+ return false;
1909
+ }
1910
+ /**
1911
+ * Restores the active workspace tab (syncing the active app and URL) or, when no active tab
1912
+ * exists (such as a fresh workspace after an invalid deep link suppressed default tab creation),
1913
+ * opens a default tab on the active or landing application so the loading screen always clears.
1914
+ */
1915
+ async restoreOrOpenFallbackTab() {
1916
+ const config = this.workspaceManager.GetConfiguration();
1917
+ const activeTab = config?.activeTabId
1918
+ ? config.tabs?.find(tab => tab.id === config.activeTabId)
1919
+ : undefined;
1920
+ if (config && activeTab) {
1921
+ await this.syncActiveAppWithTab(config);
1922
+ await this.syncUrlWithWorkspace(config);
1923
+ return;
1924
+ }
1925
+ if (config?.tabs && config.tabs.length > 0) {
1926
+ const fallbackTab = config.tabs[0];
1927
+ this.workspaceManager.SetActiveTab(fallbackTab.id);
1928
+ const updatedConfig = this.workspaceManager.GetConfiguration() ?? { ...config, activeTabId: fallbackTab.id };
1929
+ await this.syncActiveAppWithTab(updatedConfig);
1930
+ await this.syncUrlWithWorkspace(updatedConfig);
1931
+ return;
1932
+ }
1933
+ let activeApp = this.appManager.GetActiveApp();
1934
+ if (!activeApp) {
1935
+ const allApps = this.appManager.GetAllApps?.() ?? [];
1936
+ if (allApps.length > 0) {
1937
+ await this.openLandingApp(allApps);
1938
+ activeApp = this.appManager.GetActiveApp();
1939
+ }
1940
+ }
1941
+ if (activeApp) {
1942
+ const existingAppTabs = this.workspaceManager.GetAppTabs?.(activeApp.ID) ?? [];
1943
+ if (existingAppTabs.length === 0) {
1944
+ const defaultTabRequest = await activeApp.CreateDefaultTab();
1945
+ if (defaultTabRequest) {
1946
+ this.tabService.OpenTab(defaultTabRequest);
1947
+ }
1948
+ }
1949
+ const latestConfig = this.workspaceManager.GetConfiguration();
1950
+ if (latestConfig?.activeTabId) {
1951
+ await this.syncUrlWithWorkspace(latestConfig);
1952
+ }
1953
+ else {
1954
+ this.router.navigateByUrl(this.appManager.GetAppUrl(activeApp), { replaceUrl: true });
1955
+ }
1849
1956
  }
1850
1957
  }
1851
1958
  /**
@@ -1858,9 +1965,16 @@ export class ShellComponent extends BaseAngularComponent {
1858
1965
  if (queryIndex === -1)
1859
1966
  return {};
1860
1967
  const params = new URLSearchParams(cleanUrl.substring(queryIndex + 1));
1861
- const result = {};
1862
- params.forEach((value, key) => { result[key] = value; });
1863
- return result;
1968
+ return this.urlSearchParamsToRecord(params);
1969
+ }
1970
+ urlSearchParamsToRecord(params) {
1971
+ const safeEntries = [];
1972
+ params.forEach((value, key) => {
1973
+ if (key !== '__proto__' && key !== 'constructor' && key !== 'prototype') {
1974
+ safeEntries.push([key, value]);
1975
+ }
1976
+ });
1977
+ return Object.fromEntries(safeEntries);
1864
1978
  }
1865
1979
  /**
1866
1980
  * Compare two query param records for equality, normalizing encoding differences
@@ -1877,6 +1991,7 @@ export class ShellComponent extends BaseAngularComponent {
1877
1991
  /**
1878
1992
  * Handle the case where no tab matches the URL during back/forward navigation.
1879
1993
  * Creates new tabs for resources when the original tab was replaced.
1994
+ * Returns true when a valid resource/tab was opened, or false when the URL did not resolve.
1880
1995
  */
1881
1996
  async handleMissingTabForUrl(url) {
1882
1997
  const urlPath = url.split('?')[0];
@@ -1890,23 +2005,26 @@ export class ShellComponent extends BaseAngularComponent {
1890
2005
  if (appRecordMatch) {
1891
2006
  const entityName = decodeURIComponent(appRecordMatch[2]);
1892
2007
  const recordId = decodeURIComponent(appRecordMatch[3]);
1893
- this.openRecordFromUrl(entityName, recordId, queryParams);
1894
- return;
2008
+ return this.openRecordFromUrl(entityName, recordId, queryParams);
1895
2009
  }
1896
2010
  // Check for app-scoped view URL: /app/:appName/view/:viewId
1897
2011
  const appViewMatch = urlPath.match(/^\/app\/([^\/]+)\/view\/([^\/]+)$/);
1898
2012
  if (appViewMatch && appViewMatch[2] !== 'dynamic') {
1899
2013
  const viewId = appViewMatch[2];
1900
2014
  this.navigationService.OpenView(viewId, 'View');
1901
- return;
2015
+ return true;
1902
2016
  }
1903
2017
  // Check for app-scoped dynamic view URL: /app/:appName/view/dynamic/:entityName
1904
2018
  const appDynamicViewMatch = urlPath.match(/^\/app\/([^\/]+)\/view\/dynamic\/(.+)$/);
1905
2019
  if (appDynamicViewMatch) {
1906
2020
  const entityName = decodeURIComponent(appDynamicViewMatch[2]);
1907
- const extraFilter = queryParams.get('ExtraFilter') || undefined;
1908
- this.navigationService.OpenDynamicView(entityName, extraFilter);
1909
- return;
2021
+ if (!this.isKnownEntity(entityName)) {
2022
+ return false;
2023
+ }
2024
+ const extraFilter = queryParams.get('ExtraFilter') || queryParams.get('extraFilter') || undefined;
2025
+ const alternateBaseView = queryParams.get('AlternateBaseView') || queryParams.get('alternateBaseView') || queryParams.get('abv') || undefined;
2026
+ this.navigationService.OpenDynamicView(entityName, extraFilter, alternateBaseView ? { alternateBaseView } : undefined);
2027
+ return true;
1910
2028
  }
1911
2029
  // Check for app-scoped dashboard URL: /app/:appName/dashboard/:dashboardId. The tab goes to
1912
2030
  // the URL's application, where the ResourceResolver also opens it.
@@ -1915,28 +2033,28 @@ export class ShellComponent extends BaseAngularComponent {
1915
2033
  OpenDashboard: (dashboardId, dashboardName, options) => this.navigationService.OpenDashboard(dashboardId, dashboardName, options),
1916
2034
  });
1917
2035
  if (openedDashboard) {
1918
- return;
2036
+ return true;
1919
2037
  }
1920
2038
  // Check for app-scoped artifact URL: /app/:appName/artifact/:artifactId
1921
2039
  const appArtifactMatch = urlPath.match(/^\/app\/([^\/]+)\/artifact\/(.+)$/);
1922
2040
  if (appArtifactMatch) {
1923
2041
  const artifactId = appArtifactMatch[2];
1924
2042
  this.navigationService.OpenArtifact(artifactId, 'Artifact');
1925
- return;
2043
+ return true;
1926
2044
  }
1927
2045
  // Check for app-scoped query URL: /app/:appName/query/:queryId
1928
2046
  const appQueryMatch = urlPath.match(/^\/app\/([^\/]+)\/query\/(.+)$/);
1929
2047
  if (appQueryMatch) {
1930
2048
  const queryId = appQueryMatch[2];
1931
2049
  this.navigationService.OpenQuery(queryId, 'Query');
1932
- return;
2050
+ return true;
1933
2051
  }
1934
2052
  // Check for app-scoped search URL: /app/:appName/search/:searchInput
1935
2053
  const appSearchMatch = urlPath.match(/^\/app\/([^\/]+)\/search\/(.+)$/);
1936
2054
  if (appSearchMatch) {
1937
2055
  const searchInput = decodeURIComponent(appSearchMatch[2]);
1938
2056
  this.navigationService.OpenSearch(searchInput);
1939
- return;
2057
+ return true;
1940
2058
  }
1941
2059
  // Check for app nav item URL: /app/:appName/:navItemName
1942
2060
  const appNavItemMatch = urlPath.match(/^\/app\/([^\/]+)\/([^\/]+)$/);
@@ -1948,18 +2066,18 @@ export class ShellComponent extends BaseAngularComponent {
1948
2066
  // Activate the app and open the nav item
1949
2067
  await this.appManager.SetActiveApp(app.ID);
1950
2068
  const navItems = await app.GetNavItems();
1951
- const navItem = navItems.find(item => item.Label === navItemName);
2069
+ const navItem = navItems.find(item => item.Label?.trim().toLowerCase() === navItemName.trim().toLowerCase());
1952
2070
  if (navItem) {
1953
2071
  // Parse query params to pass as configuration
1954
- const qpObj = {};
1955
- queryParams.forEach((value, key) => { qpObj[key] = value; });
2072
+ const qpObj = this.urlSearchParamsToRecord(queryParams);
1956
2073
  this.navigationService.OpenNavItem(app.ID, navItem, app.GetColor(), Object.keys(qpObj).length > 0 ? { queryParams: qpObj } : undefined);
2074
+ return true;
1957
2075
  }
1958
2076
  else {
1959
2077
  console.warn('handleMissingTabForUrl: nav item not found:', navItemName, 'in app:', appPath);
1960
2078
  }
1961
2079
  }
1962
- return;
2080
+ return false;
1963
2081
  }
1964
2082
  // Check for app-only URL: /app/:appName
1965
2083
  const appOnlyMatch = urlPath.match(/^\/app\/([^\/]+)$/);
@@ -1967,6 +2085,7 @@ export class ShellComponent extends BaseAngularComponent {
1967
2085
  const appPath = decodeURIComponent(appOnlyMatch[1]);
1968
2086
  const app = this.appManager.GetAppByPath(appPath) || this.appManager.GetAppByName(appPath);
1969
2087
  if (app) {
2088
+ const qpObj = this.urlSearchParamsToRecord(queryParams);
1970
2089
  const navItems = await app.GetNavItems();
1971
2090
  // Only auto-create tabs for apps with zero nav items
1972
2091
  // Apps with nav items should have had their tabs preserved
@@ -1975,7 +2094,14 @@ export class ShellComponent extends BaseAngularComponent {
1975
2094
  await this.appManager.SetActiveApp(app.ID);
1976
2095
  const defaultTab = await app.CreateDefaultTab();
1977
2096
  if (defaultTab) {
2097
+ if (Object.keys(qpObj).length > 0) {
2098
+ defaultTab.Configuration = {
2099
+ ...(defaultTab.Configuration || {}),
2100
+ queryParams: qpObj
2101
+ };
2102
+ }
1978
2103
  this.workspaceManager.OpenTab(defaultTab, app.GetColor());
2104
+ return true;
1979
2105
  }
1980
2106
  }
1981
2107
  else {
@@ -1983,11 +2109,12 @@ export class ShellComponent extends BaseAngularComponent {
1983
2109
  await this.appManager.SetActiveApp(app.ID);
1984
2110
  const defaultNavItem = navItems.find(item => item.isDefault) || navItems[0];
1985
2111
  if (defaultNavItem) {
1986
- this.navigationService.OpenNavItem(app.ID, defaultNavItem, app.GetColor());
2112
+ this.navigationService.OpenNavItem(app.ID, defaultNavItem, app.GetColor(), Object.keys(qpObj).length > 0 ? { queryParams: qpObj } : undefined);
2113
+ return true;
1987
2114
  }
1988
2115
  }
1989
2116
  }
1990
- return;
2117
+ return false;
1991
2118
  }
1992
2119
  // Legacy resource URLs (backward compatibility)
1993
2120
  // Check for legacy record URL: /resource/record/:entityName/:recordId
@@ -1995,45 +2122,105 @@ export class ShellComponent extends BaseAngularComponent {
1995
2122
  if (legacyRecordMatch) {
1996
2123
  const entityName = decodeURIComponent(legacyRecordMatch[1]);
1997
2124
  const recordId = decodeURIComponent(legacyRecordMatch[2]);
1998
- this.openRecordFromUrl(entityName, recordId, queryParams);
1999
- return;
2125
+ return this.openRecordFromUrl(entityName, recordId, queryParams);
2126
+ }
2127
+ // Check for legacy dynamic view URL: /resource/view/dynamic/:entityName
2128
+ const legacyDynamicViewMatch = urlPath.match(/^\/resource\/view\/dynamic\/(.+)$/);
2129
+ if (legacyDynamicViewMatch) {
2130
+ const entityName = decodeURIComponent(legacyDynamicViewMatch[1]);
2131
+ if (!this.isKnownEntity(entityName)) {
2132
+ return false;
2133
+ }
2134
+ const extraFilter = queryParams.get('ExtraFilter') || queryParams.get('extraFilter') || undefined;
2135
+ const alternateBaseView = queryParams.get('AlternateBaseView') || queryParams.get('alternateBaseView') || queryParams.get('abv') || undefined;
2136
+ this.navigationService.OpenDynamicView(entityName, extraFilter, alternateBaseView ? { alternateBaseView } : undefined);
2137
+ return true;
2000
2138
  }
2001
2139
  // Check for legacy view URL: /resource/view/:viewId
2002
2140
  const legacyViewMatch = urlPath.match(/^\/resource\/view\/([^\/]+)$/);
2003
2141
  if (legacyViewMatch && legacyViewMatch[1] !== 'dynamic') {
2004
2142
  const viewId = legacyViewMatch[1];
2005
2143
  this.navigationService.OpenView(viewId, 'View');
2006
- return;
2144
+ return true;
2007
2145
  }
2008
2146
  // Check for legacy dashboard URL: /resource/dashboard/:dashboardId
2009
2147
  const legacyDashboardMatch = urlPath.match(/^\/resource\/dashboard\/(.+)$/);
2010
2148
  if (legacyDashboardMatch) {
2011
2149
  const dashboardId = legacyDashboardMatch[1];
2012
2150
  this.navigationService.OpenDashboard(dashboardId, 'Dashboard');
2013
- return;
2151
+ return true;
2014
2152
  }
2153
+ // Check for legacy artifact URL: /resource/artifact/:artifactId
2154
+ const legacyArtifactMatch = urlPath.match(/^\/resource\/artifact\/(.+)$/);
2155
+ if (legacyArtifactMatch) {
2156
+ this.navigationService.OpenArtifact(legacyArtifactMatch[1], 'Artifact');
2157
+ return true;
2158
+ }
2159
+ // Check for legacy query URL: /resource/query/:queryId
2160
+ const legacyQueryMatch = urlPath.match(/^\/resource\/query\/(.+)$/);
2161
+ if (legacyQueryMatch) {
2162
+ this.navigationService.OpenQuery(legacyQueryMatch[1], 'Query');
2163
+ return true;
2164
+ }
2165
+ // Check for legacy search URL: /resource/search/:searchInput
2166
+ const legacySearchMatch = urlPath.match(/^\/resource\/search\/(.+)$/);
2167
+ if (legacySearchMatch) {
2168
+ this.navigationService.OpenSearch(decodeURIComponent(legacySearchMatch[1]));
2169
+ return true;
2170
+ }
2171
+ return false;
2015
2172
  }
2016
2173
  finally {
2017
2174
  this.urlBasedNavigation = false;
2018
2175
  }
2019
2176
  }
2177
+ /**
2178
+ * Checks whether an entity exists in metadata when metadata is loaded.
2179
+ * When metadata is empty (e.g. lightweight unit test harnesses), returns true.
2180
+ */
2181
+ isKnownEntity(entityName) {
2182
+ let md = null;
2183
+ try {
2184
+ md = this.getProviderSafe();
2185
+ }
2186
+ catch {
2187
+ return true;
2188
+ }
2189
+ const entities = md?.Entities;
2190
+ if (!entities || entities.length === 0) {
2191
+ return true;
2192
+ }
2193
+ return !!md?.EntityByName(entityName);
2194
+ }
2195
+ getProviderSafe() {
2196
+ return this.ProviderToUse ?? null;
2197
+ }
2020
2198
  /**
2021
2199
  * Recreate a record tab from a URL. `/new` is OpenNewEntityRecord — feeding
2022
2200
  * the sentinel through OpenEntityRecord produced an empty CompositeKey and
2023
2201
  * another forced tab, which is the loop that killed the browser.
2024
2202
  */
2025
2203
  openRecordFromUrl(entityName, recordId, queryParams) {
2204
+ if (!this.isKnownEntity(entityName)) {
2205
+ LogError(`Entity ${entityName} not found in metadata`);
2206
+ return false;
2207
+ }
2208
+ // Recreating from browser history must keep `?form=standard` (MJ#4755),
2209
+ // or back/forward would reopen the record in the custom form.
2210
+ const formMode = ReadStandardFormQuery({ [FORM_MODE_QUERY_PARAM]: queryParams.get(FORM_MODE_QUERY_PARAM) });
2026
2211
  if (IsNewEntityRecordUrlId(recordId)) {
2027
2212
  const nrv = queryParams.get(NEW_RECORD_VALUES_QUERY_PARAM) ?? undefined;
2028
2213
  this.navigationService.OpenNewEntityRecord(entityName, {
2029
2214
  newRecordValues: nrv,
2030
2215
  recordSource: 'none',
2216
+ formMode,
2031
2217
  });
2032
- return;
2218
+ return true;
2033
2219
  }
2034
2220
  const compositeKey = new CompositeKey();
2035
2221
  compositeKey.SimpleLoadFromURLSegment(recordId);
2036
- this.navigationService.OpenEntityRecord(entityName, compositeKey, { recordSource: 'none' });
2222
+ this.navigationService.OpenEntityRecord(entityName, compositeKey, { recordSource: 'none', formMode });
2223
+ return true;
2037
2224
  }
2038
2225
  recordTabMatchesUrl(tab, entityName, recordId) {
2039
2226
  const tabConfig = tab.configuration || {};
@@ -2088,6 +2275,18 @@ export class ShellComponent extends BaseAngularComponent {
2088
2275
  if (navItems.length === 0) {
2089
2276
  return tabs.find(tab => UUIDsEqual(tab.applicationId, app.ID)) || null;
2090
2277
  }
2278
+ // For apps with nav items, an app-level URL (/app/:appName) resolves to the default nav item's tab
2279
+ const defaultNavItem = navItems.find(item => item.isDefault) || navItems[0];
2280
+ if (defaultNavItem) {
2281
+ return tabs.find(tab => {
2282
+ const tabConfig = tab.configuration || {};
2283
+ const tabAppName = tabConfig['appName'];
2284
+ const tabNavItemName = tabConfig['navItemName'];
2285
+ const appMatches = (tabAppName && tabAppName.toLowerCase() === app.Name.toLowerCase()) ||
2286
+ UUIDsEqual(tab.applicationId, app.ID);
2287
+ return appMatches && tabNavItemName?.toLowerCase() === defaultNavItem.Label?.toLowerCase();
2288
+ }) || null;
2289
+ }
2091
2290
  return null;
2092
2291
  }
2093
2292
  }
@@ -2115,7 +2314,9 @@ export class ShellComponent extends BaseAngularComponent {
2115
2314
  const resourceType = tabConfig['resourceType']?.toLowerCase();
2116
2315
  const tabEntity = (tabConfig['Entity'] || tabConfig['entity']);
2117
2316
  const isDynamic = tabConfig['isDynamic'];
2118
- return resourceType === 'user views' && isDynamic && tabEntity?.toLowerCase() === entityName.toLowerCase();
2317
+ return (resourceType === 'user views' || resourceType === 'mj: user views') &&
2318
+ isDynamic &&
2319
+ tabEntity?.toLowerCase() === entityName.toLowerCase();
2119
2320
  }) || null;
2120
2321
  }
2121
2322
  // Saved view: /app/:appName/view/:viewId
@@ -2126,7 +2327,7 @@ export class ShellComponent extends BaseAngularComponent {
2126
2327
  const tabConfig = tab.configuration || {};
2127
2328
  const resourceType = tabConfig['resourceType']?.toLowerCase();
2128
2329
  const tabViewId = (tabConfig['viewId'] || tabConfig['recordId'] || tab.resourceRecordId);
2129
- return resourceType === 'user views' && tabViewId === viewId;
2330
+ return (resourceType === 'user views' || resourceType === 'mj: user views') && tabViewId === viewId;
2130
2331
  }) || null;
2131
2332
  }
2132
2333
  // Query: /app/:appName/query/:queryId
@@ -2237,6 +2438,14 @@ export class ShellComponent extends BaseAngularComponent {
2237
2438
  const params = new URLSearchParams(all);
2238
2439
  return `${url}${separator}${params.toString()}`;
2239
2440
  };
2441
+ // Extra query params a record tab's URL carries beyond the tab's own
2442
+ // queryParams (which appendQP already merges — that is where the
2443
+ // standard-form `form` param lives, MJ#4755): initial values for an
2444
+ // unsaved record.
2445
+ const recordQueryParams = (isNewRecord) => {
2446
+ const nrv = isNewRecord ? EncodeNewRecordValuesForURL(config['NewRecordValues']) : undefined;
2447
+ return nrv ? { [NEW_RECORD_VALUES_QUERY_PARAM]: nrv } : {};
2448
+ };
2240
2449
  // Helper function to get app path for URL
2241
2450
  const getAppPath = (appIdOrName) => {
2242
2451
  // First try by ID
@@ -2266,7 +2475,7 @@ export class ShellComponent extends BaseAngularComponent {
2266
2475
  // If this is an app's default dashboard (no nav items), use app-level URL
2267
2476
  if (isAppDefault && appName) {
2268
2477
  const appPath = getAppPath(appName) || appName;
2269
- return `/app/${encodeURIComponent(appPath)}`;
2478
+ return appendQP(`/app/${encodeURIComponent(appPath)}`);
2270
2479
  }
2271
2480
  // Fallback: If tab belongs to a non-system app but doesn't have appName/navItemName,
2272
2481
  // try to reconstruct the URL from the ApplicationId and tab title
@@ -2308,7 +2517,7 @@ export class ShellComponent extends BaseAngularComponent {
2308
2517
  if (staticNavItems.length === 0 && (!resourceType || resourceType === 'custom')) {
2309
2518
  // App has zero static nav items AND this is not an orphan resource (no resourceType or custom)
2310
2519
  // Use app-level URL - this handles apps that only have a default dashboard
2311
- return `/app/${encodeURIComponent(appPath)}`;
2520
+ return appendQP(`/app/${encodeURIComponent(appPath)}`);
2312
2521
  }
2313
2522
  // Fall through to orphan resource URL building below
2314
2523
  }
@@ -2347,21 +2556,26 @@ export class ShellComponent extends BaseAngularComponent {
2347
2556
  // here, `syncUrlWithWorkspace`'s `currentUrl !== newUrl` check is permanently true and can
2348
2557
  // drive a re-navigation loop (with onSameUrlNavigation:'reload'). The read side already
2349
2558
  // decodeURIComponent()s this segment, so encoding here keeps both sides consistent.
2350
- const nrv = isNewRecord ? EncodeNewRecordValuesForURL(config['NewRecordValues']) : undefined;
2351
- return appendQP(`/app/${encodeURIComponent(appPath)}/record/${encodeURIComponent(entityName)}/${encodeURIComponent(idSeg)}`, nrv ? { [NEW_RECORD_VALUES_QUERY_PARAM]: nrv } : undefined);
2559
+ return appendQP(`/app/${encodeURIComponent(appPath)}/record/${encodeURIComponent(entityName)}/${encodeURIComponent(idSeg)}`, recordQueryParams(isNewRecord));
2352
2560
  }
2353
2561
  }
2354
2562
  break;
2355
2563
  case 'user views':
2564
+ case 'mj: user views':
2356
2565
  // Check if it's a dynamic view
2357
2566
  if (isDynamic || recordId === 'dynamic') {
2358
- // /app/:appName/view/dynamic/:entityName?ExtraFilter=...
2567
+ // /app/:appName/view/dynamic/:entityName?ExtraFilter=...&abv=...
2359
2568
  if (entityName) {
2360
- let url = `/app/${encodeURIComponent(appPath)}/view/dynamic/${encodeURIComponent(entityName)}`;
2569
+ const url = `/app/${encodeURIComponent(appPath)}/view/dynamic/${encodeURIComponent(entityName)}`;
2570
+ const extraParams = {};
2361
2571
  if (extraFilter) {
2362
- url += `?ExtraFilter=${encodeURIComponent(extraFilter)}`;
2572
+ extraParams['ExtraFilter'] = extraFilter;
2363
2573
  }
2364
- return appendQP(url);
2574
+ const abv = (config['AlternateBaseView'] || config['alternateBaseView']);
2575
+ if (abv) {
2576
+ extraParams['abv'] = abv;
2577
+ }
2578
+ return appendQP(url, extraParams);
2365
2579
  }
2366
2580
  }
2367
2581
  else if (recordId) {
@@ -2420,8 +2634,7 @@ export class ShellComponent extends BaseAngularComponent {
2420
2634
  if (idSeg) {
2421
2635
  // Encode the CompositeKey segment ('|' → %7C) to match Angular's serialized router.url and
2422
2636
  // the decodeURIComponent() on the read side — see the app-scoped 'records' case above.
2423
- const nrv = isNewRecord ? EncodeNewRecordValuesForURL(config['NewRecordValues']) : undefined;
2424
- return appendQP(`/resource/record/${encodeURIComponent(entityName)}/${encodeURIComponent(idSeg)}`, nrv ? { [NEW_RECORD_VALUES_QUERY_PARAM]: nrv } : undefined);
2637
+ return appendQP(`/resource/record/${encodeURIComponent(entityName)}/${encodeURIComponent(idSeg)}`, recordQueryParams(isNewRecord));
2425
2638
  }
2426
2639
  }
2427
2640
  break;
@@ -3350,9 +3563,8 @@ export class ShellComponent extends BaseAngularComponent {
3350
3563
  if (compositeKey.KeyValuePairs.length === 0)
3351
3564
  return null;
3352
3565
  const results = await md.GetEntityRecordNames([{ EntityName: entityName, CompositeKey: compositeKey }]);
3353
- if (results.length > 0 && results[0].Success && results[0].RecordName) {
3354
- return results[0].RecordName;
3355
- }
3566
+ const rawName = results.length > 0 && results[0].Success ? results[0].RecordName : null;
3567
+ return FormatEntityRecordDisplayTitle(entityInfo, entityName, compositeKey, rawName);
3356
3568
  }
3357
3569
  catch {
3358
3570
  // Fall through to null
@@ -3549,7 +3761,7 @@ export class ShellComponent extends BaseAngularComponent {
3549
3761
  // Get resource name from active tab
3550
3762
  let resourceName = null;
3551
3763
  if (activeTab) {
3552
- resourceName = activeTab.title || null;
3764
+ resourceName = ResolveRecordTabDisplayTitle(activeTab, this.ProviderToUse);
3553
3765
  }
3554
3766
  // Update title via TitleService
3555
3767
  this.titleService.setContext(appName, resourceName);