@fleetbase/fleetops-engine 0.6.60 → 0.6.62
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/RELEASE.md +21 -17
- package/addon/components/ai/route-preview-map.js +1 -5
- package/addon/components/customer/orders.js +2 -1
- package/addon/components/driver/card.hbs +49 -0
- package/addon/components/driver/card.js +30 -0
- package/addon/components/map/leaflet-live-map.js +4 -19
- package/addon/components/modals/place-details.hbs +1 -1
- package/addon/components/modals/point-map.hbs +1 -1
- package/addon/components/orchestrator-workbench.js +2 -1
- package/addon/components/order/form/route.js +3 -0
- package/addon/components/order-tracking-lookup.js +2 -1
- package/addon/components/place/details.hbs +1 -1
- package/addon/components/positions-replay.js +4 -1
- package/addon/components/service-area/details.hbs +1 -1
- package/addon/components/widget/live-fleet.js +7 -7
- package/addon/components/zone/details.hbs +1 -1
- package/addon/controllers/management/drivers/index.js +22 -1
- package/addon/controllers/settings/map.js +16 -0
- package/addon/helpers/leaflet-tile-url.js +19 -0
- package/addon/services/map-settings.js +26 -1
- package/addon/templates/management/drivers/index.hbs +67 -33
- package/addon/templates/settings/map.hbs +10 -0
- package/addon/utils/leaflet-tile-url.js +31 -0
- package/addon/utils/prepare-place-for-save.js +13 -0
- package/app/components/driver/card.js +1 -0
- package/app/helpers/leaflet-tile-url.js +1 -0
- package/app/utils/leaflet-tile-url.js +1 -0
- package/composer.json +1 -1
- package/extension.json +1 -1
- package/index.js +11 -0
- package/package.json +2 -1
- package/server/migrations/2026_09_05_000001_add_unique_indexes_to_fleet_membership_pivots.php +129 -0
- package/server/src/Exceptions/PublicRelationNotFoundException.php +47 -0
- package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesFleetOpsApiResources.php +42 -7
- package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesPublicExpansions.php +187 -0
- package/server/src/Http/Controllers/Api/v1/DriverController.php +312 -59
- package/server/src/Http/Controllers/Api/v1/EntityController.php +1 -1
- package/server/src/Http/Controllers/Api/v1/FleetController.php +370 -33
- package/server/src/Http/Controllers/Api/v1/FuelReportController.php +1 -1
- package/server/src/Http/Controllers/Api/v1/IssueController.php +1 -1
- package/server/src/Http/Controllers/Api/v1/ManifestController.php +250 -0
- package/server/src/Http/Controllers/Api/v1/OrderController.php +1 -1
- package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +1 -1
- package/server/src/Http/Controllers/Api/v1/TrackingStatusController.php +1 -1
- package/server/src/Http/Controllers/Api/v1/VehicleController.php +139 -34
- package/server/src/Http/Controllers/Api/v1/ZoneController.php +1 -1
- package/server/src/Http/Controllers/Internal/v1/SettingController.php +22 -0
- package/server/src/Http/Filter/Concerns/ResolvesPublicRelationUuids.php +54 -0
- package/server/src/Http/Filter/DriverFilter.php +71 -15
- package/server/src/Http/Filter/FleetFilter.php +32 -33
- package/server/src/Http/Filter/FuelReportFilter.php +29 -0
- package/server/src/Http/Filter/IssueFilter.php +29 -0
- package/server/src/Http/Filter/VehicleFilter.php +57 -19
- package/server/src/Http/Requests/Concerns/ScopesPublicRelationRules.php +48 -0
- package/server/src/Http/Requests/CreateDriverRequest.php +100 -9
- package/server/src/Http/Requests/CreateFleetRequest.php +30 -2
- package/server/src/Http/Requests/CreateServiceRateRequest.php +7 -2
- package/server/src/Http/Requests/CreateVehicleRequest.php +150 -26
- package/server/src/Http/Resources/v1/Concerns/ResolvesPublicRelationFields.php +110 -0
- package/server/src/Http/Resources/v1/Driver.php +22 -2
- package/server/src/Http/Resources/v1/Fleet.php +38 -25
- package/server/src/Http/Resources/v1/Manifest.php +52 -0
- package/server/src/Http/Resources/v1/ManifestStop.php +47 -0
- package/server/src/Http/Resources/v1/OrderConfig.php +51 -4
- package/server/src/Http/Resources/v1/Vehicle.php +46 -4
- package/server/src/Models/Driver.php +2 -2
- package/server/src/Models/Place.php +1 -1
- package/server/src/routes.php +29 -0
- package/server/tests/ApiDriverControllerContractsTest.php +317 -13
- package/server/tests/ApiDriverPasswordContractsTest.php +457 -0
- package/server/tests/ApiEntityControllerContractsTest.php +1 -1
- package/server/tests/ApiEquipmentControllerContractsTest.php +2 -2
- package/server/tests/ApiFleetControllerContractsTest.php +354 -21
- package/server/tests/ApiFuelReportControllerContractsTest.php +1 -1
- package/server/tests/ApiIssueControllerContractsTest.php +1 -1
- package/server/tests/ApiManifestControllerContractsTest.php +599 -0
- package/server/tests/ApiOrderControllerContractsTest.php +1 -1
- package/server/tests/ApiPartControllerContractsTest.php +2 -2
- package/server/tests/ApiServiceAreaZoneControllerContractsTest.php +2 -2
- package/server/tests/ApiTrackingStatusControllerContractsTest.php +1 -1
- package/server/tests/ApiVehicleControllerContractsTest.php +210 -8
- package/server/tests/CompactResourceSerializationTest.php +13 -7
- package/server/tests/ControllerFilterContractsTest.php +132 -13
- package/server/tests/ControllerHelperContractsTest.php +6 -3
- package/server/tests/DriverFilterExecutionTest.php +130 -6
- package/server/tests/ExceptionContractsTest.php +15 -0
- package/server/tests/Feature/Http/Api/DeviceControllerContractsTest.php +1 -1
- package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +61 -0
- package/server/tests/Feature/Http/Api/FleetMembershipMigrationTest.php +140 -0
- package/server/tests/Feature/Http/Api/FleetMembershipTest.php +333 -0
- package/server/tests/Feature/Http/Api/FleetPublicContractTest.php +439 -0
- package/server/tests/Feature/Http/Api/FuelTransactionControllerContractsTest.php +2 -2
- package/server/tests/Feature/Http/Api/NullRelationshipInputTest.php +191 -0
- package/server/tests/Feature/Http/Api/PublicIdentifierFilterTest.php +293 -0
- package/server/tests/Feature/Http/Api/SmallApiControllerHelpersTest.php +7 -1
- package/server/tests/Feature/Http/Api/VehicleControllerTrackingTest.php +3 -1
- package/server/tests/Feature/Http/Api/VehiclePublicContractTest.php +454 -0
- package/server/tests/Feature/Http/Api/WorkOrderControllerContractsTest.php +1 -1
- package/server/tests/Feature/Http/Internal/PlaceControllerTest.php +45 -0
- package/server/tests/FleetHierarchyResourceTest.php +56 -8
- package/server/tests/RequestContractsTest.php +105 -7
- package/server/tests/SettingControllerContractsTest.php +33 -0
- package/server/tests/Unit/Http/Filter/FuelReportFilterTest.php +135 -0
- package/server/tests/Unit/Http/Filter/IssueFilterTest.php +43 -0
- package/server/tests/Unit/Http/Resources/FleetResourceTest.php +50 -6
- package/server/tests/Unit/Http/Resources/ManifestResourceTest.php +129 -0
- package/server/tests/Unit/Http/Resources/OrderConfigResourceTest.php +91 -0
- package/server/tests/VehicleControllerHelperContractsTest.php +1 -1
- package/translations/en-us.yaml +4 -0
package/RELEASE.md
CHANGED
|
@@ -1,34 +1,38 @@
|
|
|
1
|
-
> v0.6.
|
|
1
|
+
> v0.6.62 ~ "The public Fleet, Vehicle and Driver APIs now expose what the records actually hold"
|
|
2
2
|
|
|
3
3
|
---
|
|
4
4
|
## Highlights
|
|
5
|
-
|
|
5
|
+
The public v1 API exposed a small subset of what the Fleet, Vehicle and Driver records can hold, and the gaps were silent: a caller sending a field the controller did not copy received a `200` and a response body that looked correct while the value was discarded. This release closes that gap, adds fleet hierarchies and fleet membership to the public API, and makes it possible to record a driver who has no email address or phone number.
|
|
6
6
|
|
|
7
7
|
---
|
|
8
|
-
##
|
|
9
|
-
- **
|
|
10
|
-
-
|
|
8
|
+
## Features
|
|
9
|
+
- **Fleet create and update accept every safe field.** `name`, `color`, `task`, `status`, and the `service_area`, `zone`, `vendor` and `parent_fleet` relationships. Only `name` and `service_area` were reachable before, so a fleet hierarchy could not be built through the API at all. `"parent_fleet": null` clears a parent; a fleet may not be its own parent, nor sit beneath one of its own descendants.
|
|
10
|
+
- **Fleet membership has public endpoints.** `POST` and `DELETE` on `/v1/fleets/{fleet}/vehicles/{vehicle}` and `/v1/fleets/{fleet}/drivers/{driver}`, all four taking public IDs and sharing one response shape. Assignment is idempotent and restores a soft-deleted membership rather than duplicating it; removal is a safe no-op and touches only the pivot, never the driver, the vehicle, the driver's current vehicle, or any other fleet.
|
|
11
|
+
- **The Vehicle contract covers the whole record.** The input projection accepted 21 of the model's 99 fields; it now accepts all 90 safe ones — identity, odometer and measurement, body, capacity and dimensions, lifecycle and financing, regulatory and engine specifications, structured `specs`/`details`/`meta`, and orchestrator constraints — each with type-appropriate validation. `vendor`, `category`, `warranty` and `photo` resolve from public IDs.
|
|
12
|
+
- **A driver can be recorded without credentials.** `email` and `phone` are optional on create; an operational record may legitimately have neither. Nothing is invented to fill the gap and no invitation is sent when there is nowhere to send one. Such a driver cannot sign in to Navigator until credentials are supplied.
|
|
13
|
+
- **Relationships are readable back as public IDs.** A caller that writes `parent_fleet: "fleet_abc"` can now read the assignment back. Asking for a relation through `?with=` still returns the nested object it always did, and internal console responses are unchanged.
|
|
11
14
|
|
|
12
15
|
---
|
|
13
16
|
## Bug Fixes
|
|
14
|
-
-
|
|
15
|
-
- **
|
|
16
|
-
-
|
|
17
|
-
- **
|
|
18
|
-
- **
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
-
-
|
|
22
|
-
-
|
|
17
|
+
- **`Driver::$fillable` listed `'meta,'`** — a trailing comma inside the string — so driver metadata was never mass assignable and never persisted through the public API.
|
|
18
|
+
- **Driver input was a blocklist, not an allowlist.** Anything nobody had thought to exclude reached `Driver::create()` intact, including `auth_token`, `user_uuid` and `company_uuid`, while `location`, `heading`, `altitude`, `speed` and `meta` were dropped on every write.
|
|
19
|
+
- **Driver photo upload wrote `photo_uuid` to `users`,** which has no such column. `User` guards mass assignment by fillable, so every photo uploaded through the public API was discarded without a word. It now writes `avatar_uuid`.
|
|
20
|
+
- **A partial vehicle update took the vehicle offline.** The create-time `online` default was applied on update too, so a plate correction or an odometer reading silently reset it.
|
|
21
|
+
- **Relationship filters could never match.** `?vendor=`, `?fleet=`, `?driver=` and the fleet hierarchy filters compared caller-supplied public IDs against uuid columns. `FleetFilter::query()` searched a `user` relation Fleet does not have, `DriverFilter::phone()` a `phone` relation that does not exist, and `FleetFilter::zone()` a `zone_uuid` column `zones` does not have — the last three raised rather than filtered.
|
|
22
|
+
- **`VehicleFilter` had no `internal_id` filter,** which is the lookup an importer keys on to decide whether a vehicle already exists.
|
|
23
|
+
- **Cross-company relationship IDs were accepted and silently dropped.** Relationship inputs were validated with unscoped `exists` rules, so another organization's public ID passed validation and then resolved to nothing. They are now scoped at validation and again at resolution, and a cross-company ID is answered exactly as a missing one — so a response cannot be used to probe another organization's data.
|
|
24
|
+
- **The Fleet resource ran four `count()` queries per fleet on every public request** and discarded the results, because `when()` evaluates a plain value argument eagerly.
|
|
25
|
+
- **The Fleet webhook payload gated `parent_fleet` on `serviceArea`,** so a subfleet with no service area never reported its parent.
|
|
23
26
|
|
|
24
27
|
---
|
|
25
28
|
## Testing
|
|
26
|
-
- Coverage
|
|
29
|
+
- Coverage held at 100% on all three metrics — 34670/34670 statements, 4428/4428 methods, 530/530 classes.
|
|
30
|
+
- New database-backed coverage of the fleet membership pivots: idempotent assignment, restore of a soft-deleted membership, repeated removal as a no-op, and preservation of the driver's vehicle and of unrelated fleet memberships.
|
|
31
|
+
- Data-driven parity tests prove every newly exposed Fleet, Vehicle and Driver field is accepted and persisted, and that tenancy, authentication and generated columns still are not.
|
|
27
32
|
|
|
28
33
|
---
|
|
29
34
|
## Continuous Integration
|
|
30
|
-
- The
|
|
31
|
-
- The contract run tests this branch's API code rather than the published package.
|
|
35
|
+
- The PHP, Ember and Postman contract workflows now run on pull requests targeting `release/v*`. `release.yml` learned that branch name in v0.6.61's follow-up, but the three check workflows did not — so a PR retargeted onto a release branch ran no checks at all.
|
|
32
36
|
|
|
33
37
|
---
|
|
34
38
|
## Need help?
|
|
@@ -168,11 +168,7 @@ export default class AiRoutePreviewMapComponent extends Component {
|
|
|
168
168
|
get tileUrl() {
|
|
169
169
|
const theme = document.body?.dataset?.theme;
|
|
170
170
|
|
|
171
|
-
|
|
172
|
-
return 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
return 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png';
|
|
171
|
+
return this.mapSettings.getLeafletTileUrl(theme === 'dark' ? 'dark' : 'light');
|
|
176
172
|
}
|
|
177
173
|
|
|
178
174
|
get emptyText() {
|
|
@@ -9,6 +9,7 @@ import { debug } from '@ember/debug';
|
|
|
9
9
|
import { task, timeout } from 'ember-concurrency';
|
|
10
10
|
import { Control as RoutingControl } from '@fleetbase/leaflet-routing-machine';
|
|
11
11
|
import engineService from '@fleetbase/ember-core/decorators/engine-service';
|
|
12
|
+
import { DEFAULT_LEAFLET_TILE_URL } from '../../utils/leaflet-tile-url';
|
|
12
13
|
import registerComponent from '@fleetbase/ember-core/utils/register-component';
|
|
13
14
|
import OrderProgressCardComponent from '../order-progress-card';
|
|
14
15
|
import DisplayPlaceComponent from '../display-place';
|
|
@@ -38,7 +39,7 @@ export default class CustomerOrdersComponent extends Component {
|
|
|
38
39
|
@tracked longitude;
|
|
39
40
|
@tracked route;
|
|
40
41
|
@tracked query;
|
|
41
|
-
@tracked tileSourceUrl =
|
|
42
|
+
@tracked tileSourceUrl = DEFAULT_LEAFLET_TILE_URL;
|
|
42
43
|
@tracked scheduledAt;
|
|
43
44
|
@tracked deliveryInstructions = {};
|
|
44
45
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<Layout::Resource::Card ...attributes as |Card|>
|
|
2
|
+
<Card.header class="{{@headerClass}}">
|
|
3
|
+
<RegistryYield @registry="fleet-ops:component:driver:card:header:start" as |RegistryComponent|>
|
|
4
|
+
<RegistryComponent @order={{@resource}} @resource={{@resource}} />
|
|
5
|
+
</RegistryYield>
|
|
6
|
+
<div class="flex items-center w-full min-w-0">
|
|
7
|
+
<div class="font-semibold flex-1 truncate" data-test-driver-name>{{or @resource.name @resource.display_name}}</div>
|
|
8
|
+
<FaIcon @icon="circle" @size="xs" class="ml-2 flex-none {{if @resource.online 'text-green-500' 'text-gray-400'}}" data-test-driver-online-indicator />
|
|
9
|
+
</div>
|
|
10
|
+
<div class="text-gray-400 dark:text-gray-500 text-sm w-full truncate">{{n-a @resource.phone @resource.public_id}}</div>
|
|
11
|
+
{{#if (has-block "header")}}
|
|
12
|
+
{{yield to="header"}}
|
|
13
|
+
{{/if}}
|
|
14
|
+
<RegistryYield @registry="fleet-ops:component:driver:card:header:end" as |RegistryComponent|>
|
|
15
|
+
<RegistryComponent @order={{@resource}} @resource={{@resource}} />
|
|
16
|
+
</RegistryYield>
|
|
17
|
+
</Card.header>
|
|
18
|
+
<Card.body class="{{@bodyClass}} h-40 overflow-hidden">
|
|
19
|
+
<Image src={{avatar-url @resource.photo_url}} @fallbackSrc={{this.fallbackImage}} class="block w-full h-full object-cover rounded-none" data-test-driver-photo />
|
|
20
|
+
{{#if (has-block "body")}}
|
|
21
|
+
{{yield to="body"}}
|
|
22
|
+
{{/if}}
|
|
23
|
+
</Card.body>
|
|
24
|
+
<Card.footer class={{@footerClass}}>
|
|
25
|
+
<RegistryYield @registry="fleet-ops:component:driver:card:footer:start" as |RegistryComponent|>
|
|
26
|
+
<RegistryComponent @order={{@resource}} @resource={{@resource}} />
|
|
27
|
+
</RegistryYield>
|
|
28
|
+
<div class="flex flex-row items-center justify-between w-full">
|
|
29
|
+
<Badge @status={{or this.statusValue "default"}} @size="xs" data-test-driver-status-badge>{{this.statusLabel}}</Badge>
|
|
30
|
+
<div class="flex flex-row items-center space-x-1">
|
|
31
|
+
<Button @icon="eye" @size="xs" @onClick={{fn this.driverActions.transition.view @resource}} />
|
|
32
|
+
<Button @icon="pencil" @size="xs" @onClick={{fn this.driverActions.transition.edit @resource}} />
|
|
33
|
+
<Button @icon="trash" @size="xs" @type="danger" @onClick={{fn this.driverActions.delete @resource}} />
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
<RegistryYield @registry="fleet-ops:component:driver:card:footer:end" as |RegistryComponent|>
|
|
37
|
+
<RegistryComponent @order={{@resource}} @resource={{@resource}} />
|
|
38
|
+
</RegistryYield>
|
|
39
|
+
{{#if (has-block "footer")}}
|
|
40
|
+
{{yield to="footer"}}
|
|
41
|
+
{{/if}}
|
|
42
|
+
<div class="mt-1.5 w-full">
|
|
43
|
+
<div class="text-gray-400 dark:text-gray-500 text-xs truncate">
|
|
44
|
+
<FaIcon @icon="car" @prefix="fas" class="mr-1" />{{n-a this.assignedVehicleLabel}}
|
|
45
|
+
</div>
|
|
46
|
+
<div class="text-gray-400 dark:text-gray-500 text-xs">Last Modified: {{@resource.updatedAt}}</div>
|
|
47
|
+
</div>
|
|
48
|
+
</Card.footer>
|
|
49
|
+
</Layout::Resource::Card>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import Component from '@glimmer/component';
|
|
2
|
+
import { inject as service } from '@ember/service';
|
|
3
|
+
import { get } from '@ember/object';
|
|
4
|
+
import config from 'ember-get-config';
|
|
5
|
+
|
|
6
|
+
export default class DriverCardComponent extends Component {
|
|
7
|
+
@service driverActions;
|
|
8
|
+
|
|
9
|
+
get resource() {
|
|
10
|
+
return this.args.resource;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
get fallbackImage() {
|
|
14
|
+
return config?.defaultValues?.driverImage;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
get statusValue() {
|
|
18
|
+
return get(this.resource, 'status') ?? '';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
get statusLabel() {
|
|
22
|
+
return this.statusValue || '-';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get assignedVehicleLabel() {
|
|
26
|
+
const driver = this.resource;
|
|
27
|
+
|
|
28
|
+
return get(driver, 'vehicle_assigned.display_name') ?? get(driver, 'vehicle.display_name') ?? get(driver, 'vehicle_name');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -48,7 +48,6 @@ export default class MapLeafletLiveMapComponent extends Component {
|
|
|
48
48
|
@tracked latitude = this.location.getLatitude();
|
|
49
49
|
@tracked longitude = this.location.getLongitude();
|
|
50
50
|
@tracked contextmenuItems = [];
|
|
51
|
-
@tracked tileUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png';
|
|
52
51
|
@tracked theme = 'light';
|
|
53
52
|
@tracked routes = [];
|
|
54
53
|
@tracked drivers = [];
|
|
@@ -57,6 +56,10 @@ export default class MapLeafletLiveMapComponent extends Component {
|
|
|
57
56
|
@tracked leafletPluginsReady = hasLeafletPluginsReady();
|
|
58
57
|
_viewportReloadLocks = new Set();
|
|
59
58
|
|
|
59
|
+
get tileUrl() {
|
|
60
|
+
return this.mapSettings.getLeafletTileUrl(this.theme);
|
|
61
|
+
}
|
|
62
|
+
|
|
60
63
|
constructor() {
|
|
61
64
|
super(...arguments);
|
|
62
65
|
|
|
@@ -888,22 +891,4 @@ export default class MapLeafletLiveMapComponent extends Component {
|
|
|
888
891
|
// Fallback to default Singapore longitude
|
|
889
892
|
return 103.8864;
|
|
890
893
|
}
|
|
891
|
-
|
|
892
|
-
#changeTileSource(source) {
|
|
893
|
-
switch (source) {
|
|
894
|
-
case 'dark':
|
|
895
|
-
this.theme = 'dark';
|
|
896
|
-
this.tileUrl = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
|
|
897
|
-
break;
|
|
898
|
-
case 'custom':
|
|
899
|
-
this.theme = 'custom';
|
|
900
|
-
this.tileUrl = source.startsWith('https://') ? source : 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png';
|
|
901
|
-
break;
|
|
902
|
-
case 'light':
|
|
903
|
-
default:
|
|
904
|
-
this.theme = 'light';
|
|
905
|
-
this.tileUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png';
|
|
906
|
-
break;
|
|
907
|
-
}
|
|
908
|
-
}
|
|
909
894
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<div class="flex">
|
|
4
4
|
<div class="w-48 mb-6 mr-6">
|
|
5
5
|
<LeafletMap class="w-full h-48 mb-2 rounded-md shadow-md" @lat={{@options.place.latitude}} @lng={{@options.place.longitude}} @zoom={{12}} as |layers|>
|
|
6
|
-
<layers.tile @url=
|
|
6
|
+
<layers.tile @url={{leaflet-tile-url}} />
|
|
7
7
|
|
|
8
8
|
<layers.marker @location={{@options.place.coordinates}} />
|
|
9
9
|
</LeafletMap>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<Modal::Default @modalIsOpened={{@modalIsOpened}} @options={{@options}} @confirm={{@onConfirm}} @decline={{@onDecline}}>
|
|
2
2
|
<div class="fleetbase-leaflet-map-container">
|
|
3
3
|
<LeafletMap class="map-height-base" @lat={{@options.latitude}} @lng={{@options.longitude}} @zoom={{12}} as |layers|>
|
|
4
|
-
<layers.tile @url=
|
|
4
|
+
<layers.tile @url={{leaflet-tile-url}} />
|
|
5
5
|
|
|
6
6
|
<layers.marker @icon={{@options.icon}} @location={{point-to-coordinates @options.location}} @riseOnHover={{true}} as |marker|>
|
|
7
7
|
<marker.popup @maxWidth="500" @minWidth="180">
|
|
@@ -36,6 +36,7 @@ export default class OrchestratorWorkbenchComponent extends Component {
|
|
|
36
36
|
@service modalsManager;
|
|
37
37
|
@service location;
|
|
38
38
|
@service mapManager;
|
|
39
|
+
@service mapSettings;
|
|
39
40
|
@service routeEngine;
|
|
40
41
|
@service('order-allocation') allocationService;
|
|
41
42
|
|
|
@@ -661,7 +662,7 @@ export default class OrchestratorWorkbenchComponent extends Component {
|
|
|
661
662
|
|
|
662
663
|
get tileSourceUrl() {
|
|
663
664
|
const isDark = document.documentElement.classList.contains('dark');
|
|
664
|
-
return isDark ? '
|
|
665
|
+
return this.mapSettings.getLeafletTileUrl(isDark ? 'dark' : 'light');
|
|
665
666
|
}
|
|
666
667
|
|
|
667
668
|
// ── Computed helpers ──────────────────────────────────────────────────────
|
|
@@ -5,6 +5,7 @@ import { action, get } from '@ember/object';
|
|
|
5
5
|
import { task } from 'ember-concurrency';
|
|
6
6
|
import { colorForId, routeColorForStatus, routeStyleForStatus } from '../../../utils/route-colors';
|
|
7
7
|
import { buildRoutePointMarkerPresentation, buildRoutePointsFromPayload, describeRoutePoint } from '../../../utils/route-visualization';
|
|
8
|
+
import preparePlaceForSave from '../../../utils/prepare-place-for-save';
|
|
8
9
|
|
|
9
10
|
const ORDER_ROUTE_PREVIEW_PADDING_BOTTOM_RIGHT = [420, 0];
|
|
10
11
|
const ORDER_ROUTE_PREVIEW_MAX_ZOOM_TWO_POINTS = 13;
|
|
@@ -153,6 +154,7 @@ export default class OrderFormRouteComponent extends Component {
|
|
|
153
154
|
@action setWaypointPlace(index, place) {
|
|
154
155
|
if (!this.args.resource.payload.waypoints[index]) return;
|
|
155
156
|
|
|
157
|
+
place = preparePlaceForSave(this.store, place);
|
|
156
158
|
this.args.resource.payload.waypoints[index].place = place;
|
|
157
159
|
this.args.resource.payload.waypoints[index]?.setProperties({
|
|
158
160
|
street1: place.street1,
|
|
@@ -186,6 +188,7 @@ export default class OrderFormRouteComponent extends Component {
|
|
|
186
188
|
}
|
|
187
189
|
|
|
188
190
|
@action setPayloadPlace(prop, place) {
|
|
191
|
+
place = preparePlaceForSave(this.store, place);
|
|
189
192
|
this.args.resource.payload[prop] = place;
|
|
190
193
|
this.previewRoute();
|
|
191
194
|
this.requestServiceQuoteRefresh(`route.${prop}.changed`);
|
|
@@ -10,6 +10,7 @@ import { task } from 'ember-concurrency';
|
|
|
10
10
|
import { OSRMv1, Control as RoutingControl } from '@fleetbase/leaflet-routing-machine';
|
|
11
11
|
import getRoutingHost from '@fleetbase/ember-core/utils/get-routing-host';
|
|
12
12
|
import engineService from '@fleetbase/ember-core/decorators/engine-service';
|
|
13
|
+
import { DEFAULT_LEAFLET_TILE_URL } from '../utils/leaflet-tile-url';
|
|
13
14
|
|
|
14
15
|
export default class OrderTrackingLookupComponent extends Component {
|
|
15
16
|
@service urlSearchParams;
|
|
@@ -28,7 +29,7 @@ export default class OrderTrackingLookupComponent extends Component {
|
|
|
28
29
|
@tracked latitude;
|
|
29
30
|
@tracked longitude;
|
|
30
31
|
@tracked route;
|
|
31
|
-
@tracked tileSourceUrl =
|
|
32
|
+
@tracked tileSourceUrl = DEFAULT_LEAFLET_TILE_URL;
|
|
32
33
|
|
|
33
34
|
constructor() {
|
|
34
35
|
super(...arguments);
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
@zoomControl={{false}}
|
|
76
76
|
as |layers|
|
|
77
77
|
>
|
|
78
|
-
<layers.tile @url=
|
|
78
|
+
<layers.tile @url={{leaflet-tile-url}} />
|
|
79
79
|
<layers.marker
|
|
80
80
|
@id={{@resource.id}}
|
|
81
81
|
@icon={{icon iconUrl=(or @resource.avatar_url "/engines-dist/images/building-marker.png") iconSize=(array 40 40)}}
|
|
@@ -15,6 +15,7 @@ export default class PositionsReplayComponent extends Component {
|
|
|
15
15
|
@service positionPlayback;
|
|
16
16
|
@service notifications;
|
|
17
17
|
@service location;
|
|
18
|
+
@service mapSettings;
|
|
18
19
|
|
|
19
20
|
/** Component ID */
|
|
20
21
|
id = guidFor(this);
|
|
@@ -29,7 +30,9 @@ export default class PositionsReplayComponent extends Component {
|
|
|
29
30
|
@tracked latitude = this.args.resource.latitude || this.location.getLatitude();
|
|
30
31
|
@tracked longitude = this.args.resource.longitude || this.location.getLongitude();
|
|
31
32
|
@tracked zoom = 14;
|
|
32
|
-
|
|
33
|
+
get tileUrl() {
|
|
34
|
+
return this.mapSettings.getLeafletTileUrl('light');
|
|
35
|
+
}
|
|
33
36
|
|
|
34
37
|
/** Computed properties - read state from service */
|
|
35
38
|
get isReplaying() {
|
|
@@ -8,15 +8,11 @@ import { task } from 'ember-concurrency';
|
|
|
8
8
|
import { renderCompleted, waitForInsertedAndSized } from '@fleetbase/ember-ui/utils/dom';
|
|
9
9
|
import LeafletTrackingMarkerComponent from '../leaflet-tracking-marker';
|
|
10
10
|
import ensureLeafletDrawEditNamespace from '../../utils/leaflet-draw-namespace-guard';
|
|
11
|
+
import { DEFAULT_LEAFLET_TILE_ATTRIBUTION } from '../../utils/leaflet-tile-url';
|
|
11
12
|
|
|
12
13
|
const DEFAULT_CENTER = [1.31, 103.85];
|
|
13
14
|
const DEFAULT_ZOOM = 11;
|
|
14
|
-
|
|
15
|
-
// dashboard styling consistent with the operational live map. Falls back gracefully
|
|
16
|
-
// in dark mode via the next-leaflet-container-map theming.
|
|
17
|
-
const TILE_URL = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png';
|
|
18
|
-
const TILE_URL_DARK = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png';
|
|
19
|
-
const TILE_ATTRIBUTION = '© OpenStreetMap contributors © CARTO';
|
|
15
|
+
const TILE_ATTRIBUTION = DEFAULT_LEAFLET_TILE_ATTRIBUTION;
|
|
20
16
|
const RECONCILE_INTERVAL_MS = 5 * 60_000;
|
|
21
17
|
|
|
22
18
|
/**
|
|
@@ -30,6 +26,7 @@ export default class WidgetLiveFleetComponent extends Component {
|
|
|
30
26
|
@service fetch;
|
|
31
27
|
@service socket;
|
|
32
28
|
@service currentUser;
|
|
29
|
+
@service mapSettings;
|
|
33
30
|
|
|
34
31
|
@tracked data = null;
|
|
35
32
|
@tracked error = null;
|
|
@@ -38,7 +35,7 @@ export default class WidgetLiveFleetComponent extends Component {
|
|
|
38
35
|
|
|
39
36
|
get tileUrl() {
|
|
40
37
|
const isDark = typeof document !== 'undefined' && document.documentElement?.dataset?.theme === 'dark';
|
|
41
|
-
return isDark ?
|
|
38
|
+
return this.mapSettings.getLeafletTileUrl(isDark ? 'dark' : 'light');
|
|
42
39
|
}
|
|
43
40
|
|
|
44
41
|
socketChannel = null;
|
|
@@ -78,6 +75,9 @@ export default class WidgetLiveFleetComponent extends Component {
|
|
|
78
75
|
this.#ensureTrackingMarkerRegistered();
|
|
79
76
|
this.#patchLeafletDrawInitHook();
|
|
80
77
|
this.#resetMapDeferred();
|
|
78
|
+
// Widgets can render before the fleetops engine boots, so make sure the
|
|
79
|
+
// company's map settings (custom tile provider URL) are loaded.
|
|
80
|
+
this.mapSettings.load();
|
|
81
81
|
this.load.perform();
|
|
82
82
|
this.subscribe();
|
|
83
83
|
this.reconcileTimer = setInterval(() => this.load.perform(), RECONCILE_INTERVAL_MS);
|
|
@@ -12,6 +12,7 @@ export default class ManagementDriversIndexController extends Controller {
|
|
|
12
12
|
@service notifications;
|
|
13
13
|
@service tableContext;
|
|
14
14
|
@service intl;
|
|
15
|
+
@service appCache;
|
|
15
16
|
|
|
16
17
|
/** query params */
|
|
17
18
|
@tracked queryParams = [
|
|
@@ -45,7 +46,7 @@ export default class ManagementDriversIndexController extends Controller {
|
|
|
45
46
|
@tracked status;
|
|
46
47
|
@tracked created_at;
|
|
47
48
|
@tracked updated_at;
|
|
48
|
-
@tracked layout = 'table';
|
|
49
|
+
@tracked layout = this.appCache.get('fleetops:drivers:layout', 'table');
|
|
49
50
|
@tracked table;
|
|
50
51
|
|
|
51
52
|
hasAssignedVehicle(driver) {
|
|
@@ -71,6 +72,25 @@ export default class ManagementDriversIndexController extends Controller {
|
|
|
71
72
|
/** action buttons */
|
|
72
73
|
get actionButtons() {
|
|
73
74
|
return [
|
|
75
|
+
{
|
|
76
|
+
component: 'dropdown-button',
|
|
77
|
+
icon: 'display',
|
|
78
|
+
size: 'xs',
|
|
79
|
+
items: [
|
|
80
|
+
{
|
|
81
|
+
label: this.intl.t('common.table-view'),
|
|
82
|
+
icon: 'table-list',
|
|
83
|
+
onClick: () => this.changeLayout('table'),
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
label: this.intl.t('common.grid-view'),
|
|
87
|
+
icon: 'grip',
|
|
88
|
+
onClick: () => this.changeLayout('grid'),
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
renderInPlace: true,
|
|
92
|
+
helpText: 'Change the layout',
|
|
93
|
+
},
|
|
74
94
|
{
|
|
75
95
|
icon: 'refresh',
|
|
76
96
|
onClick: this.driverActions.refresh,
|
|
@@ -377,6 +397,7 @@ export default class ManagementDriversIndexController extends Controller {
|
|
|
377
397
|
|
|
378
398
|
@action changeLayout(layout) {
|
|
379
399
|
this.layout = layout;
|
|
400
|
+
this.appCache.set('fleetops:drivers:layout', layout);
|
|
380
401
|
}
|
|
381
402
|
|
|
382
403
|
@action createIssue(driver) {
|
|
@@ -33,6 +33,8 @@ export default class SettingsMapController extends Controller {
|
|
|
33
33
|
* @var {String}
|
|
34
34
|
*/
|
|
35
35
|
@tracked mapProvider = 'leaflet';
|
|
36
|
+
@tracked leafletTileUrl = '';
|
|
37
|
+
@tracked leafletDarkTileUrl = '';
|
|
36
38
|
@tracked googleMapsMapType = 'roadmap';
|
|
37
39
|
@tracked showGoogleMapsTrafficLayer = false;
|
|
38
40
|
@tracked showGoogleMapsTransitLayer = false;
|
|
@@ -62,6 +64,16 @@ export default class SettingsMapController extends Controller {
|
|
|
62
64
|
return this.mapProvider === 'google';
|
|
63
65
|
}
|
|
64
66
|
|
|
67
|
+
/**
|
|
68
|
+
* True when the user has selected Leaflet as the provider.
|
|
69
|
+
*
|
|
70
|
+
* @memberof SettingsMapController
|
|
71
|
+
* @return {Boolean}
|
|
72
|
+
*/
|
|
73
|
+
get isLeafletSelected() {
|
|
74
|
+
return this.mapProvider === 'leaflet';
|
|
75
|
+
}
|
|
76
|
+
|
|
65
77
|
// ─── Actions ───────────────────────────────────────────────────────────────
|
|
66
78
|
|
|
67
79
|
/**
|
|
@@ -77,6 +89,8 @@ export default class SettingsMapController extends Controller {
|
|
|
77
89
|
@task *saveSettings() {
|
|
78
90
|
const settings = {
|
|
79
91
|
mapProvider: this.mapProvider,
|
|
92
|
+
leafletTileUrl: (this.leafletTileUrl ?? '').trim(),
|
|
93
|
+
leafletDarkTileUrl: (this.leafletDarkTileUrl ?? '').trim(),
|
|
80
94
|
googleMapsMapType: this.googleMapsMapType,
|
|
81
95
|
showGoogleMapsTrafficLayer: this.showGoogleMapsTrafficLayer,
|
|
82
96
|
showGoogleMapsTransitLayer: this.showGoogleMapsTransitLayer,
|
|
@@ -105,6 +119,8 @@ export default class SettingsMapController extends Controller {
|
|
|
105
119
|
const response = yield this.mapSettings.load({ force: true });
|
|
106
120
|
|
|
107
121
|
this.mapProvider = response?.mapProvider ?? 'leaflet';
|
|
122
|
+
this.leafletTileUrl = response?.leafletTileUrl ?? '';
|
|
123
|
+
this.leafletDarkTileUrl = response?.leafletDarkTileUrl ?? '';
|
|
108
124
|
this.googleMapsMapType = response?.googleMapsMapType ?? 'roadmap';
|
|
109
125
|
this.showGoogleMapsTrafficLayer = Boolean(response?.showGoogleMapsTrafficLayer);
|
|
110
126
|
this.showGoogleMapsTransitLayer = Boolean(response?.showGoogleMapsTransitLayer);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import Helper from '@ember/component/helper';
|
|
2
|
+
import { inject as service } from '@ember/service';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolves the Leaflet tile URL from Fleet-Ops map settings.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* <layers.tile @url={{leaflet-tile-url}} />
|
|
9
|
+
* <layers.tile @url={{leaflet-tile-url theme="dark"}} />
|
|
10
|
+
*
|
|
11
|
+
* Recomputes automatically when map settings load or change.
|
|
12
|
+
*/
|
|
13
|
+
export default class LeafletTileUrlHelper extends Helper {
|
|
14
|
+
@service mapSettings;
|
|
15
|
+
|
|
16
|
+
compute(_params, { theme = 'light' } = {}) {
|
|
17
|
+
return this.mapSettings.getLeafletTileUrl(theme);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import Service from '@ember/service';
|
|
2
2
|
import { tracked } from '@glimmer/tracking';
|
|
3
3
|
import { inject as service } from '@ember/service';
|
|
4
|
+
import { getLeafletTileUrl } from '../utils/leaflet-tile-url';
|
|
4
5
|
|
|
5
6
|
const DEFAULT_SETTINGS = {
|
|
6
7
|
mapProvider: 'leaflet',
|
|
8
|
+
leafletTileUrl: '',
|
|
9
|
+
leafletDarkTileUrl: '',
|
|
7
10
|
googleMapsApiKey: '',
|
|
8
11
|
googleMapsMapId: '',
|
|
9
12
|
googleMapsMapType: 'roadmap',
|
|
@@ -15,7 +18,10 @@ export default class MapSettingsService extends Service {
|
|
|
15
18
|
@service fetch;
|
|
16
19
|
@tracked settings = { ...DEFAULT_SETTINGS };
|
|
17
20
|
@tracked isLoaded = false;
|
|
18
|
-
|
|
21
|
+
// Internal request deduplication state is not rendered. Keeping this tracked
|
|
22
|
+
// makes load() read and then dirty the same tag when called during component
|
|
23
|
+
// construction, which Ember correctly rejects as a render-time mutation.
|
|
24
|
+
loadPromise = null;
|
|
19
25
|
|
|
20
26
|
get mapProvider() {
|
|
21
27
|
return this.settings.mapProvider ?? 'leaflet';
|
|
@@ -45,6 +51,25 @@ export default class MapSettingsService extends Service {
|
|
|
45
51
|
return this.mapProvider === 'google';
|
|
46
52
|
}
|
|
47
53
|
|
|
54
|
+
get leafletTileUrl() {
|
|
55
|
+
return this.getLeafletTileUrl('light');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get leafletDarkTileUrl() {
|
|
59
|
+
return this.getLeafletTileUrl('dark');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve the Leaflet tile URL for a theme, preferring the company's
|
|
64
|
+
* configured custom tile provider and falling back to the keyless default.
|
|
65
|
+
*
|
|
66
|
+
* @param {String} theme 'light' or 'dark'
|
|
67
|
+
* @return {String}
|
|
68
|
+
*/
|
|
69
|
+
getLeafletTileUrl(theme = 'light') {
|
|
70
|
+
return getLeafletTileUrl(this.settings, theme);
|
|
71
|
+
}
|
|
72
|
+
|
|
48
73
|
async load({ force = false } = {}) {
|
|
49
74
|
if (!force && this.isLoaded) {
|
|
50
75
|
return this.settings;
|