Skip to content
This repository has been archived by the owner on Jun 3, 2024. It is now read-only.

Added Copy to Clipboard component #932

Merged
merged 13 commits into from
Apr 12, 2021
Merged
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/).


## UNRELEASED
### Added
- [#932](https://github.com/plotly/dash-core-components/pull/932). Adds a new copy to clipboard component.


## [1.16.0] - 2021-04-08
### Added
- [#863](https://github.com/plotly/dash-core-components/pull/863) Adds a new `Download` component. Along with this several utility functions are added to help construct the appropriate data format:
Expand Down
1 change: 1 addition & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# AUTO GENERATED FILE - DO NOT EDIT

export(dccChecklist)
export(dccClipboard)
export(dccConfirmDialog)
export(dccConfirmDialogProvider)
export(dccDatePickerRange)
Expand Down
3,543 changes: 2,346 additions & 1,197 deletions package-lock.json

Large diffs are not rendered by default.

12 changes: 8 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@
"maintainer": "Ryan Patrick Kyle <ryan@plotly.com>",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.34",
"@fortawesome/free-regular-svg-icons": "^5.15.2",
"@fortawesome/free-solid-svg-icons": "^5.15.2",
"@fortawesome/react-fontawesome": "^0.1.14",
"base64-js": "^1.3.1",
"color": "^3.1.0",
"fast-isnumeric": "^1.1.3",
Expand All @@ -50,7 +54,7 @@
"prop-types": "^15.6.0",
"ramda": "^0.26.1",
"rc-slider": "^9.1.0",
"react-addons-shallow-compare": "^15.6.0",
"react-addons-shallow-compare": "^15.6.3",
"react-dates": "^20.1.0",
"react-docgen": "^3.0.0",
"react-dropzone": "^4.1.2",
Expand All @@ -61,7 +65,7 @@
"uniqid": "^5.0.3"
},
"devDependencies": {
"@babel/cli": "^7.4.0",
"@babel/cli": "^7.13.0",
"@babel/core": "^7.4.0",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.10.4",
"@babel/plugin-proposal-object-rest-spread": "^7.4.0",
Expand All @@ -76,11 +80,11 @@
"babel-loader": "^8.0.5",
"check-prop-types": "^1.1.2",
"component-playground": "^3.0.0",
"copyfiles": "^2.0.0",
"copyfiles": "^2.4.1",
"css-loader": "^1.0.1",
"enzyme": "^3.7.0",
"enzyme-adapter-react-16": "^1.7.0",
"es-check": "^5.0.0",
"es-check": "^5.2.3",
"eslint": "^5.8.0",
"eslint-config-prettier": "^3.0.1",
"eslint-plugin-import": "^2.14.0",
Expand Down
186 changes: 186 additions & 0 deletions src/components/Clipboard.react.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import React, {Component} from 'react'; // eslint-disable-line no-unused-vars
import PropTypes from 'prop-types';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faCopy, faCheckCircle} from '@fortawesome/free-regular-svg-icons';

const clipboardAPI = navigator.clipboard;

function wait(ms) {
return new Promise(r => setTimeout(r, ms));
}

/**
* The Clipboard component copies text to the clipboard
*/

export default class Clipboard extends React.Component {
constructor(props) {
super(props);
this.copyToClipboard = this.copyToClipboard.bind(this);
this.copySuccess = this.copySuccess.bind(this);
this.getTargetText = this.getTargetText.bind(this);
this.loading = this.loading.bind(this);
this.stringifyId = this.stringifyId.bind(this);
this.state = {
copied: false,
};
}

// stringifies object ids used in pattern matching callbacks
stringifyId(id) {
if (typeof id !== 'object') {
return id;
}
const stringifyVal = v => (v && v.wild) || JSON.stringify(v);
const parts = Object.keys(id)
.sort()
.map(k => JSON.stringify(k) + ':' + stringifyVal(id[k]));
return '{' + parts.join(',') + '}';
}

async copySuccess(text) {
const showCopiedIcon = 1000;
await clipboardAPI.writeText(text);
this.setState({copied: true});
await wait(showCopiedIcon);
this.setState({copied: false});
}

getTargetText() {
// get the inner text. If none, use the content of the value param
const id = this.stringifyId(this.props.target_id);
const target = document.getElementById(id);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what if you provide a bad target_id?

document.getElementById('asdf').innerText
> Uncaught TypeError: Cannot read property 'innerText' of null

perhaps add something like if (!target) { return ''; } immediately after the getElementById?

Copy link
Contributor Author

@AnnMarieW AnnMarieW Apr 7, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right - that handles the error, but it could be really hard to debug. Is there a way to display a message in the error box?

Another option - don't display the the copySuccess() animation. Do you think that would be enough of a hint to look for an invalid id?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, that's a good question - no, I don't know of a good way to expose this error to the dash devtools error box - it's happening in an event handler, and React does wrap these so it's possible there's a way we could access them from the renderer. Right now though we don't have a way to do that.

OK, so let's leave it as you have it now - that'll at least prevent copySuccess and display the error in the js console, and later we can investigate whether there's some generic way to trap event handler errors.

I suppose if you think Uncaught TypeError: Cannot read property 'innerText' of null is too removed from the issue of not having the right ID, we could also explicitly raise such an error:

if (!target) {
    throw new Error("Clipboard copy failed: no element found for target_id " + target_id);
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just belatedly passing through and noting this issue for exposing errors in Dash DevTools: plotly/dash#1088

if (!target) {
throw new Error(
'Clipboard copy failed: no element found for target_id ' +
this.props.target_id
);
}
let text = target.innerText;
if (!text) {
text = target.value;
text = text === undefined ? null : text;
}
return text;
}

async loading() {
while (this.props.loading_state?.is_loading) {
await wait(100);
}
}

async copyToClipboard() {
this.props.setProps({
n_clicks: this.props.n_clicks + 1,
});

let text;
if (this.props.target_id) {
text = this.getTargetText();
} else {
await wait(100); // gives time for callback to start
await this.loading();
text = this.props.text;
}
if (text) {
this.copySuccess(text);
}
}

componentDidMount() {
if (!clipboardAPI) {
console.warn('Copy to clipboard not available with this browser'); // eslint-disable-line no-console
}
}

render() {
const {id, title, className, style, loading_state} = this.props;
const copyIcon = <FontAwesomeIcon icon={faCopy} />;
const copiedIcon = <FontAwesomeIcon icon={faCheckCircle} />;
const btnIcon = this.state.copied ? copiedIcon : copyIcon;

return clipboardAPI ? (
<div
id={id}
title={title}
style={style}
className={className}
onClick={this.copyToClipboard}
data-dash-is-loading={
(loading_state && loading_state.is_loading) || undefined
}
>
<i> {btnIcon}</i>
</div>
) : null;
}
}

Clipboard.defaultProps = {
text: null,
target_id: null,
n_clicks: 0,
};

Clipboard.propTypes = {
/**
* The ID used to identify this component.
*/
id: PropTypes.string,

/**
* The id of target component containing text to copy to the clipboard.
* The inner text of the `children` prop will be copied to the clipboard. If none, then the text from the
* `value` prop will be copied.
*/
target_id: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),

/**
* The text to be copied to the clipboard if the `target_id` is None.
*/
text: PropTypes.string,

/**
* The number of times copy button was clicked
*/
n_clicks: PropTypes.number,

/**
* The text shown as a tooltip when hovering over the copy icon.
*/
title: PropTypes.string,

/**
* The icon's styles
*/
style: PropTypes.object,

/**
* The class name of the icon element
*/
className: PropTypes.string,

/**
* Object that holds the loading state object coming from dash-renderer
*/
loading_state: PropTypes.shape({
/**
* Determines if the component is loading or not
*/
is_loading: PropTypes.bool,
/**
* Holds which property is loading
*/
prop_name: PropTypes.string,
/**
* Holds the name of the component that is loading
*/
component_name: PropTypes.string,
}),

/**
* Dash-assigned callback that gets fired when the value changes.
*/
setProps: PropTypes.func,
};
4 changes: 3 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import Tabs from './components/Tabs.react';
import Tab from './components/Tab.react';
import Store from './components/Store.react';
import LogoutButton from './components/LogoutButton.react';
import Clipboard from './components/Clipboard.react';

import 'react-dates/lib/css/_datepicker.css';
import './components/css/react-dates@20.1.0-fix.css';
Expand Down Expand Up @@ -49,5 +50,6 @@ export {
Upload,
Store,
LogoutButton,
Download
Download,
Clipboard
};
17 changes: 17 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,20 @@ def dash_dcc(request, dash_thread_server, tmpdir):
pause=request.config.getoption("pause"),
) as dc:
yield dc


