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}} 详情"