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 045e31428 feat: link a plugin's documentation from the plugin editor 
(#3462)
045e31428 is described below

commit 045e3142867e3b7d5d1b8ec40bf8f66a7ce24a64
Author: Yuhan <[email protected]>
AuthorDate: Tue Aug 4 14:15:20 2026 +0800

    feat: link a plugin's documentation from the plugin editor (#3462)
---
 e2e/tests/regression/plugin.docs-link.spec.ts      | 137 +++++++++++++++++++++
 .../form-slice/FormItemPlugins/PluginCard.tsx      |   9 +-
 .../form-slice/FormItemPlugins/PluginDocsLink.tsx  |  77 ++++++++++++
 .../FormItemPlugins/PluginEditorDrawer.tsx         |   8 +-
 src/locales/de/common.json                         |   1 +
 src/locales/en/common.json                         |   1 +
 src/locales/es/common.json                         |   1 +
 src/locales/tr/common.json                         |   1 +
 src/locales/zh/common.json                         |   1 +
 src/utils/pluginDocs.test.ts                       |  74 +++++++++++
 src/utils/pluginDocs.ts                            |  56 +++++++++
 11 files changed, 359 insertions(+), 7 deletions(-)

diff --git a/e2e/tests/regression/plugin.docs-link.spec.ts 
b/e2e/tests/regression/plugin.docs-link.spec.ts
new file mode 100644
index 000000000..6c5977dba
--- /dev/null
+++ b/e2e/tests/regression/plugin.docs-link.spec.ts
@@ -0,0 +1,137 @@
+/**
+ * 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 { routesPom } from '@e2e/pom/routes';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { expect } from '@playwright/test';
+
+import { deleteAllRoutes } from '@/apis/routes';
+
+// #3461: the plugin editor never said what a plugin does. The gateway
+// carries no description for any plugin (0 of 106 have a
+// `schema.description`), so the docs site is the only source, and its URL
+// is derivable from the plugin name.
+
+const DOCS = 'https://apisix.apache.org';
+
+test.beforeAll(async () => {
+  await deleteAllRoutes(e2eReq);
+});
+
+test.afterEach(async ({ page }) => {
+  await page
+    .evaluate(() => localStorage.removeItem('settings:lang'))
+    .catch(() => {});
+});
+
+test.afterAll(async () => {
+  await deleteAllRoutes(e2eReq);
+});
+
+const openPicker = async (page: import('@playwright/test').Page) => {
+  await routesPom.toAdd(page);
+  await routesPom.isAddPage(page);
+  await page.getByRole('button', { name: 'Select Plugins' }).click();
+  const picker = page.getByRole('dialog', { name: 'Select Plugins' });
+  await expect(picker).toBeVisible();
+  return picker;
+};
+
+const openPluginDrawer = async (
+  page: import('@playwright/test').Page,
+  plugin: string
+) => {
+  const picker = await openPicker(page);
+  await picker.getByPlaceholder('Search').fill(plugin);
+  await picker
+    .getByTestId(`plugin-${plugin}`)
+    .getByRole('button', { name: 'Add' })
+    .click();
+  const drawer = page.getByRole('dialog', { name: 'Add Plugin' });
+  await expect(drawer).toBeVisible();
+  return drawer;
+};
+
+test('the plugin editor links that plugin documentation', async ({ page }) => {
+  const drawer = await openPluginDrawer(page, 'key-auth');
+
+  // getByRole('link') is the load-bearing part: it fails if the control
+  // ever becomes a <button href>, which cannot be cmd-clicked or copied.
+  const link = drawer.getByRole('link', { name: 'Docs' });
+  await expect(link).toHaveAttribute(
+    'href',
+    `${DOCS}/docs/apisix/plugins/key-auth/`
+  );
+  await expect(link).toHaveAttribute('target', '_blank');
+});
+
+test('a plugin with no documentation page gets no link', async ({ page }) => {
+  const drawer = await openPluginDrawer(page, 'example-plugin');
+
+  // example-plugin is the gateway's sample plugin and has no docs page.
+  // A link here would be a broken link, which is worse than none.
+  await expect(drawer.getByRole('link', { name: 'Docs' })).toHaveCount(0);
+});
+
+test('the documentation link follows the UI language', async ({ page }) => {
+  await routesPom.toIndex(page);
+  await routesPom.isIndexPage(page);
+  await page.locator('button[aria-haspopup="menu"]').click();
+  await page.getByRole('menuitem', { name: '中文' }).click();
+  await expect(
+    page.getByRole('link', { name: '路由', exact: true })
+  ).toBeVisible();
+
+  await routesPom.toAdd(page);
+  await page.getByRole('button', { name: '选择插件' }).click();
+  const picker = page.getByRole('dialog', { name: '选择插件' });
+  await picker.getByPlaceholder('搜索').fill('key-auth');
+  await picker
+    .getByTestId('plugin-key-auth')
+    .getByRole('button', { name: '新增' })
+    .click();
+  const drawer = page.getByRole('dialog', { name: '添加插件' });
+  await expect(drawer.getByRole('link', { name: '文档' })).toHaveAttribute(
+    'href',
+    `${DOCS}/zh/docs/apisix/plugins/key-auth/`
+  );
+});
+
+test('a picker card links that plugin documentation', async ({ page }) => {
+  const picker = await openPicker(page);
+  await picker.getByPlaceholder('Search').fill('key-auth');
+
+  // Icon-only: the accessible name is what tells a screen reader which of
+  // a hundred identical icons this one belongs to.
+  const link = picker
+    .getByTestId('plugin-key-auth')
+    .getByRole('link', { name: 'key-auth documentation' });
+  await expect(link).toHaveAttribute(
+    'href',
+    `${DOCS}/docs/apisix/plugins/key-auth/`
+  );
+});
+
+test('a picker card for an undocumented plugin has no link', async ({
+  page,
+}) => {
+  const picker = await openPicker(page);
+  await picker.getByPlaceholder('Search').fill('example-plugin');
+  await expect(
+    picker.getByTestId('plugin-example-plugin').getByRole('link')
+  ).toHaveCount(0);
+});
diff --git a/src/components/form-slice/FormItemPlugins/PluginCard.tsx 
b/src/components/form-slice/FormItemPlugins/PluginCard.tsx
index bd6ad045f..d0ace6ecd 100644
--- a/src/components/form-slice/FormItemPlugins/PluginCard.tsx
+++ b/src/components/form-slice/FormItemPlugins/PluginCard.tsx
@@ -18,6 +18,8 @@ import { Button, Card,Group, Text } from '@mantine/core';
 import { modals } from '@mantine/modals';
 import { useTranslation } from 'react-i18next';
 
+import { PluginDocsLink } from './PluginDocsLink';
+
 export type PluginCardProps = {
   name: string;
   desc?: string;
@@ -34,10 +36,9 @@ export const PluginCard = (props: PluginCardProps) => {
   return (
     <Card withBorder radius="md" p="md" data-testid={`plugin-${name}`}>
       <Card.Section withBorder inheritPadding py="xs">
-        <Group justify="space-between">
-          <Group>
-            <Text fw={500}>{name}</Text>
-          </Group>
+        <Group justify="space-between" wrap="nowrap">
+          <Text fw={500}>{name}</Text>
+          <PluginDocsLink name={name} />
         </Group>
       </Card.Section>
 
diff --git a/src/components/form-slice/FormItemPlugins/PluginDocsLink.tsx 
b/src/components/form-slice/FormItemPlugins/PluginDocsLink.tsx
new file mode 100644
index 000000000..15c7dd8dc
--- /dev/null
+++ b/src/components/form-slice/FormItemPlugins/PluginDocsLink.tsx
@@ -0,0 +1,77 @@
+/**
+ * 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 { Anchor, Group, Tooltip } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+
+import { getPluginDocsUrl } from '@/utils/pluginDocs';
+import IconExternalLink from '~icons/tabler/external-link';
+
+export type PluginDocsLinkProps = {
+  name: string;
+  /** Show the visible label beside the icon. Icon-only otherwise. */
+  withLabel?: boolean;
+};
+
+/**
+ * A real `<a>`, not the repo's `RouteLinkBtn`, which is a Mantine `Button`
+ * carrying `href`: middle-click, cmd-click and "copy link address" are
+ * exactly what someone reaching for documentation does, and none of them
+ * work on a `<button>`.
+ *
+ * Renders nothing when the plugin has no page on the released docs —
+ * a missing affordance beats a broken link.
+ */
+export const PluginDocsLink = (props: PluginDocsLinkProps) => {
+  const { name, withLabel = false } = props;
+  const { t, i18n } = useTranslation();
+
+  const href = getPluginDocsUrl(name, i18n.language);
+  if (!href) return null;
+
+  // i18next escapes interpolated values by default; #3459 shipped an
+  // accessible name reading `R&amp;D` before that was caught. Plugin names
+  // are gateway-controlled and hyphenated, so this is prophylactic.
+  const named = t('form.plugins.docsFor', {
+    name,
+    interpolation: { escapeValue: false },
+  });
+
+  const link = (
+    <Anchor
+      href={href}
+      target="_blank"
+      rel="noopener noreferrer"
+      underline="never"
+      size="sm"
+      // With a visible label the label IS the accessible name; overriding
+      // it with text that does not contain the visible label would break
+      // WCAG 2.5.3. Icon-only has no visible text, so it needs one.
+      {...(!withLabel && { 'aria-label': named })}
+    >
+      {withLabel ? (
+        <Group component="span" gap={4} wrap="nowrap">
+          <IconExternalLink aria-hidden focusable="false" />
+          {t('docs')}
+        </Group>
+      ) : (
+        <IconExternalLink aria-hidden focusable="false" />
+      )}
+    </Anchor>
+  );
+
+  return withLabel ? link : <Tooltip label={named}>{link}</Tooltip>;
+};
diff --git a/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx 
b/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx
index f72e07685..b883d9bc0 100644
--- a/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx
+++ b/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx
@@ -27,6 +27,7 @@ import { FormSubmitBtn } from '@/components/form/Btn';
 import { FormItemEditor } from '@/components/form/Editor';
 
 import type { PluginCardListProps } from './PluginCardList';
