diff --git a/@plotly/dash-test-components/src/components/ExternalComponent.js b/@plotly/dash-test-components/src/components/ExternalComponent.js new file mode 100644 index 0000000000..bb3369d87e --- /dev/null +++ b/@plotly/dash-test-components/src/components/ExternalComponent.js @@ -0,0 +1,28 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + + +const ExternalComponent = ({ id, text, input_id }) => { + const ctx = window.dash_component_api.useDashContext(); + const ExternalWrapper = window.dash_component_api.ExternalWrapper; + + return ( +
+ +
+ ) +} + +ExternalComponent.propTypes = { + id: PropTypes.string, + text: PropTypes.string, + input_id: PropTypes.string, +}; + +export default ExternalComponent; diff --git a/@plotly/dash-test-components/src/index.js b/@plotly/dash-test-components/src/index.js index bdddfc18b5..f72bfd0521 100644 --- a/@plotly/dash-test-components/src/index.js +++ b/@plotly/dash-test-components/src/index.js @@ -13,6 +13,7 @@ import AddPropsComponent from "./components/AddPropsComponent"; import ReceivePropsComponent from "./components/ReceivePropsComponent"; import ShapeOrExactKeepOrderComponent from "./components/ShapeOrExactKeepOrderComponent"; import ArrayOfExactOrShapeWithNodePropAssignNone from './components/ArrayOfExactOrShapeWithNodePropAssignNone'; +import ExternalComponent from './components/ExternalComponent'; export { @@ -29,5 +30,6 @@ export { AddPropsComponent, ReceivePropsComponent, ShapeOrExactKeepOrderComponent, - ArrayOfExactOrShapeWithNodePropAssignNone + ArrayOfExactOrShapeWithNodePropAssignNone, + ExternalComponent, }; diff --git a/dash/dash-renderer/src/actions/constants.js b/dash/dash-renderer/src/actions/constants.js index 352f25be88..9c744f1655 100644 --- a/dash/dash-renderer/src/actions/constants.js +++ b/dash/dash-renderer/src/actions/constants.js @@ -8,7 +8,9 @@ const actionList = { SET_CONFIG: 1, ADD_HTTP_HEADERS: 1, ON_ERROR: 1, - SET_HOOKS: 1 + SET_HOOKS: 1, + INSERT_COMPONENT: 1, + REMOVE_COMPONENT: 1 }; export const getAction = action => { diff --git a/dash/dash-renderer/src/actions/index.js b/dash/dash-renderer/src/actions/index.js index 2b1dd51324..e46644ff9d 100644 --- a/dash/dash-renderer/src/actions/index.js +++ b/dash/dash-renderer/src/actions/index.js @@ -18,6 +18,8 @@ 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')); +export const insertComponent = createAction(getAction('INSERT_COMPONENT')); +export const removeComponent = createAction(getAction('REMOVE_COMPONENT')); export const dispatchError = dispatch => (message, lines) => dispatch( diff --git a/dash/dash-renderer/src/dashApi.ts b/dash/dash-renderer/src/dashApi.ts index f388299d85..a0a914c5e4 100644 --- a/dash/dash-renderer/src/dashApi.ts +++ b/dash/dash-renderer/src/dashApi.ts @@ -2,6 +2,7 @@ import {path} from 'ramda'; import {DashContext, useDashContext} from './wrapper/DashContext'; import {getPath} from './actions/paths'; import {getStores} from './utils/stores'; +import ExternalWrapper from './wrapper/ExternalWrapper'; /** * Get the dash props from a component path or id. @@ -28,6 +29,7 @@ function getLayout(componentPathOrId: string[] | string): any { } (window as any).dash_component_api = { + ExternalWrapper, DashContext, useDashContext, getLayout diff --git a/dash/dash-renderer/src/reducers/layout.js b/dash/dash-renderer/src/reducers/layout.js index b0986aec07..e9c2aba0e7 100644 --- a/dash/dash-renderer/src/reducers/layout.js +++ b/dash/dash-renderer/src/reducers/layout.js @@ -1,4 +1,12 @@ -import {includes, mergeRight, append, view, lensPath, assocPath} from 'ramda'; +import { + includes, + mergeRight, + append, + view, + lensPath, + assocPath, + dissocPath +} from 'ramda'; import {getAction} from '../actions/constants'; @@ -20,6 +28,14 @@ const layout = (state = {}, action) => { const mergedProps = mergeRight(existingProps, action.payload.props); return assocPath(propPath, mergedProps, state); } + // Custom component rendered out of tree. + else if (action.type === getAction('INSERT_COMPONENT')) { + const {component, componentPath} = action.payload; + return assocPath(componentPath, component, state); + } else if (action.type === getAction('REMOVE_COMPONENT')) { + const {componentPath} = action.payload; + return dissocPath(componentPath, state); + } return state; }; diff --git a/dash/dash-renderer/src/wrapper/DashContext.tsx b/dash/dash-renderer/src/wrapper/DashContext.tsx index 249da52732..1da719f664 100644 --- a/dash/dash-renderer/src/wrapper/DashContext.tsx +++ b/dash/dash-renderer/src/wrapper/DashContext.tsx @@ -9,15 +9,19 @@ type LoadingFilterFunc = (loading: LoadingPayload) => boolean; type LoadingOptions = { /** - * + * Path to add after the current component if loading. + * Ex `["props"]` will return true only for when that component load. */ extraPath?: DashLayoutPath; /** - * + * A raw path used instead of the current component. + * Useful if you want the loading of a child component + * as the path is available in `child.props.componentPath`. */ rawPath?: boolean; /** * Function used to filter the properties of the loading component. + * Filter argument is an Entry of `{path, property, id}`. */ filterFunc?: LoadingFilterFunc; }; @@ -113,5 +117,12 @@ export function DashContextProvider(props: DashContextProviderProps) { } export function useDashContext() { - return useContext(DashContext); + const ctx = useContext(DashContext); + if (!ctx) { + // eslint-disable-next-line no-console + console.error( + 'Dash Context was not found, component was rendered without a wrapper. Use `window.dash_component_api.ExternalWrapper` to make sure the component is properly connected.' + ); + } + return ctx || {}; } diff --git a/dash/dash-renderer/src/wrapper/DashWrapper.tsx b/dash/dash-renderer/src/wrapper/DashWrapper.tsx index b9839a9472..2756ff54a3 100644 --- a/dash/dash-renderer/src/wrapper/DashWrapper.tsx +++ b/dash/dash-renderer/src/wrapper/DashWrapper.tsx @@ -422,10 +422,24 @@ function DashWrapper({ ); } -export default memo( - DashWrapper, - (prevProps, nextProps) => - JSON.stringify(prevProps.componentPath) === - JSON.stringify(nextProps.componentPath) && - prevProps._dashprivate_error === nextProps._dashprivate_error -); +function wrapperEquality(prev: any, next: any) { + const { + componentPath: prevPath, + _dashprivate_error: prevError, + ...prevProps + } = prev; + const { + componentPath: nextPath, + _dashprivate_error: nextError, + ...nextProps + } = next; + if (JSON.stringify(prevPath) !== JSON.stringify(nextPath)) { + return false; + } + if (prevError !== nextError) { + return false; + } + return equals(prevProps, nextProps); +} + +export default memo(DashWrapper, wrapperEquality); diff --git a/dash/dash-renderer/src/wrapper/ExternalWrapper.tsx b/dash/dash-renderer/src/wrapper/ExternalWrapper.tsx new file mode 100644 index 0000000000..025bb2ea55 --- /dev/null +++ b/dash/dash-renderer/src/wrapper/ExternalWrapper.tsx @@ -0,0 +1,52 @@ +import React, {useState, useEffect} from 'react'; +import {useDispatch} from 'react-redux'; + +import {DashLayoutPath} from '../types/component'; +import DashWrapper from './DashWrapper'; +import {insertComponent, removeComponent} from '../actions'; + +type Props = { + componentPath: DashLayoutPath; + componentType: string; + componentNamespace: string; + [k: string]: any; +}; + +/** + * For rendering components that are out of the regular layout tree. + */ +function ExternalWrapper({ + componentType, + componentNamespace, + componentPath, + ...props +}: Props) { + const dispatch = useDispatch(); + const [inserted, setInserted] = useState(false); + + useEffect(() => { + // Give empty props for the inserted components. + // The props will come from the parent so they can be updated. + dispatch( + insertComponent({ + component: { + type: componentType, + namespace: componentNamespace, + props: {} + }, + componentPath + }) + ); + setInserted(true); + return () => { + dispatch(removeComponent({componentPath})); + }; + }, []); + + if (!inserted) { + return null; + } + // Render a wrapper with the actual props. + return ; +} +export default ExternalWrapper; diff --git a/tests/integration/renderer/test_external_component.py b/tests/integration/renderer/test_external_component.py new file mode 100644 index 0000000000..db095214a2 --- /dev/null +++ b/tests/integration/renderer/test_external_component.py @@ -0,0 +1,27 @@ +from dash import Dash, html, dcc, html, Input, Output, State +from dash_test_components import ExternalComponent + + +def test_rext001_render_external_component(dash_duo): + app = Dash() + app.layout = html.Div( + [ + dcc.Input(id="sync", value="synced"), + html.Button("sync", id="sync-btn"), + ExternalComponent("ext", input_id="external", text="external"), + ] + ) + + @app.callback( + Output("ext", "text"), + Input("sync-btn", "n_clicks"), + State("sync", "value"), + prevent_initial_call=True, + ) + def on_sync(_, value): + return value + + dash_duo.start_server(app) + dash_duo.wait_for_text_to_equal("#external", "external") + dash_duo.find_element("#sync-btn").click() + dash_duo.wait_for_text_to_equal("#external", "synced")