From c0c01651c39eb5dba338d8f262aeecad4ced2d31 Mon Sep 17 00:00:00 2001 From: Carl Dawson Date: Sat, 9 May 2020 18:30:02 -0700 Subject: [PATCH 01/14] Issue-1213 clientside callback_context Initial Commit --- dash-renderer/src/actions/index.js | 34 +++++++++++ .../integration/clientside/test_clientside.py | 60 ++++++++++++++++++- 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/dash-renderer/src/actions/index.js b/dash-renderer/src/actions/index.js index 83972f4bcc..c2c8b9c42e 100644 --- a/dash-renderer/src/actions/index.js +++ b/dash-renderer/src/actions/index.js @@ -525,6 +525,23 @@ 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 + let inputs = {}; + for (let i = 0; i < inputs_list.length; i++) { + let inputsi = Array.isArray(inputs_list[i]) ? inputs_list[i] : [inputs_list[i]]; + for (let ii = 0; ii < inputsi.length; ii++) { + let id_str = `${JSON.stringify(inputsi[ii].id)}.${inputsi[ii].property}`; + inputs[id_str] = inputsi[ii].value; + } + } + return inputs; +} + function handleClientside(clientside_function, payload) { const dc = (window.dash_clientside = window.dash_clientside || {}); if (!dc.no_update) { @@ -541,6 +558,23 @@ function handleClientside(clientside_function, payload) { const {inputs, outputs, state} = payload; + if (!dc.callback_context) { + Object.defineProperty(dc, 'callback_context', { + value: {'triggered': [{'prop_id': '.', 'value': null}], + 'inputs_list': inputsToDict(payload.inputs), + 'inputs': payload.inputs, + 'outputs_list': payload.outputs} + // TODO: add the rest: states, states_list, response(??) + }) + } + else{ + let input_dict = inputsToDict(inputs); + dc.callback_context.triggered = payload.changedPropIds.map( + prop_id => ({'prop_id': prop_id, 'value': input_dict[prop_id]})); + dc.callback_context.inputs_list = input_dict; + dc.callback_context.inputs = inputs; + } + let returnValue; try { diff --git a/tests/integration/clientside/test_clientside.py b/tests/integration/clientside/test_clientside.py index 1b51fc4f43..68fea7df2f 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 +import dash def test_clsd001_simple_clientside_serverside_callback(dash_duo): @@ -365,3 +366,60 @@ 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(dash_duo): + app = Dash(__name__, assets_folder="assets") + + app.layout = html.Div( + [ + html.Button("0", id={"btn": 0}), + html.Button("1", id={"btn": 1}), + html.Button("2", id={"btn": 2}), + html.Div(id="output-clientside"), + html.Div(id="output-serverside"), + ] + ) + + @app.callback( + Output("output-serverside", "children"), [Input({"btn": ALL}, "n_clicks")] + ) + def update_output(n_clicks): + return f"triggered: {dash.callback_context.triggered}" + + app.clientside_callback( + """ + function (n_clicks) { + console.log(dash_clientside.callback_context) + return `triggered: ${JSON.stringify(dash_clientside.callback_context.triggered)}` + } + """, + Output("output-clientside", "children"), + [Input({"btn": ALL}, "n_clicks")], + ) + + dash_duo.start_server(app) + + dash_duo.wait_for_text_to_equal( + "#output-serverside", "triggered: [{'prop_id': '.', 'value': None}]" + ) + dash_duo.wait_for_text_to_equal( + "#output-clientside", r'triggered: [{"prop_id":".","value":null}]' + ) + + dash_duo.find_element("button[id*='0']").click() + + dash_duo.wait_for_text_to_equal( + "#output-clientside", + r'triggered: [{"prop_id":"{\"btn\":0}.n_clicks","value":1}]', + ) + + dash_duo.find_element("button[id*='2']").click() + + dash_duo.wait_for_text_to_equal( + "#output-clientside", + r'triggered: [{"prop_id":"{\"btn\":2}.n_clicks","value":1}]', + ) + + # TODO: flush out these tests and make them look prettier. + # Maybe one test for each of the callback_context properties? From 2412b388ed75d84d789d0fa41f50c434535c057c Mon Sep 17 00:00:00 2001 From: Carl Dawson Date: Mon, 11 May 2020 10:21:48 -0700 Subject: [PATCH 02/14] Minor refactor, no need for Object.defineProperty --- dash-renderer/src/actions/index.js | 31 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/dash-renderer/src/actions/index.js b/dash-renderer/src/actions/index.js index c2c8b9c42e..7ce359526b 100644 --- a/dash-renderer/src/actions/index.js +++ b/dash-renderer/src/actions/index.js @@ -558,32 +558,31 @@ function handleClientside(clientside_function, payload) { const {inputs, outputs, state} = payload; - if (!dc.callback_context) { - Object.defineProperty(dc, 'callback_context', { - value: {'triggered': [{'prop_id': '.', 'value': null}], - 'inputs_list': inputsToDict(payload.inputs), - 'inputs': payload.inputs, - 'outputs_list': payload.outputs} - // TODO: add the rest: states, states_list, response(??) - }) - } - else{ + let returnValue; + + try { + // setup callback context let input_dict = inputsToDict(inputs); - dc.callback_context.triggered = payload.changedPropIds.map( - prop_id => ({'prop_id': prop_id, 'value': input_dict[prop_id]})); + dc.callback_context = {}; + if (payload.changedPropIds.length === 0) { + dc.callback_context.triggered = [{'prop_id': '.', 'value': null}]; + } + else { + dc.callback_context.triggered = payload.changedPropIds.map( + prop_id => ({'prop_id': prop_id, 'value': input_dict[prop_id]})); + } dc.callback_context.inputs_list = input_dict; dc.callback_context.inputs = inputs; - } + // TODO: add the rest: states, states_list, response(??) - let returnValue; - - try { 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 {}; From c97f1c82e08009c37397b05d5b7154567727e31f Mon Sep 17 00:00:00 2001 From: Carl Dawson Date: Mon, 11 May 2020 10:27:19 -0700 Subject: [PATCH 03/14] remove fstring for py2 compatibility --- tests/integration/clientside/test_clientside.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/clientside/test_clientside.py b/tests/integration/clientside/test_clientside.py index 68fea7df2f..c5cc4591e9 100644 --- a/tests/integration/clientside/test_clientside.py +++ b/tests/integration/clientside/test_clientside.py @@ -385,7 +385,7 @@ def test_clsd009_clientside_callback_context(dash_duo): Output("output-serverside", "children"), [Input({"btn": ALL}, "n_clicks")] ) def update_output(n_clicks): - return f"triggered: {dash.callback_context.triggered}" + return "triggered: %s" % dash.callback_context.triggered app.clientside_callback( """ From b9ab9378a041a0ab511ef7faddd00d8508ba669e Mon Sep 17 00:00:00 2001 From: Carl Dawson Date: Mon, 11 May 2020 11:09:59 -0700 Subject: [PATCH 04/14] :bug: fixed prop_id for non-pattern-matching callbacks --- dash-renderer/src/actions/index.js | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/dash-renderer/src/actions/index.js b/dash-renderer/src/actions/index.js index 7ce359526b..e2654a7101 100644 --- a/dash-renderer/src/actions/index.js +++ b/dash-renderer/src/actions/index.js @@ -533,10 +533,16 @@ function inputsToDict(inputs_list) { // values contain the property value let inputs = {}; for (let i = 0; i < inputs_list.length; i++) { - let inputsi = Array.isArray(inputs_list[i]) ? inputs_list[i] : [inputs_list[i]]; - for (let ii = 0; ii < inputsi.length; ii++) { - let id_str = `${JSON.stringify(inputsi[ii].id)}.${inputsi[ii].property}`; - inputs[id_str] = inputsi[ii].value; + if (Array.isArray(inputs_list[i])) { + let inputsi = inputs_list[i]; + for (let ii = 0; ii < inputsi.length; ii++) { + let id_str = `${JSON.stringify(inputsi[ii].id)}.${inputsi[ii].property}`; + inputs[id_str] = inputsi[ii].value; + } + } + else { + let id_str = `${inputs_list[i].id}.${inputs_list[i].property}`; + inputs[id_str] = inputs_list[i].value; } } return inputs; @@ -581,7 +587,7 @@ function handleClientside(clientside_function, payload) { args = concat(args, state.map(getVals)); } returnValue = dc[namespace][function_name](...args); - + delete dc.callback_context; } catch (e) { if (e === dc.PreventUpdate) { From 8d024b4fa1de6e6568d5f6b079a1314a7a7931e4 Mon Sep 17 00:00:00 2001 From: Carl Dawson Date: Mon, 11 May 2020 11:23:52 -0700 Subject: [PATCH 05/14] :tiger2: tests both vanilla and pattern-matching callbacks --- .../integration/clientside/test_clientside.py | 37 +++++++++---------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/tests/integration/clientside/test_clientside.py b/tests/integration/clientside/test_clientside.py index c5cc4591e9..7fa87e47c4 100644 --- a/tests/integration/clientside/test_clientside.py +++ b/tests/integration/clientside/test_clientside.py @@ -368,57 +368,56 @@ def update_output(value): dash_duo.wait_for_text_to_equal("#output-clientside", 'Client says "hello world"') -def test_clsd009_clientside_callback_context(dash_duo): +def test_clsd009_clientside_callback_context_triggered(dash_duo): app = Dash(__name__, assets_folder="assets") app.layout = html.Div( [ - html.Button("0", id={"btn": 0}), - html.Button("1", id={"btn": 1}), - html.Button("2", id={"btn": 2}), + 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"), - html.Div(id="output-serverside"), ] ) - @app.callback( - Output("output-serverside", "children"), [Input({"btn": ALL}, "n_clicks")] - ) - def update_output(n_clicks): - return "triggered: %s" % dash.callback_context.triggered - app.clientside_callback( """ - function (n_clicks) { + function (n_clicks0, n_clicks1) { console.log(dash_clientside.callback_context) return `triggered: ${JSON.stringify(dash_clientside.callback_context.triggered)}` } """, Output("output-clientside", "children"), - [Input({"btn": ALL}, "n_clicks")], + [Input("btn0", "n_clicks"), + Input({"btn1": ALL}, "n_clicks")], ) dash_duo.start_server(app) dash_duo.wait_for_text_to_equal( - "#output-serverside", "triggered: [{'prop_id': '.', 'value': None}]" + "#output-clientside", r'triggered: [{"prop_id":".","value":null}]' ) + + dash_duo.find_element("#btn0").click() + dash_duo.wait_for_text_to_equal( - "#output-clientside", r'triggered: [{"prop_id":".","value":null}]' + "#output-clientside", + r'triggered: [{"prop_id":"btn0.n_clicks","value":1}]', ) - dash_duo.find_element("button[id*='0']").click() + dash_duo.find_element("button[id*='btn1\":0']").click() dash_duo.wait_for_text_to_equal( "#output-clientside", - r'triggered: [{"prop_id":"{\"btn\":0}.n_clicks","value":1}]', + r'triggered: [{"prop_id":"{\"btn1\":0}.n_clicks","value":1}]', ) - dash_duo.find_element("button[id*='2']").click() + dash_duo.find_element("button[id*='btn1\":2']").click() dash_duo.wait_for_text_to_equal( "#output-clientside", - r'triggered: [{"prop_id":"{\"btn\":2}.n_clicks","value":1}]', + r'triggered: [{"prop_id":"{\"btn1\":2}.n_clicks","value":1}]', ) # TODO: flush out these tests and make them look prettier. From 587de56f327eaf03e76d91cc45417f5e3213d648 Mon Sep 17 00:00:00 2001 From: Carl Dawson Date: Mon, 11 May 2020 11:30:48 -0700 Subject: [PATCH 06/14] :necktie: lint --- dash-renderer/src/actions/index.js | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/dash-renderer/src/actions/index.js b/dash-renderer/src/actions/index.js index e2654a7101..39e3433251 100644 --- a/dash-renderer/src/actions/index.js +++ b/dash-renderer/src/actions/index.js @@ -531,17 +531,18 @@ function inputsToDict(inputs_list) { // returns an Object (map): // keys of the form `id.property` or `{"id": 0}.property` // values contain the property value - let inputs = {}; + const inputs = {}; for (let i = 0; i < inputs_list.length; i++) { if (Array.isArray(inputs_list[i])) { - let inputsi = inputs_list[i]; + const inputsi = inputs_list[i]; for (let ii = 0; ii < inputsi.length; ii++) { - let id_str = `${JSON.stringify(inputsi[ii].id)}.${inputsi[ii].property}`; + const id_str = `${JSON.stringify(inputsi[ii].id)}.${ + inputsi[ii].property + }`; inputs[id_str] = inputsi[ii].value; } - } - else { - let id_str = `${inputs_list[i].id}.${inputs_list[i].property}`; + } else { + const id_str = `${inputs_list[i].id}.${inputs_list[i].property}`; inputs[id_str] = inputs_list[i].value; } } @@ -568,14 +569,14 @@ function handleClientside(clientside_function, payload) { try { // setup callback context - let input_dict = inputsToDict(inputs); + const input_dict = inputsToDict(inputs); dc.callback_context = {}; if (payload.changedPropIds.length === 0) { - dc.callback_context.triggered = [{'prop_id': '.', 'value': null}]; - } - else { + dc.callback_context.triggered = [{prop_id: '.', value: null}]; + } else { dc.callback_context.triggered = payload.changedPropIds.map( - prop_id => ({'prop_id': prop_id, 'value': input_dict[prop_id]})); + prop_id => ({prop_id: prop_id, value: input_dict[prop_id]}) + ); } dc.callback_context.inputs_list = input_dict; dc.callback_context.inputs = inputs; From 4566a924ef109191660bf2176a24713896b004ef Mon Sep 17 00:00:00 2001 From: Carl Dawson Date: Tue, 12 May 2020 16:48:30 -0700 Subject: [PATCH 07/14] :tiger2: integration tests --- .../clientside/assets/clientside.js | 28 +- .../integration/clientside/test_clientside.py | 291 +++++++++++++++++- 2 files changed, 303 insertions(+), 16 deletions(-) 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 7fa87e47c4..dfb777b80f 100644 --- a/tests/integration/clientside/test_clientside.py +++ b/tests/integration/clientside/test_clientside.py @@ -5,7 +5,7 @@ import dash_core_components as dcc from dash import Dash from dash.dependencies import Input, Output, State, ClientsideFunction, ALL -import dash +from selenium.webdriver.common.keys import Keys def test_clsd001_simple_clientside_serverside_callback(dash_duo): @@ -377,48 +377,311 @@ def test_clsd009_clientside_callback_context_triggered(dash_duo): 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"), + html.Div(id="output-clientside", style={"font-family": "monospace"}), ] ) app.clientside_callback( - """ - function (n_clicks0, n_clicks1) { - console.log(dash_clientside.callback_context) - return `triggered: ${JSON.stringify(dash_clientside.callback_context.triggered)}` - } - """, + ClientsideFunction(namespace="clientside", function_name="triggered_to_str"), Output("output-clientside", "children"), - [Input("btn0", "n_clicks"), - Input({"btn1": ALL}, "n_clicks")], + [Input("btn0", "n_clicks"), Input({"btn1": ALL}, "n_clicks")], ) dash_duo.start_server(app) + dash_duo.wait_for_text_to_equal("#output-clientside", ". = null") + + dash_duo.find_element("#btn0").click() + dash_duo.wait_for_text_to_equal( - "#output-clientside", r'triggered: [{"prop_id":".","value":null}]' + "#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", - r'triggered: [{"prop_id":"btn0.n_clicks","value":1}]', + ( + "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", - r'triggered: [{"prop_id":"{\"btn1\":0}.n_clicks","value":1}]', + ( + "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", - r'triggered: [{"prop_id":"{\"btn1\":2}.n_clicks","value":1}]', + ( + "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}]]' + ), + ) # TODO: flush out these tests and make them look prettier. # Maybe one test for each of the callback_context properties? + + +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"}]]' + ), + ) From 84569e9f3508e5e7122d90401fbdd8f23d456dd6 Mon Sep 17 00:00:00 2001 From: Carl Dawson Date: Tue, 12 May 2020 21:03:32 -0700 Subject: [PATCH 08/14] :see_no_evil: oops.. goes with 4566a92 --- dash-renderer/src/actions/index.js | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/dash-renderer/src/actions/index.js b/dash-renderer/src/actions/index.js index 39e3433251..310faa2a36 100644 --- a/dash-renderer/src/actions/index.js +++ b/dash-renderer/src/actions/index.js @@ -531,6 +531,9 @@ function inputsToDict(inputs_list) { // 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])) { @@ -539,11 +542,11 @@ function inputsToDict(inputs_list) { const id_str = `${JSON.stringify(inputsi[ii].id)}.${ inputsi[ii].property }`; - inputs[id_str] = inputsi[ii].value; + inputs[id_str] = inputsi[ii].value ? inputsi[ii].value : null; } } else { const id_str = `${inputs_list[i].id}.${inputs_list[i].property}`; - inputs[id_str] = inputs_list[i].value; + inputs[id_str] = inputs_list[i].value ? inputs_list[i].value : null; } } return inputs; @@ -578,9 +581,10 @@ function handleClientside(clientside_function, payload) { prop_id => ({prop_id: prop_id, value: input_dict[prop_id]}) ); } - dc.callback_context.inputs_list = input_dict; - dc.callback_context.inputs = inputs; - // TODO: add the rest: states, states_list, response(??) + 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); From 123aa164d9f1a531fa3034020e88699243d230b0 Mon Sep 17 00:00:00 2001 From: Carl Dawson Date: Tue, 12 May 2020 21:21:18 -0700 Subject: [PATCH 09/14] :necktie: lint again --- dash-renderer/src/actions/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dash-renderer/src/actions/index.js b/dash-renderer/src/actions/index.js index 310faa2a36..9fd74a15cb 100644 --- a/dash-renderer/src/actions/index.js +++ b/dash-renderer/src/actions/index.js @@ -531,7 +531,7 @@ function inputsToDict(inputs_list) { // returns an Object (map): // keys of the form `id.property` or `{"id": 0}.property` // values contain the property value - if (!inputs_list){ + if (!inputs_list) { return {}; } const inputs = {}; From 0d3e0f6c4b60d9b9f578f724ca7146be5d609c80 Mon Sep 17 00:00:00 2001 From: Carl Dawson Date: Wed, 13 May 2020 16:28:35 -0700 Subject: [PATCH 10/14] stringifyId and null-coalescing operator --- dash-renderer/src/actions/index.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/dash-renderer/src/actions/index.js b/dash-renderer/src/actions/index.js index 9fd74a15cb..6dc3f44d54 100644 --- a/dash-renderer/src/actions/index.js +++ b/dash-renderer/src/actions/index.js @@ -539,14 +539,16 @@ function inputsToDict(inputs_list) { if (Array.isArray(inputs_list[i])) { const inputsi = inputs_list[i]; for (let ii = 0; ii < inputsi.length; ii++) { - const id_str = `${JSON.stringify(inputsi[ii].id)}.${ + const id_str = `${stringifyId(inputsi[ii].id)}.${ inputsi[ii].property }`; - inputs[id_str] = inputsi[ii].value ? inputsi[ii].value : null; + inputs[id_str] = inputsi[ii].value ?? null; } } else { - const id_str = `${inputs_list[i].id}.${inputs_list[i].property}`; - inputs[id_str] = inputs_list[i].value ? inputs_list[i].value : null; + const id_str = `${stringifyId(inputs_list[i].id)}.${ + inputs_list[i].property + }`; + inputs[id_str] = inputs_list[i].value ?? null; } } return inputs; From 8afe6e93ca0bfc4f4d3c04ea35d5888f6613d380 Mon Sep 17 00:00:00 2001 From: Carl Dawson Date: Wed, 13 May 2020 16:36:27 -0700 Subject: [PATCH 11/14] remove default triggered value --- dash-renderer/src/actions/index.js | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/dash-renderer/src/actions/index.js b/dash-renderer/src/actions/index.js index 6dc3f44d54..05d0427381 100644 --- a/dash-renderer/src/actions/index.js +++ b/dash-renderer/src/actions/index.js @@ -576,13 +576,10 @@ function handleClientside(clientside_function, payload) { // setup callback context const input_dict = inputsToDict(inputs); dc.callback_context = {}; - if (payload.changedPropIds.length === 0) { - dc.callback_context.triggered = [{prop_id: '.', value: null}]; - } else { - dc.callback_context.triggered = payload.changedPropIds.map( - prop_id => ({prop_id: prop_id, value: input_dict[prop_id]}) - ); - } + 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; From 3ea8d1992f9b81334990d782db6d80f68aa82787 Mon Sep 17 00:00:00 2001 From: Carl Dawson Date: Wed, 13 May 2020 16:39:00 -0700 Subject: [PATCH 12/14] :tiger2: no default triggered value --- tests/integration/clientside/test_clientside.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/integration/clientside/test_clientside.py b/tests/integration/clientside/test_clientside.py index dfb777b80f..f3239ba6bc 100644 --- a/tests/integration/clientside/test_clientside.py +++ b/tests/integration/clientside/test_clientside.py @@ -389,7 +389,7 @@ def test_clsd009_clientside_callback_context_triggered(dash_duo): dash_duo.start_server(app) - dash_duo.wait_for_text_to_equal("#output-clientside", ". = null") + dash_duo.wait_for_text_to_equal("#output-clientside", "") dash_duo.find_element("#btn0").click() @@ -545,8 +545,6 @@ def test_clsd011_clientside_callback_context_inputs_list(dash_duo): '{"id":{"btn1":2},"property":"n_clicks","value":1}]]' ), ) - # TODO: flush out these tests and make them look prettier. - # Maybe one test for each of the callback_context properties? def test_clsd012_clientside_callback_context_states(dash_duo): From 06e07e9939c27cbafb886ab09e26d66ca1d9bd22 Mon Sep 17 00:00:00 2001 From: Carl Dawson Date: Wed, 13 May 2020 21:55:06 -0700 Subject: [PATCH 13/14] changelog for clientside callback_context --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe67863ca0..39b48ef155 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,9 @@ All notable changes to `dash` will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). -## [1.12.0] - 2020-05-05 +## [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. - [#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. - [#1201](https://github.com/plotly/dash/pull/1201) New attribute `app.validation_layout` allows you to create a multi-page app without `suppress_callback_exceptions=True` or layout function tricks. Set this to a component layout containing the superset of all IDs on all pages in your app. - [#1078](https://github.com/plotly/dash/pull/1078) Permit usage of arbitrary file extensions for assets within component libraries From a9f8cc95e891a620a7a398549cd493816f37e3ed Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Thu, 14 May 2020 09:32:37 -0400 Subject: [PATCH 14/14] update clientside callback context changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39b48ef155..70685b2b1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ 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. - [#1201](https://github.com/plotly/dash/pull/1201) New attribute `app.validation_layout` allows you to create a multi-page app without `suppress_callback_exceptions=True` or layout function tricks. Set this to a component layout containing the superset of all IDs on all pages in your app. - [#1078](https://github.com/plotly/dash/pull/1078) Permit usage of arbitrary file extensions for assets within component libraries