@memberjunction/ng-shared
Version:
MemberJunction: MJ Explorer Angular Shared Package - utility functions and other reusable elements used across other MJ Angular packages within the MJ Explorer App - do not use outside of MJ Explorer.
787 lines • 34.9 kB
JavaScript
import { Injectable } from '@angular/core';
import { fromEvent, BehaviorSubject, Subject } from 'rxjs';
import { map, distinctUntilChanged } from 'rxjs/operators';
import { UUIDsEqual } from '@memberjunction/global';
import * as i0 from "@angular/core";
import * as i1 from "@memberjunction/ng-base-application";
/**
* System application ID for non-app-specific resources (fallback only)
* Uses double underscore prefix to indicate system-level resource
* @deprecated Prefer using NavigationService.getDefaultApplicationId() instead
*/
export const SYSTEM_APP_ID = '__explorer';
/**
* Neutral color for fallback when no app is available
*/
const NEUTRAL_APP_COLOR = '#9E9E9E'; // Material Design Gray 500
/**
* Centralized navigation service that handles all navigation operations
* with automatic shift-key detection for power user workflows
*/
export class NavigationService {
workspaceManager;
appManager;
shiftKeyPressed = false;
subscriptions = [];
queryParamChanged$ = new Subject();
/** Observable that emits when query params change on a tab (back/forward navigation). */
QueryParamChanged$ = this.queryParamChanged$.asObservable();
/** Cached Home app ID (null means not found, undefined means not checked) */
_homeAppId = undefined;
/** Cached Home app color */
_homeAppColor = null;
constructor(workspaceManager, appManager) {
this.workspaceManager = workspaceManager;
this.appManager = appManager;
this.setupGlobalShiftKeyDetection();
}
/**
* Get the neutral color used for system-wide resources (entities, views, dashboards)
* Returns a light neutral gray
* @deprecated Use getDefaultAppColor() for better UX with Home app integration
*/
get ExplorerAppColor() {
return NEUTRAL_APP_COLOR;
}
/**
* Gets the default application ID for orphan resources.
* Priority: Home app > Active app > SYSTEM_APP_ID
*
* This ensures orphan resources (entity records, dashboards, views opened directly)
* are grouped under the Home app instead of being orphaned in the tab system.
*/
getDefaultApplicationId() {
// Check cache first
if (this._homeAppId !== undefined) {
if (this._homeAppId !== null) {
return this._homeAppId;
}
// Home app not found, check active app
const activeApp = this.appManager.GetActiveApp();
if (activeApp) {
return activeApp.ID;
}
return SYSTEM_APP_ID;
}
// First time - look for Home app
const homeApp = this.appManager.GetAppByName('Home');
if (homeApp) {
this._homeAppId = homeApp.ID;
this._homeAppColor = homeApp.GetColor();
return homeApp.ID;
}
// Cache that Home app doesn't exist
this._homeAppId = null;
// Fall back to currently active app
const activeApp = this.appManager.GetActiveApp();
if (activeApp) {
return activeApp.ID;
}
// Last resort - system app ID
return SYSTEM_APP_ID;
}
/**
* Gets the default app color for orphan resources.
* Returns Home app color if available, otherwise neutral gray.
*/
getDefaultAppColor() {
// Ensure cache is populated
this.getDefaultApplicationId();
// If Home app exists, use its color
if (this._homeAppColor) {
return this._homeAppColor;
}
// Check active app
const activeApp = this.appManager.GetActiveApp();
if (activeApp) {
return activeApp.GetColor();
}
// Fall back to neutral color
return NEUTRAL_APP_COLOR;
}
/**
* Clears the cached Home app info.
* Call this if apps are reloaded or user logs out.
*/
clearHomeAppCache() {
this._homeAppId = undefined;
this._homeAppColor = null;
}
// ════════════════════════════════════════════
// Agent Context & Client Tools
// ════════════════════════════════════════════
/**
* Observable stream of agent context updates from resource components.
* The shell subscribes to this to update the ComponentCacheManager and
* push changes to the chat overlay's AppContextSnapshot.DashboardContext.
*/
AgentContextUpdated$ = new Subject();
/**
* Latest `AppContextSnapshot` published by the Explorer app shell.
*
* Why: any embedded `<mj-conversation-chat-area>` instance outside the
* floating chat overlay (Form Builder cockpit, future domain dashboards
* that pop their own AI pane) needs to feed the SAME context the overlay
* does so the agent sees what app + view + dashboard state the user is
* looking at. Without this, the agent only sees the embedder's narrow
* `AdditionalContext` slice and treats the user as if they have no app
* context at all — which is the bug we just fixed.
*
* `MJExplorerAppComponent` is the canonical publisher (it owns the
* snapshot construction); consumers SUBSCRIBE and bind the value to
* their chat-area's `[appContext]`. Non-Explorer apps (custom MJ apps
* that don't include explorer-app at all) build their own snapshot via
* `BuildAppContextSnapshot()` in `@memberjunction/ai-core-plus`.
*
* Initial value is `null`; the publisher emits the first real snapshot
* after the active app + nav state resolve on bootstrap.
*/
AppContextSnapshot$ = new BehaviorSubject(null);
/**
* Push a fresh AppContextSnapshot. Called by MJExplorerAppComponent
* after each (a) app/tab change, (b) `handleAgentContextUpdate`
* merging in `AdditionalContext` from a dashboard. Idempotent — no
* de-duplication; embedders should treat the stream as "the latest
* value is canonical."
*/
PublishAppContextSnapshot(snapshot) {
this.AppContextSnapshot$.next(snapshot);
}
/**
* Report the current agent-visible state from a resource component.
* Call this whenever the dashboard's internal state changes (tab switch,
* filter change, pipeline status change, drill-down, etc.).
*
* @param caller - Pass `this` from the calling component. Used to match
* against the ComponentCacheManager to identify which cached component
* this update belongs to.
* @param context - Key-value pairs representing dashboard state the agent
* should know about. Each dashboard defines its own shape.
*/
SetAgentContext(caller, context) {
this.AgentContextUpdated$.next({ Caller: caller, AgentContext: context });
}
/**
* Register the client tools available from a resource component.
* Call this on component init and whenever the available tools change.
* Tools are automatically unregistered when the component becomes inactive
* (tab switch) and re-registered when it becomes active again.
*
* @param caller - Pass `this` from the calling component.
* @param tools - Array of tool definitions with Name, Description,
* ParameterSchema (JSON Schema), and Handler function.
*/
SetAgentClientTools(caller, tools) {
this.AgentContextUpdated$.next({ Caller: caller, AgentClientTools: tools });
}
ngOnDestroy() {
this.subscriptions.forEach(sub => sub.unsubscribe());
}
/**
* Set up global keyboard event listeners to track shift key state
*/
setupGlobalShiftKeyDetection() {
// Track shift key via mousedown events (capture phase) instead of keydown/keyup.
// This is more reliable because:
// 1. MouseEvent.shiftKey always reflects the actual modifier state at click time
// 2. No risk of "stuck" state from missed keyup events (focus loss, tab switch, etc.)
// 3. Navigation is always triggered by a click, so the shift state is read
// at exactly the right moment
this.subscriptions.push(fromEvent(document, 'mousedown', { capture: true }).subscribe(event => {
this.shiftKeyPressed = event.shiftKey;
}));
}
/**
* Get current shift key state
*/
isShiftPressed() {
return this.shiftKeyPressed;
}
/**
* Determine if a new tab should be forced based on options and shift key state
*/
shouldForceNewTab(options) {
// If forceNewTab is explicitly set, use that
if (options?.forceNewTab !== undefined) {
return options.forceNewTab;
}
// Otherwise, use global shift key detection
return this.isShiftPressed();
}
/**
* Returns whether the caller should use OpenTabForced (force-new path) or
* OpenTab (replace-temp path).
*
* Rule: only honor an explicit force-new request — from the user via
* shift+click, or from the caller via `options.forceNewTab`. We deliberately
* do NOT apply heuristics that auto-switch the workspace out of
* single-resource mode on cross-resource navigation. A previous version of
* this method tried to do that ("force new if single-resource + different
* resource") and it caused a regression: every plain hyperlink click on a
* record opened a new tab and dropped the user into multi-tab mode, even
* though they didn't ask for it. That violated the principle that mode
* transitions are user-driven (shift) or explicitly requested (options).
*
* If a particular caller really needs the parent context preserved when
* creating/navigating to a child resource (e.g. "+New" on a related-entity
* grid inside an open record), the caller should pass `forceNewTab: true`
* in `NavigationOptions`. That keeps intent explicit at the call site
* instead of buried in a global heuristic.
*/
handleSingleResourceModeTransition(forceNew, _newRequest) {
return forceNew;
}
/**
* Check if a tab request matches an existing tab's resource
*/
isSameResource(tab, request) {
// Different apps = different resources
if (tab.applicationId !== request.ApplicationId) {
return false;
}
// For resource-based tabs, compare resourceType and recordId
if (request.Configuration?.resourceType) {
const requestRecordId = request.ResourceRecordId || '';
const tabRecordId = tab.resourceRecordId || '';
return tab.configuration?.resourceType === request.Configuration.resourceType &&
tabRecordId === requestRecordId;
}
// For app nav items, compare appName and navItemName
if (request.Configuration?.appName && request.Configuration?.navItemName) {
return tab.configuration?.appName === request.Configuration.appName &&
tab.configuration?.navItemName === request.Configuration.navItemName;
}
// Fallback to basic comparison
return false;
}
/**
* Open a navigation item within an app
*/
OpenNavItem(appId, navItem, appColor, options) {
let forceNew = this.shouldForceNewTab(options);
// Get the app to find its name
const app = this.appManager.GetAppById(appId);
const appName = app?.Name || '';
// Dynamic nav items (e.g. orphan entity records) carry their original tab Configuration
// and should NOT get navItemName stamped on them — that would cause buildResourceUrl
// to produce a nav-item-style URL like /app/home/<label> instead of the correct
// resource-type URL like /app/home/record/Entity/ID|...
const isDynamic = navItem.isDynamic === true;
const request = {
ApplicationId: appId,
Title: navItem.Label,
ResourceRecordId: navItem.RecordID || '', // Also store at top level for consistent tab matching
Configuration: {
route: navItem.Route,
resourceType: navItem.ResourceType,
driverClass: navItem.DriverClass, // Pass through DriverClass for Custom resource type
recordId: navItem.RecordID,
appName: appName, // Store app name for URL building
appId: appId,
...(isDynamic ? {} : { navItemName: navItem.Label }), // Only set for static nav items
...(navItem.Configuration || {})
},
IsPinned: options?.pinTab || false
};
// Handle transition from single-resource mode
forceNew = this.handleSingleResourceModeTransition(forceNew, request);
let tabId;
if (forceNew) {
// Always create a new tab
tabId = this.workspaceManager.OpenTabForced(request, appColor);
}
else {
// Use existing OpenTab logic (may replace temporary tab)
tabId = this.workspaceManager.OpenTab(request, appColor);
}
// Apply query params to the newly opened/activated tab if provided
if (options?.queryParams) {
this.applyQueryParamsToTab(tabId, options.queryParams);
}
return tabId;
}
/**
* Open an entity record view
* Uses Home app if available, otherwise falls back to active app or system app
*/
OpenEntityRecord(entityName, recordPkey, options) {
const appId = this.getDefaultApplicationId();
const appColor = this.getDefaultAppColor();
let forceNew = this.shouldForceNewTab(options);
const recordId = recordPkey.ToURLSegment();
const request = {
ApplicationId: appId,
Title: `${entityName} - ${recordId}`,
Configuration: {
resourceType: 'Records',
Entity: entityName, // Must use 'Entity' (capital E) - expected by record-resource.component
recordId: recordId // Also needed in Configuration for tab-container.component to populate ResourceRecordID
},
ResourceRecordId: recordId,
IsPinned: options?.pinTab || false
};
// Handle transition from single-resource mode
forceNew = this.handleSingleResourceModeTransition(forceNew, request);
let tabId;
if (forceNew) {
tabId = this.workspaceManager.OpenTabForced(request, appColor);
}
else {
tabId = this.workspaceManager.OpenTab(request, appColor);
}
return tabId;
}
/**
* Open a view
* Uses Home app if available, otherwise falls back to active app or system app
*/
OpenView(viewId, viewName, options) {
const appId = this.getDefaultApplicationId();
const appColor = this.getDefaultAppColor();
let forceNew = this.shouldForceNewTab(options);
const request = {
ApplicationId: appId,
Title: viewName,
Configuration: {
resourceType: 'MJ: User Views',
viewId,
recordId: viewId // Also needed in Configuration for tab-container.component to populate ResourceRecordID
},
ResourceRecordId: viewId,
IsPinned: options?.pinTab || false
};
// Handle transition from single-resource mode
forceNew = this.handleSingleResourceModeTransition(forceNew, request);
if (forceNew) {
return this.workspaceManager.OpenTabForced(request, appColor);
}
else {
return this.workspaceManager.OpenTab(request, appColor);
}
}
/**
* Open a dashboard
* Uses Home app if available, otherwise falls back to active app or system app
*/
OpenDashboard(dashboardId, dashboardName, options) {
const appId = this.getDefaultApplicationId();
const appColor = this.getDefaultAppColor();
let forceNew = this.shouldForceNewTab(options);
const request = {
ApplicationId: appId,
Title: dashboardName,
Configuration: {
resourceType: 'Dashboards',
dashboardId,
recordId: dashboardId // Also needed in Configuration for tab-container.component to populate ResourceRecordID
},
ResourceRecordId: dashboardId,
IsPinned: options?.pinTab || false
};
// Handle transition from single-resource mode
forceNew = this.handleSingleResourceModeTransition(forceNew, request);
if (forceNew) {
return this.workspaceManager.OpenTabForced(request, appColor);
}
else {
return this.workspaceManager.OpenTab(request, appColor);
}
}
/**
* Open a report
* Uses Home app if available, otherwise falls back to active app or system app
*/
OpenReport(reportId, reportName, options) {
const appId = this.getDefaultApplicationId();
const appColor = this.getDefaultAppColor();
let forceNew = this.shouldForceNewTab(options);
const request = {
ApplicationId: appId,
Title: reportName,
Configuration: {
resourceType: 'Reports',
reportId,
recordId: reportId // Also needed in Configuration for tab-container.component to populate ResourceRecordID
},
ResourceRecordId: reportId,
IsPinned: options?.pinTab || false
};
// Handle transition from single-resource mode
forceNew = this.handleSingleResourceModeTransition(forceNew, request);
if (forceNew) {
return this.workspaceManager.OpenTabForced(request, appColor);
}
else {
return this.workspaceManager.OpenTab(request, appColor);
}
}
/**
* Open an artifact
* Artifacts are versioned content containers (reports, dashboards, UI components, etc.)
* Uses Home app if available, otherwise falls back to active app or system app
*/
OpenArtifact(artifactId, artifactName, options) {
const appId = this.getDefaultApplicationId();
const appColor = this.getDefaultAppColor();
let forceNew = this.shouldForceNewTab(options);
const request = {
ApplicationId: appId,
Title: artifactName || `Artifact - ${artifactId}`,
Configuration: {
resourceType: 'Artifacts',
artifactId,
recordId: artifactId // Also needed in Configuration for tab-container.component to populate ResourceRecordID
},
ResourceRecordId: artifactId,
IsPinned: options?.pinTab || false
};
// Handle transition from single-resource mode
forceNew = this.handleSingleResourceModeTransition(forceNew, request);
if (forceNew) {
return this.workspaceManager.OpenTabForced(request, appColor);
}
else {
return this.workspaceManager.OpenTab(request, appColor);
}
}
/**
* Open a dynamic view
* Dynamic views are entity-based views with custom filters, not saved views
* Uses Home app if available, otherwise falls back to active app or system app
*/
OpenDynamicView(entityName, extraFilter, options) {
const appId = this.getDefaultApplicationId();
const appColor = this.getDefaultAppColor();
let forceNew = this.shouldForceNewTab(options);
const filterSuffix = extraFilter ? ' (Filtered)' : '';
const request = {
ApplicationId: appId,
Title: `${entityName}${filterSuffix}`,
Configuration: {
resourceType: 'MJ: User Views',
Entity: entityName,
ExtraFilter: extraFilter,
isDynamic: true,
recordId: 'dynamic' // Special marker for dynamic views
},
ResourceRecordId: 'dynamic',
IsPinned: options?.pinTab || false
};
// Handle transition from single-resource mode
forceNew = this.handleSingleResourceModeTransition(forceNew, request);
if (forceNew) {
return this.workspaceManager.OpenTabForced(request, appColor);
}
else {
return this.workspaceManager.OpenTab(request, appColor);
}
}
/**
* Open a query
* Uses Home app if available, otherwise falls back to active app or system app
*/
OpenQuery(queryId, queryName, options) {
const appId = this.getDefaultApplicationId();
const appColor = this.getDefaultAppColor();
let forceNew = this.shouldForceNewTab(options);
const request = {
ApplicationId: appId,
Title: queryName,
Configuration: {
resourceType: 'Queries',
queryId,
recordId: queryId // Also needed in Configuration for tab-container.component to populate ResourceRecordID
},
ResourceRecordId: queryId,
IsPinned: options?.pinTab || false
};
// Handle transition from single-resource mode
forceNew = this.handleSingleResourceModeTransition(forceNew, request);
if (forceNew) {
return this.workspaceManager.OpenTabForced(request, appColor);
}
else {
return this.workspaceManager.OpenTab(request, appColor);
}
}
/**
* Open a new entity record creation form
* Uses Home app if available, otherwise falls back to active app or system app
* @param entityName The name of the entity to create a new record for
* @param options Navigation options including optional newRecordValues for pre-populating fields
*/
OpenNewEntityRecord(entityName, options) {
const appId = this.getDefaultApplicationId();
const appColor = this.getDefaultAppColor();
let forceNew = this.shouldForceNewTab(options);
const request = {
ApplicationId: appId,
Title: `New ${entityName}`,
Configuration: {
resourceType: 'Records',
Entity: entityName, // Must use 'Entity' (capital E) - expected by record-resource.component
recordId: '', // Empty recordId indicates new record
isNew: true, // Flag to indicate this is a new record
NewRecordValues: options?.newRecordValues // Pass through initial values if provided
},
ResourceRecordId: '', // Empty for new records
IsPinned: options?.pinTab || false
};
// Handle transition from single-resource mode
forceNew = this.handleSingleResourceModeTransition(forceNew, request);
if (forceNew) {
return this.workspaceManager.OpenTabForced(request, appColor);
}
else {
return this.workspaceManager.OpenTab(request, appColor);
}
}
/**
* Open a universal search results tab for the given query.
* This is the primary way to open search results from anywhere in the application.
*
* @param query The search query text
* @param searchOptions Optional search-specific options (e.g., minRelevance, scopeIDs)
* @param options Navigation options
*/
OpenSearch(query, searchOptions, options) {
const appId = this.getDefaultApplicationId();
const appColor = this.getDefaultAppColor();
let forceNew = this.shouldForceNewTab(options);
const config = {
resourceType: 'Search Results',
Query: query,
SearchInput: query,
recordId: `search-${query}`
};
if (searchOptions?.minRelevance != null) {
config['MinRelevance'] = searchOptions.minRelevance;
}
if (searchOptions?.scopeIDs && searchOptions.scopeIDs.length > 0) {
config['ScopeIDs'] = searchOptions.scopeIDs;
}
const request = {
ApplicationId: appId,
Title: `Search: ${query}`,
Configuration: config,
ResourceRecordId: `search-${query}`,
IsPinned: false
};
// Handle transition from single-resource mode
forceNew = this.handleSingleResourceModeTransition(forceNew, request);
if (forceNew) {
return this.workspaceManager.OpenTabForced(request, appColor);
}
else {
return this.workspaceManager.OpenTab(request, appColor);
}
}
/**
* Navigate to a nav item by name within the current or specified application.
* Allows passing additional configuration parameters to merge with the nav item's config.
* This is useful for cross-resource navigation where a component needs to navigate
* to another nav item with specific parameters (e.g., navigate to Conversations with a specific conversationId).
*
* @param navItemName The label/name of the nav item to navigate to
* @param configuration Additional configuration to merge (e.g., conversationId, artifactId)
* @param appId Optional app ID (defaults to current active app)
* @param options Navigation options
* @returns The tab ID if successful, null if nav item not found
*/
async OpenNavItemByName(navItemName, configuration, appId, options) {
// Get app (use provided or current active)
const targetAppId = appId || this.appManager.GetActiveApp()?.ID;
if (!targetAppId) {
return null;
}
const app = this.appManager.GetAppById(targetAppId);
if (!app) {
return null;
}
// Find the nav item by name
const navItems = await app.GetNavItems();
const navItem = navItems.find(item => item.Label === navItemName);
if (!navItem) {
return null;
}
// Create a merged nav item with additional configuration
const mergedNavItem = {
...navItem,
Configuration: {
...(navItem.Configuration || {}),
...(configuration || {})
}
};
// Use existing OpenNavItem
return this.OpenNavItem(targetAppId, mergedNavItem, app.GetColor(), options);
}
/**
* Switch to an application by ID.
* This sets the app as active and either opens a specific nav item or creates a default tab.
* If the requested nav item already has an open tab, switches to that tab instead of creating a new one.
* @param appId The application ID to switch to
* @param navItemName Optional name of a nav item to open within the app. If provided, opens that nav item.
* @param queryParams Optional query params to apply to the target tab. Applied SYNCHRONOUSLY once the
* target tab is active — critical when navigating (e.g. from a Home pin) to an
* app whose resource component is cached: the params must be in the tab config
* BEFORE the tab-container reattaches the cached component, otherwise the cache
* restores its own (stale) saved params and the navigation intent is lost.
*/
async SwitchToApp(appId, navItemName, queryParams) {
await this.appManager.SetActiveApp(appId);
const app = this.appManager.GetAllApps().find(a => UUIDsEqual(a.ID, appId));
if (!app) {
return;
}
const appTabs = this.workspaceManager.GetAppTabs(appId);
// If a specific nav item is requested
if (navItemName) {
const navItems = await app.GetNavItems();
const navItem = navItems.find(item => item.Label === navItemName);
if (navItem) {
// Check if there's already a tab for this nav item
const existingTab = appTabs.find(tab => tab.title === navItem.Label ||
(tab.configuration?.['route'] === navItem.Route && navItem.Route));
if (existingTab) {
// Switch to existing tab
this.workspaceManager.SetActiveTab(existingTab.id);
}
else {
// Open new tab for this nav item
this.OpenNavItem(appId, navItem, app.GetColor());
}
// Apply the requested query params to whichever tab is now active — synchronously,
// so they're present before the (possibly cached) resource component reattaches.
if (queryParams && Object.keys(queryParams).length > 0) {
const targetTabId = this.workspaceManager.GetActiveTabId();
if (targetTabId) {
this.applyQueryParamsToTab(targetTabId, queryParams);
}
}
return;
}
// Nav item not found, fall through to default behavior
}
// No specific nav item requested - check if app has any tabs
if (appTabs.length === 0) {
// Create default tab
const tabRequest = await app.CreateDefaultTab();
if (tabRequest) {
this.workspaceManager.OpenTab(tabRequest, app.GetColor());
}
}
else {
// App has tabs - switch to the first one (or active one if exists)
const config = this.workspaceManager.GetConfiguration();
const activeAppTab = appTabs.find(t => t.id === config?.activeTabId);
if (!activeAppTab) {
// No active tab for this app, switch to first tab
this.workspaceManager.SetActiveTab(appTabs[0].id);
}
}
}
/**
* Update the query params for the currently active tab.
* This updates the tab's configuration and triggers a URL sync via the shell's
* workspace configuration subscription.
*
* Use this instead of directly calling router.navigate() to ensure proper
* URL management that respects app-scoped routes.
*
* @param queryParams Object containing query param key-value pairs.
* Use null values to remove a query param.
* @example
* // Add or update query params
* navigationService.UpdateActiveTabQueryParams({ category: 'abc123', dashboard: 'xyz789' });
*
* // Remove a query param
* navigationService.UpdateActiveTabQueryParams({ category: null });
*/
UpdateActiveTabQueryParams(queryParams) {
const activeTabId = this.workspaceManager.GetActiveTabId();
if (!activeTabId) {
console.warn('NavigationService.UpdateActiveTabQueryParams: No active tab');
return;
}
this.applyQueryParamsToTab(activeTabId, queryParams);
}
/**
* Notify subscribers that query params changed on a specific tab.
* Called by the shell when back/forward navigation changes query params on the active tab.
* The notification includes the tab ID so only the component in that tab reacts.
*/
NotifyQueryParamsChanged(tabId, params) {
this.queryParamChanged$.next({ TabId: tabId, Params: params });
}
/**
* Reactively observe the query params for a specific tab.
*
* Backed by the workspace BehaviorSubject, so a subscriber receives the current
* params *immediately* on subscribe AND every subsequent change — including the
* deep-link params that the ResourceResolver merges into the tab configuration on
* a cold/direct URL load.
*
* This is the race-free counterpart to {@link NotifyQueryParamsChanged} (a plain
* Subject that drops events fired before a component has subscribed). A resource
* component that mounts from workspace restoration can subscribe here and still
* pick up its initial deep-link state regardless of whether the params landed in
* the tab config before or after it mounted.
*/
ObserveTabQueryParams(tabId) {
return this.workspaceManager.Configuration.pipe(map(config => {
const tab = config?.tabs?.find(t => t.id === tabId);
return (tab?.configuration?.['queryParams'] || {});
}), distinctUntilChanged((a, b) => this.shallowParamsEqual(a, b)));
}
shallowParamsEqual(a, b) {
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) {
return false;
}
return keysA.every(key => a[key] === b[key]);
}
/**
* Apply query params to a specific tab by ID.
* Merges with any existing query params on the tab. Use null values to remove params.
*/
applyQueryParamsToTab(tabId, queryParams) {
const tab = this.workspaceManager.GetTab(tabId);
if (!tab) {
console.warn('NavigationService.applyQueryParamsToTab: Tab not found:', tabId);
return;
}
// Get existing queryParams from tab configuration
const existingQueryParams = (tab.configuration?.['queryParams'] || {});
// Merge with new query params
const mergedQueryParams = {};
// Start with existing params (excluding nulls)
for (const [key, value] of Object.entries(existingQueryParams)) {
if (value !== null) {
mergedQueryParams[key] = value;
}
}
// Apply new params (null means remove)
for (const [key, value] of Object.entries(queryParams)) {
if (value === null) {
delete mergedQueryParams[key];
}
else {
mergedQueryParams[key] = value;
}
}
// Update the tab configuration
this.workspaceManager.UpdateTabConfiguration(tabId, {
queryParams: Object.keys(mergedQueryParams).length > 0 ? mergedQueryParams : undefined
});
}
static ɵfac = function NavigationService_Factory(__ngFactoryType__) { return new (__ngFactoryType__ || NavigationService)(i0.ɵɵinject(i1.WorkspaceStateManager), i0.ɵɵinject(i1.ApplicationManager)); };
static ɵprov = /*@__PURE__*/ i0.ɵɵdefineInjectable({ token: NavigationService, factory: NavigationService.ɵfac, providedIn: 'root' });
}
(() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(NavigationService, [{
type: Injectable,
args: [{
providedIn: 'root'
}]
}], () => [{ type: i1.WorkspaceStateManager }, { type: i1.ApplicationManager }], null); })();
//# sourceMappingURL=navigation.service.js.map