Skip to content

Commit efb9d32

Browse files
authored
Merge pull request #2876 from plotly/fix/pmc-side-updates
Fix pattern matching in side updates (running/progress/set_props)
2 parents b1c6efb + 88a4c94 commit efb9d32

File tree

7 files changed

+229
-27
lines changed

7 files changed

+229
-27
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
1111
- [#2859](https://github.com/plotly/dash/pull/2859) Fix base patch operators. fixes [#2855](https://github.com/plotly/dash/issues/2855)
1212
- [#2856](https://github.com/plotly/dash/pull/2856) Fix multiple consecutive calls with same id to set_props only keeping the last props. Fixes [#2852](https://github.com/plotly/dash/issues/2852)
1313
- [#2867](https://github.com/plotly/dash/pull/2867) Fix clientside no output callback. Fixes [#2866](https://github.com/plotly/dash/issues/2866)
14+
- [#2876](https://github.com/plotly/dash/pull/2876) Fix pattern matching in callback running argument. Fixes [#2863](https://github.com/plotly/dash/issues/2863)
1415

1516
## [2.17.0] - 2024-05-03
1617

dash/dash-renderer/src/actions/callbacks.ts

Lines changed: 60 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ import {
3131
IPrioritizedCallback,
3232
LongCallbackInfo,
3333
CallbackResponse,
34-
CallbackResponseData
34+
CallbackResponseData,
35+
SideUpdateOutput
3536
} from '../types/callbacks';
3637
import {isMultiValued, stringifyId, isMultiOutputProp} from './dependencies';
3738
import {urlBase} from './utils';
@@ -44,6 +45,8 @@ import {handlePatch, isPatch} from './patch';
4445
import {getPath} from './paths';
4546

4647
import {requestDependencies} from './requestDependencies';
48+
import {parsePMCId} from './patternMatching';
49+
import {replacePMC} from './patternMatching';
4750

4851
export const addBlockedCallbacks = createAction<IBlockedCallback[]>(
4952
CallbackActionType.AddBlocked
@@ -340,33 +343,53 @@ function updateComponent(component_id: any, props: any) {
340343
};
341344
}
342345

343-
function sideUpdate(outputs: any, dispatch: any) {
344-
toPairs(outputs).forEach(([id, value]) => {
345-
let componentId = id,
346-
propName;
346+
/**
347+
* Update a component props with `running`/`progress`/`set_props` calls.
348+
*
349+
* @param outputs Props to update.
350+
* @param cb The originating callback info.
351+
* @returns
352+
*/
353+
function sideUpdate(outputs: SideUpdateOutput, cb: ICallbackPayload) {
354+
return function (dispatch: any, getState: any) {
355+
toPairs(outputs)
356+
.reduce((acc, [id, value], i) => {
357+
let componentId = id,
358+
propName,
359+
replacedIds = [];
360+
361+
if (id.startsWith('{')) {
362+
[componentId, propName] = parsePMCId(id);
363+
replacedIds = replacePMC(componentId, cb, i, getState);
364+
} else if (id.includes('.')) {
365+
[componentId, propName] = id.split('.');
366+
}
347367

348-
if (id.startsWith('{')) {
349-
const index = id.lastIndexOf('}');
350-
if (index + 2 < id.length) {
351-
propName = id.substring(index + 2);
352-
componentId = JSON.parse(id.substring(0, index + 1));
353-
} else {
354-
componentId = JSON.parse(id);
355-
}
356-
} else if (id.includes('.')) {
357-
[componentId, propName] = id.split('.');
358-
}
368+
const props = propName ? {[propName]: value} : value;
359369

360-
const props = propName ? {[propName]: value} : value;
361-
dispatch(updateComponent(componentId, props));
362-
});
370+
if (replacedIds.length === 0) {
371+
acc.push([componentId, props]);
372+
} else if (replacedIds.length === 1) {
373+
acc.push([replacedIds[0], props]);
374+
} else {
375+
replacedIds.forEach((rep: any) => {
376+
acc.push([rep, props]);
377+
});
378+
}
379+
380+
return acc;
381+
}, [] as any[])
382+
.forEach(([id, idProps]) => {
383+
dispatch(updateComponent(id, idProps));
384+
});
385+
};
363386
}
364387

365388
function handleServerside(
366389
dispatch: any,
367390
hooks: any,
368391
config: any,
369-
payload: any,
392+
payload: ICallbackPayload,
370393
long: LongCallbackInfo | undefined,
371394
additionalArgs: [string, string, boolean?][] | undefined,
372395
getState: any,
@@ -386,7 +409,7 @@ function handleServerside(
386409
let moreArgs = additionalArgs;
387410

388411
if (running) {
389-
sideUpdate(running.running, dispatch);
412+
dispatch(sideUpdate(running.running, payload));
390413
runningOff = running.runningOff;
391414
}
392415

@@ -496,10 +519,10 @@ function handleServerside(
496519
dispatch(removeCallbackJob({jobId: job}));
497520
}
498521
if (runningOff) {
499-
sideUpdate(runningOff, dispatch);
522+
dispatch(sideUpdate(runningOff, payload));
500523
}
501524
if (progressDefault) {
502-
sideUpdate(progressDefault, dispatch);
525+
dispatch(sideUpdate(progressDefault, payload));
503526
}
504527
};
505528

@@ -522,11 +545,11 @@ function handleServerside(
522545
}
523546

524547
if (data.sideUpdate) {
525-
sideUpdate(data.sideUpdate, dispatch);
548+
dispatch(sideUpdate(data.sideUpdate, payload));
526549
}
527550

528551
if (data.progress) {
529-
sideUpdate(data.progress, dispatch);
552+
dispatch(sideUpdate(data.progress, payload));
530553
}
531554
if (!progressDefault && data.progressDefault) {
532555
progressDefault = data.progressDefault;
@@ -671,11 +694,19 @@ export function executeCallback(
671694

672695
const __execute = async (): Promise<CallbackResult> => {
673696
try {
697+
const changedPropIds = keys<string>(cb.changedPropIds);
698+
const parsedChangedPropsIds = changedPropIds.map(propId => {
699+
if (propId.startsWith('{')) {
700+
return parsePMCId(propId)[0];
701+
}
702+
return propId;
703+
});
674704
const payload: ICallbackPayload = {
675705
output,
676706
outputs: isMultiOutputProp(output) ? outputs : outputs[0],
677707
inputs: inVals,
678-
changedPropIds: keys(cb.changedPropIds),
708+
changedPropIds,
709+
parsedChangedPropsIds,
679710
state: cb.callback.state.length
680711
? fillVals(paths, layout, cb, state, 'State')
681712
: undefined
@@ -721,7 +752,9 @@ export function executeCallback(
721752
if (inter.length) {
722753
additionalArgs.push(['cancelJob', job.jobId]);
723754
if (job.progressDefault) {
724-
sideUpdate(job.progressDefault, dispatch);
755+
dispatch(
756+
sideUpdate(job.progressDefault, payload)
757+
);
725758
}
726759
}
727760
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import {keys, equals, dissoc, toPairs} from 'ramda';
2+
import {ICallbackPayload} from '../types/callbacks';
3+
4+
/**
5+
* Deserialize pattern matching ids that come in one of the form:
6+
* - '{"type":"component","index":["MATCH"]}.children'
7+
* - '{"type":"component","index":["MATCH"]}'
8+
*
9+
* @param id The raw object as a string id.
10+
* @returns The id object.
11+
*/
12+
export function parsePMCId(id: string): [any, string | undefined] {
13+
let componentId, propName;
14+
const index = id.lastIndexOf('}');
15+
if (index + 2 < id.length) {
16+
propName = id.substring(index + 2);
17+
componentId = JSON.parse(id.substring(0, index + 1));
18+
} else {
19+
componentId = JSON.parse(id);
20+
}
21+
return [componentId, propName];
22+
}
23+
24+
/**
25+
* Get all the associated ids for an id.
26+
*
27+
* @param id Id to get all the pmc ids from.
28+
* @param state State of the store.
29+
* @param triggerKey Key to remove from the equality comparison.
30+
* @returns
31+
*/
32+
export function getAllPMCIds(id: any, state: any, triggerKey: string) {
33+
const keysOfIds = keys(id);
34+
const idKey = keysOfIds.join(',');
35+
return state.paths.objs[idKey]
36+
.map((obj: any) =>
37+
keysOfIds.reduce((acc, key, i) => {
38+
acc[key] = obj.values[i];
39+
return acc;
40+
}, {} as any)
41+
)
42+
.filter((obj: any) =>
43+
equals(dissoc(triggerKey, obj), dissoc(triggerKey, id))
44+
);
45+
}
46+
47+
/**
48+
* Replace the pattern matching ids with the actual trigger value
49+
* for MATCH, all the ids for ALL and smaller than the trigger value
50+
* for ALLSMALLER.
51+
*
52+
* @param id The parsed id in dictionary format.
53+
* @param cb Original callback info.
54+
* @param index Index of the dependency in case there is more than one changed id.
55+
* @param getState Function to get the state of the redux store.
56+
* @returns List of replaced ids.
57+
*/
58+
export function replacePMC(
59+
id: any,
60+
cb: ICallbackPayload,
61+
index: number,
62+
getState: any
63+
): any[] {
64+
let extras: any = [];
65+
const replaced: any = {};
66+
toPairs(id).forEach(([key, value]) => {
67+
if (extras.length) {
68+
// All done.
69+
return;
70+
}
71+
if (Array.isArray(value)) {
72+
const triggerValue = (cb.parsedChangedPropsIds[index] ||
73+
cb.parsedChangedPropsIds[0])[key];
74+
if (value.includes('MATCH')) {
75+
replaced[key] = triggerValue;
76+
} else if (value.includes('ALL')) {
77+
extras = getAllPMCIds(id, getState(), key);
78+
} else if (value.includes('ALLSMALLER')) {
79+
extras = getAllPMCIds(id, getState(), key).filter(
80+
(obj: any) => obj[key] < triggerValue
81+
);
82+
}
83+
} else {
84+
replaced[key] = value;
85+
}
86+
});
87+
if (extras.length) {
88+
return extras;
89+
}
90+
return [replaced];
91+
}

dash/dash-renderer/src/types/callbacks.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export interface IStoredCallback extends IExecutedCallback {
7272

7373
export interface ICallbackPayload {
7474
changedPropIds: any[];
75+
parsedChangedPropsIds: any[];
7576
inputs: any[];
7677
output: string;
7778
outputs: any[];
@@ -106,3 +107,7 @@ export type CallbackResponseData = {
106107
cancel?: ICallbackProperty[];
107108
sideUpdate?: any;
108109
};
110+
111+
export type SideUpdateOutput = {
112+
[key: string]: any;
113+
};

tests/integration/callbacks/test_wildcards.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import re
33
from selenium.webdriver.common.keys import Keys
44
import json
5+
from multiprocessing import Lock
56

67
from dash.testing import wait
78
import dash
@@ -552,3 +553,69 @@ def update_selected_values(values):
552553
dash_duo.wait_for_text_to_equal(
553554
"#selected-values", "['option0-2', 'option1-2', 'option2-2']"
554555
)
556+
557+
558+
def test_cbwc008_running_match(dash_duo):
559+
lock = Lock()
560+
app = dash.Dash()
561+
562+
app.layout = [
563+
html.Div(
564+
[
565+
html.Button(
566+
"Test1",
567+
id={"component": "button", "index": "1"},
568+
),
569+
html.Button(
570+
"Test2",
571+
id={"component": "button", "index": "2"},
572+
),
573+
],
574+
id="buttons",
575+
),
576+
html.Div(html.Div(id={"component": "output", "index": "1"}), id="output1"),
577+
html.Div(html.Div(id={"component": "output", "index": "2"}), id="output2"),
578+
]
579+
580+
@app.callback(
581+
Output({"component": "output", "index": MATCH}, "children"),
582+
Input({"component": "button", "index": MATCH}, "n_clicks"),
583+
running=[
584+
(
585+
Output({"component": "button", "index": MATCH}, "children"),
586+
"running",
587+
"finished",
588+
),
589+
(Output({"component": "button", "index": ALL}, "disabled"), True, False),
590+
],
591+
prevent_initial_call=True,
592+
)
593+
def on_click(_) -> str:
594+
with lock:
595+
return "done"
596+
597+
dash_duo.start_server(app)
598+
599+
for i in range(1, 3):
600+
with lock:
601+
dash_duo.find_element(f"#buttons button:nth-child({i})").click()
602+
dash_duo.wait_for_text_to_equal(
603+
f"#buttons button:nth-child({i})", "running"
604+
)
605+
# verify all the buttons were disabled.
606+
assert dash_duo.find_element("#buttons button:nth-child(1)").get_attribute(
607+
"disabled"
608+
)
609+
assert dash_duo.find_element("#buttons button:nth-child(2)").get_attribute(
610+
"disabled"
611+
)
612+
613+
dash_duo.wait_for_text_to_equal(f"#output{i}", "done")
614+
dash_duo.wait_for_text_to_equal(f"#buttons button:nth-child({i})", "finished")
615+
616+
assert not dash_duo.find_element("#buttons button:nth-child(1)").get_attribute(
617+
"disabled"
618+
)
619+
assert not dash_duo.find_element("#buttons button:nth-child(2)").get_attribute(
620+
"disabled"
621+
)

tests/integration/renderer/test_request_hooks.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,13 +95,15 @@ def update_output(value):
9595
"output": "output-1.children",
9696
"outputs": {"id": "output-1", "property": "children"},
9797
"changedPropIds": ["input.value"],
98+
"parsedChangedPropsIds": ["input.value"],
9899
"inputs": [{"id": "input", "property": "value", "value": "fire request hooks"}],
99100
}
100101

101102
assert json.loads(dash_duo.find_element("#output-post-payload").text) == {
102103
"output": "output-1.children",
103104
"outputs": {"id": "output-1", "property": "children"},
104105
"changedPropIds": ["input.value"],
106+
"parsedChangedPropsIds": ["input.value"],
105107
"inputs": [{"id": "input", "property": "value", "value": "fire request hooks"}],
106108
}
107109

tests/integration/test_patch.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import json
22

3+
import flaky
4+
35
from selenium.webdriver.common.keys import Keys
46

57
from dash import Dash, html, dcc, Input, Output, State, ALL, Patch
68

79

10+
@flaky.flaky(max_runs=3)
811
def test_pch001_patch_operations(dash_duo):
912

1013
app = Dash(__name__)

0 commit comments

Comments
 (0)