diff --git a/README.md b/README.md index 5e878ef..653d868 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ An instance initializer registers the descriptors with the `resource-registry` s Opening a resource loads the Fleet-Ops engine on demand and hands the record to its action service, so the engine bundle is only fetched when someone clicks. When Fleet-Ops itself boots it replaces these descriptors with its own, which open the same panels directly. -The helpers behind the descriptors and the styled placeholder images are exported from `@fleetbase/fleetops-data/utils/resource-descriptors` and `@fleetbase/fleetops-data/utils/placeholder-images` for other packages that build descriptors. +The helpers behind the descriptors and the styled placeholder images are exported from `@fleetbase/fleetops-data/utils/resource-descriptors` and `@fleetbase/fleetops-data/utils/placeholder-images` for other packages that build descriptors. `@fleetbase/fleetops-data/utils/identity-stub` builds a stand-in record for a relation a row only knows by name (`driver_name`, `pickupName`), so an identity cell can render it and load the real record on demand. ## Contributing diff --git a/addon/utils/identity-stub.js b/addon/utils/identity-stub.js new file mode 100644 index 0000000..ee67944 --- /dev/null +++ b/addon/utils/identity-stub.js @@ -0,0 +1,56 @@ +import { get } from '@ember/object'; + +const inflight = new WeakMap(); + +/** + * A stand-in for a related record a row only knows by name: enough for an + * identity cell, pill or summary to render (`name`, `resourceType`) and a + * `loadResource()` they call when the real record is needed. The stub never + * carries a UUID in an identifier field, so nothing renders one as a plate + * or an id, and concurrent loads of the same row share one request. + * + * `load` receives the row and returns the record (or a promise of it); with + * no `load`, `loadResource()` resolves to null and the stub stays static. + */ +export function buildIdentityStub(row, { type, nameKey = `${type}_name`, name, load, extra = {} } = {}) { + const label = name ?? (row ? get(row, nameKey) : null); + + if (!label) { + return null; + } + + return { + ...extra, + name: label, + display_name: label, + displayName: label, + resourceType: type, + isIdentityStub: true, + loadResource: async () => { + if (typeof load !== 'function') { + return null; + } + + let loads = inflight.get(row); + + if (!loads) { + loads = new Map(); + inflight.set(row, loads); + } + + if (!loads.has(type)) { + loads.set( + type, + Promise.resolve() + .then(() => load(row)) + .catch(() => null) + .finally(() => loads.delete(type)) + ); + } + + return loads.get(type); + }, + }; +} + +export default buildIdentityStub; diff --git a/addon/utils/resource-descriptors/helpers.js b/addon/utils/resource-descriptors/helpers.js index 07e88f7..3948d4c 100644 --- a/addon/utils/resource-descriptors/helpers.js +++ b/addon/utils/resource-descriptors/helpers.js @@ -107,11 +107,10 @@ export function typeLabel(value) { return null; } - const head = String(value) - .split(':')[0] - .replace(/-fleet-ops$/, '') - .replace(/[-_]+/g, ' ') - .trim(); + const [prefix, ...rest] = String(value).split(':'); + // "customer-fleet-ops:contact" reads as "Customer"; a bare "fleet-ops:contact" has + // nothing before the extension name, so it reads as the type after the colon. + const head = (prefix.replace(/-?fleet-ops$/, '') || rest.join(':')).replace(/[-_]+/g, ' ').trim(); return head ? head.charAt(0).toUpperCase() + head.slice(1) : null; } @@ -138,7 +137,18 @@ export function polymorphicType(record, relationName, typeAttr) { /** The universe extension manager, through either of its service names. */ export function extensionManager(owner) { - return lookupService(owner, 'universe/extension-manager') ?? lookupService(owner, 'universe')?.extensionManager ?? null; + const direct = lookupService(owner, 'universe/extension-manager'); + + if (direct) { + return direct; + } + + // The universe service resolves its manager lazily and throws on a host that has none. + try { + return lookupService(owner, 'universe')?.extensionManager ?? null; + } catch { + return null; + } } /** Whether an engine is installed, so a resource it owns has somewhere to open. */ diff --git a/addon/components/cell/contact-identity.hbs b/app/components/cell/contact-identity.hbs similarity index 100% rename from addon/components/cell/contact-identity.hbs rename to app/components/cell/contact-identity.hbs diff --git a/app/components/cell/contact-identity.js b/app/components/cell/contact-identity.js deleted file mode 100644 index 3a97c2a..0000000 --- a/app/components/cell/contact-identity.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/fleetops-data/components/cell/contact-identity'; diff --git a/addon/components/cell/customer-identity.hbs b/app/components/cell/customer-identity.hbs similarity index 100% rename from addon/components/cell/customer-identity.hbs rename to app/components/cell/customer-identity.hbs diff --git a/app/components/cell/customer-identity.js b/app/components/cell/customer-identity.js deleted file mode 100644 index 7832618..0000000 --- a/app/components/cell/customer-identity.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/fleetops-data/components/cell/customer-identity'; diff --git a/addon/components/cell/driver-identity.hbs b/app/components/cell/driver-identity.hbs similarity index 100% rename from addon/components/cell/driver-identity.hbs rename to app/components/cell/driver-identity.hbs diff --git a/app/components/cell/driver-identity.js b/app/components/cell/driver-identity.js deleted file mode 100644 index 431d0ec..0000000 --- a/app/components/cell/driver-identity.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/fleetops-data/components/cell/driver-identity'; diff --git a/addon/components/cell/fleet-identity.hbs b/app/components/cell/fleet-identity.hbs similarity index 100% rename from addon/components/cell/fleet-identity.hbs rename to app/components/cell/fleet-identity.hbs diff --git a/app/components/cell/fleet-identity.js b/app/components/cell/fleet-identity.js deleted file mode 100644 index 263b22b..0000000 --- a/app/components/cell/fleet-identity.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/fleetops-data/components/cell/fleet-identity'; diff --git a/addon/components/cell/order-identity.hbs b/app/components/cell/order-identity.hbs similarity index 100% rename from addon/components/cell/order-identity.hbs rename to app/components/cell/order-identity.hbs diff --git a/app/components/cell/order-identity.js b/app/components/cell/order-identity.js deleted file mode 100644 index 4bb3613..0000000 --- a/app/components/cell/order-identity.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/fleetops-data/components/cell/order-identity'; diff --git a/addon/components/cell/place-identity.hbs b/app/components/cell/place-identity.hbs similarity index 100% rename from addon/components/cell/place-identity.hbs rename to app/components/cell/place-identity.hbs diff --git a/app/components/cell/place-identity.js b/app/components/cell/place-identity.js deleted file mode 100644 index eb31bd1..0000000 --- a/app/components/cell/place-identity.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/fleetops-data/components/cell/place-identity'; diff --git a/addon/components/cell/vehicle-identity.hbs b/app/components/cell/vehicle-identity.hbs similarity index 100% rename from addon/components/cell/vehicle-identity.hbs rename to app/components/cell/vehicle-identity.hbs diff --git a/app/components/cell/vehicle-identity.js b/app/components/cell/vehicle-identity.js deleted file mode 100644 index 9e54d46..0000000 --- a/app/components/cell/vehicle-identity.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/fleetops-data/components/cell/vehicle-identity'; diff --git a/addon/components/cell/vendor-identity.hbs b/app/components/cell/vendor-identity.hbs similarity index 100% rename from addon/components/cell/vendor-identity.hbs rename to app/components/cell/vendor-identity.hbs diff --git a/app/components/cell/vendor-identity.js b/app/components/cell/vendor-identity.js deleted file mode 100644 index 9ef5d6c..0000000 --- a/app/components/cell/vendor-identity.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/fleetops-data/components/cell/vendor-identity'; diff --git a/addon/components/contact/pill.hbs b/app/components/contact/pill.hbs similarity index 100% rename from addon/components/contact/pill.hbs rename to app/components/contact/pill.hbs diff --git a/app/components/contact/pill.js b/app/components/contact/pill.js deleted file mode 100644 index c7a297a..0000000 --- a/app/components/contact/pill.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/fleetops-data/components/contact/pill'; diff --git a/addon/components/contact/summary.hbs b/app/components/contact/summary.hbs similarity index 100% rename from addon/components/contact/summary.hbs rename to app/components/contact/summary.hbs diff --git a/app/components/contact/summary.js b/app/components/contact/summary.js deleted file mode 100644 index 7e7c800..0000000 --- a/app/components/contact/summary.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/fleetops-data/components/contact/summary'; diff --git a/addon/components/customer/pill.hbs b/app/components/customer/pill.hbs similarity index 100% rename from addon/components/customer/pill.hbs rename to app/components/customer/pill.hbs diff --git a/app/components/customer/pill.js b/app/components/customer/pill.js deleted file mode 100644 index aea27bb..0000000 --- a/app/components/customer/pill.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/fleetops-data/components/customer/pill'; diff --git a/addon/components/customer/summary.hbs b/app/components/customer/summary.hbs similarity index 100% rename from addon/components/customer/summary.hbs rename to app/components/customer/summary.hbs diff --git a/app/components/customer/summary.js b/app/components/customer/summary.js deleted file mode 100644 index 899a942..0000000 --- a/app/components/customer/summary.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/fleetops-data/components/customer/summary'; diff --git a/addon/components/driver/pill.hbs b/app/components/driver/pill.hbs similarity index 100% rename from addon/components/driver/pill.hbs rename to app/components/driver/pill.hbs diff --git a/app/components/driver/pill.js b/app/components/driver/pill.js deleted file mode 100644 index 3490441..0000000 --- a/app/components/driver/pill.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/fleetops-data/components/driver/pill'; diff --git a/addon/components/driver/summary.hbs b/app/components/driver/summary.hbs similarity index 100% rename from addon/components/driver/summary.hbs rename to app/components/driver/summary.hbs diff --git a/app/components/driver/summary.js b/app/components/driver/summary.js deleted file mode 100644 index e96be83..0000000 --- a/app/components/driver/summary.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/fleetops-data/components/driver/summary'; diff --git a/addon/components/fleet/pill.hbs b/app/components/fleet/pill.hbs similarity index 100% rename from addon/components/fleet/pill.hbs rename to app/components/fleet/pill.hbs diff --git a/app/components/fleet/pill.js b/app/components/fleet/pill.js deleted file mode 100644 index d849910..0000000 --- a/app/components/fleet/pill.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/fleetops-data/components/fleet/pill'; diff --git a/addon/components/fleet/summary.hbs b/app/components/fleet/summary.hbs similarity index 100% rename from addon/components/fleet/summary.hbs rename to app/components/fleet/summary.hbs diff --git a/app/components/fleet/summary.js b/app/components/fleet/summary.js deleted file mode 100644 index e8a90c5..0000000 --- a/app/components/fleet/summary.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/fleetops-data/components/fleet/summary'; diff --git a/addon/components/order/pill.hbs b/app/components/order/pill.hbs similarity index 100% rename from addon/components/order/pill.hbs rename to app/components/order/pill.hbs diff --git a/app/components/order/pill.js b/app/components/order/pill.js deleted file mode 100644 index f3ceb03..0000000 --- a/app/components/order/pill.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/fleetops-data/components/order/pill'; diff --git a/addon/components/order/summary.hbs b/app/components/order/summary.hbs similarity index 100% rename from addon/components/order/summary.hbs rename to app/components/order/summary.hbs diff --git a/app/components/order/summary.js b/app/components/order/summary.js deleted file mode 100644 index 24bf123..0000000 --- a/app/components/order/summary.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/fleetops-data/components/order/summary'; diff --git a/addon/components/place/pill.hbs b/app/components/place/pill.hbs similarity index 100% rename from addon/components/place/pill.hbs rename to app/components/place/pill.hbs diff --git a/app/components/place/pill.js b/app/components/place/pill.js deleted file mode 100644 index 2757d3a..0000000 --- a/app/components/place/pill.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/fleetops-data/components/place/pill'; diff --git a/addon/components/place/summary.hbs b/app/components/place/summary.hbs similarity index 100% rename from addon/components/place/summary.hbs rename to app/components/place/summary.hbs diff --git a/app/components/place/summary.js b/app/components/place/summary.js deleted file mode 100644 index 3ee08c9..0000000 --- a/app/components/place/summary.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/fleetops-data/components/place/summary'; diff --git a/addon/components/select-option/contact.hbs b/app/components/select-option/contact.hbs similarity index 100% rename from addon/components/select-option/contact.hbs rename to app/components/select-option/contact.hbs diff --git a/app/components/select-option/contact.js b/app/components/select-option/contact.js deleted file mode 100644 index fd9d008..0000000 --- a/app/components/select-option/contact.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/fleetops-data/components/select-option/contact'; diff --git a/addon/components/select-option/customer.hbs b/app/components/select-option/customer.hbs similarity index 100% rename from addon/components/select-option/customer.hbs rename to app/components/select-option/customer.hbs diff --git a/app/components/select-option/customer.js b/app/components/select-option/customer.js deleted file mode 100644 index 26fd3c3..0000000 --- a/app/components/select-option/customer.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/fleetops-data/components/select-option/customer'; diff --git a/addon/components/select-option/driver.hbs b/app/components/select-option/driver.hbs similarity index 100% rename from addon/components/select-option/driver.hbs rename to app/components/select-option/driver.hbs diff --git a/app/components/select-option/driver.js b/app/components/select-option/driver.js deleted file mode 100644 index a08f3e0..0000000 --- a/app/components/select-option/driver.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/fleetops-data/components/select-option/driver'; diff --git a/addon/components/select-option/fleet.hbs b/app/components/select-option/fleet.hbs similarity index 100% rename from addon/components/select-option/fleet.hbs rename to app/components/select-option/fleet.hbs diff --git a/app/components/select-option/fleet.js b/app/components/select-option/fleet.js deleted file mode 100644 index 3e6eba6..0000000 --- a/app/components/select-option/fleet.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/fleetops-data/components/select-option/fleet'; diff --git a/addon/components/select-option/order.hbs b/app/components/select-option/order.hbs similarity index 100% rename from addon/components/select-option/order.hbs rename to app/components/select-option/order.hbs diff --git a/app/components/select-option/order.js b/app/components/select-option/order.js deleted file mode 100644 index 34dc932..0000000 --- a/app/components/select-option/order.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/fleetops-data/components/select-option/order'; diff --git a/addon/components/select-option/place.hbs b/app/components/select-option/place.hbs similarity index 100% rename from addon/components/select-option/place.hbs rename to app/components/select-option/place.hbs diff --git a/app/components/select-option/place.js b/app/components/select-option/place.js deleted file mode 100644 index d78d889..0000000 --- a/app/components/select-option/place.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/fleetops-data/components/select-option/place'; diff --git a/addon/components/select-option/vehicle.hbs b/app/components/select-option/vehicle.hbs similarity index 100% rename from addon/components/select-option/vehicle.hbs rename to app/components/select-option/vehicle.hbs diff --git a/app/components/select-option/vehicle.js b/app/components/select-option/vehicle.js deleted file mode 100644 index 3c49271..0000000 --- a/app/components/select-option/vehicle.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/fleetops-data/components/select-option/vehicle'; diff --git a/addon/components/select-option/vendor.hbs b/app/components/select-option/vendor.hbs similarity index 100% rename from addon/components/select-option/vendor.hbs rename to app/components/select-option/vendor.hbs diff --git a/app/components/select-option/vendor.js b/app/components/select-option/vendor.js deleted file mode 100644 index da45f9c..0000000 --- a/app/components/select-option/vendor.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/fleetops-data/components/select-option/vendor'; diff --git a/addon/components/vehicle/pill.hbs b/app/components/vehicle/pill.hbs similarity index 100% rename from addon/components/vehicle/pill.hbs rename to app/components/vehicle/pill.hbs diff --git a/app/components/vehicle/pill.js b/app/components/vehicle/pill.js deleted file mode 100644 index e1f78a1..0000000 --- a/app/components/vehicle/pill.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/fleetops-data/components/vehicle/pill'; diff --git a/addon/components/vehicle/summary.hbs b/app/components/vehicle/summary.hbs similarity index 100% rename from addon/components/vehicle/summary.hbs rename to app/components/vehicle/summary.hbs diff --git a/app/components/vehicle/summary.js b/app/components/vehicle/summary.js deleted file mode 100644 index 5dae0f5..0000000 --- a/app/components/vehicle/summary.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/fleetops-data/components/vehicle/summary'; diff --git a/addon/components/vendor/pill.hbs b/app/components/vendor/pill.hbs similarity index 100% rename from addon/components/vendor/pill.hbs rename to app/components/vendor/pill.hbs diff --git a/app/components/vendor/pill.js b/app/components/vendor/pill.js deleted file mode 100644 index fa810e5..0000000 --- a/app/components/vendor/pill.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/fleetops-data/components/vendor/pill'; diff --git a/addon/components/vendor/summary.hbs b/app/components/vendor/summary.hbs similarity index 100% rename from addon/components/vendor/summary.hbs rename to app/components/vendor/summary.hbs diff --git a/app/components/vendor/summary.js b/app/components/vendor/summary.js deleted file mode 100644 index b9f4eee..0000000 --- a/app/components/vendor/summary.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/fleetops-data/components/vendor/summary'; diff --git a/console/node_modules/.pnpm/@fleetbase+ember-core@0.3.25_@ember+string@3.1.1_@ember+test-helpers@3.3.1_@babel+core@_cb240661001c0e719c8b5c498e670661/node_modules/@fleetbase/ember-core/addon/adapters/application.js.html b/console/node_modules/.pnpm/@fleetbase+ember-core@0.3.25_@ember+string@3.1.1_@ember+test-helpers@3.3.1_@babel+core@_cb240661001c0e719c8b5c498e670661/node_modules/@fleetbase/ember-core/addon/adapters/application.js.html new file mode 100644 index 0000000..70224c2 --- /dev/null +++ b/console/node_modules/.pnpm/@fleetbase+ember-core@0.3.25_@ember+string@3.1.1_@ember+test-helpers@3.3.1_@babel+core@_cb240661001c0e719c8b5c498e670661/node_modules/@fleetbase/ember-core/addon/adapters/application.js.html @@ -0,0 +1,796 @@ + + + + +
++ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 | + + + + + + + + + + + + + + +1x + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +86x + +86x +86x +86x + + + + + + + + +86x +86x +86x +86x + + + +86x +86x +86x + + +86x +86x +86x + + + + + + + + + + +86x + +86x + + + +86x + + + +86x + + + +86x +86x + + + + + + + + + + + + + + + + + + + + + + + + + + +23x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import RESTAdapter from '@ember-data/adapter/rest';
+import AdapterError from '@ember-data/adapter/error';
+import { tracked } from '@glimmer/tracking';
+import { inject as service } from '@ember/service';
+import { storageFor } from 'ember-local-storage';
+import { get } from '@ember/object';
+import { isBlank } from '@ember/utils';
+import { isArray } from '@ember/array';
+import { dasherize } from '@ember/string';
+import { pluralize } from 'ember-inflector';
+import { decompress as decompressJson } from 'compress-json';
+import getUserOptions from '../utils/get-user-options';
+import config from 'ember-get-config';
+
+/* istanbul ignore if -- runs at import; whichever of adapters/application or services/fetch loads first sets the host, so the other can never enter */
+if (isBlank(config.API.host)) {
+ config.API.host = `${window.location.protocol}//${window.location.hostname}`;
+}
+const DEFAULT_ERROR_MESSAGE = 'Oops! Something went wrong. Please try again or contact support if the issue persists.';
+export default class ApplicationAdapter extends RESTAdapter {
+ /**
+ * Inject the `session` service
+ *
+ * @var {Service}
+ */
+ @service session;
+
+ /**
+ * Inject the `currentUser` service
+ *
+ * @var {Service}
+ */
+ @service currentUser;
+
+ /**
+ * User options for setting specific headers
+ *
+ * @var StorageObject
+ */
+ @storageFor('user-options') userOptions;
+
+ /**
+ * The default namespace for the adapter
+ *
+ * @var {String}
+ */
+ @tracked host;
+
+ /**
+ * The default namespace for adapter
+ *
+ * @var {String}
+ */
+ @tracked namespace;
+
+ /**
+ * Credentials
+ *
+ * @var {String}
+ */
+ @tracked credentials = 'include';
+
+ /**
+ * Mutable headers property.
+ *
+ * @var {Array}
+ */
+ @tracked _headers;
+
+ /**
+ * Creates an instance of ApplicationAdapter.
+ * @memberof ApplicationAdapter
+ */
+ constructor() {
+ super(...arguments);
+
+ this.host = get(config, 'API.host');
+ this.namespace = get(config, 'API.namespace');
+ this.headers = this.setupHeaders();
+ }
+
+ /**
+ * Setup headers that should be sent with request.
+ *
+ * @return {Object}
+ */
+ setupHeaders() {
+ const headers = {};
+ const userId = this.session.data.authenticated.user;
+ const userOptions = getUserOptions();
+ const isSandbox = get(userOptions, `${userId}:sandbox`) === true;
+ // `currentUser.setOption` dasherizes before storing, so the key written
+ // by `setOption('testKey', …)` is `<user>:test-key`. Reading `testKey`
+ // never matched anything, so this header was never sent.
+ const testKey = get(userOptions, `${userId}:test-key`);
+ let isAuthenticated = this.session.isAuthenticated;
+ let { token } = this.session.data.authenticated;
+
+ // If the session data is not yet available, check localStorage
+ Eif (!isAuthenticated) {
+ const localStorageSession = JSON.parse(window.localStorage.getItem('ember_simple_auth-session'));
+ Iif (localStorageSession) {
+ const { authenticated } = localStorageSession;
+ if (authenticated) {
+ token = authenticated.token;
+ }
+
+ // Check isAuthenticated again
+ isAuthenticated = !!token;
+ }
+ }
+
+ headers['Content-Type'] = 'application/json';
+
+ Iif (isAuthenticated) {
+ headers['Authorization'] = `Bearer ${token}`;
+ }
+
+ Iif (isAuthenticated && isSandbox) {
+ headers['Access-Console-Sandbox'] = true;
+ }
+
+ Iif (isAuthenticated && !isBlank(testKey)) {
+ headers['Access-Console-Sandbox-Key'] = testKey;
+ }
+
+ this.headers = headers;
+ return this.headers;
+ }
+
+ /**
+ * Configure AJAX options for request, return as options hash
+ *
+ * @param {String} url
+ * @param {String} type The request type GET, POST, PUT, DELETE etc.
+ * @param {Object} options
+ *
+ * @return {Object}
+ */
+ ajaxOptions(url, type, options) {
+ this.setupHeaders();
+
+ const ajaxOptions = super.ajaxOptions(url, type, options);
+ ajaxOptions.credentials = this.credentials;
+
+ return ajaxOptions;
+ }
+
+ /**
+ * Dasherize the path for type
+ *
+ * @param {Object} type
+ */
+ pathForType(type) {
+ return dasherize(pluralize(type));
+ }
+
+ /**
+ * Handles the response from an AJAX request.
+ * It decompresses the payload if needed, checks for error responses, and returns an AdapterError for error scenarios.
+ * For valid responses, the handling is delegated to the superclass's handleResponse method.
+ *
+ * @param {number} status - HTTP status code of the response.
+ * @param {object} headers - Response headers.
+ * @param {object} payload - Response payload.
+ * @param {object} requestData - Original request data.
+ * @return {Object | AdapterError} - The response object or an AdapterError in case of errors.
+ */
+ handleResponse(status, headers, payload, requestData) {
+ const decompressedPayload = this.decompressPayload(payload, headers);
+ if (this.isErrorResponse(status, decompressedPayload)) {
+ const errors = this.getResponseErrors(decompressedPayload);
+ const errorMessage = this.getErrorMessage(errors);
+ return new AdapterError(errors, errorMessage);
+ }
+
+ return super.handleResponse(status, headers, decompressedPayload, requestData);
+ }
+
+ /**
+ * Decompresses the response payload if it's marked as compressed in the response headers.
+ *
+ * This method checks the response headers for a specific 'x-compressed-json' flag.
+ * If this flag is set, indicating that the response payload is compressed, the method
+ * decompresses the payload. The decompressed payload is then parsed as JSON and returned.
+ * If the payload is not compressed, it is returned as is.
+ *
+ * @param {object} payload - The original payload of the response.
+ * @param {object} headers - The headers of the response, used to check if the payload is compressed.
+ * @return {object} The decompressed payload if it was compressed, or the original payload otherwise.
+ */
+ decompressPayload(payload, headers) {
+ // Check if the response is compressed
+ if (headers['x-compressed-json'] === '1' || headers['x-compressed-json'] === 1) {
+ // Decompress the payload
+ const decompressedPayload = decompressJson(payload);
+ // Replace payload with decompressed json payload
+ payload = JSON.parse(decompressedPayload);
+ }
+
+ return payload;
+ }
+
+ /**
+ * Extracts the error message from a list of errors.
+ * Returns a default error message if the provided list is empty or undefined.
+ *
+ * @param {Array} errors - Array of error messages or objects.
+ * @return {string} - The extracted error message.
+ */
+ getErrorMessage(errors = []) {
+ return errors[0] ? errors[0] : DEFAULT_ERROR_MESSAGE;
+ }
+
+ /**
+ * Extracts errors from a payload.
+ * Assumes the payload contains an `errors` array; returns a default error message otherwise.
+ *
+ * @param {object} payload - The response payload.
+ * @return {Array} - Array of extracted errors or a default error message.
+ */
+ getResponseErrors(payload) {
+ return isArray(payload.errors) ? payload.errors : [DEFAULT_ERROR_MESSAGE];
+ }
+
+ /**
+ * Determines if the response status indicates an error.
+ * Checks both the HTTP status code and the presence of errors in the payload.
+ *
+ * @param {number} status - The HTTP status code.
+ * @param {object} payload - The response payload.
+ * @return {boolean} - True if the response indicates an error, false otherwise.
+ */
+ isErrorResponse(status, payload) {
+ return (status >= 400 && status < 600) || (!isBlank(payload) && isArray(payload.errors));
+ }
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| application.js | +
+
+ |
+ 47.91% | +23/48 | +25% | +8/32 | +33.33% | +3/9 | +47.91% | +23/48 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import { tracked } from '@glimmer/tracking';
+
+/**
+ * Base class for all extension contracts
+ * Provides common functionality for validation, serialization, and option management
+ *
+ * Uses a two-phase construction pattern:
+ * 1. Constructor - Sets up initial state
+ * 2. setup() - Called after construction for validation and post-init logic
+ *
+ * @class BaseContract
+ */
+export default class BaseContract {
+ /* istanbul ignore next -- the constructor assigns _options unconditionally, so this default is never read */
+ @tracked _options = {};
+
+ constructor(options = {}) {
+ this._options = { ...options };
+ // Don't validate here - let subclasses set properties first
+ }
+
+ /**
+ * Setup method called after construction
+ * Subclasses should call super.setup() to trigger validation
+ *
+ * @method setup
+ */
+ setup() {
+ this.validate();
+ }
+
+ /**
+ * Validate the contract
+ * Override in subclasses to add specific validation logic
+ *
+ * @method validate
+ */
+ validate() {
+ // Base validation - override in subclasses
+ }
+
+ /**
+ * Get the plain object representation of this contract
+ *
+ * @method toObject
+ * @returns {Object} Plain object representation
+ */
+ toObject() {
+ return { ...this._options };
+ }
+
+ /**
+ * Set an option with method chaining support
+ *
+ * @method setOption
+ * @param {String} key The option key
+ * @param {*} value The option value
+ * @returns {BaseContract} This instance for chaining
+ */
+ setOption(key, value) {
+ this._options[key] = value;
+ return this;
+ }
+
+ /**
+ * Get an option value
+ *
+ * @method getOption
+ * @param {String} key The option key
+ * @param {*} defaultValue Default value if option doesn't exist
+ * @returns {*} The option value or default
+ */
+ getOption(key, defaultValue = null) {
+ return this._options[key] !== undefined ? this._options[key] : defaultValue;
+ }
+
+ /**
+ * Check if an option exists
+ *
+ * @method hasOption
+ * @param {String} key The option key
+ * @returns {Boolean} True if option exists
+ */
+ hasOption(key) {
+ return this._options[key] !== undefined;
+ }
+
+ /**
+ * Remove an option
+ *
+ * @method removeOption
+ * @param {String} key The option key
+ * @returns {BaseContract} This instance for chaining
+ */
+ removeOption(key) {
+ delete this._options[key];
+ return this;
+ }
+
+ /**
+ * Get all options
+ *
+ * @method getOptions
+ * @returns {Object} All options
+ */
+ getOptions() {
+ return { ...this._options };
+ }
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import BaseContract from './base-contract';
+
+/**
+ * Represents a lazy-loadable component from an engine
+ *
+ * This contract defines a component that will be loaded on-demand from an engine,
+ * preserving lazy loading capabilities while allowing cross-engine component usage.
+ *
+ * @class ExtensionComponent
+ * @extends BaseContract
+ *
+ * @example
+ * // Simple usage
+ * new ExtensionComponent('@fleetbase/fleetops-engine', 'components/admin/navigator-app')
+ *
+ * @example
+ * // With options
+ * new ExtensionComponent('@fleetbase/fleetops-engine', {
+ * path: 'components/admin/navigator-app',
+ * loadingComponent: 'loading-spinner',
+ * errorComponent: 'error-display'
+ * })
+ *
+ * @example
+ * // With method chaining
+ * new ExtensionComponent('@fleetbase/fleetops-engine', 'components/widget/metrics')
+ * .withLoadingComponent('skeletons/widget')
+ * .withErrorComponent('error-boundary')
+ * .withData({ refreshInterval: 5000 })
+ */
+export default class ExtensionComponent extends BaseContract {
+ /**
+ * Create a new ExtensionComponent
+ *
+ * @constructor
+ * @param {String} engineName The name of the engine (e.g., '@fleetbase/fleetops-engine')
+ * @param {String|Function|Object} pathClassOrOptions Component path, component class, or options object
+ */
+ constructor(engineName, pathClassOrOptions = {}) {
+ // Handle component class
+ if (typeof pathClassOrOptions === 'function' && pathClassOrOptions.prototype) {
+ const componentClass = pathClassOrOptions;
+ super({
+ engine: engineName,
+ class: componentClass,
+ name: componentClass.name,
+ });
+
+ this.engine = engineName;
+ this.class = componentClass;
+ this.name = componentClass.name;
+ this.path = null; // No path for classes
+ this.isClass = true;
+ this.loadingComponent = null;
+ this.errorComponent = null;
+ return;
+ }
+
+ // Handle string path or options object
+ const options = typeof pathClassOrOptions === 'string' ? { path: pathClassOrOptions } : pathClassOrOptions;
+
+ super({
+ engine: engineName,
+ ...options,
+ });
+
+ this.engine = engineName;
+ this.path = options.path;
+ this.name = options.path; // Add name for parity
+ this.class = null;
+ this.isClass = false;
+ this.loadingComponent = options.loadingComponent || null;
+ this.errorComponent = options.errorComponent || null;
+ }
+
+ /**
+ * Validate the component definition
+ *
+ * @method validate
+ * @throws {Error} If engine name or path/class is missing
+ */
+ validate() {
+ if (!this.engine) {
+ throw new Error('ExtensionComponent requires an engine name');
+ }
+ if (!this.path && !this.class) {
+ throw new Error('ExtensionComponent requires a component path or class');
+ }
+ }
+
+ /**
+ * Set a custom loading component to display while the engine loads
+ *
+ * @method withLoadingComponent
+ * @param {String} componentName Name of the loading component
+ * @returns {ExtensionComponent} This instance for chaining
+ */
+ withLoadingComponent(componentName) {
+ this.loadingComponent = componentName;
+ this._options.loadingComponent = componentName;
+ return this;
+ }
+
+ /**
+ * Set a custom error component to display if loading fails
+ *
+ * @method withErrorComponent
+ * @param {String} componentName Name of the error component
+ * @returns {ExtensionComponent} This instance for chaining
+ */
+ withErrorComponent(componentName) {
+ this.errorComponent = componentName;
+ this._options.errorComponent = componentName;
+ return this;
+ }
+
+ /**
+ * Add custom data to pass to the component when it renders
+ *
+ * @method withData
+ * @param {Object} data Custom data object
+ * @returns {ExtensionComponent} This instance for chaining
+ */
+ withData(data) {
+ this._options.data = data;
+ return this;
+ }
+
+ /**
+ * Set a timeout for loading the component
+ *
+ * @method withTimeout
+ * @param {Number} milliseconds Timeout in milliseconds
+ * @returns {ExtensionComponent} This instance for chaining
+ */
+ withTimeout(milliseconds) {
+ this._options.timeout = milliseconds;
+ return this;
+ }
+
+ /**
+ * Get the plain object representation
+ *
+ * @method toObject
+ * @returns {Object} Plain object with all component definition properties
+ */
+ toObject() {
+ return {
+ engine: this.engine,
+ path: this.path,
+ name: this.name,
+ class: this.class,
+ isClass: this.isClass,
+ loadingComponent: this.loadingComponent,
+ errorComponent: this.errorComponent,
+ ...this._options,
+ };
+ }
+
+ /**
+ * Get the string representation
+ *
+ * @method toString
+ * @returns {String} Plain string component definition properties
+ */
+ toString() {
+ if (this.isClass) {
+ return `#extension-component:${this.engine}:${this.name}`;
+ }
+
+ return `#extension-component:${this.engine}:${this.path}`;
+ }
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 | + + + + + + + + + + + + + + + + + + | import { tracked } from '@glimmer/tracking';
+
+/**
+ * HookRegistry
+ *
+ * Shared registry object for application hooks.
+ * Registered as a singleton in the application container to ensure
+ * all HookService instances (app and engines) share the same hooks.
+ *
+ * @class HookRegistry
+ */
+export default class HookRegistry {
+ /**
+ * Map of hook names to arrays of hook objects
+ * @type {Object}
+ */
+ @tracked hooks = {};
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import BaseContract from './base-contract';
+import { guidFor } from '@ember/object/internals';
+import isObject from '../utils/is-object';
+
+/**
+ * Represents a lifecycle or application hook
+ *
+ * Hooks allow extensions to inject custom logic at specific points in the application lifecycle.
+ *
+ * @class Hook
+ * @extends BaseContract
+ *
+ * @example
+ * // Simple hook with chaining
+ * new Hook('application:before-model', (session, router) => {
+ * if (session.isCustomer) {
+ * router.transitionTo('customer-portal');
+ * }
+ * })
+ *
+ * @example
+ * // Full definition object (first-class)
+ * new Hook({
+ * name: 'application:before-model',
+ * handler: (session, router) => {
+ * if (session.isCustomer) {
+ * router.transitionTo('customer-portal');
+ * }
+ * },
+ * priority: 10,
+ * once: false,
+ * id: 'customer-redirect'
+ * })
+ *
+ * @example
+ * // Hook with method chaining
+ * new Hook('order:before-save')
+ * .withPriority(10)
+ * .once()
+ * .execute(async (order) => {
+ * await validateOrder(order);
+ * })
+ */
+export default class Hook extends BaseContract {
+ /**
+ * Create a new Hook
+ *
+ * @constructor
+ * @param {String|Object} nameOrDefinition Hook name or full definition object
+ * @param {Function} handlerOrOptions Handler function or options object (only used if first param is string)
+ */
+ constructor(nameOrDefinition, handlerOrOptions = null) {
+ // Prepare options for super
+ const options = typeof handlerOrOptions === 'function' ? { handler: handlerOrOptions } : handlerOrOptions || {};
+
+ // Call super FIRST (JavaScript requirement)
+ super(isObject(nameOrDefinition) && nameOrDefinition.name ? nameOrDefinition : { name: nameOrDefinition, ...options });
+
+ // THEN set properties
+ if (isObject(nameOrDefinition) && nameOrDefinition.name) {
+ const definition = nameOrDefinition;
+
+ this.name = definition.name;
+ this.handler = definition.handler || null;
+ this.priority = definition.priority !== undefined ? definition.priority : 0;
+ this.runOnce = definition.once || false;
+ this.id = definition.id || guidFor(this);
+ this.enabled = definition.enabled !== undefined ? definition.enabled : true;
+ } else {
+ this.name = nameOrDefinition;
+ this.handler = options.handler || null;
+ this.priority = options.priority || 0;
+ this.runOnce = options.once || false;
+ this.id = options.id || guidFor(this);
+ this.enabled = options.enabled !== undefined ? options.enabled : true;
+ }
+
+ // Call setup() to trigger validation after properties are set
+ super.setup();
+ }
+
+ /**
+ * Validate the hook
+ *
+ * @method validate
+ * @throws {Error} If name is missing
+ */
+ validate() {
+ if (!this.name) {
+ throw new Error('Hook requires a name');
+ }
+ }
+
+ /**
+ * Set the hook handler function
+ *
+ * @method execute
+ * @param {Function} handler The handler function
+ * @returns {Hook} This instance for chaining
+ */
+ execute(handler) {
+ this.handler = handler;
+ this._options.handler = handler;
+ return this;
+ }
+
+ /**
+ * Set the hook priority
+ * Lower numbers execute first
+ *
+ * @method withPriority
+ * @param {Number} priority Priority value
+ * @returns {Hook} This instance for chaining
+ */
+ withPriority(priority) {
+ this.priority = priority;
+ this._options.priority = priority;
+ return this;
+ }
+
+ /**
+ * Mark this hook to run only once
+ * After execution, it will be automatically removed
+ *
+ * @method once
+ * @returns {Hook} This instance for chaining
+ */
+ once() {
+ this.runOnce = true;
+ this._options.once = true;
+ return this;
+ }
+
+ /**
+ * Set a unique ID for this hook
+ * Useful for removing specific hooks later
+ *
+ * @method withId
+ * @param {String} id Unique identifier
+ * @returns {Hook} This instance for chaining
+ */
+ withId(id) {
+ this.id = id;
+ this._options.id = id;
+ return this;
+ }
+
+ /**
+ * Enable or disable the hook
+ *
+ * @method setEnabled
+ * @param {Boolean} enabled Whether the hook is enabled
+ * @returns {Hook} This instance for chaining
+ */
+ setEnabled(enabled) {
+ this.enabled = enabled;
+ this._options.enabled = enabled;
+ return this;
+ }
+
+ /**
+ * Disable the hook
+ *
+ * @method disable
+ * @returns {Hook} This instance for chaining
+ */
+ disable() {
+ return this.setEnabled(false);
+ }
+
+ /**
+ * Enable the hook
+ *
+ * @method enable
+ * @returns {Hook} This instance for chaining
+ */
+ enable() {
+ return this.setEnabled(true);
+ }
+
+ /**
+ * Add metadata to the hook
+ *
+ * @method withMetadata
+ * @param {Object} metadata Metadata object
+ * @returns {Hook} This instance for chaining
+ */
+ withMetadata(metadata) {
+ this._options.metadata = metadata;
+ return this;
+ }
+
+ /**
+ * Get the plain object representation
+ *
+ * @method toObject
+ * @returns {Object} Plain object with all hook properties
+ */
+ toObject() {
+ return {
+ name: this.name,
+ handler: this.handler,
+ priority: this.priority,
+ once: this.runOnce,
+ id: this.id,
+ enabled: this.enabled,
+ ...this._options,
+ };
+ }
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| base-contract.js | +
+
+ |
+ 0% | +0/10 | +0% | +0/4 | +0% | +0/9 | +0% | +0/10 | +
| extension-component.js | +
+
+ |
+ 0% | +0/38 | +0% | +0/19 | +0% | +0/8 | +0% | +0/38 | +
| hook-registry.js | +
+
+ |
+ 0% | +0/1 | +100% | +0/0 | +100% | +0/0 | +0% | +0/1 | +
| hook.js | +
+
+ |
+ 0% | +0/39 | +0% | +0/35 | +0% | +0/11 | +0% | +0/39 | +
| menu-item.js | +
+
+ |
+ 0% | +0/132 | +0% | +0/98 | +0% | +0/20 | +0% | +0/132 | +
| menu-panel.js | +
+
+ |
+ 0% | +0/35 | +0% | +0/23 | +0% | +0/9 | +0% | +0/34 | +
| template-helper.js | +
+
+ |
+ 0% | +0/18 | +0% | +0/4 | +0% | +0/1 | +0% | +0/18 | +
| universe-registry.js | +
+
+ |
+ 0% | +0/1 | +100% | +0/0 | +100% | +0/0 | +0% | +0/1 | +
| widget.js | +
+
+ |
+ 0% | +0/61 | +0% | +0/30 | +0% | +0/14 | +0% | +0/61 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +573 +574 +575 +576 +577 +578 +579 +580 +581 +582 +583 +584 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | 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';
+
+/**
+ * Represents a menu item in the application
+ *
+ * Menu items can be simple navigation links or complex interactive components.
+ * They support routing, icons, priorities, click handlers, and lazy-loaded components.
+ *
+ * @class MenuItem
+ * @extends BaseContract
+ *
+ * @example
+ * // Simple menu item with chaining
+ * new MenuItem('Fleet-Ops', 'console.fleet-ops')
+ * .withIcon('route')
+ * .withPriority(0)
+ *
+ * @example
+ * // Full definition object (first-class)
+ * new MenuItem({
+ * title: 'Fleet-Ops',
+ * route: 'console.fleet-ops',
+ * icon: 'route',
+ * priority: 0,
+ * component: { engine: '@fleetbase/fleetops-engine', path: 'components/admin/navigator-app' }
+ * })
+ *
+ * @example
+ * // Menu item with component
+ * new MenuItem('Settings')
+ * .withComponent(new ExtensionComponent('@fleetbase/my-engine', 'components/settings'))
+ * .withOnClick((menuItem, router) => {
+ * router.transitionTo('virtual', menuItem.slug);
+ * })
+ *
+ * @example
+ * // Menu item with description and shortcuts (Phase 2)
+ * new MenuItem('Fleet-Ops', 'console.fleet-ops')
+ * .withIcon('route')
+ * .withDescription('Manage fleets, drivers, and live orders')
+ * .withShortcuts([
+ * { title: 'Scheduler', route: 'console.fleet-ops.scheduler', icon: 'calendar' },
+ * { title: 'Order Config', route: 'console.fleet-ops.order-configs', icon: 'gear' },
+ * ])
+ */
+export default class MenuItem extends BaseContract {
+ /**
+ * Create a new MenuItem
+ *
+ * @constructor
+ * @param {String|Object} titleOrDefinition The menu item title or full definition object
+ * @param {String} route Optional route name (only used if first param is string)
+ */
+ constructor(titleOrDefinition, route = null) {
+ // Call super FIRST (JavaScript requirement)
+ super(isObject(titleOrDefinition) ? titleOrDefinition : { title: titleOrDefinition, route });
+
+ // THEN set properties
+ if (isObject(titleOrDefinition)) {
+ const definition = titleOrDefinition;
+
+ // Core properties
+ this.title = definition.title || null;
+ this.text = definition.text || definition.title;
+ this.label = definition.label || definition.title;
+ this.id = definition.id || (definition.title ? dasherize(definition.title) : null);
+ this.slug = definition.slug || (this.title ? dasherize(this.title) : null);
+
+ // Routing properties
+ this.route = definition.route || null;
+ this.section = definition.section || null;
+ this.queryParams = definition.queryParams || {};
+ this.routeParams = definition.routeParams || [];
+ this.view = definition.view || (this.title ? dasherize(this.title) : null);
+
+ // Display properties
+ this.icon = definition.icon || 'circle-dot';
+ this.iconComponent = definition.iconComponent || null;
+ this.iconComponentOptions = definition.iconComponentOptions || {};
+ this.iconSize = definition.iconSize || null;
+ this.iconPrefix = definition.iconPrefix || null;
+ this.iconClass = definition.iconClass || null;
+
+ // Component properties
+ this.component = definition.component || null;
+ this.componentParams = definition.componentParams || {};
+ this.renderComponentInPlace = definition.renderComponentInPlace || false;
+
+ // Styling properties
+ this.class = definition.class || null;
+ this.inlineClass = definition.inlineClass || null;
+ this.wrapperClass = definition.wrapperClass || null;
+ this.overwriteWrapperClass = definition.overwriteWrapperClass || false;
+
+ // Behavior properties
+ this.priority = definition.priority !== undefined ? definition.priority : 9;
+ this.index = definition.index !== undefined ? definition.index : 0;
+ this.type = definition.type || 'default';
+ this.buttonType = definition.buttonType || null;
+ this.onClick = definition.onClick || null;
+
+ // State properties
+ this.disabled = definition.disabled || false;
+ this.isLoading = definition.isLoading || false;
+
+ // Permission and i18n
+ this.permission = definition.permission || null;
+ this.intl = definition.intl || null;
+
+ // Nested items
+ this.items = definition.items || null;
+
+ // ── Phase 2 additions ──────────────────────────────────────────
+ // A short human-readable description of the extension shown in the
+ // overflow dropdown. Optional – defaults to null.
+ this.description = definition.description || null;
+
+ // An array of shortcut items (sub-menu links) displayed beneath the
+ // extension name in the multi-column dropdown. Each shortcut is a
+ // 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;
+ this.text = titleOrDefinition;
+ this.label = titleOrDefinition;
+ this.id = dasherize(titleOrDefinition);
+ this.slug = dasherize(titleOrDefinition);
+
+ // Routing properties
+ this.route = route;
+ this.section = null;
+ this.queryParams = {};
+ this.routeParams = [];
+ this.view = dasherize(titleOrDefinition);
+
+ // Display properties
+ this.icon = 'circle-dot';
+ this.iconComponent = null;
+ this.iconComponentOptions = {};
+ this.iconSize = null;
+ this.iconPrefix = null;
+ this.iconClass = null;
+
+ // Component properties
+ this.component = null;
+ this.componentParams = {};
+ this.renderComponentInPlace = false;
+
+ // Styling properties
+ this.class = null;
+ this.inlineClass = null;
+ this.wrapperClass = null;
+ this.overwriteWrapperClass = false;
+
+ // Behavior properties
+ this.priority = 9;
+ this.index = 0;
+ this.type = 'default';
+ this.buttonType = null;
+ this.onClick = null;
+
+ // State properties
+ this.disabled = false;
+ this.isLoading = false;
+
+ // Permission and i18n
+ this.permission = null;
+ this.intl = null;
+
+ // Nested items
+ this.items = null;
+
+ // ── Phase 2 additions ──────────────────────────────────────────
+ this.description = null;
+ this.shortcuts = null;
+ this.tags = null;
+ }
+
+ // Call setup() to trigger validation after properties are set
+ super.setup();
+ }
+
+ /**
+ * Validate the menu item
+ *
+ * @method validate
+ * @throws {Error} If title is missing
+ */
+ validate() {
+ if (!this.title) {
+ throw new Error('MenuItem requires a title');
+ }
+ }
+
+ /**
+ * Set the menu item icon
+ *
+ * @method withIcon
+ * @param {String} icon Icon name (FontAwesome or custom)
+ * @returns {MenuItem} This instance for chaining
+ */
+ withIcon(icon) {
+ this.icon = icon;
+ this._options.icon = icon;
+ return this;
+ }
+
+ /**
+ * Set the menu item priority
+ * Lower numbers appear first in the menu
+ *
+ * @method withPriority
+ * @param {Number} priority Priority value (default: 9)
+ * @returns {MenuItem} This instance for chaining
+ */
+ withPriority(priority) {
+ this.priority = priority;
+ this._options.priority = priority;
+ return this;
+ }
+
+ /**
+ * Set a component for the menu item
+ *
+ * @method withComponent
+ * @param {ExtensionComponent|Object} component Component definition
+ * @returns {MenuItem} This instance for chaining
+ */
+ withComponent(component) {
+ if (component instanceof ExtensionComponent) {
+ this.component = component.toObject();
+ } else {
+ this.component = component;
+ }
+ this._options.component = this.component;
+ return this;
+ }
+
+ /**
+ * Set a click handler for the menu item.
+ *
+ * `onClick` itself is the handler property every consumer calls, so the setter
+ * cannot share its name: an instance's `onClick` field would shadow it.
+ *
+ * @method withOnClick
+ * @param {Function} handler Click handler function
+ * @returns {MenuItem} This instance for chaining
+ */
+ withOnClick(handler) {
+ this.onClick = handler;
+ this._options.onClick = handler;
+ return this;
+ }
+
+ /**
+ * Set the menu item slug
+ *
+ * @method withSlug
+ * @param {String} slug URL-friendly slug
+ * @returns {MenuItem} This instance for chaining
+ */
+ withSlug(slug) {
+ this.slug = slug;
+ this._options.slug = slug;
+ return this;
+ }
+
+ /**
+ * Set query parameters for the route
+ *
+ * @method withQueryParams
+ * @param {Object} params Query parameters object
+ * @returns {MenuItem} This instance for chaining
+ */
+ withQueryParams(params) {
+ this.queryParams = params;
+ this._options.queryParams = params;
+ return this;
+ }
+
+ /**
+ * Set route parameters
+ *
+ * @method withRouteParams
+ * @param {...*} params Route parameters
+ * @returns {MenuItem} This instance for chaining
+ */
+ withRouteParams(...params) {
+ this.routeParams = params;
+ this._options.routeParams = params;
+ return this;
+ }
+
+ /**
+ * Set the section this menu item belongs to
+ *
+ * @method inSection
+ * @param {String} section Section name
+ * @returns {MenuItem} This instance for chaining
+ */
+ inSection(section) {
+ this.section = section;
+ this._options.section = section;
+ return this;
+ }
+
+ /**
+ * Set the index position within its section
+ *
+ * @method atIndex
+ * @param {Number} index Index position
+ * @returns {MenuItem} This instance for chaining
+ */
+ atIndex(index) {
+ this.index = index;
+ this._options.index = index;
+ return this;
+ }
+
+ /**
+ * Set the menu item type
+ *
+ * @method withType
+ * @param {String} type Type (e.g., 'link', 'button', 'default')
+ * @returns {MenuItem} This instance for chaining
+ */
+ withType(type) {
+ this.type = type;
+ this._options.type = type;
+ return this;
+ }
+
+ /**
+ * Set wrapper CSS class
+ *
+ * @method withWrapperClass
+ * @param {String} wrapperClass CSS class for wrapper element
+ * @returns {MenuItem} This instance for chaining
+ */
+ withWrapperClass(wrapperClass) {
+ this.wrapperClass = wrapperClass;
+ this._options.wrapperClass = wrapperClass;
+ return this;
+ }
+
+ /**
+ * Set component parameters
+ *
+ * @method withComponentParams
+ * @param {Object} params Parameters to pass to component
+ * @returns {MenuItem} This instance for chaining
+ */
+ withComponentParams(params) {
+ this._options.componentParams = params;
+ return this;
+ }
+
+ /**
+ * Set whether to render component in place
+ *
+ * @method renderInPlace
+ * @param {Boolean} inPlace Whether to render in place
+ * @returns {MenuItem} This instance for chaining
+ */
+ renderInPlace(inPlace = true) {
+ this._options.renderComponentInPlace = inPlace;
+ return this;
+ }
+
+ // ── Phase 2 builder methods ────────────────────────────────────────────
+
+ /**
+ * Set a short human-readable description for the extension.
+ * Displayed beneath the extension title in the overflow dropdown.
+ *
+ * @method withDescription
+ * @param {String} description Short description text
+ * @returns {MenuItem} This instance for chaining
+ *
+ * @example
+ * new MenuItem('Fleet-Ops', 'console.fleet-ops')
+ * .withDescription('Manage fleets, drivers, and live orders')
+ */
+ withDescription(description) {
+ this.description = description;
+ this._options.description = description;
+ return this;
+ }
+
+ /**
+ * 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}
+ *
+ * Metadata:
+ * description {String} Short description shown in the card
+ * tags {String[]} Search tags
+ *
+ * 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
+ * @returns {MenuItem} This instance for chaining
+ *
+ * @example
+ * new MenuItem('Fleet-Ops', 'console.fleet-ops')
+ * .withShortcuts([
+ * {
+ * 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 = 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.
+ *
+ * @method addShortcut
+ * @param {Object} shortcut Shortcut definition object { title, route, icon?, iconPrefix?, id? }
+ * @returns {MenuItem} This instance for chaining
+ *
+ * @example
+ * new MenuItem('Fleet-Ops', 'console.fleet-ops')
+ * .addShortcut({ title: 'Scheduler', route: 'console.fleet-ops.scheduler' })
+ * .addShortcut({ title: 'Order Config', route: 'console.fleet-ops.order-configs' })
+ */
+ addShortcut(shortcut) {
+ if (!isArray(this.shortcuts)) {
+ this.shortcuts = [];
+ }
+ this.shortcuts = [...this.shortcuts, shortcut];
+ this._options.shortcuts = this.shortcuts;
+ return this;
+ }
+
+ /**
+ * Get the plain object representation
+ *
+ * @method toObject
+ * @returns {Object} Plain object with all menu item properties
+ */
+ toObject() {
+ return {
+ // Core properties
+ id: this.id,
+ title: this.title,
+ text: this.text,
+ label: this.label,
+ slug: this.slug,
+
+ // Routing properties
+ route: this.route,
+ section: this.section,
+ view: this.view,
+ queryParams: this.queryParams,
+ routeParams: this.routeParams,
+
+ // Display properties
+ icon: this.icon,
+ iconComponent: this.iconComponent,
+ iconComponentOptions: this.iconComponentOptions,
+ iconSize: this.iconSize,
+ iconPrefix: this.iconPrefix,
+ iconClass: this.iconClass,
+
+ // Component properties
+ component: this.component,
+ componentParams: this.componentParams,
+ renderComponentInPlace: this.renderComponentInPlace,
+
+ // Styling properties
+ class: this.class,
+ inlineClass: this.inlineClass,
+ wrapperClass: this.wrapperClass,
+ overwriteWrapperClass: this.overwriteWrapperClass,
+
+ // Behavior properties
+ priority: this.priority,
+ index: this.index,
+ type: this.type,
+ buttonType: this.buttonType,
+ onClick: this.onClick,
+
+ // State properties
+ disabled: this.disabled,
+ isLoading: this.isLoading,
+
+ // Permission and i18n
+ permission: this.permission,
+ intl: this.intl,
+
+ // Nested items
+ items: this.items,
+
+ // ── Phase 2 additions ──────────────────────────────────────────
+ // Optional short description shown in the overflow dropdown card
+ description: this.description,
+
+ // 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,
+
+ // Include any additional options
+ ...this._options,
+ };
+ }
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import BaseContract from './base-contract';
+import MenuItem from './menu-item';
+import { dasherize } from '@ember/string';
+import isObject from '../utils/is-object';
+
+/**
+ * Represents a menu panel containing multiple menu items
+ *
+ * Menu panels are used in admin settings and other sections to group related menu items.
+ *
+ * @class MenuPanel
+ * @extends BaseContract
+ *
+ * @example
+ * // With chaining
+ * new MenuPanel('Fleet-Ops Config')
+ * .withSlug('fleet-ops')
+ * .withIcon('truck')
+ * .addItem(new MenuItem('Navigator App').withIcon('location-arrow'))
+ * .addItem(new MenuItem('Avatar Management').withIcon('images'))
+ *
+ * @example
+ * // Full definition object (first-class)
+ * new MenuPanel({
+ * title: 'Fleet-Ops Config',
+ * slug: 'fleet-ops',
+ * icon: 'truck',
+ * priority: 5,
+ * items: [
+ * { title: 'Navigator App', icon: 'location-arrow' },
+ * { title: 'Avatar Management', icon: 'images' }
+ * ]
+ * })
+ */
+export default class MenuPanel extends BaseContract {
+ /**
+ * Create a new MenuPanel
+ *
+ * @constructor
+ * @param {String|Object} titleOrDefinition The panel title or full definition object
+ * @param {Array} items Optional array of menu items (only used if first param is string)
+ */
+ constructor(titleOrDefinition, items = []) {
+ // Call super FIRST (JavaScript requirement)
+ super(isObject(titleOrDefinition) && titleOrDefinition.title ? titleOrDefinition : { title: titleOrDefinition });
+
+ // THEN set properties
+ if (isObject(titleOrDefinition) && titleOrDefinition.title) {
+ const definition = titleOrDefinition;
+
+ this.title = definition.title;
+ this.items = definition.items || [];
+ this.slug = definition.slug || dasherize(this.title);
+ this.icon = definition.icon || null;
+ this.open = definition.open !== undefined ? definition.open : true;
+ this.priority = definition.priority !== undefined ? definition.priority : 9;
+ } else {
+ // Handle string title (chaining pattern)
+ this.title = titleOrDefinition;
+ this.items = items;
+ this.slug = dasherize(titleOrDefinition);
+ this.icon = null;
+ this.open = true;
+ this.priority = 9;
+ }
+
+ // Call setup() to trigger validation after properties are set
+ super.setup();
+ }
+
+ /**
+ * Validate the menu panel
+ *
+ * @method validate
+ * @throws {Error} If title is missing
+ */
+ validate() {
+ if (!this.title) {
+ throw new Error('MenuPanel requires a title');
+ }
+ }
+
+ /**
+ * Set the panel slug
+ *
+ * @method withSlug
+ * @param {String} slug URL-friendly slug
+ * @returns {MenuPanel} This instance for chaining
+ */
+ withSlug(slug) {
+ this.slug = slug;
+ this._options.slug = slug;
+ return this;
+ }
+
+ /**
+ * Set the panel icon
+ *
+ * @method withIcon
+ * @param {String} icon Icon name
+ * @returns {MenuPanel} This instance for chaining
+ */
+ withIcon(icon) {
+ this.icon = icon;
+ this._options.icon = icon;
+ return this;
+ }
+
+ /**
+ * Set the panel priority
+ *
+ * @method withPriority
+ * @param {Number} priority Priority value
+ * @returns {MenuPanel} This instance for chaining
+ */
+ withPriority(priority) {
+ this.priority = priority;
+ this._options.priority = priority;
+ return this;
+ }
+
+ /**
+ * Add a menu item to the panel
+ *
+ * @method addItem
+ * @param {MenuItem|Object} item Menu item to add
+ * @returns {MenuPanel} This instance for chaining
+ */
+ addItem(item) {
+ if (item instanceof MenuItem) {
+ this.items.push(item.toObject());
+ } else {
+ this.items.push(item);
+ }
+ return this;
+ }
+
+ /**
+ * Add multiple menu items to the panel
+ *
+ * @method addItems
+ * @param {Array} items Array of menu items
+ * @returns {MenuPanel} This instance for chaining
+ */
+ addItems(items) {
+ items.forEach((item) => this.addItem(item));
+ return this;
+ }
+
+ /**
+ * Get the plain object representation
+ *
+ * @method toObject
+ * @returns {Object} Plain object with all panel properties
+ */
+ toObject() {
+ return {
+ title: this.title,
+ slug: this.slug,
+ icon: this.icon,
+ open: this.open,
+ priority: this.priority,
+ items: this.items,
+ // Indicator flag
+ _isMenuPanel: true,
+ ...this._options,
+ };
+ }
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | /**
+ * Represents a helper that can be lazy-loaded from an engine or registered directly.
+ * Used by the RegistryService to share helpers across engines.
+ *
+ * @class TemplateHelper
+ * @example
+ * // Lazy loading from engine
+ * new TemplateHelper('@fleetbase/storefront-engine', 'helpers/calculate-delivery-fee')
+ *
+ * // Direct class registration
+ * new TemplateHelper(null, CalculateDeliveryFeeHelper)
+ */
+export default class TemplateHelper {
+ /**
+ * The name of the engine this helper belongs to
+ * @type {string|null}
+ */
+ engineName = null;
+
+ /**
+ * The path to the helper within the engine (for lazy loading)
+ * @type {string|null}
+ */
+ path = null;
+
+ /**
+ * The helper class or function (for direct registration)
+ * @type {Function|null}
+ */
+ class = null;
+
+ /**
+ * Whether this is a direct class registration (true) or lazy loading (false)
+ * @type {boolean}
+ */
+ isClass = false;
+
+ /**
+ * The resolved helper name (extracted from path or class name)
+ * @type {string|null}
+ */
+ name = null;
+
+ /**
+ * Creates a new TemplateHelper instance
+ *
+ * @param {string|null} engineName - The engine name (e.g., '@fleetbase/storefront-engine') or null for direct class
+ * @param {string|Function} pathOrClass - Either a path string for lazy loading or a helper class/function
+ */
+ constructor(engineName, pathOrClass) {
+ this.engineName = engineName;
+
+ if (typeof pathOrClass === 'string') {
+ // Lazy loading case
+ this.path = pathOrClass;
+ this.isClass = false;
+ this.name = this.#extractNameFromPath(pathOrClass);
+ } else {
+ // Direct class/function registration
+ this.class = pathOrClass;
+ this.isClass = true;
+ this.name = this.#extractNameFromClass(pathOrClass);
+ }
+ }
+
+ /**
+ * Extracts helper name from path
+ * @private
+ * @param {string} path - The helper path (e.g., 'helpers/calculate-delivery-fee')
+ * @returns {string} The helper name (e.g., 'calculate-delivery-fee')
+ */
+ #extractNameFromPath(path) {
+ const parts = path.split('/');
+ return parts[parts.length - 1];
+ }
+
+ /**
+ * Extracts helper name from class/function
+ * @private
+ * @param {Function} classOrFn - The helper class or function
+ * @returns {string|null} The helper name or null if not available
+ */
+ #extractNameFromClass(classOrFn) {
+ if (classOrFn.name) {
+ // Convert PascalCase or camelCase to kebab-case
+ return classOrFn.name
+ .replace(/([a-z])([A-Z])/g, '$1-$2')
+ .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
+ .toLowerCase()
+ .replace(/helper$/, ''); // Remove 'helper' suffix if present
+ }
+ return null;
+ }
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 | + + + + + + + + + + + + + + + + + + + + + + + + | import { tracked } from '@glimmer/tracking';
+import { TrackedMap } from 'tracked-built-ins';
+
+/**
+ * UniverseRegistry
+ *
+ * A singleton registry class that stores all universe registrations.
+ * This class is registered to the application container to ensure
+ * the same registry instance is shared across the app and all engines.
+ *
+ * Pattern inspired by RouteOptimizationRegistry - ensures registrations
+ * persist across engine boundaries by storing data in the application
+ * container rather than in service instances.
+ *
+ * @class UniverseRegistry
+ */
+export default class UniverseRegistry {
+ /**
+ * TrackedMap of section name → TrackedObject with dynamic lists
+ * Fully reactive - templates update when registries change
+ * @type {TrackedMap<string, TrackedObject>}
+ */
+ @tracked registries = new TrackedMap();
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import BaseContract from './base-contract';
+import ExtensionComponent from './extension-component';
+import isObject from '../utils/is-object';
+
+/**
+ * Represents a dashboard widget
+ *
+ * Widgets are modular components that can be added to dashboards.
+ * They support grid layout options, custom configurations, and lazy-loaded components.
+ *
+ * @class Widget
+ * @extends BaseContract
+ *
+ * @example
+ * // With chaining
+ * new Widget('fleet-ops-metrics')
+ * .withName('Fleet-Ops Metrics')
+ * .withDescription('Key metrics from Fleet-Ops')
+ * .withIcon('truck')
+ * .withComponent(new ExtensionComponent('@fleetbase/fleetops-engine', 'components/widget/metrics'))
+ * .withGridOptions({ w: 12, h: 12, minW: 8, minH: 12 })
+ * .asDefault()
+ *
+ * @example
+ * // Full definition object (first-class)
+ * new Widget({
+ * id: 'fleet-ops-metrics',
+ * name: 'Fleet-Ops Metrics',
+ * description: 'Key metrics from Fleet-Ops',
+ * icon: 'truck',
+ * component: new ExtensionComponent('@fleetbase/fleetops-engine', 'components/widget/metrics'),
+ * grid_options: { w: 12, h: 12, minW: 8, minH: 12 },
+ * default: true
+ * })
+ *
+ * @example
+ * // Full definition with string component (local)
+ * new Widget({
+ * id: 'welcome',
+ * name: 'Welcome',
+ * component: 'widget/welcome',
+ * default: true
+ * })
+ */
+export default class Widget extends BaseContract {
+ /**
+ * Create a new Widget
+ *
+ * @constructor
+ * @param {String|Object} idOrDefinition Unique widget identifier or full definition object
+ */
+ constructor(idOrDefinition) {
+ // Call super FIRST (JavaScript requirement)
+ super(isObject(idOrDefinition) ? idOrDefinition : { id: idOrDefinition });
+
+ // THEN set properties
+ if (isObject(idOrDefinition)) {
+ const definition = idOrDefinition;
+
+ // Support both id and widgetId for backward compatibility
+ this.id = definition.id || definition.widgetId;
+ this.name = definition.name || null;
+ this.description = definition.description || null;
+ this.icon = definition.icon || null;
+ this.grid_options = definition.grid_options || {};
+ this.options = definition.options || {};
+ this.category = definition.category || 'default';
+
+ // Handle component - support both string and ExtensionComponent
+ if (definition.component instanceof ExtensionComponent) {
+ this.component = definition.component.toObject();
+ } else if (isObject(definition.component)) {
+ // Plain object component definition
+ this.component = definition.component;
+ } else {
+ // String component path
+ this.component = definition.component || null;
+ }
+
+ // Store default flag if present
+ if (definition.default) {
+ this._options.default = true;
+ }
+ } else {
+ // Handle string id (chaining pattern)
+ this.id = idOrDefinition;
+ this.name = null;
+ this.description = null;
+ this.icon = null;
+ this.component = null;
+ this.grid_options = {};
+ this.options = {};
+ this.category = 'default';
+ }
+
+ // Call setup() to trigger validation after properties are set
+ super.setup();
+ }
+
+ /**
+ * Validate the widget
+ *
+ * @method validate
+ * @throws {Error} If id is missing
+ */
+ validate() {
+ if (!this.id) {
+ throw new Error('Widget requires an id');
+ }
+ }
+
+ /**
+ * Set the widget name
+ *
+ * @method withName
+ * @param {String} name Display name
+ * @returns {Widget} This instance for chaining
+ */
+ withName(name) {
+ this.name = name;
+ this._options.name = name;
+ return this;
+ }
+
+ /**
+ * Set the widget description
+ *
+ * @method withDescription
+ * @param {String} description Widget description
+ * @returns {Widget} This instance for chaining
+ */
+ withDescription(description) {
+ this.description = description;
+ this._options.description = description;
+ return this;
+ }
+
+ /**
+ * Set the widget icon
+ *
+ * @method withIcon
+ * @param {String} icon Icon name
+ * @returns {Widget} This instance for chaining
+ */
+ withIcon(icon) {
+ this.icon = icon;
+ this._options.icon = icon;
+ return this;
+ }
+
+ /**
+ * Set the widget component
+ * Supports both string paths and ExtensionComponent instances
+ *
+ * @method withComponent
+ * @param {String|ExtensionComponent|Object} component Component definition
+ * @returns {Widget} This instance for chaining
+ */
+ withComponent(component) {
+ if (component instanceof ExtensionComponent) {
+ this.component = component.toObject();
+ } else {
+ this.component = component;
+ }
+ this._options.component = this.component;
+ return this;
+ }
+
+ /**
+ * Set grid layout options
+ *
+ * @method withGridOptions
+ * @param {Object} options Grid options (w, h, minW, minH, etc.)
+ * @returns {Widget} This instance for chaining
+ */
+ withGridOptions(options) {
+ this.grid_options = { ...this.grid_options, ...options };
+ this._options.grid_options = this.grid_options;
+ return this;
+ }
+
+ /**
+ * Set widget-specific options
+ *
+ * @method withOptions
+ * @param {Object} options Widget options
+ * @returns {Widget} This instance for chaining
+ */
+ withOptions(options) {
+ this.options = { ...this.options, ...options };
+ this._options.options = this.options;
+ return this;
+ }
+
+ /**
+ * Set the widget category
+ *
+ * @method withCategory
+ * @param {String} category Category name
+ * @returns {Widget} This instance for chaining
+ */
+ withCategory(category) {
+ this.category = category;
+ this._options.category = category;
+ return this;
+ }
+
+ /**
+ * Mark this widget as a default widget
+ * Default widgets are automatically added to new dashboards
+ *
+ * @method asDefault
+ * @returns {Widget} This instance for chaining
+ */
+ asDefault() {
+ this._options.default = true;
+ return this;
+ }
+
+ /**
+ * Check if this widget is marked as default
+ *
+ * @method isDefault
+ * @returns {Boolean} True if widget is a default widget
+ */
+ isDefault() {
+ return this._options.default === true;
+ }
+
+ /**
+ * Set the widget title
+ *
+ * @method withTitle
+ * @param {String} title Widget title
+ * @returns {Widget} This instance for chaining
+ */
+ withTitle(title) {
+ this.options.title = title;
+ this._options.options = this.options;
+ return this;
+ }
+
+ /**
+ * Set refresh interval for the widget
+ *
+ * @method withRefreshInterval
+ * @param {Number} milliseconds Refresh interval in milliseconds
+ * @returns {Widget} This instance for chaining
+ */
+ withRefreshInterval(milliseconds) {
+ this.options.refreshInterval = milliseconds;
+ this._options.options = this.options;
+ return this;
+ }
+
+ /**
+ * Get the plain object representation
+ *
+ * @method toObject
+ * @returns {Object} Plain object with all widget properties
+ */
+ toObject() {
+ return {
+ id: this.id,
+ name: this.name,
+ description: this.description,
+ icon: this.icon,
+ component: this.component,
+ grid_options: this.grid_options,
+ options: this.options,
+ category: this.category,
+ ...this._options,
+ };
+ }
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| load-socketcluster-client.js | +
+
+ |
+ 100% | +5/5 | +50% | +1/2 | +100% | +1/1 | +100% | +5/5 | +
| local-storage-adapter.js | +
+
+ |
+ 50% | +2/4 | +50% | +1/2 | +33.33% | +1/3 | +50% | +2/4 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 | + + +1x +1x +1x +1x +1x + + + + + + + | export function initialize() {
+ // Check if the script already exists
+ // Only insert the script tag if it doesn't already exist
+ Eif (!document.querySelector('script[data-socketcluster-client]')) {
+ const socketClusterClientScript = document.createElement('script');
+ socketClusterClientScript.setAttribute('data-socketcluster-client', '1');
+ socketClusterClientScript.src = '/assets/socketcluster-client.min.js';
+ document.body.appendChild(socketClusterClientScript);
+ }
+}
+
+export default {
+ initialize,
+};
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 | + + + +1x +1x + + + + + + + + + + + + + + + + | import Store from '@ember-data/store';
+import { importData, exportData } from 'ember-local-storage/helpers/import-export';
+
+export function initialize() {
+ Eif (!Store.prototype._emberLocalStoragePatched) {
+ Store.reopen({
+ _emberLocalStoragePatched: true,
+ importData: function (json, options) {
+ return importData(this, json, options);
+ },
+ exportData: function (types, options) {
+ return exportData(this, types, options);
+ },
+ });
+ }
+}
+
+export default {
+ name: 'local-storage-adapter',
+ initialize: initialize,
+};
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 | + + + + + +353x + + + + + + +353x + + + + + + + + +14x + + + + + + + + + + + +94x + +94x + + +94x +380x + +380x +216x + + + + +216x +188x + + +28x + + + +94x + + + +94x +94x + + +94x + + + + + + + + + + + + +161x + +161x + + + | import RESTSerializer from '@ember-data/serializer/rest';
+import { isNone } from '@ember/utils';
+import { underscore } from '@ember/string';
+import normalizePolymorphicTypeWithinHash from '../utils/serialize/normalize-polymorphic-type-within-hash';
+
+export default class ApplicationSerializer extends RESTSerializer {
+ readOnlyAttributes = ['slug'];
+
+ /**
+ * Default primary keys to uuid
+ *
+ * @var {string}
+ */
+ primaryKey = 'uuid';
+
+ /**
+ * keyForPolymorphicType can be used to define a custom key when serializing and deserializing a polymorphic type.
+ * By default, the returned key is ${key}Type.
+ *
+ * @var {String}
+ */
+ keyForPolymorphicType(key) {
+ return `${underscore(key)}_type`;
+ }
+
+ /**
+ * Customize serializer so that any attributes that are instances of Models or objects
+ * that are to accept and ID get serialized into the id only
+ *
+ * @param {Snapshot} snapshot
+ * @param {Object} options
+ * @return {Object} json
+ */
+ serialize(snapshot) {
+ const json = super.serialize(...arguments);
+
+ this.removeReadOnlyAttributes(json);
+
+ // for each relationship make sure the id is set
+ snapshot.eachRelationship((key, relationship) => {
+ const { kind } = relationship.meta;
+
+ if (kind === 'belongsTo') {
+ const relationSnapshot = snapshot.belongsTo(key);
+
+ /* istanbul ignore next -- keyForRelationship is defined on the serializer prototype, so the fallback arm is unreachable */
+ key = this.keyForRelationship ? this.keyForRelationship(key, 'belongsTo', 'serialize') : key;
+
+ if (isNone(relationSnapshot)) {
+ return;
+ }
+
+ json[`${key}_uuid`] = relationSnapshot.id;
+ }
+ });
+
+ return json;
+ }
+
+ removeReadOnlyAttributes(json = {}) {
+ this.readOnlyAttributes.forEach((attribute) => {
+ delete json[attribute];
+ });
+
+ return json;
+ }
+
+ /**
+ * Normalizes a part of the JSON payload returned by the server.
+ *
+ * @method normalize
+ * @param {Model} modelClass
+ * @param {Object} resourceHash
+ * @param {String} prop
+ * @return {Object}
+ */
+ normalize(model, hash, prop) {
+ hash = normalizePolymorphicTypeWithinHash(hash);
+
+ return super.normalize(model, hash, prop);
+ }
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| application.js | +
+
+ |
+ 100% | +18/18 | +80% | +4/5 | +100% | +6/6 | +100% | +18/18 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import Service from '@ember/service';
+import Evented from '@ember/object/evented';
+import { inject as service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+import { dasherize } from '@ember/string';
+import { get } from '@ember/object';
+import { isBlank } from '@ember/utils';
+import { alias } from '@ember/object/computed';
+import { storageFor } from 'ember-local-storage';
+import { debug } from '@ember/debug';
+import lookupUserIp, { getBrowserTimezone } from '../utils/lookup-user-ip';
+
+/**
+ * CurrentUserService
+ *
+ * Manages the authenticated user's identity and preferences. Extends Evented
+ * so that any service or component can subscribe to user lifecycle events
+ * directly on this service.
+ *
+ * Session lifecycle events emitted (on both this service and via EventsService
+ * which re-broadcasts them on the universe bus for cross-engine listeners):
+ *
+ * user.loaded — fired after a successful login or session restore.
+ * Payload: (user, organization, properties)
+ *
+ * user.updated — fired when the user record is refreshed in-session
+ * (e.g. profile edit). Payload: (user, properties)
+ *
+ * user.organization_switched — fired when the user switches active org.
+ * Payload: (organization, properties)
+ */
+export default class CurrentUserService extends Service.extend(Evented) {
+ @service session;
+ @service store;
+ @service fetch;
+ @service theme;
+ @service notifications;
+ @service intl;
+ @service events;
+ @service universe;
+
+ @tracked user = { id: 'anon' };
+ @tracked userSnapshot = { id: 'anon' };
+ @tracked company = {};
+ @tracked permissions = [];
+ @tracked organizations = [];
+ @tracked whoisData = {};
+ @tracked locale = 'en-us';
+
+ @storageFor('user-options') options;
+ @storageFor('local-cache') cache;
+ @alias('userSnapshot.id') id;
+ @alias('userSnapshot.name') name;
+ @alias('userSnapshot.phone') phone;
+ @alias('userSnapshot.email') email;
+ @alias('userSnapshot.avatar_url') avatarUrl;
+ @alias('userSnapshot.is_admin') isAdmin;
+ @alias('userSnapshot.company_uuid') companyId;
+ @alias('userSnapshot.company_name') companyName;
+ @alias('userSnapshot.role_name') roleName;
+ @alias('userSnapshot.role') role;
+
+ get authenticatedOptionOwnerId() {
+ const authenticatedUserId = this.session?.data?.authenticated?.user;
+
+ if (this.session?.isAuthenticated && authenticatedUserId) {
+ return authenticatedUserId;
+ }
+
+ try {
+ const localStorageSession = JSON.parse(window.localStorage.getItem('ember_simple_auth-session'));
+ const authenticatedSession = localStorageSession?.authenticated;
+
+ if (authenticatedSession?.token && authenticatedSession?.user) {
+ return authenticatedSession.user;
+ }
+ } catch (error) {
+ // Ignore malformed session storage and fall back to the current snapshot.
+ }
+
+ return null;
+ }
+
+ get optionsPrefix() {
+ return `${this.authenticatedOptionOwnerId || this.id || 'anon'}:`;
+ }
+
+ get latitude() {
+ return this.whois('latitude');
+ }
+
+ get longitude() {
+ return this.whois('longitude');
+ }
+
+ get currency() {
+ return this.whois('currency.code');
+ }
+
+ get city() {
+ return this.whois('city');
+ }
+
+ get country() {
+ return this.whois('country_code');
+ }
+
+ get timezone() {
+ return this.whois('timezone') || getBrowserTimezone();
+ }
+
+ async load() {
+ if (this.session.isAuthenticated) {
+ const user = await this.store.findRecord('user', 'me');
+
+ // set user
+ await this.setUser(user);
+
+ // Load preferences
+ await this.loadPreferences();
+
+ return user;
+ }
+
+ return null;
+ }
+
+ async promiseUser(options = {}) {
+ const NoUserAuthenticatedError = new Error('Failed to authenticate user.');
+ if (!this.session.isAuthenticated) {
+ throw NoUserAuthenticatedError;
+ }
+
+ try {
+ const user = await this.store.queryRecord('user', { me: true });
+
+ // set user
+ await this.setUser(user);
+
+ // Load user whois data
+ await this.loadWhois();
+
+ // Load user organizations
+ await this.loadOrganizations();
+
+ // Optional callback
+ if (typeof options?.onUserResolved === 'function') {
+ options.onUserResolved(user);
+ }
+
+ return user;
+ } catch (error) {
+ debug(`Error loading current user : ${error.message}`);
+ throw error;
+ }
+ }
+
+ async loadPreferences() {
+ await this.loadLocale();
+ await this.loadWhois();
+ await this.loadOrganizations();
+ }
+
+ async loadLocale() {
+ try {
+ const { locale } = await this.fetch.get('users/locale');
+ this.setLocale(locale);
+
+ return locale;
+ } catch (error) {
+ this.notifications.serverError(error);
+ }
+ }
+
+ async loadOrganizations() {
+ try {
+ const organizations = await this.fetch.get('auth/organizations', {}, { normalizeToEmberData: true, normalizeModelType: 'company' });
+ this.setOption('organizations', organizations);
+ this.organizations = organizations;
+
+ return organizations;
+ } catch (error) {
+ this.notifications.serverError(error);
+ }
+ }
+
+ async loadWhois() {
+ // Use frontend IP lookup to get accurate user location
+ // This avoids the issue of server-side lookup returning server IP instead of user IP
+ const whois = await lookupUserIp({
+ timeout: 5000,
+ cache: true,
+ });
+
+ // lookupUserIp absorbs its own failures and hands back a fallback rather than
+ // rejecting, so the result, not an exception, says the location is unknown.
+ if (whois._source === 'fallback') {
+ this.notifications.warning('Unable to detect your location. Some features may use default settings.');
+ }
+
+ this.whoisData = whois;
+ try {
+ this.setOption('whois', whois);
+ } catch (error) {
+ console.error('[currentUser] Failed to store whois:', error);
+ }
+
+ return whois;
+ }
+
+ getCompany() {
+ this.company = this.store.peekRecord('company', this.user.company_uuid);
+ return this.company;
+ }
+
+ async loadCompany() {
+ const company = this.store.peekRecord('company', this.user.company_uuid);
+ if (company) {
+ return company;
+ }
+
+ return this.store.findRecord('company', this.user.company_uuid);
+ }
+
+ getUserPermissions(user) {
+ const permissions = [];
+
+ // get direct applied permissions
+ if (user.get('permissions')) {
+ permissions.push(...user.get('permissions').toArray());
+ }
+
+ // get role permissions and role policies permissions
+ if (user.get('role')) {
+ if (user.get('role.permissions')) {
+ permissions.push(...user.get('role.permissions').toArray());
+ }
+
+ if (user.get('role.policies')) {
+ for (let i = 0; i < user.get('role.policies').length; i++) {
+ const policy = user.get('role.policies').objectAt(i);
+ if (policy.get('permissions')) {
+ permissions.push(...policy.get('permissions').toArray());
+ }
+ }
+ }
+ }
+
+ // get direct applied policy permissions
+ if (user.get('policies')) {
+ for (let i = 0; i < user.get('policies').length; i++) {
+ const policy = user.get('policies').objectAt(i);
+ if (policy.get('permissions')) {
+ permissions.push(...policy.get('permissions').toArray());
+ }
+ }
+ }
+
+ return permissions;
+ }
+
+ whois(key) {
+ return this.getWhoisProperty(key);
+ }
+
+ setLocale(locale) {
+ this.setOption('locale', locale);
+ this.intl.setLocale(locale);
+ this.locale = locale;
+
+ return this;
+ }
+
+ setOption(key, value) {
+ key = `${this.optionsPrefix}${dasherize(key)}`;
+
+ this.options.set(key, value);
+
+ return this;
+ }
+
+ getOption(key, defaultValue = null) {
+ key = `${this.optionsPrefix}${dasherize(key)}`;
+
+ const value = this.options.get(key);
+ return value !== undefined ? value : defaultValue;
+ }
+
+ getWhoisProperty(prop) {
+ const whois = this.getOption('whois');
+
+ if (!whois || typeof whois !== 'object') {
+ // Fallback to lookup/whois in local-cache
+ const cachedWhois = this.cache.get('lookup/whois');
+ if (cachedWhois) {
+ return get(cachedWhois, prop);
+ }
+ return null;
+ }
+
+ return get(whois, prop);
+ }
+
+ hasOption(key) {
+ // Read storage directly rather than going through getOption: its
+ // `defaultValue = null` parameter applies whenever the stored value is
+ // undefined, so getOption can never return undefined and this was
+ // always true.
+ key = `${this.optionsPrefix}${dasherize(key)}`;
+
+ return this.options.get(key) !== undefined;
+ }
+
+ filledOption(key) {
+ return !isBlank(this.getOption(key));
+ }
+
+ async getUserSnapshot(user) {
+ const role = await user.get('role');
+ const snapshot = user.serialize({ includeId: true });
+
+ return {
+ ...snapshot,
+ id: snapshot.uuid,
+ company_name: user.get('company_name'),
+ role_name: user.get('role_name'),
+ role: {
+ ...role.serialize({ includeId: true }),
+ id: role.get('id'),
+ },
+ };
+ }
+
+ getCompanyOption(key, defaultValue = null) {
+ const company = this.store.peekRecord('company', this.companyId);
+ if (company) {
+ const value = get(company, `options.${key}`);
+ if (value === undefined) {
+ return defaultValue;
+ }
+
+ return value;
+ }
+
+ return defaultValue;
+ }
+
+ /**
+ * Sets the current user and fires all user.loaded lifecycle events.
+ *
+ * This is the canonical place where the authenticated user identity is
+ * established. It fires:
+ *
+ * 1. `this.trigger('user.loaded', user)` — on the currentUser service
+ * itself (Evented), for direct service-level listeners.
+ *
+ * 2. `this.events.trackUserLoaded(user, organization)` — on the events
+ * service, which re-broadcasts on both the events bus and the universe
+ * bus so cross-engine listeners (Intercom, PostHog, Attio, etc.) can
+ * subscribe via `universe.on('user.loaded', handler)`.
+ *
+ * @param {Model} user
+ */
+ async setUser(user) {
+ const snapshot = await this.getUserSnapshot(user);
+
+ // Set current user
+ this.set('user', user);
+ this.set('userSnapshot', snapshot);
+ this.theme.syncThemeFromCurrentUser();
+
+ // Resolve the organization for event payload
+ const organization = this.store.peekRecord('company', user.get('company_uuid'));
+
+ // 1. Trigger on the currentUser Evented bus (backward-compatible)
+ this.trigger('user.loaded', user);
+
+ // 2. Fire through the events service — broadcasts on both events bus
+ // and universe bus for cross-engine listeners
+ if (this.events) {
+ this.events.trackUserLoaded(user, organization);
+ }
+
+ // 3. Trigger directly on universe for framework-level uniformity —
+ // guarantees delivery to all engines on the shared bus
+ if (this.universe) {
+ this.universe.trigger('user.loaded', user, organization);
+ }
+
+ // Set permissions
+ this.permissions = this.getUserPermissions(user);
+
+ // Set environment from user option
+ this.theme.setEnvironment();
+
+ // Set locale
+ if (user.locale) {
+ this.setLocale(user.locale);
+ } else {
+ await this.loadLocale();
+ }
+ }
+
+ /**
+ * Fires a user.updated event when the user record is refreshed in-session.
+ * Call this after any in-session profile update to keep integrations in sync.
+ *
+ * @param {Model} user
+ */
+ async refreshUser(user) {
+ const snapshot = await this.getUserSnapshot(user);
+ this.set('user', user);
+ this.set('userSnapshot', snapshot);
+
+ const organization = this.store.peekRecord('company', user.get('company_uuid'));
+
+ this.trigger('user.updated', user);
+
+ if (this.events) {
+ this.events.trackEvent('user.updated', {
+ user_id: user?.id,
+ organization_id: organization?.id,
+ organization_name: organization?.name,
+ });
+ }
+
+ if (this.universe) {
+ this.universe.trigger('user.updated', user, organization);
+ }
+ }
+
+ /**
+ * Fires a user.organization_switched event when the user changes their
+ * active organization. Call this after a successful org switch.
+ *
+ * @param {Model} organization
+ */
+ switchOrganization(organization) {
+ this.company = organization;
+
+ this.trigger('user.organization_switched', organization);
+
+ if (this.events) {
+ this.events.trackEvent('user.organization_switched', {
+ organization_id: organization?.id,
+ organization_name: organization?.name,
+ });
+ }
+
+ if (this.universe) {
+ this.universe.trigger('user.organization_switched', organization);
+ }
+ }
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import Service, { inject as service } from '@ember/service';
+import Evented from '@ember/object/evented';
+import config from 'ember-get-config';
+
+/**
+ * EventsService
+ *
+ * Provides a centralized, standardized event tracking system for Fleetbase.
+ *
+ * This service is the single source of truth for all application lifecycle
+ * events. It emits every event on two buses simultaneously:
+ *
+ * 1. Its own Evented bus — for direct service/component listeners:
+ * this.events.on('user.loaded', handler)
+ *
+ * 2. The universe service bus — for cross-engine listeners (recommended
+ * for use in Ember Engines / extensions):
+ * this.universe.on('user.loaded', handler)
+ *
+ * ─────────────────────────────────────────────────────────────────────────────
+ * Session Lifecycle Events
+ * ─────────────────────────────────────────────────────────────────────────────
+ *
+ * session.authenticated Fired after a successful login or session restore.
+ * Payload: (properties)
+ *
+ * session.invalidated Fired after the session is destroyed (logout).
+ * Payload: (duration_seconds, properties)
+ *
+ * session.terminated Alias for session.invalidated — provided for
+ * backward compatibility and semantic clarity.
+ * Payload: (duration_seconds, properties)
+ *
+ * user.loaded Fired after the authenticated user record and
+ * organization have been fully loaded into the
+ * currentUser service. This is the canonical event
+ * for integrations to boot (Intercom, PostHog, etc.)
+ * Payload: (user, organization, properties)
+ *
+ * user.updated Fired when the user record is refreshed in-session
+ * (e.g. after a profile edit).
+ * Payload: (user, properties)
+ *
+ * user.deauthenticated Fired when the user identity is cleared on logout.
+ * Semantic alias for session.invalidated — use this
+ * to shut down integrations cleanly (Intercom, etc.)
+ * Payload: (duration_seconds, properties)
+ *
+ * user.organization_switched Fired when the user switches their active org.
+ * Payload: (organization, properties)
+ *
+ * ─────────────────────────────────────────────────────────────────────────────
+ * Resource Events
+ * ─────────────────────────────────────────────────────────────────────────────
+ *
+ * resource.created Generic resource creation.
+ * resource.updated Generic resource update.
+ * resource.deleted Generic resource deletion.
+ * resource.imported Bulk import.
+ * resource.exported Export.
+ * resource.bulk_action Bulk action (delete, archive, etc.)
+ * {modelName}.created Model-specific creation (e.g. order.created)
+ * {modelName}.updated Model-specific update.
+ * {modelName}.deleted Model-specific deletion.
+ * {modelName}.exported Model-specific export.
+ *
+ * @class EventsService
+ * @extends Service
+ */
+export default class EventsService extends Service.extend(Evented) {
+ @service universe;
+ @service currentUser;
+
+ // =========================================================================
+ // Session Lifecycle Tracking
+ // =========================================================================
+
+ /**
+ * Tracks a successful authentication (login or session restore).
+ *
+ * Called by SessionService.handleAuthentication().
+ *
+ * @param {Object} [props={}] - Additional properties to include
+ */
+ trackSessionAuthenticated(props = {}) {
+ const properties = this.#enrichProperties(props);
+ this.#trigger('session.authenticated', properties);
+ }
+
+ /**
+ * Tracks when a user session is terminated (logout).
+ *
+ * Called by SessionService.handleInvalidation(). Also fires the semantic
+ * `user.deauthenticated` event so integrations can react to the user
+ * identity being cleared without needing to know about session internals.
+ *
+ * @param {Number|null} duration - Session duration in seconds (null if unknown)
+ * @param {Object} [props={}] - Additional properties to include
+ */
+ trackSessionTerminated(duration, props = {}) {
+ const properties = this.#enrichProperties({
+ session_duration: duration,
+ ...props,
+ });
+
+ // Fire session.invalidated (technical event)
+ this.#trigger('session.invalidated', duration, properties);
+
+ // Fire session.terminated (backward-compatible alias)
+ this.#trigger('session.terminated', duration, properties);
+
+ // Fire user.deauthenticated (semantic event for integrations)
+ this.#trigger('user.deauthenticated', duration, properties);
+ }
+
+ /**
+ * Tracks when the current user is loaded (session initialized).
+ *
+ * Called by CurrentUserService.setUser() after a successful login or
+ * session restore. This is the canonical event for integrations to boot.
+ *
+ * @param {Object} user - The authenticated user model
+ * @param {Object} organization - The user's active organization model
+ * @param {Object} [props={}] - Additional properties to include
+ */
+ trackUserLoaded(user, organization, props = {}) {
+ const properties = this.#enrichProperties({
+ user_id: user?.id,
+ organization_id: organization?.id,
+ organization_name: organization?.name,
+ ...props,
+ });
+
+ this.#trigger('user.loaded', user, organization, properties);
+ }
+
+ /**
+ * Tracks when the user record is refreshed in-session (e.g. profile edit).
+ *
+ * Called by CurrentUserService.refreshUser().
+ *
+ * @param {Object} user - The updated user model
+ * @param {Object} [props={}] - Additional properties to include
+ */
+ trackUserUpdated(user, props = {}) {
+ const properties = this.#enrichProperties({
+ user_id: user?.id,
+ ...props,
+ });
+
+ this.#trigger('user.updated', user, properties);
+ }
+
+ /**
+ * Tracks when the user switches their active organization.
+ *
+ * Called by CurrentUserService.switchOrganization().
+ *
+ * @param {Object} organization - The new active organization model
+ * @param {Object} [props={}] - Additional properties to include
+ */
+ trackOrganizationSwitched(organization, props = {}) {
+ const properties = this.#enrichProperties({
+ organization_id: organization?.id,
+ organization_name: organization?.name,
+ ...props,
+ });
+
+ this.#trigger('user.organization_switched', organization, properties);
+ }
+
+ // =========================================================================
+ // Resource Event Tracking
+ // =========================================================================
+
+ /**
+ * Tracks the creation of a resource
+ *
+ * @param {Object} resource - The created resource/model
+ * @param {Object} [props={}] - Additional properties to include
+ */
+ trackResourceCreated(resource, props = {}) {
+ const events = this.#getResourceEvents(resource, 'created');
+ const properties = this.#enrichProperties({
+ ...this.#getSafeProperties(resource),
+ ...props,
+ });
+
+ events.forEach((eventName) => {
+ this.#trigger(eventName, resource, properties);
+ });
+ }
+
+ /**
+ * Tracks the update of a resource
+ *
+ * @param {Object} resource - The updated resource/model
+ * @param {Object} [props={}] - Additional properties to include
+ */
+ trackResourceUpdated(resource, props = {}) {
+ const events = this.#getResourceEvents(resource, 'updated');
+ const properties = this.#enrichProperties({
+ ...this.#getSafeProperties(resource),
+ ...props,
+ });
+
+ events.forEach((eventName) => {
+ this.#trigger(eventName, resource, properties);
+ });
+ }
+
+ /**
+ * Tracks the deletion of a resource
+ *
+ * @param {Object} resource - The deleted resource/model
+ * @param {Object} [props={}] - Additional properties to include
+ */
+ trackResourceDeleted(resource, props = {}) {
+ const events = this.#getResourceEvents(resource, 'deleted');
+ const properties = this.#enrichProperties({
+ ...this.#getSafeProperties(resource),
+ ...props,
+ });
+
+ events.forEach((eventName) => {
+ this.#trigger(eventName, resource, properties);
+ });
+ }
+
+ /**
+ * Tracks a bulk import of resources
+ *
+ * @param {String} modelName - The name of the model being imported
+ * @param {Number} count - Number of resources imported
+ * @param {Object} [props={}] - Additional properties to include
+ */
+ trackResourceImported(modelName, count, props = {}) {
+ const properties = this.#enrichProperties({
+ model_name: modelName,
+ count: count,
+ ...props,
+ });
+
+ this.#trigger('resource.imported', modelName, count, properties);
+ }
+
+ /**
+ * Tracks a resource export
+ *
+ * @param {String} modelName - The name of the model being exported
+ * @param {String} format - Export format (csv, xlsx, etc.)
+ * @param {Object} [params={}] - Export parameters/filters
+ * @param {Object} [props={}] - Additional properties to include
+ */
+ trackResourceExported(modelName, format, params = {}, props = {}) {
+ const properties = this.#enrichProperties({
+ model_name: modelName,
+ export_format: format,
+ has_filters: !!(params && Object.keys(params).length > 0),
+ ...props,
+ });
+
+ this.#trigger('resource.exported', modelName, format, params, properties);
+
+ // Also trigger model-specific export event
+ const specificEvent = `${modelName}.exported`;
+ this.#trigger(specificEvent, modelName, format, params, properties);
+ }
+
+ /**
+ * Tracks a bulk action on multiple resources
+ *
+ * @param {String} verb - The action verb (delete, archive, etc.)
+ * @param {Array} resources - Array of selected resources
+ * @param {Object} [props={}] - Additional properties to include
+ */
+ trackBulkAction(verb, resources, props = {}) {
+ const firstResource = resources && resources.length > 0 ? resources[0] : null;
+ const modelName = this.#getModelName(firstResource);
+
+ const properties = this.#enrichProperties({
+ action: verb,
+ count: resources?.length || 0,
+ model_name: modelName,
+ ...props,
+ });
+
+ this.#trigger('resource.bulk_action', verb, resources, firstResource, properties);
+ }
+
+ // =========================================================================
+ // Generic Event Tracking
+ // =========================================================================
+
+ /**
+ * Tracks a generic custom event
+ *
+ * @param {String} eventName - The event name (dot notation)
+ * @param {Object} [props={}] - Event properties
+ */
+ trackEvent(eventName, props = {}) {
+ const properties = this.#enrichProperties(props);
+ this.#trigger(eventName, properties);
+ }
+
+ /**
+ * Checks if event tracking is enabled
+ *
+ * @returns {Boolean}
+ */
+ isEnabled() {
+ const eventsConfig = config?.events || {};
+ return eventsConfig.enabled !== false; // Enabled by default
+ }
+
+ // =========================================================================
+ // Private Methods (using # syntax)
+ // =========================================================================
+
+ /**
+ * Triggers an event on both the events service and universe service.
+ *
+ * This dual event system allows listeners to subscribe to events on either:
+ * - this.events.on('event.name', handler) — local listeners
+ * - this.universe.on('event.name', handler) — cross-engine listeners
+ *
+ * @private
+ * @param {String} eventName - The event name
+ * @param {...*} args - Arguments to pass to event listeners
+ */
+ #trigger(eventName, ...args) {
+ if (!this.isEnabled()) {
+ return;
+ }
+
+ // Debug logging if enabled
+ if (config?.events?.debug) {
+ console.log(`[Events] ${eventName}`, args);
+ }
+
+ // Trigger on events service (local listeners)
+ this.trigger(eventName, ...args);
+
+ // Trigger on universe service (cross-engine listeners)
+ if (this.universe) {
+ this.universe.trigger(eventName, ...args);
+ } else {
+ console.warn('[Events] Universe service not available');
+ }
+ }
+
+ /**
+ * Generates both generic and specific event names for a resource action
+ *
+ * @private
+ * @param {Object} resource - The resource/model
+ * @param {String} action - The action (created, updated, deleted)
+ * @returns {Array<String>} Array of event names
+ */
+ #getResourceEvents(resource, action) {
+ const modelName = this.#getModelName(resource);
+ return [`resource.${action}`, `${modelName}.${action}`];
+ }
+
+ /**
+ * Extracts safe, serializable properties from a resource
+ *
+ * @private
+ * @param {Object} resource - The resource/model
+ * @returns {Object} Safe properties object
+ */
+ #getSafeProperties(resource) {
+ if (!resource) {
+ return {};
+ }
+
+ const props = {
+ id: resource.id,
+ model_name: this.#getModelName(resource),
+ };
+
+ // Add common properties if available
+ const commonProps = ['name', 'status', 'type', 'slug', 'public_id'];
+ commonProps.forEach((prop) => {
+ if (resource[prop] !== undefined && resource[prop] !== null) {
+ props[prop] = resource[prop];
+ }
+ });
+
+ return props;
+ }
+
+ /**
+ * Enriches properties with user, organization, and timestamp context
+ *
+ * @private
+ * @param {Object} props - Base properties
+ * @returns {Object} Enriched properties
+ */
+ #enrichProperties(props) {
+ const eventsConfig = config?.events || {};
+ const enrichConfig = eventsConfig.enrich || {};
+ const enriched = { ...props };
+
+ // Add user context if enabled
+ if (enrichConfig.user !== false && this.currentUser?.user) {
+ enriched.user_id = this.currentUser.user.id;
+ }
+
+ // Add organization context if enabled
+ if (enrichConfig.organization !== false && this.currentUser?.organization) {
+ enriched.organization_id = this.currentUser.organization.id;
+ }
+
+ // Add timestamp if enabled
+ if (enrichConfig.timestamp !== false) {
+ enriched.timestamp = new Date().toISOString();
+ }
+
+ return enriched;
+ }
+
+ /**
+ * Safely extracts the model name from a resource
+ *
+ * @private
+ * @param {Object} resource - The resource/model
+ * @returns {String} Model name or 'unknown'
+ */
+ #getModelName(resource) {
+ if (!resource) {
+ return 'unknown';
+ }
+
+ // Try multiple ways to get model name
+ if (resource.constructor?.modelName) {
+ return resource.constructor.modelName;
+ }
+
+ if (resource._internalModel?.modelName) {
+ return resource._internalModel.modelName;
+ }
+
+ if (resource.modelName) {
+ return resource.modelName;
+ }
+
+ return 'unknown';
+ }
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +573 +574 +575 +576 +577 +578 +579 +580 +581 +582 +583 +584 +585 +586 +587 +588 +589 +590 +591 +592 +593 +594 +595 +596 +597 +598 +599 +600 +601 +602 +603 +604 +605 +606 +607 +608 +609 +610 +611 +612 +613 +614 +615 +616 +617 +618 +619 +620 +621 +622 +623 +624 +625 +626 +627 +628 +629 +630 +631 +632 +633 +634 +635 +636 +637 +638 +639 +640 +641 +642 +643 +644 +645 +646 +647 +648 +649 +650 +651 +652 +653 +654 +655 +656 +657 +658 +659 +660 +661 +662 +663 +664 +665 +666 +667 +668 +669 +670 +671 +672 +673 +674 +675 +676 +677 +678 +679 +680 +681 +682 +683 +684 +685 +686 +687 +688 +689 +690 +691 +692 +693 +694 +695 +696 +697 +698 +699 +700 +701 +702 +703 +704 +705 +706 +707 +708 +709 +710 +711 +712 +713 +714 +715 +716 +717 +718 | + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import Service from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+import { inject as service } from '@ember/service';
+import { get, set } from '@ember/object';
+import { isBlank } from '@ember/utils';
+import { dasherize, underscore } from '@ember/string';
+import { isArray } from '@ember/array';
+import { singularize, pluralize } from 'ember-inflector';
+import { task } from 'ember-concurrency';
+import { storageFor } from 'ember-local-storage';
+import { add, isAfter, parseISO } from 'date-fns';
+import { decompress as decompressJson } from 'compress-json';
+import config from 'ember-get-config';
+import corslite from '../utils/corslite';
+import getMimeType from '../utils/get-mime-type';
+import download from '../utils/download';
+import getUserOptions from '../utils/get-user-options';
+import isEmptyObject from '../utils/is-empty-object';
+import isObject from '../utils/is-object';
+import fetch from 'fetch';
+
+/* istanbul ignore if -- runs at import; whichever of adapters/application or services/fetch loads first sets the host, so the other can never enter */
+if (isBlank(config.API.host)) {
+ config.API.host = `${window.location.protocol}//${window.location.hostname}`;
+}
+
+export default class FetchService extends Service {
+ /**
+ * Creates an instance of FetchService.
+ * @memberof FetchService
+ */
+ constructor() {
+ super(...arguments);
+
+ this.headers = this.getHeaders();
+ this.host = get(config, 'API.host');
+ this.namespace = get(config, 'API.namespace');
+ }
+
+ /**
+ * Mutable headers property.
+ *
+ * @var {Array}
+ */
+ @tracked headers;
+
+ /**
+ * Mutable namespace property.
+ *
+ * @var {String}
+ */
+ @tracked namespace;
+
+ /**
+ * Mutable host property.
+ *
+ * @var {String}
+ */
+ @tracked host;
+
+ /**
+ * Gets headers that should be sent with request.
+ *
+ * @return {Object}
+ */
+ getHeaders() {
+ const headers = {};
+ const isAuthenticated = this.session.isAuthenticated;
+ const userId = this.session.data.authenticated.user;
+ const userOptions = getUserOptions();
+ const isSandbox = get(userOptions, `${userId}:sandbox`) === true;
+ // See the note in adapters/application.js: `setOption` dasherizes, so
+ // the stored key is `<user>:test-key`, not `<user>:testKey`.
+ const testKey = get(userOptions, `${userId}:test-key`);
+
+ headers['Content-Type'] = 'application/json';
+
+ if (isAuthenticated) {
+ headers['Authorization'] = `Bearer ${this.session.data.authenticated.token}`;
+ }
+
+ if (isAuthenticated && isSandbox) {
+ headers['Access-Console-Sandbox'] = true;
+ }
+
+ if (isAuthenticated && !isBlank(testKey)) {
+ headers['Access-Console-Sandbox-Key'] = testKey;
+ }
+
+ return headers;
+ }
+
+ /**
+ * Updates headers property before making request.
+ *
+ * @return {FetchService}
+ * @memberof FetchService
+ */
+ refreshHeaders() {
+ this.headers = this.getHeaders();
+
+ return this;
+ }
+
+ /**
+ * Allows namespace to be set before making fetch request.
+ *
+ * @param {String} namespace
+ * @return {FetchService}
+ * @memberof FetchService
+ */
+ setNamespace(namespace) {
+ this.namespace = namespace;
+
+ return this;
+ }
+
+ /**
+ * Allows host to be set before making fetch request.
+ *
+ * @param {String} host
+ * @return {FetchService}
+ * @memberof FetchService
+ */
+ setHost(host) {
+ this.host = host;
+
+ return this;
+ }
+
+ /**
+ * Credentials
+ *
+ * @var {String}
+ */
+ credentials = 'include';
+
+ /**
+ * Inject the `store` service
+ *
+ * @var {Service}
+ */
+ @service store;
+
+ /**
+ * Inject the `session` service
+ *
+ * @var {Service}
+ */
+ @service session;
+
+ /**
+ * Inject the `currentUser` service
+ *
+ * @var {Service}
+ */
+ @service currentUser;
+
+ /**
+ * Inject the `notifications` service
+ *
+ * @var {Service}
+ */
+ @service notifications;
+
+ /**
+ * Local cache for some static requests
+ *
+ * @var StorageObject
+ */
+ @storageFor('local-cache') localCache;
+
+ /**
+ * Normalizes a model response from fetch to a ember data model
+ *
+ * @param {Object} payload A response from a network request
+ * @param {String} modelType The type of model to be normalized too
+ *
+ * @return {Model} An ember model
+ */
+ normalizeModel(payload, modelType = null) {
+ if (modelType === null) {
+ const modelTypeKeys = Object.keys(payload);
+ // `Object.keys` returns a plain array, which has no `firstObject`
+ // once prototype extensions are off — this silently yielded
+ // undefined, so the payload was returned unnormalized.
+ modelType = modelTypeKeys.length ? modelTypeKeys[0] : false;
+ }
+
+ if (typeof modelType !== 'string') {
+ return payload;
+ }
+
+ const type = dasherize(singularize(modelType));
+ const pluralizedModelType = pluralize(underscore(modelType));
+
+ if (isArray(payload)) {
+ return payload.map((instance) => this.store.push(this.store.normalize(type, instance)));
+ }
+
+ if (isArray(payload[pluralizedModelType])) {
+ return payload[pluralizedModelType].map((instance) => this.store.push(this.store.normalize(type, instance)));
+ }
+
+ if (isArray(payload[modelType])) {
+ return payload[modelType].map((instance) => this.store.push(this.store.normalize(type, instance)));
+ }
+
+ if (!isBlank(payload) && isBlank(payload[modelType])) {
+ return this.jsonToModel(payload, type);
+ }
+
+ return this.store.push(this.store.normalize(type, payload[modelType]));
+ }
+
+ /**
+ * Normalizes a model response from a JSON object or string
+ *
+ * @param {Object} payload A response from a network request
+ * @param {String} modelType The type of model to be normalized too
+ *
+ * @return {Model} An ember model
+ */
+ jsonToModel(attributes = {}, modelType) {
+ if (typeof attributes === 'string') {
+ attributes = JSON.parse(attributes);
+ }
+
+ const type = dasherize(modelType);
+ const normalized = this.store.push(this.store.normalize(type, attributes));
+
+ return normalized;
+ }
+
+ /**
+ * Parses the JSON returned by a network request
+ *
+ * @param {Object} response A response from a network request
+ * @return {Object} The parsed JSON, status from the response
+ *
+ * @return {Promise}
+ */
+ async parseJSON(response) {
+ try {
+ const compressedHeader = await response.headers.get('x-compressed-json');
+ let json;
+
+ if (compressedHeader === '1') {
+ // Handle compressed json
+ const text = await response.text();
+ json = JSON.parse(text);
+ json = decompressJson(json);
+ json = JSON.parse(json);
+ } else {
+ // Handle regular json
+ json = await response.json();
+ }
+
+ return {
+ statusText: response.statusText,
+ status: response.status,
+ ok: response.ok,
+ json,
+ };
+ } catch (error) {
+ throw new Error('Error processing response: ' + error.message);
+ }
+ }
+
+ /**
+ * The base request method
+ *
+ * @param {String} path
+ * @param {String} method
+ * @param {Object} data
+ * @param {Object} options
+ *
+ * @return {Promise}
+ */
+ request(path, method = 'GET', data = {}, options = {}) {
+ const headers = Object.assign(this.getHeaders(), options.headers ?? {});
+ const host = options.host ?? this.host;
+ const namespace = options.namespace ?? this.namespace;
+ const url = options.externalRequest === true ? path : [host, namespace, path].filter(Boolean).join('/');
+
+ return new Promise((resolve, reject) => {
+ return fetch(url, {
+ method,
+ mode: options.mode || 'cors',
+ credentials: options.credentials || this.credentials,
+ headers,
+ ...data,
+ })
+ .then(this.parseJSON)
+ .then((response) => {
+ if (response.ok) {
+ if (options.normalizeToEmberData) {
+ const normalized = this.normalizeModel(response.json, options.normalizeModelType);
+
+ if (typeof options.onSuccess === 'function') {
+ options.onSuccess(normalized);
+ }
+
+ return resolve(normalized);
+ }
+
+ if (typeof options.onSuccess === 'function') {
+ options.onSuccess(response.json);
+ }
+
+ return resolve(response.json);
+ }
+
+ if (typeof options.onError === 'function') {
+ options.onError(response.json);
+ }
+
+ if (options.rawError) {
+ return reject(response.json);
+ }
+
+ if (isArray(response.json.errors)) {
+ // Decoded JSON is a plain array, so `firstObject` was
+ // undefined and every such error surfaced as the
+ // literal string "undefined".
+ return reject(new Error(response.json.errors[0] ?? response.statusText));
+ }
+
+ if (response.json.error && typeof response.json.error === 'string') {
+ return reject(new Error(response.json.error));
+ }
+
+ // error objects
+ if (response.json.error && isObject(response.json.error)) {
+ return reject(response.json.error);
+ }
+
+ if (response.json.message && typeof response.json.message === 'string') {
+ return reject(new Error(response.json.message));
+ }
+
+ return reject(response.json);
+ })
+ .catch(reject);
+ });
+ }
+
+ /**
+ * Makes a GET request with fetch
+ *
+ * @param {String} path
+ * @param {Object} query
+ * @param {Object} options
+ *
+ * @return {Promise}
+ */
+ get(path, query = {}, options = {}) {
+ // handle if want to request from cache
+ if (options.fromCache === true) {
+ return this.cachedGet(...arguments);
+ }
+
+ const urlParams = !isEmptyObject(query) ? new URLSearchParams(query).toString() : '';
+
+ return this.request(`${path}${urlParams ? '?' + urlParams : ''}`, 'GET', {}, options);
+ }
+
+ /**
+ * Makes a GET request with fetch, but if the fetch is stored in local cache,
+ * retrieve from storage to prevent unnecessary netwrok request
+ *
+ * @param {String} path
+ * @param {Object} query
+ * @param {Object} options
+ *
+ * @return {Promise}
+ */
+ cachedGet(path, query = {}, options = {}) {
+ const pathKey = dasherize(path);
+ const pathKeyVersion = new Date().toISOString();
+
+ const request = () => {
+ delete options.fromCache;
+ return this.get(path, query, options).then((response) => {
+ // cache the response
+ this.localCache.set(pathKey, response);
+ this.localCache.set(`${pathKey}-version`, pathKeyVersion);
+
+ // return response
+ return response;
+ });
+ };
+
+ // check to see if in storage already
+ if (this.localCache.get(pathKey)) {
+ return new Promise((resolve) => {
+ // get cached data
+ const data = this.localCache.get(pathKey);
+
+ // get the path key version value
+ const version = this.localCache.get(`${pathKey}-version`);
+ const expirationInterval = options.expirationInterval ?? 3;
+ const expirationIntervalUnit = pluralize(options.expirationIntervalUnit ?? 'days');
+
+ // Expire once the whole interval has passed since the cache version. (The
+ // interval's unit component alone would read a month-old cache as 0 days old.)
+ const shouldExpire = isAfter(new Date(), add(parseISO(version), { [expirationIntervalUnit]: expirationInterval }));
+
+ // if the version is older than 3 days clear it
+ if (!version || shouldExpire || options.clearData === true) {
+ this.flushRequestCache(path);
+ return request().then(resolve);
+ }
+
+ if (options.normalizeToEmberData) {
+ return resolve(this.normalizeModel(data, options.normalizeModelType));
+ }
+
+ // return cached response
+ return resolve(data);
+ });
+ }
+
+ // if no cached data request from server
+ return request();
+ }
+
+ /**
+ * Flushes the local cache for a specific path by setting its value and version to undefined.
+ *
+ * @param {string} path - The path for which the cache should be flushed.
+ */
+ flushRequestCache(path) {
+ const pathKey = dasherize(path);
+
+ this.localCache.set(pathKey, undefined);
+ this.localCache.set(`${pathKey}-version`, undefined);
+ }
+
+ /**
+ * Determines whether the cache should be reset by comparing the current version
+ * of the console with the cached version. If they differ, the cache is cleared
+ * and the new version is saved.
+ */
+ shouldResetCache() {
+ const consoleVersion = this.localCache.get('console-version');
+
+ if (!consoleVersion || consoleVersion !== config.APP.version) {
+ this.localCache.clear();
+ this.localCache.set('console-version', config.APP.version);
+ }
+ }
+
+ /**
+ * Makes a POST request with fetch
+ *
+ * @param {String} path
+ * @param {Object} data
+ * @param {Object} options
+ *
+ * @return {Promise}
+ */
+ post(path, data = {}, options = {}) {
+ return this.request(path, 'POST', { body: JSON.stringify(data) }, options);
+ }
+
+ /**
+ * Makes a PUT request with fetch
+ *
+ * @param {String} path
+ * @param {Object} data
+ * @param {Object} options
+ *
+ * @return {Promise}
+ */
+ put(path, data = {}, options = {}) {
+ return this.request(path, 'PUT', { body: JSON.stringify(data) }, options);
+ }
+
+ /**
+ * Makes a DELETE request with fetch
+ *
+ * @param {String} path
+ * @param {Object} data
+ * @param {Object} options
+ *
+ * @return {Promise}
+ */
+ delete(path, data = {}, options = {}) {
+ return this.request(path, 'DELETE', { body: JSON.stringify(data) }, options);
+ }
+
+ /**
+ * Makes a PATCH request with fetch
+ * @param {String} path
+ * @param {Object} data
+ * @param {Object} options
+ *
+ * @return {Promise}
+ */
+ patch(path, data = {}, options = {}) {
+ return this.request(path, 'PATCH', { body: JSON.stringify(data) }, options);
+ }
+
+ /**
+ * Makes a upload request with fetch
+ *
+ * @param {String} path
+ * @param {Array} files
+ * @param {Object} options
+ *
+ * @return {Promise}
+ */
+ upload(path, files = [], options = {}) {
+ const body = new FormData();
+ files.forEach((file) => {
+ body.append('file', file);
+ });
+ return this.request(path, 'POST', { body }, options);
+ }
+
+ /**
+ * Sends request to routing service.
+ *
+ * @param {Array} coordinates
+ * @param {Object} query
+ * @param {String} service
+ * @param {String} profile
+ * @param {String} version
+ */
+ routing(coordinates, query = {}, options = {}) {
+ let service = options?.service ?? 'trip';
+ let profile = options?.profile ?? 'driving';
+ let version = options?.version ?? 'v1';
+ let host = options?.host ?? `https://${options?.subdomain ?? 'routing'}.fleetbase.io`;
+ let route = coordinates.map((coords) => coords.join(',')).join(';');
+ let params = !isEmptyObject(query) ? new URLSearchParams(query).toString() : '';
+ let path = `${host}/${service}/${version}/${profile}/${route}`;
+ let url = `${path}${params ? '?' + params : ''}`;
+
+ return new Promise((resolve, reject) => {
+ corslite(url, (container, xhr) => {
+ if (!xhr || !xhr.response) {
+ reject(new Error('Request failed.'));
+ return;
+ }
+
+ let response = xhr.response;
+ let isJson = typeof response === 'string' && response.startsWith('{');
+
+ resolve(isJson ? JSON.parse(response) : response);
+ });
+ });
+ }
+
+ /**
+ * Concurrency task to handle a file upload
+ *
+ * @void
+ */
+ @task({ enqueue: true, maxConcurrency: 3 }) *uploadFile(file, params = {}, callback, errorCallback) {
+ const { queue } = file;
+ const headers = this.getHeaders();
+
+ // make sure this task runs once for this file in correct state
+ // this can occur when the task is called twice when upload button exists inside upload dropzone
+ if (['queued', 'failed', 'timed_out', 'aborted'].indexOf(file.state) === -1) return;
+
+ // remove Content-Type header
+ delete headers['Content-Type'];
+
+ try {
+ const upload = yield file
+ .upload(`${get(config, 'API.host')}/${get(config, 'API.namespace')}/files/upload`, {
+ data: {
+ ...params,
+ file_size: file.size,
+ },
+ withCredentials: true,
+ headers,
+ })
+ .then((response) => response.json())
+ .catch((error) => {
+ this.notifications.serverError(error, 'File upload failed.');
+
+ if (typeof errorCallback === 'function') {
+ errorCallback(error);
+ }
+ });
+
+ if (upload) {
+ const model = this.store.push(this.store.normalize('file', upload.file));
+ set(file, 'model', model);
+
+ if (typeof callback === 'function') {
+ callback(model);
+ }
+
+ return model;
+ }
+
+ return null;
+ } catch (error) {
+ queue.remove(file);
+ this.notifications.serverError(error, 'File upload failed.');
+
+ if (typeof errorCallback === 'function') {
+ errorCallback(error);
+ }
+ }
+ }
+
+ /**
+ * Downloads blob of the request path to user
+ *
+ * @param {String} path
+ * @param {Object} query
+ * @param {Object} options
+ *
+ * @return {Promise}
+ */
+ download(path, query = {}, options = {}) {
+ const headers = Object.assign(this.getHeaders(), options.headers ?? {});
+ const method = options.method ?? 'GET';
+ const credentials = options.credentials ?? this.credentials;
+ const baseUrl = options.externalRequest === true ? '' : `${options.host ?? this.host}/${options.namespace || this.namespace}`;
+ const isReadOnlyRequest = ['GET', 'HEAD'].includes(method.toUpperCase());
+ const params = isReadOnlyRequest && !isEmptyObject(query) ? `?${new URLSearchParams(query).toString()}` : '';
+ const body = !isReadOnlyRequest ? JSON.stringify(query) : {};
+ const fetchOptions = {
+ method,
+ credentials,
+ headers,
+ };
+
+ // Only supply body to fetch if not GET or HEAD request
+ if (!isReadOnlyRequest) {
+ fetchOptions.body = body;
+ }
+
+ return new Promise((resolve, reject) => {
+ return fetch(`${baseUrl ? baseUrl + '/' : ''}${path}${params}`, fetchOptions)
+ .then((response) => {
+ options.fileName = this.getFilenameFromResponse(response, options.fileName);
+ options.mimeType = this.getMimeTypeFromResponse(response, options.mimeType);
+
+ if (!options.mimeType) {
+ options.mimeType = getMimeType(options.fileName);
+ }
+
+ return response;
+ })
+ .then((response) => response.blob())
+ .then((blob) => resolve(download(blob, options.fileName, options.mimeType)))
+ .catch((error) => {
+ reject(error);
+ });
+ });
+ }
+
+ getFilenameFromResponse(response, defaultFilename = null) {
+ const contentDisposition = response.headers.get('content-disposition');
+ let fileName = defaultFilename;
+
+ // A name the caller asked for wins; the header only fills in when there is none.
+ if (contentDisposition && !defaultFilename) {
+ const results = /filename=(.*)/.exec(contentDisposition);
+
+ if (isArray(results) && results.length > 1) {
+ fileName = results[1];
+
+ // clean fileName
+ fileName = fileName.replaceAll('"', '');
+ }
+ }
+
+ return fileName;
+ }
+
+ getMimeTypeFromResponse(response, defaultMimeType = null) {
+ const contentType = response.headers.get('content-type');
+ let mimeType = defaultMimeType;
+
+ if (contentType) {
+ // The media type is everything before the first parameter, if there are any:
+ // `text/csv` and `text/csv; charset=utf-8` both mean `text/csv`.
+ mimeType = contentType.split(';')[0].trim();
+ }
+
+ return mimeType;
+ }
+
+ fetchOrderConfigurations(params = {}) {
+ return new Promise((resolve, reject) => {
+ this.request('fleet-ops/order-configs/get-installed', params)
+ .then((configs) => {
+ const serialized = [];
+
+ for (let i = 0; i < configs.length; i++) {
+ // `configs` is decoded JSON and `serialized` is a plain
+ // array literal; neither has the Ember array methods
+ // once prototype extensions are off.
+ const config = configs[i];
+ const normalizedConfig = this.store.normalize('order-config', config);
+ const serializedConfig = this.store.push(normalizedConfig);
+
+ serialized.push(serializedConfig);
+ }
+
+ resolve(serialized);
+ })
+ .catch((error) => {
+ reject(error);
+ });
+ });
+ }
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| current-user.js | +
+
+ |
+ 0% | +0/150 | +0% | +0/66 | +0% | +0/29 | +0% | +0/148 | +
| events.js | +
+
+ |
+ 0% | +0/75 | +0% | +0/57 | +0% | +0/17 | +0% | +0/75 | +
| fetch.js | +
+
+ |
+ 0.47% | +1/212 | +0.53% | +1/187 | +0% | +0/46 | +0.48% | +1/206 | +
| notifications.js | +
+
+ |
+ 0% | +0/13 | +0% | +0/12 | +0% | +0/2 | +0% | +0/13 | +
| session.js | +
+
+ |
+ 0% | +0/68 | +0% | +0/24 | +0% | +0/19 | +0% | +0/68 | +
| universe.js | +
+
+ |
+ 0% | +0/139 | +0% | +0/112 | +0% | +0/63 | +0% | +0/130 | +
| url-search-params.js | +
+
+ |
+ 0% | +0/52 | +0% | +0/9 | +0% | +0/20 | +0% | +0/52 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import EmberNotificationsService from 'ember-cli-notifications/services/notifications';
+import { isArray } from '@ember/array';
+import getWithDefault from '../utils/get-with-default';
+
+export default class NotificationsService extends EmberNotificationsService {
+ serverError(error, fallbackMessage = 'Oops! Something went wrong with your request.', options = {}) {
+ if (error && isArray(error.errors)) {
+ const errors = getWithDefault(error, 'errors');
+ const errorMessage = getWithDefault(errors, '0', fallbackMessage);
+
+ return this.error(errorMessage, options);
+ }
+
+ if (error instanceof Error) {
+ const errorMessage = getWithDefault(error, 'message', fallbackMessage);
+ return this.error(errorMessage, options);
+ }
+
+ if (typeof error === 'string') {
+ return this.error(error, options);
+ }
+
+ return this.error(fallbackMessage, options);
+ }
+
+ invoke(type, message, ...params) {
+ if (typeof message === 'function') {
+ this[type](message(...params));
+ } else {
+ this[type](message);
+ }
+ }
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import SimpleAuthSessionService from 'ember-simple-auth/services/session';
+import { tracked } from '@glimmer/tracking';
+import { inject as service } from '@ember/service';
+import { later } from '@ember/runloop';
+import { debug } from '@ember/debug';
+import getWithDefault from '../utils/get-with-default';
+
+/**
+ * SessionService
+ *
+ * Extends ember-simple-auth's session service to add:
+ *
+ * - Proper lifecycle event firing through the EventsService (which
+ * re-broadcasts on both the events bus and the universe bus).
+ * - Session start timestamp tracking for duration calculation.
+ *
+ * Session lifecycle events fired (via EventsService → universe bus):
+ *
+ * session.authenticated — fired after a successful login/session restore.
+ * Payload: (properties)
+ *
+ * session.invalidated — fired after the session is destroyed (logout).
+ * Payload: (duration_seconds, properties)
+ *
+ * user.deauthenticated — alias for session.invalidated, provided as a
+ * semantic convenience for integrations that want
+ * to react to the user identity being cleared
+ * (e.g. Intercom shutdown, PostHog reset).
+ * Payload: (duration_seconds, properties)
+ *
+ * All events are fired on both the EventsService Evented bus and the universe
+ * service, so listeners can use either:
+ *
+ * this.events.on('session.authenticated', handler)
+ * this.universe.on('session.authenticated', handler) ← recommended for engines
+ */
+export default class SessionService extends SimpleAuthSessionService {
+ @service router;
+ @service currentUser;
+ @service fetch;
+ @service notifications;
+ @service events;
+ @service universe;
+
+ /**
+ * Set where to transition to
+ *
+ * @var {String}
+ */
+ @tracked redirectTo = 'console';
+
+ /**
+ * If session is onboarding a user.
+ *
+ * @var {String}
+ */
+ @tracked _isOnboarding = false;
+
+ /**
+ * Timestamp (ms) when the session was authenticated.
+ * Used to calculate session duration on invalidation.
+ *
+ * @var {Number|null}
+ */
+ @tracked _sessionStartedAt = null;
+
+ /**
+ * Set this as onboarding.
+ *
+ * @return {SessionService}
+ */
+ isOnboarding() {
+ this._isOnboarding = true;
+
+ return this;
+ }
+
+ /**
+ * Manually authenticate user
+ */
+ manuallyAuthenticate(authToken) {
+ return this.session._setup('authenticator:fleetbase', { token: authToken }, true);
+ }
+
+ /**
+ * Overwrite the handle authentication method.
+ *
+ * Fires `session.authenticated` through the events service so that
+ * integrations (Intercom, PostHog, etc.) can react to a successful login.
+ *
+ * @void
+ */
+ async handleAuthentication() {
+ // Record session start time for duration tracking on logout
+ this._sessionStartedAt = Date.now();
+
+ // Fire session.authenticated event
+ this._fireSessionEvent('session.authenticated');
+
+ if (this._isOnboarding) {
+ return;
+ }
+
+ const loaderNode = this.showLoader('Starting session...');
+ const removeLoaderNode = () => {
+ later(
+ this,
+ () => {
+ // remove node from body
+ document.body.removeChild(loaderNode);
+ this.isLoaderNodeOpen = false;
+ },
+ 600 * 3
+ );
+ };
+ this.isLoaderNodeOpen = true;
+
+ try {
+ await this.router.transitionTo(this.redirectTo);
+ } catch (error) {
+ debug(`Session's handleAuthentication() failed to transition: ${error.message}`);
+ }
+
+ removeLoaderNode();
+ }
+
+ /**
+ * Extends the parent handleInvalidation method.
+ *
+ * IMPORTANT: super.handleInvalidation(routeAfterInvalidation) is called
+ * first to preserve the ember-simple-auth behaviour of redirecting the
+ * user to the login page (via handleSessionInvalidated). Our event
+ * firing happens after so it cannot interfere with the redirect.
+ *
+ * @param {String} routeAfterInvalidation - Passed through from ember-simple-auth
+ * @void
+ */
+ handleInvalidation(routeAfterInvalidation) {
+ // 1. Always call super first — this performs the actual post-logout
+ // redirect/reload that ember-simple-auth is responsible for.
+ super.handleInvalidation(routeAfterInvalidation);
+
+ const durationSeconds = this._sessionStartedAt ? Math.round((Date.now() - this._sessionStartedAt) / 1000) : null;
+
+ // 2. Fire session lifecycle events for integrations (Intercom, PostHog, etc.)
+ if (this.events) {
+ this.events.trackSessionTerminated(durationSeconds);
+ }
+
+ // 3. Fire user.deauthenticated directly on universe for framework-level
+ // uniformity — engines can listen without needing the events service.
+ this._fireSessionEvent('user.deauthenticated', { session_duration: durationSeconds });
+
+ // Reset session start time
+ this._sessionStartedAt = null;
+ }
+
+ /**
+ * Loads the current authenticated user
+ *
+ * @void
+ */
+ async loadCurrentUser() {
+ try {
+ const user = await this.currentUser.load();
+
+ if (!user) {
+ return this.invalidateWithLoader('Session authentication failed...');
+ }
+
+ return user;
+ } catch (error) {
+ await this.invalidateWithLoader(getWithDefault(error, 'message', 'Session authentication failed...'));
+ }
+ }
+
+ /**
+ * Loads the current authenticated user
+ *
+ * @param {Transition} transition
+ * @void
+ */
+ async promiseCurrentUser(transition = null) {
+ const invalidateWithLoader = this.invalidateWithLoader.bind(this);
+
+ // Abort and invalidate once, whichever way authentication failed.
+ const fail = async (error) => {
+ if (transition) {
+ transition.abort();
+ }
+
+ await invalidateWithLoader(error.message ?? 'Session authentication failed...');
+ throw error;
+ };
+
+ let user;
+ try {
+ user = await this.currentUser.promiseUser();
+ } catch (error) {
+ return fail(error);
+ }
+
+ if (!user) {
+ return fail(new Error('Session authentication failed...'));
+ }
+
+ return user;
+ }
+
+ /**
+ * Creates an HTML element node for a loading overlay with a message.
+ *
+ * @param {String} loadingMessage
+ * @return {HTMLElement} loader
+ */
+ showLoader(loadingMessage) {
+ const loader = document.createElement('div');
+ loader.classList.add('overloader');
+ loader.innerHTML = `<div class="flex items-center justify-center">
+ <div>
+ <svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" role="img" focusable="false" aria-hidden="true" data-icon="spinner-third" data-prefix="fad" id="ember240" class="svg-inline--fa fa-spinner-third fa-w-16 fa-spin ember-view text-sky-500 fa-spin-800ms mr-3"><g class="fa-group"><path class="fa-secondary" fill="currentColor" d="M478.71 364.58zm-22 6.11l-27.83-15.9a15.92 15.92 0 0 1-6.94-19.2A184 184 0 1 1 256 72c5.89 0 11.71.29 17.46.83-.74-.07-1.48-.15-2.23-.21-8.49-.69-15.23-7.31-15.23-15.83v-32a16 16 0 0 1 15.34-16C266.24 8.46 261.18 8 256 8 119 8 8 119 8 256s111 248 248 248c98 0 182.42-56.95 222.71-139.42-4.13 7.86-14.23 10.55-22 6.11z"></path><path class="fa-primary" fill="currentColor" d="M271.23 72.62c-8.49-.69-15.23-7.31-15.23-15.83V24.73c0-9.11 7.67-16.78 16.77-16.17C401.92 17.18 504 124.67 504 256a246 246 0 0 1-25 108.24c-4 8.17-14.37 11-22.26 6.45l-27.84-15.9c-7.41-4.23-9.83-13.35-6.2-21.07A182.53 182.53 0 0 0 440 256c0-96.49-74.27-175.63-168.77-183.38z"></path></g>
+ </svg>
+ </div>
+
+ <span class="font-semibold text-gray-700 dark:text-gray-100 test-sm">${loadingMessage}</span>
+ </div>`;
+ document.body.appendChild(loader);
+
+ return loader;
+ }
+
+ /**
+ * Invalidates the current session while displaying a loading message on the page.
+ *
+ * @param {String} loadingMessage
+ * @return {Promise}
+ */
+ invalidateWithLoader(loadingMessage = 'Ending session...') {
+ // if loader node is open already just invalidate
+ if (this.isLoaderNodeOpen === true) {
+ return this.session.invalidate();
+ }
+
+ const loaderNode = this.showLoader(loadingMessage);
+ this.isLoaderNodeOpen = true;
+
+ return this.session.invalidate().then(() => {
+ later(
+ this,
+ () => {
+ document.body.removeChild(loaderNode);
+ this.isLoaderNodeOpen = false;
+ },
+ 600
+ );
+ });
+ }
+
+ /**
+ * Set the redirect route after authentication
+ *
+ * @void
+ */
+ setRedirect(whereTo = 'console') {
+ this.redirectTo = whereTo;
+ }
+
+ /**
+ * Get session time expiry date in moment
+ *
+ * @return {Date}
+ */
+ getExpiresAtDate() {
+ return new Date(this.data.authenticated.expires_at);
+ }
+
+ /**
+ * Get session time expiry in seconds
+ *
+ * @return {Integer}
+ */
+ getSessionSecondsRemaining() {
+ const date = this.getExpiresAtDate();
+ const now = new Date();
+
+ return Math.round((date - now) / 1000);
+ }
+
+ /**
+ * Checks for the presence of two-factor authentication for a given user identity.
+ *
+ * @param {String} identity
+ * @return {Promise}
+ * @throws {Error}
+ */
+ checkForTwoFactor(identity) {
+ return this.fetch.get('two-fa/check', { identity }).catch((error) => {
+ throw new Error(error.message);
+ });
+ }
+
+ // =========================================================================
+ // Private helpers
+ // =========================================================================
+
+ /**
+ * Fires a named session lifecycle event on both the events service and
+ * directly on the universe service.
+ *
+ * Firing on universe directly (in addition to via events service) ensures
+ * that all engines and extensions receive the event on the shared framework-
+ * level bus regardless of whether the events service has fully initialised.
+ *
+ * Listeners can subscribe via:
+ * this.universe.on('session.authenticated', handler)
+ * this.universe.on('user.deauthenticated', handler)
+ * this.events.on('session.authenticated', handler)
+ *
+ * @private
+ * @param {String} eventName
+ * @param {Object} [extraProps={}]
+ */
+ _fireSessionEvent(eventName, extraProps = {}) {
+ // 1. Fire through the events service (dual-broadcasts on events + universe bus)
+ if (this.events) {
+ this.events.trackEvent(eventName, extraProps);
+ }
+
+ // 2. Also trigger directly on universe for framework-level uniformity —
+ // ensures the event reaches all engines even if events service is
+ // not yet available or not injected in a given engine context.
+ if (this.universe) {
+ this.universe.trigger(eventName, extraProps);
+ }
+ }
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +573 +574 +575 +576 +577 +578 +579 +580 +581 +582 +583 +584 +585 +586 +587 +588 +589 +590 +591 +592 +593 +594 +595 +596 +597 +598 +599 +600 +601 +602 +603 +604 +605 +606 +607 +608 +609 +610 +611 +612 +613 +614 +615 +616 +617 +618 +619 +620 +621 +622 +623 +624 +625 +626 +627 +628 +629 +630 +631 +632 +633 +634 +635 +636 +637 +638 +639 +640 +641 +642 +643 +644 +645 +646 +647 +648 +649 +650 +651 +652 +653 +654 +655 +656 +657 +658 +659 +660 +661 +662 +663 +664 +665 +666 +667 +668 +669 +670 +671 +672 +673 +674 +675 +676 +677 +678 +679 +680 +681 +682 +683 +684 +685 +686 +687 +688 +689 +690 +691 +692 +693 +694 +695 +696 +697 +698 +699 +700 +701 +702 +703 +704 +705 +706 +707 +708 +709 +710 +711 +712 +713 +714 +715 +716 +717 +718 +719 +720 +721 +722 +723 +724 +725 +726 +727 +728 +729 +730 +731 +732 +733 +734 +735 +736 +737 +738 +739 +740 +741 +742 +743 +744 +745 +746 +747 +748 +749 +750 +751 +752 +753 +754 +755 +756 +757 +758 +759 +760 +761 +762 +763 +764 +765 +766 +767 +768 +769 +770 +771 +772 +773 +774 +775 +776 +777 +778 +779 +780 +781 +782 +783 +784 +785 +786 +787 +788 +789 +790 +791 +792 +793 +794 +795 +796 +797 +798 +799 +800 +801 +802 +803 +804 +805 +806 +807 +808 +809 +810 +811 +812 +813 +814 +815 +816 +817 +818 +819 +820 +821 +822 +823 +824 +825 +826 +827 +828 +829 +830 +831 +832 +833 +834 +835 +836 +837 +838 +839 +840 +841 +842 +843 +844 +845 +846 +847 +848 +849 +850 +851 +852 +853 +854 +855 +856 +857 +858 +859 +860 +861 +862 +863 +864 +865 +866 +867 +868 +869 +870 +871 +872 +873 +874 +875 +876 +877 +878 +879 +880 +881 +882 +883 +884 +885 +886 +887 +888 +889 +890 +891 +892 +893 +894 +895 +896 +897 +898 +899 +900 +901 +902 +903 +904 +905 +906 +907 +908 +909 +910 +911 +912 +913 +914 +915 +916 +917 +918 +919 +920 +921 +922 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import Service from '@ember/service';
+import Evented from '@ember/object/evented';
+import { inject as service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+import { action } from '@ember/object';
+import { getOwner } from '@ember/application';
+import { A } from '@ember/array';
+import MenuItem from '../contracts/menu-item';
+
+/**
+ * UniverseService (Refactored)
+ *
+ * This is the new UniverseService that acts as a facade to the specialized sub-services.
+ * It maintains backward compatibility with the old API while delegating to the new architecture.
+ *
+ * The service decomposition provides:
+ * - ExtensionManager: Handles lazy loading of engines
+ * - RegistryService: Manages all registries using Ember's container
+ * - MenuService: Manages menu items and panels
+ * - WidgetService: Manages dashboard widgets
+ * - HookService: Manages application hooks
+ *
+ * @class UniverseService
+ * @extends Service
+ */
+export default class UniverseService extends Service.extend(Evented) {
+ // Inject specialized services
+ @service('universe/extension-manager') extensionManager;
+ @service('universe/registry-service') registryService;
+ @service('universe/menu-service') menuService;
+ @service('universe/widget-service') widgetService;
+ @service('universe/hook-service') hookService;
+ @service('universe/resource-view-service') resourceViewService;
+ @service router;
+ @service intl;
+ @service urlSearchParams;
+ @tracked applicationInstance;
+ @tracked initialLocation = { ...window.location };
+ @tracked bootCallbacks = A([]);
+
+ /**
+ * Set the application instance on this service and cascade to RegistryService
+ * Called by the instance initializer to ensure both services have access
+ * to the root application container
+ *
+ * @method setApplicationInstance
+ * @param {Application} application The root application instance
+ */
+ setApplicationInstance(application) {
+ this.applicationInstance = application;
+
+ // Cascade to all child services
+ if (this.registryService) {
+ this.registryService.setApplicationInstance(application);
+ }
+ if (this.extensionManager) {
+ this.extensionManager.setApplicationInstance(application);
+ }
+ if (this.menuService) {
+ this.menuService.setApplicationInstance(application);
+ }
+ if (this.widgetService) {
+ this.widgetService.setApplicationInstance(application);
+ }
+ if (this.hookService) {
+ this.hookService.setApplicationInstance(application);
+ }
+ }
+
+ /**
+ * Get a service by name
+ * Convenience method for extensions to access specialized services
+ *
+ * Supports multiple naming patterns:
+ * - "universe/menu-service" -> universe/menu-service
+ * - "menu-service" -> universe/menu-service
+ * - "menuService" -> universe/menu-service
+ * - "menu" -> universe/menu-service
+ * - "hooks" or "hook" -> universe/hook-service
+ * - "widgets" or "widget" -> universe/widget-service
+ * - "registry" -> universe/registry-service
+ * - "resource-view" or "resourceView" -> universe/resource-view-service
+ *
+ * @method getService
+ * @param {String} serviceName Service name in various formats
+ * @returns {Service} The service instance
+ */
+ getService(serviceName) {
+ const owner = getOwner(this);
+ let resolvedName = serviceName;
+
+ // Normalize the service name
+ if (!/\//.test(serviceName)) {
+ // No slash, might be camelCase or short name
+ const kebabCase = serviceName.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
+
+ // Map short names and variations to full service names
+ const nameMapping = {
+ hook: 'hook-service',
+ hooks: 'hook-service',
+ 'hook-service': 'hook-service',
+ menu: 'menu-service',
+ 'menu-service': 'menu-service',
+ widget: 'widget-service',
+ widgets: 'widget-service',
+ 'widget-service': 'widget-service',
+ registry: 'registry-service',
+ 'registry-service': 'registry-service',
+ 'resource-view': 'resource-view-service',
+ 'resource-views': 'resource-view-service',
+ 'resource-view-service': 'resource-view-service',
+ };
+
+ const mappedName = nameMapping[kebabCase] || kebabCase;
+ resolvedName = `universe/${mappedName}`;
+ } else if (serviceName.startsWith('universe/')) {
+ // Already has universe/ prefix, ensure it's using -service naming
+ resolvedName = serviceName;
+ }
+
+ return owner.lookup(`service:${resolvedName}`);
+ }
+
+ // ============================================================================
+ // Extension Management (delegates to ExtensionManager)
+ // ============================================================================
+
+ /**
+ * Ensure an engine is loaded
+ *
+ * @method ensureEngineLoaded
+ * @param {String} engineName Engine name
+ * @returns {Promise<EngineInstance>} Engine instance
+ */
+ async ensureEngineLoaded(engineName) {
+ return this.extensionManager.ensureEngineLoaded(engineName);
+ }
+
+ /**
+ * Get an engine instance
+ *
+ * @method getEngineInstance
+ * @param {String} engineName Engine name
+ * @returns {EngineInstance|null} Engine instance or null
+ */
+ getEngineInstance(engineName) {
+ return this.extensionManager.getEngineInstance(engineName);
+ }
+
+ /**
+ * Register an extension
+ *
+ * @method registerExtension
+ * @param {String} name Extension name
+ * @param {Object} metadata Extension metadata
+ */
+ registerExtension(name, metadata = {}) {
+ this.extensionManager.registerExtension(name, metadata);
+ }
+
+ /**
+ * Listen for a specific engine to be loaded
+ * Note: This uses event listeners and will NOT run if the engine is already loaded.
+ * Use whenEngineLoaded() if you want to handle both cases.
+ *
+ * @method onEngineLoaded
+ * @param {String} engineName The engine name to listen for
+ * @param {Function} callback Function to call when the engine loads, receives engineInstance as parameter
+ * @example
+ * universe.onEngineLoaded('@fleetbase/fleetops-engine', (engineInstance) => {
+ * console.log('FleetOps engine loaded!', engineInstance);
+ * });
+ */
+ onEngineLoaded(engineName, callback) {
+ this.extensionManager.on('engine.loaded', (name, instance) => {
+ if (name === engineName) {
+ callback(instance);
+ }
+ });
+ }
+
+ /**
+ * Execute a callback when an engine is loaded
+ * If the engine is already loaded, the callback runs immediately
+ * Otherwise, it's stored and runs when the engine loads
+ *
+ * This is the recommended way to handle engine-dependent setup.
+ *
+ * @method whenEngineLoaded
+ * @param {String} engineName The engine name
+ * @param {Function} callback Function to call, receives (engineInstance, universe, appInstance)
+ * @example
+ * // Replaces this pattern:
+ * if (universe.extensionManager.isEngineLoaded('@fleetbase/fleetops-engine')) {
+ * const engine = universe.extensionManager.getEngineInstance('@fleetbase/fleetops-engine');
+ * doSomething(engine);
+ * } else {
+ * universe.onEngineLoaded('@fleetbase/fleetops-engine', (engine) => {
+ * doSomething(engine);
+ * });
+ * }
+ *
+ * // With this simpler pattern:
+ * universe.whenEngineLoaded('@fleetbase/fleetops-engine', (engine) => {
+ * doSomething(engine);
+ * });
+ */
+ whenEngineLoaded(engineName, callback) {
+ return this.extensionManager.whenEngineLoaded(engineName, callback);
+ }
+
+ /**
+ * Get the application instance
+ *
+ * @method getApplicationInstance
+ * @returns {ApplicationInstance} The application instance
+ */
+ getApplicationInstance() {
+ return this.applicationInstance;
+ }
+
+ /**
+ * Get a service from a specific engine
+ *
+ * @method getServiceFromEngine
+ * @param {String} engineName The engine name
+ * @param {String} serviceName The service name
+ * @param {Object} options Optional options
+ * @param {Object} options.inject Properties to inject into the service
+ * @returns {Service|null} The service instance or null
+ * @example
+ * const userService = universe.getServiceFromEngine('user-engine', 'user');
+ * if (userService) {
+ * userService.doSomething();
+ * }
+ */
+ getServiceFromEngine(engineName, serviceName, options = {}) {
+ const engineInstance = this.getEngineInstance(engineName);
+
+ if (engineInstance && typeof serviceName === 'string') {
+ const serviceInstance = engineInstance.lookup(`service:${serviceName}`);
+ if (options && options.inject) {
+ for (let injectionName in options.inject) {
+ serviceInstance[injectionName] = options.inject[injectionName];
+ }
+ }
+ return serviceInstance;
+ }
+
+ return null;
+ }
+
+ // ============================================================================
+ // Registry Management (delegates to RegistryService)
+ // ============================================================================
+
+ /**
+ * Create a new registry
+ *
+ * @method createRegistry
+ * @param {String} name Registry name
+ * @returns {Array} The created registry
+ */
+ createRegistry(name) {
+ return this.registryService.createRegistry(name);
+ }
+
+ /**
+ * Create multiple registries
+ *
+ * @method createRegistries
+ * @param {Array} names Array of registry names
+ */
+ createRegistries(names) {
+ this.registryService.createRegistries(names);
+ }
+
+ /**
+ * Get a registry
+ *
+ * @method getRegistry
+ * @param {String} name Registry name
+ * @param {String} listName The list within the registry (default 'menu-item', as createRegistry)
+ * @returns {Array} Registry items
+ */
+ getRegistry(name, listName = 'menu-item') {
+ return this.registryService.getRegistry(name, listName);
+ }
+
+ /**
+ * Register an item to a registry
+ *
+ * @method registerInRegistry
+ * @param {String} registryName Registry name
+ * @param {String} key Item key
+ * @param {*} value Item value
+ * @param {String} listName The list within the registry (default 'menu-item', as createRegistry)
+ */
+ registerInRegistry(registryName, key, value, listName = 'menu-item') {
+ this.registryService.register(registryName, listName, key, value);
+ }
+
+ /**
+ * Lookup an item from a registry
+ *
+ * @method lookupFromRegistry
+ * @param {String} registryName Registry name
+ * @param {String} key Item key
+ * @param {String} listName The list within the registry (default 'menu-item', as createRegistry)
+ * @returns {*} The registered item
+ */
+ lookupFromRegistry(registryName, key, listName = 'menu-item') {
+ return this.registryService.lookup(registryName, listName, key);
+ }
+
+ // ============================================================================
+ // Resource View Registries (delegates to ResourceViewService)
+ // ============================================================================
+
+ /**
+ * Register columns, actions or buttons into a table or details view slot.
+ *
+ * @method registerInResourceView
+ * @param {String} registryName e.g. 'fleet-ops:driver:table:columns'
+ * @param {Object|Array} items TableColumn, ResourceAction or ActionButton contracts
+ * @returns {Boolean}
+ *
+ * @example
+ * universe.registerInResourceView('ledger:invoice:details:menu', new ResourceAction({ id: 'acme-print', label: 'Print', fn: (invoice) => … }));
+ */
+ registerInResourceView(registryName, items) {
+ return this.resourceViewService.register(registryName, items);
+ }
+
+ registerTableColumn(extension, resource, column) {
+ return this.resourceViewService.registerTableColumn(extension, resource, column);
+ }
+
+ registerRowAction(extension, resource, action) {
+ return this.resourceViewService.registerRowAction(extension, resource, action);
+ }
+
+ registerBulkAction(extension, resource, action) {
+ return this.resourceViewService.registerBulkAction(extension, resource, action);
+ }
+
+ registerTableAction(extension, resource, button) {
+ return this.resourceViewService.registerTableAction(extension, resource, button);
+ }
+
+ registerDetailsAction(extension, resource, button) {
+ return this.resourceViewService.registerDetailsAction(extension, resource, button);
+ }
+
+ registerDetailsMenuItem(extension, resource, action) {
+ return this.resourceViewService.registerDetailsMenuItem(extension, resource, action);
+ }
+
+ // ============================================================================
+ // Application Container Registration (delegates to RegistryService)
+ // ============================================================================
+
+ /**
+ * Registers a component to the root application container.
+ * This ensures the component is available to all engines and the host app.
+ * @method registerComponent
+ * @param {String} name The component name (e.g., 'my-component')
+ * @param {Class} componentClass The component class
+ * @param {Object} options Registration options
+ */
+ registerComponent(name, componentClass, options = {}) {
+ this.registryService.registerComponent(name, componentClass, options);
+ }
+
+ /**
+ * Registers a service to the root application container.
+ * This ensures the service is available to all engines and the host app.
+ * @method registerService
+ * @param {String} name The service name (e.g., 'my-service')
+ * @param {Class} serviceClass The service class
+ * @param {Object} options Registration options
+ */
+ registerService(name, serviceClass, options = {}) {
+ this.registryService.registerService(name, serviceClass, options);
+ }
+
+ // ============================================================================
+ // Menu Management (delegates to MenuService)
+ // ============================================================================
+
+ /**
+ * Register a header menu item
+ *
+ * @method registerHeaderMenuItem
+ * @param {MenuItem|String} menuItemOrTitle MenuItem instance or title
+ * @param {String} route Optional route
+ * @param {Object} options Optional options
+ */
+ registerHeaderMenuItem(menuItemOrTitle, route = null, options = {}) {
+ this.menuService.registerHeaderMenuItem(menuItemOrTitle, route, options);
+ }
+
+ /**
+ * Register an organization menu item
+ *
+ * @method registerOrganizationMenuItem
+ * @param {MenuItem|String} menuItemOrTitle MenuItem instance or title
+ * @param {Object} options Optional options
+ */
+ registerOrganizationMenuItem(menuItemOrTitle, options = {}) {
+ this.menuService.registerOrganizationMenuItem(menuItemOrTitle, options);
+ }
+
+ /**
+ * Register a user menu item
+ *
+ * @method registerUserMenuItem
+ * @param {MenuItem|String} menuItemOrTitle MenuItem instance or title
+ * @param {Object} options Optional options
+ */
+ registerUserMenuItem(menuItemOrTitle, options = {}) {
+ this.menuService.registerUserMenuItem(menuItemOrTitle, options);
+ }
+
+ /**
+ * Register an admin menu panel
+ *
+ * @method registerAdminMenuPanel
+ * @param {MenuPanel|String} panelOrTitle MenuPanel instance or title
+ * @param {Array} items Optional items
+ * @param {Object} options Optional options
+ */
+ registerAdminMenuPanel(panelOrTitle, items = [], options = {}) {
+ this.menuService.registerAdminMenuPanel(panelOrTitle, items, options);
+ }
+
+ /**
+ * Register a settings menu item
+ *
+ * @method registerSettingsMenuItem
+ * @param {MenuItem|String} menuItemOrTitle MenuItem instance or title
+ * @param {Object} options Optional options
+ */
+ registerSettingsMenuItem(menuItemOrTitle, options = {}) {
+ this.menuService.registerSettingsMenuItem(menuItemOrTitle, options);
+ }
+
+ /**
+ * Register a menu item to a custom registry
+ *
+ * @method registerMenuItem
+ * @param {String} registryName Registry name
+ * @param {MenuItem|String} menuItemOrTitle MenuItem instance or title
+ * @param {String|Object} routeOrOptions Route or options
+ * @param {Object} options Optional options
+ */
+ registerMenuItem(registryName, menuItemOrTitle, routeOrOptions = {}, options = {}) {
+ this.menuService.registerMenuItem(registryName, menuItemOrTitle, routeOrOptions, options);
+ }
+
+ /**
+ * Get header menu items
+ *
+ * @computed headerMenuItems
+ * @returns {Array} Header menu items
+ */
+ get headerMenuItems() {
+ return this.menuService.getHeaderMenuItems();
+ }
+
+ /**
+ * Get organization menu items
+ *
+ * @computed organizationMenuItems
+ * @returns {Array} Organization menu items
+ */
+ get organizationMenuItems() {
+ return this.menuService.getOrganizationMenuItems();
+ }
+
+ /**
+ * Get user menu items
+ *
+ * @computed userMenuItems
+ * @returns {Array} User menu items
+ */
+ get userMenuItems() {
+ return this.menuService.getUserMenuItems();
+ }
+
+ /**
+ * Get admin menu items
+ *
+ * @computed adminMenuItems
+ * @returns {Array} Admin menu items
+ */
+ get adminMenuItems() {
+ return this.menuService.getAdminMenuItems();
+ }
+
+ /**
+ * Get admin menu panels
+ *
+ * @computed adminMenuPanels
+ * @returns {Array} Admin menu panels
+ */
+ get adminMenuPanels() {
+ return this.menuService.getAdminMenuPanels();
+ }
+
+ // ============================================================================
+ // Widget Management (delegates to WidgetService)
+ // ============================================================================
+
+ /**
+ * Register default dashboard widgets
+ *
+ * @method registerDefaultDashboardWidgets
+ * @param {Array<Widget>} widgets Array of widgets
+ */
+ registerDefaultDashboardWidgets(widgets) {
+ this.widgetService.registerDefaultDashboardWidgets(widgets);
+ }
+
+ /**
+ * Register dashboard widgets
+ *
+ * @method registerDashboardWidgets
+ * @param {Array<Widget>} widgets Array of widgets
+ */
+ registerDashboardWidgets(widgets) {
+ this.widgetService.registerDashboardWidgets(widgets);
+ }
+
+ /**
+ * Register a dashboard
+ *
+ * @method registerDashboard
+ * @param {String} name Dashboard name
+ * @param {Object} options Dashboard options
+ */
+ registerDashboard(name, options = {}) {
+ this.widgetService.registerDashboard(name, options);
+ }
+
+ /**
+ * Register a dashboard render slot.
+ *
+ * @method registerDashboardSlot
+ * @param {String} slotId Dashboard slot identifier
+ * @param {Object} options Slot options
+ */
+ registerDashboardSlot(slotId, options = {}) {
+ this.widgetService.registerDashboardSlot(slotId, options);
+ }
+
+ /**
+ * Register a system dashboard for a render slot.
+ *
+ * @method registerDashboardForSlot
+ * @param {String} slotId Dashboard slot identifier
+ * @param {String} dashboardName Dashboard widget namespace
+ * @param {Object} options Dashboard slot options
+ */
+ registerDashboardForSlot(slotId, dashboardName, options = {}) {
+ this.widgetService.registerDashboardForSlot(slotId, dashboardName, options);
+ }
+
+ /**
+ * Set the first system dashboard for a render slot.
+ *
+ * @method setDefaultDashboardForSlot
+ * @param {String} slotId Dashboard slot identifier
+ * @param {String} dashboardName Dashboard widget namespace
+ */
+ setDefaultDashboardForSlot(slotId, dashboardName) {
+ this.widgetService.setDefaultDashboardForSlot(slotId, dashboardName);
+ }
+
+ /**
+ * Set the dashboard that should load first on the console home.
+ *
+ * @method setConsoleDashboard
+ * @param {String} dashboardName Dashboard widget namespace
+ */
+ setConsoleDashboard(dashboardName) {
+ this.widgetService.setConsoleDashboard(dashboardName);
+ }
+
+ /**
+ * Get dashboard widgets
+ *
+ * @computed dashboardWidgets
+ * @returns {Object} Dashboard widgets object
+ */
+ get dashboardWidgets() {
+ return {
+ // The dashboard the deprecated registerDashboardWidgets() and
+ // registerDefaultDashboardWidgets() register into.
+ defaultWidgets: this.widgetService.getDefaultWidgets('dashboard'),
+ widgets: this.widgetService.getWidgets('dashboard'),
+ };
+ }
+
+ // ============================================================================
+ // Hook Management (delegates to HookService)
+ // ============================================================================
+
+ /**
+ * Register a hook
+ *
+ * @method registerHook
+ * @param {Hook|String} hookOrName Hook instance or name
+ * @param {Function} handler Optional handler
+ * @param {Object} options Optional options
+ */
+ registerHook(hookOrName, handler = null, options = {}) {
+ this.hookService.registerHook(hookOrName, handler, options);
+ }
+
+ /**
+ * Execute hooks
+ *
+ * @method executeHook
+ * @param {String} hookName Hook name
+ * @param {...*} args Arguments to pass to hooks
+ * @returns {Promise<Array>} Array of hook results
+ */
+ async executeHook(hookName, ...args) {
+ return this.hookService.execute(hookName, ...args);
+ }
+
+ /**
+ * Get hooks
+ *
+ * @computed hooks
+ * @returns {Object} Hooks object
+ */
+ get hooks() {
+ return this.hookService.hooks;
+ }
+
+ // ============================================================================
+ // Utility Methods
+ // ============================================================================
+
+ /**
+ * Get view from transition
+ *
+ * @method getViewFromTransition
+ * @param {Object} transition Transition object
+ * @returns {String|null} View parameter
+ */
+ getViewFromTransition(transition) {
+ const queryParams = transition.to?.queryParams ?? { view: null };
+ return queryParams.view;
+ }
+
+ /**
+ * Virtual route redirect
+ * Handles redirecting to menu items based on URL slug
+ *
+ * @method virtualRouteRedirect
+ * @param {Object} transition Transition object
+ * @param {String} registryName Registry name
+ * @param {String} route Route name
+ * @param {Object} options Options
+ * @returns {Promise} Transition promise
+ */
+ async virtualRouteRedirect(transition, registryName, route, options = {}) {
+ const view = this.getViewFromTransition(transition);
+ const slug = window.location.pathname.replace('/', '');
+ const queryParams = this.urlSearchParams.all();
+ const menuItem = this.lookupMenuItemFromRegistry(registryName, slug, view);
+
+ if (menuItem && transition.from === null) {
+ return this.transitionMenuItem(route, menuItem, { queryParams }).then((transition) => {
+ if (options && options.restoreQueryParams === true) {
+ this.urlSearchParams.setParamsToCurrentUrl(queryParams);
+ }
+ return transition;
+ });
+ }
+ }
+
+ /**
+ * Transition to a menu item
+ * Handles section, slug, and view parameters for virtual routes
+ *
+ * @method transitionMenuItem
+ * @param {String} route Route name
+ * @param {Object} menuItem Menu item object with slug, view, and optional section
+ * @param {Object} options
+ * @param {Object} options.queryParams Query params to carry into the transition
+ * @returns {Transition} The router transition
+ */
+ @action
+ transitionMenuItem(route, menuItem, options = {}) {
+ const { slug, view, section } = menuItem;
+ const params = section && slug ? [section, slug] : [slug];
+ // The menu item's own view wins over a `view` among the carried query params.
+ const queryParams = { ...options.queryParams, ...(view ? { view } : {}) };
+
+ if (Object.keys(queryParams).length) {
+ return this.router.transitionTo(route, ...params, { queryParams });
+ }
+
+ return this.router.transitionTo(route, ...params);
+ }
+
+ /**
+ * Register a boot callback
+ *
+ * @method onBoot
+ * @param {Function} callback Callback function
+ */
+ onBoot(callback) {
+ if (typeof callback === 'function') {
+ this.bootCallbacks.pushObject(callback);
+ }
+ }
+
+ /**
+ * Execute boot callbacks
+ *
+ * @method executeBootCallbacks
+ */
+ async executeBootCallbacks() {
+ for (const callback of this.bootCallbacks) {
+ try {
+ await callback(this);
+ } catch (error) {
+ console.error('Error executing boot callback:', error);
+ }
+ }
+
+ // Mark boot as complete
+ this.extensionManager.finishBoot();
+ }
+
+ // ============================================================================
+ // Backward Compatibility Methods
+ // ============================================================================
+
+ /**
+ * Get menu items from a registry
+ * Backward compatibility facade
+ *
+ * @method getMenuItemsFromRegistry
+ * @param {String} registryName Registry name
+ * @returns {Array} Menu items
+ */
+ getMenuItemsFromRegistry(registryName) {
+ return this.menuService.getMenuItems(registryName) || A([]);
+ }
+
+ /**
+ * Get menu panels from a registry
+ * Backward compatibility facade
+ *
+ * @method getMenuPanelsFromRegistry
+ * @param {String} registryName Registry name
+ * @returns {Array} Menu panels
+ */
+ getMenuPanelsFromRegistry(registryName) {
+ return this.menuService.getMenuPanels(registryName) || A([]);
+ }
+
+ /**
+ * Lookup a menu item from a registry
+ * Backward compatibility facade
+ *
+ * @method lookupMenuItemFromRegistry
+ * @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
+ */
+ lookupMenuItemFromRegistry(registryName, slug, view = null, section = null) {
+ const items = this.getMenuItemsFromRegistry(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;
+ });
+ }
+
+ /**
+ * Create a registry event
+ * Backward compatibility facade
+ *
+ * @method createRegistryEvent
+ * @param {String} registryName Registry name
+ * @param {String} eventName Event name
+ * @param {...*} args Event arguments
+ */
+ createRegistryEvent(registryName, eventName, ...args) {
+ this.trigger(`${registryName}:${eventName}`, ...args);
+ }
+
+ /**
+ * Register after boot callback
+ * Backward compatibility facade
+ *
+ * @method afterBoot
+ * @param {Function} callback Callback function
+ */
+ afterBoot(callback) {
+ this.extensionManager.afterBoot(callback);
+ }
+
+ /**
+ * Create a menu item (internal helper)
+ * Backward compatibility helper
+ *
+ * @method _createMenuItem
+ * @param {String} title Menu item title
+ * @param {String} route Menu item route
+ * @param {Object} options Menu item options
+ * @returns {Object} Menu item object
+ */
+ _createMenuItem(title, route = null, options = {}) {
+ const menuItem = new MenuItem(title, route);
+
+ if (options.icon) menuItem.withIcon(options.icon);
+ if (options.component) menuItem.withComponent(options.component);
+ if (options.slug) menuItem.withSlug(options.slug);
+ if (options.section) menuItem.inSection(options.section);
+ if (options.priority) menuItem.withPriority(options.priority);
+ if (options.type) menuItem.withType(options.type);
+ if (options.wrapperClass) menuItem.withWrapperClass(options.wrapperClass);
+ if (options.queryParams) menuItem.withQueryParams(options.queryParams);
+ if (options.onClick) menuItem.withOnClick(options.onClick);
+
+ return menuItem.toObject();
+ }
+
+ /**
+ * Register a renderable component for cross-engine rendering
+ * Facade method - delegates to RegistryService
+ *
+ * @method registerRenderableComponent
+ * @param {String} registryName Registry name (slot identifier)
+ * @param {Object|Class|Array} component ExtensionComponent definition, component class, or array of either
+ * @param {Object} options Optional configuration
+ *
+ * @example
+ * // ExtensionComponent definition with path (lazy loading)
+ * universe.registerRenderableComponent(
+ * 'fleet-ops:component:order:details',
+ * new ExtensionComponent('@fleetbase/storefront-engine', 'storefront-order-summary')
+ * );
+ */
+ registerRenderableComponent(registryName, component, options = {}) {
+ return this.registryService.registerRenderableComponent(registryName, component, options);
+ }
+
+ /**
+ * Get renderable components from a registry
+ * Backward compatibility method - delegates to RegistryService
+ *
+ * @method getRenderableComponentsFromRegistry
+ * @param {String} registryName Registry name
+ * @returns {Array} Array of component definitions/classes
+ */
+ getRenderableComponentsFromRegistry(registryName) {
+ return this.registryService.getRenderableComponents(registryName);
+ }
+
+ /**
+ * Register a helper to the application container
+ * Makes the helper available globally to all engines and the host app
+ * Facade method - delegates to RegistryService
+ *
+ * @method registerHelper
+ * @param {String} helperName The helper name (e.g., 'calculate-delivery-fee')
+ * @param {Function|Class|TemplateHelper} helperClassOrTemplateHelper Helper function, class, or TemplateHelper instance
+ * @param {Object} options Registration options
+ * @returns {Promise<void>}
+ *
+ * @example
+ * // Direct function registration
+ * await universe.registerHelper('calculate-delivery-fee', calculateDeliveryFeeHelper);
+ *
+ * @example
+ * // Lazy loading from engine (ensures engine is loaded first)
+ * import TemplateHelper from '@fleetbase/ember-core/contracts/template-helper';
+ * await universe.registerHelper(
+ * 'calculate-delivery-fee',
+ * new TemplateHelper('@fleetbase/storefront-engine', 'helpers/calculate-delivery-fee')
+ * );
+ */
+ async registerHelper(helperName, helperClassOrTemplateHelper, options = {}) {
+ return await this.registryService.registerHelper(helperName, helperClassOrTemplateHelper, options);
+ }
+
+ /**
+ * Legacy method for registering components in engines
+ * Maintained for backward compatibility
+ *
+ * @method registerComponentInEngine
+ * @param {String} engineName Engine name
+ * @param {*} componentClass Component class
+ * @param {Object} options Options
+ */
+ async registerComponentInEngine(engineName, componentClass, options = {}) {
+ const engineInstance = await this.ensureEngineLoaded(engineName);
+
+ if (engineInstance && componentClass && typeof componentClass.name === 'string') {
+ const dasherized = componentClass.name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
+ engineInstance.register(`component:${componentClass.name}`, componentClass);
+ engineInstance.register(`component:${dasherized}`, componentClass);
+
+ if (options.registerAs) {
+ engineInstance.register(`component:${options.registerAs}`, componentClass);
+ }
+ }
+ }
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import Service, { inject as service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+import { getOwner } from '@ember/application';
+import Hook from '../../contracts/hook';
+import HookRegistry from '../../contracts/hook-registry';
+
+/**
+ * HookService
+ *
+ * Manages application lifecycle hooks and custom event hooks.
+ * Allows extensions to inject logic at specific points in the application.
+ *
+ * @class HookService
+ * @extends Service
+ */
+export default class HookService extends Service {
+ @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;
+
+ #hookRegistry = null;
+
+ /**
+ * The shared hook registry, resolved on first use.
+ *
+ * This is deliberately lazy rather than resolved in the constructor:
+ * #getApplication prefers an explicitly set applicationInstance over the
+ * owner, and setApplicationInstance can only run after construction. Doing
+ * the lookup here means that preference is actually honoured.
+ *
+ * Read-only. It was a writable field before, but nothing in the addon, its
+ * tests or the console app ever assigned it, and replacing a memoized
+ * container lookup from outside is not a meaningful operation.
+ *
+ * @type {HookRegistry}
+ */
+ get hookRegistry() {
+ return this.#resolveHookRegistry();
+ }
+
+ /**
+ * Memoized resolution, kept out of the getter body so a property read is not
+ * itself an assignment.
+ *
+ * @private
+ * @returns {HookRegistry}
+ */
+ #resolveHookRegistry() {
+ if (!this.#hookRegistry) {
+ this.#hookRegistry = this.#initializeHookRegistry();
+ }
+
+ return this.#hookRegistry;
+ }
+
+ /**
+ * Set the application instance
+ *
+ * @method setApplicationInstance
+ * @param {Application} application The root application instance
+ */
+ setApplicationInstance(application) {
+ this.applicationInstance = application;
+ }
+
+ /**
+ * Initialize shared hook registry singleton
+ * Ensures all HookService instances share the same hooks
+ *
+ * @private
+ * @returns {HookRegistry}
+ */
+ #initializeHookRegistry() {
+ const registryKey = 'registry:hooks';
+ const application = this.#getApplication();
+
+ if (!application.hasRegistration(registryKey)) {
+ application.register(registryKey, new HookRegistry(), {
+ instantiate: false,
+ });
+ }
+
+ return application.resolveRegistration(registryKey);
+ }
+
+ /**
+ * Get the application instance
+ * Tries multiple fallback methods to find the root application
+ *
+ * @private
+ * @returns {Application}
+ */
+ #getApplication() {
+ // First priority use the universe application instance
+ if (this.universe.applicationInstance) {
+ return this.universe.applicationInstance;
+ }
+
+ // Second priority: use applicationInstance if set
+ if (this.applicationInstance) {
+ return this.applicationInstance;
+ }
+
+ // Third priority: try to get application from owner
+ const owner = getOwner(this);
+ if (owner && owner.application) {
+ return owner.application;
+ }
+
+ // Last resort: return owner itself (might be EngineInstance)
+ return owner;
+ }
+
+ /**
+ * Getter and setter for hooks property
+ * Delegates to the shared hookRegistry object
+ */
+ get hooks() {
+ return this.hookRegistry.hooks;
+ }
+
+ set hooks(value) {
+ this.hookRegistry.hooks = value;
+ }
+
+ /**
+ * Find a specific hook
+ *
+ * @private
+ * @method #findHook
+ * @param {String} hookName Hook name
+ * @param {String} hookId Hook ID
+ * @returns {Object|null} Hook or null
+ */
+ #findHook(hookName, hookId) {
+ const hookList = this.hooks[hookName] || [];
+ return hookList.find((h) => h.id === hookId) || null;
+ }
+
+ /**
+ * Normalize a hook input to a plain object
+ *
+ * @private
+ * @method #normalizeHook
+ * @param {Hook|String} input Hook instance or hook name
+ * @param {Function} handler Optional handler
+ * @param {Object} options Optional options
+ * @returns {Object} Normalized hook object
+ */
+ #normalizeHook(input, handler, options) {
+ if (input instanceof Hook) {
+ return input.toObject();
+ }
+
+ if (typeof input === 'string') {
+ const hook = new Hook(input, handler);
+
+ if (options.priority !== undefined) hook.withPriority(options.priority);
+ if (options.once) hook.once();
+ if (options.id) hook.withId(options.id);
+ if (options.enabled !== undefined) hook.setEnabled(options.enabled);
+
+ return hook.toObject();
+ }
+
+ return input;
+ }
+
+ /**
+ * Register a hook
+ *
+ * @method registerHook
+ * @param {Hook|String} hookOrName Hook instance or hook name
+ * @param {Function} handler Optional handler (if first param is string)
+ * @param {Object} options Optional options
+ */
+ registerHook(hookOrName, handler = null, options = {}) {
+ const hook = this.#normalizeHook(hookOrName, handler, options);
+
+ if (!this.hooks[hook.name]) {
+ this.hooks[hook.name] = [];
+ }
+
+ this.hooks[hook.name].push(hook);
+
+ // Sort by priority (lower numbers first)
+ this.hooks[hook.name].sort((a, b) => a.priority - b.priority);
+ }
+
+ /**
+ * Execute all hooks for a given name
+ *
+ * @method execute
+ * @param {String} hookName Hook name
+ * @param {...*} args Arguments to pass to hook handlers
+ * @returns {Promise<Array>} Array of hook results
+ */
+ async execute(hookName, ...args) {
+ const hookList = this.hooks[hookName] || [];
+ const results = [];
+
+ for (const hook of hookList) {
+ if (!hook.enabled) {
+ continue;
+ }
+
+ if (typeof hook.handler === 'function') {
+ try {
+ const result = await hook.handler(...args);
+ results.push(result);
+
+ // Remove hook if it should only run once
+ if (hook.once) {
+ this.removeHook(hookName, hook.id);
+ }
+ } catch (error) {
+ console.error(`Error executing hook '${hookName}' (${hook.id}):`, error);
+ }
+ }
+ }
+
+ return results;
+ }
+
+ /**
+ * Execute hooks synchronously
+ *
+ * @method executeSync
+ * @param {String} hookName Hook name
+ * @param {...*} args Arguments to pass to hook handlers
+ * @returns {Array} Array of hook results
+ */
+ executeSync(hookName, ...args) {
+ const hookList = this.hooks[hookName] || [];
+ const results = [];
+
+ for (const hook of hookList) {
+ if (!hook.enabled) {
+ continue;
+ }
+
+ if (typeof hook.handler === 'function') {
+ try {
+ const result = hook.handler(...args);
+ results.push(result);
+
+ if (hook.once) {
+ this.removeHook(hookName, hook.id);
+ }
+ } catch (error) {
+ console.error(`Error executing hook '${hookName}' (${hook.id}):`, error);
+ }
+ }
+ }
+
+ return results;
+ }
+
+ /**
+ * Remove a specific hook
+ *
+ * @method removeHook
+ * @param {String} hookName Hook name
+ * @param {String} hookId Hook ID
+ */
+ removeHook(hookName, hookId) {
+ if (this.hooks[hookName]) {
+ this.hooks[hookName] = this.hooks[hookName].filter((h) => h.id !== hookId);
+ }
+ }
+
+ /**
+ * Remove all hooks for a given name
+ *
+ * @method removeAllHooks
+ * @param {String} hookName Hook name
+ */
+ removeAllHooks(hookName) {
+ if (this.hooks[hookName]) {
+ this.hooks[hookName] = [];
+ }
+ }
+
+ /**
+ * Get all hooks for a given name
+ *
+ * @method getHooks
+ * @param {String} hookName Hook name
+ * @returns {Array} Array of hooks
+ */
+ getHooks(hookName) {
+ return this.hooks[hookName] || [];
+ }
+
+ /**
+ * Check if a hook exists
+ *
+ * @method hasHook
+ * @param {String} hookName Hook name
+ * @returns {Boolean} True if hook exists
+ */
+ hasHook(hookName) {
+ return this.hooks[hookName] && this.hooks[hookName].length > 0;
+ }
+
+ /**
+ * Enable a hook
+ *
+ * @method enableHook
+ * @param {String} hookName Hook name
+ * @param {String} hookId Hook ID
+ */
+ enableHook(hookName, hookId) {
+ const hook = this.#findHook(hookName, hookId);
+ if (hook) {
+ hook.enabled = true;
+ }
+ }
+
+ /**
+ * Disable a hook
+ *
+ * @method disableHook
+ * @param {String} hookName Hook name
+ * @param {String} hookId Hook ID
+ */
+ disableHook(hookName, hookId) {
+ const hook = this.#findHook(hookName, hookId);
+ if (hook) {
+ hook.enabled = false;
+ }
+ }
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| hook-service.js | +
+
+ |
+ 0% | +0/84 | +0% | +0/60 | +0% | +0/16 | +0% | +0/77 | +
| menu-service.js | +
+
+ |
+ 0% | +0/137 | +0% | +0/161 | +0% | +0/36 | +0% | +0/120 | +
| registry-service.js | +
+
+ |
+ 0% | +0/119 | +0% | +0/105 | +0% | +0/35 | +0% | +0/116 | +
| resource-view-service.js | +
+
+ |
+ 0% | +0/85 | +0% | +0/68 | +0% | +0/24 | +0% | +0/83 | +
| widget-service.js | +
+
+ |
+ 0% | +0/64 | +0% | +0/48 | +0% | +0/24 | +0% | +0/62 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +573 +574 +575 +576 +577 +578 +579 +580 +581 +582 +583 +584 +585 +586 +587 +588 +589 +590 +591 +592 +593 +594 +595 +596 +597 +598 +599 +600 +601 +602 +603 +604 +605 +606 +607 +608 +609 +610 +611 +612 +613 +614 +615 +616 +617 +618 +619 +620 +621 +622 +623 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | 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) {
+ 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.withOnClick(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,
+ // Shortcuts without their own permission inherit the parent's,
+ // so the header never offers a module the user cannot open.
+ permission: sc.permission ?? menuItem.permission ?? null,
+ 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. Items registered by title share the '~' slug (their URL is
+ // `virtual/~?view=<view>`), so they are keyed by view or title instead; keyed by '~'
+ // each would replace the one before it.
+ const key = menuItem.slug && menuItem.slug !== '~' ? menuItem.slug : (menuItem.view ?? menuItem.title);
+ this.registry.register(registryName, 'menu-item', key, 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.
+ // A(...).filter() returns a plain array, which has no sortBy, so the
+ // result has to be re-wrapped before sorting.
+ const extensions = A(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.getAllFromPrefix('console:account', 'menu-item', 'organization:');
+ }
+
+ /**
+ * Get user menu items
+ *
+ * @method getUserMenuItems
+ * @returns {Array} User menu items
+ */
+ getUserMenuItems() {
+ return this.registry.getAllFromPrefix('console:account', 'menu-item', 'user:');
+ }
+
+ /**
+ * 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();
+ }
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +573 +574 +575 +576 +577 +578 +579 +580 +581 +582 +583 +584 +585 +586 +587 +588 +589 +590 +591 +592 +593 +594 +595 +596 +597 +598 +599 +600 +601 +602 +603 +604 +605 +606 +607 +608 +609 +610 +611 +612 +613 +614 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import Service, { inject as service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+import { warn } from '@ember/debug';
+import { A, isArray } from '@ember/array';
+import { TrackedObject } from 'tracked-built-ins';
+import { getOwner } from '@ember/application';
+import TemplateHelper from '../../contracts/template-helper';
+import UniverseRegistry from '../../contracts/universe-registry';
+
+/**
+ * RegistryService
+ *
+ * Fully dynamic, Map-based registry for storing categorized items.
+ * Supports grouped registries with multiple list types per section.
+ *
+ * Structure:
+ * registries (TrackedMap) → section name → TrackedObject { list-name: A([]), ... }
+ *
+ * Usage:
+ * ```javascript
+ * // Register an item to a specific list within a section
+ * registryService.register('console:admin', 'menu-panels', 'fleet-ops', panelObject);
+ *
+ * // Get all items from a list
+ * const panels = registryService.getRegistry('console:admin', 'menu-panels');
+ *
+ * // Lookup specific item
+ * const panel = registryService.lookup('console:admin', 'menu-panels', 'fleet-ops');
+ * ```
+ *
+ * @class RegistryService
+ * @extends Service
+ */
+/**
+ * Whether a helper is a class to instantiate (a `Helper` subclass) rather than a function.
+ */
+function isHelperClass(value) {
+ return typeof value.prototype?.compute === 'function';
+}
+
+/**
+ * Whether a registry item can carry a `_registryKey`: any object, or a class.
+ */
+function isKeyable(value) {
+ return (typeof value === 'object' && value !== null) || typeof value === 'function';
+}
+
+export default class RegistryService extends Service {
+ @service('universe/extension-manager') extensionManager;
+
+ /**
+ * Reference to the root Ember Application Instance.
+ * Used for registering components/services to the application container
+ * for cross-engine sharing.
+ */
+ @tracked applicationInstance = null;
+
+ #registry = null;
+
+ /**
+ * The singleton UniverseRegistry instance, resolved on first use.
+ * Initialized once and shared across the app and all engines.
+ *
+ * This is deliberately lazy. It was a field initializer, which runs during
+ * construction — before setApplicationInstance can possibly have been
+ * called — so #initializeRegistry's documented first choice, an explicitly
+ * set applicationInstance, could never be taken and every service fell back
+ * to the owner. HookService's registry was made lazy for the same reason.
+ *
+ * @type {UniverseRegistry}
+ */
+ get registry() {
+ return this.#resolveRegistry();
+ }
+
+ /**
+ * Memoized resolution, kept out of the getter body so a property read is not
+ * itself an assignment.
+ *
+ * @private
+ * @returns {UniverseRegistry}
+ */
+ #resolveRegistry() {
+ if (!this.#registry) {
+ this.#registry = this.#initializeRegistry();
+ }
+
+ return this.#registry;
+ }
+
+ /**
+ * Getter for the registries TrackedMap.
+ * Provides access to the shared registry data.
+ * @type {TrackedMap<string, TrackedObject>}
+ */
+ get registries() {
+ return this.registry.registries;
+ }
+
+ /**
+ * Sets the root Ember Application Instance.
+ * Called by an initializer to enable cross-engine registration.
+ * @method setApplicationInstance
+ * @param {Object} appInstance
+ */
+ setApplicationInstance(application) {
+ this.applicationInstance = application;
+ }
+
+ /**
+ * Initializes the UniverseRegistry singleton.
+ * Registers it to the application container if not already registered.
+ * This ensures all service instances (app and engines) share the same registry.
+ * @private
+ * @method #initializeRegistry
+ * @returns {UniverseRegistry} The singleton registry instance
+ */
+ #initializeRegistry() {
+ const registryKey = 'registry:universe';
+
+ // First priority: use applicationInstance if set
+ let application = this.applicationInstance;
+
+ if (!application) {
+ // Second priority: try to get from owner
+ const owner = getOwner(this);
+ if (owner && owner.application) {
+ application = owner.application;
+ } else {
+ warn('[RegistryService] Could not find application instance for registry initialization', {
+ id: 'registry-service.no-application',
+ });
+ // Return a new instance as fallback (won't be shared)
+ return new UniverseRegistry();
+ }
+ }
+
+ // Register the singleton if not already registered
+ if (!application.hasRegistration(registryKey)) {
+ application.register(registryKey, new UniverseRegistry(), {
+ instantiate: false,
+ });
+ }
+
+ // Resolve and return the singleton instance
+ return application.resolveRegistration(registryKey);
+ }
+
+ /**
+ * Get or create a registry section.
+ * Returns a TrackedObject containing dynamic lists.
+ *
+ * @method getOrCreateSection
+ * @param {String} sectionName Section name (e.g., 'console:admin', 'dashboard:widgets')
+ * @returns {TrackedObject} The section object
+ */
+ getOrCreateSection(sectionName) {
+ if (!this.registries.has(sectionName)) {
+ this.registries.set(sectionName, new TrackedObject({}));
+ }
+ return this.registries.get(sectionName);
+ }
+
+ /**
+ * Get or create a list within a section.
+ * Returns an Ember Array for the specified list.
+ *
+ * @method getOrCreateList
+ * @param {String} sectionName Section name
+ * @param {String} listName List name (e.g., 'menu-items', 'menu-panels')
+ * @returns {Array} The Ember Array for the list
+ */
+ getOrCreateList(sectionName, listName) {
+ const section = this.getOrCreateSection(sectionName);
+
+ if (!section[listName]) {
+ section[listName] = A([]);
+ }
+
+ return section[listName];
+ }
+
+ /**
+ * Register an item in a specific list within a registry section.
+ *
+ * @method register
+ * @param {String} sectionName Section name (e.g., 'console:admin')
+ * @param {String} listName List name within the section (e.g., 'menu-items', 'menu-panels')
+ * @param {String} key Unique identifier for the item
+ * @param {Object} value The item to register
+ */
+ register(sectionName, listName, key, value) {
+ const registry = this.getOrCreateList(sectionName, listName);
+
+ // Store the key with the value for lookups. A class (a renderable component) is
+ // keyed too, or nothing could look it up by name.
+ if (isKeyable(value)) {
+ value._registryKey = key;
+ }
+
+ // Check if already exists
+ const existing = registry.find((item) => {
+ if (isKeyable(item)) {
+ return item._registryKey === key || item.slug === key || item.id === key || item.widgetId === key;
+ }
+ return false;
+ });
+
+ if (existing) {
+ // Update existing item
+ const index = registry.indexOf(existing);
+ registry.replace(index, 1, [value]);
+ } else {
+ // Add new item
+ registry.pushObject(value);
+ }
+ }
+
+ /**
+ * Get all items from a specific list within a registry section.
+ *
+ * @method getRegistry
+ * @param {String} sectionName Section name (e.g., 'console:admin')
+ * @param {String} listName List name within the section (e.g., 'menu-items', 'menu-panels')
+ * @returns {Array} Array of items in the list
+ */
+ getRegistry(sectionName, listName) {
+ const section = this.registries.get(sectionName);
+
+ if (!section || !section[listName]) {
+ return A([]);
+ }
+
+ return section[listName];
+ }
+
+ /**
+ * Get the entire section object (all lists within a section).
+ *
+ * @method getSection
+ * @param {String} sectionName Section name
+ * @returns {TrackedObject|null} The section object or null
+ */
+ getSection(sectionName) {
+ return this.registries.get(sectionName) || null;
+ }
+
+ /**
+ * Lookup a specific item by key
+ *
+ * @method lookup
+ * @param {String} sectionName Section name (e.g., 'console:admin')
+ * @param {String} listName List name within the section (e.g., 'menu-items', 'menu-panels')
+ * @param {String} key Item key
+ * @returns {Object|null} The item or null if not found
+ */
+ lookup(sectionName, listName, key) {
+ const registry = this.getRegistry(sectionName, listName);
+ return (
+ registry.find((item) => {
+ if (isKeyable(item)) {
+ return item._registryKey === key || item.slug === key || item.id === key || item.widgetId === key;
+ }
+ return false;
+ }) || null
+ );
+ }
+
+ /**
+ * Get items matching a key prefix
+ *
+ * @method getAllFromPrefix
+ * @param {String} sectionName Section name (e.g., 'console:admin')
+ * @param {String} listName List name within the section (e.g., 'menu-items')
+ * @param {String} prefix Key prefix to match
+ * @returns {Array} Matching items
+ */
+ getAllFromPrefix(sectionName, listName, prefix) {
+ const registry = this.getRegistry(sectionName, listName);
+ return registry.filter((item) => {
+ if (typeof item === 'object' && item !== null && item._registryKey) {
+ return item._registryKey.startsWith(prefix);
+ }
+ return false;
+ });
+ }
+
+ /**
+ * Register a renderable component for cross-engine rendering
+ * Supports both ExtensionComponent definitions and raw component classes
+ *
+ * @method registerRenderableComponent
+ * @param {String} registryName Registry name (slot identifier)
+ * @param {Object|Class|Array} component ExtensionComponent definition, component class, or array of either
+ * @param {Object} options Optional configuration
+ * @param {String} options.engineName Engine name (required for raw component classes)
+ *
+ * @example
+ * // ExtensionComponent definition with path (lazy loading)
+ * registryService.registerRenderableComponent(
+ * 'fleet-ops:component:order:details',
+ * new ExtensionComponent('@fleetbase/storefront-engine', 'storefront-order-summary')
+ * );
+ *
+ * @example
+ * // ExtensionComponent definition with class (immediate)
+ * import MyComponent from './components/my-component';
+ * registryService.registerRenderableComponent(
+ * 'fleet-ops:component:order:details',
+ * new ExtensionComponent('@fleetbase/fleetops-engine', MyComponent)
+ * );
+ *
+ * @example
+ * // Raw component class (requires engineName in options)
+ * registryService.registerRenderableComponent(
+ * 'fleet-ops:component:order:details',
+ * MyComponent,
+ * { engineName: '@fleetbase/fleetops-engine' }
+ * );
+ */
+ registerRenderableComponent(registryName, component, options = {}) {
+ // Handle arrays
+ if (isArray(component)) {
+ component.forEach((comp) => this.registerRenderableComponent(registryName, comp, options));
+ return;
+ }
+
+ // Generate unique key for the component
+ const key = component._registryKey || component.name || component.path || `component-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
+
+ // Register to RegistryService using map-based structure
+ // Structure: registries.get(registryName).components = [component1, component2, ...]
+ this.register(registryName, 'components', key, component);
+ }
+
+ /**
+ * Get renderable components from a registry
+ *
+ * @method getRenderableComponents
+ * @param {String} registryName Registry name
+ * @returns {Array} Array of component definitions/classes
+ */
+ getRenderableComponents(registryName) {
+ return this.getRegistry(registryName, 'components');
+ }
+
+ /**
+ * Create a registry (section with default list).
+ * For backward compatibility with existing code.
+ * Creates a section with a 'menu-item' list by default.
+ *
+ * @method createRegistry
+ * @param {String} sectionName Section name
+ * @param {String} type Type of registry
+ * @returns {Array} The default list array
+ */
+ createRegistry(sectionName, type = 'menu-item') {
+ return this.getOrCreateList(sectionName, type);
+ }
+
+ /**
+ * Create multiple registries
+ *
+ * @method createRegistries
+ * @param {Array} sectionNames Array of section names
+ */
+ createRegistries(sectionNames) {
+ if (isArray(sectionNames)) {
+ sectionNames.forEach((sectionName) => this.createRegistry(sectionName));
+ }
+ }
+
+ /**
+ * Create a registry section (or get existing).
+ * This is a convenience method for explicitly creating sections.
+ *
+ * @method createSection
+ * @param {String} sectionName Section name
+ * @returns {TrackedObject} The section object
+ */
+ createSection(sectionName) {
+ return this.getOrCreateSection(sectionName);
+ }
+
+ /**
+ * Create multiple registry sections
+ *
+ * @method createSections
+ * @param {Array} sectionNames Array of section names
+ */
+ createSections(sectionNames) {
+ if (isArray(sectionNames)) {
+ sectionNames.forEach((sectionName) => this.createSection(sectionName));
+ }
+ }
+
+ /**
+ * Check if a section exists
+ *
+ * @method hasSection
+ * @param {String} sectionName Section name
+ * @returns {Boolean} True if section exists
+ */
+ hasSection(sectionName) {
+ return this.registries.has(sectionName);
+ }
+
+ /**
+ * Check if a list exists within a section
+ *
+ * @method hasList
+ * @param {String} sectionName Section name
+ * @param {String} listName List name
+ * @returns {Boolean} True if list exists
+ */
+ hasList(sectionName, listName) {
+ const section = this.registries.get(sectionName);
+ return !!(section && section[listName]);
+ }
+
+ /**
+ * Clear a specific list within a section
+ *
+ * @method clearList
+ * @param {String} sectionName Section name
+ * @param {String} listName List name
+ */
+ clearList(sectionName, listName) {
+ const section = this.registries.get(sectionName);
+ if (section && section[listName]) {
+ section[listName].clear();
+ }
+ }
+
+ /**
+ * Clear an entire section (all lists)
+ *
+ * @method clearSection
+ * @param {String} sectionName Section name
+ */
+ clearSection(sectionName) {
+ const section = this.registries.get(sectionName);
+ if (section) {
+ Object.keys(section).forEach((listName) => {
+ if (section[listName] && typeof section[listName].clear === 'function') {
+ section[listName].clear();
+ }
+ });
+ this.registries.delete(sectionName);
+ }
+ }
+
+ /**
+ * Clear all registries
+ *
+ * @method clearAll
+ */
+ clearAll() {
+ this.registries.forEach((section) => {
+ Object.keys(section).forEach((listName) => {
+ if (section[listName] && typeof section[listName].clear === 'function') {
+ section[listName].clear();
+ }
+ });
+ });
+ this.registries.clear();
+ }
+
+ /**
+ * Registers a component to the root application container.
+ * This ensures the component is available to all engines and the host app.
+ * @method registerComponent
+ * @param {String} name The component name (e.g., 'my-component')
+ * @param {Class} componentClass The component class
+ * @param {Object} options Registration options (e.g., { singleton: true })
+ */
+ registerComponent(name, componentClass, options = {}) {
+ if (this.applicationInstance) {
+ this.applicationInstance.register(`component:${name}`, componentClass, options);
+ } else {
+ warn('Application instance not set on RegistryService. Cannot register component.', { id: 'registry-service.no-app-instance' });
+ }
+ }
+
+ /**
+ * Registers a service to the root application container.
+ * This ensures the service is available to all engines and the host app.
+ * @method registerService
+ * @param {String} name The service name (e.g., 'my-service')
+ * @param {Class} serviceClass The service class
+ * @param {Object} options Registration options (e.g., { singleton: true })
+ */
+ registerService(name, serviceClass, options = {}) {
+ if (this.applicationInstance) {
+ this.applicationInstance.register(`service:${name}`, serviceClass, options);
+ } else {
+ warn('Application instance not set on RegistryService. Cannot register service.', { id: 'registry-service.no-app-instance' });
+ }
+ }
+
+ /**
+ * Registers a helper to the root application container.
+ * This makes the helper available globally to all engines and the host app.
+ * Supports both direct helper functions/classes and lazy loading via TemplateHelper.
+ *
+ * @method registerHelper
+ * @param {String} helperName The helper name (e.g., 'calculate-delivery-fee')
+ * @param {Function|Class|TemplateHelper} helperClassOrTemplateHelper Helper function, class, or TemplateHelper instance
+ * @param {Object} options Registration options
+ * @param {Boolean} options.instantiate Whether to instantiate the helper (default: false for functions)
+ * @returns {Promise<void>}
+ *
+ * @example
+ * // Direct function registration
+ * await registryService.registerHelper('calculate-delivery-fee', calculateDeliveryFeeHelper);
+ *
+ * @example
+ * // Direct class registration
+ * await registryService.registerHelper('format-currency', FormatCurrencyHelper);
+ *
+ * @example
+ * // Lazy loading from engine (ensures engine is loaded first)
+ * await registryService.registerHelper(
+ * 'calculate-delivery-fee',
+ * new TemplateHelper('@fleetbase/storefront-engine', 'helpers/calculate-delivery-fee')
+ * );
+ */
+ async registerHelper(helperName, helperClassOrTemplateHelper, options = {}) {
+ const owner = this.applicationInstance || getOwner(this);
+
+ if (!owner) {
+ warn('No owner available for helper registration. Cannot register helper.', {
+ id: 'registry-service.no-owner',
+ });
+ return;
+ }
+
+ // Check if it's a TemplateHelper instance
+ if (helperClassOrTemplateHelper instanceof TemplateHelper) {
+ const templateHelper = helperClassOrTemplateHelper;
+
+ if (templateHelper.isClass) {
+ // Direct class registration from TemplateHelper
+ owner.register(`helper:${helperName}`, templateHelper.class, {
+ instantiate: options.instantiate !== undefined ? options.instantiate : true,
+ });
+ } else {
+ // Lazy loading from engine (async - ensures engine is loaded)
+ const helper = await this.#loadHelperFromEngine(templateHelper);
+ if (helper) {
+ owner.register(`helper:${helperName}`, helper, {
+ instantiate: options.instantiate !== undefined ? options.instantiate : true,
+ });
+ } else {
+ warn(`Failed to load helper from engine: ${templateHelper.engineName}/${templateHelper.path}`, {
+ id: 'registry-service.helper-load-failed',
+ });
+ }
+ }
+ } else {
+ // Direct function or class registration
+ // A Helper subclass is instantiated; a plain function helper is not, however it was written.
+ const instantiate = options.instantiate !== undefined ? options.instantiate : typeof helperClassOrTemplateHelper !== 'function' || isHelperClass(helperClassOrTemplateHelper);
+
+ owner.register(`helper:${helperName}`, helperClassOrTemplateHelper, {
+ instantiate,
+ });
+ }
+ }
+
+ /**
+ * Loads a helper from an engine using TemplateHelper definition.
+ * Ensures the engine is loaded before attempting to resolve the helper.
+ * @private
+ * @method #loadHelperFromEngine
+ * @param {TemplateHelper} templateHelper The TemplateHelper instance
+ * @returns {Promise<Function|Class|null>} The loaded helper or null if failed
+ */
+ async #loadHelperFromEngine(templateHelper) {
+ // No owner check here: registerHelper is the only caller and has already
+ // returned on the identical expression, so a second one could never fire.
+ try {
+ // Ensure the engine is loaded (will load if not already loaded)
+ const engineInstance = await this.extensionManager.ensureEngineLoaded(templateHelper.engineName);
+
+ if (!engineInstance) {
+ warn(`Engine could not be loaded: ${templateHelper.engineName}`, {
+ id: 'registry-service.engine-not-loaded',
+ });
+ return null;
+ }
+
+ // Try to resolve the helper from the engine
+ const helperPath = templateHelper.path.startsWith('helper:') ? templateHelper.path : `helper:${templateHelper.path}`;
+
+ const helper = engineInstance.resolveRegistration(helperPath);
+
+ if (!helper) {
+ warn(`Helper not found in engine: ${helperPath}`, {
+ id: 'registry-service.helper-not-found',
+ });
+ return null;
+ }
+
+ return helper;
+ } catch (error) {
+ warn(`Error loading helper from engine: ${error.message}`, {
+ id: 'registry-service.helper-load-error',
+ });
+ return null;
+ }
+ }
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import Service, { inject as service } from '@ember/service';
+import { warn } from '@ember/debug';
+import { isArray } from '@ember/array';
+import { getOwner } from '@ember/application';
+import mergeRegisteredItems from '../../utils/merge-registered-items';
+import isObject from '../../utils/is-object';
+import { parseRegistryName, parseRegistryPrefix, buildRegistryName, resourceRegistryNames } from '../../utils/resource-view-registry';
+
+/**
+ * ResourceViewService
+ *
+ * Lets extensions add columns, actions and buttons to the table and details
+ * views of any engine, and lets those views merge them in.
+ *
+ * Registry names follow `<extension>:<resource>:<surface>:<slot>`:
+ *
+ * | surface | slot | adds | contract |
+ * |-----------|----------------|------------------------------------------|------------------|
+ * | `table` | `columns` | table columns | `TableColumn` |
+ * | `table` | `row-actions` | items in each row's dropdown | `ResourceAction` |
+ * | `table` | `bulk-actions` | items in the Bulk Actions dropdown | `ResourceAction` |
+ * | `table` | `actions` | toolbar buttons | `ActionButton` |
+ * | `details` | `actions` | details header buttons | `ActionButton` |
+ * | `details` | `menu` | items in the details header's "…" menu | `ResourceAction` |
+ * | `details` | `tabs` | details tabs | `MenuItem` |
+ *
+ * Items are stored in the shared universe registry: the section is the full
+ * registry name and the list is the contract type, e.g.
+ * `registryService.getRegistry('fleet-ops:driver:table:columns', 'table-column')`.
+ * Tabs are the exception: `<extension>:<resource>:details:tabs` is an alias for the
+ * legacy tab registry, `<extension>:component:<resource>:details`.
+ *
+ * @example
+ * const views = universe.getService('resource-view');
+ * views.register('fleet-ops:driver:table:row-actions', new ResourceAction({ id: 'acme-sync', label: 'Sync', fn: (driver) => … }));
+ * views.registerTableColumn('ledger', 'invoice', new TableColumn({ id: 'po', label: 'PO', valuePath: 'meta.po' }));
+ *
+ * @class ResourceViewService
+ * @extends Service
+ */
+export default class ResourceViewService extends Service {
+ @service('universe/registry-service') registryService;
+ @service('universe/menu-service') menuService;
+
+ /**
+ * Views whose query params were already read by a controller. A filter
+ * column registered for one of these afterwards cannot get its param.
+ */
+ #resolvedQueryParams = new Set();
+
+ // ========================================================================
+ // Naming
+ // ========================================================================
+
+ /**
+ * Every registry name for one resource's table and details views.
+ *
+ * @param {String} extension
+ * @param {String} resource
+ * @returns {Array<String>}
+ */
+ resourceRegistryNames(extension, resource) {
+ return resourceRegistryNames(extension, resource);
+ }
+
+ /**
+ * Declare the registries for resources an engine owns, so they exist (and
+ * show up in the registry) before anything registers into them.
+ *
+ * @param {String} extension The owning engine's mount segment
+ * @param {Array<String>} resources
+ * @returns {Array<String>} The declared names
+ */
+ declare(extension, resources = []) {
+ const names = resources.flatMap((resource) => resourceRegistryNames(extension, resource));
+ names.forEach((name) => {
+ const { section, list } = parseRegistryName(name);
+ this.registryService.getOrCreateList(section, list);
+ });
+ return names;
+ }
+
+ // ========================================================================
+ // Registering
+ // ========================================================================
+
+ /**
+ * Register one or more items into a view slot.
+ *
+ * Rejects, with a warning, a name that does not follow the convention, an
+ * item without an `id`, and a contract that does not belong in the slot.
+ *
+ * @param {String} registryName e.g. `fleet-ops:driver:table:columns`
+ * @param {Object|Array} items Contracts or plain objects
+ * @returns {Boolean} Whether every item was registered
+ */
+ register(registryName, items) {
+ const parsed = parseRegistryName(registryName);
+ if (!parsed) {
+ warn(`[resource-view] "${registryName}" is not a valid registry name. Use <extension>:<resource>:<table|details>:<slot>.`, false, {
+ id: 'resource-view.invalid-name',
+ });
+ return false;
+ }
+
+ return (isArray(items) ? items : [items]).map((item) => this.#registerItem(parsed, item)).every(Boolean);
+ }
+
+ registerTableColumn(extension, resource, column) {
+ return this.register(buildRegistryName(extension, resource, 'table', 'columns'), column);
+ }
+
+ registerRowAction(extension, resource, action) {
+ return this.register(buildRegistryName(extension, resource, 'table', 'row-actions'), action);
+ }
+
+ registerBulkAction(extension, resource, action) {
+ return this.register(buildRegistryName(extension, resource, 'table', 'bulk-actions'), action);
+ }
+
+ registerTableAction(extension, resource, button) {
+ return this.register(buildRegistryName(extension, resource, 'table', 'actions'), button);
+ }
+
+ registerDetailsAction(extension, resource, button) {
+ return this.register(buildRegistryName(extension, resource, 'details', 'actions'), button);
+ }
+
+ registerDetailsMenuItem(extension, resource, action) {
+ return this.register(buildRegistryName(extension, resource, 'details', 'menu'), action);
+ }
+
+ /**
+ * Register a details tab, through `<extension>:<resource>:details:tabs`, the
+ * alias for `<extension>:component:<resource>:details`.
+ *
+ * @returns {Boolean}
+ */
+ registerDetailsTab(extension, resource, menuItem) {
+ return this.register(buildRegistryName(extension, resource, 'details', 'tabs'), menuItem);
+ }
+
+ /**
+ * Remove a registered item.
+ *
+ * @param {String} registryName
+ * @param {String} id
+ * @returns {Boolean} Whether an item was removed
+ */
+ unregister(registryName, id) {
+ const parsed = parseRegistryName(registryName);
+ if (!parsed) {
+ return false;
+ }
+
+ const item = this.registryService.lookup(parsed.section, parsed.list, id);
+ if (!item) {
+ return false;
+ }
+
+ this.registryService.getRegistry(parsed.section, parsed.list).removeObject(item);
+ return true;
+ }
+
+ // ========================================================================
+ // Reading
+ // ========================================================================
+
+ /**
+ * The items registered into a slot, in registration order.
+ *
+ * @param {String} registryName
+ * @returns {Array}
+ */
+ get(registryName) {
+ const parsed = parseRegistryName(registryName);
+ return parsed ? this.registryService.getRegistry(parsed.section, parsed.list) : [];
+ }
+
+ /**
+ * Merge a slot's registered items into a view's built-in items. See
+ * `utils/merge-registered-items` for placement and handler binding.
+ *
+ * @param {String} registryName
+ * @param {Array} baseItems
+ * @param {Object} context Extra view context (`resource`, `controller`, `table`, …)
+ * @returns {Array}
+ */
+ merge(registryName, baseItems = [], context = {}) {
+ const parsed = parseRegistryName(registryName);
+ if (!parsed) {
+ return toPlainArray(baseItems);
+ }
+
+ const registered = this.get(registryName);
+ if (!registered.length) {
+ return toPlainArray(baseItems);
+ }
+
+ return mergeRegisteredItems(baseItems, registered, {
+ slot: parsed.slot,
+ context: this.#buildContext(parsed, context),
+ canAccess: (permission) => this.#can(permission),
+ });
+ }
+
+ /**
+ * Merge one slot of a surface, addressed by the `@registry` prefix a layout
+ * component receives (`fleet-ops:driver:table`) plus the slot name.
+ *
+ * @param {String} prefix
+ * @param {String} slot
+ * @param {Array} baseItems
+ * @param {Object} context
+ * @returns {Array}
+ */
+ mergeSlot(prefix, slot, baseItems = [], context = {}) {
+ const parsed = parseRegistryPrefix(prefix);
+ if (!parsed) {
+ return toPlainArray(baseItems);
+ }
+
+ return this.merge(`${prefix}:${slot}`, baseItems, context);
+ }
+
+ /**
+ * A table's row-actions column with registered row actions merged into its
+ * `actions`. Returns the column list unchanged when nothing is registered.
+ *
+ * @param {String} prefix A table prefix, e.g. `fleet-ops:driver:table`
+ * @param {Array} columns
+ * @param {Object} context
+ * @returns {Array}
+ */
+ mergeRowActions(prefix, columns = [], context = {}) {
+ const registryName = `${prefix}:row-actions`;
+ if (!parseRegistryName(registryName) || !this.get(registryName).length) {
+ return toPlainArray(columns);
+ }
+
+ return toPlainArray(columns).map((column) => {
+ if (column?.cellComponent !== 'table/cell/dropdown') {
+ return column;
+ }
+
+ return { ...column, actions: this.merge(registryName, column.actions ?? [], context) };
+ });
+ }
+
+ // ========================================================================
+ // Filters
+ // ========================================================================
+
+ /**
+ * The query params an index controller must declare: its own, plus the
+ * `filterParam` of every filterable registered column.
+ *
+ * Call it from the controller's `queryParams` class field. Filter columns
+ * must therefore be registered in `setupExtension`, before the controller
+ * is first created.
+ *
+ * @param {String} extension
+ * @param {String} resource
+ * @param {Array} baseQueryParams
+ * @returns {Array}
+ */
+ queryParamsFor(extension, resource, baseQueryParams = []) {
+ const registryName = buildRegistryName(extension, resource, 'table', 'columns');
+ this.#resolvedQueryParams.add(registryName);
+
+ const queryParams = [...baseQueryParams];
+ for (const param of this.get(registryName).map(filterParamOf).filter(Boolean)) {
+ if (!queryParams.includes(param)) {
+ queryParams.push(param);
+ }
+ }
+
+ return queryParams;
+ }
+
+ // ========================================================================
+ // Internals
+ // ========================================================================
+
+ #registerItem(parsed, item) {
+ // Tabs go to the legacy tab registry, through the menu service, which keys and
+ // wraps menu items as every other tab registration expects.
+ if (parsed.slot === 'tabs') {
+ if (!isObject(item)) {
+ warn(`[resource-view] Tabs registered into "${parsed.name}" must be MenuItems or menu item objects.`, false, { id: 'resource-view.invalid-tab' });
+ return false;
+ }
+
+ this.menuService.registerMenuItem(parsed.section, item);
+ return true;
+ }
+
+ const value = normalizeItem(item);
+
+ if (!value || !value.id) {
+ warn(`[resource-view] Items registered into "${parsed.name}" need an id.`, false, { id: 'resource-view.missing-id' });
+ return false;
+ }
+
+ if (value._contractType && value._contractType !== parsed.list) {
+ warn(`[resource-view] "${value.id}" is a ${value._contractType}, but "${parsed.name}" holds ${parsed.list} items.`, false, {
+ id: 'resource-view.wrong-contract',
+ });
+ return false;
+ }
+
+ if (parsed.slot === 'columns' && filterParamOf(value) && this.#resolvedQueryParams.has(parsed.name)) {
+ warn(
+ `[resource-view] The filter "${value.filterParam}" on "${value.id}" was registered after its table was set up, so it is not a query param. Register filter columns in setupExtension.`,
+ false,
+ { id: 'resource-view.late-filter' }
+ );
+ }
+
+ this.registryService.register(parsed.section, parsed.list, value.id, { ...value, _contractType: parsed.list });
+ return true;
+ }
+
+ #buildContext(parsed, context) {
+ const base = {
+ registry: parsed.name,
+ extension: parsed.extension,
+ surface: parsed.surface,
+ resourceName: parsed.resource,
+ slot: parsed.slot,
+ owner: getOwner(this),
+ };
+
+ // Copy descriptors rather than spreading: a view may pass lazy getters
+ // (its table is set up after first render), and a spread would read
+ // them now, during the render that merges.
+ return Object.defineProperties(base, Object.getOwnPropertyDescriptors(context));
+ }
+
+ #can(permission) {
+ const abilities = getOwner(this)?.lookup('service:abilities');
+ return !abilities || typeof abilities.can !== 'function' || abilities.can(permission);
+ }
+}
+
+function normalizeItem(item) {
+ if (item && typeof item.toObject === 'function') {
+ return item.toObject();
+ }
+
+ return isObject(item) ? { ...item } : null;
+}
+
+function filterParamOf(column) {
+ return column?.filterable && typeof column.filterParam === 'string' ? column.filterParam : null;
+}
+
+function toPlainArray(items) {
+ if (!items) {
+ return [];
+ }
+
+ return typeof items.toArray === 'function' ? items.toArray() : Array.from(items);
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import Service from '@ember/service';
+import { inject as service } from '@ember/service';
+import { warn } from '@ember/debug';
+import { isArray } from '@ember/array';
+import { tracked } from '@glimmer/tracking';
+import Widget from '../../contracts/widget';
+import isObject from '../../utils/is-object';
+
+/**
+ * WidgetManagerService
+ *
+ * Manages dashboard widgets and widget registrations.
+ *
+ * Widgets are registered per-dashboard:
+ * - registerWidgets(dashboardName, widgets) - Makes widgets available for selection on a dashboard
+ * - registerDefaultWidgets(dashboardName, widgets) - Auto-loads specific widgets on a dashboard
+ *
+ * Registry Structure:
+ * - Dashboards: 'dashboards' section, 'dashboard' list
+ * - Widgets: 'dashboard:widgets' section, 'widget' list
+ * - Default Widgets: 'dashboard:widgets' section, 'default-widget' list
+ *
+ * @class WidgetService
+ * @extends Service
+ */
+export default class WidgetService extends Service {
+ @service('universe/registry-service') registry;
+
+ /**
+ * 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;
+ }
+
+ /**
+ * Normalize a widget input to a plain object
+ *
+ * @private
+ * @method #normalizeWidget
+ * @param {Widget|Object} input Widget instance or object
+ * @returns {Object} Normalized widget object
+ */
+ #normalizeWidget(input) {
+ if (input instanceof Widget) {
+ return input.toObject();
+ }
+
+ // Handle plain objects - ensure id property exists
+ if (isObject(input)) {
+ // Support both id and widgetId for backward compatibility
+ const id = input.id || input.widgetId;
+
+ if (!id) {
+ warn('[WidgetService] Widget definition is missing id or widgetId', { id: 'widget-service.missing-id' });
+ }
+
+ return {
+ ...input,
+ id, // Ensure id property is set
+ };
+ }
+
+ return input;
+ }
+
+ /**
+ * Register a dashboard
+ *
+ * @method registerDashboard
+ * @param {String} name Dashboard name/ID
+ * @param {Object} options Dashboard options
+ */
+ registerDashboard(name, options = {}) {
+ const dashboard = {
+ name,
+ ...options,
+ };
+
+ // Register to 'dashboards' section, 'dashboard' list
+ this.registry.register('dashboards', 'dashboard', name, dashboard);
+ }
+
+ /**
+ * Register a dashboard render slot.
+ *
+ * Slots describe where dashboards render (for example `console.home`),
+ * while dashboard names continue to describe widget namespaces.
+ *
+ * @method registerDashboardSlot
+ * @param {String} slotId Slot identifier
+ * @param {Object} options Slot options
+ */
+ registerDashboardSlot(slotId, options = {}) {
+ const slot = {
+ id: slotId,
+ ...options,
+ };
+
+ this.registry.register('dashboard:slots', 'slot', slotId, slot);
+ }
+
+ /**
+ * Register a system dashboard that should be available in a render slot.
+ *
+ * @method registerDashboardForSlot
+ * @param {String} slotId Slot identifier
+ * @param {String} dashboardName Dashboard widget namespace
+ * @param {Object} options Dashboard slot options
+ */
+ registerDashboardForSlot(slotId, dashboardName, options = {}) {
+ this.registerDashboardSlot(slotId);
+ this.registerDashboard(dashboardName, options);
+
+ const dashboard = {
+ id: dashboardName,
+ dashboardId: dashboardName,
+ name: options.name ?? options.defaultDashboardName ?? dashboardName,
+ extension: options.extension ?? 'core',
+ priority: options.priority ?? 0,
+ ...options,
+ };
+
+ this.registry.register('dashboard:slots', 'dashboard', `${slotId}#${dashboardName}`, {
+ slotId,
+ ...dashboard,
+ });
+ }
+
+ /**
+ * Set the system dashboard that should load first for a render slot.
+ *
+ * @method setDefaultDashboardForSlot
+ * @param {String} slotId Slot identifier
+ * @param {String} dashboardName Dashboard widget namespace
+ */
+ setDefaultDashboardForSlot(slotId, dashboardName) {
+ this.registerDashboardSlot(slotId);
+ this.registry.register('dashboard:slots', 'default', slotId, {
+ slotId,
+ dashboardId: dashboardName,
+ });
+ }
+
+ /**
+ * Convenience alias for setting the console home dashboard.
+ *
+ * @method setConsoleDashboard
+ * @param {String} dashboardName Dashboard widget namespace
+ */
+ setConsoleDashboard(dashboardName) {
+ this.setDefaultDashboardForSlot('console.home', dashboardName);
+ }
+
+ /**
+ * Get system dashboards registered for a slot.
+ *
+ * @method getDashboardsForSlot
+ * @param {String} slotId Slot identifier
+ * @returns {Array} Slot dashboard registrations
+ */
+ getDashboardsForSlot(slotId) {
+ if (!slotId) {
+ return [];
+ }
+
+ const prefix = `${slotId}#`;
+ return [...this.registry.getRegistry('dashboard:slots', 'dashboard')]
+ .filter((dashboard) => dashboard?._registryKey?.startsWith(prefix))
+ .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
+ }
+
+ /**
+ * Get the dashboard namespace that should load first for a slot.
+ *
+ * @method getDefaultDashboardForSlot
+ * @param {String} slotId Slot identifier
+ * @returns {String|null} Dashboard namespace
+ */
+ getDefaultDashboardForSlot(slotId) {
+ return this.registry.lookup('dashboard:slots', 'default', slotId)?.dashboardId ?? null;
+ }
+
+ /**
+ * Register widgets to a specific dashboard
+ * Makes these widgets available for selection on the dashboard
+ * If a widget has `default: true`, it's also registered as a default widget
+ *
+ * @method registerWidgets
+ * @param {String} dashboardName Dashboard name/ID
+ * @param {Array<Widget>} widgets Array of widget instances or objects
+ */
+ registerWidgets(dashboardName, widgets) {
+ if (!isArray(widgets)) {
+ widgets = [widgets];
+ }
+
+ widgets.forEach((widget) => {
+ const normalized = this.#normalizeWidget(widget);
+
+ // Register widget to 'dashboard:widgets' section, 'widget' list
+ // Key format: dashboardName#widgetId
+ this.registry.register('dashboard:widgets', 'widget', `${dashboardName}#${normalized.id}`, normalized);
+
+ // If marked as default, also register to default widget list
+ if (normalized.default === true) {
+ this.registry.register('dashboard:widgets', 'default-widget', `${dashboardName}#${normalized.id}`, normalized);
+ }
+ });
+ }
+
+ /**
+ * Register default widgets for a specific dashboard
+ * These widgets are automatically loaded on the dashboard
+ *
+ * @method registerDefaultWidgets
+ * @param {String} dashboardName Dashboard name/ID
+ * @param {Array<Widget>} widgets Array of widget instances or objects
+ */
+ registerDefaultWidgets(dashboardName, widgets) {
+ if (!isArray(widgets)) {
+ widgets = [widgets];
+ }
+
+ widgets.forEach((widget) => {
+ const normalized = this.#normalizeWidget(widget);
+
+ // Register to 'dashboard:widgets' section, 'default-widget' list
+ // Key format: dashboardName#widgetId
+ this.registry.register('dashboard:widgets', 'default-widget', `${dashboardName}#${normalized.id}`, normalized);
+ });
+ }
+
+ /**
+ * Get widgets for a specific dashboard
+ * Returns all widgets available for selection on that dashboard
+ *
+ * @method getWidgets
+ * @param {String} dashboardName Dashboard name/ID
+ * @returns {Array} Widgets available for the dashboard
+ */
+ getWidgets(dashboardName) {
+ if (!dashboardName) {
+ return [];
+ }
+
+ // Get all widgets from 'dashboard:widgets' section, 'widget' list
+ const registry = this.registry.getRegistry('dashboard:widgets', 'widget');
+
+ // Filter widgets by registration key prefix
+ const prefix = `${dashboardName}#`;
+
+ return registry.filter((widget) => {
+ if (!widget || typeof widget !== 'object') return false;
+
+ // Match widgets registered for this dashboard
+ return widget._registryKey && widget._registryKey.startsWith(prefix);
+ });
+ }
+
+ /**
+ * Get default widgets for a specific dashboard
+ * Returns widgets that should be auto-loaded
+ *
+ * @method getDefaultWidgets
+ * @param {String} dashboardName Dashboard name/ID
+ * @returns {Array} Default widgets for the dashboard
+ */
+ getDefaultWidgets(dashboardName) {
+ if (!dashboardName) {
+ return [];
+ }
+
+ // Get all default widgets from 'dashboard:widgets' section, 'default-widget' list
+ const registry = this.registry.getRegistry('dashboard:widgets', 'default-widget');
+
+ // Filter widgets by registration key prefix
+ const prefix = `${dashboardName}#`;
+
+ return registry.filter((widget) => {
+ if (!widget || typeof widget !== 'object') return false;
+
+ // Match default widgets registered for this dashboard
+ return widget._registryKey && widget._registryKey.startsWith(prefix);
+ });
+ }
+
+ /**
+ * Get a specific widget by ID from a dashboard
+ *
+ * @method getWidget
+ * @param {String} dashboardName Dashboard name/ID
+ * @param {String} widgetId Widget ID
+ * @returns {Object|null} Widget or null
+ */
+ getWidget(dashboardName, widgetId) {
+ return this.registry.lookup('dashboard:widgets', 'widget', `${dashboardName}#${widgetId}`);
+ }
+
+ /**
+ * Get all dashboards
+ *
+ * @method getDashboards
+ * @returns {Array} All dashboards
+ */
+ getDashboards() {
+ return this.registry.getRegistry('dashboards', 'dashboard');
+ }
+
+ /**
+ * Get a specific dashboard
+ *
+ * @method getDashboard
+ * @param {String} name Dashboard name
+ * @returns {Object|null} Dashboard or null
+ */
+ getDashboard(name) {
+ return this.registry.lookup('dashboards', 'dashboard', name);
+ }
+
+ /**
+ * Get registry for a specific dashboard
+ * Used by dashboard models to get their widget registry
+ *
+ * @method getRegistry
+ * @param {String} dashboardId Dashboard ID
+ * @returns {Array} Widget registry for the dashboard
+ */
+ getRegistry(dashboardId) {
+ return this.getWidgets(dashboardId);
+ }
+
+ // ============================================================================
+ // DEPRECATED METHODS (for backward compatibility)
+ // ============================================================================
+
+ /**
+ * Register default dashboard widgets
+ * DEPRECATED: Use registerDefaultWidgets(dashboardName, widgets) instead
+ *
+ * @method registerDefaultDashboardWidgets
+ * @param {Array<Widget>} widgets Array of widget instances or objects
+ * @deprecated Use registerDefaultWidgets('dashboard', widgets) instead
+ */
+ registerDefaultDashboardWidgets(widgets) {
+ warn('[WidgetService] registerDefaultDashboardWidgets is deprecated. Use registerDefaultWidgets(dashboardName, widgets) instead.', { id: 'widget-service.deprecated-method' });
+ this.registerDefaultWidgets('dashboard', widgets);
+ }
+
+ /**
+ * Register dashboard widgets
+ * DEPRECATED: Use registerWidgets(dashboardName, widgets) instead
+ *
+ * @method registerDashboardWidgets
+ * @param {Array<Widget>} widgets Array of widget instances or objects
+ * @deprecated Use registerWidgets('dashboard', widgets) instead
+ */
+ registerDashboardWidgets(widgets) {
+ warn('[WidgetService] registerDashboardWidgets is deprecated. Use registerWidgets(dashboardName, widgets) instead.', { id: 'widget-service.deprecated-method' });
+ this.registerWidgets('dashboard', widgets);
+ }
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import Service from '@ember/service';
+import { debounce } from '@ember/runloop';
+import hasJsonStructure from '../utils/has-json-structure';
+
+/**
+ * Service for manipulating URL search parameters.
+ *
+ * This service provides methods to get, set, remove, and check URL query parameters.
+ * It also allows updating the browser's URL without reloading the page.
+ *
+ * @extends Service
+ */
+export default class UrlSearchParamsService extends Service {
+ /**
+ * The working set of query parameters.
+ *
+ * Rebuilt from `window.location.search` whenever the browser's search string
+ * differs from the one these params were built from, so a navigation is
+ * picked up; otherwise the same instance is handed back, so `setParam`,
+ * `addParam` and `removeParam` actually persist until `updateUrl` publishes
+ * them.
+ *
+ * @type {URLSearchParams}
+ * @private
+ */
+ #params = null;
+ #builtFrom = null;
+
+ get urlParams() {
+ return this.#currentParams();
+ }
+
+ /**
+ * Rebuild-if-stale, kept out of the getter body so a property read is not
+ * itself an assignment.
+ *
+ * @private
+ * @returns {URLSearchParams}
+ */
+ #currentParams() {
+ const search = window.location.search;
+
+ if (this.#params === null || this.#builtFrom !== search) {
+ this.#params = new URLSearchParams(search);
+ this.#builtFrom = search;
+ }
+
+ return this.#params;
+ }
+
+ set urlParams(value) {
+ this.#params = value;
+ this.#builtFrom = window.location.search;
+ }
+
+ /**
+ * Retrieves the value of a specific query parameter.
+ *
+ * If the parameter value is a JSON string, it will be parsed into an object or array.
+ *
+ * @param {string} key - The name of the query parameter to retrieve.
+ * @returns {*} The value of the query parameter, parsed from JSON if applicable, or null if not found.
+ */
+ getParam(key) {
+ let value = this.urlParams.get(key);
+
+ if (hasJsonStructure(value)) {
+ value = JSON.parse(value);
+ }
+
+ return value;
+ }
+
+ /**
+ * Sets or updates a query parameter in the URL search parameters.
+ *
+ * If the value is an object or array, it will be stringified to JSON.
+ *
+ * @param {string} key - The name of the query parameter to set.
+ * @param {*} value - The value of the query parameter.
+ * @returns {this} Returns the service instance for chaining.
+ */
+ setParam(key, value) {
+ if (typeof value === 'object') {
+ value = JSON.stringify(value);
+ } else {
+ value = encodeURIComponent(value);
+ }
+
+ this.urlParams.set(key, value);
+
+ return this;
+ }
+
+ /**
+ * Alias for `getParam`.
+ *
+ * @param {string} key - The name of the query parameter to retrieve.
+ * @returns {*} The value of the query parameter.
+ */
+ get(key) {
+ return this.getParam(key);
+ }
+
+ /**
+ * Sets or updates a query parameter with multiple values.
+ *
+ * @param {string} key - The name of the query parameter to set.
+ * @param {Array} values - An array of values for the parameter.
+ * @returns {this} Returns the service instance for chaining.
+ */
+ setParamArray(key, values) {
+ this.urlParams.delete(key);
+ values.forEach((value) => {
+ this.urlParams.append(key, value);
+ });
+
+ return this;
+ }
+
+ /**
+ * Retrieves all values of a specific query parameter.
+ *
+ * @param {string} key - The name of the query parameter.
+ * @returns {Array} An array of values for the parameter.
+ */
+ getParamArray(key) {
+ return this.urlParams.getAll(key);
+ }
+
+ /**
+ * Checks if a specific query parameter exists in the URL.
+ *
+ * @param {string} key - The name of the query parameter to check.
+ * @returns {boolean} True if the parameter exists, false otherwise.
+ */
+ exists(key) {
+ return this.urlParams.has(key);
+ }
+
+ /**
+ * Checks if a specific query parameter has in the URL.
+ *
+ * @param {string} key - The name of the query parameter to check.
+ * @returns {boolean} True if the parameter exists, false otherwise.
+ */
+ has(key) {
+ return this.urlParams.has(key);
+ }
+
+ /**
+ * Removes a specific query parameter from the URL search parameters.
+ *
+ * @param {string} key - The name of the query parameter to remove.
+ * @returns {this} Returns the service instance for chaining.
+ */
+ remove(key) {
+ this.urlParams.delete(key);
+
+ return this;
+ }
+
+ /**
+ * Retrieves all query parameters as an object.
+ *
+ * Each parameter value is processed by `getParam`, which parses JSON values if applicable.
+ *
+ * @returns {Object} An object containing all query parameters and their values.
+ */
+ all() {
+ const all = {};
+
+ for (let key of this.urlParams.keys()) {
+ all[key] = this.getParam(key);
+ }
+
+ return all;
+ }
+
+ /**
+ * Updates the browser's URL with the current `urlParams` without reloading the page.
+ *
+ * @returns {void}
+ */
+ updateUrl() {
+ const url = new URL(window.location.href);
+ url.search = this.urlParams.toString();
+ window.history.pushState({ path: url.href }, '', url.href);
+ }
+
+ /**
+ * Updates the browser's URL with the current `urlParams`, debounced to prevent excessive calls.
+ *
+ * @returns {void}
+ */
+ updateUrlDebounced() {
+ debounce(this, this.updateUrl, 100);
+ }
+
+ /**
+ * Clears all query parameters from the URL search parameters.
+ *
+ * @returns {this} Returns the service instance for chaining.
+ */
+ clear() {
+ this.urlParams = new URLSearchParams();
+
+ return this;
+ }
+
+ /**
+ * Returns the full URL as a string with the current `urlParams`.
+ *
+ * @returns {string} The full URL with updated query parameters.
+ */
+ getFullUrl() {
+ const url = new URL(window.location.href);
+ url.search = this.urlParams.toString();
+
+ return url.toString();
+ }
+
+ /**
+ * Returns the current path with the updated query parameters.
+ *
+ * @returns {string} The path and search portion of the URL.
+ */
+ getPathWithParams() {
+ return `${window.location.pathname}?${this.urlParams.toString()}`;
+ }
+
+ /**
+ * Removes a query parameter from the current URL and updates the browser history.
+ *
+ * This method modifies the browser's URL by removing the specified parameter and uses the History API
+ * to update the URL without reloading the page.
+ *
+ * @param {string} paramToRemove - The name of the query parameter to remove from the URL.
+ * @returns {void}
+ */
+ removeParamFromCurrentUrl(paramToRemove) {
+ const url = new URL(window.location.href);
+ url.searchParams.delete(paramToRemove);
+ window.history.pushState({ path: url.href }, '', url.href);
+ }
+
+ /**
+ * Adds or updates a query parameter in the current URL and updates the browser history.
+ *
+ * This method modifies the browser's URL by adding or updating the specified parameter and uses the History API
+ * to update the URL without reloading the page.
+ *
+ * @param {string} paramName - The name of the query parameter to add or update.
+ * @param {string} paramValue - The value of the query parameter.
+ * @returns {void}
+ */
+ addParamToCurrentUrl(paramName, paramValue) {
+ const url = new URL(window.location.href);
+ url.searchParams.set(paramName, paramValue);
+ window.history.pushState({ path: url.href }, '', url.href);
+ }
+
+ /**
+ * Adds or updates a query parameters from a provided object into the current URL and updates the browser history.
+ *
+ * This method modifies the browser's URL by adding or updating the specified parameters and uses the History API
+ * to update the URL without reloading the page.
+ *
+ * @param {Object} params - The query parameters to add or update.
+ * @returns {void}
+ */
+ setParamsToCurrentUrl(params = {}) {
+ for (let param in params) {
+ const value = params[param];
+ this.addParamToCurrentUrl(param, value);
+ }
+ }
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 | + + + + + + + + + + + + + + + + + +3x + + + +3x +3x + + + + + + | import Transform from '@ember-data/serializer/transform';
+import isIterable from '../utils/is-iterable';
+import { isArray } from '@ember/array';
+
+export default class ArrayTransform extends Transform {
+ deserialize(serialized) {
+ if (isArray(serialized)) {
+ return serialized;
+ }
+
+ if (!isIterable(serialized)) {
+ return [];
+ }
+
+ return Array.from(serialized);
+ }
+
+ serialize(deserialized) {
+ Iif (isArray(deserialized)) {
+ return deserialized;
+ }
+
+ Eif (!isIterable(deserialized)) {
+ return [];
+ }
+
+ return Array.from(deserialized);
+ }
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ ++ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 | + + + + + + + +39x + + + | import Transform from '@ember-data/serializer/transform';
+
+export default class ObjectTransform extends Transform {
+ deserialize(serialized) {
+ return Object.assign({}, serialized);
+ }
+
+ serialize(deserialized) {
+ return Object.assign({}, deserialized);
+ }
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 | + + + +4x + + + +148x + + + | import Transform from '@ember-data/serializer/transform';
+
+export default class RawTransform extends Transform {
+ deserialize(serialized) {
+ return serialized;
+ }
+
+ serialize(deserialized) {
+ return deserialized;
+ }
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import { later } from '@ember/runloop';
+
+function corslite(url, callback, cors) {
+ var sent = false;
+
+ if (typeof window.XMLHttpRequest === 'undefined') {
+ return callback(Error('Browser not supported'));
+ }
+
+ if (typeof cors === 'undefined') {
+ var m = url.match(/^\s*https?:\/\/[^\/]*/);
+ /* istanbul ignore next -- the test server always runs on an explicit port, so the portless arm of the ternary is unreachable here */
+ cors = m && m[0] !== location.protocol + '//' + location.hostname + (location.port ? ':' + location.port : '');
+ }
+
+ var x = new window.XMLHttpRequest();
+
+ function isSuccessful(status) {
+ return (status >= 200 && status < 300) || status === 304;
+ }
+
+ if (cors && !('withCredentials' in x)) {
+ // IE8-9
+ x = new window.XDomainRequest();
+
+ // Ensure callback is never called synchronously, i.e., before
+ // x.send() returns (this has been observed in the wild).
+ // See https://github.com/mapbox/mapbox.js/issues/472
+ var original = callback;
+ callback = function () {
+ if (sent) {
+ original.apply(this, arguments);
+ } else {
+ later(
+ this,
+ function () {
+ original.apply(this, arguments);
+ },
+ 0
+ );
+ }
+ };
+ }
+
+ function loaded() {
+ if (
+ // XDomainRequest
+ x.status === undefined ||
+ // modern browsers
+ isSuccessful(x.status)
+ )
+ callback.call(x, null, x);
+ else callback.call(x, x, null);
+ }
+
+ // Both `onreadystatechange` and `onload` can fire. `onreadystatechange`
+ // has [been supported for longer](http://stackoverflow.com/a/9181508/229001).
+ if ('onload' in x) {
+ x.onload = loaded;
+ } else {
+ x.onreadystatechange = function readystate() {
+ if (x.readyState === 4) {
+ loaded();
+ }
+ };
+ }
+
+ // Call the callback with the XMLHttpRequest object as an error and prevent
+ // it from ever being called again by reassigning it to `noop`
+ x.onerror = function error(evt) {
+ // XDomainRequest provides no evt parameter
+ callback.call(this, evt || true, null);
+ callback = function () {};
+ };
+
+ // IE9 must have onprogress be set to a unique function.
+ x.onprogress = function () {};
+
+ x.ontimeout = function (evt) {
+ callback.call(this, evt, null);
+ callback = function () {};
+ };
+
+ x.onabort = function (evt) {
+ callback.call(this, evt, null);
+ callback = function () {};
+ };
+
+ // GET is the only supported HTTP Verb by XDomainRequest and is the
+ // only one supported here.
+ x.open('GET', url, true);
+
+ // Send the request. Sending data is not supported.
+ x.send(null);
+ sent = true;
+
+ return x;
+}
+
+export default corslite;
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 | 0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 | Cannot read properties of undefined (reading 'start') +TypeError: Cannot read properties of undefined (reading 'start') + at /Users/ron/Development/fleetbase/oss/fleetbase-dev/console/node_modules/.pnpm/istanbul-reports@3.2.0/node_modules/istanbul-reports/lib/html/annotator.js:194:41 + at Array.forEach ( |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 | 1x + + + + + + + + + + + + + + + + + + + + + + + | const MIME_TYPES = {
+ pdf: 'application/pdf',
+ zip: 'application/zip',
+ doc: 'application/msword',
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ png: 'image/png',
+ jpeg: 'image/jpeg',
+ jpg: 'image/jpeg',
+ csv: 'text/csv',
+};
+
+/**
+ * The mime type for a file name, from its extension, or null when it is not one we know.
+ *
+ * @param {String} fileName
+ * @returns {String|null}
+ */
+export default function getMimeType(fileName) {
+ const extension = String(fileName).split('.').pop().toLowerCase();
+
+ return MIME_TYPES[extension] ?? null;
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 | + + + + + + + + + +86x + +86x +86x +86x + + + + + + + | /**
+ * Retrieves user options from local storage.
+ *
+ * Attempts to read the user options stored in local storage as a JSON string under the key
+ * USER_OPTIONS_STORAGE_KEY. If the JSON parsing fails, an empty object is returned.
+ *
+ * @returns {Object} The user options as an object, or an empty object if no valid user options were found.
+ * @throws Will not throw an error, even if JSON parsing fails. Errors are silently caught and ignored.
+ */
+export default function getUserOptions() {
+ const USER_OPTIONS_STORAGE_KEY = '@fleetbase/storage:user-options';
+
+ try {
+ const storedValue = window.localStorage.getItem(USER_OPTIONS_STORAGE_KEY);
+ return storedValue ? JSON.parse(storedValue) : {};
+ } catch (error) {
+ // For debugging purposes, you can log the error (optional)
+ // console.error('Failed to parse user options from local storage:', error);
+ return {};
+ }
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 | + + + + + + | import { get } from '@ember/object';
+
+export default function getWithDefault(obj, key, defaultValue) {
+ let value = get(obj, key);
+ return value === undefined ? defaultValue : value;
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 | + + + + + + + + + + | export default function hasJsonStructure(str) { + if (typeof str !== 'string') return false; + try { + const result = JSON.parse(str); + const type = Object.prototype.toString.call(result); + return type === '[object Object]' || type === '[object Array]'; + } catch (err) { + return false; + } +} + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| corslite.js | +
+
+ |
+ 0% | +0/37 | +0% | +0/23 | +0% | +0/13 | +0% | +0/37 | +
| download.js | +
+
+ |
+ 0% | +0/89 | +0% | +0/55 | +0% | +0/10 | +0% | +0/86 | +
| get-mime-type.js | +
+
+ |
+ 33.33% | +1/3 | +0% | +0/2 | +0% | +0/1 | +33.33% | +1/3 | +
| get-user-options.js | +
+
+ |
+ 80% | +4/5 | +50% | +1/2 | +100% | +1/1 | +80% | +4/5 | +
| get-with-default.js | +
+
+ |
+ 0% | +0/2 | +0% | +0/2 | +0% | +0/1 | +0% | +0/2 | +
| has-json-structure.js | +
+
+ |
+ 0% | +0/7 | +0% | +0/4 | +0% | +0/1 | +0% | +0/6 | +
| is-empty-object.js | +
+
+ |
+ 0% | +0/3 | +0% | +0/4 | +0% | +0/1 | +0% | +0/2 | +
| is-iterable.js | +
+
+ |
+ 66.66% | +2/3 | +50% | +1/2 | +100% | +1/1 | +66.66% | +2/3 | +
| is-latitude.js | +
+
+ |
+ 100% | +1/1 | +100% | +2/2 | +100% | +1/1 | +100% | +1/1 | +
| is-longitude.js | +
+
+ |
+ 100% | +1/1 | +100% | +2/2 | +100% | +1/1 | +100% | +1/1 | +
| is-model.js | +
+
+ |
+ 100% | +1/1 | +100% | +2/2 | +100% | +1/1 | +100% | +1/1 | +
| is-not-model.js | +
+
+ |
+ 100% | +1/1 | +100% | +0/0 | +100% | +1/1 | +100% | +1/1 | +
| is-object.js | +
+
+ |
+ 0% | +0/1 | +0% | +0/3 | +0% | +0/1 | +0% | +0/1 | +
| is-valid-coordinates.js | +
+
+ |
+ 60% | +6/10 | +50% | +9/18 | +100% | +1/1 | +60% | +6/10 | +
| lookup-user-ip.js | +
+
+ |
+ 0% | +0/55 | +0% | +0/32 | +0% | +0/10 | +0% | +0/54 | +
| merge-registered-items.js | +
+
+ |
+ 0% | +0/95 | +0% | +0/92 | +0% | +0/23 | +0% | +0/85 | +
| resource-view-registry.js | +
+
+ |
+ 18.51% | +5/27 | +0% | +0/12 | +0% | +0/7 | +20% | +5/25 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 | + + + + + + | import { isBlank } from '@ember/utils';
+
+export default function isEmptyObject(obj) {
+ if (isBlank(obj)) return true;
+ return obj.constructor === Object && Object.keys(obj).length === 0;
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 | +3x +3x + + + + + | export default function isIterable(obj) {
+ Eif (obj == null) {
+ return false;
+ }
+
+ return typeof obj[Symbol.iterator] === 'function';
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 | +21x + + | export default function isLatitude(coordinate) {
+ return isFinite(coordinate) && Math.abs(coordinate) <= 90;
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 | +16x + + | export default function isLongitude(coordinate) {
+ return isFinite(coordinate) && Math.abs(coordinate) <= 180;
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 | + + + +7x + + | import Model from '@ember-data/model';
+import ObjectProxy from '@ember/object/proxy';
+
+export default function isModel(mixed) {
+ return mixed instanceof Model || mixed instanceof ObjectProxy;
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 | + + +7x + + | import isModel from './is-model';
+
+export default function isNotModel(model) {
+ return !isModel(model);
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 | + + + | export default function isObject(obj) { + return obj && typeof obj === 'object' && Object.prototype.toString.call(obj) === '[object Object]'; +} + |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 | + + + + + + + + + + + + + + + + + + + + +21x +21x +21x + + +21x + + + + +21x + + + + +21x + + | import { isArray } from '@ember/array';
+import isLatitude from './is-latitude';
+import isLongitude from './is-longitude';
+import isObject from './is-object';
+
+/**
+ * Validates coordinates
+ *
+ * ```
+ * isValidCoordinates([0, 0]);
+ * isValidCoordinates({lat: 0, lng: 0});
+ * isValidCoordinates({latitude: 0, longitude: 0});
+ * isValidCoordinates(0, 0);
+ * ```
+ *
+ * @param {Array|Object|Integer} latitude
+ * @param {Array|Object|Integer} longitude
+ */
+export default function isValidCoordinates(latitude, longitude = null) {
+ let testLatitude, testLongitude;
+
+ Eif (isArray(latitude) && longitude === null) {
+ testLatitude = latitude[0];
+ testLongitude = latitude[1];
+ }
+
+ Iif (!isArray(latitude) && isObject(latitude) && longitude === null) {
+ testLatitude = latitude.lat || latitude.latitude;
+ testLongitude = latitude.lng || latitude.longitude;
+ }
+
+ Iif (longitude !== null) {
+ testLatitude = latitude;
+ testLongitude = longitude;
+ }
+
+ return isLatitude(testLatitude) && isLongitude(testLongitude);
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | /**
+ * Lookup user's IP address and geolocation information using geoiplookup.io
+ *
+ * This method calls the geoiplookup.io API directly from the browser to get
+ * accurate user location data, avoiding the issue of server-side IP lookup
+ * returning the server's location instead of the user's location.
+ *
+ * @param {Object} options - Configuration options
+ * @param {number} options.timeout - Request timeout in milliseconds (default: 5000)
+ * @param {boolean} options.cache - Whether to cache the result (default: true)
+ * @returns {Promise<Object>} User's IP and geolocation data
+ *
+ * @example
+ * const whois = await lookupUserIp();
+ * console.log(whois.city); // "New York"
+ * console.log(whois.country_code); // "US"
+ */
+export default async function lookupUserIp(options = {}) {
+ const { timeout = 5000, cache = true } = options;
+
+ // Check cache first if enabled
+ if (cache) {
+ const cached = getCachedWhois();
+ if (cached) {
+ return cached;
+ }
+ }
+
+ // Try multiple APIs with fallback
+ const apis = [
+ {
+ url: 'https://json.geoiplookup.io/',
+ normalize: normalizeGeoIPLookup,
+ },
+ {
+ url: 'https://ipapi.co/json/',
+ normalize: normalizeIPApi,
+ },
+ ];
+
+ for (const api of apis) {
+ try {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
+
+ const response = await fetch(api.url, {
+ signal: controller.signal,
+ headers: {
+ Accept: 'application/json',
+ },
+ });
+
+ clearTimeout(timeoutId);
+
+ if (!response.ok) {
+ console.warn(`[lookupUserIp] ${api.url} returned ${response.status}`);
+ continue;
+ }
+
+ const data = await response.json();
+ const normalized = api.normalize(data);
+ const normalizedWithTimezone = ensureWhoisTimezone(normalized);
+
+ // Cache the result if enabled
+ if (cache) {
+ cacheWhois(normalizedWithTimezone);
+ }
+
+ return normalizedWithTimezone;
+ } catch (error) {
+ console.warn(`[lookupUserIp] ${api.url} failed:`, error.message);
+ // Continue to next API
+ }
+ }
+
+ // All APIs failed, return fallback
+ console.error('[lookupUserIp] All IP lookup APIs failed, using fallback data');
+ return getFallbackWhois();
+}
+
+/**
+ * Normalize geoiplookup.io response to match Fleetbase whois format
+ */
+function normalizeGeoIPLookup(data) {
+ return {
+ ip: data.ip,
+ city: data.city,
+ region: data.region,
+ country_code: data.country_code,
+ country_name: data.country_name,
+ continent_code: data.continent_code,
+ continent_name: data.continent_name,
+ latitude: data.latitude,
+ longitude: data.longitude,
+ postal_code: data.postal_code,
+ timezone: data.timezone_name || getBrowserTimezone(),
+ currency: {
+ code: data.currency_code,
+ name: data.currency_name,
+ },
+ languages: [
+ {
+ code: data.language_code,
+ name: data.language_name,
+ },
+ ],
+ isp: data.isp,
+ org: data.org,
+ asn: data.asn,
+ connection_type: data.connection_type,
+ // Metadata
+ _source: 'geoiplookup.io',
+ _timestamp: Date.now(),
+ };
+}
+
+/**
+ * Normalize ipapi.co response to match Fleetbase whois format
+ */
+function normalizeIPApi(data) {
+ return {
+ ip: data.ip,
+ city: data.city,
+ region: data.region,
+ country_code: data.country_code,
+ country_name: data.country_name,
+ continent_code: data.continent_code,
+ continent_name: null,
+ latitude: data.latitude,
+ longitude: data.longitude,
+ postal_code: data.postal,
+ timezone: data.timezone || getBrowserTimezone(),
+ currency: {
+ code: data.currency,
+ name: data.currency_name,
+ },
+ languages: data.languages ? data.languages.split(',').map((lang) => ({ code: lang.trim(), name: lang.trim() })) : [],
+ isp: data.org,
+ org: data.org,
+ asn: data.asn,
+ connection_type: null,
+ // Metadata
+ _source: 'ipapi.co',
+ _timestamp: Date.now(),
+ };
+}
+
+/**
+ * Get cached whois data from localStorage
+ */
+function getCachedWhois() {
+ try {
+ const cached = localStorage.getItem('fleetbase:whois');
+ if (!cached) {
+ return null;
+ }
+
+ const data = JSON.parse(cached);
+ const age = Date.now() - data._timestamp;
+ const maxAge = 60 * 60 * 1000; // 1 hour
+
+ if (age > maxAge) {
+ localStorage.removeItem('fleetbase:whois');
+ return null;
+ }
+
+ const normalized = ensureWhoisTimezone(data);
+ if (normalized.timezone !== data.timezone) {
+ cacheWhois(normalized);
+ }
+
+ return normalized;
+ } catch (error) {
+ console.error('[getCachedWhois] Error reading cache:', error);
+ return null;
+ }
+}
+
+/**
+ * Cache whois data to localStorage
+ */
+function cacheWhois(data) {
+ try {
+ localStorage.setItem('fleetbase:whois', JSON.stringify(data));
+ } catch (error) {
+ console.error('[cacheWhois] Error writing cache:', error);
+ }
+}
+
+/**
+ * Get fallback whois data when all APIs fail
+ */
+function getFallbackWhois() {
+ // Try to get browser language and timezone as fallback
+ const browserLang = navigator.language || 'en-US';
+ const timezone = getBrowserTimezone();
+
+ return {
+ ip: null,
+ city: null,
+ region: null,
+ country_code: null,
+ country_name: null,
+ continent_code: null,
+ continent_name: null,
+ latitude: null,
+ longitude: null,
+ postal_code: null,
+ timezone: timezone,
+ currency: {
+ code: null,
+ name: null,
+ },
+ languages: [
+ {
+ code: browserLang.split('-')[0],
+ name: browserLang,
+ },
+ ],
+ isp: null,
+ org: null,
+ asn: null,
+ connection_type: null,
+ _source: 'fallback',
+ _timestamp: Date.now(),
+ };
+}
+
+export function getBrowserTimezone() {
+ try {
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || null;
+ } catch (error) {
+ return null;
+ }
+}
+
+export function ensureWhoisTimezone(whois = {}) {
+ whois = whois || {};
+
+ return {
+ ...whois,
+ timezone: whois.timezone || getBrowserTimezone(),
+ };
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import { debug } from '@ember/debug';
+
+/**
+ * Merge the items extensions registered into a view slot with the view's own
+ * built-in items.
+ *
+ * - Built-ins win: a registered item whose `id` matches a built-in is dropped.
+ * - Registered items are placed in `priority` order (lowest first), each at its
+ * `before`/`after` anchor, else at `index`, else at the slot's default spot:
+ * columns go just before the row-actions column, `row-actions` and `menu`
+ * items go before the delete action, and everything else is appended.
+ * - Every registered item is a fresh copy, so a consumer that mutates what it
+ * renders (the column picker sets `hidden`) never touches the registry.
+ * - Handlers are bound to the view context: an action's `fn(target)` and
+ * `isVisible(target)` become `fn(target, ctx)` and `isVisible(target, ctx)`, a
+ * bulk action's target is the selection at click time (`ctx.getSelectedRows()`), a
+ * button's `onClick()` becomes `onClick(ctx)`, and a button's `isVisible(ctx)`
+ * is evaluated here, so hidden buttons are left out entirely.
+ *
+ * Pure: neither input is modified.
+ *
+ * @param {Array} baseItems The view's built-in items
+ * @param {Array} registeredItems The items registered for this slot
+ * @param {Object} options
+ * @param {String} options.slot The slot being merged (`columns`, `row-actions`, …)
+ * @param {Object} options.context The view context passed to handlers
+ * @param {Function} options.canAccess `(permission) => Boolean`; registered columns failing it are left out
+ * @returns {Array}
+ */
+export default function mergeRegisteredItems(baseItems = [], registeredItems = [], { slot = null, context = {}, canAccess = null } = {}) {
+ const result = [...toArray(baseItems)];
+ const takenIds = new Set(result.map((item) => item?.id).filter(Boolean));
+
+ const registered = toArray(registeredItems)
+ .map((item, order) => ({ item, order }))
+ .sort((a, b) => priorityOf(a.item) - priorityOf(b.item) || a.order - b.order)
+ .map(({ item }) => item);
+
+ for (const item of registered) {
+ if (!item || !item.id) {
+ continue;
+ }
+
+ if (takenIds.has(item.id)) {
+ debug(`[resource-view] "${item.id}" is already in the ${slot ?? 'view'} slot; the registered item was skipped.`);
+ continue;
+ }
+
+ if (slot === 'columns' && item.permission && typeof canAccess === 'function' && !canAccess(item.permission)) {
+ continue;
+ }
+
+ const prepared = prepareItem(item, slot, context);
+ if (!prepared) {
+ continue;
+ }
+
+ result.splice(placementIndex(result, prepared, slot), 0, prepared);
+ takenIds.add(item.id);
+ }
+
+ return result;
+}
+
+function toArray(items) {
+ if (!items) {
+ return [];
+ }
+
+ return typeof items.toArray === 'function' ? items.toArray() : Array.from(items);
+}
+
+function priorityOf(item) {
+ const priority = Number(item?.priority);
+ return Number.isFinite(priority) ? priority : 10;
+}
+
+function bindAction(action, context) {
+ const bound = { ...action };
+
+ if (typeof action.fn === 'function') {
+ bound.fn = (target, ...rest) => action.fn(target, context, ...rest);
+ }
+
+ if (typeof action.isVisible === 'function') {
+ bound.isVisible = (target) => action.isVisible(target, context);
+ }
+
+ return bound;
+}
+
+function prepareItem(item, slot, context) {
+ if (slot === 'actions') {
+ if (typeof item.isVisible === 'function' && !item.isVisible(context)) {
+ return null;
+ }
+
+ const button = { ...item };
+ delete button.isVisible;
+
+ if (typeof item.onClick === 'function') {
+ button.onClick = (...args) => item.onClick(context, ...args);
+ }
+
+ if (typeof item.fn === 'function') {
+ button.fn = (...args) => item.fn(context, ...args);
+ }
+
+ if (Array.isArray(item.items)) {
+ button.items = item.items
+ .filter((child) => typeof child?.isVisible !== 'function' || child.isVisible(context.resource, context))
+ .map((child) => {
+ const bound = bindAction(child, context);
+ if (typeof child.fn === 'function') {
+ bound.fn = (...args) => child.fn(context.resource, context, ...args);
+ }
+ return bound;
+ });
+ }
+
+ return button;
+ }
+
+ if (slot === 'menu') {
+ if (typeof item.isVisible === 'function' && !item.isVisible(context.resource, context)) {
+ return null;
+ }
+
+ const bound = bindAction(item, context);
+ delete bound.isVisible;
+ if (typeof item.fn === 'function') {
+ bound.fn = (...args) => item.fn(context.resource, context, ...args);
+ }
+
+ return bound;
+ }
+
+ if (slot === 'row-actions') {
+ return bindAction(item, context);
+ }
+
+ if (slot === 'bulk-actions') {
+ const bound = bindAction(item, context);
+ if (typeof item.fn === 'function' && typeof context.getSelectedRows === 'function') {
+ bound.fn = (...args) => item.fn(context.getSelectedRows(), context, ...args);
+ }
+
+ return bound;
+ }
+
+ return { ...item };
+}
+
+function indexOfId(items, id) {
+ return items.findIndex((candidate) => candidate?.id === id);
+}
+
+function placementIndex(items, item, slot) {
+ if (item.before) {
+ const index = indexOfId(items, item.before);
+ if (index !== -1) {
+ return index;
+ }
+ }
+
+ if (item.after) {
+ const index = indexOfId(items, item.after);
+ if (index !== -1) {
+ return index + 1;
+ }
+ }
+
+ if (Number.isInteger(item.index)) {
+ return Math.min(Math.max(item.index, 0), items.length);
+ }
+
+ return defaultIndex(items, slot);
+}
+
+function defaultIndex(items, slot) {
+ if (slot === 'columns') {
+ const index = items.findIndex((column) => column?.cellComponent === 'table/cell/dropdown' || column?.sticky === 'right');
+ return index === -1 ? items.length : index;
+ }
+
+ if (slot === 'row-actions' || slot === 'menu') {
+ const index = indexOfId(items, 'delete');
+ if (index === -1) {
+ return items.length;
+ }
+
+ // Keep a separator that introduces the delete action attached to it.
+ return index > 0 && items[index - 1]?.separator ? index - 1 : index;
+ }
+
+ return items.length;
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 | + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + +1x + + + + + + + + +1x +1x +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | /**
+ * Naming rules for the resource view registries.
+ *
+ * Every table and details view slot an extension can add to is named
+ *
+ * <extension>:<resource>:<surface>:<slot>
+ *
+ * - `extension` is the engine that owns the view, and always equals its
+ * console mount segment (`console.fleet-ops` → `fleet-ops`).
+ * - `resource` is the singular, dasherized resource without an engine prefix
+ * (`invoice`, not `ledger-invoice`).
+ * - `surface` is `table` or `details`.
+ * - `slot` comes from a fixed vocabulary per surface. `actions` always means
+ * header buttons; dropdown items are `row-actions`, `bulk-actions` or `menu`.
+ *
+ * Details tabs predate this convention and live in
+ * `<extension>:component:<resource>:details`. `<extension>:<resource>:details:tabs`
+ * is an alias for that registry: both names reach the same tabs.
+ *
+ * @example
+ * 'fleet-ops:driver:table:columns'
+ * 'ledger:invoice:details:menu'
+ * 'fleet-ops:driver:details:tabs' // → fleet-ops:component:driver:details
+ */
+
+export const SURFACE_SLOTS = {
+ table: ['columns', 'row-actions', 'bulk-actions', 'actions'],
+ details: ['actions', 'menu', 'tabs'],
+};
+
+/** The registry list each slot stores into, named after the contract it holds. */
+export const SLOT_LISTS = {
+ columns: 'table-column',
+ 'row-actions': 'action',
+ 'bulk-actions': 'action',
+ menu: 'action',
+ actions: 'action-button',
+ tabs: 'menu-item',
+};
+
+const SEGMENT = '[a-z0-9]+(?:-[a-z0-9]+)*';
+const NAME_PATTERN = new RegExp(`^(${SEGMENT}):(${SEGMENT}):(table|details):([a-z]+(?:-[a-z]+)*)$`);
+const PREFIX_PATTERN = new RegExp(`^(${SEGMENT}):(${SEGMENT}):(table|details)$`);
+
+/**
+ * Parse a full registry name. Returns `null` for anything that does not follow
+ * the convention, including an unknown surface or a slot the surface lacks.
+ *
+ * `section` and `list` say where the slot's items are stored. For every slot
+ * but `tabs` that is the name itself; tabs are stored in the legacy tab registry.
+ *
+ * @param {String} name
+ * @returns {{name: String, prefix: String, extension: String, resource: String, surface: String, slot: String, section: String, list: String}|null}
+ */
+export function parseRegistryName(name) {
+ if (typeof name !== 'string') {
+ return null;
+ }
+
+ const match = name.match(NAME_PATTERN);
+ if (!match) {
+ return null;
+ }
+
+ const [, extension, resource, surface, slot] = match;
+ if (!SURFACE_SLOTS[surface].includes(slot)) {
+ return null;
+ }
+
+ const section = slot === 'tabs' ? detailsTabsRegistryName(extension, resource) : name;
+ return { name, prefix: `${extension}:${resource}:${surface}`, extension, resource, surface, slot, section, list: SLOT_LISTS[slot] };
+}
+
+/**
+ * Parse a surface prefix such as `fleet-ops:driver:table`, the form layout
+ * components take as `@registry`.
+ *
+ * @param {String} prefix
+ * @returns {{prefix: String, extension: String, resource: String, surface: String}|null}
+ */
+export function parseRegistryPrefix(prefix) {
+ if (typeof prefix !== 'string') {
+ return null;
+ }
+
+ const match = prefix.match(PREFIX_PATTERN);
+ if (!match) {
+ return null;
+ }
+
+ const [, extension, resource, surface] = match;
+ return { prefix, extension, resource, surface };
+}
+
+/**
+ * Build a full registry name from its segments, in the order they appear.
+ *
+ * @returns {String}
+ */
+export function buildRegistryName(extension, resource, surface, slot) {
+ return `${extension}:${resource}:${surface}:${slot}`;
+}
+
+/**
+ * Every registry name for one resource's table and details views, including
+ * the details tabs alias.
+ *
+ * @param {String} extension
+ * @param {String} resource
+ * @returns {Array<String>}
+ */
+export function resourceRegistryNames(extension, resource) {
+ return Object.entries(SURFACE_SLOTS).flatMap(([surface, slots]) => slots.map((slot) => buildRegistryName(extension, resource, surface, slot)));
+}
+
+/**
+ * The legacy details-tab registry for a resource. Tabs predate the convention
+ * and keep their original name so existing registrations keep working;
+ * `<extension>:<resource>:details:tabs` is an alias for it.
+ *
+ * @returns {String}
+ */
+export function detailsTabsRegistryName(extension, resource) {
+ return `${extension}:component:${resource}:details`;
+}
+
+export default parseRegistryName;
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| normalize-polymorphic-type-within-hash.js | +
+
+ |
+ 90.9% | +10/11 | +76.92% | +10/13 | +100% | +1/1 | +90.9% | +10/11 | +
| normalize-polymorphic-type.js | +
+
+ |
+ 83.33% | +5/6 | +40% | +2/5 | +100% | +2/2 | +80% | +4/5 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 | + + + + +161x + + + +161x +469x +7x +7x + +7x + + +7x +7x +7x + + + + + +161x + + | import normalizePolymorphicType from './normalize-polymorphic-type';
+import { isBlank } from '@ember/utils';
+import { set } from '@ember/object';
+
+export default function serializeNormalizePolymorphicTypeWithinHash(hash) {
+ Iif (typeof hash !== 'object') {
+ return hash;
+ }
+
+ for (let attr in hash) {
+ if (typeof attr === 'string' && attr.includes('_type') && encodeURI(hash[attr]).includes('%5C')) {
+ const emberPolymorphicType = normalizePolymorphicType(hash[attr]);
+ const polymorphicRelationType = attr.replace(`_type`, ``);
+
+ hash[attr] = emberPolymorphicType;
+
+ // set the type of relationship using the polymorphic relation attr
+ Eif (hash[polymorphicRelationType] && !isBlank(hash[polymorphicRelationType])) {
+ Eif (hash[polymorphicRelationType].type) {
+ set(hash, `${polymorphicRelationType}._type`, hash[polymorphicRelationType].type);
+ }
+ }
+ }
+ }
+
+ return hash;
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 | 7x + + + +7x + +7x + + + + +7x + + | const lowercase = (string) => string.toLowerCase();
+
+export default function serializeNormalizePolymorphicType(polymorphicTypeValue, defaultValue = null) {
+ // encode first
+ const encodedPolymorphicTypeValue = encodeURI(polymorphicTypeValue);
+ // if encoded is invalid send the default value or the original value back
+ Iif (!encodedPolymorphicTypeValue.includes('%5C')) {
+ return defaultValue || polymorphicTypeValue;
+ }
+ // from fleetbase api should be something like Fleetbase\Models\Model
+ // change value to ember-data polymorphic type
+ return lowercase(encodedPolymorphicTypeValue.split('%5C').reverse()[0]);
+}
+ |