From 6c9ccca6878fca2e4ac930530cddd52a0dda1ba3 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Wed, 4 Dec 2024 19:23:18 +0000 Subject: [PATCH] feat: implement CRUD UI for IDP organization sync settings (#15503) resolves coder/internal#205 The goal is to create a new page located in deployment settings to allow users to create and update organization IDP sync settings. - [x] Use shadcn button for export policy button - [x] Disable save button if form is not dirty - [x] Disable "Add IdP organization" button if idp org name or coder orgs are empty - [x] Add footnote label below organization sync field input - [x] Add button to Delete rows in mapping table - [x] Create Multi-select combox box component to select coder org to map to idp org - [x] Storybook tests - [x] Tooltip for assign default org switch - [x] Display success/error toast on form submission Screenshot 2024-12-02 at 20 33 07 --- site/components.json | 3 +- site/package.json | 12 +- site/pnpm-lock.yaml | 942 ++++++++++++++++++ site/src/api/api.ts | 18 + site/src/api/queries/idpsync.ts | 24 + site/src/components/Badge/Badge.stories.tsx | 15 + site/src/components/Badge/Badge.tsx | 32 + site/src/components/Button/Button.stories.tsx | 80 ++ .../{ui/button.tsx => Button/Button.tsx} | 43 +- site/src/components/Command/Command.tsx | 145 +++ site/src/components/Dialog/Dialog.stories.tsx | 45 + site/src/components/Dialog/Dialog.tsx | 125 +++ site/src/components/Input/Input.stories.tsx | 21 + site/src/components/Input/Input.tsx | 28 + site/src/components/Label/Label.stories.tsx | 15 + site/src/components/Label/Label.tsx | 25 + .../MultiSelectCombobox.stories.tsx | 100 ++ .../MultiSelectCombobox.tsx | 651 ++++++++++++ site/src/components/Switch/Switch.stories.tsx | 38 + site/src/components/Switch/Switch.tsx | 35 + site/src/contexts/auth/permissions.tsx | 7 + site/src/index.css | 70 +- site/src/modules/management/SidebarView.tsx | 5 + .../ExportPolicyButton.stories.tsx | 41 + .../IdpOrgSyncPage/ExportPolicyButton.tsx | 47 + .../IdpOrgSyncPage/IdpOrgSyncPage.tsx | 110 ++ .../IdpOrgSyncPageView.stories.tsx | 50 + .../IdpOrgSyncPage/IdpOrgSyncPageView.tsx | 352 +++++++ .../IdpOrgSyncPage/OrganizationPills.tsx | 103 ++ .../PremiumPage/PremiumPageView.tsx | 2 +- .../ExportPolicyButton.stories.tsx | 2 +- .../IdpSyncPage/ExportPolicyButton.tsx | 15 +- .../IdpSyncPage/IdpPillList.tsx | 12 +- .../IdpSyncPage/IdpSyncPageView.stories.tsx | 2 +- site/src/router.tsx | 4 + site/src/testHelpers/entities.ts | 24 + site/src/utils/uuid.ts | 5 + site/tailwind.config.js | 13 +- site/tsconfig.json | 2 +- 39 files changed, 3188 insertions(+), 75 deletions(-) create mode 100644 site/src/api/queries/idpsync.ts create mode 100644 site/src/components/Badge/Badge.stories.tsx create mode 100644 site/src/components/Badge/Badge.tsx create mode 100644 site/src/components/Button/Button.stories.tsx rename site/src/components/{ui/button.tsx => Button/Button.tsx} (54%) create mode 100644 site/src/components/Command/Command.tsx create mode 100644 site/src/components/Dialog/Dialog.stories.tsx create mode 100644 site/src/components/Dialog/Dialog.tsx create mode 100644 site/src/components/Input/Input.stories.tsx create mode 100644 site/src/components/Input/Input.tsx create mode 100644 site/src/components/Label/Label.stories.tsx create mode 100644 site/src/components/Label/Label.tsx create mode 100644 site/src/components/MultiSelectCombobox/MultiSelectCombobox.stories.tsx create mode 100644 site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx create mode 100644 site/src/components/Switch/Switch.stories.tsx create mode 100644 site/src/components/Switch/Switch.tsx create mode 100644 site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/ExportPolicyButton.stories.tsx create mode 100644 site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/ExportPolicyButton.tsx create mode 100644 site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage.tsx create mode 100644 site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPageView.stories.tsx create mode 100644 site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPageView.tsx create mode 100644 site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/OrganizationPills.tsx create mode 100644 site/src/utils/uuid.ts diff --git a/site/components.json b/site/components.json index 8f7ae7d9d6..f3db7d2f5b 100644 --- a/site/components.json +++ b/site/components.json @@ -16,5 +16,6 @@ "ui": "/components/ui", "lib": "/lib", "hooks": "/hooks" - } + }, + "iconLibrary": "lucide" } diff --git a/site/package.json b/site/package.json index 109e7814be..1951d32ad8 100644 --- a/site/package.json +++ b/site/package.json @@ -50,7 +50,12 @@ "@mui/system": "5.16.7", "@mui/utils": "5.16.6", "@mui/x-tree-view": "7.18.0", + "@radix-ui/react-dialog": "1.1.2", + "@radix-ui/react-label": "2.1.0", + "@radix-ui/react-slider": "1.2.1", "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-switch": "1.1.1", + "@radix-ui/react-visually-hidden": "1.1.0", "@tanstack/react-query-devtools": "4.35.3", "@xterm/addon-canvas": "0.7.0", "@xterm/addon-fit": "0.10.0", @@ -67,6 +72,7 @@ "chroma-js": "2.4.2", "class-variance-authority": "0.7.0", "clsx": "2.1.1", + "cmdk": "1.0.0", "color-convert": "2.0.1", "cron-parser": "4.9.0", "cronstrue": "2.50.0", @@ -176,7 +182,11 @@ "vite-plugin-checker": "0.8.0", "vite-plugin-turbosnap": "1.0.3" }, - "browserslist": ["chrome 110", "firefox 111", "safari 16.0"], + "browserslist": [ + "chrome 110", + "firefox 111", + "safari 16.0" + ], "resolutions": { "optionator": "0.9.3", "semver": "7.6.2" diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index fbd144a3ac..f0030fe9ae 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -63,9 +63,24 @@ importers: '@mui/x-tree-view': specifier: 7.18.0 version: 7.18.0(@emotion/react@11.13.3(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.0(@emotion/react@11.13.3(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@mui/material@5.16.7(@emotion/react@11.13.3(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.0(@emotion/react@11.13.3(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mui/system@5.16.7(@emotion/react@11.13.3(@types/react@18.3.12)(react@18.3.1))(@emotion/styled@11.13.0(@emotion/react@11.13.3(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dialog': + specifier: 1.1.2 + version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-label': + specifier: 2.1.0 + version: 2.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slider': + specifier: 1.2.1 + version: 1.2.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': specifier: 1.1.0 version: 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-switch': + specifier: 1.1.1 + version: 1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-visually-hidden': + specifier: 1.1.0 + version: 1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tanstack/react-query-devtools': specifier: 4.35.3 version: 4.35.3(@tanstack/react-query@4.35.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -114,6 +129,9 @@ importers: clsx: specifier: 2.1.1 version: 2.1.1 + cmdk: + specifier: 1.0.0 + version: 1.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) color-convert: specifier: 2.0.1 version: 2.0.1 @@ -1523,6 +1541,37 @@ packages: '@protobufjs/utf8@1.1.0': resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@radix-ui/number@1.1.0': + resolution: {integrity: sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==} + + '@radix-ui/primitive@1.0.1': + resolution: {integrity: sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==} + + '@radix-ui/primitive@1.1.0': + resolution: {integrity: sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==} + + '@radix-ui/react-collection@1.1.0': + resolution: {integrity: sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.0.1': + resolution: {integrity: sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-compose-refs@1.1.0': resolution: {integrity: sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==} peerDependencies: @@ -1532,6 +1581,269 @@ packages: '@types/react': optional: true + '@radix-ui/react-context@1.0.1': + resolution: {integrity: sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.0': + resolution: {integrity: sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.1': + resolution: {integrity: sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.0.5': + resolution: {integrity: sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dialog@1.1.2': + resolution: {integrity: sha512-Yj4dZtqa2o+kG61fzB0H2qUvmwBA2oyQroGLyNtBj1beo1khoQ3q1a2AO8rrQYjd8256CO9+N8L9tvsS+bnIyA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.0': + resolution: {integrity: sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.0.5': + resolution: {integrity: sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.1': + resolution: {integrity: sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.0.1': + resolution: {integrity: sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-guards@1.1.1': + resolution: {integrity: sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.0.4': + resolution: {integrity: sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-scope@1.1.0': + resolution: {integrity: sha512-200UD8zylvEyL8Bx+z76RJnASR2gRMuxlgFCPAe/Q/679a/r0eK3MBVYMb7vZODZcffZBdob1EGnky78xmVvcA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.0.1': + resolution: {integrity: sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-id@1.1.0': + resolution: {integrity: sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.0': + resolution: {integrity: sha512-peLblDlFw/ngk3UWq0VnYaOLy6agTZZ+MUO/WhVfm14vJGML+xH4FAl2XQGLqdefjNb7ApRg6Yn7U42ZhmYXdw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.0.4': + resolution: {integrity: sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.2': + resolution: {integrity: sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.0.1': + resolution: {integrity: sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.1': + resolution: {integrity: sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@1.0.3': + resolution: {integrity: sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.0.0': + resolution: {integrity: sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slider@1.2.1': + resolution: {integrity: sha512-bEzQoDW0XP+h/oGbutF5VMWJPAl/UU8IJjr7h02SOHDIIIxq+cep8nItVNoBV+OMmahCdqdF38FTpmXoqQUGvw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.0.2': + resolution: {integrity: sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-slot@1.1.0': resolution: {integrity: sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==} peerDependencies: @@ -1541,6 +1853,122 @@ packages: '@types/react': optional: true + '@radix-ui/react-switch@1.1.1': + resolution: {integrity: sha512-diPqDDoBcZPSicYoMWdWx+bCPuTRH4QSp9J+65IvtdS0Kuzt67bI6n32vCj8q6NZmYW/ah+2orOtMwcX5eQwIg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.0.1': + resolution: {integrity: sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.0': + resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.0.1': + resolution: {integrity: sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.1.0': + resolution: {integrity: sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.0.3': + resolution: {integrity: sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.0': + resolution: {integrity: sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.0.1': + resolution: {integrity: sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.0': + resolution: {integrity: sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.0': + resolution: {integrity: sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.0': + resolution: {integrity: sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.1.0': + resolution: {integrity: sha512-N8MDZqtgCgG5S3aV60INAB475osJousYpZ4cTJ2cFbMpdHS5Y6loLTH8LPtkj2QN0x93J30HT/M3qJXM0+lyeQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@remix-run/router@1.19.2': resolution: {integrity: sha512-baiMx18+IMuD1yyvOGaHM9QrVUPGGG0jC+z+IPHnRJWUAUvaKuWKyE8gjDj2rzv3sz9zOGoRSPgeBVHRhZnBlA==} engines: {node: '>=14.0.0'} @@ -2396,6 +2824,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.4: + resolution: {integrity: sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==} + engines: {node: '>=10'} + aria-query@5.1.3: resolution: {integrity: sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==} @@ -2670,6 +3102,12 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cmdk@1.0.0: + resolution: {integrity: sha512-gDzVf0a09TvoJ5jnuPvygTB77+XdOSwEmJ88L6XPFPlv7T3RxbP9jgenfylrAMD0+Le1aO0nVjQUzl2g+vjz5Q==} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + co@4.6.0: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} @@ -2922,6 +3360,9 @@ packages: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -3072,6 +3513,7 @@ packages: eslint@8.52.0: resolution: {integrity: sha512-zh/JHnaixqHZsolRB/w9/02akBk9EPrOs9JwcTP2ek7yL5bVvXuRariiaAjjoJ5DvuwQ1WAE/HsMz+w17YgBCg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true espree@9.6.1: @@ -3279,6 +3721,10 @@ packages: resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} engines: {node: '>= 0.4'} + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + get-package-type@0.1.0: resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} engines: {node: '>=8.0.0'} @@ -4642,6 +5088,36 @@ packages: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} + react-remove-scroll-bar@2.3.6: + resolution: {integrity: sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.5.5: + resolution: {integrity: sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.6.0: + resolution: {integrity: sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + react-router-dom@6.26.2: resolution: {integrity: sha512-z7YkaEW0Dy35T3/QKPYB1LjMK2R1fxnHO8kWpUMTBdfVzZrWOiY9a7CtN8HqdWtDUWd5FY6Dl8HFsqVwH4uOtQ==} engines: {node: '>=14.0.0'} @@ -4655,6 +5131,16 @@ packages: peerDependencies: react: '>=16.8' + react-style-singleton@2.2.1: + resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + react-syntax-highlighter@15.5.0: resolution: {integrity: sha512-+zq2myprEnQmH5yw6Gqc8lD55QHnpKaU8TOcFeC/Lg/MQSs8UknEA0JC4nTZGFAXC2J2Hyj/ijJ7NlabyPi2gg==} peerDependencies: @@ -5310,6 +5796,26 @@ packages: url-parse@1.5.10: resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + use-callback-ref@1.3.2: + resolution: {integrity: sha512-elOQwe6Q8gqZgDA8mrh44qRTQqpIHDcZ3hXTLjBe1i4ph8XpNJnO+aQf3NaG+lriLopI4HMx9VjQLfPQ6vhnoA==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.2: + resolution: {integrity: sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': ^16.9.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + use-sync-external-store@1.2.0: resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==} peerDependencies: @@ -6699,12 +7205,283 @@ snapshots: '@protobufjs/utf8@1.1.0': {} + '@radix-ui/number@1.1.0': {} + + '@radix-ui/primitive@1.0.1': + dependencies: + '@babel/runtime': 7.25.6 + + '@radix-ui/primitive@1.1.0': {} + + '@radix-ui/react-collection@1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-context': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.1.0(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + + '@radix-ui/react-compose-refs@1.0.1(@types/react@18.3.12)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.25.6 + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.12 + '@radix-ui/react-compose-refs@1.1.0(@types/react@18.3.12)(react@18.3.1)': dependencies: react: 18.3.1 optionalDependencies: '@types/react': 18.3.12 + '@radix-ui/react-context@1.0.1(@types/react@18.3.12)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.25.6 + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.12 + + '@radix-ui/react-context@1.1.0(@types/react@18.3.12)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.12 + + '@radix-ui/react-context@1.1.1(@types/react@18.3.12)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.12 + + '@radix-ui/react-dialog@1.0.5(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.25.6 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-context': 1.0.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.0.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.0.4(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.0.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-portal': 1.0.4(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.0.2(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.3.12)(react@18.3.1) + aria-hidden: 1.2.4 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.5.5(@types/react@18.3.12)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + + '@radix-ui/react-dialog@1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-portal': 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.12)(react@18.3.1) + aria-hidden: 1.2.4 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.6.0(@types/react@18.3.12)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + + '@radix-ui/react-direction@1.1.0(@types/react@18.3.12)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.12 + + '@radix-ui/react-dismissable-layer@1.0.5(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.25.6 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-use-escape-keydown': 1.0.3(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + + '@radix-ui/react-dismissable-layer@1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-use-escape-keydown': 1.1.0(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + + '@radix-ui/react-focus-guards@1.0.1(@types/react@18.3.12)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.25.6 + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.12 + + '@radix-ui/react-focus-guards@1.1.1(@types/react@18.3.12)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.12 + + '@radix-ui/react-focus-scope@1.0.4(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.25.6 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + + '@radix-ui/react-focus-scope@1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + + '@radix-ui/react-id@1.0.1(@types/react@18.3.12)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.25.6 + '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.12 + + '@radix-ui/react-id@1.1.0(@types/react@18.3.12)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.12 + + '@radix-ui/react-label@2.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + + '@radix-ui/react-portal@1.0.4(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.25.6 + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + + '@radix-ui/react-portal@1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + + '@radix-ui/react-presence@1.0.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.25.6 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + + '@radix-ui/react-presence@1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + + '@radix-ui/react-primitive@1.0.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.25.6 + '@radix-ui/react-slot': 1.0.2(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + + '@radix-ui/react-primitive@2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-slot': 1.1.0(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + + '@radix-ui/react-slider@1.2.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/number': 1.1.0 + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-collection': 1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-direction': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.0(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + + '@radix-ui/react-slot@1.0.2(@types/react@18.3.12)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.25.6 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.12 + '@radix-ui/react-slot@1.1.0(@types/react@18.3.12)(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) @@ -6712,6 +7489,99 @@ snapshots: optionalDependencies: '@types/react': 18.3.12 + '@radix-ui/react-switch@1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.0(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + + '@radix-ui/react-use-callback-ref@1.0.1(@types/react@18.3.12)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.25.6 + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.12 + + '@radix-ui/react-use-callback-ref@1.1.0(@types/react@18.3.12)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.12 + + '@radix-ui/react-use-controllable-state@1.0.1(@types/react@18.3.12)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.25.6 + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.12 + + '@radix-ui/react-use-controllable-state@1.1.0(@types/react@18.3.12)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.12 + + '@radix-ui/react-use-escape-keydown@1.0.3(@types/react@18.3.12)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.25.6 + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.12 + + '@radix-ui/react-use-escape-keydown@1.1.0(@types/react@18.3.12)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.12 + + '@radix-ui/react-use-layout-effect@1.0.1(@types/react@18.3.12)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.25.6 + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.12 + + '@radix-ui/react-use-layout-effect@1.1.0(@types/react@18.3.12)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.12 + + '@radix-ui/react-use-previous@1.1.0(@types/react@18.3.12)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.12 + + '@radix-ui/react-use-size@1.1.0(@types/react@18.3.12)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.12 + + '@radix-ui/react-visually-hidden@1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + '@remix-run/router@1.19.2': {} '@rollup/pluginutils@5.0.5(rollup@4.24.3)': @@ -7598,6 +8468,10 @@ snapshots: argparse@2.0.1: optional: true + aria-hidden@1.2.4: + dependencies: + tslib: 2.6.2 + aria-query@5.1.3: dependencies: deep-equal: 2.2.2 @@ -7908,6 +8782,16 @@ snapshots: clsx@2.1.1: {} + cmdk@1.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@radix-ui/react-dialog': 1.0.5(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + co@4.6.0: {} code-block-writer@11.0.3: {} @@ -8129,6 +9013,8 @@ snapshots: detect-newline@3.1.0: {} + detect-node-es@1.1.0: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -8592,6 +9478,8 @@ snapshots: has-symbols: 1.0.3 hasown: 2.0.0 + get-nonce@1.0.1: {} + get-package-type@0.1.0: {} get-stream@6.0.1: {} @@ -10348,6 +11236,36 @@ snapshots: react-refresh@0.14.2: {} + react-remove-scroll-bar@2.3.6(@types/react@18.3.12)(react@18.3.1): + dependencies: + react: 18.3.1 + react-style-singleton: 2.2.1(@types/react@18.3.12)(react@18.3.1) + tslib: 2.6.2 + optionalDependencies: + '@types/react': 18.3.12 + + react-remove-scroll@2.5.5(@types/react@18.3.12)(react@18.3.1): + dependencies: + react: 18.3.1 + react-remove-scroll-bar: 2.3.6(@types/react@18.3.12)(react@18.3.1) + react-style-singleton: 2.2.1(@types/react@18.3.12)(react@18.3.1) + tslib: 2.6.2 + use-callback-ref: 1.3.2(@types/react@18.3.12)(react@18.3.1) + use-sidecar: 1.1.2(@types/react@18.3.12)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + + react-remove-scroll@2.6.0(@types/react@18.3.12)(react@18.3.1): + dependencies: + react: 18.3.1 + react-remove-scroll-bar: 2.3.6(@types/react@18.3.12)(react@18.3.1) + react-style-singleton: 2.2.1(@types/react@18.3.12)(react@18.3.1) + tslib: 2.6.2 + use-callback-ref: 1.3.2(@types/react@18.3.12)(react@18.3.1) + use-sidecar: 1.1.2(@types/react@18.3.12)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + react-router-dom@6.26.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@remix-run/router': 1.19.2 @@ -10360,6 +11278,15 @@ snapshots: '@remix-run/router': 1.19.2 react: 18.3.1 + react-style-singleton@2.2.1(@types/react@18.3.12)(react@18.3.1): + dependencies: + get-nonce: 1.0.1 + invariant: 2.2.4 + react: 18.3.1 + tslib: 2.6.2 + optionalDependencies: + '@types/react': 18.3.12 + react-syntax-highlighter@15.5.0(react@18.3.1): dependencies: '@babel/runtime': 7.22.6 @@ -11114,6 +12041,21 @@ snapshots: querystringify: 2.2.0 requires-port: 1.0.0 + use-callback-ref@1.3.2(@types/react@18.3.12)(react@18.3.1): + dependencies: + react: 18.3.1 + tslib: 2.6.2 + optionalDependencies: + '@types/react': 18.3.12 + + use-sidecar@1.1.2(@types/react@18.3.12)(react@18.3.1): + dependencies: + detect-node-es: 1.1.0 + react: 18.3.1 + tslib: 2.6.2 + optionalDependencies: + '@types/react': 18.3.12 + use-sync-external-store@1.2.0(react@18.3.1): dependencies: react: 18.3.1 diff --git a/site/src/api/api.ts b/site/src/api/api.ts index cfba27408e..8f059797df 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -712,6 +712,24 @@ class ApiMethods { return response.data; }; + getOrganizationIdpSyncSettings = + async (): Promise => { + const response = await this.axios.get( + "/api/v2/settings/idpsync/organization", + ); + return response.data; + }; + + patchOrganizationIdpSyncSettings = async ( + data: TypesGen.OrganizationSyncSettings, + ) => { + const response = await this.axios.patch( + "/api/v2/settings/idpsync/organization", + data, + ); + return response.data; + }; + /** * @param organization Can be the organization's ID or name */ diff --git a/site/src/api/queries/idpsync.ts b/site/src/api/queries/idpsync.ts new file mode 100644 index 0000000000..05fb26a462 --- /dev/null +++ b/site/src/api/queries/idpsync.ts @@ -0,0 +1,24 @@ +import { API } from "api/api"; +import type { OrganizationSyncSettings } from "api/typesGenerated"; +import type { QueryClient } from "react-query"; + +export const getOrganizationIdpSyncSettingsKey = () => [ + "organizationIdpSyncSettings", +]; + +export const patchOrganizationSyncSettings = (queryClient: QueryClient) => { + return { + mutationFn: (request: OrganizationSyncSettings) => + API.patchOrganizationIdpSyncSettings(request), + onSuccess: async () => + await queryClient.invalidateQueries(getOrganizationIdpSyncSettingsKey()), + }; +}; + +export const organizationIdpSyncSettings = (isIdpSyncEnabled: boolean) => { + return { + queryKey: getOrganizationIdpSyncSettingsKey(), + queryFn: () => API.getOrganizationIdpSyncSettings(), + enabled: isIdpSyncEnabled, + }; +}; diff --git a/site/src/components/Badge/Badge.stories.tsx b/site/src/components/Badge/Badge.stories.tsx new file mode 100644 index 0000000000..939e1d20f8 --- /dev/null +++ b/site/src/components/Badge/Badge.stories.tsx @@ -0,0 +1,15 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Badge } from "./Badge"; + +const meta: Meta = { + title: "components/Badge", + component: Badge, + args: { + children: "Badge", + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/site/src/components/Badge/Badge.tsx b/site/src/components/Badge/Badge.tsx new file mode 100644 index 0000000000..94d0fa9052 --- /dev/null +++ b/site/src/components/Badge/Badge.tsx @@ -0,0 +1,32 @@ +/** + * Copied from shadc/ui on 11/13/2024 + * @see {@link https://ui.shadcn.com/docs/components/badge} + */ +import { type VariantProps, cva } from "class-variance-authority"; +import type { FC } from "react"; +import { cn } from "utils/cn"; + +export const badgeVariants = cva( + "inline-flex items-center rounded-md border px-2.5 py-1 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-surface-secondary text-content-secondary shadow hover:bg-surface-tertiary", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +export const Badge: FC = ({ className, variant, ...props }) => { + return ( +
+ ); +}; diff --git a/site/src/components/Button/Button.stories.tsx b/site/src/components/Button/Button.stories.tsx new file mode 100644 index 0000000000..90d37ee9af --- /dev/null +++ b/site/src/components/Button/Button.stories.tsx @@ -0,0 +1,80 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Trash } from "lucide-react"; +import { Button } from "./Button"; + +const meta: Meta = { + title: "components/Button", + component: Button, + args: { + children: ( + <> + + Button + + ), + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Outline: Story = { + args: { + variant: "outline", + }, +}; + +export const Subtle: Story = { + args: { + variant: "subtle", + }, +}; + +export const Warning: Story = { + args: { + variant: "warning", + }, +}; + +export const DefaultDisabled: Story = { + args: { + disabled: true, + }, +}; + +export const OutlineDisabled: Story = { + args: { + variant: "outline", + disabled: true, + }, +}; + +export const SubtleDisabled: Story = { + args: { + variant: "subtle", + disabled: true, + }, +}; + +export const IconButtonDefault: Story = { + args: { + variant: "default", + children: , + }, +}; + +export const IconButtonOutline: Story = { + args: { + variant: "outline", + children: , + }, +}; + +export const IconButtonSubtle: Story = { + args: { + variant: "subtle", + children: , + }, +}; diff --git a/site/src/components/ui/button.tsx b/site/src/components/Button/Button.tsx similarity index 54% rename from site/src/components/ui/button.tsx rename to site/src/components/Button/Button.tsx index d3a708d48a..8b83149e3b 100644 --- a/site/src/components/ui/button.tsx +++ b/site/src/components/Button/Button.tsx @@ -1,16 +1,19 @@ +/** + * Copied from shadc/ui on 11/06/2024 + * @see {@link https://ui.shadcn.com/docs/components/button} + */ import { Slot } from "@radix-ui/react-slot"; import { type VariantProps, cva } from "class-variance-authority"; -import * as React from "react"; - +import { type FC, forwardRef } from "react"; import { cn } from "utils/cn"; -const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 font-semibold border-solid", +export const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link disabled:pointer-events-none disabled:text-content-disabled [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 font-semibold border-solid cursor-pointer", { variants: { variant: { default: - "bg-surface-invert-primary text-content-invert hover:bg-surface-invert-secondary", + "bg-surface-invert-primary text-content-invert hover:bg-surface-invert-secondary border-none disabled:bg-surface-secondary", outline: "border border-border-default text-content-primary bg-transparent hover:bg-surface-secondary", subtle: @@ -19,7 +22,7 @@ const buttonVariants = cva( "border border-border-error text-content-primary bg-surface-error hover:bg-transparent", }, size: { - default: "h-10 px-3 py-2", + default: "h-9 px-3 py-2", sm: "h-8 px-2 text-xs", }, }, @@ -36,18 +39,16 @@ export interface ButtonProps asChild?: boolean; } -const Button = React.forwardRef( - ({ className, variant, size, asChild = false, ...props }, ref) => { - const Comp = asChild ? Slot : "button"; - return ( - - ); - }, -); -Button.displayName = "Button"; - -export { Button, buttonVariants }; +export const Button: FC = forwardRef< + HTMLButtonElement, + ButtonProps +>(({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); +}); diff --git a/site/src/components/Command/Command.tsx b/site/src/components/Command/Command.tsx new file mode 100644 index 0000000000..d101e8159f --- /dev/null +++ b/site/src/components/Command/Command.tsx @@ -0,0 +1,145 @@ +/** + * Copied from shadc/ui on 11/13/2024 + * @see {@link https://ui.shadcn.com/docs/components/command} + */ +import type { DialogProps } from "@radix-ui/react-dialog"; +import { Command as CommandPrimitive } from "cmdk"; +import { Dialog, DialogContent } from "components/Dialog/Dialog"; +import { Search } from "lucide-react"; +import { type FC, forwardRef } from "react"; +import { cn } from "utils/cn"; + +export const Command = forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +export const CommandDialog: FC = ({ children, ...props }) => { + return ( + + + + {children} + + + + ); +}; + +export const CommandInput = forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)); + +export const CommandList = forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +export const CommandEmpty = forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)); + +export const CommandGroup = forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +export const CommandSeparator = forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +export const CommandItem = forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +export const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; diff --git a/site/src/components/Dialog/Dialog.stories.tsx b/site/src/components/Dialog/Dialog.stories.tsx new file mode 100644 index 0000000000..f0b555055d --- /dev/null +++ b/site/src/components/Dialog/Dialog.stories.tsx @@ -0,0 +1,45 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { userEvent, within } from "@storybook/test"; +import { Button } from "components/Button/Button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "./Dialog"; + +const meta: Meta = { + title: "components/Dialog", + component: Dialog, + args: { + children: ( + <> + + + + + + Example Dialog Title + Dialog Description text + + + + + + + ), + }, +}; + +export default meta; +type Story = StoryObj; + +export const OpenDialog: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Open Dialog" })); + }, +}; diff --git a/site/src/components/Dialog/Dialog.tsx b/site/src/components/Dialog/Dialog.tsx new file mode 100644 index 0000000000..dbcc0efc94 --- /dev/null +++ b/site/src/components/Dialog/Dialog.tsx @@ -0,0 +1,125 @@ +/** + * Copied from shadc/ui on 11/13/2024 + * @see {@link https://ui.shadcn.com/docs/components/dialog} + */ +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; +import { + type ComponentPropsWithoutRef, + type ElementRef, + type FC, + type HTMLAttributes, + forwardRef, +} from "react"; +import { cn } from "utils/cn"; + +export const Dialog = DialogPrimitive.Root; + +export const DialogTrigger = DialogPrimitive.Trigger; + +export const DialogPortal = DialogPrimitive.Portal; + +export const DialogClose = DialogPrimitive.Close; + +export const DialogOverlay = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +export const DialogContent = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); + +export const DialogHeader: FC> = ({ + className, + ...props +}) => ( +
+); + +export const DialogFooter: FC> = ({ + className, + ...props +}) => ( +
+); + +export const DialogTitle = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +export const DialogDescription = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); diff --git a/site/src/components/Input/Input.stories.tsx b/site/src/components/Input/Input.stories.tsx new file mode 100644 index 0000000000..ba88692f66 --- /dev/null +++ b/site/src/components/Input/Input.stories.tsx @@ -0,0 +1,21 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Input } from "./Input"; + +const meta: Meta = { + title: "components/Input", + component: Input, + args: { + placeholder: "Placeholder", + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Disabled: Story = { + args: { + disabled: true, + }, +}; diff --git a/site/src/components/Input/Input.tsx b/site/src/components/Input/Input.tsx new file mode 100644 index 0000000000..f1abd6ecc9 --- /dev/null +++ b/site/src/components/Input/Input.tsx @@ -0,0 +1,28 @@ +/** + * Copied from shadc/ui on 11/13/2024 + * @see {@link https://ui.shadcn.com/docs/components/input} + */ +import { forwardRef } from "react"; +import { cn } from "utils/cn"; + +export const Input = forwardRef< + HTMLInputElement, + React.ComponentProps<"input"> +>(({ className, type, ...props }, ref) => { + return ( + + ); +}); diff --git a/site/src/components/Label/Label.stories.tsx b/site/src/components/Label/Label.stories.tsx new file mode 100644 index 0000000000..11d3f92821 --- /dev/null +++ b/site/src/components/Label/Label.stories.tsx @@ -0,0 +1,15 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Label } from "./Label"; + +const meta: Meta = { + title: "components/Label", + component: Label, + args: { + children: "Label Text", + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/site/src/components/Label/Label.tsx b/site/src/components/Label/Label.tsx new file mode 100644 index 0000000000..2b052d0935 --- /dev/null +++ b/site/src/components/Label/Label.tsx @@ -0,0 +1,25 @@ +/** + * Copied from shadc/ui on 11/13/2024 + * @see {@link https://ui.shadcn.com/docs/components/label} + */ +import * as LabelPrimitive from "@radix-ui/react-label"; +import { type VariantProps, cva } from "class-variance-authority"; +import { forwardRef } from "react"; + +import { cn } from "utils/cn"; + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", +); + +export const Label = forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)); diff --git a/site/src/components/MultiSelectCombobox/MultiSelectCombobox.stories.tsx b/site/src/components/MultiSelectCombobox/MultiSelectCombobox.stories.tsx new file mode 100644 index 0000000000..fd35842e0f --- /dev/null +++ b/site/src/components/MultiSelectCombobox/MultiSelectCombobox.stories.tsx @@ -0,0 +1,100 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { expect, userEvent, waitFor, within } from "@storybook/test"; +import { MockOrganization, MockOrganization2 } from "testHelpers/entities"; +import { MultiSelectCombobox } from "./MultiSelectCombobox"; + +const organizations = [MockOrganization, MockOrganization2]; + +const meta: Meta = { + title: "components/MultiSelectCombobox", + component: MultiSelectCombobox, + args: { + hidePlaceholderWhenSelected: true, + placeholder: "Select organization", + emptyIndicator: ( +

+ All organizations selected +

+ ), + defaultOptions: organizations.map((org) => ({ + label: org.display_name, + value: org.id, + })), + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const OpenCombobox: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByPlaceholderText("Select organization")); + + await waitFor(() => + expect(canvas.getByText("My Organization")).toBeInTheDocument(), + ); + }, +}; + +export const SelectComboboxItem: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByPlaceholderText("Select organization")); + await userEvent.click( + canvas.getByRole("option", { name: "My Organization" }), + ); + }, +}; + +export const SelectAllComboboxItems: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByPlaceholderText("Select organization")); + await userEvent.click( + canvas.getByRole("option", { name: "My Organization" }), + ); + await userEvent.click( + canvas.getByRole("option", { name: "My Organization 2" }), + ); + + await waitFor(() => + expect( + canvas.getByText("All organizations selected"), + ).toBeInTheDocument(), + ); + }, +}; + +export const ClearFirstSelectedItem: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByPlaceholderText("Select organization")); + await userEvent.click( + canvas.getByRole("option", { name: "My Organization" }), + ); + await userEvent.click( + canvas.getByRole("option", { name: "My Organization 2" }), + ); + await userEvent.click(canvas.getAllByTestId("clear-option-button")[0]); + }, +}; + +export const ClearAllComboboxItems: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByPlaceholderText("Select organization")); + await userEvent.click( + canvas.getByRole("option", { name: "My Organization" }), + ); + await userEvent.click(canvas.getByTestId("clear-all-button")); + + await waitFor(() => + expect( + canvas.getByPlaceholderText("Select organization"), + ).toBeInTheDocument(), + ); + }, +}; diff --git a/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx b/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx new file mode 100644 index 0000000000..9afe1f40ce --- /dev/null +++ b/site/src/components/MultiSelectCombobox/MultiSelectCombobox.tsx @@ -0,0 +1,651 @@ +/** + * This component is based on multiple-selector + * @see {@link https://shadcnui-expansions.typeart.cc/docs/multiple-selector} + */ +import { Command as CommandPrimitive, useCommandState } from "cmdk"; +import { Badge } from "components/Badge/Badge"; +import { + Command, + CommandGroup, + CommandItem, + CommandList, +} from "components/Command/Command"; +import { useDebouncedValue } from "hooks/debounce"; +import { ChevronDown, X } from "lucide-react"; +import { + type ComponentProps, + type ComponentPropsWithoutRef, + type KeyboardEvent, + type ReactNode, + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from "react"; +import { cn } from "utils/cn"; + +export interface Option { + value: string; + label: string; + disable?: boolean; + /** fixed option that can't be removed. */ + fixed?: boolean; + /** Group the options by providing key. */ + [key: string]: string | boolean | undefined; +} +interface GroupOption { + [key: string]: Option[]; +} + +interface MultiSelectComboboxProps { + value?: Option[]; + defaultOptions?: Option[]; + /** manually controlled options */ + options?: Option[]; + placeholder?: string; + /** Loading component. */ + loadingIndicator?: ReactNode; + /** Empty component. */ + emptyIndicator?: ReactNode; + /** Debounce time for async search. Only work with `onSearch`. */ + delay?: number; + /** + * Only work with `onSearch` prop. Trigger search when `onFocus`. + * For example, when user click on the input, it will trigger the search to get initial options. + **/ + triggerSearchOnFocus?: boolean; + /** async search */ + onSearch?: (value: string) => Promise; + /** + * sync search. This search will not showing loadingIndicator. + * The rest props are the same as async search. + * i.e.: creatable, groupBy, delay. + **/ + onSearchSync?: (value: string) => Option[]; + onChange?: (options: Option[]) => void; + /** Limit the maximum number of selected options. */ + maxSelected?: number; + /** When the number of selected options exceeds the limit, the onMaxSelected will be called. */ + onMaxSelected?: (maxLimit: number) => void; + /** Hide the placeholder when there are options selected. */ + hidePlaceholderWhenSelected?: boolean; + disabled?: boolean; + /** Group the options base on provided key. */ + groupBy?: string; + className?: string; + badgeClassName?: string; + /** + * First item selected is a default behavior by cmdk. That is why the default is true. + * This is a workaround solution by add a dummy item. + * + * @see {@link https://github.com/pacocoursey/cmdk/issues/171} + */ + selectFirstItem?: boolean; + /** Allow user to create option when there is no option matched. */ + creatable?: boolean; + /** Props of `Command` */ + commandProps?: ComponentPropsWithoutRef; + /** Props of `CommandInput` */ + inputProps?: Omit< + ComponentPropsWithoutRef, + "value" | "placeholder" | "disabled" + >; + /** hide or show the button that clears all the selected options. */ + hideClearAllButton?: boolean; +} + +export interface MultiSelectComboboxRef { + selectedValue: Option[]; + input: HTMLInputElement; + focus: () => void; + reset: () => void; +} + +function transitionToGroupOption(options: Option[], groupBy?: string) { + if (options.length === 0) { + return {}; + } + if (!groupBy) { + return { + "": options, + }; + } + + const groupOption: GroupOption = {}; + for (const option of options) { + const key = (option[groupBy] as string) || ""; + if (!groupOption[key]) { + groupOption[key] = []; + } + groupOption[key].push(option); + } + return groupOption; +} + +function removePickedOption(groupOption: GroupOption, picked: Option[]) { + const cloneOption = structuredClone(groupOption); + + for (const [key, value] of Object.entries(cloneOption)) { + cloneOption[key] = value.filter( + (val) => !picked.find((p) => p.value === val.value), + ); + } + return cloneOption; +} + +function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) { + return Object.values(groupOption).some((value) => + value.some((option) => targetOption.some((o) => o.value === option.value)), + ); +} + +/** + * The `CommandEmpty` of shadcn/ui will cause the cmdk-empty to not render correctly. + * Here a new CommandEmpty is created using the `Empty` implementation from `cmdk`. + * + * @reference: https://github.com/hsuanyi-chou/shadcn-ui-expansions/issues/34#issuecomment-1949561607 + **/ +const CommandEmpty = forwardRef< + HTMLDivElement, + ComponentProps +>(({ className, ...props }, forwardedRef) => { + const render = useCommandState((state) => state.filtered.count === 0); + + if (!render) return null; + + return ( +
+ ); +}); + +export const MultiSelectCombobox = forwardRef< + MultiSelectComboboxRef, + MultiSelectComboboxProps +>( + ( + { + value, + onChange, + placeholder, + defaultOptions: arrayDefaultOptions = [], + options: arrayOptions, + delay, + onSearch, + onSearchSync, + loadingIndicator, + emptyIndicator, + maxSelected = Number.MAX_SAFE_INTEGER, + onMaxSelected, + hidePlaceholderWhenSelected, + disabled, + groupBy, + className, + badgeClassName, + selectFirstItem = true, + creatable = false, + triggerSearchOnFocus = false, + commandProps, + inputProps, + hideClearAllButton = false, + }: MultiSelectComboboxProps, + ref, + ) => { + const inputRef = useRef(null); + const [open, setOpen] = useState(false); + const [onScrollbar, setOnScrollbar] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const dropdownRef = useRef(null); // Added this + + const [selected, setSelected] = useState(value || []); + const [options, setOptions] = useState( + transitionToGroupOption(arrayDefaultOptions, groupBy), + ); + const [inputValue, setInputValue] = useState(""); + const debouncedSearchTerm = useDebouncedValue(inputValue, delay || 500); + + const [previousValue, setPreviousValue] = useState(value || []); + if (value && value !== previousValue) { + setPreviousValue(value); + setSelected(value); + } + + useImperativeHandle( + ref, + () => ({ + selectedValue: [...selected], + input: inputRef.current as HTMLInputElement, + focus: () => inputRef?.current?.focus(), + reset: () => setSelected([]), + }), + [selected], + ); + + const handleUnselect = useCallback( + (option: Option) => { + const newOptions = selected.filter((s) => s.value !== option.value); + setSelected(newOptions); + onChange?.(newOptions); + }, + [onChange, selected], + ); + + const handleKeyDown = (e: KeyboardEvent) => { + const input = inputRef.current; + if (input) { + if (e.key === "Delete" || e.key === "Backspace") { + if (input.value === "" && selected.length > 0) { + const lastSelectOption = selected[selected.length - 1]; + // If last item is fixed, we should not remove it. + if (!lastSelectOption.fixed) { + handleUnselect(selected[selected.length - 1]); + } + } + } + // This is not a default behavior of the field + if (e.key === "Escape") { + input.blur(); + } + } + }; + + useEffect(() => { + if (!open) { + return; + } + + const handleClickOutside = (event: MouseEvent | TouchEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) && + inputRef.current && + !inputRef.current.contains(event.target as Node) + ) { + setOpen(false); + inputRef.current.blur(); + } + }; + + if (open) { + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("touchend", handleClickOutside); + } + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("touchend", handleClickOutside); + }; + }, [open]); + + useEffect(() => { + /** If `onSearch` is provided, do not trigger options updated. */ + if (!arrayOptions || onSearch) { + return; + } + const newOption = transitionToGroupOption(arrayOptions || [], groupBy); + if (JSON.stringify(newOption) !== JSON.stringify(options)) { + setOptions(newOption); + } + }, [arrayOptions, groupBy, onSearch, options]); + + useEffect(() => { + /** sync search */ + + const doSearchSync = () => { + const res = onSearchSync?.(debouncedSearchTerm); + setOptions(transitionToGroupOption(res || [], groupBy)); + }; + + const exec = () => { + if (!onSearchSync || !open) return; + + if (triggerSearchOnFocus) { + doSearchSync(); + } + + if (debouncedSearchTerm) { + doSearchSync(); + } + }; + + void exec(); + }, [ + debouncedSearchTerm, + groupBy, + open, + triggerSearchOnFocus, + onSearchSync, + ]); + + useEffect(() => { + /** async search */ + + const doSearch = async () => { + setIsLoading(true); + const res = await onSearch?.(debouncedSearchTerm); + setOptions(transitionToGroupOption(res || [], groupBy)); + setIsLoading(false); + }; + + const exec = async () => { + if (!onSearch || !open) return; + + if (triggerSearchOnFocus) { + await doSearch(); + } + + if (debouncedSearchTerm) { + await doSearch(); + } + }; + + void exec(); + }, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus, onSearch]); + + const CreatableItem = () => { + if (!creatable) return undefined; + if ( + isOptionsExist(options, [{ value: inputValue, label: inputValue }]) || + selected.find((s) => s.value === inputValue) + ) { + return undefined; + } + + const Item = ( + { + e.preventDefault(); + e.stopPropagation(); + }} + onSelect={(value: string) => { + if (selected.length >= maxSelected) { + onMaxSelected?.(selected.length); + return; + } + setInputValue(""); + const newOptions = [...selected, { value, label: value }]; + setSelected(newOptions); + onChange?.(newOptions); + }} + > + Create "{inputValue}" + + ); + + // For normal creatable + if (!onSearch && inputValue.length > 0) { + return Item; + } + + // For async search creatable. avoid showing creatable item before loading at first. + if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) { + return Item; + } + + return undefined; + }; + + const EmptyItem = useCallback(() => { + if (!emptyIndicator) return undefined; + + // For async search that showing emptyIndicator + if (onSearch && !creatable && Object.keys(options).length === 0) { + return ( + + {emptyIndicator} + + ); + } + + return {emptyIndicator}; + }, [creatable, emptyIndicator, onSearch, options]); + + const selectables = useMemo( + () => removePickedOption(options, selected), + [options, selected], + ); + + /** Avoid Creatable Selector freezing or lagging when paste a long string. */ + const commandFilter = () => { + if (commandProps?.filter) { + return commandProps.filter; + } + + if (creatable) { + return (value: string, search: string) => { + return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1; + }; + } + // Using default filter in `cmdk`. We don't have to provide it. + return undefined; + }; + + const fixedOptions = selected.filter((s) => s.fixed); + + return ( + { + handleKeyDown(e); + commandProps?.onKeyDown?.(e); + }} + className={cn( + "h-auto overflow-visible bg-transparent", + commandProps?.className, + )} + shouldFilter={ + commandProps?.shouldFilter !== undefined + ? commandProps.shouldFilter + : !onSearch + } // When onSearch is provided, we don't want to filter the options. You can still override it. + filter={commandFilter()} + > + {/* biome-ignore lint/a11y/useKeyWithClickEvents: onKeyDown is not needed here */} +
{ + if (disabled) return; + inputRef?.current?.focus(); + }} + > +
+
+ {selected.map((option) => { + return ( + + {option.label} + + + ); + })} + {/* Avoid having the "Search" Icon */} + { + setInputValue(value); + inputProps?.onValueChange?.(value); + }} + onBlur={(event) => { + if (!onScrollbar) { + setOpen(false); + } + inputProps?.onBlur?.(event); + }} + onFocus={(event) => { + setOpen(true); + triggerSearchOnFocus && onSearch?.(debouncedSearchTerm); + inputProps?.onFocus?.(event); + }} + placeholder={ + hidePlaceholderWhenSelected && selected.length !== 0 + ? "" + : placeholder + } + className={cn( + "flex-1 border-none outline-none bg-transparent placeholder:text-content-secondary", + { + "w-full": hidePlaceholderWhenSelected, + "px-3 py-2.5": selected.length === 0, + "ml-1": selected.length !== 0, + }, + inputProps?.className, + )} + /> +
+
+ + +
+
+
+
+ {open && ( + { + setOnScrollbar(false); + }} + onPointerEnter={() => { + setOnScrollbar(true); + }} + onMouseUp={() => { + inputRef?.current?.focus(); + }} + > + {isLoading ? ( + <>{loadingIndicator} + ) : ( + <> + {EmptyItem()} + {CreatableItem()} + {!selectFirstItem && ( + + )} + {Object.entries(selectables).map(([key, dropdowns]) => ( + + {/* biome-ignore lint/complexity/noUselessFragments: A parent element is + needed for multiple dropdown items */} + <> + {dropdowns.map((option) => { + return ( + { + e.preventDefault(); + e.stopPropagation(); + }} + onSelect={() => { + if (selected.length >= maxSelected) { + onMaxSelected?.(selected.length); + return; + } + setInputValue(""); + const newOptions = [...selected, option]; + setSelected(newOptions); + onChange?.(newOptions); + }} + className={cn( + "cursor-pointer", + option.disable && + "cursor-default text-content-disabled", + )} + > + {option.label} + + ); + })} + + + ))} + + )} + + )} +
+
+ ); + }, +); diff --git a/site/src/components/Switch/Switch.stories.tsx b/site/src/components/Switch/Switch.stories.tsx new file mode 100644 index 0000000000..8563a23c9c --- /dev/null +++ b/site/src/components/Switch/Switch.stories.tsx @@ -0,0 +1,38 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Switch } from "./Switch"; + +const meta: Meta = { + title: "components/Switch", + component: Switch, +}; + +export default meta; +type Story = StoryObj; + +export const DefaultOn: Story = { + args: { + checked: true, + disabled: false, + }, +}; + +export const DefaultOff: Story = { + args: { + checked: false, + disabled: false, + }, +}; + +export const DisabledOn: Story = { + args: { + checked: true, + disabled: true, + }, +}; + +export const DisabledOff: Story = { + args: { + checked: false, + disabled: true, + }, +}; diff --git a/site/src/components/Switch/Switch.tsx b/site/src/components/Switch/Switch.tsx new file mode 100644 index 0000000000..b4a24069f4 --- /dev/null +++ b/site/src/components/Switch/Switch.tsx @@ -0,0 +1,35 @@ +/** + * Copied from shadc/ui on 11/13/2024 + * @see {@link https://ui.shadcn.com/docs/components/switch} + */ +import * as SwitchPrimitives from "@radix-ui/react-switch"; +import { forwardRef } from "react"; +import { cn } from "utils/cn"; + +export const Switch = forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); diff --git a/site/src/contexts/auth/permissions.tsx b/site/src/contexts/auth/permissions.tsx index d2de786487..b44d85e963 100644 --- a/site/src/contexts/auth/permissions.tsx +++ b/site/src/contexts/auth/permissions.tsx @@ -21,6 +21,7 @@ export const checks = { createGroup: "createGroup", viewAllLicenses: "viewAllLicenses", viewNotificationTemplate: "viewNotificationTemplate", + viewOrganizationIDPSyncSettings: "viewOrganizationIDPSyncSettings", } as const satisfies Record; // Type expression seems a little redundant (`keyof typeof checks` has the same @@ -152,6 +153,12 @@ export const permissionsToCheck = { }, action: "read", }, + [checks.viewOrganizationIDPSyncSettings]: { + object: { + resource_type: "idpsync_settings", + }, + action: "read", + }, } as const satisfies Record; export type Permissions = Record; diff --git a/site/src/index.css b/site/src/index.css index aeca658d11..1353b20074 100644 --- a/site/src/index.css +++ b/site/src/index.css @@ -4,50 +4,66 @@ @layer base { :root { - --content-primary: 240, 3%, 15%; - --content-secondary: 240, 4%, 32%; - --content-link: 217, 84%, 54%; + --content-primary: 240, 10%, 4%; + --content-secondary: 240, 5%, 34%; + --content-link: 221, 83%, 53%; --content-invert: 0, 0%, 98%; - --content-disabled: 240, 4%, 66%; - --content-success: 144, 83%, 32%; - --content-danger: 0, 82%, 63%; + --content-disabled: 240, 5%, 65%; + --content-success: 142, 72%, 29%; + --content-danger: 0, 84%, 60%; + --content-warning: 27, 96%, 61%; --surface-primary: 0, 0%, 98%; - --surface-secondary: 240, 4%, 96%; - --surface-tertiary: 240, 4%, 89%; - --surface-invert-primary: 240, 3%, 15%; - --surface-invert-secondary: 240, 4%, 25%; + --surface-secondary: 240, 5%, 96%; + --surface-tertiary: 240, 6%, 90%; + --surface-quaternary: 240, 5%, 84%; + --surface-invert-primary: 240, 4%, 16%; + --surface-invert-secondary: 240, 5%, 26%; --surface-error: 0, 100%, 14%; - --border-default: 240, 4%, 89%; - --border-error: 0, 82%, 63%; + --border-default: 240, 6%, 90%; + --border-success: 142, 76%, 36%; + --border-error: 0, 84%, 60%; --radius: 0.5rem; --chart-1: 12 76% 61%; --chart-2: 173 58% 39%; --chart-3: 197 37% 24%; --chart-4: 43 74% 66%; --chart-5: 27 87% 67%; - --background: 0, 0%, 98%; + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 240 10% 3.9%; } .dark { --content-primary: 0, 0%, 98%; - --content-secondary: 240, 4%, 66%; - --content-link: 217, 92%, 67%; - --content-invert: 240, 5%, 4%; - --content-disabled: 240, 4%, 25%; - --content-success: 144, 83%, 35%; - --content-danger: 0, 82%, 71%; - --surface-primary: 240, 5%, 4%; - --surface-secondary: 240, 5%, 9%; - --surface-tertiary: 240, 3%, 15%; - --surface-invert-primary: 240, 4%, 89%; - --surface-invert-secondary: 240, 4%, 66%; + --content-secondary: 240, 5%, 65%; + --content-link: 213, 94%, 68%; + --content-invert: 240, 10%, 4%; + --content-disabled: 240, 5%, 26%; + --content-success: 142, 76%, 36%; + --content-danger: 0, 91%, 71%; + --content-warning: 27, 96%, 61%; + --surface-primary: 240, 10%, 4%; + --surface-secondary: 240, 6%, 10%; + --surface-tertiary: 240, 4%, 16%; + --surface-quaternary: 240, 5%, 26%; + --surface-invert-primary: 240, 6%, 90%; + --surface-invert-secondary: 240, 5%, 65%; --surface-error: 0, 100%, 14%; - --border-default: 240, 3%, 15%; - --border-error: 0, 82%, 71%; + --border-default: 240, 4%, 16%; + --border-success: 142, 76%, 36%; + --border-error: 0, 91%, 71%; --chart-1: 220 70% 50%; --chart-2: 160 60% 45%; --chart-3: 30 80% 55%; --chart-4: 280 65% 60%; --chart-5: 340 75% 55%; - --background: 240, 5%, 4%; + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 240 4.9% 83.9%; + } +} + +@layer base { + * { + @apply border-border; } } diff --git a/site/src/modules/management/SidebarView.tsx b/site/src/modules/management/SidebarView.tsx index eabcac8f30..d63dcca388 100644 --- a/site/src/modules/management/SidebarView.tsx +++ b/site/src/modules/management/SidebarView.tsx @@ -155,6 +155,11 @@ const DeploymentSettingsNavigation: FC = ({ )} + {permissions.viewOrganizationIDPSyncSettings && ( + + IdP Organization Sync + + )} {!isPremium && ( Premium )} diff --git a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/ExportPolicyButton.stories.tsx b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/ExportPolicyButton.stories.tsx new file mode 100644 index 0000000000..a4206c3b04 --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/ExportPolicyButton.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { expect, fn, userEvent, waitFor, within } from "@storybook/test"; +import { MockOrganizationSyncSettings } from "testHelpers/entities"; +import { ExportPolicyButton } from "./ExportPolicyButton"; + +const meta: Meta = { + title: "pages/DeploymentSettingsPage/IdpOrgSyncPage/ExportPolicyButton", + component: ExportPolicyButton, + args: { + syncSettings: MockOrganizationSyncSettings, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const ClickExportPolicy: Story = { + args: { + syncSettings: MockOrganizationSyncSettings, + download: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + await userEvent.click( + canvas.getByRole("button", { name: "Export Policy" }), + ); + await waitFor(() => + expect(args.download).toHaveBeenCalledWith( + expect.anything(), + "organizations_policy.json", + ), + ); + const blob: Blob = (args.download as jest.Mock).mock.lastCall[0]; + await expect(blob.type).toEqual("application/json"); + await expect(await blob.text()).toEqual( + JSON.stringify(MockOrganizationSyncSettings, null, 2), + ); + }, +}; diff --git a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/ExportPolicyButton.tsx b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/ExportPolicyButton.tsx new file mode 100644 index 0000000000..ca5532d3c1 --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/ExportPolicyButton.tsx @@ -0,0 +1,47 @@ +import type { OrganizationSyncSettings } from "api/typesGenerated"; +import { Button } from "components/Button/Button"; +import { displayError } from "components/GlobalSnackbar/utils"; +import { saveAs } from "file-saver"; +import { Download } from "lucide-react"; +import { type FC, useState } from "react"; + +interface ExportPolicyButtonProps { + syncSettings: OrganizationSyncSettings | undefined; + download?: (file: Blob, filename: string) => void; +} + +export const ExportPolicyButton: FC = ({ + syncSettings, + download = saveAs, +}) => { + const [isDownloading, setIsDownloading] = useState(false); + + const canCreatePolicyJson = + syncSettings?.field && Object.keys(syncSettings?.mapping).length > 0; + + return ( + + ); +}; diff --git a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage.tsx b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage.tsx new file mode 100644 index 0000000000..13f405affc --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage.tsx @@ -0,0 +1,110 @@ +import { getErrorMessage } from "api/errors"; +import { + organizationIdpSyncSettings, + patchOrganizationSyncSettings, +} from "api/queries/idpsync"; +import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"; +import { displayError } from "components/GlobalSnackbar/utils"; +import { displaySuccess } from "components/GlobalSnackbar/utils"; +import { Loader } from "components/Loader/Loader"; +import { Paywall } from "components/Paywall/Paywall"; +import { SquareArrowOutUpRight } from "lucide-react"; +import { useDashboard } from "modules/dashboard/useDashboard"; +import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility"; +import { type FC, useEffect } from "react"; +import { Helmet } from "react-helmet-async"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { docs } from "utils/docs"; +import { pageTitle } from "utils/page"; +import { ExportPolicyButton } from "./ExportPolicyButton"; +import IdpOrgSyncPageView from "./IdpOrgSyncPageView"; + +export const IdpOrgSyncPage: FC = () => { + const queryClient = useQueryClient(); + // IdP sync does not have its own entitlement and is based on templace_rbac + const { template_rbac: isIdpSyncEnabled } = useFeatureVisibility(); + const { organizations } = useDashboard(); + const { + data: orgSyncSettingsData, + isLoading, + error, + } = useQuery(organizationIdpSyncSettings(isIdpSyncEnabled)); + + const patchOrganizationSyncSettingsMutation = useMutation( + patchOrganizationSyncSettings(queryClient), + ); + + useEffect(() => { + if (patchOrganizationSyncSettingsMutation.error) { + displayError( + getErrorMessage( + patchOrganizationSyncSettingsMutation.error, + "Error updating organization idp sync settings.", + ), + ); + } + }, [patchOrganizationSyncSettingsMutation.error]); + + if (isLoading) { + return ; + } + + return ( + <> + + {pageTitle("Organization IdP Sync")} + + +
+
+
+

