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>
   );
 };

Reply via email to