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

Conversation

AnnMarieW
Copy link
Contributor

@AnnMarieW AnnMarieW commented Mar 4, 2021

The Clipboard component is based on the request in #1009.

The Clipboard component allows the user to copy text from the app to the browser's clipboard by clicking on a copy icon.

The easiest way to trigger the copy is by using the the target_id prop. No callback is required!

  • Place dcc.Clipboard(target_id=...) in the layout where you would like the copy icon located.
  • Specify the target_id of the component with text to copy.

Clicking the copy icon will copy the text to the clipboard and briefly update the icon to show the copy was successful.
Clipboard

This copies the inner text of components like dcc.Markdown and html.Table. For components such as dcc.Textarea or dcc.Input it copies the content of the target's value prop. If you would like to customize the text, you can do so by updating the Clipboard's content prop in a callback. This works well with components like the DataTable as shown in the following demo.

This demo shows both using Clipboard with and without a callback and 3 ways to style the copy icon.

import dash
from dash.dependencies import Input, Output, State
import dash_core_components as dcc
import dash_html_components as html
import dash_table
import pandas as pd
import numpy as np

external_stylesheets = ["https://codepen.io/chriddyp/pen/bWLwgP.css"]

app = dash.Dash(__name__, external_stylesheets=external_stylesheets)

df = pd.read_csv("https://raw.githubusercontent.com/plotly/datasets/master/solar.csv")

#Sample large dataset
df = pd.DataFrame(
    np.random.randint(0, 10000, size=(100000, 10)), columns=list("ABCDEFGHIJ")
)


code = """```
        html.Div('Copy icon in top right corner of a scrollable div'),
        html.Div([
        dcc.Markdown(code,
            id="code",
            style={"width": 800, "height":200, "overflow": "auto"},
        ),
        dcc.Clipboard(target_id="code",
                      style= {
                          "position": "absolute",
                          "top": 0,
                          "right":20,
                          "fontSize": 20,
                        }
                      ),
           ], style={"width": 800, "height":200 ,"position":"relative", "marginBottom": 100},
        ),

```"""



app.layout = html.Div(
    [
        html.H3("Copy To Clipboard Demo"),
        html.Div(
            [
                dcc.Textarea(
                    id="textarea_id", value="Text to copy", style={"width": 800, "height":100}
                ),
                dcc.Clipboard(
                    target_id="textarea_id",
                    title="copy",
                    style={
                        "display": "inline-block",
                        "fontSize": 20,
                        "verticalAlign": "top",
                    },
                ),
            ],
            style={"margin": "20px 0px"},
        ),


        html.Div('Copy icon in top right corner of a scrollable div'),
        html.Div([
        dcc.Markdown(code,
            id="code",
            style={"width": 800, "height":200, "overflow": "auto"},
        ),
        dcc.Clipboard(target_id="code",
                      style= {
                          "position": "absolute",
                          "top": 0,
                          "right":20,
                          "fontSize": 20,
                        }
                      ),
           ], style={"width": 800, "height":200 ,"position":"relative", "marginBottom": 100},
        ),


        html.Div("""Copy icon styled as a button.  It's also wrapped in the dcc.Loading'
                 component for better UI when copying larger datasets"""),
        html.Div(
            [
                dcc.Loading(
                    dcc.Clipboard(
                        id="DataTable_copy", title="copy table",
                        style={"fontSize": 25, "color":"white", "backgroundColor": "grey"},
                        className='button',
                    ),
                    type="dot",
                    parent_style={"maxWidth": 50},
                ),
                dash_table.DataTable(
                    id="DataTable",
                    columns=[{"name": i, "id": i} for i in df.columns],
                    data=df.to_dict("records"),
                    sort_action="native",
                    page_size=10
                ),
            ]
        ),
    ]
)


