This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new 76cea71e4 fix(web): localize dashboard copy and guard the template 
save (#5873)
76cea71e4 is described below

commit 76cea71e4003701ed5e9e802cfb48d22ff91ce0a
Author: btlqql <[email protected]>
AuthorDate: Mon Oct 12 16:12:48 2026 +0800

    fix(web): localize dashboard copy and guard the template save (#5873)
    
    Three fixes from the same author for user-visible copy and messaging on the 
home and AI
    surfaces — two locale gaps and one button that invites a spurious error 
toast — folded into
    a single commit.
    
    - #5873 `web/src/pages/home/dashboard.tsx:168`, `:169` and `:172` hard-code 
the load-failure
      Alert's message, description and retry button in Chinese, the only 
untranslated copy left on a
      page where everything else goes through `t()`, so an en-locale user whose 
`getDashboard` call
      fails (`:161-177`) reads a Chinese error block; switches the three 
strings to
      `t('dashboard.loadFailed')`, `t('dashboard.loadFailedDescription')` and 
the already-existing
      `t('common.retry')` (`web/src/i18n/translations.ts:77`), adding the two 
new keys to the
      Dashboard block with zh and en values side by side.
    - #5874 `web/src/pages/home/DashboardTrafficInsights.tsx:159` interpolates 
a full-width colon and
      joins the findings with `、` regardless of locale, rendering
      `Traffic signals to review:prod carries 88.6%…、canary …` under en — the 
only hard-coded CJK
      punctuation in the file; reads `lang` from the existing `useLang()` call 
(`:67`) and derives a
      list separator (`、` for zh, `; ` otherwise) and a label separator (`:` 
for zh, `: ` otherwise),
      the convention trunk already follows in `pages/instance/index.tsx:408` and
      `components/MiniBar.tsx:50`, so no new i18n key is needed — 
`dashboardTraffic.findings` already
      exists in both locales.
    - #5875 `web/src/pages/ai/components/PromptTemplateModal.tsx:370` gates the 
"save current input"
      button on `!inputValue.trim()` alone, but a successful save clears the 
title (`:162`) while the
      composer draft stays put, so a second click still reaches 
`handleSaveCurrentPromptTemplate`
      (`:143`), `saveCustomPromptTemplate` returns `reason: 'empty_title'`, and
      `ai.promptTemplates.titleRequired` (`:153-154`) pops an error toast right 
next to the success
      one; adds `|| !customTemplateTitle.trim()` to the same `disabled` 
expression, leaving the
      `empty_title` branch in place as defence in depth.
    
    Three new cases, one per member, all mutation-sensitive: reverting the 
dashboard copy leaves
    `findByText('Failed to load dashboard')` and the `Retry` button unmatched 
and reverting either new
    translation key makes `t()` fall back to the key itself, reverting the 
separator derivation fails
    both `not.toContain('、')` and `not.toContain(':')` against an en render 
whose fixture produces
    at least two findings, and reverting the save guard makes the second click 
surface
    `Enter a template title` while the accompanying 
`loadPromptTemplateCatalog()` check still pins
    that only one custom template was written.
    
    Folded: #5874, #5875 were closed as folded into this commit (same author).
---
 web/src/i18n/translations.ts                       |  5 ++
 .../ai/__tests__/PromptTemplateModal.test.tsx      | 87 ++++++++++++++++++++++
 .../pages/ai/components/PromptTemplateModal.tsx    |  2 +-
 web/src/pages/home/DashboardTrafficInsights.tsx    |  8 +-
 .../pages/home/__tests__/DashboardPage.test.tsx    | 21 +++++-
 .../__tests__/DashboardTrafficInsights.test.tsx    | 24 +++++-
 web/src/pages/home/dashboard.tsx                   |  6 +-
 7 files changed, 145 insertions(+), 8 deletions(-)

diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index b9a607189..7e33dc2bf 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -105,6 +105,11 @@ const translations: Record<string, Record<Lang, string>> = 
{
   'dashboard.clusterHealth': { zh: '集群健康概览', en: 'Cluster Health' },
   'dashboard.clusterName': { zh: '集群名称', en: 'Cluster Name' },
   'dashboard.allInstances': { zh: '全部已配置实例', en: 'All configured instances' },
+  'dashboard.loadFailed': { zh: '仪表盘加载失败', en: 'Failed to load dashboard' },
+  'dashboard.loadFailedDescription': {
+    zh: '无法获取集群概览,请检查网络连接后重试。',
+    en: 'Could not load the cluster overview. Check the connection and retry.',
+  },
   'dashboard.instanceFilter': { zh: '实例筛选', en: 'Instance filter' },
   'dashboard.broker': { zh: 'Broker', en: 'Broker' },
   'dashboard.proxy': { zh: 'Proxy', en: 'Proxy' },
diff --git a/web/src/pages/ai/__tests__/PromptTemplateModal.test.tsx 
b/web/src/pages/ai/__tests__/PromptTemplateModal.test.tsx
new file mode 100644
index 000000000..f21e2fc72
--- /dev/null
+++ b/web/src/pages/ai/__tests__/PromptTemplateModal.test.tsx
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { App } from 'antd';
+import { LangProvider } from '../../../i18n/LangContext';
+import PromptTemplateModal from '../components/PromptTemplateModal';
+import { loadPromptTemplateCatalog } from '../promptTemplates';
+
+const before = () => {
+  localStorage.setItem('rocketmq-studio-language', 'en');
+  render(
+    <App>
+      <LangProvider>
+        <PromptTemplateModal
+          open
+          inputValue="diagnose consumer lag on orders topic"
+          mode="diagnose"
+          enhance={false}
+          onClose={vi.fn()}
+          onApply={vi.fn()}
+        />
+      </LangProvider>
+    </App>,
+  );
+};
+
+describe('PromptTemplateModal', () => {
+  beforeAll(() => {
+    Object.defineProperty(window, 'matchMedia', {
+      writable: true,
+      value: vi.fn().mockImplementation((query: string) => ({
+        matches: false,
+        media: query,
+        onchange: null,
+        addListener: vi.fn(),
+        removeListener: vi.fn(),
+        addEventListener: vi.fn(),
+        removeEventListener: vi.fn(),
+        dispatchEvent: vi.fn(),
+      })),
+    });
+  });
+
+  beforeEach(() => {
+    localStorage.clear();
+  });
+
+  it('a second click on Save after a successful save neither saves nor 
errors', async () => {
+    const user = userEvent.setup();
+    before();
+
+    await user.type(screen.getByLabelText('Template title'), 'lag runbook');
+    await user.click(screen.getByRole('button', { name: 'Save' }));
+
+    const saved = loadPromptTemplateCatalog().templates.filter(
+      (template) => template.scope === 'custom',
+    );
+    expect(saved).toHaveLength(1);
+    expect(saved[0]?.title).toBe('lag runbook');
+
+    // Double-click / repeated Enter lands on the Save button again right 
after the
+    // first save cleared the title: that must be a no-op, not a "title 
required"
+    // error toast next to a success toast.
+    await user.click(screen.getByRole('button', { name: 'Save' }));
+
+    expect(screen.queryByText('Enter a template 
title')).not.toBeInTheDocument();
+    expect(
+      loadPromptTemplateCatalog().templates.filter((template) => 
template.scope === 'custom'),
+    ).toHaveLength(1);
+  });
+});
diff --git a/web/src/pages/ai/components/PromptTemplateModal.tsx 
b/web/src/pages/ai/components/PromptTemplateModal.tsx
index ae28c17e8..fe236cec0 100644
--- a/web/src/pages/ai/components/PromptTemplateModal.tsx
+++ b/web/src/pages/ai/components/PromptTemplateModal.tsx
@@ -367,7 +367,7 @@ const PromptTemplateModal = ({
             />
             <Button
               icon={<Plus size={16} />}
-              disabled={!inputValue.trim()}
+              disabled={!inputValue.trim() || !customTemplateTitle.trim()}
               onClick={handleSaveCurrentPromptTemplate}
             >
               {t('common.save')}
diff --git a/web/src/pages/home/DashboardTrafficInsights.tsx 
b/web/src/pages/home/DashboardTrafficInsights.tsx
index b2d36ba20..7307a358e 100644
--- a/web/src/pages/home/DashboardTrafficInsights.tsx
+++ b/web/src/pages/home/DashboardTrafficInsights.tsx
@@ -64,8 +64,12 @@ const issueTextKey = (issue: DashboardTrafficIssue) => {
 };
 
 const DashboardTrafficInsights = ({ insights }: Props) => {
-  const { t } = useLang();
+  const { t, lang } = useLang();
   const visibleIssues = insights.issues.slice(0, 4);
+  // Locale-aware separators: the findings line is prose, and CJK punctuation 
in an
+  // English sentence reads as noise. Same convention as instance batch-delete 
toasts.
+  const listSeparator = lang === 'zh' ? '、' : '; ';
+  const labelSeparator = lang === 'zh' ? ':' : ': ';
 
   const issueText = (issue: DashboardTrafficIssue) =>
     t(issueTextKey(issue), {
@@ -156,7 +160,7 @@ const DashboardTrafficInsights = ({ insights }: Props) => {
                 ? 'warning'
                 : 'info'
           }
-          
message={`${t('dashboardTraffic.findings')}:${visibleIssues.map(issueText).join('、')}`}
+          
message={`${t('dashboardTraffic.findings')}${labelSeparator}${visibleIssues.map(issueText).join(listSeparator)}`}
           style={{ marginBottom: 16 }}
         />
       )}
diff --git a/web/src/pages/home/__tests__/DashboardPage.test.tsx 
b/web/src/pages/home/__tests__/DashboardPage.test.tsx
index 1533a4f5b..2b533e233 100644
--- a/web/src/pages/home/__tests__/DashboardPage.test.tsx
+++ b/web/src/pages/home/__tests__/DashboardPage.test.tsx
@@ -10,9 +10,10 @@ import { render, screen, waitFor, within } from 
'@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import type React from 'react';
 import { MemoryRouter, useLocation } from 'react-router-dom';
-import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 
'vitest';
 import type { DashboardData } from '../../../api/metrics';
 import { LangProvider } from '../../../i18n/LangContext';
+import { LANGUAGE_STORAGE_KEY } from '../../../i18n/languagePreference';
 import * as dashboardService from '../../../services/dashboardService';
 import * as instanceService from '../../../services/instanceService';
 import DashboardPage from '../dashboard';
@@ -160,6 +161,12 @@ beforeEach(() => {
   ]);
 });
 
+afterEach(() => {
+  // The English-locale case below writes the language preference; without 
this the rest of the file
+  // (and anything appended later) silently depends on test order.
+  localStorage.removeItem(LANGUAGE_STORAGE_KEY);
+});
+
 describe('DashboardPage', () => {
   it('renders unavailable Proxy topology counts as N/A instead of zero', async 
() => {
     
vi.mocked(dashboardService.getDashboard).mockResolvedValue(unavailableTopologyDashboard());
@@ -323,4 +330,16 @@ describe('DashboardPage', () => {
       screen.queryByText('cloud-instance', { selector: 
'.ant-select-item-option-content' }),
     ).not.toBeInTheDocument();
   });
+
+  it('localizes the load-error alert for English users', async () => {
+    localStorage.setItem(LANGUAGE_STORAGE_KEY, 'en');
+    vi.mocked(dashboardService.getDashboard).mockRejectedValue(new 
Error('boom'));
+    vi.mocked(instanceService.listInstances).mockResolvedValue([]);
+
+    renderWithProviders(<DashboardPage />);
+
+    expect(await screen.findByText('Failed to load 
dashboard')).toBeInTheDocument();
+    expect(screen.queryByText('仪表盘加载失败')).not.toBeInTheDocument();
+    expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
+  });
 });
diff --git a/web/src/pages/home/__tests__/DashboardTrafficInsights.test.tsx 
b/web/src/pages/home/__tests__/DashboardTrafficInsights.test.tsx
index d5d0198a8..2c2fc1e8f 100644
--- a/web/src/pages/home/__tests__/DashboardTrafficInsights.test.tsx
+++ b/web/src/pages/home/__tests__/DashboardTrafficInsights.test.tsx
@@ -17,9 +17,10 @@
 
 import { App } from 'antd';
 import { render, screen } from '@testing-library/react';
-import { beforeAll, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
 import type { DashboardData } from '../../../api/metrics';
 import { LangProvider } from '../../../i18n/LangContext';
+import { LANGUAGE_STORAGE_KEY } from '../../../i18n/languagePreference';
 import { buildDashboardTrafficInsights } from 
'../../../utils/dashboardTrafficInsights';
 import DashboardTrafficInsights from '../DashboardTrafficInsights';
 
@@ -108,6 +109,10 @@ beforeAll(() => {
   });
 });
 
+afterEach(() => {
+  localStorage.removeItem(LANGUAGE_STORAGE_KEY);
+});
+
 describe('DashboardTrafficInsights', () => {
   it('renders traffic findings and summary cards without duplicating cluster 
rows', () => {
     renderPanel();
@@ -153,4 +158,21 @@ describe('DashboardTrafficInsights', () => {
     // Findings render as one joined line, so match on a substring rather than 
the exact node text.
     expect(screen.getByText(/未检测到活跃流量/u)).toBeInTheDocument();
   });
+
+  /**
+   * Both branches of the separator switch, not just the one being added: 
asserting English alone
+   * leaves `lang === 'zh' ? '、' : '; '` free to collapse to a constant 
without anything going red.
+   */
+  it.each([
+    { lang: 'zh', heading: '需要关注的流量信号', label: ':', list: '、', other: '; ' },
+    { lang: 'en', heading: 'Traffic signals to review', label: ': ', list: '; 
', other: '、' },
+  ])('formats findings with the $lang separators', ({ lang, heading, label, 
list, other }) => {
+    localStorage.setItem(LANGUAGE_STORAGE_KEY, lang);
+    renderPanel();
+
+    const text = screen.getByText(new RegExp(heading, 'u')).textContent ?? '';
+    expect(text).toContain(`${heading}${label}`);
+    expect(text).toContain(list);
+    expect(text).not.toContain(other);
+  });
 });
diff --git a/web/src/pages/home/dashboard.tsx b/web/src/pages/home/dashboard.tsx
index 396909dde..87561e9ae 100644
--- a/web/src/pages/home/dashboard.tsx
+++ b/web/src/pages/home/dashboard.tsx
@@ -165,11 +165,11 @@ const DashboardPage = () => {
         <Alert
           type="error"
           showIcon
-          message="仪表盘加载失败"
-          description="无法获取集群概览,请检查网络连接后重试。"
+          message={t('dashboard.loadFailed')}
+          description={t('dashboard.loadFailedDescription')}
           action={
             <Button size="small" onClick={() => void loadDashboard()} 
loading={loading}>
-              重试
+              {t('common.retry')}
             </Button>
           }
         />

Reply via email to