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 eac6f6667 feat: let a resource id be copied from the list (#3457)
eac6f6667 is described below

commit eac6f66673c331278f1ecd343eb82aa237cc836c
Author: Yuhan <[email protected]>
AuthorDate: Mon Aug 3 09:25:29 2026 +0800

    feat: let a resource id be copied from the list (#3457)
---
 e2e/tests/consumer_groups.crud-all-fields.spec.ts  | 14 +++-
 .../consumer_groups.crud-required-fields.spec.ts   | 10 ++-
 e2e/tests/regression/general.copyable-id.spec.ts   | 79 ++++++++++++++++++++++
 src/components/page/CopyableId.tsx                 | 68 +++++++++++++++++++
 src/locales/de/common.json                         |  2 +
 src/locales/en/common.json                         |  2 +
 src/locales/es/common.json                         |  2 +
 src/locales/tr/common.json                         |  2 +
 src/locales/zh/common.json                         |  2 +
 src/routes/consumer_groups/index.tsx               |  2 +
 src/routes/consumers/index.tsx                     |  2 +
 src/routes/global_rules/index.tsx                  |  2 +
 src/routes/plugin_configs/index.tsx                |  2 +
 src/routes/protos/index.tsx                        |  2 +
 src/routes/routes/index.tsx                        |  2 +
 src/routes/secrets/index.tsx                       |  2 +
 src/routes/services/index.tsx                      |  2 +
 src/routes/ssls/index.tsx                          |  2 +
 src/routes/stream_routes/index.tsx                 |  2 +
 src/routes/upstreams/index.tsx                     |  2 +
 20 files changed, 199 insertions(+), 4 deletions(-)

diff --git a/e2e/tests/consumer_groups.crud-all-fields.spec.ts 
b/e2e/tests/consumer_groups.crud-all-fields.spec.ts
index e1e4e1b9e..c29e9941f 100644
--- a/e2e/tests/consumer_groups.crud-all-fields.spec.ts
+++ b/e2e/tests/consumer_groups.crud-all-fields.spec.ts
@@ -139,7 +139,12 @@ test('should CRUD Consumer Group with all fields', async 
({ page }) => {
     await consumerGroupsPom.isIndexPage(page);
 
     // Verify consumer group exists
-    await expect(page.getByRole('cell', { name: testId, exact: true 
})).toBeVisible();
+    // Target the id text, not the cell's accessible name: the identifier
+    // cell also holds a copy control, so the cell's name is "<id> Copy" and
+    // an exact match on the id alone no longer applies.
+    await expect(
+      page.getByRole('table').getByText(testId, { exact: true })
+    ).toBeVisible();
     await expect(
       page.getByRole('cell', { name: 'Updated description with all fields' })
     ).toBeVisible();
@@ -168,6 +173,11 @@ test('should CRUD Consumer Group with all fields', async 
({ page }) => {
     });
 
     // Verify deletion
-    await expect(page.getByRole('cell', { name: testId, exact: true 
})).toBeHidden();
+    // Target the id text, not the cell's accessible name: the identifier
+    // cell also holds a copy control, so the cell's name is "<id> Copy" and
+    // an exact match on the id alone no longer applies.
+    await expect(
+      page.getByRole('table').getByText(testId, { exact: true })
+    ).toBeHidden();
   });
 });
diff --git a/e2e/tests/consumer_groups.crud-required-fields.spec.ts 
b/e2e/tests/consumer_groups.crud-required-fields.spec.ts
index c3352fd5e..75f521b2d 100644
--- a/e2e/tests/consumer_groups.crud-required-fields.spec.ts
+++ b/e2e/tests/consumer_groups.crud-required-fields.spec.ts
@@ -93,8 +93,11 @@ test('should CRUD Consumer Group with required fields', 
async ({ page }) => {
     await consumerGroupsPom.isIndexPage(page);
 
     // Verify consumer group exists in list
+    // Target the id text, not the cell's accessible name: the identifier
+    // cell also holds a copy control, so the cell's name is "<id> Copy" and
+    // an exact match on the id alone no longer applies.
     await expect(
-      page.getByRole('cell', { name: testId, exact: true })
+      page.getByRole('table').getByText(testId, { exact: true })
     ).toBeVisible();
   });
 
