diff --git a/RELEASE.md b/RELEASE.md index 6a0c444866..25321a220b 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -14,6 +14,7 @@ Please follow the established format: - Display published URLs. (#1907) - Conditionally move session store and stats file to .viz directory. (#1915) - Refactor namespace pipelines. (#1897) +- Expose the internal Redux state through `options` prop while using Kedro-Viz as a React component. (#1969) ## Bug fixes and other changes diff --git a/src/actions/index.js b/src/actions/index.js index 47e83197af..a04c759fef 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -331,3 +331,16 @@ export function updateRunNotes(notes, runId) { runId, }; } + +export const UPDATE_STATE_FROM_OPTIONS = 'UPDATE_STATE_FROM_OPTIONS'; + +/** + * Update state with latest options prop coming from the react component + * @param {Object} updatedOptions + */ +export const updateStateFromOptions = (updatedOptions) => { + return { + type: UPDATE_STATE_FROM_OPTIONS, + payload: updatedOptions, + }; +}; diff --git a/src/components/app/app.js b/src/components/app/app.js index ba70cfb8af..6b495c48ca 100644 --- a/src/components/app/app.js +++ b/src/components/app/app.js @@ -3,7 +3,8 @@ import PropTypes from 'prop-types'; import { Provider } from 'react-redux'; import 'what-input'; import configureStore from '../../store'; -import { resetData } from '../../actions'; +import { isEqual } from 'lodash/fp'; +import { resetData, updateStateFromOptions } from '../../actions'; import { loadInitialPipelineData } from '../../actions/pipelines'; import Wrapper from '../wrapper'; import getInitialState, { @@ -38,6 +39,9 @@ class App extends React.Component { if (prevProps.data !== this.props.data) { this.updatePipelineData(); } + if (!isEqual(prevProps.options, this.props.options)) { + this.store.dispatch(updateStateFromOptions(this.props.options)); + } } /** @@ -85,29 +89,47 @@ App.propTypes = { tags: PropTypes.array, }), ]), - /** - * Specify the theme: Either 'light' or 'dark'. - * If set, this will override the localStorage value. - */ - theme: PropTypes.oneOf(['dark', 'light']), - /** - * Override visibility of various features, e.g. icon buttons - */ - visible: PropTypes.shape({ - labelBtn: PropTypes.bool, - layerBtn: PropTypes.bool, - exportBtn: PropTypes.bool, - pipelineBtn: PropTypes.bool, - sidebar: PropTypes.bool, - }), - /** - * Determines if certain elements are displayed, e.g global tool bar, sidebar - */ - display: PropTypes.shape({ - globalToolbar: PropTypes.bool, - sidebar: PropTypes.bool, - miniMap: PropTypes.bool, - expandAllPipelines: PropTypes.bool, + options: PropTypes.shape({ + /** + * Specify the theme: Either 'light' or 'dark'. + * If set, this will override the localStorage value. + */ + theme: PropTypes.oneOf(['dark', 'light']), + /** + * Override visibility of various features, e.g. icon buttons + */ + visible: PropTypes.shape({ + labelBtn: PropTypes.bool, + layerBtn: PropTypes.bool, + exportBtn: PropTypes.bool, + pipelineBtn: PropTypes.bool, + sidebar: PropTypes.bool, + }), + /** + * Determines if certain elements are displayed, e.g global tool bar, sidebar + */ + display: PropTypes.shape({ + globalToolbar: PropTypes.bool, + sidebar: PropTypes.bool, + miniMap: PropTypes.bool, + expandAllPipelines: PropTypes.bool, + }), + /** + * Override the default enabled/disabled tags + */ + tag: PropTypes.shape({ + enabled: PropTypes.objectOf(PropTypes.bool), + }), + /** + * Override the default enabled/disabled node types + */ + nodeType: PropTypes.shape({ + disabled: PropTypes.shape({ + parameters: PropTypes.bool, + task: PropTypes.bool, + data: PropTypes.bool, + }), + }), }), }; diff --git a/src/components/flowchart-primary-toolbar/flowchart-primary-toolbar.js b/src/components/flowchart-primary-toolbar/flowchart-primary-toolbar.js index d40e6ecfeb..7d7c957c8d 100644 --- a/src/components/flowchart-primary-toolbar/flowchart-primary-toolbar.js +++ b/src/components/flowchart-primary-toolbar/flowchart-primary-toolbar.js @@ -25,7 +25,6 @@ import { useGeneratePathname } from '../../utils/hooks/use-generate-pathname'; */ export const FlowchartPrimaryToolbar = ({ disableLayerBtn, - displaySidebar, onToggleExportModal, onToggleLayers, onToggleSidebar, @@ -46,11 +45,7 @@ export const FlowchartPrimaryToolbar = ({ return ( <> - + ({ disableLayerBtn: !state.layer.ids.length, - displaySidebar: state.display.sidebar, textLabels: state.textLabels, visible: state.visible, visibleLayers: Boolean(getVisibleLayerIDs(state).length), diff --git a/src/components/flowchart-primary-toolbar/flowchart-primary-toolbar.test.js b/src/components/flowchart-primary-toolbar/flowchart-primary-toolbar.test.js index a6f328c8a5..8ba76359d8 100644 --- a/src/components/flowchart-primary-toolbar/flowchart-primary-toolbar.test.js +++ b/src/components/flowchart-primary-toolbar/flowchart-primary-toolbar.test.js @@ -26,7 +26,7 @@ describe('PrimaryToolbar', () => { pipelineBtn: false, }; const wrapper = setup.mount(, { - visible, + options: { visible }, }); expect(wrapper.find('.pipeline-icon-toolbar__button').length).toBe(1); }); @@ -36,7 +36,7 @@ describe('PrimaryToolbar', () => { labelBtn: false, }; const wrapper = setup.mount(, { - visible, + options: { visible }, }); expect(wrapper.find('.pipeline-icon-toolbar__button').length).toBe(4); }); @@ -71,7 +71,6 @@ describe('PrimaryToolbar', () => { disableLayerBtn: expect.any(Boolean), textLabels: expect.any(Boolean), expandedPipelines: expect.any(Boolean), - displaySidebar: true, visible: expect.objectContaining({ exportBtn: expect.any(Boolean), exportModal: expect.any(Boolean), diff --git a/src/components/flowchart-wrapper/flowchart-wrapper.js b/src/components/flowchart-wrapper/flowchart-wrapper.js index bbc04812f1..6b46249371 100644 --- a/src/components/flowchart-wrapper/flowchart-wrapper.js +++ b/src/components/flowchart-wrapper/flowchart-wrapper.js @@ -45,6 +45,7 @@ import './flowchart-wrapper.scss'; */ export const FlowChartWrapper = ({ fullNodeNames, + displaySidebar, graph, loading, metadataVisible, @@ -58,6 +59,9 @@ export const FlowChartWrapper = ({ pipelines, sidebarVisible, activePipeline, + tag, + nodeType, + expandAllPipelines, }) => { const history = useHistory(); const { pathname, search } = useLocation(); @@ -97,24 +101,16 @@ export const FlowChartWrapper = ({ } }, tag: (value) => { - if (!searchParams.has(params.tags)) { - const enabledKeys = getKeysByValue(value.enabled, true); - enabledKeys && toSetQueryParam(params.tags, enabledKeys); - } + const enabledKeys = getKeysByValue(value.enabled, true); + enabledKeys && toSetQueryParam(params.tags, enabledKeys); }, nodeType: (value) => { - if (!searchParams.has(params.types)) { - const disabledKeys = getKeysByValue(value.disabled, false); - // Replace task with node to keep UI label & the URL consistent - const mappedDisabledNodes = mapNodeTypes(disabledKeys); - disabledKeys && toSetQueryParam(params.types, mappedDisabledNodes); - } - }, - expandAllPipelines: (value) => { - if (!searchParams.has(params.expandAll)) { - toSetQueryParam(params.expandAll, value); - } + const disabledKeys = getKeysByValue(value.disabled, false); + // Replace task with node to keep UI label & the URL consistent + const mappedDisabledNodes = mapNodeTypes(disabledKeys); + disabledKeys && toSetQueryParam(params.types, mappedDisabledNodes); }, + expandAllPipelines: (value) => toSetQueryParam(params.expandAll, value), }; for (const [key, value] of Object.entries(localStorageParams)) { @@ -128,7 +124,7 @@ export const FlowChartWrapper = ({ useEffect(() => { setParamsFromLocalStorage(activePipeline); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activePipeline]); + }, [activePipeline, tag, nodeType, expandAllPipelines]); const resetErrorMessage = () => { setErrorMessage({}); @@ -308,7 +304,7 @@ export const FlowChartWrapper = ({ if (isInvalidUrl) { return (
- + {displaySidebar && } - + {displaySidebar && }
@@ -358,6 +354,7 @@ export const FlowChartWrapper = ({ export const mapStateToProps = (state) => ({ fullNodeNames: getNodeFullName(state), + displaySidebar: state.display.sidebar, graph: state.graph, loading: isLoading(state), metadataVisible: getVisibleMetaSidebar(state), @@ -366,6 +363,9 @@ export const mapStateToProps = (state) => ({ pipelines: state.pipeline.ids, activePipeline: state.pipeline.active, sidebarVisible: state.visible.sidebar, + tag: state.tag.enabled, + nodeType: state.nodeType.disabled, + expandAllPipelines: state.expandAllPipelines, }); export const mapDispatchToProps = (dispatch) => ({ diff --git a/src/components/flowchart/flowchart.js b/src/components/flowchart/flowchart.js index 1590a7823e..054a36a1ca 100644 --- a/src/components/flowchart/flowchart.js +++ b/src/components/flowchart/flowchart.js @@ -596,8 +596,13 @@ export class FlowChart extends Component { * Render React elements */ render() { - const { chartSize, layers, visibleGraph, displayGlobalToolbar } = - this.props; + const { + chartSize, + layers, + visibleGraph, + displayGlobalToolbar, + displaySidebar, + } = this.props; const { outerWidth = 0, outerHeight = 0 } = chartSize; return ( @@ -657,6 +662,7 @@ export class FlowChart extends Component { 'pipeline-flowchart__layer-names--visible': layers.length, 'pipeline-flowchart__layer-names--no-global-toolbar': !displayGlobalToolbar, + 'pipeline-flowchart__layer-names--no-sidebar': !displaySidebar, })} ref={this.layerNamesRef} /> @@ -690,6 +696,7 @@ export const mapStateToProps = (state, ownProps) => ({ chartSize: getChartSize(state), chartZoom: getChartZoom(state), displayGlobalToolbar: state.display.globalToolbar, + displaySidebar: state.display.sidebar, edges: state.graph.edges || emptyEdges, focusMode: state.visible.modularPipelineFocusMode, graphSize: state.graph.size || emptyGraphSize, diff --git a/src/components/flowchart/flowchart.test.js b/src/components/flowchart/flowchart.test.js index d350c80dd5..77edafb283 100644 --- a/src/components/flowchart/flowchart.test.js +++ b/src/components/flowchart/flowchart.test.js @@ -485,6 +485,7 @@ describe('FlowChart', () => { inputOutputDataEdges: expect.any(Object), focusMode: expect.any(Object), displayGlobalToolbar: expect.any(Boolean), + displaySidebar: expect.any(Boolean), }; expect(mapStateToProps(mockState.spaceflights)).toEqual(expectedResult); }); diff --git a/src/components/flowchart/styles/_layers.scss b/src/components/flowchart/styles/_layers.scss index 068a12a202..5f4086923b 100644 --- a/src/components/flowchart/styles/_layers.scss +++ b/src/components/flowchart/styles/_layers.scss @@ -46,6 +46,10 @@ left: -#{variables.$global-toolbar-width}; } + &--no-sidebar { + left: 0; + } + @media print { display: none; } diff --git a/src/reducers/index.js b/src/reducers/index.js index 29080f5bf7..86e92e02be 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -8,6 +8,7 @@ import node from './nodes'; import nodeType from './node-type'; import pipeline from './pipeline'; import tag from './tags'; +import merge from 'lodash/merge'; import modularPipeline from './modular-pipelines'; import visible from './visible'; import { @@ -21,6 +22,7 @@ import { UPDATE_CHART_SIZE, UPDATE_ZOOM, TOGGLE_EXPAND_ALL_PIPELINES, + UPDATE_STATE_FROM_OPTIONS, } from '../actions'; import { TOGGLE_PARAMETERS_HOVERED } from '../actions'; @@ -53,6 +55,19 @@ function resetDataReducer(state = {}, action) { return state; } +/** + * Update state from options props coming form react component + * @param {Object} state Complete app state + * @param {Object} action Redux action + * @return {Object} Updated state + */ +function updateStateFromOptionsReducer(state = {}, action) { + if (action.type === UPDATE_STATE_FROM_OPTIONS) { + return merge({}, state, action.payload); + } + return state; +} + const combinedReducer = combineReducers({ // These props have their own reducers in other files flags, @@ -103,7 +118,10 @@ const combinedReducer = combineReducers({ ), }); -const rootReducer = (state, action) => - combinedReducer(resetDataReducer(state, action), action); +const rootReducer = (state, action) => { + let newState = resetDataReducer(state, action); + newState = updateStateFromOptionsReducer(newState, action); + return combinedReducer(newState, action); +}; export default rootReducer; diff --git a/src/reducers/reducers.test.js b/src/reducers/reducers.test.js index 6cc8b1488b..ab2e5e7a9e 100644 --- a/src/reducers/reducers.test.js +++ b/src/reducers/reducers.test.js @@ -20,6 +20,7 @@ import { UPDATE_CHART_SIZE, TOGGLE_HOVERED_FOCUS_MODE, TOGGLE_EXPAND_ALL_PIPELINES, + UPDATE_STATE_FROM_OPTIONS, } from '../actions'; import { TOGGLE_NODE_CLICKED, @@ -430,4 +431,45 @@ describe('Reducer', () => { expect(newState.hoveredFocusMode).toBe(true); }); }); + + describe('UPDATE_STATE_FROM_OPTIONS', () => { + it('should update the theme state based on options props from a react component', () => { + const newOptions = { + theme: 'dark', + }; + const newState = reducer(mockState.spaceflights, { + type: UPDATE_STATE_FROM_OPTIONS, + payload: newOptions, + }); + expect(newState.theme).toBe('dark'); + }); + + it('should update the textLabels state based on options props from a react component', () => { + const newOptions = { + visible: { + textLabels: false, + }, + }; + const newState = reducer(mockState.spaceflights, { + type: UPDATE_STATE_FROM_OPTIONS, + payload: newOptions, + }); + expect(newState.visible.textLabels).toBe(false); + }); + + it('should update the tag state based on options props from a react component', () => { + const newOptions = { + tag: { + enabled: { + large: true, + }, + }, + }; + const newState = reducer(mockState.spaceflights, { + type: UPDATE_STATE_FROM_OPTIONS, + payload: newOptions, + }); + expect(newState.tag.enabled.large).toBe(true); + }); + }); }); diff --git a/src/selectors/layout.js b/src/selectors/layout.js index 4f0f68574a..5d91a716fe 100644 --- a/src/selectors/layout.js +++ b/src/selectors/layout.js @@ -13,6 +13,8 @@ import { const getSizeWarningFlag = (state) => state.flags.sizewarning; const getVisibleSidebar = (state) => state.visible.sidebar; +const getDisplayGlobalToolbar = (state) => state.display.globalToolbar; +const getDisplaySidebar = (state) => state.display.sidebar; const getVisibleCode = (state) => state.visible.code; const getIgnoreLargeWarning = (state) => state.ignoreLargeWarning; const getGraphHasNodes = (state) => Boolean(state.graph?.nodes?.length); @@ -70,15 +72,36 @@ export const getSidebarWidth = (visible, { open, closed }) => * and add some useful new ones */ export const getChartSize = createSelector( - [getVisibleSidebar, getVisibleMetaSidebar, getVisibleCode, getChartSizeState], - (visibleSidebar, visibleMetaSidebar, visibleCodeSidebar, chartSize) => { + [ + getVisibleSidebar, + getVisibleMetaSidebar, + getVisibleCode, + getChartSizeState, + getDisplaySidebar, + getDisplayGlobalToolbar, + ], + ( + visibleSidebar, + visibleMetaSidebar, + visibleCodeSidebar, + chartSize, + displaySidebar, + displayGlobalToolbar + ) => { const { left, top, width, height } = chartSize; if (!width || !height) { return {}; } + // Determine if the sidebar is visible and open + const isSidebarVisible = displaySidebar && visibleSidebar; + // Get the actual sidebar width - const sidebarWidthActual = getSidebarWidth(visibleSidebar, sidebarWidth); + const sidebarWidthActual = + displaySidebar || displayGlobalToolbar + ? getSidebarWidth(isSidebarVisible, sidebarWidth) + : 0; + const metaSidebarWidthActual = getSidebarWidth( visibleMetaSidebar, metaSidebarWidth diff --git a/src/store/initial-state.js b/src/store/initial-state.js index 9e65f9497f..a35ce45f07 100644 --- a/src/store/initial-state.js +++ b/src/store/initial-state.js @@ -215,17 +215,8 @@ export const preparePipelineState = ( * @param {Object} urlParams An object containing parsed URL parameters. * @returns {Object} The new non-pipeline state with modifications applied. */ -export const prepareNonPipelineState = (props, urlParams) => { +export const prepareNonPipelineState = (urlParams) => { let state = mergeLocalStorage(createInitialState()); - let newVisibleProps = {}; - - if (props.display?.sidebar === false || state.display.sidebar === false) { - newVisibleProps['sidebar'] = false; - } - - if (props.display?.minimap === false || state.display.miniMap === false) { - newVisibleProps['miniMap'] = false; - } if (urlParams) { state = applyUrlParametersToNonPipelineState(state, urlParams); @@ -234,9 +225,6 @@ export const prepareNonPipelineState = (props, urlParams) => { return { ...state, flags: { ...state.flags, ...getFlagsFromUrl() }, - theme: props.theme || state.theme, - visible: { ...state.visible, ...props.visible, ...newVisibleProps }, - display: { ...state.display, ...props.display }, }; }; @@ -249,11 +237,13 @@ export const prepareNonPipelineState = (props, urlParams) => { */ const getInitialState = (props = {}) => { const urlParams = parseUrlParameters(); - const nonPipelineState = prepareNonPipelineState(props, urlParams); + const nonPipelineState = prepareNonPipelineState(urlParams); + let expandAllPipelines = nonPipelineState.expandAllPipelines; - const expandAllPipelines = - nonPipelineState.display.expandAllPipelines || - nonPipelineState.expandAllPipelines; + if (props.options) { + expandAllPipelines = + props.options.expandAllPipelines || nonPipelineState.expandAllPipelines; + } const pipelineState = preparePipelineState( props.data, @@ -262,10 +252,12 @@ const getInitialState = (props = {}) => { urlParams ); - return { + const initialState = { ...nonPipelineState, ...pipelineState, }; + + return props.options ? deepmerge(initialState, props.options) : initialState; }; export default getInitialState; diff --git a/src/store/initial-state.test.js b/src/store/initial-state.test.js index 2ac6a3fb5f..7a58c15967 100644 --- a/src/store/initial-state.test.js +++ b/src/store/initial-state.test.js @@ -91,22 +91,6 @@ describe('prepareNonPipelineState', () => { }); }); - it('overrides theme with value from prop', () => { - const props = { theme: 'light' }; - expect( - prepareNonPipelineState({ data: spaceflights, ...props }) - ).toMatchObject(props); - }); - - it('overrides visible with values from prop', () => { - const props = { - visible: { miniMap: true, sidebar: false }, - }; - expect( - prepareNonPipelineState({ data: spaceflights, ...props }) - ).toMatchObject(props); - }); - it('overrides expandAllPipelines with values from URL', () => { // In this case, location.href is not provided expect(prepareNonPipelineState({ data: spaceflights })).toMatchObject({ @@ -145,7 +129,7 @@ describe('getInitialState', () => { expect( getInitialState({ ...props, - theme: 'light', + options: { visible: { labelBtn: true }, theme: 'light' }, }) ).toMatchObject({ theme: 'light', @@ -165,7 +149,9 @@ describe('getInitialState', () => { it('uses prop values instead of localstorage if provided', () => { saveLocalStorage(localStorageName, { theme: 'light' }); - expect(getInitialState({ ...props, theme: 'dark' })).toMatchObject({ + expect( + getInitialState({ ...props, options: { theme: 'dark' } }) + ).toMatchObject({ theme: 'dark', }); window.localStorage.clear();