From 27a9121c2892105c49fb4f7f675d2b9f7de772b0 Mon Sep 17 00:00:00 2001 From: Lily Delalande <119957291+lily-de@users.noreply.github.com> Date: Mon, 17 Mar 2025 09:11:42 -0700 Subject: [PATCH] ui: new extensions modal (#1711) --- ui/desktop/src/components/Modal.tsx | 56 +++- .../extensions/ExtensionsSection.tsx | 104 ++----- .../extensions/modal/EnvVarsSection.tsx | 83 ++++-- .../modal/ExtensionConfigFields.tsx | 62 +++-- .../extensions/modal/ExtensionModal.tsx | 253 +++++++++++++----- .../subcomponents/ExtensionItem.tsx | 11 +- .../subcomponents/ExtensionList.tsx | 4 +- .../settings_v2/extensions/utils.ts | 97 +++++++ .../modal/ProviderConfiguationModal.tsx | 2 +- 9 files changed, 462 insertions(+), 210 deletions(-) create mode 100644 ui/desktop/src/components/settings_v2/extensions/utils.ts diff --git a/ui/desktop/src/components/Modal.tsx b/ui/desktop/src/components/Modal.tsx index 2f9cfb51..0a6a2374 100644 --- a/ui/desktop/src/components/Modal.tsx +++ b/ui/desktop/src/components/Modal.tsx @@ -1,18 +1,64 @@ -import React from 'react'; +import React, { useEffect, useRef } from 'react'; import { Card } from './ui/card'; interface ModalProps { children: React.ReactNode; + footer?: React.ReactNode; // Optional footer + onClose: () => void; // Function to call when modal should close + preventBackdropClose?: boolean; // Optional prop to prevent closing on backdrop click } /** * A reusable modal component that renders content with a semi-transparent backdrop and blur effect. + * Closes when clicking outside the modal or pressing Esc key. */ -export default function Modal({ children }: ModalProps) { +export default function Modal({ + children, + footer, + onClose, + preventBackdropClose = false, +}: ModalProps) { + const modalRef = useRef(null); + + // Handle click outside the modal content + const handleBackdropClick = (e: React.MouseEvent) => { + if (preventBackdropClose) return; + // Check if the click was on the backdrop and not on the modal content + if (modalRef.current && !modalRef.current.contains(e.target as Node)) { + onClose(); + } + }; + + // Handle Esc key press + useEffect(() => { + const handleEscKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }; + + // Add event listener + document.addEventListener('keydown', handleEscKey); + + // Clean up + return () => { + document.removeEventListener('keydown', handleEscKey); + }; + }, [onClose]); + return ( -
- -
{children}
+
+ +
{children}
+ {footer && ( +
{footer}
+ )}
); diff --git a/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx b/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx index 45ea2be1..2dd75419 100644 --- a/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx +++ b/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx @@ -3,12 +3,17 @@ import { Button } from '../../ui/button'; import { Plus } from 'lucide-react'; import { GPSIcon } from '../../ui/icons'; import { useConfig, FixedExtensionEntry } from '../../ConfigContext'; -import { ExtensionConfig } from '../../../api/types.gen'; import ExtensionList from './subcomponents/ExtensionList'; import ExtensionModal from './modal/ExtensionModal'; +import { + createExtensionConfig, + ExtensionFormData, + extensionToFormData, + getDefaultFormData, +} from './utils'; export default function ExtensionsSection() { - const { toggleExtension, getExtensions, addExtension } = useConfig(); + const { toggleExtension, getExtensions, addExtension, removeExtension } = useConfig(); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [extensions, setExtensions] = useState([]); @@ -74,6 +79,16 @@ export default function ExtensionsSection() { } }; + const handleDeleteExtension = async (name: string) => { + try { + await removeExtension(name); + handleModalClose(); + fetchExtensions(); // Refresh the list after deleting + } catch (error) { + console.error('Failed to delete extension:', error); + } + }; + const handleModalClose = () => { setIsModalOpen(false); setIsAddModalOpen(false); @@ -122,7 +137,9 @@ export default function ExtensionsSection() { initialData={extensionToFormData(selectedExtension)} onClose={handleModalClose} onSubmit={handleUpdateExtension} + onDelete={handleDeleteExtension} submitLabel="Save Changes" + modalType={'edit'} /> )} @@ -134,90 +151,9 @@ export default function ExtensionsSection() { onClose={handleModalClose} onSubmit={handleAddExtension} submitLabel="Add Extension" + modalType={'add'} /> )} ); } - -// Helper functions - -export interface ExtensionFormData { - name: string; - type: 'stdio' | 'sse' | 'builtin'; - cmd?: string; - args?: string[]; - endpoint?: string; - enabled: boolean; - envVars: { key: string; value: string }[]; -} - -function getDefaultFormData(): ExtensionFormData { - return { - name: '', - type: 'stdio', - cmd: '', - args: [], - endpoint: '', - enabled: true, - envVars: [], - }; -} - -function extensionToFormData(extension: FixedExtensionEntry): ExtensionFormData { - // Type guard: Check if 'envs' property exists for this variant - const hasEnvs = extension.type === 'sse' || extension.type === 'stdio'; - - const envVars = - hasEnvs && extension.envs - ? Object.entries(extension.envs).map(([key, value]) => ({ - key, - value: value as string, - })) - : []; - - return { - name: extension.name, - type: extension.type, - cmd: extension.type === 'stdio' ? extension.cmd : undefined, - args: extension.type === 'stdio' ? extension.args : [], - endpoint: extension.type === 'sse' ? extension.uri : undefined, - enabled: extension.enabled, - envVars, - }; -} - -function createExtensionConfig(formData: ExtensionFormData): ExtensionConfig { - const envs = formData.envVars.reduce( - (acc, { key, value }) => { - if (key) { - acc[key] = value; - } - return acc; - }, - {} as Record - ); - - if (formData.type === 'stdio') { - return { - type: 'stdio', - name: formData.name, - cmd: formData.cmd, - args: formData.args, - ...(Object.keys(envs).length > 0 ? { envs } : {}), - }; - } else if (formData.type === 'sse') { - return { - type: 'sse', - name: formData.name, - uri: formData.endpoint, // Assuming endpoint maps to uri for SSE type - ...(Object.keys(envs).length > 0 ? { envs } : {}), - }; - } else { - // For other types - return { - type: formData.type, - name: formData.name, - }; - } -} diff --git a/ui/desktop/src/components/settings_v2/extensions/modal/EnvVarsSection.tsx b/ui/desktop/src/components/settings_v2/extensions/modal/EnvVarsSection.tsx index 7e064b66..d79cb3a6 100644 --- a/ui/desktop/src/components/settings_v2/extensions/modal/EnvVarsSection.tsx +++ b/ui/desktop/src/components/settings_v2/extensions/modal/EnvVarsSection.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Button } from '../../../ui/button'; -import { X } from 'lucide-react'; +import { Plus, X } from 'lucide-react'; import { Input } from '../../../ui/input'; interface EnvVarsSectionProps { @@ -8,6 +8,8 @@ interface EnvVarsSectionProps { onAdd: () => void; onRemove: (index: number) => void; onChange: (index: number, field: 'key' | 'value', value: string) => void; + submitAttempted: boolean; + isValid: boolean; } export default function EnvVarsSection({ @@ -15,40 +17,73 @@ export default function EnvVarsSection({ onAdd, onRemove, onChange, + submitAttempted, + isValid, }: EnvVarsSectionProps) { return (
-
- - +
+ {' '} + {/* Added relative positioning with minimal margin */} + + {submitAttempted && !isValid && ( +
+ {' '} + {/* Removed absolute positioning */} + Environment variables must consist of sets of variable names and values +
+ )}
- -
+
+ {/* Existing environment variables */} {envVars.map((envVar, index) => ( -
- onChange(index, 'key', e.target.value)} - placeholder="Key" - className="flex-1" - /> - onChange(index, 'value', e.target.value)} - placeholder="Value" - className="flex-1" - /> + +
+ onChange(index, 'key', e.target.value)} + placeholder="Variable name" + className={`w-full bg-bgSubtle border-borderSubtle text-textStandard`} + /> +
+
+ onChange(index, 'value', e.target.value)} + placeholder="Value" + className={`w-full bg-bgSubtle border-borderSubtle text-textStandard`} + /> +
-
+ ))} + + {/* Empty row with Add button */} + + +
); diff --git a/ui/desktop/src/components/settings_v2/extensions/modal/ExtensionConfigFields.tsx b/ui/desktop/src/components/settings_v2/extensions/modal/ExtensionConfigFields.tsx index 2837c1cd..4f9bd7f4 100644 --- a/ui/desktop/src/components/settings_v2/extensions/modal/ExtensionConfigFields.tsx +++ b/ui/desktop/src/components/settings_v2/extensions/modal/ExtensionConfigFields.tsx @@ -3,57 +3,55 @@ import React from 'react'; interface ExtensionConfigFieldsProps { type: 'stdio' | 'sse' | 'builtin'; - cmd: string; - args: string; + full_cmd: string; endpoint: string; onChange: (key: string, value: any) => void; + submitAttempted?: boolean; + isValid?: boolean; } export default function ExtensionConfigFields({ type, - cmd, - args, + full_cmd, endpoint, onChange, + submitAttempted = false, + isValid, }: ExtensionConfigFieldsProps) { if (type === 'stdio') { return (
- - onChange('cmd', e.target.value)} - placeholder="Enter command..." - className="w-full" - /> -
-
- - - onChange( - 'args', - e.target.value.split(' ').filter((arg) => arg.length > 0) - ) - } - placeholder="Enter arguments..." - className="w-full" - /> + +
+ onChange('cmd', e.target.value)} + placeholder="e.g. npx -y @modelcontextprotocol/my-extension " + className={`w-full ${!submitAttempted || isValid ? 'border-borderSubtle' : 'border-red-500'} text-textStandard`} + /> + {submitAttempted && !isValid && ( +
Command is required
+ )} +
); } else { return (
- - onChange('endpoint', e.target.value)} - placeholder="Enter endpoint URL..." - className="w-full" - /> + +
+ onChange('endpoint', e.target.value)} + placeholder="Enter endpoint URL..." + className={`w-full ${!submitAttempted || isValid ? 'border-borderSubtle' : 'border-red-500'} text-textStandard`} + /> + {submitAttempted && !isValid && ( +
Endpoint URL is required
+ )} +
); } diff --git a/ui/desktop/src/components/settings_v2/extensions/modal/ExtensionModal.tsx b/ui/desktop/src/components/settings_v2/extensions/modal/ExtensionModal.tsx index f3614ec2..3e853de5 100644 --- a/ui/desktop/src/components/settings_v2/extensions/modal/ExtensionModal.tsx +++ b/ui/desktop/src/components/settings_v2/extensions/modal/ExtensionModal.tsx @@ -1,20 +1,22 @@ -// ExtensionModal.tsx import React, { useState } from 'react'; import { Button } from '../../../ui/button'; import Modal from '../../../Modal'; import { Input } from '../../../ui/input'; import Select from 'react-select'; import { createDarkSelectStyles, darkSelectTheme } from '../../../ui/select-styles'; -import { ExtensionFormData } from '../ExtensionsSection'; +import { ExtensionFormData } from '../utils'; import EnvVarsSection from './EnvVarsSection'; import ExtensionConfigFields from './ExtensionConfigFields'; +import { PlusIcon, Edit, Trash2, AlertTriangle } from 'lucide-react'; interface ExtensionModalProps { title: string; initialData: ExtensionFormData; onClose: () => void; onSubmit: (formData: ExtensionFormData) => void; + onDelete?: (name: string) => void; submitLabel: string; + modalType: 'add' | 'edit'; } export default function ExtensionModal({ @@ -22,9 +24,13 @@ export default function ExtensionModal({ initialData, onClose, onSubmit, + onDelete, submitLabel, + modalType, }: ExtensionModalProps) { const [formData, setFormData] = useState(initialData); + const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false); + const [submitAttempted, setSubmitAttempted] = useState(false); const handleAddEnvVar = () => { setFormData({ @@ -51,69 +57,192 @@ export default function ExtensionModal({ }); }; + // Function to determine which icon to display with proper styling + const getModalIcon = () => { + if (showDeleteConfirmation) { + return ; + } + return modalType === 'add' ? ( + + ) : ( + + ); + }; + + const isNameValid = () => { + return formData.name.trim() !== ''; + }; + + const isConfigValid = () => { + return ( + (formData.type === 'stdio' && formData.cmd && formData.cmd.trim() !== '') || + (formData.type === 'sse' && formData.endpoint && formData.endpoint.trim() !== '') + ); + }; + + const isEnvVarsValid = () => { + return formData.envVars.every( + ({ key, value }) => (key === '' && value === '') || (key !== '' && value !== '') + ); + }; + + // Form validation + const isFormValid = () => { + return isNameValid() && isConfigValid() && isEnvVarsValid(); + }; + + // Handle submit with validation + const handleSubmit = () => { + setSubmitAttempted(true); + + if (isFormValid()) { + onSubmit(formData); + } + }; + + // Create footer buttons based on current state + const footerContent = showDeleteConfirmation ? ( + // Delete confirmation footer + <> +
+

+ Are you sure you want to delete "{formData.name}"? This action cannot be undone. +

+
+ + + + ) : ( + // Normal footer + <> + {modalType === 'edit' && onDelete && ( + + )} + + + + ); + + // Update title based on current state + const modalTitle = showDeleteConfirmation ? `Delete Extension "${formData.name}"` : title; + return ( - -
-

{title}

- -
-
- - setFormData({ ...formData, name: e.target.value })} - placeholder="Enter extension name..." - /> -
-
- - setFormData({ ...formData, name: e.target.value })} + placeholder="Enter extension name..." + className={`${!submitAttempted || formData.name.trim() !== '' ? 'border-borderSubtle' : 'border-red-500'} text-textStandard focus:border-borderStandard`} + /> + {submitAttempted && !isNameValid() && ( +
Name is required
+ )} +
+
+ {/*Type Dropdown */} +
+ +