diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs index 92067b3867..65b6199ae1 100644 --- a/web/eslint.config.mjs +++ b/web/eslint.config.mjs @@ -12,6 +12,10 @@ export default [ { ignores: [ "dist/", + // don't lint the cache + ".wireit/", + // let packages have their own configurations + "packages/", // don't ever lint node_modules "node_modules/", ".storybook/*", diff --git a/web/package-lock.json b/web/package-lock.json index 21af1118fa..bd26196435 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -96,7 +96,6 @@ "cross-env": "^7.0.3", "esbuild": "^0.23.0", "eslint": "^9.8.0", - "eslint-config-google": "^0.14.0", "eslint-plugin-lit": "^1.14.0", "eslint-plugin-sonarjs": "^1.0.4", "eslint-plugin-wc": "^2.1.0", @@ -8637,9 +8636,8 @@ }, "node_modules/@types/mocha": { "version": "10.0.7", - "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.7.tgz", - "integrity": "sha512-GN8yJ1mNTcFcah/wKEFIJckJx9iJLoMSzWcfRRuxz/Jk+U6KQNnml+etbtxFK8lPjzOw3zp4Ha/kjSst9fsHYw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/ms": { "version": "0.7.34", @@ -9063,9 +9061,8 @@ }, "node_modules/@wdio/browser-runner": { "version": "8.40.2", - "resolved": "https://registry.npmjs.org/@wdio/browser-runner/-/browser-runner-8.40.2.tgz", - "integrity": "sha512-CqWRREUk5VYjPAq1abglHApVntVOuEEf7KKzjO6hmnPuzSDKpplbLX1+131GwweN042UdtghxAjzsGl0+Kk0fQ==", "dev": true, + "license": "MIT", "dependencies": { "@babel/plugin-proposal-class-properties": "^7.18.6", "@originjs/vite-plugin-commonjs": "^1.0.3", @@ -9577,9 +9574,8 @@ }, "node_modules/@wdio/cli": { "version": "8.40.2", - "resolved": "https://registry.npmjs.org/@wdio/cli/-/cli-8.40.2.tgz", - "integrity": "sha512-/h6Md8yMZqH8Z4XK9wjbDb/YIf9UibDkdaUxWKj5UyLA5PIyrIsLvz42PXH9ArdSq8YCUO1Jl859Z2tKdxwfgA==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "^20.1.1", "@vitest/snapshot": "^2.0.4", @@ -9639,9 +9635,8 @@ }, "node_modules/@wdio/config": { "version": "8.40.2", - "resolved": "https://registry.npmjs.org/@wdio/config/-/config-8.40.2.tgz", - "integrity": "sha512-RED2vcdX5Zdd6r+K+aWcjK4douxjJY4LP/8YvvavgqM0TURd5PDI0Y7IEz7+BIJOT4Uh+3atZawIN9/3yWFeag==", "dev": true, + "license": "MIT", "dependencies": { "@wdio/logger": "8.38.0", "@wdio/types": "8.39.0", @@ -9657,9 +9652,8 @@ }, "node_modules/@wdio/config/node_modules/glob": { "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", "dev": true, + "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", @@ -9677,9 +9671,8 @@ }, "node_modules/@wdio/config/node_modules/jackspeak": { "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" }, @@ -9692,15 +9685,13 @@ }, "node_modules/@wdio/config/node_modules/lru-cache": { "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/@wdio/config/node_modules/path-scurry": { "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" @@ -9714,9 +9705,8 @@ }, "node_modules/@wdio/globals": { "version": "8.40.2", - "resolved": "https://registry.npmjs.org/@wdio/globals/-/globals-8.40.2.tgz", - "integrity": "sha512-eF47oRE79JY2Cgl0/OCpCLdEAh4eNgU11e4O8fvkPrwbPgW6gcS8xG23ZmNGc3EjhHUZUOzrm7uJ8ymcRPIuoA==", "dev": true, + "license": "MIT", "engines": { "node": "^16.13 || >=18" }, @@ -9727,9 +9717,8 @@ }, "node_modules/@wdio/local-runner": { "version": "8.40.2", - "resolved": "https://registry.npmjs.org/@wdio/local-runner/-/local-runner-8.40.2.tgz", - "integrity": "sha512-Q6NDtI5IqYHciv+3t6mxwUefmdTdKmXf1aAg/KzJUTDl0RaISb9gKBOBW4pyMbY2ot5yF2mB7rXF93aN2Kmq6Q==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "^20.1.0", "@wdio/logger": "8.38.0", @@ -9746,18 +9735,16 @@ }, "node_modules/@wdio/local-runner/node_modules/@types/node": { "version": "20.14.14", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.14.tgz", - "integrity": "sha512-d64f00982fS9YoOgJkAMolK7MN8Iq3TDdVjchbYHdEmjth/DHowx82GnoA+tVUAN+7vxfYUgAzi+JXbKNd2SDQ==", "dev": true, + "license": "MIT", "dependencies": { "undici-types": "~5.26.4" } }, "node_modules/@wdio/local-runner/node_modules/undici-types": { "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@wdio/logger": { "version": "8.38.0", @@ -9786,9 +9773,8 @@ }, "node_modules/@wdio/mocha-framework": { "version": "8.40.2", - "resolved": "https://registry.npmjs.org/@wdio/mocha-framework/-/mocha-framework-8.40.2.tgz", - "integrity": "sha512-hqhyYzfIe40aAXrC6SQXKqRbrpnf4BSaLlJyxDbMVkge/5du/pCinciz25HmOdfHDhG/t9Ox9q1fNfD6+1IMew==", "dev": true, + "license": "MIT", "dependencies": { "@types/mocha": "^10.0.0", "@types/node": "^20.1.0", @@ -9803,18 +9789,16 @@ }, "node_modules/@wdio/mocha-framework/node_modules/@types/node": { "version": "20.14.14", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.14.tgz", - "integrity": "sha512-d64f00982fS9YoOgJkAMolK7MN8Iq3TDdVjchbYHdEmjth/DHowx82GnoA+tVUAN+7vxfYUgAzi+JXbKNd2SDQ==", "dev": true, + "license": "MIT", "dependencies": { "undici-types": "~5.26.4" } }, "node_modules/@wdio/mocha-framework/node_modules/undici-types": { "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@wdio/protocols": { "version": "8.38.0", @@ -9875,9 +9859,8 @@ }, "node_modules/@wdio/runner": { "version": "8.40.2", - "resolved": "https://registry.npmjs.org/@wdio/runner/-/runner-8.40.2.tgz", - "integrity": "sha512-alK1n5fHiAG/Pf77O9gb8mjs/KIbLFEedrQsIk2ZMVvgTfmyriKb790Iy64RCYDfZpWQmCvcj9yDARc64IhSGw==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "^20.11.28", "@wdio/config": "8.40.2", @@ -9897,18 +9880,16 @@ }, "node_modules/@wdio/runner/node_modules/@types/node": { "version": "20.14.14", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.14.tgz", - "integrity": "sha512-d64f00982fS9YoOgJkAMolK7MN8Iq3TDdVjchbYHdEmjth/DHowx82GnoA+tVUAN+7vxfYUgAzi+JXbKNd2SDQ==", "dev": true, + "license": "MIT", "dependencies": { "undici-types": "~5.26.4" } }, "node_modules/@wdio/runner/node_modules/undici-types": { "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@wdio/spec-reporter": { "version": "8.39.0", @@ -9962,9 +9943,8 @@ }, "node_modules/@wdio/utils": { "version": "8.40.2", - "resolved": "https://registry.npmjs.org/@wdio/utils/-/utils-8.40.2.tgz", - "integrity": "sha512-leYcCUSaAdLUCVKqRKNgMCASPOUo/VvOTKETiZ/qpdY2azCBt/KnLugtiycCzakeYg6Kp+VIjx5fkm0M7y4qhA==", "dev": true, + "license": "MIT", "dependencies": { "@puppeteer/browsers": "^1.6.0", "@wdio/logger": "8.38.0", @@ -11133,9 +11113,8 @@ }, "node_modules/browser-stdout": { "version": "1.3.1", - "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", - "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/browserslist": { "version": "4.23.3", @@ -13970,17 +13949,6 @@ "url": "https://eslint.org/donate" } }, - "node_modules/eslint-config-google": { - "version": "0.14.0", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=0.10.0" - }, - "peerDependencies": { - "eslint": ">=5.16.0" - } - }, "node_modules/eslint-plugin-lit": { "version": "1.14.0", "dev": true, @@ -14952,9 +14920,8 @@ }, "node_modules/flat": { "version": "5.0.2", - "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", - "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", "dev": true, + "license": "BSD-3-Clause", "bin": { "flat": "cli.js" } @@ -15174,9 +15141,8 @@ }, "node_modules/gaze": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/gaze/-/gaze-1.1.3.tgz", - "integrity": "sha512-BRdNm8hbWzFzWHERTrejLqwHDfS4GibPoq5wjTPIoJHoBtKGPg3xAFfxmM+9ztbXelxcf2hwQcaz1PtmFeue8g==", "dev": true, + "license": "MIT", "dependencies": { "globule": "^1.0.0" }, @@ -15532,9 +15498,8 @@ }, "node_modules/globule": { "version": "1.3.4", - "resolved": "https://registry.npmjs.org/globule/-/globule-1.3.4.tgz", - "integrity": "sha512-OPTIfhMBh7JbBYDpa5b+Q5ptmMWKwcNcFSR/0c6t8V4f3ZAVBEsKNY37QdVqmLRYSMhOUGYrY0QhSoEpzGr/Eg==", "dev": true, + "license": "MIT", "dependencies": { "glob": "~7.1.1", "lodash": "^4.17.21", @@ -15546,9 +15511,8 @@ }, "node_modules/globule/node_modules/brace-expansion": { "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -15556,10 +15520,8 @@ }, "node_modules/globule/node_modules/glob": { "version": "7.1.7", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", - "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==", - "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, + "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -15577,9 +15539,8 @@ }, "node_modules/globule/node_modules/minimatch": { "version": "3.0.8", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.8.tgz", - "integrity": "sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -18550,9 +18511,8 @@ }, "node_modules/mocha": { "version": "10.7.0", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.7.0.tgz", - "integrity": "sha512-v8/rBWr2VO5YkspYINnvu81inSz2y3ODJrhO175/Exzor1RcEZZkizgE2A+w/CAXXoESS8Kys5E62dOHGHzULA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-colors": "^4.1.3", "browser-stdout": "^1.3.1", @@ -18585,9 +18545,8 @@ }, "node_modules/mocha/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -18600,15 +18559,13 @@ }, "node_modules/mocha/node_modules/argparse": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "dev": true, + "license": "Python-2.0" }, "node_modules/mocha/node_modules/cliui": { "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", "dev": true, + "license": "ISC", "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", @@ -18617,9 +18574,8 @@ }, "node_modules/mocha/node_modules/escape-string-regexp": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -18629,9 +18585,8 @@ }, "node_modules/mocha/node_modules/find-up": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, + "license": "MIT", "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -18645,10 +18600,8 @@ }, "node_modules/mocha/node_modules/glob": { "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, + "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -18665,18 +18618,16 @@ }, "node_modules/mocha/node_modules/has-flag": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/mocha/node_modules/js-yaml": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -18686,9 +18637,8 @@ }, "node_modules/mocha/node_modules/locate-path": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, + "license": "MIT", "dependencies": { "p-locate": "^5.0.0" }, @@ -18701,9 +18651,8 @@ }, "node_modules/mocha/node_modules/minimatch": { "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -18713,15 +18662,13 @@ }, "node_modules/mocha/node_modules/ms": { "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/mocha/node_modules/p-limit": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, + "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" }, @@ -18734,9 +18681,8 @@ }, "node_modules/mocha/node_modules/p-locate": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, + "license": "MIT", "dependencies": { "p-limit": "^3.0.2" }, @@ -18749,9 +18695,8 @@ }, "node_modules/mocha/node_modules/strip-ansi": { "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, + "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" }, @@ -18761,9 +18706,8 @@ }, "node_modules/mocha/node_modules/supports-color": { "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -18776,9 +18720,8 @@ }, "node_modules/mocha/node_modules/wrap-ansi": { "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -18793,9 +18736,8 @@ }, "node_modules/mocha/node_modules/yargs": { "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", "dev": true, + "license": "MIT", "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", @@ -20560,9 +20502,8 @@ }, "node_modules/randombytes": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", "dev": true, + "license": "MIT", "dependencies": { "safe-buffer": "^5.1.0" } @@ -21823,9 +21764,8 @@ }, "node_modules/serialize-javascript": { "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "randombytes": "^2.1.0" } @@ -22755,9 +22695,8 @@ }, "node_modules/stream-buffers": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-3.0.3.tgz", - "integrity": "sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw==", "dev": true, + "license": "Unlicense", "engines": { "node": ">= 0.10.0" } @@ -23791,7 +23730,6 @@ }, "node_modules/tree-sitter-json": { "version": "0.20.2", - "hasInstallScript": true, "license": "MIT", "optional": true, "dependencies": { @@ -23800,7 +23738,6 @@ }, "node_modules/tree-sitter-yaml": { "version": "0.5.0", - "hasInstallScript": true, "license": "MIT", "optional": true, "dependencies": { @@ -24899,9 +24836,8 @@ }, "node_modules/webdriver": { "version": "8.40.2", - "resolved": "https://registry.npmjs.org/webdriver/-/webdriver-8.40.2.tgz", - "integrity": "sha512-GoRR94m3yL8tWC9Myf+xIBSdVK8fi1ilZgEZZaYT8+XIWewR02dvrC6rml+/2ZjXUQzeee0RFGDwk9IC7cyYrg==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "^20.1.0", "@types/ws": "^8.5.3", @@ -24921,24 +24857,21 @@ }, "node_modules/webdriver/node_modules/@types/node": { "version": "20.14.14", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.14.tgz", - "integrity": "sha512-d64f00982fS9YoOgJkAMolK7MN8Iq3TDdVjchbYHdEmjth/DHowx82GnoA+tVUAN+7vxfYUgAzi+JXbKNd2SDQ==", "dev": true, + "license": "MIT", "dependencies": { "undici-types": "~5.26.4" } }, "node_modules/webdriver/node_modules/undici-types": { "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/webdriverio": { "version": "8.40.2", - "resolved": "https://registry.npmjs.org/webdriverio/-/webdriverio-8.40.2.tgz", - "integrity": "sha512-6yuzUlE064qNuMy98Du1+8QHbXk0st8qTWF7MDZRgYK19FGoy+KhQbaUv1wlFJuFHM0PiAYuduTURL4ub6HvzQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "^20.1.0", "@wdio/config": "8.40.2", @@ -25133,9 +25066,8 @@ }, "node_modules/workerpool": { "version": "6.5.1", - "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz", - "integrity": "sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==", - "dev": true + "dev": true, + "license": "Apache-2.0" }, "node_modules/wrap-ansi": { "version": "6.2.0", @@ -25314,18 +25246,16 @@ }, "node_modules/yargs-parser": { "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", "dev": true, + "license": "ISC", "engines": { "node": ">=10" } }, "node_modules/yargs-unparser": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", - "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", "dev": true, + "license": "MIT", "dependencies": { "camelcase": "^6.0.0", "decamelize": "^4.0.0", @@ -25338,9 +25268,8 @@ }, "node_modules/yargs-unparser/node_modules/camelcase": { "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -25350,9 +25279,8 @@ }, "node_modules/yargs-unparser/node_modules/decamelize": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", - "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -25362,9 +25290,8 @@ }, "node_modules/yargs-unparser/node_modules/is-plain-obj": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", - "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -25505,9 +25432,7 @@ } }, "packages/sfe/node_modules/@goauthentik/api": { - "version": "2024.6.0-1720200294", - "resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2024.6.0-1720200294.tgz", - "integrity": "sha512-qGpI+0BpsHWlO8waj89q+6SWjVVuRtYqdmpSIrKFsZt9GLNXCvIAvgS5JI1Sq2z1uWK/8kLNZKDocI/XagqMPQ==" + "version": "2024.6.0-1720200294" } } } diff --git a/web/package.json b/web/package.json index 05615915f8..b3466f9b56 100644 --- a/web/package.json +++ b/web/package.json @@ -84,7 +84,6 @@ "cross-env": "^7.0.3", "esbuild": "^0.23.0", "eslint": "^9.8.0", - "eslint-config-google": "^0.14.0", "eslint-plugin-lit": "^1.14.0", "eslint-plugin-sonarjs": "^1.0.4", "eslint-plugin-wc": "^2.1.0", @@ -137,7 +136,9 @@ "format": "wireit", "lint": "wireit", "lint:lockfile": "wireit", + "lint:nightmare": "wireit", "lint:package": "wireit", + "lint:precommit": "wireit", "lit-analyse": "wireit", "postinstall": "bash scripts/patch-spotlight.sh", "precommit": "wireit", diff --git a/web/scripts/eslint-nightmare.mjs b/web/scripts/eslint-nightmare.mjs new file mode 100644 index 0000000000..ae47fcc124 --- /dev/null +++ b/web/scripts/eslint-nightmare.mjs @@ -0,0 +1,67 @@ +import { execFileSync } from "child_process"; +import { ESLint } from "eslint"; +import path from "path"; +import process from "process"; + +// Code assumes this script is in the './web/scripts' folder. +const projectRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf8", +}).replace("\n", ""); +process.chdir(path.join(projectRoot, "./web")); + +const eslintConfig = { + fix: true, + overrideConfig: { + env: { + browser: true, + es2021: true, + }, + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:lit/recommended", + "plugin:custom-elements/recommended", + "plugin:storybook/recommended", + "plugin:sonarjs/recommended", + ], + parser: "@typescript-eslint/parser", + parserOptions: { + ecmaVersion: 12, + sourceType: "module", + project: true, + }, + plugins: ["@typescript-eslint", "lit", "custom-elements", "sonarjs"], + ignorePatterns: ["authentik-live-tests/**", "./.storybook/**/*.ts"], + rules: { + "indent": "off", + "linebreak-style": ["error", "unix"], + "quotes": ["error", "double", { avoidEscape: true }], + "semi": ["error", "always"], + "@typescript-eslint/ban-ts-comment": "off", + "no-unused-vars": "off", + "sonarjs/cognitive-complexity": ["warn", 9], + "sonarjs/no-duplicate-string": "off", + "sonarjs/no-nested-template-literals": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], + "no-console": ["error", { allow: ["debug", "warn", "error"] }], + }, + }, +}; + +const updated = ["./src/", "./build.mjs", "./scripts/*.mjs"]; + +const eslint = new ESLint(eslintConfig); +const results = await eslint.lintFiles(updated); +const formatter = await eslint.loadFormatter("stylish"); +const resultText = formatter.format(results); +const errors = results.reduce((acc, result) => acc + result.errorCount, 0); + +console.log(resultText); +process.exit(errors > 1 ? 1 : 0); diff --git a/web/scripts/eslint-precommit.mjs b/web/scripts/eslint-precommit.mjs new file mode 100644 index 0000000000..d004bd3934 --- /dev/null +++ b/web/scripts/eslint-precommit.mjs @@ -0,0 +1,94 @@ +import { execFileSync } from "child_process"; +import { ESLint } from "eslint"; +import path from "path"; +import process from "process"; + +// Code assumes this script is in the './web/scripts' folder. +const projectRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf8", +}).replace("\n", ""); +process.chdir(path.join(projectRoot, "./web")); + +const eslintConfig = { + fix: true, + overrideConfig: { + env: { + browser: true, + es2021: true, + }, + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:lit/recommended", + "plugin:custom-elements/recommended", + "plugin:storybook/recommended", + "plugin:sonarjs/recommended", + ], + parser: "@typescript-eslint/parser", + parserOptions: { + ecmaVersion: 12, + sourceType: "module", + project: true, + }, + plugins: ["@typescript-eslint", "lit", "custom-elements", "sonarjs"], + ignorePatterns: ["authentik-live-tests/**", "./.storybook/**/*.ts"], + rules: { + "indent": "off", + "linebreak-style": ["error", "unix"], + "quotes": ["error", "double", { avoidEscape: true }], + "semi": ["error", "always"], + "@typescript-eslint/ban-ts-comment": "off", + "no-unused-vars": "off", + "sonarjs/cognitive-complexity": ["warn", 9], + "sonarjs/no-duplicate-string": "off", + "sonarjs/no-nested-template-literals": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], + "no-console": ["error", { allow: ["debug", "warn", "error"] }], + }, + }, +}; + +const porcelainV1 = /^(..)\s+(.*$)/; +const gitStatus = execFileSync("git", ["status", "--porcelain", "."], { encoding: "utf8" }); + +const statuses = gitStatus.split("\n").reduce((acc, line) => { + const match = porcelainV1.exec(line.replace("\n")); + if (!match) { + return acc; + } + const [status, path] = Array.from(match).slice(1, 3); + return [...acc, [status, path.split("\x00")[0]]]; +}, []); + +const isModified = /^(M|\?|\s)(M|\?|\s)/; +const modified = (s) => isModified.test(s); + +const isCheckable = /\.(ts|js|mjs)$/; +const checkable = (s) => isCheckable.test(s); + +const ignored = /\/\.storybook\//; +const notIgnored = (s) => !ignored.test(s); + +const updated = statuses.reduce( + (acc, [status, filename]) => + modified(status) && checkable(filename) && notIgnored(filename) + ? [...acc, path.join(projectRoot, filename)] + : acc, + [], +); + +const eslint = new ESLint(eslintConfig); +const results = await eslint.lintFiles(updated); +const formatter = await eslint.loadFormatter("stylish"); +const resultText = formatter.format(results); +const errors = results.reduce((acc, result) => acc + result.errorCount, 0); + +console.log(resultText); +process.exit(errors > 1 ? 1 : 0); diff --git a/web/scripts/eslint.nightmare.mjs b/web/scripts/eslint.nightmare.mjs index 5c946a4cc6..0c44096ee8 100644 --- a/web/scripts/eslint.nightmare.mjs +++ b/web/scripts/eslint.nightmare.mjs @@ -13,6 +13,8 @@ export default [ { ignores: [ "dist/", + ".wireit/", + "packages/", // don't ever lint node_modules "node_modules/", ".storybook/*", diff --git a/web/scripts/eslint.precommit.mjs b/web/scripts/eslint.precommit.mjs index f934303503..7a71a87b73 100644 --- a/web/scripts/eslint.precommit.mjs +++ b/web/scripts/eslint.precommit.mjs @@ -13,6 +13,8 @@ export default [ { ignores: [ "dist/", + ".wireit/", + "packages/", // don't ever lint node_modules "node_modules/", ".storybook/*", diff --git a/web/src/admin/common/ak-flow-search/ak-flow-search.stories.ts b/web/src/admin/common/ak-flow-search/ak-flow-search.stories.ts index 698b0cb42b..164a27c1a2 100644 --- a/web/src/admin/common/ak-flow-search/ak-flow-search.stories.ts +++ b/web/src/admin/common/ak-flow-search/ak-flow-search.stories.ts @@ -61,7 +61,7 @@ const mockData = { }; const metadata: Meta> = { - title: "Elements / Select Search / Flow", + title: "Elements / Search Select / Flow", component: "ak-flow-search", parameters: { docs: { diff --git a/web/src/elements/ak-dual-select/types.ts b/web/src/elements/ak-dual-select/types.ts index a98b44c5a3..e10d16296d 100644 --- a/web/src/elements/ak-dual-select/types.ts +++ b/web/src/elements/ak-dual-select/types.ts @@ -2,9 +2,21 @@ import { TemplateResult } from "lit"; import { Pagination } from "@goauthentik/api"; -// Key, Label (string or TemplateResult), (optional) string to sort by. If the sort string is -// missing, it will use the label, which doesn't always work for TemplateResults). -export type DualSelectPair = [string, string | TemplateResult, string?, T?]; +// +// - key: string +// - label (string or TemplateResult), +// - sortBy (optional) string to sort by. If the sort string is +// - localMapping: The object the key represents; used by some specific apps. API layers may use +// this as a way to find the preset object. +// +// Note that this is a *tuple*, not a record or map! + +export type DualSelectPair = [ + key: string, + label: string | TemplateResult, + sortBy?: string, + localMapping?: T, +]; export type BasePagination = Pick< Pagination, diff --git a/web/src/elements/ak-list-select/ak-list-select.ts b/web/src/elements/ak-list-select/ak-list-select.ts new file mode 100644 index 0000000000..ac8381ad35 --- /dev/null +++ b/web/src/elements/ak-list-select/ak-list-select.ts @@ -0,0 +1,338 @@ +import { AKElement } from "@goauthentik/elements/Base.js"; +import { bound } from "@goauthentik/elements/decorators/bound.js"; +import type { + GroupedOptions, + SelectGroup, + SelectOption, + SelectOptions, +} from "@goauthentik/elements/types.js"; +import { randomId } from "@goauthentik/elements/utils/randomId.js"; +import { match } from "ts-pattern"; + +import { PropertyValueMap, css, html, nothing } from "lit"; +import { customElement, property, query, state } from "lit/decorators.js"; + +import PFDropdown from "@patternfly/patternfly/components/Dropdown/dropdown.css"; +import PFSelect from "@patternfly/patternfly/components/Select/select.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +import { groupOptions, isVisibleInScrollRegion } from "./utils.js"; + +export interface IListSelect { + options: SelectOptions; + value?: string; + emptyOption?: string; +} + +/** + * @class ListSelect + * @element ak-list-select + * + * authentik scrolling list select element + * + * Provides a menu of elements to be used for selection. + * + * - @prop options (SelectOption[]): The options to display. + * - @attr value (string): the current value of the Component + * - @attr emptyOption (string): if defined, the component can be `undefined` and will + * display this string at the top. + * + * - @fires change: When the value of the element has changed + * + * - @part ak-list-select-wrapper: the `
` that contains the whole + * - @part ak-list-select: the `
    ` that defines the list. This is the component + * to target if you want to change the max height. + * - @part ak-list-select-option: The `
  • ` items of the list + * - @part ak-list-select-button: The ` +
  • `; + } + + private renderMenuItems(options: SelectOption[]) { + return options.map( + ([value, label, desc]: SelectOption) => html` +
  • + +
  • + `, + ); + } + + private renderMenuGroups(optionGroups: SelectGroup[]) { + return optionGroups.map( + ({ name, options }) => html` +
    +

    + ${name} +

    +
      + ${this.renderMenuItems(options)} +
    +
    + `, + ); + } + + public override render() { + return html`
    +
      + ${this.emptyOption === undefined ? nothing : this.renderEmptyMenuItem()} + ${this._options.grouped + ? this.renderMenuGroups(this._options.options) + : this.renderMenuItems(this._options.options)} +
    +
    `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-list-select": ListSelect; + } +} diff --git a/web/src/elements/ak-list-select/stories/ak-list-select.stories.ts b/web/src/elements/ak-list-select/stories/ak-list-select.stories.ts new file mode 100644 index 0000000000..b41852a3e7 --- /dev/null +++ b/web/src/elements/ak-list-select/stories/ak-list-select.stories.ts @@ -0,0 +1,97 @@ +import { EVENT_MESSAGE } from "@goauthentik/common/constants"; +import "@goauthentik/elements/messages/MessageContainer"; +import { Meta, StoryObj } from "@storybook/web-components"; +import { slug } from "github-slugger"; + +import { TemplateResult, html } from "lit"; + +import "../ak-list-select.js"; +import { ListSelect } from "../ak-list-select.js"; +import { groupedSampleData, sampleData } from "./sampleData.js"; + +const longGoodForYouPairs = { + grouped: false, + options: sampleData.map(({ produce }) => [slug(produce), produce]), +}; + +const metadata: Meta = { + title: "Elements / List Select", + component: "ak-list-select", + parameters: { + docs: { + description: { + component: "A scrolling component from which elements can be selected", + }, + }, + }, + argTypes: { + options: { + type: "string", + description: "An array of [key, label, desc] pairs of what to show", + }, + }, +}; + +export default metadata; + +type Story = StoryObj; + +const sendMessage = (message: string) => + document.dispatchEvent( + new CustomEvent(EVENT_MESSAGE, { bubbles: true, composed: true, detail: { message } }), + ); + +const container = (testItem: TemplateResult) => { + window.setTimeout(() => { + const menu = document.getElementById("ak-list-select"); + if (!menu) { + throw new Error("Test was not initialized correctly."); + } + menu.addEventListener("focusin", () => sendMessage("Element received focus")); + menu.addEventListener("blur", () => sendMessage("Element lost focus")); + menu.addEventListener("change", (event: Event) => + sendMessage(`Value changed to: ${(event.target as HTMLInputElement)?.value}`), + ); + }, 250); + + return html`
    + + + ${testItem} +
    `; +}; + +export const Default: Story = { + render: () => + container( + html` `, + ), +}; + +export const Grouped: Story = { + render: () => + container( + html` `, + ), +}; diff --git a/web/src/elements/ak-list-select/stories/sampleData.ts b/web/src/elements/ak-list-select/stories/sampleData.ts new file mode 100644 index 0000000000..9fa2161193 --- /dev/null +++ b/web/src/elements/ak-list-select/stories/sampleData.ts @@ -0,0 +1,359 @@ +import { slug } from "github-slugger"; + +import type { TemplateResult } from "lit"; + +// The descriptions were generated by ChatGPT. Don't blame us. + +export type ViewSample = { + produce: string; + seasons: string[]; + desc?: string; +}; + +export const sampleData: ViewSample[] = [ + { + produce: "Apples", + seasons: ["Spring", "Summer", "Fall", "Winter"], + desc: "Apples are a sweet and crunchy fruit that can be eaten fresh or used in pies, juice, and ciders.", + }, + { + produce: "Apricots", + seasons: ["Spring", "Summer"], + desc: "Apricots are a sweet and tangy stone fruit with a velvety skin that's often orange-yellow in color", + }, + { + produce: "Asparagus", + seasons: ["Spring"], + desc: "Asparagus is a delicate and nutritious vegetable with a tender spear-like shape", + }, + { + produce: "Avocados", + seasons: ["Spring", "Summer", "Winter"], + desc: "Avocados are a nutritious fruit with a creamy texture and nutty flavor", + }, + { + produce: "Bananas", + seasons: ["Spring", "Summer", "Fall", "Winter"], + desc: "Bananas are a type of curved, yellow fruit that grows on banana plants", + }, + { + produce: "Beets", + seasons: ["Summer", "Fall", "Winter"], + desc: "Beets are a sweet and earthy root vegetable that can be pickled, roasted, or boiled", + }, + { + produce: "Bell Peppers", + seasons: ["Summer", "Fall"], + desc: "Bell peppers are a sweet and crunchy type of pepper that can be green, red, yellow, or orange", + }, + { + produce: "Blackberries", + seasons: ["Summer"], + desc: "Blackberries are a type of fruit that are dark purple in color and have a sweet-tart taste", + }, + { + produce: "Blueberries", + seasons: ["Summer"], + desc: "Blueberries are small, round, and sweet-tart berries with a powdery coating and a burst of juicy flavor.", + }, + { + produce: "Broccoli", + seasons: ["Spring", "Fall"], + desc: "Broccoli is a green, cruciferous vegetable with a tree-like shape and a slightly bitter taste.", + }, + { + produce: "Brussels Sprouts", + seasons: ["Fall", "Winter"], + desc: "Brussels sprouts are a cruciferous vegetable that is small, green, and formed like a tiny cabbage head, with a sweet and slightly bitter flavor.", + }, + { + produce: "Cabbage", + seasons: ["Spring", "Fall", "Winter"], + desc: "Cabbage is a crunchy, sweet, and slightly bitter vegetable with a dense head of tightly packed leaves.", + }, + { + produce: "Cantaloupe", + seasons: ["Summer"], + desc: "Cantaloupe is a sweet and juicy melon with a netted or reticulated rind and yellow-orange flesh.", + }, + { + produce: "Carrots", + seasons: ["Spring", "Summer", "Fall", "Winter"], + desc: "Carrots are a crunchy and sweet root vegetable commonly eaten raw or cooked in various dishes.", + }, + { + produce: "Cauliflower", + seasons: ["Fall"], + desc: "Cauliflower is a cruciferous vegetable with a white or pale yellow florets resembling tiny trees", + }, + { + produce: "Celery", + seasons: ["Spring", "Summer", "Fall", "Winter"], + desc: "Celery is a crunchy, sweet-tasting vegetable with a mild flavor, often used in salads and as a snack.", + }, + { + produce: "Cherries", + seasons: ["Summer"], + desc: "Cherries are a sweet and juicy stone fruit that typically range in color from bright red to dark purple.", + }, + { + produce: "Collard Greens", + seasons: ["Spring", "Fall", "Winter"], + desc: "Collard greens are a type of leafy green vegetable with a slightly bitter and earthy flavor.", + }, + { + produce: "Corn", + seasons: ["Summer"], + desc: "Corn is a sweet and savory grain that can be eaten fresh or used in various dishes, such as soups, salads, and baked goods.", + }, + { + produce: "Cranberries", + seasons: ["Fall"], + desc: "Cranberries are a type of small, tart-tasting fruit native to North America", + }, + { + produce: "Cucumbers", + seasons: ["Summer"], + desc: "Cucumbers are a long, green vegetable that is commonly consumed raw or pickled", + }, + { + produce: "Eggplant", + seasons: ["Summer"], + desc: "Eggplant is a purple vegetable with a spongy texture and a slightly bitter taste.", + }, + { + produce: "Garlic", + seasons: ["Spring", "Summer", "Fall"], + desc: "Garlic is a pungent and flavorful herb with a distinctive aroma and taste", + }, + { + produce: "Ginger", + seasons: ["Fall"], + desc: "Ginger is a spicy, sweet, and tangy root commonly used in Asian cuisine to add warmth and depth", + }, + { + produce: "Grapefruit", + seasons: ["Winter"], + desc: "Grapefruit is a tangy and sweet citrus fruit with a tart flavor profile and a slightly bitter aftertaste.", + }, + { + produce: "Grapes", + seasons: ["Fall"], + desc: "Grapes are a type of fruit that grow in clusters on vines and are often eaten fresh or used to make wine, jam, and juice.", + }, + { + produce: "Green Beans", + seasons: ["Summer", "Fall"], + desc: "Green beans are a type of long, thin, green vegetable that is commonly eaten as a side dish or used in various recipes.", + }, + { + produce: "Herbs", + seasons: ["Spring", "Summer", "Fall", "Winter"], + desc: "Herbs are plant parts, such as leaves, stems, or flowers, used to add flavor or aroma", + }, + { + produce: "Honeydew Melon", + seasons: ["Summer"], + desc: "Honeydew melons are sweet and refreshing, with a smooth, pale green rind and juicy, creamy white flesh.", + }, + { + produce: "Kale", + seasons: ["Spring", "Fall", "Winter"], + desc: "Kale is a type of leafy green vegetable that is packed with nutrients and has a slightly bitter, earthy flavor.", + }, + { + produce: "Kiwifruit", + seasons: ["Spring", "Fall", "Winter"], + desc: "Kiwifruit is a small, oval-shaped fruit with a fuzzy exterior and bright green or yellow flesh that tastes sweet and slightly tart.", + }, + { + produce: "Leeks", + seasons: ["Winter"], + desc: "Leeks are a type of vegetable that is similar to onions and garlic, but has a milder flavor and a more delicate texture.", + }, + { + produce: "Lemons", + seasons: ["Spring", "Summer", "Fall", "Winter"], + desc: "Lemons are a sour and tangy citrus fruit with a bright yellow color and a strong, distinctive flavor used in cooking, cleaning, and as a natural remedy.", + }, + { + produce: "Lettuce", + seasons: ["Spring", "Fall"], + desc: "Lettuce is a crisp and refreshing green leafy vegetable often used in salads.", + }, + { + produce: "Lima Beans", + seasons: ["Summer"], + desc: "Lima beans are a type of green legume with a mild flavor and soft, creamy texture.", + }, + { + produce: "Limes", + seasons: ["Spring", "Summer", "Fall", "Winter"], + desc: "Limes are small, citrus fruits with a sour taste and a bright green color.", + }, + { + produce: "Mangos", + seasons: ["Summer", "Fall"], + desc: "Mangos are sweet and creamy tropical fruits with a velvety texture", + }, + { + produce: "Mushrooms", + seasons: ["Spring", "Fall"], + desc: "Mushrooms are a type of fungus that grow underground or on decaying organic matter", + }, + { + produce: "Okra", + seasons: ["Summer"], + desc: "Okra is a nutritious, green vegetable with a unique texture and flavor", + }, + { + produce: "Onions", + seasons: ["Spring", "Fall", "Winter"], + desc: "Onions are a type of vegetable characterized by their layered, bulbous structure and pungent flavor.", + }, + { + produce: "Oranges", + seasons: ["Winter"], + desc: "Oranges are a sweet and juicy citrus fruit with a thick, easy-to-peel skin.", + }, + { + produce: "Parsnips", + seasons: ["Fall", "Winter"], + desc: "Parsnips are a type of root vegetable that is sweet and nutty in flavor, with a texture similar to carrots.", + }, + { + produce: "Peaches", + seasons: ["Summer"], + desc: "Peaches are sweet and juicy stone fruits with a soft, velvety texture.", + }, + { + produce: "Pears", + seasons: ["Fall", "Winter"], + desc: "Pears are a type of sweet and juicy fruit with a smooth, buttery texture and a mild flavor", + }, + { + produce: "Peas", + seasons: ["Spring", "Fall"], + desc: "Peas are small, round, sweet-tasting legumes that grow on vines and are often eaten as a side dish or added to various recipes.", + }, + { + produce: "Pineapples", + seasons: ["Spring", "Fall", "Winter"], + desc: "Pineapples are a tropical fruit with tough, prickly skin and juicy, sweet flesh.", + }, + { + produce: "Plums", + seasons: ["Summer"], + desc: "Plums are a type of stone fruit characterized by their juicy sweetness and rough, dark skin.", + }, + { + produce: "Potatoes", + seasons: ["Fall", "Winter"], + desc: "Potatoes are a starchy root vegetable that is often brown on the outside and white or yellow on the inside.", + }, + { + produce: "Pumpkin", + seasons: ["Fall", "Winter"], + desc: "Pumpkin is a type of squash that is typically orange in color and is often used to make pies, soups, and other sweet or savory dishes.", + }, + { + produce: "Radishes", + seasons: ["Spring", "Fall"], + desc: "Radishes are a pungent, crunchy and spicy root vegetable that can be eaten raw or cooked,", + }, + { + produce: "Raspberries", + seasons: ["Summer", "Fall"], + desc: "Raspberries are a type of sweet-tart fruit that grows on thorny bushes and is often eaten fresh or used in jams, preserves, and desserts.", + }, + { + produce: "Rhubarb", + seasons: ["Spring"], + desc: "Rhubarb is a perennial vegetable with long, tart stalks that are often used in pies and preserves", + }, + { + produce: "Rutabagas", + seasons: ["Fall", "Winter"], + desc: "Rutabagas are a type of root vegetable that is similar to a cross between a cabbage and a turnip", + }, + { + produce: "Spinach", + seasons: ["Spring", "Fall"], + desc: "Spinach is a nutritious leafy green vegetable that is rich in iron and vitamins A, C, and K.", + }, + { + produce: "Strawberries", + seasons: ["Spring", "Summer"], + desc: "Sweet and juicy, strawberries are a popular type of fruit that grow on low-lying plants with sweet-tasting seeds.", + }, + { + produce: "Summer Squash", + seasons: ["Summer"], + desc: "Summer squash is a type of warm-season vegetable that includes varieties like zucchini, yellow crookneck, and straightneck", + }, + { + produce: "Sweet Potatoes", + seasons: ["Fall", "Winter"], + desc: "Sweet potatoes are a type of root vegetable with a sweet and nutty flavor, often orange in color", + }, + { + produce: "Swiss Chard", + seasons: ["Spring", "Fall", "Winter"], + desc: "Swiss Chard is a leafy green vegetable with a slightly bitter taste and a vibrant red or gold stem", + }, + { + produce: "Tomatillos", + seasons: ["Summer"], + desc: "Tomatillos are a type of fruit that is similar to tomatoes, but with a papery husk and a more tart, slightly sweet flavor.", + }, + { + produce: "Tomatoes", + seasons: ["Summer"], + desc: "Tomatoes are a juicy, sweet, and tangy fruit that is commonly used in salads, sandwiches, and as a topping for various dishes.", + }, + { + produce: "Turnips", + seasons: ["Spring", "Fall", "Winter"], + desc: "Turnips are a root vegetable with a sweet and peppery flavor, often used in soups, stews, and salads.", + }, + { + produce: "Watermelon", + seasons: ["Summer"], + desc: "Watermelon is a juicy and refreshing sweet fruit with a green rind and pink or yellow flesh.", + }, + { + produce: "Winter Squash", + seasons: ["Fall", "Winter"], + desc: "Winter squash is a type of starchy vegetable that is harvested in the fall and has a hard, dry rind that can be stored for several months.", + }, + { + produce: "Zucchini", + seasons: ["Summer"], + desc: "Zucchini is a popular summer squash that is often green or yellow in color and has a mild, slightly sweet flavor.", + }, +]; + +type Seasoned = [string, string, string | TemplateResult]; + +const reseason = (acc: Seasoned[], { produce, seasons, desc }: ViewSample): Seasoned[] => [ + ...acc, + ...seasons.map((s) => [s, produce, desc] as Seasoned), +]; + +export const groupedSampleData = (() => { + const seasoned: Seasoned[] = sampleData.reduce(reseason, [] as Seasoned[]); + const grouped = Object.groupBy(seasoned, ([season]) => season); + const ungrouped = ([_season, label, desc]: Seasoned) => [slug(label), label, desc]; + + if (grouped === undefined) { + throw new Error("Not possible with existing data."); + } + + return { + grouped: true, + options: ["Spring", "Summer", "Fall", "Winter"].map((season) => ({ + name: season, + options: grouped[season]?.map(ungrouped) ?? [], + })), + }; +})(); diff --git a/web/src/elements/ak-list-select/utils.ts b/web/src/elements/ak-list-select/utils.ts new file mode 100644 index 0000000000..b182c514c5 --- /dev/null +++ b/web/src/elements/ak-list-select/utils.ts @@ -0,0 +1,17 @@ +import type { GroupedOptions, SelectOptions } from "@goauthentik/elements/types"; + +export function isVisibleInScrollRegion(el: HTMLElement, container: HTMLElement) { + const elTop = el.offsetTop; + const elBottom = elTop + el.clientHeight; + const containerTop = container.scrollTop; + const containerBottom = containerTop + container.clientHeight; + return ( + (elTop >= containerTop && elBottom <= containerBottom) || + (elTop < containerTop && containerTop < elBottom) || + (elTop < containerBottom && containerBottom < elBottom) + ); +} + +export function groupOptions(options: SelectOptions): GroupedOptions { + return Array.isArray(options) ? { grouped: false, options: options } : options; +} diff --git a/web/src/elements/forms/SearchSelect/SearchKeyboardController.ts b/web/src/elements/forms/SearchSelect/SearchKeyboardController.ts deleted file mode 100644 index adbe0be564..0000000000 --- a/web/src/elements/forms/SearchSelect/SearchKeyboardController.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { bound } from "@goauthentik/elements/decorators/bound.js"; -import { match } from "ts-pattern"; - -import { LitElement, ReactiveController, ReactiveControllerHost } from "lit"; - -import { - KeyboardControllerCloseEvent, - KeyboardControllerSelectEvent, -} from "./SearchKeyboardControllerEvents.js"; - -type ReactiveElementHost = Partial & LitElement & { value?: string }; -type ValuedHtmlElement = HTMLElement & { value: string }; - -/** - * @class AkKeyboardController - * - * This reactive controller connects to the host and sets up listeners for keyboard events to manage - * a list of elements. Navigational controls (up, down, home, end) do what you'd expect. Enter and Space - * "select" the current item, which means: - * - * - All other items lose focus and tabIndex - * - The selected item gains focus and tabIndex - * - The value of the selected item is sent to the host as an event - * - * @fires ak-keyboard-controller-select - When an element is selected. Contains the `value` of the - * selected item. - * - * @fires ak-keyboard-controller-close - When `Escape` is pressed. Clients can do with this as they - * wish. - * - */ -export class AkKeyboardController implements ReactiveController { - private host: ReactiveElementHost; - - private index: number = 0; - - private selector: string; - - private highlighter: string; - - private items: ValuedHtmlElement[] = []; - - /** - * @arg selector: The class identifier (it *must* be a class identifier) of the DOM objects - * that this controller will be working with. - * - * NOTE: The objects identified by the selector *must* have a `value` associated with them, and - * as in all things HTML, that value must be a string. - * - * @arg highlighter: The class identifier that clients *may* use to set an alternative focus - * on the object. Note that the object will always receive focus. - * - */ - constructor( - host: ReactiveElementHost, - selector = ".ak-select-item", - highlighter = ".ak-highlight-item", - ) { - this.host = host; - host.addController(this); - this.selector = selector[0] === "." ? selector : `.${selector}`; - this.highlighter = highlighter.replace(/^\./, ""); - } - - hostUpdated() { - this.items = Array.from(this.host.renderRoot.querySelectorAll(this.selector)); - const current = this.items.findIndex((item) => item.value === this.host.value); - if (current >= 0) { - this.index = current; - } - } - - hostConnected() { - this.host.addEventListener("keydown", this.onKeydown); - } - - hostDisconnected() { - this.host.removeEventListener("keydown", this.onKeydown); - } - - hostVisible() { - this.items[this.index].focus(); - } - - get current() { - return this.items[this.index]; - } - - get value() { - return this.current?.value; - } - - set value(v: string) { - const index = this.items.findIndex((i) => i.value === v); - if (index !== undefined) { - this.index = index; - this.performUpdate(); - } - } - - private performUpdate() { - const items = this.items; - items.forEach((item) => { - item.classList.remove(this.highlighter); - item.tabIndex = -1; - }); - items[this.index].classList.add(this.highlighter); - items[this.index].tabIndex = 0; - items[this.index].focus(); - } - - @bound - onKeydown(event: KeyboardEvent) { - const key = event.key; - match({ key }) - .with({ key: "ArrowDown" }, () => { - this.index = Math.min(this.index + 1, this.items.length - 1); - this.performUpdate(); - }) - .with({ key: "ArrowUp" }, () => { - this.index = Math.max(this.index - 1, 0); - this.performUpdate(); - }) - .with({ key: "Home" }, () => { - this.index = 0; - this.performUpdate(); - }) - .with({ key: "End" }, () => { - this.index = this.items.length - 1; - this.performUpdate(); - }) - .with({ key: " " }, () => { - this.host.dispatchEvent(new KeyboardControllerSelectEvent(this.value)); - }) - .with({ key: "Enter" }, () => { - this.host.dispatchEvent(new KeyboardControllerSelectEvent(this.value)); - }) - .with({ key: "Escape" }, () => { - this.host.dispatchEvent(new KeyboardControllerCloseEvent()); - }); - } -} diff --git a/web/src/elements/forms/SearchSelect/SearchKeyboardControllerEvents.ts b/web/src/elements/forms/SearchSelect/SearchKeyboardControllerEvents.ts deleted file mode 100644 index 1ab84dd25c..0000000000 --- a/web/src/elements/forms/SearchSelect/SearchKeyboardControllerEvents.ts +++ /dev/null @@ -1,20 +0,0 @@ -export class KeyboardControllerSelectEvent extends Event { - value: string | undefined; - constructor(value: string | undefined) { - super("ak-keyboard-controller-select", { composed: true, bubbles: true }); - this.value = value; - } -} - -export class KeyboardControllerCloseEvent extends Event { - constructor() { - super("ak-keyboard-controller-close", { composed: true, bubbles: true }); - } -} - -declare global { - interface GlobalEventHandlersEventMap { - "ak-keyboard-controller-select": KeyboardControllerSelectEvent; - "ak-keyboard-controller-close": KeyboardControllerCloseEvent; - } -} diff --git a/web/src/elements/forms/SearchSelect/SearchSelect.ts b/web/src/elements/forms/SearchSelect/SearchSelect.ts new file mode 100644 index 0000000000..35b6939409 --- /dev/null +++ b/web/src/elements/forms/SearchSelect/SearchSelect.ts @@ -0,0 +1,260 @@ +import { EVENT_REFRESH } from "@goauthentik/common/constants"; +import { APIErrorTypes, parseAPIError } from "@goauthentik/common/errors"; +import { groupBy } from "@goauthentik/common/utils"; +import { AkControlElement } from "@goauthentik/elements/AkControlElement.js"; +import { PreventFormSubmit } from "@goauthentik/elements/forms/helpers"; +import type { GroupedOptions, SelectGroup, SelectOption } from "@goauthentik/elements/types.js"; +import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter"; +import { randomId } from "@goauthentik/elements/utils/randomId.js"; + +import { msg } from "@lit/localize"; +import { TemplateResult, html } from "lit"; +import { property, state } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +import { ResponseError } from "@goauthentik/api"; + +import "./ak-search-select-view.js"; +import { SearchSelectView } from "./ak-search-select-view.js"; + +type Group = [string, T[]]; + +export interface ISearchSelectBase { + blankable: boolean; + query?: string; + objects?: T[]; + selectedObject?: T; + name?: string; + placeholder: string; + emptyOption: string; +} + +export class SearchSelectBase + extends CustomEmitterElement(AkControlElement) + implements ISearchSelectBase +{ + static get styles() { + return [PFBase]; + } + + // A function which takes the query state object (accepting that it may be empty) and returns a + // new collection of objects. + fetchObjects!: (query?: string) => Promise; + + // A function passed to this object that extracts a string representation of items of the + // collection under search. + renderElement!: (element: T) => string; + + // A function passed to this object that extracts an HTML representation of additional + // information for items of the collection under search. + renderDescription?: (element: T) => string | TemplateResult; + + // A function which returns the currently selected object's primary key, used for serialization + // into forms. + value!: (element: T | undefined) => unknown; + + // A function passed to this object that determines an object in the collection under search + // should be automatically selected. Only used when the search itself is responsible for + // fetching the data; sets an initial default value. + selected?: (element: T, elements: T[]) => boolean; + + // A function passed to this object (or using the default below) that groups objects in the + // collection under search into categories. + groupBy: (items: T[]) => [string, T[]][] = (items: T[]): [string, T[]][] => { + return groupBy(items, () => { + return ""; + }); + }; + + // Whether or not the dropdown component can be left blank + @property({ type: Boolean }) + blankable = false; + + // An initial string to filter the search contents, and the value of the input which further + // serves to restrict the search + @property() + query?: string; + + // The objects currently available under search + @property({ attribute: false }) + objects?: T[]; + + // The currently selected object + @property({ attribute: false }) + selectedObject?: T; + + // Used to inform the form of the name of the object + @property() + name?: string; + + // The textual placeholder for the search's object, if currently empty. Used as the + // native object's `placeholder` field. + @property() + placeholder: string = msg("Select an object."); + + // A textual string representing "The user has affirmed they want to leave the selection blank." + // Only used if `blankable` above is true. + @property() + emptyOption = "---------"; + + isFetchingData = false; + + @state() + error?: APIErrorTypes; + + public toForm(): unknown { + if (!this.objects) { + throw new PreventFormSubmit(msg("Loading options...")); + } + return this.value(this.selectedObject) || ""; + } + + public json() { + return this.toForm(); + } + + public async updateData() { + if (this.isFetchingData) { + return Promise.resolve(); + } + this.isFetchingData = true; + return this.fetchObjects(this.query) + .then((objects) => { + objects.forEach((obj) => { + if (this.selected && this.selected(obj, objects || [])) { + this.selectedObject = obj; + this.dispatchCustomEvent("ak-change", { value: this.selectedObject }); + } + }); + this.objects = objects; + this.isFetchingData = false; + }) + .catch((exc: ResponseError) => { + this.isFetchingData = false; + this.objects = undefined; + parseAPIError(exc).then((err) => { + this.error = err; + }); + }); + } + + public override connectedCallback(): void { + super.connectedCallback(); + this.setAttribute("data-ouia-component-type", "ak-search-select"); + this.setAttribute("data-ouia-component-id", this.getAttribute("id") || randomId()); + this.dataset.akControl = "true"; + this.updateData(); + this.addEventListener(EVENT_REFRESH, this.updateData); + } + + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this.removeEventListener(EVENT_REFRESH, this.updateData); + } + + private onSearch(event: InputEvent) { + const value = (event.target as SearchSelectView).rawValue; + if (value === undefined) { + this.selectedObject = undefined; + return; + } + + this.query = value; + this.updateData()?.then(() => { + this.dispatchCustomEvent("ak-change", { value: this.selectedObject }); + }); + } + + private onSelect(event: InputEvent) { + const value = (event.target as SearchSelectView).value; + if (value === undefined) { + this.selectedObject = undefined; + this.dispatchCustomEvent("ak-change", { value: undefined }); + return; + } + const selected = (this.objects ?? []).find((obj) => `${this.value(obj)}` === value); + if (!selected) { + console.warn(`ak-search-select: No corresponding object found for value (${value}`); + } + this.selectedObject = selected; + this.dispatchCustomEvent("ak-change", { value: this.selectedObject }); + } + + private getGroupedItems(): GroupedOptions { + const groupedItems = this.groupBy(this.objects || []); + + const makeSearchTuples = (items: T[]): SelectOption[] => + items.map((item) => [ + `${this.value(item)}`, + this.renderElement(item), + this.renderDescription ? this.renderDescription(item) : undefined, + ]); + + const makeSearchGroups = (items: Group[]): SelectGroup[] => + items.map((group) => ({ + name: group[0], + options: makeSearchTuples(group[1]), + })); + + if (groupedItems.length === 0) { + return { grouped: false, options: [] }; + } + + if ( + groupedItems.length === 1 && + (groupedItems[0].length < 1 || groupedItems[0][0] === "") + ) { + return { + grouped: false, + options: makeSearchTuples(groupedItems[0][1]), + }; + } + + return { + grouped: true, + options: makeSearchGroups(groupedItems), + }; + } + + public override performUpdate() { + this.removeAttribute("data-ouia-component-safe"); + super.performUpdate(); + } + + public override render() { + if (this.error) { + return html`${msg("Failed to fetch objects: ")} ${this.error.detail}`; + } + + if (!this.objects) { + return html`${msg("Loading...")}`; + } + + const options = this.getGroupedItems(); + const value = this.selectedObject ? `${this.value(this.selectedObject) ?? ""}` : undefined; + + return html` `; + } + + public override updated() { + // It is not safe for automated tests to interact with this component while it is fetching + // data. + if (!this.isFetchingData) { + this.setAttribute("data-ouia-component-safe", "true"); + } + } +} + +export default SearchSelectBase; diff --git a/web/src/elements/forms/SearchSelect/SearchSelectEvents.ts b/web/src/elements/forms/SearchSelect/SearchSelectEvents.ts deleted file mode 100644 index 16257f6d9b..0000000000 --- a/web/src/elements/forms/SearchSelect/SearchSelectEvents.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * class SearchSelectSelectEvent - * - * Intended meaning: the user selected an item from the entire dialogue, either by clicking on it - * with the mouse, or selecting it with the keyboard controls and pressing Enter or Space. - */ -export class SearchSelectSelectEvent extends Event { - value: string | undefined; - constructor(value: string | undefined) { - super("ak-search-select-select", { composed: true, bubbles: true }); - this.value = value; - } -} - -/** - * class SearchSelectSelectMenuEvent - * - * Intended meaning: the user selected an item from the menu, either by clicking on it with the - * mouse, or selecting it with the keyboard controls and pressing Enter or Space. This is - * intercepted an interpreted internally, usually resulting in a throw of SearchSelectSelectEvent. - * They have to be distinct to avoid an infinite event loop. - */ -export class SearchSelectSelectMenuEvent extends Event { - value: string | undefined; - constructor(value: string | undefined) { - super("ak-search-select-select-menu", { composed: true, bubbles: true }); - this.value = value; - } -} - -/** - * class SearchSelectCloseEvent - * - * Intended meaning: the user requested that the menu dropdown close. Usually triggered by pressing - * the Escape key. - */ -export class SearchSelectCloseEvent extends Event { - constructor() { - super("ak-search-select-close", { composed: true, bubbles: true }); - } -} - -/** - * class SearchSelectInputEvent - * - * Intended meaning: the user made a change to the content of the `` field - */ -export class SearchSelectInputEvent extends Event { - value: string | undefined; - constructor(value: string | undefined) { - super("ak-search-select-input", { composed: true, bubbles: true }); - this.value = value; - } -} - -declare global { - interface GlobalEventHandlersEventMap { - "ak-search-select-select-menu": SearchSelectSelectMenuEvent; - "ak-search-select-select": SearchSelectSelectEvent; - "ak-search-select-input": SearchSelectInputEvent; - "ak-search-select-close": SearchSelectCloseEvent; - } -} diff --git a/web/src/elements/forms/SearchSelect/ak-portal.ts b/web/src/elements/forms/SearchSelect/ak-portal.ts new file mode 100644 index 0000000000..026b9ef154 --- /dev/null +++ b/web/src/elements/forms/SearchSelect/ak-portal.ts @@ -0,0 +1,143 @@ +import { autoUpdate, computePosition, flip, hide } from "@floating-ui/dom"; +import { randomId } from "@goauthentik/elements/utils/randomId.js"; + +import { LitElement, nothing } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +/** + * @class Portal + * @element ak-portal + * + * An intermediate class to handle a menu and its position. + * + * It has no rendering of its own, and mostly is just a pass-through for options to the menu. + * DOTADIW: it tracks the top-of-DOM object into which we render our menu, guaranteeing that it + * appears above everything else, and operates the positioning control for it. + * + * - @prop anchor (HTMLElement): The component which will be visually associated with the portaled popup. + * - @attr open (boolean): whether or not the component is visible + * - @attr name (string): (optional) used to managed the relationship the portal mediates. + */ + +export interface IPortal { + anchor: HTMLElement; + open: boolean; + name?: string; +} + +@customElement("ak-portal") +export class Portal extends LitElement implements IPortal { + /** + * The host element which will be our reference point for rendering. Is not necessarily + * the element that receives the events. + * + * @prop + */ + @property({ type: Object, attribute: false }) + anchor!: HTMLElement; + + /** + * Whether or not the content is visible + * + * @attr + */ + @property({ type: Boolean, reflect: true }) + open = false; + + /** + * The name; used mostly for the management layer. + * + * @attr + */ + @property() + name?: string; + + /** + * The tether object. + */ + dropdownContainer!: HTMLDivElement; + public cleanup?: () => void; + + connected = false; + + content!: Element; + + connectedCallback() { + super.connectedCallback(); + this.setAttribute("data-ouia-component-type", "ak-portal"); + this.setAttribute("data-ouia-component-id", this.getAttribute("id") || randomId()); + this.dropdownContainer = document.createElement("div"); + this.dropdownContainer.dataset["managedBy"] = "ak-portal"; + if (this.name) { + this.dropdownContainer.dataset["managedFor"] = this.name; + } + document.body.append(this.dropdownContainer); + if (!this.anchor) { + throw new Error("Tether entrance initialized incorrectly: missing anchor"); + } + this.connected = true; + if (this.firstElementChild) { + this.content = this.firstElementChild as Element; + } else { + throw new Error("No content to be portaled included in the tag"); + } + } + + disconnectedCallback(): void { + this.connected = false; + this.dropdownContainer?.remove(); + this.cleanup?.(); + super.disconnectedCallback(); + } + + setPosition() { + if (!(this.anchor && this.dropdownContainer)) { + throw new Error("Tether initialized incorrectly: missing anchor or tether destination"); + } + + this.cleanup = autoUpdate(this.anchor, this.dropdownContainer, async () => { + const { x, y } = await computePosition(this.anchor, this.dropdownContainer, { + placement: "bottom-start", + strategy: "fixed", + middleware: [flip(), hide()], + }); + + Object.assign(this.dropdownContainer.style, { + "position": "fixed", + "display": "block", + "z-index": "9999", + "top": 0, + "left": 0, + "transform": `translate(${x}px, ${y}px)`, + }); + }); + } + + public override performUpdate() { + this.removeAttribute("data-ouia-component-safe"); + super.performUpdate(); + } + + render() { + this.dropdownContainer.appendChild(this.content); + // This is a dummy object that just has to exist to be the communications channel between + // the tethered object and its anchor. + return nothing; + } + + updated() { + (this.content as HTMLElement).style.display = "none"; + if (this.anchor && this.dropdownContainer && this.open && !this.hidden) { + (this.content as HTMLElement).style.display = ""; + this.setPosition(); + } + // Testing should always check if this component is open, even if it's set safe. + this.setAttribute("data-ouia-component-safe", "true"); + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-portal": Portal; + } +} diff --git a/web/src/elements/forms/SearchSelect/ak-search-select-ez.ts b/web/src/elements/forms/SearchSelect/ak-search-select-ez.ts new file mode 100644 index 0000000000..a05d1d7f62 --- /dev/null +++ b/web/src/elements/forms/SearchSelect/ak-search-select-ez.ts @@ -0,0 +1,74 @@ +import { TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +import { type ISearchSelectBase, SearchSelectBase } from "./SearchSelect.js"; + +export interface ISearchSelectApi { + fetchObjects: (query?: string) => Promise; + renderElement: (element: T) => string; + renderDescription?: (element: T) => string | TemplateResult; + value: (element: T | undefined) => unknown; + selected?: (element: T, elements: T[]) => boolean; + groupBy: (items: T[]) => [string, T[]][]; +} + +export interface ISearchSelectEz extends ISearchSelectBase { + config: ISearchSelectApi; +} + +/** + * @class SearchSelectEz + * @element ak-search-select-ez + * + * The API layer of ak-search-select, now in EZ format! + * + * - @prop config (Object): A Record that fulfills the API needed by Search + * Select to retrieve, filter, group, describe, and return elements. + * - @attr blankable (boolean): if true, the component is blankable and can return `undefined` + * - @attr name (string): The name of the component, for forms + * - @attr query (string): The current search criteria for fetching objects + * - @attr placeholder (string): What to show when the input is empty + * - @attr emptyOption (string): What to show in the menu to indicate "leave this undefined". Only + * shown if `blankable` + * - @attr selectedObject (Object): The current object, or undefined, selected + * + * ¹ Due to a limitation in the parsing of properties-vs-attributes, these must be defined as + * properties, not attributes. As a consequence, they must be declared in property syntax. + * Example: + * + * `.renderElement=${"name"}` + * + * - @fires ak-change - When a value from the collection has been positively chosen, either as a + * consequence of the user typing or when selecting from the list. + * + */ + +@customElement("ak-search-select-ez") +export class SearchSelectEz extends SearchSelectBase implements ISearchSelectEz { + static get styles() { + return [PFBase]; + } + + @property({ type: Object, attribute: false }) + config!: ISearchSelectApi; + + connectedCallback() { + this.fetchObjects = this.config.fetchObjects; + this.renderElement = this.config.renderElement; + this.renderDescription = this.config.renderDescription; + this.value = this.config.value; + this.selected = this.config.selected; + this.groupBy = this.config.groupBy; + super.connectedCallback(); + } +} + +export default SearchSelectEz; + +declare global { + interface HTMLElementTagNameMap { + "ak-search-select-ez": SearchSelectEz; + } +} diff --git a/web/src/elements/forms/SearchSelect/ak-search-select-menu-position.ts b/web/src/elements/forms/SearchSelect/ak-search-select-menu-position.ts deleted file mode 100644 index 3dfdbe46db..0000000000 --- a/web/src/elements/forms/SearchSelect/ak-search-select-menu-position.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { autoUpdate, computePosition, flip, hide } from "@floating-ui/dom"; - -import { LitElement, html, nothing, render } from "lit"; -import { customElement, property } from "lit/decorators.js"; -import { ifDefined } from "lit/directives/if-defined.js"; -import { Ref, createRef, ref } from "lit/directives/ref.js"; - -import { KeyboardControllerCloseEvent } from "./SearchKeyboardControllerEvents.js"; -import "./ak-search-select-menu.js"; -import { type SearchSelectMenu } from "./ak-search-select-menu.js"; -import type { SearchOptions } from "./types.js"; - -/** - * An intermediate class to handle the menu and its position. - * - * It has no rendering of its own, and mostly is just a pass-through for options to the menu. - * DOTADIW: it tracks the top-of-DOM object into which we render our menu, guaranteeing that it - * appears above everything else, and operates the positioning control for it. - * - * - @fires ak-search-select-close - Fired (by the keyboard controller) when the tethered end loses - * focus. Clients can do with this information as they wish. - */ - -@customElement("ak-search-select-menu-position") -export class SearchSelectMenuPosition extends LitElement { - /** - * The host to which all relevant events will be routed. Useful for managing floating / tethered - * components. - * - * @prop - */ - @property({ type: Object, attribute: false }) - host!: HTMLElement; - - /** - * The host element which will be our reference point for rendering. - * - * @prop - */ - @property({ type: Object, attribute: false }) - anchor!: HTMLElement; - - /** - * Passthrough of the options that we'll be rendering. - * - * @prop - */ - @property({ type: Array, attribute: false }) - options: SearchOptions = []; - - /** - * Passthrough of the current value - * - * @prop - */ - @property() - value?: string; - - /** - * If undefined, there will be no empty option shown - * - * @attr - */ - @property() - emptyOption?: string; - - /** - * Whether or not the menu is visible - * - * @attr - */ - @property({ type: Boolean, reflect: true }) - open = false; - - /** - * The name; used mostly for the management layer. - * - * @attr - */ - @property() - name?: string; - - /** - * The tether object. - */ - dropdownContainer!: HTMLDivElement; - public cleanup?: () => void; - - connected = false; - - /** - *Communicates forward with the menu to detect when the tether has lost focus - */ - menuRef: Ref = createRef(); - - connectedCallback() { - super.connectedCallback(); - this.dropdownContainer = document.createElement("div"); - this.dropdownContainer.dataset["managedBy"] = "ak-search-select"; - if (this.name) { - this.dropdownContainer.dataset["managedFor"] = this.name; - } - document.body.append(this.dropdownContainer); - if (!this.host) { - throw new Error("Tether entrance initialized incorrectly: missing host"); - } - this.connected = true; - } - - disconnectedCallback(): void { - this.connected = false; - this.dropdownContainer?.remove(); - this.cleanup?.(); - super.disconnectedCallback(); - } - - setPosition() { - if (!(this.anchor && this.dropdownContainer)) { - throw new Error("Tether initialized incorrectly: missing anchor or tether destination"); - } - - this.cleanup = autoUpdate(this.anchor, this.dropdownContainer, async () => { - const { x, y } = await computePosition(this.anchor, this.dropdownContainer, { - placement: "bottom-start", - strategy: "fixed", - middleware: [flip(), hide()], - }); - - Object.assign(this.dropdownContainer.style, { - "position": "fixed", - "z-index": "9999", - "top": 0, - "left": 0, - "transform": `translate(${x}px, ${y}px)`, - }); - }); - } - - updated() { - if (this.anchor && this.dropdownContainer && !this.hidden) { - this.setPosition(); - } - } - - hasFocus() { - return ( - this.menuRef.value && - (this.menuRef.value === document.activeElement || - this.menuRef.value.renderRoot.contains(document.activeElement)) - ); - } - - onFocusOut() { - this.dispatchEvent(new KeyboardControllerCloseEvent()); - } - - render() { - // The 'hidden' attribute is a little weird and the current Typescript definition for - // it is incompatible with actual implementations, so we drill `open` all the way down, - // but we set the hidden attribute here, and on the actual menu use CSS and the - // the attribute's presence to hide/show as needed. - render( - html``, - this.dropdownContainer, - ); - // This is a dummy object that just has to exist to be the communications channel between - // the tethered object and its anchor. - return nothing; - } -} - -declare global { - interface HTMLElementTagNameMap { - "ak-search-select-menu-position": SearchSelectMenuPosition; - } -} diff --git a/web/src/elements/forms/SearchSelect/ak-search-select-menu.ts b/web/src/elements/forms/SearchSelect/ak-search-select-menu.ts deleted file mode 100644 index 58b305931d..0000000000 --- a/web/src/elements/forms/SearchSelect/ak-search-select-menu.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { AKElement } from "@goauthentik/elements/Base.js"; -import { bound } from "@goauthentik/elements/decorators/bound.js"; - -import { PropertyValues, css, html, nothing } from "lit"; -import { customElement, property } from "lit/decorators.js"; - -import PFDropdown from "@patternfly/patternfly/components/Dropdown/dropdown.css"; -import PFSelect from "@patternfly/patternfly/components/Select/select.css"; -import PFBase from "@patternfly/patternfly/patternfly-base.css"; - -import { AkKeyboardController } from "./SearchKeyboardController.js"; -import { - KeyboardControllerCloseEvent, - KeyboardControllerSelectEvent, -} from "./SearchKeyboardControllerEvents.js"; -import { SearchSelectCloseEvent, SearchSelectSelectMenuEvent } from "./SearchSelectEvents.js"; -import type { GroupedOptions, SearchGroup, SearchOptions, SearchTuple } from "./types.js"; - -/** - * @class SearchSelectMenu - * @element ak-search-select-menu - * - * The actual renderer of our components. Intended to be positioned and controlled automatically - * from the outside. - * - * @fires ak-search-select-select - An element has been selected. Contains the `value` of the - * selected item. - * - * @fires ak-search-select-close - The user has triggered the `close` event. Clients can do with this - * as they wish. - */ - -@customElement("ak-search-select-menu") -export class SearchSelectMenu extends AKElement { - static get styles() { - return [ - PFBase, - PFDropdown, - PFSelect, - css` - :host { - overflow: visible; - z-index: 9999; - } - - :host([hidden]) { - display: none; - } - - .pf-c-dropdown__menu { - max-height: 50vh; - overflow-y: auto; - } - `, - ]; - } - - /** - * The host to which all relevant events will be routed. Useful for managing floating / tethered - * components. - */ - @property({ type: Object, attribute: false }) - host!: HTMLElement; - - /** - * See the search options type, described in the `./types` file, for the relevant types. - */ - @property({ type: Array, attribute: false }) - options: SearchOptions = []; - - @property() - value?: string; - - @property() - emptyOption?: string; - - @property({ type: Boolean, reflect: true }) - open = false; - - private keyboardController: AkKeyboardController; - - constructor() { - super(); - this.keyboardController = new AkKeyboardController(this); - this.addEventListener("ak-keyboard-controller-select", this.onKeySelect); - this.addEventListener("ak-keyboard-controller-close", this.onKeyClose); - } - - // Handles the "easy mode" of just passing an array of tuples. - fixedOptions(): GroupedOptions { - return Array.isArray(this.options) - ? { grouped: false, options: this.options } - : this.options; - } - - @bound - onClick(event: Event, value: string) { - event.stopPropagation(); - this.host.dispatchEvent(new SearchSelectSelectMenuEvent(value)); - this.value = value; - } - - @bound - onEmptyClick(event: Event) { - event.stopPropagation(); - this.host.dispatchEvent(new SearchSelectSelectMenuEvent(undefined)); - this.value = undefined; - } - - @bound - onKeySelect(event: KeyboardControllerSelectEvent) { - event.stopPropagation(); - this.value = event.value; - this.host.dispatchEvent(new SearchSelectSelectMenuEvent(this.value)); - } - - @bound - onKeyClose(event: KeyboardControllerCloseEvent) { - event.stopPropagation(); - this.host.dispatchEvent(new SearchSelectCloseEvent()); - } - - updated(changed: PropertyValues) { - if (changed.has("open") && this.open) { - this.keyboardController.hostVisible(); - } - } - - renderEmptyMenuItem() { - return html`
  • - -
  • `; - } - - renderMenuItems(options: SearchTuple[]) { - return options.map( - ([value, label, desc]: SearchTuple) => html` -
  • - -
  • - `, - ); - } - - renderMenuGroups(options: SearchGroup[]) { - return options.map( - ({ name, options }) => html` -
    -

    ${name}

    -
      - ${this.renderMenuItems(options)} -
    -
    - `, - ); - } - - render() { - const options = this.fixedOptions(); - return html`
    -
      - ${this.emptyOption !== undefined ? this.renderEmptyMenuItem() : nothing} - ${options.grouped - ? this.renderMenuGroups(options.options) - : this.renderMenuItems(options.options)} -
    -
    `; - } -} - -declare global { - interface HTMLElementTagNameMap { - "ak-search-select-menu": SearchSelectMenu; - } -} diff --git a/web/src/elements/forms/SearchSelect/ak-search-select-view.ts b/web/src/elements/forms/SearchSelect/ak-search-select-view.ts index 1d39d6d012..7324b9edc4 100644 --- a/web/src/elements/forms/SearchSelect/ak-search-select-view.ts +++ b/web/src/elements/forms/SearchSelect/ak-search-select-view.ts @@ -1,10 +1,13 @@ import { AKElement } from "@goauthentik/elements/Base"; +import "@goauthentik/elements/ak-list-select/ak-list-select.js"; +import { ListSelect } from "@goauthentik/elements/ak-list-select/ak-list-select.js"; import { bound } from "@goauthentik/elements/decorators/bound.js"; -import "@goauthentik/elements/forms/SearchSelect/ak-search-select-menu-position.js"; -import type { SearchSelectMenuPosition } from "@goauthentik/elements/forms/SearchSelect/ak-search-select-menu-position.js"; +import "@goauthentik/elements/forms/SearchSelect/ak-portal.js"; +import type { GroupedOptions, SelectOption, SelectOptions } from "@goauthentik/elements/types.js"; +import { randomId } from "@goauthentik/elements/utils/randomId.js"; import { msg } from "@lit/localize"; -import { PropertyValues, html } from "lit"; +import { PropertyValues, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { Ref, createRef, ref } from "lit/directives/ref.js"; @@ -14,14 +17,19 @@ import PFFormControl from "@patternfly/patternfly/components/FormControl/form-co import PFSelect from "@patternfly/patternfly/components/Select/select.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; -import { - SearchSelectCloseEvent, - SearchSelectInputEvent, - SearchSelectSelectEvent, - SearchSelectSelectMenuEvent, -} from "./SearchSelectEvents.js"; -import type { SearchOptions } from "./types.js"; -import { optionsToOptionsMap } from "./utils.js"; +import { findFlatOptions, findOptionsSubset, groupOptions, optionsToFlat } from "./utils.js"; + +export interface ISearchSelectView { + options: SelectOptions; + value?: string; + open: boolean; + blankable: boolean; + caseSensitive: boolean; + name?: string; + placeholder: string; + managed: boolean; + emptyOption: string; +} /** * @class SearchSelectView @@ -30,8 +38,26 @@ import { optionsToOptionsMap } from "./utils.js"; * Main component of ak-search-select, renders the object and controls interaction with the * portaled menu list. * - * @fires ak-search-select-input - When the user selects an item from the list. A derivative Event - * with the `value` as its payload. + * - @prop options! (GroupedOptions): The options passed to the component + * - @attr value? (string): The current value. Reflected. + * - @attr open (boolean): if the menu dropdown is visible + * - @attr blankable (boolean): if true, the component is blankable and can return `undefined` + * - @attr managed (boolean): if true, the options and search are managed by a higher-level + component. + * - @attr caseSensitive (boolean): if `managed`, local searches will be case sensitive. False by + default. + * - @attr name? (string): The name of the component, for forms + * - @attr placeholder (string): What to show when the input is empty + * - @attr emptyOption (string): What to show in the menu to indicate "leave this undefined". Only + * shown if `blankable` + * + * - @fires change - When a value from the list has been positively chosen, either as a consequence of + * the user typing or when selecting from the list. + * + * - @part ak-search-select: The main Patternfly div + * - @part ak-search-select-toggle: The Patternfly inner div + * - @part ak-search-select-wrapper: Yet another Patternfly inner div + * - @part ak-search-select-toggle-typeahead: The `` component itself * * Note that this is more on the HTML / Web Component side of the operational line: the keys which * represent the values we pass back to clients are always strings here. This component is strictly @@ -41,9 +67,8 @@ import { optionsToOptionsMap } from "./utils.js"; * the object that key references when extracting the value for use. * */ - @customElement("ak-search-select-view") -export class SearchSelectView extends AKElement { +export class SearchSelectView extends AKElement implements ISearchSelectView { /** * The options collection. The simplest variant is just [key, label, optional]. See * the `./types.ts` file for variants and how to use them. @@ -51,16 +76,33 @@ export class SearchSelectView extends AKElement { * @prop */ @property({ type: Array, attribute: false }) - options: SearchOptions = []; + set options(options: SelectOptions) { + this._options = groupOptions(options); + this.flatOptions = optionsToFlat(this._options); + } + + get options() { + return this._options; + } + + _options!: GroupedOptions; /** * The current value. Must be one of the keys in the options group above. * * @prop */ - @property() + @property({ type: String, reflect: true }) value?: string; + /** + * Whether or not the dropdown is open + * + * @attr + */ + @property({ type: Boolean, reflect: true }) + open = false; + /** * If set to true, this object MAY return undefined in no value is passed in and none is set * during interaction. @@ -70,31 +112,42 @@ export class SearchSelectView extends AKElement { @property({ type: Boolean }) blankable = false; + /** + * If not managed, make the matcher case-sensitive during interaction. If managed, + * the manager must handle this. + * + * @attr + */ + @property({ type: Boolean, attribute: "case-sensitive" }) + caseSensitive = false; + /** * The name of the input, for forms * * @attr */ - @property() + @property({ type: String }) name?: string; - /** - * Whether or not the portal is open - * - * @attr - */ - @property({ type: Boolean, reflect: true }) - open = false; - /** * The textual placeholder for the search's object, if currently empty. Used as the * native object's `placeholder` field. * * @attr */ - @property() + @property({ type: String }) placeholder: string = msg("Select an object."); + /** + * If true, the component only sends an input message up to a parent component. If false, the + * list of options sent downstream will be filtered by the contents of the `` field + * locally. + * + *@attr + */ + @property({ type: Boolean }) + managed = false; + /** * A textual string representing "The user has affirmed they want to leave the selection blank." * Only used if `blankable` above is true. @@ -106,136 +159,206 @@ export class SearchSelectView extends AKElement { // Handle the behavior of the drop-down when the :host scrolls off the page. scrollHandler?: () => void; - observer: IntersectionObserver; + + // observer: IntersectionObserver; @state() displayValue = ""; + + // Tracks when the inputRef is populated, so we can safely reschedule the + // render of the dropdown with respect to it. + @state() + inputRefIsAvailable = false; + + /** + * Permanent identity with the portal so focus events can be checked. + */ + menuRef: Ref = createRef(); + /** * Permanent identify for the input object, so the floating portal can find where to anchor * itself. */ inputRef: Ref = createRef(); - /** - * Permanent identity with the portal so focus events can be checked. - */ - menuRef: Ref = createRef(); - /** * Maps a value from the portal to labels to be put into the field> */ - optionsMap: Map = new Map(); + flatOptions: [string, SelectOption][] = []; static get styles() { return [PFBase, PFForm, PFFormControl, PFSelect]; } - constructor() { - super(); - this.observer = new IntersectionObserver(() => { - this.open = false; - }); - this.observer.observe(this); - - /* These can't be attached with the `@` syntax because they're not passed through to the - * menu; the positioner is in the way, and it deliberately renders objects *outside* of the - * path from `document` to this object. That's why we pass the positioner (and its target) - * the `this` (host) object; so they can send messages to this object despite being outside - * the event's bubble path. - */ - this.addEventListener("ak-search-select-select-menu", this.onSelect); - this.addEventListener("ak-search-select-close", this.onClose); + connectedCallback() { + super.connectedCallback(); + this.setAttribute("data-ouia-component-type", "ak-search-select-view"); + this.setAttribute("data-ouia-component-id", this.getAttribute("id") || randomId()); } - disconnectedCallback(): void { - this.observer.disconnect(); - super.disconnectedCallback(); - } + // TODO: Reconcile value <-> display value, Reconcile option changes to value <-> displayValue - onOpenEvent(event: Event) { - this.open = true; - if ( - this.blankable && - this.value === this.emptyOption && - event.target && - event.target instanceof HTMLInputElement - ) { - event.target.value = ""; - } + // If the user has changed the content of the input box, they are manipulating the *Label*, not + // the value. We'll have to retroactively decide the value and publish it to any listeners. + settleValue() { + // TODO } @bound - onSelect(event: SearchSelectSelectMenuEvent) { - this.open = false; - this.value = event.value; - this.displayValue = this.value ? (this.optionsMap.get(this.value) ?? this.value ?? "") : ""; - this.dispatchEvent(new SearchSelectSelectEvent(this.value)); - } - - @bound - onClose(event: SearchSelectCloseEvent) { - event.stopPropagation(); + onClick(_ev: Event) { + this.open = !this.open; this.inputRef.value?.focus(); - this.open = false; } - @bound - onFocus(event: FocusEvent) { - this.onOpenEvent(event); - } - - @bound - onClick(event: Event) { - this.onOpenEvent(event); - } - - @bound - onInput(_event: InputEvent) { - this.value = this.inputRef?.value?.value ?? ""; - this.displayValue = this.value ? (this.optionsMap.get(this.value) ?? this.value ?? "") : ""; - this.dispatchEvent(new SearchSelectInputEvent(this.value)); + setFromMatchList(value: string | undefined) { + if (value === undefined) { + return; + } + const probableValue = this.flatOptions.find((option) => option[0] === this.value); + if (probableValue && this.inputRef.value) { + this.inputRef.value.value = probableValue[1][1]; + } } @bound onKeydown(event: KeyboardEvent) { - if (event.key === "Escape") { + if (event.code === "Escape") { event.stopPropagation(); this.open = false; } + if (event.code === "ArrowDown" || event.code === "ArrowUp") { + this.open = true; + } + if (event.code === "Tab" && this.open) { + event.preventDefault(); + this.setFromMatchList(this.value); + this.menuRef.value?.currentElement?.focus(); + } } @bound - onFocusOut(event: FocusEvent) { - event.stopPropagation(); - window.setTimeout(() => { - if (!this.menuRef.value?.hasFocus()) { - this.open = false; + onListBlur(event: FocusEvent) { + // If we lost focus but the menu got it, don't do anything; + const relatedTarget = event.relatedTarget as HTMLElement | undefined; + if ( + relatedTarget && + (this.contains(relatedTarget) || + this.renderRoot.contains(relatedTarget) || + this.menuRef.value?.contains(relatedTarget) || + this.menuRef.value?.renderRoot.contains(relatedTarget)) + ) { + return; + } + this.open = false; + if (this.value === undefined) { + if (this.inputRef.value) { + this.inputRef.value.value = ""; } - }, 0); - } - - willUpdate(changed: PropertyValues) { - if (changed.has("options")) { - this.optionsMap = optionsToOptionsMap(this.options); - } - if (changed.has("value")) { - this.displayValue = this.value - ? (this.optionsMap.get(this.value) ?? this.value ?? "") - : ""; + this.setValue(undefined); } } - updated() { - if (this.inputRef?.value && this.inputRef?.value?.value !== this.displayValue) { - this.inputRef.value.value = this.displayValue; + setValue(newValue: string | undefined) { + this.value = newValue; + this.dispatchEvent(new Event("change", { bubbles: true, composed: true })); // prettier-ignore + } + + findValueForInput() { + const value = this.inputRef.value?.value; + if (value === undefined || value.trim() === "") { + this.setValue(undefined); + return; + } + + const matchesFound = findFlatOptions(this.flatOptions, value); + if (matchesFound.length > 0) { + const newValue = matchesFound[0][0]; + if (newValue === value) { + return; + } + this.setValue(newValue); + } else { + this.setValue(undefined); } } - render() { - return html`
    -
    -
    + @bound + onInput(_ev: InputEvent) { + if (!this.managed) { + this.findValueForInput(); + this.requestUpdate(); + } + this.open = true; + } + + @bound + onListKeydown(event: KeyboardEvent) { + if (event.key === "Escape") { + this.open = false; + this.inputRef.value?.focus(); + } + if (event.key === "Tab" && event.shiftKey) { + event.preventDefault(); + this.inputRef.value?.focus(); + } + } + + @bound + onListChange(event: InputEvent) { + if (!event.target) { + return; + } + const value = (event.target as HTMLInputElement).value; + if (value !== undefined) { + const newDisplayValue = this.findDisplayForValue(value); + if (this.inputRef.value) { + this.inputRef.value.value = newDisplayValue ?? ""; + } + } else if (this.inputRef.value) { + this.inputRef.value.value = ""; + } + this.open = false; + this.setValue(value); + } + + findDisplayForValue(value: string) { + const newDisplayValue = this.flatOptions.find((option) => option[0] === value); + return newDisplayValue ? newDisplayValue[1][1] : undefined; + } + + public override performUpdate() { + this.removeAttribute("data-ouia-component-safe"); + super.performUpdate(); + } + + public override willUpdate(changed: PropertyValues) { + if (changed.has("value") && this.value) { + const newDisplayValue = this.findDisplayForValue(this.value); + if (newDisplayValue) { + this.displayValue = newDisplayValue; + } + } + } + + get rawValue() { + return this.inputRef.value?.value ?? ""; + } + + get managedOptions() { + return this.managed + ? this._options + : findOptionsSubset(this._options, this.rawValue, this.caseSensitive); + } + + public override render() { + const emptyOption = this.blankable ? this.emptyOption : undefined; + const open = this.open; + + return html`
    +
    +
    - `; + ${this.inputRefIsAvailable + ? html` + + + + ` + : nothing}`; + } + + public override updated() { + this.setAttribute("data-ouia-component-safe", "true"); + } + + public override firstUpdated() { + // Route around Lit's scheduling algorithm complaining about re-renders + window.setTimeout(() => { + this.inputRefIsAvailable = Boolean(this.inputRef?.value); + }, 0); } } diff --git a/web/src/elements/forms/SearchSelect/ak-search-select.ts b/web/src/elements/forms/SearchSelect/ak-search-select.ts index 1b21ef4712..0c7bcf9b67 100644 --- a/web/src/elements/forms/SearchSelect/ak-search-select.ts +++ b/web/src/elements/forms/SearchSelect/ak-search-select.ts @@ -1,27 +1,61 @@ -import { EVENT_REFRESH } from "@goauthentik/common/constants"; -import { APIErrorTypes, parseAPIError } from "@goauthentik/common/errors"; import { groupBy } from "@goauthentik/common/utils"; -import { AkControlElement } from "@goauthentik/elements/AkControlElement.js"; -import { PreventFormSubmit } from "@goauthentik/elements/forms/helpers"; -import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter"; -import { msg } from "@lit/localize"; -import { TemplateResult, html } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; -import { ifDefined } from "lit/directives/if-defined.js"; +import { TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators.js"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; -import { ResponseError } from "@goauthentik/api"; +import { type ISearchSelectBase, SearchSelectBase } from "./SearchSelect.js"; -import { SearchSelectInputEvent, SearchSelectSelectEvent } from "./SearchSelectEvents.js"; -import "./ak-search-select-view.js"; -import type { GroupedOptions, SearchGroup, SearchTuple } from "./types.js"; +export interface ISearchSelect extends ISearchSelectBase { + fetchObjects: (query?: string) => Promise; + renderElement: (element: T) => string; + renderDescription?: (element: T) => string | TemplateResult; + value: (element: T | undefined) => unknown; + selected?: (element: T, elements: T[]) => boolean; + groupBy: (items: T[]) => [string, T[]][]; +} -type Group = [string, T[]]; +/** + * @class SearchSelect + * @element ak-search-select + * + * The API layer of ak-search-select + * + * - @prop fetchObjects (Function): The function by which objects are retrieved by the API. + * - @prop renderElement (Function | string): Either a function that can retrieve the string + * "label" of the element, or the name of the field from which the label can be retrieved.¹ + * - @prop renderDescription (Function | string): Either a function that can retrieve the string + * or TemplateResult "description" of the element, or the name of the field from which the + * description can be retrieved.¹ + * - @prop value (Function | string): Either a function that can retrieve the value (the current + * API object's primary key) selected or the name of the field from which the value can be + * retrieved.¹ + * - @prop selected (Function): A function that retrieves the current "live" value from the + list of objects fetched by the function above. + * - @prop groupBy (Function): A function that can group the objects fetched from the API by + an internal criteria. + * - @attr blankable (boolean): if true, the component is blankable and can return `undefined` + * - @attr name (string): The name of the component, for forms + * - @attr query (string): The current search criteria for fetching objects + * - @attr placeholder (string): What to show when the input is empty + * - @attr emptyOption (string): What to show in the menu to indicate "leave this undefined". Only + * shown if `blankable` + * - @attr selectedObject (Object): The current object, or undefined, selected + * + * ¹ Due to a limitation in the parsing of properties-vs-attributes, these must be defined as + * properties, not attributes. As a consequence, they must be declared in property syntax. + * Example: + * + * `.renderElement=${"name"}` + * + * - @fires ak-change - When a value from the collection has been positively chosen, either as a + * consequence of the user typing or when selecting from the list. + * + */ @customElement("ak-search-select") -export class SearchSelect extends CustomEmitterElement(AkControlElement) { +export class SearchSelect extends SearchSelectBase implements ISearchSelect { static get styles() { return [PFBase]; } @@ -39,7 +73,7 @@ export class SearchSelect extends CustomEmitterElement(AkControlElement) { // A function passed to this object that extracts an HTML representation of additional // information for items of the collection under search. @property({ attribute: false }) - renderDescription?: (element: T) => TemplateResult; + renderDescription?: (element: T) => string | TemplateResult; // A function which returns the currently selected object's primary key, used for serialization // into forms. @@ -60,174 +94,6 @@ export class SearchSelect extends CustomEmitterElement(AkControlElement) { return ""; }); }; - - // Whether or not the dropdown component can be left blank - @property({ type: Boolean }) - blankable = false; - - // An initial string to filter the search contents, and the value of the input which further - // serves to restrict the search - @property() - query?: string; - - // The objects currently available under search - @property({ attribute: false }) - objects?: T[]; - - // The currently selected object - @property({ attribute: false }) - selectedObject?: T; - - // Used to inform the form of the name of the object - @property() - name?: string; - - // The textual placeholder for the search's object, if currently empty. Used as the - // native object's `placeholder` field. - @property() - placeholder: string = msg("Select an object."); - - // A textual string representing "The user has affirmed they want to leave the selection blank." - // Only used if `blankable` above is true. - @property() - emptyOption = "---------"; - - isFetchingData = false; - - @state() - error?: APIErrorTypes; - - toForm(): unknown { - if (!this.objects) { - throw new PreventFormSubmit(msg("Loading options...")); - } - return this.value(this.selectedObject) || ""; - } - - json() { - return this.toForm(); - } - - async updateData() { - if (this.isFetchingData) { - return Promise.resolve(); - } - this.isFetchingData = true; - return this.fetchObjects(this.query) - .then((objects) => { - objects.forEach((obj) => { - if (this.selected && this.selected(obj, objects || [])) { - this.selectedObject = obj; - this.dispatchCustomEvent("ak-change", { value: this.selectedObject }); - } - }); - this.objects = objects; - this.isFetchingData = false; - }) - .catch((exc: ResponseError) => { - this.isFetchingData = false; - this.objects = undefined; - parseAPIError(exc).then((err) => { - this.error = err; - }); - }); - } - - connectedCallback(): void { - super.connectedCallback(); - this.dataset.akControl = "true"; - this.updateData(); - this.addEventListener(EVENT_REFRESH, this.updateData); - } - - disconnectedCallback(): void { - super.disconnectedCallback(); - this.removeEventListener(EVENT_REFRESH, this.updateData); - } - - onSearch(event: SearchSelectInputEvent) { - if (event.value === undefined) { - this.selectedObject = undefined; - return; - } - - this.query = event.value; - this.updateData()?.then(() => { - this.dispatchCustomEvent("ak-change", { value: this.selectedObject }); - }); - } - - onSelect(event: SearchSelectSelectEvent) { - if (event.value === undefined) { - this.selectedObject = undefined; - this.dispatchCustomEvent("ak-change", { value: undefined }); - return; - } - const selected = (this.objects ?? []).find((obj) => `${this.value(obj)}` === event.value); - if (!selected) { - console.warn( - `ak-search-select: No corresponding object found for value (${event.value}`, - ); - } - this.selectedObject = selected; - this.dispatchCustomEvent("ak-change", { value: this.selectedObject }); - } - - getGroupedItems(): GroupedOptions { - const items = this.groupBy(this.objects || []); - const makeSearchTuples = (items: T[]): SearchTuple[] => - items.map((item) => [ - `${this.value(item)}`, - this.renderElement(item), - this.renderDescription ? this.renderDescription(item) : undefined, - ]); - - const makeSearchGroups = (items: Group[]): SearchGroup[] => - items.map((group) => ({ - name: group[0], - options: makeSearchTuples(group[1]), - })); - - if (items.length === 0) { - return { grouped: false, options: [] }; - } - - if (items.length === 1 && (items[0].length < 1 || items[0][0] === "")) { - return { - grouped: false, - options: makeSearchTuples(items[0][1]), - }; - } - - return { - grouped: true, - options: makeSearchGroups(items), - }; - } - - render() { - if (this.error) { - return html`${msg("Failed to fetch objects: ")} ${this.error.detail}`; - } - - if (!this.objects) { - return html`${msg("Loading...")}`; - } - - const options = this.getGroupedItems(); - const value = this.selectedObject ? `${this.value(this.selectedObject) ?? ""}` : undefined; - - return html` `; - } } export default SearchSelect; diff --git a/web/src/elements/forms/SearchSelect/stories/ak-search-select-menu.stories.ts b/web/src/elements/forms/SearchSelect/stories/ak-search-select-menu.stories.ts deleted file mode 100644 index f04b645f77..0000000000 --- a/web/src/elements/forms/SearchSelect/stories/ak-search-select-menu.stories.ts +++ /dev/null @@ -1,119 +0,0 @@ -import "@goauthentik/elements/messages/MessageContainer"; -import { Meta, StoryObj } from "@storybook/web-components"; -import { slug } from "github-slugger"; - -import { TemplateResult, html } from "lit"; - -import { SearchSelectSelectMenuEvent } from "../SearchSelectEvents.js"; -import "../ak-search-select-menu.js"; -import { SearchSelectMenu } from "../ak-search-select-menu.js"; -import { groupedSampleData, sampleData } from "./sampleData.js"; - -const metadata: Meta = { - title: "Elements / Search Select / Tethered Menu", - component: "ak-search-select-menu", - parameters: { - docs: { - description: { - component: "The tethered panel containing the scrollable list of selectable items", - }, - }, - }, - argTypes: { - options: { - type: "string", - description: "An array of [key, label, desc] pairs of what to show", - }, - }, -}; - -export default metadata; - -const onClick = (event: SearchSelectSelectMenuEvent) => { - const target = document.querySelector("#action-button-message-pad"); - target!.innerHTML = ""; - target!.append( - new DOMParser().parseFromString(`
  • ${event.value}
  • `, "text/xml").firstChild!, - ); -}; - -const container = (testItem: TemplateResult) => { - window.setTimeout(() => { - const menu = document.getElementById("ak-search-select-menu"); - const container = document.getElementById("the-main-event"); - if (menu && container) { - container.addEventListener("ak-search-select-select-menu", onClick); - (menu as SearchSelectMenu).host = container; - } - }, 250); - - return html`
    - - - ${testItem} -
    -

    Messages received from the menu:

    -
      -
      -
      `; -}; - -type Story = StoryObj; - -const goodForYouPairs = { - grouped: false, - options: sampleData.slice(0, 20).map(({ produce }) => [slug(produce), produce]), -}; - -export const Default: Story = { - render: () => - container( - html` `, - ), -}; - -const longGoodForYouPairs = { - grouped: false, - options: sampleData.map(({ produce }) => [slug(produce), produce]), -}; - -export const Scrolling: Story = { - render: () => - container( - html` `, - ), -}; - -export const Grouped: Story = { - render: () => - container( - html` `, - ), -}; diff --git a/web/src/elements/forms/SearchSelect/stories/ak-search-select.stories.ts b/web/src/elements/forms/SearchSelect/stories/ak-search-select.stories.ts index a5f0c2c74c..bcc3f59a6f 100644 --- a/web/src/elements/forms/SearchSelect/stories/ak-search-select.stories.ts +++ b/web/src/elements/forms/SearchSelect/stories/ak-search-select.stories.ts @@ -1,6 +1,8 @@ import { groupBy } from "@goauthentik/common/utils"; import "@goauthentik/elements/forms/SearchSelect/ak-search-select"; import { SearchSelect } from "@goauthentik/elements/forms/SearchSelect/ak-search-select"; +import "@goauthentik/elements/forms/SearchSelect/ak-search-select-ez"; +import { type ISearchSelectApi } from "@goauthentik/elements/forms/SearchSelect/ak-search-select-ez"; import { Meta } from "@storybook/web-components"; import { TemplateResult, html } from "lit"; @@ -59,11 +61,8 @@ const container = (testItem: TemplateResult) => // eslint-disable-next-line @typescript-eslint/no-explicit-any const displayChange = (ev: any) => { - document.getElementById("message-pad")!.innerText = `Value selected: ${JSON.stringify( - ev.detail.value, - null, - 2, - )}`; + document.getElementById("message-pad")!.innerText = + `Value selected: ${JSON.stringify(ev.detail.value, null, 2)}`; }; export const Default = () => @@ -89,6 +88,23 @@ export const Grouped = () => { ); }; +export const GroupedAndEz = () => { + const config: ISearchSelectApi = { + fetchObjects: getSamples, + renderElement: (sample: Sample) => sample.name, + value: (sample: Sample | undefined) => sample?.pk, + groupBy: (samples: Sample[]) => + groupBy(samples, (sample: Sample) => sample.season[0] ?? ""), + }; + + return container( + html``, + ); +}; + export const SelectedAndBlankable = () => { return container( html`>>input"); + } + + async listElements() { + return await this.menu.$$(">>>li"); + } + + async focusOnInput() { + // @ts-ignore + await (await this.input()).focus(); + } + + async inputIsVisible() { + return await this.element.$(">>>input").isDisplayed(); + } + + async menuIsVisible() { + return (await this.menu.isExisting()) && (await this.menu.isDisplayed()); + } + + async clickInput() { + return await (await this.input()).click(); + } +} diff --git a/web/src/elements/forms/SearchSelect/tests/ak-search-select.test.ts b/web/src/elements/forms/SearchSelect/tests/ak-search-select.test.ts new file mode 100644 index 0000000000..6c22e88696 --- /dev/null +++ b/web/src/elements/forms/SearchSelect/tests/ak-search-select.test.ts @@ -0,0 +1,104 @@ +import { $, browser } from "@wdio/globals"; +import { slug } from "github-slugger"; +import { Key } from "webdriverio"; + +import { html, render } from "lit"; + +import "../ak-search-select-view.js"; +import { sampleData } from "../stories/sampleData.js"; +import { AkSearchSelectViewDriver } from "./ak-search-select-view.comp.js"; + +const longGoodForYouPairs = { + grouped: false, + options: sampleData.map(({ produce }) => [slug(produce), produce]), +}; + +describe("Search select: Test Input Field", () => { + let select: AkSearchSelectViewDriver; + + beforeEach(async () => { + await render( + html` `, + document.body, + ); + // @ts-ignore + select = await AkSearchSelectViewDriver.build(await $("ak-search-select-view")); + }); + + it("should open the menu when the input is clicked", async () => { + expect(await select.open).toBe(false); + expect(await select.menuIsVisible()).toBe(false); + await select.clickInput(); + expect(await select.open).toBe(true); + // expect(await select.menuIsVisible()).toBe(true); + }); + + it("should not open the menu when the input is focused", async () => { + expect(await select.open).toBe(false); + await select.focusOnInput(); + expect(await select.open).toBe(false); + expect(await select.menuIsVisible()).toBe(false); + }); + + it("should close the menu when the input is clicked a second time", async () => { + expect(await select.open).toBe(false); + expect(await select.menuIsVisible()).toBe(false); + await select.clickInput(); + expect(await select.menuIsVisible()).toBe(true); + expect(await select.open).toBe(true); + await select.clickInput(); + expect(await select.open).toBe(false); + expect(await select.open).toBe(false); + }); + + it("should open the menu from a focused but closed input when a search is begun", async () => { + expect(await select.open).toBe(false); + await select.focusOnInput(); + expect(await select.open).toBe(false); + expect(await select.menuIsVisible()).toBe(false); + await browser.keys("A"); + expect(await select.open).toBe(true); + expect(await select.menuIsVisible()).toBe(true); + }); + + it("should update the list as the user types", async () => { + await select.focusOnInput(); + await browser.keys("Ap"); + expect(await select.menuIsVisible()).toBe(true); + const elements = Array.from(await select.listElements()); + expect(elements.length).toBe(2); + }); + + it("set the value when a match is close", async () => { + await select.focusOnInput(); + await browser.keys("Ap"); + expect(await select.menuIsVisible()).toBe(true); + const elements = Array.from(await select.listElements()); + expect(elements.length).toBe(2); + await browser.keys(Key.Tab); + expect(await (await select.input()).getValue()).toBe("Apples"); + }); + + it("should close the menu when the user clicks away", async () => { + document.body.insertAdjacentHTML( + "afterbegin", + '', + ); + const input = await browser.$("#a-separate-component"); + + await select.clickInput(); + expect(await select.open).toBe(true); + await input.click(); + expect(await select.open).toBe(false); + }); + + afterEach(async () => { + await document.body.querySelector("#a-separate-component")?.remove(); + await document.body.querySelector("ak-search-select-view")?.remove(); + // @ts-expect-error expression of type '"_$litPart$"' is added by Lit + if (document.body["_$litPart$"]) { + // @ts-expect-error expression of type '"_$litPart$"' is added by Lit + delete document.body["_$litPart$"]; + } + }); +}); diff --git a/web/src/elements/forms/SearchSelect/tests/is-visible.ts b/web/src/elements/forms/SearchSelect/tests/is-visible.ts new file mode 100644 index 0000000000..b2b78ea0fa --- /dev/null +++ b/web/src/elements/forms/SearchSelect/tests/is-visible.ts @@ -0,0 +1,22 @@ +const isStyledVisible = ({ visibility, display }: CSSStyleDeclaration) => + visibility !== "hidden" && display !== "none"; + +const isDisplayContents = ({ display }: CSSStyleDeclaration) => display === "contents"; + +function computedStyleIsVisible(element: HTMLElement) { + const computedStyle = window.getComputedStyle(element); + return ( + isStyledVisible(computedStyle) && + (isDisplayContents(computedStyle) || + !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length)) + ); +} + +export function isVisible(element: HTMLElement) { + return ( + element && + element.isConnected && + isStyledVisible(element.style) && + computedStyleIsVisible(element) + ); +} diff --git a/web/src/elements/forms/SearchSelect/utils.ts b/web/src/elements/forms/SearchSelect/utils.ts index 03aefb5e93..6b4c1ab534 100644 --- a/web/src/elements/forms/SearchSelect/utils.ts +++ b/web/src/elements/forms/SearchSelect/utils.ts @@ -1,16 +1,67 @@ -import type { SearchOptions, SearchTuple } from "./types.js"; +import type { + GroupedOptions, + SelectGrouped, + SelectOption, + SelectOptions, +} from "@goauthentik/elements/types.js"; -type Pair = [string, string]; -const justThePair = ([key, label]: SearchTuple): Pair => [key, label]; +type Pair = [string, SelectOption]; +const mapPair = (option: SelectOption): Pair => [option[0], option]; -export function optionsToOptionsMap(options: SearchOptions): Map { - const pairs: Pair[] = Array.isArray(options) - ? options.map(justThePair) - : options.grouped - ? options.options.reduce( - (acc: Pair[], { options }): Pair[] => [...acc, ...options.map(justThePair)], - [] as Pair[], - ) - : options.options.map(justThePair); - return new Map(pairs); +const isSelectOptionsArray = (v: unknown): v is SelectOption[] => Array.isArray(v); + +// prettier-ignore +const isGroupedOptionsCollection = (v: unknown): v is SelectGrouped => + v !== null && typeof v === "object" && "grouped" in v && v.grouped === true; + +export const groupOptions = (options: SelectOptions): GroupedOptions => + isSelectOptionsArray(options) ? { grouped: false, options: options } : options; + +export function optionsToFlat(groupedOptions: GroupedOptions): Pair[] { + return isGroupedOptionsCollection(groupedOptions) + ? groupedOptions.options.reduce( + (acc: Pair[], { options }): Pair[] => [...acc, ...options.map(mapPair)], + [] as Pair[], + ) + : groupedOptions.options.map(mapPair); +} + +export function findFlatOptions(options: Pair[], value: string): Pair[] { + const fragLength = value.length; + return options.filter((option) => (option[1][1] ?? "").substring(0, fragLength) === value); +} + +export function findOptionsSubset( + groupedOptions: GroupedOptions, + value: string, + caseSensitive = false, +): GroupedOptions { + const fragLength = value.length; + if (value.trim() === "") { + return groupedOptions; + } + + const compValue = caseSensitive ? value : value.toLowerCase(); + const compOption = (option: SelectOption) => { + const extractedOption = (option[1] ?? "").substring(0, fragLength); + return caseSensitive ? extractedOption : extractedOption.toLowerCase(); + }; + + const optFilter = (options: SelectOption[]) => + options.filter((option) => compOption(option) === compValue); + + return groupedOptions.grouped + ? { + grouped: true, + options: groupedOptions.options + .map(({ name, options }) => ({ + name, + options: optFilter(options), + })) + .filter(({ options }) => options.length !== 0), + } + : { + grouped: false, + options: optFilter(groupedOptions.options), + }; } diff --git a/web/src/elements/types.ts b/web/src/elements/types.ts index c0247b1e91..76f8cb231d 100644 --- a/web/src/elements/types.ts +++ b/web/src/elements/types.ts @@ -1,5 +1,6 @@ import { AKElement } from "@goauthentik/elements/Base"; +import { TemplateResult } from "lit"; import { ReactiveControllerHost } from "lit"; export type ReactiveElementHost = Partial & T; @@ -9,3 +10,66 @@ export type Constructor = new (...args: any[]) => T; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type AbstractConstructor = abstract new (...args: any[]) => T; + +// authentik Search/List types +// +// authentik's list types (ak-dual-select, ak-list-select, ak-search-select) all take a tuple of two +// or three items, or a collection of groups of such tuples. In order to push dynamic checking +// around, we also allow the inclusion of a fourth component, which is just a scratchpad the +// developer can use for their own reasons. + +// The displayed element for our list can be a TemplateResult. If it is, we *strongly* recommend +// that you include the `sortBy` string as well, which is used for sorting but is also used for our +// autocomplete element (ak-search-select) both for tracking the user's input and for what we +// display in the autocomplete input box. + +// - key: string +// - label (string). This is the field that will be sorted and used for filtering and searching. +// - desc (optional) A string or TemplateResult used to describe the option. +// - localMapping: The object the key represents; used by some specific apps. API layers may use +// this as a way to find the referenced object, rather than the string and keeping a local map. +// +// Note that this is a *tuple*, not a record or map! + +// prettier-ignore +export type SelectOption = [ + key: string, + label: string, + desc?: string | TemplateResult, + localMapping?: T, +]; + +/** + * A search list without groups will always just consist of an array of SelectTuples and the + * `grouped: false` flag. Note that it *is* possible to pass to any of the rendering components an + * array of SelectTuples; they will be automatically mapped to a SelectFlat object. + * + */ +/* PRIVATE */ +export type SelectFlat = { + grouped: false; + options: SelectOption[]; +}; + +/** + * A search group consists of a group name and a collection of SelectTuples. + * + */ +export type SelectGroup = { name: string; options: SelectOption[] }; + +/** + * A grouped search is an array of SelectGroups, of course! + * + */ +export type SelectGrouped = { + grouped: true; + options: SelectGroup[]; +}; + +/** + * Internally, we only work with these two, but we have the `SelectOptions` variant + * below to support the case where you just want to pass in an array of SelectTuples. + * + */ +export type GroupedOptions = SelectGrouped | SelectFlat; +export type SelectOptions = SelectOption[] | GroupedOptions; diff --git a/web/src/elements/utils/isVisible.ts b/web/src/elements/utils/isVisible.ts new file mode 100644 index 0000000000..b2b78ea0fa --- /dev/null +++ b/web/src/elements/utils/isVisible.ts @@ -0,0 +1,22 @@ +const isStyledVisible = ({ visibility, display }: CSSStyleDeclaration) => + visibility !== "hidden" && display !== "none"; + +const isDisplayContents = ({ display }: CSSStyleDeclaration) => display === "contents"; + +function computedStyleIsVisible(element: HTMLElement) { + const computedStyle = window.getComputedStyle(element); + return ( + isStyledVisible(computedStyle) && + (isDisplayContents(computedStyle) || + !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length)) + ); +} + +export function isVisible(element: HTMLElement) { + return ( + element && + element.isConnected && + isStyledVisible(element.style) && + computedStyleIsVisible(element) + ); +}