diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ab0315eb0..eb46a1c33d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,12 @@ This project adheres to [Semantic Versioning](https://semver.org/). - Upgrade `black` to v22.3.0 for Python 3.7+ - if you use `dash[ci]` and you call `black`, this may alter your code formatting slightly, including more consistently breaking Python 2 compatibility. - Many other mainly JS dependency upgrades to the internals of Dash renderer and components. These may patch bugs or improve performance. +### Fixed + +- [#1970](https://github.com/plotly/dash/pull/1970) dcc.Dropdown Refactor fixes: + - Fix bug [#1868](https://github.com/plotly/dash/issues/1868) value does not update when selected option removed from options. + - Fix bug [#1908](https://github.com/plotly/dash/issues/1908) Selected options not showing when the value contains a comma. + ## [2.3.1] - 2022-03-29 ### Fixed diff --git a/components/dash-core-components/src/fragments/Dropdown.react.js b/components/dash-core-components/src/fragments/Dropdown.react.js index 43dd15e006..c7ca287d59 100644 --- a/components/dash-core-components/src/fragments/Dropdown.react.js +++ b/components/dash-core-components/src/fragments/Dropdown.react.js @@ -1,5 +1,5 @@ -import {isNil, pluck, omit, type} from 'ramda'; -import React, {Component} from 'react'; +import {isNil, pluck, without, pick} from 'ramda'; +import React, {useState, useCallback, useEffect, useMemo} from 'react'; import ReactDropdown from 'react-virtualized-select'; import createFilterOptions from 'react-select-fast-filter-options'; import '../components/css/react-virtualized-select@3.1.0.css'; @@ -21,90 +21,111 @@ const TOKENIZER = { }, }; -const DELIMITER = ','; +const RDProps = [ + 'multi', + 'clearable', + 'searchable', + 'search_value', + 'placeholder', + 'disabled', + 'optionHeight', + 'style', + 'className', +]; -export default class Dropdown extends Component { - constructor(props) { - super(props); - this.state = { - filterOptions: createFilterOptions({ - options: sanitizeOptions(props.options), +const Dropdown = props => { + const { + id, + clearable, + multi, + options, + setProps, + style, + loading_state, + value, + } = props; + const [optionsCheck, setOptionsCheck] = useState(null); + const [sanitizedOptions, filterOptions] = useMemo(() => { + const sanitized = sanitizeOptions(options); + return [ + sanitized, + createFilterOptions({ + options: sanitized, tokenizer: TOKENIZER, }), - }; - } + ]; + }, [options]); - UNSAFE_componentWillReceiveProps(newProps) { - if (newProps.options !== this.props.options) { - this.setState({ - filterOptions: createFilterOptions({ - options: sanitizeOptions(newProps.options), - tokenizer: TOKENIZER, - }), - }); - } - } + const onChange = useCallback( + selectedOption => { + if (multi) { + let value; + if (isNil(selectedOption)) { + value = []; + } else { + value = pluck('value', selectedOption); + } + setProps({value}); + } else { + let value; + if (isNil(selectedOption)) { + value = null; + } else { + value = selectedOption.value; + } + setProps({value}); + } + }, + [multi] + ); - render() { - const { - id, - clearable, - multi, - options, - setProps, - style, - loading_state, - value, - } = this.props; - const {filterOptions} = this.state; - let selectedValue; - if (type(value) === 'Array') { - selectedValue = value.join(DELIMITER); - } else { - selectedValue = value; - } - return ( -
setProps({search_value}), + [] + ); + + useEffect(() => { + if (optionsCheck !== sanitizedOptions && !isNil(value)) { + const values = sanitizedOptions.map(option => option.value); + if (multi && Array.isArray(value)) { + const invalids = value.filter(v => !values.includes(v)); + if (invalids.length) { + setProps({value: without(invalids, value)}); } - > - { - if (multi) { - let value; - if (isNil(selectedOption)) { - value = []; - } else { - value = pluck('value', selectedOption); - } - setProps({value}); - } else { - let value; - if (isNil(selectedOption)) { - value = null; - } else { - value = selectedOption.value; - } - setProps({value}); - } - }} - onInputChange={search_value => setProps({search_value})} - backspaceRemoves={clearable} - deleteRemoves={clearable} - inputProps={{autoComplete: 'off'}} - {...omit(['setProps', 'value', 'options'], this.props)} - /> -
- ); - } -} + } else { + if (!values.includes(value)) { + setProps({value: null}); + } + } + setOptionsCheck(sanitizedOptions); + } + }, [sanitizedOptions, optionsCheck, multi, value]); + + return ( +
+ +
+ ); +}; Dropdown.propTypes = propTypes; Dropdown.defaultProps = defaultProps; + +export default Dropdown; diff --git a/components/dash-core-components/tests/integration/dropdown/test_dynamic_options.py b/components/dash-core-components/tests/integration/dropdown/test_dynamic_options.py index c16c3f39eb..cd15bd709b 100644 --- a/components/dash-core-components/tests/integration/dropdown/test_dynamic_options.py +++ b/components/dash-core-components/tests/integration/dropdown/test_dynamic_options.py @@ -52,24 +52,18 @@ def update_options(search_value): assert dash_dcc.get_logs() == [] -def test_dddo002_array_value(dash_dcc): - dropdown_options = [ - {"label": "New York City", "value": "New,York,City"}, - {"label": "Montreal", "value": "Montreal"}, - {"label": "San Francisco", "value": "San,Francisco"}, - ] - +def test_dddo002_array_comma_value(dash_dcc): app = Dash(__name__) - arrayValue = ["San", "Francisco"] dropdown = dcc.Dropdown( - options=dropdown_options, - value=arrayValue, + options=["New York, NY", "Montreal, QC", "San Francisco, CA"], + value=["San Francisco, CA"], + multi=True, ) - app.layout = html.Div([dropdown]) + app.layout = html.Div(dropdown) dash_dcc.start_server(app) - dash_dcc.wait_for_text_to_equal("#react-select-2--value-item", "San Francisco") + dash_dcc.wait_for_text_to_equal("#react-select-2--value-0", "San Francisco, CA\n ") assert dash_dcc.get_logs() == [] diff --git a/components/dash-core-components/tests/integration/dropdown/test_remove_option.py b/components/dash-core-components/tests/integration/dropdown/test_remove_option.py new file mode 100644 index 0000000000..f56371b6ab --- /dev/null +++ b/components/dash-core-components/tests/integration/dropdown/test_remove_option.py @@ -0,0 +1,84 @@ +import json + +from dash import Dash, html, dcc, Output, Input +from dash.exceptions import PreventUpdate + + +sample_dropdown_options = [ + {"label": "New York City", "value": "NYC"}, + {"label": "Montreal", "value": "MTL"}, + {"label": "San Francisco", "value": "SF"}, +] + + +def test_ddro001_remove_option_single(dash_dcc): + dropdown_options = sample_dropdown_options + + app = Dash(__name__) + value = "SF" + + app.layout = html.Div( + [ + dcc.Dropdown( + options=dropdown_options, + value=value, + id="dropdown", + ), + html.Button("Remove option", id="remove"), + html.Div(id="value-output"), + ] + ) + + @app.callback(Output("dropdown", "options"), [Input("remove", "n_clicks")]) + def on_click(n_clicks): + if not n_clicks: + raise PreventUpdate + return sample_dropdown_options[:-1] + + @app.callback(Output("value-output", "children"), [Input("dropdown", "value")]) + def on_change(val): + if not val: + raise PreventUpdate + return val or "None" + + dash_dcc.start_server(app) + btn = dash_dcc.wait_for_element("#remove") + btn.click() + + dash_dcc.wait_for_text_to_equal("#value-output", "None") + + +def test_ddro002_remove_option_multi(dash_dcc): + dropdown_options = sample_dropdown_options + + app = Dash(__name__) + value = ["MTL", "SF"] + + app.layout = html.Div( + [ + dcc.Dropdown( + options=dropdown_options, + value=value, + multi=True, + id="dropdown", + ), + html.Button("Remove option", id="remove"), + html.Div(id="value-output"), + ] + ) + + @app.callback(Output("dropdown", "options"), [Input("remove", "n_clicks")]) + def on_click(n_clicks): + if not n_clicks: + raise PreventUpdate + return sample_dropdown_options[:-1] + + @app.callback(Output("value-output", "children"), [Input("dropdown", "value")]) + def on_change(val): + return json.dumps(val) + + dash_dcc.start_server(app) + btn = dash_dcc.wait_for_element("#remove") + btn.click() + + dash_dcc.wait_for_text_to_equal("#value-output", '["MTL"]') diff --git a/components/dash-html-components/dash_html_components_base/__init__.py b/components/dash-html-components/dash_html_components_base/__init__.py index dee9b02959..fb4145dccf 100644 --- a/components/dash-html-components/dash_html_components_base/__init__.py +++ b/components/dash-html-components/dash_html_components_base/__init__.py @@ -33,27 +33,27 @@ _js_dist = [ { - "relative_package_path": 'html/{}.min.js'.format(_this_module), + "relative_package_path": "html/{}.min.js".format(_this_module), "external_url": ( "https://unpkg.com/dash-html-components@{}" "/dash_html_components/dash_html_components.min.js" ).format(__version__), - "namespace": "dash" + "namespace": "dash", }, { - 'relative_package_path': 'html/{}.min.js.map'.format(_this_module), - 'external_url': ( - 'https://unpkg.com/dash-html-components@{}' - '/dash_html_components/dash_html_components.min.js.map' + "relative_package_path": "html/{}.min.js.map".format(_this_module), + "external_url": ( + "https://unpkg.com/dash-html-components@{}" + "/dash_html_components/dash_html_components.min.js.map" ).format(__version__), - 'namespace': 'dash', - 'dynamic': True - } + "namespace": "dash", + "dynamic": True, + }, ] _css_dist = [] for _component in __all__: - setattr(locals()[_component], '_js_dist', _js_dist) - setattr(locals()[_component], '_css_dist', _css_dist) + setattr(locals()[_component], "_js_dist", _js_dist) + setattr(locals()[_component], "_css_dist", _css_dist) diff --git a/components/dash-html-components/setup.py b/components/dash-html-components/setup.py index ecd37e2b84..cf8fc48205 100644 --- a/components/dash-html-components/setup.py +++ b/components/dash-html-components/setup.py @@ -2,22 +2,22 @@ import json from setuptools import setup -with open('package.json') as f: +with open("package.json") as f: package = json.load(f) package_name = str(package["name"].replace(" ", "_").replace("-", "_")) setup( - name='dash_html_components', + name="dash_html_components", version=package["version"], - author=package['author'], - author_email='chris@plotly.com', + author=package["author"], + author_email="chris@plotly.com", packages=[package_name], - url='https://github.com/plotly/dash-html-components', + url="https://github.com/plotly/dash-html-components", include_package_data=True, - license=package['license'], - description=package['description'] if 'description' in package else package_name, - long_description=io.open('README.md', encoding='utf-8').read(), - long_description_content_type='text/markdown', - install_requires=[] + license=package["license"], + description=package["description"] if "description" in package else package_name, + long_description=io.open("README.md", encoding="utf-8").read(), + long_description_content_type="text/markdown", + install_requires=[], ) diff --git a/components/dash-html-components/tests/test_dash_html_components.py b/components/dash-html-components/tests/test_dash_html_components.py index 8ce332d445..206f4fb3e8 100644 --- a/components/dash-html-components/tests/test_dash_html_components.py +++ b/components/dash-html-components/tests/test_dash_html_components.py @@ -23,7 +23,7 @@ def test_imports(): def test_sample_items(): layout = html.Div( html.Div(html.Img(src="https://plotly.com/~chris/1638.png")), - style={"color": "red"} + style={"color": "red"}, ) expected = ( diff --git a/components/dash-html-components/tests/utils.py b/components/dash-html-components/tests/utils.py index 349264fd26..89791183ce 100644 --- a/components/dash-html-components/tests/utils.py +++ b/components/dash-html-components/tests/utils.py @@ -4,18 +4,14 @@ def assert_clean_console(TestClass): def assert_no_console_errors(TestClass): TestClass.assertEqual( - TestClass.driver.execute_script( - 'return window.tests.console.error.length' - ), - 0 + TestClass.driver.execute_script("return window.tests.console.error.length"), + 0, ) def assert_no_console_warnings(TestClass): TestClass.assertEqual( - TestClass.driver.execute_script( - 'return window.tests.console.warn.length' - ), - 0 + TestClass.driver.execute_script("return window.tests.console.warn.length"), + 0, ) assert_no_console_warnings(TestClass) diff --git a/package.json b/package.json index 1c9bfbdcf8..383a2240c0 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "scripts": { "private::format.black": "black dash tests --exclude metadata_test.py", "private::format.renderer": "cd dash/dash-renderer && npm run format", + "private::format.dcc": "cd components/dash-core-components && npm run format", "private::initialize.renderer": "cd dash/dash-renderer && npm ci", "private::build.components": "python dash/development/update_components.py 'all'", "private::build.renderer": "cd dash/dash-renderer && renderer build",