From b42e411c0601b4830a263cc28f9df9cdbe1db024 Mon Sep 17 00:00:00 2001 From: Nikalexis Nikos Date: Thu, 9 Apr 2020 20:37:59 +0300 Subject: [PATCH 01/56] Sort also the directory name for proper ordering between css and js plugin folders --- dash/dash.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dash/dash.py b/dash/dash.py index 09b0ae61eb..e6acabee63 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -1001,7 +1001,7 @@ def _walk_assets_directory(self): ignore_str = self.config.assets_ignore ignore_filter = re.compile(ignore_str) if ignore_str else None - for current, _, files in os.walk(walk_dir): + for current, _, files in sorted(os.walk(walk_dir)): if current == walk_dir: base = "" else: From 439e13b673e8e8f3d4223e905d44c08c9f11eef2 Mon Sep 17 00:00:00 2001 From: Ryan Patrick Kyle Date: Thu, 7 May 2020 00:38:41 -0400 Subject: [PATCH 02/56] Apply WC insertion fix to .Rd also (#1234) --- dash/development/_r_components_generation.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/dash/development/_r_components_generation.py b/dash/development/_r_components_generation.py index 4735db7c98..198cc3e85f 100644 --- a/dash/development/_r_components_generation.py +++ b/dash/development/_r_components_generation.py @@ -185,6 +185,7 @@ def generate_class_string(name, props, project_shortname, prefix): props = reorder_props(props=props) prop_keys = list(props.keys()) + prop_keys_wc = list(props.keys()) wildcards = "" wildcard_declaration = "" @@ -193,8 +194,8 @@ def generate_class_string(name, props, project_shortname, prefix): default_argtext = "" accepted_wildcards = "" - if any(key.endswith("-*") for key in prop_keys): - accepted_wildcards = get_wildcards_r(prop_keys) + if any(key.endswith("-*") for key in prop_keys_wc): + accepted_wildcards = get_wildcards_r(prop_keys_wc) wildcards = ", ..." wildcard_declaration = wildcard_template.format( accepted_wildcards.replace("-*", "") From df9848bb34b2eafb080948879a7cb6c363c8efba Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Sat, 9 May 2020 02:46:20 -0400 Subject: [PATCH 03/56] API connection status notifications --- dash-renderer/src/actions/api.js | 72 ++++++++++------ .../FrontEnd/FrontEndErrorContainer.react.js | 8 +- .../error/GlobalErrorOverlay.react.js | 13 ++- .../src/components/error/menu/DebugMenu.css | 38 ++++++++ .../components/error/menu/DebugMenu.react.js | 86 +++++++++---------- dash-renderer/src/reducers/error.js | 15 +++- 6 files changed, 149 insertions(+), 83 deletions(-) diff --git a/dash-renderer/src/actions/api.js b/dash-renderer/src/actions/api.js index 788da55b71..8a78708d9f 100644 --- a/dash-renderer/src/actions/api.js +++ b/dash-renderer/src/actions/api.js @@ -30,43 +30,61 @@ const request = {GET, POST}; export default function apiThunk(endpoint, method, store, id, body) { return (dispatch, getState) => { - const config = getState().config; + const {config} = getState(); const url = `${urlBase(config)}${endpoint}`; + function setConnectionStatus(connected) { + if (getState().error.backEndConnected !== connected) { + dispatch({ + type: 'SET_CONNECTION_STATUS', + payload: connected, + }); + } + } + dispatch({ type: store, payload: {id, status: 'loading'}, }); return request[method](url, config.fetch, body) - .then(res => { - const contentType = res.headers.get('content-type'); - if ( - contentType && - contentType.indexOf('application/json') !== -1 - ) { - return res.json().then(json => { - dispatch({ - type: store, - payload: { - status: res.status, - content: json, - id, - }, + .then( + res => { + setConnectionStatus(true); + const contentType = res.headers.get('content-type'); + if ( + contentType && + contentType.indexOf('application/json') !== -1 + ) { + return res.json().then(json => { + dispatch({ + type: store, + payload: { + status: res.status, + content: json, + id, + }, + }); + return json; }); - return json; + } + logWarningOnce( + 'Response is missing header: content-type: application/json' + ); + return dispatch({ + type: store, + payload: { + id, + status: res.status, + }, }); + }, + () => { + // fetch rejection - this means the request didn't return, + // we don't get here from 400/500 errors, only network + // errors or unresponsive servers. + setConnectionStatus(false); } - logWarningOnce( - 'Response is missing header: content-type: application/json' - ); - return dispatch({ - type: store, - payload: { - id, - status: res.status, - }, - }); - }) + ) .catch(err => { const message = 'Error from API call: ' + endpoint; handleAsyncError(err, message, dispatch); diff --git a/dash-renderer/src/components/error/FrontEnd/FrontEndErrorContainer.react.js b/dash-renderer/src/components/error/FrontEnd/FrontEndErrorContainer.react.js index ddb18ac8d3..b3701410d6 100644 --- a/dash-renderer/src/components/error/FrontEnd/FrontEndErrorContainer.react.js +++ b/dash-renderer/src/components/error/FrontEnd/FrontEndErrorContainer.react.js @@ -9,7 +9,8 @@ class FrontEndErrorContainer extends Component { } render() { - const errorsLength = this.props.errors.length; + const {errors, connected} = this.props; + const errorsLength = errors.length; if (errorsLength === 0) { return null; } @@ -17,7 +18,7 @@ class FrontEndErrorContainer extends Component { const inAlertsTray = this.props.inAlertsTray; let cardClasses = 'dash-error-card dash-error-card--container'; - const errorElements = this.props.errors.map((error, i) => { + const errorElements = errors.map((error, i) => { return ; }); if (inAlertsTray) { @@ -31,7 +32,7 @@ class FrontEndErrorContainer extends Component { {errorsLength} - ) + ){connected ? null : '\u00a0 🚫 Back End Disconnected'}
{errorElements}
@@ -42,6 +43,7 @@ class FrontEndErrorContainer extends Component { FrontEndErrorContainer.propTypes = { errors: PropTypes.array, + connected: PropTypes.bool, inAlertsTray: PropTypes.any, }; diff --git a/dash-renderer/src/components/error/GlobalErrorOverlay.react.js b/dash-renderer/src/components/error/GlobalErrorOverlay.react.js index 8ef4655902..bdbc46b34d 100644 --- a/dash-renderer/src/components/error/GlobalErrorOverlay.react.js +++ b/dash-renderer/src/components/error/GlobalErrorOverlay.react.js @@ -11,13 +11,18 @@ export default class GlobalErrorOverlay extends Component { } render() { - const {visible, error, toastsEnabled} = this.props; + const {visible, error, errorsOpened} = this.props; let frontEndErrors; - if (toastsEnabled) { + if (errorsOpened) { const errors = concat(error.frontEnd, error.backEnd); - frontEndErrors = ; + frontEndErrors = ( + + ); } return (
@@ -36,5 +41,5 @@ GlobalErrorOverlay.propTypes = { children: PropTypes.object, visible: PropTypes.bool, error: PropTypes.object, - toastsEnabled: PropTypes.any, + errorsOpened: PropTypes.any, }; diff --git a/dash-renderer/src/components/error/menu/DebugMenu.css b/dash-renderer/src/components/error/menu/DebugMenu.css index 8a7aad5439..51c8186145 100644 --- a/dash-renderer/src/components/error/menu/DebugMenu.css +++ b/dash-renderer/src/components/error/menu/DebugMenu.css @@ -65,6 +65,7 @@ color: #A2B1C6; font-size: 10px; margin-top: 4px; + text-align: center; } .dash-debug-menu__button { @@ -100,3 +101,40 @@ .dash-debug-menu__button--enabled:hover { background-color: #03bb8a; } + +.dash-debug-menu__connection { + display: flex; + justify-content: center; + align-items: center; + font-size: 50px; + line-height: 1; +} + +.dash-debug-alert { + display: flex; + align-items: center; + font-size: 10px; +} + +.dash-debug-alert-label { + display: flex; + position: fixed; + bottom: 81px; + right: 29px; + z-index: 10001; + cursor: pointer; + box-shadow: 0px 0px 1px rgba(0, 0, 0, 0.25), + 0px 1px 3px rgba(162, 177, 198, 0.32); + border-radius: 32px; + background-color: white; + padding: 4px; +} + +.dash-debug-error-count { + display: block; +} + +.dash-debug-disconnected { + font-size: 14px; + margin-left: 3px; +} diff --git a/dash-renderer/src/components/error/menu/DebugMenu.react.js b/dash-renderer/src/components/error/menu/DebugMenu.react.js index e25c8cc2bc..315d7fed28 100644 --- a/dash-renderer/src/components/error/menu/DebugMenu.react.js +++ b/dash-renderer/src/components/error/menu/DebugMenu.react.js @@ -1,5 +1,4 @@ import React, {Component} from 'react'; -import {concat, isEmpty} from 'ramda'; import './DebugMenu.css'; import DebugIcon from '../icons/DebugIcon.svg'; @@ -9,7 +8,6 @@ import BellIconGrey from '../icons/BellIconGrey.svg'; import GraphIcon from '../icons/GraphIcon.svg'; import GraphIconGrey from '../icons/GraphIconGrey.svg'; import PropTypes from 'prop-types'; -import {DebugAlertContainer} from './DebugAlertContainer.react'; import GlobalErrorOverlay from '../GlobalErrorOverlay.react'; import {CallbackGraphContainer} from '../CallbackGraph/CallbackGraphContainer.react'; @@ -19,40 +17,33 @@ class DebugMenu extends Component { this.state = { opened: false, - alertsOpened: false, callbackGraphOpened: false, - toastsEnabled: true, + errorsOpened: true, }; } render() { - const { - opened, - alertsOpened, - toastsEnabled, - callbackGraphOpened, - } = this.state; + const {opened, errorsOpened, callbackGraphOpened} = this.state; const {error, graphs} = this.props; const menuClasses = opened ? 'dash-debug-menu dash-debug-menu--opened' : 'dash-debug-menu dash-debug-menu--closed'; + const errCount = error.frontEnd.length + error.backEnd.length; + const connected = error.backEndConnected; + + const toggleErrorsOpened = () => { + this.setState({errorsOpened: !errorsOpened}); + }; + + const _GraphIcon = callbackGraphOpened ? GraphIcon : GraphIconGrey; + const _BellIcon = errorsOpened ? BellIcon : BellIconGrey; + const menuContent = opened ? (
{callbackGraphOpened ? ( ) : null} - {error.frontEnd.length > 0 || error.backEnd.length > 0 ? ( -
- - this.setState({alertsOpened: !alertsOpened}) - } - /> -
- ) : null}
- {callbackGraphOpened ? ( - - ) : ( - - )} + <_GraphIcon className="dash-debug-menu__icon dash-debug-menu__icon--graph" />
{errorElements}
diff --git a/dash-renderer/src/components/error/GlobalErrorContainer.react.js b/dash-renderer/src/components/error/GlobalErrorContainer.react.js index 6f61b07fcf..bd9fa3887a 100644 --- a/dash-renderer/src/components/error/GlobalErrorContainer.react.js +++ b/dash-renderer/src/components/error/GlobalErrorContainer.react.js @@ -10,10 +10,14 @@ class UnconnectedGlobalErrorContainer extends Component { } render() { - const {error, graphs, children} = this.props; + const {config, error, graphs, children} = this.props; return (
- +
{children}
@@ -23,11 +27,13 @@ class UnconnectedGlobalErrorContainer extends Component { UnconnectedGlobalErrorContainer.propTypes = { children: PropTypes.object, + config: PropTypes.object, error: PropTypes.object, graphs: PropTypes.object, }; const GlobalErrorContainer = connect(state => ({ + config: state.config, error: state.error, graphs: state.graphs, }))(Radium(UnconnectedGlobalErrorContainer)); diff --git a/dash-renderer/src/components/error/menu/DebugMenu.css b/dash-renderer/src/components/error/menu/DebugMenu.css index 51c8186145..d7cf3cfc2e 100644 --- a/dash-renderer/src/components/error/menu/DebugMenu.css +++ b/dash-renderer/src/components/error/menu/DebugMenu.css @@ -107,7 +107,7 @@ justify-content: center; align-items: center; font-size: 50px; - line-height: 1; + height: 66px; } .dash-debug-alert { @@ -132,6 +132,7 @@ .dash-debug-error-count { display: block; + margin: 0 3px; } .dash-debug-disconnected { diff --git a/dash-renderer/src/components/error/menu/DebugMenu.react.js b/dash-renderer/src/components/error/menu/DebugMenu.react.js index 315d7fed28..e7e1cb2004 100644 --- a/dash-renderer/src/components/error/menu/DebugMenu.react.js +++ b/dash-renderer/src/components/error/menu/DebugMenu.react.js @@ -23,7 +23,7 @@ class DebugMenu extends Component { } render() { const {opened, errorsOpened, callbackGraphOpened} = this.state; - const {error, graphs} = this.props; + const {error, graphs, hotReload} = this.props; const menuClasses = opened ? 'dash-debug-menu dash-debug-menu--opened' @@ -81,10 +81,13 @@ class DebugMenu extends Component {
- {connected ? '✅' : '🚫'} + {hotReload ? (connected ? '✅' : '🚫') : '❄'}
@@ -147,6 +150,7 @@ DebugMenu.propTypes = { children: PropTypes.object, error: PropTypes.object, graphs: PropTypes.object, + hotReload: PropTypes.bool, }; export {DebugMenu}; From 2ae75ec27d7eeaaf23db6ac7fc65aa3002b8fed3 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Sun, 10 May 2020 23:54:38 -0400 Subject: [PATCH 07/56] delete now-unused files --- .../components/error/icons/ErrorIconWhite.svg | 3 -- .../error/menu/DebugAlertContainer.css | 44 ------------------- .../error/menu/DebugAlertContainer.react.js | 38 ---------------- 3 files changed, 85 deletions(-) delete mode 100755 dash-renderer/src/components/error/icons/ErrorIconWhite.svg delete mode 100644 dash-renderer/src/components/error/menu/DebugAlertContainer.css delete mode 100644 dash-renderer/src/components/error/menu/DebugAlertContainer.react.js diff --git a/dash-renderer/src/components/error/icons/ErrorIconWhite.svg b/dash-renderer/src/components/error/icons/ErrorIconWhite.svg deleted file mode 100755 index 85688a2989..0000000000 --- a/dash-renderer/src/components/error/icons/ErrorIconWhite.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/dash-renderer/src/components/error/menu/DebugAlertContainer.css b/dash-renderer/src/components/error/menu/DebugAlertContainer.css deleted file mode 100644 index a186016e73..0000000000 --- a/dash-renderer/src/components/error/menu/DebugAlertContainer.css +++ /dev/null @@ -1,44 +0,0 @@ -.dash-debug-alert-container { - box-sizing: border-box; - background: #f3f6fa; - border-radius: 2px; - padding: 8px; - display: flex; - flex-direction: column; - justify-content: center; - transition: background-color 0.1s, border 0.1s; -} -.dash-debug-alert-container:hover { - cursor: pointer; -} -.dash-debug-alert-container--opened { - background-color: #119dff; - color: white; -} -.dash-debug-alert-container__icon { - width: 12px; - height: 12px; - margin-right: 4px; -} -.dash-debug-alert-container__icon--warning { - height: auto; -} - -.dash-debug-alert { - display: flex; - align-items: center; - font-size: 10px; -} - -.dash-debug-alert-label { - display: flex; - position: fixed; - bottom: 81px; - right: 29px; - z-index: 10001; - box-shadow: 0px 0px 1px rgba(0, 0, 0, 0.25), - 0px 1px 3px rgba(162, 177, 198, 0.32); - border-radius: 32px; - background-color: white; - padding: 4px; -} diff --git a/dash-renderer/src/components/error/menu/DebugAlertContainer.react.js b/dash-renderer/src/components/error/menu/DebugAlertContainer.react.js deleted file mode 100644 index bd4833db36..0000000000 --- a/dash-renderer/src/components/error/menu/DebugAlertContainer.react.js +++ /dev/null @@ -1,38 +0,0 @@ -import './DebugAlertContainer.css'; -import {Component} from 'react'; -import PropTypes from 'prop-types'; -import ErrorIconWhite from '../icons/ErrorIconWhite.svg'; - -class DebugAlertContainer extends Component { - constructor(props) { - super(props); - } - render() { - const {alertsOpened} = this.props; - return ( -
-
- {alertsOpened ? ( - - ) : ( - '🛑 ' - )} - {this.props.errors.length} -
-
- ); - } -} - -DebugAlertContainer.propTypes = { - errors: PropTypes.object, - alertsOpened: PropTypes.bool, - onClick: PropTypes.func, -}; - -export {DebugAlertContainer}; From 2412b388ed75d84d789d0fa41f50c434535c057c Mon Sep 17 00:00:00 2001 From: Carl Dawson Date: Mon, 11 May 2020 10:21:48 -0700 Subject: [PATCH 08/56] 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 09/56] 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 10/56] :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 11/56] :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 12/56] :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 27ad3afe446a93312de37e23f0ed3d1e9a444f3e Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Tue, 12 May 2020 02:58:51 -0400 Subject: [PATCH 13/56] changelog for hot reload status --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe67863ca0..102cbdad3a 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] +### Changed +- [#1237](https://github.com/plotly/dash/pull/1237) Closes [#920](https://github.com/plotly/dash/issues/920): Converts hot reload fetch failures into a server status indicator showing whether the latest fetch succeeded or failed. Callback fetch failures still appear as errors but have a clearer message. + ## [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. From 8c5055fb70530f53675edbb8a6fe24191923d1b4 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Tue, 12 May 2020 03:03:07 -0400 Subject: [PATCH 14/56] better styling for server status indicator --- .../src/components/error/icons/CheckIcon.svg | 3 + .../src/components/error/icons/ClockIcon.svg | 3 + .../src/components/error/icons/OffIcon.svg | 3 + .../components/error/icons/WhiteCloseIcon.svg | 4 +- .../src/components/error/menu/DebugMenu.css | 59 +++++++++++-- .../components/error/menu/DebugMenu.react.js | 88 +++++++++++-------- 6 files changed, 114 insertions(+), 46 deletions(-) create mode 100644 dash-renderer/src/components/error/icons/CheckIcon.svg create mode 100644 dash-renderer/src/components/error/icons/ClockIcon.svg create mode 100644 dash-renderer/src/components/error/icons/OffIcon.svg diff --git a/dash-renderer/src/components/error/icons/CheckIcon.svg b/dash-renderer/src/components/error/icons/CheckIcon.svg new file mode 100644 index 0000000000..bcadc0d371 --- /dev/null +++ b/dash-renderer/src/components/error/icons/CheckIcon.svg @@ -0,0 +1,3 @@ + + + diff --git a/dash-renderer/src/components/error/icons/ClockIcon.svg b/dash-renderer/src/components/error/icons/ClockIcon.svg new file mode 100644 index 0000000000..12810384df --- /dev/null +++ b/dash-renderer/src/components/error/icons/ClockIcon.svg @@ -0,0 +1,3 @@ + + + diff --git a/dash-renderer/src/components/error/icons/OffIcon.svg b/dash-renderer/src/components/error/icons/OffIcon.svg new file mode 100644 index 0000000000..6f4e3862b2 --- /dev/null +++ b/dash-renderer/src/components/error/icons/OffIcon.svg @@ -0,0 +1,3 @@ + + + diff --git a/dash-renderer/src/components/error/icons/WhiteCloseIcon.svg b/dash-renderer/src/components/error/icons/WhiteCloseIcon.svg index ccecc2c0f0..39928df41f 100755 --- a/dash-renderer/src/components/error/icons/WhiteCloseIcon.svg +++ b/dash-renderer/src/components/error/icons/WhiteCloseIcon.svg @@ -1,3 +1,3 @@ - - + + diff --git a/dash-renderer/src/components/error/menu/DebugMenu.css b/dash-renderer/src/components/error/menu/DebugMenu.css index d7cf3cfc2e..3edbee9fc9 100644 --- a/dash-renderer/src/components/error/menu/DebugMenu.css +++ b/dash-renderer/src/components/error/menu/DebugMenu.css @@ -31,9 +31,9 @@ width: 24px; height: 28px; } -.dash-debug-menu__icon--close { - width: 14px; - height: 14px; +.dash-debug-menu__icon--small { + width: 24px; + height: 24px; } .dash-debug-menu__icon--bell { height: 24px; @@ -61,6 +61,9 @@ width: 74px; margin-left: 10px; } +.dash-debug-menu__button-container--small { + margin-right: -20px; +} .dash-debug-menu__button-label { color: #A2B1C6; font-size: 10px; @@ -102,12 +105,56 @@ background-color: #03bb8a; } -.dash-debug-menu__connection { +.dash-debug-menu__indicator { + border-radius: 100%; + width: 34px; + height: 34px; + font-size: 10px; display: flex; justify-content: center; align-items: center; - font-size: 50px; - height: 66px; + position: relative; +} +.dash-debug-menu__indicator::before { + visibility: hidden; + pointer-events: none; + position: absolute; + box-sizing: border-box; + top: 110%; + left: 50%; + margin-left: -60px; + padding: 7px; + width: 120px; + border-radius: 3px; + background-color: rgba(68,68,68,0.7); + color: #fff; + content: attr(data-tooltip); + text-align: center; + font-size: 10px; + line-height: 1.2; +} +.dash-debug-menu__indicator:hover::before { + visibility: visible; +} +.dash-debug-menu__indicator--available { + background-color: #7FCCBB; +} +.dash-debug-menu__indicator--available::before { + content: "Server Available" +} +.dash-debug-menu__indicator--unavailable { + background-color: #F1564E; +} +.dash-debug-menu__indicator--unavailable::before { + content: "Server Unavailable. Check if the process has halted."; + margin-left: -70px; + width: 140px; +} +.dash-debug-menu__indicator--cold { + background-color: #FDDA68; +} +.dash-debug-menu__indicator--cold::before { + content: "Hot Reload Disabled" } .dash-debug-alert { diff --git a/dash-renderer/src/components/error/menu/DebugMenu.react.js b/dash-renderer/src/components/error/menu/DebugMenu.react.js index e7e1cb2004..be27c5e7a1 100644 --- a/dash-renderer/src/components/error/menu/DebugMenu.react.js +++ b/dash-renderer/src/components/error/menu/DebugMenu.react.js @@ -1,13 +1,18 @@ import React, {Component} from 'react'; +import PropTypes from 'prop-types'; + import './DebugMenu.css'; -import DebugIcon from '../icons/DebugIcon.svg'; -import WhiteCloseIcon from '../icons/WhiteCloseIcon.svg'; import BellIcon from '../icons/BellIcon.svg'; import BellIconGrey from '../icons/BellIconGrey.svg'; +import CheckIcon from '../icons/CheckIcon.svg'; +import ClockIcon from '../icons/ClockIcon.svg'; +import DebugIcon from '../icons/DebugIcon.svg'; import GraphIcon from '../icons/GraphIcon.svg'; import GraphIconGrey from '../icons/GraphIconGrey.svg'; -import PropTypes from 'prop-types'; +import OffIcon from '../icons/OffIcon.svg'; +import WhiteCloseIcon from '../icons/WhiteCloseIcon.svg'; + import GlobalErrorOverlay from '../GlobalErrorOverlay.react'; import {CallbackGraphContainer} from '../CallbackGraph/CallbackGraphContainer.react'; @@ -25,19 +30,40 @@ class DebugMenu extends Component { const {opened, errorsOpened, callbackGraphOpened} = this.state; const {error, graphs, hotReload} = this.props; - const menuClasses = opened - ? 'dash-debug-menu dash-debug-menu--opened' - : 'dash-debug-menu dash-debug-menu--closed'; + const menuClasses = + 'dash-debug-menu dash-debug-menu--' + + (opened ? 'opened' : 'closed'); const errCount = error.frontEnd.length + error.backEnd.length; const connected = error.backEndConnected; + const toggleOpened = () => { + this.setState({opened: !opened}); + }; + const toggleGraphOpened = () => { + this.setState({callbackGraphOpened: !callbackGraphOpened}); + }; const toggleErrorsOpened = () => { this.setState({errorsOpened: !errorsOpened}); }; const _GraphIcon = callbackGraphOpened ? GraphIcon : GraphIconGrey; const _BellIcon = errorsOpened ? BellIcon : BellIconGrey; + const status = hotReload + ? connected + ? 'available' + : 'unavailable' + : 'cold'; + const _StatusIcon = hotReload + ? connected + ? CheckIcon + : OffIcon + : ClockIcon; + + const btnClasses = enabled => + `dash-debug-menu__button ${ + enabled ? 'dash-debug-menu__button--enabled' : '' + }`; const menuContent = opened ? (
@@ -46,16 +72,8 @@ class DebugMenu extends Component { ) : null}
- this.setState({ - callbackGraphOpened: !callbackGraphOpened, - }) - } + className={btnClasses(callbackGraphOpened)} + onClick={toggleGraphOpened} > <_GraphIcon className="dash-debug-menu__icon dash-debug-menu__icon--graph" />
@@ -65,40 +83,34 @@ class DebugMenu extends Component {
<_BellIcon className="dash-debug-menu__icon dash-debug-menu__icon--bell" />
-
-
- {hotReload ? (connected ? '✅' : '🚫') : '❄'} +
+
+ <_StatusIcon className="dash-debug-menu__icon--small" />
-
{ - e.stopPropagation(); - this.setState({opened: false}); - }} + onClick={toggleOpened} > - +
@@ -130,7 +142,7 @@ class DebugMenu extends Component { {alertsLabel}
this.setState({opened: true})} + onClick={opened ? null : toggleOpened} > {menuContent}
From 4566a924ef109191660bf2176a24713896b004ef Mon Sep 17 00:00:00 2001 From: Carl Dawson Date: Tue, 12 May 2020 16:48:30 -0700 Subject: [PATCH 15/56] :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 16/56] :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 17/56] :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 831a06d690fe508e7fb9da638b0ee2a47c8def89 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Wed, 13 May 2020 01:50:44 -0400 Subject: [PATCH 18/56] bigger status icons & cleaner debug menu css --- .../src/components/error/icons/CheckIcon.svg | 4 +- .../src/components/error/icons/ClockIcon.svg | 4 +- .../src/components/error/menu/DebugMenu.css | 141 +++++++++--------- .../components/error/menu/DebugMenu.react.js | 136 ++++++++--------- 4 files changed, 140 insertions(+), 145 deletions(-) diff --git a/dash-renderer/src/components/error/icons/CheckIcon.svg b/dash-renderer/src/components/error/icons/CheckIcon.svg index bcadc0d371..ad6689b2ed 100644 --- a/dash-renderer/src/components/error/icons/CheckIcon.svg +++ b/dash-renderer/src/components/error/icons/CheckIcon.svg @@ -1,3 +1,3 @@ - - + + diff --git a/dash-renderer/src/components/error/icons/ClockIcon.svg b/dash-renderer/src/components/error/icons/ClockIcon.svg index 12810384df..df0359e8e1 100644 --- a/dash-renderer/src/components/error/icons/ClockIcon.svg +++ b/dash-renderer/src/components/error/icons/ClockIcon.svg @@ -1,3 +1,3 @@ - - + + diff --git a/dash-renderer/src/components/error/menu/DebugMenu.css b/dash-renderer/src/components/error/menu/DebugMenu.css index 3edbee9fc9..cc6e31eac9 100644 --- a/dash-renderer/src/components/error/menu/DebugMenu.css +++ b/dash-renderer/src/components/error/menu/DebugMenu.css @@ -1,29 +1,23 @@ .dash-debug-menu { - transition: width 0.05s, background-color 0.1s; + transition: 0.3s; position: fixed; bottom: 35px; right: 35px; display: flex; justify-content: center; align-items: center; - z-index: 10000; -} -.dash-debug-menu--closed { + z-index: 10001; background-color: #119dff; border-radius: 100%; width: 64px; height: 64px; + cursor: pointer; } -.dash-debug-menu--opened { - box-shadow: 0px 0px 1px rgba(0, 0, 0, 0.25), - 0px 1px 3px rgba(162, 177, 198, 0.32); - border-radius: 4px; - padding: 12px 0px; - background-color: white; +.dash-debug-menu--open { + transform: rotate(-180deg); } -.dash-debug-menu--closed:hover { - cursor: pointer; +.dash-debug-menu:hover { background-color: #108de4; } @@ -31,10 +25,6 @@ width: 24px; height: 28px; } -.dash-debug-menu__icon--small { - width: 24px; - height: 24px; -} .dash-debug-menu__icon--bell { height: 24px; width: 28px; @@ -46,6 +36,35 @@ .dash-debug-menu__icon--graph { height: 24px; } +.dash-debug-menu__icon--indicator { + height: 54px; + width: 54px; +} + +.dash-debug-menu__outer { + transition: 0.3s; + box-sizing: border-box; + position: fixed; + bottom: 27px; + right: 27px; + display: flex; + justify-content: center; + align-items: center; + z-index: 10000; + height: 80px; + border-radius: 40px; + padding: 5px 78px 5px 5px; + background-color: #fff; + box-shadow: 0px 0px 1px rgba(0, 0, 0, 0.25), + 0px 1px 3px rgba(162, 177, 198, 0.32); +} +.dash-debug-menu__outer--closed { + height: 60px; + width: 60px; + bottom: 37px; + right: 37px; + padding: 0; +} .dash-debug-menu__content { display: flex; @@ -59,68 +78,43 @@ justify-content: center; align-items: center; width: 74px; - margin-left: 10px; -} -.dash-debug-menu__button-container--small { - margin-right: -20px; -} -.dash-debug-menu__button-label { - color: #A2B1C6; - font-size: 10px; - margin-top: 4px; - text-align: center; } .dash-debug-menu__button { - background-color: white; + position: relative; + background-color: #B9C2CE; border-radius: 100%; - border: 1px solid #B9C2CE; width: 64px; height: 64px; font-size: 10px; display: flex; + flex-direction: column; justify-content: center; align-items: center; transition: background-color 0.2s; - color: black; -} -.dash-debug-menu__button--enabled { - background-color: #00CC96; - color: white; -} -.dash-debug-menu__button--small { - width: 32px; - height: 32px; - background-color: #B9C2CE; -} -.dash-debug-menu__button:hover { + color: #fff; cursor: pointer; - background-color: #f5f5f5; } -.dash-debug-menu__button--small:hover { +.dash-debug-menu__button:hover { background-color: #a1a9b5; } - -.dash-debug-menu__button--enabled:hover { +.dash-debug-menu__button--enabled { + background-color: #00CC96; +} +.dash-debug-menu__button.dash-debug-menu__button--enabled:hover { background-color: #03bb8a; } -.dash-debug-menu__indicator { - border-radius: 100%; - width: 34px; - height: 34px; - font-size: 10px; - display: flex; - justify-content: center; - align-items: center; - position: relative; +.dash-debug-menu__button-label { + cursor: inherit; } -.dash-debug-menu__indicator::before { + +.dash-debug-menu__button::before { visibility: hidden; pointer-events: none; position: absolute; box-sizing: border-box; - top: 110%; + bottom: 110%; left: 50%; margin-left: -60px; padding: 7px; @@ -128,33 +122,42 @@ border-radius: 3px; background-color: rgba(68,68,68,0.7); color: #fff; - content: attr(data-tooltip); text-align: center; font-size: 10px; line-height: 1.2; } -.dash-debug-menu__indicator:hover::before { +.dash-debug-menu__button:hover::before { visibility: visible; } -.dash-debug-menu__indicator--available { - background-color: #7FCCBB; +.dash-debug-menu__button--callbacks::before { + content: "Toggle Callback Graph"; } -.dash-debug-menu__indicator--available::before { - content: "Server Available" +.dash-debug-menu__button--errors::before { + content: "Toggle Errors"; } -.dash-debug-menu__indicator--unavailable { +.dash-debug-menu__button--available, +.dash-debug-menu__button--available:hover { + background-color: #00CC96; + cursor: default; +} +.dash-debug-menu__button--available::before { + content: "Server Available"; +} +.dash-debug-menu__button--unavailable, +.dash-debug-menu__button--unavailable:hover { background-color: #F1564E; + cursor: default; } -.dash-debug-menu__indicator--unavailable::before { +.dash-debug-menu__button--unavailable::before { content: "Server Unavailable. Check if the process has halted."; - margin-left: -70px; - width: 140px; } -.dash-debug-menu__indicator--cold { +.dash-debug-menu__button--cold, +.dash-debug-menu__button--cold:hover { background-color: #FDDA68; + cursor: default; } -.dash-debug-menu__indicator--cold::before { - content: "Hot Reload Disabled" +.dash-debug-menu__button--cold::before { + content: "Hot Reload Disabled"; } .dash-debug-alert { @@ -168,7 +171,7 @@ position: fixed; bottom: 81px; right: 29px; - z-index: 10001; + z-index: 10002; cursor: pointer; box-shadow: 0px 0px 1px rgba(0, 0, 0, 0.25), 0px 1px 3px rgba(162, 177, 198, 0.32); diff --git a/dash-renderer/src/components/error/menu/DebugMenu.react.js b/dash-renderer/src/components/error/menu/DebugMenu.react.js index be27c5e7a1..8044cfde93 100644 --- a/dash-renderer/src/components/error/menu/DebugMenu.react.js +++ b/dash-renderer/src/components/error/menu/DebugMenu.react.js @@ -4,18 +4,43 @@ import PropTypes from 'prop-types'; import './DebugMenu.css'; import BellIcon from '../icons/BellIcon.svg'; -import BellIconGrey from '../icons/BellIconGrey.svg'; import CheckIcon from '../icons/CheckIcon.svg'; import ClockIcon from '../icons/ClockIcon.svg'; import DebugIcon from '../icons/DebugIcon.svg'; import GraphIcon from '../icons/GraphIcon.svg'; -import GraphIconGrey from '../icons/GraphIconGrey.svg'; import OffIcon from '../icons/OffIcon.svg'; -import WhiteCloseIcon from '../icons/WhiteCloseIcon.svg'; import GlobalErrorOverlay from '../GlobalErrorOverlay.react'; import {CallbackGraphContainer} from '../CallbackGraph/CallbackGraphContainer.react'; +const classes = (base, variant, variant2) => + `${base} ${base}--${variant}` + (variant2 ? ` ${base}--${variant2}` : ''); + +const buttonFactory = ( + enabled, + buttonVariant, + toggle, + _Icon, + iconVariant, + label +) => ( +
+
+ <_Icon className={classes('dash-debug-menu__icon', iconVariant)} /> + {label ? ( + + ) : null} +
+
+); + class DebugMenu extends Component { constructor(props) { super(props); @@ -30,25 +55,13 @@ class DebugMenu extends Component { const {opened, errorsOpened, callbackGraphOpened} = this.state; const {error, graphs, hotReload} = this.props; - const menuClasses = - 'dash-debug-menu dash-debug-menu--' + - (opened ? 'opened' : 'closed'); - const errCount = error.frontEnd.length + error.backEnd.length; const connected = error.backEndConnected; - const toggleOpened = () => { - this.setState({opened: !opened}); - }; - const toggleGraphOpened = () => { - this.setState({callbackGraphOpened: !callbackGraphOpened}); - }; - const toggleErrorsOpened = () => { + const toggleErrors = () => { this.setState({errorsOpened: !errorsOpened}); }; - const _GraphIcon = callbackGraphOpened ? GraphIcon : GraphIconGrey; - const _BellIcon = errorsOpened ? BellIcon : BellIconGrey; const status = hotReload ? connected ? 'available' @@ -60,71 +73,41 @@ class DebugMenu extends Component { : OffIcon : ClockIcon; - const btnClasses = enabled => - `dash-debug-menu__button ${ - enabled ? 'dash-debug-menu__button--enabled' : '' - }`; - const menuContent = opened ? (
{callbackGraphOpened ? ( ) : null} -
-
- <_GraphIcon className="dash-debug-menu__icon dash-debug-menu__icon--graph" /> -
- -
-
-
- <_BellIcon className="dash-debug-menu__icon dash-debug-menu__icon--bell" /> -
- -
-
-
- <_StatusIcon className="dash-debug-menu__icon--small" /> -
-
-
-
- -
-
+ {buttonFactory( + callbackGraphOpened, + 'callbacks', + () => { + this.setState({ + callbackGraphOpened: !callbackGraphOpened, + }); + }, + GraphIcon, + 'graph', + 'Callbacks' + )} + {buttonFactory( + errorsOpened, + 'errors', + toggleErrors, + BellIcon, + 'bell', + errCount + ' Error' + (errCount === 1 ? '' : 's') + )} + {buttonFactory(false, status, null, _StatusIcon, 'indicator')}
) : ( - +
); const alertsLabel = (errCount || !connected) && !opened ? (
-
+
{errCount ? (
{'🛑 ' + errCount} @@ -137,14 +120,23 @@ class DebugMenu extends Component {
) : null; + const openVariant = opened ? 'open' : 'closed'; + return (
{alertsLabel} +
+ {menuContent} +
{ + this.setState({opened: !opened}); + }} > - {menuContent} +
Date: Wed, 13 May 2020 02:03:33 -0400 Subject: [PATCH 19/56] remove now unused icons --- dash-renderer/src/components/error/icons/BellIconGrey.svg | 3 --- dash-renderer/src/components/error/icons/CloseIcon.svg | 3 --- dash-renderer/src/components/error/icons/GraphIconGrey.svg | 3 --- dash-renderer/src/components/error/icons/WarningIconWhite.svg | 3 --- dash-renderer/src/components/error/icons/WhiteCloseIcon.svg | 3 --- 5 files changed, 15 deletions(-) delete mode 100755 dash-renderer/src/components/error/icons/BellIconGrey.svg delete mode 100755 dash-renderer/src/components/error/icons/CloseIcon.svg delete mode 100644 dash-renderer/src/components/error/icons/GraphIconGrey.svg delete mode 100755 dash-renderer/src/components/error/icons/WarningIconWhite.svg delete mode 100755 dash-renderer/src/components/error/icons/WhiteCloseIcon.svg diff --git a/dash-renderer/src/components/error/icons/BellIconGrey.svg b/dash-renderer/src/components/error/icons/BellIconGrey.svg deleted file mode 100755 index c3b91aca6d..0000000000 --- a/dash-renderer/src/components/error/icons/BellIconGrey.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/dash-renderer/src/components/error/icons/CloseIcon.svg b/dash-renderer/src/components/error/icons/CloseIcon.svg deleted file mode 100755 index 90eb4191fc..0000000000 --- a/dash-renderer/src/components/error/icons/CloseIcon.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/dash-renderer/src/components/error/icons/GraphIconGrey.svg b/dash-renderer/src/components/error/icons/GraphIconGrey.svg deleted file mode 100644 index e81bae0294..0000000000 --- a/dash-renderer/src/components/error/icons/GraphIconGrey.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/dash-renderer/src/components/error/icons/WarningIconWhite.svg b/dash-renderer/src/components/error/icons/WarningIconWhite.svg deleted file mode 100755 index d77f988141..0000000000 --- a/dash-renderer/src/components/error/icons/WarningIconWhite.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/dash-renderer/src/components/error/icons/WhiteCloseIcon.svg b/dash-renderer/src/components/error/icons/WhiteCloseIcon.svg deleted file mode 100755 index 39928df41f..0000000000 --- a/dash-renderer/src/components/error/icons/WhiteCloseIcon.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - From 92d0246d147c2b9c82161536533f299b970c4a0e Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Wed, 13 May 2020 10:48:09 -0400 Subject: [PATCH 20/56] test server status indicator --- .../integration/devtools/test_devtools_ui.py | 8 +-- tests/integration/devtools/test_hot_reload.py | 51 +++++++++++++++++-- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/tests/integration/devtools/test_devtools_ui.py b/tests/integration/devtools/test_devtools_ui.py index 7d6ce5abc3..878e6377b7 100644 --- a/tests/integration/devtools/test_devtools_ui.py +++ b/tests/integration/devtools/test_devtools_ui.py @@ -1,3 +1,5 @@ +from time import sleep + import dash_core_components as dcc import dash_html_components as html import dash @@ -25,9 +27,9 @@ def test_dvui001_disable_props_check_config(dash_duo): dash_duo.wait_for_text_to_equal("#tcid", "Hello Props Check") assert dash_duo.find_elements("#broken svg.main-svg"), "graph should be rendered" - assert dash_duo.find_elements( - ".dash-debug-menu" - ), "the debug menu icon should show up" + # open the debug menu so we see the "hot reload off" indicator + dash_duo.find_element(".dash-debug-menu").click() + sleep(1) # wait for debug menu opening animation dash_duo.percy_snapshot("devtools - disable props check - Graph should render") diff --git a/tests/integration/devtools/test_hot_reload.py b/tests/integration/devtools/test_hot_reload.py index b57bba4de5..0b0a0fae28 100644 --- a/tests/integration/devtools/test_hot_reload.py +++ b/tests/integration/devtools/test_hot_reload.py @@ -3,6 +3,8 @@ import dash_html_components as html import dash +from dash.dependencies import Input, Output +from dash.exceptions import PreventUpdate RED_BG = """ @@ -14,15 +16,27 @@ def test_dvhr001_hot_reload(dash_duo): app = dash.Dash(__name__, assets_folder="hr_assets") - app.layout = html.Div([html.H3("Hot reload")], id="hot-reload-content") + app.layout = html.Div([ + html.H3("Hot reload", id="text"), + html.Button("Click", id="btn") + ], id="hot-reload-content") - dash_duo.start_server( - app, + @app.callback(Output("text", "children"), [Input("btn", "n_clicks")]) + def new_text(n): + if not n: + raise PreventUpdate + return n + + hot_reload_settings = dict( dev_tools_hot_reload=True, + dev_tools_ui=True, + dev_tools_serve_dev_bundles=True, dev_tools_hot_reload_interval=0.1, dev_tools_hot_reload_max_retry=100, ) + dash_duo.start_server(app, **hot_reload_settings) + # default overload color is blue dash_duo.wait_for_style_to_equal( "#hot-reload-content", "background-color", "rgba(0, 0, 255, 1)" @@ -51,3 +65,34 @@ def test_dvhr001_hot_reload(dash_duo): dash_duo.wait_for_style_to_equal( "#hot-reload-content", "background-color", "rgba(0, 0, 255, 1)" ) + + # Now check the server status indicator functionality + + dash_duo.find_element(".dash-debug-menu").click() + dash_duo.find_element(".dash-debug-menu__button--available") + sleep(1) # wait for opening animation + dash_duo.percy_snapshot(name="hot-reload-available") + + dash_duo.server.stop() + sleep(1) # make sure we would have requested the reload hash multiple times + dash_duo.find_element(".dash-debug-menu__button--unavailable") + dash_duo.wait_for_no_elements(".dash-fe-error__title") + dash_duo.percy_snapshot(name="hot-reload-unavailable") + + dash_duo.find_element(".dash-debug-menu").click() + sleep(1) # wait for opening animation + dash_duo.find_element(".dash-debug-disconnected") + dash_duo.percy_snapshot(name="hot-reload-unavailable-small") + + dash_duo.find_element("#btn").click() + dash_duo.wait_for_text_to_equal( + ".dash-fe-error__title", "Callback failed: the server did not respond." + ) + + # start up the server again + dash_duo.start_server(app, **hot_reload_settings) + + # rerenders with debug menu closed after reload + # reopen and check that server is now available + dash_duo.find_element(".dash-debug-menu--closed").click() + dash_duo.find_element(".dash-debug-menu__button--available") From 9a7539f3c25b977649865f1d77ab933c125976b3 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Wed, 13 May 2020 12:45:23 -0400 Subject: [PATCH 21/56] Label the server icon & standardize debug css --- .../src/components/error/icons/CheckIcon.svg | 4 ++-- .../src/components/error/icons/ClockIcon.svg | 4 ++-- .../src/components/error/icons/OffIcon.svg | 4 ++-- .../src/components/error/menu/DebugMenu.css | 15 --------------- .../src/components/error/menu/DebugMenu.react.js | 2 +- 5 files changed, 7 insertions(+), 22 deletions(-) diff --git a/dash-renderer/src/components/error/icons/CheckIcon.svg b/dash-renderer/src/components/error/icons/CheckIcon.svg index ad6689b2ed..b5ad358c68 100644 --- a/dash-renderer/src/components/error/icons/CheckIcon.svg +++ b/dash-renderer/src/components/error/icons/CheckIcon.svg @@ -1,3 +1,3 @@ - - + + diff --git a/dash-renderer/src/components/error/icons/ClockIcon.svg b/dash-renderer/src/components/error/icons/ClockIcon.svg index df0359e8e1..36dd569e5b 100644 --- a/dash-renderer/src/components/error/icons/ClockIcon.svg +++ b/dash-renderer/src/components/error/icons/ClockIcon.svg @@ -1,3 +1,3 @@ - - + + diff --git a/dash-renderer/src/components/error/icons/OffIcon.svg b/dash-renderer/src/components/error/icons/OffIcon.svg index 6f4e3862b2..44f805eb6d 100644 --- a/dash-renderer/src/components/error/icons/OffIcon.svg +++ b/dash-renderer/src/components/error/icons/OffIcon.svg @@ -1,3 +1,3 @@ - - + + diff --git a/dash-renderer/src/components/error/menu/DebugMenu.css b/dash-renderer/src/components/error/menu/DebugMenu.css index cc6e31eac9..c895b4a95b 100644 --- a/dash-renderer/src/components/error/menu/DebugMenu.css +++ b/dash-renderer/src/components/error/menu/DebugMenu.css @@ -22,24 +22,9 @@ } .dash-debug-menu__icon { - width: 24px; - height: 28px; -} -.dash-debug-menu__icon--bell { - height: 24px; - width: 28px; -} -.dash-debug-menu__icon--debug { - height: 24px; width: auto; -} -.dash-debug-menu__icon--graph { height: 24px; } -.dash-debug-menu__icon--indicator { - height: 54px; - width: 54px; -} .dash-debug-menu__outer { transition: 0.3s; diff --git a/dash-renderer/src/components/error/menu/DebugMenu.react.js b/dash-renderer/src/components/error/menu/DebugMenu.react.js index 8044cfde93..fef8f42ac3 100644 --- a/dash-renderer/src/components/error/menu/DebugMenu.react.js +++ b/dash-renderer/src/components/error/menu/DebugMenu.react.js @@ -98,7 +98,7 @@ class DebugMenu extends Component { 'bell', errCount + ' Error' + (errCount === 1 ? '' : 's') )} - {buttonFactory(false, status, null, _StatusIcon, 'indicator')} + {buttonFactory(false, status, null, _StatusIcon, 'indicator', 'Server')}
) : (
From 593f55e4ac369ee5585d383a4f288d98848995f6 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Wed, 13 May 2020 12:52:25 -0400 Subject: [PATCH 22/56] lint & fix bell icon for new css --- dash-renderer/src/components/error/icons/BellIcon.svg | 2 +- .../src/components/error/menu/DebugMenu.react.js | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/dash-renderer/src/components/error/icons/BellIcon.svg b/dash-renderer/src/components/error/icons/BellIcon.svg index d73e9e4680..d397df2930 100755 --- a/dash-renderer/src/components/error/icons/BellIcon.svg +++ b/dash-renderer/src/components/error/icons/BellIcon.svg @@ -1,3 +1,3 @@ - + diff --git a/dash-renderer/src/components/error/menu/DebugMenu.react.js b/dash-renderer/src/components/error/menu/DebugMenu.react.js index fef8f42ac3..636323eb48 100644 --- a/dash-renderer/src/components/error/menu/DebugMenu.react.js +++ b/dash-renderer/src/components/error/menu/DebugMenu.react.js @@ -98,7 +98,14 @@ class DebugMenu extends Component { 'bell', errCount + ' Error' + (errCount === 1 ? '' : 's') )} - {buttonFactory(false, status, null, _StatusIcon, 'indicator', 'Server')} + {buttonFactory( + false, + status, + null, + _StatusIcon, + 'indicator', + 'Server' + )}
) : (
From 0d3e0f6c4b60d9b9f578f724ca7146be5d609c80 Mon Sep 17 00:00:00 2001 From: Carl Dawson Date: Wed, 13 May 2020 16:28:35 -0700 Subject: [PATCH 23/56] 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 24/56] 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 25/56] :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 26/56] 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 27/56] 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 From 7e84ccf627161f0cccea12bf26465328b0403a62 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Thu, 14 May 2020 09:34:58 -0400 Subject: [PATCH 28/56] Update dash-renderer/src/components/error/menu/DebugMenu.css Co-authored-by: Chris Parmer --- dash-renderer/src/components/error/menu/DebugMenu.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dash-renderer/src/components/error/menu/DebugMenu.css b/dash-renderer/src/components/error/menu/DebugMenu.css index c895b4a95b..092e379db2 100644 --- a/dash-renderer/src/components/error/menu/DebugMenu.css +++ b/dash-renderer/src/components/error/menu/DebugMenu.css @@ -134,7 +134,7 @@ cursor: default; } .dash-debug-menu__button--unavailable::before { - content: "Server Unavailable. Check if the process has halted."; + content: "Server Unavailable. Check if the process has halted or crashed."; } .dash-debug-menu__button--cold, .dash-debug-menu__button--cold:hover { From 99f8e952f9508ac1815002b37f705eccacb663b8 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Fri, 15 May 2020 00:38:45 -0400 Subject: [PATCH 29/56] support persistence with dict ids --- dash-renderer/src/persistence.js | 3 +- tests/integration/devtools/test_hot_reload.py | 8 +- .../integration/renderer/test_persistence.py | 77 ++++++++++++++++++- 3 files changed, 82 insertions(+), 6 deletions(-) diff --git a/dash-renderer/src/persistence.js b/dash-renderer/src/persistence.js index 36b8688a3f..4940d0ff89 100644 --- a/dash-renderer/src/persistence.js +++ b/dash-renderer/src/persistence.js @@ -69,6 +69,7 @@ import { import {createAction} from 'redux-actions'; import Registry from './registry'; +import {stringifyId} from './actions/dependencies'; export const storePrefix = '_dash_persistence.'; @@ -270,7 +271,7 @@ const getTransform = (element, propName, propPart) => : noopTransform; const getValsKey = (id, persistedProp, persistence) => - `${id}.${persistedProp}.${JSON.stringify(persistence)}`; + `${stringifyId(id)}.${persistedProp}.${JSON.stringify(persistence)}`; const getProps = layout => { const {props, type, namespace} = layout; diff --git a/tests/integration/devtools/test_hot_reload.py b/tests/integration/devtools/test_hot_reload.py index 0b0a0fae28..5fdaa50865 100644 --- a/tests/integration/devtools/test_hot_reload.py +++ b/tests/integration/devtools/test_hot_reload.py @@ -16,10 +16,10 @@ def test_dvhr001_hot_reload(dash_duo): app = dash.Dash(__name__, assets_folder="hr_assets") - app.layout = html.Div([ - html.H3("Hot reload", id="text"), - html.Button("Click", id="btn") - ], id="hot-reload-content") + app.layout = html.Div( + [html.H3("Hot reload", id="text"), html.Button("Click", id="btn")], + id="hot-reload-content", + ) @app.callback(Output("text", "children"), [Input("btn", "n_clicks")]) def new_text(n): diff --git a/tests/integration/renderer/test_persistence.py b/tests/integration/renderer/test_persistence.py index 2fc5672be8..49d91d6f24 100644 --- a/tests/integration/renderer/test_persistence.py +++ b/tests/integration/renderer/test_persistence.py @@ -5,7 +5,7 @@ from selenium.webdriver.common.keys import Keys import dash -from dash.dependencies import Input, Output +from dash.dependencies import Input, Output, State, MATCH import dash_core_components as dcc import dash_html_components as html @@ -451,3 +451,78 @@ def set_out(val): dash_duo.find_element("#persistence-val").send_keys("2") assert not dash_duo.get_logs() dash_duo.wait_for_text_to_equal("#out", "artichoke") + + +def test_rdps012_pattern_matching(dash_duo): + # copy of rdps010 but with dict IDs, + # plus a button to change the dict ID so the persistence should reset + def make_input(persistence, n): + return dcc.Input( + id={"i": n, "id": "persisted"}, + className="persisted", + value="a", + persistence=persistence, + ) + + app = dash.Dash(__name__) + app.layout = html.Div( + [html.Button("click", id="btn", n_clicks=0), html.Div(id="content")] + ) + + @app.callback(Output("content", "children"), [Input("btn", "n_clicks")]) + def content(n): + return [ + dcc.Input( + id={"i": n, "id": "persistence-val"}, + value="", + className="persistence-val", + ), + html.Div(make_input("", n), id={"i": n, "id": "persisted-container"}), + html.Div(id={"i": n, "id": "out"}, className="out"), + ] + + @app.callback( + Output({"i": MATCH, "id": "persisted-container"}, "children"), + [Input({"i": MATCH, "id": "persistence-val"}, "value")], + [State("btn", "n_clicks")], + ) + def set_persistence(val, n): + return make_input(val, n) + + @app.callback( + Output({"i": MATCH, "id": "out"}, "children"), + [Input({"i": MATCH, "id": "persisted"}, "value")], + ) + def set_out(val): + return val + + dash_duo.start_server(app) + + for _ in range(3): + dash_duo.wait_for_text_to_equal(".out", "a") + dash_duo.find_element(".persisted").send_keys("lpaca") + dash_duo.wait_for_text_to_equal(".out", "alpaca") + + dash_duo.find_element(".persistence-val").send_keys("s") + dash_duo.wait_for_text_to_equal(".out", "a") + dash_duo.find_element(".persisted").send_keys("nchovies") + dash_duo.wait_for_text_to_equal(".out", "anchovies") + + dash_duo.find_element(".persistence-val").send_keys("2") + dash_duo.wait_for_text_to_equal(".out", "a") + dash_duo.find_element(".persisted").send_keys( + Keys.BACK_SPACE + ) # persist falsy value + dash_duo.wait_for_text_to_equal(".out", "") + + # alpaca not saved with falsy persistence + dash_duo.clear_input(".persistence-val") + dash_duo.wait_for_text_to_equal(".out", "a") + + # anchovies and aardvark saved + dash_duo.find_element(".persistence-val").send_keys("s") + dash_duo.wait_for_text_to_equal(".out", "anchovies") + dash_duo.find_element(".persistence-val").send_keys("2") + dash_duo.wait_for_text_to_equal(".out", "") + + dash_duo.find_element("#btn").click() From 20cf65f0aa52dcd9837559f0ac17baeec7ae6b6d Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Fri, 15 May 2020 00:43:13 -0400 Subject: [PATCH 30/56] changelog for dict id persistence fix --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79fa8a2150..405158ab14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Changed - [#1237](https://github.com/plotly/dash/pull/1237) Closes [#920](https://github.com/plotly/dash/issues/920): Converts hot reload fetch failures into a server status indicator showing whether the latest fetch succeeded or failed. Callback fetch failures still appear as errors but have a clearer message. +### Fixed +- [#1248](https://github.com/plotly/dash/pull/1248) Fixes [#1245](https://github.com/plotly/dash/issues/1245), so you can use prop persistence with components that have dict IDs, ie for pattern-matching callbacks. + ## [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. From 603f6a31424bcc68a3212a7df0dde7374623871b Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Fri, 15 May 2020 01:06:13 -0400 Subject: [PATCH 31/56] fix #919 - protect against item with no funcargs --- dash/testing/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dash/testing/plugin.py b/dash/testing/plugin.py index 724f07c6fd..3161df820e 100644 --- a/dash/testing/plugin.py +++ b/dash/testing/plugin.py @@ -75,7 +75,7 @@ def pytest_runtest_makereport(item, call): # pylint: disable=unused-argument rep = outcome.get_result() # we only look at actual failing test calls, not setup/teardown - if rep.when == "call" and rep.failed: + if rep.when == "call" and rep.failed and hasattr(item, "funcargs"): for name, fixture in item.funcargs.items(): try: if name in {"dash_duo", "dash_br", "dashr"}: From 34baeb46d20ba1f178830eada2fe2974d22be036 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Fri, 15 May 2020 14:01:28 -0400 Subject: [PATCH 32/56] changelog for pytest-flake8/black fix --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79fa8a2150..81e86217c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Changed - [#1237](https://github.com/plotly/dash/pull/1237) Closes [#920](https://github.com/plotly/dash/issues/920): Converts hot reload fetch failures into a server status indicator showing whether the latest fetch succeeded or failed. Callback fetch failures still appear as errors but have a clearer message. +### Fixed +- [#1249](https://github.com/plotly/dash/pull/1249) Fixes [#919](https://github.com/plotly/dash/issues/919) so `dash.testing` is compatible with more `pytest` plugins, particularly `pytest-flake8` and `pytest-black`. + ## [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. From 52eb81c50f6646b28aed1dfaf90c4a91bfa63e31 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Fri, 15 May 2020 14:48:44 -0400 Subject: [PATCH 33/56] changelog for asset directory sorting --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 884d5b3757..de8d84ce38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Fixed - [#1249](https://github.com/plotly/dash/pull/1249) Fixes [#919](https://github.com/plotly/dash/issues/919) so `dash.testing` is compatible with more `pytest` plugins, particularly `pytest-flake8` and `pytest-black`. - [#1248](https://github.com/plotly/dash/pull/1248) Fixes [#1245](https://github.com/plotly/dash/issues/1245), so you can use prop persistence with components that have dict IDs, ie for pattern-matching callbacks. +- [#1185](https://github.com/plotly/dash/pull/1185) Sort asset directories, same as we sort files inside those directories. This way if you need your assets loaded in a certain order, you can add prefixes to subdirectory names and enforce that order. ## [1.12.0] - 2020-05-05 ### Added From 44262e3ebf33cfb64998d7d0e95eec03d3beabf7 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Sat, 16 May 2020 00:33:45 -0400 Subject: [PATCH 34/56] hard reload targets just this window --- dash-renderer/src/components/core/Reloader.react.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dash-renderer/src/components/core/Reloader.react.js b/dash-renderer/src/components/core/Reloader.react.js index f91df5c415..0a20dd9edd 100644 --- a/dash-renderer/src/components/core/Reloader.react.js +++ b/dash-renderer/src/components/core/Reloader.react.js @@ -148,7 +148,7 @@ class Reloader extends React.Component { // Assets file have changed // or a component lib has been added/removed - // Must do a hard reload - window.top.location.reload(); + window.location.reload(); } } else { // Backend code changed - can do a soft reload in place From 6747fd4a22065ec7fc734519d6acaf3acfbfdadb Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Sat, 16 May 2020 01:13:59 -0400 Subject: [PATCH 35/56] hard & soft reload test --- .../devtools/hr_assets/hot_reload.js | 2 + tests/integration/devtools/test_hot_reload.py | 63 +++++++++++++++---- 2 files changed, 54 insertions(+), 11 deletions(-) create mode 100644 tests/integration/devtools/hr_assets/hot_reload.js diff --git a/tests/integration/devtools/hr_assets/hot_reload.js b/tests/integration/devtools/hr_assets/hot_reload.js new file mode 100644 index 0000000000..71acdc2219 --- /dev/null +++ b/tests/integration/devtools/hr_assets/hot_reload.js @@ -0,0 +1,2 @@ + +window.cheese = 'roquefort'; diff --git a/tests/integration/devtools/test_hot_reload.py b/tests/integration/devtools/test_hot_reload.py index 5fdaa50865..83669ba6c3 100644 --- a/tests/integration/devtools/test_hot_reload.py +++ b/tests/integration/devtools/test_hot_reload.py @@ -1,6 +1,7 @@ import os from time import sleep +from dash.testing.wait import until import dash_html_components as html import dash from dash.dependencies import Input, Output @@ -13,6 +14,24 @@ } """ +GOUDA = """ +window.cheese = 'gouda'; +""" + + +def replace_file(filename, new_content): + path = os.path.join( + os.path.dirname(__file__), "hr_assets", filename + ) + with open(path, "r+") as fp: + sleep(1) # ensure a new mod time + old_content = fp.read() + fp.truncate(0) + fp.seek(0) + fp.write(new_content) + + return path, old_content + def test_dvhr001_hot_reload(dash_duo): app = dash.Dash(__name__, assets_folder="hr_assets") @@ -42,15 +61,12 @@ def new_text(n): "#hot-reload-content", "background-color", "rgba(0, 0, 255, 1)" ) - hot_reload_file = os.path.join( - os.path.dirname(__file__), "hr_assets", "hot_reload.css" - ) - with open(hot_reload_file, "r+") as fp: - sleep(1) # ensure a new mod time - old_content = fp.read() - fp.truncate(0) - fp.seek(0) - fp.write(RED_BG) + # set a global var - if we soft reload it should still be there, + # hard reload will delete it + dash_duo.driver.execute_script("window.someVar = 42;") + assert dash_duo.driver.execute_script("return window.someVar") == 42 + + soft_reload_file, old_soft = replace_file("hot_reload.css", RED_BG) try: # red is live changed during the test execution @@ -59,13 +75,38 @@ def new_text(n): ) finally: sleep(1) # ensure a new mod time - with open(hot_reload_file, "w") as f: - f.write(old_content) + with open(soft_reload_file, "w") as f: + f.write(old_soft) dash_duo.wait_for_style_to_equal( "#hot-reload-content", "background-color", "rgba(0, 0, 255, 1)" ) + # only soft reload, someVar is still there + assert dash_duo.driver.execute_script("return window.someVar") == 42 + + assert dash_duo.driver.execute_script("return window.cheese") == "roquefort" + + hard_reload_file, old_hard = replace_file("hot_reload.js", GOUDA) + + try: + until( + lambda: dash_duo.driver.execute_script("return window.cheese") == "gouda", + timeout=3 + ) + finally: + sleep(1) # ensure a new mod time + with open(hard_reload_file, "w") as f: + f.write(old_hard) + + until( + lambda: dash_duo.driver.execute_script("return window.cheese") == "roquefort", + timeout=3 + ) + + # we've done a hard reload so someVar is gone + assert dash_duo.driver.execute_script("return window.someVar") is None + # Now check the server status indicator functionality dash_duo.find_element(".dash-debug-menu").click() From fc83610a1621822dcd165a451597c3a98329df35 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Sat, 16 May 2020 01:16:10 -0400 Subject: [PATCH 36/56] changelog for hard hot reload fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index de8d84ce38..b5bd28ee4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - [#1237](https://github.com/plotly/dash/pull/1237) Closes [#920](https://github.com/plotly/dash/issues/920): Converts hot reload fetch failures into a server status indicator showing whether the latest fetch succeeded or failed. Callback fetch failures still appear as errors but have a clearer message. ### Fixed +- [#1255](https://github.com/plotly/dash/pull/1255) Hard hot reload targets only the current window, not the top - so if your app is in an iframe you will only reload the app - [#1249](https://github.com/plotly/dash/pull/1249) Fixes [#919](https://github.com/plotly/dash/issues/919) so `dash.testing` is compatible with more `pytest` plugins, particularly `pytest-flake8` and `pytest-black`. - [#1248](https://github.com/plotly/dash/pull/1248) Fixes [#1245](https://github.com/plotly/dash/issues/1245), so you can use prop persistence with components that have dict IDs, ie for pattern-matching callbacks. - [#1185](https://github.com/plotly/dash/pull/1185) Sort asset directories, same as we sort files inside those directories. This way if you need your assets loaded in a certain order, you can add prefixes to subdirectory names and enforce that order. From ebac7d4e82ebb7895b3437443863a889f1d30587 Mon Sep 17 00:00:00 2001 From: Ryan Patrick Kyle Date: Mon, 18 May 2020 23:33:11 -0400 Subject: [PATCH 37/56] Add JuliaRunner to support Dash.jl integration tests (#1239) --- dash/development/_r_components_generation.py | 21 ++- dash/testing/application_runners.py | 136 ++++++++++++++++--- dash/testing/composite.py | 14 ++ dash/testing/plugin.py | 29 +++- 4 files changed, 173 insertions(+), 27 deletions(-) diff --git a/dash/development/_r_components_generation.py b/dash/development/_r_components_generation.py index 198cc3e85f..1e50f30141 100644 --- a/dash/development/_r_components_generation.py +++ b/dash/development/_r_components_generation.py @@ -185,7 +185,6 @@ def generate_class_string(name, props, project_shortname, prefix): props = reorder_props(props=props) prop_keys = list(props.keys()) - prop_keys_wc = list(props.keys()) wildcards = "" wildcard_declaration = "" @@ -194,8 +193,8 @@ def generate_class_string(name, props, project_shortname, prefix): default_argtext = "" accepted_wildcards = "" - if any(key.endswith("-*") for key in prop_keys_wc): - accepted_wildcards = get_wildcards_r(prop_keys_wc) + if any(key.endswith("-*") for key in prop_keys): + accepted_wildcards = get_wildcards_r(prop_keys) wildcards = ", ..." wildcard_declaration = wildcard_template.format( accepted_wildcards.replace("-*", "") @@ -222,6 +221,9 @@ def generate_class_string(name, props, project_shortname, prefix): default_argtext += ", ".join("{}=NULL".format(p) for p in prop_keys) + if wildcards == ", ...": + default_argtext += ", ..." + # pylint: disable=C0301 default_paramtext += ", ".join( "{0}={0}".format(p) if p != "children" else "{}=children".format(p) @@ -380,15 +382,20 @@ def write_help_file(name, props, description, prefix, rpkg_data): funcname = format_fn_name(prefix, name) file_name = funcname + ".Rd" + wildcards = "" default_argtext = "" item_text = "" + accepted_wildcards = "" # the return value of all Dash components should be the same, # in an abstract sense -- they produce a list value_text = "named list of JSON elements corresponding to React.js properties and their values" # noqa:E501 prop_keys = list(props.keys()) - prop_keys_wc = list(props.keys()) + + if any(key.endswith("-*") for key in prop_keys): + accepted_wildcards = get_wildcards_r(prop_keys) + wildcards = ", ..." # Filter props to remove those we don't want to expose for item in prop_keys[:]: @@ -413,9 +420,9 @@ def write_help_file(name, props, description, prefix, rpkg_data): if "**Example Usage**" in description: description = description.split("**Example Usage**")[0].rstrip() - if any(key.endswith("-*") for key in prop_keys_wc): - default_argtext += ", ..." - item_text += wildcard_help_template.format(get_wildcards_r(prop_keys_wc)) + if wildcards == ", ...": + default_argtext += wildcards + item_text += wildcard_help_template.format(accepted_wildcards) # in R, the online help viewer does not properly wrap lines for # the usage string -- we will hard wrap at 60 characters using diff --git a/dash/testing/application_runners.py b/dash/testing/application_runners.py index a14f421abd..75b1dc0bb3 100644 --- a/dash/testing/application_runners.py +++ b/dash/testing/application_runners.py @@ -221,6 +221,10 @@ def stop(self): try: logger.info("proc.terminate with pid %s", self.proc.pid) self.proc.terminate() + if self.tmp_app_path and os.path.exists(self.tmp_app_path): + logger.debug("removing temporary app path %s", + self.tmp_app_path) + shutil.rmtree(self.tmp_app_path) if utils.PY3: # pylint:disable=no-member _except = subprocess.TimeoutExpired @@ -285,6 +289,24 @@ def start(self, app, start_timeout=2, cwd=None): break if cwd: logger.info("RRunner inferred cwd from the Python call stack: %s", cwd) + + # try copying all valid sub folders (i.e. assets) in cwd to tmp + # note that the R assets folder name can be any valid folder name + assets = [ + os.path.join(cwd, _) + for _ in os.listdir(cwd) + if not _.startswith("__") and os.path.isdir(os.path.join(cwd, _)) + ] + + for asset in assets: + target = os.path.join(self.tmp_app_path, os.path.basename(asset)) + if os.path.exists(target): + logger.debug("delete existing target %s", target) + shutil.rmtree(target) + logger.debug("copying %s => %s", asset, self.tmp_app_path) + shutil.copytree(asset, target) + logger.debug("copied with %s", os.listdir(target)) + else: logger.warning( "RRunner found no cwd in the Python call stack. " @@ -293,23 +315,6 @@ def start(self, app, start_timeout=2, cwd=None): "dashr.run_server(app, cwd=os.path.dirname(__file__))" ) - # try copying all valid sub folders (i.e. assets) in cwd to tmp - # note that the R assets folder name can be any valid folder name - assets = [ - os.path.join(cwd, _) - for _ in os.listdir(cwd) - if not _.startswith("__") and os.path.isdir(os.path.join(cwd, _)) - ] - - for asset in assets: - target = os.path.join(self.tmp_app_path, os.path.basename(asset)) - if os.path.exists(target): - logger.debug("delete existing target %s", target) - shutil.rmtree(target) - logger.debug("copying %s => %s", asset, self.tmp_app_path) - shutil.copytree(asset, target) - logger.debug("copied with %s", os.listdir(target)) - logger.info("Run dashR app with Rscript => %s", app) args = shlex.split( @@ -334,3 +339,100 @@ def start(self, app, start_timeout=2, cwd=None): return self.started = True + + +class JuliaRunner(ProcessRunner): + def __init__(self, keep_open=False, stop_timeout=3): + super(JuliaRunner, self).__init__(keep_open=keep_open, stop_timeout=stop_timeout) + self.proc = None + + # pylint: disable=arguments-differ + def start(self, app, start_timeout=30, cwd=None): + """Start the server with subprocess and julia.""" + + if os.path.isfile(app) and os.path.exists(app): + # app is already a file in a dir - use that as cwd + if not cwd: + cwd = os.path.dirname(app) + logger.info("JuliaRunner inferred cwd from app path: %s", cwd) + else: + # app is a string chunk, we make a temporary folder to store app.jl + # and its relevants assets + self._tmp_app_path = os.path.join( + "/tmp" if not self.is_windows else os.getenv("TEMP"), uuid.uuid4().hex + ) + try: + os.mkdir(self.tmp_app_path) + except OSError: + logger.exception("cannot make temporary folder %s", self.tmp_app_path) + path = os.path.join(self.tmp_app_path, "app.jl") + + logger.info("JuliaRunner start => app is Julia code chunk") + logger.info("make a temporary Julia file for execution => %s", path) + logger.debug("content of the Dash.jl app") + logger.debug("%s", app) + + with open(path, "w") as fp: + fp.write(app) + + app = path + + # try to find the path to the calling script to use as cwd + if not cwd: + for entry in inspect.stack(): + if "/dash/testing/" not in entry[1].replace("\\", "/"): + cwd = os.path.dirname(os.path.realpath(entry[1])) + logger.warning("get cwd from inspect => %s", cwd) + break + if cwd: + logger.info("JuliaRunner inferred cwd from the Python call stack: %s", cwd) + + # try copying all valid sub folders (i.e. assets) in cwd to tmp + # note that the R assets folder name can be any valid folder name + assets = [ + os.path.join(cwd, _) + for _ in os.listdir(cwd) + if not _.startswith("__") and os.path.isdir(os.path.join(cwd, _)) + ] + + for asset in assets: + target = os.path.join(self.tmp_app_path, os.path.basename(asset)) + if os.path.exists(target): + logger.debug("delete existing target %s", target) + shutil.rmtree(target) + logger.debug("copying %s => %s", asset, self.tmp_app_path) + shutil.copytree(asset, target) + logger.debug("copied with %s", os.listdir(target)) + + else: + logger.warning( + "JuliaRunner found no cwd in the Python call stack. " + "You may wish to specify an explicit working directory " + "using something like: " + "dashjl.run_server(app, cwd=os.path.dirname(__file__))" + ) + + logger.info("Run Dash.jl app with julia => %s", app) + + args = shlex.split( + "julia {}".format(os.path.realpath(app)), + posix=not self.is_windows, + ) + logger.debug("start Dash.jl process with %s", args) + + try: + self.proc = subprocess.Popen( + args, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=self.tmp_app_path if self.tmp_app_path else cwd, + ) + # wait until server is able to answer http request + wait.until(lambda: self.accessible(self.url), timeout=start_timeout) + + except (OSError, ValueError): + logger.exception("process server has encountered an error") + self.started = False + return + + self.started = True diff --git a/dash/testing/composite.py b/dash/testing/composite.py index c45d20f8a6..8bb05a6dda 100644 --- a/dash/testing/composite.py +++ b/dash/testing/composite.py @@ -29,3 +29,17 @@ def start_server(self, app, cwd=None): # set the default server_url, it implicitly call wait_for_page self.server_url = self.server.url + + +class DashJuliaComposite(Browser): + def __init__(self, server, **kwargs): + super(DashJuliaComposite, self).__init__(**kwargs) + self.server = server + + def start_server(self, app, cwd=None): + # start server with Dash.jl app. The app sets its own run_server args + # on the Julia side, but we support overriding the automatic cwd + self.server(app, cwd=cwd) + + # set the default server_url, it implicitly call wait_for_page + self.server_url = self.server.url diff --git a/dash/testing/plugin.py b/dash/testing/plugin.py index 3161df820e..60e774fcbc 100644 --- a/dash/testing/plugin.py +++ b/dash/testing/plugin.py @@ -4,9 +4,9 @@ try: - from dash.testing.application_runners import ThreadedRunner, ProcessRunner, RRunner + from dash.testing.application_runners import ThreadedRunner, ProcessRunner, RRunner, JuliaRunner from dash.testing.browser import Browser - from dash.testing.composite import DashComposite, DashRComposite + from dash.testing.composite import DashComposite, DashRComposite, DashJuliaComposite except ImportError: pass @@ -78,7 +78,7 @@ def pytest_runtest_makereport(item, call): # pylint: disable=unused-argument if rep.when == "call" and rep.failed and hasattr(item, "funcargs"): for name, fixture in item.funcargs.items(): try: - if name in {"dash_duo", "dash_br", "dashr"}: + if name in {"dash_duo", "dash_br", "dashr", "dashjl"}: fixture.take_snapshot(item.name) except Exception as e: # pylint: disable=broad-except print(e) @@ -109,6 +109,12 @@ def dashr_server(): yield starter +@pytest.fixture +def dashjl_server(): + with JuliaRunner() as starter: + yield starter + + @pytest.fixture def dash_br(request, tmpdir): with Browser( @@ -157,3 +163,20 @@ def dashr(request, dashr_server, tmpdir): pause=request.config.getoption("pause"), ) as dc: yield dc + + +@pytest.fixture +def dashjl(request, dashjl_server, tmpdir): + with DashJuliaComposite( + dashjl_server, + browser=request.config.getoption("webdriver"), + remote=request.config.getoption("remote"), + remote_url=request.config.getoption("remote_url"), + headless=request.config.getoption("headless"), + options=request.config.hook.pytest_setup_options(), + download_path=tmpdir.mkdir("download").strpath, + percy_assets_root=request.config.getoption("percy_assets"), + percy_finalize=request.config.getoption("nopercyfinalize"), + pause=request.config.getoption("pause"), + ) as dc: + yield dc From 3309c814d0137230cbb4f78cba749f6127a295c2 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Wed, 20 May 2020 10:26:43 -0400 Subject: [PATCH 38/56] robustify rddd001 --- tests/integration/renderer/test_due_diligence.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integration/renderer/test_due_diligence.py b/tests/integration/renderer/test_due_diligence.py index cb44d39fb0..3ec1ce40ba 100644 --- a/tests/integration/renderer/test_due_diligence.py +++ b/tests/integration/renderer/test_due_diligence.py @@ -54,6 +54,7 @@ def test_rddd001_initial_state(dash_duo): # fmt:on dash_duo.start_server(app) + dash_duo.wait_for_text_to_equal(r"#p\.c\.5", "") # Note: this .html file shows there's no undo/redo button by default with open( From 316b6628033e1acf2810f95640e64cf3babae913 Mon Sep 17 00:00:00 2001 From: Ryan Patrick Kyle Date: Fri, 22 May 2020 10:29:18 -0400 Subject: [PATCH 39/56] Add R build test to CircleCI (#1238) --- .circleci/config.yml | 111 ++++++++++++++++++- .pylintrc | 4 +- .pylintrc37 | 5 +- dash/development/_r_components_generation.py | 3 - 4 files changed, 114 insertions(+), 9 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 8a7120d4f5..39a2b141e4 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -29,6 +29,7 @@ jobs: docker: - image: circleci/python:3.7-stretch-node-browsers environment: + PYLINTRC: .pylintrc37 PYVERSION: python37 steps: @@ -81,7 +82,6 @@ jobs: docker: - image: circleci/python:3.7-stretch-node-browsers environment: - PYLINTRC: .pylintrc37 PYVERSION: python37 steps: - checkout @@ -213,6 +213,113 @@ jobs: paths: - packages/*.tar.gz + build-dashr: + working_directory: ~/dashr + docker: + - image: plotly/dashr:ci + environment: + PERCY_PARALLEL_TOTAL: -1 + PYVERSION: python37 + _R_CHECK_FORCE_SUGGESTS_: FALSE + + steps: + - checkout + + - run: + name: ️️🏭 clone and npm build core for R + command: | + python -m venv venv + . venv/bin/activate + git clone --depth 1 https://github.com/plotly/dash.git -b ${CIRCLE_BRANCH} dash-main + cd dash-main && pip install -e .[dev,testing] --progress-bar off && cd .. + git clone --depth 1 https://github.com/plotly/dashR.git -b dev dashR + git clone --depth 1 https://github.com/plotly/dash-html-components.git + git clone --depth 1 https://github.com/plotly/dash-core-components.git + git clone --depth 1 https://github.com/plotly/dash-table.git + shopt -s extglob + cd dash-html-components; npm ci && npm run build; rm -rf !(.|..|DESCRIPTION|LICENSE.txt|LICENSE|NAMESPACE|.Rbuildignore|R|man|inst|vignettes|build) + cd ../dash-core-components; npm ci && npm run build; rm -rf !(.|..|DESCRIPTION|LICENSE.txt|LICENSE|NAMESPACE|.Rbuildignore|R|man|inst|vignettes|build) + cd ../dash-table; npm ci && npm run build; rm -rf !(.|..|DESCRIPTION|LICENSE.txt|LICENSE|NAMESPACE|.Rbuildignore|R|man|inst|vignettes|build); cd .. + + - run: + name: 🔧fix up dash metadata + command: | + sudo Rscript -e 'dash_desc <- read.dcf("dashR/DESCRIPTION"); dt_version <- read.dcf("dash-table/DESCRIPTION")[,"Version"]; dcc_version <- read.dcf("dash-core-components/DESCRIPTION")[,"Version"]; dhc_version <- read.dcf("dash-html-components/DESCRIPTION")[,"Version"]; imports <- dash_desc[,"Imports"][[1]]; imports <- gsub("((?<=dashHtmlComponents )(\\\\(.*?\\\\)))", paste0("(= ", dhc_version, ")"), imports, perl = TRUE); imports <- gsub("((?<=dashCoreComponents )(\\\\(.*?\\\\)))", paste0("(= ", dcc_version, ")"), imports, perl = TRUE); imports <- gsub("((?<=dashTable )(\\\\(.*?\\\\)))", paste0("(= ", dt_version, ")"), imports, perl = TRUE); dash_desc[,"Imports"][[1]] <- imports; dhc_hash <- system("cd dash-html-components; git rev-parse HEAD | tr -d '\''\n'\''", intern=TRUE); dcc_hash <- system("cd dash-core-components; git rev-parse HEAD | tr -d '\''\n'\''", intern=TRUE); dt_hash <- system("cd dash-table; git rev-parse HEAD | tr -d '\''\n'\''", intern=TRUE); remotes <- dash_desc[,"Remotes"][[1]]; remotes <- gsub("((?<=plotly\\\\/dash-html-components@)([a-zA-Z0-9]+))", dhc_hash, remotes, perl=TRUE); remotes <- gsub("((?<=plotly\\\\/dash-core-components@)([a-zA-Z0-9]+))", dcc_hash, remotes, perl=TRUE); remotes <- gsub("((?<=plotly\\\\/dash-table@)([a-zA-Z0-9]+))", dt_hash, remotes, perl=TRUE); dash_desc[,"Remotes"][[1]] <- remotes; write.dcf(dash_desc, "dashR/DESCRIPTION")' + + - run: + name: 🎛 set environment variables + command: | + Rscript --vanilla \ + -e 'dash_dsc <- read.dcf("dashR/DESCRIPTION")' \ + -e 'cat(sprintf("export DASH_TARBALL=%s_%s.tar.gz\n", dash_dsc[,"Package"], dash_dsc[,"Version"]))' \ + -e 'cat(sprintf("export DASH_CHECK_DIR=%s.Rcheck\n", dash_dsc[,"Package"]))' \ + -e 'dhc_dsc <- read.dcf("dash-html-components/DESCRIPTION")' \ + -e 'cat(sprintf("export DHC_TARBALL=%s_%s.tar.gz\n", dhc_dsc[,"Package"], dhc_dsc[,"Version"]))' \ + -e 'cat(sprintf("export DHC_CHECK_DIR=%s.Rcheck\n", dhc_dsc[,"Package"]))' \ + -e 'dcc_dsc <- read.dcf("dash-core-components/DESCRIPTION")' \ + -e 'cat(sprintf("export DCC_TARBALL=%s_%s.tar.gz\n", dcc_dsc[,"Package"], dcc_dsc[,"Version"]))' \ + -e 'cat(sprintf("export DCC_CHECK_DIR=%s.Rcheck\n", dcc_dsc[,"Package"]))' \ + -e 'dt_dsc <- read.dcf("dash-table/DESCRIPTION")' \ + -e 'cat(sprintf("export DT_TARBALL=%s_%s.tar.gz\n", dt_dsc[,"Package"], dt_dsc[,"Version"]))' \ + -e 'cat(sprintf("export DT_CHECK_DIR=%s.Rcheck\n", dt_dsc[,"Package"]))' \ + >> ${BASH_ENV} + + - run: + name: ️️📋 run CRAN package checks + command: | + R CMD build dash-core-components + R CMD build dash-html-components + R CMD build dash-table + R CMD build dashR + sudo R CMD INSTALL dash-core-components + sudo R CMD INSTALL dash-html-components + sudo R CMD INSTALL dash-table + sudo R CMD INSTALL dashR + R CMD check "${DHC_TARBALL}" --as-cran --no-manual + R CMD check "${DCC_TARBALL}" --as-cran --no-manual + R CMD check "${DT_TARBALL}" --as-cran --no-manual + R CMD check "${DASH_TARBALL}" --as-cran --no-manual + + - run: + name: 🕵 detect failures + command: | + Rscript -e "message(devtools::check_failures(path = '${DHC_CHECK_DIR}'))" + Rscript -e "message(devtools::check_failures(path = '${DCC_CHECK_DIR}'))" + Rscript -e "message(devtools::check_failures(path = '${DT_CHECK_DIR}'))" + Rscript -e "message(devtools::check_failures(path = '${DASH_CHECK_DIR}'))" + # warnings are errors; enable for stricter checks once CRAN submission finished + # if grep -q -R "WARNING" "${DHC_CHECK_DIR}/00check.log"; then exit 1; fi + # if grep -q -R "WARNING" "${DCC_CHECK_DIR}/00check.log"; then exit 1; fi + # if grep -q -R "WARNING" "${DT_CHECK_DIR}/00check.log"; then exit 1; fi + # if grep -q -R "WARNING" "${DASH_CHECK_DIR}/00check.log"; then exit 1; fi + + - run: + name: 🔎 run unit tests + command: | + sudo Rscript -e 'res=devtools::test("dashR/tests/", reporter=default_reporter());df=as.data.frame(res);if(sum(df$failed) > 0 || any(df$error)) {q(status=1)}' + + - run: + name: ⚙️ Integration tests + command: | + python -m venv venv + . venv/bin/activate + cd dash-main/\@plotly/dash-generator-test-component-nested && npm ci && npm run build && sudo R CMD INSTALL . && cd ../../.. + cd dash-main/\@plotly/dash-generator-test-component-standard && npm ci && npm run build && sudo R CMD INSTALL . && cd ../../.. + export PATH=$PATH:/home/circleci/.local/bin/ + pytest --nopercyfinalize --junitxml=test-reports/dashr.xml dashR/tests/integration/dopsa/ + - store_artifacts: + path: test-reports + - store_test_results: + path: test-reports + - store_artifacts: + path: /tmp/dash_artifacts + + - run: + name: 🦔 percy finalize + command: npx percy finalize --all + when: on_fail + + test-37: &test working_directory: ~/dash docker: @@ -279,12 +386,14 @@ workflows: - build-core-37 - build-windows-37 - build-misc-37 + - build-dashr - test-37: requires: - build-core-37 - build-misc-37 - percy-finalize: requires: + - build-dashr - test-37 - artifacts: requires: diff --git a/.pylintrc b/.pylintrc index f81e253526..cb11ae92f9 100644 --- a/.pylintrc +++ b/.pylintrc @@ -270,7 +270,7 @@ ignore-docstrings=yes ignore-imports=no # Minimum lines number of a similarity. -min-similarity-lines=10 +min-similarity-lines=20 [SPELLING] @@ -466,4 +466,4 @@ known-third-party=enchant # Exceptions that will emit a warning when being caught. Defaults to # "Exception" -overgeneral-exceptions=Exception \ No newline at end of file +overgeneral-exceptions=Exception diff --git a/.pylintrc37 b/.pylintrc37 index 0e477b99a2..ea4f5abf4e 100644 --- a/.pylintrc37 +++ b/.pylintrc37 @@ -365,8 +365,7 @@ ignore-docstrings=yes ignore-imports=no # Minimum lines number of a similarity. -min-similarity-lines=10 - +min-similarity-lines=20 [SPELLING] @@ -565,4 +564,4 @@ known-third-party=enchant # Exceptions that will emit a warning when being caught. Defaults to # "Exception". -overgeneral-exceptions=Exception \ No newline at end of file +overgeneral-exceptions=Exception diff --git a/dash/development/_r_components_generation.py b/dash/development/_r_components_generation.py index 1e50f30141..7b86572a42 100644 --- a/dash/development/_r_components_generation.py +++ b/dash/development/_r_components_generation.py @@ -221,9 +221,6 @@ def generate_class_string(name, props, project_shortname, prefix): default_argtext += ", ".join("{}=NULL".format(p) for p in prop_keys) - if wildcards == ", ...": - default_argtext += ", ..." - # pylint: disable=C0301 default_paramtext += ", ".join( "{0}={0}".format(p) if p != "children" else "{}=children".format(p) From 1b9619d828c2596b4d7c02f5838b656a385bda12 Mon Sep 17 00:00:00 2001 From: Danny Sepler Date: Mon, 25 May 2020 15:00:37 -0400 Subject: [PATCH 40/56] Fill in missing steps in CONTRIBUTING.md --- CONTRIBUTING.md | 7 ++++++- package.json | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 23a05fc9b3..0098532543 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,10 +16,13 @@ $ pip install -e .[testing,dev] # in some shells you need \ to escape [] $ cd dash-renderer # build renderer bundles, this will build all bundles from source code # the only true source of npm version is defined in package.json +$ npm install $ npm run build # or `renderer build` # install dash-renderer for development $ pip install -e . # build and install components used in tests +$ cd .. # should be back in dash/ root directory +$ npm install $ npm run setup-tests # you should see both dash and dash-renderer are pointed to local source repos $ pip list | grep dash @@ -104,7 +107,7 @@ Note that we also start using [`black`](https://black.readthedocs.io/en/stable/) ## Tests -We started migrating to [pytest](https://docs.pytest.org/en/latest/) from `unittest` as our test automation framework. You will see more testing enhancements in the near future. +We started migrating to [pytest](https://docs.pytest.org/en/latest/) from `unittest` as our test automation framework. You will see more testing enhancements in the near future. To run the tests, see the commands in the `package.json` (such as `npm run test.integration`) ### Unit Tests @@ -116,6 +119,8 @@ Note: *You might find out that we have more integration tests than unit tests in We introduced the `dash.testing` feature in [Dash 1.0](https://community.plotly.com/t/announcing-dash-testing/24868). It makes writing a Dash integration test much easier. Please read the [tutorial](http://dash.plotly.com/testing) and add relevant integration tests with any new features or bug fixes. +To run the integration tests, you may have to [install circleci](https://circleci.com/docs/2.0/local-cli/). + ## Financial Contributions Dash, and many of Plotly's open source products, have been funded through direct sponsorship by companies. [Get in touch] about funding feature additions, consulting, or custom app development. diff --git a/package.json b/package.json index b3a650973f..8b9a0ad2b7 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "private::lint.renderer": "cd dash-renderer && npm run lint", "private::test.setup-nested": "cd \\@plotly/dash-generator-test-component-nested && npm ci && npm run build && pip install -e .", "private::test.setup-standard": "cd \\@plotly/dash-generator-test-component-standard && npm ci && npm run build && pip install -e .", - "private::test.unit-dash": "PYTHONPATH=~/dash/tests/assets pytest tests/unit", + "private::test.unit-dash": "PYTHONPATH=tests/assets pytest tests/unit", "private::test.unit-renderer": "cd dash-renderer && npm run test", "private::test.integration-dash": "TESTFILES=$(circleci tests glob \"tests/integration/**/test_*.py\" | circleci tests split --split-by=timings) && pytest --headless --nopercyfinalize --junitxml=test-reports/junit_intg.xml ${TESTFILES}", "format": "run-s private::format.*", From fdc11f4ee2213950b48419bc6f3e6e0f4afcfbd1 Mon Sep 17 00:00:00 2001 From: Danny Sepler Date: Sat, 30 May 2020 14:17:36 -0400 Subject: [PATCH 41/56] Debug in same module --- dash/dash.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/dash/dash.py b/dash/dash.py index 9da6233840..a6c7b7eabe 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -1338,14 +1338,19 @@ def enable_dev_tools( _reload = self._hot_reload _reload.hash = generate_hash() + # find_loader should return None on __main__ but doesn't + # on some python versions https://bugs.python.org/issue14710 + packages = [ + pkgutil.find_loader(x) + for x in list(ComponentRegistry.registry) + ["dash_renderer"] + if x not in ("__main__") + ] + component_packages_dist = [ os.path.dirname(package.path) if hasattr(package, "path") else package.filename - for package in ( - pkgutil.find_loader(x) - for x in list(ComponentRegistry.registry) + ["dash_renderer"] - ) + for package in packages ] _reload.watch_thread = threading.Thread( From 9d81ad10637001a0f60f496718c286aa3f7039c6 Mon Sep 17 00:00:00 2001 From: Danny Sepler Date: Sat, 30 May 2020 14:23:39 -0400 Subject: [PATCH 42/56] Changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5bd28ee4b..5a197a119f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - [#1249](https://github.com/plotly/dash/pull/1249) Fixes [#919](https://github.com/plotly/dash/issues/919) so `dash.testing` is compatible with more `pytest` plugins, particularly `pytest-flake8` and `pytest-black`. - [#1248](https://github.com/plotly/dash/pull/1248) Fixes [#1245](https://github.com/plotly/dash/issues/1245), so you can use prop persistence with components that have dict IDs, ie for pattern-matching callbacks. - [#1185](https://github.com/plotly/dash/pull/1185) Sort asset directories, same as we sort files inside those directories. This way if you need your assets loaded in a certain order, you can add prefixes to subdirectory names and enforce that order. +- [#1288](https://github.com/plotly/dash/pull/1288) Closes [#1285](https://github.com/plotly/dash/issues/1285): Debug=True should work in the __main__ module. ## [1.12.0] - 2020-05-05 ### Added From c0c8c7feb4920a24cb3555f326896ca463677549 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Wed, 20 May 2020 10:22:39 -0400 Subject: [PATCH 43/56] use DASH_PROXY env var to fix startup message and verify the proxy will see your app! --- dash/dash.py | 73 +++++++++++++++++++++++++++----------- dash/exceptions.py | 4 +++ tests/unit/test_configs.py | 60 +++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 20 deletions(-) diff --git a/dash/dash.py b/dash/dash.py index 9da6233840..c82f6620fc 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -1,8 +1,6 @@ from __future__ import print_function -import itertools import os -import random import sys import collections import importlib @@ -14,6 +12,7 @@ import mimetypes from functools import wraps +from future.moves.urllib.parse import urlparse import flask from flask_compress import Compress @@ -25,7 +24,7 @@ from .fingerprint import build_fingerprint, check_fingerprint from .resources import Scripts, Css from .development.base_component import ComponentRegistry -from .exceptions import PreventUpdate, InvalidResourceError +from .exceptions import PreventUpdate, InvalidResourceError, ProxyError from .version import __version__ from ._configs import get_combined_config, pathname_configs from ._utils import ( @@ -1332,7 +1331,8 @@ def enable_dev_tools( if dev_tools.silence_routes_logging: logging.getLogger("werkzeug").setLevel(logging.ERROR) - self.logger.setLevel(logging.INFO) + + self.logger.setLevel(logging.INFO) if dev_tools.hot_reload: _reload = self._hot_reload @@ -1444,6 +1444,7 @@ def run_server( self, host=os.getenv("HOST", "127.0.0.1"), port=os.getenv("PORT", "8050"), + proxy=os.getenv("DASH_PROXY", None), debug=False, dev_tools_ui=None, dev_tools_props_check=None, @@ -1470,6 +1471,14 @@ def run_server( env: ``PORT`` :type port: int + :param proxy: If this application will be served to a different URL + via a proxy configured outside of Python, you can list it here + as a string of the form ``"{input}::{output}"``, for example: + ``"http://0.0.0.0:8050::https://my.domain.com"`` + so that the startup message will display an accurate URL. + env: ``DASH_PROXY`` + :type proxy: string + :param debug: Set Flask debug mode and enable dev tools. env: ``DASH_DEBUG`` :type debug: bool @@ -1550,25 +1559,49 @@ def run_server( ] raise - if self._dev_tools.silence_routes_logging: - # Since it's silenced, the address doesn't show anymore. + # so we only see the "Running on" message once with hot reloading + # https://stackoverflow.com/a/57231282/9188800 + if os.getenv("WERKZEUG_RUN_MAIN") != "true": ssl_context = flask_run_options.get("ssl_context") - self.logger.info( - "Running on %s://%s:%s%s", - "https" if ssl_context else "http", - host, - port, - self.config.requests_pathname_prefix, - ) + protocol = "https" if ssl_context else "http" + path = self.config.requests_pathname_prefix + + if proxy: + served_url, proxied_url = map(urlparse, proxy.split("::")) + + def verify_url_part(served_part, url_part, part_name): + if served_part != url_part: + raise ProxyError( + """ + {0}: {1} is incompatible with the proxy: + {3} + To see your app at {4}, + you must use {0}: {2} + """.format( + part_name, + url_part, + served_part, + proxy, + proxied_url.geturl(), + ) + ) + + verify_url_part(served_url.scheme, protocol, "protocol") + verify_url_part(served_url.hostname, host, "host") + verify_url_part(served_url.port, port, "port") - # Generate a debugger pin and log it to the screen. - debugger_pin = os.environ["WERKZEUG_DEBUG_PIN"] = "-".join( - itertools.chain( - "".join([str(random.randint(0, 9)) for _ in range(3)]) - for _ in range(3) + display_url = ( + proxied_url.scheme, + proxied_url.hostname, + (":{}".format(proxied_url.port) if proxied_url.port else ""), + path, ) - ) + else: + display_url = (protocol, host, ":{}".format(port), path) + + self.logger.info("Running on {}://{}{}{}".format(*display_url)) - self.logger.info("Debugger PIN: %s", debugger_pin) + if not os.environ.get("FLASK_ENV"): + os.environ["FLASK_ENV"] = "development" self.server.run(host=host, port=port, debug=debug, **flask_run_options) diff --git a/dash/exceptions.py b/dash/exceptions.py index 54439735fc..8a08df4010 100644 --- a/dash/exceptions.py +++ b/dash/exceptions.py @@ -73,3 +73,7 @@ class MissingCallbackContextException(CallbackException): class UnsupportedRelativePath(CallbackException): pass + + +class ProxyError(DashException): + pass diff --git a/tests/unit/test_configs.py b/tests/unit/test_configs.py index 7fccbc1766..c933b7f88c 100644 --- a/tests/unit/test_configs.py +++ b/tests/unit/test_configs.py @@ -254,3 +254,63 @@ def test_port_env_fail_range(empty_environ): excinfo.exconly() == "AssertionError: Expecting an integer from 1 to 65535, found port=65536" ) + + +def test_no_proxy_success(mocker, caplog, empty_environ): + app = Dash() + + # mock out the run method so we don't actually start listening forever + mocker.patch.object(app.server, "run") + + app.run_server(port=8787) + + assert "Running on http://127.0.0.1:8787/\n" in caplog.text + + +@pytest.mark.parametrize( + "proxy, host, port, path", + [ + ("https://daash.plot.ly", "127.0.0.1", 8050, "/"), + ("https://daaash.plot.ly", "0.0.0.0", 8050, "/a/b/c/"), + ("https://daaaash.plot.ly", "127.0.0.1", 1234, "/"), + ("http://go.away", "127.0.0.1", 8050, "/now/"), + ("http://my.server.tv:8765", "0.0.0.0", 80, "/"), + ], +) +def test_proxy_success(mocker, caplog, empty_environ, proxy, host, port, path): + proxystr = "http://{}:{}::{}".format(host, port, proxy) + app = Dash(url_base_pathname=path) + mocker.patch.object(app.server, "run") + + app.run_server(proxy=proxystr, host=host, port=port) + + assert "Running on {}{}\n".format(proxy, path) in caplog.text + + +def test_proxy_failure(mocker, empty_environ): + app = Dash() + + # if the tests work we'll never get to server.run, but keep the mock + # in case something is amiss and we don't get an exception. + mocker.patch.object(app.server, "run") + + with pytest.raises(_exc.ProxyError) as excinfo: + app.run_server( + proxy="https://127.0.0.1:8055::http://plot.ly", host="127.0.0.1", port=8055 + ) + assert "protocol: http is incompatible with the proxy" in excinfo.exconly() + assert "you must use protocol: https" in excinfo.exconly() + + with pytest.raises(_exc.ProxyError) as excinfo: + app.run_server( + proxy="http://0.0.0.0:8055::http://plot.ly", host="127.0.0.1", port=8055 + ) + assert "host: 127.0.0.1 is incompatible with the proxy" in excinfo.exconly() + assert "you must use host: 0.0.0.0" in excinfo.exconly() + + with pytest.raises(_exc.ProxyError) as excinfo: + app.run_server( + proxy="http://0.0.0.0:8155::http://plot.ly", host="0.0.0.0", port=8055 + ) + assert "port: 8055 is incompatible with the proxy" in excinfo.exconly() + assert "you must use port: 8155" in excinfo.exconly() From 7814009dfa206670884b1edce97fb9571678cade Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Sat, 30 May 2020 23:41:20 -0400 Subject: [PATCH 44/56] changelog for DASH_PROXY and lint --- CHANGELOG.md | 1 + dash/dash.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5bd28ee4b..5dae4c1b2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## [UNRELEASED] ### Added +- [#1289](https://github.com/plotly/dash/pull/1289) Supports `DASH_PROXY` env var to tell `app.run_server` to report the correct URL to view your app, when it's being proxied. Throws an error if the proxy is incompatible with the host and port you've given the server. - [#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. ### Changed diff --git a/dash/dash.py b/dash/dash.py index c82f6620fc..def42d9831 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -1599,7 +1599,7 @@ def verify_url_part(served_part, url_part, part_name): else: display_url = (protocol, host, ":{}".format(port), path) - self.logger.info("Running on {}://{}{}{}".format(*display_url)) + self.logger.info("Running on %s://%s%s%s", *display_url) if not os.environ.get("FLASK_ENV"): os.environ["FLASK_ENV"] = "development" From c5e9e0cd6a17273719db7b158f4327346a1f4b8f Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Sun, 31 May 2020 00:00:56 -0400 Subject: [PATCH 45/56] dev server warning --- dash/dash.py | 4 +++- tests/unit/test_configs.py | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/dash/dash.py b/dash/dash.py index def42d9831..c325ecf81c 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -1599,7 +1599,9 @@ def verify_url_part(served_part, url_part, part_name): else: display_url = (protocol, host, ":{}".format(port), path) - self.logger.info("Running on %s://%s%s%s", *display_url) + self.logger.info("Dash is running on %s://%s%s%s\n", *display_url) + self.logger.info(" Warning: This is a development server. Do not use app.run_server") + self.logger.info(" in production, use a production WSGI server like gunicorn instead.\n") if not os.environ.get("FLASK_ENV"): os.environ["FLASK_ENV"] = "development" diff --git a/tests/unit/test_configs.py b/tests/unit/test_configs.py index c933b7f88c..9a7c8b5c01 100644 --- a/tests/unit/test_configs.py +++ b/tests/unit/test_configs.py @@ -264,7 +264,7 @@ def test_no_proxy_success(mocker, caplog, empty_environ): app.run_server(port=8787) - assert "Running on http://127.0.0.1:8787/\n" in caplog.text + assert "Dash is running on http://127.0.0.1:8787/\n" in caplog.text @pytest.mark.parametrize( @@ -284,7 +284,7 @@ def test_proxy_success(mocker, caplog, empty_environ, proxy, host, port, path): app.run_server(proxy=proxystr, host=host, port=port) - assert "Running on {}{}\n".format(proxy, path) in caplog.text + assert "Dash is running on {}{}\n".format(proxy, path) in caplog.text def test_proxy_failure(mocker, empty_environ): From 4fac5cee8fe3a15e35a3679c1e613f157b8904a0 Mon Sep 17 00:00:00 2001 From: Danny Sepler Date: Mon, 1 Jun 2020 01:10:44 -0400 Subject: [PATCH 46/56] "!=" instead of "not in" Co-authored-by: alexcjohnson --- dash/dash.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dash/dash.py b/dash/dash.py index a6c7b7eabe..3367e1d236 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -1343,7 +1343,7 @@ def enable_dev_tools( packages = [ pkgutil.find_loader(x) for x in list(ComponentRegistry.registry) + ["dash_renderer"] - if x not in ("__main__") + if x != "__main__" ] component_packages_dist = [ From a7e7957806c972e014b23a50bbfa2d9ad2dd4d5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Rivet?= Date: Tue, 2 Jun 2020 16:42:04 -0400 Subject: [PATCH 47/56] 3.6.9 | 3.7.6 (#1290) --- .circleci/config.yml | 24 +++++++++---------- dash/testing/application_runners.py | 14 ++++++----- dash/testing/plugin.py | 7 +++++- tests/integration/devtools/test_hot_reload.py | 8 +++---- 4 files changed, 29 insertions(+), 24 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 39a2b141e4..2802b5192a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -10,7 +10,7 @@ jobs: artifacts: docker: - - image: circleci/python:3.7-stretch-node-browsers + - image: circleci/python:3.7.6-stretch-node-browsers environment: PYVERSION: python37 steps: @@ -27,7 +27,7 @@ jobs: lint-unit-37: &lint-unit working_directory: ~/dash docker: - - image: circleci/python:3.7-stretch-node-browsers + - image: circleci/python:3.7.6-stretch-node-browsers environment: PYLINTRC: .pylintrc37 PYVERSION: python37 @@ -64,7 +64,7 @@ jobs: lint-unit-36: <<: *lint-unit docker: - - image: circleci/python:3.6-stretch-node-browsers + - image: circleci/python:3.6.9-stretch-node-browsers environment: PYLINTRC: .pylintrc PYVERSION: python36 @@ -80,7 +80,7 @@ jobs: build-core-37: &build-core working_directory: ~/dash docker: - - image: circleci/python:3.7-stretch-node-browsers + - image: circleci/python:3.7.6-stretch-node-browsers environment: PYVERSION: python37 steps: @@ -115,7 +115,7 @@ jobs: build-core-36: <<: *build-core docker: - - image: circleci/python:3.6-stretch-node-browsers + - image: circleci/python:3.6.9-stretch-node-browsers environment: PYVERSION: python36 @@ -129,7 +129,7 @@ jobs: build-misc-37: &build-misc working_directory: ~/dash docker: - - image: circleci/python:3.7-stretch-node-browsers + - image: circleci/python:3.7.6-stretch-node-browsers environment: PYVERSION: python37 @@ -165,7 +165,7 @@ jobs: build-misc-36: <<: *build-misc docker: - - image: circleci/python:3.6-stretch-node-browsers + - image: circleci/python:3.6.9-stretch-node-browsers environment: PYVERSION: python36 @@ -240,7 +240,7 @@ jobs: cd dash-html-components; npm ci && npm run build; rm -rf !(.|..|DESCRIPTION|LICENSE.txt|LICENSE|NAMESPACE|.Rbuildignore|R|man|inst|vignettes|build) cd ../dash-core-components; npm ci && npm run build; rm -rf !(.|..|DESCRIPTION|LICENSE.txt|LICENSE|NAMESPACE|.Rbuildignore|R|man|inst|vignettes|build) cd ../dash-table; npm ci && npm run build; rm -rf !(.|..|DESCRIPTION|LICENSE.txt|LICENSE|NAMESPACE|.Rbuildignore|R|man|inst|vignettes|build); cd .. - + - run: name: 🔧fix up dash metadata command: | @@ -286,7 +286,7 @@ jobs: Rscript -e "message(devtools::check_failures(path = '${DHC_CHECK_DIR}'))" Rscript -e "message(devtools::check_failures(path = '${DCC_CHECK_DIR}'))" Rscript -e "message(devtools::check_failures(path = '${DT_CHECK_DIR}'))" - Rscript -e "message(devtools::check_failures(path = '${DASH_CHECK_DIR}'))" + Rscript -e "message(devtools::check_failures(path = '${DASH_CHECK_DIR}'))" # warnings are errors; enable for stricter checks once CRAN submission finished # if grep -q -R "WARNING" "${DHC_CHECK_DIR}/00check.log"; then exit 1; fi # if grep -q -R "WARNING" "${DCC_CHECK_DIR}/00check.log"; then exit 1; fi @@ -317,13 +317,13 @@ jobs: - run: name: 🦔 percy finalize command: npx percy finalize --all - when: on_fail + when: on_fail test-37: &test working_directory: ~/dash docker: - - image: circleci/python:3.7-stretch-node-browsers + - image: circleci/python:3.7.6-stretch-node-browsers environment: PERCY_PARALLEL_TOTAL: -1 PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: True @@ -365,7 +365,7 @@ jobs: test-36: <<: *test docker: - - image: circleci/python:3.6-stretch-node-browsers + - image: circleci/python:3.6.9-stretch-node-browsers environment: PERCY_ENABLE: 0 PYVERSION: python36 diff --git a/dash/testing/application_runners.py b/dash/testing/application_runners.py index 75b1dc0bb3..d395792664 100644 --- a/dash/testing/application_runners.py +++ b/dash/testing/application_runners.py @@ -222,8 +222,7 @@ def stop(self): logger.info("proc.terminate with pid %s", self.proc.pid) self.proc.terminate() if self.tmp_app_path and os.path.exists(self.tmp_app_path): - logger.debug("removing temporary app path %s", - self.tmp_app_path) + logger.debug("removing temporary app path %s", self.tmp_app_path) shutil.rmtree(self.tmp_app_path) if utils.PY3: # pylint:disable=no-member @@ -343,7 +342,9 @@ def start(self, app, start_timeout=2, cwd=None): class JuliaRunner(ProcessRunner): def __init__(self, keep_open=False, stop_timeout=3): - super(JuliaRunner, self).__init__(keep_open=keep_open, stop_timeout=stop_timeout) + super(JuliaRunner, self).__init__( + keep_open=keep_open, stop_timeout=stop_timeout + ) self.proc = None # pylint: disable=arguments-differ @@ -385,7 +386,9 @@ def start(self, app, start_timeout=30, cwd=None): logger.warning("get cwd from inspect => %s", cwd) break if cwd: - logger.info("JuliaRunner inferred cwd from the Python call stack: %s", cwd) + logger.info( + "JuliaRunner inferred cwd from the Python call stack: %s", cwd + ) # try copying all valid sub folders (i.e. assets) in cwd to tmp # note that the R assets folder name can be any valid folder name @@ -415,8 +418,7 @@ def start(self, app, start_timeout=30, cwd=None): logger.info("Run Dash.jl app with julia => %s", app) args = shlex.split( - "julia {}".format(os.path.realpath(app)), - posix=not self.is_windows, + "julia {}".format(os.path.realpath(app)), posix=not self.is_windows, ) logger.debug("start Dash.jl process with %s", args) diff --git a/dash/testing/plugin.py b/dash/testing/plugin.py index 60e774fcbc..0f2a8d313b 100644 --- a/dash/testing/plugin.py +++ b/dash/testing/plugin.py @@ -4,7 +4,12 @@ try: - from dash.testing.application_runners import ThreadedRunner, ProcessRunner, RRunner, JuliaRunner + from dash.testing.application_runners import ( + ThreadedRunner, + ProcessRunner, + RRunner, + JuliaRunner, + ) from dash.testing.browser import Browser from dash.testing.composite import DashComposite, DashRComposite, DashJuliaComposite except ImportError: diff --git a/tests/integration/devtools/test_hot_reload.py b/tests/integration/devtools/test_hot_reload.py index 83669ba6c3..27047f7f5a 100644 --- a/tests/integration/devtools/test_hot_reload.py +++ b/tests/integration/devtools/test_hot_reload.py @@ -20,9 +20,7 @@ def replace_file(filename, new_content): - path = os.path.join( - os.path.dirname(__file__), "hr_assets", filename - ) + path = os.path.join(os.path.dirname(__file__), "hr_assets", filename) with open(path, "r+") as fp: sleep(1) # ensure a new mod time old_content = fp.read() @@ -92,7 +90,7 @@ def new_text(n): try: until( lambda: dash_duo.driver.execute_script("return window.cheese") == "gouda", - timeout=3 + timeout=3, ) finally: sleep(1) # ensure a new mod time @@ -101,7 +99,7 @@ def new_text(n): until( lambda: dash_duo.driver.execute_script("return window.cheese") == "roquefort", - timeout=3 + timeout=3, ) # we've done a hard reload so someVar is gone From c3b40464d08536e6e128cb41a2a715254395bcb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Rivet?= Date: Tue, 2 Jun 2020 17:18:45 -0400 Subject: [PATCH 48/56] CI on forked repo and other tweaks (#1280) --- .circleci/config.yml | 7 +++---- package.json | 13 +++++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 2802b5192a..1c314c36ef 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -230,8 +230,8 @@ jobs: command: | python -m venv venv . venv/bin/activate - git clone --depth 1 https://github.com/plotly/dash.git -b ${CIRCLE_BRANCH} dash-main - cd dash-main && pip install -e .[dev,testing] --progress-bar off && cd .. + npm ci + pip install --no-cache-dir --upgrade -e .[dev,testing] --progress-bar off git clone --depth 1 https://github.com/plotly/dashR.git -b dev dashR git clone --depth 1 https://github.com/plotly/dash-html-components.git git clone --depth 1 https://github.com/plotly/dash-core-components.git @@ -303,8 +303,7 @@ jobs: command: | python -m venv venv . venv/bin/activate - cd dash-main/\@plotly/dash-generator-test-component-nested && npm ci && npm run build && sudo R CMD INSTALL . && cd ../../.. - cd dash-main/\@plotly/dash-generator-test-component-standard && npm ci && npm run build && sudo R CMD INSTALL . && cd ../../.. + npm run setup-tests.R export PATH=$PATH:/home/circleci/.local/bin/ pytest --nopercyfinalize --junitxml=test-reports/dashr.xml dashR/tests/integration/dopsa/ - store_artifacts: diff --git a/package.json b/package.json index b3a650973f..97df5ce65b 100644 --- a/package.json +++ b/package.json @@ -10,16 +10,21 @@ "private::lint.pylint-dash": "PYLINTRC=${PYLINTRC:=.pylintrc37} && pylint dash setup.py --rcfile=$PYLINTRC", "private::lint.pylint-tests": "PYLINTRC=${PYLINTRC:=.pylintrc37} && pylint tests/unit tests/integration -d all --rcfile=$PYLINTRC", "private::lint.renderer": "cd dash-renderer && npm run lint", - "private::test.setup-nested": "cd \\@plotly/dash-generator-test-component-nested && npm ci && npm run build && pip install -e .", - "private::test.setup-standard": "cd \\@plotly/dash-generator-test-component-standard && npm ci && npm run build && pip install -e .", + "private::test.setup-nested": "cd \\@plotly/dash-generator-test-component-nested && npm ci && npm run build", + "private::test.setup-standard": "cd \\@plotly/dash-generator-test-component-standard && npm ci && npm run build", + "private::test.py.deploy-nested": "npm run private::test.setup-nested && cd \\@plotly/dash-generator-test-component-nested && pip install -e .", + "private::test.py.deploy-standard": "npm run private::test.setup-standard && cd \\@plotly/dash-generator-test-component-standard && pip install -e .", + "private::test.R.deploy-nested": "npm run private::test.setup-nested && cd \\@plotly/dash-generator-test-component-nested && sudo R CMD INSTALL .", + "private::test.R.deploy-standard": "npm run private::test.setup-standard && cd \\@plotly/dash-generator-test-component-standard && sudo R CMD INSTALL .", "private::test.unit-dash": "PYTHONPATH=~/dash/tests/assets pytest tests/unit", "private::test.unit-renderer": "cd dash-renderer && npm run test", "private::test.integration-dash": "TESTFILES=$(circleci tests glob \"tests/integration/**/test_*.py\" | circleci tests split --split-by=timings) && pytest --headless --nopercyfinalize --junitxml=test-reports/junit_intg.xml ${TESTFILES}", "format": "run-s private::format.*", "initialize": "run-s private::initialize.*", "lint": "run-s private::lint.*", - "setup-tests": "run-s private::test.setup-*", - "test.integration": "run-s setup-tests private::test.integration-*", + "setup-tests.py": "run-s private::test.py.deploy-*", + "setup-tests.R": "run-s private::test.R.deploy-*", + "test.integration": "run-s setup-tests.py private::test.integration-*", "test.unit": "run-s private::test.unit-**" }, "devDependencies": { From dc837bdd09b3bdd234d71857a1221c50b98dd936 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Wed, 3 Jun 2020 00:46:11 -0400 Subject: [PATCH 49/56] test dash_process_server without PYTHONPATH --- package.json | 2 +- tests/unit/test_app_runners.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index caa45c71fa..023964fa8f 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "private::test.py.deploy-standard": "npm run private::test.setup-standard && cd \\@plotly/dash-generator-test-component-standard && pip install -e .", "private::test.R.deploy-nested": "npm run private::test.setup-nested && cd \\@plotly/dash-generator-test-component-nested && sudo R CMD INSTALL .", "private::test.R.deploy-standard": "npm run private::test.setup-standard && cd \\@plotly/dash-generator-test-component-standard && sudo R CMD INSTALL .", - "private::test.unit-dash": "PYTHONPATH=tests/assets pytest tests/unit", + "private::test.unit-dash": "pytest tests/unit", "private::test.unit-renderer": "cd dash-renderer && npm run test", "private::test.integration-dash": "TESTFILES=$(circleci tests glob \"tests/integration/**/test_*.py\" | circleci tests split --split-by=timings) && pytest --headless --nopercyfinalize --junitxml=test-reports/junit_intg.xml ${TESTFILES}", "format": "run-s private::format.*", diff --git a/tests/unit/test_app_runners.py b/tests/unit/test_app_runners.py index 22bca7407a..3b8adc12aa 100644 --- a/tests/unit/test_app_runners.py +++ b/tests/unit/test_app_runners.py @@ -1,3 +1,4 @@ +import os import sys import requests import pytest @@ -25,6 +26,9 @@ def test_threaded_server_smoke(dash_thread_server): sys.version_info < (3,), reason="requires python3 for process testing" ) def test_process_server_smoke(dash_process_server): + this_dir = os.path.dirname(__file__) + assets_dir = os.path.abspath(os.path.join(this_dir, "..", "assets")) + os.chdir(assets_dir) dash_process_server("simple_app") r = requests.get(dash_process_server.url) assert r.status_code == 200, "the server is reachable" From a481b948067d3b34f8474924bebcf331c3d05020 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Wed, 3 Jun 2020 00:53:03 -0400 Subject: [PATCH 50/56] test.*->citest.* - and add simple local "test" script --- .circleci/config.yml | 4 ++-- package.json | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 1c314c36ef..6727bcd97d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -59,7 +59,7 @@ jobs: name: 🐍 Python Unit Tests & ☕ JS Unit Tests command: | . venv/bin/activate - npm run test.unit + npm run citest.unit lint-unit-36: <<: *lint-unit @@ -349,7 +349,7 @@ jobs: name: 🧪 Run Integration Tests command: | . venv/bin/activate - npm run test.integration + npm run citest.integration - store_artifacts: path: test-reports - store_test_results: diff --git a/package.json b/package.json index 023964fa8f..10a412002d 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,9 @@ "lint": "run-s private::lint.*", "setup-tests.py": "run-s private::test.py.deploy-*", "setup-tests.R": "run-s private::test.R.deploy-*", - "test.integration": "run-s setup-tests.py private::test.integration-*", - "test.unit": "run-s private::test.unit-**" + "citest.integration": "run-s setup-tests.py private::test.integration-*", + "citest.unit": "run-s private::test.unit-**", + "test": "pytest && cd dash-renderer && npm run test" }, "devDependencies": { "husky": "4.2.3" From c8652578702d39b6fb1dcacd4a2175f0f49da3bc Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Wed, 3 Jun 2020 01:11:41 -0400 Subject: [PATCH 51/56] dash_process_server test: back to original working dir after test --- tests/unit/test_app_runners.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/unit/test_app_runners.py b/tests/unit/test_app_runners.py index 3b8adc12aa..d0b27b563e 100644 --- a/tests/unit/test_app_runners.py +++ b/tests/unit/test_app_runners.py @@ -26,10 +26,14 @@ def test_threaded_server_smoke(dash_thread_server): sys.version_info < (3,), reason="requires python3 for process testing" ) def test_process_server_smoke(dash_process_server): + cwd = os.getcwd() this_dir = os.path.dirname(__file__) assets_dir = os.path.abspath(os.path.join(this_dir, "..", "assets")) - os.chdir(assets_dir) - dash_process_server("simple_app") - r = requests.get(dash_process_server.url) - assert r.status_code == 200, "the server is reachable" - assert 'id="react-entry-point"' in r.text, "the entrypoint is present" + try: + os.chdir(assets_dir) + dash_process_server("simple_app") + r = requests.get(dash_process_server.url) + assert r.status_code == 200, "the server is reachable" + assert 'id="react-entry-point"' in r.text, "the entrypoint is present" + finally: + os.chdir(cwd) From bce1a3751ad4236dfc93a83ab537c3004d28bb9a Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Wed, 3 Jun 2020 01:34:08 -0400 Subject: [PATCH 52/56] more CONTRIBUTING updates --- CONTRIBUTING.md | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0098532543..4370423efb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,9 +51,7 @@ When a change in renderer code doesn't reflect in your browser as expected, this Writing Python 2/3 compatible code might be a challenging task for contributors used to working on one particular version, especially new learners who start directly with Python 3. -From the #892, we started to adopt `python-future` instead of `six` as our tool to better achieve the goal where we can mainly write Python 3 code and make it back-compatible in Python 2.7 (last Python 2 version Dash supports before it gets deprecated). - -Please refer to [this list of idioms](https://python-future.org/compatible_idioms.html "https://python-future.org/compatible_idioms.html") for more details on working with `python-future`. +We use `python-future` as our tool to mainly write Python 3 code and make it back-compatible to Python 2.7 (the only Python 2 version Dash supports). Please refer to [this list of idioms](https://python-future.org/compatible_idioms.html "https://python-future.org/compatible_idioms.html") for more details on working with `python-future`. ## Git @@ -101,13 +99,15 @@ Emojis make the commit messages :cherry_blossom:. If you have no idea about what ### Coding Style -We use both `flake8` and `pylint` for basic linting check, please refer to the relevant steps in `.circleci/config.yml`. - -Note that we also start using [`black`](https://black.readthedocs.io/en/stable/) as formatter during the test code migration. +We use `flake8`, `pylint`, and [`black`](https://black.readthedocs.io/en/stable/) for linting. please refer to the relevant steps in `.circleci/config.yml`. ## Tests -We started migrating to [pytest](https://docs.pytest.org/en/latest/) from `unittest` as our test automation framework. You will see more testing enhancements in the near future. To run the tests, see the commands in the `package.json` (such as `npm run test.integration`) +Our tests use Google Chrome via Selenium. You will need to install [ChromeDriver](http://chromedriver.chromium.org/getting-started) matching the version of Chrome installed on your system. Here are some helpful tips for [Mac](https://www.kenst.com/2015/03/installing-chromedriver-on-mac-osx/) and [Windows](http://jonathansoma.com/lede/foundations-2018/classes/selenium/selenium-windows-install/). + +We use [pytest](https://docs.pytest.org/en/latest/) as our test automation framework, plus [jest](https://jestjs.io/) for a few renderer unit tests. You can `npm run test` to run them all, but this command simply runs `pytest` with no arguments, then `cd dash-renderer && npm run test` for the renderer unit tests. + +Most of the time, however, you will want to just run a few relevant tests and let CI run the whole suite. `pytest` lets you specify a directory or file to run tests from (eg `pytest tests/unit`) or a part of the test case name using `-k` - for example `pytest -k cbcx004` will run a single test, or `pytest -k cbcx` will run that whole file. See the [testing tutorial](https://dash.plotly.com/testing) to learn about the test case ID convention we use. ### Unit Tests @@ -119,8 +119,6 @@ Note: *You might find out that we have more integration tests than unit tests in We introduced the `dash.testing` feature in [Dash 1.0](https://community.plotly.com/t/announcing-dash-testing/24868). It makes writing a Dash integration test much easier. Please read the [tutorial](http://dash.plotly.com/testing) and add relevant integration tests with any new features or bug fixes. -To run the integration tests, you may have to [install circleci](https://circleci.com/docs/2.0/local-cli/). - ## Financial Contributions Dash, and many of Plotly's open source products, have been funded through direct sponsorship by companies. [Get in touch] about funding feature additions, consulting, or custom app development. From 8212782a333c749fa9f2000b92cfbe2a681512e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Rivet?= Date: Mon, 15 Jun 2020 17:33:41 -0400 Subject: [PATCH 53/56] Callback chain refactoring and performance improvements (#1254) --- .circleci/config.yml | 8 +- CHANGELOG.md | 1 + dash-renderer/@Types/modules.d.ts | 9 + dash-renderer/babel.config.js | 5 + dash-renderer/jest.config.js | 2 +- dash-renderer/package-lock.json | 533 ++++++++++++++ dash-renderer/package.json | 15 +- dash-renderer/src/APIController.react.js | 58 +- ...rovider.react.js => AppProvider.react.tsx} | 15 +- dash-renderer/src/StoreObserver.ts | 113 +++ dash-renderer/src/TreeContainer.js | 241 +++---- dash-renderer/src/actions/callbacks.ts | 422 +++++++++++ dash-renderer/src/actions/dependencies.js | 400 +---------- dash-renderer/src/actions/dependencies_ts.ts | 333 +++++++++ dash-renderer/src/actions/index.js | 665 +----------------- dash-renderer/src/actions/isLoading.ts | 5 + dash-renderer/src/actions/loadingMap.ts | 5 + dash-renderer/src/checkPropTypes.js | 2 +- .../components/core/DocumentTitle.react.js | 6 +- .../src/components/core/Loading.react.js | 6 +- .../error/ComponentErrorBoundary.react.js | 15 +- .../src/observers/executedCallbacks.ts | 237 +++++++ .../src/observers/executingCallbacks.ts | 63 ++ dash-renderer/src/observers/isLoading.ts | 28 + dash-renderer/src/observers/loadingMap.ts | 83 +++ .../src/observers/prioritizedCallbacks.ts | 143 ++++ .../src/observers/requestedCallbacks.ts | 348 +++++++++ .../src/observers/storedCallbacks.ts | 69 ++ dash-renderer/src/reducers/callbacks.ts | 154 ++++ dash-renderer/src/reducers/isLoading.ts | 22 + dash-renderer/src/reducers/loadingMap.ts | 22 + .../src/reducers/pendingCallbacks.js | 11 - dash-renderer/src/reducers/reducer.js | 30 +- dash-renderer/src/store.js | 52 -- dash-renderer/src/store.ts | 96 +++ dash-renderer/src/types/callbacks.ts | 85 +++ dash-renderer/src/utils/TreeContainer.ts | 78 ++ dash-renderer/src/utils/callbacks.ts | 8 + dash-renderer/tsconfig.json | 25 + dash-renderer/tslint.json | 57 ++ dash-renderer/webpack.config.js | 8 + dash/dash.py | 8 +- dash/testing/dash_page.py | 36 +- .../callbacks/test_basic_callback.py | 4 +- .../callbacks/test_callback_context.py | 4 +- .../test_layout_paths_with_callbacks.py | 2 +- .../callbacks/test_missing_inputs.py | 2 +- .../callbacks/test_multiple_callbacks.py | 2 +- tests/integration/callbacks/test_wildcards.py | 62 ++ .../integration/renderer/test_dependencies.py | 2 +- .../renderer/test_due_diligence.py | 2 +- tests/integration/test_render.py | 9 +- 52 files changed, 3290 insertions(+), 1321 deletions(-) create mode 100644 dash-renderer/@Types/modules.d.ts rename dash-renderer/src/{AppProvider.react.js => AppProvider.react.tsx} (82%) create mode 100644 dash-renderer/src/StoreObserver.ts create mode 100644 dash-renderer/src/actions/callbacks.ts create mode 100644 dash-renderer/src/actions/dependencies_ts.ts create mode 100644 dash-renderer/src/actions/isLoading.ts create mode 100644 dash-renderer/src/actions/loadingMap.ts create mode 100644 dash-renderer/src/observers/executedCallbacks.ts create mode 100644 dash-renderer/src/observers/executingCallbacks.ts create mode 100644 dash-renderer/src/observers/isLoading.ts create mode 100644 dash-renderer/src/observers/loadingMap.ts create mode 100644 dash-renderer/src/observers/prioritizedCallbacks.ts create mode 100644 dash-renderer/src/observers/requestedCallbacks.ts create mode 100644 dash-renderer/src/observers/storedCallbacks.ts create mode 100644 dash-renderer/src/reducers/callbacks.ts create mode 100644 dash-renderer/src/reducers/isLoading.ts create mode 100644 dash-renderer/src/reducers/loadingMap.ts delete mode 100644 dash-renderer/src/reducers/pendingCallbacks.js delete mode 100644 dash-renderer/src/store.js create mode 100644 dash-renderer/src/store.ts create mode 100644 dash-renderer/src/types/callbacks.ts create mode 100644 dash-renderer/src/utils/TreeContainer.ts create mode 100644 dash-renderer/src/utils/callbacks.ts create mode 100644 dash-renderer/tsconfig.json create mode 100644 dash-renderer/tslint.json diff --git a/.circleci/config.yml b/.circleci/config.yml index 6727bcd97d..ce118f8ebd 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -72,7 +72,7 @@ jobs: lint-unit-27: <<: *lint-unit docker: - - image: circleci/python:2.7-stretch-node-browsers + - image: circleci/python:2.7.18-stretch-node-browsers environment: PYLINTRC: .pylintrc PYVERSION: python27 @@ -122,7 +122,7 @@ jobs: build-core-27: <<: *build-core docker: - - image: circleci/python:2.7-stretch-node-browsers + - image: circleci/python:2.7.18-stretch-node-browsers environment: PYVERSION: python27 @@ -172,7 +172,7 @@ jobs: build-misc-27: <<: *build-misc docker: - - image: circleci/python:2.7-stretch-node-browsers + - image: circleci/python:2.7.18-stretch-node-browsers environment: PYVERSION: python27 @@ -372,7 +372,7 @@ jobs: test-27: <<: *test docker: - - image: circleci/python:2.7-stretch-node-browsers + - image: circleci/python:2.7.18-stretch-node-browsers environment: PERCY_ENABLE: 0 PYVERSION: python27 diff --git a/CHANGELOG.md b/CHANGELOG.md index fe567b9c32..4312e8ca04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Changed - [#1237](https://github.com/plotly/dash/pull/1237) Closes [#920](https://github.com/plotly/dash/issues/920): Converts hot reload fetch failures into a server status indicator showing whether the latest fetch succeeded or failed. Callback fetch failures still appear as errors but have a clearer message. +- [#1254](https://github.com/plotly/dash/pull/1254) Modifies the callback chain implementation and improves performance for apps with a lot of components ### Fixed - [#1255](https://github.com/plotly/dash/pull/1255) Hard hot reload targets only the current window, not the top - so if your app is in an iframe you will only reload the app diff --git a/dash-renderer/@Types/modules.d.ts b/dash-renderer/@Types/modules.d.ts new file mode 100644 index 0000000000..042d739872 --- /dev/null +++ b/dash-renderer/@Types/modules.d.ts @@ -0,0 +1,9 @@ +declare module 'cookie' { + const value: { + parse: (cookie: string) => { + _csrf_token: string + } + }; + + export default value; +} diff --git a/dash-renderer/babel.config.js b/dash-renderer/babel.config.js index aee2bac0c7..455e1966b4 100644 --- a/dash-renderer/babel.config.js +++ b/dash-renderer/babel.config.js @@ -1,11 +1,16 @@ module.exports = { presets: [ + '@babel/preset-typescript', '@babel/preset-env', '@babel/preset-react' ], + plugins: [ + '@babel/plugin-proposal-class-properties', + ], env: { test: { plugins: [ + '@babel/plugin-proposal-class-properties', '@babel/plugin-transform-modules-commonjs' ] } diff --git a/dash-renderer/jest.config.js b/dash-renderer/jest.config.js index bfe097b036..4e05b43997 100644 --- a/dash-renderer/jest.config.js +++ b/dash-renderer/jest.config.js @@ -85,7 +85,7 @@ module.exports = { // notifyMode: "always", // A preset that is used as a base for Jest's configuration - // preset: null, + preset: "ts-jest/presets/js-with-babel", // Run tests from one or more projects // projects: null, diff --git a/dash-renderer/package-lock.json b/dash-renderer/package-lock.json index 1c452c4a1d..58209deaf0 100644 --- a/dash-renderer/package-lock.json +++ b/dash-renderer/package-lock.json @@ -398,6 +398,164 @@ "semver": "^5.5.0" } }, + "@babel/helper-create-class-features-plugin": { + "version": "7.10.2", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.10.2.tgz", + "integrity": "sha512-5C/QhkGFh1vqcziq1vAL6SI9ymzUp8BCYjFpvYVhWP4DlATIb3u5q3iUd35mvlyGs8fO7hckkW7i0tmH+5+bvQ==", + "dev": true, + "requires": { + "@babel/helper-function-name": "^7.10.1", + "@babel/helper-member-expression-to-functions": "^7.10.1", + "@babel/helper-optimise-call-expression": "^7.10.1", + "@babel/helper-plugin-utils": "^7.10.1", + "@babel/helper-replace-supers": "^7.10.1", + "@babel/helper-split-export-declaration": "^7.10.1" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.1.tgz", + "integrity": "sha512-IGhtTmpjGbYzcEDOw7DcQtbQSXcG9ftmAXtWTu9V936vDye4xjjekktFAtgZsWpzTj/X01jocB46mTywm/4SZw==", + "dev": true, + "requires": { + "@babel/highlight": "^7.10.1" + } + }, + "@babel/generator": { + "version": "7.10.2", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.10.2.tgz", + "integrity": "sha512-AxfBNHNu99DTMvlUPlt1h2+Hn7knPpH5ayJ8OqDWSeLld+Fi2AYBTC/IejWDM9Edcii4UzZRCsbUt0WlSDsDsA==", + "dev": true, + "requires": { + "@babel/types": "^7.10.2", + "jsesc": "^2.5.1", + "lodash": "^4.17.13", + "source-map": "^0.5.0" + } + }, + "@babel/helper-function-name": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.10.1.tgz", + "integrity": "sha512-fcpumwhs3YyZ/ttd5Rz0xn0TpIwVkN7X0V38B9TWNfVF42KEkhkAAuPCQ3oXmtTRtiPJrmZ0TrfS0GKF0eMaRQ==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.10.1", + "@babel/template": "^7.10.1", + "@babel/types": "^7.10.1" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.10.1.tgz", + "integrity": "sha512-F5qdXkYGOQUb0hpRaPoetF9AnsXknKjWMZ+wmsIRsp5ge5sFh4c3h1eH2pRTTuy9KKAA2+TTYomGXAtEL2fQEw==", + "dev": true, + "requires": { + "@babel/types": "^7.10.1" + } + }, + "@babel/helper-member-expression-to-functions": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.10.1.tgz", + "integrity": "sha512-u7XLXeM2n50gb6PWJ9hoO5oO7JFPaZtrh35t8RqKLT1jFKj9IWeD1zrcrYp1q1qiZTdEarfDWfTIP8nGsu0h5g==", + "dev": true, + "requires": { + "@babel/types": "^7.10.1" + } + }, + "@babel/helper-optimise-call-expression": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.10.1.tgz", + "integrity": "sha512-a0DjNS1prnBsoKx83dP2falChcs7p3i8VMzdrSbfLhuQra/2ENC4sbri34dz/rWmDADsmF1q5GbfaXydh0Jbjg==", + "dev": true, + "requires": { + "@babel/types": "^7.10.1" + } + }, + "@babel/helper-plugin-utils": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.1.tgz", + "integrity": "sha512-fvoGeXt0bJc7VMWZGCAEBEMo/HAjW2mP8apF5eXK0wSqwLAVHAISCWRoLMBMUs2kqeaG77jltVqu4Hn8Egl3nA==", + "dev": true + }, + "@babel/helper-replace-supers": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.10.1.tgz", + "integrity": "sha512-SOwJzEfpuQwInzzQJGjGaiG578UYmyi2Xw668klPWV5n07B73S0a9btjLk/52Mlcxa+5AdIYqws1KyXRfMoB7A==", + "dev": true, + "requires": { + "@babel/helper-member-expression-to-functions": "^7.10.1", + "@babel/helper-optimise-call-expression": "^7.10.1", + "@babel/traverse": "^7.10.1", + "@babel/types": "^7.10.1" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.10.1.tgz", + "integrity": "sha512-UQ1LVBPrYdbchNhLwj6fetj46BcFwfS4NllJo/1aJsT+1dLTEnXJL0qHqtY7gPzF8S2fXBJamf1biAXV3X077g==", + "dev": true, + "requires": { + "@babel/types": "^7.10.1" + } + }, + "@babel/highlight": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.10.1.tgz", + "integrity": "sha512-8rMof+gVP8mxYZApLF/JgNDAkdKa+aJt3ZYxF8z6+j/hpeXL7iMsKCPHa2jNMHu/qqBwzQF4OHNoYi8dMA/rYg==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.10.1", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + } + }, + "@babel/parser": { + "version": "7.10.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.10.2.tgz", + "integrity": "sha512-PApSXlNMJyB4JiGVhCOlzKIif+TKFTvu0aQAhnTvfP/z3vVSN6ZypH5bfUNwFXXjRQtUEBNFd2PtmCmG2Py3qQ==", + "dev": true + }, + "@babel/template": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.10.1.tgz", + "integrity": "sha512-OQDg6SqvFSsc9A0ej6SKINWrpJiNonRIniYondK2ViKhB06i3c0s+76XUft71iqBEe9S1OKsHwPAjfHnuvnCig==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.10.1", + "@babel/parser": "^7.10.1", + "@babel/types": "^7.10.1" + } + }, + "@babel/traverse": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.10.1.tgz", + "integrity": "sha512-C/cTuXeKt85K+p08jN6vMDz8vSV0vZcI0wmQ36o6mjbuo++kPMdpOYw23W2XH04dbRt9/nMEfA4W3eR21CD+TQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.10.1", + "@babel/generator": "^7.10.1", + "@babel/helper-function-name": "^7.10.1", + "@babel/helper-split-export-declaration": "^7.10.1", + "@babel/parser": "^7.10.1", + "@babel/types": "^7.10.1", + "debug": "^4.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.13" + } + }, + "@babel/types": { + "version": "7.10.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.10.2.tgz", + "integrity": "sha512-AD3AwWBSz0AWF0AkCN9VPiWrvldXq+/e3cHa4J89vo4ymjz1XwrBFFVZmkJTsQIPNk+ZVomPSXUJqq8yyjZsng==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.10.1", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + } + } + }, "@babel/helper-create-regexp-features-plugin": { "version": "7.8.6", "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.8.6.tgz", @@ -1115,6 +1273,12 @@ "@babel/types": "^7.7.0" } }, + "@babel/helper-validator-identifier": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.1.tgz", + "integrity": "sha512-5vW/JXLALhczRCWP0PnFDMCJAchlBvM7f4uk/jXritBnIa6E1KmqmtrS3yn1LAnxFBypQ3eneLuXjsnfQsgILw==", + "dev": true + }, "@babel/helper-wrap-function": { "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.8.3.tgz", @@ -1382,6 +1546,24 @@ "@babel/plugin-syntax-async-generators": "^7.8.0" } }, + "@babel/plugin-proposal-class-properties": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.10.1.tgz", + "integrity": "sha512-sqdGWgoXlnOdgMXU+9MbhzwFRgxVLeiGBqTrnuS7LC2IBU31wSsESbTUreT2O418obpfPdGUR2GbEufZF1bpqw==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.10.1", + "@babel/helper-plugin-utils": "^7.10.1" + }, + "dependencies": { + "@babel/helper-plugin-utils": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.1.tgz", + "integrity": "sha512-fvoGeXt0bJc7VMWZGCAEBEMo/HAjW2mP8apF5eXK0wSqwLAVHAISCWRoLMBMUs2kqeaG77jltVqu4Hn8Egl3nA==", + "dev": true + } + } + }, "@babel/plugin-proposal-dynamic-import": { "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.8.3.tgz", @@ -1550,6 +1732,23 @@ "@babel/helper-plugin-utils": "^7.8.3" } }, + "@babel/plugin-syntax-typescript": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.10.1.tgz", + "integrity": "sha512-X/d8glkrAtra7CaQGMiGs/OGa6XgUzqPcBXCIGFCpCqnfGlT0Wfbzo/B89xHhnInTaItPK8LALblVXcUOEh95Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.1" + }, + "dependencies": { + "@babel/helper-plugin-utils": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.1.tgz", + "integrity": "sha512-fvoGeXt0bJc7VMWZGCAEBEMo/HAjW2mP8apF5eXK0wSqwLAVHAISCWRoLMBMUs2kqeaG77jltVqu4Hn8Egl3nA==", + "dev": true + } + } + }, "@babel/plugin-transform-arrow-functions": { "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.8.3.tgz", @@ -2108,6 +2307,25 @@ "@babel/helper-plugin-utils": "^7.8.3" } }, + "@babel/plugin-transform-typescript": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.10.1.tgz", + "integrity": "sha512-v+QWKlmCnsaimLeqq9vyCsVRMViZG1k2SZTlcZvB+TqyH570Zsij8nvVUZzOASCRiQFUxkLrn9Wg/kH0zgy5OQ==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.10.1", + "@babel/helper-plugin-utils": "^7.10.1", + "@babel/plugin-syntax-typescript": "^7.10.1" + }, + "dependencies": { + "@babel/helper-plugin-utils": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.1.tgz", + "integrity": "sha512-fvoGeXt0bJc7VMWZGCAEBEMo/HAjW2mP8apF5eXK0wSqwLAVHAISCWRoLMBMUs2kqeaG77jltVqu4Hn8Egl3nA==", + "dev": true + } + } + }, "@babel/plugin-transform-unicode-regex": { "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.8.3.tgz", @@ -2238,6 +2456,24 @@ } } }, + "@babel/preset-typescript": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.10.1.tgz", + "integrity": "sha512-m6GV3y1ShiqxnyQj10600ZVOFrSSAa8HQ3qIUk2r+gcGtHTIRw0dJnFLt1WNXpKjtVw7yw1DAPU/6ma2ZvgJuA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.1", + "@babel/plugin-transform-typescript": "^7.10.1" + }, + "dependencies": { + "@babel/helper-plugin-utils": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.1.tgz", + "integrity": "sha512-fvoGeXt0bJc7VMWZGCAEBEMo/HAjW2mP8apF5eXK0wSqwLAVHAISCWRoLMBMUs2kqeaG77jltVqu4Hn8Egl3nA==", + "dev": true + } + } + }, "@babel/runtime": { "version": "7.7.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.7.2.tgz", @@ -3246,6 +3482,16 @@ "@types/node": "*" } }, + "@types/hoist-non-react-statics": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", + "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", + "dev": true, + "requires": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, "@types/istanbul-lib-coverage": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz", @@ -3301,12 +3547,64 @@ "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==", "dev": true }, + "@types/prop-types": { + "version": "15.7.3", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz", + "integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==", + "dev": true + }, "@types/q": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/@types/q/-/q-1.5.2.tgz", "integrity": "sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw==", "dev": true }, + "@types/ramda": { + "version": "0.27.6", + "resolved": "https://registry.npmjs.org/@types/ramda/-/ramda-0.27.6.tgz", + "integrity": "sha512-ephagb0ZIAJSoS5I/qMS4Mqo1b/Nd50pWM+o1QO/dz8NF//GsCGPTLDVRqgXlVncy74KShfHzE5rPZXTeek4PA==", + "dev": true, + "requires": { + "ts-toolbelt": "^6.3.3" + } + }, + "@types/react": { + "version": "16.9.34", + "resolved": "https://registry.npmjs.org/@types/react/-/react-16.9.34.tgz", + "integrity": "sha512-8AJlYMOfPe1KGLKyHpflCg5z46n0b5DbRfqDksxBLBTUpB75ypDBAO9eCUcjNwE6LCUslwTz00yyG/X9gaVtow==", + "dev": true, + "requires": { + "@types/prop-types": "*", + "csstype": "^2.2.0" + } + }, + "@types/react-redux": { + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.7.tgz", + "integrity": "sha512-U+WrzeFfI83+evZE2dkZ/oF/1vjIYgqrb5dGgedkqVV8HEfDFujNgWCwHL89TDuWKb47U0nTBT6PLGq4IIogWg==", + "dev": true, + "requires": { + "@types/hoist-non-react-statics": "^3.3.0", + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0", + "redux": "^4.0.0" + } + }, + "@types/redux": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@types/redux/-/redux-3.6.0.tgz", + "integrity": "sha1-8evh5UEVGAcuT9/KXHbhbnTBOZo=", + "dev": true, + "requires": { + "redux": "*" + } + }, + "@types/redux-actions": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@types/redux-actions/-/redux-actions-2.6.1.tgz", + "integrity": "sha512-zKgK+ATp3sswXs6sOYo1tk8xdXTy4CTaeeYrVQlClCjeOpag5vzPo0ASWiiBJ7vsiQRAdb3VkuFLnDoBimF67g==", + "dev": true + }, "@types/stack-utils": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-1.0.1.tgz", @@ -4594,6 +4892,15 @@ } } }, + "bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "requires": { + "fast-json-stable-stringify": "2.x" + } + }, "bser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", @@ -4632,6 +4939,12 @@ "integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=", "dev": true }, + "builtin-modules": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz", + "integrity": "sha1-Jw8HbFpywC9bZaR9+Uxf46J4iS8=", + "dev": true + }, "builtin-status-codes": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz", @@ -5599,6 +5912,12 @@ } } }, + "csstype": { + "version": "2.6.10", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.10.tgz", + "integrity": "sha512-D34BqZU4cIlMCY93rZHbrq9pjTAQJ3U8S8rfBqjwHxkGPThWFjzZDQpgMJY0QViLxth6ZKYiwFBo14RdN44U/w==", + "dev": true + }, "currently-unhandled": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz", @@ -5849,6 +6168,12 @@ "integrity": "sha512-ZIzRpLJrOj7jjP2miAtgqIfmzbxa4ZOr5jJc601zklsfEx9oTzmmj2nVpIPRpNlRTIh8lc1kyViIY7BWSGNmKw==", "dev": true }, + "diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true + }, "diff-sequences": { "version": "25.1.0", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-25.1.0.tgz", @@ -12058,6 +12383,12 @@ "semver": "^5.6.0" } }, + "make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, "make-plural": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/make-plural/-/make-plural-4.3.0.tgz", @@ -18315,18 +18646,220 @@ "integrity": "sha512-tdzBRDGWcI1OpPVmChbdSKhvSVurznZ8X36AYURAcl+0o2ldlCY2XPzyXNNxwJwwyIU+rIglTCG4kxtNKBQH7Q==", "dev": true }, + "ts-jest": { + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-26.0.0.tgz", + "integrity": "sha512-eBpWH65mGgzobuw7UZy+uPP9lwu+tPp60o324ASRX4Ijg8UC5dl2zcge4kkmqr2Zeuk9FwIjvCTOPuNMEyGWWw==", + "dev": true, + "requires": { + "bs-logger": "0.x", + "buffer-from": "1.x", + "fast-json-stable-stringify": "2.x", + "json5": "2.x", + "lodash.memoize": "4.x", + "make-error": "1.x", + "micromatch": "4.x", + "mkdirp": "1.x", + "semver": "7.x", + "yargs-parser": "18.x" + }, + "dependencies": { + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "requires": { + "fill-range": "^7.0.1" + } + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, + "micromatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz", + "integrity": "sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==", + "dev": true, + "requires": { + "braces": "^3.0.1", + "picomatch": "^2.0.5" + } + }, + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true + }, + "semver": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz", + "integrity": "sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==", + "dev": true + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + }, + "yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + } + } + }, + "ts-loader": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-7.0.2.tgz", + "integrity": "sha512-DwpZFB67RoILQHx42dMjSgv2STpacsQu5X+GD/H9ocd8IhU0m8p3b/ZrIln2KmcucC6xep2PdEMEblpWT71euA==", + "dev": true, + "requires": { + "chalk": "^2.3.0", + "enhanced-resolve": "^4.0.0", + "loader-utils": "^1.0.2", + "micromatch": "^4.0.0", + "semver": "^6.0.0" + }, + "dependencies": { + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "requires": { + "fill-range": "^7.0.1" + } + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, + "micromatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz", + "integrity": "sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==", + "dev": true, + "requires": { + "braces": "^3.0.1", + "picomatch": "^2.0.5" + } + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + } + } + }, + "ts-toolbelt": { + "version": "6.9.4", + "resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-6.9.4.tgz", + "integrity": "sha512-muRZZqfOTOVvLk5cdnp7YWm6xX+kD/WL2cS/L4zximBRcbQSuMoTbQQ2ZZBVMs1gB0EZw1qThP+HrIQB35OmEw==", + "dev": true + }, "tslib": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.10.0.tgz", "integrity": "sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==", "dev": true }, + "tslint": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/tslint/-/tslint-6.1.2.tgz", + "integrity": "sha512-UyNrLdK3E0fQG/xWNqAFAC5ugtFyPO4JJR1KyyfQAyzR8W0fTRrC91A8Wej4BntFzcvETdCSDa/4PnNYJQLYiA==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "builtin-modules": "^1.1.1", + "chalk": "^2.3.0", + "commander": "^2.12.1", + "diff": "^4.0.1", + "glob": "^7.1.1", + "js-yaml": "^3.13.1", + "minimatch": "^3.0.4", + "mkdirp": "^0.5.3", + "resolve": "^1.3.2", + "semver": "^5.3.0", + "tslib": "^1.10.0", + "tsutils": "^2.29.0" + }, + "dependencies": { + "minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "dev": true + }, + "mkdirp": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", + "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "dev": true, + "requires": { + "minimist": "^1.2.5" + } + } + } + }, "tsscmp": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", "dev": true }, + "tsutils": { + "version": "2.29.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-2.29.0.tgz", + "integrity": "sha512-g5JVHCIJwzfISaXpXE1qvNalca5Jwob6FjI4AoPlqMusJ6ftFE7IkkFoMhVLRgK+4Kx3gkzb8UZK5t5yTTvEmA==", + "dev": true, + "requires": { + "tslib": "^1.8.1" + } + }, "tty-browserify": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz", diff --git a/dash-renderer/package.json b/dash-renderer/package.json index 3b383ef7f6..a2c1b48606 100644 --- a/dash-renderer/package.json +++ b/dash-renderer/package.json @@ -4,11 +4,13 @@ "description": "render dash components in react", "main": "dash_renderer/dash_renderer.min.js", "scripts": { - "prepublishOnly": "rm -rf lib && babel src --out-dir lib --copy-files", + "prepublishOnly": "rm -rf lib && babel src --extensions=\".ts,.tsx,.js,.jsx\" --out-dir lib --copy-files", "private::format.js-eslint": "eslint --quiet --fix .", "private::format.js-prettier": "prettier --config .prettierrc --write \"src/**/*.js\"", + "private::format.ts": "tslint --fix --project tsconfig.json --config tslint.json", "private::lint.js-eslint": "eslint .", "private::lint.js-prettier": "prettier --config .prettierrc \"src/**/*.js\" --list-different", + "private::lint.ts": "tslint --project tsconfig.json --config tslint.json", "build:js": "webpack --build release", "build:dev": "webpack --build local", "build:local": "renderer build local", @@ -41,10 +43,17 @@ "devDependencies": { "@babel/cli": "^7.8.4", "@babel/core": "^7.8.7", + "@babel/plugin-proposal-class-properties": "^7.10.1", "@babel/plugin-transform-modules-commonjs": "^7.8.3", "@babel/preset-env": "^7.8.7", "@babel/preset-react": "^7.8.3", + "@babel/preset-typescript": "^7.10.1", "@svgr/webpack": "^5.2.0", + "@types/ramda": "^0.27.6", + "@types/react": "^16.9.34", + "@types/react-redux": "^7.1.7", + "@types/redux": "^3.6.0", + "@types/redux-actions": "^2.6.1", "babel-eslint": "^10.1.0", "babel-loader": "^8.0.6", "css-loader": "^3.4.2", @@ -63,6 +72,10 @@ "prettier-eslint-cli": "^5.0.0", "prettier-stylelint": "^0.4.2", "style-loader": "^1.1.3", + "ts-jest": "^26.0.0", + "ts-loader": "^7.0.2", + "tslint": "^6.1.2", + "typescript": "^3.8.3", "webpack": "^4.42.0", "webpack-cli": "^3.3.11", "webpack-dev-server": "^3.10.3", diff --git a/dash-renderer/src/APIController.react.js b/dash-renderer/src/APIController.react.js index 23e5f34c01..8479b0fd07 100644 --- a/dash-renderer/src/APIController.react.js +++ b/dash-renderer/src/APIController.react.js @@ -1,6 +1,6 @@ import {connect} from 'react-redux'; import {includes, isEmpty} from 'ramda'; -import React, {useEffect, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState, createContext} from 'react'; import PropTypes from 'prop-types'; import TreeContainer from './TreeContainer'; import GlobalErrorContainer from './components/error/GlobalErrorContainer.react'; @@ -19,6 +19,9 @@ import {EventEmitter} from './actions/utils'; import {applyPersistence} from './persistence'; import {getAppState} from './reducers/constants'; import {STATUS} from './constants/constants'; +import {getLoadingState, getLoadingHash} from './utils/TreeContainer'; + +export const DashContext = createContext({}); /** * Fire off API calls for initialization @@ -26,6 +29,16 @@ import {STATUS} from './constants/constants'; * @returns {*} component */ const UnconnectedContainer = props => { + const { + appLifecycle, + config, + dependenciesRequest, + error, + layoutRequest, + layout, + loadingMap, + } = props; + const [errorLoading, setErrorLoading] = useState(false); const events = useRef(null); @@ -34,6 +47,18 @@ const UnconnectedContainer = props => { } const renderedTree = useRef(false); + const propsRef = useRef({}); + propsRef.current = props; + + const provider = useRef({ + fn: () => ({ + _dashprivate_config: propsRef.current.config, + _dashprivate_dispatch: propsRef.current.dispatch, + _dashprivate_graphs: propsRef.current.graphs, + _dashprivate_loadingMap: propsRef.current.loadingMap, + }), + }); + useEffect(storeEffect.bind(null, props, events, setErrorLoading)); useEffect(() => { @@ -43,14 +68,6 @@ const UnconnectedContainer = props => { } }); - const { - appLifecycle, - dependenciesRequest, - layoutRequest, - layout, - config, - } = props; - let content; if ( layoutRequest.status && @@ -65,11 +82,24 @@ const UnconnectedContainer = props => { content =
Error loading dependencies
; } else if (appLifecycle === getAppState('HYDRATED')) { renderedTree.current = true; + content = ( - + + + ); } else { content =
Loading...
; @@ -157,6 +187,7 @@ UnconnectedContainer.propTypes = { graphs: PropTypes.object, layoutRequest: PropTypes.object, layout: PropTypes.object, + loadingMap: PropTypes.any, history: PropTypes.any, error: PropTypes.object, config: PropTypes.object, @@ -169,6 +200,7 @@ const Container = connect( dependenciesRequest: state.dependenciesRequest, layoutRequest: state.layoutRequest, layout: state.layout, + loadingMap: state.loadingMap, graphs: state.graphs, history: state.history, error: state.error, diff --git a/dash-renderer/src/AppProvider.react.js b/dash-renderer/src/AppProvider.react.tsx similarity index 82% rename from dash-renderer/src/AppProvider.react.js rename to dash-renderer/src/AppProvider.react.tsx index d44a27eb61..6b534be900 100644 --- a/dash-renderer/src/AppProvider.react.js +++ b/dash-renderer/src/AppProvider.react.tsx @@ -1,14 +1,13 @@ +import PropTypes from 'prop-types'; import React from 'react'; import {Provider} from 'react-redux'; import initializeStore from './store'; import AppContainer from './AppContainer.react'; -import PropTypes from 'prop-types'; - const store = initializeStore(); -const AppProvider = ({hooks}) => { +const AppProvider = ({hooks}: any) => { return ( @@ -19,15 +18,17 @@ const AppProvider = ({hooks}) => { AppProvider.propTypes = { hooks: PropTypes.shape({ request_pre: PropTypes.func, - request_post: PropTypes.func, - }), + request_post: PropTypes.func + }) }; AppProvider.defaultProps = { hooks: { request_pre: null, - request_post: null, - }, + request_post: null + } }; export default AppProvider; + + diff --git a/dash-renderer/src/StoreObserver.ts b/dash-renderer/src/StoreObserver.ts new file mode 100644 index 0000000000..4bc82382f0 --- /dev/null +++ b/dash-renderer/src/StoreObserver.ts @@ -0,0 +1,113 @@ +import { + any, + filter, + forEach, + map, + path +} from 'ramda'; + +import { Store, Unsubscribe } from 'redux'; + +type Observer = (store: TStore) => void; +type UnregisterObserver = () => void; + +interface IStoreObserverState { + inputPaths: string[][]; + lastState: any; + observer: Observer; + triggered: boolean; +} + +export interface IStoreObserverDefinition { + observer: Observer>; + inputs: string[] +} + +export default class StoreObserver { + private _store?: Store; + private _unsubscribe?: Unsubscribe; + + private readonly _observers: IStoreObserverState>[] = []; + + constructor(store?: Store) { + this.__init__(store); + } + + observe = ( + observer: IStoreObserverDefinition | Observer>, + inputs?: string[] + ): UnregisterObserver => { + if (typeof observer === 'function') { + if (!Array.isArray(inputs)) { + throw new Error('inputs must be an array'); + } + + this.add(observer, inputs); + return () => this.remove(observer); + } else { + this.add(observer.observer, observer.inputs); + return () => this.remove(observer.observer); + } + } + + setStore = (store: Store) => { + this.__finalize__(); + this.__init__(store); + } + + private __finalize__ = () => this._unsubscribe?.() + + private __init__ = (store?: Store) => { + this._store = store; + if (store) { + this._unsubscribe = store.subscribe(this.notify); + } + + forEach(o => o.lastState = null, this._observers); + } + + private add = ( + observer: Observer>, + inputs: string[] + ) => this._observers.push({ + inputPaths: map(p => p.split('.'), inputs), + lastState: null, + observer, + triggered: false + }); + + private notify = () => { + const store = this._store; + if (!store) { + return; + } + + const state = store.getState(); + + const triggered = filter( + o => !o.triggered && any( + i => path(i, state) !== path(i, o.lastState), + o.inputPaths + ), + this._observers + ); + + forEach(o => o.triggered = true, triggered); + + forEach( + o => { + o.lastState = store.getState(); + o.observer(store); + o.triggered = false; + }, + triggered + ); + } + + private remove = (observer: Observer>) => this._observers.splice( + this._observers.findIndex( + o => observer === o.observer, + this._observers + ), 1 + ); +} diff --git a/dash-renderer/src/TreeContainer.js b/dash-renderer/src/TreeContainer.js index 3b1c3c13c2..bf317ad17d 100644 --- a/dash-renderer/src/TreeContainer.js +++ b/dash-renderer/src/TreeContainer.js @@ -1,15 +1,12 @@ -import React, {Component} from 'react'; +import React, {Component, memo} from 'react'; import PropTypes from 'prop-types'; import Registry from './registry'; import {propTypeErrorHandler} from './exceptions'; -import {connect} from 'react-redux'; import { addIndex, concat, dissoc, equals, - filter, - has, isEmpty, isNil, keys, @@ -26,46 +23,16 @@ import {recordUiEdit} from './persistence'; import ComponentErrorBoundary from './components/error/ComponentErrorBoundary.react'; import checkPropTypes from './checkPropTypes'; import {getWatchedKeys, stringifyId} from './actions/dependencies'; - -function validateComponent(componentDefinition) { - if (type(componentDefinition) === 'Array') { - throw new Error( - 'The children property of a component is a list of lists, instead ' + - 'of just a list. ' + - 'Check the component that has the following contents, ' + - 'and remove one of the levels of nesting: \n' + - JSON.stringify(componentDefinition, null, 2) - ); - } - if ( - type(componentDefinition) === 'Object' && - !( - has('namespace', componentDefinition) && - has('type', componentDefinition) && - has('props', componentDefinition) - ) - ) { - throw new Error( - 'An object was provided as `children` instead of a component, ' + - 'string, or number (or list of those). ' + - 'Check the children property that looks something like:\n' + - JSON.stringify(componentDefinition, null, 2) - ); - } -} - -const createContainer = (component, path) => - isSimpleComponent(component) ? ( - component - ) : ( - - ); +import { + getLoadingHash, + getLoadingState, + validateComponent, +} from './utils/TreeContainer'; +import {DashContext} from './APIController.react'; + +const NOT_LOADING = { + is_loading: false, +}; function CheckedComponent(p) { const {element, extraProps, props, children, type} = p; @@ -100,13 +67,51 @@ function createElement(element, props, extraProps, children) { return React.createElement(element, allProps, children); } -class TreeContainer extends Component { +const TreeContainer = memo(props => ( + + {context => ( + + )} + +)); + +class BaseTreeContainer extends Component { constructor(props) { super(props); this.setProps = this.setProps.bind(this); } + createContainer(props, component, path) { + return isSimpleComponent(component) ? ( + component + ) : ( + + ); + } + setProps(newProps) { const { _dashprivate_graphs, @@ -161,17 +166,26 @@ class TreeContainer extends Component { return Array.isArray(components) ? addIndex(map)( (component, i) => - createContainer( + this.createContainer( + this.props, component, concat(path, ['props', 'children', i]) ), components ) - : createContainer(components, concat(path, ['props', 'children'])); + : this.createContainer( + this.props, + components, + concat(path, ['props', 'children']) + ); } getComponent(_dashprivate_layout, children, loading_state, setProps) { - const {_dashprivate_config} = this.props; + const { + _dashprivate_config, + _dashprivate_dispatch, + _dashprivate_error, + } = this.props; if (isEmpty(_dashprivate_layout)) { return null; @@ -192,13 +206,18 @@ class TreeContainer extends Component { // just the id we pass on to the rendered component props.id = stringifyId(props.id); } - const extraProps = {loading_state, setProps}; + const extraProps = { + loading_state: loading_state || NOT_LOADING, + setProps, + }; return ( {_dashprivate_config.props_check ? ( - !isSimpleComponent(child) && !isLoadingComponent(child), - Array.isArray(children) ? children : [children] - ); - - queue.push(...filteredChildren); - } - } - - return ids; -} - -function getLoadingState(layout, pendingCallbacks) { - const ids = isLoadingComponent(layout) - ? getNestedIds(layout) - : layout && layout.props.id && [layout.props.id]; - - let isLoading = false; - let loadingProp; - let loadingComponent; - - if (pendingCallbacks && pendingCallbacks.length && ids && ids.length) { - const idStrs = ids.map(stringifyId); - - pendingCallbacks.forEach(cb => { - const {requestId, requestedOutputs} = cb; - if (requestId === undefined) { - return; - } - - idStrs.forEach(idStr => { - const props = requestedOutputs[idStr]; - if (props) { - isLoading = true; - // TODO: what about multiple loading components / props? - loadingComponent = idStr; - loadingProp = props[0]; - } - }); - }); - } - - // Set loading state - return { - is_loading: isLoading, - prop_name: loadingProp, - component_name: loadingComponent, - }; -} - -export const AugmentedTreeContainer = connect( - state => ({ - graphs: state.graphs, - pendingCallbacks: state.pendingCallbacks, - config: state.config, - }), - dispatch => ({dispatch}), - (stateProps, dispatchProps, ownProps) => ({ - _dashprivate_graphs: stateProps.graphs, - _dashprivate_dispatch: dispatchProps.dispatch, - _dashprivate_layout: ownProps._dashprivate_layout, - _dashprivate_path: ownProps._dashprivate_path, - _dashprivate_loadingState: getLoadingState( - ownProps._dashprivate_layout, - stateProps.pendingCallbacks - ), - _dashprivate_config: stateProps.config, - }) -)(TreeContainer); - -export default AugmentedTreeContainer; +export default TreeContainer; diff --git a/dash-renderer/src/actions/callbacks.ts b/dash-renderer/src/actions/callbacks.ts new file mode 100644 index 0000000000..c660bcb218 --- /dev/null +++ b/dash-renderer/src/actions/callbacks.ts @@ -0,0 +1,422 @@ +import { + concat, + flatten, + keys, + map, + mergeDeepRight, + path, + pick, + pluck, + zip +} from 'ramda'; + +import { STATUS } from '../constants/constants'; +import { CallbackActionType, CallbackAggregateActionType } from '../reducers/callbacks'; +import { CallbackResult, ICallback, IExecutedCallback, IExecutingCallback, ICallbackPayload, IStoredCallback, IBlockedCallback, IPrioritizedCallback } from '../types/callbacks'; +import { isMultiValued, stringifyId, isMultiOutputProp } from './dependencies'; +import { urlBase } from './utils'; +import { getCSRFHeader } from '.'; +import { createAction, Action } from 'redux-actions'; + +export const addBlockedCallbacks = createAction( + CallbackActionType.AddBlocked +); +export const addCompletedCallbacks = createAction( + CallbackAggregateActionType.AddCompleted +); +export const addExecutedCallbacks = createAction( + CallbackActionType.AddExecuted +); +export const addExecutingCallbacks = createAction( + CallbackActionType.AddExecuting +); +export const addPrioritizedCallbacks = createAction( + CallbackActionType.AddPrioritized +); +export const addRequestedCallbacks = createAction( + CallbackActionType.AddRequested +); +export const addStoredCallbacks = createAction( + CallbackActionType.AddStored +); +export const addWatchedCallbacks = createAction(CallbackActionType.AddWatched); +export const removeExecutedCallbacks = createAction( + CallbackActionType.RemoveExecuted +); +export const removeBlockedCallbacks = createAction( + CallbackActionType.RemoveBlocked +); +export const removeExecutingCallbacks = createAction( + CallbackActionType.RemoveExecuting +); +export const removePrioritizedCallbacks = createAction( + CallbackActionType.RemovePrioritized +); +export const removeRequestedCallbacks = createAction( + CallbackActionType.RemoveRequested +); +export const removeStoredCallbacks = createAction( + CallbackActionType.RemoveStored +); +export const removeWatchedCallbacks = createAction( + CallbackActionType.RemoveWatched +); +export const aggregateCallbacks = createAction<( + Action | + Action | + null +)[]>(CallbackAggregateActionType.Aggregate); + +function unwrapIfNotMulti( + paths: any, + idProps: any, + spec: any, + anyVals: any, + depType: any +) { + let msg = ''; + + if (isMultiValued(spec)) { + return [idProps, msg]; + } + + if (idProps.length !== 1) { + if (!idProps.length) { + const isStr = typeof spec.id === 'string'; + msg = + 'A nonexistent object was used in an `' + + depType + + '` of a Dash callback. The id of this object is ' + + (isStr + ? '`' + spec.id + '`' + : JSON.stringify(spec.id) + + (anyVals ? ' with MATCH values ' + anyVals : '')) + + ' and the property is `' + + spec.property + + (isStr + ? '`. The string ids in the current layout are: [' + + keys(paths.strs).join(', ') + + ']' + : '`. The wildcard ids currently available are logged above.'); + } else { + msg = + 'Multiple objects were found for an `' + + depType + + '` of a callback that only takes one value. The id spec is ' + + JSON.stringify(spec.id) + + (anyVals ? ' with MATCH values ' + anyVals : '') + + ' and the property is `' + + spec.property + + '`. The objects we found are: ' + + JSON.stringify(map(pick(['id', 'property']), idProps)); + } + } + return [idProps[0], msg]; +} + +function fillVals( + paths: any, + layout: any, + cb: ICallback, + specs: any, + depType: any, + allowAllMissing: boolean = false +) { + const getter = depType === 'Input' ? cb.getInputs : cb.getState; + const errors: any[] = []; + let emptyMultiValues = 0; + + const inputVals = getter(paths).map((inputList: any, i: number) => { + const [inputs, inputError] = unwrapIfNotMulti( + paths, + inputList.map(({ id, property, path: path_ }: any) => ({ + id, + property, + value: (path(path_, layout) as any).props[property] + })), + specs[i], + cb.anyVals, + depType + ); + if (isMultiValued(specs[i]) && !inputs.length) { + emptyMultiValues++; + } + if (inputError) { + errors.push(inputError); + } + return inputs; + }); + + if (errors.length) { + if ( + allowAllMissing && + errors.length + emptyMultiValues === inputVals.length + ) { + // We have at least one non-multivalued input, but all simple and + // multi-valued inputs are missing. + // (if all inputs are multivalued and all missing we still return + // them as normal, and fire the callback.) + return null; + } + // If we get here we have some missing and some present inputs. + // Or all missing in a context that doesn't allow this. + // That's a real problem, so throw the first message as an error. + refErr(errors, paths); + } + + return inputVals; +} + +function refErr(errors: any, paths: any) { + const err = errors[0]; + if (err.indexOf('logged above') !== -1) { + // Wildcard reference errors mention a list of wildcard specs logged + // TODO: unwrapped list of wildcard ids? + // eslint-disable-next-line no-console + console.error(paths.objs); + } + throw new ReferenceError(err); +} + +const getVals = (input: any) => + Array.isArray(input) ? pluck('value', input) : input.value; + +const zipIfArray = (a: any, b: any) => (Array.isArray(a) ? zip(a, b) : [[a, b]]); + +function handleClientside(clientside_function: any, payload: ICallbackPayload) { + const dc = ((window as any).dash_clientside = (window as any).dash_clientside || {}); + if (!dc.no_update) { + Object.defineProperty(dc, 'no_update', { + value: { description: 'Return to prevent updating an Output.' }, + writable: false + }); + + Object.defineProperty(dc, 'PreventUpdate', { + value: { description: 'Throw to prevent updating all Outputs.' }, + writable: false + }); + } + + const { inputs, outputs, state } = payload; + + let returnValue; + + try { + const { namespace, function_name } = clientside_function; + let args = inputs.map(getVals); + if (state) { + args = concat(args, state.map(getVals)); + } + + // 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); + + returnValue = dc[namespace][function_name](...args); + } catch (e) { + if (e === dc.PreventUpdate) { + return {}; + } + throw e; + } finally { + delete dc.callback_context; + } + + if (typeof returnValue?.then === 'function') { + throw new Error( + 'The clientside function returned a Promise. ' + + 'Promises are not supported in Dash clientside ' + + 'right now, but may be in the future.' + ); + } + + const data: any = {}; + zipIfArray(outputs, returnValue).forEach(([outi, reti]) => { + zipIfArray(outi, reti).forEach(([outij, retij]) => { + const { id, property } = outij; + const idStr = stringifyId(id); + const dataForId = (data[idStr] = data[idStr] || {}); + if (retij !== dc.no_update) { + dataForId[property] = retij; + } + }); + }); + return data; +} + +function handleServerside( + hooks: any, + config: any, + payload: any +): Promise { + if (hooks.request_pre !== null) { + hooks.request_pre(payload); + } + + return fetch( + `${urlBase(config)}_dash-update-component`, + mergeDeepRight(config.fetch, { + method: 'POST', + headers: getCSRFHeader() as any, + body: JSON.stringify(payload) + }) + ).then((res: any) => { + const { status } = res; + if (status === STATUS.OK) { + return res.json().then((data: any) => { + const { multi, response } = data; + if (hooks.request_post !== null) { + hooks.request_post(payload, response); + } + + if (multi) { + return response; + } + + const { output } = payload; + const id = output.substr(0, output.lastIndexOf('.')); + return { [id]: response.props }; + }); + } + if (status === STATUS.PREVENT_UPDATE) { + return {}; + } + throw res; + }, () => { + // fetch rejection - this means the request didn't return, + // we don't get here from 400/500 errors, only network + // errors or unresponsive servers. + throw new Error('Callback failed: the server did not respond.'); + }); +} + +function inputsToDict(inputs_list: any) { + // 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: any = {}; + 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; +} + +export function executeCallback( + cb: IPrioritizedCallback, + config: any, + hooks: any, + paths: any, + layout: any, + { allOutputs }: any +): IExecutingCallback { + const { output, inputs, state, clientside_function } = cb.callback; + + try { + const inVals = fillVals(paths, layout, cb, inputs, 'Input', true); + + /* Prevent callback if there's no inputs */ + if (inVals === null) { + return { + ...cb, + executionPromise: null + }; + } + + const outputs: any[] = []; + const outputErrors: any[] = []; + allOutputs.forEach((out: any, i: number) => { + const [outi, erri] = unwrapIfNotMulti( + paths, + map(pick(['id', 'property']), out), + cb.callback.outputs[i], + cb.anyVals, + 'Output' + ); + outputs.push(outi); + if (erri) { + outputErrors.push(erri); + } + }); + + if (outputErrors.length) { + if (flatten(inVals).length) { + refErr(outputErrors, paths); + } + // This case is all-empty multivalued wildcard inputs, + // which we would normally fire the callback for, except + // some outputs are missing. So instead we treat it like + // regular missing inputs and just silently prevent it. + return { + ...cb, + executionPromise: null + }; + } + + const __promise = new Promise(resolve => { + try { + const payload: ICallbackPayload = { + output, + outputs: isMultiOutputProp(output) ? outputs : outputs[0], + inputs: inVals, + changedPropIds: keys(cb.changedPropIds), + state: cb.callback.state.length ? + fillVals(paths, layout, cb, state, 'State') : + undefined + }; + + if (clientside_function) { + try { + resolve({ data: handleClientside(clientside_function, payload), payload }); + } catch (error) { + resolve({ error, payload }); + } + return null; + } else { + handleServerside(hooks, config, payload) + .then(data => resolve({ data, payload })) + .catch(error => resolve({ error, payload })); + } + } catch (error) { + resolve({ error, payload: null }); + } + }); + + const newCb = { + ...cb, + executionPromise: __promise + }; + + return newCb; + } catch (error) { + return { + ...cb, + executionPromise: { error, payload: null } + }; + } +} diff --git a/dash-renderer/src/actions/dependencies.js b/dash-renderer/src/actions/dependencies.js index add6a20597..1d77086d1d 100644 --- a/dash-renderer/src/actions/dependencies.js +++ b/dash-renderer/src/actions/dependencies.js @@ -5,9 +5,7 @@ import { any, ap, assoc, - clone, difference, - dissoc, equals, evolve, findIndex, @@ -18,24 +16,25 @@ import { isEmpty, keys, map, - mergeDeepRight, mergeRight, - mergeWith, - partition, path, - pickBy, pluck, - propEq, props, startsWith, - unnest, values, zip, zipObj, } from 'ramda'; -const mergeMax = mergeWith(Math.max); - +import { + combineIdAndProp, + getCallbacksByInput, + getPriority, + INDIRECT, + mergeMax, + makeResolvedCallback, + resolveDeps, +} from './dependencies_ts'; import {computePaths, getPath} from './paths'; import {crawlLayout} from './utils'; @@ -91,7 +90,7 @@ function parseMultipleOutputs(outputIdAndProp) { return outputIdAndProp.substr(2, outputIdAndProp.length - 4).split('...'); } -function splitIdAndProp(idAndProp) { +export function splitIdAndProp(idAndProp) { // since wildcard ids can have . in them but props can't, // look for the last . in the string and split there const dotPos = idAndProp.lastIndexOf('.'); @@ -109,9 +108,6 @@ export function parseIfWildcard(idStr) { return isWildcardId(idStr) ? parseWildcardId(idStr) : idStr; } -export const combineIdAndProp = ({id, property}) => - `${stringifyId(id)}.${property}`; - /* * JSON.stringify - for the object form - but ensuring keys are sorted */ @@ -846,7 +842,14 @@ function findWildcardKeys(id) { * Optionally, include another reference set of the same - to ensure the * correct matching of MATCH or ALLSMALLER between input and output items. */ -function idMatch(keys, vals, patternVals, refKeys, refVals, refPatternVals) { +export function idMatch( + keys, + vals, + patternVals, + refKeys, + refVals, + refPatternVals +) { for (let i = 0; i < keys.length; i++) { const val = vals[i]; const patternVal = patternVals[i]; @@ -901,74 +904,6 @@ function getAnyVals(patternVals, vals) { return matches.length ? JSON.stringify(matches) : ''; } -function resolveDeps(refKeys, refVals, refPatternVals) { - return paths => ({id: idPattern, property}) => { - if (typeof idPattern === 'string') { - const path = getPath(paths, idPattern); - return path ? [{id: idPattern, property, path}] : []; - } - const keys = Object.keys(idPattern).sort(); - const patternVals = props(keys, idPattern); - const keyStr = keys.join(','); - const keyPaths = paths.objs[keyStr]; - if (!keyPaths) { - return []; - } - const result = []; - keyPaths.forEach(({values: vals, path}) => { - if ( - idMatch( - keys, - vals, - patternVals, - refKeys, - refVals, - refPatternVals - ) - ) { - result.push({id: zipObj(keys, vals), property, path}); - } - }); - return result; - }; -} - -/* - * Create a pending callback object. Includes the original callback definition, - * its resolved ID (including the value of all MATCH wildcards), - * accessors to find all inputs, outputs, and state involved in this - * callback (lazy as not all users will want all of these), - * placeholders for which other callbacks this one is blockedBy or blocking, - * and a boolean for whether it has been dispatched yet. - */ -const makeResolvedCallback = (callback, resolve, anyVals) => ({ - callback, - anyVals, - resolvedId: callback.output + anyVals, - getOutputs: paths => callback.outputs.map(resolve(paths)), - getInputs: paths => callback.inputs.map(resolve(paths)), - getState: paths => callback.state.map(resolve(paths)), - blockedBy: {}, - blocking: {}, - changedPropIds: {}, - initialCall: false, - requestId: 0, - requestedOutputs: {}, -}); - -const DIRECT = 2; -const INDIRECT = 1; - -let nextRequestId = 0; - -/* - * Give a callback a new requestId. - */ -export function setNewRequestId(callback) { - nextRequestId++; - return assoc('requestId', nextRequestId, callback); -} - /* * Does this item (input / output / state) support multiple values? * string IDs do not; wildcard IDs only do if they contain ALL or ALLSMALLER @@ -991,8 +926,6 @@ export function isMultiValued({id}) { * The result is a list of {id (string or object), property (string)} * getInputs: same for inputs * getState: same for state - * blockedBy: an object of {[resolvedId]: 1} blocking this callback - * blocking: an object of {[resolvedId]: 1} this callback is blocking * changedPropIds: an object of {[idAndProp]: v} triggering this callback * v = DIRECT (2): the prop was changed in the front end, so dependent * callbacks *MUST* be executed. @@ -1003,12 +936,6 @@ export function isMultiValued({id}) { * this value on page load or changing part of the layout. * By default this is true for callbacks generated by * getCallbackByOutput, false from getCallbacksByInput. - * requestId: integer: starts at 0. when this callback is dispatched it will - * get a unique requestId, but if it gets added again the requestId will - * be reset to 0, and we'll know to ignore the response of the first - * request. - * requestedOutputs: object of {[idStr]: [props]} listing all the props - * actually requested for update. * } */ function getCallbackByOutput(graphs, paths, id, prop) { @@ -1062,7 +989,7 @@ function addResolvedFromOutputs(callback, outPattern, outs, matches) { }); } -function addAllResolvedFromOutputs(resolve, paths, matches) { +export function addAllResolvedFromOutputs(resolve, paths, matches) { return callback => { const {matchKeys, firstSingleOutput, outputs} = callback; if (matchKeys.length) { @@ -1119,47 +1046,6 @@ function addAllResolvedFromOutputs(resolve, paths, matches) { * (with an MATCH corresponding to the input's ALLSMALLER) will only appear * in one entry. */ -export function getCallbacksByInput(graphs, paths, id, prop, changeType) { - const matches = []; - const idAndProp = combineIdAndProp({id, property: prop}); - - if (typeof id === 'string') { - // standard id version - const callbacks = (graphs.inputMap[id] || {})[prop]; - if (!callbacks) { - return []; - } - - callbacks.forEach( - addAllResolvedFromOutputs(resolveDeps(), paths, matches) - ); - } else { - // wildcard version - const keys = Object.keys(id).sort(); - const vals = props(keys, id); - const keyStr = keys.join(','); - const patterns = (graphs.inputPatterns[keyStr] || {})[prop]; - if (!patterns) { - return []; - } - patterns.forEach(pattern => { - if (idMatch(keys, vals, pattern.values)) { - pattern.callbacks.forEach( - addAllResolvedFromOutputs( - resolveDeps(keys, vals, pattern.values), - paths, - matches - ) - ); - } - }); - } - matches.forEach(match => { - match.changedPropIds[idAndProp] = changeType || DIRECT; - }); - return matches; -} - export function getWatchedKeys(id, newProps, graphs) { if (!(id && graphs && newProps.length)) { return []; @@ -1205,7 +1091,7 @@ export function getWatchedKeys(id, newProps, graphs) { * {callback, resolvedId, getOutputs, getInputs, getState, ...etc} * See getCallbackByOutput for details. */ -export function getCallbacksInLayout(graphs, paths, layoutChunk, opts) { +export function getUnfilteredLayoutCallbacks(graphs, paths, layoutChunk, opts) { const {outputsOnly, removedArrayInputsOnly, newPaths, chunkPath} = opts; const foundCbIds = {}; const callbacks = []; @@ -1315,245 +1201,11 @@ export function getCallbacksInLayout(graphs, paths, layoutChunk, opts) { } }); - // We still need to follow these forward in order to capture blocks and, - // if based on a partial layout, any knock-on effects in the full layout. - const finalCallbacks = followForward(graphs, paths, callbacks); - - // Exception to the `initialCall` case of callbacks found by output: - // if *every* input to this callback is itself an output of another - // callback earlier in the chain, we remove the `initialCall` flag - // so that if all of those prior callbacks abort all of their outputs, - // this later callback never runs. - // See test inin003 "callback2 is never triggered, even on initial load" - finalCallbacks.forEach(cb => { - if (cb.initialCall && !isEmpty(cb.blockedBy)) { - const inputs = flatten(cb.getInputs(paths)); - cb.initialCall = false; - inputs.forEach(i => { - const propId = combineIdAndProp(i); - if (cb.changedPropIds[propId]) { - cb.changedPropIds[propId] = INDIRECT; - } else { - cb.initialCall = true; - } - }); - } - }); - - return finalCallbacks; -} - -export function removePendingCallback( - pendingCallbacks, - paths, - removeResolvedId, - skippedProps -) { - const finalPendingCallbacks = []; - pendingCallbacks.forEach(pending => { - const {blockedBy, blocking, changedPropIds, resolvedId} = pending; - if (resolvedId !== removeResolvedId) { - finalPendingCallbacks.push( - mergeRight(pending, { - blockedBy: dissoc(removeResolvedId, blockedBy), - blocking: dissoc(removeResolvedId, blocking), - changedPropIds: pickBy( - (v, k) => v === DIRECT || !includes(k, skippedProps), - changedPropIds - ), - }) - ); - } - }); - // If any callback no longer has any changed inputs, it shouldn't fire. - // This will repeat recursively until all unneeded callbacks are pruned - if (skippedProps.length) { - for (let i = 0; i < finalPendingCallbacks.length; i++) { - const cb = finalPendingCallbacks[i]; - if (!cb.initialCall && isEmpty(cb.changedPropIds)) { - return removePendingCallback( - finalPendingCallbacks, - paths, - cb.resolvedId, - flatten(cb.getOutputs(paths)).map(combineIdAndProp) - ); - } - } - } - return finalPendingCallbacks; -} - -/* - * Split the list of pending callbacks into ready (not blocked by any others) - * and blocked. Sort the ready callbacks by how many each is blocking, on the - * theory that the most important ones to dispatch are the ones with the most - * others depending on them. - */ -export function findReadyCallbacks(pendingCallbacks) { - const [readyCallbacks, blockedCallbacks] = partition( - pending => isEmpty(pending.blockedBy) && !pending.requestId, - pendingCallbacks + return map( + cb => ({ + ...cb, + priority: getPriority(graphs, paths, cb), + }), + callbacks ); - readyCallbacks.sort((a, b) => { - return Object.keys(b.blocking).length - Object.keys(a.blocking).length; - }); - - return {readyCallbacks, blockedCallbacks}; -} - -function addBlock(callbacks, blockingId, blockedId) { - callbacks.forEach(({blockedBy, blocking, resolvedId}) => { - if (resolvedId === blockingId || blocking[blockingId]) { - blocking[blockedId] = 1; - } else if (resolvedId === blockedId || blockedBy[blockedId]) { - blockedBy[blockingId] = 1; - } - }); -} - -function collectIds(callbacks) { - const allResolvedIds = {}; - callbacks.forEach(({resolvedId}, i) => { - allResolvedIds[resolvedId] = i; - }); - return allResolvedIds; -} - -/* - * Take a list of callbacks and follow them all forward, ie see if any of their - * outputs are inputs of another callback. Any new callbacks get added to the - * list. All that come after another get marked as blocked by that one, whether - * they were in the initial list or not. - */ -export function followForward(graphs, paths, callbacks_) { - const callbacks = clone(callbacks_); - const allResolvedIds = collectIds(callbacks); - let i; - let callback; - - const followOutput = ({id, property}) => { - const nextCBs = getCallbacksByInput( - graphs, - paths, - id, - property, - INDIRECT - ); - nextCBs.forEach(nextCB => { - let existingIndex = allResolvedIds[nextCB.resolvedId]; - if (existingIndex === undefined) { - existingIndex = callbacks.length; - callbacks.push(nextCB); - allResolvedIds[nextCB.resolvedId] = existingIndex; - } else { - const existingCB = callbacks[existingIndex]; - existingCB.changedPropIds = mergeMax( - existingCB.changedPropIds, - nextCB.changedPropIds - ); - } - addBlock(callbacks, callback.resolvedId, nextCB.resolvedId); - }); - }; - - // Using a for loop instead of forEach because followOutput may extend the - // callbacks array, and we want to continue into these new elements. - for (i = 0; i < callbacks.length; i++) { - callback = callbacks[i]; - const outputs = unnest(callback.getOutputs(paths)); - outputs.forEach(followOutput); - } - return callbacks; -} - -function mergeAllBlockers(cb1, cb2) { - function mergeBlockers(a, b) { - if (cb1[a][cb2.resolvedId] && !cb2[b][cb1.resolvedId]) { - cb2[b][cb1.resolvedId] = cb1[a][cb2.resolvedId]; - cb2[b] = mergeMax(cb1[b], cb2[b]); - cb1[a] = mergeMax(cb2[a], cb1[a]); - } - } - mergeBlockers('blockedBy', 'blocking'); - mergeBlockers('blocking', 'blockedBy'); -} - -/* - * Given two arrays of pending callbacks, merge them into one so that - * each will only fire once, and any extra blockages from combining the lists - * will be accounted for. - */ -export function mergePendingCallbacks(cb1, cb2) { - if (!cb2.length) { - return cb1; - } - if (!cb1.length) { - return cb2; - } - const finalCallbacks = clone(cb1); - const callbacks2 = clone(cb2); - const allResolvedIds = collectIds(finalCallbacks); - - callbacks2.forEach((callback, i) => { - const existingIndex = allResolvedIds[callback.resolvedId]; - if (existingIndex !== undefined) { - finalCallbacks.forEach(finalCb => { - mergeAllBlockers(finalCb, callback); - }); - callbacks2.slice(i + 1).forEach(cb2 => { - mergeAllBlockers(cb2, callback); - }); - finalCallbacks[existingIndex] = mergeDeepRight( - finalCallbacks[existingIndex], - callback - ); - } else { - allResolvedIds[callback.resolvedId] = finalCallbacks.length; - finalCallbacks.push(callback); - } - }); - - return finalCallbacks; -} - -/* - * Remove callbacks whose outputs or changed inputs have been removed - * from the layout - */ -export function pruneRemovedCallbacks(pendingCallbacks, paths) { - const removeIds = []; - let cleanedCallbacks = pendingCallbacks.map(callback => { - const {changedPropIds, getOutputs, resolvedId} = callback; - if (!flatten(getOutputs(paths)).length) { - removeIds.push(resolvedId); - return callback; - } - - let omittedProps = false; - const newChangedProps = pickBy((_, propId) => { - if (getPath(paths, splitIdAndProp(propId).id)) { - return true; - } - omittedProps = true; - return false; - }, changedPropIds); - - return omittedProps - ? assoc('changedPropIds', newChangedProps, callback) - : callback; - }); - - removeIds.forEach(resolvedId => { - const cb = cleanedCallbacks.find(propEq('resolvedId', resolvedId)); - if (cb) { - cleanedCallbacks = removePendingCallback( - pendingCallbacks, - paths, - resolvedId, - flatten(cb.getOutputs(paths)).map(combineIdAndProp) - ); - } - }); - - return cleanedCallbacks; } diff --git a/dash-renderer/src/actions/dependencies_ts.ts b/dash-renderer/src/actions/dependencies_ts.ts new file mode 100644 index 0000000000..840f03d89f --- /dev/null +++ b/dash-renderer/src/actions/dependencies_ts.ts @@ -0,0 +1,333 @@ +import { + all, + assoc, + concat, + difference, + filter, + flatten, + forEach, + isEmpty, + keys, + map, + mergeWith, + partition, + pickBy, + props, + reduce, + zipObj +} from 'ramda'; +import { ICallback, ICallbackProperty, ICallbackDefinition, ILayoutCallbackProperty, ICallbackTemplate } from '../types/callbacks'; +import { addAllResolvedFromOutputs, splitIdAndProp, stringifyId, getUnfilteredLayoutCallbacks, isMultiValued, idMatch } from './dependencies'; +import { getPath } from './paths'; + +export const DIRECT = 2; +export const INDIRECT = 1; +export const mergeMax = mergeWith(Math.max); + +export const combineIdAndProp = ({ + id, + property +}: ICallbackProperty) => `${stringifyId(id)}.${property}`; + +export function getCallbacksByInput( + graphs: any, + paths: any, + id: any, + prop: any, + changeType?: any, + withPriority: boolean = true +): ICallback[] { + const matches: ICallback[] = []; + const idAndProp = combineIdAndProp({ id, property: prop }); + + if (typeof id === 'string') { + // standard id version + const callbacks = (graphs.inputMap[id] || {})[prop]; + if (!callbacks) { + return []; + } + + callbacks.forEach( + addAllResolvedFromOutputs(resolveDeps(), paths, matches) + ); + } else { + // wildcard version + const _keys = Object.keys(id).sort(); + const vals = props(_keys, id); + const keyStr = _keys.join(','); + const patterns: any[] = (graphs.inputPatterns[keyStr] || {})[prop]; + if (!patterns) { + return []; + } + patterns.forEach(pattern => { + if (idMatch(_keys, vals, pattern.values)) { + pattern.callbacks.forEach( + addAllResolvedFromOutputs( + resolveDeps(_keys, vals, pattern.values), + paths, + matches + ) + ); + } + }); + } + matches.forEach(match => { + match.changedPropIds[idAndProp] = changeType || DIRECT; + if (withPriority) { + match.priority = getPriority(graphs, paths, match) + } + }); + return matches; +} + +/* + * Builds a tree of all callbacks that can be triggered by the provided callback. + * Uses the number of callbacks at each tree depth and the total depth of the tree + * to create a sortable priority hash. + */ +export function getPriority(graphs: any, paths: any, callback: ICallback): string { + let callbacks: ICallback[] = [callback]; + let touchedOutputs: { [key: string]: boolean } = {}; + let priority: number[] = []; + + while (callbacks.length) { + const outputs = filter( + o => !touchedOutputs[combineIdAndProp(o)], + flatten(map( + cb => flatten(cb.getOutputs(paths)), + callbacks + )) + ); + + touchedOutputs = reduce( + (touched, o) => assoc(combineIdAndProp(o), true, touched), + touchedOutputs, + outputs + ); + + callbacks = flatten(map( + ({ id, property }: any) => getCallbacksByInput( + graphs, + paths, + id, + property, + INDIRECT, + false + ), + outputs + )); + + if (callbacks.length) { + priority.push(callbacks.length); + } + } + + priority.unshift(priority.length); + + return map(i => Math.min(i, 35).toString(36), priority).join(''); +} + +export const getReadyCallbacks = ( + paths: any, + candidates: ICallback[], + callbacks: ICallback[] = candidates +): ICallback[] => { + // Skip if there's no candidates + if (!candidates.length) { + return []; + } + + // Find all outputs of all active callbacks + const outputs = map( + combineIdAndProp, + reduce( + (o, cb) => concat(o, flatten(cb.getOutputs(paths))), + [], + callbacks + ) + ); + + // Make `outputs` hash table for faster access + const outputsMap: { [key: string]: boolean } = {}; + forEach(output => outputsMap[output] = true, outputs); + + // Find `requested` callbacks that do not depend on a outstanding output (as either input or state) + return filter( + cb => all( + cbp => !outputsMap[combineIdAndProp(cbp)], + flatten(cb.getInputs(paths)) + ), + candidates + ); +} + +export const getLayoutCallbacks = ( + graphs: any, + paths: any, + layout: any, + options: any +): ICallback[] => { + let exclusions: string[] = []; + let callbacks = getUnfilteredLayoutCallbacks( + graphs, + paths, + layout, + options + ); + + /* + Remove from the initial callbacks those that are left with only excluded inputs. + + Exclusion of inputs happens when: + - an input is missing + - an input in the initial callback chain depends only on excluded inputs + + Further execlusion might happen after callbacks return with: + - PreventUpdate + - no_update + */ + while (true) { + // Find callbacks for which all inputs are missing or in the exclusions + const [included, excluded] = partition(({ + callback: { inputs }, + getInputs + }) => all(isMultiValued, inputs) || + !isEmpty(difference( + map(combineIdAndProp, flatten(getInputs(paths))), + exclusions + )), + callbacks + ); + + // If there's no additional exclusions, break loop - callbacks have been cleaned + if (!excluded.length) { + break; + } + + callbacks = included; + + // update exclusions with all additional excluded outputs + exclusions = concat( + exclusions, + map(combineIdAndProp, flatten(map( + ({ getOutputs }) => getOutputs(paths), + excluded + ))) + ); + } + + /* + Return all callbacks with an `executionGroup` to allow group-processing + */ + const executionGroup = Math.random().toString(16); + return map(cb => ({ + ...cb, + executionGroup + }), callbacks); +} + +export const getUniqueIdentifier = ({ + anyVals, + callback: { + inputs, + outputs, + state + } +}: ICallback): string => concat( + map(combineIdAndProp, [ + ...inputs, + ...outputs, + ...state + ]), + Array.isArray(anyVals) ? + anyVals : + anyVals === '' ? [] : [anyVals] + ).join(','); + +export function includeObservers(id: any, properties: any, graphs: any, paths: any): ICallback[] { + return flatten(map( + propName => getCallbacksByInput(graphs, paths, id, propName), + keys(properties) + )); +} + +/* + * Create a pending callback object. Includes the original callback definition, + * its resolved ID (including the value of all MATCH wildcards), + * accessors to find all inputs, outputs, and state involved in this + * callback (lazy as not all users will want all of these). + */ +export const makeResolvedCallback = ( + callback: ICallbackDefinition, + resolve: (_: any) => (_: ICallbackProperty) => ILayoutCallbackProperty[], + anyVals: any[] | string +): ICallbackTemplate => ({ + callback, + anyVals, + resolvedId: callback.output + anyVals, + getOutputs: paths => callback.outputs.map(resolve(paths)), + getInputs: paths => callback.inputs.map(resolve(paths)), + getState: paths => callback.state.map(resolve(paths)), + changedPropIds: {}, + initialCall: false +}); + +export function pruneCallbacks(callbacks: T[], paths: any): { + added: T[], + removed: T[] +} { + const [, removed] = partition( + ({ getOutputs, callback: { outputs } }) => flatten(getOutputs(paths)).length === outputs.length, + callbacks + ); + + const [, modified] = partition( + ({ getOutputs }) => !flatten(getOutputs(paths)).length, + removed + ); + + const added = map( + cb => assoc('changedPropIds', pickBy( + (_, propId) => getPath(paths, splitIdAndProp(propId).id), + cb.changedPropIds + ), cb), + modified + ); + + return { + added, + removed + }; +} + +export function resolveDeps(refKeys?: any, refVals?: any, refPatternVals?: string) { + return (paths: any) => ({ id: idPattern, property }: ICallbackProperty) => { + if (typeof idPattern === 'string') { + const path = getPath(paths, idPattern); + return path ? [{ id: idPattern, property, path }] : []; + } + const _keys = Object.keys(idPattern).sort(); + const patternVals = props(_keys, idPattern); + const keyStr = _keys.join(','); + const keyPaths = paths.objs[keyStr]; + if (!keyPaths) { + return []; + } + const result: ILayoutCallbackProperty[] = []; + keyPaths.forEach(({ values: vals, path }: any) => { + if ( + idMatch( + _keys, + vals, + patternVals, + refKeys, + refVals, + refPatternVals + ) + ) { + result.push({ id: zipObj(_keys, vals), property, path }); + } + }); + return result; + }; +} diff --git a/dash-renderer/src/actions/index.js b/dash-renderer/src/actions/index.js index a48c81a687..7d165f41a6 100644 --- a/dash-renderer/src/actions/index.js +++ b/dash-renderer/src/actions/index.js @@ -1,59 +1,22 @@ -import { - concat, - flatten, - has, - isEmpty, - keys, - map, - mergeDeepRight, - once, - path, - pick, - pickBy, - pluck, - propEq, - type, - uniq, - without, - zip, -} from 'ramda'; +import {once} from 'ramda'; import {createAction} from 'redux-actions'; +import {addRequestedCallbacks} from './callbacks'; import {getAppState} from '../reducers/constants'; import {getAction} from './constants'; import cookie from 'cookie'; -import {urlBase} from './utils'; -import { - combineIdAndProp, - findReadyCallbacks, - followForward, - getCallbacksByInput, - getCallbacksInLayout, - isMultiOutputProp, - isMultiValued, - mergePendingCallbacks, - removePendingCallback, - parseIfWildcard, - pruneRemovedCallbacks, - setNewRequestId, - stringifyId, - validateCallbacksToLayout, -} from './dependencies'; -import {computePaths, getPath} from './paths'; -import {STATUS} from '../constants/constants'; -import {applyPersistence, prunePersistence} from '../persistence'; +import {validateCallbacksToLayout} from './dependencies'; +import {includeObservers, getLayoutCallbacks} from './dependencies_ts'; +import {getPath} from './paths'; -import isAppReady from './isAppReady'; - -export const updateProps = createAction(getAction('ON_PROP_CHANGE')); -export const setPendingCallbacks = createAction('SET_PENDING_CALLBACKS'); -export const setRequestQueue = createAction(getAction('SET_REQUEST_QUEUE')); -export const setGraphs = createAction(getAction('SET_GRAPHS')); -export const setPaths = createAction(getAction('SET_PATHS')); +export const onError = createAction(getAction('ON_ERROR')); export const setAppLifecycle = createAction(getAction('SET_APP_LIFECYCLE')); export const setConfig = createAction(getAction('SET_CONFIG')); +export const setGraphs = createAction(getAction('SET_GRAPHS')); export const setHooks = createAction(getAction('SET_HOOKS')); export const setLayout = createAction(getAction('SET_LAYOUT')); -export const onError = createAction(getAction('ON_ERROR')); +export const setPaths = createAction(getAction('SET_PATHS')); +export const setRequestQueue = createAction(getAction('SET_REQUEST_QUEUE')); +export const updateProps = createAction(getAction('ON_PROP_CHANGE')); export const dispatchError = dispatch => (message, lines) => dispatch( @@ -103,10 +66,13 @@ function triggerDefaultState(dispatch, getState) { ); } - const initialCallbacks = getCallbacksInLayout(graphs, paths, layout, { - outputsOnly: true, - }); - dispatch(startCallbacks(initialCallbacks)); + dispatch( + addRequestedCallbacks( + getLayoutCallbacks(graphs, paths, layout, { + outputsOnly: true, + }) + ) + ); } export const redo = moveHistory('REDO'); @@ -135,606 +101,15 @@ function moveHistory(changeType) { }; } -function unwrapIfNotMulti(paths, idProps, spec, anyVals, depType) { - let msg = ''; - - if (isMultiValued(spec)) { - return [idProps, msg]; - } - - if (idProps.length !== 1) { - if (!idProps.length) { - const isStr = typeof spec.id === 'string'; - msg = - 'A nonexistent object was used in an `' + - depType + - '` of a Dash callback. The id of this object is ' + - (isStr - ? '`' + spec.id + '`' - : JSON.stringify(spec.id) + - (anyVals ? ' with MATCH values ' + anyVals : '')) + - ' and the property is `' + - spec.property + - (isStr - ? '`. The string ids in the current layout are: [' + - keys(paths.strs).join(', ') + - ']' - : '`. The wildcard ids currently available are logged above.'); - } else { - msg = - 'Multiple objects were found for an `' + - depType + - '` of a callback that only takes one value. The id spec is ' + - JSON.stringify(spec.id) + - (anyVals ? ' with MATCH values ' + anyVals : '') + - ' and the property is `' + - spec.property + - '`. The objects we found are: ' + - JSON.stringify(map(pick(['id', 'property']), idProps)); - } - } - return [idProps[0], msg]; -} - -function startCallbacks(callbacks) { - return async function(dispatch, getState) { - return await fireReadyCallbacks(dispatch, getState, callbacks); - }; -} - -async function fireReadyCallbacks(dispatch, getState, callbacks) { - const {readyCallbacks, blockedCallbacks} = findReadyCallbacks(callbacks); - const {config, hooks, layout, paths} = getState(); - - // We want to calculate all the outputs only once, but we need them - // for pendingCallbacks which we're going to dispatch prior to - // initiating the queue. So first loop over readyCallbacks to - // generate the output lists, then dispatch pendingCallbacks, - // then loop again to fire off the requests. - const outputStash = {}; - const requestedCallbacks = readyCallbacks.map(cb => { - const cbOut = setNewRequestId(cb); - - const {requestId, getOutputs} = cbOut; - const allOutputs = getOutputs(paths); - const flatOutputs = flatten(allOutputs); - const allPropIds = []; - - const reqOut = {}; - flatOutputs.forEach(({id, property}) => { - const idStr = stringifyId(id); - const idOut = (reqOut[idStr] = reqOut[idStr] || []); - idOut.push(property); - allPropIds.push(combineIdAndProp({id: idStr, property})); - }); - cbOut.requestedOutputs = reqOut; - - outputStash[requestId] = {allOutputs, allPropIds}; - - return cbOut; - }); - - const allCallbacks = concat(requestedCallbacks, blockedCallbacks); - dispatch(setPendingCallbacks(allCallbacks)); - - const ids = requestedCallbacks.map(cb => [ - cb.getInputs(paths), - cb.getState(paths), - ]); - await isAppReady(layout, paths, uniq(pluck('id', flatten(ids)))); - - function fireNext() { - return fireReadyCallbacks( - dispatch, - getState, - getState().pendingCallbacks - ); - } - - let hasClientSide = false; - - const queue = requestedCallbacks.map(cb => { - const {output, inputs, state, clientside_function} = cb.callback; - const {requestId, resolvedId} = cb; - const {allOutputs, allPropIds} = outputStash[requestId]; - - let payload; - try { - const inVals = fillVals(paths, layout, cb, inputs, 'Input', true); - - const preventCallback = () => { - removeCallbackFromPending(); - // no server call here; for performance purposes pretend this is - // a clientside callback and defer fireNext for the end - // of the currently-ready callbacks. - hasClientSide = true; - return null; - }; - - if (inVals === null) { - return preventCallback(); - } - - const outputs = []; - const outputErrors = []; - allOutputs.forEach((out, i) => { - const [outi, erri] = unwrapIfNotMulti( - paths, - map(pick(['id', 'property']), out), - cb.callback.outputs[i], - cb.anyVals, - 'Output' - ); - outputs.push(outi); - if (erri) { - outputErrors.push(erri); - } - }); - if (outputErrors.length) { - if (flatten(inVals).length) { - refErr(outputErrors, paths); - } - // This case is all-empty multivalued wildcard inputs, - // which we would normally fire the callback for, except - // some outputs are missing. So instead we treat it like - // regular missing inputs and just silently prevent it. - return preventCallback(); - } - - payload = { - output, - outputs: isMultiOutputProp(output) ? outputs : outputs[0], - inputs: inVals, - changedPropIds: keys(cb.changedPropIds), - }; - if (cb.callback.state.length) { - payload.state = fillVals(paths, layout, cb, state, 'State'); - } - } catch (e) { - handleError(e); - return fireNext(); - } - - function updatePending(pendingCallbacks, skippedProps) { - const newPending = removePendingCallback( - pendingCallbacks, - getState().paths, - resolvedId, - skippedProps - ); - dispatch(setPendingCallbacks(newPending)); - } - - function handleData(data) { - let {pendingCallbacks} = getState(); - if (!requestIsActive(pendingCallbacks, resolvedId, requestId)) { - return; - } - const updated = []; - Object.entries(data).forEach(([id, props]) => { - const parsedId = parseIfWildcard(id); - - const {layout: oldLayout, paths: oldPaths} = getState(); - - const appliedProps = doUpdateProps( - dispatch, - getState, - parsedId, - props - ); - if (appliedProps) { - // doUpdateProps can cause new callbacks to be added - // via derived props - update pendingCallbacks - // But we may also need to merge in other callbacks that - // we found in an earlier interation of the data loop. - const statePendingCallbacks = getState().pendingCallbacks; - if (statePendingCallbacks !== pendingCallbacks) { - pendingCallbacks = mergePendingCallbacks( - pendingCallbacks, - statePendingCallbacks - ); - } - - Object.keys(appliedProps).forEach(property => { - updated.push(combineIdAndProp({id, property})); - }); - - if (has('children', appliedProps)) { - const oldChildren = path( - concat(getPath(oldPaths, parsedId), [ - 'props', - 'children', - ]), - oldLayout - ); - // If components changed, need to update paths, - // check if all pending callbacks are still - // valid, and add all callbacks associated with - // new components, either as inputs or outputs, - // or components removed from ALL/ALLSMALLER inputs - pendingCallbacks = updateChildPaths( - dispatch, - getState, - pendingCallbacks, - parsedId, - appliedProps.children, - oldChildren - ); - } - - // persistence edge case: if you explicitly update the - // persistence key, other props may change that require us - // to fire additional callbacks - const addedProps = pickBy( - (v, k) => !(k in props), - appliedProps - ); - if (!isEmpty(addedProps)) { - const {graphs, paths} = getState(); - pendingCallbacks = includeObservers( - id, - addedProps, - graphs, - paths, - pendingCallbacks - ); - } - } - }); - updatePending(pendingCallbacks, without(updated, allPropIds)); - } - - function removeCallbackFromPending() { - const {pendingCallbacks} = getState(); - if (requestIsActive(pendingCallbacks, resolvedId, requestId)) { - // Skip all prop updates from this callback, and remove - // it from the pending list so callbacks it was blocking - // that have other changed inputs will still fire. - updatePending(pendingCallbacks, allPropIds); - } - } - - function handleError(err) { - removeCallbackFromPending(); - const outputs = payload - ? map(combineIdAndProp, flatten([payload.outputs])).join(', ') - : output; - let message = `Callback error updating ${outputs}`; - if (clientside_function) { - const {namespace: ns, function_name: fn} = clientside_function; - message += ` via clientside function ${ns}.${fn}`; - } - handleAsyncError(err, message, dispatch); - } - - if (clientside_function) { - try { - handleData(handleClientside(clientside_function, payload)); - } catch (err) { - handleError(err); - } - hasClientSide = true; - return null; - } - - return handleServerside(config, payload, hooks) - .then(handleData) - .catch(handleError) - .then(fireNext); - }); - const done = Promise.all(queue); - return hasClientSide ? fireNext().then(done) : done; -} - -function fillVals(paths, layout, cb, specs, depType, allowAllMissing) { - const getter = depType === 'Input' ? cb.getInputs : cb.getState; - const errors = []; - let emptyMultiValues = 0; - - const inputVals = getter(paths).map((inputList, i) => { - const [inputs, inputError] = unwrapIfNotMulti( - paths, - inputList.map(({id, property, path: path_}) => ({ - id, - property, - value: path(path_, layout).props[property], - })), - specs[i], - cb.anyVals, - depType - ); - if (isMultiValued(specs[i]) && !inputs.length) { - emptyMultiValues++; - } - if (inputError) { - errors.push(inputError); - } - return inputs; - }); - - if (errors.length) { - if ( - allowAllMissing && - errors.length + emptyMultiValues === inputVals.length - ) { - // We have at least one non-multivalued input, but all simple and - // multi-valued inputs are missing. - // (if all inputs are multivalued and all missing we still return - // them as normal, and fire the callback.) - return null; - } - // If we get here we have some missing and some present inputs. - // Or all missing in a context that doesn't allow this. - // That's a real problem, so throw the first message as an error. - refErr(errors, paths); - } - - return inputVals; -} - -function refErr(errors, paths) { - const err = errors[0]; - if (err.indexOf('logged above') !== -1) { - // Wildcard reference errors mention a list of wildcard specs logged - // TODO: unwrapped list of wildcard ids? - // eslint-disable-next-line no-console - console.error(paths.objs); - } - throw new ReferenceError(err); -} - -function handleServerside(config, payload, hooks) { - if (hooks.request_pre !== null) { - hooks.request_pre(payload); - } - - return fetch( - `${urlBase(config)}_dash-update-component`, - mergeDeepRight(config.fetch, { - method: 'POST', - headers: getCSRFHeader(), - body: JSON.stringify(payload), - }) - ).then( - res => { - const {status} = res; - if (status === STATUS.OK) { - return res.json().then(data => { - const {multi, response} = data; - if (hooks.request_post !== null) { - hooks.request_post(payload, response); - } - - if (multi) { - return response; - } - - const {output} = payload; - const id = output.substr(0, output.lastIndexOf('.')); - return {[id]: response.props}; - }); - } - if (status === STATUS.PREVENT_UPDATE) { - return {}; - } - throw res; - }, - () => { - // fetch rejection - this means the request didn't return, - // we don't get here from 400/500 errors, only network - // errors or unresponsive servers. - throw new Error('Callback failed: the server did not respond.'); - } - ); -} - -const getVals = input => - Array.isArray(input) ? pluck('value', input) : input.value; - -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) { - Object.defineProperty(dc, 'no_update', { - value: {description: 'Return to prevent updating an Output.'}, - writable: false, - }); - - Object.defineProperty(dc, 'PreventUpdate', { - value: {description: 'Throw to prevent updating all Outputs.'}, - writable: false, - }); - } - - const {inputs, outputs, state} = 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 {}; - } - throw e; - } - - if (type(returnValue) === 'Promise') { - throw new Error( - 'The clientside function returned a Promise. ' + - 'Promises are not supported in Dash clientside ' + - 'right now, but may be in the future.' - ); - } - - const data = {}; - zipIfArray(outputs, returnValue).forEach(([outi, reti]) => { - zipIfArray(outi, reti).forEach(([outij, retij]) => { - const {id, property} = outij; - const idStr = stringifyId(id); - const dataForId = (data[idStr] = data[idStr] || {}); - if (retij !== dc.no_update) { - dataForId[property] = retij; - } - }); - }); - return data; -} - -function requestIsActive(pendingCallbacks, resolvedId, requestId) { - const thisCallback = pendingCallbacks.find( - propEq('resolvedId', resolvedId) - ); - // could be inactivated if it was requested again, in which case it could - // potentially even have finished and been removed from the list - return thisCallback && thisCallback.requestId === requestId; -} - -function doUpdateProps(dispatch, getState, id, updatedProps) { - const {layout, paths} = getState(); - const itempath = getPath(paths, id); - if (!itempath) { - return false; - } - - // This is a callback-generated update. - // Check if this invalidates existing persisted prop values, - // or if persistence changed, whether this updates other props. - const updatedProps2 = prunePersistence( - path(itempath, layout), - updatedProps, - dispatch - ); - - // In case the update contains whole components, see if any of - // those components have props to update to persist user edits. - const {props} = applyPersistence({props: updatedProps2}, dispatch); - - dispatch( - updateProps({ - itempath, - props, - source: 'response', - }) - ); - - return props; -} - -function updateChildPaths( - dispatch, - getState, - pendingCallbacks, - id, - children, - oldChildren -) { - const {paths: oldPaths, graphs} = getState(); - const childrenPath = concat(getPath(oldPaths, id), ['props', 'children']); - const paths = computePaths(children, childrenPath, oldPaths); - dispatch(setPaths(paths)); - - const cleanedCallbacks = pruneRemovedCallbacks(pendingCallbacks, paths); - - const newCallbacks = getCallbacksInLayout(graphs, paths, children, { - chunkPath: childrenPath, - }); - - // Wildcard callbacks with array inputs (ALL / ALLSMALLER) need to trigger - // even due to the deletion of components - const deletedComponentCallbacks = getCallbacksInLayout( - graphs, - oldPaths, - oldChildren, - {removedArrayInputsOnly: true, newPaths: paths, chunkPath: childrenPath} - ); - - const allNewCallbacks = mergePendingCallbacks( - newCallbacks, - deletedComponentCallbacks - ); - return mergePendingCallbacks(cleanedCallbacks, allNewCallbacks); -} - export function notifyObservers({id, props}) { return async function(dispatch, getState) { - const {graphs, paths, pendingCallbacks} = getState(); - const finalCallbacks = includeObservers( - id, - props, - graphs, - paths, - pendingCallbacks + const {graphs, paths} = getState(); + dispatch( + addRequestedCallbacks(includeObservers(id, props, graphs, paths)) ); - dispatch(startCallbacks(finalCallbacks)); }; } -function includeObservers(id, props, graphs, paths, pendingCallbacks) { - const changedProps = keys(props); - let finalCallbacks = pendingCallbacks; - - changedProps.forEach(propName => { - const newCBs = getCallbacksByInput(graphs, paths, id, propName); - if (newCBs.length) { - finalCallbacks = mergePendingCallbacks( - finalCallbacks, - followForward(graphs, paths, newCBs) - ); - } - }); - return finalCallbacks; -} - export function handleAsyncError(err, message, dispatch) { // Handle html error responses if (err && typeof err.text === 'function') { diff --git a/dash-renderer/src/actions/isLoading.ts b/dash-renderer/src/actions/isLoading.ts new file mode 100644 index 0000000000..a501211317 --- /dev/null +++ b/dash-renderer/src/actions/isLoading.ts @@ -0,0 +1,5 @@ +import { createAction } from 'redux-actions'; + +import { IsLoadingActionType, IsLoadingState } from '../reducers/isLoading'; + +export const setIsLoading = createAction(IsLoadingActionType.Set); diff --git a/dash-renderer/src/actions/loadingMap.ts b/dash-renderer/src/actions/loadingMap.ts new file mode 100644 index 0000000000..2e4834dbd8 --- /dev/null +++ b/dash-renderer/src/actions/loadingMap.ts @@ -0,0 +1,5 @@ +import { createAction } from 'redux-actions'; + +import { LoadingMapActionType, LoadingMapState } from '../reducers/loadingMap'; + +export const setLoadingMap = createAction(LoadingMapActionType.Set); diff --git a/dash-renderer/src/checkPropTypes.js b/dash-renderer/src/checkPropTypes.js index 04f4d7b821..18dc8b5615 100644 --- a/dash-renderer/src/checkPropTypes.js +++ b/dash-renderer/src/checkPropTypes.js @@ -21,7 +21,7 @@ export default function checkPropTypes( values, location, componentName, - getStack + getStack = null ) { const errors = []; for (const typeSpecName in typeSpecs) { diff --git a/dash-renderer/src/components/core/DocumentTitle.react.js b/dash-renderer/src/components/core/DocumentTitle.react.js index 46eba06bfc..e192f32ce1 100644 --- a/dash-renderer/src/components/core/DocumentTitle.react.js +++ b/dash-renderer/src/components/core/DocumentTitle.react.js @@ -11,7 +11,7 @@ class DocumentTitle extends Component { } UNSAFE_componentWillReceiveProps(props) { - if (props.pendingCallbacks.length) { + if (props.isLoading) { document.title = 'Updating...'; } else { document.title = this.state.initialTitle; @@ -28,9 +28,9 @@ class DocumentTitle extends Component { } DocumentTitle.propTypes = { - pendingCallbacks: PropTypes.array.isRequired, + isLoading: PropTypes.bool.isRequired, }; export default connect(state => ({ - pendingCallbacks: state.pendingCallbacks, + isLoading: state.isLoading, }))(DocumentTitle); diff --git a/dash-renderer/src/components/core/Loading.react.js b/dash-renderer/src/components/core/Loading.react.js index 999684a8dc..b4eb2793f5 100644 --- a/dash-renderer/src/components/core/Loading.react.js +++ b/dash-renderer/src/components/core/Loading.react.js @@ -3,16 +3,16 @@ import React from 'react'; import PropTypes from 'prop-types'; function Loading(props) { - if (props.pendingCallbacks.length) { + if (props.isLoading) { return
; } return null; } Loading.propTypes = { - pendingCallbacks: PropTypes.array.isRequired, + isLoading: PropTypes.bool.isRequired, }; export default connect(state => ({ - pendingCallbacks: state.pendingCallbacks, + isLoading: state.isLoading, }))(Loading); diff --git a/dash-renderer/src/components/error/ComponentErrorBoundary.react.js b/dash-renderer/src/components/error/ComponentErrorBoundary.react.js index 5440cae0eb..f62b63cbe6 100644 --- a/dash-renderer/src/components/error/ComponentErrorBoundary.react.js +++ b/dash-renderer/src/components/error/ComponentErrorBoundary.react.js @@ -1,10 +1,8 @@ -import {connect} from 'react-redux'; import {Component} from 'react'; import PropTypes from 'prop-types'; -import Radium from 'radium'; import {onError, revert} from '../../actions'; -class UnconnectedComponentErrorBoundary extends Component { +class ComponentErrorBoundary extends Component { constructor(props) { super(props); this.state = { @@ -51,20 +49,11 @@ class UnconnectedComponentErrorBoundary extends Component { } } -UnconnectedComponentErrorBoundary.propTypes = { +ComponentErrorBoundary.propTypes = { children: PropTypes.object, componentId: PropTypes.string, error: PropTypes.object, dispatch: PropTypes.func, }; -const ComponentErrorBoundary = connect( - state => ({ - error: state.error, - }), - dispatch => { - return {dispatch}; - } -)(Radium(UnconnectedComponentErrorBoundary)); - export default ComponentErrorBoundary; diff --git a/dash-renderer/src/observers/executedCallbacks.ts b/dash-renderer/src/observers/executedCallbacks.ts new file mode 100644 index 0000000000..90e3787dd7 --- /dev/null +++ b/dash-renderer/src/observers/executedCallbacks.ts @@ -0,0 +1,237 @@ +import { + concat, + flatten, + isEmpty, + isNil, + map, + path, + forEach, + keys, + has, + pickBy, + toPairs +} from 'ramda'; + +import { IStoreState } from '../store'; + +import { + aggregateCallbacks, + addRequestedCallbacks, + removeExecutedCallbacks, + addCompletedCallbacks, + addStoredCallbacks +} from '../actions/callbacks'; + +import { parseIfWildcard } from '../actions/dependencies'; + +import { + combineIdAndProp, + getCallbacksByInput, + getLayoutCallbacks, + includeObservers +} from '../actions/dependencies_ts'; + +import { + ICallback, + IStoredCallback +} from '../types/callbacks'; + +import { updateProps, setPaths, handleAsyncError } from '../actions'; +import { getPath, computePaths } from '../actions/paths'; + +import { + applyPersistence, + prunePersistence +} from '../persistence'; +import { IStoreObserverDefinition } from '../StoreObserver'; + +const observer: IStoreObserverDefinition = { + observer: ({ + dispatch, + getState + }) => { + const { + callbacks: { + executed + } + } = getState(); + + function applyProps(id: any, updatedProps: any) { + const { layout, paths } = getState(); + const itempath = getPath(paths, id); + if (!itempath) { + return false; + } + + // This is a callback-generated update. + // Check if this invalidates existing persisted prop values, + // or if persistence changed, whether this updates other props. + updatedProps = prunePersistence( + path(itempath, layout), + updatedProps, + dispatch + ); + + // In case the update contains whole components, see if any of + // those components have props to update to persist user edits. + const { props } = applyPersistence({ props: updatedProps }, dispatch); + + dispatch( + updateProps({ + itempath, + props, + source: 'response' + }) + ); + + return props; + } + + let requestedCallbacks: ICallback[] = []; + let storedCallbacks: IStoredCallback[] = []; + + forEach(cb => { + const predecessors = concat( + cb.predecessors ?? [], + [cb.callback] + ); + + const { + callback: { + clientside_function, + output + }, + executionResult + } = cb; + + if (isNil(executionResult)) { + return; + } + + const { data, error, payload } = executionResult; + + if (data !== undefined) { + forEach(([id, props]: [any, { [key: string]: any }]) => { + const parsedId = parseIfWildcard(id); + const { graphs, layout: oldLayout, paths: oldPaths } = getState(); + + // Components will trigger callbacks on their own as required (eg. derived) + const appliedProps = applyProps(parsedId, props); + + // Add callbacks for modified inputs + requestedCallbacks = concat( + requestedCallbacks, + flatten(map( + prop => getCallbacksByInput(graphs, oldPaths, parsedId, prop, true), + keys(props) + )).map(rcb => ({ + ...rcb, + predecessors + })) + ); + + // New layout - trigger callbacks for that explicitly + if (has('children', appliedProps)) { + const { children } = appliedProps; + + const oldChildrenPath: string[] = concat(getPath(oldPaths, parsedId) as string[], ['props', 'children']); + const oldChildren = path(oldChildrenPath, oldLayout); + + const paths = computePaths(children, oldChildrenPath, oldPaths); + dispatch(setPaths(paths)); + + // Get callbacks for new layout (w/ execution group) + requestedCallbacks = concat( + requestedCallbacks, + getLayoutCallbacks(graphs, paths, children, { + chunkPath: oldChildrenPath + }).map(rcb => ({ + ...rcb, + predecessors + })) + ); + + // Wildcard callbacks with array inputs (ALL / ALLSMALLER) need to trigger + // even due to the deletion of components + requestedCallbacks = concat( + requestedCallbacks, + getLayoutCallbacks(graphs, oldPaths, oldChildren, { + removedArrayInputsOnly: true, newPaths: paths, chunkPath: oldChildrenPath + }).map(rcb => ({ + ...rcb, + predecessors + })) + ); + } + + // persistence edge case: if you explicitly update the + // persistence key, other props may change that require us + // to fire additional callbacks + const addedProps = pickBy( + (_, k) => !(k in props), + appliedProps + ); + if (!isEmpty(addedProps)) { + const { graphs: currentGraphs, paths } = getState(); + + requestedCallbacks = concat( + requestedCallbacks, + includeObservers(id, addedProps, currentGraphs, paths).map(rcb => ({ + ...rcb, + predecessors + })) + ); + } + }, Object.entries(data)); + + // Add information about potentially updated outputs vs. updated outputs, + // this will be used to drop callbacks from execution groups when no output + // matching the downstream callback's inputs were modified + storedCallbacks.push({ + ...cb, + executionMeta: { + allProps: map(combineIdAndProp, flatten(cb.getOutputs(getState().paths))), + updatedProps: flatten(map( + ([id, value]) => map( + property => combineIdAndProp({ id, property }), + keys(value) + ), + toPairs(data) + )) + } + }); + } + + if (error !== undefined) { + const outputs = payload + ? map(combineIdAndProp, flatten([payload.outputs])).join(', ') + : output; + let message = `Callback error updating ${outputs}`; + if (clientside_function) { + const { namespace: ns, function_name: fn } = clientside_function; + message += ` via clientside function ${ns}.${fn}`; + } + + handleAsyncError(error, message, dispatch); + + storedCallbacks.push({ + ...cb, + executionMeta: { + allProps: map(combineIdAndProp, flatten(cb.getOutputs(getState().paths))), + updatedProps: [] + } + }); + } + }, executed); + + dispatch(aggregateCallbacks([ + executed.length ? removeExecutedCallbacks(executed) : null, + executed.length ? addCompletedCallbacks(executed.length) : null, + storedCallbacks.length ? addStoredCallbacks(storedCallbacks) : null, + requestedCallbacks.length ? addRequestedCallbacks(requestedCallbacks) : null + ])); + }, + inputs: ['callbacks.executed'] +}; + +export default observer; diff --git a/dash-renderer/src/observers/executingCallbacks.ts b/dash-renderer/src/observers/executingCallbacks.ts new file mode 100644 index 0000000000..ffc92141e4 --- /dev/null +++ b/dash-renderer/src/observers/executingCallbacks.ts @@ -0,0 +1,63 @@ +import { + assoc, + find, + forEach, + partition +} from 'ramda'; + +import { + addExecutedCallbacks, + addWatchedCallbacks, + aggregateCallbacks, + removeExecutingCallbacks, + removeWatchedCallbacks +} from '../actions/callbacks'; + +import { IStoreObserverDefinition } from '../StoreObserver'; +import { IStoreState } from '../store'; + +const observer: IStoreObserverDefinition = { + observer: ({ + dispatch, + getState + }) => { + const { + callbacks: { + executing + } + } = getState(); + + const [deferred, skippedOrReady] = partition(cb => cb.executionPromise instanceof Promise, executing); + + dispatch(aggregateCallbacks([ + executing.length ? removeExecutingCallbacks(executing) : null, + deferred.length ? addWatchedCallbacks(deferred) : null, + skippedOrReady.length ? addExecutedCallbacks(skippedOrReady.map(cb => assoc('executionResult', cb.executionPromise as any, cb))) : null + ])); + + forEach(async cb => { + const result = await cb.executionPromise; + + const { callbacks: { watched } } = getState(); + + // Check if it's been removed from the `watched` list since - on callback completion, another callback may be cancelled + // Find the callback instance or one that matches its promise (eg. could have been pruned) + const currentCb = find(_cb => _cb === cb || _cb.executionPromise === cb.executionPromise, watched); + if (!currentCb) { + return; + } + + // Otherwise move to `executed` and remove from `watched` + dispatch(aggregateCallbacks([ + removeWatchedCallbacks([currentCb]), + addExecutedCallbacks([{ + ...currentCb, + executionResult: result + }]) + ])); + }, deferred); + }, + inputs: ['callbacks.executing'] +}; + +export default observer; diff --git a/dash-renderer/src/observers/isLoading.ts b/dash-renderer/src/observers/isLoading.ts new file mode 100644 index 0000000000..fc625d45a9 --- /dev/null +++ b/dash-renderer/src/observers/isLoading.ts @@ -0,0 +1,28 @@ +import { IStoreObserverDefinition } from '../StoreObserver'; +import { IStoreState } from '../store'; +import { getPendingCallbacks } from '../utils/callbacks'; +import { setIsLoading } from '../actions/isLoading'; + + +const observer: IStoreObserverDefinition = { + observer: ({ + dispatch, + getState + }) => { + const { + callbacks, + isLoading + } = getState(); + + const pendingCallbacks = getPendingCallbacks(callbacks); + + const next = Boolean(pendingCallbacks.length); + + if (isLoading !== next) { + dispatch(setIsLoading(next)); + } + }, + inputs: ['callbacks'] +}; + +export default observer; diff --git a/dash-renderer/src/observers/loadingMap.ts b/dash-renderer/src/observers/loadingMap.ts new file mode 100644 index 0000000000..8242143083 --- /dev/null +++ b/dash-renderer/src/observers/loadingMap.ts @@ -0,0 +1,83 @@ +import { + equals, + flatten, + forEach, + isEmpty, + map, + reduce +} from 'ramda'; + +import { setLoadingMap } from '../actions/loadingMap'; +import { IStoreObserverDefinition } from '../StoreObserver'; +import { IStoreState } from '../store'; +import { ILayoutCallbackProperty } from '../types/callbacks'; + +const observer: IStoreObserverDefinition = { + observer: ({ + dispatch, + getState + }) => { + const { + callbacks: { + executing, + watched, + executed + }, + loadingMap, + paths + } = getState(); + + /* + Get the path of all components impacted by callbacks + with states: executing, watched, executed. + + For each path, keep track of all (id,prop) tuples that + are impacted for this node and nested nodes. + */ + + const loadingPaths: ILayoutCallbackProperty[] = flatten(map( + cb => cb.getOutputs(paths), + [...executing, ...watched, ...executed] + )); + + const nextMap: any = isEmpty(loadingPaths) ? + null : + reduce( + (res, path) => { + let target = res; + const idprop = { + id: path.id, + property: path.property + }; + + // Assign all affected props for this path and nested paths + target.__dashprivate__idprops__ = target.__dashprivate__idprops__ || []; + target.__dashprivate__idprops__.push(idprop); + + forEach(p => { + target = (target[p] = + target[p] ?? + p === 'children' ? [] : {} + ) + + target.__dashprivate__idprops__ = target.__dashprivate__idprops__ || []; + target.__dashprivate__idprops__.push(idprop); + }, path.path); + + // Assign one affected prop for this path + target.__dashprivate__idprop__ = target.__dashprivate__idprop__ || idprop; + + return res; + }, + {} as any, + loadingPaths + ); + + if (!equals(nextMap, loadingMap)) { + dispatch(setLoadingMap(nextMap)); + } + }, + inputs: ['callbacks.executing', 'callbacks.watched', 'callbacks.executed'] +}; + +export default observer; diff --git a/dash-renderer/src/observers/prioritizedCallbacks.ts b/dash-renderer/src/observers/prioritizedCallbacks.ts new file mode 100644 index 0000000000..0b6efeb2ba --- /dev/null +++ b/dash-renderer/src/observers/prioritizedCallbacks.ts @@ -0,0 +1,143 @@ +import { + find, + flatten, + forEach, + map, + partition, + pluck, + sort, + uniq +} from 'ramda'; + +import { IStoreState } from '../store'; + +import { + addBlockedCallbacks, + addExecutingCallbacks, + aggregateCallbacks, + executeCallback, + removeBlockedCallbacks, + removePrioritizedCallbacks +} from '../actions/callbacks'; + +import { stringifyId } from '../actions/dependencies'; + +import { + combineIdAndProp +} from '../actions/dependencies_ts'; + +import isAppReady from '../actions/isAppReady'; + +import { + IBlockedCallback, + ICallback, + ILayoutCallbackProperty, + IPrioritizedCallback +} from '../types/callbacks'; +import { IStoreObserverDefinition } from '../StoreObserver'; + +const sortPriority = (c1: ICallback, c2: ICallback): number => { + return (c1.priority ?? '') > (c2.priority ?? '') ? -1 : 1; +} + +const getStash = (cb: IPrioritizedCallback, paths: any): { + allOutputs: ILayoutCallbackProperty[][], + allPropIds: any[] +} => { + const { getOutputs } = cb; + const allOutputs = getOutputs(paths); + const flatOutputs: any[] = flatten(allOutputs); + const allPropIds: any[] = []; + + const reqOut: any = {}; + flatOutputs.forEach(({ id, property }) => { + const idStr = stringifyId(id); + const idOut = (reqOut[idStr] = reqOut[idStr] || []); + idOut.push(property); + allPropIds.push(combineIdAndProp({ id: idStr, property })); + }); + + return { allOutputs, allPropIds }; +} + +const getIds = (cb: ICallback, paths: any) => uniq(pluck('id', [ + ...flatten(cb.getInputs(paths)), + ...flatten(cb.getState(paths)) +])); + +const observer: IStoreObserverDefinition = { + observer: async ({ + dispatch, + getState + }) => { + const { callbacks: { executing, watched }, config, hooks, layout, paths } = getState(); + let { callbacks: { prioritized } } = getState(); + + const available = Math.max( + 0, + 12 - executing.length - watched.length + ); + + // Order prioritized callbacks based on depth and breadth of callback chain + prioritized = sort(sortPriority, prioritized); + + // Divide between sync and async + const [syncCallbacks, asyncCallbacks] = partition(cb => isAppReady( + layout, + paths, + getIds(cb, paths) + ) === true, prioritized); + + const pickedSyncCallbacks = syncCallbacks.slice(0, available); + const pickedAsyncCallbacks = asyncCallbacks.slice(0, available - pickedSyncCallbacks.length); + + if (pickedSyncCallbacks.length) { + dispatch(aggregateCallbacks([ + removePrioritizedCallbacks(pickedSyncCallbacks), + addExecutingCallbacks(map( + cb => executeCallback(cb, config, hooks, paths, layout, getStash(cb, paths)), + pickedSyncCallbacks + )) + ])); + } + + if (pickedAsyncCallbacks.length) { + const deffered = map( + cb => ({ + ...cb, + ...getStash(cb, paths), + isReady: isAppReady(layout, paths, getIds(cb, paths)) + }), + pickedAsyncCallbacks + ); + + dispatch(aggregateCallbacks([ + removePrioritizedCallbacks(pickedAsyncCallbacks), + addBlockedCallbacks(deffered) + ])); + + forEach(async cb => { + await cb.isReady; + + const { callbacks: { blocked } } = getState(); + + // Check if it's been removed from the `blocked` list since - on callback completion, another callback may be cancelled + // Find the callback instance or one that matches its promise (eg. could have been pruned) + const currentCb = find(_cb => _cb === cb || _cb.isReady === cb.isReady, blocked); + if (!currentCb) { + return; + } + + const executingCallback = executeCallback(cb, config, hooks, paths, layout, cb); + + dispatch(aggregateCallbacks([ + removeBlockedCallbacks([cb]), + addExecutingCallbacks([executingCallback]) + ])); + }, deffered); + } + }, + inputs: ['callbacks.prioritized', 'callbacks.completed'] +}; + +export default observer; diff --git a/dash-renderer/src/observers/requestedCallbacks.ts b/dash-renderer/src/observers/requestedCallbacks.ts new file mode 100644 index 0000000000..1ca30a3ba1 --- /dev/null +++ b/dash-renderer/src/observers/requestedCallbacks.ts @@ -0,0 +1,348 @@ +import { + all, + concat, + difference, + filter, + flatten, + groupBy, + includes, + intersection, + isEmpty, + isNil, + map, + values +} from 'ramda'; + +import { IStoreState } from '../store'; + +import { + aggregateCallbacks, + removeRequestedCallbacks, + removePrioritizedCallbacks, + removeExecutingCallbacks, + removeWatchedCallbacks, + addRequestedCallbacks, + addPrioritizedCallbacks, + addExecutingCallbacks, + addWatchedCallbacks, + removeBlockedCallbacks, + addBlockedCallbacks +} from '../actions/callbacks'; + +import { isMultiValued } from '../actions/dependencies'; + +import { + combineIdAndProp, + getReadyCallbacks, + getUniqueIdentifier, + pruneCallbacks +} from '../actions/dependencies_ts'; + +import { + ICallback, + IExecutingCallback, + IStoredCallback, + IBlockedCallback +} from '../types/callbacks'; + +import { getPendingCallbacks } from '../utils/callbacks'; +import { IStoreObserverDefinition } from '../StoreObserver'; + +const observer: IStoreObserverDefinition = { + observer: ({ + dispatch, + getState + }) => { + const { callbacks, callbacks: { prioritized, blocked, executing, watched, stored }, paths } = getState(); + let { callbacks: { requested } } = getState(); + + const pendingCallbacks = getPendingCallbacks(callbacks); + + /* + 0. Prune circular callbacks that have completed the loop + - cb.callback included in cb.predecessors + */ + const rCirculars = filter( + cb => includes(cb.callback, cb.predecessors ?? []), + requested + ); + + /* + TODO? + Clean up the `requested` list - during the dispatch phase, + circulars will be removed for real + */ + requested = difference(requested, rCirculars); + + /* + 1. Remove duplicated `requested` callbacks - give precedence to newer callbacks over older ones + */ + + /* + Extract all but the first callback from each IOS-key group + these callbacks are duplicates. + */ + const rDuplicates = flatten(map( + group => group.slice(0, -1), + values( + groupBy( + getUniqueIdentifier, + requested + ) + ) + )); + + /* + TODO? + Clean up the `requested` list - during the dispatch phase, + duplicates will be removed for real + */ + requested = difference(requested, rDuplicates); + + /* + 2. Remove duplicated `prioritized`, `executing` and `watching` callbacks + */ + + /* + Extract all but the first callback from each IOS-key group + these callbacks are `prioritized` and duplicates. + */ + const pDuplicates = flatten(map( + group => group.slice(0, -1), + values( + groupBy( + getUniqueIdentifier, + concat(prioritized, requested) + ) + ) + )); + + const bDuplicates = flatten(map( + group => group.slice(0, -1), + values( + groupBy( + getUniqueIdentifier, + concat(blocked, requested) + ) + ) + )) as IBlockedCallback[]; + + const eDuplicates = flatten(map( + group => group.slice(0, -1), + values( + groupBy( + getUniqueIdentifier, + concat(executing, requested) + ) + ) + )) as IExecutingCallback[]; + + const wDuplicates = flatten(map( + group => group.slice(0, -1), + values( + groupBy( + getUniqueIdentifier, + concat(watched, requested) + ) + ) + )) as IExecutingCallback[]; + + /* + 3. Modify or remove callbacks that are outputing to non-existing layout `id`. + */ + + const { added: rAdded, removed: rRemoved } = pruneCallbacks(requested, paths); + const { added: pAdded, removed: pRemoved } = pruneCallbacks(prioritized, paths); + const { added: bAdded, removed: bRemoved } = pruneCallbacks(blocked, paths); + const { added: eAdded, removed: eRemoved } = pruneCallbacks(executing, paths); + const { added: wAdded, removed: wRemoved } = pruneCallbacks(watched, paths); + + /* + TODO? + Clean up the `requested` list - during the dispatch phase, + it will be updated for real + */ + requested = concat( + difference( + requested, + rRemoved + ), + rAdded + ); + + /* + 4. Find `requested` callbacks that do not depend on a outstanding output (as either input or state) + */ + let readyCallbacks = getReadyCallbacks(paths, requested, pendingCallbacks); + + let oldBlocked: ICallback[] = []; + let newBlocked: ICallback[] = []; + + /** + * If there is : + * - no ready callbacks + * - at least one requested callback + * - no additional pending callbacks + * + * can assume: + * - the requested callbacks are part of a circular dependency loop + * + * then recursively: + * - assume the first callback in the list is ready (the entry point for the loop) + * - check what callbacks are blocked / ready with the assumption + * - update the missing predecessors based on assumptions + * - continue until there are no remaining candidates + * + */ + if ( + !readyCallbacks.length && + requested.length && + requested.length === pendingCallbacks.length + ) { + let candidates = requested.slice(0); + + while (candidates.length) { + // Assume 1st callback is ready and + // update candidates / readyCallbacks accordingly + const readyCallback = candidates[0]; + + readyCallbacks.push(readyCallback); + candidates = candidates.slice(1); + + // Remaining candidates are not blocked by current assumptions + candidates = getReadyCallbacks(paths, candidates, readyCallbacks); + + // Blocked requests need to make sure they have the callback as a predecessor + const blockedByAssumptions = difference(candidates, candidates); + + const modified = filter( + cb => !cb.predecessors || !includes(readyCallback.callback, cb.predecessors), + blockedByAssumptions + ); + + oldBlocked = concat(oldBlocked, modified); + newBlocked = concat(newBlocked, modified.map(cb => ({ + ...cb, + predecessors: concat(cb.predecessors ?? [], [readyCallback.callback]) + }))); + } + } + + /* + TODO? + Clean up the `requested` list - during the dispatch phase, + it will be updated for real + */ + requested = concat( + difference( + requested, + oldBlocked + ), + newBlocked + ); + + /* + 5. Prune callbacks that became irrelevant in their `executionGroup` + */ + + // Group by executionGroup, drop non-executionGroup callbacks + // those were not triggered by layout changes and don't have "strong" interdependency for + // callback chain completion + const pendingGroups = groupBy( + cb => cb.executionGroup as any, + filter(cb => !isNil(cb.executionGroup), stored) + ); + + const dropped: ICallback[] = filter(cb => { + // If there is no `stored` callback for the group, no outputs were dropped -> `cb` is kept + if (!cb.executionGroup || !pendingGroups[cb.executionGroup] || !pendingGroups[cb.executionGroup].length) { + return false; + } + + // Get all intputs for `cb` + const inputs = map(combineIdAndProp, flatten(cb.getInputs(paths))); + + // Get all the potentially updated props for the group so far + const allProps = flatten(map( + gcb => gcb.executionMeta.allProps, + pendingGroups[cb.executionGroup] + )); + + // Get all the updated props for the group so far + const updated = flatten(map( + gcb => gcb.executionMeta.updatedProps, + pendingGroups[cb.executionGroup] + )); + + // If there's no overlap between the updated props and the inputs, + // + there's no props that aren't covered by the potentially updated props, + // and not all inputs are multi valued + // -> drop `cb` + const res = + isEmpty(intersection( + inputs, + updated + )) && + isEmpty(difference( + inputs, + allProps + )) + && !all( + isMultiValued, + cb.callback.inputs + ); + + return res; + }, + readyCallbacks + ); + + /* + TODO? + Clean up the `requested` list - during the dispatch phase, + it will be updated for real + */ + requested = difference( + requested, + dropped + ); + + readyCallbacks = difference( + readyCallbacks, + dropped + ); + + dispatch(aggregateCallbacks([ + // Clean up duplicated callbacks + rDuplicates.length ? removeRequestedCallbacks(rDuplicates) : null, + pDuplicates.length ? removePrioritizedCallbacks(pDuplicates) : null, + bDuplicates.length ? removeBlockedCallbacks(bDuplicates) : null, + eDuplicates.length ? removeExecutingCallbacks(eDuplicates) : null, + wDuplicates.length ? removeWatchedCallbacks(wDuplicates) : null, + // Prune callbacks + rRemoved.length ? removeRequestedCallbacks(rRemoved) : null, + rAdded.length ? addRequestedCallbacks(rAdded) : null, + pRemoved.length ? removePrioritizedCallbacks(pRemoved) : null, + pAdded.length ? addPrioritizedCallbacks(pAdded) : null, + bRemoved.length ? removeBlockedCallbacks(bRemoved) : null, + bAdded.length ? addBlockedCallbacks(bAdded) : null, + eRemoved.length ? removeExecutingCallbacks(eRemoved) : null, + eAdded.length ? addExecutingCallbacks(eAdded) : null, + wRemoved.length ? removeWatchedCallbacks(wRemoved) : null, + wAdded.length ? addWatchedCallbacks(wAdded) : null, + // Prune circular callbacks + rCirculars.length ? removeRequestedCallbacks(rCirculars) : null, + // Prune circular assumptions + oldBlocked.length ? removeRequestedCallbacks(oldBlocked) : null, + newBlocked.length ? addRequestedCallbacks(newBlocked) : null, + // Drop non-triggered initial callbacks + dropped.length ? removeRequestedCallbacks(dropped) : null, + // Promote callbacks + readyCallbacks.length ? removeRequestedCallbacks(readyCallbacks) : null, + readyCallbacks.length ? addPrioritizedCallbacks(readyCallbacks) : null + ])); + }, + inputs: ['callbacks.requested', 'callbacks.completed'] +}; + +export default observer; diff --git a/dash-renderer/src/observers/storedCallbacks.ts b/dash-renderer/src/observers/storedCallbacks.ts new file mode 100644 index 0000000000..83b19518b9 --- /dev/null +++ b/dash-renderer/src/observers/storedCallbacks.ts @@ -0,0 +1,69 @@ +import { + concat, + filter, + groupBy, + isNil, + partition, + reduce, + toPairs +} from 'ramda'; + +import { IStoreState } from '../store'; + +import { + aggregateCallbacks, + removeStoredCallbacks +} from '../actions/callbacks'; + +import { + ICallback, + IStoredCallback +} from '../types/callbacks'; + +import { getPendingCallbacks } from '../utils/callbacks'; +import { IStoreObserverDefinition } from '../StoreObserver'; + +const observer: IStoreObserverDefinition = { + observer: ({ + dispatch, + getState + }) => { + const { callbacks } = getState(); + const pendingCallbacks = getPendingCallbacks(callbacks); + + let { callbacks: { stored } } = getState(); + + const [nullGroupCallbacks, groupCallbacks] = partition( + cb => isNil(cb.executionGroup), + stored + ); + + const executionGroups = groupBy( + cb => cb.executionGroup as any, + groupCallbacks + ) + + const pendingGroups = groupBy( + cb => cb.executionGroup as any, + filter(cb => !isNil(cb.executionGroup), pendingCallbacks) + ); + + let dropped = reduce((res, [ + executionGroup, + executionGroupCallbacks + ]) => !pendingGroups[executionGroup] ? + concat(res, executionGroupCallbacks) : + res, + [] as IStoredCallback[], + toPairs(executionGroups) + ); + + dispatch(aggregateCallbacks([ + nullGroupCallbacks.length ? removeStoredCallbacks(nullGroupCallbacks) : null, + dropped.length ? removeStoredCallbacks(dropped) : null + ])); + }, + inputs: ['callbacks.stored', 'callbacks.completed'] +}; + +export default observer; diff --git a/dash-renderer/src/reducers/callbacks.ts b/dash-renderer/src/reducers/callbacks.ts new file mode 100644 index 0000000000..81d24e65ab --- /dev/null +++ b/dash-renderer/src/reducers/callbacks.ts @@ -0,0 +1,154 @@ +import { + concat, + difference, + reduce +} from 'ramda'; + +import { + ICallback, + IExecutedCallback, + IExecutingCallback, + IStoredCallback, + IPrioritizedCallback, + IBlockedCallback, + IWatchedCallback +} from '../types/callbacks'; + +export enum CallbackActionType { + AddBlocked = 'Callbacks.AddBlocked', + AddExecuted = 'Callbacks.AddExecuted', + AddExecuting = 'Callbacks.AddExecuting', + AddPrioritized = 'Callbacks.AddPrioritized', + AddRequested = 'Callbacks.AddRequested', + AddStored = 'Callbacks.AddStored', + AddWatched = 'Callbacks.AddWatched', + RemoveBlocked = 'Callbacks.RemoveBlocked', + RemoveExecuted = 'Callbacks.RemoveExecuted', + RemoveExecuting = 'Callbacks.RemoveExecuting', + RemovePrioritized = 'Callbacks.ReomvePrioritized', + RemoveRequested = 'Callbacks.RemoveRequested', + RemoveStored = 'Callbacks.RemoveStored', + RemoveWatched = 'Callbacks.RemoveWatched' +} + +export enum CallbackAggregateActionType { + AddCompleted = 'Callbacks.Completed', + Aggregate = 'Callbacks.Aggregate' +} + +export interface IAggregateAction { + type: CallbackAggregateActionType.Aggregate, + payload: (ICallbackAction | ICompletedAction | null)[] +} + +export interface ICallbackAction { + type: CallbackActionType; + payload: ICallback[]; +} + +export interface ICompletedAction { + type: CallbackAggregateActionType.AddCompleted, + payload: number +} + +type CallbackAction = + IAggregateAction | + ICallbackAction | + ICompletedAction; + +export interface ICallbacksState { + requested: ICallback[]; + prioritized: IPrioritizedCallback[]; + blocked: IBlockedCallback[]; + executing: IExecutingCallback[]; + watched: IWatchedCallback[]; + executed: IExecutedCallback[]; + stored: IStoredCallback[]; + completed: number; +} + +const DEFAULT_STATE: ICallbacksState = { + blocked: [], + executed: [], + executing: [], + prioritized: [], + requested: [], + stored: [], + watched: [], + completed: 0 +}; + +const transforms: { + [key: string]: (a1: ICallback[], a2: ICallback[]) => ICallback[] +} = { + [CallbackActionType.AddBlocked]: concat, + [CallbackActionType.AddExecuted]: concat, + [CallbackActionType.AddExecuting]: concat, + [CallbackActionType.AddPrioritized]: concat, + [CallbackActionType.AddRequested]: concat, + [CallbackActionType.AddStored]: concat, + [CallbackActionType.AddWatched]: concat, + [CallbackActionType.RemoveBlocked]: difference, + [CallbackActionType.RemoveExecuted]: difference, + [CallbackActionType.RemoveExecuting]: difference, + [CallbackActionType.RemovePrioritized]: difference, + [CallbackActionType.RemoveRequested]: difference, + [CallbackActionType.RemoveStored]: difference, + [CallbackActionType.RemoveWatched]: difference +}; + +const fields: { + [key: string]: keyof Omit +} = { + [CallbackActionType.AddBlocked]: 'blocked', + [CallbackActionType.AddExecuted]: 'executed', + [CallbackActionType.AddExecuting]: 'executing', + [CallbackActionType.AddPrioritized]: 'prioritized', + [CallbackActionType.AddRequested]: 'requested', + [CallbackActionType.AddStored]: 'stored', + [CallbackActionType.AddWatched]: 'watched', + [CallbackActionType.RemoveBlocked]: 'blocked', + [CallbackActionType.RemoveExecuted]: 'executed', + [CallbackActionType.RemoveExecuting]: 'executing', + [CallbackActionType.RemovePrioritized]: 'prioritized', + [CallbackActionType.RemoveRequested]: 'requested', + [CallbackActionType.RemoveStored]: 'stored', + [CallbackActionType.RemoveWatched]: 'watched' +} + +const mutateCompleted = ( + state: ICallbacksState, + action: ICompletedAction +) => ({ ...state, completed: state.completed + action.payload }); + +const mutateCallbacks = ( + state: ICallbacksState, + action: ICallbackAction +) => { + const transform = transforms[action.type]; + const field = fields[action.type]; + + return (!transform || !field || action.payload.length === 0) ? + state : { + ...state, + [field]: transform(state[field], action.payload) + }; +} + + + +export default ( + state: ICallbacksState = DEFAULT_STATE, + action: CallbackAction +) => reduce((s, a) => { + if (a === null) { + return s; + } else if (a.type === CallbackAggregateActionType.AddCompleted) { + return mutateCompleted(s, a); + } else { + return mutateCallbacks(s, a); + } +}, state, action.type === CallbackAggregateActionType.Aggregate ? + action.payload : + [action] +); diff --git a/dash-renderer/src/reducers/isLoading.ts b/dash-renderer/src/reducers/isLoading.ts new file mode 100644 index 0000000000..0a25260551 --- /dev/null +++ b/dash-renderer/src/reducers/isLoading.ts @@ -0,0 +1,22 @@ +export enum IsLoadingActionType { + Set = 'IsLoading.Set' +} + +export interface ILoadingMapAction { + type: IsLoadingActionType.Set; + payload: any; +} + +type IsLoadingState = boolean; +export { + IsLoadingState +}; + +const DEFAULT_STATE: IsLoadingState = true; + +export default ( + state: IsLoadingState = DEFAULT_STATE, + action: ILoadingMapAction +) => action.type === IsLoadingActionType.Set ? + action.payload : + state; diff --git a/dash-renderer/src/reducers/loadingMap.ts b/dash-renderer/src/reducers/loadingMap.ts new file mode 100644 index 0000000000..1fb31a208d --- /dev/null +++ b/dash-renderer/src/reducers/loadingMap.ts @@ -0,0 +1,22 @@ +export enum LoadingMapActionType { + Set = 'LoadingMap.Set' +} + +export interface ILoadingMapAction { + type: LoadingMapActionType.Set; + payload: any; +} + +type LoadingMapState = any; +export { + LoadingMapState +}; + +const DEFAULT_STATE: LoadingMapState = {}; + +export default ( + state: LoadingMapState = DEFAULT_STATE, + action: ILoadingMapAction +) => action.type === LoadingMapActionType.Set ? + action.payload : + state; diff --git a/dash-renderer/src/reducers/pendingCallbacks.js b/dash-renderer/src/reducers/pendingCallbacks.js deleted file mode 100644 index 70a2cd3f86..0000000000 --- a/dash-renderer/src/reducers/pendingCallbacks.js +++ /dev/null @@ -1,11 +0,0 @@ -const pendingCallbacks = (state = [], action) => { - switch (action.type) { - case 'SET_PENDING_CALLBACKS': - return action.payload; - - default: - return state; - } -}; - -export default pendingCallbacks; diff --git a/dash-renderer/src/reducers/reducer.js b/dash-renderer/src/reducers/reducer.js index ffdb8794fa..d238b75225 100644 --- a/dash-renderer/src/reducers/reducer.js +++ b/dash-renderer/src/reducers/reducer.js @@ -1,18 +1,20 @@ import {forEach, isEmpty, keys, path} from 'ramda'; import {combineReducers} from 'redux'; -import {getCallbacksByInput} from '../actions/dependencies'; +import {getCallbacksByInput} from '../actions/dependencies_ts'; -import layout from './layout'; -import graphs from './dependencyGraph'; -import paths from './paths'; -import pendingCallbacks from './pendingCallbacks'; +import createApiReducer from './api'; import appLifecycle from './appLifecycle'; -import history from './history'; +import callbacks from './callbacks'; +import config from './config'; +import graphs from './dependencyGraph'; import error from './error'; +import history from './history'; import hooks from './hooks'; -import createApiReducer from './api'; -import config from './config'; +import isLoading from './isLoading'; +import layout from './layout'; +import loadingMap from './loadingMap'; +import paths from './paths'; export const apiRequests = [ 'dependenciesRequest', @@ -24,14 +26,16 @@ export const apiRequests = [ function mainReducer() { const parts = { appLifecycle, - layout, - graphs, - paths, - pendingCallbacks, + callbacks, config, - history, error, + graphs, + history, hooks, + isLoading, + layout, + loadingMap, + paths, }; forEach(r => { parts[r] = createApiReducer(r); diff --git a/dash-renderer/src/store.js b/dash-renderer/src/store.js deleted file mode 100644 index fc9fa7f465..0000000000 --- a/dash-renderer/src/store.js +++ /dev/null @@ -1,52 +0,0 @@ -import {createStore, applyMiddleware} from 'redux'; -import thunk from 'redux-thunk'; -import {createReducer} from './reducers/reducer'; - -let store; - -/** - * Initialize a Redux store with thunk, plus logging (only in development mode) middleware - * - * @param {bool} reset: discard any previous store - * - * @returns {Store} - * An initialized redux store with middleware and possible hot reloading of reducers - */ -const initializeStore = reset => { - if (store && !reset) { - return store; - } - - const reducer = createReducer(); - - // eslint-disable-next-line no-process-env - if (process.env.NODE_ENV === 'production') { - store = createStore(reducer, applyMiddleware(thunk)); - } else { - // only attach logger to middleware in non-production mode - const reduxDTEC = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__; - if (reduxDTEC) { - store = createStore(reducer, reduxDTEC(applyMiddleware(thunk))); - } else { - store = createStore(reducer, applyMiddleware(thunk)); - } - } - - if (!reset) { - // TODO - Protect this under a debug mode? - window.store = store; - } - - if (module.hot) { - // Enable hot module replacement for reducers - module.hot.accept('./reducers/reducer', () => { - const nextRootReducer = require('./reducers/reducer').createReducer(); - - store.replaceReducer(nextRootReducer); - }); - } - - return store; -}; - -export default initializeStore; diff --git a/dash-renderer/src/store.ts b/dash-renderer/src/store.ts new file mode 100644 index 0000000000..3b26f75a92 --- /dev/null +++ b/dash-renderer/src/store.ts @@ -0,0 +1,96 @@ +import { once } from 'ramda'; +import { createStore, applyMiddleware, Store, Observer } from 'redux'; +import thunk from 'redux-thunk'; +import {createReducer} from './reducers/reducer'; +import StoreObserver from './StoreObserver'; +import { ICallbacksState } from './reducers/callbacks'; +import { LoadingMapState } from './reducers/loadingMap'; +import { IsLoadingState } from './reducers/isLoading'; + +import executedCallbacks from './observers/executedCallbacks'; +import executingCallbacks from './observers/executingCallbacks'; +import isLoading from './observers/isLoading' +import loadingMap from './observers/loadingMap'; +import prioritizedCallbacks from './observers/prioritizedCallbacks'; +import requestedCallbacks from './observers/requestedCallbacks'; +import storedCallbacks from './observers/storedCallbacks'; + +export interface IStoreObserver { + observer: Observer>; + inputs: string[]; +} + +export interface IStoreState { + callbacks: ICallbacksState; + isLoading: IsLoadingState; + loadingMap: LoadingMapState; + [key: string]: any; +} + +let store: Store; +const storeObserver = new StoreObserver(); + +const setObservers = once(() => { + const observe = storeObserver.observe; + + observe(isLoading); + observe(loadingMap); + observe(requestedCallbacks); + observe(prioritizedCallbacks); + observe(executingCallbacks); + observe(executedCallbacks); + observe(storedCallbacks); +}); + +function createAppStore(reducer: any, middleware: any) { + store = createStore(reducer, middleware); + storeObserver.setStore(store); + setObservers(); +} + +/** + * Initialize a Redux store with thunk, plus logging (only in development mode) middleware + * + * @param {bool} reset: discard any previous store + * + * @returns {Store} + * An initialized redux store with middleware and possible hot reloading of reducers + */ +const initializeStore = (reset?: boolean): Store => { + if (store && !reset) { + return store; + } + + const reducer = createReducer(); + + // eslint-disable-next-line no-process-env + if (process.env.NODE_ENV === 'production') { + createAppStore(reducer, applyMiddleware(thunk)); + } else { + // only attach logger to middleware in non-production mode + const reduxDTEC = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__; + if (reduxDTEC) { + createAppStore(reducer, reduxDTEC(applyMiddleware(thunk))); + } else { + createAppStore(reducer, applyMiddleware(thunk)); + } + } + + if (!reset) { + // TODO - Protect this under a debug mode? + (window as any).store = store; + } + + if ((module as any).hot) { + // Enable hot module replacement for reducers + (module as any).hot.accept('./reducers/reducer', () => { + const nextRootReducer = require('./reducers/reducer').createReducer(); + + store.replaceReducer(nextRootReducer); + }); + } + + return store; +}; + +export default initializeStore; diff --git a/dash-renderer/src/types/callbacks.ts b/dash-renderer/src/types/callbacks.ts new file mode 100644 index 0000000000..14882607d5 --- /dev/null +++ b/dash-renderer/src/types/callbacks.ts @@ -0,0 +1,85 @@ +type CallbackId = string | { [key: string]: any } + +export interface ICallbackDefinition { + clientside_function?: { + namespace: string; + function_name: string; + }; + input: string; + inputs: ICallbackProperty[]; + output: string; + outputs: ICallbackProperty[]; + prevent_initial_call: boolean; + state: ICallbackProperty[]; +} + +export interface ICallbackProperty { + id: CallbackId; + property: string; +} + +export interface ILayoutCallbackProperty extends ICallbackProperty { + path: (string | number)[]; +} + +export interface ICallbackTemplate { + anyVals: any[] | string; + callback: ICallbackDefinition; + changedPropIds: any; + executionGroup?: string; + initialCall: boolean; + getInputs: (paths: any) => ILayoutCallbackProperty[][]; + getOutputs: (paths: any) => ILayoutCallbackProperty[][]; + getState: (paths: any) => ILayoutCallbackProperty[][]; + resolvedId: any; +} + +export interface ICallback extends ICallbackTemplate { + predecessors?: ICallbackDefinition[]; + priority?: string; +} + +// tslint:disable-next-line:no-empty-interface +export interface IPrioritizedCallback extends ICallback { + +} + +export interface IBlockedCallback extends IPrioritizedCallback { + allOutputs: ILayoutCallbackProperty[][]; + allPropIds: any[]; + isReady: Promise | true; +} + +export interface IExecutingCallback extends IPrioritizedCallback { + executionPromise: Promise | CallbackResult | null; +} + +// tslint:disable-next-line:no-empty-interface +export interface IWatchedCallback extends IExecutingCallback { + +} + +export interface IExecutedCallback extends IWatchedCallback { + executionResult: CallbackResult | null; +} + +export interface IStoredCallback extends IExecutedCallback { + executionMeta: { + allProps: string[]; + updatedProps: string[]; + } +} + +export interface ICallbackPayload { + changedPropIds: any[]; + inputs: any[]; + output: string; + outputs: any[]; + state?: any[] | null; +} + +export type CallbackResult = { + data?: any; + error?: Error; + payload: ICallbackPayload | null; +} diff --git a/dash-renderer/src/utils/TreeContainer.ts b/dash-renderer/src/utils/TreeContainer.ts new file mode 100644 index 0000000000..cf9df689c1 --- /dev/null +++ b/dash-renderer/src/utils/TreeContainer.ts @@ -0,0 +1,78 @@ +import { path, type, has } from 'ramda'; + +import Registry from '../registry'; +import { stringifyId } from '../actions/dependencies'; + +function isLoadingComponent(layout: any) { + validateComponent(layout); + return (Registry.resolve(layout) as any)._dashprivate_isLoadingComponent; +} + +const NULL_LOADING_STATE = false; + +export function getLoadingState(componentLayout: any, componentPath: any, loadingMap: any) { + if (!loadingMap) { + return NULL_LOADING_STATE; + } + + const loadingFragment: any = path(componentPath, loadingMap); + // Component and children are not loading if there's no loading fragment + // for the component's path in the layout. + if (!loadingFragment) { + return NULL_LOADING_STATE; + } + + const idprop: any = loadingFragment.__dashprivate__idprop__; + if (idprop) { + return { + is_loading: true, + prop_name: idprop.property, + component_name: stringifyId(idprop.id) + }; + } + + const idprops: any = loadingFragment.__dashprivate__idprops__?.[0]; + if (idprops && isLoadingComponent(componentLayout)) { + return { + is_loading: true, + prop_name: idprops.property, + component_name: stringifyId(idprops.id) + }; + } + + return NULL_LOADING_STATE; +} + +export const getLoadingHash = ( + componentPath: any, + loadingMap: any +) => ( + ((loadingMap && (path(componentPath, loadingMap) as any)?.__dashprivate__idprops__) ?? []) as any[] +).map(({ id, property }) => `${id}.${property}`).join(','); + +export function validateComponent(componentDefinition: any) { + if (type(componentDefinition) === 'Array') { + throw new Error( + 'The children property of a component is a list of lists, instead ' + + 'of just a list. ' + + 'Check the component that has the following contents, ' + + 'and remove one of the levels of nesting: \n' + + JSON.stringify(componentDefinition, null, 2) + ); + } + if ( + type(componentDefinition) === 'Object' && + !( + has('namespace', componentDefinition) && + has('type', componentDefinition) && + has('props', componentDefinition) + ) + ) { + throw new Error( + 'An object was provided as `children` instead of a component, ' + + 'string, or number (or list of those). ' + + 'Check the children property that looks something like:\n' + + JSON.stringify(componentDefinition, null, 2) + ); + } +} diff --git a/dash-renderer/src/utils/callbacks.ts b/dash-renderer/src/utils/callbacks.ts new file mode 100644 index 0000000000..14befb6e7c --- /dev/null +++ b/dash-renderer/src/utils/callbacks.ts @@ -0,0 +1,8 @@ +import { omit, values } from 'ramda'; + +import { ICallbacksState } from '../reducers/callbacks'; +import { ICallback } from '../types/callbacks'; + +export const getPendingCallbacks = (state: ICallbacksState) => Array().concat( + ...values(omit(['stored', 'completed'], state)) +); diff --git a/dash-renderer/tsconfig.json b/dash-renderer/tsconfig.json new file mode 100644 index 0000000000..ca13e15e1c --- /dev/null +++ b/dash-renderer/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "allowJs": true, + "allowSyntheticDefaultImports": true, + "emitDecoratorMetadata": false, + "experimentalDecorators": true, + "jsx": "react", + "lib": ["esnext", "dom", "es2018.promise"], + "module": "esnext", + "moduleResolution": "node", + "noImplicitAny": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "sourceMap": false, + "strict": true, + "strictBindCallApply": true, + "strictNullChecks": true, + "strictPropertyInitialization": true, + "target": "esnext", + "traceResolution": false + }, + "include": [ + "src/*" + ] +} \ No newline at end of file diff --git a/dash-renderer/tslint.json b/dash-renderer/tslint.json new file mode 100644 index 0000000000..600dce2dfc --- /dev/null +++ b/dash-renderer/tslint.json @@ -0,0 +1,57 @@ +{ + "defaultSeverity": "error", + "extends": [ + "tslint:recommended" + ], + "linterOptions": { + "exclude": [ + ".config/**", + "cypress/**", + "inst/**", + "node_modules/**", + "@Types/**", + "venv/**", + "**/*.js" + ] + }, + "rules": { + "array-type": false, + "arrow-parens": [true, "ban-single-arg-parens"], + "ban-types": false, + "eofline": true, + "max-classes-per-file": false, + "max-line-length": false, + "member-access": false, + "member-ordering": false, + "no-conditional-assignment": false, + "no-console": false, + "no-empty": false, + "no-unused-expression": [true, "allow-new"], + "object-literal-key-quotes": [true, "as-needed"], + "object-literal-sort-keys": false, + "object-literal-shorthand": false, + "one-line": [true, + "check-catch", + "check-finally", + "check-else", + "check-whitespace" + ], + "only-arrow-functions": [ + true, + "allow-declarations", + "allow-named-functions" + ], + "ordered-imports": false, + "prefer-const": false, + "prefer-for-of": false, + "quotemark": [true, "single"], + "space-before-function-paren": [false, "always"], + "trailing-comma": [true, { + "singleline": "never", + "multiline": "never" + }], + "unified-signatures": false, + "variable-name": false + }, + "rulesDirectory": [] +} diff --git a/dash-renderer/webpack.config.js b/dash-renderer/webpack.config.js index 7df554cd08..ae4ac37a5e 100644 --- a/dash-renderer/webpack.config.js +++ b/dash-renderer/webpack.config.js @@ -15,6 +15,11 @@ const defaults = { loader: 'babel-loader', }, }, + { + test: /\.ts(x?)$/, + exclude: /node_modules/, + use: ['babel-loader', 'ts-loader'], + }, { test: /\.css$/, use: ['style-loader', 'css-loader'], @@ -24,6 +29,9 @@ const defaults = { use: ['@svgr/webpack'], } ] + }, + resolve: { + extensions: ['.js', '.ts', '.tsx'] } }; diff --git a/dash/dash.py b/dash/dash.py index 577ad51607..75c7ba6436 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -1605,8 +1605,12 @@ def verify_url_part(served_part, url_part, part_name): display_url = (protocol, host, ":{}".format(port), path) self.logger.info("Dash is running on %s://%s%s%s\n", *display_url) - self.logger.info(" Warning: This is a development server. Do not use app.run_server") - self.logger.info(" in production, use a production WSGI server like gunicorn instead.\n") + self.logger.info( + " Warning: This is a development server. Do not use app.run_server" + ) + self.logger.info( + " in production, use a production WSGI server like gunicorn instead.\n" + ) if not os.environ.get("FLASK_ENV"): os.environ["FLASK_ENV"] = "development" diff --git a/dash/testing/dash_page.py b/dash/testing/dash_page.py index 63b30d407a..caedb9b5e7 100644 --- a/dash/testing/dash_page.py +++ b/dash/testing/dash_page.py @@ -36,13 +36,33 @@ def redux_state_paths(self): def redux_state_rqs(self): return self.driver.execute_script( """ - return window.store.getState().pendingCallbacks.map(function(cb) { - var out = {}; - for (var key in cb) { - if (typeof cb[key] !== 'function') { out[key] = cb[key]; } - } - return out; - }) + + // Check for legacy `pendingCallbacks` store prop (compatibility for Dash matrix testing) + var pendingCallbacks = window.store.getState().pendingCallbacks; + if (pendingCallbacks) { + return pendingCallbacks.map(function(cb) { + var out = {}; + for (var key in cb) { + if (typeof cb[key] !== 'function') { out[key] = cb[key]; } + } + return out; + }); + } + + // Otherwise, use the new `callbacks` store prop + var callbacksState = Object.assign({}, window.store.getState().callbacks); + delete callbacksState.stored; + delete callbacksState.completed; + + return Array.prototype.concat.apply([], Object.values(callbacksState)); + """ + ) + + @property + def redux_state_is_loading(self): + return self.driver.execute_script( + """ + return window.store.getState().isLoading; """ ) @@ -51,7 +71,7 @@ def window_store(self): return self.driver.execute_script("return window.store") def _wait_for_callbacks(self): - return not self.window_store or self.redux_state_rqs == [] + return not self.window_store or self.redux_state_rqs def get_local_storage(self, store_id="local"): return self.driver.execute_script( diff --git a/tests/integration/callbacks/test_basic_callback.py b/tests/integration/callbacks/test_basic_callback.py index 3d84d7a07f..f7f37c2f47 100644 --- a/tests/integration/callbacks/test_basic_callback.py +++ b/tests/integration/callbacks/test_basic_callback.py @@ -41,7 +41,7 @@ def update_output(value): assert call_count.value == 2 + len("hello world"), "initial count + each key stroke" - assert dash_duo.redux_state_rqs == [] + assert not dash_duo.redux_state_is_loading assert dash_duo.get_logs() == [] @@ -133,7 +133,7 @@ def update_input(value): "#sub-output-1", pad_input.attrs["value"] + "deadbeef" ) - assert dash_duo.redux_state_rqs == [], "pendingCallbacks is empty" + assert not dash_duo.redux_state_is_loading, "loadingMap is empty" dash_duo.percy_snapshot(name="callback-generating-function-2") assert dash_duo.get_logs() == [], "console is clean" diff --git a/tests/integration/callbacks/test_callback_context.py b/tests/integration/callbacks/test_callback_context.py index bddca9c6de..f4f4552c4c 100644 --- a/tests/integration/callbacks/test_callback_context.py +++ b/tests/integration/callbacks/test_callback_context.py @@ -17,7 +17,7 @@ def test_cbcx001_modified_response(dash_duo): @app.callback(Output("output", "children"), [Input("input", "value")]) def update_output(value): - callback_context.response.set_cookie("dash cookie", value + " - cookie") + callback_context.response.set_cookie("dash_cookie", value + " - cookie") return value + " - output" dash_duo.start_server(app) @@ -27,7 +27,7 @@ def update_output(value): input1.send_keys("cd") dash_duo.wait_for_text_to_equal("#output", "abcd - output") - cookie = dash_duo.driver.get_cookie("dash cookie") + cookie = dash_duo.driver.get_cookie("dash_cookie") # cookie gets json encoded assert cookie["value"] == '"abcd - cookie"' diff --git a/tests/integration/callbacks/test_layout_paths_with_callbacks.py b/tests/integration/callbacks/test_layout_paths_with_callbacks.py index 80656d5b5f..163aa01e7e 100644 --- a/tests/integration/callbacks/test_layout_paths_with_callbacks.py +++ b/tests/integration/callbacks/test_layout_paths_with_callbacks.py @@ -176,7 +176,7 @@ def check_chapter(chapter): TIMEOUT, ) - assert dash_duo.redux_state_rqs == [], "pendingCallbacks is empty" + assert not dash_duo.redux_state_is_loading, "loadingMap is empty" def check_call_counts(chapters, count): for chapter in chapters: diff --git a/tests/integration/callbacks/test_missing_inputs.py b/tests/integration/callbacks/test_missing_inputs.py index 2cfffb8ecc..8ee1df8bb2 100644 --- a/tests/integration/callbacks/test_missing_inputs.py +++ b/tests/integration/callbacks/test_missing_inputs.py @@ -9,7 +9,7 @@ def wait_for_queue(dash_duo): # mostly for cases where no callbacks should fire: # just wait until we have the button and the queue is empty dash_duo.wait_for_text_to_equal("#btn", "click") - wait.until(lambda: dash_duo.redux_state_rqs == [], 3) + wait.until(lambda: not dash_duo.redux_state_is_loading, 3) def test_cbmi001_all_missing_inputs(dash_duo): diff --git a/tests/integration/callbacks/test_multiple_callbacks.py b/tests/integration/callbacks/test_multiple_callbacks.py index d9b13503a0..8c081d2cad 100644 --- a/tests/integration/callbacks/test_multiple_callbacks.py +++ b/tests/integration/callbacks/test_multiple_callbacks.py @@ -32,7 +32,7 @@ def update_output(n_clicks): assert call_count.value == 4, "get called 4 times" assert dash_duo.find_element("#output").text == "3", "clicked button 3 times" - assert dash_duo.redux_state_rqs == [] + assert not dash_duo.redux_state_is_loading dash_duo.percy_snapshot( name="test_callbacks_called_multiple_times_and_out_of_order" diff --git a/tests/integration/callbacks/test_wildcards.py b/tests/integration/callbacks/test_wildcards.py index 2dbfeb368a..2cd14954a2 100644 --- a/tests/integration/callbacks/test_wildcards.py +++ b/tests/integration/callbacks/test_wildcards.py @@ -6,6 +6,7 @@ import dash_html_components as html import dash_core_components as dcc import dash +from dash.testing import wait from dash.dependencies import Input, Output, State, ALL, ALLSMALLER, MATCH @@ -229,7 +230,17 @@ def assert_item(item, text, done, prefix="", suffix=""): assert_count(0) +fibonacci_count = 0 +fibonacci_sum_count = 0 + + def fibonacci_app(clientside): + global fibonacci_count + global fibonacci_sum_count + + fibonacci_count = 0 + fibonacci_sum_count = 0 + # This app tests 2 things in particular: # - clientside callbacks work the same as server-side # - callbacks using ALLSMALLER as an input to MATCH of the exact same id/prop @@ -275,12 +286,20 @@ def items(n): Output({"i": MATCH}, "children"), [Input({"i": ALLSMALLER}, "children")] ) def sequence(prev): + global fibonacci_count + fibonacci_count = fibonacci_count + 1 + print(fibonacci_count) + if len(prev) < 2: return len(prev) return int(prev[-1] or 0) + int(prev[-2] or 0) @app.callback(Output("sum", "children"), [Input({"i": ALL}, "children")]) def show_sum(seq): + global fibonacci_sum_count + fibonacci_sum_count = fibonacci_sum_count + 1 + print("fibonacci_sum_count: ", fibonacci_sum_count) + return "{} elements, sum: {}".format( len(seq), sum(int(v or 0) for v in seq) ) @@ -454,3 +473,46 @@ def update_output_on_page_pattern(value): trigger_text = 'triggered is Truthy with prop_ids {"index":1,"type":"input"}.value' dash_duo.wait_for_text_to_equal("#output-outer", trigger_text) dash_duo.wait_for_text_to_equal("#output-inner", trigger_text) + + +def test_cbwc005_callbacks_count(dash_duo): + global fibonacci_count + global fibonacci_sum_count + + app = fibonacci_app(False) + dash_duo.start_server(app) + + wait.until(lambda: fibonacci_count == 4, 3) # initial + wait.until(lambda: fibonacci_sum_count == 2, 3) # initial + triggered + + dash_duo.find_element("#n").send_keys(Keys.UP) # 5 + wait.until(lambda: fibonacci_count == 9, 3) + wait.until(lambda: fibonacci_sum_count == 3, 3) + + dash_duo.find_element("#n").send_keys(Keys.UP) # 6 + wait.until(lambda: fibonacci_count == 15, 3) + wait.until(lambda: fibonacci_sum_count == 4, 3) + + dash_duo.find_element("#n").send_keys(Keys.DOWN) # 5 + wait.until(lambda: fibonacci_count == 20, 3) + wait.until(lambda: fibonacci_sum_count == 5, 3) + + dash_duo.find_element("#n").send_keys(Keys.DOWN) # 4 + wait.until(lambda: fibonacci_count == 24, 3) + wait.until(lambda: fibonacci_sum_count == 6, 3) + + dash_duo.find_element("#n").send_keys(Keys.DOWN) # 3 + wait.until(lambda: fibonacci_count == 27, 3) + wait.until(lambda: fibonacci_sum_count == 7, 3) + + dash_duo.find_element("#n").send_keys(Keys.DOWN) # 2 + wait.until(lambda: fibonacci_count == 29, 3) + wait.until(lambda: fibonacci_sum_count == 8, 3) + + dash_duo.find_element("#n").send_keys(Keys.DOWN) # 1 + wait.until(lambda: fibonacci_count == 30, 3) + wait.until(lambda: fibonacci_sum_count == 9, 3) + + dash_duo.find_element("#n").send_keys(Keys.DOWN) # 0 + wait.until(lambda: fibonacci_count == 30, 3) + wait.until(lambda: fibonacci_sum_count == 10, 3) diff --git a/tests/integration/renderer/test_dependencies.py b/tests/integration/renderer/test_dependencies.py index 6213d71f7b..b29413d1d6 100644 --- a/tests/integration/renderer/test_dependencies.py +++ b/tests/integration/renderer/test_dependencies.py @@ -40,6 +40,6 @@ def update_output_2(value): assert output_1_call_count.value == 2 and output_2_call_count.value == 0 - assert dash_duo.redux_state_rqs == [] + assert not dash_duo.redux_state_is_loading assert dash_duo.get_logs() == [] diff --git a/tests/integration/renderer/test_due_diligence.py b/tests/integration/renderer/test_due_diligence.py index 3ec1ce40ba..e46314bc51 100644 --- a/tests/integration/renderer/test_due_diligence.py +++ b/tests/integration/renderer/test_due_diligence.py @@ -95,7 +95,7 @@ def test_rddd001_initial_state(dash_duo): ) }, "paths should reflect to the component hierarchy" - assert dash_duo.redux_state_rqs == [], "no callback => no pendingCallbacks" + assert not dash_duo.redux_state_is_loading, "no callback => no pendingCallbacks" dash_duo.percy_snapshot(name="layout") assert dash_duo.get_logs() == [], "console has no errors" diff --git a/tests/integration/test_render.py b/tests/integration/test_render.py index ed3bc7a180..1a79090323 100644 --- a/tests/integration/test_render.py +++ b/tests/integration/test_render.py @@ -490,10 +490,13 @@ def update_output(n_clicks): self.assertEqual(call_count.value, 3) self.wait_for_text_to_equal("#output1", "2") self.wait_for_text_to_equal("#output2", "3") - pending_count = self.driver.execute_script( - "return window.store.getState().pendingCallbacks.length" + ready = self.driver.execute_script( + """ + return !window.store.getState().isLoading; + """ ) - self.assertEqual(pending_count, 0) + + assert ready def test_callbacks_with_shared_grandparent(self): app = Dash() From e14b2e9d8b6b20deaaf55254f29205e0ffbc36c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andre=CC=81=20Rivet?= Date: Wed, 17 Jun 2020 14:05:13 -0400 Subject: [PATCH 54/56] bump dash to 1.13.0, bump renderer to 1.5.0, loosen version requirements --- CHANGELOG.md | 2 +- dash-renderer/package-lock.json | 2 +- dash-renderer/package.json | 2 +- dash/version.py | 2 +- requires-install.txt | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4312e8ca04..aee86ce037 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to `dash` will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). -## [UNRELEASED] +## [1.13.0] - 2020-06-17 ### Added - [#1289](https://github.com/plotly/dash/pull/1289) Supports `DASH_PROXY` env var to tell `app.run_server` to report the correct URL to view your app, when it's being proxied. Throws an error if the proxy is incompatible with the host and port you've given the server. - [#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. diff --git a/dash-renderer/package-lock.json b/dash-renderer/package-lock.json index 58209deaf0..77882c96c1 100644 --- a/dash-renderer/package-lock.json +++ b/dash-renderer/package-lock.json @@ -1,6 +1,6 @@ { "name": "dash-renderer", - "version": "1.4.1", + "version": "1.5.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/dash-renderer/package.json b/dash-renderer/package.json index a2c1b48606..cff92c4a03 100644 --- a/dash-renderer/package.json +++ b/dash-renderer/package.json @@ -1,6 +1,6 @@ { "name": "dash-renderer", - "version": "1.4.1", + "version": "1.5.0", "description": "render dash components in react", "main": "dash_renderer/dash_renderer.min.js", "scripts": { diff --git a/dash/version.py b/dash/version.py index b518f6eed0..9a34ccc9fa 100644 --- a/dash/version.py +++ b/dash/version.py @@ -1 +1 @@ -__version__ = "1.12.0" +__version__ = "1.13.0" diff --git a/requires-install.txt b/requires-install.txt index f11bb1f937..68de06a232 100644 --- a/requires-install.txt +++ b/requires-install.txt @@ -1,8 +1,8 @@ Flask>=1.0.2 flask-compress plotly -dash_renderer==1.4.1 +dash_renderer>=1.4.1 dash-core-components==1.10.0 dash-html-components==1.0.3 -dash-table==4.7.0 +dash-table>=4.7.0 future \ No newline at end of file From 694c4a8ddf5e8dd0987349eee649f0c688e942dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andre=CC=81=20Rivet?= Date: Wed, 17 Jun 2020 16:21:32 -0400 Subject: [PATCH 55/56] strict versions --- requires-install.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requires-install.txt b/requires-install.txt index 68de06a232..00e66f8a50 100644 --- a/requires-install.txt +++ b/requires-install.txt @@ -1,8 +1,8 @@ Flask>=1.0.2 flask-compress plotly -dash_renderer>=1.4.1 +dash_renderer==1.5.0 dash-core-components==1.10.0 dash-html-components==1.0.3 -dash-table>=4.7.0 +dash-table==4.8.0 future \ No newline at end of file From 447a1d57d95e9e339fc3509941fcaa1582ee2831 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andre=CC=81=20Rivet?= Date: Wed, 17 Jun 2020 17:05:48 -0400 Subject: [PATCH 56/56] noise