From 3ded640230623eb857787976d0ddf0afc240365d Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 17 Jun 2026 11:57:02 +0800 Subject: [PATCH 01/20] v0.6.54 --- composer.json | 2 +- extension.json | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 3d948f3a..c7fda30f 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/fleetops-api", - "version": "0.6.53", + "version": "0.6.54", "description": "Fleet & Transport Management Extension for Fleetbase", "keywords": [ "fleetbase-extension", diff --git a/extension.json b/extension.json index 5c88b1f1..90bf0e78 100644 --- a/extension.json +++ b/extension.json @@ -1,6 +1,6 @@ { "name": "Fleet-Ops", - "version": "0.6.53", + "version": "0.6.54", "description": "Fleet & Transport Management Extension for Fleetbase", "repository": "https://github.com/fleetbase/fleetops", "license": "AGPL-3.0-or-later", diff --git a/package.json b/package.json index 1f7534ef..3c2eca01 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fleetbase/fleetops-engine", - "version": "0.6.53", + "version": "0.6.54", "description": "Fleet & Transport Management Extension for Fleetbase", "fleetbase": { "route": "fleet-ops" From f62f2c3a8f6a0403fcc78f09f570516af9d1ccdc Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 17 Jun 2026 12:00:09 +0800 Subject: [PATCH 02/20] Fix FleetOps registry sidebar ordering --- addon/components/layout/fleet-ops-sidebar.js | 110 +++++++++++++++--- .../layout/fleet-ops-sidebar-test.js | 92 ++++++++++++++- 2 files changed, 183 insertions(+), 19 deletions(-) diff --git a/addon/components/layout/fleet-ops-sidebar.js b/addon/components/layout/fleet-ops-sidebar.js index 6251792d..dfb36919 100644 --- a/addon/components/layout/fleet-ops-sidebar.js +++ b/addon/components/layout/fleet-ops-sidebar.js @@ -2,6 +2,7 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { inject as service } from '@ember/service'; import { action } from '@ember/object'; +import isMenuItemActive from '@fleetbase/ember-ui/utils/is-menu-item-active'; const SECTION_REGISTRY_KEYS = { operations: 'universeOperationsMenuItems', @@ -198,23 +199,26 @@ export default class LayoutFleetOpsSidebarComponent extends Component { } get registryRootItems() { - return this.universeMenuItems.filter((item) => !item.renderComponentInPlace).map((item) => this.registryItem(item)); + return this.sortByPriority(this.universeMenuItems.filter((item) => !item.renderComponentInPlace).map((item) => this.registryItem(item))); } get registryPanelItems() { - return this.universeMenuPanels.map((panel) => { - const children = (panel.items ?? []).filter((item) => !item.renderComponentInPlace).map((item) => this.registryItem(item)); - - return { - id: panel.id ?? panel.slug ?? panel.title, - label: panel.intl ? this.intl.t(panel.intl) : panel.title, - icon: panel.icon, - visible: panel.visible, - permission: panel.permission, - keywords: [panel.slug, panel.title, panel.intl].filter(Boolean), - children, - }; - }); + return this.sortByPriority( + this.universeMenuPanels.map((panel) => { + const children = this.sortByPriority((panel.items ?? []).filter((item) => !item.renderComponentInPlace).map((item) => this.registryItem(item))); + + return { + id: panel.id ?? panel.slug ?? panel.title, + label: panel.intl ? this.intl.t(panel.intl) : panel.title, + icon: panel.icon, + visible: panel.visible, + permission: panel.permission, + priority: panel.priority, + keywords: [panel.slug, panel.title, panel.intl].filter(Boolean), + children, + }; + }) + ); } get footerRegistryComponents() { @@ -257,6 +261,7 @@ export default class LayoutFleetOpsSidebarComponent extends Component { createItem(intl, icon, route, permission, ability, keywords = []) { return { + priority: this.defaultPriorityForRoute(route), label: this.intl.t(intl), description: this.intl.t(intl), icon, @@ -269,6 +274,8 @@ export default class LayoutFleetOpsSidebarComponent extends Component { createHubItem(label, icon, route, permission, ability, keywords = []) { return { + pinnedFirst: true, + priority: this.defaultPriorityForRoute(route), label, description: label, icon, @@ -280,8 +287,9 @@ export default class LayoutFleetOpsSidebarComponent extends Component { } registryItem(menuItem) { - return { + const registryMenuItem = { ...menuItem, + _virtual: true, label: menuItem.intl ? this.intl.t(menuItem.intl) : (menuItem.title ?? menuItem.label), description: menuItem.description, icon: menuItem.icon, @@ -289,15 +297,83 @@ export default class LayoutFleetOpsSidebarComponent extends Component { permission: menuItem.permission, visible: menuItem.visible, keywords: [menuItem.slug, menuItem.view, menuItem.section, menuItem.title, menuItem.label, menuItem.intl, ...(menuItem.keywords ?? [])].filter(Boolean), - onClick: () => this.universe.transitionMenuItem(`${this.routePrefix}virtual`, menuItem), + activeWhen: () => isMenuItemActive(menuItem.section, menuItem.slug, menuItem.view), }; + + registryMenuItem.onClick = () => this.universe.transitionMenuItem(`${this.routePrefix}virtual`, registryMenuItem); + + return registryMenuItem; } withRegistryItems(section, items) { const registryProperty = SECTION_REGISTRY_KEYS[section]; const registryItems = (this[registryProperty] ?? []).filter((item) => !item.renderComponentInPlace).map((item) => this.registryItem(item)); - return [...items, ...registryItems]; + return this.sortByPriority([...items, ...registryItems]); + } + + sortByPriority(items = []) { + return [...items] + .map((item, index) => ({ item, index })) + .sort((a, b) => { + if (a.item.pinnedFirst !== b.item.pinnedFirst) { + return a.item.pinnedFirst ? -1 : 1; + } + + const priorityOrder = (a.item.priority ?? 0) - (b.item.priority ?? 0); + + if (priorityOrder !== 0) { + return priorityOrder; + } + + return a.index - b.index; + }) + .map(({ item }) => item); + } + + defaultPriorityForRoute(route) { + const priorities = { + 'operations.orders': 0, + 'operations.orchestrator': 1, + 'operations.scheduler': 2, + 'operations.order-config': 3, + 'operations.service-rates': 4, + 'management.index': 0, + 'management.drivers': 1, + 'management.vehicles': 2, + 'management.fleets': 3, + 'management.vendors': 4, + 'management.contacts': 5, + 'management.places': 6, + 'management.fuel-reports': 7, + 'management.fuel-transactions': 8, + 'management.issues': 9, + 'maintenance.index': 0, + 'maintenance.schedules': 1, + 'maintenance.work-orders': 2, + 'maintenance.maintenances': 3, + 'maintenance.equipment': 4, + 'maintenance.parts': 5, + 'connectivity.telematics': 0, + 'connectivity.fuel-providers': 1, + 'connectivity.devices': 2, + 'connectivity.sensors': 3, + 'connectivity.events': 4, + 'analytics.index': 0, + 'analytics.reports': 1, + 'settings.index': 0, + 'settings.navigator-app': 1, + 'settings.map': 2, + 'settings.payments': 3, + 'settings.notifications': 4, + 'settings.routing': 5, + 'settings.orchestrator': 6, + 'settings.scheduling': 7, + 'settings.custom-fields': 8, + 'settings.avatars': 9, + }; + + return priorities[route] ?? 0; } fullRoute(route) { diff --git a/tests/integration/components/layout/fleet-ops-sidebar-test.js b/tests/integration/components/layout/fleet-ops-sidebar-test.js index e7fa66fc..76ee3fba 100644 --- a/tests/integration/components/layout/fleet-ops-sidebar-test.js +++ b/tests/integration/components/layout/fleet-ops-sidebar-test.js @@ -3,21 +3,33 @@ import { setupRenderingTest } from 'dummy/tests/helpers'; import { click, fillIn, render, waitFor } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; import Service from '@ember/service'; +import window from 'ember-window-mock'; +import { getOwner } from '@ember/application'; class RouterStubService extends Service { currentRouteName = 'console.fleet-ops.operations.orders'; currentURL = '/fleet-ops'; transitions = []; + handlers = {}; - on() {} + on(eventName, handler) { + this.handlers[eventName] = handler; + } - off() {} + off(eventName) { + delete this.handlers[eventName]; + } transitionTo(route, ...args) { this.currentRouteName = route; this.transitions.push({ route, args }); + this.triggerRouteDidChange(); return Promise.resolve(); } + + triggerRouteDidChange() { + this.handlers.routeDidChange?.(); + } } module('Integration | Component | layout/fleet-ops-sidebar', function (hooks) { @@ -113,6 +125,82 @@ module('Integration | Component | layout/fleet-ops-sidebar', function (hooks) { assert.dom('.next-sidebar-navigator-view-in .next-sidebar-navigator-item:first-of-type svg[data-icon="table-cells-large"]').doesNotExist(); }); + test('it preserves registered item priority, virtual metadata, and nested active state', async function (assert) { + assert.expect(8); + + const contractsItem = { + title: 'Contracts', + slug: 'contracts', + section: 'management', + icon: 'file-signature', + priority: -10, + visible: true, + }; + const permitsItem = { + title: 'Permits', + slug: 'permits', + section: 'management', + icon: 'stamp', + priority: 1.5, + visible: true, + }; + + class MenuServiceStub extends Service { + getMenuItems(registryName) { + assert.strictEqual(registryName, 'engine:fleet-ops'); + return [permitsItem, contractsItem]; + } + + getMenuPanels(registryName) { + assert.strictEqual(registryName, 'engine:fleet-ops'); + return [ + { + title: 'Registry Late', + icon: 'box', + priority: 20, + items: [], + }, + { + title: 'Registry Early', + icon: 'box', + priority: 10, + items: [], + }, + ]; + } + } + + class UniverseStub extends Service { + transitionMenuItem(route, menuItem) { + assert.strictEqual(route, 'console.fleet-ops.virtual'); + assert.true(menuItem._virtual, 'registered item keeps virtual metadata'); + assert.strictEqual(menuItem.slug, 'contracts'); + + const router = getOwner(this).lookup('service:router'); + router.currentRouteName = 'console.fleet-ops.virtual'; + router.currentURL = '/fleet-ops/management/contracts'; + window.location.href = '/fleet-ops/management/contracts'; + router.triggerRouteDidChange(); + } + } + + this.owner.register('service:universe/menu-service', MenuServiceStub); + this.owner.register('service:universe', UniverseStub); + + await render(hbs``); + + await click('.next-sidebar-navigator-view-in .next-sidebar-navigator-item:nth-of-type(2)'); + + const labels = [...this.element.querySelectorAll('.next-sidebar-navigator-view-in .next-sidebar-navigator-item-label')].map((element) => element.textContent.trim()); + + assert.deepEqual(labels.slice(0, 5), ['Resources Hub', 'Contracts', 'Drivers', 'Permits', 'Vehicles'], 'hub items stay first while registered section items sort by priority'); + + await click('.next-sidebar-navigator-view-in .next-sidebar-navigator-item:nth-of-type(2)'); + + assert.dom('.next-sidebar-navigator-back').includesText('Resources'); + assert.dom('.next-sidebar-navigator-view-in .next-sidebar-navigator-item:nth-of-type(2)').hasClass('is-active'); + }); + test('it keeps block usage backwards compatible', async function (assert) { await render(hbs` From de1c51a9efe0c0908b7c542f8e76fda363bec428 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 17 Jun 2026 12:00:14 +0800 Subject: [PATCH 03/20] fix: preserve driver license expiry on updates --- .../src/Http/Requests/CreateDriverRequest.php | 27 +++++------ .../Requests/Internal/CreateDriverRequest.php | 1 + server/src/Http/Resources/v1/Driver.php | 13 +++++- server/src/Models/Driver.php | 16 +++++++ server/tests/VehicleDriverAssignmentTest.php | 27 +++++++++++ tests/unit/services/driver-actions-test.js | 46 +++++++++++++++++++ 6 files changed, 115 insertions(+), 15 deletions(-) diff --git a/server/src/Http/Requests/CreateDriverRequest.php b/server/src/Http/Requests/CreateDriverRequest.php index 5de9fbe8..00749937 100644 --- a/server/src/Http/Requests/CreateDriverRequest.php +++ b/server/src/Http/Requests/CreateDriverRequest.php @@ -28,19 +28,20 @@ public function rules() $isCreating = $this->isMethod('POST'); return [ - 'name' => [Rule::requiredIf($isCreating)], - 'email' => [Rule::requiredIf($isCreating), Rule::when($this->filled('email'), ['email']), Rule::when($isCreating, [Rule::unique('users')->whereNull('deleted_at')])], - 'phone' => [Rule::requiredIf($isCreating), Rule::when($isCreating, [Rule::unique('users')->whereNull('deleted_at')])], - 'password' => 'nullable|string', - 'country' => 'nullable|size:2', - 'city' => 'nullable|string', - 'vehicle' => 'nullable|string|starts_with:vehicle_|exists:vehicles,public_id', - 'status' => 'nullable|string|in:active,available,inactive', - 'vendor' => 'nullable|exists:vendors,public_id', - 'job' => 'nullable|exists:orders,public_id', - 'location' => ['nullable', new ResolvablePoint()], - 'latitude' => ['nullable', 'required_with:longitude'], - 'longitude' => ['nullable', 'required_with:latitude'], + 'name' => [Rule::requiredIf($isCreating)], + 'email' => [Rule::requiredIf($isCreating), Rule::when($this->filled('email'), ['email']), Rule::when($isCreating, [Rule::unique('users')->whereNull('deleted_at')])], + 'phone' => [Rule::requiredIf($isCreating), Rule::when($isCreating, [Rule::unique('users')->whereNull('deleted_at')])], + 'password' => 'nullable|string', + 'country' => 'nullable|size:2', + 'city' => 'nullable|string', + 'vehicle' => 'nullable|string|starts_with:vehicle_|exists:vehicles,public_id', + 'license_expiry' => 'nullable|date', + 'status' => 'nullable|string|in:active,available,inactive', + 'vendor' => 'nullable|exists:vendors,public_id', + 'job' => 'nullable|exists:orders,public_id', + 'location' => ['nullable', new ResolvablePoint()], + 'latitude' => ['nullable', 'required_with:longitude'], + 'longitude' => ['nullable', 'required_with:latitude'], ]; } diff --git a/server/src/Http/Requests/Internal/CreateDriverRequest.php b/server/src/Http/Requests/Internal/CreateDriverRequest.php index 28d90053..4a406116 100644 --- a/server/src/Http/Requests/Internal/CreateDriverRequest.php +++ b/server/src/Http/Requests/Internal/CreateDriverRequest.php @@ -51,6 +51,7 @@ public function rules() 'country' => 'nullable|string|size:2', 'city' => 'nullable|string|max:255', 'vehicle' => ['nullable', new ResolvableVehicle()], + 'license_expiry' => 'nullable|date', 'status' => 'nullable|string|in:active,available,inactive', 'vendor' => 'nullable|exists:vendors,public_id', 'job' => 'nullable|exists:orders,public_id', diff --git a/server/src/Http/Resources/v1/Driver.php b/server/src/Http/Resources/v1/Driver.php index 3be0f91a..2e8fc4cd 100644 --- a/server/src/Http/Resources/v1/Driver.php +++ b/server/src/Http/Resources/v1/Driver.php @@ -38,7 +38,7 @@ public function toArray($request) 'email' => $this->email, 'phone' => $this->phone, 'drivers_license_number' => $this->drivers_license_number, - 'license_expiry' => $this->license_expiry, + 'license_expiry' => $this->formatDateOnly($this->license_expiry), 'photo_url' => $this->photo_url, 'avatar_url' => $this->avatar_url, 'avatar_value' => $this->when(Http::isInternalRequest(), $this->getOriginal('avatar_url')), @@ -96,6 +96,15 @@ public function getJobs(): AnonymousResourceCollection|FleetbaseResourceCollecti ); } + protected function formatDateOnly($date): ?string + { + if (!$date) { + return null; + } + + return method_exists($date, 'toDateString') ? $date->toDateString() : (string) $date; + } + /** * Transform the resource into an webhook payload. * @@ -110,7 +119,7 @@ public function toWebhookPayload() 'email' => $this->email, 'phone' => $this->phone, 'photo_url' => $this->photo_url, - 'license_expiry' => $this->license_expiry, + 'license_expiry' => $this->formatDateOnly($this->license_expiry), 'vehicle' => data_get($this, 'vehicle.public_id'), 'current_job' => data_get($this, 'currentJob.public_id'), 'vendor' => data_get($this, 'vendor.public_id'), diff --git a/server/src/Models/Driver.php b/server/src/Models/Driver.php index 6ae9b334..d82c045c 100644 --- a/server/src/Models/Driver.php +++ b/server/src/Models/Driver.php @@ -32,6 +32,7 @@ use Illuminate\Database\Eloquent\Relations\MorphOne; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Arr; +use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; @@ -235,6 +236,21 @@ protected static function boot() static::addGlobalScope(new DriverScope()); } + public function setLicenseExpiryAttribute($value): void + { + if (empty($value)) { + if ($this->exists && !empty($this->getOriginal('license_expiry'))) { + return; + } + + $this->attributes['license_expiry'] = null; + + return; + } + + $this->attributes['license_expiry'] = Carbon::parse($value)->toDateString(); + } + /** * @return BelongsTo */ diff --git a/server/tests/VehicleDriverAssignmentTest.php b/server/tests/VehicleDriverAssignmentTest.php index 5556408c..9a9baf06 100644 --- a/server/tests/VehicleDriverAssignmentTest.php +++ b/server/tests/VehicleDriverAssignmentTest.php @@ -17,6 +17,33 @@ ->toContain("Driver::where('vehicle_uuid', \$this->uuid)->update(['vehicle_uuid' => null])"); }); +test('driver vehicle assignment preserves license expiry', function () { + $apiController = file_get_contents(dirname(__DIR__) . '/src/Http/Controllers/Api/v1/DriverController.php'); + $internalController = file_get_contents(dirname(__DIR__) . '/src/Http/Controllers/Internal/v1/DriverController.php'); + $driverModel = file_get_contents(dirname(__DIR__) . '/src/Models/Driver.php'); + $driverResource = file_get_contents(dirname(__DIR__) . '/src/Http/Resources/v1/Driver.php'); + + expect($apiController) + ->not->toContain('normalizeDriverUpdateInput') + ->not->toContain("array_key_exists('license_expiry', \$input)") + ->and($internalController) + ->not->toContain('normalizeDriverUpdateInput') + ->toContain('public function assignVehicle(Request $request, string $id): JsonResponse') + ->toContain('$driver->assignVehicle($vehicle)') + ->not->toContain("'license_expiry' => null") + ->and($driverModel) + ->toContain('public function setLicenseExpiryAttribute($value): void') + ->toContain("!empty(\$this->getOriginal('license_expiry'))") + ->toContain("Carbon::parse(\$value)->toDateString()") + ->toContain('public function assignVehicle(Vehicle $vehicle): self') + ->toContain('$this->setVehicle($vehicle)') + ->toContain('$this->save()') + ->not->toContain('$this->license_expiry = null') + ->and($driverResource) + ->toContain("'license_expiry' => \$this->formatDateOnly(\$this->license_expiry)") + ->toContain('protected function formatDateOnly($date): ?string'); +}); + test('driver assigned-order workflows expose list and multi-unassign endpoints', function () { $routes = file_get_contents(dirname(__DIR__) . '/src/routes.php'); $controller = file_get_contents(dirname(__DIR__) . '/src/Http/Controllers/Internal/v1/DriverController.php'); diff --git a/tests/unit/services/driver-actions-test.js b/tests/unit/services/driver-actions-test.js index 073512a0..aafdb64d 100644 --- a/tests/unit/services/driver-actions-test.js +++ b/tests/unit/services/driver-actions-test.js @@ -9,6 +9,52 @@ module('Unit | Service | driver-actions', function (hooks) { assert.ok(service); }); + test('assignVehicle posts only the selected vehicle and reloads the driver', async function (assert) { + const service = this.owner.lookup('service:driver-actions'); + const options = {}; + const posted = {}; + const driver = { + id: 'driver-1', + name: 'Alex Driver', + vehicle_uuid: 'vehicle-1', + license_expiry: null, + reload: async () => assert.step('driver reloaded'), + save: () => assert.ok(false, 'assignVehicle should not save the driver model'), + rollbackAttributes: () => assert.ok(false, 'assignVehicle should not roll back on success'), + }; + + service.intl = { t: (key) => key }; + service.refresh = () => assert.step('refreshed'); + service.notifications = { + serverError: () => assert.ok(false, 'unexpected call'), + success: (message) => assert.strictEqual(message, 'driver.prompts.assign-vehicle-success'), + warning: () => assert.ok(false, 'unexpected call'), + }; + service.fetch = { + post: async (url, payload) => { + posted.url = url; + posted.payload = payload; + }, + }; + service.modalsManager = { + show: (_name, modalOptions) => Object.assign(options, modalOptions), + }; + + service.assignVehicle(driver); + await options.confirm({ + startLoading: () => assert.step('modal loading'), + stopLoading: () => assert.ok(false, 'modal should not stop loading on success'), + done: () => assert.step('modal closed'), + }); + + assert.deepEqual(posted, { + url: 'drivers/driver-1/assign-vehicle', + payload: { vehicle: 'vehicle-1' }, + }); + assert.false(Object.prototype.hasOwnProperty.call(posted.payload, 'license_expiry'), 'does not post license_expiry'); + assert.verifySteps(['modal loading', 'driver reloaded', 'modal closed', 'refreshed']); + }); + test('unassignOrders loads assigned orders, highlights the current job, and posts selected orders', async function (assert) { const service = this.owner.lookup('service:driver-actions'); const options = {}; From 00149009e8aeffe8c3e9e17f1db4c39371fda4b1 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 17 Jun 2026 12:00:40 +0800 Subject: [PATCH 04/20] Add service quote refresh coordinator --- addon/components/order/form/details.hbs | 2 +- addon/components/order/form/details.js | 22 ++++++ addon/components/order/form/payload.js | 50 +++++++++++- addon/components/order/form/route.js | 15 ++++ addon/components/order/form/service-rate.js | 74 +++++++++++++++++- addon/services/order-creation.js | 12 ++- .../components/order/form/details-test.js | 36 +++++++++ .../components/order/form/payload-test.js | 44 +++++++++++ .../components/order/form/route-test.js | 42 ++++++++++ .../order/form/service-rate-test.js | 77 ++++++++++++++++++- tests/unit/services/order-creation-test.js | 18 ++++- 11 files changed, 384 insertions(+), 8 deletions(-) diff --git a/addon/components/order/form/details.hbs b/addon/components/order/form/details.hbs index bf39913e..80aebfad 100644 --- a/addon/components/order/form/details.hbs +++ b/addon/components/order/form/details.hbs @@ -28,7 +28,7 @@ @value={{@resource.scheduled_at}} @minDate={{format-date-fns (now) "yyyy-MM-dd"}} @minTime={{format-date-fns (now) "HH:mm"}} - @onUpdate={{fn (mut @resource.scheduled_at)}} + @onUpdate={{this.setScheduledAt}} @disabled={{or @resource.dispatched (cannot-write @resource)}} /> diff --git a/addon/components/order/form/details.js b/addon/components/order/form/details.js index 86a99a45..838b78aa 100644 --- a/addon/components/order/form/details.js +++ b/addon/components/order/form/details.js @@ -20,9 +20,14 @@ export default class OrderFormDetailsComponent extends Component { this.orderConfigActions.loadAll.perform(); } + get integratedVendorServiceType() { + return this.args.resource?.type; + } + @action selectFacilitator(model) { this.args.resource.set('facilitator', model); this.args.resource.set('driver', null); + this.requestServiceQuoteRefresh('details.facilitator.changed'); } @action selectCustomer(model) { @@ -39,6 +44,7 @@ export default class OrderFormDetailsComponent extends Component { type: orderConfig.key, }); this.args.resource.payload.set('type', orderConfig.key); + this.requestServiceQuoteRefresh('details.order_config.changed'); try { const customFieldsManager = yield this.customFieldsRegistry.loadSubjectCustomFields.perform(orderConfig); @@ -127,6 +133,18 @@ export default class OrderFormDetailsComponent extends Component { } } + @action setScheduledAt(value) { + this.args.resource.scheduled_at = value; + this.requestServiceQuoteRefresh('details.scheduled_at.changed'); + } + + @action selectIntegratedServiceType(serviceType) { + const type = serviceType?.key ?? serviceType?.value ?? serviceType; + this.args.resource.type = type; + this.args.resource.payload.set('type', type); + this.requestServiceQuoteRefresh('details.integrated_service_type.changed'); + } + @action toggleAdhoc(toggled) { this.args.resource.adhoc = toggled; this.args.resource.adhoc_distance = this.currentUser.getCompanyOption('fleetops.adhoc_distance', 5000); @@ -136,4 +154,8 @@ export default class OrderFormDetailsComponent extends Component { this.args.resource.pod_required = toggled; this.args.resource.pod_method = toggled ? 'scan' : null; } + + requestServiceQuoteRefresh(reason) { + this.orderCreation.requestServiceQuoteRefresh(reason, this.args.resource); + } } diff --git a/addon/components/order/form/payload.js b/addon/components/order/form/payload.js index 2b3e401e..a87738c2 100644 --- a/addon/components/order/form/payload.js +++ b/addon/components/order/form/payload.js @@ -7,6 +7,7 @@ export default class OrderFormPayloadComponent extends Component { @service store; @service fetch; @service entityActions; + @service orderCreation; get entitiesByImportId() { const groups = []; @@ -32,6 +33,7 @@ export default class OrderFormPayloadComponent extends Component { const { value } = target; this.args.resource.payload.entities[index].destination_uuid = value; + this.requestServiceQuoteRefresh('entity.destination.changed'); } @action addFromCustomEntity(customEntity) { @@ -41,11 +43,15 @@ export default class OrderFormPayloadComponent extends Component { }); this.args.resource.payload.entities.pushObject(entity); + this.requestServiceQuoteRefresh('entity.added'); } @action addEntities(entities = []) { if (isArray(entities)) { this.args.resource.payload.entities.pushObjects(entities); + if (entities.length) { + this.requestServiceQuoteRefresh('entity.batch_added'); + } } } @@ -56,21 +62,61 @@ export default class OrderFormPayloadComponent extends Component { }); this.args.resource.payload.entities.pushObject(entity); + this.requestServiceQuoteRefresh('entity.added'); } @action removeEntity(entity) { if (this.args.resource.payload.entities.length === 1) return; if (!entity.isNew) { - return entity.destroyRecord(); + return entity.destroyRecord().then(() => { + this.requestServiceQuoteRefresh('entity.removed'); + }); } this.args.resource.payload.entities.removeObject(entity); + this.requestServiceQuoteRefresh('entity.removed'); } @action editEntity(entity) { this.entityActions.modal.edit(entity, { - confirm: (modal) => modal.done(), + confirm: (modal) => { + modal.done(); + this.requestServiceQuoteRefresh('entity.edited'); + }, }); } + + @action setEntityQuoteField(entity, field, value) { + this.setEntityValue(entity, field, value); + this.requestServiceQuoteRefresh(`entity.${field}.changed`); + } + + @action setEntityCurrency(entity, value) { + this.setEntityValue(entity, 'currency', value); + this.requestServiceQuoteRefresh('entity.currency.changed'); + } + + @action setEntityDimensionsUnit(entity, value) { + this.setEntityValue(entity, 'dimensions_unit', value); + this.requestServiceQuoteRefresh('entity.dimensions_unit.changed'); + } + + @action setEntityWeightUnit(entity, value) { + this.setEntityValue(entity, 'weight_unit', value); + this.requestServiceQuoteRefresh('entity.weight_unit.changed'); + } + + setEntityValue(entity, field, value) { + if (typeof entity?.set === 'function') { + entity.set(field, value); + return; + } + + entity[field] = value; + } + + requestServiceQuoteRefresh(reason) { + this.orderCreation.requestServiceQuoteRefresh(reason, this.args.resource); + } } diff --git a/addon/components/order/form/route.js b/addon/components/order/form/route.js index 1961843b..0185ccde 100644 --- a/addon/components/order/form/route.js +++ b/addon/components/order/form/route.js @@ -20,6 +20,7 @@ export default class OrderFormRouteComponent extends Component { @service currentUser; @service notifications; @service placeActions; + @service orderCreation; @tracked multipleWaypoints = false; @tracked routingControl; @tracked route; @@ -119,6 +120,8 @@ export default class OrderFormRouteComponent extends Component { this.clearWaypoints(); } + + this.requestServiceQuoteRefresh('route.waypoints.toggled'); } @action sortWaypoints({ sourceList, sourceIndex, targetList, targetIndex }) { @@ -132,6 +135,7 @@ export default class OrderFormRouteComponent extends Component { targetList.insertAt(targetIndex, item); this.previewRoute(); + this.requestServiceQuoteRefresh('route.waypoints.reordered'); } @action addWaypoint(properties = {}) { @@ -143,6 +147,7 @@ export default class OrderFormRouteComponent extends Component { this.args.resource.payload.waypoints.pushObject(waypoint); this.previewRoute(); + this.requestServiceQuoteRefresh('route.waypoint.added'); } @action setWaypointPlace(index, place) { @@ -159,6 +164,7 @@ export default class OrderFormRouteComponent extends Component { location: place.location, }); this.previewRoute(); + this.requestServiceQuoteRefresh('route.waypoint.place.changed'); } @action setWaypointCustomer(waypoint, model) { @@ -170,16 +176,19 @@ export default class OrderFormRouteComponent extends Component { if (this.multipleWaypoints && this.args.resource.payload.waypoints.length === 1) return; this.args.resource.payload.waypoints.removeObject(waypoint); this.previewRoute(); + this.requestServiceQuoteRefresh('route.waypoint.removed'); } @action clearWaypoints() { this.args.resource.payload.waypoints.clear(); this.previewRoute(); + this.requestServiceQuoteRefresh('route.waypoints.cleared'); } @action setPayloadPlace(prop, place) { this.args.resource.payload[prop] = place; this.previewRoute(); + this.requestServiceQuoteRefresh(`route.${prop}.changed`); } @action editPlace(place) { @@ -291,6 +300,7 @@ export default class OrderFormRouteComponent extends Component { } this.previewRoute(); this.args.resource.set('optimized', true); + this.requestServiceQuoteRefresh('route.optimized'); } @action setOptimizedRoute(route, trip, waypoints, engine = 'osrm') { @@ -314,5 +324,10 @@ export default class OrderFormRouteComponent extends Component { const routeModel = this.store.createRecord('route', payload); this.args.resource.route = routeModel; this.route = payload; + this.requestServiceQuoteRefresh('route.changed'); + } + + requestServiceQuoteRefresh(reason) { + this.orderCreation.requestServiceQuoteRefresh(reason, this.args.resource); } } diff --git a/addon/components/order/form/service-rate.js b/addon/components/order/form/service-rate.js index 23c379af..4c428fb6 100644 --- a/addon/components/order/form/service-rate.js +++ b/addon/components/order/form/service-rate.js @@ -1,18 +1,38 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { inject as service } from '@ember/service'; -import { task } from 'ember-concurrency'; +import { task, timeout } from 'ember-concurrency'; +import { SERVICE_QUOTE_REFRESH_REQUESTED } from '../../../services/order-creation'; + +const SERVICE_QUOTE_REFRESH_DEBOUNCE_MS = 500; export default class OrderFormServiceRateComponent extends Component { @service serviceRateActions; + @service orderCreation; @tracked selectedRate; @tracked serviceRates = []; @tracked serviceQuotes = []; + constructor() { + super(...arguments); + this._serviceQuoteRefreshRequested = (event) => this.handleServiceQuoteRefreshRequest(event); + this.orderCreation.on(SERVICE_QUOTE_REFRESH_REQUESTED, this._serviceQuoteRefreshRequested); + } + + willDestroy() { + this.orderCreation.off(SERVICE_QUOTE_REFRESH_REQUESTED, this._serviceQuoteRefreshRequested); + this.refreshServiceQuotes.cancelAll(); + super.willDestroy(...arguments); + } + get isServicable() { return this.args.resource?.order_config && this.args.resource?.payloadCoordinates?.length >= 2; } + get canRefreshServiceQuotes() { + return this.args.resource?.servicable && this.selectedRate && this.args.resource?.payloadCoordinates?.length >= 2; + } + @task *queryServiceRates(toggled) { this.args.resource.servicable = toggled; if (!toggled) return; @@ -21,6 +41,56 @@ export default class OrderFormServiceRateComponent extends Component { @task *getServiceQuotes(serviceRate) { this.selectedRate = serviceRate; - this.serviceQuotes = yield this.serviceRateActions.getServiceQuotes.perform(serviceRate, this.args.resource); + yield this.loadServiceQuotes(serviceRate); + } + + @task *refreshServiceQuotes() { + yield timeout(SERVICE_QUOTE_REFRESH_DEBOUNCE_MS); + + if (!this.canRefreshServiceQuotes) { + return; + } + + yield this.loadServiceQuotes(this.selectedRate); + } + + handleServiceQuoteRefreshRequest({ order } = {}) { + if (order && order !== this.args.resource) { + return; + } + + if (!this.canRefreshServiceQuotes) { + return; + } + + this.refreshServiceQuotes.cancelAll(); + this.refreshServiceQuotes.perform(); + } + + async loadServiceQuotes(serviceRate) { + const serviceQuotes = await this.serviceRateActions.getServiceQuotes.perform(serviceRate, this.args.resource); + this.serviceQuotes = serviceQuotes ?? []; + this.clearStaleSelectedServiceQuote(); + } + + clearStaleSelectedServiceQuote() { + const selectedServiceQuote = this.args.resource?.service_quote_uuid; + + if (!selectedServiceQuote) { + return; + } + + if (!this.serviceQuotes?.length) { + this.args.resource.service_quote_uuid = null; + return; + } + + const selectedQuoteExists = this.serviceQuotes.some((serviceQuote) => { + return serviceQuote?.uuid === selectedServiceQuote || serviceQuote?.id === selectedServiceQuote; + }); + + if (!selectedQuoteExists) { + this.args.resource.service_quote_uuid = null; + } } } diff --git a/addon/services/order-creation.js b/addon/services/order-creation.js index 8fb3f0f0..f069c2fc 100644 --- a/addon/services/order-creation.js +++ b/addon/services/order-creation.js @@ -1,8 +1,11 @@ import Service, { inject as service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; import { next } from '@ember/runloop'; +import Evented from '@ember/object/evented'; -export default class OrderCreationService extends Service { +export const SERVICE_QUOTE_REFRESH_REQUESTED = 'service-quote-refresh-requested'; + +export default class OrderCreationService extends Service.extend(Evented) { @service orderActions; @tracked context; @tracked order; @@ -34,4 +37,11 @@ export default class OrderCreationService extends Service { removeContext(key) { delete this.context[key]; } + + requestServiceQuoteRefresh(reason, order = this.order) { + this.trigger(SERVICE_QUOTE_REFRESH_REQUESTED, { + reason, + order, + }); + } } diff --git a/tests/integration/components/order/form/details-test.js b/tests/integration/components/order/form/details-test.js index f683eac9..175665e5 100644 --- a/tests/integration/components/order/form/details-test.js +++ b/tests/integration/components/order/form/details-test.js @@ -3,6 +3,7 @@ import Service from '@ember/service'; import { setupRenderingTest } from 'dummy/tests/helpers'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import OrderFormDetailsComponent from '@fleetbase/fleetops-engine/components/order/form/details'; module('Integration | Component | order/form/details', function (hooks) { setupRenderingTest(hooks); @@ -37,4 +38,39 @@ module('Integration | Component | order/form/details', function (hooks) { assert.true(requiredLabels.includes('Order Type')); assert.true(requiredLabels.includes('Proof of Delivery')); }); + + test('quote-relevant detail changes request service quote refresh', function (assert) { + const requests = []; + const resource = { + payload: { + set() {}, + }, + set(field, value) { + this[field] = value; + }, + }; + + class OrderCreationStub extends Service { + requestServiceQuoteRefresh(reason, order) { + requests.push({ reason, order }); + } + } + + this.owner.register('service:order-creation', OrderCreationStub); + + const component = new OrderFormDetailsComponent(this.owner, { resource }); + + component.selectFacilitator({ id: 'facilitator-1' }); + component.setScheduledAt('2026-06-17T12:00:00Z'); + component.selectIntegratedServiceType('express'); + + assert.deepEqual( + requests.map((request) => request.reason), + ['details.facilitator.changed', 'details.scheduled_at.changed', 'details.integrated_service_type.changed'] + ); + assert.true( + requests.every((request) => request.order === resource), + 'requests refresh for the current order' + ); + }); }); diff --git a/tests/integration/components/order/form/payload-test.js b/tests/integration/components/order/form/payload-test.js index 28b2dc14..6cbfdc68 100644 --- a/tests/integration/components/order/form/payload-test.js +++ b/tests/integration/components/order/form/payload-test.js @@ -2,6 +2,7 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; import Service from '@ember/service'; import { click, findAll, render } from '@ember/test-helpers'; +import { A } from '@ember/array'; import { hbs } from 'ember-cli-htmlbars'; module('Integration | Component | order/form/payload', function (hooks) { @@ -62,4 +63,47 @@ module('Integration | Component | order/form/payload', function (hooks) { assert.strictEqual(savedCount, 0, 'clicking edit does not save the entity directly'); }); + + test('adding and removing entities requests service quote refresh', async function (assert) { + const requests = []; + const entity = { + isNew: true, + name: 'Draft box', + sku: 'BOX-1', + type: 'entity', + photo_url: 'https://example.test/box.png', + }; + const order = { + imported: false, + payload: { + entities: A([entity]), + waypoints: A([]), + }, + }; + + class OrderCreationStub extends Service { + requestServiceQuoteRefresh(reason, resource) { + requests.push({ reason, resource }); + } + } + + this.owner.register('service:order-creation', OrderCreationStub); + this.set('order', order); + + await render(hbs``); + + const addButton = findAll('button').find((button) => button.textContent.includes('Add Item')); + assert.ok(addButton, 'renders the add item button'); + + await click(addButton); + + assert.strictEqual(requests.at(-1).reason, 'entity.added'); + assert.strictEqual(requests.at(-1).resource, order); + + const removeButtons = findAll('button').filter((button) => button.querySelector('[data-icon="times"]')); + await click(removeButtons.at(-1)); + + assert.strictEqual(requests.at(-1).reason, 'entity.removed'); + assert.strictEqual(requests.at(-1).resource, order); + }); }); diff --git a/tests/integration/components/order/form/route-test.js b/tests/integration/components/order/form/route-test.js index 7036d310..e68b1980 100644 --- a/tests/integration/components/order/form/route-test.js +++ b/tests/integration/components/order/form/route-test.js @@ -2,6 +2,7 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; import { click, render, settled } from '@ember/test-helpers'; import { A } from '@ember/array'; +import Service from '@ember/service'; import { hbs } from 'ember-cli-htmlbars'; module('Integration | Component | order/form/route', function (hooks) { @@ -62,4 +63,45 @@ module('Integration | Component | order/form/route', function (hooks) { assert.dom('[data-test-waypoint-row="3"]').doesNotHaveClass('fleetops-order-form-waypoint--required'); assert.dom('[data-test-required-waypoint-tab]').exists({ count: 2 }); }); + + test('route mutations request service quote refresh', async function (assert) { + const requests = []; + + class OrderCreationStub extends Service { + requestServiceQuoteRefresh(reason, resource) { + requests.push({ reason, resource }); + } + } + + this.owner.register('service:order-creation', OrderCreationStub); + this.set('resource', { + customer: null, + driver_assigned: null, + id: 'test-order', + facilitator: { + isIntegratedVendor: false, + }, + payload: { + pickup: null, + dropoff: null, + return: null, + waypoints: A([]), + setProperties(properties) { + Object.assign(this, properties); + }, + }, + }); + + await render(hbs``); + await click('[role="checkbox"]'); + + assert.true( + requests.some((request) => request.reason === 'route.waypoints.toggled'), + 'requests refresh when waypoint mode changes' + ); + assert.true( + requests.every((request) => request.resource === this.resource), + 'requests refresh for the current order' + ); + }); }); diff --git a/tests/integration/components/order/form/service-rate-test.js b/tests/integration/components/order/form/service-rate-test.js index 652a4c14..b019fd6e 100644 --- a/tests/integration/components/order/form/service-rate-test.js +++ b/tests/integration/components/order/form/service-rate-test.js @@ -1,7 +1,9 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { click, render } from '@ember/test-helpers'; +import Service from '@ember/service'; +import { click, render, waitUntil } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import OrderFormServiceRateComponent from '@fleetbase/fleetops-engine/components/order/form/service-rate'; module('Integration | Component | order/form/service-rate', function (hooks) { setupRenderingTest(hooks); @@ -39,4 +41,77 @@ module('Integration | Component | order/form/service-rate', function (hooks) { assert.dom('.ember-power-select-search-input').exists(); }); + + test('service quote refresh events run debounced quote lookup for the matching order', async function (assert) { + const calls = []; + const resource = { + servicable: true, + service_quote_uuid: 'stale-quote', + payloadCoordinates: ['1,1', '2,2'], + payload: { + payloadCoordinates: ['1,1', '2,2'], + }, + }; + const selectedRate = { id: 'rate-1' }; + + class ServiceRateActionsStub extends Service { + getServiceQuotes = { + perform(serviceRate, order) { + calls.push({ serviceRate, order }); + return Promise.resolve([{ uuid: 'fresh-quote' }]); + }, + }; + } + + this.owner.register('service:service-rate-actions', ServiceRateActionsStub); + + const orderCreation = this.owner.lookup('service:order-creation'); + const component = new OrderFormServiceRateComponent(this.owner, { resource }); + component.selectedRate = selectedRate; + + orderCreation.requestServiceQuoteRefresh('entity.added', { id: 'other-order' }); + orderCreation.requestServiceQuoteRefresh('entity.added', resource); + + await waitUntil(() => calls.length === 1, { timeout: 1000 }); + + assert.strictEqual(calls.length, 1, 'refreshes once after debounce'); + assert.strictEqual(calls[0].serviceRate, selectedRate); + assert.strictEqual(calls[0].order, resource); + assert.strictEqual(resource.service_quote_uuid, null, 'clears stale selected quote'); + + component.willDestroy(); + }); + + test('service quote refresh events are ignored until a rate is selected', async function (assert) { + const calls = []; + const resource = { + servicable: true, + payloadCoordinates: ['1,1', '2,2'], + payload: { + payloadCoordinates: ['1,1', '2,2'], + }, + }; + + class ServiceRateActionsStub extends Service { + getServiceQuotes = { + perform(serviceRate, order) { + calls.push({ serviceRate, order }); + return Promise.resolve([]); + }, + }; + } + + this.owner.register('service:service-rate-actions', ServiceRateActionsStub); + + const orderCreation = this.owner.lookup('service:order-creation'); + const component = new OrderFormServiceRateComponent(this.owner, { resource }); + + orderCreation.requestServiceQuoteRefresh('entity.added', resource); + + await new Promise((resolve) => setTimeout(resolve, 600)); + + assert.strictEqual(calls.length, 0); + + component.willDestroy(); + }); }); diff --git a/tests/unit/services/order-creation-test.js b/tests/unit/services/order-creation-test.js index 2932e246..d26d19dd 100644 --- a/tests/unit/services/order-creation-test.js +++ b/tests/unit/services/order-creation-test.js @@ -1,12 +1,28 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +import { SERVICE_QUOTE_REFRESH_REQUESTED } from '@fleetbase/fleetops-engine/services/order-creation'; module('Unit | Service | order-creation', function (hooks) { setupTest(hooks); - // TODO: Replace this with your real tests. test('it exists', function (assert) { let service = this.owner.lookup('service:order-creation'); assert.ok(service); }); + + test('requestServiceQuoteRefresh triggers an event payload', function (assert) { + assert.expect(3); + + const service = this.owner.lookup('service:order-creation'); + const order = { id: 'order-1' }; + + service.on(SERVICE_QUOTE_REFRESH_REQUESTED, (event) => { + assert.strictEqual(event.reason, 'entity.added'); + assert.strictEqual(event.order, order); + }); + + service.requestServiceQuoteRefresh('entity.added', order); + + assert.ok(true, 'event was triggered'); + }); }); From 99b7eeab5324c8fe41d66f587c50554f1892a701 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 17 Jun 2026 12:22:02 +0800 Subject: [PATCH 05/20] Add service quote refresh loading state --- addon/components/order/form/service-rate.hbs | 13 ++- addon/components/order/form/service-rate.js | 38 ++++++-- .../order/form/service-rate-test.js | 91 +++++++++++++++++++ translations/en-us.yaml | 1 + 4 files changed, 134 insertions(+), 9 deletions(-) diff --git a/addon/components/order/form/service-rate.hbs b/addon/components/order/form/service-rate.hbs index d023e05f..3e610ef2 100644 --- a/addon/components/order/form/service-rate.hbs +++ b/addon/components/order/form/service-rate.hbs @@ -36,18 +36,27 @@