From 94058277dff7030905267089eb6565d309d9764a Mon Sep 17 00:00:00 2001 From: Jitendra Gundaniya <38945204+jitu5@users.noreply.github.com> Date: Fri, 12 Apr 2024 11:32:16 +0100 Subject: [PATCH] Extending stateful URLS with node filters and Expanding/collapsing modular pipelines flag (#1799) * shareable viz with multiple platform UI * test fix * Relative path fix for user entered url * Test updated * Test updated * refactor router to accept new deployer inputs (#1739) Signed-off-by: ravi-kumar-pilla * Refactor Shareableviz CLI (#1740) * refactor router to accept new deployer inputs Signed-off-by: ravi-kumar-pilla * refactor cli for shareableviz deploy Signed-off-by: ravi-kumar-pilla * merge router change Signed-off-by: ravi-kumar-pilla * PR comments fix Signed-off-by: ravi-kumar-pilla --------- Signed-off-by: ravi-kumar-pilla * Kedro Viz Static Website hosting on Azure (#1708) * CLI command kedro viz build added * Lint fix * lint fix * Lint fix * add mypy ignore * Missing build file added * Lint error fix * BaseDeployer class added * Unused code removed * Fix lint issue * azure deploy initial draft Signed-off-by: ravi-kumar-pilla * added base_deployer * add deployer factory * partial working draft Signed-off-by: ravi-kumar-pilla * Test and comments of deployers updated * test draft Signed-off-by: ravi-kumar-pilla * fix lint Signed-off-by: ravi-kumar-pilla * remove circular dependency Signed-off-by: ravi-kumar-pilla * fix lint Signed-off-by: ravi-kumar-pilla * revert back consent Signed-off-by: ravi-kumar-pilla * minor updates Signed-off-by: ravi-kumar-pilla * update pytests Signed-off-by: ravi-kumar-pilla * add pytest for azure shareableviz Signed-off-by: ravi-kumar-pilla * refactor and add timeout Signed-off-by: ravi-kumar-pilla * refactor cli Signed-off-by: ravi-kumar-pilla * update pytest Signed-off-by: ravi-kumar-pilla * add release note Signed-off-by: ravi-kumar-pilla * fix flaky test Signed-off-by: ravi-kumar-pilla * fix PR comments and flaky test Signed-off-by: ravi-kumar-pilla * testing flaky c y test Signed-off-by: ravi-kumar-pilla * remove flaky test Signed-off-by: ravi-kumar-pilla * resolve conflicts Signed-off-by: ravi-kumar-pilla * fix PR comments Signed-off-by: ravi-kumar-pilla * add back cypress flaky test Signed-off-by: ravi-kumar-pilla * remove cypress flaky test Signed-off-by: ravi-kumar-pilla * remove duplicate pytest parameter Signed-off-by: ravi-kumar-pilla * remove fsspec upper bound Signed-off-by: ravi-kumar-pilla --------- Signed-off-by: ravi-kumar-pilla Co-authored-by: Jitendra Gundaniya Co-authored-by: rashidakanchwala * Kedro Viz Static Website Hosting on GCP (#1711) * CLI command kedro viz build added * Lint fix * lint fix * Lint fix * add mypy ignore * Missing build file added * Lint error fix * BaseDeployer class added * Unused code removed * Fix lint issue * azure deploy initial draft Signed-off-by: ravi-kumar-pilla * added base_deployer * add deployer factory * partial working draft Signed-off-by: ravi-kumar-pilla * Test and comments of deployers updated * test draft Signed-off-by: ravi-kumar-pilla * fix lint Signed-off-by: ravi-kumar-pilla * remove circular dependency Signed-off-by: ravi-kumar-pilla * fix lint Signed-off-by: ravi-kumar-pilla * revert back consent Signed-off-by: ravi-kumar-pilla * initial draft Signed-off-by: ravi-kumar-pilla * minor updates Signed-off-by: ravi-kumar-pilla * update pytests Signed-off-by: ravi-kumar-pilla * add pytest for azure shareableviz Signed-off-by: ravi-kumar-pilla * refactor and add timeout Signed-off-by: ravi-kumar-pilla * refactor cli Signed-off-by: ravi-kumar-pilla * update pytest Signed-off-by: ravi-kumar-pilla * add release note Signed-off-by: ravi-kumar-pilla * fix flaky test Signed-off-by: ravi-kumar-pilla * fix PR comments and flaky test Signed-off-by: ravi-kumar-pilla * testing flaky c y test Signed-off-by: ravi-kumar-pilla * remove flaky test Signed-off-by: ravi-kumar-pilla * add pytest for gcp Signed-off-by: ravi-kumar-pilla * fix gcp pytest coverage Signed-off-by: ravi-kumar-pilla * fix lint Signed-off-by: ravi-kumar-pilla * update pytest Signed-off-by: ravi-kumar-pilla * revert file permission change --------- Signed-off-by: ravi-kumar-pilla Co-authored-by: Jitendra Gundaniya Co-authored-by: rashidakanchwala * Initial commit * Set query params from local storage on load * Test fix * test fix * Test fixes * Some method docs added. * Shortening existing URL query params focused_id, selected_id, selected_name and pipeline_id * Fix experiment tracking search query test * Clear filter button added * Missing test added * Release note added * Clear to Reset * Release note & retain qparams updated * fixes * almost done * missing nodeTypes added * URL Params handling moved to user-generated-pathname.js * Review suggestion added * getKeysByValue moved * updateStateWithFilters renamed * Reset button status check removed * Reset buton status test removed * Reset button status check revert * Reset button UI update * code review suggestions for reset button status * state compare for nodeTypes * On group filter click fix * Task to node mapping added * All nodeTypes match UI labels with URLparmas * Removed console.log * modified test as per query param name update --------- Signed-off-by: ravi-kumar-pilla Co-authored-by: Ravi Kumar Pilla Co-authored-by: rashidakanchwala --- RELEASE.md | 1 + .../experiment-tracking.cy.js | 5 +- cypress/tests/ui/flowchart/flowchart.cy.js | 2 +- cypress/tests/ui/flowchart/menu.cy.js | 46 ++++- .../flowchart-wrapper/flowchart-wrapper.js | 63 ++++++- src/components/node-list/index.js | 87 +++++++++- src/components/node-list/node-list.js | 17 +- src/components/node-list/node-list.test.js | 158 +++++++++++++++--- src/components/node-list/styles/_section.scss | 50 +++++- .../pipeline-list/pipeline-list.test.js | 8 +- .../settings-modal/settings-modal.js | 13 +- src/config.js | 30 +++- src/store/initial-state.js | 115 +++++++++---- src/store/normalize-data.js | 73 +++++++- src/utils/get-key-by-value.js | 3 - src/utils/get-key-by-value.test.js | 19 --- src/utils/hooks/use-generate-pathname.js | 120 +++++++++---- src/utils/index.js | 15 ++ src/utils/match-path.js | 34 ++-- src/utils/object-utils.js | 12 ++ src/utils/object-utils.test.js | 34 ++++ 21 files changed, 743 insertions(+), 162 deletions(-) delete mode 100644 src/utils/get-key-by-value.js delete mode 100644 src/utils/get-key-by-value.test.js diff --git a/RELEASE.md b/RELEASE.md index ef37c46fb3..adb83d2c49 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -9,6 +9,7 @@ Please follow the established format: ## Major features and improvements +- Extending stateful URLs with node filters and expand/collapse modular pipelines. (#1799) - Introduce `--include-hooks` option and remove `--ignore-plugins` from cli commands. (#1818) - Add Dataset Factory Patterns to Experiment Tracking. (#1824) diff --git a/cypress/tests/ui/experiment-tracking/experiment-tracking.cy.js b/cypress/tests/ui/experiment-tracking/experiment-tracking.cy.js index 7cbfb1bf31..81d80e03e4 100644 --- a/cypress/tests/ui/experiment-tracking/experiment-tracking.cy.js +++ b/cypress/tests/ui/experiment-tracking/experiment-tracking.cy.js @@ -198,10 +198,7 @@ describe('Experiment Tracking', () => { cy.get('.accordion__title--hyperlink').first().click(); // Assert after action - cy.location('search').should( - 'eq', - `?pipeline_id=__default__&selected_name=${plotNameText}` - ); + cy.location('search').should('contain', `?pid=__default__&sn=${plotNameText}`); cy.__checkForText__( '.pipeline-node--selected > .pipeline-node__text', prettifyName(stripNamespace(plotNameText)) diff --git a/cypress/tests/ui/flowchart/flowchart.cy.js b/cypress/tests/ui/flowchart/flowchart.cy.js index bd0e0e3de4..b91c2ba082 100644 --- a/cypress/tests/ui/flowchart/flowchart.cy.js +++ b/cypress/tests/ui/flowchart/flowchart.cy.js @@ -190,7 +190,7 @@ describe('Flowchart DAG', () => { // Assert after action cy.get('.pipeline-warning__title') .should('exist') - .and('have.text', `Oops, there's nothing to see here`); + .and('include.text', `Oops, there's nothing to see here`); }); it('verifies that users can open and see the dataset statistics in the metadata panel for datasets. #TC-51', () => { diff --git a/cypress/tests/ui/flowchart/menu.cy.js b/cypress/tests/ui/flowchart/menu.cy.js index 875f3302a4..44cee29b01 100644 --- a/cypress/tests/ui/flowchart/menu.cy.js +++ b/cypress/tests/ui/flowchart/menu.cy.js @@ -31,7 +31,7 @@ describe('Flowchart Menu', () => { cy.location('search').should((queryParams) => { expect(decodeURIComponent(queryParams).toLowerCase()).to.contain( - menuOptionValue.toLowerCase() + menuOptionValue.toLowerCase().replace(/ /g, '+') ); }); @@ -170,4 +170,48 @@ describe('Flowchart Menu', () => { .should('have.class', 'pipeline-nodelist__row__label--faded') .should('have.class', 'pipeline-nodelist__row__label--disabled'); }); + + it('verifies that after checking node type URL should be updated with correct query params', () => { + const nodeToToggleText = 'Parameters'; + + // Alias + cy.get(`.pipeline-nodelist__row__checkbox[name=${nodeToToggleText}]`).as( + 'nodeToToggle' + ); + + // Assert before action + cy.get('@nodeToToggle').should('not.be.checked'); + + // Action + cy.get('@nodeToToggle').check({ force: true }); + + // Assert after action + cy.url().should('include', 'parameters'); + }); + + it('Verify that if the URL contains the nodeTag query parameter, the same parameter should be reflected on the UI.', () => { + const visibleRowLabel = 'Companies'; + cy.visit(`/?tags=${visibleRowLabel}`); + + // Alias + cy.get(`.pipeline-nodelist__row__checkbox[name=${visibleRowLabel}]`).as( + 'nodeToToggle' + ); + + // Assert + cy.get('@nodeToToggle').should('be.checked'); + }); + + it('Verify that if the URL contains the nodeType query parameter, the same parameter should be reflected on the UI.', () => { + const visibleRowLabel = 'Datasets'; + cy.visit('/?types=datasets'); + + // Alias + cy.get(`.pipeline-nodelist__row__checkbox[name=${visibleRowLabel}]`).as( + 'nodeToToggle' + ); + + // Assert + cy.get('@nodeToToggle').should('be.checked'); + }); }); diff --git a/src/components/flowchart-wrapper/flowchart-wrapper.js b/src/components/flowchart-wrapper/flowchart-wrapper.js index 5be27a971b..b305bc73ea 100644 --- a/src/components/flowchart-wrapper/flowchart-wrapper.js +++ b/src/components/flowchart-wrapper/flowchart-wrapper.js @@ -30,11 +30,13 @@ import { errorMessages, linkToFlowchartInitialVal, localStorageFlowchartLink, + localStorageName, params, } from '../../config'; import { findMatchedPath } from '../../utils/match-path'; -import { getKeyByValue } from '../../utils/get-key-by-value'; -import { isRunningLocally } from '../../utils'; +import { getKeyByValue, getKeysByValue } from '../../utils/object-utils'; +import { isRunningLocally, mapNodeTypes } from '../../utils'; +import { useGeneratePathname } from '../../utils/hooks/use-generate-pathname'; import './flowchart-wrapper.scss'; /** @@ -55,10 +57,12 @@ export const FlowChartWrapper = ({ onUpdateActivePipeline, pipelines, sidebarVisible, + activePipeline, }) => { const history = useHistory(); const { pathname, search } = useLocation(); const searchParams = new URLSearchParams(search); + const { toSetQueryParam } = useGeneratePathname(); const [errorMessage, setErrorMessage] = useState({}); const [isInvalidUrl, setIsInvalidUrl] = useState(false); @@ -78,6 +82,54 @@ export const FlowChartWrapper = ({ matchedFocusedNode, } = findMatchedPath(pathname, search); + /** + * On initial load & when user switch active pipeline, + * sets the query params from local storage based on NodeType, tag, expandAllPipelines and active pipeline. + * @param {string} activePipeline - The active pipeline. + */ + const setParamsFromLocalStorage = (activePipeline) => { + const localStorageParams = loadLocalStorage(localStorageName); + if (localStorageParams) { + const paramActions = { + pipeline: (value) => { + if (!searchParams.has(params.pipeline) && activePipeline) { + toSetQueryParam(params.pipeline, value.active || activePipeline); + } + }, + tag: (value) => { + if (!searchParams.has(params.tags)) { + 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); + } + }, + flags: (value) => { + if (!searchParams.has(params.expandAll)) { + toSetQueryParam(params.expandAll, value.expandAllPipelines); + } + }, + }; + + for (const [key, value] of Object.entries(localStorageParams)) { + if (paramActions[key]) { + paramActions[key](value); + } + } + } + }; + + useEffect(() => { + setParamsFromLocalStorage(activePipeline); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activePipeline]); + const resetErrorMessage = () => { setErrorMessage({}); setIsInvalidUrl(false); @@ -199,17 +251,17 @@ export const FlowChartWrapper = ({ resetErrorMessage(); } - if (matchedSelectedPipeline) { + if (matchedSelectedPipeline()) { // Redirecting to a different pipeline is also handled at `preparePipelineState` // to ensure the data is ready before being passed to here redirectSelectedPipeline(); } - if (matchedSelectedNodeName || matchedSelectedNodeId) { + if (matchedSelectedNodeName() || matchedSelectedNodeId()) { redirectToSelectedNode(); } - if (matchedFocusedNode) { + if (matchedFocusedNode()) { redirectToFocusedNode(); } @@ -312,6 +364,7 @@ export const mapStateToProps = (state) => ({ modularPipelinesTree: getModularPipelinesTree(state), nodes: state.node.modularPipelines, pipelines: state.pipeline.ids, + activePipeline: state.pipeline.active, sidebarVisible: state.visible.sidebar, }); diff --git a/src/components/node-list/index.js b/src/components/node-list/index.js index 3327ec1f29..5c25245c80 100644 --- a/src/components/node-list/index.js +++ b/src/components/node-list/index.js @@ -39,6 +39,7 @@ import { } from '../../actions/nodes'; import { useGeneratePathname } from '../../utils/hooks/use-generate-pathname'; import './styles/node-list.scss'; +import { params, NODE_TYPES } from '../../config'; /** * Provides data from the store to populate a NodeList component. @@ -68,9 +69,16 @@ const NodeListProvider = ({ inputOutputDataNodes, }) => { const [searchValue, updateSearchValue] = useState(''); + const [isResetFilterActive, setIsResetFilterActive] = useState(false); - const { toSelectedPipeline, toSelectedNode, toFocusedModularPipeline } = - useGeneratePathname(); + const { + toSelectedPipeline, + toSelectedNode, + toFocusedModularPipeline, + toUpdateUrlParamsOnResetFilter, + toUpdateUrlParamsOnFilter, + toSetQueryParam, + } = useGeneratePathname(); const items = getFilteredItems({ nodes, @@ -105,10 +113,50 @@ const NodeListProvider = ({ } }; + // To get existing values from URL query parameters + const getExistingValuesFromUrlQueryParams = (paramName, searchParams) => { + const paramValues = searchParams.get(paramName); + return new Set(paramValues ? paramValues.split(',') : []); + }; + + const handleUrlParamsUpdateOnFilter = (item) => { + const searchParams = new URLSearchParams(window.location.search); + const paramName = isElementType(item.type) ? params.types : params.tags; + const existingValues = getExistingValuesFromUrlQueryParams( + paramName, + searchParams + ); + + toUpdateUrlParamsOnFilter(item, paramName, existingValues); + }; + + // To update URL query parameters when a filter group is clicked + const handleUrlParamsUpdateOnGroupFilter = ( + groupType, + groupItems, + groupItemsDisabled + ) => { + if (groupItemsDisabled) { + // If all items in group are disabled + groupItems.forEach((item) => { + handleUrlParamsUpdateOnFilter(item); + }); + } else { + // If some items in group are enabled + const paramName = isElementType(groupType) ? params.types : params.tags; + toSetQueryParam(paramName, []); + } + }; + const onItemChange = (item, checked, clickedIconType) => { if (isGroupType(item.type) || isModularPipelineType(item.type)) { onGroupItemChange(item, checked); + // Update URL query parameters when a filter item is clicked + if (!clickedIconType) { + handleUrlParamsUpdateOnFilter(item); + } + if (isModularPipelineType(item.type)) { if (clickedIconType === 'focus') { if (focusMode === null) { @@ -169,6 +217,13 @@ const NodeListProvider = ({ (groupItem) => !groupItem.checked ); + // Update URL query parameters when a filter group is clicked + handleUrlParamsUpdateOnGroupFilter( + groupType, + groupItems, + groupItemsDisabled + ); + if (isTagType(groupType)) { onToggleTagFilter( groupItems.map((item) => item.id), @@ -207,6 +262,32 @@ const NodeListProvider = ({ onToggleNodeSelected(null); } }; + + // Reset applied filters to default + const onResetFilter = () => { + onToggleTypeDisabled({ task: false, data: false, parameters: true }); + onToggleTagFilter( + tags.map((item) => item.id), + false + ); + + toUpdateUrlParamsOnResetFilter(); + }; + + // Helper function to check if NodeTypes is modified + const hasModifiedNodeTypes = (nodeTypes) => { + return nodeTypes.some( + (item) => NODE_TYPES[item.id]?.defaultState !== item.disabled + ); + }; + + // Updates the reset filter button status based on the node types and tags. + useEffect(() => { + const isNodeTypeModified = hasModifiedNodeTypes(nodeTypes); + const isNodeTagModified = tags.some((tag) => tag.enabled); + setIsResetFilterActive(isNodeTypeModified || isNodeTagModified); + }, [tags, nodeTypes]); + useEffect(() => { window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); @@ -230,6 +311,8 @@ const NodeListProvider = ({ onItemChange={onItemChange} focusMode={focusMode} disabledModularPipeline={disabledModularPipeline} + onResetFilter={onResetFilter} + isResetFilterActive={isResetFilterActive} /> ); }; diff --git a/src/components/node-list/node-list.js b/src/components/node-list/node-list.js index 67c0edc794..0106c0594c 100644 --- a/src/components/node-list/node-list.js +++ b/src/components/node-list/node-list.js @@ -28,6 +28,8 @@ const NodeList = ({ onModularPipelineToggleExpanded, focusMode, disabledModularPipeline, + onResetFilter, + isResetFilterActive, }) => { return (
-

- Filters -

+
+

+ Filters +

+ +
{ }); it('renders without crashing', () => { - const wrapper = setup.mount(); + const wrapper = setup.mount( + + + + ); const search = wrapper.find('.pipeline-search-list'); const nodeList = wrapper.find('.pipeline-nodelist__list'); expect(search.length).toBe(1); @@ -33,7 +38,11 @@ describe('NodeList', () => { describe('tree-search-ui', () => { describe('displays nodes matching search value', () => { - const wrapper = setup.mount(); + const wrapper = setup.mount( + + + + ); const searches = [ // search text that matches an external node only @@ -83,7 +92,11 @@ describe('NodeList', () => { ); }); it('clears the search input and resets the list when hitting the Escape key', () => { - const wrapper = setup.mount(); + const wrapper = setup.mount( + + + + ); const searchWrapper = wrapper.find('.pipeline-search-list'); // Re-find elements from root each time to see updates const search = () => wrapper.find('.search-input__field'); @@ -128,7 +141,9 @@ describe('NodeList', () => { }); it('displays search results when in focus mode', () => { const wrapper = setup.mount( - + + + ); const searchWrapper = wrapper.find('.pipeline-search-list'); // Re-find elements from root each time to see updates @@ -182,9 +197,14 @@ describe('NodeList', () => { .map((row) => [row.prop('title')]); it('shows full node names when pretty name is turned off', () => { - const wrapper = setup.mount(, { - beforeLayoutActions: [() => toggleIsPrettyName(false)], - }); + const wrapper = setup.mount( + + + , + { + beforeLayoutActions: [() => toggleIsPrettyName(false)], + } + ); expect(elements(wrapper)).toEqual([ ['data_processing'], ['data_science'], @@ -194,9 +214,14 @@ describe('NodeList', () => { ]); }); it('shows formatted node names when pretty name is turned on', () => { - const wrapper = setup.mount(, { - beforeLayoutActions: [() => toggleIsPrettyName(true)], - }); + const wrapper = setup.mount( + + + , + { + beforeLayoutActions: [() => toggleIsPrettyName(true)], + } + ); expect(elements(wrapper)).toEqual([ ['Data Processing'], ['Data Science'], @@ -242,9 +267,14 @@ describe('NodeList', () => { it('selecting tags enables only elements with given tags and modular pipelines', () => { //Parameters are enabled here to override the default behavior - const wrapper = setup.mount(, { - beforeLayoutActions: [() => toggleTypeDisabled('parameters', false)], - }); + const wrapper = setup.mount( + + + , + { + beforeLayoutActions: [() => toggleTypeDisabled('parameters', false)], + } + ); changeRows(wrapper, ['Preprocessing'], true); expect(elementsEnabled(wrapper)).toEqual([ @@ -262,9 +292,14 @@ describe('NodeList', () => { it('selecting a tag sorts elements by modular pipelines first then by task, data and parameter nodes ', () => { //Parameters are enabled here to override the default behavior - const wrapper = setup.mount(, { - beforeLayoutActions: [() => toggleTypeDisabled('parameters', false)], - }); + const wrapper = setup.mount( + + + , + { + beforeLayoutActions: [() => toggleTypeDisabled('parameters', false)], + } + ); // with the modular pipeline tree structure the elements displayed here are for the top level pipeline expect(elements(wrapper)).toEqual([ @@ -280,7 +315,11 @@ describe('NodeList', () => { }); it('adds a class to tag group item when all tags unchecked', () => { - const wrapper = setup.mount(); + const wrapper = setup.mount( + + + + ); const uncheckedClass = 'pipeline-nodelist__group--all-unchecked'; expect(tagItem(wrapper).hasClass(uncheckedClass)).toBe(true); @@ -291,7 +330,11 @@ describe('NodeList', () => { }); it('adds a class to the row when a tag row unchecked', () => { - const wrapper = setup.mount(); + const wrapper = setup.mount( + + + + ); const uncheckedClass = 'pipeline-nodelist__row--unchecked'; expect(rowByName(wrapper, 'Preprocessing').hasClass(uncheckedClass)).toBe( @@ -308,7 +351,11 @@ describe('NodeList', () => { }); it('shows as partially selected when at least one but not all tags selected', () => { - const wrapper = setup.mount(); + const wrapper = setup.mount( + + + + ); // No tags selected expect(partialIcon(wrapper)).toHaveLength(0); @@ -327,7 +374,11 @@ describe('NodeList', () => { }); it('saves enabled tags in localStorage on selecting a tag on node-list', () => { - const wrapper = setup.mount(); + const wrapper = setup.mount( + + + + ); changeRows(wrapper, ['Preprocessing'], true); const localStoredValues = JSON.parse( window.localStorage.getItem(localStorageName) @@ -338,7 +389,11 @@ describe('NodeList', () => { describe('node list', () => { it('renders the correct number of tags in the filter panel', () => { - const wrapper = setup.mount(); + const wrapper = setup.mount( + + + + ); const nodeList = wrapper.find( '.pipeline-nodelist__list--nested .pipeline-nodelist__row' ); @@ -348,7 +403,11 @@ describe('NodeList', () => { expect(nodeList.length).toBe(tags.length + elementTypes.length); }); it('renders the correct number of modular pipelines and nodes in the tree sidepanel', () => { - const wrapper = setup.mount(); + const wrapper = setup.mount( + + + + ); const nodeList = wrapper.find('.pipeline-nodelist__row__text--tree'); const modularPipelinesTree = getModularPipelinesTree( mockState.spaceflights @@ -359,7 +418,11 @@ describe('NodeList', () => { }); it('renders elements panel, filter panel inside a SplitPanel with a handle', () => { - const wrapper = setup.mount(); + const wrapper = setup.mount( + + + + ); const split = wrapper.find(SplitPanel); expect(split.find('.pipeline-nodelist__split').exists()).toBe(true); @@ -379,7 +442,11 @@ describe('NodeList', () => { }); describe('node list element item', () => { - const wrapper = setup.mount(); + const wrapper = setup.mount( + + + + ); // this needs to be the 3rd element as the first 2 elements are modular pipelines rows which does not apply the '--active' class const nodeRow = () => wrapper.find('.pipeline-nodelist__row').at(3); @@ -395,7 +462,11 @@ describe('NodeList', () => { }); describe('node list element item checkbox', () => { - const wrapper = setup.mount(); + const wrapper = setup.mount( + + + + ); const checkbox = () => wrapper.find('.pipeline-nodelist__row input').at(4); it('handles toggle off event', () => { @@ -423,6 +494,43 @@ describe('NodeList', () => { }); }); + describe('Reset node filters', () => { + const wrapper = setup.mount( + + + + ); + + const resetFilterButton = wrapper.find( + '.pipeline-nodelist-section__reset-filter' + ); + + it('On first load before applying filter button should be disabled', () => { + expect(resetFilterButton.prop('disabled')).toBe(true); + }); + + it('After applying any filter filter button should not be disabled', () => { + const nodeTypeFilter = wrapper.find( + `.pipeline-nodelist__row__checkbox[name="Datasets"]` + ); + nodeTypeFilter.simulate('click'); + + nodeTypeFilter.simulate('change', { + target: { checked: false }, + }); + + setTimeout(() => { + expect(resetFilterButton.prop('disabled')).toBe(false); + }, 1); // Wait for 1 second before asserting + }); + + it('should update URL parameters when onResetFilter is called', () => { + resetFilterButton.simulate('click'); + + expect(window.location.search).not.toContain('tags'); + }); + }); + it('maps state to props', () => { const nodeList = expect.arrayContaining([ expect.objectContaining({ diff --git a/src/components/node-list/styles/_section.scss b/src/components/node-list/styles/_section.scss index e098cad9bc..a854ce8ee8 100644 --- a/src/components/node-list/styles/_section.scss +++ b/src/components/node-list/styles/_section.scss @@ -1,11 +1,45 @@ @use './variables'; +@use '../../../styles/extends'; +@use '../../../styles/variables' as colors; -.pipeline-nodelist-section__title { - display: block; - margin: 6px variables.$section-title-padding-x 12px - (variables.$section-title-padding-x + 0.085); - font-weight: normal; - font-size: 1.6em; - opacity: 0.55; - user-select: none; +.kui-theme--light { + --color-text-reset: #{colors.$black-800}; +} + +.kui-theme--dark { + --color-text-reset: #{colors.$white-600}; +} + +.pipeline-nodelist-section__filters { + display: flex; + justify-content: space-between; + align-items: center; + margin: 6px (variables.$section-title-padding-x + 0.92) 12px + (variables.$section-title-padding-x + 1.06); + + .pipeline-nodelist-section__title { + font-weight: normal; + font-size: 1.6em; + opacity: 0.55; + user-select: none; + margin: 0; + } + + .pipeline-nodelist-section__reset-filter { + @extend %button; + + font-size: 1.3em; + cursor: pointer; + color: var(--color-text-reset); + opacity: 0.55; + + &:hover { + opacity: 0.85; + } + + &:disabled { + cursor: default; + opacity: 0; + } + } } diff --git a/src/components/pipeline-list/pipeline-list.test.js b/src/components/pipeline-list/pipeline-list.test.js index 2b2175c435..bb71ac0217 100644 --- a/src/components/pipeline-list/pipeline-list.test.js +++ b/src/components/pipeline-list/pipeline-list.test.js @@ -6,11 +6,17 @@ import PipelineList, { import { mockState, setup } from '../../utils/state.mock'; const mockHistoryPush = jest.fn(); +const mockLocationSearch = '?query=mockQuery'; +const mockLocationPathname = '/'; jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useHistory: () => ({ push: mockHistoryPush, + location: { + search: mockLocationSearch, + pathname: mockLocationPathname, + }, }), })); @@ -47,7 +53,7 @@ describe('PipelineList', () => { wrapper.find('MenuOption').at(i).simulate('click'); expect(wrapper.find('PipelineList').props().pipeline.active).toBe(id); - expect(mockHistoryPush).toHaveBeenCalledWith(`/?pipeline_id=${id}`); + expect(mockHistoryPush).toHaveBeenCalledWith(`/?pid=${id}`); } ); diff --git a/src/components/settings-modal/settings-modal.js b/src/components/settings-modal/settings-modal.js index e0bffc5b70..c3f081f8e3 100644 --- a/src/components/settings-modal/settings-modal.js +++ b/src/components/settings-modal/settings-modal.js @@ -8,9 +8,14 @@ import { } from '../../actions'; import { getFlagsState } from '../../utils/flags'; import SettingsModalRow from './settings-modal-row'; -import { settings as settingsConfig, localStorageName } from '../../config'; +import { + settings as settingsConfig, + localStorageName, + params, +} from '../../config'; import { saveLocalStorage } from '../../store/helpers'; import { localStorageKeyFeatureHintsStep } from '../../components/feature-hints/feature-hints'; +import { useGeneratePathname } from '../../utils/hooks/use-generate-pathname'; import Button from '../ui/button'; import Modal from '../ui/modal'; @@ -42,6 +47,8 @@ const SettingsModal = ({ useState(showFeatureHints); const [toggleFlags, setToggleFlags] = useState(flags); + const { toSetQueryParam } = useGeneratePathname(); + useEffect(() => { setShowFeatureHintsValue(showFeatureHints); }, [showFeatureHints]); @@ -59,6 +66,9 @@ const SettingsModal = ({ const updatedFlags = Object.entries(toggleFlags); updatedFlags.map((each) => { const [name, value] = each; + if (name === params.expandAll) { + toSetQueryParam(params.expandAll, value); + } return onToggleFlag(name, value); }); @@ -85,6 +95,7 @@ const SettingsModal = ({ onToggleIsPrettyName, showSettingsModal, toggleFlags, + toSetQueryParam, ]); const resetStateCloseModal = () => { diff --git a/src/config.js b/src/config.js index ecdd9707bf..0dca77dcd1 100644 --- a/src/config.js +++ b/src/config.js @@ -113,13 +113,16 @@ export const tabLabels = ['Overview', 'Metrics', 'Plots']; // URL parameters for each element/section export const params = { - focused: 'focused_id', - selected: 'selected_id', - selectedName: 'selected_name', - pipeline: 'pipeline_id', + focused: 'fid', + selected: 'sid', + selectedName: 'sn', + pipeline: 'pid', run: 'run_ids', view: 'view', comparisonMode: 'comparison', + types: 'types', + tags: 'tags', + expandAll: 'expandAllPipelines', }; const activePipeline = `${params.pipeline}=:pipelineId`; @@ -141,9 +144,9 @@ export const routes = { }; export const errorMessages = { - node: 'Please check the value of "selected_id" or "selected_name" in the URL', - modularPipeline: 'Please check the value of "focused_id" in the URL', - pipeline: 'Please check the value of "pipeline_id" in the URL', + node: 'Please check the value of "selected_id"/"sid" or "selected_name"/"sn" in the URL', + modularPipeline: 'Please check the value of "focused_id"/"fid" in the URL', + pipeline: 'Please check the value of "pipeline_id"/"pid" in the URL', experimentTracking: `Please check the spelling of "run_ids" or "view" or "comparison" in the URL. It may be a typo 😇`, runIds: `Please check the value of "run_ids" in the URL. Perhaps you've deleted the entity 🙈 or it may be a typo 😇`, }; @@ -178,3 +181,16 @@ export const KEDRO_VIZ_PREVIEW_DATASETS_DOCS_URL = `${KEDRO_VIZ_DOCS_URL}preview export const KEDRO_VIZ_PUBLISH_AWS_DOCS_URL = `${KEDRO_VIZ_DOCS_URL}publish_and_share_kedro_viz_on_aws.html#set-up-endpoint`; export const KEDRO_VIZ_PUBLISH_AZURE_DOCS_URL = `${KEDRO_VIZ_DOCS_URL}publish_and_share_kedro_viz_on_azure.html#set-up-endpoint`; export const KEDRO_VIZ_PUBLISH_GCP_DOCS_URL = `${KEDRO_VIZ_DOCS_URL}publish_and_share_kedro_viz_on_gcp.html#set-up-endpoint`; + +export const defaultQueryParams = [ + params.types, + params.tags, + params.expandAll, + params.pipeline, +]; + +export const NODE_TYPES = { + task: { name: 'nodes', defaultState: false }, + data: { name: 'datasets', defaultState: false }, + parameters: { name: 'parameters', defaultState: true }, +}; diff --git a/src/store/initial-state.js b/src/store/initial-state.js index 51de76fafc..5279bdd49b 100644 --- a/src/store/initial-state.js +++ b/src/store/initial-state.js @@ -2,6 +2,7 @@ import deepmerge from 'deepmerge'; import { loadLocalStorage } from './helpers'; import normalizeData from './normalize-data'; import { getFlagsFromUrl, Flags } from '../utils/flags'; +import { mapNodeType } from '../utils'; import { settings, sidebarWidth, @@ -53,6 +54,81 @@ export const createInitialState = () => ({ runsMetadata: {}, }); +const parseUrlParameters = () => { + const search = new URLSearchParams(window.location.search); + return { + pipelineIdFromURL: search.get(params.pipeline), + nodeIdFromUrl: search.get(params.selected), + nodeNameFromUrl: search.get(params.selectedName), + nodeTypeInUrl: search.get(params.types) + ? search.get(params.types).split(',') + : [], + nodeTagInUrl: search.get(params.tags) + ? search.get(params.tags).split(',') + : [], + }; +}; + +/** + * Applies URL parameters to the application state. + * This function modifies the state based on URL parameters such as + * pipeline ID, node ID, node name, node type presence, and tag presence. + * + * @param {Object} state The current application state. + * @param {Object} urlParams An object containing parsed URL parameters. + * @returns {Object} The new state with modifications applied based on URL parameters. + */ +const applyUrlParametersToState = (state, urlParams) => { + const { + pipelineIdFromURL, + nodeIdFromUrl, + nodeNameFromUrl, + nodeTypeInUrl, + nodeTagInUrl, + } = urlParams; + + let newState = { ...state }; + const nodeTypes = ['parameters', 'task', 'data']; + + // Use main pipeline if pipeline from URL isn't recognised + if (pipelineIdFromURL) { + newState.pipeline.active = newState.pipeline.ids.includes(pipelineIdFromURL) + ? pipelineIdFromURL + : newState.pipeline.main; + } + + // Ensure data tags are on to allow redirection back to the selected node + if (nodeNameFromUrl) { + newState.nodeType.disabled.data = false; + } + + if (nodeTypeInUrl.length) { + Object.keys(newState.nodeType.disabled).forEach((key) => { + newState.nodeType.disabled[key] = true; + }); + nodeTypeInUrl.forEach((key) => { + newState.nodeType.disabled[mapNodeType(key)] = false; + }); + } + + // Enable node types based on presence in URL and current node type settings + if (nodeIdFromUrl && nodeTypes.includes(state.node.type[nodeIdFromUrl])) { + newState.nodeType.disabled[newState.node.type[nodeIdFromUrl]] = false; + } + + if (nodeTagInUrl.length) { + // Set all tags to false initially + Object.keys(newState.tag.enabled).forEach((key) => { + newState.tag.enabled[key] = false; + }); + nodeTagInUrl.forEach((tag) => { + newState.tag.enabled[tag] = true; + }); + } + + return newState; +}; + /** * Load values from localStorage and combine with existing state, * but filter out any unused values from localStorage @@ -87,34 +163,8 @@ export const mergeLocalStorage = (state) => { * @param {Boolean} applyFixes Whether to override initialState */ export const preparePipelineState = (data, applyFixes, expandAllPipelines) => { - const state = mergeLocalStorage(normalizeData(data, expandAllPipelines)); - - const search = new URLSearchParams(window.location.search); - const pipelineIdFromURL = search.get(params.pipeline); - const nodeIdFromUrl = search.get(params.selected); - const nodeNameFromUrl = search.get(params.selectedName); - - const nodeTypes = ['parameters', 'task', 'data']; - - if (pipelineIdFromURL) { - // Use main pipeline if pipeline from URL isn't recognised - if (!state.pipeline.ids.includes(pipelineIdFromURL)) { - state.pipeline.active = state.pipeline.main; - } else { - state.pipeline.active = pipelineIdFromURL; - } - } - - // Set the nodeType.disable to false depending on what type of data it is, e.g. parameters, data, etc. - if (nodeTypes.includes(state.node.type[nodeIdFromUrl])) { - state.nodeType.disabled[state.node.type[nodeIdFromUrl]] = false; - } - - // If there is a "selected_name" in the URL we need to ensure - // data tags is on so the app can redirect back to the selected node - if (nodeNameFromUrl) { - state.nodeType.disabled.data = false; - } + let state = mergeLocalStorage(normalizeData(data, expandAllPipelines)); + const urlParams = parseUrlParameters(); if (applyFixes) { // Use main pipeline if active pipeline from localStorage isn't recognised @@ -122,7 +172,7 @@ export const preparePipelineState = (data, applyFixes, expandAllPipelines) => { state.pipeline.active = state.pipeline.main; } } - + state = applyUrlParametersToState(state, urlParams); return state; }; @@ -130,21 +180,18 @@ export const preparePipelineState = (data, applyFixes, expandAllPipelines) => { * Prepare the non-pipeline data part of the state. This part is separated so that it * will persist if the pipeline data is reset. * Merge local storage and add custom state overrides from props etc - * @param {Object} props Props passed to App component - * @return {Object} Updated initial state + * @param {object} props Props passed to App component + * @return {object} Updated initial state */ export const prepareNonPipelineState = (props) => { const 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; } - return { ...state, flags: { ...state.flags, ...getFlagsFromUrl() }, diff --git a/src/store/normalize-data.js b/src/store/normalize-data.js index 1546526930..6ad17baf44 100644 --- a/src/store/normalize-data.js +++ b/src/store/normalize-data.js @@ -1,3 +1,4 @@ +import { params } from '../config'; import { arrayToObject, prettifyName, @@ -196,6 +197,75 @@ const addLayer = (state) => (layer) => { state.layer.name[layer] = layer; }; +/** + * Split query params from URL into an array and remove any empty strings + * @param {String} queryParams - Query params from URL + */ +const splitQueryParams = (queryParams) => + queryParams ? queryParams.split(',').filter((item) => item !== '') : []; + +/** + * Returns an object with filters for tags as set in current URL + * @param {Object} state - State object + * @param {Array} tagsQueryParam - List of node tags from URL + * @param {Array} allNodeTags - List of all associated tags + */ + +const getNodeTagsFiltersFromUrl = (state, tagsQueryParam, allNodeTags = []) => { + const queryParamsTagsArray = splitQueryParams(tagsQueryParam); + + if (queryParamsTagsArray.length !== 0) { + const queryParamsTagsSet = new Set(queryParamsTagsArray); + const enabledTags = allNodeTags.reduce((result, tag) => { + result[tag.id] = queryParamsTagsSet.has(tag.id); + return result; + }, {}); + + state.tag.enabled = enabledTags; + } + + return state; +}; + +/** + * Updates the disabled state of node types based on the provided type query parameters. + * @param {Object} state - The current state object. + * @param {string} typeQueryParams - The type query parameters. + * @returns {Object} - The updated state object. + */ +const getNodeTypesFromUrl = (state, typeQueryParams) => { + const nodeTypes = splitQueryParams(typeQueryParams); + + if (nodeTypes.length !== 0) { + Object.keys(state.nodeType.disabled).forEach((key) => { + state.nodeType.disabled[key] = !nodeTypes.includes(key); + }); + } + + return state; +}; + +/** + * Updates the state with filters from the URL. + * @param {Object} state - State object + * @param {Array} NodeTags - List of all associated tags + * * @returns {Object} - The updated state object. + */ +const updateStateWithFilters = (state, NodeTags) => { + const search = new URLSearchParams(window.location.search); + const typeQueryParams = search.get(params.types); + const tagQueryParams = search.get(params.tags); + + const updatedStateWithTags = getNodeTagsFiltersFromUrl( + state, + tagQueryParams, + NodeTags + ); + const updatedStateWithTypes = getNodeTypesFromUrl(state, typeQueryParams); + + return { ...state, ...updatedStateWithTags, ...updatedStateWithTypes }; +}; + /** * Convert the pipeline data into a normalized state object * @param {Object} data Raw unformatted data input @@ -256,7 +326,8 @@ const normalizeData = (data, expandAllPipelines) => { data.layers.forEach(addLayer(state)); } - return state; + const updatedState = updateStateWithFilters(state, data.tags); + return updatedState; }; export default normalizeData; diff --git a/src/utils/get-key-by-value.js b/src/utils/get-key-by-value.js deleted file mode 100644 index 5efe992add..0000000000 --- a/src/utils/get-key-by-value.js +++ /dev/null @@ -1,3 +0,0 @@ -export const getKeyByValue = (object, value) => { - return Object.keys(object).find((key) => object[key] === value); -}; diff --git a/src/utils/get-key-by-value.test.js b/src/utils/get-key-by-value.test.js deleted file mode 100644 index 53b78e60cf..0000000000 --- a/src/utils/get-key-by-value.test.js +++ /dev/null @@ -1,19 +0,0 @@ -import { getKeyByValue } from './get-key-by-value'; - -const mockObject = { - key1: 'value1', - key2: 'value2', - key3: 'value3', - key4: 'value4', -}; - -const mockValue = 'value3'; - -describe('getKeyByValue', () => { - it('return the correct key for the value', () => { - const expected = 'key3'; - const result = getKeyByValue(mockObject, mockValue); - - expect(result).toEqual(expected); - }); -}); diff --git a/src/utils/hooks/use-generate-pathname.js b/src/utils/hooks/use-generate-pathname.js index 77d4cb9e55..a70fd14ecf 100644 --- a/src/utils/hooks/use-generate-pathname.js +++ b/src/utils/hooks/use-generate-pathname.js @@ -1,12 +1,33 @@ import { useCallback } from 'react'; import { useHistory, generatePath } from 'react-router-dom'; -import { localStorageName, routes } from '../../config'; +import { + localStorageName, + params, + routes, + defaultQueryParams, + NODE_TYPES, +} from '../../config'; +import { mapNodeType } from '../../utils'; const getCurrentActivePipeline = () => { const localStorage = window.localStorage.getItem(localStorageName); return JSON.parse(localStorage)?.pipeline?.active; }; +/** + * Retains default query parameters and removes all others from the given searchParams object. + * @param {URLSearchParams} searchParams - The searchParams object to modify. + */ +const retainDefaultQueryParams = (searchParams) => { + const searchParamsEntries = [...searchParams.keys()]; + + for (const key of searchParamsEntries) { + if (!defaultQueryParams.includes(key)) { + searchParams.delete(key); + } + } +}; + /** * To generate different pathnames based on each action * E.g.: click on a node, or focus on a modular pipeline @@ -15,59 +36,102 @@ const getCurrentActivePipeline = () => { export const useGeneratePathname = () => { const history = useHistory(); + /** + * Updates the URL with search parameters based on the provided update function. + * @param {Function} updateFunction - The function that updates the search parameters. + */ + const updateURLWithSearchParams = useCallback( + (updateFunction) => { + const searchParams = new URLSearchParams(history.location.search); + updateFunction(searchParams); + const url = decodeURIComponent( + history.location.pathname + '?' + searchParams.toString() + ); + history.push(url); + }, + [history] + ); + const toFlowchartPage = useCallback(() => { - const url = generatePath(routes.flowchart.main); - history.push(url); - }, [history]); + updateURLWithSearchParams(retainDefaultQueryParams); + }, [updateURLWithSearchParams]); const toSelectedPipeline = useCallback( (pipelineValue) => { - // Get the value from param if it exists first - // before checking from localStorage - const activePipeline = pipelineValue - ? pipelineValue - : getCurrentActivePipeline(); - - const url = generatePath(routes.flowchart.selectedPipeline, { - pipelineId: activePipeline, + updateURLWithSearchParams((searchParams) => { + retainDefaultQueryParams(searchParams); + + // Get the value from param if it exists first + // before checking from localStorage + const activePipeline = pipelineValue + ? pipelineValue + : getCurrentActivePipeline(); + searchParams.set(params.pipeline, activePipeline); }); - - history.push(url); }, - [history] + [updateURLWithSearchParams] ); const toSelectedNode = useCallback( (item) => { - const activePipeline = getCurrentActivePipeline(); - - const url = generatePath(routes.flowchart.selectedNode, { - pipelineId: activePipeline, - id: item.id, + updateURLWithSearchParams((searchParams) => { + searchParams.set(params.selected, item.id); }); - history.push(url); }, - [history] + [updateURLWithSearchParams] ); const toFocusedModularPipeline = useCallback( (item) => { - const activePipeline = getCurrentActivePipeline(); + updateURLWithSearchParams((searchParams) => { + searchParams.set(params.focused, item.id); + }); + }, + [updateURLWithSearchParams] + ); - const url = generatePath(routes.flowchart.focusedNode, { - pipelineId: activePipeline, - id: item.id, + const toSetQueryParam = useCallback( + (param, value) => { + updateURLWithSearchParams((searchParams) => { + if (Array.isArray(value) && value.length === 0) { + searchParams.delete(param); + } else { + searchParams.set(param, value); + } }); - history.push(url); }, - [history] + [updateURLWithSearchParams] ); + const toUpdateUrlParamsOnResetFilter = useCallback(() => { + updateURLWithSearchParams((searchParams) => { + searchParams.delete(params.tags); + searchParams.set( + params.types, + `${NODE_TYPES.task.name},${NODE_TYPES.data.name}` + ); + }); + }, [updateURLWithSearchParams]); + + const toUpdateUrlParamsOnFilter = (item, paramName, existingValues) => { + const mapItemId = mapNodeType(item.id); + if (item.checked) { + existingValues.delete(mapItemId); + } else { + existingValues.add(mapItemId); + } + + toSetQueryParam(paramName, Array.from(existingValues)); + }; + return { toSelectedPipeline, toFlowchartPage, toSelectedNode, toFocusedModularPipeline, + toSetQueryParam, + toUpdateUrlParamsOnResetFilter, + toUpdateUrlParamsOnFilter, }; }; diff --git a/src/utils/index.js b/src/utils/index.js index 38cfade677..b8cd1888bf 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -228,3 +228,18 @@ export async function fetchPackageCompatibilities() { }); return request; } + +const nodeTypeMapObj = { + nodes: 'task', + task: 'nodes', + datasets: 'data', + data: 'datasets', +}; +/** + * Mapping task to node and vice versa to keep UI label & the URL consistent + */ +export const mapNodeType = (nodeType) => nodeTypeMapObj[nodeType] || nodeType; + +export const mapNodeTypes = (nodeTypes) => { + return nodeTypes.replace(/task|data/g, (matched) => mapNodeType(matched)); +}; diff --git a/src/utils/match-path.js b/src/utils/match-path.js index 0a7b6d2b8d..58a707437c 100644 --- a/src/utils/match-path.js +++ b/src/utils/match-path.js @@ -1,31 +1,27 @@ import { matchPath } from 'react-router-dom'; -import { routes } from '../config'; +import { params, routes } from '../config'; export const findMatchedPath = (pathname, search) => { const matchedFlowchartMainPage = matchPath(pathname + search, { - exact: true, + exact: false, path: [routes.flowchart.main], }); - const matchedSelectedPipeline = matchPath(pathname + search, { - exact: true, - path: [routes.flowchart.selectedPipeline], - }); - - const matchedSelectedNodeId = matchPath(pathname + search, { - exact: true, - path: [routes.flowchart.selectedNode], - }); + const isQueryParamExist = (queryParam, queryString) => { + const searchParams = new URLSearchParams(queryString); + return searchParams.has(queryParam); + }; - const matchedSelectedNodeName = matchPath(pathname + search, { - exact: true, - path: [routes.flowchart.selectedName], - }); + const hasQueryParam = (param) => { + const hasPipelineId = isQueryParamExist(params.pipeline, search); + const hasParam = isQueryParamExist(param, search); + return param ? hasPipelineId && hasParam : hasPipelineId; + }; - const matchedFocusedNode = matchPath(pathname + search, { - exact: true, - path: [routes.flowchart.focusedNode], - }); + const matchedSelectedPipeline = () => hasQueryParam(); + const matchedSelectedNodeId = () => hasQueryParam(params.selected); + const matchedSelectedNodeName = () => hasQueryParam(params.selectedName); + const matchedFocusedNode = () => hasQueryParam(params.focused); const matchedExperimentTrackingMainPage = matchPath(pathname + search, { exact: true, diff --git a/src/utils/object-utils.js b/src/utils/object-utils.js index a785c86802..8efdaab066 100644 --- a/src/utils/object-utils.js +++ b/src/utils/object-utils.js @@ -23,3 +23,15 @@ export const removeElementsFromObjectValues = (obj, itemsToRemove) => { return updatedObj; }; + +// Returns the key of the given value in the object. +export const getKeyByValue = (object, value) => { + return Object.keys(object).find((key) => object[key] === value); +}; + +// Returns an array of keys from the given object that have the specified value. +export const getKeysByValue = (object, value) => { + return Object.keys(object) + .filter((key) => object[key] === value) + .join(','); +}; diff --git a/src/utils/object-utils.test.js b/src/utils/object-utils.test.js index 075cf8b85f..00ad189980 100644 --- a/src/utils/object-utils.test.js +++ b/src/utils/object-utils.test.js @@ -1,6 +1,8 @@ import { removeChildFromObject, removeElementsFromObjectValues, + getKeyByValue, + getKeysByValue, } from './object-utils'; import { data } from '../components/experiment-tracking/mock-data'; @@ -48,3 +50,35 @@ test('return expected runs which should not have the first 3 values', () => { expect(received).toStrictEqual(expected); }); + +test('return the correct key for the value', () => { + const mockObject = { + key1: 'value1', + key2: 'value2', + key3: 'value3', + key4: 'value4', + }; + + const mockValue = 'value3'; + + const expected = 'key3'; + const result = getKeyByValue(mockObject, mockValue); + + expect(result).toEqual(expected); +}); + +test('return the correct keys for the value', () => { + const mockObject = { + key1: 'value1', + key2: 'value1', + key3: 'value2', + key4: 'value3', + }; + + const mockValue = 'value1'; + + const expected = 'key1,key2'; + const result = getKeysByValue(mockObject, mockValue); + + expect(result).toEqual(expected); +});