web: provide a "select / select all" tool for the dual list multiselect

**This commit**

- Fixes the bug whereby pagination would leave the 'some moves available' state visible by clearing
  the 'to-move' state when the list of options changes.
- Fixes the bug whereby a change of 'options' in available would also cause an update to
  `selectedKeys`, causing the entire selected field to clear. Fixed by making `selectedKeys` a
  static object updated only when `selected` is generated rather than generating it anew with each
  re-rerender. (Hey, kids, can you say "functional programming and immutability" five time fast? I
  knew you could!)
- Fixes the bug whereby the change of outpost type would not cause an update of the `options`
  collection.
- Fixes the bug whereby the CSS was not creating enough whitespace separation between the whole
  component and its siblings. Host components are coded `span:static` unless otherwise styled to be
  `block`; we want `block` most of the time.
- Fixes the bug whereby the list of existing objects wasn't being passed to the handler correctly.
- Updates the Form Handler to recognize this new input object.
- Fixes the bug whereby changing outpost type doesn't handle the list of selected applications well.
- Fixes the bug whereby the identity of the outpost type's associated `fetch()` function loses
  identity -- necessary to maintain the selected outpost type switch.
- Fixes the CSS bug whereby horizontal scrolling would not enable correctly when the application's
  name overflows the listbox.
- Completes this assignment.  :-)
This commit is contained in:
Ken Sternberg 2024-01-02 15:46:28 -08:00
parent cef378da82
commit 7c4fafdc28
15 changed files with 657 additions and 265 deletions

View File

