mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-12 03:39:39 +08:00
🐛 fix(config-secret-storage): 修复密文编辑与状态残留问题
- 修复自定义连接编辑时已保存 DSN 无法留空沿用的问题 - 重置 AI 供应商编辑态与清空密钥开关,避免关闭后状态残留 - 对齐浏览器 mock 复制连接的 config.id 语义并补充回归测试
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Modal, Button, Input, Select, Form, Checkbox, message as antdMessage, Tooltip, Tabs, Space, Popconfirm, Slider } from 'antd';
|
||||
import { PlusOutlined, DeleteOutlined, EditOutlined, CheckOutlined, ApiOutlined, SafetyCertificateOutlined, RobotOutlined, ThunderboltOutlined, CloudOutlined, ExperimentOutlined, KeyOutlined, LinkOutlined, AppstoreOutlined, ToolOutlined } from '@ant-design/icons';
|
||||
import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel } from '../types';
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
PROVIDER_PRESET_CARD_TITLE_STYLE,
|
||||
} from '../utils/aiSettingsPresetLayout';
|
||||
import { resolveProviderSecretDraft } from '../utils/providerSecretDraft';
|
||||
import { buildAddProviderEditorSession, buildClosedProviderEditorSession, buildEditProviderEditorSession, type ProviderEditorSession } from '../utils/aiProviderEditorState';
|
||||
|
||||
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
|
||||
@@ -134,18 +135,41 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
|
||||
useEffect(() => { if (open) void loadConfig(); }, [open, loadConfig]);
|
||||
|
||||
const applyProviderEditorSession = useCallback((session: ProviderEditorSession) => {
|
||||
setEditingProvider(session.editingProvider as AIProviderConfig | null);
|
||||
setIsEditing(session.isEditing);
|
||||
setTestStatus(session.testStatus);
|
||||
setClearProviderSecret(session.clearProviderSecret);
|
||||
form.resetFields();
|
||||
if (session.formValues) {
|
||||
form.setFieldsValue(session.formValues);
|
||||
}
|
||||
}, [form]);
|
||||
|
||||
const resetProviderEditorSession = useCallback(() => {
|
||||
applyProviderEditorSession(buildClosedProviderEditorSession());
|
||||
}, [applyProviderEditorSession]);
|
||||
|
||||
const handleModalClose = useCallback(() => {
|
||||
resetProviderEditorSession();
|
||||
onClose();
|
||||
}, [onClose, resetProviderEditorSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
resetProviderEditorSession();
|
||||
}
|
||||
}, [open, resetProviderEditorSession]);
|
||||
const handleAddProvider = () => {
|
||||
const preset = findPreset('openai');
|
||||
const newProvider: AIProviderConfig = {
|
||||
id: '', type: preset.backendType, name: '', apiKey: '',
|
||||
baseUrl: preset.defaultBaseUrl, model: preset.defaultModel,
|
||||
models: [], maxTokens: 4096, temperature: 0.7,
|
||||
};
|
||||
setEditingProvider({ ...newProvider, presetKey: 'openai' } as any);
|
||||
setIsEditing(true);
|
||||
setTestStatus('idle');
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ ...newProvider, presetKey: 'openai', apiFormat: 'openai' });
|
||||
applyProviderEditorSession(buildAddProviderEditorSession({
|
||||
presetKey: 'openai',
|
||||
presetBackendType: preset.backendType,
|
||||
presetBaseUrl: preset.defaultBaseUrl,
|
||||
presetModel: preset.defaultModel,
|
||||
presetModels: preset.models,
|
||||
apiFormat: 'openai',
|
||||
}));
|
||||
};
|
||||
|
||||
const handleEditProvider = (p: AIProviderConfig) => {
|
||||
@@ -156,17 +180,16 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
presetFixedApiFormat: matchedPreset.fixedApiFormat,
|
||||
valuesApiFormat: p.apiFormat,
|
||||
});
|
||||
setEditingProvider(p);
|
||||
setIsEditing(true);
|
||||
setTestStatus('idle');
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
...p,
|
||||
type: resolvedTransport.type,
|
||||
models: p.models || [],
|
||||
presetKey: matchedPreset.key,
|
||||
apiFormat: resolvedTransport.apiFormat || p.apiFormat || 'openai',
|
||||
});
|
||||
applyProviderEditorSession(buildEditProviderEditorSession({
|
||||
provider: { ...p, presetKey: matchedPreset.key } as any,
|
||||
formValues: {
|
||||
...p,
|
||||
type: resolvedTransport.type,
|
||||
models: p.models || [],
|
||||
presetKey: matchedPreset.key,
|
||||
apiFormat: resolvedTransport.apiFormat || p.apiFormat || 'openai',
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const handleDeleteProvider = async (id: string) => {
|
||||
@@ -239,7 +262,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
};
|
||||
// 后端 AISaveProvider 统一处理新增和更新,返回 void,失败抛异常
|
||||
await Service?.AISaveProvider?.(payload);
|
||||
void messageApi.success('已保存'); setIsEditing(false); setEditingProvider(null); setClearProviderSecret(false); void loadConfig();
|
||||
void messageApi.success('已保存'); resetProviderEditorSession(); void loadConfig();
|
||||
window.dispatchEvent(new CustomEvent('gonavi:ai:provider-changed'));
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) { /* antd form validation error, ignore */ }
|
||||
@@ -420,7 +443,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
<div>
|
||||
{/* 顶部返回 */}
|
||||
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<Button size="small" onClick={() => { setIsEditing(false); setEditingProvider(null); setClearProviderSecret(false); }}
|
||||
<Button size="small" onClick={resetProviderEditorSession}
|
||||
style={{ borderRadius: 8 }}>← 返回</Button>
|
||||
<span style={{ fontWeight: 700, fontSize: 16, color: overlayTheme.titleText }}>
|
||||
{editingProvider?.id ? '编辑模型供应商' : '添加模型供应商'}
|
||||
@@ -732,7 +755,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
</div>
|
||||
}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
onCancel={handleModalClose}
|
||||
footer={null}
|
||||
width={820}
|
||||
styles={{
|
||||
@@ -802,3 +825,5 @@ export default AISettingsModal;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Select, Alert, Card, Row, Col, Typography, Collapse, Space, Table, Tag } from 'antd';
|
||||
import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutlined, AppstoreAddOutlined, CloudOutlined, CheckCircleFilled, CloseCircleFilled, LinkOutlined, EditOutlined, AppstoreOutlined, BgColorsOutlined } from '@ant-design/icons';
|
||||
import { getDbIcon, getDbDefaultColor, getDbIconLabel, DB_ICON_TYPES, PRESET_ICON_COLORS } from './DatabaseIcons';
|
||||
@@ -6,6 +6,7 @@ import { useStore } from '../store';
|
||||
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
import { resolveConnectionSecretDraft } from '../utils/connectionSecretDraft';
|
||||
import { getCustomConnectionDsnValidationMessage } from '../utils/customConnectionDsn';
|
||||
import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectDatabaseFile, SelectSSHKeyFile } from '../../wailsjs/go/app/App';
|
||||
import { ConnectionConfig, MongoMemberInfo, SavedConnection } from '../types';
|
||||
|
||||
@@ -819,6 +820,19 @@ const ConnectionModal: React.FC<{
|
||||
}
|
||||
});
|
||||
|
||||
const createCustomDsnRule = () => ({
|
||||
validator(_: unknown, value: unknown) {
|
||||
const validationMessage = getCustomConnectionDsnValidationMessage({
|
||||
dsnInput: value,
|
||||
hasStoredSecret: initialValues?.hasOpaqueDSN,
|
||||
clearStoredSecret: clearSecrets.opaqueDSN,
|
||||
});
|
||||
return validationMessage
|
||||
? Promise.reject(new Error(validationMessage))
|
||||
: Promise.resolve();
|
||||
}
|
||||
});
|
||||
|
||||
const getUriPlaceholder = () => {
|
||||
if (dbType === 'mysql' || dbType === 'mariadb' || dbType === 'diros' || dbType === 'sphinx') {
|
||||
const defaultPort = getDefaultPortByType(dbType);
|
||||
@@ -2100,7 +2114,7 @@ const ConnectionModal: React.FC<{
|
||||
<Form.Item name="driver" label="驱动名称 (Driver Name)" rules={[{ required: true, message: '请输入驱动名称' }]} help="已支持: mysql, postgres, sqlite, oracle, dm, kingbase">
|
||||
<Input placeholder="例如: mysql, postgres" />
|
||||
</Form.Item>
|
||||
<Form.Item name="dsn" label="连接字符串 (DSN)" rules={[{ required: true, message: '请输入连接字符串' }]}>
|
||||
<Form.Item name="dsn" label="连接字符串 (DSN)" rules={[createCustomDsnRule()]}>
|
||||
<Input.TextArea rows={4} placeholder="例如: user:pass@tcp(localhost:3306)/dbname?charset=utf8" />
|
||||
</Form.Item>
|
||||
{renderStoredSecretControls({
|
||||
@@ -3108,3 +3122,5 @@ export default ConnectionModal;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
// import './index.css' // Optional global styles
|
||||
@@ -15,35 +15,9 @@ dayjs.locale('zh-cn')
|
||||
import 'monaco-editor/esm/nls.messages.zh-cn'
|
||||
import { loader } from '@monaco-editor/react'
|
||||
import * as monaco from 'monaco-editor'
|
||||
import { cloneBrowserMockValue, duplicateBrowserMockConnection, resolveBrowserMockSecretFlag } from './utils/browserMockConnections'
|
||||
loader.config({ monaco })
|
||||
|
||||
const cloneBrowserMockValue = (value: any) => {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveBrowserMockSecretFlag = (nextValue: unknown, clearFlag: boolean, existingFlag?: boolean) => {
|
||||
if (String(nextValue ?? '') !== '') return true;
|
||||
if (clearFlag) return false;
|
||||
return !!existingFlag;
|
||||
};
|
||||
|
||||
const buildBrowserMockDuplicateName = (rawName: string, items: any[]): string => {
|
||||
const baseName = String(rawName || '').trim() || '连接';
|
||||
const suffix = ' - 副本';
|
||||
const usedNames = new Set(items.map((item) => String(item?.name || '').trim()));
|
||||
let candidate = `${baseName}${suffix}`;
|
||||
let counter = 2;
|
||||
while (usedNames.has(candidate)) {
|
||||
candidate = `${baseName}${suffix} ${counter}`;
|
||||
counter += 1;
|
||||
}
|
||||
return candidate;
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined' && !(window as any).go) {
|
||||
const mockConnections: any[] = [];
|
||||
let mockGlobalProxy: any = { enabled: false, type: 'socks5', host: '', port: 1080, user: '', password: '', hasPassword: false };
|
||||
@@ -124,13 +98,10 @@ if (typeof window !== 'undefined' && !(window as any).go) {
|
||||
DuplicateConnection: async (id: string) => {
|
||||
const existing = mockConnections.find((item) => item.id === id);
|
||||
if (!existing) return null;
|
||||
const duplicated = cloneBrowserMockValue({
|
||||
...existing,
|
||||
id: `mock-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
name: buildBrowserMockDuplicateName(existing.name, mockConnections),
|
||||
config: cloneBrowserMockValue(existing.config),
|
||||
includeDatabases: Array.isArray(existing.includeDatabases) ? [...existing.includeDatabases] : undefined,
|
||||
includeRedisDatabases: Array.isArray(existing.includeRedisDatabases) ? [...existing.includeRedisDatabases] : undefined,
|
||||
const duplicated = duplicateBrowserMockConnection({
|
||||
existing,
|
||||
items: mockConnections,
|
||||
nextId: `mock-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
});
|
||||
mockConnections.push(duplicated);
|
||||
return cloneBrowserMockValue(duplicated);
|
||||
@@ -174,3 +145,6 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
49
frontend/src/utils/aiProviderEditorState.test.ts
Normal file
49
frontend/src/utils/aiProviderEditorState.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildAddProviderEditorSession,
|
||||
buildClosedProviderEditorSession,
|
||||
buildEditProviderEditorSession,
|
||||
} from './aiProviderEditorState';
|
||||
|
||||
describe('aiProviderEditorState', () => {
|
||||
it('resets clearProviderSecret when starting add flow', () => {
|
||||
const session = buildAddProviderEditorSession({
|
||||
previousClearProviderSecret: true,
|
||||
presetBackendType: 'openai',
|
||||
presetBaseUrl: 'https://api.openai.com/v1',
|
||||
presetModel: 'gpt-4.1',
|
||||
});
|
||||
|
||||
expect(session.clearProviderSecret).toBe(false);
|
||||
expect(session.isEditing).toBe(true);
|
||||
expect(session.testStatus).toBe('idle');
|
||||
});
|
||||
|
||||
it('resets clearProviderSecret when starting edit flow', () => {
|
||||
const session = buildEditProviderEditorSession({
|
||||
previousClearProviderSecret: true,
|
||||
provider: {
|
||||
id: 'provider-1',
|
||||
type: 'openai',
|
||||
name: 'OpenAI',
|
||||
apiKey: '',
|
||||
hasSecret: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(session.clearProviderSecret).toBe(false);
|
||||
expect(session.isEditing).toBe(true);
|
||||
expect(session.editingProvider?.id).toBe('provider-1');
|
||||
});
|
||||
|
||||
it('resets clearProviderSecret when the modal closes', () => {
|
||||
const session = buildClosedProviderEditorSession({
|
||||
previousClearProviderSecret: true,
|
||||
});
|
||||
|
||||
expect(session.clearProviderSecret).toBe(false);
|
||||
expect(session.isEditing).toBe(false);
|
||||
expect(session.editingProvider).toBeNull();
|
||||
});
|
||||
});
|
||||
92
frontend/src/utils/aiProviderEditorState.ts
Normal file
92
frontend/src/utils/aiProviderEditorState.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { AIProviderConfig, AIProviderType } from '../types';
|
||||
|
||||
type ProviderEditorStatus = 'idle' | 'success' | 'error';
|
||||
|
||||
type ProviderEditorConfig = Partial<AIProviderConfig> & Pick<AIProviderConfig, 'id' | 'type' | 'name' | 'apiKey'> & { presetKey?: string };
|
||||
|
||||
export interface ProviderEditorSession {
|
||||
editingProvider: ProviderEditorConfig | null;
|
||||
formValues: Record<string, unknown> | null;
|
||||
isEditing: boolean;
|
||||
clearProviderSecret: boolean;
|
||||
testStatus: ProviderEditorStatus;
|
||||
}
|
||||
|
||||
interface BuildAddProviderEditorSessionInput {
|
||||
previousClearProviderSecret?: boolean;
|
||||
presetKey?: string;
|
||||
presetBackendType: AIProviderType;
|
||||
presetBaseUrl: string;
|
||||
presetModel: string;
|
||||
presetModels?: string[];
|
||||
apiFormat?: string;
|
||||
}
|
||||
|
||||
interface BuildEditProviderEditorSessionInput {
|
||||
previousClearProviderSecret?: boolean;
|
||||
provider: ProviderEditorConfig;
|
||||
formValues?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface BuildClosedProviderEditorSessionInput {
|
||||
previousClearProviderSecret?: boolean;
|
||||
}
|
||||
|
||||
export const buildAddProviderEditorSession = ({
|
||||
presetKey = 'openai',
|
||||
presetBackendType,
|
||||
presetBaseUrl,
|
||||
presetModel,
|
||||
presetModels = [],
|
||||
apiFormat = 'openai',
|
||||
}: BuildAddProviderEditorSessionInput): ProviderEditorSession => {
|
||||
const editingProvider: ProviderEditorConfig = {
|
||||
id: '',
|
||||
type: presetBackendType,
|
||||
name: '',
|
||||
apiKey: '',
|
||||
baseUrl: presetBaseUrl,
|
||||
model: presetModel,
|
||||
models: [...presetModels],
|
||||
maxTokens: 4096,
|
||||
temperature: 0.7,
|
||||
presetKey,
|
||||
};
|
||||
|
||||
return {
|
||||
editingProvider,
|
||||
formValues: {
|
||||
...editingProvider,
|
||||
presetKey,
|
||||
apiFormat,
|
||||
},
|
||||
isEditing: true,
|
||||
clearProviderSecret: false,
|
||||
testStatus: 'idle',
|
||||
};
|
||||
};
|
||||
|
||||
export const buildEditProviderEditorSession = ({
|
||||
provider,
|
||||
formValues,
|
||||
}: BuildEditProviderEditorSessionInput): ProviderEditorSession => ({
|
||||
editingProvider: provider,
|
||||
formValues: formValues || {
|
||||
...provider,
|
||||
models: provider.models || [],
|
||||
presetKey: provider.presetKey,
|
||||
apiFormat: provider.apiFormat || 'openai',
|
||||
},
|
||||
isEditing: true,
|
||||
clearProviderSecret: false,
|
||||
testStatus: 'idle',
|
||||
});
|
||||
|
||||
export const buildClosedProviderEditorSession = (_input?: BuildClosedProviderEditorSessionInput): ProviderEditorSession => ({
|
||||
editingProvider: null,
|
||||
formValues: null,
|
||||
isEditing: false,
|
||||
clearProviderSecret: false,
|
||||
testStatus: 'idle',
|
||||
});
|
||||
|
||||
26
frontend/src/utils/browserMockConnections.test.ts
Normal file
26
frontend/src/utils/browserMockConnections.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { duplicateBrowserMockConnection } from './browserMockConnections';
|
||||
|
||||
describe('duplicateBrowserMockConnection', () => {
|
||||
it('rewrites config.id to match the duplicated top-level id', () => {
|
||||
const duplicated = duplicateBrowserMockConnection({
|
||||
existing: {
|
||||
id: 'conn-1',
|
||||
name: 'Primary',
|
||||
config: {
|
||||
id: 'conn-1',
|
||||
type: 'postgres',
|
||||
},
|
||||
includeDatabases: ['appdb'],
|
||||
},
|
||||
items: [],
|
||||
nextId: 'conn-2',
|
||||
});
|
||||
|
||||
expect(duplicated.id).toBe('conn-2');
|
||||
expect(duplicated.config.id).toBe('conn-2');
|
||||
expect(duplicated.name).toBe('Primary - 副本');
|
||||
expect(duplicated.includeDatabases).toEqual(['appdb']);
|
||||
});
|
||||
});
|
||||
47
frontend/src/utils/browserMockConnections.ts
Normal file
47
frontend/src/utils/browserMockConnections.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export const cloneBrowserMockValue = <T,>(value: T): T => {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
export const resolveBrowserMockSecretFlag = (nextValue: unknown, clearFlag: boolean, existingFlag?: boolean) => {
|
||||
if (String(nextValue ?? '') !== '') return true;
|
||||
if (clearFlag) return false;
|
||||
return !!existingFlag;
|
||||
};
|
||||
|
||||
export const buildBrowserMockDuplicateName = (rawName: string, items: any[]): string => {
|
||||
const baseName = String(rawName || '').trim() || '连接';
|
||||
const suffix = ' - 副本';
|
||||
const usedNames = new Set(items.map((item) => String(item?.name || '').trim()));
|
||||
let candidate = `${baseName}${suffix}`;
|
||||
let counter = 2;
|
||||
while (usedNames.has(candidate)) {
|
||||
candidate = `${baseName}${suffix} ${counter}`;
|
||||
counter += 1;
|
||||
}
|
||||
return candidate;
|
||||
};
|
||||
|
||||
interface DuplicateBrowserMockConnectionInput {
|
||||
existing: any;
|
||||
items: any[];
|
||||
nextId: string;
|
||||
}
|
||||
|
||||
export const duplicateBrowserMockConnection = ({ existing, items, nextId }: DuplicateBrowserMockConnectionInput) => {
|
||||
const duplicated = cloneBrowserMockValue({
|
||||
...existing,
|
||||
id: nextId,
|
||||
name: buildBrowserMockDuplicateName(existing?.name, items),
|
||||
config: {
|
||||
...cloneBrowserMockValue(existing?.config),
|
||||
id: nextId,
|
||||
},
|
||||
includeDatabases: Array.isArray(existing?.includeDatabases) ? [...existing.includeDatabases] : undefined,
|
||||
includeRedisDatabases: Array.isArray(existing?.includeRedisDatabases) ? [...existing.includeRedisDatabases] : undefined,
|
||||
});
|
||||
return duplicated;
|
||||
};
|
||||
37
frontend/src/utils/customConnectionDsn.test.ts
Normal file
37
frontend/src/utils/customConnectionDsn.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { shouldAllowBlankCustomDsn } from './customConnectionDsn';
|
||||
|
||||
describe('shouldAllowBlankCustomDsn', () => {
|
||||
it('allows a blank DSN when editing a connection that already has a stored opaque DSN', () => {
|
||||
expect(shouldAllowBlankCustomDsn({
|
||||
dsnInput: '',
|
||||
hasStoredSecret: true,
|
||||
clearStoredSecret: false,
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('requires a new DSN when the user chooses to clear the stored opaque DSN', () => {
|
||||
expect(shouldAllowBlankCustomDsn({
|
||||
dsnInput: '',
|
||||
hasStoredSecret: true,
|
||||
clearStoredSecret: true,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('requires a DSN for brand new custom connections', () => {
|
||||
expect(shouldAllowBlankCustomDsn({
|
||||
dsnInput: '',
|
||||
hasStoredSecret: false,
|
||||
clearStoredSecret: false,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a newly entered DSN even when a stored secret already exists', () => {
|
||||
expect(shouldAllowBlankCustomDsn({
|
||||
dsnInput: 'driver://demo',
|
||||
hasStoredSecret: true,
|
||||
clearStoredSecret: true,
|
||||
})).toBe(true);
|
||||
});
|
||||
});
|
||||
27
frontend/src/utils/customConnectionDsn.ts
Normal file
27
frontend/src/utils/customConnectionDsn.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export interface CustomConnectionDsnState {
|
||||
dsnInput: unknown;
|
||||
hasStoredSecret?: boolean;
|
||||
clearStoredSecret?: boolean;
|
||||
}
|
||||
|
||||
export const getCustomConnectionDsnValidationMessage = ({
|
||||
dsnInput,
|
||||
hasStoredSecret,
|
||||
clearStoredSecret,
|
||||
}: CustomConnectionDsnState): string | null => {
|
||||
const dsnText = String(dsnInput ?? '').trim();
|
||||
if (dsnText !== '') {
|
||||
return null;
|
||||
}
|
||||
if (hasStoredSecret && !clearStoredSecret) {
|
||||
return null;
|
||||
}
|
||||
if (hasStoredSecret && clearStoredSecret) {
|
||||
return '请输入新的连接字符串,或取消清除已保存 DSN';
|
||||
}
|
||||
return '请输入连接字符串';
|
||||
};
|
||||
|
||||
export const shouldAllowBlankCustomDsn = (state: CustomConnectionDsnState): boolean => (
|
||||
getCustomConnectionDsnValidationMessage(state) === null
|
||||
);
|
||||
Reference in New Issue
Block a user