Skip to content

Commit 18b1c4f

Browse files
authored
fix(registry): fill default args into form (#617)
* fix: fill registry args into form * feat: handle default args with badge on input * feat: add onBlur behavior * test: add cmd args use cases * feat: handle paste from clipboard, splitting args * feat: handle cmd arg badge experience in custom run form * leftover * fix: reset form on dialog close * test: add test case for run command * fix: reset mcp registry form on dialog close
1 parent c68dd3b commit 18b1c4f

14 files changed

+476
-94
lines changed
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import React, { useState, useRef } from 'react'
2+
import { Button } from '@/common/components/ui/button'
3+
import { Badge } from '@/common/components/ui/badge'
4+
import { X } from 'lucide-react'
5+
import {
6+
FormField,
7+
FormItem,
8+
FormLabel,
9+
FormControl,
10+
FormMessage,
11+
FormDescription,
12+
} from '@/common/components/ui/form'
13+
import { cn } from '@/common/lib/utils'
14+
import type { Control, Path } from 'react-hook-form'
15+
import { Input } from '@/common/components/ui/input'
16+
import type { FormSchemaRunFromRegistry } from '@/features/registry-servers/lib/get-form-schema-run-from-registry'
17+
import type { FormSchemaRunMcpCommand } from '@/features/mcp-servers/lib/form-schema-run-mcp-server-with-command'
18+
19+
type CmdArguments =
20+
| FormSchemaRunFromRegistry['cmd_arguments']
21+
| FormSchemaRunMcpCommand['cmd_arguments']
22+
23+
interface CommandArgumentsFieldProps<
24+
T extends {
25+
cmd_arguments?: CmdArguments
26+
},
27+
> {
28+
getValues: (name: 'cmd_arguments') => string[] | undefined
29+
setValue: (name: 'cmd_arguments', value: string[]) => void
30+
cmd_arguments?: string[]
31+
control: Control<T>
32+
}
33+
34+
export function CommandArgumentsField<
35+
T extends {
36+
cmd_arguments?: CmdArguments
37+
},
38+
>({
39+
getValues,
40+
setValue,
41+
cmd_arguments = [],
42+
control,
43+
}: CommandArgumentsFieldProps<T>) {
44+
const [inputValue, setInputValue] = useState('')
45+
const inputRef = useRef<HTMLInputElement>(null)
46+
// console.log({ cmd_arguments }, getValues('cmd_arguments'))
47+
const addArgument = () => {
48+
if (inputValue.trim()) {
49+
const currentArgs = getValues('cmd_arguments') || []
50+
setValue('cmd_arguments', [...currentArgs, inputValue.trim()])
51+
setInputValue('')
52+
}
53+
}
54+
55+
const removeArgument = (index: number) => {
56+
const currentArgs = getValues('cmd_arguments') || []
57+
const newArgs = currentArgs.filter((_, i) => i !== index)
58+
setValue('cmd_arguments', newArgs)
59+
setTimeout(() => inputRef.current?.focus(), 0)
60+
}
61+
62+
const handleKeyDown = (e: React.KeyboardEvent) => {
63+
if (e.key === 'Enter' || e.key === ' ') {
64+
e.preventDefault()
65+
addArgument()
66+
}
67+
}
68+
69+
return (
70+
<FormField
71+
control={control}
72+
name={'cmd_arguments' as Path<T>}
73+
render={({ field }) => (
74+
<FormItem className="mb-10">
75+
<FormLabel htmlFor={`${field.name}-input`}>
76+
Command arguments
77+
</FormLabel>
78+
<FormDescription
79+
id={`${field.name}-description`}
80+
className="flex items-center gap-1"
81+
>
82+
Add individual arguments for the command
83+
</FormDescription>
84+
<FormControl>
85+
<div
86+
className={cn(
87+
`border-input flex min-h-9 w-full cursor-text flex-wrap
88+
items-center gap-1 rounded-md border bg-transparent px-3 py-1
89+
text-sm transition-[color,box-shadow]`,
90+
`aria-invalid:ring-destructive/20
91+
dark:aria-invalid:ring-destructive/40
92+
aria-invalid:border-destructive`
93+
)}
94+
onClick={() => {
95+
inputRef.current?.focus()
96+
}}
97+
>
98+
{field.value &&
99+
Array.isArray(field.value) &&
100+
field.value.length > 0 && (
101+
<>
102+
{field.value.map((arg: string, index: number) => (
103+
<Badge
104+
key={index}
105+
variant="secondary"
106+
className={cn(
107+
'flex h-6 items-center gap-1 px-2 font-mono text-xs',
108+
cmd_arguments?.includes(arg) &&
109+
'cursor-not-allowed opacity-40'
110+
)}
111+
>
112+
{arg}
113+
<Button
114+
type="button"
115+
variant="ghost"
116+
size="icon"
117+
disabled={cmd_arguments?.includes(arg)}
118+
className="hover:text-muted-foreground/80 ml-1 size-3
119+
p-1 hover:cursor-pointer disabled:cursor-not-allowed
120+
disabled:opacity-40"
121+
onClick={(e) => {
122+
e.stopPropagation()
123+
removeArgument(index)
124+
}}
125+
aria-label={`Remove argument ${arg}`}
126+
>
127+
<X className="size-3" />
128+
</Button>
129+
</Badge>
130+
))}
131+
</>
132+
)}
133+
134+
<Input
135+
ref={inputRef}
136+
id={`${field.name}-input`}
137+
value={inputValue}
138+
onChange={(e) => setInputValue(e.target.value)}
139+
onKeyDown={handleKeyDown}
140+
onBlur={() => addArgument()}
141+
onPaste={(e) => {
142+
e.preventDefault()
143+
144+
const pastedText = e.clipboardData.getData('text').trim()
145+
146+
if (!pastedText) return
147+
148+
const newArguments = pastedText.split(/\s+/)
149+
150+
if (newArguments.length > 0) {
151+
const currentArgs = getValues('cmd_arguments') || []
152+
setValue('cmd_arguments', [...currentArgs, ...newArguments])
153+
}
154+
}}
155+
placeholder={
156+
field.value &&
157+
Array.isArray(field.value) &&
158+
field.value.length > 0
159+
? 'Add argument...'
160+
: 'e.g. --debug, --port, 8080'
161+
}
162+
className="placeholder:text-muted-foreground min-w-[120px]
163+
flex-1 border-0 bg-transparent text-sm shadow-none
164+
outline-none focus:ring-0 focus-visible:ring-0"
165+
autoComplete="off"
166+
/>
167+
</div>
168+
</FormControl>
169+
<FormMessage />
170+
</FormItem>
171+
)}
172+
/>
173+
)
174+
}

renderer/src/features/mcp-servers/components/__tests__/dialog-form-run-mcp-command.test.tsx

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ describe('DialogFormRunMcpServerWithCommand', () => {
192192
protocol: 'npx',
193193
package_name: '@test/package',
194194
type: 'package_manager',
195-
cmd_arguments: '--debug',
195+
cmd_arguments: ['--debug'],
196196
}),
197197
},
198198
expect.any(Object)
@@ -451,4 +451,64 @@ describe('DialogFormRunMcpServerWithCommand', () => {
451451

452452
expect(mockOnOpenChange).toHaveBeenCalledWith(false)
453453
})
454+
455+
it('paste arg from clipboard into command arguments field', async () => {
456+
const mockInstallServerMutation = vi.fn()
457+
mockUseRunCustomServer.mockReturnValue({
458+
installServerMutation: mockInstallServerMutation,
459+
checkServerStatus: vi.fn(),
460+
isErrorSecrets: false,
461+
isPendingSecrets: false,
462+
})
463+
464+
render(
465+
<QueryClientProvider client={queryClient}>
466+
<Dialog open>
467+
<DialogFormRunMcpServerWithCommand isOpen onOpenChange={vi.fn()} />
468+
</Dialog>
469+
</QueryClientProvider>
470+
)
471+
472+
await waitFor(() => {
473+
expect(screen.getByRole('dialog')).toBeVisible()
474+
})
475+
476+
await userEvent.type(screen.getByLabelText('Name'), 'npm-server')
477+
await userEvent.click(screen.getByLabelText('Transport'))
478+
await userEvent.click(screen.getByRole('option', { name: 'stdio' }))
479+
await userEvent.type(
480+
screen.getByLabelText('Docker image'),
481+
'ghcr.io/test/server'
482+
)
483+
484+
const commandArgsInput = screen.getByLabelText('Command arguments')
485+
await userEvent.click(commandArgsInput)
486+
487+
await userEvent.paste('--toolsets repos,issues,pull_requests --read-only')
488+
489+
expect(screen.getByText('--toolsets')).toBeVisible()
490+
expect(screen.getByText('repos,issues,pull_requests')).toBeVisible()
491+
expect(screen.getByText('--read-only')).toBeVisible()
492+
493+
expect(commandArgsInput).toHaveValue('')
494+
expect(screen.getByText('repos,issues,pull_requests')).toBeVisible()
495+
await userEvent.click(
496+
screen.getByRole('button', { name: 'Install server' })
497+
)
498+
499+
await waitFor(() => {
500+
expect(mockInstallServerMutation).toHaveBeenCalledWith(
501+
expect.objectContaining({
502+
data: expect.objectContaining({
503+
cmd_arguments: [
504+
'--toolsets',
505+
'repos,issues,pull_requests',
506+
'--read-only',
507+
],
508+
}),
509+
}),
510+
expect.any(Object)
511+
)
512+
})
513+
})
454514
})

