This is an automated email from the ASF dual-hosted git repository.
guoqqqi 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 952c0534c fix: give each page a distinct localized document title
(#3441)
952c0534c is described below
commit 952c0534c725b5e84685ed1135fb58537dfce6cf
Author: Yuhan <[email protected]>
AuthorDate: Tue Jul 28 13:56:34 2026 +0800
fix: give each page a distinct localized document title (#3441)
---
e2e/tests/regression/router.document-title.spec.ts | 94 +++++++++++++++++++
src/hooks/useDocumentTitle.test.ts | 96 ++++++++++++++++++++
src/hooks/useDocumentTitle.ts | 101 +++++++++++++++++++++
src/routes/__root.tsx | 14 ++-
4 files changed, 303 insertions(+), 2 deletions(-)
diff --git a/e2e/tests/regression/router.document-title.spec.ts
b/e2e/tests/regression/router.document-title.spec.ts
new file mode 100644
index 000000000..df851f51a
--- /dev/null
+++ b/e2e/tests/regression/router.document-title.spec.ts
@@ -0,0 +1,94 @@
+/**
+ * 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 a UX/IA item of apache/apisix-dashboard#3417: every page
+// shipped the same static document.title. Titles are now derived per
+// route (section name + Add/Detail qualifier) from the nav route table.
+
+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 { uiGoto } from '@e2e/utils/ui';
+import { expect } from '@playwright/test';
+
+import { deleteAllConsumers } from '@/apis/consumers';
+import { API_SECRETS } from '@/config/constant';
+
+test('each page has a distinct, section-specific document title', async ({
+ page,
+}) => {
+ await routesPom.toIndex(page);
+ await routesPom.isIndexPage(page);
+ await expect(page).toHaveTitle(/^Routes - /);
+
+ await uiGoto(page, '/upstreams');
+ await expect(page).toHaveTitle(/^Upstreams - /);
+
+ // longest-prefix match: /consumer_groups must not resolve as /consumers
+ await uiGoto(page, '/consumer_groups');
+ await expect(page).toHaveTitle(/^Consumer Groups - /);
+
+ await uiGoto(page, '/consumers');
+ await expect(page).toHaveTitle(/^Consumers - /);
+
+ // add and detail qualifiers
+ await routesPom.toIndex(page);
+ await routesPom.getAddRouteBtn(page).click();
+ await routesPom.isAddPage(page);
+ await expect(page).toHaveTitle(/Add .* - /);
+});
+
+// #3441 review: a nested resource page must be classified by the deepest
+// matched route, not by the parent `detail/$id` in the middle of the path.
+test('nested resource pages get their own title, not the parent detail', async
({
+ page,
+}) => {
+ const username = randomId('title_c');
+ await e2eReq.put(`/consumers/${username}`, { username });
+
+ await uiGoto(page, '/consumers/detail/$username', { username });
+ await expect(page).toHaveTitle(/^Consumers Detail - /);
+
+ await uiGoto(page, '/consumers/detail/$username/credentials/add', {
+ username,
+ });
+ // must be the credential add page, NOT "Consumers Detail"
+ await expect(page).toHaveTitle(/^Add Credentials - /);
+
+ await deleteAllConsumers(e2eReq);
+});
+
+// #3441 review: /secrets/detail/$manager/$id ends with TWO params; a
+// classifier that only inspected the second-to-last segment fell through
+// to the list branch and produced the generic application title.
+test('a detail route with two trailing params still gets its title', async ({
+ page,
+}) => {
+ const id = randomId('title-secret');
+ const manager = 'vault';
+ await e2eReq.put(`${API_SECRETS}/${manager}/${id}`, {
+ uri: 'http://vault.example.com:8200',
+ prefix: 'apisix',
+ token: 'title-token',
+ });
+
+ await uiGoto(page, '/secrets/detail/$manager/$id', { manager, id });
+ await expect(page).toHaveTitle(/^Secrets Detail - /);
+
+ await e2eReq.delete(`${API_SECRETS}/${manager}/${id}`).catch(() => {});
+});
diff --git a/src/hooks/useDocumentTitle.test.ts
b/src/hooks/useDocumentTitle.test.ts
new file mode 100644
index 000000000..fa56f6f25
--- /dev/null
+++ b/src/hooks/useDocumentTitle.test.ts
@@ -0,0 +1,96 @@
+/**
+ * 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.
+ */
+import { describe, expect, it } from 'vitest';
+
+import { classifyRouteId } from './useDocumentTitle';
+
+// Every route pattern the generated tree actually produces, so a new
+// route shape cannot silently fall back to the generic app title.
+describe('classifyRouteId', () => {
+ it('classifies top-level list / add / detail routes', () => {
+ expect(classifyRouteId('/routes/')).toEqual({
+ label: 'routes',
+ action: 'list',
+ });
+ expect(classifyRouteId('/routes/add')).toEqual({
+ label: 'routes',
+ action: 'add',
+ });
+ expect(classifyRouteId('/routes/detail/$id')).toEqual({
+ label: 'routes',
+ action: 'detail',
+ });
+ expect(classifyRouteId('/consumer_groups/detail/$id')).toEqual({
+ label: 'consumerGroups',
+ action: 'detail',
+ });
+ expect(classifyRouteId('/stream_routes/add')).toEqual({
+ label: 'streamRoutes',
+ action: 'add',
+ });
+ });
+
+ // #3441 review: /secrets/detail/$manager/$id ends with TWO params, so a
+ // check that only looked at the second-to-last segment fell through to
+ // the list branch, picked $manager as the resource and returned null —
+ // the page got the generic application title.
+ it('classifies a detail route with multiple trailing params', () => {
+ expect(classifyRouteId('/secrets/detail/$manager/$id')).toEqual({
+ label: 'secrets',
+ action: 'detail',
+ });
+ });
+
+ it('classifies nested resources by their own leaf, not the parent', () => {
+ expect(classifyRouteId('/services/detail/$id/routes/')).toEqual({
+ label: 'routes',
+ action: 'list',
+ });
+ expect(classifyRouteId('/services/detail/$id/routes/add')).toEqual({
+ label: 'routes',
+ action: 'add',
+ });
+ expect(
+ classifyRouteId('/services/detail/$id/routes/detail/$routeId')
+ ).toEqual({ label: 'routes', action: 'detail' });
+ expect(
+ classifyRouteId('/services/detail/$id/stream_routes/detail/$routeId')
+ ).toEqual({ label: 'streamRoutes', action: 'detail' });
+
expect(classifyRouteId('/consumers/detail/$username/credentials/add')).toEqual(
+ { label: 'credentials', action: 'add' }
+ );
+ expect(
+ classifyRouteId('/consumers/detail/$username/credentials/detail/$id')
+ ).toEqual({ label: 'credentials', action: 'detail' });
+ });
+
+ it('still classifies the parent detail page itself', () => {
+ expect(classifyRouteId('/services/detail/$id/')).toEqual({
+ label: 'services',
+ action: 'detail',
+ });
+ expect(classifyRouteId('/consumers/detail/$username/')).toEqual({
+ label: 'consumers',
+ action: 'detail',
+ });
+ });
+
+ it('returns null for routes outside the resource sections', () => {
+ expect(classifyRouteId('/')).toBeNull();
+ expect(classifyRouteId('/__root__')).toBeNull();
+ });
+});
diff --git a/src/hooks/useDocumentTitle.ts b/src/hooks/useDocumentTitle.ts
new file mode 100644
index 000000000..9a4f2294f
--- /dev/null
+++ b/src/hooks/useDocumentTitle.ts
@@ -0,0 +1,101 @@
+/**
+ * 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.
+ */
+import { useRouterState } from '@tanstack/react-router';
+import { useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import type { Resources } from '@/config/i18n';
+import { navRoutes } from '@/config/navRoutes';
+
+// matches the static <title> in index.html (the default before any route
+// resolves and the suffix on every page)
+const APP_NAME = 'Apache APISIX Dashboard';
+
+type SourceLabel = keyof Resources['en']['common']['sources'];
+
+// path segment -> sources i18n label. Built from the nav table (the single
+// source of truth), plus `credentials`, which is only ever a nested
+// resource (never a top-level nav entry).
+const segmentToLabel: Record<string, SourceLabel> = {
+ ...Object.fromEntries(
+ navRoutes.map((r) => [r.to.replace(/^\//, ''), r.label])
+ ),
+ credentials: 'credentials',
+};
+
+const isParam = (seg: string) => seg.startsWith('$');
+
+export type RouteTitleParts = {
+ label: SourceLabel;
+ action: 'add' | 'detail' | 'list';
+};
+
+/**
+ * Classify the DEEPEST matched route by its pattern (e.g.
+ * `/services/detail/$id/routes/add`).
+ *
+ * All TRAILING params are stripped first, so routes with more than one
+ * (`/secrets/detail/$manager/$id`) are recognised as detail pages too
+ * (#3441 review). The action then comes from the tail of what remains, so
+ * a `detail/$id` in the MIDDLE (navigation context) never wins: a nested
+ * `…/routes/add` is "Add Route", not "Service Detail".
+ */
+export const classifyRouteId = (routeId: string): RouteTitleParts | null => {
+ const segs = routeId.split('/').filter(Boolean);
+ while (segs.length > 0 && isParam(segs[segs.length - 1])) segs.pop();
+ if (segs.length === 0) return null;
+
+ const last = segs[segs.length - 1];
+ const action: RouteTitleParts['action'] =
+ last === 'add' ? 'add' : last === 'detail' ? 'detail' : 'list';
+ // for add/detail the resource is the segment before the action keyword;
+ // for a list page the leaf segment IS the resource
+ const resource = action === 'list' ? last : segs[segs.length - 2];
+
+ const label = resource ? segmentToLabel[resource] : undefined;
+ return label ? { label, action } : null;
+};
+
+/**
+ * Give every page a distinct, localized document.title instead of the one
+ * static title the app shipped with (#3417). Derived centrally from the
+ * deepest matched route + the nav route table, reactive to both
+ * navigation and language change — no per-route boilerplate.
+ */
+export const useDocumentTitle = () => {
+ const { t, i18n } = useTranslation();
+ const routeId = useRouterState({
+ select: (s) => s.matches[s.matches.length - 1]?.routeId as string,
+ });
+
+ useEffect(() => {
+ const matched = routeId ? classifyRouteId(routeId) : null;
+ let title = APP_NAME;
+ if (matched) {
+ const name = t(`sources.${matched.label}`);
+ if (matched.action === 'add') {
+ title = `${t('info.add.title', { name })} - ${APP_NAME}`;
+ } else if (matched.action === 'detail') {
+ title = `${t('info.detail.title', { name })} - ${APP_NAME}`;
+ } else {
+ title = `${name} - ${APP_NAME}`;
+ }
+ }
+ document.title = title;
+ // i18n.language in deps so the title re-localizes on a language switch
+ }, [routeId, t, i18n.language]);
+};
diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx
index e9bc3031d..d9cdbe689 100644
--- a/src/routes/__root.tsx
+++ b/src/routes/__root.tsx
@@ -37,11 +37,13 @@ import {
APPSHELL_NAVBAR_WIDTH,
} from '@/config/constant';
import i18n from '@/config/i18n';
+import { useDocumentTitle } from '@/hooks/useDocumentTitle';
-const Root = () => {
+const RootShell = () => {
const [opened, { toggle }] = useDisclosure(false);
+ useDocumentTitle();
return (
- <I18nextProvider i18n={i18n}>
+ <>
<HeadContent />
<AppShell
header={{ height: APPSHELL_HEADER_HEIGHT }}
@@ -67,6 +69,14 @@ const Root = () => {
</>
)}
<SettingsModal />
+ </>
+ );
+};
+
+const Root = () => {
+ return (
+ <I18nextProvider i18n={i18n}>
+ <RootShell />
</I18nextProvider>
);
};