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 01/13] 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 02/13] 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 637c5ae2facf38369fb5053d8f81b8c1d1941f08 Mon Sep 17 00:00:00 2001 From: Huong Nguyen <32060364+Huongg@users.noreply.github.com> Date: Thu, 14 Nov 2024 10:02:13 +0000 Subject: [PATCH 03/13] 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 = ({ >
    - +