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'],