Skip to content

Commit 24f4580

Browse files
authored
feat: Migrate CopyButton to use ActionButton as base (#2616)
Preparing for adding a dh.ui copy button, it should have the same API as an ActionButton. So migrating component to use the ActionButton as the base button, so dh.ui can expose all the same props. Breaking change matching PR for enterprise is up as draft. BREAKING CHANGE: Copy buttons default styling is now the same as a default ActionButton, add the isQuiet prop to match previous default styling.
1 parent a62a8cb commit 24f4580

31 files changed

Lines changed: 190 additions & 68 deletions

packages/chart/src/ChartErrorOverlay.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@ function ChartErrorOverlay({
2525
<div className="chart-panel-overlay-content chart-error-overlay-content">
2626
<div className="info-message" data-testid={messageTestId}>
2727
{errorMessage}
28-
<CopyButton copy={errorMessage} style={{ margin: '0' }} />
28+
<CopyButton
29+
copy={errorMessage}
30+
UNSAFE_style={{ margin: '0' }}
31+
isQuiet
32+
/>
2933
</div>
3034
<div>
3135
{onCancel && (

packages/code-studio/src/settings/SettingsMenu.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -394,16 +394,14 @@ export class SettingsMenu extends Component<
394394
)}
395395
</div>
396396
<CopyButton
397-
kind="inline"
398-
tooltip="Copy version numbers"
399397
copy={Object.entries({
400398
...versionInfo,
401399
...pluginInfo,
402400
})
403401
.map(([key, value]) => `${key}: ${value}`)
404402
.join('\n')}
405403
>
406-
Copy Versions
404+
Copy Versions{' '}
407405
<small className="text-muted">({copyShortcut})</small>
408406
</CopyButton>
409407
</Content>

packages/code-studio/src/styleguide/ThemeColors.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ export function ThemeColors(): JSX.Element {
7878
<Tooltip interactive>
7979
<TooltipContent name={name} value={value} />
8080
</Tooltip>
81-
{name && <CopyButton copy={name} />}
81+
{name && <CopyButton copy={name} isQuiet />}
8282
</div>
8383
))}
8484
</Fragment>
@@ -136,7 +136,7 @@ export function ThemeColors(): JSX.Element {
136136
{name.endsWith('-hue') || note != null ? (
137137
<span>{note ?? value}</span>
138138
) : null}
139-
<CopyButton copy={name} />
139+
<CopyButton copy={name} isQuiet />
140140
</div>
141141
)
142142
)}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import React from 'react';
2+
import { render, screen } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import CopyButton, { type CopyButtonProps } from './CopyButton';
5+
6+
const mockCopyToClipboard = jest.fn();
7+
let mockCopied = false;
8+
9+
jest.mock('@deephaven/react-hooks', () => ({
10+
...jest.requireActual('@deephaven/react-hooks'),
11+
useCopyToClipboard: () => [mockCopied, mockCopyToClipboard],
12+
}));
13+
14+
function makeCopyButton({
15+
copy = 'test value',
16+
...rest
17+
}: Partial<CopyButtonProps> = {}) {
18+
// eslint-disable-next-line react/jsx-props-no-spreading
19+
return render(<CopyButton copy={copy} {...rest} />);
20+
}
21+
22+
beforeEach(() => {
23+
mockCopied = false;
24+
mockCopyToClipboard.mockClear();
25+
});
26+
27+
it('mounts and unmounts without failing', () => {
28+
makeCopyButton();
29+
});
30+
31+
it('renders with default tooltip', () => {
32+
makeCopyButton();
33+
expect(screen.getByRole('button', { name: 'Copy' })).toBeInTheDocument();
34+
});
35+
36+
it('renders with custom tooltip', () => {
37+
makeCopyButton({ tooltip: 'Copy column name' });
38+
expect(
39+
screen.getByRole('button', { name: 'Copy column name' })
40+
).toBeInTheDocument();
41+
});
42+
43+
it('copies string value when clicked', async () => {
44+
const user = userEvent.setup();
45+
makeCopyButton({ copy: 'my text to copy' });
46+
47+
await user.click(screen.getByRole('button'));
48+
49+
expect(mockCopyToClipboard).toHaveBeenCalledTimes(1);
50+
expect(mockCopyToClipboard).toHaveBeenCalledWith('my text to copy');
51+
});
52+
53+
it('copies result of function when clicked', async () => {
54+
const user = userEvent.setup();
55+
const copyFn = jest.fn(() => 'dynamic value');
56+
makeCopyButton({ copy: copyFn });
57+
58+
await user.click(screen.getByRole('button'));
59+
60+
expect(copyFn).toHaveBeenCalledTimes(1);
61+
expect(mockCopyToClipboard).toHaveBeenCalledWith('dynamic value');
62+
});
63+
64+
it('shows "Copied" tooltip when copied is true', () => {
65+
mockCopied = true;
66+
makeCopyButton();
67+
68+
expect(screen.getByRole('button', { name: 'Copied' })).toBeInTheDocument();
69+
});
70+
71+
it('renders children as text', () => {
72+
makeCopyButton({ children: 'Copy Text' });
73+
74+
expect(screen.getByText('Copy Text')).toBeInTheDocument();
75+
});
76+
77+
it('hides tooltip when children are provided and tooltip is default', () => {
78+
const { container } = makeCopyButton({ children: 'Copy Label' });
79+
80+
// Tooltip should not be rendered when children exist and tooltip is default
81+
expect(container.querySelector('[class*="Tooltip"]')).toBeNull();
82+
});
83+
84+
it('shows "Copied" tooltip when children are provided and copied is true', () => {
85+
mockCopied = true;
86+
makeCopyButton({ children: 'Copy Label' });
87+
88+
// "Copied" tooltip should show even with children because it differs from default
89+
expect(screen.getByRole('button', { name: 'Copied' })).toBeInTheDocument();
90+
});
91+
92+
it('shows tooltip when children are provided but tooltip is custom', () => {
93+
makeCopyButton({ children: 'Copy Label', tooltip: 'Custom tooltip' });
94+
95+
// Custom tooltip should still show even with children
96+
expect(
97+
screen.getByRole('button', { name: 'Custom tooltip' })
98+
).toBeInTheDocument();
99+
});
100+
101+
it('passes additional props to ActionButton', () => {
102+
makeCopyButton({ isDisabled: true });
103+
104+
expect(screen.getByRole('button')).toBeDisabled();
105+
});
Lines changed: 33 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,56 @@
1-
import React from 'react';
1+
/* eslint-disable react/jsx-props-no-spreading */
2+
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
23
import { vsPassFilled, vsCopy } from '@deephaven/icons';
34
import { useCopyToClipboard } from '@deephaven/react-hooks';
4-
import Button, { type ButtonKind } from './Button';
5+
import { ActionButton, Icon, Text, type ActionButtonProps } from './spectrum';
6+
import { Tooltip } from './popper';
57

