Skip to content

Commit 2d71e1e

Browse files
committed
Add CredentialTypeSelector for HTTP node
1 parent 47dcaaa commit 2d71e1e

5 files changed

Lines changed: 261 additions & 20 deletions

File tree

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { fireEvent, render, screen } from '@testing-library/react'
2+
3+
import type { ComponentCredentialSchema } from '@/core/types'
4+
5+
import { CredentialTypeSelector } from './CredentialTypeSelector'
6+
7+
// ─── Mocks ────────────────────────────────────────────────────────────────────
8+
9+
jest.mock('@tabler/icons-react', () => ({
10+
IconKey: () => <span data-testid='icon-key-fallback' />,
11+
IconSearch: () => <span data-testid='icon-search' />,
12+
IconX: (props: { onClick?: () => void }) => <button data-testid='icon-x' onClick={props.onClick} />
13+
}))
14+
15+
// ─── Fixtures ─────────────────────────────────────────────────────────────────
16+
17+
const schemas: ComponentCredentialSchema[] = [
18+
{ label: 'HTTP Basic Auth', name: 'httpBasicAuth', inputs: [] },
19+
{ label: 'HTTP Bearer Token', name: 'httpBearerToken', inputs: [] },
20+
{ label: 'HTTP Api Key', name: 'httpApiKey', inputs: [] }
21+
]
22+
23+
const apiBaseUrl = 'http://localhost:3000'
24+
25+
// ─── Tests ────────────────────────────────────────────────────────────────────
26+
27+
describe('CredentialTypeSelector', () => {
28+
it('renders search input with placeholder', () => {
29+
render(<CredentialTypeSelector schemas={schemas} apiBaseUrl={apiBaseUrl} onSelect={jest.fn()} />)
30+
31+
expect(screen.getByPlaceholderText('Search credential')).toBeInTheDocument()
32+
})
33+
34+
it('renders all credential cards with labels and icons', () => {
35+
render(<CredentialTypeSelector schemas={schemas} apiBaseUrl={apiBaseUrl} onSelect={jest.fn()} />)
36+
37+
expect(screen.getByText('HTTP Basic Auth')).toBeInTheDocument()
38+
expect(screen.getByText('HTTP Bearer Token')).toBeInTheDocument()
39+
expect(screen.getByText('HTTP Api Key')).toBeInTheDocument()
40+
41+
const images = screen.getAllByRole('img')
42+
expect(images).toHaveLength(3)
43+
expect(images[0]).toHaveAttribute('src', 'http://localhost:3000/api/v1/components-credentials-icon/httpBasicAuth')
44+
expect(images[0]).toHaveAttribute('alt', 'httpBasicAuth')
45+
})
46+
47+
it('calls onSelect with the clicked schema', () => {
48+
const onSelect = jest.fn()
49+
render(<CredentialTypeSelector schemas={schemas} apiBaseUrl={apiBaseUrl} onSelect={onSelect} />)
50+
51+
fireEvent.click(screen.getByText('HTTP Bearer Token'))
52+
53+
expect(onSelect).toHaveBeenCalledTimes(1)
54+
expect(onSelect).toHaveBeenCalledWith(schemas[1])
55+
})
56+
57+
it('filters schemas by search input', () => {
58+
render(<CredentialTypeSelector schemas={schemas} apiBaseUrl={apiBaseUrl} onSelect={jest.fn()} />)
59+
60+
const searchInput = screen.getByPlaceholderText('Search credential')
61+
fireEvent.change(searchInput, { target: { value: 'bearer' } })
62+
63+
expect(screen.getByText('HTTP Bearer Token')).toBeInTheDocument()
64+
expect(screen.queryByText('HTTP Basic Auth')).not.toBeInTheDocument()
65+
expect(screen.queryByText('HTTP Api Key')).not.toBeInTheDocument()
66+
})
67+
68+
it('search is case-insensitive', () => {
69+
render(<CredentialTypeSelector schemas={schemas} apiBaseUrl={apiBaseUrl} onSelect={jest.fn()} />)
70+
71+
fireEvent.change(screen.getByPlaceholderText('Search credential'), { target: { value: 'API' } })
72+
73+
expect(screen.getByText('HTTP Api Key')).toBeInTheDocument()
74+
expect(screen.queryByText('HTTP Basic Auth')).not.toBeInTheDocument()
75+
})
76+
77+
it('clears search when clear button is clicked', () => {
78+
render(<CredentialTypeSelector schemas={schemas} apiBaseUrl={apiBaseUrl} onSelect={jest.fn()} />)
79+
80+
const searchInput = screen.getByPlaceholderText('Search credential')
81+
fireEvent.change(searchInput, { target: { value: 'bearer' } })
82+
83+
expect(screen.queryByText('HTTP Basic Auth')).not.toBeInTheDocument()
84+
85+
fireEvent.click(screen.getByTestId('icon-x'))
86+
87+
expect(screen.getByText('HTTP Basic Auth')).toBeInTheDocument()
88+
expect(screen.getByText('HTTP Bearer Token')).toBeInTheDocument()
89+
expect(screen.getByText('HTTP Api Key')).toBeInTheDocument()
90+
})
91+
92+
it('shows no cards when search matches nothing', () => {
93+
render(<CredentialTypeSelector schemas={schemas} apiBaseUrl={apiBaseUrl} onSelect={jest.fn()} />)
94+
95+
fireEvent.change(screen.getByPlaceholderText('Search credential'), { target: { value: 'nonexistent' } })
96+
97+
expect(screen.queryByText('HTTP Basic Auth')).not.toBeInTheDocument()
98+
expect(screen.queryByText('HTTP Bearer Token')).not.toBeInTheDocument()
99+
expect(screen.queryByText('HTTP Api Key')).not.toBeInTheDocument()
100+
expect(screen.queryAllByRole('img')).toHaveLength(0)
101+
})
102+
103+
it('renders empty list when schemas is empty', () => {
104+
render(<CredentialTypeSelector schemas={[]} apiBaseUrl={apiBaseUrl} onSelect={jest.fn()} />)
105+
106+
expect(screen.getByPlaceholderText('Search credential')).toBeInTheDocument()
107+
expect(screen.queryAllByRole('img')).toHaveLength(0)
108+
})
109+
110+
it('shows fallback key icon when credential icon fails to load', () => {
111+
render(<CredentialTypeSelector schemas={[schemas[0]]} apiBaseUrl={apiBaseUrl} onSelect={jest.fn()} />)
112+
113+
const img = screen.getByAltText('httpBasicAuth')
114+
fireEvent.error(img)
115+
116+
expect(screen.queryByAltText('httpBasicAuth')).not.toBeInTheDocument()
117+
expect(screen.getByTestId('icon-key-fallback')).toBeInTheDocument()
118+
})
119+
})
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { type SyntheticEvent, useState } from 'react'
2+
3+
import { Box, InputAdornment, List, ListItemButton, OutlinedInput, Typography } from '@mui/material'
4+
import { alpha, useTheme } from '@mui/material/styles'
5+
import { IconKey, IconSearch, IconX } from '@tabler/icons-react'
6+
7+
import type { ComponentCredentialSchema } from '@/core/types'
8+
9+
export interface CredentialTypeSelectorProps {
10+
schemas: ComponentCredentialSchema[]
11+
apiBaseUrl: string
12+
onSelect: (schema: ComponentCredentialSchema) => void
13+
}
14+
15+
/**
16+
* Search + grid selector for choosing a credential type.
17+
* Renders a search bar and a 3-column grid of credential cards with icons.
18+
*/
19+
export function CredentialTypeSelector({ schemas, apiBaseUrl, onSelect }: CredentialTypeSelectorProps) {
20+
const theme = useTheme()
21+
const [searchValue, setSearchValue] = useState('')
22+
23+
const filtered = schemas.filter((s) => s.label.toLowerCase().includes(searchValue.toLowerCase()))
24+
25+
return (
26+
<>
27+
<Box sx={{ backgroundColor: theme.palette.background.paper, pt: 2, position: 'sticky', top: 0, zIndex: 10 }}>
28+
<OutlinedInput
29+
sx={{ width: '100%', pr: 2, pl: 2 }}
30+
value={searchValue}
31+
onChange={(e) => setSearchValue(e.target.value)}
32+
placeholder='Search credential'
33+
startAdornment={
34+
<InputAdornment position='start'>
35+
<IconSearch stroke={1.5} size='1rem' color={theme.palette.grey[500]} />
36+
</InputAdornment>
37+
}
38+
endAdornment={
39+
<InputAdornment
40+
position='end'
41+
sx={{ cursor: 'pointer', color: theme.palette.grey[500], '&:hover': { color: theme.palette.grey[900] } }}
42+
title='Clear Search'
43+
>
44+
<IconX stroke={1.5} size='1rem' onClick={() => setSearchValue('')} style={{ cursor: 'pointer' }} />
45+
</InputAdornment>
46+
}
47+
/>
48+
</Box>
49+
<List
50+
sx={{
51+
width: '100%',
52+
display: 'grid',
53+
gridTemplateColumns: 'repeat(3, 1fr)',
54+
gap: 2,
55+
py: 0,
56+
zIndex: 9,
57+
borderRadius: '10px',
58+
[theme.breakpoints.down('md')]: {
59+
maxWidth: 370
60+
}
61+
}}
62+
>
63+
{filtered.map((schema) => (
64+
<ListItemButton
65+
key={schema.name}
66+
onClick={() => onSelect(schema)}
67+
sx={{
68+
border: 1,
69+
borderColor: alpha(theme.palette.grey[900], 0.25),
70+
borderRadius: 2,
71+
display: 'flex',
72+
alignItems: 'center',
73+
justifyContent: 'start',
74+
textAlign: 'left',
75+
gap: 1,
76+
p: 2
77+
}}
78+
>
79+
<CredentialIcon name={schema.name} apiBaseUrl={apiBaseUrl} />
80+
<Typography>{schema.label}</Typography>
81+
</ListItemButton>
82+
))}
83+
</List>
84+
</>
85+
)
86+
}
87+
88+
/** Circular credential icon with fallback to a key icon on load error. */
89+
function CredentialIcon({ name, apiBaseUrl }: { name: string; apiBaseUrl: string }) {
90+
const theme = useTheme()
91+
const [failed, setFailed] = useState(false)
92+
93+
const handleError = (e: SyntheticEvent<HTMLImageElement>) => {
94+
e.currentTarget.onerror = null
95+
setFailed(true)
96+
}
97+
98+
return (
99+
<div
100+
style={{
101+
width: 50,
102+
height: 50,
103+
borderRadius: '50%',
104+
backgroundColor: theme.palette.common.white,
105+
flexShrink: 0,
106+
display: 'flex',
107+
alignItems: 'center',
108+
justifyContent: 'center'
109+
}}
110+
>
111+
{failed ? (
112+
<IconKey size={30} stroke={1.5} />
113+
) : (
114+
<img
115+
style={{
116+
width: '100%',
117+
height: '100%',
118+
padding: 7,
119+
borderRadius: '50%',
120+
objectFit: 'contain'
121+
}}
122+
alt={name}
123+
src={`${apiBaseUrl}/api/v1/components-credentials-icon/${name}`}
124+
onError={handleError}
125+
/>
126+
)}
127+
</div>
128+
)
129+
}

