diff --git a/.eslintignore b/.eslintignore index d474a40b..abfa0942 100644 --- a/.eslintignore +++ b/.eslintignore @@ -23,3 +23,7 @@ /package.json.ember-try /package-lock.json.ember-try /yarn.lock.ember-try + +#server +/server +/server_vendor diff --git a/addon/components/context-panel.hbs b/addon/components/context-panel.hbs new file mode 100644 index 00000000..bd41d174 --- /dev/null +++ b/addon/components/context-panel.hbs @@ -0,0 +1,5 @@ +{{#if this.contextPanel.currentContextRegistry}} + {{#let this.contextPanel.currentContext this.contextPanel.currentContextRegistry this.contextPanel.currentContextComponentArguments as |model registry dynamicArgs|}} + {{component registry.component context=model dynamicArgs=dynamicArgs onPressCancel=this.contextPanel.clear}} + {{/let}} +{{/if}} \ No newline at end of file diff --git a/addon/components/context-panel.js b/addon/components/context-panel.js new file mode 100644 index 00000000..1a63221f --- /dev/null +++ b/addon/components/context-panel.js @@ -0,0 +1,6 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; + +export default class ContextPanelComponent extends Component { + @service contextPanel; +} diff --git a/addon/components/driver-form-panel.hbs b/addon/components/driver-form-panel.hbs index 83e774a0..c9c063c1 100644 --- a/addon/components/driver-form-panel.hbs +++ b/addon/components/driver-form-panel.hbs @@ -1,43 +1,43 @@ - +
- {{#if @driver.id}} + {{#if this.driver.id}}
-
-
- {{@driver.name}} + {{this.driver.name}} - +

- {{#if @driver.id}} - {{@driver.name}} + {{#if this.driver.id}} + {{this.driver.name}} {{else}} - {{#if @driver.name}} - {{@driver.name}} + {{#if this.driver.name}} + {{this.driver.name}} {{else}} New Driver {{/if}} {{/if}}

- {{#if @driver.vehicle}} + {{#if this.driver.vehicle}}
- {{@driver.vehicle.displayName}} + {{this.driver.vehicle.displayName}}
{{else}}
@@ -49,7 +49,7 @@
- +
@@ -61,7 +61,7 @@ - +
@@ -70,35 +70,35 @@ - +
- +
- +
- +
- + {{model.name}}
@@ -107,7 +107,7 @@ - + {{model.display_name}} @@ -116,14 +116,14 @@ - +
- +
diff --git a/addon/components/driver-form-panel.js b/addon/components/driver-form-panel.js index f4119f85..291a3af3 100644 --- a/addon/components/driver-form-panel.js +++ b/addon/components/driver-form-panel.js @@ -1,4 +1,5 @@ import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; import { inject as service } from '@ember/service'; import { action } from '@ember/object'; @@ -6,10 +7,35 @@ export default class DriverFormPanelComponent extends Component { @service store; @service notifications; @service hostRouter; + @service contextPanel; @service loader; + @tracked driver; + + constructor() { + super(...arguments); + this.driver = this.args.driver; + this.applyDynamicArguments(); + } + + applyDynamicArguments() { + // Apply context if available + if (this.args.context) { + this.driver = this.args.context; + } + + // Apply dynamic arguments if available + if (this.args.dynamicArgs) { + const keys = Object.keys(this.args.dynamicArgs); + + keys.forEach((key) => { + this[key] = this.args.dynamicArgs[key]; + }); + } + } @action save() { - const { driver, onAfterSave } = this.args; + const { driver } = this; + const { onAfterSave } = this.args; this.loader.showLoader('.overlay-inner-content', 'Saving driver...'); @@ -33,7 +59,6 @@ export default class DriverFormPanelComponent extends Component { } @action viewDetails() { - const { driver } = this.args; - return this.hostRouter.transitionTo('console.fleet-ops.management.drivers.index.details', driver.public_id); + this.contextPanel.focus(this.driver, 'viewing'); } } diff --git a/addon/components/driver-panel.hbs b/addon/components/driver-panel.hbs index a8fc6717..e0380ae2 100644 --- a/addon/components/driver-panel.hbs +++ b/addon/components/driver-panel.hbs @@ -1,4 +1,4 @@ - +
@@ -13,17 +13,17 @@
- {{@driver.name}} + {{this.driver.name}} - +
-

{{@driver.name}}

+

{{this.driver.name}}

- {{#if @driver.vehicle}} + {{#if this.driver.vehicle}}
- {{@driver.vehicle.displayName}} + {{this.driver.vehicle.displayName}}
{{else}}
@@ -36,7 +36,7 @@
- +
@@ -54,7 +54,7 @@
- {{component this.tab.component driver=@driver tabOptions=this.tab params=this.tab.componentParams}} + {{component this.tab.component driver=this.driver tabOptions=this.tab params=this.tab.componentParams}}
\ No newline at end of file diff --git a/addon/components/driver-panel.js b/addon/components/driver-panel.js index 182830c4..31c63755 100644 --- a/addon/components/driver-panel.js +++ b/addon/components/driver-panel.js @@ -11,10 +11,11 @@ export default class DriverPanelComponent extends Component { @service universe; @service store; @service hostRouter; + @service contextPanel; @tracked currentTab; @tracked devices = []; @tracked deviceApi = {}; - @tracked vehicle; + @tracked driver; get tabs() { const registeredTabs = this.universe.getMenuItemsFromRegistry('component:driver-panel'); @@ -38,11 +39,28 @@ export default class DriverPanelComponent extends Component { constructor() { super(...arguments); - this.vehicle = this.args.vehicle; - this.changeTab(this.args.tab || 'details'); + this.driver = this.args.driver; + this.changeTab(this.args.tab); + this.applyDynamicArguments(); } - @action async changeTab(tab) { + applyDynamicArguments() { + // Apply context if available + if (this.args.context) { + this.driver = this.args.context; + } + + // Apply dynamic arguments if available + if (this.args.dynamicArgs) { + const keys = Object.keys(this.args.dynamicArgs); + + keys.forEach((key) => { + this[key] = this.args.dynamicArgs[key]; + }); + } + } + + @action async changeTab(tab = 'details') { this.currentTab = tab; if (typeof this.args.onTabChanged === 'function') { @@ -51,7 +69,6 @@ export default class DriverPanelComponent extends Component { } @action editDriver() { - const { driver } = this.args; - return this.hostRouter.transitionTo('console.fleet-ops.management.drivers.index.edit', driver.public_id); + this.contextPanel.focus(this.driver, 'editing'); } } diff --git a/addon/components/fleet-vehicle-listing.hbs b/addon/components/fleet-vehicle-listing.hbs index a00c1b3d..c977ca3f 100644 --- a/addon/components/fleet-vehicle-listing.hbs +++ b/addon/components/fleet-vehicle-listing.hbs @@ -13,7 +13,7 @@
{{#if @onAddVehicle}} - + {{model.display_name}} {{/if}} diff --git a/addon/components/live-map.hbs b/addon/components/live-map.hbs index 1e2f8447..b0230139 100644 --- a/addon/components/live-map.hbs +++ b/addon/components/live-map.hbs @@ -1,10 +1,10 @@ -
- +
+ - {{#if this.isDriversVisible}} + {{#if this.visibilityControls.drivers}} {{#each this.drivers as |driver|}} - + @@ -13,9 +13,20 @@ {{/each}} {{/if}} - {{#if this.isPlacesVisible}} + {{#if this.visibilityControls.vehicles}} + {{#each this.vehicles as |vehicle|}} + + +
{{vehicle.displayName}}
+
{{if vehicle.online "Online" "Offline"}}
+
+
+ {{/each}} + {{/if}} + + {{#if this.visibilityControls.places}} {{#each this.places as |place|}} - +
{{place.address}}
{{format-point place.location}}
@@ -57,15 +68,15 @@ {{/each}} {{/if}} --}} - + {{#each this.activeServiceAreas as |serviceArea|}} - + {{serviceArea.name}} Service Area {{#each serviceArea.zones as |zone|}} - + {{zone.name}} Zone {{/each}} diff --git a/addon/components/live-map.js b/addon/components/live-map.js index 5ff4a833..72106d64 100644 --- a/addon/components/live-map.js +++ b/addon/components/live-map.js @@ -1,67 +1,227 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { inject as service } from '@ember/service'; -import { action, computed, set } from '@ember/object'; +import { action, set } from '@ember/object'; import { isArray } from '@ember/array'; -import { isBlank } from '@ember/utils'; -import { dasherize } from '@ember/string'; +import { dasherize, camelize } from '@ember/string'; +import { singularize } from 'ember-inflector'; import { alias } from '@ember/object/computed'; -import { guidFor } from '@ember/object/internals'; import { later } from '@ember/runloop'; import { allSettled } from 'rsvp'; +import getWithDefault from '@fleetbase/ember-core/utils/get-with-default'; const DEFAULT_LATITUDE = 1.369; const DEFAULT_LONGITUDE = 103.8864; +/** + * Component which displays live activity. + * + * @class + */ export default class LiveMapComponent extends Component { + /** + * Inject the `store` service. + * + * @memberof LiveMapComponent + */ @service store; + + /** + * Inject the `fetch` service. + * + * @memberof LiveMapComponent + */ @service fetch; + + /** + * Inject the `socket` service. + * + * @memberof LiveMapComponent + */ @service socket; + + /** + * Inject the `currentUser` service. + * + * @memberof LiveMapComponent + */ @service currentUser; + + /** + * Inject the `notifications` service. + * + * @memberof LiveMapComponent + */ @service notifications; + + /** + * Inject the `serviceAreas` service. + * + * @memberof LiveMapComponent + */ @service serviceAreas; + + /** + * Inject the `appCache` service. + * + * @memberof LiveMapComponent + */ @service appCache; + + /** + * Inject the `universe` service. + * + * @memberof LiveMapComponent + */ @service universe; + /** + * Inject the `crud` service. + * + * @memberof LiveMapComponent + */ + @service crud; + + /** + * Inject the `contextPanel` service. + * + * @memberof LiveMapComponent + */ + @service contextPanel; + + /** + * Inject the `leafletMapManager` service. + * + * @memberof LiveMapComponent + */ + @service leafletMapManager; + + /** + * Inject the `leafletContextmenuManager` service. + * + * @memberof LiveMapComponent + */ + @service leafletContextmenuManager; + + /** + * An array of routes. + * @type {Array} + */ @tracked routes = []; + + /** + * An array of drivers. + * @type {Array} + */ @tracked drivers = []; + + /** + * An array of vehicles. + * @type {Array} + */ + @tracked vehicles = []; + + /** + * An array of places. + * @type {Array} + */ @tracked places = []; + + /** + * An array of channels. + * @type {Array} + */ @tracked channels = []; + + /** + * Indicates if data is loading. + * @type {boolean} + */ @tracked isLoading = true; + + /** + * Indicates if the component is ready. + * @type {boolean} + */ @tracked isReady = false; - @tracked isDriversVisible = true; - @tracked isPlacesVisible = true; - @tracked isRoutesVisible = true; - @tracked isDrawControlsVisible = false; - @tracked isCreatingServiceArea = false; - @tracked isCreatingZone = false; - @tracked currentContextMenuItems = []; + + /** + * Controls for visibility. + * @type {Object} + */ + @tracked visibilityControls = {}; + + /** + * An array of active service areas. + * @type {Array} + */ @tracked activeServiceAreas = []; + + /** + * An array of editable map layers. + * @type {Array} + */ @tracked editableLayers = []; + + /** + * The Leaflet map instance. + * @type {Object} + */ @tracked leafletMap; - @tracked activeFeatureGroup; + + /** + * The map's zoom level. + * @type {number} + */ + @tracked zoom = 12; + + /** + * The feature group for drawing on the map. + * @type {Object} + */ @tracked drawFeatureGroup; + + /** + * The draw control for the map. + * @type {Object} + */ @tracked drawControl; + + /** + * The URL for the map's tile source. + * @type {string} + */ + @tracked tileSourceUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png'; + + /** + * The latitude for the map view. + * @type {number} + */ @tracked latitude = DEFAULT_LATITUDE; - @tracked longitude = DEFAULT_LONGITUDE; - @tracked skipSetCoordinates = false; - @tracked mapId = guidFor(this); - @alias('currentUser.latitude') userLatitude; - @alias('currentUser.longitude') userLongitude; - @computed('args.zoom') get zoom() { - return this.args.zoom || 12; - } + /** + * The longitude for the map view. + * @type {number} + */ + @tracked longitude = DEFAULT_LONGITUDE; - @computed('args.{tileSourceUrl,darkMode}') get tileSourceUrl() { - const { darkMode, tileSourceUrl } = this.args; + /** + * Indicates if coordinate setting should be skipped. + * @type {boolean} + */ + @tracked skipSetCoordinates = false; - if (darkMode === true) { - return 'https://{s}.tile.jawg.io/jawg-matrix/{z}/{x}/{y}{r}.png?access-token='; - } + /** + * The user's latitude from the currentUser. + * @type {number} + */ + @alias('currentUser.latitude') userLatitude; - return tileSourceUrl ?? 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png'; - } + /** + * The user's longitude from the currentUser. + * @type {number} + */ + @alias('currentUser.longitude') userLongitude; /** * Creates an instance of LiveMapComponent. @@ -69,26 +229,85 @@ export default class LiveMapComponent extends Component { */ constructor() { super(...arguments); + this.skipSetCoordinates = getWithDefault(this.args, 'skipSetCoordinates', false); + this.zoom = getWithDefault(this.args, 'zoom', 12); + this.tileSourceUrl = getWithDefault(this.args, 'tileSourceUrl', 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png'); - this.skipSetCoordinates = this.args.skipSetCoordinates ?? false; + if (this.args.darkMode === true) { + this.tileSourceUrl = 'https://{s}.tile.jawg.io/jawg-matrix/{z}/{x}/{y}{r}.png?access-token='; + } } /** - * ---------------------------------------------------------------------------- - * SETUP - * ---------------------------------------------------------------------------- + * Initializes the LiveMapComponent by triggering events, setting initial coordinates, + * and loading required live data. * - * Functions for initialization and setup. + * @memberof LiveMapComponent + * @action + * @function */ - @action async setupLiveMap() { + @action setupComponent() { // trigger that initial coordinates have been set - this.universe.trigger('livemap.loaded', this); + this.universe.trigger('fleetops.livemap.loaded', this); + + // set initial coordinates + this.setInitialCoordinates(); + + // load required live data + this.fetchLiveData('routes'); + this.fetchLiveData('vehicles', { + onLoaded: (vehicles) => { + this.watchMovingObjects('vehicles', vehicles); + }, + }); + this.fetchLiveData('drivers', { + onLoaded: (drivers) => { + this.watchMovingObjects('drivers', drivers); + }, + }); + this.fetchLiveData('places'); + this.listen(); + this.ready(); + } + + /** + * Marks the LiveMapComponent as ready by setting the "isReady" property and triggering + * the "onReady" action and a "fleetops.livemap.ready" event. + * + * @memberof LiveMapComponent + * @function + */ + ready() { + this.isReady = true; + this.triggerAction('onReady'); + this.universe.trigger('fleetops.livemap.ready', this); + } + /** + * Sets the initial coordinates for the LiveMapComponent. + * + * This function checks if initial coordinates are available in the appCache, and if not, + * it fetches the coordinates using the "getInitialCoordinates" function. It sets the + * latitude and longitude properties and triggers an event to notify that coordinates + * have been set. + * + * @memberof LiveMapComponent + * @async + * @function + * @returns {Promise<[number, number] | null>} An array containing the latitude and longitude + * if available, or null if the function is skipped. + */ + async setInitialCoordinates() { if (this.skipSetCoordinates === false) { if (this.appCache.has(['map_latitude', 'map_longitude'])) { this.latitude = this.appCache.get('map_latitude'); this.longitude = this.appCache.get('map_longitude'); this.isReady = true; + + // trigger that initial coordinates is set to livemap component + this.universe.trigger('fleetops.livemap.has_coordinates', { latitude: this.latitude, longitude: this.longitude }); + + return [this.latitude, this.longitude]; } const { latitude, longitude } = await this.getInitialCoordinates(); @@ -100,39 +319,49 @@ export default class LiveMapComponent extends Component { this.latitude = latitude; this.longitude = longitude; - } - - // trigger that initial coordinates have been set - this.universe.trigger('livemap.has_coordinates', { latitude: this.latitude, longitude: this.longitude }); - - this.routes = await this.fetchActiveRoutes(); - this.drivers = await this.fetchActiveDrivers(); - this.places = await this.fetchActivePlaces(); - this.serviceAreaRecords = await this.fetchServiceAreas(); - this.isReady = true; + this.isReady = true; - this.watchDrivers(this.drivers); - this.listenForOrders(); + // trigger that initial coordinates is set to livemap component + this.universe.trigger('fleetops.livemap.has_coordinates', { latitude: this.latitude, longitude: this.longitude }); - if (typeof this.args.onReady === 'function') { - this.args.onReady(this); + return [this.latitude, this.longitude]; } - // add context event - this.universe.trigger('livemap.ready', this); + return null; } - @action setMapReference(event) { + /** + * Sets up the LiveMap component and the Leaflet map instance. + * + * This function initializes the LiveMap component, associates it with the Leaflet map instance, + * triggers the "fleetops.livemap.leaflet_ready" event, and performs additional setup tasks like + * configuring context menus, hiding draw controls, and associating the map with the "serviceAreas" + * service. It also triggers the "onLoad" action with the provided event and target. + * + * @action + * @function + * @param {Event} event - The event object. + */ + @action setupMap(event) { const { target } = event; // set liveMapComponent component to instance - set(event, 'target.liveMap', this); + set(target, 'liveMap', this); // set map instance this.leafletMap = target; + // trigger liveMap ready through universe + this.universe.trigger('fleetops.livemap.leaflet_ready', event, target); + + // make fleetops map globally available on the window + window.FleetOpsLeafletMap = target; + + // store this component to universe + this.universe.set('FleetOpsLiveMap', this); + // setup context menu - this.setupContextMenu(target); + this.createMapContextMenu(target); // hide draw controls by default this.hideDrawControls(); @@ -140,98 +369,269 @@ export default class LiveMapComponent extends Component { // set instance to service areas service this.serviceAreas.setMapInstance(target); - if (typeof this.args.onLoad === 'function') { - this.args.onLoad(...arguments); - } + // trigger map loaded event + this.triggerAction('onLoad', ...arguments); } - @action setupContextMenu(map) { - if (!map?.contextmenu) { - return; + /** + * Invokes an action by name on the current component and its arguments (if defined). + * + * This function checks if an action with the specified name exists on the current component. + * If found, it invokes the action with the provided parameters. It also checks the component's + * arguments for the action and invokes it if defined. + * + * @action + * @function + * @param {string} actionName - The name of the action to trigger. + * @param {...any} params - Optional parameters to pass to the action. + */ + @action triggerAction(actionName, ...params) { + if (typeof this[actionName] === 'function') { + this[actionName](...params); } - // reset items if any - if (typeof map?.contextmenu?.removeAllItems === 'function') { - map?.contextmenu?.removeAllItems(); + if (typeof this.args[actionName] === 'function') { + this.args[actionName](...params); } + } - const { contextmenu } = map; - const contextMenuItems = this.buildContextMenuItems(); + /** + * Fetches live data from the specified path and updates the component state accordingly. + * + * @memberof LiveMapComponent + * @function + * @param {string} path - The path to fetch live data from. + * @param {Object} [options={}] - Optional configuration options. + * @param {Object} [options.params={}] - Additional parameters to include in the request. + * @param {Function} [options.onLoaded] - A callback function to execute when the data is loaded. + * @returns {Promise} A promise that resolves with the fetched data. + */ + fetchLiveData(path, options = {}) { + this.isLoading = true; - contextMenuItems.forEach((options) => contextmenu.addItem(options)); + const internalName = camelize(path); + const callbackFnName = `on${internalName}Loaded`; + const params = getWithDefault(options, 'params', {}); - if (contextmenu.enabled === true || contextmenu._enabled === true) { - return; - } + return this.fetch + .get(`fleet-ops/live/${path}`, params, { normalizeToEmberData: true, normalizeModelType: singularize(internalName) }) + .then((data) => { + this.triggerAction(callbackFnName); + this.createVisibilityControl(internalName); + this[internalName] = data; - contextmenu.enable(); - } + if (typeof options.onLoaded === 'function') { + options.onLoaded(data); + } - @action toggleDrawControlContextMenuItem() { - const index = this.currentContextMenuItems.findIndex((options) => options.text?.includes('draw controls')); + return data; + }) + .finally(() => { + this.isLoading = false; + }); + } - if (index > 0) { - const options = this.currentContextMenuItems.objectAt(index); + /** + * Creates or updates a visibility control for a specific element by name. + * + * @function + * @param {string} name - The name or identifier for the visibility control. + * @param {boolean} [visible=true] - A boolean value indicating whether the element is initially visible (default is true). + */ + createVisibilityControl(name, visible = true) { + this.visibilityControls = { + ...this.visibilityControls, + [name]: visible, + }; + } - if (!isBlank(options)) { - options.text = this.isDrawControlsVisible ? 'Hide draw controls...' : 'Enable draw controls...'; - } + /** + * Hide all visibility controls associated with the current instance. + */ + hideAll() { + const controls = Object.keys(this.visibilityControls); - this.leafletMap?.contextmenu?.removeItem(index); - this.leafletMap?.contextmenu?.insertItem(options, index); + for (let i = 0; i < controls.length; i++) { + const control = controls.objectAt(i); + this.hide(control); } } - @action removeServiceAreaFromContextMenu(serviceArea) { - const index = this.currentContextMenuItems.findIndex((options) => options.text?.includes(`Focus Service Area: ${serviceArea.name}`)); + /** + * Show all visibility controls associated with the current instance. + */ + showAll() { + const controls = Object.keys(this.visibilityControls); - if (index > 0) { - this.leafletMap?.contextmenu?.removeItem(index); + for (let i = 0; i < controls.length; i++) { + const control = controls.objectAt(i); + this.show(control); } } - @action rebuildContextMenu() { - const map = this.leafletMap; + /** + * Hides a specific element by name using a visibility control. + * + * @function + * @param {string} name - The name or identifier of the element to hide. + */ + hide(name) { + if (isArray(name)) { + return name.forEach(this.hide); + } + + this.createVisibilityControl(name, false); + } - if (map) { - this.setupContextMenu(map); + /** + * Shows a specific element by name using a visibility control. + * + * @function + * @param {string} name - The name or identifier of the element to show. + */ + show(name) { + if (isArray(name)) { + return name.forEach(this.show); } + + this.createVisibilityControl(name, true); } - @action setFn(actionName, callback) { - this[actionName] = callback; + /** + * Check if a specific element or feature is currently visible based on its name. + * + * @param {string} name - The name of the element or feature to check visibility for. + * @returns {boolean} Returns `true` if the element or feature is currently visible, `false` otherwise. + */ + isVisible(name) { + return this.visibilityControls[name] === true; } - @action shouldSkipSettingInitialCoordinates() { - this.skipSetCoordinates = true; + /** + * Toggles the context menu item for enabling/disabling draw controls. + * + * @param {Object} [options] - Optional settings for the context menu item. + * @param {string} [options.onText='Hide draw controls...'] - Text to display when enabling draw controls. + * @param {string} [options.offText='Enable draw controls...'] - Text to display when disabling draw controls. + * @param {string} [options.callback=function] - Callback function to trigger after toggle. + */ + toggleDrawControlContextMenuItem(options = {}) { + const toggle = !this.isVisible('drawControls'); + + this.leafletContextmenuManager.toggleContextMenuItem('map', 'draw controls', { + onText: 'Hide draw controls...', + offText: 'Enable draw controls...', + toggle, + callback: (isToggled) => { + if (isToggled) { + this.showDrawControls(); + } else { + this.hideDrawControls(); + } + }, + ...options, + }); + } + + /** + * Removes a specific service area from the context menu. + * + * @param {Object} serviceArea - The service area to be removed from the context menu. + */ + removeServiceAreaFromContextMenu(serviceArea) { + this.leafletContextmenuManager.removeItemFromContextMenu('map', `Focus Service Area: ${serviceArea.name}`); + } + + /** + * Get a Leaflet layer from the map based on its ID. + * + * @param {string} id - The ID of the Leaflet layer to retrieve. + * @returns {Object|null} The found Leaflet layer or `null` if not found. + */ + getLeafletLayerById(id) { + return this.leafletMapManager.getLeafletLayerById(this.leafletMap, id); + } + + /** + * Find a specific Leaflet layer on the map using a callback function. + * + * @param {Function} callback - A callback function that defines the condition for finding the layer. + * @returns {Object|null} The found Leaflet layer or `null` if not found. + */ + findLeafletLayer(callback) { + return this.leafletMapManager.findLeafletLayer(this.leafletMap, callback); + } + + /** + * Find an editable layer in the collection by its record ID. + * + * @param {Object} record - The record with the ID used for lookup. + * @returns {Layer|null} The found editable layer, or null if not found. + * @memberof LiveMapComponent + */ + getLeafletLayerByRecordId(record) { + const id = getWithDefault(record, 'id', record); + let targetLayer = null; + + this.leafletMap.eachLayer((layer) => { + // Check if the layer has an ID property + if (layer.record_id === id) { + targetLayer = layer; + } + }); + + return targetLayer; } /** - * ---------------------------------------------------------------------------- - * TRACKED LAYER UTILITIES - * ---------------------------------------------------------------------------- + * Push an editable layer to the collection of editable layers. * - * Functions provide utility for managing tracked editable layers. + * @param {Layer} layer - The layer to be added to the collection. + * @memberof LiveMapComponent */ - @action pushEditableLayer(layer) { + pushEditableLayer(layer) { if (!this.editableLayers.includes(layer)) { this.editableLayers.pushObject(layer); } } - @action removeEditableLayerByRecordId(record) { - const index = this.editableLayers.findIndex((layer) => layer.record_id === record?.id ?? record); + /** + * Remove an editable layer from the collection by its record ID. + * + * @param {Object} record - The record with the ID used for removal. + * @memberof LiveMapComponent + */ + removeEditableLayerByRecordId(record) { + const id = getWithDefault(record, 'id', record); + const index = this.editableLayers.findIndex((layer) => layer.record_id === id); const layer = this.editableLayers.objectAt(index); - this.drawFeatureGroup?.addLayer(layer); - this.editableLayers.removeAt(index); + if (this.drawFeatureGroup) { + this.drawFeatureGroup.addLayer(layer); + this.editableLayers.removeAt(index); + } } - @action findEditableLayerByRecordId(record) { - return this.editableLayers.find((layer) => layer.record_id === record?.id ?? record); + /** + * Find an editable layer in the collection by its record ID. + * + * @param {Object} record - The record with the ID used for lookup. + * @returns {Layer|null} The found editable layer, or null if not found. + * @memberof LiveMapComponent + */ + findEditableLayerByRecordId(record) { + const id = getWithDefault(record, 'id', record); + return this.editableLayers.find((layer) => layer.record_id === id); } - @action peekRecordForLayer(layer) { + /** + * Peek a record for a given layer by its record ID and type. + * + * @param {Layer} layer - The layer associated with a record. + * @returns {Object|null} The peeked record, or null if not found. + * @memberof LiveMapComponent + */ + peekRecordForLayer(layer) { if (layer.record_id && layer.record_type) { return this.store.peekRecord(dasherize(layer.record_type), layer.record_id); } @@ -240,27 +640,22 @@ export default class LiveMapComponent extends Component { } /** - * ---------------------------------------------------------------------------- - * LAYER EVENTS - * ---------------------------------------------------------------------------- + * Handle the 'drawstop' event. * - * Functions that are only triggered from the `LiveMap` Leaflet Layer Component callbacks. + * @param {Event} event - The 'drawstop' event object. + * @param {Layer} layer - The layer associated with the event. + * @memberof LiveMapComponent */ - - @action onAction(actionName, ...params) { - if (typeof this[actionName] === 'function') { - this[actionName](...params); - } - - if (typeof this.args[actionName] === 'function') { - this.args[actionName](...params); - } - } - @action onDrawDrawstop(event, layer) { this.serviceAreas.createGenericLayer(event, layer); } + /** + * Handle the 'deleted' event for drawn elements. + * + * @param {Event} event - The 'deleted' event object. + * @memberof LiveMapComponent + */ @action onDrawDeleted(event) { /** @var {L.LayerGroup} layers */ const { layers } = event; @@ -278,6 +673,12 @@ export default class LiveMapComponent extends Component { }); } + /** + * Handle the 'edited' event for drawn elements. + * + * @param {Event} event - The 'edited' event object. + * @memberof LiveMapComponent + */ @action onDrawEdited(event) { /** @var {L.LayerGroup} layers */ const { layers } = event; @@ -301,89 +702,219 @@ export default class LiveMapComponent extends Component { allSettled(requests); } + /** + * Handle the addition of a service area layer. + * + * @param {ServiceAreaModel} serviceArea - The service area object. + * @param {Event} event - The event object associated with the addition. + * @memberof LiveMapComponent + */ @action onServiceAreaLayerAdded(serviceArea, event) { const { target } = event; set(target, 'record_id', serviceArea.id); set(target, 'record_type', 'service-area'); - // add to draw feature group - this.drawFeatureGroup?.addLayer(target); + // set the layer instance to the serviceArea model + set(serviceArea, '_layer', target); + + if (this.drawFeatureGroup) { + // add to draw feature group + this.drawFeatureGroup.addLayer(target); + } // this.flyToBoundsOnly(target); - this.createContextMenuForServiceArea(serviceArea, target); + this.createServiceAreaContextMenu(serviceArea, target); this.pushEditableLayer(target); } + /** + * Handle the addition of a zone layer. + * + * @param {ZoneModel} zone - The zone object. + * @param {Event} event - The event object associated with the addition. + * @memberof LiveMapComponent + */ @action onZoneLayerAdd(zone, event) { const { target } = event; set(target, 'record_id', zone.id); set(target, 'record_type', 'zone'); - // add to draw feature group - this.drawFeatureGroup?.addLayer(target); + // set the layer instance to the zone model + set(zone, '_layer', target); - this.createContextMenuForZone(zone, target); + if (this.drawFeatureGroup) { + // add to draw feature group + this.drawFeatureGroup.addLayer(target); + } + + this.createZoneContextMenu(zone, target); this.pushEditableLayer(target); } + /** + * Handle the creation of the draw feature group. + * + * @param {DrawFeatureGroup} drawFeatureGroup - The draw feature group instance. + * @memberof LiveMapComponent + */ @action onDrawFeatureGroupCreated(drawFeatureGroup) { this.drawFeatureGroup = drawFeatureGroup; } - @action onDriverAdded(driver, event) { - const { target } = event; + /** + * Handle the addition of a driver marker. + * + * @param {DriverModel} driver - The driver object. + * @param {Event} event - The event object associated with the addition. + * @memberof LiveMapComponent + */ + @action onDriverAdded(driver, event) { + const { target } = event; + + set(target, 'record_id', driver.id); + set(target, 'record_type', 'driver'); + + // set the marker instance to the driver model + set(driver, '_marker', target); + + this.createDriverContextMenu(driver, target); + } + + /** + * Handle the click event of a driver marker. + * + * @param {DriverModel} driver - The driver object. + * @param {Event} event - The event object associated with the addition. + * @memberof LiveMapComponent + */ + @action onDriverClicked(driver) { + this.contextPanel.focus(driver); + } + + /** + * Handle the addition of a vehicle marker. + * + * @param {VehicleModel} vehicle - The vehicle object. + * @param {Event} event - The event object associated with the addition. + * @memberof LiveMapComponent + */ + @action onVehicleAdded(vehicle, event) { + const { target } = event; + + set(target, 'record_id', vehicle.id); + set(target, 'record_type', 'vehicle'); + + // set the marker instance to the vehicle model + set(vehicle, '_marker', target); + + this.createVehicleContextMenu(vehicle, target); + } + + /** + * Handle the click event of a vehicle marker. + * + * @param {VehicleModel} vehicle - The vehicle object. + * @param {Event} event - The event object associated with the addition. + * @memberof LiveMapComponent + */ + @action onVehicleClicked(vehicle) { + this.contextPanel.focus(vehicle); + } + + /** + * Handle the creation of the draw control. + * + * @param {DrawControl} drawControl - The draw control instance. + * @memberof LiveMapComponent + */ + @action onDrawControlCreated(drawControl) { + this.drawControl = drawControl; + } + + /** + * Hide the draw controls on the map. + * + * @param {Object} [options={}] - Additional options. + * @param {string|boolean} [options.text] - Text to set for the menu item or `true` to set the default text. + * @param {function} [options.callback] - A callback function to execute. + * @memberof LiveMapComponent + */ + @action hideDrawControls(options = {}) { + this.hide('drawControls'); - // set the marker instance to the driver model - set(driver, '_marker', target); + const text = getWithDefault(options, 'text'); + const callback = getWithDefault(options, 'callback'); - console.log('onDriverAdded()', ...arguments); + if (typeof callback === 'function') { + callback(); + } - this.createContextMenuForDriver(driver, target); - } + if (typeof text === 'string') { + this.leafletContextmenuManager.changeMenuItemText('map', 'draw controls', text); + } - @action onDrawControlCreated(drawControl) { - this.drawControl = drawControl; + if (text === true) { + this.leafletContextmenuManager.changeMenuItemText('map', 'draw controls', 'Enable draw controls...'); + } + + if (this.drawControl) { + this.leafletMap.removeControl(this.drawControl); + } } /** - * ---------------------------------------------------------------------------- - * LEAFLET UTILITIES - * ---------------------------------------------------------------------------- - * - * Functions are used to help or utilize on Leaflet Layers/ Controls. + * Show the draw controls on the map. * + * @param {Object} [options={}] - Additional options. + * @param {string|boolean} [options.text] - Text to set for the menu item or `true` to set the default text. + * @param {function} [options.callback] - A callback function to execute. + * @memberof LiveMapComponent */ - @action removeDrawingControl() { - if (isBlank(this.drawControl)) { - return; + @action showDrawControls(options = {}) { + this.show('drawControls'); + + const text = getWithDefault(options, 'text'); + const callback = getWithDefault(options, 'callback'); + + if (typeof callback === 'function') { + callback(); } - this.isDrawControlsVisible = false; - this.leafletMap?.removeControl(this.drawControl); - this.toggleDrawControlContextMenuItem(); - } + if (typeof text === 'string') { + this.leafletContextmenuManager.changeMenuItemText('map', 'draw controls', text); + } - // alias for `removeDrawingControl()` - @action hideDrawControls() { - this.removeDrawingControl(); - } + if (text === true) { + this.leafletContextmenuManager.changeMenuItemText('map', 'draw controls', 'Hide draw controls...'); + } - @action enableDrawControls() { - this.isDrawControlsVisible = true; - this.leafletMap?.addControl(this.drawControl); - this.toggleDrawControlContextMenuItem(); + if (this.drawControl) { + this.leafletMap.addControl(this.drawControl); + } } - @action focusLayerByRecord(record) { - const layer = this.findEditableLayerByRecordId(record); + /** + * Focus on a layer associated with a record. + * + * @param {Object} record - The record to focus on. + * @memberof LiveMapComponent + */ + @action focusLayerBoundsByRecord(record) { + const layer = this.getLeafletLayerByRecordId(record); if (layer) { this.flyToBoundsOnly(layer); } } + /** + * Fly to a service area layer on the map. + * + * @param {ServiceAreaModel} serviceArea - The service area object to fly to. + * @memberof LiveMapComponent + */ @action flyToServiceArea(serviceArea) { const layer = this.findEditableLayerByRecordId(serviceArea); @@ -392,11 +923,12 @@ export default class LiveMapComponent extends Component { } } - // alias for `flyToServiceArea()` - @action jumpToServiceArea(serviceArea) { - return this.flyToServiceArea(serviceArea); - } - + /** + * Focus on a service area by activating it and then flying to it on the map. + * + * @param {ServiceArea} serviceArea - The service area to focus on. + * @memberof LiveMapComponent + */ @action focusServiceArea(serviceArea) { this.activateServiceArea(serviceArea); @@ -409,7 +941,13 @@ export default class LiveMapComponent extends Component { ); } - @action blurAllServiceAreas(except = []) { + /** + * Blur all service areas except for those specified in the 'except' array. + * + * @param {Array} except - An array of records to exclude from blurring. + * @memberof LiveMapComponent + */ + blurAllServiceAreas(except = []) { if (!isArray(except)) { except = []; } @@ -441,7 +979,13 @@ export default class LiveMapComponent extends Component { } } - @action focusAllServiceAreas(except = []) { + /** + * Focus on all service areas except for those specified in the 'except' array by activating them. + * + * @param {Array} except - An array of records to exclude from activation. + * @memberof LiveMapComponent + */ + focusAllServiceAreas(except = []) { if (!isArray(except)) { except = []; } @@ -463,93 +1007,143 @@ export default class LiveMapComponent extends Component { } } - @action blurServiceArea(serviceArea) { + /** + * Blur a specific service area by removing it from the active service areas. + * + * @param {ServiceAreaModel} serviceArea - The service area to blur. + * @memberof LiveMapComponent + */ + blurServiceArea(serviceArea) { if (this.activeServiceAreas.includes(serviceArea)) { this.activeServiceAreas.removeObject(serviceArea); } } - @action activateServiceArea(serviceArea) { + /** + * Activate a service area by adding it to the active service areas. + * + * @param {ServiceAreaModel} serviceArea - The service area to activate. + * @memberof LiveMapComponent + */ + activateServiceArea(serviceArea) { if (!this.activeServiceAreas.includes(serviceArea)) { this.activeServiceAreas.pushObject(serviceArea); } } - @action hideDrivers() { - this.isDriversVisible = false; - } - - @action showDrivers() { - this.isDriversVisible = true; - } - - @action toggleDrivers() { - this.isDriversVisible = !this.isDriversVisible; - } - - @action hidePlaces() { - this.isPlacesVisible = false; - } - - @action showPlaces() { - this.isPlacesVisible = true; - } - - @action togglePlaces() { - this.isPlacesVisible = !this.isPlacesVisible; - } - - @action hideRoutes() { - this.isRoutesVisible = false; - } - - @action showRoutes() { - this.isRoutesVisible = true; - } - - @action toggleRoutes() { - this.isRoutesVisible = !this.isRoutesVisible; - } - + /** + * Show coordinates information by displaying them as an info notification. + * + * @param {Event} event - The event containing latitude and longitude information. + * @memberof LiveMapComponent + */ @action showCoordinates(event) { this.notifications.info(event.latlng); } + /** + * Center the map on a specific location provided in the event. + * + * @param {Event} event - The event containing the target location (latlng). + * @memberof LiveMapComponent + */ @action centerMap(event) { - this.leafletMap?.panTo(event.latlng); + this.leafletMap.panTo(event.latlng); } + /** + * Zoom in on the map. + * + * @memberof LiveMapComponent + */ @action zoomIn() { - this.leafletMap?.zoomIn(); + this.leafletMap.zoomIn(); } + /** + * Zoom out on the map. + * + * @memberof LiveMapComponent + */ @action zoomOut() { - this.leafletMap?.zoomOut(); + this.leafletMap.zoomOut(); + } + + /** + * Set the maximum bounds of the map based on the provided layer's bounds. + * + * @param {Layer} layer - The layer used to determine the map's maximum bounds. + * @memberof LiveMapComponent + */ + setMaxBoundsFromLayer(layer) { + if (layer && typeof layer.getBounds === 'function') { + const bounds = layer.getBounds(); + + this.leafletMap.flyToBounds(bounds); + this.leafletMap.setMaxBounds(bounds); + } } - @action setMaxBoundsFromLayer(layer) { - const bounds = layer?.getBounds(); + /** + * Fly to and focus on a specific layer's bounds on the map. + * + * @param {Layer} layer - The layer to focus on. + * @memberof LiveMapComponent + */ + flyToBoundsOnly(layer) { + if (layer && typeof layer.getBounds === 'function') { + const bounds = layer.getBounds(); - this.leafletMap?.flyToBounds(bounds); - this.leafletMap?.setMaxBounds(bounds); + this.leafletMap.flyToBounds(bounds); + } } - @action flyToBoundsOnly(layer) { - const bounds = layer?.getBounds(); + /** + * Focus on a specific layer and optionally zoom in/out on it. + * + * @param {Layer} layer - The layer to focus on. + * @param {number} zoom - The zoom level for the focus operation. + * @param {Object} options - Additional options for the focus operation. + * @memberof LiveMapComponent + */ + @action focusLayer(layer, zoom, options = {}) { + this.leafletMapManager.flyToLayer(this.leafletMap, layer, zoom, options); - this.leafletMap?.flyToBounds(bounds); + if (typeof options.onAfterFocus === 'function') { + options.onAfterFocus(layer); + } } /** - * ---------------------------------------------------------------------------- - * CONTEXT MENU INITIALIZERS - * ---------------------------------------------------------------------------- + * Focuses the Leaflet map on a specific layer associated with a record. * - * Functions that are used to build context menu for layers and controls on the map. + * @param {Object} record - The record associated with the target layer. + * @param {number} zoom - The desired zoom level for the map. + * @param {Object} [options={}] - Additional options for the map focus. + * @returns {void} * + * @example + * focusLayerByRecord(recordData, 12, { animate: true }); */ + @action focusLayerByRecord(record, zoom, options = {}) { + const layer = this.getLeafletLayerByRecordId(record); + + if (layer) { + this.focusLayer(layer, zoom, options); + } + + if (typeof options.onAfterFocusWithRecord === 'function') { + options.onAfterFocusWithRecord(record, layer); + } + } - @action buildContextMenuItems() { + /** + * Create a context menu for the map with various options. + * + * @param {L.Map} map - The map to which the context menu is attached. + * @memberof LiveMapComponent + */ + @action createMapContextMenu(map) { const contextmenuItems = [ { text: 'Show coordinates...', @@ -572,8 +1166,8 @@ export default class LiveMapComponent extends Component { index: 3, }, { - text: this.isDrawControlsVisible ? `Hide draw controls...` : `Enable draw controls...`, - callback: () => (this.isDrawControlsVisible ? this.hideDrawControls() : this.enableDrawControls()), + text: this.isVisible('drawControls') ? `Hide draw controls...` : `Enable draw controls...`, + callback: this.toggleDrawControlContextMenuItem.bind(this), index: 4, }, { @@ -586,67 +1180,159 @@ export default class LiveMapComponent extends Component { }, ]; - // add service areas to context menu - const serviceAreas = this.serviceAreas.getFromCache() ?? []; + // Add Service Area Context Menu Items + const serviceAreas = this.serviceAreas.getFromCache(); - if (serviceAreas?.length > 0) { + if (isArray(serviceAreas)) { contextmenuItems.pushObject({ separator: true, }); - } - // add to context menu - for (let i = 0; i < serviceAreas?.length; i++) { - const serviceArea = serviceAreas.objectAt(i); + // Add for each Service Area + for (let i = 0; i < serviceAreas.length; i++) { + const serviceArea = serviceAreas.objectAt(i); + const nextIndex = contextmenuItems.length + 2; - contextmenuItems.pushObject({ - text: `Focus Service Area: ${serviceArea.name}`, - callback: () => this.focusServiceArea(serviceArea), - index: (contextmenuItems.lastObject?.index ?? 0) + 1 + i, - }); + contextmenuItems.pushObject({ + text: `Focus Service Area: ${serviceArea.name}`, + callback: () => this.focusServiceArea(serviceArea), + index: nextIndex, + }); + } } - this.currentContextMenuItems = contextmenuItems; + // create contextmenu registry + const contextmenuRegistry = this.leafletContextmenuManager.createContextMenu('map', map, contextmenuItems); + + // trigger that contextmenu registry was created + this.universe.createRegistryEvent('contextmenu:map', 'created', contextmenuRegistry, this.leafletContextmenuManager); - return contextmenuItems; + return contextmenuRegistry; + } + + /** + * Rebuild the context menu for the map. + * This function calls the `createMapContextMenu` method. + * @memberof LiveMapComponent + */ + rebuildMapContextMenu() { + this.createMapContextMenu(this.leafletMap); } - @action createContextMenuForDriver(driver, layer) { + /** + * Create a context menu for a driver marker on the map. + * + * @param {DriverModel} driver - The driver associated with the marker. + * @param {L.Layer} layer - The layer representing the driver marker. + * @memberof LiveMapComponent + */ + @action createDriverContextMenu(driver, layer) { const contextmenuItems = [ { separator: true, }, { text: `View Driver: ${driver.name}`, - // callback: () => this.editServiceAreaDetails(serviceArea) + callback: () => this.contextPanel.focus(driver), }, { text: `Edit Driver: ${driver.name}`, - // callback: () => this.editServiceAreaDetails(serviceArea) + callback: () => this.contextPanel.focus(driver, 'editing'), }, { text: `Delete Driver: ${driver.name}`, - // callback: () => this.deleteServiceArea(serviceArea) + callback: () => this.crud.delete(driver), + }, + { + text: `View Vehicle for: ${driver.name}`, + callback: () => this.contextPanel.focus(driver.vehicle), + }, + ]; + + // append items from universe registry + const registeredContextMenuItems = this.universe.getMenuItemsFromRegistry('contextmenu:driver'); + if (isArray(registeredContextMenuItems)) { + contextmenuItems = [ + ...contextmenuItems, + ...registeredContextMenuItems.map((menuItem) => { + return { + text: menuItem.title, + callback: () => { + return menuItem.onClick(driver, layer, menuItem); + }, + }; + }), + ]; + } + + // create contextmenu registry + const contextmenuRegistry = this.leafletContextmenuManager.createContextMenu(`driver:${driver.public_id}`, layer, contextmenuItems, { driver }); + + // trigger that contextmenu registry was created + this.universe.createRegistryEvent('contextmenu:driver', 'created', contextmenuRegistry, this.leafletContextmenuManager); + + return contextmenuRegistry; + } + + /** + * Create a context menu for a vehicle marker on the map. + * + * @param {Vehicle} vehicle - The vehicle associated with the marker. + * @param {Layer} layer - The layer representing the vehicle marker. + * @memberof LiveMapComponent + */ + @action createVehicleContextMenu(vehicle, layer) { + let contextmenuItems = [ + { + separator: true, }, { - text: `Assign Order to Driver: ${driver.name}`, - // callback: () => this.deleteServiceArea(serviceArea) + text: `View Vehicle: ${vehicle.displayName}`, + callback: () => this.contextPanel.focus(vehicle), }, { - text: `View Vehicle for: ${driver.name}`, - // callback: () => this.deleteServiceArea(serviceArea) + text: `Edit Vehicle: ${vehicle.displayName}`, + callback: () => this.contextPanel.focus(vehicle, 'editing'), + }, + { + text: `Delete Vehicle: ${vehicle.displayName}`, + callback: () => this.crud.delete(vehicle), }, ]; - if (typeof layer?.bindContextMenu === 'function') { - layer.bindContextMenu({ - contextmenu: true, - contextmenuItems, - }); + // append items from universe registry + const registeredContextMenuItems = this.universe.getMenuItemsFromRegistry('contextmenu:vehicle'); + if (isArray(registeredContextMenuItems)) { + contextmenuItems = [ + ...contextmenuItems, + ...registeredContextMenuItems.map((menuItem) => { + return { + text: menuItem.title, + callback: () => { + return menuItem.onClick(vehicle, layer, menuItem); + }, + }; + }), + ]; } + + // create contextmenu registry + const contextmenuRegistry = this.leafletContextmenuManager.createContextMenu(`vehicle:${vehicle.public_id}`, layer, contextmenuItems, { vehicle }); + + // trigger that contextmenu registry was created + this.universe.createRegistryEvent('contextmenu:vehicle', 'created', contextmenuRegistry, this.leafletContextmenuManager); + + return contextmenuRegistry; } - @action createContextMenuForZone(zone, layer) { + /** + * Create a context menu for a zone layer on the map. + * + * @param {ZoneModel} zone - The zone associated with the layer. + * @param {Layer} layer - The layer representing the zone. + * @memberof LiveMapComponent + */ + @action createZoneContextMenu(zone, layer) { const contextmenuItems = [ { separator: true, @@ -670,15 +1356,23 @@ export default class LiveMapComponent extends Component { }, ]; - if (typeof layer?.bindContextMenu === 'function') { - layer.bindContextMenu({ - contextmenu: true, - contextmenuItems, - }); - } + // create contextmenu registry + const contextmenuRegistry = this.leafletContextmenuManager.createContextMenu(`zone:${zone.public_id}`, layer, contextmenuItems, { zone }); + + // trigger that contextmenu registry was created + this.universe.createRegistryEvent('contextmenu:zone', 'created', contextmenuRegistry, this.leafletContextmenuManager); + + return contextmenuRegistry; } - @action createContextMenuForServiceArea(serviceArea, layer) { + /** + * Create a context menu for a service area layer on the map. + * + * @param {ServiceAreaModel} serviceArea - The service area associated with the layer. + * @param {Layer} layer - The layer representing the service area. + * @memberof LiveMapComponent + */ + @action createServiceAreaContextMenu(serviceArea, layer) { const contextmenuItems = [ { separator: true, @@ -704,30 +1398,32 @@ export default class LiveMapComponent extends Component { callback: () => this.serviceAreas.deleteServiceArea(serviceArea, { onFinish: () => { - this.rebuildContextMenu(); + this.rebuildMapContextMenu(); this.removeEditableLayerByRecordId(serviceArea); }, }), }, ]; - if (typeof layer?.bindContextMenu === 'function') { - layer.bindContextMenu({ - contextmenu: true, - contextmenuItems, - }); - } + // create contextmenu registry + const contextmenuRegistry = this.leafletContextmenuManager.createContextMenu(`service-area:${serviceArea.public_id}`, layer, contextmenuItems, { serviceArea }); + + // trigger that contextmenu registry was created + this.universe.createRegistryEvent('contextmenu:service-area', 'created', contextmenuRegistry, this.leafletContextmenuManager); + + return contextmenuRegistry; } /** - * ---------------------------------------------------------------------------- - * Async/Socket Functions - * ---------------------------------------------------------------------------- + * Listens for events on the company channel and logs incoming data. * - * Functions are used to fetch date or handle socket callbacks/initializations. + * This function sets up a WebSocket connection, subscribes to the company-specific channel, + * and listens for events. When events are received, it logs them to the console. * + * @async + * @function */ - @action async listenForOrders() { + async listen() { // setup socket const socket = this.socket.instance(); @@ -751,26 +1447,49 @@ export default class LiveMapComponent extends Component { })(); } - @action watchDrivers(drivers = []) { - // setup socket + /** + * Watches and manages moving objects (e.g., drivers or vehicles) on the LiveMapComponent. + * + * This function can be used to watch different types of moving objects by specifying + * the 'type' parameter. + * + * @action + * @function + * @param {string} objectType - The type of moving object to watch (e.g., 'drivers', 'vehicles'). + * @param {Array} movingObjects - An array of moving objects to watch. + */ + watchMovingObjects(objectType, objects = []) { + // Setup socket const socket = this.socket.instance(); - // listen for stivers - for (let i = 0; i < drivers.length; i++) { - const driver = drivers.objectAt(i); - this.listenForDriver(driver, socket); + // Listen for moving objects + for (let i = 0; i < objects.length; i++) { + const movingObject = objects.objectAt(i); + this.listenForMovingObject(objectType, movingObject, socket); } } - @action async listenForDriver(driver, socket) { - // listen on company channel - const channelId = `driver.${driver.id}`; + /** + * Listens for events related to a specific type of moving object (e.g., driver or vehicle) and manages the associated marker. + * + * This function subscribes to the channel corresponding to the provided 'objectType' and the specific 'movingObject' + * to listen for location-related events. It processes and updates the associated marker when events are received. + * + * @async + * @function + * @param {string} objectType - The type of moving object being watched (e.g., 'drivers', 'vehicles'). + * @param {Object} movingObject - The specific moving object to track. + * @param {Socket} socket - The WebSocket instance used for communication. + */ + async listenForMovingObject(objectType, movingObject, socket) { + // Listen on the specific channel + const channelId = `${objectType}.${movingObject.id}`; const channel = socket.subscribe(channelId); - // track channel + // Track the channel this.channels.pushObject(channel); - // listen to channel for events + // Listen to the channel for events await channel.listener('subscribe').once(); // Initialize an empty buffer to store incoming events @@ -787,11 +1506,19 @@ export default class LiveMapComponent extends Component { // Process sorted events for (const output of eventBuffer) { const { event, data } = output; + + // log incoming event console.log(`${event} - #${data.additionalData.index} (${output.created_at}) [ ${data.location.coordinates.join(' ')} ]`); - // update driver heading degree - driver._marker.setRotationAngle(data.heading); - // move driver's marker to new coordinates - driver._marker.slideTo(data.location.coordinates, { duration: 2000 }); + + // get movingObject marker + const objectMarker = movingObject._layer; + + if (objectMarker) { + // Update the object's heading degree + objectMarker.setRotationAngle(data.heading); + // Move the object's marker to new coordinates + objectMarker.slideTo(data.location.coordinates, { duration: 2000 }); + } } // Clear the buffer @@ -801,12 +1528,12 @@ export default class LiveMapComponent extends Component { // Start a timer to process the buffer at intervals setInterval(processBuffer, bufferTime); - // get incoming data and console out + // Get incoming data and console out (async () => { for await (let output of channel) { const { event } = output; - if (event === 'driver.location_changed' || event === 'driver.simulated_location_changed') { + if (event === `${objectType}.location_changed` || event === `${objectType}.simulated_location_changed`) { // Add the incoming event to the buffer eventBuffer.push(output); } @@ -814,15 +1541,27 @@ export default class LiveMapComponent extends Component { })(); } + /** + * Close all socket channels associated subscribed to. + * @memberof LiveMapComponent + */ @action closeChannels() { - for (let i = 0; i < this.channels.length; i++) { - const channel = this.channels.objectAt(i); + if (isArray(this.channels)) { + for (let i = 0; i < this.channels.length; i++) { + const channel = this.channels.objectAt(i); - channel.close(); + channel.close(); + } } } - @action getInitialCoordinates() { + /** + * Retrieve the initial coordinates for the map view. + * + * @returns {Promise} A promise that resolves to an object containing latitude and longitude. + * @memberof LiveMapComponent + */ + getInitialCoordinates() { const initialCoordinates = { latitude: DEFAULT_LATITUDE, longitude: DEFAULT_LONGITUDE, @@ -880,73 +1619,13 @@ export default class LiveMapComponent extends Component { }); } - @action fetchActiveRoutes() { - this.isLoading = true; - - return new Promise((resolve) => { - this.fetch - .get('fleet-ops/live/routes') - .then((routes) => { - this.isLoading = false; - - if (typeof this.args.onRoutesLoaded === 'function') { - this.args.onRoutesLoaded(routes); - } - - resolve(routes); - }) - .catch(() => { - resolve([]); - }); - }); - } - - @action fetchActiveDrivers() { - this.isLoading = true; - - return new Promise((resolve) => { - this.fetch - .get('fleet-ops/live/drivers', {}, { normalizeToEmberData: true, normalizeModelType: 'driver' }) - .then((drivers) => { - this.isLoading = false; - - if (typeof this.args.onDriversLoaded === 'function') { - this.args.onDriversLoaded(drivers); - } - - resolve(drivers); - }) - .catch(() => { - resolve([]); - }); - }); - } - - @action fetchActivePlaces() { - this.isLoading = true; - - // get the center of map - const center = this.leafletMap.getCenter(); - - return new Promise((resolve) => { - this.fetch - .get('fleet-ops/live/places', { within: center }, { normalizeToEmberData: true, normalizeModelType: 'place' }) - .then((places) => { - this.isLoading = false; - - if (typeof this.args.onPlacesLoaded === 'function') { - this.args.onPlacesLoaded(places); - } - - resolve(places); - }) - .catch(() => { - resolve([]); - }); - }); - } - - @action fetchServiceAreas() { + /** + * Fetch service areas and cache them if not already cached. + * + * @returns {Promise} A promise that resolves to an array of service area records. + * @memberof LiveMapComponent + */ + fetchServiceAreas() { this.isLoading = true; return new Promise((resolve) => { @@ -968,14 +1647,26 @@ export default class LiveMapComponent extends Component { }); } - @action getLocalLatitude() { + /** + * Get the local latitude for the map view. + * + * @returns {number} The local latitude. + * @memberof LiveMapComponent + */ + getLocalLatitude() { const whois = this.currentUser.getOption('whois'); const latitude = this.appCache.get('map_latitude'); return latitude || whois?.latitude || DEFAULT_LATITUDE; } - @action getLocalLongitude() { + /** + * Get the local longitude for the map view. + * + * @returns {number} The local longitude. + * @memberof LiveMapComponent + */ + getLocalLongitude() { const whois = this.currentUser.getOption('whois'); const longitude = this.appCache.get('map_longitude'); diff --git a/addon/components/settings-window.hbs b/addon/components/settings-window.hbs index eea2788b..c0f95419 100644 --- a/addon/components/settings-window.hbs +++ b/addon/components/settings-window.hbs @@ -1,4 +1,4 @@ - +
-
-
- {{@vehicle.name}} + {{this.vehicle.name}} - +

- {{#if @vehicle.id}} - {{@vehicle.displayName}} + {{#if this.vehicle.id}} + {{this.vehicle.displayName}} {{else}} - {{#if @vehicle.displayName}} - {{@vehicle.displayName}} + {{#if this.vehicle.displayName}} + {{this.vehicle.displayName}} {{else}} New Vehicle {{/if}} {{/if}}

- {{#if @vehicle.driver}} + {{#if this.vehicle.driver}}
- {{@vehicle.driver.name}} + {{this.vehicle.driver.name}}
{{else}}
@@ -49,7 +49,7 @@
- +
@@ -62,50 +62,50 @@ - +
- +
- +
- +
- +
- +
- + {{model.name}}
@@ -116,13 +116,13 @@
- + {{titleize (humanize key)}}
- {{@vehicle.public_id}} + {{this.vehicle.public_id}}
diff --git a/addon/components/vehicle-form-panel.js b/addon/components/vehicle-form-panel.js index 9f821ddb..51c85478 100644 --- a/addon/components/vehicle-form-panel.js +++ b/addon/components/vehicle-form-panel.js @@ -7,9 +7,33 @@ export default class VehicleFormPanelComponent extends Component { @service notifications; @service hostRouter; @service loader; + @service contextPanel; + + constructor() { + super(...arguments); + this.vehicle = this.args.vehicle; + this.applyDynamicArguments(); + } + + applyDynamicArguments() { + // Apply context if available + if (this.args.context) { + this.vehicle = this.args.context; + } + + // Apply dynamic arguments if available + if (this.args.dynamicArgs) { + const keys = Object.keys(this.args.dynamicArgs); + + keys.forEach((key) => { + this[key] = this.args.dynamicArgs[key]; + }); + } + } @action save() { - const { vehicle, onAfterSave } = this.args; + const { onAfterSave } = this.args; + const { vehicle } = this; this.loader.showLoader('.overlay-inner-content', 'Saving vehicle...'); @@ -33,7 +57,6 @@ export default class VehicleFormPanelComponent extends Component { } @action viewDetails() { - const { vehicle } = this.args; - return this.hostRouter.transitionTo('console.fleet-ops.management.vehicles.index.details', vehicle.public_id); + this.contextPanel.focus(this.vehicle, 'viewing'); } } diff --git a/addon/components/vehicle-panel.hbs b/addon/components/vehicle-panel.hbs index eb267044..06f281b3 100644 --- a/addon/components/vehicle-panel.hbs +++ b/addon/components/vehicle-panel.hbs @@ -1,8 +1,7 @@ - +
- {{!--
@@ -13,17 +12,17 @@
- {{@vehicle.name}} + {{this.vehicle.name}} - +
-

{{@vehicle.display_name}}

+

{{this.vehicle.display_name}}

- {{#if @vehicle.driver}} + {{#if this.vehicle.driver}}
- {{@vehicle.driver.name}} + {{this.vehicle.driver.name}}
{{else}}
@@ -36,7 +35,7 @@
- +
@@ -54,7 +53,7 @@
- {{component this.tab.component vehicle=@vehicle tabOptions=this.tab params=this.tab.componentParams}} + {{component this.tab.component vehicle=this.vehicle tabOptions=this.tab params=this.tab.componentParams}}
\ No newline at end of file diff --git a/addon/components/vehicle-panel.js b/addon/components/vehicle-panel.js index f3c6d885..648a6656 100644 --- a/addon/components/vehicle-panel.js +++ b/addon/components/vehicle-panel.js @@ -11,6 +11,7 @@ export default class VehiclePanelComponent extends Component { @service universe; @service store; @service hostRouter; + @service contextPanel; @tracked currentTab; @tracked devices = []; @tracked deviceApi = {}; @@ -39,10 +40,27 @@ export default class VehiclePanelComponent extends Component { constructor() { super(...arguments); this.vehicle = this.args.vehicle; - this.changeTab(this.args.tab || 'details'); + this.changeTab(this.args.tab); + this.applyDynamicArguments(); } - @action async changeTab(tab) { + applyDynamicArguments() { + // Apply context if available + if (this.args.context) { + this.vehicle = this.args.context; + } + + // Apply dynamic arguments if available + if (this.args.dynamicArgs) { + const keys = Object.keys(this.args.dynamicArgs); + + keys.forEach((key) => { + this[key] = this.args.dynamicArgs[key]; + }); + } + } + + @action async changeTab(tab = 'details') { this.currentTab = tab; if (typeof this.args.onTabChanged === 'function') { @@ -51,7 +69,6 @@ export default class VehiclePanelComponent extends Component { } @action editVehicle() { - const { vehicle } = this.args; - return this.hostRouter.transitionTo('console.fleet-ops.management.vehicles.index.edit', vehicle.public_id); + this.contextPanel.focus(this.vehicle, 'editing'); } } diff --git a/addon/controllers/operations/orders/index.js b/addon/controllers/operations/orders/index.js index c5423041..57f7e58c 100644 --- a/addon/controllers/operations/orders/index.js +++ b/addon/controllers/operations/orders/index.js @@ -113,6 +113,20 @@ export default class OperationsOrdersIndexController extends Controller { 'layout', ]; + /** + * The current driver being focused. + * + * @var {DriverModel|null} + */ + @tracked focusedDriver; + + /** + * The current vehicle being focused. + * + * @var {VehicleModel|null} + */ + @tracked focusedVehicle; + /** * The current page of data being viewed * @@ -628,8 +642,7 @@ export default class OperationsOrdersIndexController extends Controller { @action resetView() { if (this.leafletMap && this.leafletMap.liveMap) { - this.leafletMap.liveMap.hideDrivers(); - this.leafletMap.liveMap.hideRoutes(); + this.leafletMap.liveMap.hideAll(); } } diff --git a/addon/controllers/operations/orders/index/new.js b/addon/controllers/operations/orders/index/new.js index 3cfb3422..bb80e32f 100644 --- a/addon/controllers/operations/orders/index/new.js +++ b/addon/controllers/operations/orders/index/new.js @@ -567,8 +567,7 @@ export default class OperationsOrdersIndexNewController extends Controller { @action setupInterface() { if (this.leafletMap && this.leafletMap.liveMap) { - this.leafletMap.liveMap.hideDrivers(); - this.leafletMap.liveMap.hideRoutes(); + this.leafletMap.liveMap.hideAll(); // track all layers added from this view this.leafletMap.on('layeradd', ({ layer }) => { @@ -585,7 +584,7 @@ export default class OperationsOrdersIndexNewController extends Controller { }); } else { // setup interface when livemap is ready - this.universe.on('livemap.ready', () => { + this.universe.on('fleetops.livemap.ready', () => { this.setupInterface(); }); } @@ -596,8 +595,7 @@ export default class OperationsOrdersIndexNewController extends Controller { @action resetInterface() { if (this.leafletMap && this.leafletMap.liveMap) { - this.leafletMap.liveMap.showDrivers(); - this.leafletMap.liveMap.showRoutes(); + this.leafletMap.liveMap.show(['drivers', 'vehicles', 'routes']); } } diff --git a/addon/controllers/operations/orders/index/view.js b/addon/controllers/operations/orders/index/view.js index b6dedd0d..15c6cac7 100644 --- a/addon/controllers/operations/orders/index/view.js +++ b/addon/controllers/operations/orders/index/view.js @@ -170,8 +170,7 @@ export default class OperationsOrdersIndexViewController extends Controller { @action resetInterface() { if (this.leafletMap && this.leafletMap.liveMap) { - this.leafletMap.liveMap.showDrivers(); - this.leafletMap.liveMap.showRoutes(); + this.leafletMap.liveMap.show(['drivers', 'routes']); } } @@ -219,8 +218,7 @@ export default class OperationsOrdersIndexViewController extends Controller { this, () => { if (this.leafletMap && this.leafletMap.liveMap) { - this.leafletMap.liveMap.hideDrivers(); - this.leafletMap.liveMap.hideRoutes(); + this.leafletMap.liveMap.hideAll(); } // display order route on map @@ -236,14 +234,14 @@ export default class OperationsOrdersIndexViewController extends Controller { }; // re-display order routes when livemap has coordinates - this.universe.on('livemap.has_coordinates', this, displayOrderRoute); + this.universe.on('fleetops.livemap.has_coordinates', this, displayOrderRoute); // when transitioning away kill event listener this.hostRouter.on('routeWillChange', () => { - const isListening = this.universe.has('livemap.has_coordinates'); + const isListening = this.universe.has('fleetops.livemap.has_coordinates'); if (isListening) { - this.universe.off('livemap.has_coordinates', this, displayOrderRoute); + this.universe.off('fleetops.livemap.has_coordinates', this, displayOrderRoute); } }); diff --git a/addon/engine.js b/addon/engine.js index 305606b8..45886f6e 100644 --- a/addon/engine.js +++ b/addon/engine.js @@ -42,6 +42,12 @@ export default class FleetOpsEngine extends Engine { // register the driver panel universe.createRegistry('component:driver-panel'); + + // register vehicle context menu + universe.createRegistry('contextmenu:vehicle'); + + // register driver context menu + universe.createRegistry('contextmenu:driver'); }; } diff --git a/addon/routes/operations/orders/index.js b/addon/routes/operations/orders/index.js index 99ef3dc3..1b0e81a8 100644 --- a/addon/routes/operations/orders/index.js +++ b/addon/routes/operations/orders/index.js @@ -28,7 +28,11 @@ export default class OperationsOrdersIndexRoute extends Route { }; @action willTransition(transition) { - this.controller?.resetView(transition); + const shouldReset = typeof transition.to.name === 'string' && !transition.to.name.includes('operations.orders'); + + if (this.controller && shouldReset) { + this.controller.resetView(transition); + } } @action model(params) { diff --git a/addon/routes/operations/orders/index/view.js b/addon/routes/operations/orders/index/view.js index a1b8d694..33a40c9d 100644 --- a/addon/routes/operations/orders/index/view.js +++ b/addon/routes/operations/orders/index/view.js @@ -8,8 +8,10 @@ export default class OperationsOrdersIndexViewRoute extends Route { @service store; @service socket; - @action willTransition() { - if (this.controller) { + @action willTransition(transition) { + const shouldReset = typeof transition.to.name === 'string' && !transition.to.name.includes('operations.orders'); + + if (this.controller && shouldReset) { this.controller.resetView(); } } diff --git a/addon/services/context-panel.js b/addon/services/context-panel.js new file mode 100644 index 00000000..fdb833c1 --- /dev/null +++ b/addon/services/context-panel.js @@ -0,0 +1,106 @@ +import Service from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import getModelName from '@fleetbase/ember-core/utils/get-model-name'; + +/** + * Service to manage the context panel in the application. + */ +export default class ContextPanelService extends Service { + /** + * Registry to map models to their corresponding components and arguments. + */ + registry = { + driver: { + viewing: { + component: 'driver-panel', + componentArguments: [{ isResizable: true }, { width: '550px' }], + }, + editing: { + component: 'driver-form-panel', + componentArguments: [{ isResizable: true }, { width: '550px' }], + }, + }, + vehicle: { + viewing: { + component: 'vehicle-panel', + componentArguments: [{ isResizable: true }, { width: '600px' }], + }, + editing: { + component: 'vehicle-form-panel', + componentArguments: [{ isResizable: true }, { width: '600px' }], + }, + }, + }; + + /** + * The current context model. + */ + @tracked currentContext; + + /** + * The current context registry. + */ + @tracked currentContextRegistry; + + /** + * The current context component arguments. + */ + @tracked currentContextComponentArguments = {}; + + /** + * Focuses on a given model with a specific intent. + * @param {Object} model - The model to focus on. + * @param {string} [intent='viewing'] - The intent for focusing (e.g., 'viewing', 'editing'). + */ + @action focus(model, intent = 'viewing') { + const modelName = getModelName(model); + const registry = this.registry[modelName]; + + if (registry && registry[intent]) { + this.currentContext = model; + this.currentContextRegistry = registry[intent]; + this.currentContextComponentArguments = this.createDynamicArgsFromRegistry(registry[intent], model); + } + } + + /** + * Clears the current context. + */ + @action clear() { + this.currentContext = null; + this.currentContextRegistry = null; + this.currentContextComponentArguments = {}; + } + + @action changeIntent(intent) { + if (this.currentContext) { + return this.focus(this.currentContext, intent); + } + } + + /** + * Creates dynamic arguments from the registry. + * @param {Object} registry - The registry for the current context. + * @param {Object} model - The model to focus on. + * @returns {Object} The dynamic arguments. + */ + createDynamicArgsFromRegistry(registry, model) { + // Generate dynamic arguments object + const dynamicArgs = {}; + const componentArguments = registry.componentArguments || []; + + componentArguments.forEach((arg, index) => { + if (typeof arg === 'string') { + dynamicArgs[arg] = model[arg]; // Map string arguments to model properties + } else if (typeof arg === 'object' && arg !== null) { + Object.assign(dynamicArgs, arg); + } else { + // Handle other types of arguments as needed + dynamicArgs[`arg${index}`] = arg; + } + }); + + return dynamicArgs; + } +} diff --git a/addon/services/leaflet-contextmenu-manager.js b/addon/services/leaflet-contextmenu-manager.js new file mode 100644 index 00000000..b0aa25b8 --- /dev/null +++ b/addon/services/leaflet-contextmenu-manager.js @@ -0,0 +1,311 @@ +import Service from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { camelize } from '@ember/string'; +import { later } from '@ember/runloop'; +import getWithDefault from '@fleetbase/ember-core/utils/get-with-default'; + +/** + * Service for managing context menus in Leaflet maps. + * + * This service provides functions to toggle context menu items, create context menus for specific + * layers, and manage the context menu registry. It also includes utility functions for internal use. + * + * @class + */ +export default class LeafletContextmenuManagerService extends Service { + /** + * Registry for context menus associated with specific layers. + * + * @type {Object} + */ + @tracked contextMenuRegistry = {}; + + /** + * Creates a context menu for a specific layer with the given items. + * + * @param {string} registryName - The name of the context menu registry. + * @param {Object} layer - The Leaflet layer associated with the context menu. + * @param {Array} items - An array of context menu items to add. + * @returns {Object} The created context menu registry. + */ + createContextMenu(registryName, layer, contextmenuItems = [], additionalContext = {}) { + // create internal registry name + const internalRegistryName = this.createInternalRegistryName(registryName); + + // get layer contextmenu api + const contextmenuApi = layer.contextmenu; + + // bind contextmenu first + if (typeof layer.bindContextMenu === 'function') { + layer.bindContextMenu({ + contextmenu: true, + contextmenuItems, + }); + } + + // setup context menu + if (contextmenuApi) { + // remove all items first + contextmenuApi.removeAllItems(); + + // add items + for (let i = 0; i < contextmenuItems.length; i++) { + const item = contextmenuItems.objectAt(i); + contextmenuApi.addItem(item); + } + + // enable contextmenu + contextmenuApi.enable(); + } + + // create contextmenu registry + this.contextMenuRegistry[internalRegistryName] = { + contextmenuItems, + layer, + contextmenuApi, + ...additionalContext, + }; + + return this.contextMenuRegistry[internalRegistryName]; + } + + /** + * Retrieves a context menu registry by its name. + * + * @param {string} registryName - The name of the context menu registry to retrieve. + * @returns {Object|null} The context menu registry or null if not found. + */ + getRegistry(registryName) { + const internalRegistryName = this.createInternalRegistryName(registryName); + + if (this.contextMenuRegistry[internalRegistryName]) { + return this.contextMenuRegistry[internalRegistryName]; + } + + return null; + } + + /** + * Creates an internal registry name by camelizing the provided registry name and appending "ConextmenuRegistry" to it. + * + * @method createInternalRegistryName + * @public + * @memberof LeafletContextmenuManagerService + * @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, '-'))}ConextmenuRegistry`; + } + + /** + * Toggles a context menu item in the specified registry. + * + * @method createInternalRegistryName + * @public + * @param {string} registryName - The name of the context menu registry to toggle the item in. + * @param {string|Function} selector - The selector or callback for the item to toggle. + * @param {Object} options - Additional options for toggling the item (optional). + */ + toggleContextMenuItem(registryName, selector, options = {}) { + const registry = this.getRegistry(registryName); + + // get the context menu api + const { contextmenuApi } = registry; + + // if just toggling text + const toggle = getWithDefault(options, 'toggle', true); + const onText = getWithDefault(options, 'onText'); + const offText = getWithDefault(options, 'offText'); + const callback = getWithDefault(options, 'callback'); + + if (registry) { + const index = registry.contextmenuItems.findIndex((item) => { + if (typeof selector === 'string' && typeof item.text === 'string') { + return item.text.includes(selector); + } + + if (typeof selector === 'function') { + return selector(item); + } + }); + + if (index > 0) { + const item = registry.contextmenuItems.objectAt(index); + + if (item) { + item.text = toggle ? onText : offText; + } + + if (typeof callback === 'function') { + callback(toggle, item, registry); + } + + // insert back into contextmenu + if (contextmenuApi) { + contextmenuApi.removeItem(index); + contextmenuApi.insertItem(item, index); + } + } + } + } + + /** + * Change the text of a context menu item within a specified registry. + * + * @param {string} registryName - The name of the context menu registry. + * @param {string|function} selector - A string or function to select the menu item to change. + * @param {string} newText - The new text to set for the menu item. + * @param {Object} [options={}] - Additional options. + * @param {function} [options.callback] - A callback function to execute. + * @memberof LeafletContextmenuManagerService + */ + changeMenuItemText(registryName, selector, newText, options = {}) { + const registry = this.getRegistry(registryName); + + // get the context menu api + const { contextmenuApi } = registry; + + // if just toggling text + const callback = getWithDefault(options, 'callback'); + + if (registry) { + const index = registry.contextmenuItems.findIndex((item) => { + if (typeof selector === 'string' && typeof item.text === 'string') { + return item.text.includes(selector); + } + + if (typeof selector === 'function') { + return selector(item); + } + }); + + if (index > 0) { + const item = registry.contextmenuItems.objectAt(index); + + if (item) { + item.text = newText; + } + + if (typeof callback === 'function') { + callback(item, registry); + } + + // insert back into contextmenu + if (contextmenuApi) { + contextmenuApi.removeItem(index); + contextmenuApi.insertItem(item, index); + } + } + } + } + + /** + * Removes a specific item from the context menu associated with the given registry name. + * + * @method createInternalRegistryName + * @public + * @param {string} registryName - The name of the context menu registry to target. + * @param {string|Function} selector - A string or function used to identify the item to remove. + * @param {Object} options - Additional options for removing the item. + * @param {Function} options.callback - A callback function to execute after item removal. + */ + removeItemFromContextMenu(registryName, selector, options = {}) { + const registry = this.getRegistry(registryName); + + // get the context menu api + const { contextmenuApi } = registry; + + // if just toggling text + const callback = getWithDefault(options, 'callback'); + + if (registry) { + const index = registry.contextmenuItems.findIndex((item) => { + if (typeof selector === 'string' && typeof item.text === 'string') { + return item.text.includes(selector); + } + + if (typeof selector === 'function') { + return selector(item); + } + }); + + if (index > 0) { + const item = registry.contextmenuItems.objectAt(index); + + if (typeof callback === 'function') { + callback(item, registry); + } + + // remove from context menu + if (contextmenuApi) { + contextmenuApi.removeItem(index); + } + } + } + } + + /** + * Find a context menu registry by layer. + * + * @param {Layer} layer - The layer for which to find the registry. + * @returns {Object|null} The context menu registry associated with the provided layer, or null if not found. + */ + findRegistryByLayer(layer) { + for (const registryName in this.contextMenuRegistry) { + const registry = this.contextMenuRegistry[registryName]; + if (registry.layer === layer) { + return registry; + } + } + + // If no matching registry is found, return null or handle the case accordingly. + return null; + } + + /** + * Rebind the context menu for a layer and update the registry if it exists. + * + * @param {Layer} layer - The layer to rebind the context menu for. + * @param {Array} contextmenuItems - An array of context menu items to bind. + */ + rebindContextMenu(layer, contextmenuItems = []) { + // make sure layer is instance of leaflet + if (!(layer instanceof L.Layer)) { + return; + } + + const registry = this.findRegistryByLayer(layer); + + if (registry) { + later( + this, + () => { + try { + if (typeof layer.unbindContextMenu === 'function') { + layer.unbindContextMenu().bindContextMenu({ + contextmenu: true, + contextmenuItems, + }); + } else { + // just bind + layer.bindContextMenu({ + contextmenu: true, + contextmenuItems, + }); + } + } catch (error) { + // silence + } + + // if found registry update layer and contextmenu api + if (registry) { + registry.layer = layer; + registry.contextmenuApi = layer.contextmenu; + } + }, + 300 + ); + } + } +} diff --git a/addon/services/leaflet-map-manager.js b/addon/services/leaflet-map-manager.js new file mode 100644 index 00000000..0f7daa2d --- /dev/null +++ b/addon/services/leaflet-map-manager.js @@ -0,0 +1,61 @@ +import Service from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import getLeafletLayerById from '../utils/get-leaflet-layer-by-id'; +import findLeafletLayer from '../utils/find-leaflet-layer'; +import flyToLeafletLayer from '../utils/fly-to-leaflet-layer'; + +/** + * Service for managing Leaflet maps and layers. + * + * This service provides functions to work with Leaflet maps, including accessing layers by ID, + * finding layers based on a custom callback, and flying to specific layers with optional zoom and options. + * + * @class + */ +export default class LeafletMapManagerService extends Service { + /** + * An array of editable layers on the map. + * + * @memberof LeafletMapManagerService + * @type {Array} + */ + @tracked editableLayers = []; + + /** + * Get a Leaflet layer by its ID from the given map. + * + * @memberof LeafletMapManagerService + * @param {Object} map - The Leaflet map instance. + * @param {string} layerId - The ID of the layer to retrieve. + * @returns {Object|null} The Leaflet layer with the specified ID, or null if not found. + */ + getLeafletLayerById(map, layerId) { + return getLeafletLayerById(map, layerId); + } + + /** + * Find a Leaflet layer in the given map using a custom callback. + * + * @memberof LeafletMapManagerService + * @param {Object} map - The Leaflet map instance. + * @param {Function} findCallback - A custom callback function to find the desired layer. + * @returns {Object|null} The found Leaflet layer, or null if not found. + */ + findLeafletLayer(map, findCallback) { + return findLeafletLayer(map, findCallback); + } + + /** + * Fly to a specific Leaflet layer on the map with optional zoom and options. + * + * @memberof LeafletMapManagerService + * @param {Object} map - The Leaflet map instance. + * @param {Object} layer - The Leaflet layer to fly to. + * @param {number} zoom - The zoom level to apply (optional). + * @param {Object} options - Additional options for the fly animation (optional). + * @returns {Object} The Leaflet map instance. + */ + flyToLayer(map, layer, zoom, options = {}) { + return flyToLeafletLayer(map, layer, zoom, options); + } +} diff --git a/addon/services/map-manager.js b/addon/services/map-manager.js deleted file mode 100644 index 3743c3a7..00000000 --- a/addon/services/map-manager.js +++ /dev/null @@ -1,3 +0,0 @@ -import Service from '@ember/service'; - -export default class MapManagerService extends Service {} diff --git a/addon/services/service-areas.js b/addon/services/service-areas.js index d985c856..a4df9b62 100644 --- a/addon/services/service-areas.js +++ b/addon/services/service-areas.js @@ -11,22 +11,86 @@ import Polygon from '@fleetbase/fleetops-data/utils/geojson/polygon'; import FeatureCollection from '@fleetbase/fleetops-data/utils/geojson/feature-collection'; export default class ServiceAreasService extends Service { + /** + * Inject the `store` service. + * + * @memberof ServiceAreasService + */ @service store; + + /** + * Inject the `modalsManager` service. + * + * @memberof ServiceAreasService + */ @service modalsManager; + + /** + * Inject the `notifications` service. + * + * @memberof ServiceAreasService + */ @service notifications; + + /** + * Inject the `crud` service. + * + * @memberof ServiceAreasService + */ @service crud; + + /** + * Inject the `appCache` service. + * + * @memberof ServiceAreasService + */ @service appCache; + /** + * The Leaflet map instance used by the service for map-related operations. + * + * @type {Object|null} + */ @tracked leafletMap; + + /** + * An array of service area types available within the application. + * + * @type {string[]} + */ @tracked serviceAreaTypes = ['neighborhood', 'city', 'region', 'state', 'province', 'country', 'continent']; + + /** + * A context variable that stores the current context for layer creation. + * + * @type {string|null} + */ @tracked layerCreationContext; + + /** + * A context variable that stores information related to the service area in which zones are being created. + * + * @type {Object|null} + */ @tracked zoneServiceAreaContext; - @action getFromCache() { + /** + * Retrieves service areas from the cache. + * + * @function + * @returns {Array} An array of service areas retrieved from the cache. + */ + getFromCache() { return this.appCache.getEmberData('serviceAreas', 'service-area'); } - @action removeFromCache(serviceArea) { + /** + * Removes a service area from the cache. + * + * @function + * @param {Object} serviceArea - The service area to remove from the cache. + */ + removeFromCache(serviceArea) { const serviceAreas = this.getFromCache(); const index = serviceAreas?.findIndex((sa) => sa.id === serviceArea.id); @@ -36,7 +100,13 @@ export default class ServiceAreasService extends Service { } } - @action addToCache(serviceArea) { + /** + * Adds a service area to the cache. + * + * @function + * @param {ServiceAreaModel} serviceArea - The service area to add to the cache. + */ + addToCache(serviceArea) { const serviceAreas = this.getFromCache(); if (isArray(serviceAreas)) { @@ -46,7 +116,14 @@ export default class ServiceAreasService extends Service { } } - @action layerToTerraformerPrimitive(layer) { + /** + * Converts a Leaflet layer to a Terraformer primitive. + * + * @function + * @param {Object} layer - The Leaflet layer to convert. + * @returns {Object} The Terraformer primitive. + */ + layerToTerraformerPrimitive(layer) { const leafletLayerGeoJson = layer.toGeoJSON(); let featureCollection, feature; @@ -62,7 +139,14 @@ export default class ServiceAreasService extends Service { return primitive; } - @action layerToTerraformerMultiPolygon(layer) { + /** + * Converts a Leaflet layer to a Terraformer MultiPolygon. + * + * @function + * @param {Object} layer - The Leaflet layer to convert. + * @returns {Object} The Terraformer MultiPolygon. + */ + layerToTerraformerMultiPolygon(layer) { const leafletLayerGeoJson = layer.toGeoJSON(); let featureCollection, feature, coordinates; @@ -79,7 +163,14 @@ export default class ServiceAreasService extends Service { return multipolygon; } - @action layerToTerraformerPolygon(layer) { + /** + * Converts a Leaflet layer to a Terraformer Polygon. + * + * @function + * @param {Object} layer - The Leaflet layer to convert. + * @returns {Object} The Terraformer Polygon. + */ + layerToTerraformerPolygon(layer) { const leafletLayerGeoJson = layer.toGeoJSON(); let featureCollection, feature, coordinates; @@ -96,36 +187,82 @@ export default class ServiceAreasService extends Service { return polygon; } - @action clearLayerCreationContext() { + /** + * Clears the layer creation context. + * + * @function + */ + clearLayerCreationContext() { this.layerCreationContext = undefined; } - @action setLayerCreationContext(context) { + /** + * Sets the layer creation context. + * + * @function + * @param {string} context - The context to set. + */ + setLayerCreationContext(context) { this.layerCreationContext = context; } - @action clearZoneServiceAreaContext() { + /** + * Clears the zone service area context. + * + * @function + */ + clearZoneServiceAreaContext() { this.zoneServiceAreaContext = undefined; } - @action setZoneServiceAreaContext(serviceArea) { + /** + * Sets the zone service area context. + * + * @function + * @param {Object} serviceArea - The service area to set as the context. + */ + setZoneServiceAreaContext(serviceArea) { this.zoneServiceAreaContext = serviceArea; } - @action getZoneServiceAreaContext() { + /** + * Retrieves the zone service area context. + * + * @function + * @returns {Object} The zone service area context. + */ + getZoneServiceAreaContext() { return this.zoneServiceAreaContext; } - @action setMapInstance(map) { + /** + * Sets the Leaflet map instance for the service. + * + * @function + * @param {Object} map - The Leaflet map instance to set. + */ + setMapInstance(map) { this.leafletMap = map; } - @action sendToLiveMap(fn, ...params) { - this.leafletMap?.liveMap[fn](...params); + /** + * Sends a command to the LiveMap through the Leaflet map instance. + * + * @function + * @param {string} fn - The function name to call on the LiveMap. + * @param {...any} params - Additional parameters to pass to the function. + */ + triggerLiveMapFn(fn, ...params) { + this.leafletMap.liveMap[fn](...params); } + /** + * Initiates the creation of a service area on the map. + * + * @function + */ @action createServiceArea() { - this.sendToLiveMap('enableDrawControls'); + this.triggerLiveMapFn('showDrawControls', { text: true }); this.setLayerCreationContext('service-area'); this.notifications.info('Use drawing controls to the right to draw a service area, complete point connections to save service area.', { @@ -133,6 +270,14 @@ export default class ServiceAreasService extends Service { }); } + /** + * Creates a generic layer on the map, such as a service area or zone. + * + * @function + * @param {Object} event - The event that triggered the creation. + * @param {Object} layer - The layer being created. + * @param {Object} options - Additional options for the creation (optional). + */ @action createGenericLayer(event, layer, options = {}) { if (this.layerCreationContext === 'service-area') { return this.saveServiceArea(...arguments); @@ -185,18 +330,18 @@ export default class ServiceAreasService extends Service { // if service area has been created, add to the active service areas if (selectedLayerType === 'Service Area') { - this.sendToLiveMap('activateServiceArea', record); - this.sendToLiveMap('focusLayerByRecord', record); + this.triggerLiveMapFn('activateServiceArea', record); + this.triggerLiveMapFn('focusLayerBoundsByRecord', record); } else { // if zone was created then we simply add the zone to the serviceArea selected // then we focus the service area serviceArea?.zones.pushObject(record); - this.sendToLiveMap('activateServiceArea', serviceArea); - this.sendToLiveMap('focusLayerByRecord', serviceArea); + this.triggerLiveMapFn('activateServiceArea', serviceArea); + this.triggerLiveMapFn('focusLayerBoundsByRecord', serviceArea); } // rebuild context menu - this.sendToLiveMap('rebuildContextMenu'); + this.triggerLiveMapFn('rebuildMapContextMenu'); this.clearLayerCreationContext(); }); }, @@ -208,6 +353,13 @@ export default class ServiceAreasService extends Service { }); } + /** + * Saves a service area to the database. + * + * @function + * @param {Object} event - The event that triggered the saving. + * @param {Object} layer - The layer to be saved as a service area. + */ @action saveServiceArea(event, layer) { const { _map } = layer; const border = this.layerToTerraformerMultiPolygon(layer); @@ -230,6 +382,13 @@ export default class ServiceAreasService extends Service { }); } + /** + * Edits and saves details of a service area. + * + * @function + * @param {Object} serviceArea - The service area to edit. + * @param {Object} options - Additional options for the edit (optional). + */ @action editServiceAreaDetails(serviceArea, options = {}) { this.modalsManager.show('modals/service-area-form', { title: 'Edit Service Area', @@ -247,13 +406,13 @@ export default class ServiceAreasService extends Service { this.clearLayerCreationContext(); this.addToCache(serviceArea); - this.sendToLiveMap('focusServiceArea', serviceArea); - // this.sendToLiveMap('rebuildContextMenu'); + this.triggerLiveMapFn('focusServiceArea', serviceArea); + this.triggerLiveMapFn('rebuildMapContextMenu'); }); }, decline: (modal) => { this.clearLayerCreationContext(); - this.sendToLiveMap('hideDrawControls'); + this.triggerLiveMapFn('hideDrawControls', { text: true }); if (serviceArea.isNew) { serviceArea.destroyRecord(); @@ -264,21 +423,34 @@ export default class ServiceAreasService extends Service { }); } + /** + * Deletes a service area from the database. + * + * @function + * @param {Object} serviceArea - The service area to delete. + * @param {Object} options - Additional options for the deletion (optional). + */ @action deleteServiceArea(serviceArea, options = {}) { - this.sendToLiveMap('focusLayerByRecord', serviceArea); + this.triggerLiveMapFn('focusLayerBoundsByRecord', serviceArea); this.crud.delete(serviceArea, { onConfirm: () => { - this.sendToLiveMap('blurServiceArea', serviceArea); + this.triggerLiveMapFn('blurServiceArea', serviceArea); this.removeFromCache(serviceArea); }, ...options, }); } + /** + * Initiates the creation of a zone within a service area on the map. + * + * @function + * @param {Object} serviceArea - The service area within which the zone is being created. + */ @action createZone(serviceArea) { - this.sendToLiveMap('enableDrawControls'); - this.sendToLiveMap('focusServiceArea', serviceArea); + this.triggerLiveMapFn('showDrawControls', { text: true }); + this.triggerLiveMapFn('focusServiceArea', serviceArea); this.setZoneServiceAreaContext(serviceArea); this.setLayerCreationContext('zone'); @@ -287,6 +459,14 @@ export default class ServiceAreasService extends Service { }); } + /** + * Saves a zone to the database. + * + * @function + * @param {Object} event - The event that triggered the saving. + * @param {Object} layer - The layer to be saved as a zone. + * @returns {Promise} A promise that resolves when the zone is saved. + */ @action saveZone(event, layer) { const { _map } = layer; const border = this.layerToTerraformerPolygon(layer); @@ -307,6 +487,15 @@ export default class ServiceAreasService extends Service { }); } + /** + * Edits and saves details of a zone. + * + * @function + * @param {Object} zone - The zone to edit. + * @param {Object} serviceArea - The service area to which the zone belongs. + * @param {Object} options - Additional options for the edit (optional). + * @returns {Promise} A promise that resolves when the zone is successfully saved. + */ @action editZone(zone, serviceArea, options = {}) { this.modalsManager.show('modals/zone-form', { title: 'Edit Zone', @@ -323,23 +512,23 @@ export default class ServiceAreasService extends Service { this.clearLayerCreationContext(); this.clearZoneServiceAreaContext(); - this.sendToLiveMap('hideDrawControls'); - this.sendToLiveMap('blurAllServiceAreas'); + this.triggerLiveMapFn('hideDrawControls', { text: true }); + this.triggerLiveMapFn('blurAllServiceAreas'); later( this, () => { - this.sendToLiveMap('focusServiceArea', serviceArea); + this.triggerLiveMapFn('focusServiceArea', serviceArea); }, 300 ); - // this.sendToLiveMap('rebuildContextMenu'); + this.triggerLiveMapFn('rebuildMapContextMenu'); }); }, decline: (modal) => { this.clearLayerCreationContext(); this.clearZoneServiceAreaContext(); - this.sendToLiveMap('hideDrawControls'); + this.triggerLiveMapFn('hideDrawControls', { text: true }); if (zone.isNew) { zone.destroyRecord(); @@ -350,12 +539,26 @@ export default class ServiceAreasService extends Service { }); } + /** + * Deletes a zone from the database. + * + * @function + * @param {Object} zone - The zone to delete. + * @param {Object} options - Additional options for the deletion (optional). + */ @action deleteZone(zone, options = {}) { this.crud.delete(zone, { ...options, }); } + /** + * Displays a service area in a dialog for viewing. + * + * @function + * @param {Object} serviceArea - The service area to view in the dialog. + * @param {Object} options - Additional options for the dialog (optional). + */ @action viewServiceAreaInDialog(serviceArea, options = {}) { this.modalsManager.show('modals/view-service-area', { title: `Service Area (${serviceArea.get('name')})`, @@ -369,6 +572,13 @@ export default class ServiceAreasService extends Service { }); } + /** + * Displays a zone in a dialog for viewing. + * + * @function + * @param {Object} zone - The zone to view in the dialog. + * @param {Object} options - Additional options for the dialog (optional). + */ @action viewZoneInDialog(zone, options = {}) { this.modalsManager.show('modals/view-zone', { title: `Zone (${zone.get('name')})`, diff --git a/addon/templates/application.hbs b/addon/templates/application.hbs index e480f77b..e8278160 100644 --- a/addon/templates/application.hbs +++ b/addon/templates/application.hbs @@ -6,4 +6,5 @@ {{outlet}} - \ No newline at end of file + + \ No newline at end of file diff --git a/addon/utils/find-leaflet-layer.js b/addon/utils/find-leaflet-layer.js new file mode 100644 index 00000000..dc8c9a19 --- /dev/null +++ b/addon/utils/find-leaflet-layer.js @@ -0,0 +1,13 @@ +export default function findLeafletLayer(map, findCallback) { + const layers = []; + + map.eachLayer((layer) => { + layers.push(layer); + }); + + if (typeof findCallback === 'function') { + return layers.find(findCallback); + } + + return null; +} diff --git a/addon/utils/fly-to-leaflet-layer.js b/addon/utils/fly-to-leaflet-layer.js new file mode 100644 index 00000000..59b81614 --- /dev/null +++ b/addon/utils/fly-to-leaflet-layer.js @@ -0,0 +1,25 @@ +export default function flyToLeafletLayer(map, layer, zoom, options = {}) { + if (!map || !layer) { + return; + } + + // Check the type of the layer (marker, polygon, etc.) and get its center or bounds + let targetLatLng; + + if (layer instanceof L.Marker) { + // For markers, you can directly get the marker's LatLng + targetLatLng = layer.getLatLng(); + } else { + // For other types of layers, like polygons or circles, you can calculate the center + if (layer.getCenter) { + targetLatLng = layer.getCenter(); + } else if (layer.getBounds) { + targetLatLng = layer.getBounds().getCenter(); + } + } + + // Check if we have a valid LatLng + if (targetLatLng) { + map.flyTo(targetLatLng, zoom, options); + } +} diff --git a/addon/utils/get-leaflet-layer-by-id.js b/addon/utils/get-leaflet-layer-by-id.js new file mode 100644 index 00000000..b445bb71 --- /dev/null +++ b/addon/utils/get-leaflet-layer-by-id.js @@ -0,0 +1,12 @@ +export default function getLeafletLayerById(map, layerId) { + let targetLayer = null; + + map.eachLayer((layer) => { + // Check if the layer has an ID property + if (layer.options && layer.options.id === layerId) { + targetLayer = layer; + } + }); + + return targetLayer; +} diff --git a/addon/utils/layer-can-bind-contextmenu.js b/addon/utils/layer-can-bind-contextmenu.js new file mode 100644 index 00000000..a2f3f359 --- /dev/null +++ b/addon/utils/layer-can-bind-contextmenu.js @@ -0,0 +1,3 @@ +export default function layerCanBindContextmenu(layer) { + return typeof layer === 'object' && typeof layer.bindContextMenu === 'function'; +} diff --git a/app/components/context-panel.js b/app/components/context-panel.js new file mode 100644 index 00000000..4553c55e --- /dev/null +++ b/app/components/context-panel.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/context-panel'; diff --git a/app/services/context-panel.js b/app/services/context-panel.js new file mode 100644 index 00000000..3165fc19 --- /dev/null +++ b/app/services/context-panel.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/services/context-panel'; diff --git a/app/services/leaflet-contextmenu-manager.js b/app/services/leaflet-contextmenu-manager.js new file mode 100644 index 00000000..f98937e0 --- /dev/null +++ b/app/services/leaflet-contextmenu-manager.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/services/leaflet-contextmenu-manager'; diff --git a/app/services/leaflet-map-manager.js b/app/services/leaflet-map-manager.js new file mode 100644 index 00000000..01dcc216 --- /dev/null +++ b/app/services/leaflet-map-manager.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/services/leaflet-map-manager'; diff --git a/app/services/map-manager.js b/app/services/map-manager.js deleted file mode 100644 index f0f49846..00000000 --- a/app/services/map-manager.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/fleetops-engine/services/map-manager'; diff --git a/app/utils/find-leaflet-layer.js b/app/utils/find-leaflet-layer.js new file mode 100644 index 00000000..1e6f95d1 --- /dev/null +++ b/app/utils/find-leaflet-layer.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/utils/find-leaflet-layer'; diff --git a/app/utils/fly-to-leaflet-layer.js b/app/utils/fly-to-leaflet-layer.js new file mode 100644 index 00000000..8640598a --- /dev/null +++ b/app/utils/fly-to-leaflet-layer.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/utils/fly-to-leaflet-layer'; diff --git a/app/utils/get-leaflet-layer-by-id.js b/app/utils/get-leaflet-layer-by-id.js new file mode 100644 index 00000000..d67984b8 --- /dev/null +++ b/app/utils/get-leaflet-layer-by-id.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/utils/get-leaflet-layer-by-id'; diff --git a/app/utils/layer-can-bind-contextmenu.js b/app/utils/layer-can-bind-contextmenu.js new file mode 100644 index 00000000..e1f56129 --- /dev/null +++ b/app/utils/layer-can-bind-contextmenu.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/utils/layer-can-bind-contextmenu'; diff --git a/composer.json b/composer.json index a402cdd9..419d3e42 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/fleetops-api", - "version": "0.2.8", + "version": "0.2.9", "description": "Fleet & Transport Management Extension for Fleetbase", "keywords": [ "fleetbase-extension", @@ -60,6 +60,16 @@ "Fleetbase\\FleetOps\\Providers\\FleetOpsServiceProvider", "Fleetbase\\FleetOps\\Providers\\EventServiceProvider" ] + }, + "fleetbase/fleetops-api": { + "excludes": [ + "addon", + "app", + "assets", + "config", + "tests", + "vendor" + ] } }, "config": { diff --git a/extension.json b/extension.json index a1ec36f5..4a2012fc 100644 --- a/extension.json +++ b/extension.json @@ -1,6 +1,6 @@ { "name": "Fleet-Ops", - "version": "0.2.8", + "version": "0.2.9", "description": "Fleet & Transport Management Extension for Fleetbase", "repository": "https://github.com/fleetbase/fleetops", "license": "MIT", diff --git a/package.json b/package.json index e08e8247..cf0c82c0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fleetbase/fleetops-engine", - "version": "0.2.8", + "version": "0.2.9", "description": "Fleet & Transport Management Extension for Fleetbase", "fleetbase": { "route": "fleet-ops", @@ -39,7 +39,7 @@ "publish:github": "npm config set '@fleetbase:registry' https://npm.pkg.github.com/ && npm publish" }, "dependencies": { - "@fleetbase/ember-core": "^0.1.4", + "@fleetbase/ember-core": "^0.1.5", "@fleetbase/ember-ui": "^0.2.0", "@fleetbase/fleetops-data": "^0.1.1", "@fleetbase/leaflet-routing-machine": "^3.2.16", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c236b36..24547993 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2,8 +2,8 @@ lockfileVersion: '6.0' dependencies: '@fleetbase/ember-core': - specifier: ^0.1.4 - version: 0.1.4(@babel/core@7.23.0)(ember-fetch@8.1.2)(postcss@8.4.31)(webpack@5.74.0) + specifier: ^0.1.5 + version: 0.1.5(@babel/core@7.23.0)(ember-fetch@8.1.2)(postcss@8.4.31)(webpack@5.74.0) '@fleetbase/ember-ui': specifier: ^0.2.0 version: 0.2.0(@babel/core@7.23.0)(@ember/test-helpers@2.8.1)(ember-source@4.6.0)(postcss@8.4.31)(rollup@4.0.0)(webpack@5.74.0) @@ -134,7 +134,7 @@ devDependencies: version: 3.0.1 ember-engines: specifier: ^0.8.23 - version: 0.8.23(@ember/legacy-built-in-components@0.5.0-alpha.0)(ember-source@4.6.0) + version: 0.8.23(@ember/legacy-built-in-components@0.5.0)(ember-source@4.6.0) ember-load-initializers: specifier: ^2.1.2 version: 2.1.2(@babel/core@7.23.0) @@ -2008,11 +2008,11 @@ packages: /@ember/edition-utils@1.2.0: resolution: {integrity: sha512-VmVq/8saCaPdesQmftPqbFtxJWrzxNGSQ+e8x8LLe3Hjm36pJ04Q8LeORGZkAeOhldoUX9seLGmSaHeXkIqoog==} - /@ember/legacy-built-in-components@0.5.0-alpha.0(ember-source@4.6.0): - resolution: {integrity: sha512-1FLeOfcTmXDvcNnlH+rdTsngq2Nr0LOLZ4JT9D7D1uT5qieZV07umxKUN33ImY00Zc265mDO+nyxuqfGntxh+A==} - engines: {node: 12.* || 14.* || >= 16} + /@ember/legacy-built-in-components@0.5.0(ember-source@4.6.0): + resolution: {integrity: sha512-hbUCt5rii6CT1L4mheH+aqCDeF1dzp/UjS2g7KFIKYGd9zMqyKU4OEnQGk2/O5tATXkEGPf4Zpj671BddBOrbQ==} + engines: {node: '>= 16'} peerDependencies: - ember-source: '*' + ember-source: '>= 4.8' dependencies: '@embroider/macros': 1.13.2 ember-cli-babel: 7.26.11 @@ -2241,8 +2241,8 @@ packages: - supports-color dev: true - /@fleetbase/ember-core@0.1.4(@babel/core@7.23.0)(ember-fetch@8.1.2)(postcss@8.4.31)(webpack@5.74.0): - resolution: {integrity: sha512-lQLjdLbQGcW5d1+n3zzy2UKpxpqCaa40F6i3JGO9vShZsDzoZermb1QjR0x9zaNAQP154ebhDfs8cDQLC8qr+g==} + /@fleetbase/ember-core@0.1.5(@babel/core@7.23.0)(ember-fetch@8.1.2)(postcss@8.4.31)(webpack@5.74.0): + resolution: {integrity: sha512-GFYTZ3emH/QwvwR6RoFbgA6vTgdtjH0zJA5ALHFzBMipL1PqVGlWWJHRxHUkP9i8nP3ZXWQuJbhmryU4hn7Vdw==} engines: {node: 14.* || >= 16} dependencies: date-fns: 2.30.0 @@ -7509,14 +7509,14 @@ packages: - supports-color dev: false - /ember-engines@0.8.23(@ember/legacy-built-in-components@0.5.0-alpha.0)(ember-source@4.6.0): + /ember-engines@0.8.23(@ember/legacy-built-in-components@0.5.0)(ember-source@4.6.0): resolution: {integrity: sha512-rrvHUkZRNrf+9u/sCw7XYrITStjP/9Ypykk1nYQHoo+6Krp11e81QNVsGTXFpXtMHXbNtH5IcRyZvfSXqUOrUQ==} engines: {node: 10.* || >= 12} peerDependencies: '@ember/legacy-built-in-components': '*' ember-source: ^3.12 || 4 dependencies: - '@ember/legacy-built-in-components': 0.5.0-alpha.0(ember-source@4.6.0) + '@ember/legacy-built-in-components': 0.5.0(ember-source@4.6.0) '@embroider/macros': 1.13.2 amd-name-resolver: 1.3.1 babel-plugin-compact-reexports: 1.1.0 diff --git a/server/src/Http/Controllers/Internal/v1/LiveController.php b/server/src/Http/Controllers/Internal/v1/LiveController.php index 2d49fbcb..86ffa13b 100644 --- a/server/src/Http/Controllers/Internal/v1/LiveController.php +++ b/server/src/Http/Controllers/Internal/v1/LiveController.php @@ -3,7 +3,10 @@ namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1; use Fleetbase\FleetOps\Http\Filter\PlaceFilter; +use Fleetbase\FleetOps\Http\Resources\v1\Driver as DriverResource; use Fleetbase\FleetOps\Http\Resources\v1\Order as OrderResource; +use Fleetbase\FleetOps\Http\Resources\v1\Place as PlaceResource; +use Fleetbase\FleetOps\Http\Resources\v1\Vehicle as VehicleResource; use Fleetbase\FleetOps\Models\Driver; use Fleetbase\FleetOps\Models\Order; use Fleetbase\FleetOps\Models\Place; @@ -94,7 +97,7 @@ function ($q) { ) ->get(); - return response()->json($drivers); + return DriverResource::collection($drivers); } /** @@ -105,9 +108,9 @@ function ($q) { public function vehicles() { // Fetch vehicles that are online - $vehicles = Vehicle::where(['company_uuid' => session('company'), 'online' => 1])->get(); + $vehicles = Vehicle::where(['company_uuid' => session('company')])->with(['devices'])->get(); - return response()->json($vehicles); + return VehicleResource::collection($vehicles); } /** @@ -122,6 +125,6 @@ public function places(Request $request) ->filter(new PlaceFilter($request)) ->get(); - return response()->json($places); + return PlaceResource::collection($places); } } diff --git a/server/src/Http/Filter/VehicleFilter.php b/server/src/Http/Filter/VehicleFilter.php index 806b1e7b..ea5ad37e 100644 --- a/server/src/Http/Filter/VehicleFilter.php +++ b/server/src/Http/Filter/VehicleFilter.php @@ -88,4 +88,11 @@ function ($q) use ($fleet) { } ); } + + public function assignedFleet(string $assignedFleet) + { + if ($assignedFleet === 'false') { + $this->builder->whereDoesntHave('fleets'); + } + } } diff --git a/server/src/Http/Resources/v1/Vehicle.php b/server/src/Http/Resources/v1/Vehicle.php index 7b68a88d..aa8e9c8c 100644 --- a/server/src/Http/Resources/v1/Vehicle.php +++ b/server/src/Http/Resources/v1/Vehicle.php @@ -4,6 +4,7 @@ use Fleetbase\Http\Resources\FleetbaseResource; use Fleetbase\Support\Http; +use Grimzy\LaravelMysqlSpatial\Types\Point; class Vehicle extends FleetbaseResource { @@ -19,27 +20,33 @@ public function toArray($request) return array_merge( $this->getInternalIds(), [ - 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id), - 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), - 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id), - 'internal_id' => $this->internal_id, - 'name' => $this->display_name, - 'display_name' => $this->when(Http::isInternalRequest(), $this->display_name), - 'vin' => $this->vin ?? null, - 'driver' => $this->whenLoaded('driver', new Driver($this->driver)), - 'photo' => $this->photoUrl, - 'make' => $this->make, - 'model' => $this->model, - 'year' => $this->year, - 'trim' => $this->trim, - 'type' => $this->type, - 'plate_number' => $this->plate_number, - 'vin_data' => $this->vin_data, - 'status' => $this->status, - 'online' => $this->online, - 'meta' => $this->meta ?? [], - 'updated_at' => $this->updated_at, - 'created_at' => $this->created_at, + 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), + 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id), + 'internal_id' => $this->internal_id, + 'name' => $this->display_name, + 'display_name' => $this->when(Http::isInternalRequest(), $this->display_name), + 'vin' => $this->vin ?? null, + 'driver' => $this->whenLoaded('driver', new Driver($this->driver)), + 'devices' => $this->whenLoaded('devices', $this->devices), + 'photo' => $this->photoUrl, + 'make' => $this->make, + 'model' => $this->model, + 'year' => $this->year, + 'trim' => $this->trim, + 'type' => $this->type, + 'plate_number' => $this->plate_number, + 'vin' => $this->vin, + 'vin_data' => $this->vin_data, + 'status' => $this->status, + 'online' => $this->online, + 'location' => data_get($this, 'location', new Point(0, 0)), + 'heading' => (int) data_get($this, 'heading', 0), + 'altitude' => (int) data_get($this, 'altitude', 0), + 'speed' => (int) data_get($this, 'speed', 0), + 'meta' => $this->meta ?? [], + 'updated_at' => $this->updated_at, + 'created_at' => $this->created_at, ] ); } @@ -52,24 +59,29 @@ public function toArray($request) public function toWebhookPayload() { return [ - 'id' => $this->public_id, - 'internal_id' => $this->internal_id, - 'name' => $this->name, - 'vin' => $this->vin ?? null, - 'driver' => $this->whenLoaded('driver', new Driver($this->driver)), - 'photo' => $this->photoUrl, - 'make' => $this->make, - 'model' => $this->model, - 'year' => $this->year, - 'trim' => $this->trim, - 'type' => $this->type, - 'plate_number' => $this->plate_number, - 'vin_data' => $this->vin_data, - 'status' => $this->status, - 'online' => $this->online, - 'meta' => $this->meta ?? [], - 'updated_at' => $this->updated_at, - 'created_at' => $this->created_at, + 'id' => $this->public_id, + 'internal_id' => $this->internal_id, + 'name' => $this->name, + 'vin' => $this->vin ?? null, + 'driver' => $this->whenLoaded('driver', new Driver($this->driver)), + 'photo' => $this->photoUrl, + 'make' => $this->make, + 'model' => $this->model, + 'year' => $this->year, + 'trim' => $this->trim, + 'type' => $this->type, + 'plate_number' => $this->plate_number, + 'vin' => $this->vin, + 'vin_data' => $this->vin_data, + 'status' => $this->status, + 'online' => $this->online, + 'location' => data_get($this, 'location', new Point(0, 0)), + 'heading' => (int) data_get($this, 'heading', 0), + 'altitude' => (int) data_get($this, 'altitude', 0), + 'speed' => (int) data_get($this, 'speed', 0), + 'meta' => $this->meta ?? [], + 'updated_at' => $this->updated_at, + 'created_at' => $this->created_at, ]; } } diff --git a/tests/integration/components/context-panel-test.js b/tests/integration/components/context-panel-test.js new file mode 100644 index 00000000..68401d93 --- /dev/null +++ b/tests/integration/components/context-panel-test.js @@ -0,0 +1,26 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | context-panel', function (hooks) { + setupRenderingTest(hooks); + + test('it renders', async function (assert) { + // Set any properties with this.set('myProperty', 'value'); + // Handle any actions with this.set('myAction', function(val) { ... }); + + await render(hbs``); + + assert.dom(this.element).hasText(''); + + // Template block usage: + await render(hbs` + + template block text + + `); + + assert.dom(this.element).hasText('template block text'); + }); +}); diff --git a/tests/unit/services/context-panel-test.js b/tests/unit/services/context-panel-test.js new file mode 100644 index 00000000..2fe54f49 --- /dev/null +++ b/tests/unit/services/context-panel-test.js @@ -0,0 +1,12 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; + +module('Unit | Service | context-panel', function (hooks) { + setupTest(hooks); + + // TODO: Replace this with your real tests. + test('it exists', function (assert) { + let service = this.owner.lookup('service:context-panel'); + assert.ok(service); + }); +}); diff --git a/tests/unit/services/leaflet-contextmenu-manager-test.js b/tests/unit/services/leaflet-contextmenu-manager-test.js new file mode 100644 index 00000000..fd63bddc --- /dev/null +++ b/tests/unit/services/leaflet-contextmenu-manager-test.js @@ -0,0 +1,12 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; + +module('Unit | Service | leaflet-contextmenu-manager', function (hooks) { + setupTest(hooks); + + // TODO: Replace this with your real tests. + test('it exists', function (assert) { + let service = this.owner.lookup('service:leaflet-contextmenu-manager'); + assert.ok(service); + }); +}); diff --git a/tests/unit/services/leaflet-map-manager-test.js b/tests/unit/services/leaflet-map-manager-test.js new file mode 100644 index 00000000..d1f87a0a --- /dev/null +++ b/tests/unit/services/leaflet-map-manager-test.js @@ -0,0 +1,12 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; + +module('Unit | Service | leaflet-map-manager', function (hooks) { + setupTest(hooks); + + // TODO: Replace this with your real tests. + test('it exists', function (assert) { + let service = this.owner.lookup('service:leaflet-map-manager'); + assert.ok(service); + }); +}); diff --git a/tests/unit/utils/find-leaflet-layer-test.js b/tests/unit/utils/find-leaflet-layer-test.js new file mode 100644 index 00000000..4de6e9dc --- /dev/null +++ b/tests/unit/utils/find-leaflet-layer-test.js @@ -0,0 +1,10 @@ +import findLeafletLayer from 'dummy/utils/find-leaflet-layer'; +import { module, test } from 'qunit'; + +module('Unit | Utility | find-leaflet-layer', function () { + // TODO: Replace this with your real tests. + test('it works', function (assert) { + let result = findLeafletLayer(); + assert.ok(result); + }); +}); diff --git a/tests/unit/utils/fly-to-leaflet-layer-test.js b/tests/unit/utils/fly-to-leaflet-layer-test.js new file mode 100644 index 00000000..883d2a68 --- /dev/null +++ b/tests/unit/utils/fly-to-leaflet-layer-test.js @@ -0,0 +1,10 @@ +import flyToLeafletLayer from 'dummy/utils/fly-to-leaflet-layer'; +import { module, test } from 'qunit'; + +module('Unit | Utility | fly-to-leaflet-layer', function () { + // TODO: Replace this with your real tests. + test('it works', function (assert) { + let result = flyToLeafletLayer(); + assert.ok(result); + }); +}); diff --git a/tests/unit/utils/get-leaflet-layer-by-id-test.js b/tests/unit/utils/get-leaflet-layer-by-id-test.js new file mode 100644 index 00000000..7cbc2b98 --- /dev/null +++ b/tests/unit/utils/get-leaflet-layer-by-id-test.js @@ -0,0 +1,10 @@ +import getLeafletLayerById from 'dummy/utils/get-leaflet-layer-by-id'; +import { module, test } from 'qunit'; + +module('Unit | Utility | get-leaflet-layer-by-id', function () { + // TODO: Replace this with your real tests. + test('it works', function (assert) { + let result = getLeafletLayerById(); + assert.ok(result); + }); +}); diff --git a/tests/unit/utils/layer-can-bind-contextmenu-test.js b/tests/unit/utils/layer-can-bind-contextmenu-test.js new file mode 100644 index 00000000..741a0612 --- /dev/null +++ b/tests/unit/utils/layer-can-bind-contextmenu-test.js @@ -0,0 +1,10 @@ +import layerCanBindContextmenu from 'dummy/utils/layer-can-bind-contextmenu'; +import { module, test } from 'qunit'; + +module('Unit | Utility | layer-can-bind-contextmenu', function () { + // TODO: Replace this with your real tests. + test('it works', function (assert) { + let result = layerCanBindContextmenu(); + assert.ok(result); + }); +});