diff --git a/@plotly/dash-test-components/src/components/ComponentAsProp.js b/@plotly/dash-test-components/src/components/ComponentAsProp.js index 52cf52d848..79ea4e5946 100644 --- a/@plotly/dash-test-components/src/components/ComponentAsProp.js +++ b/@plotly/dash-test-components/src/components/ComponentAsProp.js @@ -3,7 +3,17 @@ import PropTypes from 'prop-types'; const ComponentAsProp = (props) => { - const { element, id, shapeEl, list_of_shapes, multi_components } = props; + const { + element, + id, + shapeEl, + list_of_shapes, + multi_components, + dynamic, + dynamic_list, + dynamic_dict, + dynamic_nested_list, + } = props; return (
{shapeEl && shapeEl.header} @@ -11,6 +21,18 @@ const ComponentAsProp = (props) => { {shapeEl && shapeEl.footer} {list_of_shapes && } {multi_components &&
{multi_components.map(m =>
{m.first} - {m.second}
)}
} + { + dynamic &&
{Object.keys(dynamic).map(key =>
{dynamic[key]}
)}
+ } + { + dynamic_dict && dynamic_dict.node &&
{Object.keys(dynamic_dict.node).map(key =>
{dynamic_dict.node[key]}
)}
+ } + { + dynamic_list &&
{dynamic_list.map((obj, i) => Object.keys(obj).map(key =>
{obj[key]}
))}
+ } + { + dynamic_nested_list &&
{dynamic_nested_list.map((e => <>{Object.values(e.obj)}))}
+ }
) } @@ -37,7 +59,18 @@ ComponentAsProp.propTypes = { first: PropTypes.node, second: PropTypes.node, }) - ) + ), + + dynamic: PropTypes.objectOf(PropTypes.node), + + dynamic_list: PropTypes.arrayOf(PropTypes.objectOf(PropTypes.node)), + + dynamic_dict: PropTypes.shape({ + node: PropTypes.objectOf(PropTypes.node), + }), + dynamic_nested_list: PropTypes.arrayOf( + PropTypes.shape({ obj: PropTypes.objectOf(PropTypes.node)}) + ), } export default ComponentAsProp; diff --git a/CHANGELOG.md b/CHANGELOG.md index 4600b8a068..6ac8ca9c53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## Fixed - [#2508](https://github.com/plotly/dash/pull/2508) Fix error message, when callback output has different length than spec +- [#2207](https://github.com/plotly/dash/pull/2207) Fix object of components support. ## [2.9.3] - 2023-04-13 diff --git a/dash/dash-renderer/src/TreeContainer.js b/dash/dash-renderer/src/TreeContainer.js index 47fb8ee0da..259fab801b 100644 --- a/dash/dash-renderer/src/TreeContainer.js +++ b/dash/dash-renderer/src/TreeContainer.js @@ -14,6 +14,7 @@ import { has, keys, map, + mapObjIndexed, mergeRight, pick, pickBy, @@ -252,6 +253,14 @@ class BaseTreeContainer extends Component { for (let i = 0; i < childrenProps.length; i++) { const childrenProp = childrenProps[i]; + + const handleObject = (obj, opath) => { + return mapObjIndexed( + (node, k) => this.wrapChildrenProp(node, [...opath, k]), + obj + ); + }; + if (childrenProp.includes('.')) { let path = childrenProp.split('.'); let node; @@ -259,17 +268,33 @@ class BaseTreeContainer extends Component { if (childrenProp.includes('[]')) { let frontPath = [], backPath = [], - found = false; + found = false, + hasObject = false; path.forEach(p => { if (!found) { if (p.includes('[]')) { found = true; - frontPath.push(p.replace('[]', '')); + if (p.includes('{}')) { + hasObject = true; + frontPath.push( + p.replace('{}', '').replace('[]', '') + ); + } else { + frontPath.push(p.replace('[]', '')); + } + } else if (p.includes('{}')) { + hasObject = true; + frontPath.push(p.replace('{}', '')); } else { frontPath.push(p); } } else { - backPath.push(p); + if (p.includes('{}')) { + hasObject = true; + backPath.push(p.replace('{}', '')); + } else { + backPath.push(p); + } } }); @@ -281,38 +306,120 @@ class BaseTreeContainer extends Component { if (!firstNode) { continue; } + nodeValue = node.map((element, i) => { const elementPath = concat( frontPath, concat([i], backPath) ); - return assocPath( - backPath, - this.wrapChildrenProp( + let listValue; + if (hasObject) { + if (backPath.length) { + listValue = handleObject( + rpath(backPath, element), + elementPath + ); + } else { + listValue = handleObject(element, elementPath); + } + } else { + listValue = this.wrapChildrenProp( rpath(backPath, element), elementPath - ), - element - ); + ); + } + return assocPath(backPath, listValue, element); }); path = frontPath; } else { - node = rpath(path, props); - if (node === undefined) { - continue; + if (childrenProp.includes('{}')) { + // Only supports one level of nesting. + const front = []; + let dynamic = []; + let hasBack = false; + const backPath = []; + + for (let j = 0; j < path.length; j++) { + const cur = path[j]; + if (cur.includes('{}')) { + dynamic = concat(front, [ + cur.replace('{}', '') + ]); + if (j < path.length - 1) { + hasBack = true; + } + } else { + if (hasBack) { + backPath.push(cur); + } else { + front.push(cur); + } + } + } + + const dynValue = rpath(dynamic, props); + if (dynValue !== undefined) { + nodeValue = mapObjIndexed( + (d, k) => + this.wrapChildrenProp( + hasBack ? rpath(backPath, d) : d, + hasBack + ? concat( + dynamic, + concat([k], backPath) + ) + : concat(dynamic, [k]) + ), + dynValue + ); + path = dynamic; + } + } else { + node = rpath(path, props); + if (node === undefined) { + continue; + } + nodeValue = this.wrapChildrenProp(node, path); } - nodeValue = this.wrapChildrenProp(node, path); } props = assocPath(path, nodeValue, props); - continue; - } - const node = props[childrenProp]; - if (node !== undefined) { - props = assoc( - childrenProp, - this.wrapChildrenProp(node, [childrenProp]), - props - ); + } else { + if (childrenProp.includes('{}')) { + let opath = childrenProp.replace('{}', ''); + const isArray = childrenProp.includes('[]'); + if (isArray) { + opath = opath.replace('[]', ''); + } + const node = props[opath]; + + if (node !== undefined) { + if (isArray) { + for (let j = 0; j < node.length; j++) { + const aPath = concat([opath], [j]); + props = assocPath( + aPath, + handleObject(node[j], aPath), + props + ); + } + } else { + props = assoc( + opath, + handleObject(node, [opath]), + props + ); + } + } + } else { + const node = props[childrenProp]; + if (node !== undefined) { + props = assoc( + childrenProp, + this.wrapChildrenProp(node, [childrenProp]), + props + ); + } + } } } diff --git a/dash/dash-renderer/src/actions/callbacks.ts b/dash/dash-renderer/src/actions/callbacks.ts index 33426c7238..cb04852349 100644 --- a/dash/dash-renderer/src/actions/callbacks.ts +++ b/dash/dash-renderer/src/actions/callbacks.ts @@ -163,7 +163,7 @@ function fillVals( inputList.map(({id, property, path: path_}: any) => ({ id, property, - value: (path(path_, layout) as any).props[property] + value: path([...path_, 'props', property], layout) as any })), specs[i], cb.anyVals, diff --git a/dash/dash-renderer/src/actions/utils.js b/dash/dash-renderer/src/actions/utils.js index 621c3421e1..eb9f382adc 100644 --- a/dash/dash-renderer/src/actions/utils.js +++ b/dash/dash-renderer/src/actions/utils.js @@ -1,4 +1,14 @@ -import {append, concat, has, path, pathOr, type, path as rpath} from 'ramda'; +import { + append, + concat, + has, + path, + pathOr, + type, + findIndex, + includes, + slice +} from 'ramda'; /* * requests_pathname_prefix is the new config parameter introduced in @@ -37,11 +47,44 @@ export const crawlLayout = ( // children array object.forEach((child, i) => { if (extraPath) { - crawlLayout( - rpath(extraPath, child), - func, - concat(currentPath, concat([i], extraPath)) - ); + const objOf = findIndex(p => includes('{}', p), extraPath); + if (objOf !== -1) { + const front = slice(0, objOf, extraPath); + const back = slice(objOf, extraPath.length, extraPath); + if (front.length) { + crawlLayout( + path(front, child), + func, + concat(currentPath, concat([i], front)), + back + ); + } else { + const backPath = back + .map(p => p.replace('{}', '')) + .filter(e => e); + let childObj, + childPath = concat([i], backPath); + if (backPath.length) { + childObj = path(backPath, child); + } else { + childObj = child; + } + for (const key in childObj) { + const value = childObj[key]; + crawlLayout( + value, + func, + concat(currentPath, childPath.concat([key])) + ); + } + } + } else { + crawlLayout( + path(extraPath, child), + func, + concat(currentPath, concat([i], extraPath)) + ); + } } else { crawlLayout(child, func, append(i, currentPath)); } @@ -65,19 +108,61 @@ export const crawlLayout = ( let [frontPath, backPath] = childrenProp .split('[]') .map(p => p.split('.').filter(e => e)); + const front = concat(['props'], frontPath); const basePath = concat(currentPath, front); crawlLayout(path(front, object), func, basePath, backPath); } else { - const newPath = concat(currentPath, [ - 'props', - ...childrenProp.split('.') - ]); - crawlLayout( - path(['props', ...childrenProp.split('.')], object), - func, - newPath - ); + if (childrenProp.includes('{}')) { + const opath = childrenProp.split('.'); + const frontPath = []; + const backPath = []; + let found = false; + + for (let i = 0; i < opath.length; i++) { + const curPath = opath[i]; + if (!found && curPath.includes('{}')) { + found = true; + frontPath.push(curPath.replace('{}', '')); + } else { + if (found) { + backPath.push(curPath); + } else { + frontPath.push(curPath); + } + } + } + const newPath = concat(currentPath, [ + 'props', + ...frontPath + ]); + + const oValue = path(['props', ...frontPath], object); + if (oValue !== undefined) { + for (const key in oValue) { + const value = oValue[key]; + if (backPath.length) { + crawlLayout( + path(backPath, value), + func, + concat(newPath, [key, ...backPath]) + ); + } else { + crawlLayout(value, func, [...newPath, key]); + } + } + } + } else { + const newPath = concat(currentPath, [ + 'props', + ...childrenProp.split('.') + ]); + crawlLayout( + path(['props', ...childrenProp.split('.')], object), + func, + newPath + ); + } } }); } diff --git a/dash/development/_collect_nodes.py b/dash/development/_collect_nodes.py index c2e04a69ef..da19df021d 100644 --- a/dash/development/_collect_nodes.py +++ b/dash/development/_collect_nodes.py @@ -14,6 +14,8 @@ def collect_array(a_value, base, nodes): nodes = collect_nodes(a_value["value"], base + "[]", nodes) elif a_type == "union": nodes = collect_union(a_value["value"], base + "[]", nodes) + elif a_type == "objectOf": + nodes = collect_object(a_value["value"], base + "[]", nodes) return nodes @@ -25,6 +27,22 @@ def collect_union(type_list, base, nodes): nodes = collect_nodes(t["value"], base, nodes) elif t["name"] == "arrayOf": nodes = collect_array(t["value"], base, nodes) + elif t["name"] == "objectOf": + nodes = collect_object(t["value"], base, nodes) + return nodes + + +def collect_object(o_value, base, nodes): + o_name = o_value.get("name") + o_key = base + "{}" + if is_node(o_name): + nodes.append(o_key) + elif is_shape(o_name): + nodes = collect_nodes(o_value.get("value", {}), o_key, nodes) + elif o_name == "union": + nodes = collect_union(o_value.get("value"), o_key, nodes) + elif o_name == "arrayOf": + nodes = collect_array(o_value, o_key, nodes) return nodes @@ -49,9 +67,12 @@ def collect_nodes(metadata, base="", nodes=None): nodes = collect_nodes(t_value["value"], key, nodes) elif p_type == "union": nodes = collect_union(t_value["value"], key, nodes) + elif p_type == "objectOf": + o_value = t_value.get("value", {}) + nodes = collect_object(o_value, key, nodes) return nodes def filter_base_nodes(nodes): - return [n for n in nodes if not any(e in n for e in ("[]", "."))] + return [n for n in nodes if not any(e in n for e in ("[]", ".", "{}"))] diff --git a/tests/integration/renderer/test_component_as_prop.py b/tests/integration/renderer/test_component_as_prop.py index 9abb3a813b..b1f3324cb4 100644 --- a/tests/integration/renderer/test_component_as_prop.py +++ b/tests/integration/renderer/test_component_as_prop.py @@ -121,6 +121,38 @@ def test_rdcap001_component_as_prop(dash_duo): }, ], ), + ComponentAsProp( + dynamic={ + "inside-dynamic": Div("dynamic", "inside-dynamic"), + "output-dynamic": Div(id="output-dynamic"), + "clicker": Button("click-dynamic", id="click-dynamic"), + "clicker-dict": Button("click-dict", id="click-dict"), + "clicker-list": Button("click-list", id="click-list"), + "clicker-nested": Button("click-nested", id="click-nested"), + }, + dynamic_dict={ + "node": { + "dict-dyn": Div("dict-dyn", id="inside-dict"), + "dict-2": Div("dict-2", id="inside-dict-2"), + } + }, + dynamic_list=[ + { + "list": Div("dynamic-list", id="inside-list"), + "list-2": Div("list-2", id="inside-list-2"), + }, + {"list-3": Div("list-3", id="inside-list-3")}, + ], + dynamic_nested_list=[ + {"obj": {"nested": Div("nested", id="nested-dyn")}}, + { + "obj": { + "nested": Div("nested-2", id="nested-2"), + "nested-again": Div("nested-again", id="nested-again"), + }, + }, + ], + ), ] ) @@ -171,6 +203,38 @@ def send_to_list_of_dict(n_clicks): def updated_from_list(*_): return callback_context.triggered[0]["prop_id"] + @app.callback( + Output("output-dynamic", "children"), + Input("click-dynamic", "n_clicks"), + prevent_initial_call=True, + ) + def on_click(n_clicks): + return f"Clicked {n_clicks}" + + @app.callback( + Output("inside-dict", "children"), + Input("click-dict", "n_clicks"), + prevent_initial_call=True, + ) + def on_click(n_clicks): + return f"Clicked {n_clicks}" + + @app.callback( + Output("inside-list", "children"), + Input("click-list", "n_clicks"), + prevent_initial_call=True, + ) + def on_click(n_clicks): + return f"Clicked {n_clicks}" + + @app.callback( + Output("nested-dyn", "children"), + Input("click-nested", "n_clicks"), + prevent_initial_call=True, + ) + def on_click(n_clicks): + return f"Clicked {n_clicks}" + dash_duo.start_server(app) assert dash_duo.get_logs() == [] @@ -221,6 +285,28 @@ def updated_from_list(*_): dash_duo.wait_for_text_to_equal("#multi", "first - second") dash_duo.wait_for_text_to_equal("#multi2", "foo - bar") + dash_duo.wait_for_text_to_equal("#inside-dynamic", "dynamic") + dash_duo.wait_for_text_to_equal("#dict-dyn", "dict-dyn") + dash_duo.wait_for_text_to_equal("#inside-dict-2", "dict-2") + dash_duo.wait_for_text_to_equal("#nested-2", "nested-2") + dash_duo.wait_for_text_to_equal("#nested-again", "nested-again") + + dash_duo.wait_for_text_to_equal("#inside-list", "dynamic-list") + dash_duo.wait_for_text_to_equal("#inside-list-2", "list-2") + dash_duo.wait_for_text_to_equal("#inside-list-3", "list-3") + + dash_duo.find_element("#click-dynamic").click() + dash_duo.wait_for_text_to_equal("#output-dynamic", "Clicked 1") + + dash_duo.find_element("#click-dict").click() + dash_duo.wait_for_text_to_equal("#inside-dict", "Clicked 1") + + dash_duo.find_element("#click-list").click() + dash_duo.wait_for_text_to_equal("#inside-list", "Clicked 1") + + dash_duo.find_element("#click-nested").click() + dash_duo.wait_for_text_to_equal("#nested-dyn", "Clicked 1") + assert dash_duo.get_logs() == [] diff --git a/tests/unit/development/test_collect_nodes.py b/tests/unit/development/test_collect_nodes.py index 294e9a7a95..0ac4c119f3 100644 --- a/tests/unit/development/test_collect_nodes.py +++ b/tests/unit/development/test_collect_nodes.py @@ -61,6 +61,34 @@ }, } }, + "dynamic": { + "type": {"name": "objectOf", "value": {"name": "node"}}, + }, + "dynamic_list": { + "type": { + "name": "arrayOf", + "value": {"name": "objectOf", "value": {"name": "node"}}, + } + }, + "dynamic_node_in_dict": { + "type": { + "name": "shape", + "value": {"a": {"name": "objectOf", "value": {"name": "node"}}}, + } + }, + "dynamic_in_object": { + "type": { + "name": "objectOf", + "value": { + "name": "shape", + "value": { + "a": { + "name": "node", + } + }, + }, + } + }, } @@ -76,6 +104,10 @@ def test_dcn001_collect_nodes(): "mixed", "direct", "nested_list.list[].component", + "dynamic{}", + "dynamic_list[]{}", + "dynamic_node_in_dict.a{}", + "dynamic_in_object{}.a", ]