diff --git a/CHANGELOG.md b/CHANGELOG.md index ed2ea067fc..f6dba82d31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## Fixed - [#3279](https://github.com/plotly/dash/pull/3279) Fix an issue where persisted values were incorrectly pruned when updated via callback. Now, callback returned values are correctly stored in the persistence storage. Fix [#2678](https://github.com/plotly/dash/issues/2678) +## Added +- [#3294](https://github.com/plotly/dash/pull/3294) Added the ability to pass `allow_optional` to Input and State to allow callbacks to work even if these components are not in the dash layout. + ## [3.0.4] - 2025-04-24 ## Fixed diff --git a/dash/dash-renderer/src/actions/callbacks.ts b/dash/dash-renderer/src/actions/callbacks.ts index 82c7211446..eae5682cb4 100644 --- a/dash/dash-renderer/src/actions/callbacks.ts +++ b/dash/dash-renderer/src/actions/callbacks.ts @@ -122,22 +122,27 @@ function unwrapIfNotMulti( 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.'); + if (spec.allow_optional) { + idProps = [{...spec, value: null}]; + msg = ''; + } else { + 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 `' + @@ -203,7 +208,6 @@ function fillVals( // That's a real problem, so throw the first message as an error. refErr(errors, paths); } - return inputVals; } diff --git a/dash/dependencies.py b/dash/dependencies.py index 9fcd058b9d..ae74a52858 100644 --- a/dash/dependencies.py +++ b/dash/dependencies.py @@ -35,6 +35,7 @@ class DashDependency: # pylint: disable=too-few-public-methods allow_duplicate: bool component_property: str allowed_wildcards: Sequence[_Wildcard] + allow_optional: bool def __init__(self, component_id: ComponentIdType, component_property: str): @@ -45,6 +46,7 @@ def __init__(self, component_id: ComponentIdType, component_property: str): self.component_property = component_property self.allow_duplicate = False + self.allow_optional = False def __str__(self): return f"{self.component_id_str()}.{self.component_property}" @@ -56,7 +58,10 @@ def component_id_str(self) -> str: return stringify_id(self.component_id) def to_dict(self) -> dict: - return {"id": self.component_id_str(), "property": self.component_property} + specs = {"id": self.component_id_str(), "property": self.component_property} + if self.allow_optional: + specs["allow_optional"] = True + return specs def __eq__(self, other): """ @@ -134,12 +139,30 @@ def __init__( class Input(DashDependency): # pylint: disable=too-few-public-methods """Input of callback: trigger an update when it is updated.""" + def __init__( + self, + component_id: ComponentIdType, + component_property: str, + allow_optional: bool = False, + ): + super().__init__(component_id, component_property) + self.allow_optional = allow_optional + allowed_wildcards = (MATCH, ALL, ALLSMALLER) class State(DashDependency): # pylint: disable=too-few-public-methods """Use the value of a State in a callback but don't trigger updates.""" + def __init__( + self, + component_id: ComponentIdType, + component_property: str, + allow_optional: bool = False, + ): + super().__init__(component_id, component_property) + self.allow_optional = allow_optional + allowed_wildcards = (MATCH, ALL, ALLSMALLER) diff --git a/tests/integration/callbacks/test_callback_optional.py b/tests/integration/callbacks/test_callback_optional.py new file mode 100644 index 0000000000..73cc969055 --- /dev/null +++ b/tests/integration/callbacks/test_callback_optional.py @@ -0,0 +1,101 @@ +from dash import Dash, html, Output, Input, State, no_update + + +def test_cbop001_optional_input(dash_duo): + app = Dash(suppress_callback_exceptions=True) + + app.layout = html.Div( + [ + html.Button(id="button1", children="Button 1"), + html.Div(id="button-container"), + html.Div(id="test-out"), + html.Div(id="test-out2"), + ] + ) + + @app.callback( + Output("button-container", "children"), + Input("button1", "n_clicks"), + State("button-container", "children"), + prevent_initial_call=True, + ) + def _(_, c): + if not c: + return html.Button(id="button2", children="Button 2") + return no_update + + @app.callback( + Output("test-out", "children"), + Input("button1", "n_clicks"), + Input("button2", "n_clicks", allow_optional=True), + prevent_inital_call=True, + ) + def display(n, n2): + return f"{n} - {n2}" + + dash_duo.start_server(app) + dash_duo.wait_for_text_to_equal("#button1", "Button 1") + assert dash_duo.get_logs() == [] + dash_duo.wait_for_text_to_equal("#test-out", "None - None") + dash_duo.find_element("#button1").click() + dash_duo.wait_for_text_to_equal("#test-out", "1 - None") + + dash_duo.find_element("#button2").click() + dash_duo.wait_for_text_to_equal("#test-out", "1 - 1") + assert dash_duo.get_logs() == [] + + +def test_cbop002_optional_state(dash_duo): + app = Dash(suppress_callback_exceptions=True) + + app.layout = html.Div( + [ + html.Button(id="button1", children="Button 1"), + html.Div(id="button-container"), + html.Div(id="test-out"), + html.Div(id="test-out2"), + ] + ) + + @app.callback( + Output("button-container", "children"), + Input("button1", "n_clicks"), + State("button-container", "children"), + prevent_initial_call=True, + ) + def _(_, c): + if not c: + return html.Button(id="button2", children="Button 2") + return no_update + + @app.callback( + Output("test-out", "children"), + Input("button1", "n_clicks"), + State("button2", "n_clicks", allow_optional=True), + prevent_inital_call=True, + ) + def display(n, n2): + return f"{n} - {n2}" + + @app.callback( + Output("test-out2", "children"), + Input("button2", "n_clicks", allow_optional=True), + ) + def test(n): + if n: + return n + return no_update + + dash_duo.start_server(app) + dash_duo.wait_for_text_to_equal("#button1", "Button 1") + assert dash_duo.get_logs() == [] + dash_duo.wait_for_text_to_equal("#test-out", "None - None") + dash_duo.find_element("#button1").click() + dash_duo.wait_for_text_to_equal("#test-out", "1 - None") + + dash_duo.find_element("#button2").click() + dash_duo.wait_for_text_to_equal("#test-out2", "1") + dash_duo.wait_for_text_to_equal("#test-out", "1 - None") + dash_duo.find_element("#button1").click() + dash_duo.wait_for_text_to_equal("#test-out", "2 - 1") + assert dash_duo.get_logs() == []