renderer/src/features/mcp-servers/components/dialog-form-run-mcp-command.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ export function DialogFormRunMcpServerWithCommand({
102102
<Dialog open={isOpen} onOpenChange={onOpenChange}>
103103
<DialogContent
104104
className="p-0 sm:max-w-2xl"
105+
onCloseAutoFocus={() => form.reset()}
105106
onInteractOutside={(e) => {
106107
// Prevent closing the dialog when clicking outside
107108
e.preventDefault()

renderer/src/features/mcp-servers/components/form-fields-run-mcp-command.tsx

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
} from '@/common/components/ui/select'
1919
import { TooltipInfoIcon } from '@/common/components/ui/tooltip-info-icon'
2020
import { Tabs, TabsList, TabsTrigger } from '@/common/components/ui/tabs'
21+
import { CommandArgumentsField } from '@/common/components/workload-cmd-arg/command-arguments-field'
2122

2223
export function FormFieldsRunMcpCommand({
2324
form,
@@ -238,28 +239,10 @@ export function FormFieldsRunMcpCommand({
238239
/>
239240
) : null}
240241

241-
<FormField
242+
<CommandArgumentsField
243+
getValues={(name) => form.getValues(name)}
244+
setValue={(name, value) => form.setValue(name, value)}
242245
control={form.control}
243-
name="cmd_arguments"
244-
render={({ field }) => (
245-
<FormItem>
246-
<div className="flex items-center gap-1">
247-
<FormLabel>Command arguments</FormLabel>
248-
<TooltipInfoIcon>
249-
Space separated arguments for the command.
250-
</TooltipInfoIcon>
251-
</div>
252-
<FormControl>
253-
<Input
254-
placeholder="e.g. -y --oauth-setup"
255-
defaultValue={field.value}
256-
onChange={(e) => field.onChange(e.target.value)}
257-
name={field.name}
258-
/>
259-
</FormControl>
260-
<FormMessage />
261-
</FormItem>
262-
)}
263246
/>
264247
</>
265248
)

0 commit comments

Comments
 (0)