Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 57 additions & 18 deletions dash/dash-renderer/src/actions/callbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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};
Expand Down Expand Up @@ -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) {
Expand Down
163 changes: 163 additions & 0 deletions dash/dash-renderer/src/actions/patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -166,3 +303,29 @@ export function handlePatch<T>(previousValue: T, patchValue: any): T {

return reducedValue;
}

export function parsePatchProps(
props: any,
previousProps: any
): Record<string, any> {
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;
}
13 changes: 12 additions & 1 deletion dash/dash-renderer/src/utils/clientsideFunctions.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand All @@ -31,7 +38,10 @@ function set_props(
})
);
dispatch(notifyObservers({id: idOrPath, props}));
const oldComponent = getComponentLayout(componentPath, state);

if (!oldComponent) {
return;
}

dispatch(
setPaths(
Expand Down Expand Up @@ -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;
4 changes: 3 additions & 1 deletion tests/async_tests/test_async_callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
[
Expand Down Expand Up @@ -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"
Expand Down
Loading