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'); + }); + }); +});