UNPKG

@fleetbase/ember-core

Version:

Provides all the core services, decorators and utilities for building a Fleetbase extension for the Console.

621 lines (545 loc) 23.4 kB
import Service from '@ember/service'; import Evented from '@ember/object/evented'; import { tracked } from '@glimmer/tracking'; import { inject as service } from '@ember/service'; import { dasherize } from '@ember/string'; import { A, isArray } from '@ember/array'; import MenuItem from '../../contracts/menu-item'; import MenuPanel from '../../contracts/menu-panel'; /** * MenuManagerService * * Manages all menu items and panels in the application. * Uses RegistryService for storage, providing cross-engine access. * * @class MenuService * @extends Service */ export default class MenuService extends Service.extend(Evented) { @service('universe/registry-service') registry; @service universe; /** * Reference to the root Ember Application Instance. * Used for registering components/services to the application container * for cross-engine sharing. */ @tracked applicationInstance = null; /** * Set the application instance (for consistency with other services) * * @method setApplicationInstance * @param {Application} application The root application instance */ setApplicationInstance(application) { this.applicationInstance = application; } /** * Wrap an onClick handler to automatically pass menuItem and universe as parameters * * @private * @method #wrapOnClickHandler * @param {Function} onClick The original onClick function * @param {Object} menuItem The menu item object * @returns {Function} Wrapped onClick function */ #wrapOnClickHandler(onClick, menuItem) { if (typeof onClick !== 'function') { return onClick; } const universe = this.universe; return function () { return onClick(menuItem, universe); }; } /** * Normalize a menu item input to a plain object * * @private * @method #normalizeMenuItem * @param {MenuItem|String|Object} input MenuItem instance, title, or object * @param {String} route Optional route * @param {Object} options Optional options * @returns {Object} Normalized menu item object */ #normalizeMenuItem(input, route = null, options = {}) { let menuItemObj; if (input instanceof MenuItem) { menuItemObj = input.toObject(); } else if (typeof input === 'object' && input !== null && !input.title) { menuItemObj = input; } else if (typeof input === 'string') { const menuItem = new MenuItem(input, route); // Apply options Object.keys(options).forEach((key) => { if (key === 'icon') menuItem.withIcon(options[key]); else if (key === 'priority') menuItem.withPriority(options[key]); else if (key === 'component') menuItem.withComponent(options[key]); else if (key === 'slug') menuItem.withSlug(options[key]); else if (key === 'section') menuItem.inSection(options[key]); else if (key === 'index') menuItem.atIndex(options[key]); else if (key === 'type') menuItem.withType(options[key]); else if (key === 'wrapperClass') menuItem.withWrapperClass(options[key]); else if (key === 'queryParams') menuItem.withQueryParams(options[key]); else if (key === 'onClick') menuItem.onClick(options[key]); else menuItem.setOption(key, options[key]); }); menuItemObj = menuItem.toObject(); } else { menuItemObj = input; } // Wrap onClick handler to automatically pass menuItem and universe if (menuItemObj && typeof menuItemObj.onClick === 'function') { menuItemObj.onClick = this.#wrapOnClickHandler(menuItemObj.onClick, menuItemObj); } return menuItemObj; } /** * Normalize a menu panel input to a plain object * * @private * @method #normalizeMenuPanel * @param {MenuPanel|String|Object} input MenuPanel instance, title, or object * @param {Array} items Optional items * @param {Object} options Optional options * @returns {Object} Normalized menu panel object */ #normalizeMenuPanel(input, items = [], options = {}) { if (input instanceof MenuPanel) { return input.toObject(); } if (typeof input === 'object' && input !== null && !input.title) { return input; } if (typeof input === 'string') { const panel = new MenuPanel(input, items); if (options.slug) panel.withSlug(options.slug); if (options.icon) panel.withIcon(options.icon); if (options.priority) panel.withPriority(options.priority); return panel.toObject(); } return input; } // ============================================================================ // Registration Methods // ============================================================================ /** * Register a header menu item * * @method registerHeaderMenuItem * @param {MenuItem|String} menuItemOrTitle MenuItem instance or title * @param {String} route Optional route (if first param is string) * @param {Object} options Optional options (if first param is string) */ registerHeaderMenuItem(itemOrTitle, route = null, options = {}) { const menuItem = this.#normalizeMenuItem(itemOrTitle, route, options); this.registry.register('header', 'menu-item', menuItem.slug, menuItem); // Auto-register each shortcut as a first-class header menu item so that // they appear in the customiser's "All Extensions" list and can be found // by id in allItems when pinned to the bar. if (isArray(menuItem.shortcuts)) { for (const sc of menuItem.shortcuts) { const scId = sc.id ?? dasherize(menuItem.id + '-sc-' + sc.title); const scSlug = sc.slug ?? scId; // Build a first-class item that supports the full MenuItem // property surface. Each property falls back to the parent's // value so shortcuts inherit sensible defaults without the // consumer having to repeat them. const scItem = { // ── Identity ────────────────────────────────────────────── id: scId, slug: scSlug, title: sc.title, text: sc.text ?? sc.title, label: sc.label ?? sc.title, view: sc.view ?? scId, // ── Routing ─────────────────────────────────────────────── route: sc.route ?? menuItem.route, section: sc.section ?? null, queryParams: sc.queryParams ?? {}, routeParams: sc.routeParams ?? [], // ── Icons (full surface) ────────────────────────────────── icon: sc.icon ?? menuItem.icon, iconPrefix: sc.iconPrefix ?? menuItem.iconPrefix, iconSize: sc.iconSize ?? menuItem.iconSize ?? null, iconClass: sc.iconClass ?? menuItem.iconClass ?? null, iconComponent: sc.iconComponent ?? null, iconComponentOptions: sc.iconComponentOptions ?? {}, // ── Metadata ────────────────────────────────────────────── description: sc.description ?? null, // Shortcuts inherit parent tags so they surface under the // same search terms; shortcut-specific tags take precedence. tags: isArray(sc.tags) ? sc.tags : isArray(menuItem.tags) ? menuItem.tags : null, // ── Behaviour ───────────────────────────────────────────── onClick: sc.onClick ?? null, disabled: sc.disabled ?? false, type: sc.type ?? 'default', buttonType: sc.buttonType ?? null, // ── Styling ─────────────────────────────────────────────── class: sc.class ?? null, inlineClass: sc.inlineClass ?? null, wrapperClass: sc.wrapperClass ?? null, // ── Internal flags ──────────────────────────────────────── _isShortcut: true, _parentTitle: menuItem.title, _parentId: menuItem.id, priority: (menuItem.priority ?? 0) + 1, _isMenuItem: true, }; this.registry.register('header', 'menu-item', scSlug, scItem); this.trigger('menuItem.registered', scItem, 'header'); } } // Trigger event for backward compatibility this.trigger('menuItem.registered', menuItem, 'header'); } /** * Register an admin menu item * * @method registerAdminMenuItem * @param {MenuItem|String} itemOrTitle MenuItem instance or title * @param {String} route Optional route (if first param is string) * @param {Object} options Optional options (if first param is string) */ registerAdminMenuItem(itemOrTitle, route = null, options = {}) { const menuItem = this.#normalizeMenuItem(itemOrTitle, route, options); this.registry.register('console:admin', 'menu-item', menuItem.slug, menuItem); // Trigger event for backward compatibility this.trigger('menuItem.registered', menuItem, 'console:admin'); } /** * Register an organization menu item * * @method registerOrganizationMenuItem * @param {MenuItem|String} menuItemOrTitle MenuItem instance or title * @param {Object} options Optional options */ registerOrganizationMenuItem(menuItemOrTitle, options = {}) { const menuItem = this.#normalizeMenuItem(menuItemOrTitle, options.route || 'console.virtual', options); if (!menuItem.section) { menuItem.section = 'settings'; } this.registry.register('console:account', 'menu-item', `organization:${menuItem.slug}`, menuItem); } /** * Register a user menu item * * @method registerUserMenuItem * @param {MenuItem|String} menuItemOrTitle MenuItem instance or title * @param {Object} options Optional options */ registerUserMenuItem(menuItemOrTitle, options = {}) { const menuItem = this.#normalizeMenuItem(menuItemOrTitle, options.route || 'console.virtual', options); if (!menuItem.section) { menuItem.section = 'account'; } this.registry.register('console:account', 'menu-item', `user:${menuItem.slug}`, menuItem); } /** * Register an admin menu panel * * @method registerAdminMenuPanel * @param {MenuPanel|String} panelOrTitle MenuPanel instance or title * @param {Array} items Optional items array (if first param is string) * @param {Object} options Optional options (if first param is string) */ registerAdminMenuPanel(panelOrTitle, items = [], options = {}) { const panel = this.#normalizeMenuPanel(panelOrTitle, items, options); this.registry.register('console:admin', 'menu-panel', panel.slug, panel); // The PDF states: "Additionally registering menu panels should also register there items." // We assume the items are passed in the panel object or items array. if (panel.items && panel.items.length) { panel.items = panel.items.map((item) => { const menuItem = this.#normalizeMenuItem(item); // CRITICAL: Original behavior for panel items: // - slug = panel slug (e.g., 'fleet-ops') ← Used in URL // - view = item slug (e.g., 'navigator-app') ← Used in query param // - section = null (not used for panel items) // Result: /admin/fleet-ops?view=navigator-app const itemSlug = menuItem.slug; // Save the original item slug menuItem.slug = panel.slug; // Set slug to panel slug for URL menuItem.view = itemSlug; // Set view to item slug for query param menuItem.section = null; // Panel items don't use section // Mark as panel item to prevent duplication in main menu menuItem._isPanelItem = true; menuItem._panelSlug = panel.slug; // Register with the item slug as key (for lookup) this.registry.register('console:admin', 'menu-item', itemSlug, menuItem); // Trigger event for backward compatibility this.trigger('menuItem.registered', menuItem, 'console:admin'); // Return the modified menu item so panel.items gets updated return menuItem; }); } // Trigger event for backward compatibility this.trigger('menuPanel.registered', panel, 'console:admin'); } /** * Register a settings menu item * * @method registerSettingsMenuItem * @param {MenuItem|String} menuItemOrTitle MenuItem instance or title * @param {Object} options Optional options */ registerSettingsMenuItem(menuItemOrTitle, options = {}) { const menuItem = this.#normalizeMenuItem(menuItemOrTitle, options.route || 'console.settings.virtual', options); this.registry.register('console:settings', 'menu-item', menuItem.slug, menuItem); } /** * Register a menu item to a custom registry * * Supports two patterns: * 1. Original: registerMenuItem(registryName, title, options) * 2. New: registerMenuItem(registryName, menuItemInstance) * * @method registerMenuItem * @param {String} registryName Registry name (e.g., 'auth:login', 'engine:fleet-ops') * @param {String|MenuItem} titleOrMenuItem Menu item title string or MenuItem instance * @param {Object} options Optional options (only used with title string) */ registerMenuItem(registryName, titleOrMenuItem, options = {}) { let menuItem; // Normalize the menu item first (handles both MenuItem instances and string titles) if (titleOrMenuItem instanceof MenuItem) { menuItem = this.#normalizeMenuItem(titleOrMenuItem); } else { // Original pattern: title string + options const title = titleOrMenuItem; const route = options.route || `console.${dasherize(registryName)}.virtual`; // Set defaults matching original behavior const slug = options.slug || '~'; menuItem = this.#normalizeMenuItem(title, route, { ...options, slug, }); } // Apply finalView normalization consistently for ALL menu items // If slug === view, set view to null to prevent redundant query params // This matches the legacy behavior: const finalView = (slug === view) ? null : view; if (menuItem.slug && menuItem.view && menuItem.slug === menuItem.view) { menuItem.view = null; } // Register the menu item this.registry.register(registryName, 'menu-item', menuItem.slug || menuItem.title, menuItem); // Trigger event this.trigger('menuItem.registered', menuItem, registryName); } // ============================================================================ // Getter Methods (Improved DX) // ============================================================================ /** * Get menu items from a registry * * @method getMenuItems * @param {String} registryName Registry name (e.g., 'engine:fleet-ops') * @returns {Array} Menu items */ getMenuItems(registryName) { return this.registry.getRegistry(registryName, 'menu-item'); } /** * Get menu panels from a registry * * @method getMenuPanels * @param {String} registryName Registry name (e.g., 'engine:fleet-ops') * @returns {Array} Menu panels */ getMenuPanels(registryName) { return this.registry.getRegistry(registryName, 'menu-panel'); } /** * Lookup a menu item from a registry * * @method lookupMenuItem * @param {String} registryName Registry name * @param {String} slug Menu item slug * @param {String} view Optional view * @param {String} section Optional section * @returns {Object|null} Menu item or null */ lookupMenuItem(registryName, slug, view = null, section = null) { const items = this.getMenuItems(registryName); return items.find((item) => { const slugMatch = item.slug === slug; const viewMatch = !view || item.view === view; const sectionMatch = !section || item.section === section; return slugMatch && viewMatch && sectionMatch; }); } /** * Alias for lookupMenuItem * * @method getMenuItem * @param {String} registryName Registry name * @param {String} slug Menu item slug * @param {String} view Optional view * @param {String} section Optional section * @returns {Object|null} Menu item or null */ getMenuItem(registryName, slug, view = null, section = null) { return this.lookupMenuItem(registryName, slug, view, section); } /** * Get header menu items * * @method getHeaderMenuItems * @returns {Array} Header menu items sorted by priority */ getHeaderMenuItems() { const items = this.registry.getRegistry('header', 'menu-item'); // Sort extensions by priority first, then append shortcuts at the end. // Shortcuts must never interleave with extensions in the sorted list // because the default bar is built by slicing the first N items — if // shortcuts sort between extensions (e.g. priority 1.1 between 1 and 2) // they would displace real extensions from the default pinned bar. const extensions = A(items) .filter((i) => !i._isShortcut) .sortBy('priority'); const shortcuts = A(items).filter((i) => i._isShortcut); return A([...extensions, ...shortcuts]); } /** * Get organization menu items * * @method getOrganizationMenuItems * @returns {Array} Organization menu items */ getOrganizationMenuItems() { return this.registry.getRegistry('console:account', 'menu-item'); } /** * Get user menu items * * @method getUserMenuItems * @returns {Array} User menu items */ getUserMenuItems() { return this.registry.getRegistry('console:account', 'menu-item'); } /** * Get admin menu panels * * @method getAdminMenuPanels * @returns {Array} Admin panels sorted by priority */ getAdminMenuPanels() { const panels = this.registry.getRegistry('console:admin', 'menu-panel'); return A(panels).sortBy('priority'); } /** * Alias for getAdminMenuPanels * * @method getAdminPanels * @returns {Array} Admin panels */ getAdminPanels() { return this.getAdminMenuPanels(); } /** * Get admin menu items * Excludes items that belong to panels (to prevent duplication) * * @method getAdminMenuItems * @returns {Array} Admin menu items (excluding panel items) */ getAdminMenuItems() { const items = this.registry.getRegistry('console:admin', 'menu-item'); // Filter out panel items to prevent duplication in the UI return items.filter((item) => !item._isPanelItem); } /** * Get menu items from a specific panel * * @method getMenuItemsFromPanel * @param {String} panelSlug Panel slug * @returns {Array} Menu items belonging to the panel */ getMenuItemsFromPanel(panelSlug) { const items = this.registry.getRegistry('console:admin', 'menu-item'); return items.filter((item) => item._panelSlug === panelSlug); } /** * Get settings menu items * * @method getSettingsMenuItems * @returns {Array} Settings menu items */ getSettingsMenuItems() { return this.registry.getRegistry('console:settings', 'menu-item'); } /** * Get settings menu panels * * @method getSettingsMenuPanels * @returns {Array} Settings menu panels */ getSettingsMenuPanels() { const panels = this.registry.getRegistry('console:settings', 'menu-panel'); return A(panels).sortBy('priority'); } // ============================================================================ // Computed Getters (for template access) // ============================================================================ /** * Get header menu items (computed getter) * * @computed headerMenuItems * @returns {Array} Header menu items */ get headerMenuItems() { return this.getHeaderMenuItems(); } /** * Get organization menu items (computed getter) * * @computed organizationMenuItems * @returns {Array} Organization menu items */ get organizationMenuItems() { return this.getOrganizationMenuItems(); } /** * Get user menu items (computed getter) * * @computed userMenuItems * @returns {Array} User menu items */ get userMenuItems() { return this.getUserMenuItems(); } /** * Get admin menu items (computed getter) * * @computed adminMenuItems * @returns {Array} Admin menu items */ get adminMenuItems() { return this.getAdminMenuItems(); } /** * Get admin menu panels (computed getter) * * @computed adminMenuPanels * @returns {Array} Admin menu panels */ get adminMenuPanels() { return this.getAdminMenuPanels(); } /** * Get settings menu items (computed getter) * * @computed settingsMenuItems * @returns {Array} Settings menu items */ get settingsMenuItems() { return this.getSettingsMenuItems(); } /** * Get settings menu panels (computed getter) * * @computed settingsMenuPanels * @returns {Array} Settings menu panels */ get settingsMenuPanels() { return this.getSettingsMenuPanels(); } }