diff --git a/e2e/tests/stream_routes.show-disabled-error.spec.ts b/e2e/tests/stream_routes.show-disabled-error.spec.ts index 91ebfeeb56..58899479ad 100644 --- a/e2e/tests/stream_routes.show-disabled-error.spec.ts +++ b/e2e/tests/stream_routes.show-disabled-error.spec.ts @@ -33,6 +33,7 @@ import { exec } from 'node:child_process'; import { readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; import { streamRoutesPom } from '@e2e/pom/stream_routes'; @@ -51,7 +52,7 @@ type APISIXConf = { }; const getE2EServerDir = () => { - const currentDir = new URL('.', import.meta.url).pathname; + const currentDir = path.dirname(fileURLToPath(import.meta.url)); return path.join(currentDir, '../server'); }; diff --git a/e2e/utils/common.ts b/e2e/utils/common.ts index 5c624673b0..d84b70ca24 100644 --- a/e2e/utils/common.ts +++ b/e2e/utils/common.ts @@ -16,6 +16,7 @@ */ import { access, readFile } from 'node:fs/promises'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { nanoid } from 'nanoid'; import selfsigned from 'selfsigned'; @@ -25,7 +26,7 @@ type APISIXConf = { deployment: { admin: { admin_key: { key: string }[] } }; }; export const getAPISIXConf = async () => { - const currentDir = new URL('.', import.meta.url).pathname; + const currentDir = path.dirname(fileURLToPath(import.meta.url)); const confPath = path.join(currentDir, '../server/apisix_conf.yml'); const file = await readFile(confPath, 'utf-8'); const res = parse(file) as APISIXConf; diff --git a/e2e/utils/ui/upstreams.ts b/e2e/utils/ui/upstreams.ts index 9def978d95..2ba62577c8 100644 --- a/e2e/utils/ui/upstreams.ts +++ b/e2e/utils/ui/upstreams.ts @@ -238,7 +238,15 @@ export async function uiFillUpstreamAllFields( await tlsSection .getByRole('textbox', { name: 'Client Key', exact: true }) .fill(tls.key); - await tlsSection.getByRole('switch', { name: 'Verify' }).click(); + const verifySwitch = tlsSection + .locator('input[name$="tls.verify"]') + .first(); + if (!(await verifySwitch.isChecked())) { + await verifySwitch.evaluate((node) => { + (node as HTMLInputElement).click(); + }); + } + await expect(verifySwitch).toBeChecked(); // 12. Health Check settings // Activate active health check diff --git a/src/components/CopyableText.tsx b/src/components/CopyableText.tsx new file mode 100644 index 0000000000..6922f8fddb --- /dev/null +++ b/src/components/CopyableText.tsx @@ -0,0 +1,151 @@ +/** + * 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 { CheckOutlined, CopyOutlined } from '@ant-design/icons'; +import { + type CSSProperties, + type ReactNode, + useEffect, + useId, + useRef, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; + +export type CopyableTextProps = { + text: string; + children?: ReactNode; + emptyPlaceholder?: ReactNode; +}; + +const wrapperStyle: CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + gap: 4, + maxWidth: '100%', +}; + +const copyButtonStyle: CSSProperties = { + border: 'none', + background: 'transparent', + color: '#1677ff', + cursor: 'pointer', + padding: 0, + display: 'inline-flex', + alignItems: 'center', + lineHeight: 1, +}; + +const srOnlyStyle: CSSProperties = { + position: 'absolute', + width: 1, + height: 1, + padding: 0, + margin: -1, + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + whiteSpace: 'nowrap', + border: 0, +}; + +const fallbackCopy = (value: string) => { + const textarea = document.createElement('textarea'); + textarea.value = value; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + const copied = document.execCommand('copy'); + document.body.removeChild(textarea); + return copied; +}; + +const writeToClipboard = async (value: string) => { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(value); + return; + } + + if (!fallbackCopy(value)) { + throw new Error('Failed to copy text'); + } +}; + +export const CopyableText = (props: CopyableTextProps) => { + const { text, children, emptyPlaceholder = '-' } = props; + const { t } = useTranslation(); + const [copied, setCopied] = useState(false); + const copiedResetTimer = useRef | null>(null); + const announceId = useId(); + + useEffect(() => { + return () => { + if (copiedResetTimer.current) { + clearTimeout(copiedResetTimer.current); + } + }; + }, []); + + const hasValue = text.trim().length > 0; + const displayContent = children ?? (hasValue ? text : emptyPlaceholder); + + const onCopy = async () => { + if (!hasValue) { + return; + } + + try { + await writeToClipboard(text); + setCopied(true); + if (copiedResetTimer.current) { + clearTimeout(copiedResetTimer.current); + } + copiedResetTimer.current = setTimeout(() => { + setCopied(false); + }, 1500); + } catch { + setCopied(false); + } + }; + + return ( + + {displayContent} + {hasValue && ( + <> + + + {copied ? t('copy_success') : ''} + + + )} + + ); +}; diff --git a/src/components/form-slice/FormItemPlugins/PluginCard.tsx b/src/components/form-slice/FormItemPlugins/PluginCard.tsx index 2d9bca8f56..a4bf8348e1 100644 --- a/src/components/form-slice/FormItemPlugins/PluginCard.tsx +++ b/src/components/form-slice/FormItemPlugins/PluginCard.tsx @@ -14,9 +14,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Button, Card,Group, Text } from '@mantine/core'; +import { Button, Card, Group, Text } from '@mantine/core'; import { useTranslation } from 'react-i18next'; +import { CopyableText } from '@/components/CopyableText'; + export type PluginCardProps = { name: string; desc?: string; @@ -30,12 +32,16 @@ export type PluginCardProps = { export const PluginCard = (props: PluginCardProps) => { const { name, desc, mode, onAdd, onEdit, onView, onDelete } = props; const { t } = useTranslation(); + const pluginName = String(name); + return ( - + - {name} + + + @@ -50,7 +56,7 @@ export const PluginCard = (props: PluginCardProps) => { size="compact-xs" variant="light" color="blue" - onClick={() => onAdd?.(name)} + onClick={() => onAdd?.(pluginName)} > {t('form.btn.add')} @@ -59,7 +65,7 @@ export const PluginCard = (props: PluginCardProps) => { @@ -70,7 +76,7 @@ export const PluginCard = (props: PluginCardProps) => { size="compact-xs" variant="light" color="blue" - onClick={() => onEdit?.(name)} + onClick={() => onEdit?.(pluginName)} > {t('form.btn.edit')} @@ -78,7 +84,7 @@ export const PluginCard = (props: PluginCardProps) => { size="compact-xs" variant="light" color="red" - onClick={() => onDelete?.(name)} + onClick={() => onDelete?.(pluginName)} > {t('form.btn.delete')} diff --git a/src/components/form-slice/FormPartUpstream/index.tsx b/src/components/form-slice/FormPartUpstream/index.tsx index f34492b574..54e9ffe10f 100644 --- a/src/components/form-slice/FormPartUpstream/index.tsx +++ b/src/components/form-slice/FormPartUpstream/index.tsx @@ -44,6 +44,7 @@ export const FormSectionTLS = () => { control={control} name={np('tls.verify')} label={t('form.upstreams.tls.verify')} + data-testid="upstream-tls-verify-switch" /> ( + + ), }, { dataIndex: ['value', 'name'], diff --git a/src/routes/consumers/detail.$username/credentials/index.tsx b/src/routes/consumers/detail.$username/credentials/index.tsx index d433ba2b59..2501e97f3e 100644 --- a/src/routes/consumers/detail.$username/credentials/index.tsx +++ b/src/routes/consumers/detail.$username/credentials/index.tsx @@ -24,6 +24,7 @@ import { getCredentialListQueryOptions, useCredentialsList, } from '@/apis/hooks'; +import { CopyableText } from '@/components/CopyableText'; import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; import PageHeader from '@/components/page/PageHeader'; import { ToAddPageBtn, ToDetailPageBtn } from '@/components/page/ToAddPageBtn'; @@ -48,6 +49,9 @@ function CredentialsList() { title: 'ID', key: 'id', valueType: 'text', + render: (_, record) => ( + + ), }, { dataIndex: ['value', 'desc'], diff --git a/src/routes/consumers/index.tsx b/src/routes/consumers/index.tsx index b431ed3b4b..77f2c441ef 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 { CopyableText } from '@/components/CopyableText'; import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; import PageHeader from '@/components/page/PageHeader'; import { ToAddPageBtn, ToDetailPageBtn } from '@/components/page/ToAddPageBtn'; @@ -41,6 +42,9 @@ function ConsumersList() { title: t('form.consumers.username'), key: 'username', valueType: 'text', + render: (_, record) => ( + + ), }, { dataIndex: ['value', 'desc'], diff --git a/src/routes/global_rules/index.tsx b/src/routes/global_rules/index.tsx index 248455efae..258018438e 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 { CopyableText } from '@/components/CopyableText'; import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; import PageHeader from '@/components/page/PageHeader'; import { ToAddPageBtn, ToDetailPageBtn } from '@/components/page/ToAddPageBtn'; @@ -56,6 +57,9 @@ function GlobalRulesList() { title: 'ID', key: 'id', valueType: 'text', + render: (_, record) => ( + + ), }, { title: t('table.actions'), diff --git a/src/routes/plugin_configs/index.tsx b/src/routes/plugin_configs/index.tsx index bccb89848f..f444626053 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 { CopyableText } from '@/components/CopyableText'; import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; import PageHeader from '@/components/page/PageHeader'; import { ToAddPageBtn, ToDetailPageBtn } from '@/components/page/ToAddPageBtn'; @@ -43,6 +44,9 @@ function PluginConfigsList() { title: 'ID', key: 'id', valueType: 'text', + render: (_, record) => ( + + ), }, { dataIndex: ['value', 'name'], diff --git a/src/routes/protos/index.tsx b/src/routes/protos/index.tsx index 2754954980..694b35dcc9 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 { CopyableText } from '@/components/CopyableText'; import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; import PageHeader from '@/components/page/PageHeader'; import { ToAddPageBtn, ToDetailPageBtn } from '@/components/page/ToAddPageBtn'; @@ -44,6 +45,9 @@ function RouteComponent() { title: 'ID', key: 'id', valueType: 'text', + render: (_, record) => ( + + ), }, { title: t('table.actions'), diff --git a/src/routes/routes/index.tsx b/src/routes/routes/index.tsx index 6b44fbd776..a5034d5a70 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 { CopyableText } from '@/components/CopyableText'; import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; import PageHeader from '@/components/page/PageHeader'; import { ToAddPageBtn, ToDetailPageBtn } from '@/components/page/ToAddPageBtn'; @@ -55,6 +56,9 @@ export const RouteList = (props: RouteListProps) => { title: 'ID', key: 'id', valueType: 'text', + render: (_, record) => ( + + ), }, { dataIndex: ['value', 'name'], diff --git a/src/routes/secrets/index.tsx b/src/routes/secrets/index.tsx index fa9810c34b..66ebde9c1a 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 { CopyableText } from '@/components/CopyableText'; import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; import PageHeader from '@/components/page/PageHeader'; import { ToAddPageBtn, ToDetailPageBtn } from '@/components/page/ToAddPageBtn'; @@ -44,6 +45,9 @@ function SecretList() { key: 'id', valueType: 'text', width: 300, + render: (_, record) => ( + + ), }, { dataIndex: ['value', 'manager'], diff --git a/src/routes/services/index.tsx b/src/routes/services/index.tsx index ea5c5011f6..48020b9f76 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 { CopyableText } from '@/components/CopyableText'; import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; import PageHeader from '@/components/page/PageHeader'; import { ToAddPageBtn, ToDetailPageBtn } from '@/components/page/ToAddPageBtn'; @@ -42,6 +43,9 @@ const ServiceList = () => { title: 'ID', key: 'id', valueType: 'text', + render: (_, record) => ( + + ), }, { dataIndex: ['value', 'name'], diff --git a/src/routes/ssls/index.tsx b/src/routes/ssls/index.tsx index 9bc31f2073..f315d25171 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 { CopyableText } from '@/components/CopyableText'; import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; import PageHeader from '@/components/page/PageHeader'; import { ToAddPageBtn, ToDetailPageBtn } from '@/components/page/ToAddPageBtn'; @@ -41,6 +42,9 @@ function RouteComponent() { title: 'ID', key: 'id', valueType: 'text', + render: (_, record) => ( + + ), }, { dataIndex: ['value', 'sni'], diff --git a/src/routes/upstreams/index.tsx b/src/routes/upstreams/index.tsx index a1e6d55d0f..52fe69368e 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 { CopyableText } from '@/components/CopyableText'; import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn'; import PageHeader from '@/components/page/PageHeader'; import { ToAddPageBtn, ToDetailPageBtn } from '@/components/page/ToAddPageBtn'; @@ -43,6 +44,9 @@ function RouteComponent() { title: 'ID', key: 'id', valueType: 'text', + render: (_, record) => ( + + ), }, { dataIndex: ['value', 'name'],