diff --git a/CHANGELOG.md b/CHANGELOG.md index a6a2224907..28cb1c9850 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ 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 diff --git a/dash/dash-renderer/src/actions/callbacks.ts b/dash/dash-renderer/src/actions/callbacks.ts index ff677fee94..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'; @@ -419,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, @@ -809,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}; @@ -873,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/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/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)