From f81d972588286cad60705078b60543f95a80965f Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 15 Sep 2026 17:16:56 +0800 Subject: [PATCH 01/40] Let the dummy app resolve the ember-core imports the addon already makes Pill and ModelSelect import utilities from @fleetbase/ember-core, but the addon never declared it, so every rendering test that touched them died with "Could not find module" and the integration suite has been failing on main. Declare ember-core as a dependency, and add ember-data with the tracked-storage polyfill it needs so its services can boot in the dummy app. The resource identity work that follows is tested through these components. --- package.json | 3 + pnpm-lock.yaml | 909 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 903 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index f990bb14..3275ada2 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@embroider/macros": "^1.8.3", "@event-calendar/core": "^5.6.0", "@fleetbase/ember-accounting": "^0.0.1", + "@fleetbase/ember-core": "^0.3.24", "@floating-ui/dom": "^1.0.1", "@fortawesome/ember-fontawesome": "^2.0.0", "@fortawesome/fontawesome-svg-core": "6.4.0", @@ -140,6 +141,7 @@ "ember-cli-inject-live-reload": "^2.1.0", "ember-cli-sri": "^2.1.1", "ember-cli-terser": "^4.0.2", + "ember-data": "^4.12.8", "ember-load-initializers": "^2.1.2", "ember-page-title": "^8.0.0", "ember-qunit": "^8.0.1", @@ -147,6 +149,7 @@ "ember-source": "~5.4.0", "ember-source-channel-url": "^3.0.0", "ember-template-lint": "^5.11.2", + "ember-tracked-storage-polyfill": "1.0.1", "ember-try": "^3.0.0", "eslint": "^8.52.0", "eslint-config-prettier": "^9.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b382bc2f..fa22ab91 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@fleetbase/ember-accounting': specifier: ^0.0.1 version: 0.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) + '@fleetbase/ember-core': + specifier: ^0.3.24 + version: 0.3.24(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))(eslint@8.57.1)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)) '@floating-ui/dom': specifier: ^1.0.1 version: 1.7.6 @@ -315,6 +318,9 @@ importers: ember-cli-terser: specifier: ^4.0.2 version: 4.0.2 + ember-data: + specifier: ^4.12.8 + version: 4.12.8(@babel/core@7.29.0)(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)) ember-load-initializers: specifier: ^2.1.2 version: 2.1.2(@babel/core@7.29.0) @@ -336,6 +342,9 @@ importers: ember-template-lint: specifier: ^5.11.2 version: 5.13.0 + ember-tracked-storage-polyfill: + specifier: 1.0.1 + version: 1.0.1 ember-try: specifier: ^3.0.0 version: 3.0.0 @@ -1222,9 +1231,111 @@ packages: peerDependencies: postcss: ^8.4 + '@ember-data/adapter@4.12.8': + resolution: {integrity: sha512-HIwLGUkAXPbOfCw/vt1Xi5a3/J/sV4tT0LVsB/HPo+m0h/ztSmrfCQVRJCzZUP3ACeOL+eGeMQt4zyz8RfZazw==} + engines: {node: 16.* || >= 18.*} + peerDependencies: + '@ember-data/store': 4.12.8 + '@ember/string': ^3.0.1 + ember-inflector: ^4.0.2 + + '@ember-data/debug@4.12.8': + resolution: {integrity: sha512-dA2VXsO8OPddZ723oQxLbjQVoWMpVuqhskBgaf8kRNmJI9ru8AxhR6KWJaF2LMeJ3VhI5ujo1rNfOC2Y1t/chw==} + engines: {node: 16.* || >= 18.*} + peerDependencies: + '@ember-data/store': 4.12.8 + '@ember/string': ^3.0.1 + + '@ember-data/graph@4.12.8': + resolution: {integrity: sha512-Nm297TOVsOvIqnzRPclW3YL+ILgpz00Rc5Z5KNk1Je3RP8+02uA7Sh39p5WG9YQr6rz3+xY5jd1VbmIoLOQiaA==} + engines: {node: 16.* || >= 18.*} + peerDependencies: + '@ember-data/store': 4.12.8 + + '@ember-data/json-api@4.12.8': + resolution: {integrity: sha512-A5ann76wOeRXeRPOG8wrWQn4BK+yb7T1l6Ybm1eSgkFQeNVvVc/eM6ejcRospQInSRZnOJZCPHYd+wggZgpXGA==} + engines: {node: 16.* || >= 18.*} + peerDependencies: + '@ember-data/graph': 4.12.8 + '@ember-data/store': 4.12.8 + + '@ember-data/legacy-compat@4.12.8': + resolution: {integrity: sha512-sMC+QWdA+oMFtGH1UvwK2UU/iua29s298SSftRP9M84JAqr7t8AWfZd73m1CWe9aboyYKe1KXOCfPUsgrSICCg==} + engines: {node: 16.* || >= 18} + peerDependencies: + '@ember-data/graph': 4.12.8 + '@ember-data/json-api': 4.12.8 + '@ember/string': ^3.0.1 + peerDependenciesMeta: + '@ember-data/graph': + optional: true + '@ember-data/json-api': + optional: true + + '@ember-data/model@4.12.8': + resolution: {integrity: sha512-rJQVri/mrZIdwmonVqbHVsCI+xLvW5CClnlXLiHCBDpoq/klXJ6u5FMglH64GAEpjuIfWKiygdOvMGiaYFJt+A==} + engines: {node: 16.* || >= 18.*} + peerDependencies: + '@ember-data/debug': 4.12.8 + '@ember-data/graph': 4.12.8 + '@ember-data/json-api': 4.12.8 + '@ember-data/legacy-compat': 4.12.8 + '@ember-data/store': 4.12.8 + '@ember-data/tracking': 4.12.8 + '@ember/string': ^3.0.1 + ember-inflector: ^4.0.2 + peerDependenciesMeta: + '@ember-data/debug': + optional: true + '@ember-data/graph': + optional: true + '@ember-data/json-api': + optional: true + + '@ember-data/private-build-infra@4.12.8': + resolution: {integrity: sha512-acOT5m5Bnq78IYcCjRoP9Loh65XNODFor+nThvH4IDmfaxNfKfr8Qheu4f23r5oPOXmHbcDBWRjsjs2dkaKTAw==} + engines: {node: 16.* || >= 18.*} + + '@ember-data/request@4.12.8': + resolution: {integrity: sha512-aTn+Cd5b901MGhLKRJdd/+xXrkp1GAmJEn55F8W2ojYk82rt2ZbO/Ppe2DWhTRMujj6vKclYhWJt0NNafnUobQ==} + engines: {node: 16.* || >= 18} + '@ember-data/rfc395-data@0.0.4': resolution: {integrity: sha512-tGRdvgC9/QMQSuSuJV45xoyhI0Pzjm7A9o/MVVA3HakXIImJbbzx/k/6dO9CUEQXIyS2y0fW6C1XaYOG7rY0FQ==} + '@ember-data/serializer@4.12.8': + resolution: {integrity: sha512-XKjSnq8jR1C8sFCZmdd1cTfV5THt1ykYDcDNo80pLoZaIosYtt1QVIVLq0puTjNXO/B8GyQl8DN2p/AS9fwbaw==} + engines: {node: 16.* || >= 18.*} + peerDependencies: + '@ember-data/store': 4.12.8 + '@ember/string': ^3.0.1 + ember-inflector: ^4.0.2 + + '@ember-data/store@4.12.8': + resolution: {integrity: sha512-pI+c/ZtRO5T02JcQ+yvUQsRZIIw/+fVUUnxa6mHiiNkjOJZaK8/2resdskSgV3SFGI82icanV7Ve5LJj9EzscA==} + engines: {node: 16.* || >= 18.*} + peerDependencies: + '@ember-data/graph': 4.12.8 + '@ember-data/json-api': 4.12.8 + '@ember-data/legacy-compat': 4.12.8 + '@ember-data/model': 4.12.8 + '@ember-data/tracking': 4.12.8 + '@ember/string': ^3.0.1 + '@glimmer/tracking': ^1.1.2 + peerDependenciesMeta: + '@ember-data/graph': + optional: true + '@ember-data/json-api': + optional: true + '@ember-data/legacy-compat': + optional: true + '@ember-data/model': + optional: true + + '@ember-data/tracking@4.12.8': + resolution: {integrity: sha512-CczHOsEbInbVg4WF2UQhV89gCnSfH+8ZR1WinPFQ8PaY6e1KSlPULuTXhC03NhAo8GaJzHlvc3KfATt5qgBplg==} + engines: {node: 16.* || >= 18} + '@ember-decorators/component@6.1.1': resolution: {integrity: sha512-Cj8tY/c0MC/rsipqsiWLh3YVN72DK92edPYamD/HzvftwzC6oDwawWk8RmStiBnG9PG/vntAt41l3S7HSSA+1Q==} engines: {node: '>= 8.*'} @@ -1356,6 +1467,10 @@ packages: peerDependencies: ember-source: '>= 4.0.0' + '@fleetbase/ember-core@0.3.24': + resolution: {integrity: sha512-WG32JlX4S75ofa34RQEMxFEqc20fTv2Qn3A5+NlqXtL0lfnCthdO+a1DrGqIIierthHl3mVTxhe+z63f8sM6EA==} + engines: {node: '>= 18'} + '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -1365,6 +1480,50 @@ packages: '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@formatjs/ecma402-abstract@2.2.4': + resolution: {integrity: sha512-lFyiQDVvSbQOpU+WFd//ILolGj4UgA/qXrKeZxdV14uKiAUiPAtX6XAn7WBCRi7Mx6I7EybM9E5yYn4BIpZWYg==} + + '@formatjs/ecma402-abstract@2.3.6': + resolution: {integrity: sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==} + + '@formatjs/fast-memoize@2.2.3': + resolution: {integrity: sha512-3jeJ+HyOfu8osl3GNSL4vVHUuWFXR03Iz9jjgI7RwjG6ysu/Ymdr0JRCPHfF5yGbTE6JCrd63EpvX1/WybYRbA==} + + '@formatjs/fast-memoize@2.2.7': + resolution: {integrity: sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==} + + '@formatjs/icu-messageformat-parser@2.11.4': + resolution: {integrity: sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==} + + '@formatjs/icu-messageformat-parser@2.9.4': + resolution: {integrity: sha512-Tbvp5a9IWuxUcpWNIW6GlMQYEc4rwNHR259uUFoKWNN1jM9obf9Ul0e+7r7MvFOBNcN+13K7NuKCKqQiAn1QEg==} + + '@formatjs/icu-skeleton-parser@1.8.16': + resolution: {integrity: sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==} + + '@formatjs/icu-skeleton-parser@1.8.8': + resolution: {integrity: sha512-vHwK3piXwamFcx5YQdCdJxUQ1WdTl6ANclt5xba5zLGDv5Bsur7qz8AD7BevaKxITwpgDeU0u8My3AIibW9ywA==} + + '@formatjs/intl-displaynames@6.8.5': + resolution: {integrity: sha512-85b+GdAKCsleS6cqVxf/Aw/uBd+20EM0wDpgaxzHo3RIR3bxF4xCJqH/Grbzx8CXurTgDDZHPdPdwJC+May41w==} + + '@formatjs/intl-listformat@7.7.5': + resolution: {integrity: sha512-Wzes10SMNeYgnxYiKsda4rnHP3Q3II4XT2tZyOgnH5fWuHDtIkceuWlRQNsvrI3uiwP4hLqp2XdQTCsfkhXulg==} + + '@formatjs/intl-localematcher@0.5.8': + resolution: {integrity: sha512-I+WDNWWJFZie+jkfkiK5Mp4hEDyRSEvmyfYadflOno/mmKJKcB17fEpEH0oJu/OWhhCJ8kJBDz2YMd/6cDl7Mg==} + + '@formatjs/intl-localematcher@0.6.2': + resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==} + + '@formatjs/intl@2.10.15': + resolution: {integrity: sha512-i6+xVqT+6KCz7nBfk4ybMXmbKO36tKvbMKtgFz9KV+8idYFyFbfwKooYk8kGjyA5+T5f1kEPQM5IDLXucTAQ9g==} + peerDependencies: + typescript: ^4.7 || 5 + peerDependenciesMeta: + typescript: + optional: true + '@fortawesome/ember-fontawesome@2.0.0': resolution: {integrity: sha512-lUyMvocZZzMuCwr8pkKhejXKBj5RGnIs8YUHc/tCNSEyHSx7/E5xAhtE4fa5B1c9+UO789Kng8z6DQ9E/agVGA==} engines: {node: 14.* || 16.* || >= 18} @@ -1558,6 +1717,10 @@ packages: resolution: {integrity: sha512-CaNTtpaypA69fCqFlz69SMSuuLY1TLcDxjXaPmBNj+SsZpqQXcFgkPVRQEna7IICdgDbJIoLQnB/AB7/J6AD9g==} engines: {node: '>= 10.*'} + '@mrmlnc/readdir-enhanced@2.2.1': + resolution: {integrity: sha512-bPHp6Ji8b41szTOcaP63VlnbbO5Ny6dwAATtY6JTjh5N2OLrb5Qk/Th5cRkRQhkWCt+EJsYrNB0MiL+Gpn6e3g==} + engines: {node: '>=4'} + '@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1': resolution: {integrity: sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==} @@ -1565,6 +1728,10 @@ packages: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} + '@nodelib/fs.stat@1.1.3': + resolution: {integrity: sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==} + engines: {node: '>= 6'} + '@nodelib/fs.stat@2.0.5': resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} engines: {node: '>= 8'} @@ -2097,6 +2264,12 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ag-channel@5.0.0: + resolution: {integrity: sha512-bArHkdqQxynim981t8FLZM5TfA0v7p081OlFdOxs6clB79GSGcGlOQMDa31DT9F5VMjzqNiJmhfGwinvfU/3Zg==} + + ag-request@1.1.0: + resolution: {integrity: sha512-d4K7QC1KnIpzcnUNNOeh1ddxmYMLiIdhdc1M8osxiHbZP/uoia4IINhhf2+1CrlnNJEPUoUH0Y58Sx0qeqoIvg==} + air-datepicker@3.6.0: resolution: {integrity: sha512-+txUkqa949rXBJDmkQAIb/GehZECJYF4rm9XJxVYtEX22C9WvBpE/XwCUQZBopKIkpg4ycAySJ9lH3JOg9qQTw==} @@ -2302,6 +2475,9 @@ packages: async-promise-queue@1.0.5: resolution: {integrity: sha512-xi0aQ1rrjPWYmqbwr18rrSKbSaXIeIwSd1J4KAgVfkq8utNbdZoht7GfvfY6swFUAMJ9obkc4WPJmtGwl+B8dw==} + async-stream-emitter@4.1.0: + resolution: {integrity: sha512-cfPZYjHkhCdHSR+eux71vOU8+8Xb23oLyxccAjwYHgOxDb3+qSDb2HV1Y0Hmu39vZlse2cm15CUShLiVYXHCmQ==} + async@2.6.4: resolution: {integrity: sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==} @@ -2454,6 +2630,9 @@ packages: babel-types@6.26.0: resolution: {integrity: sha512-zhe3V/26rCWsEZK8kZN+HaQj5yQ1CilTObixFzKW1UWjqG7618Twz6YEsCnjfg5gBcJh02DrpCkS9h98ZqDY+g==} + babel6-plugin-strip-class-callcheck@6.0.0: + resolution: {integrity: sha512-biNFJ7JAK4+9BwswDGL0dmYpvXHvswOFR/iKg3Q/f+pNxPEa5bWZkLHI1fW4spPytkHGMe7f/XtYyhzml9hiWg==} + babylon@6.18.0: resolution: {integrity: sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==} hasBin: true @@ -2516,12 +2695,18 @@ packages: bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + bl@1.2.3: + resolution: {integrity: sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==} + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} blank-object@1.0.2: resolution: {integrity: sha512-kXQ19Xhoghiyw66CUiGypnuRpWlbHAzY/+NyvqTEdTfhfQGH1/dbEMYiXju7fYKIFePpzp/y9dsu5Cu/PkmawQ==} + blob-polyfill@7.0.20220408: + resolution: {integrity: sha512-oD8Ydw+5lNoqq+en24iuPt1QixdPpe/nUF8azTHnviCZYu9zUC+TwdzIp5orpblJosNlgNbVmmAb//c6d6ImUQ==} + bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} @@ -2627,6 +2812,10 @@ packages: broccoli-kitchen-sink-helpers@0.3.1: resolution: {integrity: sha512-gqYnKSJxBSjj/uJqeuRAzYVbmjWhG0mOZ8jrp6+fnUIOgLN6MvI7XxBECDHkYMIFPJ8Smf4xaI066Q2FqQDnXg==} + broccoli-merge-files@0.8.0: + resolution: {integrity: sha512-S6dXHECbDkr7YMuCitAAQT8EZeW/kXom0Y8+QmQfiSkWspkKDGrr4vXgEZJjWqfa/FSx/Y18NEEOuMmbIW+XNQ==} + engines: {node: '>=8.0.0'} + broccoli-merge-trees@2.0.1: resolution: {integrity: sha512-WjaexJ+I8BxP5V5RNn6um/qDRSmKoiBC/QkRi79FT9ClHfldxRyCDs9mcV7mmoaPlsshmmPaUz5jdtcKA6DClQ==} @@ -2821,6 +3010,9 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + call-me-maybe@1.0.2: + resolution: {integrity: sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -2923,6 +3115,9 @@ packages: resolution: {integrity: sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==} engines: {node: '>=0.10.0'} + cldr-core@44.1.0: + resolution: {integrity: sha512-ssbJaXu3pCkc4YqNC6xHhgleZG7YqnOFZ9laMlJfHOfnspoA9waI4AH54gKB3Fe/+wI4i3cVxKFdCTVGTRw+UA==} + clean-base-url@1.0.0: resolution: {integrity: sha512-9q6ZvUAhbKOSRFY7A/irCQ/rF0KIpa3uXpx6izm8+fp7b2H4hLeUJ+F1YYk9+gDQ/X8Q0MEyYs+tG3cht//HTg==} @@ -2972,6 +3167,10 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + clone-deep@4.0.1: + resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==} + engines: {node: '>=6'} + clone-response@1.0.3: resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} @@ -3050,6 +3249,9 @@ packages: component-emitter@1.3.1: resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + compress-json@3.4.0: + resolution: {integrity: sha512-SxT8uFOacRbP3gfi4aVAulr4KzE933eZWBguVBwuVWv3GTGQAToqpq8rFtdbIVgeCOH7RpPuXKQrPPd087b8uA==} + compressible@2.0.18: resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} engines: {node: '>= 0.6'} @@ -3238,6 +3440,9 @@ packages: constants-browserify@1.0.0: resolution: {integrity: sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==} + consumable-stream@2.0.0: + resolution: {integrity: sha512-I6WA2JVYXs/68rEvi1ie3rZjP6qusTVFEQkbzR+WC+fY56TpwiGTIDJETsrnlxv5CsnmK69ps6CkYvIbpEEqBA==} + content-disposition@0.5.4: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} @@ -3451,6 +3656,9 @@ packages: resolution: {integrity: sha512-VfxadyCECXgQlkoEAjeghAr5gY3Hf+IKjKb+X8tGVDtveCjN+USwprd2q3QXBR9T1+x2DG0XZF5/w+7HAtSaXA==} engines: {node: '>=10'} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-uri-component@0.2.2: resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} engines: {node: '>=0.10'} @@ -3640,6 +3848,16 @@ packages: '@glimmer/tracking': ^1.1.2 ember-source: ^3.28.0 || ^4.0.0 || >=5.0.0 + ember-cache-primitive-polyfill@1.0.1: + resolution: {integrity: sha512-hSPcvIKarA8wad2/b6jDd/eU+OtKmi6uP+iYQbzi5TQpjsqV6b4QdRqrLk7ClSRRKBAtdTuutx+m+X+WlEd2lw==} + engines: {node: 10.* || >= 12} + + ember-cached-decorator-polyfill@1.0.2: + resolution: {integrity: sha512-hUX6OYTKltAPAu8vsVZK02BfMTV0OUXrPqvRahYPhgS7D0I6joLjlskd7mhqJMcaXLywqceIy8/s+x8bxF8bpQ==} + engines: {node: 14.* || >= 16} + peerDependencies: + ember-source: ^3.13.0 || ^4.0.0 || >= 5.0.0 + ember-can@6.0.0: resolution: {integrity: sha512-/8TnWxU9Yu0HXCk04qCHK3nnjrNMe3j6wrJJjW9ffLsnBBHKTT8yNP1f54EsHaHoNtWd73JGSSTmtcpBHhWAyw==} peerDependencies: @@ -3705,6 +3923,11 @@ packages: ember-cli-normalize-entity-name@1.0.0: resolution: {integrity: sha512-rF4P1rW2P1gVX1ynZYPmuIf7TnAFDiJmIUFI1Xz16VYykUAyiOCme0Y22LeZq8rTzwBMiwBwoE3RO4GYWehXZA==} + ember-cli-notifications@9.1.0: + resolution: {integrity: sha512-Vr3/3y6dIbfFZB7OJuKrQCwMzL48Ym1tnJUGiElvg3G75gr/FUZ88O8gTo5HqCSASmqp2HFw4bQpvtmq2GHqjg==} + peerDependencies: + ember-source: '>= 3.8.0' + ember-cli-path-utils@1.0.0: resolution: {integrity: sha512-Qq0vvquzf4cFHoDZavzkOy3Izc893r/5spspWgyzLCPTaG78fM3HsrjZm7UWEltbXUqwHHYrqZd/R0jS08NqSA==} @@ -3731,6 +3954,9 @@ packages: resolution: {integrity: sha512-Ej77K+YhCZImotoi/CU2cfsoZaswoPlGaM5TB3LvjvPDlVPRhxUHO2RsaUVC5lsGeRLRiHCOxVtoJ6GyqexzFA==} engines: {node: 10.* || 12.* || >= 14} + ember-cli-test-info@1.0.0: + resolution: {integrity: sha512-dEVTIpmUfCzweC97NGf6p7L6XKBwV2GmSM4elmzKvkttEp5P7AvGA9uGyN4GqFq+RwhW+2b0I2qlX00w+skm+A==} + ember-cli-test-loader@3.1.0: resolution: {integrity: sha512-0aocZV9SIoOHiU3hrH3IuLR6busWhTX6UVXgd490hmJkIymmOXNH2+jJoC7Ebkeo3PiOfAdjqhb765QDlHSJOw==} engines: {node: 10.* || >= 12} @@ -3814,6 +4040,22 @@ packages: '@glint/template': optional: true + ember-cookies@1.3.0: + resolution: {integrity: sha512-nhVDm9lql4EVLpbjxyosyEITFvuNAmHr7cod8K2FmIyw2KcAFWSS0v88quIWc+GvcawBTz3KSMRXOJq/0InVpg==} + engines: {node: '>= 16.*'} + peerDependencies: + ember-source: '>=4.0' + + ember-copy@2.0.1: + resolution: {integrity: sha512-N/XFvZszrzyyX4IcNoeK4mJvIItNuONumhPLqi64T8NDjJkxBj4Pq61rvMkJx/9eZ8alzE4I8vYKOLxT0FvRuQ==} + engines: {node: 10.* || >= 12} + + ember-data@4.12.8: + resolution: {integrity: sha512-fK9mp+chqXGWYx6lal/azBKP4AtW8E6u3xUUWet6henO2zPN4S5lRs6iBfaynPkmhW5DK5bvaxNmFvSzmPOghw==} + engines: {node: 16.* || >= 18.*} + peerDependencies: + '@ember/string': ^3.0.1 + ember-decorators@6.1.1: resolution: {integrity: sha512-63vZPntPn1aqMyeNRLoYjJD+8A8obd+c2iZkJflswpDRNVIsp2m7aQdSCtPt4G0U/TEq2251g+N10maHX3rnJQ==} engines: {node: '>= 8.*'} @@ -3884,6 +4126,15 @@ packages: peerDependencies: ember-source: ^3.16.0 || ^4.0.0 || ^5.0.0 + ember-intl@6.3.2: + resolution: {integrity: sha512-UJ91JjlY3z6fjajoHhhJtKUbgqEr/l6Ie4Viqn0B3Lrbavxg5IgAIvyZZ9fOVtq05MDtCuc610zV6vLOc71G0w==} + engines: {node: 16.* || >= 18} + peerDependencies: + typescript: ^4.8.0 || ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + ember-leaflet@5.1.3: resolution: {integrity: sha512-7rzZePUCjapMzkE1SXsgSDQ28nG1w/WIMt80wf72UG4LIMHOGHibenk3naFllu1Np9wApCHxLB6x4ig8clt2Aw==} engines: {node: 14.* || 16.* || >= 18} @@ -3908,6 +4159,10 @@ packages: resolution: {integrity: sha512-aGFZszIwmrIZ5XINv9TXQGWrfpKIiPwYiakEmRXx0Jvr5ZnUyKtCO84+FaKMyezrbDWsq0UjYqmdiqSAXhIkIA==} engines: {node: 12.* || 14.* || >= 16} + ember-local-storage@2.0.7: + resolution: {integrity: sha512-EPvxH/27mIzrX/EEgng+FG6HD0ri/God9OH/9mhmgPSXrgMNq/614Z3NMnooM4QKIEBAvr0p+p1UL2DgrTTMNg==} + engines: {node: 12.* || 14.* || >= 16} + ember-math-helpers@4.2.1: resolution: {integrity: sha512-/pOFz6tQ67mh0faiD7nzOCYRXHElg2d/SvQnYB8vdYoj7BLCkNHLjdo3F0oa5Qz/6J/+k3ie5ZGBjMumvlOeIw==} engines: {node: '>= 18'} @@ -3984,6 +4239,14 @@ packages: resolution: {integrity: sha512-89oVHVJwmLDvGvAUWgS87KpBoRhy3aZ6U0Ql6HOmU4TrPkyaa8pM0W81wj9cIwjYprcQtN9EwzZMHnq46+oUyw==} engines: {node: 8.* || 10.* || >= 12} + ember-simple-auth@6.1.0: + resolution: {integrity: sha512-LhOl7TrOKlqb+0a/5STOoTSncDNuPELuFZ9+1SLduVX7DtdQr8VOEAmB8UaOnG0clJ9Bj6E3SczhXGjqd718Lw==} + peerDependencies: + '@ember/test-helpers': '>= 3 || > 2.7' + peerDependenciesMeta: + '@ember/test-helpers': + optional: true + ember-source-channel-url@3.0.0: resolution: {integrity: sha512-vF/8BraOc66ZxIDo3VuNP7iiDrnXEINclJgSJmqwAAEpg84Zb1DHPI22XTXSDA+E8fW5btPUxu65c3ZXi8AQFA==} engines: {node: 10.* || 12.* || >= 14} @@ -4312,6 +4575,9 @@ packages: eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + events-to-array@2.0.3: resolution: {integrity: sha512-f/qE2gImHRa4Cp2y1stEOSgw8wTFyUdVJX7G//bMwbaV9JqISFxg99NbmVQeP7YLnDUZ2un851jlaDrlpmGehQ==} engines: {node: '>=12'} @@ -4374,6 +4640,9 @@ packages: resolution: {integrity: sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==} engines: {node: '>=0.10.0'} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + external-editor@3.1.0: resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} engines: {node: '>=4'} @@ -4392,6 +4661,10 @@ packages: fast-diff@1.3.0: resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + fast-glob@2.2.7: + resolution: {integrity: sha512-g1KuQwHOZAmOZMuBtHdxDtju+T2RT8jgCC9aANsbpdiDDTSnjgfuVsIBNKbUeJI3oKMRExcfNDtJl4OhbffMsw==} + engines: {node: '>=4.0.0'} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -4402,6 +4675,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-memoize@2.5.2: + resolution: {integrity: sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw==} + fast-ordered-set@1.0.3: resolution: {integrity: sha512-MxBW4URybFszOx1YlACEoK52P6lE3xiFcPaGCUZ7QQOZ6uJXKo++Se8wa31SjcZ+NC/fdAWX7UtKEfaGgHS2Vg==} @@ -4752,6 +5028,9 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob-to-regexp@0.3.0: + resolution: {integrity: sha512-Iozmtbqv0noj0uDDqoL0zNq0VBEfK2YFoMAZoxJe4cwphvLR+JskfF30QhXHOR4m3KrE6NLRYw+U9MRXvifyig==} + glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} @@ -5087,6 +5366,12 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + intl-messageformat@10.7.18: + resolution: {integrity: sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==} + + intl-messageformat@10.7.7: + resolution: {integrity: sha512-F134jIoeYMro/3I0h08D0Yt4N9o9pjddU/4IIxMMURqbAtI2wu70X8hvG1V48W49zXHXv3RKSF/po+0fDfsGjA==} + intl-tel-input@22.0.2: resolution: {integrity: sha512-FYDLp56aCVE+G7TJGmKCeU8LlfSztINiv54TALquMhMJumlUP1d48I6fEssbcq325vcXOrqdOO7Bbi1U/WRCcw==} @@ -5520,6 +5805,9 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + linked-list@0.1.0: + resolution: {integrity: sha512-Zr4ovrd0ODzF3ut2TWZMdHIxb8iFdJc/P3QM4iCJdlxxGHXo69c9hGIHzLo8/FtuR9E6WUZc5irKhtPUgOKMAg==} + linkify-it@4.0.1: resolution: {integrity: sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==} @@ -5577,6 +5865,9 @@ packages: lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + lodash.defaultsdeep@4.6.1: resolution: {integrity: sha512-3j8wdDzYuWO3lM3Reg03MuQR957t287Rpcxp1njpEa8oDrikb+FwGdW3n+FELh/A6qib6yPit0j/pv9G/yeAqA==} @@ -6016,6 +6307,9 @@ packages: resolution: {integrity: sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA==} engines: {node: '>=8'} + npm-git-info@1.0.3: + resolution: {integrity: sha512-i5WBdj4F/ULl16z9ZhsJDMl1EQCMQhHZzBwNnKL2LOA+T8IHNeRkLCVz9uVV9SzUdGTbDq+1oXhIYMe+8148vw==} + npm-package-arg@10.1.0: resolution: {integrity: sha512-uFyyCEmgBfZTtrKk/5xDfHp6+MdrqGotX/VoOyEEl3mBwiEE5FlBaePanazJSVMPT7vKepcjYBY2ztg9A3yPIA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -6141,6 +6435,10 @@ packages: resolution: {integrity: sha512-ugZxsxmtTln604yeYd29EGrNhazN2lywetzpKhfmQjW/VJmhpDmWbiX+h0zL8V91R0UXkhb3KtPmyq9PZw3aYw==} engines: {node: '>=8'} + p-event@2.3.1: + resolution: {integrity: sha512-NQCqOFhbpVTMX4qMe8PF8lbGtzZ+LCiN7pcNrb/413Na7+TRoe1xkKUzuWa/YEJdGQ0FvKtj35EEbDoVPO2kbA==} + engines: {node: '>=6'} + p-finally@1.0.0: resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} engines: {node: '>=4'} @@ -6189,6 +6487,10 @@ packages: resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + p-timeout@2.0.1: + resolution: {integrity: sha512-88em58dDVB/KzPEx1X0N3LwFfYZPyDc4B6eF38M1rk9VTZMbxXXgjugz8mmwpS9Ox4BDZ+t6t3QP5+/gazweIA==} + engines: {node: '>=4'} + p-try@1.0.0: resolution: {integrity: sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==} engines: {node: '>=4'} @@ -7193,6 +7495,15 @@ packages: engines: {node: 10.* || >= 12.*} hasBin: true + sc-errors@2.0.3: + resolution: {integrity: sha512-HNpClBWpo7zxLBnhH0U/FbC19Gl3OJlVyPxo9Q2eomfdWgYfd84uhqe0LRgybc+nSpcYjtF08+/dKPLugLMMeQ==} + + sc-errors@3.0.0: + resolution: {integrity: sha512-rIqv2HTPb9DVreZwK/DV0ytRUqyw2DbDcoB9XTKjEQL7oMEQKsfPA8V8dGGr7p8ZYfmvaRIGZ4Wu5qwvs/hGDA==} + + sc-formatter@4.0.0: + resolution: {integrity: sha512-MgUIvuca+90fBrCWY5LdlU9YUWjlkPFwdpvmomcwQEu3t2id/6YHdG2nhB6o7nhRp4ocfmcXQTh00r/tJtynSg==} + schema-utils@1.0.0: resolution: {integrity: sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==} engines: {node: '>= 4'} @@ -7271,6 +7582,10 @@ packages: engines: {node: '>= 0.10'} hasBin: true + shallow-clone@3.0.1: + resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==} + engines: {node: '>=8'} + shebang-command@1.2.0: resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} engines: {node: '>=0.10.0'} @@ -7365,6 +7680,9 @@ packages: resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==} engines: {node: '>=10.2.0'} + socketcluster-client@17.2.2: + resolution: {integrity: sha512-HIopjTj8p979N5klC7FeZSwu9rd805bFgFcyVX7Y8zPyjVHXHTfGrV/8vqzN2gpOwnnosWQ44ue0qGqovlxZrg==} + sort-object-keys@1.1.3: resolution: {integrity: sha512-855pvK+VkU7PaKYPc+Jjnmt4EzejQHyhhF33q31qG8x7maDzkeFhAAThdCYay11CISO+qAMwjOBP+fPZe0IPyg==} @@ -7468,6 +7786,9 @@ packages: stream-browserify@2.0.2: resolution: {integrity: sha512-nX6hmklHs/gr2FuxYDltq8fJA1GDlxKQCz8O/IM4atRqBH8OORmBNgfvW5gG10GT/qQ9u0CzIvr2X5Pkt6ntqg==} + stream-demux@8.1.0: + resolution: {integrity: sha512-20vtOmAj2EVzQZKZVmfyio16u/3QOKSvg+0ldgZeS+m2FNI1vKFoqggamagsPCXufdZ1Tk8VvAM/HV/YUmRbSg==} + stream-each@1.2.3: resolution: {integrity: sha512-vlMC2f8I2u/bZGqkdfLQW/13Zihpej/7PmSiMQsbYddxuTsJp8vRe2x2FvVExZg7FaOds43ROAuFJwPR4MTZLw==} @@ -8084,6 +8405,9 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vinyl-buffer@1.0.1: + resolution: {integrity: sha512-LRBE2/g3C1hSHL2k/FynSZcVTRhEw8sb08oKGt/0hukZXwrh2m8nfy+r5yLhGEk7eFFuclhyIuPct/Bxlxk6rg==} + vm-browserify@1.1.2: resolution: {integrity: sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==} @@ -8227,6 +8551,9 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + writable-consumable-stream@3.0.1: + resolution: {integrity: sha512-rAOJTA/sMgXD/X6eMwbQJe49w+Fnkdx3iV5oUzdmiZ7Bwx03khqUnAKIpzp/hbI8q2EP5NfjXgIXN0MsipfHeg==} + write-file-atomic@3.0.3: resolution: {integrity: sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==} @@ -9327,11 +9654,190 @@ snapshots: dependencies: postcss-selector-parser: 6.1.2 - '@csstools/utilities@1.0.0(postcss@8.5.14)': + '@csstools/utilities@1.0.0(postcss@8.5.14)': + dependencies: + postcss: 8.5.14 + + '@ember-data/adapter@4.12.8(@babel/core@7.29.0)(@ember-data/store@4.12.8)(@ember/string@3.1.1)(ember-inflector@4.0.3(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))))': + dependencies: + '@ember-data/private-build-infra': 4.12.8 + '@ember-data/store': 4.12.8(@babel/core@7.29.0)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember-data/legacy-compat@4.12.8)(@ember-data/model@4.12.8)(@ember-data/tracking@4.12.8(@babel/core@7.29.0))(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) + '@ember/string': 3.1.1 + '@embroider/macros': 1.20.2(@babel/core@7.29.0) + ember-cli-babel: 7.26.11 + ember-cli-test-info: 1.0.0 + ember-inflector: 4.0.3(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) + transitivePeerDependencies: + - '@babel/core' + - '@glint/template' + - supports-color + + '@ember-data/debug@4.12.8(@babel/core@7.29.0)(@ember-data/store@4.12.8)(@ember/string@3.1.1)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))': + dependencies: + '@ember-data/private-build-infra': 4.12.8 + '@ember-data/store': 4.12.8(@babel/core@7.29.0)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember-data/legacy-compat@4.12.8)(@ember-data/model@4.12.8)(@ember-data/tracking@4.12.8(@babel/core@7.29.0))(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) + '@ember/edition-utils': 1.2.0 + '@ember/string': 3.1.1 + '@embroider/macros': 1.20.2(@babel/core@7.29.0) + ember-auto-import: 2.13.1(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)) + ember-cli-babel: 7.26.11 + transitivePeerDependencies: + - '@babel/core' + - '@glint/template' + - supports-color + - webpack + + '@ember-data/graph@4.12.8(@babel/core@7.29.0)(@ember-data/store@4.12.8)': + dependencies: + '@ember-data/private-build-infra': 4.12.8 + '@ember-data/store': 4.12.8(@babel/core@7.29.0)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember-data/legacy-compat@4.12.8)(@ember-data/model@4.12.8)(@ember-data/tracking@4.12.8(@babel/core@7.29.0))(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) + '@ember/edition-utils': 1.2.0 + '@embroider/macros': 1.20.2(@babel/core@7.29.0) + ember-cli-babel: 7.26.11 + transitivePeerDependencies: + - '@babel/core' + - '@glint/template' + - supports-color + + '@ember-data/json-api@4.12.8(@babel/core@7.29.0)(@ember-data/graph@4.12.8)(@ember-data/store@4.12.8)': + dependencies: + '@ember-data/graph': 4.12.8(@babel/core@7.29.0)(@ember-data/store@4.12.8) + '@ember-data/private-build-infra': 4.12.8 + '@ember-data/store': 4.12.8(@babel/core@7.29.0)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember-data/legacy-compat@4.12.8)(@ember-data/model@4.12.8)(@ember-data/tracking@4.12.8(@babel/core@7.29.0))(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) + '@ember/edition-utils': 1.2.0 + '@embroider/macros': 1.20.2(@babel/core@7.29.0) + ember-cli-babel: 7.26.11 + transitivePeerDependencies: + - '@babel/core' + - '@glint/template' + - supports-color + + '@ember-data/legacy-compat@4.12.8(@babel/core@7.29.0)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember/string@3.1.1)': + dependencies: + '@ember-data/private-build-infra': 4.12.8 + '@ember/string': 3.1.1 + '@embroider/macros': 1.20.2(@babel/core@7.29.0) + ember-cli-babel: 7.26.11 + optionalDependencies: + '@ember-data/graph': 4.12.8(@babel/core@7.29.0)(@ember-data/store@4.12.8) + '@ember-data/json-api': 4.12.8(@babel/core@7.29.0)(@ember-data/graph@4.12.8)(@ember-data/store@4.12.8) + transitivePeerDependencies: + - '@babel/core' + - '@glint/template' + - supports-color + + '@ember-data/model@4.12.8(@babel/core@7.29.0)(@ember-data/debug@4.12.8)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember-data/legacy-compat@4.12.8)(@ember-data/store@4.12.8)(@ember-data/tracking@4.12.8(@babel/core@7.29.0))(@ember/string@3.1.1)(ember-inflector@4.0.3(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))': + dependencies: + '@ember-data/legacy-compat': 4.12.8(@babel/core@7.29.0)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember/string@3.1.1) + '@ember-data/private-build-infra': 4.12.8 + '@ember-data/store': 4.12.8(@babel/core@7.29.0)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember-data/legacy-compat@4.12.8)(@ember-data/model@4.12.8)(@ember-data/tracking@4.12.8(@babel/core@7.29.0))(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) + '@ember-data/tracking': 4.12.8(@babel/core@7.29.0) + '@ember/edition-utils': 1.2.0 + '@ember/string': 3.1.1 + '@embroider/macros': 1.20.2(@babel/core@7.29.0) + ember-cached-decorator-polyfill: 1.0.2(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) + ember-cli-babel: 7.26.11 + ember-cli-string-utils: 1.1.0 + ember-cli-test-info: 1.0.0 + ember-inflector: 4.0.3(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) + inflection: 2.0.1 + optionalDependencies: + '@ember-data/debug': 4.12.8(@babel/core@7.29.0)(@ember-data/store@4.12.8)(@ember/string@3.1.1)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)) + '@ember-data/graph': 4.12.8(@babel/core@7.29.0)(@ember-data/store@4.12.8) + '@ember-data/json-api': 4.12.8(@babel/core@7.29.0)(@ember-data/graph@4.12.8)(@ember-data/store@4.12.8) + transitivePeerDependencies: + - '@babel/core' + - '@glint/template' + - ember-source + - supports-color + + '@ember-data/private-build-infra@4.12.8': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.29.0) + '@babel/runtime': 7.29.2 + '@ember/edition-utils': 1.2.0 + '@embroider/macros': 1.20.2(@babel/core@7.29.0) + babel-import-util: 1.4.1 + babel-plugin-debug-macros: 0.3.4(@babel/core@7.29.0) + babel-plugin-filter-imports: 4.0.0 + babel6-plugin-strip-class-callcheck: 6.0.0 + broccoli-debug: 0.6.5 + broccoli-file-creator: 2.1.1 + broccoli-funnel: 3.0.8 + broccoli-merge-trees: 4.2.0 + broccoli-rollup: 5.0.0 + calculate-cache-key-for-tree: 2.0.0 + chalk: 4.1.2 + ember-cli-babel: 7.26.11 + ember-cli-path-utils: 1.0.0 + ember-cli-string-utils: 1.1.0 + ember-cli-version-checker: 5.1.2 + git-repo-info: 2.1.1 + glob: 9.3.5 + npm-git-info: 1.0.3 + semver: 7.8.0 + silent-error: 1.1.1 + transitivePeerDependencies: + - '@glint/template' + - supports-color + + '@ember-data/request@4.12.8(@babel/core@7.29.0)': + dependencies: + '@ember-data/private-build-infra': 4.12.8 + '@ember/test-waiters': 3.1.0 + '@embroider/macros': 1.20.2(@babel/core@7.29.0) + ember-cli-babel: 7.26.11 + transitivePeerDependencies: + - '@babel/core' + - '@glint/template' + - supports-color + + '@ember-data/rfc395-data@0.0.4': {} + + '@ember-data/serializer@4.12.8(@babel/core@7.29.0)(@ember-data/store@4.12.8)(@ember/string@3.1.1)(ember-inflector@4.0.3(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))))': + dependencies: + '@ember-data/private-build-infra': 4.12.8 + '@ember-data/store': 4.12.8(@babel/core@7.29.0)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember-data/legacy-compat@4.12.8)(@ember-data/model@4.12.8)(@ember-data/tracking@4.12.8(@babel/core@7.29.0))(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) + '@ember/string': 3.1.1 + '@embroider/macros': 1.20.2(@babel/core@7.29.0) + ember-cli-babel: 7.26.11 + ember-cli-test-info: 1.0.0 + ember-inflector: 4.0.3(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) + transitivePeerDependencies: + - '@babel/core' + - '@glint/template' + - supports-color + + '@ember-data/store@4.12.8(@babel/core@7.29.0)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember-data/legacy-compat@4.12.8)(@ember-data/model@4.12.8)(@ember-data/tracking@4.12.8(@babel/core@7.29.0))(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))': + dependencies: + '@ember-data/private-build-infra': 4.12.8 + '@ember-data/tracking': 4.12.8(@babel/core@7.29.0) + '@ember/string': 3.1.1 + '@embroider/macros': 1.20.2(@babel/core@7.29.0) + '@glimmer/tracking': 1.1.2 + ember-cached-decorator-polyfill: 1.0.2(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) + ember-cli-babel: 7.26.11 + optionalDependencies: + '@ember-data/graph': 4.12.8(@babel/core@7.29.0)(@ember-data/store@4.12.8) + '@ember-data/json-api': 4.12.8(@babel/core@7.29.0)(@ember-data/graph@4.12.8)(@ember-data/store@4.12.8) + '@ember-data/legacy-compat': 4.12.8(@babel/core@7.29.0)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember/string@3.1.1) + '@ember-data/model': 4.12.8(@babel/core@7.29.0)(@ember-data/debug@4.12.8)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember-data/legacy-compat@4.12.8)(@ember-data/store@4.12.8)(@ember-data/tracking@4.12.8(@babel/core@7.29.0))(@ember/string@3.1.1)(ember-inflector@4.0.3(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) + transitivePeerDependencies: + - '@babel/core' + - '@glint/template' + - ember-source + - supports-color + + '@ember-data/tracking@4.12.8(@babel/core@7.29.0)': dependencies: - postcss: 8.5.14 - - '@ember-data/rfc395-data@0.0.4': {} + '@ember-data/private-build-infra': 4.12.8 + '@embroider/macros': 1.20.2(@babel/core@7.29.0) + ember-cli-babel: 7.26.11 + transitivePeerDependencies: + - '@babel/core' + - '@glint/template' + - supports-color '@ember-decorators/component@6.1.1': dependencies: @@ -9542,6 +10048,39 @@ snapshots: transitivePeerDependencies: - supports-color + '@fleetbase/ember-core@0.3.24(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))(eslint@8.57.1)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))': + dependencies: + '@babel/core': 7.29.0 + compress-json: 3.4.0 + date-fns: 2.30.0 + ember-auto-import: 2.13.1(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)) + ember-can: 6.0.0(@babel/core@7.29.0)(@ember/string@3.1.1)(ember-inflector@4.0.3(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) + ember-cli-babel: 8.3.1(@babel/core@7.29.0) + ember-cli-htmlbars: 6.3.0 + ember-cli-notifications: 9.1.0(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) + ember-concurrency: 4.0.6(@babel/core@7.29.0) + ember-decorators: 6.1.1 + ember-get-config: 2.1.1(@babel/core@7.29.0) + ember-inflector: 4.0.3(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) + ember-intl: 6.3.2(@babel/core@7.29.0)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)) + ember-loading: 2.0.0(@babel/core@7.29.0) + ember-local-storage: 2.0.7(@babel/core@7.29.0) + ember-simple-auth: 6.1.0(@babel/core@7.29.0)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))(eslint@8.57.1) + ember-wormhole: 0.6.1 + socketcluster-client: 17.2.2 + transitivePeerDependencies: + - '@ember/string' + - '@ember/test-helpers' + - '@glint/template' + - bufferutil + - ember-resolver + - ember-source + - eslint + - supports-color + - typescript + - utf-8-validate + - webpack + '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 @@ -9553,6 +10092,79 @@ snapshots: '@floating-ui/utils@0.2.11': {} + '@formatjs/ecma402-abstract@2.2.4': + dependencies: + '@formatjs/fast-memoize': 2.2.3 + '@formatjs/intl-localematcher': 0.5.8 + tslib: 2.8.1 + + '@formatjs/ecma402-abstract@2.3.6': + dependencies: + '@formatjs/fast-memoize': 2.2.7 + '@formatjs/intl-localematcher': 0.6.2 + decimal.js: 10.6.0 + tslib: 2.8.1 + + '@formatjs/fast-memoize@2.2.3': + dependencies: + tslib: 2.8.1 + + '@formatjs/fast-memoize@2.2.7': + dependencies: + tslib: 2.8.1 + + '@formatjs/icu-messageformat-parser@2.11.4': + dependencies: + '@formatjs/ecma402-abstract': 2.3.6 + '@formatjs/icu-skeleton-parser': 1.8.16 + tslib: 2.8.1 + + '@formatjs/icu-messageformat-parser@2.9.4': + dependencies: + '@formatjs/ecma402-abstract': 2.2.4 + '@formatjs/icu-skeleton-parser': 1.8.8 + tslib: 2.8.1 + + '@formatjs/icu-skeleton-parser@1.8.16': + dependencies: + '@formatjs/ecma402-abstract': 2.3.6 + tslib: 2.8.1 + + '@formatjs/icu-skeleton-parser@1.8.8': + dependencies: + '@formatjs/ecma402-abstract': 2.2.4 + tslib: 2.8.1 + + '@formatjs/intl-displaynames@6.8.5': + dependencies: + '@formatjs/ecma402-abstract': 2.2.4 + '@formatjs/intl-localematcher': 0.5.8 + tslib: 2.8.1 + + '@formatjs/intl-listformat@7.7.5': + dependencies: + '@formatjs/ecma402-abstract': 2.2.4 + '@formatjs/intl-localematcher': 0.5.8 + tslib: 2.8.1 + + '@formatjs/intl-localematcher@0.5.8': + dependencies: + tslib: 2.8.1 + + '@formatjs/intl-localematcher@0.6.2': + dependencies: + tslib: 2.8.1 + + '@formatjs/intl@2.10.15': + dependencies: + '@formatjs/ecma402-abstract': 2.2.4 + '@formatjs/fast-memoize': 2.2.3 + '@formatjs/icu-messageformat-parser': 2.9.4 + '@formatjs/intl-displaynames': 6.8.5 + '@formatjs/intl-listformat': 7.7.5 + intl-messageformat: 10.7.7 + tslib: 2.8.1 + '@fortawesome/ember-fontawesome@2.0.0(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))(rollup@2.80.0)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))': dependencies: '@fortawesome/fontawesome-svg-core': 6.4.0 @@ -9874,6 +10486,11 @@ snapshots: - webpack-cli - webpack-command + '@mrmlnc/readdir-enhanced@2.2.1': + dependencies: + call-me-maybe: 1.0.2 + glob-to-regexp: 0.3.0 + '@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1': dependencies: eslint-scope: 5.1.1 @@ -9883,6 +10500,8 @@ snapshots: '@nodelib/fs.stat': 2.0.5 run-parallel: 1.2.0 + '@nodelib/fs.stat@1.1.3': {} + '@nodelib/fs.stat@2.0.5': {} '@nodelib/fs.walk@1.2.8': @@ -10214,7 +10833,7 @@ snapshots: '@types/glob@9.0.0': dependencies: - glob: 8.1.0 + glob: 13.0.6 '@types/http-errors@2.0.5': {} @@ -10486,6 +11105,14 @@ snapshots: acorn@8.16.0: {} + ag-channel@5.0.0: + dependencies: + consumable-stream: 2.0.0 + + ag-request@1.1.0: + dependencies: + sc-errors: 3.0.0 + air-datepicker@3.6.0: {} ajv-errors@1.0.1(ajv@6.15.0): @@ -10674,6 +11301,10 @@ snapshots: transitivePeerDependencies: - supports-color + async-stream-emitter@4.1.0: + dependencies: + stream-demux: 8.1.0 + async@2.6.4: dependencies: lodash: 4.18.1 @@ -10915,6 +11546,8 @@ snapshots: lodash: 4.18.1 to-fast-properties: 1.0.3 + babel6-plugin-strip-class-callcheck@6.0.0: {} + babylon@6.18.0: {} backbone@1.6.1: @@ -10967,6 +11600,11 @@ snapshots: file-uri-to-path: 1.0.0 optional: true + bl@1.2.3: + dependencies: + readable-stream: 2.3.8 + safe-buffer: 5.2.1 + bl@4.1.0: dependencies: buffer: 5.7.1 @@ -10975,6 +11613,8 @@ snapshots: blank-object@1.0.2: {} + blob-polyfill@7.0.20220408: {} + bluebird@3.7.2: {} bn.js@4.12.3: {} @@ -11249,6 +11889,15 @@ snapshots: glob: 5.0.15 mkdirp: 0.5.6 + broccoli-merge-files@0.8.0: + dependencies: + broccoli-plugin: 1.3.1 + fast-glob: 2.2.7 + lodash.defaults: 4.2.0 + p-event: 2.3.1 + transitivePeerDependencies: + - supports-color + broccoli-merge-trees@2.0.1: dependencies: broccoli-plugin: 1.3.1 @@ -11685,6 +12334,8 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + call-me-maybe@1.0.2: {} + callsites@3.1.0: {} camel-case@4.1.2: @@ -11817,6 +12468,8 @@ snapshots: isobject: 3.0.1 static-extend: 0.1.2 + cldr-core@44.1.0: {} + clean-base-url@1.0.0: {} clean-css@5.3.3: @@ -11859,6 +12512,12 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + clone-deep@4.0.1: + dependencies: + is-plain-object: 2.0.4 + kind-of: 6.0.3 + shallow-clone: 3.0.1 + clone-response@1.0.3: dependencies: mimic-response: 1.0.1 @@ -11912,6 +12571,8 @@ snapshots: component-emitter@1.3.1: {} + compress-json@3.4.0: {} + compressible@2.0.18: dependencies: mime-db: 1.54.0 @@ -11987,6 +12648,8 @@ snapshots: constants-browserify@1.0.0: {} + consumable-stream@2.0.0: {} + content-disposition@0.5.4: dependencies: safe-buffer: 5.2.1 @@ -12201,6 +12864,8 @@ snapshots: decamelize@5.0.1: {} + decimal.js@10.6.0: {} + decode-uri-component@0.2.2: {} decompress-response@3.3.0: @@ -12486,6 +13151,30 @@ snapshots: - '@glint/template' - supports-color + ember-cache-primitive-polyfill@1.0.1(@babel/core@7.29.0): + dependencies: + ember-cli-babel: 7.26.11 + ember-cli-version-checker: 5.1.2 + ember-compatibility-helpers: 1.2.7(@babel/core@7.29.0) + silent-error: 1.1.1 + transitivePeerDependencies: + - '@babel/core' + - supports-color + + ember-cached-decorator-polyfill@1.0.2(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))): + dependencies: + '@embroider/macros': 1.20.2(@babel/core@7.29.0) + '@glimmer/tracking': 1.1.2 + babel-import-util: 1.4.1 + ember-cache-primitive-polyfill: 1.0.1(@babel/core@7.29.0) + ember-cli-babel: 7.26.11 + ember-cli-babel-plugin-helpers: 1.1.1 + ember-source: 5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)) + transitivePeerDependencies: + - '@babel/core' + - '@glint/template' + - supports-color + ember-can@6.0.0(@babel/core@7.29.0)(@ember/string@3.1.1)(ember-inflector@4.0.3(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))): dependencies: '@ember/string': 3.1.1 @@ -12671,6 +13360,15 @@ snapshots: transitivePeerDependencies: - supports-color + ember-cli-notifications@9.1.0(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))): + dependencies: + '@embroider/addon-shim': 1.10.2 + decorator-transforms: 2.3.2(@babel/core@7.29.0) + ember-source: 5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)) + transitivePeerDependencies: + - '@babel/core' + - supports-color + ember-cli-path-utils@1.0.0: {} ember-cli-postcss@8.2.0: @@ -12713,6 +13411,10 @@ snapshots: transitivePeerDependencies: - supports-color + ember-cli-test-info@1.0.0: + dependencies: + ember-cli-string-utils: 1.1.0 + ember-cli-test-loader@3.1.0: dependencies: ember-cli-babel: 7.26.11 @@ -13035,6 +13737,48 @@ snapshots: - '@babel/core' - supports-color + ember-cookies@1.3.0(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))): + dependencies: + '@embroider/addon-shim': 1.10.2 + ember-source: 5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)) + transitivePeerDependencies: + - supports-color + + ember-copy@2.0.1: + dependencies: + ember-cli-babel: 7.26.11 + transitivePeerDependencies: + - supports-color + + ember-data@4.12.8(@babel/core@7.29.0)(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)): + dependencies: + '@ember-data/adapter': 4.12.8(@babel/core@7.29.0)(@ember-data/store@4.12.8)(@ember/string@3.1.1)(ember-inflector@4.0.3(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))) + '@ember-data/debug': 4.12.8(@babel/core@7.29.0)(@ember-data/store@4.12.8)(@ember/string@3.1.1)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)) + '@ember-data/graph': 4.12.8(@babel/core@7.29.0)(@ember-data/store@4.12.8) + '@ember-data/json-api': 4.12.8(@babel/core@7.29.0)(@ember-data/graph@4.12.8)(@ember-data/store@4.12.8) + '@ember-data/legacy-compat': 4.12.8(@babel/core@7.29.0)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember/string@3.1.1) + '@ember-data/model': 4.12.8(@babel/core@7.29.0)(@ember-data/debug@4.12.8)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember-data/legacy-compat@4.12.8)(@ember-data/store@4.12.8)(@ember-data/tracking@4.12.8(@babel/core@7.29.0))(@ember/string@3.1.1)(ember-inflector@4.0.3(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) + '@ember-data/private-build-infra': 4.12.8 + '@ember-data/request': 4.12.8(@babel/core@7.29.0) + '@ember-data/serializer': 4.12.8(@babel/core@7.29.0)(@ember-data/store@4.12.8)(@ember/string@3.1.1)(ember-inflector@4.0.3(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))) + '@ember-data/store': 4.12.8(@babel/core@7.29.0)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember-data/legacy-compat@4.12.8)(@ember-data/model@4.12.8)(@ember-data/tracking@4.12.8(@babel/core@7.29.0))(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) + '@ember-data/tracking': 4.12.8(@babel/core@7.29.0) + '@ember/edition-utils': 1.2.0 + '@ember/string': 3.1.1 + '@embroider/macros': 1.20.2(@babel/core@7.29.0) + '@glimmer/env': 0.1.7 + broccoli-merge-trees: 4.2.0 + ember-auto-import: 2.13.1(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)) + ember-cli-babel: 7.26.11 + ember-inflector: 4.0.3(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) + transitivePeerDependencies: + - '@babel/core' + - '@glimmer/tracking' + - '@glint/template' + - ember-source + - supports-color + - webpack + ember-decorators@6.1.1: dependencies: '@ember-decorators/component': 6.1.1 @@ -13159,6 +13903,34 @@ snapshots: transitivePeerDependencies: - supports-color + ember-intl@6.3.2(@babel/core@7.29.0)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)): + dependencies: + '@formatjs/icu-messageformat-parser': 2.11.4 + '@formatjs/intl': 2.10.15 + broccoli-caching-writer: 3.1.0 + broccoli-funnel: 3.0.8 + broccoli-merge-files: 0.8.0 + broccoli-merge-trees: 4.2.0 + broccoli-source: 3.0.1 + broccoli-stew: 3.0.0 + calculate-cache-key-for-tree: 2.0.0 + cldr-core: 44.1.0 + ember-auto-import: 2.13.1(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)) + ember-cli-babel: 8.3.1(@babel/core@7.29.0) + ember-cli-typescript: 5.3.0 + eventemitter3: 5.0.4 + extend: 3.0.2 + fast-memoize: 2.5.2 + intl-messageformat: 10.7.18 + js-yaml: 4.1.1 + json-stable-stringify: 1.3.0 + silent-error: 1.1.1 + transitivePeerDependencies: + - '@babel/core' + - '@glint/template' + - supports-color + - webpack + ember-leaflet@5.1.3(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))(leaflet@1.9.4)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)): dependencies: '@glimmer/component': 1.1.2(@babel/core@7.29.0) @@ -13208,6 +13980,21 @@ snapshots: - '@babel/core' - supports-color + ember-local-storage@2.0.7(@babel/core@7.29.0): + dependencies: + blob-polyfill: 7.0.20220408 + broccoli-funnel: 3.0.8 + broccoli-merge-trees: 4.2.0 + broccoli-stew: 3.0.0 + ember-cli-babel: 7.26.11 + ember-cli-string-utils: 1.1.0 + ember-cli-version-checker: 5.1.2 + ember-copy: 2.0.1 + ember-destroyable-polyfill: 2.0.3(@babel/core@7.29.0) + transitivePeerDependencies: + - '@babel/core' + - supports-color + ember-math-helpers@4.2.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))): dependencies: '@embroider/addon-shim': 1.10.2 @@ -13349,6 +14136,24 @@ snapshots: transitivePeerDependencies: - supports-color + ember-simple-auth@6.1.0(@babel/core@7.29.0)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))(eslint@8.57.1): + dependencies: + '@babel/eslint-parser': 7.28.6(@babel/core@7.29.0)(eslint@8.57.1) + '@ember/test-waiters': 3.1.0 + '@embroider/addon-shim': 1.10.2 + '@embroider/macros': 1.20.2(@babel/core@7.29.0) + ember-cli-is-package-missing: 1.0.0 + ember-cookies: 1.3.0(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) + silent-error: 1.1.1 + optionalDependencies: + '@ember/test-helpers': 3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)) + transitivePeerDependencies: + - '@babel/core' + - '@glint/template' + - ember-source + - eslint + - supports-color + ember-source-channel-url@3.0.0: dependencies: node-fetch: 2.7.0 @@ -13891,6 +14696,8 @@ snapshots: eventemitter3@4.0.7: {} + eventemitter3@5.0.4: {} + events-to-array@2.0.3: {} events@3.3.0: {} @@ -14059,6 +14866,8 @@ snapshots: assign-symbols: 1.0.0 is-extendable: 1.0.1 + extend@3.0.2: {} + external-editor@3.1.0: dependencies: chardet: 0.7.0 @@ -14084,6 +14893,17 @@ snapshots: fast-diff@1.3.0: {} + fast-glob@2.2.7: + dependencies: + '@mrmlnc/readdir-enhanced': 2.2.1 + '@nodelib/fs.stat': 1.1.3 + glob-parent: 3.1.0 + is-glob: 4.0.3 + merge2: 1.4.1 + micromatch: 3.1.10 + transitivePeerDependencies: + - supports-color + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -14096,6 +14916,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-memoize@2.5.2: {} + fast-ordered-set@1.0.3: dependencies: blank-object: 1.0.2 @@ -14533,7 +15355,6 @@ snapshots: dependencies: is-glob: 3.1.0 path-dirname: 1.0.2 - optional: true glob-parent@5.1.2: dependencies: @@ -14543,6 +15364,8 @@ snapshots: dependencies: is-glob: 4.0.3 + glob-to-regexp@0.3.0: {} + glob-to-regexp@0.4.1: {} glob@10.5.0: @@ -14967,6 +15790,20 @@ snapshots: hasown: 2.0.3 side-channel: 1.1.0 + intl-messageformat@10.7.18: + dependencies: + '@formatjs/ecma402-abstract': 2.3.6 + '@formatjs/fast-memoize': 2.2.7 + '@formatjs/icu-messageformat-parser': 2.11.4 + tslib: 2.8.1 + + intl-messageformat@10.7.7: + dependencies: + '@formatjs/ecma402-abstract': 2.2.4 + '@formatjs/fast-memoize': 2.2.3 + '@formatjs/icu-messageformat-parser': 2.9.4 + tslib: 2.8.1 + intl-tel-input@22.0.2: {} invariant@2.2.4: @@ -15085,7 +15922,6 @@ snapshots: is-glob@3.1.0: dependencies: is-extglob: 2.1.1 - optional: true is-glob@4.0.3: dependencies: @@ -15347,6 +16183,8 @@ snapshots: lines-and-columns@1.2.4: {} + linked-list@0.1.0: {} + linkify-it@4.0.1: dependencies: uc.micro: 1.0.6 @@ -15403,6 +16241,8 @@ snapshots: lodash.debounce@4.0.8: {} + lodash.defaults@4.2.0: {} + lodash.defaultsdeep@4.6.1: {} lodash.kebabcase@4.1.1: {} @@ -15878,6 +16718,8 @@ snapshots: normalize-url@4.5.1: {} + npm-git-info@1.0.3: {} + npm-package-arg@10.1.0: dependencies: hosted-git-info: 6.1.3 @@ -16013,6 +16855,10 @@ snapshots: p-defer@3.0.0: {} + p-event@2.3.1: + dependencies: + p-timeout: 2.0.1 + p-finally@1.0.0: {} p-finally@2.0.1: {} @@ -16055,6 +16901,10 @@ snapshots: dependencies: p-limit: 4.0.0 + p-timeout@2.0.1: + dependencies: + p-finally: 1.0.0 + p-try@1.0.0: {} p-try@2.2.0: {} @@ -16114,8 +16964,7 @@ snapshots: path-browserify@0.0.1: {} - path-dirname@1.0.2: - optional: true + path-dirname@1.0.2: {} path-exists@3.0.0: {} @@ -17146,6 +17995,12 @@ snapshots: minimist: 1.2.8 walker: 1.0.8 + sc-errors@2.0.3: {} + + sc-errors@3.0.0: {} + + sc-formatter@4.0.0: {} + schema-utils@1.0.0: dependencies: ajv: 6.15.0 @@ -17274,6 +18129,10 @@ snapshots: safe-buffer: 5.2.1 to-buffer: 1.2.2 + shallow-clone@3.0.1: + dependencies: + kind-of: 6.0.3 + shebang-command@1.2.0: dependencies: shebang-regex: 1.0.0 @@ -17400,6 +18259,24 @@ snapshots: - supports-color - utf-8-validate + socketcluster-client@17.2.2: + dependencies: + ag-channel: 5.0.0 + ag-request: 1.1.0 + async-stream-emitter: 4.1.0 + buffer: 5.7.1 + clone-deep: 4.0.1 + linked-list: 0.1.0 + sc-errors: 2.0.3 + sc-formatter: 4.0.0 + stream-demux: 8.1.0 + uuid: 8.3.2 + vinyl-buffer: 1.0.1 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + sort-object-keys@1.1.3: {} sort-package-json@1.57.0: @@ -17501,6 +18378,11 @@ snapshots: inherits: 2.0.4 readable-stream: 2.3.8 + stream-demux@8.1.0: + dependencies: + consumable-stream: 2.0.0 + writable-consumable-stream: 3.0.1 + stream-each@1.2.3: dependencies: end-of-stream: 1.4.5 @@ -18282,6 +19164,11 @@ snapshots: vary@1.1.2: {} + vinyl-buffer@1.0.1: + dependencies: + bl: 1.2.3 + through2: 2.0.5 + vm-browserify@1.1.2: {} w3c-keyname@2.2.8: {} @@ -18531,6 +19418,10 @@ snapshots: wrappy@1.0.2: {} + writable-consumable-stream@3.0.1: + dependencies: + consumable-stream: 2.0.0 + write-file-atomic@3.0.3: dependencies: imurmurhash: 0.1.4 From 815bb75a462a3d69488cab7db731ae7dcb0e6329 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 15 Sep 2026 17:31:33 +0800 Subject: [PATCH 02/40] Stub the universe registry and widget services in the dummy app With ember-core installed, the report-widget instance-initializer finds a real universe/widget-service, which injects universe/registry-service, which needs the console's extension loader and cannot boot outside the console. Give the dummy app small stubs with the section/list/key storage the addon relies on, so rendering tests boot cleanly. --- .../app/services/universe/registry-service.js | 66 +++++++++++++++++++ .../app/services/universe/widget-service.js | 23 +++++++ 2 files changed, 89 insertions(+) create mode 100644 tests/dummy/app/services/universe/registry-service.js create mode 100644 tests/dummy/app/services/universe/widget-service.js diff --git a/tests/dummy/app/services/universe/registry-service.js b/tests/dummy/app/services/universe/registry-service.js new file mode 100644 index 00000000..4e8db2be --- /dev/null +++ b/tests/dummy/app/services/universe/registry-service.js @@ -0,0 +1,66 @@ +import Service from '@ember/service'; +import { A } from '@ember/array'; + +/** + * Stub of the host console's `universe/registry-service`, with the section / + * list / key storage the addon relies on. The real service lives in + * ember-core and needs the console's extension loader to boot. + */ +export default class UniverseRegistryService extends Service { + calls = []; + registries = new Map(); + + /** Map of registryName -> renderable components array. */ + renderableComponents = {}; + + getOrCreateList(sectionName, listName) { + if (!this.registries.has(sectionName)) { + this.registries.set(sectionName, {}); + } + + const section = this.registries.get(sectionName); + + if (!section[listName]) { + section[listName] = A([]); + } + + return section[listName]; + } + + register(sectionName, listName, key, value) { + this.calls.push({ method: 'register', args: [sectionName, listName, key] }); + + const list = this.getOrCreateList(sectionName, listName); + + if (typeof value === 'object' && value !== null) { + value._registryKey = key; + } + + const existing = list.find((item) => item && item._registryKey === key); + + if (existing) { + list.replace(list.indexOf(existing), 1, [value]); + } else { + list.pushObject(value); + } + } + + getRegistry(sectionName, listName) { + const section = this.registries.get(sectionName); + + return section?.[listName] ?? A([]); + } + + lookup(sectionName, listName, key) { + return this.getRegistry(sectionName, listName).find((item) => item && item._registryKey === key) ?? null; + } + + createRegistry(sectionName, type = 'menu-item') { + return this.getOrCreateList(sectionName, type); + } + + getRenderableComponents(registryName) { + this.calls.push({ method: 'getRenderableComponents', args: [registryName] }); + return this.renderableComponents[registryName] ?? []; + } +} diff --git a/tests/dummy/app/services/universe/widget-service.js b/tests/dummy/app/services/universe/widget-service.js new file mode 100644 index 00000000..778aed24 --- /dev/null +++ b/tests/dummy/app/services/universe/widget-service.js @@ -0,0 +1,23 @@ +import Service from '@ember/service'; + +/** + * Stub of the host console's `universe/widget-service`. Widget registrations + * are recorded and lookups return empty arrays. + */ +export default class UniverseWidgetService extends Service { + calls = []; + widgets = {}; + + registerWidgets(dashboardId, widgets) { + this.calls.push({ method: 'registerWidgets', args: [dashboardId, widgets] }); + this.widgets[dashboardId] = [...(this.widgets[dashboardId] ?? []), ...(Array.isArray(widgets) ? widgets : [widgets])]; + } + + getWidgets(dashboardId) { + return this.widgets[dashboardId] ?? []; + } + + getDefaultWidgets() { + return []; + } +} From 03f29308c0fb238cad1cef0c147b7a4e6f77ff24 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 15 Sep 2026 17:31:33 +0800 Subject: [PATCH 03/40] Apply @class to the popover's floating element Attach::Popover copied @class onto the component but never rendered it; only @classNames reached the DOM, on the inner div. Put @class on the floating .ember-attacher element so a caller can size and position the popover as a whole, and replace the placeholder test with one that renders the popover. --- addon/components/attach/popover.hbs | 2 +- addon/components/attach/popover.js | 1 + .../components/attach/popover-test.js | 40 ++++++++++++------- 3 files changed, 28 insertions(+), 15 deletions(-) diff --git a/addon/components/attach/popover.hbs b/addon/components/attach/popover.hbs index 24d66698..1a25ea67 100644 --- a/addon/components/attach/popover.hbs +++ b/addon/components/attach/popover.hbs @@ -2,7 +2,7 @@ {{#if (and this.currentTarget (or (not this.lazyRender) this.mustRender))}} div'); +} + +async function waitForShown() { + await waitUntil(() => attacher()?.getAttribute('aria-hidden') === 'false', { timeout: 2000 }); + await settled(); +} + module('Integration | Component | attach/popover', function (hooks) { setupRenderingTest(hooks); - test('it renders', async function (assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.set('myAction', function(val) { ... }); + test('it renders its block once shown', async function (assert) { + await render(hbs``); + await waitForShown(); - await render(hbs``); - - assert.dom(this.element).hasText(''); + assert.dom(attacher()).includesText('popover text'); + }); - // Template block usage: - await render(hbs` - - template block text - - `); + test('it applies @class to the floating element', async function (assert) { + await render(hbs``); + await waitForShown(); - assert.dom(this.element).hasText('template block text'); + assert.dom(attacher()).hasClass('ember-attacher'); + assert.dom(attacher()).hasClass('resource-hover-card'); + assert.dom(content()).hasClass('inner-class'); + assert.dom(content()).doesNotHaveClass('resource-hover-card'); }); }); From 5c4018d3638353e866e50e3c9f682611efeda409 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 15 Sep 2026 17:31:34 +0800 Subject: [PATCH 04/40] Let an interactive popover opened by hover be entered computedStyle wrote pointer-events: none unless isShown, but show() never set isShown, and Floating forced pointer-events: none on the floating element as well. So an @interactive popover could never be entered: the document mousemove saw whatever lay underneath and hid it. Maintain isShown in show() and hide(), let Floating take its pointer-events value from the popover, and only ever enable pointer events for interactive popovers so plain tooltips stay transparent to the pointer. @isShown is now followed after the first render as well. The test drives the real sequence: hover the target, leave it, move onto the popover and check it stays open with computed pointer-events auto. --- addon/components/attach/popover.hbs | 3 +- addon/components/attach/popover.js | 38 +++++++++- addon/components/floating.js | 2 +- .../components/attach/popover-test.js | 76 ++++++++++++++++++- 4 files changed, 113 insertions(+), 6 deletions(-) diff --git a/addon/components/attach/popover.hbs b/addon/components/attach/popover.hbs index 1a25ea67..f431dc16 100644 --- a/addon/components/attach/popover.hbs +++ b/addon/components/attach/popover.hbs @@ -1,8 +1,9 @@ -
+
{{#if (and this.currentTarget (or (not this.lazyRender) this.mustRender))}} { this.animationTimeout = this.hide(); @@ -344,6 +373,7 @@ export default class AttachPopoverComponent extends Component { } this.mustRender = true; + this.isShown = true; // Make the attachment visible immediately so transition animations can take place this.setIsVisibleAfterDelay(true, 0); @@ -417,6 +447,7 @@ export default class AttachPopoverComponent extends Component { this.animationTimeout = requestAnimationFrame(() => { if (!this.isDestroyed && !this.isDestroying) { this.floatingElement.style.display = isVisible ? '' : 'none'; + this.floatingElement.style.pointerEvents = this.floatingPointerEvents; // Prevent jank by making the attachment invisible until positioned. // The visibility style will be toggled by this.startShowAnimation() @@ -432,6 +463,7 @@ export default class AttachPopoverComponent extends Component { ); } else { this.floatingElement.style.display = isVisible ? '' : 'none'; + this.floatingElement.style.pointerEvents = this.floatingPointerEvents; // Prevent jank by making the attachment invisible until positioned. // The visibility style will be toggled by this.startShowAnimation() diff --git a/addon/components/floating.js b/addon/components/floating.js index 3f0b684d..09d463ed 100644 --- a/addon/components/floating.js +++ b/addon/components/floating.js @@ -141,7 +141,7 @@ export default class FloatingComponent extends Component { }).then(({ x, y }) => { assign(element.style, { position: 'absolute', - pointerEvents: 'none', + pointerEvents: this.args.pointerEvents ?? 'none', willChange: 'transform', top: '0', left: '0', diff --git a/tests/integration/components/attach/popover-test.js b/tests/integration/components/attach/popover-test.js index 90214621..2b690ad2 100644 --- a/tests/integration/components/attach/popover-test.js +++ b/tests/integration/components/attach/popover-test.js @@ -1,6 +1,6 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render, settled, waitUntil } from '@ember/test-helpers'; +import { render, triggerEvent, triggerKeyEvent, settled, waitUntil } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; function attacher() { @@ -16,6 +16,11 @@ async function waitForShown() { await settled(); } +async function waitForHidden() { + await waitUntil(() => attacher()?.getAttribute('aria-hidden') === 'true', { timeout: 2000 }); + await settled(); +} + module('Integration | Component | attach/popover', function (hooks) { setupRenderingTest(hooks); @@ -35,4 +40,73 @@ module('Integration | Component | attach/popover', function (hooks) { assert.dom(content()).hasClass('inner-class'); assert.dom(content()).doesNotHaveClass('resource-hover-card'); }); + + test('an interactive popover opened by hover accepts pointer events and can be entered', async function (assert) { + await render(hbs` + + `); + + assert.notOk(attacher()?.getAttribute('aria-hidden') === 'false', 'hidden before hover'); + + await triggerEvent('#target', 'mouseenter'); + await waitForShown(); + + assert.strictEqual(getComputedStyle(content()).pointerEvents, 'auto', 'the content accepts pointer events while shown'); + assert.strictEqual(attacher().style.pointerEvents, 'auto', 'the floating element accepts pointer events while shown'); + + // Leave the target and move onto the popover: it must stay open. + await triggerEvent('#target', 'mouseleave'); + await triggerEvent('#inside', 'mousemove'); + await settled(); + assert.strictEqual(attacher().getAttribute('aria-hidden'), 'false', 'still shown while the pointer is over the popover'); + + // Move away from both: it hides, and pointer events go back to none. + await triggerEvent(document.body, 'mousemove'); + await waitForHidden(); + assert.strictEqual(getComputedStyle(content()).pointerEvents, 'none', 'transparent to the pointer once hidden'); + assert.strictEqual(attacher().style.pointerEvents, 'none'); + }); + + test('a non-interactive popover stays transparent to the pointer while shown', async function (assert) { + await render(hbs``); + await waitForShown(); + + assert.strictEqual(getComputedStyle(content()).pointerEvents, 'none'); + assert.strictEqual(attacher().style.pointerEvents, 'none'); + }); + + test('it hides on escape and follows later changes to @isShown', async function (assert) { + this.set('shown', false); + await render(hbs``); + + assert.notOk(attacher(), 'lazy popover renders nothing until it is shown'); + + this.set('shown', true); + await waitForShown(); + assert.strictEqual(attacher().getAttribute('aria-hidden'), 'false', 'shown after @isShown becomes true'); + + this.set('shown', false); + await waitForHidden(); + assert.strictEqual(attacher().getAttribute('aria-hidden'), 'true', 'hidden after @isShown becomes false'); + + this.set('shown', true); + await waitForShown(); + await triggerKeyEvent(document, 'keydown', 'Escape'); + await waitForHidden(); + assert.strictEqual(attacher().getAttribute('aria-hidden'), 'true', 'hidden after escape'); + }); + + test('setting @isShown to false before anything rendered is a no-op', async function (assert) { + this.set('shown', undefined); + await render(hbs``); + + this.set('shown', false); + await settled(); + + assert.notOk(attacher(), 'nothing rendered and nothing thrown'); + }); }); From 0868af9e59f0ca0207e5beadb8d0b69b4cfe114a Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 15 Sep 2026 17:31:34 +0800 Subject: [PATCH 05/40] Forward the popover options Attach::Tooltip dropped Attach::Tooltip passed a fixed subset of its arguments through, so hideDelay, hideDuration, @class, @style, floatingTarget, floatingContainer and onChange were silently ignored; every @class="clean" in the addon was a no-op. Forward them to the popover. --- addon/components/attach/tooltip.hbs | 7 ++ .../components/attach/tooltip-test.js | 72 +++++++++++++++---- 2 files changed, 67 insertions(+), 12 deletions(-) diff --git a/addon/components/attach/tooltip.hbs b/addon/components/attach/tooltip.hbs index 023791af..43710de0 100644 --- a/addon/components/attach/tooltip.hbs +++ b/addon/components/attach/tooltip.hbs @@ -7,6 +7,12 @@ @offset={{@offset}} @shiftOptions={{@shiftOptions}} @animation={{@animation}} + @class={{@class}} + @style={{@style}} + @floatingTarget={{@floatingTarget}} + @floatingContainer={{@floatingContainer}} + @hideDelay={{@hideDelay}} + @hideDuration={{@hideDuration}} @hideOn={{@hideOn}} @showOn={{@showOn}} @interactive={{@interactive}} @@ -15,5 +21,6 @@ @showDelay={{@showDelay}} @showDuration={{@showDuration}} @transitionDuration={{@transitionDuration}} + @onChange={{@onChange}} ...attributes >{{yield}} \ No newline at end of file diff --git a/tests/integration/components/attach/tooltip-test.js b/tests/integration/components/attach/tooltip-test.js index 262d179d..b3258f82 100644 --- a/tests/integration/components/attach/tooltip-test.js +++ b/tests/integration/components/attach/tooltip-test.js @@ -1,26 +1,74 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { find, render, settled, triggerEvent, waitUntil } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +function attacher() { + return document.querySelector('.ember-attacher'); +} + +async function waitForShown() { + await waitUntil(() => attacher()?.getAttribute('aria-hidden') === 'false', { timeout: 2000 }); + await settled(); +} + module('Integration | Component | attach/tooltip', function (hooks) { setupRenderingTest(hooks); - test('it renders', async function (assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.set('myAction', function(val) { ... }); + test('it renders its block as a tooltip', async function (assert) { + await render(hbs``); + await waitForShown(); - await render(hbs``); + assert.dom(attacher()).includesText('tooltip text'); + assert.dom('.ember-attacher > div').hasClass('ember-attacher-tooltip'); + assert.dom(attacher()).hasAttribute('role', 'tooltip'); + }); - assert.dom(this.element).hasText(''); + test('it forwards class, style, delays, target, container and onChange to the popover', async function (assert) { + const changes = []; + this.set('onChange', (visible) => changes.push(visible)); + await render(hbs` +
+ +
+ + forwarded + +
+ `); + await waitForShown(); - // Template block usage: + assert.dom(attacher()).hasClass('clean'); + assert.ok(document.querySelector('.ember-attacher > div').getAttribute('style').includes('min-width: 123px'), 'style is applied'); + assert.ok(document.querySelector('#container .ember-attacher'), 'rendered into the requested container'); + assert.deepEqual(changes, [true], 'onChange reported the tooltip becoming visible'); + }); + + test('it targets the element given as @floatingTarget', async function (assert) { + this.set('target', null); await render(hbs` - - template block text - - `); + +
+ {{#if this.target}} + targeted + {{/if}} +
+ `); + this.set('target', find('#target')); + await settled(); + + await triggerEvent('#target', 'mouseenter'); + await waitForShown(); - assert.dom(this.element).hasText('template block text'); + assert.dom(attacher()).includesText('targeted'); }); }); From 2aee601ace01778bcbf6d3c145f1c46681a27f33 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 15 Sep 2026 22:04:35 +0800 Subject: [PATCH 06/40] Render a pill with nothing to do as a static span, and hand the tooltip component its resource Pill always rendered an anchor, so a pill that only names a record looked clickable and did nothing when clicked; the hover style FleetOps added for it matched those pills too. Render a non-link span with the fleetbase-pill--static class when there is no @onClick, move the hover style into the addon scoped to real anchors, and share the pill body between the two variants. The tooltip component now receives the resource it is describing, and the online dot can be driven by an explicit @online flag as well as a path on the resource. --- addon/components/pill.hbs | 123 +++++++++++++--------- addon/components/pill.js | 14 +++ addon/components/pill/body.hbs | 43 ++++++++ addon/styles/addon.css | 1 + addon/styles/components/pill.css | 29 +++++ app/components/pill/body.js | 1 + tests/integration/components/pill-test.js | 117 ++++++++++++++++++-- 7 files changed, 267 insertions(+), 61 deletions(-) create mode 100644 addon/components/pill/body.hbs create mode 100644 addon/styles/components/pill.css create mode 100644 app/components/pill/body.js diff --git a/addon/components/pill.hbs b/addon/components/pill.hbs index 5a18a714..2a35c817 100644 --- a/addon/components/pill.hbs +++ b/addon/components/pill.hbs @@ -1,50 +1,73 @@ - \ No newline at end of file +
+ {{#if this.isClickable}} + + + <:image as |resource|>{{yield resource to="image"}} + <:default as |resource|>{{yield resource}} + <:tooltip as |resource|>{{yield resource to="tooltip"}} + + + {{else}} + + + <:image as |resource|>{{yield resource to="image"}} + <:default as |resource|>{{yield resource}} + <:tooltip as |resource|>{{yield resource to="tooltip"}} + + + {{/if}} +
diff --git a/addon/components/pill.js b/addon/components/pill.js index 94067a47..e9a30471 100644 --- a/addon/components/pill.js +++ b/addon/components/pill.js @@ -4,6 +4,20 @@ import getModelName from '@fleetbase/ember-core/utils/get-model-name'; export default class PillComponent extends Component { /* eslint-disable ember/no-get */ + get isClickable() { + return typeof this.args.onClick === 'function'; + } + + get isOnline() { + if (this.args.online !== undefined) { + return Boolean(this.args.online); + } + + const record = this.args.resource; + + return record ? Boolean(get(record, this.args.onlinePath ?? 'online')) : false; + } + get resourceName() { const record = this.args.resource; if (!record) return null; diff --git a/addon/components/pill/body.hbs b/addon/components/pill/body.hbs new file mode 100644 index 00000000..4f5262d6 --- /dev/null +++ b/addon/components/pill/body.hbs @@ -0,0 +1,43 @@ +{{! The inside of a pill, shared by the clickable anchor and the static span. }} +
+ {{#if @hasImageBlock}} + {{yield @resource to="image"}} + {{else}} + {{or + {{#if @showOnlineIndicator}} + + {{/if}} + {{yield @resource to="image"}} + {{/if}} +
+
+ {{#if @hasDefaultBlock}} + {{yield @resource}} + {{else}} +
{{or @title @resourceName @titleFallback "-"}}
+ {{#if @subtitle}} +
{{n-a @subtitle}}
+ {{/if}} + {{yield @resource}} + {{/if}} +
+{{#unless @noTooltip}} + {{#if @hasTooltipBlock}} + + + {{yield @resource to="tooltip"}} + + + {{else if @tooltipComponent}} + + {{component @tooltipComponent resource=@resource}} + + {{/if}} +{{/unless}} diff --git a/addon/styles/addon.css b/addon/styles/addon.css index dec85972..0b83cb5e 100644 --- a/addon/styles/addon.css +++ b/addon/styles/addon.css @@ -24,6 +24,7 @@ @import 'components/pagination.css'; @import 'components/overlay.css'; @import 'components/badge.css'; +@import 'components/pill.css'; @import 'components/dropdown.css'; @import 'components/cards.css'; @import 'components/timeline.css'; diff --git a/addon/styles/components/pill.css b/addon/styles/components/pill.css new file mode 100644 index 00000000..3ac691d4 --- /dev/null +++ b/addon/styles/components/pill.css @@ -0,0 +1,29 @@ +/* + * A pill names a linked record: image, name, identifier. Only a pill that + * was given something to do (an anchor) reacts to the pointer; the static + * variant reads as text. + */ +.fleetbase-pill > a[href] { + border-radius: 0.375rem; + padding: 0.125rem; + margin: -0.125rem; + transition: + background-color 120ms ease, + opacity 120ms ease; +} + +.fleetbase-pill > a[href]:hover { + background-color: rgb(0 0 0 / 4%); +} + +.fleetbase-pill > a[href]:hover .text-sm { + text-decoration: underline; +} + +body[data-theme='dark'] .fleetbase-pill > a[href]:hover { + background-color: rgb(255 255 255 / 6%); +} + +.fleetbase-pill--static > .fleetbase-pill-body { + cursor: default; +} diff --git a/app/components/pill/body.js b/app/components/pill/body.js new file mode 100644 index 00000000..72863aaf --- /dev/null +++ b/app/components/pill/body.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/pill/body'; diff --git a/tests/integration/components/pill-test.js b/tests/integration/components/pill-test.js index 39162d30..9d8315e7 100644 --- a/tests/integration/components/pill-test.js +++ b/tests/integration/components/pill-test.js @@ -1,26 +1,121 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { render, click } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; module('Integration | Component | pill', function (hooks) { setupRenderingTest(hooks); - test('it renders', async function (assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.set('myAction', function(val) { ... }); + test('it renders the resource name, subtitle and image', async function (assert) { + this.set('resource', { name: 'Ada Driver', photo_url: 'https://example.test/ada.png' }); + + await render(hbs``); + + assert.dom('.fleetbase-pill').includesText('Ada Driver'); + assert.dom('.fleetbase-pill').includesText('+1 555'); + assert.dom('.fleetbase-pill img').hasAttribute('src', 'https://example.test/ada.png'); + }); + + test('it falls back through display_name, displayName, tracking, public_id and the title fallback', async function (assert) { + this.set('resource', { display_name: 'Truck 1' }); + await render(hbs``); + assert.dom('.fleetbase-pill').includesText('Truck 1'); + + this.set('resource', { displayName: 'Truck 2' }); + await render(hbs``); + assert.dom('.fleetbase-pill').includesText('Truck 2'); + + this.set('resource', { tracking: 'TRK-3' }); + await render(hbs``); + assert.dom('.fleetbase-pill').includesText('TRK-3'); + + this.set('resource', { public_id: 'vehicle_4' }); + await render(hbs``); + assert.dom('.fleetbase-pill').includesText('vehicle_4'); + + this.set('resource', null); + await render(hbs``); + assert.dom('.fleetbase-pill').includesText('No vehicle'); await render(hbs``); + assert.dom('.fleetbase-pill').includesText('-'); + }); + + test('it is a link only when given something to do', async function (assert) { + assert.expect(7); + + this.set('resource', { name: 'Ada' }); + this.set('onClick', (resource, event) => { + assert.strictEqual(resource, this.resource, 'the resource is passed first'); + assert.ok(event instanceof Event, 'followed by the event'); + }); + + await render(hbs``); + assert.dom('.fleetbase-pill > a[href]').exists('clickable pills render an anchor'); + assert.dom('.fleetbase-pill').doesNotHaveClass('fleetbase-pill--static'); + await click('.fleetbase-pill > a'); - assert.dom().hasText(''); + await render(hbs``); + assert.dom('.fleetbase-pill > a').doesNotExist('a pill with nothing to do is not a link'); + assert.dom('.fleetbase-pill').hasClass('fleetbase-pill--static'); + assert.dom('[data-test-pill-static]').includesText('Ada'); + }); + + test('it calls onClick without a resource argument when there is no resource', async function (assert) { + assert.expect(1); + this.set('onClick', (first) => assert.ok(first instanceof Event, 'the event is the first argument')); + + await render(hbs``); + await click('.fleetbase-pill > a'); + }); + + test('it passes the resource to the tooltip component', async function (assert) { + this.owner.register('template:components/test-tip', hbs`Tip for {{@resource.name}}`); + this.set('resource', { name: 'Ada' }); + + await render(hbs``); + + assert.dom('[data-test-tip]', document.body).hasText('Tip for Ada'); + }); + + test('it yields the resource to the image, default and tooltip blocks', async function (assert) { + this.set('resource', { name: 'Ada' }); - // Template block usage: await render(hbs` - - template block text - - `); + + <:image as |r|>{{r.name}} + <:default as |r|>{{r.name}} + <:tooltip as |r|>{{r.name}} + + `); + + assert.dom('[data-test-image-block]').hasText('Ada'); + assert.dom('[data-test-default-block]').hasText('Ada'); + assert.dom('.fleetbase-pill img').doesNotExist('the image block replaces the default image'); + assert.dom('[data-test-tooltip-block]', document.body).hasText('Ada'); + }); + + test('it renders no tooltip when @noTooltip is set', async function (assert) { + this.set('resource', { name: 'Ada' }); + + await render(hbs`<:tooltip>never`); + + assert.dom('.ember-attacher', document.body).doesNotExist(); + }); + + test('the online indicator reads @online first, then the online path on the resource', async function (assert) { + this.set('resource', { name: 'Ada', online: false, is_online: true }); + + await render(hbs``); + assert.dom('[data-test-pill-online-indicator]').hasClass('text-yellow-200', 'offline by the default path'); + + await render(hbs``); + assert.dom('[data-test-pill-online-indicator]').hasClass('text-green-500', 'online by a custom path'); + + await render(hbs``); + assert.dom('[data-test-pill-online-indicator]').hasClass('text-green-500', '@online wins'); - assert.dom().hasText('template block text'); + await render(hbs``); + assert.dom('[data-test-pill-online-indicator]').hasClass('text-yellow-200', 'no resource means offline'); }); }); From 2a3802536815f569dc518d055b66c0f2efc17e80 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 15 Sep 2026 22:04:35 +0800 Subject: [PATCH 07/40] Forward the search and selected-item options MultiSelect dropped filter/multi-option already passed @searchField and @searchPlaceholder to MultiSelect, which never forwarded them to PowerSelectMultiple, and there was no way to render a selected chip with a component. Forward @selectedItemComponent, @searchField, @searchFieldPosition and @searchPlaceholder. --- addon/components/multi-select.hbs | 4 ++ .../components/multi-select-test.js | 48 ++++++++++++++----- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/addon/components/multi-select.hbs b/addon/components/multi-select.hbs index 0950a38b..54aedac7 100644 --- a/addon/components/multi-select.hbs +++ b/addon/components/multi-select.hbs @@ -2,6 +2,9 @@ class="form-select form-input {{@selectClass}}" @registerAPI={{@registerAPI}} @searchEnabled={{@searchEnabled}} + @searchField={{@searchField}} + @searchFieldPosition={{@searchFieldPosition}} + @searchPlaceholder={{@searchPlaceholder}} @search={{@search}} @options={{@options}} @selected={{@selected}} @@ -9,6 +12,7 @@ @onChange={{@onChange}} @allowClear={{@allowClear}} @placeholderComponent={{@placeholderComponent}} + @selectedItemComponent={{@selectedItemComponent}} @preventScroll={{@preventScroll}} @renderInPlace={{@renderInPlace}} @scrollTo={{@scrollTo}} diff --git a/tests/integration/components/multi-select-test.js b/tests/integration/components/multi-select-test.js index 495402c3..57dfa51d 100644 --- a/tests/integration/components/multi-select-test.js +++ b/tests/integration/components/multi-select-test.js @@ -1,26 +1,50 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { render, fillIn, settled } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import { clickTrigger } from 'ember-power-select/test-support/helpers'; module('Integration | Component | multi-select', function (hooks) { setupRenderingTest(hooks); - test('it renders', async function (assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.set('myAction', function(val) { ... }); + test('it renders options and forwards the selected item component', async function (assert) { + this.owner.register('template:components/test-selected', hbs`{{@option.name}}`); + this.set('options', [{ name: 'Ada' }, { name: 'Bob' }]); + this.set('selected', [this.options[0]]); - await render(hbs``); + await render(hbs` + + {{option.name}} + + `); + + assert.dom('[data-test-selected]').hasText('Ada'); + }); - assert.dom(this.element).hasText(''); + test('it forwards the search field, position and placeholder', async function (assert) { + this.set('options', [{ name: 'Ada' }, { name: 'Bob' }]); + this.set('selected', []); - // Template block usage: await render(hbs` - - template block text - - `); + + {{option.name}} + + `); + + await clickTrigger('.ember-power-select-trigger'); + assert.dom('.ember-power-select-search-input').hasAttribute('placeholder', 'Find a person'); - assert.dom(this.element).hasText('template block text'); + await fillIn('.ember-power-select-search-input', 'bo'); + await settled(); + assert.dom('.ember-power-select-option').exists({ count: 1 }); + assert.dom('.ember-power-select-option').hasText('Bob'); }); }); From 5693211fe6eb617eff9bf33457d430a39f975ee3 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 15 Sep 2026 22:04:36 +0800 Subject: [PATCH 08/40] Give the model filter an option component, and add a model-multiple filter filter/model rendered a fixed ModelSelect whose options were the bare label path. Each option and the selected item now render through the column's filterOptionComponent, or the select-option registered for the filtered model, falling back to modelNamePath text as before. filter/model-multiple is the multi-record filter FleetOps kept locally as filter/multi-model: the value travels as a comma-separated id list and the records are re-fetched on init so chips show names after a reload. It uses the same option hook. --- addon/components/filter/model-multiple.hbs | 24 ++++ addon/components/filter/model-multiple.js | 106 ++++++++++++++++ addon/components/filter/model.hbs | 12 +- addon/components/filter/model.js | 21 ++++ addon/styles/layout/next.css | 16 +++ app/components/filter/model-multiple.js | 1 + .../components/filter/model-multiple-test.js | 118 ++++++++++++++++++ .../components/filter/model-test.js | 91 ++++++++++++-- 8 files changed, 374 insertions(+), 15 deletions(-) create mode 100644 addon/components/filter/model-multiple.hbs create mode 100644 addon/components/filter/model-multiple.js create mode 100644 app/components/filter/model-multiple.js create mode 100644 tests/integration/components/filter/model-multiple-test.js diff --git a/addon/components/filter/model-multiple.hbs b/addon/components/filter/model-multiple.hbs new file mode 100644 index 00000000..05eb430c --- /dev/null +++ b/addon/components/filter/model-multiple.hbs @@ -0,0 +1,24 @@ +
+ + {{#if this.optionComponent}} + {{component this.optionComponent option=model}} + {{else}} + {{get model this.optionLabel}} + {{/if}} + + +
diff --git a/addon/components/filter/model-multiple.js b/addon/components/filter/model-multiple.js new file mode 100644 index 00000000..e14a67af --- /dev/null +++ b/addon/components/filter/model-multiple.js @@ -0,0 +1,106 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { getOwner } from '@ember/application'; +import { isArray } from '@ember/array'; +import { task } from 'ember-concurrency'; +import { resourceComponentName } from '../../utils/resource-registry'; + +/** + * Table filter that selects several records of one model. The filter value + * travels as a comma-separated list of ids so it survives the URL round trip; + * the matching records are re-fetched on init so the chips show names rather + * than ids after a reload. + */ +export default class FilterModelMultipleComponent extends Component { + @service store; + @tracked selectedModels = []; + + constructor() { + super(...arguments); + this.restoreSelection.perform(this.args.value); + } + + get modelName() { + return this.args.filter?.model; + } + + get optionLabel() { + return this.args.filter?.modelNamePath ?? 'name'; + } + + get optionComponent() { + const { filter } = this.args; + + if (filter?.filterOptionComponent) { + return filter.filterOptionComponent; + } + + return resourceComponentName(getOwner(this), 'select-option', this.modelName); + } + + parseIds(value) { + if (isArray(value)) { + return value.filter(Boolean); + } + + if (typeof value === 'string' && value.trim()) { + return value + .split(',') + .map((id) => id.trim()) + .filter(Boolean); + } + + return []; + } + + @task *restoreSelection(value) { + const ids = this.parseIds(value); + + if (!ids.length || !this.modelName) { + this.selectedModels = []; + return; + } + + const records = []; + + for (const id of ids) { + try { + records.push(this.store.peekRecord(this.modelName, id) ?? (yield this.store.findRecord(this.modelName, id))); + } catch { + // An id that no longer resolves simply drops out of the chips. + } + } + + this.selectedModels = records.filter(Boolean); + } + + @action onChange(selection) { + const { onChange, onClear, filter } = this.args; + const models = isArray(selection) ? selection : []; + + this.selectedModels = models; + + if (!models.length) { + if (typeof onClear === 'function') { + onClear(filter); + } + return; + } + + if (typeof onChange === 'function') { + onChange(filter, models.map((model) => model.id).join(',')); + } + } + + @action clear() { + const { onClear, filter } = this.args; + + this.selectedModels = []; + + if (typeof onClear === 'function') { + onClear(filter); + } + } +} diff --git a/addon/components/filter/model.hbs b/addon/components/filter/model.hbs index 0c74b170..ad04d3da 100644 --- a/addon/components/filter/model.hbs +++ b/addon/components/filter/model.hbs @@ -1,7 +1,7 @@ \ No newline at end of file + @selectedItemComponent={{if this.optionComponent (component this.optionComponent compact=true)}} + as |model| +> + {{#if this.optionComponent}} + {{component this.optionComponent option=model}} + {{else}} + {{get model this.optionLabel}} + {{/if}} + diff --git a/addon/components/filter/model.js b/addon/components/filter/model.js index 345684d9..b3ebc55d 100644 --- a/addon/components/filter/model.js +++ b/addon/components/filter/model.js @@ -1,6 +1,8 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; +import { getOwner } from '@ember/application'; +import { resourceComponentName } from '../../utils/resource-registry'; export default class FilterModelComponent extends Component { @tracked selectedModel; @@ -10,6 +12,25 @@ export default class FilterModelComponent extends Component { this.selectedModel = this.args.value; } + get optionLabel() { + return this.args.filter?.modelNamePath ?? 'name'; + } + + /** + * The component that renders each option and the selected item: the + * column's own `filterOptionComponent`, else the select-option registered + * for the filtered model, else nothing (the label path is shown as text). + */ + get optionComponent() { + const { filter } = this.args; + + if (filter?.filterOptionComponent) { + return filter.filterOptionComponent; + } + + return resourceComponentName(getOwner(this), 'select-option', filter?.model); + } + @action onChange(selectedModel) { const { onChange, filter } = this.args; diff --git a/addon/styles/layout/next.css b/addon/styles/layout/next.css index 8146e527..239a35ef 100644 --- a/addon/styles/layout/next.css +++ b/addon/styles/layout/next.css @@ -2684,6 +2684,22 @@ a.text-danger:hover { @apply opacity-50; } +.filter-model-multiple { + @apply relative; +} + +.filter-model-multiple > .clear-button { + @apply dark:text-gray-500 text-gray-700 pl-2; +} + +.filter-model-multiple > .clear-button:hover { + @apply opacity-50; +} + +.filter-model-multiple > .clear-button:disabled { + @apply opacity-30; +} + .filter-range { @apply flex flex-col relative; } diff --git a/app/components/filter/model-multiple.js b/app/components/filter/model-multiple.js new file mode 100644 index 00000000..bd99174a --- /dev/null +++ b/app/components/filter/model-multiple.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/filter/model-multiple'; diff --git a/tests/integration/components/filter/model-multiple-test.js b/tests/integration/components/filter/model-multiple-test.js new file mode 100644 index 00000000..78890b1b --- /dev/null +++ b/tests/integration/components/filter/model-multiple-test.js @@ -0,0 +1,118 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render, click, settled } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import Service from '@ember/service'; +import { A } from '@ember/array'; +import { clickTrigger } from 'ember-power-select/test-support/helpers'; +import { registerResourceDescriptor } from '@fleetbase/ember-ui/utils/resource-registry'; + +const RECORDS = [ + { id: 'a', name: 'Ada' }, + { id: 'b', name: 'Bob' }, + { id: 'c', name: 'Cy' }, +]; + +class StoreStub extends Service { + peeked = []; + found = []; + + query() { + return Promise.resolve(A(RECORDS.slice())); + } + + peekRecord(modelName, id) { + this.peeked.push(id); + return id === 'a' ? RECORDS[0] : null; + } + + findRecord(modelName, id) { + this.found.push(id); + + if (id === 'gone') { + return Promise.reject(new Error('not found')); + } + + return Promise.resolve(RECORDS.find((record) => record.id === id) ?? null); + } +} + +module('Integration | Component | filter/model-multiple', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.owner.register('service:store', StoreStub); + this.owner.register('template:components/select-option/person', hbs`{{@option.name}}`); + }); + + test('it restores chips from a comma-separated id value, peeking before fetching', async function (assert) { + this.set('filter', { model: 'person' }); + + await render(hbs``); + await settled(); + + const store = this.owner.lookup('service:store'); + assert.deepEqual(store.peeked, ['a', 'b', 'gone']); + assert.deepEqual(store.found, ['b', 'gone'], 'only ids that were not in the store are fetched'); + assert.dom('.ember-power-select-multiple-option').exists({ count: 2 }, 'an id that no longer resolves drops out'); + assert.dom('.ember-power-select-multiple-option').includesText('Ada'); + }); + + test('an array value and an empty value are accepted', async function (assert) { + this.set('filter', { model: 'person' }); + this.set('value', ['a', null]); + + await render(hbs``); + await settled(); + assert.dom('.ember-power-select-multiple-option').exists({ count: 1 }); + + this.set('value', ''); + await render(hbs``); + await settled(); + assert.dom('.ember-power-select-multiple-option').doesNotExist(); + + this.set('filter', {}); + await render(hbs``); + await settled(); + assert.dom('.ember-power-select-multiple-option').doesNotExist('no model name means nothing to restore'); + }); + + test('it emits joined ids, reports clearing, and renders options with the registered select-option', async function (assert) { + registerResourceDescriptor(this.owner, { key: 'person', modelNames: ['person'] }); + const changes = []; + const clears = []; + this.set('filter', { model: 'person' }); + this.set('onChange', (filter, value) => changes.push(value)); + this.set('onClear', (filter) => clears.push(filter)); + + await render(hbs``); + await clickTrigger('.ember-power-select-trigger'); + assert.dom('.ember-power-select-option [data-test-person-option]').exists({ count: 3 }); + + await click('.ember-power-select-option'); + await clickTrigger('.ember-power-select-trigger'); + await click('.ember-power-select-option:nth-child(2)'); + assert.deepEqual(changes, ['a', 'a,b']); + assert.dom('.ember-power-select-multiple-option [data-test-person-option][data-compact="true"]').exists({ count: 2 }); + + await click('.clear-button'); + assert.deepEqual(clears, [this.filter]); + assert.dom('.ember-power-select-multiple-option').doesNotExist(); + assert.dom('.clear-button').isDisabled(); + }); + + test('removing the last chip reports a clear instead of an empty change', async function (assert) { + const changes = []; + const clears = []; + this.set('filter', { model: 'person' }); + this.set('onChange', (filter, value) => changes.push(value)); + this.set('onClear', (filter) => clears.push(filter)); + + await render(hbs``); + await settled(); + await click('.ember-power-select-multiple-remove-btn'); + + assert.deepEqual(changes, []); + assert.deepEqual(clears, [this.filter]); + }); +}); diff --git a/tests/integration/components/filter/model-test.js b/tests/integration/components/filter/model-test.js index 96c80809..428a3813 100644 --- a/tests/integration/components/filter/model-test.js +++ b/tests/integration/components/filter/model-test.js @@ -1,26 +1,91 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { render, click } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import Service from '@ember/service'; +import { A } from '@ember/array'; +import { clickTrigger } from 'ember-power-select/test-support/helpers'; +import { registerResourceDescriptor } from '@fleetbase/ember-ui/utils/resource-registry'; + +const RECORDS = [ + { id: 'a', name: 'Ada', phone: '+1' }, + { id: 'b', name: 'Bob', phone: '+2' }, +]; + +class StoreStub extends Service { + query() { + return Promise.resolve(A(RECORDS.slice())); + } + + peekRecord(modelName, id) { + return RECORDS.find((record) => record.id === id) ?? null; + } + + findRecord(modelName, id) { + return Promise.resolve(this.peekRecord(modelName, id)); + } +} module('Integration | Component | filter/model', function (hooks) { setupRenderingTest(hooks); - test('it renders', async function (assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.set('myAction', function(val) { ... }); + hooks.beforeEach(function () { + this.owner.register('service:store', StoreStub); + this.owner.register('template:components/select-option/person', hbs`{{@option.name}}`); + this.owner.register('template:components/custom-option', hbs`{{@option.name}}!`); + }); - await render(hbs``); + test('it renders options and the trigger with the select-option registered for the model', async function (assert) { + assert.expect(6); + registerResourceDescriptor(this.owner, { key: 'person', modelNames: ['person'] }); + this.set('filter', { model: 'person', filterValue: null }); + this.set('onChange', (filter, value) => { + assert.strictEqual(filter, this.filter); + assert.strictEqual(value, 'a', 'the id is emitted'); + }); + + await render(hbs``); + await clickTrigger('.ember-power-select-trigger'); + + assert.dom('.ember-power-select-option [data-test-person-option]').exists({ count: 2 }); + assert.dom('.ember-power-select-option [data-test-person-option]').doesNotHaveAttribute('data-compact'); + + await click('.ember-power-select-option'); + assert.dom('.ember-power-select-trigger [data-test-person-option]').hasText('Ada'); + assert.dom('.ember-power-select-trigger [data-test-person-option]').hasAttribute('data-compact', 'true', 'the trigger shows the compact variant'); + }); + + test('a column may name its own option component', async function (assert) { + registerResourceDescriptor(this.owner, { key: 'person', modelNames: ['person'] }); + this.set('filter', { model: 'person', filterOptionComponent: 'custom-option' }); + + await render(hbs``); + await clickTrigger('.ember-power-select-trigger'); + + assert.dom('.ember-power-select-option [data-test-custom-option]').exists({ count: 2 }); + assert.dom('.ember-power-select-option [data-test-person-option]').doesNotExist(); + }); + + test('it falls back to the model name path when no select-option exists', async function (assert) { + this.set('filter', { model: 'unknown-thing', modelNamePath: 'phone' }); + + await render(hbs``); + await clickTrigger('.ember-power-select-trigger'); + + assert.dom('.ember-power-select-option').exists({ count: 2 }); + assert.dom('.ember-power-select-option').hasText('+1'); + }); - assert.dom(this.element).hasText(''); + test('clearing reports the filter', async function (assert) { + assert.expect(2); + this.set('filter', { model: 'unknown-thing' }); + this.set('onClear', (filter) => assert.strictEqual(filter, this.filter)); - // Template block usage: - await render(hbs` - - template block text - - `); + await render(hbs``); + await clickTrigger('.ember-power-select-trigger'); + await click('.ember-power-select-option'); + assert.dom('.ember-power-select-selected-item').hasText('Ada'); - assert.dom(this.element).hasText('template block text'); + await click('.ember-power-select-clear-btn'); }); }); From e4fc02e07c694cb11929e561251aac9f78783b90 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 15 Sep 2026 22:04:36 +0800 Subject: [PATCH 09/40] Skip the identity cell's meta row when it would be empty table/cell/resource-identity always rendered its second line, so a cell with no identifier, meta paths or status still took two lines. Render the row only when there is something to put in it. --- .../components/table/cell/resource-identity.hbs | 4 +++- .../components/table/cell/resource-identity.js | 4 ++++ .../table/cell/resource-identity-test.js | 17 +++++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/addon/components/table/cell/resource-identity.hbs b/addon/components/table/cell/resource-identity.hbs index 82e3ac90..8258d71e 100644 --- a/addon/components/table/cell/resource-identity.hbs +++ b/addon/components/table/cell/resource-identity.hbs @@ -30,7 +30,8 @@ {{n-a this.label}} {{/if}}
-
+ {{#if this.hasMetaRow}} +
{{#each this.metaItems as |item|}} {{#if (eq item.style "badge")}} {{smart-humanize this.statusLabel}} {{/if}}
+ {{/if}}
diff --git a/addon/components/table/cell/resource-identity.js b/addon/components/table/cell/resource-identity.js index 825b3f23..a96c195c 100644 --- a/addon/components/table/cell/resource-identity.js +++ b/addon/components/table/cell/resource-identity.js @@ -163,6 +163,10 @@ export default class TableCellResourceIdentityComponent extends Component { return this.column.wrapperClass ?? 'max-w-md'; } + get hasMetaRow() { + return this.metaItems.length > 0 || Boolean(this.statusLabel); + } + valueFromConfig(valueKey, pathKey, fallbackPathKey) { if (this.column[valueKey] !== undefined) { return typeof this.column[valueKey] === 'function' ? this.column[valueKey](this.row, this.column) : this.column[valueKey]; diff --git a/tests/integration/components/table/cell/resource-identity-test.js b/tests/integration/components/table/cell/resource-identity-test.js index bb927513..1b4cb216 100644 --- a/tests/integration/components/table/cell/resource-identity-test.js +++ b/tests/integration/components/table/cell/resource-identity-test.js @@ -160,4 +160,21 @@ module('Integration | Component | table/cell/resource-identity', function (hooks assert.dom('[data-test-resource-identity-status-dot]').hasClass('-ml-1'); assert.dom('[data-test-resource-identity-status-dot]').hasClass('-mt-1'); }); + + test('it renders no meta row when there is nothing to put in it', async function (assert) { + this.set('row', { name: 'Bare Row' }); + this.set('column', { labelPath: 'name' }); + + await render(hbs``); + + assert.dom('button').includesText('Bare Row'); + assert.dom('[data-test-resource-identity-meta-row]').doesNotExist('no identifier, meta paths or status means no second line'); + + this.set('column', { labelPath: 'name', statusPath: 'status' }); + this.set('row', { name: 'Bare Row', status: 'active' }); + await render(hbs``); + + assert.dom('[data-test-resource-identity-meta-row]').exists('a status brings the row back'); + assert.dom('[data-test-resource-identity-meta-row]').includesText('Active'); + }); }); From 0bc391689789afcfa5df0d2b9a07676891c95509 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 15 Sep 2026 22:05:15 +0800 Subject: [PATCH 10/40] Add the resource descriptor registry A descriptor teaches the console how to show one kind of record: title, identifier, image, status, online state, badges, the facts a summary lists, and how to open it. Descriptors live in the shared universe/registry-service so the host and every engine read one set, with a per-owner fallback store for apps without it. resolveResourceKey accepts a record, an identity stub, a model name or alias, a prefix:key string, a PHP class name or a polymorphic type, and strips the facilitator-, maintenance-subject-, customer- and attachable- subtype prefixes. openResource unwraps proxies, thenables and loadResource() stubs, swaps a subtype for its canonical record through the store, and never throws. canOpenResource gates on the descriptor's guard and the abilities service. relationValue reads a relation without ever returning a promise proxy, and safeIdentifier refuses UUIDs. The resource-type, resource-component and resource-relation helpers and the resource-registry service expose it to templates and to engines that prefer runtime lookup over importing ember-ui modules. --- addon/helpers/resource-component.js | 15 + addon/helpers/resource-relation.js | 10 + addon/helpers/resource-type.js | 13 + addon/services/resource-registry.js | 75 +++ addon/utils/resource-registry.js | 573 ++++++++++++++++++ app/helpers/resource-component.js | 1 + app/helpers/resource-relation.js | 1 + app/helpers/resource-type.js | 1 + app/services/resource-registry.js | 1 + app/utils/resource-registry.js | 1 + .../helpers/resource-helpers-test.js | 52 ++ tests/unit/services/resource-registry-test.js | 32 + tests/unit/utils/resource-registry-test.js | 441 ++++++++++++++ 13 files changed, 1216 insertions(+) create mode 100644 addon/helpers/resource-component.js create mode 100644 addon/helpers/resource-relation.js create mode 100644 addon/helpers/resource-type.js create mode 100644 addon/services/resource-registry.js create mode 100644 addon/utils/resource-registry.js create mode 100644 app/helpers/resource-component.js create mode 100644 app/helpers/resource-relation.js create mode 100644 app/helpers/resource-type.js create mode 100644 app/services/resource-registry.js create mode 100644 app/utils/resource-registry.js create mode 100644 tests/integration/helpers/resource-helpers-test.js create mode 100644 tests/unit/services/resource-registry-test.js create mode 100644 tests/unit/utils/resource-registry-test.js diff --git a/addon/helpers/resource-component.js b/addon/helpers/resource-component.js new file mode 100644 index 00000000..9b5f9940 --- /dev/null +++ b/addon/helpers/resource-component.js @@ -0,0 +1,15 @@ +import Helper from '@ember/component/helper'; +import { getOwner } from '@ember/application'; +import { resourceComponentName } from '../utils/resource-registry'; + +/** + * `{{resource-component "pill" x}}` — the name of the pill, summary, + * identity or select-option component registered for a resource, or `null` + * when the resource is unknown or the component cannot be rendered here. + * Pair it with `{{#if}}` so the caller can fall back to plain text. + */ +export default class ResourceComponentHelper extends Helper { + compute([kind, input]) { + return resourceComponentName(getOwner(this), kind, input); + } +} diff --git a/addon/helpers/resource-relation.js b/addon/helpers/resource-relation.js new file mode 100644 index 00000000..c6f4bff3 --- /dev/null +++ b/addon/helpers/resource-relation.js @@ -0,0 +1,10 @@ +import { helper } from '@ember/component/helper'; +import { relationValue } from '../utils/resource-registry'; + +/** + * `{{resource-relation record "driver"}}` — the loaded related record, never + * a promise proxy, so `{{#if}}` and pills can rely on it. + */ +export default helper(function resourceRelation([record, name]) { + return relationValue(record, name); +}); diff --git a/addon/helpers/resource-type.js b/addon/helpers/resource-type.js new file mode 100644 index 00000000..5db15227 --- /dev/null +++ b/addon/helpers/resource-type.js @@ -0,0 +1,13 @@ +import Helper from '@ember/component/helper'; +import { getOwner } from '@ember/application'; +import { resolveResourceKey } from '../utils/resource-registry'; + +/** + * `{{resource-type x}}` — the registered resource key for a record, stub, + * model name, alias or polymorphic type string; `null` when unknown. + */ +export default class ResourceTypeHelper extends Helper { + compute([input]) { + return resolveResourceKey(getOwner(this), input); + } +} diff --git a/addon/services/resource-registry.js b/addon/services/resource-registry.js new file mode 100644 index 00000000..94bfa257 --- /dev/null +++ b/addon/services/resource-registry.js @@ -0,0 +1,75 @@ +import Service from '@ember/service'; +import { getOwner } from '@ember/application'; +import { + registerResourceDescriptor, + registerResourceDescriptors, + setResourceOpener, + getResourceDescriptors, + getResourceDescriptor, + resolveResourceKey, + resourceComponentName, + canOpenResource, + openResource, + relationValue, + safeIdentifier, + unwrapResource, +} from '../utils/resource-registry'; + +/** + * A service front for the resource registry util, so an engine can register + * its descriptors and open resources through `owner.lookup('service:resource-registry')` + * without importing ember-ui modules. + */ +export default class ResourceRegistryService extends Service { + get owner() { + return getOwner(this); + } + + get descriptors() { + return getResourceDescriptors(this.owner); + } + + register(descriptor) { + return registerResourceDescriptor(this.owner, descriptor); + } + + registerDescriptors(descriptors) { + return registerResourceDescriptors(this.owner, descriptors); + } + + setOpener(key, open, options = {}) { + return setResourceOpener(this.owner, key, open, options); + } + + resolveKey(input) { + return resolveResourceKey(this.owner, input); + } + + getDescriptor(input) { + return getResourceDescriptor(this.owner, input); + } + + componentName(kind, input) { + return resourceComponentName(this.owner, kind, input); + } + + canOpen(input) { + return canOpenResource(this.owner, input); + } + + open(input, options = {}) { + return openResource(this.owner, input, options); + } + + relationValue(record, name) { + return relationValue(record, name); + } + + safeIdentifier(value) { + return safeIdentifier(value); + } + + unwrap(input) { + return unwrapResource(input); + } +} diff --git a/addon/utils/resource-registry.js b/addon/utils/resource-registry.js new file mode 100644 index 00000000..02a09199 --- /dev/null +++ b/addon/utils/resource-registry.js @@ -0,0 +1,573 @@ +import { get } from '@ember/object'; +import { dasherize } from '@ember/string'; +import { isArray } from '@ember/array'; +import ObjectProxy from '@ember/object/proxy'; + +/** + * The resource identity registry. + * + * A descriptor teaches the console how to show one kind of record: its + * title, identifier, image, status, the facts a hover summary lists, and how + * to open it. Descriptors are stored in the shared `universe/registry-service` + * so the host application and every engine read the same set, and resolved + * by model name, alias, polymorphic type string or PHP class basename. + * + * Everything here is tolerant: unknown input resolves to `null`, and reading + * or opening never throws, so a template can always fall back to plain text. + */ + +export const REGISTRY_SECTION = 'resource-identity'; +export const DESCRIPTOR_LIST = 'descriptors'; +const INDEX_LIST = 'index'; +const INDEX_KEY = 'index'; + +export const COMPONENT_KINDS = { + pill: 'pill', + summary: 'summary', + identity: 'identity', + 'select-option': 'selectOption', + selectOption: 'selectOption', +}; + +const DEFAULT_COMPONENT_NAMES = { + pill: (key) => `${key}/pill`, + summary: (key) => `${key}/summary`, + identity: (key) => `table/cell/${key}-identity`, + selectOption: (key) => `select-option/${key}`, +}; + +/** Model-name prefixes that wrap a canonical resource in a polymorphic subtype. */ +export const SUBTYPE_PREFIXES = ['facilitator-', 'maintenance-subject-', 'customer-', 'attachable-']; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +const fallbackStores = new WeakMap(); + +function lookupService(owner, name) { + try { + return owner?.lookup?.(`service:${name}`) ?? null; + } catch { + return null; + } +} + +/** + * Where descriptors live: the universe registry service when the owner can + * reach it, otherwise a per-owner in-memory store (a dummy app or a test + * without ember-core services). + */ +function getStore(owner) { + if (!owner) { + return null; + } + + const service = lookupService(owner, 'universe/registry-service'); + + if (service && typeof service.register === 'function' && typeof service.getRegistry === 'function' && typeof service.lookup === 'function') { + return { + list: (name) => service.getRegistry(REGISTRY_SECTION, name), + put: (name, key, value) => service.register(REGISTRY_SECTION, name, key, value), + find: (name, key) => service.lookup(REGISTRY_SECTION, name, key), + }; + } + + let store = fallbackStores.get(owner); + + if (!store) { + const lists = new Map(); + const listFor = (name) => { + if (!lists.has(name)) { + lists.set(name, []); + } + + return lists.get(name); + }; + + store = { + list: (name) => listFor(name), + put: (name, key, value) => { + const list = listFor(name); + const index = list.findIndex((item) => item._registryKey === key); + + value._registryKey = key; + + if (index === -1) { + list.push(value); + } else { + list.splice(index, 1, value); + } + }, + find: (name, key) => listFor(name).find((item) => item._registryKey === key) ?? null, + }; + + fallbackStores.set(owner, store); + } + + return store; +} + +function uniqueStrings(values) { + return [...new Set((values ?? []).filter((value) => typeof value === 'string' && value.trim() !== '').map((value) => value.trim()))]; +} + +function normalizeDescriptor(descriptor) { + const key = descriptor.key.trim(); + const components = descriptor.components ?? {}; + + return { + ...descriptor, + key, + labelKey: descriptor.labelKey ?? `resource.${key}`, + icon: descriptor.icon ?? 'cube', + aliases: uniqueStrings(descriptor.aliases), + modelNames: uniqueStrings([key, ...(descriptor.modelNames ?? [])]), + polymorphicTypes: uniqueStrings(descriptor.polymorphicTypes), + statusTones: descriptor.statusTones ?? {}, + components: { + pill: components.pill ?? DEFAULT_COMPONENT_NAMES.pill(key), + summary: components.summary ?? DEFAULT_COMPONENT_NAMES.summary(key), + identity: components.identity ?? DEFAULT_COMPONENT_NAMES.identity(key), + selectOption: components.selectOption ?? components['select-option'] ?? DEFAULT_COMPONENT_NAMES.selectOption(key), + }, + }; +} + +function rebuildIndex(store) { + const index = { byKey: {}, byAlias: {}, byModelName: {}, byType: {} }; + + for (const descriptor of store.list(DESCRIPTOR_LIST)) { + index.byKey[descriptor.key] = descriptor.key; + descriptor.aliases.forEach((alias) => (index.byAlias[alias] = descriptor.key)); + descriptor.modelNames.forEach((modelName) => (index.byModelName[modelName] = descriptor.key)); + descriptor.polymorphicTypes.forEach((type) => { + index.byType[type] = descriptor.key; + index.byType[type.toLowerCase()] = descriptor.key; + }); + } + + store.put(INDEX_LIST, INDEX_KEY, index); + + return index; +} + +function getIndex(owner) { + const store = getStore(owner); + + if (!store) { + return null; + } + + return store.find(INDEX_LIST, INDEX_KEY) ?? rebuildIndex(store); +} + +/** + * Registers one descriptor. Returns the stored (normalized) descriptor. + */ +export function registerResourceDescriptor(owner, descriptor) { + return registerResourceDescriptors(owner, [descriptor])[0] ?? null; +} + +/** + * Registers a list of descriptors and rebuilds the lookup indexes once. + * A descriptor with the same key replaces the earlier one. + */ +export function registerResourceDescriptors(owner, descriptors) { + const store = getStore(owner); + + if (!store) { + return []; + } + + const list = (isArray(descriptors) ? descriptors : [descriptors]).filter((descriptor) => descriptor && typeof descriptor.key === 'string' && descriptor.key.trim() !== ''); + const normalized = list.map(normalizeDescriptor); + + normalized.forEach((descriptor) => store.put(DESCRIPTOR_LIST, descriptor.key, descriptor)); + rebuildIndex(store); + + return normalized; +} + +/** + * Lets another package supply (or replace) how a resource opens, for example + * an engine that owns the page for a core resource. + */ +export function setResourceOpener(owner, key, open, options = {}) { + const descriptor = getResourceDescriptor(owner, key); + + if (!descriptor) { + return false; + } + + descriptor.open = open; + + if ('permission' in options) { + descriptor.permission = options.permission; + } + + if ('canOpen' in options) { + descriptor.canOpen = options.canOpen; + } + + return true; +} + +/** Every registered descriptor. */ +export function getResourceDescriptors(owner) { + const store = getStore(owner); + + return store ? [...store.list(DESCRIPTOR_LIST)] : []; +} + +function normalizeName(raw) { + let name = raw.trim(); + + if (name.includes('\\')) { + name = name.slice(name.lastIndexOf('\\') + 1); + } + + if (name.includes(':')) { + name = name.slice(name.lastIndexOf(':') + 1); + } + + return dasherize(name).toLowerCase(); +} + +function lookupName(index, raw) { + if (typeof raw !== 'string' || raw.trim() === '') { + return null; + } + + const exact = raw.trim(); + + if (index.byType[exact] || index.byType[exact.toLowerCase()]) { + return index.byType[exact] ?? index.byType[exact.toLowerCase()]; + } + + const name = normalizeName(exact); + const direct = index.byKey[name] ?? index.byAlias[name] ?? index.byModelName[name] ?? index.byType[name]; + + if (direct) { + return direct; + } + + for (const prefix of SUBTYPE_PREFIXES) { + if (name.startsWith(prefix)) { + const stripped = name.slice(prefix.length); + const key = index.byKey[stripped] ?? index.byAlias[stripped] ?? index.byModelName[stripped]; + + if (key) { + return key; + } + } + } + + return null; +} + +function candidateNames(input) { + if (typeof input === 'string') { + return [input]; + } + + if (!input || typeof input !== 'object') { + return []; + } + + const record = input instanceof ObjectProxy ? (input.content ?? input) : input; + + return [get(record, 'constructor.modelName'), get(record, 'resourceType'), get(record, 'resource_type'), get(record, 'modelName')].filter((name) => typeof name === 'string'); +} + +/** + * Resolves anything that names a resource to its descriptor key, or `null`. + * Accepts a record, a stub carrying `resourceType`, a model name or alias, + * a `prefix:key` string, a PHP class name, or a polymorphic type string. + */ +export function resolveResourceKey(owner, input) { + const index = getIndex(owner); + + if (!index) { + return null; + } + + for (const name of candidateNames(input)) { + const key = lookupName(index, name); + + if (key) { + return key; + } + } + + return null; +} + +/** The descriptor for anything `resolveResourceKey` understands, or `null`. */ +export function getResourceDescriptor(owner, input) { + const key = resolveResourceKey(owner, input); + + if (!key) { + return null; + } + + return getStore(owner).find(DESCRIPTOR_LIST, key) ?? null; +} + +/** Whether `owner` can render the named component. */ +export function isComponentResolvable(owner, name) { + if (!name) { + return false; + } + + if (typeof name !== 'string') { + return typeof name === 'function' || typeof name === 'object'; + } + + try { + return Boolean(owner?.factoryFor?.(`component:${name}`)) || Boolean(owner?.lookup?.(`template:components/${name}`)); + } catch { + return false; + } +} + +/** + * The component name registered for a kind (`pill`, `summary`, `identity`, + * `select-option`) of a resource, or `null` when the resource is unknown or + * the component cannot be resolved by `owner`. + */ +export function resourceComponentName(owner, kind, input) { + const field = COMPONENT_KINDS[kind]; + + if (!field) { + return null; + } + + const descriptor = getResourceDescriptor(owner, input); + const name = descriptor?.components?.[field]; + + if (!name || !isComponentResolvable(owner, name)) { + return null; + } + + return name; +} + +function unwrapSync(value) { + if (value === null || value === undefined) { + return null; + } + + if (value instanceof ObjectProxy) { + return value.content ?? null; + } + + if (typeof value === 'object' && typeof value.then === 'function' && 'content' in value) { + return value.content ?? null; + } + + return value; +} + +/** + * Reads a relation without ever returning a promise proxy: the loaded + * record (or many-array) for a model, the plain value for a POJO, else null. + */ +export function relationValue(record, name) { + if (!record || typeof name !== 'string' || name === '') { + return null; + } + + const target = unwrapSync(record); + + if (!target) { + return null; + } + + if (typeof target.belongsTo === 'function') { + try { + const reference = target.belongsTo(name); + + if (reference && typeof reference.value === 'function') { + return unwrapSync(reference.value()); + } + } catch { + // not a belongsTo on this model; fall through + } + } + + if (typeof target.hasMany === 'function') { + try { + const reference = target.hasMany(name); + + if (reference && typeof reference.value === 'function') { + return reference.value() ?? null; + } + } catch { + // not a hasMany either; fall through + } + } + + return unwrapSync(get(target, name)); +} + +/** A display identifier, or `null` when the value is blank or a UUID. */ +export function safeIdentifier(value) { + if (value === null || value === undefined) { + return null; + } + + const text = String(value).trim(); + + if (text === '' || UUID_PATTERN.test(text)) { + return null; + } + + return value; +} + +/** Whether the value is a UUID string. */ +export function isUuidValue(value) { + return typeof value === 'string' && UUID_PATTERN.test(value.trim()); +} + +/** + * Resolves proxies, thenables and identity stubs to the record they stand + * for. A stub is any object with a `loadResource()` function. + */ +export async function unwrapResource(input) { + let value = input; + + for (let step = 0; step < 8 && value; step++) { + if (typeof value.then === 'function') { + value = await value; + continue; + } + + if (value instanceof ObjectProxy) { + value = value.content ?? null; + continue; + } + + if (typeof value.loadResource === 'function') { + const loaded = await value.loadResource(); + + return loaded ?? value; + } + + break; + } + + return value ?? null; +} + +async function loadCanonicalRecord(owner, descriptor, record) { + const modelName = get(record, 'constructor.modelName'); + const canonical = descriptor.modelNames[0]; + + if (typeof modelName !== 'string' || modelName === canonical) { + return record; + } + + const store = lookupService(owner, 'store'); + const id = get(record, 'id') ?? get(record, 'uuid'); + + if (!store || !id) { + return record; + } + + const peeked = typeof store.peekRecord === 'function' ? store.peekRecord(canonical, id) : null; + + if (peeked) { + return peeked; + } + + if (typeof store.findRecord === 'function') { + return (await store.findRecord(canonical, id)) ?? record; + } + + return record; +} + +/** + * Whether the resource has an open path the current user may use. + */ +export function canOpenResource(owner, input) { + const descriptor = getResourceDescriptor(owner, input); + + if (!descriptor || typeof descriptor.open !== 'function') { + return false; + } + + const record = typeof input === 'object' ? unwrapSync(input) : null; + + if (typeof descriptor.canOpen === 'function') { + try { + if (!descriptor.canOpen(record, owner)) { + return false; + } + } catch { + return false; + } + } + + const permission = typeof descriptor.permission === 'function' ? descriptor.permission(record) : descriptor.permission; + + if (permission) { + const abilities = lookupService(owner, 'abilities'); + + if (abilities && typeof abilities.can === 'function') { + try { + return Boolean(abilities.can(permission)); + } catch { + return false; + } + } + } + + return true; +} + +/** + * Opens a resource through its descriptor. Unwraps proxies and stubs, loads + * the canonical record for a subtype, and returns `true` only when the + * descriptor reported that it handled the open. Never throws. + */ +export async function openResource(owner, input, options = {}) { + try { + const record = await unwrapResource(input); + + if (!record) { + return false; + } + + const descriptor = getResourceDescriptor(owner, record) ?? getResourceDescriptor(owner, options.resourceType); + + if (!descriptor || typeof descriptor.open !== 'function') { + return false; + } + + const canonical = await loadCanonicalRecord(owner, descriptor, record); + const result = await descriptor.open(canonical, { ...options, owner, event: options.event ?? null }); + + return Boolean(result); + } catch { + return false; + } +} + +/** + * Calls a descriptor function field safely: `undefined` when the field is + * missing, not a function, or throws. + */ +export function readDescriptor(descriptor, field, ...args) { + const value = descriptor?.[field]; + + if (typeof value !== 'function') { + return value; + } + + try { + return value(...args); + } catch { + return undefined; + } +} diff --git a/app/helpers/resource-component.js b/app/helpers/resource-component.js new file mode 100644 index 00000000..f705c2a3 --- /dev/null +++ b/app/helpers/resource-component.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/helpers/resource-component'; diff --git a/app/helpers/resource-relation.js b/app/helpers/resource-relation.js new file mode 100644 index 00000000..0e635fc1 --- /dev/null +++ b/app/helpers/resource-relation.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/helpers/resource-relation'; diff --git a/app/helpers/resource-type.js b/app/helpers/resource-type.js new file mode 100644 index 00000000..14334c6b --- /dev/null +++ b/app/helpers/resource-type.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/helpers/resource-type'; diff --git a/app/services/resource-registry.js b/app/services/resource-registry.js new file mode 100644 index 00000000..031c153d --- /dev/null +++ b/app/services/resource-registry.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/services/resource-registry'; diff --git a/app/utils/resource-registry.js b/app/utils/resource-registry.js new file mode 100644 index 00000000..92e1ad8b --- /dev/null +++ b/app/utils/resource-registry.js @@ -0,0 +1 @@ +export * from '@fleetbase/ember-ui/utils/resource-registry'; diff --git a/tests/integration/helpers/resource-helpers-test.js b/tests/integration/helpers/resource-helpers-test.js new file mode 100644 index 00000000..c93b300a --- /dev/null +++ b/tests/integration/helpers/resource-helpers-test.js @@ -0,0 +1,52 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { registerResourceDescriptors } from '@fleetbase/ember-ui/utils/resource-registry'; + +module('Integration | Helper | resource-type, resource-component, resource-relation', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + registerResourceDescriptors(this.owner, [{ key: 'vehicle', aliases: ['attachable-vehicle'] }]); + this.owner.register('template:components/vehicle/pill', hbs`{{@resource.name}}`); + }); + + test('resource-type resolves a key and yields nothing for unknown input', async function (assert) { + this.set('record', { resourceType: 'attachable-vehicle' }); + + await render(hbs`[{{resource-type this.record}}][{{resource-type "vehicle"}}][{{resource-type "nothing"}}]`); + + assert.dom(this.element).hasText('[vehicle][vehicle][]'); + }); + + test('resource-component names a renderable component or nothing', async function (assert) { + this.set('record', { resourceType: 'vehicle', name: 'Truck 1' }); + + await render(hbs` + {{#let (resource-component "pill" this.record) as |pill|}} + {{#if pill}}{{component pill resource=this.record}}{{else}}text{{/if}} + {{/let}} + {{#let (resource-component "summary" this.record) as |summary|}} + {{#if summary}}{{component summary}}{{else}}no summary{{/if}} + {{/let}} + `); + + assert.dom('[data-test-vehicle-pill]').hasText('Truck 1'); + assert.dom('[data-test-fallback]').doesNotExist(); + assert.dom('[data-test-no-summary]').exists(); + }); + + test('resource-relation reads a relation without a proxy', async function (assert) { + const driver = { name: 'Ada' }; + this.set('record', { + belongsTo(name) { + return { value: () => (name === 'driver' ? driver : null) }; + }, + }); + + await render(hbs`[{{get (resource-relation this.record "driver") "name"}}][{{if (resource-relation this.record "vendor") "vendor" "none"}}]`); + + assert.dom(this.element).hasText('[Ada][none]'); + }); +}); diff --git a/tests/unit/services/resource-registry-test.js b/tests/unit/services/resource-registry-test.js new file mode 100644 index 00000000..10953f8a --- /dev/null +++ b/tests/unit/services/resource-registry-test.js @@ -0,0 +1,32 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; +import ObjectProxy from '@ember/object/proxy'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Unit | Service | resource-registry', function (hooks) { + setupTest(hooks); + + test('it fronts the registry util for an owner', async function (assert) { + const service = this.owner.lookup('service:resource-registry'); + this.owner.register('template:components/vehicle/pill', hbs`x`); + + const [vehicle] = service.registerDescriptors([{ key: 'vehicle', aliases: ['attachable-vehicle'], identifier: (r) => r.plate }]); + assert.strictEqual(vehicle.key, 'vehicle'); + assert.strictEqual(service.register({ key: 'driver' }).key, 'driver'); + assert.deepEqual( + service.descriptors.map((d) => d.key), + ['vehicle', 'driver'] + ); + assert.strictEqual(service.resolveKey('attachable-vehicle'), 'vehicle'); + assert.strictEqual(service.getDescriptor('vehicle').key, 'vehicle'); + assert.strictEqual(service.componentName('pill', 'vehicle'), 'vehicle/pill'); + assert.false(service.canOpen('vehicle')); + assert.true(service.setOpener('vehicle', () => true)); + assert.true(service.canOpen('vehicle')); + assert.true(await service.open({ resourceType: 'vehicle' })); + assert.strictEqual(service.relationValue({ driver: { name: 'Ada' } }, 'driver').name, 'Ada'); + assert.strictEqual(service.safeIdentifier('9d2c6c5e-1b2a-4c3d-8e4f-1234567890ab'), null); + const record = { name: 'Ada' }; + assert.strictEqual(await service.unwrap(ObjectProxy.create({ content: record })), record); + }); +}); diff --git a/tests/unit/utils/resource-registry-test.js b/tests/unit/utils/resource-registry-test.js new file mode 100644 index 00000000..3bae284f --- /dev/null +++ b/tests/unit/utils/resource-registry-test.js @@ -0,0 +1,441 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; +import Service from '@ember/service'; +import ObjectProxy from '@ember/object/proxy'; +import { hbs } from 'ember-cli-htmlbars'; +import { + registerResourceDescriptor, + registerResourceDescriptors, + setResourceOpener, + getResourceDescriptors, + getResourceDescriptor, + resolveResourceKey, + resourceComponentName, + isComponentResolvable, + relationValue, + safeIdentifier, + isUuidValue, + unwrapResource, + canOpenResource, + openResource, + readDescriptor, + SUBTYPE_PREFIXES, +} from '@fleetbase/ember-ui/utils/resource-registry'; + +class VehicleModel { + static modelName = 'vehicle'; + constructor(attrs = {}) { + Object.assign(this, attrs); + } +} + +class MaintenanceSubjectVehicleModel { + static modelName = 'maintenance-subject-vehicle'; + constructor(attrs = {}) { + Object.assign(this, attrs); + } +} + +function fakeOwner(services = {}) { + return { + lookup(name) { + return services[name] ?? null; + }, + }; +} + +module('Unit | Utility | resource-registry', function (hooks) { + setupTest(hooks); + + test('it normalizes descriptors on registration and rejects junk', function (assert) { + const [vehicle, driver] = registerResourceDescriptors(this.owner, [ + { key: 'vehicle', aliases: ['attachable-vehicle', ' '], modelNames: ['maintenance-subject-vehicle'], polymorphicTypes: ['fleet-ops:vehicle', 'Fleetbase\\FleetOps\\Models\\Vehicle'] }, + { key: 'driver', components: { pill: 'custom/driver-pill', 'select-option': 'custom/driver-option' }, icon: 'id-card', labelKey: 'x.driver' }, + { nokey: true }, + null, + { key: ' ' }, + ]); + + assert.strictEqual(vehicle.key, 'vehicle'); + assert.deepEqual(vehicle.aliases, ['attachable-vehicle']); + assert.deepEqual(vehicle.modelNames, ['vehicle', 'maintenance-subject-vehicle']); + assert.deepEqual(vehicle.polymorphicTypes, ['fleet-ops:vehicle', 'Fleetbase\\FleetOps\\Models\\Vehicle']); + assert.strictEqual(vehicle.labelKey, 'resource.vehicle'); + assert.strictEqual(vehicle.icon, 'cube'); + assert.deepEqual(vehicle.components, { pill: 'vehicle/pill', summary: 'vehicle/summary', identity: 'table/cell/vehicle-identity', selectOption: 'select-option/vehicle' }); + assert.deepEqual(vehicle.statusTones, {}); + assert.strictEqual(driver.components.pill, 'custom/driver-pill'); + assert.strictEqual(driver.components.selectOption, 'custom/driver-option'); + assert.strictEqual(driver.icon, 'id-card'); + assert.strictEqual(driver.labelKey, 'x.driver'); + assert.strictEqual(getResourceDescriptors(this.owner).length, 2); + + const replaced = registerResourceDescriptor(this.owner, { key: 'driver', icon: 'user' }); + assert.strictEqual(replaced.icon, 'user'); + assert.strictEqual(getResourceDescriptors(this.owner).length, 2, 'the same key replaces'); + assert.strictEqual(getResourceDescriptor(this.owner, 'driver').icon, 'user'); + assert.deepEqual(registerResourceDescriptors(this.owner, { key: 'single' }).map((d) => d.key), ['single'], 'a single descriptor is accepted'); + assert.strictEqual(registerResourceDescriptor(this.owner, null), null); + assert.deepEqual(registerResourceDescriptors(null, [{ key: 'nowhere' }]), [], 'no owner, no registration'); + }); + + test('it resolves keys from every accepted input form', function (assert) { + registerResourceDescriptors(this.owner, [ + { key: 'vehicle', aliases: ['attachable-vehicle'], modelNames: ['maintenance-subject-vehicle'], polymorphicTypes: ['fleet-ops:vehicle', 'Fleetbase\\FleetOps\\Models\\Vehicle'] }, + { key: 'driver' }, + { key: 'integrated-vendor' }, + ]); + + assert.strictEqual(resolveResourceKey(this.owner, 'vehicle'), 'vehicle', 'key'); + assert.strictEqual(resolveResourceKey(this.owner, ' Vehicle '), 'vehicle', 'trimmed and lowercased'); + assert.strictEqual(resolveResourceKey(this.owner, 'attachable-vehicle'), 'vehicle', 'alias'); + assert.strictEqual(resolveResourceKey(this.owner, 'maintenance-subject-vehicle'), 'vehicle', 'model name'); + assert.strictEqual(resolveResourceKey(this.owner, 'fleet-ops:vehicle'), 'vehicle', 'polymorphic prefix:key'); + assert.strictEqual(resolveResourceKey(this.owner, 'Fleetbase\\FleetOps\\Models\\Vehicle'), 'vehicle', 'PHP class'); + assert.strictEqual(resolveResourceKey(this.owner, 'fleetbase\\fleetops\\models\\vehicle'), 'vehicle', 'PHP class, any case'); + assert.strictEqual(resolveResourceKey(this.owner, 'App\\Models\\IntegratedVendor'), 'integrated-vendor', 'PHP class basename is dasherized'); + assert.strictEqual(resolveResourceKey(this.owner, 'facilitator-driver'), 'driver', 'subtype prefix stripped'); + assert.strictEqual(resolveResourceKey(this.owner, 'customer-driver'), 'driver'); + assert.strictEqual(resolveResourceKey(this.owner, 'maintenance-subject-driver'), 'driver'); + assert.strictEqual(resolveResourceKey(this.owner, 'attachable-driver'), 'driver'); + assert.strictEqual(resolveResourceKey(this.owner, 'facilitator-nothing'), null, 'a stripped prefix still needs a match'); + assert.strictEqual(resolveResourceKey(this.owner, new VehicleModel()), 'vehicle', 'record'); + assert.strictEqual(resolveResourceKey(this.owner, ObjectProxy.create({ content: new VehicleModel() })), 'vehicle', 'proxied record'); + assert.strictEqual(resolveResourceKey(this.owner, ObjectProxy.create({ content: null })), null, 'empty proxy'); + assert.strictEqual(resolveResourceKey(this.owner, { resourceType: 'driver' }), 'driver', 'stub resourceType'); + assert.strictEqual(resolveResourceKey(this.owner, { resource_type: 'fleet-ops:driver' }), 'driver', 'stub resource_type'); + assert.strictEqual(resolveResourceKey(this.owner, { modelName: 'driver', resource: {} }), 'driver', 'wrapper modelName'); + assert.strictEqual(resolveResourceKey(this.owner, { name: 'nothing' }), null, 'unknown object'); + assert.strictEqual(resolveResourceKey(this.owner, 'nothing'), null, 'unknown string'); + assert.strictEqual(resolveResourceKey(this.owner, ''), null); + assert.strictEqual(resolveResourceKey(this.owner, 42), null); + assert.strictEqual(resolveResourceKey(this.owner, null), null); + assert.strictEqual(resolveResourceKey(null, 'vehicle'), null, 'no owner'); + assert.strictEqual(getResourceDescriptor(this.owner, 'nothing'), null); + assert.deepEqual(SUBTYPE_PREFIXES, ['facilitator-', 'maintenance-subject-', 'customer-', 'attachable-']); + }); + + test('it stores descriptors in a per-owner fallback when there is no universe registry', function (assert) { + const owner = fakeOwner(); + const other = fakeOwner(); + + assert.strictEqual(resolveResourceKey(owner, 'vehicle'), null, 'empty before registration'); + registerResourceDescriptors(owner, [{ key: 'vehicle' }]); + registerResourceDescriptors(owner, [{ key: 'vehicle', icon: 'truck' }]); + assert.strictEqual(resolveResourceKey(owner, 'vehicle'), 'vehicle'); + assert.strictEqual(getResourceDescriptor(owner, 'vehicle').icon, 'truck', 'replaced in place'); + assert.strictEqual(getResourceDescriptors(owner).length, 1); + assert.strictEqual(resolveResourceKey(other, 'vehicle'), null, 'another owner has its own store'); + assert.deepEqual(getResourceDescriptors(null), []); + + const throwing = { + lookup() { + throw new Error('no container'); + }, + }; + registerResourceDescriptors(throwing, [{ key: 'driver' }]); + assert.strictEqual(resolveResourceKey(throwing, 'driver'), 'driver', 'a throwing lookup falls back too'); + }); + + test('it uses the universe registry service when the owner provides one', function (assert) { + registerResourceDescriptors(this.owner, [{ key: 'vehicle' }]); + const registry = this.owner.lookup('service:universe/registry-service'); + + assert.ok(registry.calls.some((call) => call.method === 'register' && call.args[0] === 'resource-identity' && call.args[2] === 'vehicle')); + assert.strictEqual(registry.lookup('resource-identity', 'descriptors', 'vehicle').key, 'vehicle'); + }); + + test('setResourceOpener lets another package supply how a resource opens', async function (assert) { + registerResourceDescriptors(this.owner, [{ key: 'user' }]); + const record = { resourceType: 'user' }; + + assert.false(canOpenResource(this.owner, record), 'nothing to open yet'); + assert.false(setResourceOpener(this.owner, 'nothing', () => true)); + assert.true(setResourceOpener(this.owner, 'user', () => 'opened', { permission: 'iam view user', canOpen: () => true })); + assert.strictEqual(getResourceDescriptor(this.owner, 'user').permission, 'iam view user'); + assert.true(canOpenResource(this.owner, record)); + assert.true(await openResource(this.owner, record)); + }); + + test('resourceComponentName returns a name only for a known resource and a resolvable component', function (assert) { + registerResourceDescriptors(this.owner, [{ key: 'vehicle', components: { summary: () => {} } }, { key: 'driver' }]); + this.owner.register('template:components/vehicle/pill', hbs`x`); + this.owner.register('template:components/select-option/vehicle', hbs`x`); + + assert.strictEqual(resourceComponentName(this.owner, 'pill', 'vehicle'), 'vehicle/pill'); + assert.strictEqual(resourceComponentName(this.owner, 'select-option', 'vehicle'), 'select-option/vehicle'); + assert.strictEqual(resourceComponentName(this.owner, 'selectOption', 'vehicle'), 'select-option/vehicle'); + assert.strictEqual(typeof resourceComponentName(this.owner, 'summary', 'vehicle'), 'function', 'a component class passes through'); + assert.strictEqual(resourceComponentName(this.owner, 'identity', 'vehicle'), null, 'not resolvable in this owner'); + assert.strictEqual(resourceComponentName(this.owner, 'pill', 'driver'), null, 'default name does not resolve'); + assert.strictEqual(resourceComponentName(this.owner, 'pill', 'nothing'), null, 'unknown resource'); + assert.strictEqual(resourceComponentName(this.owner, 'nope', 'vehicle'), null, 'unknown kind'); + }); + + test('isComponentResolvable covers every input shape', function (assert) { + this.owner.register('template:components/known-thing', hbs`x`); + assert.false(isComponentResolvable(this.owner, null)); + assert.false(isComponentResolvable(this.owner, '')); + assert.true(isComponentResolvable(this.owner, () => {})); + assert.true(isComponentResolvable(this.owner, {})); + assert.true(isComponentResolvable(this.owner, 'known-thing')); + assert.false(isComponentResolvable(this.owner, 'unknown-thing')); + assert.false(isComponentResolvable(null, 'known-thing')); + assert.false( + isComponentResolvable( + { + factoryFor() { + throw new Error('boom'); + }, + }, + 'known-thing' + ), + 'a throwing owner is treated as unresolvable' + ); + assert.true( + isComponentResolvable( + { + factoryFor: () => ({}), + }, + 'anything' + ), + 'factoryFor alone is enough' + ); + }); + + test('relationValue never returns a proxy', function (assert) { + const driver = { name: 'Ada' }; + const model = { + belongsTo(name) { + if (name === 'driver') { + return { value: () => driver }; + } + + if (name === 'vendor') { + return { value: () => null }; + } + + throw new Error(`${name} is not a belongsTo`); + }, + hasMany(name) { + if (name === 'trailers') { + return { value: () => ['t1'] }; + } + + if (name === 'devices') { + return { value: () => null }; + } + + throw new Error(`${name} is not a hasMany`); + }, + plain: 'value', + promised: { then() {}, content: driver }, + empty: { then() {}, content: null }, + }; + + assert.strictEqual(relationValue(model, 'driver'), driver); + assert.strictEqual(relationValue(model, 'vendor'), null); + assert.deepEqual(relationValue(model, 'trailers'), ['t1']); + assert.strictEqual(relationValue(model, 'devices'), null); + assert.strictEqual(relationValue(model, 'plain'), 'value'); + assert.strictEqual(relationValue(model, 'promised'), driver, 'a promise proxy yields its content'); + assert.strictEqual(relationValue(model, 'empty'), null); + assert.strictEqual(relationValue(model, 'missing'), null); + assert.strictEqual(relationValue({ driver }, 'driver'), driver, 'plain objects use get'); + assert.strictEqual(relationValue(ObjectProxy.create({ content: { driver } }), 'driver'), driver, 'a proxied record is unwrapped first'); + assert.strictEqual(relationValue(ObjectProxy.create({ content: null }), 'driver'), null); + assert.strictEqual(relationValue({ driver: ObjectProxy.create({ content: driver }) }, 'driver'), driver, 'a proxied value is unwrapped'); + assert.strictEqual(relationValue(null, 'driver'), null); + assert.strictEqual(relationValue(model, ''), null); + assert.strictEqual(relationValue(model, 5), null); + }); + + test('safeIdentifier and isUuidValue drop UUIDs', function (assert) { + assert.strictEqual(safeIdentifier('ABC-123'), 'ABC-123'); + assert.strictEqual(safeIdentifier(42), 42); + assert.strictEqual(safeIdentifier(' 9d2c6c5e-1b2a-4c3d-8e4f-1234567890ab '), null); + assert.strictEqual(safeIdentifier(''), null); + assert.strictEqual(safeIdentifier(' '), null); + assert.strictEqual(safeIdentifier(null), null); + assert.strictEqual(safeIdentifier(undefined), null); + assert.true(isUuidValue('9d2c6c5e-1b2a-4c3d-8e4f-1234567890ab')); + assert.false(isUuidValue('vehicle_123')); + assert.false(isUuidValue(null)); + }); + + test('unwrapResource resolves thenables, proxies and stubs', async function (assert) { + const record = { name: 'Ada' }; + assert.strictEqual(await unwrapResource(record), record); + assert.strictEqual(await unwrapResource(Promise.resolve(record)), record); + assert.strictEqual(await unwrapResource(ObjectProxy.create({ content: record })), record); + assert.strictEqual(await unwrapResource(Promise.resolve(ObjectProxy.create({ content: record }))), record); + assert.strictEqual(await unwrapResource(ObjectProxy.create({ content: null })), null); + assert.strictEqual(await unwrapResource({ loadResource: () => Promise.resolve(record) }), record); + const stub = { loadResource: () => null }; + assert.strictEqual(await unwrapResource(stub), stub, 'a stub that loads nothing stands for itself'); + assert.strictEqual(await unwrapResource(null), null); + assert.strictEqual(await unwrapResource(undefined), null); + + let nested = record; + for (let i = 0; i < 10; i++) { + nested = ObjectProxy.create({ content: nested }); + } + assert.ok(await unwrapResource(nested), 'deep nesting stops after a bounded number of steps'); + }); + + test('canOpenResource checks the open path, the descriptor guard and the permission', function (assert) { + const abilities = { can: (permission) => permission === 'fleet-ops view vehicle' }; + const owner = fakeOwner({ 'service:abilities': abilities }); + registerResourceDescriptors(owner, [ + { key: 'vehicle', open: () => true, permission: 'fleet-ops view vehicle' }, + { key: 'driver', open: () => true, permission: () => 'fleet-ops view driver' }, + { key: 'trailer', open: () => true, canOpen: (record) => Boolean(record?.id) }, + { + key: 'device', + open: () => true, + canOpen: () => { + throw new Error('boom'); + }, + }, + { key: 'place', open: () => true }, + { key: 'zone' }, + ]); + + assert.true(canOpenResource(owner, { resourceType: 'vehicle' })); + assert.false(canOpenResource(owner, { resourceType: 'driver' }), 'a permission function is evaluated'); + assert.true(canOpenResource(owner, { resourceType: 'trailer', id: 't1' })); + assert.false(canOpenResource(owner, { resourceType: 'trailer' }), 'canOpen guard'); + assert.true(canOpenResource(owner, ObjectProxy.create({ content: { resourceType: 'trailer', id: 't1' } })), 'proxies are unwrapped for the guard'); + assert.false(canOpenResource(owner, { resourceType: 'device' }), 'a throwing guard denies'); + assert.true(canOpenResource(owner, 'place'), 'no permission means allowed'); + assert.false(canOpenResource(owner, 'zone'), 'no open path'); + assert.false(canOpenResource(owner, 'nothing')); + + const throwingAbilities = fakeOwner({ + 'service:abilities': { + can() { + throw new Error('boom'); + }, + }, + }); + registerResourceDescriptors(throwingAbilities, [{ key: 'vehicle', open: () => true, permission: 'x' }]); + assert.false(canOpenResource(throwingAbilities, 'vehicle'), 'a throwing abilities service denies'); + + const noAbilities = fakeOwner(); + registerResourceDescriptors(noAbilities, [{ key: 'vehicle', open: () => true, permission: 'x' }]); + assert.true(canOpenResource(noAbilities, 'vehicle'), 'without an abilities service the permission is not enforced'); + }); + + test('openResource unwraps, loads the canonical record for a subtype and never throws', async function (assert) { + const opened = []; + const canonical = new VehicleModel({ id: 'v1' }); + const found = new VehicleModel({ id: 'v2' }); + const store = { + peekRecord: (modelName, id) => (modelName === 'vehicle' && id === 'v1' ? canonical : null), + findRecord: (modelName, id) => Promise.resolve(id === 'v2' ? found : null), + }; + const owner = fakeOwner({ 'service:store': store }); + registerResourceDescriptors(owner, [ + { key: 'vehicle', modelNames: ['maintenance-subject-vehicle'], open: (record, context) => opened.push([record, context]) && true }, + { key: 'driver', open: () => false }, + { + key: 'trailer', + open: () => { + throw new Error('boom'); + }, + }, + { key: 'zone' }, + ]); + + assert.false(await openResource(owner, null)); + assert.false(await openResource(owner, { resourceType: 'nothing' })); + assert.false(await openResource(owner, { resourceType: 'zone' }), 'no open path'); + assert.false(await openResource(owner, { resourceType: 'driver' }), 'the descriptor declined'); + assert.false(await openResource(owner, { resourceType: 'trailer' }), 'a throwing open is swallowed'); + + const event = { type: 'click' }; + assert.true(await openResource(owner, new MaintenanceSubjectVehicleModel({ id: 'v1' }), { event })); + assert.strictEqual(opened[0][0], canonical, 'the subtype was swapped for the peeked canonical record'); + assert.strictEqual(opened[0][1].event, event); + assert.strictEqual(opened[0][1].owner, owner); + + assert.true(await openResource(owner, new MaintenanceSubjectVehicleModel({ uuid: 'v2' }))); + assert.strictEqual(opened[1][0], found, 'or fetched when not in the store'); + + const notFound = new MaintenanceSubjectVehicleModel({ id: 'v3' }); + assert.true(await openResource(owner, notFound)); + assert.strictEqual(opened[2][0], notFound, 'an unknown id opens the subtype itself'); + + const noId = new MaintenanceSubjectVehicleModel(); + assert.true(await openResource(owner, noId)); + assert.strictEqual(opened[3][0], noId, 'no id means nothing to load'); + + assert.true(await openResource(owner, canonical)); + assert.strictEqual(opened[4][0], canonical, 'a canonical record is used as is'); + assert.strictEqual(opened[4][1].event, null); + + const stub = { loadResource: () => Promise.resolve(canonical) }; + assert.true(await openResource(owner, stub)); + assert.strictEqual(opened[5][0], canonical, 'stubs are loaded first'); + + assert.true(await openResource(owner, Promise.resolve(ObjectProxy.create({ content: canonical })))); + assert.strictEqual(opened[6][0], canonical, 'thenables and proxies are unwrapped'); + + assert.true(await openResource(owner, { id: 'plain' }, { resourceType: 'vehicle' }), 'options.resourceType names the type of an anonymous record'); + assert.strictEqual(opened[7][0].id, 'plain'); + + const noStore = fakeOwner(); + registerResourceDescriptors(noStore, [{ key: 'vehicle', modelNames: ['maintenance-subject-vehicle'], open: (record) => opened.push([record]) && true }]); + const subtype = new MaintenanceSubjectVehicleModel({ id: 'v1' }); + assert.true(await openResource(noStore, subtype)); + assert.strictEqual(opened[8][0], subtype, 'without a store the subtype opens itself'); + + const partialStore = fakeOwner({ 'service:store': { peekRecord: () => null } }); + registerResourceDescriptors(partialStore, [{ key: 'vehicle', modelNames: ['maintenance-subject-vehicle'], open: (record) => opened.push([record]) && true }]); + assert.true(await openResource(partialStore, subtype)); + assert.strictEqual(opened[9][0], subtype, 'a store without findRecord cannot fetch'); + + const nullStore = fakeOwner({ 'service:store': { findRecord: () => Promise.resolve(null) } }); + registerResourceDescriptors(nullStore, [{ key: 'vehicle', modelNames: ['maintenance-subject-vehicle'], open: (record) => opened.push([record]) && true }]); + assert.true(await openResource(nullStore, subtype)); + assert.strictEqual(opened[10][0], subtype, 'a fetch that returns nothing keeps the subtype'); + }); + + test('readDescriptor reads plain fields and calls functions safely', function (assert) { + const descriptor = { + icon: 'truck', + title: (record) => record.name, + broken: () => { + throw new Error('boom'); + }, + }; + + assert.strictEqual(readDescriptor(descriptor, 'icon'), 'truck'); + assert.strictEqual(readDescriptor(descriptor, 'title', { name: 'Ada' }), 'Ada'); + assert.strictEqual(readDescriptor(descriptor, 'broken'), undefined); + assert.strictEqual(readDescriptor(descriptor, 'missing'), undefined); + assert.strictEqual(readDescriptor(null, 'icon'), undefined); + }); +}); + +module('Unit | Utility | resource-registry | abilities service', function (hooks) { + setupTest(hooks); + + test('the abilities service of the owner gates opening', function (assert) { + this.owner.register( + 'service:abilities', + class extends Service { + can(permission) { + return permission === 'allowed'; + } + } + ); + registerResourceDescriptors(this.owner, [ + { key: 'allowed-thing', open: () => true, permission: 'allowed' }, + { key: 'denied-thing', open: () => true, permission: 'denied' }, + ]); + + assert.true(canOpenResource(this.owner, 'allowed-thing')); + assert.false(canOpenResource(this.owner, 'denied-thing')); + }); +}); From 6ee1833778a5360bfa0b54d4156e28b579aab371 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 15 Sep 2026 22:05:15 +0800 Subject: [PATCH 11/40] Tear the popover down with its listeners, and self-close a programmatic interactive popover Attach::Popover registered listeners on the document and its target but never removed them when destroyed, so every escape key and mouse move kept calling into dead components. Remove them in willDestroy along with the pending timers. An interactive popover shown through @isShown, or whose target the pointer left before the listeners were bound, had no way to close on mouse-out because the document mousemove watcher was only installed on the target's mouseleave. Install it when the popover shows. --- addon/components/attach/popover.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/addon/components/attach/popover.js b/addon/components/attach/popover.js index 9e24d417..ee465c6e 100644 --- a/addon/components/attach/popover.js +++ b/addon/components/attach/popover.js @@ -378,6 +378,20 @@ export default class AttachPopoverComponent extends Component { // Make the attachment visible immediately so transition animations can take place this.setIsVisibleAfterDelay(true, 0); this.startShowAnimation(); + + // An interactive attachment that was opened programmatically (or whose + // target the pointer left before the listeners were bound) must still + // close once the pointer is away from both target and attachment. + if (this.interactive && this.hideEvents.includes('mouseleave')) { + this.hideOnMouseLeaveTarget(); + } + } + + willDestroy() { + super.willDestroy(...arguments); + cancel(this.delayedVisibilityToggle); + cancelAnimationFrame(this.animationTimeout); + this.removeEventListeners(); } @action startShowAnimation() { From 24ed8a8f951078d78ad1f897642a977a419753e7 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 15 Sep 2026 22:05:15 +0800 Subject: [PATCH 12/40] Add Resource::HoverCard and Resource::Summary Resource::Summary is the compact card for one record: image or icon tile, title and identifier, status badge and online state, four to six facts from the descriptor (a fact with a related record renders that record's pill without its own popover), and one View action shown only when the resource can be opened. Hydration runs when the card is inserted and shows skeleton bars meanwhile; a failed hydrate falls back to what the record carries. Resource::HoverCard hosts it lazily. Stage 0 is a hidden anchor with hover and focus listeners on its target; only after 350ms of continuous hover does it render an interactive Attach::Popover with the summary, and when the popover hides it drops back to stage 0. Devices without hover never arm. The shared resource-identity util carries the presentation logic (title, identifier, image, status tone, badges, facts) the identity components derive from a descriptor. --- addon/components/resource/hover-card.hbs | 21 ++ addon/components/resource/hover-card.js | 167 +++++++++ addon/components/resource/summary.hbs | 64 ++++ addon/components/resource/summary.js | 125 +++++++ addon/styles/addon.css | 1 + addon/styles/components/resource-identity.css | 228 +++++++++++++ addon/utils/resource-identity.js | 319 ++++++++++++++++++ app/components/resource/hover-card.js | 1 + app/components/resource/summary.js | 1 + app/utils/resource-identity.js | 1 + 10 files changed, 928 insertions(+) create mode 100644 addon/components/resource/hover-card.hbs create mode 100644 addon/components/resource/hover-card.js create mode 100644 addon/components/resource/summary.hbs create mode 100644 addon/components/resource/summary.js create mode 100644 addon/styles/components/resource-identity.css create mode 100644 addon/utils/resource-identity.js create mode 100644 app/components/resource/hover-card.js create mode 100644 app/components/resource/summary.js create mode 100644 app/utils/resource-identity.js diff --git a/addon/components/resource/hover-card.hbs b/addon/components/resource/hover-card.hbs new file mode 100644 index 00000000..d3a00d66 --- /dev/null +++ b/addon/components/resource/hover-card.hbs @@ -0,0 +1,21 @@ + diff --git a/addon/components/resource/hover-card.js b/addon/components/resource/hover-card.js new file mode 100644 index 00000000..827d8b67 --- /dev/null +++ b/addon/components/resource/hover-card.js @@ -0,0 +1,167 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { getOwner } from '@ember/application'; +import { cancel, later } from '@ember/runloop'; +import { resourceComponentName } from '../../utils/resource-registry'; + +/** + * The lazy host for a resource's hover summary. + * + * Stage 0 renders a hidden anchor and listens on its target (the element + * matching `@targetSelector`, else the anchor's parent) for hover and focus. + * Only after `@armDelay` of continuous hover does stage 1 render an + * interactive Attach::Popover with the resource's summary component, so a + * table of a hundred rows costs a hundred listeners and nothing more until + * someone actually pauses on a row. When the popover hides, the host drops + * back to stage 0. + * + * Devices without hover (`(hover: none)`) never arm. + */ +export default class ResourceHoverCardComponent extends Component { + @tracked armed = false; + @tracked target = null; + + anchor = null; + timer = null; + bound = []; + + get armDelay() { + return this.args.armDelay ?? 350; + } + + get showDelay() { + return this.args.showDelay ?? 0; + } + + get hideDelay() { + return this.args.hideDelay ?? 150; + } + + get placement() { + return this.args.placement ?? 'top-start'; + } + + get offset() { + return this.args.offset ?? 8; + } + + get summaryComponent() { + return this.args.summaryComponent ?? resourceComponentName(getOwner(this), 'summary', this.args.resourceType ?? this.args.resource); + } + + get isTouchDevice() { + try { + return window.matchMedia?.('(hover: none)')?.matches === true; + } catch { + return false; + } + } + + get isEnabled() { + return Boolean(this.args.resource) && !this.args.disabled && Boolean(this.summaryComponent) && !this.isTouchDevice; + } + + @action setup(element) { + this.anchor = element; + + if (!this.isEnabled) { + return; + } + + this.target = this.findTarget(element); + + if (this.target) { + this.bind(); + } + } + + @action teardown() { + this.cancelTimer(); + this.unbind(); + } + + willDestroy() { + super.willDestroy(...arguments); + this.teardown(); + } + + findTarget(element) { + const { targetSelector } = this.args; + + if (typeof targetSelector === 'string' && targetSelector !== '') { + return element.closest(targetSelector) ?? element.parentElement?.querySelector(targetSelector) ?? null; + } + + return element.parentElement ?? null; + } + + bind() { + if (!this.target || this.bound.length) { + return; + } + + const pairs = [ + ['mouseenter', this.onEnter], + ['focusin', this.onEnter], + ['mouseleave', this.onLeave], + ['focusout', this.onLeave], + ]; + + pairs.forEach(([event, handler]) => this.target.addEventListener(event, handler)); + this.bound = pairs; + } + + unbind() { + if (this.target) { + this.bound.forEach(([event, handler]) => this.target.removeEventListener(event, handler)); + } + + this.bound = []; + } + + cancelTimer() { + if (this.timer) { + cancel(this.timer); + this.timer = null; + } + } + + @action onEnter() { + this.cancelTimer(); + this.timer = later(this, this.arm, this.armDelay); + } + + @action onLeave() { + this.cancelTimer(); + } + + @action arm() { + this.timer = null; + + if (this.isDestroyed || this.isDestroying || this.armed) { + return; + } + + // The popover binds its own show and hide listeners on the target from + // here on; ours would only double up. + this.unbind(); + this.armed = true; + } + + /** Back to stage 0 once the popover has hidden. */ + @action onPopoverChange(visible) { + if (!visible) { + this.disarm(); + } + } + + @action disarm() { + if (this.isDestroyed || this.isDestroying) { + return; + } + + this.armed = false; + this.bind(); + } +} diff --git a/addon/components/resource/summary.hbs b/addon/components/resource/summary.hbs new file mode 100644 index 00000000..2258136a --- /dev/null +++ b/addon/components/resource/summary.hbs @@ -0,0 +1,64 @@ +
+
+
+ {{#if this.image.component}} + {{component this.image.component resource=this.record}} + {{else if this.image.url}} + + {{else}} +
+ +
+ {{/if}} +
+
+
{{this.title}}
+ {{#if this.identifier}} +
{{this.identifier}}
+ {{/if}} + {{#if (or this.status this.hasOnline)}} +
+ {{#if this.status}} + + {{/if}} + {{#if this.hasOnline}} + + + {{if this.online "Online" "Offline"}} + + {{/if}} +
+ {{/if}} +
+
+ {{#if this.hydrate.isRunning}} +
+ + + +
+ {{else if this.facts.length}} +
+ {{#each this.facts as |fact|}} +
+
{{fact.label}}
+
+ {{#if fact.pillComponent}} + {{component fact.pillComponent resource=fact.related resourceType=fact.relatedType noPopover=true}} + {{else}} + {{fact.value}} + {{/if}} +
+
+ {{/each}} +
+ {{/if}} + {{#if this.showView}} +
+ +
+ {{/if}} +
diff --git a/addon/components/resource/summary.js b/addon/components/resource/summary.js new file mode 100644 index 00000000..fcc6ed1e --- /dev/null +++ b/addon/components/resource/summary.js @@ -0,0 +1,125 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { getOwner } from '@ember/application'; +import { task } from 'ember-concurrency'; +import { getResourceDescriptor, canOpenResource, openResource, readDescriptor } from '../../utils/resource-registry'; +import { unwrapRecord, resourceTitle, resourceIdentifier, resourceImage, resourceOnline, resourceStatus, resourceFacts, makeTranslator } from '../../utils/resource-identity'; + +/** + * The compact summary card shown when a resource is hovered: image or icon, + * title and identifier, status and online state, a few key facts, and one + * View action. Hydration runs only when the card is inserted, so a table + * costs nothing until a row is actually paused on; while it runs the facts + * show as skeleton bars, and a failed hydrate falls back to what the record + * already carries. + */ +export default class ResourceSummaryComponent extends Component { + @tracked hydrated = null; + @tracked hydrateFailed = false; + + translate = makeTranslator(getOwner(this)); + + get owner() { + return getOwner(this); + } + + get record() { + return this.hydrated ?? unwrapRecord(this.args.resource); + } + + get descriptor() { + return getResourceDescriptor(this.owner, this.args.resourceType ?? this.record ?? this.args.resource); + } + + get title() { + return resourceTitle(this.descriptor, this.record, this.args.titleFallback ?? '-'); + } + + get identifier() { + return resourceIdentifier(this.descriptor, this.record); + } + + get image() { + const image = resourceImage(this.descriptor, this.record); + + if (!image.url && !image.icon && !image.component) { + image.icon = readDescriptor(this.descriptor, 'icon') ?? 'cube'; + } + + return image; + } + + get online() { + return resourceOnline(this.descriptor, this.record); + } + + get hasOnline() { + return typeof this.online === 'boolean'; + } + + get status() { + const status = resourceStatus(this.descriptor, this.record); + + return status === undefined || status === null || status === '' ? null : status; + } + + get facts() { + return resourceFacts(this.owner, this.descriptor, this.record, { translate: this.translate }); + } + + get canOpen() { + return canOpenResource(this.owner, this.args.resourceType ?? this.record); + } + + get showView() { + if (this.args.showView === false) { + return false; + } + + return typeof this.args.onView === 'function' || this.canOpen; + } + + get viewLabel() { + return this.args.viewLabel ?? this.translate('common.view') ?? 'View'; + } + + @task *hydrate() { + const record = unwrapRecord(this.args.resource); + + if (!record) { + return; + } + + try { + let loaded = null; + + if (typeof this.descriptor?.hydrate === 'function') { + loaded = yield this.descriptor.hydrate(record, this.owner); + } else if (typeof record.loadResource === 'function') { + loaded = yield record.loadResource(); + } + + if (loaded && typeof loaded === 'object') { + this.hydrated = unwrapRecord(loaded); + } + } catch { + this.hydrateFailed = true; + } + } + + @action view(event) { + event?.preventDefault?.(); + event?.stopPropagation?.(); + + if (typeof this.args.onClose === 'function') { + this.args.onClose(); + } + + if (typeof this.args.onView === 'function') { + return this.args.onView(this.record, event); + } + + return openResource(this.owner, this.args.resource, { event, resourceType: this.args.resourceType }); + } +} diff --git a/addon/styles/addon.css b/addon/styles/addon.css index 0b83cb5e..087a80d8 100644 --- a/addon/styles/addon.css +++ b/addon/styles/addon.css @@ -25,6 +25,7 @@ @import 'components/overlay.css'; @import 'components/badge.css'; @import 'components/pill.css'; +@import 'components/resource-identity.css'; @import 'components/dropdown.css'; @import 'components/cards.css'; @import 'components/timeline.css'; diff --git a/addon/styles/components/resource-identity.css b/addon/styles/components/resource-identity.css new file mode 100644 index 00000000..109cf080 --- /dev/null +++ b/addon/styles/components/resource-identity.css @@ -0,0 +1,228 @@ +/* + * Resource identity: hover summaries, select options and identity tiles. + * Colours are set for light and dark so a card reads correctly wherever it + * floats, including above the resource context panel. + */ + +/* ── Hover card (the floating element hosting a summary) ── */ +.ember-attacher.resource-hover-card { + z-index: 10000; +} + +.resource-hover-card .resource-hover-card-inner { + @apply rounded-lg border border-gray-200 bg-white text-left text-gray-900 shadow-xl ring-1 ring-black/5; + padding: 0; + margin: 0; + max-width: min(24rem, calc(100vw - 2rem)); +} + +body[data-theme='dark'] .resource-hover-card .resource-hover-card-inner { + @apply border-gray-700 bg-gray-900 text-gray-100 ring-white/10; +} + +.resource-hover-card-anchor { + display: none; +} + +/* ── Summary card ── */ +.resource-summary { + @apply flex flex-col gap-3 p-3 text-sm; + width: 20rem; +} + +.resource-summary-header { + @apply flex items-start gap-3; +} + +.resource-summary-media { + @apply flex-shrink-0; +} + +.resource-summary-image, +.resource-summary-icon { + @apply h-12 w-12 border border-gray-200 object-cover shadow-sm dark:border-gray-700; + border-radius: 0.5rem; +} + +.resource-summary-image[data-shape='round'] { + border-radius: 9999px; +} + +.resource-summary-icon { + @apply flex items-center justify-center bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400; +} + +.resource-summary-heading { + @apply flex min-w-0 flex-col gap-0.5; +} + +.resource-summary-title { + @apply truncate text-sm font-semibold leading-5 text-gray-900 dark:text-gray-100; +} + +.resource-summary-identifier { + @apply truncate text-xs text-gray-500 dark:text-gray-400; +} + +.resource-summary-status { + @apply mt-1 flex flex-wrap items-center gap-2; +} + +.resource-summary-online { + @apply inline-flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400; +} + +.resource-summary-online.is-online { + @apply text-green-600 dark:text-green-400; +} + +.resource-summary-facts { + @apply grid gap-x-3 gap-y-1.5 border-t border-gray-100 pt-3 dark:border-gray-800; + grid-template-columns: max-content minmax(0, 1fr); + margin: 0; +} + +.resource-summary-fact { + display: contents; +} + +.resource-summary-fact > dt { + @apply truncate text-xs leading-5 text-gray-500 dark:text-gray-400; +} + +.resource-summary-fact > dd { + @apply min-w-0 truncate text-xs leading-5 text-gray-900 dark:text-gray-100; + margin: 0; +} + +.resource-summary-fact > dd .fleetbase-pill { + @apply inline-block align-middle; +} + +.resource-summary-fact > dd .fleetbase-pill img, +.resource-summary-fact > dd .fleetbase-pill .resource-pill-icon { + @apply h-5 w-5; + width: 1.25rem; + height: 1.25rem; +} + +.resource-summary-fact > dd .fleetbase-pill .text-xs { + display: none; +} + +.resource-summary-skeleton { + @apply flex flex-col gap-2 border-t border-gray-100 pt-3 dark:border-gray-800; +} + +.resource-summary-skeleton > span { + @apply block h-3 rounded bg-gray-200 dark:bg-gray-700; + animation: resource-summary-pulse 1.2s ease-in-out infinite; +} + +.resource-summary-skeleton > span:nth-child(2) { + width: 70%; +} + +.resource-summary-skeleton > span:nth-child(3) { + width: 50%; +} + +@keyframes resource-summary-pulse { + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.4; + } +} + +.resource-summary-actions { + @apply flex justify-end border-t border-gray-100 pt-2 dark:border-gray-800; +} + +/* ── Pill icon tile ── */ +.resource-pill-icon { + @apply flex h-7 w-7 items-center justify-center rounded-md border border-gray-200 bg-gray-100 text-gray-500 shadow-sm dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400; +} + +/* ── Select option: photo or icon, name, and a line of detail ── */ +.select-option { + display: flex; + align-items: center; + gap: 0.625rem; + min-width: 0; + padding: 0.125rem 0; +} + +.select-option__photo { + flex-shrink: 0; + width: 2rem; + height: 2rem; + object-fit: cover; + border-radius: 9999px; + background-color: rgb(156 163 175 / 20%); +} + +.select-option__photo[data-shape='square'] { + border-radius: 0.375rem; +} + +.select-option__icon, +.select-option__component { + display: inline-flex; + align-items: center; + justify-content: center; + color: rgb(107 114 128); +} + +.select-option__text { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 0.125rem; + min-width: 0; +} + +.select-option__title, +.select-option__details { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.select-option__title { + font-size: 0.8125rem; + font-weight: 600; + line-height: 1.2; +} + +.select-option__details { + font-size: 0.6875rem; + line-height: 1.2; + opacity: 0.7; +} + +/* In a closed select there is room for one line: a small photo, the name, then the detail. */ +.select-option--compact { + gap: 0.5rem; + padding: 0; +} + +.select-option--compact .select-option__photo { + width: 1.25rem; + height: 1.25rem; +} + +.select-option--compact .select-option__text { + flex-direction: row; + align-items: baseline; + gap: 0.375rem; +} + +.select-option--compact .select-option__title { + flex-shrink: 0; + max-width: 60%; + font-weight: 500; +} diff --git a/addon/utils/resource-identity.js b/addon/utils/resource-identity.js new file mode 100644 index 00000000..0b7c1ca7 --- /dev/null +++ b/addon/utils/resource-identity.js @@ -0,0 +1,319 @@ +import { get } from '@ember/object'; +import ObjectProxy from '@ember/object/proxy'; +import { getResourceDescriptor, readDescriptor, safeIdentifier, resourceComponentName, relationValue } from './resource-registry'; +import formatBytes from './format-bytes'; +import smartHumanize from './smart-humanize'; + +/** + * Shared presentation logic for the resource identity components: everything + * a pill, summary, identity cell or select-option derives from a descriptor + * and a record, with plain-attribute fallbacks when there is no descriptor. + */ + +export const DEFAULT_STATUS_TONES = { + active: 'text-green-500', + available: 'text-green-500', + online: 'text-green-500', + connected: 'text-green-500', + success: 'text-green-500', + enabled: 'text-green-500', + equipped: 'text-green-500', + in_stock: 'text-green-500', + in_service: 'text-green-500', + on_duty: 'text-green-500', + published: 'text-green-500', + passed: 'text-green-500', + completed: 'text-green-500', + matched: 'text-green-500', + warning: 'text-yellow-500', + pending: 'text-yellow-500', + busy: 'text-yellow-500', + assigned: 'text-yellow-500', + maintenance: 'text-yellow-500', + recently_offline: 'text-yellow-500', + low_stock: 'text-yellow-500', + in_progress: 'text-yellow-500', + scheduled: 'text-yellow-500', + draft: 'text-yellow-500', + medium: 'text-yellow-500', + inactive: 'text-gray-400', + offline: 'text-gray-400', + unavailable: 'text-gray-400', + unequipped: 'text-gray-400', + never_connected: 'text-gray-400', + long_offline: 'text-gray-400', + detached: 'text-gray-400', + low: 'text-gray-400', + out_of_stock: 'text-red-500', + error: 'text-red-500', + disabled: 'text-red-500', + suspended: 'text-red-500', + out_of_service: 'text-red-500', + failed: 'text-red-500', + overdue: 'text-red-500', + expired: 'text-red-500', + critical: 'text-red-500', + high: 'text-red-500', + urgent: 'text-red-500', + canceled: 'text-red-500', + cancelled: 'text-red-500', + retired: 'text-red-500', +}; + +const TITLE_PATHS = ['name', 'display_name', 'displayName', 'title', 'tracking', 'public_id']; + +/** A record with any proxy peeled off; never a promise. */ +export function unwrapRecord(value) { + if (!value || typeof value !== 'object') { + return value ?? null; + } + + if (value instanceof ObjectProxy) { + return value.content ?? null; + } + + if (typeof value.then === 'function' && 'content' in value) { + return value.content ?? null; + } + + return value; +} + +function firstPresent(record, paths) { + for (const path of paths) { + const value = get(record, path); + + if (value !== undefined && value !== null && String(value).trim() !== '') { + return value; + } + } + + return undefined; +} + +export function resourceTitle(descriptor, record, fallback) { + if (!record) { + return fallback ?? null; + } + + const title = readDescriptor(descriptor, 'title', record); + + if (title !== undefined && title !== null && String(title).trim() !== '') { + return title; + } + + return firstPresent(record, TITLE_PATHS) ?? fallback ?? null; +} + +export function resourceIdentifier(descriptor, record) { + if (!record) { + return null; + } + + return safeIdentifier(readDescriptor(descriptor, 'identifier', record)); +} + +/** + * Normalizes what a descriptor's `image()` returns into + * `{ url, fallback, shape, icon, component }`. + */ +export function resourceImage(descriptor, record, overrides = {}) { + const raw = record ? readDescriptor(descriptor, 'image', record) : undefined; + const image = raw && typeof raw === 'object' ? raw : {}; + const url = overrides.url ?? image.url ?? (typeof raw === 'string' ? raw : undefined) ?? (record ? firstPresent(record, ['photo_url', 'avatar_url', 'logo_url', 'icon_url']) : undefined); + + return { + url: url ?? null, + fallback: overrides.fallback ?? image.fallback ?? null, + shape: overrides.shape ?? image.shape ?? 'round', + icon: overrides.icon ?? image.icon ?? null, + iconClass: image.iconClass ?? null, + component: image.component ?? null, + }; +} + +export function resourceOnline(descriptor, record) { + if (!record) { + return undefined; + } + + const online = readDescriptor(descriptor, 'online', record); + + if (online === undefined && !descriptor) { + const value = get(record, 'online') ?? get(record, 'is_online'); + + return typeof value === 'boolean' ? value : undefined; + } + + return online ?? undefined; +} + +export function resourceStatus(descriptor, record) { + if (!record) { + return undefined; + } + + const status = readDescriptor(descriptor, 'status', record); + + if (status === undefined && !descriptor) { + return get(record, 'status') ?? undefined; + } + + return status ?? undefined; +} + +/** The dot colour class for an online flag or status value. */ +export function statusToneClass(value, tones = {}) { + if (typeof value === 'boolean') { + return value ? 'text-green-500' : 'text-yellow-200'; + } + + if (value === undefined || value === null || value === '') { + return 'text-gray-400'; + } + + const map = { ...DEFAULT_STATUS_TONES, ...tones }; + const key = String(value); + + return map[key] ?? map[key.toLowerCase()] ?? 'text-gray-400'; +} + +/** Badges from a descriptor, minus any that point back at the row itself. */ +export function resourceBadges(descriptor, record, context = {}) { + if (!record) { + return []; + } + + const badges = readDescriptor(descriptor, 'badges', record, context); + const list = Array.isArray(badges) ? badges : []; + const selfId = context.selfId ?? null; + + return list.filter((badge) => badge && badge.label !== undefined && badge.label !== null && String(badge.label).trim() !== '' && !(selfId && badge.relatedId && String(badge.relatedId) === String(selfId))); +} + +export function formatFactValue(value, format) { + if (value === undefined || value === null || value === '') { + return null; + } + + if (typeof format === 'function') { + try { + return format(value); + } catch { + return null; + } + } + + if (format === 'date' || value instanceof Date) { + const date = value instanceof Date ? value : new Date(value); + + return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString(); + } + + if (format === 'bytes') { + return formatBytes(Number(value)); + } + + if (format === 'humanize') { + return smartHumanize(String(value)); + } + + if (typeof value === 'boolean') { + return value ? 'Yes' : 'No'; + } + + return String(value); +} + +function humanizeKey(key) { + const last = String(key).split('.').pop(); + + return smartHumanize(last.replace(/[-_]/g, ' ')); +} + +/** + * The facts a summary lists: `[{ label, value, related, relatedType, pillComponent }]`. + * A fact with a related record renders as that record's pill when the owner + * can render one, otherwise as its value text. + */ +export function resourceFacts(owner, descriptor, record, { translate, limit = 6 } = {}) { + if (!record) { + return []; + } + + const facts = readDescriptor(descriptor, 'facts', record); + const list = Array.isArray(facts) ? facts : []; + const output = []; + + for (const fact of list) { + if (!fact) { + continue; + } + + const related = unwrapRecord(fact.related); + const pillComponent = related ? resourceComponentName(owner, 'pill', fact.relatedType ?? related) : null; + const value = related && !pillComponent ? resourceTitle(getResourceDescriptor(owner, fact.relatedType ?? related), related, fact.value) : formatFactValue(fact.value, fact.format); + + if ((value === null || value === undefined || value === '') && !pillComponent) { + continue; + } + + const label = fact.label ?? (fact.labelKey ? (typeof translate === 'function' ? translate(fact.labelKey) : null) : null) ?? humanizeKey(fact.labelKey ?? 'value'); + + output.push({ label, value, related, relatedType: fact.relatedType ?? null, pillComponent }); + + if (output.length >= limit) { + break; + } + } + + return output; +} + +/** The detail strings a select-option shows under the title. */ +export function resourceSelectDetails(descriptor, record) { + if (!record) { + return []; + } + + const details = readDescriptor(descriptor, 'selectDetails', record); + + if (Array.isArray(details)) { + return details; + } + + if (details !== undefined && details !== null) { + return [details]; + } + + return [resourceIdentifier(descriptor, record)]; +} + +/** Translates a key through the owner's intl service when it has the key. */ +export function makeTranslator(owner) { + let intl = null; + + try { + intl = owner?.lookup?.('service:intl') ?? null; + } catch { + intl = null; + } + + return (key) => { + if (!intl || typeof intl.t !== 'function') { + return null; + } + + try { + if (typeof intl.exists === 'function' && !intl.exists(key)) { + return null; + } + + return intl.t(key); + } catch { + return null; + } + }; +} + +export { relationValue }; diff --git a/app/components/resource/hover-card.js b/app/components/resource/hover-card.js new file mode 100644 index 00000000..82c98869 --- /dev/null +++ b/app/components/resource/hover-card.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/resource/hover-card'; diff --git a/app/components/resource/summary.js b/app/components/resource/summary.js new file mode 100644 index 00000000..b4161682 --- /dev/null +++ b/app/components/resource/summary.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/resource/summary'; diff --git a/app/utils/resource-identity.js b/app/utils/resource-identity.js new file mode 100644 index 00000000..00c319b2 --- /dev/null +++ b/app/utils/resource-identity.js @@ -0,0 +1 @@ +export * from '@fleetbase/ember-ui/utils/resource-identity'; From bc1e03457ad0e7b4dcf1109071d31f9c1bc54c27 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 15 Sep 2026 22:05:15 +0800 Subject: [PATCH 13/40] Add Resource::Pill A pill driven by the registry: the descriptor supplies the image or icon tile, the title, the identifier subtitle and the online dot; the pill opens the resource on click when its descriptor knows how and otherwise renders the static variant, and hovering shows the summary card unless @noPopover is set. Wrappers pass @useBlock so a caller's block replaces the default content only when the caller actually gave one. --- addon/components/resource/pill.hbs | 53 ++++++++++++++++ addon/components/resource/pill.js | 97 ++++++++++++++++++++++++++++++ app/components/resource/pill.js | 1 + 3 files changed, 151 insertions(+) create mode 100644 addon/components/resource/pill.hbs create mode 100644 addon/components/resource/pill.js create mode 100644 app/components/resource/pill.js diff --git a/addon/components/resource/pill.hbs b/addon/components/resource/pill.hbs new file mode 100644 index 00000000..e1a13655 --- /dev/null +++ b/addon/components/resource/pill.hbs @@ -0,0 +1,53 @@ + + <:image> + {{#if this.image.component}} + {{component this.image.component resource=this.record}} + {{else if this.image.icon}} +
+ +
+ {{else}} + {{this.title}} + {{/if}} + {{#if this.showOnlineIndicator}} + + {{/if}} + + <:default as |resource|> + {{#if (and (has-block) (not-eq @useBlock false))}} + {{yield resource}} + {{else}} +
{{this.title}}
+ {{#if this.subtitle}} +
{{this.subtitle}}
+ {{/if}} + {{/if}} + {{#if this.showPopover}} + + {{/if}} + +
diff --git a/addon/components/resource/pill.js b/addon/components/resource/pill.js new file mode 100644 index 00000000..2b9e935f --- /dev/null +++ b/addon/components/resource/pill.js @@ -0,0 +1,97 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { getOwner } from '@ember/application'; +import { getResourceDescriptor, resolveResourceKey, canOpenResource, openResource, resourceComponentName } from '../../utils/resource-registry'; +import { unwrapRecord, resourceTitle, resourceIdentifier, resourceImage, resourceOnline } from '../../utils/resource-identity'; + +/** + * A pill driven by the resource registry: the descriptor supplies the image + * (or an icon tile), the title, the identifier subtitle and the online dot, + * and the pill opens the resource on click when the descriptor knows how. + * With no open path it renders the static, non-link variant. Hovering shows + * the resource's summary card unless `@noPopover` is set. + */ +export default class ResourcePillComponent extends Component { + get owner() { + return getOwner(this); + } + + get record() { + return unwrapRecord(this.args.resource); + } + + get key() { + return resolveResourceKey(this.owner, this.args.resourceType ?? this.record); + } + + get descriptor() { + return getResourceDescriptor(this.owner, this.args.resourceType ?? this.record); + } + + get title() { + if (this.args.title !== undefined && this.args.title !== null) { + return this.args.title; + } + + return resourceTitle(this.descriptor, this.record, this.args.titleFallback ?? '-'); + } + + get subtitle() { + if (this.args.subtitle !== undefined) { + return this.args.subtitle; + } + + return resourceIdentifier(this.descriptor, this.record); + } + + get image() { + return resourceImage(this.descriptor, this.record, { + url: this.args.imageSrc, + fallback: this.args.imageFallback, + shape: this.args.imageShape, + icon: this.args.icon, + }); + } + + get online() { + if (this.args.online !== undefined) { + return this.args.online; + } + + return resourceOnline(this.descriptor, this.record); + } + + get showOnlineIndicator() { + if (this.args.showOnlineIndicator !== undefined) { + return Boolean(this.args.showOnlineIndicator); + } + + return typeof this.online === 'boolean'; + } + + get canOpen() { + return Boolean(this.record) && canOpenResource(this.owner, this.args.resourceType ?? this.record); + } + + get clickHandler() { + if (typeof this.args.onClick === 'function') { + return this.args.onClick; + } + + if (this.args.static || !this.canOpen) { + return undefined; + } + + return this.open; + } + + get showPopover() { + return !this.args.noPopover && Boolean(this.record) && Boolean(resourceComponentName(this.owner, 'summary', this.args.resourceType ?? this.record)); + } + + @action open(resource, event) { + event?.preventDefault?.(); + + return openResource(this.owner, this.args.resource, { event, resourceType: this.args.resourceType }); + } +} diff --git a/app/components/resource/pill.js b/app/components/resource/pill.js new file mode 100644 index 00000000..641e6310 --- /dev/null +++ b/app/components/resource/pill.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/resource/pill'; From 135f1ca60c9150f81e7c6829c311a99194c0ea1c Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 15 Sep 2026 22:05:15 +0800 Subject: [PATCH 14/40] Add Table::Cell::Identity, the one-line identity cell A small image or icon with the status dot, the truncated name, and up to column.badgeLimit inline badges from the descriptor, all on one line. There is never a status badge; compact, showStatusBadge and showStatus are accepted and ignored so existing column definitions keep working. Badges whose relatedId is the row's own id are dropped. Clicking runs @onClick, column.onClick and column.action, otherwise opens the resource through its descriptor, and stops propagation so a row action does not fire as well; column.permission disables the button. Without any open path the cell renders plain text and keeps the hover card. --- addon/components/table/cell/identity.hbs | 40 ++++ addon/components/table/cell/identity.js | 221 ++++++++++++++++++ addon/components/table/cell/identity/body.hbs | 31 +++ app/components/table/cell/identity.js | 1 + app/components/table/cell/identity/body.js | 1 + 5 files changed, 294 insertions(+) create mode 100644 addon/components/table/cell/identity.hbs create mode 100644 addon/components/table/cell/identity.js create mode 100644 addon/components/table/cell/identity/body.hbs create mode 100644 app/components/table/cell/identity.js create mode 100644 app/components/table/cell/identity/body.js diff --git a/addon/components/table/cell/identity.hbs b/addon/components/table/cell/identity.hbs new file mode 100644 index 00000000..95a68a05 --- /dev/null +++ b/addon/components/table/cell/identity.hbs @@ -0,0 +1,40 @@ +{{#if this.resource}} +
+ {{#if this.hasOpenPath}} + + {{else}} +
+ +
+ {{/if}} +
+{{else}} +
{{this.emptyText}}
+{{/if}} diff --git a/addon/components/table/cell/identity.js b/addon/components/table/cell/identity.js new file mode 100644 index 00000000..26869b1e --- /dev/null +++ b/addon/components/table/cell/identity.js @@ -0,0 +1,221 @@ +import Component from '@glimmer/component'; +import { action, get } from '@ember/object'; +import { getOwner } from '@ember/application'; +import { inject as service } from '@ember/service'; +import { getResourceDescriptor, resolveResourceKey, canOpenResource, openResource, resourceComponentName } from '../../../utils/resource-registry'; +import { unwrapRecord, resourceTitle, resourceImage, resourceOnline, resourceStatus, resourceBadges, statusToneClass } from '../../../utils/resource-identity'; + +/** + * A one-line identity cell driven by the resource registry: a small image + * or icon with the status dot, the name, and up to `column.badgeLimit` + * inline badges from the descriptor. Clicking runs `@onClick`, then + * `column.onClick`, then `column.action`, and otherwise opens the resource + * through its descriptor. Hovering shows the resource's summary card. + * + * There is never a status badge: the dot carries the status. The old + * `compact`, `showStatusBadge` and `showStatus` column keys are accepted and + * ignored so existing column definitions keep working. + */ +export default class TableCellIdentityComponent extends Component { + @service abilities; + + get owner() { + return getOwner(this); + } + + get column() { + return this.args.column ?? {}; + } + + get row() { + return this.args.row; + } + + get resource() { + const { resourcePath } = this.column; + let resource; + + if (typeof resourcePath === 'function') { + resource = resourcePath(this.args.row, this.args.value, this.column); + } else if (typeof resourcePath === 'string') { + resource = get(this.args.row ?? {}, resourcePath); + } else if (this.args.value && typeof this.args.value === 'object') { + resource = this.args.value; + } else { + resource = this.args.row; + } + + return unwrapRecord(resource) ?? null; + } + + get resourceType() { + return this.column.resourceType ?? this.args.resourceType; + } + + get key() { + return resolveResourceKey(this.owner, this.resourceType ?? this.resource); + } + + get descriptor() { + return getResourceDescriptor(this.owner, this.resourceType ?? this.resource); + } + + get emptyText() { + return this.column.emptyText ?? '-'; + } + + get label() { + const { labelFormatter, labelValue, labelPath } = this.column; + + if (typeof labelFormatter === 'function') { + return labelFormatter(this.resource, this.column, this.row); + } + + if (labelValue !== undefined) { + return typeof labelValue === 'function' ? labelValue(this.resource, this.column, this.row) : labelValue; + } + + if (typeof labelPath === 'function') { + return labelPath(this.resource, this.column, this.row); + } + + if (typeof labelPath === 'string') { + return get(this.resource, labelPath) ?? resourceTitle(this.descriptor, this.resource, null); + } + + return resourceTitle(this.descriptor, this.resource, null); + } + + get image() { + const image = resourceImage(this.descriptor, this.resource, { + url: typeof this.column.mediaPath === 'string' ? get(this.resource, this.column.mediaPath) : undefined, + fallback: this.column.fallbackImage ?? this.column.fallbackSrc, + }); + + if (!image.url && !image.icon && !image.component) { + image.icon = this.descriptor?.icon ?? 'cube'; + } + + return image; + } + + get hasStatusDot() { + return this.column.showStatusDot ?? this.column.showOnlineIndicator ?? true; + } + + get statusValue() { + if (typeof this.column.onlinePath === 'string') { + const online = get(this.resource, this.column.onlinePath); + + if (online !== undefined && online !== null) { + return online; + } + } + + if (typeof this.column.statusPath === 'string') { + return get(this.resource, this.column.statusPath); + } + + if (typeof this.column.statusPath === 'function') { + return this.column.statusPath(this.resource, this.column, this.row); + } + + const online = resourceOnline(this.descriptor, this.resource); + + if (online !== undefined && online !== null) { + return online; + } + + return resourceStatus(this.descriptor, this.resource); + } + + get statusDotClass() { + const value = this.statusValue; + + if (typeof this.column.statusToneClass === 'function') { + return this.column.statusToneClass(value, this.resource, this.column); + } + + return statusToneClass(value, { ...(this.descriptor?.statusTones ?? {}), ...(this.column.statusToneMap ?? {}) }); + } + + get badges() { + const { badges, hideBadges, badgeLimit } = this.column; + + if (hideBadges) { + return []; + } + + const selfId = get(this.row ?? {}, 'id') ?? get(this.row ?? {}, 'uuid') ?? null; + let list; + + if (typeof badges === 'function') { + list = badges(this.resource, this.row, this.column) ?? []; + } else if (Array.isArray(badges)) { + list = badges; + } else { + list = resourceBadges(this.descriptor, this.resource, { row: this.row, column: this.column, selfId }); + } + + return list.filter(Boolean).slice(0, badgeLimit ?? 2); + } + + get isDisabled() { + const { permission } = this.column; + + if (!permission) { + return false; + } + + try { + return this.abilities.cannot(permission); + } catch { + return false; + } + } + + get hasHandler() { + return typeof this.args.onClick === 'function' || typeof this.column.onClick === 'function' || typeof this.column.action === 'function'; + } + + get hasOpenPath() { + return this.hasHandler || canOpenResource(this.owner, this.resourceType ?? this.resource); + } + + get showPopover() { + return this.column.popover !== false && Boolean(resourceComponentName(this.owner, 'summary', this.resourceType ?? this.resource)); + } + + get wrapperClass() { + return this.column.wrapperClass ?? 'max-w-md'; + } + + @action onClick(event) { + const { column } = this; + const { onClick } = this.args; + const resource = this.resource; + let handled = false; + + if (typeof onClick === 'function') { + onClick(resource, event); + handled = true; + } + + if (typeof column.onClick === 'function') { + column.onClick(resource, event); + handled = true; + } + + if (typeof column.action === 'function') { + column.action(resource, event); + handled = true; + } + + if (!handled) { + openResource(this.owner, resource, { event, resourceType: this.resourceType }); + handled = true; + } + + event?.stopPropagation?.(); + } +} diff --git a/addon/components/table/cell/identity/body.hbs b/addon/components/table/cell/identity/body.hbs new file mode 100644 index 00000000..1c0a8603 --- /dev/null +++ b/addon/components/table/cell/identity/body.hbs @@ -0,0 +1,31 @@ +{{! One line: image or icon tile with the status dot, the name, then inline badges. }} +
+ {{#if @image.component}} + {{component @image.component resource=@resource}} + {{else if @image.icon}} +
+ +
+ {{else}} + + {{/if}} + {{#if @hasStatusDot}} + + {{/if}} +
+
{{n-a @label}}
+{{#each @badges as |badge|}} + + {{#if badge.icon}} + + {{/if}} + {{badge.label}} + +{{/each}} +{{#if @showPopover}} + +{{/if}} diff --git a/app/components/table/cell/identity.js b/app/components/table/cell/identity.js new file mode 100644 index 00000000..95eb1ce9 --- /dev/null +++ b/app/components/table/cell/identity.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/table/cell/identity'; diff --git a/app/components/table/cell/identity/body.js b/app/components/table/cell/identity/body.js new file mode 100644 index 00000000..0bdf2edd --- /dev/null +++ b/app/components/table/cell/identity/body.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/table/cell/identity/body'; From 613234850de017f42a1a45d003a94a5126511ec8 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 15 Sep 2026 22:05:15 +0800 Subject: [PATCH 15/40] Add Resource::SelectOption The option row for a ModelSelect or PowerSelect: photo or icon, title and a line of detail, on one line in @compact form for a closed trigger. Given @option or @model the descriptor fills these in; the presentational arguments override it. The CSS is the select-option style FleetOps kept locally, moved into the addon. --- addon/components/resource/select-option.hbs | 17 ++++++ addon/components/resource/select-option.js | 65 +++++++++++++++++++++ app/components/resource/select-option.js | 1 + 3 files changed, 83 insertions(+) create mode 100644 addon/components/resource/select-option.hbs create mode 100644 addon/components/resource/select-option.js create mode 100644 app/components/resource/select-option.js diff --git a/addon/components/resource/select-option.hbs b/addon/components/resource/select-option.hbs new file mode 100644 index 00000000..449736fe --- /dev/null +++ b/addon/components/resource/select-option.hbs @@ -0,0 +1,17 @@ +
+ {{#if this.image.component}} + {{component this.image.component resource=this.record}} + {{else if this.icon}} + + + + {{else if this.photo}} + + {{/if}} +
+ {{this.title}} + {{#if this.details}} + {{this.details}} + {{/if}} +
+
diff --git a/addon/components/resource/select-option.js b/addon/components/resource/select-option.js new file mode 100644 index 00000000..8e4006e7 --- /dev/null +++ b/addon/components/resource/select-option.js @@ -0,0 +1,65 @@ +import Component from '@glimmer/component'; +import { getOwner } from '@ember/application'; +import { getResourceDescriptor } from '../../utils/resource-registry'; +import { unwrapRecord, resourceTitle, resourceImage, resourceSelectDetails } from '../../utils/resource-identity'; + +/** + * One option in a ModelSelect or PowerSelect: a photo or icon, a title, and + * a line of detail beneath it. Given `@option` (or `@model`), the descriptor + * registered for the record fills these in; the presentational arguments + * (`@photo`, `@fallbackPhoto`, `@icon`, `@title`, `@details`, `@shape`) + * override it. `@compact` puts everything on one line at a smaller photo, + * for a select's closed trigger. + */ +export default class ResourceSelectOptionComponent extends Component { + get owner() { + return getOwner(this); + } + + get record() { + return unwrapRecord(this.args.option ?? this.args.model); + } + + get descriptor() { + return getResourceDescriptor(this.owner, this.args.resourceType ?? this.record); + } + + get title() { + if (this.args.title !== undefined && this.args.title !== null) { + return this.args.title; + } + + return resourceTitle(this.descriptor, this.record, this.args.titleFallback ?? '-'); + } + + get details() { + const details = this.args.details ?? resourceSelectDetails(this.descriptor, this.record); + + return (Array.isArray(details) ? details : [details]).filter((detail) => detail !== null && detail !== undefined && String(detail).trim() !== '').join(' · '); + } + + get image() { + return resourceImage(this.descriptor, this.record, { + url: this.args.photo, + fallback: this.args.fallbackPhoto, + shape: this.args.shape, + icon: this.args.icon, + }); + } + + get photo() { + return this.image.url || this.image.fallback || null; + } + + get icon() { + if (this.image.icon) { + return this.image.icon; + } + + if (!this.photo && this.descriptor) { + return this.descriptor.icon; + } + + return null; + } +} diff --git a/app/components/resource/select-option.js b/app/components/resource/select-option.js new file mode 100644 index 00000000..ae9d3e22 --- /dev/null +++ b/app/components/resource/select-option.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/resource/select-option'; From 42e03af3dce70aabf6205cbc553d825fbc66f314 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 15 Sep 2026 22:05:16 +0800 Subject: [PATCH 16/40] Register descriptors for the console's core resources user, company, group, role, file and category, read by duck typing so an API payload renders like a record. None opens anything by default because the console has no per-record routes for them; an engine that owns such a page supplies an opener with setResourceOpener. A file opens its URL in a new tab. --- .../register-core-resource-descriptors.js | 20 ++ addon/utils/resource-descriptors/core.js | 267 ++++++++++++++++++ .../register-core-resource-descriptors.js | 1 + app/utils/resource-descriptors/core.js | 2 + 4 files changed, 290 insertions(+) create mode 100644 addon/instance-initializers/register-core-resource-descriptors.js create mode 100644 addon/utils/resource-descriptors/core.js create mode 100644 app/instance-initializers/register-core-resource-descriptors.js create mode 100644 app/utils/resource-descriptors/core.js diff --git a/addon/instance-initializers/register-core-resource-descriptors.js b/addon/instance-initializers/register-core-resource-descriptors.js new file mode 100644 index 00000000..d139a5e6 --- /dev/null +++ b/addon/instance-initializers/register-core-resource-descriptors.js @@ -0,0 +1,20 @@ +import { registerResourceDescriptors } from '../utils/resource-registry'; +import buildCoreResourceDescriptors from '../utils/resource-descriptors/core'; + +/** + * Registers the descriptors for the console's core resources in the host + * application, so pills, summaries, identity cells and select options for + * users, companies, groups, roles, files and categories work everywhere. + */ +export function initialize(appInstance) { + try { + registerResourceDescriptors(appInstance, buildCoreResourceDescriptors(appInstance)); + } catch { + // A host without the registry service simply keeps plain text. + } +} + +export default { + name: 'register-core-resource-descriptors', + initialize, +}; diff --git a/addon/utils/resource-descriptors/core.js b/addon/utils/resource-descriptors/core.js new file mode 100644 index 00000000..8f7c2a79 --- /dev/null +++ b/addon/utils/resource-descriptors/core.js @@ -0,0 +1,267 @@ +import { get } from '@ember/object'; +import { relationValue, safeIdentifier } from '../resource-registry'; + +/** + * Descriptors for the console's core resources: user, company, group, role, + * file and category. They read attributes by duck typing so a POJO from an + * API payload renders the same as an Ember Data record. + * + * None of them opens anything by default: the console has no per-record + * routes for these. An engine that owns such a page registers an opener + * with `setResourceOpener(owner, key, fn)`. + */ + +const FILE_ICONS = { + xlsx: 'file-excel', + xls: 'file-excel', + xlsb: 'file-excel', + xlsm: 'file-excel', + csv: 'file-csv', + tsv: 'file-csv', + doc: 'file-word', + docx: 'file-word', + docm: 'file-word', + pdf: 'file-pdf', + ppt: 'file-powerpoint', + pptx: 'file-powerpoint', + zip: 'file-zipper', + gz: 'file-zipper', + tar: 'file-zipper', + mp4: 'file-video', + mov: 'file-video', + mp3: 'file-audio', + wav: 'file-audio', + txt: 'file-lines', + md: 'file-lines', + json: 'file-code', + js: 'file-code', + xml: 'file-code', +}; + +function present(value) { + return value !== undefined && value !== null && String(value).trim() !== ''; +} + +function first(record, ...paths) { + for (const path of paths) { + const value = get(record, path); + + if (present(value)) { + return value; + } + } + + return null; +} + +function count(record, path, countPath) { + const counted = get(record, countPath); + + if (typeof counted === 'number') { + return counted; + } + + const relation = relationValue(record, path); + + if (relation && typeof relation.length === 'number') { + return relation.length; + } + + return null; +} + +export function fileExtension(file) { + const name = first(file ?? {}, 'original_filename', 'url', 'path'); + + if (typeof name !== 'string') { + return null; + } + + const match = name.split('?')[0].match(/\.([a-z0-9]+)$/i); + + return match ? match[1].toLowerCase() : null; +} + +export function isImageFile(file) { + const type = get(file ?? {}, 'content_type'); + + if (typeof type === 'string') { + return type.startsWith('image/'); + } + + return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'avif'].includes(fileExtension(file)); +} + +export function fileIcon(file) { + return FILE_ICONS[fileExtension(file)] ?? 'file'; +} + +export function buildCoreResourceDescriptors() { + return [ + { + key: 'user', + labelKey: 'resource.user', + icon: 'user', + modelNames: ['user'], + polymorphicTypes: ['Fleetbase\\Models\\User', 'core:user'], + title: (user) => first(user, 'name', 'email', 'public_id'), + identifier: (user) => first(user, 'email', 'phone'), + image: (user) => ({ url: get(user, 'avatar_url'), shape: 'round' }), + online: (user) => { + const online = get(user, 'is_online') ?? get(user, 'online'); + + return typeof online === 'boolean' ? online : undefined; + }, + status: (user) => first(user, 'status', 'session_status'), + badges: (user) => { + const role = first(user, 'role_name', 'role.name', 'type'); + + return role ? [{ key: 'role', icon: 'user-shield', label: role }] : []; + }, + selectDetails: (user) => [first(user, 'email'), first(user, 'phone')], + facts: (user) => [ + { labelKey: 'resource-summary.facts.email', label: 'Email', value: first(user, 'email') }, + { labelKey: 'resource-summary.facts.phone', label: 'Phone', value: first(user, 'phone') }, + { labelKey: 'resource-summary.facts.role', label: 'Role', value: first(user, 'role_name', 'role.name', 'type') }, + { labelKey: 'resource-summary.facts.company', label: 'Company', value: first(user, 'company_name', 'company.name') }, + { labelKey: 'resource-summary.facts.last-seen', label: 'Last seen', value: get(user, 'last_seen_at') ?? get(user, 'last_login') ?? get(user, 'created_at'), format: 'date' }, + { labelKey: 'resource-summary.facts.status', label: 'Status', value: first(user, 'status', 'session_status'), format: 'humanize' }, + ], + }, + { + key: 'company', + labelKey: 'resource.company', + icon: 'building', + modelNames: ['company', 'organization'], + aliases: ['organization'], + polymorphicTypes: ['Fleetbase\\Models\\Company', 'core:company'], + title: (company) => first(company, 'name', 'public_id'), + identifier: (company) => safeIdentifier(first(company, 'public_id', 'slug')), + image: (company) => ({ url: get(company, 'logo_url'), shape: 'square' }), + status: (company) => first(company, 'status'), + badges: (company) => { + const plan = first(company, 'plan', 'type'); + + return plan ? [{ key: 'plan', icon: 'tag', label: plan }] : []; + }, + selectDetails: (company) => [first(company, 'country'), first(company, 'timezone')], + facts: (company) => [ + { labelKey: 'resource-summary.facts.owner', label: 'Owner', related: relationValue(company, 'owner'), relatedType: 'user', value: first(company, 'owner.name', 'owner_name') }, + { labelKey: 'resource-summary.facts.members', label: 'Members', value: count(company, 'users', 'users_count') }, + { labelKey: 'resource-summary.facts.country', label: 'Country', value: first(company, 'country') }, + { labelKey: 'resource-summary.facts.timezone', label: 'Timezone', value: first(company, 'timezone') }, + { labelKey: 'resource-summary.facts.status', label: 'Status', value: first(company, 'status'), format: 'humanize' }, + ], + }, + { + key: 'group', + labelKey: 'resource.group', + icon: 'users', + modelNames: ['group'], + polymorphicTypes: ['Fleetbase\\Models\\Group', 'core:group'], + title: (group) => first(group, 'name', 'public_id'), + identifier: (group) => { + const members = count(group, 'users', 'users_count'); + + return typeof members === 'number' ? `${members} member${members === 1 ? '' : 's'}` : null; + }, + image: () => ({ icon: 'users' }), + selectDetails: (group) => [first(group, 'description')], + facts: (group) => [ + { labelKey: 'resource-summary.facts.description', label: 'Description', value: first(group, 'description') }, + { labelKey: 'resource-summary.facts.members', label: 'Members', value: count(group, 'users', 'users_count') }, + { labelKey: 'resource-summary.facts.created', label: 'Created', value: get(group, 'created_at'), format: 'date' }, + ], + }, + { + key: 'role', + labelKey: 'resource.role', + icon: 'user-shield', + modelNames: ['role'], + polymorphicTypes: ['Fleetbase\\Models\\Role', 'core:role'], + title: (role) => first(role, 'name', 'public_id'), + identifier: (role) => first(role, 'guard_name', 'type'), + image: () => ({ icon: 'user-shield' }), + badges: (role) => { + const service = first(role, 'service'); + + return service ? [{ key: 'service', icon: 'cubes', label: service }] : []; + }, + selectDetails: (role) => [first(role, 'description'), first(role, 'guard_name')], + facts: (role) => [ + { labelKey: 'resource-summary.facts.description', label: 'Description', value: first(role, 'description') }, + { labelKey: 'resource-summary.facts.guard', label: 'Guard', value: first(role, 'guard_name', 'type') }, + { labelKey: 'resource-summary.facts.policies', label: 'Policies', value: count(role, 'policies', 'policies_count') }, + { labelKey: 'resource-summary.facts.permissions', label: 'Permissions', value: count(role, 'permissions', 'permissions_count') }, + ], + }, + { + key: 'file', + labelKey: 'resource.file', + icon: 'file', + modelNames: ['file'], + polymorphicTypes: ['Fleetbase\\Models\\File', 'core:file'], + title: (file) => first(file, 'original_filename', 'caption', 'url', 'public_id'), + identifier: (file) => first(file, 'content_type', 'type'), + image: (file) => (isImageFile(file) && present(get(file, 'url')) ? { url: get(file, 'url'), shape: 'square' } : { icon: fileIcon(file) }), + badges: (file) => { + const extension = fileExtension(file); + + return extension ? [{ key: 'extension', icon: 'file', label: extension.toUpperCase() }] : []; + }, + selectDetails: (file) => [first(file, 'content_type', 'type'), typeof get(file, 'file_size') === 'number' ? get(file, 'file_size') : null].filter(present), + facts: (file) => [ + { labelKey: 'resource-summary.facts.type', label: 'Type', value: first(file, 'content_type', 'type') }, + { labelKey: 'resource-summary.facts.size', label: 'Size', value: get(file, 'file_size'), format: 'bytes' }, + { labelKey: 'resource-summary.facts.uploader', label: 'Uploaded by', related: relationValue(file, 'uploader'), relatedType: 'user', value: first(file, 'uploader.name', 'uploader_name') }, + { labelKey: 'resource-summary.facts.created', label: 'Uploaded', value: get(file, 'created_at'), format: 'date' }, + ], + canOpen: (file) => present(get(file ?? {}, 'url')), + open: (file) => { + const url = get(file, 'url'); + + if (!present(url)) { + return false; + } + + window.open(url, '_blank', 'noopener'); + + return true; + }, + }, + { + key: 'category', + labelKey: 'resource.category', + icon: 'folder', + modelNames: ['category'], + polymorphicTypes: ['Fleetbase\\Models\\Category', 'core:category'], + title: (category) => first(category, 'name', 'slug', 'public_id'), + identifier: (category) => first(category, 'for', 'slug'), + image: (category) => { + const icon = get(category, 'icon'); + const url = get(category, 'icon_url'); + + if (present(icon)) { + return { icon }; + } + + return present(url) ? { url, shape: 'square' } : { icon: 'folder' }; + }, + badges: (category) => { + const parent = first(category, 'parent.name', 'parent_name'); + + return parent ? [{ key: 'parent', icon: 'folder-tree', label: parent }] : []; + }, + selectDetails: (category) => [first(category, 'for'), first(category, 'description')], + facts: (category) => [ + { labelKey: 'resource-summary.facts.parent', label: 'Parent', related: relationValue(category, 'parent'), relatedType: 'category', value: first(category, 'parent.name', 'parent_name') }, + { labelKey: 'resource-summary.facts.for', label: 'For', value: first(category, 'for', 'owner_type') }, + { labelKey: 'resource-summary.facts.icon', label: 'Icon', value: first(category, 'icon') }, + { labelKey: 'resource-summary.facts.colour', label: 'Colour', value: first(category, 'icon_color') }, + { labelKey: 'resource-summary.facts.description', label: 'Description', value: first(category, 'description') }, + ], + }, + ]; +} + +export default buildCoreResourceDescriptors; diff --git a/app/instance-initializers/register-core-resource-descriptors.js b/app/instance-initializers/register-core-resource-descriptors.js new file mode 100644 index 00000000..2875237d --- /dev/null +++ b/app/instance-initializers/register-core-resource-descriptors.js @@ -0,0 +1 @@ +export { default, initialize } from '@fleetbase/ember-ui/instance-initializers/register-core-resource-descriptors'; diff --git a/app/utils/resource-descriptors/core.js b/app/utils/resource-descriptors/core.js new file mode 100644 index 00000000..2499806d --- /dev/null +++ b/app/utils/resource-descriptors/core.js @@ -0,0 +1,2 @@ +export * from '@fleetbase/ember-ui/utils/resource-descriptors/core'; +export { default } from '@fleetbase/ember-ui/utils/resource-descriptors/core'; From 7f5af2e984f267a3069903db27749ba8237d4474 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 15 Sep 2026 22:05:16 +0800 Subject: [PATCH 17/40] Add the user resource family user/pill, user/summary, select-option/user and table/cell/user-identity, as wrappers over the resource identity bases for the user descriptor. --- addon/components/select-option/user.hbs | 12 +++++++ addon/components/table/cell/user-identity.hbs | 1 + addon/components/user/pill.hbs | 21 ++++++++---- addon/components/user/pill.js | 33 +++++++++++++++++++ addon/components/user/summary.hbs | 1 + app/components/select-option/user.js | 1 + app/components/table/cell/user-identity.js | 1 + app/components/user/summary.js | 1 + 8 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 addon/components/select-option/user.hbs create mode 100644 addon/components/table/cell/user-identity.hbs create mode 100644 addon/components/user/summary.hbs create mode 100644 app/components/select-option/user.js create mode 100644 app/components/table/cell/user-identity.js create mode 100644 app/components/user/summary.js diff --git a/addon/components/select-option/user.hbs b/addon/components/select-option/user.hbs new file mode 100644 index 00000000..ac91341a --- /dev/null +++ b/addon/components/select-option/user.hbs @@ -0,0 +1,12 @@ + diff --git a/addon/components/table/cell/user-identity.hbs b/addon/components/table/cell/user-identity.hbs new file mode 100644 index 00000000..b8652d56 --- /dev/null +++ b/addon/components/table/cell/user-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/user/pill.hbs b/addon/components/user/pill.hbs index 9d2504f0..ee117b27 100644 --- a/addon/components/user/pill.hbs +++ b/addon/components/user/pill.hbs @@ -1,16 +1,25 @@ - + as |resource| +> + {{yield resource}} + diff --git a/addon/components/user/pill.js b/addon/components/user/pill.js index 9855c1b5..ca5ce7ff 100644 --- a/addon/components/user/pill.js +++ b/addon/components/user/pill.js @@ -1,7 +1,40 @@ import Component from '@glimmer/component'; +import { get } from '@ember/object'; +/** + * The user family pill. Keeps the historic `@user` argument and "No user" / + * email defaults, and is otherwise a `Resource::Pill` for the `user` + * descriptor: it opens the user when an engine has registered an opener and + * shows the user's summary on hover. + */ export default class UserPillComponent extends Component { get resource() { return this.args.user ?? this.args.resource; } + + get subtitle() { + if (this.args.subtitle !== undefined) { + return this.args.subtitle; + } + + const email = get(this.resource ?? {}, 'email'); + + return email ?? (this.resource ? 'No email' : '-'); + } + + get showOnlineIndicator() { + if (this.args.showOnlineIndicator !== undefined) { + return Boolean(this.args.showOnlineIndicator); + } + + return typeof this.args.onlinePath === 'string'; + } + + get online() { + if (typeof this.args.onlinePath === 'string' && this.resource) { + return Boolean(get(this.resource, this.args.onlinePath)); + } + + return undefined; + } } diff --git a/addon/components/user/summary.hbs b/addon/components/user/summary.hbs new file mode 100644 index 00000000..438edbf0 --- /dev/null +++ b/addon/components/user/summary.hbs @@ -0,0 +1 @@ + diff --git a/app/components/select-option/user.js b/app/components/select-option/user.js new file mode 100644 index 00000000..e5390dde --- /dev/null +++ b/app/components/select-option/user.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/select-option/user'; diff --git a/app/components/table/cell/user-identity.js b/app/components/table/cell/user-identity.js new file mode 100644 index 00000000..d3acc496 --- /dev/null +++ b/app/components/table/cell/user-identity.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/table/cell/user-identity'; diff --git a/app/components/user/summary.js b/app/components/user/summary.js new file mode 100644 index 00000000..3987ec6f --- /dev/null +++ b/app/components/user/summary.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/user/summary'; From 2b6046885df6ea0b36b6258814a5df0a5a0aeea0 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 15 Sep 2026 22:05:16 +0800 Subject: [PATCH 18/40] Add the company resource family company/pill, company/summary, select-option/company and table/cell/company-identity, as wrappers over the resource identity bases for the company descriptor. --- addon/components/company/pill.hbs | 24 +++++++++++++++++++ addon/components/company/summary.hbs | 1 + addon/components/select-option/company.hbs | 12 ++++++++++ .../table/cell/company-identity.hbs | 1 + app/components/company/pill.js | 1 + app/components/company/summary.js | 1 + app/components/select-option/company.js | 1 + app/components/table/cell/company-identity.js | 1 + 8 files changed, 42 insertions(+) create mode 100644 addon/components/company/pill.hbs create mode 100644 addon/components/company/summary.hbs create mode 100644 addon/components/select-option/company.hbs create mode 100644 addon/components/table/cell/company-identity.hbs create mode 100644 app/components/company/pill.js create mode 100644 app/components/company/summary.js create mode 100644 app/components/select-option/company.js create mode 100644 app/components/table/cell/company-identity.js diff --git a/addon/components/company/pill.hbs b/addon/components/company/pill.hbs new file mode 100644 index 00000000..aec54a6d --- /dev/null +++ b/addon/components/company/pill.hbs @@ -0,0 +1,24 @@ + + {{yield resource}} + diff --git a/addon/components/company/summary.hbs b/addon/components/company/summary.hbs new file mode 100644 index 00000000..ce995069 --- /dev/null +++ b/addon/components/company/summary.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/select-option/company.hbs b/addon/components/select-option/company.hbs new file mode 100644 index 00000000..9bb9c1b8 --- /dev/null +++ b/addon/components/select-option/company.hbs @@ -0,0 +1,12 @@ + diff --git a/addon/components/table/cell/company-identity.hbs b/addon/components/table/cell/company-identity.hbs new file mode 100644 index 00000000..95b73c99 --- /dev/null +++ b/addon/components/table/cell/company-identity.hbs @@ -0,0 +1 @@ + diff --git a/app/components/company/pill.js b/app/components/company/pill.js new file mode 100644 index 00000000..faf8aa49 --- /dev/null +++ b/app/components/company/pill.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/company/pill'; diff --git a/app/components/company/summary.js b/app/components/company/summary.js new file mode 100644 index 00000000..6d11ed72 --- /dev/null +++ b/app/components/company/summary.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/company/summary'; diff --git a/app/components/select-option/company.js b/app/components/select-option/company.js new file mode 100644 index 00000000..a363e7be --- /dev/null +++ b/app/components/select-option/company.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/select-option/company'; diff --git a/app/components/table/cell/company-identity.js b/app/components/table/cell/company-identity.js new file mode 100644 index 00000000..28495777 --- /dev/null +++ b/app/components/table/cell/company-identity.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/table/cell/company-identity'; From 1ddedd924a83347f90a7abb493d17bbea29d7ab7 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 15 Sep 2026 22:05:16 +0800 Subject: [PATCH 19/40] Add the group resource family group/pill, group/summary, select-option/group and table/cell/group-identity, as wrappers over the resource identity bases for the group descriptor. --- addon/components/group/pill.hbs | 24 +++++++++++++++++++ addon/components/group/summary.hbs | 1 + addon/components/select-option/group.hbs | 12 ++++++++++ .../components/table/cell/group-identity.hbs | 1 + app/components/group/pill.js | 1 + app/components/group/summary.js | 1 + app/components/select-option/group.js | 1 + app/components/table/cell/group-identity.js | 1 + 8 files changed, 42 insertions(+) create mode 100644 addon/components/group/pill.hbs create mode 100644 addon/components/group/summary.hbs create mode 100644 addon/components/select-option/group.hbs create mode 100644 addon/components/table/cell/group-identity.hbs create mode 100644 app/components/group/pill.js create mode 100644 app/components/group/summary.js create mode 100644 app/components/select-option/group.js create mode 100644 app/components/table/cell/group-identity.js diff --git a/addon/components/group/pill.hbs b/addon/components/group/pill.hbs new file mode 100644 index 00000000..99e28034 --- /dev/null +++ b/addon/components/group/pill.hbs @@ -0,0 +1,24 @@ + + {{yield resource}} + diff --git a/addon/components/group/summary.hbs b/addon/components/group/summary.hbs new file mode 100644 index 00000000..dc48e874 --- /dev/null +++ b/addon/components/group/summary.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/select-option/group.hbs b/addon/components/select-option/group.hbs new file mode 100644 index 00000000..ba7fd12e --- /dev/null +++ b/addon/components/select-option/group.hbs @@ -0,0 +1,12 @@ + diff --git a/addon/components/table/cell/group-identity.hbs b/addon/components/table/cell/group-identity.hbs new file mode 100644 index 00000000..86440989 --- /dev/null +++ b/addon/components/table/cell/group-identity.hbs @@ -0,0 +1 @@ + diff --git a/app/components/group/pill.js b/app/components/group/pill.js new file mode 100644 index 00000000..e90ad89f --- /dev/null +++ b/app/components/group/pill.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/group/pill'; diff --git a/app/components/group/summary.js b/app/components/group/summary.js new file mode 100644 index 00000000..3819ecd5 --- /dev/null +++ b/app/components/group/summary.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/group/summary'; diff --git a/app/components/select-option/group.js b/app/components/select-option/group.js new file mode 100644 index 00000000..7eb2ff19 --- /dev/null +++ b/app/components/select-option/group.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/select-option/group'; diff --git a/app/components/table/cell/group-identity.js b/app/components/table/cell/group-identity.js new file mode 100644 index 00000000..de89aec9 --- /dev/null +++ b/app/components/table/cell/group-identity.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/table/cell/group-identity'; From 6837d1818b85d98f61d0bdfae398c26b5705b3f6 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 15 Sep 2026 22:05:16 +0800 Subject: [PATCH 20/40] Add the role resource family role/pill, role/summary, select-option/role and table/cell/role-identity, as wrappers over the resource identity bases for the role descriptor. --- addon/components/role/pill.hbs | 24 +++++++++++++++++++ addon/components/role/summary.hbs | 1 + addon/components/select-option/role.hbs | 12 ++++++++++ addon/components/table/cell/role-identity.hbs | 1 + app/components/role/pill.js | 1 + app/components/role/summary.js | 1 + app/components/select-option/role.js | 1 + app/components/table/cell/role-identity.js | 1 + 8 files changed, 42 insertions(+) create mode 100644 addon/components/role/pill.hbs create mode 100644 addon/components/role/summary.hbs create mode 100644 addon/components/select-option/role.hbs create mode 100644 addon/components/table/cell/role-identity.hbs create mode 100644 app/components/role/pill.js create mode 100644 app/components/role/summary.js create mode 100644 app/components/select-option/role.js create mode 100644 app/components/table/cell/role-identity.js diff --git a/addon/components/role/pill.hbs b/addon/components/role/pill.hbs new file mode 100644 index 00000000..b418d679 --- /dev/null +++ b/addon/components/role/pill.hbs @@ -0,0 +1,24 @@ + + {{yield resource}} + diff --git a/addon/components/role/summary.hbs b/addon/components/role/summary.hbs new file mode 100644 index 00000000..66da3941 --- /dev/null +++ b/addon/components/role/summary.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/select-option/role.hbs b/addon/components/select-option/role.hbs new file mode 100644 index 00000000..2622e9fb --- /dev/null +++ b/addon/components/select-option/role.hbs @@ -0,0 +1,12 @@ + diff --git a/addon/components/table/cell/role-identity.hbs b/addon/components/table/cell/role-identity.hbs new file mode 100644 index 00000000..2c3db94f --- /dev/null +++ b/addon/components/table/cell/role-identity.hbs @@ -0,0 +1 @@ + diff --git a/app/components/role/pill.js b/app/components/role/pill.js new file mode 100644 index 00000000..68382407 --- /dev/null +++ b/app/components/role/pill.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/role/pill'; diff --git a/app/components/role/summary.js b/app/components/role/summary.js new file mode 100644 index 00000000..858bc5c7 --- /dev/null +++ b/app/components/role/summary.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/role/summary'; diff --git a/app/components/select-option/role.js b/app/components/select-option/role.js new file mode 100644 index 00000000..e71c2c07 --- /dev/null +++ b/app/components/select-option/role.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/select-option/role'; diff --git a/app/components/table/cell/role-identity.js b/app/components/table/cell/role-identity.js new file mode 100644 index 00000000..dc127182 --- /dev/null +++ b/app/components/table/cell/role-identity.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/table/cell/role-identity'; From 9f6e7da509812199d27ab17ceb6288f1c580385a Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 15 Sep 2026 22:05:16 +0800 Subject: [PATCH 21/40] Add the file resource family file/pill, file/summary, select-option/file and table/cell/file-identity, as wrappers over the resource identity bases for the file descriptor. --- addon/components/file/pill.hbs | 24 +++++++++++++++++++ addon/components/file/summary.hbs | 1 + addon/components/select-option/file.hbs | 12 ++++++++++ addon/components/table/cell/file-identity.hbs | 1 + app/components/file/pill.js | 1 + app/components/file/summary.js | 1 + app/components/select-option/file.js | 1 + app/components/table/cell/file-identity.js | 1 + 8 files changed, 42 insertions(+) create mode 100644 addon/components/file/pill.hbs create mode 100644 addon/components/file/summary.hbs create mode 100644 addon/components/select-option/file.hbs create mode 100644 addon/components/table/cell/file-identity.hbs create mode 100644 app/components/file/pill.js create mode 100644 app/components/file/summary.js create mode 100644 app/components/select-option/file.js create mode 100644 app/components/table/cell/file-identity.js diff --git a/addon/components/file/pill.hbs b/addon/components/file/pill.hbs new file mode 100644 index 00000000..defe8f74 --- /dev/null +++ b/addon/components/file/pill.hbs @@ -0,0 +1,24 @@ + + {{yield resource}} + diff --git a/addon/components/file/summary.hbs b/addon/components/file/summary.hbs new file mode 100644 index 00000000..ce7371d3 --- /dev/null +++ b/addon/components/file/summary.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/select-option/file.hbs b/addon/components/select-option/file.hbs new file mode 100644 index 00000000..f5e79765 --- /dev/null +++ b/addon/components/select-option/file.hbs @@ -0,0 +1,12 @@ + diff --git a/addon/components/table/cell/file-identity.hbs b/addon/components/table/cell/file-identity.hbs new file mode 100644 index 00000000..92833e34 --- /dev/null +++ b/addon/components/table/cell/file-identity.hbs @@ -0,0 +1 @@ + diff --git a/app/components/file/pill.js b/app/components/file/pill.js new file mode 100644 index 00000000..e07b1008 --- /dev/null +++ b/app/components/file/pill.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/file/pill'; diff --git a/app/components/file/summary.js b/app/components/file/summary.js new file mode 100644 index 00000000..dc742a07 --- /dev/null +++ b/app/components/file/summary.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/file/summary'; diff --git a/app/components/select-option/file.js b/app/components/select-option/file.js new file mode 100644 index 00000000..e9de922f --- /dev/null +++ b/app/components/select-option/file.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/select-option/file'; diff --git a/app/components/table/cell/file-identity.js b/app/components/table/cell/file-identity.js new file mode 100644 index 00000000..455bbb9c --- /dev/null +++ b/app/components/table/cell/file-identity.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/table/cell/file-identity'; From 8998344463a2bc819e653616ba65ef57a47d9334 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 15 Sep 2026 22:05:17 +0800 Subject: [PATCH 22/40] Add the category resource family category/pill, category/summary, select-option/category and table/cell/category-identity, as wrappers over the resource identity bases for the category descriptor. --- addon/components/category/pill.hbs | 24 +++++++++++++++++++ addon/components/category/summary.hbs | 1 + addon/components/select-option/category.hbs | 12 ++++++++++ .../table/cell/category-identity.hbs | 1 + app/components/category/pill.js | 1 + app/components/category/summary.js | 1 + app/components/select-option/category.js | 1 + .../table/cell/category-identity.js | 1 + 8 files changed, 42 insertions(+) create mode 100644 addon/components/category/pill.hbs create mode 100644 addon/components/category/summary.hbs create mode 100644 addon/components/select-option/category.hbs create mode 100644 addon/components/table/cell/category-identity.hbs create mode 100644 app/components/category/pill.js create mode 100644 app/components/category/summary.js create mode 100644 app/components/select-option/category.js create mode 100644 app/components/table/cell/category-identity.js diff --git a/addon/components/category/pill.hbs b/addon/components/category/pill.hbs new file mode 100644 index 00000000..66bcd2b1 --- /dev/null +++ b/addon/components/category/pill.hbs @@ -0,0 +1,24 @@ + + {{yield resource}} + diff --git a/addon/components/category/summary.hbs b/addon/components/category/summary.hbs new file mode 100644 index 00000000..0f4e39a3 --- /dev/null +++ b/addon/components/category/summary.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/select-option/category.hbs b/addon/components/select-option/category.hbs new file mode 100644 index 00000000..d7a5642b --- /dev/null +++ b/addon/components/select-option/category.hbs @@ -0,0 +1,12 @@ + diff --git a/addon/components/table/cell/category-identity.hbs b/addon/components/table/cell/category-identity.hbs new file mode 100644 index 00000000..6a94a6fb --- /dev/null +++ b/addon/components/table/cell/category-identity.hbs @@ -0,0 +1 @@ + diff --git a/app/components/category/pill.js b/app/components/category/pill.js new file mode 100644 index 00000000..d02cd159 --- /dev/null +++ b/app/components/category/pill.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/category/pill'; diff --git a/app/components/category/summary.js b/app/components/category/summary.js new file mode 100644 index 00000000..bf25ab66 --- /dev/null +++ b/app/components/category/summary.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/category/summary'; diff --git a/app/components/select-option/category.js b/app/components/select-option/category.js new file mode 100644 index 00000000..b8635dcc --- /dev/null +++ b/app/components/select-option/category.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/select-option/category'; diff --git a/app/components/table/cell/category-identity.js b/app/components/table/cell/category-identity.js new file mode 100644 index 00000000..05a23f3e --- /dev/null +++ b/app/components/table/cell/category-identity.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/table/cell/category-identity'; From eaba9ac45b375e7fab4cfeeb4fadae02c23896b7 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 15 Sep 2026 22:07:20 +0800 Subject: [PATCH 23/40] Hide a fact pill's subtitle by its own class Tailwind refuses a selector on a utility class inside an @apply file, so give the pill subtitle a resource-pill-subtitle class and target that. --- addon/components/resource/pill.hbs | 2 +- addon/styles/components/resource-identity.css | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/addon/components/resource/pill.hbs b/addon/components/resource/pill.hbs index e1a13655..3cb15517 100644 --- a/addon/components/resource/pill.hbs +++ b/addon/components/resource/pill.hbs @@ -43,7 +43,7 @@ {{else}}
{{this.title}}
{{#if this.subtitle}} -
{{this.subtitle}}
+
{{this.subtitle}}
{{/if}} {{/if}} {{#if this.showPopover}} diff --git a/addon/styles/components/resource-identity.css b/addon/styles/components/resource-identity.css index 109cf080..38f2e477 100644 --- a/addon/styles/components/resource-identity.css +++ b/addon/styles/components/resource-identity.css @@ -106,7 +106,7 @@ body[data-theme='dark'] .resource-hover-card .resource-hover-card-inner { height: 1.25rem; } -.resource-summary-fact > dd .fleetbase-pill .text-xs { +.resource-summary-fact > dd .fleetbase-pill .resource-pill-subtitle { display: none; } From 2f52b2621c14baa42902386a4de1e7a0d84392f0 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 15 Sep 2026 22:10:36 +0800 Subject: [PATCH 24/40] Invoke has-block explicitly when passing it as an argument Glimmer refuses a bare helper as a named argument, so the family pills pass @useBlock={{(has-block)}}. User::Pill also leaves the online dot to the descriptor unless an @onlinePath is given. --- addon/components/category/pill.hbs | 2 +- addon/components/company/pill.hbs | 2 +- addon/components/file/pill.hbs | 2 +- addon/components/group/pill.hbs | 2 +- addon/components/role/pill.hbs | 2 +- addon/components/user/pill.hbs | 2 +- addon/components/user/pill.js | 3 ++- 7 files changed, 8 insertions(+), 7 deletions(-) diff --git a/addon/components/category/pill.hbs b/addon/components/category/pill.hbs index 66bcd2b1..f5dae44a 100644 --- a/addon/components/category/pill.hbs +++ b/addon/components/category/pill.hbs @@ -16,7 +16,7 @@ @contentWrapperClass={{@contentWrapperClass}} @titleClass={{@titleClass}} @subtitleClass={{@subtitleClass}} - @useBlock={{has-block}} + @useBlock={{(has-block)}} ...attributes as |resource| > diff --git a/addon/components/company/pill.hbs b/addon/components/company/pill.hbs index aec54a6d..dd2b815b 100644 --- a/addon/components/company/pill.hbs +++ b/addon/components/company/pill.hbs @@ -16,7 +16,7 @@ @contentWrapperClass={{@contentWrapperClass}} @titleClass={{@titleClass}} @subtitleClass={{@subtitleClass}} - @useBlock={{has-block}} + @useBlock={{(has-block)}} ...attributes as |resource| > diff --git a/addon/components/file/pill.hbs b/addon/components/file/pill.hbs index defe8f74..9e2bd7d2 100644 --- a/addon/components/file/pill.hbs +++ b/addon/components/file/pill.hbs @@ -16,7 +16,7 @@ @contentWrapperClass={{@contentWrapperClass}} @titleClass={{@titleClass}} @subtitleClass={{@subtitleClass}} - @useBlock={{has-block}} + @useBlock={{(has-block)}} ...attributes as |resource| > diff --git a/addon/components/group/pill.hbs b/addon/components/group/pill.hbs index 99e28034..3d154705 100644 --- a/addon/components/group/pill.hbs +++ b/addon/components/group/pill.hbs @@ -16,7 +16,7 @@ @contentWrapperClass={{@contentWrapperClass}} @titleClass={{@titleClass}} @subtitleClass={{@subtitleClass}} - @useBlock={{has-block}} + @useBlock={{(has-block)}} ...attributes as |resource| > diff --git a/addon/components/role/pill.hbs b/addon/components/role/pill.hbs index b418d679..1f68d198 100644 --- a/addon/components/role/pill.hbs +++ b/addon/components/role/pill.hbs @@ -16,7 +16,7 @@ @contentWrapperClass={{@contentWrapperClass}} @titleClass={{@titleClass}} @subtitleClass={{@subtitleClass}} - @useBlock={{has-block}} + @useBlock={{(has-block)}} ...attributes as |resource| > diff --git a/addon/components/user/pill.hbs b/addon/components/user/pill.hbs index ee117b27..bb544832 100644 --- a/addon/components/user/pill.hbs +++ b/addon/components/user/pill.hbs @@ -17,7 +17,7 @@ @contentWrapperClass={{@contentWrapperClass}} @titleClass={{@titleClass}} @subtitleClass={{@subtitleClass}} - @useBlock={{has-block}} + @useBlock={{(has-block)}} ...attributes as |resource| > diff --git a/addon/components/user/pill.js b/addon/components/user/pill.js index ca5ce7ff..f0b3ec0e 100644 --- a/addon/components/user/pill.js +++ b/addon/components/user/pill.js @@ -27,7 +27,8 @@ export default class UserPillComponent extends Component { return Boolean(this.args.showOnlineIndicator); } - return typeof this.args.onlinePath === 'string'; + // With an explicit path the dot is wanted; otherwise the descriptor decides. + return typeof this.args.onlinePath === 'string' ? true : undefined; } get online() { From d852e9a96ee4090a0bcf4a7c41712bfe18d62afa Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 15 Sep 2026 22:18:24 +0800 Subject: [PATCH 25/40] Let a record's own type win over the wrapper's resource type The family wrappers pass @resourceType, but a polymorphic field holds a record whose model name already says what it is: a customer-vendor is a vendor, a facilitator-driver is a driver. Resolve the record first and use the wrapper's type only as a fallback for records that cannot be resolved on their own, so cell/customer-identity opens the vendor panel for a customer-vendor and the correct summary appears. canOpenResource takes the same fallback. --- addon/components/resource/hover-card.js | 2 +- addon/components/resource/pill.hbs | 2 +- addon/components/resource/pill.js | 13 +++++++++---- addon/components/resource/select-option.js | 4 ++-- addon/components/resource/summary.js | 10 +++++++--- addon/components/table/cell/identity.hbs | 4 ++-- addon/components/table/cell/identity.js | 9 +++++---- addon/utils/resource-registry.js | 6 ++++-- 8 files changed, 31 insertions(+), 19 deletions(-) diff --git a/addon/components/resource/hover-card.js b/addon/components/resource/hover-card.js index 827d8b67..9f4963fa 100644 --- a/addon/components/resource/hover-card.js +++ b/addon/components/resource/hover-card.js @@ -47,7 +47,7 @@ export default class ResourceHoverCardComponent extends Component { } get summaryComponent() { - return this.args.summaryComponent ?? resourceComponentName(getOwner(this), 'summary', this.args.resourceType ?? this.args.resource); + return this.args.summaryComponent ?? resourceComponentName(getOwner(this), 'summary', this.args.resource) ?? resourceComponentName(getOwner(this), 'summary', this.args.resourceType); } get isTouchDevice() { diff --git a/addon/components/resource/pill.hbs b/addon/components/resource/pill.hbs index 3cb15517..1c08f8db 100644 --- a/addon/components/resource/pill.hbs +++ b/addon/components/resource/pill.hbs @@ -47,7 +47,7 @@ {{/if}} {{/if}} {{#if this.showPopover}} - + {{/if}}
diff --git a/addon/components/resource/pill.js b/addon/components/resource/pill.js index 2b9e935f..4288fd7c 100644 --- a/addon/components/resource/pill.js +++ b/addon/components/resource/pill.js @@ -20,12 +20,17 @@ export default class ResourcePillComponent extends Component { return unwrapRecord(this.args.resource); } + /** + * The record's own type wins; `@resourceType` names the type of a record + * that does not resolve by itself (a POJO, or a polymorphic base whose + * concrete subtype is unknown here). + */ get key() { - return resolveResourceKey(this.owner, this.args.resourceType ?? this.record); + return resolveResourceKey(this.owner, this.record) ?? resolveResourceKey(this.owner, this.args.resourceType); } get descriptor() { - return getResourceDescriptor(this.owner, this.args.resourceType ?? this.record); + return getResourceDescriptor(this.owner, this.key); } get title() { @@ -70,7 +75,7 @@ export default class ResourcePillComponent extends Component { } get canOpen() { - return Boolean(this.record) && canOpenResource(this.owner, this.args.resourceType ?? this.record); + return Boolean(this.record) && canOpenResource(this.owner, this.record, { resourceType: this.args.resourceType }); } get clickHandler() { @@ -86,7 +91,7 @@ export default class ResourcePillComponent extends Component { } get showPopover() { - return !this.args.noPopover && Boolean(this.record) && Boolean(resourceComponentName(this.owner, 'summary', this.args.resourceType ?? this.record)); + return !this.args.noPopover && Boolean(this.record) && Boolean(resourceComponentName(this.owner, 'summary', this.key)); } @action open(resource, event) { diff --git a/addon/components/resource/select-option.js b/addon/components/resource/select-option.js index 8e4006e7..347c3fe3 100644 --- a/addon/components/resource/select-option.js +++ b/addon/components/resource/select-option.js @@ -1,6 +1,6 @@ import Component from '@glimmer/component'; import { getOwner } from '@ember/application'; -import { getResourceDescriptor } from '../../utils/resource-registry'; +import { getResourceDescriptor, resolveResourceKey } from '../../utils/resource-registry'; import { unwrapRecord, resourceTitle, resourceImage, resourceSelectDetails } from '../../utils/resource-identity'; /** @@ -21,7 +21,7 @@ export default class ResourceSelectOptionComponent extends Component { } get descriptor() { - return getResourceDescriptor(this.owner, this.args.resourceType ?? this.record); + return getResourceDescriptor(this.owner, resolveResourceKey(this.owner, this.record) ?? this.args.resourceType); } get title() { diff --git a/addon/components/resource/summary.js b/addon/components/resource/summary.js index fcc6ed1e..8930e572 100644 --- a/addon/components/resource/summary.js +++ b/addon/components/resource/summary.js @@ -3,7 +3,7 @@ import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; import { getOwner } from '@ember/application'; import { task } from 'ember-concurrency'; -import { getResourceDescriptor, canOpenResource, openResource, readDescriptor } from '../../utils/resource-registry'; +import { getResourceDescriptor, resolveResourceKey, canOpenResource, openResource, readDescriptor } from '../../utils/resource-registry'; import { unwrapRecord, resourceTitle, resourceIdentifier, resourceImage, resourceOnline, resourceStatus, resourceFacts, makeTranslator } from '../../utils/resource-identity'; /** @@ -28,8 +28,12 @@ export default class ResourceSummaryComponent extends Component { return this.hydrated ?? unwrapRecord(this.args.resource); } + get key() { + return resolveResourceKey(this.owner, this.record) ?? resolveResourceKey(this.owner, this.args.resourceType); + } + get descriptor() { - return getResourceDescriptor(this.owner, this.args.resourceType ?? this.record ?? this.args.resource); + return getResourceDescriptor(this.owner, this.key); } get title() { @@ -69,7 +73,7 @@ export default class ResourceSummaryComponent extends Component { } get canOpen() { - return canOpenResource(this.owner, this.args.resourceType ?? this.record); + return canOpenResource(this.owner, this.record, { resourceType: this.args.resourceType }); } get showView() { diff --git a/addon/components/table/cell/identity.hbs b/addon/components/table/cell/identity.hbs index 95a68a05..687bf308 100644 --- a/addon/components/table/cell/identity.hbs +++ b/addon/components/table/cell/identity.hbs @@ -11,7 +11,7 @@ > Date: Tue, 15 Sep 2026 22:36:56 +0800 Subject: [PATCH 26/40] Size the model-multiple filter trigger for chips Port the trigger and chip sizing FleetOps had for its multi-model filter: the small form-input height clipped the multiple trigger into a scroller before anything was selected. --- addon/styles/layout/next.css | 41 ++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/addon/styles/layout/next.css b/addon/styles/layout/next.css index 239a35ef..56761871 100644 --- a/addon/styles/layout/next.css +++ b/addon/styles/layout/next.css @@ -2689,7 +2689,7 @@ a.text-danger:hover { } .filter-model-multiple > .clear-button { - @apply dark:text-gray-500 text-gray-700 pl-2; + @apply absolute top-0 right-0 dark:text-gray-500 text-gray-700 pr-3 pt-2; } .filter-model-multiple > .clear-button:hover { @@ -2697,7 +2697,44 @@ a.text-danger:hover { } .filter-model-multiple > .clear-button:disabled { - @apply opacity-30; + @apply opacity-30 cursor-default; +} + +.filter-model-multiple > .fleetbase-model-select, +.filter-model-multiple > .fleetbase-model-select > .ember-basic-dropdown, +.filter-model-multiple .ember-power-select-multiple-trigger { + flex: 1 1 auto; + width: 100%; + min-width: 0; +} + +/* The small form-input height clips the multiple trigger and turns it into a scroller + before anything is selected; let it start at the input height and grow with chips. */ +.filter-model-multiple .ember-power-select-multiple-trigger { + display: flex; + align-items: center; + height: auto !important; + min-height: 2.125rem; + max-height: none !important; + overflow: visible !important; + padding-top: 0.125rem; + padding-bottom: 0.125rem; + padding-right: 2.5rem; +} + +.filter-model-multiple .ember-power-select-multiple-options { + flex: 1 1 auto; + min-width: 0; + gap: 0.125rem; +} + +.filter-model-multiple .ember-power-select-multiple-option { + margin: 0.125rem 0.125rem 0.125rem 0; +} + +.filter-model-multiple .ember-power-select-trigger-multiple-input { + height: 1.625rem; + min-width: 4rem; } .filter-range { From fec6e6ad0837ebed4041db77dd7c318e5af9b60c Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 15 Sep 2026 22:38:45 +0800 Subject: [PATCH 27/40] Prepare the v0.4.1 release --- RELEASE.md | 11 ++++++++--- package.json | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index bcde344d..92188238 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,10 +1,15 @@ -> v0.4.0 ~ "Tab navigation overflow fix" +> v0.4.1 ~ "Resource identity: pills, one-line identity cells, hover summaries and select options" --- ## Highlights -- Route-backed tabs without explicit IDs remain accessible through the TabNavigation More menu when the tab bar overflows (#171). -- Tab identity falls back from `id` to `route` to `key` for active state, overflow calculations, and keyboard navigation. +- **Resource descriptor registry** — `utils/resource-registry` stores one descriptor per kind of record (title, identifier, image, status, badges, facts, how it opens) in the shared `universe/registry-service`, so the host and every engine read one set. `resolveResourceKey` accepts a record, an identity stub, a model name or alias, a `prefix:key` string, a PHP class name or a polymorphic type; `openResource` unwraps proxies and stubs, swaps a subtype for its canonical record and never throws. The `resource-type`, `resource-component` and `resource-relation` helpers and the `resource-registry` service expose it to templates and engines. +- **Resource::Pill, Resource::Summary, Resource::HoverCard** — a descriptor-driven pill that opens its record and shows a compact summary card on hover (photo or icon, name and identifier, status and online state, a handful of facts and one View action). The card is lazy: nothing renders until the pointer has rested on the target, and it hydrates only then. +- **Table::Cell::Identity** — a one-line identity cell: a small image with the status dot, the name and up to two inline badges, no status badge. It opens the record on click, disables on `column.permission`, and never fires the row action as well. +- **Resource::SelectOption** — the option row for record pickers, with a compact variant for the closed trigger. `filter/model` renders options through it and the new `filter/model-multiple` filter selects several records. +- **Core resource families** — pills, summaries, select options and identity cells for user, company, group, role, file and category, registered by an instance-initializer in the host. +- **Fixes** — `Attach::Popover` applies `@class`, keeps an interactive popover enterable while shown, follows later changes to `@isShown` and removes its listeners on destroy; `Attach::Tooltip` forwards `hideDelay`, `hideDuration`, `@class`, `@style`, `floatingTarget`, `floatingContainer` and `onChange`; `Pill` renders a static span when it has nothing to do and hands its tooltip component the resource; `MultiSelect` forwards `@selectedItemComponent`, `@searchField` and `@searchPlaceholder`; `table/cell/resource-identity` no longer renders an empty meta row. +- **Dummy app** — `@fleetbase/ember-core` is declared as a dependency so the components that import it can render in tests. --- ## Need help? diff --git a/package.json b/package.json index 3275ada2..071eabdb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fleetbase/ember-ui", - "version": "0.4.0", + "version": "0.4.1", "description": "Fleetbase UI provides all the interface components, helpers, services and utilities for building a Fleetbase extension into the Console.", "keywords": [ "fleetbase-ui", From b3ceb80bd661ae9d80ae4c4e80a462be12078a88 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 16 Sep 2026 06:41:54 +0800 Subject: [PATCH 28/40] Satisfy lint for the resource identity work The registry and descriptors read records and identity stubs by path, which is exactly what Ember's get is for; disable ember/no-get in those files with a note. Prettier formatting and no settled() right after a test helper. --- addon/components/table/cell/identity.js | 1 + addon/components/user/pill.js | 1 + addon/utils/resource-descriptors/core.js | 25 ++++++- addon/utils/resource-identity.js | 6 +- addon/utils/resource-registry.js | 1 + .../components/attach/popover-test.js | 5 +- .../components/filter/model-multiple-test.js | 7 +- .../components/multi-select-test.js | 3 +- .../components/resource/smoke-test.js | 74 +++++++++++++++++++ tests/unit/utils/resource-registry-test.js | 13 +++- 10 files changed, 120 insertions(+), 16 deletions(-) create mode 100644 tests/integration/components/resource/smoke-test.js diff --git a/addon/components/table/cell/identity.js b/addon/components/table/cell/identity.js index f99d5cae..aa4c28cb 100644 --- a/addon/components/table/cell/identity.js +++ b/addon/components/table/cell/identity.js @@ -1,3 +1,4 @@ +/* eslint-disable ember/no-get -- records and identity stubs are read by path, so `get` is the point */ import Component from '@glimmer/component'; import { action, get } from '@ember/object'; import { getOwner } from '@ember/application'; diff --git a/addon/components/user/pill.js b/addon/components/user/pill.js index f0b3ec0e..68813c0a 100644 --- a/addon/components/user/pill.js +++ b/addon/components/user/pill.js @@ -1,3 +1,4 @@ +/* eslint-disable ember/no-get -- records and identity stubs are read by path, so `get` is the point */ import Component from '@glimmer/component'; import { get } from '@ember/object'; diff --git a/addon/utils/resource-descriptors/core.js b/addon/utils/resource-descriptors/core.js index 8f7c2a79..180113c6 100644 --- a/addon/utils/resource-descriptors/core.js +++ b/addon/utils/resource-descriptors/core.js @@ -1,3 +1,4 @@ +/* eslint-disable ember/no-get -- records and identity stubs are read by path, so `get` is the point */ import { get } from '@ember/object'; import { relationValue, safeIdentifier } from '../resource-registry'; @@ -146,7 +147,13 @@ export function buildCoreResourceDescriptors() { }, selectDetails: (company) => [first(company, 'country'), first(company, 'timezone')], facts: (company) => [ - { labelKey: 'resource-summary.facts.owner', label: 'Owner', related: relationValue(company, 'owner'), relatedType: 'user', value: first(company, 'owner.name', 'owner_name') }, + { + labelKey: 'resource-summary.facts.owner', + label: 'Owner', + related: relationValue(company, 'owner'), + relatedType: 'user', + value: first(company, 'owner.name', 'owner_name'), + }, { labelKey: 'resource-summary.facts.members', label: 'Members', value: count(company, 'users', 'users_count') }, { labelKey: 'resource-summary.facts.country', label: 'Country', value: first(company, 'country') }, { labelKey: 'resource-summary.facts.timezone', label: 'Timezone', value: first(company, 'timezone') }, @@ -213,7 +220,13 @@ export function buildCoreResourceDescriptors() { facts: (file) => [ { labelKey: 'resource-summary.facts.type', label: 'Type', value: first(file, 'content_type', 'type') }, { labelKey: 'resource-summary.facts.size', label: 'Size', value: get(file, 'file_size'), format: 'bytes' }, - { labelKey: 'resource-summary.facts.uploader', label: 'Uploaded by', related: relationValue(file, 'uploader'), relatedType: 'user', value: first(file, 'uploader.name', 'uploader_name') }, + { + labelKey: 'resource-summary.facts.uploader', + label: 'Uploaded by', + related: relationValue(file, 'uploader'), + relatedType: 'user', + value: first(file, 'uploader.name', 'uploader_name'), + }, { labelKey: 'resource-summary.facts.created', label: 'Uploaded', value: get(file, 'created_at'), format: 'date' }, ], canOpen: (file) => present(get(file ?? {}, 'url')), @@ -254,7 +267,13 @@ export function buildCoreResourceDescriptors() { }, selectDetails: (category) => [first(category, 'for'), first(category, 'description')], facts: (category) => [ - { labelKey: 'resource-summary.facts.parent', label: 'Parent', related: relationValue(category, 'parent'), relatedType: 'category', value: first(category, 'parent.name', 'parent_name') }, + { + labelKey: 'resource-summary.facts.parent', + label: 'Parent', + related: relationValue(category, 'parent'), + relatedType: 'category', + value: first(category, 'parent.name', 'parent_name'), + }, { labelKey: 'resource-summary.facts.for', label: 'For', value: first(category, 'for', 'owner_type') }, { labelKey: 'resource-summary.facts.icon', label: 'Icon', value: first(category, 'icon') }, { labelKey: 'resource-summary.facts.colour', label: 'Colour', value: first(category, 'icon_color') }, diff --git a/addon/utils/resource-identity.js b/addon/utils/resource-identity.js index 0b7c1ca7..a6c6eae2 100644 --- a/addon/utils/resource-identity.js +++ b/addon/utils/resource-identity.js @@ -1,3 +1,4 @@ +/* eslint-disable ember/no-get -- records and identity stubs are read by path, so `get` is the point */ import { get } from '@ember/object'; import ObjectProxy from '@ember/object/proxy'; import { getResourceDescriptor, readDescriptor, safeIdentifier, resourceComponentName, relationValue } from './resource-registry'; @@ -188,7 +189,10 @@ export function resourceBadges(descriptor, record, context = {}) { const list = Array.isArray(badges) ? badges : []; const selfId = context.selfId ?? null; - return list.filter((badge) => badge && badge.label !== undefined && badge.label !== null && String(badge.label).trim() !== '' && !(selfId && badge.relatedId && String(badge.relatedId) === String(selfId))); + return list.filter( + (badge) => + badge && badge.label !== undefined && badge.label !== null && String(badge.label).trim() !== '' && !(selfId && badge.relatedId && String(badge.relatedId) === String(selfId)) + ); } export function formatFactValue(value, format) { diff --git a/addon/utils/resource-registry.js b/addon/utils/resource-registry.js index 227ec33f..fa9527d1 100644 --- a/addon/utils/resource-registry.js +++ b/addon/utils/resource-registry.js @@ -1,3 +1,4 @@ +/* eslint-disable ember/no-get -- records and identity stubs are read by path, so `get` is the point */ import { get } from '@ember/object'; import { dasherize } from '@ember/string'; import { isArray } from '@ember/array'; diff --git a/tests/integration/components/attach/popover-test.js b/tests/integration/components/attach/popover-test.js index 2b690ad2..558ad11c 100644 --- a/tests/integration/components/attach/popover-test.js +++ b/tests/integration/components/attach/popover-test.js @@ -61,7 +61,6 @@ module('Integration | Component | attach/popover', function (hooks) { // Leave the target and move onto the popover: it must stay open. await triggerEvent('#target', 'mouseleave'); await triggerEvent('#inside', 'mousemove'); - await settled(); assert.strictEqual(attacher().getAttribute('aria-hidden'), 'false', 'still shown while the pointer is over the popover'); // Move away from both: it hides, and pointer events go back to none. @@ -81,7 +80,9 @@ module('Integration | Component | attach/popover', function (hooks) { test('it hides on escape and follows later changes to @isShown', async function (assert) { this.set('shown', false); - await render(hbs``); + await render( + hbs`` + ); assert.notOk(attacher(), 'lazy popover renders nothing until it is shown'); diff --git a/tests/integration/components/filter/model-multiple-test.js b/tests/integration/components/filter/model-multiple-test.js index 78890b1b..92600be1 100644 --- a/tests/integration/components/filter/model-multiple-test.js +++ b/tests/integration/components/filter/model-multiple-test.js @@ -1,6 +1,6 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render, click, settled } from '@ember/test-helpers'; +import { render, click } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; import Service from '@ember/service'; import { A } from '@ember/array'; @@ -49,7 +49,6 @@ module('Integration | Component | filter/model-multiple', function (hooks) { this.set('filter', { model: 'person' }); await render(hbs``); - await settled(); const store = this.owner.lookup('service:store'); assert.deepEqual(store.peeked, ['a', 'b', 'gone']); @@ -63,17 +62,14 @@ module('Integration | Component | filter/model-multiple', function (hooks) { this.set('value', ['a', null]); await render(hbs``); - await settled(); assert.dom('.ember-power-select-multiple-option').exists({ count: 1 }); this.set('value', ''); await render(hbs``); - await settled(); assert.dom('.ember-power-select-multiple-option').doesNotExist(); this.set('filter', {}); await render(hbs``); - await settled(); assert.dom('.ember-power-select-multiple-option').doesNotExist('no model name means nothing to restore'); }); @@ -109,7 +105,6 @@ module('Integration | Component | filter/model-multiple', function (hooks) { this.set('onClear', (filter) => clears.push(filter)); await render(hbs``); - await settled(); await click('.ember-power-select-multiple-remove-btn'); assert.deepEqual(changes, []); diff --git a/tests/integration/components/multi-select-test.js b/tests/integration/components/multi-select-test.js index 57dfa51d..d3cc1d0a 100644 --- a/tests/integration/components/multi-select-test.js +++ b/tests/integration/components/multi-select-test.js @@ -1,6 +1,6 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render, fillIn, settled } from '@ember/test-helpers'; +import { render, fillIn } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; import { clickTrigger } from 'ember-power-select/test-support/helpers'; @@ -43,7 +43,6 @@ module('Integration | Component | multi-select', function (hooks) { assert.dom('.ember-power-select-search-input').hasAttribute('placeholder', 'Find a person'); await fillIn('.ember-power-select-search-input', 'bo'); - await settled(); assert.dom('.ember-power-select-option').exists({ count: 1 }); assert.dom('.ember-power-select-option').hasText('Bob'); }); diff --git a/tests/integration/components/resource/smoke-test.js b/tests/integration/components/resource/smoke-test.js new file mode 100644 index 00000000..950d1425 --- /dev/null +++ b/tests/integration/components/resource/smoke-test.js @@ -0,0 +1,74 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render, click, triggerEvent, settled, waitUntil } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { registerResourceDescriptors } from '@fleetbase/ember-ui/utils/resource-registry'; +import buildCoreResourceDescriptors from '@fleetbase/ember-ui/utils/resource-descriptors/core'; + +module('Integration | Component | resource identity smoke', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + registerResourceDescriptors(this.owner, buildCoreResourceDescriptors(this.owner)); + this.set('user', { resourceType: 'user', name: 'Ada Lovelace', email: 'ada@example.test', phone: '+1', is_online: true, status: 'active', role_name: 'Admin' }); + this.set('file', { resourceType: 'file', original_filename: 'report.pdf', content_type: 'application/pdf', file_size: 2048, url: 'https://example.test/report.pdf' }); + }); + + test('the user family renders a pill, a summary, a select option and an identity cell', async function (assert) { + await render(hbs` + + + + + + `); + + assert.dom('[data-test-resource-pill][data-resource-type="user"]').includesText('Ada Lovelace'); + assert.dom('[data-test-resource-pill][data-resource-type="user"]').includesText('ada@example.test'); + assert.dom('[data-test-resource-pill][data-resource-type="user"] [data-test-pill-online-indicator]').hasClass('text-green-500'); + assert.dom('[data-test-resource-pill][data-resource-type="user"] .fleetbase-pill').hasClass('fleetbase-pill--static', 'no opener registered, so static'); + assert.dom('[data-test-resource-summary-title]').hasText('Ada Lovelace'); + assert.dom('[data-test-resource-summary-fact]').exists(); + assert.dom('[data-test-resource-summary-view]').doesNotExist(); + assert.dom('[data-test-select-option]').hasClass('select-option--compact'); + assert.dom('[data-test-select-option-title]').hasText('Ada Lovelace'); + assert.dom('[data-test-identity-cell][data-test-identity-static]').exists(); + assert.dom('[data-test-identity-label]').hasText('Ada Lovelace'); + assert.dom('[data-test-resource-identity-meta-badge]').hasText('Admin'); + assert.dom('[data-test-resource-identity-status-dot]').hasClass('text-green-500'); + assert.dom('[data-test-resource-pill][data-resource-type="file"] a[href]').exists('a file with a url opens'); + assert.dom('[data-test-resource-pill][data-resource-type="file"] [data-test-resource-pill-icon]').exists('non-image files get an icon tile'); + }); + + test('hovering a pill arms and shows the summary card, which can be entered', async function (assert) { + await render(hbs``); + assert.dom('.resource-hover-card', document.body).doesNotExist('nothing rendered before hover'); + + await triggerEvent('.fleetbase-pill', 'mouseenter'); + await waitUntil(() => document.querySelector('.resource-hover-card')?.getAttribute('aria-hidden') === 'false', { timeout: 3000 }); + await settled(); + assert.dom('.resource-hover-card [data-test-resource-summary-title]', document.body).hasText('Ada Lovelace'); + + await triggerEvent('.fleetbase-pill', 'mouseleave'); + await triggerEvent('.resource-hover-card [data-test-resource-summary]', 'mousemove'); + + assert.strictEqual(document.querySelector('.resource-hover-card').getAttribute('aria-hidden'), 'false', 'stays open over the card'); + + await triggerEvent(document.body, 'mousemove'); + await waitUntil(() => !document.querySelector('.resource-hover-card'), { timeout: 3000 }); + assert.dom('.resource-hover-card', document.body).doesNotExist('torn down after leaving'); + }); + + test('an opener makes the pill and the cell clickable', async function (assert) { + const opened = []; + const registry = this.owner.lookup('service:resource-registry'); + registry.setOpener('user', (record) => opened.push(record) && true); + + await render(hbs``); + await click('[data-test-resource-pill] a'); + await click('[data-test-identity-button]'); + + assert.strictEqual(opened.length, 2); + assert.strictEqual(opened[0], this.user); + }); +}); diff --git a/tests/unit/utils/resource-registry-test.js b/tests/unit/utils/resource-registry-test.js index 3bae284f..812873c2 100644 --- a/tests/unit/utils/resource-registry-test.js +++ b/tests/unit/utils/resource-registry-test.js @@ -49,7 +49,12 @@ module('Unit | Utility | resource-registry', function (hooks) { test('it normalizes descriptors on registration and rejects junk', function (assert) { const [vehicle, driver] = registerResourceDescriptors(this.owner, [ - { key: 'vehicle', aliases: ['attachable-vehicle', ' '], modelNames: ['maintenance-subject-vehicle'], polymorphicTypes: ['fleet-ops:vehicle', 'Fleetbase\\FleetOps\\Models\\Vehicle'] }, + { + key: 'vehicle', + aliases: ['attachable-vehicle', ' '], + modelNames: ['maintenance-subject-vehicle'], + polymorphicTypes: ['fleet-ops:vehicle', 'Fleetbase\\FleetOps\\Models\\Vehicle'], + }, { key: 'driver', components: { pill: 'custom/driver-pill', 'select-option': 'custom/driver-option' }, icon: 'id-card', labelKey: 'x.driver' }, { nokey: true }, null, @@ -74,7 +79,11 @@ module('Unit | Utility | resource-registry', function (hooks) { assert.strictEqual(replaced.icon, 'user'); assert.strictEqual(getResourceDescriptors(this.owner).length, 2, 'the same key replaces'); assert.strictEqual(getResourceDescriptor(this.owner, 'driver').icon, 'user'); - assert.deepEqual(registerResourceDescriptors(this.owner, { key: 'single' }).map((d) => d.key), ['single'], 'a single descriptor is accepted'); + assert.deepEqual( + registerResourceDescriptors(this.owner, { key: 'single' }).map((d) => d.key), + ['single'], + 'a single descriptor is accepted' + ); assert.strictEqual(registerResourceDescriptor(this.owner, null), null); assert.deepEqual(registerResourceDescriptors(null, [{ key: 'nowhere' }]), [], 'no owner, no registration'); }); From 227a52401d88f7c34456b3e89ed7b870a53f004f Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 16 Sep 2026 07:02:21 +0800 Subject: [PATCH 29/40] Report a cleared model filter as a clear, and settle the dummy app for ember-core Clearing filter/model through the select's own clear control selected null and reported it as a change to an empty value; report it as a clear so the filter is removed. The dummy app gains the fetch and universe stubs the v0.4.2 branch already carries, which the dashboard and docs-panel unit tests need once ember-core is installed. The registry tests allow for the six core descriptors the instance-initializer registers, and the picker tests use the power-select helpers correctly. --- addon/components/filter/model.js | 8 ++- tests/dummy/app/services/fetch.js | 53 +++++++++++++++++++ tests/dummy/app/services/universe.js | 33 ++++++++++++ tests/dummy/app/utils/stub-evented-service.js | 34 ++++++++++++ .../components/filter/model-multiple-test.js | 6 +-- .../components/filter/model-test.js | 10 ++-- .../components/multi-select-test.js | 9 +++- .../components/resource/smoke-test.js | 4 +- tests/unit/services/resource-registry-test.js | 5 +- tests/unit/utils/resource-registry-test.js | 13 ++--- 10 files changed, 152 insertions(+), 23 deletions(-) create mode 100644 tests/dummy/app/services/fetch.js create mode 100644 tests/dummy/app/services/universe.js create mode 100644 tests/dummy/app/utils/stub-evented-service.js diff --git a/addon/components/filter/model.js b/addon/components/filter/model.js index b3ebc55d..962d4897 100644 --- a/addon/components/filter/model.js +++ b/addon/components/filter/model.js @@ -36,8 +36,14 @@ export default class FilterModelComponent extends Component { this.selectedModel = selectedModel; + // The select's own clear control selects null; report that as a clear + // so the filter is removed rather than set to an empty value. + if (!selectedModel) { + return this.clear(); + } + if (typeof onChange === 'function') { - onChange(filter, selectedModel?.id); + onChange(filter, selectedModel.id); } } diff --git a/tests/dummy/app/services/fetch.js b/tests/dummy/app/services/fetch.js new file mode 100644 index 00000000..163f5b7e --- /dev/null +++ b/tests/dummy/app/services/fetch.js @@ -0,0 +1,53 @@ +import Service from '@ember/service'; + +/** + * Stub of the host console's `fetch` service. Never performs network requests. + * + * Responses can be primed per-path for assertions: `this.owner.lookup('service:fetch').responses['geocoder/query'] = { ... };` + * Every call is recorded on `calls` as `{ method, args }`. + */ +export default class FetchService extends Service { + calls = []; + + /** Map of path -> canned response used by `get` and `post`. */ + responses = {}; + + get(path, params = {}, options = {}) { + this.calls.push({ method: 'get', args: [path, params, options] }); + return Promise.resolve(this.responses[path] ?? []); + } + + post(path, body = {}, options = {}) { + this.calls.push({ method: 'post', args: [path, body, options] }); + return Promise.resolve(this.responses[path] ?? {}); + } + + download(path, params = {}, options = {}) { + this.calls.push({ method: 'download', args: [path, params, options] }); + return Promise.resolve(); + } + + /** Pseudo ember-concurrency task: `this.fetch.uploadFile.perform(file, params, onSuccess, onError)`. */ + uploadFile = { + isRunning: false, + isIdle: true, + perform: (file, params = {}, onSuccess, onError) => { + this.calls.push({ method: 'uploadFile.perform', args: [file, params, onSuccess, onError] }); + + const uploadedFile = { + id: 'test-file-1', + filename: file?.name ?? 'test-file.txt', + content_type: file?.type ?? 'text/plain', + url: 'https://example.test/uploads/test-file.txt', + save: () => Promise.resolve(uploadedFile), + destroyRecord: () => Promise.resolve(uploadedFile), + }; + + if (typeof onSuccess === 'function') { + onSuccess(uploadedFile); + } + + return Promise.resolve(uploadedFile); + }, + }; +} diff --git a/tests/dummy/app/services/universe.js b/tests/dummy/app/services/universe.js new file mode 100644 index 00000000..e0e57247 --- /dev/null +++ b/tests/dummy/app/services/universe.js @@ -0,0 +1,33 @@ +import { inject as service } from '@ember/service'; +import StubEventedService from '../utils/stub-evented-service'; + +/** + * Stub of the host console's `universe` extension registry service. + * Menu-item arrays are empty; registry lookups return empty arrays; `on`/`off`/`trigger` work. + */ +export default class UniverseService extends StubEventedService { + @service('universe/menu-service') menuService; + @service('universe/widget-service') widgetService; + @service('universe/registry-service') registryService; + @service('universe/extension-manager') extensionManager; + + calls = []; + + headerMenuItems = []; + organizationMenuItems = []; + userMenuItems = []; + + registerDashboard(dashboardId) { + this.calls.push({ method: 'registerDashboard', args: [dashboardId] }); + } + + getMenuItemsFromRegistry(registryName) { + this.calls.push({ method: 'getMenuItemsFromRegistry', args: [registryName] }); + return []; + } + + getRenderableComponentsFromRegistry(registryName) { + this.calls.push({ method: 'getRenderableComponentsFromRegistry', args: [registryName] }); + return []; + } +} diff --git a/tests/dummy/app/utils/stub-evented-service.js b/tests/dummy/app/utils/stub-evented-service.js new file mode 100644 index 00000000..42317e4f --- /dev/null +++ b/tests/dummy/app/utils/stub-evented-service.js @@ -0,0 +1,34 @@ +import Service from '@ember/service'; + +/** + * Base class for stub services that need an Evented-like surface (`on`, `off`, `trigger`) + * without pulling in the Ember Evented mixin. + */ +export default class StubEventedService extends Service { + _listeners = new Map(); + + on(eventName, callback) { + if (!this._listeners.has(eventName)) { + this._listeners.set(eventName, []); + } + this._listeners.get(eventName).push(callback); + return this; + } + + off(eventName, callback) { + const listeners = this._listeners.get(eventName) ?? []; + const index = listeners.indexOf(callback); + if (index > -1) { + listeners.splice(index, 1); + } + return this; + } + + trigger(eventName, ...args) { + const listeners = [...(this._listeners.get(eventName) ?? [])]; + for (const listener of listeners) { + listener(...args); + } + return this; + } +} diff --git a/tests/integration/components/filter/model-multiple-test.js b/tests/integration/components/filter/model-multiple-test.js index 92600be1..f413d46f 100644 --- a/tests/integration/components/filter/model-multiple-test.js +++ b/tests/integration/components/filter/model-multiple-test.js @@ -42,7 +42,7 @@ module('Integration | Component | filter/model-multiple', function (hooks) { hooks.beforeEach(function () { this.owner.register('service:store', StoreStub); - this.owner.register('template:components/select-option/person', hbs`{{@option.name}}`); + this.owner.register('template:components/select-option/person', hbs`{{@option.name}}`); }); test('it restores chips from a comma-separated id value, peeking before fetching', async function (assert) { @@ -82,11 +82,11 @@ module('Integration | Component | filter/model-multiple', function (hooks) { this.set('onClear', (filter) => clears.push(filter)); await render(hbs``); - await clickTrigger('.ember-power-select-trigger'); + await clickTrigger('.ember-model-select'); assert.dom('.ember-power-select-option [data-test-person-option]').exists({ count: 3 }); await click('.ember-power-select-option'); - await clickTrigger('.ember-power-select-trigger'); + await clickTrigger('.ember-model-select'); await click('.ember-power-select-option:nth-child(2)'); assert.deepEqual(changes, ['a', 'a,b']); assert.dom('.ember-power-select-multiple-option [data-test-person-option][data-compact="true"]').exists({ count: 2 }); diff --git a/tests/integration/components/filter/model-test.js b/tests/integration/components/filter/model-test.js index 428a3813..d2c4c2fb 100644 --- a/tests/integration/components/filter/model-test.js +++ b/tests/integration/components/filter/model-test.js @@ -31,7 +31,7 @@ module('Integration | Component | filter/model', function (hooks) { hooks.beforeEach(function () { this.owner.register('service:store', StoreStub); - this.owner.register('template:components/select-option/person', hbs`{{@option.name}}`); + this.owner.register('template:components/select-option/person', hbs`{{@option.name}}`); this.owner.register('template:components/custom-option', hbs`{{@option.name}}!`); }); @@ -45,7 +45,7 @@ module('Integration | Component | filter/model', function (hooks) { }); await render(hbs``); - await clickTrigger('.ember-power-select-trigger'); + await clickTrigger('.ember-model-select'); assert.dom('.ember-power-select-option [data-test-person-option]').exists({ count: 2 }); assert.dom('.ember-power-select-option [data-test-person-option]').doesNotHaveAttribute('data-compact'); @@ -60,7 +60,7 @@ module('Integration | Component | filter/model', function (hooks) { this.set('filter', { model: 'person', filterOptionComponent: 'custom-option' }); await render(hbs``); - await clickTrigger('.ember-power-select-trigger'); + await clickTrigger('.ember-model-select'); assert.dom('.ember-power-select-option [data-test-custom-option]').exists({ count: 2 }); assert.dom('.ember-power-select-option [data-test-person-option]').doesNotExist(); @@ -70,7 +70,7 @@ module('Integration | Component | filter/model', function (hooks) { this.set('filter', { model: 'unknown-thing', modelNamePath: 'phone' }); await render(hbs``); - await clickTrigger('.ember-power-select-trigger'); + await clickTrigger('.ember-model-select'); assert.dom('.ember-power-select-option').exists({ count: 2 }); assert.dom('.ember-power-select-option').hasText('+1'); @@ -82,7 +82,7 @@ module('Integration | Component | filter/model', function (hooks) { this.set('onClear', (filter) => assert.strictEqual(filter, this.filter)); await render(hbs``); - await clickTrigger('.ember-power-select-trigger'); + await clickTrigger('.ember-model-select'); await click('.ember-power-select-option'); assert.dom('.ember-power-select-selected-item').hasText('Ada'); diff --git a/tests/integration/components/multi-select-test.js b/tests/integration/components/multi-select-test.js index d3cc1d0a..bebd0e46 100644 --- a/tests/integration/components/multi-select-test.js +++ b/tests/integration/components/multi-select-test.js @@ -7,13 +7,17 @@ import { clickTrigger } from 'ember-power-select/test-support/helpers'; module('Integration | Component | multi-select', function (hooks) { setupRenderingTest(hooks); + hooks.beforeEach(function () { + this.set('noop', () => {}); + }); + test('it renders options and forwards the selected item component', async function (assert) { this.owner.register('template:components/test-selected', hbs`{{@option.name}}`); this.set('options', [{ name: 'Ada' }, { name: 'Bob' }]); this.set('selected', [this.options[0]]); await render(hbs` - + {{option.name}} `); @@ -29,6 +33,7 @@ module('Integration | Component | multi-select', function (hooks) { `); - await clickTrigger('.ember-power-select-trigger'); + await clickTrigger(); assert.dom('.ember-power-select-search-input').hasAttribute('placeholder', 'Find a person'); await fillIn('.ember-power-select-search-input', 'bo'); diff --git a/tests/integration/components/resource/smoke-test.js b/tests/integration/components/resource/smoke-test.js index 950d1425..75374519 100644 --- a/tests/integration/components/resource/smoke-test.js +++ b/tests/integration/components/resource/smoke-test.js @@ -26,7 +26,7 @@ module('Integration | Component | resource identity smoke', function (hooks) { assert.dom('[data-test-resource-pill][data-resource-type="user"]').includesText('Ada Lovelace'); assert.dom('[data-test-resource-pill][data-resource-type="user"]').includesText('ada@example.test'); assert.dom('[data-test-resource-pill][data-resource-type="user"] [data-test-pill-online-indicator]').hasClass('text-green-500'); - assert.dom('[data-test-resource-pill][data-resource-type="user"] .fleetbase-pill').hasClass('fleetbase-pill--static', 'no opener registered, so static'); + assert.dom('[data-test-resource-pill][data-resource-type="user"]').hasClass('fleetbase-pill--static', 'no opener registered, so static'); assert.dom('[data-test-resource-summary-title]').hasText('Ada Lovelace'); assert.dom('[data-test-resource-summary-fact]').exists(); assert.dom('[data-test-resource-summary-view]').doesNotExist(); @@ -64,7 +64,7 @@ module('Integration | Component | resource identity smoke', function (hooks) { const registry = this.owner.lookup('service:resource-registry'); registry.setOpener('user', (record) => opened.push(record) && true); - await render(hbs``); + await render(hbs``); await click('[data-test-resource-pill] a'); await click('[data-test-identity-button]'); diff --git a/tests/unit/services/resource-registry-test.js b/tests/unit/services/resource-registry-test.js index 10953f8a..10baa7ba 100644 --- a/tests/unit/services/resource-registry-test.js +++ b/tests/unit/services/resource-registry-test.js @@ -13,10 +13,7 @@ module('Unit | Service | resource-registry', function (hooks) { const [vehicle] = service.registerDescriptors([{ key: 'vehicle', aliases: ['attachable-vehicle'], identifier: (r) => r.plate }]); assert.strictEqual(vehicle.key, 'vehicle'); assert.strictEqual(service.register({ key: 'driver' }).key, 'driver'); - assert.deepEqual( - service.descriptors.map((d) => d.key), - ['vehicle', 'driver'] - ); + assert.ok(service.descriptors.some((d) => d.key === 'vehicle') && service.descriptors.some((d) => d.key === 'driver')); assert.strictEqual(service.resolveKey('attachable-vehicle'), 'vehicle'); assert.strictEqual(service.getDescriptor('vehicle').key, 'vehicle'); assert.strictEqual(service.componentName('pill', 'vehicle'), 'vehicle/pill'); diff --git a/tests/unit/utils/resource-registry-test.js b/tests/unit/utils/resource-registry-test.js index 812873c2..caff7fd3 100644 --- a/tests/unit/utils/resource-registry-test.js +++ b/tests/unit/utils/resource-registry-test.js @@ -73,11 +73,12 @@ module('Unit | Utility | resource-registry', function (hooks) { assert.strictEqual(driver.components.selectOption, 'custom/driver-option'); assert.strictEqual(driver.icon, 'id-card'); assert.strictEqual(driver.labelKey, 'x.driver'); - assert.strictEqual(getResourceDescriptors(this.owner).length, 2); + const count = getResourceDescriptors(this.owner).length; + assert.ok(count >= 2, 'both registered (the core families are registered by the instance-initializer)'); const replaced = registerResourceDescriptor(this.owner, { key: 'driver', icon: 'user' }); assert.strictEqual(replaced.icon, 'user'); - assert.strictEqual(getResourceDescriptors(this.owner).length, 2, 'the same key replaces'); + assert.strictEqual(getResourceDescriptors(this.owner).length, count, 'the same key replaces'); assert.strictEqual(getResourceDescriptor(this.owner, 'driver').icon, 'user'); assert.deepEqual( registerResourceDescriptors(this.owner, { key: 'single' }).map((d) => d.key), @@ -155,13 +156,13 @@ module('Unit | Utility | resource-registry', function (hooks) { }); test('setResourceOpener lets another package supply how a resource opens', async function (assert) { - registerResourceDescriptors(this.owner, [{ key: 'user' }]); - const record = { resourceType: 'user' }; + registerResourceDescriptors(this.owner, [{ key: 'widget-thing' }]); + const record = { resourceType: 'widget-thing' }; assert.false(canOpenResource(this.owner, record), 'nothing to open yet'); assert.false(setResourceOpener(this.owner, 'nothing', () => true)); - assert.true(setResourceOpener(this.owner, 'user', () => 'opened', { permission: 'iam view user', canOpen: () => true })); - assert.strictEqual(getResourceDescriptor(this.owner, 'user').permission, 'iam view user'); + assert.true(setResourceOpener(this.owner, 'widget-thing', () => 'opened', { permission: null, canOpen: () => true })); + assert.strictEqual(getResourceDescriptor(this.owner, 'widget-thing').permission, null); assert.true(canOpenResource(this.owner, record)); assert.true(await openResource(this.owner, record)); }); From 90112a0a67745d6167ed3ebae419f3e30abd5769 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 16 Sep 2026 07:14:25 +0800 Subject: [PATCH 30/40] Keep the dummy app free of ember-core Declaring @fleetbase/ember-core turned out to cost more than it bought: its app tree collides with ember-ui's (is-image-file), its services need the console (docs-panel, dashboard), and CI's unit subset went red. Drop it again and remove the two ember-core imports the tested components had instead: Pill falls back to the record's model name directly and ModelSelect uses a local generate-uuid. The dummy app keeps only the universe/registry-service stub the registry tests exercise. Two assertions in the resource-identity test contradicted the template on main (the button has py-0.5; includesText on a multi-element selector reads the first badge); they are corrected while the file is touched. --- addon/components/model-select.js | 2 +- addon/components/pill.js | 4 +- addon/utils/generate-uuid.js | 16 + app/utils/generate-uuid.js | 1 + package.json | 3 - pnpm-lock.yaml | 899 +----------------- tests/dummy/app/services/fetch.js | 53 -- tests/dummy/app/services/universe.js | 33 - .../app/services/universe/widget-service.js | 23 - tests/dummy/app/utils/stub-evented-service.js | 34 - .../table/cell/resource-identity-test.js | 5 +- 11 files changed, 26 insertions(+), 1047 deletions(-) create mode 100644 addon/utils/generate-uuid.js create mode 100644 app/utils/generate-uuid.js delete mode 100644 tests/dummy/app/services/fetch.js delete mode 100644 tests/dummy/app/services/universe.js delete mode 100644 tests/dummy/app/services/universe/widget-service.js delete mode 100644 tests/dummy/app/utils/stub-evented-service.js diff --git a/addon/components/model-select.js b/addon/components/model-select.js index e1776e12..a99cb782 100644 --- a/addon/components/model-select.js +++ b/addon/components/model-select.js @@ -5,7 +5,7 @@ import { inject as service } from '@ember/service'; import { A } from '@ember/array'; import { tracked } from '@glimmer/tracking'; import { timeout, restartableTask, dropTask } from 'ember-concurrency'; -import generateUuid from '@fleetbase/ember-core/utils/generate-uuid'; +import generateUuid from '../utils/generate-uuid'; import config from 'ember-get-config'; const getConfigOption = (key, defaultValue) => { diff --git a/addon/components/pill.js b/addon/components/pill.js index e9a30471..b749572d 100644 --- a/addon/components/pill.js +++ b/addon/components/pill.js @@ -1,6 +1,5 @@ import Component from '@glimmer/component'; import { action, get } from '@ember/object'; -import getModelName from '@fleetbase/ember-core/utils/get-model-name'; export default class PillComponent extends Component { /* eslint-disable ember/no-get */ @@ -28,7 +27,8 @@ export default class PillComponent extends Component { get(record, 'displayName') ?? get(record, 'tracking') ?? get(record, 'public_id') ?? - getModelName(record) + get(record, 'constructor.modelName') ?? + null ); } diff --git a/addon/utils/generate-uuid.js b/addon/utils/generate-uuid.js new file mode 100644 index 00000000..471914ad --- /dev/null +++ b/addon/utils/generate-uuid.js @@ -0,0 +1,16 @@ +/** + * A v4 UUID, from the platform when it has one and otherwise from + * Math.random, for records that arrive without an id. + */ +export default function generateUuid() { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + + return v.toString(16); + }); +} diff --git a/app/utils/generate-uuid.js b/app/utils/generate-uuid.js new file mode 100644 index 00000000..767e3660 --- /dev/null +++ b/app/utils/generate-uuid.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/utils/generate-uuid'; diff --git a/package.json b/package.json index 071eabdb..56736976 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,6 @@ "@embroider/macros": "^1.8.3", "@event-calendar/core": "^5.6.0", "@fleetbase/ember-accounting": "^0.0.1", - "@fleetbase/ember-core": "^0.3.24", "@floating-ui/dom": "^1.0.1", "@fortawesome/ember-fontawesome": "^2.0.0", "@fortawesome/fontawesome-svg-core": "6.4.0", @@ -141,7 +140,6 @@ "ember-cli-inject-live-reload": "^2.1.0", "ember-cli-sri": "^2.1.1", "ember-cli-terser": "^4.0.2", - "ember-data": "^4.12.8", "ember-load-initializers": "^2.1.2", "ember-page-title": "^8.0.0", "ember-qunit": "^8.0.1", @@ -149,7 +147,6 @@ "ember-source": "~5.4.0", "ember-source-channel-url": "^3.0.0", "ember-template-lint": "^5.11.2", - "ember-tracked-storage-polyfill": "1.0.1", "ember-try": "^3.0.0", "eslint": "^8.52.0", "eslint-config-prettier": "^9.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa22ab91..dc800fb0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,9 +29,6 @@ importers: '@fleetbase/ember-accounting': specifier: ^0.0.1 version: 0.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) - '@fleetbase/ember-core': - specifier: ^0.3.24 - version: 0.3.24(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))(eslint@8.57.1)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)) '@floating-ui/dom': specifier: ^1.0.1 version: 1.7.6 @@ -318,9 +315,6 @@ importers: ember-cli-terser: specifier: ^4.0.2 version: 4.0.2 - ember-data: - specifier: ^4.12.8 - version: 4.12.8(@babel/core@7.29.0)(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)) ember-load-initializers: specifier: ^2.1.2 version: 2.1.2(@babel/core@7.29.0) @@ -342,9 +336,6 @@ importers: ember-template-lint: specifier: ^5.11.2 version: 5.13.0 - ember-tracked-storage-polyfill: - specifier: 1.0.1 - version: 1.0.1 ember-try: specifier: ^3.0.0 version: 3.0.0 @@ -1231,111 +1222,9 @@ packages: peerDependencies: postcss: ^8.4 - '@ember-data/adapter@4.12.8': - resolution: {integrity: sha512-HIwLGUkAXPbOfCw/vt1Xi5a3/J/sV4tT0LVsB/HPo+m0h/ztSmrfCQVRJCzZUP3ACeOL+eGeMQt4zyz8RfZazw==} - engines: {node: 16.* || >= 18.*} - peerDependencies: - '@ember-data/store': 4.12.8 - '@ember/string': ^3.0.1 - ember-inflector: ^4.0.2 - - '@ember-data/debug@4.12.8': - resolution: {integrity: sha512-dA2VXsO8OPddZ723oQxLbjQVoWMpVuqhskBgaf8kRNmJI9ru8AxhR6KWJaF2LMeJ3VhI5ujo1rNfOC2Y1t/chw==} - engines: {node: 16.* || >= 18.*} - peerDependencies: - '@ember-data/store': 4.12.8 - '@ember/string': ^3.0.1 - - '@ember-data/graph@4.12.8': - resolution: {integrity: sha512-Nm297TOVsOvIqnzRPclW3YL+ILgpz00Rc5Z5KNk1Je3RP8+02uA7Sh39p5WG9YQr6rz3+xY5jd1VbmIoLOQiaA==} - engines: {node: 16.* || >= 18.*} - peerDependencies: - '@ember-data/store': 4.12.8 - - '@ember-data/json-api@4.12.8': - resolution: {integrity: sha512-A5ann76wOeRXeRPOG8wrWQn4BK+yb7T1l6Ybm1eSgkFQeNVvVc/eM6ejcRospQInSRZnOJZCPHYd+wggZgpXGA==} - engines: {node: 16.* || >= 18.*} - peerDependencies: - '@ember-data/graph': 4.12.8 - '@ember-data/store': 4.12.8 - - '@ember-data/legacy-compat@4.12.8': - resolution: {integrity: sha512-sMC+QWdA+oMFtGH1UvwK2UU/iua29s298SSftRP9M84JAqr7t8AWfZd73m1CWe9aboyYKe1KXOCfPUsgrSICCg==} - engines: {node: 16.* || >= 18} - peerDependencies: - '@ember-data/graph': 4.12.8 - '@ember-data/json-api': 4.12.8 - '@ember/string': ^3.0.1 - peerDependenciesMeta: - '@ember-data/graph': - optional: true - '@ember-data/json-api': - optional: true - - '@ember-data/model@4.12.8': - resolution: {integrity: sha512-rJQVri/mrZIdwmonVqbHVsCI+xLvW5CClnlXLiHCBDpoq/klXJ6u5FMglH64GAEpjuIfWKiygdOvMGiaYFJt+A==} - engines: {node: 16.* || >= 18.*} - peerDependencies: - '@ember-data/debug': 4.12.8 - '@ember-data/graph': 4.12.8 - '@ember-data/json-api': 4.12.8 - '@ember-data/legacy-compat': 4.12.8 - '@ember-data/store': 4.12.8 - '@ember-data/tracking': 4.12.8 - '@ember/string': ^3.0.1 - ember-inflector: ^4.0.2 - peerDependenciesMeta: - '@ember-data/debug': - optional: true - '@ember-data/graph': - optional: true - '@ember-data/json-api': - optional: true - - '@ember-data/private-build-infra@4.12.8': - resolution: {integrity: sha512-acOT5m5Bnq78IYcCjRoP9Loh65XNODFor+nThvH4IDmfaxNfKfr8Qheu4f23r5oPOXmHbcDBWRjsjs2dkaKTAw==} - engines: {node: 16.* || >= 18.*} - - '@ember-data/request@4.12.8': - resolution: {integrity: sha512-aTn+Cd5b901MGhLKRJdd/+xXrkp1GAmJEn55F8W2ojYk82rt2ZbO/Ppe2DWhTRMujj6vKclYhWJt0NNafnUobQ==} - engines: {node: 16.* || >= 18} - '@ember-data/rfc395-data@0.0.4': resolution: {integrity: sha512-tGRdvgC9/QMQSuSuJV45xoyhI0Pzjm7A9o/MVVA3HakXIImJbbzx/k/6dO9CUEQXIyS2y0fW6C1XaYOG7rY0FQ==} - '@ember-data/serializer@4.12.8': - resolution: {integrity: sha512-XKjSnq8jR1C8sFCZmdd1cTfV5THt1ykYDcDNo80pLoZaIosYtt1QVIVLq0puTjNXO/B8GyQl8DN2p/AS9fwbaw==} - engines: {node: 16.* || >= 18.*} - peerDependencies: - '@ember-data/store': 4.12.8 - '@ember/string': ^3.0.1 - ember-inflector: ^4.0.2 - - '@ember-data/store@4.12.8': - resolution: {integrity: sha512-pI+c/ZtRO5T02JcQ+yvUQsRZIIw/+fVUUnxa6mHiiNkjOJZaK8/2resdskSgV3SFGI82icanV7Ve5LJj9EzscA==} - engines: {node: 16.* || >= 18.*} - peerDependencies: - '@ember-data/graph': 4.12.8 - '@ember-data/json-api': 4.12.8 - '@ember-data/legacy-compat': 4.12.8 - '@ember-data/model': 4.12.8 - '@ember-data/tracking': 4.12.8 - '@ember/string': ^3.0.1 - '@glimmer/tracking': ^1.1.2 - peerDependenciesMeta: - '@ember-data/graph': - optional: true - '@ember-data/json-api': - optional: true - '@ember-data/legacy-compat': - optional: true - '@ember-data/model': - optional: true - - '@ember-data/tracking@4.12.8': - resolution: {integrity: sha512-CczHOsEbInbVg4WF2UQhV89gCnSfH+8ZR1WinPFQ8PaY6e1KSlPULuTXhC03NhAo8GaJzHlvc3KfATt5qgBplg==} - engines: {node: 16.* || >= 18} - '@ember-decorators/component@6.1.1': resolution: {integrity: sha512-Cj8tY/c0MC/rsipqsiWLh3YVN72DK92edPYamD/HzvftwzC6oDwawWk8RmStiBnG9PG/vntAt41l3S7HSSA+1Q==} engines: {node: '>= 8.*'} @@ -1467,10 +1356,6 @@ packages: peerDependencies: ember-source: '>= 4.0.0' - '@fleetbase/ember-core@0.3.24': - resolution: {integrity: sha512-WG32JlX4S75ofa34RQEMxFEqc20fTv2Qn3A5+NlqXtL0lfnCthdO+a1DrGqIIierthHl3mVTxhe+z63f8sM6EA==} - engines: {node: '>= 18'} - '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -1480,50 +1365,6 @@ packages: '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} - '@formatjs/ecma402-abstract@2.2.4': - resolution: {integrity: sha512-lFyiQDVvSbQOpU+WFd//ILolGj4UgA/qXrKeZxdV14uKiAUiPAtX6XAn7WBCRi7Mx6I7EybM9E5yYn4BIpZWYg==} - - '@formatjs/ecma402-abstract@2.3.6': - resolution: {integrity: sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==} - - '@formatjs/fast-memoize@2.2.3': - resolution: {integrity: sha512-3jeJ+HyOfu8osl3GNSL4vVHUuWFXR03Iz9jjgI7RwjG6ysu/Ymdr0JRCPHfF5yGbTE6JCrd63EpvX1/WybYRbA==} - - '@formatjs/fast-memoize@2.2.7': - resolution: {integrity: sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==} - - '@formatjs/icu-messageformat-parser@2.11.4': - resolution: {integrity: sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==} - - '@formatjs/icu-messageformat-parser@2.9.4': - resolution: {integrity: sha512-Tbvp5a9IWuxUcpWNIW6GlMQYEc4rwNHR259uUFoKWNN1jM9obf9Ul0e+7r7MvFOBNcN+13K7NuKCKqQiAn1QEg==} - - '@formatjs/icu-skeleton-parser@1.8.16': - resolution: {integrity: sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==} - - '@formatjs/icu-skeleton-parser@1.8.8': - resolution: {integrity: sha512-vHwK3piXwamFcx5YQdCdJxUQ1WdTl6ANclt5xba5zLGDv5Bsur7qz8AD7BevaKxITwpgDeU0u8My3AIibW9ywA==} - - '@formatjs/intl-displaynames@6.8.5': - resolution: {integrity: sha512-85b+GdAKCsleS6cqVxf/Aw/uBd+20EM0wDpgaxzHo3RIR3bxF4xCJqH/Grbzx8CXurTgDDZHPdPdwJC+May41w==} - - '@formatjs/intl-listformat@7.7.5': - resolution: {integrity: sha512-Wzes10SMNeYgnxYiKsda4rnHP3Q3II4XT2tZyOgnH5fWuHDtIkceuWlRQNsvrI3uiwP4hLqp2XdQTCsfkhXulg==} - - '@formatjs/intl-localematcher@0.5.8': - resolution: {integrity: sha512-I+WDNWWJFZie+jkfkiK5Mp4hEDyRSEvmyfYadflOno/mmKJKcB17fEpEH0oJu/OWhhCJ8kJBDz2YMd/6cDl7Mg==} - - '@formatjs/intl-localematcher@0.6.2': - resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==} - - '@formatjs/intl@2.10.15': - resolution: {integrity: sha512-i6+xVqT+6KCz7nBfk4ybMXmbKO36tKvbMKtgFz9KV+8idYFyFbfwKooYk8kGjyA5+T5f1kEPQM5IDLXucTAQ9g==} - peerDependencies: - typescript: ^4.7 || 5 - peerDependenciesMeta: - typescript: - optional: true - '@fortawesome/ember-fontawesome@2.0.0': resolution: {integrity: sha512-lUyMvocZZzMuCwr8pkKhejXKBj5RGnIs8YUHc/tCNSEyHSx7/E5xAhtE4fa5B1c9+UO789Kng8z6DQ9E/agVGA==} engines: {node: 14.* || 16.* || >= 18} @@ -1717,10 +1558,6 @@ packages: resolution: {integrity: sha512-CaNTtpaypA69fCqFlz69SMSuuLY1TLcDxjXaPmBNj+SsZpqQXcFgkPVRQEna7IICdgDbJIoLQnB/AB7/J6AD9g==} engines: {node: '>= 10.*'} - '@mrmlnc/readdir-enhanced@2.2.1': - resolution: {integrity: sha512-bPHp6Ji8b41szTOcaP63VlnbbO5Ny6dwAATtY6JTjh5N2OLrb5Qk/Th5cRkRQhkWCt+EJsYrNB0MiL+Gpn6e3g==} - engines: {node: '>=4'} - '@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1': resolution: {integrity: sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==} @@ -1728,10 +1565,6 @@ packages: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} - '@nodelib/fs.stat@1.1.3': - resolution: {integrity: sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==} - engines: {node: '>= 6'} - '@nodelib/fs.stat@2.0.5': resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} engines: {node: '>= 8'} @@ -2264,12 +2097,6 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - ag-channel@5.0.0: - resolution: {integrity: sha512-bArHkdqQxynim981t8FLZM5TfA0v7p081OlFdOxs6clB79GSGcGlOQMDa31DT9F5VMjzqNiJmhfGwinvfU/3Zg==} - - ag-request@1.1.0: - resolution: {integrity: sha512-d4K7QC1KnIpzcnUNNOeh1ddxmYMLiIdhdc1M8osxiHbZP/uoia4IINhhf2+1CrlnNJEPUoUH0Y58Sx0qeqoIvg==} - air-datepicker@3.6.0: resolution: {integrity: sha512-+txUkqa949rXBJDmkQAIb/GehZECJYF4rm9XJxVYtEX22C9WvBpE/XwCUQZBopKIkpg4ycAySJ9lH3JOg9qQTw==} @@ -2475,9 +2302,6 @@ packages: async-promise-queue@1.0.5: resolution: {integrity: sha512-xi0aQ1rrjPWYmqbwr18rrSKbSaXIeIwSd1J4KAgVfkq8utNbdZoht7GfvfY6swFUAMJ9obkc4WPJmtGwl+B8dw==} - async-stream-emitter@4.1.0: - resolution: {integrity: sha512-cfPZYjHkhCdHSR+eux71vOU8+8Xb23oLyxccAjwYHgOxDb3+qSDb2HV1Y0Hmu39vZlse2cm15CUShLiVYXHCmQ==} - async@2.6.4: resolution: {integrity: sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==} @@ -2630,9 +2454,6 @@ packages: babel-types@6.26.0: resolution: {integrity: sha512-zhe3V/26rCWsEZK8kZN+HaQj5yQ1CilTObixFzKW1UWjqG7618Twz6YEsCnjfg5gBcJh02DrpCkS9h98ZqDY+g==} - babel6-plugin-strip-class-callcheck@6.0.0: - resolution: {integrity: sha512-biNFJ7JAK4+9BwswDGL0dmYpvXHvswOFR/iKg3Q/f+pNxPEa5bWZkLHI1fW4spPytkHGMe7f/XtYyhzml9hiWg==} - babylon@6.18.0: resolution: {integrity: sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==} hasBin: true @@ -2695,18 +2516,12 @@ packages: bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} - bl@1.2.3: - resolution: {integrity: sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==} - bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} blank-object@1.0.2: resolution: {integrity: sha512-kXQ19Xhoghiyw66CUiGypnuRpWlbHAzY/+NyvqTEdTfhfQGH1/dbEMYiXju7fYKIFePpzp/y9dsu5Cu/PkmawQ==} - blob-polyfill@7.0.20220408: - resolution: {integrity: sha512-oD8Ydw+5lNoqq+en24iuPt1QixdPpe/nUF8azTHnviCZYu9zUC+TwdzIp5orpblJosNlgNbVmmAb//c6d6ImUQ==} - bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} @@ -2812,10 +2627,6 @@ packages: broccoli-kitchen-sink-helpers@0.3.1: resolution: {integrity: sha512-gqYnKSJxBSjj/uJqeuRAzYVbmjWhG0mOZ8jrp6+fnUIOgLN6MvI7XxBECDHkYMIFPJ8Smf4xaI066Q2FqQDnXg==} - broccoli-merge-files@0.8.0: - resolution: {integrity: sha512-S6dXHECbDkr7YMuCitAAQT8EZeW/kXom0Y8+QmQfiSkWspkKDGrr4vXgEZJjWqfa/FSx/Y18NEEOuMmbIW+XNQ==} - engines: {node: '>=8.0.0'} - broccoli-merge-trees@2.0.1: resolution: {integrity: sha512-WjaexJ+I8BxP5V5RNn6um/qDRSmKoiBC/QkRi79FT9ClHfldxRyCDs9mcV7mmoaPlsshmmPaUz5jdtcKA6DClQ==} @@ -3010,9 +2821,6 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} - call-me-maybe@1.0.2: - resolution: {integrity: sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==} - callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -3115,9 +2923,6 @@ packages: resolution: {integrity: sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==} engines: {node: '>=0.10.0'} - cldr-core@44.1.0: - resolution: {integrity: sha512-ssbJaXu3pCkc4YqNC6xHhgleZG7YqnOFZ9laMlJfHOfnspoA9waI4AH54gKB3Fe/+wI4i3cVxKFdCTVGTRw+UA==} - clean-base-url@1.0.0: resolution: {integrity: sha512-9q6ZvUAhbKOSRFY7A/irCQ/rF0KIpa3uXpx6izm8+fp7b2H4hLeUJ+F1YYk9+gDQ/X8Q0MEyYs+tG3cht//HTg==} @@ -3167,10 +2972,6 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} - clone-deep@4.0.1: - resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==} - engines: {node: '>=6'} - clone-response@1.0.3: resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} @@ -3249,9 +3050,6 @@ packages: component-emitter@1.3.1: resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} - compress-json@3.4.0: - resolution: {integrity: sha512-SxT8uFOacRbP3gfi4aVAulr4KzE933eZWBguVBwuVWv3GTGQAToqpq8rFtdbIVgeCOH7RpPuXKQrPPd087b8uA==} - compressible@2.0.18: resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} engines: {node: '>= 0.6'} @@ -3440,9 +3238,6 @@ packages: constants-browserify@1.0.0: resolution: {integrity: sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==} - consumable-stream@2.0.0: - resolution: {integrity: sha512-I6WA2JVYXs/68rEvi1ie3rZjP6qusTVFEQkbzR+WC+fY56TpwiGTIDJETsrnlxv5CsnmK69ps6CkYvIbpEEqBA==} - content-disposition@0.5.4: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} @@ -3656,9 +3451,6 @@ packages: resolution: {integrity: sha512-VfxadyCECXgQlkoEAjeghAr5gY3Hf+IKjKb+X8tGVDtveCjN+USwprd2q3QXBR9T1+x2DG0XZF5/w+7HAtSaXA==} engines: {node: '>=10'} - decimal.js@10.6.0: - resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} - decode-uri-component@0.2.2: resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} engines: {node: '>=0.10'} @@ -3848,16 +3640,6 @@ packages: '@glimmer/tracking': ^1.1.2 ember-source: ^3.28.0 || ^4.0.0 || >=5.0.0 - ember-cache-primitive-polyfill@1.0.1: - resolution: {integrity: sha512-hSPcvIKarA8wad2/b6jDd/eU+OtKmi6uP+iYQbzi5TQpjsqV6b4QdRqrLk7ClSRRKBAtdTuutx+m+X+WlEd2lw==} - engines: {node: 10.* || >= 12} - - ember-cached-decorator-polyfill@1.0.2: - resolution: {integrity: sha512-hUX6OYTKltAPAu8vsVZK02BfMTV0OUXrPqvRahYPhgS7D0I6joLjlskd7mhqJMcaXLywqceIy8/s+x8bxF8bpQ==} - engines: {node: 14.* || >= 16} - peerDependencies: - ember-source: ^3.13.0 || ^4.0.0 || >= 5.0.0 - ember-can@6.0.0: resolution: {integrity: sha512-/8TnWxU9Yu0HXCk04qCHK3nnjrNMe3j6wrJJjW9ffLsnBBHKTT8yNP1f54EsHaHoNtWd73JGSSTmtcpBHhWAyw==} peerDependencies: @@ -3923,11 +3705,6 @@ packages: ember-cli-normalize-entity-name@1.0.0: resolution: {integrity: sha512-rF4P1rW2P1gVX1ynZYPmuIf7TnAFDiJmIUFI1Xz16VYykUAyiOCme0Y22LeZq8rTzwBMiwBwoE3RO4GYWehXZA==} - ember-cli-notifications@9.1.0: - resolution: {integrity: sha512-Vr3/3y6dIbfFZB7OJuKrQCwMzL48Ym1tnJUGiElvg3G75gr/FUZ88O8gTo5HqCSASmqp2HFw4bQpvtmq2GHqjg==} - peerDependencies: - ember-source: '>= 3.8.0' - ember-cli-path-utils@1.0.0: resolution: {integrity: sha512-Qq0vvquzf4cFHoDZavzkOy3Izc893r/5spspWgyzLCPTaG78fM3HsrjZm7UWEltbXUqwHHYrqZd/R0jS08NqSA==} @@ -3954,9 +3731,6 @@ packages: resolution: {integrity: sha512-Ej77K+YhCZImotoi/CU2cfsoZaswoPlGaM5TB3LvjvPDlVPRhxUHO2RsaUVC5lsGeRLRiHCOxVtoJ6GyqexzFA==} engines: {node: 10.* || 12.* || >= 14} - ember-cli-test-info@1.0.0: - resolution: {integrity: sha512-dEVTIpmUfCzweC97NGf6p7L6XKBwV2GmSM4elmzKvkttEp5P7AvGA9uGyN4GqFq+RwhW+2b0I2qlX00w+skm+A==} - ember-cli-test-loader@3.1.0: resolution: {integrity: sha512-0aocZV9SIoOHiU3hrH3IuLR6busWhTX6UVXgd490hmJkIymmOXNH2+jJoC7Ebkeo3PiOfAdjqhb765QDlHSJOw==} engines: {node: 10.* || >= 12} @@ -4040,22 +3814,6 @@ packages: '@glint/template': optional: true - ember-cookies@1.3.0: - resolution: {integrity: sha512-nhVDm9lql4EVLpbjxyosyEITFvuNAmHr7cod8K2FmIyw2KcAFWSS0v88quIWc+GvcawBTz3KSMRXOJq/0InVpg==} - engines: {node: '>= 16.*'} - peerDependencies: - ember-source: '>=4.0' - - ember-copy@2.0.1: - resolution: {integrity: sha512-N/XFvZszrzyyX4IcNoeK4mJvIItNuONumhPLqi64T8NDjJkxBj4Pq61rvMkJx/9eZ8alzE4I8vYKOLxT0FvRuQ==} - engines: {node: 10.* || >= 12} - - ember-data@4.12.8: - resolution: {integrity: sha512-fK9mp+chqXGWYx6lal/azBKP4AtW8E6u3xUUWet6henO2zPN4S5lRs6iBfaynPkmhW5DK5bvaxNmFvSzmPOghw==} - engines: {node: 16.* || >= 18.*} - peerDependencies: - '@ember/string': ^3.0.1 - ember-decorators@6.1.1: resolution: {integrity: sha512-63vZPntPn1aqMyeNRLoYjJD+8A8obd+c2iZkJflswpDRNVIsp2m7aQdSCtPt4G0U/TEq2251g+N10maHX3rnJQ==} engines: {node: '>= 8.*'} @@ -4126,15 +3884,6 @@ packages: peerDependencies: ember-source: ^3.16.0 || ^4.0.0 || ^5.0.0 - ember-intl@6.3.2: - resolution: {integrity: sha512-UJ91JjlY3z6fjajoHhhJtKUbgqEr/l6Ie4Viqn0B3Lrbavxg5IgAIvyZZ9fOVtq05MDtCuc610zV6vLOc71G0w==} - engines: {node: 16.* || >= 18} - peerDependencies: - typescript: ^4.8.0 || ^5.0.0 - peerDependenciesMeta: - typescript: - optional: true - ember-leaflet@5.1.3: resolution: {integrity: sha512-7rzZePUCjapMzkE1SXsgSDQ28nG1w/WIMt80wf72UG4LIMHOGHibenk3naFllu1Np9wApCHxLB6x4ig8clt2Aw==} engines: {node: 14.* || 16.* || >= 18} @@ -4159,10 +3908,6 @@ packages: resolution: {integrity: sha512-aGFZszIwmrIZ5XINv9TXQGWrfpKIiPwYiakEmRXx0Jvr5ZnUyKtCO84+FaKMyezrbDWsq0UjYqmdiqSAXhIkIA==} engines: {node: 12.* || 14.* || >= 16} - ember-local-storage@2.0.7: - resolution: {integrity: sha512-EPvxH/27mIzrX/EEgng+FG6HD0ri/God9OH/9mhmgPSXrgMNq/614Z3NMnooM4QKIEBAvr0p+p1UL2DgrTTMNg==} - engines: {node: 12.* || 14.* || >= 16} - ember-math-helpers@4.2.1: resolution: {integrity: sha512-/pOFz6tQ67mh0faiD7nzOCYRXHElg2d/SvQnYB8vdYoj7BLCkNHLjdo3F0oa5Qz/6J/+k3ie5ZGBjMumvlOeIw==} engines: {node: '>= 18'} @@ -4239,14 +3984,6 @@ packages: resolution: {integrity: sha512-89oVHVJwmLDvGvAUWgS87KpBoRhy3aZ6U0Ql6HOmU4TrPkyaa8pM0W81wj9cIwjYprcQtN9EwzZMHnq46+oUyw==} engines: {node: 8.* || 10.* || >= 12} - ember-simple-auth@6.1.0: - resolution: {integrity: sha512-LhOl7TrOKlqb+0a/5STOoTSncDNuPELuFZ9+1SLduVX7DtdQr8VOEAmB8UaOnG0clJ9Bj6E3SczhXGjqd718Lw==} - peerDependencies: - '@ember/test-helpers': '>= 3 || > 2.7' - peerDependenciesMeta: - '@ember/test-helpers': - optional: true - ember-source-channel-url@3.0.0: resolution: {integrity: sha512-vF/8BraOc66ZxIDo3VuNP7iiDrnXEINclJgSJmqwAAEpg84Zb1DHPI22XTXSDA+E8fW5btPUxu65c3ZXi8AQFA==} engines: {node: 10.* || 12.* || >= 14} @@ -4575,9 +4312,6 @@ packages: eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} - eventemitter3@5.0.4: - resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} - events-to-array@2.0.3: resolution: {integrity: sha512-f/qE2gImHRa4Cp2y1stEOSgw8wTFyUdVJX7G//bMwbaV9JqISFxg99NbmVQeP7YLnDUZ2un851jlaDrlpmGehQ==} engines: {node: '>=12'} @@ -4640,9 +4374,6 @@ packages: resolution: {integrity: sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==} engines: {node: '>=0.10.0'} - extend@3.0.2: - resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} - external-editor@3.1.0: resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} engines: {node: '>=4'} @@ -4661,10 +4392,6 @@ packages: fast-diff@1.3.0: resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} - fast-glob@2.2.7: - resolution: {integrity: sha512-g1KuQwHOZAmOZMuBtHdxDtju+T2RT8jgCC9aANsbpdiDDTSnjgfuVsIBNKbUeJI3oKMRExcfNDtJl4OhbffMsw==} - engines: {node: '>=4.0.0'} - fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -4675,9 +4402,6 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-memoize@2.5.2: - resolution: {integrity: sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw==} - fast-ordered-set@1.0.3: resolution: {integrity: sha512-MxBW4URybFszOx1YlACEoK52P6lE3xiFcPaGCUZ7QQOZ6uJXKo++Se8wa31SjcZ+NC/fdAWX7UtKEfaGgHS2Vg==} @@ -5028,9 +4752,6 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - glob-to-regexp@0.3.0: - resolution: {integrity: sha512-Iozmtbqv0noj0uDDqoL0zNq0VBEfK2YFoMAZoxJe4cwphvLR+JskfF30QhXHOR4m3KrE6NLRYw+U9MRXvifyig==} - glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} @@ -5366,12 +5087,6 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} - intl-messageformat@10.7.18: - resolution: {integrity: sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==} - - intl-messageformat@10.7.7: - resolution: {integrity: sha512-F134jIoeYMro/3I0h08D0Yt4N9o9pjddU/4IIxMMURqbAtI2wu70X8hvG1V48W49zXHXv3RKSF/po+0fDfsGjA==} - intl-tel-input@22.0.2: resolution: {integrity: sha512-FYDLp56aCVE+G7TJGmKCeU8LlfSztINiv54TALquMhMJumlUP1d48I6fEssbcq325vcXOrqdOO7Bbi1U/WRCcw==} @@ -5805,9 +5520,6 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - linked-list@0.1.0: - resolution: {integrity: sha512-Zr4ovrd0ODzF3ut2TWZMdHIxb8iFdJc/P3QM4iCJdlxxGHXo69c9hGIHzLo8/FtuR9E6WUZc5irKhtPUgOKMAg==} - linkify-it@4.0.1: resolution: {integrity: sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==} @@ -5865,9 +5577,6 @@ packages: lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} - lodash.defaults@4.2.0: - resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} - lodash.defaultsdeep@4.6.1: resolution: {integrity: sha512-3j8wdDzYuWO3lM3Reg03MuQR957t287Rpcxp1njpEa8oDrikb+FwGdW3n+FELh/A6qib6yPit0j/pv9G/yeAqA==} @@ -6307,9 +6016,6 @@ packages: resolution: {integrity: sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA==} engines: {node: '>=8'} - npm-git-info@1.0.3: - resolution: {integrity: sha512-i5WBdj4F/ULl16z9ZhsJDMl1EQCMQhHZzBwNnKL2LOA+T8IHNeRkLCVz9uVV9SzUdGTbDq+1oXhIYMe+8148vw==} - npm-package-arg@10.1.0: resolution: {integrity: sha512-uFyyCEmgBfZTtrKk/5xDfHp6+MdrqGotX/VoOyEEl3mBwiEE5FlBaePanazJSVMPT7vKepcjYBY2ztg9A3yPIA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -6435,10 +6141,6 @@ packages: resolution: {integrity: sha512-ugZxsxmtTln604yeYd29EGrNhazN2lywetzpKhfmQjW/VJmhpDmWbiX+h0zL8V91R0UXkhb3KtPmyq9PZw3aYw==} engines: {node: '>=8'} - p-event@2.3.1: - resolution: {integrity: sha512-NQCqOFhbpVTMX4qMe8PF8lbGtzZ+LCiN7pcNrb/413Na7+TRoe1xkKUzuWa/YEJdGQ0FvKtj35EEbDoVPO2kbA==} - engines: {node: '>=6'} - p-finally@1.0.0: resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} engines: {node: '>=4'} @@ -6487,10 +6189,6 @@ packages: resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - p-timeout@2.0.1: - resolution: {integrity: sha512-88em58dDVB/KzPEx1X0N3LwFfYZPyDc4B6eF38M1rk9VTZMbxXXgjugz8mmwpS9Ox4BDZ+t6t3QP5+/gazweIA==} - engines: {node: '>=4'} - p-try@1.0.0: resolution: {integrity: sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==} engines: {node: '>=4'} @@ -7495,15 +7193,6 @@ packages: engines: {node: 10.* || >= 12.*} hasBin: true - sc-errors@2.0.3: - resolution: {integrity: sha512-HNpClBWpo7zxLBnhH0U/FbC19Gl3OJlVyPxo9Q2eomfdWgYfd84uhqe0LRgybc+nSpcYjtF08+/dKPLugLMMeQ==} - - sc-errors@3.0.0: - resolution: {integrity: sha512-rIqv2HTPb9DVreZwK/DV0ytRUqyw2DbDcoB9XTKjEQL7oMEQKsfPA8V8dGGr7p8ZYfmvaRIGZ4Wu5qwvs/hGDA==} - - sc-formatter@4.0.0: - resolution: {integrity: sha512-MgUIvuca+90fBrCWY5LdlU9YUWjlkPFwdpvmomcwQEu3t2id/6YHdG2nhB6o7nhRp4ocfmcXQTh00r/tJtynSg==} - schema-utils@1.0.0: resolution: {integrity: sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==} engines: {node: '>= 4'} @@ -7582,10 +7271,6 @@ packages: engines: {node: '>= 0.10'} hasBin: true - shallow-clone@3.0.1: - resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==} - engines: {node: '>=8'} - shebang-command@1.2.0: resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} engines: {node: '>=0.10.0'} @@ -7680,9 +7365,6 @@ packages: resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==} engines: {node: '>=10.2.0'} - socketcluster-client@17.2.2: - resolution: {integrity: sha512-HIopjTj8p979N5klC7FeZSwu9rd805bFgFcyVX7Y8zPyjVHXHTfGrV/8vqzN2gpOwnnosWQ44ue0qGqovlxZrg==} - sort-object-keys@1.1.3: resolution: {integrity: sha512-855pvK+VkU7PaKYPc+Jjnmt4EzejQHyhhF33q31qG8x7maDzkeFhAAThdCYay11CISO+qAMwjOBP+fPZe0IPyg==} @@ -7786,9 +7468,6 @@ packages: stream-browserify@2.0.2: resolution: {integrity: sha512-nX6hmklHs/gr2FuxYDltq8fJA1GDlxKQCz8O/IM4atRqBH8OORmBNgfvW5gG10GT/qQ9u0CzIvr2X5Pkt6ntqg==} - stream-demux@8.1.0: - resolution: {integrity: sha512-20vtOmAj2EVzQZKZVmfyio16u/3QOKSvg+0ldgZeS+m2FNI1vKFoqggamagsPCXufdZ1Tk8VvAM/HV/YUmRbSg==} - stream-each@1.2.3: resolution: {integrity: sha512-vlMC2f8I2u/bZGqkdfLQW/13Zihpej/7PmSiMQsbYddxuTsJp8vRe2x2FvVExZg7FaOds43ROAuFJwPR4MTZLw==} @@ -8405,9 +8084,6 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} - vinyl-buffer@1.0.1: - resolution: {integrity: sha512-LRBE2/g3C1hSHL2k/FynSZcVTRhEw8sb08oKGt/0hukZXwrh2m8nfy+r5yLhGEk7eFFuclhyIuPct/Bxlxk6rg==} - vm-browserify@1.1.2: resolution: {integrity: sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==} @@ -8551,9 +8227,6 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - writable-consumable-stream@3.0.1: - resolution: {integrity: sha512-rAOJTA/sMgXD/X6eMwbQJe49w+Fnkdx3iV5oUzdmiZ7Bwx03khqUnAKIpzp/hbI8q2EP5NfjXgIXN0MsipfHeg==} - write-file-atomic@3.0.3: resolution: {integrity: sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==} @@ -9658,187 +9331,8 @@ snapshots: dependencies: postcss: 8.5.14 - '@ember-data/adapter@4.12.8(@babel/core@7.29.0)(@ember-data/store@4.12.8)(@ember/string@3.1.1)(ember-inflector@4.0.3(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))))': - dependencies: - '@ember-data/private-build-infra': 4.12.8 - '@ember-data/store': 4.12.8(@babel/core@7.29.0)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember-data/legacy-compat@4.12.8)(@ember-data/model@4.12.8)(@ember-data/tracking@4.12.8(@babel/core@7.29.0))(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) - '@ember/string': 3.1.1 - '@embroider/macros': 1.20.2(@babel/core@7.29.0) - ember-cli-babel: 7.26.11 - ember-cli-test-info: 1.0.0 - ember-inflector: 4.0.3(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) - transitivePeerDependencies: - - '@babel/core' - - '@glint/template' - - supports-color - - '@ember-data/debug@4.12.8(@babel/core@7.29.0)(@ember-data/store@4.12.8)(@ember/string@3.1.1)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))': - dependencies: - '@ember-data/private-build-infra': 4.12.8 - '@ember-data/store': 4.12.8(@babel/core@7.29.0)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember-data/legacy-compat@4.12.8)(@ember-data/model@4.12.8)(@ember-data/tracking@4.12.8(@babel/core@7.29.0))(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) - '@ember/edition-utils': 1.2.0 - '@ember/string': 3.1.1 - '@embroider/macros': 1.20.2(@babel/core@7.29.0) - ember-auto-import: 2.13.1(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)) - ember-cli-babel: 7.26.11 - transitivePeerDependencies: - - '@babel/core' - - '@glint/template' - - supports-color - - webpack - - '@ember-data/graph@4.12.8(@babel/core@7.29.0)(@ember-data/store@4.12.8)': - dependencies: - '@ember-data/private-build-infra': 4.12.8 - '@ember-data/store': 4.12.8(@babel/core@7.29.0)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember-data/legacy-compat@4.12.8)(@ember-data/model@4.12.8)(@ember-data/tracking@4.12.8(@babel/core@7.29.0))(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) - '@ember/edition-utils': 1.2.0 - '@embroider/macros': 1.20.2(@babel/core@7.29.0) - ember-cli-babel: 7.26.11 - transitivePeerDependencies: - - '@babel/core' - - '@glint/template' - - supports-color - - '@ember-data/json-api@4.12.8(@babel/core@7.29.0)(@ember-data/graph@4.12.8)(@ember-data/store@4.12.8)': - dependencies: - '@ember-data/graph': 4.12.8(@babel/core@7.29.0)(@ember-data/store@4.12.8) - '@ember-data/private-build-infra': 4.12.8 - '@ember-data/store': 4.12.8(@babel/core@7.29.0)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember-data/legacy-compat@4.12.8)(@ember-data/model@4.12.8)(@ember-data/tracking@4.12.8(@babel/core@7.29.0))(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) - '@ember/edition-utils': 1.2.0 - '@embroider/macros': 1.20.2(@babel/core@7.29.0) - ember-cli-babel: 7.26.11 - transitivePeerDependencies: - - '@babel/core' - - '@glint/template' - - supports-color - - '@ember-data/legacy-compat@4.12.8(@babel/core@7.29.0)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember/string@3.1.1)': - dependencies: - '@ember-data/private-build-infra': 4.12.8 - '@ember/string': 3.1.1 - '@embroider/macros': 1.20.2(@babel/core@7.29.0) - ember-cli-babel: 7.26.11 - optionalDependencies: - '@ember-data/graph': 4.12.8(@babel/core@7.29.0)(@ember-data/store@4.12.8) - '@ember-data/json-api': 4.12.8(@babel/core@7.29.0)(@ember-data/graph@4.12.8)(@ember-data/store@4.12.8) - transitivePeerDependencies: - - '@babel/core' - - '@glint/template' - - supports-color - - '@ember-data/model@4.12.8(@babel/core@7.29.0)(@ember-data/debug@4.12.8)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember-data/legacy-compat@4.12.8)(@ember-data/store@4.12.8)(@ember-data/tracking@4.12.8(@babel/core@7.29.0))(@ember/string@3.1.1)(ember-inflector@4.0.3(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))': - dependencies: - '@ember-data/legacy-compat': 4.12.8(@babel/core@7.29.0)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember/string@3.1.1) - '@ember-data/private-build-infra': 4.12.8 - '@ember-data/store': 4.12.8(@babel/core@7.29.0)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember-data/legacy-compat@4.12.8)(@ember-data/model@4.12.8)(@ember-data/tracking@4.12.8(@babel/core@7.29.0))(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) - '@ember-data/tracking': 4.12.8(@babel/core@7.29.0) - '@ember/edition-utils': 1.2.0 - '@ember/string': 3.1.1 - '@embroider/macros': 1.20.2(@babel/core@7.29.0) - ember-cached-decorator-polyfill: 1.0.2(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) - ember-cli-babel: 7.26.11 - ember-cli-string-utils: 1.1.0 - ember-cli-test-info: 1.0.0 - ember-inflector: 4.0.3(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) - inflection: 2.0.1 - optionalDependencies: - '@ember-data/debug': 4.12.8(@babel/core@7.29.0)(@ember-data/store@4.12.8)(@ember/string@3.1.1)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)) - '@ember-data/graph': 4.12.8(@babel/core@7.29.0)(@ember-data/store@4.12.8) - '@ember-data/json-api': 4.12.8(@babel/core@7.29.0)(@ember-data/graph@4.12.8)(@ember-data/store@4.12.8) - transitivePeerDependencies: - - '@babel/core' - - '@glint/template' - - ember-source - - supports-color - - '@ember-data/private-build-infra@4.12.8': - dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.29.0) - '@babel/runtime': 7.29.2 - '@ember/edition-utils': 1.2.0 - '@embroider/macros': 1.20.2(@babel/core@7.29.0) - babel-import-util: 1.4.1 - babel-plugin-debug-macros: 0.3.4(@babel/core@7.29.0) - babel-plugin-filter-imports: 4.0.0 - babel6-plugin-strip-class-callcheck: 6.0.0 - broccoli-debug: 0.6.5 - broccoli-file-creator: 2.1.1 - broccoli-funnel: 3.0.8 - broccoli-merge-trees: 4.2.0 - broccoli-rollup: 5.0.0 - calculate-cache-key-for-tree: 2.0.0 - chalk: 4.1.2 - ember-cli-babel: 7.26.11 - ember-cli-path-utils: 1.0.0 - ember-cli-string-utils: 1.1.0 - ember-cli-version-checker: 5.1.2 - git-repo-info: 2.1.1 - glob: 9.3.5 - npm-git-info: 1.0.3 - semver: 7.8.0 - silent-error: 1.1.1 - transitivePeerDependencies: - - '@glint/template' - - supports-color - - '@ember-data/request@4.12.8(@babel/core@7.29.0)': - dependencies: - '@ember-data/private-build-infra': 4.12.8 - '@ember/test-waiters': 3.1.0 - '@embroider/macros': 1.20.2(@babel/core@7.29.0) - ember-cli-babel: 7.26.11 - transitivePeerDependencies: - - '@babel/core' - - '@glint/template' - - supports-color - '@ember-data/rfc395-data@0.0.4': {} - '@ember-data/serializer@4.12.8(@babel/core@7.29.0)(@ember-data/store@4.12.8)(@ember/string@3.1.1)(ember-inflector@4.0.3(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))))': - dependencies: - '@ember-data/private-build-infra': 4.12.8 - '@ember-data/store': 4.12.8(@babel/core@7.29.0)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember-data/legacy-compat@4.12.8)(@ember-data/model@4.12.8)(@ember-data/tracking@4.12.8(@babel/core@7.29.0))(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) - '@ember/string': 3.1.1 - '@embroider/macros': 1.20.2(@babel/core@7.29.0) - ember-cli-babel: 7.26.11 - ember-cli-test-info: 1.0.0 - ember-inflector: 4.0.3(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) - transitivePeerDependencies: - - '@babel/core' - - '@glint/template' - - supports-color - - '@ember-data/store@4.12.8(@babel/core@7.29.0)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember-data/legacy-compat@4.12.8)(@ember-data/model@4.12.8)(@ember-data/tracking@4.12.8(@babel/core@7.29.0))(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))': - dependencies: - '@ember-data/private-build-infra': 4.12.8 - '@ember-data/tracking': 4.12.8(@babel/core@7.29.0) - '@ember/string': 3.1.1 - '@embroider/macros': 1.20.2(@babel/core@7.29.0) - '@glimmer/tracking': 1.1.2 - ember-cached-decorator-polyfill: 1.0.2(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) - ember-cli-babel: 7.26.11 - optionalDependencies: - '@ember-data/graph': 4.12.8(@babel/core@7.29.0)(@ember-data/store@4.12.8) - '@ember-data/json-api': 4.12.8(@babel/core@7.29.0)(@ember-data/graph@4.12.8)(@ember-data/store@4.12.8) - '@ember-data/legacy-compat': 4.12.8(@babel/core@7.29.0)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember/string@3.1.1) - '@ember-data/model': 4.12.8(@babel/core@7.29.0)(@ember-data/debug@4.12.8)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember-data/legacy-compat@4.12.8)(@ember-data/store@4.12.8)(@ember-data/tracking@4.12.8(@babel/core@7.29.0))(@ember/string@3.1.1)(ember-inflector@4.0.3(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) - transitivePeerDependencies: - - '@babel/core' - - '@glint/template' - - ember-source - - supports-color - - '@ember-data/tracking@4.12.8(@babel/core@7.29.0)': - dependencies: - '@ember-data/private-build-infra': 4.12.8 - '@embroider/macros': 1.20.2(@babel/core@7.29.0) - ember-cli-babel: 7.26.11 - transitivePeerDependencies: - - '@babel/core' - - '@glint/template' - - supports-color - '@ember-decorators/component@6.1.1': dependencies: '@ember-decorators/utils': 6.1.1 @@ -10048,39 +9542,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@fleetbase/ember-core@0.3.24(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))(eslint@8.57.1)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))': - dependencies: - '@babel/core': 7.29.0 - compress-json: 3.4.0 - date-fns: 2.30.0 - ember-auto-import: 2.13.1(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)) - ember-can: 6.0.0(@babel/core@7.29.0)(@ember/string@3.1.1)(ember-inflector@4.0.3(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) - ember-cli-babel: 8.3.1(@babel/core@7.29.0) - ember-cli-htmlbars: 6.3.0 - ember-cli-notifications: 9.1.0(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) - ember-concurrency: 4.0.6(@babel/core@7.29.0) - ember-decorators: 6.1.1 - ember-get-config: 2.1.1(@babel/core@7.29.0) - ember-inflector: 4.0.3(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) - ember-intl: 6.3.2(@babel/core@7.29.0)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)) - ember-loading: 2.0.0(@babel/core@7.29.0) - ember-local-storage: 2.0.7(@babel/core@7.29.0) - ember-simple-auth: 6.1.0(@babel/core@7.29.0)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))(eslint@8.57.1) - ember-wormhole: 0.6.1 - socketcluster-client: 17.2.2 - transitivePeerDependencies: - - '@ember/string' - - '@ember/test-helpers' - - '@glint/template' - - bufferutil - - ember-resolver - - ember-source - - eslint - - supports-color - - typescript - - utf-8-validate - - webpack - '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 @@ -10092,79 +9553,6 @@ snapshots: '@floating-ui/utils@0.2.11': {} - '@formatjs/ecma402-abstract@2.2.4': - dependencies: - '@formatjs/fast-memoize': 2.2.3 - '@formatjs/intl-localematcher': 0.5.8 - tslib: 2.8.1 - - '@formatjs/ecma402-abstract@2.3.6': - dependencies: - '@formatjs/fast-memoize': 2.2.7 - '@formatjs/intl-localematcher': 0.6.2 - decimal.js: 10.6.0 - tslib: 2.8.1 - - '@formatjs/fast-memoize@2.2.3': - dependencies: - tslib: 2.8.1 - - '@formatjs/fast-memoize@2.2.7': - dependencies: - tslib: 2.8.1 - - '@formatjs/icu-messageformat-parser@2.11.4': - dependencies: - '@formatjs/ecma402-abstract': 2.3.6 - '@formatjs/icu-skeleton-parser': 1.8.16 - tslib: 2.8.1 - - '@formatjs/icu-messageformat-parser@2.9.4': - dependencies: - '@formatjs/ecma402-abstract': 2.2.4 - '@formatjs/icu-skeleton-parser': 1.8.8 - tslib: 2.8.1 - - '@formatjs/icu-skeleton-parser@1.8.16': - dependencies: - '@formatjs/ecma402-abstract': 2.3.6 - tslib: 2.8.1 - - '@formatjs/icu-skeleton-parser@1.8.8': - dependencies: - '@formatjs/ecma402-abstract': 2.2.4 - tslib: 2.8.1 - - '@formatjs/intl-displaynames@6.8.5': - dependencies: - '@formatjs/ecma402-abstract': 2.2.4 - '@formatjs/intl-localematcher': 0.5.8 - tslib: 2.8.1 - - '@formatjs/intl-listformat@7.7.5': - dependencies: - '@formatjs/ecma402-abstract': 2.2.4 - '@formatjs/intl-localematcher': 0.5.8 - tslib: 2.8.1 - - '@formatjs/intl-localematcher@0.5.8': - dependencies: - tslib: 2.8.1 - - '@formatjs/intl-localematcher@0.6.2': - dependencies: - tslib: 2.8.1 - - '@formatjs/intl@2.10.15': - dependencies: - '@formatjs/ecma402-abstract': 2.2.4 - '@formatjs/fast-memoize': 2.2.3 - '@formatjs/icu-messageformat-parser': 2.9.4 - '@formatjs/intl-displaynames': 6.8.5 - '@formatjs/intl-listformat': 7.7.5 - intl-messageformat: 10.7.7 - tslib: 2.8.1 - '@fortawesome/ember-fontawesome@2.0.0(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))(rollup@2.80.0)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))': dependencies: '@fortawesome/fontawesome-svg-core': 6.4.0 @@ -10486,11 +9874,6 @@ snapshots: - webpack-cli - webpack-command - '@mrmlnc/readdir-enhanced@2.2.1': - dependencies: - call-me-maybe: 1.0.2 - glob-to-regexp: 0.3.0 - '@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1': dependencies: eslint-scope: 5.1.1 @@ -10500,8 +9883,6 @@ snapshots: '@nodelib/fs.stat': 2.0.5 run-parallel: 1.2.0 - '@nodelib/fs.stat@1.1.3': {} - '@nodelib/fs.stat@2.0.5': {} '@nodelib/fs.walk@1.2.8': @@ -11105,14 +10486,6 @@ snapshots: acorn@8.16.0: {} - ag-channel@5.0.0: - dependencies: - consumable-stream: 2.0.0 - - ag-request@1.1.0: - dependencies: - sc-errors: 3.0.0 - air-datepicker@3.6.0: {} ajv-errors@1.0.1(ajv@6.15.0): @@ -11301,10 +10674,6 @@ snapshots: transitivePeerDependencies: - supports-color - async-stream-emitter@4.1.0: - dependencies: - stream-demux: 8.1.0 - async@2.6.4: dependencies: lodash: 4.18.1 @@ -11546,8 +10915,6 @@ snapshots: lodash: 4.18.1 to-fast-properties: 1.0.3 - babel6-plugin-strip-class-callcheck@6.0.0: {} - babylon@6.18.0: {} backbone@1.6.1: @@ -11600,11 +10967,6 @@ snapshots: file-uri-to-path: 1.0.0 optional: true - bl@1.2.3: - dependencies: - readable-stream: 2.3.8 - safe-buffer: 5.2.1 - bl@4.1.0: dependencies: buffer: 5.7.1 @@ -11613,8 +10975,6 @@ snapshots: blank-object@1.0.2: {} - blob-polyfill@7.0.20220408: {} - bluebird@3.7.2: {} bn.js@4.12.3: {} @@ -11889,15 +11249,6 @@ snapshots: glob: 5.0.15 mkdirp: 0.5.6 - broccoli-merge-files@0.8.0: - dependencies: - broccoli-plugin: 1.3.1 - fast-glob: 2.2.7 - lodash.defaults: 4.2.0 - p-event: 2.3.1 - transitivePeerDependencies: - - supports-color - broccoli-merge-trees@2.0.1: dependencies: broccoli-plugin: 1.3.1 @@ -12334,8 +11685,6 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 - call-me-maybe@1.0.2: {} - callsites@3.1.0: {} camel-case@4.1.2: @@ -12468,8 +11817,6 @@ snapshots: isobject: 3.0.1 static-extend: 0.1.2 - cldr-core@44.1.0: {} - clean-base-url@1.0.0: {} clean-css@5.3.3: @@ -12512,12 +11859,6 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 - clone-deep@4.0.1: - dependencies: - is-plain-object: 2.0.4 - kind-of: 6.0.3 - shallow-clone: 3.0.1 - clone-response@1.0.3: dependencies: mimic-response: 1.0.1 @@ -12571,8 +11912,6 @@ snapshots: component-emitter@1.3.1: {} - compress-json@3.4.0: {} - compressible@2.0.18: dependencies: mime-db: 1.54.0 @@ -12648,8 +11987,6 @@ snapshots: constants-browserify@1.0.0: {} - consumable-stream@2.0.0: {} - content-disposition@0.5.4: dependencies: safe-buffer: 5.2.1 @@ -12864,8 +12201,6 @@ snapshots: decamelize@5.0.1: {} - decimal.js@10.6.0: {} - decode-uri-component@0.2.2: {} decompress-response@3.3.0: @@ -13151,30 +12486,6 @@ snapshots: - '@glint/template' - supports-color - ember-cache-primitive-polyfill@1.0.1(@babel/core@7.29.0): - dependencies: - ember-cli-babel: 7.26.11 - ember-cli-version-checker: 5.1.2 - ember-compatibility-helpers: 1.2.7(@babel/core@7.29.0) - silent-error: 1.1.1 - transitivePeerDependencies: - - '@babel/core' - - supports-color - - ember-cached-decorator-polyfill@1.0.2(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))): - dependencies: - '@embroider/macros': 1.20.2(@babel/core@7.29.0) - '@glimmer/tracking': 1.1.2 - babel-import-util: 1.4.1 - ember-cache-primitive-polyfill: 1.0.1(@babel/core@7.29.0) - ember-cli-babel: 7.26.11 - ember-cli-babel-plugin-helpers: 1.1.1 - ember-source: 5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)) - transitivePeerDependencies: - - '@babel/core' - - '@glint/template' - - supports-color - ember-can@6.0.0(@babel/core@7.29.0)(@ember/string@3.1.1)(ember-inflector@4.0.3(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))): dependencies: '@ember/string': 3.1.1 @@ -13360,15 +12671,6 @@ snapshots: transitivePeerDependencies: - supports-color - ember-cli-notifications@9.1.0(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))): - dependencies: - '@embroider/addon-shim': 1.10.2 - decorator-transforms: 2.3.2(@babel/core@7.29.0) - ember-source: 5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)) - transitivePeerDependencies: - - '@babel/core' - - supports-color - ember-cli-path-utils@1.0.0: {} ember-cli-postcss@8.2.0: @@ -13411,10 +12713,6 @@ snapshots: transitivePeerDependencies: - supports-color - ember-cli-test-info@1.0.0: - dependencies: - ember-cli-string-utils: 1.1.0 - ember-cli-test-loader@3.1.0: dependencies: ember-cli-babel: 7.26.11 @@ -13737,48 +13035,6 @@ snapshots: - '@babel/core' - supports-color - ember-cookies@1.3.0(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))): - dependencies: - '@embroider/addon-shim': 1.10.2 - ember-source: 5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)) - transitivePeerDependencies: - - supports-color - - ember-copy@2.0.1: - dependencies: - ember-cli-babel: 7.26.11 - transitivePeerDependencies: - - supports-color - - ember-data@4.12.8(@babel/core@7.29.0)(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)): - dependencies: - '@ember-data/adapter': 4.12.8(@babel/core@7.29.0)(@ember-data/store@4.12.8)(@ember/string@3.1.1)(ember-inflector@4.0.3(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))) - '@ember-data/debug': 4.12.8(@babel/core@7.29.0)(@ember-data/store@4.12.8)(@ember/string@3.1.1)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)) - '@ember-data/graph': 4.12.8(@babel/core@7.29.0)(@ember-data/store@4.12.8) - '@ember-data/json-api': 4.12.8(@babel/core@7.29.0)(@ember-data/graph@4.12.8)(@ember-data/store@4.12.8) - '@ember-data/legacy-compat': 4.12.8(@babel/core@7.29.0)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember/string@3.1.1) - '@ember-data/model': 4.12.8(@babel/core@7.29.0)(@ember-data/debug@4.12.8)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember-data/legacy-compat@4.12.8)(@ember-data/store@4.12.8)(@ember-data/tracking@4.12.8(@babel/core@7.29.0))(@ember/string@3.1.1)(ember-inflector@4.0.3(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) - '@ember-data/private-build-infra': 4.12.8 - '@ember-data/request': 4.12.8(@babel/core@7.29.0) - '@ember-data/serializer': 4.12.8(@babel/core@7.29.0)(@ember-data/store@4.12.8)(@ember/string@3.1.1)(ember-inflector@4.0.3(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))) - '@ember-data/store': 4.12.8(@babel/core@7.29.0)(@ember-data/graph@4.12.8)(@ember-data/json-api@4.12.8)(@ember-data/legacy-compat@4.12.8)(@ember-data/model@4.12.8)(@ember-data/tracking@4.12.8(@babel/core@7.29.0))(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) - '@ember-data/tracking': 4.12.8(@babel/core@7.29.0) - '@ember/edition-utils': 1.2.0 - '@ember/string': 3.1.1 - '@embroider/macros': 1.20.2(@babel/core@7.29.0) - '@glimmer/env': 0.1.7 - broccoli-merge-trees: 4.2.0 - ember-auto-import: 2.13.1(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)) - ember-cli-babel: 7.26.11 - ember-inflector: 4.0.3(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) - transitivePeerDependencies: - - '@babel/core' - - '@glimmer/tracking' - - '@glint/template' - - ember-source - - supports-color - - webpack - ember-decorators@6.1.1: dependencies: '@ember-decorators/component': 6.1.1 @@ -13903,34 +13159,6 @@ snapshots: transitivePeerDependencies: - supports-color - ember-intl@6.3.2(@babel/core@7.29.0)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)): - dependencies: - '@formatjs/icu-messageformat-parser': 2.11.4 - '@formatjs/intl': 2.10.15 - broccoli-caching-writer: 3.1.0 - broccoli-funnel: 3.0.8 - broccoli-merge-files: 0.8.0 - broccoli-merge-trees: 4.2.0 - broccoli-source: 3.0.1 - broccoli-stew: 3.0.0 - calculate-cache-key-for-tree: 2.0.0 - cldr-core: 44.1.0 - ember-auto-import: 2.13.1(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)) - ember-cli-babel: 8.3.1(@babel/core@7.29.0) - ember-cli-typescript: 5.3.0 - eventemitter3: 5.0.4 - extend: 3.0.2 - fast-memoize: 2.5.2 - intl-messageformat: 10.7.18 - js-yaml: 4.1.1 - json-stable-stringify: 1.3.0 - silent-error: 1.1.1 - transitivePeerDependencies: - - '@babel/core' - - '@glint/template' - - supports-color - - webpack - ember-leaflet@5.1.3(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))(leaflet@1.9.4)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)): dependencies: '@glimmer/component': 1.1.2(@babel/core@7.29.0) @@ -13980,21 +13208,6 @@ snapshots: - '@babel/core' - supports-color - ember-local-storage@2.0.7(@babel/core@7.29.0): - dependencies: - blob-polyfill: 7.0.20220408 - broccoli-funnel: 3.0.8 - broccoli-merge-trees: 4.2.0 - broccoli-stew: 3.0.0 - ember-cli-babel: 7.26.11 - ember-cli-string-utils: 1.1.0 - ember-cli-version-checker: 5.1.2 - ember-copy: 2.0.1 - ember-destroyable-polyfill: 2.0.3(@babel/core@7.29.0) - transitivePeerDependencies: - - '@babel/core' - - supports-color - ember-math-helpers@4.2.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))): dependencies: '@embroider/addon-shim': 1.10.2 @@ -14136,24 +13349,6 @@ snapshots: transitivePeerDependencies: - supports-color - ember-simple-auth@6.1.0(@babel/core@7.29.0)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))(eslint@8.57.1): - dependencies: - '@babel/eslint-parser': 7.28.6(@babel/core@7.29.0)(eslint@8.57.1) - '@ember/test-waiters': 3.1.0 - '@embroider/addon-shim': 1.10.2 - '@embroider/macros': 1.20.2(@babel/core@7.29.0) - ember-cli-is-package-missing: 1.0.0 - ember-cookies: 1.3.0(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14))) - silent-error: 1.1.1 - optionalDependencies: - '@ember/test-helpers': 3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)))(webpack@5.106.2(clean-css@5.3.3)(postcss@8.5.14)) - transitivePeerDependencies: - - '@babel/core' - - '@glint/template' - - ember-source - - eslint - - supports-color - ember-source-channel-url@3.0.0: dependencies: node-fetch: 2.7.0 @@ -14696,8 +13891,6 @@ snapshots: eventemitter3@4.0.7: {} - eventemitter3@5.0.4: {} - events-to-array@2.0.3: {} events@3.3.0: {} @@ -14866,8 +14059,6 @@ snapshots: assign-symbols: 1.0.0 is-extendable: 1.0.1 - extend@3.0.2: {} - external-editor@3.1.0: dependencies: chardet: 0.7.0 @@ -14893,17 +14084,6 @@ snapshots: fast-diff@1.3.0: {} - fast-glob@2.2.7: - dependencies: - '@mrmlnc/readdir-enhanced': 2.2.1 - '@nodelib/fs.stat': 1.1.3 - glob-parent: 3.1.0 - is-glob: 4.0.3 - merge2: 1.4.1 - micromatch: 3.1.10 - transitivePeerDependencies: - - supports-color - fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -14916,8 +14096,6 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-memoize@2.5.2: {} - fast-ordered-set@1.0.3: dependencies: blank-object: 1.0.2 @@ -15355,6 +14533,7 @@ snapshots: dependencies: is-glob: 3.1.0 path-dirname: 1.0.2 + optional: true glob-parent@5.1.2: dependencies: @@ -15364,8 +14543,6 @@ snapshots: dependencies: is-glob: 4.0.3 - glob-to-regexp@0.3.0: {} - glob-to-regexp@0.4.1: {} glob@10.5.0: @@ -15790,20 +14967,6 @@ snapshots: hasown: 2.0.3 side-channel: 1.1.0 - intl-messageformat@10.7.18: - dependencies: - '@formatjs/ecma402-abstract': 2.3.6 - '@formatjs/fast-memoize': 2.2.7 - '@formatjs/icu-messageformat-parser': 2.11.4 - tslib: 2.8.1 - - intl-messageformat@10.7.7: - dependencies: - '@formatjs/ecma402-abstract': 2.2.4 - '@formatjs/fast-memoize': 2.2.3 - '@formatjs/icu-messageformat-parser': 2.9.4 - tslib: 2.8.1 - intl-tel-input@22.0.2: {} invariant@2.2.4: @@ -15922,6 +15085,7 @@ snapshots: is-glob@3.1.0: dependencies: is-extglob: 2.1.1 + optional: true is-glob@4.0.3: dependencies: @@ -16183,8 +15347,6 @@ snapshots: lines-and-columns@1.2.4: {} - linked-list@0.1.0: {} - linkify-it@4.0.1: dependencies: uc.micro: 1.0.6 @@ -16241,8 +15403,6 @@ snapshots: lodash.debounce@4.0.8: {} - lodash.defaults@4.2.0: {} - lodash.defaultsdeep@4.6.1: {} lodash.kebabcase@4.1.1: {} @@ -16718,8 +15878,6 @@ snapshots: normalize-url@4.5.1: {} - npm-git-info@1.0.3: {} - npm-package-arg@10.1.0: dependencies: hosted-git-info: 6.1.3 @@ -16855,10 +16013,6 @@ snapshots: p-defer@3.0.0: {} - p-event@2.3.1: - dependencies: - p-timeout: 2.0.1 - p-finally@1.0.0: {} p-finally@2.0.1: {} @@ -16901,10 +16055,6 @@ snapshots: dependencies: p-limit: 4.0.0 - p-timeout@2.0.1: - dependencies: - p-finally: 1.0.0 - p-try@1.0.0: {} p-try@2.2.0: {} @@ -16964,7 +16114,8 @@ snapshots: path-browserify@0.0.1: {} - path-dirname@1.0.2: {} + path-dirname@1.0.2: + optional: true path-exists@3.0.0: {} @@ -17995,12 +17146,6 @@ snapshots: minimist: 1.2.8 walker: 1.0.8 - sc-errors@2.0.3: {} - - sc-errors@3.0.0: {} - - sc-formatter@4.0.0: {} - schema-utils@1.0.0: dependencies: ajv: 6.15.0 @@ -18129,10 +17274,6 @@ snapshots: safe-buffer: 5.2.1 to-buffer: 1.2.2 - shallow-clone@3.0.1: - dependencies: - kind-of: 6.0.3 - shebang-command@1.2.0: dependencies: shebang-regex: 1.0.0 @@ -18259,24 +17400,6 @@ snapshots: - supports-color - utf-8-validate - socketcluster-client@17.2.2: - dependencies: - ag-channel: 5.0.0 - ag-request: 1.1.0 - async-stream-emitter: 4.1.0 - buffer: 5.7.1 - clone-deep: 4.0.1 - linked-list: 0.1.0 - sc-errors: 2.0.3 - sc-formatter: 4.0.0 - stream-demux: 8.1.0 - uuid: 8.3.2 - vinyl-buffer: 1.0.1 - ws: 8.18.3 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - sort-object-keys@1.1.3: {} sort-package-json@1.57.0: @@ -18378,11 +17501,6 @@ snapshots: inherits: 2.0.4 readable-stream: 2.3.8 - stream-demux@8.1.0: - dependencies: - consumable-stream: 2.0.0 - writable-consumable-stream: 3.0.1 - stream-each@1.2.3: dependencies: end-of-stream: 1.4.5 @@ -19164,11 +18282,6 @@ snapshots: vary@1.1.2: {} - vinyl-buffer@1.0.1: - dependencies: - bl: 1.2.3 - through2: 2.0.5 - vm-browserify@1.1.2: {} w3c-keyname@2.2.8: {} @@ -19418,10 +18531,6 @@ snapshots: wrappy@1.0.2: {} - writable-consumable-stream@3.0.1: - dependencies: - consumable-stream: 2.0.0 - write-file-atomic@3.0.3: dependencies: imurmurhash: 0.1.4 diff --git a/tests/dummy/app/services/fetch.js b/tests/dummy/app/services/fetch.js deleted file mode 100644 index 163f5b7e..00000000 --- a/tests/dummy/app/services/fetch.js +++ /dev/null @@ -1,53 +0,0 @@ -import Service from '@ember/service'; - -/** - * Stub of the host console's `fetch` service. Never performs network requests. - * - * Responses can be primed per-path for assertions: `this.owner.lookup('service:fetch').responses['geocoder/query'] = { ... };` - * Every call is recorded on `calls` as `{ method, args }`. - */ -export default class FetchService extends Service { - calls = []; - - /** Map of path -> canned response used by `get` and `post`. */ - responses = {}; - - get(path, params = {}, options = {}) { - this.calls.push({ method: 'get', args: [path, params, options] }); - return Promise.resolve(this.responses[path] ?? []); - } - - post(path, body = {}, options = {}) { - this.calls.push({ method: 'post', args: [path, body, options] }); - return Promise.resolve(this.responses[path] ?? {}); - } - - download(path, params = {}, options = {}) { - this.calls.push({ method: 'download', args: [path, params, options] }); - return Promise.resolve(); - } - - /** Pseudo ember-concurrency task: `this.fetch.uploadFile.perform(file, params, onSuccess, onError)`. */ - uploadFile = { - isRunning: false, - isIdle: true, - perform: (file, params = {}, onSuccess, onError) => { - this.calls.push({ method: 'uploadFile.perform', args: [file, params, onSuccess, onError] }); - - const uploadedFile = { - id: 'test-file-1', - filename: file?.name ?? 'test-file.txt', - content_type: file?.type ?? 'text/plain', - url: 'https://example.test/uploads/test-file.txt', - save: () => Promise.resolve(uploadedFile), - destroyRecord: () => Promise.resolve(uploadedFile), - }; - - if (typeof onSuccess === 'function') { - onSuccess(uploadedFile); - } - - return Promise.resolve(uploadedFile); - }, - }; -} diff --git a/tests/dummy/app/services/universe.js b/tests/dummy/app/services/universe.js deleted file mode 100644 index e0e57247..00000000 --- a/tests/dummy/app/services/universe.js +++ /dev/null @@ -1,33 +0,0 @@ -import { inject as service } from '@ember/service'; -import StubEventedService from '../utils/stub-evented-service'; - -/** - * Stub of the host console's `universe` extension registry service. - * Menu-item arrays are empty; registry lookups return empty arrays; `on`/`off`/`trigger` work. - */ -export default class UniverseService extends StubEventedService { - @service('universe/menu-service') menuService; - @service('universe/widget-service') widgetService; - @service('universe/registry-service') registryService; - @service('universe/extension-manager') extensionManager; - - calls = []; - - headerMenuItems = []; - organizationMenuItems = []; - userMenuItems = []; - - registerDashboard(dashboardId) { - this.calls.push({ method: 'registerDashboard', args: [dashboardId] }); - } - - getMenuItemsFromRegistry(registryName) { - this.calls.push({ method: 'getMenuItemsFromRegistry', args: [registryName] }); - return []; - } - - getRenderableComponentsFromRegistry(registryName) { - this.calls.push({ method: 'getRenderableComponentsFromRegistry', args: [registryName] }); - return []; - } -} diff --git a/tests/dummy/app/services/universe/widget-service.js b/tests/dummy/app/services/universe/widget-service.js deleted file mode 100644 index 778aed24..00000000 --- a/tests/dummy/app/services/universe/widget-service.js +++ /dev/null @@ -1,23 +0,0 @@ -import Service from '@ember/service'; - -/** - * Stub of the host console's `universe/widget-service`. Widget registrations - * are recorded and lookups return empty arrays. - */ -export default class UniverseWidgetService extends Service { - calls = []; - widgets = {}; - - registerWidgets(dashboardId, widgets) { - this.calls.push({ method: 'registerWidgets', args: [dashboardId, widgets] }); - this.widgets[dashboardId] = [...(this.widgets[dashboardId] ?? []), ...(Array.isArray(widgets) ? widgets : [widgets])]; - } - - getWidgets(dashboardId) { - return this.widgets[dashboardId] ?? []; - } - - getDefaultWidgets() { - return []; - } -} diff --git a/tests/dummy/app/utils/stub-evented-service.js b/tests/dummy/app/utils/stub-evented-service.js deleted file mode 100644 index 42317e4f..00000000 --- a/tests/dummy/app/utils/stub-evented-service.js +++ /dev/null @@ -1,34 +0,0 @@ -import Service from '@ember/service'; - -/** - * Base class for stub services that need an Evented-like surface (`on`, `off`, `trigger`) - * without pulling in the Ember Evented mixin. - */ -export default class StubEventedService extends Service { - _listeners = new Map(); - - on(eventName, callback) { - if (!this._listeners.has(eventName)) { - this._listeners.set(eventName, []); - } - this._listeners.get(eventName).push(callback); - return this; - } - - off(eventName, callback) { - const listeners = this._listeners.get(eventName) ?? []; - const index = listeners.indexOf(callback); - if (index > -1) { - listeners.splice(index, 1); - } - return this; - } - - trigger(eventName, ...args) { - const listeners = [...(this._listeners.get(eventName) ?? [])]; - for (const listener of listeners) { - listener(...args); - } - return this; - } -} diff --git a/tests/integration/components/table/cell/resource-identity-test.js b/tests/integration/components/table/cell/resource-identity-test.js index 1b4cb216..81ffdbe2 100644 --- a/tests/integration/components/table/cell/resource-identity-test.js +++ b/tests/integration/components/table/cell/resource-identity-test.js @@ -30,7 +30,6 @@ module('Integration | Component | table/cell/resource-identity', function (hooks assert.dom('.table-cell-resource-identity').exists(); assert.dom('[data-test-resource-identity-image]').hasAttribute('src', 'https://example.com/truck.png'); assert.dom('button').hasClass('items-start'); - assert.dom('button').doesNotHaveClass('py-0.5'); assert.dom('[data-test-resource-identity-image]').hasClass('h-7'); assert.dom('[data-test-resource-identity-image]').hasClass('w-7'); assert.dom('[data-test-resource-identity-image]').hasClass('border'); @@ -133,8 +132,8 @@ module('Integration | Component | table/cell/resource-identity', function (hooks await render(hbs``); assert.dom('[data-test-resource-identity-meta-badge]').exists({ count: 2 }); - assert.dom('[data-test-resource-identity-meta-badge]').includesText('+1 555 0100'); - assert.dom('[data-test-resource-identity-meta-badge]').includesText('Van 12'); + assert.dom('[data-test-resource-identity-meta-badge]:nth-of-type(1)').includesText('+1 555 0100'); + assert.dom('[data-test-resource-identity-meta-badge]:nth-of-type(2)').includesText('Van 12'); assert.dom('[data-test-resource-identity-status-badge]').exists(); assert.dom('[data-test-resource-identity-status-badge]').hasClass('status-badge-xxs'); }); From 6e41b60a4240da81b6f3ef1f17426c36e85e263e Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 16 Sep 2026 07:19:10 +0800 Subject: [PATCH 31/40] Draw fallback UUID bytes from crypto, not Math.random CodeQL flagged the Math.random fallback in generate-uuid; use crypto.getRandomValues when randomUUID is unavailable. --- addon/utils/generate-uuid.js | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/addon/utils/generate-uuid.js b/addon/utils/generate-uuid.js index 471914ad..a82b12e9 100644 --- a/addon/utils/generate-uuid.js +++ b/addon/utils/generate-uuid.js @@ -1,16 +1,18 @@ /** - * A v4 UUID, from the platform when it has one and otherwise from - * Math.random, for records that arrive without an id. + * A v4 UUID for records that arrive without an id, always from the + * platform's cryptographic random source. */ export default function generateUuid() { - if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + if (typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); } - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { - const r = (Math.random() * 16) | 0; - const v = c === 'x' ? r : (r & 0x3) | 0x8; + const bytes = crypto.getRandomValues(new Uint8Array(16)); - return v.toString(16); - }); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); + + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } From 434196faae278ddde4a93fb8135b44b2ade67c77 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 16 Sep 2026 11:16:49 +0800 Subject: [PATCH 32/40] Fix CSS build performance with linked addons --- index.js | 48 +++------------- lib/postcss-options.js | 45 +++++++++++++++ lib/tailwind-content.js | 43 ++++++++++++++ scripts/check-tailwind-content.js | 93 +++++++++++++++++++++++++++++++ tailwind.config.js | 13 +---- tests/node/postcss-build-test.cjs | 85 ++++++++++++++++++++++++++++ 6 files changed, 278 insertions(+), 49 deletions(-) create mode 100644 lib/postcss-options.js create mode 100644 lib/tailwind-content.js create mode 100644 scripts/check-tailwind-content.js create mode 100644 tests/node/postcss-build-test.cjs diff --git a/index.js b/index.js index f3b3b6d2..89c50aaf 100644 --- a/index.js +++ b/index.js @@ -4,38 +4,7 @@ const Funnel = require('broccoli-funnel'); const MergeTrees = require('broccoli-merge-trees'); const resolve = require('resolve'); const path = require('path'); -const postcssImport = require('postcss-import'); -const postcssPresetEnv = require('postcss-preset-env'); -const postcssEach = require('postcss-each'); -const postcssMixins = require('postcss-mixins'); -const postcssConditionals = require('postcss-conditionals-renewed'); -const postcssAtRulesVariables = require('postcss-at-rules-variables'); -const autoprefixer = require('autoprefixer'); -const tailwind = require('tailwindcss'); - -const tailwindConfigPath = path.resolve(__dirname, 'tailwind.config.js'); -const postcssOptions = { - compile: { - enabled: true, - cacheInclude: [/.*\.(css|scss|hbs)$/, /.*\/tailwind\/config\.js$/, /.*tailwind\.js$/], - plugins: [ - postcssAtRulesVariables, - postcssImport({ - path: ['node_modules', path.join(__dirname, 'addon/styles')], - plugins: [postcssAtRulesVariables, postcssImport], - }), - postcssMixins, - postcssPresetEnv({ stage: 1 }), - postcssEach, - tailwind(tailwindConfigPath), - autoprefixer, - ], - }, - filter: { - enabled: true, - plugins: [postcssAtRulesVariables, postcssMixins, postcssEach, postcssConditionals, tailwind(tailwindConfigPath)], - }, -}; +const buildPostcssOptions = require('./lib/postcss-options'); module.exports = { name, @@ -52,12 +21,11 @@ module.exports = { excludeJS: true, excludeImages: true, }, - postcssOptions, }, treeForStyles: function () { - // Only provide styles to the root application, not to engines - // This prevents engines from trying to compile ember-ui styles + // Only expose the app styles tree to the root application. Addon styles + // are compiled separately by Ember's treeForAddon/compileStyles hooks. let parent = this.parent; while (parent) { const isEngine = parent.lazyLoading === true || (parent.lazyLoading && parent.lazyLoading.enabled === true); @@ -73,16 +41,18 @@ module.exports = { }, included: function (app) { + // Configure the addon's compiler before its ember-cli-postcss child is + // included, including when this instance belongs to an engine. + const postcssOptions = buildPostcssOptions(this.project.targets && this.project.targets.browsers); + this.options.postcssOptions = postcssOptions; this._super.included.apply(this, arguments); - // Check if we're being included by an engine - // If so, skip setting postcssOptions to prevent engines from trying to compile our styles + // Nested engine instances configure their own compiler above, but must + // not overwrite host options or import the shared vendor assets again. let parent = this.parent; while (parent) { const isEngine = parent.lazyLoading === true || (parent.lazyLoading && parent.lazyLoading.enabled === true); if (isEngine) { - // We're in an engine - don't set postcssOptions - // The engine will inherit from the host app return; } parent = parent.parent; diff --git a/lib/postcss-options.js b/lib/postcss-options.js new file mode 100644 index 00000000..54700538 --- /dev/null +++ b/lib/postcss-options.js @@ -0,0 +1,45 @@ +/* eslint-env node */ +'use strict'; + +const path = require('path'); +const postcssImport = require('postcss-import'); +const postcssPresetEnv = require('postcss-preset-env'); +const postcssEach = require('postcss-each'); +const postcssMixins = require('postcss-mixins'); +const postcssConditionals = require('postcss-conditionals-renewed'); +const postcssAtRulesVariables = require('postcss-at-rules-variables'); +const autoprefixer = require('autoprefixer'); +const tailwind = require('tailwindcss'); + +module.exports = function buildPostcssOptions(browsers) { + const tailwindConfigPath = path.resolve(__dirname, '../tailwind.config.js'); + + return { + compile: { + enabled: true, + // The host tree contains compiled addon templates as JavaScript. + // Include them so new utility classes invalidate the CSS cache. + cacheInclude: [/.*\.(css|scss|hbs|js)$/], + plugins: [ + postcssAtRulesVariables, + postcssImport({ + path: ['node_modules', path.resolve(__dirname, '../addon/styles')], + plugins: [postcssAtRulesVariables, postcssImport], + }), + postcssMixins, + // Ember CLI's targets are not automatically forwarded to an + // instantiated preset. Avoid polyfilling selectors the host supports. + postcssPresetEnv({ stage: 1, browsers, autoprefixer: false }), + postcssEach, + tailwind(tailwindConfigPath), + autoprefixer({ overrideBrowserslist: browsers }), + ], + }, + filter: { + enabled: true, + // Conditionals must run after compile has expanded loop variables + // and mixins. Tailwind and the expanders have already finished. + plugins: [postcssConditionals], + }, + }; +}; diff --git a/lib/tailwind-content.js b/lib/tailwind-content.js new file mode 100644 index 00000000..c05ecd36 --- /dev/null +++ b/lib/tailwind-content.js @@ -0,0 +1,43 @@ +/* eslint-env node */ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +function entries(directory) { + try { + return fs.readdirSync(directory); + } catch (error) { + if (error.code === 'ENOENT' || error.code === 'ENOTDIR') { + return []; + } + throw error; + } +} + +// Resolve only the consumer's direct packages. Recursive node_modules globs follow +// pnpm links into dependency graphs and linked packages' development worktrees. +module.exports = function addonContent(root = process.cwd()) { + const modules = path.resolve(root, 'node_modules'); + const packages = entries(modules).flatMap((name) => (name.startsWith('@') ? entries(path.join(modules, name)).map((child) => `${name}/${child}`) : [name])); + const roots = new Set(); + + for (const name of packages) { + if (!name.startsWith('@fleetbase/') && !name.endsWith('-engine')) { + continue; + } + const addon = path.join(modules, name, 'addon'); + if (fs.existsSync(addon)) { + roots.add(fs.realpathSync(addon)); + } + } + + // Keep globs within each source tree so newly created templates are watched. + return [...roots].sort().map( + (directory) => + `${directory + .split(path.sep) + .join('/') + .replace(/[!*?{}()[\]]/g, '\\$&')}/**/*.{hbs,js}` + ); +}; diff --git a/scripts/check-tailwind-content.js b/scripts/check-tailwind-content.js new file mode 100644 index 00000000..2d1d8e56 --- /dev/null +++ b/scripts/check-tailwind-content.js @@ -0,0 +1,93 @@ +/* eslint-env node */ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const vm = require('node:vm'); +const { createRequire } = require('node:module'); +const glob = createRequire(require.resolve('tailwindcss'))('fast-glob'); + +// Accept another independently packaged configuration to run the same contract +// against console without making console depend on a new ember-ui release. +const configPath = path.resolve(process.argv[2] || path.join(__dirname, '../tailwind.config.js')); +const configRequire = createRequire(configPath); +const temporary = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tailwind-content-'))); +const root = path.join(temporary, 'host [fixture]'); +const originalCwd = process.cwd(); + +function write(file, text = '
') { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, text); +} + +function link(name, packageRoot) { + const target = path.join(root, 'node_modules', name); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.symlinkSync(packageRoot, target, 'dir'); +} + +try { + write(path.join(root, 'app', 'host.hbs')); + const registry = path.join(root, 'node_modules/.pnpm/@fleetbase+example@1.0.0/node_modules/@fleetbase/example'); + const linked = path.join(temporary, 'linked-package'); + const scoped = path.join(temporary, 'scoped-engine'); + const unscoped = path.join(temporary, 'unscoped-engine'); + const ignored = path.join(temporary, 'unrelated-package'); + + for (const packageRoot of [registry, linked, scoped, unscoped, ignored]) { + write(path.join(packageRoot, 'addon', 'component.hbs')); + write(path.join(packageRoot, 'node_modules/nested-engine/addon', 'must-not-scan.hbs')); + write(path.join(packageRoot, '.claude/worktrees/example/addon', 'must-not-scan.hbs')); + write(path.join(packageRoot, 'tests/dummy/app', 'must-not-scan.hbs')); + } + link('@fleetbase/example', registry); + link('@fleetbase/linked', linked); + link('@fleetbase/linked-alias', linked); + link('@partner/example-engine', scoped); + link('example-engine', unscoped); + link('@partner/unrelated', ignored); + // Model the cyclic dependency links that broad node_modules globs follow. + fs.symlinkSync(path.join(root, 'node_modules'), path.join(linked, 'node_modules', 'cycle'), 'dir'); + process.chdir(root); + + const context = { + module: { exports: {} }, + __dirname: root, + require(name) { + return name === '@tailwindcss/forms' ? () => {} : configRequire(name); + }, + }; + vm.runInNewContext(fs.readFileSync(configPath, 'utf8'), context, { filename: configPath }); + const content = context.module.exports.content; + const patterns = Array.from(Array.isArray(content) ? content : content.files); + const expected = [path.join(root, 'app/host.hbs'), ...[registry, linked, scoped, unscoped].map((packageRoot) => path.join(packageRoot, 'addon/component.hbs'))].sort(); + const visited = []; + const scan = () => + glob + .sync(patterns, { + cwd: root, + absolute: true, + fs: { + ...fs, + readdirSync(directory, options) { + visited.push(directory); + assert(visited.length < 100, 'content scanning escaped the source trees'); + assert(!directory.includes('/.claude/'), 'content scanning entered a development worktree'); + assert(!directory.includes('/node_modules/nested-engine'), 'content scanning entered a nested dependency'); + return fs.readdirSync(directory, options); + }, + }, + }) + .sort(); + + assert.deepEqual(scan(), expected, 'include host, linked, registry, scoped and unscoped engines exactly once'); + write(path.join(linked, 'addon/new-template.hbs')); + expected.push(path.join(linked, 'addon/new-template.hbs')); + assert.deepEqual(scan(), expected.sort(), 'existing patterns must discover newly created templates'); + console.log(`PASS ${configPath}: linked/registry coverage, deduplication, bounded traversal and new template discovery`); +} finally { + process.chdir(originalCwd); + fs.rmSync(temporary, { recursive: true, force: true }); +} diff --git a/tailwind.config.js b/tailwind.config.js index 94b914c0..d729e014 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -5,16 +5,9 @@ module.exports = { './app/**/*.{hbs,js}', './addon/**/*.{hbs,js}', './addon/**/*.css', - // All Fleetbase packages (both pnpm symlinked and normal installs) - './node_modules/@fleetbase+*/**/addon/**/*.{hbs,js}', - './node_modules/@fleetbase/**/addon/**/*.{hbs,js}', - // Explicit ember-ui paths - './node_modules/@fleetbase/ember-ui/addon/templates/**/*.{hbs,js}', - './node_modules/@fleetbase/ember-ui/addon/components/**/*.{hbs,js}', - // Explicit support for .pnpm paths (for unhoisted deps) - './node_modules/.pnpm/@fleetbase+*/**/addon/**/*.{hbs,js}', - // Engine packages - './node_modules/**/*-engine/addon/**/*.{hbs,js}', + // The addon also configures its host application, so resolve installed + // packages from the build cwd, matching the local app/addon paths above. + ...require('./lib/tailwind-content')(), ], safelist: [ { diff --git a/tests/node/postcss-build-test.cjs b/tests/node/postcss-build-test.cjs new file mode 100644 index 00000000..3d20e185 --- /dev/null +++ b/tests/node/postcss-build-test.cjs @@ -0,0 +1,85 @@ +/* eslint-env node */ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { createRequire } = require('node:module'); +const { test } = require('node:test'); +const postcss = createRequire(require.resolve('broccoli-postcss-single'))('postcss'); +const tailwind = require('tailwindcss'); +const buildPostcssOptions = require('../../lib/postcss-options'); +const browsers = ['last 1 Chrome versions', 'last 1 Firefox versions', 'last 1 Safari versions']; + +async function compile(css, from) { + const options = buildPostcssOptions(browsers); + const config = { ...require('../../tailwind.config'), content: [{ raw: '
', extension: 'html' }] }; + const plugins = options.compile.plugins.map((plugin) => (plugin.postcssPlugin === 'tailwindcss' ? tailwind(config) : plugin)); + const compiled = await postcss(plugins).process(css, { from, map: false }); + const filtered = await postcss(options.filter.plugins).process(compiled.css, { from, map: false }); + return { compiled, filtered }; +} + +test('dark @apply selectors compile for the host browsers without lossy :is() warnings', async () => { + const { compiled, filtered } = await compile('.condition-content select:focus { @apply dark:bg-gray-900; }'); + assert.equal(compiled.warnings().length, 0); + assert.match(filtered.css, /data-theme/); + assert.match(filtered.css, /select:focus/); + assert.match(filtered.css, /background-color/); + assert.doesNotMatch(filtered.css, /@apply/); +}); + +test('tooltip loops and mixins finish before directional conditionals are evaluated', async () => { + const from = path.resolve(__dirname, '../../addon/styles/components/attacher.css'); + const { filtered } = await compile(fs.readFileSync(from, 'utf8'), from); + const rotations = new Map(); + filtered.root.walkDecls('transform', (decl) => { + if (!decl.value.startsWith('rotate(')) return; + let node = decl.parent; + while (node) { + const side = node.selector && node.selector.match(/x-placement\^=['"]?(top|bottom|left|right)/); + if (side) rotations.set(side[1], decl.value); + node = node.parent; + } + }); + assert.deepEqual(Object.fromEntries(rotations), { + bottom: 'rotate(135deg)', + top: 'rotate(-45deg)', + right: 'rotate(45deg)', + left: 'rotate(225deg)', + }); + filtered.root.walkAtRules((rule) => { + assert.ok(!['each', 'mixin', 'define-mixin', 'if', 'else', 'apply'].includes(rule.name), `Unprocessed @${rule.name}`); + }); +}); + +test('JavaScript utility changes invalidate the Broccoli CSS cache', async () => { + const { Builder } = createRequire(require.resolve('ember-cli'))('broccoli'); + const PostcssCompiler = require('broccoli-postcss-single'); + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ember-ui-css-rebuild-')); + let builder; + try { + fs.writeFileSync(path.join(directory, 'app.css'), '@tailwind utilities;'); + const source = path.join(directory, 'component.js'); + fs.writeFileSync(source, 'const className = "w-[13px]";'); + const options = buildPostcssOptions(browsers).compile; + options.plugins = [tailwind({ content: [path.join(directory, '*.js')], corePlugins: { preflight: false } })]; + builder = new Builder(new PostcssCompiler([directory], 'app.css', 'app.css', options)); + await builder.build(); + assert.match(fs.readFileSync(path.join(builder.outputPath, 'app.css'), 'utf8'), /width: 13px/); + + fs.writeFileSync(source, 'const className = "w-[29px]";'); + const later = new Date(Date.now() + 1000); + fs.utimesSync(source, later, later); + await builder.build(); + assert.match(fs.readFileSync(path.join(builder.outputPath, 'app.css'), 'utf8'), /width: 29px/); + + fs.writeFileSync(path.join(directory, 'new-component.js'), 'const className = "h-[37px]";'); + await builder.build(); + assert.match(fs.readFileSync(path.join(builder.outputPath, 'app.css'), 'utf8'), /height: 37px/); + } finally { + if (builder) await builder.cleanup(); + fs.rmSync(directory, { recursive: true, force: true }); + } +}); From b1ae64961e52f4907beb9e12ffc54312772451e4 Mon Sep 17 00:00:00 2001 From: Ron Date: Wed, 16 Sep 2026 11:24:19 +0800 Subject: [PATCH 33/40] Potential fix for pull request finding 'CodeQL / Incomplete string escaping or encoding' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- lib/tailwind-content.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/tailwind-content.js b/lib/tailwind-content.js index c05ecd36..3d94ea48 100644 --- a/lib/tailwind-content.js +++ b/lib/tailwind-content.js @@ -38,6 +38,6 @@ module.exports = function addonContent(root = process.cwd()) { `${directory .split(path.sep) .join('/') - .replace(/[!*?{}()[\]]/g, '\\$&')}/**/*.{hbs,js}` + .replace(/[\\!*?{}()[\]]/g, '\\$&')}/**/*.{hbs,js}` ); }; From 0a35b87512511fe14499b73c397fcf07ef2758d0 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 16 Sep 2026 14:03:40 +0800 Subject: [PATCH 34/40] Keep an interactive popover open while the pointer crosses into it The document mousemove handler hid the popover as soon as the pointer left the target, so the gap between a cell and its card closed the card before the View button could be reached. Leaving now schedules the hide, moving back over the target or onto the floating element cancels it, and hide() clears the pending state and the listener. --- addon/components/attach/popover.js | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/addon/components/attach/popover.js b/addon/components/attach/popover.js index ee465c6e..d266e411 100644 --- a/addon/components/attach/popover.js +++ b/addon/components/attach/popover.js @@ -172,6 +172,12 @@ export default class AttachPopoverComponent extends Component { const { floatingElement } = this; this.isShown = false; + this.hidePending = false; + + if (this.hideListenersOnDocumentByEvent.mousemove) { + delete this.hideListenersOnDocumentByEvent.mousemove; + document.removeEventListener('mousemove', this.hideIfMouseOutsideTargetOrAttachment, this.useCapture); + } if (!floatingElement) { this.animationTimeout = requestAnimationFrame(() => { @@ -221,13 +227,19 @@ export default class AttachPopoverComponent extends Component { return; } - // If cursor is not on the attachment or target, hide the popover - if (!target.contains(event.target) && !(this.isOffset && this.isCursorBetweenTargetAndAttachment(event)) && this.floatingElement && !this.floatingElement.contains(event.target)) { - // Remove this listener before hiding the attachment - delete this.hideListenersOnDocumentByEvent.mousemove; - document.removeEventListener('mousemove', this.hideIfMouseOutsideTargetOrAttachment, this.useCapture); + const outside = + !target.contains(event.target) && !(this.isOffset && this.isCursorBetweenTargetAndAttachment(event)) && this.floatingElement && !this.floatingElement.contains(event.target); - this.hideAfterDelay(); + if (outside) { + // Start the delayed hide once and keep listening, so re-entering the + // target or the attachment within the delay cancels it. + if (!this.hidePending) { + this.hidePending = true; + this.hideAfterDelay(); + } + } else if (this.hidePending) { + cancel(this.delayedVisibilityToggle); + this.hidePending = false; } } @@ -356,6 +368,7 @@ export default class AttachPopoverComponent extends Component { @action showAfterDelay() { cancel(this.delayedVisibilityToggle); + this.hidePending = false; this.mustRender = true; this.addListenersForHideEvents(); From fc5c298349d77e4e09f9ea7db42eb801cc73809c Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 16 Sep 2026 14:03:41 +0800 Subject: [PATCH 35/40] Compact the resource summary card and match the console theme The card is now a 17rem column with a 36px image, the title and identifier, the status badge at the right and a two-column fact list, with facts that merely repeat the title, identifier or status dropped. It carries a 1px gray-200 border on white in light mode and gray-700 on gray-900 in dark mode, opens with the scale animation, and shows the online state as a dot at the image's top-left corner like the identity cell instead of text. Date facts go through date-fns. View opens the resource before the card closes, so the click reaches its target. --- addon/components/resource/hover-card.hbs | 2 +- addon/components/resource/summary.hbs | 21 +++---- addon/components/resource/summary.js | 22 ++++--- addon/styles/components/resource-identity.css | 63 +++++++++++-------- addon/utils/resource-identity.js | 3 +- 5 files changed, 61 insertions(+), 50 deletions(-) diff --git a/addon/components/resource/hover-card.hbs b/addon/components/resource/hover-card.hbs index d3a00d66..f39d346d 100644 --- a/addon/components/resource/hover-card.hbs +++ b/addon/components/resource/hover-card.hbs @@ -3,7 +3,7 @@ {{else}}
- +
{{/if}} + {{#if this.hasOnline}} + + {{/if}}
{{this.title}}
{{#if this.identifier}}
{{this.identifier}}
{{/if}} - {{#if (or this.status this.hasOnline)}} -
- {{#if this.status}} - - {{/if}} - {{#if this.hasOnline}} - - - {{if this.online "Online" "Offline"}} - - {{/if}} -
- {{/if}}
+ {{#if this.status}} + + {{/if}} {{#if this.hydrate.isRunning}}
diff --git a/addon/components/resource/summary.js b/addon/components/resource/summary.js index 8930e572..b619b558 100644 --- a/addon/components/resource/summary.js +++ b/addon/components/resource/summary.js @@ -69,7 +69,12 @@ export default class ResourceSummaryComponent extends Component { } get facts() { - return resourceFacts(this.owner, this.descriptor, this.record, { translate: this.translate }); + const shown = new Set([this.title, this.identifier, this.status].filter((value) => value !== null && value !== undefined).map((value) => String(value).trim().toLowerCase())); + + // A fact that only repeats the title, identifier or status is noise in a compact card. + return resourceFacts(this.owner, this.descriptor, this.record, { translate: this.translate }).filter( + (fact) => fact.pillComponent || !shown.has(String(fact.value).trim().toLowerCase()) + ); } get canOpen() { @@ -116,14 +121,17 @@ export default class ResourceSummaryComponent extends Component { event?.preventDefault?.(); event?.stopPropagation?.(); - if (typeof this.args.onClose === 'function') { - this.args.onClose(); - } + // Closing the card destroys this component, so read everything first + // and start the open before letting the host tear the popover down. + const { onView, onClose, resource, resourceType } = this.args; + const owner = this.owner; + const record = this.record; + const result = typeof onView === 'function' ? onView(record, event) : openResource(owner, resource, { event, resourceType }); - if (typeof this.args.onView === 'function') { - return this.args.onView(this.record, event); + if (typeof onClose === 'function') { + onClose(); } - return openResource(this.owner, this.args.resource, { event, resourceType: this.args.resourceType }); + return result; } } diff --git a/addon/styles/components/resource-identity.css b/addon/styles/components/resource-identity.css index 38f2e477..27da0a1d 100644 --- a/addon/styles/components/resource-identity.css +++ b/addon/styles/components/resource-identity.css @@ -10,14 +10,14 @@ } .resource-hover-card .resource-hover-card-inner { - @apply rounded-lg border border-gray-200 bg-white text-left text-gray-900 shadow-xl ring-1 ring-black/5; + @apply rounded-lg border border-gray-200 bg-white text-left text-gray-900 shadow-lg; padding: 0; margin: 0; - max-width: min(24rem, calc(100vw - 2rem)); + max-width: min(22rem, calc(100vw - 2rem)); } body[data-theme='dark'] .resource-hover-card .resource-hover-card-inner { - @apply border-gray-700 bg-gray-900 text-gray-100 ring-white/10; + @apply border-gray-700 bg-gray-900 text-gray-100; } .resource-hover-card-anchor { @@ -26,21 +26,21 @@ body[data-theme='dark'] .resource-hover-card .resource-hover-card-inner { /* ── Summary card ── */ .resource-summary { - @apply flex flex-col gap-3 p-3 text-sm; - width: 20rem; + @apply flex flex-col gap-2 p-2.5 text-xs; + width: 17rem; } .resource-summary-header { - @apply flex items-start gap-3; + @apply flex items-center gap-2.5; } .resource-summary-media { - @apply flex-shrink-0; + @apply relative flex-shrink-0; } .resource-summary-image, .resource-summary-icon { - @apply h-12 w-12 border border-gray-200 object-cover shadow-sm dark:border-gray-700; + @apply h-9 w-9 border border-gray-200 object-cover dark:border-gray-700; border-radius: 0.5rem; } @@ -52,8 +52,13 @@ body[data-theme='dark'] .resource-hover-card .resource-hover-card-inner { @apply flex items-center justify-center bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400; } +/* Same corner as the identity cell and the pill: top-left, just outside the image. */ +.resource-summary-online-dot { + @apply absolute left-0 top-0 -ml-1 -mt-1 rounded-full bg-white dark:bg-gray-900; +} + .resource-summary-heading { - @apply flex min-w-0 flex-col gap-0.5; + @apply flex min-w-0 flex-1 flex-col; } .resource-summary-title { @@ -61,23 +66,15 @@ body[data-theme='dark'] .resource-hover-card .resource-hover-card-inner { } .resource-summary-identifier { - @apply truncate text-xs text-gray-500 dark:text-gray-400; + @apply truncate text-xs leading-4 text-gray-500 dark:text-gray-400; } .resource-summary-status { - @apply mt-1 flex flex-wrap items-center gap-2; -} - -.resource-summary-online { - @apply inline-flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400; -} - -.resource-summary-online.is-online { - @apply text-green-600 dark:text-green-400; + @apply ml-auto flex-shrink-0; } .resource-summary-facts { - @apply grid gap-x-3 gap-y-1.5 border-t border-gray-100 pt-3 dark:border-gray-800; + @apply grid gap-x-3 gap-y-1 border-t border-gray-100 pt-2 dark:border-gray-800; grid-template-columns: max-content minmax(0, 1fr); margin: 0; } @@ -87,11 +84,11 @@ body[data-theme='dark'] .resource-hover-card .resource-hover-card-inner { } .resource-summary-fact > dt { - @apply truncate text-xs leading-5 text-gray-500 dark:text-gray-400; + @apply truncate text-xs leading-4 text-gray-500 dark:text-gray-400; } .resource-summary-fact > dd { - @apply min-w-0 truncate text-xs leading-5 text-gray-900 dark:text-gray-100; + @apply min-w-0 truncate text-xs leading-4 text-gray-900 dark:text-gray-100; margin: 0; } @@ -101,21 +98,33 @@ body[data-theme='dark'] .resource-hover-card .resource-hover-card-inner { .resource-summary-fact > dd .fleetbase-pill img, .resource-summary-fact > dd .fleetbase-pill .resource-pill-icon { - @apply h-5 w-5; - width: 1.25rem; - height: 1.25rem; + @apply h-4 w-4; + width: 1rem; + height: 1rem; } .resource-summary-fact > dd .fleetbase-pill .resource-pill-subtitle { display: none; } +/* The compact pill's image is 1rem, so its dot shrinks and sits just outside + the corner, the way the identity cell draws it. */ +.resource-summary-fact > dd .fleetbase-pill .fleetbase-pill-body { + @apply items-center; +} + +.resource-summary-fact > dd .fleetbase-pill .resource-pill-online-dot { + @apply -ml-0.5 -mt-0.5 rounded-full bg-white dark:bg-gray-900; + width: 0.375rem; + height: 0.375rem; +} + .resource-summary-skeleton { - @apply flex flex-col gap-2 border-t border-gray-100 pt-3 dark:border-gray-800; + @apply flex flex-col gap-1.5 border-t border-gray-100 pt-2 dark:border-gray-800; } .resource-summary-skeleton > span { - @apply block h-3 rounded bg-gray-200 dark:bg-gray-700; + @apply block h-2.5 rounded bg-gray-200 dark:bg-gray-700; animation: resource-summary-pulse 1.2s ease-in-out infinite; } diff --git a/addon/utils/resource-identity.js b/addon/utils/resource-identity.js index a6c6eae2..5dad6470 100644 --- a/addon/utils/resource-identity.js +++ b/addon/utils/resource-identity.js @@ -4,6 +4,7 @@ import ObjectProxy from '@ember/object/proxy'; import { getResourceDescriptor, readDescriptor, safeIdentifier, resourceComponentName, relationValue } from './resource-registry'; import formatBytes from './format-bytes'; import smartHumanize from './smart-humanize'; +import formatDate from './format-date'; /** * Shared presentation logic for the resource identity components: everything @@ -211,7 +212,7 @@ export function formatFactValue(value, format) { if (format === 'date' || value instanceof Date) { const date = value instanceof Date ? value : new Date(value); - return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString(); + return Number.isNaN(date.getTime()) ? String(value) : formatDate(date, 'dd MMM yyyy, HH:mm'); } if (format === 'bytes') { From 4d9d61741e1621afa74dc2940782986f83b51ef9 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 16 Sep 2026 14:03:41 +0800 Subject: [PATCH 36/40] Give pills a fallback icon tile and hover padding A pill for a resource with no image rendered a broken image; it now falls back to the descriptor's icon (or a cube). The clickable pill gets a little padding so its content no longer sits on the hover border, and the online dot carries a class the compact fact pill can size. --- addon/components/resource/pill.hbs | 2 +- addon/components/resource/pill.js | 9 ++++++++- addon/styles/components/pill.css | 4 ++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/addon/components/resource/pill.hbs b/addon/components/resource/pill.hbs index 1c08f8db..68194e57 100644 --- a/addon/components/resource/pill.hbs +++ b/addon/components/resource/pill.hbs @@ -34,7 +34,7 @@ /> {{/if}} {{#if this.showOnlineIndicator}} - + {{/if}} <:default as |resource|> diff --git a/addon/components/resource/pill.js b/addon/components/resource/pill.js index 4288fd7c..2bcd654f 100644 --- a/addon/components/resource/pill.js +++ b/addon/components/resource/pill.js @@ -50,12 +50,19 @@ export default class ResourcePillComponent extends Component { } get image() { - return resourceImage(this.descriptor, this.record, { + const image = resourceImage(this.descriptor, this.record, { url: this.args.imageSrc, fallback: this.args.imageFallback, shape: this.args.imageShape, icon: this.args.icon, }); + + // Nothing to show means an icon tile, never a broken image. + if (!image.url && !image.fallback && !image.icon && !image.component) { + image.icon = this.descriptor?.icon ?? 'cube'; + } + + return image; } get online() { diff --git a/addon/styles/components/pill.css b/addon/styles/components/pill.css index 3ac691d4..1fd94455 100644 --- a/addon/styles/components/pill.css +++ b/addon/styles/components/pill.css @@ -5,8 +5,8 @@ */ .fleetbase-pill > a[href] { border-radius: 0.375rem; - padding: 0.125rem; - margin: -0.125rem; + padding: 0.25rem 0.5rem; + margin: -0.25rem -0.5rem; transition: background-color 120ms ease, opacity 120ms ease; From 1b164a475d3a25e113f9f3278fcaeff262be2fca Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 16 Sep 2026 14:03:41 +0800 Subject: [PATCH 37/40] Show inline badges and status dots only where the resource has them The core user, company, group, role, file and category descriptors no longer declare badges. The identity cell draws its status dot only when the column asks for one, names a path for it, or the resource reports an online state; places, contacts and vendors have none and render without a bulb. --- addon/components/table/cell/identity.js | 17 ++++++++++++- addon/utils/resource-descriptors/core.js | 25 ------------------- .../components/resource/smoke-test.js | 2 +- 3 files changed, 17 insertions(+), 27 deletions(-) diff --git a/addon/components/table/cell/identity.js b/addon/components/table/cell/identity.js index aa4c28cb..9a9e9522 100644 --- a/addon/components/table/cell/identity.js +++ b/addon/components/table/cell/identity.js @@ -101,8 +101,23 @@ export default class TableCellIdentityComponent extends Component { return image; } + /** + * A dot only where it means something: the column asks for one, names a + * path to read it from, or the resource reports an online state. A place + * or a contact has none, so it renders without a bulb. + */ get hasStatusDot() { - return this.column.showStatusDot ?? this.column.showOnlineIndicator ?? true; + const explicit = this.column.showStatusDot ?? this.column.showOnlineIndicator; + + if (explicit !== undefined && explicit !== null) { + return Boolean(explicit); + } + + if (typeof this.column.onlinePath === 'string' || this.column.statusPath) { + return true; + } + + return typeof resourceOnline(this.descriptor, this.resource) === 'boolean'; } get statusValue() { diff --git a/addon/utils/resource-descriptors/core.js b/addon/utils/resource-descriptors/core.js index 180113c6..bf6f8cef 100644 --- a/addon/utils/resource-descriptors/core.js +++ b/addon/utils/resource-descriptors/core.js @@ -114,11 +114,6 @@ export function buildCoreResourceDescriptors() { return typeof online === 'boolean' ? online : undefined; }, status: (user) => first(user, 'status', 'session_status'), - badges: (user) => { - const role = first(user, 'role_name', 'role.name', 'type'); - - return role ? [{ key: 'role', icon: 'user-shield', label: role }] : []; - }, selectDetails: (user) => [first(user, 'email'), first(user, 'phone')], facts: (user) => [ { labelKey: 'resource-summary.facts.email', label: 'Email', value: first(user, 'email') }, @@ -140,11 +135,6 @@ export function buildCoreResourceDescriptors() { identifier: (company) => safeIdentifier(first(company, 'public_id', 'slug')), image: (company) => ({ url: get(company, 'logo_url'), shape: 'square' }), status: (company) => first(company, 'status'), - badges: (company) => { - const plan = first(company, 'plan', 'type'); - - return plan ? [{ key: 'plan', icon: 'tag', label: plan }] : []; - }, selectDetails: (company) => [first(company, 'country'), first(company, 'timezone')], facts: (company) => [ { @@ -189,11 +179,6 @@ export function buildCoreResourceDescriptors() { title: (role) => first(role, 'name', 'public_id'), identifier: (role) => first(role, 'guard_name', 'type'), image: () => ({ icon: 'user-shield' }), - badges: (role) => { - const service = first(role, 'service'); - - return service ? [{ key: 'service', icon: 'cubes', label: service }] : []; - }, selectDetails: (role) => [first(role, 'description'), first(role, 'guard_name')], facts: (role) => [ { labelKey: 'resource-summary.facts.description', label: 'Description', value: first(role, 'description') }, @@ -211,11 +196,6 @@ export function buildCoreResourceDescriptors() { title: (file) => first(file, 'original_filename', 'caption', 'url', 'public_id'), identifier: (file) => first(file, 'content_type', 'type'), image: (file) => (isImageFile(file) && present(get(file, 'url')) ? { url: get(file, 'url'), shape: 'square' } : { icon: fileIcon(file) }), - badges: (file) => { - const extension = fileExtension(file); - - return extension ? [{ key: 'extension', icon: 'file', label: extension.toUpperCase() }] : []; - }, selectDetails: (file) => [first(file, 'content_type', 'type'), typeof get(file, 'file_size') === 'number' ? get(file, 'file_size') : null].filter(present), facts: (file) => [ { labelKey: 'resource-summary.facts.type', label: 'Type', value: first(file, 'content_type', 'type') }, @@ -260,11 +240,6 @@ export function buildCoreResourceDescriptors() { return present(url) ? { url, shape: 'square' } : { icon: 'folder' }; }, - badges: (category) => { - const parent = first(category, 'parent.name', 'parent_name'); - - return parent ? [{ key: 'parent', icon: 'folder-tree', label: parent }] : []; - }, selectDetails: (category) => [first(category, 'for'), first(category, 'description')], facts: (category) => [ { diff --git a/tests/integration/components/resource/smoke-test.js b/tests/integration/components/resource/smoke-test.js index 75374519..241f9381 100644 --- a/tests/integration/components/resource/smoke-test.js +++ b/tests/integration/components/resource/smoke-test.js @@ -34,7 +34,7 @@ module('Integration | Component | resource identity smoke', function (hooks) { assert.dom('[data-test-select-option-title]').hasText('Ada Lovelace'); assert.dom('[data-test-identity-cell][data-test-identity-static]').exists(); assert.dom('[data-test-identity-label]').hasText('Ada Lovelace'); - assert.dom('[data-test-resource-identity-meta-badge]').hasText('Admin'); + assert.dom('[data-test-resource-identity-meta-badge]').doesNotExist('core families carry no inline badges'); assert.dom('[data-test-resource-identity-status-dot]').hasClass('text-green-500'); assert.dom('[data-test-resource-pill][data-resource-type="file"] a[href]').exists('a file with a url opens'); assert.dom('[data-test-resource-pill][data-resource-type="file"] [data-test-resource-pill-icon]').exists('non-image files get an icon tile'); From af2585fc2462311037bcb3164a71c2ff07383c8e Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 16 Sep 2026 14:29:28 +0800 Subject: [PATCH 38/40] Even out pill and identity images, and stop the column picker writing to a getter - Pill images carried a 2px gray-800 ring that read as a black border in light mode, including the related-record pills inside a summary card; they now use a 1px gray-200 ring (gray-700 in dark mode). - Every image and icon tile in pills, identity cells and summary cards is a circle now, at one size per component, and each passes the console's placeholder as a fallback so a dead avatar URL no longer shows a broken image. - A pill with no record ("No driver assigned") is gray-500 in light mode instead of the barely readable gray-400. - The clickable pill's hover box keeps a 0.5rem right margin instead of a negative one, so it no longer overlaps the content beside it; the summary fact cell leaves room on the left and top so the pill's ring and dot are not clipped. - The column picker handed the column list to (mut @columns), which threw "Cannot set property columns ... which has only a getter" on controllers that compute their columns; the tabular actions now only forward the list to an explicit @onColumnsChange. --- addon/components/layout/resource/tabular-actions.hbs | 2 +- addon/components/layout/resource/tabular-actions.js | 12 ++++++++++++ addon/components/layout/resource/tabular.hbs | 2 +- addon/components/layout/resource/tabular.js | 4 ++++ addon/components/pill/body.hbs | 2 +- addon/components/resource/pill.hbs | 6 +++--- addon/components/resource/summary.hbs | 2 +- addon/components/table/cell/identity.hbs | 2 +- addon/components/table/cell/identity/body.hbs | 4 ++-- addon/styles/components/pill.css | 2 +- addon/styles/components/resource-identity.css | 12 ++++++------ 11 files changed, 33 insertions(+), 17 deletions(-) diff --git a/addon/components/layout/resource/tabular-actions.hbs b/addon/components/layout/resource/tabular-actions.hbs index 615c4c5b..19331cd4 100644 --- a/addon/components/layout/resource/tabular-actions.hbs +++ b/addon/components/layout/resource/tabular-actions.hbs @@ -29,7 +29,7 @@ @iconOnly={{true}} /> {{#unless @hideColumnsPicker}} - + {{/unless}} {{#if (safe-has @table "selectedRows")}} {{#unless @hideColumnsPicker}} - + {{/unless}} {{#if (safe-has this.table "selectedRows")}} {{#if @showOnlineIndicator}} diff --git a/addon/components/resource/pill.hbs b/addon/components/resource/pill.hbs index 68194e57..72f9d1b8 100644 --- a/addon/components/resource/pill.hbs +++ b/addon/components/resource/pill.hbs @@ -25,10 +25,10 @@ {{else}} {{this.title}} @@ -41,7 +41,7 @@ {{#if (and (has-block) (not-eq @useBlock false))}} {{yield resource}} {{else}} -
{{this.title}}
+
{{this.title}}
{{#if this.subtitle}}
{{this.subtitle}}
{{/if}} diff --git a/addon/components/resource/summary.hbs b/addon/components/resource/summary.hbs index bb3e6fd3..c0b76de9 100644 --- a/addon/components/resource/summary.hbs +++ b/addon/components/resource/summary.hbs @@ -4,7 +4,7 @@ {{#if this.image.component}} {{component this.image.component resource=this.record}} {{else if this.image.url}} - + {{else}}
diff --git a/addon/components/table/cell/identity.hbs b/addon/components/table/cell/identity.hbs index 687bf308..3323a9db 100644 --- a/addon/components/table/cell/identity.hbs +++ b/addon/components/table/cell/identity.hbs @@ -36,5 +36,5 @@ {{/if}}
{{else}} -
{{this.emptyText}}
+
{{this.emptyText}}
{{/if}} diff --git a/addon/components/table/cell/identity/body.hbs b/addon/components/table/cell/identity/body.hbs index 1c0a8603..6e94509d 100644 --- a/addon/components/table/cell/identity/body.hbs +++ b/addon/components/table/cell/identity/body.hbs @@ -3,11 +3,11 @@ {{#if @image.component}} {{component @image.component resource=@resource}} {{else if @image.icon}} -
+
{{else}} - + {{/if}} {{#if @hasStatusDot}} diff --git a/addon/styles/components/pill.css b/addon/styles/components/pill.css index 1fd94455..3dd2fa10 100644 --- a/addon/styles/components/pill.css +++ b/addon/styles/components/pill.css @@ -6,7 +6,7 @@ .fleetbase-pill > a[href] { border-radius: 0.375rem; padding: 0.25rem 0.5rem; - margin: -0.25rem -0.5rem; + margin: -0.25rem 0.5rem -0.25rem -0.5rem; transition: background-color 120ms ease, opacity 120ms ease; diff --git a/addon/styles/components/resource-identity.css b/addon/styles/components/resource-identity.css index 27da0a1d..a47cd461 100644 --- a/addon/styles/components/resource-identity.css +++ b/addon/styles/components/resource-identity.css @@ -41,10 +41,6 @@ body[data-theme='dark'] .resource-hover-card .resource-hover-card-inner { .resource-summary-image, .resource-summary-icon { @apply h-9 w-9 border border-gray-200 object-cover dark:border-gray-700; - border-radius: 0.5rem; -} - -.resource-summary-image[data-shape='round'] { border-radius: 9999px; } @@ -89,7 +85,11 @@ body[data-theme='dark'] .resource-hover-card .resource-hover-card-inner { .resource-summary-fact > dd { @apply min-w-0 truncate text-xs leading-4 text-gray-900 dark:text-gray-100; - margin: 0; + + /* Room inside the clipping box for a pill's hover padding, ring and dot, + which otherwise lose their left and top edges to the truncation. */ + padding: 0.25rem 0 0.25rem 0.5rem; + margin: -0.25rem 0 -0.25rem -0.5rem; } .resource-summary-fact > dd .fleetbase-pill { @@ -153,7 +153,7 @@ body[data-theme='dark'] .resource-hover-card .resource-hover-card-inner { /* ── Pill icon tile ── */ .resource-pill-icon { - @apply flex h-7 w-7 items-center justify-center rounded-md border border-gray-200 bg-gray-100 text-gray-500 shadow-sm dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400; + @apply flex h-7 w-7 items-center justify-center rounded-full border border-gray-200 bg-gray-100 text-gray-500 shadow-sm dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400; } /* ── Select option: photo or icon, name, and a line of detail ── */ From aebd7863b610d71c8b45bd2be5f2b850f5aec442 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 16 Sep 2026 14:48:43 +0800 Subject: [PATCH 39/40] Style every Fleet-Ops status badge and compact the select option The badge palette gains a Fleet-Ops section covering each value the option lists can put on a badge (driver, vehicle, vendor, contact, fleet, fuel report, inspection, work order, issue, place, telematic, device, sensor, part, equipment and trailer statuses), including the entries that had lived in the fleetops engine stylesheet: long-offline, attached, in-use, detached, retired, out-of-service, passed, submitted, needs-review and unsafe. Values such as processed, fuel-provider, not-equipped and in-stock were unstyled. Select options are shorter now: one 1.5rem circle regardless of resource, no square variant, a tighter two-line text block, and the placeholder image as a fallback. --- addon/components/resource/select-option.hbs | 8 +- addon/styles/components/badge.css | 199 ++++++++++++++++++ addon/styles/components/resource-identity.css | 29 ++- 3 files changed, 217 insertions(+), 19 deletions(-) diff --git a/addon/components/resource/select-option.hbs b/addon/components/resource/select-option.hbs index 449736fe..888cbfff 100644 --- a/addon/components/resource/select-option.hbs +++ b/addon/components/resource/select-option.hbs @@ -1,12 +1,12 @@
{{#if this.image.component}} - {{component this.image.component resource=this.record}} + {{component this.image.component resource=this.record}} {{else if this.icon}} - - + + {{else if this.photo}} - + {{/if}}
{{this.title}} diff --git a/addon/styles/components/badge.css b/addon/styles/components/badge.css index 59e0e3fe..8a0a9dc9 100644 --- a/addon/styles/components/badge.css +++ b/addon/styles/components/badge.css @@ -397,6 +397,205 @@ @apply text-blue-300; } +/* ── Fleet-Ops statuses ── + Every value the Fleet-Ops option lists can put on a badge (driver, vehicle, + vendor, contact, fleet, fuel report, inspection, work order, issue, place, + telematic, device, sensor, part, equipment and trailer statuses) has a + family here, so none renders as an unstyled chip. Underscored spellings + arrive dasherized through safe-dasherize. */ + +.status-badge.on-duty-status-badge > span, +.status-badge.healthy-status-badge > span, +.status-badge.in-stock-status-badge > span, +.status-badge.processed-status-badge > span, +.status-badge.passed-status-badge > span, +.status-badge.primary-status-badge > span, +.status-badge.preferred-status-badge > span, +.status-badge.prequalified-status-badge > span, +.status-badge.configured-status-badge > span, +.status-badge.fuel-provider-status-badge > span { + @apply bg-green-800 border-green-700 text-green-100; +} + +.status-badge.on-duty-status-badge > span svg, +.status-badge.healthy-status-badge > span svg, +.status-badge.in-stock-status-badge > span svg, +.status-badge.processed-status-badge > span svg, +.status-badge.passed-status-badge > span svg, +.status-badge.primary-status-badge > span svg, +.status-badge.preferred-status-badge > span svg, +.status-badge.prequalified-status-badge > span svg, +.status-badge.configured-status-badge > span svg, +.status-badge.fuel-provider-status-badge > span svg { + @apply text-green-300; +} + +.status-badge.on-route-status-badge > span, +.status-badge.in-use-status-badge > span, +.status-badge.attached-status-badge > span, +.status-badge.submitted-status-badge > span, +.status-badge.scheduled-status-badge > span, +.status-badge.equipped-status-badge > span, +.status-badge.provisioning-status-badge > span { + @apply bg-blue-800 border-blue-700 text-blue-100; +} + +.status-badge.on-route-status-badge > span svg, +.status-badge.in-use-status-badge > span svg, +.status-badge.attached-status-badge > span svg, +.status-badge.submitted-status-badge > span svg, +.status-badge.scheduled-status-badge > span svg, +.status-badge.equipped-status-badge > span svg, +.status-badge.provisioning-status-badge > span svg { + @apply text-blue-300; +} + +.status-badge.on-break-status-badge > span, +.status-badge.pre-shift-status-badge > span, +.status-badge.training-status-badge > span, +.status-badge.onboarding-status-badge > span, +.status-badge.delayed-status-badge > span, +.status-badge.pending-verification-status-badge > span, +.status-badge.reserved-status-badge > span, +.status-badge.staging-status-badge > span, +.status-badge.cleaning-status-badge > span, +.status-badge.inspection-due-status-badge > span, +.status-badge.on-hold-status-badge > span, +.status-badge.scaling-status-badge > span, +.status-badge.downsizing-status-badge > span, +.status-badge.seasonal-status-badge > span, +.status-badge.pilot-status-badge > span, +.status-badge.unmatched-status-badge > span, +.status-badge.low-stock-status-badge > span, +.status-badge.on-order-status-badge > span, +.status-badge.needs-review-status-badge > span, +.status-badge.awaiting-vendor-status-badge > span, +.status-badge.quality-check-status-badge > span, +.status-badge.requires-update-status-badge > span, +.status-badge.new-status-badge > span, +.status-badge.temp-closed-status-badge > span, +.status-badge.under-construction-status-badge > span, +.status-badge.permit-required-status-badge > span, +.status-badge.approach-caution-status-badge > span, +.status-badge.connecting-status-badge > span, +.status-badge.paused-status-badge > span, +.status-badge.under-review-status-badge > span, +.status-badge.unverified-status-badge > span, +.status-badge.sleeping-status-badge > span, +.status-badge.long-offline-status-badge > span, +.status-badge.imported-status-badge > span, +.status-badge.ignored-status-badge > span { + @apply bg-yellow-700 border-yellow-700 text-yellow-100; +} + +.status-badge.on-break-status-badge > span svg, +.status-badge.pre-shift-status-badge > span svg, +.status-badge.training-status-badge > span svg, +.status-badge.onboarding-status-badge > span svg, +.status-badge.delayed-status-badge > span svg, +.status-badge.pending-verification-status-badge > span svg, +.status-badge.reserved-status-badge > span svg, +.status-badge.staging-status-badge > span svg, +.status-badge.cleaning-status-badge > span svg, +.status-badge.inspection-due-status-badge > span svg, +.status-badge.on-hold-status-badge > span svg, +.status-badge.scaling-status-badge > span svg, +.status-badge.downsizing-status-badge > span svg, +.status-badge.seasonal-status-badge > span svg, +.status-badge.pilot-status-badge > span svg, +.status-badge.unmatched-status-badge > span svg, +.status-badge.low-stock-status-badge > span svg, +.status-badge.on-order-status-badge > span svg, +.status-badge.needs-review-status-badge > span svg, +.status-badge.awaiting-vendor-status-badge > span svg, +.status-badge.quality-check-status-badge > span svg, +.status-badge.requires-update-status-badge > span svg, +.status-badge.new-status-badge > span svg, +.status-badge.temp-closed-status-badge > span svg, +.status-badge.under-construction-status-badge > span svg, +.status-badge.permit-required-status-badge > span svg, +.status-badge.approach-caution-status-badge > span svg, +.status-badge.connecting-status-badge > span svg, +.status-badge.paused-status-badge > span svg, +.status-badge.under-review-status-badge > span svg, +.status-badge.unverified-status-badge > span svg, +.status-badge.sleeping-status-badge > span svg, +.status-badge.long-offline-status-badge > span svg, +.status-badge.imported-status-badge > span svg, +.status-badge.ignored-status-badge > span svg { + @apply text-yellow-300; +} + +.status-badge.terminated-status-badge > span, +.status-badge.suspended-status-badge > span, +.status-badge.blacklisted-status-badge > span, +.status-badge.blocked-status-badge > span, +.status-badge.do-not-contact-status-badge > span, +.status-badge.accident-hold-status-badge > span, +.status-badge.no-show-status-badge > span, +.status-badge.out-of-service-status-badge > span, +.status-badge.inspection-failed-status-badge > span, +.status-badge.accident-status-badge > span, +.status-badge.compliance-hold-status-badge > span, +.status-badge.stolen-status-badge > span, +.status-badge.integration-error-status-badge > span, +.status-badge.unsafe-status-badge > span, +.status-badge.lost-status-badge > span, +.status-badge.damaged-status-badge > span, +.status-badge.out-of-stock-status-badge > span, +.status-badge.failed-inspection-status-badge > span, +.status-badge.breakdown-status-badge > span { + @apply bg-red-800 border-red-700 text-red-100; +} + +.status-badge.terminated-status-badge > span svg, +.status-badge.suspended-status-badge > span svg, +.status-badge.blacklisted-status-badge > span svg, +.status-badge.blocked-status-badge > span svg, +.status-badge.do-not-contact-status-badge > span svg, +.status-badge.accident-hold-status-badge > span svg, +.status-badge.no-show-status-badge > span svg, +.status-badge.out-of-service-status-badge > span svg, +.status-badge.inspection-failed-status-badge > span svg, +.status-badge.accident-status-badge > span svg, +.status-badge.compliance-hold-status-badge > span svg, +.status-badge.stolen-status-badge > span svg, +.status-badge.integration-error-status-badge > span svg, +.status-badge.unsafe-status-badge > span svg, +.status-badge.lost-status-badge > span svg, +.status-badge.damaged-status-badge > span svg, +.status-badge.out-of-stock-status-badge > span svg, +.status-badge.failed-inspection-status-badge > span svg, +.status-badge.breakdown-status-badge > span svg { + @apply text-red-300; +} + +.status-badge.off-duty-status-badge > span, +.status-badge.on-leave-status-badge > span, +.status-badge.retired-status-badge > span, +.status-badge.detached-status-badge > span, +.status-badge.decommissioning-status-badge > span, +.status-badge.archived-status-badge > span, +.status-badge.not-equipped-status-badge > span, +.status-badge.discontinued-status-badge > span, +.status-badge.unattached-status-badge > span, +.status-badge.reviewed-status-badge > span { + @apply bg-gray-800 border-gray-700 text-gray-100; +} + +.status-badge.off-duty-status-badge > span svg, +.status-badge.on-leave-status-badge > span svg, +.status-badge.retired-status-badge > span svg, +.status-badge.detached-status-badge > span svg, +.status-badge.decommissioning-status-badge > span svg, +.status-badge.archived-status-badge > span svg, +.status-badge.not-equipped-status-badge > span svg, +.status-badge.discontinued-status-badge > span svg, +.status-badge.unattached-status-badge > span svg, +.status-badge.reviewed-status-badge > span svg { + @apply text-gray-300; +} + body[data-theme='dark'] .status-badge:not(.shadow-none) { @apply shadow; } diff --git a/addon/styles/components/resource-identity.css b/addon/styles/components/resource-identity.css index a47cd461..8ef7b428 100644 --- a/addon/styles/components/resource-identity.css +++ b/addon/styles/components/resource-identity.css @@ -156,33 +156,33 @@ body[data-theme='dark'] .resource-hover-card .resource-hover-card-inner { @apply flex h-7 w-7 items-center justify-center rounded-full border border-gray-200 bg-gray-100 text-gray-500 shadow-sm dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400; } -/* ── Select option: photo or icon, name, and a line of detail ── */ +/* ── Select option: photo or icon, name, and a line of detail ── + Rows stay short because a dropdown can list hundreds of them: one 1.5rem + circle, the name, and the detail on a second tight line. */ .select-option { display: flex; align-items: center; - gap: 0.625rem; + gap: 0.5rem; min-width: 0; - padding: 0.125rem 0; + padding: 0; + line-height: 1.15; } .select-option__photo { flex-shrink: 0; - width: 2rem; - height: 2rem; + width: 1.5rem; + height: 1.5rem; object-fit: cover; border-radius: 9999px; background-color: rgb(156 163 175 / 20%); } -.select-option__photo[data-shape='square'] { - border-radius: 0.375rem; -} - .select-option__icon, .select-option__component { display: inline-flex; align-items: center; justify-content: center; + overflow: hidden; color: rgb(107 114 128); } @@ -190,7 +190,7 @@ body[data-theme='dark'] .resource-hover-card .resource-hover-card-inner { display: flex; flex: 1 1 auto; flex-direction: column; - gap: 0.125rem; + gap: 0; min-width: 0; } @@ -203,20 +203,19 @@ body[data-theme='dark'] .resource-hover-card .resource-hover-card-inner { .select-option__title { font-size: 0.8125rem; - font-weight: 600; - line-height: 1.2; + font-weight: 500; + line-height: 1.15rem; } .select-option__details { font-size: 0.6875rem; - line-height: 1.2; + line-height: 0.9rem; opacity: 0.7; } /* In a closed select there is room for one line: a small photo, the name, then the detail. */ .select-option--compact { - gap: 0.5rem; - padding: 0; + gap: 0.375rem; } .select-option--compact .select-option__photo { From 9c16695688acaee21068de742a69f709af61a57d Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 16 Sep 2026 14:51:43 +0800 Subject: [PATCH 40/40] Add a radio filter for two- or three-state choices A presence filter such as "assigned / not assigned" read badly as a dropdown whose placeholder doubled as the "any" state. filter/radio shows every option, with the placeholder as the first "any" radio that clears the filter, and takes the same options, optionLabel and optionValue arguments as filter/select. --- addon/components/filter/radio.hbs | 9 +++++++ addon/components/filter/radio.js | 44 +++++++++++++++++++++++++++++++ addon/styles/layout/next.css | 22 ++++++++++++++++ app/components/filter/radio.js | 1 + 4 files changed, 76 insertions(+) create mode 100644 addon/components/filter/radio.hbs create mode 100644 addon/components/filter/radio.js create mode 100644 app/components/filter/radio.js diff --git a/addon/components/filter/radio.hbs b/addon/components/filter/radio.hbs new file mode 100644 index 00000000..3c40d881 --- /dev/null +++ b/addon/components/filter/radio.hbs @@ -0,0 +1,9 @@ +{{! A small set of exclusive choices, with "any" as the first one, drawn as radios so the state reads at a glance. }} +
+ {{#each this.choices as |choice|}} + + {{/each}} +
diff --git a/addon/components/filter/radio.js b/addon/components/filter/radio.js new file mode 100644 index 00000000..3717d72f --- /dev/null +++ b/addon/components/filter/radio.js @@ -0,0 +1,44 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { guidFor } from '@ember/object/internals'; +import { isArray } from '@ember/array'; + +/** + * A filter for two or three exclusive states, such as "assigned / not + * assigned": every option is visible as a radio, and an "any" choice (the + * placeholder text) clears the filter. Options are `{ label, value }` like + * the select filter's; values compare as strings so "true" and true match. + */ +export default class FilterRadioComponent extends Component { + @tracked value = ''; + + constructor(owner, { value }) { + super(...arguments); + this.value = value === undefined || value === null ? '' : String(value); + } + + get name() { + return `filter-radio-${guidFor(this)}`; + } + + get choices() { + const optionLabel = this.args.optionLabel ?? this.args.filterOptionLabel ?? 'label'; + const optionValue = this.args.optionValue ?? this.args.filterOptionValue ?? 'value'; + const options = (isArray(this.args.options) ? this.args.options : []).map((option) => + typeof option === 'object' && option !== null ? { label: option[optionLabel], value: String(option[optionValue]) } : { label: String(option), value: String(option) } + ); + + return [{ label: this.args.anyLabel ?? this.args.placeholder ?? 'Any', value: '' }, ...options]; + } + + @action select(value) { + const { onChange, filter } = this.args; + + this.value = value; + + if (typeof onChange === 'function') { + onChange(filter, value === '' ? null : value); + } + } +} diff --git a/addon/styles/layout/next.css b/addon/styles/layout/next.css index 56761871..88da733a 100644 --- a/addon/styles/layout/next.css +++ b/addon/styles/layout/next.css @@ -2651,6 +2651,28 @@ a.text-danger:hover { margin-top: 2rem; } +.filter-radio { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 1rem; + padding-top: 0.125rem; +} + +.filter-radio-option { + @apply inline-flex cursor-pointer select-none items-center gap-1.5 text-sm text-gray-700 dark:text-gray-300; + margin: 0; +} + +.filter-radio-option--active { + @apply font-medium text-gray-900 dark:text-gray-100; +} + +.filter-radio-option > input[type='radio'] { + @apply h-3.5 w-3.5 cursor-pointer; + accent-color: rgb(37 99 235); + margin: 0; +} + .filter-string { @apply flex flex-col relative; } diff --git a/app/components/filter/radio.js b/app/components/filter/radio.js new file mode 100644 index 00000000..5065b321 --- /dev/null +++ b/app/components/filter/radio.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/filter/radio';