diff --git a/dash/_callback.py b/dash/_callback.py index 071c209dec..8ab0f8257a 100644 --- a/dash/_callback.py +++ b/dash/_callback.py @@ -71,6 +71,7 @@ def callback( manager=None, cache_args_to_ignore=None, on_error: Optional[Callable[[Exception], Any]] = None, + enable_persistence: bool = False, **_kwargs, ): """ @@ -145,6 +146,10 @@ def callback( Function to call when the callback raises an exception. Receives the exception object as first argument. The callback_context can be used to access the original callback inputs, states and output. + :param enable_persistence: + Indicates whether the callback can write in the persistence storage. + If set to `True`, any outputs with persistence enabled will have their values + stored in the browser's persistence storage when updated by the callback. """ long_spec = None @@ -195,6 +200,7 @@ def callback( manager=manager, running=running, on_error=on_error, + enable_persistence=enable_persistence, ) @@ -237,6 +243,7 @@ def insert_callback( running=None, dynamic_creator: Optional[bool] = False, no_output=False, + enable_persistence: bool = False, ): if prevent_initial_call is None: prevent_initial_call = config_prevent_initial_callbacks @@ -260,6 +267,7 @@ def insert_callback( }, "dynamic_creator": dynamic_creator, "no_output": no_output, + "enable_persistence": enable_persistence, } if running: callback_spec["running"] = running @@ -315,6 +323,7 @@ def register_callback( manager = _kwargs.get("manager") running = _kwargs.get("running") on_error = _kwargs.get("on_error") + enable_persistence = _kwargs.get("enable_persistence") if running is not None: if not isinstance(running[0], (list, tuple)): running = [running] @@ -340,6 +349,7 @@ def register_callback( dynamic_creator=allow_dynamic_callbacks, running=running, no_output=not has_output, + enable_persistence=enable_persistence, ) # pylint: disable=too-many-locals @@ -602,6 +612,7 @@ def register_clientside_callback( ): output, inputs, state, prevent_initial_call = handle_callback_args(args, kwargs) no_output = isinstance(output, (list,)) and len(output) == 0 + enable_persistence = kwargs.get("enable_persistence") insert_callback( callback_list, callback_map, @@ -613,6 +624,7 @@ def register_clientside_callback( None, prevent_initial_call, no_output=no_output, + enable_persistence=enable_persistence, ) # If JS source is explicitly given, create a namespace and function diff --git a/dash/dash-renderer/src/TreeContainer.js b/dash/dash-renderer/src/TreeContainer.js index 25b51bc493..3d08aaa24a 100644 --- a/dash/dash-renderer/src/TreeContainer.js +++ b/dash/dash-renderer/src/TreeContainer.js @@ -26,7 +26,6 @@ import { } from 'ramda'; import {notifyObservers, updateProps, onError} from './actions'; import isSimpleComponent from './isSimpleComponent'; -import {recordUiEdit} from './persistence'; import ComponentErrorBoundary from './components/error/ComponentErrorBoundary.react'; import checkPropTypes from './checkPropTypes'; import {getWatchedKeys, stringifyId} from './actions/dependencies'; @@ -132,8 +131,7 @@ class BaseTreeContainer extends Component { } setProps(newProps) { - const {_dashprivate_dispatch, _dashprivate_path, _dashprivate_layout} = - this.props; + const {_dashprivate_dispatch, _dashprivate_path} = this.props; const oldProps = this.getLayoutProps(); const {id} = oldProps; @@ -163,10 +161,6 @@ class BaseTreeContainer extends Component { ); batch(() => { - // setProps here is triggered by the UI - record these changes - // for persistence - recordUiEdit(_dashprivate_layout, newProps, dispatch); - // Only dispatch changes to Dash if a watched prop changed if (watchedKeys.length) { dispatch( diff --git a/dash/dash-renderer/src/actions/index.js b/dash/dash-renderer/src/actions/index.js index 2b1dd51324..5ce68888ca 100644 --- a/dash/dash-renderer/src/actions/index.js +++ b/dash/dash-renderer/src/actions/index.js @@ -1,4 +1,4 @@ -import {once} from 'ramda'; +import {once, path} from 'ramda'; import {createAction} from 'redux-actions'; import {addRequestedCallbacks} from './callbacks'; import {getAppState} from '../reducers/constants'; @@ -7,6 +7,7 @@ import cookie from 'cookie'; import {validateCallbacksToLayout} from './dependencies'; import {includeObservers, getLayoutCallbacks} from './dependencies_ts'; import {getPath} from './paths'; +import {recordUiEdit} from '../persistence'; export const onError = createAction(getAction('ON_ERROR')); export const setAppLifecycle = createAction(getAction('SET_APP_LIFECYCLE')); @@ -17,7 +18,20 @@ export const setHooks = createAction(getAction('SET_HOOKS')); export const setLayout = createAction(getAction('SET_LAYOUT')); export const setPaths = createAction(getAction('SET_PATHS')); export const setRequestQueue = createAction(getAction('SET_REQUEST_QUEUE')); -export const updateProps = createAction(getAction('ON_PROP_CHANGE')); + +// Change the variable name of the action +export const onPropChange = createAction(getAction('ON_PROP_CHANGE')); + +export function updateProps(payload) { + return (dispatch, getState) => { + const {enable_persistence} = payload; + if (payload.source !== 'response' || enable_persistence) { + const component = path(payload.itempath, getState().layout); + recordUiEdit(component, payload.props, dispatch); + } + dispatch(onPropChange(payload)); + }; +} export const dispatchError = dispatch => (message, lines) => dispatch( diff --git a/dash/dash-renderer/src/observers/executedCallbacks.ts b/dash/dash-renderer/src/observers/executedCallbacks.ts index 1249252704..681ef5df43 100644 --- a/dash/dash-renderer/src/observers/executedCallbacks.ts +++ b/dash/dash-renderer/src/observers/executedCallbacks.ts @@ -12,6 +12,8 @@ import { } from 'ramda'; import {IStoreState} from '../store'; +import {ThunkDispatch} from 'redux-thunk'; +import {AnyAction} from 'redux'; import { aggregateCallbacks, @@ -44,7 +46,11 @@ const observer: IStoreObserverDefinition = { callbacks: {executed} } = getState(); - function applyProps(id: any, updatedProps: any) { + function applyProps( + id: any, + updatedProps: any, + enable_persistence: boolean + ) { const {layout, paths} = getState(); const itempath = getPath(paths, id); if (!itempath) { @@ -57,18 +63,22 @@ const observer: IStoreObserverDefinition = { updatedProps = prunePersistence( path(itempath, layout), updatedProps, - dispatch + dispatch, + enable_persistence ); - // In case the update contains whole components, see if any of - // those components have props to update to persist user edits. - const {props} = applyPersistence({props: updatedProps}, dispatch); + const {props} = applyPersistence( + {props: updatedProps}, + dispatch, + enable_persistence + ); - dispatch( + (dispatch as ThunkDispatch)( updateProps({ itempath, props, - source: 'response' + source: 'response', + enable_persistence }) ); @@ -102,8 +112,17 @@ const observer: IStoreObserverDefinition = { paths: oldPaths } = getState(); + const enable_persistence = + cb.callback.enable_persistence === undefined + ? false + : cb.callback.enable_persistence; + // Components will trigger callbacks on their own as required (eg. derived) - const appliedProps = applyProps(parsedId, props); + const appliedProps = applyProps( + parsedId, + props, + enable_persistence + ); // Add callbacks for modified inputs requestedCallbacks = concat( diff --git a/dash/dash-renderer/src/persistence.js b/dash/dash-renderer/src/persistence.js index b7bc2719ba..6091741691 100644 --- a/dash/dash-renderer/src/persistence.js +++ b/dash/dash-renderer/src/persistence.js @@ -307,6 +307,10 @@ const getProps = layout => { }; export function recordUiEdit(layout, newProps, dispatch) { + if (newProps === undefined) { + return; + } + const { canPersist, id, @@ -316,43 +320,57 @@ export function recordUiEdit(layout, newProps, dispatch) { persisted_props, persistence_type } = getProps(layout); - if (!canPersist || !persistence) { - return; - } - forEach(persistedProp => { - const [propName, propPart] = persistedProp.split('.'); - if (newProps[propName] !== undefined) { - const storage = getStore(persistence_type, dispatch); - const {extract} = getTransform(element, propName, propPart); - - const valsKey = getValsKey(id, persistedProp, persistence); - let originalVal = extract(props[propName]); - const newVal = extract(newProps[propName]); - - // mainly for nested props with multiple persisted parts, it's - // possible to have the same value as before - should not store - // in this case. - if (originalVal !== newVal) { - if (storage.hasItem(valsKey)) { - originalVal = storage.getItem(valsKey)[1]; - } + if (canPersist && persistence) { + forEach(persistedProp => { + const [propName, propPart] = persistedProp.split('.'); + if (newProps[propName] !== undefined) { + const storage = getStore(persistence_type, dispatch); + const {extract} = getTransform(element, propName, propPart); + const valsKey = getValsKey(id, persistedProp, persistence); + + let originalVal = storage.hasItem(valsKey) + ? storage.getItem(valsKey)[1] + : extract(props[propName]); + let newVal = extract(newProps[propName]); + const vals = originalVal === undefined ? [newVal] : [newVal, originalVal]; + storage.setItem(valsKey, vals, dispatch); } - } - }, persisted_props); + }, persisted_props); + } + + // Recursively record UI edits for children + const {children} = props; + if (Array.isArray(children)) { + children.forEach((child, i) => { + if ( + type(child) === 'Object' && + child.props && + newProps['children'] !== undefined + ) { + recordUiEdit(child, newProps['children'][i]['props'], dispatch); + } + }); + } else if ( + type(children) === 'Object' && + children.props && + newProps['children'] !== undefined + ) { + recordUiEdit(children, newProps['children']['props'], dispatch); + } } /* * Used for entire layouts (on load) or partial layouts (from children * callbacks) to apply previously-stored UI edits to components */ -export function applyPersistence(layout, dispatch) { - if (type(layout) !== 'Object' || !layout.props) { +export function applyPersistence(layout, dispatch, enable_persistence) { + if (type(layout) !== 'Object' || !layout.props || enable_persistence) { return layout; } @@ -447,7 +465,12 @@ function persistenceMods(layout, component, path, dispatch) { * these override UI-driven edits of those exact props * but not for props nested inside children */ -export function prunePersistence(layout, newProps, dispatch) { +export function prunePersistence( + layout, + newProps, + dispatch, + enable_persistence +) { const { canPersist, id, @@ -462,7 +485,11 @@ export function prunePersistence(layout, newProps, dispatch) { propName in newProps ? newProps[propName] : prevVal; const finalPersistence = getFinal('persistence', persistence); - if (!canPersist || !(persistence || finalPersistence)) { + if ( + !canPersist || + !(persistence || finalPersistence) || + enable_persistence + ) { return newProps; } diff --git a/dash/dash-renderer/src/types/callbacks.ts b/dash/dash-renderer/src/types/callbacks.ts index d1cf636776..0a48cb4b51 100644 --- a/dash/dash-renderer/src/types/callbacks.ts +++ b/dash/dash-renderer/src/types/callbacks.ts @@ -15,6 +15,7 @@ export interface ICallbackDefinition { dynamic_creator?: boolean; running: any; no_output?: boolean; + enable_persistence?: boolean; } export interface ICallbackProperty {