@@ -141,8 +144,11 @@ test('should CRUD Consumer Group with required fields', 
async ({ page }) => {
     await consumerGroupsPom.isIndexPage(page);
 
     // Verify consumer group is deleted
+    // Target the id text, not the cell's accessible name: the identifier
+    // cell also holds a copy control, so the cell's name is "<id> Copy" and
+    // an exact match on the id alone no longer applies.
     await expect(
-      page.getByRole('cell', { name: testId, exact: true })
+      page.getByRole('table').getByText(testId, { exact: true })
     ).toBeHidden();
   });
 });
diff --git a/e2e/tests/regression/general.copyable-id.spec.ts 
b/e2e/tests/regression/general.copyable-id.spec.ts
new file mode 100644
index 000000000..8ad56d1fc
--- /dev/null
+++ b/e2e/tests/regression/general.copyable-id.spec.ts
@@ -0,0 +1,79 @@
+/**
+ * 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 { safeClean } from '@e2e/utils/clean';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiGoto } from '@e2e/utils/ui';
+import { expect } from '@playwright/test';
+
+import { deleteAllRoutes, putRouteReq } from '@/apis/routes';
+import type { APISIXType } from '@/types/schema/apisix';
+
+// #3453 item 3: a resource id is the string you paste into a curl against
+// the Admin API, into a declarative config, or into another resource's
+// `upstream_id` — but the only way to get it out of the dashboard was to
+// select the text by hand.
+//
+// The control lives in the list's identifier cell rather than on the detail
+// page's id field: detail sections render as a disabled <fieldset> to
+// express read-only, and the HTML spec disables every form control inside
+// one, so a button there is dead exactly where it is most wanted.
+
+const ROUTE_ID = 'reg-copyable-id';
+// A distinct name: an id that also appears in the Name column would make
+// the assertion below ambiguous.
+const ROUTE_NAME = 'reg copyable id route';
+
+const clean = () => safeClean(() => deleteAllRoutes(e2eReq));
+
+test.use({ permissions: ['clipboard-read', 'clipboard-write'] });
+
+test.beforeAll(async () => {
+  await clean();
+  await putRouteReq(e2eReq, {
+    id: ROUTE_ID,
+    name: ROUTE_NAME,
+    uri: '/reg-copyable-id',
+    methods: ['GET'],
+    // A route needs one of plugins / upstream / service_id alongside its
+    // uri, or the Admin API rejects it.
+    upstream: { type: 'roundrobin', nodes: { 'copyable.local:80': 1 } },
+  } as APISIXType['Route']);
+});
+
+test.afterAll(clean);
+
+test('a list row id can be copied to the clipboard', async ({ page }) => {
+  await uiGoto(page, '/routes');
+
+  const table = page.getByRole('table');
+  await expect(table.getByText(ROUTE_ID, { exact: true })).toBeVisible();
+
+  const copyBtn = table.getByRole('button', { name: 'Copy', exact: true });
+  await copyBtn.click();
+
+  // The state flip is the visible feedback...
+  await expect(
+    table.getByRole('button', { name: 'Copied', exact: true })
+  ).toBeVisible();
+
+  // ...but only the clipboard proves it copied the right thing. A control
+  // that flips to "Copied" while writing the wrong value — or nothing at
+  // all — would still satisfy the assertion above.
+  const clipboard = await page.evaluate(() => navigator.clipboard.readText());
+  expect(clipboard).toBe(ROUTE_ID);
+});
diff --git a/src/components/page/CopyableId.tsx 
b/src/components/page/CopyableId.tsx
new file mode 100644
index 000000000..c31cca038
--- /dev/null
+++ b/src/components/page/CopyableId.tsx
@@ -0,0 +1,68 @@
+/**
+ * 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 { ActionIcon, CopyButton, Group, Tooltip } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+
+import IconCheck from '~icons/tabler/check';
+import IconCopy from '~icons/tabler/copy';
+
+export type CopyableIdProps = {
+  value?: string;
+};
+
+/**
+ * A resource identifier plus a control to copy it.
+ *
+ * The id is the string you paste into a `curl` against the Admin API, into a
+ * declarative config, or into another resource's `upstream_id`; until now the
+ * only way to get it out of the dashboard was to select the text by hand.
+ *
+ * This lives in the table cell rather than on the detail page's id field:
+ * detail sections render as a disabled `<fieldset>` to express read-only,
+ * and the HTML spec disables every form control inside one — so a button
+ * there would be dead exactly where it is most wanted.
+ */
+export const CopyableId = (props: CopyableIdProps) => {
+  const { value } = props;
+  const { t } = useTranslation();
+
+  if (!value) return '-';
+
+  return (
+    <Group gap={4} wrap="nowrap">
+      <span>{value}</span>
+      <CopyButton value={value} timeout={2000}>
+        {({ copied, copy }) => {
+          const label = copied ? t('form.btn.copied') : t('form.btn.copy');
+          return (
+            <Tooltip label={label} withArrow>
+              <ActionIcon
+                size="sm"
+                variant="subtle"
+                color={copied ? 'teal' : 'gray'}
+                onClick={copy}
+                aria-label={label}
+              >
+                {copied ? <IconCheck /> : <IconCopy />}
+              </ActionIcon>
+            </Tooltip>
+          );
+        }}
+      </CopyButton>
+    </Group>
+  );
+};
diff --git a/src/locales/de/common.json b/src/locales/de/common.json
index 8084c3059..81c40c901 100644
--- a/src/locales/de/common.json
+++ b/src/locales/de/common.json
@@ -35,6 +35,8 @@
       "add": "Hinzufügen",
       "addARow": "Zeile hinzufügen",
       "cancel": "Abbrechen",
