diff --git a/addon/models/asset-connection.js b/addon/models/asset-connection.js new file mode 100644 index 0000000..700ab8b --- /dev/null +++ b/addon/models/asset-connection.js @@ -0,0 +1,47 @@ +import Model, { attr, belongsTo } from '@ember-data/model'; +import { computed } from '@ember/object'; +import { format as formatDate, isValid as isValidDate, formatDistanceStrict } from 'date-fns'; + +export default class AssetConnectionModel extends Model { + @attr('string') uuid; + @attr('string') public_id; + @attr('string') company_uuid; + @attr('string') connector_type; + @attr('string') connector_uuid; + @attr('string') connected_type; + @attr('string') connected_uuid; + @attr('string', { defaultValue: 'towing' }) relationship_type; + @attr('number', { defaultValue: 1 }) position; + @attr('string') source; + @attr('string') confidence; + @attr('string') notes; + @attr('raw') meta; + @attr('boolean') active; + @attr('date') connected_at; + @attr('date') disconnected_at; + @attr('date') created_at; + @attr('date') updated_at; + + @belongsTo('vehicle', { async: false, inverse: null }) vehicle; + @belongsTo('trailer', { async: false, inverse: null }) trailer; + + @computed('active', 'disconnected_at') get isActive() { + return this.active === true || (this.active !== false && !this.disconnected_at); + } + + @computed('connected_at') get connectedAt() { + return isValidDate(this.connected_at) ? formatDate(this.connected_at, 'yyyy-MM-dd HH:mm') : null; + } + + @computed('disconnected_at') get disconnectedAt() { + return isValidDate(this.disconnected_at) ? formatDate(this.disconnected_at, 'yyyy-MM-dd HH:mm') : null; + } + + @computed('connected_at', 'disconnected_at') get duration() { + if (!isValidDate(this.connected_at)) { + return null; + } + + return formatDistanceStrict(this.connected_at, isValidDate(this.disconnected_at) ? this.disconnected_at : new Date()); + } +} diff --git a/addon/models/asset.js b/addon/models/asset.js index 8387795..dc7ee61 100644 --- a/addon/models/asset.js +++ b/addon/models/asset.js @@ -55,10 +55,22 @@ export default class AssetModel extends Model { @attr('string') odometer_unit; @attr('string') transmission; @attr('string') fuel_volume_unit; - @attr('string') fuel_Type; + @attr('string') fuel_type; @attr('string') ownership_type; @attr('string') engine_hours; @attr('string') gvw; + @attr('number') width; + @attr('number') length; + @attr('number') height; + @attr('number') tare_weight; + @attr('number') gvwr; + @attr('number') payload_capacity; + @attr('number') cargo_volume; + @attr('string') currency; + @attr('string') acquisition_cost; + @attr('string') current_value; + @attr('string') insurance_value; + @attr('string') depreciation_rate; @attr('raw') capacity; @attr('raw') specs; @attr('raw') attributes; @@ -77,6 +89,8 @@ export default class AssetModel extends Model { /** @dates */ @attr('date') deleted_at; + @attr('date') purchased_at; + @attr('date') lease_expires_at; @attr('date') created_at; @attr('date') updated_at; diff --git a/addon/models/attachable-driver.js b/addon/models/attachable-driver.js new file mode 100644 index 0000000..49e66b1 --- /dev/null +++ b/addon/models/attachable-driver.js @@ -0,0 +1,18 @@ +import AttachableModel from './attachable'; +import { attr } from '@ember-data/model'; + +/** + * Concrete polymorphic model for a Driver that equipment is issued to. + * + * Drivers are not telematics attachables, but equipment can be equipped to a driver + * (`fleet-ops:driver`), and the equipment serializer resolves that polymorphic + * relationship through the attachable model family. + */ +export default class AttachableDriverModel extends AttachableModel { + @attr('string') internal_id; + @attr('string') phone; + @attr('string') email; + @attr('string') drivers_license_number; + @attr('string') vehicle_name; + @attr('string') vendor_name; +} diff --git a/addon/models/attachable-trailer.js b/addon/models/attachable-trailer.js new file mode 100644 index 0000000..68bc3e9 --- /dev/null +++ b/addon/models/attachable-trailer.js @@ -0,0 +1,11 @@ +import AttachableAssetModel from './attachable-asset'; +import { attr } from '@ember-data/model'; + +/** Concrete polymorphic model for a Trailer attached to a device. */ +export default class AttachableTrailerModel extends AttachableAssetModel { + @attr('string') body_type; + @attr('string') coupling_type; + @attr('number') axle_count; + @attr('boolean') refrigerated; + @attr('string') current_vehicle_name; +} diff --git a/addon/models/attachable.js b/addon/models/attachable.js index 6e7b2c2..d366768 100644 --- a/addon/models/attachable.js +++ b/addon/models/attachable.js @@ -4,7 +4,7 @@ import { format as formatDate, isValid as isValidDate, formatDistanceToNow } fro /** * Abstract base model for resources a device can be attached to. - * Concrete types: attachable-vehicle, attachable-asset. + * Concrete types: attachable-vehicle, attachable-asset, attachable-trailer. */ export default class AttachableModel extends Model { /** @ids */ diff --git a/addon/models/equipment.js b/addon/models/equipment.js index a897ade..c2b82ad 100644 --- a/addon/models/equipment.js +++ b/addon/models/equipment.js @@ -15,6 +15,7 @@ export default class EquipmentModel extends Model { /** @relationships */ @belongsTo('warranty', { async: false }) warranty; @belongsTo('file', { async: false }) photo; + @belongsTo('attachable', { polymorphic: true, async: false }) equipable; @hasMany('maintenance', { async: false }) maintenances; @hasMany('custom-field-value', { async: false }) custom_field_values; diff --git a/addon/models/maintenance-subject-trailer.js b/addon/models/maintenance-subject-trailer.js new file mode 100644 index 0000000..f24a4a8 --- /dev/null +++ b/addon/models/maintenance-subject-trailer.js @@ -0,0 +1,15 @@ +import MaintenanceSubjectModel from './maintenance-subject'; +import { attr } from '@ember-data/model'; + +/** Concrete polymorphic model for Trailer maintenance targets. */ +export default class MaintenanceSubjectTrailerModel extends MaintenanceSubjectModel { + @attr('string') code; + @attr('string') vin; + @attr('string') plate_number; + @attr('string') make; + @attr('string') model; + @attr('string') year; + @attr('string') body_type; + @attr('number') axle_count; + @attr('string') current_vehicle_name; +} diff --git a/addon/models/maintenance-subject.js b/addon/models/maintenance-subject.js index efaff85..7b584d2 100644 --- a/addon/models/maintenance-subject.js +++ b/addon/models/maintenance-subject.js @@ -4,10 +4,12 @@ import { format as formatDate, isValid as isValidDate, formatDistanceToNow } fro /** * Abstract base model for polymorphic maintenance subjects. - * Concrete types: maintenance-subject-vehicle, maintenance-subject-equipment + * Concrete types: maintenance-subject-vehicle, maintenance-subject-trailer, + * maintenance-subject-equipment * * The backend stores the type as a PolymorphicType cast string, e.g.: * 'fleet-ops:vehicle' -> maintenance-subject-vehicle + * 'fleet-ops:trailer' -> maintenance-subject-trailer * 'fleet-ops:equipment' -> maintenance-subject-equipment */ export default class MaintenanceSubjectModel extends Model { diff --git a/addon/models/trailer.js b/addon/models/trailer.js new file mode 100644 index 0000000..e78f27a --- /dev/null +++ b/addon/models/trailer.js @@ -0,0 +1,129 @@ +import AssetModel from './asset'; +import { attr, belongsTo, hasMany } from '@ember-data/model'; +import { computed, get } from '@ember/object'; +import { not } from '@ember/object/computed'; +import isValidCoordinates from '@fleetbase/ember-core/utils/is-valid-coordinates'; +import { format as formatDate, isValid as isValidDate, formatDistanceToNow } from 'date-fns'; + +/** + * A first-class towed fleet asset. + * + * Trailer records share the common Asset contract while exposing the + * operational, connection, capacity, and telemetry fields used by Fleet-Ops. + */ +export default class TrailerModel extends AssetModel { + /** @relationships */ + @belongsTo('vehicle', { async: false, inverse: null }) current_vehicle; + @belongsTo('asset-connection', { async: false, inverse: null }) current_connection; + @hasMany('asset-connection', { async: false, inverse: null }) connections; + @hasMany('maintenance-schedule', { async: false, inverse: null }) maintenance_schedules; + @hasMany('work-order', { async: false, inverse: null }) work_orders; + @hasMany('position', { async: false, inverse: null }) positions; + + /** @classification */ + @attr('string', { defaultValue: 'trailer' }) asset_class; + @attr('string') body_type; + @attr('string') coupling_type; + @attr('string') brake_type; + + /** @capacity and dimensions */ + @attr('number') length; + @attr('number') width; + @attr('number') height; + @attr('number') tare_weight; + @attr('number') gvwr; + @attr('number') payload_capacity; + @attr('number') cargo_volume; + @attr('number') axle_count; + @attr('number') tire_count; + @attr('number') door_count; + + /** @specialized trailer capabilities */ + @attr('boolean') abs_equipped; + @attr('boolean') ebs_equipped; + @attr('boolean') refrigerated; + @attr('number') temperature_min; + @attr('number') temperature_max; + @attr('number') reefer_engine_hours; + + /** @current operational projections */ + @attr('boolean') online; + @attr('string') attachment_state; + @attr('string') vehicle_id; + @attr('string') connectivity_status; + @attr('string') movement_status; + @attr('raw') telematics; + @attr('raw') resolved_location; + @attr('string') current_vehicle_name; + @attr('string') current_vehicle_id; + @attr('date') attached_at; + @attr('number') devices_count; + @attr('number') equipment_count; + @attr('date') last_online_at; + + @computed('display_name', 'name', 'yearMakeModel', 'code', 'public_id') get displayName() { + return this.display_name || this.name || this.yearMakeModel || this.code || this.public_id; + } + + @computed('attachment_state') get isAttached() { + return this.attachment_state === 'attached'; + } + + @computed('connectivity_status', 'online') get isOnline() { + return this.connectivity_status === 'online' || this.online === true; + } + + @computed('last_online_at') get lastOnlineAt() { + if (!isValidDate(this.last_online_at)) { + return null; + } + + return formatDate(this.last_online_at, 'yyyy-MM-dd HH:mm'); + } + + @computed('last_online_at') get lastOnlineAgo() { + if (!isValidDate(this.last_online_at)) { + return null; + } + + return formatDistanceToNow(this.last_online_at, { addSuffix: true }); + } + + @computed('attached_at') get attachedAt() { + if (!isValidDate(this.attached_at)) { + return null; + } + + return formatDate(this.attached_at, 'yyyy-MM-dd HH:mm'); + } + + @computed('name', 'display_name', 'code', 'plate_number', 'vin', 'serial_number', 'yearMakeModel') get searchString() { + return [this.name, this.display_name, this.code, this.plate_number, this.vin, this.serial_number, this.yearMakeModel].filter(Boolean).join(' '); + } + + @computed('location') get longitude() { + return get(this.location, 'coordinates.0'); + } + + @computed('location') get latitude() { + return get(this.location, 'coordinates.1'); + } + + @computed('latitude', 'longitude') get coordinates() { + return [get(this, 'latitude'), get(this, 'longitude')]; + } + + @computed('latitude', 'longitude') get latlng() { + return { lat: get(this, 'latitude'), lng: get(this, 'longitude') }; + } + + @computed('coordinates', 'latitude', 'longitude') get hasValidCoordinates() { + if (this.longitude === 0 || this.latitude === 0) { + return false; + } + + return isValidCoordinates(this.coordinates); + } + + @not('hasValidCoordinates') hasInvalidCoordinates; +} diff --git a/addon/models/vehicle.js b/addon/models/vehicle.js index 7874f2c..e641478 100644 --- a/addon/models/vehicle.js +++ b/addon/models/vehicle.js @@ -22,6 +22,9 @@ export default class VehicleModel extends Model { @belongsTo('driver', { async: false }) driver; @belongsTo('vendor', { async: false }) vendor; @hasMany('device', { async: false }) devices; + @hasMany('trailer', { async: false, inverse: null }) trailers; + @hasMany('asset-connection', { async: false, inverse: null }) trailer_connections; + @hasMany('equipment', { async: false, inverse: null }) equipments; @hasMany('custom-field-value', { async: false }) custom_field_values; /** @attributes */ diff --git a/addon/serializers/asset-connection.js b/addon/serializers/asset-connection.js new file mode 100644 index 0000000..095c72c --- /dev/null +++ b/addon/serializers/asset-connection.js @@ -0,0 +1,11 @@ +import ApplicationSerializer from '@fleetbase/ember-core/serializers/application'; +import { EmbeddedRecordsMixin } from '@ember-data/serializer/rest'; + +export default class AssetConnectionSerializer extends ApplicationSerializer.extend(EmbeddedRecordsMixin) { + get attrs() { + return { + vehicle: { embedded: 'always' }, + trailer: { embedded: 'always' }, + }; + } +} diff --git a/addon/serializers/device.js b/addon/serializers/device.js index 34d547f..a0faaad 100644 --- a/addon/serializers/device.js +++ b/addon/serializers/device.js @@ -78,7 +78,7 @@ export default class DeviceSerializer extends ApplicationSerializer.extend(Embed .replace(/^attachable-/, '') .toLowerCase(); - if (!['vehicle', 'asset'].includes(type)) { + if (!['vehicle', 'asset', 'trailer'].includes(type)) { return undefined; } diff --git a/addon/serializers/equipment.js b/addon/serializers/equipment.js index 8766fa0..f454f2c 100644 --- a/addon/serializers/equipment.js +++ b/addon/serializers/equipment.js @@ -1,4 +1,67 @@ import ApplicationSerializer from '@fleetbase/ember-core/serializers/application'; import { EmbeddedRecordsMixin } from '@ember-data/serializer/rest'; +import { isBlank } from '@ember/utils'; -export default class EquipmentSerializer extends ApplicationSerializer.extend(EmbeddedRecordsMixin) {} +export default class EquipmentSerializer extends ApplicationSerializer.extend(EmbeddedRecordsMixin) { + get attrs() { + return { + warranty: { embedded: 'always' }, + photo: { embedded: 'always' }, + equipable: { embedded: 'always' }, + custom_field_values: { embedded: 'always' }, + }; + } + + normalize(model, hash, prop) { + const equipableDomainType = hash?.equipable?.type; + + if (hash?.equipable) { + hash.equipable.type = this.equipableModelNameFromType(hash.equipable_type); + } + + const normalized = super.normalize(model, hash, prop); + + if (equipableDomainType && !this.equipableModelNameFromType(equipableDomainType)) { + const equipable = normalized?.data?.relationships?.equipable?.data; + const included = normalized?.included?.find((resource) => resource.type === equipable?.type && resource.id === equipable?.id); + + if (included) { + included.attributes = included.attributes ?? {}; + included.attributes.type = equipableDomainType; + } + } + + return normalized; + } + + serializePolymorphicType(snapshot, json, relationship) { + let key = relationship.key; + + if (key !== 'equipable') { + return typeof super.serializePolymorphicType === 'function' ? super.serializePolymorphicType(...arguments) : undefined; + } + + const belongsTo = snapshot.belongsTo(key); + + if (!isBlank(snapshot.attr(`${key}_type`))) { + return; + } + + key = this.keyForAttribute ? this.keyForAttribute(key, 'serialize') : key; + json[`${key}_type`] = belongsTo ? `fleet-ops:${belongsTo.modelName.replace(/^attachable-/, '')}` : null; + } + + equipableModelNameFromType(type) { + if (!type || typeof type !== 'string') { + return undefined; + } + + const normalized = type + .split('\\') + .pop() + .replace(/^fleet-ops:/, '') + .replace(/^attachable-/, '') + .toLowerCase(); + return ['vehicle', 'trailer', 'driver', 'asset'].includes(normalized) ? `attachable-${normalized}` : undefined; + } +} diff --git a/addon/serializers/trailer.js b/addon/serializers/trailer.js new file mode 100644 index 0000000..d2e43ee --- /dev/null +++ b/addon/serializers/trailer.js @@ -0,0 +1,19 @@ +import ApplicationSerializer from '@fleetbase/ember-core/serializers/application'; +import { EmbeddedRecordsMixin } from '@ember-data/serializer/rest'; + +export default class TrailerSerializer extends ApplicationSerializer.extend(EmbeddedRecordsMixin) { + get attrs() { + return { + category: { embedded: 'always' }, + vendor: { embedded: 'always' }, + warranty: { embedded: 'always' }, + photo: { embedded: 'always' }, + current_vehicle: { embedded: 'always', serialize: false }, + current_connection: { embedded: 'always', serialize: false }, + connections: { embedded: 'always', serialize: false }, + devices: { embedded: 'always', serialize: false }, + equipments: { embedded: 'always', serialize: false }, + custom_field_values: { embedded: 'always' }, + }; + } +} diff --git a/addon/serializers/vehicle.js b/addon/serializers/vehicle.js index 3c23cf8..9d501c7 100644 --- a/addon/serializers/vehicle.js +++ b/addon/serializers/vehicle.js @@ -12,6 +12,7 @@ export default class VehicleSerializer extends ApplicationSerializer.extend(Embe driver: { embedded: 'always' }, vendor: { embedded: 'always' }, devices: { embedded: 'always' }, + trailers: { embedded: 'always' }, custom_field_values: { embedded: 'always' }, }; } diff --git a/app/models/asset-connection.js b/app/models/asset-connection.js new file mode 100644 index 0000000..78513f8 --- /dev/null +++ b/app/models/asset-connection.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/models/asset-connection'; diff --git a/app/models/attachable-driver.js b/app/models/attachable-driver.js new file mode 100644 index 0000000..6b2fe84 --- /dev/null +++ b/app/models/attachable-driver.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/models/attachable-driver'; diff --git a/app/models/attachable-trailer.js b/app/models/attachable-trailer.js new file mode 100644 index 0000000..e1ec252 --- /dev/null +++ b/app/models/attachable-trailer.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/models/attachable-trailer'; diff --git a/app/models/maintenance-subject-trailer.js b/app/models/maintenance-subject-trailer.js new file mode 100644 index 0000000..b1f3d42 --- /dev/null +++ b/app/models/maintenance-subject-trailer.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/models/maintenance-subject-trailer'; diff --git a/app/models/trailer.js b/app/models/trailer.js new file mode 100644 index 0000000..38af3f7 --- /dev/null +++ b/app/models/trailer.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/models/trailer'; diff --git a/app/serializers/asset-connection.js b/app/serializers/asset-connection.js new file mode 100644 index 0000000..10a5c77 --- /dev/null +++ b/app/serializers/asset-connection.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/serializers/asset-connection'; diff --git a/app/serializers/trailer.js b/app/serializers/trailer.js new file mode 100644 index 0000000..eaccf02 --- /dev/null +++ b/app/serializers/trailer.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/serializers/trailer'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 703c379..f07160f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,10 +32,10 @@ importers: version: 7.29.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1) '@ember/optional-features': specifier: ^2.0.0 - version: 2.3.0(supports-color@8.1.1) + version: 2.3.0 '@ember/string': specifier: ^3.1.1 - version: 3.1.1(supports-color@8.1.1) + version: 3.1.1 '@ember/test-helpers': specifier: ^3.2.0 version: 3.3.1(@babel/core@7.29.0(supports-color@8.1.1))(ember-source@5.4.1(@babel/core@7.29.0(supports-color@8.1.1))(@glimmer/component@1.1.2(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(rsvp@4.8.5)(supports-color@8.1.1)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)(uglify-js@3.19.3)))(supports-color@8.1.1)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)(uglify-js@3.19.3)) diff --git a/tests/unit/models/asset-connection-test.js b/tests/unit/models/asset-connection-test.js new file mode 100644 index 0000000..b7a79eb --- /dev/null +++ b/tests/unit/models/asset-connection-test.js @@ -0,0 +1,33 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; + +module('Unit | Model | asset-connection', function (hooks) { + setupTest(hooks); + + test('it represents an effective-dated towing relationship', function (assert) { + const store = this.owner.lookup('service:store'); + const connection = store.createRecord('asset-connection', { relationship_type: 'towing', active: true, position: 1 }); + + assert.strictEqual(connection.relationship_type, 'towing'); + assert.true(connection.active); + assert.strictEqual(connection.position, 1); + }); + + test('it formats connection timing for the console', function (assert) { + const store = this.owner.lookup('service:store'); + const active = store.createRecord('asset-connection', { connected_at: new Date('2026-09-01T08:00:00Z') }); + const ended = store.createRecord('asset-connection', { + active: false, + connected_at: new Date('2026-09-01T08:00:00Z'), + disconnected_at: new Date('2026-09-01T10:00:00Z'), + }); + + assert.true(active.isActive); + assert.ok(active.connectedAt.startsWith('2026-09-01')); + assert.strictEqual(active.disconnectedAt, null); + assert.ok(active.duration); + assert.false(ended.isActive); + assert.strictEqual(ended.duration, '2 hours'); + assert.strictEqual(store.createRecord('asset-connection').duration, null); + }); +}); diff --git a/tests/unit/models/attachable-driver-test.js b/tests/unit/models/attachable-driver-test.js new file mode 100644 index 0000000..37889e0 --- /dev/null +++ b/tests/unit/models/attachable-driver-test.js @@ -0,0 +1,14 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; + +module('Unit | Model | attachable-driver', function (hooks) { + setupTest(hooks); + + test('it resolves equipment issued to a driver through the attachable family', function (assert) { + const store = this.owner.lookup('service:store'); + const driver = store.createRecord('attachable-driver', { name: 'Dana Driver', internal_id: 'DRV-1' }); + + assert.strictEqual(driver.displayName, 'Dana Driver'); + assert.strictEqual(driver.internal_id, 'DRV-1'); + }); +}); diff --git a/tests/unit/models/trailer-test.js b/tests/unit/models/trailer-test.js new file mode 100644 index 0000000..7501ebb --- /dev/null +++ b/tests/unit/models/trailer-test.js @@ -0,0 +1,46 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; + +module('Unit | Model | trailer', function (hooks) { + setupTest(hooks); + + test('it exposes first-class identity, capacity, connection, and telemetry state', function (assert) { + const store = this.owner.lookup('service:store'); + const trailer = store.createRecord('trailer', { + name: 'Reefer 12', + year: '2026', + make: 'Utility', + model: '3000R', + location: { type: 'Point', coordinates: [106.9, 47.9] }, + payload_capacity: 20000, + }); + + assert.strictEqual(trailer.asset_class, 'trailer'); + assert.strictEqual(trailer.yearMakeModel, '2026 Utility 3000R'); + assert.strictEqual(trailer.payload_capacity, 20000); + assert.deepEqual(trailer.coordinates, [47.9, 106.9]); + }); + + test('it derives display, attachment, and connectivity projections', function (assert) { + const store = this.owner.lookup('service:store'); + const trailer = store.createRecord('trailer', { + public_id: 'trailer_one', + attachment_state: 'attached', + connectivity_status: 'recently_offline', + last_online_at: new Date('2026-09-01T10:30:00Z'), + attached_at: new Date('2026-08-30T08:00:00Z'), + }); + + assert.strictEqual(trailer.displayName, 'trailer_one', 'falls back to the public id when no name is set'); + assert.true(trailer.isAttached); + assert.false(trailer.isOnline); + assert.ok(trailer.lastOnlineAt.startsWith('2026-09-01')); + assert.ok(trailer.attachedAt.startsWith('2026-08-30')); + assert.ok(trailer.lastOnlineAgo); + + trailer.setProperties({ name: 'Reefer 12', connectivity_status: 'online', last_online_at: null }); + assert.strictEqual(trailer.displayName, 'Reefer 12'); + assert.true(trailer.isOnline); + assert.strictEqual(trailer.lastOnlineAt, null); + }); +}); diff --git a/tests/unit/serializers/asset-connection-test.js b/tests/unit/serializers/asset-connection-test.js new file mode 100644 index 0000000..ca4064d --- /dev/null +++ b/tests/unit/serializers/asset-connection-test.js @@ -0,0 +1,14 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; + +module('Unit | Serializer | asset-connection', function (hooks) { + setupTest(hooks); + + test('it serializes connection metadata', function (assert) { + const store = this.owner.lookup('service:store'); + const serialized = store.createRecord('asset-connection', { relationship_type: 'towing', source: 'manual' }).serialize(); + + assert.strictEqual(serialized.relationship_type, 'towing'); + assert.strictEqual(serialized.source, 'manual'); + }); +}); diff --git a/tests/unit/serializers/device-test.js b/tests/unit/serializers/device-test.js index 02839cc..3b9ab3d 100644 --- a/tests/unit/serializers/device-test.js +++ b/tests/unit/serializers/device-test.js @@ -128,6 +128,31 @@ module('Unit | Serializer | device', function (hooks) { assert.strictEqual(serialized.attachable_type, 'fleet-ops:vehicle'); }); + test('it normalizes and serializes trailer attachments', function (assert) { + const store = this.owner.lookup('service:store'); + const serializer = store.serializerFor('device'); + const normalized = serializer.normalize(store.modelFor('device'), { + uuid: 'device-2', + attachable_uuid: 'trailer-1', + attachable_type: 'fleet-ops:trailer', + attachable: { uuid: 'trailer-1', public_id: 'trailer_1', name: 'Reefer 1' }, + }); + + assert.strictEqual(normalized.data.relationships.attachable.data.type, 'attachable-trailer'); + + const json = {}; + serializer.serializePolymorphicType( + { + attr: () => undefined, + belongsTo: () => ({ modelName: 'attachable-trailer' }), + }, + json, + { key: 'attachable' } + ); + + assert.strictEqual(json.attachable_type, 'fleet-ops:trailer'); + }); + module('serializePolymorphicType', function () { test('a non-attachable relationship is handed to the application serializer untouched', function (assert) { const json = {}; diff --git a/tests/unit/serializers/equipment-test.js b/tests/unit/serializers/equipment-test.js index 789a552..4403164 100644 --- a/tests/unit/serializers/equipment-test.js +++ b/tests/unit/serializers/equipment-test.js @@ -47,4 +47,43 @@ module('Unit | Serializer | equipment', function (hooks) { assert.notOk(json.warranty_uuid, 'a relationship that was never set is simply absent'); }); + + test('it supports polymorphic vehicle and trailer attachments', function (assert) { + const store = this.owner.lookup('service:store'); + const serializer = store.serializerFor('equipment'); + const normalized = serializer.normalize(store.modelFor('equipment'), { + uuid: 'equipment-1', + equipable_uuid: 'trailer-1', + equipable_type: 'Fleetbase\\FleetOps\\Models\\Trailer', + equipable: { uuid: 'trailer-1', public_id: 'trailer_1', name: 'Flatbed 1' }, + }); + + assert.strictEqual(normalized.data.relationships.equipable.data.type, 'attachable-trailer'); + + const json = {}; + serializer.serializePolymorphicType( + { + attr: () => undefined, + belongsTo: () => ({ modelName: 'attachable-vehicle' }), + }, + json, + { key: 'equipable' } + ); + + assert.strictEqual(json.equipable_type, 'fleet-ops:vehicle'); + }); + + test('it resolves equipment issued to a driver through the attachable-driver model', function (assert) { + const store = this.owner.lookup('service:store'); + const serializer = store.serializerFor('equipment'); + const normalized = serializer.normalize(store.modelFor('equipment'), { + uuid: 'equipment-2', + equipable_uuid: 'driver-1', + equipable_type: 'fleet-ops:driver', + equipable: { uuid: 'driver-1', public_id: 'driver_1', name: 'Dana Driver' }, + }); + + assert.strictEqual(normalized.data.relationships.equipable.data.type, 'attachable-driver'); + assert.ok(store.modelFor('attachable-driver'), 'the attachable-driver model exists for the store'); + }); }); diff --git a/tests/unit/serializers/trailer-test.js b/tests/unit/serializers/trailer-test.js new file mode 100644 index 0000000..cbc350d --- /dev/null +++ b/tests/unit/serializers/trailer-test.js @@ -0,0 +1,75 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; + +module('Unit | Serializer | trailer', function (hooks) { + setupTest(hooks); + + test('it embeds Trailer relationships without writing read-only projections', function (assert) { + const store = this.owner.lookup('service:store'); + const serializer = store.serializerFor('trailer'); + + assert.ok(serializer); + assert.strictEqual(serializer.attrs.vendor.embedded, 'always'); + assert.strictEqual(serializer.attrs.category.embedded, 'always'); + assert.strictEqual(serializer.attrs.current_vehicle.serialize, false); + assert.strictEqual(serializer.attrs.connections.serialize, false); + assert.strictEqual(serializer.attrs.devices.serialize, false); + assert.strictEqual(serializer.attrs.equipments.serialize, false); + }); + + test('it normalizes a `trailers` collection envelope into an array of Trailer records', function (assert) { + const store = this.owner.lookup('service:store'); + const serializer = store.serializerFor('trailer'); + const payload = { + trailers: [ + { id: 'trailer-1', uuid: 'trailer-1', public_id: 'trailer_one', name: 'Reefer 12', type: 'reefer', status: 'available', attachment_state: 'detached' }, + { id: 'trailer-2', uuid: 'trailer-2', public_id: 'trailer_two', name: 'Flatbed 3', type: 'flatbed', status: 'in_use', attachment_state: 'attached' }, + ], + meta: { total: 2, current_page: 1, last_page: 1 }, + }; + + const normalized = serializer.normalizeResponse(store, store.modelFor('trailer'), payload, null, 'query'); + + assert.ok(Array.isArray(normalized.data), 'query responses normalize to an array'); + assert.strictEqual(normalized.data.length, 2); + assert.deepEqual( + normalized.data.map((resource) => resource.type), + ['trailer', 'trailer'] + ); + assert.strictEqual(normalized.data[0].attributes.attachment_state, 'detached'); + assert.deepEqual(normalized.meta, { total: 2, current_page: 1, last_page: 1 }); + }); + + test('it normalizes an empty `trailers` collection to an empty array', function (assert) { + const store = this.owner.lookup('service:store'); + const serializer = store.serializerFor('trailer'); + + const normalized = serializer.normalizeResponse(store, store.modelFor('trailer'), { trailers: [], meta: { total: 0 } }, null, 'query'); + + assert.deepEqual(normalized.data, []); + }); + + test('it normalizes a single `trailer` record envelope with embedded connection state', function (assert) { + const store = this.owner.lookup('service:store'); + const serializer = store.serializerFor('trailer'); + const payload = { + trailer: { + id: 'trailer-1', + uuid: 'trailer-1', + public_id: 'trailer_one', + name: 'Reefer 12', + current_vehicle: { id: 'vehicle-1', uuid: 'vehicle-1', public_id: 'vehicle_one', name: 'Truck 1' }, + current_connection: { id: 'connection-1', uuid: 'connection-1', public_id: 'connection_one', relationship_type: 'towing', position: 1, active: true }, + connections: [{ id: 'connection-1', uuid: 'connection-1', public_id: 'connection_one', relationship_type: 'towing', position: 1, active: true }], + }, + }; + + const normalized = serializer.normalizeResponse(store, store.modelFor('trailer'), payload, 'trailer-1', 'findRecord'); + + assert.strictEqual(normalized.data.type, 'trailer'); + assert.strictEqual(normalized.data.relationships.current_vehicle.data.id, 'vehicle-1'); + assert.strictEqual(normalized.data.relationships.current_connection.data.type, 'asset-connection'); + assert.strictEqual(normalized.data.relationships.connections.data.length, 1); + assert.ok(normalized.included.some((resource) => resource.type === 'vehicle' && resource.id === 'vehicle-1')); + }); +}); diff --git a/tests/unit/serializers/vehicle-test.js b/tests/unit/serializers/vehicle-test.js index f207c7a..83ef8ed 100644 --- a/tests/unit/serializers/vehicle-test.js +++ b/tests/unit/serializers/vehicle-test.js @@ -4,7 +4,6 @@ import { setupTest } from 'dummy/tests/helpers'; module('Unit | Serializer | vehicle', 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('vehicle'); @@ -20,4 +19,31 @@ module('Unit | Serializer | vehicle', function (hooks) { assert.ok(serializedRecord); }); + + test('it embeds the devices and current trailers the live feed sends with each vehicle', function (assert) { + const store = this.owner.lookup('service:store'); + const serializer = store.serializerFor('vehicle'); + + assert.strictEqual(serializer.attrs.devices.embedded, 'always'); + assert.strictEqual(serializer.attrs.trailers.embedded, 'always'); + + const payload = { + vehicle: { + id: 'vehicle-1', + uuid: 'vehicle-1', + public_id: 'vehicle_one', + name: 'Truck 1', + devices: [{ id: 'device-1', uuid: 'device-1', public_id: 'device_one', name: 'Tracker', online: true }], + trailers: [{ id: 'trailer-1', uuid: 'trailer-1', public_id: 'trailer_one', name: 'Reefer 12', type: 'reefer', attachment_state: 'attached', online: false }], + }, + }; + + const normalized = serializer.normalizeResponse(store, store.modelFor('vehicle'), payload, 'vehicle-1', 'findRecord'); + const includedTypes = normalized.included.map((resource) => resource.type); + + assert.deepEqual(normalized.data.relationships.trailers.data, [{ id: 'trailer-1', type: 'trailer' }]); + assert.deepEqual(normalized.data.relationships.devices.data, [{ id: 'device-1', type: 'device' }]); + assert.ok(includedTypes.includes('trailer'), 'embedded trailers are pushed alongside the vehicle'); + assert.ok(includedTypes.includes('device'), 'embedded devices are pushed alongside the vehicle'); + }); });