From 750a299ca119faca947904f47f665550b678cc31 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 19 Jun 2026 18:32:19 +0800 Subject: [PATCH 1/2] v0.1.40 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9357cca..4e2e79b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fleetbase/fleetops-data", - "version": "0.1.39", + "version": "0.1.40", "description": "Fleetbase Fleet-Ops based models, serializers, transforms, adapters and GeoJson utility functions.", "keywords": [ "fleetbase-data", From b95a8e071b30197bdc9fea1ae5c1a08fb3f85de2 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 19 Jun 2026 18:41:13 +0800 Subject: [PATCH 2/2] Add attachable device models --- addon/models/attachable-asset.js | 38 +++++++ addon/models/attachable-vehicle.js | 50 +++++++++ addon/models/attachable.js | 60 ++++++++++ addon/models/device-event.js | 9 ++ addon/models/device.js | 1 + addon/serializers/device.js | 96 ++++++++++++++++ app/models/attachable-asset.js | 1 + app/models/attachable-vehicle.js | 1 + app/models/attachable.js | 1 + tests/unit/models/attachable-asset-test.js | 13 +++ tests/unit/models/attachable-test.js | 13 +++ tests/unit/models/attachable-vehicle-test.js | 13 +++ tests/unit/models/device-event-test.js | 26 ++++- tests/unit/models/device-test.js | 11 +- tests/unit/serializers/device-test.js | 109 ++++++++++++++++++- 15 files changed, 439 insertions(+), 3 deletions(-) create mode 100644 addon/models/attachable-asset.js create mode 100644 addon/models/attachable-vehicle.js create mode 100644 addon/models/attachable.js create mode 100644 app/models/attachable-asset.js create mode 100644 app/models/attachable-vehicle.js create mode 100644 app/models/attachable.js create mode 100644 tests/unit/models/attachable-asset-test.js create mode 100644 tests/unit/models/attachable-test.js create mode 100644 tests/unit/models/attachable-vehicle-test.js diff --git a/addon/models/attachable-asset.js b/addon/models/attachable-asset.js new file mode 100644 index 0000000..4825a44 --- /dev/null +++ b/addon/models/attachable-asset.js @@ -0,0 +1,38 @@ +import AttachableModel from './attachable'; +import { attr } from '@ember-data/model'; + +/** + * Concrete polymorphic model for an Asset attached to a device. + */ +export default class AttachableAssetModel extends AttachableModel { + /** @ids */ + @attr('string') category_uuid; + @attr('string') vendor_uuid; + @attr('string') warranty_uuid; + @attr('string') telematic_uuid; + @attr('string') current_place_uuid; + @attr('string') photo_uuid; + + /** @attributes */ + @attr('string') description; + @attr('string') code; + @attr('string') category_name; + @attr('string') vendor_name; + @attr('string') warranty_name; + @attr('string') current_location; + @attr('string') usage_type; + @attr('string') vin; + @attr('string') plate_number; + @attr('string') make; + @attr('string') model; + @attr('string') year; + @attr('string') color; + @attr('string') serial_number; + @attr('string') measurement_system; + @attr('string') odometer; + @attr('string') odometer_unit; + @attr('string') notes; + @attr('raw') capacity; + @attr('raw') specs; + @attr('raw') attributes; +} diff --git a/addon/models/attachable-vehicle.js b/addon/models/attachable-vehicle.js new file mode 100644 index 0000000..60daeb9 --- /dev/null +++ b/addon/models/attachable-vehicle.js @@ -0,0 +1,50 @@ +import AttachableModel from './attachable'; +import { attr } from '@ember-data/model'; +import { get, computed } from '@ember/object'; +import config from 'ember-get-config'; + +/** + * Concrete polymorphic model for a Vehicle attached to a device. + */ +export default class AttachableVehicleModel extends AttachableModel { + /** @ids */ + @attr('string') internal_id; + @attr('string') photo_uuid; + @attr('string') vendor_uuid; + @attr('string') category_uuid; + @attr('string') warranty_uuid; + @attr('string') telematic_uuid; + + /** @attributes */ + @attr('string', { + defaultValue: get(config, 'defaultValues.vehicleImage'), + }) + photo_url; + + @attr('string', { + defaultValue: get(config, 'defaultValues.vehicleAvatar'), + }) + avatar_url; + + @attr('string') avatar_value; + @attr('string') driver_name; + @attr('string') vendor_name; + @attr('string') make; + @attr('string') model; + @attr('string') model_type; + @attr('string') year; + @attr('string') trim; + @attr('string') color; + @attr('string') plate_number; + @attr('string') call_sign; + @attr('string') serial_number; + @attr('string') vin; + @attr('string') measurement_system; + @attr('string') odometer; + @attr('string') odometer_unit; + @attr('number') engine_hours; + + @computed('year', 'make', 'model') get yearMakeModel() { + return [this.year, this.make, this.model].filter(Boolean).join(' '); + } +} diff --git a/addon/models/attachable.js b/addon/models/attachable.js new file mode 100644 index 0000000..6e7b2c2 --- /dev/null +++ b/addon/models/attachable.js @@ -0,0 +1,60 @@ +import Model, { attr } from '@ember-data/model'; +import { computed } from '@ember/object'; +import { format as formatDate, isValid as isValidDate, formatDistanceToNow } from 'date-fns'; + +/** + * Abstract base model for resources a device can be attached to. + * Concrete types: attachable-vehicle, attachable-asset. + */ +export default class AttachableModel extends Model { + /** @ids */ + @attr('string') uuid; + @attr('string') public_id; + @attr('string') company_uuid; + + /** @attributes */ + @attr('string') name; + @attr('string') display_name; + @attr('string') type; + @attr('string') status; + @attr('string') photo_url; + @attr('string') avatar_url; + @attr('boolean') online; + @attr('boolean') is_online; + @attr('point') location; + @attr('string') speed; + @attr('string') heading; + @attr('string') altitude; + @attr('raw') meta; + + /** @dates */ + @attr('date') deleted_at; + @attr('date') created_at; + @attr('date') updated_at; + + /** @computed */ + @computed('name', 'display_name', 'public_id') get displayName() { + return this.display_name || this.name || this.public_id; + } + + @computed('updated_at') get updatedAgo() { + if (!isValidDate(this.updated_at)) { + return null; + } + return formatDistanceToNow(this.updated_at); + } + + @computed('updated_at') get updatedAt() { + if (!isValidDate(this.updated_at)) { + return null; + } + return formatDate(this.updated_at, 'yyyy-MM-dd HH:mm'); + } + + @computed('updated_at') get updatedAtShort() { + if (!isValidDate(this.updated_at)) { + return null; + } + return formatDate(this.updated_at, 'dd, MMM'); + } +} diff --git a/addon/models/device-event.js b/addon/models/device-event.js index c3474bb..2906106 100644 --- a/addon/models/device-event.js +++ b/addon/models/device-event.js @@ -26,6 +26,15 @@ export default class DeviceEventModel extends Model { @attr('string') comment; @attr('string') slug; @attr('string') device_name; + @attr('string') device_id; + @attr('string') device_imei; + @attr('string') device_serial_number; + @attr('string') device_connection_status; + @attr('string') device_status; + @attr('string') device_photo_url; + @attr('string') telematic_uuid; + @attr('string') telematic_name; + @attr('object') provider_descriptor; /** @dates */ @attr('date') occurred_at; diff --git a/addon/models/device.js b/addon/models/device.js index 7b93af3..ee75da1 100644 --- a/addon/models/device.js +++ b/addon/models/device.js @@ -16,6 +16,7 @@ export default class DeviceModel extends Model { /** @relationships */ @belongsTo('telematic', { async: false }) telematic; @belongsTo('warranty', { async: false }) warranty; + @belongsTo('attachable', { polymorphic: true, async: false }) attachable; @hasMany('device-event', { async: false }) events; @hasMany('sensor', { async: false }) sensors; @hasMany('custom-field-value', { async: false }) custom_field_values; diff --git a/addon/serializers/device.js b/addon/serializers/device.js index 04ce467..3672502 100644 --- a/addon/serializers/device.js +++ b/addon/serializers/device.js @@ -1,5 +1,6 @@ import ApplicationSerializer from '@fleetbase/ember-core/serializers/application'; import { EmbeddedRecordsMixin } from '@ember-data/serializer/rest'; +import { isBlank } from '@ember/utils'; export default class DeviceSerializer extends ApplicationSerializer.extend(EmbeddedRecordsMixin) { /** @@ -11,7 +12,102 @@ export default class DeviceSerializer extends ApplicationSerializer.extend(Embed return { telematic: { embedded: 'always' }, warranty: { embedded: 'always' }, + attachable: { embedded: 'always' }, custom_field_values: { embedded: 'always' }, }; } + + normalize(model, hash, prop) { + let attachableDomainType; + + if (hash?.attachable) { + attachableDomainType = hash.attachable.type; + hash.attachable.type = this.attachableModelNameFromType(hash.attachable_type); + } + + const normalized = super.normalize(model, hash, prop); + + if (this.shouldRestoreAttachableDomainType(attachableDomainType)) { + this.restoreAttachableDomainType(normalized, attachableDomainType); + } + + return normalized; + } + + serializePolymorphicType(snapshot, json, relationship) { + let key = relationship.key; + + if (key !== 'attachable') { + if (typeof super.serializePolymorphicType === 'function') { + return super.serializePolymorphicType(...arguments); + } + return; + } + + const belongsTo = snapshot.belongsTo(key); + const isPolymorphicTypeBlank = isBlank(snapshot.attr(`${key}_type`)); + + if (!isPolymorphicTypeBlank) { + return; + } + + key = this.keyForAttribute ? this.keyForAttribute(key, 'serialize') : key; + + if (!belongsTo) { + json[`${key}_type`] = null; + return; + } + + let type = belongsTo.modelName; + + if (typeof type === 'string') { + type = type.replace(/^attachable-/, ''); + } + + json[`${key}_type`] = `fleet-ops:${type}`; + } + + attachableModelNameFromType(type) { + if (!type || typeof type !== 'string') { + return undefined; + } + + if (type.includes('\\')) { + type = type.split('\\').pop(); + } + + type = type + .replace(/^fleet-ops:/, '') + .replace(/^attachable-/, '') + .toLowerCase(); + + if (!['vehicle', 'asset'].includes(type)) { + return undefined; + } + + return `attachable-${type}`; + } + + shouldRestoreAttachableDomainType(type) { + if (!type || typeof type !== 'string') { + return false; + } + + return !this.attachableModelNameFromType(type); + } + + restoreAttachableDomainType(normalized, type) { + const attachable = normalized?.data?.relationships?.attachable?.data; + + if (!attachable) { + return; + } + + const included = normalized?.included?.find((resource) => resource.type === attachable.type && resource.id === attachable.id); + + if (included) { + included.attributes = included.attributes ?? {}; + included.attributes.type = type; + } + } } diff --git a/app/models/attachable-asset.js b/app/models/attachable-asset.js new file mode 100644 index 0000000..9701bfb --- /dev/null +++ b/app/models/attachable-asset.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/models/attachable-asset'; diff --git a/app/models/attachable-vehicle.js b/app/models/attachable-vehicle.js new file mode 100644 index 0000000..c709572 --- /dev/null +++ b/app/models/attachable-vehicle.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/models/attachable-vehicle'; diff --git a/app/models/attachable.js b/app/models/attachable.js new file mode 100644 index 0000000..c490118 --- /dev/null +++ b/app/models/attachable.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/models/attachable'; diff --git a/tests/unit/models/attachable-asset-test.js b/tests/unit/models/attachable-asset-test.js new file mode 100644 index 0000000..83959da --- /dev/null +++ b/tests/unit/models/attachable-asset-test.js @@ -0,0 +1,13 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; + +module('Unit | Model | attachable asset', function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + const store = this.owner.lookup('service:store'); + const model = store.createRecord('attachable-asset', {}); + + assert.ok(model); + }); +}); diff --git a/tests/unit/models/attachable-test.js b/tests/unit/models/attachable-test.js new file mode 100644 index 0000000..d890163 --- /dev/null +++ b/tests/unit/models/attachable-test.js @@ -0,0 +1,13 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; + +module('Unit | Model | attachable', function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + const store = this.owner.lookup('service:store'); + const model = store.createRecord('attachable', {}); + + assert.ok(model); + }); +}); diff --git a/tests/unit/models/attachable-vehicle-test.js b/tests/unit/models/attachable-vehicle-test.js new file mode 100644 index 0000000..6f62f33 --- /dev/null +++ b/tests/unit/models/attachable-vehicle-test.js @@ -0,0 +1,13 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; + +module('Unit | Model | attachable vehicle', function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + const store = this.owner.lookup('service:store'); + const model = store.createRecord('attachable-vehicle', {}); + + assert.ok(model); + }); +}); diff --git a/tests/unit/models/device-event-test.js b/tests/unit/models/device-event-test.js index 3799299..2b23fb0 100644 --- a/tests/unit/models/device-event-test.js +++ b/tests/unit/models/device-event-test.js @@ -5,10 +5,34 @@ import { setupTest } from 'dummy/tests/helpers'; module('Unit | Model | device event', function (hooks) { setupTest(hooks); - // Replace this with your real tests. test('it exists', function (assert) { let store = this.owner.lookup('service:store'); let model = store.createRecord('device-event', {}); assert.ok(model); }); + + test('it exposes connectivity identity payload fields', function (assert) { + let store = this.owner.lookup('service:store'); + let model = store.createRecord('device-event', { + device_id: 'BX-025', + device_imei: '867747078951793', + device_connection_status: 'offline', + telematic_uuid: 'telematic_1', + telematic_name: 'AFAQY', + provider_descriptor: { + label: 'AFAQY', + icon: '/engines-dist/images/telematics/providers/afaqy.webp', + }, + }); + + assert.strictEqual(model.device_id, 'BX-025'); + assert.strictEqual(model.device_imei, '867747078951793'); + assert.strictEqual(model.device_connection_status, 'offline'); + assert.strictEqual(model.telematic_uuid, 'telematic_1'); + assert.strictEqual(model.telematic_name, 'AFAQY'); + assert.deepEqual(model.provider_descriptor, { + label: 'AFAQY', + icon: '/engines-dist/images/telematics/providers/afaqy.webp', + }); + }); }); diff --git a/tests/unit/models/device-test.js b/tests/unit/models/device-test.js index f2253ea..ce0afed 100644 --- a/tests/unit/models/device-test.js +++ b/tests/unit/models/device-test.js @@ -5,10 +5,19 @@ import { setupTest } from 'dummy/tests/helpers'; module('Unit | Model | device', function (hooks) { setupTest(hooks); - // Replace this with your real tests. test('it exists', function (assert) { let store = this.owner.lookup('service:store'); let model = store.createRecord('device', {}); assert.ok(model); }); + + test('attachable is a synchronous polymorphic relationship', function (assert) { + const store = this.owner.lookup('service:store'); + const relationship = store.modelFor('device').relationshipsByName.get('attachable'); + + assert.strictEqual(relationship.kind, 'belongsTo'); + assert.strictEqual(relationship.type, 'attachable'); + assert.true(relationship.options.polymorphic); + assert.false(relationship.options.async); + }); }); diff --git a/tests/unit/serializers/device-test.js b/tests/unit/serializers/device-test.js index 89802c0..5e0ead8 100644 --- a/tests/unit/serializers/device-test.js +++ b/tests/unit/serializers/device-test.js @@ -5,7 +5,6 @@ import { setupTest } from 'dummy/tests/helpers'; module('Unit | Serializer | device', function (hooks) { setupTest(hooks); - // Replace this with your real tests. test('it exists', function (assert) { let store = this.owner.lookup('service:store'); let serializer = store.serializerFor('device'); @@ -21,4 +20,112 @@ module('Unit | Serializer | device', function (hooks) { assert.ok(serializedRecord); }); + + test('it normalizes an embedded vehicle attachable relationship', function (assert) { + const store = this.owner.lookup('service:store'); + const serializer = store.serializerFor('device'); + const modelClass = store.modelFor('device'); + + const normalized = serializer.normalize(modelClass, { + uuid: 'device-1', + public_id: 'device_1', + attachable_uuid: 'vehicle-1', + attachable_type: 'fleet-ops:vehicle', + attachable: { + uuid: 'vehicle-1', + public_id: 'vehicle_1', + display_name: 'Truck 100', + }, + }); + + assert.strictEqual(normalized.data.relationships.attachable.data.type, 'attachable-vehicle'); + assert.strictEqual(normalized.data.relationships.attachable.data.id, 'vehicle-1'); + }); + + test('it normalizes an embedded asset attachable relationship', function (assert) { + const store = this.owner.lookup('service:store'); + const serializer = store.serializerFor('device'); + const modelClass = store.modelFor('device'); + + const normalized = serializer.normalize(modelClass, { + uuid: 'device-1', + public_id: 'device_1', + attachable_uuid: 'asset-1', + attachable_type: 'fleet-ops:asset', + attachable: { + uuid: 'asset-1', + public_id: 'asset_1', + name: 'Cold Chain Pallet', + }, + }); + + assert.strictEqual(normalized.data.relationships.attachable.data.type, 'attachable-asset'); + assert.strictEqual(normalized.data.relationships.attachable.data.id, 'asset-1'); + }); + + test('it normalizes a PHP vehicle attachable type without treating vehicle kind as model type', function (assert) { + const store = this.owner.lookup('service:store'); + const serializer = store.serializerFor('device'); + const modelClass = store.modelFor('device'); + + const normalized = serializer.normalize(modelClass, { + uuid: 'device-1', + public_id: 'device_1', + attachable_uuid: 'vehicle-1', + attachable_type: 'Fleetbase\\FleetOps\\Models\\Vehicle', + attachable: { + uuid: 'vehicle-1', + public_id: 'vehicle_1', + display_name: 'Van 100', + type: 'van', + }, + }); + + const attachable = normalized.data.relationships.attachable.data; + const included = normalized.included.find((resource) => resource.type === attachable.type && resource.id === attachable.id); + + assert.strictEqual(attachable.type, 'attachable-vehicle'); + assert.strictEqual(attachable.id, 'vehicle-1'); + assert.strictEqual(included.attributes.type, 'van'); + }); + + test('it normalizes a PHP asset attachable type', function (assert) { + const store = this.owner.lookup('service:store'); + const serializer = store.serializerFor('device'); + const modelClass = store.modelFor('device'); + + const normalized = serializer.normalize(modelClass, { + uuid: 'device-1', + public_id: 'device_1', + attachable_uuid: 'asset-1', + attachable_type: 'Fleetbase\\FleetOps\\Models\\Asset', + attachable: { + uuid: 'asset-1', + public_id: 'asset_1', + name: 'Cold Chain Pallet', + }, + }); + + assert.strictEqual(normalized.data.relationships.attachable.data.type, 'attachable-asset'); + assert.strictEqual(normalized.data.relationships.attachable.data.id, 'asset-1'); + }); + + test('it serializes attachable vehicle type for polymorphic mutations', function (assert) { + const store = this.owner.lookup('service:store'); + const vehicle = store.push({ + data: { + type: 'attachable-vehicle', + id: 'vehicle-1', + attributes: { + display_name: 'Truck 100', + }, + }, + }); + const device = store.createRecord('device', { attachable: vehicle }); + + const serialized = device.serialize(); + + assert.strictEqual(serialized.attachable_uuid, 'vehicle-1'); + assert.strictEqual(serialized.attachable_type, 'fleet-ops:vehicle'); + }); });