@app.callback(
    Output("DataTable_copy", "content"),
    Input("DataTable_copy", "n_clicks"),
    State("DataTable", "derived_virtual_data"),
    prevent_initial_call=True,
)
def custom_copy(_, data):
    dff = pd.DataFrame(data)
    text = dff.to_string()   # includes headers
    #text = dff.to_string(header=False)   # excludes headers
    return text


if __name__ == "__main__":
    app.run_server(debug=True)



componentDidMount() {
if (!navigator.clipboard) {
this.setState({hasNavigator: false});
alert('Copy to clipboard not available with this browser');
Copy link
Collaborator

Choose a reason for hiding this comment

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

I feel like an alert is too much in this case. Most of the time this component is just a convenience to the user, without it they can select and cmd/ctrl-C. So I wouldn't interrupt their experience with an alert. console.warn feels sufficient to me, so the developer will know what happened but as far as users are concerned the icon silently fails to render.

Also I don't think hasNavigator needs to be state (nor is it quite the right name...) - I bet we could make a module-level const clipboardAPI = navigator.clipboard, test for it in the constructor if (!clipboardAPI) { console.warn(...) } and even access it that way clipboardAPI.writeText(text)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good points - I included both suggestions

Choose a reason for hiding this comment

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

Hey @AnnMarieW

Why I am getting "Copy to clipboard not available with this browser" on dash 1.20.0

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hi @AltafHussain4748
The Clipboard component was not added until V1.21.0, so if you are trying to use it with V1.20.0, I would expect you to see an error like:
module 'dash_core_components' has no attribute 'Clipboard'

If you are using it with V1.21.0 and see the message: Copy to clipboard not available with this browser, it's because you are using an older browser that doesn't support the Clipboard API. See the browser compatibility list here.

* 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.string.isRequired,
Copy link
Collaborator

Choose a reason for hiding this comment

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

Two thoughts about this:

  • There may be use cases where the text you want copied doesn't map cleanly to the contents of a specific DOM element. What if we allowed you to omit target_id, and if you do that we fall back on another prop (perhaps just text) that could be set by a callback if necessary?
  • pattern-matching callbacks use object IDs, which are then stringified in a specific way when rendered into the DOM. Ideally we should do that for the user (in getText), so they can pass the object in as target_id without having to know and replicate how we stringify. The code we use for that is here and it's entirely self-contained and expected to be stable, unless @Marc-Andre-Rivet has a clever idea how to share that code I'd probably just duplicate it here.

Copy link
Contributor Author

@AnnMarieW AnnMarieW Apr 6, 2021

Choose a reason for hiding this comment

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

Regarding the first thought, I added a text prop as you suggested and also n_clicks .

Also, if there is no target_id or text, the copy icon will just act like a button. This makes it possible to use other copy to clipboard methods in a callback like pandas.DataFrame.to_clipboard which works great with the DataTable.

On the second point, the stringifyID function worked like a charm :-)


getText() {
// get the inner text. If none, use the content of the value param
var text = document.getElementById(this.props.target_id).innerText;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Use let instead of var. But also you should only need to call getElementById once ie const target = document.getElementById(stringifiedId)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Included in the commit.

Comment on lines 44 to 46
() => {
this.copySuccess();
},
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
() => {
this.copySuccess();
},
this.copySuccess,

Copy link
Contributor Author

Choose a reason for hiding this comment

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

included in next commit

@alexcjohnson
Copy link
Collaborator

alexcjohnson commented Apr 3, 2021

Looking great! Needs a changelog entry, and it would be nice to figure out how to write a test for this. navigator.clipboard.readText() is a bit tricky as it returns a promise - looking at https://stackoverflow.com/a/28066902/9188800 I suspect it would work to do something like:

copied_value = dash_dcc.driver.execute_async_script("""
const done = arguments[0];
navigator.clipboard.readText().then(val => { done(val) })
""")

or simply

copied_value = dash_dcc.driver.execute_async_script("""
navigator.clipboard.readText().then(arguments[0])
""")

I worry that this may run afoul of the permissions system though (allowing JS to read from the clipboard can be a security risk) - even if it works locally it may not work on circleCI where the browser runs headless. So it may be better to use keypresses to paste the copied value into a separate element. We have an example of this in the table tests here but that's pretty abstracted - I think it can be something like:

dash_dcc.find_element("#paste-into").click()
ActionChains(dash_dcc.driver).key_down(Keys.CONTROL).send_keys("v").key_up(Keys.CONTROL).perform()
dash_dcc.wait_for_text_to_equal("#paste-into", expected_val)

For completeness, there's also pyperclip though again I worry a bit about circleCI given pyperclip's comments about linux support.

getText() {
// 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

alert('copy error');
});
} else {
this.copySuccess();
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is the "just use this icon as a button" case where you specified neither props.text nor target_id, right? But as it is we'll also get here if props.text or the target text is ''.

I also don't think we want to promote methods like pandas.DataFrame.to_clipboard - that'll work fine when you're running the app locally, but once deployed it will fail. So seems to me this branch of calling copySuccess without actually having written anything to the clipboard is not a good idea. I'm not sure though what we should do when there's nothing to copy - put a blank string in the clipboard and still show the check mark? Show an X mark instead (for the same short duration) and don't write anything to the clipboard? I guess I'd lean toward the latter.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

OK, I'll take out the "just use the icon as a button."

It's pretty clear that the copy doesn't work when you click on the icon and nothing happens.

And It's probably best to copy nothing to the clipboard in this case. An empty string will clear out the current content.

Copy link
Collaborator

@alexcjohnson alexcjohnson left a comment

Choose a reason for hiding this comment

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

Looks great! 💃

The test failures (AttributeError: module 'dash_core_components' has no attribute 'Clipboard') I believe come from a quirk of how we build packages on CI for PRs that were open before the latest release was made. If you update to the tip of dev it should work again. Note though that you'll probably have to go in and adjust the CHANGELOG to create a new UNRELEASED section and put this PR into it, just letting git do the merge will leave this PR looking like it was already released!


elem = dash_dcc.find_element("#paste")
val = elem.get_attribute("value")
assert val == copy_text
Copy link
Collaborator

Choose a reason for hiding this comment

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

Now both tests are failing on this assertion... either one of these operations is disallowed in the CI environment or it's slower here and the pasted value hasn't shown up yet. I don't think it's slower, because both tests on all Python versions are consistently showing this same error and anyway we do essentially the same stuff in dash-table tests. Same reasoning says paste is not the problem, so the only thing left AFAICT is the clipboard API being blocked.

Here's a stackoverflow post that looks like the same issue: https://stackoverflow.com/questions/53669639/enable-clipboard-in-automated-tests-with-protractor-and-webdriver

Unfortunately the fix looks like it would need to be deep inside dash.testing, I don't see a way to do it locally here.

So if you see these tests passing locally, I'm comfortable skipping them on CI for now and opening an issue in the main dash repo to follow up. You can do this with something like:

@pytest.mark.skipif(
    'CIRCLECI' in os.environ,
    reason="clipboard API is blocked on CI, see <dash issue url>"
)
def test_clp001...

@alexcjohnson alexcjohnson merged commit aa5ea32 into plotly:dev Apr 12, 2021
@AltafHussain4748
Copy link

Hey Everyone,

I am getting below issue

Property "text" was used with component ID:
"DataTable_copy"
in one of the Output items of a callback.
This ID is assigned to a dash_core_components.Clipboard component
in the layout, which does not support this property.
This ID was used in the callback(s) for Output(s):
DataTable_copy.text

@AnnMarieW
Copy link
Contributor Author

Hi @AltafHussain4748 The prop name text was changed to content during review. I just updated the example app in this PR.

You can also see more examples in the announcement of Dash 1.21.0 here.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants