GrafanaUI: Add success state to ClipboardButton (#52069)

* User Experience: apply the same pattern feedback for all copy to clipboard buttons

* add copy icon to all ClipboardButton use cases

* Change primary color for copy to clipboard in create token

* Add success button variant

* Remove copy confirmation from TableCellInspectModal because it's in the base component now

* Design tweaks to copy confirmation

 - Only change the icon to tick to avoid the button changing size
 - Change button to success green
 - Only show copy confirmation state for 2 seconds

* revert TabelCellInspectModal text button back

* revert accidental change to ShareLink

Co-authored-by: joshhunt <josh@trtr.co>
This commit is contained in:
Ezequiel Victorero 2022-07-20 06:33:46 -03:00 committed by GitHub
parent ba76be174f
commit 0633840777
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 78 additions and 125 deletions

View File

@ -11,7 +11,7 @@ import { getPropertiesForButtonSize } from '../Forms/commonStyles';
import { Icon } from '../Icon/Icon';
import { PopoverContent, Tooltip, TooltipPlacement } from '../Tooltip';
export type ButtonVariant = 'primary' | 'secondary' | 'destructive';
export type ButtonVariant = 'primary' | 'secondary' | 'destructive' | 'success';
export const allButtonVariants: ButtonVariant[] = ['primary', 'secondary', 'destructive'];
export type ButtonFill = 'solid' | 'outline' | 'text';
export const allButtonFills: ButtonFill[] = ['solid', 'outline', 'text'];
@ -294,6 +294,9 @@ export function getPropertiesForVariant(theme: GrafanaTheme2, variant: ButtonVar
case 'destructive':
return getButtonVariantStyles(theme, theme.colors.error, fill);
case 'success':
return getButtonVariantStyles(theme, theme.colors.success, fill);
case 'primary':
default:
return getButtonVariantStyles(theme, theme.colors.primary, fill);

View File

@ -1,4 +1,4 @@
import React, { useCallback, useRef } from 'react';
import React, { useCallback, useRef, useState, useEffect } from 'react';
import { Button, ButtonProps } from '../Button';
@ -11,13 +11,40 @@ export interface Props extends ButtonProps {
onClipboardError?(copiedText: string, error: unknown): void;
}
export function ClipboardButton({ onClipboardCopy, onClipboardError, children, getText, ...buttonProps }: Props) {
const SHOW_SUCCESS_DURATION = 2 * 1000;
export function ClipboardButton({
onClipboardCopy,
onClipboardError,
children,
getText,
icon,
variant,
...buttonProps
}: Props) {
const [showCopySuccess, setShowCopySuccess] = useState(false);
useEffect(() => {
let timeoutId: NodeJS.Timeout;
if (showCopySuccess) {
timeoutId = setTimeout(() => {
setShowCopySuccess(false);
}, SHOW_SUCCESS_DURATION);
}
return () => {
window.clearTimeout(timeoutId);
};
}, [showCopySuccess]);
const buttonRef = useRef<null | HTMLButtonElement>(null);
const copyTextCallback = useCallback(async () => {
const textToCopy = getText();
try {
await copyText(textToCopy, buttonRef);
setShowCopySuccess(true);
onClipboardCopy?.(textToCopy);
} catch (e) {
onClipboardError?.(textToCopy, e);
@ -25,7 +52,14 @@ export function ClipboardButton({ onClipboardCopy, onClipboardError, children, g
}, [getText, onClipboardCopy, onClipboardError]);
return (
<Button onClick={copyTextCallback} {...buttonProps} ref={buttonRef}>
<Button
onClick={copyTextCallback}
icon={showCopySuccess ? 'check' : icon}
variant={showCopySuccess ? 'success' : variant}
aria-label={showCopySuccess ? 'Copied' : undefined}
{...buttonProps}
ref={buttonRef}
>
{children}
</Button>
);

View File

@ -1,8 +1,7 @@
import { isString } from 'lodash';
import React, { useEffect, useState } from 'react';
import React from 'react';
import { ClipboardButton } from '../ClipboardButton/ClipboardButton';
import { Icon } from '../Icon/Icon';
import { Modal } from '../Modal/Modal';
import { CodeEditor } from '../Monaco/CodeEditor';
@ -13,23 +12,6 @@ interface TableCellInspectModalProps {
}
export function TableCellInspectModal({ value, onDismiss, mode }: TableCellInspectModalProps) {
const [isInClipboard, setIsInClipboard] = useState(false);
const timeoutRef = React.useRef<number>();
useEffect(() => {
if (isInClipboard) {
timeoutRef.current = window.setTimeout(() => {
setIsInClipboard(false);
}, 2000);
}
return () => {
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current);
}
};
}, [isInClipboard]);
let displayValue = value;
if (isString(value)) {
try {
@ -60,15 +42,8 @@ export function TableCellInspectModal({ value, onDismiss, mode }: TableCellInspe
<pre>{text}</pre>
)}
<Modal.ButtonRow>
<ClipboardButton getText={() => text} onClipboardCopy={() => setIsInClipboard(true)}>
{!isInClipboard ? (
'Copy to Clipboard'
) : (
<>
<Icon name="check" />
Copied to clipboard
</>
)}
<ClipboardButton icon="copy" getText={() => text}>
Copy to Clipboard
</ClipboardButton>
</Modal.ButtonRow>
</Modal>

View File

@ -1,9 +1,7 @@
import React, { PureComponent } from 'react';
import { AppEvents } from '@grafana/data';
import { getBackendSrv } from '@grafana/runtime';
import { LoadingPlaceholder, JSONFormatter, Icon, HorizontalGroup, ClipboardButton } from '@grafana/ui';
import appEvents from 'app/core/app_events';
import { DashboardModel, PanelModel } from '../dashboard/state';
@ -58,10 +56,6 @@ export class TestRuleResult extends PureComponent<Props, State> {
return JSON.stringify(this.formattedJson, null, 2);
};
onClipboardSuccess = () => {
appEvents.emit(AppEvents.alertSuccess, ['Content copied to clipboard']);
};
onToggleExpand = () => {
this.setState((prevState) => ({
...prevState,
@ -108,7 +102,7 @@ export class TestRuleResult extends PureComponent<Props, State> {
<div className="pull-right">
<HorizontalGroup spacing="md">
<div onClick={this.onToggleExpand}>{this.renderExpandCollapse()}</div>
<ClipboardButton getText={this.getTextForClipboard} onClipboardCopy={this.onClipboardSuccess} icon="copy">
<ClipboardButton getText={this.getTextForClipboard} icon="copy">
Copy to Clipboard
</ClipboardButton>
</HorizontalGroup>

View File

@ -200,9 +200,7 @@ export const RuleDetailsActionButtons: FC<Props> = ({ rule, rulesSource }) => {
rightButtons.push(
<ClipboardButton
key="copy"
onClipboardCopy={() => {
notifyApp.success('URL copied!');
}}
icon="copy"
onClipboardError={(copiedText) => {
notifyApp.error('Error while copying URL', copiedText);
}}

View File

@ -2,11 +2,7 @@
import React, { useCallback } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Alert, Field, Modal, useStyles2, Input, Icon, ClipboardButton } from '@grafana/ui';
import { notifyApp } from '../../core/actions';
import { createSuccessNotification } from '../../core/copy/appNotification';
import { dispatch } from '../../store/store';
import { Alert, Field, Modal, useStyles2, Input, ClipboardButton } from '@grafana/ui';
export interface Props {
onDismiss: () => void;
@ -17,9 +13,7 @@ export interface Props {
export function ApiKeysAddedModal({ onDismiss, apiKey, rootPath }: Props): JSX.Element {
const styles = useStyles2(getStyles);
const getClipboardText = useCallback(() => apiKey, [apiKey]);
const onClipboardCopy = () => {
dispatch(notifyApp(createSuccessNotification('Content copied to clipboard')));
};
return (
<Modal title="API Key Created" onDismiss={onDismiss} onClickBackdrop={onDismiss} isOpen>
<Field label="Key">
@ -28,8 +22,8 @@ export function ApiKeysAddedModal({ onDismiss, apiKey, rootPath }: Props): JSX.E
value={apiKey}
readOnly
addonAfter={
<ClipboardButton variant="primary" getText={getClipboardText} onClipboardCopy={onClipboardCopy}>
<Icon name="copy" /> Copy
<ClipboardButton icon="copy" variant="primary" getText={getClipboardText}>
Copy
</ClipboardButton>
}
/>

View File

@ -5,13 +5,11 @@ import React, { useCallback, useState } from 'react';
import { GrafanaTheme } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { Button, ClipboardButton, HorizontalGroup, stylesFactory, TextArea, useTheme } from '@grafana/ui';
import { useAppNotification } from 'app/core/copy/appNotification';
import { SaveDashboardFormProps } from '../types';
export const SaveProvisionedDashboardForm: React.FC<SaveDashboardFormProps> = ({ dashboard, onCancel }) => {
const theme = useTheme();
const notifyApp = useAppNotification();
const [dashboardJSON, setDashboardJson] = useState(() => {
const clone = dashboard.getSaveModelClone();
delete clone.id;
@ -25,10 +23,6 @@ export const SaveProvisionedDashboardForm: React.FC<SaveDashboardFormProps> = ({
saveAs(blob, dashboard.title + '-' + new Date().getTime() + '.json');
}, [dashboard.title, dashboardJSON]);
const onCopyToClipboardSuccess = useCallback(() => {
notifyApp.success('Dashboard JSON copied to clipboard');
}, [notifyApp]);
const styles = getStyles(theme);
return (
<>
@ -64,7 +58,7 @@ export const SaveProvisionedDashboardForm: React.FC<SaveDashboardFormProps> = ({
<Button variant="secondary" onClick={onCancel} fill="outline">
Cancel
</Button>
<ClipboardButton getText={() => dashboardJSON} onClipboardCopy={onCopyToClipboardSuccess}>
<ClipboardButton icon="copy" getText={() => dashboardJSON}>
Copy JSON to clipboard
</ClipboardButton>
<Button type="submit" onClick={saveToFile}>

View File

@ -1,9 +1,8 @@
import React, { FormEvent, PureComponent } from 'react';
import { AppEvents, SelectableValue } from '@grafana/data';
import { SelectableValue } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime/src';
import { ClipboardButton, Field, Modal, RadioButtonGroup, Switch, TextArea } from '@grafana/ui';
import { appEvents } from 'app/core/core';
import { ShareModalTabProps } from './types';
import { buildIframeHtml } from './utils';
@ -62,10 +61,6 @@ export class ShareEmbed extends PureComponent<Props, State> {
this.setState({ selectedTheme: value }, this.buildIframeHtml);
};
onIframeHtmlCopy = () => {
appEvents.emit(AppEvents.alertSuccess, ['Content copied to clipboard']);
};
getIframeHtml = () => {
return this.state.iframeHtml;
};
@ -104,7 +99,7 @@ export class ShareEmbed extends PureComponent<Props, State> {
/>
</Field>
<Modal.ButtonRow>
<ClipboardButton variant="primary" getText={this.getIframeHtml} onClipboardCopy={this.onIframeHtmlCopy}>
<ClipboardButton icon="copy" variant="primary" getText={this.getIframeHtml}>
Copy to clipboard
</ClipboardButton>
</Modal.ButtonRow>

View File

@ -1,11 +1,10 @@
import React, { PureComponent } from 'react';
import { AppEvents, SelectableValue } from '@grafana/data';
import { SelectableValue } from '@grafana/data';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { reportInteraction } from '@grafana/runtime/src';
import { Alert, ClipboardButton, Field, FieldSet, Icon, Input, RadioButtonGroup, Switch } from '@grafana/ui';
import config from 'app/core/config';
import { appEvents } from 'app/core/core';
import { ShareModalTabProps } from './types';
import { buildImageUrl, buildShareUrl } from './utils';
@ -76,10 +75,6 @@ export class ShareLink extends PureComponent<Props, State> {
this.setState({ selectedTheme: value });
};
onShareUrlCopy = () => {
appEvents.emit(AppEvents.alertSuccess, ['Content copied to clipboard']);
};
getShareUrl = () => {
return this.state.shareUrl;
};
@ -120,8 +115,8 @@ export class ShareLink extends PureComponent<Props, State> {
value={shareUrl}
readOnly
addonAfter={
<ClipboardButton variant="primary" getText={this.getShareUrl} onClipboardCopy={this.onShareUrlCopy}>
<Icon name="copy" /> Copy
<ClipboardButton icon="copy" variant="primary" getText={this.getShareUrl}>
Copy
</ClipboardButton>
}
/>

View File

@ -1,6 +1,5 @@
import React, { useCallback, useEffect, useState } from 'react';
import { AppEvents } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime/src';
import {
Alert,
@ -16,7 +15,6 @@ import {
} from '@grafana/ui';
import { notifyApp } from 'app/core/actions';
import { createErrorNotification } from 'app/core/copy/appNotification';
import { appEvents } from 'app/core/core';
import { dispatch } from 'app/store/store';
import {
@ -79,10 +77,6 @@ export const SharePublicDashboard = (props: Props) => {
savePublicDashboardConfig(props.dashboard.uid, publicDashboard, setPublicDashboardConfig).catch();
};
const onShareUrlCopy = () => {
appEvents.emit(AppEvents.alertSuccess, ['Content copied to clipboard']);
};
const onAcknowledge = useCallback(
(field: string, checked: boolean) => {
setAcknowledgements({ ...acknowledgements, [field]: checked });
@ -219,7 +213,6 @@ export const SharePublicDashboard = (props: Props) => {
getText={() => {
return generatePublicDashboardUrl(publicDashboard);
}}
onClipboardCopy={onShareUrlCopy}
>
Copy
</ClipboardButton>

View File

@ -1,9 +1,8 @@
import React, { PureComponent } from 'react';
import { AppEvents, SelectableValue } from '@grafana/data';
import { SelectableValue } from '@grafana/data';
import { getBackendSrv, reportInteraction } from '@grafana/runtime';
import { Button, ClipboardButton, Field, Icon, Input, LinkButton, Modal, Select, Spinner } from '@grafana/ui';
import { appEvents } from 'app/core/core';
import { Button, ClipboardButton, Field, Input, LinkButton, Modal, Select, Spinner } from '@grafana/ui';
import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
@ -198,10 +197,6 @@ export class ShareSnapshot extends PureComponent<Props, State> {
});
};
onSnapshotUrlCopy = () => {
appEvents.emit(AppEvents.alertSuccess, ['Content copied to clipboard']);
};
renderStep1() {
const { onDismiss } = this.props;
const { snapshotName, selectedExpireOption, timeoutSeconds, isLoading, sharingButtonText, externalEnabled } =
@ -262,17 +257,18 @@ export class ShareSnapshot extends PureComponent<Props, State> {
return (
<>
<div className="gf-form" style={{ marginTop: '40px' }}>
<div className="gf-form-row">
<a href={snapshotUrl} className="large share-modal-link" target="_blank" rel="noreferrer">
<Icon name="external-link-alt" /> {snapshotUrl}
</a>
<br />
<ClipboardButton variant="secondary" getText={this.getSnapshotUrl} onClipboardCopy={this.onSnapshotUrlCopy}>
Copy Link
</ClipboardButton>
</div>
</div>
<Field label="Snapshot URL">
<Input
id="snapshot-url-input"
value={snapshotUrl}
readOnly
addonAfter={
<ClipboardButton icon="copy" variant="primary" getText={this.getSnapshotUrl}>
Copy
</ClipboardButton>
}
/>
</Field>
<div className="pull-right" style={{ padding: '5px' }}>
Did you make a mistake?{' '}

View File

@ -3,10 +3,6 @@ import AutoSizer from 'react-virtualized-auto-sizer';
import { ClipboardButton, CodeEditor, Modal } from '@grafana/ui';
import { notifyApp } from '../../../../core/actions';
import { createSuccessNotification } from '../../../../core/copy/appNotification';
import { dispatch } from '../../../../store/store';
export interface ViewJsonModalProps {
json: string;
onDismiss: () => void;
@ -14,16 +10,13 @@ export interface ViewJsonModalProps {
export function ViewJsonModal({ json, onDismiss }: ViewJsonModalProps): JSX.Element {
const getClipboardText = useCallback(() => json, [json]);
const onClipboardCopy = () => {
dispatch(notifyApp(createSuccessNotification('Content copied to clipboard')));
};
return (
<Modal title="JSON" onDismiss={onDismiss} onClickBackdrop={onDismiss} isOpen>
<AutoSizer disableHeight>
{({ width }) => <CodeEditor value={json} language="json" showMiniMap={false} height="500px" width={width} />}
</AutoSizer>
<Modal.ButtonRow>
<ClipboardButton getText={getClipboardText} onClipboardCopy={onClipboardCopy}>
<ClipboardButton icon="copy" getText={getClipboardText}>
Copy to Clipboard
</ClipboardButton>
</Modal.ButtonRow>

View File

@ -2,12 +2,11 @@ import { css } from '@emotion/css';
import React, { PureComponent } from 'react';
import { Subscription } from 'rxjs';
import { AppEvents, DataFrame } from '@grafana/data';
import { DataFrame } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Stack } from '@grafana/experimental';
import { config, RefreshEvent } from '@grafana/runtime';
import { Button, ClipboardButton, JSONFormatter, LoadingPlaceholder } from '@grafana/ui';
import appEvents from 'app/core/app_events';
import { backendSrv } from 'app/core/services/backend_srv';
import { supportsDataQuery } from 'app/features/dashboard/components/PanelEditor/utils';
import { PanelModel } from 'app/features/dashboard/state';
@ -186,10 +185,6 @@ export class QueryInspector extends PureComponent<Props, State> {
return JSON.stringify(this.formattedJson, null, 2);
};
onClipboardSuccess = () => {
appEvents.emit(AppEvents.alertSuccess, ['Content copied to clipboard']);
};
onToggleExpand = () => {
this.setState((prevState) => ({
...prevState,
@ -297,7 +292,6 @@ export class QueryInspector extends PureComponent<Props, State> {
{haveData && (
<ClipboardButton
getText={this.getTextForClipboard}
onClipboardCopy={this.onClipboardSuccess}
className={styles.toolbarItem}
icon="copy"
variant="secondary"

View File

@ -26,7 +26,7 @@ class InviteeRow extends PureComponent<Props> {
<td>{invitee.email}</td>
<td>{invitee.name}</td>
<td className="text-right">
<ClipboardButton variant="secondary" size="sm" getText={() => invitee.url}>
<ClipboardButton icon="copy" variant="secondary" size="sm" getText={() => invitee.url}>
Copy Invite
</ClipboardButton>
&nbsp;

View File

@ -1,8 +1,7 @@
import React, { useState } from 'react';
import { AppEvents, SelectableValue, UrlQueryMap, urlUtil } from '@grafana/data';
import { Checkbox, ClipboardButton, Field, FieldSet, Icon, Input, Modal, RadioButtonGroup } from '@grafana/ui';
import appEvents from 'app/core/app_events';
import { SelectableValue, UrlQueryMap, urlUtil } from '@grafana/data';
import { Checkbox, ClipboardButton, Field, FieldSet, Input, Modal, RadioButtonGroup } from '@grafana/ui';
import { buildBaseUrl } from '../dashboard/components/ShareModal/utils';
@ -23,10 +22,6 @@ export const ShareModal = ({ playlistUid, onDismiss }: ShareModalProps) => {
{ label: 'Kiosk', value: true },
];
const onShareUrlCopy = () => {
appEvents.emit(AppEvents.alertSuccess, ['Content copied to clipboard']);
};
const params: UrlQueryMap = {};
if (mode) {
params.kiosk = mode;
@ -59,8 +54,8 @@ export const ShareModal = ({ playlistUid, onDismiss }: ShareModalProps) => {
value={shareUrl}
readOnly
addonAfter={
<ClipboardButton variant="primary" getText={() => shareUrl} onClipboardCopy={onShareUrlCopy}>
<Icon name="copy" /> Copy
<ClipboardButton icon="copy" variant="primary" getText={() => shareUrl}>
Copy
</ClipboardButton>
}
/>

View File

@ -134,7 +134,7 @@ export const CreateTokenModal = ({ isOpen, token, serviceAccountLogin, onCreateT
<Input name="tokenValue" value={token} readOnly />
<ClipboardButton
className={styles.modalCopyToClipboardButton}
variant="secondary"
variant="primary"
size="md"
icon="copy"
getText={() => token}