Skip to content
87 changes: 77 additions & 10 deletions addon/contracts/menu-item.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import BaseContract from './base-contract';
import ExtensionComponent from './extension-component';
import { dasherize } from '@ember/string';
import { isArray } from '@ember/array';
import isObject from '../utils/is-object';

/**
Expand Down Expand Up @@ -123,6 +124,11 @@ export default class MenuItem extends BaseContract {
// plain object with at minimum { title, route } and optionally
// { icon, iconPrefix, id }. Optional – defaults to null.
this.shortcuts = definition.shortcuts || null;

// An array of string tags used to improve search discoverability in
// the overflow dropdown. e.g. ['logistics', 'tracking', 'fleet'].
// Optional – defaults to null.
this.tags = isArray(definition.tags) ? definition.tags : definition.tags ? [definition.tags] : null;
} else {
// Handle string title with optional route (chaining pattern)
this.title = titleOrDefinition;
Expand Down Expand Up @@ -178,6 +184,7 @@ export default class MenuItem extends BaseContract {
// ── Phase 2 additions ──────────────────────────────────────────
this.description = null;
this.shortcuts = null;
this.tags = null;
}

// Call setup() to trigger validation after properties are set
Expand Down Expand Up @@ -388,14 +395,40 @@ export default class MenuItem extends BaseContract {
}

/**
* Set an array of shortcut items displayed beneath the extension in the
* multi-column overflow dropdown. Each shortcut is a plain object:
* Set an array of shortcut items displayed as independent sibling cards in
* the multi-column overflow dropdown (AWS Console style). Each shortcut
* supports the full MenuItem property surface:
*
* Required:
* title {String} Display label
*
* Routing:
* route {String} Ember route name
* queryParams {Object}
* routeParams {Array}
*
* Identity:
* id {String} Explicit id (auto-dasherized from title if omitted)
* slug {String} URL slug (falls back to id)
*
* Icons:
* icon {String} FontAwesome icon name
* iconPrefix {String} FA prefix (e.g. 'far', 'fab')
* iconSize {String} FA size string
* iconClass {String} Extra CSS class on the icon element
* iconComponent {String} Lazy-loaded engine component path
* iconComponentOptions {Object}
*
* { title, route, icon?, iconPrefix?, id? }
* Metadata:
* description {String} Short description shown in the card
* tags {String[]} Search tags
*
* Shortcuts are purely navigational – they do not support onClick handlers.
* They are rendered as compact links inside the extension card in the
* dropdown and can be individually pinned to the navigation bar.
* Behaviour:
* onClick {Function} Click handler (receives the shortcut item)
* disabled {Boolean}
*
* Shortcuts are registered as first-class header menu items at boot time
* and can be individually pinned to the navigation bar.
*
* @method withShortcuts
* @param {Array<Object>} shortcuts Array of shortcut definition objects
Expand All @@ -404,16 +437,47 @@ export default class MenuItem extends BaseContract {
* @example
* new MenuItem('Fleet-Ops', 'console.fleet-ops')
* .withShortcuts([
* { title: 'Scheduler', route: 'console.fleet-ops.scheduler', icon: 'calendar' },
* { title: 'Order Config', route: 'console.fleet-ops.order-configs', icon: 'gear' },
* {
* title: 'Scheduler',
* route: 'console.fleet-ops.scheduler',
* icon: 'calendar',
* description: 'Plan and visualise driver schedules',
* tags: ['schedule', 'calendar'],
* },
* {
* title: 'Live Map',
* route: 'console.fleet-ops',
* iconComponent: 'fleet-ops@components/live-map-icon',
* description: 'Real-time vehicle tracking',
* },
* ])
*/
withShortcuts(shortcuts) {
this.shortcuts = Array.isArray(shortcuts) ? shortcuts : null;
this.shortcuts = isArray(shortcuts) ? shortcuts : null;
this._options.shortcuts = this.shortcuts;
return this;
}

/**
* Set an array of string tags for this menu item.
* Tags are matched against the search query in the overflow dropdown,
* making items discoverable even when the query doesn't match the title
* or description.
*
* @method withTags
* @param {String|String[]} tags One tag string or an array of tag strings
* @returns {MenuItem} This instance for chaining
*
* @example
* new MenuItem('Fleet-Ops', 'console.fleet-ops')
* .withTags(['logistics', 'tracking', 'fleet', 'drivers'])
*/
withTags(tags) {
this.tags = isArray(tags) ? tags : tags ? [tags] : null;
this._options.tags = this.tags;
return this;
}

/**
* Add a single shortcut to the existing shortcuts array.
* Creates the array if it does not yet exist.
Expand All @@ -428,7 +492,7 @@ export default class MenuItem extends BaseContract {
* .addShortcut({ title: 'Order Config', route: 'console.fleet-ops.order-configs' })
*/
addShortcut(shortcut) {
if (!Array.isArray(this.shortcuts)) {
if (!isArray(this.shortcuts)) {
this.shortcuts = [];
}
this.shortcuts = [...this.shortcuts, shortcut];
Expand Down Expand Up @@ -502,6 +566,9 @@ export default class MenuItem extends BaseContract {
// Optional array of shortcut sub-links shown inside the extension card
shortcuts: this.shortcuts,

// Optional array of string tags for search discoverability
tags: this.tags,

// Indicator flag
_isMenuItem: true,

Expand Down
66 changes: 65 additions & 1 deletion addon/services/universe/menu-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ 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 } from '@ember/array';
import { A, isArray } from '@ember/array';
import MenuItem from '../../contracts/menu-item';
import MenuPanel from '../../contracts/menu-panel';

Expand Down Expand Up @@ -153,6 +153,70 @@ export default class MenuService extends Service.extend(Evented) {
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');
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@fleetbase/ember-core",
"version": "0.3.13",
"version": "0.3.14",
"description": "Provides all the core services, decorators and utilities for building a Fleetbase extension for the Console.",
"keywords": [
"fleetbase-core",
Expand Down
Loading