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": { "packages": {
"": { "": {
"name": "@goauthentik/web-tests", "name": "@goauthentik/web-tests",
"dependencies": {
"chromedriver": "^120.0.1"
},
"devDependencies": { "devDependencies": {
"@trivago/prettier-plugin-sort-imports": "^4.3.0", "@trivago/prettier-plugin-sort-imports": "^4.3.0",
"@typescript-eslint/eslint-plugin": "^6.16.0", "@typescript-eslint/eslint-plugin": "^6.16.0",
@ -786,6 +789,11 @@
"node": ">=14.16" "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": { "node_modules/@tootallnate/quickjs-emscripten": {
"version": "0.23.0", "version": "0.23.0",
"resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz",
@ -885,7 +893,7 @@
"version": "20.7.0", "version": "20.7.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.7.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.7.0.tgz",
"integrity": "sha512-zI22/pJW2wUZOVyguFaUL1HABdmSVxpXrzIqkjsHmyUjNhPoWM1CKfvVuXfetHhIok4RY573cqS0mZ1SJEnoTg==", "integrity": "sha512-zI22/pJW2wUZOVyguFaUL1HABdmSVxpXrzIqkjsHmyUjNhPoWM1CKfvVuXfetHhIok4RY573cqS0mZ1SJEnoTg==",
"dev": true "devOptional": true
}, },
"node_modules/@types/normalize-package-data": { "node_modules/@types/normalize-package-data": {
"version": "2.4.2", "version": "2.4.2",
@ -939,7 +947,6 @@
"version": "2.10.1", "version": "2.10.1",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.1.tgz", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.1.tgz",
"integrity": "sha512-CHzgNU3qYBnp/O4S3yv2tXPlvMTq0YWSTVg2/JYLqWZGHwwgJGAwd00poay/11asPq8wLFwHzubyInqHIFmmiw==", "integrity": "sha512-CHzgNU3qYBnp/O4S3yv2tXPlvMTq0YWSTVg2/JYLqWZGHwwgJGAwd00poay/11asPq8wLFwHzubyInqHIFmmiw==",
"dev": true,
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@types/node": "*" "@types/node": "*"
@ -1710,6 +1717,11 @@
"node": ">=0.12.0" "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": { "node_modules/available-typed-arrays": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", "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" "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": { "node_modules/b4a": {
"version": "1.6.4", "version": "1.6.4",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz",
@ -1867,7 +1889,6 @@
"version": "0.2.13", "version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
"dev": true,
"engines": { "engines": {
"node": "*" "node": "*"
} }
@ -2022,6 +2043,50 @@
"fsevents": "~2.3.2" "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": { "node_modules/chromium-bidi": {
"version": "0.4.16", "version": "0.4.16",
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.4.16.tgz", "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.4.16.tgz",
@ -2182,6 +2247,17 @@
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
"dev": true "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": { "node_modules/commander": {
"version": "9.5.0", "version": "9.5.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
@ -2191,6 +2267,11 @@
"node": "^12.20.0 || >=14" "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": { "node_modules/compress-commons": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-5.0.1.tgz", "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-5.0.1.tgz",
@ -2338,7 +2419,6 @@
"version": "4.3.4", "version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
"dev": true,
"dependencies": { "dependencies": {
"ms": "2.1.2" "ms": "2.1.2"
}, },
@ -2393,8 +2473,7 @@
"node_modules/deep-is": { "node_modules/deep-is": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="
"dev": true
}, },
"node_modules/deepmerge-ts": { "node_modules/deepmerge-ts": {
"version": "5.1.0", "version": "5.1.0",
@ -2471,6 +2550,14 @@
"node": ">= 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": { "node_modules/dequal": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@ -2683,7 +2770,6 @@
"version": "1.4.4", "version": "1.4.4",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
"integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
"dev": true,
"dependencies": { "dependencies": {
"once": "^1.4.0" "once": "^1.4.0"
} }
@ -3218,7 +3304,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
"integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
"dev": true,
"dependencies": { "dependencies": {
"debug": "^4.1.1", "debug": "^4.1.1",
"get-stream": "^5.1.0", "get-stream": "^5.1.0",
@ -3238,7 +3323,6 @@
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
"integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
"dev": true,
"dependencies": { "dependencies": {
"pump": "^3.0.0" "pump": "^3.0.0"
}, },
@ -3302,7 +3386,6 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
"dev": true,
"dependencies": { "dependencies": {
"pend": "~1.2.0" "pend": "~1.2.0"
} }
@ -3457,6 +3540,25 @@
"integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==",
"dev": true "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": { "node_modules/for-each": {
"version": "0.3.3", "version": "0.3.3",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
@ -3482,6 +3584,19 @@
"url": "https://github.com/sponsors/isaacs" "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": { "node_modules/form-data-encoder": {
"version": "2.1.4", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz", "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz",
@ -4279,6 +4394,14 @@
"integrity": "sha512-PuExPYUiu6qMBQb4l06ecm6T6ujzhmh+MeJcW9wa89PoAz5pvd4zPgN5WJV104mb6S2T1AwNIAaB70JNrLQWhg==", "integrity": "sha512-PuExPYUiu6qMBQb4l06ecm6T6ujzhmh+MeJcW9wa89PoAz5pvd4zPgN5WJV104mb6S2T1AwNIAaB70JNrLQWhg==",
"dev": true "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": { "node_modules/is-array-buffer": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", "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" "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": { "node_modules/is-weakref": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz",
@ -4583,6 +4711,19 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/isarray": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
@ -5518,6 +5659,25 @@
"node": ">=8.6" "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": { "node_modules/mimic-fn": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",
@ -5837,8 +5997,7 @@
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
"dev": true
}, },
"node_modules/mute-stream": { "node_modules/mute-stream": {
"version": "1.0.0", "version": "1.0.0",
@ -6132,7 +6291,6 @@
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dev": true,
"dependencies": { "dependencies": {
"wrappy": "1" "wrappy": "1"
} }
@ -6462,8 +6620,7 @@
"node_modules/pend": { "node_modules/pend": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="
"dev": true
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "2.3.1", "version": "2.3.1",
@ -6609,14 +6766,12 @@
"node_modules/proxy-from-env": { "node_modules/proxy-from-env": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
"dev": true
}, },
"node_modules/pump": { "node_modules/pump": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
"integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==",
"dev": true,
"dependencies": { "dependencies": {
"end-of-stream": "^1.1.0", "end-of-stream": "^1.1.0",
"once": "^1.3.1" "once": "^1.3.1"
@ -7974,6 +8129,31 @@
"streamx": "^2.15.0" "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": { "node_modules/text-table": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@ -8805,8 +8985,7 @@
"node_modules/wrappy": { "node_modules/wrappy": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
"dev": true
}, },
"node_modules/ws": { "node_modules/ws": {
"version": "8.14.2", "version": "8.14.2",
@ -8920,7 +9099,6 @@
"version": "2.10.0", "version": "2.10.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
"dev": true,
"dependencies": { "dependencies": {
"buffer-crc32": "~0.2.3", "buffer-crc32": "~0.2.3",
"fd-slicer": "~1.1.0" "fd-slicer": "~1.1.0"

View file

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

View file

@ -61,8 +61,11 @@ export const config: Options.Testrunner = {
capabilities: [ capabilities: [
{ {
"browserName": "chrome", "browserName": "chrome",
"wdio:chromedriverOptions": {
"binary": "./node_modules/.bin/chromedriver",
},
"goog:chromeOptions": { "goog:chromeOptions": {
args: ["--disable-infobars", "--window-size=1280,800"].concat( "args": ["--disable-infobars", "--window-size=1280,800"].concat(
(function () { (function () {
return process.env.HEADLESS_CHROME === "1" 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 { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { docLink } from "@goauthentik/common/global"; import { docLink } from "@goauthentik/common/global";
import { groupBy } from "@goauthentik/common/utils"; import { groupBy } from "@goauthentik/common/utils";
import "@goauthentik/elements/CodeMirror"; import "@goauthentik/elements/CodeMirror";
import { CodeMirrorMode } from "@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 "@goauthentik/elements/forms/HorizontalFormElement";
import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
import "@goauthentik/elements/forms/SearchSelect"; import "@goauthentik/elements/forms/SearchSelect";
@ -19,13 +21,68 @@ import {
OutpostTypeEnum, OutpostTypeEnum,
OutpostsApi, OutpostsApi,
OutpostsServiceConnectionsAllListRequest, OutpostsServiceConnectionsAllListRequest,
PaginatedLDAPProviderList, Pagination,
PaginatedProxyProviderList,
PaginatedRadiusProviderList,
ProvidersApi, ProvidersApi,
ServiceConnection, ServiceConnection,
} from "@goauthentik/api"; } 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") @customElement("ak-outpost-form")
export class OutpostForm extends ModelForm<Outpost, string> { export class OutpostForm extends ModelForm<Outpost, string> {
@property() @property()
@ -35,10 +92,7 @@ export class OutpostForm extends ModelForm<Outpost, string> {
embedded = false; embedded = false;
@state() @state()
providers?: providers?: DataProvider;
| PaginatedProxyProviderList
| PaginatedLDAPProviderList
| PaginatedRadiusProviderList;
defaultConfig?: OutpostDefaultConfig; defaultConfig?: OutpostDefaultConfig;
@ -52,30 +106,9 @@ export class OutpostForm extends ModelForm<Outpost, string> {
async load(): Promise<void> { async load(): Promise<void> {
this.defaultConfig = await new OutpostsApi( this.defaultConfig = await new OutpostsApi(
DEFAULT_CONFIG, DEFAULT_CONFIG
).outpostsInstancesDefaultSettingsRetrieve(); ).outpostsInstancesDefaultSettingsRetrieve();
switch (this.type) { this.providers = providerProvider(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;
}
} }
getSuccessMessage(): string { getSuccessMessage(): string {
@ -145,7 +178,7 @@ export class OutpostForm extends ModelForm<Outpost, string> {
args.search = query; args.search = query;
} }
const items = await new OutpostsApi( const items = await new OutpostsApi(
DEFAULT_CONFIG, DEFAULT_CONFIG
).outpostsServiceConnectionsAllList(args); ).outpostsServiceConnectionsAllList(args);
return items.results; return items.results;
}} }}
@ -170,7 +203,7 @@ export class OutpostForm extends ModelForm<Outpost, string> {
</ak-search-select> </ak-search-select>
<p class="pf-c-form__helper-text"> <p class="pf-c-form__helper-text">
${msg( ${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>
<p class="pf-c-form__helper-text"> <p class="pf-c-form__helper-text">
@ -185,32 +218,18 @@ export class OutpostForm extends ModelForm<Outpost, string> {
?required=${!this.embedded} ?required=${!this.embedded}
name="providers" name="providers"
> >
<select class="pf-c-form-control" multiple> <ak-dual-select-provider
${this.providers?.results.map((provider) => { .provider=${this.providers}
const selected = Array.from(this.instance?.providers || []).some((sp) => { .selected=${(this.instance?.providersObj ?? []).map(dualSelectPairMaker)}
return sp == provider.pk; available-label="Available Applications"
}); selected-label="Selected Applications"
let appName = provider.assignedApplicationName; ></ak-dual-select-provider>
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-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Configuration")} name="config"> <ak-form-element-horizontal label=${msg("Configuration")} name="config">
<ak-codemirror <ak-codemirror
mode=${CodeMirrorMode.YAML} mode=${CodeMirrorMode.YAML}
value="${YAML.stringify( value="${YAML.stringify(
this.instance ? this.instance.config : this.defaultConfig?.config, this.instance ? this.instance.config : this.defaultConfig?.config
)}" )}"
></ak-codemirror> ></ak-codemirror>
<p class="pf-c-form__helper-text"> <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"; } from "@goauthentik/elements/utils/eventEmitter";
import { msg, str } from "@lit/localize"; 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 { customElement, property } from "lit/decorators.js";
import { createRef, ref } from "lit/directives/ref.js"; import { createRef, ref } from "lit/directives/ref.js";
import type { Ref } from "lit/directives/ref.js"; import type { Ref } from "lit/directives/ref.js";
import { unsafeHTML } from "lit/directives/unsafe-html.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 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 PFBase from "@patternfly/patternfly/patternfly-base.css";
import "./components/ak-dual-select-available-pane"; 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 "./components/ak-dual-select-selected-pane";
import { AkDualSelectSelectedPane } from "./components/ak-dual-select-selected-pane"; import { AkDualSelectSelectedPane } from "./components/ak-dual-select-selected-pane";
import "./components/ak-pagination"; import "./components/ak-pagination";
import { globalVariables, mainStyles } from "./components/styles.css";
import { import {
EVENT_ADD_ALL, EVENT_ADD_ALL,
EVENT_ADD_ONE, EVENT_ADD_ONE,
@ -33,46 +32,39 @@ import {
} from "./constants"; } from "./constants";
import type { BasePagination, DualSelectPair } from "./types"; 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 = [ const styles = [
PFBase, PFBase,
PFButton, PFButton,
globalVariables, globalVariables,
mainStyles, 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") @customElement("ak-dual-select")
export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKElement)) { export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKElement)) {
static get styles() { static get styles() {
return 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 }) @property({ type: Array })
options: DualSelectPair[] = []; options: DualSelectPair[] = [];
/* The list of options selected. This is the *entire* list and will not be paginated. */
@property({ type: Array }) @property({ type: Array })
selected: DualSelectPair[] = []; selected: DualSelectPair[] = [];
@ -89,6 +81,8 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
selectedPane: Ref<AkDualSelectSelectedPane> = createRef(); selectedPane: Ref<AkDualSelectSelectedPane> = createRef();
selectedKeys: Set<string> = new Set();
constructor() { constructor() {
super(); super();
this.handleMove = this.handleMove.bind(this); this.handleMove = this.handleMove.bind(this);
@ -112,7 +106,21 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
return this.selected; 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) { handleMove(eventName: string, event: Event) {
if (!(event instanceof CustomEvent)) {
throw new Error(`Expected move event here, got ${eventName}`);
}
switch (eventName) { switch (eventName) {
case EVENT_ADD_SELECTED: { case EVENT_ADD_SELECTED: {
this.addSelected(); this.addSelected();
@ -148,7 +156,7 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
`AkDualSelect.handleMove received unknown event type: ${eventName}` `AkDualSelect.handleMove received unknown event type: ${eventName}`
); );
} }
this.dispatchCustomEvent("change", { value: this.selected }); this.dispatchCustomEvent("ak-dual-select-change", { value: this.value });
event.stopPropagation(); event.stopPropagation();
} }
@ -175,13 +183,10 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
} }
} }
removeOne(key: string) { // These are the *currently visible* options; the parent node is responsible for paginating and
this.selected = this.selected.filter(([k, _]) => k !== key); // updating the list of currently visible options;
}
// You must remember, these are the *currently visible* options; the parent node is responsible
// for paginating and updating the list of currently visible options;
addAllVisible() { addAllVisible() {
// Create a new array of all current options and selected, and de-dupe.
const selected = new Map([...this.options, ...this.selected]); const selected = new Map([...this.options, ...this.selected]);
this.selected = Array.from(selected.entries()).sort(); this.selected = Array.from(selected.entries()).sort();
this.availablePane.value!.clearMove(); this.availablePane.value!.clearMove();
@ -196,8 +201,12 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
this.selectedPane.value!.clearMove(); 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() { removeAllVisible() {
// Remove all the items from selected that are in the *currently visible* options list
const options = new Set(this.options.map(([k, _]) => k)); const options = new Set(this.options.map(([k, _]) => k));
this.selected = this.selected.filter(([k, _]) => !options.has(k)); this.selected = this.selected.filter(([k, _]) => !options.has(k));
this.selectedPane.value!.clearMove(); this.selectedPane.value!.clearMove();
@ -208,21 +217,21 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
this.selectedPane.value!.clearMove(); this.selectedPane.value!.clearMove();
} }
selectedKeys() {
return new Set(this.selected.map(([k, _]) => k));
}
get canAddAll() { get canAddAll() {
// False unless any visible option cannot be found in the selected list, so can still be // False unless any visible option cannot be found in the selected list, so can still be
// added. // added.
const selected = this.selectedKeys(); const allMoved =
return this.options.length > 0 && !!this.options.find(([key, _]) => !selected.has(key)); this.options.length ===
this.options.filter(([key, _]) => this.selectedKeys.has(key)).length;
return this.options.length > 0 && !allMoved;
} }
get canRemoveAll() { get canRemoveAll() {
// False if no visible option can be found in the selected list // False if no visible option can be found in the selected list
const selected = this.selectedKeys(); return (
return this.options.length > 0 && !!this.options.find(([key, _]) => selected.has(key)); this.options.length > 0 && !!this.options.find(([key, _]) => this.selectedKeys.has(key))
);
} }
get needPagination() { get needPagination() {
@ -230,7 +239,6 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
} }
render() { render() {
const selected = this.selectedKeys();
const availableCount = this.availablePane.value?.toMove.size ?? 0; const availableCount = this.availablePane.value?.toMove.size ?? 0;
const selectedCount = this.selectedPane.value?.toMove.size ?? 0; const selectedCount = this.selectedPane.value?.toMove.size ?? 0;
const selectedTotal = this.selected.length; const selectedTotal = this.selected.length;
@ -262,7 +270,7 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
<ak-dual-select-available-pane <ak-dual-select-available-pane
${ref(this.availablePane)} ${ref(this.availablePane)}
.options=${this.options} .options=${this.options}
.selected=${selected} .selected=${this.selectedKeys}
></ak-dual-select-available-pane> ></ak-dual-select-available-pane>
${this.needPagination ${this.needPagination
? html`<ak-pagination .pages=${this.pages}></ak-pagination>` ? html`<ak-pagination .pages=${this.pages}></ak-pagination>`
@ -296,7 +304,7 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
<ak-dual-select-selected-pane <ak-dual-select-selected-pane
${ref(this.selectedPane)} ${ref(this.selectedPane)}
.selected=${this.selected} .selected=${this.selected.toSorted(alphaSort)}
></ak-dual-select-selected-pane> ></ak-dual-select-selected-pane>
</div> </div>
</div> </div>

View file

@ -1,7 +1,7 @@
import { AKElement } from "@goauthentik/elements/Base"; import { AKElement } from "@goauthentik/elements/Base";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter"; 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 { customElement, property, state } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js"; import { classMap } from "lit/directives/class-map.js";
import { map } from "lit/directives/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 { EVENT_ADD_ONE } from "../constants";
import type { DualSelectPair } from "../types"; import type { DualSelectPair } from "../types";
import { availablePaneStyles, listStyles } from "./styles.css";
const styles = [ const styles = [
PFBase, PFBase,
PFButton, PFButton,
PFDualListSelector, PFDualListSelector,
css` listStyles,
.pf-c-dual-list-selector__item { availablePaneStyles
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;
}
`,
]; ];
const hostAttributes = [ const hostAttributes = [
@ -48,11 +36,13 @@ const hostAttributes = [
* of objects selected to move. "selected" options are marked with a checkmark to show they're * 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. * 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. * @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 * It is not expected that the `ak-dual-select-available-move-changed` event will be used; instead,
* attribute will be read by the parent when a control is clicked. * the attribute will be read by the parent when a control is clicked.
* *
*/ */
@customElement("ak-dual-select-available-pane") @customElement("ak-dual-select-available-pane")
@ -65,7 +55,7 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) {
@property({ type: Array }) @property({ type: Array })
readonly options: DualSelectPair[] = []; 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. * 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); this.onMove = this.onMove.bind(this);
} }
connectedCallback() {
super.connectedCallback();
hostAttributes.forEach(([attr, value]) => {
if (!this.hasAttribute(attr)) {
this.setAttribute(attr, value);
}
});
}
get moveable() { get moveable() {
return Array.from(this.toMove.values()); return Array.from(this.toMove.values());
} }
@ -97,8 +96,6 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) {
onClick(key: string) { onClick(key: string) {
if (this.selected.has(key)) { 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; return;
} }
if (this.toMove.has(key)) { if (this.toMove.has(key)) {
@ -121,15 +118,6 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) {
this.requestUpdate(); 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 // 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 // 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 // change; this allows the available pane to illustrate selected items with the checkmark
@ -149,6 +137,7 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) {
@click=${() => this.onClick(key)} @click=${() => this.onClick(key)}
@dblclick=${() => this.onMove(key)} @dblclick=${() => this.onMove(key)}
role="option" role="option"
data-ak-key=${key}
tabindex="-1" tabindex="-1"
> >
<div class="pf-c-dual-list-selector__list-item-row ${selected}"> <div class="pf-c-dual-list-selector__list-item-row ${selected}">

View file

@ -104,8 +104,8 @@ export class AkDualSelectControls extends CustomEmitterElement(AKElement) {
return html` return html`
<div class="pf-c-dual-list-selector__controls-item"> <div class="pf-c-dual-list-selector__controls-item">
<button <button
?aria-disabled=${this.disabled || !active} ?aria-disabled=${!active}
?disabled=${this.disabled || !active} ?disabled=${!active}
aria-label=${label} aria-label=${label}
class="pf-c-button pf-m-plain" class="pf-c-button pf-m-plain"
type="button" 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 PFDualListSelector from "@patternfly/patternfly/components/DualListSelector/dual-list-selector.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css";
import type { DualSelectPair } from "../types";
import { EVENT_REMOVE_ONE } from "../constants"; import { EVENT_REMOVE_ONE } from "../constants";
import { selectedPaneStyles } from "./styles.css"; import type { DualSelectPair } from "../types";
import { listStyles, selectedPaneStyles } from "./styles.css";
const styles = [
PFBase,
PFButton,
PFDualListSelector,
selectedPaneStyles
];
const styles = [PFBase, PFButton, PFDualListSelector, listStyles, selectedPaneStyles];
const hostAttributes = [ const hostAttributes = [
["aria-labelledby", "dual-list-selector-selected-pane-status"], ["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 * 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. * 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. * @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 * It is not expected that the `ak-dual-select-selected-move-changed` will be used; instead, the
@ -120,6 +116,7 @@ export class AkDualSelectSelectedPane extends CustomEmitterElement(AKElement) {
@click=${() => this.onClick(key)} @click=${() => this.onClick(key)}
@dblclick=${() => this.onMove(key)} @dblclick=${() => this.onMove(key)}
role="option" role="option"
data-ak-key=${key}
tabindex="-1" tabindex="-1"
> >
<div class="pf-c-dual-list-selector__list-item-row ${selected}"> <div class="pf-c-dual-list-selector__list-item-row ${selected}">

View file

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

View file

@ -1,5 +1,10 @@
import { css } from "lit"; 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` export const globalVariables = css`
:host { :host {
--pf-c-dual-list-selector--GridTemplateColumns--pane--MinMax--min: 12.5rem; --pf-c-dual-list-selector--GridTemplateColumns--pane--MinMax--min: 12.5rem;
@ -85,6 +90,14 @@ export const globalVariables = css`
`; `;
export const mainStyles = 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 { .pf-c-dual-list-selector__title-text {
font-weight: var(--pf-c-dual-list-selector__title-text--FontWeight); font-weight: var(--pf-c-dual-list-selector__title-text--FontWeight);
} }
@ -96,27 +109,64 @@ export const mainStyles = css`
.ak-dual-list-selector { .ak-dual-list-selector {
display: grid; display: grid;
grid-template-columns: grid-template-columns: minmax(0, 1fr) min-content minmax(0, 1fr);
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-selected-pane {
min-content display: grid;
minmax( grid-template-rows: auto auto 1fr auto;
var(--pf-c-dual-list-selector--GridTemplateColumns--pane--MinMax--min), max-width: 100%;
var(--pf-c-dual-list-selector--GridTemplateColumns--pane--MinMax--max) overflow: hidden;
); }
ak-dual-select-controls {
height: 100%;
} }
`; `;
export const selectedPaneStyles = css` export const listStyles = css`
:host {
display: block;
overflow: hidden;
max-width: 100%;
}
.pf-c-dual-list-selector__menu { .pf-c-dual-list-selector__menu {
max-width: 100%;
height: 100%; height: 100%;
} }
.pf-c-dual-list-selector__list {
max-width: 100%;
display: block;
}
.pf-c-dual-list-selector__item { .pf-c-dual-list-selector__item {
padding: 0.25rem; 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] { input[type="checkbox"][readonly] {
pointer-events: none; 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 { AkDualSelect } from "./ak-dual-select";
import "./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; export default AkDualSelect;

View file

@ -8,3 +8,10 @@ export type BasePagination = Pick<
Pagination, Pagination,
"startIndex" | "endIndex" | "count" | "previous" | "next" "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; return;
} }
// TODO: Tighten up the typing so that we can handle both.
if ("akControl" in element.dataset) { if ("akControl" in element.dataset) {
assignValue(element, (element as unknown as AkControlElement).json(), json); assignValue(element, (element as unknown as AkControlElement).json(), json);
return; return;
@ -79,6 +78,12 @@ export function serializeForm<T extends KeyUnknown>(
if (element.hidden || !inputElement) { if (element.hidden || !inputElement) {
return; 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 // Skip elements that are writeOnly where the user hasn't clicked on the value
if (element.writeOnly && !element.writeOnlyActivated) { if (element.writeOnly && !element.writeOnlyActivated) {
return; 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") @customElement("ak-form-element-horizontal")
export class HorizontalFormElement extends AKElement { export class HorizontalFormElement extends AKElement {
static get styles(): CSSResult[] { static get styles(): CSSResult[] {
@ -112,19 +128,18 @@ export class HorizontalFormElement extends AKElement {
}); });
} }
this.querySelectorAll("*").forEach((input) => { this.querySelectorAll("*").forEach((input) => {
switch (input.tagName.toLowerCase()) { if (isAkControl(input) && !input.getAttribute("name")) {
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); input.setAttribute("name", this.name);
break; // This is fine; writeOnly won't apply to anything built this way.
default:
return; return;
} }
if (nameables.has(input.tagName.toLowerCase())) {
input.setAttribute("name", this.name);
} else {
return;
}
if (this.writeOnly && !this.writeOnlyActivated) { if (this.writeOnly && !this.writeOnlyActivated) {
const i = input as HTMLInputElement; const i = input as HTMLInputElement;
i.setAttribute("hidden", "true"); i.setAttribute("hidden", "true");