diff --git a/CHANGELOG.md b/CHANGELOG.md index d9bc7db83b..28cb1c9850 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,14 @@ This project adheres to [Semantic Versioning](https://semver.org/). - [#3395](https://github.com/plotly/dash/pull/3396) Add position argument to hooks.devtool - [#3403](https://github.com/plotly/dash/pull/3403) Add app_context to get_app, allowing to get the current app in routes. - [#3407](https://github.com/plotly/dash/pull/3407) Add `hidden` to callback arguments, hiding the callback from appearing in the devtool callback graph. +- [#3424](https://github.com/plotly/dash/pull/3424) Adds support for `Patch` on clientside callbacks class `dash_clientside.Patch`, as well as supporting side updates, eg: (Running, SetProps). +- [#3347](https://github.com/plotly/dash/pull/3347) Added 'api_endpoint' to `callback` to expose api endpoints at the provided path for use to be executed directly without dash. ## Fixed - [#3395](https://github.com/plotly/dash/pull/3395) Fix Components added through set_props() cannot trigger related callback functions. Fix [#3316](https://github.com/plotly/dash/issues/3316) - [#3397](https://github.com/plotly/dash/pull/3397) Add optional callbacks, suppressing callback warning for missing component ids for a single callback. +- [#3415](https://github.com/plotly/dash/pull/3415) Fix the error triggered when only a single no_update is returned for client-side callback functions with multiple Outputs. Fix [#3366](https://github.com/plotly/dash/issues/3366) +- [#3416](https://github.com/plotly/dash/issues/3416) Fix DeprecationWarning in dash/_jupyter.py by migrating from deprecated ipykernel.comm.Comm to comm module ## [3.2.0] - 2025-07-31 diff --git a/CITATION.cff b/CITATION.cff index 39aea60526..6e9039e94f 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -9,7 +9,7 @@ authors: given-names: "Alex" orcid: https://orcid.org/0000-0003-4623-4147 title: "A data and analytics web app framework for Python, no JavaScript required." -version: 2.18.2 +version: 3.2.0 doi: 10.5281/zenodo.14182630 -date-released: 2024-11-04 +date-released: 2025-07-31 url: https://github.com/plotly/dash diff --git a/components/dash-html-components/scripts/data/attributes.json b/components/dash-html-components/scripts/data/attributes.json index b426737bdb..9c4c6f418e 100644 --- a/components/dash-html-components/scripts/data/attributes.json +++ b/components/dash-html-components/scripts/data/attributes.json @@ -226,11 +226,8 @@ "button", "fieldset", "input", - "label", - "meter", "object", "output", - "progress", "select", "textarea" ], @@ -993,28 +990,13 @@ "target" ], "label": [ - "htmlFor", - "form" + "htmlFor" ], "output": [ "htmlFor", "form", "name" ], - "meter": [ - "form", - "high", - "low", - "max", - "min", - "optimum", - "value" - ], - "progress": [ - "form", - "max", - "value" - ], "canvas": [ "height", "width" @@ -1025,6 +1007,14 @@ "type", "width" ], + "meter": [ + "high", + "low", + "max", + "min", + "optimum", + "value" + ], "base": [ "href", "target" @@ -1035,6 +1025,10 @@ "marquee": [ "loop" ], + "progress": [ + "max", + "value" + ], "source": [ "media", "sizes", diff --git a/dash/_callback.py b/dash/_callback.py index dd757028c2..aacb8dbdde 100644 --- a/dash/_callback.py +++ b/dash/_callback.py @@ -62,6 +62,7 @@ def _invoke_callback(func, *args, **kwargs): # used to mark the frame for the d GLOBAL_CALLBACK_LIST = [] GLOBAL_CALLBACK_MAP = {} GLOBAL_INLINE_SCRIPTS = [] +GLOBAL_API_PATHS = {} # pylint: disable=too-many-locals,too-many-arguments @@ -77,6 +78,7 @@ def callback( cache_args_to_ignore: Optional[list] = None, cache_ignore_triggered=True, on_error: Optional[Callable[[Exception], Any]] = None, + api_endpoint: Optional[str] = None, optional: Optional[bool] = False, hidden: Optional[bool] = False, **_kwargs, @@ -165,6 +167,14 @@ def callback( Mark all dependencies as not required on the initial layout checks. :param hidden: Hide the callback from the devtools callbacks tab. + :param api_endpoint: + If provided, the callback will be available at the given API endpoint. + This allows you to call the callback directly through HTTP requests + instead of through the Dash front-end. The endpoint should be a string + that starts with a forward slash (e.g. `/my_callback`). + The endpoint is relative to the Dash app's base URL. + Note that the endpoint will not appear in the list of registered + callbacks in the Dash devtools. """ background_spec = None @@ -219,6 +229,7 @@ def callback( manager=manager, running=running, on_error=on_error, + api_endpoint=api_endpoint, optional=optional, hidden=hidden, ) @@ -587,7 +598,11 @@ def _prepare_response( # pylint: disable=too-many-branches,too-many-statements def register_callback( - callback_list, callback_map, config_prevent_initial_callbacks, *_args, **_kwargs + callback_list, + callback_map, + config_prevent_initial_callbacks, + *_args, + **_kwargs, ): ( output, @@ -642,6 +657,10 @@ def register_callback( # pylint: disable=too-many-locals def wrap_func(func): + if _kwargs.get("api_endpoint"): + api_endpoint = _kwargs.get("api_endpoint") + GLOBAL_API_PATHS[api_endpoint] = func + if background is None: background_key = None else: diff --git a/dash/_get_app.py b/dash/_get_app.py index a088e593ff..77df6d2219 100644 --- a/dash/_get_app.py +++ b/dash/_get_app.py @@ -23,7 +23,6 @@ def with_app_context_async(func): async def wrap(self, *args, **kwargs): app_context.set(self) ctx = copy_context() - print("copied and set") return await ctx.run(func, self, *args, **kwargs) return wrap diff --git a/dash/_jupyter.py b/dash/_jupyter.py index ac06f849c7..4211c81164 100644 --- a/dash/_jupyter.py +++ b/dash/_jupyter.py @@ -22,12 +22,12 @@ from IPython.core.display import HTML from IPython.core.ultratb import FormattedTB from retrying import retry - from ipykernel.comm import Comm + from comm import create_comm import nest_asyncio import requests - _dash_comm = Comm(target_name="dash") + _dash_comm = create_comm(target_name="dash") _dep_installed = True except ImportError: _dep_installed = False @@ -97,10 +97,14 @@ def convert(name, locals=locals, formatarg=formatarg, formatvalue=formatvalue): def _send_jupyter_config_comm_request(): # If running in an ipython kernel, # request that the front end extension send us the notebook server base URL - if get_ipython() is not None: - if _dash_comm.kernel is not None: - _caller["parent"] = _dash_comm.kernel.get_parent() - _dash_comm.send({"type": "base_url_request"}) + ipython = get_ipython() + if ( + ipython is not None + and hasattr(ipython, "kernel") + and ipython.kernel is not None + ): + _caller["parent"] = ipython.kernel.get_parent() + _dash_comm.send({"type": "base_url_request"}) def _jupyter_comm_response_received(): @@ -109,7 +113,8 @@ def _jupyter_comm_response_received(): def _request_jupyter_config(timeout=2): # Heavily inspired by implementation of CaptureExecution in the - if _dash_comm.kernel is None: + ipython = get_ipython() + if ipython is None or not hasattr(ipython, "kernel") or ipython.kernel is None: # Not in jupyter setting return @@ -215,8 +220,15 @@ def __init__(self): @_dash_comm.on_msg def _receive_message(msg): prev_parent = _caller.get("parent") - if prev_parent and prev_parent != _dash_comm.kernel.get_parent(): - _dash_comm.kernel.set_parent( + ipython = get_ipython() + if ( + prev_parent + and ipython is not None + and hasattr(ipython, "kernel") + and ipython.kernel is not None + and prev_parent != ipython.kernel.get_parent() + ): + ipython.kernel.set_parent( [prev_parent["header"]["session"]], prev_parent ) del _caller["parent"] diff --git a/dash/dash-renderer/package-lock.json b/dash/dash-renderer/package-lock.json index 4f0e413452..15195f8c38 100644 --- a/dash/dash-renderer/package-lock.json +++ b/dash/dash-renderer/package-lock.json @@ -9439,15 +9439,22 @@ "dev": true }, "node_modules/sha.js": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", - "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", "dependencies": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" }, "bin": { "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/shallow-clone": { @@ -17849,12 +17856,13 @@ "dev": true }, "sha.js": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", - "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", "requires": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" } }, "shallow-clone": { diff --git a/dash/dash-renderer/src/actions/callbacks.ts b/dash/dash-renderer/src/actions/callbacks.ts index 78b2ad1550..ee3a251378 100644 --- a/dash/dash-renderer/src/actions/callbacks.ts +++ b/dash/dash-renderer/src/actions/callbacks.ts @@ -41,7 +41,7 @@ import {createAction, Action} from 'redux-actions'; import {addHttpHeaders} from '../actions'; import {notifyObservers, updateProps} from './index'; import {CallbackJobPayload} from '../reducers/callbackJobs'; -import {handlePatch, isPatch} from './patch'; +import {parsePatchProps} from './patch'; import {computePaths, getPath} from './paths'; import {requestDependencies} from './requestDependencies'; @@ -226,8 +226,16 @@ function refErr(errors: any, paths: any) { 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]]; +const zipIfArray = (a: any, b: any) => { + if (Array.isArray(a)) { + // For client-side callbacks with multiple Outputs, only return a single dash_clientside.no_update + if (b === (window as any).dash_clientside.no_update) { + return zip(a, [b]); + } + return zip(a, b); + } + return [[a, b]]; +}; function cleanOutputProp(property: string) { return property.split('@')[0]; @@ -411,22 +419,31 @@ function sideUpdate(outputs: SideUpdateOutput, cb: ICallbackPayload) { }, [] as any[]) .forEach(([id, idProps]) => { const state = getState(); - dispatch(updateComponent(id, idProps, cb)); const componentPath = getPath(state.paths, id); + let oldComponent = {props: {}}; + if (componentPath) { + oldComponent = getComponentLayout(componentPath, state); + } + + const oldProps = oldComponent?.props || {}; + + const patchedProps = parsePatchProps(idProps, oldProps); + + dispatch(updateComponent(id, patchedProps, cb)); + if (!componentPath) { // Component doesn't exist, doesn't matter just allow the // callback to continue. return; } - const oldComponent = getComponentLayout(componentPath, state); dispatch( setPaths( computePaths( { ...oldComponent, - props: {...oldComponent.props, ...idProps} + props: {...oldComponent.props, ...patchedProps} }, [...componentPath], state.paths, @@ -801,12 +818,37 @@ export function executeCallback( if (clientside_function) { try { - const data = await handleClientside( + let data = await handleClientside( dispatch, clientside_function, config, payload ); + // Patch methodology: always run through parsePatchProps for each output + const currentLayout = getState().layout; + flatten(outputs).forEach((out: any) => { + const propName = cleanOutputProp(out.property); + const outputPath = getPath(paths, out.id); + const dataPath = [stringifyId(out.id), propName]; + const outputValue = path(dataPath, data); + if (outputValue === undefined) { + return; + } + const oldProps = + path( + outputPath.concat(['props']), + currentLayout + ) || {}; + const newProps = parsePatchProps( + {[propName]: outputValue}, + oldProps + ); + data = assocPath( + dataPath, + newProps[propName], + data + ); + }); return {data, payload}; } catch (error: any) { return {error, payload}; @@ -865,26 +907,31 @@ export function executeCallback( dispatch(addHttpHeaders(newHeaders)); } // Layout may have changed. + // DRY: Always run through parsePatchProps for each output const currentLayout = getState().layout; flatten(outputs).forEach((out: any) => { const propName = cleanOutputProp(out.property); const outputPath = getPath(paths, out.id); - const previousValue = path( - outputPath.concat(['props', propName]), - currentLayout - ); const dataPath = [stringifyId(out.id), propName]; const outputValue = path(dataPath, data); - if (isPatch(outputValue)) { - if (previousValue === undefined) { - throw new Error('Cannot patch undefined'); - } - data = assocPath( - dataPath, - handlePatch(previousValue, outputValue), - data - ); + if (outputValue === undefined) { + return; } + const oldProps = + path( + outputPath.concat(['props']), + currentLayout + ) || {}; + const newProps = parsePatchProps( + {[propName]: outputValue}, + oldProps + ); + + data = assocPath( + dataPath, + newProps[propName], + data + ); }); if (dynamic_creator) { diff --git a/dash/dash-renderer/src/actions/patch.ts b/dash/dash-renderer/src/actions/patch.ts index fac5b59ba3..3c3e14d204 100644 --- a/dash/dash-renderer/src/actions/patch.ts +++ b/dash/dash-renderer/src/actions/patch.ts @@ -44,6 +44,143 @@ function getLocationPath(location: LocationIndex[], obj: any) { return current; } +export class PatchBuilder { + private operations: PatchOperation[] = []; + + assign(location: LocationIndex[], value: any) { + this.operations.push({ + operation: 'Assign', + location, + params: {value} + }); + return this; + } + + merge(location: LocationIndex[], value: any) { + this.operations.push({ + operation: 'Merge', + location, + params: {value} + }); + return this; + } + + extend(location: LocationIndex[], value: any) { + this.operations.push({ + operation: 'Extend', + location, + params: {value} + }); + return this; + } + + delete(location: LocationIndex[]) { + this.operations.push({ + operation: 'Delete', + location, + params: {} + }); + return this; + } + + insert(location: LocationIndex[], index: number, value: any) { + this.operations.push({ + operation: 'Insert', + location, + params: {index, value} + }); + return this; + } + + append(location: LocationIndex[], value: any) { + this.operations.push({ + operation: 'Append', + location, + params: {value} + }); + return this; + } + + prepend(location: LocationIndex[], value: any) { + this.operations.push({ + operation: 'Prepend', + location, + params: {value} + }); + return this; + } + + add(location: LocationIndex[], value: any) { + this.operations.push({ + operation: 'Add', + location, + params: {value} + }); + return this; + } + + sub(location: LocationIndex[], value: any) { + this.operations.push({ + operation: 'Sub', + location, + params: {value} + }); + return this; + } + + mul(location: LocationIndex[], value: any) { + this.operations.push({ + operation: 'Mul', + location, + params: {value} + }); + return this; + } + + div(location: LocationIndex[], value: any) { + this.operations.push({ + operation: 'Div', + location, + params: {value} + }); + return this; + } + + clear(location: LocationIndex[]) { + this.operations.push({ + operation: 'Clear', + location, + params: {} + }); + return this; + } + + reverse(location: LocationIndex[]) { + this.operations.push({ + operation: 'Reverse', + location, + params: {} + }); + return this; + } + + remove(location: LocationIndex[], value: any) { + this.operations.push({ + operation: 'Remove', + location, + params: {value} + }); + return this; + } + + build() { + return { + __dash_patch_update: '__dash_patch_update', + operations: this.operations + }; + } +} + const patchHandlers: {[k: string]: PatchHandler} = { Assign: (previous, patchOperation) => { const {params, location} = patchOperation; @@ -166,3 +303,29 @@ export function handlePatch(previousValue: T, patchValue: any): T { return reducedValue; } + +export function parsePatchProps( + props: any, + previousProps: any +): Record { + if (!is(Object, props)) { + return props; + } + + const patchedProps: any = {}; + + for (const key of Object.keys(props)) { + const val = props[key]; + if (isPatch(val)) { + const previousValue = previousProps[key]; + if (previousValue === undefined) { + throw new Error('Cannot patch undefined'); + } + patchedProps[key] = handlePatch(previousValue, val); + } else { + patchedProps[key] = val; + } + } + + return patchedProps; +} diff --git a/dash/dash-renderer/src/utils/clientsideFunctions.ts b/dash/dash-renderer/src/utils/clientsideFunctions.ts index f325e34b5c..4ca3076cac 100644 --- a/dash/dash-renderer/src/utils/clientsideFunctions.ts +++ b/dash/dash-renderer/src/utils/clientsideFunctions.ts @@ -1,4 +1,5 @@ import {updateProps, notifyObservers, setPaths} from '../actions/index'; +import {parsePatchProps, PatchBuilder} from '../actions/patch'; import {computePaths, getPath} from '../actions/paths'; import {getComponentLayout} from '../wrapper/wrapping'; import {getStores} from './stores'; @@ -23,6 +24,12 @@ function set_props( } else { componentPath = idOrPath; } + const oldComponent = getComponentLayout(componentPath, state); + + // Handle any patch props + props = parsePatchProps(props, oldComponent?.props || {}); + + // Update the props dispatch( updateProps({ props, @@ -31,7 +38,10 @@ function set_props( }) ); dispatch(notifyObservers({id: idOrPath, props})); - const oldComponent = getComponentLayout(componentPath, state); + + if (!oldComponent) { + return; + } dispatch( setPaths( @@ -77,3 +87,4 @@ const dc = ((window as any).dash_clientside = (window as any).dash_clientside || {}); dc['set_props'] = set_props; dc['clean_url'] = dc['clean_url'] === undefined ? clean_url : dc['clean_url']; +dc['Patch'] = PatchBuilder; diff --git a/dash/dash.py b/dash/dash.py index c4181ef80e..8430259c27 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -569,6 +569,7 @@ def __init__( # pylint: disable=too-many-statements self.callback_map = {} # same deps as a list to catch duplicate outputs, and to send to the front end self._callback_list = [] + self.callback_api_paths = {} # list of inline scripts self._inline_scripts = [] @@ -783,6 +784,54 @@ def _setup_routes(self): # catch-all for front-end routes, used by dcc.Location self._add_url("", self.index) + def setup_apis(self): + """ + Register API endpoints for all callbacks defined using `dash.callback`. + + This method must be called after all callbacks are registered and before the app is served. + It ensures that all callback API routes are available for the Dash app to function correctly. + + Typical usage: + app = Dash(__name__) + # Register callbacks here + app.setup_apis() + app.run() + + If not called, callback endpoints will not be available and the app will not function as expected. + """ + for k in list(_callback.GLOBAL_API_PATHS): + if k in self.callback_api_paths: + raise DuplicateCallback( + f"The callback `{k}` provided with `dash.callback` was already " + "assigned with `app.callback`." + ) + self.callback_api_paths[k] = _callback.GLOBAL_API_PATHS.pop(k) + + def make_parse_body(func): + def _parse_body(): + if flask.request.is_json: + data = flask.request.get_json() + return flask.jsonify(func(**data)) + return flask.jsonify({}) + + return _parse_body + + def make_parse_body_async(func): + async def _parse_body_async(): + if flask.request.is_json: + data = flask.request.get_json() + result = await func(**data) + return flask.jsonify(result) + return flask.jsonify({}) + + return _parse_body_async + + for path, func in self.callback_api_paths.items(): + if asyncio.iscoroutinefunction(func): + self._add_url(path, make_parse_body_async(func), ["POST"]) + else: + self._add_url(path, make_parse_body(func), ["POST"]) + def _setup_plotlyjs(self): # pylint: disable=import-outside-toplevel from plotly.offline import get_plotlyjs_version @@ -1364,6 +1413,7 @@ def callback(self, *_args, **_kwargs) -> Callable[..., Any]: config_prevent_initial_callbacks=self.config.prevent_initial_callbacks, callback_list=self._callback_list, callback_map=self.callback_map, + callback_api_paths=self.callback_api_paths, **_kwargs, ) @@ -1516,6 +1566,7 @@ def dispatch(self): def _setup_server(self): if self._got_first_request["setup_server"]: return + self._got_first_request["setup_server"] = True # Apply _force_eager_loading overrides from modules diff --git a/tests/async_tests/test_async_callbacks.py b/tests/async_tests/test_async_callbacks.py index 13cb8418f9..4bd3fd0abd 100644 --- a/tests/async_tests/test_async_callbacks.py +++ b/tests/async_tests/test_async_callbacks.py @@ -376,6 +376,7 @@ async def set_path(n): dash_duo.wait_for_text_to_equal("#out", '[{"a": "/2:a"}] - /2') +@flaky.flaky(max_runs=3) def test_async_cbsc008_wildcard_prop_callbacks(dash_duo): if not is_dash_async(): return @@ -384,7 +385,7 @@ def test_async_cbsc008_wildcard_prop_callbacks(dash_duo): app = Dash(__name__) app.layout = html.Div( [ - dcc.Input(id="input", value="initial value"), + dcc.Input(id="input", value="initial value", debounce=False), html.Div( html.Div( [ @@ -427,6 +428,7 @@ async def update_text(data): for key in "hello world": with lock: input1.send_keys(key) + time.sleep(0.05) # allow some time for debounced callback to be sent dash_duo.wait_for_text_to_equal("#output-1", "hello world") assert dash_duo.find_element("#output-1").get_attribute("data-cb") == "hello world" diff --git a/tests/integration/callbacks/test_api_callback.py b/tests/integration/callbacks/test_api_callback.py new file mode 100644 index 0000000000..be06936c15 --- /dev/null +++ b/tests/integration/callbacks/test_api_callback.py @@ -0,0 +1,59 @@ +from dash import ( + Dash, + Input, + Output, + html, + ctx, +) +import requests +import json +from flask import jsonify + +test_string = ( + '{"step_0": "Data fetched - 1", "step_1": "Data fetched - 1", "step_2": "Data fetched - 1", ' + '"step_3": "Data fetched - 1", "step_4": "Data fetched - 1"}' +) + + +def test_apib001_api_callback(dash_duo): + + app = Dash(__name__) + app.layout = html.Div( + [ + html.Button("Slow Callback", id="slow-btn"), + html.Div(id="slow-output"), + ] + ) + + def get_data(n_clicks): + # Simulate an async data fetch + return f"Data fetched - {n_clicks}" + + @app.callback( + Output("slow-output", "children"), + Input("slow-btn", "n_clicks"), + prevent_initial_call=True, + api_endpoint="/api/slow_callback", # Example API path for the slow callback + ) + def slow_callback(n_clicks): + data = {} + for i in range(5): + data[f"step_{i}"] = get_data(n_clicks) + ret = f"{json.dumps(data)}" + if ctx: + return ret + return jsonify(ret) + + app.setup_apis() + + dash_duo.start_server(app) + + dash_duo.wait_for_element("#slow-btn").click() + dash_duo.wait_for_text_to_equal("#slow-output", test_string) + r = requests.post( + dash_duo.server_url + "/api/slow_callback", + json={"n_clicks": 1}, + headers={"Content-Type": "application/json"}, + ) + assert r.status_code == 200 + assert r.json() == test_string diff --git a/tests/integration/clientside/test_clientside_multiple_output_return_single_no_update.py b/tests/integration/clientside/test_clientside_multiple_output_return_single_no_update.py new file mode 100644 index 0000000000..4ba55a2c40 --- /dev/null +++ b/tests/integration/clientside/test_clientside_multiple_output_return_single_no_update.py @@ -0,0 +1,46 @@ +from dash import ( + Dash, + Input, + Output, + html, + clientside_callback, +) + + +def test_cmorsnu001_clientside_multiple_output_return_single_no_update(dash_duo): + app = Dash(__name__) + app.layout = html.Div( + [ + html.Button("trigger", id="trigger-demo"), + html.Div("demo1", id="output-demo1"), + html.Div("demo2", id="output-demo2"), + ], + style={"padding": 50}, + ) + + clientside_callback( + """(n_clicks) => { + try { + return window.dash_clientside.no_update; + } catch (e) { + return [null, null]; + } + }""", + Output("output-demo1", "children"), + Output("output-demo2", "children"), + Input("trigger-demo", "n_clicks"), + prevent_initial_call=True, + ) + + dash_duo.start_server(app) + + trigger_clicker = dash_duo.wait_for_element("#trigger-demo") + trigger_clicker.click() + dash_duo.wait_for_text_to_equal( + "#output-demo1", + "demo1", + ) + dash_duo.wait_for_text_to_equal( + "#output-demo2", + "demo2", + ) diff --git a/tests/integration/test_clientside_patch.py b/tests/integration/test_clientside_patch.py new file mode 100644 index 0000000000..7a41c520ee --- /dev/null +++ b/tests/integration/test_clientside_patch.py @@ -0,0 +1,685 @@ +import json + +import flaky + +from selenium.webdriver.common.keys import Keys + +from dash import Dash, html, dcc, Input, Output, State +from dash.testing.wait import until + + +@flaky.flaky(max_runs=3) +def test_pch_cs001_patch_operations_clientside(dash_duo): + app = Dash(__name__) + + app.layout = html.Div( + [ + html.Div([dcc.Input(id="set-value"), html.Button("Set", id="set-btn")]), + html.Div( + [dcc.Input(id="append-value"), html.Button("Append", id="append-btn")] + ), + html.Div( + [ + dcc.Input(id="prepend-value"), + html.Button("prepend", id="prepend-btn"), + ] + ), + html.Div( + [ + dcc.Input(id="insert-value"), + dcc.Input(id="insert-index", type="number", value=1), + html.Button("insert", id="insert-btn"), + ] + ), + html.Div( + [dcc.Input(id="extend-value"), html.Button("extend", id="extend-btn")] + ), + html.Div( + [dcc.Input(id="merge-value"), html.Button("Merge", id="merge-btn")] + ), + html.Button("Delete", id="delete-btn"), + html.Button("Delete index", id="delete-index"), + html.Button("Clear", id="clear-btn"), + html.Button("Reverse", id="reverse-btn"), + html.Button("Remove", id="remove-btn"), + dcc.Store( + data={ + "value": "unset", + "n_clicks": 0, + "array": ["initial"], + "delete": "Delete me", + }, + id="store", + ), + html.Div(id="store-content"), + ] + ) + + app.clientside_callback( + "function a(value) {return JSON.stringify(value)}", + Output("store-content", "children"), + Input("store", "data"), + ) + + app.clientside_callback( + """ + function a(n_clicks, value) { + const patch = new dash_clientside.Patch + return patch + .assign(["value"], value) + .add(["n_clicks"], 1) + .build(); + } + """, + Output("store", "data"), + Input("set-btn", "n_clicks"), + State("set-value", "value"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks, value) { + const patch = new dash_clientside.Patch + return patch + .append(["array"], value) + .add(["n_clicks"], 1) + .build(); + } + """, + Output("store", "data", allow_duplicate=True), + Input("append-btn", "n_clicks"), + State("append-value", "value"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks, value) { + const patch = new dash_clientside.Patch + return patch + .prepend(["array"], value) + .add(["n_clicks"], 1) + .build(); + } + """, + Output("store", "data", allow_duplicate=True), + Input("prepend-btn", "n_clicks"), + State("prepend-value", "value"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks, value) { + const patch = new dash_clientside.Patch + return patch + .extend(["array"], [value]) + .add(["n_clicks"], 1) + .build(); + } + """, + Output("store", "data", allow_duplicate=True), + Input("extend-btn", "n_clicks"), + State("extend-value", "value"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks, value) { + const patch = new dash_clientside.Patch + return patch + .merge([], {merged: value}) + .add(["n_clicks"], 1) + .build(); + } + """, + Output("store", "data", allow_duplicate=True), + Input("merge-btn", "n_clicks"), + State("merge-value", "value"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks) { + const patch = new dash_clientside.Patch + return patch + .delete(["delete"]) + .build(); + } + """, + Output("store", "data", allow_duplicate=True), + Input("delete-btn", "n_clicks"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks, value, index) { + const patch = new dash_clientside.Patch + return patch + .insert(["array"], index, value) + .build(); + } + """, + Output("store", "data", allow_duplicate=True), + Input("insert-btn", "n_clicks"), + State("insert-value", "value"), + State("insert-index", "value"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks) { + const patch = new dash_clientside.Patch + return patch + .delete(["array", 1]) + .delete(["array", -2]) + .build(); + } + """, + Output("store", "data", allow_duplicate=True), + Input("delete-index", "n_clicks"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks) { + const patch = new dash_clientside.Patch + return patch + .clear(["array"]) + .build(); + } + """, + Output("store", "data", allow_duplicate=True), + Input("clear-btn", "n_clicks"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks) { + const patch = new dash_clientside.Patch + return patch + .reverse(["array"]) + .build(); + } + """, + Output("store", "data", allow_duplicate=True), + Input("reverse-btn", "n_clicks"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks) { + const patch = new dash_clientside.Patch + return patch + .remove(["array"], "initial") + .build(); + } + """, + Output("store", "data", allow_duplicate=True), + Input("remove-btn", "n_clicks"), + prevent_initial_call=True, + ) + + dash_duo.start_server(app) + + assert dash_duo.get_logs() == [] + + def get_output(): + e = dash_duo.find_element("#store-content") + return json.loads(e.text) + + _input = dash_duo.find_element("#set-value") + _input.send_keys("Set Value") + dash_duo.find_element("#set-btn").click() + + until(lambda: get_output().get("value") == "Set Value", 2) + + _input = dash_duo.find_element("#append-value") + _input.send_keys("Append") + dash_duo.find_element("#append-btn").click() + + until(lambda: get_output().get("array") == ["initial", "Append"], 2) + + _input = dash_duo.find_element("#prepend-value") + _input.send_keys("Prepend") + dash_duo.find_element("#prepend-btn").click() + + until(lambda: get_output().get("array") == ["Prepend", "initial", "Append"], 2) + + _input = dash_duo.find_element("#extend-value") + _input.send_keys("Extend") + dash_duo.find_element("#extend-btn").click() + + until( + lambda: get_output().get("array") == ["Prepend", "initial", "Append", "Extend"], + 2, + ) + + undef = object() + until(lambda: get_output().get("merged", undef) is undef, 2) + + _input = dash_duo.find_element("#merge-value") + _input.send_keys("Merged") + dash_duo.find_element("#merge-btn").click() + + until(lambda: get_output().get("merged") == "Merged", 2) + + until(lambda: get_output().get("delete") == "Delete me", 2) + + dash_duo.find_element("#delete-btn").click() + + until(lambda: get_output().get("delete", undef) is undef, 2) + + _input = dash_duo.find_element("#insert-value") + _input.send_keys("Inserted") + dash_duo.find_element("#insert-btn").click() + + until( + lambda: get_output().get("array") + == [ + "Prepend", + "Inserted", + "initial", + "Append", + "Extend", + ], + 2, + ) + + _input.send_keys(" with negative index") + _input = dash_duo.find_element("#insert-index") + _input.send_keys(Keys.BACKSPACE) + _input.send_keys("-1") + dash_duo.find_element("#insert-btn").click() + + until( + lambda: get_output().get("array") + == [ + "Prepend", + "Inserted", + "initial", + "Append", + "Inserted with negative index", + "Extend", + ], + 2, + ) + + dash_duo.find_element("#delete-index").click() + until( + lambda: get_output().get("array") + == [ + "Prepend", + "initial", + "Append", + "Extend", + ], + 2, + ) + + dash_duo.find_element("#reverse-btn").click() + until( + lambda: get_output().get("array") + == [ + "Extend", + "Append", + "initial", + "Prepend", + ], + 2, + ) + + dash_duo.find_element("#remove-btn").click() + until( + lambda: get_output().get("array") + == [ + "Extend", + "Append", + "Prepend", + ], + 2, + ) + + dash_duo.find_element("#clear-btn").click() + until(lambda: get_output()["array"] == [], 2) + + +@flaky.flaky(max_runs=3) +def test_pch_cs002_patch_operations_set_props(dash_duo): + app = Dash(__name__) + + app.layout = html.Div( + [ + html.Div([dcc.Input(id="set-value"), html.Button("Set", id="set-btn")]), + html.Div( + [dcc.Input(id="append-value"), html.Button("Append", id="append-btn")] + ), + html.Div( + [ + dcc.Input(id="prepend-value"), + html.Button("prepend", id="prepend-btn"), + ] + ), + html.Div( + [ + dcc.Input(id="insert-value"), + dcc.Input(id="insert-index", type="number", value=1), + html.Button("insert", id="insert-btn"), + ] + ), + html.Div( + [dcc.Input(id="extend-value"), html.Button("extend", id="extend-btn")] + ), + html.Div( + [dcc.Input(id="merge-value"), html.Button("Merge", id="merge-btn")] + ), + html.Button("Delete", id="delete-btn"), + html.Button("Delete index", id="delete-index"), + html.Button("Clear", id="clear-btn"), + html.Button("Reverse", id="reverse-btn"), + html.Button("Remove", id="remove-btn"), + dcc.Store( + data={ + "value": "unset", + "n_clicks": 0, + "array": ["initial"], + "delete": "Delete me", + }, + id="store", + ), + html.Div(id="store-content"), + ] + ) + + app.clientside_callback( + "function a(value) {return JSON.stringify(value)}", + Output("store-content", "children"), + Input("store", "data"), + ) + + app.clientside_callback( + """ + function a(n_clicks, value) { + const patch = new dash_clientside.Patch + dash_clientside.set_props('store', {data: patch + .assign(["value"], value) + .add(["n_clicks"], 1) + .build()}); + } + """, + Input("set-btn", "n_clicks"), + State("set-value", "value"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks, value) { + const patch = new dash_clientside.Patch + dash_clientside.set_props('store', {data: patch + .append(["array"], value) + .add(["n_clicks"], 1) + .build()}); + } + """, + Input("append-btn", "n_clicks"), + State("append-value", "value"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks, value) { + const patch = new dash_clientside.Patch + dash_clientside.set_props('store', {data: patch + .prepend(["array"], value) + .add(["n_clicks"], 1) + .build()}); + } + """, + Input("prepend-btn", "n_clicks"), + State("prepend-value", "value"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks, value) { + const patch = new dash_clientside.Patch + dash_clientside.set_props('store', {data: patch + .extend(["array"], [value]) + .add(["n_clicks"], 1) + .build()}); + } + """, + Input("extend-btn", "n_clicks"), + State("extend-value", "value"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks, value) { + const patch = new dash_clientside.Patch + dash_clientside.set_props('store', {data: patch + .merge([], {merged: value}) + .add(["n_clicks"], 1) + .build()}); + } + """, + Input("merge-btn", "n_clicks"), + State("merge-value", "value"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks) { + const patch = new dash_clientside.Patch + dash_clientside.set_props('store', {data: patch + .delete(["delete"]) + .build()}); + } + """, + Input("delete-btn", "n_clicks"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks, value, index) { + const patch = new dash_clientside.Patch + dash_clientside.set_props('store', {data: patch + .insert(["array"], index, value) + .build()}); + } + """, + Input("insert-btn", "n_clicks"), + State("insert-value", "value"), + State("insert-index", "value"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks) { + const patch = new dash_clientside.Patch + dash_clientside.set_props('store', {data: patch + .delete(["array", 1]) + .delete(["array", -2]) + .build()}); + } + """, + Input("delete-index", "n_clicks"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks) { + const patch = new dash_clientside.Patch + dash_clientside.set_props('store', {data: patch + .clear(["array"]) + .build()}); + } + """, + Input("clear-btn", "n_clicks"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks) { + const patch = new dash_clientside.Patch + dash_clientside.set_props('store', {data: patch + .reverse(["array"]) + .build()}); + } + """, + Input("reverse-btn", "n_clicks"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks) { + const patch = new dash_clientside.Patch + dash_clientside.set_props('store', {data: patch + .remove(["array"], "initial") + .build()}); + } + """, + Input("remove-btn", "n_clicks"), + prevent_initial_call=True, + ) + + dash_duo.start_server(app) + + assert dash_duo.get_logs() == [] + + def get_output(): + e = dash_duo.find_element("#store-content") + return json.loads(e.text) + + _input = dash_duo.find_element("#set-value") + _input.send_keys("Set Value") + dash_duo.find_element("#set-btn").click() + + until(lambda: get_output().get("value") == "Set Value", 2) + + _input = dash_duo.find_element("#append-value") + _input.send_keys("Append") + dash_duo.find_element("#append-btn").click() + + until(lambda: get_output().get("array") == ["initial", "Append"], 2) + + _input = dash_duo.find_element("#prepend-value") + _input.send_keys("Prepend") + dash_duo.find_element("#prepend-btn").click() + + until(lambda: get_output().get("array") == ["Prepend", "initial", "Append"], 2) + + _input = dash_duo.find_element("#extend-value") + _input.send_keys("Extend") + dash_duo.find_element("#extend-btn").click() + + until( + lambda: get_output().get("array") == ["Prepend", "initial", "Append", "Extend"], + 2, + ) + + undef = object() + until(lambda: get_output().get("merged", undef) is undef, 2) + + _input = dash_duo.find_element("#merge-value") + _input.send_keys("Merged") + dash_duo.find_element("#merge-btn").click() + + until(lambda: get_output().get("merged") == "Merged", 2) + + until(lambda: get_output().get("delete") == "Delete me", 2) + + dash_duo.find_element("#delete-btn").click() + + until(lambda: get_output().get("delete", undef) is undef, 2) + + _input = dash_duo.find_element("#insert-value") + _input.send_keys("Inserted") + dash_duo.find_element("#insert-btn").click() + + until( + lambda: get_output().get("array") + == [ + "Prepend", + "Inserted", + "initial", + "Append", + "Extend", + ], + 2, + ) + + _input.send_keys(" with negative index") + _input = dash_duo.find_element("#insert-index") + _input.send_keys(Keys.BACKSPACE) + _input.send_keys("-1") + dash_duo.find_element("#insert-btn").click() + + until( + lambda: get_output().get("array") + == [ + "Prepend", + "Inserted", + "initial", + "Append", + "Inserted with negative index", + "Extend", + ], + 2, + ) + + dash_duo.find_element("#delete-index").click() + until( + lambda: get_output().get("array") + == [ + "Prepend", + "initial", + "Append", + "Extend", + ], + 2, + ) + + dash_duo.find_element("#reverse-btn").click() + until( + lambda: get_output().get("array") + == [ + "Extend", + "Append", + "initial", + "Prepend", + ], + 2, + ) + + dash_duo.find_element("#remove-btn").click() + until( + lambda: get_output().get("array") + == [ + "Extend", + "Append", + "Prepend", + ], + 2, + ) + + dash_duo.find_element("#clear-btn").click() + until(lambda: get_output()["array"] == [], 2) diff --git a/tests/integration/test_patch.py b/tests/integration/test_patch.py index c2b3b3f500..28cfd09f9b 100644 --- a/tests/integration/test_patch.py +++ b/tests/integration/test_patch.py @@ -4,7 +4,7 @@ from selenium.webdriver.common.keys import Keys -from dash import Dash, html, dcc, Input, Output, State, ALL, Patch +from dash import Dash, html, dcc, Input, Output, State, ALL, Patch, set_props from dash.testing.wait import until @@ -220,38 +220,39 @@ def get_output(): _input.send_keys("Set Value") dash_duo.find_element("#set-btn").click() - until(lambda: get_output()["value"] == "Set Value", 2) + until(lambda: get_output().get("value") == "Set Value", 2) _input = dash_duo.find_element("#append-value") _input.send_keys("Append") dash_duo.find_element("#append-btn").click() - until(lambda: get_output()["array"] == ["initial", "Append"], 2) + until(lambda: get_output().get("array") == ["initial", "Append"], 2) _input = dash_duo.find_element("#prepend-value") _input.send_keys("Prepend") dash_duo.find_element("#prepend-btn").click() - until(lambda: get_output()["array"] == ["Prepend", "initial", "Append"], 2) + until(lambda: get_output().get("array") == ["Prepend", "initial", "Append"], 2) _input = dash_duo.find_element("#extend-value") _input.send_keys("Extend") dash_duo.find_element("#extend-btn").click() until( - lambda: get_output()["array"] == ["Prepend", "initial", "Append", "Extend"], 2 + lambda: get_output().get("array") == ["Prepend", "initial", "Append", "Extend"], + 2, ) undef = object() - until(lambda: get_output().get("merge", undef) is undef, 2) + until(lambda: get_output().get("merged", undef) is undef, 2) _input = dash_duo.find_element("#merge-value") _input.send_keys("Merged") dash_duo.find_element("#merge-btn").click() - until(lambda: get_output()["merged"] == "Merged", 2) + until(lambda: get_output().get("merged") == "Merged", 2) - until(lambda: get_output()["delete"] == "Delete me", 2) + until(lambda: get_output().get("delete") == "Delete me", 2) dash_duo.find_element("#delete-btn").click() @@ -570,3 +571,316 @@ def on_merge(_): dash_duo.wait_for_text_to_equal( "#dict-store-output", '{"initial":"initial","merged":"merged"}' ) + + +@flaky.flaky(max_runs=3) +def test_pch007_patch_operations_side_updates(dash_duo): + + app = Dash(__name__) + + app.layout = html.Div( + [ + html.Div( + [ + dcc.Input(id="set-value"), + html.Button("Set", id="set-btn"), + ] + ), + html.Div( + [ + dcc.Input(id="append-value"), + html.Button("Append", id="append-btn"), + ] + ), + html.Div( + [ + dcc.Input(id="prepend-value"), + html.Button("prepend", id="prepend-btn"), + ] + ), + html.Div( + [ + dcc.Input(id="insert-value"), + dcc.Input(id="insert-index", type="number", value=1), + html.Button("insert", id="insert-btn"), + ] + ), + html.Div( + [ + dcc.Input(id="extend-value"), + html.Button("extend", id="extend-btn"), + ] + ), + html.Div( + [ + dcc.Input(id="merge-value"), + html.Button("Merge", id="merge-btn"), + ] + ), + html.Button("Delete", id="delete-btn"), + html.Button("Delete index", id="delete-index"), + html.Button("Clear", id="clear-btn"), + html.Button("Reverse", id="reverse-btn"), + html.Button("Remove", id="remove-btn"), + dcc.Store( + data={ + "value": "unset", + "n_clicks": 0, + "array": ["initial"], + "delete": "Delete me", + }, + id="store", + ), + html.Div(id="store-content"), + ] + ) + + app.clientside_callback( + "function(value) {return JSON.stringify(value)}", + Output("store-content", "children"), + Input("store", "data"), + ) + + @app.callback( + Input("set-btn", "n_clicks"), + State("set-value", "value"), + prevent_initial_call=True, + ) + def on_click(_, value): + p = Patch() + p.value = value + p.n_clicks += 1 + + set_props("store", {"data": p}) + + @app.callback( + Input("append-btn", "n_clicks"), + State("append-value", "value"), + prevent_initial_call=True, + ) + def on_click(_, value): + p = Patch() + p.array.append(value) + p.n_clicks += 1 + + set_props("store", {"data": p}) + + @app.callback( + Input("prepend-btn", "n_clicks"), + State("prepend-value", "value"), + prevent_initial_call=True, + ) + def on_click(_, value): + p = Patch() + p.array.prepend(value) + p.n_clicks += 1 + + set_props("store", {"data": p}) + + @app.callback( + Input("extend-btn", "n_clicks"), + State("extend-value", "value"), + prevent_initial_call=True, + ) + def on_click(_, value): + p = Patch() + p.array.extend([value]) + p.n_clicks += 1 + + set_props("store", {"data": p}) + + @app.callback( + Input("merge-btn", "n_clicks"), + State("merge-value", "value"), + prevent_initial_call=True, + ) + def on_click(_, value): + p = Patch() + p.update({"merged": value}) + p.n_clicks += 1 + + set_props("store", {"data": p}) + + @app.callback( + Input("delete-btn", "n_clicks"), + prevent_initial_call=True, + ) + def on_click(_): + p = Patch() + del p.delete + set_props("store", {"data": p}) + + @app.callback( + Input("insert-btn", "n_clicks"), + State("insert-value", "value"), + State("insert-index", "value"), + prevent_initial_call=True, + ) + def on_insert(_, value, index): + p = Patch() + p.array.insert(index, value) + + set_props("store", {"data": p}) + + @app.callback( + Input("delete-index", "n_clicks"), + prevent_initial_call=True, + ) + def on_click(_): + p = Patch() + del p.array[1] + del p.array[-2] + + set_props("store", {"data": p}) + + @app.callback( + Input("clear-btn", "n_clicks"), + prevent_initial_call=True, + ) + def on_clear(_): + p = Patch() + p.array.clear() + + set_props("store", {"data": p}) + + @app.callback( + Input("reverse-btn", "n_clicks"), + prevent_initial_call=True, + ) + def on_reverse(_): + p = Patch() + p.array.reverse() + + set_props("store", {"data": p}) + + @app.callback( + Input("remove-btn", "n_clicks"), + prevent_initial_call=True, + ) + def on_remove(_): + p = Patch() + p.array.remove("initial") + set_props("store", {"data": p}) + + dash_duo.start_server(app) + + assert dash_duo.get_logs() == [] + + def get_output(): + e = dash_duo.find_element("#store-content") + return json.loads(e.text) + + _input = dash_duo.find_element("#set-value") + _input.send_keys("Set Value") + dash_duo.find_element("#set-btn").click() + + until(lambda: get_output().get("value") == "Set Value", 2) + + _input = dash_duo.find_element("#append-value") + _input.send_keys("Append") + dash_duo.find_element("#append-btn").click() + + until(lambda: get_output().get("array") == ["initial", "Append"], 2) + + _input = dash_duo.find_element("#prepend-value") + _input.send_keys("Prepend") + dash_duo.find_element("#prepend-btn").click() + + until(lambda: get_output().get("array") == ["Prepend", "initial", "Append"], 2) + + _input = dash_duo.find_element("#extend-value") + _input.send_keys("Extend") + dash_duo.find_element("#extend-btn").click() + + until( + lambda: get_output().get("array") == ["Prepend", "initial", "Append", "Extend"], + 2, + ) + + undef = object() + until(lambda: get_output().get("merged", undef) is undef, 2) + + _input = dash_duo.find_element("#merge-value") + _input.send_keys("Merged") + dash_duo.find_element("#merge-btn").click() + + until(lambda: get_output().get("merged") == "Merged", 2) + + until(lambda: get_output().get("delete") == "Delete me", 2) + + dash_duo.find_element("#delete-btn").click() + + until(lambda: get_output().get("delete", undef) is undef, 2) + + _input = dash_duo.find_element("#insert-value") + _input.send_keys("Inserted") + dash_duo.find_element("#insert-btn").click() + + until( + lambda: get_output().get("array") + == [ + "Prepend", + "Inserted", + "initial", + "Append", + "Extend", + ], + 2, + ) + + _input.send_keys(" with negative index") + _input = dash_duo.find_element("#insert-index") + _input.send_keys(Keys.BACKSPACE) + _input.send_keys("-1") + dash_duo.find_element("#insert-btn").click() + + until( + lambda: get_output().get("array") + == [ + "Prepend", + "Inserted", + "initial", + "Append", + "Inserted with negative index", + "Extend", + ], + 2, + ) + + dash_duo.find_element("#delete-index").click() + until( + lambda: get_output().get("array") + == [ + "Prepend", + "initial", + "Append", + "Extend", + ], + 2, + ) + + dash_duo.find_element("#reverse-btn").click() + until( + lambda: get_output().get("array") + == [ + "Extend", + "Append", + "initial", + "Prepend", + ], + 2, + ) + + dash_duo.find_element("#remove-btn").click() + until( + lambda: get_output().get("array") + == [ + "Extend", + "Append", + "Prepend", + ], + 2, + ) + + dash_duo.find_element("#clear-btn").click() + until(lambda: get_output()["array"] == [], 2)