+      "copied": "Kopiert",
+      "copy": "Kopieren",
       "delete": "Löschen",
       "edit": "Bearbeiten",
       "save": "Speichern",
diff --git a/src/locales/en/common.json b/src/locales/en/common.json
index 9b0c9d979..8020f6da1 100644
--- a/src/locales/en/common.json
+++ b/src/locales/en/common.json
@@ -35,6 +35,8 @@
       "add": "Add",
       "addARow": "Add a row",
       "cancel": "Cancel",
+      "copied": "Copied",
+      "copy": "Copy",
       "delete": "Delete",
       "edit": "Edit",
       "save": "Save",
diff --git a/src/locales/es/common.json b/src/locales/es/common.json
index 267d1dfea..693e09db0 100644
--- a/src/locales/es/common.json
+++ b/src/locales/es/common.json
@@ -35,6 +35,8 @@
       "add": "Añadir",
       "addARow": "Añadir una fila",
       "cancel": "Cancelar",
+      "copied": "Copiado",
+      "copy": "Copiar",
       "delete": "Eliminar",
       "edit": "Editar",
       "save": "Guardar",
diff --git a/src/locales/tr/common.json b/src/locales/tr/common.json
index 97f24efe0..e2b48a65e 100644
--- a/src/locales/tr/common.json
+++ b/src/locales/tr/common.json
@@ -35,6 +35,8 @@
       "add": "Ekle",
       "addARow": "Satır ekle",
       "cancel": "İptal",
+      "copied": "Kopyalandı",
+      "copy": "Kopyala",
       "delete": "Sil",
       "edit": "Düzenle",
       "save": "Kaydet",
diff --git a/src/locales/zh/common.json b/src/locales/zh/common.json
index 7ac66953c..7223c63c5 100644
--- a/src/locales/zh/common.json
+++ b/src/locales/zh/common.json
@@ -35,6 +35,8 @@
       "add": "新增",
       "addARow": "新增一行",
       "cancel": "取消",
+      "copied": "已复制",
+      "copy": "复制",
       "delete": "删除",
       "edit": "编辑",
       "save": "保存",
