diff --git a/README.npm.md b/README.npm.md index c271389115..038bf75e46 100644 --- a/README.npm.md +++ b/README.npm.md @@ -149,6 +149,7 @@ The example below demonstrates how to configure your kedro-viz using different ` | Name | Type | Default | Description | | ------------ | ------- | ------- | ----------- | | `data` | `{ edges: array (required), layers: array, nodes: array (required), tags: array }` | - | Pipeline data to be displayed on the chart | +| `onActionCallback` | function | - | Callback function to be invoked when the specified action is dispatched. e.g. `const action = { type: NODE_CLICK, payload: node }; onActionCallback(action);` | | options.display | | | | | `expandPipelinesBtn` | boolean | true | Show/Hide expand pipelines button | | `exportBtn` | boolean | true | Show/Hide export button | @@ -166,7 +167,8 @@ The example below demonstrates how to configure your kedro-viz using different ` ### Note -When `display.sidebar` is `false`, `display.miniMap` prop will be ignored. +- `onActionCallback` callback is only called when the user clicks on a node in the flowchart, and we are passing the node object as the payload in the callback argument. In future releases, we will add more actions to be dispatched in this callback. +- When `display.sidebar` is `false`, `display.miniMap` prop will be ignored. All components are annotated to understand their positions in the Kedro-Viz UI. diff --git a/RELEASE.md b/RELEASE.md index 4605346441..b744afe112 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -7,6 +7,10 @@ Please follow the established format: --> # Upcoming Release +## Major features and improvements + +- Introduce `onActionCallback` prop in Kedro-Viz react component. (#2022) + ## Bug fixes and other changes - Fixes design issues in metadata panel. (#2009) diff --git a/src/components/app/app.js b/src/components/app/app.js index 2eb3a2abe6..340dc1e44e 100644 --- a/src/components/app/app.js +++ b/src/components/app/app.js @@ -25,7 +25,11 @@ class App extends React.Component { constructor(props) { super(props); const initialState = getInitialState(props); - this.store = configureStore(initialState, this.props.data); + this.store = configureStore( + initialState, + this.props.data, + this.props.onActionCallback + ); } componentDidMount() { diff --git a/src/store/middleware.js b/src/store/middleware.js new file mode 100644 index 0000000000..0ebb7b76aa --- /dev/null +++ b/src/store/middleware.js @@ -0,0 +1,49 @@ +import { TOGGLE_NODE_CLICKED } from '../actions/nodes'; + +/** + * Middleware to handle custom callback actions in the Redux store. + * + * This middleware intercepts actions dispatched to the Redux store and checks for the action type. + * If the action type matches and callback function is provided, + * Then it invokes the provided callback function with type and payload. + * + * @param {Function} callback - The callback function to be invoked when the specified action is dispatched. + * @returns {Function} - A middleware function to be used in the Redux store. + */ +const createCallbackMiddleware = + (callback) => (store) => (next) => (action) => { + if (!callback) { + return next(action); + } + + switch (action.type) { + case TOGGLE_NODE_CLICKED: + if (action.nodeClicked) { + const state = store.getState(); + const node = state?.graph?.nodes?.find( + (node) => node.id === action.nodeClicked + ); + if (node) { + const nodeClickAction = { + type: TOGGLE_NODE_CLICKED, + payload: node, + }; + callback(nodeClickAction); + } + } + break; + /** + * Add additional cases here to handle other action types. + * Ensure on whatever action you want to trigger, It should be a Redux action. + * And payload should be in Redux state. + * Example: + * const action = { type: SLICE_PIPELINE, payload: runCommand }; + callback(action); + */ + default: + break; + } + return next(action); + }; + +export default createCallbackMiddleware; diff --git a/src/store/middleware.test.js b/src/store/middleware.test.js new file mode 100644 index 0000000000..eebdbc9f9d --- /dev/null +++ b/src/store/middleware.test.js @@ -0,0 +1,74 @@ +import createCallbackMiddleware from './middleware'; + +describe('createCallbackMiddleware', () => { + let store; + let next; + let callback; + let middleware; + + beforeEach(() => { + store = { + getState: jest.fn(), + }; + next = jest.fn(); + callback = jest.fn(); + middleware = createCallbackMiddleware(callback)(store)(next); + }); + + it('should call the callback with the correct node when action type is TOGGLE_NODE_CLICKED and node is found', () => { + const nodeClicked = '123'; + const node = { id: nodeClicked, name: 'Node 123' }; + const nodeClickAction = { type: 'TOGGLE_NODE_CLICKED', payload: node }; + const action = { type: 'TOGGLE_NODE_CLICKED', nodeClicked }; + + store.getState.mockReturnValue({ + graph: { + nodes: [node], + }, + }); + + middleware(action); + + expect(callback).toHaveBeenCalledWith(nodeClickAction); + expect(next).toHaveBeenCalledWith(action); + }); + + it('should not call the callback if action type is different', () => { + const action = { type: 'OTHER_ACTION', nodeClicked: '123' }; + + middleware(action); + + expect(callback).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(action); + }); + + it('should not call the callback if node is not found', () => { + const action = { type: 'TOGGLE_NODE_CLICKED', nodeClicked: '123' }; + + store.getState.mockReturnValue({ + graph: { + nodes: [{ id: '456', name: 'Node 456' }], + }, + }); + + middleware(action); + + expect(callback).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(action); + }); + + it('should not call the callback if callback is not provided', () => { + middleware = createCallbackMiddleware(null)(store)(next); + const action = { type: 'TOGGLE_NODE_CLICKED', nodeClicked: '123' }; + + store.getState.mockReturnValue({ + graph: { + nodes: [{ id: '123', name: 'Node 123' }], + }, + }); + + middleware(action); + + expect(next).toHaveBeenCalledWith(action); + }); +}); diff --git a/src/store/store.js b/src/store/store.js index 6b20e60c3e..a532a1b988 100644 --- a/src/store/store.js +++ b/src/store/store.js @@ -6,6 +6,7 @@ import { getGraphInput } from '../selectors/layout'; import { calculateGraph } from '../actions/graph'; import { saveLocalStorage, pruneFalseyKeys } from './helpers'; import { localStorageName, localStorageRunsMetadata } from '../config'; +import createCallbackMiddleware from './middleware'; /** * Watch the getGraphInput selector, and dispatch an asynchronous action to @@ -70,15 +71,22 @@ const saveStateToLocalStorage = (state) => { * Configure initial state and create the Redux store * @param {Object} initialState Initial Redux state (from initial-state.js) * @param {Object} dataType type of pipeline data - "static" or "json" (if data is loaded from API) + * @param {Function} onActionCallback Callback function to be called on specific action * @return {Object} Redux store */ -export default function configureStore(initialState, dataType) { +export default function configureStore( + initialState, + dataType, + onActionCallback +) { const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; + + const callbackMiddleware = createCallbackMiddleware(onActionCallback); const store = createStore( reducer, initialState, - composeEnhancers(applyMiddleware(thunk)) + composeEnhancers(applyMiddleware(thunk, callbackMiddleware)) ); // dispatch the calculateGraph action to ensure the graph nodes still gets rendered