+import { PluginDocsLink } from './PluginDocsLink';
 import { redactByPaths } from './redact';
 
 // PluginConfig is defined in the API layer (apis/plugins) and re-exported
@@ -113,9 +114,10 @@ export const PluginEditorDrawer = (props: 
PluginEditorDrawerProps) => {
       {...(mode === 'edit' && { title: t('form.plugins.editPlugin') })}
       {...(mode === 'view' && { title: t('form.plugins.viewPlugin') })}
     >
-      <Title order={3} mb={10}>
-        {name}
-      </Title>
+      <Group gap="xs" mb={10} align="center">
+        <Title order={3}>{name}</Title>
+        <PluginDocsLink name={name} withLabel />
+      </Group>
       <FormProvider {...methods}>
         {mode === 'view' && hasSecrets && (
           <Button
diff --git a/src/locales/de/common.json b/src/locales/de/common.json
index a030962b4..60b822fe1 100644
--- a/src/locales/de/common.json
+++ b/src/locales/de/common.json
@@ -66,6 +66,7 @@
     "plugins": {
       "addPlugin": "Plugin hinzufügen",
       "configId": "Plugin-Konfigurations-ID",
+      "docsFor": "Dokumentation zu {{name}}",
       "editPlugin": "Plugin bearbeiten",
       "hideSecrets": "Geheimnisse verbergen",
       "label": "Plugins",
diff --git a/src/locales/en/common.json b/src/locales/en/common.json
index 67c092583..b896a6ef9 100644
--- a/src/locales/en/common.json
+++ b/src/locales/en/common.json
@@ -66,6 +66,7 @@
     "plugins": {
       "addPlugin": "Add Plugin",
       "configId": "Plugin Config ID",
+      "docsFor": "{{name}} documentation",
       "editPlugin": "Edit Plugin",
       "hideSecrets": "Hide secrets",
       "label": "Plugins",
diff --git a/src/locales/es/common.json b/src/locales/es/common.json
index f34caa921..635c9a48e 100644
--- a/src/locales/es/common.json
+++ b/src/locales/es/common.json
@@ -66,6 +66,7 @@
     "plugins": {
       "addPlugin": "Añadir Plugin",
       "configId": "ID de Configuración de Plugin",
+      "docsFor": "Documentación de {{name}}",
       "editPlugin": "Editar Plugin",
       "hideSecrets": "Ocultar secretos",
       "label": "Plugins",
diff --git a/src/locales/tr/common.json b/src/locales/tr/common.json
index 816430647..ed8e03371 100644
--- a/src/locales/tr/common.json
+++ b/src/locales/tr/common.json
@@ -66,6 +66,7 @@
     "plugins": {
       "addPlugin": "Plugin Ekle",
       "configId": "Plugin Config ID",
+      "docsFor": "{{name}} dokümantasyonu",
       "editPlugin": "Plugin Düzenle",
       "hideSecrets": "Gizli değerleri gizle",
       "label": "Plugin'ler",
diff --git a/src/locales/zh/common.json b/src/locales/zh/common.json
index b803c02b5..84e323c40 100644
--- a/src/locales/zh/common.json
+++ b/src/locales/zh/common.json
@@ -66,6 +66,7 @@
     "plugins": {
       "addPlugin": "添加插件",
       "configId": "插件配置ID",
+      "docsFor": "{{name}} 文档",
       "editPlugin": "编辑插件",
       "hideSecrets": "隐藏密钥",
       "label": "插件",
diff --git a/src/utils/pluginDocs.test.ts b/src/utils/pluginDocs.test.ts
new file mode 100644
index 000000000..2e995f7ce
--- /dev/null
+++ b/src/utils/pluginDocs.test.ts
@@ -0,0 +1,74 @@
+/**
+ * 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 { getPluginDocsUrl } from './pluginDocs';
+
+const BASE = 'https://apisix.apache.org';
+
+describe('getPluginDocsUrl', () => {
+  it('builds the released-docs URL from the plugin name', () => {
+    expect(getPluginDocsUrl('key-auth', 'en')).toBe(
+      `${BASE}/docs/apisix/plugins/key-auth/`
+    );
+  });
+
+  it.each([
+    'serverless-pre-function',
+    'serverless-post-function',
+  ])('sends %s to the shared serverless page', (name) => {
+    expect(getPluginDocsUrl(name, 'en')).toBe(
+      `${BASE}/docs/apisix/plugins/serverless/`
+    );
+  });
+
+  // Verified against the live docs site on 2026-08-03: these are the only
+  // names with no page on the released docs. `example-plugin` is the
+  // gateway's sample and `ai` has no page of its own; the other three are
+  // newer than the current docs release.
+  it.each([
+    'example-plugin',
+    'ai',
+    'ai-cache',
+    'ai-lakera-guard',
+    'mcp-bridge',
+  ])('returns null for %s, which has no released docs page', (name) => {
+    expect(getPluginDocsUrl(name, 'en')).toBeNull();
+  });
+
+  it('uses the Chinese docs when the UI language is zh', () => {
+    expect(getPluginDocsUrl('key-auth', 'zh')).toBe(
+      `${BASE}/zh/docs/apisix/plugins/key-auth/`
+    );
+  });
+
+  // The docs site publishes en and zh only; /es/, /de/ and /tr/ are 404.
+  it.each(['es', 'de', 'tr'])(
+    'falls back to English for %s, which the docs site does not publish',
+    (language) => {
+      expect(getPluginDocsUrl('key-auth', language)).toBe(
+        `${BASE}/docs/apisix/plugins/key-auth/`
+      );
+    }
+  );
+
+  it('applies the zh prefix and the slug override together', () => {
+    expect(getPluginDocsUrl('serverless-pre-function', 'zh')).toBe(
+      `${BASE}/zh/docs/apisix/plugins/serverless/`
+    );
+  });
+});
diff --git a/src/utils/pluginDocs.ts b/src/utils/pluginDocs.ts
new file mode 100644
index 000000000..dea2b2767
--- /dev/null
+++ b/src/utils/pluginDocs.ts
@@ -0,0 +1,56 @@
+/**
+ * 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.
+ */
+const DOCS_ORIGIN = 'https://apisix.apache.org';
+
+/** Plugins whose documentation page is not named after the plugin. */
+const SLUG_OVERRIDES: Record<string, string> = {
+  // both halves are documented on one page
+  'serverless-pre-function': 'serverless',
+  'serverless-post-function': 'serverless',
+};
+
+/**
+ * Names with no page on the released docs. Two reasons, one consequence:
+ * `example-plugin` is the gateway's sample plugin and `ai` has no page of
+ * its own; the rest are newer than the current docs release and resolve
+ * only under `/next/`. Drop those from here once the released docs catch
+ * up — linking `/next/` instead would point users at documentation for a
+ * version they are not running.
+ */
+const WITHOUT_DOC_PAGE = new Set([
+  'example-plugin',
+  'ai',
+  'ai-cache',
+  'ai-lakera-guard',
+  'mcp-bridge',
+]);
+
+/**
+ * The docs site publishes English and Chinese only. `language` is one of
+ * the bare codes in `src/config/i18n.ts` (`en | zh | es | de | tr`) —
+ * `supportedLngs` is derived from the resource keys, so there are no
+ * regional variants to match loosely.
+ */
+export const getPluginDocsUrl = (
+  name: string,
+  language: string
+): string | null => {
+  if (WITHOUT_DOC_PAGE.has(name)) return null;
+  const slug = SLUG_OVERRIDES[name] ?? name;
+  const prefix = language === 'zh' ? '/zh' : '';
+  return `${DOCS_ORIGIN}${prefix}/docs/apisix/plugins/${slug}/`;
+};

Reply via email to