Skip to content

Commit c57e42f

Browse files
committed
frontend: CopyableCell component : integrate copy in tables
1 parent 72d1290 commit c57e42f

File tree

27 files changed

+414
-23
lines changed

27 files changed

+414
-23
lines changed

e2e-tests/bun.lock

Lines changed: 35 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/*
2+
* Copyright 2025 The Kubernetes Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { Icon } from '@iconify/react';
18+
import Box from '@mui/material/Box';
19+
import IconButton from '@mui/material/IconButton';
20+
import Tooltip from '@mui/material/Tooltip';
21+
import type { MouseEvent } from 'react';
22+
import { ReactNode, useState } from 'react';
23+
import { useTranslation } from 'react-i18next';
24+
25+
export interface CopyableCellProps {
26+
/** The text value to copy to clipboard */
27+
value: string;
28+
/** The content to display in the cell */
29+
children: ReactNode;
30+
}
31+
32+
/**
33+
* A wrapper component that adds a copy-to-clipboard button on hover.
34+
* Used for table cells containing values that users commonly need to copy,
35+
* such as IP addresses, hostnames, etc.
36+
*/
37+
export default function CopyableCell({ value, children }: CopyableCellProps) {
38+
const { t } = useTranslation(['translation']);
39+
const [copied, setCopied] = useState(false);
40+
41+
if (!value) {
42+
return <>{children}</>;
43+
}
44+
45+
async function handleCopy(e: MouseEvent) {
46+
e.stopPropagation();
47+
try {
48+
await navigator.clipboard.writeText(value);
49+
setCopied(true);
50+
setTimeout(() => setCopied(false), 1500);
51+
} catch (err) {
52+
console.error('Failed to copy to clipboard:', err);
53+
}
54+
}
55+
56+
const tooltipTitle = copied ? t('translation|Copied!') : t('translation|Copy to clipboard');
57+
58+
return (
59+
<Box
60+
sx={{
61+
display: 'flex',
62+
alignItems: 'center',
63+
gap: 0.5,
64+
'& .copy-button': {
65+
opacity: 0,
66+
transition: 'opacity 0.2s',
67+
},
68+
'&:hover .copy-button': {
69+
opacity: 1,
70+
},
71+
}}
72+
>
73+
<Box sx={{ flex: 1, minWidth: 0 }}>{children}</Box>
74+
<Tooltip title={tooltipTitle}>
75+
<IconButton
76+
className="copy-button"
77+
size="small"
78+
onClick={handleCopy}
79+
aria-label={t('translation|Copy to clipboard')}
80+
sx={{
81+
padding: '2px',
82+
flexShrink: 0,
83+
}}
84+
>
85+
<Icon icon={copied ? 'mdi:check' : 'mdi:content-copy'} width="16" height="16" />
86+
</IconButton>
87+
</Tooltip>
88+
</Box>
89+
);
90+
}

