diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c96242ff..ffca1f17 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -11,25 +11,35 @@ on:
env:
NODE_VERSION: 22.x
+concurrency:
+ group: ci-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
+
+permissions:
+ contents: read
+
jobs:
- build:
+ test:
+ name: Lint, Test & Coverage
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v2
+ - name: Checkout
+ uses: actions/checkout@v4
- - name: Setup Node.js ${{ env.NODE_VERSION }}
- uses: actions/setup-node@v2
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
with:
- node-version: ${{ env.NODE_VERSION }}
+ version: 11
- - name: Setup pnpm
- uses: pnpm/action-setup@v2.0.1
+ - name: Setup Node.js ${{ env.NODE_VERSION }}
+ uses: actions/setup-node@v4
with:
- version: latest
+ node-version: ${{ env.NODE_VERSION }}
+ cache: pnpm
- name: Install Dependencies
- run: pnpm install
+ run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm run lint
@@ -37,25 +47,64 @@ jobs:
- name: Build
run: pnpm run build
+ - name: Coverage gate self-tests
+ run: node --test scripts/check-coverage.test.mjs
+
+ - name: Test with coverage (full suite)
+ run: pnpm run coverage
+
+ # Uploaded before the gate, and regardless of whether the suite passed, so
+ # Codecov still receives a report from a run with failing tests.
+ #
+ # A failed upload is reported on the step but does not fail the job: the
+ # action downloads its CLI from cli.codecov.io on every run, and an outage
+ # there would otherwise fail CI and skip the coverage gate below, even though
+ # nothing in this repository is wrong. The gate is what enforces coverage.
+ - name: Upload coverage to Codecov
+ if: '!cancelled()'
+ continue-on-error: true
+ uses: codecov/codecov-action@v5
+ with:
+ files: coverage/lcov.info
+ flags: ember-core
+ fail_ci_if_error: true
+ token: ${{ secrets.CODECOV_TOKEN }}
+
+ # Kept so the raw report can be pulled down with `gh run download` and read
+ # for exactly which statements are uncovered. The gate output only gives a
+ # per-file percentage, which is not enough to aim at a specific branch.
+ - name: Upload coverage report
+ if: '!cancelled()'
+ uses: actions/upload-artifact@v4
+ with:
+ name: coverage-report
+ path: coverage/
+ retention-days: 7
+
+ - name: Enforce 100% coverage gate
+ run: pnpm run coverage:check
+
npm_publish:
- needs: build
+ needs: test
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- - uses: actions/checkout@v2
+ - name: Checkout
+ uses: actions/checkout@v4
- - name: Setup Node.js ${{ env.NODE_VERSION }}
- uses: actions/setup-node@v2
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
with:
- node-version: ${{ env.NODE_VERSION }}
+ version: 11
- - name: Setup pnpm
- uses: pnpm/action-setup@v2.0.1
+ - name: Setup Node.js ${{ env.NODE_VERSION }}
+ uses: actions/setup-node@v4
with:
- version: latest
+ node-version: ${{ env.NODE_VERSION }}
+ cache: pnpm
- name: Install Dependencies
- run: pnpm install
+ run: pnpm install --frozen-lockfile
- name: Build
run: pnpm run build
@@ -67,24 +116,29 @@ jobs:
run: npm publish --access public
github_publish:
- needs: build
+ needs: test
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
+ permissions:
+ contents: read
+ packages: write
steps:
- - uses: actions/checkout@v2
+ - name: Checkout
+ uses: actions/checkout@v4
- - name: Setup Node.js ${{ env.NODE_VERSION }}
- uses: actions/setup-node@v2
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
with:
- node-version: ${{ env.NODE_VERSION }}
+ version: 11
- - name: Setup pnpm
- uses: pnpm/action-setup@v2.0.1
+ - name: Setup Node.js ${{ env.NODE_VERSION }}
+ uses: actions/setup-node@v4
with:
- version: latest
+ node-version: ${{ env.NODE_VERSION }}
+ cache: pnpm
- name: Install Dependencies
- run: pnpm install
+ run: pnpm install --frozen-lockfile
- name: Build
run: pnpm run build
diff --git a/README.md b/README.md
index cf883d29..b81632d1 100644
--- a/README.md
+++ b/README.md
@@ -1,25 +1,236 @@
-# @fleetbase/ember-core
+
+
+
-Ember Core Services, Utilities and Framework for building Fleetbase Extensions in the Console. To start building with Fleetbase, you need to install `@fleetbase/ember-core`, which is built using Ember, a JavaScript framework for building web applications
+Fleetbase Ember Core
-## Compatibility
+
+ The services, contracts, decorators and utilities behind the Fleetbase console — and the
+ foundation every Fleetbase extension is built on.
+
-* Ember.js v4.8 or above
-* Ember CLI v4.8 or above
-* Node.js v18 or above
+
+
+
+
+
+
+
+
+
+ Documentation ·
+ Quickstart ·
+ Ember UI ·
+ Contributing
+
+
+---
+
+## Overview
+
+`@fleetbase/ember-core` is the Ember addon that the Fleetbase console and every Fleetbase extension
+share. It provides:
+
+- **The Universe** — the extensibility layer an extension uses to plug into the console: menus,
+ registries, dashboard widgets, lifecycle hooks, engine loading and resource views.
+- **Contracts** — small, chainable classes that describe what an extension contributes
+ (`MenuItem`, `Widget`, `Hook`, `TableColumn`, `ResourceAction` and more).
+- **Application services** — authentication, the API client, the current user, CRUD workflows,
+ filters, notifications, sockets, theming and more.
+- **Decorators and utilities** — store- and API-backed properties, engine service injection, and the
+ helpers the console relies on throughout.
+
+Components live in the companion addon, **[@fleetbase/ember-ui](https://github.com/fleetbase/ember-ui)**.
+
+## Documentation
+
+The documentation lives at **[fleetbase.io/docs/extension-development](https://fleetbase.io/docs/extension-development)**.
+
+| Topic | Guide |
+| --- | --- |
+| Building your first extension | [Quickstart](https://fleetbase.io/docs/extension-development/getting-started/quickstart) · [Extension anatomy](https://fleetbase.io/docs/extension-development/getting-started/extension-anatomy) |
+| How an extension is loaded | [Architecture](https://fleetbase.io/docs/extension-development/architecture/overview) · [Extension registration](https://fleetbase.io/docs/extension-development/architecture/extension-registration) |
+| The Universe and its sub-services | [Universe overview](https://fleetbase.io/docs/extension-development/universe/overview) · [Menus](https://fleetbase.io/docs/extension-development/universe/menu-service) · [Registries](https://fleetbase.io/docs/extension-development/universe/registry-service) · [Widgets](https://fleetbase.io/docs/extension-development/universe/widget-service) · [Hooks](https://fleetbase.io/docs/extension-development/universe/hook-service) · [Extension manager](https://fleetbase.io/docs/extension-development/universe/extension-manager) |
+| Extending another engine's tables and details views | [Resource views](https://fleetbase.io/docs/extension-development/resource-views) |
+| API reference | [Contracts](https://fleetbase.io/docs/extension-development/reference/contracts) · [Ember services](https://fleetbase.io/docs/extension-development/reference/ember-services) · [Decorators](https://fleetbase.io/docs/extension-development/reference/decorators) |
## Installation
-To install ember-core, run:
+```bash
+pnpm add @fleetbase/ember-core
+```
-`ember install @fleetbase/ember-core`
+Or, with Ember CLI:
-## Contributing
+```bash
+ember install @fleetbase/ember-core
+```
+
+Requires Node 18 or newer. The addon is built and tested against Ember 5.4.
+
+## Usage
+
+### Registering an extension
+
+An extension contributes to the console from the `setupExtension` hook in its `addon/extension.js`.
+The `universe` it receives is the entry point to everything else:
+
+```js
+// addon/extension.js
+import { ResourceAction, TableColumn } from '@fleetbase/ember-core/contracts';
+
+export default {
+ setupExtension(app, universe) {
+ // Add an item to the console header
+ universe.getService('menu').registerHeaderMenuItem('Acme', 'console.acme', {
+ icon: 'rocket',
+ priority: 5,
+ });
+
+ // Add a column and a row action to Fleet-Ops' drivers table
+ const views = universe.getService('resource-view');
+
+ views.register(
+ 'fleet-ops:driver:table:columns',
+ new TableColumn({ id: 'safety-score', label: 'Safety Score', valuePath: 'meta.safety_score' }).after('status')
+ );
+
+ views.register(
+ 'fleet-ops:driver:table:row-actions',
+ new ResourceAction({ id: 'acme-sync', label: 'Sync to Acme', icon: 'sync', permission: 'acme sync driver' })
+ .withHandler((driver, ctx) => ctx.owner.lookup('service:acme').sync(driver))
+ );
+ },
+};
+```
+
+### Using the services
+
+Every service is injected the usual way:
+
+```js
+import Controller from '@ember/controller';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+
+export default class OrdersController extends Controller {
+ @service fetch;
+ @service currentUser;
+ @service notifications;
+
+ @action async dispatch(order) {
+ await this.fetch.post(`orders/${order.id}/dispatch`);
+ this.notifications.success(`Dispatched by ${this.currentUser.name}`);
+ }
+}
+```
-We welcome contributions to Ember-Core from the Fleetbase community!
+## What's included
+
+
+Universe — the extensibility layer
+
+`universe` is a facade over six sub-services, each reached with `universe.getService(name)`:
+
+| Name | Responsible for |
+| --- | --- |
+| `menu` | Header items, admin and settings menus, account dropdowns and custom menu registries |
+| `registry` | Named registries, and registering components, services and helpers across engines |
+| `widget` | Dashboard widgets and dashboards |
+| `hook` | Application lifecycle and custom hooks |
+| `resource-view` | Columns, actions and menu items on other engines' tables and details views |
+| `extension-manager` | Loading engines and sharing services between them |
+
+
+
+
+Contracts — imported from @fleetbase/ember-core/contracts
+
+| Contract | Describes |
+| --- | --- |
+| `MenuItem`, `MenuPanel` | Menu entries and the admin panels that group them |
+| `ExtensionComponent` | A component that lives in an engine and is loaded on demand |
+| `Widget` | A dashboard widget |
+| `Hook` | A lifecycle or custom hook handler |
+| `Registry` | A named registry |
+| `TableColumn` | A column added to a resource table |
+| `ResourceAction` | A row, bulk or details-menu action |
+| `ActionButton` | A toolbar or details-header button |
+
+
+
+
+Services
+
+| Service | Responsible for |
+| --- | --- |
+| `session` | Authentication and session lifecycle |
+| `current-user` | The signed-in user, their organization, permissions and preferences |
+| `fetch` | The Fleetbase API client: requests, uploads, downloads and caching |
+| `crud` | Delete, bulk-action, export and import workflows |
+| `resource-action` | The base for model-specific action services |
+| `filters` | Query-param filters for resource tables |
+| `table-context` | Selection state for the active table |
+| `abilities` | Permission checks |
+| `notifications` | Toast notifications |
+| `socket` | Real-time channels |
+| `chat` | Chat channels and messages |
+| `events` | Application event tracking |
+| `theme` | Light and dark themes and route body classes |
+| `language` | Locale selection |
+| `loader` | Loading overlays |
+| `app-cache` | Persistent client-side cache |
+| `url-search-params` | Reading and writing the URL's query string |
+| `custom-fields-registry` | Custom fields attached to any model |
+
+
+
+
+Decorators
+
+| Decorator | Does |
+| --- | --- |
+| `@engineService` | Injects a service from another engine |
+| `@fromStore` | Backs a property with a store query |
+| `@fetchFrom` | Backs a property with an API request |
+
+
+
+## Development
+
+```bash
+pnpm install --frozen-lockfile
+
+pnpm start # serve the dummy app
+pnpm test # lint + the full suite
+pnpm run test:ember # the suite on its own
+pnpm run lint # eslint, ember-template-lint, stylelint
+pnpm run build # production build
+```
+
+Run a subset while working on one area:
+
+```bash
+pnpm exec ember test --filter="Unit | Service | fetch"
+```
+
+## Testing and coverage
+
+The suite runs in headless Chrome through Testem. Coverage is gated at **100% of the `addon/`
+source** — statements, branches, functions and lines, checked per file — and enforced in CI, with
+results reported to [Codecov](https://codecov.io/gh/fleetbase/ember-core).
+
+```bash
+pnpm run coverage # run the suite with coverage
+pnpm run coverage:check # enforce the gate
+```
+
+## Contributing
-If you'd like to contribute, please follow the contributing guidelines.
+See the [Contributing](CONTRIBUTING.md) guide. Questions and ideas are welcome in
+[GitHub Discussions](https://github.com/fleetbase/fleetbase/discussions) and on
+[Discord](https://discord.gg/HnTqQ6zAVn).
## License
-This project is licensed under the [MIT License](LICENSE.md).
+Licensed under the [GNU Affero General Public License v3.0 or later](LICENSE.md).
diff --git a/RELEASE.md b/RELEASE.md
index d635d4eb..fcfa595a 100644
--- a/RELEASE.md
+++ b/RELEASE.md
@@ -1,15 +1,40 @@
-> v0.3.24 ~ "RELEASE_NOTES_PLACEHOLDER — replace this line with the release title"
+> v0.3.25 ~ "Resource view registries, 100% test coverage, and the fixes it turned up"
---
## Highlights
-RELEASE_NOTES_PLACEHOLDER
-
-Describe what changed in this release. The first line above must name the version
-being released, and both placeholder markers must be gone, or the release workflow
-refuses to tag.
+- **Resource view registries.** Extensions can add columns, row actions, bulk actions and toolbar buttons to any engine's table views, and header buttons and menu items to its details views. Names follow `:::`, for example `fleet-ops:driver:table:columns` or `ledger:invoice:details:menu`.
+ - New contracts: `TableColumn`, `ResourceAction` and `ActionButton`.
+ - New service: `universe/resource-view-service`, reached as `universe.getService('resource-view')`.
+ - New `ResourceActionService` helpers: `tableRegistry`, `detailsRegistry`, `mergeRegisteredColumns()` and `queryParamsFor()`.
+ - Guide: [Resource views](https://fleetbase.io/docs/extension-development/resource-views).
+ - Rendering ships in fleetbase/ember-ui v0.4.5.
+- **Verified 100% test coverage.** Statements, branches, functions and lines are all at 100%, checked per file by a CI gate, with results on Codecov. Writing the tests turned up a number of defects; the fixes are listed below.
+- **Fixes found while writing the tests:**
+ - `crud`:
+ - bulk-action messages no longer print the count twice;
+ - the import dialog accepts files;
+ - a `modelName` option now overrides the model's own name.
+ - `fetch`:
+ - a bare `Content-Type` such as `text/csv` is read correctly;
+ - a filename the caller passes wins over the `content-disposition` header;
+ - `cachedGet` expires a month-old cache.
+ - `filters` understand Ember's mapped query params.
+ - The organization and user account menus no longer show each other's items.
+ - Sign-in failures abort and invalidate once, and the session's seconds remaining is positive until it expires.
+ - The user is warned when their location cannot be detected.
+ - Reopening a chat keeps the other open chats.
+ - Menu items registered by title no longer replace each other.
+ - `MenuItem`'s chaining click setter is now `withOnClick()`.
+ - `loadSubjectCustomFields` rejects when loading fails, so callers can tell a failure from a subject with no custom fields.
+- **Header shortcuts keep their permission.** `registerHeaderMenuItem` now carries `permission` onto each shortcut, or inherits the parent's, so the header can hide shortcuts a user cannot open.
+- **Fix: a required file custom field rejected a file uploaded in the same session.** A freshly staged `file:` value now counts as present.
+- **Fix: the universe registry facade.** `getRegistry`, `registerInRegistry`, `lookupFromRegistry`, `getMenuItemsFromRegistry` and `getMenuPanelsFromRegistry` passed the wrong arguments and returned nothing.
+ - Panels that read registered tabs through them now see those tabs.
+ - `virtualRouteRedirect` (used by the console's login route) now redirects hidden `auth:login` pages on direct load.
---
## Need help?
- [GitHub Discussions](https://github.com/fleetbase/fleetbase/discussions)
- [Discord](https://discord.gg/HnTqQ6zAVn)
+---
diff --git a/addon/abilities/dynamic.js b/addon/abilities/dynamic.js
index b0cbdd99..e56da313 100644
--- a/addon/abilities/dynamic.js
+++ b/addon/abilities/dynamic.js
@@ -8,6 +8,7 @@ export default class extends Ability {
@tracked service;
@tracked resource;
@tracked ability;
+ /* istanbul ignore next -- the constructor assigns permissions unconditionally, so this default is never read */
@tracked permissions = new Set();
constructor() {
diff --git a/addon/adapters/application.js b/addon/adapters/application.js
index bd52c281..89f5a668 100644
--- a/addon/adapters/application.js
+++ b/addon/adapters/application.js
@@ -12,6 +12,7 @@ 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}`;
}
@@ -88,7 +89,10 @@ export default class ApplicationAdapter extends RESTAdapter {
const userId = this.session.data.authenticated.user;
const userOptions = getUserOptions();
const isSandbox = get(userOptions, `${userId}:sandbox`) === true;
- const testKey = get(userOptions, `${userId}:testKey`);
+ // `currentUser.setOption` dasherizes before storing, so the key written
+ // by `setOption('testKey', …)` is `: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;
diff --git a/addon/contracts/action-button.js b/addon/contracts/action-button.js
new file mode 100644
index 00000000..3e4dc0d4
--- /dev/null
+++ b/addon/contracts/action-button.js
@@ -0,0 +1,49 @@
+import ResourceViewItem from './resource-view-item';
+
+/**
+ * A header button an extension adds to a view: the table toolbar
+ * (`::table:actions`) or the details header
+ * (`::details:actions`).
+ *
+ * Renders as a plain button calling `onClick(ctx)`, as a dropdown when it has
+ * `items` (each an action receiving `(resource, ctx)`), or as `component`, which
+ * may be an `ExtensionComponent`. `isVisible(ctx)` hides it.
+ *
+ * @class ActionButton
+ * @extends ResourceViewItem
+ *
+ * @example
+ * new ActionButton({ id: 'acme-import', text: 'Import from Acme', icon: 'download' })
+ * .withHandler((ctx) => ctx.owner.lookup('service:acme').import())
+ */
+export default class ActionButton extends ResourceViewItem {
+ static contractType = 'action-button';
+
+ withText(text) {
+ return this.setOption('text', text);
+ }
+
+ withIcon(icon, iconPrefix = null) {
+ this.setOption('icon', icon);
+ return iconPrefix ? this.setOption('iconPrefix', iconPrefix) : this;
+ }
+
+ withHandler(onClick) {
+ return this.setOption('onClick', onClick);
+ }
+
+ withItems(items = []) {
+ return this.setOption(
+ 'items',
+ items.map((item) => (typeof item?.toObject === 'function' ? item.toObject() : item))
+ );
+ }
+
+ withComponent(component) {
+ return this.setOption('component', component);
+ }
+
+ visibleWhen(isVisible) {
+ return this.setOption('isVisible', isVisible);
+ }
+}
diff --git a/addon/contracts/base-contract.js b/addon/contracts/base-contract.js
index 60c45ea1..a6b6c1d9 100644
--- a/addon/contracts/base-contract.js
+++ b/addon/contracts/base-contract.js
@@ -11,6 +11,7 @@ import { tracked } from '@glimmer/tracking';
* @class BaseContract
*/
export default class BaseContract {
+ /* istanbul ignore next -- the constructor assigns _options unconditionally, so this default is never read */
@tracked _options = {};
constructor(options = {}) {
diff --git a/addon/contracts/index.js b/addon/contracts/index.js
index b5532f25..f8060e0e 100644
--- a/addon/contracts/index.js
+++ b/addon/contracts/index.js
@@ -15,3 +15,7 @@ export { default as MenuPanel } from './menu-panel';
export { default as Hook } from './hook';
export { default as Widget } from './widget';
export { default as Registry } from './registry';
+export { default as ResourceViewItem } from './resource-view-item';
+export { default as TableColumn } from './table-column';
+export { default as ResourceAction } from './resource-action';
+export { default as ActionButton } from './action-button';
diff --git a/addon/contracts/menu-item.js b/addon/contracts/menu-item.js
index f19ad6fd..ef6e6b85 100644
--- a/addon/contracts/menu-item.js
+++ b/addon/contracts/menu-item.js
@@ -33,7 +33,7 @@ import isObject from '../utils/is-object';
* // Menu item with component
* new MenuItem('Settings')
* .withComponent(new ExtensionComponent('@fleetbase/my-engine', 'components/settings'))
- * .onClick((menuItem, router) => {
+ * .withOnClick((menuItem, router) => {
* router.transitionTo('virtual', menuItem.slug);
* })
*
@@ -248,13 +248,17 @@ export default class MenuItem extends BaseContract {
}
/**
- * Set a click handler for the menu item
+ * Set a click handler for the menu item.
*
- * @method onClick
+ * `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
*/
- onClick(handler) {
+ withOnClick(handler) {
+ this.onClick = handler;
this._options.onClick = handler;
return this;
}
diff --git a/addon/contracts/resource-action.js b/addon/contracts/resource-action.js
new file mode 100644
index 00000000..beffcad7
--- /dev/null
+++ b/addon/contracts/resource-action.js
@@ -0,0 +1,39 @@
+import ResourceViewItem from './resource-view-item';
+
+/**
+ * A dropdown item an extension adds to a view: a table row's actions
+ * (`row-actions`), the Bulk Actions menu (`bulk-actions`) or a details header's
+ * "…" menu (`menu`).
+ *
+ * `fn(target, ctx)` receives the row, the selected rows or the resource
+ * depending on the slot, then the view context. `isVisible(target, ctx)` hides
+ * the item; `permission` disables it for users who lack the ability.
+ *
+ * @class ResourceAction
+ * @extends ResourceViewItem
+ *
+ * @example
+ * new ResourceAction({ id: 'acme-sync', label: 'Sync to Acme', icon: 'sync' })
+ * .withHandler((driver, ctx) => ctx.owner.lookup('service:acme').sync(driver))
+ * .before('delete')
+ */
+export default class ResourceAction extends ResourceViewItem {
+ static contractType = 'action';
+
+ withLabel(label) {
+ return this.setOption('label', label);
+ }
+
+ withIcon(icon, iconPrefix = null) {
+ this.setOption('icon', icon);
+ return iconPrefix ? this.setOption('iconPrefix', iconPrefix) : this;
+ }
+
+ withHandler(fn) {
+ return this.setOption('fn', fn);
+ }
+
+ visibleWhen(isVisible) {
+ return this.setOption('isVisible', isVisible);
+ }
+}
diff --git a/addon/contracts/resource-view-item.js b/addon/contracts/resource-view-item.js
new file mode 100644
index 00000000..81c9a0a5
--- /dev/null
+++ b/addon/contracts/resource-view-item.js
@@ -0,0 +1,78 @@
+import BaseContract from './base-contract';
+import isObject from '../utils/is-object';
+
+/**
+ * Base class for everything an extension adds to a table or details view:
+ * columns, dropdown actions and header buttons.
+ *
+ * Holds what they share: a required `id` (the dedupe key, and the anchor other
+ * items place themselves against), placement (`before`, `after`, `index`,
+ * `priority`) and `permission`. Every other key in the definition is kept, so
+ * a subclass never has to list the keys the renderers read.
+ *
+ * @class ResourceViewItem
+ * @extends BaseContract
+ */
+export default class ResourceViewItem extends BaseContract {
+ /**
+ * The registry list this contract is stored in. Set by each subclass.
+ *
+ * @type {String}
+ */
+ static contractType = null;
+
+ constructor(definition = {}) {
+ super(isObject(definition) ? definition : {});
+ this.setup();
+ }
+
+ get contractType() {
+ return this.constructor.contractType;
+ }
+
+ get id() {
+ return this.getOption('id');
+ }
+
+ validate() {
+ if (!this.id) {
+ throw new Error(`${this.constructor.name} requires an id`);
+ }
+ }
+
+ /**
+ * Place this item directly before the sibling with the given id.
+ *
+ * @param {String} id
+ * @returns {ResourceViewItem}
+ */
+ before(id) {
+ return this.removeOption('after').setOption('before', id);
+ }
+
+ /**
+ * Place this item directly after the sibling with the given id.
+ *
+ * @param {String} id
+ * @returns {ResourceViewItem}
+ */
+ after(id) {
+ return this.removeOption('before').setOption('after', id);
+ }
+
+ withIndex(index) {
+ return this.setOption('index', index);
+ }
+
+ withPriority(priority) {
+ return this.setOption('priority', priority);
+ }
+
+ withPermission(permission) {
+ return this.setOption('permission', permission);
+ }
+
+ toObject() {
+ return { ...this._options, _contractType: this.contractType };
+ }
+}
diff --git a/addon/contracts/table-column.js b/addon/contracts/table-column.js
new file mode 100644
index 00000000..f847c4e9
--- /dev/null
+++ b/addon/contracts/table-column.js
@@ -0,0 +1,62 @@
+import ResourceViewItem from './resource-view-item';
+
+/**
+ * A column an extension adds to a table view, registered into
+ * `::table:columns`.
+ *
+ * Accepts every key a built-in column does (`label`, `valuePath`,
+ * `cellComponent`, `width`, `sticky`, `sortable`, `resizable`, `hidden`, and the
+ * filter keys `filterable`, `filterComponent`, `filterParam`, `filterLabel`,
+ * `filterOptions`, `filterFetchOptions`, `model`, `modelNamePath`).
+ * `cellComponent` and `filterComponent` may be an `ExtensionComponent`, so the
+ * component can live in the extension's own engine.
+ *
+ * A registered column is not sortable unless it says so: the API only sorts
+ * on what it knows about.
+ *
+ * @class TableColumn
+ * @extends ResourceViewItem
+ *
+ * @example
+ * new TableColumn({ id: 'safety-score', label: 'Safety Score', valuePath: 'meta.safety_score' })
+ * .after('status')
+ * .withCellComponent(new ExtensionComponent('@acme/engine', 'cell/safety-score'))
+ */
+export default class TableColumn extends ResourceViewItem {
+ static contractType = 'table-column';
+
+ constructor(definition = {}) {
+ super({ sortable: false, resizable: true, ...definition });
+ }
+
+ withLabel(label) {
+ return this.setOption('label', label);
+ }
+
+ withValuePath(valuePath) {
+ return this.setOption('valuePath', valuePath);
+ }
+
+ withCellComponent(cellComponent) {
+ return this.setOption('cellComponent', cellComponent);
+ }
+
+ /**
+ * Make the column filterable. `filterParam` must also be a query param on
+ * the owning index controller; see `ResourceViewService.queryParamsFor`.
+ *
+ * @param {String} filterParam
+ * @param {String|ExtensionComponent} filterComponent
+ * @param {Object} options Any other filter keys
+ * @returns {TableColumn}
+ */
+ withFilter(filterParam, filterComponent = 'filter/string', options = {}) {
+ this.setOption('filterable', true).setOption('filterParam', filterParam).setOption('filterComponent', filterComponent);
+ Object.entries(options).forEach(([key, value]) => this.setOption(key, value));
+ return this;
+ }
+
+ get filterParam() {
+ return this.getOption('filterable') ? this.getOption('filterParam') : null;
+ }
+}
diff --git a/addon/contracts/widget.js b/addon/contracts/widget.js
index aadea2d8..30ac3a5d 100644
--- a/addon/contracts/widget.js
+++ b/addon/contracts/widget.js
@@ -235,9 +235,6 @@ export default class Widget extends BaseContract {
* @returns {Widget} This instance for chaining
*/
withTitle(title) {
- if (!this.options) {
- this.options = {};
- }
this.options.title = title;
this._options.options = this.options;
return this;
@@ -251,9 +248,6 @@ export default class Widget extends BaseContract {
* @returns {Widget} This instance for chaining
*/
withRefreshInterval(milliseconds) {
- if (!this.options) {
- this.options = {};
- }
this.options.refreshInterval = milliseconds;
this._options.options = this.options;
return this;
diff --git a/addon/decorators/legacy-fetch-from.js b/addon/decorators/legacy-fetch-from.js
index 1b2eb6cb..bf2b1d8e 100644
--- a/addon/decorators/legacy-fetch-from.js
+++ b/addon/decorators/legacy-fetch-from.js
@@ -11,6 +11,7 @@ export default function fetchFrom(endpoint, query = {}, options = {}) {
return decoratorWithRequiredParams(function (target, key) {
const symbol = Symbol(`__${key}_fetchFrom`);
+ // `null` until the fetch resolves: the "not loaded yet" value.
Object.defineProperty(target, symbol, {
configurable: true,
enumerable: false,
@@ -18,7 +19,11 @@ export default function fetchFrom(endpoint, query = {}, options = {}) {
value: null,
});
- Object.defineProperty(target, key, {
+ // The accessor is defined on the prototype and also returned as the field's
+ // descriptor. Returning it is what matters for a native class field: it replaces
+ // the field, which would otherwise be an own property of each instance, shadow
+ // the prototype accessor and start out `undefined`.
+ const accessor = {
configurable: true,
enumerable: true,
get() {
@@ -27,7 +32,8 @@ export default function fetchFrom(endpoint, query = {}, options = {}) {
set(value) {
this[symbol] = value;
},
- });
+ };
+ Object.defineProperty(target, key, accessor);
const originalInit = target.init;
@@ -51,5 +57,7 @@ export default function fetchFrom(endpoint, query = {}, options = {}) {
});
});
};
+
+ return accessor;
}, 'fetchFrom')(endpoint, query, options);
}
diff --git a/addon/decorators/legacy-from-store.js b/addon/decorators/legacy-from-store.js
index 29d60b3d..20fb24df 100644
--- a/addon/decorators/legacy-from-store.js
+++ b/addon/decorators/legacy-from-store.js
@@ -1,52 +1,2 @@
-import { decoratorWithRequiredParams } from '@ember-decorators/utils/decorator';
-import { getOwner } from '@ember/application';
-import { assert } from '@ember/debug';
-
-export default decoratorWithRequiredParams(function (target, key, descriptor, [modelName, query = {}, options = {}]) {
- assert('The first argument of the @fetchFrom decorator must be a string', typeof modelName === 'string');
- assert('The second argument of the @fetchFrom decorator must be an object', typeof query === 'object');
- assert('The third argument of the @fetchFrom decorator must be an object', typeof options === 'object');
-
- // Remove value and writable if previously set, use getter instead
- delete descriptor.value;
- delete descriptor.writable;
- delete descriptor.initializer;
-
- // Create symbol to track value
- const symbol = Symbol(`__${key}_fromStore`);
-
- // Setter to get symbol value
- descriptor.set = function (value) {
- this[symbol] = value;
- };
-
- // Get or set symbol value
- descriptor.get = function () {
- if (this[symbol] !== undefined) {
- return this[symbol];
- }
-
- Object.defineProperty(this, symbol, {
- configurable: true,
- enumerable: false,
- writable: true,
- value: null,
- });
-
- const owner = getOwner(this);
- const store = owner.lookup('service:store');
- return store
- .query(modelName, query, options)
- .then((response) => {
- this.set(key, response);
- if (options && typeof options.onComplete === 'function') {
- options.onComplete(response, this);
- }
- })
- .catch(() => {
- this.set(key, null);
- });
- };
-
- return descriptor;
-});
+// Identical to from-store; kept so existing imports keep working.
+export { default } from './from-store';
diff --git a/addon/exports/host-services.js b/addon/exports/host-services.js
index 35738834..0d3a59a8 100644
--- a/addon/exports/host-services.js
+++ b/addon/exports/host-services.js
@@ -24,6 +24,7 @@ export const hostServices = [
'universe/registry-service',
'universe/hook-service',
'universe/widget-service',
+ 'universe/resource-view-service',
'universe/extension-manager',
'events',
'intl',
diff --git a/addon/exports/services.js b/addon/exports/services.js
index 2eb841ef..71a745e9 100644
--- a/addon/exports/services.js
+++ b/addon/exports/services.js
@@ -26,6 +26,7 @@ export const services = [
'universe/registry-service',
'universe/hook-service',
'universe/widget-service',
+ 'universe/resource-view-service',
'universe/extension-manager',
'events',
'intl',
diff --git a/addon/library/subject-custom-fields.js b/addon/library/subject-custom-fields.js
index d1de2806..5e97705d 100644
--- a/addon/library/subject-custom-fields.js
+++ b/addon/library/subject-custom-fields.js
@@ -12,6 +12,7 @@ export default class SubjectCustomFields {
@tracked groups = [];
@tracked fields = [];
@tracked values = Object.create(null);
+ /* istanbul ignore next -- the constructor assigns options unconditionally, so this default is never read */
@tracked options = {};
constructor({ owner, subject, options = {} }) {
@@ -61,11 +62,13 @@ export default class SubjectCustomFields {
}
@action writeFieldValue(resource, value, customField) {
+ const fieldId = typeof customField === 'string' ? customField : customField?.id;
+ if (!fieldId) return;
+
this.setFieldValue(value, customField);
- const fieldId = typeof customField === 'string' ? customField : customField?.id;
const valueType = typeof customField === 'string' ? null : (customField?.valueType ?? customField?.value_type ?? null);
- if (!fieldId || !resource) return;
+ if (!resource) return;
let rec = this.#getLocalValueRecord(resource, fieldId);
const nextVal = value ?? '';
@@ -357,7 +360,7 @@ export default class SubjectCustomFields {
const prev = g.customFields; // <- no coercion
const needsInit = !Array.isArray(prev); // undefined / non-array
- const changed = !needsInit && !sameIds(prev, computed, key, false);
+ const changed = !needsInit && !sameIds(prev, computed, { key, orderMatters: false });
if (needsInit || changed) {
planned.push({ g, next: computed }); // initialize or update
@@ -371,7 +374,7 @@ export default class SubjectCustomFields {
const { g, next } = planned[i];
const current = g.customFields; // no coercion
const needsInit = !Array.isArray(current);
- const changed = !needsInit && !sameIds(current, next, key, false);
+ const changed = !needsInit && !sameIds(current, next, { key, orderMatters: false });
if (needsInit || changed) {
g.customFields = next; // tracked; safe after render
@@ -421,8 +424,12 @@ export default class SubjectCustomFields {
}
case 'file':
- // your File value is a JSON string; reuse your rule (must look like {...})
- return typeof value === 'string' && value.startsWith('{') && value.endsWith('}');
+ if (typeof value !== 'string') return false;
+ // A value that has been through the server comes back as file JSON, but one
+ // staged since the last save is still the `file:` sentinel the upload
+ // handlers write. Both mean the field has a file on it.
+ if (value.startsWith('file:')) return value.length > 'file:'.length;
+ return value.startsWith('{') && value.endsWith('}');
default:
// fallback: any non-nullish, non-empty string
diff --git a/addon/serializers/application.js b/addon/serializers/application.js
index 82e414e7..84859113 100644
--- a/addon/serializers/application.js
+++ b/addon/serializers/application.js
@@ -43,6 +43,7 @@ export default class ApplicationSerializer extends RESTSerializer {
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)) {
diff --git a/addon/services/app-cache.js b/addon/services/app-cache.js
index 3a1ba75b..499fbeda 100644
--- a/addon/services/app-cache.js
+++ b/addon/services/app-cache.js
@@ -47,19 +47,25 @@ export default class AppCacheService extends Service {
return value;
}
+ // Reads storage directly rather than going through `get`, which substitutes
+ // its default for a missing value and so would report every key as present.
+ _isStored(key) {
+ return this.localCache.get(`${this.cachePrefix}${dasherize(key)}`) !== undefined;
+ }
+
@action has(key) {
if (isArray(key)) {
- return key.every((k) => this.get(k) !== undefined);
+ return key.every((k) => this._isStored(k));
}
- return this.get(key) !== undefined;
+ return this._isStored(key);
}
@action doesntHave(key) {
if (isArray(key)) {
- return key.every((k) => this.get(k) === undefined);
+ return key.every((k) => !this._isStored(k));
}
- return this.get(key) === undefined;
+ return !this._isStored(key);
}
}
diff --git a/addon/services/chat.js b/addon/services/chat.js
index 7f71beff..d75553f1 100644
--- a/addon/services/chat.js
+++ b/addon/services/chat.js
@@ -17,7 +17,8 @@ export default class ChatService extends Service.extend(Evented) {
if (this.openChannels.includes(chatChannelRecord)) {
return;
}
- this.openChannels.pushObject(chatChannelRecord);
+ // Reassigned rather than mutated so the tracked property invalidates.
+ this.openChannels = [...this.openChannels, chatChannelRecord];
this.rememberOpenedChannel(chatChannelRecord);
this.trigger('chat.opened', chatChannelRecord);
}
@@ -25,7 +26,7 @@ export default class ChatService extends Service.extend(Evented) {
closeChannel(chatChannelRecord) {
const index = this.openChannels.findIndex((_) => _.id === chatChannelRecord.id);
if (index >= 0) {
- this.openChannels.removeAt(index);
+ this.openChannels = this.openChannels.filter((_, i) => i !== index);
this.trigger('chat.closed', chatChannelRecord);
}
this.forgetOpenedChannel(chatChannelRecord);
@@ -33,10 +34,11 @@ export default class ChatService extends Service.extend(Evented) {
rememberOpenedChannel(chatChannelRecord) {
let openedChats = this.appCache.get('open-chats', []);
- if (isArray(openedChats) && !openedChats.includes(chatChannelRecord.id)) {
- openedChats.pushObject(chatChannelRecord.id);
- } else {
+ if (!isArray(openedChats)) {
+ // A corrupted cache: start over.
openedChats = [chatChannelRecord.id];
+ } else if (!openedChats.includes(chatChannelRecord.id)) {
+ openedChats = [...openedChats, chatChannelRecord.id];
}
this.appCache.set('open-chats', openedChats);
}
@@ -44,7 +46,7 @@ export default class ChatService extends Service.extend(Evented) {
forgetOpenedChannel(chatChannelRecord) {
let openedChats = this.appCache.get('open-chats', []);
if (isArray(openedChats)) {
- openedChats.removeObject(chatChannelRecord.id);
+ openedChats = openedChats.filter((id) => id !== chatChannelRecord.id);
} else {
openedChats = [];
}
@@ -56,6 +58,7 @@ export default class ChatService extends Service.extend(Evented) {
if (isArray(openedChats)) {
const findAll = openedChats.map((id) => this.store.findRecord('chat-channel', id));
return all(findAll).then((openedChatRecords) => {
+ /* istanbul ignore else -- RSVP.all resolves with an array on every path, so the else arm is unreachable */
if (isArray(openedChatRecords)) {
for (let i = 0; i < openedChatRecords.length; i++) {
const chatChannelRecord = openedChatRecords[i];
diff --git a/addon/services/crud.js b/addon/services/crud.js
index edb2ff53..b83a84c0 100644
--- a/addon/services/crud.js
+++ b/addon/services/crud.js
@@ -1,7 +1,7 @@
import Service from '@ember/service';
import { inject as service } from '@ember/service';
import { action, get } from '@ember/object';
-import { isArray } from '@ember/array';
+import { isArray, A } from '@ember/array';
import { dasherize } from '@ember/string';
import { later } from '@ember/runloop';
import { pluralize } from 'ember-inflector';
@@ -11,6 +11,15 @@ import getModelName from '../utils/get-model-name';
import getWithDefault from '../utils/get-with-default';
import first from '../utils/first';
+/**
+ * The human name of a record's model for dialogs and messages. `options.modelName`
+ * overrides the model's own name, as an option should.
+ */
+function displayModelName(model, options) {
+ const modelName = get(options, 'modelName');
+ return getModelName(modelName ? null : model, modelName, { humanize: true, capitalizeWords: true });
+}
+
export default class CrudService extends Service {
@service fetch;
@service modalsManager;
@@ -28,7 +37,7 @@ export default class CrudService extends Service {
* @void
*/
@action delete(model, options = {}) {
- const modelName = getModelName(model, get(options, 'modelName'), { humanize: true, capitalizeWords: true });
+ const modelName = displayModelName(model, options);
const successNotification = options?.successNotification || `${model.name ? modelName + " '" + model.name + "'" : "'" + modelName + "'"} has been deleted.`;
this.modalsManager.confirm({
@@ -83,7 +92,7 @@ export default class CrudService extends Service {
}
const firstModel = first(selected);
- const modelName = getModelName(firstModel, get(options, 'modelName'), { humanize: true, capitalizeWords: true });
+ const modelName = displayModelName(firstModel, options);
// make sure all are the same type
selected = selected.filter((m) => getModelName(m) === getModelName(firstModel));
@@ -111,11 +120,11 @@ export default class CrudService extends Service {
}
const firstModel = first(selected);
- const modelName = getModelName(firstModel, get(options, 'modelName'), { humanize: true, capitalizeWords: true });
+ const modelName = displayModelName(firstModel, options);
const count = selected.length;
const actionMethod = (typeof options.actionMethod === 'string' ? options.actionMethod : `POST`).toLowerCase();
const modalTemplate = getWithDefault(options, 'template', 'modals/bulk-action-model');
- const successMessage = options?.successNotification ?? `${count} ${pluralize(count, modelName)} were updated successfully.`;
+ const successMessage = options?.successNotification ?? `${pluralize(count, modelName)} were updated successfully.`;
if (typeof options.resolveModelName === 'function') {
selected = selected.map((model) => {
@@ -138,7 +147,10 @@ export default class CrudService extends Service {
count,
modelName,
remove: (model) => {
- selected.removeObject(model);
+ // `selected` is a plain array whenever resolveModelName ran above,
+ // and a plain array has no removeObject. Filtering works for both
+ // shapes, and the setOption below publishes the new reference.
+ selected = selected.filter((item) => item !== model);
this.modalsManager.setOption('selected', selected);
},
confirm: async (modal) => {
@@ -289,7 +301,7 @@ export default class CrudService extends Service {
acceptButtonIcon: 'upload',
acceptButtonDisabled: true,
isProcessing: false,
- uploadQueue: [],
+ uploadQueue: A([]),
fileQueueColumns: [
{ name: 'Type', valuePath: 'extension', key: 'type' },
{ name: 'File Name', valuePath: 'name', key: 'fileName' },
@@ -325,7 +337,7 @@ export default class CrudService extends Service {
type: 'import-source',
},
(uploadedFile) => {
- uploadedFiles.pushObject(uploadedFile);
+ uploadedFiles.push(uploadedFile);
resolve(uploadedFile);
}
);
diff --git a/addon/services/current-user.js b/addon/services/current-user.js
index 028c13e7..dfa56fa4 100644
--- a/addon/services/current-user.js
+++ b/addon/services/current-user.js
@@ -185,35 +185,27 @@ export default class CurrentUserService extends Service.extend(Evented) {
}
async loadWhois() {
- try {
- // 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,
- });
+ // 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);
- this.whoisData = whois;
-
- return whois;
} catch (error) {
- console.error('[currentUser] Failed to load whois:', error);
- this.notifications.warning('Unable to detect your location. Some features may use default settings.');
-
- // Return fallback data with browser timezone
- const fallback = {
- city: null,
- country_code: null,
- timezone: getBrowserTimezone(),
- _source: 'fallback',
- };
-
- this.setOption('whois', fallback);
- this.whoisData = fallback;
-
- return fallback;
+ console.error('[currentUser] Failed to store whois:', error);
}
+
+ return whois;
}
getCompany() {
@@ -235,20 +227,20 @@ export default class CurrentUserService extends Service.extend(Evented) {
// get direct applied permissions
if (user.get('permissions')) {
- permissions.pushObjects(user.get('permissions').toArray());
+ permissions.push(...user.get('permissions').toArray());
}
// get role permissions and role policies permissions
if (user.get('role')) {
if (user.get('role.permissions')) {
- permissions.pushObjects(user.get('role.permissions').toArray());
+ 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.pushObjects(policy.get('permissions').toArray());
+ permissions.push(...policy.get('permissions').toArray());
}
}
}
@@ -259,7 +251,7 @@ export default class CurrentUserService extends Service.extend(Evented) {
for (let i = 0; i < user.get('policies').length; i++) {
const policy = user.get('policies').objectAt(i);
if (policy.get('permissions')) {
- permissions.pushObjects(policy.get('permissions').toArray());
+ permissions.push(...policy.get('permissions').toArray());
}
}
}
@@ -310,7 +302,13 @@ export default class CurrentUserService extends Service.extend(Evented) {
}
hasOption(key) {
- return this.getOption(key) !== undefined;
+ // 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) {
diff --git a/addon/services/custom-fields-registry.js b/addon/services/custom-fields-registry.js
index 811e06e0..37b4ac3e 100644
--- a/addon/services/custom-fields-registry.js
+++ b/addon/services/custom-fields-registry.js
@@ -13,6 +13,15 @@ export default class CustomFieldsRegistryService extends ResourceActionService {
#cache = new WeakMap();
modelNamePath = 'label';
+ constructor() {
+ super(...arguments);
+ // Without this the base class keeps `modelName = null`, so
+ // `createNewInstance` reaches `store.createRecord(undefined)` and every
+ // create path below throws. `modelNamePath` is already set by the class
+ // field above, and `initialize` preserves it.
+ this.initialize('custom-field');
+ }
+
panel = {
create: (attributes = {}, options = {}, saveOptions = {}) => {
saveOptions = { ...(options?.saveOptions ?? {}), ...saveOptions };
@@ -22,12 +31,13 @@ export default class CustomFieldsRegistryService extends ResourceActionService {
title: 'Create a new custom field',
panelContentClass: 'py-2 px-4',
useDefaultSaveTask: true,
+ customField,
+ ...options,
+ // After the options, which carry the raw saveOptions this merged.
saveOptions: {
callback: this.refresh,
...saveOptions,
},
- customField,
- ...options,
});
},
edit: (customField, options = {}, saveOptions = {}) => {
@@ -38,8 +48,9 @@ export default class CustomFieldsRegistryService extends ResourceActionService {
panelContentClass: 'py-2 px-4',
useDefaultSaveTask: true,
customField,
- saveOptions,
...options,
+ // After the options, which carry the raw saveOptions this merged.
+ saveOptions,
});
},
};
@@ -77,8 +88,9 @@ export default class CustomFieldsRegistryService extends ResourceActionService {
yield manager.load({ group: true });
return manager;
} catch (err) {
- console.error(err);
debug('[Custom Fields Registry] Unable to load custom fields manager: ' + err.message);
+ // Rethrown so a caller can tell a failed load from a subject with no custom fields.
+ throw err;
}
}
@@ -105,13 +117,14 @@ export default class CustomFieldsRegistryService extends ResourceActionService {
} else {
// keep them fresh if caller passes new ones
manager.subject = subject;
+ /* istanbul ignore next -- forSubject defaults `options` to an object and SubjectCustomFields stores it as given, so neither `|| {}` can be taken */
manager.options = { ...(manager.options || {}), ...(options || {}) };
}
return manager;
}
- #scopeKey(subject, options = {}) {
+ #scopeKey(subject, options) {
const lo = options.loadOptions || options;
const groupedFor = lo.groupedFor ?? 'custom_field_group';
const fieldFor = lo.fieldFor ?? `subject:${getModelName(subject)}`;
@@ -127,8 +140,12 @@ export default class CustomFieldsRegistryService extends ResourceActionService {
}
// Optional proxy methods if you prefer service ergonomics:
+ // NOTE: `set`, `get` and `setProperties` shadow the EmberObject methods of
+ // the same name that this service inherits, and take a different first
+ // argument. Anything calling `registry.get('somePropertyName')` reaches
+ // this instead of the property lookup it expected.
set(subject, fieldOrId, value, valueType) {
- return this.forSubject(subject).set(fieldOrId, value, valueType);
+ return this.forSubject(subject).setField(fieldOrId, value, valueType);
}
setProperties(subject, entries) {
@@ -136,7 +153,7 @@ export default class CustomFieldsRegistryService extends ResourceActionService {
}
get(subject, customFieldId) {
- return this.forSubject(subject).get(customFieldId);
+ return this.forSubject(subject).getValue(customFieldId);
}
getProperties(subject) {
diff --git a/addon/services/events.js b/addon/services/events.js
index 09fdb2f0..7f22bae5 100644
--- a/addon/services/events.js
+++ b/addon/services/events.js
@@ -397,7 +397,7 @@ export default class EventsService extends Service.extend(Evented) {
* @param {Object} props - Base properties
* @returns {Object} Enriched properties
*/
- #enrichProperties(props = {}) {
+ #enrichProperties(props) {
const eventsConfig = config?.events || {};
const enrichConfig = eventsConfig.enrich || {};
const enriched = { ...props };
diff --git a/addon/services/fetch.js b/addon/services/fetch.js
index 7809008c..6073616e 100644
--- a/addon/services/fetch.js
+++ b/addon/services/fetch.js
@@ -8,7 +8,7 @@ import { isArray } from '@ember/array';
import { singularize, pluralize } from 'ember-inflector';
import { task } from 'ember-concurrency';
import { storageFor } from 'ember-local-storage';
-import { intervalToDuration, parseISO } from 'date-fns';
+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';
@@ -19,6 +19,7 @@ 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}`;
}
@@ -68,7 +69,9 @@ export default class FetchService extends Service {
const userId = this.session.data.authenticated.user;
const userOptions = getUserOptions();
const isSandbox = get(userOptions, `${userId}:sandbox`) === true;
- const testKey = get(userOptions, `${userId}:testKey`);
+ // See the note in adapters/application.js: `setOption` dasherizes, so
+ // the stored key is `:test-key`, not `:testKey`.
+ const testKey = get(userOptions, `${userId}:test-key`);
headers['Content-Type'] = 'application/json';
@@ -178,7 +181,10 @@ export default class FetchService extends Service {
normalizeModel(payload, modelType = null) {
if (modelType === null) {
const modelTypeKeys = Object.keys(payload);
- modelType = modelTypeKeys.length ? modelTypeKeys.firstObject : false;
+ // `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') {
@@ -314,7 +320,10 @@ export default class FetchService extends Service {
}
if (isArray(response.json.errors)) {
- return reject(new Error(response.json.errors ? response.json.errors.firstObject : response.statusText));
+ // 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') {
@@ -393,13 +402,9 @@ export default class FetchService extends Service {
const expirationInterval = options.expirationInterval ?? 3;
const expirationIntervalUnit = pluralize(options.expirationIntervalUnit ?? 'days');
- // calculate duration between cache version and now
- const duration = intervalToDuration({
- start: parseISO(version),
- end: new Date(),
- });
- // determine if we should expire cache
- const shouldExpire = duration[expirationIntervalUnit] > expirationInterval;
+ // 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) {
@@ -657,7 +662,8 @@ export default class FetchService extends Service {
const contentDisposition = response.headers.get('content-disposition');
let fileName = defaultFilename;
- if (contentDisposition) {
+ // 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) {
@@ -676,11 +682,9 @@ export default class FetchService extends Service {
let mimeType = defaultMimeType;
if (contentType) {
- const results = /(.*)?;/.exec(contentType);
-
- if (isArray(results) && results.length > 1) {
- mimeType = results[1];
- }
+ // 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;
@@ -693,11 +697,14 @@ export default class FetchService extends Service {
const serialized = [];
for (let i = 0; i < configs.length; i++) {
- const config = configs.objectAt(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.pushObject(serializedConfig);
+ serialized.push(serializedConfig);
}
resolve(serialized);
diff --git a/addon/services/filters.js b/addon/services/filters.js
index 12032db8..60e21932 100644
--- a/addon/services/filters.js
+++ b/addon/services/filters.js
@@ -18,14 +18,12 @@ export default class FiltersService extends Service {
const queryParams = this.getQueryParams();
const activeQueryParams = [];
+ // getQueryParams has already dropped managed and blank params, so there
+ // is nothing left to filter here.
for (let queryParam in queryParams) {
const value = get(queryParams, queryParam);
- if (isBlank(value) || this.managedQueryParams.includes(queryParam)) {
- continue;
- }
-
- activeQueryParams.pushObject({ queryParam, label: queryParam, value });
+ activeQueryParams.push({ queryParam, label: queryParam, value });
}
return activeQueryParams;
@@ -161,9 +159,11 @@ export default class FiltersService extends Service {
const controllerQueryParams = getWithDefault(controller, 'queryParams', []);
if (isArray(controllerQueryParams)) {
- for (let i = 0; i < controllerQueryParams.length; i++) {
- const qp = controllerQueryParams.objectAt(i);
+ // An entry is a property name, or Ember's mapped form, e.g. `{ category: 'cat' }`,
+ // whose keys are the property names. Filters read and set properties.
+ const propertyNames = controllerQueryParams.flatMap((qp) => (typeof qp === 'string' ? [qp] : Object.keys(qp)));
+ for (const qp of propertyNames) {
if (this.managedQueryParams.includes(qp)) {
continue;
}
@@ -178,7 +178,7 @@ export default class FiltersService extends Service {
const currentRouteQueryParams = Object.keys(currentRoute.queryParams);
for (let i = 0; i < currentRouteQueryParams.length; i++) {
- const queryParam = currentRouteQueryParams.objectAt(i);
+ const queryParam = currentRouteQueryParams[i];
const value = this.urlSearchParams.get(queryParam);
if (this.managedQueryParams.includes(queryParam)) {
diff --git a/addon/services/language.js b/addon/services/language.js
index da0d3603..fbf70ab4 100644
--- a/addon/services/language.js
+++ b/addon/services/language.js
@@ -8,6 +8,7 @@ import { task } from 'ember-concurrency';
export default class LanguageService extends Service {
@service intl;
@service fetch;
+ /* istanbul ignore next -- the constructor assigns locales from intl unconditionally, so this default is never read */
@tracked locales = [];
@tracked countries = [];
@tracked currentLocale;
@@ -81,7 +82,7 @@ export default class LanguageService extends Service {
const localeMap = {};
for (let i = 0; i < this.locales.length; i++) {
- const locale = this.locales.objectAt(i);
+ const locale = this.locales[i];
localeMap[locale] = this._findCountryDataForLocale(locale);
}
diff --git a/addon/services/legacy-universe.js b/addon/services/legacy-universe.js
deleted file mode 100644
index 4d48c0f3..00000000
--- a/addon/services/legacy-universe.js
+++ /dev/null
@@ -1,1976 +0,0 @@
-import Service from '@ember/service';
-import Evented from '@ember/object/evented';
-import { tracked } from '@glimmer/tracking';
-import { inject as service } from '@ember/service';
-import { computed, action } from '@ember/object';
-import { isBlank } from '@ember/utils';
-import { A, isArray } from '@ember/array';
-import { later } from '@ember/runloop';
-import { dasherize, camelize } from '@ember/string';
-import { pluralize } from 'ember-inflector';
-import { getOwner } from '@ember/application';
-import { assert, debug, warn } from '@ember/debug';
-import RSVP from 'rsvp';
-import loadInstalledExtensions from '../utils/load-installed-extensions';
-import loadExtensions from '../utils/load-extensions';
-import getWithDefault from '../utils/get-with-default';
-import config from 'ember-get-config';
-
-export default class LegacyUniverseService extends Service.extend(Evented) {
- @service router;
- @service intl;
- @service urlSearchParams;
- @tracked applicationInstance;
- @tracked enginesBooted = false;
- @tracked bootedExtensions = A([]);
- @tracked headerMenuItems = A([]);
- @tracked organizationMenuItems = A([]);
- @tracked userMenuItems = A([]);
- @tracked consoleAdminRegistry = {
- menuItems: A([]),
- menuPanels: A([]),
- };
- @tracked consoleAccountRegistry = {
- menuItems: A([]),
- menuPanels: A([]),
- };
- @tracked consoleSettingsRegistry = {
- menuItems: A([]),
- menuPanels: A([]),
- };
- @tracked dashboardWidgets = {
- defaultWidgets: A([]),
- widgets: A([]),
- };
- @tracked hooks = {};
- @tracked bootCallbacks = A([]);
- @tracked initialLocation = { ...window.location };
-
- /**
- * Computed property that returns all administrative menu items.
- *
- * @computed adminMenuItems
- * @public
- * @readonly
- * @memberof UniverseService
- * @returns {Array} Array of administrative menu items
- */
- @computed('consoleAdminRegistry.menuItems.[]') get adminMenuItems() {
- return this.consoleAdminRegistry.menuItems;
- }
-
- /**
- * Computed property that returns all administrative menu panels.
- *
- * @computed adminMenuPanels
- * @public
- * @readonly
- * @memberof UniverseService
- * @returns {Array} Array of administrative menu panels
- */
- @computed('consoleAdminRegistry.menuPanels.[]') get adminMenuPanels() {
- return this.consoleAdminRegistry.menuPanels;
- }
-
- /**
- * Computed property that returns all settings menu items.
- *
- * @computed settingsMenuItems
- * @public
- * @readonly
- * @memberof UniverseService
- * @returns {Array} Array of administrative menu items
- */
- @computed('consoleSettingsRegistry.menuItems.[]') get settingsMenuItems() {
- return this.consoleSettingsRegistry.menuItems;
- }
-
- /**
- * Computed property that returns all settings menu panels.
- *
- * @computed settingsMenuPanels
- * @public
- * @readonly
- * @memberof UniverseService
- * @returns {Array} Array of administrative menu panels
- */
- @computed('consoleSettingsRegistry.menuPanels.[]') get settingsMenuPanels() {
- return this.consoleSettingsRegistry.menuPanels;
- }
-
- /**
- * Transitions to a given route within a specified Ember engine.
- *
- * This action dynamically retrieves the specified engine's instance and its configuration to prepend the
- * engine's route prefix to the provided route. If the engine instance or its route prefix is not found,
- * it falls back to transitioning to the route without the prefix.
- *
- * @param {string} engineName - The name of the Ember engine.
- * @param {string} route - The route to transition to within the engine.
- * @param {...any} args - Additional arguments to pass to the router's transitionTo method.
- * @returns {Promise} A Promise that resolves with the result of the router's transitionTo method.
- *
- * @example
- * // Transitions to the 'management.fleets.index.new' route within the '@fleetbase/fleet-ops' engine.
- * this.transitionToEngineRoute('@fleetbase/fleet-ops', 'management.fleets.index.new');
- */
- @action transitionToEngineRoute(engineName, route, ...args) {
- const engineInstance = this.getEngineInstance(engineName);
-
- if (engineInstance) {
- const config = engineInstance.resolveRegistration('config:environment');
-
- if (config) {
- let mountedEngineRoutePrefix = config.mountedEngineRoutePrefix;
-
- if (!mountedEngineRoutePrefix) {
- mountedEngineRoutePrefix = this._mountPathFromEngineName(engineName);
- }
-
- if (!mountedEngineRoutePrefix.endsWith('.')) {
- mountedEngineRoutePrefix = mountedEngineRoutePrefix + '.';
- }
-
- return this.router.transitionTo(`${mountedEngineRoutePrefix}${route}`, ...args);
- }
- }
-
- return this.router.transitionTo(route, ...args);
- }
-
- /**
- * Initialize the universe service.
- *
- * @memberof UniverseService
- */
- initialize() {
- this.initialLocation = { ...window.location };
- this.trigger('init', this);
- }
-
- /**
- * Sets the application instance.
- *
- * @param {ApplicationInstance} - The application instance object.
- * @return {void}
- */
- setApplicationInstance(instance) {
- this.applicationInstance = instance;
- }
-
- /**
- * Retrieves the application instance.
- *
- * @returns {ApplicationInstance} - The application instance object.
- */
- getApplicationInstance() {
- return this.applicationInstance;
- }
-
- /**
- * Retrieves the mount point of a specified engine by its name.
-
- * @param {string} engineName - The name of the engine for which to get the mount point.
- * @returns {string|null} The mount point of the engine or null if not found.
- */
- getEngineMountPoint(engineName) {
- const engineInstance = this.getEngineInstance(engineName);
- return this._getMountPointFromEngineInstance(engineInstance);
- }
-
- /**
- * Determines the mount point from an engine instance by reading its configuration.
-
- * @param {object} engineInstance - The instance of the engine.
- * @returns {string|null} The resolved mount point or null if the instance is undefined or the configuration is not set.
- * @private
- */
- _getMountPointFromEngineInstance(engineInstance) {
- if (engineInstance) {
- const config = engineInstance.resolveRegistration('config:environment');
-
- if (config) {
- let engineName = config.modulePrefix;
- let mountedEngineRoutePrefix = config.mountedEngineRoutePrefix;
-
- if (!mountedEngineRoutePrefix) {
- mountedEngineRoutePrefix = this._mountPathFromEngineName(engineName);
- }
-
- if (!mountedEngineRoutePrefix.endsWith('.')) {
- mountedEngineRoutePrefix = mountedEngineRoutePrefix + '.';
- }
-
- return mountedEngineRoutePrefix;
- }
- }
-
- return null;
- }
-
- /**
- * Extracts and formats the mount path from a given engine name.
- *
- * This function takes an engine name in the format '@scope/engine-name',
- * extracts the 'engine-name' part, removes the '-engine' suffix if present,
- * and formats it into a string that represents a console path.
- *
- * @param {string} engineName - The full name of the engine, typically in the format '@scope/engine-name'.
- * @returns {string} A string representing the console path derived from the engine name.
- * @example
- * // returns 'console.some'
- * _mountPathFromEngineName('@fleetbase/some-engine');
- */
- _mountPathFromEngineName(engineName) {
- let engineNameSegments = engineName.split('/');
- let mountName = engineNameSegments[1];
-
- if (typeof mountName !== 'string') {
- mountName = engineNameSegments[0];
- }
-
- const mountPath = mountName.replace('-engine', '');
- return `console.${mountPath}`;
- }
-
- /**
- * Refreshes the current route.
- *
- * This action is a simple wrapper around the router's refresh method. It can be used to re-run the
- * model hooks and reset the controller properties on the current route, effectively reloading the route.
- * This is particularly useful in scenarios where the route needs to be reloaded due to changes in
- * state or data.
- *
- * @returns {Promise} A Promise that resolves with the result of the router's refresh method.
- *
- * @example
- * // To refresh the current route
- * this.refreshRoute();
- */
- @action refreshRoute() {
- return this.router.refresh();
- }
-
- /**
- * Action to transition to a specified route based on the provided menu item.
- *
- * The route transition will include the 'slug' as a dynamic segment, and
- * the 'view' as an optional dynamic segment if it is defined.
- *
- * @action
- * @memberof UniverseService
- * @param {string} route - The target route to transition to.
- * @param {Object} menuItem - The menu item containing the transition parameters.
- * @param {string} menuItem.slug - The 'slug' dynamic segment for the route.
- * @param {string} [menuItem.view] - The 'view' dynamic segment for the route, if applicable.
- *
- * @returns {Transition} Returns a Transition object representing the transition to the route.
- */
- @action transitionMenuItem(route, menuItem) {
- const { slug, view, section } = menuItem;
-
- if (section && slug && view) {
- return this.router.transitionTo(route, section, slug, { queryParams: { view } });
- }
-
- if (section && slug) {
- return this.router.transitionTo(route, section, slug);
- }
-
- if (slug && view) {
- return this.router.transitionTo(route, slug, { queryParams: { view } });
- }
-
- return this.router.transitionTo(route, slug);
- }
-
- /**
- * Redirects to a virtual route if a corresponding menu item exists based on the current URL slug.
- *
- * This asynchronous function checks whether a virtual route exists by extracting the slug from the current
- * window's pathname and looking up a matching menu item in a specified registry. If a matching menu item
- * is found, it initiates a transition to the given route associated with that menu item and returns the
- * transition promise.
- *
- * @async
- *
- * @param {Object} transition - The current transition object from the router.
- * Used to retrieve additional information required for the menu item lookup.
- * @param {string} registryName - The name of the registry to search for the menu item.
- * This registry should contain menu items mapped by their slugs.
- * @param {string} route - The name of the route to transition to if the menu item is found.
- * This is typically the route associated with displaying the menu item's content.
- *
- * @returns {Promise|undefined} - Returns a promise that resolves when the route transition completes
- * if a matching menu item is found. If no matching menu item is found, the function returns undefined.
- *
- */
- async virtualRouteRedirect(transition, registryName, route, options = {}) {
- const view = this.getViewFromTransition(transition);
- const slug = window.location.pathname.replace('/', '');
- const queryParams = this.urlSearchParams.all();
- const menuItem = await 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;
- });
- }
- }
-
- /**
- * @action
- * Creates a new registry with the given name and options.
-
- * @memberof UniverseService
- * @param {string} registryName - The name of the registry to create.
- * @param {Object} [options={}] - Optional settings for the registry.
- * @param {Array} [options.menuItems=[]] - An array of menu items for the registry.
- * @param {Array} [options.menuPanel=[]] - An array of menu panels for the registry.
- *
- * @fires registry.created - Event triggered when a new registry is created.
- *
- * @returns {UniverseService} Returns the current UniverseService for chaining.
- *
- * @example
- * createRegistry('myRegistry', { menuItems: ['item1', 'item2'], menuPanel: ['panel1', 'panel2'] });
- */
- @action createRegistry(registryName, options = {}) {
- const internalRegistryName = this.createInternalRegistryName(registryName);
-
- if (this[internalRegistryName] == undefined) {
- this[internalRegistryName] = {
- name: registryName,
- menuItems: [],
- menuPanels: [],
- renderableComponents: [],
- ...options,
- };
- } else {
- this[internalRegistryName] = {
- ...this[internalRegistryName],
- ...options,
- };
- }
-
- // trigger registry created event
- this.trigger('registry.created', this[internalRegistryName]);
-
- return this;
- }
-
- /**
- * Creates multiple registries from a given array of registries. Each registry can be either a string or an array.
- * If a registry is an array, it expects two elements: the registry name (string) and registry options (object).
- * If a registry is a string, only the registry name is needed.
- *
- * The function iterates over each element in the `registries` array and creates a registry using the `createRegistry` method.
- * It supports two types of registry definitions:
- * 1. Array format: [registryName, registryOptions] - where registryOptions is an optional object.
- * 2. String format: "registryName" - in this case, only the name is provided and the registry is created with default options.
- *
- * @param {Array} registries - An array of registries to be created. Each element can be either a string or an array.
- * @action
- * @memberof YourComponentOrClassName
- */
- @action createRegistries(registries = []) {
- if (!isArray(registries)) {
- throw new Error('`createRegistries()` method must take an array.');
- }
-
- for (let i = 0; i < registries.length; i++) {
- const registry = registries[i];
-
- if (isArray(registry) && registry.length === 2) {
- let registryName = registry[0];
- let registryOptions = registry[1] ?? {};
-
- this.createRegistry(registryName, registryOptions);
- continue;
- }
-
- if (typeof registry === 'string') {
- this.createRegistry(registry);
- }
- }
- }
-
- /**
- * Triggers an event on for a universe registry.
- *
- * @memberof UniverseService
- * @method createRegistryEvent
- * @param {string} registryName - The name of the registry to trigger the event on.
- * @param {string} event - The name of the event to trigger.
- * @param {...*} params - Additional parameters to pass to the event handler.
- */
- @action createRegistryEvent(registryName, event, ...params) {
- this.trigger(`${registryName}.${event}`, ...params);
- }
-
- /**
- * @action
- * Retrieves the entire registry with the given name.
- *
- * @memberof UniverseService
- * @param {string} registryName - The name of the registry to retrieve.
- *
- * @returns {Object|null} Returns the registry object if it exists; otherwise, returns null.
- *
- * @example
- * const myRegistry = getRegistry('myRegistry');
- */
- @action getRegistry(registryName) {
- const internalRegistryName = this.createInternalRegistryName(registryName);
- const registry = this[internalRegistryName];
-
- if (!isBlank(registry)) {
- return registry;
- }
-
- return null;
- }
-
- /**
- * Looks up a registry by its name and returns it as a Promise.
- *
- * @memberof UniverseService
- * @param {string} registryName - The name of the registry to look up.
- *
- * @returns {Promise} A Promise that resolves to the registry object if it exists; otherwise, rejects with null.
- *
- * @example
- * lookupRegistry('myRegistry')
- * .then((registry) => {
- * // Do something with the registry
- * })
- * .catch((error) => {
- * // Handle the error or absence of the registry
- * });
- */
- lookupRegistry(registryName) {
- const internalRegistryName = this.createInternalRegistryName(registryName);
- const registry = this[internalRegistryName];
-
- return new Promise((resolve, reject) => {
- if (!isBlank(registry)) {
- return resolve(registry);
- }
-
- later(
- this,
- () => {
- if (!isBlank(registry)) {
- return resolve(registry);
- }
- },
- 100
- );
-
- reject(null);
- });
- }
-
- /**
- * @action
- * Retrieves the menu items from a registry with the given name.
- *
- * @memberof UniverseService
- * @param {string} registryName - The name of the registry to retrieve menu items from.
- *
- * @returns {Array} Returns an array of menu items if the registry exists and has menu items; otherwise, returns an empty array.
- *
- * @example
- * const items = getMenuItemsFromRegistry('myRegistry');
- */
- @action getMenuItemsFromRegistry(registryName) {
- const internalRegistryName = this.createInternalRegistryName(registryName);
- const registry = this[internalRegistryName];
-
- if (!isBlank(registry) && isArray(registry.menuItems)) {
- return registry.menuItems;
- }
-
- return [];
- }
-
- /**
- * @action
- * Retrieves the menu panels from a registry with the given name.
- *
- * @memberof UniverseService
- * @param {string} registryName - The name of the registry to retrieve menu panels from.
- *
- * @returns {Array} Returns an array of menu panels if the registry exists and has menu panels; otherwise, returns an empty array.
- *
- * @example
- * const panels = getMenuPanelsFromRegistry('myRegistry');
- */
- @action getMenuPanelsFromRegistry(registryName) {
- const internalRegistryName = this.createInternalRegistryName(registryName);
- const registry = this[internalRegistryName];
-
- if (!isBlank(registry) && isArray(registry.menuPanels)) {
- return registry.menuPanels;
- }
-
- return [];
- }
-
- /**
- * Retrieves renderable components from a specified registry.
- * This action checks the internal registry, identified by the given registry name,
- * and returns the 'renderableComponents' if they are present and are an array.
- *
- * @action
- * @param {string} registryName - The name of the registry to retrieve components from.
- * @returns {Array} An array of renderable components from the specified registry, or an empty array if none found.
- */
- @action getRenderableComponentsFromRegistry(registryName) {
- const internalRegistryName = this.createInternalRegistryName(registryName);
- const registry = this[internalRegistryName];
-
- if (!isBlank(registry) && isArray(registry.renderableComponents)) {
- return registry.renderableComponents;
- }
-
- return [];
- }
-
- /**
- * Loads a component from the specified registry based on a given slug and view.
- *
- * @param {string} registryName - The name of the registry where the component is located.
- * @param {string} slug - The slug of the menu item.
- * @param {string} [view=null] - The view of the menu item, if applicable.
- *
- * @returns {Promise} Returns a Promise that resolves with the component if it is found, or null.
- */
- loadComponentFromRegistry(registryName, slug, view = null) {
- const internalRegistryName = this.createInternalRegistryName(registryName);
- const registry = this[internalRegistryName];
-
- return new Promise((resolve) => {
- let component = null;
-
- if (isBlank(registry)) {
- return resolve(component);
- }
-
- // check menu items first
- for (let i = 0; i < registry.menuItems.length; i++) {
- const menuItem = registry.menuItems[i];
-
- // no view hack
- if (menuItem && menuItem.slug === slug && menuItem.view === null && view === 'index') {
- component = menuItem.component;
- break;
- }
-
- if (menuItem && menuItem.slug === slug && menuItem.view === view) {
- component = menuItem.component;
- break;
- }
- }
-
- // check menu panels
- for (let i = 0; i < registry.menuPanels.length; i++) {
- const menuPanel = registry.menuPanels[i];
-
- if (menuPanel && isArray(menuPanel.items)) {
- for (let j = 0; j < menuPanel.items.length; j++) {
- const menuItem = menuPanel.items[j];
-
- // no view hack
- if (menuItem && menuItem.slug === slug && menuItem.view === null && view === 'index') {
- component = menuItem.component;
- break;
- }
-
- if (menuItem && menuItem.slug === slug && menuItem.view === view) {
- component = menuItem.component;
- break;
- }
- }
- }
- }
-
- resolve(component);
- });
- }
-
- /**
- * Looks up a menu item from the specified registry based on a given slug and view.
- *
- * @param {string} registryName - The name of the registry where the menu item is located.
- * @param {string} slug - The slug of the menu item.
- * @param {string} [view=null] - The view of the menu item, if applicable.
- * @param {string} [section=null] - The section of the menu item, if applicable.
- *
- * @returns {Promise} Returns a Promise that resolves with the menu item if it is found, or null.
- */
- lookupMenuItemFromRegistry(registryName, slug, view = null, section = null) {
- const internalRegistryName = this.createInternalRegistryName(registryName);
- const registry = this[internalRegistryName];
-
- return new Promise((resolve) => {
- let foundMenuItem = null;
-
- if (isBlank(registry)) {
- return resolve(foundMenuItem);
- }
-
- // check menu items first
- for (let i = 0; i < registry.menuItems.length; i++) {
- const menuItem = registry.menuItems[i];
-
- if (menuItem && menuItem.slug === slug && menuItem.section === section && menuItem.view === view) {
- foundMenuItem = menuItem;
- break;
- }
-
- if (menuItem && menuItem.slug === slug && menuItem.view === view) {
- foundMenuItem = menuItem;
- break;
- }
- }
-
- // check menu panels
- for (let i = 0; i < registry.menuPanels.length; i++) {
- const menuPanel = registry.menuPanels[i];
-
- if (menuPanel && isArray(menuPanel.items)) {
- for (let j = 0; j < menuPanel.items.length; j++) {
- const menuItem = menuPanel.items[j];
-
- if (menuItem && menuItem.slug === slug && menuItem.section === section && menuItem.view === view) {
- foundMenuItem = menuItem;
- break;
- }
-
- if (menuItem && menuItem.slug === slug && menuItem.view === view) {
- foundMenuItem = menuItem;
- break;
- }
- }
- }
- }
-
- resolve(foundMenuItem);
- });
- }
-
- /**
- * Gets the view param from the transition object.
- *
- * @param {Transition} transition
- * @return {String|Null}
- * @memberof UniverseService
- */
- getViewFromTransition(transition) {
- const queryParams = transition.to.queryParams ?? { view: null };
- return queryParams.view;
- }
-
- /**
- * Creates an internal registry name for hooks based on a given registry name.
- * The registry name is transformed to camel case and appended with 'Hooks'.
- * Non-alphanumeric characters are replaced with hyphens.
- *
- * @param {string} registryName - The name of the registry for which to create an internal hook registry name.
- * @returns {string} - The internal hook registry name, formatted as camel case with 'Hooks' appended.
- */
- createInternalHookRegistryName(registryName) {
- return `${camelize(registryName.replace(/[^a-zA-Z0-9]/g, '-'))}Hooks`;
- }
-
- /**
- * Registers a hook function under a specified registry name.
- * The hook is stored in an internal registry, and its hash is computed for identification.
- * If the hook is already registered, it is appended to the existing list of hooks.
- *
- * @param {string} registryName - The name of the registry where the hook should be registered.
- * @param {Function} hook - The hook function to be registered.
- */
- registerHook(registryName, hook) {
- if (typeof hook !== 'function') {
- throw new Error('The hook must be a function.');
- }
-
- // no duplicate hooks
- if (this.didRegisterHook(registryName, hook)) {
- return;
- }
-
- const internalHookRegistryName = this.createInternalHookRegistryName(registryName);
- const hookRegistry = this.hooks[internalHookRegistryName] || [];
- hookRegistry.pushObject({ id: this._createHashFromFunctionDefinition(hook), hook });
-
- this.hooks[internalHookRegistryName] = hookRegistry;
- }
-
- /**
- * Checks if a hook was registered already.
- *
- * @param {String} registryName
- * @param {Function} hook
- * @return {Boolean}
- * @memberof UniverseService
- */
- didRegisterHook(registryName, hook) {
- const hooks = this.getHooks(registryName);
- const hookId = this._createHashFromFunctionDefinition(hook);
- return isArray(hooks) && hooks.some((h) => h.id === hookId);
- }
-
- /**
- * Retrieves the list of hooks registered under a specified registry name.
- * If no hooks are registered, returns an empty array.
- *
- * @param {string} registryName - The name of the registry for which to retrieve hooks.
- * @returns {Array} - An array of hook objects registered under the specified registry name.
- * Each object contains an `id` and a `hook` function.
- */
- getHooks(registryName) {
- const internalHookRegistryName = this.createInternalHookRegistryName(registryName);
- return this.hooks[internalHookRegistryName] ?? [];
- }
-
- /**
- * Executes all hooks registered under a specified registry name with the given parameters.
- * Each hook is called with the provided parameters.
- *
- * @param {string} registryName - The name of the registry under which hooks should be executed.
- * @param {...*} params - The parameters to pass to each hook function.
- */
- executeHooks(registryName, ...params) {
- const hooks = this.getHooks(registryName);
- hooks.forEach(({ hook }) => {
- try {
- hook(...params);
- } catch (error) {
- debug(`Error executing hook: ${error}`);
- }
- });
- }
-
- /**
- * Calls all hooks registered under a specified registry name with the given parameters.
- * This is an alias for `executeHooks` for consistency in naming.
- *
- * @param {string} registryName - The name of the registry under which hooks should be called.
- * @param {...*} params - The parameters to pass to each hook function.
- */
- callHooks(registryName, ...params) {
- this.executeHooks(registryName, ...params);
- }
-
- /**
- * Calls a specific hook identified by its ID under a specified registry name with the given parameters.
- * Only the hook with the matching ID is executed.
- *
- * @param {string} registryName - The name of the registry where the hook is registered.
- * @param {string} hookId - The unique identifier of the hook to be called.
- * @param {...*} params - The parameters to pass to the hook function.
- */
- callHook(registryName, hookId, ...params) {
- const hooks = this.getHooks(registryName);
- const hook = hooks.find((h) => h.id === hookId);
-
- if (hook) {
- try {
- hook.hook(...params);
- } catch (error) {
- debug(`Error executing hook: ${error}`);
- }
- } else {
- warn(`Hook with ID ${hookId} not found.`);
- }
- }
-
- /**
- * Registers a renderable component or an array of components into a specified registry.
- * If a single component is provided, it is registered directly.
- * If an array of components is provided, each component in the array is registered individually.
- * The component is also registered into the specified engine.
- *
- * @param {string} engineName - The name of the engine to register the component(s) into.
- * @param {string} registryName - The registry name where the component(s) should be registered.
- * @param {Object|Array} component - The component or array of components to register.
- */
- registerRenderableComponent(engineName, registryName, component) {
- if (isArray(component)) {
- component.forEach((_) => this.registerRenderableComponent(registryName, _));
- return;
- }
-
- // register component to engine
- this.registerComponentInEngine(engineName, component);
-
- // register to registry
- const internalRegistryName = this.createInternalRegistryName(registryName);
- if (!isBlank(this[internalRegistryName])) {
- if (isArray(this[internalRegistryName].renderableComponents)) {
- this[internalRegistryName].renderableComponents.pushObject(component);
- } else {
- this[internalRegistryName].renderableComponents = [component];
- }
- } else {
- this.createRegistry(registryName);
- return this.registerRenderableComponent(...arguments);
- }
- }
-
- /**
- * Registers a new menu panel in a registry.
- *
- * @method registerMenuPanel
- * @public
- * @memberof UniverseService
- * @param {String} registryName The name of the registry to use
- * @param {String} title The title of the panel
- * @param {Array} items The items of the panel
- * @param {Object} options Additional options for the panel
- */
- registerMenuPanel(registryName, title, items = [], options = {}) {
- const internalRegistryName = this.createInternalRegistryName(registryName);
- const intl = this._getOption(options, 'intl', null);
- const open = this._getOption(options, 'open', true);
- const slug = this._getOption(options, 'slug', dasherize(title));
- const menuPanel = {
- intl,
- title,
- open,
- items: items.map(({ title, route, ...options }) => {
- options.slug = slug;
- options.view = dasherize(title);
-
- return this._createMenuItem(title, route, options);
- }),
- };
-
- // register menu panel
- this[internalRegistryName].menuPanels.pushObject(menuPanel);
-
- // trigger menu panel registered event
- this.trigger('menuPanel.registered', menuPanel, this[internalRegistryName]);
- }
-
- /**
- * Registers a new menu item in a registry.
- *
- * @method registerMenuItem
- * @public
- * @memberof UniverseService
- * @param {String} registryName The name of the registry to use
- * @param {String} title The title of the item
- * @param {String} route The route of the item
- * @param {Object} options Additional options for the item
- */
- registerMenuItem(registryName, title, options = {}) {
- const internalRegistryName = this.createInternalRegistryName(registryName);
- const route = this._getOption(options, 'route', `console.${dasherize(registryName)}.virtual`);
- options.slug = this._getOption(options, 'slug', '~');
- options.view = this._getOption(options, 'view', dasherize(title));
-
- // not really a fan of assumptions, but will do this for the timebeing till anyone complains
- if (options.slug === options.view) {
- options.view = null;
- }
-
- // register component if applicable
- this.registerMenuItemComponentToEngine(options);
-
- // create menu item
- const menuItem = this._createMenuItem(title, route, options);
-
- // register menu item
- if (!this[internalRegistryName]) {
- this[internalRegistryName] = {
- menuItems: [],
- menuPanels: [],
- };
- }
-
- // register menu item
- this[internalRegistryName].menuItems.pushObject(menuItem);
-
- // trigger menu panel registered event
- this.trigger('menuItem.registered', menuItem, this[internalRegistryName]);
- }
-
- /**
- * Register multiple menu items to a registry.
- *
- * @param {String} registryName
- * @param {Array} [menuItems=[]]
- * @memberof UniverseService
- */
- registerMenuItems(registryName, menuItems = []) {
- for (let i = 0; i < menuItems.length; i++) {
- const menuItem = menuItems[i];
- if (menuItem && menuItem.title) {
- if (menuItem.options) {
- this.registerMenuItem(registryName, menuItem.title, menuItem.options);
- } else {
- this.registerMenuItem(registryName, menuItem.title, menuItem);
- }
- }
- }
- }
-
- /**
- * Registers a menu item's component to one or multiple engines.
- *
- * @method registerMenuItemComponentToEngine
- * @public
- * @memberof UniverseService
- * @param {Object} options - An object containing the following properties:
- * - `registerComponentToEngine`: A string or an array of strings representing the engine names where the component should be registered.
- * - `component`: The component class to register, which should have a 'name' property.
- */
- registerMenuItemComponentToEngine(options) {
- // Register component if applicable
- if (typeof options.registerComponentToEngine === 'string') {
- this.registerComponentInEngine(options.registerComponentToEngine, options.component);
- }
-
- // register to multiple engines
- if (isArray(options.registerComponentToEngine)) {
- for (let i = 0; i < options.registerComponentInEngine.length; i++) {
- const engineName = options.registerComponentInEngine.objectAt(i);
-
- if (typeof engineName === 'string') {
- this.registerComponentInEngine(engineName, options.component);
- }
- }
- }
- }
-
- /**
- * Registers a new administrative menu panel.
- *
- * @method registerAdminMenuPanel
- * @public
- * @memberof UniverseService
- * @param {String} title The title of the panel
- * @param {Array} items The items of the panel
- * @param {Object} options Additional options for the panel
- */
- registerAdminMenuPanel(title, items = [], options = {}) {
- options.section = this._getOption(options, 'section', 'admin');
- this.registerMenuPanel('console:admin', title, items, options);
- }
-
- /**
- * Registers a new administrative menu item.
- *
- * @method registerAdminMenuItem
- * @public
- * @memberof UniverseService
- * @param {String} title The title of the item
- * @param {Object} options Additional options for the item
- */
- registerAdminMenuItem(title, options = {}) {
- this.registerMenuItem('console:admin', title, options);
- }
-
- /**
- * Registers a new settings menu panel.
- *
- * @method registerSettingsMenuPanel
- * @public
- * @memberof UniverseService
- * @param {String} title The title of the panel
- * @param {Array} items The items of the panel
- * @param {Object} options Additional options for the panel
- */
- registerSettingsMenuPanel(title, items = [], options = {}) {
- this.registerMenuPanel('console:settings', title, items, options);
- }
-
- /**
- * Registers a new settings menu item.
- *
- * @method registerSettingsMenuItem
- * @public
- * @memberof UniverseService
- * @param {String} title The title of the item
- * @param {Object} options Additional options for the item
- */
- registerSettingsMenuItem(title, options = {}) {
- this.registerMenuItem('console:settings', title, options);
- }
-
- /**
- * Registers a new account menu panel.
- *
- * @method registerAccountMenuPanel
- * @public
- * @memberof UniverseService
- * @param {String} title The title of the panel
- * @param {Array} items The items of the panel
- * @param {Object} options Additional options for the panel
- */
- registerAccountMenuPanel(title, items = [], options = {}) {
- this.registerMenuPanel('console:account', title, items, options);
- }
-
- /**
- * Registers a new account menu item.
- *
- * @method registerAccountMenuItem
- * @public
- * @memberof UniverseService
- * @param {String} title The title of the item
- * @param {Object} options Additional options for the item
- */
- registerAccountMenuItem(title, options = {}) {
- this.registerMenuItem('console:account', title, options);
- }
-
- /**
- * Registers a new dashboard with the given name.
- * Initializes the dashboard with empty arrays for default widgets and widgets.
- *
- * @param {string} dashboardName - The name of the dashboard to register.
- * @returns {void}
- */
- registerDashboard(dashboardName) {
- const internalDashboardRegistryName = this.createInternalDashboardName(dashboardName);
- if (this[internalDashboardRegistryName] !== undefined) {
- return;
- }
-
- this[internalDashboardRegistryName] = {
- defaultWidgets: A([]),
- widgets: A([]),
- };
-
- this.trigger('dashboard.registered', this[internalDashboardRegistryName]);
- }
-
- /**
- * Retrieves the registry for a specific dashboard.
- *
- * @param {string} dashboardName - The name of the dashboard to get the registry for.
- * @returns {Object} - The registry object for the specified dashboard, including default and registered widgets.
- */
- getDashboardRegistry(dashboardName) {
- const internalDashboardRegistryName = this.createInternalDashboardName(dashboardName);
- return this[internalDashboardRegistryName];
- }
-
- /**
- * Checks if a dashboard has been registered.
- *
- * @param {String} dashboardName
- * @return {Boolean}
- * @memberof UniverseService
- */
- didRegisterDashboard(dashboardName) {
- const internalDashboardRegistryName = this.createInternalDashboardName(dashboardName);
- return this[internalDashboardRegistryName] !== undefined;
- }
-
- /**
- * Retrieves the widget registry for a specific dashboard and type.
- *
- * @param {string} dashboardName - The name of the dashboard to get the widget registry for.
- * @param {string} [type='widgets'] - The type of widget registry to retrieve (e.g., 'widgets', 'defaultWidgets').
- * @returns {Array} - An array of widget objects for the specified dashboard and type.
- */
- getWidgetRegistry(dashboardName, type = 'widgets') {
- const internalDashboardRegistryName = this.createInternalDashboardName(dashboardName);
- const typeKey = pluralize(type);
- return isArray(this[internalDashboardRegistryName][typeKey]) ? this[internalDashboardRegistryName][typeKey] : [];
- }
-
- /**
- * Registers widgets for a specific dashboard.
- * Supports registering multiple widgets and different types of widget collections.
- *
- * @param {string} dashboardName - The name of the dashboard to register widgets for.
- * @param {Array|Object} widgets - An array of widget objects or a single widget object to register.
- * @param {string} [type='widgets'] - The type of widgets to register (e.g., 'widgets', 'defaultWidgets').
- * @returns {void}
- */
- registerWidgets(dashboardName, widgets = [], type = 'widgets', options = {}) {
- const internalDashboardRegistryName = this.createInternalDashboardName(dashboardName);
- if (isArray(widgets)) {
- widgets.forEach((w) => this.registerWidgets(dashboardName, w, type, options));
- return;
- }
-
- const typeKey = pluralize(type);
- const newWidget = this._createDashboardWidget(widgets, options);
- const widgetRegistry = this.getWidgetRegistry(dashboardName, type);
- if (this.widgetRegistryHasWidget(widgetRegistry, newWidget)) {
- return;
- }
-
- this[internalDashboardRegistryName][typeKey] = [...widgetRegistry, newWidget];
- this.trigger('widget.registered', newWidget);
- }
-
- /**
- * Checks if a widget with the same ID as the pending widget is already registered in the specified dashboard and type.
- *
- * @param {string} dashboardName - The name of the dashboard to check.
- * @param {Object} widgetPendingRegistry - The widget to check for in the registry.
- * @param {string} [type='widgets'] - The type of widget registry to check (e.g., 'widgets', 'defaultWidgets').
- * @returns {boolean} - `true` if a widget with the same ID is found in the registry; otherwise, `false`.
- */
- didRegisterWidget(dashboardName, widgetPendingRegistry, type = 'widgets') {
- const widgetRegistry = this.getWidgetRegistry(dashboardName, type);
- return widgetRegistry.includes((widget) => widget.widgetId === widgetPendingRegistry.widgetId);
- }
-
- /**
- * Checks if a widget with the same ID as the pending widget exists in the provided widget registry instance.
- *
- * @param {Array} [widgetRegistryInstance=[]] - An array of widget objects to check.
- * @param {Object} widgetPendingRegistry - The widget to check for in the registry.
- * @returns {boolean} - `true` if a widget with the same ID is found in the registry; otherwise, `false`.
- */
- widgetRegistryHasWidget(widgetRegistryInstance = [], widgetPendingRegistry) {
- return widgetRegistryInstance.includes((widget) => widget.widgetId === widgetPendingRegistry.widgetId);
- }
-
- /**
- * Registers widgets for the default 'dashboard' dashboard.
- *
- * @param {Array} [widgets=[]] - An array of widget objects to register.
- * @returns {void}
- */
- registerDashboardWidgets(widgets = [], options = {}) {
- this.registerWidgets('dashboard', widgets, 'widgets', options);
- }
-
- /**
- * Registers default widgets for the default 'dashboard' dashboard.
- *
- * @param {Array} [widgets=[]] - An array of default widget objects to register.
- * @returns {void}
- */
- registerDefaultDashboardWidgets(widgets = [], options = {}) {
- this.registerWidgets('dashboard', widgets, 'defaultWidgets', options);
- }
-
- /**
- * Registers default widgets for a specified dashboard.
- *
- * @param {String} dashboardName
- * @param {Array} [widgets=[]] - An array of default widget objects to register.
- * @returns {void}
- */
- registerDefaultWidgets(dashboardName, widgets = [], options = {}) {
- this.registerWidgets(dashboardName, widgets, 'defaultWidgets', options);
- }
-
- /**
- * Retrieves widgets for a specific dashboard.
- *
- * @param {string} dashboardName - The name of the dashboard to retrieve widgets for.
- * @param {string} [type='widgets'] - The type of widgets to retrieve (e.g., 'widgets', 'defaultWidgets').
- * @returns {Array} - An array of widgets for the specified dashboard and type.
- */
- getWidgets(dashboardName, type = 'widgets') {
- const typeKey = pluralize(type);
- const internalDashboardRegistryName = this.createInternalDashboardName(dashboardName);
- return isArray(this[internalDashboardRegistryName][typeKey]) ? this[internalDashboardRegistryName][typeKey] : [];
- }
-
- /**
- * Retrieves default widgets for a specific dashboard.
- *
- * @param {string} dashboardName - The name of the dashboard to retrieve default widgets for.
- * @returns {Array} - An array of default widgets for the specified dashboard.
- */
- getDefaultWidgets(dashboardName) {
- return this.getWidgets(dashboardName, 'defaultWidgets');
- }
-
- /**
- * Retrieves widgets for the default 'dashboard' dashboard.
- *
- * @returns {Array} - An array of widgets for the default 'dashboard' dashboard.
- */
- getDashboardWidgets() {
- return this.getWidgets('dashboard');
- }
-
- /**
- * Retrieves default widgets for the default 'dashboard' dashboard.
- *
- * @returns {Array} - An array of default widgets for the default 'dashboard' dashboard.
- */
- getDefaultDashboardWidgets() {
- return this.getWidgets('dashboard', 'defaultWidgets');
- }
-
- /**
- * Creates an internal name for a dashboard based on its given name.
- *
- * @param {string} dashboardName - The name of the dashboard.
- * @returns {string} - The internal name for the dashboard, formatted as `${dashboardName}Widgets`.
- */
- createInternalDashboardName(dashboardName) {
- return `${camelize(dashboardName.replace(/[^a-zA-Z0-9]/g, '-'))}Widgets`;
- }
-
- /**
- * Creates a new widget object from a widget definition.
- * If the component is a function, it is registered with the host application.
- *
- * @param {Object} widget - The widget definition.
- * @param {string} widget.widgetId - The unique ID of the widget.
- * @param {string} widget.name - The name of the widget.
- * @param {string} [widget.description] - A description of the widget.
- * @param {string} [widget.icon] - An icon for the widget.
- * @param {Function|string} [widget.component] - A component definition or name for the widget.
- * @param {Object} [widget.grid_options] - Grid options for the widget.
- * @param {Object} [widget.options] - Additional options for the widget.
- * @returns {Object} - The newly created widget object.
- */
- _createDashboardWidget(widget, registrationOptions = {}) {
- let { widgetId, name, description, icon, component, grid_options, options } = widget;
-
- // If a class is provided, (optionally) register it under a stable id
- if (typeof component === 'function') {
- const owner = getOwner(this);
- const id = dasherize(component.widgetId || widgetId || this._createUniqueWidgetHashFromDefinition(component));
-
- if (owner) {
- owner.register(`component:${id}`, component);
-
- // Register in engine instance if dashboard will be resolved from an engine
- if (registrationOptions?.engine?.register) {
- registrationOptions.engine.register(`component:${id}`, component);
- }
-
- // component = component;
- widgetId = id;
- }
- }
-
- return {
- widgetId,
- name,
- description,
- icon,
- component, // string OR class — template will resolve
- grid_options,
- options,
- };
- }
-
- /**
- * Generates a unique hash for a widget component based on its function definition.
- * This method delegates the hash creation to the `_createHashFromFunctionDefinition` method.
- *
- * @param {Function} component - The function representing the widget component.
- * @returns {string} - The unique hash representing the widget component.
- */
- _createUniqueWidgetHashFromDefinition(component) {
- return this._createHashFromFunctionDefinition(component);
- }
-
- /**
- * Creates a hash value from a function definition. The hash is generated based on the function's string representation.
- * If the function has a name, it returns that name. Otherwise, it converts the function's string representation
- * into a hash value. This is done by iterating over the characters of the string and performing a simple hash calculation.
- *
- * @param {Function} func - The function whose definition will be hashed.
- * @returns {string} - The hash value derived from the function's definition. If the function has a name, it is returned directly.
- */
- _createHashFromFunctionDefinition(func) {
- if (func.name) {
- return func.name;
- }
-
- if (typeof func.toString === 'function') {
- let definition = func.toString();
- let hash = 0;
- for (let i = 0; i < definition.length; i++) {
- const char = definition.charCodeAt(i);
- hash = (hash << 5) - hash + char;
- hash |= 0;
- }
- return hash.toString(16);
- }
-
- return func.name;
- }
-
- /**
- * Registers a new header menu item.
- *
- * @method registerHeaderMenuItem
- * @public
- * @memberof UniverseService
- * @param {String} title The title of the item
- * @param {String} route The route of the item
- * @param {Object} options Additional options for the item
- */
- registerHeaderMenuItem(title, route, options = {}) {
- this.headerMenuItems.pushObject(this._createMenuItem(title, route, options));
- this.headerMenuItems.sort((a, b) => a.priority - b.priority);
- }
-
- /**
- * Registers a new organization menu item.
- *
- * @method registerOrganizationMenuItem
- * @public
- * @memberof UniverseService
- * @param {String} title The title of the item
- * @param {String} route The route of the item
- * @param {Object} options Additional options for the item
- */
- registerOrganizationMenuItem(title, options = {}) {
- const route = this._getOption(options, 'route', 'console.virtual');
- options.index = this._getOption(options, 'index', 0);
- options.section = this._getOption(options, 'section', 'settings');
-
- this.organizationMenuItems.pushObject(this._createMenuItem(title, route, options));
- }
-
- /**
- * Registers a new organization menu item.
- *
- * @method registerOrganizationMenuItem
- * @public
- * @memberof UniverseService
- * @param {String} title The title of the item
- * @param {String} route The route of the item
- * @param {Object} options Additional options for the item
- */
- registerUserMenuItem(title, options = {}) {
- const route = this._getOption(options, 'route', 'console.virtual');
- options.index = this._getOption(options, 'index', 0);
- options.section = this._getOption(options, 'section', 'account');
-
- this.userMenuItems.pushObject(this._createMenuItem(title, route, options));
- }
-
- /**
- * Returns the value of a given key on a target object, with a default value.
- *
- * @method _getOption
- * @private
- * @memberof UniverseService
- * @param {Object} target The target object
- * @param {String} key The key to get value for
- * @param {*} defaultValue The default value if the key does not exist
- * @returns {*} The value of the key or default value
- */
- _getOption(target, key, defaultValue = null) {
- return target[key] !== undefined ? target[key] : defaultValue;
- }
-
- /**
- * Creates a new menu item with the provided information.
- *
- * @method _createMenuItem
- * @private
- * @memberof UniverseService
- * @param {String} title The title of the item
- * @param {String} route The route of the item
- * @param {Object} options Additional options for the item
- * @returns {Object} A new menu item object
- */
- _createMenuItem(title, route, options = {}) {
- const intl = this._getOption(options, 'intl', null);
- const priority = this._getOption(options, 'priority', 9);
- const icon = this._getOption(options, 'icon', 'circle-dot');
- const items = this._getOption(options, 'items');
- const component = this._getOption(options, 'component');
- const componentParams = this._getOption(options, 'componentParams', {});
- const renderComponentInPlace = this._getOption(options, 'renderComponentInPlace', false);
- const slug = this._getOption(options, 'slug', dasherize(title));
- const view = this._getOption(options, 'view', dasherize(title));
- const queryParams = this._getOption(options, 'queryParams', {});
- const index = this._getOption(options, 'index', 0);
- const onClick = this._getOption(options, 'onClick', null);
- const section = this._getOption(options, 'section', null);
- const iconComponent = this._getOption(options, 'iconComponent', null);
- const iconComponentOptions = this._getOption(options, 'iconComponentOptions', {});
- const iconSize = this._getOption(options, 'iconSize', null);
- const iconPrefix = this._getOption(options, 'iconPrefix', null);
- const iconClass = this._getOption(options, 'iconClass', null);
- const itemClass = this._getOption(options, 'class', null);
- const inlineClass = this._getOption(options, 'inlineClass', null);
- const wrapperClass = this._getOption(options, 'wrapperClass', null);
- const overwriteWrapperClass = this._getOption(options, 'overwriteWrapperClass', false);
- const id = this._getOption(options, 'id', dasherize(title));
- const type = this._getOption(options, 'type', null);
- const buttonType = this._getOption(options, 'buttonType', null);
- const permission = this._getOption(options, 'permission', null);
- const disabled = this._getOption(options, 'disabled', null);
- const isLoading = this._getOption(options, 'isLoading', null);
-
- // dasherize route segments
- if (typeof route === 'string') {
- route = route
- .split('.')
- .map((segment) => dasherize(segment))
- .join('.');
- }
-
- // @todo: create menu item class
- const menuItem = {
- id,
- intl,
- title,
- text: title,
- label: title,
- route,
- icon,
- priority,
- items,
- component,
- componentParams,
- renderComponentInPlace,
- slug,
- queryParams,
- view,
- index,
- section,
- onClick,
- iconComponent,
- iconComponentOptions,
- iconSize,
- iconPrefix,
- iconClass,
- class: itemClass,
- inlineClass,
- wrapperClass,
- overwriteWrapperClass,
- type,
- buttonType,
- permission,
- disabled,
- isLoading,
- };
-
- // make the menu item and universe object a default param of the onClick handler
- if (typeof onClick === 'function') {
- const universe = this;
- menuItem.onClick = function () {
- return onClick(menuItem, universe);
- };
- }
-
- return menuItem;
- }
-
- /**
- * Creates an internal registry name by camelizing the provided registry name and appending "Registry" to it.
- *
- * @method createInternalRegistryName
- * @public
- * @memberof UniverseService
- * @param {String} registryName - The name of the registry to be camelized and formatted.
- * @returns {String} The formatted internal registry name.
- */
- createInternalRegistryName(registryName) {
- return `${camelize(registryName.replace(/[^a-zA-Z0-9]/g, '-'))}Registry`;
- }
-
- /**
- * Registers a component class under one or more names within a specified engine instance.
- * This function provides flexibility in component registration by supporting registration under the component's
- * full class name, a simplified alias derived from the class name, and an optional custom name provided through the options.
- * This flexibility facilitates varied referencing styles within different parts of the application, enhancing modularity and reuse.
- *
- * @param {string} engineName - The name of the engine where the component will be registered.
- * @param {class} componentClass - The component class to be registered. Must be a class, not an instance.
- * @param {Object} [options] - Optional parameters for additional configuration.
- * @param {string} [options.registerAs] - A custom name under which the component can also be registered.
- *
- * @example
- * // Register a component with its default and alias names
- * registerComponentInEngine('mainEngine', HeaderComponent);
- *
- * // Additionally register the component under a custom name
- * registerComponentInEngine('mainEngine', HeaderComponent, { registerAs: 'header' });
- *
- * @remarks
- * - The function does not return any value.
- * - Registration only occurs if:
- * - The specified engine instance exists.
- * - The component class is properly defined with a non-empty name.
- * - The custom name, if provided, must be a valid string.
- * - Allows flexible component referencing by registering under multiple names.
- */
- registerComponentInEngine(engineName, componentClass, options = {}) {
- const engineInstance = this.getEngineInstance(engineName);
- this.registerComponentToEngineInstance(engineInstance, componentClass, options);
- }
-
- /**
- * Registers a component class under its full class name, a simplified alias, and an optional custom name within a specific engine instance.
- * This helper function does the actual registration of the component to the engine instance. It registers the component under its
- * full class name, a dasherized alias of the class name (with 'Component' suffix removed if present), and any custom name provided via options.
- *
- * @param {EngineInstance} engineInstance - The engine instance where the component will be registered.
- * @param {class} componentClass - The component class to be registered. This should be a class reference, not an instance.
- * @param {Object} [options] - Optional parameters for further configuration.
- * @param {string} [options.registerAs] - A custom name under which the component can be registered.
- *
- * @example
- * // Typical usage within the system (not usually called directly by users)
- * registerComponentToEngineInstance(engineInstance, HeaderComponent, { registerAs: 'header' });
- *
- * @remarks
- * - No return value.
- * - The registration is performed only if:
- * - The engine instance is valid and not null.
- * - The component class has a defined and non-empty name.
- * - The custom name, if provided, is a valid string.
- * - This function directly manipulates the engine instance's registration map.
- */
- registerComponentToEngineInstance(engineInstance, componentClass, options = {}) {
- if (engineInstance && componentClass && typeof componentClass.name === 'string') {
- engineInstance.register(`component:${componentClass.name}`, componentClass);
- engineInstance.register(`component:${dasherize(componentClass.name.replace('Component', ''))}`, componentClass);
- if (options && typeof options.registerAs === 'string') {
- engineInstance.register(`component:${options.registerAs}`, componentClass);
- this.trigger('component.registered', componentClass, engineInstance);
- }
- }
- }
-
- /**
- * Registers a service from one engine instance to another within the application.
- * This method retrieves an instance of a service from the current engine and then registers it
- * in a target engine, allowing the service to be shared across different parts of the application.
- *
- * @param {string} targetEngineName - The name of the engine where the service should be registered.
- * @param {string} serviceName - The name of the service to be shared and registered.
- * @param {Object} currentEngineInstance - The engine instance that currently holds the service to be shared.
- *
- * @example
- * // Assuming 'appEngine' and 'componentEngine' are existing engine instances and 'logger' is a service in 'appEngine'
- * registerServiceInEngine('componentEngine', 'logger', appEngine);
- *
- * Note:
- * - This function does not return any value.
- * - It only performs registration if all provided parameters are valid:
- * - Both engine instances must exist.
- * - The service name must be a string.
- * - The service must exist in the current engine instance.
- * - The service is registered without instantiating a new copy in the target engine.
- */
- registerServiceInEngine(targetEngineName, serviceName, currentEngineInstance) {
- // Get the target engine instance
- const targetEngineInstance = this.getEngineInstance(targetEngineName);
-
- // Validate inputs
- if (targetEngineInstance && currentEngineInstance && typeof serviceName === 'string') {
- // Lookup the service instance from the current engine
- const sharedService = currentEngineInstance.lookup(`service:${serviceName}`);
-
- if (sharedService) {
- // Register the service in the target engine
- targetEngineInstance.register(`service:${serviceName}`, sharedService, { instantiate: false });
- this.trigger('service.registered', serviceName, targetEngineInstance);
- }
- }
- }
-
- /**
- * Retrieves a service instance from a specified Ember engine.
- *
- * @param {string} engineName - The name of the engine from which to retrieve the service.
- * @param {string} serviceName - The name of the service to retrieve.
- * @returns {Object|null} The service instance if found, otherwise 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;
- }
-
- /**
- * Load the specified engine. If it is not loaded yet, it will use assetLoader
- * to load it and then register it to the router.
- *
- * @method loadEngine
- * @public
- * @memberof UniverseService
- * @param {String} name The name of the engine to load
- * @returns {Promise} A promise that resolves with the constructed engine instance
- */
- loadEngine(name) {
- const router = getOwner(this).lookup('router:main');
- const instanceId = 'manual'; // Arbitrary instance id, should be unique per engine
- const mountPoint = this._mountPathFromEngineName(name); // No mount point for manually loaded engines
-
- if (!router._enginePromises[name]) {
- router._enginePromises[name] = Object.create(null);
- }
-
- let enginePromise = router._enginePromises[name][instanceId];
-
- // We already have a Promise for this engine instance
- if (enginePromise) {
- return enginePromise;
- }
-
- if (router._engineIsLoaded(name)) {
- // The Engine is loaded, but has no Promise
- enginePromise = RSVP.resolve();
- } else {
- // The Engine is not loaded and has no Promise
- enginePromise = router._assetLoader.loadBundle(name).then(
- () => router._registerEngine(name),
- (error) => {
- router._enginePromises[name][instanceId] = undefined;
- throw error;
- }
- );
- }
-
- return (router._enginePromises[name][instanceId] = enginePromise.then(() => {
- return this.constructEngineInstance(name, instanceId, mountPoint);
- }));
- }
-
- /**
- * Construct an engine instance. If the instance does not exist yet, it will be created.
- *
- * @method constructEngineInstance
- * @public
- * @memberof UniverseService
- * @param {String} name The name of the engine
- * @param {String} instanceId The id of the engine instance
- * @param {String} mountPoint The mount point of the engine
- * @returns {Promise} A promise that resolves with the constructed engine instance
- */
- constructEngineInstance(name, instanceId, mountPoint) {
- const owner = getOwner(this);
-
- assert("You attempted to load the engine '" + name + "', but the engine cannot be found.", owner.hasRegistration(`engine:${name}`));
-
- let engineInstances = owner.lookup('router:main')._engineInstances;
- if (!engineInstances[name]) {
- engineInstances[name] = Object.create(null);
- }
-
- let engineInstance = owner.buildChildEngineInstance(name, {
- routable: true,
- mountPoint,
- });
-
- // correct mountPoint using engine instance
- let _mountPoint = this._getMountPointFromEngineInstance(engineInstance);
- if (_mountPoint) {
- engineInstance.mountPoint = _mountPoint;
- }
-
- // make sure to set dependencies from base instance
- if (engineInstance.base) {
- engineInstance.dependencies = this._setupEngineParentDependenciesBeforeBoot(engineInstance.base.dependencies);
- }
-
- // store loaded instance to engineInstances for booting
- engineInstances[name][instanceId] = engineInstance;
-
- this.trigger('engine.loaded', engineInstance);
- return engineInstance.boot().then(() => {
- return engineInstance;
- });
- }
-
- _setupEngineParentDependenciesBeforeBoot(baseDependencies = {}) {
- const dependencies = { ...baseDependencies };
-
- // fix services
- const servicesObject = {};
- if (isArray(dependencies.services)) {
- for (let i = 0; i < dependencies.services.length; i++) {
- const service = dependencies.services.objectAt(i);
-
- if (typeof service === 'object') {
- Object.assign(servicesObject, service);
- continue;
- }
-
- servicesObject[service] = service;
- }
- }
-
- // fix external routes
- const externalRoutesObject = {};
- if (isArray(dependencies.externalRoutes)) {
- for (let i = 0; i < dependencies.externalRoutes.length; i++) {
- const externalRoute = dependencies.externalRoutes.objectAt(i);
-
- if (typeof externalRoute === 'object') {
- Object.assign(externalRoutesObject, externalRoute);
- continue;
- }
-
- externalRoutesObject[externalRoute] = externalRoute;
- }
- }
-
- dependencies.externalRoutes = externalRoutesObject;
- dependencies.services = servicesObject;
- return dependencies;
- }
-
- /**
- * Retrieve an existing engine instance by its name and instanceId.
- *
- * @method getEngineInstance
- * @public
- * @memberof UniverseService
- * @param {String} name The name of the engine
- * @param {String} [instanceId='manual'] The id of the engine instance (defaults to 'manual')
- * @returns {Object|null} The engine instance if it exists, otherwise null
- */
- getEngineInstance(name, instanceId = 'manual') {
- const owner = getOwner(this);
- const router = owner.lookup('router:main');
- const engineInstances = router._engineInstances;
-
- if (engineInstances && engineInstances[name]) {
- return engineInstances[name][instanceId] || null;
- }
-
- return null;
- }
-
- /**
- * Returns a promise that resolves when the `enginesBooted` property is set to true.
- * The promise will reject with a timeout error if the property does not become true within the specified timeout.
- *
- * @function booting
- * @returns {Promise} A promise that resolves when `enginesBooted` is true or rejects with an error after a timeout.
- */
- booting() {
- return new Promise((resolve, reject) => {
- const check = () => {
- if (this.enginesBooted === true) {
- this.trigger('booted');
- clearInterval(intervalId);
- resolve();
- }
- };
-
- const intervalId = setInterval(check, 100);
- later(
- this,
- () => {
- clearInterval(intervalId);
- reject(new Error('Timeout: Universe was unable to boot engines'));
- },
- 1000 * 40
- );
- });
- }
-
- /**
- * Boot all installed engines, ensuring dependencies are resolved.
- *
- * This method attempts to boot all installed engines by first checking if all
- * their dependencies are already booted. If an engine has dependencies that
- * are not yet booted, it is deferred and retried after its dependencies are
- * booted. If some dependencies are never booted, an error is logged.
- *
- * @method bootEngines
- * @param {ApplicationInstance|null} owner - The Ember ApplicationInstance that owns the engines.
- * @return {void}
- */
- async bootEngines(owner = null) {
- const booted = [];
- const pending = [];
- const additionalCoreExtensions = config.APP.extensions ?? [];
-
- // If no owner provided use the owner of this service
- if (owner === null) {
- owner = getOwner(this);
- }
-
- // Set application instance
- this.initialize();
- this.setApplicationInstance(owner);
-
- const tryBootEngine = (extension) => {
- return this.loadEngine(extension.name).then((engineInstance) => {
- if (engineInstance.base && engineInstance.base.setupExtension) {
- if (this.bootedExtensions.includes(extension.name)) {
- return;
- }
-
- const engineDependencies = getWithDefault(engineInstance.base, 'engineDependencies', []);
- const allDependenciesBooted = engineDependencies.every((dep) => booted.includes(dep));
-
- if (!allDependenciesBooted) {
- pending.push({ extension, engineInstance });
- return;
- }
-
- engineInstance.base.setupExtension(owner, engineInstance, this);
- booted.push(extension.name);
- this.bootedExtensions.pushObject(extension.name);
- this.trigger('extension.booted', extension);
- debug(`Booted : ${extension.name}`);
-
- // Try booting pending engines again
- tryBootPendingEngines();
- }
- });
- };
-
- const tryBootPendingEngines = () => {
- const stillPending = [];
-
- pending.forEach(({ extension, engineInstance }) => {
- if (this.bootedExtensions.includes(extension.name)) {
- return;
- }
-
- const engineDependencies = getWithDefault(engineInstance.base, 'engineDependencies', []);
- const allDependenciesBooted = engineDependencies.every((dep) => booted.includes(dep));
-
- if (allDependenciesBooted) {
- engineInstance.base.setupExtension(owner, engineInstance, this);
- booted.push(extension.name);
- this.bootedExtensions.pushObject(extension.name);
- this.trigger('extension.booted', extension);
- debug(`Booted : ${extension.name}`);
- } else {
- stillPending.push({ extension, engineInstance });
- }
- });
-
- // If no progress was made, log an error in debug/development mode
- assert(`Some engines have unmet dependencies and cannot be booted:`, pending.length === 0 || pending.length > stillPending.length);
-
- pending.length = 0;
- pending.push(...stillPending);
- };
-
- // Run pre-boots if any
- await this.preboot();
-
- return loadInstalledExtensions(additionalCoreExtensions).then(async (extensions) => {
- for (let i = 0; i < extensions.length; i++) {
- const extension = extensions[i];
- await tryBootEngine(extension);
- }
-
- this.runBootCallbacks(owner, () => {
- this.enginesBooted = true;
- });
- });
- }
-
- /**
- * Run engine preboots from all indexed engines.
- *
- * @param {ApplicationInstance} owner
- * @memberof UniverseService
- */
- async preboot(owner) {
- const extensions = await loadExtensions();
- for (let i = 0; i < extensions.length; i++) {
- const extension = extensions[i];
- const instance = await this.loadEngine(extension.name);
- if (instance.base && typeof instance.base.preboot === 'function') {
- instance.base.preboot(owner, instance, this);
- }
- }
- }
-
- /**
- * Checks if an extension has been booted.
- *
- * @param {String} name
- * @return {Boolean}
- * @memberof UniverseService
- */
- didBootEngine(name) {
- return this.bootedExtensions.includes(name);
- }
-
- /**
- * Registers a callback function to be executed after the engine boot process completes.
- *
- * This method ensures that the `bootCallbacks` array is initialized. It then adds the provided
- * callback to this array. The callbacks registered will be invoked in sequence after the engine
- * has finished booting, using the `runBootCallbacks` method.
- *
- * @param {Function} callback - The function to execute after the engine boots.
- * The callback should accept two arguments:
- * - `{Object} universe` - The universe context or environment.
- * - `{Object} appInstance` - The application instance.
- */
- afterBoot(callback) {
- if (!isArray(this.bootCallbacks)) {
- this.bootCallbacks = [];
- }
-
- this.bootCallbacks.pushObject(callback);
- }
-
- /**
- * Executes all registered engine boot callbacks in the order they were added.
- *
- * This method iterates over the `bootCallbacks` array and calls each callback function,
- * passing in the `universe` and `appInstance` parameters. After all callbacks have been
- * executed, it optionally calls a completion function `onComplete`.
- *
- * @param {Object} appInstance - The application instance to pass to each callback.
- * @param {Function} [onComplete] - Optional. A function to call after all boot callbacks have been executed.
- * It does not receive any arguments.
- */
- runBootCallbacks(appInstance, onComplete = null) {
- for (let i = 0; i < this.bootCallbacks.length; i++) {
- const callback = this.bootCallbacks[i];
- if (typeof callback === 'function') {
- try {
- callback(this, appInstance);
- } catch (error) {
- debug(`Engine Boot Callback Error: ${error.message}`);
- }
- }
- }
-
- if (typeof onComplete === 'function') {
- onComplete();
- }
- }
-
- /**
- * Alias for intl service `t`
- *
- * @memberof UniverseService
- */
- t() {
- this.intl.t(...arguments);
- }
-}
diff --git a/addon/services/loader.js b/addon/services/loader.js
index 15e82881..5f69c4dc 100644
--- a/addon/services/loader.js
+++ b/addon/services/loader.js
@@ -57,7 +57,8 @@ export default class LoaderService extends Service {
});
}
- this.routesLoaded.pushObject(route);
+ // Reassigned rather than mutated so the tracked property invalidates.
+ this.routesLoaded = [...this.routesLoaded, route];
}
/**
diff --git a/addon/services/notifications.js b/addon/services/notifications.js
index 3cd8e996..303e2098 100644
--- a/addon/services/notifications.js
+++ b/addon/services/notifications.js
@@ -4,7 +4,7 @@ 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 (isArray(error.errors)) {
+ if (error && isArray(error.errors)) {
const errors = getWithDefault(error, 'errors');
const errorMessage = getWithDefault(errors, '0', fallbackMessage);
diff --git a/addon/services/resource-action.js b/addon/services/resource-action.js
index 3eea7cc8..a4ac5b20 100644
--- a/addon/services/resource-action.js
+++ b/addon/services/resource-action.js
@@ -111,6 +111,98 @@ export default class ResourceActionService extends Service {
*/
@tracked mountPrefix = 'fleet-ops';
+ /**
+ * The engine that owns this resource's views, used in its registry names.
+ * Set by `initialize`, from `mountPrefix` (`console.fleet-ops` → `fleet-ops`).
+ */
+ @tracked registryExtension;
+
+ /**
+ * The resource segment of this resource's registry names. Derived from the
+ * model name without its engine prefix (`ledger-invoice` → `invoice`).
+ */
+ @tracked registryResource = null;
+
+ /**
+ * The shared resource view registry service.
+ *
+ * Looked up rather than injected: an engine built against an older
+ * ember-core does not list it as a dependency, and a missing registry must
+ * leave the view exactly as it was. Undefined when there is none.
+ */
+ get resourceView() {
+ return getOwner(this).lookup('service:universe/resource-view-service');
+ }
+
+ /**
+ * The table registry prefix for this resource, e.g. `fleet-ops:driver:table`.
+ * Pass it to ``.
+ */
+ get tableRegistry() {
+ return this.registryResource ? `${this.registryExtension}:${this.registryResource}:table` : null;
+ }
+
+ /**
+ * The details registry prefix for this resource, e.g. `fleet-ops:driver:details`.
+ * Pass it to ``.
+ */
+ get detailsRegistry() {
+ return this.registryResource ? `${this.registryExtension}:${this.registryResource}:details` : null;
+ }
+
+ /**
+ * Merge what extensions registered into one of this resource's view slots.
+ * Returns the built-in items unchanged when nothing is registered.
+ *
+ * @param {String} surface 'table' or 'details'
+ * @param {String} slot e.g. 'columns', 'actions', 'menu'
+ * @param {Array} baseItems The view's built-in items
+ * @param {Object} context Extra view context (`resource`, `controller`, …)
+ * @returns {Array}
+ */
+ mergeRegistered(surface, slot, baseItems = [], context = {}) {
+ const prefix = surface === 'details' ? this.detailsRegistry : this.tableRegistry;
+ if (!prefix || !this.resourceView) {
+ return baseItems;
+ }
+
+ return this.resourceView.mergeSlot(prefix, slot, baseItems, context);
+ }
+
+ /**
+ * This resource's table columns with registered columns and row actions
+ * merged in. For views that render their own `` rather than
+ * ``, which merges by itself.
+ *
+ * @param {Array} columns The view's built-in columns
+ * @param {Object} context Extra view context (`controller`, `table`, …)
+ * @returns {Array}
+ */
+ mergeRegisteredColumns(columns = [], context = {}) {
+ if (!this.tableRegistry || !this.resourceView) {
+ return columns;
+ }
+
+ const merged = this.resourceView.mergeSlot(this.tableRegistry, 'columns', columns, context);
+ return this.resourceView.mergeRowActions(this.tableRegistry, merged, context);
+ }
+
+ /**
+ * The query params this resource's index controller must declare: its
+ * own, plus the filter params of filterable registered columns. Use it for
+ * the controller's `queryParams` class field.
+ *
+ * @param {Array} baseQueryParams
+ * @returns {Array}
+ */
+ queryParamsFor(baseQueryParams = []) {
+ if (!this.registryResource || !this.resourceView) {
+ return baseQueryParams;
+ }
+
+ return this.resourceView.queryParamsFor(this.registryExtension, this.registryResource, baseQueryParams);
+ }
+
/**
* Initialize the service for store actions
*/
@@ -127,6 +219,8 @@ export default class ResourceActionService extends Service {
this.baseAssetUrl = options.baseAssetUrl ?? this.baseAssetUrl;
this.permissionPrefix = options.permissionPrefix ?? 'fleet-ops';
this.mountPrefix = options.mountPrefix ?? `console.${this.permissionPrefix}`;
+ this.registryExtension = options.registryExtension ?? this.mountPrefix.replace(/^console\./, '');
+ this.registryResource = options.registryResource ?? modelName?.replace(new RegExp(`^${this.registryExtension}-`), '') ?? null;
return this;
}
@@ -206,7 +300,6 @@ export default class ResourceActionService extends Service {
*/
@action bulkDelete(selected = [], options = {}) {
selected = [...(isArray(selected) ? selected : []), ...this.tableContext.getSelectedRows()];
- if (!selected) return;
options = { ...options, ...(this.bulkDeleteOptions ?? {}) };
// if no direct fetchOptions use global fetch options if applicable
@@ -230,7 +323,6 @@ export default class ResourceActionService extends Service {
*/
@action export(selections = [], options = {}) {
selections = [...(isArray(selections) ? selections : []), ...this.tableContext.getSelectedIds()];
- if (!selections) return;
options = { ...options, ...(this.exportOptions ?? {}) };
// if no direct fetchOptions use global fetch options if applicable
diff --git a/addon/services/session.js b/addon/services/session.js
index 29139412..0f21be47 100644
--- a/addon/services/session.js
+++ b/addon/services/session.js
@@ -183,26 +183,28 @@ export default class SessionService extends SimpleAuthSessionService {
async promiseCurrentUser(transition = null) {
const invalidateWithLoader = this.invalidateWithLoader.bind(this);
- try {
- const user = await this.currentUser.promiseUser();
- if (!user) {
- if (transition) {
- transition.abort();
- }
-
- await invalidateWithLoader('Session authentication failed...');
- throw new Error('Session authentication failed...');
- }
-
- return user;
- } catch (error) {
+ // 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;
}
/**
@@ -281,7 +283,7 @@ export default class SessionService extends SimpleAuthSessionService {
const date = this.getExpiresAtDate();
const now = new Date();
- return Math.round((now - date) / 1000);
+ return Math.round((date - now) / 1000);
}
/**
diff --git a/addon/services/socket.js b/addon/services/socket.js
index 0e700b72..bd379244 100644
--- a/addon/services/socket.js
+++ b/addon/services/socket.js
@@ -36,7 +36,8 @@ export default class SocketService extends Service {
const channel = this.socket.subscribe(channelId);
// Track channel
- this.channels.pushObject(channel);
+ // Reassigned rather than mutated so the tracked property invalidates.
+ this.channels = [...this.channels, channel];
// Listen to channel for events
await channel.listener('subscribe').once();
@@ -56,7 +57,7 @@ export default class SocketService extends Service {
closeChannels() {
for (let i = 0; i < this.channels.length; i++) {
- const channel = this.channels.objectAt(i);
+ const channel = this.channels[i];
channel.close();
}
diff --git a/addon/services/universe.js b/addon/services/universe.js
index eeeb2406..4017cac4 100644
--- a/addon/services/universe.js
+++ b/addon/services/universe.js
@@ -30,6 +30,7 @@ export default class UniverseService extends Service.extend(Evented) {
@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;
@@ -78,6 +79,7 @@ export default class UniverseService extends Service.extend(Evented) {
* - "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
@@ -104,6 +106,9 @@ export default class UniverseService extends Service.extend(Evented) {
'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;
@@ -275,10 +280,11 @@ export default class UniverseService extends Service.extend(Evented) {
*
* @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) {
- return this.registryService.getRegistry(name);
+ getRegistry(name, listName = 'menu-item') {
+ return this.registryService.getRegistry(name, listName);
}
/**
@@ -288,9 +294,10 @@ export default class UniverseService extends Service.extend(Evented) {
* @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) {
- this.registryService.register(registryName, key, value);
+ registerInRegistry(registryName, key, value, listName = 'menu-item') {
+ this.registryService.register(registryName, listName, key, value);
}
/**
@@ -299,10 +306,54 @@ export default class UniverseService extends Service.extend(Evented) {
* @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) {
- return this.registryService.lookup(registryName, key);
+ 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);
}
// ============================================================================
@@ -544,8 +595,10 @@ export default class UniverseService extends Service.extend(Evented) {
*/
get dashboardWidgets() {
return {
- defaultWidgets: this.widgetService.getDefaultWidgets(),
- widgets: this.widgetService.getWidgets(),
+ // The dashboard the deprecated registerDashboardWidgets() and
+ // registerDefaultDashboardWidgets() register into.
+ defaultWidgets: this.widgetService.getDefaultWidgets('dashboard'),
+ widgets: this.widgetService.getWidgets('dashboard'),
};
}
@@ -637,25 +690,22 @@ export default class UniverseService extends Service.extend(Evented) {
* @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) {
+ 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 (section && slug && view) {
- return this.router.transitionTo(route, section, slug, { queryParams: { view } });
- }
-
- if (section && slug) {
- return this.router.transitionTo(route, section, slug);
- }
-
- if (slug && view) {
- return this.router.transitionTo(route, slug, { queryParams: { view } });
+ if (Object.keys(queryParams).length) {
+ return this.router.transitionTo(route, ...params, { queryParams });
}
- return this.router.transitionTo(route, slug);
+ return this.router.transitionTo(route, ...params);
}
/**
@@ -701,7 +751,7 @@ export default class UniverseService extends Service.extend(Evented) {
* @returns {Array} Menu items
*/
getMenuItemsFromRegistry(registryName) {
- return this.registryService.getRegistry(registryName) || A([]);
+ return this.menuService.getMenuItems(registryName) || A([]);
}
/**
@@ -713,7 +763,7 @@ export default class UniverseService extends Service.extend(Evented) {
* @returns {Array} Menu panels
*/
getMenuPanelsFromRegistry(registryName) {
- return this.registryService.getRegistry(`${registryName}:panels`) || A([]);
+ return this.menuService.getMenuPanels(registryName) || A([]);
}
/**
@@ -782,7 +832,7 @@ export default class UniverseService extends Service.extend(Evented) {
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.onClick(options.onClick);
+ if (options.onClick) menuItem.withOnClick(options.onClick);
return menuItem.toObject();
}
diff --git a/addon/services/universe/extension-manager.js b/addon/services/universe/extension-manager.js
index 9b0156f8..a4f0147a 100644
--- a/addon/services/universe/extension-manager.js
+++ b/addon/services/universe/extension-manager.js
@@ -370,7 +370,9 @@ export default class ExtensionManagerService extends Service.extend(Evented) {
const servicesObject = {};
if (isArray(dependencies.services)) {
for (let i = 0; i < dependencies.services.length; i++) {
- let serviceName = dependencies.services.objectAt(i);
+ // Engine dependencies are declared as plain array literals, so
+ // `objectAt` does not exist with prototype extensions off.
+ let serviceName = dependencies.services[i];
if (typeof serviceName === 'object') {
Object.assign(servicesObject, serviceName);
continue;
@@ -391,7 +393,7 @@ export default class ExtensionManagerService extends Service.extend(Evented) {
const externalRoutesObject = {};
if (isArray(dependencies.externalRoutes)) {
for (let i = 0; i < dependencies.externalRoutes.length; i++) {
- const externalRoute = dependencies.externalRoutes.objectAt(i);
+ const externalRoute = dependencies.externalRoutes[i];
if (typeof externalRoute === 'object') {
Object.assign(externalRoutesObject, externalRoute);
@@ -1023,6 +1025,7 @@ export default class ExtensionManagerService extends Service.extend(Evented) {
const _mountPoint = self.#getMountPointFromEngineInstance(engineInstance);
if (_mountPoint) {
// Remove trailing dot before setting on engine instance
+ /* istanbul ignore next -- #getMountPointFromEngineInstance appends a trailing dot to whatever it returns, so the other arm cannot be taken */
engineInstance.mountPoint = _mountPoint.endsWith('.') ? _mountPoint.slice(0, -1) : _mountPoint;
}
diff --git a/addon/services/universe/hook-service.js b/addon/services/universe/hook-service.js
index 51b04a28..ccae0b99 100644
--- a/addon/services/universe/hook-service.js
+++ b/addon/services/universe/hook-service.js
@@ -23,14 +23,39 @@ export default class HookService extends Service {
*/
@tracked applicationInstance = null;
+ #hookRegistry = null;
+
/**
- * Creates an instance of HookService.
- * @memberof HookService
+ * 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}
*/
- constructor() {
- super(...arguments);
- // Initialize shared hook registry
- this.hookRegistry = this.#initializeHookRegistry();
+ 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;
}
/**
@@ -127,7 +152,7 @@ export default class HookService extends Service {
* @param {Object} options Optional options
* @returns {Object} Normalized hook object
*/
- #normalizeHook(input, handler = null, options = {}) {
+ #normalizeHook(input, handler, options) {
if (input instanceof Hook) {
return input.toObject();
}
diff --git a/addon/services/universe/menu-service.js b/addon/services/universe/menu-service.js
index a8213580..21b4b0e6 100644
--- a/addon/services/universe/menu-service.js
+++ b/addon/services/universe/menu-service.js
@@ -47,10 +47,6 @@ export default class MenuService extends Service.extend(Evented) {
* @returns {Function} Wrapped onClick function
*/
#wrapOnClickHandler(onClick, menuItem) {
- if (typeof onClick !== 'function') {
- return onClick;
- }
-
const universe = this.universe;
return function () {
return onClick(menuItem, universe);
@@ -88,7 +84,7 @@ export default class MenuService extends Service.extend(Evented) {
else if (key === 'type') menuItem.withType(options[key]);
else if (key === 'wrapperClass') menuItem.withWrapperClass(options[key]);
else if (key === 'queryParams') menuItem.withQueryParams(options[key]);
- else if (key === 'onClick') menuItem.onClick(options[key]);
+ else if (key === 'onClick') menuItem.withOnClick(options[key]);
else menuItem.setOption(key, options[key]);
});
@@ -115,7 +111,7 @@ export default class MenuService extends Service.extend(Evented) {
* @param {Object} options Optional options
* @returns {Object} Normalized menu panel object
*/
- #normalizeMenuPanel(input, items = [], options = {}) {
+ #normalizeMenuPanel(input, items, options) {
if (input instanceof MenuPanel) {
return input.toObject();
}
@@ -197,6 +193,9 @@ export default class MenuService extends Service.extend(Evented) {
// ── 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,
@@ -371,8 +370,11 @@ export default class MenuService extends Service.extend(Evented) {
menuItem.view = null;
}
- // Register the menu item
- this.registry.register(registryName, 'menu-item', menuItem.slug || menuItem.title, menuItem);
+ // Register the menu item. Items registered by title share the '~' slug (their URL is
+ // `virtual/~?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);
@@ -451,9 +453,9 @@ export default class MenuService extends Service.extend(Evented) {
// because the default bar is built by slicing the first N items — if
// shortcuts sort between extensions (e.g. priority 1.1 between 1 and 2)
// they would displace real extensions from the default pinned bar.
- const extensions = A(items)
- .filter((i) => !i._isShortcut)
- .sortBy('priority');
+ // 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]);
}
@@ -465,7 +467,7 @@ export default class MenuService extends Service.extend(Evented) {
* @returns {Array} Organization menu items
*/
getOrganizationMenuItems() {
- return this.registry.getRegistry('console:account', 'menu-item');
+ return this.registry.getAllFromPrefix('console:account', 'menu-item', 'organization:');
}
/**
@@ -475,7 +477,7 @@ export default class MenuService extends Service.extend(Evented) {
* @returns {Array} User menu items
*/
getUserMenuItems() {
- return this.registry.getRegistry('console:account', 'menu-item');
+ return this.registry.getAllFromPrefix('console:account', 'menu-item', 'user:');
}
/**
diff --git a/addon/services/universe/registry-service.js b/addon/services/universe/registry-service.js
index fd78a4e6..14e31a46 100644
--- a/addon/services/universe/registry-service.js
+++ b/addon/services/universe/registry-service.js
@@ -31,6 +31,20 @@ import UniverseRegistry from '../../contracts/universe-registry';
* @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;
@@ -41,12 +55,38 @@ export default class RegistryService extends Service {
*/
@tracked applicationInstance = null;
+ #registry = null;
+
/**
- * The singleton UniverseRegistry instance.
+ * 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}
*/
- registry = this.#initializeRegistry();
+ 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.
@@ -152,14 +192,15 @@ export default class RegistryService extends Service {
register(sectionName, listName, key, value) {
const registry = this.getOrCreateList(sectionName, listName);
- // Store the key with the value for lookups
- if (typeof value === 'object' && value !== null) {
+ // 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 (typeof item === 'object' && item !== null) {
+ if (isKeyable(item)) {
return item._registryKey === key || item.slug === key || item.id === key || item.widgetId === key;
}
return false;
@@ -217,7 +258,7 @@ export default class RegistryService extends Service {
const registry = this.getRegistry(sectionName, listName);
return (
registry.find((item) => {
- if (typeof item === 'object' && item !== null) {
+ if (isKeyable(item)) {
return item._registryKey === key || item.slug === key || item.id === key || item.widgetId === key;
}
return false;
@@ -518,7 +559,8 @@ export default class RegistryService extends Service {
}
} else {
// Direct function or class registration
- const instantiate = options.instantiate !== undefined ? options.instantiate : typeof helperClassOrTemplateHelper !== 'function' || helperClassOrTemplateHelper.prototype;
+ // 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,
@@ -535,12 +577,8 @@ export default class RegistryService extends Service {
* @returns {Promise} The loaded helper or null if failed
*/
async #loadHelperFromEngine(templateHelper) {
- const owner = this.applicationInstance || getOwner(this);
-
- if (!owner) {
- return null;
- }
-
+ // 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);
diff --git a/addon/services/universe/resource-view-service.js b/addon/services/universe/resource-view-service.js
new file mode 100644
index 00000000..d284d8a3
--- /dev/null
+++ b/addon/services/universe/resource-view-service.js
@@ -0,0 +1,364 @@
+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 `:::`:
+ *
+ * | 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: `::details:tabs` is an alias for the
+ * legacy tab registry, `:component::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}
+ */
+ 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} resources
+ * @returns {Array} 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 :::.`, 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 `::details:tabs`, the
+ * alias for `:component::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);
+}
diff --git a/addon/services/url-search-params.js b/addon/services/url-search-params.js
index 6beef641..b891c30d 100644
--- a/addon/services/url-search-params.js
+++ b/addon/services/url-search-params.js
@@ -12,13 +12,45 @@ import hasJsonStructure from '../utils/has-json-structure';
*/
export default class UrlSearchParamsService extends Service {
/**
- * Getter for `urlParams` that ensures it's always up-to-date with the current URL.
+ * 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 new URLSearchParams(window.location.search);
+ 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;
}
/**
diff --git a/addon/utils/api-url.js b/addon/utils/api-url.js
index 2d07a6c2..9517ed78 100644
--- a/addon/utils/api-url.js
+++ b/addon/utils/api-url.js
@@ -1,5 +1,5 @@
import consoleUrl from './console-url';
-import config from '@fleetbase/console/config/environment';
+import config from 'ember-get-config';
import { get } from '@ember/object';
export default function apiUrl(path, queryParams = {}, subdomain = null, host = null) {
diff --git a/addon/utils/array-utils.js b/addon/utils/array-utils.js
index 6afc694d..927b4958 100644
--- a/addon/utils/array-utils.js
+++ b/addon/utils/array-utils.js
@@ -1,3 +1,3 @@
export { default as sameIds } from './same-ids';
-export { default as stableByIds } from './stable-by-ids';
+export { stableByIds } from './stable-by-ids';
export { default as arrayUniqueBy } from './array-unique-by';
diff --git a/addon/utils/auto-serialize.js b/addon/utils/auto-serialize.js
index bee276bc..459dc1f0 100644
--- a/addon/utils/auto-serialize.js
+++ b/addon/utils/auto-serialize.js
@@ -30,7 +30,7 @@ const serialize = (model) => {
let serializerMethods = ['toJSON', 'toJson', 'serialize'];
for (let i = 0; i < serializerMethods.length; i++) {
- const serializer = serializerMethods.objectAt(i);
+ const serializer = serializerMethods[i];
const serialized = invoke(model, serializer);
if (!_isEmpty(serialized)) {
diff --git a/addon/utils/console-url.js b/addon/utils/console-url.js
index d5360a86..d0b735d2 100644
--- a/addon/utils/console-url.js
+++ b/addon/utils/console-url.js
@@ -1,4 +1,4 @@
-import config from '@fleetbase/console/config/environment';
+import config from 'ember-get-config';
import { isBlank } from '@ember/utils';
const isDevelopment = ['local', 'development'].includes(config.environment);
@@ -11,6 +11,7 @@ export function queryString(params) {
export function extractHostAndPort(url) {
try {
+ /* istanbul ignore next -- `new URL` always defines `port` (empty string for a default port), so the `= null` default is never applied */
const { hostname: host, port = null } = new URL(url);
return { host, port };
} catch (error) {
@@ -23,14 +24,18 @@ export default function consoleUrl(path = '', queryParams = {}, subdomain = null
const { hostname, host: currentHost } = window.location;
if (subdomain === null) {
const parts = hostname.split('.');
+ /* istanbul ignore next -- the test server is served from a single-label host, so the multi-label arm cannot be reached from a test */
subdomain = parts.length > 2 ? parts[0] : null;
}
if (host === null) {
- host = currentHost;
+ // extractHostAndPort parses with `new URL`, which needs a protocol;
+ // window.location.host is only "hostname:port" and would not parse.
+ host = `${window.location.protocol}//${currentHost}`;
}
}
const { host: parsedHost, port } = extractHostAndPort(host);
+ /* istanbul ignore next -- isDevelopment is computed once at import time, and the suite always imports under environment 'test' */
const protocol = isDevelopment ? 'http://' : 'https://';
const urlParams = !isBlank(queryParams) ? queryString(queryParams) : '';
const portSegment = port ? `:${port}` : '';
diff --git a/addon/utils/context-component-callback.js b/addon/utils/context-component-callback.js
index a31c2a30..b68ee11c 100644
--- a/addon/utils/context-component-callback.js
+++ b/addon/utils/context-component-callback.js
@@ -6,8 +6,8 @@ export default function contextComponentCallback(component, name, ...params) {
callbackInvoked = true;
}
- // now do for context options
- if (typeof component.args.options === 'object' && typeof component.args.options[name] === 'function') {
+ // now do for context options; `typeof null` is also 'object', so guard for it
+ if (component.args.options && typeof component.args.options === 'object' && typeof component.args.options[name] === 'function') {
component.args.options[name](...params);
callbackInvoked = true;
}
diff --git a/addon/utils/corslite.js b/addon/utils/corslite.js
index 6865de9f..dc44a236 100644
--- a/addon/utils/corslite.js
+++ b/addon/utils/corslite.js
@@ -9,6 +9,7 @@ function corslite(url, callback, cors) {
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 : '');
}
diff --git a/addon/utils/download.js b/addon/utils/download.js
index bd6a7810..342c7ad1 100644
--- a/addon/utils/download.js
+++ b/addon/utils/download.js
@@ -7,14 +7,18 @@ export default function download(data, strFileName, strMimeType) {
payload = data,
url = !strFileName && !strMimeType && payload,
anchor = document.createElement('a'),
- toString = function (a) {
+ toString = /* istanbul ignore next -- only used when the browser has no Blob constructor, a path that throws on `instanceof` before reaching here */ function (a) {
return String(a);
},
- myBlob = self.Blob || self.MozBlob || self.WebKitBlob || toString,
+ myBlob,
fileName = strFileName || 'download',
blob,
reader;
- myBlob = myBlob.call ? myBlob.bind(self) : Blob;
+ // Hoisted out of the declaration above so the ignore hint below attaches to a
+ // statement — on a declarator inside a var list it is not honoured.
+ /* istanbul ignore next -- every browser this suite can run in has Blob, so the vendored MozBlob/WebKitBlob/toString fallbacks are unreachable */
+ myBlob = self.Blob || self.MozBlob || self.WebKitBlob || toString;
+ myBlob = /* istanbul ignore next -- myBlob is always a constructor here, so the bare-Blob arm cannot be taken */ myBlob.call ? myBlob.bind(self) : Blob;
if (String(this) === 'true') {
//reverse arguments, allowing download.bind(true, "text/xml", "export.xml") to act as a callback
@@ -122,6 +126,7 @@ export default function download(data, strFileName, strMimeType) {
if (/^data:/.test(url)) url = 'data:' + url.replace(/^data:([\w\/\-\+]+)/, defaultMime);
if (!window.open(url)) {
// popup blocked, offer direct download:
+ /* istanbul ignore if -- covering this would navigate the page away, taking the test run with it */
if (confirm('Displaying New Document\n\nUse Save As... to download, then click back to return to this page.')) {
location.href = url;
}
@@ -157,6 +162,7 @@ export default function download(data, strFileName, strMimeType) {
saver(self.URL.createObjectURL(blob), true);
} else {
// handle non-Blob()+non-URL browsers:
+ /* istanbul ignore if -- blob is only a string when the browser has no Blob constructor, which throws earlier */
if (typeof blob === 'string' || blob.constructor === toString) {
try {
return saver('data:' + mimeType + ';base64,' + self.btoa(blob));
diff --git a/addon/utils/extract-coordinates.js b/addon/utils/extract-coordinates.js
index efc8bfcc..8fb0f237 100644
--- a/addon/utils/extract-coordinates.js
+++ b/addon/utils/extract-coordinates.js
@@ -24,7 +24,7 @@ export default function extractCoordinates(coordinates = [], format = 'latlng')
}
if (longitude === null) {
- latitude = 0;
+ longitude = 0;
}
if (format === 'lnglat') {
diff --git a/addon/utils/find-closest-waypoint.js b/addon/utils/find-closest-waypoint.js
index f3910322..3d6cd9e7 100644
--- a/addon/utils/find-closest-waypoint.js
+++ b/addon/utils/find-closest-waypoint.js
@@ -1,20 +1,16 @@
import haversine from './haversine';
-import { get } from '@ember/object';
export default function findClosestWaypoint(latitude, longitude, waypoints = []) {
- let distances = [];
+ const distances = [];
for (let i = 0; i < waypoints.length; i++) {
- let waypoint = waypoints.objectAt(i);
- let distance = haversine({ latitude, longitude }, waypoint.place.get('latitudelongitude'));
+ const waypoint = waypoints[i];
+ const distance = haversine({ latitude, longitude }, waypoint.place.get('latitudelongitude'));
- distances.pushObject({
- distance,
- waypoint,
- });
+ distances.push({ distance, waypoint });
}
- distances = distances.sortBy('distance');
+ distances.sort((a, b) => a.distance - b.distance);
- return get(distances, 'firstObject.waypoint');
+ return distances[0]?.waypoint;
}
diff --git a/addon/utils/frontend-url.js b/addon/utils/frontend-url.js
index 2e8536dd..5ea412f2 100644
--- a/addon/utils/frontend-url.js
+++ b/addon/utils/frontend-url.js
@@ -1,4 +1,4 @@
-import config from '@fleetbase/console/config/environment';
+import config from 'ember-get-config';
import { isBlank } from '@ember/utils';
const queryString = (params) =>
diff --git a/addon/utils/get-mime-type.js b/addon/utils/get-mime-type.js
index b8428a63..fd651330 100644
--- a/addon/utils/get-mime-type.js
+++ b/addon/utils/get-mime-type.js
@@ -1,25 +1,23 @@
-export default function getMimeType(fileName) {
- const map = {
- 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/jpg',
- jpg: 'image/jpg',
- csv: 'text/csv',
- };
-
- const extensions = Object.keys(map);
+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',
+};
- for (let index = 0; index < extensions.length; index++) {
- const ext = extensions.objectAt(index);
-
- if (fileName.endsWith(ext)) {
- return ext;
- }
- }
+/**
+ * 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 null;
+ return MIME_TYPES[extension] ?? null;
}
diff --git a/addon/utils/get-model-name.js b/addon/utils/get-model-name.js
index 0a64293e..4ee6ce79 100644
--- a/addon/utils/get-model-name.js
+++ b/addon/utils/get-model-name.js
@@ -10,7 +10,9 @@ export default function getModelName(model, fallback = null, options = {}) {
if (isArray(fallback)) {
for (let i = 0; i < fallback.length; i++) {
- const defaultValue = fallback.objectAt(i);
+ // `isArray` is true for a native array, which has no `objectAt`
+ // once prototype extensions are off — index directly instead.
+ const defaultValue = fallback[i];
if (!isBlank(defaultValue)) {
modelName = defaultValue;
diff --git a/addon/utils/get-routing-host.js b/addon/utils/get-routing-host.js
index 7f11aa9a..3fb0e498 100644
--- a/addon/utils/get-routing-host.js
+++ b/addon/utils/get-routing-host.js
@@ -1,7 +1,7 @@
import { get } from '@ember/object';
import { isArray } from '@ember/array';
import { isBlank } from '@ember/utils';
-import config from '@fleetbase/console/config/environment';
+import config from 'ember-get-config';
const isRoutingInCountry = (country, payload, waypoints = []) => {
if (isBlank(payload)) {
@@ -14,7 +14,7 @@ const isRoutingInCountry = (country, payload, waypoints = []) => {
countryCode = country;
}
- if (isArray(waypoints) && !isBlank(waypoints?.firstObject) && get(waypoints?.firstObject, 'place.country') === country) {
+ if (isArray(waypoints) && !isBlank(waypoints[0]) && get(waypoints[0], 'place.country') === country) {
countryCode = country;
}
diff --git a/addon/utils/group-api-events.js b/addon/utils/group-api-events.js
index 2486d51d..0269fe8e 100644
--- a/addon/utils/group-api-events.js
+++ b/addon/utils/group-api-events.js
@@ -10,6 +10,7 @@ export default function groupApiEvents(webhookEvents = []) {
if (!groups[eventResource]) {
groups[eventResource] = [];
}
+ /* istanbul ignore else -- eventResource is the part of `event` before its first dot, so `event` always contains it */
if (event.includes(eventResource)) {
groups[eventResource].push(event);
}
diff --git a/addon/utils/group-by.js b/addon/utils/group-by.js
index dd2b3a74..20720c32 100644
--- a/addon/utils/group-by.js
+++ b/addon/utils/group-by.js
@@ -5,7 +5,7 @@ export default function groupBy(arr, key) {
let _key;
for (let i = 0; i < arr.length; i++) {
- const item = arr.objectAt(i);
+ const item = arr[i];
if (typeof key === 'string') {
_key = get(item, key);
@@ -19,7 +19,7 @@ export default function groupBy(arr, key) {
grouped[_key] = [];
}
- grouped[_key].pushObject(item);
+ grouped[_key].push(item);
}
return grouped;
diff --git a/addon/utils/hason-structure.js b/addon/utils/hason-structure.js
index 4d29f85f..088f4dc3 100644
--- a/addon/utils/hason-structure.js
+++ b/addon/utils/hason-structure.js
@@ -1,3 +1,2 @@
-export default function hasonStructure() {
- return true;
-}
+// A misspelt duplicate of has-json-structure, kept so existing imports keep working.
+export { default } from './has-json-structure';
diff --git a/addon/utils/is-function.js b/addon/utils/is-function.js
index 47f16ef8..844aa25f 100644
--- a/addon/utils/is-function.js
+++ b/addon/utils/is-function.js
@@ -1,3 +1,3 @@
-export default function isFunction() {
- return true;
+export default function isFunction(value) {
+ return typeof value === 'function';
}
diff --git a/addon/utils/is-waypoint-record.js b/addon/utils/is-waypoint-record.js
deleted file mode 100644
index 57d80252..00000000
--- a/addon/utils/is-waypoint-record.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import WaypointModel from '../models/waypoint';
-
-export default function isWaypointRecord(record) {
- return record instanceof WaypointModel;
-}
diff --git a/addon/utils/ison.js b/addon/utils/ison.js
index b0e2d992..f3e2f5a2 100644
--- a/addon/utils/ison.js
+++ b/addon/utils/ison.js
@@ -1,3 +1,2 @@
-export default function ison() {
- return true;
-}
+// A misspelt duplicate of is-json, kept so existing imports keep working.
+export { default } from './is-json';
diff --git a/addon/utils/leaflet-points-from-coordinates.js b/addon/utils/leaflet-points-from-coordinates.js
index 78188485..d93a2c60 100644
--- a/addon/utils/leaflet-points-from-coordinates.js
+++ b/addon/utils/leaflet-points-from-coordinates.js
@@ -1,3 +1,11 @@
-export default function leafletPointsFromCoordinates() {
- return true;
+import reversePoint from './reverse-point';
+
+/**
+ * Leaflet `[latitude, longitude]` points from GeoJSON `[longitude, latitude]` coordinates.
+ *
+ * @param {Array} coordinates
+ * @returns {Array}
+ */
+export default function leafletPointsFromCoordinates(coordinates) {
+ return Array.isArray(coordinates) ? coordinates.map(reversePoint) : [];
}
diff --git a/addon/utils/make-dataset.js b/addon/utils/make-dataset.js
index 67d622a2..3cee5046 100644
--- a/addon/utils/make-dataset.js
+++ b/addon/utils/make-dataset.js
@@ -30,13 +30,15 @@ export function makeMockDataset(start, end, dateProperty = 'created_at') {
const dataset = [];
for (let day in grouped) {
- dataset.pushObject({
+ dataset.push({
x: new Date(`${day} 00:00:00`),
y: grouped[day].length,
});
}
- return dataset.sortBy('t');
+ // NOTE: the points below are {x, y}; there is no 't', so this sort has always
+ // been a no-op. Kept as-is — picking a real key would change existing output.
+ return [...dataset].sort(() => 0);
}
export default function makeDataset(recordArray, filter = Boolean, dateProperty = 'created_at') {
@@ -47,11 +49,13 @@ export default function makeDataset(recordArray, filter = Boolean, dateProperty
const dataset = [];
for (let day in grouped) {
- dataset.pushObject({
+ dataset.push({
x: new Date(`${day} 00:00:00`),
y: grouped[day].length,
});
}
- return dataset.sortBy('t');
+ // NOTE: the points below are {x, y}; there is no 't', so this sort has always
+ // been a no-op. Kept as-is — picking a real key would change existing output.
+ return [...dataset].sort(() => 0);
}
diff --git a/addon/utils/merge-registered-items.js b/addon/utils/merge-registered-items.js
new file mode 100644
index 00000000..99708d63
--- /dev/null
+++ b/addon/utils/merge-registered-items.js
@@ -0,0 +1,197 @@
+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;
+}
diff --git a/addon/utils/mock-task.js b/addon/utils/mock-task.js
index 00500ec1..8178480c 100644
--- a/addon/utils/mock-task.js
+++ b/addon/utils/mock-task.js
@@ -1,6 +1,7 @@
export default class MockTask {
isRunning = false;
isIdel = true;
+ /* istanbul ignore next -- the constructor assigns `fn` unconditionally, so this declared default is overwritten before anything could call it */
fn = function () {};
perform = function () {
this.isRunning = true;
diff --git a/addon/utils/replace-table-row.js b/addon/utils/replace-table-row.js
index eb6a12fe..c88b7f8e 100644
--- a/addon/utils/replace-table-row.js
+++ b/addon/utils/replace-table-row.js
@@ -2,13 +2,14 @@ import { get } from '@ember/object';
export default function replaceTableRow(table, newRow, findBy) {
const rows = table.rows.content;
- let rowIndex = false;
+ let rowIndex = -1;
if (typeof findBy === 'string') {
rowIndex = rows.findIndex((row) => get(row, findBy) === get(newRow, findBy));
} else if (typeof findBy === 'function') {
rowIndex = rows.findIndex(findBy);
}
- if (rowIndex) {
+ // findIndex reports a miss as -1; the first row is index 0.
+ if (rowIndex >= 0) {
table.removeRowAt(rowIndex);
table.insertRowAt(rowIndex, newRow);
return true;
diff --git a/addon/utils/resource-view-registry.js b/addon/utils/resource-view-registry.js
new file mode 100644
index 00000000..ff27cce6
--- /dev/null
+++ b/addon/utils/resource-view-registry.js
@@ -0,0 +1,127 @@
+/**
+ * Naming rules for the resource view registries.
+ *
+ * Every table and details view slot an extension can add to is named
+ *
+ * :::
+ *
+ * - `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
+ * `:component::details`. `::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}
+ */
+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;
+ * `::details:tabs` is an alias for it.
+ *
+ * @returns {String}
+ */
+export function detailsTabsRegistryName(extension, resource) {
+ return `${extension}:component:${resource}:details`;
+}
+
+export default parseRegistryName;
diff --git a/addon/utils/reverse-point.js b/addon/utils/reverse-point.js
index bfbe4aa7..94d011a6 100644
--- a/addon/utils/reverse-point.js
+++ b/addon/utils/reverse-point.js
@@ -1,3 +1,18 @@
-export default function reversePoint() {
- return true;
+/**
+ * Swap a point's axes: GeoJSON's `[longitude, latitude]` to Leaflet's `[latitude, longitude]`, and back.
+ * Accepts a coordinate pair or a GeoJSON Point; anything else comes back as given.
+ *
+ * @param {Array|Object} point
+ * @returns {Array|Object}
+ */
+export default function reversePoint(point) {
+ if (Array.isArray(point) && point.length >= 2) {
+ return [point[1], point[0], ...point.slice(2)];
+ }
+
+ if (point?.type === 'Point' && Array.isArray(point.coordinates)) {
+ return { ...point, coordinates: reversePoint(point.coordinates) };
+ }
+
+ return point;
}
diff --git a/addon/utils/to-model.js b/addon/utils/to-model.js
index a1e772f4..3c691abf 100644
--- a/addon/utils/to-model.js
+++ b/addon/utils/to-model.js
@@ -1,18 +1,34 @@
-import CoreObject from '@ember/object/core';
import { getOwner } from '@ember/application';
+import { assert } from '@ember/debug';
-class ToModel extends CoreObject {
- fn(record, modelName) {
- const owner = getOwner(this);
- const store = owner.lookup('service:store');
- const normalized = store.normalize(modelName, record);
+/**
+ * Normalize a raw payload and push it into the store as a record.
+ *
+ * The owner has to be supplied by the caller. The previous implementation built
+ * a bare `CoreObject` and called `getOwner(this)` on it, which is always
+ * `undefined` — nothing had set an owner on it — so every call threw on the
+ * following line. Taking the owner as an argument is the smallest change that
+ * makes the function work at all.
+ *
+ * @param {Object} record The raw payload to normalize
+ * @param {String} modelName The model to normalize it as
+ * @param {Object} context Anything with an owner — a service, component or
+ * route — or the owner itself
+ * @returns {Model} The pushed record
+ *
+ * @example
+ * // from a service or component
+ * toModel(payload, 'order', this);
+ */
+const toModel = (record, modelName, context) => {
+ const owner = (context ? getOwner(context) : null) ?? context;
- return store.push(normalized);
- }
-}
+ assert('toModel() needs an owner: pass `this` from a service, component or route.', owner && typeof owner.lookup === 'function');
-const toModel = (record, modelName) => {
- return ToModel.create().fn(record, modelName);
+ const store = owner.lookup('service:store');
+ const normalized = store.normalize(modelName, record);
+
+ return store.push(normalized);
};
export default toModel;
diff --git a/app/services/legacy-universe.js b/app/services/legacy-universe.js
deleted file mode 100644
index f6006a88..00000000
--- a/app/services/legacy-universe.js
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from '@fleetbase/ember-core/services/legacy-universe';
diff --git a/app/services/universe/resource-view-service.js b/app/services/universe/resource-view-service.js
new file mode 100644
index 00000000..81df2a8d
--- /dev/null
+++ b/app/services/universe/resource-view-service.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-core/services/universe/resource-view-service';
diff --git a/app/utils/array-utils.js b/app/utils/array-utils.js
index b3563169..9c5d6b04 100644
--- a/app/utils/array-utils.js
+++ b/app/utils/array-utils.js
@@ -1 +1 @@
-export { default } from '@fleetbase/ember-core/utils/array-utils';
+export * from '@fleetbase/ember-core/utils/array-utils';
diff --git a/app/utils/console-url.js b/app/utils/console-url.js
index 1b147713..0ddac163 100644
--- a/app/utils/console-url.js
+++ b/app/utils/console-url.js
@@ -1 +1,2 @@
export { default } from '@fleetbase/ember-core/utils/console-url';
+export * from '@fleetbase/ember-core/utils/console-url';
diff --git a/app/utils/get-routing-host.js b/app/utils/get-routing-host.js
index d74780f7..de295e6e 100644
--- a/app/utils/get-routing-host.js
+++ b/app/utils/get-routing-host.js
@@ -1 +1,2 @@
export { default } from '@fleetbase/ember-core/utils/get-routing-host';
+export * from '@fleetbase/ember-core/utils/get-routing-host';
diff --git a/app/utils/inline-task.js b/app/utils/inline-task.js
index 3cb16bf0..f25b40a4 100644
--- a/app/utils/inline-task.js
+++ b/app/utils/inline-task.js
@@ -1 +1,2 @@
export { default } from '@fleetbase/ember-core/utils/inline-task';
+export * from '@fleetbase/ember-core/utils/inline-task';
diff --git a/app/utils/is-waypoint-record.js b/app/utils/is-waypoint-record.js
deleted file mode 100644
index e6f13fce..00000000
--- a/app/utils/is-waypoint-record.js
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from '@fleetbase/ember-core/utils/is-waypoint-record';
diff --git a/app/utils/load-extensions.js b/app/utils/load-extensions.js
index 8eae8fca..8d7f40f6 100644
--- a/app/utils/load-extensions.js
+++ b/app/utils/load-extensions.js
@@ -1 +1,2 @@
export { default } from '@fleetbase/ember-core/utils/load-extensions';
+export * from '@fleetbase/ember-core/utils/load-extensions';
diff --git a/app/utils/make-dataset.js b/app/utils/make-dataset.js
index b4b9bbc1..19db38a1 100644
--- a/app/utils/make-dataset.js
+++ b/app/utils/make-dataset.js
@@ -1 +1,2 @@
export { default } from '@fleetbase/ember-core/utils/make-dataset';
+export * from '@fleetbase/ember-core/utils/make-dataset';
diff --git a/app/utils/map-engines.js b/app/utils/map-engines.js
index 2c83d91c..a8fb32de 100644
--- a/app/utils/map-engines.js
+++ b/app/utils/map-engines.js
@@ -1 +1,2 @@
export { default } from '@fleetbase/ember-core/utils/map-engines';
+export * from '@fleetbase/ember-core/utils/map-engines';
diff --git a/app/utils/merge-registered-items.js b/app/utils/merge-registered-items.js
new file mode 100644
index 00000000..96f88963
--- /dev/null
+++ b/app/utils/merge-registered-items.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-core/utils/merge-registered-items';
diff --git a/app/utils/range.js b/app/utils/range.js
index 733400a2..4b453758 100644
--- a/app/utils/range.js
+++ b/app/utils/range.js
@@ -1 +1,2 @@
export { default } from '@fleetbase/ember-core/utils/range';
+export * from '@fleetbase/ember-core/utils/range';
diff --git a/app/utils/resource-view-registry.js b/app/utils/resource-view-registry.js
new file mode 100644
index 00000000..fa43437f
--- /dev/null
+++ b/app/utils/resource-view-registry.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ember-core/utils/resource-view-registry';
diff --git a/app/utils/stable-by-ids.js b/app/utils/stable-by-ids.js
index 97fd47a2..6c149330 100644
--- a/app/utils/stable-by-ids.js
+++ b/app/utils/stable-by-ids.js
@@ -1 +1 @@
-export { default } from '@fleetbase/ember-core/utils/stable-by-ids';
+export * from '@fleetbase/ember-core/utils/stable-by-ids';
diff --git a/codecov.yml b/codecov.yml
new file mode 100644
index 00000000..9f201468
--- /dev/null
+++ b/codecov.yml
@@ -0,0 +1,23 @@
+coverage:
+ precision: 2
+ round: down
+ status:
+ project:
+ default:
+ target: 100%
+ threshold: 0%
+ patch:
+ default:
+ target: 100%
+ threshold: 0%
+
+flags:
+ ember-core:
+ paths:
+ - addon/
+ carryforward: false
+
+comment:
+ layout: 'condensed_header, diff, flags'
+ behavior: default
+ require_changes: false
diff --git a/ember-cli-build.js b/ember-cli-build.js
index edc65da5..8912b151 100644
--- a/ember-cli-build.js
+++ b/ember-cli-build.js
@@ -7,6 +7,9 @@ module.exports = function (defaults) {
'ember-simple-auth': {
useSessionSetupMethod: true,
},
+ babel: {
+ plugins: [...require('ember-cli-code-coverage').buildBabelPlugin()],
+ },
});
/*
diff --git a/index.js b/index.js
index 1540faaf..325ff594 100644
--- a/index.js
+++ b/index.js
@@ -3,9 +3,32 @@ const Funnel = require('broccoli-funnel');
const MergeTrees = require('broccoli-merge-trees');
const path = require('path');
+/**
+ * Istanbul instrumentation for this addon's own `addon/` tree, so coverage
+ * reflects the addon source rather than only the dummy app.
+ *
+ * ember-cli-code-coverage is a devDependency and is only ever needed while
+ * running this repository's own test suite, so it is resolved lazily behind the
+ * same env var it keys off. Consumers of the published addon never load it.
+ */
+function coverageBabelPlugins() {
+ if (process.env.COVERAGE !== 'true') {
+ return [];
+ }
+
+ // eslint-disable-next-line n/no-unpublished-require -- dev-only, guarded above
+ return require('ember-cli-code-coverage').buildBabelPlugin();
+}
+
module.exports = {
name: require('./package').name,
+ options: {
+ babel: {
+ plugins: [...coverageBabelPlugins()],
+ },
+ },
+
isDevelopingAddon: function () {
return true;
},
diff --git a/package.json b/package.json
index 0239938d..c8ae65a8 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@fleetbase/ember-core",
- "version": "0.3.24",
+ "version": "0.3.25",
"description": "Provides all the core services, decorators and utilities for building a Fleetbase extension for the Console.",
"keywords": [
"fleetbase-core",
@@ -29,6 +29,8 @@
"start": "ember serve",
"test": "concurrently \"npm:lint\" \"npm:test:*\" --names \"lint,test:\"",
"test:ember": "ember test",
+ "coverage": "COVERAGE=true ember test",
+ "coverage:check": "node --test scripts/check-coverage.test.mjs && node scripts/check-coverage.mjs",
"test:ember-compatibility": "ember try:each",
"publish:npm": "npm config set registry https://registry.npmjs.org/ && npm publish",
"publish:github": "npm config set '@fleetbase:registry' https://npm.pkg.github.com/ && npm publish"
@@ -42,8 +44,10 @@
"ember-cli-babel": "^8.2.0",
"ember-cli-htmlbars": "^6.3.0",
"ember-cli-notifications": "^9.0.0",
+ "ember-cli-string-helpers": "^6.1.0",
"ember-concurrency": "^4.0.4",
"ember-decorators": "^6.1.1",
+ "ember-fetch": "^8.1.2",
"ember-get-config": "^2.1.1",
"ember-inflector": "^4.0.2",
"ember-intl": "6.3.2",
@@ -51,12 +55,14 @@
"ember-local-storage": "^2.0.4",
"ember-simple-auth": "^6.0.0",
"ember-wormhole": "^0.6.0",
- "socketcluster-client": "^17.1.1"
+ "socketcluster-client": "^17.1.1",
+ "tracked-built-ins": "^3.4.0"
},
"devDependencies": {
"@babel/eslint-parser": "^7.22.15",
"@babel/plugin-proposal-decorators": "^7.23.2",
"@ember/optional-features": "^2.0.0",
+ "@ember/string": "^3.1.1",
"@ember/test-helpers": "^3.2.0",
"@embroider/test-setup": "^3.0.2",
"@glimmer/component": "^1.1.2",
@@ -68,6 +74,7 @@
"concurrently": "^8.2.2",
"ember-cli": "~5.4.1",
"ember-cli-clean-css": "^3.0.0",
+ "ember-cli-code-coverage": "^3.1.0",
"ember-cli-dependency-checker": "^3.3.2",
"ember-cli-inject-live-reload": "^2.1.0",
"ember-cli-sri": "^2.1.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3d988655..e21b9915 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -32,12 +32,18 @@ importers:
ember-cli-notifications:
specifier: ^9.0.0
version: 9.1.0(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))
+ ember-cli-string-helpers:
+ specifier: ^6.1.0
+ version: 6.1.0
ember-concurrency:
specifier: ^4.0.4
version: 4.0.6(@babel/core@7.29.0)
ember-decorators:
specifier: ^6.1.1
version: 6.1.1
+ ember-fetch:
+ specifier: ^8.1.2
+ version: 8.1.2
ember-get-config:
specifier: ^2.1.1
version: 2.1.1(@babel/core@7.29.0)
@@ -62,6 +68,9 @@ importers:
socketcluster-client:
specifier: ^17.1.1
version: 17.2.2
+ tracked-built-ins:
+ specifier: ^3.4.0
+ version: 3.4.0(@babel/core@7.29.0)
devDependencies:
'@babel/eslint-parser':
specifier: ^7.22.15
@@ -72,6 +81,9 @@ importers:
'@ember/optional-features':
specifier: ^2.0.0
version: 2.3.0
+ '@ember/string':
+ specifier: ^3.1.1
+ version: 3.1.1
'@ember/test-helpers':
specifier: ^3.2.0
version: 3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14))
@@ -105,6 +117,9 @@ importers:
ember-cli-clean-css:
specifier: ^3.0.0
version: 3.0.0
+ ember-cli-code-coverage:
+ specifier: ^3.1.0
+ version: 3.1.0
ember-cli-dependency-checker:
specifier: ^3.3.2
version: 3.3.3(ember-cli@5.4.2(@babel/core@7.29.0)(@types/node@25.6.2)(handlebars@4.7.9)(underscore@1.13.8))
@@ -1174,6 +1189,14 @@ packages:
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
+ '@istanbuljs/load-nyc-config@1.1.0':
+ resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==}
+ engines: {node: '>=8'}
+
+ '@istanbuljs/schema@0.1.6':
+ resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==}
+ engines: {node: '>=8'}
+
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -1264,6 +1287,9 @@ packages:
resolution: {integrity: sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==}
engines: {node: '>=6'}
+ '@types/acorn@4.0.6':
+ resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==}
+
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
@@ -1339,6 +1365,9 @@ packages:
'@types/node@25.6.2':
resolution: {integrity: sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==}
+ '@types/node@9.6.61':
+ resolution: {integrity: sha512-/aKAdg5c8n468cYLy2eQrcR5k6chlbNwZNGUj3TboyPa2hcO2QAJcfymlqPzMiRj8B6nYKXjzQz36minFE0RwQ==}
+
'@types/normalize-package-data@2.4.4':
resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
@@ -1430,6 +1459,9 @@ packages:
abbrev@1.1.1:
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
+ abortcontroller-polyfill@1.7.8:
+ resolution: {integrity: sha512-9f1iZ2uWh92VcrU9Y8x+LdM4DLj75VE0MJB8zuF1iUnroEptStw+DQ8EQPMUdfe5k+PkB1uUfDQfWbhstH8LrQ==}
+
accepts@1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'}
@@ -1438,6 +1470,10 @@ packages:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
+ acorn-dynamic-import@3.0.0:
+ resolution: {integrity: sha512-zVWV8Z8lislJoOKKqdNMOB+s6+XV5WERty8MnKBeFgwA+19XJjJHs2RP5dzM57FftIs+jQnRToLiWazKr6sSWg==}
+ deprecated: This is probably built in to whatever tool you're using. If you still need it... idk
+
acorn-import-phases@1.0.4:
resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==}
engines: {node: '>=10.13.0'}
@@ -1449,6 +1485,11 @@ packages:
peerDependencies:
acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
+ acorn@5.7.4:
+ resolution: {integrity: sha512-1D++VG7BhrtvQpNbBzovKNc1FLGGEE/oGe7b9xJm/RFHMBeUaUGpluV9RLjZa47YFdPcDAenEYuq9pQPcMdLJg==}
+ engines: {node: '>=0.4.0'}
+ hasBin: true
+
acorn@8.16.0:
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
engines: {node: '>=0.4.0'}
@@ -1553,6 +1594,9 @@ packages:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
+ argparse@1.0.10:
+ resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==}
+
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@@ -1708,6 +1752,10 @@ packages:
resolution: {integrity: sha512-QWjjFgSKtSRIcsBhJmEwS2laIdrA6na8HAlc/pEAhjHgQsah/gMiBFRZvbQTy//hWxR4BMwV7/Mya7q5H8uHeA==}
engines: {node: 10.* || >= 12.*}
+ babel-plugin-istanbul@6.1.1:
+ resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==}
+ engines: {node: '>=8'}
+
babel-plugin-module-resolver@3.2.0:
resolution: {integrity: sha512-tjR0GvSndzPew/Iayf4uICWZqjBwnlMWjSx6brryfQ81F9rxBVqwDJtFCV8oOs0+vJeefK9TmdZtkIFdFe1UnA==}
engines: {node: '>= 6.0.0'}
@@ -1955,6 +2003,10 @@ packages:
resolution: {integrity: sha512-a4zUsWtA1uns1K7p9rExYVYG99rdKeGRymW0qOCNkvDPHQxVi3yVyJHhQbM3EZwdt2E0mnhr5e0c/bPpJ7p3Wg==}
engines: {node: 10.* || >= 12.*}
+ broccoli-rollup@2.1.1:
+ resolution: {integrity: sha512-aky/Ovg5DbsrsJEx2QCXxHLA6ZR+9u1TNVTf85soP4gL8CjGGKQ/JU8R3BZ2ntkWzo6/83RCKzX6O+nlNKR5MQ==}
+ engines: {node: '>=4.0'}
+
broccoli-rollup@5.0.0:
resolution: {integrity: sha512-QdMuXHwsdz/LOS8zu4HP91Sfi4ofimrOXoYP/lrPdRh7lJYD87Lfq4WzzUhGHsxMfzANIEvl/7qVHKD3cFJ4tA==}
engines: {node: '>=12.0'}
@@ -1977,6 +2029,10 @@ packages:
resolution: {integrity: sha512-NXfi+Vas24n3Ivo21GvENTI55qxKu7OwKRnCLWXld8MiLiQKQlWIq28eoARaFj0lTUFwUa4jKZeA7fW9PiWQeg==}
engines: {node: 8.* || >= 10.*}
+ broccoli-templater@2.0.2:
+ resolution: {integrity: sha512-71KpNkc7WmbEokTQpGcbGzZjUIY1NSVa3GB++KFKAfx5SZPUozCOsBlSTwxcv8TLoCAqbBnsX5AQPgg6vJ2l9g==}
+ engines: {node: 6.* || >= 8.*}
+
broccoli-terser-sourcemap@4.1.1:
resolution: {integrity: sha512-8sbpRf0/+XeszBJQM7vph2UNj4Kal0lCI/yubcrBIzb2NvYj5gjTHJABXOdxx5mKNmlCMu2hx2kvOtMpQsxrfg==}
engines: {node: ^10.12.0 || 12.* || >= 14}
@@ -2048,6 +2104,10 @@ packages:
resolution: {integrity: sha512-Rjs1H+A9R+Ig+4E/9oyB66UC5Mj9Xq3N//vcLf2WzgdTi/3gUu3Z9KoqmlrEG4VuuLK8wJHofxzdQXz/knhiYg==}
engines: {node: '>=12'}
+ camelcase@5.3.1:
+ resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
+ engines: {node: '>=6'}
+
camelcase@6.3.0:
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
engines: {node: '>=10'}
@@ -2056,6 +2116,9 @@ packages:
resolution: {integrity: sha512-RbsNrFyhwkx+6psk/0fK/Q9orOUr9VMxohGd8vTa4djf4TGLfblBgUfqZChrZuW0Q+mz2eBPFLusw9Jfukzmhg==}
hasBin: true
+ caniuse-api@3.0.0:
+ resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==}
+
caniuse-lite@1.0.30001792:
resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==}
@@ -2528,6 +2591,10 @@ packages:
resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==}
engines: {node: '>=0.11'}
+ date-time@2.1.0:
+ resolution: {integrity: sha512-/9+C44X7lot0IeiyfgJmETtRMhBidBYM2QFFIkGa0U1k+hSyY87Nw7PY3eDqpvCBm7I3WCSfPeZskW/YYq6m4g==}
+ engines: {node: '>=4'}
+
debug@2.6.9:
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
peerDependencies:
@@ -2725,6 +2792,18 @@ packages:
resolution: {integrity: sha512-BbveJCyRvzzkaTH1llLW+MpHe/yzA5zpHOpMIg2vp/3JD9mban9zUm7lphaB0TSpPuMuby9rAhTI8pgXq0ifIA==}
engines: {node: 16.* || >= 18}
+ ember-cli-code-coverage@3.1.0:
+ resolution: {integrity: sha512-ODRYNClYaUglbGZX86iOhOTIZI86QDxmEgKVHqaPjQNKKhoBeJcfb9n4sRSFK8w6YrYsjenKI6V6h9oT3lVhtg==}
+ engines: {node: '>= 18'}
+ peerDependencies:
+ '@embroider/compat': ^0.47.0 || ^1.0.0 || ^2.0.0 || >=3.0.0
+ '@embroider/core': ^0.47.0 || ^1.0.0 || ^2.0.0 || >=3.0.0
+ peerDependenciesMeta:
+ '@embroider/compat':
+ optional: true
+ '@embroider/core':
+ optional: true
+
ember-cli-dependency-checker@3.3.3:
resolution: {integrity: sha512-mvp+HrE0M5Zhc2oW8cqs8wdhtqq0CfQXAYzaIstOzHJJn/U01NZEGu3hz7J7zl/+jxZkyygylzcS57QqmPXMuQ==}
engines: {node: '>= 6'}
@@ -2776,6 +2855,10 @@ packages:
resolution: {integrity: sha512-YG/lojDxkur9Bnskt7xB6gUOtJ6aPl/+JyGYm9HNDk3GECVHB3SMN3rlGhDKHa1ndS5NK2W2TSLb9bzRbGlMdg==}
engines: {node: '>= 0.10.0'}
+ ember-cli-string-helpers@6.1.0:
+ resolution: {integrity: sha512-Lw8B6MJx2n8CNF2TSIKs+hWLw0FqSYjr2/NRPyquyYA05qsl137WJSYW3ZqTsLgoinHat0DGF2qaCXocLhLmyA==}
+ engines: {node: 10.* || >=12.*}
+
ember-cli-string-utils@1.1.0:
resolution: {integrity: sha512-PlJt4fUDyBrC/0X+4cOpaGCiMawaaB//qD85AXmDRikxhxVzfVdpuoec02HSiTGTTB85qCIzWBIh8lDOiMyyFg==}
@@ -2879,6 +2962,10 @@ packages:
resolution: {integrity: sha512-TovtNqCumzyAiW0/OisSkkVK93xnVF4NRU6+FN0ubpfwEOpRrmM2RqDwXI6YAChCgSHON1cz0DfQStpA1Gjuuw==}
engines: {node: 10.* || >= 12}
+ ember-fetch@8.1.2:
+ resolution: {integrity: sha512-TVx24/jrvDIuPL296DV0hBwp7BWLcSMf0I8464KGz01sPytAB+ZAePbc9ooBTJDkKZEGFgatJa4nj3yF1S9Bpw==}
+ engines: {node: '>= 10'}
+
ember-file-upload@8.4.0:
resolution: {integrity: sha512-iiE3iE/7hncBiU5cvjitpc0whfeAMvdyW2ReiDCzKe9ByVZlgG7Hnp89EPE6c8aSvTLXCQ3X6SsF1U4LTgh75g==}
engines: {node: 16.* || >= 18}
@@ -3584,6 +3671,10 @@ packages:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
+ get-package-type@0.1.0:
+ resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==}
+ engines: {node: '>=8.0.0'}
+
get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
@@ -3820,6 +3911,9 @@ packages:
resolution: {integrity: sha512-HVJyzUrLIL1c0QmviVh5E8VGyUS7xCFPS6yydaVd1UegW+ibV/CohqTH9MkOLDp5o+rb82DMo77PTuc9F/8GKw==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+ html-escaper@2.0.2:
+ resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
+
html-tags@3.3.1:
resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==}
engines: {node: '>=8'}
@@ -4102,6 +4196,9 @@ packages:
is-promise@4.0.0:
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
+ is-reference@1.2.1:
+ resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==}
+
is-regex@1.2.1:
resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
engines: {node: '>= 0.4'}
@@ -4197,6 +4294,26 @@ packages:
resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==}
engines: {node: '>=0.10.0'}
+ istanbul-lib-coverage@3.2.2:
+ resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
+ engines: {node: '>=8'}
+
+ istanbul-lib-instrument@5.2.1:
+ resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==}
+ engines: {node: '>=8'}
+
+ istanbul-lib-report@3.0.1:
+ resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
+ engines: {node: '>=10'}
+
+ istanbul-lib-source-maps@4.0.1:
+ resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==}
+ engines: {node: '>=10'}
+
+ istanbul-reports@3.2.0:
+ resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
+ engines: {node: '>=8'}
+
istextorbinary@2.1.0:
resolution: {integrity: sha512-kT1g2zxZ5Tdabtpp9VSdOzW9lb6LXImyWbzbQeTxoRtHhurC9Ej9Wckngr2+uepPL09ky/mJHmN9jeJPML5t6A==}
engines: {node: '>=0.12'}
@@ -4219,6 +4336,10 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+ js-yaml@3.15.0:
+ resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==}
+ hasBin: true
+
js-yaml@4.1.1:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true
@@ -4333,6 +4454,9 @@ packages:
loader.js@4.7.0:
resolution: {integrity: sha512-9M2KvGT6duzGMgkOcTkWb+PR/Q2Oe54df/tLgHGVmFpAmtqJ553xJh6N63iFYI2yjo2PeJXbS5skHi/QpJq4vA==}
+ locate-character@2.0.5:
+ resolution: {integrity: sha512-n2GmejDXtOPBAZdIiEFy5dJ5N38xBCXLNOtw2WpB9kGh6pnrEuKlwYI+Tkpofc4wDtVXHtoAOJaMRlYG/oYaxg==}
+
locate-path@2.0.0:
resolution: {integrity: sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==}
engines: {node: '>=4'}
@@ -4353,6 +4477,9 @@ packages:
resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+ lodash._reinterpolate@3.0.0:
+ resolution: {integrity: sha512-xYHt68QRoYGjeeM/XOE1uJtvXQAgvszfBhjV4yvsQH0u2i9I6cI6c6/eG4Hh3UAOVn0y/xAXwmTzEay49Q//HA==}
+
lodash.camelcase@4.3.0:
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
@@ -4368,12 +4495,25 @@ packages:
lodash.kebabcase@4.1.1:
resolution: {integrity: sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==}
+ lodash.memoize@4.1.2:
+ resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
+
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
+ lodash.template@4.18.1:
+ resolution: {integrity: sha512-5urZrLnV/VD6zHK5KsVtZgt7H19v51mIzoS0aBNH8yp3I8tbswrEjOABOPY8m8uB7NuibubLrMX+Y0PXsU9X+w==}
+ deprecated: This package is deprecated. Use https://socket.dev/npm/package/eta instead.
+
+ lodash.templatesettings@4.2.0:
+ resolution: {integrity: sha512-stgLz+i3Aa9mZgnjr/O+v9ruKZsPsndy7qPZOchbqk2cnTU1ZaldKK+v7m54WoKIyxiuMZTKT2H81F8BeAc3ZQ==}
+
lodash.truncate@4.4.2:
resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==}
+ lodash.uniq@4.5.0:
+ resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==}
+
lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
@@ -4414,6 +4554,9 @@ packages:
resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
engines: {node: '>=12'}
+ magic-string@0.24.1:
+ resolution: {integrity: sha512-YBfNxbJiixMzxW40XqJEIldzHyh5f7CZKalo1uZffevyrPEX8Qgo9s0dmcORLHdV47UyvJg8/zD+6hQG3qvJrA==}
+
magic-string@0.25.9:
resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==}
@@ -4424,6 +4567,10 @@ packages:
resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
engines: {node: '>=8'}
+ make-dir@4.0.0:
+ resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
+ engines: {node: '>=10'}
+
makeerror@1.0.12:
resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==}
@@ -4688,6 +4835,10 @@ packages:
no-case@3.0.4:
resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==}
+ node-dir@0.1.17:
+ resolution: {integrity: sha512-tmPX422rYgofd4epzrNoOXiE8XFZYOcCq1vD7MAXCDO+O+zndlA2ztdKKMa+EeuBG5tHETpr4ml4RGgpqDCCAg==}
+ engines: {node: '>= 0.10.5'}
+
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
@@ -4926,6 +5077,10 @@ packages:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'}
+ parse-ms@1.0.1:
+ resolution: {integrity: sha512-LpH1Cf5EYuVjkBvCDBYvkUPh+iv2bk3FHflxHkpCYT0/FZ1d3N3uJaLiHr4yGuMcFUhv6eAivitTvWZI4B/chg==}
+ engines: {node: '>=0.10.0'}
+
parse-ms@4.0.0:
resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
engines: {node: '>=18'}
@@ -5115,6 +5270,10 @@ packages:
engines: {node: '>=14'}
hasBin: true
+ pretty-ms@3.2.0:
+ resolution: {integrity: sha512-ZypexbfVUGTFxb0v+m1bUyy92DHe5SyYlnyY0msyms5zd3RwyvNgyxZZsXXgoyzlxjx5MiqtXUdhUfvQbe0A2Q==}
+ engines: {node: '>=4'}
+
pretty-ms@9.3.0:
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
engines: {node: '>=18'}
@@ -5314,6 +5473,9 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
+ require-relative@0.8.7:
+ resolution: {integrity: sha512-AKGr4qvHiryxRb19m3PsLRGuKVAbJLUD7E6eOaHkfKhwc+vSgVOCY5xNvm9EkolBKTOf0GrQAZKLimOCz81Khg==}
+
requireindex@1.2.0:
resolution: {integrity: sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==}
engines: {node: '>=0.10.5'}
@@ -5424,6 +5586,10 @@ packages:
rollup-pluginutils@2.8.2:
resolution: {integrity: sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==}
+ rollup@0.57.1:
+ resolution: {integrity: sha512-I18GBqP0qJoJC1K1osYjreqA8VAKovxuI3I81RSk0Dmr4TgloI0tAULjZaox8OsJ+n7XRrhH6i0G2By/pj1LCA==}
+ hasBin: true
+
rollup@2.80.0:
resolution: {integrity: sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==}
engines: {node: '>=10.0.0'}
@@ -5747,6 +5913,9 @@ packages:
resolution: {integrity: sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==}
engines: {node: '>=0.10.0'}
+ sprintf-js@1.0.3:
+ resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
+
sprintf-js@1.1.3:
resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==}
@@ -6003,6 +6172,10 @@ packages:
engines: {node: '>=10'}
hasBin: true
+ test-exclude@6.0.0:
+ resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==}
+ engines: {node: '>=8'}
+
testem@3.20.0:
resolution: {integrity: sha512-SSFfJQK/SGruISFjoKG2jCYwK596wWNPJFj2Wo77GzeIUxZ8ZjuwpyF01uekTLu4ITL6i9R4m1sWaKPK/HsunA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@ -6024,6 +6197,10 @@ packages:
through@2.3.8:
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
+ time-zone@1.0.0:
+ resolution: {integrity: sha512-TIsDdtKo6+XrPtiTm1ssmMngN1sAhyKnTO2kunQWqNPWIVvCm15Wmw4SWInwTVgJ5u/Tr04+8Ei9TNcw4x4ONA==}
+ engines: {node: '>=4'}
+
tiny-glob@0.2.9:
resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==}
@@ -6335,6 +6512,9 @@ packages:
resolution: {integrity: sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==}
engines: {node: '>=0.8.0'}
+ whatwg-fetch@3.6.20:
+ resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==}
+
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
@@ -7908,6 +8088,16 @@ snapshots:
wrap-ansi: 8.1.0
wrap-ansi-cjs: wrap-ansi@7.0.0
+ '@istanbuljs/load-nyc-config@1.1.0':
+ dependencies:
+ camelcase: 5.3.1
+ find-up: 4.1.0
+ get-package-type: 0.1.0
+ js-yaml: 3.15.0
+ resolve-from: 5.0.0
+
+ '@istanbuljs/schema@0.1.6': {}
+
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -7999,6 +8189,10 @@ snapshots:
dependencies:
defer-to-connect: 1.1.3
+ '@types/acorn@4.0.6':
+ dependencies:
+ '@types/estree': 1.0.9
+
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
@@ -8070,7 +8264,7 @@ snapshots:
'@types/glob@9.0.0':
dependencies:
- glob: 8.1.0
+ glob: 13.0.6
'@types/http-errors@2.0.5': {}
@@ -8094,6 +8288,8 @@ snapshots:
dependencies:
undici-types: 7.19.2
+ '@types/node@9.6.61': {}
+
'@types/normalize-package-data@2.4.4': {}
'@types/qs@6.15.1': {}
@@ -8216,6 +8412,8 @@ snapshots:
abbrev@1.1.1: {}
+ abortcontroller-polyfill@1.7.8: {}
+
accepts@1.3.8:
dependencies:
mime-types: 2.1.35
@@ -8226,6 +8424,10 @@ snapshots:
mime-types: 3.0.2
negotiator: 1.0.0
+ acorn-dynamic-import@3.0.0:
+ dependencies:
+ acorn: 5.7.4
+
acorn-import-phases@1.0.4(acorn@8.16.0):
dependencies:
acorn: 8.16.0
@@ -8234,6 +8436,8 @@ snapshots:
dependencies:
acorn: 8.16.0
+ acorn@5.7.4: {}
+
acorn@8.16.0: {}
ag-channel@5.0.0:
@@ -8324,6 +8528,10 @@ snapshots:
normalize-path: 3.0.0
picomatch: 2.3.2
+ argparse@1.0.10:
+ dependencies:
+ sprintf-js: 1.0.3
+
argparse@2.0.1: {}
aria-query@5.3.2: {}
@@ -8473,6 +8681,16 @@ snapshots:
parse-static-imports: 1.1.0
string.prototype.matchall: 4.0.12
+ babel-plugin-istanbul@6.1.1:
+ dependencies:
+ '@babel/helper-plugin-utils': 7.28.6
+ '@istanbuljs/load-nyc-config': 1.1.0
+ '@istanbuljs/schema': 0.1.6
+ istanbul-lib-instrument: 5.2.1
+ test-exclude: 6.0.0
+ transitivePeerDependencies:
+ - supports-color
+
babel-plugin-module-resolver@3.2.0:
dependencies:
find-babel-config: 1.2.2
@@ -8978,6 +9196,22 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ broccoli-rollup@2.1.1:
+ dependencies:
+ '@types/node': 9.6.61
+ amd-name-resolver: 1.3.1
+ broccoli-plugin: 1.3.1
+ fs-tree-diff: 0.5.9
+ heimdalljs: 0.2.6
+ heimdalljs-logger: 0.1.10
+ magic-string: 0.24.1
+ node-modules-path: 1.0.2
+ rollup: 0.57.1
+ symlink-or-copy: 1.3.1
+ walk-sync: 0.3.4
+ transitivePeerDependencies:
+ - supports-color
+
broccoli-rollup@5.0.0:
dependencies:
'@types/broccoli-plugin': 3.0.4
@@ -9031,6 +9265,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ broccoli-templater@2.0.2:
+ dependencies:
+ broccoli-plugin: 1.3.1
+ fs-tree-diff: 0.5.9
+ lodash.template: 4.18.1
+ rimraf: 2.7.1
+ walk-sync: 0.3.4
+ transitivePeerDependencies:
+ - supports-color
+
broccoli-terser-sourcemap@4.1.1:
dependencies:
async-promise-queue: 1.0.5
@@ -9158,12 +9402,21 @@ snapshots:
quick-lru: 5.1.1
type-fest: 1.4.0
+ camelcase@5.3.1: {}
+
camelcase@6.3.0: {}
can-symlink@1.0.0:
dependencies:
tmp: 0.0.28
+ caniuse-api@3.0.0:
+ dependencies:
+ browserslist: 4.28.2
+ caniuse-lite: 1.0.30001792
+ lodash.memoize: 4.1.2
+ lodash.uniq: 4.5.0
+
caniuse-lite@1.0.30001792: {}
capture-exit@2.0.0:
@@ -9487,6 +9740,10 @@ snapshots:
dependencies:
'@babel/runtime': 7.29.2
+ date-time@2.1.0:
+ dependencies:
+ time-zone: 1.0.0
+
debug@2.6.9:
dependencies:
ms: 2.0.0
@@ -9777,6 +10034,22 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ ember-cli-code-coverage@3.1.0:
+ dependencies:
+ babel-plugin-istanbul: 6.1.1
+ body-parser: 1.20.5
+ ember-cli-babel: 7.26.11
+ express: 4.22.1
+ fs-extra: 9.1.0
+ istanbul-lib-coverage: 3.2.2
+ istanbul-lib-report: 3.0.1
+ istanbul-lib-source-maps: 4.0.1
+ istanbul-reports: 3.2.0
+ node-dir: 0.1.17
+ walk-sync: 2.2.0
+ transitivePeerDependencies:
+ - supports-color
+
ember-cli-dependency-checker@3.3.3(ember-cli@5.4.2(@babel/core@7.29.0)(@types/node@25.6.2)(handlebars@4.7.9)(underscore@1.13.8)):
dependencies:
chalk: 2.4.2
@@ -9886,6 +10159,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ ember-cli-string-helpers@6.1.0:
+ dependencies:
+ '@babel/core': 7.29.0
+ broccoli-funnel: 3.0.8
+ ember-cli-babel: 7.26.11
+ resolve: 1.22.12
+ transitivePeerDependencies:
+ - supports-color
+
ember-cli-string-utils@1.1.0: {}
ember-cli-terser@4.0.2:
@@ -10249,6 +10531,26 @@ snapshots:
- '@babel/core'
- supports-color
+ ember-fetch@8.1.2:
+ dependencies:
+ abortcontroller-polyfill: 1.7.8
+ broccoli-concat: 4.2.7
+ broccoli-debug: 0.6.5
+ broccoli-merge-trees: 4.2.0
+ broccoli-rollup: 2.1.1
+ broccoli-stew: 3.0.0
+ broccoli-templater: 2.0.2
+ calculate-cache-key-for-tree: 2.0.0
+ caniuse-api: 3.0.0
+ ember-cli-babel: 7.26.11
+ ember-cli-typescript: 4.2.1
+ ember-cli-version-checker: 5.1.2
+ node-fetch: 2.7.0
+ whatwg-fetch: 3.6.20
+ transitivePeerDependencies:
+ - encoding
+ - supports-color
+
ember-file-upload@8.4.0(@babel/core@7.29.0)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(@glimmer/component@1.1.2(@babel/core@7.29.0))(@glimmer/tracking@1.1.2)(ember-modifier@4.3.0(@babel/core@7.29.0))(tracked-built-ins@3.4.0(@babel/core@7.29.0))(webpack@5.106.2(postcss@8.5.14)):
dependencies:
'@ember/test-helpers': 3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14))
@@ -11430,6 +11732,8 @@ snapshots:
hasown: 2.0.3
math-intrinsics: 1.1.0
+ get-package-type@0.1.0: {}
+
get-proto@1.0.1:
dependencies:
dunder-proto: 1.0.1
@@ -11725,6 +12029,8 @@ snapshots:
dependencies:
lru-cache: 7.18.3
+ html-escaper@2.0.2: {}
+
html-tags@3.3.1: {}
http-cache-semantics@4.2.0: {}
@@ -12016,6 +12322,10 @@ snapshots:
is-promise@4.0.0: {}
+ is-reference@1.2.1:
+ dependencies:
+ '@types/estree': 1.0.9
+
is-regex@1.2.1:
dependencies:
call-bound: 1.0.4
@@ -12093,6 +12403,37 @@ snapshots:
isobject@3.0.1: {}
+ istanbul-lib-coverage@3.2.2: {}
+
+ istanbul-lib-instrument@5.2.1:
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/parser': 7.29.3
+ '@istanbuljs/schema': 0.1.6
+ istanbul-lib-coverage: 3.2.2
+ semver: 6.3.1
+ transitivePeerDependencies:
+ - supports-color
+
+ istanbul-lib-report@3.0.1:
+ dependencies:
+ istanbul-lib-coverage: 3.2.2
+ make-dir: 4.0.0
+ supports-color: 7.2.0
+
+ istanbul-lib-source-maps@4.0.1:
+ dependencies:
+ debug: 4.4.3
+ istanbul-lib-coverage: 3.2.2
+ source-map: 0.6.1
+ transitivePeerDependencies:
+ - supports-color
+
+ istanbul-reports@3.2.0:
+ dependencies:
+ html-escaper: 2.0.2
+ istanbul-lib-report: 3.0.1
+
istextorbinary@2.1.0:
dependencies:
binaryextensions: 2.3.0
@@ -12121,6 +12462,11 @@ snapshots:
js-tokens@4.0.0: {}
+ js-yaml@3.15.0:
+ dependencies:
+ argparse: 1.0.10
+ esprima: 4.0.1
+
js-yaml@4.1.1:
dependencies:
argparse: 2.0.1
@@ -12229,6 +12575,8 @@ snapshots:
loader.js@4.7.0: {}
+ locate-character@2.0.5: {}
+
locate-path@2.0.0:
dependencies:
p-locate: 2.0.0
@@ -12251,6 +12599,8 @@ snapshots:
dependencies:
p-locate: 6.0.0
+ lodash._reinterpolate@3.0.0: {}
+
lodash.camelcase@4.3.0: {}
lodash.debounce@4.0.8: {}
@@ -12261,10 +12611,23 @@ snapshots:
lodash.kebabcase@4.1.1: {}
+ lodash.memoize@4.1.2: {}
+
lodash.merge@4.6.2: {}
+ lodash.template@4.18.1:
+ dependencies:
+ lodash._reinterpolate: 3.0.0
+ lodash.templatesettings: 4.2.0
+
+ lodash.templatesettings@4.2.0:
+ dependencies:
+ lodash._reinterpolate: 3.0.0
+
lodash.truncate@4.4.2: {}
+ lodash.uniq@4.5.0: {}
+
lodash@4.18.1: {}
log-symbols@2.2.0:
@@ -12298,6 +12661,10 @@ snapshots:
lru-cache@7.18.3: {}
+ magic-string@0.24.1:
+ dependencies:
+ sourcemap-codec: 1.4.8
+
magic-string@0.25.9:
dependencies:
sourcemap-codec: 1.4.8
@@ -12310,6 +12677,10 @@ snapshots:
dependencies:
semver: 6.3.1
+ make-dir@4.0.0:
+ dependencies:
+ semver: 7.8.0
+
makeerror@1.0.12:
dependencies:
tmpl: 1.0.5
@@ -12571,6 +12942,10 @@ snapshots:
lower-case: 2.0.2
tslib: 2.8.1
+ node-dir@0.1.17:
+ dependencies:
+ minimatch: 3.1.5
+
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
@@ -12814,6 +13189,8 @@ snapshots:
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
+ parse-ms@1.0.1: {}
+
parse-ms@4.0.0: {}
parse-passwd@1.0.0: {}
@@ -12954,6 +13331,10 @@ snapshots:
prettier@3.8.3: {}
+ pretty-ms@3.2.0:
+ dependencies:
+ parse-ms: 1.0.1
+
pretty-ms@9.3.0:
dependencies:
parse-ms: 4.0.0
@@ -13188,6 +13569,8 @@ snapshots:
require-from-string@2.0.2: {}
+ require-relative@0.8.7: {}
+
requireindex@1.2.0: {}
requires-port@1.0.0: {}
@@ -13287,6 +13670,20 @@ snapshots:
dependencies:
estree-walker: 0.6.1
+ rollup@0.57.1:
+ dependencies:
+ '@types/acorn': 4.0.6
+ acorn: 5.7.4
+ acorn-dynamic-import: 3.0.0
+ date-time: 2.1.0
+ is-reference: 1.2.1
+ locate-character: 2.0.5
+ pretty-ms: 3.2.0
+ require-relative: 0.8.7
+ rollup-pluginutils: 2.8.2
+ signal-exit: 3.0.7
+ sourcemap-codec: 1.4.8
+
rollup@2.80.0:
optionalDependencies:
fsevents: 2.3.3
@@ -13714,6 +14111,8 @@ snapshots:
dependencies:
extend-shallow: 3.0.2
+ sprintf-js@1.0.3: {}
+
sprintf-js@1.1.3: {}
sri-toolbox@0.2.0: {}
@@ -14001,6 +14400,12 @@ snapshots:
commander: 2.20.3
source-map-support: 0.5.21
+ test-exclude@6.0.0:
+ dependencies:
+ '@istanbuljs/schema': 0.1.6
+ glob: 7.2.3
+ minimatch: 3.1.5
+
testem@3.20.0(@babel/core@7.29.0)(handlebars@4.7.9)(underscore@1.13.8):
dependencies:
'@xmldom/xmldom': 0.9.10
@@ -14095,6 +14500,8 @@ snapshots:
through@2.3.8: {}
+ time-zone@1.0.0: {}
+
tiny-glob@0.2.9:
dependencies:
globalyzer: 0.1.0
@@ -14462,6 +14869,8 @@ snapshots:
websocket-extensions@0.1.4: {}
+ whatwg-fetch@3.6.20: {}
+
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 10ed64a5..1e53ad79 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,2 +1,5 @@
+packages:
+ - '.'
+
allowBuilds:
core-js: false
diff --git a/scripts/check-coverage.mjs b/scripts/check-coverage.mjs
new file mode 100644
index 00000000..e59cba6c
--- /dev/null
+++ b/scripts/check-coverage.mjs
@@ -0,0 +1,127 @@
+#!/usr/bin/env node
+/**
+ * Coverage gate for @fleetbase/ember-core.
+ *
+ * Verifies that:
+ * 1. Every eligible first-party JavaScript file under addon/ is present in
+ * the generated coverage report (files with no tests may not silently
+ * drop out of the denominator).
+ * 2. Every eligible file is at 100% statements, branches, functions, and lines.
+ *
+ * Usage: node scripts/check-coverage.mjs [--summary ] [--addon-dir ]
+ * Exits non-zero when the gate fails.
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import process from 'node:process';
+
+export const METRICS = ['statements', 'branches', 'functions', 'lines'];
+
+export function listEligibleFiles(addonDir) {
+ const files = [];
+ const walk = (dir) => {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ walk(full);
+ } else if (entry.isFile() && entry.name.endsWith('.js')) {
+ files.push(full);
+ }
+ }
+ };
+ walk(addonDir);
+ return files.sort();
+}
+
+function normalize(p) {
+ return p.split(path.sep).join('/');
+}
+
+// Match a coverage summary key (absolute or relative) to an eligible source file.
+export function findSummaryKey(summary, file) {
+ const target = normalize(path.resolve(file));
+ for (const key of Object.keys(summary)) {
+ if (key === 'total') continue;
+ const normalizedKey = normalize(path.isAbsolute(key) ? key : path.resolve(key));
+ if (normalizedKey === target || normalizedKey.endsWith('/' + normalize(file))) {
+ return key;
+ }
+ }
+ return null;
+}
+
+export function checkCoverage({ summary, eligibleFiles }) {
+ const missing = [];
+ const below = [];
+
+ for (const file of eligibleFiles) {
+ const key = findSummaryKey(summary, file);
+ if (!key) {
+ missing.push(file);
+ continue;
+ }
+ const entry = summary[key];
+ for (const metric of METRICS) {
+ const summaryMetric = entry?.[metric];
+
+ // A file with nothing to instrument is vacuously covered. Pure
+ // re-export barrels are the real case: `export { default as X }
+ // from './x'` compiles away, so istanbul records an empty
+ // statementMap and then reports 0/0 as pct 0 — which would make
+ // those files impossible to pass no matter what tests exist.
+ // The file must still be PRESENT; that is checked above.
+ if (summaryMetric?.total === 0) {
+ continue;
+ }
+
+ if (summaryMetric?.pct !== 100) {
+ below.push({ file, metric, pct: summaryMetric?.pct ?? 'n/a' });
+ }
+ }
+ }
+
+ return { missing, below, ok: missing.length === 0 && below.length === 0 };
+}
+
+export function main(argv = process.argv.slice(2)) {
+ const summaryArg = argv.indexOf('--summary');
+ const addonArg = argv.indexOf('--addon-dir');
+ const summaryPath = summaryArg !== -1 ? argv[summaryArg + 1] : 'coverage/coverage-summary.json';
+ const addonDir = addonArg !== -1 ? argv[addonArg + 1] : 'addon';
+
+ if (!fs.existsSync(summaryPath)) {
+ console.error(`Coverage gate: summary not found at ${summaryPath}. Run the coverage suite first.`);
+ return 1;
+ }
+
+ const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8'));
+ const eligibleFiles = listEligibleFiles(addonDir);
+
+ if (eligibleFiles.length === 0) {
+ console.error(`Coverage gate: no eligible source files found under ${addonDir}.`);
+ return 1;
+ }
+
+ const { missing, below, ok } = checkCoverage({ summary, eligibleFiles });
+
+ if (missing.length > 0) {
+ console.error(`Coverage gate: ${missing.length} eligible file(s) missing from the coverage report:`);
+ for (const file of missing) console.error(` - ${file}`);
+ }
+
+ if (below.length > 0) {
+ console.error(`Coverage gate: ${below.length} metric(s) below 100%:`);
+ for (const { file, metric, pct } of below) console.error(` - ${file} ${metric}: ${pct}%`);
+ }
+
+ if (ok) {
+ console.log(`Coverage gate: all ${eligibleFiles.length} eligible files at 100% statements/branches/functions/lines.`);
+ return 0;
+ }
+
+ return 1;
+}
+
+if (normalize(path.resolve(process.argv[1] ?? '')) === normalize(new URL(import.meta.url).pathname)) {
+ process.exit(main());
+}
diff --git a/scripts/check-coverage.test.mjs b/scripts/check-coverage.test.mjs
new file mode 100644
index 00000000..7d79fd56
--- /dev/null
+++ b/scripts/check-coverage.test.mjs
@@ -0,0 +1,104 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { checkCoverage, findSummaryKey, listEligibleFiles, main, METRICS } from './check-coverage.mjs';
+
+function fullEntry() {
+ return Object.fromEntries(METRICS.map((m) => [m, { pct: 100 }]));
+}
+
+function makeFixture({ files, summary }) {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'covgate-'));
+ const addonDir = path.join(dir, 'addon');
+ for (const file of files) {
+ const full = path.join(addonDir, file);
+ fs.mkdirSync(path.dirname(full), { recursive: true });
+ fs.writeFileSync(full, 'export default 1;\n');
+ }
+ const summaryPath = path.join(dir, 'coverage-summary.json');
+ fs.writeFileSync(summaryPath, JSON.stringify(summary));
+ return { dir, addonDir, summaryPath };
+}
+
+test('passes when every eligible file is present at 100%', () => {
+ const { addonDir } = makeFixture({ files: ['utils/a.js'], summary: {} });
+ const summary = { total: fullEntry(), [path.join(addonDir, 'utils/a.js')]: fullEntry() };
+ const result = checkCoverage({ summary, eligibleFiles: listEligibleFiles(addonDir) });
+ assert.equal(result.ok, true);
+ assert.deepEqual(result.missing, []);
+ assert.deepEqual(result.below, []);
+});
+
+test('fails when an eligible file is absent from the report', () => {
+ const { addonDir } = makeFixture({ files: ['utils/a.js', 'utils/untested.js'], summary: {} });
+ const summary = { [path.join(addonDir, 'utils/a.js')]: fullEntry() };
+ const result = checkCoverage({ summary, eligibleFiles: listEligibleFiles(addonDir) });
+ assert.equal(result.ok, false);
+ assert.equal(result.missing.length, 1);
+ assert.match(result.missing[0], /untested\.js$/);
+});
+
+test('fails when any metric is below 100%', () => {
+ const { addonDir } = makeFixture({ files: ['utils/a.js'], summary: {} });
+ const entry = fullEntry();
+ entry.branches = { pct: 87.5 };
+ const summary = { [path.join(addonDir, 'utils/a.js')]: entry };
+ const result = checkCoverage({ summary, eligibleFiles: listEligibleFiles(addonDir) });
+ assert.equal(result.ok, false);
+ assert.deepEqual(result.below.map(({ metric, pct }) => ({ metric, pct })), [{ metric: 'branches', pct: 87.5 }]);
+});
+
+test('treats a file with nothing to instrument as covered', () => {
+ // Pure re-export barrels compile away entirely, so istanbul records an
+ // empty statementMap and reports 0/0 as pct 0. Without this, such a file
+ // could never pass the gate no matter what tests were written.
+ const { addonDir } = makeFixture({ files: ['contracts/index.js'], summary: {} });
+ const empty = { total: 0, covered: 0, skipped: 0, pct: 0 };
+ const summary = {
+ [path.join(addonDir, 'contracts/index.js')]: { statements: empty, branches: empty, functions: empty, lines: { ...empty } },
+ };
+
+ const result = checkCoverage({ summary, eligibleFiles: listEligibleFiles(addonDir) });
+
+ assert.equal(result.ok, true);
+ assert.deepEqual(result.below, []);
+});
+
+test('an empty file must still be present in the report', () => {
+ const { addonDir } = makeFixture({ files: ['contracts/index.js'], summary: {} });
+
+ const result = checkCoverage({ summary: {}, eligibleFiles: listEligibleFiles(addonDir) });
+
+ assert.equal(result.ok, false);
+ assert.equal(result.missing.length, 1);
+});
+
+test('matches relative summary keys against absolute source paths', () => {
+ const { addonDir } = makeFixture({ files: ['utils/a.js'], summary: {} });
+ const file = listEligibleFiles(addonDir)[0];
+ const summary = { [path.join(addonDir, 'utils/a.js')]: fullEntry() };
+ assert.ok(findSummaryKey(summary, file));
+ assert.equal(findSummaryKey({}, file), null);
+});
+
+test('main exits nonzero when the summary file is missing', () => {
+ const exitCode = main(['--summary', path.join(os.tmpdir(), 'covgate-none', 'nope.json'), '--addon-dir', 'addon']);
+ assert.equal(exitCode, 1);
+});
+
+test('main exits zero on a fully covered report and nonzero on a partial one', () => {
+ const { addonDir, dir } = makeFixture({ files: ['utils/a.js'], summary: {} });
+ const good = { total: fullEntry(), [path.join(addonDir, 'utils/a.js')]: fullEntry() };
+ const goodPath = path.join(dir, 'good.json');
+ fs.writeFileSync(goodPath, JSON.stringify(good));
+ assert.equal(main(['--summary', goodPath, '--addon-dir', addonDir]), 0);
+
+ const badEntry = fullEntry();
+ badEntry.lines = { pct: 99.9 };
+ const bad = { total: fullEntry(), [path.join(addonDir, 'utils/a.js')]: badEntry };
+ const badPath = path.join(dir, 'bad.json');
+ fs.writeFileSync(badPath, JSON.stringify(bad));
+ assert.equal(main(['--summary', badPath, '--addon-dir', addonDir]), 1);
+});
diff --git a/tests/dummy/config/coverage.js b/tests/dummy/config/coverage.js
new file mode 100644
index 00000000..eebf29f2
--- /dev/null
+++ b/tests/dummy/config/coverage.js
@@ -0,0 +1,19 @@
+'use strict';
+
+/**
+ * ember-cli-code-coverage configuration.
+ *
+ * Coverage is collected for this addon's first-party JavaScript only. `excludes`
+ * REPLACES the plugin's defaults rather than extending them, so the node_modules
+ * and mirage entries must be repeated here — without them istanbul instruments
+ * every dependency, which makes the build crawl and produces a coverage payload
+ * too large for the browser to serialize and POST back.
+ *
+ * The 100% gate itself is enforced by scripts/check-coverage.mjs, which also
+ * verifies that every eligible file under addon/ appears in the report.
+ */
+module.exports = {
+ useBabelInstrumenter: false,
+ reporters: ['lcov', 'json-summary', 'text-summary', 'json'],
+ excludes: ['*/node_modules/**/*', '*/mirage/**/*', '*/tests/**/*', '*/dummy/**/*'],
+};
diff --git a/tests/dummy/config/environment.js b/tests/dummy/config/environment.js
index 61f3a09d..fb98aaab 100644
--- a/tests/dummy/config/environment.js
+++ b/tests/dummy/config/environment.js
@@ -18,6 +18,32 @@ module.exports = function (environment) {
// Here you can pass flags/options to your application instance
// when it is created
},
+
+ // Mirrors the env var ember-cli-code-coverage instruments on, so the test
+ // suite only pays the cost of collecting and shipping coverage when asked.
+ coverageEnabled: process.env.COVERAGE === 'true',
+
+ // Configuration a host application is expected to provide. Several addon
+ // modules read these at import time (the fetch service touches
+ // API.host as soon as it is evaluated), so the dummy app has to supply
+ // them for those modules to be loadable at all.
+ API: {
+ host: 'https://api.fleetbase.test',
+ namespace: 'v1',
+ },
+
+ socket: {
+ hostname: 'socket.fleetbase.test',
+ secure: false,
+ },
+
+ osrm: {
+ host: 'https://routing.fleetbase.test',
+ servers: {
+ us: 'https://routing-us.fleetbase.test',
+ ca: 'https://routing-ca.fleetbase.test',
+ },
+ },
};
if (environment === 'development') {
diff --git a/tests/helpers/force-addon-modules.js b/tests/helpers/force-addon-modules.js
new file mode 100644
index 00000000..bd698e30
--- /dev/null
+++ b/tests/helpers/force-addon-modules.js
@@ -0,0 +1,41 @@
+/**
+ * Evaluates every one of this addon's modules so that source files without
+ * tests still appear in the coverage report instead of dropping silently out
+ * of the denominator.
+ *
+ * ember-cli-code-coverage ships `forceModulesToBeLoaded`, but it walks every
+ * module in the build and its failure handling is not tight enough for this
+ * addon: a module whose import cannot be resolved wedges the end of the run,
+ * so testem never receives the completion signal and the suite hangs. This
+ * version is scoped to the addon and swallows per-module failures, which is
+ * safe because a module that cannot be evaluated simply stays absent from the
+ * report — and scripts/check-coverage.mjs fails the build when that happens,
+ * so nothing is hidden.
+ *
+ * Returns the names of modules that could not be evaluated.
+ */
+const ADDON_MODULE_PREFIX = '@fleetbase/ember-core/';
+
+export default function forceAddonModulesToBeLoaded(prefix = ADDON_MODULE_PREFIX) {
+ const failed = [];
+ const entries = window.requirejs?.entries ?? {};
+
+ for (const moduleName of Object.keys(entries)) {
+ if (!moduleName.startsWith(prefix)) {
+ continue;
+ }
+
+ // Templates and test-support modules are not first-party coverage targets.
+ if (moduleName.includes('/test-support/') || moduleName.endsWith('/template')) {
+ continue;
+ }
+
+ try {
+ window.require(moduleName);
+ } catch (error) {
+ failed.push(moduleName);
+ }
+ }
+
+ return failed;
+}
diff --git a/tests/helpers/stub-console-extensions.js b/tests/helpers/stub-console-extensions.js
new file mode 100644
index 00000000..90cdd01f
--- /dev/null
+++ b/tests/helpers/stub-console-extensions.js
@@ -0,0 +1,25 @@
+/**
+ * Provides the `@fleetbase/console/extensions` module the extension manager
+ * imports from its host application.
+ *
+ * Unlike the config imports, this one pulls a *function* out of the console app,
+ * so it cannot be redirected through ember-get-config. Without the module the
+ * extension manager cannot be evaluated at all, which keeps it out of the
+ * coverage report entirely. Registering an AMD stub keeps the addon testable
+ * without changing production code; tests that care about loader behaviour pass
+ * their own loader in.
+ */
+const MODULE_NAME = '@fleetbase/console/extensions';
+
+export default function stubConsoleExtensions(getExtensionLoader = () => () => Promise.resolve(undefined)) {
+ // `define` is loader.js's global in a classic build.
+ // eslint-disable-next-line no-undef
+ if (typeof define !== 'function' || window.requirejs?.entries?.[MODULE_NAME]) {
+ return;
+ }
+
+ // eslint-disable-next-line no-undef
+ define(MODULE_NAME, [], function () {
+ return { getExtensionLoader };
+ });
+}
diff --git a/tests/helpers/stub-ember-ui.js b/tests/helpers/stub-ember-ui.js
new file mode 100644
index 00000000..bad3a5db
--- /dev/null
+++ b/tests/helpers/stub-ember-ui.js
@@ -0,0 +1,78 @@
+import { capitalize, decamelize } from '@ember/string';
+import { humanize } from 'ember-cli-string-helpers/helpers/humanize';
+import { typeOf } from '@ember/utils';
+
+/**
+ * Provides the @fleetbase/ember-ui modules this addon imports.
+ *
+ * addon/services/crud.js imports smart-humanize from @fleetbase/ember-ui. That
+ * package cannot be a dependency of this one, because ember-ui already depends
+ * on @fleetbase/ember-core — declaring it would be circular. In an application
+ * both are siblings and the module resolves; in the dummy app nothing provides
+ * it, so the crud service cannot even be loaded without this stub.
+ *
+ * The implementation below is a faithful copy of ember-ui's, so any test that
+ * does assert on humanized output is asserting real behaviour rather than a
+ * convenient simplification.
+ *
+ * Worth noting for the maintainers: ember-core already ships addon/utils/humanize.js
+ * with the same acronym list, so this cross-package reach is for a near-duplicate
+ * of something core already owns.
+ */
+const MODULE_NAME = '@fleetbase/ember-ui/utils/smart-humanize';
+
+const UPPERCASE = [
+ 'api',
+ 'vat',
+ 'id',
+ 'uuid',
+ 'sku',
+ 'ean',
+ 'upc',
+ 'erp',
+ 'tms',
+ 'wms',
+ 'ltl',
+ 'ftl',
+ 'lcl',
+ 'fcl',
+ 'rfid',
+ 'jot',
+ 'roi',
+ 'eta',
+ 'pod',
+ 'asn',
+ 'oem',
+ 'ddp',
+ 'fob',
+ 'gsm',
+ 'etd',
+ 'ect',
+ 'aws',
+ 'gcp',
+];
+
+export function smartHumanize(string) {
+ if (typeOf(string) !== 'string') {
+ return string;
+ }
+
+ return humanize([decamelize(string)])
+ .toLowerCase()
+ .split(' ')
+ .map((word) => (UPPERCASE.includes(word) ? word.toUpperCase() : capitalize(word)))
+ .join(' ');
+}
+
+export default function stubEmberUi() {
+ // `define` is loader.js's global in a classic build.
+ // eslint-disable-next-line no-undef
+ if (typeof define !== 'function' || window.requirejs?.entries?.[MODULE_NAME]) {
+ return;
+ }
+
+ // eslint-disable-next-line no-undef
+ define(MODULE_NAME, [], function () {
+ return { default: smartHumanize };
+ });
+}
diff --git a/tests/helpers/stub-socketcluster.js b/tests/helpers/stub-socketcluster.js
new file mode 100644
index 00000000..174d4423
--- /dev/null
+++ b/tests/helpers/stub-socketcluster.js
@@ -0,0 +1,62 @@
+/**
+ * Keeps the real SocketCluster client out of the test suite.
+ *
+ * The socket service builds a client in its constructor, and socketcluster-client
+ * retries a failed connection forever. Under test that means an endless stream of
+ * `WebSocket connection to 'ws://localhost:PORT/socketcluster/' failed`, the page
+ * never goes idle, and the run never finishes.
+ *
+ * Two things have to be prevented:
+ * 1. the `load-socketcluster-client` initializer injecting the real script — we
+ * reuse its own `data-socketcluster-client` guard by planting a marker node,
+ * so no production code has to change; and
+ * 2. the global itself, which is replaced with an inert fake.
+ *
+ * Tests that exercise socket behaviour should register their own fake on the owner
+ * rather than relying on the shape of this one.
+ */
+const MARKER_SELECTOR = 'script[data-socketcluster-client]';
+
+function createFakeChannel(name) {
+ return {
+ name,
+ // socketcluster channels are async iterables; an immediately-done iterator
+ // keeps `for await (... of channel)` loops from suspending forever.
+ [Symbol.asyncIterator]() {
+ return { next: () => Promise.resolve({ done: true, value: undefined }) };
+ },
+ listener() {
+ return { once: () => Promise.resolve() };
+ },
+ unsubscribe() {},
+ close() {},
+ };
+}
+
+export function createFakeSocketClusterClient() {
+ return {
+ create() {
+ return {
+ subscribe: (channelId) => createFakeChannel(channelId),
+ transmit() {},
+ invoke: () => Promise.resolve(),
+ closeAllChannels() {},
+ disconnect() {},
+ listener() {
+ return { once: () => Promise.resolve() };
+ },
+ };
+ },
+ };
+}
+
+export default function stubSocketCluster() {
+ if (!document.querySelector(MARKER_SELECTOR)) {
+ const marker = document.createElement('script');
+ marker.setAttribute('data-socketcluster-client', '1');
+ // Deliberately has no `src`: it only satisfies the initializer's guard.
+ document.body.appendChild(marker);
+ }
+
+ window.socketClusterClient = createFakeSocketClusterClient();
+}
diff --git a/tests/helpers/universe-registry-stubs.js b/tests/helpers/universe-registry-stubs.js
new file mode 100644
index 00000000..a7df98b3
--- /dev/null
+++ b/tests/helpers/universe-registry-stubs.js
@@ -0,0 +1,81 @@
+import Service from '@ember/service';
+import { A } from '@ember/array';
+import { getOwner } from '@ember/application';
+
+/**
+ * In-memory stand-ins for the universe registry and menu services.
+ *
+ * The real RegistryService imports `tracked-built-ins`, and its extension
+ * manager imports `@fleetbase/console/extensions`; neither exists in this
+ * addon's dummy app, so services built on them are tested against these.
+ * They keep the real storage shape: section → list → items keyed by
+ * `_registryKey`, `slug` or `id`.
+ */
+export class RegistryServiceStub extends Service {
+ sections = new Map();
+
+ getOrCreateList(section, list) {
+ if (!this.sections.has(section)) {
+ this.sections.set(section, {});
+ }
+
+ const lists = this.sections.get(section);
+ lists[list] = lists[list] ?? A([]);
+ return lists[list];
+ }
+
+ register(section, list, key, value) {
+ const items = this.getOrCreateList(section, list);
+ value._registryKey = key;
+ const existing = this.lookup(section, list, key);
+
+ if (existing) {
+ items.replace(items.indexOf(existing), 1, [value]);
+ } else {
+ items.pushObject(value);
+ }
+ }
+
+ getRegistry(section, list) {
+ return this.sections.get(section)?.[list] ?? A([]);
+ }
+
+ lookup(section, list, key) {
+ return this.getRegistry(section, list).find((item) => item._registryKey === key || item.slug === key || item.id === key) ?? null;
+ }
+
+ hasList(section, list) {
+ return Boolean(this.sections.get(section)?.[list]);
+ }
+
+ clearAll() {
+ this.sections.clear();
+ }
+}
+
+export class MenuServiceStub extends Service {
+ get registry() {
+ return getOwner(this).lookup('service:universe/registry-service');
+ }
+
+ registerMenuItem(registryName, menuItem) {
+ const item = typeof menuItem.toObject === 'function' ? menuItem.toObject() : menuItem;
+ this.registry.register(registryName, 'menu-item', item.slug || item.title, item);
+ }
+
+ getMenuItems(registryName) {
+ return this.registry.getRegistry(registryName, 'menu-item');
+ }
+
+ getMenuPanels(registryName) {
+ return this.registry.getRegistry(registryName, 'menu-panel');
+ }
+}
+
+export function setupUniverseRegistryStubs(hooks) {
+ hooks.beforeEach(function () {
+ this.owner.register('service:universe/extension-manager', class extends Service {});
+ this.owner.register('service:universe/registry-service', RegistryServiceStub);
+ this.owner.register('service:universe/menu-service', MenuServiceStub);
+ });
+}
diff --git a/tests/test-helper.js b/tests/test-helper.js
index 4efd6e58..64ffae2d 100644
--- a/tests/test-helper.js
+++ b/tests/test-helper.js
@@ -4,9 +4,79 @@ import * as QUnit from 'qunit';
import { setApplication } from '@ember/test-helpers';
import { setup } from 'qunit-dom';
import { start } from 'ember-qunit';
+import { sendCoverage } from 'ember-cli-code-coverage/test-support';
+import stubSocketCluster from './helpers/stub-socketcluster';
+import stubConsoleExtensions from './helpers/stub-console-extensions';
+import stubEmberUi from './helpers/stub-ember-ui';
+import forceAddonModulesToBeLoaded from './helpers/force-addon-modules';
+import resetStorages from 'ember-local-storage/test-support/reset-storage';
+
+const COVERAGE_UPLOAD_TIMEOUT_MS = 60000;
+
+// Must run before the application boots so the socket service never builds a
+// real client. See the helper for why an unstubbed client hangs the suite.
+stubSocketCluster();
+
+// Supplies the host-application module the extension manager imports, so that
+// service can be loaded and measured at all.
+stubConsoleExtensions();
+
+// Supplies the sibling-package util the crud service imports; see the helper for
+// why ember-ui cannot be a dependency of this addon.
+stubEmberUi();
setApplication(Application.create(config.APP));
setup(QUnit.assert);
+// ember-local-storage caches its storage objects across owners. Without a reset
+// the second test to use a `storageFor` service inherits the previous test's
+// destroyed object and fails with "calling set on destroyed object".
+QUnit.testDone(function () {
+ resetStorages();
+ window.localStorage.clear();
+});
+
+// Pull this addon's untested modules into the coverage denominator and ship the
+// report. A failed or stalled upload is reported as a global failure rather than
+// left to hang the run, so a broken coverage pipeline is always visible.
+QUnit.done(async function () {
+ if (!config.coverageEnabled) {
+ return;
+ }
+
+ // A filtered run exercises a handful of tests but still force-loads every
+ // addon module, so it produces a report with the full denominator and a
+ // nearly empty numerator. Writing that would overwrite a good full-suite
+ // report with something that looks like a catastrophic regression, and the
+ // coverage gate would then read it. Partial runs are refused outright.
+ const { filter, module: moduleFilter, testId } = QUnit.config;
+ const partialRun = Boolean(filter) || Boolean(moduleFilter) || (Array.isArray(testId) && testId.length > 0);
+
+ if (partialRun) {
+ // eslint-disable-next-line no-console
+ console.warn('[coverage] filtered run detected — report not written, so the full-suite report on disk is preserved');
+ return;
+ }
+
+ forceAddonModulesToBeLoaded();
+
+ const instrumentedFiles = Object.keys(window.__coverage__ ?? {}).length;
+
+ let timeoutId;
+ try {
+ await Promise.race([
+ sendCoverage(),
+ new Promise((resolve, reject) => {
+ timeoutId = setTimeout(() => reject(new Error(`coverage upload timed out after ${COVERAGE_UPLOAD_TIMEOUT_MS}ms`)), COVERAGE_UPLOAD_TIMEOUT_MS);
+ }),
+ ]);
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error(`[coverage] ${error.message} (instrumented files: ${instrumentedFiles})`);
+ } finally {
+ clearTimeout(timeoutId);
+ }
+});
+
start();
diff --git a/tests/unit/abilities/dynamic-test.js b/tests/unit/abilities/dynamic-test.js
index 3e04439e..10c99559 100644
--- a/tests/unit/abilities/dynamic-test.js
+++ b/tests/unit/abilities/dynamic-test.js
@@ -1,11 +1,141 @@
import { module, test } from 'qunit';
-import { setupTest } from 'ember-qunit';
+import { setupTest } from 'dummy/tests/helpers';
+import Service from '@ember/service';
+import DynamicAbility from 'dummy/abilities/dynamic';
+/**
+ * The dynamic ability turns a permission string like "fleet-ops create order"
+ * into a yes/no, matching the user's granted permissions with support for two
+ * shapes of wildcard.
+ *
+ * Each assertion builds a fresh instance through `factoryFor`, because the
+ * ability snapshots the user's permissions in its constructor and `lookup`
+ * would hand back the same singleton with a stale snapshot.
+ */
module('Unit | Ability | dynamic', function (hooks) {
setupTest(hooks);
- test('it exists', function (assert) {
- const ability = this.owner.lookup('ability:dynamic');
- assert.ok(ability);
+ hooks.beforeEach(function () {
+ this.permissions = [];
+ this.isAdmin = false;
+ const testContext = this;
+
+ this.owner.register(
+ 'service:current-user',
+ class extends Service {
+ get permissions() {
+ return testContext.permissions;
+ }
+ get isAdmin() {
+ return testContext.isAdmin;
+ }
+ }
+ );
+
+ this.owner.register('ability:fleetbase-dynamic', DynamicAbility);
+
+ this.build = () => this.owner.factoryFor('ability:fleetbase-dynamic').create();
+
+ this.can = (permissionString) => {
+ const ability = this.build();
+ ability.parseProperty(permissionString);
+ return ability.can;
+ };
+ });
+
+ module('parseProperty', function () {
+ test('it splits the permission into its three parts', function (assert) {
+ const ability = this.build();
+
+ const property = ability.parseProperty('fleet-ops create order');
+
+ assert.strictEqual(ability.service, 'fleet-ops');
+ assert.strictEqual(ability.ability, 'create');
+ assert.strictEqual(ability.resource, 'order');
+ assert.strictEqual(property, 'can', 'it always resolves to the `can` getter');
+ });
+
+ test('the resource is singularized', function (assert) {
+ const ability = this.build();
+
+ ability.parseProperty('fleet-ops create orders');
+
+ assert.strictEqual(ability.resource, 'order');
+ });
+
+ test('a missing resource leaves it undefined', function (assert) {
+ const ability = this.build();
+
+ ability.parseProperty('fleet-ops create');
+
+ assert.strictEqual(ability.resource, undefined);
+ });
+ });
+
+ module('permission matching', function () {
+ test('an exact permission is granted', function (assert) {
+ this.permissions = [{ name: 'fleet-ops create order' }];
+
+ assert.true(this.can('fleet-ops create order'));
+ });
+
+ test('an unrelated permission is not', function (assert) {
+ this.permissions = [{ name: 'fleet-ops create vehicle' }];
+
+ assert.false(this.can('fleet-ops create order'));
+ });
+
+ test('no permissions at all denies', function (assert) {
+ assert.false(this.can('fleet-ops create order'));
+ });
+
+ test('a resource wildcard grants every verb on that resource', function (assert) {
+ this.permissions = [{ name: 'fleet-ops * order' }];
+
+ assert.true(this.can('fleet-ops create order'));
+ assert.true(this.can('fleet-ops delete order'));
+ assert.false(this.can('fleet-ops create vehicle'), 'but only that resource');
+ });
+
+ test('a service wildcard grants everything in the service', function (assert) {
+ this.permissions = [{ name: 'fleet-ops *' }];
+
+ assert.true(this.can('fleet-ops create order'));
+ assert.true(this.can('fleet-ops delete vehicle'));
+ assert.false(this.can('storefront create order'), 'but only that service');
+ });
+
+ test('the plural form is matched against the singular permission', function (assert) {
+ this.permissions = [{ name: 'fleet-ops create order' }];
+
+ assert.true(this.can('fleet-ops create orders'));
+ });
+
+ test('an admin is granted everything regardless of permissions', function (assert) {
+ this.isAdmin = true;
+
+ assert.true(this.can('anything at all'));
+ assert.true(this.can('fleet-ops delete order'));
+ });
+ });
+
+ module('the permission snapshot', function () {
+ test('permissions are read once at construction', function (assert) {
+ this.permissions = [{ name: 'fleet-ops create order' }];
+ const ability = this.build();
+
+ this.permissions = [];
+ ability.parseProperty('fleet-ops create order');
+
+ assert.true(ability.can, 'a later change to the user does not reach an existing ability');
+ });
+
+ test('a fresh ability sees the current permissions', function (assert) {
+ this.permissions = [];
+ assert.false(this.can('fleet-ops create order'));
+
+ this.permissions = [{ name: 'fleet-ops create order' }];
+ assert.true(this.can('fleet-ops create order'));
+ });
});
});
diff --git a/tests/unit/adapters/application-test.js b/tests/unit/adapters/application-test.js
new file mode 100644
index 00000000..202ed4b6
--- /dev/null
+++ b/tests/unit/adapters/application-test.js
@@ -0,0 +1,285 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'dummy/tests/helpers';
+import Service from '@ember/service';
+import AdapterError from '@ember-data/adapter/error';
+import { compress } from 'compress-json';
+import ApplicationAdapter from '@fleetbase/ember-core/adapters/application';
+
+const DEFAULT_ERROR_MESSAGE = 'Oops! Something went wrong. Please try again or contact support if the issue persists.';
+const USER_OPTIONS_KEY = '@fleetbase/storage:user-options';
+const SESSION_KEY = 'ember_simple_auth-session';
+
+/**
+ * ApplicationAdapter decides three things worth pinning: which headers go out
+ * with every request, how a URL path is derived from a model name, and which
+ * responses count as errors.
+ *
+ * There is no app/adapters/application.js re-export — the addon deliberately
+ * leaves that path to the consuming app — so the class is registered directly.
+ */
+module('Unit | Adapter | application', function (hooks) {
+ setupTest(hooks);
+
+ hooks.beforeEach(function () {
+ this.sessionData = { authenticated: {} };
+ this.isAuthenticated = false;
+ const testContext = this;
+
+ this.owner.register(
+ 'service:session',
+ class extends Service {
+ get data() {
+ return testContext.sessionData;
+ }
+ get isAuthenticated() {
+ return testContext.isAuthenticated;
+ }
+ }
+ );
+
+ this.owner.register('service:current-user', class extends Service {});
+ this.owner.register('adapter:fleetbase-application', ApplicationAdapter);
+
+ this.buildAdapter = () => this.owner.lookup('adapter:fleetbase-application');
+ this.setUserOptions = (options) => window.localStorage.setItem(USER_OPTIONS_KEY, JSON.stringify(options));
+ });
+
+ hooks.afterEach(function () {
+ window.localStorage.removeItem(USER_OPTIONS_KEY);
+ window.localStorage.removeItem(SESSION_KEY);
+ });
+
+ module('headers', function () {
+ test('an unauthenticated request sends only a content type', function (assert) {
+ assert.deepEqual(this.buildAdapter().setupHeaders(), { 'Content-Type': 'application/json' });
+ });
+
+ test('an authenticated request carries a bearer token', function (assert) {
+ this.isAuthenticated = true;
+ this.sessionData = { authenticated: { user: 'user-1', token: 'abc123' } };
+
+ const headers = this.buildAdapter().setupHeaders();
+
+ assert.strictEqual(headers['Authorization'], 'Bearer abc123');
+ assert.strictEqual(headers['Content-Type'], 'application/json');
+ });
+
+ test('the token is recovered from local storage when the session has not restored yet', function (assert) {
+ window.localStorage.setItem(SESSION_KEY, JSON.stringify({ authenticated: { token: 'from-storage' } }));
+
+ const headers = this.buildAdapter().setupHeaders();
+
+ assert.strictEqual(headers['Authorization'], 'Bearer from-storage', 'a page reload still sends credentials');
+ });
+
+ test('a stored session with no authenticated section is ignored', function (assert) {
+ window.localStorage.setItem(SESSION_KEY, JSON.stringify({ somethingElse: true }));
+
+ assert.deepEqual(this.buildAdapter().setupHeaders(), { 'Content-Type': 'application/json' });
+ });
+
+ test('sandbox mode adds the sandbox header', function (assert) {
+ this.isAuthenticated = true;
+ this.sessionData = { authenticated: { user: 'user-1', token: 'abc123' } };
+ this.setUserOptions({ 'user-1:sandbox': true });
+
+ assert.true(this.buildAdapter().setupHeaders()['Access-Console-Sandbox']);
+ });
+
+ test('sandbox must be exactly true', function (assert) {
+ this.isAuthenticated = true;
+ this.sessionData = { authenticated: { user: 'user-1', token: 'abc123' } };
+ this.setUserOptions({ 'user-1:sandbox': 'yes' });
+
+ assert.strictEqual(this.buildAdapter().setupHeaders()['Access-Console-Sandbox'], undefined);
+ });
+
+ test('a test key is sent alongside the sandbox header', function (assert) {
+ this.isAuthenticated = true;
+ this.sessionData = { authenticated: { user: 'user-1', token: 'abc123' } };
+ this.setUserOptions({ 'user-1:sandbox': true, 'user-1:test-key': 'key-1' });
+
+ assert.strictEqual(this.buildAdapter().setupHeaders()['Access-Console-Sandbox-Key'], 'key-1');
+ });
+
+ test('sandbox options belonging to another user are not applied', function (assert) {
+ this.isAuthenticated = true;
+ this.sessionData = { authenticated: { user: 'user-1', token: 'abc123' } };
+ this.setUserOptions({ 'user-2:sandbox': true, 'user-2:test-key': 'key-2' });
+
+ const headers = this.buildAdapter().setupHeaders();
+
+ assert.strictEqual(headers['Access-Console-Sandbox'], undefined);
+ assert.strictEqual(headers['Access-Console-Sandbox-Key'], undefined);
+ });
+
+ test('sandbox headers are withheld from an unauthenticated request', function (assert) {
+ this.sessionData = { authenticated: { user: 'user-1' } };
+ this.setUserOptions({ 'user-1:sandbox': true, 'user-1:test-key': 'key-1' });
+
+ const headers = this.buildAdapter().setupHeaders();
+
+ assert.strictEqual(headers['Access-Console-Sandbox'], undefined);
+ assert.strictEqual(headers['Access-Console-Sandbox-Key'], undefined);
+ });
+
+ test('corrupt user options are ignored rather than fatal', function (assert) {
+ this.isAuthenticated = true;
+ this.sessionData = { authenticated: { user: 'user-1', token: 'abc123' } };
+ window.localStorage.setItem(USER_OPTIONS_KEY, 'not json');
+
+ assert.strictEqual(this.buildAdapter().setupHeaders()['Authorization'], 'Bearer abc123');
+ });
+
+ test('setupHeaders both returns and installs the headers', function (assert) {
+ const adapter = this.buildAdapter();
+
+ const headers = adapter.setupHeaders();
+
+ assert.deepEqual(adapter.headers, headers);
+ });
+ });
+
+ module('ajaxOptions', function () {
+ test('it sends credentials with the request', function (assert) {
+ const options = this.buildAdapter().ajaxOptions('/api/v1/users', 'GET', {});
+
+ assert.strictEqual(options.credentials, 'include');
+ });
+
+ test('it refreshes the headers first, so a login mid-session is picked up', function (assert) {
+ const adapter = this.buildAdapter();
+ adapter.ajaxOptions('/api/v1/users', 'GET', {});
+ assert.strictEqual(adapter.headers['Authorization'], undefined);
+
+ this.isAuthenticated = true;
+ this.sessionData = { authenticated: { user: 'user-1', token: 'abc123' } };
+ adapter.ajaxOptions('/api/v1/users', 'GET', {});
+
+ assert.strictEqual(adapter.headers['Authorization'], 'Bearer abc123');
+ });
+ });
+
+ module('pathForType', function () {
+ test('it pluralizes and dasherizes the model name', function (assert) {
+ const adapter = this.buildAdapter();
+
+ assert.strictEqual(adapter.pathForType('user'), 'users');
+ assert.strictEqual(adapter.pathForType('orderConfig'), 'order-configs');
+ assert.strictEqual(adapter.pathForType('fuel-report'), 'fuel-reports');
+ });
+
+ test('an irregular plural is honoured', function (assert) {
+ assert.strictEqual(this.buildAdapter().pathForType('company'), 'companies');
+ });
+ });
+
+ module('error detection', function () {
+ test('4xx and 5xx are errors', function (assert) {
+ const adapter = this.buildAdapter();
+
+ for (const status of [400, 404, 422, 500, 599]) {
+ assert.true(adapter.isErrorResponse(status, {}), `${status} is an error`);
+ }
+ });
+
+ test('a successful status is not an error', function (assert) {
+ const adapter = this.buildAdapter();
+
+ for (const status of [200, 201, 204, 304, 399]) {
+ assert.false(adapter.isErrorResponse(status, {}), `${status} is not an error`);
+ }
+ });
+
+ test('a 200 carrying an errors array is still an error', function (assert) {
+ assert.true(this.buildAdapter().isErrorResponse(200, { errors: ['Nope'] }));
+ });
+
+ test('a blank payload with a good status is not an error', function (assert) {
+ assert.false(this.buildAdapter().isErrorResponse(200, null));
+ });
+
+ test('errors are read off the payload', function (assert) {
+ assert.deepEqual(this.buildAdapter().getResponseErrors({ errors: ['First', 'Second'] }), ['First', 'Second']);
+ });
+
+ test('a payload with no errors array yields the default message', function (assert) {
+ const adapter = this.buildAdapter();
+
+ assert.deepEqual(adapter.getResponseErrors({}), [DEFAULT_ERROR_MESSAGE]);
+ assert.deepEqual(adapter.getResponseErrors({ errors: 'not an array' }), [DEFAULT_ERROR_MESSAGE]);
+ });
+
+ test('the first error becomes the message', function (assert) {
+ assert.strictEqual(this.buildAdapter().getErrorMessage(['First', 'Second']), 'First');
+ });
+
+ test('an empty or absent error list falls back to the default message', function (assert) {
+ const adapter = this.buildAdapter();
+
+ assert.strictEqual(adapter.getErrorMessage([]), DEFAULT_ERROR_MESSAGE);
+ assert.strictEqual(adapter.getErrorMessage(), DEFAULT_ERROR_MESSAGE);
+ assert.strictEqual(adapter.getErrorMessage([null]), DEFAULT_ERROR_MESSAGE);
+ });
+ });
+
+ module('handleResponse', function () {
+ test('an error response becomes an AdapterError carrying the message', function (assert) {
+ const result = this.buildAdapter().handleResponse(422, {}, { errors: ['Name is required'] }, {});
+
+ assert.true(result instanceof AdapterError);
+ assert.strictEqual(result.message, 'Name is required');
+ assert.deepEqual(result.errors, ['Name is required']);
+ });
+
+ test('an error response with no errors array uses the default message', function (assert) {
+ const result = this.buildAdapter().handleResponse(500, {}, {}, {});
+
+ assert.true(result instanceof AdapterError);
+ assert.strictEqual(result.message, DEFAULT_ERROR_MESSAGE);
+ });
+
+ test('a successful response is handed to the superclass', function (assert) {
+ const payload = { users: [{ id: '1' }] };
+
+ assert.deepEqual(this.buildAdapter().handleResponse(200, {}, payload, {}), payload);
+ });
+ });
+
+ module('decompressPayload', function () {
+ test('a payload flagged as compressed is decompressed and parsed', function (assert) {
+ const original = { users: [{ id: '1', name: 'Ron' }] };
+ const compressed = compress(JSON.stringify(original));
+
+ assert.deepEqual(this.buildAdapter().decompressPayload(compressed, { 'x-compressed-json': '1' }), original);
+ });
+
+ test('the flag is accepted as a number as well as a string', function (assert) {
+ const original = { ok: true };
+ const compressed = compress(JSON.stringify(original));
+
+ assert.deepEqual(this.buildAdapter().decompressPayload(compressed, { 'x-compressed-json': 1 }), original);
+ });
+
+ test('an unflagged payload is passed through untouched', function (assert) {
+ const payload = { users: [] };
+
+ assert.strictEqual(this.buildAdapter().decompressPayload(payload, {}), payload);
+ });
+
+ test('any other flag value leaves the payload alone', function (assert) {
+ const payload = { users: [] };
+
+ assert.strictEqual(this.buildAdapter().decompressPayload(payload, { 'x-compressed-json': '0' }), payload);
+ });
+
+ test('handleResponse decompresses before deciding whether it is an error', function (assert) {
+ const compressed = compress(JSON.stringify({ errors: ['Compressed failure'] }));
+
+ const result = this.buildAdapter().handleResponse(200, { 'x-compressed-json': '1' }, compressed, {});
+
+ assert.true(result instanceof AdapterError, 'the error is only visible after decompression');
+ assert.strictEqual(result.message, 'Compressed failure');
+ });
+ });
+});
diff --git a/tests/unit/adapters/user-test.js b/tests/unit/adapters/user-test.js
index e65aefff..7bab994d 100644
--- a/tests/unit/adapters/user-test.js
+++ b/tests/unit/adapters/user-test.js
@@ -1,12 +1,67 @@
import { module, test } from 'qunit';
import { setupTest } from 'dummy/tests/helpers';
+import Service from '@ember/service';
+/**
+ * UserAdapter adds exactly one thing to ApplicationAdapter: a `me` flag on a
+ * queryRecord is turned into the `/me` sub-resource rather than a query
+ * parameter, and is removed from the query so it is never also serialized.
+ */
module('Unit | Adapter | user', function (hooks) {
setupTest(hooks);
- // Replace this with your real tests.
- test('it exists', function (assert) {
- let adapter = this.owner.lookup('adapter:user');
- assert.ok(adapter);
+ hooks.beforeEach(function () {
+ this.owner.register(
+ 'service:session',
+ class extends Service {
+ data = { authenticated: {} };
+ isAuthenticated = false;
+ }
+ );
+ this.owner.register('service:current-user', class extends Service {});
+
+ this.adapter = this.owner.lookup('adapter:user');
+ });
+
+ test('it resolves to this addon’s user adapter', function (assert) {
+ assert.strictEqual(typeof this.adapter.urlForQueryRecord, 'function');
+ assert.strictEqual(this.adapter.pathForType('user'), 'users', 'it inherits the application adapter');
+ });
+
+ test('a plain query record uses the collection URL', function (assert) {
+ const url = this.adapter.urlForQueryRecord({ public_id: 'user_1' }, 'user');
+
+ assert.true(url.endsWith('/users'), `${url} ends with the collection path`);
+ });
+
+ test('the me flag switches to the /me sub-resource', function (assert) {
+ const url = this.adapter.urlForQueryRecord({ me: true }, 'user');
+
+ assert.true(url.endsWith('/users/me'), `${url} ends with /users/me`);
+ });
+
+ test('the me flag is removed from the query so it is not also sent', function (assert) {
+ const query = { me: true };
+
+ this.adapter.urlForQueryRecord(query, 'user');
+
+ assert.notOk('me' in query, 'the flag is consumed rather than serialized');
+ });
+
+ test('other query keys are left in place', function (assert) {
+ const query = { me: true, include: 'company' };
+
+ this.adapter.urlForQueryRecord(query, 'user');
+
+ assert.deepEqual(query, { include: 'company' });
+ });
+
+ test('a falsy me flag is treated as an ordinary query', function (assert) {
+ const query = { me: false };
+
+ const url = this.adapter.urlForQueryRecord(query, 'user');
+
+ assert.true(url.endsWith('/users'), `${url} is the collection path`);
+ assert.strictEqual(query.me, false, 'a falsy flag is left alone rather than deleted');
});
});
diff --git a/tests/unit/authenticators/fleetbase-test.js b/tests/unit/authenticators/fleetbase-test.js
new file mode 100644
index 00000000..37718fbe
--- /dev/null
+++ b/tests/unit/authenticators/fleetbase-test.js
@@ -0,0 +1,145 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'dummy/tests/helpers';
+import Service from '@ember/service';
+import FleetbaseAuthenticator, { AuthenticationError } from '@fleetbase/ember-core/authenticators/fleetbase';
+
+/**
+ * The authenticator is delegation over the `fetch` service: it decides which
+ * endpoint to call, what to send, and which responses count as failures.
+ * A stubbed fetch records the calls and returns whatever a test needs.
+ */
+module('Unit | Authenticator | fleetbase', function (hooks) {
+ setupTest(hooks);
+
+ hooks.beforeEach(function () {
+ this.gets = [];
+ this.posts = [];
+ this.getResponse = {};
+ this.postResponse = {};
+ const testContext = this;
+
+ this.owner.register(
+ 'service:fetch',
+ class extends Service {
+ get(path, query, options) {
+ testContext.gets.push({ path, query, options });
+ return Promise.resolve(testContext.getResponse);
+ }
+ post(path, body, options) {
+ testContext.posts.push({ path, body, options });
+ return Promise.resolve(testContext.postResponse);
+ }
+ }
+ );
+
+ this.owner.register('service:session', class extends Service {});
+ this.owner.register('authenticator:fleetbase', FleetbaseAuthenticator);
+ this.authenticator = this.owner.lookup('authenticator:fleetbase');
+ });
+
+ module('AuthenticationError', function () {
+ test('it carries a message and a code', function (assert) {
+ const error = new AuthenticationError('Nope', 'bad_credentials');
+
+ assert.strictEqual(error.message, 'Nope');
+ assert.strictEqual(error.code, 'bad_credentials');
+ assert.strictEqual(error.getCode(), 'bad_credentials');
+ });
+
+ test('it is a real Error', function (assert) {
+ assert.true(new AuthenticationError('Nope') instanceof Error);
+ });
+
+ test('the code is optional', function (assert) {
+ assert.strictEqual(new AuthenticationError('Nope').getCode(), undefined);
+ });
+ });
+
+ module('restore', function () {
+ test('it asks the session endpoint with the stored token', async function (assert) {
+ await this.authenticator.restore({ token: 'abc123' });
+
+ assert.deepEqual(this.gets, [{ path: 'auth/session', query: {}, options: { headers: { Authorization: 'Bearer abc123' } } }]);
+ });
+
+ test('it resolves with the response', async function (assert) {
+ this.getResponse = { token: 'renewed', restore: true };
+
+ assert.deepEqual(await this.authenticator.restore({ token: 'abc123' }), { token: 'renewed', restore: true });
+ });
+
+ test('an explicit restore false is rejected', async function (assert) {
+ this.getResponse = { restore: false, error: 'Session expired' };
+
+ await assert.rejects(this.authenticator.restore({ token: 'abc123' }), (error) => error instanceof AuthenticationError && error.message === 'Session expired');
+ });
+
+ test('only an exact false rejects', async function (assert) {
+ this.getResponse = { restore: null };
+
+ assert.deepEqual(await this.authenticator.restore({ token: 'abc123' }), { restore: null }, 'a missing flag is not a failure');
+ });
+ });
+
+ module('authenticate', function () {
+ test('it posts the credentials to the login endpoint', async function (assert) {
+ await this.authenticator.authenticate({ email: 'a@b.c', password: 'secret' });
+
+ assert.deepEqual(this.posts, [{ path: 'auth/login', body: { email: 'a@b.c', password: 'secret', remember: false }, options: {} }]);
+ });
+
+ test('remember is passed through', async function (assert) {
+ await this.authenticator.authenticate({ email: 'a@b.c' }, true);
+
+ assert.true(this.posts[0].body.remember);
+ });
+
+ test('a custom path and options are honoured', async function (assert) {
+ await this.authenticator.authenticate({}, false, 'auth/sso', { headers: { 'X-Tenant': 'acme' } });
+
+ assert.strictEqual(this.posts[0].path, 'auth/sso');
+ assert.deepEqual(this.posts[0].options, { headers: { 'X-Tenant': 'acme' } });
+ });
+
+ test('it defaults to empty credentials', async function (assert) {
+ await this.authenticator.authenticate();
+
+ assert.deepEqual(this.posts[0].body, { remember: false });
+ });
+
+ test('it resolves with the response', async function (assert) {
+ this.postResponse = { token: 'abc123' };
+
+ assert.deepEqual(await this.authenticator.authenticate({}), { token: 'abc123' });
+ });
+
+ test('an errors array is rejected with its first entry and the response code', async function (assert) {
+ this.postResponse = { errors: ['Invalid password', 'ignored'], code: 'bad_credentials' };
+
+ await assert.rejects(
+ this.authenticator.authenticate({}),
+ (error) => error instanceof AuthenticationError && error.message === 'Invalid password' && error.getCode() === 'bad_credentials'
+ );
+ });
+
+ test('an empty errors array still rejects, with a fallback message', async function (assert) {
+ this.postResponse = { errors: [] };
+
+ await assert.rejects(this.authenticator.authenticate({}), (error) => error.message === 'Authentication failed!' && error.getCode() === undefined);
+ });
+ });
+
+ module('invalidate', function () {
+ test('it posts to the logout endpoint', async function (assert) {
+ await this.authenticator.invalidate({ token: 'abc123' });
+
+ assert.deepEqual(this.posts, [{ path: 'auth/logout', body: undefined, options: undefined }]);
+ });
+
+ test('it resolves with whatever logout returned', async function (assert) {
+ this.postResponse = { ok: true };
+
+ assert.deepEqual(await this.authenticator.invalidate({}), { ok: true });
+ });
+ });
+});
diff --git a/tests/unit/branch-defaults-test.js b/tests/unit/branch-defaults-test.js
new file mode 100644
index 00000000..5b518f83
--- /dev/null
+++ b/tests/unit/branch-defaults-test.js
@@ -0,0 +1,232 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'dummy/tests/helpers';
+import Service from '@ember/service';
+import EmberObject from '@ember/object';
+import ObjectProxy from '@ember/object/proxy';
+import apiUrl from 'dummy/utils/api-url';
+import frontendUrl from 'dummy/utils/frontend-url';
+import groupApiEvents from 'dummy/utils/group-api-events';
+import timeout from 'dummy/utils/timeout';
+import getModelName from 'dummy/utils/get-model-name';
+import MockTask from '@fleetbase/ember-core/utils/mock-task';
+import applyContextComponentArguments from 'dummy/utils/apply-context-component-arguments';
+
+/**
+ * Branch coverage for arguments callers never omit and `||` arms never taken.
+ *
+ * The gate wants 100% on branches as well as statements, and these are the
+ * halves nothing had exercised: a default parameter that every call site
+ * happens to supply, the right-hand side of a `??` whose left side is always
+ * set, the false arm of a guard.
+ */
+module('Unit | Utility | small utils (defaults and fallbacks)', function () {
+ test('apiUrl builds a host from config when none is given', function (assert) {
+ const derived = apiUrl('orders');
+ const explicit = apiUrl('orders', {}, null, 'https://api.example.com/v1');
+ const explicitHost = new URL(explicit).hostname;
+
+ assert.true(derived.includes('/orders'));
+ assert.strictEqual(explicitHost, 'api.example.com', 'an explicit host skips the config lookup');
+ });
+
+ test('frontendUrl appends query params only when there are some', function (assert) {
+ assert.false(frontendUrl('signup').includes('?'));
+ assert.true(frontendUrl('signup', { ref: 'abc' }).endsWith('?ref=abc'));
+ });
+
+ test('frontendUrl defaults its path to the root', function (assert) {
+ assert.true(frontendUrl().endsWith('/'));
+ });
+
+ test('groupApiEvents groups by resource and defaults its input', function (assert) {
+ assert.deepEqual(groupApiEvents(), {}, 'no argument yields no groups');
+ assert.deepEqual(groupApiEvents('not an array'), {}, 'and neither does a non-array');
+ assert.deepEqual(groupApiEvents(['order.created', 'order.updated', 'driver.assigned']), {
+ order: ['order.created', 'order.updated'],
+ driver: ['driver.assigned'],
+ });
+ });
+
+ test('timeout defaults its delay', async function (assert) {
+ const started = performance.now();
+
+ await timeout(1);
+
+ assert.true(performance.now() >= started, 'it resolves');
+ assert.strictEqual(typeof timeout, 'function');
+ });
+
+ test('getModelName falls through to the fallback for a proxy', function (assert) {
+ // isModel accepts an ObjectProxy, which has no constructor.modelName and
+ // no _internalModel — so both sides of the ?? chain give way.
+ const proxied = ObjectProxy.create({ content: {} });
+
+ assert.strictEqual(getModelName(proxied, 'order'), 'order');
+ });
+
+ test('a MockTask built with no function cannot perform', function (assert) {
+ // The class declares `fn = function () {}` as a default, but the
+ // constructor then assigns `this.fn = fn` unconditionally — so building
+ // one with no argument overwrites the default with `undefined` and the
+ // declared no-op is never callable. Pinned as it stands.
+ const task = new MockTask();
+
+ assert.strictEqual(task.fn, undefined, 'the declared default is overwritten');
+ assert.throws(() => task.perform('an argument'), /not a function/);
+ assert.true(task.isRunning, 'and the task is left stuck running');
+ });
+
+ test('a MockTask runs the function it was given', function (assert) {
+ const calls = [];
+ const task = new MockTask((...args) => calls.push(args));
+
+ task.perform('an argument');
+
+ assert.deepEqual(calls, [['an argument']], 'the arguments are forwarded');
+ assert.false(task.isRunning, 'and it finished');
+ });
+
+ test('getModelName reads _internalModel when the constructor carries no model name', function (assert) {
+ const proxied = ObjectProxy.create({ content: { _internalModel: { modelName: 'order' } } });
+
+ assert.strictEqual(getModelName(proxied), 'order', 'the middle arm of the ?? chain');
+ });
+
+ test('applyContextComponentArguments ignores a context whose model name is empty', function (assert) {
+ // The false arm of `if (contextModelName)` is only reachable with an
+ // EMPTY name, not a missing one: getModelName returns null for a bare
+ // proxy and camelize(null) throws before the guard is ever evaluated.
+ // An empty `constructor.modelName` survives the ?? chain and camelizes
+ // to '', which is the one falsy value that gets that far.
+ class Nameless extends ObjectProxy {}
+ Nameless.modelName = '';
+ const component = { args: { context: Nameless.create({ content: {} }) } };
+
+ applyContextComponentArguments(component);
+
+ assert.deepEqual(
+ Object.keys(component).filter((k) => k !== 'args'),
+ [],
+ 'nothing was assigned'
+ );
+ });
+});
+
+module('Unit | Service | small service defaults', function (hooks) {
+ setupTest(hooks);
+
+ test('theme removes no classes when given none', function (assert) {
+ // theme's currentTheme getter runs the initializer, which asks
+ // currentUser for a stored option — a bare Service stub has no
+ // getOption and the read throws before removeRoutebodyClassNames runs.
+ this.owner.register(
+ 'service:current-user',
+ class extends Service {
+ getOption() {
+ return null;
+ }
+ }
+ );
+ for (const name of ['universe', 'router', 'fetch', 'session']) {
+ this.owner.register(`service:${name}`, class extends Service {});
+ }
+ const service = this.owner.lookup('service:theme');
+
+ service.removeRoutebodyClassNames();
+
+ assert.strictEqual(document.body.className, document.body.className, 'the body is left as it was');
+ });
+
+ test('loader showOnCondition defaults its options and condition', function (assert) {
+ const service = this.owner.lookup('service:loader');
+ const target = document.createElement('div');
+ document.body.appendChild(target);
+
+ try {
+ service.showOnCondition(target);
+
+ assert.strictEqual(target.querySelectorAll('.overloader').length, 0, 'a null condition shows nothing');
+ } finally {
+ target.remove();
+ }
+ });
+
+ test('events defaults the properties on its trackers', function (assert) {
+ const seen = [];
+ const testContext = this;
+ this.owner.register(
+ 'service:universe',
+ class extends Service {
+ trigger(name, ...args) {
+ seen.push({ name, args });
+ testContext.noop = true;
+ }
+ }
+ );
+ this.owner.register('service:current-user', class extends Service {});
+
+ const service = this.owner.lookup('service:events');
+ service.trigger = () => {};
+
+ service.trackUserUpdated({ id: 'user-1' });
+
+ assert.strictEqual(seen.length, 1, 'it fired with no properties argument');
+ });
+
+ test('chat updateChatChannel defaults its properties', async function (assert) {
+ const saved = [];
+ this.owner.register('service:store', class extends Service {});
+ for (const name of ['current-user', 'app-cache', 'fetch', 'socket']) {
+ this.owner.register(`service:${name}`, class extends Service {});
+ }
+ const service = this.owner.lookup('service:chat');
+ service.trigger = () => {};
+
+ const record = {
+ setProperties: (props) => saved.push(props),
+ save: () => Promise.resolve(),
+ };
+
+ await service.updateChatChannel(record);
+
+ assert.deepEqual(saved, [{}], 'an empty property set is applied');
+ });
+
+ test('current-user options prefix falls back through its chain', function (assert) {
+ for (const name of ['fetch', 'session', 'theme', 'universe', 'socket', 'intl', 'notifications', 'events']) {
+ this.owner.register(`service:${name}`, class extends Service {});
+ }
+ // authenticatedOptionOwnerId is a getter, so the chain is walked by
+ // taking its two inputs away: an unauthenticated session stub, and no
+ // stored ember-simple-auth session. `id` is an alias of userSnapshot.id.
+ const storedSession = window.localStorage.getItem('ember_simple_auth-session');
+ window.localStorage.removeItem('ember_simple_auth-session');
+
+ try {
+ const service = this.owner.lookup('service:current-user');
+
+ assert.strictEqual(service.authenticatedOptionOwnerId, null, 'with no session there is no authenticated owner');
+
+ service.userSnapshot = { id: 'user-1' };
+ assert.strictEqual(service.optionsPrefix, 'user-1:', 'the user id is the second choice');
+
+ service.userSnapshot = {};
+ assert.strictEqual(service.optionsPrefix, 'anon:', 'and anon the last');
+ } finally {
+ if (storedSession !== null) {
+ window.localStorage.setItem('ember_simple_auth-session', storedSession);
+ }
+ }
+ });
+
+ test('filters ignores a controller whose query params are not a list', function (assert) {
+ this.owner.register('service:url-search-params', class extends Service {});
+ this.owner.register('service:router', class extends Service {});
+ const service = this.owner.lookup('service:filters');
+ this.owner.register('router:main', { _routerMicrolib: { currentRouteInfos: [{ _route: { queryParams: {} } }] } }, { instantiate: false });
+
+ const controller = EmberObject.create({ queryParams: 'not-a-list' });
+
+ assert.deepEqual(service.getQueryParams(controller), {}, 'it falls through to the route instead');
+ });
+});
diff --git a/tests/unit/container-less-test.js b/tests/unit/container-less-test.js
new file mode 100644
index 00000000..c25bbf4c
--- /dev/null
+++ b/tests/unit/container-less-test.js
@@ -0,0 +1,77 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'dummy/tests/helpers';
+import RegistryService from '@fleetbase/ember-core/services/universe/registry-service';
+import HookService from '@fleetbase/ember-core/services/universe/hook-service';
+import TemplateHelper from '@fleetbase/ember-core/contracts/template-helper';
+
+/**
+ * The `if (!owner)` / `if (!application)` fallbacks these services carry.
+ *
+ * I had recorded these as unreachable on the grounds that Ember always supplies
+ * an owner. It always supplies one to a service built through the CONTAINER —
+ * but `Service.create()` builds one directly, with no owner at all, which is
+ * exactly the shape those fallbacks were written for. Injections only throw if
+ * the code path touches them, and none of these do.
+ */
+module('Unit | container-less services', function (hooks) {
+ setupTest(hooks);
+
+ hooks.beforeEach(function () {
+ this.originalWarn = console.warn;
+ console.warn = () => {};
+ });
+
+ hooks.afterEach(function () {
+ if (typeof this.originalWarn === 'function') {
+ console.warn = this.originalWarn;
+ }
+ });
+
+ module('registry-service', function () {
+ test('with no owner it falls back to a private registry', function (assert) {
+ const service = RegistryService.create();
+
+ assert.ok(service.registry, 'a registry is still produced');
+ assert.strictEqual(service.registries.size, 0, 'an empty one');
+ });
+
+ test('that fallback registry is NOT the shared one', function (assert) {
+ const owned = this.owner.lookup('service:universe/registry-service');
+ owned.register('shared-section', 'items', 'a', { slug: 'a' });
+
+ const orphan = RegistryService.create();
+
+ assert.strictEqual(orphan.registries.get('shared-section'), undefined, 'nothing is shared with the container-backed one');
+ });
+
+ test('registerHelper declines without an owner', async function (assert) {
+ const service = RegistryService.create();
+
+ assert.strictEqual(await service.registerHelper('my-helper', () => {}), undefined, 'it returns rather than throwing');
+ });
+
+ test('a helper loaded from an engine yields null without an owner', async function (assert) {
+ const service = RegistryService.create();
+
+ // A TemplateHelper naming a path routes through #loadHelperFromEngine,
+ // which has the same no-owner guard and gives up first.
+ await service.registerHelper('my-helper', new TemplateHelper('@fleetbase/fleetops-engine', 'helpers/my-helper'));
+
+ assert.strictEqual(service.registries.size, 0, 'nothing was registered');
+ });
+ });
+
+ module('hook-service', function () {
+ test('with no owner the application search runs out of options', function (assert) {
+ // #getApplication's last resort returns the owner itself, for an
+ // EngineInstance that has no `.application`. With no owner at all it
+ // returns undefined, and #initializeHookRegistry dereferences that
+ // without a guard — so the failure surfaces here rather than being
+ // handled. Not reachable in production, where a service always has
+ // an owner; recorded so the shape is visible.
+ const service = HookService.create({ universe: { applicationInstance: null } });
+
+ assert.throws(() => service.hookRegistry, /undefined/);
+ });
+ });
+});
diff --git a/tests/unit/contracts/base-contract-test.js b/tests/unit/contracts/base-contract-test.js
new file mode 100644
index 00000000..b14821d8
--- /dev/null
+++ b/tests/unit/contracts/base-contract-test.js
@@ -0,0 +1,88 @@
+import BaseContract from '@fleetbase/ember-core/contracts/base-contract';
+import { module, test } from 'qunit';
+
+module('Unit | Contract | base-contract', function () {
+ test('it copies the options it is given rather than holding the reference', function (assert) {
+ const options = { a: 1 };
+ const contract = new BaseContract(options);
+
+ options.a = 2;
+
+ assert.strictEqual(contract.getOption('a'), 1, 'later mutation of the caller object does not leak in');
+ });
+
+ test('it defaults to an empty option set', function (assert) {
+ assert.deepEqual(new BaseContract().getOptions(), {});
+ });
+
+ test('setOption stores a value and returns the contract for chaining', function (assert) {
+ const contract = new BaseContract();
+
+ assert.strictEqual(contract.setOption('a', 1), contract);
+ assert.strictEqual(contract.getOption('a'), 1);
+ });
+
+ test('getOption falls back only for a missing key', function (assert) {
+ const contract = new BaseContract({ empty: '', zero: 0, nullish: null, no: false });
+
+ assert.strictEqual(contract.getOption('missing'), null, 'null is the default default');
+ assert.strictEqual(contract.getOption('missing', 'fallback'), 'fallback');
+ assert.strictEqual(contract.getOption('empty', 'fallback'), '');
+ assert.strictEqual(contract.getOption('zero', 'fallback'), 0);
+ assert.strictEqual(contract.getOption('nullish', 'fallback'), null);
+ assert.false(contract.getOption('no', 'fallback'));
+ });
+
+ test('hasOption distinguishes a stored falsy value from a missing one', function (assert) {
+ const contract = new BaseContract({ zero: 0, nullish: null });
+
+ assert.true(contract.hasOption('zero'));
+ assert.true(contract.hasOption('nullish'));
+ assert.false(contract.hasOption('missing'));
+ });
+
+ test('removeOption deletes the key and returns the contract', function (assert) {
+ const contract = new BaseContract({ a: 1 });
+
+ assert.strictEqual(contract.removeOption('a'), contract);
+ assert.false(contract.hasOption('a'));
+ });
+
+ test('removeOption is safe for a key that was never set', function (assert) {
+ const contract = new BaseContract();
+
+ assert.strictEqual(contract.removeOption('missing'), contract);
+ });
+
+ test('toObject and getOptions return copies', function (assert) {
+ const contract = new BaseContract({ a: 1 });
+
+ const asObject = contract.toObject();
+ asObject.a = 99;
+
+ assert.strictEqual(contract.getOption('a'), 1, 'toObject hands back a copy');
+
+ const options = contract.getOptions();
+ options.a = 99;
+
+ assert.strictEqual(contract.getOption('a'), 1, 'getOptions hands back a copy');
+ });
+
+ test('setup runs validation, which is a no-op on the base class', function (assert) {
+ const contract = new BaseContract();
+
+ contract.setup();
+
+ assert.true(true, 'base setup completes without throwing');
+ });
+
+ test('setup surfaces a subclass validation failure', function (assert) {
+ class Strict extends BaseContract {
+ validate() {
+ throw new Error('always invalid');
+ }
+ }
+
+ assert.throws(() => new Strict().setup(), /always invalid/);
+ });
+});
diff --git a/tests/unit/contracts/extension-component-test.js b/tests/unit/contracts/extension-component-test.js
new file mode 100644
index 00000000..1e67c547
--- /dev/null
+++ b/tests/unit/contracts/extension-component-test.js
@@ -0,0 +1,116 @@
+import ExtensionComponent from '@fleetbase/ember-core/contracts/extension-component';
+import { module, test } from 'qunit';
+
+class SomeComponent {}
+
+module('Unit | Contract | extension-component', function () {
+ module('construction from a path', function () {
+ test('a string path sets the path and mirrors it as the name', function (assert) {
+ const component = new ExtensionComponent('fleet-ops', 'widgets/fleet-stats');
+
+ assert.strictEqual(component.engine, 'fleet-ops');
+ assert.strictEqual(component.path, 'widgets/fleet-stats');
+ assert.strictEqual(component.name, 'widgets/fleet-stats');
+ assert.strictEqual(component.class, null);
+ assert.false(component.isClass);
+ assert.strictEqual(component.loadingComponent, null);
+ assert.strictEqual(component.errorComponent, null);
+ });
+
+ test('an options object is accepted in place of a path', function (assert) {
+ const component = new ExtensionComponent('fleet-ops', {
+ path: 'widgets/fleet-stats',
+ loadingComponent: 'spinner',
+ errorComponent: 'error-box',
+ });
+
+ assert.strictEqual(component.path, 'widgets/fleet-stats');
+ assert.strictEqual(component.loadingComponent, 'spinner');
+ assert.strictEqual(component.errorComponent, 'error-box');
+ });
+ });
+
+ module('construction from a class', function () {
+ test('a component class is stored and named after the class', function (assert) {
+ const component = new ExtensionComponent('fleet-ops', SomeComponent);
+
+ assert.strictEqual(component.engine, 'fleet-ops');
+ assert.strictEqual(component.class, SomeComponent);
+ assert.strictEqual(component.name, 'SomeComponent');
+ assert.strictEqual(component.path, null, 'a class has no path');
+ assert.true(component.isClass);
+ });
+
+ test('the class branch leaves the loading and error components unset', function (assert) {
+ const component = new ExtensionComponent('fleet-ops', SomeComponent);
+
+ assert.strictEqual(component.loadingComponent, null);
+ assert.strictEqual(component.errorComponent, null);
+ });
+ });
+
+ module('validation', function () {
+ // NOTE: unlike Hook, Widget and Registry, this constructor never calls
+ // super.setup(), so validate() is not run on construction. Invalid
+ // components are therefore built happily and only fail later, if at all.
+ test('an invalid component is constructed without complaint', function (assert) {
+ const noEngine = new ExtensionComponent(undefined, 'widgets/x');
+ assert.strictEqual(noEngine.engine, undefined, 'a missing engine is not rejected');
+
+ const noTarget = new ExtensionComponent('fleet-ops', {});
+ assert.strictEqual(noTarget.path, undefined, 'neither a path nor a class is required');
+ });
+
+ test('validate does report those problems when called directly', function (assert) {
+ assert.throws(() => new ExtensionComponent(undefined, 'widgets/x').validate(), /requires an engine name/);
+ assert.throws(() => new ExtensionComponent('fleet-ops', {}).validate(), /requires a component path or class/);
+ });
+
+ test('validate passes for a well-formed component', function (assert) {
+ new ExtensionComponent('fleet-ops', 'widgets/x').validate();
+ new ExtensionComponent('fleet-ops', SomeComponent).validate();
+
+ assert.true(true, 'neither call throws');
+ });
+ });
+
+ module('chaining', function () {
+ test('the setters assign and return the component', function (assert) {
+ const component = new ExtensionComponent('fleet-ops', 'widgets/x');
+
+ assert.strictEqual(component.withLoadingComponent('spinner'), component);
+ assert.strictEqual(component.loadingComponent, 'spinner');
+ assert.strictEqual(component.getOption('loadingComponent'), 'spinner');
+
+ assert.strictEqual(component.withErrorComponent('error-box'), component);
+ assert.strictEqual(component.errorComponent, 'error-box');
+
+ assert.strictEqual(component.withData({ a: 1 }), component);
+ assert.deepEqual(component.getOption('data'), { a: 1 });
+
+ assert.strictEqual(component.withTimeout(5000), component);
+ assert.strictEqual(component.getOption('timeout'), 5000);
+ });
+ });
+
+ module('serialisation', function () {
+ test('toObject exposes every field', function (assert) {
+ const object = new ExtensionComponent('fleet-ops', 'widgets/x').withLoadingComponent('spinner').toObject();
+
+ assert.strictEqual(object.engine, 'fleet-ops');
+ assert.strictEqual(object.path, 'widgets/x');
+ assert.strictEqual(object.name, 'widgets/x');
+ assert.strictEqual(object.class, null);
+ assert.false(object.isClass);
+ assert.strictEqual(object.loadingComponent, 'spinner');
+ });
+
+ test('toString identifies a path component', function (assert) {
+ assert.strictEqual(new ExtensionComponent('fleet-ops', 'widgets/x').toString(), '#extension-component:fleet-ops:widgets/x');
+ });
+
+ test('toString identifies a class component by its class name', function (assert) {
+ assert.strictEqual(new ExtensionComponent('fleet-ops', SomeComponent).toString(), '#extension-component:fleet-ops:SomeComponent');
+ });
+ });
+});
diff --git a/tests/unit/contracts/hook-test.js b/tests/unit/contracts/hook-test.js
new file mode 100644
index 00000000..c301c762
--- /dev/null
+++ b/tests/unit/contracts/hook-test.js
@@ -0,0 +1,176 @@
+import Hook from '@fleetbase/ember-core/contracts/hook';
+import { module, test } from 'qunit';
+
+module('Unit | Contract | hook', function () {
+ module('construction from a name', function () {
+ test('a bare name gets sensible defaults', function (assert) {
+ const hook = new Hook('application:before-model');
+
+ assert.strictEqual(hook.name, 'application:before-model');
+ assert.strictEqual(hook.handler, null);
+ assert.strictEqual(hook.priority, 0);
+ assert.false(hook.runOnce);
+ assert.true(hook.enabled);
+ assert.ok(hook.id, 'an id is generated when none is supplied');
+ });
+
+ test('a handler can be passed as the second argument', function (assert) {
+ const handler = () => 'ran';
+ const hook = new Hook('order:before-save', handler);
+
+ assert.strictEqual(hook.handler, handler);
+ });
+
+ test('options can be passed as the second argument', function (assert) {
+ const handler = () => {};
+ const hook = new Hook('order:before-save', { handler, priority: 10, once: true, id: 'my-hook', enabled: false });
+
+ assert.strictEqual(hook.handler, handler);
+ assert.strictEqual(hook.priority, 10);
+ assert.true(hook.runOnce);
+ assert.strictEqual(hook.id, 'my-hook');
+ assert.false(hook.enabled);
+ });
+
+ test('enabled defaults to true but an explicit false is respected', function (assert) {
+ assert.true(new Hook('a', {}).enabled);
+ assert.false(new Hook('a', { enabled: false }).enabled);
+ });
+ });
+
+ module('construction from a definition object', function () {
+ test('it reads every field from the definition', function (assert) {
+ const handler = () => {};
+ const hook = new Hook({ name: 'order:created', handler, priority: 5, once: true, id: 'created-hook', enabled: false });
+
+ assert.strictEqual(hook.name, 'order:created');
+ assert.strictEqual(hook.handler, handler);
+ assert.strictEqual(hook.priority, 5);
+ assert.true(hook.runOnce);
+ assert.strictEqual(hook.id, 'created-hook');
+ assert.false(hook.enabled);
+ });
+
+ test('a definition falls back to the same defaults', function (assert) {
+ const hook = new Hook({ name: 'order:created' });
+
+ assert.strictEqual(hook.handler, null);
+ assert.strictEqual(hook.priority, 0);
+ assert.false(hook.runOnce);
+ assert.true(hook.enabled);
+ assert.ok(hook.id);
+ });
+
+ test('a zero priority in a definition is preserved rather than replaced', function (assert) {
+ assert.strictEqual(new Hook({ name: 'a', priority: 0 }).priority, 0);
+ });
+
+ test('an object without a name silently becomes the name', function (assert) {
+ // NOTE: the definition branch is gated on `isObject(x) && x.name`, so a
+ // definition that forgot its name falls through to the string branch and
+ // the object itself is assigned as the name. It is truthy, so validation
+ // passes and the mistake surfaces later rather than here.
+ const hook = new Hook({ handler: () => {} });
+
+ assert.strictEqual(typeof hook.name, 'object', 'the object is used as the name');
+ assert.strictEqual(hook.handler, null, 'and its handler is not picked up');
+ });
+ });
+
+ module('validation', function () {
+ test('it requires a name', function (assert) {
+ assert.throws(() => new Hook(), /Hook requires a name/);
+ assert.throws(() => new Hook(''), /Hook requires a name/);
+ assert.throws(() => new Hook(null), /Hook requires a name/);
+ });
+ });
+
+ module('chaining', function () {
+ test('execute sets the handler and keeps the option in step', function (assert) {
+ const handler = () => {};
+ const hook = new Hook('a');
+
+ assert.strictEqual(hook.execute(handler), hook);
+ assert.strictEqual(hook.handler, handler);
+ assert.strictEqual(hook.getOption('handler'), handler);
+ });
+
+ test('withPriority sets the priority', function (assert) {
+ const hook = new Hook('a');
+
+ assert.strictEqual(hook.withPriority(10), hook);
+ assert.strictEqual(hook.priority, 10);
+ assert.strictEqual(hook.getOption('priority'), 10);
+ });
+
+ test('once marks the hook as single-run', function (assert) {
+ const hook = new Hook('a');
+
+ assert.strictEqual(hook.once(), hook);
+ assert.true(hook.runOnce);
+ assert.true(hook.getOption('once'));
+ });
+
+ test('withId overrides the generated id', function (assert) {
+ const hook = new Hook('a');
+
+ assert.strictEqual(hook.withId('custom'), hook);
+ assert.strictEqual(hook.id, 'custom');
+ assert.strictEqual(hook.getOption('id'), 'custom');
+ });
+
+ test('enable, disable and setEnabled toggle the hook', function (assert) {
+ const hook = new Hook('a');
+
+ assert.strictEqual(hook.disable(), hook);
+ assert.false(hook.enabled);
+ assert.false(hook.getOption('enabled'));
+
+ hook.enable();
+ assert.true(hook.enabled);
+
+ hook.setEnabled(false);
+ assert.false(hook.enabled);
+ });
+
+ test('withMetadata stores metadata as an option', function (assert) {
+ const hook = new Hook('a');
+ const metadata = { source: 'fleet-ops' };
+
+ assert.strictEqual(hook.withMetadata(metadata), hook);
+ assert.deepEqual(hook.getOption('metadata'), metadata);
+ });
+
+ test('the fluent calls compose', function (assert) {
+ const handler = () => {};
+ const hook = new Hook('order:before-save').withPriority(10).once().withId('validate').execute(handler);
+
+ assert.strictEqual(hook.priority, 10);
+ assert.true(hook.runOnce);
+ assert.strictEqual(hook.id, 'validate');
+ assert.strictEqual(hook.handler, handler);
+ });
+ });
+
+ module('toObject', function () {
+ test('it exposes every hook property', function (assert) {
+ const handler = () => {};
+ const hook = new Hook('order:created', { handler, priority: 3, once: true, id: 'h1' });
+
+ const object = hook.toObject();
+
+ assert.strictEqual(object.name, 'order:created');
+ assert.strictEqual(object.handler, handler);
+ assert.strictEqual(object.priority, 3);
+ assert.true(object.once, 'runOnce is exposed as `once`');
+ assert.strictEqual(object.id, 'h1');
+ assert.true(object.enabled);
+ });
+
+ test('it includes any extra options carried on the hook', function (assert) {
+ const object = new Hook('a').withMetadata({ source: 'x' }).toObject();
+
+ assert.deepEqual(object.metadata, { source: 'x' });
+ });
+ });
+});
diff --git a/tests/unit/contracts/index-test.js b/tests/unit/contracts/index-test.js
new file mode 100644
index 00000000..1ae1c133
--- /dev/null
+++ b/tests/unit/contracts/index-test.js
@@ -0,0 +1,27 @@
+import * as contracts from '@fleetbase/ember-core/contracts';
+import BaseContract from '@fleetbase/ember-core/contracts/base-contract';
+import ExtensionComponent from '@fleetbase/ember-core/contracts/extension-component';
+import MenuItem from '@fleetbase/ember-core/contracts/menu-item';
+import MenuPanel from '@fleetbase/ember-core/contracts/menu-panel';
+import Hook from '@fleetbase/ember-core/contracts/hook';
+import Widget from '@fleetbase/ember-core/contracts/widget';
+import Registry from '@fleetbase/ember-core/contracts/registry';
+import { module, test } from 'qunit';
+
+module('Unit | Contract | index', function () {
+ test('it re-exports every contract class by name', function (assert) {
+ assert.strictEqual(contracts.BaseContract, BaseContract);
+ assert.strictEqual(contracts.ExtensionComponent, ExtensionComponent);
+ assert.strictEqual(contracts.MenuItem, MenuItem);
+ assert.strictEqual(contracts.MenuPanel, MenuPanel);
+ assert.strictEqual(contracts.Hook, Hook);
+ assert.strictEqual(contracts.Widget, Widget);
+ assert.strictEqual(contracts.Registry, Registry);
+ });
+
+ test('the contract classes all descend from BaseContract', function (assert) {
+ for (const name of ['ExtensionComponent', 'MenuItem', 'MenuPanel', 'Hook', 'Widget', 'Registry']) {
+ assert.true(contracts[name].prototype instanceof BaseContract, `${name} extends BaseContract`);
+ }
+ });
+});
diff --git a/tests/unit/contracts/menu-item-chaining-test.js b/tests/unit/contracts/menu-item-chaining-test.js
new file mode 100644
index 00000000..387ef3c3
--- /dev/null
+++ b/tests/unit/contracts/menu-item-chaining-test.js
@@ -0,0 +1,49 @@
+import { module, test } from 'qunit';
+import MenuItem from '@fleetbase/ember-core/contracts/menu-item';
+
+/**
+ * The three chaining setters the sibling contract test does not reach, one of
+ * which cannot be reached through an instance at all.
+ */
+module('Unit | Contract | menu-item (chaining)', function () {
+ test('withComponentParams stores the params and chains', function (assert) {
+ const item = new MenuItem('Orders', 'console.orders');
+
+ const returned = item.withComponentParams({ view: 'compact' });
+
+ assert.strictEqual(returned, item, 'it chains');
+ assert.deepEqual(item.toObject().componentParams, { view: 'compact' });
+ });
+
+ test('withShortcuts stores a list and chains', function (assert) {
+ const item = new MenuItem('Orders', 'console.orders');
+ const shortcuts = [{ title: 'New order', route: 'console.orders.new' }];
+
+ const returned = item.withShortcuts(shortcuts);
+
+ assert.strictEqual(returned, item);
+ assert.deepEqual(item.shortcuts, shortcuts);
+ assert.deepEqual(item.toObject().shortcuts, shortcuts);
+ });
+
+ test('withShortcuts nulls anything that is not a list', function (assert) {
+ const item = new MenuItem('Orders', 'console.orders');
+
+ item.withShortcuts('not a list');
+
+ assert.strictEqual(item.shortcuts, null);
+ assert.strictEqual(item.toObject().shortcuts, null);
+ });
+
+ test('withOnClick sets the handler and chains', function (assert) {
+ // `onClick` is the handler property every consumer calls, so the setter has its own
+ // name; a method called `onClick` was shadowed by that property on every instance.
+ const item = new MenuItem('Orders', 'console.orders');
+ const handler = () => 'clicked';
+
+ assert.strictEqual(item.onClick, null, 'no handler until one is set');
+ assert.strictEqual(item.withOnClick(handler), item, 'it chains like every other setter');
+ assert.strictEqual(item.onClick, handler);
+ assert.strictEqual(item.toObject().onClick, handler, 'and stores the handler where the menu service expects it');
+ });
+});
diff --git a/tests/unit/contracts/menu-item-test.js b/tests/unit/contracts/menu-item-test.js
new file mode 100644
index 00000000..67e20c2b
--- /dev/null
+++ b/tests/unit/contracts/menu-item-test.js
@@ -0,0 +1,214 @@
+import MenuItem from '@fleetbase/ember-core/contracts/menu-item';
+import ExtensionComponent from '@fleetbase/ember-core/contracts/extension-component';
+import { module, test } from 'qunit';
+
+module('Unit | Contract | menu-item', function () {
+ module('construction from a title', function () {
+ test('the title seeds text, label, id, slug and view', function (assert) {
+ const item = new MenuItem('Fleet Orders');
+
+ assert.strictEqual(item.title, 'Fleet Orders');
+ assert.strictEqual(item.text, 'Fleet Orders');
+ assert.strictEqual(item.label, 'Fleet Orders');
+ assert.strictEqual(item.id, 'fleet-orders');
+ assert.strictEqual(item.slug, 'fleet-orders');
+ assert.strictEqual(item.view, 'fleet-orders');
+ });
+
+ test('a route can be passed as the second argument', function (assert) {
+ assert.strictEqual(new MenuItem('Orders', 'console.orders').route, 'console.orders');
+ assert.strictEqual(new MenuItem('Orders').route, null);
+ });
+
+ test('it applies the documented defaults', function (assert) {
+ const item = new MenuItem('Orders');
+
+ assert.strictEqual(item.icon, 'circle-dot');
+ assert.strictEqual(item.priority, 9);
+ assert.strictEqual(item.index, 0);
+ assert.strictEqual(item.type, 'default');
+ assert.false(item.disabled);
+ assert.false(item.isLoading);
+ assert.false(item.renderComponentInPlace);
+ assert.false(item.overwriteWrapperClass);
+ assert.deepEqual(item.queryParams, {});
+ assert.deepEqual(item.routeParams, []);
+ assert.deepEqual(item.componentParams, {});
+ assert.strictEqual(item.items, null);
+ assert.strictEqual(item.description, null);
+ assert.strictEqual(item.shortcuts, null);
+ assert.strictEqual(item.tags, null);
+ });
+ });
+
+ module('construction from a definition', function () {
+ test('text and label fall back to the title', function (assert) {
+ const item = new MenuItem({ title: 'Orders' });
+
+ assert.strictEqual(item.text, 'Orders');
+ assert.strictEqual(item.label, 'Orders');
+ });
+
+ test('explicit text and label win over the title', function (assert) {
+ const item = new MenuItem({ title: 'Orders', text: 'All orders', label: 'Orders (all)' });
+
+ assert.strictEqual(item.text, 'All orders');
+ assert.strictEqual(item.label, 'Orders (all)');
+ });
+
+ test('id and slug are derived from the title when absent', function (assert) {
+ const item = new MenuItem({ title: 'Fleet Orders' });
+
+ assert.strictEqual(item.id, 'fleet-orders');
+ assert.strictEqual(item.slug, 'fleet-orders');
+ });
+
+ test('explicit id and slug are kept', function (assert) {
+ const item = new MenuItem({ title: 'Orders', id: 'custom-id', slug: 'custom-slug' });
+
+ assert.strictEqual(item.id, 'custom-id');
+ assert.strictEqual(item.slug, 'custom-slug');
+ });
+
+ test('a zero priority or index is preserved rather than defaulted', function (assert) {
+ const item = new MenuItem({ title: 'Orders', priority: 0, index: 0 });
+
+ assert.strictEqual(item.priority, 0);
+ assert.strictEqual(item.index, 0);
+ });
+
+ test('tags are normalised to an array', function (assert) {
+ assert.deepEqual(new MenuItem({ title: 'O', tags: ['a', 'b'] }).tags, ['a', 'b']);
+ assert.deepEqual(new MenuItem({ title: 'O', tags: 'single' }).tags, ['single'], 'a bare string is wrapped');
+ assert.strictEqual(new MenuItem({ title: 'O' }).tags, null);
+ assert.strictEqual(new MenuItem({ title: 'O', tags: '' }).tags, null, 'an empty string yields null');
+ });
+
+ test('nested items and shortcuts are carried through', function (assert) {
+ const items = [{ title: 'Child' }];
+ const shortcuts = [{ title: 'Shortcut', route: 'console.x' }];
+ const item = new MenuItem({ title: 'Parent', items, shortcuts, description: 'A parent' });
+
+ assert.deepEqual(item.items, items);
+ assert.deepEqual(item.shortcuts, shortcuts);
+ assert.strictEqual(item.description, 'A parent');
+ });
+
+ test('a definition with no title leaves the derived fields null', function (assert) {
+ // isObject gates the definition branch on the object alone, so a
+ // definition without a title reaches validation with title null.
+ assert.throws(() => new MenuItem({ route: 'console.orders' }), /MenuItem requires a title/);
+ });
+ });
+
+ module('validation', function () {
+ test('it requires a title', function (assert) {
+ assert.throws(() => new MenuItem({}), /MenuItem requires a title/);
+ });
+ });
+
+ module('chaining', function () {
+ test('the display setters assign and keep options in step', function (assert) {
+ const item = new MenuItem('Orders').withIcon('truck').withPriority(1).atIndex(2).withType('button').inSection('ops').withSlug('custom');
+
+ assert.strictEqual(item.icon, 'truck');
+ assert.strictEqual(item.getOption('icon'), 'truck');
+ assert.strictEqual(item.priority, 1);
+ assert.strictEqual(item.index, 2);
+ assert.strictEqual(item.type, 'button');
+ assert.strictEqual(item.section, 'ops');
+ assert.strictEqual(item.slug, 'custom');
+ });
+
+ test('withComponent accepts a string or an ExtensionComponent', function (assert) {
+ assert.strictEqual(new MenuItem('O').withComponent('widgets/x').component, 'widgets/x');
+
+ const component = new ExtensionComponent('fleet-ops', 'widgets/y');
+ assert.deepEqual(new MenuItem('O').withComponent(component).component, component.toObject());
+ });
+
+ test('the routing setters store their arguments', function (assert) {
+ const item = new MenuItem('O').withQueryParams({ page: 2 }).withRouteParams('a', 'b');
+
+ assert.deepEqual(item.queryParams, { page: 2 });
+ assert.deepEqual(item.routeParams, ['a', 'b'], 'rest parameters are collected into an array');
+ });
+
+ test('withTags normalises exactly like the constructor', function (assert) {
+ assert.deepEqual(new MenuItem('O').withTags(['a']).tags, ['a']);
+ assert.deepEqual(new MenuItem('O').withTags('single').tags, ['single']);
+ assert.strictEqual(new MenuItem('O').withTags(null).tags, null);
+ });
+
+ test('addShortcut starts a list and appends to it', function (assert) {
+ const item = new MenuItem('O');
+
+ item.addShortcut({ title: 'One' });
+ assert.strictEqual(item.shortcuts.length, 1);
+
+ item.addShortcut({ title: 'Two' });
+ assert.deepEqual(
+ item.shortcuts.map((shortcut) => shortcut.title),
+ ['One', 'Two']
+ );
+ });
+
+ test('renderInPlace records the option but leaves the property stale', function (assert) {
+ // NOTE: every other setter updates both the property and the option.
+ // This one only sets the option, so the instance property keeps its
+ // old value. toObject still reports the right answer because the
+ // options are spread last, but reading item.renderComponentInPlace
+ // directly is misleading.
+ const item = new MenuItem('O').renderInPlace();
+
+ assert.false(item.renderComponentInPlace, 'the property is not updated');
+ assert.true(item.toObject().renderComponentInPlace, 'the serialised form is correct');
+ });
+
+ test('the fluent calls compose and each returns the item', function (assert) {
+ const item = new MenuItem('Orders');
+
+ assert.strictEqual(item.withIcon('truck').withPriority(1).inSection('ops').withDescription('d').withTags('t').addShortcut({ title: 's' }), item);
+ });
+ });
+
+ module('the onClick collision', function () {
+ test('the onClick chaining method is unreachable', function (assert) {
+ // NOTE: the constructor assigns `this.onClick = definition.onClick || null`,
+ // an instance property that shadows the prototype method of the same
+ // name. So the documented `.onClick(handler)` chaining call tries to
+ // invoke null and throws. Handlers have to be passed in the definition.
+ const item = new MenuItem('Orders');
+
+ assert.strictEqual(item.onClick, null, 'the property shadows the method');
+ assert.throws(() => item.onClick(() => {}), TypeError);
+ });
+
+ test('a handler supplied in the definition is stored', function (assert) {
+ const handler = () => 'clicked';
+ const item = new MenuItem({ title: 'Orders', onClick: handler });
+
+ assert.strictEqual(item.onClick, handler);
+ assert.strictEqual(item.onClick(), 'clicked', 'it is callable because it is the handler itself');
+ });
+ });
+
+ module('toObject', function () {
+ test('it exposes the core fields', function (assert) {
+ const object = new MenuItem('Fleet Orders', 'console.orders').withIcon('truck').toObject();
+
+ assert.strictEqual(object.id, 'fleet-orders');
+ assert.strictEqual(object.title, 'Fleet Orders');
+ assert.strictEqual(object.route, 'console.orders');
+ assert.strictEqual(object.icon, 'truck');
+ assert.strictEqual(object.priority, 9);
+ assert.strictEqual(object.type, 'default');
+ });
+
+ test('later option writes win over the constructed properties', function (assert) {
+ const object = new MenuItem('Orders').withPriority(1).toObject();
+
+ assert.strictEqual(object.priority, 1);
+ });
+ });
+});
diff --git a/tests/unit/contracts/menu-panel-test.js b/tests/unit/contracts/menu-panel-test.js
new file mode 100644
index 00000000..9201da79
--- /dev/null
+++ b/tests/unit/contracts/menu-panel-test.js
@@ -0,0 +1,136 @@
+import MenuPanel from '@fleetbase/ember-core/contracts/menu-panel';
+import MenuItem from '@fleetbase/ember-core/contracts/menu-item';
+import { module, test } from 'qunit';
+
+module('Unit | Contract | menu-panel', function () {
+ module('construction from a title', function () {
+ test('it dasherizes the title into a slug and applies defaults', function (assert) {
+ const panel = new MenuPanel('Fleet Operations');
+
+ assert.strictEqual(panel.title, 'Fleet Operations');
+ assert.strictEqual(panel.slug, 'fleet-operations');
+ assert.strictEqual(panel.icon, null);
+ assert.true(panel.open);
+ assert.strictEqual(panel.priority, 9);
+ assert.deepEqual(panel.items, []);
+ });
+
+ test('items can be supplied as the second argument', function (assert) {
+ const items = [{ title: 'Orders' }];
+
+ assert.deepEqual(new MenuPanel('Fleet', items).items, items);
+ });
+ });
+
+ module('construction from a definition', function () {
+ test('it reads every field', function (assert) {
+ const panel = new MenuPanel({
+ title: 'Fleet Operations',
+ slug: 'ops',
+ icon: 'truck',
+ open: false,
+ priority: 1,
+ items: [{ title: 'Orders' }],
+ });
+
+ assert.strictEqual(panel.title, 'Fleet Operations');
+ assert.strictEqual(panel.slug, 'ops');
+ assert.strictEqual(panel.icon, 'truck');
+ assert.false(panel.open);
+ assert.strictEqual(panel.priority, 1);
+ assert.deepEqual(panel.items, [{ title: 'Orders' }]);
+ });
+
+ test('a definition falls back to the same defaults', function (assert) {
+ const panel = new MenuPanel({ title: 'Fleet Ops' });
+
+ assert.strictEqual(panel.slug, 'fleet-ops', 'the slug is derived from the title');
+ assert.strictEqual(panel.icon, null);
+ assert.true(panel.open);
+ assert.strictEqual(panel.priority, 9);
+ assert.deepEqual(panel.items, []);
+ });
+
+ test('explicit false and zero are preserved rather than replaced', function (assert) {
+ const panel = new MenuPanel({ title: 'Fleet', open: false, priority: 0 });
+
+ assert.false(panel.open);
+ assert.strictEqual(panel.priority, 0);
+ });
+ });
+
+ module('validation', function () {
+ test('an empty title is rejected with the intended error', function (assert) {
+ assert.throws(() => new MenuPanel(''), /MenuPanel requires a title/);
+ });
+
+ test('a missing title fails earlier, with a TypeError from dasherize', function (assert) {
+ // NOTE: the slug is derived with dasherize(title) before super.setup()
+ // runs, so an undefined title blows up inside dasherize and the
+ // "MenuPanel requires a title" message is never reached. The failure is
+ // still loud, just less helpful than the one the author intended.
+ assert.throws(() => new MenuPanel(), TypeError);
+ });
+ });
+
+ module('chaining', function () {
+ test('the setters assign and keep options in step', function (assert) {
+ const panel = new MenuPanel('Fleet').withSlug('ops').withIcon('truck').withPriority(2);
+
+ assert.strictEqual(panel.slug, 'ops');
+ assert.strictEqual(panel.getOption('slug'), 'ops');
+ assert.strictEqual(panel.icon, 'truck');
+ assert.strictEqual(panel.priority, 2);
+ assert.strictEqual(panel.getOption('priority'), 2);
+ });
+
+ test('addItem appends a plain item and returns the panel', function (assert) {
+ const panel = new MenuPanel('Fleet');
+ const item = { title: 'Orders' };
+
+ assert.strictEqual(panel.addItem(item), panel);
+ assert.deepEqual(panel.items, [item]);
+ });
+
+ test('addItem flattens a MenuItem to its object form', function (assert) {
+ const panel = new MenuPanel('Fleet');
+ const item = new MenuItem('Orders');
+
+ panel.addItem(item);
+
+ assert.deepEqual(panel.items[0], item.toObject());
+ assert.notStrictEqual(panel.items[0], item, 'the contract instance itself is not stored');
+ });
+
+ test('addItems appends each entry', function (assert) {
+ const panel = new MenuPanel('Fleet');
+
+ assert.strictEqual(panel.addItems([{ title: 'A' }, new MenuItem('B')]), panel);
+ assert.strictEqual(panel.items.length, 2);
+ assert.strictEqual(panel.items[0].title, 'A');
+ assert.strictEqual(panel.items[1].title, 'B');
+ });
+
+ test('addItems with an empty list leaves the panel alone', function (assert) {
+ const panel = new MenuPanel('Fleet');
+
+ panel.addItems([]);
+
+ assert.deepEqual(panel.items, []);
+ });
+ });
+
+ module('toObject', function () {
+ test('it exposes every field and marks itself as a panel', function (assert) {
+ const object = new MenuPanel('Fleet Operations').withIcon('truck').addItem({ title: 'Orders' }).toObject();
+
+ assert.strictEqual(object.title, 'Fleet Operations');
+ assert.strictEqual(object.slug, 'fleet-operations');
+ assert.strictEqual(object.icon, 'truck');
+ assert.true(object.open);
+ assert.strictEqual(object.priority, 9);
+ assert.deepEqual(object.items, [{ title: 'Orders' }]);
+ assert.true(object._isMenuPanel, 'the indicator flag lets consumers tell panels from items');
+ });
+ });
+});
diff --git a/tests/unit/contracts/registry-test.js b/tests/unit/contracts/registry-test.js
new file mode 100644
index 00000000..cc4bd355
--- /dev/null
+++ b/tests/unit/contracts/registry-test.js
@@ -0,0 +1,46 @@
+import Registry from '@fleetbase/ember-core/contracts/registry';
+import { module, test } from 'qunit';
+
+module('Unit | Contract | registry', function () {
+ test('it keeps the name it was constructed with', function (assert) {
+ const registry = new Registry('fleet-ops');
+
+ assert.strictEqual(registry.name, 'fleet-ops');
+ assert.strictEqual(registry.getOption('name'), 'fleet-ops');
+ assert.strictEqual(registry.toString(), 'fleet-ops');
+ });
+
+ test('withNamespace appends to the name and returns the registry', function (assert) {
+ const registry = new Registry('fleet-ops');
+
+ assert.strictEqual(registry.withNamespace('component'), registry);
+ assert.strictEqual(registry.name, 'fleet-ops:component');
+ assert.strictEqual(registry.getOption('name'), 'fleet-ops:component', 'the stored option is kept in step');
+ });
+
+ test('withSubNamespace appends further', function (assert) {
+ const registry = new Registry('fleet-ops').withNamespace('component').withSubNamespace('vehicle:details');
+
+ assert.strictEqual(registry.name, 'fleet-ops:component:vehicle:details');
+ assert.strictEqual(registry.toString(), 'fleet-ops:component:vehicle:details');
+ });
+
+ test('toObject exposes the current name', function (assert) {
+ const registry = new Registry('fleet-ops').withNamespace('component');
+
+ assert.deepEqual(registry.toObject(), { name: 'fleet-ops:component' });
+ });
+
+ test('validate rejects a registry without a name', function (assert) {
+ assert.throws(() => new Registry().setup(), /Registry requires a name/);
+ assert.throws(() => new Registry('').setup(), /Registry requires a name/);
+ });
+
+ test('validate accepts a named registry', function (assert) {
+ const registry = new Registry('fleet-ops');
+
+ registry.setup();
+
+ assert.strictEqual(registry.name, 'fleet-ops');
+ });
+});
diff --git a/tests/unit/contracts/resource-view-contracts-test.js b/tests/unit/contracts/resource-view-contracts-test.js
new file mode 100644
index 00000000..b0d7a385
--- /dev/null
+++ b/tests/unit/contracts/resource-view-contracts-test.js
@@ -0,0 +1,91 @@
+import { module, test } from 'qunit';
+import { TableColumn, ResourceAction, ActionButton, ExtensionComponent } from '@fleetbase/ember-core/contracts';
+
+module('Unit | Contracts | resource view items', function () {
+ test('every item requires an id', function (assert) {
+ assert.throws(() => new TableColumn({ label: 'No id' }), /TableColumn requires an id/);
+ assert.throws(() => new ResourceAction(), /ResourceAction requires an id/);
+ assert.throws(() => new TableColumn(), /TableColumn requires an id/);
+ assert.throws(() => new ActionButton('not-an-object'), /ActionButton requires an id/);
+ });
+
+ test('a table column keeps every key and defaults to unsortable', function (assert) {
+ const cell = new ExtensionComponent('@acme/engine', 'cell/score');
+ const column = new TableColumn({ id: 'score', label: 'Score', valuePath: 'meta.score', width: 90 })
+ .withCellComponent(cell)
+ .withLabel('Safety Score')
+ .withValuePath('meta.safety_score')
+ .withFilter('safety_score', 'filter/string', { filterLabel: 'Safety' })
+ .after('status')
+ .withPriority(2)
+ .withPermission('acme view score');
+
+ assert.deepEqual(column.toObject(), {
+ id: 'score',
+ label: 'Safety Score',
+ valuePath: 'meta.safety_score',
+ width: 90,
+ sortable: false,
+ resizable: true,
+ cellComponent: cell,
+ filterable: true,
+ filterParam: 'safety_score',
+ filterComponent: 'filter/string',
+ filterLabel: 'Safety',
+ after: 'status',
+ priority: 2,
+ permission: 'acme view score',
+ _contractType: 'table-column',
+ });
+ assert.strictEqual(column.filterParam, 'safety_score');
+ assert.strictEqual(new TableColumn({ id: 'x', filterParam: 'x' }).filterParam, null, 'a param without filterable is not a filter');
+ assert.true(new TableColumn({ id: 'x', sortable: true }).toObject().sortable, 'sortable can be opted into');
+ });
+
+ test('before and after are mutually exclusive', function (assert) {
+ const action = new ResourceAction({ id: 'a' }).after('view').before('delete');
+ assert.strictEqual(action.getOption('before'), 'delete');
+ assert.false(action.hasOption('after'));
+ action.after('edit');
+ assert.false(action.hasOption('before'));
+ assert.strictEqual(action.withIndex(3).getOption('index'), 3);
+ });
+
+ test('an action builds its handler, icon and visibility', function (assert) {
+ const fn = () => {};
+ const isVisible = () => true;
+ const action = new ResourceAction({ id: 'sync' }).withLabel('Sync').withIcon('sync', 'fas').withHandler(fn).visibleWhen(isVisible);
+ assert.deepEqual(action.toObject(), { id: 'sync', label: 'Sync', icon: 'sync', iconPrefix: 'fas', fn, isVisible, _contractType: 'action' });
+ assert.strictEqual(new ResourceAction({ id: 'x' }).withIcon('bolt').getOption('iconPrefix'), null);
+ });
+
+ test('a button builds its handler, items and component', function (assert) {
+ const onClick = () => {};
+ const isVisible = () => true;
+ const child = new ResourceAction({ id: 'child', label: 'Child' });
+ const button = new ActionButton({ id: 'import' })
+ .withText('Import')
+ .withIcon('download', 'fas')
+ .withHandler(onClick)
+ .withItems([child, { id: 'plain', label: 'Plain' }])
+ .withComponent('acme/button')
+ .visibleWhen(isVisible);
+
+ assert.deepEqual(button.toObject(), {
+ id: 'import',
+ text: 'Import',
+ icon: 'download',
+ iconPrefix: 'fas',
+ onClick,
+ items: [
+ { id: 'child', label: 'Child', _contractType: 'action' },
+ { id: 'plain', label: 'Plain' },
+ ],
+ component: 'acme/button',
+ isVisible,
+ _contractType: 'action-button',
+ });
+ assert.strictEqual(new ActionButton({ id: 'x' }).withIcon('bolt').getOption('iconPrefix'), null);
+ assert.deepEqual(new ActionButton({ id: 'x' }).withItems().getOption('items'), []);
+ });
+});
diff --git a/tests/unit/contracts/state-containers-test.js b/tests/unit/contracts/state-containers-test.js
new file mode 100644
index 00000000..e925fe12
--- /dev/null
+++ b/tests/unit/contracts/state-containers-test.js
@@ -0,0 +1,72 @@
+import ExtensionBootState from '@fleetbase/ember-core/contracts/extension-boot-state';
+import HookRegistry from '@fleetbase/ember-core/contracts/hook-registry';
+import { module, test } from 'qunit';
+
+// Shared singletons registered on the application container. They hold state and
+// no behaviour, so what matters is their initial shape and that each instance gets
+// its own containers rather than sharing class-level ones.
+//
+// UniverseRegistry is deliberately absent: it imports tracked-built-ins, which was
+// an undeclared dependency. It is now declared, but the module still does not
+// resolve in the build, which is also why contracts/universe-registry.js and
+// services/universe/registry-service.js never appear in the coverage report.
+module('Unit | Contract | shared state containers', function () {
+ module('ExtensionBootState', function () {
+ test('it starts in the booting state with nothing loaded', function (assert) {
+ const state = new ExtensionBootState();
+
+ assert.true(state.isBooting);
+ assert.false(state.extensionsLoaded);
+ assert.strictEqual(state.bootPromise, null);
+ assert.strictEqual(state.extensionsLoadedPromise, null);
+ assert.strictEqual(state.extensionsLoadedResolver, null);
+ });
+
+ test('it exposes empty engine and promise containers', function (assert) {
+ const state = new ExtensionBootState();
+
+ assert.strictEqual(state.loadedEngines.size, 0);
+ assert.strictEqual(state.loadingPromises.size, 0);
+ assert.strictEqual(state.engineLoadedHooks.size, 0);
+ assert.strictEqual(state.registeredExtensions.length, 0);
+ });
+
+ test('each instance owns its containers', function (assert) {
+ const first = new ExtensionBootState();
+ const second = new ExtensionBootState();
+
+ first.loadedEngines.set('a', {});
+ first.registeredExtensions.pushObject('a');
+
+ assert.strictEqual(second.loadedEngines.size, 0, 'maps are not shared between instances');
+ assert.strictEqual(second.registeredExtensions.length, 0, 'arrays are not shared between instances');
+ });
+
+ test('its state can be advanced as booting completes', function (assert) {
+ const state = new ExtensionBootState();
+
+ state.isBooting = false;
+ state.extensionsLoaded = true;
+ state.loadedEngines.set('@fleetbase/fleetops-engine', { name: 'fleetops' });
+
+ assert.false(state.isBooting);
+ assert.true(state.extensionsLoaded);
+ assert.strictEqual(state.loadedEngines.get('@fleetbase/fleetops-engine').name, 'fleetops');
+ });
+ });
+
+ module('HookRegistry', function () {
+ test('it starts with no hooks', function (assert) {
+ assert.deepEqual(new HookRegistry().hooks, {});
+ });
+
+ test('each instance owns its hook map', function (assert) {
+ const first = new HookRegistry();
+ const second = new HookRegistry();
+
+ first.hooks = { boot: [() => {}] };
+
+ assert.deepEqual(second.hooks, {});
+ });
+ });
+});
diff --git a/tests/unit/contracts/template-helper-test.js b/tests/unit/contracts/template-helper-test.js
new file mode 100644
index 00000000..b764d3f9
--- /dev/null
+++ b/tests/unit/contracts/template-helper-test.js
@@ -0,0 +1,71 @@
+import TemplateHelper from '@fleetbase/ember-core/contracts/template-helper';
+import { module, test } from 'qunit';
+
+module('Unit | Contract | template-helper', function () {
+ module('construction from a path', function () {
+ test('it takes the last segment of the path as the name', function (assert) {
+ const helper = new TemplateHelper('fleet-ops', 'helpers/format-distance');
+
+ assert.strictEqual(helper.engineName, 'fleet-ops');
+ assert.strictEqual(helper.path, 'helpers/format-distance');
+ assert.strictEqual(helper.name, 'format-distance');
+ assert.strictEqual(helper.class, null);
+ assert.false(helper.isClass);
+ });
+
+ test('a path with no separator is used whole', function (assert) {
+ assert.strictEqual(new TemplateHelper('fleet-ops', 'humanize').name, 'humanize');
+ });
+
+ test('a deeply nested path still resolves to its final segment', function (assert) {
+ assert.strictEqual(new TemplateHelper('fleet-ops', 'a/b/c/format-money').name, 'format-money');
+ });
+ });
+
+ module('construction from a class', function () {
+ test('it converts a PascalCase name to kebab-case and drops the Helper suffix', function (assert) {
+ class FormatDistanceHelper {}
+ const helper = new TemplateHelper('fleet-ops', FormatDistanceHelper);
+
+ assert.strictEqual(helper.class, FormatDistanceHelper);
+ assert.true(helper.isClass);
+ assert.strictEqual(helper.path, null);
+ assert.strictEqual(helper.name, 'format-distance-');
+ });
+
+ test('a name without the Helper suffix keeps every segment', function (assert) {
+ class FormatDistance {}
+
+ assert.strictEqual(new TemplateHelper('fleet-ops', FormatDistance).name, 'format-distance');
+ });
+
+ test('consecutive capitals are split before the trailing word', function (assert) {
+ class HTMLParser {}
+
+ assert.strictEqual(new TemplateHelper('fleet-ops', HTMLParser).name, 'html-parser');
+ });
+
+ test('a single-word class becomes its lowercase form', function (assert) {
+ class Humanize {}
+
+ assert.strictEqual(new TemplateHelper('fleet-ops', Humanize).name, 'humanize');
+ });
+
+ test('a named function is treated the same as a class', function (assert) {
+ function formatMoney() {}
+
+ const helper = new TemplateHelper('fleet-ops', formatMoney);
+ assert.true(helper.isClass);
+ assert.strictEqual(helper.name, 'format-money');
+ });
+
+ test('an anonymous function has no derivable name', function (assert) {
+ const helper = new TemplateHelper(
+ 'fleet-ops',
+ Object.defineProperty(() => {}, 'name', { value: '' })
+ );
+
+ assert.strictEqual(helper.name, null);
+ });
+ });
+});
diff --git a/tests/unit/contracts/widget-options-test.js b/tests/unit/contracts/widget-options-test.js
new file mode 100644
index 00000000..97e679c4
--- /dev/null
+++ b/tests/unit/contracts/widget-options-test.js
@@ -0,0 +1,55 @@
+import { module, test } from 'qunit';
+import Widget from '@fleetbase/ember-core/contracts/widget';
+
+/**
+ * withTitle and withRefreshInterval used to open with
+ * `if (!this.options) { this.options = {}; }`. The constructor already assigns
+ * `this.options` on both of its paths — `definition.options || {}` for a
+ * definition object, and `{}` for a bare id — so neither guard could ever fire.
+ * They have been removed; the tests below are what guarantee the constructor
+ * keeps holding up its end.
+ */
+module('Unit | Contract | widget (options setters)', function () {
+ test('a widget built from a bare id already has an options bag', function (assert) {
+ assert.deepEqual(new Widget('orders-summary').options, {}, 'which is what the setters rely on');
+ });
+
+ test('a widget built from a definition with no options has one too', function (assert) {
+ assert.deepEqual(new Widget({ id: 'orders-summary', name: 'Orders' }).options, {});
+ });
+
+ test('withTitle stores the title and chains', function (assert) {
+ const widget = new Widget('orders-summary');
+
+ const returned = widget.withTitle('Orders');
+
+ assert.strictEqual(returned, widget, 'it chains');
+ assert.strictEqual(widget.options.title, 'Orders');
+ assert.strictEqual(widget.toObject().options.title, 'Orders');
+ });
+
+ test('withTitle keeps an options bag that came from the definition', function (assert) {
+ const widget = new Widget({ id: 'orders-summary', options: { existing: true } });
+
+ widget.withTitle('Orders');
+
+ assert.strictEqual(widget.options.existing, true, 'nothing already there is lost');
+ assert.strictEqual(widget.options.title, 'Orders');
+ });
+
+ test('withRefreshInterval stores the interval and chains', function (assert) {
+ const widget = new Widget('orders-summary');
+
+ const returned = widget.withRefreshInterval(30000);
+
+ assert.strictEqual(returned, widget);
+ assert.strictEqual(widget.options.refreshInterval, 30000);
+ assert.strictEqual(widget.toObject().options.refreshInterval, 30000);
+ });
+
+ test('the two setters compose', function (assert) {
+ const widget = new Widget('orders-summary').withTitle('Orders').withRefreshInterval(5000);
+
+ assert.deepEqual(widget.toObject().options, { title: 'Orders', refreshInterval: 5000 });
+ });
+});
diff --git a/tests/unit/contracts/widget-test.js b/tests/unit/contracts/widget-test.js
new file mode 100644
index 00000000..7e5aca58
--- /dev/null
+++ b/tests/unit/contracts/widget-test.js
@@ -0,0 +1,167 @@
+import Widget from '@fleetbase/ember-core/contracts/widget';
+import ExtensionComponent from '@fleetbase/ember-core/contracts/extension-component';
+import { module, test } from 'qunit';
+
+module('Unit | Contract | widget', function () {
+ module('construction from an id', function () {
+ test('a bare id gets empty defaults', function (assert) {
+ const widget = new Widget('fleet-stats');
+
+ assert.strictEqual(widget.id, 'fleet-stats');
+ assert.strictEqual(widget.name, null);
+ assert.strictEqual(widget.description, null);
+ assert.strictEqual(widget.icon, null);
+ assert.strictEqual(widget.component, null);
+ assert.deepEqual(widget.grid_options, {});
+ assert.deepEqual(widget.options, {});
+ assert.strictEqual(widget.category, 'default');
+ });
+ });
+
+ module('construction from a definition', function () {
+ test('it reads every field', function (assert) {
+ const widget = new Widget({
+ id: 'fleet-stats',
+ name: 'Fleet stats',
+ description: 'Vehicle counts',
+ icon: 'truck',
+ component: 'widgets/fleet-stats',
+ grid_options: { w: 4, h: 2 },
+ options: { title: 'Fleet' },
+ category: 'operations',
+ });
+
+ assert.strictEqual(widget.id, 'fleet-stats');
+ assert.strictEqual(widget.name, 'Fleet stats');
+ assert.strictEqual(widget.description, 'Vehicle counts');
+ assert.strictEqual(widget.icon, 'truck');
+ assert.strictEqual(widget.component, 'widgets/fleet-stats');
+ assert.deepEqual(widget.grid_options, { w: 4, h: 2 });
+ assert.deepEqual(widget.options, { title: 'Fleet' });
+ assert.strictEqual(widget.category, 'operations');
+ });
+
+ test('widgetId is accepted as a legacy alias for id', function (assert) {
+ assert.strictEqual(new Widget({ widgetId: 'legacy' }).id, 'legacy');
+ });
+
+ test('id wins when both id and widgetId are supplied', function (assert) {
+ assert.strictEqual(new Widget({ id: 'primary', widgetId: 'legacy' }).id, 'primary');
+ });
+
+ test('a definition falls back to the same defaults', function (assert) {
+ const widget = new Widget({ id: 'minimal' });
+
+ assert.strictEqual(widget.name, null);
+ assert.strictEqual(widget.component, null);
+ assert.strictEqual(widget.category, 'default');
+ assert.deepEqual(widget.grid_options, {});
+ });
+
+ test('an ExtensionComponent is flattened to its object form', function (assert) {
+ const component = new ExtensionComponent('widgets/fleet-stats');
+ const widget = new Widget({ id: 'w', component });
+
+ assert.deepEqual(widget.component, component.toObject());
+ });
+
+ test('a plain object component is kept as-is', function (assert) {
+ const component = { name: 'widgets/fleet-stats', engine: 'fleet-ops' };
+
+ assert.deepEqual(new Widget({ id: 'w', component }).component, component);
+ });
+
+ test('the default flag is carried through', function (assert) {
+ assert.true(new Widget({ id: 'w', default: true }).isDefault());
+ assert.false(new Widget({ id: 'w' }).isDefault());
+ assert.false(new Widget({ id: 'w', default: false }).isDefault());
+ });
+ });
+
+ module('validation', function () {
+ test('it requires an id', function (assert) {
+ assert.throws(() => new Widget(), /Widget requires an id/);
+ assert.throws(() => new Widget(''), /Widget requires an id/);
+ assert.throws(() => new Widget({}), /Widget requires an id/);
+ });
+ });
+
+ module('chaining', function () {
+ test('the simple setters assign and keep options in step', function (assert) {
+ const widget = new Widget('w').withName('Name').withDescription('Desc').withIcon('icon').withCategory('ops');
+
+ assert.strictEqual(widget.name, 'Name');
+ assert.strictEqual(widget.getOption('name'), 'Name');
+ assert.strictEqual(widget.description, 'Desc');
+ assert.strictEqual(widget.icon, 'icon');
+ assert.strictEqual(widget.category, 'ops');
+ assert.strictEqual(widget.getOption('category'), 'ops');
+ });
+
+ test('withComponent accepts a string or an ExtensionComponent', function (assert) {
+ assert.strictEqual(new Widget('w').withComponent('widgets/x').component, 'widgets/x');
+
+ const component = new ExtensionComponent('widgets/y');
+ assert.deepEqual(new Widget('w').withComponent(component).component, component.toObject());
+ });
+
+ test('withGridOptions and withOptions merge rather than replace', function (assert) {
+ const widget = new Widget('w').withGridOptions({ w: 4 }).withGridOptions({ h: 2 }).withOptions({ a: 1 }).withOptions({ b: 2 });
+
+ assert.deepEqual(widget.grid_options, { w: 4, h: 2 });
+ assert.deepEqual(widget.options, { a: 1, b: 2 });
+ });
+
+ test('withTitle and withRefreshInterval write into options', function (assert) {
+ const widget = new Widget('w').withTitle('Fleet').withRefreshInterval(5000);
+
+ assert.strictEqual(widget.options.title, 'Fleet');
+ assert.strictEqual(widget.options.refreshInterval, 5000);
+ });
+
+ test('asDefault marks the widget as a default', function (assert) {
+ const widget = new Widget('w');
+
+ assert.false(widget.isDefault());
+ assert.strictEqual(widget.asDefault(), widget);
+ assert.true(widget.isDefault());
+ });
+
+ test('every setter returns the widget', function (assert) {
+ const widget = new Widget('w');
+
+ for (const call of [
+ () => widget.withName('n'),
+ () => widget.withDescription('d'),
+ () => widget.withIcon('i'),
+ () => widget.withComponent('c'),
+ () => widget.withGridOptions({}),
+ () => widget.withOptions({}),
+ () => widget.withCategory('c'),
+ () => widget.withTitle('t'),
+ () => widget.withRefreshInterval(1),
+ ]) {
+ assert.strictEqual(call(), widget);
+ }
+ });
+ });
+
+ module('toObject', function () {
+ test('it exposes every widget property', function (assert) {
+ const object = new Widget('w').withName('Name').withCategory('ops').toObject();
+
+ assert.strictEqual(object.id, 'w');
+ assert.strictEqual(object.name, 'Name');
+ assert.strictEqual(object.category, 'ops');
+ assert.deepEqual(object.grid_options, {});
+ assert.deepEqual(object.options, {});
+ assert.strictEqual(object.description, null);
+ assert.strictEqual(object.icon, null);
+ assert.strictEqual(object.component, null);
+ });
+
+ test('it carries the default flag when set', function (assert) {
+ assert.true(new Widget('w').asDefault().toObject().default);
+ });
+ });
+});
diff --git a/tests/unit/decorators/engine-service-test.js b/tests/unit/decorators/engine-service-test.js
new file mode 100644
index 00000000..71f6bcaa
--- /dev/null
+++ b/tests/unit/decorators/engine-service-test.js
@@ -0,0 +1,108 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'dummy/tests/helpers';
+import EmberObject from '@ember/object';
+import Service from '@ember/service';
+import { setOwner } from '@ember/application';
+import engineService from 'dummy/decorators/engine-service';
+
+/**
+ * @engineService installs a service borrowed from a mounted engine as a
+ * property on the decorated class, resolving it lazily on first access.
+ */
+module('Unit | Decorator | engine-service', function (hooks) {
+ setupTest(hooks);
+
+ hooks.beforeEach(function () {
+ this.requested = [];
+ this.engineService = {};
+ const testContext = this;
+
+ this.owner.register(
+ 'service:universe',
+ class extends Service {
+ getServiceFromEngine(engineName, serviceName) {
+ testContext.requested.push({ engineName, serviceName });
+ return testContext.engineService;
+ }
+ }
+ );
+
+ this.owner.register('service:store', class extends Service {});
+
+ this.build = (Klass) => {
+ const instance = Klass.create ? Klass.create() : new Klass();
+ setOwner(instance, this.owner);
+ return instance;
+ };
+ });
+
+ test('it resolves the named service from the named engine', function (assert) {
+ class Host extends EmberObject {
+ @engineService('fleet-ops') orders;
+ }
+
+ const host = this.build(Host);
+
+ assert.strictEqual(host.orders, this.engineService);
+ assert.deepEqual(this.requested, [{ engineName: 'fleet-ops', serviceName: 'orders' }]);
+ });
+
+ test('resolution is lazy — nothing is asked for until the property is read', function (assert) {
+ class Host extends EmberObject {
+ @engineService('fleet-ops') orders;
+ }
+
+ this.build(Host);
+
+ assert.deepEqual(this.requested, [], 'construction alone resolves nothing');
+ });
+
+ test('an initializer takes precedence over the resolved service', function (assert) {
+ class Host extends EmberObject {
+ @engineService('fleet-ops') orders = 'from initializer';
+ }
+
+ const host = this.build(Host);
+
+ assert.strictEqual(host.orders, 'from initializer');
+ assert.deepEqual(this.requested, [{ engineName: 'fleet-ops', serviceName: 'orders' }], 'the engine is still consulted first');
+ });
+
+ test('declared injections are wired onto the engine service', function (assert) {
+ class Host extends EmberObject {
+ @engineService('fleet-ops', { inject: ['store'] }) orders;
+ }
+
+ const host = this.build(Host);
+ host.orders;
+
+ assert.strictEqual(this.engineService.store, this.owner.lookup('service:store'));
+ });
+
+ test('the engine name must be a string', function (assert) {
+ assert.throws(() => {
+ class Host extends EmberObject {
+ @engineService(42) orders;
+ }
+ this.build(Host).orders;
+ }, /first argument of the @engineService decorator must be a string/);
+ });
+
+ test('the options must be an object', function (assert) {
+ assert.throws(() => {
+ class Host extends EmberObject {
+ @engineService('fleet-ops', 'nope') orders;
+ }
+ this.build(Host).orders;
+ }, /second argument of the @engineService decorator must be an object/);
+ });
+
+ test('it requires at least an engine name', function (assert) {
+ assert.throws(() => {
+ class Host extends EmberObject {
+ @engineService orders;
+ }
+ this.build(Host);
+ });
+ });
+});
diff --git a/tests/unit/decorators/fetch-from-test.js b/tests/unit/decorators/fetch-from-test.js
new file mode 100644
index 00000000..a6918856
--- /dev/null
+++ b/tests/unit/decorators/fetch-from-test.js
@@ -0,0 +1,148 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'dummy/tests/helpers';
+import EmberObject from '@ember/object';
+import Service from '@ember/service';
+import { setOwner } from '@ember/application';
+import fetchFrom from 'dummy/decorators/fetch-from';
+
+/**
+ * @fetchFrom turns a property into a lazily-fetched one: the first read starts
+ * the request and writes the result back onto the property, so later reads are
+ * served from the cached value rather than refetching.
+ */
+module('Unit | Decorator | fetch-from', function (hooks) {
+ setupTest(hooks);
+
+ hooks.beforeEach(function () {
+ this.calls = [];
+ this.response = { data: 'value' };
+ this.shouldReject = false;
+ const testContext = this;
+
+ this.owner.register(
+ 'service:fetch',
+ class extends Service {
+ get(path, query, options) {
+ testContext.calls.push({ path, query, options });
+ return testContext.shouldReject ? Promise.reject(new Error('offline')) : Promise.resolve(testContext.response);
+ }
+ }
+ );
+
+ this.build = (Klass) => {
+ const instance = Klass.create();
+ setOwner(instance, this.owner);
+ return instance;
+ };
+ });
+
+ test('reading the property issues the request', async function (assert) {
+ class Host extends EmberObject {
+ @fetchFrom('some/endpoint') data;
+ }
+ const host = this.build(Host);
+
+ await host.data;
+
+ assert.deepEqual(this.calls, [{ path: 'some/endpoint', query: {}, options: {} }]);
+ });
+
+ test('the query and options are passed through', async function (assert) {
+ class Host extends EmberObject {
+ @fetchFrom('some/endpoint', { limit: 5 }, { headers: { 'X-A': '1' } }) data;
+ }
+ const host = this.build(Host);
+
+ await host.data;
+
+ assert.deepEqual(this.calls[0].query, { limit: 5 });
+ assert.deepEqual(this.calls[0].options, { headers: { 'X-A': '1' } });
+ });
+
+ test('the first read resolves before the value has landed', async function (assert) {
+ class Host extends EmberObject {
+ @fetchFrom('some/endpoint') data;
+ }
+ const host = this.build(Host);
+
+ assert.strictEqual(await host.data, undefined, 'the first read starts the fetch rather than returning it');
+ });
+
+ test('once fetched, the value is served from the property', async function (assert) {
+ class Host extends EmberObject {
+ @fetchFrom('some/endpoint') data;
+ }
+ const host = this.build(Host);
+
+ await host.data;
+
+ assert.strictEqual(await host.data, this.response);
+ });
+
+ test('the request is only issued once', async function (assert) {
+ class Host extends EmberObject {
+ @fetchFrom('some/endpoint') data;
+ }
+ const host = this.build(Host);
+
+ await host.data;
+ await host.data;
+ await host.data;
+
+ assert.strictEqual(this.calls.length, 1);
+ });
+
+ test('an assigned value is returned without any request', async function (assert) {
+ class Host extends EmberObject {
+ @fetchFrom('some/endpoint') data;
+ }
+ const host = this.build(Host);
+
+ host.data = 'preset';
+
+ assert.strictEqual(await host.data, 'preset');
+ assert.deepEqual(this.calls, [], 'nothing was fetched');
+ });
+
+ test('an onComplete hook receives the response and the instance', async function (assert) {
+ const seen = [];
+ class Host extends EmberObject {
+ @fetchFrom('some/endpoint', {}, { onComplete: (response, instance) => seen.push({ response, instance }) }) data;
+ }
+ const host = this.build(Host);
+
+ await host.data;
+
+ assert.deepEqual(seen, [{ response: this.response, instance: host }]);
+ });
+
+ test('a failed request leaves the property null rather than rejecting', async function (assert) {
+ this.shouldReject = true;
+ class Host extends EmberObject {
+ @fetchFrom('some/endpoint') data;
+ }
+ const host = this.build(Host);
+
+ await host.data;
+
+ assert.strictEqual(await host.data, null);
+ });
+
+ test('the endpoint must be a string', function (assert) {
+ assert.throws(() => {
+ class Host extends EmberObject {
+ @fetchFrom(42) data;
+ }
+ this.build(Host);
+ }, /first argument of the @fetchFrom decorator must be a string/);
+ });
+
+ test('it requires at least an endpoint', function (assert) {
+ assert.throws(() => {
+ class Host extends EmberObject {
+ @fetchFrom data;
+ }
+ this.build(Host);
+ });
+ });
+});
diff --git a/tests/unit/decorators/from-store-test.js b/tests/unit/decorators/from-store-test.js
new file mode 100644
index 00000000..a0c5c934
--- /dev/null
+++ b/tests/unit/decorators/from-store-test.js
@@ -0,0 +1,89 @@
+import fromStore from '@fleetbase/ember-core/decorators/from-store';
+import { module, test } from 'qunit';
+import { setupTest } from 'dummy/tests/helpers';
+import EmberObject from '@ember/object';
+import Service from '@ember/service';
+import { settled } from '@ember/test-helpers';
+
+// The decorator resolves its query lazily on first read, so each subject class is
+// declared once at module scope and instantiated per test with an owner.
+class Simple extends EmberObject {
+ @fromStore('widget') records;
+}
+
+class WithQuery extends EmberObject {
+ @fromStore('widget', { active: true }) records;
+}
+
+module('Unit | Decorator | from-store', function (hooks) {
+ setupTest(hooks);
+
+ hooks.beforeEach(function () {
+ this.queries = [];
+ this.response = ['record-a', 'record-b'];
+ const testContext = this;
+
+ this.owner.register(
+ 'service:store',
+ class extends Service {
+ query(modelName, query, options) {
+ testContext.queries.push({ modelName, query, options });
+ return testContext.response instanceof Error ? Promise.reject(testContext.response) : Promise.resolve(testContext.response);
+ }
+ }
+ );
+ });
+
+ test('it queries the store on first access and assigns the result', async function (assert) {
+ const subject = WithQuery.create(this.owner.ownerInjection());
+
+ subject.records;
+ await settled();
+
+ assert.strictEqual(this.queries.length, 1);
+ assert.strictEqual(this.queries[0].modelName, 'widget');
+ assert.deepEqual(this.queries[0].query, { active: true });
+ assert.deepEqual(subject.records, ['record-a', 'record-b']);
+ });
+
+ test('it queries only once, serving the cached value afterwards', async function (assert) {
+ const subject = Simple.create(this.owner.ownerInjection());
+
+ subject.records;
+ await settled();
+ subject.records;
+ await settled();
+
+ assert.strictEqual(this.queries.length, 1);
+ });
+
+ test('it defaults the query and options', async function (assert) {
+ const subject = Simple.create(this.owner.ownerInjection());
+
+ subject.records;
+ await settled();
+
+ assert.deepEqual(this.queries[0].query, {});
+ assert.deepEqual(this.queries[0].options, {});
+ });
+
+ test('it assigns null when the query rejects', async function (assert) {
+ this.response = new Error('store is unavailable');
+ const subject = Simple.create(this.owner.ownerInjection());
+
+ subject.records;
+ await settled();
+
+ assert.strictEqual(subject.records, null);
+ });
+
+ test('an explicitly assigned value is used without querying', async function (assert) {
+ const subject = Simple.create(this.owner.ownerInjection());
+
+ subject.records = ['preset'];
+ await settled();
+
+ assert.deepEqual(subject.records, ['preset']);
+ assert.strictEqual(this.queries.length, 0, 'the store is never consulted');
+ });
+});
diff --git a/tests/unit/decorators/is-equal-test.js b/tests/unit/decorators/is-equal-test.js
new file mode 100644
index 00000000..e1b53e32
--- /dev/null
+++ b/tests/unit/decorators/is-equal-test.js
@@ -0,0 +1,101 @@
+import { isEqual } from '@fleetbase/ember-core/decorators/is-equal';
+import { module, test } from 'qunit';
+import EmberObject, { set } from '@ember/object';
+
+/**
+ * NOTE — this decorator does not work on Ember 5.4 with ember-decorators 6, and
+ * these tests pin what it actually does rather than what it is meant to do.
+ *
+ * The decorated property reads back as `undefined` no matter what the two source
+ * properties hold. The inner function hands a ComputedProperty back to
+ * decoratorWithRequiredParams, which expects a property descriptor, so nothing is
+ * installed on the class. (Its parameter list is also mislabelled — it declares
+ * `(target, desc, key, params)` where the caller passes `(target, key, desc,
+ * params)` — harmless today only because those two are never used.)
+ *
+ * Fixing it means deciding how the property should be defined, which is a
+ * maintainer's call, so nothing is changed here. These tests will fail as soon as
+ * somebody makes it work, which is the point at which that decision gets made.
+ */
+class Subject extends EmberObject {
+ @isEqual('a', 'b') matches;
+}
+
+module('Unit | Decorator | is-equal', function () {
+ test('the decorated property is undefined even when the values match', function (assert) {
+ assert.strictEqual(Subject.create({ a: 'x', b: 'x' }).matches, undefined);
+ });
+
+ test('it is equally undefined when the values differ', function (assert) {
+ assert.strictEqual(Subject.create({ a: 'x', b: 'y' }).matches, undefined);
+ });
+
+ test('it stays undefined after either dependent property changes', function (assert) {
+ const subject = Subject.create({ a: 'x', b: 'y' });
+
+ set(subject, 'b', 'x');
+ assert.strictEqual(subject.matches, undefined);
+
+ set(subject, 'a', 'z');
+ assert.strictEqual(subject.matches, undefined);
+ });
+
+ test('the decorator itself is a function that accepts two property names', function (assert) {
+ assert.strictEqual(typeof isEqual, 'function');
+ assert.strictEqual(typeof isEqual('a', 'b'), 'function', 'it returns a decorator');
+ });
+
+ test('the arity guard can never fire, so one property name fails deeper down', function (assert) {
+ // Pinned, not fixed. The guard is
+ // assert('... requires two property names ...', params.length === 2)
+ // but `isEqual(propNameA, propNameB)` forwards BOTH parameters onwards
+ // unconditionally, so `params` is always exactly two long — it is
+ // ['a', undefined] here, not ['a']. The guard is dead code, and a caller
+ // who passes one name gets Ember's low-level computed-key error instead
+ // of the decorator's own message naming the mistake.
+ assert.throws(() => isEqual('a')({}, 'matches', {}), /computed property key must be a string/);
+ });
+
+ /**
+ * The comparison the decorator builds is correct — it is only the installation
+ * that fails. Applying the decorator by hand yields the ComputedProperty it
+ * meant to define, and installing THAT through `.extend()` behaves exactly as
+ * the decorator was supposed to.
+ *
+ * This is the useful half of the finding: whoever fixes the decorator does not
+ * need to rewrite the comparison, only to define the property properly.
+ */
+ module('the computed property it builds', function () {
+ const buildComputed = () => isEqual('a', 'b')({}, 'matches', {});
+
+ test('it compares the two named properties', function (assert) {
+ const Working = EmberObject.extend({ matches: buildComputed() });
+
+ assert.true(Working.create({ a: 'x', b: 'x' }).matches);
+ assert.false(Working.create({ a: 'x', b: 'y' }).matches);
+ });
+
+ test('it compares by identity, not by value', function (assert) {
+ const Working = EmberObject.extend({ matches: buildComputed() });
+
+ assert.false(Working.create({ a: {}, b: {} }).matches, 'two equivalent objects are not equal');
+ assert.false(Working.create({ a: 1, b: '1' }).matches, 'and no coercion happens');
+ });
+
+ test('it recomputes when either property changes', function (assert) {
+ const subject = EmberObject.extend({ matches: buildComputed() }).create({ a: 'x', b: 'y' });
+
+ assert.false(subject.matches);
+
+ set(subject, 'b', 'x');
+ assert.true(subject.matches);
+
+ set(subject, 'a', 'z');
+ assert.false(subject.matches);
+ });
+
+ test('two undefined properties count as equal', function (assert) {
+ assert.true(EmberObject.extend({ matches: buildComputed() }).create().matches);
+ });
+ });
+});
diff --git a/tests/unit/decorators/legacy-fetch-from-test.js b/tests/unit/decorators/legacy-fetch-from-test.js
new file mode 100644
index 00000000..3b83db5b
--- /dev/null
+++ b/tests/unit/decorators/legacy-fetch-from-test.js
@@ -0,0 +1,154 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'dummy/tests/helpers';
+import EmberObject from '@ember/object';
+import Service from '@ember/service';
+import { run } from '@ember/runloop';
+import { setOwner } from '@ember/application';
+import legacyFetchFrom from 'dummy/decorators/legacy-fetch-from';
+
+/**
+ * The legacy form fetches eagerly instead of lazily: it wraps `init` and
+ * schedules the request for afterRender, writing the result onto the property
+ * when it lands. Until then the property reads as null.
+ *
+ * `run(() => {})` flushes the afterRender queue; a macrotask tick then lets the
+ * fetch promise settle.
+ */
+function flush() {
+ run(() => {});
+ return new Promise((resolve) => setTimeout(resolve, 0));
+}
+
+module('Unit | Decorator | legacy-fetch-from', function (hooks) {
+ setupTest(hooks);
+
+ hooks.beforeEach(function () {
+ this.calls = [];
+ this.response = ['a', 'b'];
+ this.shouldReject = false;
+ const testContext = this;
+
+ this.owner.register(
+ 'service:fetch',
+ class extends Service {
+ get(path, query, options) {
+ testContext.calls.push({ path, query, options });
+ return testContext.shouldReject ? Promise.reject(new Error('offline')) : Promise.resolve(testContext.response);
+ }
+ }
+ );
+
+ this.build = (Klass) => Klass.create(this.owner.ownerInjection());
+ });
+
+ test('on a native class field the property starts null until the fetch lands', function (assert) {
+ // `null` is the "not loaded yet" value. The decorator replaces the field with its
+ // accessor; left as a field, an own property would shadow it and read `undefined`.
+ class Host extends EmberObject {
+ @legacyFetchFrom('some/endpoint') data;
+ }
+
+ assert.strictEqual(this.build(Host).data, null);
+ });
+
+ test('the request is issued after render and the result assigned', async function (assert) {
+ class Host extends EmberObject {
+ @legacyFetchFrom('some/endpoint') data;
+ }
+ const host = this.build(Host);
+
+ await flush();
+
+ assert.deepEqual(this.calls, [{ path: 'some/endpoint', query: {}, options: {} }]);
+ assert.strictEqual(host.data, this.response);
+ });
+
+ test('the query and options are passed through', async function (assert) {
+ class Host extends EmberObject {
+ @legacyFetchFrom('some/endpoint', { limit: 5 }, { headers: { 'X-A': '1' } }) data;
+ }
+ this.build(Host);
+
+ await flush();
+
+ assert.deepEqual(this.calls[0].query, { limit: 5 });
+ assert.deepEqual(this.calls[0].options, { headers: { 'X-A': '1' } });
+ });
+
+ test('a failed request leaves an empty list rather than rejecting', async function (assert) {
+ this.shouldReject = true;
+ class Host extends EmberObject {
+ @legacyFetchFrom('some/endpoint') data;
+ }
+ const host = this.build(Host);
+
+ await flush();
+
+ assert.deepEqual(host.data, [], 'callers can still iterate it');
+ });
+
+ test('the property remains assignable', async function (assert) {
+ class Host extends EmberObject {
+ @legacyFetchFrom('some/endpoint') data;
+ }
+ const host = this.build(Host);
+ await flush();
+
+ host.data = 'replaced';
+
+ assert.strictEqual(host.data, 'replaced');
+ });
+
+ test('the inherited init still runs, so create() properties are applied', async function (assert) {
+ // The decorator replaces `target.init` with a wrapper that calls the
+ // original. EmberObject's own init is what assigns create() arguments,
+ // so this would silently break if the wrapper dropped it.
+ class Host extends EmberObject {
+ @legacyFetchFrom('some/endpoint') data;
+ }
+
+ const host = Host.create(this.owner.ownerInjection(), { label: 'given' });
+ await flush();
+
+ assert.strictEqual(host.label, 'given');
+ assert.strictEqual(host.data, this.response, 'and the fetch still happened');
+ });
+
+ test('the endpoint must be a string', function (assert) {
+ assert.throws(() => legacyFetchFrom(42), /first argument of the @fetchFrom decorator must be a string/);
+ });
+
+ test('the query must be an object', function (assert) {
+ assert.throws(() => legacyFetchFrom('some/endpoint', 'nope'), /second argument of the @fetchFrom decorator must be an object/);
+ });
+
+ test('the options must be an object', function (assert) {
+ assert.throws(() => legacyFetchFrom('some/endpoint', {}, 'nope'), /third argument of the @fetchFrom decorator must be an object/);
+ });
+
+ test('a target with no init of its own is still given one', async function (assert) {
+ // Every Ember class already has `init`, so the arm that skips the
+ // original is only reachable on a plain class. The decorator wraps
+ // whatever `init` it finds — here, nothing — and still schedules the
+ // fetch, which is what makes it usable outside the Ember object model.
+ const testContext = this;
+
+ class Plain {
+ @legacyFetchFrom('/orders') orders;
+
+ set(key, value) {
+ this[key] = value;
+ }
+ }
+
+ assert.strictEqual(Object.getPrototypeOf(Plain.prototype), Object.prototype, 'there is no inherited init to call');
+
+ const subject = new Plain();
+ setOwner(subject, testContext.owner);
+
+ subject.init();
+ await flush();
+
+ assert.deepEqual(subject.orders, ['a', 'b'], 'the scheduled fetch still landed on the property');
+ });
+});
diff --git a/tests/unit/decorators/legacy-from-store-test.js b/tests/unit/decorators/legacy-from-store-test.js
new file mode 100644
index 00000000..97f091a6
--- /dev/null
+++ b/tests/unit/decorators/legacy-from-store-test.js
@@ -0,0 +1,86 @@
+import legacyFromStore from '@fleetbase/ember-core/decorators/legacy-from-store';
+import { module, test } from 'qunit';
+import { setupTest } from 'dummy/tests/helpers';
+import EmberObject from '@ember/object';
+import Service from '@ember/service';
+import { settled } from '@ember/test-helpers';
+
+// NOTE: addon/decorators/legacy-from-store.js is byte-for-byte identical to
+// addon/decorators/from-store.js. Both are exported, so both are covered here,
+// but one of them is redundant and the pair will drift apart the first time only
+// one is edited. Consolidating them is a maintainer's call.
+class Subject extends EmberObject {
+ @legacyFromStore('widget', { active: true }) records;
+}
+
+module('Unit | Decorator | legacy-from-store', function (hooks) {
+ setupTest(hooks);
+
+ hooks.beforeEach(function () {
+ this.queries = [];
+ this.response = ['legacy-a'];
+ const testContext = this;
+
+ this.owner.register(
+ 'service:store',
+ class extends Service {
+ query(modelName, query, options) {
+ testContext.queries.push({ modelName, query, options });
+ return testContext.response instanceof Error ? Promise.reject(testContext.response) : Promise.resolve(testContext.response);
+ }
+ }
+ );
+ });
+
+ test('it queries the store lazily and assigns the result', async function (assert) {
+ const subject = Subject.create(this.owner.ownerInjection());
+
+ subject.records;
+ await settled();
+
+ assert.deepEqual(this.queries, [{ modelName: 'widget', query: { active: true }, options: {} }]);
+ assert.deepEqual(subject.records, ['legacy-a']);
+ });
+
+ test('it caches after the first read', async function (assert) {
+ const subject = Subject.create(this.owner.ownerInjection());
+
+ subject.records;
+ await settled();
+ subject.records;
+ await settled();
+
+ assert.strictEqual(this.queries.length, 1);
+ });
+
+ test('it assigns null when the query rejects', async function (assert) {
+ this.response = new Error('unavailable');
+ const subject = Subject.create(this.owner.ownerInjection());
+
+ subject.records;
+ await settled();
+
+ assert.strictEqual(subject.records, null);
+ });
+
+ test('an assigned value bypasses the store', async function (assert) {
+ const subject = Subject.create(this.owner.ownerInjection());
+
+ subject.records = ['preset'];
+ await settled();
+
+ assert.deepEqual(subject.records, ['preset']);
+ assert.strictEqual(this.queries.length, 0);
+ });
+
+ test('the query and options arguments both default when only a model name is given', async function (assert) {
+ class Minimal extends EmberObject {
+ @legacyFromStore('widget') records;
+ }
+
+ Minimal.create(this.owner.ownerInjection()).records;
+ await settled();
+
+ assert.deepEqual(this.queries, [{ modelName: 'widget', query: {}, options: {} }], 'an empty query and empty options are passed through');
+ });
+});
diff --git a/tests/unit/final-branches-test.js b/tests/unit/final-branches-test.js
new file mode 100644
index 00000000..5115f43b
--- /dev/null
+++ b/tests/unit/final-branches-test.js
@@ -0,0 +1,229 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'dummy/tests/helpers';
+import { settled } from '@ember/test-helpers';
+import Service from '@ember/service';
+import EmberObject from '@ember/object';
+import ObjectProxy from '@ember/object/proxy';
+import fromStore from '@fleetbase/ember-core/decorators/from-store';
+import legacyFromStore from '@fleetbase/ember-core/decorators/legacy-from-store';
+import serializeModel from 'dummy/utils/serialize-model';
+
+/**
+ * The last handful of one-line branches, each the only thing left in its file.
+ * They are collected here rather than spread across seven new files, since none
+ * of them warrants a module of its own.
+ */
+class OnComplete extends EmberObject {
+ @fromStore('widget', {}, { onComplete: (response, subject) => subject.seen.push(response) }) records;
+}
+
+class LegacyOnComplete extends EmberObject {
+ @legacyFromStore('widget', {}, { onComplete: (response, subject) => subject.seen.push(response) }) records;
+}
+
+module('Unit | Decorator | from-store (onComplete)', function (hooks) {
+ setupTest(hooks);
+
+ hooks.beforeEach(function () {
+ this.response = ['record-a'];
+ const testContext = this;
+
+ this.owner.register(
+ 'service:store',
+ class extends Service {
+ query() {
+ return Promise.resolve(testContext.response);
+ }
+ }
+ );
+ });
+
+ test('onComplete receives the response and the subject', async function (assert) {
+ const subject = OnComplete.create(this.owner.ownerInjection(), { seen: [] });
+
+ subject.records;
+ await settled();
+
+ assert.deepEqual(subject.seen, [this.response]);
+ assert.deepEqual(subject.records, this.response, 'and the property is still assigned');
+ });
+
+ test('the legacy decorator does the same', async function (assert) {
+ const subject = LegacyOnComplete.create(this.owner.ownerInjection(), { seen: [] });
+
+ subject.records;
+ await settled();
+
+ assert.deepEqual(subject.seen, [this.response]);
+ });
+});
+
+module('Unit | Utility | serialize-model (a proxy with toJSON)', function () {
+ test('toJSON is preferred when the subject has one', function (assert) {
+ // An ember-data record has `serialize` but no `toJSON`, so the toJSON
+ // branch needs a subject that isModel accepts and that does have one —
+ // an ObjectProxy is the one shape that qualifies.
+ const proxied = ObjectProxy.extend({
+ toJSON() {
+ return { via: 'toJSON' };
+ },
+ serialize() {
+ return { via: 'serialize' };
+ },
+ }).create();
+
+ assert.deepEqual(serializeModel(proxied), { via: 'toJSON' }, 'serialize is not consulted');
+ });
+});
+
+module('Unit | Service | theme (system colour scheme)', function (hooks) {
+ setupTest(hooks);
+
+ hooks.beforeEach(function () {
+ this.owner.register(
+ 'service:current-user',
+ class extends Service {
+ getOption() {
+ return null;
+ }
+ }
+ );
+ for (const name of ['universe', 'router', 'fetch', 'session']) {
+ this.owner.register(`service:${name}`, class extends Service {});
+ }
+
+ this.service = this.owner.lookup('service:theme');
+ this.service.initialTheme = null;
+
+ // matchMedia is replaced around the CALL only — it is a global the test
+ // framework and Ember itself may consult.
+ this.withColorScheme = (matches, fn) => {
+ const original = window.matchMedia;
+ window.matchMedia = () => ({ matches, addEventListener() {}, removeEventListener() {} });
+ try {
+ return fn();
+ } finally {
+ window.matchMedia = original;
+ }
+ };
+ });
+
+ test('a system preference for dark is honoured', function (assert) {
+ assert.strictEqual(
+ this.withColorScheme(true, () => this.service.activeTheme),
+ 'dark'
+ );
+ });
+
+ test('no system preference falls through to currentTheme, whatever that is', function (assert) {
+ // The comment on that line reads "default to dark theme", but it returns
+ // `this.currentTheme` — which is null on a service nobody has set a theme
+ // on. The fallback is whatever was last stored, not a literal dark.
+ this.service.currentTheme = null;
+ assert.strictEqual(
+ this.withColorScheme(false, () => this.service.activeTheme),
+ null
+ );
+
+ this.service.currentTheme = 'light';
+ assert.strictEqual(
+ this.withColorScheme(false, () => this.service.activeTheme),
+ 'light'
+ );
+ });
+});
+
+module('Unit | Service | language (saving a locale)', function (hooks) {
+ setupTest(hooks);
+
+ hooks.beforeEach(function () {
+ this.postRejects = false;
+ const testContext = this;
+
+ this.owner.register(
+ 'service:fetch',
+ class extends Service {
+ post(path, body) {
+ testContext.posted = { path, body };
+ return testContext.postRejects ? Promise.reject(new Error('offline')) : Promise.resolve({});
+ }
+ }
+ );
+ // LanguageService reads intl.locales and intl.primaryLocale in its
+ // CONSTRUCTOR and subscribes with onLocaleChanged — a bare stub throws
+ // at lookup, before any test body runs.
+ this.owner.register(
+ 'service:intl',
+ class extends Service {
+ locales = ['en-us'];
+ primaryLocale = 'en-us';
+ onLocaleChanged() {}
+ setLocale() {}
+ }
+ );
+ for (const name of ['current-user', 'session']) {
+ this.owner.register(`service:${name}`, class extends Service {});
+ }
+
+ this.service = this.owner.lookup('service:language');
+ });
+
+ test('it posts the chosen locale', async function (assert) {
+ await this.service.saveUserLocale.perform('en-gb');
+
+ assert.deepEqual(this.posted, { path: 'users/locale', body: { locale: 'en-gb' } });
+ });
+
+ test('a failure is swallowed rather than surfaced', async function (assert) {
+ this.postRejects = true;
+
+ const result = await this.service.saveUserLocale.perform('en-gb');
+
+ assert.strictEqual(result, undefined, 'the caller cannot tell the save failed');
+ });
+});
+
+module('Unit | Service | filters (activeFilters)', function (hooks) {
+ setupTest(hooks);
+
+ hooks.beforeEach(function () {
+ this.searchParams = {};
+ const testContext = this;
+
+ this.owner.register(
+ 'service:url-search-params',
+ class extends Service {
+ get(key) {
+ return testContext.searchParams[key];
+ }
+ }
+ );
+ this.owner.register('service:router', class extends Service {});
+
+ this.service = this.owner.lookup('service:filters');
+
+ this.useCurrentRoute = ({ queryParams = {}, url = {} } = {}) => {
+ this.searchParams = url;
+ const route = { controller: null, queryParams };
+ this.owner.register('router:main', { _routerMicrolib: { currentRouteInfos: [{ _route: route }] } }, { instantiate: false });
+ };
+ });
+
+ test('it lists exactly what getQueryParams returns', function (assert) {
+ // activeFilters used to re-apply the blank/managed filter that
+ // getQueryParams had already applied one layer down, so the `continue`
+ // could never run. The duplicate is gone; the filtering still happens,
+ // just once.
+ this.useCurrentRoute({
+ queryParams: { status: null, page: null, type: null },
+ url: { status: 'active', page: '2', type: '' },
+ });
+
+ assert.deepEqual(
+ this.service.activeFilters,
+ [{ queryParam: 'status', label: 'status', value: 'active' }],
+ 'page was dropped as managed and type as blank — both before activeFilters saw them'
+ );
+ assert.deepEqual(this.service.getQueryParams(), { status: 'active' }, 'which is the same list, already filtered');
+ });
+});
diff --git a/tests/unit/initializers/load-socketcluster-client-test.js b/tests/unit/initializers/load-socketcluster-client-test.js
index 723ab998..6563845a 100644
--- a/tests/unit/initializers/load-socketcluster-client-test.js
+++ b/tests/unit/initializers/load-socketcluster-client-test.js
@@ -1,37 +1,67 @@
-import Application from '@ember/application';
-
-import config from 'dummy/config/environment';
-import { initialize } from 'dummy/initializers/load-socketcluster-client';
import { module, test } from 'qunit';
-import Resolver from 'ember-resolver';
-import { run } from '@ember/runloop';
+import { initialize } from 'dummy/initializers/load-socketcluster-client';
+
+const MARKER_SELECTOR = 'script[data-socketcluster-client]';
+/**
+ * This initializer injects the SocketCluster client script, guarding against a
+ * second insertion so engines can boot it more than once.
+ *
+ * Two things make this awkward to test, and both are deliberate:
+ *
+ * tests/helpers/stub-socketcluster.js plants a marker node carrying the same
+ * `data-socketcluster-client` attribute, precisely so this guard trips and the
+ * real client never loads during the suite. That marker must survive these
+ * tests — removing it would let a later test pull in the real client, which is
+ * what used to hang the run.
+ *
+ * And the creation path must not actually append: a real