From 532c195ab0932917b3e1ec044e937ecf03730521 Mon Sep 17 00:00:00 2001 From: Jonathan Jara-Almonte Date: Mon, 14 Oct 2019 22:58:51 -0400 Subject: [PATCH 1/5] Adds no_update and PreventUpdate to clientside callbacks. --- dash-renderer/src/actions/index.js | 36 ++++++++ dash/dependencies.py | 5 ++ .../clientside/assets/clientside.js | 14 +++ .../integration/clientside/test_clientside.py | 85 ++++++++++++++++++- 4 files changed, 139 insertions(+), 1 deletion(-) diff --git a/dash-renderer/src/actions/index.js b/dash-renderer/src/actions/index.js index 6e4557e984..1f453989d9 100644 --- a/dash-renderer/src/actions/index.js +++ b/dash-renderer/src/actions/index.js @@ -566,6 +566,26 @@ function updateOutput( // Clientside hook if (clientside_function) { let returnValue; + + /* + * Create the dash_clientside namespace if it doesn't exist and inject + * no_update and PreventUpdate. + */ + if (!window.dash_clientside) + window.dash_clientside = {}; + + if (!window.dash_clientside.no_update) { + Object.defineProperty(window.dash_clientside, 'no_update', { + value: {description: 'Return to prevent updating an Output.'}, + writable: false + }); + + Object.defineProperty(window.dash_clientside, 'PreventUpdate', { + value: {description: 'Throw to prevent updating all Outputs.'}, + writable: false + }); + } + try { returnValue = window.dash_clientside[clientside_function.namespace][ clientside_function.function_name @@ -574,6 +594,15 @@ function updateOutput( ...(has('state', payload) ? pluck('value', payload.state) : []) ); } catch (e) { + + /* + * Prevent all updates. + */ + if (e === window.dash_clientside.PreventUpdate) { + updateRequestQueue(true, STATUS.PREVENT_UPDATE); + return; + } + /* eslint-disable no-console */ console.error( `The following error occurred while executing ${clientside_function.namespace}.${clientside_function.function_name} ` + @@ -618,6 +647,13 @@ function updateOutput( * like a successful serverside response (200 status code) */ updateRequestQueue(false, STATUS.OK); + + /* + * Prevent update. + */ + if(outputValue === window.dash_clientside.no_update) { + return; + } // Update the layout with the new result const appliedProps = doUpdateProps(outputId, updatedProps); diff --git a/dash/dependencies.py b/dash/dependencies.py index 9ba9a9fbff..314cf7db75 100644 --- a/dash/dependencies.py +++ b/dash/dependencies.py @@ -35,6 +35,11 @@ class State(DashDependency): # pylint: disable=too-few-public-methods class ClientsideFunction: # pylint: disable=too-few-public-methods def __init__(self, namespace=None, function_name=None): + + if namespace in ['PreventUpdate', 'no_update']: + raise ValueError('"{}" is a forbidden namespace in' + ' dash_clientside.'.format(namespace)) + self.namespace = namespace self.function_name = function_name diff --git a/tests/integration/clientside/assets/clientside.js b/tests/integration/clientside/assets/clientside.js index 2a02278c0b..067d7b4a1f 100644 --- a/tests/integration/clientside/assets/clientside.js +++ b/tests/integration/clientside/assets/clientside.js @@ -22,6 +22,20 @@ window.dash_clientside.clientside = { return parseInt(value, 10) + 1; }, + add1_prevent_at_11: function (value1, value2) { + if (parseInt(value1, 10) === 11) { + throw window.dash_clientside.PreventUpdate; + } + return parseInt(value2, 10) + 1; + }, + + add1_no_update_at_11: function (value1, value2, value3) { + if (parseInt(value1, 10) === 11) { + return [window.dash_clientside.no_update, parseInt(value3, 10) + 1]; + } + return [parseInt(value2, 10) + 1, parseInt(value3, 10) + 1]; + }, + add_to_four_outputs: function(value) { return [ parseInt(value) + 1, diff --git a/tests/integration/clientside/test_clientside.py b/tests/integration/clientside/test_clientside.py index c83a5ef5b9..83d8057ecf 100644 --- a/tests/integration/clientside/test_clientside.py +++ b/tests/integration/clientside/test_clientside.py @@ -4,7 +4,7 @@ import dash_html_components as html import dash_core_components as dcc from dash import Dash -from dash.dependencies import Input, Output, ClientsideFunction +from dash.dependencies import Input, Output, State, ClientsideFunction def test_clsd001_simple_clientside_serverside_callback(dash_duo): @@ -261,3 +261,86 @@ def test_clsd005_clientside_fails_when_returning_a_promise(dash_duo): dash_duo.wait_for_text_to_equal("#input", "hello") 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") + + app.layout = html.Div( + [ + dcc.Input(id="first", value=1), + dcc.Input(id="second", value=1), + dcc.Input(id="third", value=1) + ] + ) + + app.clientside_callback( + ClientsideFunction(namespace="clientside", function_name="add1_prevent_at_11"), + Output("second", "value"), + [Input("first", "value")], + [State("second", "value")] + ) + + app.clientside_callback( + ClientsideFunction(namespace="clientside", function_name="add1_prevent_at_11"), + Output("third", "value"), + [Input("second", "value")], + [State("third", "value")] + ) + + dash_duo.start_server(app) + + dash_duo.wait_for_text_to_equal("#first", '1') + dash_duo.wait_for_text_to_equal("#second", '2') + dash_duo.wait_for_text_to_equal("#third", '2') + + dash_duo.find_element("#first").send_keys("1") + + dash_duo.wait_for_text_to_equal("#first", '11') + dash_duo.wait_for_text_to_equal("#second", '2') + dash_duo.wait_for_text_to_equal("#third", '2') + + dash_duo.find_element("#first").send_keys("1") + + 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", '3') + + +def test_clsd006_no_update(dash_duo): + app = Dash(__name__, assets_folder="assets") + + app.layout = html.Div( + [ + dcc.Input(id="first", value=1), + dcc.Input(id="second", value=1), + dcc.Input(id="third", value=1) + ] + ) + + app.clientside_callback( + ClientsideFunction(namespace="clientside", function_name="add1_no_update_at_11"), + [Output("second", "value"), + Output("third", "value")], + [Input("first", "value")], + [State("second", "value"), + State("third", "value")] + ) + + dash_duo.start_server(app) + + dash_duo.wait_for_text_to_equal("#first", '1') + dash_duo.wait_for_text_to_equal("#second", '2') + dash_duo.wait_for_text_to_equal("#third", '2') + + dash_duo.find_element("#first").send_keys("1") + + dash_duo.wait_for_text_to_equal("#first", '11') + dash_duo.wait_for_text_to_equal("#second", '2') + dash_duo.wait_for_text_to_equal("#third", '3') + + dash_duo.find_element("#first").send_keys("1") + + 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') From 40256478798b0aff8b336b813378ec8306b11e04 Mon Sep 17 00:00:00 2001 From: Jonathan Jara-Almonte Date: Mon, 14 Oct 2019 22:59:49 -0400 Subject: [PATCH 2/5] Lets Popen search the path on windows. --- dash/_utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dash/_utils.py b/dash/_utils.py index da0a79da1b..4816864385 100644 --- a/dash/_utils.py +++ b/dash/_utils.py @@ -128,7 +128,8 @@ def create_callback_id(output): def run_command_with_process(cmd): - proc = subprocess.Popen(shlex.split(cmd, posix=sys.platform != "win32")) + is_win = sys.platform == "win32" + proc = subprocess.Popen(shlex.split(cmd, posix=is_win), shell=is_win) proc.wait() if proc.poll() is None: logger.warning("🚨 trying to terminate subprocess in safe way") From 903efdd88be80d671daadaedf5ef96b68f5532a0 Mon Sep 17 00:00:00 2001 From: Jonathan Jara-Almonte Date: Mon, 14 Oct 2019 23:18:30 -0400 Subject: [PATCH 3/5] Appeasing the lint gods. --- dash-renderer/src/actions/index.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dash-renderer/src/actions/index.js b/dash-renderer/src/actions/index.js index 1f453989d9..30a67de8a6 100644 --- a/dash-renderer/src/actions/index.js +++ b/dash-renderer/src/actions/index.js @@ -571,8 +571,9 @@ function updateOutput( * Create the dash_clientside namespace if it doesn't exist and inject * no_update and PreventUpdate. */ - if (!window.dash_clientside) + if (!window.dash_clientside) { window.dash_clientside = {}; + } if (!window.dash_clientside.no_update) { Object.defineProperty(window.dash_clientside, 'no_update', { From c4f6f5e9b34ea46e8066d91bf7397241c0bf4948 Mon Sep 17 00:00:00 2001 From: Jonathan Jara-Almonte Date: Tue, 15 Oct 2019 19:34:06 -0400 Subject: [PATCH 4/5] Making it prettier? --- dash-renderer/src/actions/index.js | 41 +++++++++++++++--------------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/dash-renderer/src/actions/index.js b/dash-renderer/src/actions/index.js index 30a67de8a6..09bce7391f 100644 --- a/dash-renderer/src/actions/index.js +++ b/dash-renderer/src/actions/index.js @@ -566,27 +566,27 @@ function updateOutput( // Clientside hook if (clientside_function) { let returnValue; - + /* - * Create the dash_clientside namespace if it doesn't exist and inject + * Create the dash_clientside namespace if it doesn't exist and inject * no_update and PreventUpdate. */ if (!window.dash_clientside) { - window.dash_clientside = {}; + window.dash_clientside = {}; } - + if (!window.dash_clientside.no_update) { - Object.defineProperty(window.dash_clientside, 'no_update', { - value: {description: 'Return to prevent updating an Output.'}, - writable: false - }); - - Object.defineProperty(window.dash_clientside, 'PreventUpdate', { - value: {description: 'Throw to prevent updating all Outputs.'}, - writable: false - }); + Object.defineProperty(window.dash_clientside, 'no_update', { + value: {description: 'Return to prevent updating an Output.'}, + writable: false, + }); + + Object.defineProperty(window.dash_clientside, 'PreventUpdate', { + value: {description: 'Throw to prevent updating all Outputs.'}, + writable: false, + }); } - + try { returnValue = window.dash_clientside[clientside_function.namespace][ clientside_function.function_name @@ -595,15 +595,14 @@ function updateOutput( ...(has('state', payload) ? pluck('value', payload.state) : []) ); } catch (e) { - /* * Prevent all updates. */ if (e === window.dash_clientside.PreventUpdate) { - updateRequestQueue(true, STATUS.PREVENT_UPDATE); - return; + updateRequestQueue(true, STATUS.PREVENT_UPDATE); + return; } - + /* eslint-disable no-console */ console.error( `The following error occurred while executing ${clientside_function.namespace}.${clientside_function.function_name} ` + @@ -648,12 +647,12 @@ function updateOutput( * like a successful serverside response (200 status code) */ updateRequestQueue(false, STATUS.OK); - + /* * Prevent update. */ - if(outputValue === window.dash_clientside.no_update) { - return; + if (outputValue === window.dash_clientside.no_update) { + return; } // Update the layout with the new result From d1b1820d596edafbf4d9f6a826f96c11121d9ab2 Mon Sep 17 00:00:00 2001 From: Jonathan Jara-Almonte Date: Thu, 17 Oct 2019 21:22:17 -0400 Subject: [PATCH 5/5] Adding a changelog entry. --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f889d87c0c..4fdbc465e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,12 @@ # Change Log for Dash All notable changes to `dash` will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). - +## Unreleased +### Added +- [#964](https://github.com/plotly/dash/pull/964) Adds support for preventing +updates in clientside functions. + - Reject all updates with `throw window.dash_clientside.PreventUpdate;` + - Reject a single output by returning `window.dash_clientside.no_update` ## [1.4.0] - 2019-10-08 ### Added - [#948](https://github.com/plotly/dash/pull/948) Support setting working directory for R apps run using the `dashr` fixture, primarily useful for tests with assets. `dashr.start_server` supports a `cwd` argument to set an explicit working directory, and has smarter defaults when it's omitted: if `app` is a path to an R script, uses the directory of that path; if `app` is a string, uses the directory the test file itself is in.