6-
type CopyButtonProps = {
8+
const DEFAULT_TOOLTIP = 'Copy';
9+
10+
export interface CopyButtonProps
11+
extends Omit<ActionButtonProps, 'aria-label' | 'onPress'> {
712
/** The value to copy when clicked, accepts string or function returning a string. */
813
copy: string | (() => string);
9-
/** The kind of button */
10-
kind?: ButtonKind;
11-
/** Optional tooltip label ex. 'Copy column name' */
14+
/** Optional tooltip label ex. 'Copy column name'. Defaults to 'Copy'. */
1215
tooltip?: string;
13-
/** Optional extra classname */
14-
className?: string;
15-
/** Optional extra styles */
16-
style?: React.CSSProperties;
17-
/** Optional extra testid */
18-
'data-testid'?: string;
19-
/** Optional button children */
20-
children?: React.ReactNode;
21-
};
16+
}
2217

2318
/**
2419
* Button that has a copy icon, and copies text to a clipboard when clicked.
2520
*/
2621
function CopyButton({
2722
copy,
28-
kind = 'ghost',
29-
tooltip = 'Copy',
30-
className,
31-
style,
32-
'data-testid': dataTestId,
23+
tooltip = DEFAULT_TOOLTIP,
3324
children,
25+
...rest
3426
}: CopyButtonProps): JSX.Element {
3527
const [copied, copyToClipboard] = useCopyToClipboard();
28+
const currentTooltip = copied ? 'Copied' : tooltip;
29+
3630
return (
37-
<Button
38-
kind={kind}
39-
className={className}
40-
style={style}
41-
data-testid={dataTestId}
42-
icon={copied ? vsPassFilled : vsCopy}
43-
tooltip={copied ? 'Copied' : tooltip}
44-
onClick={() => {
31+
<ActionButton
32+
{...rest}
33+
aria-label={currentTooltip}
34+
onPress={() => {
4535
copyToClipboard(typeof copy === 'function' ? copy() : copy);
4636
}}
4737
>
48-
{children}
49-
</Button>
38+
<Icon
39+
UNSAFE_className={
40+
children == null ? 'action-button-icon-with-tooltip' : undefined
41+
}
42+
>
43+
<FontAwesomeIcon icon={copied ? vsPassFilled : vsCopy} />
44+
</Icon>
45+
{children != null && <Text>{children}</Text>}
46+
{/* Assumes children means button has a label, and no longer needs a tooltip */}
47+
{(children == null || currentTooltip !== DEFAULT_TOOLTIP) && (
48+
<Tooltip>{currentTooltip}</Tooltip>
49+
)}
50+
</ActionButton>
5051
);
5152
}
5253

54+
CopyButton.displayName = 'CopyButton';
55+
5356
export default CopyButton;

packages/components/src/ErrorView.scss

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,17 @@
6363

6464
.error-view-copy-button {
6565
min-width: 3rem;
66+
border-radius: 0;
67+
color: var(--dh-color-contrast-dark);
68+
opacity: 0.8;
69+
padding: $spacer-1;
70+
71+
--spectrum-actionbutton-background-color: var(--dh-color-negative-bg);
72+
--spectrum-actionbutton-background-color-hover: var(
73+
--dh-color-negative-hover-bg
74+
);
75+
--spectrum-actionbutton-border-color: transparent;
76+
--spectrum-actionbutton-border-color-hover: transparent;
6677
}
6778
}
6879
}