packages/agentflow/src/atoms/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
export { ArrayInput, type ArrayInputProps } from './ArrayInput'
33
export { CodeInput, type CodeInputProps } from './CodeInput'
44
export { ConditionBuilder, type ConditionBuilderProps } from './ConditionBuilder'
5+
export { CredentialTypeSelector, type CredentialTypeSelectorProps } from './CredentialTypeSelector'
56
export { Dropdown, type DropdownOption, type DropdownProps } from './Dropdown'
67
export { ExpandTextDialog, type ExpandTextDialogProps } from './ExpandTextDialog'
78
export { JsonInput, type JsonInputProps } from './JsonInput'

packages/agentflow/src/features/node-editor/CreateCredentialDialog.test.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import { CreateCredentialDialog } from './CreateCredentialDialog'
88

99
jest.mock('@tabler/icons-react', () => ({
1010
IconAlertTriangle: () => <span data-testid='icon-alert-triangle' />,
11-
IconArrowsMaximize: () => <span data-testid='icon-expand' />
11+
IconArrowsMaximize: () => <span data-testid='icon-expand' />,
12+
IconSearch: () => <span data-testid='icon-search' />,
13+
IconX: () => <span data-testid='icon-x' />
1214
}))
1315

1416
jest.mock('html-react-parser', () => ({
@@ -282,7 +284,7 @@ describe('CreateCredentialDialog – multiple credential types', () => {
282284
render(<CreateCredentialDialog {...defaultProps} credentialNames={['openAIApi', 'awsApi']} />)
283285

284286
await waitFor(() => {
285-
expect(screen.getByText('Select Credential Type')).toBeInTheDocument()
287+
expect(screen.getByText('Add New Credential')).toBeInTheDocument()
286288
expect(screen.getByText('OpenAI API')).toBeInTheDocument()
287289
expect(screen.getByText('AWS security credentials')).toBeInTheDocument()
288290
})

packages/agentflow/src/features/node-editor/CreateCredentialDialog.tsx

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { useTheme } from '@mui/material/styles'
1717
import { IconAlertTriangle, IconArrowsMaximize } from '@tabler/icons-react'
1818
import parser from 'html-react-parser'
1919

20+
import { CredentialTypeSelector } from '@/atoms/CredentialTypeSelector'
2021
import { Dropdown } from '@/atoms/Dropdown'
2122
import { JsonInput } from '@/atoms/JsonInput'
2223
import { SwitchInput } from '@/atoms/SwitchInput'
@@ -173,8 +174,8 @@ export function CreateCredentialDialog({ open, credentialNames, onClose, onCreat
173174
const showSelection = !loading && !selectedSchema && schemas.length > 1
174175

175176
return (
176-
<Dialog open={open} onClose={handleClose} fullWidth maxWidth='sm'>
177-
<DialogTitle sx={{ fontSize: '1rem' }}>
177+
<Dialog open={open} onClose={handleClose} fullWidth maxWidth={showSelection ? 'md' : 'sm'}>
178+
<DialogTitle sx={{ fontSize: '1rem', p: 3, pb: 0 }}>
178179
{selectedSchema ? (
179180
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
180181
<div
@@ -201,10 +202,12 @@ export function CreateCredentialDialog({ open, credentialNames, onClose, onCreat
201202
{selectedSchema.label}
202203
</div>
203204
) : (
204-
'Select Credential Type'
205+
'Add New Credential'
205206
)}
206207
</DialogTitle>
207-
<DialogContent>
208+
<DialogContent
209+
sx={showSelection ? { display: 'flex', flexDirection: 'column', gap: 2, maxHeight: '75vh', px: 3, pb: 3 } : undefined}
210+
>
208211
{loading && (
209212
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
210213
<CircularProgress />
@@ -217,20 +220,7 @@ export function CreateCredentialDialog({ open, credentialNames, onClose, onCreat
217220
</Alert>
218221
)}
219222

220-
{showSelection && (
221-
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
222-
{schemas.map((schema) => (
223-
<Button
224-
key={schema.name}
225-
variant='outlined'
226-
onClick={() => selectSchema(schema)}
227-
sx={{ justifyContent: 'flex-start', textTransform: 'none' }}
228-
>
229-
{schema.label}
230-
</Button>
231-
))}
232-
</Box>
233-
)}
223+
{showSelection && <CredentialTypeSelector schemas={schemas} apiBaseUrl={apiBaseUrl} onSelect={selectSchema} />}
234224

235225
{selectedSchema && (
236226
<>

0 commit comments

Comments
 (0)