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

LiteSun pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-dashboard.git


The following commit(s) were added to refs/heads/master by this push:
     new 1cee49b6a fix: persist language, localize antd, degarble delete 
confirmation (#3440)
1cee49b6a is described below

commit 1cee49b6adf455f0f2ff5ab58eebca991f218988
Author: Yuhan <[email protected]>
AuthorDate: Tue Jul 28 14:41:59 2026 +0800

    fix: persist language, localize antd, degarble delete confirmation (#3440)
---
 .../regression/i18n.persistence-and-locale.spec.ts | 117 +++++++++++++++++++++
 src/components/page/DeleteResourceBtn.tsx          |  31 +++---
 src/config/antdConfigProvider.tsx                  |  19 +++-
 src/config/i18n.ts                                 |  32 +++++-
 src/locales/de/common.json                         |   3 +-
 src/locales/en/common.json                         |   3 +-
 src/locales/es/common.json                         |   3 +-
 src/locales/tr/common.json                         |   3 +-
 src/locales/zh/common.json                         |   3 +-
 9 files changed, 193 insertions(+), 21 deletions(-)

diff --git a/e2e/tests/regression/i18n.persistence-and-locale.spec.ts 
b/e2e/tests/regression/i18n.persistence-and-locale.spec.ts
new file mode 100644
index 000000000..d3a7087f3
--- /dev/null
+++ b/e2e/tests/regression/i18n.persistence-and-locale.spec.ts
@@ -0,0 +1,117 @@
+/**
+ * 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/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.
+ */
+
+// Regression for an i18n item of apache/apisix-dashboard#3417:
+// - the language choice was never persisted or detected (`lng: 'en'`
+//   hardcoded): a reload reset a zh user to English while the admin key
+//   IS persisted right next to it;
+// - antd's ConfigProvider was hardwired to enUS, so antd-rendered
+//   surfaces (list pagination etc.) stayed English after switching;
+// - the delete confirmation was assembled from three concatenated pieces
+//   (sentence + bold target + a '?'/'¿?' mark), producing garbled output
+//   in languages whose sentence already ends the question or places the
+//   verb last (es/de/tr).
+
+import { routesPom } from '@e2e/pom/routes';
+import { randomId } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { expect } from '@playwright/test';
+
+import { deleteAllRoutes } from '@/apis/routes';
+
+const switchLanguage = async (
+  page: import('@playwright/test').Page,
+  label: string
+) => {
+  await page.locator('button[aria-haspopup="menu"]').click();
+  await page.getByRole('menuitem', { name: label }).click();
+};
+
+test.beforeEach(async () => {
+  await deleteAllRoutes(e2eReq);
+});
+
+test.afterEach(async ({ page }) => {
+  await page
+    .evaluate(() => localStorage.removeItem('settings:lang'))
+    .catch(() => {});
+});
+
+test.afterAll(async () => {
+  await deleteAllRoutes(e2eReq);
+});
+
+test('language choice survives a reload', async ({ page }) => {
+  await routesPom.toIndex(page);
+  await routesPom.isIndexPage(page);
+
+  await switchLanguage(page, '中文');
+  await expect(page.getByRole('link', { name: '路由', exact: true 
})).toBeVisible();
+
+  await page.reload();
+  // unfixed: lng is hardcoded to 'en' and the choice is lost
+  await expect(page.getByRole('link', { name: '路由', exact: true 
})).toBeVisible();
+});
+
+test('antd-rendered pagination follows the app language', async ({ page }) => {
+  const names = Array.from({ length: 11 }, () => randomId('reg-antd-locale'));
+  for (const name of names) {
+    await e2eReq.put(`/routes/${name}`, {
+      name,
+      uri: `/reg-antd-locale/${name}`,
+      upstream: { type: 'roundrobin', nodes: { 'antd-locale.local:80': 1 } },
+    });
+  }
+
+  await routesPom.toIndex(page);
+  await routesPom.isIndexPage(page);
+  await switchLanguage(page, '中文');
+  await expect(page.getByRole('link', { name: '路由', exact: true 
})).toBeVisible();
+
+  // 11 rows → pagination size changer renders; its text comes from the
+  // antd locale (unfixed: hardwired enUS → "10 / page")
+  await expect(page.getByText(/条\/页/)).toBeVisible();
+});
+
+test('delete confirmation is one proper sentence in Spanish', async ({
+  page,
+}) => {
+  const name = randomId('reg-es-delete');
+  await e2eReq.put(`/routes/${name}`, {
+    name,
+    uri: `/reg-es-delete/${name}`,
+    upstream: { type: 'roundrobin', nodes: { 'es-delete.local:80': 1 } },
+  });
+
+  await routesPom.toIndex(page);
+  await routesPom.isIndexPage(page);
+  await switchLanguage(page, 'Español');
+
+  await page
+    .getByRole('row', { name })
+    .getByRole('button', { name: 'Eliminar' })
+    .click();
+  const dialog = page.getByRole('dialog');
+  await expect(dialog).toBeVisible();
+
+  const text = (await dialog.innerText()).replace(/\s+/g, ' ');
+  // unfixed: the es sentence already ends with '?', then the code appends
+  // the target and a literal '¿?' mark → '…la Ruta? <name> ¿?'
+  expect(text).not.toContain('¿?');
+  expect(text).toMatch(/¿[^?]+\?/);
+});
diff --git a/src/components/page/DeleteResourceBtn.tsx 
b/src/components/page/DeleteResourceBtn.tsx
index fcaddde03..38cb56dbe 100644
--- a/src/components/page/DeleteResourceBtn.tsx
+++ b/src/components/page/DeleteResourceBtn.tsx
@@ -19,7 +19,7 @@ import { useCallbackRef } from '@mantine/hooks';
 import { modals } from '@mantine/modals';
 import { notifications } from '@mantine/notifications';
 import { type AxiosResponse, isAxiosError } from 'axios';
-import { useTranslation } from 'react-i18next';
+import { Trans, useTranslation } from 'react-i18next';
 
 import { queryClient } from '@/config/global';
 import { req } from '@/config/req';
@@ -54,18 +54,23 @@ export const DeleteResourceBtn = (props: 
DeleteResourceProps) => {
       title: t('info.delete.title', { name: name }),
       children: (
         <Text>
-          {t('info.delete.content', { name: name })}
-          {target && (
-            <Text
-              component="span"
-              fw={700}
-              mx="0.25em"
-              style={{ wordBreak: 'break-all' }}
-            >
-              {target}
-            </Text>
-          )}
-          {t('mark.question')}
+          {/* one locale-owned sentence with the identifier in a bold slot
+              — the old three-piece concatenation (sentence + target + a
+              literal '?'/'¿?') garbled es/de/tr where the sentence
+              already ends the question (#3417) */}
+          <Trans
+            i18nKey="info.delete.confirm"
+            values={{ name: target || name }}
+            components={[
+              <Text
+                key="target"
+                component="span"
+                fw={700}
+                mx="0.25em"
+                style={{ wordBreak: 'break-all' }}
+              />,
+            ]}
+          />
         </Text>
       ),
       labels: { confirm: t('form.btn.delete'), cancel: t('form.btn.cancel') },
diff --git a/src/config/antdConfigProvider.tsx 
b/src/config/antdConfigProvider.tsx
index 13e393e6f..e8af7d8cb 100644
--- a/src/config/antdConfigProvider.tsx
+++ b/src/config/antdConfigProvider.tsx
@@ -17,18 +17,33 @@
 import '@ant-design/v5-patch-for-react-19';
 
 import { ConfigProvider } from 'antd';
+import deDE from 'antd/locale/de_DE';
 import enUS from 'antd/locale/en_US';
+import esES from 'antd/locale/es_ES';
+import trTR from 'antd/locale/tr_TR';
+import zhCN from 'antd/locale/zh_CN';
 import type { PropsWithChildren } from 'react';
 import { useTranslation } from 'react-i18next';
 
+// antd's own locale must follow the app language, or antd-rendered
+// surfaces (pagination, table controls) stay English after a switch
+// (#3417).
+const antdLocales: Record<string, typeof enUS> = {
+  en: enUS,
+  zh: zhCN,
+  es: esES,
+  de: deDE,
+  tr: trTR,
+};
+
 export const AntdConfigProvider = (props: PropsWithChildren) => {
   const { children } = props;
-  const { t } = useTranslation();
+  const { t, i18n } = useTranslation();
 
   return (
     <ConfigProvider
       virtual
-      locale={enUS}
+      locale={antdLocales[i18n.language] ?? enUS}
       renderEmpty={() => <div>{t('noData')}</div>}
       theme={{
         token: {
diff --git a/src/config/i18n.ts b/src/config/i18n.ts
index 3490d825e..6830c4bb4 100644
--- a/src/config/i18n.ts
+++ b/src/config/i18n.ts
@@ -44,12 +44,42 @@ export const resources = {
 export type Resources = typeof resources;
 export const defaultNS: keyof Resources['en'] = 'common';
 
+// Persist the language the same way the admin key is persisted (both are
+// user settings). A reload must not reset a zh/tr user to English (#3417).
+// We restore the STORED choice only and default to English otherwise —
+// deliberately not auto-detecting the browser language, which would
+// silently change the default for existing English users.
+export const LANG_STORAGE_KEY = 'settings:lang';
+
+const supportedLngs = Object.keys(resources) as (keyof Resources)[];
+
+const detectInitialLng = (): keyof Resources => {
+  try {
+    const stored = localStorage.getItem(LANG_STORAGE_KEY);
+    if (stored && (supportedLngs as string[]).includes(stored)) {
+      return stored as keyof Resources;
+    }
+  } catch {
+    // localStorage unavailable (SSR, privacy mode) — fall back to English
+  }
+  return 'en';
+};
+
 i18n.use(initReactI18next).init({
-  lng: 'en',
+  lng: detectInitialLng(),
+  supportedLngs,
   ns: ['common'],
   defaultNS,
   resources,
   fallbackLng: 'en',
 });
 
+i18n.on('languageChanged', (lng) => {
+  try {
+    localStorage.setItem(LANG_STORAGE_KEY, lng);
+  } catch {
+    // ignore persistence failures (privacy mode)
+  }
+});
+
 export default i18n;
diff --git a/src/locales/de/common.json b/src/locales/de/common.json
index 5c8a9ab7a..a84c56780 100644
--- a/src/locales/de/common.json
+++ b/src/locales/de/common.json
@@ -296,7 +296,8 @@
     "delete": {
       "content": "Möchten Sie {{name}} löschen?",
       "success": "{{name}} erfolgreich gelöscht",
-      "title": "{{name}} löschen"
+      "title": "{{name}} löschen",
+      "confirm": "Möchten Sie <0>{{name}}</0> wirklich löschen?"
     },
     "detail": {
       "title": "{{name}} Detail"
diff --git a/src/locales/en/common.json b/src/locales/en/common.json
index 2ebfb685e..e233fa1ba 100644
--- a/src/locales/en/common.json
+++ b/src/locales/en/common.json
@@ -296,7 +296,8 @@
     "delete": {
       "content": "Do you want to delete the {{name}}",
       "success": "Delete {{name}} Successfully",
-      "title": "Delete {{name}}"
+      "title": "Delete {{name}}",
+      "confirm": "Are you sure you want to delete <0>{{name}}</0>?"
     },
     "detail": {
       "title": "{{name}} Detail"
diff --git a/src/locales/es/common.json b/src/locales/es/common.json
index 7d8b3a8ee..58615f839 100644
--- a/src/locales/es/common.json
+++ b/src/locales/es/common.json
@@ -296,7 +296,8 @@
     "delete": {
       "content": "¿Desea eliminar el {{name}}?",
       "success": "Eliminado {{name}} con éxito",
-      "title": "Eliminar {{name}}"
+      "title": "Eliminar {{name}}",
+      "confirm": "¿Está seguro de que desea eliminar <0>{{name}}</0>?"
     },
     "detail": {
       "title": "Detalle de {{name}}"
diff --git a/src/locales/tr/common.json b/src/locales/tr/common.json
index e25f0ac9b..168d81543 100644
--- a/src/locales/tr/common.json
+++ b/src/locales/tr/common.json
@@ -296,7 +296,8 @@
     "delete": {
       "content": "{{name}} öğesini silmek istediğinizden emin misiniz?",
       "success": "{{name}} başarıyla silindi",
-      "title": "{{name}} Sil"
+      "title": "{{name}} Sil",
+      "confirm": "<0>{{name}}</0> öğesini silmek istediğinizden emin misiniz?"
     },
     "detail": {
       "title": "{{name}} Detay"
diff --git a/src/locales/zh/common.json b/src/locales/zh/common.json
index 1499a2151..15830a184 100644
--- a/src/locales/zh/common.json
+++ b/src/locales/zh/common.json
@@ -296,7 +296,8 @@
     "delete": {
       "content": "是否要删除 {{name}}",
       "success": "删除 {{name}} 成功",
-      "title": "删除 {{name}}"
+      "title": "删除 {{name}}",
+      "confirm": "确定要删除 <0>{{name}}</0> 吗?"
     },
     "detail": {
       "title": "{{name}} 详情"

Reply via email to