@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.
618 lines • 25.8 kB
JavaScript
import { Injectable } from '@angular/core';
import { fromEvent } from 'rxjs';
import { map } from 'rxjs/operators';
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 = [];
/** 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;
}
ngOnDestroy() {
this.subscriptions.forEach(sub => sub.unsubscribe());
}
/**
* Set up global keyboard event listeners to track shift key state
*/
setupGlobalShiftKeyDetection() {
// Track shift key down
const keyDown$ = fromEvent(document, 'keydown').pipe(map(event => event.shiftKey));
// Track shift key up
const keyUp$ = fromEvent(document, 'keyup').pipe(map(event => event.shiftKey));
this.subscriptions.push(keyDown$.subscribe(shiftKey => {
this.shiftKeyPressed = shiftKey;
}), keyUp$.subscribe(shiftKey => {
this.shiftKeyPressed = 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();
}
/**
* Handle temporary tab preservation when forcing new tabs
* Rule: Only ONE tab should be temporary at a time
* When shift+clicking to force a new tab, pin the current active tab if it's temporary
*/
handleSingleResourceModeTransition(forceNew, newRequest) {
if (!forceNew) {
return; // Normal navigation, not forcing new tab
}
const config = this.workspaceManager.GetConfiguration();
if (!config || !config.tabs || config.tabs.length === 0) {
return; // No tabs to preserve
}
// Find the currently active tab
const activeTab = config.tabs.find(tab => tab.id === config.activeTabId);
if (!activeTab) {
return; // No active tab
}
// If the active tab is NOT pinned (i.e., it's temporary), pin it to preserve it
// This maintains the "only one temporary tab" rule
if (!activeTab.isPinned) {
this.workspaceManager.TogglePin(activeTab.id);
}
}
/**
* 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) {
const 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
this.handleSingleResourceModeTransition(forceNew, request);
if (forceNew) {
// Always create a new tab
const tabId = this.workspaceManager.OpenTabForced(request, appColor);
return tabId;
}
else {
// Use existing OpenTab logic (may replace temporary tab)
const tabId = this.workspaceManager.OpenTab(request, appColor);
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();
const 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
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();
const forceNew = this.shouldForceNewTab(options);
const request = {
ApplicationId: appId,
Title: viewName,
Configuration: {
resourceType: '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
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();
const 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
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();
const 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
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();
const 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
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();
const forceNew = this.shouldForceNewTab(options);
const filterSuffix = extraFilter ? ' (Filtered)' : '';
const request = {
ApplicationId: appId,
Title: `${entityName}${filterSuffix}`,
Configuration: {
resourceType: '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
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();
const 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
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();
const 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
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.
*/
async SwitchToApp(appId, navItemName) {
await this.appManager.SetActiveApp(appId);
const app = this.appManager.GetAllApps().find(a => 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());
}
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;
}
// Get the current tab to merge with existing queryParams
const tab = this.workspaceManager.GetTab(activeTabId);
if (!tab) {
console.warn('NavigationService.UpdateActiveTabQueryParams: Tab not found');
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(activeTabId, {
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