diff --git a/CHANGELOG.md b/CHANGELOG.md index d1c77b3709..b123ad5869 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,11 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## Added - [#3369](https://github.com/plotly/dash/pull/3369) Expose `dash.NoUpdate` type +- [#3371](https://github.com/plotly/dash/pull/3371) Add devtool hook to add components to the devtool bar ui. ## Fixed - [#3353](https://github.com/plotly/dash/pull/3353) Support pattern-matching/dict ids in `dcc.Loading` `target_components` - +- [#3371](https://github.com/plotly/dash/pull/3371) Fix allow_optional triggering a warning for not found input. # [3.1.1] - 2025-06-29 diff --git a/dash/_hooks.py b/dash/_hooks.py index 058f0f7e12..5be26d6584 100644 --- a/dash/_hooks.py +++ b/dash/_hooks.py @@ -46,6 +46,7 @@ def __init__(self) -> None: "callback": [], "index": [], "custom_data": [], + "dev_tools": [], } self._js_dist = [] self._css_dist = [] @@ -216,6 +217,24 @@ def wrap(func: _t.Callable[[_t.Dict], _t.Any]): return wrap + def devtool(self, namespace: str, component_type: str, props=None): + """ + Add a component to be rendered inside the dev tools. + + If it's a dash component, it can be used in callbacks provided + that it has an id and the dependency is set with allow_optional=True. + + `props` can be a function, in which case it will be called before + sending the component to the frontend. + """ + self._ns["dev_tools"].append( + { + "namespace": namespace, + "type": component_type, + "props": props or {}, + } + ) + hooks = _Hooks() diff --git a/dash/dash-renderer/src/APIController.react.js b/dash/dash-renderer/src/APIController.react.js index beb0bc7669..d83f7b7415 100644 --- a/dash/dash-renderer/src/APIController.react.js +++ b/dash/dash-renderer/src/APIController.react.js @@ -103,14 +103,14 @@ const UnconnectedContainer = props => { content = ( <> - {Array.isArray(layout) ? ( - layout.map((c, i) => + {Array.isArray(layout.components) ? ( + layout.components.map((c, i) => isSimpleComponent(c) ? ( c ) : ( ) @@ -118,7 +118,7 @@ const UnconnectedContainer = props => { ) : ( )} @@ -153,7 +153,7 @@ function storeEffect(props, events, setErrorLoading) { } dispatch(apiThunk('_dash-layout', 'GET', 'layoutRequest')); } else if (layoutRequest.status === STATUS.OK) { - if (isEmpty(layout)) { + if (isEmpty(layout.components)) { if (typeof hooks.layout_post === 'function') { hooks.layout_post(layoutRequest.content); } @@ -163,7 +163,12 @@ function storeEffect(props, events, setErrorLoading) { ); dispatch( setPaths( - computePaths(finalLayout, [], null, events.current) + computePaths( + finalLayout, + ['components'], + null, + events.current + ) ) ); dispatch(setLayout(finalLayout)); @@ -194,7 +199,7 @@ function storeEffect(props, events, setErrorLoading) { !isEmpty(graphs) && // LayoutRequest and its computed stores layoutRequest.status === STATUS.OK && - !isEmpty(layout) && + !isEmpty(layout.components) && // Hasn't already hydrated appLifecycle === getAppState('STARTED') ) { diff --git a/dash/dash-renderer/src/actions/dependencies.js b/dash/dash-renderer/src/actions/dependencies.js index 223b3f8c4c..63c9440fb2 100644 --- a/dash/dash-renderer/src/actions/dependencies.js +++ b/dash/dash-renderer/src/actions/dependencies.js @@ -5,6 +5,7 @@ import { any, ap, assoc, + concat, difference, equals, evolve, @@ -568,10 +569,20 @@ export function validateCallbacksToLayout(state_, dispatchError) { function validateMap(map, cls, doState) { for (const id in map) { const idProps = map[id]; + const fcb = flatten(values(idProps)); + const optional = all( + ({allow_optional}) => allow_optional, + flatten( + fcb.map(cb => concat(cb.outputs, cb.inputs, cb.states)) + ).filter(dep => dep.id === id) + ); + if (optional) { + continue; + } const idPath = getPath(paths, id); if (!idPath) { if (validateIds) { - missingId(id, cls, flatten(values(idProps))); + missingId(id, cls, fcb); } } else { for (const property in idProps) { diff --git a/dash/dash-renderer/src/actions/index.js b/dash/dash-renderer/src/actions/index.js index fd8c314d78..01dd67cf8a 100644 --- a/dash/dash-renderer/src/actions/index.js +++ b/dash/dash-renderer/src/actions/index.js @@ -89,7 +89,7 @@ function triggerDefaultState(dispatch, getState) { dispatch( addRequestedCallbacks( - getLayoutCallbacks(graphs, paths, layout, { + getLayoutCallbacks(graphs, paths, layout.components, { outputsOnly: true }) ) diff --git a/dash/dash-renderer/src/components/error/menu/DebugMenu.react.js b/dash/dash-renderer/src/components/error/menu/DebugMenu.react.js index e978ef1d4a..701812ab85 100644 --- a/dash/dash-renderer/src/components/error/menu/DebugMenu.react.js +++ b/dash/dash-renderer/src/components/error/menu/DebugMenu.react.js @@ -13,6 +13,8 @@ import Expand from '../icons/Expand.svg'; import {VersionInfo} from './VersionInfo.react'; import {CallbackGraphContainer} from '../CallbackGraph/CallbackGraphContainer.react'; import {FrontEndErrorContainer} from '../FrontEnd/FrontEndErrorContainer.react'; +import ExternalWrapper from '../../../wrapper/ExternalWrapper'; +import {useSelector} from 'react-redux'; const classes = (base, variant, variant2) => `${base} ${base}--${variant}` + (variant2 ? ` ${base}--${variant2}` : ''); @@ -35,6 +37,7 @@ const MenuContent = ({ toggleCallbackGraph, config }) => { + const ready = useSelector(state => state.appLifecycle === 'HYDRATED'); const _StatusIcon = hotReload ? connected ? CheckIcon @@ -47,6 +50,25 @@ const MenuContent = ({ : 'unavailable' : 'cold'; + let custom = null; + if (config.dev_tools?.length && ready) { + custom = ( + <> + {config.dev_tools.map((devtool, i) => ( + + ))} +
+ + ); + } + return (
); }; diff --git a/dash/dash-renderer/src/reducers/layout.js b/dash/dash-renderer/src/reducers/layout.js index e9c2aba0e7..6b58deabea 100644 --- a/dash/dash-renderer/src/reducers/layout.js +++ b/dash/dash-renderer/src/reducers/layout.js @@ -10,12 +10,15 @@ import { import {getAction} from '../actions/constants'; -const layout = (state = {}, action) => { +const layout = (state = {components: []}, action) => { if (action.type === getAction('SET_LAYOUT')) { if (Array.isArray(action.payload)) { - return [...action.payload]; + state.components = [...action.payload]; + } else { + state.components = {...action.payload}; } - return {...action.payload}; + + return state; } else if ( includes(action.type, [ 'UNDO_PROP_CHANGE', diff --git a/dash/dash.py b/dash/dash.py index 92c4a5d542..eab3e3358e 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -890,6 +890,16 @@ def _config(self): config["validation_layout"] = validation_layout + if self._dev_tools.ui: + # Add custom dev tools hooks if the ui is activated. + custom_dev_tools = [] + for hook_dev_tools in self._hooks.get_hooks("dev_tools"): + props = hook_dev_tools.get("props", {}) + if callable(props): + props = props() + custom_dev_tools.append({**hook_dev_tools, "props": props}) + config["dev_tools"] = custom_dev_tools + return config def serve_reload_hash(self): diff --git a/tests/async_tests/test_async_callbacks.py b/tests/async_tests/test_async_callbacks.py index 8ad200f51b..13cb8418f9 100644 --- a/tests/async_tests/test_async_callbacks.py +++ b/tests/async_tests/test_async_callbacks.py @@ -119,9 +119,10 @@ async def update_input(value): paths = dash_duo.redux_state_paths assert paths["objs"] == {} assert paths["strs"] == { - "input": ["props", "children", 0], - "output": ["props", "children", 1], + "input": ["components", "props", "children", 0], + "output": ["components", "props", "children", 1], "sub-input-1": [ + "components", "props", "children", 1, @@ -132,6 +133,7 @@ async def update_input(value): 0, ], "sub-output-1": [ + "components", "props", "children", 1, diff --git a/tests/integration/callbacks/state_path.json b/tests/integration/callbacks/state_path.json index 94ca6b4dcd..cceb9ddfdc 100644 --- a/tests/integration/callbacks/state_path.json +++ b/tests/integration/callbacks/state_path.json @@ -2,9 +2,10 @@ "chapter1": { "objs": {}, "strs": { - "toc": ["props", "children", 0], - "body": ["props", "children", 1], + "toc": ["components", "props", "children", 0], + "body": ["components", "props", "children", 1], "chapter1-header": [ + "components", "props", "children", 1, @@ -15,6 +16,7 @@ 0 ], "chapter1-controls": [ + "components", "props", "children", 1, @@ -25,6 +27,7 @@ 1 ], "chapter1-label": [ + "components", "props", "children", 1, @@ -35,6 +38,7 @@ 2 ], "chapter1-graph": [ + "components", "props", "children", 1, @@ -49,9 +53,10 @@ "chapter2": { "objs": {}, "strs": { - "toc": ["props", "children", 0], - "body": ["props", "children", 1], + "toc": ["components", "props", "children", 0], + "body": ["components", "props", "children", 1], "chapter2-header": [ + "components", "props", "children", 1, @@ -62,6 +67,7 @@ 0 ], "chapter2-controls": [ + "components", "props", "children", 1, @@ -72,6 +78,7 @@ 1 ], "chapter2-label": [ + "components", "props", "children", 1, @@ -82,6 +89,7 @@ 2 ], "chapter2-graph": [ + "components", "props", "children", 1, @@ -96,9 +104,10 @@ "chapter3": { "objs": {}, "strs": { - "toc": ["props", "children", 0], - "body": ["props", "children", 1], + "toc": ["components", "props", "children", 0], + "body": ["components", "props", "children", 1], "chapter3-header": [ + "components", "props", "children", 1, @@ -112,6 +121,7 @@ 0 ], "chapter3-label": [ + "components", "props", "children", 1, @@ -125,6 +135,7 @@ 1 ], "chapter3-graph": [ + "components", "props", "children", 1, @@ -138,6 +149,7 @@ 2 ], "chapter3-controls": [ + "components", "props", "children", 1, diff --git a/tests/integration/callbacks/test_basic_callback.py b/tests/integration/callbacks/test_basic_callback.py index 72af7d3cac..41e0e8592c 100644 --- a/tests/integration/callbacks/test_basic_callback.py +++ b/tests/integration/callbacks/test_basic_callback.py @@ -113,9 +113,10 @@ def update_input(value): paths = dash_duo.redux_state_paths assert paths["objs"] == {} assert paths["strs"] == { - "input": ["props", "children", 0], - "output": ["props", "children", 1], + "input": ["components", "props", "children", 0], + "output": ["components", "props", "children", 1], "sub-input-1": [ + "components", "props", "children", 1, @@ -126,6 +127,7 @@ def update_input(value): 0, ], "sub-output-1": [ + "components", "props", "children", 1, diff --git a/tests/integration/callbacks/test_layout_paths_with_callbacks.py b/tests/integration/callbacks/test_layout_paths_with_callbacks.py index ffa793a7a3..7ce0296bc4 100644 --- a/tests/integration/callbacks/test_layout_paths_with_callbacks.py +++ b/tests/integration/callbacks/test_layout_paths_with_callbacks.py @@ -37,8 +37,8 @@ def snapshot(name): html.Div(id="body"), ] ) - for script in dcc._js_dist: - app.scripts.append_script(script) + # for script in dcc._js_dist: + # app.scripts.append_script(script) chapters = { "chapter1": html.Div( @@ -183,7 +183,7 @@ def check_chapter(chapter): ) == value ), - TIMEOUT, + 20, ) assert not dash_duo.redux_state_is_loading, "loadingMap is empty" @@ -237,8 +237,8 @@ def check_call_counts(chapters, count): ), "each element should exist in the dom" assert paths["strs"] == { - "toc": ["props", "children", 0], - "body": ["props", "children", 1], + "toc": ["components", "props", "children", 0], + "body": ["components", "props", "children", 1], } dash_duo.find_elements('input[type="radio"]')[0].click() diff --git a/tests/integration/callbacks/test_missing_outputs.py b/tests/integration/callbacks/test_missing_outputs.py index 07ea446611..a2ccca22fa 100644 --- a/tests/integration/callbacks/test_missing_outputs.py +++ b/tests/integration/callbacks/test_missing_outputs.py @@ -319,7 +319,9 @@ def chapter2_assertions(): dash_duo.wait_for_text_to_equal("#body", "Chapter 2") layout = dash_duo.driver.execute_script( - "return JSON.parse(JSON.stringify(" "window.store.getState().layout" "))" + "return JSON.parse(JSON.stringify(" + "window.store.getState().layout" + ")).components" ) dcc_radio = layout["props"]["children"][0] diff --git a/tests/integration/renderer/test_due_diligence.py b/tests/integration/renderer/test_due_diligence.py index 8c28edd853..9ff689a4f6 100644 --- a/tests/integration/renderer/test_due_diligence.py +++ b/tests/integration/renderer/test_due_diligence.py @@ -71,7 +71,7 @@ def test_rddd001_initial_state(dash_duo): assert dash_duo.get_logs() == [], "Check that no errors or warnings were displayed" assert dash_duo.driver.execute_script( - "return JSON.parse(JSON.stringify(window.store.getState().layout))" + "return JSON.parse(JSON.stringify(window.store.getState().layout)).components" ) == json.loads( json.dumps(app.layout, cls=plotly.utils.PlotlyJSONEncoder) ), "the state layout is identical to app.layout" @@ -83,7 +83,8 @@ def test_rddd001_initial_state(dash_duo): paths = dash_duo.redux_state_paths assert paths["objs"] == {} assert paths["strs"] == { - abbr: [ + abbr: ["components"] + + [ int(token) if token in string.digits else token.replace("p", "props").replace("c", "children") diff --git a/tests/integration/test_hooks.py b/tests/integration/test_hooks.py index cbb1f44551..ef6c0cc4a4 100644 --- a/tests/integration/test_hooks.py +++ b/tests/integration/test_hooks.py @@ -15,6 +15,7 @@ def hook_cleanup(): hooks._ns["callback"] = [] hooks._ns["index"] = [] hooks._ns["custom_data"] = [] + hooks._ns["dev_tools"] = [] hooks._css_dist = [] hooks._js_dist = [] hooks._finals = {} @@ -210,3 +211,32 @@ def cb(_): dash_duo.start_server(app) dash_duo.wait_for_element("#btn").click() dash_duo.wait_for_text_to_equal("#output", "custom-data") + + +def test_hook011_devtool_hook(hook_cleanup, dash_duo): + hooks.devtool( + "dash_html_components", "Button", {"children": "devtool", "id": "devtool"} + ) + + app = Dash() + app.layout = html.Div(["hooked", html.Div(id="output")]) + + @app.callback( + Output("output", "children"), + Input("devtool", "n_clicks", allow_optional=True), + prevent_initial_call=True, + ) + def cb(_): + return "hooked from devtools" + + dash_duo.start_server( + app, + debug=True, + use_reloader=False, + use_debugger=True, + dev_tools_hot_reload=False, + dev_tools_props_check=False, + dev_tools_disable_version_check=True, + ) + dash_duo.wait_for_element("#devtool").click() + dash_duo.wait_for_text_to_equal("#output", "hooked from devtools")