frontend/src/components/common/Resource/ResourceTable.tsx

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import { useLocalStorageState } from '../../globalSearch/useLocalStorageState';
5151
import { DateLabel } from '../Label';
5252
import Link from '../Link';
5353
import Table, { TableColumn } from '../Table';
54+
import CopyableCell from './CopyableCell';
5455
import DeleteButton from './DeleteButton';
5556
import EditButton from './EditButton';
5657
import ResourceTableMultiActions from './ResourceTableMultiActions';
@@ -89,6 +90,12 @@ export type ResourceTableColumn<RowItem> = {
8990
gridTemplate?: string | number;
9091
/** Options for the select filter */
9192
filterSelectOptions?: TableColumn<any>['filterSelectOptions'];
93+
/**
94+
* If true, adds a copy-to-clipboard button that appears on hover.
95+
* The value from getValue() will be copied when clicked.
96+
* @default false
97+
*/
98+
copyable?: boolean;
9299
} & (
93100
| {
94101
/** To render a simple value provide property name of the item */
@@ -425,9 +432,33 @@ function ResourceTableContent<RowItem extends KubeObject>(props: ResourceTablePr
425432
} else {
426433
mrtColumn.accessorFn = (item: RowItem) => item[column.datum];
427434
}
428-
if ('render' in column) {
435+
if ('render' in column && 'getValue' in column) {
436+
const getValueFn = column.getValue as (
437+
item: RowItem
438+
) => string | number | null | undefined;
439+
const renderFn = column.render;
440+
if (column.copyable) {
441+
mrtColumn.Cell = ({ row }: { row: MRT_Row<RowItem> }) => {
442+
const value = String(getValueFn(row.original) ?? '');
443+
return (
444+
<CopyableCell value={value}>{renderFn?.(row.original) ?? null}</CopyableCell>
445+
);
446+
};
447+
} else {
448+
mrtColumn.Cell = ({ row }: { row: MRT_Row<RowItem> }) =>
449+
renderFn?.(row.original) ?? null;
450+
}
451+
} else if ('render' in column) {
429452
mrtColumn.Cell = ({ row }: { row: MRT_Row<RowItem> }) =>
430453
column.render?.(row.original) ?? null;
454+
} else if (column.copyable && 'getValue' in column) {
455+
const getValueFn = column.getValue as (
456+
item: RowItem
457+
) => string | number | null | undefined;
458+
mrtColumn.Cell = ({ row }: { row: MRT_Row<RowItem> }) => {
459+
const value = String(getValueFn(row.original) ?? '');
460+
return <CopyableCell value={value}>{value}</CopyableCell>;
461+
};
431462
}
432463
if (sort && typeof sort === 'function') {
433464
mrtColumn.sortingFn = sortingFn(sort);

frontend/src/components/common/Resource/index.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const avoidCheck = [
3030

3131
const checkExports = [
3232
'CircularChart',
33+
'CopyableCell',
3334
'CreateButton',
3435
'CopyButton',
3536
'DeleteButton',

frontend/src/components/common/Resource/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ export * from './CreateButton';
2626
export { default as CreateButton } from './CreateButton';
2727
export * from './CopyButton';
2828
export { default as CopyButton } from './CopyButton';
29+
export * from './CopyableCell';
30+
export { default as CopyableCell } from './CopyableCell';
2931
export * from './DeleteButton';
3032
export { default as DeleteButton } from './DeleteButton';
3133
export * from './DocsViewer';

frontend/src/components/endpointSlices/List.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export default function EndpointSliceList() {
6363
gap: '4px',
6464
},
6565
},
66+
copyable: true,
6667
},
6768
{
6869
id: 'ports',

frontend/src/components/endpointSlices/__snapshots__/EndpointSliceList.Items.stories.storyshot

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -717,13 +717,32 @@
717717
class="MuiTableCell-root MuiTableCell-alignLeft MuiTableCell-sizeMedium css-1g81de4-MuiTableCell-root"
718718
>
719719
<div
720-
class="MuiBox-root css-1baulvz"
720+
class="MuiBox-root css-55gkwq"
721721
>
722-
<span
723-
class="MuiTypography-root MuiTypography-body1 css-1ge3384-MuiTypography-root"
722+
<div
723+
class="MuiBox-root css-1fjtzvx"
724+
>
725+
<div
726+
class="MuiBox-root css-1baulvz"
727+
>
728+
<span
729+
class="MuiTypography-root MuiTypography-body1 css-1ge3384-MuiTypography-root"
730+
>
731+
127.0.0.1
732+
</span>
733+
</div>
734+
</div>
735+
<button
736+
aria-label="Copy to clipboard"
737+
class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeSmall copy-button css-d7r42q-MuiButtonBase-root-MuiIconButton-root"
738+
data-mui-internal-clone-element="true"
739+
tabindex="0"
740+
type="button"
724741
>
725-
127.0.0.1
726-
</span>
742+
<span
743+
class="MuiTouchRipple-root css-8je8zh-MuiTouchRipple-root"
744+
/>
745+
</button>
727746
</div>
728747
</td>
729748
<td

frontend/src/components/endpoints/List.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export default function EndpointList() {
4343
getValue: endpoint => endpoint.getAddresses().join(', '),
4444
render: endpoint => <LabelListItem labels={endpoint.getAddresses()} />,
4545
gridTemplate: 1.5,
46+
copyable: true,
4647
},
4748
'age',
4849
]}

frontend/src/components/endpoints/__snapshots__/EndpointList.Items.stories.storyshot

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -606,13 +606,32 @@
606606
<td
607607
class="MuiTableCell-root MuiTableCell-alignLeft MuiTableCell-sizeMedium css-cxvskc-MuiTableCell-root"
608608
>
609-
<span
610-
aria-label="127.0.01:8080"
611-
class=""
612-
data-mui-internal-clone-element="true"
609+
<div
610+
class="MuiBox-root css-55gkwq"
613611
>
614-
127.0.01:8080
615-
</span>
612+
<div
613+
class="MuiBox-root css-1fjtzvx"
614+
>
615+
<span
616+
aria-label="127.0.01:8080"
617+
class=""
618+
data-mui-internal-clone-element="true"
619+
>
620+
127.0.01:8080
621+
</span>
622+
</div>
623+
<button
624+
aria-label="Copy to clipboard"
625+
class="MuiButtonBase-root MuiIconButton-root MuiIconButton-sizeSmall copy-button css-d7r42q-MuiButtonBase-root-MuiIconButton-root"
626+
data-mui-internal-clone-element="true"
627+
tabindex="0"
628+
type="button"
629+
>
630+
<span
631+
class="MuiTouchRipple-root css-8je8zh-MuiTouchRipple-root"
632+
/>
633+
</button>
634+
</div>
616635
</td>
617636
<td
618637
class="MuiTableCell-root MuiTableCell-alignRight MuiTableCell-sizeMedium css-14i1ub9-MuiTableCell-root"

frontend/src/components/node/List.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,11 +107,14 @@ export default function NodeList() {
107107
id: 'internalIP',
108108
label: t('translation|Internal IP'),
109109
getValue: node => node.getInternalIP(),
110+
copyable: true,
110111
},
111112
{
112113
id: 'externalIP',
113114
label: t('External IP'),
114-
getValue: node => node.getExternalIP() || t('translation|None'),
115+
getValue: node => node.getExternalIP() || '',
116+
render: node => node.getExternalIP() || t('translation|None'),
117+
copyable: true,
115118
},
116119
{
117120
id: 'version',

0 commit comments

Comments
 (0)