diff --git a/@plotly/dash-test-components/src/components/ExternalComponent.js b/@plotly/dash-test-components/src/components/ExternalComponent.js index bb3369d87e..d82fbd93a0 100644 --- a/@plotly/dash-test-components/src/components/ExternalComponent.js +++ b/@plotly/dash-test-components/src/components/ExternalComponent.js @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; -const ExternalComponent = ({ id, text, input_id }) => { +const ExternalComponent = ({ id, text, input_id, extra_component }) => { const ctx = window.dash_component_api.useDashContext(); const ExternalWrapper = window.dash_component_api.ExternalWrapper; @@ -15,6 +15,14 @@ const ExternalComponent = ({ id, text, input_id }) => { value={text} componentPath={[...ctx.componentPath, 'external']} /> + { + extra_component && + } ) } @@ -23,6 +31,11 @@ ExternalComponent.propTypes = { id: PropTypes.string, text: PropTypes.string, input_id: PropTypes.string, + extra_component: PropTypes.exact({ + type: PropTypes.string, + namespace: PropTypes.string, + props: PropTypes.object, + }), }; export default ExternalComponent; diff --git a/CHANGELOG.md b/CHANGELOG.md index 45914dda5e..839cb2e4d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to `dash` will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/). +## [3.0.0-rc3] - 2025-02-21 + +## Added + +- [#3121](https://github.com/plotly/dash/pull/3121) Restyle and add version checker to dev tools. +- [#3175](https://github.com/plotly/dash/pull/3175) Add `custom_data` hook. +- [#3175](https://github.com/plotly/dash/pull/3175) Improved error for removed Dash app attribute, run_server and long_callback +- [#3175](https://github.com/plotly/dash/pull/3175) Expose `stringifyId` in `window.dash_component_api`. + +## Fixed + +- [#3175](https://github.com/plotly/dash/pull/3175) Fix `ExternalWrapper` rendering children and support pattern matching ids. + ## [3.0.0-rc2] - 2025-02-18 ## Added diff --git a/dash/_callback_context.py b/dash/_callback_context.py index 42e4c506d9..55d3cf0a49 100644 --- a/dash/_callback_context.py +++ b/dash/_callback_context.py @@ -304,6 +304,14 @@ def origin(self): """ return _get_from_context("origin", "") + @property + @has_context + def custom_data(self): + """ + Custom data set by hooks.custom_data. + """ + return _get_from_context("custom_data", {}) + callback_context = CallbackContext() diff --git a/dash/_dash_renderer.py b/dash/_dash_renderer.py index 41d4e6452f..36588b30b5 100644 --- a/dash/_dash_renderer.py +++ b/dash/_dash_renderer.py @@ -1,6 +1,6 @@ import os -__version__ = "2.0.1" +__version__ = "2.0.2" _available_react_versions = {"18.3.1", "18.2.0", "16.14.0"} _available_reactdom_versions = {"18.3.1", "18.2.0", "16.14.0"} @@ -64,7 +64,7 @@ def _set_react_version(v_react, v_reactdom=None): { "relative_package_path": "dash-renderer/build/dash_renderer.min.js", "dev_package_path": "dash-renderer/build/dash_renderer.dev.js", - "external_url": "https://unpkg.com/dash-renderer@2.0.1" + "external_url": "https://unpkg.com/dash-renderer@2.0.2" "/build/dash_renderer.min.js", "namespace": "dash", }, diff --git a/dash/_hooks.py b/dash/_hooks.py index 7c4b2389f0..1790aa3e3d 100644 --- a/dash/_hooks.py +++ b/dash/_hooks.py @@ -45,6 +45,7 @@ def __init__(self) -> None: "error": [], "callback": [], "index": [], + "custom_data": [], } self._js_dist = [] self._css_dist = [] @@ -191,6 +192,28 @@ def wrap(func): return wrap + def custom_data( + self, namespace: str, priority: _t.Optional[int] = None, final=False + ): + """ + Add data to the callback_context.custom_data property under the namespace. + + The hook function takes the current context_value and before the ctx is set + and has access to the flask request context. + """ + + def wrap(func: _t.Callable[[_t.Dict], _t.Any]): + self.add_hook( + "custom_data", + func, + priority=priority, + final=final, + data={"namespace": namespace}, + ) + return func + + return wrap + hooks = _Hooks() diff --git a/dash/_obsolete.py b/dash/_obsolete.py new file mode 100644 index 0000000000..c2b682f8c9 --- /dev/null +++ b/dash/_obsolete.py @@ -0,0 +1,23 @@ +# pylint: disable=too-few-public-methods +from .exceptions import ObsoleteAttributeException + + +class ObsoleteAttribute: + def __init__(self, message: str, exc=ObsoleteAttributeException): + self.message = message + self.exc = exc + + +class ObsoleteChecker: + _obsolete_attributes = { + "run_server": ObsoleteAttribute("app.run_server has been replaced by app.run"), + "long_callback": ObsoleteAttribute( + "app.long_callback has been removed, use app.callback(..., background=True) instead" + ), + } + + def __getattr__(self, name: str): + if name in self._obsolete_attributes: + err = self._obsolete_attributes[name] + raise err.exc(err.message) + return getattr(self, name) diff --git a/dash/dash-renderer/package-lock.json b/dash/dash-renderer/package-lock.json index ed4d4819c4..a4821a1f19 100644 --- a/dash/dash-renderer/package-lock.json +++ b/dash/dash-renderer/package-lock.json @@ -1,12 +1,12 @@ { "name": "dash-renderer", - "version": "2.0.1", + "version": "2.0.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "dash-renderer", - "version": "2.0.1", + "version": "2.0.2", "license": "MIT", "dependencies": { "@babel/polyfill": "^7.12.1", diff --git a/dash/dash-renderer/package.json b/dash/dash-renderer/package.json index 5ab97ae90c..2d481f8522 100644 --- a/dash/dash-renderer/package.json +++ b/dash/dash-renderer/package.json @@ -1,6 +1,6 @@ { "name": "dash-renderer", - "version": "2.0.1", + "version": "2.0.2", "description": "render dash components in react", "main": "build/dash_renderer.min.js", "scripts": { @@ -13,7 +13,7 @@ "build:dev": "webpack", "build:local": "renderer build local", "build": "renderer build && npm run prepublishOnly", - "postbuild": "es-check es2018 ../deps/*.js build/*.js", + "postbuild": "es-check es2015 ../deps/*.js build/*.js", "test": "karma start karma.conf.js --single-run", "format": "run-s private::format.*", "lint": "run-s private::lint.* --continue-on-error" diff --git a/dash/dash-renderer/src/actions/index.js b/dash/dash-renderer/src/actions/index.js index e46644ff9d..86649c2d7d 100644 --- a/dash/dash-renderer/src/actions/index.js +++ b/dash/dash-renderer/src/actions/index.js @@ -6,7 +6,7 @@ import {getAction} from './constants'; import cookie from 'cookie'; import {validateCallbacksToLayout} from './dependencies'; import {includeObservers, getLayoutCallbacks} from './dependencies_ts'; -import {getPath} from './paths'; +import {computePaths, getPath} from './paths'; export const onError = createAction(getAction('ON_ERROR')); export const setAppLifecycle = createAction(getAction('SET_APP_LIFECYCLE')); @@ -21,6 +21,14 @@ export const updateProps = createAction(getAction('ON_PROP_CHANGE')); export const insertComponent = createAction(getAction('INSERT_COMPONENT')); export const removeComponent = createAction(getAction('REMOVE_COMPONENT')); +export const addComponentToLayout = payload => (dispatch, getState) => { + const {paths} = getState(); + dispatch(insertComponent(payload)); + dispatch( + setPaths(computePaths(payload.component, payload.componentPath, paths)) + ); +}; + export const dispatchError = dispatch => (message, lines) => dispatch( onError({ diff --git a/dash/dash-renderer/src/dashApi.ts b/dash/dash-renderer/src/dashApi.ts index a0a914c5e4..75365f731c 100644 --- a/dash/dash-renderer/src/dashApi.ts +++ b/dash/dash-renderer/src/dashApi.ts @@ -3,6 +3,7 @@ import {DashContext, useDashContext} from './wrapper/DashContext'; import {getPath} from './actions/paths'; import {getStores} from './utils/stores'; import ExternalWrapper from './wrapper/ExternalWrapper'; +import {stringifyId} from './actions/dependencies'; /** * Get the dash props from a component path or id. @@ -32,5 +33,6 @@ function getLayout(componentPathOrId: string[] | string): any { ExternalWrapper, DashContext, useDashContext, - getLayout + getLayout, + stringifyId }; diff --git a/dash/dash-renderer/src/wrapper/ExternalWrapper.tsx b/dash/dash-renderer/src/wrapper/ExternalWrapper.tsx index 025bb2ea55..79bcbb5162 100644 --- a/dash/dash-renderer/src/wrapper/ExternalWrapper.tsx +++ b/dash/dash-renderer/src/wrapper/ExternalWrapper.tsx @@ -1,9 +1,14 @@ import React, {useState, useEffect} from 'react'; -import {useDispatch} from 'react-redux'; +import {batch, useDispatch} from 'react-redux'; import {DashLayoutPath} from '../types/component'; import DashWrapper from './DashWrapper'; -import {insertComponent, removeComponent} from '../actions'; +import { + addComponentToLayout, + notifyObservers, + removeComponent, + updateProps +} from '../actions'; type Props = { componentPath: DashLayoutPath; @@ -21,18 +26,18 @@ function ExternalWrapper({ componentPath, ...props }: Props) { - const dispatch = useDispatch(); + const dispatch: any = useDispatch(); const [inserted, setInserted] = useState(false); useEffect(() => { // Give empty props for the inserted components. // The props will come from the parent so they can be updated. dispatch( - insertComponent({ + addComponentToLayout({ component: { type: componentType, namespace: componentNamespace, - props: {} + props: props }, componentPath }) @@ -43,10 +48,17 @@ function ExternalWrapper({ }; }, []); + useEffect(() => { + batch(() => { + dispatch(updateProps({itempath: componentPath, props})); + dispatch(notifyObservers({id: props.id, props})); + }); + }, [props]); + if (!inserted) { return null; } // Render a wrapper with the actual props. - return ; + return ; } export default ExternalWrapper; diff --git a/dash/dash.py b/dash/dash.py index b7b5f18b35..d890bb49c6 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -68,6 +68,7 @@ from . import _get_app from ._grouping import map_grouping, grouping_len, update_args_group +from ._obsolete import ObsoleteChecker from . import _pages from ._pages import ( @@ -207,7 +208,7 @@ def _do_skip(error): # pylint: disable=too-many-instance-attributes # pylint: disable=too-many-arguments, too-many-locals -class Dash: +class Dash(ObsoleteChecker): """Dash is a framework for building analytical web applications. No JavaScript required. @@ -1388,6 +1389,10 @@ def dispatch(self): g.path = flask.request.full_path g.remote = flask.request.remote_addr g.origin = flask.request.origin + g.custom_data = AttributeDict({}) + + for hook in self._hooks.get_hooks("custom_data"): + g.custom_data[hook.data["namespace"]] = hook(g) except KeyError as missing_callback_function: msg = f"Callback function not found for output '{output}', perhaps you forgot to prepend the '@'?" diff --git a/dash/exceptions.py b/dash/exceptions.py index a971d2e5a3..00bd2c1553 100644 --- a/dash/exceptions.py +++ b/dash/exceptions.py @@ -10,6 +10,10 @@ class ObsoleteKwargException(DashException): pass +class ObsoleteAttributeException(DashException): + pass + + class NoLayoutException(DashException): pass diff --git a/dash/version.py b/dash/version.py index f7bbf541c6..0b609eb386 100644 --- a/dash/version.py +++ b/dash/version.py @@ -1 +1 @@ -__version__ = "3.0.0rc2" +__version__ = "3.0.0rc3" diff --git a/tests/integration/renderer/test_external_component.py b/tests/integration/renderer/test_external_component.py index db095214a2..72657125f5 100644 --- a/tests/integration/renderer/test_external_component.py +++ b/tests/integration/renderer/test_external_component.py @@ -1,4 +1,4 @@ -from dash import Dash, html, dcc, html, Input, Output, State +from dash import Dash, html, dcc, html, Input, Output, State, MATCH from dash_test_components import ExternalComponent @@ -8,7 +8,22 @@ def test_rext001_render_external_component(dash_duo): [ dcc.Input(id="sync", value="synced"), html.Button("sync", id="sync-btn"), - ExternalComponent("ext", input_id="external", text="external"), + ExternalComponent( + id="ext", + input_id="external", + text="external", + extra_component={ + "type": "Div", + "namespace": "dash_html_components", + "props": { + "id": "extra", + "children": [ + html.Div("extra children", id={"type": "extra", "index": 1}) + ], + }, + }, + ), + html.Div(html.Div(id={"type": "output", "index": 1}), id="out"), ] ) @@ -21,7 +36,20 @@ def test_rext001_render_external_component(dash_duo): def on_sync(_, value): return value + @app.callback( + Output({"type": "output", "index": MATCH}, "children"), + Input({"type": "extra", "index": MATCH}, "n_clicks"), + prevent_initial_call=True, + ) + def click(*_): + return "clicked" + dash_duo.start_server(app) dash_duo.wait_for_text_to_equal("#external", "external") dash_duo.find_element("#sync-btn").click() dash_duo.wait_for_text_to_equal("#external", "synced") + + dash_duo.wait_for_text_to_equal("#extra", "extra children") + + dash_duo.find_element("#extra > div").click() + dash_duo.wait_for_text_to_equal("#out", "clicked") diff --git a/tests/integration/test_hooks.py b/tests/integration/test_hooks.py index ca4143eadb..cbb1f44551 100644 --- a/tests/integration/test_hooks.py +++ b/tests/integration/test_hooks.py @@ -2,7 +2,7 @@ import requests import pytest -from dash import Dash, Input, Output, html, hooks, set_props +from dash import Dash, Input, Output, html, hooks, set_props, ctx @pytest.fixture @@ -14,6 +14,7 @@ def hook_cleanup(): hooks._ns["error"] = [] hooks._ns["callback"] = [] hooks._ns["index"] = [] + hooks._ns["custom_data"] = [] hooks._css_dist = [] hooks._js_dist = [] hooks._finals = {} @@ -188,3 +189,24 @@ def test_hook009_hook_clientside_callback(hook_cleanup, dash_duo): dash_duo.wait_for_element("#hook-start").click() dash_duo.wait_for_text_to_equal("#hook-output", "Called 1") + + +def test_hook010_hook_custom_data(hook_cleanup, dash_duo): + @hooks.custom_data("custom") + def custom_data(_): + return "custom-data" + + app = Dash() + app.layout = [html.Button("insert", id="btn"), html.Div(id="output")] + + @app.callback( + Output("output", "children"), + Input("btn", "n_clicks"), + prevent_initial_call=True, + ) + def cb(_): + return ctx.custom_data.custom + + dash_duo.start_server(app) + dash_duo.wait_for_element("#btn").click() + dash_duo.wait_for_text_to_equal("#output", "custom-data")