From d5e74abbccfdecc81a2cb419e49d6e4f3673efd4 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 9 Oct 2026 16:53:12 +0800 Subject: [PATCH] feat(descriptors): share the driver, vehicle, customer, contact, place, order, vendor and fleet identity descriptors with every extension The resource identity descriptors for the resources every extension can see through this package's models now live here, together with the thin pill, summary, identity-cell and select-option wrappers that name the resource type for ember-ui's generic components, and the styled placeholder silhouettes they fall back to. An instance initializer registers the descriptors whenever the host or an engine boots, filling in only keys nobody has registered, so a Storefront or Ledger table renders a driver as an identity cell on a deep link without loading the FleetOps engine. Opening a resource loads FleetOps on demand and hands the record to its action service, so the engine bundle is only fetched on click. FleetOps replaces these with its own openers when it boots. Covered at 100% by unit tests for the helpers, descriptors, placeholder images and initializer, and a rendering test that proves every wrapper resolves and passes its resource type through. --- README.md | 21 +- addon/components/cell/contact-identity.hbs | 1 + addon/components/cell/customer-identity.hbs | 1 + addon/components/cell/driver-identity.hbs | 1 + addon/components/cell/fleet-identity.hbs | 1 + addon/components/cell/order-identity.hbs | 1 + addon/components/cell/place-identity.hbs | 1 + addon/components/cell/vehicle-identity.hbs | 1 + addon/components/cell/vendor-identity.hbs | 1 + addon/components/contact/pill.hbs | 24 ++ addon/components/contact/summary.hbs | 9 + addon/components/customer/pill.hbs | 24 ++ addon/components/customer/summary.hbs | 9 + addon/components/driver/pill.hbs | 24 ++ addon/components/driver/summary.hbs | 9 + addon/components/fleet/pill.hbs | 24 ++ addon/components/fleet/summary.hbs | 9 + addon/components/order/pill.hbs | 24 ++ addon/components/order/summary.hbs | 9 + addon/components/place/pill.hbs | 24 ++ addon/components/place/summary.hbs | 9 + addon/components/select-option/contact.hbs | 12 + addon/components/select-option/customer.hbs | 12 + addon/components/select-option/driver.hbs | 12 + addon/components/select-option/fleet.hbs | 12 + addon/components/select-option/order.hbs | 12 + addon/components/select-option/place.hbs | 12 + addon/components/select-option/vehicle.hbs | 12 + addon/components/select-option/vendor.hbs | 12 + addon/components/vehicle/pill.hbs | 24 ++ addon/components/vehicle/summary.hbs | 9 + addon/components/vendor/pill.hbs | 24 ++ addon/components/vendor/summary.hbs | 9 + .../register-shared-resource-descriptors.js | 46 +++ addon/utils/placeholder-images.js | 71 +++++ addon/utils/resource-descriptors/helpers.js | 193 ++++++++++++ addon/utils/resource-descriptors/index.js | 2 + addon/utils/resource-descriptors/shared.js | 296 ++++++++++++++++++ app/components/cell/contact-identity.js | 1 + app/components/cell/customer-identity.js | 1 + app/components/cell/driver-identity.js | 1 + app/components/cell/fleet-identity.js | 1 + app/components/cell/order-identity.js | 1 + app/components/cell/place-identity.js | 1 + app/components/cell/vehicle-identity.js | 1 + app/components/cell/vendor-identity.js | 1 + app/components/contact/pill.js | 1 + app/components/contact/summary.js | 1 + app/components/customer/pill.js | 1 + app/components/customer/summary.js | 1 + app/components/driver/pill.js | 1 + app/components/driver/summary.js | 1 + app/components/fleet/pill.js | 1 + app/components/fleet/summary.js | 1 + app/components/order/pill.js | 1 + app/components/order/summary.js | 1 + app/components/place/pill.js | 1 + app/components/place/summary.js | 1 + app/components/select-option/contact.js | 1 + app/components/select-option/customer.js | 1 + app/components/select-option/driver.js | 1 + app/components/select-option/fleet.js | 1 + app/components/select-option/order.js | 1 + app/components/select-option/place.js | 1 + app/components/select-option/vehicle.js | 1 + app/components/select-option/vendor.js | 1 + app/components/vehicle/pill.js | 1 + app/components/vehicle/summary.js | 1 + app/components/vendor/pill.js | 1 + app/components/vendor/summary.js | 1 + .../register-shared-resource-descriptors.js | 1 + package.json | 2 +- .../components/resource-wrappers-test.js | 58 ++++ ...gister-shared-resource-descriptors-test.js | 98 ++++++ tests/unit/utils/placeholder-images-test.js | 63 ++++ .../resource-descriptors/helpers-test.js | 207 ++++++++++++ .../utils/resource-descriptors/shared-test.js | 270 ++++++++++++++++ 77 files changed, 1726 insertions(+), 2 deletions(-) create mode 100644 addon/components/cell/contact-identity.hbs create mode 100644 addon/components/cell/customer-identity.hbs create mode 100644 addon/components/cell/driver-identity.hbs create mode 100644 addon/components/cell/fleet-identity.hbs create mode 100644 addon/components/cell/order-identity.hbs create mode 100644 addon/components/cell/place-identity.hbs create mode 100644 addon/components/cell/vehicle-identity.hbs create mode 100644 addon/components/cell/vendor-identity.hbs create mode 100644 addon/components/contact/pill.hbs create mode 100644 addon/components/contact/summary.hbs create mode 100644 addon/components/customer/pill.hbs create mode 100644 addon/components/customer/summary.hbs create mode 100644 addon/components/driver/pill.hbs create mode 100644 addon/components/driver/summary.hbs create mode 100644 addon/components/fleet/pill.hbs create mode 100644 addon/components/fleet/summary.hbs create mode 100644 addon/components/order/pill.hbs create mode 100644 addon/components/order/summary.hbs create mode 100644 addon/components/place/pill.hbs create mode 100644 addon/components/place/summary.hbs create mode 100644 addon/components/select-option/contact.hbs create mode 100644 addon/components/select-option/customer.hbs create mode 100644 addon/components/select-option/driver.hbs create mode 100644 addon/components/select-option/fleet.hbs create mode 100644 addon/components/select-option/order.hbs create mode 100644 addon/components/select-option/place.hbs create mode 100644 addon/components/select-option/vehicle.hbs create mode 100644 addon/components/select-option/vendor.hbs create mode 100644 addon/components/vehicle/pill.hbs create mode 100644 addon/components/vehicle/summary.hbs create mode 100644 addon/components/vendor/pill.hbs create mode 100644 addon/components/vendor/summary.hbs create mode 100644 addon/instance-initializers/register-shared-resource-descriptors.js create mode 100644 addon/utils/placeholder-images.js create mode 100644 addon/utils/resource-descriptors/helpers.js create mode 100644 addon/utils/resource-descriptors/index.js create mode 100644 addon/utils/resource-descriptors/shared.js create mode 100644 app/components/cell/contact-identity.js create mode 100644 app/components/cell/customer-identity.js create mode 100644 app/components/cell/driver-identity.js create mode 100644 app/components/cell/fleet-identity.js create mode 100644 app/components/cell/order-identity.js create mode 100644 app/components/cell/place-identity.js create mode 100644 app/components/cell/vehicle-identity.js create mode 100644 app/components/cell/vendor-identity.js create mode 100644 app/components/contact/pill.js create mode 100644 app/components/contact/summary.js create mode 100644 app/components/customer/pill.js create mode 100644 app/components/customer/summary.js create mode 100644 app/components/driver/pill.js create mode 100644 app/components/driver/summary.js create mode 100644 app/components/fleet/pill.js create mode 100644 app/components/fleet/summary.js create mode 100644 app/components/order/pill.js create mode 100644 app/components/order/summary.js create mode 100644 app/components/place/pill.js create mode 100644 app/components/place/summary.js create mode 100644 app/components/select-option/contact.js create mode 100644 app/components/select-option/customer.js create mode 100644 app/components/select-option/driver.js create mode 100644 app/components/select-option/fleet.js create mode 100644 app/components/select-option/order.js create mode 100644 app/components/select-option/place.js create mode 100644 app/components/select-option/vehicle.js create mode 100644 app/components/select-option/vendor.js create mode 100644 app/components/vehicle/pill.js create mode 100644 app/components/vehicle/summary.js create mode 100644 app/components/vendor/pill.js create mode 100644 app/components/vendor/summary.js create mode 100644 app/instance-initializers/register-shared-resource-descriptors.js create mode 100644 tests/integration/components/resource-wrappers-test.js create mode 100644 tests/unit/instance-initializers/register-shared-resource-descriptors-test.js create mode 100644 tests/unit/utils/placeholder-images-test.js create mode 100644 tests/unit/utils/resource-descriptors/helpers-test.js create mode 100644 tests/unit/utils/resource-descriptors/shared-test.js diff --git a/README.md b/README.md index a45c828..5e878ef 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,26 @@ ember install @fleetbase/fleetops-data ## Usage -[Longer description of how to use the addon in apps.] +Install the addon and the Fleet-Ops models, adapters, serializers and transforms are available to the host application and to every engine that lists it as a dependency. + +### Shared resource descriptors + +The addon also ships the resource identity descriptors for the resources every extension can see through its models: `driver`, `vehicle`, `customer`, `contact`, `place`, `order`, `vendor` and `fleet`, along with the thin ``, ``, `` and `` wrappers that name the resource type for the generic components in `@fleetbase/ember-ui`. + +An instance initializer registers the descriptors with the `resource-registry` service whenever the host or an engine boots, filling in only the keys nobody has registered yet. That means a Storefront or Ledger table can render a driver as an identity cell before the Fleet-Ops engine has loaded: + +```js +{ + id: 'driver-assigned', + cellComponent: 'table/cell/identity', + resourceType: 'driver', + resourcePath: 'driver_assigned', +} +``` + +Opening a resource loads the Fleet-Ops engine on demand and hands the record to its action service, so the engine bundle is only fetched when someone clicks. When Fleet-Ops itself boots it replaces these descriptors with its own, which open the same panels directly. + +The helpers behind the descriptors and the styled placeholder images are exported from `@fleetbase/fleetops-data/utils/resource-descriptors` and `@fleetbase/fleetops-data/utils/placeholder-images` for other packages that build descriptors. ## Contributing diff --git a/addon/components/cell/contact-identity.hbs b/addon/components/cell/contact-identity.hbs new file mode 100644 index 0000000..3457db9 --- /dev/null +++ b/addon/components/cell/contact-identity.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/addon/components/cell/customer-identity.hbs b/addon/components/cell/customer-identity.hbs new file mode 100644 index 0000000..e099808 --- /dev/null +++ b/addon/components/cell/customer-identity.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/addon/components/cell/driver-identity.hbs b/addon/components/cell/driver-identity.hbs new file mode 100644 index 0000000..44f6282 --- /dev/null +++ b/addon/components/cell/driver-identity.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/addon/components/cell/fleet-identity.hbs b/addon/components/cell/fleet-identity.hbs new file mode 100644 index 0000000..3706244 --- /dev/null +++ b/addon/components/cell/fleet-identity.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/addon/components/cell/order-identity.hbs b/addon/components/cell/order-identity.hbs new file mode 100644 index 0000000..9c3940b --- /dev/null +++ b/addon/components/cell/order-identity.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/addon/components/cell/place-identity.hbs b/addon/components/cell/place-identity.hbs new file mode 100644 index 0000000..f5cb82a --- /dev/null +++ b/addon/components/cell/place-identity.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/addon/components/cell/vehicle-identity.hbs b/addon/components/cell/vehicle-identity.hbs new file mode 100644 index 0000000..77d5a71 --- /dev/null +++ b/addon/components/cell/vehicle-identity.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/addon/components/cell/vendor-identity.hbs b/addon/components/cell/vendor-identity.hbs new file mode 100644 index 0000000..303c84e --- /dev/null +++ b/addon/components/cell/vendor-identity.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/addon/components/contact/pill.hbs b/addon/components/contact/pill.hbs new file mode 100644 index 0000000..9e32991 --- /dev/null +++ b/addon/components/contact/pill.hbs @@ -0,0 +1,24 @@ + + {{yield resource}} + \ No newline at end of file diff --git a/addon/components/contact/summary.hbs b/addon/components/contact/summary.hbs new file mode 100644 index 0000000..56e167a --- /dev/null +++ b/addon/components/contact/summary.hbs @@ -0,0 +1,9 @@ + \ No newline at end of file diff --git a/addon/components/customer/pill.hbs b/addon/components/customer/pill.hbs new file mode 100644 index 0000000..5e7da42 --- /dev/null +++ b/addon/components/customer/pill.hbs @@ -0,0 +1,24 @@ + + {{yield resource}} + \ No newline at end of file diff --git a/addon/components/customer/summary.hbs b/addon/components/customer/summary.hbs new file mode 100644 index 0000000..dbc7d25 --- /dev/null +++ b/addon/components/customer/summary.hbs @@ -0,0 +1,9 @@ + \ No newline at end of file diff --git a/addon/components/driver/pill.hbs b/addon/components/driver/pill.hbs new file mode 100644 index 0000000..a540456 --- /dev/null +++ b/addon/components/driver/pill.hbs @@ -0,0 +1,24 @@ + + {{yield resource}} + \ No newline at end of file diff --git a/addon/components/driver/summary.hbs b/addon/components/driver/summary.hbs new file mode 100644 index 0000000..2bb542e --- /dev/null +++ b/addon/components/driver/summary.hbs @@ -0,0 +1,9 @@ + \ No newline at end of file diff --git a/addon/components/fleet/pill.hbs b/addon/components/fleet/pill.hbs new file mode 100644 index 0000000..7a090fb --- /dev/null +++ b/addon/components/fleet/pill.hbs @@ -0,0 +1,24 @@ + + {{yield resource}} + \ No newline at end of file diff --git a/addon/components/fleet/summary.hbs b/addon/components/fleet/summary.hbs new file mode 100644 index 0000000..6581492 --- /dev/null +++ b/addon/components/fleet/summary.hbs @@ -0,0 +1,9 @@ + \ No newline at end of file diff --git a/addon/components/order/pill.hbs b/addon/components/order/pill.hbs new file mode 100644 index 0000000..c71acca --- /dev/null +++ b/addon/components/order/pill.hbs @@ -0,0 +1,24 @@ + + {{yield resource}} + \ No newline at end of file diff --git a/addon/components/order/summary.hbs b/addon/components/order/summary.hbs new file mode 100644 index 0000000..377afb6 --- /dev/null +++ b/addon/components/order/summary.hbs @@ -0,0 +1,9 @@ + \ No newline at end of file diff --git a/addon/components/place/pill.hbs b/addon/components/place/pill.hbs new file mode 100644 index 0000000..1a0c28f --- /dev/null +++ b/addon/components/place/pill.hbs @@ -0,0 +1,24 @@ + + {{yield resource}} + \ No newline at end of file diff --git a/addon/components/place/summary.hbs b/addon/components/place/summary.hbs new file mode 100644 index 0000000..25fb183 --- /dev/null +++ b/addon/components/place/summary.hbs @@ -0,0 +1,9 @@ + \ No newline at end of file diff --git a/addon/components/select-option/contact.hbs b/addon/components/select-option/contact.hbs new file mode 100644 index 0000000..99423f7 --- /dev/null +++ b/addon/components/select-option/contact.hbs @@ -0,0 +1,12 @@ + \ No newline at end of file diff --git a/addon/components/select-option/customer.hbs b/addon/components/select-option/customer.hbs new file mode 100644 index 0000000..8efec3d --- /dev/null +++ b/addon/components/select-option/customer.hbs @@ -0,0 +1,12 @@ + \ No newline at end of file diff --git a/addon/components/select-option/driver.hbs b/addon/components/select-option/driver.hbs new file mode 100644 index 0000000..988eaef --- /dev/null +++ b/addon/components/select-option/driver.hbs @@ -0,0 +1,12 @@ + \ No newline at end of file diff --git a/addon/components/select-option/fleet.hbs b/addon/components/select-option/fleet.hbs new file mode 100644 index 0000000..fb8a6d3 --- /dev/null +++ b/addon/components/select-option/fleet.hbs @@ -0,0 +1,12 @@ + \ No newline at end of file diff --git a/addon/components/select-option/order.hbs b/addon/components/select-option/order.hbs new file mode 100644 index 0000000..b2001ca --- /dev/null +++ b/addon/components/select-option/order.hbs @@ -0,0 +1,12 @@ + \ No newline at end of file diff --git a/addon/components/select-option/place.hbs b/addon/components/select-option/place.hbs new file mode 100644 index 0000000..67ae56a --- /dev/null +++ b/addon/components/select-option/place.hbs @@ -0,0 +1,12 @@ + \ No newline at end of file diff --git a/addon/components/select-option/vehicle.hbs b/addon/components/select-option/vehicle.hbs new file mode 100644 index 0000000..1fa9a3f --- /dev/null +++ b/addon/components/select-option/vehicle.hbs @@ -0,0 +1,12 @@ + \ No newline at end of file diff --git a/addon/components/select-option/vendor.hbs b/addon/components/select-option/vendor.hbs new file mode 100644 index 0000000..9f81f2c --- /dev/null +++ b/addon/components/select-option/vendor.hbs @@ -0,0 +1,12 @@ + \ No newline at end of file diff --git a/addon/components/vehicle/pill.hbs b/addon/components/vehicle/pill.hbs new file mode 100644 index 0000000..13093a2 --- /dev/null +++ b/addon/components/vehicle/pill.hbs @@ -0,0 +1,24 @@ + + {{yield resource}} + \ No newline at end of file diff --git a/addon/components/vehicle/summary.hbs b/addon/components/vehicle/summary.hbs new file mode 100644 index 0000000..6abeea1 --- /dev/null +++ b/addon/components/vehicle/summary.hbs @@ -0,0 +1,9 @@ + \ No newline at end of file diff --git a/addon/components/vendor/pill.hbs b/addon/components/vendor/pill.hbs new file mode 100644 index 0000000..38fa047 --- /dev/null +++ b/addon/components/vendor/pill.hbs @@ -0,0 +1,24 @@ + + {{yield resource}} + \ No newline at end of file diff --git a/addon/components/vendor/summary.hbs b/addon/components/vendor/summary.hbs new file mode 100644 index 0000000..933014d --- /dev/null +++ b/addon/components/vendor/summary.hbs @@ -0,0 +1,9 @@ + \ No newline at end of file diff --git a/addon/instance-initializers/register-shared-resource-descriptors.js b/addon/instance-initializers/register-shared-resource-descriptors.js new file mode 100644 index 0000000..8d60c0b --- /dev/null +++ b/addon/instance-initializers/register-shared-resource-descriptors.js @@ -0,0 +1,46 @@ +import { buildSharedResourceDescriptors } from '../utils/resource-descriptors/shared'; + +/** + * Registers the shared FleetOps resource descriptors with the resource + * registry the UI package provides, so a driver, customer or place renders + * as an identity cell, pill or summary in any extension without that + * extension loading the FleetOps engine first. + * + * It runs for the host application and again for every engine that depends + * on this package, so it only fills in descriptors nobody has registered: + * FleetOps registers richer versions of the same keys when it boots, and + * a later engine must not replace them. A host without the registry service + * simply keeps plain text. + */ +export function initialize(owner) { + let registry; + + try { + registry = owner.lookup('service:resource-registry'); + } catch { + registry = null; + } + + if (!registry || typeof registry.registerDescriptors !== 'function') { + return; + } + + const missing = buildSharedResourceDescriptors(owner).filter((descriptor) => !isRegistered(registry, descriptor.key)); + + if (missing.length) { + registry.registerDescriptors(missing); + } +} + +function isRegistered(registry, key) { + try { + return Boolean(typeof registry.getDescriptor === 'function' ? registry.getDescriptor(key) : registry.descriptors?.some?.((descriptor) => descriptor.key === key)); + } catch { + return false; + } +} + +export default { + name: 'register-shared-resource-descriptors', + initialize, +}; diff --git a/addon/utils/placeholder-images.js b/addon/utils/placeholder-images.js new file mode 100644 index 0000000..28b8e36 --- /dev/null +++ b/addon/utils/placeholder-images.js @@ -0,0 +1,71 @@ +import config from 'ember-get-config'; + +/** + * Flat silhouettes shown wherever a resource has no photo, in one consistent style + * across the console. They are inline data URIs so the engine does not depend on a + * hosted asset; a console can override any of them through + * `defaultValues.placeholders.` in its environment config. + */ +export const PLACEHOLDER_IMAGES = { + trailer: + 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NDAgNDgwIiB3aWR0aD0iNjQwIiBoZWlnaHQ9IjQ4MCI+PGcgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIj48cmVjdCB4PSI4OCIgeT0iMTI4IiB3aWR0aD0iNDcwIiBoZWlnaHQ9IjE5NiIgcng9IjE2IiBmaWxsPSIjYmRiZGJkIiBzdHJva2U9IiNkZWRlZGUiIHN0cm9rZS13aWR0aD0iMTAiLz48cmVjdCB4PSIxMDgiIHk9IjE1MCIgd2lkdGg9IjQzMCIgaGVpZ2h0PSIxNTIiIHJ4PSI4IiBmaWxsPSIjYzljOWM5Ii8+PHBhdGggZD0iTTEyOCAxNzhoMzkwTTEyOCAyMjZoMzkwTTEyOCAyNzRoMzkwIiBzdHJva2U9IiNiZGJkYmQiIHN0cm9rZS13aWR0aD0iNiIvPjxyZWN0IHg9IjUyMiIgeT0iMTQ2IiB3aWR0aD0iMTAiIGhlaWdodD0iMTYwIiByeD0iNCIgZmlsbD0iI2IwYjBiMCIvPjxyZWN0IHg9Ijg4IiB5PSIzMjQiIHdpZHRoPSI0NzAiIGhlaWdodD0iMTgiIHJ4PSI2IiBmaWxsPSIjYTlhOWE5Ii8+PHJlY3QgeD0iMTU2IiB5PSIzNDIiIHdpZHRoPSIxNCIgaGVpZ2h0PSI1MiIgcng9IjQiIGZpbGw9IiNhOWE5YTkiLz48cmVjdCB4PSIxNDYiIHk9IjM5MiIgd2lkdGg9IjM0IiBoZWlnaHQ9IjEwIiByeD0iNCIgZmlsbD0iI2E5YTlhOSIvPjxyZWN0IHg9Ijg4IiB5PSIzNDIiIHdpZHRoPSI1MiIgaGVpZ2h0PSIxNiIgcng9IjYiIGZpbGw9IiNhOWE5YTkiLz48Y2lyY2xlIGN4PSI0MTIiIGN5PSIzNjYiIHI9IjQwIiBmaWxsPSIjOGY4ZjhmIiBzdHJva2U9IiNkZWRlZGUiIHN0cm9rZS13aWR0aD0iOCIvPjxjaXJjbGUgY3g9IjQxMiIgY3k9IjM2NiIgcj0iMTYiIGZpbGw9IiNkOWQ5ZDkiLz48Y2lyY2xlIGN4PSI1MDAiIGN5PSIzNjYiIHI9IjQwIiBmaWxsPSIjOGY4ZjhmIiBzdHJva2U9IiNkZWRlZGUiIHN0cm9rZS13aWR0aD0iOCIvPjxjaXJjbGUgY3g9IjUwMCIgY3k9IjM2NiIgcj0iMTYiIGZpbGw9IiNkOWQ5ZDkiLz48L2c+PC9zdmc+', + vehicle: + 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NDAgNDgwIiB3aWR0aD0iNjQwIiBoZWlnaHQ9IjQ4MCI+PGcgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIj48ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwIDApIHNjYWxlKDEuMCkiIG9wYWNpdHk9IjEuMCI+PHJlY3QgeD0iMjE2IiB5PSIxMzIiIHdpZHRoPSIzNTAiIGhlaWdodD0iMTkyIiByeD0iMTQiIGZpbGw9IiNiZGJkYmQiIHN0cm9rZT0iI2RlZGVkZSIgc3Ryb2tlLXdpZHRoPSIxMCIvPjxyZWN0IHg9IjIzNiIgeT0iMTU0IiB3aWR0aD0iMzEwIiBoZWlnaHQ9IjE0OCIgcng9IjgiIGZpbGw9IiNjOWM5YzkiLz48cGF0aCBkPSJNMjU2IDE4MmgyNzBNMjU2IDIzMGgyNzBNMjU2IDI3OGgyNzAiIHN0cm9rZT0iI2JkYmRiZCIgc3Ryb2tlLXdpZHRoPSI2Ii8+PHBhdGggZD0iTTIxNiAzMjRWMTkwSDk2Yy0xMiAwLTIyIDgtMjYgMjBMNjAgMjQ0djgweiIgZmlsbD0iI2JkYmRiZCIgc3Ryb2tlPSIjZGVkZWRlIiBzdHJva2Utd2lkdGg9IjEwIi8+PHBhdGggZD0iTTEwNCAyMDZoODh2NTJIODR6IiBmaWxsPSIjZDlkOWQ5Ii8+PHJlY3QgeD0iNjAiIHk9IjMyNCIgd2lkdGg9IjUwNiIgaGVpZ2h0PSIxOCIgcng9IjYiIGZpbGw9IiNhOWE5YTkiLz48cmVjdCB4PSI1NiIgeT0iMjkyIiB3aWR0aD0iMTgiIGhlaWdodD0iMzAiIHJ4PSI1IiBmaWxsPSIjZDlkOWQ5Ii8+PGNpcmNsZSBjeD0iMTUwIiBjeT0iMzY2IiByPSI0MCIgZmlsbD0iIzhmOGY4ZiIgc3Ryb2tlPSIjZGVkZWRlIiBzdHJva2Utd2lkdGg9IjgiLz48Y2lyY2xlIGN4PSIxNTAiIGN5PSIzNjYiIHI9IjE2IiBmaWxsPSIjZDlkOWQ5Ii8+PGNpcmNsZSBjeD0iNDcwIiBjeT0iMzY2IiByPSI0MCIgZmlsbD0iIzhmOGY4ZiIgc3Ryb2tlPSIjZGVkZWRlIiBzdHJva2Utd2lkdGg9IjgiLz48Y2lyY2xlIGN4PSI0NzAiIGN5PSIzNjYiIHI9IjE2IiBmaWxsPSIjZDlkOWQ5Ii8+PC9nPjwvZz48L3N2Zz4=', + driver: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NDAgNDgwIiB3aWR0aD0iNjQwIiBoZWlnaHQ9IjQ4MCI+PGcgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIj48cGF0aCBkPSJNMTEwIDQ4MGMwLTk2IDc2LTE2NiAyMTAtMTY2czIxMCA3MCAyMTAgMTY2eiIgZmlsbD0iI2JkYmRiZCIgc3Ryb2tlPSIjZGVkZWRlIiBzdHJva2Utd2lkdGg9IjEwIi8+PHBhdGggZD0iTTI3MCAzMjJsNTAgNTQgNTAtNTQiIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2Q5ZDlkOSIgc3Ryb2tlLXdpZHRoPSI4Ii8+PGNpcmNsZSBjeD0iMzIwIiBjeT0iMjA0IiByPSI5MiIgZmlsbD0iI2JkYmRiZCIgc3Ryb2tlPSIjZGVkZWRlIiBzdHJva2Utd2lkdGg9IjEwIi8+PHBhdGggZD0iTTIzMCAxODZjMC02MiA0Mi05NiA5MC05NnM5MCAzNCA5MCA5NnoiIGZpbGw9IiNhOWE5YTkiIHN0cm9rZT0iI2RlZGVkZSIgc3Ryb2tlLXdpZHRoPSIxMCIvPjxwYXRoIGQ9Ik0zMjAgOTZ2ODgiIHN0cm9rZT0iI2RlZGVkZSIgc3Ryb2tlLXdpZHRoPSI2Ii8+PGNpcmNsZSBjeD0iMzIwIiBjeT0iOTIiIHI9IjkiIGZpbGw9IiNhOWE5YTkiIHN0cm9rZT0iI2RlZGVkZSIgc3Ryb2tlLXdpZHRoPSI2Ii8+PHBhdGggZD0iTTIyMiAxODZoMTkwYzY2IDAgOTYgMTAgOTYgMjJzLTMwIDIyLTk2IDIySDIyMnoiIGZpbGw9IiNhOWE5YTkiIHN0cm9rZT0iI2RlZGVkZSIgc3Ryb2tlLXdpZHRoPSI4Ii8+PC9nPjwvc3ZnPg==', + contact: + 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NDAgNDgwIiB3aWR0aD0iNjQwIiBoZWlnaHQ9IjQ4MCI+PGcgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIj48cGF0aCBkPSJNMTEwIDQ4MGMwLTk2IDc2LTE2NiAyMTAtMTY2czIxMCA3MCAyMTAgMTY2eiIgZmlsbD0iI2JkYmRiZCIgc3Ryb2tlPSIjZGVkZWRlIiBzdHJva2Utd2lkdGg9IjEwIi8+PHBhdGggZD0iTTI3MCAzMjJsNTAgNTQgNTAtNTQiIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2Q5ZDlkOSIgc3Ryb2tlLXdpZHRoPSI4Ii8+PGNpcmNsZSBjeD0iMzIwIiBjeT0iMTg4IiByPSI5NiIgZmlsbD0iI2JkYmRiZCIgc3Ryb2tlPSIjZGVkZWRlIiBzdHJva2Utd2lkdGg9IjEwIi8+PC9nPjwvc3ZnPg==', + customer: + 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NDAgNDgwIiB3aWR0aD0iNjQwIiBoZWlnaHQ9IjQ4MCI+PGcgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIj48cGF0aCBkPSJNMTEwIDQ4MGMwLTk2IDc2LTE2NiAyMTAtMTY2czIxMCA3MCAyMTAgMTY2eiIgZmlsbD0iI2JkYmRiZCIgc3Ryb2tlPSIjZGVkZWRlIiBzdHJva2Utd2lkdGg9IjEwIi8+PHBhdGggZD0iTTI3MCAzMjJsNTAgNTQgNTAtNTQiIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2Q5ZDlkOSIgc3Ryb2tlLXdpZHRoPSI4Ii8+PGNpcmNsZSBjeD0iMzIwIiBjeT0iMTg4IiByPSI5NiIgZmlsbD0iI2JkYmRiZCIgc3Ryb2tlPSIjZGVkZWRlIiBzdHJva2Utd2lkdGg9IjEwIi8+PGNpcmNsZSBjeD0iNDkwIiBjeT0iMzkyIiByPSI2MCIgZmlsbD0iIzhmOGY4ZiIgc3Ryb2tlPSIjZGVkZWRlIiBzdHJva2Utd2lkdGg9IjEwIi8+PHBhdGggZD0iTTQ1OCAzOTRsMjIgMjIgNDQtNDgiIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2Q5ZDlkOSIgc3Ryb2tlLXdpZHRoPSIxNCIvPjwvZz48L3N2Zz4=', + vendor: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NDAgNDgwIiB3aWR0aD0iNjQwIiBoZWlnaHQ9IjQ4MCI+PGcgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIj48cGF0aCBkPSJNNzIgMjAwTDMyMCA5NmwyNDggMTA0djI0SDcyeiIgZmlsbD0iI2E5YTlhOSIgc3Ryb2tlPSIjZGVkZWRlIiBzdHJva2Utd2lkdGg9IjEwIi8+PHJlY3QgeD0iMTAwIiB5PSIyMjIiIHdpZHRoPSI0NDAiIGhlaWdodD0iMTkwIiByeD0iOCIgZmlsbD0iI2JkYmRiZCIgc3Ryb2tlPSIjZGVkZWRlIiBzdHJva2Utd2lkdGg9IjEwIi8+PHJlY3QgeD0iMjcyIiB5PSIyOTIiIHdpZHRoPSI5NiIgaGVpZ2h0PSIxMjAiIHJ4PSI2IiBmaWxsPSIjOGY4ZjhmIiBzdHJva2U9IiNkZWRlZGUiIHN0cm9rZS13aWR0aD0iOCIvPjxyZWN0IHg9IjE0MCIgeT0iMjYyIiB3aWR0aD0iODgiIGhlaWdodD0iNjYiIHJ4PSI2IiBmaWxsPSIjZDlkOWQ5Ii8+PHJlY3QgeD0iNDEyIiB5PSIyNjIiIHdpZHRoPSI4OCIgaGVpZ2h0PSI2NiIgcng9IjYiIGZpbGw9IiNkOWQ5ZDkiLz48cGF0aCBkPSJNMTg0IDI2MnY2Nk0xNDAgMjk1aDg4TTQ1NiAyNjJ2NjZNNDEyIDI5NWg4OCIgc3Ryb2tlPSIjYmRiZGJkIiBzdHJva2Utd2lkdGg9IjYiLz48cmVjdCB4PSI2MCIgeT0iNDEyIiB3aWR0aD0iNTIwIiBoZWlnaHQ9IjE2IiByeD0iNiIgZmlsbD0iI2E5YTlhOSIvPjwvZz48L3N2Zz4=', + fleet: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NDAgNDgwIiB3aWR0aD0iNjQwIiBoZWlnaHQ9IjQ4MCI+PGcgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIj48ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSgxNTAgLTQwKSBzY2FsZSgwLjcyKSIgb3BhY2l0eT0iMC40NSI+PHJlY3QgeD0iMjE2IiB5PSIxMzIiIHdpZHRoPSIzNTAiIGhlaWdodD0iMTkyIiByeD0iMTQiIGZpbGw9IiNiZGJkYmQiIHN0cm9rZT0iI2RlZGVkZSIgc3Ryb2tlLXdpZHRoPSIxMCIvPjxyZWN0IHg9IjIzNiIgeT0iMTU0IiB3aWR0aD0iMzEwIiBoZWlnaHQ9IjE0OCIgcng9IjgiIGZpbGw9IiNjOWM5YzkiLz48cGF0aCBkPSJNMjU2IDE4MmgyNzBNMjU2IDIzMGgyNzBNMjU2IDI3OGgyNzAiIHN0cm9rZT0iI2JkYmRiZCIgc3Ryb2tlLXdpZHRoPSI2Ii8+PHBhdGggZD0iTTIxNiAzMjRWMTkwSDk2Yy0xMiAwLTIyIDgtMjYgMjBMNjAgMjQ0djgweiIgZmlsbD0iI2JkYmRiZCIgc3Ryb2tlPSIjZGVkZWRlIiBzdHJva2Utd2lkdGg9IjEwIi8+PHBhdGggZD0iTTEwNCAyMDZoODh2NTJIODR6IiBmaWxsPSIjZDlkOWQ5Ii8+PHJlY3QgeD0iNjAiIHk9IjMyNCIgd2lkdGg9IjUwNiIgaGVpZ2h0PSIxOCIgcng9IjYiIGZpbGw9IiNhOWE5YTkiLz48cmVjdCB4PSI1NiIgeT0iMjkyIiB3aWR0aD0iMTgiIGhlaWdodD0iMzAiIHJ4PSI1IiBmaWxsPSIjZDlkOWQ5Ii8+PGNpcmNsZSBjeD0iMTUwIiBjeT0iMzY2IiByPSI0MCIgZmlsbD0iIzhmOGY4ZiIgc3Ryb2tlPSIjZGVkZWRlIiBzdHJva2Utd2lkdGg9IjgiLz48Y2lyY2xlIGN4PSIxNTAiIGN5PSIzNjYiIHI9IjE2IiBmaWxsPSIjZDlkOWQ5Ii8+PGNpcmNsZSBjeD0iNDcwIiBjeT0iMzY2IiByPSI0MCIgZmlsbD0iIzhmOGY4ZiIgc3Ryb2tlPSIjZGVkZWRlIiBzdHJva2Utd2lkdGg9IjgiLz48Y2lyY2xlIGN4PSI0NzAiIGN5PSIzNjYiIHI9IjE2IiBmaWxsPSIjZDlkOWQ5Ii8+PC9nPjxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDgwIDEwKSBzY2FsZSgwLjgpIiBvcGFjaXR5PSIwLjciPjxyZWN0IHg9IjIxNiIgeT0iMTMyIiB3aWR0aD0iMzUwIiBoZWlnaHQ9IjE5MiIgcng9IjE0IiBmaWxsPSIjYmRiZGJkIiBzdHJva2U9IiNkZWRlZGUiIHN0cm9rZS13aWR0aD0iMTAiLz48cmVjdCB4PSIyMzYiIHk9IjE1NCIgd2lkdGg9IjMxMCIgaGVpZ2h0PSIxNDgiIHJ4PSI4IiBmaWxsPSIjYzljOWM5Ii8+PHBhdGggZD0iTTI1NiAxODJoMjcwTTI1NiAyMzBoMjcwTTI1NiAyNzhoMjcwIiBzdHJva2U9IiNiZGJkYmQiIHN0cm9rZS13aWR0aD0iNiIvPjxwYXRoIGQ9Ik0yMTYgMzI0VjE5MEg5NmMtMTIgMC0yMiA4LTI2IDIwTDYwIDI0NHY4MHoiIGZpbGw9IiNiZGJkYmQiIHN0cm9rZT0iI2RlZGVkZSIgc3Ryb2tlLXdpZHRoPSIxMCIvPjxwYXRoIGQ9Ik0xMDQgMjA2aDg4djUySDg0eiIgZmlsbD0iI2Q5ZDlkOSIvPjxyZWN0IHg9IjYwIiB5PSIzMjQiIHdpZHRoPSI1MDYiIGhlaWdodD0iMTgiIHJ4PSI2IiBmaWxsPSIjYTlhOWE5Ii8+PHJlY3QgeD0iNTYiIHk9IjI5MiIgd2lkdGg9IjE4IiBoZWlnaHQ9IjMwIiByeD0iNSIgZmlsbD0iI2Q5ZDlkOSIvPjxjaXJjbGUgY3g9IjE1MCIgY3k9IjM2NiIgcj0iNDAiIGZpbGw9IiM4ZjhmOGYiIHN0cm9rZT0iI2RlZGVkZSIgc3Ryb2tlLXdpZHRoPSI4Ii8+PGNpcmNsZSBjeD0iMTUwIiBjeT0iMzY2IiByPSIxNiIgZmlsbD0iI2Q5ZDlkOSIvPjxjaXJjbGUgY3g9IjQ3MCIgY3k9IjM2NiIgcj0iNDAiIGZpbGw9IiM4ZjhmOGYiIHN0cm9rZT0iI2RlZGVkZSIgc3Ryb2tlLXdpZHRoPSI4Ii8+PGNpcmNsZSBjeD0iNDcwIiBjeT0iMzY2IiByPSIxNiIgZmlsbD0iI2Q5ZDlkOSIvPjwvZz48ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwIDYwKSBzY2FsZSgwLjg4KSIgb3BhY2l0eT0iMS4wIj48cmVjdCB4PSIyMTYiIHk9IjEzMiIgd2lkdGg9IjM1MCIgaGVpZ2h0PSIxOTIiIHJ4PSIxNCIgZmlsbD0iI2JkYmRiZCIgc3Ryb2tlPSIjZGVkZWRlIiBzdHJva2Utd2lkdGg9IjEwIi8+PHJlY3QgeD0iMjM2IiB5PSIxNTQiIHdpZHRoPSIzMTAiIGhlaWdodD0iMTQ4IiByeD0iOCIgZmlsbD0iI2M5YzljOSIvPjxwYXRoIGQ9Ik0yNTYgMTgyaDI3ME0yNTYgMjMwaDI3ME0yNTYgMjc4aDI3MCIgc3Ryb2tlPSIjYmRiZGJkIiBzdHJva2Utd2lkdGg9IjYiLz48cGF0aCBkPSJNMjE2IDMyNFYxOTBIOTZjLTEyIDAtMjIgOC0yNiAyMEw2MCAyNDR2ODB6IiBmaWxsPSIjYmRiZGJkIiBzdHJva2U9IiNkZWRlZGUiIHN0cm9rZS13aWR0aD0iMTAiLz48cGF0aCBkPSJNMTA0IDIwNmg4OHY1Mkg4NHoiIGZpbGw9IiNkOWQ5ZDkiLz48cmVjdCB4PSI2MCIgeT0iMzI0IiB3aWR0aD0iNTA2IiBoZWlnaHQ9IjE4IiByeD0iNiIgZmlsbD0iI2E5YTlhOSIvPjxyZWN0IHg9IjU2IiB5PSIyOTIiIHdpZHRoPSIxOCIgaGVpZ2h0PSIzMCIgcng9IjUiIGZpbGw9IiNkOWQ5ZDkiLz48Y2lyY2xlIGN4PSIxNTAiIGN5PSIzNjYiIHI9IjQwIiBmaWxsPSIjOGY4ZjhmIiBzdHJva2U9IiNkZWRlZGUiIHN0cm9rZS13aWR0aD0iOCIvPjxjaXJjbGUgY3g9IjE1MCIgY3k9IjM2NiIgcj0iMTYiIGZpbGw9IiNkOWQ5ZDkiLz48Y2lyY2xlIGN4PSI0NzAiIGN5PSIzNjYiIHI9IjQwIiBmaWxsPSIjOGY4ZjhmIiBzdHJva2U9IiNkZWRlZGUiIHN0cm9rZS13aWR0aD0iOCIvPjxjaXJjbGUgY3g9IjQ3MCIgY3k9IjM2NiIgcj0iMTYiIGZpbGw9IiNkOWQ5ZDkiLz48L2c+PC9nPjwvc3ZnPg==', +}; + +/** + * Hosted images the data models and console config fall back to when a record has no + * photo. Treat them as "no photo" so the styled placeholder wins over the legacy PNG. + */ +const LEGACY_DEFAULT_PATTERN = /(no-avatar|vehicle-placeholder|image-file-icon|fallback-placeholder|default-extension-icon)\.(png|svg|jpe?g)/i; + +const CONFIG_KEYS = { + vehicle: 'vehicleImage', + driver: 'driverImage', + vendor: 'vendorImage', + contact: 'contactImage', + customer: 'customerImage', + fleet: 'fleetImage', + trailer: 'trailerImage', +}; + +export function getPlaceholderImage(type = 'contact') { + const overrides = config?.defaultValues?.placeholders ?? {}; + // A console that already replaced its hosted defaults with inline images wins; + // hosted PNG defaults are ignored so the silhouette set stays consistent. + const configured = config?.defaultValues?.[CONFIG_KEYS[type]]; + const inlineConfigured = typeof configured === 'string' && configured.startsWith('data:') ? configured : null; + + return overrides[type] || inlineConfigured || PLACEHOLDER_IMAGES[type] || PLACEHOLDER_IMAGES.contact; +} + +export function isDefaultImage(url) { + if (typeof url !== 'string' || url.trim() === '') { + return true; + } + + if (LEGACY_DEFAULT_PATTERN.test(url)) { + return true; + } + + const defaults = Object.values(config?.defaultValues ?? {}).filter((value) => typeof value === 'string'); + + return defaults.includes(url); +} + +/** + * The image to show for a record: its own photo, or the styled placeholder when the + * photo is missing or one of the legacy hosted defaults. + */ +export function resolveResourceImage(url, type) { + return isDefaultImage(url) ? getPlaceholderImage(type) : url; +} + +export default getPlaceholderImage; diff --git a/addon/utils/resource-descriptors/helpers.js b/addon/utils/resource-descriptors/helpers.js new file mode 100644 index 0000000..07e88f7 --- /dev/null +++ b/addon/utils/resource-descriptors/helpers.js @@ -0,0 +1,193 @@ +import { get } from '@ember/object'; +import { format } from 'date-fns'; +import { getPlaceholderImage, resolveResourceImage } from '../placeholder-images'; + +/** + * Small building blocks the shared resource descriptors use. They are + * written against attribute names, so an identity stub or an API payload + * renders the same as an Ember Data record, and they never import from the + * UI package: whatever they need from it is looked up as a service. + */ + +export const FLEETOPS_ENGINE = '@fleetbase/fleetops-engine'; + +export function present(value) { + return value !== undefined && value !== null && String(value).trim() !== ''; +} + +/** The first present value at any of the paths. */ +export function first(record, ...paths) { + if (!record) { + return null; + } + + for (const path of paths) { + const value = get(record, path); + + if (present(value)) { + return value; + } + } + + return null; +} + +/** A service from the owner, or null when the owner cannot provide it. */ +export function lookupService(owner, name) { + try { + return owner?.lookup?.(`service:${name}`) ?? null; + } catch { + return null; + } +} + +/** A related record read through its reference, never a promise proxy. */ +export function relation(owner, record, name) { + const registry = lookupService(owner, 'resource-registry'); + + if (registry && typeof registry.relationValue === 'function') { + return registry.relationValue(record, name); + } + + const value = record ? get(record, name) : null; + + if (value && typeof value === 'object' && typeof value.then === 'function') { + return value.content ?? null; + } + + return value ?? null; +} + +/** A photo with the styled placeholder as fallback. */ +export function photo(record, type, path = 'photo_url') { + return { url: resolveResourceImage(get(record, path), type), fallback: getPlaceholderImage(type), shape: type === 'vehicle' || type === 'trailer' ? 'square' : 'round' }; +} + +export function icon(name, iconClass) { + return iconClass ? { icon: name, iconClass } : { icon: name }; +} + +export function badge(key, iconName, label, extra = {}) { + return present(label) ? { key, icon: iconName, label, ...extra } : null; +} + +export function badges(...list) { + return list.filter(Boolean); +} + +export function fact(label, value, extra = {}) { + return { labelKey: `resource-summary.facts.${label}`, value, ...extra }; +} + +/** A fact that renders as the related record's pill when the record is loaded. */ +export function relatedFact(label, related, relatedType, fallbackValue) { + return { labelKey: `resource-summary.facts.${label}`, related: related ?? null, relatedType, value: fallbackValue ?? null }; +} + +/** + * A date the way the console writes one everywhere else (date-fns, not the + * browser locale), or null for anything that is not a date. + */ +export function dateLabel(value, pattern = 'dd MMM yyyy, HH:mm') { + if (!present(value)) { + return null; + } + + const date = value instanceof Date ? value : new Date(value); + + return Number.isNaN(date.getTime()) ? null : format(date, pattern); +} + +/** + * A contact's type the way a person reads it: "customer-fleet-ops:contact" + * and "customer" both become "Customer"; anything unreadable becomes null. + */ +export function typeLabel(value) { + if (!present(value)) { + return null; + } + + const head = String(value) + .split(':')[0] + .replace(/-fleet-ops$/, '') + .replace(/[-_]+/g, ' ') + .trim(); + + return head ? head.charAt(0).toUpperCase() + head.slice(1) : null; +} + +export function money(amount, currency) { + if (!present(amount)) { + return null; + } + + return present(currency) ? `${amount} ${currency}` : String(amount); +} + +export function join(parts, separator = ' ') { + return parts.filter(present).join(separator) || null; +} + +/** The resolved `*_type` for a polymorphic relation, from the record or a `_type` attribute. */ +export function polymorphicType(record, relationName, typeAttr) { + const related = record ? get(record, relationName) : null; + const modelName = related?.constructor?.modelName ?? related?.content?.constructor?.modelName; + + return modelName ?? (typeAttr && record ? get(record, typeAttr) : null) ?? null; +} + +/** The universe extension manager, through either of its service names. */ +export function extensionManager(owner) { + return lookupService(owner, 'universe/extension-manager') ?? lookupService(owner, 'universe')?.extensionManager ?? null; +} + +/** Whether an engine is installed, so a resource it owns has somewhere to open. */ +export function engineInstalled(owner, engineName = FLEETOPS_ENGINE) { + const manager = extensionManager(owner); + + try { + return Boolean(manager?.isInstalled?.(engineName)); + } catch { + return false; + } +} + +/** + * An opener that loads an engine on demand, then hands the record to one of + * its action services: the context panel when it has one, else its route + * transition. The engine bundle is only fetched when someone actually clicks, + * never when a page that merely shows the resource renders. + */ +export function engineOpener(owner, serviceName, { engineName = FLEETOPS_ENGINE, mode = 'panel' } = {}) { + return async (record) => { + const manager = extensionManager(owner); + + if (!record || !manager || typeof manager.ensureEngineLoaded !== 'function') { + return false; + } + + let engine; + + try { + engine = await manager.ensureEngineLoaded(engineName); + } catch { + return false; + } + + const service = engine?.lookup?.(`service:${serviceName}`); + + if (!service) { + return false; + } + + const view = (mode === 'panel' ? (service.panel?.view ?? service.transition?.view) : null) ?? (mode === 'transition' ? service.transition?.view : null); + + if (typeof view !== 'function') { + return false; + } + + view.call(service, record); + + return true; + }; +} diff --git a/addon/utils/resource-descriptors/index.js b/addon/utils/resource-descriptors/index.js new file mode 100644 index 0000000..dd90d3e --- /dev/null +++ b/addon/utils/resource-descriptors/index.js @@ -0,0 +1,2 @@ +export { buildSharedResourceDescriptors, SHARED_RESOURCE_KEYS, guardOrder, default } from './shared'; +export * from './helpers'; diff --git a/addon/utils/resource-descriptors/shared.js b/addon/utils/resource-descriptors/shared.js new file mode 100644 index 0000000..9120409 --- /dev/null +++ b/addon/utils/resource-descriptors/shared.js @@ -0,0 +1,296 @@ +import { get } from '@ember/object'; +import { first, present, relation, photo, icon, badge, badges, fact, relatedFact, join, dateLabel, typeLabel, polymorphicType, engineOpener, engineInstalled } from './helpers'; + +/** + * The resources every Fleetbase extension can see through this package's + * models: drivers, vehicles, customers, contacts, places, orders, vendors and + * fleets. A descriptor teaches the console's resource identity components + * how to show one of them. Opening loads the FleetOps engine on demand, so a + * Storefront or Ledger page that lists drivers never pays for FleetOps until + * someone clicks one. FleetOps itself replaces these with its own openers + * when it boots. + */ +export const SHARED_RESOURCE_KEYS = ['driver', 'vehicle', 'customer', 'contact', 'place', 'order', 'vendor', 'fleet']; + +function onlineFlag(record) { + const online = get(record, 'online'); + + return typeof online === 'boolean' ? online : undefined; +} + +export function buildSharedResourceDescriptors(owner) { + const canOpen = () => engineInstalled(owner); + + return [ + { + key: 'driver', + labelKey: 'resource.driver', + icon: 'id-card', + modelNames: ['driver'], + aliases: ['attachable-driver', 'facilitator-driver', 'fleet-driver'], + polymorphicTypes: ['fleet-ops:driver', 'Fleetbase\\FleetOps\\Models\\Driver'], + permission: 'fleet-ops view driver', + statusTones: { + available: 'text-green-500', + active: 'text-green-500', + on_duty: 'text-green-500', + busy: 'text-yellow-500', + assigned: 'text-yellow-500', + unavailable: 'text-gray-400', + offline: 'text-gray-400', + suspended: 'text-red-500', + }, + title: (driver) => first(driver, 'name', 'displayName', 'display_name', 'public_id'), + identifier: (driver) => first(driver, 'phone', 'email'), + image: (driver) => photo(driver, 'driver'), + online: onlineFlag, + status: (driver) => first(driver, 'status'), + badges: (driver) => { + const vehicle = relation(owner, driver, 'vehicle'); + const label = first(vehicle, 'displayName', 'display_name', 'name') ?? first(driver, 'vehicle_assigned.display_name', 'vehicle_name'); + + return badges(badge('vehicle', 'car', label, { relatedType: 'vehicle', relatedId: vehicle?.id ?? get(driver, 'vehicle_uuid') })); + }, + selectDetails: (driver) => [first(driver, 'phone'), first(driver, 'email')], + facts: (driver) => [ + fact('phone', first(driver, 'phone')), + fact('email', first(driver, 'email')), + relatedFact('vehicle', relation(owner, driver, 'vehicle'), 'vehicle', first(driver, 'vehicle_name')), + fact( + 'licence', + join( + [ + first(driver, 'drivers_license_number'), + dateLabel(get(driver, 'license_expiry'), 'dd MMM yyyy') ? `expires ${dateLabel(get(driver, 'license_expiry'), 'dd MMM yyyy')}` : null, + ], + ' · ' + ) + ), + relatedFact('vendor', relation(owner, driver, 'vendor'), 'vendor', first(driver, 'vendor_name')), + fact('status', first(driver, 'status'), { format: 'humanize' }), + ], + canOpen, + open: engineOpener(owner, 'driver-actions'), + }, + { + key: 'vehicle', + labelKey: 'resource.vehicle', + icon: 'truck', + modelNames: ['vehicle'], + aliases: ['attachable-vehicle', 'maintenance-subject-vehicle'], + polymorphicTypes: ['fleet-ops:vehicle', 'Fleetbase\\FleetOps\\Models\\Vehicle'], + permission: 'fleet-ops view vehicle', + statusTones: { + available: 'text-green-500', + active: 'text-green-500', + in_service: 'text-green-500', + maintenance: 'text-yellow-500', + unavailable: 'text-gray-400', + inactive: 'text-gray-400', + out_of_service: 'text-red-500', + }, + title: (vehicle) => + first(vehicle, 'displayName', 'display_name', 'name', 'yearMakeModel', 'public_id') ?? join([get(vehicle, 'year'), get(vehicle, 'make'), get(vehicle, 'model')]), + identifier: (vehicle) => first(vehicle, 'plate_number', 'call_sign', 'vin', 'serial_number'), + image: (vehicle) => photo(vehicle, 'vehicle'), + online: onlineFlag, + status: (vehicle) => first(vehicle, 'status'), + badges: (vehicle) => { + const driver = relation(owner, vehicle, 'driver'); + + return badges( + badge('plate', 'id-card', first(vehicle, 'plate_number', 'call_sign', 'vehicle_number')), + badge('driver', 'user', first(driver, 'name', 'displayName') ?? first(vehicle, 'driver_name'), { + relatedType: 'driver', + relatedId: driver?.id ?? get(vehicle, 'driver_uuid'), + }) + ); + }, + selectDetails: (vehicle) => [first(vehicle, 'plate_number', 'vin', 'serial_number', 'call_sign'), first(vehicle, 'driver_name')], + facts: (vehicle) => { + const driver = relation(owner, vehicle, 'driver'); + const trailers = relation(owner, vehicle, 'trailers'); + const trailer = trailers ? (typeof trailers.objectAt === 'function' ? trailers.objectAt(0) : trailers[0]) : null; + const extra = trailers?.length > 1 ? ` (+${trailers.length - 1})` : ''; + + return [ + fact('make-model', join([get(vehicle, 'year'), get(vehicle, 'make'), get(vehicle, 'model'), get(vehicle, 'trim')])), + fact('plate', join([first(vehicle, 'plate_number'), first(vehicle, 'vin')], ' · ')), + relatedFact('driver', driver, 'driver', first(vehicle, 'driver_name')), + trailer ? { ...relatedFact('trailer', trailer, 'trailer', first(trailer, 'displayName', 'display_name', 'name')), suffix: extra } : fact('trailer', null), + fact('odometer', present(get(vehicle, 'odometer')) ? join([get(vehicle, 'odometer'), get(vehicle, 'odometer_unit')]) : null), + fact('status', first(vehicle, 'status'), { format: 'humanize' }), + ]; + }, + canOpen, + open: engineOpener(owner, 'vehicle-actions'), + }, + { + key: 'customer', + labelKey: 'resource.customer', + icon: 'user-tag', + modelNames: ['customer'], + aliases: ['facilitator-customer'], + polymorphicTypes: ['fleet-ops:customer', 'Fleetbase\\FleetOps\\Models\\Customer'], + permission: 'fleet-ops view contact', + title: (customer) => first(customer, 'name', 'public_id'), + identifier: (customer) => typeLabel(first(customer, 'customer_type', 'type')) ?? 'Customer', + image: (customer) => photo(customer, 'customer'), + selectDetails: (customer) => [first(customer, 'email'), first(customer, 'phone')], + facts: (customer) => [ + fact('type', typeLabel(first(customer, 'customer_type', 'type'))), + fact('email', first(customer, 'email')), + fact('phone', first(customer, 'phone')), + fact('address', first(customer, 'address', 'address_street')), + relatedFact('place', relation(owner, customer, 'place'), 'place', first(customer, 'place.address')), + ], + canOpen, + open: engineOpener(owner, 'customer-actions'), + }, + { + key: 'contact', + labelKey: 'resource.contact', + icon: 'address-book', + modelNames: ['contact'], + aliases: ['facilitator-contact', 'customer-contact'], + polymorphicTypes: ['fleet-ops:contact', 'Fleetbase\\FleetOps\\Models\\Contact'], + permission: 'fleet-ops view contact', + title: (contact) => first(contact, 'name', 'public_id'), + identifier: (contact) => first(contact, 'title') ?? typeLabel(first(contact, 'type')), + image: (contact) => photo(contact, 'contact'), + selectDetails: (contact) => [first(contact, 'email'), first(contact, 'phone')], + facts: (contact) => [ + fact('title', first(contact, 'title')), + fact('email', first(contact, 'email')), + fact('phone', first(contact, 'phone')), + relatedFact('place', relation(owner, contact, 'place'), 'place', first(contact, 'address', 'place.address')), + relatedFact('user', relation(owner, contact, 'user'), 'user', first(contact, 'user.name')), + ], + canOpen, + open: engineOpener(owner, 'contact-actions'), + }, + { + key: 'place', + labelKey: 'resource.place', + icon: 'location-dot', + modelNames: ['place'], + polymorphicTypes: ['fleet-ops:place', 'Fleetbase\\FleetOps\\Models\\Place'], + permission: 'fleet-ops view place', + title: (place) => first(place, 'displayName', 'name', 'address', 'street1', 'public_id'), + identifier: (place) => join([first(place, 'city'), first(place, 'country')], ', ') ?? first(place, 'public_id'), + image: () => icon('location-dot'), + selectDetails: (place) => [first(place, 'address', 'street1'), join([first(place, 'city'), first(place, 'country')], ', ')], + facts: (place) => [ + fact('address', first(place, 'address', 'street1')), + fact('phone', first(place, 'phone')), + fact('type', first(place, 'type'), { format: 'humanize' }), + relatedFact('vendor', relation(owner, place, 'vendor'), 'vendor', first(place, 'vendor_name')), + fact('coordinates', first(place, 'positionString') ?? (present(get(place, 'latitude')) ? `${get(place, 'latitude')}, ${get(place, 'longitude')}` : null)), + ], + canOpen, + open: engineOpener(owner, 'place-actions'), + }, + { + key: 'order', + labelKey: 'resource.order', + icon: 'box', + modelNames: ['order'], + polymorphicTypes: ['fleet-ops:order', 'Fleetbase\\FleetOps\\Models\\Order'], + permission: 'fleet-ops view order', + statusTones: { + created: 'text-gray-400', + preparing: 'text-yellow-500', + dispatched: 'text-yellow-500', + started: 'text-yellow-500', + in_progress: 'text-yellow-500', + enroute: 'text-yellow-500', + completed: 'text-green-500', + delivered: 'text-green-500', + canceled: 'text-red-500', + cancelled: 'text-red-500', + failed: 'text-red-500', + }, + title: (order) => first(order, 'tracking', 'tracking_number.tracking_number', 'public_id'), + identifier: (order) => first(order, 'public_id', 'internal_id'), + image: () => icon('box'), + status: (order) => first(order, 'status'), + selectDetails: (order) => [first(order, 'public_id'), first(order, 'status')], + facts: (order) => [ + fact('status', first(order, 'status'), { format: 'humanize' }), + relatedFact('customer', relation(owner, order, 'customer'), polymorphicType(order, 'customer', 'customer_type'), first(order, 'customer_name')), + relatedFact('driver', relation(owner, order, 'driver_assigned'), 'driver', first(order, 'driver_name')), + fact('route', join([first(order, 'pickupName', 'pickup_name'), first(order, 'dropoffName', 'dropoff_name')], ' → ')), + fact('scheduled', get(order, 'scheduled_at'), { format: 'date' }), + fact('order-config', first(order, 'order_config.name', 'type')), + ], + canOpen, + open: guardOrder(engineOpener(owner, 'order-actions')), + }, + { + key: 'vendor', + labelKey: 'resource.vendor', + icon: 'building', + modelNames: ['vendor'], + aliases: ['facilitator-vendor', 'customer-vendor'], + polymorphicTypes: ['fleet-ops:vendor', 'Fleetbase\\FleetOps\\Models\\Vendor'], + permission: 'fleet-ops view vendor', + title: (vendor) => first(vendor, 'name', 'public_id'), + identifier: (vendor) => first(vendor, 'prettyType', 'internal_id', 'business_id'), + image: (vendor) => photo(vendor, 'vendor', 'logo_url'), + status: (vendor) => first(vendor, 'status'), + selectDetails: (vendor) => [first(vendor, 'email'), first(vendor, 'phone')], + facts: (vendor) => [ + fact('type', first(vendor, 'prettyType', 'type'), { format: 'humanize' }), + fact('email', first(vendor, 'email')), + fact('phone', first(vendor, 'phone')), + fact('website', first(vendor, 'website_url')), + fact('address', first(vendor, 'address', 'place.address', 'address_street')), + fact('country', first(vendor, 'country')), + ], + canOpen, + open: engineOpener(owner, 'vendor-actions'), + }, + { + key: 'fleet', + labelKey: 'resource.fleet', + icon: 'layer-group', + modelNames: ['fleet'], + polymorphicTypes: ['fleet-ops:fleet', 'Fleetbase\\FleetOps\\Models\\Fleet'], + permission: 'fleet-ops view fleet', + title: (fleet) => first(fleet, 'name', 'public_id'), + identifier: (fleet) => first(fleet, 'task', 'public_id'), + image: (fleet) => photo(fleet, 'fleet'), + status: (fleet) => first(fleet, 'status'), + selectDetails: (fleet) => [first(fleet, 'task'), present(get(fleet, 'drivers_count')) ? `${get(fleet, 'drivers_count')} drivers` : null], + facts: (fleet) => [ + fact('task', first(fleet, 'task')), + relatedFact('service-area', relation(owner, fleet, 'service_area'), 'service-area', first(fleet, 'service_area.name')), + relatedFact('zone', relation(owner, fleet, 'zone'), 'zone', first(fleet, 'zone.name')), + relatedFact('vendor', relation(owner, fleet, 'vendor'), 'vendor', first(fleet, 'vendor.name')), + fact('drivers', present(get(fleet, 'drivers_count')) ? `${get(fleet, 'drivers_online_count') ?? 0} online of ${get(fleet, 'drivers_count')}` : null), + fact('vehicles', present(get(fleet, 'vehicles_count')) ? `${get(fleet, 'vehicles_online_count') ?? 0} online of ${get(fleet, 'vehicles_count')}` : null), + ], + canOpen, + open: engineOpener(owner, 'fleet-actions'), + }, + ].map((descriptor) => ({ + ...descriptor, + components: { identity: `cell/${descriptor.key}-identity`, ...(descriptor.components ?? {}) }, + })); +} + +/** + * An order row may only know its order by name (an identity stub) and a + * stub has nothing to open; a saved order must carry an id. + */ +export function guardOrder(open) { + return (order, ...rest) => { + if (!order || order.isIdentityStub || !first(order, 'id', 'uuid')) { + return false; + } + + return open(order, ...rest); + }; +} + +export default buildSharedResourceDescriptors; diff --git a/app/components/cell/contact-identity.js b/app/components/cell/contact-identity.js new file mode 100644 index 0000000..3a97c2a --- /dev/null +++ b/app/components/cell/contact-identity.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/components/cell/contact-identity'; diff --git a/app/components/cell/customer-identity.js b/app/components/cell/customer-identity.js new file mode 100644 index 0000000..7832618 --- /dev/null +++ b/app/components/cell/customer-identity.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/components/cell/customer-identity'; diff --git a/app/components/cell/driver-identity.js b/app/components/cell/driver-identity.js new file mode 100644 index 0000000..431d0ec --- /dev/null +++ b/app/components/cell/driver-identity.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/components/cell/driver-identity'; diff --git a/app/components/cell/fleet-identity.js b/app/components/cell/fleet-identity.js new file mode 100644 index 0000000..263b22b --- /dev/null +++ b/app/components/cell/fleet-identity.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/components/cell/fleet-identity'; diff --git a/app/components/cell/order-identity.js b/app/components/cell/order-identity.js new file mode 100644 index 0000000..4bb3613 --- /dev/null +++ b/app/components/cell/order-identity.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/components/cell/order-identity'; diff --git a/app/components/cell/place-identity.js b/app/components/cell/place-identity.js new file mode 100644 index 0000000..eb31bd1 --- /dev/null +++ b/app/components/cell/place-identity.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/components/cell/place-identity'; diff --git a/app/components/cell/vehicle-identity.js b/app/components/cell/vehicle-identity.js new file mode 100644 index 0000000..9e54d46 --- /dev/null +++ b/app/components/cell/vehicle-identity.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/components/cell/vehicle-identity'; diff --git a/app/components/cell/vendor-identity.js b/app/components/cell/vendor-identity.js new file mode 100644 index 0000000..9ef5d6c --- /dev/null +++ b/app/components/cell/vendor-identity.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/components/cell/vendor-identity'; diff --git a/app/components/contact/pill.js b/app/components/contact/pill.js new file mode 100644 index 0000000..c7a297a --- /dev/null +++ b/app/components/contact/pill.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/components/contact/pill'; diff --git a/app/components/contact/summary.js b/app/components/contact/summary.js new file mode 100644 index 0000000..7e7c800 --- /dev/null +++ b/app/components/contact/summary.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/components/contact/summary'; diff --git a/app/components/customer/pill.js b/app/components/customer/pill.js new file mode 100644 index 0000000..aea27bb --- /dev/null +++ b/app/components/customer/pill.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/components/customer/pill'; diff --git a/app/components/customer/summary.js b/app/components/customer/summary.js new file mode 100644 index 0000000..899a942 --- /dev/null +++ b/app/components/customer/summary.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/components/customer/summary'; diff --git a/app/components/driver/pill.js b/app/components/driver/pill.js new file mode 100644 index 0000000..3490441 --- /dev/null +++ b/app/components/driver/pill.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/components/driver/pill'; diff --git a/app/components/driver/summary.js b/app/components/driver/summary.js new file mode 100644 index 0000000..e96be83 --- /dev/null +++ b/app/components/driver/summary.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/components/driver/summary'; diff --git a/app/components/fleet/pill.js b/app/components/fleet/pill.js new file mode 100644 index 0000000..d849910 --- /dev/null +++ b/app/components/fleet/pill.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/components/fleet/pill'; diff --git a/app/components/fleet/summary.js b/app/components/fleet/summary.js new file mode 100644 index 0000000..e8a90c5 --- /dev/null +++ b/app/components/fleet/summary.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/components/fleet/summary'; diff --git a/app/components/order/pill.js b/app/components/order/pill.js new file mode 100644 index 0000000..f3ceb03 --- /dev/null +++ b/app/components/order/pill.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/components/order/pill'; diff --git a/app/components/order/summary.js b/app/components/order/summary.js new file mode 100644 index 0000000..24bf123 --- /dev/null +++ b/app/components/order/summary.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/components/order/summary'; diff --git a/app/components/place/pill.js b/app/components/place/pill.js new file mode 100644 index 0000000..2757d3a --- /dev/null +++ b/app/components/place/pill.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/components/place/pill'; diff --git a/app/components/place/summary.js b/app/components/place/summary.js new file mode 100644 index 0000000..3ee08c9 --- /dev/null +++ b/app/components/place/summary.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/components/place/summary'; diff --git a/app/components/select-option/contact.js b/app/components/select-option/contact.js new file mode 100644 index 0000000..fd9d008 --- /dev/null +++ b/app/components/select-option/contact.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/components/select-option/contact'; diff --git a/app/components/select-option/customer.js b/app/components/select-option/customer.js new file mode 100644 index 0000000..26fd3c3 --- /dev/null +++ b/app/components/select-option/customer.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/components/select-option/customer'; diff --git a/app/components/select-option/driver.js b/app/components/select-option/driver.js new file mode 100644 index 0000000..a08f3e0 --- /dev/null +++ b/app/components/select-option/driver.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/components/select-option/driver'; diff --git a/app/components/select-option/fleet.js b/app/components/select-option/fleet.js new file mode 100644 index 0000000..3e6eba6 --- /dev/null +++ b/app/components/select-option/fleet.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/components/select-option/fleet'; diff --git a/app/components/select-option/order.js b/app/components/select-option/order.js new file mode 100644 index 0000000..34dc932 --- /dev/null +++ b/app/components/select-option/order.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/components/select-option/order'; diff --git a/app/components/select-option/place.js b/app/components/select-option/place.js new file mode 100644 index 0000000..d78d889 --- /dev/null +++ b/app/components/select-option/place.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/components/select-option/place'; diff --git a/app/components/select-option/vehicle.js b/app/components/select-option/vehicle.js new file mode 100644 index 0000000..3c49271 --- /dev/null +++ b/app/components/select-option/vehicle.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/components/select-option/vehicle'; diff --git a/app/components/select-option/vendor.js b/app/components/select-option/vendor.js new file mode 100644 index 0000000..da45f9c --- /dev/null +++ b/app/components/select-option/vendor.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/components/select-option/vendor'; diff --git a/app/components/vehicle/pill.js b/app/components/vehicle/pill.js new file mode 100644 index 0000000..e1f78a1 --- /dev/null +++ b/app/components/vehicle/pill.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/components/vehicle/pill'; diff --git a/app/components/vehicle/summary.js b/app/components/vehicle/summary.js new file mode 100644 index 0000000..5dae0f5 --- /dev/null +++ b/app/components/vehicle/summary.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/components/vehicle/summary'; diff --git a/app/components/vendor/pill.js b/app/components/vendor/pill.js new file mode 100644 index 0000000..fa810e5 --- /dev/null +++ b/app/components/vendor/pill.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/components/vendor/pill'; diff --git a/app/components/vendor/summary.js b/app/components/vendor/summary.js new file mode 100644 index 0000000..b9f4eee --- /dev/null +++ b/app/components/vendor/summary.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-data/components/vendor/summary'; diff --git a/app/instance-initializers/register-shared-resource-descriptors.js b/app/instance-initializers/register-shared-resource-descriptors.js new file mode 100644 index 0000000..3a54173 --- /dev/null +++ b/app/instance-initializers/register-shared-resource-descriptors.js @@ -0,0 +1 @@ +export { default, initialize } from '@fleetbase/fleetops-data/instance-initializers/register-shared-resource-descriptors'; diff --git a/package.json b/package.json index 527592d..365edaf 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@fleetbase/fleetops-data", "version": "0.2.3", - "description": "Fleetbase Fleet-Ops based models, serializers, transforms, adapters and GeoJson utility functions.", + "description": "Fleetbase Fleet-Ops based models, serializers, transforms, adapters, GeoJson utilities and the shared resource identity descriptors.", "keywords": [ "fleetbase-data", "fleetops-data", diff --git a/tests/integration/components/resource-wrappers-test.js b/tests/integration/components/resource-wrappers-test.js new file mode 100644 index 0000000..d83997a --- /dev/null +++ b/tests/integration/components/resource-wrappers-test.js @@ -0,0 +1,58 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { helper } from '@ember/component/helper'; +import { SHARED_RESOURCE_KEYS } from '@fleetbase/fleetops-data/utils/resource-descriptors'; + +/** + * Each shared resource ships a pill, a summary, an identity cell and a + * select option that only name the resource type and hand everything else + * to the generic components the UI package provides. The UI package is not + * part of this addon, so those generics are stubbed here with templates + * that echo what they were given. + */ +module('Integration | Component | resource wrappers', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.owner.register( + 'helper:or', + helper(([a, b, c]) => a ?? b ?? c) + ); + this.owner.register('template:components/resource/pill', hbs`{{@resource.name}}`); + this.owner.register('template:components/resource/summary', hbs`{{@resource.name}}`); + this.owner.register('template:components/resource/select-option', hbs`{{@option.name}}`); + this.owner.register('template:components/table/cell/identity', hbs`{{@row.name}}`); + }); + + for (const key of SHARED_RESOURCE_KEYS) { + test(`${key} wrappers resolve and pass the ${key} resource type through`, async function (assert) { + this.set('record', { name: `A ${key}` }); + this.set('pill', `${key}/pill`); + this.set('summary', `${key}/summary`); + this.set('cell', `cell/${key}-identity`); + this.set('option', `select-option/${key}`); + + await render(hbs` + {{component this.pill resource=this.record}} + {{component this.summary resource=this.record}} + {{component this.cell row=this.record}} + {{component this.option option=this.record}} + `); + + assert.dom('[data-test-pill]').hasAttribute('data-type', key).hasText(`A ${key}`); + assert.dom('[data-test-summary]').hasAttribute('data-type', key).hasText(`A ${key}`); + assert.dom('[data-test-cell]').hasAttribute('data-type', key).hasText(`A ${key}`); + assert.dom('[data-test-option]').hasAttribute('data-type', key).hasText(`A ${key}`); + }); + } + + test('a pill still accepts the resource under its own name, as it always did', async function (assert) { + this.set('driver', { name: 'Ada Driver' }); + + await render(hbs``); + + assert.dom('[data-test-pill]').hasAttribute('data-type', 'driver').hasText('Ada Driver'); + }); +}); diff --git a/tests/unit/instance-initializers/register-shared-resource-descriptors-test.js b/tests/unit/instance-initializers/register-shared-resource-descriptors-test.js new file mode 100644 index 0000000..4b0f4d4 --- /dev/null +++ b/tests/unit/instance-initializers/register-shared-resource-descriptors-test.js @@ -0,0 +1,98 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; +import { initialize } from '@fleetbase/fleetops-data/instance-initializers/register-shared-resource-descriptors'; +import { SHARED_RESOURCE_KEYS } from '@fleetbase/fleetops-data/utils/resource-descriptors'; + +/** A stand-in for ember-ui's resource-registry service that remembers what it holds. */ +function fakeRegistry(existingKeys = []) { + const held = new Map(existingKeys.map((key) => [key, { key, open: 'kept' }])); + + return { + held, + registerDescriptors(list) { + list.forEach((descriptor) => held.set(descriptor.key, descriptor)); + }, + getDescriptor(key) { + return held.get(key) ?? null; + }, + }; +} + +function ownerWith(registry) { + return { lookup: (name) => (name === 'service:resource-registry' ? registry : null) }; +} + +module('Unit | Instance Initializer | register-shared-resource-descriptors', function (hooks) { + setupTest(hooks); + + test('it registers every shared descriptor on an empty registry', function (assert) { + const registry = fakeRegistry(); + + initialize(ownerWith(registry)); + + assert.deepEqual([...registry.held.keys()], SHARED_RESOURCE_KEYS); + assert.strictEqual(typeof registry.held.get('driver').open, 'function'); + }); + + test('it leaves descriptors another package already registered alone', function (assert) { + const registry = fakeRegistry(['driver', 'order']); + + initialize(ownerWith(registry)); + + assert.strictEqual(registry.held.get('driver').open, 'kept', 'the FleetOps driver descriptor survives'); + assert.strictEqual(registry.held.get('order').open, 'kept', 'the FleetOps order descriptor survives'); + assert.strictEqual(registry.held.size, SHARED_RESOURCE_KEYS.length, 'the missing ones were filled in'); + }); + + test('running twice registers nothing the second time', function (assert) { + const registry = fakeRegistry(); + initialize(ownerWith(registry)); + const first = registry.held.get('vehicle'); + + initialize(ownerWith(registry)); + + assert.strictEqual(registry.held.get('vehicle'), first, 'the same descriptor object remains'); + }); + + test('a registry that only lists its descriptors is read through that list', function (assert) { + const held = [{ key: 'place' }]; + const registry = { + descriptors: held, + registerDescriptors(list) { + held.push(...list); + }, + }; + + initialize(ownerWith(registry)); + + assert.strictEqual(held.filter((descriptor) => descriptor.key === 'place').length, 1, 'place was kept'); + assert.strictEqual(held.length, SHARED_RESOURCE_KEYS.length); + }); + + test('a registry whose lookup throws is treated as empty', function (assert) { + const registered = []; + const registry = { + registerDescriptors(list) { + registered.push(...list); + }, + getDescriptor() { + throw new Error('index not built'); + }, + }; + + initialize(ownerWith(registry)); + + assert.strictEqual(registered.length, SHARED_RESOURCE_KEYS.length); + }); + + test('it does nothing on a host without the registry service', function (assert) { + initialize({ lookup: () => null }); + initialize({ + lookup() { + throw new Error('no such service'); + }, + }); + + assert.ok(true, 'nothing thrown'); + }); +}); diff --git a/tests/unit/utils/placeholder-images-test.js b/tests/unit/utils/placeholder-images-test.js new file mode 100644 index 0000000..d555cfe --- /dev/null +++ b/tests/unit/utils/placeholder-images-test.js @@ -0,0 +1,63 @@ +import { module, test } from 'qunit'; +import ENV from 'dummy/config/environment'; +import getPlaceholderImage, { PLACEHOLDER_IMAGES, isDefaultImage, resolveResourceImage } from '@fleetbase/fleetops-data/utils/placeholder-images'; + +const INLINE = 'data:image/svg+xml;base64,PHN2Zy8+'; + +module('Unit | Utility | placeholder-images', function (hooks) { + let defaultValues; + + hooks.beforeEach(function () { + defaultValues = ENV.defaultValues; + ENV.defaultValues = { ...defaultValues }; + }); + + hooks.afterEach(function () { + ENV.defaultValues = defaultValues; + }); + + test('it ships one inline SVG silhouette per resource type', function (assert) { + for (const type of ['trailer', 'vehicle', 'driver', 'fleet', 'vendor', 'contact', 'customer']) { + assert.ok(PLACEHOLDER_IMAGES[type].startsWith('data:image/svg+xml;base64,'), `${type} placeholder is an inline data URI`); + assert.ok(atob(PLACEHOLDER_IMAGES[type].split(',')[1]).includes(' wins'); + + delete ENV.defaultValues.placeholders; + ENV.defaultValues.vehicleImage = INLINE; + assert.strictEqual(getPlaceholderImage('vehicle'), INLINE, 'an inline defaultValues.Image wins'); + + assert.strictEqual(getPlaceholderImage('vendor'), PLACEHOLDER_IMAGES.vendor, 'a hosted defaultValues.Image is ignored'); + + delete ENV.defaultValues; + assert.strictEqual(getPlaceholderImage('driver'), PLACEHOLDER_IMAGES.driver, 'no defaultValues at all is fine'); + }); + + test('it treats blank photos, the legacy hosted defaults and the configured defaults as "no photo"', function (assert) { + assert.true(isDefaultImage(undefined)); + assert.true(isDefaultImage('')); + assert.true(isDefaultImage(42)); + assert.true(isDefaultImage('https://s3.ap-southeast-1.amazonaws.com/flb-assets/static/no-avatar.png')); + assert.true(isDefaultImage('https://s3.ap-southeast-1.amazonaws.com/flb-assets/static/vehicle-placeholder.png')); + assert.true(isDefaultImage('https://flb-assets.s3.ap-southeast-1.amazonaws.com/static/image-file-icon.png')); + assert.true(isDefaultImage(ENV.defaultValues.driverImage), 'the console default is not a real photo'); + assert.false(isDefaultImage('https://cdn.example/uploads/truck-42.jpg')); + + delete ENV.defaultValues; + assert.false(isDefaultImage('https://cdn.example/uploads/truck-42.jpg'), 'no defaultValues at all is fine'); + }); + + test('it resolves a record image to its own photo or the typed placeholder', function (assert) { + assert.strictEqual(resolveResourceImage('https://cdn.example/uploads/truck-42.jpg', 'vehicle'), 'https://cdn.example/uploads/truck-42.jpg'); + assert.strictEqual(resolveResourceImage('https://s3.ap-southeast-1.amazonaws.com/flb-assets/static/no-avatar.png', 'driver'), PLACEHOLDER_IMAGES.driver); + assert.strictEqual(resolveResourceImage(null, 'customer'), PLACEHOLDER_IMAGES.customer); + }); +}); diff --git a/tests/unit/utils/resource-descriptors/helpers-test.js b/tests/unit/utils/resource-descriptors/helpers-test.js new file mode 100644 index 0000000..b633d50 --- /dev/null +++ b/tests/unit/utils/resource-descriptors/helpers-test.js @@ -0,0 +1,207 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; +import { + present, + first, + lookupService, + relation, + photo, + icon, + badge, + badges, + fact, + relatedFact, + dateLabel, + typeLabel, + money, + join, + polymorphicType, + extensionManager, + engineInstalled, + engineOpener, + FLEETOPS_ENGINE, +} from '@fleetbase/fleetops-data/utils/resource-descriptors/helpers'; +import { PLACEHOLDER_IMAGES } from '@fleetbase/fleetops-data/utils/placeholder-images'; + +module('Unit | Utility | resource-descriptors/helpers', function (hooks) { + setupTest(hooks); + + test('present and first read attributes by path and skip blanks', function (assert) { + assert.false(present(undefined)); + assert.false(present(null)); + assert.false(present(' ')); + assert.true(present(0)); + assert.true(present('x')); + + assert.strictEqual(first(null, 'name'), null, 'no record'); + assert.strictEqual(first({ name: '', nested: { label: 'Deep' } }, 'name', 'nested.label'), 'Deep'); + assert.strictEqual(first({ name: '' }, 'name', 'missing'), null); + }); + + test('lookupService returns null when the owner cannot provide the service', function (assert) { + assert.strictEqual(lookupService(null, 'anything'), null); + assert.strictEqual(lookupService({ lookup: () => null }, 'anything'), null); + assert.strictEqual( + lookupService( + { + lookup() { + throw new Error('nope'); + }, + }, + 'anything' + ), + null + ); + + const service = {}; + assert.strictEqual(lookupService({ lookup: () => service }, 'anything'), service); + }); + + test('relation goes through the resource registry when there is one', function (assert) { + const vehicle = { id: 'vehicle_1' }; + const registry = { relationValue: (record, name) => (name === 'vehicle' ? vehicle : null) }; + const owner = { lookup: (name) => (name === 'service:resource-registry' ? registry : null) }; + + assert.strictEqual(relation(owner, { vehicle: 'ignored' }, 'vehicle'), vehicle); + }); + + test('relation falls back to the attribute, unwrapping a promise proxy, without a registry', function (assert) { + const owner = { lookup: () => null }; + const vehicle = { id: 'vehicle_1' }; + + assert.strictEqual(relation(owner, null, 'vehicle'), null, 'no record'); + assert.strictEqual(relation(owner, {}, 'vehicle'), null, 'no relation'); + assert.strictEqual(relation(owner, { vehicle }, 'vehicle'), vehicle, 'a plain value'); + assert.strictEqual(relation(owner, { vehicle: { then() {}, content: vehicle } }, 'vehicle'), vehicle, 'a resolved proxy'); + assert.strictEqual(relation(owner, { vehicle: { then() {} } }, 'vehicle'), null, 'an unresolved proxy'); + }); + + test('photo pairs the record photo with the typed placeholder and shape', function (assert) { + assert.deepEqual(photo({ photo_url: 'https://cdn.test/a.png' }, 'driver'), { url: 'https://cdn.test/a.png', fallback: PLACEHOLDER_IMAGES.driver, shape: 'round' }); + assert.deepEqual(photo({}, 'vehicle'), { url: PLACEHOLDER_IMAGES.vehicle, fallback: PLACEHOLDER_IMAGES.vehicle, shape: 'square' }); + assert.strictEqual(photo({}, 'trailer').shape, 'square'); + assert.strictEqual(photo({ logo_url: 'https://cdn.test/logo.png' }, 'vendor', 'logo_url').url, 'https://cdn.test/logo.png'); + }); + + test('icon, badge, badges, fact and relatedFact build the descriptor fragments', function (assert) { + assert.deepEqual(icon('box'), { icon: 'box' }); + assert.deepEqual(icon('box', 'tile'), { icon: 'box', iconClass: 'tile' }); + + assert.strictEqual(badge('plate', 'id-card', ''), null, 'a blank label is no badge'); + assert.deepEqual(badge('plate', 'id-card', 'ABC', { relatedId: 'v1' }), { key: 'plate', icon: 'id-card', label: 'ABC', relatedId: 'v1' }); + assert.deepEqual(badges(null, { key: 'a' }, undefined, { key: 'b' }), [{ key: 'a' }, { key: 'b' }]); + + assert.deepEqual(fact('phone', '123'), { labelKey: 'resource-summary.facts.phone', value: '123' }); + assert.deepEqual(fact('status', 'active', { format: 'humanize' }), { labelKey: 'resource-summary.facts.status', value: 'active', format: 'humanize' }); + assert.deepEqual(relatedFact('vehicle', undefined, 'vehicle'), { labelKey: 'resource-summary.facts.vehicle', related: null, relatedType: 'vehicle', value: null }); + const vehicle = {}; + assert.deepEqual(relatedFact('vehicle', vehicle, 'vehicle', 'Truck'), { labelKey: 'resource-summary.facts.vehicle', related: vehicle, relatedType: 'vehicle', value: 'Truck' }); + }); + + test('dateLabel formats dates and strings and refuses anything else', function (assert) { + assert.strictEqual(dateLabel(null), null); + assert.strictEqual(dateLabel('not a date'), null); + assert.strictEqual(dateLabel(new Date(2026, 0, 2, 3, 4)), '02 Jan 2026, 03:04'); + assert.strictEqual(dateLabel('2026-01-02T00:00:00', 'dd MMM yyyy'), '02 Jan 2026'); + }); + + test('typeLabel reads a polymorphic type the way a person does', function (assert) { + assert.strictEqual(typeLabel(''), null); + assert.strictEqual(typeLabel('customer-fleet-ops:contact'), 'Customer'); + assert.strictEqual(typeLabel('facilitator_vendor'), 'Facilitator vendor'); + assert.strictEqual(typeLabel(':contact'), null, 'nothing readable before the colon'); + }); + + test('money and join leave out what is missing', function (assert) { + assert.strictEqual(money(null, 'USD'), null); + assert.strictEqual(money(10), '10'); + assert.strictEqual(money(10, 'USD'), '10 USD'); + assert.strictEqual(join([null, '', 'a', 'b']), 'a b'); + assert.strictEqual(join(['a', 'b'], ', '), 'a, b'); + assert.strictEqual(join([null]), null); + }); + + test('polymorphicType reads the related model name, its proxy, or the type attribute', function (assert) { + class Customer { + static modelName = 'contact'; + } + + assert.strictEqual(polymorphicType(null, 'customer', 'customer_type'), null); + assert.strictEqual(polymorphicType({ customer: new Customer() }, 'customer', 'customer_type'), 'contact'); + assert.strictEqual(polymorphicType({ customer: { content: new Customer() } }, 'customer', 'customer_type'), 'contact'); + assert.strictEqual(polymorphicType({ customer: null, customer_type: 'vendor' }, 'customer', 'customer_type'), 'vendor'); + assert.strictEqual(polymorphicType({ customer: null }, 'customer'), null); + }); + + module('reaching the FleetOps engine', function () { + test('extensionManager is found as its own service or through universe', function (assert) { + const manager = {}; + + assert.strictEqual(extensionManager({ lookup: () => null }), null); + assert.strictEqual(extensionManager({ lookup: (name) => (name === 'service:universe/extension-manager' ? manager : null) }), manager); + assert.strictEqual(extensionManager({ lookup: (name) => (name === 'service:universe' ? { extensionManager: manager } : null) }), manager); + }); + + test('engineInstalled is false without a manager, when not installed, or when the check throws', function (assert) { + assert.false(engineInstalled({ lookup: () => null })); + assert.false(engineInstalled({ lookup: () => ({ isInstalled: () => false }) })); + assert.true(engineInstalled({ lookup: () => ({ isInstalled: (name) => name === FLEETOPS_ENGINE }) })); + assert.true(engineInstalled({ lookup: () => ({ isInstalled: (name) => name === '@other/engine' }) }, '@other/engine')); + assert.false( + engineInstalled({ + lookup: () => ({ + isInstalled() { + throw new Error('boom'); + }, + }), + }) + ); + }); + + function ownerWithEngine(services, overrides = {}) { + const engine = { lookup: (name) => services[name.replace('service:', '')] ?? null }; + const manager = { ensureEngineLoaded: async () => engine, ...overrides }; + + return { lookup: (name) => (name === 'service:universe/extension-manager' ? manager : null) }; + } + + test('engineOpener prefers the panel, then the transition, and reports what it could not do', async function (assert) { + const record = { id: 'r1' }; + const seen = []; + const panelService = { panel: { view: (r) => seen.push(['panel', r]) }, transition: { view: (r) => seen.push(['transition', r]) } }; + const transitionService = { transition: { view: (r) => seen.push(['transition-only', r]) } }; + const bareService = {}; + + assert.false(await engineOpener({ lookup: () => null }, 'driver-actions')(record), 'no manager'); + assert.false(await engineOpener(ownerWithEngine({ 'driver-actions': panelService }), 'driver-actions')(null), 'no record'); + assert.false(await engineOpener(ownerWithEngine({}), 'driver-actions')(record), 'no service'); + assert.false(await engineOpener(ownerWithEngine({ 'driver-actions': bareService }), 'driver-actions')(record), 'no view'); + assert.false(await engineOpener(ownerWithEngine({ 'driver-actions': panelService }, { ensureEngineLoaded: async () => null }), 'driver-actions')(record), 'no engine'); + assert.false( + await engineOpener( + ownerWithEngine( + { 'driver-actions': panelService }, + { + ensureEngineLoaded: async () => { + throw new Error('offline'); + }, + } + ), + 'driver-actions' + )(record), + 'the load failed' + ); + + assert.true(await engineOpener(ownerWithEngine({ 'driver-actions': panelService }), 'driver-actions')(record)); + assert.true(await engineOpener(ownerWithEngine({ 'driver-actions': transitionService }), 'driver-actions')(record), 'no panel means the transition'); + assert.true(await engineOpener(ownerWithEngine({ 'driver-actions': panelService }), 'driver-actions', { mode: 'transition' })(record)); + assert.false(await engineOpener(ownerWithEngine({ 'driver-actions': panelService }), 'driver-actions', { mode: 'modal' })(record), 'an unknown mode opens nothing'); + + assert.deepEqual(seen, [ + ['panel', record], + ['transition-only', record], + ['transition', record], + ]); + }); + }); +}); diff --git a/tests/unit/utils/resource-descriptors/shared-test.js b/tests/unit/utils/resource-descriptors/shared-test.js new file mode 100644 index 0000000..df9f01c --- /dev/null +++ b/tests/unit/utils/resource-descriptors/shared-test.js @@ -0,0 +1,270 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; +import { buildSharedResourceDescriptors, SHARED_RESOURCE_KEYS, guardOrder } from '@fleetbase/fleetops-data/utils/resource-descriptors'; + +const UUID = '0b2d9a6e-5e6b-4d6c-9a1f-3c8e1f2a4b5d'; + +/** Every descriptor field that takes a record, called with the given record. */ +function readAll(descriptor, record) { + const values = {}; + + for (const field of ['title', 'identifier', 'image', 'online', 'status', 'badges', 'selectDetails', 'facts']) { + if (typeof descriptor[field] === 'function') { + values[field] = descriptor[field](record); + } + } + + return values; +} + +function byKey(owner) { + return Object.fromEntries(buildSharedResourceDescriptors(owner).map((descriptor) => [descriptor.key, descriptor])); +} + +function factValue(facts, label) { + return facts.find((fact) => fact.labelKey === `resource-summary.facts.${label}`); +} + +module('Unit | Utility | resource-descriptors/shared', function (hooks) { + setupTest(hooks); + + test('there is one descriptor per shared resource, each naming its identity cell wrapper', function (assert) { + const descriptors = buildSharedResourceDescriptors(this.owner); + + assert.deepEqual( + descriptors.map((descriptor) => descriptor.key), + SHARED_RESOURCE_KEYS, + 'driver, vehicle, customer, contact, place, order, vendor and fleet' + ); + + for (const descriptor of descriptors) { + assert.strictEqual(descriptor.components.identity, `cell/${descriptor.key}-identity`, `${descriptor.key} identity cell`); + assert.ok(descriptor.modelNames.includes(descriptor.key), `${descriptor.key} names its model`); + assert.ok( + descriptor.polymorphicTypes.some((type) => type.startsWith('fleet-ops:')), + `${descriptor.key} names its fleet-ops type` + ); + assert.strictEqual(typeof descriptor.open, 'function', `${descriptor.key} can be opened`); + assert.strictEqual(typeof descriptor.canOpen, 'function', `${descriptor.key} guards opening on the engine`); + } + }); + + test('every field tolerates an empty record and a sparse one', function (assert) { + for (const descriptor of buildSharedResourceDescriptors(this.owner)) { + for (const record of [{}, { public_id: `${descriptor.key}_1` }]) { + let values; + + try { + values = readAll(descriptor, record); + } catch (error) { + assert.ok(false, `${descriptor.key} threw on ${JSON.stringify(record)}: ${error.message}`); + continue; + } + + assert.ok(Array.isArray(values.facts), `${descriptor.key} facts are a list`); + assert.ok(Array.isArray(values.selectDetails), `${descriptor.key} select details are a list`); + assert.ok(values.image && typeof values.image === 'object', `${descriptor.key} image is an object`); + } + } + }); + + test('identifiers never surface a UUID', function (assert) { + for (const descriptor of buildSharedResourceDescriptors(this.owner)) { + const record = { id: UUID, uuid: UUID, public_id: 'thing_1', name: 'Named', plate_number: null, driver_uuid: UUID, vehicle_uuid: UUID }; + const identifier = descriptor.identifier?.(record); + + assert.notStrictEqual(identifier, UUID, `${descriptor.key} identifier is not the uuid`); + } + }); + + test('a driver renders its name, phone, photo placeholder and online dot, and its vehicle as a badge', function (assert) { + const { driver } = byKey(this.owner); + const record = { + name: 'Ada Driver', + phone: '+15551234567', + online: true, + status: 'available', + vehicle_name: 'Truck 10', + vehicle_uuid: 'vehicle_1', + drivers_license_number: 'DL-1', + license_expiry: '2027-03-04T00:00:00', + }; + const values = readAll(driver, record); + + assert.strictEqual(values.title, 'Ada Driver'); + assert.strictEqual(values.identifier, '+15551234567'); + assert.true(values.online); + assert.strictEqual(values.status, 'available'); + assert.ok(values.image.url.startsWith('data:image/svg+xml'), 'no photo means the styled placeholder'); + assert.strictEqual(values.image.shape, 'round'); + assert.deepEqual( + values.badges.map((badge) => [badge.key, badge.label, badge.relatedId]), + [['vehicle', 'Truck 10', 'vehicle_1']] + ); + assert.strictEqual(factValue(values.facts, 'licence').value, 'DL-1 · expires 04 Mar 2027'); + assert.strictEqual(factValue(values.facts, 'vehicle').value, 'Truck 10'); + assert.strictEqual(driver.online({ online: 'yes' }), undefined, 'a non-boolean online flag is no dot'); + }); + + test('a driver with a loaded vehicle names it from the relation', function (assert) { + const { driver } = byKey(this.owner); + const vehicle = { id: 'vehicle_2', displayName: 'Van 2' }; + const values = readAll(driver, { name: 'Ada', vehicle }); + + assert.deepEqual( + values.badges.map((badge) => [badge.key, badge.label, badge.relatedId]), + [['vehicle', 'Van 2', 'vehicle_2']] + ); + assert.strictEqual(factValue(values.facts, 'vehicle').related, vehicle); + assert.strictEqual(factValue(values.facts, 'licence').value, null, 'no licence, no label'); + }); + + test('a vehicle is square, titles itself from year make model when it has no name, and lists its driver and trailers', function (assert) { + const { vehicle } = byKey(this.owner); + + assert.strictEqual(vehicle.image({ photo_url: 'https://cdn.test/truck.png' }).shape, 'square'); + assert.strictEqual(vehicle.image({ photo_url: 'https://cdn.test/truck.png' }).url, 'https://cdn.test/truck.png'); + assert.strictEqual(vehicle.title({ year: 2020, make: 'Ford', model: 'Transit' }), '2020 Ford Transit'); + assert.strictEqual(vehicle.title({ displayName: 'Truck 1', year: 2020 }), 'Truck 1'); + + const driver = { id: 'driver_1', name: 'Ada' }; + const loaded = readAll(vehicle, { plate_number: 'ABC-1', driver, trailers: [{ displayName: 'Box 1' }, { displayName: 'Box 2' }], odometer: 1200, odometer_unit: 'km' }); + assert.deepEqual( + loaded.badges.map((badge) => [badge.key, badge.label]), + [ + ['plate', 'ABC-1'], + ['driver', 'Ada'], + ] + ); + assert.strictEqual(factValue(loaded.facts, 'trailer').value, 'Box 1'); + assert.strictEqual(factValue(loaded.facts, 'trailer').suffix, ' (+1)'); + assert.strictEqual(factValue(loaded.facts, 'odometer').value, '1200 km'); + + const emberArray = readAll(vehicle, { trailers: { length: 1, objectAt: (index) => (index === 0 ? { displayName: 'Box 1' } : null) } }); + assert.strictEqual(factValue(emberArray.facts, 'trailer').value, 'Box 1', 'a many-array is read through objectAt'); + assert.strictEqual(factValue(emberArray.facts, 'trailer').suffix, ''); + + const arrayLike = readAll(vehicle, { trailers: { 0: { displayName: 'Box 1' }, length: 1 } }); + assert.strictEqual(factValue(arrayLike.facts, 'trailer').value, 'Box 1', 'a plain list is read by index'); + + const bare = readAll(vehicle, { driver_name: 'Bob', driver_uuid: 'driver_9' }); + assert.deepEqual( + bare.badges.map((badge) => [badge.key, badge.label, badge.relatedId]), + [['driver', 'Bob', 'driver_9']] + ); + assert.strictEqual(factValue(bare.facts, 'trailer').value, null); + assert.strictEqual(factValue(bare.facts, 'odometer').value, null); + }); + + test('a place is an icon with its city and country, and a coordinate fact', function (assert) { + const { place } = byKey(this.owner); + + assert.deepEqual(place.image({}), { icon: 'location-dot' }); + assert.strictEqual(place.identifier({ city: 'Singapore', country: 'SG' }), 'Singapore, SG'); + assert.strictEqual(place.identifier({ public_id: 'place_1' }), 'place_1'); + assert.strictEqual(factValue(place.facts({ positionString: '1.3, 103.8' }), 'coordinates').value, '1.3, 103.8'); + assert.strictEqual(factValue(place.facts({ latitude: 1.3, longitude: 103.8 }), 'coordinates').value, '1.3, 103.8'); + assert.strictEqual(factValue(place.facts({}), 'coordinates').value, null); + }); + + test('an order reads its tracking number and names its customer by its polymorphic type', function (assert) { + const { order } = byKey(this.owner); + + assert.strictEqual(order.title({ tracking: 'TRK-1', public_id: 'order_1' }), 'TRK-1'); + assert.strictEqual(order.identifier({ public_id: 'order_1' }), 'order_1'); + const facts = order.facts({ customer_type: 'contact', customer_name: 'Ada', pickupName: 'A', dropoffName: 'B' }); + assert.strictEqual(factValue(facts, 'customer').relatedType, 'contact'); + assert.strictEqual(factValue(facts, 'customer').value, 'Ada'); + assert.strictEqual(factValue(facts, 'route').value, 'A → B'); + }); + + test('customers, contacts, vendors and fleets read their identifiers', function (assert) { + const { customer, contact, vendor, fleet } = byKey(this.owner); + + assert.strictEqual(customer.identifier({ customer_type: 'customer-fleet-ops:contact' }), 'Customer'); + assert.strictEqual(customer.identifier({}), 'Customer'); + assert.strictEqual(contact.identifier({ title: 'Manager', type: 'facilitator' }), 'Manager'); + assert.strictEqual(contact.identifier({ type: 'facilitator' }), 'Facilitator'); + assert.strictEqual(vendor.image({ logo_url: 'https://cdn.test/logo.png' }).url, 'https://cdn.test/logo.png'); + assert.deepEqual(fleet.selectDetails({ task: 'Deliveries', drivers_count: 3 }), ['Deliveries', '3 drivers']); + assert.deepEqual(fleet.selectDetails({}), [null, null]); + const facts = fleet.facts({ drivers_count: 3, vehicles_count: 2, vehicles_online_count: 1 }); + assert.strictEqual(factValue(facts, 'drivers').value, '0 online of 3'); + assert.strictEqual(factValue(facts, 'vehicles').value, '1 online of 2'); + assert.strictEqual(factValue(fleet.facts({ vehicles_count: 2 }), 'vehicles').value, '0 online of 2'); + }); + + module('opening through the FleetOps engine', function () { + function fakeManager(overrides = {}) { + const service = { + panel: { + view(record) { + service.viewed = record; + }, + }, + }; + const engine = { lookup: (name) => (name === 'service:driver-actions' ? service : null) }; + const manager = { + installed: true, + loads: 0, + isInstalled() { + return this.installed; + }, + async ensureEngineLoaded(name) { + this.loads++; + this.loadedName = name; + return engine; + }, + ...overrides, + }; + + return { manager, service }; + } + + test('canOpen is true only when the FleetOps extension is installed', function (assert) { + const { manager } = fakeManager(); + this.owner.register('service:universe/extension-manager', manager, { instantiate: false }); + const [driver] = buildSharedResourceDescriptors(this.owner); + + assert.true(driver.canOpen({})); + + manager.installed = false; + assert.false(driver.canOpen({})); + }); + + test('canOpen is false on a host without the extension manager', function (assert) { + const [driver] = buildSharedResourceDescriptors(this.owner); + + assert.false(driver.canOpen({})); + }); + + test('open loads the engine on demand, then hands the record to its action service panel', async function (assert) { + const { manager, service } = fakeManager(); + this.owner.register('service:universe/extension-manager', manager, { instantiate: false }); + const [driver] = buildSharedResourceDescriptors(this.owner); + const record = { id: 'driver_1', name: 'Ada' }; + + assert.true(await driver.open(record)); + assert.strictEqual(manager.loads, 1, 'the engine was loaded'); + assert.strictEqual(manager.loadedName, '@fleetbase/fleetops-engine'); + assert.strictEqual(service.viewed, record, 'the panel shows the record'); + }); + + test('an order stub or an unsaved order never opens', async function (assert) { + let opened = 0; + const open = guardOrder(() => { + opened++; + return true; + }); + + assert.false(open(null)); + assert.false(open({ isIdentityStub: true, name: 'ORD-1' })); + assert.false(open({ public_id: 'order_1' })); + assert.true(open({ id: 'order_1' })); + assert.strictEqual(opened, 1); + + const { order } = byKey(this.owner); + assert.false(await order.open({ isIdentityStub: true }), 'the shared order descriptor is guarded'); + }); + }); +});