diff --git a/@plotly/dash-test-components/src/components/MyPersistedComponent.js b/@plotly/dash-test-components/src/components/MyPersistedComponent.js new file mode 100644 index 0000000000..d64a0bb33e --- /dev/null +++ b/@plotly/dash-test-components/src/components/MyPersistedComponent.js @@ -0,0 +1,166 @@ +import React, {PureComponent} from 'react'; +import PropTypes from 'prop-types'; + +const isEquivalent = (v1, v2) => v1 === v2 || (isNaN(v1) && isNaN(v2)); + +const omit = (key, obj) => { + const { [key]: omitted, ...rest } = obj; + return rest; + } + +/** + * Adapted dcc input component for persistence tests. + * + * Note that unnecessary props have been removed. + */ +export default class MyPersistedComponent extends PureComponent { + constructor(props) { + super(props); + this.input = React.createRef(); + this.onChange = this.onChange.bind(this); + this.onEvent = this.onEvent.bind(this); + this.onKeyPress = this.onKeyPress.bind(this); + this.setInputValue = this.setInputValue.bind(this); + this.setPropValue = this.setPropValue.bind(this); + } + + UNSAFE_componentWillReceiveProps(nextProps) { + const {value} = this.input.current; + this.setInputValue( + value, + nextProps.value + ); + this.setState({value: nextProps.value}); + } + + componentDidMount() { + const {value} = this.input.current; + this.setInputValue( + value, + this.props.value + ); + } + + UNSAFE_componentWillMount() { + this.setState({value: this.props.value}) + } + + render() { + const valprops = {value: this.state.value} + return ( + + ); + } + + setInputValue(base, value) { + base = NaN; + + if (!isEquivalent(base, value)) { + this.input.current.value = value + } + } + + setPropValue(base, value) { + if (!isEquivalent(base, value)) { + this.props.setProps({value}); + } + } + + onEvent() { + const {value} = this.input.current; + this.props.setProps({value}) + } + + onKeyPress(e) { + return this.onEvent(); + } + + onChange() { + this.onEvent() + } +} + +MyPersistedComponent.defaultProps = { + persisted_props: ['value'], + persistence_type: 'local', +}; + +MyPersistedComponent.propTypes = { + /** + * The ID of this component, used to identify dash components + * in callbacks. The ID needs to be unique across all of the + * components in an app. + */ + id: PropTypes.string, + + /** + * The value of the input + */ + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + + /** + * The name of the control, which is submitted with the form data. + */ + name: PropTypes.string, + + /** + * Dash-assigned callback that gets fired when the value changes. + */ + setProps: PropTypes.func, + + + /** + * Used to allow user interactions in this component to be persisted when + * the component - or the page - is refreshed. If `persisted` is truthy and + * hasn't changed from its previous value, a `value` that the user has + * changed while using the app will keep that change, as long as + * the new `value` also matches what was given originally. + * Used in conjunction with `persistence_type`. + */ + persistence: PropTypes.oneOfType([ + PropTypes.bool, + PropTypes.string, + PropTypes.number, + ]), + + /** + * Properties whose user interactions will persist after refreshing the + * component or the page. Since only `value` is allowed this prop can + * normally be ignored. + */ + persisted_props: PropTypes.arrayOf(PropTypes.oneOf(['value'])), + + /** + * Where persisted user changes will be stored: + * memory: only kept in memory, reset on page refresh. + * local: window.localStorage, data is kept after the browser quit. + * session: window.sessionStorage, data is cleared once the browser quit. + */ + persistence_type: PropTypes.oneOf(['local', 'session', 'memory']), +}; + +MyPersistedComponent.persistenceTransforms = { + value: { + + extract: propValue => { + if (!(propValue === null || propValue === undefined)) { + return propValue.toUpperCase(); + } + return propValue; + }, + apply: storedValue => storedValue, + + }, + }; \ No newline at end of file diff --git a/@plotly/dash-test-components/src/components/MyPersistedComponentNested.js b/@plotly/dash-test-components/src/components/MyPersistedComponentNested.js new file mode 100644 index 0000000000..c18d5a1bad --- /dev/null +++ b/@plotly/dash-test-components/src/components/MyPersistedComponentNested.js @@ -0,0 +1,169 @@ +import React, {PureComponent} from 'react'; +import PropTypes from 'prop-types'; + +const isEquivalent = (v1, v2) => v1 === v2 || (isNaN(v1) && isNaN(v2)); + +const omit = (key, obj) => { + const { [key]: omitted, ...rest } = obj; + return rest; + } + +/** + * Adapted dcc input component for persistence tests. + * + * Note that unnecessary props have been removed. + */ +export default class MyPersistedComponentNested extends PureComponent { + constructor(props) { + super(props); + this.input = React.createRef(); + this.onChange = this.onChange.bind(this); + this.onEvent = this.onEvent.bind(this); + this.onKeyPress = this.onKeyPress.bind(this); + this.setInputValue = this.setInputValue.bind(this); + this.setPropValue = this.setPropValue.bind(this); + } + + UNSAFE_componentWillReceiveProps(nextProps) { + const {value} = this.input.current; + this.setInputValue( + value, + nextProps.value + ); + this.setState({value: nextProps.value}); + } + + componentDidMount() { + const {value} = this.input.current; + this.setInputValue( + value, + this.props.value + ); + } + + UNSAFE_componentWillMount() { + this.setState({value: this.props.value}) + } + + render() { + const valprops = {value: this.state.value} + return ( + + ); + } + + setInputValue(base, value) { + base = NaN; + + if (!isEquivalent(base, value)) { + this.input.current.value = value + } + } + + setPropValue(base, value) { + if (!isEquivalent(base, value)) { + this.props.setProps({value}); + } + } + + onEvent() { + const {value} = this.input.current; + this.props.setProps({value}) + } + + onKeyPress(e) { + return this.onEvent(); + } + + onChange() { + this.onEvent() + } +} + +MyPersistedComponentNested.defaultProps = { + persisted_props: ['value.nested_value'], + persistence_type: 'local', +}; + +MyPersistedComponentNested.propTypes = { + /** + * The ID of this component, used to identify dash components + * in callbacks. The ID needs to be unique across all of the + * components in an app. + */ + id: PropTypes.string, + + /** + * The value of the input + */ + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + + /** + * The name of the control, which is submitted with the form data. + */ + name: PropTypes.string, + + /** + * Dash-assigned callback that gets fired when the value changes. + */ + setProps: PropTypes.func, + + + /** + * Used to allow user interactions in this component to be persisted when + * the component - or the page - is refreshed. If `persisted` is truthy and + * hasn't changed from its previous value, a `value` that the user has + * changed while using the app will keep that change, as long as + * the new `value` also matches what was given originally. + * Used in conjunction with `persistence_type`. + */ + persistence: PropTypes.oneOfType([ + PropTypes.bool, + PropTypes.string, + PropTypes.number, + ]), + + /** + * Properties whose user interactions will persist after refreshing the + * component or the page. Since only `value` is allowed this prop can + * normally be ignored. + */ + persisted_props: PropTypes.arrayOf(PropTypes.oneOf(['value.nested_value'])), + + /** + * Where persisted user changes will be stored: + * memory: only kept in memory, reset on page refresh. + * local: window.localStorage, data is kept after the browser quit. + * session: window.sessionStorage, data is cleared once the browser quit. + */ + persistence_type: PropTypes.oneOf(['local', 'session', 'memory']), +}; + +MyPersistedComponentNested.persistenceTransforms = { + value: { + + nested_value: { + + extract: propValue => { + if (!(propValue === null || propValue === undefined)) { + return propValue.toUpperCase(); + } + return propValue; + }, + apply: storedValue => storedValue, + + } + }, +}; diff --git a/@plotly/dash-test-components/src/index.js b/@plotly/dash-test-components/src/index.js index 24f7254ffe..f9752fc6a2 100644 --- a/@plotly/dash-test-components/src/index.js +++ b/@plotly/dash-test-components/src/index.js @@ -1,5 +1,8 @@ import StyledComponent from './components/StyledComponent'; +import MyPersistedComponent from './components/MyPersistedComponent'; +import MyPersistedComponentNested from './components/MyPersistedComponentNested'; + export { - StyledComponent, + StyledComponent, MyPersistedComponent, MyPersistedComponentNested }; diff --git a/CHANGELOG.md b/CHANGELOG.md index c5a41709c6..3be5312c0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to `dash` will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/). +## [UNRELEASED] +### Changed +- [#1376](https://github.com/plotly/dash/pull/1376) Extends the `getTransform` logic in the renderer to handle `persistenceTransforms` for both nested and non-nested persisted props. This was used to to fix [dcc#700](https://github.com/plotly/dash-core-components/issues/700) in conjunction with [dcc#854](https://github.com/plotly/dash-core-components/pull/854) by using persistenceTransforms to strip the time part of the datetime so that datepickers can persist when defined in callbacks. + ## [1.16.0] - 2020-09-03 ### Added - [#1371](https://github.com/plotly/dash/pull/1371) You can now get [CSP `script-src` hashes](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src) of all added inline scripts by calling `app.csp_hashes()` (both Dash internal inline scripts, and those added with `app.clientside_callback`) . diff --git a/dash-renderer/src/persistence.js b/dash-renderer/src/persistence.js index 41222a6589..b7bc2719ba 100644 --- a/dash-renderer/src/persistence.js +++ b/dash-renderer/src/persistence.js @@ -265,10 +265,18 @@ const noopTransform = { apply: (storedValue, _propValue) => storedValue }; -const getTransform = (element, propName, propPart) => - propPart - ? element.persistenceTransforms[propName][propPart] - : noopTransform; +const getTransform = (element, propName, propPart) => { + if ( + element.persistenceTransforms && + element.persistenceTransforms[propName] + ) { + if (propPart) { + return element.persistenceTransforms[propName][propPart]; + } + return element.persistenceTransforms[propName]; + } + return noopTransform; +}; const getValsKey = (id, persistedProp, persistence) => `${stringifyId(id)}.${persistedProp}.${JSON.stringify(persistence)}`; diff --git a/tests/integration/renderer/test_persistence.py b/tests/integration/renderer/test_persistence.py index 49d91d6f24..e0879a1e46 100644 --- a/tests/integration/renderer/test_persistence.py +++ b/tests/integration/renderer/test_persistence.py @@ -11,6 +11,9 @@ import dash_html_components as html import dash_table as dt +from dash_test_components import MyPersistedComponent +from dash_test_components import MyPersistedComponentNested + @pytest.fixture(autouse=True) def clear_storage(dash_duo): @@ -526,3 +529,36 @@ def set_out(val): dash_duo.wait_for_text_to_equal(".out", "") dash_duo.find_element("#btn").click() + + +def test_rdps013_persisted_props_nested(dash_duo): + # testing persistenceTransforms with generated test components + # with persisted prop and persisted nested prop + app = dash.Dash(__name__) + + app.layout = html.Div( + [ + html.Button("click me", id="btn"), + html.Div(id="container1"), + html.Div(id="container2"), + ] + ) + + @app.callback(Output("container1", "children"), [Input("btn", "n_clicks")]) + def update_container(n_clicks): + return MyPersistedComponent(id="component-propName", persistence=True) + + @app.callback(Output("container2", "children"), [Input("btn", "n_clicks")]) + def update_container(n_clicks): + return MyPersistedComponentNested(id="component-propPart", persistence=True) + + dash_duo.start_server(app) + + # send lower case strings to test components + dash_duo.find_element("#component-propName").send_keys("alpaca") + dash_duo.find_element("#component-propPart").send_keys("artichoke") + dash_duo.find_element("#btn").click() + + # persistenceTransforms should return upper case strings + dash_duo.wait_for_text_to_equal("#component-propName", "ALPACA") + dash_duo.wait_for_text_to_equal("#component-propPart", "ARTICHOKE")