diff --git a/addon/services/current-user.js b/addon/services/current-user.js index 726b603..defc226 100644 --- a/addon/services/current-user.js +++ b/addon/services/current-user.js @@ -32,6 +32,8 @@ export default class CurrentUserService extends Service.extend(Evented) { @alias('user.is_admin') isAdmin; @alias('user.company_uuid') companyId; @alias('user.company_name') companyName; + @alias('user.role_name') roleName; + @alias('user.role') role; @computed('id') get optionsPrefix() { return `${this.id}:`; @@ -94,8 +96,18 @@ export default class CurrentUserService extends Service.extend(Evented) { // Set environment from user option this.theme.setEnvironment(); - // Load user preferces - await this.loadPreferences(); + // Set locale + if (user.locale) { + this.setLocale(user.locale); + } else { + await this.loadLocale(); + } + + // Load user whois data + await this.loadWhois(); + + // Load user organizations + await this.loadOrganizations(); // Optional callback if (typeof options?.onUserResolved === 'function') { @@ -118,9 +130,7 @@ export default class CurrentUserService extends Service.extend(Evented) { async loadLocale() { try { const { locale } = await this.fetch.get('users/locale'); - this.setOption('locale', locale); - this.intl.setLocale(locale); - this.locale = locale; + this.setLocale(locale); return locale; } catch (error) { @@ -207,6 +217,14 @@ export default class CurrentUserService extends Service.extend(Evented) { return this.getWhoisProperty(key); } + setLocale(locale) { + this.setOption('locale', locale); + this.intl.setLocale(locale); + this.locale = locale; + + return this; + } + setOption(key, value) { key = `${this.optionsPrefix}${dasherize(key)}`; diff --git a/addon/services/fetch.js b/addon/services/fetch.js index 79fbcb6..2024343 100644 --- a/addon/services/fetch.js +++ b/addon/services/fetch.js @@ -360,6 +360,7 @@ export default class FetchService extends Service { const pathKeyVersion = new Date().toISOString(); const request = () => { + delete options.fromCache; return this.get(path, query, options).then((response) => { // cache the response this.localCache.set(pathKey, response); @@ -392,7 +393,7 @@ export default class FetchService extends Service { // if the version is older than 3 days clear it if (!version || shouldExpire || options.clearData === true) { this.flushRequestCache(path); - return request(); + return request().then(resolve); } if (options.normalizeToEmberData) { @@ -408,6 +409,11 @@ export default class FetchService extends Service { return request(); } + /** + * Flushes the local cache for a specific path by setting its value and version to undefined. + * + * @param {string} path - The path for which the cache should be flushed. + */ flushRequestCache(path) { const pathKey = dasherize(path); @@ -415,6 +421,11 @@ export default class FetchService extends Service { this.localCache.set(`${pathKey}-version`, undefined); } + /** + * Determines whether the cache should be reset by comparing the current version + * of the console with the cached version. If they differ, the cache is cleared + * and the new version is saved. + */ shouldResetCache() { const consoleVersion = this.localCache.get('console-version'); diff --git a/addon/services/universe.js b/addon/services/universe.js index ca3cb8f..f45443e 100644 --- a/addon/services/universe.js +++ b/addon/services/universe.js @@ -19,6 +19,7 @@ import config from 'ember-get-config'; export default class UniverseService extends Service.extend(Evented) { @service router; @service intl; + @service urlSearchParams; @tracked applicationInstance; @tracked enginesBooted = false; @tracked bootedExtensions = A([]); @@ -42,6 +43,7 @@ export default class UniverseService extends Service.extend(Evented) { widgets: A([]), }; @tracked hooks = {}; + @tracked bootCallbacks = A([]); /** * Computed property that returns all administrative menu items. @@ -135,6 +137,17 @@ export default class UniverseService extends Service.extend(Evented) { return this.router.transitionTo(route, ...args); } + /** + * Sets the application instance. + * + * @param {ApplicationInstance} - The application instance object. + * @return {void} + */ + setApplicationInstance(instance) { + window.Fleetbase = instance; + this.applicationInstance = instance; + } + /** * Retrieves the application instance. * @@ -261,6 +274,43 @@ export default class UniverseService extends Service.extend(Evented) { 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. @@ -777,9 +827,11 @@ export default class UniverseService extends Service.extend(Evented) { */ 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 }) => { @@ -1321,6 +1373,7 @@ export default class UniverseService extends Service.extend(Evented) { * @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'); @@ -1360,6 +1413,7 @@ export default class UniverseService extends Service.extend(Evented) { // @todo: create menu item class const menuItem = { id, + intl, title, text: title, route, @@ -1391,6 +1445,14 @@ export default class UniverseService extends Service.extend(Evented) { 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; } @@ -1730,7 +1792,7 @@ export default class UniverseService extends Service.extend(Evented) { * @param {ApplicationInstance|null} owner - The Ember ApplicationInstance that owns the engines. * @return {void} */ - bootEngines(owner = null) { + async bootEngines(owner = null) { const booted = []; const pending = []; const additionalCoreExtensions = config.APP.extensions ?? []; @@ -1741,10 +1803,10 @@ export default class UniverseService extends Service.extend(Evented) { } // Set application instance - this.applicationInstance = owner; + this.setApplicationInstance(owner); const tryBootEngine = (extension) => { - this.loadEngine(extension.name).then((engineInstance) => { + return this.loadEngine(extension.name).then((engineInstance) => { if (engineInstance.base && engineInstance.base.setupExtension) { if (this.bootedExtensions.includes(extension.name)) { return; @@ -1799,11 +1861,13 @@ export default class UniverseService extends Service.extend(Evented) { pending.push(...stillPending); }; - return loadInstalledExtensions(additionalCoreExtensions).then((extensions) => { - extensions.forEach((extension) => { - tryBootEngine(extension); - }); + 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; }); } @@ -1831,10 +1895,10 @@ export default class UniverseService extends Service.extend(Evented) { } // Set application instance - this.applicationInstance = owner; + this.setApplicationInstance(owner); const tryBootEngine = (extension) => { - this.loadEngine(extension.name).then((engineInstance) => { + return this.loadEngine(extension.name).then((engineInstance) => { if (engineInstance.base && engineInstance.base.setupExtension) { const engineDependencies = getWithDefault(engineInstance.base, 'engineDependencies', []); @@ -1883,11 +1947,13 @@ export default class UniverseService extends Service.extend(Evented) { pending.push(...stillPending); }; - return loadExtensions().then((extensions) => { - extensions.forEach((extension) => { - tryBootEngine(extension); - }); + return loadExtensions().then(async (extensions) => { + for (let i = 0; i < extensions.length; i++) { + const extension = extensions[i]; + await tryBootEngine(extension); + } + this.runBootCallbacks(owner); this.enginesBooted = true; }); } @@ -1903,6 +1969,50 @@ export default class UniverseService extends Service.extend(Evented) { 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') { + callback(this, appInstance); + } + } + + if (typeof onComplete === 'function') { + onComplete(); + } + } + /** * Alias for intl service `t` * diff --git a/addon/services/url-search-params.js b/addon/services/url-search-params.js index f37c751..6beef64 100644 --- a/addon/services/url-search-params.js +++ b/addon/services/url-search-params.js @@ -1,35 +1,35 @@ import Service from '@ember/service'; -import { tracked } from '@glimmer/tracking'; +import { debounce } from '@ember/runloop'; import hasJsonStructure from '../utils/has-json-structure'; +/** + * Service for manipulating URL search parameters. + * + * This service provides methods to get, set, remove, and check URL query parameters. + * It also allows updating the browser's URL without reloading the page. + * + * @extends Service + */ export default class UrlSearchParamsService extends Service { /** - * The active URL params + * Getter for `urlParams` that ensures it's always up-to-date with the current URL. * - * @var {Array} + * @type {URLSearchParams} + * @private */ - @tracked urlParams; - - /** - * Update the URL params - * - * @void - */ - setSearchParams() { - this.urlParams = new URLSearchParams(window.location.search); - - return this; + get urlParams() { + return new URLSearchParams(window.location.search); } /** - * Get a param + * Retrieves the value of a specific query parameter. * - * @param {String} key the url param - * @return mixed + * If the parameter value is a JSON string, it will be parsed into an object or array. + * + * @param {string} key - The name of the query parameter to retrieve. + * @returns {*} The value of the query parameter, parsed from JSON if applicable, or null if not found. */ getParam(key) { - this.setSearchParams(); - let value = this.urlParams.get(key); if (hasJsonStructure(value)) { @@ -40,47 +40,102 @@ export default class UrlSearchParamsService extends Service { } /** - * Get a param + * Sets or updates a query parameter in the URL search parameters. + * + * If the value is an object or array, it will be stringified to JSON. + * + * @param {string} key - The name of the query parameter to set. + * @param {*} value - The value of the query parameter. + * @returns {this} Returns the service instance for chaining. + */ + setParam(key, value) { + if (typeof value === 'object') { + value = JSON.stringify(value); + } else { + value = encodeURIComponent(value); + } + + this.urlParams.set(key, value); + + return this; + } + + /** + * Alias for `getParam`. * - * @param {String} key the url param - * @return mixed + * @param {string} key - The name of the query parameter to retrieve. + * @returns {*} The value of the query parameter. */ get(key) { return this.getParam(key); } /** - * Determines if a queryParam exists + * Sets or updates a query parameter with multiple values. * - * @param {String} key the url param - * @var {Boolean} + * @param {string} key - The name of the query parameter to set. + * @param {Array} values - An array of values for the parameter. + * @returns {this} Returns the service instance for chaining. + */ + setParamArray(key, values) { + this.urlParams.delete(key); + values.forEach((value) => { + this.urlParams.append(key, value); + }); + + return this; + } + + /** + * Retrieves all values of a specific query parameter. + * + * @param {string} key - The name of the query parameter. + * @returns {Array} An array of values for the parameter. + */ + getParamArray(key) { + return this.urlParams.getAll(key); + } + + /** + * Checks if a specific query parameter exists in the URL. + * + * @param {string} key - The name of the query parameter to check. + * @returns {boolean} True if the parameter exists, false otherwise. */ exists(key) { - this.setSearchParams(); + return this.urlParams.has(key); + } + /** + * Checks if a specific query parameter has in the URL. + * + * @param {string} key - The name of the query parameter to check. + * @returns {boolean} True if the parameter exists, false otherwise. + */ + has(key) { return this.urlParams.has(key); } /** - * Remove a queryparam + * Removes a specific query parameter from the URL search parameters. * - * @param {String} key the url param - * @void + * @param {string} key - The name of the query parameter to remove. + * @returns {this} Returns the service instance for chaining. */ remove(key) { - this.setSearchParams(); + this.urlParams.delete(key); - return this.urlParams.delete(key); + return this; } /** - * Returns object of all params + * Retrieves all query parameters as an object. * - * @return {Array} + * Each parameter value is processed by `getParam`, which parses JSON values if applicable. + * + * @returns {Object} An object containing all query parameters and their values. */ all() { - this.setSearchParams(); - const all = {}; for (let key of this.urlParams.keys()) { @@ -89,4 +144,103 @@ export default class UrlSearchParamsService extends Service { return all; } + + /** + * Updates the browser's URL with the current `urlParams` without reloading the page. + * + * @returns {void} + */ + updateUrl() { + const url = new URL(window.location.href); + url.search = this.urlParams.toString(); + window.history.pushState({ path: url.href }, '', url.href); + } + + /** + * Updates the browser's URL with the current `urlParams`, debounced to prevent excessive calls. + * + * @returns {void} + */ + updateUrlDebounced() { + debounce(this, this.updateUrl, 100); + } + + /** + * Clears all query parameters from the URL search parameters. + * + * @returns {this} Returns the service instance for chaining. + */ + clear() { + this.urlParams = new URLSearchParams(); + + return this; + } + + /** + * Returns the full URL as a string with the current `urlParams`. + * + * @returns {string} The full URL with updated query parameters. + */ + getFullUrl() { + const url = new URL(window.location.href); + url.search = this.urlParams.toString(); + + return url.toString(); + } + + /** + * Returns the current path with the updated query parameters. + * + * @returns {string} The path and search portion of the URL. + */ + getPathWithParams() { + return `${window.location.pathname}?${this.urlParams.toString()}`; + } + + /** + * Removes a query parameter from the current URL and updates the browser history. + * + * This method modifies the browser's URL by removing the specified parameter and uses the History API + * to update the URL without reloading the page. + * + * @param {string} paramToRemove - The name of the query parameter to remove from the URL. + * @returns {void} + */ + removeParamFromCurrentUrl(paramToRemove) { + const url = new URL(window.location.href); + url.searchParams.delete(paramToRemove); + window.history.pushState({ path: url.href }, '', url.href); + } + + /** + * Adds or updates a query parameter in the current URL and updates the browser history. + * + * This method modifies the browser's URL by adding or updating the specified parameter and uses the History API + * to update the URL without reloading the page. + * + * @param {string} paramName - The name of the query parameter to add or update. + * @param {string} paramValue - The value of the query parameter. + * @returns {void} + */ + addParamToCurrentUrl(paramName, paramValue) { + const url = new URL(window.location.href); + url.searchParams.set(paramName, paramValue); + window.history.pushState({ path: url.href }, '', url.href); + } + + /** + * Adds or updates a query parameters from a provided object into the current URL and updates the browser history. + * + * This method modifies the browser's URL by adding or updating the specified parameters and uses the History API + * to update the URL without reloading the page. + * + * @param {Object} params - The query parameters to add or update. + * @returns {void} + */ + setParamsToCurrentUrl(params = {}) { + for (let param in params) { + const value = params[param]; + this.addParamToCurrentUrl(param, value); + } + } } diff --git a/addon/utils/inject-engine-service.js b/addon/utils/inject-engine-service.js index cdb7ebb..721e0d5 100644 --- a/addon/utils/inject-engine-service.js +++ b/addon/utils/inject-engine-service.js @@ -1,10 +1,11 @@ import { getOwner } from '@ember/application'; import { isArray } from '@ember/array'; +import Service from '@ember/service'; import isObject from './is-object'; function findService(owner, target, serviceName) { let service = target[serviceName]; - if (!service) { + if (!(service instanceof Service)) { service = owner.lookup(`service:${serviceName}`); } @@ -33,8 +34,12 @@ function automaticServiceResolution(service, target, owner) { } } +function _getOwner(target) { + return window.Fleetbase ?? getOwner(target); +} + export default function injectEngineService(target, engineName, serviceName, options = {}) { - const owner = getOwner(target); + const owner = _getOwner(target); const universe = owner.lookup('service:universe'); const service = universe.getServiceFromEngine(engineName, serviceName); const key = options.key || null; diff --git a/addon/utils/register-component.js b/addon/utils/register-component.js new file mode 100644 index 0000000..1c6df1c --- /dev/null +++ b/addon/utils/register-component.js @@ -0,0 +1,8 @@ +import { dasherize } from '@ember/string'; + +export default function registerComponent(owner, componentClass, options = {}) { + const registrationName = options && options.as ? `component:${options.as}` : `component:${dasherize(componentClass.name).replace('-component', '')}`; + if (!owner.hasRegistration(registrationName)) { + owner.register(registrationName, componentClass); + } +} diff --git a/addon/utils/register-helper.js b/addon/utils/register-helper.js new file mode 100644 index 0000000..396a64f --- /dev/null +++ b/addon/utils/register-helper.js @@ -0,0 +1,8 @@ +import { dasherize } from '@ember/string'; + +export default function registerHelper(owner, name, helperFn) { + const registrationName = `helper:${dasherize(name)}`; + if (!owner.hasRegistration(registrationName)) { + owner.register(registrationName, helperFn); + } +} diff --git a/app/utils/register-component.js b/app/utils/register-component.js new file mode 100644 index 0000000..af6623f --- /dev/null +++ b/app/utils/register-component.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-core/utils/register-component'; diff --git a/app/utils/register-helper.js b/app/utils/register-helper.js new file mode 100644 index 0000000..2f651db --- /dev/null +++ b/app/utils/register-helper.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-core/utils/register-helper'; diff --git a/package.json b/package.json index 7553690..923dbed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fleetbase/ember-core", - "version": "0.2.18", + "version": "0.2.19", "description": "Provides all the core services, decorators and utilities for building a Fleetbase extension for the Console.", "keywords": [ "fleetbase-core", diff --git a/tests/unit/utils/register-component-test.js b/tests/unit/utils/register-component-test.js new file mode 100644 index 0000000..ec3f9f5 --- /dev/null +++ b/tests/unit/utils/register-component-test.js @@ -0,0 +1,10 @@ +import registerComponent from 'dummy/utils/register-component'; +import { module, test } from 'qunit'; + +module('Unit | Utility | register-component', function () { + // TODO: Replace this with your real tests. + test('it works', function (assert) { + let result = registerComponent(); + assert.ok(result); + }); +}); diff --git a/tests/unit/utils/register-helper-test.js b/tests/unit/utils/register-helper-test.js new file mode 100644 index 0000000..57b49c1 --- /dev/null +++ b/tests/unit/utils/register-helper-test.js @@ -0,0 +1,10 @@ +import registerHelper from 'dummy/utils/register-helper'; +import { module, test } from 'qunit'; + +module('Unit | Utility | register-helper', function () { + // TODO: Replace this with your real tests. + test('it works', function (assert) { + let result = registerHelper(); + assert.ok(result); + }); +});