feat(desktop): add i18n infrastructure with react-intl (#8105)
This commit is contained in:
@@ -3,6 +3,7 @@ import { Button } from '../../ui/button';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { GPSIcon } from '../../ui/icons';
|
||||
import { useConfig, FixedExtensionEntry } from '../../ConfigContext';
|
||||
import { defineMessages, useIntl } from '../../../i18n';
|
||||
import ExtensionList from './subcomponents/ExtensionList';
|
||||
import ExtensionModal from './modal/ExtensionModal';
|
||||
import {
|
||||
@@ -15,6 +16,29 @@ import {
|
||||
import { activateExtensionDefault, deleteExtension, toggleExtensionDefault } from './index';
|
||||
import { ExtensionConfig } from '../../../api/types.gen';
|
||||
|
||||
const i18n = defineMessages({
|
||||
addCustomExtension: {
|
||||
id: 'extensionsSection.addCustomExtension',
|
||||
defaultMessage: 'Add custom extension',
|
||||
},
|
||||
browseExtensions: {
|
||||
id: 'extensionsSection.browseExtensions',
|
||||
defaultMessage: 'Browse extensions',
|
||||
},
|
||||
updateExtension: {
|
||||
id: 'extensionsSection.updateExtension',
|
||||
defaultMessage: 'Update Extension',
|
||||
},
|
||||
saveChanges: {
|
||||
id: 'extensionsSection.saveChanges',
|
||||
defaultMessage: 'Save Changes',
|
||||
},
|
||||
addExtension: {
|
||||
id: 'extensionsSection.addExtension',
|
||||
defaultMessage: 'Add Extension',
|
||||
},
|
||||
});
|
||||
|
||||
interface ExtensionSectionProps {
|
||||
deepLinkConfig?: ExtensionConfig;
|
||||
showEnvVars?: boolean;
|
||||
@@ -36,6 +60,7 @@ export default function ExtensionsSection({
|
||||
onModalClose,
|
||||
searchTerm = '',
|
||||
}: ExtensionSectionProps) {
|
||||
const intl = useIntl();
|
||||
const { getExtensions, addExtension, removeExtension, extensionsList } = useConfig();
|
||||
const [selectedExtension, setSelectedExtension] = useState<FixedExtensionEntry | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
@@ -198,7 +223,7 @@ export default function ExtensionsSection({
|
||||
onClick={() => setIsAddModalOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add custom extension
|
||||
{intl.formatMessage(i18n.addCustomExtension)}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center gap-2 justify-center"
|
||||
@@ -206,7 +231,7 @@ export default function ExtensionsSection({
|
||||
onClick={() => window.open('https://block.github.io/goose/v1/extensions/', '_blank')}
|
||||
>
|
||||
<GPSIcon size={12} />
|
||||
Browse extensions
|
||||
{intl.formatMessage(i18n.browseExtensions)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -214,12 +239,12 @@ export default function ExtensionsSection({
|
||||
{/* Modal for updating an existing extension */}
|
||||
{isModalOpen && selectedExtension && (
|
||||
<ExtensionModal
|
||||
title="Update Extension"
|
||||
title={intl.formatMessage(i18n.updateExtension)}
|
||||
initialData={extensionToFormData(selectedExtension)}
|
||||
onClose={handleModalClose}
|
||||
onSubmit={handleUpdateExtension}
|
||||
onDelete={handleDeleteExtension}
|
||||
submitLabel="Save Changes"
|
||||
submitLabel={intl.formatMessage(i18n.saveChanges)}
|
||||
modalType={'edit'}
|
||||
/>
|
||||
)}
|
||||
@@ -227,11 +252,11 @@ export default function ExtensionsSection({
|
||||
{/* Modal for adding a new extension */}
|
||||
{isAddModalOpen && (
|
||||
<ExtensionModal
|
||||
title="Add custom extension"
|
||||
title={intl.formatMessage(i18n.addCustomExtension)}
|
||||
initialData={getDefaultFormData()}
|
||||
onClose={handleModalClose}
|
||||
onSubmit={handleAddExtension}
|
||||
submitLabel="Add Extension"
|
||||
submitLabel={intl.formatMessage(i18n.addExtension)}
|
||||
modalType={'add'}
|
||||
/>
|
||||
)}
|
||||
@@ -239,14 +264,14 @@ export default function ExtensionsSection({
|
||||
{/* Modal for adding extension from deeplink*/}
|
||||
{deepLinkConfigStateVar && showEnvVarsStateVar && (
|
||||
<ExtensionModal
|
||||
title="Add custom extension"
|
||||
title={intl.formatMessage(i18n.addCustomExtension)}
|
||||
initialData={extensionToFormData({
|
||||
...deepLinkConfig,
|
||||
enabled: true,
|
||||
} as FixedExtensionEntry)}
|
||||
onClose={handleModalClose}
|
||||
onSubmit={handleAddExtension}
|
||||
submitLabel="Add Extension"
|
||||
submitLabel={intl.formatMessage(i18n.addExtension)}
|
||||
modalType={'add'}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,38 @@ import { Button } from '../../../ui/button';
|
||||
import { Plus, X, Edit } from 'lucide-react';
|
||||
import { Input } from '../../../ui/input';
|
||||
import { cn } from '../../../../utils';
|
||||
import { defineMessages, useIntl } from '../../../../i18n';
|
||||
|
||||
const i18n = defineMessages({
|
||||
environmentVariables: {
|
||||
id: 'envVarsSection.environmentVariables',
|
||||
defaultMessage: 'Environment Variables',
|
||||
},
|
||||
envVarsDescription: {
|
||||
id: 'envVarsSection.envVarsDescription',
|
||||
defaultMessage: 'Add key-value pairs for environment variables. Click the "+" button to add after filling both fields. For existing secret values, click the edit button to modify.',
|
||||
},
|
||||
variableName: {
|
||||
id: 'envVarsSection.variableName',
|
||||
defaultMessage: 'Variable name',
|
||||
},
|
||||
value: {
|
||||
id: 'envVarsSection.value',
|
||||
defaultMessage: 'Value',
|
||||
},
|
||||
bothRequired: {
|
||||
id: 'envVarsSection.bothRequired',
|
||||
defaultMessage: 'Both variable name and value must be entered',
|
||||
},
|
||||
noSpaces: {
|
||||
id: 'envVarsSection.noSpaces',
|
||||
defaultMessage: 'Variable name cannot contain spaces',
|
||||
},
|
||||
add: {
|
||||
id: 'envVarsSection.add',
|
||||
defaultMessage: 'Add',
|
||||
},
|
||||
});
|
||||
|
||||
interface EnvVarsSectionProps {
|
||||
envVars: { key: string; value: string; isEdited?: boolean }[];
|
||||
@@ -21,6 +53,7 @@ export default function EnvVarsSection({
|
||||
submitAttempted,
|
||||
onPendingInputChange,
|
||||
}: EnvVarsSectionProps) {
|
||||
const intl = useIntl();
|
||||
const [newKey, setNewKey] = React.useState('');
|
||||
const [newValue, setNewValue] = React.useState('');
|
||||
const [validationError, setValidationError] = React.useState<string | null>(null);
|
||||
@@ -45,7 +78,7 @@ export default function EnvVarsSection({
|
||||
key: keyEmpty,
|
||||
value: valueEmpty,
|
||||
});
|
||||
setValidationError('Both variable name and value must be entered');
|
||||
setValidationError(intl.formatMessage(i18n.bothRequired));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -54,7 +87,7 @@ export default function EnvVarsSection({
|
||||
key: true,
|
||||
value: false,
|
||||
});
|
||||
setValidationError('Variable name cannot contain spaces');
|
||||
setValidationError(intl.formatMessage(i18n.noSpaces));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -95,11 +128,10 @@ export default function EnvVarsSection({
|
||||
<div>
|
||||
<div className="relative mb-2">
|
||||
<label className="text-sm font-medium text-text-primary mb-2 block">
|
||||
Environment Variables
|
||||
{intl.formatMessage(i18n.environmentVariables)}
|
||||
</label>
|
||||
<p className="text-xs text-text-secondary mb-4">
|
||||
Add key-value pairs for environment variables. Click the "+" button to add after filling
|
||||
both fields. For existing secret values, click the edit button to modify.
|
||||
{intl.formatMessage(i18n.envVarsDescription)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_1fr_auto_auto] gap-2 items-center">
|
||||
@@ -110,7 +142,7 @@ export default function EnvVarsSection({
|
||||
<Input
|
||||
value={envVar.key}
|
||||
onChange={(e) => onChange(index, 'key', e.target.value)}
|
||||
placeholder="Variable name"
|
||||
placeholder={intl.formatMessage(i18n.variableName)}
|
||||
className={cn(
|
||||
'w-full text-text-primary border-border-primary hover:border-border-primary',
|
||||
isFieldInvalid(index, 'key') && 'border-red-500 focus:border-red-500'
|
||||
@@ -127,7 +159,7 @@ export default function EnvVarsSection({
|
||||
envVar.value === '••••••••' && !envVar.isEdited ? '' : e.target.value;
|
||||
onChange(index, 'value', newValue);
|
||||
}}
|
||||
placeholder="Value"
|
||||
placeholder={intl.formatMessage(i18n.value)}
|
||||
className={cn(
|
||||
'w-full border-border-primary',
|
||||
envVar.value === '••••••••' && !envVar.isEdited
|
||||
@@ -166,7 +198,7 @@ export default function EnvVarsSection({
|
||||
setNewKey(e.target.value);
|
||||
clearValidation();
|
||||
}}
|
||||
placeholder="Variable name"
|
||||
placeholder={intl.formatMessage(i18n.variableName)}
|
||||
className={cn(
|
||||
'w-full text-text-primary border-border-primary hover:border-border-primary',
|
||||
invalidFields.key && 'border-red-500 focus:border-red-500'
|
||||
@@ -178,7 +210,7 @@ export default function EnvVarsSection({
|
||||
setNewValue(e.target.value);
|
||||
clearValidation();
|
||||
}}
|
||||
placeholder="Value"
|
||||
placeholder={intl.formatMessage(i18n.value)}
|
||||
className={cn(
|
||||
'w-full text-text-primary border-border-primary hover:border-border-primary',
|
||||
invalidFields.value && 'border-red-500 focus:border-red-500'
|
||||
@@ -190,7 +222,7 @@ export default function EnvVarsSection({
|
||||
variant="ghost"
|
||||
className="flex items-center justify-start gap-1 px-2 pr-4 text-sm rounded-full text-text-primary bg-background-primary border border-border-primary hover:border-border-primary transition-colors min-w-[60px] h-9 [&>svg]:!size-4"
|
||||
>
|
||||
<Plus /> Add
|
||||
<Plus /> {intl.formatMessage(i18n.add)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,32 @@
|
||||
import { Input } from '../../../ui/input';
|
||||
import { defineMessages, useIntl } from '../../../../i18n';
|
||||
|
||||
const i18n = defineMessages({
|
||||
commandLabel: {
|
||||
id: 'extensionConfigFields.commandLabel',
|
||||
defaultMessage: 'Command',
|
||||
},
|
||||
commandPlaceholder: {
|
||||
id: 'extensionConfigFields.commandPlaceholder',
|
||||
defaultMessage: 'e.g. npx -y @modelcontextprotocol/my-extension [filepath]',
|
||||
},
|
||||
commandRequired: {
|
||||
id: 'extensionConfigFields.commandRequired',
|
||||
defaultMessage: 'Command is required',
|
||||
},
|
||||
endpointLabel: {
|
||||
id: 'extensionConfigFields.endpointLabel',
|
||||
defaultMessage: 'Endpoint',
|
||||
},
|
||||
endpointPlaceholder: {
|
||||
id: 'extensionConfigFields.endpointPlaceholder',
|
||||
defaultMessage: 'Enter endpoint URL...',
|
||||
},
|
||||
endpointRequired: {
|
||||
id: 'extensionConfigFields.endpointRequired',
|
||||
defaultMessage: 'Endpoint URL is required',
|
||||
},
|
||||
});
|
||||
|
||||
interface ExtensionConfigFieldsProps {
|
||||
type: 'stdio' | 'sse' | 'streamable_http' | 'builtin';
|
||||
@@ -17,20 +45,22 @@ export default function ExtensionConfigFields({
|
||||
submitAttempted = false,
|
||||
isValid,
|
||||
}: ExtensionConfigFieldsProps) {
|
||||
const intl = useIntl();
|
||||
|
||||
if (type === 'stdio') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block text-text-primary">Command</label>
|
||||
<label className="text-sm font-medium mb-2 block text-text-primary">{intl.formatMessage(i18n.commandLabel)}</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={full_cmd}
|
||||
onChange={(e) => onChange('cmd', e.target.value)}
|
||||
placeholder="e.g. npx -y @modelcontextprotocol/my-extension <filepath>"
|
||||
placeholder={intl.formatMessage(i18n.commandPlaceholder)}
|
||||
className={`w-full ${!submitAttempted || isValid ? 'border-border-primary' : 'border-red-500'} text-text-primary`}
|
||||
/>
|
||||
{submitAttempted && !isValid && (
|
||||
<div className="absolute text-xs text-red-500 mt-1">Command is required</div>
|
||||
<div className="absolute text-xs text-red-500 mt-1">{intl.formatMessage(i18n.commandRequired)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -39,16 +69,16 @@ export default function ExtensionConfigFields({
|
||||
} else {
|
||||
return (
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block text-text-primary">Endpoint</label>
|
||||
<label className="text-sm font-medium mb-2 block text-text-primary">{intl.formatMessage(i18n.endpointLabel)}</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={endpoint}
|
||||
onChange={(e) => onChange('endpoint', e.target.value)}
|
||||
placeholder="Enter endpoint URL..."
|
||||
placeholder={intl.formatMessage(i18n.endpointPlaceholder)}
|
||||
className={`w-full ${!submitAttempted || isValid ? 'border-border-primary' : 'border-red-500'} text-text-primary`}
|
||||
/>
|
||||
{submitAttempted && !isValid && (
|
||||
<div className="absolute text-xs text-red-500 mt-1">Endpoint URL is required</div>
|
||||
<div className="absolute text-xs text-red-500 mt-1">{intl.formatMessage(i18n.endpointRequired)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,53 @@
|
||||
import { Input } from '../../../ui/input';
|
||||
import { Select } from '../../../ui/Select';
|
||||
import { defineMessages, useIntl } from '../../../../i18n';
|
||||
|
||||
const i18n = defineMessages({
|
||||
extensionName: {
|
||||
id: 'extensionInfoFields.extensionName',
|
||||
defaultMessage: 'Extension Name',
|
||||
},
|
||||
extensionNamePlaceholder: {
|
||||
id: 'extensionInfoFields.extensionNamePlaceholder',
|
||||
defaultMessage: 'Enter extension name...',
|
||||
},
|
||||
nameRequired: {
|
||||
id: 'extensionInfoFields.nameRequired',
|
||||
defaultMessage: 'Name is required',
|
||||
},
|
||||
typeLabel: {
|
||||
id: 'extensionInfoFields.typeLabel',
|
||||
defaultMessage: 'Type',
|
||||
},
|
||||
typeStdio: {
|
||||
id: 'extensionInfoFields.typeStdio',
|
||||
defaultMessage: 'STDIO',
|
||||
},
|
||||
typeHttp: {
|
||||
id: 'extensionInfoFields.typeHttp',
|
||||
defaultMessage: 'HTTP',
|
||||
},
|
||||
typeSseUnsupported: {
|
||||
id: 'extensionInfoFields.typeSseUnsupported',
|
||||
defaultMessage: 'SSE (unsupported)',
|
||||
},
|
||||
typeStandardIo: {
|
||||
id: 'extensionInfoFields.typeStandardIo',
|
||||
defaultMessage: 'Standard IO (STDIO)',
|
||||
},
|
||||
typeStreamableHttp: {
|
||||
id: 'extensionInfoFields.typeStreamableHttp',
|
||||
defaultMessage: 'Streamable HTTP',
|
||||
},
|
||||
descriptionLabel: {
|
||||
id: 'extensionInfoFields.descriptionLabel',
|
||||
defaultMessage: 'Description',
|
||||
},
|
||||
descriptionPlaceholder: {
|
||||
id: 'extensionInfoFields.descriptionPlaceholder',
|
||||
defaultMessage: 'Optional description...',
|
||||
},
|
||||
});
|
||||
|
||||
interface ExtensionInfoFieldsProps {
|
||||
name: string;
|
||||
@@ -16,6 +64,8 @@ export default function ExtensionInfoFields({
|
||||
onChange,
|
||||
submitAttempted,
|
||||
}: ExtensionInfoFieldsProps) {
|
||||
const intl = useIntl();
|
||||
|
||||
const isNameValid = () => {
|
||||
return name.trim() !== '';
|
||||
};
|
||||
@@ -25,33 +75,33 @@ export default function ExtensionInfoFields({
|
||||
{/* Top row with Name and Type side by side */}
|
||||
<div className="flex justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="text-sm font-medium mb-2 block text-text-primary">Extension Name</label>
|
||||
<label className="text-sm font-medium mb-2 block text-text-primary">{intl.formatMessage(i18n.extensionName)}</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => onChange('name', e.target.value)}
|
||||
placeholder="Enter extension name..."
|
||||
placeholder={intl.formatMessage(i18n.extensionNamePlaceholder)}
|
||||
className={`${!submitAttempted || isNameValid() ? 'border-border-primary' : 'border-red-500'} text-text-primary focus:border-border-primary`}
|
||||
/>
|
||||
{submitAttempted && !isNameValid() && (
|
||||
<div className="absolute text-xs text-red-500 mt-1">Name is required</div>
|
||||
<div className="absolute text-xs text-red-500 mt-1">{intl.formatMessage(i18n.nameRequired)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Type Dropdown */}
|
||||
<div className="w-[200px]">
|
||||
<label className="text-sm font-medium mb-2 block text-text-primary">Type</label>
|
||||
<label className="text-sm font-medium mb-2 block text-text-primary">{intl.formatMessage(i18n.typeLabel)}</label>
|
||||
<Select
|
||||
value={{
|
||||
value: type,
|
||||
label:
|
||||
type === 'stdio'
|
||||
? 'STDIO'
|
||||
? intl.formatMessage(i18n.typeStdio)
|
||||
: type === 'streamable_http'
|
||||
? 'HTTP'
|
||||
? intl.formatMessage(i18n.typeHttp)
|
||||
: type === 'sse'
|
||||
? 'SSE (unsupported)'
|
||||
? intl.formatMessage(i18n.typeSseUnsupported)
|
||||
: type.toUpperCase(),
|
||||
}}
|
||||
onChange={(newValue: unknown) => {
|
||||
@@ -61,8 +111,8 @@ export default function ExtensionInfoFields({
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{ value: 'stdio', label: 'Standard IO (STDIO)' },
|
||||
{ value: 'streamable_http', label: 'Streamable HTTP' },
|
||||
{ value: 'stdio', label: intl.formatMessage(i18n.typeStandardIo) },
|
||||
{ value: 'streamable_http', label: intl.formatMessage(i18n.typeStreamableHttp) },
|
||||
]}
|
||||
isSearchable={false}
|
||||
/>
|
||||
@@ -71,12 +121,12 @@ export default function ExtensionInfoFields({
|
||||
|
||||
{/* Bottom row with Description spanning full width */}
|
||||
<div className="w-full">
|
||||
<label className="text-sm font-medium mb-2 block text-text-primary">Description</label>
|
||||
<label className="text-sm font-medium mb-2 block text-text-primary">{intl.formatMessage(i18n.descriptionLabel)}</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => onChange('description', e.target.value)}
|
||||
placeholder="Optional description..."
|
||||
placeholder={intl.formatMessage(i18n.descriptionPlaceholder)}
|
||||
className={`text-text-primary focus:border-border-primary`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { render, type RenderOptions, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import ExtensionModal from './ExtensionModal';
|
||||
import { ExtensionFormData } from '../utils';
|
||||
import { IntlTestWrapper } from '../../../../i18n/test-utils';
|
||||
|
||||
const renderWithIntl = (ui: React.ReactElement, options?: RenderOptions) =>
|
||||
render(ui, { wrapper: IntlTestWrapper, ...options });
|
||||
|
||||
describe('ExtensionModal', () => {
|
||||
it('does not show unsaved changes dialog when closing without modifications', async () => {
|
||||
@@ -25,7 +29,7 @@ describe('ExtensionModal', () => {
|
||||
headers: [],
|
||||
};
|
||||
|
||||
render(
|
||||
renderWithIntl(
|
||||
<ExtensionModal
|
||||
title="Edit Extension"
|
||||
initialData={initialData}
|
||||
@@ -61,7 +65,7 @@ describe('ExtensionModal', () => {
|
||||
headers: [],
|
||||
};
|
||||
|
||||
render(
|
||||
renderWithIntl(
|
||||
<ExtensionModal
|
||||
title="Edit Extension"
|
||||
initialData={initialData}
|
||||
@@ -100,7 +104,7 @@ describe('ExtensionModal', () => {
|
||||
headers: [],
|
||||
};
|
||||
|
||||
render(
|
||||
renderWithIntl(
|
||||
<ExtensionModal
|
||||
title="Edit Extension"
|
||||
initialData={initialData}
|
||||
@@ -139,7 +143,7 @@ describe('ExtensionModal', () => {
|
||||
headers: [],
|
||||
};
|
||||
|
||||
render(
|
||||
renderWithIntl(
|
||||
<ExtensionModal
|
||||
title="Edit Extension"
|
||||
initialData={initialData}
|
||||
@@ -178,7 +182,7 @@ describe('ExtensionModal', () => {
|
||||
headers: [],
|
||||
};
|
||||
|
||||
render(
|
||||
renderWithIntl(
|
||||
<ExtensionModal
|
||||
title="Add custom extension"
|
||||
initialData={initialData}
|
||||
|
||||
@@ -17,6 +17,46 @@ import ExtensionInfoFields from './ExtensionInfoFields';
|
||||
import ExtensionTimeoutField from './ExtensionTimeoutField';
|
||||
import { upsertConfig } from '../../../../api';
|
||||
import { ConfirmationModal } from '../../../ui/ConfirmationModal';
|
||||
import { defineMessages, useIntl } from '../../../../i18n';
|
||||
|
||||
const i18n = defineMessages({
|
||||
deleteExtensionTitle: {
|
||||
id: 'extensionModal.deleteExtensionTitle',
|
||||
defaultMessage: 'Delete Extension "{name}"',
|
||||
},
|
||||
deleteDescription: {
|
||||
id: 'extensionModal.deleteDescription',
|
||||
defaultMessage: 'This will permanently remove this extension and all of its settings.',
|
||||
},
|
||||
installationNotes: {
|
||||
id: 'extensionModal.installationNotes',
|
||||
defaultMessage: 'Installation Notes',
|
||||
},
|
||||
cancel: {
|
||||
id: 'extensionModal.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
confirmRemoval: {
|
||||
id: 'extensionModal.confirmRemoval',
|
||||
defaultMessage: 'Confirm removal',
|
||||
},
|
||||
removeExtension: {
|
||||
id: 'extensionModal.removeExtension',
|
||||
defaultMessage: 'Remove extension',
|
||||
},
|
||||
unsavedChangesTitle: {
|
||||
id: 'extensionModal.unsavedChangesTitle',
|
||||
defaultMessage: 'Unsaved Changes',
|
||||
},
|
||||
unsavedChangesMessage: {
|
||||
id: 'extensionModal.unsavedChangesMessage',
|
||||
defaultMessage: 'You have unsaved changes to the extension configuration. Are you sure you want to close without saving?',
|
||||
},
|
||||
closeWithoutSaving: {
|
||||
id: 'extensionModal.closeWithoutSaving',
|
||||
defaultMessage: 'Close Without Saving',
|
||||
},
|
||||
});
|
||||
|
||||
interface ExtensionModalProps {
|
||||
title: string;
|
||||
@@ -37,6 +77,7 @@ export default function ExtensionModal({
|
||||
submitLabel,
|
||||
modalType,
|
||||
}: ExtensionModalProps) {
|
||||
const intl = useIntl();
|
||||
const [formData, setFormData] = useState<ExtensionFormData>(initialData);
|
||||
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false);
|
||||
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||
@@ -315,7 +356,7 @@ export default function ExtensionModal({
|
||||
};
|
||||
|
||||
// Update title based on current state
|
||||
const modalTitle = showDeleteConfirmation ? `Delete Extension "${formData.name}"` : title;
|
||||
const modalTitle = showDeleteConfirmation ? intl.formatMessage(i18n.deleteExtensionTitle, { name: formData.name }) : title;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -328,7 +369,7 @@ export default function ExtensionModal({
|
||||
</DialogTitle>
|
||||
{showDeleteConfirmation && (
|
||||
<DialogDescription>
|
||||
This will permanently remove this extension and all of its settings.
|
||||
{intl.formatMessage(i18n.deleteDescription)}
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
@@ -336,7 +377,7 @@ export default function ExtensionModal({
|
||||
{showDeleteConfirmation ? (
|
||||
<div className="py-4">
|
||||
<p className="text-text-primary">
|
||||
This will permanently remove this extension and all of its settings.
|
||||
{intl.formatMessage(i18n.deleteDescription)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -347,7 +388,7 @@ export default function ExtensionModal({
|
||||
<Info className="h-5 w-5 text-blue-400 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-text-primary mb-1">
|
||||
Installation Notes
|
||||
{intl.formatMessage(i18n.installationNotes)}
|
||||
</h4>
|
||||
<p className="text-sm text-text-secondary">{formData.installation_notes}</p>
|
||||
</div>
|
||||
@@ -426,7 +467,7 @@ export default function ExtensionModal({
|
||||
{showDeleteConfirmation ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setShowDeleteConfirmation(false)}>
|
||||
Cancel
|
||||
{intl.formatMessage(i18n.cancel)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
@@ -438,7 +479,7 @@ export default function ExtensionModal({
|
||||
variant="destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Confirm removal
|
||||
{intl.formatMessage(i18n.confirmRemoval)}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
@@ -450,11 +491,11 @@ export default function ExtensionModal({
|
||||
className="text-red-500 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Remove extension
|
||||
{intl.formatMessage(i18n.removeExtension)}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
{intl.formatMessage(i18n.cancel)}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="extension-submit-btn"
|
||||
@@ -473,9 +514,9 @@ export default function ExtensionModal({
|
||||
{showCloseConfirmation && (
|
||||
<ConfirmationModal
|
||||
isOpen={showCloseConfirmation}
|
||||
title="Unsaved Changes"
|
||||
message="You have unsaved changes to the extension configuration. Are you sure you want to close without saving?"
|
||||
confirmLabel="Close Without Saving"
|
||||
title={intl.formatMessage(i18n.unsavedChangesTitle)}
|
||||
message={intl.formatMessage(i18n.unsavedChangesMessage)}
|
||||
confirmLabel={intl.formatMessage(i18n.closeWithoutSaving)}
|
||||
onConfirm={handleConfirmClose}
|
||||
onCancel={handleCancelClose}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Input } from '../../../ui/input';
|
||||
import { defineMessages, useIntl } from '../../../../i18n';
|
||||
|
||||
const i18n = defineMessages({
|
||||
timeoutLabel: {
|
||||
id: 'extensionTimeoutField.timeoutLabel',
|
||||
defaultMessage: 'Timeout',
|
||||
},
|
||||
});
|
||||
|
||||
interface ExtensionTimeoutFieldProps {
|
||||
timeout: number;
|
||||
@@ -24,12 +32,14 @@ export default function ExtensionTimeoutField({
|
||||
return !isNaN(timeoutValue) && timeoutValue > 0;
|
||||
};
|
||||
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 mb-6">
|
||||
{/* Row with Timeout and timeout input side by side */}
|
||||
<div className="flex flex-col">
|
||||
<div className="flex-1">
|
||||
<label className="text-sm font-medium mb-2 block text-text-primary">Timeout</label>
|
||||
<label className="text-sm font-medium mb-2 block text-text-primary">{intl.formatMessage(i18n.timeoutLabel)}</label>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
|
||||
@@ -3,6 +3,42 @@ import { Button } from '../../../ui/button';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { Input } from '../../../ui/input';
|
||||
import { cn } from '../../../../utils';
|
||||
import { defineMessages, useIntl } from '../../../../i18n';
|
||||
|
||||
const i18n = defineMessages({
|
||||
requestHeaders: {
|
||||
id: 'headersSection.requestHeaders',
|
||||
defaultMessage: 'Request Headers',
|
||||
},
|
||||
headersDescription: {
|
||||
id: 'headersSection.headersDescription',
|
||||
defaultMessage: 'Add custom HTTP headers to include in requests to the MCP server. Click the "+" button to add after filling both fields.',
|
||||
},
|
||||
headerName: {
|
||||
id: 'headersSection.headerName',
|
||||
defaultMessage: 'Header name',
|
||||
},
|
||||
value: {
|
||||
id: 'headersSection.value',
|
||||
defaultMessage: 'Value',
|
||||
},
|
||||
bothRequired: {
|
||||
id: 'headersSection.bothRequired',
|
||||
defaultMessage: 'Both header name and value must be entered',
|
||||
},
|
||||
noSpaces: {
|
||||
id: 'headersSection.noSpaces',
|
||||
defaultMessage: 'Header name cannot contain spaces',
|
||||
},
|
||||
duplicateHeader: {
|
||||
id: 'headersSection.duplicateHeader',
|
||||
defaultMessage: 'A header with this name already exists',
|
||||
},
|
||||
add: {
|
||||
id: 'headersSection.add',
|
||||
defaultMessage: 'Add',
|
||||
},
|
||||
});
|
||||
|
||||
interface HeadersSectionProps {
|
||||
headers: { key: string; value: string; isEdited?: boolean }[];
|
||||
@@ -24,6 +60,7 @@ export default function HeadersSection({
|
||||
submitAttempted,
|
||||
onPendingInputChange,
|
||||
}: HeadersSectionProps) {
|
||||
const intl = useIntl();
|
||||
const [newKey, setNewKey] = React.useState('');
|
||||
const [newValue, setNewValue] = React.useState('');
|
||||
const [validationError, setValidationError] = React.useState<string | null>(null);
|
||||
@@ -52,7 +89,7 @@ export default function HeadersSection({
|
||||
key: keyEmpty,
|
||||
value: valueEmpty,
|
||||
});
|
||||
setValidationError('Both header name and value must be entered');
|
||||
setValidationError(intl.formatMessage(i18n.bothRequired));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -61,7 +98,7 @@ export default function HeadersSection({
|
||||
key: true,
|
||||
value: false,
|
||||
});
|
||||
setValidationError('Header name cannot contain spaces');
|
||||
setValidationError(intl.formatMessage(i18n.noSpaces));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -70,7 +107,7 @@ export default function HeadersSection({
|
||||
key: true,
|
||||
value: false,
|
||||
});
|
||||
setValidationError('A header with this name already exists');
|
||||
setValidationError(intl.formatMessage(i18n.duplicateHeader));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -95,10 +132,9 @@ export default function HeadersSection({
|
||||
return (
|
||||
<div>
|
||||
<div className="relative mb-2">
|
||||
<label className="text-sm font-medium text-text-primary mb-2 block">Request Headers</label>
|
||||
<label className="text-sm font-medium text-text-primary mb-2 block">{intl.formatMessage(i18n.requestHeaders)}</label>
|
||||
<p className="text-xs text-text-secondary mb-4">
|
||||
Add custom HTTP headers to include in requests to the MCP server. Click the "+" button to
|
||||
add after filling both fields.
|
||||
{intl.formatMessage(i18n.headersDescription)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_1fr_auto] gap-2 items-center">
|
||||
@@ -109,7 +145,7 @@ export default function HeadersSection({
|
||||
<Input
|
||||
value={header.key}
|
||||
onChange={(e) => onChange(index, 'key', e.target.value)}
|
||||
placeholder="Header name"
|
||||
placeholder={intl.formatMessage(i18n.headerName)}
|
||||
className={cn(
|
||||
'w-full text-text-primary border-border-primary hover:border-border-primary',
|
||||
isFieldInvalid(index, 'key') && 'border-red-500 focus:border-red-500'
|
||||
@@ -120,7 +156,7 @@ export default function HeadersSection({
|
||||
<Input
|
||||
value={header.value}
|
||||
onChange={(e) => onChange(index, 'value', e.target.value)}
|
||||
placeholder="Value"
|
||||
placeholder={intl.formatMessage(i18n.value)}
|
||||
className={cn(
|
||||
'w-full text-text-primary border-border-primary hover:border-border-primary',
|
||||
isFieldInvalid(index, 'value') && 'border-red-500 focus:border-red-500'
|
||||
@@ -144,7 +180,7 @@ export default function HeadersSection({
|
||||
setNewKey(e.target.value);
|
||||
clearValidation();
|
||||
}}
|
||||
placeholder="Header name"
|
||||
placeholder={intl.formatMessage(i18n.headerName)}
|
||||
className={cn(
|
||||
'w-full text-text-primary border-border-primary hover:border-border-primary',
|
||||
invalidFields.key && 'border-red-500 focus:border-red-500'
|
||||
@@ -156,7 +192,7 @@ export default function HeadersSection({
|
||||
setNewValue(e.target.value);
|
||||
clearValidation();
|
||||
}}
|
||||
placeholder="Value"
|
||||
placeholder={intl.formatMessage(i18n.value)}
|
||||
className={cn(
|
||||
'w-full text-text-primary border-border-primary hover:border-border-primary',
|
||||
invalidFields.value && 'border-red-500 focus:border-red-500'
|
||||
@@ -167,7 +203,7 @@ export default function HeadersSection({
|
||||
variant="ghost"
|
||||
className="flex items-center justify-start gap-1 px-2 pr-4 text-sm rounded-full text-text-primary bg-background-primary border border-border-primary hover:border-border-primary transition-colors min-w-[60px] h-9 [&>svg]:!size-4"
|
||||
>
|
||||
<Plus /> Add
|
||||
<Plus /> {intl.formatMessage(i18n.add)}
|
||||
</Button>
|
||||
</div>
|
||||
{validationError && <div className="mt-2 text-red-500 text-sm">{validationError}</div>}
|
||||
|
||||
@@ -5,6 +5,18 @@ import { Gear } from '../../../icons';
|
||||
import { FixedExtensionEntry } from '../../../ConfigContext';
|
||||
import { getSubtitle, getFriendlyTitle } from './ExtensionList';
|
||||
import { Card, CardHeader, CardTitle, CardContent, CardAction } from '../../../ui/card';
|
||||
import { defineMessages, useIntl } from '../../../../i18n';
|
||||
|
||||
const i18n = defineMessages({
|
||||
configureExtension: {
|
||||
id: 'extensionItem.configureExtension',
|
||||
defaultMessage: 'Configure {name} Extension',
|
||||
},
|
||||
toggleExtension: {
|
||||
id: 'extensionItem.toggleExtension',
|
||||
defaultMessage: 'Toggle {name} extension On or Off',
|
||||
},
|
||||
});
|
||||
|
||||
interface ExtensionItemProps {
|
||||
extension: FixedExtensionEntry;
|
||||
@@ -19,6 +31,7 @@ export default function ExtensionItem({
|
||||
onConfigure,
|
||||
isStatic,
|
||||
}: ExtensionItemProps) {
|
||||
const intl = useIntl();
|
||||
// Add local state to track the visual toggle state
|
||||
const [visuallyEnabled, setVisuallyEnabled] = useState(extension.enabled);
|
||||
// Track if we're in the process of toggling
|
||||
@@ -84,7 +97,7 @@ export default function ExtensionItem({
|
||||
{editable && (
|
||||
<button
|
||||
className="text-text-secondary hover:text-text-primary"
|
||||
aria-label={`Configure ${getFriendlyTitle(extension)} Extension`}
|
||||
aria-label={intl.formatMessage(i18n.configureExtension, { name: getFriendlyTitle(extension) })}
|
||||
onClick={() => onConfigure?.(extension)}
|
||||
>
|
||||
<Gear className="w-4 h-4" />
|
||||
@@ -95,7 +108,7 @@ export default function ExtensionItem({
|
||||
onCheckedChange={() => handleToggle(extension)}
|
||||
disabled={isToggling}
|
||||
variant="mono"
|
||||
aria-label={`Toggle ${getFriendlyTitle(extension)} extension On or Off`}
|
||||
aria-label={intl.formatMessage(i18n.toggleExtension, { name: getFriendlyTitle(extension) })}
|
||||
/>
|
||||
</div>
|
||||
</CardAction>
|
||||
|
||||
@@ -3,6 +3,26 @@ import builtInExtensionsData from '../../../../built-in-extensions.json';
|
||||
import { ExtensionConfig } from '../../../../api';
|
||||
import { FixedExtensionEntry } from '../../../ConfigContext';
|
||||
import { combineCmdAndArgs } from '../utils';
|
||||
import { defineMessages, useIntl } from '../../../../i18n';
|
||||
|
||||
const i18n = defineMessages({
|
||||
defaultExtensions: {
|
||||
id: 'extensionList.defaultExtensions',
|
||||
defaultMessage: 'Default Extensions ({count})',
|
||||
},
|
||||
availableExtensions: {
|
||||
id: 'extensionList.availableExtensions',
|
||||
defaultMessage: 'Available Extensions ({count})',
|
||||
},
|
||||
noExtensions: {
|
||||
id: 'extensionList.noExtensions',
|
||||
defaultMessage: 'No extensions available',
|
||||
},
|
||||
builtInExtension: {
|
||||
id: 'extensionList.builtInExtension',
|
||||
defaultMessage: 'Built-in extension',
|
||||
},
|
||||
});
|
||||
|
||||
interface ExtensionListProps {
|
||||
extensions: FixedExtensionEntry[];
|
||||
@@ -35,6 +55,8 @@ export default function ExtensionList({
|
||||
);
|
||||
};
|
||||
|
||||
const intl = useIntl();
|
||||
|
||||
// Separate enabled and disabled extensions, then filter by search term
|
||||
const enabledExtensions = extensions.filter((ext) => ext.enabled && matchesSearch(ext));
|
||||
const disabledExtensions = extensions.filter((ext) => !ext.enabled && matchesSearch(ext));
|
||||
@@ -53,7 +75,7 @@ export default function ExtensionList({
|
||||
<div>
|
||||
<h2 className="text-lg font-medium text-text-primary mb-4 flex items-center gap-2">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full"></span>
|
||||
Default Extensions ({sortedEnabledExtensions.length})
|
||||
{intl.formatMessage(i18n.defaultExtensions, { count: sortedEnabledExtensions.length })}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-2">
|
||||
{sortedEnabledExtensions.map((extension) => (
|
||||
@@ -73,7 +95,7 @@ export default function ExtensionList({
|
||||
<div>
|
||||
<h2 className="text-lg font-medium text-text-secondary mb-4 flex items-center gap-2">
|
||||
<span className="w-2 h-2 bg-gray-400 rounded-full"></span>
|
||||
Available Extensions ({sortedDisabledExtensions.length})
|
||||
{intl.formatMessage(i18n.availableExtensions, { count: sortedDisabledExtensions.length })}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-2">
|
||||
{sortedDisabledExtensions.map((extension) => (
|
||||
@@ -90,7 +112,7 @@ export default function ExtensionList({
|
||||
)}
|
||||
|
||||
{extensions.length === 0 && (
|
||||
<div className="text-center text-text-secondary py-8">No extensions available</div>
|
||||
<div className="text-center text-text-secondary py-8">{intl.formatMessage(i18n.noExtensions)}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user