packages/components/src/ErrorView.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,8 @@ function ErrorView({
6363
</div>
6464
<div className="error-view-buttons">
6565
<CopyButton
66-
kind="danger"
67-
className="error-view-copy-button"
68-
tooltip="Copy exception contents"
66+
UNSAFE_className="error-view-copy-button"
67+
tooltip="Copy error contents"
6968
copy={`${type}: ${message}`.trim()}
7069
/>
7170
{(isExpandable || isExpanded) && !isExpandedProp && (

packages/components/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export * from './context-actions';
1010
export { default as Collapse } from './Collapse';
1111
export { default as Checkbox } from './Checkbox';
1212
export * from './ComponentUtils';
13-
export { default as CopyButton } from './CopyButton';
13+
export { default as CopyButton, type CopyButtonProps } from './CopyButton';
1414
export { default as CustomTimeSelect } from './CustomTimeSelect';
1515
export * from './DateTimeInput';
1616
export { default as DateInput } from './DateInput';

packages/console/src/console-history/ConsoleHistoryItemActions.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ const ConsoleHistoryItemActions = memo(
3939

4040
return (
4141
<div className={classNames('console-history-actions', actionBarClass)}>
42-
<CopyButton copy={item.command ?? ''} kind="inline" />
42+
{item.command != null && <CopyButton copy={item.command} />}
4343
<Button
4444
icon={vsDebugRerun}
4545
kind="inline"

packages/dashboard-core-plugins/src/panels/WidgetPanelTooltip.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@ function WidgetPanelTooltip(props: WidgetPanelTooltipProps): ReactElement {
1717
<div className="tab-tooltip-name-wrapper">
1818
<span className="tab-tooltip-name">{name}</span>
1919
<CopyButton
20-
className="tab-tooltip-copy"
20+
UNSAFE_className="tab-tooltip-copy"
2121
tooltip="Copy name"
2222
copy={name}
23+
isQuiet
2324
/>
2425
</div>
2526
{name !== displayName && Boolean(displayName) && (

0 commit comments

Comments
 (0)