diff --git a/src/routes/consumer_groups/index.tsx 
b/src/routes/consumer_groups/index.tsx
index 108ce06b6..a5cd679ee 100644
--- a/src/routes/consumer_groups/index.tsx
+++ b/src/routes/consumer_groups/index.tsx
@@ -21,6 +21,7 @@ import { useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import { getConsumerGroupListQueryOptions, useConsumerGroupList } from 
'@/apis/hooks';
+import { CopyableId } from '@/components/page/CopyableId';
 import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn';
 import PageHeader from '@/components/page/PageHeader';
 import { ToAddPageBtn, ToDetailPageBtn } from '@/components/page/ToAddPageBtn';
@@ -43,6 +44,7 @@ function ConsumerGroupsList() {
         title: 'ID',
         key: 'id',
         valueType: 'text',
+        render: (_, record) => <CopyableId value={record.value.id} />,
       },
       {
         dataIndex: ['value', 'name'],
diff --git a/src/routes/consumers/index.tsx b/src/routes/consumers/index.tsx
index b431ed3b4..35f966783 100644
--- a/src/routes/consumers/index.tsx
+++ b/src/routes/consumers/index.tsx
@@ -21,6 +21,7 @@ import { useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import { getConsumerListQueryOptions, useConsumerList } from '@/apis/hooks';
+import { CopyableId } from '@/components/page/CopyableId';
 import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn';
 import PageHeader from '@/components/page/PageHeader';
 import { ToAddPageBtn, ToDetailPageBtn } from '@/components/page/ToAddPageBtn';
@@ -41,6 +42,7 @@ function ConsumersList() {
         title: t('form.consumers.username'),
         key: 'username',
         valueType: 'text',
+        render: (_, record) => <CopyableId value={record.value.username} />,
       },
       {
         dataIndex: ['value', 'desc'],
diff --git a/src/routes/global_rules/index.tsx 
b/src/routes/global_rules/index.tsx
index 248455efa..bc0325e19 100644
--- a/src/routes/global_rules/index.tsx
+++ b/src/routes/global_rules/index.tsx
@@ -21,6 +21,7 @@ import { useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import { getGlobalRuleListQueryOptions, useGlobalRuleList } from 
'@/apis/hooks';
+import { CopyableId } from '@/components/page/CopyableId';
 import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn';
 import PageHeader from '@/components/page/PageHeader';
 import { ToAddPageBtn, ToDetailPageBtn } from '@/components/page/ToAddPageBtn';
@@ -56,6 +57,7 @@ function GlobalRulesList() {
         title: 'ID',
         key: 'id',
         valueType: 'text',
+        render: (_, record) => <CopyableId value={record.value.id} />,
       },
       {
         title: t('table.actions'),
diff --git a/src/routes/plugin_configs/index.tsx 
b/src/routes/plugin_configs/index.tsx
index bccb89848..1716b179b 100644
--- a/src/routes/plugin_configs/index.tsx
+++ b/src/routes/plugin_configs/index.tsx
@@ -21,6 +21,7 @@ import { useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import { getPluginConfigListQueryOptions, usePluginConfigList } from 
'@/apis/hooks';
+import { CopyableId } from '@/components/page/CopyableId';
 import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn';
 import PageHeader from '@/components/page/PageHeader';
 import { ToAddPageBtn, ToDetailPageBtn } from '@/components/page/ToAddPageBtn';
@@ -43,6 +44,7 @@ function PluginConfigsList() {
         title: 'ID',
         key: 'id',
         valueType: 'text',
+        render: (_, record) => <CopyableId value={record.value.id} />,
       },
       {
         dataIndex: ['value', 'name'],
diff --git a/src/routes/protos/index.tsx b/src/routes/protos/index.tsx
index 275495498..c1b1075fa 100644
--- a/src/routes/protos/index.tsx
+++ b/src/routes/protos/index.tsx
@@ -21,6 +21,7 @@ import { useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import { getProtoListQueryOptions, useProtoList } from '@/apis/hooks';
+import { CopyableId } from '@/components/page/CopyableId';
 import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn';
 import PageHeader from '@/components/page/PageHeader';
 import { ToAddPageBtn, ToDetailPageBtn } from '@/components/page/ToAddPageBtn';
@@ -44,6 +45,7 @@ function RouteComponent() {
         title: 'ID',
         key: 'id',
         valueType: 'text',
+        render: (_, record) => <CopyableId value={record.value.id} />,
       },
       {
         title: t('table.actions'),
diff --git a/src/routes/routes/index.tsx b/src/routes/routes/index.tsx
index 6b44fbd77..d7af823da 100644
--- a/src/routes/routes/index.tsx
+++ b/src/routes/routes/index.tsx
@@ -22,6 +22,7 @@ import { useTranslation } from 'react-i18next';
 
 import { getRouteListQueryOptions, useRouteList } from '@/apis/hooks';
 import type { WithServiceIdFilter } from '@/apis/routes';
+import { CopyableId } from '@/components/page/CopyableId';
 import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn';
 import PageHeader from '@/components/page/PageHeader';
 import { ToAddPageBtn, ToDetailPageBtn } from '@/components/page/ToAddPageBtn';
@@ -55,6 +56,7 @@ export const RouteList = (props: RouteListProps) => {
         title: 'ID',
         key: 'id',
         valueType: 'text',
+        render: (_, record) => <CopyableId value={record.value.id} />,
       },
       {
         dataIndex: ['value', 'name'],
diff --git a/src/routes/secrets/index.tsx b/src/routes/secrets/index.tsx
index fa9810c34..769426a55 100644
--- a/src/routes/secrets/index.tsx
+++ b/src/routes/secrets/index.tsx
@@ -21,6 +21,7 @@ import { useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import { getSecretListQueryOptions, useSecretList } from '@/apis/hooks';
+import { CopyableId } from '@/components/page/CopyableId';
 import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn';
 import PageHeader from '@/components/page/PageHeader';
 import { ToAddPageBtn, ToDetailPageBtn } from '@/components/page/ToAddPageBtn';
@@ -43,6 +44,7 @@ function SecretList() {
         title: 'ID',
         key: 'id',
         valueType: 'text',
+        render: (_, record) => <CopyableId value={record.value.id} />,
         width: 300,
       },
       {
diff --git a/src/routes/services/index.tsx b/src/routes/services/index.tsx
index ea5c5011f..d507ae626 100644
--- a/src/routes/services/index.tsx
+++ b/src/routes/services/index.tsx
@@ -22,6 +22,7 @@ import { useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import { getServiceListQueryOptions, useServiceList } from '@/apis/hooks';
+import { CopyableId } from '@/components/page/CopyableId';
 import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn';
 import PageHeader from '@/components/page/PageHeader';
 import { ToAddPageBtn, ToDetailPageBtn } from '@/components/page/ToAddPageBtn';
@@ -42,6 +43,7 @@ const ServiceList = () => {
         title: 'ID',
         key: 'id',
         valueType: 'text',
+        render: (_, record) => <CopyableId value={record.value.id} />,
       },
       {
         dataIndex: ['value', 'name'],
diff --git a/src/routes/ssls/index.tsx b/src/routes/ssls/index.tsx
index 9bc31f207..92e0f6a72 100644
--- a/src/routes/ssls/index.tsx
+++ b/src/routes/ssls/index.tsx
@@ -21,6 +21,7 @@ import { useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import { getSSLListQueryOptions, useSSLList } from '@/apis/hooks';
+import { CopyableId } from '@/components/page/CopyableId';
 import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn';
 import PageHeader from '@/components/page/PageHeader';
 import { ToAddPageBtn, ToDetailPageBtn } from '@/components/page/ToAddPageBtn';
@@ -41,6 +42,7 @@ function RouteComponent() {
         title: 'ID',
         key: 'id',
         valueType: 'text',
+        render: (_, record) => <CopyableId value={record.value.id} />,
       },
       {
         dataIndex: ['value', 'sni'],
diff --git a/src/routes/stream_routes/index.tsx 
b/src/routes/stream_routes/index.tsx
index 46d361321..95209ffc5 100644
--- a/src/routes/stream_routes/index.tsx
+++ b/src/routes/stream_routes/index.tsx
@@ -22,6 +22,7 @@ import { useTranslation } from 'react-i18next';
 
 import { getStreamRouteListQueryOptions, useStreamRouteList } from 
'@/apis/hooks';
 import type { WithServiceIdFilter } from '@/apis/routes';
+import { CopyableId } from '@/components/page/CopyableId';
 import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn';
 import PageHeader from '@/components/page/PageHeader';
 import { ToAddPageBtn, ToDetailPageBtn } from '@/components/page/ToAddPageBtn';
@@ -61,6 +62,7 @@ export const StreamRouteList = (props: StreamRouteListProps) 
=> {
         title: 'ID',
         key: 'id',
         valueType: 'text',
+        render: (_, record) => <CopyableId value={record.value.id} />,
       },
       {
         dataIndex: ['value', 'server_addr'],
diff --git a/src/routes/upstreams/index.tsx b/src/routes/upstreams/index.tsx
index a1e6d55d0..55a014184 100644
--- a/src/routes/upstreams/index.tsx
+++ b/src/routes/upstreams/index.tsx
@@ -21,6 +21,7 @@ import { useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import { getUpstreamListQueryOptions, useUpstreamList } from '@/apis/hooks';
+import { CopyableId } from '@/components/page/CopyableId';
 import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn';
 import PageHeader from '@/components/page/PageHeader';
 import { ToAddPageBtn, ToDetailPageBtn } from '@/components/page/ToAddPageBtn';
@@ -43,6 +44,7 @@ function RouteComponent() {
         title: 'ID',
         key: 'id',
         valueType: 'text',
+        render: (_, record) => <CopyableId value={record.value.id} />,
       },
       {
         dataIndex: ['value', 'name'],

Reply via email to