@pytest.fixture
def dash_dcc_headed(request, dash_thread_server, tmpdir):
with DashCoreComponentsComposite(
dash_thread_server,
browser=request.config.getoption("webdriver"),
remote=request.config.getoption("remote"),
remote_url=request.config.getoption("remote_url"),
headless=False,
options=request.config.hook.pytest_setup_options(),
download_path=tmpdir.mkdir("download").strpath,
percy_assets_root=request.config.getoption("percy_assets"),
percy_finalize=request.config.getoption("nopercyfinalize"),
pause=request.config.getoption("pause"),
) as dc:
yield dc
57 changes: 57 additions & 0 deletions tests/integration/clipboard/test_clipboard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import dash
import dash_core_components as dcc
import dash_html_components as html
import dash.testing.wait as wait
import time


from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.keys import Keys


def test_clp001_clipboard_text(dash_dcc_headed):
copy_text = "Hello, Dash!"
app = dash.Dash(__name__, prevent_initial_callbacks=True)
app.layout = html.Div(
[
html.Div(copy_text, id="copy"),
dcc.Clipboard(id="copy_icon", target_id="copy"),
dcc.Textarea(id="paste"),
]
)
dash_dcc_headed.start_server(app)

dash_dcc_headed.find_element("#copy_icon").click()
# time.sleep(2)
dash_dcc_headed.find_element("#paste").click()
ActionChains(dash_dcc_headed.driver).key_down(Keys.CONTROL).send_keys("v").key_up(
Keys.CONTROL
).perform()

wait.until(
lambda: dash_dcc_headed.find_element("#paste").get_attribute("value")
== copy_text,
timeout=3,
)


def test_clp002_clipboard_text(dash_dcc_headed):
copy_text = "Copy this text to the clipboard"
app = dash.Dash(__name__, prevent_initial_callbacks=True)
app.layout = html.Div(
[dcc.Clipboard(id="copy_icon", text=copy_text), dcc.Textarea(id="paste")]
)
dash_dcc_headed.start_server(app)

dash_dcc_headed.find_element("#copy_icon").click()
time.sleep(1)
dash_dcc_headed.find_element("#paste").click()
ActionChains(dash_dcc_headed.driver).key_down(Keys.CONTROL).send_keys("v").key_up(
Keys.CONTROL
).perform()

wait.until(
lambda: dash_dcc_headed.find_element("#paste").get_attribute("value")
== copy_text,
timeout=3,
)