@ -5,6 +5,9 @@
"packages": {
"": {
"name": "@goauthentik/web-tests",
"dependencies": {
"chromedriver": "^120.0.1"
},
"devDependencies": {
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
"@typescript-eslint/eslint-plugin": "^6.16.0",
@ -786,6 +789,11 @@
"node": ">=14.16"
}
},
"node_modules/@testim/chrome-version": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@testim/chrome-version/-/chrome-version-1.1.4.tgz",
"integrity": "sha512-kIhULpw9TrGYnHp/8VfdcneIcxKnLixmADtukQRtJUmsVlMg0niMkwV0xZmi8hqa57xqilIHjWFA0GKvEjVU5g=="
},
"node_modules/@tootallnate/quickjs-emscripten": {
"version": "0.23.0",
"resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz",
@ -885,7 +893,7 @@
"version": "20.7.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.7.0.tgz",
"integrity": "sha512-zI22/pJW2wUZOVyguFaUL1HABdmSVxpXrzIqkjsHmyUjNhPoWM1CKfvVuXfetHhIok4RY573cqS0mZ1SJEnoTg==",
"dev": true
"devOptional": true
},
"node_modules/@types/normalize-package-data": {
"version": "2.4.2",
@ -939,7 +947,6 @@
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.1.tgz",
"integrity": "sha512-CHzgNU3qYBnp/O4S3yv2tXPlvMTq0YWSTVg2/JYLqWZGHwwgJGAwd00poay/11asPq8wLFwHzubyInqHIFmmiw==",
"dev": true,
"optional": true,
"dependencies": {
"@types/node": "*"
@ -1710,6 +1717,11 @@
"node": ">=0.12.0"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/available-typed-arrays": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz",
@ -1722,6 +1734,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/axios": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.6.3.tgz",
"integrity": "sha512-fWyNdeawGam70jXSVlKl+SUNVcL6j6W79CuSIPfi6HnDUmSCH6gyUys/HrqHeA/wU0Az41rRgean494d0Jb+ww==",
"dependencies": {
"follow-redirects": "^1.15.0",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/b4a": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz",
@ -1867,7 +1889,6 @@
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
"dev": true,
"engines": {
"node": "*"
}
@ -2022,6 +2043,50 @@
"fsevents": "~2.3.2"
}
},
"node_modules/chromedriver": {
"version": "120.0.1",
"resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-120.0.1.tgz",
"integrity": "sha512-ETTJlkibcAmvoKsaEoq2TFqEsJw18N0O9gOQZX6Uv/XoEiOV8p+IZdidMeIRYELWJIgCZESvlOx5d1QVnB4v0w==",
"hasInstallScript": true,
"dependencies": {
"@testim/chrome-version": "^1.1.4",
"axios": "^1.6.0",
"compare-versions": "^6.1.0",
"extract-zip": "^2.0.1",
"https-proxy-agent": "^5.0.1",
"proxy-from-env": "^1.1.0",
"tcp-port-used": "^1.0.2"
},
"bin": {
"chromedriver": "bin/chromedriver"
},
"engines": {
"node": ">=18"
}
},
"node_modules/chromedriver/node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/chromedriver/node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/chromium-bidi": {
"version": "0.4.16",
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.4.16.tgz",
@ -2182,6 +2247,17 @@
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
"dev": true
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/commander": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
@ -2191,6 +2267,11 @@
"node": "^12.20.0 || >=14"
}
},
"node_modules/compare-versions": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.0.tgz",
"integrity": "sha512-LNZQXhqUvqUTotpZ00qLSaify3b4VFD588aRr8MKFw4CMUr98ytzCW5wDH5qx/DEY5kCDXcbcRuCqL0szEf2tg=="
},
"node_modules/compress-commons": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-5.0.1.tgz",
@ -2338,7 +2419,6 @@
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
"dev": true,
"dependencies": {
"ms": "2.1.2"
},
@ -2393,8 +2473,7 @@
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"dev": true
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="
},
"node_modules/deepmerge-ts": {
"version": "5.1.0",
@ -2471,6 +2550,14 @@
"node": ">= 14"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@ -2683,7 +2770,6 @@
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
"integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
"dev": true,
"dependencies": {
"once": "^1.4.0"
}
@ -3218,7 +3304,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
"integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
"dev": true,
"dependencies": {
"debug": "^4.1.1",
"get-stream": "^5.1.0",
@ -3238,7 +3323,6 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
"integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
"dev": true,
"dependencies": {
"pump": "^3.0.0"
},
@ -3302,7 +3386,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
"dev": true,
"dependencies": {
"pend": "~1.2.0"
}
@ -3457,6 +3540,25 @@
"integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==",
"dev": true
},
"node_modules/follow-redirects": {
"version": "1.15.3",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz",
"integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-each": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
@ -3482,6 +3584,19 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/form-data-encoder": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz",
@ -4279,6 +4394,14 @@
"integrity": "sha512-PuExPYUiu6qMBQb4l06ecm6T6ujzhmh+MeJcW9wa89PoAz5pvd4zPgN5WJV104mb6S2T1AwNIAaB70JNrLQWhg==",
"dev": true
},
"node_modules/ip-regex": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-4.3.0.tgz",
"integrity": "sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q==",
"engines": {
"node": ">=8"
}
},
"node_modules/is-array-buffer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz",
@ -4571,6 +4694,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww=="
},
"node_modules/is-weakref": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz",
@ -4583,6 +4711,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is2": {
"version": "2.0.9",
"resolved": "https://registry.npmjs.org/is2/-/is2-2.0.9.tgz",
"integrity": "sha512-rZkHeBn9Zzq52sd9IUIV3a5mfwBY+o2HePMh0wkGBM4z4qjvy2GwVxQ6nNXSfw6MmVP6gf1QIlWjiOavhM3x5g==",
"dependencies": {
"deep-is": "^0.1.3",
"ip-regex": "^4.1.0",
"is-url": "^1.2.4"
},
"engines": {
"node": ">=v0.10.0"
}
},
"node_modules/isarray": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
@ -5518,6 +5659,25 @@
"node": ">=8.6"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mimic-fn": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",
@ -5837,8 +5997,7 @@
"node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
"dev": true
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"node_modules/mute-stream": {
"version": "1.0.0",
@ -6132,7 +6291,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dev": true,
"dependencies": {
"wrappy": "1"
}
@ -6462,8 +6620,7 @@
"node_modules/pend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
"dev": true
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="
},
"node_modules/picomatch": {
"version": "2.3.1",
@ -6609,14 +6766,12 @@
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"dev": true
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"node_modules/pump": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
"integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==",
"dev": true,
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
@ -7974,6 +8129,31 @@
"streamx": "^2.15.0"
}
},
"node_modules/tcp-port-used": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tcp-port-used/-/tcp-port-used-1.0.2.tgz",
"integrity": "sha512-l7ar8lLUD3XS1V2lfoJlCBaeoaWo/2xfYt81hM7VlvR4RrMVFqfmzfhLVk40hAb368uitje5gPtBRL1m/DGvLA==",
"dependencies": {
"debug": "4.3.1",
"is2": "^2.0.6"
}
},
"node_modules/tcp-port-used/node_modules/debug": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
"integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
"dependencies": {
"ms": "2.1.2"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@ -8805,8 +8985,7 @@
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
},
"node_modules/ws": {
"version": "8.14.2",
@ -8920,7 +9099,6 @@
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
"dev": true,
"dependencies": {
"buffer-crc32": "~0.2.3",
"fd-slicer": "~1.1.0"

View File

@ -30,5 +30,8 @@
},
"engines": {
"node": ">=20"
},
"dependencies": {
"chromedriver": "^120.0.1"
}
}

View File

