Skip to content

Add ExternalWrapper to dash_component_api #3170

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Feb 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions @plotly/dash-test-components/src/components/ExternalComponent.js
Original file line number Diff line number Diff line change
@@ -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 (
<div id={id}>
<ExternalWrapper
id={input_id}
componentType="Input"
componentNamespace="dash_core_components"
value={text}
componentPath={[...ctx.componentPath, 'external']}
/>
</div>
)
}

ExternalComponent.propTypes = {
id: PropTypes.string,
text: PropTypes.string,
input_id: PropTypes.string,
};

export default ExternalComponent;
4 changes: 3 additions & 1 deletion @plotly/dash-test-components/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -29,5 +30,6 @@ export {
AddPropsComponent,
ReceivePropsComponent,
ShapeOrExactKeepOrderComponent,
ArrayOfExactOrShapeWithNodePropAssignNone
ArrayOfExactOrShapeWithNodePropAssignNone,
ExternalComponent,
};
4 changes: 3 additions & 1 deletion dash/dash-renderer/src/actions/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
2 changes: 2 additions & 0 deletions dash/dash-renderer/src/actions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions dash/dash-renderer/src/dashApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -28,6 +29,7 @@ function getLayout(componentPathOrId: string[] | string): any {
}

(window as any).dash_component_api = {
ExternalWrapper,
DashContext,
useDashContext,
getLayout
Expand Down
18 changes: 17 additions & 1 deletion dash/dash-renderer/src/reducers/layout.js
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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;
};
Expand Down
17 changes: 14 additions & 3 deletions dash/dash-renderer/src/wrapper/DashContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down Expand Up @@ -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 || {};
}
28 changes: 21 additions & 7 deletions dash/dash-renderer/src/wrapper/DashWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
52 changes: 52 additions & 0 deletions dash/dash-renderer/src/wrapper/ExternalWrapper.tsx
Original file line number Diff line number Diff line change
@@ -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 <DashWrapper componentPath={componentPath} {...props} />;
}
export default ExternalWrapper;
27 changes: 27 additions & 0 deletions tests/integration/renderer/test_external_component.py
Original file line number Diff line number Diff line change
@@ -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")