Organization IdP Sync

+

+ Automatically assign users to an organization based on their IdP + claims. + + View docs + + +

+
+ +
+ + + + + + { + try { + await patchOrganizationSyncSettingsMutation.mutateAsync(data); + displaySuccess("Organization sync settings updated."); + } catch (error) { + displayError( + getErrorMessage( + error, + "Failed to update organization IdP sync settings", + ), + ); + } + }} + error={error || patchOrganizationSyncSettingsMutation.error} + /> + + +
+ + ); +}; + +export default IdpOrgSyncPage; diff --git a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPageView.stories.tsx new file mode 100644 index 0000000000..99742b82e6 --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPageView.stories.tsx @@ -0,0 +1,50 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { + MockOrganization, + MockOrganization2, + MockOrganizationSyncSettings, + MockOrganizationSyncSettings2, +} from "testHelpers/entities"; +import { IdpOrgSyncPageView } from "./IdpOrgSyncPageView"; + +const meta: Meta = { + title: "pages/IdpOrgSyncPageView", + component: IdpOrgSyncPageView, +}; + +export default meta; +type Story = StoryObj; + +export const Empty: Story = { + args: { + organizationSyncSettings: { + field: "", + mapping: {}, + organization_assign_default: true, + }, + organizations: [MockOrganization, MockOrganization2], + error: undefined, + }, +}; + +export const Default: Story = { + args: { + organizationSyncSettings: MockOrganizationSyncSettings2, + organizations: [MockOrganization, MockOrganization2], + error: undefined, + }, +}; + +export const HasError: Story = { + args: { + ...Default.args, + error: "This is a test error", + }, +}; + +export const MissingGroups: Story = { + args: { + ...Default.args, + organizationSyncSettings: MockOrganizationSyncSettings, + }, +}; diff --git a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPageView.tsx b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPageView.tsx new file mode 100644 index 0000000000..470bf897fd --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPageView.tsx @@ -0,0 +1,352 @@ +import Skeleton from "@mui/material/Skeleton"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableContainer from "@mui/material/TableContainer"; +import TableHead from "@mui/material/TableHead"; +import TableRow from "@mui/material/TableRow"; +import type { + Organization, + OrganizationSyncSettings, +} from "api/typesGenerated"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { Button } from "components/Button/Button"; +import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"; +import { EmptyState } from "components/EmptyState/EmptyState"; +import { + HelpTooltip, + HelpTooltipContent, + HelpTooltipText, + HelpTooltipTrigger, +} from "components/HelpTooltip/HelpTooltip"; +import { Input } from "components/Input/Input"; +import { Label } from "components/Label/Label"; +import { + MultiSelectCombobox, + type Option, +} from "components/MultiSelectCombobox/MultiSelectCombobox"; +import { Switch } from "components/Switch/Switch"; +import { + TableLoaderSkeleton, + TableRowSkeleton, +} from "components/TableLoader/TableLoader"; +import { useFormik } from "formik"; +import { Plus, SquareArrowOutUpRight, Trash } from "lucide-react"; +import { type FC, useState } from "react"; +import { docs } from "utils/docs"; +import * as Yup from "yup"; +import { OrganizationPills } from "./OrganizationPills"; + +interface IdpSyncPageViewProps { + organizationSyncSettings: OrganizationSyncSettings | undefined; + organizations: readonly Organization[]; + onSubmit: (data: OrganizationSyncSettings) => void; + error?: unknown; +} + +const validationSchema = Yup.object({ + field: Yup.string().trim(), + organization_assign_default: Yup.boolean(), + mapping: Yup.object().shape({ + [`${String}`]: Yup.array().of(Yup.string()), + }), +}); + +export const IdpOrgSyncPageView: FC = ({ + organizationSyncSettings, + organizations, + onSubmit, + error, +}) => { + const form = useFormik({ + initialValues: { + field: organizationSyncSettings?.field ?? "", + organization_assign_default: + organizationSyncSettings?.organization_assign_default ?? true, + mapping: organizationSyncSettings?.mapping ?? {}, + }, + validationSchema: validationSchema, + onSubmit, + enableReinitialize: Boolean(organizationSyncSettings), + }); + const [coderOrgs, setCoderOrgs] = useState([]); + const [idpOrgName, setIdpOrgName] = useState(""); + const organizationMappingCount = form.values.mapping + ? Object.entries(form.values.mapping).length + : 0; + + const getOrgNames = (orgIds: readonly string[]) => { + return orgIds.map( + (orgId) => + organizations.find((org) => org.id === orgId)?.display_name || orgId, + ); + }; + + const handleDelete = async (idpOrg: string) => { + const newMapping = Object.fromEntries( + Object.entries(form.values.mapping || {}).filter( + ([key]) => key !== idpOrg, + ), + ); + const newSyncSettings = { + ...form.values, + mapping: newMapping, + }; + void form.setFieldValue("mapping", newSyncSettings.mapping); + form.handleSubmit(); + }; + + const SYNC_FIELD_ID = "sync-field"; + const ORGANIZATION_ASSIGN_DEFAULT_ID = "organization-assign-default"; + const IDP_ORGANIZATION_NAME_ID = "idp-organization-name"; + + return ( +
+ {Boolean(error) && } +
+
+
+
+ +
+
+ { + void form.setFieldValue("field", event.target.value); + }} + /> + +
+
+ { + void form.setFieldValue( + "organization_assign_default", + checked, + ); + form.handleSubmit(); + }} + /> + + + + +
+
+

