diff --git a/bun.lock b/bun.lock index 08d047a6..1b467c87 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "tanstack-start-app", "dependencies": { - "@clickhouse/click-ui": "0.2.0-rc.4", + "@clickhouse/click-ui": "0.9.1", "@librechat/data-schemas": "^0.0.56", "@radix-ui/react-dialog": "1.1.15", "@tailwindcss/vite": "^4.3.1", @@ -47,6 +47,7 @@ "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.3", "@types/js-yaml": "^4.0.9", "@types/node": "^22.20.0", "@types/react": "^19.2.17", @@ -160,7 +161,7 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "7.27.1", "@babel/helper-validator-identifier": "7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - "@clickhouse/click-ui": ["@clickhouse/click-ui@0.2.0-rc.4", "", { "dependencies": { "@h6s/calendar": "2.0.1", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-avatar": "1.1.1", "@radix-ui/react-checkbox": "1.1.2", "@radix-ui/react-context-menu": "2.2.2", "@radix-ui/react-dialog": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.1", "@radix-ui/react-dropdown-menu": "2.1.2", "@radix-ui/react-hover-card": "1.1.2", "@radix-ui/react-popover": "1.1.2", "@radix-ui/react-popper": "1.2.1", "@radix-ui/react-radio-group": "1.2.1", "@radix-ui/react-separator": "1.1.1", "@radix-ui/react-switch": "1.1.1", "@radix-ui/react-tabs": "1.1.1", "@radix-ui/react-toast": "1.2.2", "@radix-ui/react-tooltip": "1.1.2", "dayjs": "^1.11.19", "lodash-es": "^4.17.23", "react-sortablejs": "^6.1.4", "react-syntax-highlighter": "^16.1.0", "react-virtualized-auto-sizer": "^1.0.20", "react-window": "^1.8.9", "sortablejs": "^1.15.0", "styled-components": "^6.1.11" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-WcPjvS7W/InvHP/JVeMIHA/30jl3Ks6CN+zch7kfqogjcbTc4byOzSUSkOJlYblzLxfdins0z0oeBF+jVsBMDA=="], + "@clickhouse/click-ui": ["@clickhouse/click-ui@0.9.1", "", { "dependencies": { "@h6s/calendar": "2.2.0", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-avatar": "1.1.1", "@radix-ui/react-checkbox": "1.1.2", "@radix-ui/react-context-menu": "2.2.2", "@radix-ui/react-dialog": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.1", "@radix-ui/react-dropdown-menu": "2.1.2", "@radix-ui/react-hover-card": "1.1.2", "@radix-ui/react-popover": "1.1.2", "@radix-ui/react-popper": "1.2.1", "@radix-ui/react-radio-group": "1.2.1", "@radix-ui/react-separator": "1.1.1", "@radix-ui/react-switch": "1.1.1", "@radix-ui/react-tabs": "1.1.1", "@radix-ui/react-toast": "1.2.2", "@radix-ui/react-tooltip": "1.1.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dayjs": "^1.11.19", "lodash-es": "^4.17.23", "react-sortablejs": "^6.1.4", "react-syntax-highlighter": "^16.1.0", "react-virtualized-auto-sizer": "^1.0.20", "react-window": "^1.8.9", "sortablejs": "^1.15.0", "styled-components": "^6.1.11" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-gAyIlzIDYqnXcmHM2rOWNpGAoC0UAeacT5yJpDgu09I+TjmuOdzh9tYaJlS938SmUyLsg2U6t8oMA7do108Hgg=="], "@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="], @@ -188,8 +189,6 @@ "@emotion/memoize": ["@emotion/memoize@0.9.0", "", {}, "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ=="], - "@emotion/unitless": ["@emotion/unitless@0.10.0", "", {}, "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], @@ -270,7 +269,7 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], - "@h6s/calendar": ["@h6s/calendar@2.0.1", "", { "peerDependencies": { "date-fns": ">= 2", "react": ">= 18" } }, "sha512-9q5ksdnUsLDeuSm5arXzkxHyS22u7yi5TtVr4DZgW514AjdL+76kx7d+ScX9sKusasRQVxrhM2LTVmd8A/u42Q=="], + "@h6s/calendar": ["@h6s/calendar@2.2.0", "", { "peerDependencies": { "react": ">= 18" } }, "sha512-o1fb4RlMWDRIOiMEX65vN5qDTiD/OyC++EkJzmoz9l1vUOkN89gTE+wNE83XZBi3hbYqMV3CNhgKf51n/0hcBw=="], "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], @@ -606,6 +605,8 @@ "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "7.28.6" }, "optionalDependencies": { "@types/react": "19.2.14", "@types/react-dom": "19.2.3" }, "peerDependencies": { "@testing-library/dom": "10.4.1", "react": "19.2.4", "react-dom": "19.2.4" } }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], + "@testing-library/user-event": ["@testing-library/user-event@14.6.3", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-6dBq67jT8lE+JTE8Exm02Kt6ze43hz1jdiSpSJwtTZiT1xQQ6b7nZYTTQ9njdArdU8XklOwaDp/AbT/eYSKF4g=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], @@ -644,8 +645,6 @@ "@types/sortablejs": ["@types/sortablejs@1.15.9", "", {}, "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ=="], - "@types/stylis": ["@types/stylis@4.2.7", "", {}, "sha512-VgDNokpBoKF+wrdvhAAfS55OMQpL6QRglwTwNC3kIgBrzZxA4WsFj+2eLfEA/uMUDzBcEhYmjSbwQakn/i3ajA=="], - "@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="], "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], @@ -824,6 +823,8 @@ "ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + "classnames": ["classnames@2.3.1", "", {}, "sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA=="], "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], @@ -1454,8 +1455,6 @@ "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], - "shallowequal": ["shallowequal@1.1.0", "", {}, "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="], - "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], @@ -1654,10 +1653,6 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@clickhouse/click-ui/dayjs": ["dayjs@1.11.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="], - - "@clickhouse/click-ui/styled-components": ["styled-components@6.3.11", "", { "dependencies": { "@emotion/is-prop-valid": "1.4.0", "@emotion/unitless": "0.10.0", "@types/stylis": "4.2.7", "css-to-react-native": "3.2.0", "csstype": "3.2.3", "postcss": "8.4.49", "shallowequal": "1.1.0", "stylis": "4.3.6", "tslib": "2.8.1" }, "peerDependencies": { "react": ">= 16.8.0", "react-dom": ">= 16.8.0" }, "optionalPeers": ["react-dom"] }, "sha512-opzgceGlQ5rdZdGwf9ddLW7EM2F4L7tgsgLn6fFzQ2JgE5EVQ4HZwNkcgB1p8WfOBx1GEZP3fa66ajJmtXhSrA=="], - "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint/eslintrc/espree": ["espree@9.6.1", "", { "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^3.4.1" } }, "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ=="], @@ -2020,8 +2015,6 @@ "xmlbuilder2/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], - "@clickhouse/click-ui/styled-components/postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="], - "@eslint/eslintrc/espree/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint/eslintrc/minimatch/brace-expansion": ["brace-expansion@1.1.13", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w=="], @@ -2136,8 +2129,6 @@ "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "@clickhouse/click-ui/styled-components/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - "@eslint/eslintrc/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "@humanwhocodes/config-array/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], diff --git a/package.json b/package.json index 78d3e020..7b931eff 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "format:check": "prettier --check 'src/**/*.{ts,tsx,css,json}'" }, "dependencies": { - "@clickhouse/click-ui": "0.2.0-rc.4", + "@clickhouse/click-ui": "0.9.1", "@librechat/data-schemas": "^0.0.56", "@radix-ui/react-dialog": "1.1.15", "@tailwindcss/vite": "^4.3.1", @@ -64,6 +64,7 @@ "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.3", "@types/js-yaml": "^4.0.9", "@types/node": "^22.20.0", "@types/react": "^19.2.17", diff --git a/src/components/configuration/ScopeSelector.test.tsx b/src/components/configuration/ScopeSelector.test.tsx new file mode 100644 index 00000000..3697a90c --- /dev/null +++ b/src/components/configuration/ScopeSelector.test.tsx @@ -0,0 +1,226 @@ +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { PrincipalType, PermissionTypes } from 'librechat-data-provider'; +import type * as t from '@/types'; +import { ScopeSelector } from './ScopeSelector'; + +const mocks = vi.hoisted(() => ({ + scopesQueryFn: vi.fn(), + rolesQueryFn: vi.fn(), + groupsQueryFn: vi.fn(), + createScopeFn: vi.fn(), + deleteScopeFn: vi.fn(), +})); + +vi.mock('@/server', () => ({ + availableScopesOptions: { queryKey: ['availableScopes'], queryFn: mocks.scopesQueryFn }, + allRolesQueryOptions: { queryKey: ['allRoles'], queryFn: mocks.rolesQueryFn }, + allGroupsQueryOptions: { queryKey: ['allGroups'], queryFn: mocks.groupsQueryFn }, + createScopeFn: mocks.createScopeFn, + deleteScopeFn: mocks.deleteScopeFn, +})); + +vi.mock('@/hooks/useLocalize', () => ({ + default: () => (key: string) => key, + useLocalize: () => (key: string) => key, +})); + +vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, +); +Element.prototype.scrollIntoView = vi.fn(); + +const emptyPermissions = Object.fromEntries( + Object.values(PermissionTypes).map((type) => [type, {}]), +) as t.RolePermissions; + +const engineeringScope: t.ConfigScope = { + principalType: PrincipalType.ROLE, + principalId: 'role-1', + name: 'Engineering', + priority: 10, + isActive: true, +}; + +const engineeringRole: t.Role = { + id: 'role-1', + name: 'Engineering', + description: 'Engineering team', + isSystemRole: false, + isActive: true, + userCount: 0, + permissions: emptyPermissions, +}; + +const marketingRole: t.Role = { + id: 'role-2', + name: 'Marketing', + description: 'Growth team', + isSystemRole: false, + isActive: true, + userCount: 0, + permissions: emptyPermissions, +}; + +function renderSelector() { + const onSelect = vi.fn(); + const onOpenChange = vi.fn(); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + return { onSelect, onOpenChange }; +} + +async function renderListView() { + const handlers = renderSelector(); + await screen.findByText('Engineering'); + await waitFor(() => + expect(document.querySelector('[cmdk-item][aria-selected="true"]')).not.toBeNull(), + ); + return handlers; +} + +async function openDeleteConfirmationFromList(user: ReturnType) { + const deleteButton = screen.getByRole('button', { name: 'com_scope_delete' }); + deleteButton.focus(); + await user.keyboard('{Enter}'); + await screen.findByText('com_scope_delete_confirm'); +} + +async function openDeleteConfirmation(user: ReturnType) { + const handlers = await renderListView(); + await openDeleteConfirmationFromList(user); + return handlers; +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.scopesQueryFn.mockResolvedValue([engineeringScope]); + mocks.rolesQueryFn.mockResolvedValue([engineeringRole, marketingRole]); + mocks.groupsQueryFn.mockResolvedValue([]); + mocks.createScopeFn.mockResolvedValue({}); + mocks.deleteScopeFn.mockResolvedValue({}); +}); + +describe('ScopeSelector Enter key handling', () => { + it('opens the creation view when Enter is pressed on the focused Create button', async () => { + const user = userEvent.setup(); + const { onSelect, onOpenChange } = await renderListView(); + const createButton = screen.getByRole('button', { name: 'com_scope_create' }); + createButton.focus(); + await user.keyboard('{Enter}'); + expect(await screen.findByText('com_scope_create_new')).toBeInTheDocument(); + expect(onSelect).not.toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('does not close the dialog or select the Base scope when Enter is pressed in the empty search input', async () => { + const user = userEvent.setup(); + const { onSelect, onOpenChange } = await renderListView(); + screen.getByRole('combobox').focus(); + await user.keyboard('{Enter}'); + expect(onSelect).not.toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalled(); + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); + + it('creates a configuration when Enter is pressed on a focused role button in the creation view', async () => { + const user = userEvent.setup(); + await renderListView(); + screen.getByRole('button', { name: 'com_scope_create' }).focus(); + await user.keyboard('{Enter}'); + const roleButton = await screen.findByRole('button', { name: /Marketing/ }); + roleButton.focus(); + await user.keyboard('{Enter}'); + await waitFor(() => + expect(mocks.createScopeFn).toHaveBeenCalledWith({ + data: { + principalType: PrincipalType.ROLE, + name: 'Marketing', + priority: 10, + principalId: 'role-2', + }, + }), + ); + }); + + it('opens the delete confirmation without selecting the scope when Enter is pressed on a delete button', async () => { + const user = userEvent.setup(); + const { onSelect, onOpenChange } = await openDeleteConfirmation(user); + expect(onSelect).not.toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('deletes the scope once when Enter is pressed on the focused Delete button in the confirmation view', async () => { + const user = userEvent.setup(); + await openDeleteConfirmation(user); + const confirmButton = screen.getByRole('button', { name: 'com_scope_delete' }); + await waitFor(() => expect(confirmButton).toHaveFocus()); + await user.keyboard('{Enter}'); + await waitFor(() => expect(mocks.deleteScopeFn).toHaveBeenCalledTimes(1)); + expect(mocks.deleteScopeFn).toHaveBeenCalledWith({ + data: { principalType: PrincipalType.ROLE, principalId: 'role-1' }, + }); + }); + + it('returns to the list without deleting when Enter is pressed on the focused Cancel button', async () => { + const user = userEvent.setup(); + const { onOpenChange } = await openDeleteConfirmation(user); + const cancelButton = screen.getByRole('button', { name: 'com_ui_cancel' }); + cancelButton.focus(); + await user.keyboard('{Enter}'); + expect(await screen.findByRole('combobox')).toBeInTheDocument(); + expect(mocks.deleteScopeFn).not.toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('does not select the auto-highlighted Base scope with Enter after cancelling a delete reached by hovering', async () => { + const user = userEvent.setup(); + const { onSelect, onOpenChange } = await renderListView(); + await user.hover(screen.getByText('Engineering')); + await openDeleteConfirmationFromList(user); + const cancelButton = screen.getByRole('button', { name: 'com_ui_cancel' }); + cancelButton.focus(); + await user.keyboard('{Enter}'); + const searchInput = await screen.findByRole('combobox'); + searchInput.focus(); + await user.keyboard('{Enter}'); + expect(onSelect).not.toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('selects a scope with Enter after hovering it with the mouse', async () => { + const user = userEvent.setup(); + const { onSelect, onOpenChange } = await renderListView(); + await user.hover(screen.getByText('Engineering')); + screen.getByRole('combobox').focus(); + await user.keyboard('{Enter}'); + expect(onSelect).toHaveBeenCalledWith({ type: 'SCOPE', scope: engineeringScope }); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it('still selects a scope with arrow navigation followed by Enter', async () => { + const user = userEvent.setup(); + const { onSelect, onOpenChange } = await renderListView(); + screen.getByRole('combobox').focus(); + await user.keyboard('{ArrowDown}{Enter}'); + expect(onSelect).toHaveBeenCalledWith({ type: 'SCOPE', scope: engineeringScope }); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/src/components/configuration/ScopeSelector.tsx b/src/components/configuration/ScopeSelector.tsx index 9af2d913..b1e79474 100644 --- a/src/components/configuration/ScopeSelector.tsx +++ b/src/components/configuration/ScopeSelector.tsx @@ -1,11 +1,12 @@ import { Command } from 'cmdk'; -import { Button, Icon } from '@clickhouse/click-ui'; import { PrincipalType } from 'librechat-data-provider'; +import { Button, Dialog, Icon } from '@clickhouse/click-ui'; import { useCallback, useMemo, useRef, useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { VisuallyHidden } from '@radix-ui/react-visually-hidden'; import { Title as DialogTitle, Description as DialogDescription } from '@radix-ui/react-dialog'; import type { AdminGroup } from '@librechat/data-schemas'; +import type { KeyboardEvent, PointerEvent } from 'react'; import type * as t from '@/types'; import { availableScopesOptions, @@ -18,6 +19,14 @@ import { getScopeTypeConfig } from '@/constants'; import { useLocalize } from '@/hooks'; import { cn } from '@/utils'; +const NAVIGATION_KEYS = new Set(['ArrowDown', 'ArrowUp', 'Home', 'End']); +const VIM_NAVIGATION_KEYS = new Set(['n', 'j', 'p', 'k']); + +/** cmdk's root keydown preventDefaults Enter to redispatch it to the highlighted item, which cancels native button activation; keep the key from reaching the root. */ +function stopActivationKeys(e: KeyboardEvent) { + if (e.key === 'Enter' || e.key === ' ') e.stopPropagation(); +} + // ── Main selector ─────────────────────────────────────────────────── export function ScopeSelector({ @@ -36,6 +45,8 @@ export function ScopeSelector({ const [deleteTarget, setDeleteTarget] = useState(null); const [deleting, setDeleting] = useState(false); const listRef = useRef(null); + const highlightedRef = useRef(false); + const deleteButtonRef = useRef(null); const { data: scopes = [], isLoading: loading } = useQuery({ ...availableScopesOptions, @@ -57,11 +68,43 @@ export function ScopeSelector({ if (listRef.current) listRef.current.scrollTop = 0; }, []); + /** Without an explicit highlight, Enter in the empty input would silently activate the auto-highlighted first item (Base configuration) and close the dialog. */ + const handleSearchKeyDown = useCallback( + (e: KeyboardEvent) => { + if (NAVIGATION_KEYS.has(e.key) || (e.ctrlKey && VIM_NAVIGATION_KEYS.has(e.key))) { + highlightedRef.current = true; + return; + } + if (e.key === 'Enter' && search === '' && !highlightedRef.current) e.stopPropagation(); + }, + [search], + ); + + /** cmdk also moves the highlight when the pointer travels over an item, so hover counts as an explicit highlight too. */ + const handleListPointerMove = useCallback((e: PointerEvent) => { + if (e.target instanceof Element && e.target.closest('[cmdk-item]')) { + highlightedRef.current = true; + } + }, []); + + const handleDeleteAutoFocus = useCallback((e: Event) => { + e.preventDefault(); + deleteButtonRef.current?.focus(); + }, []); + const resetState = useCallback(() => { setShowCreate(false); setCreating(false); setDeleteTarget(null); setDeleting(false); + highlightedRef.current = false; + }, []); + + /** Returning to the list remounts the cmdk view with the first item auto-highlighted, so a highlight carried over from the previous view must not let Enter select it. */ + const returnToList = useCallback(() => { + setShowCreate(false); + setDeleteTarget(null); + highlightedRef.current = false; }, []); const close = useCallback(() => { @@ -172,13 +215,22 @@ export function ScopeSelector({ ) { onSelect({ type: 'BASE' }); } - setDeleteTarget(null); + returnToList(); setDeleting(false); } catch (err) { setDeleting(false); onError?.(err instanceof Error ? err.message : localize('com_scope_delete_error')); } - }, [deleteTarget, deleting, queryClient, currentSelection, onSelect, onError, localize]); + }, [ + deleteTarget, + deleting, + queryClient, + currentSelection, + onSelect, + onError, + localize, + returnToList, + ]); const roleScopes = useMemo( () => scopes.filter((s) => s.principalType === PrincipalType.ROLE), @@ -205,53 +257,35 @@ export function ScopeSelector({ if (deleteTarget) { return ( - - - {localize('com_scope_delete')} - {localize('com_scope_delete')} - - - - {localize('com_scope_delete_confirm', { name: deleteTarget.name })} - - - setDeleteTarget(null)} - disabled={deleting} - className="cursor-pointer rounded-md px-3 py-1.5 text-sm text-(--cui-color-text-muted) transition-colors hover:text-(--cui-color-text-default)" - > - {localize('com_ui_cancel')} - - - {deleting ? ( - <> - - {localize('com_scope_deleting')} - > - ) : ( - localize('com_scope_delete') - )} - + + + + + {localize('com_scope_delete_confirm', { name: deleteTarget.name })} + + + + + - - + + ); } @@ -262,118 +296,111 @@ export function ScopeSelector({ const noGroups = availableGroups.length === 0; return ( - - - {localize('com_scope_create_new')} - {localize('com_scope_create_new')} - - - setShowCreate(false)} - className="flex cursor-pointer items-center text-(--cui-color-text-muted) hover:text-(--cui-color-text-default)" - > - - - - {localize('com_scope_create_new')} - - {creating && ( - - - - )} - - - {/* Roles section */} - - {localize('com_scope_roles')} - {noRoles ? ( - - {localize('com_scope_no_available_roles')} - - ) : ( - availableRoles.map((role) => ( - handleCreateForRole(role)} - disabled={creating} - className={cn( - 'scope-item w-full text-left', - creating && 'pointer-events-none opacity-50', - )} - > - + + + + + + {creating && ( + + + + )} + + + {/* Roles section */} + + {localize('com_scope_roles')} + {noRoles ? ( + + {localize('com_scope_no_available_roles')} + + ) : ( + availableRoles.map((role) => ( + handleCreateForRole(role)} + disabled={creating} + className={cn( + 'scope-item w-full text-left', + creating && 'pointer-events-none opacity-50', + )} > - - - - - {role.name} + + - {role.description && ( - - {role.description} + + + {role.name} + {role.description && ( + + {role.description} + + )} + + + )) + )} + + + {/* Groups section */} + + {localize('com_scope_groups')} + {noGroups ? ( + + {localize('com_scope_no_available_groups')} + + ) : ( + availableGroups.map((group) => ( + handleCreateForGroup(group)} + disabled={creating} + className={cn( + 'scope-item w-full text-left', + creating && 'pointer-events-none opacity-50', )} - - - )) - )} - - - {/* Groups section */} - - {localize('com_scope_groups')} - {noGroups ? ( - - {localize('com_scope_no_available_groups')} - - ) : ( - availableGroups.map((group) => ( - handleCreateForGroup(group)} - disabled={creating} - className={cn( - 'scope-item w-full text-left', - creating && 'pointer-events-none opacity-50', - )} - > - - - - - - {group.name} + + - {group.description && ( - - {group.description} + + + {group.name} - )} - - - )) - )} + {group.description && ( + + {group.description} + + )} + + + )) + )} + - - + + ); } @@ -395,6 +422,7 @@ export function ScopeSelector({ @@ -404,11 +432,16 @@ export function ScopeSelector({ iconLeft="plus" label={localize('com_scope_create')} onClick={() => setShowCreate(true)} + onKeyDown={stopActivationKeys} /> )} - + {loading ? ( @@ -553,6 +586,7 @@ function ScopeItem({ scope, isSelected, onSelect, onDelete, localize }: ScopeIte e.stopPropagation(); onDelete(scope); }} + onKeyDown={stopActivationKeys} className="shrink-0 cursor-pointer rounded-sm p-0.5 text-(--cui-color-text-muted) opacity-0 transition-opacity group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[selected=true]:opacity-100 hover:text-(--cui-color-accent-danger)" aria-label={localize('com_scope_delete')} title={localize('com_scope_delete')} diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index a00cd996..0df53e33 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -9,6 +9,7 @@ "com_ui_remove_item": "Remove {{name}}", "com_ui_save": "Save", "com_ui_add": "Add", + "com_ui_back": "Back", "com_ui_cancel": "Cancel", "com_ui_create": "Create", "com_ui_retry": "Retry", diff --git a/vitest.config.ts b/vitest.config.ts index c7e8e233..25a36ac3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,6 +7,11 @@ export default defineConfig({ environment: 'jsdom', globals: true, setupFiles: ['./src/test/setup.ts'], - exclude: ['e2e/**', 'node_modules/**', 'tools/**'], + exclude: ['e2e/**', 'node_modules/**', 'tools/**', '.claude/**'], + server: { + deps: { + inline: ['@clickhouse/click-ui'], + }, + }, }, })
- {localize('com_scope_delete_confirm', { name: deleteTarget.name })} -
+ {localize('com_scope_delete_confirm', { name: deleteTarget.name })} +
- {localize('com_scope_no_available_roles')} -
+ {localize('com_scope_no_available_roles')} +
+ {localize('com_scope_no_available_groups')} +
- {localize('com_scope_no_available_groups')} -