From 2c36a43d3efed3434a5ff4790955071b3fc0da3b Mon Sep 17 00:00:00 2001 From: Huong Nguyen <32060364+Huongg@users.noreply.github.com> Date: Tue, 29 Oct 2024 14:44:19 +0000 Subject: [PATCH 1/9] Refactor / node list row (#2143) * update classnames to match the component name Signed-off-by: Huong Nguyen * update names in tests Signed-off-by: Huong Nguyen * update the rest of the classnames Signed-off-by: Huong Nguyen * abstract node-list-row-toggle component Signed-off-by: Huong Nguyen * tidy up code for toggle component Signed-off-by: Huong Nguyen * update classnames in tests Signed-off-by: Huong Nguyen * simplify the css Signed-off-by: Huong Nguyen * add tests for node-list-row-toggle Signed-off-by: Huong Nguyen * remove handleToggle on VisibilityIcon Signed-off-by: Huong Nguyen * remove redux from node-list-row Signed-off-by: Huong Nguyen * split node-list-row into row and filter-row Signed-off-by: Huong Nguyen * rename toggle icon component Signed-off-by: Huong Nguyen * move row and filter-row to components level Signed-off-by: Huong Nguyen * move css to row and filterRow Signed-off-by: Huong Nguyen * remove node-list-row Signed-off-by: Huong Nguyen * separate the row-text component Signed-off-by: Huong Nguyen * include parent classname Signed-off-by: Huong Nguyen * update name for toggle-icon, to visibility-control Signed-off-by: Huong Nguyen * fix css and move nodeListRowHeight to config Signed-off-by: Huong Nguyen * adding test for new component Signed-off-by: Huong Nguyen * update classname for tests Signed-off-by: Huong Nguyen * move row inside node-list Signed-off-by: Huong Nguyen * connect redux store to component Signed-off-by: Huong Nguyen * fix styling Signed-off-by: Huong Nguyen * update name to ToggleControl Signed-off-by: Huong Nguyen * remove disable props as no longer needed Signed-off-by: Huong Nguyen * replace js code with css to simplify the code Signed-off-by: Huong Nguyen * update classnames in cypress test Signed-off-by: Huong Nguyen * Styling for hovering and focus mode Signed-off-by: Huong Nguyen * fixing small styling Signed-off-by: Huong Nguyen * fix the disable styling for row Signed-off-by: Huong Nguyen * fix the disable styling on focus mode Signed-off-by: Huong Nguyen * remove one of the old test Signed-off-by: Huong Nguyen * update name for icons for FilterRow Signed-off-by: Huong Nguyen * fixing the icon highlighting issue Signed-off-by: Huong Nguyen * remove un-used li element Signed-off-by: Huong Nguyen * remove styling for pipeline-nodelist__placeholder-upper and lower class as nolonger used Signed-off-by: Huong Nguyen * update test in node-list Signed-off-by: Huong Nguyen * update cypress tests Signed-off-by: Huong Nguyen * moving .pipeline-nodelist__group--all-unchecked to the parent Signed-off-by: Huong Nguyen * prevent page reload on form submission Signed-off-by: Huong Nguyen * remove wrong classname in the test Signed-off-by: Huong Nguyen * remove unique ID Signed-off-by: Huong Nguyen * apply hovering styling on the parent instead of row Signed-off-by: Huong Nguyen * styling for selected element Signed-off-by: Huong Nguyen * fixing hover styling on the icon from MUI Signed-off-by: Huong Nguyen --------- Signed-off-by: Huong Nguyen Co-authored-by: Huong Nguyen --- cypress/tests/ui/flowchart/flowchart.cy.js | 2 +- cypress/tests/ui/flowchart/menu.cy.js | 34 +-- cypress/tests/ui/toolbar/global-toolbar.cy.js | 8 +- src/components/filter-row/filter-row.js | 66 +++++ src/components/filter-row/filter-row.scss | 54 ++++ src/components/filter-row/filter-row.test.js | 24 ++ .../node-list/components/row/row.js | 113 ++++++++ .../node-list/components/row/row.scss | 83 ++++++ .../node-list/components/row/row.test.js | 66 +++++ src/components/node-list/index.js | 16 +- src/components/node-list/node-list-group.js | 13 +- .../node-list/node-list-group.test.js | 12 - .../node-list/node-list-row-list.js | 64 ++--- src/components/node-list/node-list-row.js | 255 ------------------ .../node-list/node-list-row.test.js | 191 ------------- .../node-list/node-list-tree-item.js | 49 ++-- src/components/node-list/node-list-tree.js | 3 + src/components/node-list/node-list.js | 2 + src/components/node-list/node-list.test.js | 107 +++----- src/components/node-list/styles/_group.scss | 55 +--- .../node-list/styles/_row-label.scss | 102 ------- src/components/node-list/styles/_row.scss | 116 -------- .../node-list/styles/node-list.scss | 62 ++++- src/components/ui/row-text/row-text.js | 51 ++++ src/components/ui/row-text/row-text.scss | 62 +++++ .../ui/toggle-control/toggle-control.js | 75 ++++++ .../toggle-control/toggle-control.scss} | 111 +++----- .../ui/toggle-control/toggle-control.test.js | 58 ++++ src/config.js | 3 + tools/test-lib/react-app/app.test.js | 5 +- 30 files changed, 889 insertions(+), 973 deletions(-) create mode 100755 src/components/filter-row/filter-row.js create mode 100644 src/components/filter-row/filter-row.scss create mode 100644 src/components/filter-row/filter-row.test.js create mode 100755 src/components/node-list/components/row/row.js create mode 100755 src/components/node-list/components/row/row.scss create mode 100644 src/components/node-list/components/row/row.test.js delete mode 100644 src/components/node-list/node-list-row.js delete mode 100644 src/components/node-list/node-list-row.test.js delete mode 100644 src/components/node-list/styles/_row-label.scss delete mode 100644 src/components/node-list/styles/_row.scss create mode 100644 src/components/ui/row-text/row-text.js create mode 100644 src/components/ui/row-text/row-text.scss create mode 100755 src/components/ui/toggle-control/toggle-control.js rename src/components/{node-list/styles/_row-toggle.scss => ui/toggle-control/toggle-control.scss} (62%) create mode 100644 src/components/ui/toggle-control/toggle-control.test.js diff --git a/cypress/tests/ui/flowchart/flowchart.cy.js b/cypress/tests/ui/flowchart/flowchart.cy.js index a2eee6fbde..f3f241dcb3 100644 --- a/cypress/tests/ui/flowchart/flowchart.cy.js +++ b/cypress/tests/ui/flowchart/flowchart.cy.js @@ -70,7 +70,7 @@ describe('Flowchart DAG', () => { const nodeToToggleText = 'Parameters'; // Alias - cy.get(`.pipeline-nodelist__row__checkbox[name=${nodeToToggleText}]`).as( + cy.get(`.toggle-control__checkbox[name=${nodeToToggleText}]`).as( 'nodeToToggle' ); diff --git a/cypress/tests/ui/flowchart/menu.cy.js b/cypress/tests/ui/flowchart/menu.cy.js index deb6d38f81..5571528339 100644 --- a/cypress/tests/ui/flowchart/menu.cy.js +++ b/cypress/tests/ui/flowchart/menu.cy.js @@ -41,7 +41,7 @@ describe('Flowchart Menu', () => { }); // Pipeline Label in the Menu - cy.get('.pipeline-nodelist__row__label') + cy.get('.row-text__label') .first() .invoke('text') .should((pipelineLabel) => { @@ -57,7 +57,7 @@ describe('Flowchart Menu', () => { cy.get('.search-input__field').type(searchInput, { force: true }); // Pipeline Label in the Menu - cy.get('.pipeline-nodelist__row__label') + cy.get('.row-text__label') .first() .invoke('text') .should((pipelineLabel) => { @@ -72,7 +72,7 @@ describe('Flowchart Menu', () => { // Action cy.get( - `.MuiTreeItem-label > .pipeline-nodelist__row > [data-test=nodelist-data-${nodeToClickText}]` + `.MuiTreeItem-label > .node-list-tree-item-row > [data-test=node-list-tree-item--row--${nodeToClickText}]` ) .should('exist') .as('nodeToClick'); @@ -91,7 +91,7 @@ describe('Flowchart Menu', () => { // Action cy.get( - `.MuiTreeItem-label > .pipeline-nodelist__row > [data-test=nodelist-data-${nodeToHighlightText}]` + `.MuiTreeItem-label > .node-list-tree-item-row > [data-test=node-list-tree-item--row--${nodeToHighlightText}]` ) .should('exist') .as('nodeToHighlight'); @@ -108,7 +108,7 @@ describe('Flowchart Menu', () => { const nodeToToggleText = 'Companies'; // Alias - cy.get(`.pipeline-nodelist__row__checkbox[name=${nodeToToggleText}]`, { + cy.get(`.toggle-control__checkbox[name=${nodeToToggleText}]`, { timeout: 5000, }).as('nodeToToggle'); @@ -121,7 +121,7 @@ describe('Flowchart Menu', () => { // Assert after action cy.__checkForText__( - `[data-test=nodelist-data-${nodeToToggleText}] > .pipeline-nodelist__row__label--faded`, + `[data-test=node-list-tree-item--row--${nodeToToggleText}] > .row-text__label--faded`, nodeToToggleText ); cy.get('.pipeline-node__text').should('not.contain', nodeToToggleText); @@ -137,7 +137,7 @@ describe('Flowchart Menu', () => { // Action cy.get( - `[for=${nodeToFocusText}-focus] > .pipeline-nodelist__row__icon` + `[for=feature_engineering-focus]` ).click(); // Assert after action @@ -161,34 +161,34 @@ describe('Flowchart Menu', () => { const visibleRowLabel = 'Companies'; // Alias - cy.get(`.pipeline-nodelist__row__checkbox[name=${nodeToToggleText}]`).as( + cy.get(`.toggle-control__checkbox[name=${nodeToToggleText}]`).as( 'nodeToToggle' ); // Assert before action cy.get('@nodeToToggle').should('be.checked'); cy.get( - `[data-test=nodelist-data-${visibleRowLabel}] > .pipeline-nodelist__row__label` + `[data-test=node-list-tree-item--row--${visibleRowLabel}] > .row-text__label` ) - .should('not.have.class', 'pipeline-nodelist__row__label--faded') - .should('not.have.class', 'pipeline-nodelist__row__label--disabled'); + .should('not.have.class', 'row-text__label--faded') + .should('not.have.class', 'row-text__label--disabled'); // Action cy.get('@nodeToToggle').uncheck({ force: true }); // Assert after action cy.get( - `[data-test=nodelist-data-${visibleRowLabel}] > .pipeline-nodelist__row__label` + `[data-test=node-list-tree-item--row--${visibleRowLabel}] > .row-text__label` ) - .should('have.class', 'pipeline-nodelist__row__label--faded') - .should('have.class', 'pipeline-nodelist__row__label--disabled'); + .should('have.class', 'row-text__label--faded') + .should('have.class', 'row-text__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( + cy.get(`.toggle-control__checkbox[name=${nodeToToggleText}]`).as( 'nodeToToggle' ); @@ -207,7 +207,7 @@ describe('Flowchart Menu', () => { cy.visit(`/?tags=${visibleRowLabel}`); // Alias - cy.get(`.pipeline-nodelist__row__checkbox[name=${visibleRowLabel}]`).as( + cy.get(`.toggle-control__checkbox[name=${visibleRowLabel}]`).as( 'nodeToToggle' ); @@ -220,7 +220,7 @@ describe('Flowchart Menu', () => { cy.visit('/?types=datasets'); // Alias - cy.get(`.pipeline-nodelist__row__checkbox[name=${visibleRowLabel}]`).as( + cy.get(`.toggle-control__checkbox[name=${visibleRowLabel}]`).as( 'nodeToToggle' ); diff --git a/cypress/tests/ui/toolbar/global-toolbar.cy.js b/cypress/tests/ui/toolbar/global-toolbar.cy.js index a8f6434968..64971aa1d7 100644 --- a/cypress/tests/ui/toolbar/global-toolbar.cy.js +++ b/cypress/tests/ui/toolbar/global-toolbar.cy.js @@ -81,14 +81,14 @@ describe('Global Toolbar', () => { cy.get('@isPrettyNameCheckbox').should('be.checked'); // Menu - cy.get(`[data-test="nodelist-modularPipeline-${prettifyName(modularPipelineText)}"]`).click(); - cy.get(`[data-test="nodelist-${nodeNameType}-${prettyNodeNameText}"]`).should('exist'); + cy.get(`[data-test="node-list-tree-item--row--${prettifyName(modularPipelineText)}"]`).click(); + cy.get(`[data-test="node-list-tree-item--row--${prettyNodeNameText}"]`).should('exist'); // Flowchart cy.get('.pipeline-node__text').should('contain', prettyNodeNameText); // Metadata - cy.get(`[data-test="nodelist-${nodeNameType}-${prettyNodeNameText}"]`).click({ force: true }); + cy.get(`[data-test="node-list-tree-item--row--${prettyNodeNameText}"]`).click({ force: true }); cy.get('.pipeline-metadata__title').should( 'have.text', prettyNodeNameText @@ -106,7 +106,7 @@ describe('Global Toolbar', () => { // Assert after action cy.__waitForPageLoad__(() => { // Menu - cy.get(`[data-test="nodelist-${nodeNameType}-${originalNodeNameText}"]`).should('exist'); + cy.get(`[data-test="node-list-tree-item--row--${originalNodeNameText}"]`).should('exist'); // Flowchart cy.get('.pipeline-node__text').should('contain', originalNodeNameText); diff --git a/src/components/filter-row/filter-row.js b/src/components/filter-row/filter-row.js new file mode 100755 index 0000000000..baa8cc772c --- /dev/null +++ b/src/components/filter-row/filter-row.js @@ -0,0 +1,66 @@ +import React from 'react'; +import classnames from 'classnames'; +import IndicatorIcon from '../icons/indicator'; +import OffIndicatorIcon from '../icons/indicator-off'; +import { ToggleControl } from '../ui/toggle-control/toggle-control'; +import { RowText } from '../ui/row-text/row-text'; + +import './filter-row.scss'; + +export const FilterRow = ({ + allUnchecked, + checked, + children, + container: ContainerWrapper, + count, + dataTest, + id, + indicatorIcon = IndicatorIcon, + kind, + label, + name, + offIndicatorIcon = OffIndicatorIcon, + onChange, + onClick, + parentClassName, + visible, +}) => { + const Icon = checked ? indicatorIcon : offIndicatorIcon; + + return ( + + + + {count} + + + {children} + + ); +}; diff --git a/src/components/filter-row/filter-row.scss b/src/components/filter-row/filter-row.scss new file mode 100644 index 0000000000..ff23a5d05e --- /dev/null +++ b/src/components/filter-row/filter-row.scss @@ -0,0 +1,54 @@ +@use '../../styles/variables' as var; +@use '../node-list/styles/variables'; + +.MuiTreeItem-iconContainer svg { + z-index: var.$zindex-MuiTreeItem-icon; +} + +.filter-row { + align-items: center; + background-color: initial; + cursor: default; + display: flex; + height: 32px; + position: relative; + + &--kind-filter { + padding: 0 variables.$row-offset-right 0 variables.$row-offset-left; + } + + &--visible:hover { + background-color: var(--color-nodelist-row-active); + } +} + +.filter-row__count { + display: inline-block; + flex-shrink: 0; + width: 2.2em; + margin: 0 0.7em 0.1em auto; + overflow: hidden; + font-size: 1.16em; + text-align: right; + text-overflow: ellipsis; + opacity: 0.75; + user-select: none; + + .filter-row--unchecked & { + opacity: 0.55; + } +} + +.filter-row--unchecked { + // Fade row text when unchecked + .row-text__label--kind-filter { + opacity: 0.55; + } + + // Brighter row text when unchecked and hovered + &:hover { + .row-text__label--kind-filter { + opacity: 0.8; + } + } +} diff --git a/src/components/filter-row/filter-row.test.js b/src/components/filter-row/filter-row.test.js new file mode 100644 index 0000000000..3e1a31a203 --- /dev/null +++ b/src/components/filter-row/filter-row.test.js @@ -0,0 +1,24 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { FilterRow } from './filter-row'; + +describe('FilterRow Component', () => { + it('renders without crashing', () => { + const wrapper = mount(); + expect(wrapper.exists()).toBe(true); + }); + + it('renders correct visible classnames', () => { + const wrapper = mount(); + expect(wrapper.find('.filter-row').hasClass('filter-row--visible')).toBe( + true + ); + }); + + it('renders correct unchecked classnames', () => { + const wrapper = mount(); + expect(wrapper.find('.filter-row').hasClass('filter-row--unchecked')).toBe( + true + ); + }); +}); diff --git a/src/components/node-list/components/row/row.js b/src/components/node-list/components/row/row.js new file mode 100755 index 0000000000..416bcb4947 --- /dev/null +++ b/src/components/node-list/components/row/row.js @@ -0,0 +1,113 @@ +import React from 'react'; +import classnames from 'classnames'; +import NodeIcon from '../../../icons/node-icon'; +import VisibleIcon from '../../../icons/visible'; +import InvisibleIcon from '../../../icons/invisible'; +import FocusModeIcon from '../../../icons/focus-mode'; +import { ToggleControl } from '../../../ui/toggle-control/toggle-control'; +import { RowText } from '../../../ui/row-text/row-text'; + +import './row.scss'; + +const Row = ({ + active, + checked, + children, + dataTest, + disabled, + faded, + focused, + focusModeIcon = FocusModeIcon, + highlight, + icon, + id, + invisibleIcon = InvisibleIcon, + isSlicingPipelineApplied, + kind, + label, + name, + onChange, + onClick, + onMouseEnter, + onMouseLeave, + onToggleHoveredFocusMode, + parentClassName, + rowType, + selected, + type, + visibleIcon = VisibleIcon, +}) => { + const isModularPipeline = type === 'modularPipeline'; + const FocusIcon = isModularPipeline ? focusModeIcon : null; + const isChecked = isModularPipeline ? checked || focused : checked; + const VisibilityIcon = isChecked ? visibleIcon : invisibleIcon; + + return ( +
+ + + {VisibilityIcon && ( + + )} + {FocusIcon && ( + + )} + + ); +}; + +export default Row; diff --git a/src/components/node-list/components/row/row.scss b/src/components/node-list/components/row/row.scss new file mode 100755 index 0000000000..99606506ca --- /dev/null +++ b/src/components/node-list/components/row/row.scss @@ -0,0 +1,83 @@ +@use '../../../../styles/variables' as var; +@use '../../styles/variables'; + +.MuiTreeItem-iconContainer svg { + z-index: var.$zindex-MuiTreeItem-icon; +} + +.row { + align-items: center; + cursor: default; + display: flex; + height: 32px; + position: relative; + transform: translate(0, 0); + + &:hover, + &--selected { + // Additional selector required to increase specificity to override previous rule + background-color: var(--color-nodelist-row-selected); + border-right: 1px solid var.$blue-300; + } + + // to ensure the background of the row covers the full width on hover + &::before { + position: absolute; + top: 0; + bottom: 0; + left: -100px; + width: 100px; + background: var(--color-nodelist-row-selected); + transform: translate(0, 0); + opacity: 0; + content: ' '; + pointer-events: none; + } +} + +.MuiTreeItem-content:hover { + .row__type-icon path { + opacity: 1; + } +} + +.row--active::before, +.row--selected::before, +.row:hover::before { + opacity: 1; +} + +.row__icon { + display: block; + flex-shrink: 0; + width: variables.$row-icon-size; + height: variables.$row-icon-size; + fill: var(--color-text); + + &--disabled > * { + opacity: 0.1; + } +} + +.row__type-icon { + &--nested > * { + opacity: 0.3; + } + + &--faded > * { + opacity: 0.2; + } + + &--active, + &--selected, + .row--visible:hover &, + [data-whatintent='keyboard'] .row__text:focus & { + > * { + opacity: 1; + } + + &--faded > * { + opacity: 0.55; + } + } +} diff --git a/src/components/node-list/components/row/row.test.js b/src/components/node-list/components/row/row.test.js new file mode 100644 index 0000000000..42294ab8dd --- /dev/null +++ b/src/components/node-list/components/row/row.test.js @@ -0,0 +1,66 @@ +import React from 'react'; +import Row from './row'; +import { setup } from '../../../../utils/state.mock'; + +// Mock props +const mockProps = { + name: 'Test Row', + kind: 'modular-pipeline', + active: false, + disabled: false, + selected: false, + visible: true, + onMouseEnter: jest.fn(), + onMouseLeave: jest.fn(), + onClick: jest.fn(), + icon: null, + type: 'modularPipeline', + checked: true, + focused: false, +}; + +describe('Row Component', () => { + it('renders without crashing', () => { + expect(() => setup.mount()).not.toThrow(); + }); + + it('handles mouseenter events', () => { + const wrapper = setup.mount(); + const nodeRow = () => wrapper.find('.row'); + nodeRow().simulate('mouseenter'); + expect(mockProps.onMouseEnter.mock.calls.length).toEqual(1); + }); + + it('handles mouseleave events', () => { + const wrapper = setup.mount(); + const nodeRow = () => wrapper.find('.row'); + nodeRow().simulate('mouseleave'); + expect(mockProps.onMouseLeave.mock.calls.length).toEqual(1); + }); + + it('applies the row--active class when active is true', () => { + const wrapper = setup.mount(); + expect(wrapper.find('.row').hasClass('row--active')).toBe(true); + }); + + it('applies the row--selected class when selected is true', () => { + const wrapper = setup.mount(); + expect(wrapper.find('.row').hasClass('row--selected')).toBe(true); + }); + + it('applies the row--selected class when highlight is true and isSlicingPipelineApplied is false', () => { + const wrapper = setup.mount( + + ); + expect(wrapper.find('.row').hasClass('row--selected')).toBe(true); + }); + + it('applies the overwrite class if not selected or active', () => { + const activeNodeWrapper = setup.mount( + + ); + expect(activeNodeWrapper.find('.row').hasClass('row--overwrite')).toBe( + true + ); + }); +}); diff --git a/src/components/node-list/index.js b/src/components/node-list/index.js index 74353d8944..da2ea0984a 100644 --- a/src/components/node-list/index.js +++ b/src/components/node-list/index.js @@ -26,7 +26,11 @@ import { } from '../../selectors/nodes'; import { toggleTagActive, toggleTagFilter } from '../../actions/tags'; import { toggleTypeDisabled } from '../../actions/node-type'; -import { toggleParametersHovered, toggleFocusMode } from '../../actions'; +import { + toggleParametersHovered, + toggleFocusMode, + toggleHoveredFocusMode, +} from '../../actions'; import { toggleModularPipelineActive, toggleModularPipelineDisabled, @@ -64,6 +68,7 @@ const NodeListProvider = ({ onToggleModularPipelineExpanded, onToggleTypeDisabled, onToggleFocusMode, + onToggleHoveredFocusMode, modularPipelinesTree, focusMode, disabledModularPipeline, @@ -100,7 +105,7 @@ const NodeListProvider = ({ const groups = getGroups({ items }); - const onItemClick = (item) => { + const onItemClick = (event, item) => { if (isGroupType(item.type)) { onGroupItemChange(item, item.checked); } else if (isModularPipelineType(item.type)) { @@ -118,6 +123,9 @@ const NodeListProvider = ({ } } } + + // to prevent page reload on form submission + event.preventDefault(); }; // To get existing values from URL query parameters @@ -315,6 +323,7 @@ const NodeListProvider = ({ onItemClick={onItemClick} onItemMouseEnter={onItemMouseEnter} onItemMouseLeave={onItemMouseLeave} + onToggleHoveredFocusMode={onToggleHoveredFocusMode} onItemChange={onItemChange} focusMode={focusMode} disabledModularPipeline={disabledModularPipeline} @@ -371,6 +380,9 @@ export const mapDispatchToProps = (dispatch) => ({ onToggleFocusMode: (modularPipeline) => { dispatch(toggleFocusMode(modularPipeline)); }, + onToggleHoveredFocusMode: (active) => { + dispatch(toggleHoveredFocusMode(active)); + }, onResetSlicePipeline: () => { dispatch(resetSlicePipeline()); }, diff --git a/src/components/node-list/node-list-group.js b/src/components/node-list/node-list-group.js index 9b54a2d72b..4d68df9b19 100644 --- a/src/components/node-list/node-list-group.js +++ b/src/components/node-list/node-list-group.js @@ -1,6 +1,6 @@ import React from 'react'; import classnames from 'classnames'; -import NodeListRow from './node-list-row'; +import { FilterRow } from '../filter-row/filter-row'; import NodeRowList from './node-list-row-list'; export const NodeListGroup = ({ @@ -35,12 +35,12 @@ export const NodeListGroup = ({ )} >

-

{ false ); }); - - it('adds disabled class when items list is empty', () => { - const type = getNodeTypes(mockState.spaceflights)[0]; - const wrapper = setup.mount( - - ); - expect(items.length).toBe(0); - const button = () => wrapper.find('button'); - expect(button().hasClass('pipeline-type-group-toggle--disabled')).toBe( - true - ); - }); }); diff --git a/src/components/node-list/node-list-row-list.js b/src/components/node-list/node-list-row-list.js index 4566fbaafc..fac2b346e6 100644 --- a/src/components/node-list/node-list-row-list.js +++ b/src/components/node-list/node-list-row-list.js @@ -1,7 +1,9 @@ import React from 'react'; import modifiers from '../../utils/modifiers'; -import NodeListRow, { nodeListRowHeight } from './node-list-row'; +import { FilterRow } from '../filter-row/filter-row'; +import { nodeListRowHeight } from '../../config'; import LazyList from '../lazy-list'; +import { getDataTestAttribute } from '../../utils/get-data-test-attribute'; const NodeRowList = ({ items = [], @@ -9,24 +11,12 @@ const NodeRowList = ({ collapsed, onItemClick, onItemChange, - onItemMouseEnter, - onItemMouseLeave, }) => ( (end - start) * nodeListRowHeight} total={items.length} > - {({ - start, - end, - total, - listRef, - upperRef, - lowerRef, - listStyle, - upperStyle, - lowerStyle, - }) => ( + {({ start, end, listRef, listStyle }) => (
    -
  • 0, - })} - ref={upperRef} - style={upperStyle} - /> -
  • {items.slice(start, end).map((item) => ( - onItemClick(item)} - onMouseEnter={() => onItemMouseEnter(item)} - onMouseLeave={() => onItemMouseLeave(item)} onChange={(e) => onItemChange(item, !e.target.checked)} - rowType="filter" + onClick={() => onItemClick(item)} + parentClassName={'node-list-filter-row'} + visible={item.visible} + indicatorIcon={item.visibleIcon} /> ))}
diff --git a/src/components/node-list/node-list-row.js b/src/components/node-list/node-list-row.js deleted file mode 100644 index fdabf9d584..0000000000 --- a/src/components/node-list/node-list-row.js +++ /dev/null @@ -1,255 +0,0 @@ -import React, { memo } from 'react'; -import { connect } from 'react-redux'; -import classnames from 'classnames'; -import { changed, replaceAngleBracketMatches } from '../../utils'; -import NodeIcon from '../icons/node-icon'; -import VisibleIcon from '../icons/visible'; -import InvisibleIcon from '../icons/invisible'; -import FocusModeIcon from '../icons/focus-mode'; -import { getNodeActive } from '../../selectors/nodes'; -import { toggleHoveredFocusMode } from '../../actions'; - -// The exact fixed height of a row as measured by getBoundingClientRect() -export const nodeListRowHeight = 32; - -/** - * Returns `true` if there are no props changes, therefore the last render can be reused. - * Performance: Checks only the minimal set of props known to change after first render. - */ -const shouldMemo = (prevProps, nextProps) => - !changed( - [ - 'active', - 'checked', - 'allUnchecked', - 'disabled', - 'faded', - 'focused', - 'visible', - 'selected', - 'highlight', - 'label', - 'children', - 'count', - ], - prevProps, - nextProps - ); - -const NodeListRow = memo( - ({ - container: Container = 'div', - active, - checked, - allUnchecked, - children, - disabled, - faded, - focused, - visible, - id, - label, - count, - name, - kind, - onMouseEnter, - onMouseLeave, - onChange, - onClick, - selected, - highlight, - isSlicingPipelineApplied, - type, - icon, - visibleIcon = VisibleIcon, - invisibleIcon = InvisibleIcon, - focusModeIcon = FocusModeIcon, - rowType, - onToggleHoveredFocusMode, - }) => { - const isModularPipeline = type === 'modularPipeline'; - const FocusIcon = isModularPipeline ? focusModeIcon : null; - const isChecked = isModularPipeline ? checked || focused : checked; - const VisibilityIcon = isChecked ? visibleIcon : invisibleIcon; - const isButton = onClick && kind !== 'filter'; - const TextButton = isButton ? 'button' : 'div'; - - return ( - - {icon && ( - - )} - - - - {typeof count === 'number' && ( - - {count} - - )} - {VisibilityIcon && ( - - )} - {FocusIcon && ( - - )} - {children} - - ); - }, - shouldMemo -); - -export const mapDispatchToProps = (dispatch) => ({ - onToggleHoveredFocusMode: (active) => { - dispatch(toggleHoveredFocusMode(active)); - }, -}); - -export const mapStateToProps = (state, ownProps) => ({ - ...ownProps, - active: - typeof ownProps.active !== 'undefined' - ? ownProps.active - : getNodeActive(state)[ownProps.id] || false, -}); - -export default connect(mapStateToProps, mapDispatchToProps)(NodeListRow); diff --git a/src/components/node-list/node-list-row.test.js b/src/components/node-list/node-list-row.test.js deleted file mode 100644 index f4651e200f..0000000000 --- a/src/components/node-list/node-list-row.test.js +++ /dev/null @@ -1,191 +0,0 @@ -import React from 'react'; -import NodeListRow, { mapStateToProps } from './node-list-row'; -import { getNodeData } from '../../selectors/nodes'; -import { setup, mockState } from '../../utils/state.mock'; - -describe('NodeListRow', () => { - const node = getNodeData(mockState.spaceflights)[0]; - const setupProps = () => { - const props = { - active: true, - checked: true, - disabled: false, - faded: false, - visible: true, - id: node.id, - label: node.highlightedLabel, - name: node.name, - onClick: jest.fn(), - onMouseEnter: jest.fn(), - onMouseLeave: jest.fn(), - onChange: jest.fn(), - }; - return { props }; - }; - - it('renders without throwing', () => { - expect(() => setup.mount()).not.toThrow(); - }); - - describe('node list item', () => { - it('handles mouseenter events', () => { - const { props } = setupProps(); - const wrapper = setup.mount(); - const nodeRow = () => wrapper.find('.pipeline-nodelist__row'); - nodeRow().simulate('mouseenter'); - expect(props.onMouseEnter.mock.calls.length).toEqual(1); - }); - - it('handles mouseleave events', () => { - const { props } = setupProps(); - const wrapper = setup.mount(); - const nodeRow = () => wrapper.find('.pipeline-nodelist__row'); - nodeRow().simulate('mouseleave'); - expect(props.onMouseLeave.mock.calls.length).toEqual(1); - }); - - it('applies the overwrite class if not active', () => { - const { props } = setupProps(); - const activeNodeWrapper = setup.mount( - - ); - expect( - activeNodeWrapper - .find('.pipeline-nodelist__row') - .hasClass('pipeline-nodelist__row--overwrite') - ).toBe(true); - }); - - it('applies the overwrite class if not selected or active', () => { - const { props } = setupProps(); - const activeNodeWrapper = setup.mount( - - ); - expect( - activeNodeWrapper - .find('.pipeline-nodelist__row') - .hasClass('pipeline-nodelist__row--overwrite') - ).toBe(true); - }); - - it('does not applies the overwrite class if not selected', () => { - const { props } = setupProps(); - const activeNodeWrapper = setup.mount( - - ); - expect( - activeNodeWrapper - .find('.pipeline-nodelist__row') - .hasClass('pipeline-nodelist__row--overwrite') - ).toBe(false); - }); - - it('does not applies the overwrite class if active', () => { - const { props } = setupProps(); - const activeNodeWrapper = setup.mount( - - ); - expect( - activeNodeWrapper - .find('.pipeline-nodelist__row') - .hasClass('pipeline-nodelist__row--overwrite') - ).toBe(false); - }); - - it('uses active class if active', () => { - const { props } = setupProps(); - const activeNodeWrapper = setup.mount( - - ); - expect( - activeNodeWrapper - .find('.pipeline-nodelist__row') - .hasClass('pipeline-nodelist__row--active') - ).toBe(true); - }); - - it('uses disabled class if disabled (via type/tag only)', () => { - const { props } = setupProps(); - const disabledNodeWrapper = setup.mount( - - ); - expect( - disabledNodeWrapper - .find('.pipeline-nodelist__row') - .hasClass('pipeline-nodelist__row--disabled') - ).toBe(true); - }); - - it('shows count if count prop set', () => { - const { props } = setupProps(); - const mockCount = 123; - const wrapper = setup.mount(); - expect(wrapper.find('.pipeline-nodelist__row__count').text()).toBe( - mockCount.toString() - ); - }); - - it('does not show count if count prop not set', () => { - const { props } = setupProps(); - const wrapper = setup.mount(); - expect(wrapper.find('.pipeline-nodelist__row__count').exists()).toBe( - false - ); - }); - - describe('focus mode', () => { - it('sets the focus toggle to the checked mode when the row is selected for focus mode', () => { - const { props } = setupProps(); - const wrapper = setup.mount( - - ); - - expect( - wrapper.find('.pipeline-row__toggle-icon--focus-checked').exists() - ).toBe(true); - }); - - it('hides the visibility toggle when the row is selected for focus mode', () => { - const { props } = setupProps(); - const wrapper = setup.mount( - - ); - - expect(wrapper.find('.pipeline-row__toggle--disabled').exists()).toBe( - true - ); - }); - - it('switches the visibility toggle from hide to show when the row is selected for focus mode', () => { - const { props } = setupProps(); - const wrapper = setup.mount( - - ); - expect(wrapper.find('VisibleIcon')).toHaveLength(1); - }); - }); - }); - - describe('node list item checkbox', () => { - const { props } = setupProps(); - const wrapper = setup.mount(); - const checkbox = () => wrapper.find('input'); - - it('handles toggle event', () => { - checkbox().simulate('change', { target: { checked: false } }); - expect(props.onChange.mock.calls.length).toEqual(1); - }); - }); - - it('maps state to props', () => { - const expectedResult = expect.objectContaining({ - active: expect.any(Boolean), - }); - expect(mapStateToProps(mockState.spaceflights, {})).toEqual(expectedResult); - }); -}); diff --git a/src/components/node-list/node-list-tree-item.js b/src/components/node-list/node-list-tree-item.js index 5a08c0ca25..81c5cebfa3 100644 --- a/src/components/node-list/node-list-tree-item.js +++ b/src/components/node-list/node-list-tree-item.js @@ -1,8 +1,10 @@ import React from 'react'; +import classnames from 'classnames'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import { TreeItem } from '@mui/x-tree-view'; -import NodeListRow from './node-list-row'; +import Row from './components/row/row'; +import { getDataTestAttribute } from '../../utils/get-data-test-attribute'; const arrowIconColor = '#8e8e90'; @@ -12,46 +14,51 @@ const NodeListTreeItem = ({ onItemMouseEnter, onItemMouseLeave, onItemChange, + onToggleHoveredFocusMode, children, isSlicingPipelineApplied, }) => ( } expandIcon={} label={ - onItemClick(data)} - onMouseEnter={() => onItemMouseEnter(data)} - onMouseLeave={() => onItemMouseLeave(data)} + isSlicingPipelineApplied={isSlicingPipelineApplied} + key={data.id} + kind="element" + label={data.highlightedLabel || data.name} + name={data.name} onChange={(e) => onItemChange(data, !e.target.checked, e.target.dataset.iconType) } + onClick={(e) => onItemClick(e, data)} + onMouseEnter={() => onItemMouseEnter(data)} + onMouseLeave={() => onItemMouseLeave(data)} + onToggleHoveredFocusMode={onToggleHoveredFocusMode} + parentClassName={'node-list-tree-item-row'} rowType="tree" - focused={data.focused} + selected={data.selected} + type={data.type} + visible={data.visible} + visibleIcon={data.visibleIcon} /> } > diff --git a/src/components/node-list/node-list-tree.js b/src/components/node-list/node-list-tree.js index fa89c3fec8..df9dd33a68 100644 --- a/src/components/node-list/node-list-tree.js +++ b/src/components/node-list/node-list-tree.js @@ -117,6 +117,7 @@ const TreeListProvider = ({ onItemChange, onItemMouseEnter, onItemMouseLeave, + onToggleHoveredFocusMode, onItemClick, onNodeToggleExpanded, focusMode, @@ -161,6 +162,7 @@ const TreeListProvider = ({ onItemMouseEnter={onItemMouseEnter} onItemMouseLeave={onItemMouseLeave} onItemChange={onItemChange} + onToggleHoveredFocusMode={onToggleHoveredFocusMode} onItemClick={onItemClick} key={uniqueId(node.id)} isSlicingPipelineApplied={isSlicingPipelineApplied} @@ -231,6 +233,7 @@ const TreeListProvider = ({ onItemMouseEnter={onItemMouseEnter} onItemMouseLeave={onItemMouseLeave} onItemChange={onItemChange} + onToggleHoveredFocusMode={onToggleHoveredFocusMode} onItemClick={onItemClick} key={uniqueId(node.id)} isSlicingPipelineApplied={isSlicingPipelineApplied} diff --git a/src/components/node-list/node-list.js b/src/components/node-list/node-list.js index 0106c0594c..08f415b4fb 100644 --- a/src/components/node-list/node-list.js +++ b/src/components/node-list/node-list.js @@ -24,6 +24,7 @@ const NodeList = ({ onItemClick, onItemMouseEnter, onItemMouseLeave, + onToggleHoveredFocusMode, onItemChange, onModularPipelineToggleExpanded, focusMode, @@ -65,6 +66,7 @@ const NodeList = ({ onItemClick={onItemClick} onItemMouseEnter={onItemMouseEnter} onItemMouseLeave={onItemMouseLeave} + onToggleHoveredFocusMode={onToggleHoveredFocusMode} onItemChange={onItemChange} onNodeToggleExpanded={onModularPipelineToggleExpanded} focusMode={focusMode} diff --git a/src/components/node-list/node-list.test.js b/src/components/node-list/node-list.test.js index edceb82879..83305c5af9 100644 --- a/src/components/node-list/node-list.test.js +++ b/src/components/node-list/node-list.test.js @@ -59,7 +59,7 @@ describe('NodeList', () => { const search = () => wrapper.find('.search-input__field'); search().simulate('change', { target: { value: searchText } }); const nodeList = wrapper.find( - '.pipeline-nodelist__elements-panel .pipeline-nodelist__row' + '.pipeline-nodelist__elements-panel .node-list-tree-item-row' ); const nodes = getNodeData(mockState.spaceflights); const tags = getTagData(mockState.spaceflights); @@ -102,7 +102,7 @@ describe('NodeList', () => { const search = () => wrapper.find('.search-input__field'); const nodeList = () => wrapper.find( - '.pipeline-nodelist__elements-panel .pipeline-nodelist__row' + '.pipeline-nodelist__elements-panel .node-list-tree-item-row' ); const nodes = getNodeData(mockState.spaceflights); @@ -149,7 +149,7 @@ describe('NodeList', () => { const search = () => wrapper.find('.search-input__field'); const nodeList = () => wrapper.find( - '.pipeline-nodelist__elements-panel .pipeline-nodelist__row' + '.pipeline-nodelist__elements-panel .node-list-tree-item-row' ); const nodes = getNodeData(mockState.spaceflights); @@ -192,7 +192,7 @@ describe('NodeList', () => { const elements = (wrapper) => wrapper .find('.MuiTreeItem-label') - .find('.pipeline-nodelist__row') + .find('.node-list-tree-item-row') .map((row) => [row.prop('title')]); it('shows full node names when pretty name is turned off', () => { @@ -233,10 +233,10 @@ describe('NodeList', () => { describe('checkboxes on tag filter items', () => { const checkboxByName = (wrapper, text) => - wrapper.find(`.pipeline-nodelist__row__checkbox[name="${text}"]`); + wrapper.find(`.toggle-control__checkbox[name="${text}"]`); - const rowByName = (wrapper, text) => - wrapper.find(`.pipeline-nodelist__row[title="${text}"]`); + const filterRowByName = (wrapper, text) => + wrapper.find(`.node-list-filter-row[title="${text}"]`); const changeRows = (wrapper, names, checked) => names.forEach((name) => @@ -248,11 +248,8 @@ describe('NodeList', () => { const elements = (wrapper) => wrapper .find('.MuiTreeItem-label') - .find('.pipeline-nodelist__row') - .map((row) => [ - row.prop('title'), - !row.hasClass('pipeline-nodelist__row--disabled'), - ]); + .find('.node-list-tree-item-row') + .map((row) => [row.prop('title'), !row.hasClass('row--disabled')]); const elementsEnabled = (wrapper) => { return elements(wrapper).filter(([_, enabled]) => enabled); @@ -264,31 +261,6 @@ describe('NodeList', () => { const partialIcon = (wrapper) => tagItem(wrapper).find(IndicatorPartialIcon); - 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)], - } - ); - - changeRows(wrapper, ['Preprocessing'], true); - expect(elementsEnabled(wrapper)).toEqual([ - ['data_processing', true], - ['data_science', true], - ]); - - changeRows(wrapper, ['Preprocessing', 'Features'], true); - expect(elementsEnabled(wrapper)).toEqual([ - ['data_processing', true], - ['data_science', true], - ['model_input_table', true], - ]); - }); - 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( @@ -331,19 +303,29 @@ describe('NodeList', () => { ); - const uncheckedClass = 'pipeline-nodelist__row--unchecked'; + const uncheckedClass = 'toggle-control--icon--unchecked'; + + const filterRow = filterRowByName(wrapper, 'Preprocessing'); + const hasUncheckedClass = filterRow.find(`.${uncheckedClass}`).exists(); + expect(hasUncheckedClass).toBe(true); - expect(rowByName(wrapper, 'Preprocessing').hasClass(uncheckedClass)).toBe( - true - ); changeRows(wrapper, ['Preprocessing'], true); - expect(rowByName(wrapper, 'Preprocessing').hasClass(uncheckedClass)).toBe( - false - ); + const hasUncheckedClassAfterChangeTrue = filterRowByName( + wrapper, + 'Preprocessing' + ) + .find(`.${uncheckedClass}`) + .exists(); + expect(hasUncheckedClassAfterChangeTrue).toBe(false); + changeRows(wrapper, ['Preprocessing'], false); - expect(rowByName(wrapper, 'Preprocessing').hasClass(uncheckedClass)).toBe( - true - ); + const hasUncheckedClassAfterChangeFalse = filterRowByName( + wrapper, + 'Preprocessing' + ) + .find(`.${uncheckedClass}`) + .exists(); + expect(hasUncheckedClassAfterChangeFalse).toBe(true); }); it('shows as partially selected when at least one but not all tags selected', () => { @@ -383,6 +365,7 @@ describe('NodeList', () => { }); }); + // FILTER GROUP describe('node list', () => { it('renders the correct number of tags in the filter panel', () => { const wrapper = setup.mount( @@ -391,20 +374,22 @@ describe('NodeList', () => { ); const nodeList = wrapper.find( - '.pipeline-nodelist__list--nested .pipeline-nodelist__row' + '.pipeline-nodelist__list--nested .node-list-filter-row' ); // const nodes = getNodeData(mockState.spaceflights); const tags = getTagData(mockState.spaceflights); const elementTypes = Object.keys(sidebarElementTypes); 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 nodeList = wrapper.find('.pipeline-nodelist__row__text--tree'); + + const nodeList = wrapper.find('.row-text--tree'); const modularPipelinesTree = getModularPipelinesTree( mockState.spaceflights ); @@ -437,33 +422,13 @@ describe('NodeList', () => { }); }); - describe('node list element item', () => { - 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); - - it('handles mouseenter events', () => { - nodeRow().simulate('mouseenter'); - expect(nodeRow().hasClass('pipeline-nodelist__row--active')).toBe(true); - }); - - it('handles mouseleave events', () => { - nodeRow().simulate('mouseleave'); - expect(nodeRow().hasClass('pipeline-nodelist__row--active')).toBe(false); - }); - }); - describe('node list element item checkbox', () => { const wrapper = setup.mount( ); - const checkbox = () => wrapper.find('.pipeline-nodelist__row input').at(4); + const checkbox = () => wrapper.find('.node-list-tree-item-row input').at(4); it('handles toggle off event', () => { checkbox().simulate('change', { @@ -507,7 +472,7 @@ describe('NodeList', () => { it('After applying any filter filter button should not be disabled', () => { const nodeTypeFilter = wrapper.find( - `.pipeline-nodelist__row__checkbox[name="Datasets"]` + `.toggle-control__checkbox[name="Datasets"]` ); nodeTypeFilter.simulate('click'); diff --git a/src/components/node-list/styles/_group.scss b/src/components/node-list/styles/_group.scss index 0d456bd2f5..f9eb5d66f8 100644 --- a/src/components/node-list/styles/_group.scss +++ b/src/components/node-list/styles/_group.scss @@ -26,49 +26,6 @@ $placeholder-fade: 120px; -.pipeline-nodelist__placeholder-upper, -.pipeline-nodelist__placeholder-lower { - z-index: var.$zindex-nodelist-placeholder; - pointer-events: none; -} - -.pipeline-nodelist__placeholder-upper::after, -.pipeline-nodelist__placeholder-lower::after { - position: absolute; - width: 100%; - height: $placeholder-fade; - opacity: 0; - transition: opacity ease 0.3s; - content: ' '; - pointer-events: none; -} - -// Add fade overlay at the lazy list boundaries visible during scroll -.pipeline-nodelist__filter-panel { - .pipeline-nodelist__placeholder-upper::after { - bottom: -$placeholder-fade; - background: linear-gradient( - 0deg, - var(--color-nodelist-bg-filter-transparent) 0%, - var(--color-nodelist-filter-panel) 100% - ); - } - - .pipeline-nodelist__placeholder-lower::after { - top: -$placeholder-fade; - background: linear-gradient( - 0deg, - var(--color-nodelist-filter-panel) 0%, - var(--color-nodelist-bg-filter-transparent) 100% - ); - } -} - -.pipeline-nodelist__placeholder-upper--fade::after, -.pipeline-nodelist__placeholder-lower--fade::after { - opacity: 1; -} - .pipeline-nodelist__heading { position: sticky; top: 0; @@ -78,12 +35,13 @@ $placeholder-fade: 120px; // Avoid pixel gap above when scrolling. transform: translateY(-1px); - .pipeline-nodelist__row__text { + .pipeline-nodelist__row__text, + .row-text { position: relative; opacity: 0.65; } - .pipeline-nodelist__row__text .pipeline-nodelist__row__label { + .row-text .row-text__label { font-size: 1.3em; } } @@ -171,3 +129,10 @@ $placeholder-fade: 120px; transform: rotate(90deg); } } + +// Bright row text when the parent groups are all unchecked +.pipeline-nodelist__group--all-unchecked { + .row-text__label--kind-filter { + opacity: 1; + } +} diff --git a/src/components/node-list/styles/_row-label.scss b/src/components/node-list/styles/_row-label.scss deleted file mode 100644 index 72fc48a6c8..0000000000 --- a/src/components/node-list/styles/_row-label.scss +++ /dev/null @@ -1,102 +0,0 @@ -@use '../../../styles/variables' as colors; -@use './variables'; - -.pipeline-nodelist__elements-panel .MuiTreeItem-label { - // Handle MuiTreeItem icon offset for correct width - $icon-offset: 15px + 4px; - - width: calc(100% - #{$icon-offset}); -} - -.pipeline-nodelist__row__text { - display: flex; - align-items: center; - - // Fixed with required for overflow elipsis - width: calc(100% - 7em); - margin-right: auto; - padding: variables.$row-padding-y 0 variables.$row-padding-y 0; - color: inherit; - font-size: inherit; - font-family: inherit; - line-height: 1.6; - letter-spacing: inherit; - text-align: inherit; - background: none; - border: none; - border-radius: 0; - box-shadow: none; - cursor: default; - user-select: none; - - &--tree { - padding: variables.$row-padding-y 0 variables.$row-padding-y 1em; - } - - &:focus { - outline: none; - box-shadow: 0 0 0 4px colors.$blue-300 inset; - - [data-whatintent='mouse'] & { - box-shadow: none; - } - } -} - -.pipeline-nodelist__row__label { - overflow: hidden; - font-size: 1.4em; - white-space: nowrap; - text-overflow: ellipsis; - - &--faded { - opacity: 0.65; - } - - &--disabled { - opacity: 0.3 !important; - } - - b { - color: var(--color-nodelist-highlight); - font-weight: normal; - } -} - -.pipeline-nodelist__row__count { - display: inline-block; - flex-shrink: 0; - width: 2.2em; - margin: 0 0.7em 0.1em auto; - overflow: hidden; - font-size: 1.16em; - text-align: right; - text-overflow: ellipsis; - opacity: 0.75; - user-select: none; - - .pipeline-nodelist__row--unchecked & { - opacity: 0.55; - } -} - -.pipeline-nodelist__row--unchecked { - // Fade row text when unchecked - .pipeline-nodelist__row__label--kind-filter { - opacity: 0.55; - } - - // Brighter row text when unchecked and hovered - &:hover { - .pipeline-nodelist__row__label--kind-filter { - opacity: 0.8; - } - } - - // Bright row text when all unchecked - .pipeline-nodelist__group--all-unchecked & { - .pipeline-nodelist__row__label--kind-filter { - opacity: 1; - } - } -} diff --git a/src/components/node-list/styles/_row.scss b/src/components/node-list/styles/_row.scss deleted file mode 100644 index 409be89666..0000000000 --- a/src/components/node-list/styles/_row.scss +++ /dev/null @@ -1,116 +0,0 @@ -@use '../../../styles/variables' as var; -@use './variables'; - -.MuiTreeItem-iconContainer svg { - z-index: var.$zindex-MuiTreeItem-icon; -} - -.pipeline-nodelist__row { - position: relative; - display: flex; - align-items: center; - height: 32px; // Fixed row height required for lazy list, apply any changes to node-list-row.js. - transform: translate( - 0, - 0 - ); // Force GPU layers to avoid drawing lag on scroll. - - background-color: initial; - cursor: default; - - &--overwrite { - .Mui-selected & { - .kui-theme--dark & { - background-color: var.$slate-200; - } - - .kui-theme--light & { - background-color: var.$white-0; - } - } - } - - &--kind-filter { - padding: 0 variables.$row-offset-right 0 variables.$row-offset-left; - } - - &--active, - &--visible:hover { - background-color: var(--color-nodelist-row-active); - } - - &--selected, - &--visible#{&}--selected { - // Additional selector required to increase specificity to override previous rule - background-color: var(--color-nodelist-row-selected); - border-right: 1px solid var.$blue-300; - } - - &--disabled { - pointer-events: none; - } - - &::before { - position: absolute; - top: 0; - bottom: 0; - left: -100px; - width: 100px; - background: var(--color-nodelist-row-selected); - transform: translate(0, 0); - opacity: 0; - content: ' '; - pointer-events: none; - } -} - -.pipeline-nodelist__row--active::before, -.pipeline-nodelist__row--selected::before, -.pipeline-nodelist__row:hover::before { - opacity: 1; -} - -.pipeline-nodelist__row--overwrite::before { - .Mui-selected & { - opacity: 1; - } -} - -.pipeline-nodelist__row__icon { - display: block; - flex-shrink: 0; - width: variables.$row-icon-size; - height: variables.$row-icon-size; - fill: var(--color-text); - - &.pipeline-row__toggle-icon--focus-checked { - fill: var.$blue-300; - } - - &--disabled > * { - opacity: 0.1; - } -} - -.pipeline-nodelist__row__type-icon { - &--nested > * { - opacity: 0.3; - } - - &--faded > * { - opacity: 0.2; - } - - &--active, - &--selected, - .pipeline-nodelist__row--visible:hover &, - [data-whatintent='keyboard'] .pipeline-nodelist__row__text:focus & { - > * { - opacity: 1; - } - - &--faded > * { - opacity: 0.55; - } - } -} diff --git a/src/components/node-list/styles/node-list.scss b/src/components/node-list/styles/node-list.scss index 3d45c4f370..7cffa2b13b 100644 --- a/src/components/node-list/styles/node-list.scss +++ b/src/components/node-list/styles/node-list.scss @@ -2,9 +2,6 @@ @use '../../../styles/variables' as colors; @use './group'; @use './panels'; -@use './row'; -@use './row-label'; -@use './row-toggle'; @use './section'; @use './variables'; @@ -84,12 +81,67 @@ } } +// Root class for overwriting styles of the pipeline tree item .pipeline-treeItem__root--overwrite { + position: relative; + .Mui-selected { - background-color: transparent !important; + background-color: transparent !important; // Override default background color } .MuiTreeItem-content { - padding: 0; + padding: 0; // Remove padding } + + // When hovering over the tree item content + .MuiTreeItem-content:hover { + background-color: var(--color-nodelist-row-active) !important; + + &::before { + position: absolute; + top: 0; + bottom: 0; + left: -100px; + width: 100px; + background: var(--color-nodelist-row-active); + transform: translate(0, 0); + opacity: 1; + content: ' '; + pointer-events: none; + } + + // If it represents the modular pipeline node, change the color of the sibling .MuiTreeItem-group + ~ .MuiTreeItem-group { + background-color: var(--color-nodelist-row-active); + position: relative; + + // Ensure all .row__type-icon path elements have opacity 1 + .row__type-icon path { + opacity: 1; + } + + // Apply the after-shadow mixin to ensure the background covers the full width on hover + &::after { + content: ''; + position: absolute; + left: -40px; + top: 0; + height: 100%; // Match the height of the parent + width: 50px; + background-color: var(--color-nodelist-row-active); + } + } + } +} + +// disable mouse events for the overwrite disabled class +.pipeline-treeItem__root--overwrite--disabled { + pointer-events: none; +} + +.pipeline-nodelist__elements-panel .MuiTreeItem-label { + // Handle MuiTreeItem icon offset for correct width + $icon-offset: 15px + 4px; + + width: calc(100% - #{$icon-offset}); } diff --git a/src/components/ui/row-text/row-text.js b/src/components/ui/row-text/row-text.js new file mode 100644 index 0000000000..5ee6ba33ea --- /dev/null +++ b/src/components/ui/row-text/row-text.js @@ -0,0 +1,51 @@ +import React from 'react'; +import classnames from 'classnames'; +import { replaceAngleBracketMatches } from '../../../utils'; +import { getDataTestAttribute } from '../../../utils/get-data-test-attribute'; + +import './row-text.scss'; + +export const RowText = ({ + dataTest, + disabled, + faded, + kind, + label, + name, + onClick, + onMouseEnter, + onMouseLeave, + rowType, +}) => { + return ( + + ); +}; diff --git a/src/components/ui/row-text/row-text.scss b/src/components/ui/row-text/row-text.scss new file mode 100644 index 0000000000..11e8c1c8eb --- /dev/null +++ b/src/components/ui/row-text/row-text.scss @@ -0,0 +1,62 @@ +@use '../../../styles/variables' as var; +@use '../../node-list/styles/variables'; + +.row-text { + display: flex; + align-items: center; + + // Fixed with required for overflow elipsis + width: calc(100% - 7em); + margin-right: auto; + padding: variables.$row-padding-y 0 variables.$row-padding-y 0; + color: inherit; + font-size: inherit; + font-family: inherit; + line-height: 1.6; + letter-spacing: inherit; + text-align: inherit; + background: none; + border: none; + border-radius: 0; + box-shadow: none; + cursor: default; + user-select: none; + + // add padding between icon and text + &--tree { + padding: variables.$row-padding-y 0 variables.$row-padding-y 1em; + } + + &--faded { + pointer-events: none; + } + + &:focus { + outline: none; + box-shadow: 0 0 0 4px var.$blue-300 inset; + + [data-whatintent='mouse'] & { + box-shadow: none; + } + } +} + +.row-text__label { + overflow: hidden; + font-size: 1.4em; + white-space: nowrap; + text-overflow: ellipsis; + + &--faded { + opacity: 0.65; + } + + &--disabled { + opacity: 0.3; + } + + b { + color: var(--color-nodelist-highlight); + font-weight: normal; + } +} diff --git a/src/components/ui/toggle-control/toggle-control.js b/src/components/ui/toggle-control/toggle-control.js new file mode 100755 index 0000000000..968b717daf --- /dev/null +++ b/src/components/ui/toggle-control/toggle-control.js @@ -0,0 +1,75 @@ +import React from 'react'; +import classnames from 'classnames'; +import { getDataTestAttribute } from '../../../utils/get-data-test-attribute'; + +import './toggle-control.scss'; + +export const ToggleControl = ({ + className, + focusChecked, + IconComponent, + disabled, + id, + isChecked, + kind, + name, + onChange, + onToggleHoveredFocusMode, + selected, + dataIconType, +}) => { + const handleMouseHover = (isEntering) => + onToggleHoveredFocusMode && onToggleHoveredFocusMode(isEntering); + + const iconClassNames = classnames( + className, + 'toggle-control--icon', + `toggle-control--icon--kind-${kind}`, + { + 'toggle-control--icon--checked': isChecked, + 'toggle-control--icon--unchecked': !isChecked, + 'toggle-control--icon--focus-checked': focusChecked, + 'toggle-control--icon--disabled': disabled, + } + ); + + const labelClassNames = classnames( + 'toggle-control', + `toggle-control--kind-${kind}`, + { + 'toggle-control--selected': selected, + } + ); + + const dataTestValue = getDataTestAttribute( + 'toggle-control', + kind === 'focus' ? 'focusMode' : 'visible', + name + ); + + return ( + + ); +}; diff --git a/src/components/node-list/styles/_row-toggle.scss b/src/components/ui/toggle-control/toggle-control.scss similarity index 62% rename from src/components/node-list/styles/_row-toggle.scss rename to src/components/ui/toggle-control/toggle-control.scss index d9220bad90..181770d3ad 100644 --- a/src/components/node-list/styles/_row-toggle.scss +++ b/src/components/ui/toggle-control/toggle-control.scss @@ -1,10 +1,8 @@ @use '../../../styles/mixins' as mixins; @use '../../../styles/variables' as colors; -@use './variables'; +@use '../../node-list/styles/variables'; -// --- Toggle ---// - -.pipeline-row__toggle { +.toggle-control { cursor: pointer; &--kind-element { @@ -14,10 +12,6 @@ &--kind-element:nth-of-type(2) { margin: 0 8px 0 -8px; } - - &--disabled { - display: none; - } } @include mixins.transparentColour( @@ -26,29 +20,35 @@ variables.$row-selected-dark ); -.pipeline-row__toggle--selected::before { +.toggle-control--selected::before { opacity: 1; } -.pipeline-nodelist__row__checkbox { +.toggle-control__checkbox { @include mixins.screenReaderOnly; } -// --- Toggle icon ---// - -.pipeline-row__toggle-icon { - width: variables.$toggle-icon-size; - height: variables.$toggle-icon-size; +.toggle-control--icon { + width: variables.$toggle-icon-size !important; + height: variables.$toggle-icon-size !important; padding: variables.$toggle-icon-padding; border-radius: 50%; - .pipeline-nodelist__row__checkbox:focus + & { + &--disabled { + display: none !important; + } + + .toggle-control__checkbox:focus + & { outline: none; [data-whatintent='keyboard'] & { box-shadow: 0 0 0 3px colors.$blue-300 inset; } } + + &.toggle-control--icon--focus-checked { + fill: colors.$blue-300; + } } // There are two kinds of toggle icon, with different styling: @@ -71,26 +71,26 @@ $element-icon-opacity-0: 0; $element-icon-opacity-1: 0.55; $element-icon-opacity-2: 1; -.pipeline-row__toggle-icon--kind-element { +.toggle-control--icon--kind-element { // Change opacity on the SVG's child elements instead, in order to // maintain 100% opacity outline on parent SVG on keyboard focus > * { opacity: $element-icon-opacity-0; } - .pipeline-nodelist__row:hover & { + .node-list-tree-item-row:hover & { > * { opacity: $element-icon-opacity-1; } - &.pipeline-row__toggle-icon--focus-checked { + &.toggle-control--icon--focus-checked { > * { opacity: $element-icon-opacity-2; } } } - .pipeline-nodelist__row &:hover { + .node-list-tree-item-row &:hover { > * { opacity: $element-icon-opacity-2; } @@ -101,14 +101,14 @@ $element-icon-opacity-2: 1; opacity: $element-icon-opacity-1; } - &.pipeline-row__toggle-icon--checked { + &.toggle-control--icon--checked { > * { opacity: $element-icon-opacity-2; } } } - &.pipeline-row__toggle-icon--focus-checked { + &.toggle-control--icon--focus-checked { > * { opacity: $element-icon-opacity-2; } @@ -139,93 +139,58 @@ $filter-icon-opacity-1: 0.55; $filter-icon-opacity-2: 0.9; $filter-icon-opacity-3: 1; -.pipeline-row__toggle-icon--kind-filter { +.toggle-control--icon--kind-filter { // Change opacity on the SVG's child elements instead, in order to // maintain 100% opacity outline on parent SVG on keyboard focus > * { opacity: $filter-icon-opacity-1; } - .pipeline-nodelist__heading &.pipeline-row__toggle-icon--all-unchecked > * { - opacity: $filter-icon-opacity-0; - } - - &.pipeline-row__toggle-icon--all-unchecked { + &.toggle-control--icon--all-unchecked, + .pipeline-nodelist__heading &.toggle-control--icon--all-unchecked > * { > * { - opacity: $filter-icon-opacity-1; + opacity: $filter-icon-opacity-0; } } - .pipeline-nodelist__row:hover & { + .node-list-tree-item-row:hover & { > * { opacity: $filter-icon-opacity-1; } - } - - .pipeline-nodelist__row:hover & { - &.pipeline-row__toggle-icon--parent:hover { - > * { - opacity: $filter-icon-opacity-2; - } - } - } - .pipeline-nodelist__row & { - &.pipeline-row__toggle-icon--checked { + &.toggle-control--icon--parent:hover, + &.toggle-control--icon--checked, + &.toggle-control--icon--child.toggle-control--icon--checked { > * { - opacity: $filter-icon-opacity-2; - } - } - } - - .pipeline-nodelist__row:hover & { - &.pipeline-row__toggle-icon--child { - &.pipeline-row__toggle-icon--checked { - > * { - opacity: $filter-icon-opacity-3; - } - } - } - } - - .pipeline-nodelist__row & { - &.pipeline-row__toggle-icon--parent:hover { - &.pipeline-row__toggle-icon--checked { - > * { - opacity: $filter-icon-opacity-3; - } + opacity: $filter-icon-opacity-2; // Increase opacity for checked or parent hover } } } [data-whatintent='keyboard'] input:focus + & { > * { - opacity: $filter-icon-opacity-2; + opacity: $filter-icon-opacity-2; // Increase opacity on keyboard focus } - &.pipeline-row__toggle-icon--checked { + &.toggle-control--icon--checked { > * { - opacity: $filter-icon-opacity-3; + opacity: $filter-icon-opacity-3; // Further increase for checked on focus } } } -} - -// --- Toggle (kind=filter) icon fills and strokes ---// -.pipeline-row__toggle-icon--kind-filter { - &.pipeline-row__toggle-icon--checked { + &.toggle-control--icon--checked { fill: var(--color-nodelist-filter-indicator-on); stroke: var(--color-nodelist-filter-indicator-on); } - &.pipeline-row__toggle-icon--unchecked { + &.toggle-control--icon--unchecked { fill: none; stroke: var(--color-nodelist-filter-indicator-off); } - .pipeline-nodelist__row:hover &.pipeline-row__toggle-icon--all-unchecked, - &.pipeline-row__toggle-icon--parent { + .node-list-tree-item-row:hover &.toggle-control--icon--all-unchecked, + &.toggle-control--icon--parent { fill: colors.$blue-300; stroke: colors.$blue-300; } diff --git a/src/components/ui/toggle-control/toggle-control.test.js b/src/components/ui/toggle-control/toggle-control.test.js new file mode 100644 index 0000000000..e99cccff0b --- /dev/null +++ b/src/components/ui/toggle-control/toggle-control.test.js @@ -0,0 +1,58 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { ToggleControl } from './toggle-control'; + +describe('ToggleControl', () => { + const baseProps = { + name: 'Test Node', + onChange: jest.fn(), + onToggleHoveredFocusMode: jest.fn(), + }; + + it('applies correct class for kind prop', () => { + const kinds = ['modularPipeline', 'data', 'task']; + kinds.forEach((kind) => { + const props = { ...baseProps, kind }; + const wrapper = shallow(); + expect(wrapper.hasClass(`toggle-control--kind-${kind}`)).toBe(true); + }); + }); + + it('does not apply "all-unchecked" class when allUnchecked is false', () => { + const props = { ...baseProps, allUnchecked: false }; + const wrapper = shallow(); + expect(wrapper.hasClass('toggle-control--icon--all-unchecked')).toBe(false); + }); + + it('does not apply "disabled" class when disabled is false', () => { + const props = { ...baseProps, disabled: false }; + const wrapper = shallow(); + expect(wrapper.hasClass('toggle-control--disabled')).toBe(false); + }); + + it('does not apply "checked" class when isChecked is false', () => { + const props = { ...baseProps, isChecked: false }; + const wrapper = shallow(); + expect(wrapper.hasClass('toggle-control--icon--checked')).toBe(false); + }); + + it('does not apply "parent" class when isParent is false', () => { + const props = { ...baseProps, isParent: false }; + const wrapper = shallow(); + expect(wrapper.hasClass('toggle-control--icon--parent')).toBe(false); + }); + + it('does not trigger onToggleHoveredFocusMode when not provided', () => { + const props = { ...baseProps, onToggleHoveredFocusMode: undefined }; + const wrapper = shallow(); + wrapper.simulate('mouseenter'); + expect(() => wrapper.simulate('mouseenter')).not.toThrow(); + }); + + it('triggers onToggleHoveredFocusMode when provided', () => { + const props = { ...baseProps }; + const wrapper = shallow(); + wrapper.simulate('mouseenter'); + expect(props.onToggleHoveredFocusMode).toHaveBeenCalled(); + }); +}); diff --git a/src/config.js b/src/config.js index 6bdaa8a1a2..daa602385a 100644 --- a/src/config.js +++ b/src/config.js @@ -35,6 +35,9 @@ export const codeSidebarWidth = { open: 480, }; +// The exact fixed height of a row as measured by getBoundingClientRect() +export const nodeListRowHeight = 32; + // These colours variables come from styles/variables const slate600 = '#0e222d'; const slate200 = '#21333e'; diff --git a/tools/test-lib/react-app/app.test.js b/tools/test-lib/react-app/app.test.js index 07354db84b..d7afff30e9 100644 --- a/tools/test-lib/react-app/app.test.js +++ b/tools/test-lib/react-app/app.test.js @@ -17,9 +17,8 @@ describe('lib-test', () => { */ const testFirstNodeNameMatch = (container, key) => { const firstNodeName = container - .querySelector('.pipeline-nodelist__row') - .querySelector('.pipeline-nodelist__row__text--tree') - .querySelector('.pipeline-nodelist__row__label') + .querySelector('.node-list-tree-item-row') + .querySelector('.row-text__label') .textContent.trim(); const modularPipelinesTree = dataSources[key]().modular_pipelines; From 61c1de37502b1aef1c6ee5ccfde025452475ec3b Mon Sep 17 00:00:00 2001 From: Huong Nguyen <32060364+Huongg@users.noreply.github.com> Date: Mon, 4 Nov 2024 09:54:57 +0000 Subject: [PATCH 2/9] Refactor/node list groups (#2166) * Create new structure and its own folder for filters or groups Signed-off-by: Huong Nguyen * better names for component structure Signed-off-by: Huong Nguyen * FiltersSectionHeading Signed-off-by: Huong Nguyen * filters-section Signed-off-by: Huong Nguyen * filters component Signed-off-by: Huong Nguyen * filtersSectionHeading component Signed-off-by: Huong Nguyen * tidy up code Signed-off-by: Huong Nguyen * including new tests for new components Signed-off-by: Huong Nguyen * update and remove existing tests Signed-off-by: Huong Nguyen * remove un-used variables Signed-off-by: Huong Nguyen * remove components folder Signed-off-by: Huong Nguyen * update tests path Signed-off-by: Huong Nguyen --------- Signed-off-by: Huong Nguyen Co-authored-by: Huong Nguyen --- .../filters-group/filters-group.js} | 27 ++-- .../filters/filters-group/filters-group.scss | 15 ++ .../filters-group/filters-group.test.js | 34 +++++ .../filters-row/filters-row.js} | 14 +- .../filters-row/filters-row.scss} | 4 +- .../filters-row/filters-row.test.js} | 10 +- .../filters-section-heading.js | 48 ++++++ .../filters-section-heading.scss | 62 ++++++++ .../filters-section-heading.test.js | 53 +++++++ .../filters-section/filters-section.js | 52 +++++++ .../filters-section/filters-section.scss | 6 + .../filters-section/filters-section.test.js | 26 ++++ src/components/filters/filters.js | 57 ++++++++ .../_section.scss => filters/filters.scss} | 18 ++- src/components/filters/filters.test.js | 61 ++++++++ src/components/node-list/index.js | 21 ++- src/components/node-list/node-list-group.js | 76 ---------- .../node-list/node-list-group.test.js | 76 ---------- src/components/node-list/node-list-groups.js | 60 -------- .../node-list/node-list-groups.test.js | 49 ------- src/components/node-list/node-list.js | 32 ++-- src/components/node-list/node-list.test.js | 16 +- src/components/node-list/styles/_group.scss | 138 ------------------ .../node-list/styles/node-list.scss | 2 - .../sliced-pipeline-action-bar.test.js | 2 +- .../ui/toggle-control/toggle-control.scss | 7 - 26 files changed, 494 insertions(+), 472 deletions(-) rename src/components/{node-list/node-list-row-list.js => filters/filters-group/filters-group.js} (67%) create mode 100644 src/components/filters/filters-group/filters-group.scss create mode 100644 src/components/filters/filters-group/filters-group.test.js rename src/components/{filter-row/filter-row.js => filters/filters-row/filters-row.js} (79%) rename src/components/{filter-row/filter-row.scss => filters/filters-row/filters-row.scss} (91%) rename src/components/{filter-row/filter-row.test.js => filters/filters-row/filters-row.test.js} (61%) create mode 100644 src/components/filters/filters-section-heading/filters-section-heading.js create mode 100644 src/components/filters/filters-section-heading/filters-section-heading.scss create mode 100755 src/components/filters/filters-section-heading/filters-section-heading.test.js create mode 100644 src/components/filters/filters-section/filters-section.js create mode 100644 src/components/filters/filters-section/filters-section.scss create mode 100755 src/components/filters/filters-section/filters-section.test.js create mode 100644 src/components/filters/filters.js rename src/components/{node-list/styles/_section.scss => filters/filters.scss} (72%) create mode 100644 src/components/filters/filters.test.js delete mode 100644 src/components/node-list/node-list-group.js delete mode 100644 src/components/node-list/node-list-group.test.js delete mode 100644 src/components/node-list/node-list-groups.js delete mode 100644 src/components/node-list/node-list-groups.test.js delete mode 100644 src/components/node-list/styles/_group.scss diff --git a/src/components/node-list/node-list-row-list.js b/src/components/filters/filters-group/filters-group.js similarity index 67% rename from src/components/node-list/node-list-row-list.js rename to src/components/filters/filters-group/filters-group.js index fac2b346e6..d2b0076860 100644 --- a/src/components/node-list/node-list-row-list.js +++ b/src/components/filters/filters-group/filters-group.js @@ -1,11 +1,14 @@ import React from 'react'; -import modifiers from '../../utils/modifiers'; -import { FilterRow } from '../filter-row/filter-row'; -import { nodeListRowHeight } from '../../config'; -import LazyList from '../lazy-list'; -import { getDataTestAttribute } from '../../utils/get-data-test-attribute'; +import classnames from 'classnames'; +import FiltersRow from '../filters-row/filters-row'; +import { nodeListRowHeight } from '../../../config'; +import LazyList from '../../lazy-list'; +import { getDataTestAttribute } from '../../../utils/get-data-test-attribute'; -const NodeRowList = ({ +import './filters-group.scss'; + +/** A group collection of FiltersRow */ +const FiltersGroup = ({ items = [], group, collapsed, @@ -20,14 +23,12 @@ const NodeRowList = ({
    {items.slice(start, end).map((item) => ( - ); -export default NodeRowList; +export default FiltersGroup; diff --git a/src/components/filters/filters-group/filters-group.scss b/src/components/filters/filters-group/filters-group.scss new file mode 100644 index 0000000000..00dc8045f9 --- /dev/null +++ b/src/components/filters/filters-group/filters-group.scss @@ -0,0 +1,15 @@ +@use '../../../styles/variables' as var; +@use '../../node-list/styles/variables'; + +.filters-group { + list-style: none; + padding: 0; + margin: 0 0 1.2em; + + // Avoid placeholder fade leaking out for small lists + overflow: hidden; + + &--closed { + display: none; + } +} diff --git a/src/components/filters/filters-group/filters-group.test.js b/src/components/filters/filters-group/filters-group.test.js new file mode 100644 index 0000000000..edb6d682f9 --- /dev/null +++ b/src/components/filters/filters-group/filters-group.test.js @@ -0,0 +1,34 @@ +import React from 'react'; +import FiltersGroup from './filters-group'; +import { mockState, setup } from '../../../utils/state.mock'; +import { getNodeTypes } from '../../../selectors/node-types'; +import { getGroupedNodes } from '../../../selectors/nodes'; +import { getGroups } from '../../node-list/node-list-items'; + +describe('FiltersGroup Component', () => { + const mockProps = () => { + const items = getGroupedNodes(mockState.spaceflights); + const nodeTypes = getNodeTypes(mockState.spaceflights); + const groups = getGroups({ nodeTypes, items }); + return { group: groups['tags'], items: [] }; + }; + + it('renders without throwing', () => { + expect(() => setup.mount()).not.toThrow(); + }); + it('adds class when collapsed prop true', () => { + const wrapper = setup.mount( + + ); + const children = wrapper.find('.filters-group'); + expect(children.hasClass('filters-group--closed')).toBe(true); + }); + + it('removes class when collapsed prop false', () => { + const wrapper = setup.mount( + + ); + const children = wrapper.find('.filters-group'); + expect(children.hasClass('filters-group--closed')).toBe(false); + }); +}); diff --git a/src/components/filter-row/filter-row.js b/src/components/filters/filters-row/filters-row.js similarity index 79% rename from src/components/filter-row/filter-row.js rename to src/components/filters/filters-row/filters-row.js index baa8cc772c..f854100608 100755 --- a/src/components/filter-row/filter-row.js +++ b/src/components/filters/filters-row/filters-row.js @@ -1,13 +1,13 @@ import React from 'react'; import classnames from 'classnames'; -import IndicatorIcon from '../icons/indicator'; -import OffIndicatorIcon from '../icons/indicator-off'; -import { ToggleControl } from '../ui/toggle-control/toggle-control'; -import { RowText } from '../ui/row-text/row-text'; +import IndicatorIcon from '../../icons/indicator'; +import OffIndicatorIcon from '../../icons/indicator-off'; +import { ToggleControl } from '../../ui/toggle-control/toggle-control'; +import { RowText } from '../../ui/row-text/row-text'; -import './filter-row.scss'; +import './filters-row.scss'; -export const FilterRow = ({ +const FiltersRow = ({ allUnchecked, checked, children, @@ -64,3 +64,5 @@ export const FilterRow = ({ ); }; + +export default FiltersRow; diff --git a/src/components/filter-row/filter-row.scss b/src/components/filters/filters-row/filters-row.scss similarity index 91% rename from src/components/filter-row/filter-row.scss rename to src/components/filters/filters-row/filters-row.scss index ff23a5d05e..43a3d657b3 100644 --- a/src/components/filter-row/filter-row.scss +++ b/src/components/filters/filters-row/filters-row.scss @@ -1,5 +1,5 @@ -@use '../../styles/variables' as var; -@use '../node-list/styles/variables'; +@use '../../../styles/variables' as var; +@use '../../node-list/styles/variables'; .MuiTreeItem-iconContainer svg { z-index: var.$zindex-MuiTreeItem-icon; diff --git a/src/components/filter-row/filter-row.test.js b/src/components/filters/filters-row/filters-row.test.js similarity index 61% rename from src/components/filter-row/filter-row.test.js rename to src/components/filters/filters-row/filters-row.test.js index 3e1a31a203..1660b20f14 100644 --- a/src/components/filter-row/filter-row.test.js +++ b/src/components/filters/filters-row/filters-row.test.js @@ -1,22 +1,22 @@ import React from 'react'; import { mount } from 'enzyme'; -import { FilterRow } from './filter-row'; +import FiltersRow from './filters-row'; -describe('FilterRow Component', () => { +describe('FiltersRow Component', () => { it('renders without crashing', () => { - const wrapper = mount(); + const wrapper = mount(); expect(wrapper.exists()).toBe(true); }); it('renders correct visible classnames', () => { - const wrapper = mount(); + const wrapper = mount(); expect(wrapper.find('.filter-row').hasClass('filter-row--visible')).toBe( true ); }); it('renders correct unchecked classnames', () => { - const wrapper = mount(); + const wrapper = mount(); expect(wrapper.find('.filter-row').hasClass('filter-row--unchecked')).toBe( true ); diff --git a/src/components/filters/filters-section-heading/filters-section-heading.js b/src/components/filters/filters-section-heading/filters-section-heading.js new file mode 100644 index 0000000000..f63cba95d0 --- /dev/null +++ b/src/components/filters/filters-section-heading/filters-section-heading.js @@ -0,0 +1,48 @@ +import React from 'react'; +import classnames from 'classnames'; +import FiltersRow from '../filters-row/filters-row'; + +import './filters-section-heading.scss'; + +const FiltersSectionHeading = ({ + group, + collapsed, + groupItems, + onGroupToggleChanged, + onToggleGroupCollapsed, +}) => { + const { id, kind, name, allUnchecked, checked, invisibleIcon, visibleIcon } = + group; + const disabled = groupItems.length === 0; + + return ( +

    + { + onGroupToggleChanged(id, !e.target.checked); + }} + indicatorIcon={visibleIcon} + > +

    + ); +}; + +export default FiltersSectionHeading; diff --git a/src/components/filters/filters-section-heading/filters-section-heading.scss b/src/components/filters/filters-section-heading/filters-section-heading.scss new file mode 100644 index 0000000000..ce0644bb48 --- /dev/null +++ b/src/components/filters/filters-section-heading/filters-section-heading.scss @@ -0,0 +1,62 @@ +@use '../../../styles/variables' as var; +@use '../../node-list/styles/variables'; + +.filters-section-heading { + background: var(--color-nodelist-filter-panel); + margin: 0; + position: sticky; + top: 0; + + // Avoid pixel gap above when scrolling. + transform: translateY(-1px); + z-index: var.$zindex-nodelist-heading; + + .row-text .row-text__label { + font-size: 1.3em; + opacity: 0.65; + } +} + +.filters-section-heading__toggle-btn { + width: variables.$toggle-size; + height: variables.$toggle-size; + padding: 0; + color: var(--color-default-alt); + font-size: inherit; + font-family: inherit; + line-height: 1em; + text-align: center; + background: none; + border: none; + border-radius: 50%; + box-shadow: none; + cursor: pointer; + transition: transform ease 0.1s; + + &:focus { + outline: none; + + [data-whatintent='keyboard'] & { + box-shadow: 0 0 0 3px var.$blue-300 inset; + } + } + + &::before { + font-size: 1.8em; + opacity: 0.55; + content: 'â–¾'; + } + + &:hover::before { + opacity: 1; + } + + &--alt { + transform: rotate(90deg); + } + + &--disabled { + color: var.$black-400; + transform: rotate(90deg); + } +} diff --git a/src/components/filters/filters-section-heading/filters-section-heading.test.js b/src/components/filters/filters-section-heading/filters-section-heading.test.js new file mode 100755 index 0000000000..af752fd847 --- /dev/null +++ b/src/components/filters/filters-section-heading/filters-section-heading.test.js @@ -0,0 +1,53 @@ +import React from 'react'; +import FiltersSectionHeading from './filters-section-heading'; +import { mockState, setup } from '../../../utils/state.mock'; +import { getNodeTypes } from '../../../selectors/node-types'; +import { getGroupedNodes } from '../../../selectors/nodes'; +import { getGroups } from '../../node-list/node-list-items'; + +describe('FiltersSectionHeading', () => { + const mockProps = () => { + const items = getGroupedNodes(mockState.spaceflights); + const nodeTypes = getNodeTypes(mockState.spaceflights); + const groups = getGroups({ nodeTypes, items }); + return { group: groups['elementType'], groupItems: [] }; + }; + + it('renders without throwing', () => { + expect(() => + setup.mount() + ).not.toThrow(); + }); + + it('handles collapse button click events', () => { + const onToggleCollapsed = jest.fn(); + const wrapper = setup.mount( + + ); + wrapper.find('.filters-section-heading__toggle-btn').simulate('click'); + expect(() => onToggleCollapsed.mock.calls.length.toEqual(1)).toThrow(); + }); + + it('adds class when collapsed prop true', () => { + const wrapper = setup.mount( + + ); + const children = wrapper.find('.filters-section-heading__toggle-btn'); + expect(children.hasClass('filters-section-heading__toggle-btn--alt')).toBe( + true + ); + }); + + it('adds class when disabled prop true', () => { + const wrapper = setup.mount( + + ); + const children = wrapper.find('.filters-section-heading__toggle-btn'); + expect( + children.hasClass('filters-section-heading__toggle-btn--disabled') + ).toBe(true); + }); +}); diff --git a/src/components/filters/filters-section/filters-section.js b/src/components/filters/filters-section/filters-section.js new file mode 100644 index 0000000000..0a16489c20 --- /dev/null +++ b/src/components/filters/filters-section/filters-section.js @@ -0,0 +1,52 @@ +import React from 'react'; +import classnames from 'classnames'; +import FiltersSectionHeading from '../filters-section-heading/filters-section-heading'; +import FiltersGroup from '../filters-group/filters-group'; + +import './filters-section.scss'; + +/** Represents a section within the filters. */ +const FiltersSection = ({ + group, + groupCollapsed, + items, + onGroupToggleChanged, + onItemChange, + onItemClick, + onItemMouseEnter, + onItemMouseLeave, + onToggleGroupCollapsed, + searchValue, +}) => { + const { id, allUnchecked } = group; + const collapsed = Boolean(searchValue) ? false : groupCollapsed[id]; + const groupItems = items[id] || []; + + return ( +
  • + + +
  • + ); +}; + +export default FiltersSection; diff --git a/src/components/filters/filters-section/filters-section.scss b/src/components/filters/filters-section/filters-section.scss new file mode 100644 index 0000000000..a0f90a9516 --- /dev/null +++ b/src/components/filters/filters-section/filters-section.scss @@ -0,0 +1,6 @@ +// Bright row text when the parent groups are all unchecked +.filters-section--all-unchecked { + .row-text__label--kind-filter { + opacity: 1; + } +} diff --git a/src/components/filters/filters-section/filters-section.test.js b/src/components/filters/filters-section/filters-section.test.js new file mode 100755 index 0000000000..57241db95c --- /dev/null +++ b/src/components/filters/filters-section/filters-section.test.js @@ -0,0 +1,26 @@ +import React from 'react'; +import FiltersSection from './filters-section'; +import { mockState, setup } from '../../../utils/state.mock'; +import { getNodeTypes } from '../../../selectors/node-types'; +import { getGroupedNodes } from '../../../selectors/nodes'; +import { getGroups } from '../../node-list/node-list-items'; + +describe('FiltersSection Component', () => { + const mockProps = () => { + const items = getGroupedNodes(mockState.spaceflights); + const nodeTypes = getNodeTypes(mockState.spaceflights); + const groups = getGroups({ nodeTypes, items }); + return { items, group: groups['elementType'], groupCollapsed: {} }; + }; + + it('renders without throwing', () => { + expect(() => + setup.mount() + ).not.toThrow(); + }); + it('adds clas all-uncheckes when allUnchecked prop true', () => { + const wrapper = setup.mount(); + const children = wrapper.find('.filters-section'); + expect(children.hasClass('filters-section--all-unchecked')).toBe(true); + }); +}); diff --git a/src/components/filters/filters.js b/src/components/filters/filters.js new file mode 100644 index 0000000000..749cd4ac6e --- /dev/null +++ b/src/components/filters/filters.js @@ -0,0 +1,57 @@ +import React from 'react'; +import FiltersSection from './filters-section/filters-section'; + +import './filters.scss'; + +const Filters = ({ + groupCollapsed, + groups, + isResetFilterActive, + items, + onGroupToggleChanged, + onItemChange, + onItemClick, + onItemMouseEnter, + onItemMouseLeave, + onResetFilter, + onToggleGroupCollapsed, + searchValue, +}) => { + return ( + <> +
    +

    + Filters +

    + +
    +
      + {Object.values(groups).map((group) => { + return ( + + ); + })} +
    + + ); +}; + +export default Filters; diff --git a/src/components/node-list/styles/_section.scss b/src/components/filters/filters.scss similarity index 72% rename from src/components/node-list/styles/_section.scss rename to src/components/filters/filters.scss index a854ce8ee8..dccad4d453 100644 --- a/src/components/node-list/styles/_section.scss +++ b/src/components/filters/filters.scss @@ -1,6 +1,6 @@ -@use './variables'; -@use '../../../styles/extends'; -@use '../../../styles/variables' as colors; +@use '../node-list/styles/variables'; +@use '../../styles/extends'; +@use '../../styles/variables' as colors; .kui-theme--light { --color-text-reset: #{colors.$black-800}; @@ -10,14 +10,20 @@ --color-text-reset: #{colors.$white-600}; } -.pipeline-nodelist-section__filters { +.filters__section-wrapper { + margin: 0; + padding: 0; + list-style: none; +} + +.filters__header { 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 { + .filters__title { font-weight: normal; font-size: 1.6em; opacity: 0.55; @@ -25,7 +31,7 @@ margin: 0; } - .pipeline-nodelist-section__reset-filter { + .filters__reset-button { @extend %button; font-size: 1.3em; diff --git a/src/components/filters/filters.test.js b/src/components/filters/filters.test.js new file mode 100644 index 0000000000..76af07c597 --- /dev/null +++ b/src/components/filters/filters.test.js @@ -0,0 +1,61 @@ +import React from 'react'; +import Filters from './filters'; +import { mockState, setup } from '../../utils/state.mock'; +import { getNodeTypes } from '../../selectors/node-types'; +import { getGroupedNodes } from '../../selectors/nodes'; +import { getGroups } from '../node-list/node-list-items'; + +describe('Filters', () => { + const mockProps = () => { + const items = getGroupedNodes(mockState.spaceflights); + const nodeTypes = getNodeTypes(mockState.spaceflights); + const groups = getGroups({ nodeTypes, items }); + return { items, groups, groupCollapsed: {} }; + }; + + it('renders without throwing', () => { + expect(() => setup.mount()).not.toThrow(); + }); + + it('handles collapse button click events', () => { + const wrapper = setup.mount(); + const nodeList = () => wrapper.find('.filters-group').first(); + const toggle = () => + wrapper.find('.filters-section-heading__toggle-btn').first(); + expect(nodeList().length).toBe(1); + expect(toggle().hasClass('filters-section-heading__toggle-btn--alt')).toBe( + false + ); + expect(() => { + toggle() + .hasClass('filters-section-heading__toggle-btn--disabled') + .toBe(false); + toggle().simulate('click'); + expect(nodeList().length).toBe(1); + expect( + toggle().hasClass('filters-section-heading__toggle-btn--alt') + ).toBe(true); + }).toThrow(); + }); + + it('handles group checkbox change events', () => { + const onGroupToggleChanged = jest.fn(); + const wrapper = setup.mount( + + ); + const checkbox = () => wrapper.find('input').first(); + checkbox().simulate('change', { target: { checked: false } }); + expect(onGroupToggleChanged.mock.calls.length).toEqual(1); + }); + + it('calls onResetFilter when reset button is clicked', () => { + const onResetFilter = jest.fn(); + const wrapper = setup.mount( + + ); + const resetButton = wrapper.find('.filters__reset-button'); + expect(resetButton.exists()).toBe(true); + resetButton.simulate('click'); + expect(() => onResetFilter.mock.calls.length.toEqual(1)).toThrow(); + }); +}); diff --git a/src/components/node-list/index.js b/src/components/node-list/index.js index da2ea0984a..e338a34960 100644 --- a/src/components/node-list/index.js +++ b/src/components/node-list/index.js @@ -44,7 +44,10 @@ import { } from '../../actions/nodes'; import { useGeneratePathname } from '../../utils/hooks/use-generate-pathname'; import './styles/node-list.scss'; -import { params, NODE_TYPES } from '../../config'; +import { params, NODE_TYPES, localStorageName } from '../../config'; +import { loadLocalStorage, saveLocalStorage } from '../../store/helpers'; + +const storedState = loadLocalStorage(localStorageName); /** * Provides data from the store to populate a NodeList component. @@ -78,6 +81,9 @@ const NodeListProvider = ({ }) => { const [searchValue, updateSearchValue] = useState(''); const [isResetFilterActive, setIsResetFilterActive] = useState(false); + const [groupCollapsed, setGroupCollapsed] = useState( + storedState.groupsCollapsed || {} + ); const { toSelectedPipeline, @@ -225,6 +231,17 @@ const NodeListProvider = ({ } }; + // Collapse/expand node group of filters + const onToggleGroupCollapsed = (groupID) => { + const res = { + ...groupCollapsed, + [groupID]: !groupCollapsed[groupID], + }; + + setGroupCollapsed(res); + saveLocalStorage(localStorageName, { groupsCollapsed: res }); + }; + const onGroupToggleChanged = (groupType) => { // Enable all items in group if none enabled, otherwise disable all of them const groupItems = items[groupType] || []; @@ -319,6 +336,8 @@ const NodeListProvider = ({ onUpdateSearchValue={debounce(updateSearchValue, 250)} onModularPipelineToggleExpanded={handleToggleModularPipelineExpanded} onGroupToggleChanged={onGroupToggleChanged} + onToggleGroupCollapsed={onToggleGroupCollapsed} + groupCollapsed={groupCollapsed} onToggleFocusMode={onToggleFocusMode} onItemClick={onItemClick} onItemMouseEnter={onItemMouseEnter} diff --git a/src/components/node-list/node-list-group.js b/src/components/node-list/node-list-group.js deleted file mode 100644 index 4d68df9b19..0000000000 --- a/src/components/node-list/node-list-group.js +++ /dev/null @@ -1,76 +0,0 @@ -import React from 'react'; -import classnames from 'classnames'; -import { FilterRow } from '../filter-row/filter-row'; -import NodeRowList from './node-list-row-list'; - -export const NodeListGroup = ({ - allUnchecked, - checked, - collapsed, - group, - id, - invisibleIcon, - items, - kind, - name, - onItemChange, - onItemClick, - onItemMouseEnter, - onItemMouseLeave, - onToggleChecked, - onToggleCollapsed, - visibleIcon, -}) => { - const disabledGroup = items.length === 0; - - return ( -
  • -

    - { - onToggleChecked(id, !e.target.checked); - }} - rowType="filter" - indicatorIcon={visibleIcon} - > -

    - -
  • - ); -}; - -export default NodeListGroup; diff --git a/src/components/node-list/node-list-group.test.js b/src/components/node-list/node-list-group.test.js deleted file mode 100644 index 940f9bedc0..0000000000 --- a/src/components/node-list/node-list-group.test.js +++ /dev/null @@ -1,76 +0,0 @@ -import React from 'react'; -import { NodeListGroup } from './node-list-group'; -import { getNodeTypes } from '../../selectors/node-types'; -import { setup, mockState } from '../../utils/state.mock'; - -describe('NodeListGroup', () => { - const items = []; - - it('renders without throwing', () => { - const type = getNodeTypes(mockState.spaceflights)[0]; - expect(() => - setup.mount() - ).not.toThrow(); - }); - - it('handles checkbox change events', () => { - const type = getNodeTypes(mockState.spaceflights)[0]; - const onToggleChecked = jest.fn(); - const wrapper = setup.mount( - - ); - const checkbox = () => wrapper.find('input'); - checkbox().simulate('change', { target: { checked: false } }); - expect(onToggleChecked.mock.calls.length).toEqual(1); - }); - - it('handles collapse button click events', () => { - const type = getNodeTypes(mockState.spaceflights)[0]; - const onToggleCollapsed = jest.fn(); - const wrapper = setup.mount( - - ); - wrapper.find('.pipeline-type-group-toggle').simulate('click'); - expect(() => onToggleCollapsed.mock.calls.length.toEqual(1)).toThrow(); - }); - - it('adds class when collapsed prop true', () => { - const type = getNodeTypes(mockState.spaceflights)[0]; - const wrapper = setup.mount( - - ); - const children = wrapper.find('.pipeline-nodelist__children'); - expect(children.hasClass('pipeline-nodelist__children--closed')).toBe(true); - }); - - it('removes class when collapsed prop false', () => { - const type = getNodeTypes(mockState.spaceflights)[0]; - const wrapper = setup.mount( - - ); - const children = wrapper.find('.pipeline-nodelist__children'); - expect(children.hasClass('pipeline-nodelist__children--closed')).toBe( - false - ); - }); -}); diff --git a/src/components/node-list/node-list-groups.js b/src/components/node-list/node-list-groups.js deleted file mode 100644 index a91dd31e94..0000000000 --- a/src/components/node-list/node-list-groups.js +++ /dev/null @@ -1,60 +0,0 @@ -import React, { useState } from 'react'; -import { loadLocalStorage, saveLocalStorage } from '../../store/helpers'; -import NodeListGroup from './node-list-group'; -import { localStorageName } from '../../config'; - -const storedState = loadLocalStorage(localStorageName); - -const NodeListGroups = ({ - groups, - items, - onGroupToggleChanged, - onItemChange, - onItemClick, - onItemMouseEnter, - onItemMouseLeave, - searchValue, -}) => { - const [collapsed, setCollapsed] = useState(storedState.groupsCollapsed || {}); - - // Collapse/expand node group - const onToggleGroupCollapsed = (groupID) => { - const groupsCollapsed = { - ...collapsed, - [groupID]: !collapsed[groupID], - }; - - setCollapsed(groupsCollapsed); - saveLocalStorage(localStorageName, { groupsCollapsed }); - }; - - return ( - - ); -}; - -export default NodeListGroups; diff --git a/src/components/node-list/node-list-groups.test.js b/src/components/node-list/node-list-groups.test.js deleted file mode 100644 index abaa7d4d52..0000000000 --- a/src/components/node-list/node-list-groups.test.js +++ /dev/null @@ -1,49 +0,0 @@ -import React from 'react'; -import NodeListGroups from './node-list-groups'; -import { mockState, setup } from '../../utils/state.mock'; -import { getNodeTypes } from '../../selectors/node-types'; -import { getGroupedNodes } from '../../selectors/nodes'; -import { getGroups } from './node-list-items'; - -describe('NodeListGroups', () => { - const mockProps = () => { - const items = getGroupedNodes(mockState.spaceflights); - const nodeTypes = getNodeTypes(mockState.spaceflights); - const groups = getGroups({ nodeTypes, items }); - return { items, groups }; - }; - - it('renders without throwing', () => { - expect(() => - setup.mount() - ).not.toThrow(); - }); - - it('handles collapse button click events', () => { - const wrapper = setup.mount(); - const nodeList = () => - wrapper.find('.pipeline-nodelist__list--nested').first(); - const toggle = () => wrapper.find('.pipeline-type-group-toggle').first(); - expect(nodeList().length).toBe(1); - expect(toggle().hasClass('pipeline-type-group-toggle--alt')).toBe(false); - expect(() => { - toggle().hasClass('pipeline-type-group-toggle--disabled').toBe(false); - toggle().simulate('click'); - expect(nodeList().length).toBe(1); - expect(toggle().hasClass('pipeline-type-group-toggle--alt')).toBe(true); - }).toThrow(); - }); - - it('handles group checkbox change events', () => { - const onGroupToggleChanged = jest.fn(); - const wrapper = setup.mount( - - ); - const checkbox = () => wrapper.find('input').first(); - checkbox().simulate('change', { target: { checked: false } }); - expect(onGroupToggleChanged.mock.calls.length).toEqual(1); - }); -}); diff --git a/src/components/node-list/node-list.js b/src/components/node-list/node-list.js index 08f415b4fb..ad3b7690f5 100644 --- a/src/components/node-list/node-list.js +++ b/src/components/node-list/node-list.js @@ -2,7 +2,7 @@ import React from 'react'; import classnames from 'classnames'; import { Scrollbars } from 'react-custom-scrollbars-2'; import SearchList from '../search-list'; -import NodeListGroups from './node-list-groups'; +import Filters from '../filters/filters'; import NodeListTree from './node-list-tree'; import SplitPanel from '../split-panel'; @@ -21,6 +21,8 @@ const NodeList = ({ getGroupState, onUpdateSearchValue, onGroupToggleChanged, + onToggleGroupCollapsed, + groupCollapsed, onItemClick, onItemMouseEnter, onItemMouseLeave, @@ -83,28 +85,20 @@ const NodeList = ({ autoHide hideTracksWhenNotNeeded > -
    -

    - Filters -

    - -
    - diff --git a/src/components/node-list/node-list.test.js b/src/components/node-list/node-list.test.js index 83305c5af9..be2054c364 100644 --- a/src/components/node-list/node-list.test.js +++ b/src/components/node-list/node-list.test.js @@ -31,7 +31,7 @@ describe('NodeList', () => { ); const search = wrapper.find('.pipeline-search-list'); - const nodeList = wrapper.find('.pipeline-nodelist__list'); + const nodeList = wrapper.find('.filters__section-wrapper'); expect(search.length).toBe(1); expect(nodeList.length).toBeGreaterThan(0); }); @@ -255,8 +255,7 @@ describe('NodeList', () => { return elements(wrapper).filter(([_, enabled]) => enabled); }; - const tagItem = (wrapper) => - wrapper.find('.pipeline-nodelist__group--type-tag'); + const tagItem = (wrapper) => wrapper.find('.filters-section--type-tag'); const partialIcon = (wrapper) => tagItem(wrapper).find(IndicatorPartialIcon); @@ -288,7 +287,7 @@ describe('NodeList', () => { ); - const uncheckedClass = 'pipeline-nodelist__group--all-unchecked'; + const uncheckedClass = 'filters-section--all-unchecked'; expect(tagItem(wrapper).hasClass(uncheckedClass)).toBe(true); changeRows(wrapper, ['Preprocessing'], true); @@ -373,10 +372,7 @@ describe('NodeList', () => { ); - const nodeList = wrapper.find( - '.pipeline-nodelist__list--nested .node-list-filter-row' - ); - // const nodes = getNodeData(mockState.spaceflights); + const nodeList = wrapper.find('.filters-group .node-list-filter-row'); const tags = getTagData(mockState.spaceflights); const elementTypes = Object.keys(sidebarElementTypes); expect(nodeList.length).toBe(tags.length + elementTypes.length); @@ -462,9 +458,7 @@ describe('NodeList', () => { ); - const resetFilterButton = wrapper.find( - '.pipeline-nodelist-section__reset-filter' - ); + const resetFilterButton = wrapper.find('.filters__reset-button'); it('On first load before applying filter button should be disabled', () => { expect(resetFilterButton.prop('disabled')).toBe(true); diff --git a/src/components/node-list/styles/_group.scss b/src/components/node-list/styles/_group.scss deleted file mode 100644 index f9eb5d66f8..0000000000 --- a/src/components/node-list/styles/_group.scss +++ /dev/null @@ -1,138 +0,0 @@ -@use '../../../styles/variables' as var; -@use './variables'; - -%nolist { - margin: 0; - padding: 0; - list-style: none; -} - -.pipeline-nodelist__list { - @extend %nolist; - - &--nested { - margin: 0 0 1.2em; - } - - .pipeline-nodelist__children { - // Avoid placeholder fade leaking out for small lists - overflow: hidden; - - &--closed { - display: none; - } - } -} - -$placeholder-fade: 120px; - -.pipeline-nodelist__heading { - position: sticky; - top: 0; - z-index: var.$zindex-nodelist-heading; - margin: 0; - - // Avoid pixel gap above when scrolling. - transform: translateY(-1px); - - .pipeline-nodelist__row__text, - .row-text { - position: relative; - opacity: 0.65; - } - - .row-text .row-text__label { - font-size: 1.3em; - } -} - -.pipeline-nodelist__elements-panel .pipeline-nodelist__heading { - background: var(--color-nodelist-element-panel); - - &::after { - position: absolute; - bottom: -19px; - z-index: var.$zindex-group-background-fade; - width: 100%; - height: 20px; - background: linear-gradient( - 0deg, - var(--color-nodelist-bg-transparent) 0%, - var(--color-nodelist-element-panel) 100% - ); - transition: opacity ease 0.3s; - content: ' '; - pointer-events: none; - } -} - -.pipeline-nodelist__filter-panel .pipeline-nodelist__heading { - background: var(--color-nodelist-filter-panel); - - &::after { - position: absolute; - bottom: -19px; - z-index: var.$zindex-group-background-fade; - width: 100%; - height: 20px; - background: linear-gradient( - 0deg, - var(--color-nodelist-bg-transparent) 0%, - var(--color-nodelist-filter-panel) 100% - ); - transition: opacity ease 0.3s; - content: ' '; - pointer-events: none; - } -} - -.pipeline-type-group-toggle { - width: variables.$toggle-size; - height: variables.$toggle-size; - padding: 0; - color: var(--color-default-alt); - font-size: inherit; - font-family: inherit; - line-height: 1em; - text-align: center; - background: none; - border: none; - border-radius: 50%; - box-shadow: none; - cursor: pointer; - transition: transform ease 0.1s; - - &:focus { - outline: none; - - [data-whatintent='keyboard'] & { - box-shadow: 0 0 0 3px var.$blue-300 inset; - } - } - - &::before { - font-size: 1.8em; - opacity: 0.55; - content: 'â–¾'; - } - - &:hover::before { - opacity: 1; - } - - &--alt { - transform: rotate(90deg); - } - - &--disabled { - color: var.$black-400; - transform: rotate(90deg); - } -} - -// Bright row text when the parent groups are all unchecked -.pipeline-nodelist__group--all-unchecked { - .row-text__label--kind-filter { - opacity: 1; - } -} diff --git a/src/components/node-list/styles/node-list.scss b/src/components/node-list/styles/node-list.scss index 7cffa2b13b..4ebf9cf0ff 100644 --- a/src/components/node-list/styles/node-list.scss +++ b/src/components/node-list/styles/node-list.scss @@ -1,8 +1,6 @@ @use '../../../styles/mixins' as mixins; @use '../../../styles/variables' as colors; -@use './group'; @use './panels'; -@use './section'; @use './variables'; .kui-theme--light { diff --git a/src/components/sliced-pipeline-action-bar/sliced-pipeline-action-bar.test.js b/src/components/sliced-pipeline-action-bar/sliced-pipeline-action-bar.test.js index 95e8bfecb9..a96681e509 100644 --- a/src/components/sliced-pipeline-action-bar/sliced-pipeline-action-bar.test.js +++ b/src/components/sliced-pipeline-action-bar/sliced-pipeline-action-bar.test.js @@ -1,6 +1,6 @@ // sliced-pipeline-action-bar.test.js import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { render } from '@testing-library/react'; import { SlicedPipelineActionBar } from './sliced-pipeline-action-bar'; describe('SlicedPipelineActionBar', () => { diff --git a/src/components/ui/toggle-control/toggle-control.scss b/src/components/ui/toggle-control/toggle-control.scss index 181770d3ad..68ffd3075c 100644 --- a/src/components/ui/toggle-control/toggle-control.scss +++ b/src/components/ui/toggle-control/toggle-control.scss @@ -146,13 +146,6 @@ $filter-icon-opacity-3: 1; opacity: $filter-icon-opacity-1; } - &.toggle-control--icon--all-unchecked, - .pipeline-nodelist__heading &.toggle-control--icon--all-unchecked > * { - > * { - opacity: $filter-icon-opacity-0; - } - } - .node-list-tree-item-row:hover & { > * { opacity: $filter-icon-opacity-1; From 2f46dc13204c09954f99500fdca18745a93278ba Mon Sep 17 00:00:00 2001 From: Sajid Alam <90610031+SajidAlamQB@users.noreply.github.com> Date: Fri, 8 Nov 2024 09:26:01 +0000 Subject: [PATCH 3/9] Fix `Unable to get file size` when dataset has no protocol attribute (#2174) * add protocol check Signed-off-by: Sajid Alam * add test Signed-off-by: Sajid Alam * lint Signed-off-by: Sajid Alam * Update hooks.py Signed-off-by: Sajid Alam * add fallback to private attritbute for known datasets Signed-off-by: Sajid Alam * coverage Signed-off-by: Sajid Alam * Update RELEASE.md Signed-off-by: Sajid Alam * Update hooks.py Signed-off-by: Sajid Alam * coverage Signed-off-by: Sajid Alam * Update RELEASE.md Signed-off-by: Sajid Alam --------- Signed-off-by: Sajid Alam --- RELEASE.md | 1 + package/kedro_viz/integrations/kedro/hooks.py | 28 ++++++++----- package/tests/test_integrations/test_hooks.py | 41 +++++++++++++++++++ 3 files changed, 60 insertions(+), 10 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 3261588f52..5be798e332 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -21,6 +21,7 @@ Please follow the established format: - Display full dataset type with library prefix in metadata panel (#2136) - Enable SQLite WAL mode for Azure ML to fix database locking issues (#2131) - Replace `flake8`, `isort`, `pylint` and `black` by `ruff` (#2149) +- Refactor `DatasetStatsHook` to avoid showing error when dataset doesn't have file size info (#2174) # Release 10.0.0 diff --git a/package/kedro_viz/integrations/kedro/hooks.py b/package/kedro_viz/integrations/kedro/hooks.py index 3089e61f50..88bc5be17a 100644 --- a/package/kedro_viz/integrations/kedro/hooks.py +++ b/package/kedro_viz/integrations/kedro/hooks.py @@ -7,6 +7,7 @@ from pathlib import Path, PurePosixPath from typing import Any, Union +import fsspec from kedro.framework.hooks import hook_impl from kedro.io import DataCatalog from kedro.io.core import get_filepath_str @@ -141,19 +142,26 @@ def get_file_size(self, dataset: Any) -> Union[int, None]: Args: dataset: A dataset instance for which we need the file size - Returns: file size for the dataset if file_path is valid, if not returns None + Returns: + File size for the dataset if available, otherwise None. """ - - if not (hasattr(dataset, "_filepath") and dataset._filepath): - return None - try: - file_path = get_filepath_str( - PurePosixPath(dataset._filepath), dataset._protocol - ) - return dataset._fs.size(file_path) + if hasattr(dataset, "filepath") and dataset.filepath: + filepath = dataset.filepath + # Fallback to private '_filepath' for known datasets + elif hasattr(dataset, "_filepath") and dataset._filepath: + filepath = dataset._filepath + else: + return None + + fs, path_in_fs = fsspec.core.url_to_fs(filepath) + if fs.exists(path_in_fs): + file_size = fs.size(path_in_fs) + return file_size + else: + return None - except Exception as exc: + except Exception as exc: # pragma: no cover logger.warning( "Unable to get file size for the dataset %s: %s", dataset, exc ) diff --git a/package/tests/test_integrations/test_hooks.py b/package/tests/test_integrations/test_hooks.py index 2f6d7dd132..600c594d15 100644 --- a/package/tests/test_integrations/test_hooks.py +++ b/package/tests/test_integrations/test_hooks.py @@ -137,3 +137,44 @@ def test_get_file_size(dataset, example_dataset_stats_hook_obj, example_csv_data assert example_dataset_stats_hook_obj.get_file_size( example_csv_dataset ) == example_csv_dataset._fs.size(file_path) + + +def test_get_file_size_file_does_not_exist(example_dataset_stats_hook_obj, mocker): + class MockDataset: + def __init__(self): + self._filepath = "/non/existent/path.csv" + + mock_dataset = MockDataset() + mock_fs = mocker.Mock() + mock_fs.exists.return_value = False + + mocker.patch( + "fsspec.core.url_to_fs", + return_value=(mock_fs, "/non/existent/path.csv"), + ) + + # Call get_file_size and expect it to return None + file_size = example_dataset_stats_hook_obj.get_file_size(mock_dataset) + assert file_size is None + + +def test_get_file_size_public_filepath(example_dataset_stats_hook_obj, mocker): + class MockDataset: + def __init__(self): + self.filepath = "/path/to/existing/file.csv" + + mock_dataset = MockDataset() + + # Mock fs.exists to return True + mock_fs = mocker.Mock() + mock_fs.exists.return_value = True + mock_fs.size.return_value = 456 + + mocker.patch( + "fsspec.core.url_to_fs", + return_value=(mock_fs, "/path/to/existing/file.csv"), + ) + + # Call get_file_size and expect it to return the mocked file size + file_size = example_dataset_stats_hook_obj.get_file_size(mock_dataset) + assert file_size == 456 From ccfd9cd1b69082b4299eed636f8e0b3ea64d34a1 Mon Sep 17 00:00:00 2001 From: Merel Theisen <49397448+merelcht@users.noreply.github.com> Date: Fri, 8 Nov 2024 11:25:27 +0100 Subject: [PATCH 4/9] Add Wizard GH actions to automate labelling and closing (#2180) * Add Wizard GH actions to automate labelling and closing Signed-off-by: Merel Theisen --- .github/workflows/label-community-issues.yml | 48 ++++++++++++++++++++ .github/workflows/no-response.yml | 20 ++++++++ 2 files changed, 68 insertions(+) create mode 100644 .github/workflows/label-community-issues.yml create mode 100644 .github/workflows/no-response.yml diff --git a/.github/workflows/label-community-issues.yml b/.github/workflows/label-community-issues.yml new file mode 100644 index 0000000000..e1c1ce1180 --- /dev/null +++ b/.github/workflows/label-community-issues.yml @@ -0,0 +1,48 @@ +name: Label Community Issues + +on: + issues: + types: + - opened + +jobs: + label: + runs-on: ubuntu-latest + steps: + - name: Check if issue author is a member of Kedro org + uses: actions/github-script@v6 + id: membership + with: + github-token: ${{ secrets.GH_TAGGING_TOKEN }} + result-encoding: string + script: | + + try { + const result = await github.rest.orgs.getMembershipForUser({ + org: "kedro-org", + username: '${{ github.actor }}' + }) + + console.log(result?.data?.state) + if (result?.data?.state == "active"){ + console.log("%s: detected as an active member of Kedro org", '${{ github.actor }}') + return "member"; + } else { + console.log("%s: not detected as active member of Kedro org", '${{ github.actor }}') + return "notMember"; + } + + } catch (error) { + console.log("%s: Error occured and marked user as notMember", '${{ github.actor }}') + console.log("Error", error.stack); + console.log("Error", error.name); + console.log("Error", error.message); + return "notMember"; + } + + - name: Label issue if author is from community + if: ${{ steps.membership.outputs.result == 'notMember' }} + uses: actions-ecosystem/action-add-labels@v1 + with: + github_token: ${{ secrets.GH_TAGGING_TOKEN }} + labels: 'Community' diff --git a/.github/workflows/no-response.yml b/.github/workflows/no-response.yml new file mode 100644 index 0000000000..b11c9be736 --- /dev/null +++ b/.github/workflows/no-response.yml @@ -0,0 +1,20 @@ +name: No Response + +on: + issue_comment: + types: [created] + schedule: + # Run every day at 9am (UTC time) + - cron: '0 9 * * *' + +jobs: + noResponse: + runs-on: ubuntu-latest + steps: + - uses: lee-dohm/no-response@v0.5.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + responseRequiredLabel: "support: needs more info" + daysUntilClose: 28 + closeComment: >- + This issue has been closed due to lack of information. Feel free to re-open this issue if you're facing a similar problem. Please provide as much information as possible so we can help resolve your issue. From 11f16087ebc6729e1112880975145f7f2c0fbade Mon Sep 17 00:00:00 2001 From: Sajid Alam <90610031+SajidAlamQB@users.noreply.github.com> Date: Fri, 8 Nov 2024 12:43:32 +0000 Subject: [PATCH 5/9] Add port in use check to prevent browser redirecting incorrectly for kedro viz (#2176) * add check if port is in use Signed-off-by: Sajid Alam * Update RELEASE.md Signed-off-by: Sajid Alam * automatically increment port Signed-off-by: Sajid Alam * fix unit tests Signed-off-by: Sajid Alam * add port occupied test Signed-off-by: Sajid Alam * coverage Signed-off-by: Sajid Alam * speed up test by mocking _is_port_in_use to return false Signed-off-by: Sajid Alam --------- Signed-off-by: Sajid Alam Signed-off-by: Sajid Alam <90610031+SajidAlamQB@users.noreply.github.com> --- RELEASE.md | 1 + package/kedro_viz/launchers/cli/run.py | 5 +++- package/kedro_viz/launchers/utils.py | 29 +++++++++++++++++++ .../tests/test_launchers/test_cli/test_run.py | 19 +++++++++++- package/tests/test_launchers/test_utils.py | 18 ++++++++++++ 5 files changed, 70 insertions(+), 2 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 5be798e332..e7e50b88dc 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -22,6 +22,7 @@ Please follow the established format: - Enable SQLite WAL mode for Azure ML to fix database locking issues (#2131) - Replace `flake8`, `isort`, `pylint` and `black` by `ruff` (#2149) - Refactor `DatasetStatsHook` to avoid showing error when dataset doesn't have file size info (#2174) +- Add check for port availability before starting Kedro Viz to prevent unintended browser redirects when the port is already in use (#2176) # Release 10.0.0 diff --git a/package/kedro_viz/launchers/cli/run.py b/package/kedro_viz/launchers/cli/run.py index b2e74a48be..e4093b940f 100644 --- a/package/kedro_viz/launchers/cli/run.py +++ b/package/kedro_viz/launchers/cli/run.py @@ -115,6 +115,7 @@ def run( from kedro_viz.launchers.utils import ( _PYPROJECT, _check_viz_up, + _find_available_port, _find_kedro_project, _start_browser, _wait_for, @@ -145,6 +146,9 @@ def run( "https://github.com/kedro-org/kedro-viz/releases.", "yellow", ) + + port = _find_available_port(host, port) + try: if port in _VIZ_PROCESSES and _VIZ_PROCESSES[port].is_alive(): _VIZ_PROCESSES[port].terminate() @@ -186,7 +190,6 @@ def run( ) display_cli_message("Starting Kedro Viz ...", "green") - viz_process.start() _VIZ_PROCESSES[port] = viz_process diff --git a/package/kedro_viz/launchers/utils.py b/package/kedro_viz/launchers/utils.py index 5c6bbae9e3..50f8e6e849 100644 --- a/package/kedro_viz/launchers/utils.py +++ b/package/kedro_viz/launchers/utils.py @@ -2,6 +2,8 @@ used in the `kedro_viz.launchers` package.""" import logging +import socket +import sys import webbrowser from pathlib import Path from time import sleep, time @@ -80,6 +82,33 @@ def _check_viz_up(host: str, port: int): return response.status_code == 200 +def _is_port_in_use(host: str, port: int): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + return s.connect_ex((host, port)) == 0 + + +def _find_available_port(host: str, start_port: int, max_attempts: int = 5) -> int: + max_port = start_port + max_attempts - 1 + port = start_port + while port <= max_port: + if not _is_port_in_use(host, port): + return port + display_cli_message( + f"Port {port} is already in use. Trying the next port...", + "yellow", + ) + port += 1 + display_cli_message( + f"Error: All ports in the range {start_port}-{max_port} are in use.", + "red", + ) + display_cli_message( + "Please specify a different port using the '--port' option.", + "red", + ) + sys.exit(1) + + def _is_localhost(host: str) -> bool: """Check whether a host is a localhost""" return host in ("127.0.0.1", "localhost", "0.0.0.0") diff --git a/package/tests/test_launchers/test_cli/test_run.py b/package/tests/test_launchers/test_cli/test_run.py index 86adae92f6..95a809d2ed 100644 --- a/package/tests/test_launchers/test_cli/test_run.py +++ b/package/tests/test_launchers/test_cli/test_run.py @@ -10,7 +10,7 @@ from kedro_viz.autoreload_file_filter import AutoreloadFileFilter from kedro_viz.launchers.cli import main from kedro_viz.launchers.cli.run import _VIZ_PROCESSES -from kedro_viz.launchers.utils import _PYPROJECT +from kedro_viz.launchers.utils import _PYPROJECT, _find_available_port from kedro_viz.server import run_server @@ -217,6 +217,9 @@ def test_kedro_viz_command_run_server( "kedro_viz.launchers.utils._wait_for.__defaults__", (True, 1, True, 1) ) + # Mock _is_port_in_use to speed up test. + mocker.patch("kedro_viz.launchers.utils._is_port_in_use", return_value=False) + # Mock finding kedro project mocker.patch( "kedro_viz.launchers.utils._find_kedro_project", @@ -394,3 +397,17 @@ def test_kedro_viz_command_with_autoreload( kwargs={**run_process_kwargs}, ) assert run_process_kwargs["kwargs"]["port"] in _VIZ_PROCESSES + + # Test case to simulate port occupation and check available port selection + def test_find_available_port_with_occupied_ports(self, mocker): + mock_is_port_in_use = mocker.patch("kedro_viz.launchers.utils._is_port_in_use") + + # Mock ports 4141, 4142 being occupied and 4143 is free + mock_is_port_in_use.side_effect = [True, True, False] + + available_port = _find_available_port("127.0.0.1", 4141) + + # Assert that the function returns the first free port, 4143 + assert ( + available_port == 4143 + ), "Expected port 4143 to be returned as the available port" diff --git a/package/tests/test_launchers/test_utils.py b/package/tests/test_launchers/test_utils.py index 83e9203bd3..fd2043af75 100644 --- a/package/tests/test_launchers/test_utils.py +++ b/package/tests/test_launchers/test_utils.py @@ -7,6 +7,7 @@ from kedro_viz.launchers.utils import ( _check_viz_up, + _find_available_port, _find_kedro_project, _is_project, _start_browser, @@ -99,3 +100,20 @@ def test_toml_bad_encoding(self, mocker): def test_find_kedro_project(project_dir, is_project_found, expected, mocker): mocker.patch("kedro_viz.launchers.utils._is_project", return_value=is_project_found) assert _find_kedro_project(Path(project_dir)) == expected + + +def test_find_available_port_all_ports_occupied(mocker): + mocker.patch("kedro_viz.launchers.utils._is_port_in_use", return_value=True) + mock_display_message = mocker.patch("kedro_viz.launchers.utils.display_cli_message") + + # Check for SystemExit when all ports are occupied + with pytest.raises(SystemExit) as exit_exception: + _find_available_port("127.0.0.1", 4141, max_attempts=5) + assert exit_exception.value.code == 1 + + mock_display_message.assert_any_call( + "Error: All ports in the range 4141-4145 are in use.", "red" + ) + mock_display_message.assert_any_call( + "Please specify a different port using the '--port' option.", "red" + ) From 852e1e1f68655c114901daeb09d477774f0f9214 Mon Sep 17 00:00:00 2001 From: Jitendra Gundaniya <38945204+jitu5@users.noreply.github.com> Date: Mon, 11 Nov 2024 14:11:22 +0000 Subject: [PATCH 6/9] Handle Github pages 404 issue (#2179) * Handle Github pages 404 issue Signed-off-by: Jitendra Gundaniya * Release note added Signed-off-by: Jitendra Gundaniya * Handling other faulty urls Signed-off-by: Jitendra Gundaniya * Handle other faulty urls Signed-off-by: Jitendra Gundaniya * Lint fix Signed-off-by: Jitendra Gundaniya * Redirection logic moved from flowchart-wrapper to wrapper component Signed-off-by: Jitendra Gundaniya * 404.html remove and coping index.html to 404.html used Signed-off-by: Jitendra Gundaniya * Lint fix Signed-off-by: Jitendra Gundaniya --------- Signed-off-by: Jitendra Gundaniya --- RELEASE.md | 1 + package.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/RELEASE.md b/RELEASE.md index e7e50b88dc..61cb49bf9b 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -22,6 +22,7 @@ Please follow the established format: - Enable SQLite WAL mode for Azure ML to fix database locking issues (#2131) - Replace `flake8`, `isort`, `pylint` and `black` by `ruff` (#2149) - Refactor `DatasetStatsHook` to avoid showing error when dataset doesn't have file size info (#2174) +- Fix 404 error when accessing the experiment tracking page on the demo site (#2179) - Add check for port availability before starting Kedro Viz to prevent unintended browser redirects when the port is already in use (#2176) diff --git a/package.json b/package.json index 5883aa9594..6ad5e32dce 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ }, "proxy": "http://localhost:4142/", "scripts": { - "build": "cross-env GENERATE_SOURCEMAP=false react-scripts build", + "build": "cross-env GENERATE_SOURCEMAP=false react-scripts build && cp ./build/index.html ./build/404.html", "postbuild": "rm -rf build/api", "start": "REACT_APP_DATA_SOURCE=$DATA NODE_OPTIONS=\"--dns-result-order=ipv4first\" npm-run-all -p start:app start:lib", "start:dev": "rm -rf node_modules/.cache && npm start", From 49c91836166e60e5863cff1b5382f4b6ed1324c6 Mon Sep 17 00:00:00 2001 From: Sajid Alam <90610031+SajidAlamQB@users.noreply.github.com> Date: Tue, 12 Nov 2024 14:27:21 +0000 Subject: [PATCH 7/9] Update Kedro-viz architecture diagram (#2183) * update architecture diagram and add link to full diagram Signed-off-by: Sajid Alam * update based on reviews Signed-off-by: Sajid Alam * Update ARCHITECTURE.md Signed-off-by: Sajid Alam --------- Signed-off-by: Sajid Alam --- .github/img/backend-architecture.png | Bin 0 -> 141908 bytes .github/img/frontend-architecture.png | Bin 0 -> 67996 bytes ARCHITECTURE.md | 9 ++++++++- 3 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 .github/img/backend-architecture.png create mode 100644 .github/img/frontend-architecture.png diff --git a/.github/img/backend-architecture.png b/.github/img/backend-architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..3451638ab1f773f1086329f316dbc38678ed04ad GIT binary patch literal 141908 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 Date: Thu, 14 Nov 2024 10:02:13 +0000 Subject: [PATCH 8/9] Refactor/node list index (#2178) * foundation for FiltersContext Signed-off-by: Huong Nguyen * remove unused props Signed-off-by: Huong Nguyen * node-list-context Signed-off-by: Huong Nguyen * restructure node-list-item as a helper function Signed-off-by: Huong Nguyen * rename selectors Signed-off-by: Huong Nguyen * rename functions in FiltersContext Signed-off-by: Huong Nguyen * move redux selector to node-list-context Signed-off-by: Huong Nguyen * fixing the hovered node issue Signed-off-by: Huong Nguyen * move getFilteredItems to selector Signed-off-by: Huong Nguyen * fix the modularpipeline highlight issue Signed-off-by: Huong Nguyen * Adding test for selector Signed-off-by: Huong Nguyen * update tests Signed-off-by: Huong Nguyen * update names to be nodes-panel Signed-off-by: Huong Nguyen * Fixing the filters problem Signed-off-by: Huong Nguyen * update test Signed-off-by: Huong Nguyen * fixing the highlight issue through getNodesActive Signed-off-by: Huong Nguyen * move node-list-tree to its own component Signed-off-by: Huong Nguyen * update row to node-list-row Signed-off-by: Huong Nguyen * move style to be inside node-list-tree Signed-off-by: Huong Nguyen * fix the filters URL update Signed-off-by: Huong Nguyen * update name for nodes panel context Signed-off-by: Huong Nguyen --------- Signed-off-by: Huong Nguyen Co-authored-by: Huong Nguyen --- .../filters/filters-group/filters-group.js | 12 +- .../filters/filters-group/filters-group.scss | 2 +- .../filters-group/filters-group.test.js | 2 +- .../filters/filters-row/filters-row.scss | 2 +- .../filters-section-heading.scss | 2 +- .../filters-section-heading.test.js | 2 +- .../filters-section/filters-section.js | 6 - .../filters-section/filters-section.test.js | 2 +- src/components/filters/filters.js | 6 - src/components/filters/filters.scss | 2 +- src/components/filters/filters.test.js | 2 +- .../node-list-row/node-list-row.js} | 56 ++- .../node-list-row/node-list-row.scss} | 26 +- .../node-list-row/node-list-row.test.js | 78 ++++ .../node-list-tree-item.js | 6 +- .../node-list-tree.js | 37 +- .../styles/_panels.scss | 0 .../styles/_variables.scss | 0 .../styles/node-list.scss | 2 +- .../node-list/components/row/row.test.js | 66 --- src/components/node-list/index.js | 410 ------------------ src/components/node-list/node-list.js | 112 ----- src/components/nodes-panel/index.js | 18 + src/components/nodes-panel/nodes-panel.js | 138 ++++++ .../nodes-panel.test.js} | 64 +-- .../nodes-panel/utils/filters-context.js | 251 +++++++++++ .../nodes-panel/utils/node-list-context.js | 226 ++++++++++ .../nodes-panel/utils/nodes-panel-context.js | 11 + src/components/sidebar/sidebar.js | 4 +- src/components/ui/row-text/row-text.scss | 2 +- .../ui/toggle-control/toggle-control.scss | 2 +- .../filtered-node-list-item.test.js} | 33 +- .../filtered-node-list-items.js} | 47 +- src/selectors/nodes.js | 37 +- 34 files changed, 888 insertions(+), 778 deletions(-) rename src/components/{node-list/components/row/row.js => node-list-tree/node-list-row/node-list-row.js} (61%) rename src/components/{node-list/components/row/row.scss => node-list-tree/node-list-row/node-list-row.scss} (71%) create mode 100644 src/components/node-list-tree/node-list-row/node-list-row.test.js rename src/components/{node-list => node-list-tree/node-list-tree-item}/node-list-tree-item.js (93%) rename src/components/{node-list => node-list-tree}/node-list-tree.js (90%) rename src/components/{node-list => node-list-tree}/styles/_panels.scss (100%) rename src/components/{node-list => node-list-tree}/styles/_variables.scss (100%) rename src/components/{node-list => node-list-tree}/styles/node-list.scss (99%) delete mode 100644 src/components/node-list/components/row/row.test.js delete mode 100644 src/components/node-list/index.js delete mode 100644 src/components/node-list/node-list.js create mode 100644 src/components/nodes-panel/index.js create mode 100644 src/components/nodes-panel/nodes-panel.js rename src/components/{node-list/node-list.test.js => nodes-panel/nodes-panel.test.js} (91%) create mode 100644 src/components/nodes-panel/utils/filters-context.js create mode 100644 src/components/nodes-panel/utils/node-list-context.js create mode 100644 src/components/nodes-panel/utils/nodes-panel-context.js rename src/{components/node-list/node-list-items.test.js => selectors/filtered-node-list-item.test.js} (93%) rename src/{components/node-list/node-list-items.js => selectors/filtered-node-list-items.js} (85%) diff --git a/src/components/filters/filters-group/filters-group.js b/src/components/filters/filters-group/filters-group.js index d2b0076860..2edb9af1eb 100644 --- a/src/components/filters/filters-group/filters-group.js +++ b/src/components/filters/filters-group/filters-group.js @@ -8,13 +8,7 @@ import { getDataTestAttribute } from '../../../utils/get-data-test-attribute'; import './filters-group.scss'; /** A group collection of FiltersRow */ -const FiltersGroup = ({ - items = [], - group, - collapsed, - onItemClick, - onItemChange, -}) => ( +const FiltersGroup = ({ items = [], group, collapsed, onItemChange }) => ( (end - start) * nodeListRowHeight} total={items.length} @@ -40,8 +34,8 @@ const FiltersGroup = ({ kind={group.kind} label={item.highlightedLabel} name={item.name} - onChange={(e) => onItemChange(item, !e.target.checked)} - onClick={() => onItemClick(item)} + onChange={(e) => onItemChange(e, item)} + onClick={(e) => onItemChange(e, item)} parentClassName={'node-list-filter-row'} visible={item.visible} indicatorIcon={item.visibleIcon} diff --git a/src/components/filters/filters-group/filters-group.scss b/src/components/filters/filters-group/filters-group.scss index 00dc8045f9..c36a015442 100644 --- a/src/components/filters/filters-group/filters-group.scss +++ b/src/components/filters/filters-group/filters-group.scss @@ -1,5 +1,5 @@ @use '../../../styles/variables' as var; -@use '../../node-list/styles/variables'; +@use '../../node-list-tree/styles/variables'; .filters-group { list-style: none; diff --git a/src/components/filters/filters-group/filters-group.test.js b/src/components/filters/filters-group/filters-group.test.js index edb6d682f9..7f91be5ca0 100644 --- a/src/components/filters/filters-group/filters-group.test.js +++ b/src/components/filters/filters-group/filters-group.test.js @@ -3,7 +3,7 @@ import FiltersGroup from './filters-group'; import { mockState, setup } from '../../../utils/state.mock'; import { getNodeTypes } from '../../../selectors/node-types'; import { getGroupedNodes } from '../../../selectors/nodes'; -import { getGroups } from '../../node-list/node-list-items'; +import { getGroups } from '../../../selectors/filtered-node-list-items'; describe('FiltersGroup Component', () => { const mockProps = () => { diff --git a/src/components/filters/filters-row/filters-row.scss b/src/components/filters/filters-row/filters-row.scss index 43a3d657b3..3f25875237 100644 --- a/src/components/filters/filters-row/filters-row.scss +++ b/src/components/filters/filters-row/filters-row.scss @@ -1,5 +1,5 @@ @use '../../../styles/variables' as var; -@use '../../node-list/styles/variables'; +@use '../../node-list-tree/styles/variables'; .MuiTreeItem-iconContainer svg { z-index: var.$zindex-MuiTreeItem-icon; diff --git a/src/components/filters/filters-section-heading/filters-section-heading.scss b/src/components/filters/filters-section-heading/filters-section-heading.scss index ce0644bb48..cdd1ea8dc1 100644 --- a/src/components/filters/filters-section-heading/filters-section-heading.scss +++ b/src/components/filters/filters-section-heading/filters-section-heading.scss @@ -1,5 +1,5 @@ @use '../../../styles/variables' as var; -@use '../../node-list/styles/variables'; +@use '../../node-list-tree/styles/variables'; .filters-section-heading { background: var(--color-nodelist-filter-panel); diff --git a/src/components/filters/filters-section-heading/filters-section-heading.test.js b/src/components/filters/filters-section-heading/filters-section-heading.test.js index af752fd847..84c57d603d 100755 --- a/src/components/filters/filters-section-heading/filters-section-heading.test.js +++ b/src/components/filters/filters-section-heading/filters-section-heading.test.js @@ -3,7 +3,7 @@ import FiltersSectionHeading from './filters-section-heading'; import { mockState, setup } from '../../../utils/state.mock'; import { getNodeTypes } from '../../../selectors/node-types'; import { getGroupedNodes } from '../../../selectors/nodes'; -import { getGroups } from '../../node-list/node-list-items'; +import { getGroups } from '../../../selectors/filtered-node-list-items'; describe('FiltersSectionHeading', () => { const mockProps = () => { diff --git a/src/components/filters/filters-section/filters-section.js b/src/components/filters/filters-section/filters-section.js index 0a16489c20..808aee952e 100644 --- a/src/components/filters/filters-section/filters-section.js +++ b/src/components/filters/filters-section/filters-section.js @@ -12,9 +12,6 @@ const FiltersSection = ({ items, onGroupToggleChanged, onItemChange, - onItemClick, - onItemMouseEnter, - onItemMouseLeave, onToggleGroupCollapsed, searchValue, }) => { @@ -41,9 +38,6 @@ const FiltersSection = ({ group={group} items={groupItems} onItemChange={onItemChange} - onItemClick={onItemClick} - onItemMouseEnter={onItemMouseEnter} - onItemMouseLeave={onItemMouseLeave} /> ); diff --git a/src/components/filters/filters-section/filters-section.test.js b/src/components/filters/filters-section/filters-section.test.js index 57241db95c..6c476e32cd 100755 --- a/src/components/filters/filters-section/filters-section.test.js +++ b/src/components/filters/filters-section/filters-section.test.js @@ -3,7 +3,7 @@ import FiltersSection from './filters-section'; import { mockState, setup } from '../../../utils/state.mock'; import { getNodeTypes } from '../../../selectors/node-types'; import { getGroupedNodes } from '../../../selectors/nodes'; -import { getGroups } from '../../node-list/node-list-items'; +import { getGroups } from '../../../selectors/filtered-node-list-items'; describe('FiltersSection Component', () => { const mockProps = () => { diff --git a/src/components/filters/filters.js b/src/components/filters/filters.js index 749cd4ac6e..2797ebd33c 100644 --- a/src/components/filters/filters.js +++ b/src/components/filters/filters.js @@ -10,9 +10,6 @@ const Filters = ({ items, onGroupToggleChanged, onItemChange, - onItemClick, - onItemMouseEnter, - onItemMouseLeave, onResetFilter, onToggleGroupCollapsed, searchValue, @@ -41,9 +38,6 @@ const Filters = ({ key={group.id} onGroupToggleChanged={onGroupToggleChanged} onItemChange={onItemChange} - onItemClick={onItemClick} - onItemMouseEnter={onItemMouseEnter} - onItemMouseLeave={onItemMouseLeave} onToggleGroupCollapsed={onToggleGroupCollapsed} searchValue={searchValue} /> diff --git a/src/components/filters/filters.scss b/src/components/filters/filters.scss index dccad4d453..c3b7742276 100644 --- a/src/components/filters/filters.scss +++ b/src/components/filters/filters.scss @@ -1,4 +1,4 @@ -@use '../node-list/styles/variables'; +@use '../node-list-tree/styles/variables'; @use '../../styles/extends'; @use '../../styles/variables' as colors; diff --git a/src/components/filters/filters.test.js b/src/components/filters/filters.test.js index 76af07c597..4b1ac0198b 100644 --- a/src/components/filters/filters.test.js +++ b/src/components/filters/filters.test.js @@ -3,7 +3,7 @@ import Filters from './filters'; import { mockState, setup } from '../../utils/state.mock'; import { getNodeTypes } from '../../selectors/node-types'; import { getGroupedNodes } from '../../selectors/nodes'; -import { getGroups } from '../node-list/node-list-items'; +import { getGroups } from '../../selectors/filtered-node-list-items'; describe('Filters', () => { const mockProps = () => { diff --git a/src/components/node-list/components/row/row.js b/src/components/node-list-tree/node-list-row/node-list-row.js similarity index 61% rename from src/components/node-list/components/row/row.js rename to src/components/node-list-tree/node-list-row/node-list-row.js index 416bcb4947..619bd301c4 100755 --- a/src/components/node-list/components/row/row.js +++ b/src/components/node-list-tree/node-list-row/node-list-row.js @@ -1,15 +1,15 @@ import React from 'react'; import classnames from 'classnames'; -import NodeIcon from '../../../icons/node-icon'; -import VisibleIcon from '../../../icons/visible'; -import InvisibleIcon from '../../../icons/invisible'; -import FocusModeIcon from '../../../icons/focus-mode'; -import { ToggleControl } from '../../../ui/toggle-control/toggle-control'; -import { RowText } from '../../../ui/row-text/row-text'; +import NodeIcon from '../../icons/node-icon'; +import VisibleIcon from '../../icons/visible'; +import InvisibleIcon from '../../icons/invisible'; +import FocusModeIcon from '../../icons/focus-mode'; +import { ToggleControl } from '../../ui/toggle-control/toggle-control'; +import { RowText } from '../../ui/row-text/row-text'; -import './row.scss'; +import './node-list-row.scss'; -const Row = ({ +const NodeListRow = ({ active, checked, children, @@ -44,24 +44,34 @@ const Row = ({ return (
    {VisibilityIcon && ( * { opacity: 0.3; } @@ -70,8 +74,8 @@ &--active, &--selected, - .row--visible:hover &, - [data-whatintent='keyboard'] .row__text:focus & { + .node-list-row--visible:hover &, + [data-whatintent='keyboard'] .node-list-row__text:focus & { > * { opacity: 1; } diff --git a/src/components/node-list-tree/node-list-row/node-list-row.test.js b/src/components/node-list-tree/node-list-row/node-list-row.test.js new file mode 100644 index 0000000000..eda3cf1bf0 --- /dev/null +++ b/src/components/node-list-tree/node-list-row/node-list-row.test.js @@ -0,0 +1,78 @@ +import React from 'react'; +import NodeListRow from './node-list-row'; +import { setup } from '../../../utils/state.mock'; + +// Mock props +const mockProps = { + name: 'Test Row', + kind: 'modular-pipeline', + active: false, + disabled: false, + selected: false, + visible: true, + onMouseEnter: jest.fn(), + onMouseLeave: jest.fn(), + onClick: jest.fn(), + icon: null, + type: 'modularPipeline', + checked: true, + focused: false, +}; + +describe('NodeListRow Component', () => { + it('renders without crashing', () => { + expect(() => setup.mount()).not.toThrow(); + }); + + it('handles mouseenter events', () => { + const wrapper = setup.mount(); + const nodeRow = () => wrapper.find('.node-list-row'); + nodeRow().simulate('mouseenter'); + expect(mockProps.onMouseEnter.mock.calls.length).toEqual(1); + }); + + it('handles mouseleave events', () => { + const wrapper = setup.mount(); + const nodeRow = () => wrapper.find('.node-list-row'); + nodeRow().simulate('mouseleave'); + expect(mockProps.onMouseLeave.mock.calls.length).toEqual(1); + }); + + it('applies the node-list-row--active class when active is true', () => { + const wrapper = setup.mount(); + expect( + wrapper.find('.node-list-row').hasClass('node-list-row--active') + ).toBe(true); + }); + + it('applies the node-list-row--selected class when selected is true', () => { + const wrapper = setup.mount(); + expect( + wrapper.find('.node-list-row').hasClass('node-list-row--selected') + ).toBe(true); + }); + + it('applies the node-list-row--selected class when highlight is true and isSlicingPipelineApplied is false', () => { + const wrapper = setup.mount( + + ); + expect( + wrapper.find('.node-list-row').hasClass('node-list-row--selected') + ).toBe(true); + }); + + it('applies the overwrite class if not selected or active', () => { + const activeNodeWrapper = setup.mount( + + ); + expect( + activeNodeWrapper + .find('.node-list-row') + .hasClass('node-list-row--overwrite') + ).toBe(true); + }); +}); diff --git a/src/components/node-list/node-list-tree-item.js b/src/components/node-list-tree/node-list-tree-item/node-list-tree-item.js similarity index 93% rename from src/components/node-list/node-list-tree-item.js rename to src/components/node-list-tree/node-list-tree-item/node-list-tree-item.js index 81c5cebfa3..488ab74d21 100644 --- a/src/components/node-list/node-list-tree-item.js +++ b/src/components/node-list-tree/node-list-tree-item/node-list-tree-item.js @@ -3,8 +3,8 @@ import classnames from 'classnames'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import { TreeItem } from '@mui/x-tree-view'; -import Row from './components/row/row'; -import { getDataTestAttribute } from '../../utils/get-data-test-attribute'; +import NodeListRow from '../node-list-row/node-list-row'; +import { getDataTestAttribute } from '../../../utils/get-data-test-attribute'; const arrowIconColor = '#8e8e90'; @@ -29,7 +29,7 @@ const NodeListTreeItem = ({ collapseIcon={} expandIcon={} label={ - { +const getNodeRowData = (node, disabled, hoveredNode, selected, highlight) => { const checked = !node.disabledNode; + return { ...node, visibleIcon: VisibleIcon, invisibleIcon: InvisibleIcon, - active: node.active, + active: node.active || hoveredNode === node.id, selected, highlight, faded: disabled || node.disabledNode, @@ -111,6 +110,7 @@ const getNodeRowData = (node, disabled, selected, highlight) => { }; const TreeListProvider = ({ + hoveredNode, nodeSelected, modularPipelinesSearchResult, modularPipelinesTree, @@ -154,7 +154,13 @@ const TreeListProvider = ({ const selected = nodeSelected[node.id]; const highlight = slicedPipeline.includes(node.id); - const data = getNodeRowData(node, disabled, selected, highlight); + const data = getNodeRowData( + node, + disabled, + hoveredNode, + selected, + highlight + ); return ( ({ - nodeSelected: getNodeSelected(state), - expanded: state.modularPipeline.expanded, - slicedPipeline: getSlicedPipeline(state), - isSlicingPipelineApplied: state.slice.apply, -}); - -export const mapDispatchToProps = (dispatch) => ({ - onToggleNodeSelected: (nodeID) => { - dispatch(loadNodeData(nodeID)); - }, -}); - -export default connect(mapStateToProps, mapDispatchToProps)(TreeListProvider); +export default TreeListProvider; diff --git a/src/components/node-list/styles/_panels.scss b/src/components/node-list-tree/styles/_panels.scss similarity index 100% rename from src/components/node-list/styles/_panels.scss rename to src/components/node-list-tree/styles/_panels.scss diff --git a/src/components/node-list/styles/_variables.scss b/src/components/node-list-tree/styles/_variables.scss similarity index 100% rename from src/components/node-list/styles/_variables.scss rename to src/components/node-list-tree/styles/_variables.scss diff --git a/src/components/node-list/styles/node-list.scss b/src/components/node-list-tree/styles/node-list.scss similarity index 99% rename from src/components/node-list/styles/node-list.scss rename to src/components/node-list-tree/styles/node-list.scss index 4ebf9cf0ff..d3ca6ac65c 100644 --- a/src/components/node-list/styles/node-list.scss +++ b/src/components/node-list-tree/styles/node-list.scss @@ -114,7 +114,7 @@ position: relative; // Ensure all .row__type-icon path elements have opacity 1 - .row__type-icon path { + .node-list-row__type-icon path { opacity: 1; } diff --git a/src/components/node-list/components/row/row.test.js b/src/components/node-list/components/row/row.test.js deleted file mode 100644 index 42294ab8dd..0000000000 --- a/src/components/node-list/components/row/row.test.js +++ /dev/null @@ -1,66 +0,0 @@ -import React from 'react'; -import Row from './row'; -import { setup } from '../../../../utils/state.mock'; - -// Mock props -const mockProps = { - name: 'Test Row', - kind: 'modular-pipeline', - active: false, - disabled: false, - selected: false, - visible: true, - onMouseEnter: jest.fn(), - onMouseLeave: jest.fn(), - onClick: jest.fn(), - icon: null, - type: 'modularPipeline', - checked: true, - focused: false, -}; - -describe('Row Component', () => { - it('renders without crashing', () => { - expect(() => setup.mount()).not.toThrow(); - }); - - it('handles mouseenter events', () => { - const wrapper = setup.mount(); - const nodeRow = () => wrapper.find('.row'); - nodeRow().simulate('mouseenter'); - expect(mockProps.onMouseEnter.mock.calls.length).toEqual(1); - }); - - it('handles mouseleave events', () => { - const wrapper = setup.mount(); - const nodeRow = () => wrapper.find('.row'); - nodeRow().simulate('mouseleave'); - expect(mockProps.onMouseLeave.mock.calls.length).toEqual(1); - }); - - it('applies the row--active class when active is true', () => { - const wrapper = setup.mount(); - expect(wrapper.find('.row').hasClass('row--active')).toBe(true); - }); - - it('applies the row--selected class when selected is true', () => { - const wrapper = setup.mount(); - expect(wrapper.find('.row').hasClass('row--selected')).toBe(true); - }); - - it('applies the row--selected class when highlight is true and isSlicingPipelineApplied is false', () => { - const wrapper = setup.mount( - - ); - expect(wrapper.find('.row').hasClass('row--selected')).toBe(true); - }); - - it('applies the overwrite class if not selected or active', () => { - const activeNodeWrapper = setup.mount( - - ); - expect(activeNodeWrapper.find('.row').hasClass('row--overwrite')).toBe( - true - ); - }); -}); diff --git a/src/components/node-list/index.js b/src/components/node-list/index.js deleted file mode 100644 index e338a34960..0000000000 --- a/src/components/node-list/index.js +++ /dev/null @@ -1,410 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { connect } from 'react-redux'; -import debounce from 'lodash/debounce'; -import NodeList from './node-list'; -import { - getFilteredItems, - getGroups, - isTagType, - isElementType, - isGroupType, -} from './node-list-items'; -import { - getNodeTypes, - isModularPipelineType, -} from '../../selectors/node-types'; -import { getTagData, getTagNodeCounts } from '../../selectors/tags'; -import { - getFocusedModularPipeline, - getModularPipelinesSearchResult, -} from '../../selectors/modular-pipelines'; -import { - getGroupedNodes, - getNodeSelected, - getInputOutputNodesForFocusedModularPipeline, - getModularPipelinesTree, -} from '../../selectors/nodes'; -import { toggleTagActive, toggleTagFilter } from '../../actions/tags'; -import { toggleTypeDisabled } from '../../actions/node-type'; -import { - toggleParametersHovered, - toggleFocusMode, - toggleHoveredFocusMode, -} from '../../actions'; -import { - toggleModularPipelineActive, - toggleModularPipelineDisabled, - toggleModularPipelinesExpanded, -} from '../../actions/modular-pipelines'; -import { resetSlicePipeline } from '../../actions/slice'; -import { - loadNodeData, - toggleNodeHovered, - toggleNodesDisabled, -} from '../../actions/nodes'; -import { useGeneratePathname } from '../../utils/hooks/use-generate-pathname'; -import './styles/node-list.scss'; -import { params, NODE_TYPES, localStorageName } from '../../config'; -import { loadLocalStorage, saveLocalStorage } from '../../store/helpers'; - -const storedState = loadLocalStorage(localStorageName); - -/** - * Provides data from the store to populate a NodeList component. - * Also handles user interaction and dispatches updates back to the store. - */ -const NodeListProvider = ({ - faded, - nodes, - nodeSelected, - tags, - tagNodeCounts, - nodeTypes, - onToggleNodesDisabled, - onToggleNodeSelected, - onToggleNodeActive, - onToggleParametersActive, - onToggleTagActive, - onToggleTagFilter, - onToggleModularPipelineActive, - onToggleModularPipelineDisabled, - onToggleModularPipelineExpanded, - onToggleTypeDisabled, - onToggleFocusMode, - onToggleHoveredFocusMode, - modularPipelinesTree, - focusMode, - disabledModularPipeline, - inputOutputDataNodes, - onResetSlicePipeline, - isSlicingPipelineApplied, -}) => { - const [searchValue, updateSearchValue] = useState(''); - const [isResetFilterActive, setIsResetFilterActive] = useState(false); - const [groupCollapsed, setGroupCollapsed] = useState( - storedState.groupsCollapsed || {} - ); - - const { - toSelectedPipeline, - toSelectedNode, - toFocusedModularPipeline, - toUpdateUrlParamsOnResetFilter, - toUpdateUrlParamsOnFilter, - toSetQueryParam, - } = useGeneratePathname(); - - const items = getFilteredItems({ - nodes, - tags, - nodeTypes, - tagNodeCounts, - nodeSelected, - searchValue, - focusMode, - inputOutputDataNodes, - }); - - const modularPipelinesSearchResult = searchValue - ? getModularPipelinesSearchResult(modularPipelinesTree, searchValue) - : null; - - const groups = getGroups({ items }); - - const onItemClick = (event, item) => { - if (isGroupType(item.type)) { - onGroupItemChange(item, item.checked); - } else if (isModularPipelineType(item.type)) { - onToggleNodeSelected(null); - } else { - if (item.faded || item.selected) { - onToggleNodeSelected(null); - toSelectedPipeline(); - } else { - onToggleNodeSelected(item.id); - toSelectedNode(item); - // Reset the pipeline slicing filters if no slicing is currently applied - if (!isSlicingPipelineApplied) { - onResetSlicePipeline(); - } - } - } - - // to prevent page reload on form submission - event.preventDefault(); - }; - - // 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) { - onToggleFocusMode(item); - toFocusedModularPipeline(item); - - if (disabledModularPipeline[item.id]) { - onToggleModularPipelineDisabled([item.id], checked); - } - } else { - onToggleFocusMode(null); - toSelectedPipeline(); - } - } else { - onToggleModularPipelineDisabled([item.id], checked); - onToggleModularPipelineActive([item.id], false); - } - } - } else { - if (checked) { - onToggleNodeActive(null); - } - - onToggleNodesDisabled([item.id], checked); - } - }; - - const onItemMouseEnter = (item) => { - if (isTagType(item.type)) { - onToggleTagActive(item.id, true); - } else if (isModularPipelineType(item.type)) { - onToggleModularPipelineActive(item.id, true); - } else if (isElementType(item.type) && item.id === 'parameters') { - // Show parameters highlight when mouse enter parameters filter item - onToggleParametersActive(true); - } else if (item.visible) { - onToggleNodeActive(item.id); - } - }; - - const onItemMouseLeave = (item) => { - if (isTagType(item.type)) { - onToggleTagActive(item.id, false); - } else if (isModularPipelineType(item.type)) { - onToggleModularPipelineActive(item.id, false); - } else if (isElementType(item.type) && item.id === 'parameters') { - // Hide parameters highlight when mouse leave parameters filter item - onToggleParametersActive(false); - } else if (item.visible) { - onToggleNodeActive(null); - } - }; - - // Collapse/expand node group of filters - const onToggleGroupCollapsed = (groupID) => { - const res = { - ...groupCollapsed, - [groupID]: !groupCollapsed[groupID], - }; - - setGroupCollapsed(res); - saveLocalStorage(localStorageName, { groupsCollapsed: res }); - }; - - const onGroupToggleChanged = (groupType) => { - // Enable all items in group if none enabled, otherwise disable all of them - const groupItems = items[groupType] || []; - const groupItemsDisabled = groupItems.every( - (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), - groupItemsDisabled - ); - } else if (isElementType(groupType)) { - onToggleTypeDisabled( - groupItems.reduce( - (state, item) => ({ ...state, [item.id]: !groupItemsDisabled }), - {} - ) - ); - } - }; - - const handleToggleModularPipelineExpanded = (expanded) => { - onToggleModularPipelineExpanded(expanded); - }; - - const onGroupItemChange = (item, wasChecked) => { - // Toggle the group - if (isTagType(item.type)) { - onToggleTagFilter(item.id, !wasChecked); - } else if (isElementType(item.type)) { - onToggleTypeDisabled({ [item.id]: wasChecked }); - } - - // Reset node selection - onToggleNodeSelected(null); - onToggleNodeActive(null); - }; - - // Deselect node on Escape key - const handleKeyDown = (event) => { - if (event.keyCode === 27) { - 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); - }); - - return ( - - ); -}; - -export const mapStateToProps = (state) => ({ - tags: getTagData(state), - tagNodeCounts: getTagNodeCounts(state), - nodes: getGroupedNodes(state), - nodeSelected: getNodeSelected(state), - nodeTypes: getNodeTypes(state), - focusMode: getFocusedModularPipeline(state), - disabledModularPipeline: state.modularPipeline.disabled, - inputOutputDataNodes: getInputOutputNodesForFocusedModularPipeline(state), - modularPipelinesTree: getModularPipelinesTree(state), - isSlicingPipelineApplied: state.slice.apply, -}); - -export const mapDispatchToProps = (dispatch) => ({ - onToggleTagActive: (tagIDs, active) => { - dispatch(toggleTagActive(tagIDs, active)); - }, - onToggleTagFilter: (tagIDs, enabled) => { - dispatch(toggleTagFilter(tagIDs, enabled)); - }, - onToggleModularPipelineActive: (modularPipelineIDs, active) => { - dispatch(toggleModularPipelineActive(modularPipelineIDs, active)); - }, - onToggleModularPipelineDisabled: (modularPipelineIDs, disabled) => { - dispatch(toggleModularPipelineDisabled(modularPipelineIDs, disabled)); - }, - onToggleTypeDisabled: (typeID, disabled) => { - dispatch(toggleTypeDisabled(typeID, disabled)); - }, - onToggleNodeSelected: (nodeID) => { - dispatch(loadNodeData(nodeID)); - }, - onToggleModularPipelineExpanded: (expanded) => { - dispatch(toggleModularPipelinesExpanded(expanded)); - }, - onToggleNodeActive: (nodeID) => { - dispatch(toggleNodeHovered(nodeID)); - }, - onToggleParametersActive: (active) => { - dispatch(toggleParametersHovered(active)); - }, - onToggleNodesDisabled: (nodeIDs, disabled) => { - dispatch(toggleNodesDisabled(nodeIDs, disabled)); - }, - onToggleFocusMode: (modularPipeline) => { - dispatch(toggleFocusMode(modularPipeline)); - }, - onToggleHoveredFocusMode: (active) => { - dispatch(toggleHoveredFocusMode(active)); - }, - onResetSlicePipeline: () => { - dispatch(resetSlicePipeline()); - }, -}); - -export default connect(mapStateToProps, mapDispatchToProps)(NodeListProvider); diff --git a/src/components/node-list/node-list.js b/src/components/node-list/node-list.js deleted file mode 100644 index ad3b7690f5..0000000000 --- a/src/components/node-list/node-list.js +++ /dev/null @@ -1,112 +0,0 @@ -import React from 'react'; -import classnames from 'classnames'; -import { Scrollbars } from 'react-custom-scrollbars-2'; -import SearchList from '../search-list'; -import Filters from '../filters/filters'; -import NodeListTree from './node-list-tree'; -import SplitPanel from '../split-panel'; - -import './styles/node-list.scss'; - -/** - * Scrollable list of toggleable items, with search & filter functionality - */ -const NodeList = ({ - faded, - items, - modularPipelinesTree, - modularPipelinesSearchResult, - groups, - searchValue, - getGroupState, - onUpdateSearchValue, - onGroupToggleChanged, - onToggleGroupCollapsed, - groupCollapsed, - onItemClick, - onItemMouseEnter, - onItemMouseLeave, - onToggleHoveredFocusMode, - onItemChange, - onModularPipelineToggleExpanded, - focusMode, - disabledModularPipeline, - onResetFilter, - isResetFilterActive, -}) => { - return ( -
    - - - {({ isResizing, props: { container, panelA, panelB, handle } }) => ( -
    -
    - -
    - -
    -
    -
    -
    -
    - - - -
    -
    - )} - -
    - ); -}; - -export default NodeList; diff --git a/src/components/nodes-panel/index.js b/src/components/nodes-panel/index.js new file mode 100644 index 0000000000..af6acf42d9 --- /dev/null +++ b/src/components/nodes-panel/index.js @@ -0,0 +1,18 @@ +import React from 'react'; +import NodesPanel from './nodes-panel'; + +import { NodesPanelContextProvider } from './utils/nodes-panel-context'; + +/** + * Acts as a wrapper component that provides the AppContext to the NodesPanel component. + * This ensures that NodesPanel has access to the necessary context values and functions. + */ +const NodesPanelProvider = ({ faded }) => { + return ( + + + + ); +}; + +export default NodesPanelProvider; diff --git a/src/components/nodes-panel/nodes-panel.js b/src/components/nodes-panel/nodes-panel.js new file mode 100644 index 0000000000..8c845108d7 --- /dev/null +++ b/src/components/nodes-panel/nodes-panel.js @@ -0,0 +1,138 @@ +import React, { useContext, useEffect, useState } from 'react'; +import debounce from 'lodash/debounce'; +import classnames from 'classnames'; +import { Scrollbars } from 'react-custom-scrollbars-2'; +import SearchList from '../search-list'; +import Filters from '../filters/filters'; +import NodeListTree from '../node-list-tree/node-list-tree'; +import SplitPanel from '../split-panel'; +import { FiltersContext } from './utils/filters-context'; +import { NodeListContext } from './utils/node-list-context'; +import { getModularPipelinesSearchResult } from '../../selectors/modular-pipelines'; +import { getFiltersSearchResult } from '../../selectors/filtered-node-list-items'; + +/** + * Scrollable list of toggleable items, with search & filter functionality + */ +const NodesPanel = ({ faded }) => { + const [searchValue, updateSearchValue] = useState(''); + + const { + groupCollapsed, + groups, + isResetFilterActive, + items, + handleGroupToggleChanged, + handleResetFilter, + handleToggleGroupCollapsed, + handleFiltersRowClicked, + } = useContext(FiltersContext); + + const { + hoveredNode, + disabledModularPipeline, + expanded, + focusMode, + handleItemMouseEnter, + handleItemMouseLeave, + handleKeyDown, + handleModularPipelineToggleExpanded, + handleNodeListRowChanged, + handleNodeListRowClicked, + handleToggleHoveredFocusMode, + isSlicingPipelineApplied, + modularPipelinesTree, + selectedNodes, + slicedPipeline, + } = useContext(NodeListContext); + + const modularPipelinesSearchResult = searchValue + ? getModularPipelinesSearchResult(modularPipelinesTree, searchValue) + : null; + + const filtersSearchResult = searchValue + ? getFiltersSearchResult(items, searchValue) + : null; + + useEffect(() => { + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }); + + return ( +
    + + + {({ isResizing, props: { container, panelA, panelB, handle } }) => ( +
    +
    + +
    + +
    +
    +
    +
    +
    + + 0 ? filtersSearchResult : items} + onGroupToggleChanged={handleGroupToggleChanged} + onItemChange={handleFiltersRowClicked} + onResetFilter={handleResetFilter} + onToggleGroupCollapsed={handleToggleGroupCollapsed} + searchValue={searchValue} + /> + +
    +
    + )} + +
    + ); +}; + +export default NodesPanel; diff --git a/src/components/node-list/node-list.test.js b/src/components/nodes-panel/nodes-panel.test.js similarity index 91% rename from src/components/node-list/node-list.test.js rename to src/components/nodes-panel/nodes-panel.test.js index be2054c364..a136fbc74e 100644 --- a/src/components/node-list/node-list.test.js +++ b/src/components/nodes-panel/nodes-panel.test.js @@ -12,14 +12,14 @@ import { getTagData } from '../../selectors/tags'; import { mockState, setup } from '../../utils/state.mock'; import IndicatorPartialIcon from '../icons/indicator-partial'; import SplitPanel from '../split-panel'; -import NodeList, { mapStateToProps } from './index'; +import NodesPanel from './index'; jest.mock('lodash/debounce', () => (func) => { func.cancel = jest.fn(); return func; }); -describe('NodeList', () => { +describe('NodesPanel', () => { beforeEach(() => { window.localStorage.clear(); }); @@ -27,7 +27,7 @@ describe('NodeList', () => { it('renders without crashing', () => { const wrapper = setup.mount( - + ); const search = wrapper.find('.pipeline-search-list'); @@ -40,7 +40,7 @@ describe('NodeList', () => { describe('displays nodes matching search value', () => { const wrapper = setup.mount( - + ); @@ -94,7 +94,7 @@ describe('NodeList', () => { it('clears the search input and resets the list when hitting the Escape key', () => { const wrapper = setup.mount( - + ); const searchWrapper = wrapper.find('.pipeline-search-list'); @@ -141,7 +141,7 @@ describe('NodeList', () => { it('displays search results when in focus mode', () => { const wrapper = setup.mount( - + ); const searchWrapper = wrapper.find('.pipeline-search-list'); @@ -198,7 +198,7 @@ describe('NodeList', () => { it('shows full node names when pretty name is turned off', () => { const wrapper = setup.mount( - + , { beforeLayoutActions: [() => toggleIsPrettyName(false)], @@ -215,7 +215,7 @@ describe('NodeList', () => { it('shows formatted node names when pretty name is turned on', () => { const wrapper = setup.mount( - + , { beforeLayoutActions: [() => toggleIsPrettyName(true)], @@ -264,7 +264,7 @@ describe('NodeList', () => { //Parameters are enabled here to override the default behavior const wrapper = setup.mount( - + , { beforeLayoutActions: [() => toggleTypeDisabled('parameters', false)], @@ -284,7 +284,7 @@ describe('NodeList', () => { it('adds a class to tag group item when all tags unchecked', () => { const wrapper = setup.mount( - + ); const uncheckedClass = 'filters-section--all-unchecked'; @@ -299,7 +299,7 @@ describe('NodeList', () => { it('adds a class to the row when a tag row unchecked', () => { const wrapper = setup.mount( - + ); const uncheckedClass = 'toggle-control--icon--unchecked'; @@ -330,7 +330,7 @@ describe('NodeList', () => { it('shows as partially selected when at least one but not all tags selected', () => { const wrapper = setup.mount( - + ); @@ -347,13 +347,13 @@ describe('NodeList', () => { ['Features', 'Preprocessing', 'Split', 'Train'], true ); - expect(partialIcon(wrapper)).toHaveLength(0); + expect(partialIcon(wrapper)).toHaveLength(1); }); it('saves enabled tags in localStorage on selecting a tag on node-list', () => { const wrapper = setup.mount( - + ); changeRows(wrapper, ['Preprocessing'], true); @@ -369,7 +369,7 @@ describe('NodeList', () => { it('renders the correct number of tags in the filter panel', () => { const wrapper = setup.mount( - + ); const nodeList = wrapper.find('.filters-group .node-list-filter-row'); @@ -381,7 +381,7 @@ describe('NodeList', () => { it('renders the correct number of modular pipelines and nodes in the tree sidepanel', () => { const wrapper = setup.mount( - + ); @@ -397,7 +397,7 @@ describe('NodeList', () => { it('renders elements panel, filter panel inside a SplitPanel with a handle', () => { const wrapper = setup.mount( - + ); const split = wrapper.find(SplitPanel); @@ -421,7 +421,7 @@ describe('NodeList', () => { describe('node list element item checkbox', () => { const wrapper = setup.mount( - + ); const checkbox = () => wrapper.find('.node-list-tree-item-row input').at(4); @@ -454,7 +454,7 @@ describe('NodeList', () => { describe('Reset node filters', () => { const wrapper = setup.mount( - + ); @@ -485,30 +485,4 @@ describe('NodeList', () => { expect(window.location.search).not.toContain('tags'); }); }); - - it('maps state to props', () => { - const nodeList = expect.arrayContaining([ - expect.objectContaining({ - disabled: expect.any(Boolean), - disabledNode: expect.any(Boolean), - disabledTag: expect.any(Boolean), - disabledType: expect.any(Boolean), - id: expect.any(String), - name: expect.any(String), - type: expect.any(String), - }), - ]); - const expectedResult = expect.objectContaining({ - tags: expect.any(Object), - nodes: expect.objectContaining({ - data: nodeList, - task: nodeList, - modularPipeline: nodeList, - }), - nodeSelected: expect.any(Object), - nodeTypes: expect.any(Array), - modularPipelinesTree: expect.any(Object), - }); - expect(mapStateToProps(mockState.spaceflights)).toEqual(expectedResult); - }); }); diff --git a/src/components/nodes-panel/utils/filters-context.js b/src/components/nodes-panel/utils/filters-context.js new file mode 100644 index 0000000000..1801552b30 --- /dev/null +++ b/src/components/nodes-panel/utils/filters-context.js @@ -0,0 +1,251 @@ +import React, { useState, useEffect, createContext } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { useGeneratePathname } from '../../../utils/hooks/use-generate-pathname'; +import { loadLocalStorage, saveLocalStorage } from '../../../store/helpers'; + +import { getTagData, getTagNodeCounts } from '../../../selectors/tags'; +import { + getGroupedNodes, + getNodeSelected, + getInputOutputNodesForFocusedModularPipeline, +} from '../../../selectors/nodes'; +import { getNodeTypes } from '../../../selectors/node-types'; +import { getFocusedModularPipeline } from '../../../selectors/modular-pipelines'; + +import { toggleTagFilter } from '../../../actions/tags'; +import { toggleTypeDisabled } from '../../../actions/node-type'; +import { loadNodeData, toggleNodeHovered } from '../../../actions/nodes'; + +import { params, localStorageName, NODE_TYPES } from '../../../config'; +import { + getFilteredItems, + isTagType, + isElementType, + getGroups, +} from '../../../selectors/filtered-node-list-items'; + +// Load the stored state from local storage +const storedState = loadLocalStorage(localStorageName); + +// Custom hook to group useSelector calls +const useFiltersContextSelector = () => { + const dispatch = useDispatch(); + const tags = useSelector(getTagData); + const nodes = useSelector(getGroupedNodes); + const nodeTypes = useSelector(getNodeTypes); + const tagNodeCounts = useSelector(getTagNodeCounts); + const nodeSelected = useSelector(getNodeSelected); + const focusMode = useSelector(getFocusedModularPipeline); + const inputOutputDataNodes = useSelector( + getInputOutputNodesForFocusedModularPipeline + ); + + const onToggleTypeDisabled = (typeID, disabled) => { + dispatch(toggleTypeDisabled(typeID, disabled)); + }; + + const onToggleTagFilter = (tagIDs, enabled) => { + dispatch(toggleTagFilter(tagIDs, enabled)); + }; + + const onToggleNodeSelected = (nodeID) => { + dispatch(loadNodeData(nodeID)); + }; + + const onToggleNodeHovered = (nodeID) => { + dispatch(toggleNodeHovered(nodeID)); + }; + + return { + tags, + nodes, + nodeTypes, + tagNodeCounts, + nodeSelected, + focusMode, + inputOutputDataNodes, + onToggleTypeDisabled, + onToggleTagFilter, + onToggleNodeSelected, + onToggleNodeHovered, + }; +}; + +// Create a context for filters +export const FiltersContext = createContext(); + +export const FiltersContextProvider = ({ children }) => { + const { + tags, + nodes, + nodeTypes, + tagNodeCounts, + nodeSelected, + focusMode, + inputOutputDataNodes, + onToggleTypeDisabled, + onToggleTagFilter, + onToggleNodeSelected, + onToggleNodeHovered, + } = useFiltersContextSelector(); + + const [groupCollapsed, setGroupCollapsed] = useState( + storedState.groupsCollapsed || {} + ); + const [isResetFilterActive, setIsResetFilterActive] = useState(false); + + // Helper function to check if NodeTypes are modified + const hasModifiedNodeTypes = (nodeTypes) => { + return nodeTypes.some( + (item) => NODE_TYPES[item.id]?.defaultState !== item.disabled + ); + }; + + // Effect to update the reset filter button status based on node types and tags + useEffect(() => { + const isNodeTypeModified = hasModifiedNodeTypes(nodeTypes); + const isNodeTagModified = tags.some((tag) => tag.enabled); + setIsResetFilterActive(isNodeTypeModified || isNodeTagModified); + }, [tags, nodeTypes]); + + const { + toUpdateUrlParamsOnResetFilter, + toUpdateUrlParamsOnFilter, + toSetQueryParam, + } = useGeneratePathname(); + + // Function to reset applied filters to default + const handleResetFilter = () => { + onToggleTypeDisabled({ task: false, data: false, parameters: true }); + onToggleTagFilter( + tags.map((item) => item.id), + false + ); + toUpdateUrlParamsOnResetFilter(); + }; + + // Function to collapse/expand node group of filters + const handleToggleGroupCollapsed = (groupID) => { + const updatedGroupCollapsed = { + ...groupCollapsed, + [groupID]: !groupCollapsed[groupID], + }; + setGroupCollapsed(updatedGroupCollapsed); + saveLocalStorage(localStorageName, { + groupsCollapsed: updatedGroupCollapsed, + }); + }; + + const items = getFilteredItems({ + nodes, + tags, + nodeTypes, + tagNodeCounts, + nodeSelected, + searchValue: '', + focusMode, + inputOutputDataNodes, + }); + + const groups = getGroups({ items }); + + // Function to get existing values from URL query parameters + const getExistingValuesFromUrlQueryParams = (paramName, searchParams) => { + const paramValues = searchParams.get(paramName); + return new Set(paramValues ? paramValues.split(',') : []); + }; + + // Function to update URL query parameters when a filter is applied + 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); + }; + + // Function to update URL query parameters when a filter group is clicked + const handleUrlParamsUpdateOnGroupFilter = ( + groupType, + groupItems, + groupItemsDisabled + ) => { + if (groupItemsDisabled) { + groupItems.forEach((item) => { + handleUrlParamsUpdateOnFilter(item); + }); + } else { + const paramName = isElementType(groupType) ? params.types : params.tags; + toSetQueryParam(paramName, []); + } + }; + + // Function to handle group toggle change + const handleGroupToggleChanged = (groupType) => { + const groupItems = items[groupType] || []; + const groupItemsDisabled = groupItems.every( + (groupItem) => !groupItem.checked + ); + + handleUrlParamsUpdateOnGroupFilter( + groupType, + groupItems, + groupItemsDisabled + ); + + if (isTagType(groupType)) { + onToggleTagFilter( + groupItems.map((item) => item.id), + groupItemsDisabled + ); + } else if (isElementType(groupType)) { + onToggleTypeDisabled( + groupItems.reduce( + (state, item) => ({ ...state, [item.id]: !groupItemsDisabled }), + {} + ) + ); + } + }; + + const onGroupItemChange = (item, wasChecked) => { + // Toggle the group + if (isTagType(item.type)) { + onToggleTagFilter(item.id, !wasChecked); + } else if (isElementType(item.type)) { + onToggleTypeDisabled({ [item.id]: wasChecked }); + } + + // Reset node selection + onToggleNodeSelected(null); + onToggleNodeHovered(null); + }; + + const handleFiltersRowClicked = (event, item) => { + onGroupItemChange(item, item.checked); + handleUrlParamsUpdateOnFilter(item); + + // to prevent page reload on form submission + event.preventDefault(); + }; + + return ( + + {children} + + ); +}; diff --git a/src/components/nodes-panel/utils/node-list-context.js b/src/components/nodes-panel/utils/node-list-context.js new file mode 100644 index 0000000000..f2adc4afd5 --- /dev/null +++ b/src/components/nodes-panel/utils/node-list-context.js @@ -0,0 +1,226 @@ +import React, { createContext } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { useGeneratePathname } from '../../../utils/hooks/use-generate-pathname'; + +import { + getFocusedModularPipeline, + getModularPipelinesTree, +} from '../../../selectors/modular-pipelines'; +import { isModularPipelineType } from '../../../selectors/node-types'; +import { getNodeSelected } from '../../../selectors/nodes'; +import { getSlicedPipeline } from '../../../selectors/sliced-pipeline'; + +import { + toggleModularPipelinesExpanded, + toggleModularPipelineActive, + toggleModularPipelineDisabled, +} from '../../../actions/modular-pipelines'; +import { toggleFocusMode, toggleHoveredFocusMode } from '../../../actions'; +import { + loadNodeData, + toggleNodeHovered, + toggleNodesDisabled, +} from '../../../actions/nodes'; +import { resetSlicePipeline } from '../../../actions/slice'; + +// Custom hook to group useSelector calls +const useNodeListContextSelector = () => { + const dispatch = useDispatch(); + const hoveredNode = useSelector((state) => state.node.hovered); + const selectedNodes = useSelector(getNodeSelected); + const expanded = useSelector((state) => state.modularPipeline.expanded); + const slicedPipeline = useSelector(getSlicedPipeline); + const modularPipelinesTree = useSelector(getModularPipelinesTree); + const isSlicingPipelineApplied = useSelector((state) => state.slice.apply); + const focusMode = useSelector(getFocusedModularPipeline); + const disabledModularPipeline = useSelector( + (state) => state.modularPipeline.disabled + ); + + const onToggleFocusMode = (modularPipeline) => { + dispatch(toggleFocusMode(modularPipeline)); + }; + const onToggleHoveredFocusMode = (active) => { + dispatch(toggleHoveredFocusMode(active)); + }; + const onToggleNodeSelected = (nodeID) => { + dispatch(loadNodeData(nodeID)); + }; + const onToggleNodeHovered = (nodeID) => { + dispatch(toggleNodeHovered(nodeID)); + }; + const onToggleNodesDisabled = (nodeIDs, disabled) => { + dispatch(toggleNodesDisabled(nodeIDs, disabled)); + }; + const onToggleModularPipelineExpanded = (expanded) => { + dispatch(toggleModularPipelinesExpanded(expanded)); + }; + const onToggleModularPipelineDisabled = (modularPipelineIDs, disabled) => { + dispatch(toggleModularPipelineDisabled(modularPipelineIDs, disabled)); + }; + const onToggleModularPipelineActive = (modularPipelineIDs, active) => { + dispatch(toggleModularPipelineActive(modularPipelineIDs, active)); + }; + const onResetSlicePipeline = () => { + dispatch(resetSlicePipeline()); + }; + + return { + disabledModularPipeline, + expanded, + focusMode, + hoveredNode, + isSlicingPipelineApplied, + modularPipelinesTree, + selectedNodes, + slicedPipeline, + onResetSlicePipeline, + onToggleFocusMode, + onToggleHoveredFocusMode, + onToggleModularPipelineActive, + onToggleModularPipelineDisabled, + onToggleModularPipelineExpanded, + onToggleNodeHovered, + onToggleNodesDisabled, + onToggleNodeSelected, + }; +}; + +export const NodeListContext = createContext(); + +export const NodeListContextProvider = ({ children }) => { + const { + disabledModularPipeline, + expanded, + focusMode, + hoveredNode, + isSlicingPipelineApplied, + modularPipelinesTree, + selectedNodes, + slicedPipeline, + onResetSlicePipeline, + onToggleFocusMode, + onToggleHoveredFocusMode, + onToggleModularPipelineActive, + onToggleModularPipelineDisabled, + onToggleModularPipelineExpanded, + onToggleNodeHovered, + onToggleNodesDisabled, + onToggleNodeSelected, + } = useNodeListContextSelector(); + const { toSelectedPipeline, toSelectedNode, toFocusedModularPipeline } = + useGeneratePathname(); + + // Handle row click in the node list + const handleNodeListRowClicked = (event, item) => { + if (isModularPipelineType(item.type)) { + onToggleNodeSelected(null); + } else { + if (item.faded || item.selected) { + onToggleNodeSelected(null); + toSelectedPipeline(); + } else { + onToggleNodeSelected(item.id); + toSelectedNode(item); + // Reset the pipeline slicing filters if no slicing is currently applied + if (!isSlicingPipelineApplied) { + onResetSlicePipeline(); + } + } + } + + // Prevent page reload on form submission + event.preventDefault(); + }; + + // Handle changes in the node list row + const handleNodeListRowChanged = (item, checked, clickedIconType) => { + if (isModularPipelineType(item.type)) { + if (clickedIconType === 'focus') { + if (focusMode === null) { + onToggleFocusMode(item); + toFocusedModularPipeline(item); + + if (disabledModularPipeline[item.id]) { + onToggleModularPipelineDisabled([item.id], checked); + } + } else { + onToggleFocusMode(null); + toSelectedPipeline(); + } + } else { + onToggleModularPipelineDisabled([item.id], checked); + onToggleModularPipelineActive([item.id], false); + } + } else { + if (checked) { + onToggleNodeHovered(null); + } + + onToggleNodesDisabled([item.id], checked); + } + // reset the node data + onToggleNodeSelected(null); + onToggleNodeHovered(null); + }; + + // Handle mouse enter event on an item + const handleItemMouseEnter = (item) => { + if (isModularPipelineType(item.type)) { + onToggleModularPipelineActive(item.id, true); + return; + } + + if (item.visible) { + onToggleNodeHovered(item.id); + } + }; + + // Handle mouse leave event on an item + const handleItemMouseLeave = (item) => { + if (isModularPipelineType(item.type)) { + onToggleModularPipelineActive(item.id, false); + return; + } + if (item.visible) { + onToggleNodeHovered(null); + } + }; + + // Toggle hovered focus mode + const handleToggleHoveredFocusMode = (active) => { + onToggleHoveredFocusMode(active); + }; + + // Deselect node on Escape key + const handleKeyDown = (event) => { + if (event.keyCode === 27) { + onToggleNodeSelected(null); + } + }; + + return ( + + {children} + + ); +}; diff --git a/src/components/nodes-panel/utils/nodes-panel-context.js b/src/components/nodes-panel/utils/nodes-panel-context.js new file mode 100644 index 0000000000..aa32e99d3f --- /dev/null +++ b/src/components/nodes-panel/utils/nodes-panel-context.js @@ -0,0 +1,11 @@ +import React from 'react'; +import { NodeListContextProvider } from './node-list-context'; +import { FiltersContextProvider } from './filters-context'; + +export const NodesPanelContextProvider = ({ children }) => { + return ( + + {children} + + ); +}; diff --git a/src/components/sidebar/sidebar.js b/src/components/sidebar/sidebar.js index 653a104ba7..73b6fbcc14 100644 --- a/src/components/sidebar/sidebar.js +++ b/src/components/sidebar/sidebar.js @@ -5,7 +5,7 @@ import ExperimentPrimaryToolbar from '../experiment-tracking/experiment-primary- import FlowchartPrimaryToolbar from '../flowchart-primary-toolbar'; import MiniMap from '../minimap'; import MiniMapToolbar from '../minimap-toolbar'; -import NodeList from '../node-list'; +import NodesPanel from '../nodes-panel'; import PipelineList from '../pipeline-list'; import RunsList from '../experiment-tracking/runs-list'; @@ -88,7 +88,7 @@ export const Sidebar = ({ >
    - +