Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Inline clientside callbacks #967

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

## Unreleased
### Added
- [#967](https://github.com/plotly/dash/pull/967) Adds support for defining
clientside JavaScript callbacks via inline strings.

## [1.6.1] - 2019-11-14
### Fixed
- [#1006](https://github.com/plotly/dash/pull/1006) Fix IE11 / ES5 compatibility and validation issues
Expand Down
81 changes: 67 additions & 14 deletions dash/dash.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,9 @@ def __init__(
# list of dependencies
self.callback_map = {}

# list of inline scripts
self._inline_scripts = []

# index_string has special setter so can't go in config
self._index_string = ""
self.index_string = index_string
Expand Down Expand Up @@ -636,6 +639,10 @@ def _generate_scripts_html(self):
if isinstance(src, dict)
else '<script src="{}"></script>'.format(src)
for src in srcs
] +
[
'<script>{}</script>'.format(src)
for src in self._inline_scripts
]
)

Expand Down Expand Up @@ -1194,13 +1201,14 @@ def clientside_callback(
(JavaScript) function instead of a Python function.

Unlike `@app.calllback`, `clientside_callback` is not a decorator:
it takes a
it takes either a
`dash.dependencies.ClientsideFunction(namespace, function_name)`
argument that describes which JavaScript function to call
(Dash will look for the JavaScript function at
`window[namespace][function_name]`).
`window.dash_clientside[namespace][function_name]`), or it may take
Copy link
Collaborator

Choose a reason for hiding this comment

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

good catch! 🔬

a string argument that contains the clientside function source.

For example:
For example, when using a `dash.dependencies.ClientsideFunction`:
```
app.clientside_callback(
ClientsideFunction('my_clientside_library', 'my_function'),
Expand All @@ -1211,16 +1219,17 @@ def clientside_callback(
```

With this signature, Dash's front-end will call
`window.my_clientside_library.my_function` with the current
values of the `value` properties of the components
`my-input` and `another-input` whenever those values change.

Include a JavaScript file by including it your `assets/` folder.
The file can be named anything but you'll need to assign the
function's namespace to the `window`. For example, this file might
look like:
`window.dash_clientside.my_clientside_library.my_function` with the
current values of the `value` properties of the components `my-input`
and `another-input` whenever those values change.

Include a JavaScript file by including it your `assets/` folder. The
file can be named anything but you'll need to assign the function's
namespace to the `window.dash_clientside` namespace. For example,
this file might look:
```
window.my_clientside_library = {
window.dash_clientside = window.dash_clientside || {};
window.dash_clientside.my_clientside_library = {
my_function: function(input_value_1, input_value_2) {
return (
parseFloat(input_value_1, 10) +
Expand All @@ -1229,10 +1238,54 @@ def clientside_callback(
}
}
```

Alternatively, you can pass the JavaScript source directly to
`clientside_callback`. In this case, the same example would look like:
```
app.clientside_callback(
'''
function(input_value_1, input_value_2) {
return (
parseFloat(input_value_1, 10) +
parseFloat(input_value_2, 10)
);
}
''',
Output('my-div' 'children'),
[Input('my-input', 'value'),
Input('another-input', 'value')]
)
```
"""
self._validate_callback(output, inputs, state)
callback_id = _create_callback_id(output)

# If JS source is explicitly given, create a namespace and function
# name, then inject the code.
if isinstance(clientside_function, str):

out0 = output
if isinstance(output, (list, tuple)):
out0 = output[0]

namespace = '_dashprivate_{}'.format(out0.component_id)
function_name = '{}'.format(out0.component_property)

self._inline_scripts.append(
"""
var clientside = window.dash_clientside = window.dash_clientside || {{}};
var ns = clientside["{0}"] = clientside["{0}"] || {{}};
ns["{1}"] = {2};
""".format(namespace.replace('"', '\\"'),
function_name.replace('"', '\\"'),
clientside_function)
)

# Callback is stored in an external asset.
else:
namespace = clientside_function.namespace
function_name = clientside_function.function_name

self.callback_map[callback_id] = {
"inputs": [
{"id": c.component_id, "property": c.component_property}
Expand All @@ -1243,8 +1296,8 @@ def clientside_callback(
for c in state
],
"clientside_function": {
"namespace": clientside_function.namespace,
"function_name": clientside_function.function_name,
"namespace": namespace,
"function_name": function_name,
},
}

Expand Down
3 changes: 3 additions & 0 deletions dash/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ class ClientsideFunction:
# pylint: disable=too-few-public-methods
def __init__(self, namespace=None, function_name=None):

if namespace.startswith('_dashprivate_'):
raise ValueError("Namespaces cannot start with '_dashprivate_'.")

if namespace in ['PreventUpdate', 'no_update']:
raise ValueError('"{}" is a forbidden namespace in'
' dash_clientside.'.format(namespace))
Expand Down
45 changes: 43 additions & 2 deletions tests/integration/clientside/test_clientside.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,6 @@ def test_clsd005_clientside_fails_when_returning_a_promise(dash_duo):
dash_duo.wait_for_text_to_equal("#side-effect", "side effect")
dash_duo.wait_for_text_to_equal("#output", "output")


def test_clsd006_PreventUpdate(dash_duo):
app = Dash(__name__, assets_folder="assets")

Expand Down Expand Up @@ -307,7 +306,7 @@ def test_clsd006_PreventUpdate(dash_duo):
dash_duo.wait_for_text_to_equal("#third", '3')


def test_clsd006_no_update(dash_duo):
def test_clsd007_no_update(dash_duo):
app = Dash(__name__, assets_folder="assets")

app.layout = html.Div(
Expand Down Expand Up @@ -344,3 +343,45 @@ def test_clsd006_no_update(dash_duo):
dash_duo.wait_for_text_to_equal("#first", '111')
dash_duo.wait_for_text_to_equal("#second", '3')
dash_duo.wait_for_text_to_equal("#third", '4')

def test_clsd008_clientside_inline_source(dash_duo):
app = Dash(__name__, assets_folder="assets")

app.layout = html.Div(
[
dcc.Input(id="input"),
html.Div(id="output-clientside"),
html.Div(id="output-serverside"),
]
)

@app.callback(
Output("output-serverside", "children"), [Input("input", "value")]
)
def update_output(value):
return 'Server says "{}"'.format(value)

app.clientside_callback(
"""
function (value) {
return 'Client says "' + value + '"';
}
""",
Output("output-clientside", "children"),
[Input("input", "value")],
)

dash_duo.start_server(app)

dash_duo.wait_for_text_to_equal("#output-serverside", 'Server says "None"')
dash_duo.wait_for_text_to_equal(
"#output-clientside", 'Client says "undefined"'
)

dash_duo.find_element("#input").send_keys("hello world")
dash_duo.wait_for_text_to_equal(
"#output-serverside", 'Server says "hello world"'
)
dash_duo.wait_for_text_to_equal(
"#output-clientside", 'Client says "hello world"'
)