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

Commit aa5ea32

Browse files
authored
Merge pull request #932 from AnnMarieW/clipboard
Added Copy to Clipboard component
2 parents fe60f3c + 8c6f735 commit aa5ea32

File tree

8 files changed

+2624
-1202
lines changed

8 files changed

+2624
-1202
lines changed

Diff for: CHANGELOG.md

+6
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22
All notable changes to this project will be documented in this file.
33
This project adheres to [Semantic Versioning](http://semver.org/).
44

5+
6+
## UNRELEASED
7+
### Added
8+
- [#932](https://github.com/plotly/dash-core-components/pull/932). Adds a new copy to clipboard component.
9+
10+
511
## [1.16.0] - 2021-04-08
612
### Added
713
- [#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:

Diff for: NAMESPACE

+1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# AUTO GENERATED FILE - DO NOT EDIT
22

33
export(dccChecklist)
4+
export(dccClipboard)
45
export(dccConfirmDialog)
56
export(dccConfirmDialogProvider)
67
export(dccDatePickerRange)

Diff for: package-lock.json

+2,346-1,197
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Diff for: package.json

+8-4
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@
4040
"maintainer": "Ryan Patrick Kyle <[email protected]>",
4141
"license": "MIT",
4242
"dependencies": {
43+
"@fortawesome/fontawesome-svg-core": "^1.2.34",
44+
"@fortawesome/free-regular-svg-icons": "^5.15.2",
45+
"@fortawesome/free-solid-svg-icons": "^5.15.2",
46+
"@fortawesome/react-fontawesome": "^0.1.14",
4347
"base64-js": "^1.3.1",
4448
"color": "^3.1.0",
4549
"fast-isnumeric": "^1.1.3",
@@ -50,7 +54,7 @@
5054
"prop-types": "^15.6.0",
5155
"ramda": "^0.26.1",
5256
"rc-slider": "^9.1.0",
53-
"react-addons-shallow-compare": "^15.6.0",
57+
"react-addons-shallow-compare": "^15.6.3",
5458
"react-dates": "^20.1.0",
5559
"react-docgen": "^3.0.0",
5660
"react-dropzone": "^4.1.2",
@@ -61,7 +65,7 @@
6165
"uniqid": "^5.0.3"
6266
},
6367
"devDependencies": {
64-
"@babel/cli": "^7.4.0",
68+
"@babel/cli": "^7.13.0",
6569
"@babel/core": "^7.4.0",
6670
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.10.4",
6771
"@babel/plugin-proposal-object-rest-spread": "^7.4.0",
@@ -76,11 +80,11 @@
7680
"babel-loader": "^8.0.5",
7781
"check-prop-types": "^1.1.2",
7882
"component-playground": "^3.0.0",
79-
"copyfiles": "^2.0.0",
83+
"copyfiles": "^2.4.1",
8084
"css-loader": "^1.0.1",
8185
"enzyme": "^3.7.0",
8286
"enzyme-adapter-react-16": "^1.7.0",
83-
"es-check": "^5.0.0",
87+
"es-check": "^5.2.3",
8488
"eslint": "^5.8.0",
8589
"eslint-config-prettier": "^3.0.1",
8690
"eslint-plugin-import": "^2.14.0",

Diff for: src/components/Clipboard.react.js

+186
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import React, {Component} from 'react'; // eslint-disable-line no-unused-vars
2+
import PropTypes from 'prop-types';
3+
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
4+
import {faCopy, faCheckCircle} from '@fortawesome/free-regular-svg-icons';
5+
6+
const clipboardAPI = navigator.clipboard;
7+
8+
function wait(ms) {
9+
return new Promise(r => setTimeout(r, ms));
10+
}
11+
12+
/**
13+
* The Clipboard component copies text to the clipboard
14+
*/
15+
16+
export default class Clipboard extends React.Component {
17+
constructor(props) {
18+
super(props);
19+
this.copyToClipboard = this.copyToClipboard.bind(this);
20+
this.copySuccess = this.copySuccess.bind(this);
21+
this.getTargetText = this.getTargetText.bind(this);
22+
this.loading = this.loading.bind(this);
23+
this.stringifyId = this.stringifyId.bind(this);
24+
this.state = {
25+
copied: false,
26+
};
27+
}
28+
29+
// stringifies object ids used in pattern matching callbacks
30+
stringifyId(id) {
31+
if (typeof id !== 'object') {
32+
return id;
33+
}
34+
const stringifyVal = v => (v && v.wild) || JSON.stringify(v);
35+
const parts = Object.keys(id)
36+
.sort()
37+
.map(k => JSON.stringify(k) + ':' + stringifyVal(id[k]));
38+
return '{' + parts.join(',') + '}';
39+
}
40+
41+
async copySuccess(text) {
42+
const showCopiedIcon = 1000;
43+
await clipboardAPI.writeText(text);
44+
this.setState({copied: true});
45+
await wait(showCopiedIcon);
46+
this.setState({copied: false});
47+
}
48+
49+
getTargetText() {
50+
// get the inner text. If none, use the content of the value param
51+
const id = this.stringifyId(this.props.target_id);
52+
const target = document.getElementById(id);
53+
if (!target) {
54+
throw new Error(
55+
'Clipboard copy failed: no element found for target_id ' +
56+
this.props.target_id
57+
);
58+
}
59+
let text = target.innerText;
60+
if (!text) {
61+
text = target.value;
62+
text = text === undefined ? null : text;
63+
}
64+
return text;
65+
}
66+
67+
async loading() {
68+
while (this.props.loading_state?.is_loading) {
69+
await wait(100);
70+
}
71+
}
72+
73+
async copyToClipboard() {
74+
this.props.setProps({
75+
n_clicks: this.props.n_clicks + 1,
76+
});
77+
78+
let text;
79+
if (this.props.target_id) {
80+
text = this.getTargetText();
81+
} else {
82+
await wait(100); // gives time for callback to start
83+
await this.loading();
84+
text = this.props.text;
85+
}
86+
if (text) {
87+
this.copySuccess(text);
88+
}
89+
}
90+
91+
componentDidMount() {
92+
if (!clipboardAPI) {
93+
console.warn('Copy to clipboard not available with this browser'); // eslint-disable-line no-console
94+
}
95+
}
96+
97+
render() {
98+
const {id, title, className, style, loading_state} = this.props;
99+
const copyIcon = <FontAwesomeIcon icon={faCopy} />;
100+
const copiedIcon = <FontAwesomeIcon icon={faCheckCircle} />;
101+
const btnIcon = this.state.copied ? copiedIcon : copyIcon;
102+
103+
return clipboardAPI ? (
104+
<div
105+
id={id}
106+
title={title}
107+
style={style}
108+
className={className}
109+
onClick={this.copyToClipboard}
110+
data-dash-is-loading={
111+
(loading_state && loading_state.is_loading) || undefined
112+
}
113+
>
114+
<i> {btnIcon}</i>
115+
</div>
116+
) : null;
117+
}
118+
}
119+
120+
Clipboard.defaultProps = {
121+
text: null,
122+
target_id: null,
123+
n_clicks: 0,
124+
};
125+
126+
Clipboard.propTypes = {
127+
/**
128+
* The ID used to identify this component.
129+
*/
130+
id: PropTypes.string,
131+
132+
/**
133+
* The id of target component containing text to copy to the clipboard.
134+
* The inner text of the `children` prop will be copied to the clipboard. If none, then the text from the
135+
* `value` prop will be copied.
136+
*/
137+
target_id: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
138+
139+
/**
140+
* The text to be copied to the clipboard if the `target_id` is None.
141+
*/
142+
text: PropTypes.string,
143+
144+
/**
145+
* The number of times copy button was clicked
146+
*/
147+
n_clicks: PropTypes.number,
148+
149+
/**
150+
* The text shown as a tooltip when hovering over the copy icon.
151+
*/
152+
title: PropTypes.string,
153+
154+
/**
155+
* The icon's styles
156+
*/
157+
style: PropTypes.object,
158+
159+
/**
160+
* The class name of the icon element
161+
*/
162+
className: PropTypes.string,
163+
164+
/**
165+
* Object that holds the loading state object coming from dash-renderer
166+
*/
167+
loading_state: PropTypes.shape({
168+
/**
169+
* Determines if the component is loading or not
170+
*/
171+
is_loading: PropTypes.bool,
172+
/**
173+
* Holds which property is loading
174+
*/
175+
prop_name: PropTypes.string,
176+
/**
177+
* Holds the name of the component that is loading
178+
*/
179+
component_name: PropTypes.string,
180+
}),
181+
182+
/**
183+
* Dash-assigned callback that gets fired when the value changes.
184+
*/
185+
setProps: PropTypes.func,
186+
};

Diff for: src/index.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import Tabs from './components/Tabs.react';
2222
import Tab from './components/Tab.react';
2323
import Store from './components/Store.react';
2424
import LogoutButton from './components/LogoutButton.react';
25+
import Clipboard from './components/Clipboard.react';
2526

2627
import 'react-dates/lib/css/_datepicker.css';
2728
import './components/css/[email protected]';
@@ -49,5 +50,6 @@ export {
4950
Upload,
5051
Store,
5152
LogoutButton,
52-
Download
53+
Download,
54+
Clipboard
5355
};

Diff for: tests/conftest.py

+17
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,20 @@ def dash_dcc(request, dash_thread_server, tmpdir):
3333
pause=request.config.getoption("pause"),
3434
) as dc:
3535
yield dc
36+
37+
38+
@pytest.fixture
39+
def dash_dcc_headed(request, dash_thread_server, tmpdir):
40+
with DashCoreComponentsComposite(
41+
dash_thread_server,
42+
browser=request.config.getoption("webdriver"),
43+
remote=request.config.getoption("remote"),
44+
remote_url=request.config.getoption("remote_url"),
45+
headless=False,
46+
options=request.config.hook.pytest_setup_options(),
47+
download_path=tmpdir.mkdir("download").strpath,
48+
percy_assets_root=request.config.getoption("percy_assets"),
49+
percy_finalize=request.config.getoption("nopercyfinalize"),
50+
pause=request.config.getoption("pause"),
51+
) as dc:
52+
yield dc

Diff for: tests/integration/clipboard/test_clipboard.py

+57
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import dash
2+
import dash_core_components as dcc
3+
import dash_html_components as html
4+
import dash.testing.wait as wait
5+
import time
6+
7+
8+
from selenium.webdriver.common.action_chains import ActionChains
9+
from selenium.webdriver.common.keys import Keys
10+
11+
12+
def test_clp001_clipboard_text(dash_dcc_headed):
13+
copy_text = "Hello, Dash!"
14+
app = dash.Dash(__name__, prevent_initial_callbacks=True)
15+
app.layout = html.Div(
16+
[
17+
html.Div(copy_text, id="copy"),
18+
dcc.Clipboard(id="copy_icon", target_id="copy"),
19+
dcc.Textarea(id="paste"),
20+
]
21+
)
22+
dash_dcc_headed.start_server(app)
23+
24+
dash_dcc_headed.find_element("#copy_icon").click()
25+
# time.sleep(2)
26+
dash_dcc_headed.find_element("#paste").click()
27+
ActionChains(dash_dcc_headed.driver).key_down(Keys.CONTROL).send_keys("v").key_up(
28+
Keys.CONTROL
29+
).perform()
30+
31+
wait.until(
32+
lambda: dash_dcc_headed.find_element("#paste").get_attribute("value")
33+
== copy_text,
34+
timeout=3,
35+
)
36+
37+
38+
def test_clp002_clipboard_text(dash_dcc_headed):
39+
copy_text = "Copy this text to the clipboard"
40+
app = dash.Dash(__name__, prevent_initial_callbacks=True)
41+
app.layout = html.Div(
42+
[dcc.Clipboard(id="copy_icon", text=copy_text), dcc.Textarea(id="paste")]
43+
)
44+
dash_dcc_headed.start_server(app)
45+
46+
dash_dcc_headed.find_element("#copy_icon").click()
47+
time.sleep(1)
48+
dash_dcc_headed.find_element("#paste").click()
49+
ActionChains(dash_dcc_headed.driver).key_down(Keys.CONTROL).send_keys("v").key_up(
50+
Keys.CONTROL
51+
).perform()
52+
53+
wait.until(
54+
lambda: dash_dcc_headed.find_element("#paste").get_attribute("value")
55+
== copy_text,
56+
timeout=3,
57+
)

0 commit comments

Comments
 (0)