diff --git a/CHANGELOG.md b/CHANGELOG.md index f7109f4c26..29d739b323 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/dash/dash.py b/dash/dash.py index 2f2c98e4d4..d509ea987c 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -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 @@ -636,6 +639,10 @@ def _generate_scripts_html(self): if isinstance(src, dict) else ''.format(src) for src in srcs + ] + + [ + ''.format(src) + for src in self._inline_scripts ] ) @@ -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 + 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'), @@ -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) + @@ -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} @@ -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, }, } diff --git a/dash/dependencies.py b/dash/dependencies.py index 314cf7db75..3d9b583c1a 100644 --- a/dash/dependencies.py +++ b/dash/dependencies.py @@ -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)) diff --git a/tests/integration/clientside/test_clientside.py b/tests/integration/clientside/test_clientside.py index 83d8057ecf..d610d18be9 100644 --- a/tests/integration/clientside/test_clientside.py +++ b/tests/integration/clientside/test_clientside.py @@ -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") @@ -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( @@ -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"' + )