diff --git a/frontend/src/App.css b/frontend/src/App.css index 45bcd833..019eb53b 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -421,6 +421,36 @@ body[data-theme='light'] .redis-viewer-workbench .ant-radio-button-wrapper-check border-top: none !important; } +.gn-connection-type-row { + background: transparent; +} + +.gn-connection-type-row:hover { + background: rgba(16, 24, 40, 0.05); +} + +.gn-connection-type-row:focus-visible { + outline: 2px solid color-mix(in srgb, var(--gn-accent, #1677ff) 68%, transparent); + outline-offset: -2px; +} + +body[data-theme='dark'] .gn-connection-type-row:hover { + background: rgba(255, 255, 255, 0.06); +} + +.gn-connection-type-group:hover:not([aria-pressed='true']) { + background: rgba(16, 24, 40, 0.035) !important; +} + +.gn-connection-type-group:focus-visible { + outline: 2px solid color-mix(in srgb, var(--gn-accent, #1677ff) 68%, transparent); + outline-offset: -2px; +} + +body[data-theme='dark'] .gn-connection-type-group:hover:not([aria-pressed='true']) { + background: rgba(255, 255, 255, 0.045) !important; +} + /* Custom Title Bar Close Button Hover */ body[data-platform='windows'] .titlebar-window-controls { gap: 8px; diff --git a/frontend/src/components/ConnectionModal.i18n.test.tsx b/frontend/src/components/ConnectionModal.i18n.test.tsx index 9b597f56..d4f02a94 100644 --- a/frontend/src/components/ConnectionModal.i18n.test.tsx +++ b/frontend/src/components/ConnectionModal.i18n.test.tsx @@ -4,6 +4,7 @@ import { act, create, type ReactTestRenderer } from "react-test-renderer"; import { readFileSync } from "node:fs"; import { setCurrentLanguage } from "../i18n"; +import { getAllConnectionTypeCatalogItems } from "../utils/connectionTypeCatalog"; import { getCustomConnectionDriverHelp } from "../utils/driverImportGuidance"; const storeState = { @@ -66,7 +67,38 @@ const findClickableByAnyText = (renderer: ReactTestRenderer, texts: string[]) => )[0]; const findClickableCard = (renderer: ReactTestRenderer, text: string) => - renderer.root.findAll((node) => node.props?.role === "button" && textContent(node).includes(text))[0]; + renderer.root.findAll( + (node) => + (node.props?.role === "button" || + typeof node.props?.["data-connection-type-key"] === "string") && + textContent(node).includes(text), + )[0]; + +const findConnectionTypeButtons = (renderer: ReactTestRenderer) => + renderer.root.findAll( + (node) => + node.type === "button" && + typeof node.props?.["data-connection-type-key"] === "string", + ); + +const findConnectionTypeGroup = ( + renderer: ReactTestRenderer, + groupKey: string, +) => + renderer.root.findAll( + (node) => + node.type === "button" && + node.props?.["data-connection-group-key"] === groupKey, + )[0]; + +const findInputByPlaceholder = ( + renderer: ReactTestRenderer, + placeholder: string, +) => + renderer.root.findAll( + (node) => + node.type === "input" && node.props?.placeholder === placeholder, + )[0]; /** * 展开「连接 URI」分组。 @@ -173,6 +205,7 @@ vi.mock("@ant-design/icons", () => { ThunderboltOutlined: Icon, DownOutlined: Icon, RightOutlined: Icon, + SearchOutlined: Icon, }; }); @@ -385,6 +418,14 @@ describe("ConnectionModal i18n", () => { }); expect(textContent(renderer!.toJSON())).toContain("选择数据源类型"); + expect( + textContent( + findConnectionTypeGroup( + renderer!, + "connection_modal.step1.group.all", + ), + ), + ).toBe("全部"); await act(async () => { storeState.setLanguagePreference("en-US"); @@ -392,9 +433,58 @@ describe("ConnectionModal i18n", () => { expect(storeState.setLanguagePreference).toHaveBeenCalledWith("en-US"); expect(textContent(renderer!.toJSON())).toContain("Select connection type"); + expect( + textContent( + findConnectionTypeGroup( + renderer!, + "connection_modal.step1.group.all", + ), + ), + ).toBe("All"); + expect( + textContent( + findConnectionTypeGroup( + renderer!, + "connection_modal.step1.group.other", + ), + ), + ).toBe("Other"); expect(textContent(renderer!.toJSON())).not.toContain("选择数据源类型"); }); + it("retranslates data source groups when resolved system language changes", async () => { + storeState.languagePreference = "system"; + setCurrentLanguage("zh-CN"); + const { default: ConnectionModal } = await import("./ConnectionModal"); + const onClose = vi.fn(); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + expect( + textContent( + findConnectionTypeGroup( + renderer!, + "connection_modal.step1.group.all", + ), + ), + ).toBe("全部"); + + setCurrentLanguage("en-US"); + await act(async () => { + renderer!.update(); + }); + expect( + textContent( + findConnectionTypeGroup( + renderer!, + "connection_modal.step1.group.all", + ), + ), + ).toBe("All"); + }); + it.each(["legacy", "v2"] as const)( "renders localized create flow copy for %s ui", async (uiVersion) => { @@ -1239,6 +1329,116 @@ describe("ConnectionModal i18n", () => { expect(pageText).not.toContain("Custom (自定义)"); }); + it("searches across all data sources, keeps category state consistent, and resets on reopen", async () => { + storeState.appearance.uiVersion = "legacy"; + setCurrentLanguage("en-US"); + const { default: ConnectionModal } = await import("./ConnectionModal"); + const onClose = vi.fn(); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + const expectedKeys = new Set( + getAllConnectionTypeCatalogItems().map((item) => item.key), + ); + expect( + findConnectionTypeButtons(renderer!).map( + (node) => node.props["data-connection-type-key"], + ), + ).toEqual([...expectedKeys]); + + await act(async () => { + findConnectionTypeGroup( + renderer!, + "connection_modal.step1.group.nosql", + ).props.onClick(); + }); + expect( + findConnectionTypeButtons(renderer!).map( + (node) => node.props["data-connection-type-key"], + ), + ).toEqual(["mongodb", "redis", "elasticsearch"]); + + await act(async () => { + findInputByPlaceholder( + renderer!, + "Search data source name", + ).props.onChange({ + target: { value: " DIROS " }, + }); + }); + expect( + findConnectionTypeGroup( + renderer!, + "connection_modal.step1.group.all", + ).props["aria-pressed"], + ).toBe(true); + expect( + findConnectionTypeButtons(renderer!).map( + (node) => node.props["data-connection-type-key"], + ), + ).toEqual(["diros"]); + expect(textContent(renderer!.toJSON())).toContain("Doris"); + + await act(async () => { + findInputByPlaceholder( + renderer!, + "Search data source name", + ).props.onChange({ + target: { value: "not-a-real-data-source" }, + }); + }); + expect(findConnectionTypeButtons(renderer!)).toHaveLength(0); + expect(textContent(renderer!.toJSON())).toContain( + "No matching data source", + ); + + await act(async () => { + renderer!.update(); + }); + await act(async () => { + renderer!.update(); + }); + expect( + findInputByPlaceholder(renderer!, "Search data source name").props.value, + ).toBe(""); + expect(findConnectionTypeButtons(renderer!)).toHaveLength( + expectedKeys.size, + ); + + await act(async () => { + findConnectionTypeGroup( + renderer!, + "connection_modal.step1.group.other", + ).props.onClick(); + }); + expect( + findConnectionTypeButtons(renderer!).map( + (node) => node.props["data-connection-type-key"], + ), + ).toEqual(["jvm", "custom"]); + }); + + it("uses native buttons for category and data source keyboard interaction", async () => { + const { default: ConnectionModal } = await import("./ConnectionModal"); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + expect( + findConnectionTypeGroup( + renderer!, + "connection_modal.step1.group.all", + ).props.type, + ).toBe("button"); + expect(findClickableCard(renderer!, "MySQL").type).toBe("button"); + expect(findClickableCard(renderer!, "MySQL").props.type).toBe("button"); + }); + it("renders English custom driver DSN copy after the module was loaded in another language", async () => { storeState.appearance.uiVersion = "legacy"; setCurrentLanguage("zh-CN"); diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index 6edef8b6..44a8dfb1 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -9,9 +9,6 @@ import { Checkbox, Select, Alert, - Card, - Row, - Col, Typography, Space, Table, @@ -36,6 +33,7 @@ import { ThunderboltOutlined, DownOutlined, RightOutlined, + SearchOutlined, } from "@ant-design/icons"; import { getDbIcon, @@ -345,6 +343,7 @@ const ConnectionModal: React.FC<{ const [dbType, setDbType] = useState("mysql"); const [step, setStep] = useState(1); // 1: Select Type, 2: Configure const [activeGroup, setActiveGroup] = useState(0); // Active category index in step 1 + const [dbTypeQuery, setDbTypeQuery] = useState(""); const [activeConfigSection, setActiveConfigSection] = useState< "basic" | "network" | "appearance" >("basic"); @@ -1676,6 +1675,7 @@ const ConnectionModal: React.FC<{ setUseHttpTunnel(false); setDbType("mysql"); setActiveGroup(0); + setDbTypeQuery(""); setActiveConfigSection("basic"); setActiveNetworkConfig("ssl"); setPrimaryPasswordVisible(false); @@ -2356,17 +2356,54 @@ const ConnectionModal: React.FC<{ const driverStatusChecking = hasCurrentDriverType && !driverStatusLoaded && step === 2; - const dbTypeGroups = useMemo( - () => - buildConnectionTypeGroups(t).map((group) => ({ - ...group, - items: group.items.map((item) => ({ - ...item, - icon: getDbIcon(item.key, undefined, 36), - })), - })), - [], - ); + // 翻译函数读取运行时语言;系统语言变化时 preference 仍可能保持 "system", + // 因此这组少量目录项必须随组件重渲染重新派生,不能只按 preference 做 memo。 + const localizedDbTypeGroups = buildConnectionTypeGroups(t).map((group) => ({ + ...group, + items: group.items.map((item) => ({ + ...item, + icon: getDbIcon(item.key, undefined, 32), + })), + })); + const seenDbTypeKeys = new Set(); + const allDbTypeItems = localizedDbTypeGroups + .flatMap((group) => group.items) + .filter((item) => { + if (seenDbTypeKeys.has(item.key)) { + return false; + } + seenDbTypeKeys.add(item.key); + return true; + }); + const dbTypeGroups = [ + { + labelKey: "connection_modal.step1.group.all", + label: t("connection.modal.step1.group.all"), + items: allDbTypeItems, + }, + ...localizedDbTypeGroups, + ]; + + const normalizedDbTypeQuery = dbTypeQuery.trim().toLowerCase(); + const visibleDbTypeItems = normalizedDbTypeQuery + ? (dbTypeGroups[0]?.items ?? []).filter( + (item) => + item.name.toLowerCase().includes(normalizedDbTypeQuery) || + String(item.key).toLowerCase().includes(normalizedDbTypeQuery), + ) + : (dbTypeGroups[activeGroup]?.items ?? []); + + const handleDbTypeQueryChange = (value: string) => { + setDbTypeQuery(value); + if (value.trim()) { + setActiveGroup(0); + } + }; + + const handleDbTypeGroupSelect = (index: number) => { + setActiveGroup(index); + setDbTypeQuery(""); + }; const dbTypes = getAllConnectionTypeCatalogItems(); @@ -2379,20 +2416,46 @@ const ConnectionModal: React.FC<{ height: "100%", }} > - - + + + {t("connection.modal.step1.sectionTitle")} + + + {t("connection.modal.step1.sectionDescription")} + + + handleDbTypeQueryChange(event.target.value)} + placeholder={t("connection.modal.step1.search.placeholder")} + prefix={} style={{ - marginBottom: 12, - color: darkMode ? "#f5f7ff" : "#162033", - fontSize: 14, - fontWeight: 700, + width: "100%", + maxWidth: 320, + flex: "0 1 320px", }} - > - {t("connection.modal.step1.sectionTitle")} - - - {t("connection.modal.step1.sectionDescription")} - + {...noAutoCapInputProps} + /> {typeSelectWarning && ( - {/* 左侧分类导航 */} {dbTypeGroups.map((group, idx) => ( - setActiveGroup(idx)} + handleDbTypeGroupSelect(idx)} style={{ - padding: "11px 12px", + width: "100%", + padding: "8px 10px", cursor: "pointer", - borderRadius: 12, - marginBottom: 6, + border: "none", + borderRadius: 0, + borderLeft: `2px solid ${ + activeGroup === idx ? step1SidebarActiveColor : "transparent" + }`, + marginBottom: 2, background: - activeGroup === idx ? step1SidebarActiveBg : "transparent", + activeGroup === idx + ? `linear-gradient(90deg, ${step1SidebarActiveBg}, transparent)` + : "transparent", color: activeGroup === idx ? step1SidebarActiveColor : undefined, fontWeight: activeGroup === idx ? 700 : 500, - transition: "all 0.2s", + transition: "background 120ms ease, color 120ms ease", fontSize: 13, + fontFamily: "var(--gn-font-sans)", + textAlign: "left", }} > {group.label} - + ))} - {/* 右侧数据源卡片 */} - - {dbTypeGroups[activeGroup]?.items.map((item) => ( - - { - void handleTypeSelect(item.key); - }} + + {visibleDbTypeItems.map((item) => ( + { + void handleTypeSelect(item.key); + }} + className="gn-connection-type-row" + style={{ + width: "100%", + display: "flex", + alignItems: "center", + gap: 10, + minHeight: 44, + padding: "6px 8px", + border: "none", + borderRadius: 6, + color: "inherit", + cursor: "pointer", + fontFamily: "var(--gn-font-sans)", + textAlign: "left", + transition: "background 120ms ease", + }} + > + - + + - {item.icon} - - - - {item.name} - - - {getConnectionTypeHint(item.key, t)} - - - - + {item.name} + + + {getConnectionTypeHint(item.key, t)} + + + ))} - + + {normalizedDbTypeQuery && visibleDbTypeItems.length === 0 ? ( + + {t("connection.modal.step1.search.empty")} + + ) : null} diff --git a/frontend/src/i18n/i18n.test.ts b/frontend/src/i18n/i18n.test.ts index 31650e07..fce08ac5 100644 --- a/frontend/src/i18n/i18n.test.ts +++ b/frontend/src/i18n/i18n.test.ts @@ -17,6 +17,10 @@ type LocalizedExpectation = { }; const remainingConnectionModalSliceExpectations: LocalizedExpectation[] = [ + { + key: "connection.modal.step1.group.all", + catalogKey: "connection_modal.step1.group.all", + }, { key: "connection.modal.step1.group.relational", catalogKey: "connection_modal.step1.group.relational", @@ -69,6 +73,14 @@ const remainingConnectionModalSliceExpectations: LocalizedExpectation[] = [ key: "connection.modal.step1.hint.standard", catalogKey: "connection_modal.step1.hint.standard", }, + { + key: "connection.modal.step1.search.empty", + catalogKey: "connection_modal.step1.search.empty", + }, + { + key: "connection.modal.step1.search.placeholder", + catalogKey: "connection_modal.step1.search.placeholder", + }, { key: "connection.modal.field.driver.label", catalogKey: "connection_modal.field.driver_name", diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json index 2c8daeac..2a860864 100644 --- a/shared/i18n/de-DE.json +++ b/shared/i18n/de-DE.json @@ -3767,6 +3767,7 @@ "connection_modal.status.unhealthy": "Fehlerhaft", "connection_modal.step.select_source": "Datenquelle auswählen", "connection_modal.step.select_source_description": "Wählen Sie den zu erstellenden Verbindungstyp.", + "connection_modal.step1.group.all": "Alle", "connection_modal.step1.group.domestic": "Inländische Datenbanken", "connection_modal.step1.group.message_queue": "Nachrichtenwarteschlangen", "connection_modal.step1.group.nosql": "NoSQL-Datenbanken", @@ -3786,6 +3787,8 @@ "connection_modal.step1.hint.qdrant": "Collection-Browsing, Vektorsuche und Payload-Filter", "connection_modal.step1.hint.redis": "Einzelknoten / Cluster", "connection_modal.step1.hint.standard": "Standard-Verbindungskonfiguration", + "connection_modal.step1.search.empty": "Keine passende Datenquelle", + "connection_modal.step1.search.placeholder": "Datenquellennamen suchen", "connection_modal.switch.off": "Aus", "connection_modal.switch.on": "Ein", "connection_modal.table.health": "Zustand", diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json index 5629520d..104f8e08 100644 --- a/shared/i18n/en-US.json +++ b/shared/i18n/en-US.json @@ -3767,6 +3767,7 @@ "connection_modal.status.unhealthy": "Unhealthy", "connection_modal.step.select_source": "Select data source", "connection_modal.step.select_source_description": "Choose the connection type to create.", + "connection_modal.step1.group.all": "All", "connection_modal.step1.group.domestic": "Domestic databases", "connection_modal.step1.group.message_queue": "Message queues", "connection_modal.step1.group.nosql": "NoSQL databases", @@ -3786,6 +3787,8 @@ "connection_modal.step1.hint.qdrant": "Collection browsing, vector search, and Payload filtering", "connection_modal.step1.hint.redis": "Single node / cluster", "connection_modal.step1.hint.standard": "Standard connection configuration", + "connection_modal.step1.search.empty": "No matching data source", + "connection_modal.step1.search.placeholder": "Search data source name", "connection_modal.switch.off": "Off", "connection_modal.switch.on": "On", "connection_modal.table.health": "Health", diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json index e91a73e7..37a9a401 100644 --- a/shared/i18n/ja-JP.json +++ b/shared/i18n/ja-JP.json @@ -3767,6 +3767,7 @@ "connection_modal.status.unhealthy": "異常", "connection_modal.step.select_source": "データソースを選択", "connection_modal.step.select_source_description": "作成する接続タイプを選択します。", + "connection_modal.step1.group.all": "すべて", "connection_modal.step1.group.domestic": "国産データベース", "connection_modal.step1.group.message_queue": "メッセージキュー", "connection_modal.step1.group.nosql": "NoSQL データベース", @@ -3786,6 +3787,8 @@ "connection_modal.step1.hint.qdrant": "Collection 閲覧、ベクトル検索、Payload フィルター", "connection_modal.step1.hint.redis": "シングルノード / クラスター", "connection_modal.step1.hint.standard": "標準接続設定", + "connection_modal.step1.search.empty": "一致するデータソースがありません", + "connection_modal.step1.search.placeholder": "データソース名を検索", "connection_modal.switch.off": "オフ", "connection_modal.switch.on": "オン", "connection_modal.table.health": "ヘルス", diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json index d3549a45..90bc8f97 100644 --- a/shared/i18n/ru-RU.json +++ b/shared/i18n/ru-RU.json @@ -3767,6 +3767,7 @@ "connection_modal.status.unhealthy": "Неисправен", "connection_modal.step.select_source": "Выбор источника данных", "connection_modal.step.select_source_description": "Выберите тип создаваемого подключения.", + "connection_modal.step1.group.all": "Все", "connection_modal.step1.group.domestic": "Отечественные базы данных", "connection_modal.step1.group.message_queue": "Очереди сообщений", "connection_modal.step1.group.nosql": "Базы данных NoSQL", @@ -3786,6 +3787,8 @@ "connection_modal.step1.hint.qdrant": "Просмотр Collection, векторный поиск и фильтрация Payload", "connection_modal.step1.hint.redis": "Одиночный узел / кластер", "connection_modal.step1.hint.standard": "Стандартная конфигурация подключения", + "connection_modal.step1.search.empty": "Подходящих источников нет", + "connection_modal.step1.search.placeholder": "Поиск по названию источника", "connection_modal.switch.off": "Выкл", "connection_modal.switch.on": "Вкл", "connection_modal.table.health": "Состояние", diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json index 8fc2f58e..7af65165 100644 --- a/shared/i18n/zh-CN.json +++ b/shared/i18n/zh-CN.json @@ -3767,6 +3767,7 @@ "connection_modal.status.unhealthy": "异常", "connection_modal.step.select_source": "选择数据源", "connection_modal.step.select_source_description": "选择要创建的连接类型。", + "connection_modal.step1.group.all": "全部", "connection_modal.step1.group.domestic": "国产数据库", "connection_modal.step1.group.message_queue": "消息队列", "connection_modal.step1.group.nosql": "NoSQL 数据库", @@ -3786,6 +3787,8 @@ "connection_modal.step1.hint.qdrant": "Collection 浏览、向量搜索和 Payload 过滤", "connection_modal.step1.hint.redis": "单机 / 集群", "connection_modal.step1.hint.standard": "标准连接配置", + "connection_modal.step1.search.empty": "没有匹配的数据源", + "connection_modal.step1.search.placeholder": "搜索数据源名称", "connection_modal.switch.off": "关", "connection_modal.switch.on": "开", "connection_modal.table.health": "健康", diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json index 97897da6..05da5855 100644 --- a/shared/i18n/zh-TW.json +++ b/shared/i18n/zh-TW.json @@ -3767,6 +3767,7 @@ "connection_modal.status.unhealthy": "異常", "connection_modal.step.select_source": "選擇資料來源", "connection_modal.step.select_source_description": "選擇要建立的連線類型。", + "connection_modal.step1.group.all": "全部", "connection_modal.step1.group.domestic": "國產資料庫", "connection_modal.step1.group.message_queue": "訊息佇列", "connection_modal.step1.group.nosql": "NoSQL 資料庫", @@ -3786,6 +3787,8 @@ "connection_modal.step1.hint.qdrant": "Collection 瀏覽、向量搜尋與 Payload 過濾", "connection_modal.step1.hint.redis": "單機 / 叢集", "connection_modal.step1.hint.standard": "標準連線設定", + "connection_modal.step1.search.empty": "沒有符合的資料來源", + "connection_modal.step1.search.placeholder": "搜尋資料來源名稱", "connection_modal.switch.off": "關", "connection_modal.switch.on": "開", "connection_modal.table.health": "健康",