Skip to content

Commit db17014

Browse files
committed
Add support for edit credentials
1 parent b634046 commit db17014

7 files changed

Lines changed: 225 additions & 32 deletions

File tree

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

Lines changed: 107 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,26 @@ jest.mock('reactflow', () => ({
1515

1616
jest.mock('@tabler/icons-react', () => ({
1717
IconArrowsMaximize: () => <span data-testid='icon-expand' />,
18+
IconEdit: () => <span data-testid='icon-edit' />,
1819
IconVariable: () => <span data-testid='icon-variable' />,
1920
IconRefresh: () => <span data-testid='icon-refresh' />
2021
}))
2122

2223
jest.mock('./CreateCredentialDialog', () => ({
23-
CreateCredentialDialog: ({ open, onCreated, onClose }: { open: boolean; onCreated: (id: string) => void; onClose: () => void }) =>
24+
CreateCredentialDialog: ({
25+
open,
26+
onCreated,
27+
onClose,
28+
editCredentialId
29+
}: {
30+
open: boolean
31+
onCreated: (id: string) => void
32+
onClose: () => void
33+
editCredentialId?: string
34+
}) =>
2435
open ? (
25-
<div data-testid='create-credential-dialog'>
26-
<button onClick={() => onCreated('new-cred-id')}>Create</button>
36+
<div data-testid={editCredentialId ? 'edit-credential-dialog' : 'create-credential-dialog'}>
37+
<button onClick={() => onCreated(editCredentialId ?? 'new-cred-id')}>{editCredentialId ? 'Save' : 'Create'}</button>
2738
<button onClick={onClose}>Close</button>
2839
</div>
2940
) : null
@@ -521,3 +532,96 @@ describe('AsyncInput – Create New credential', () => {
521532
expect(mockUseAsyncOptions.mock.calls.length).toBeGreaterThan(initialCallCount)
522533
})
523534
})
535+
536+
describe('AsyncInput – Edit credential', () => {
537+
it('edit button does NOT appear when no credential is selected', () => {
538+
mockUseAsyncOptions.mockReturnValue({
539+
...idleResult(),
540+
options: [{ label: 'My API Key', name: 'cred-1' }]
541+
})
542+
543+
render(
544+
<AsyncInput
545+
inputParam={makeParam({ type: 'asyncOptions', credentialNames: ['openAIApi'] })}
546+
value=''
547+
disabled={false}
548+
onChange={jest.fn()}
549+
/>
550+
)
551+
552+
expect(screen.queryByTitle('Edit Credential')).toBeNull()
553+
})
554+
555+
it('edit button appears when a credential is selected', () => {
556+
mockUseAsyncOptions.mockReturnValue({
557+
...idleResult(),
558+
options: [{ label: 'My API Key', name: 'cred-1' }]
559+
})
560+
561+
render(
562+
<AsyncInput
563+
inputParam={makeParam({ type: 'asyncOptions', credentialNames: ['openAIApi'] })}
564+
value='cred-1'
565+
disabled={false}
566+
onChange={jest.fn()}
567+
/>
568+
)
569+
570+
expect(screen.getByTitle('Edit Credential')).toBeTruthy()
571+
})
572+
573+
it('edit button does NOT appear for non-credential async dropdowns', () => {
574+
mockUseAsyncOptions.mockReturnValue({
575+
...idleResult(),
576+
options: [{ label: 'GPT-4o', name: 'gpt-4o' }]
577+
})
578+
579+
render(<AsyncInput inputParam={makeParam({ type: 'asyncOptions' })} value='gpt-4o' disabled={false} onChange={jest.fn()} />)
580+
581+
expect(screen.queryByTitle('Edit Credential')).toBeNull()
582+
})
583+
584+
it('clicking edit button opens the edit dialog', () => {
585+
mockUseAsyncOptions.mockReturnValue({
586+
...idleResult(),
587+
options: [{ label: 'My API Key', name: 'cred-1' }]
588+
})
589+
590+
render(
591+
<AsyncInput
592+
inputParam={makeParam({ type: 'asyncOptions', credentialNames: ['openAIApi'] })}
593+
value='cred-1'
594+
disabled={false}
595+
onChange={jest.fn()}
596+
/>
597+
)
598+
599+
fireEvent.click(screen.getByTitle('Edit Credential'))
600+
expect(screen.getByTestId('edit-credential-dialog')).toBeTruthy()
601+
})
602+
603+
it('after editing, onChange is called with credential ID and component remounts to refetch', () => {
604+
const mockChange = jest.fn()
605+
mockUseAsyncOptions.mockReturnValue({
606+
...idleResult(),
607+
options: [{ label: 'My API Key', name: 'cred-1' }]
608+
})
609+
610+
render(
611+
<AsyncInput
612+
inputParam={makeParam({ type: 'asyncOptions', credentialNames: ['openAIApi'] })}
613+
value='cred-1'
614+
disabled={false}
615+
onChange={mockChange}
616+
/>
617+
)
618+
619+
const initialCallCount = mockUseAsyncOptions.mock.calls.length
620+
621+
fireEvent.click(screen.getByTitle('Edit Credential'))
622+
fireEvent.click(screen.getByText('Save'))
623+
624+
expect(mockChange).toHaveBeenCalledWith('cred-1')
625+
expect(mockUseAsyncOptions.mock.calls.length).toBeGreaterThan(initialCallCount)
626+
})
627+
})

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

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Fragment, useCallback, useState } from 'react'
22

33
import { Box, CircularProgress, IconButton, TextField, Typography } from '@mui/material'
44
import Autocomplete from '@mui/material/Autocomplete'
5-
import { IconRefresh } from '@tabler/icons-react'
5+
import { IconEdit, IconRefresh } from '@tabler/icons-react'
66

77
import type { AsyncInputProps } from '@/atoms'
88
import type { NodeOption } from '@/core/types'
@@ -31,8 +31,11 @@ function buildAsyncParams(
3131
function AsyncOptionsInput({ inputParam, value, disabled, onChange, nodeName, inputValues }: AsyncInputProps) {
3232
const isCredential = !!inputParam.credentialNames?.length
3333
const [createDialogOpen, setCreateDialogOpen] = useState(false)
34+
const [editDialogOpen, setEditDialogOpen] = useState(false)
3435
const [reloadKey, setReloadKey] = useState(0)
3536

37+
const selectedCredentialId = isCredential && typeof value === 'string' && value ? value : null
38+
3639
const handleCreated = useCallback(
3740
(newCredentialId: string) => {
3841
setCreateDialogOpen(false)
@@ -45,19 +48,35 @@ function AsyncOptionsInput({ inputParam, value, disabled, onChange, nodeName, in
4548
[onChange]
4649
)
4750

51+
const handleEdited = useCallback(
52+
(credentialId: string) => {
53+
setEditDialogOpen(false)
54+
onChange(credentialId)
55+
setReloadKey((k) => k + 1)
56+
},
57+
[onChange]
58+
)
59+
4860
return (
4961
<>
50-
<AsyncOptionsDropdown
51-
key={reloadKey}
52-
inputParam={inputParam}
53-
value={value}
54-
disabled={disabled}
55-
onChange={onChange}
56-
nodeName={nodeName}
57-
inputValues={inputValues}
58-
isCredential={isCredential}
59-
onCreateNew={() => setCreateDialogOpen(true)}
60-
/>
62+
<Box sx={{ display: 'flex', flexDirection: 'row', alignItems: 'center', mt: 1 }}>
63+
<AsyncOptionsDropdown
64+
key={reloadKey}
65+
inputParam={inputParam}
66+
value={value}
67+
disabled={disabled}
68+
onChange={onChange}
69+
nodeName={nodeName}
70+
inputValues={inputValues}
71+
isCredential={isCredential}
72+
onCreateNew={() => setCreateDialogOpen(true)}
73+
/>
74+
{selectedCredentialId && (
75+
<IconButton title='Edit Credential' color='primary' size='small' onClick={() => setEditDialogOpen(true)}>
76+
<IconEdit size={18} />
77+
</IconButton>
78+
)}
79+
</Box>
6180
{isCredential && (
6281
<CreateCredentialDialog
6382
open={createDialogOpen}
@@ -66,6 +85,15 @@ function AsyncOptionsInput({ inputParam, value, disabled, onChange, nodeName, in
6685
onCreated={handleCreated}
6786
/>
6887
)}
88+
{isCredential && selectedCredentialId && (
89+
<CreateCredentialDialog
90+
open={editDialogOpen}
91+
credentialNames={inputParam.credentialNames!}
92+
onClose={() => setEditDialogOpen(false)}
93+
onCreated={handleEdited}
94+
editCredentialId={selectedCredentialId}
95+
/>
96+
)}
6997
</>
7098
)
7199
}
@@ -123,7 +151,7 @@ function AsyncOptionsDropdown({
123151
}}
124152
loading={loading}
125153
noOptionsText={loading ? 'Loading…' : 'No options available'}
126-
sx={{ mt: 1 }}
154+
sx={{ flexGrow: 1 }}
127155
renderOption={(props, option) => (
128156
<Box component='li' {...props} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
129157
{option.name === CREATE_NEW_SENTINEL ? (

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,15 @@ jest.mock('@/atoms/Dropdown', () => ({
5454

5555
const mockGetComponentCredentialSchema = jest.fn()
5656
const mockCreateCredential = jest.fn()
57+
const mockGetCredentialById = jest.fn()
58+
const mockUpdateCredential = jest.fn()
5759

5860
const mockApiContext = {
5961
credentialsApi: {
6062
getComponentCredentialSchema: mockGetComponentCredentialSchema,
61-
createCredential: mockCreateCredential
63+
createCredential: mockCreateCredential,
64+
getCredentialById: mockGetCredentialById,
65+
updateCredential: mockUpdateCredential
6266
},
6367
apiBaseUrl: 'http://localhost:3000'
6468
}

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

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,18 @@ export interface CreateCredentialDialogProps {
3030
credentialNames: string[]
3131
onClose: () => void
3232
onCreated: (credentialId: string) => void
33+
/** When set, the dialog opens in edit mode for the given credential ID. */
34+
editCredentialId?: string
3335
}
3436

3537
/**
36-
* Dialog for creating a new credential from within the node editor.
38+
* Dialog for creating or editing a credential from within the node editor.
3739
* Fetches the credential schema from the backend and renders a dynamic form.
3840
*/
39-
export function CreateCredentialDialog({ open, credentialNames, onClose, onCreated }: CreateCredentialDialogProps) {
41+
export function CreateCredentialDialog({ open, credentialNames, onClose, onCreated, editCredentialId }: CreateCredentialDialogProps) {
4042
const { credentialsApi, apiBaseUrl } = useApiContext()
4143
const theme = useTheme()
44+
const isEditMode = !!editCredentialId
4245

4346
const [schemas, setSchemas] = useState<ComponentCredentialSchema[]>([])
4447
const [selectedSchema, setSelectedSchema] = useState<ComponentCredentialSchema | null>(null)
@@ -65,7 +68,7 @@ export function CreateCredentialDialog({ open, credentialNames, onClose, onCreat
6568
setFormValues(defaults)
6669
}, [])
6770

68-
// Fetch credential schema(s) when dialog opens
71+
// Fetch credential schema(s) when dialog opens, and load existing data in edit mode
6972
useEffect(() => {
7073
if (!open) return
7174

@@ -95,6 +98,17 @@ export function CreateCredentialDialog({ open, credentialNames, onClose, onCreat
9598
setSchemas(results)
9699
}
97100
}
101+
102+
// In edit mode, fetch existing credential and populate the form
103+
if (editCredentialId && !cancelled) {
104+
const existing = await credentialsApi.getCredentialById(editCredentialId)
105+
if (!cancelled) {
106+
setCredentialName(existing.name)
107+
if (existing.plainDataObj) {
108+
setFormValues(existing.plainDataObj)
109+
}
110+
}
111+
}
98112
} catch (err) {
99113
if (!cancelled) {
100114
setError(err instanceof Error ? err.message : 'Failed to load credential schema')
@@ -111,7 +125,7 @@ export function CreateCredentialDialog({ open, credentialNames, onClose, onCreat
111125
return () => {
112126
cancelled = true
113127
}
114-
}, [open, credentialNamesKey, credentialsApi, selectSchema])
128+
}, [open, credentialNamesKey, credentialsApi, selectSchema, editCredentialId])
115129

116130
const handleFieldChange = useCallback((fieldName: string, value: unknown) => {
117131
setFormValues((prev) => ({ ...prev, [fieldName]: value }))
@@ -135,18 +149,21 @@ export function CreateCredentialDialog({ open, credentialNames, onClose, onCreat
135149
}
136150
}
137151

138-
const result = await credentialsApi.createCredential({
152+
const body = {
139153
name: credentialName.trim(),
140154
credentialName: selectedSchema.name,
141155
plainDataObj
142-
})
156+
}
157+
const result = isEditMode
158+
? await credentialsApi.updateCredential(editCredentialId!, body)
159+
: await credentialsApi.createCredential(body)
143160
onCreated(result.id)
144161
} catch (err) {
145-
setError(err instanceof Error ? err.message : 'Failed to create credential')
162+
setError(err instanceof Error ? err.message : isEditMode ? 'Failed to update credential' : 'Failed to create credential')
146163
} finally {
147164
setSubmitting(false)
148165
}
149-
}, [selectedSchema, credentialName, formValues, credentialsApi, onCreated])
166+
}, [selectedSchema, credentialName, formValues, credentialsApi, onCreated, isEditMode, editCredentialId])
150167

151168
const handleClose = useCallback(() => {
152169
if (!submitting) onClose()
@@ -224,13 +241,15 @@ export function CreateCredentialDialog({ open, credentialNames, onClose, onCreat
224241
display: 'flex',
225242
flexDirection: 'row',
226243
borderRadius: 10,
227-
background: theme.palette.warningBanner.background,
244+
background: theme.palette.warningBanner?.background ?? '#fefcbf',
228245
padding: 10,
229246
marginTop: 10,
230247
marginBottom: 10
231248
}}
232249
>
233-
<span style={{ color: theme.palette.warningBanner.text }}>{parser(selectedSchema.description)}</span>
250+
<span style={{ color: theme.palette.warningBanner?.text ?? '#744210' }}>
251+
{parser(selectedSchema.description)}
252+
</span>
234253
</div>
235254
</Box>
236255
)}
@@ -269,7 +288,7 @@ export function CreateCredentialDialog({ open, credentialNames, onClose, onCreat
269288
</Button>
270289
{selectedSchema && (
271290
<Button variant='contained' onClick={handleSubmit} disabled={!credentialName.trim() || submitting}>
272-
{submitting ? 'Adding...' : 'Add'}
291+
{submitting ? (isEditMode ? 'Saving...' : 'Adding...') : isEditMode ? 'Save' : 'Add'}
273292
</Button>
274293
)}
275294
</DialogActions>
@@ -323,14 +342,14 @@ function CredentialField({ input, value, onChange, disabled = false }: Credentia
323342
display: 'flex',
324343
flexDirection: 'row',
325344
borderRadius: 10,
326-
background: theme.palette.warningBanner.background,
345+
background: theme.palette.warningBanner?.background ?? '#fefcbf',
327346
padding: 10,
328347
marginTop: 10,
329348
marginBottom: 10
330349
}}
331350
>
332351
<IconAlertTriangle size={36} color={theme.palette.warning.main} />
333-
<span style={{ color: theme.palette.warningBanner.text, marginLeft: 10 }}>{input.warning}</span>
352+
<span style={{ color: theme.palette.warningBanner?.text ?? '#744210', marginLeft: 10 }}>{input.warning}</span>
334353
</div>
335354
)}
336355

packages/agentflow/src/infrastructure/api/credentials.test.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { bindCredentialsApi } from './credentials'
44

55
const mockClient = {
66
get: jest.fn(),
7-
post: jest.fn()
7+
post: jest.fn(),
8+
put: jest.fn()
89
} as unknown as jest.Mocked<AxiosInstance>
910

1011
beforeEach(() => {
@@ -50,4 +51,23 @@ describe('bindCredentialsApi', () => {
5051
expect(mockClient.post).toHaveBeenCalledWith('/credentials', body)
5152
expect(result).toEqual(mockCreated)
5253
})
54+
55+
it('getCredentialById calls GET /credentials/:id', async () => {
56+
const mockCredential = { id: 'cred-1', name: 'My Key', credentialName: 'openAIApi', plainDataObj: { apiKey: 'sk-test' } }
57+
;(mockClient.get as jest.Mock).mockResolvedValue({ data: mockCredential })
58+
59+
const result = await api.getCredentialById('cred-1')
60+
expect(mockClient.get).toHaveBeenCalledWith('/credentials/cred-1')
61+
expect(result).toEqual(mockCredential)
62+
})
63+
64+
it('updateCredential calls PUT /credentials/:id with body', async () => {
65+
const body = { name: 'Updated Key', credentialName: 'openAIApi', plainDataObj: { apiKey: 'sk-new' } }
66+
const mockUpdated = { id: 'cred-1', ...body }
67+
;(mockClient.put as jest.Mock).mockResolvedValue({ data: mockUpdated })
68+
69+
const result = await api.updateCredential('cred-1', body)
70+
expect(mockClient.put).toHaveBeenCalledWith('/credentials/cred-1', body)
71+
expect(result).toEqual(mockUpdated)
72+
})
5373
})

0 commit comments

Comments
 (0)