diff --git a/dev-requirements.txt b/dev-requirements.txt index 454fad2..d89b342 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,7 +1,8 @@ dash_core_components==0.33.0 dash_html_components==0.11.0rc5 -dash==0.28.0 +dash==0.28.5 percy selenium mock six +tests/requirements/component_props-0.0.1.tar.gz diff --git a/src/TreeContainer.js b/src/TreeContainer.js index b3b44f2..a94e8e4 100644 --- a/src/TreeContainer.js +++ b/src/TreeContainer.js @@ -6,81 +6,88 @@ import PropTypes from 'prop-types'; import Registry from './registry'; import NotifyObservers from './components/core/NotifyObservers.react'; -export default class TreeContainer extends Component { - shouldComponentUpdate(nextProps) { - return nextProps.layout !== this.props.layout; - } - - render() { - return render(this.props.layout); +function isComponent(c) { + switch (R.type(c)) { + case 'Array': + return R.any(isComponent)(c); + case 'Object': + return ( + c.hasOwnProperty('namespace') && + c.hasOwnProperty('props') && + c.hasOwnProperty('type') + ); + default: + return false; } } -TreeContainer.propTypes = { - layout: PropTypes.object, -}; - -function render(component) { - if ( - R.contains(R.type(component), ['String', 'Number', 'Null', 'Boolean']) - ) { - return component; - } +function hydrateProps(props) { + const replace = {}; + Object.entries(props) + .filter(([_, v]) => isComponent(v)) + .forEach(([k, v]) => { + if (R.type(v) === 'Array') { + // TODO add key ?. + replace[k] = v.map(c => { + if (!isComponent(c)) { + return c; + } + const newProps = hydrateProps(c.props); + return hydrateComponent( + c.type, + c.namespace, + newProps + ); + }); + } else { + const newProps = hydrateProps(v.props); + replace[k] = hydrateComponent( + v.type, + v.namespace, + newProps + ); + } + }); + return R.merge(props, replace); +} - // Create list of child elements - let children; +function hydrateComponent( + component_name, + namespace, + props, +) { + const element = Registry.resolve(component_name, namespace); - const componentProps = R.propOr({}, 'props', component); + const component = React.createElement( + element, + props + ); - if ( - !R.has('props', component) || - !R.has('children', component.props) || - typeof component.props.children === 'undefined' - ) { - // No children - children = []; - } else if ( - R.contains(R.type(component.props.children), [ - 'String', - 'Number', - 'Null', - 'Boolean', - ]) - ) { - children = [component.props.children]; - } else { - // One or multiple objects - // Recursively render the tree - // TODO - I think we should pass in `key` here. - children = (Array.isArray(componentProps.children) - ? componentProps.children - : [componentProps.children] - ).map(render); - } + // eslint-disable-next-line + return {component}; +} - if (!component.type) { - /* eslint-disable no-console */ - console.error(R.type(component), component); - /* eslint-enable no-console */ - throw new Error('component.type is undefined'); - } - if (!component.namespace) { - /* eslint-disable no-console */ - console.error(R.type(component), component); - /* eslint-enable no-console */ - throw new Error('component.namespace is undefined'); +export default class TreeContainer extends Component { + shouldComponentUpdate(nextProps) { + return nextProps.layout !== this.props.layout; } - const element = Registry.resolve(component.type, component.namespace); - const parent = React.createElement( - element, - R.omit(['children'], component.props), - ...children - ); + render() { + const { layout } = this.props; + if ( + R.contains(R.type(layout), ['String', 'Number', 'Null', 'Boolean']) + ) { + return layout; + } - return {parent}; + return hydrateComponent( + layout.type, + layout.namespace, + hydrateProps(layout.props) + ); + } } -render.propTypes = { - children: PropTypes.object, +TreeContainer.propTypes = { + layout: PropTypes.object, }; diff --git a/tests/requirements/component_props-0.0.1.tar.gz b/tests/requirements/component_props-0.0.1.tar.gz new file mode 100644 index 0000000..a7f6c57 Binary files /dev/null and b/tests/requirements/component_props-0.0.1.tar.gz differ diff --git a/tests/test_render.py b/tests/test_render.py index dc1c68e..f21ca47 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -4,6 +4,11 @@ from dash.development.base_component import Component import dash_html_components as html import dash_core_components as dcc +from dash.exceptions import PreventUpdate +from selenium.webdriver.common.by import By +from selenium.webdriver.support.wait import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC + from .IntegrationTests import IntegrationTests from .utils import assert_clean_console, wait_for from multiprocessing import Value @@ -12,6 +17,9 @@ import itertools import json import unittest +import component_props + +TIMEOUT = 20 class Tests(IntegrationTests): @@ -19,31 +27,15 @@ def setUp(self): pass def wait_for_element_by_css_selector(self, selector): - start_time = time.time() - exception = Exception('Time ran out, {} not found'.format(selector)) - while time.time() < start_time + 20: - try: - return self.driver.find_element_by_css_selector(selector) - except Exception as e: - exception = e - pass - time.sleep(0.25) - raise exception + return WebDriverWait(self.driver, TIMEOUT).until( + EC.presence_of_element_located((By.CSS_SELECTOR, selector)) + ) def wait_for_text_to_equal(self, selector, assertion_text): - start_time = time.time() - exception = Exception('Time ran out, {} on {} not found'.format( - assertion_text, selector)) - while time.time() < start_time + 20: - el = self.wait_for_element_by_css_selector(selector) - try: - return self.assertEqual(el.text, assertion_text) - except Exception as e: - exception = e - pass - time.sleep(0.25) - - raise exception + return WebDriverWait(self.driver, TIMEOUT).until( + EC.text_to_be_present_in_element((By.CSS_SELECTOR, selector), + assertion_text) + ) def request_queue_assertions( self, check_rejected=True, expected_length=None): @@ -1866,3 +1858,40 @@ def update_output(*args): self.assertTrue(timestamp_2.value > timestamp_1.value) self.assertEqual(call_count.value, 4) self.percy_snapshot('button-2 click again') + + def test_component_as_props(self): + app = dash.Dash(__name__) + app.layout = html.Div([ + html.Button('button', id='mod-btn'), + component_props.ComponentProps( + id='c-props', + component_props=html.Div('Component as prop'), + comp_array=[ + html.Div([html.Div('1'), html.Div('2'), 3]), + html.Div('4') + ] + ), + html.Div(id='output') + ]) + + @app.callback(Output('c-props', 'component_props'), + [Input('mod-btn', 'n_clicks')]) + def on_clicks(n_clicks): + if n_clicks is None: + raise PreventUpdate + + return html.H2('New component from a callback') + + self.startServer(app) + + self.wait_for_text_to_equal('#c-props > div:nth-child(1)', 'Component as prop') + for i in range(1, 3): + self.wait_for_text_to_equal( + '#c-props > div:nth-child(2) > div:nth-child(1) > div:nth-child({})'.format(i), + str(i) + ) + self.wait_for_text_to_equal('#c-props > div:nth-child(2) > div:nth-child(1)', '3') + self.wait_for_text_to_equal('#c-props > div:nth-child(2) > div:nth-child(2)', '4') + + self.wait_for_element_by_css_selector('#mod-btn').click() + self.wait_for_text_to_equal('#c-props > h2', 'New component from a callback')