@ -61,8 +61,11 @@ export const config: Options.Testrunner = {
capabilities: [
{
"browserName": "chrome",
"wdio:chromedriverOptions": {
"binary": "./node_modules/.bin/chromedriver",
},
"goog:chromeOptions": {
args: ["--disable-infobars", "--window-size=1280,800"].concat(
"args": ["--disable-infobars", "--window-size=1280,800"].concat(
(function () {
return process.env.HEADLESS_CHROME === "1"
? [

View File

@ -1,8 +1,10 @@
import { DataProvider, DualSelectPair } from "@goauthentik/app/elements/ak-dual-select/types";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { docLink } from "@goauthentik/common/global";
import { groupBy } from "@goauthentik/common/utils";
import "@goauthentik/elements/CodeMirror";
import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror";
import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider";
import "@goauthentik/elements/forms/HorizontalFormElement";
import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
import "@goauthentik/elements/forms/SearchSelect";
@ -19,13 +21,68 @@ import {
OutpostTypeEnum,
OutpostsApi,
OutpostsServiceConnectionsAllListRequest,
PaginatedLDAPProviderList,
PaginatedProxyProviderList,
PaginatedRadiusProviderList,
Pagination,
ProvidersApi,
ServiceConnection,
} from "@goauthentik/api";
interface ProviderBase {
pk: number;
name: string;
assignedBackchannelApplicationName?: string;
assignedApplicationName?: string;
}
interface ProviderData {
pagination: Pagination;
results: ProviderBase[];
}
const api = () => new ProvidersApi(DEFAULT_CONFIG);
const args = (page: number) => ({
ordering: "name",
applicationIsnull: false,
pageSize: 20,
search: "",
page,
});
const dualSelectPairMaker = (item: ProviderBase): DualSelectPair => [
`${item.pk}`,
`${
item.assignedBackchannelApplicationName
? item.assignedBackchannelApplicationName
: item.assignedApplicationName
} (${item.name})`,
];
const provisionMaker = (results: ProviderData) => ({
pagination: results.pagination,
options: results.results.map(dualSelectPairMaker),
});
const proxyListFetch = async (page: number) =>
provisionMaker(await api().providersProxyList(args(page)));
const ldapListFetch = async (page: number) =>
provisionMaker(await api().providersLdapList(args(page)));
const radiusListFetch = async (page: number) =>
provisionMaker(await api().providersRadiusList(args(page)));
function providerProvider(type: OutpostTypeEnum): DataProvider {
switch (type) {
case OutpostTypeEnum.Proxy:
return proxyListFetch;
case OutpostTypeEnum.Ldap:
return ldapListFetch;
case OutpostTypeEnum.Radius:
return radiusListFetch;
default:
throw new Error(`Unrecognized OutputType: ${type}`);
}
}
@customElement("ak-outpost-form")
export class OutpostForm extends ModelForm<Outpost, string> {
@property()
@ -35,10 +92,7 @@ export class OutpostForm extends ModelForm<Outpost, string> {
embedded = false;
@state()
providers?:
| PaginatedProxyProviderList
| PaginatedLDAPProviderList
| PaginatedRadiusProviderList;
providers?: DataProvider;
defaultConfig?: OutpostDefaultConfig;
@ -52,30 +106,9 @@ export class OutpostForm extends ModelForm<Outpost, string> {
async load(): Promise<void> {
this.defaultConfig = await new OutpostsApi(
DEFAULT_CONFIG,
DEFAULT_CONFIG
).outpostsInstancesDefaultSettingsRetrieve();
switch (this.type) {
case OutpostTypeEnum.Proxy:
this.providers = await new ProvidersApi(DEFAULT_CONFIG).providersProxyList({
ordering: "name",
applicationIsnull: false,
});
break;
case OutpostTypeEnum.Ldap:
this.providers = await new ProvidersApi(DEFAULT_CONFIG).providersLdapList({
ordering: "name",
applicationIsnull: false,
});
break;
case OutpostTypeEnum.Radius:
this.providers = await new ProvidersApi(DEFAULT_CONFIG).providersRadiusList({
ordering: "name",
applicationIsnull: false,
});
break;
case OutpostTypeEnum.UnknownDefaultOpenApi:
this.providers = undefined;
}
this.providers = providerProvider(this.type);
}
getSuccessMessage(): string {
@ -145,7 +178,7 @@ export class OutpostForm extends ModelForm<Outpost, string> {
args.search = query;
}
const items = await new OutpostsApi(
DEFAULT_CONFIG,
DEFAULT_CONFIG
).outpostsServiceConnectionsAllList(args);
return items.results;
}}
@ -170,7 +203,7 @@ export class OutpostForm extends ModelForm<Outpost, string> {
</ak-search-select>
<p class="pf-c-form__helper-text">
${msg(
"Selecting an integration enables the management of the outpost by authentik.",
"Selecting an integration enables the management of the outpost by authentik."
)}
</p>
<p class="pf-c-form__helper-text">
@ -185,32 +218,18 @@ export class OutpostForm extends ModelForm<Outpost, string> {
?required=${!this.embedded}
name="providers"
>
<select class="pf-c-form-control" multiple>
${this.providers?.results.map((provider) => {
const selected = Array.from(this.instance?.providers || []).some((sp) => {
return sp == provider.pk;
});
let appName = provider.assignedApplicationName;
if (provider.assignedBackchannelApplicationName) {
appName = provider.assignedBackchannelApplicationName;
}
return html`<option value=${ifDefined(provider.pk)} ?selected=${selected}>
${appName} (${provider.name})
</option>`;
})}
</select>
<p class="pf-c-form__helper-text">
${msg("You can only select providers that match the type of the outpost.")}
</p>
<p class="pf-c-form__helper-text">
${msg("Hold control/command to select multiple items.")}
</p>
<ak-dual-select-provider
.provider=${this.providers}
.selected=${(this.instance?.providersObj ?? []).map(dualSelectPairMaker)}
available-label="Available Applications"
selected-label="Selected Applications"
></ak-dual-select-provider>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Configuration")} name="config">
<ak-codemirror
mode=${CodeMirrorMode.YAML}
value="${YAML.stringify(
this.instance ? this.instance.config : this.defaultConfig?.config,
this.instance ? this.instance.config : this.defaultConfig?.config
)}"
></ak-codemirror>
<p class="pf-c-form__helper-text">

View File

@ -0,0 +1,115 @@
import { AKElement } from "@goauthentik/elements/Base";
import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter";
import { PropertyValues, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { createRef, ref } from "lit/directives/ref.js";
import type { Ref } from "lit/directives/ref.js";
import type { Pagination } from "@goauthentik/api";
import "./ak-dual-select";
import { AkDualSelect } from "./ak-dual-select";
import type { DataProvider, DualSelectPair } from "./types";
/**
* @element ak-dual-select-provider
*
* A top-level component that understands how the authentik pagination interface works,
* and can provide new pages based upon navigation requests. This is the interface
* between authentik and the generic ak-dual-select component; aside from knowing that
* the Pagination object "looks like Django," the interior components don't know anything
* about authentik at all and could be dropped into Gravity unchanged.)
*
*/
@customElement("ak-dual-select-provider")
export class AkDualSelectProvider extends CustomListenerElement(AKElement) {
// A function that takes a page and returns the DualSelectPair[] collection with which to update
// the "Available" pane.
@property({ type: Object })
provider!: DataProvider;
@property({ type: Array })
selected: DualSelectPair[] = [];
@property({ attribute: "available-label" })
availableLabel = "Available options";
@property({ attribute: "selected-label" })
selectedLabel = "Selected options";
@state()
private options: DualSelectPair[] = [];
private dualSelector: Ref<AkDualSelect> = createRef();
private isLoading = false;
private pagination?: Pagination;
get value() {
return this.dualSelector.value!.selected.map(([k, _]) => k);
}
selectedMap: WeakMap<DataProvider, DualSelectPair[]> = new WeakMap();
constructor() {
super();
setTimeout(() => this.fetch(1), 0);
this.onNav = this.onNav.bind(this);
this.onChange = this.onChange.bind(this);
// Notify AkForElementHorizontal how to handle this thing.
this.dataset.akControl = "true";
this.addCustomListener("ak-pagination-nav-to", this.onNav);
this.addCustomListener("ak-dual-select-change", this.onChange);
}
onNav(event: Event) {
if (!(event instanceof CustomEvent)) {
throw new Error(`Expecting a CustomEvent for navigation, received ${event} instead`);
}
this.fetch(event.detail);
}
onChange(event: Event) {
if (!(event instanceof CustomEvent)) {
throw new Error(`Expecting a CustomEvent for change, received ${event} instead`);
}
this.selected = event.detail.value;
}
willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has("provider")) {
this.pagination = undefined;
if (changedProperties.get("provider")) {
this.selectedMap.set(changedProperties.get("provider"), this.selected);
this.selected = this.selectedMap.get(this.provider) ?? [];
}
this.fetch();
}
}
async fetch(page?: number) {
if (this.isLoading) {
return;
}
this.isLoading = true;
const goto = page ?? this.pagination?.current ?? 1;
const data = await this.provider(goto);
this.pagination = data.pagination;
this.options = data.options;
this.isLoading = false;
}
render() {
return html`<ak-dual-select
${ref(this.dualSelector)}
.options=${this.options}
.pages=${this.pagination}
.selected=${this.selected}
available-label=${this.availableLabel}
selected-label=${this.selectedLabel}
></ak-dual-select>`;
}
}

View File

@ -5,14 +5,14 @@ import {
} from "@goauthentik/elements/utils/eventEmitter";
import { msg, str } from "@lit/localize";
import { css, html, nothing } from "lit";
import { PropertyValues, TemplateResult, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { createRef, ref } from "lit/directives/ref.js";
import type { Ref } from "lit/directives/ref.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { globalVariables, mainStyles } from "./components/styles.css";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFDualListSelector from "@patternfly/patternfly/components/DualListSelector/dual-list-selector.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import "./components/ak-dual-select-available-pane";
@ -21,7 +21,6 @@ import "./components/ak-dual-select-controls";
import "./components/ak-dual-select-selected-pane";
import { AkDualSelectSelectedPane } from "./components/ak-dual-select-selected-pane";
import "./components/ak-pagination";
import { globalVariables, mainStyles } from "./components/styles.css";
import {
EVENT_ADD_ALL,
EVENT_ADD_ONE,
@ -33,46 +32,39 @@ import {
} from "./constants";
import type { BasePagination, DualSelectPair } from "./types";
type PairValue = string | TemplateResult;
type Pair = [string, PairValue];
const alphaSort = ([_k1, v1]: Pair, [_k2, v2]: Pair) => (v1 < v2 ? -1 : v1 > v2 ? 1 : 0);
const styles = [
PFBase,
PFButton,
globalVariables,
mainStyles,
css`
:host {
--pf-c-dual-list-selector--GridTemplateColumns--pane--MinMax--min: 12.5rem;
--pf-c-dual-list-selector--GridTemplateColumns--pane--MinMax--max: 28.125rem;
}
.ak-dual-list-selector {
display: grid;
grid-template-columns:
minmax(
var(--pf-c-dual-list-selector--GridTemplateColumns--pane--MinMax--min),
var(--pf-c-dual-list-selector--GridTemplateColumns--pane--MinMax--max)
)
min-content
minmax(
var(--pf-c-dual-list-selector--GridTemplateColumns--pane--MinMax--min),
var(--pf-c-dual-list-selector--GridTemplateColumns--pane--MinMax--max)
);
}
.ak-available-pane,
ak-dual-select-controls,
.ak-selected-pane {
height: 100%;
}
`,
];
/**
* @element ak-dual-select
*
* A master (but independent) component that shows two lists-- one of "available options" and one of
* "selected options". The Available Options panel supports pagination if it receives a valid and
* active pagination object (based on Django's pagination object) from the invoking component.
*
* @fires ak-dual-select-change - A custom change event with the current `selected` list.
*/
@customElement("ak-dual-select")
export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKElement)) {
static get styles() {
return styles;
}
/* The list of options to *currently* show. Note that this is not *all* the options, only the
* currently shown list of options from a pagination collection. */
@property({ type: Array })
options: DualSelectPair[] = [];
/* The list of options selected. This is the *entire* list and will not be paginated. */
@property({ type: Array })
selected: DualSelectPair[] = [];
@ -89,6 +81,8 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
selectedPane: Ref<AkDualSelectSelectedPane> = createRef();
selectedKeys: Set<string> = new Set();
constructor() {
super();
this.handleMove = this.handleMove.bind(this);
@ -112,7 +106,21 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
return this.selected;
}
willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has("selected")) {
this.selectedKeys = new Set(this.selected.map(([key, _]) => key));
}
// Pagination invalidates available moveables.
if (changedProperties.has("options") && this.availablePane.value) {
this.availablePane.value.clearMove();
}
}
handleMove(eventName: string, event: Event) {
if (!(event instanceof CustomEvent)) {
throw new Error(`Expected move event here, got ${eventName}`);
}
switch (eventName) {
case EVENT_ADD_SELECTED: {
this.addSelected();
@ -148,7 +156,7 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
`AkDualSelect.handleMove received unknown event type: ${eventName}`
);
}
this.dispatchCustomEvent("change", { value: this.selected });
this.dispatchCustomEvent("ak-dual-select-change", { value: this.value });
event.stopPropagation();
}
@ -175,13 +183,10 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
}
}
removeOne(key: string) {
this.selected = this.selected.filter(([k, _]) => k !== key);
}
// You must remember, these are the *currently visible* options; the parent node is responsible
// for paginating and updating the list of currently visible options;
// These are the *currently visible* options; the parent node is responsible for paginating and
// updating the list of currently visible options;
addAllVisible() {
// Create a new array of all current options and selected, and de-dupe.
const selected = new Map([...this.options, ...this.selected]);
this.selected = Array.from(selected.entries()).sort();
this.availablePane.value!.clearMove();
@ -196,8 +201,12 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
this.selectedPane.value!.clearMove();
}
// Remove all the items from selected that are in the *currently visible* options list
removeOne(key: string) {
this.selected = this.selected.filter(([k, _]) => k !== key);
}
removeAllVisible() {
// Remove all the items from selected that are in the *currently visible* options list
const options = new Set(this.options.map(([k, _]) => k));
this.selected = this.selected.filter(([k, _]) => !options.has(k));
this.selectedPane.value!.clearMove();
@ -208,21 +217,21 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
this.selectedPane.value!.clearMove();
}
selectedKeys() {
return new Set(this.selected.map(([k, _]) => k));
}
get canAddAll() {
// False unless any visible option cannot be found in the selected list, so can still be
// added.
const selected = this.selectedKeys();
return this.options.length > 0 && !!this.options.find(([key, _]) => !selected.has(key));
const allMoved =
this.options.length ===
this.options.filter(([key, _]) => this.selectedKeys.has(key)).length;
return this.options.length > 0 && !allMoved;
}
get canRemoveAll() {
// False if no visible option can be found in the selected list
const selected = this.selectedKeys();
return this.options.length > 0 && !!this.options.find(([key, _]) => selected.has(key));
return (
this.options.length > 0 && !!this.options.find(([key, _]) => this.selectedKeys.has(key))
);
}
get needPagination() {
@ -230,7 +239,6 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
}
render() {
const selected = this.selectedKeys();
const availableCount = this.availablePane.value?.toMove.size ?? 0;
const selectedCount = this.selectedPane.value?.toMove.size ?? 0;
const selectedTotal = this.selected.length;
@ -262,7 +270,7 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
<ak-dual-select-available-pane
${ref(this.availablePane)}
.options=${this.options}
.selected=${selected}
.selected=${this.selectedKeys}
></ak-dual-select-available-pane>
${this.needPagination
? html`<ak-pagination .pages=${this.pages}></ak-pagination>`
@ -296,7 +304,7 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
<ak-dual-select-selected-pane
${ref(this.selectedPane)}
.selected=${this.selected}
.selected=${this.selected.toSorted(alphaSort)}
></ak-dual-select-selected-pane>
</div>
</div>

View File

@ -1,7 +1,7 @@
import { AKElement } from "@goauthentik/elements/Base";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import { css, html, nothing } from "lit";
import { PropertyValues, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import { map } from "lit/directives/map.js";
@ -12,26 +12,14 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { EVENT_ADD_ONE } from "../constants";
import type { DualSelectPair } from "../types";
import { availablePaneStyles, listStyles } from "./styles.css";
const styles = [
PFBase,
PFButton,
PFDualListSelector,
css`
.pf-c-dual-list-selector__item {
padding: 0.25rem;
}
.pf-c-dual-list-selector__item-text i {
display: inline-block;
margin-left: 0.5rem;
font-weight: 200;
color: var(--pf-global--palette--black-500);
font-size: var(--pf-global--FontSize--xs);
}
.pf-c-dual-list-selector__menu {
width: 1fr;
}
`,
listStyles,
availablePaneStyles
];
const hostAttributes = [
@ -48,11 +36,13 @@ const hostAttributes = [
* of objects selected to move. "selected" options are marked with a checkmark to show they're
* already in the "selected" collection and would be pointless to move.
*
* @fires ak-dual-select-available-move-changed - When the list of "to move" entries changed. Includes the current * `toMove` content.
* @fires ak-dual-select-available-move-changed - When the list of "to move" entries changed.
* Includes the current * `toMove` content.
*
* @fires ak-dual-select-add-one - Doubleclick with the element clicked on.
*
* It is not expected that the `ak-dual-select-available-move-changed` will be used; instead, the
* attribute will be read by the parent when a control is clicked.
* It is not expected that the `ak-dual-select-available-move-changed` event will be used; instead,
* the attribute will be read by the parent when a control is clicked.
*
*/
@customElement("ak-dual-select-available-pane")
@ -65,7 +55,7 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) {
@property({ type: Array })
readonly options: DualSelectPair[] = [];
/* An set (set being easy for lookups) of keys with all the pairs selected, so that the ones
/* A set (set being easy for lookups) of keys with all the pairs selected, so that the ones
* currently being shown that have already been selected can be marked and their clicks ignored.
*
*/
@ -87,6 +77,15 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) {
this.onMove = this.onMove.bind(this);
}
connectedCallback() {
super.connectedCallback();
hostAttributes.forEach(([attr, value]) => {
if (!this.hasAttribute(attr)) {
this.setAttribute(attr, value);
}
});
}
get moveable() {
return Array.from(this.toMove.values());
}
@ -97,8 +96,6 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) {
onClick(key: string) {
if (this.selected.has(key)) {
// An already selected item cannot be moved into the "selected" category
console.warn(`Attempted to mark '${key}' when it should have been unavailable`);
return;
}
if (this.toMove.has(key)) {
@ -112,7 +109,7 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) {
);
this.dispatchCustomEvent("ak-dual-select-move");
// Necessary because updating a map won't trigger a state change
this.requestUpdate();
this.requestUpdate();
}
onMove(key: string) {
@ -121,15 +118,6 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) {
this.requestUpdate();
}
connectedCallback() {
super.connectedCallback();
hostAttributes.forEach(([attr, value]) => {
if (!this.hasAttribute(attr)) {
this.setAttribute(attr, value);
}
});
}
// DO NOT use `Array.map()` instead of Lit's `map()` function. Lit's `map()` is object-aware and
// will not re-arrange or reconstruct the list automatically if the actual sources do not
// change; this allows the available pane to illustrate selected items with the checkmark
@ -137,35 +125,36 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) {
render() {
return html`
<div class="pf-c-dual-list-selector__menu">
<ul class="pf-c-dual-list-selector__list">
${map(this.options, ([key, label]) => {
const selected = classMap({
"pf-m-selected": this.toMove.has(key),
});
return html` <li
class="pf-c-dual-list-selector__list-item"
aria-selected="false"
@click=${() => this.onClick(key)}
@dblclick=${() => this.onMove(key)}
role="option"
tabindex="-1"
>
<div class="pf-c-dual-list-selector__list-item-row ${selected}">
<span class="pf-c-dual-list-selector__item">
<span class="pf-c-dual-list-selector__item-main">
<span class="pf-c-dual-list-selector__item-text"
>${label}${this.selected.has(key)
? html`<i class="fa fa-check"></i>`
: nothing}</span
></span
<div class="pf-c-dual-list-selector__menu">
<ul class="pf-c-dual-list-selector__list">
${map(this.options, ([key, label]) => {
const selected = classMap({
"pf-m-selected": this.toMove.has(key),
});
return html` <li
class="pf-c-dual-list-selector__list-item"
aria-selected="false"
@click=${() => this.onClick(key)}
@dblclick=${() => this.onMove(key)}
role="option"
data-ak-key=${key}
tabindex="-1"
>
<div class="pf-c-dual-list-selector__list-item-row ${selected}">
<span class="pf-c-dual-list-selector__item">
<span class="pf-c-dual-list-selector__item-main">
<span class="pf-c-dual-list-selector__item-text"
>${label}${this.selected.has(key)
? html`<i class="fa fa-check"></i>`
: nothing}</span
></span
>
</div>
</li>`;
})}
</ul>
</div>
></span
>
</div>
</li>`;
})}
</ul>
</div>
`;
}
}

View File

@ -27,13 +27,13 @@ const styles = [
}
.pf-c-dual-list-selector {
max-width: 4rem;
}
.ak-dual-list-selector__controls {
display: grid;
justify-content: center;
align-content: center;
height: 100%;
}
}
.ak-dual-list-selector__controls {
display: grid;
justify-content: center;
align-content: center;
height: 100%;
}
`,
];
@ -104,8 +104,8 @@ export class AkDualSelectControls extends CustomEmitterElement(AKElement) {
return html`
<div class="pf-c-dual-list-selector__controls-item">
<button
?aria-disabled=${this.disabled || !active}
?disabled=${this.disabled || !active}
?aria-disabled=${!active}
?disabled=${!active}
aria-label=${label}
class="pf-c-button pf-m-plain"
type="button"

View File

@ -10,17 +10,11 @@ import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFDualListSelector from "@patternfly/patternfly/components/DualListSelector/dual-list-selector.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import type { DualSelectPair } from "../types";
import { EVENT_REMOVE_ONE } from "../constants";
import { selectedPaneStyles } from "./styles.css";
const styles = [
PFBase,
PFButton,
PFDualListSelector,
selectedPaneStyles
];
import type { DualSelectPair } from "../types";
import { listStyles, selectedPaneStyles } from "./styles.css";
const styles = [PFBase, PFButton, PFDualListSelector, listStyles, selectedPaneStyles];
const hostAttributes = [
["aria-labelledby", "dual-list-selector-selected-pane-status"],
@ -34,7 +28,9 @@ const hostAttributes = [
* The "selected options" or "right" pane in a dual-list multi-select. It receives from its parent
* a list of the selected options, and maintains an internal list of objects selected to move.
*
* @fires ak-dual-select-selected-move-changed - When the list of "to move" entries changed. Includes the current * `toMove` content.
* @fires ak-dual-select-selected-move-changed - When the list of "to move" entries changed.
* Includes the current * `toMove` content.
*
* @fires ak-dual-select-remove-one - Doubleclick with the element clicked on.
*
* It is not expected that the `ak-dual-select-selected-move-changed` will be used; instead, the
@ -87,13 +83,13 @@ export class AkDualSelectSelectedPane extends CustomEmitterElement(AKElement) {
);
this.dispatchCustomEvent("ak-dual-select-move");
// Necessary because updating a map won't trigger a state change
this.requestUpdate();
this.requestUpdate();
}
onMove(key: string) {
this.toMove.delete(key);
this.dispatchCustomEvent(EVENT_REMOVE_ONE, key);
this.requestUpdate();
this.requestUpdate();
}
connectedCallback() {
@ -107,34 +103,35 @@ export class AkDualSelectSelectedPane extends CustomEmitterElement(AKElement) {
render() {
return html`
<div class="pf-c-dual-list-selector__menu">
<ul class="pf-c-dual-list-selector__list">
${map(this.selected, ([key, label]) => {
const selected = classMap({
"pf-m-selected": this.toMove.has(key),
});
return html` <li
class="pf-c-dual-list-selector__list-item"
aria-selected="false"
id="dual-list-selector-basic-selected-pane-list-option-0"
@click=${() => this.onClick(key)}
@dblclick=${() => this.onMove(key)}
role="option"
tabindex="-1"
>
<div class="pf-c-dual-list-selector__list-item-row ${selected}">
<span class="pf-c-dual-list-selector__item">
<span class="pf-c-dual-list-selector__item-main">
<span class="pf-c-dual-list-selector__item-text"
>${label}</span
></span
<div class="pf-c-dual-list-selector__menu">
<ul class="pf-c-dual-list-selector__list">
${map(this.selected, ([key, label]) => {
const selected = classMap({
"pf-m-selected": this.toMove.has(key),
});
return html` <li
class="pf-c-dual-list-selector__list-item"
aria-selected="false"
id="dual-list-selector-basic-selected-pane-list-option-0"
@click=${() => this.onClick(key)}
@dblclick=${() => this.onMove(key)}
role="option"
data-ak-key=${key}
tabindex="-1"
>
<div class="pf-c-dual-list-selector__list-item-row ${selected}">
<span class="pf-c-dual-list-selector__item">
<span class="pf-c-dual-list-selector__item-main">
<span class="pf-c-dual-list-selector__item-text"
>${label}</span
></span
>
</div>
</li>`;
})}
</ul>
</div>
></span
>
</div>
</li>`;
})}
</ul>
</div>
`;
}
}

View File

@ -1,4 +1,5 @@
import { AKElement } from "@goauthentik/elements/Base";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import { msg, str } from "@lit/localize";
import { css, html, nothing } from "lit";
@ -8,7 +9,6 @@ import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFPagination from "@patternfly/patternfly/components/Pagination/pagination.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import type { BasePagination } from "../types";
const styles = [
@ -54,7 +54,7 @@ export class AkPagination extends CustomEmitterElement(AKElement) {
<div class="pf-c-options-menu__toggle pf-m-text pf-m-plain">
<span class="pf-c-options-menu__toggle-text">
${msg(
str`${this.pages?.startIndex} - ${this.pages?.endIndex} of ${this.pages?.count}`,
str`${this.pages?.startIndex} - ${this.pages?.endIndex} of ${this.pages?.count}`
)}
</span>
</div>

View File

@ -1,5 +1,10 @@
import { css } from "lit";
// The `host` information for the Patternfly dual list selector came with some default settings that
// we do not want in a web component. By isolating what we *really* use into this collection here,
// we get all the benefits of Patternfly without having to wrestle without also having to counteract
// those default settings.
export const globalVariables = css`
:host {
--pf-c-dual-list-selector--GridTemplateColumns--pane--MinMax--min: 12.5rem;
@ -85,6 +90,14 @@ export const globalVariables = css`
`;
export const mainStyles = css`
:host {
--pf-c-dual-list-selector--GridTemplateColumns--pane--MinMax--min: 12.5rem;
--pf-c-dual-list-selector--GridTemplateColumns--pane--MinMax--max: 28.125rem;
}
:host {
display: block grid;
}
.pf-c-dual-list-selector__title-text {
font-weight: var(--pf-c-dual-list-selector__title-text--FontWeight);
}
@ -96,27 +109,64 @@ export const mainStyles = css`
.ak-dual-list-selector {
display: grid;
grid-template-columns:
minmax(
var(--pf-c-dual-list-selector--GridTemplateColumns--pane--MinMax--min),
var(--pf-c-dual-list-selector--GridTemplateColumns--pane--MinMax--max)
)
min-content
minmax(
var(--pf-c-dual-list-selector--GridTemplateColumns--pane--MinMax--min),
var(--pf-c-dual-list-selector--GridTemplateColumns--pane--MinMax--max)
);
grid-template-columns: minmax(0, 1fr) min-content minmax(0, 1fr);
}
.ak-available-pane,
.ak-selected-pane {
display: grid;
grid-template-rows: auto auto 1fr auto;
max-width: 100%;
overflow: hidden;
}
ak-dual-select-controls {
height: 100%;
}
`;
export const selectedPaneStyles = css`
.pf-c-dual-list-selector__menu {
height: 100%;
}
.pf-c-dual-list-selector__item {
padding: 0.25rem;
}
input[type="checkbox"][readonly] {
pointer-events: none;
}
export const listStyles = css`
:host {
display: block;
overflow: hidden;
max-width: 100%;
}
.pf-c-dual-list-selector__menu {
max-width: 100%;
height: 100%;
}
.pf-c-dual-list-selector__list {
max-width: 100%;
display: block;
}
.pf-c-dual-list-selector__item {
padding: 0.25rem;
width: auto;
}
.pf-c-dual-list-selector__item-text {
user-select: none;
flex-grow: 0;
}
`;
export const selectedPaneStyles = css`
input[type="checkbox"][readonly] {
pointer-events: none;
}
`;
export const availablePaneStyles = css`
.pf-c-dual-list-selector__item-text i {
display: inline-block;
margin-left: 0.5rem;
font-weight: 200;
color: var(--pf-global--palette--black-500);
font-size: var(--pf-global--FontSize--xs);
}
`;

View File

@ -1,5 +1,8 @@
import { AkDualSelect } from "./ak-dual-select";
import "./ak-dual-select";
export { AkDualSelect }
import { AkDualSelectProvider } from "./ak-dual-select-provider";
import "./ak-dual-select-provider";
export { AkDualSelect, AkDualSelectProvider }
export default AkDualSelect;

View File

@ -8,3 +8,10 @@ export type BasePagination = Pick<
Pagination,
"startIndex" | "endIndex" | "count" | "previous" | "next"
>;
export type DataProvision = {
pagination: Pagination;
options: DualSelectPair[];
}
export type DataProvider = (page: number) => Promise<DataProvision>;

View File

@ -69,7 +69,6 @@ export function serializeForm<T extends KeyUnknown>(
return;
}
// TODO: Tighten up the typing so that we can handle both.
if ("akControl" in element.dataset) {
assignValue(element, (element as unknown as AkControlElement).json(), json);
return;
@ -79,6 +78,12 @@ export function serializeForm<T extends KeyUnknown>(
if (element.hidden || !inputElement) {
return;
}
if ("akControl" in inputElement.dataset) {
assignValue(element, inputElement.value, json);
return;
}
// Skip elements that are writeOnly where the user hasn't clicked on the value
if (element.writeOnly && !element.writeOnlyActivated) {
return;

View File

@ -36,6 +36,22 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
*
*/
const isAkControl = (el: unknown): boolean =>
el instanceof HTMLElement &&
"dataset" in el &&
el.dataset instanceof DOMStringMap &&
"akControl" in el.dataset;
const nameables = new Set([
"input",
"textarea",
"select",
"ak-codemirror",
"ak-chip-group",
"ak-search-select",
"ak-radio",
]);
@customElement("ak-form-element-horizontal")
export class HorizontalFormElement extends AKElement {
static get styles(): CSSResult[] {
@ -112,19 +128,18 @@ export class HorizontalFormElement extends AKElement {
});
}
this.querySelectorAll("*").forEach((input) => {
switch (input.tagName.toLowerCase()) {
case "input":
case "textarea":
case "select":
case "ak-codemirror":
case "ak-chip-group":
case "ak-search-select":
case "ak-radio":
input.setAttribute("name", this.name);
break;
default:
return;
if (isAkControl(input) && !input.getAttribute("name")) {
input.setAttribute("name", this.name);
// This is fine; writeOnly won't apply to anything built this way.
return;
}
if (nameables.has(input.tagName.toLowerCase())) {
input.setAttribute("name", this.name);
} else {
return;
}
if (this.writeOnly && !this.writeOnlyActivated) {
const i = input as HTMLInputElement;
i.setAttribute("hidden", "true");