+ If empty, organization sync is deactivated +

+
+
+ +
+
+
+ + { + setIdpOrgName(event.target.value); + }} + /> +
+
+ + ({ + label: org.display_name, + value: org.id, + }))} + hidePlaceholderWhenSelected + placeholder="Select organization" + emptyIndicator={ +

+ All organizations selected +

+ } + /> +
+
+   + +
+
+ + {form.values.mapping && + Object.entries(form.values.mapping) + .sort() + .map(([idpOrg, organizations]) => ( + + ))} + +
+
+
+
+ ); +}; + +interface IdpMappingTableProps { + isEmpty: boolean; + children: React.ReactNode; +} + +const IdpMappingTable: FC = ({ isEmpty, children }) => { + return ( + + + + + IdP organization + Coder organization + + + + + + + + + + + + How to set up IdP organization sync + + + } + /> + + + + + {children} + + +
+
+ ); +}; + +interface OrganizationRowProps { + idpOrg: string; + coderOrgs: readonly string[]; + onDelete: (idpOrg: string) => void; +} + +const OrganizationRow: FC = ({ + idpOrg, + coderOrgs, + onDelete, +}) => { + return ( + + {idpOrg} + + + + + + + + ); +}; + +const TableLoader = () => { + return ( + + + + + + + + + + + + + + ); +}; + +export const AssignDefaultOrgHelpTooltip: FC = () => { + return ( + + + + + Disabling will remove all users from the default organization. + + + + ); +}; + +export default IdpOrgSyncPageView; diff --git a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/OrganizationPills.tsx b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/OrganizationPills.tsx new file mode 100644 index 0000000000..7885ecbb40 --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/OrganizationPills.tsx @@ -0,0 +1,103 @@ +import { useTheme } from "@emotion/react"; +import { Pill } from "components/Pill/Pill"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "components/Popover/Popover"; +import type { FC } from "react"; +import { cn } from "utils/cn"; +import { isUUID } from "utils/uuid"; + +interface OrganizationPillsProps { + organizations: readonly string[]; +} + +export const OrganizationPills: FC = ({ + organizations, +}) => { + const orgs = organizations.map((org) => ({ + name: org, + isUUID: isUUID(org), + })); + + return ( +
+ {orgs.length > 0 ? ( + + {orgs[0].name} + + ) : ( +

None

+ )} + + {orgs.length > 1 && } +
+ ); +}; + +interface OverflowPillProps { + organizations: { name: string; isUUID: boolean }[]; +} + +const OverflowPillList: FC = ({ organizations }) => { + const theme = useTheme(); + + return ( + + + + +{organizations.length} + + + + +
    + {organizations.map((organization) => ( +
  • + + {organization.name} + +
  • + ))} +
+
+
+ ); +}; diff --git a/site/src/pages/DeploymentSettingsPage/PremiumPage/PremiumPageView.tsx b/site/src/pages/DeploymentSettingsPage/PremiumPage/PremiumPageView.tsx index b0da13a083..168fe6e591 100644 --- a/site/src/pages/DeploymentSettingsPage/PremiumPage/PremiumPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/PremiumPage/PremiumPageView.tsx @@ -1,4 +1,4 @@ -import { Button } from "components/ui/button"; +import { Button } from "components/Button/Button"; import { Activity, Coins, Expand, SquareArrowOutUpRight } from "lucide-react"; import type { FC } from "react"; import { docs } from "utils/docs"; diff --git a/site/src/pages/ManagementSettingsPage/IdpSyncPage/ExportPolicyButton.stories.tsx b/site/src/pages/ManagementSettingsPage/IdpSyncPage/ExportPolicyButton.stories.tsx index da2f5140d3..af9a6b2fa4 100644 --- a/site/src/pages/ManagementSettingsPage/IdpSyncPage/ExportPolicyButton.stories.tsx +++ b/site/src/pages/ManagementSettingsPage/IdpSyncPage/ExportPolicyButton.stories.tsx @@ -8,7 +8,7 @@ import { import { ExportPolicyButton } from "./ExportPolicyButton"; const meta: Meta = { - title: "modules/resources/ExportPolicyButton", + title: "pages/IdpSyncPage/ExportPolicyButton", component: ExportPolicyButton, args: { syncSettings: MockGroupSyncSettings, diff --git a/site/src/pages/ManagementSettingsPage/IdpSyncPage/ExportPolicyButton.tsx b/site/src/pages/ManagementSettingsPage/IdpSyncPage/ExportPolicyButton.tsx index 9cb4cb06e7..e32a39175a 100644 --- a/site/src/pages/ManagementSettingsPage/IdpSyncPage/ExportPolicyButton.tsx +++ b/site/src/pages/ManagementSettingsPage/IdpSyncPage/ExportPolicyButton.tsx @@ -7,7 +7,7 @@ import type { } from "api/typesGenerated"; import { displayError } from "components/GlobalSnackbar/utils"; import { saveAs } from "file-saver"; -import { type FC, useMemo, useState } from "react"; +import { type FC, useState } from "react"; interface DownloadPolicyButtonProps { syncSettings: RoleSyncSettings | GroupSyncSettings | undefined; @@ -24,21 +24,18 @@ export const ExportPolicyButton: FC = ({ }) => { const [isDownloading, setIsDownloading] = useState(false); - const policyJSON = useMemo(() => { - return syncSettings?.field && syncSettings.mapping - ? JSON.stringify(syncSettings, null, 2) - : null; - }, [syncSettings]); + const canCreatePolicyJson = + syncSettings?.field && Object.keys(syncSettings?.mapping).length > 0; return (