diff --git a/CHANGELOG.md b/CHANGELOG.md index fe67863ca0..70685b2b1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to `dash` will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +## [UNRELEASED] +### Added +- [#1240](https://github.com/plotly/dash/pull/1240) Adds `callback_context` to clientside callbacks (e.g. `dash_clientside.callback_context.triggered`). Supports `triggered`, `inputs`, `inputs_list`, `states`, and `states_list`, all of which closely resemble their serverside cousins. + ## [1.12.0] - 2020-05-05 ### Added - [#1228](https://github.com/plotly/dash/pull/1228) Adds control over firing callbacks on page (or layout chunk) load. Individual callbacks can have their initial calls disabled in their definition `@app.callback(..., prevent_initial_call=True)` and similar for `app.clientside_callback`. The app-wide default can also be changed with `app=Dash(prevent_initial_callbacks=True)`, then individual callbacks may disable this behavior. diff --git a/dash-renderer/src/actions/index.js b/dash-renderer/src/actions/index.js index 83972f4bcc..05d0427381 100644 --- a/dash-renderer/src/actions/index.js +++ b/dash-renderer/src/actions/index.js @@ -525,6 +525,35 @@ const getVals = input => const zipIfArray = (a, b) => (Array.isArray(a) ? zip(a, b) : [[a, b]]); +function inputsToDict(inputs_list) { + // Ported directly from _utils.py, inputs_to_dict + // takes an array of inputs (some inputs may be an array) + // returns an Object (map): + // keys of the form `id.property` or `{"id": 0}.property` + // values contain the property value + if (!inputs_list) { + return {}; + } + const inputs = {}; + for (let i = 0; i < inputs_list.length; i++) { + if (Array.isArray(inputs_list[i])) { + const inputsi = inputs_list[i]; + for (let ii = 0; ii < inputsi.length; ii++) { + const id_str = `${stringifyId(inputsi[ii].id)}.${ + inputsi[ii].property + }`; + inputs[id_str] = inputsi[ii].value ?? null; + } + } else { + const id_str = `${stringifyId(inputs_list[i].id)}.${ + inputs_list[i].property + }`; + inputs[id_str] = inputs_list[i].value ?? null; + } + } + return inputs; +} + function handleClientside(clientside_function, payload) { const dc = (window.dash_clientside = window.dash_clientside || {}); if (!dc.no_update) { @@ -544,12 +573,26 @@ function handleClientside(clientside_function, payload) { let returnValue; try { + // setup callback context + const input_dict = inputsToDict(inputs); + dc.callback_context = {}; + dc.callback_context.triggered = payload.changedPropIds.map(prop_id => ({ + prop_id: prop_id, + value: input_dict[prop_id], + })); + dc.callback_context.inputs_list = inputs; + dc.callback_context.inputs = input_dict; + dc.callback_context.states_list = state; + dc.callback_context.states = inputsToDict(state); + const {namespace, function_name} = clientside_function; let args = inputs.map(getVals); if (state) { args = concat(args, state.map(getVals)); } returnValue = dc[namespace][function_name](...args); + + delete dc.callback_context; } catch (e) { if (e === dc.PreventUpdate) { return {}; diff --git a/tests/integration/clientside/assets/clientside.js b/tests/integration/clientside/assets/clientside.js index 067d7b4a1f..eaee0e6a09 100644 --- a/tests/integration/clientside/assets/clientside.js +++ b/tests/integration/clientside/assets/clientside.js @@ -56,6 +56,30 @@ window.dash_clientside.clientside = { resolve('foo'); }, 1); }); - } + }, -} + triggered_to_str: function(n_clicks0, n_clicks1) { + const triggered = dash_clientside.callback_context.triggered; + return triggered.map(t => `${t.prop_id} = ${t.value}`).join(', '); + }, + + inputs_to_str: function(n_clicks0, n_clicks1) { + const inputs = dash_clientside.callback_context.inputs; + const keys = Object.keys(inputs); + return keys.map(k => `${k} = ${inputs[k]}`).join(', '); + }, + + inputs_list_to_str: function(n_clicks0, n_clicks1) { + return JSON.stringify(dash_clientside.callback_context.inputs_list); + }, + + states_to_str: function(val0, val1, st0, st1) { + const states = dash_clientside.callback_context.states; + const keys = Object.keys(states); + return keys.map(k => `${k} = ${states[k]}`).join(', '); + }, + + states_list_to_str: function(val0, val1, st0, st1) { + return JSON.stringify(dash_clientside.callback_context.states_list); + } +}; diff --git a/tests/integration/clientside/test_clientside.py b/tests/integration/clientside/test_clientside.py index 1b51fc4f43..f3239ba6bc 100644 --- a/tests/integration/clientside/test_clientside.py +++ b/tests/integration/clientside/test_clientside.py @@ -4,7 +4,8 @@ import dash_html_components as html import dash_core_components as dcc from dash import Dash -from dash.dependencies import Input, Output, State, ClientsideFunction +from dash.dependencies import Input, Output, State, ClientsideFunction, ALL +from selenium.webdriver.common.keys import Keys def test_clsd001_simple_clientside_serverside_callback(dash_duo): @@ -365,3 +366,320 @@ def update_output(value): 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"') + + +def test_clsd009_clientside_callback_context_triggered(dash_duo): + app = Dash(__name__, assets_folder="assets") + + app.layout = html.Div( + [ + html.Button("btn0", id="btn0"), + html.Button("btn1:0", id={"btn1": 0}), + html.Button("btn1:1", id={"btn1": 1}), + html.Button("btn1:2", id={"btn1": 2}), + html.Div(id="output-clientside", style={"font-family": "monospace"}), + ] + ) + + app.clientside_callback( + ClientsideFunction(namespace="clientside", function_name="triggered_to_str"), + Output("output-clientside", "children"), + [Input("btn0", "n_clicks"), Input({"btn1": ALL}, "n_clicks")], + ) + + dash_duo.start_server(app) + + dash_duo.wait_for_text_to_equal("#output-clientside", "") + + dash_duo.find_element("#btn0").click() + + dash_duo.wait_for_text_to_equal( + "#output-clientside", "btn0.n_clicks = 1", + ) + + dash_duo.find_element("button[id*='btn1\":0']").click() + dash_duo.find_element("button[id*='btn1\":0']").click() + + dash_duo.wait_for_text_to_equal("#output-clientside", '{"btn1":0}.n_clicks = 2') + + dash_duo.find_element("button[id*='btn1\":2']").click() + + dash_duo.wait_for_text_to_equal( + "#output-clientside", '{"btn1":2}.n_clicks = 1', + ) + + +def test_clsd010_clientside_callback_context_inputs(dash_duo): + app = Dash(__name__, assets_folder="assets") + + app.layout = html.Div( + [ + html.Button("btn0", id="btn0"), + html.Button("btn1:0", id={"btn1": 0}), + html.Button("btn1:1", id={"btn1": 1}), + html.Button("btn1:2", id={"btn1": 2}), + html.Div(id="output-clientside", style={"font-family": "monospace"}), + ] + ) + + app.clientside_callback( + ClientsideFunction(namespace="clientside", function_name="inputs_to_str"), + Output("output-clientside", "children"), + [Input("btn0", "n_clicks"), Input({"btn1": ALL}, "n_clicks")], + ) + + dash_duo.start_server(app) + + dash_duo.wait_for_text_to_equal( + "#output-clientside", + ( + "btn0.n_clicks = null, " + '{"btn1":0}.n_clicks = null, ' + '{"btn1":1}.n_clicks = null, ' + '{"btn1":2}.n_clicks = null' + ), + ) + + dash_duo.find_element("#btn0").click() + + dash_duo.wait_for_text_to_equal( + "#output-clientside", + ( + "btn0.n_clicks = 1, " + '{"btn1":0}.n_clicks = null, ' + '{"btn1":1}.n_clicks = null, ' + '{"btn1":2}.n_clicks = null' + ), + ) + + dash_duo.find_element("button[id*='btn1\":0']").click() + dash_duo.find_element("button[id*='btn1\":0']").click() + + dash_duo.wait_for_text_to_equal( + "#output-clientside", + ( + "btn0.n_clicks = 1, " + '{"btn1":0}.n_clicks = 2, ' + '{"btn1":1}.n_clicks = null, ' + '{"btn1":2}.n_clicks = null' + ), + ) + + dash_duo.find_element("button[id*='btn1\":2']").click() + + dash_duo.wait_for_text_to_equal( + "#output-clientside", + ( + "btn0.n_clicks = 1, " + '{"btn1":0}.n_clicks = 2, ' + '{"btn1":1}.n_clicks = null, ' + '{"btn1":2}.n_clicks = 1' + ), + ) + + +def test_clsd011_clientside_callback_context_inputs_list(dash_duo): + app = Dash(__name__, assets_folder="assets") + + app.layout = html.Div( + [ + html.Button("btn0", id="btn0"), + html.Button("btn1:0", id={"btn1": 0}), + html.Button("btn1:1", id={"btn1": 1}), + html.Button("btn1:2", id={"btn1": 2}), + html.Div(id="output-clientside", style={"font-family": "monospace"}), + ] + ) + + app.clientside_callback( + ClientsideFunction(namespace="clientside", function_name="inputs_list_to_str"), + Output("output-clientside", "children"), + [Input("btn0", "n_clicks"), Input({"btn1": ALL}, "n_clicks")], + ) + + dash_duo.start_server(app) + + dash_duo.wait_for_text_to_equal( + "#output-clientside", + ( + '[{"id":"btn0","property":"n_clicks"},' + '[{"id":{"btn1":0},"property":"n_clicks"},' + '{"id":{"btn1":1},"property":"n_clicks"},' + '{"id":{"btn1":2},"property":"n_clicks"}]]' + ), + ) + + dash_duo.find_element("#btn0").click() + + dash_duo.wait_for_text_to_equal( + "#output-clientside", + ( + '[{"id":"btn0","property":"n_clicks","value":1},' + '[{"id":{"btn1":0},"property":"n_clicks"},' + '{"id":{"btn1":1},"property":"n_clicks"},' + '{"id":{"btn1":2},"property":"n_clicks"}]]' + ), + ) + + dash_duo.find_element("button[id*='btn1\":0']").click() + dash_duo.find_element("button[id*='btn1\":0']").click() + + dash_duo.wait_for_text_to_equal( + "#output-clientside", + ( + '[{"id":"btn0","property":"n_clicks","value":1},' + '[{"id":{"btn1":0},"property":"n_clicks","value":2},' + '{"id":{"btn1":1},"property":"n_clicks"},' + '{"id":{"btn1":2},"property":"n_clicks"}]]' + ), + ) + + dash_duo.find_element("button[id*='btn1\":2']").click() + + dash_duo.wait_for_text_to_equal( + "#output-clientside", + ( + '[{"id":"btn0","property":"n_clicks","value":1},' + '[{"id":{"btn1":0},"property":"n_clicks","value":2},' + '{"id":{"btn1":1},"property":"n_clicks"},' + '{"id":{"btn1":2},"property":"n_clicks","value":1}]]' + ), + ) + + +def test_clsd012_clientside_callback_context_states(dash_duo): + app = Dash(__name__, assets_folder="assets") + + app.layout = html.Div( + [ + dcc.Input(id="in0"), + dcc.Input(id={"in1": 0}), + dcc.Input(id={"in1": 1}), + dcc.Input(id={"in1": 2}), + html.Div(id="output-clientside", style={"font-family": "monospace"}), + ] + ) + + app.clientside_callback( + ClientsideFunction(namespace="clientside", function_name="states_to_str"), + Output("output-clientside", "children"), + [Input("in0", "n_submit"), Input({"in1": ALL}, "n_submit")], + [State("in0", "value"), State({"in1": ALL}, "value")], + ) + + dash_duo.start_server(app) + + dash_duo.wait_for_text_to_equal( + "#output-clientside", + ( + "in0.value = null, " + '{"in1":0}.value = null, ' + '{"in1":1}.value = null, ' + '{"in1":2}.value = null' + ), + ) + + dash_duo.find_element("#in0").send_keys("test 0" + Keys.RETURN) + + dash_duo.wait_for_text_to_equal( + "#output-clientside", + ( + "in0.value = test 0, " + '{"in1":0}.value = null, ' + '{"in1":1}.value = null, ' + '{"in1":2}.value = null' + ), + ) + + dash_duo.find_element("input[id*='in1\":0']").send_keys("test 1" + Keys.RETURN) + + dash_duo.wait_for_text_to_equal( + "#output-clientside", + ( + "in0.value = test 0, " + '{"in1":0}.value = test 1, ' + '{"in1":1}.value = null, ' + '{"in1":2}.value = null' + ), + ) + + dash_duo.find_element("input[id*='in1\":2']").send_keys("test 2" + Keys.RETURN) + + dash_duo.wait_for_text_to_equal( + "#output-clientside", + ( + "in0.value = test 0, " + '{"in1":0}.value = test 1, ' + '{"in1":1}.value = null, ' + '{"in1":2}.value = test 2' + ), + ) + + +def test_clsd013_clientside_callback_context_states_list(dash_duo): + app = Dash(__name__, assets_folder="assets") + + app.layout = html.Div( + [ + dcc.Input(id="in0"), + dcc.Input(id={"in1": 0}), + dcc.Input(id={"in1": 1}), + dcc.Input(id={"in1": 2}), + html.Div(id="output-clientside", style={"font-family": "monospace"}), + ] + ) + + app.clientside_callback( + ClientsideFunction(namespace="clientside", function_name="states_list_to_str"), + Output("output-clientside", "children"), + [Input("in0", "n_submit"), Input({"in1": ALL}, "n_submit")], + [State("in0", "value"), State({"in1": ALL}, "value")], + ) + + dash_duo.start_server(app) + + dash_duo.wait_for_text_to_equal( + "#output-clientside", + ( + '[{"id":"in0","property":"value"},' + '[{"id":{"in1":0},"property":"value"},' + '{"id":{"in1":1},"property":"value"},' + '{"id":{"in1":2},"property":"value"}]]' + ), + ) + + dash_duo.find_element("#in0").send_keys("test 0" + Keys.RETURN) + + dash_duo.wait_for_text_to_equal( + "#output-clientside", + ( + '[{"id":"in0","property":"value","value":"test 0"},' + '[{"id":{"in1":0},"property":"value"},' + '{"id":{"in1":1},"property":"value"},' + '{"id":{"in1":2},"property":"value"}]]' + ), + ) + + dash_duo.find_element("input[id*='in1\":0']").send_keys("test 1" + Keys.RETURN) + + dash_duo.wait_for_text_to_equal( + "#output-clientside", + ( + '[{"id":"in0","property":"value","value":"test 0"},' + '[{"id":{"in1":0},"property":"value","value":"test 1"},' + '{"id":{"in1":1},"property":"value"},' + '{"id":{"in1":2},"property":"value"}]]' + ), + ) + + dash_duo.find_element("input[id*='in1\":2']").send_keys("test 2" + Keys.RETURN) + + dash_duo.wait_for_text_to_equal( + "#output-clientside", + ( + '[{"id":"in0","property":"value","value":"test 0"},' + '[{"id":{"in1":0},"property":"value","value":"test 1"},' + '{"id":{"in1":1},"property":"value"},' + '{"id":{"in1":2},"property":"value","value":"test 2"}]]' + ), + )