diff --git a/RELEASE.md b/RELEASE.md index 8d913321f7..0a15a496ff 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -14,7 +14,6 @@ Please follow the established format: - Introduce `behaviour` prop object with `reFocus` prop (#2161) ## Bug fixes and other changes - - Improve `kedro viz build` usage documentation (#2126) - Fix unserializable parameters value (#2122) - Replace `watchgod` library with `watchfiles` and improve autoreload file watching filter (#2134) @@ -22,6 +21,7 @@ Please follow the established format: - Enable SQLite WAL mode for Azure ML to fix database locking issues (#2131) - Replace `flake8`, `isort`, `pylint` and `black` by `ruff` (#2149) - Refactor `DatasetStatsHook` to avoid showing error when dataset doesn't have file size info (#2174) +- Refactor `node-list-tree` component. (#2193) - Fix 404 error when accessing the experiment tracking page on the demo site (#2179) - Add check for port availability before starting Kedro Viz to prevent unintended browser redirects when the port is already in use (#2176) - Include Kedro Viz version in telemetry.. (#2194) 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/filters/filters-group/filters-group.js b/src/components/filters/filters-group/filters-group.js new file mode 100644 index 0000000000..2edb9af1eb --- /dev/null +++ b/src/components/filters/filters-group/filters-group.js @@ -0,0 +1,49 @@ +import React from 'react'; +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'; + +import './filters-group.scss'; + +/** A group collection of FiltersRow */ +const FiltersGroup = ({ items = [], group, collapsed, onItemChange }) => ( + (end - start) * nodeListRowHeight} + total={items.length} + > + {({ start, end, listRef, listStyle }) => ( + + )} + +); + +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..c36a015442 --- /dev/null +++ b/src/components/filters/filters-group/filters-group.scss @@ -0,0 +1,15 @@ +@use '../../../styles/variables' as var; +@use '../../node-list-tree/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..7f91be5ca0 --- /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 '../../../selectors/filtered-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/filters/filters-row/filters-row.js b/src/components/filters/filters-row/filters-row.js new file mode 100755 index 0000000000..f854100608 --- /dev/null +++ b/src/components/filters/filters-row/filters-row.js @@ -0,0 +1,68 @@ +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 './filters-row.scss'; + +const FiltersRow = ({ + 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} + + ); +}; + +export default FiltersRow; diff --git a/src/components/filters/filters-row/filters-row.scss b/src/components/filters/filters-row/filters-row.scss new file mode 100644 index 0000000000..3f25875237 --- /dev/null +++ b/src/components/filters/filters-row/filters-row.scss @@ -0,0 +1,54 @@ +@use '../../../styles/variables' as var; +@use '../../node-list-tree/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/filters/filters-row/filters-row.test.js b/src/components/filters/filters-row/filters-row.test.js new file mode 100644 index 0000000000..1660b20f14 --- /dev/null +++ b/src/components/filters/filters-row/filters-row.test.js @@ -0,0 +1,24 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import FiltersRow from './filters-row'; + +describe('FiltersRow 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/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..cdd1ea8dc1 --- /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-tree/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..84c57d603d --- /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 '../../../selectors/filtered-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..808aee952e --- /dev/null +++ b/src/components/filters/filters-section/filters-section.js @@ -0,0 +1,46 @@ +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, + 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..6c476e32cd --- /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 '../../../selectors/filtered-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..2797ebd33c --- /dev/null +++ b/src/components/filters/filters.js @@ -0,0 +1,51 @@ +import React from 'react'; +import FiltersSection from './filters-section/filters-section'; + +import './filters.scss'; + +const Filters = ({ + groupCollapsed, + groups, + isResetFilterActive, + items, + onGroupToggleChanged, + onItemChange, + 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 71% rename from src/components/node-list/styles/_section.scss rename to src/components/filters/filters.scss index a854ce8ee8..03e5922663 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-tree/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: 4px 0 28px; + 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..4b1ac0198b --- /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 '../../selectors/filtered-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-tree/node-list-row/node-list-row.js b/src/components/node-list-tree/node-list-row/node-list-row.js new file mode 100755 index 0000000000..619bd301c4 --- /dev/null +++ b/src/components/node-list-tree/node-list-row/node-list-row.js @@ -0,0 +1,123 @@ +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 './node-list-row.scss'; + +const NodeListRow = ({ + 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 NodeListRow; diff --git a/src/components/node-list-tree/node-list-row/node-list-row.scss b/src/components/node-list-tree/node-list-row/node-list-row.scss new file mode 100755 index 0000000000..346a8f533e --- /dev/null +++ b/src/components/node-list-tree/node-list-row/node-list-row.scss @@ -0,0 +1,87 @@ +@use '../../../styles/variables' as var; +@use '../../node-list-tree/styles/variables' as variables; + +.MuiTreeItem-iconContainer svg { + z-index: var.$zindex-MuiTreeItem-icon; +} + +.node-list-row { + align-items: center; + cursor: default; + display: flex; + height: 32px; + position: relative; + transform: translate(0, 0); + + &:hover, + &--active { + background-color: var(--color-nodelist-row-selected); + } + + &--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 { + .node-list-row__type-icon path { + opacity: 1; + } +} + +.node-list-row--active::before, +.node-list-row--selected::before, +.node-list-row:hover::before { + opacity: 1; +} + +.node-list-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; + } +} + +.node-list-row__type-icon { + &--nested > * { + opacity: 0.3; + } + + &--faded > * { + opacity: 0.2; + } + + &--active, + &--selected, + .node-list-row--visible:hover &, + [data-whatintent='keyboard'] .node-list-row__text:focus & { + > * { + opacity: 1; + } + + &--faded > * { + opacity: 0.55; + } + } +} 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 72% 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 5a08c0ca25..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 @@ -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 NodeListRow from '../node-list-row/node-list-row'; +import { getDataTestAttribute } from '../../../utils/get-data-test-attribute'; const arrowIconColor = '#8e8e90'; @@ -12,11 +14,15 @@ const NodeListTreeItem = ({ onItemMouseEnter, onItemMouseLeave, onItemChange, + onToggleHoveredFocusMode, children, isSlicingPipelineApplied, }) => ( } 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-tree/node-list-tree.js similarity index 77% rename from src/components/node-list/node-list-tree.js rename to src/components/node-list-tree/node-list-tree.js index fa89c3fec8..fdb5df54d3 100644 --- a/src/components/node-list/node-list-tree.js +++ b/src/components/node-list-tree/node-list-tree.js @@ -1,5 +1,4 @@ import React from 'react'; -import { connect } from 'react-redux'; import uniqueId from 'lodash/uniqueId'; import { styled } from '@mui/system'; @@ -8,14 +7,13 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import sortBy from 'lodash/sortBy'; -import { loadNodeData } from '../../actions/nodes'; -import { getNodeSelected } from '../../selectors/nodes'; import { isModularPipelineType } from '../../selectors/node-types'; -import NodeListTreeItem from './node-list-tree-item'; +import NodeListTreeItem from './node-list-tree-item/node-list-tree-item'; import VisibleIcon from '../icons/visible'; import InvisibleIcon from '../icons/invisible'; import FocusModeIcon from '../icons/focus-mode'; -import { getSlicedPipeline } from '../../selectors/sliced-pipeline'; + +import './styles/node-list.scss'; // Display order of node groups const GROUPED_NODES_DISPLAY_ORDER = { @@ -36,20 +34,6 @@ const StyledTreeView = styled(TreeView)({ padding: '0 0 0 20px', }); -/** - * Return whether the given modular pipeline ID is on focus mode path, i.e. - * it's not the currently focused pipeline nor one of its children. - * @param {String} focusModeID The currently focused modular pipeline ID. - * @param {String} modularPipelineID The modular pipeline ID to check. - * @return {Boolean} Whether the given modular pipeline ID is on focus mode path. - */ -const isOnFocusedModePath = (focusModeID, modularPipelineID) => { - return ( - modularPipelineID === focusModeID || - modularPipelineID.startsWith(`${focusModeID}.`) - ); -}; - /** * Return the data of a modular pipeline to display as a row in the node list. * @param {Object} params @@ -94,16 +78,17 @@ const getModularPipelineRowData = ({ * @param {Boolean} selected Whether the node is currently disabled * @param {Boolean} selected Whether the node is currently selected */ -const getNodeRowData = (node, disabled, selected, highlight) => { +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, + faded: disabled || !checked, visible: !disabled && checked, checked, disabled, @@ -111,49 +96,45 @@ const getNodeRowData = (node, disabled, selected, highlight) => { }; const TreeListProvider = ({ + hoveredNode, nodeSelected, modularPipelinesSearchResult, modularPipelinesTree, onItemChange, onItemMouseEnter, onItemMouseLeave, + onToggleHoveredFocusMode, onItemClick, onNodeToggleExpanded, focusMode, - disabledModularPipeline, expanded, onToggleNodeSelected, slicedPipeline, isSlicingPipelineApplied, + nodesDisabledViaModularPipeline, }) => { // render a leaf node in the modular pipelines tree const renderLeafNode = (node) => { // As part of the slicing pipeline logic, child nodes not included in the sliced pipeline are assigned an empty data object. // Therefore, if a child node has an empty data object, it indicates it's not part of the slicing pipeline and should not be rendered. - if (Object.keys(node).length === 0) { + if (!node || Object.keys(node).length === 0) { return null; } const disabled = node.disabledTag || node.disabledType || - (focusMode && - !node.modularPipelines - .map((modularPipelineID) => - isOnFocusedModePath(focusMode.id, modularPipelineID) - ) - .some(Boolean)) || - (node.modularPipelines && - node.modularPipelines - .map( - (modularPipelineID) => disabledModularPipeline[modularPipelineID] - ) - .some(Boolean)); + nodesDisabledViaModularPipeline[node.id]; 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 58% rename from src/components/node-list/styles/node-list.scss rename to src/components/node-list-tree/styles/node-list.scss index 3d45c4f370..d3ca6ac65c 100644 --- a/src/components/node-list/styles/node-list.scss +++ b/src/components/node-list-tree/styles/node-list.scss @@ -1,11 +1,6 @@ @use '../../../styles/mixins' as mixins; @use '../../../styles/variables' as colors; -@use './group'; @use './panels'; -@use './row'; -@use './row-label'; -@use './row-toggle'; -@use './section'; @use './variables'; .kui-theme--light { @@ -84,12 +79,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 + .node-list-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/node-list/index.js b/src/components/node-list/index.js deleted file mode 100644 index 74353d8944..0000000000 --- a/src/components/node-list/index.js +++ /dev/null @@ -1,379 +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 } 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 } from '../../config'; - -/** - * 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, - modularPipelinesTree, - focusMode, - disabledModularPipeline, - inputOutputDataNodes, - onResetSlicePipeline, - isSlicingPipelineApplied, -}) => { - const [searchValue, updateSearchValue] = useState(''); - const [isResetFilterActive, setIsResetFilterActive] = useState(false); - - 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 = (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 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); - } - }; - - 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)); - }, - onResetSlicePipeline: () => { - dispatch(resetSlicePipeline()); - }, -}); - -export default connect(mapStateToProps, mapDispatchToProps)(NodeListProvider); 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 9b54a2d72b..0000000000 --- a/src/components/node-list/node-list-group.js +++ /dev/null @@ -1,77 +0,0 @@ -import React from 'react'; -import classnames from 'classnames'; -import NodeListRow from './node-list-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" - visibleIcon={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 e8ca53fb8a..0000000000 --- a/src/components/node-list/node-list-group.test.js +++ /dev/null @@ -1,88 +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 - ); - }); - - 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-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-row-list.js b/src/components/node-list/node-list-row-list.js deleted file mode 100644 index 4566fbaafc..0000000000 --- a/src/components/node-list/node-list-row-list.js +++ /dev/null @@ -1,86 +0,0 @@ -import React from 'react'; -import modifiers from '../../utils/modifiers'; -import NodeListRow, { nodeListRowHeight } from './node-list-row'; -import LazyList from '../lazy-list'; - -const NodeRowList = ({ - items = [], - group, - collapsed, - onItemClick, - onItemChange, - onItemMouseEnter, - onItemMouseLeave, -}) => ( - (end - start) * nodeListRowHeight} - total={items.length} - > - {({ - start, - end, - total, - listRef, - upperRef, - lowerRef, - listStyle, - upperStyle, - lowerStyle, - }) => ( -
      -
    • 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" - /> - ))} -
    - )} -
    -); - -export default NodeRowList; 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.js b/src/components/node-list/node-list.js deleted file mode 100644 index 0106c0594c..0000000000 --- a/src/components/node-list/node-list.js +++ /dev/null @@ -1,116 +0,0 @@ -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 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, - onItemClick, - onItemMouseEnter, - onItemMouseLeave, - onItemChange, - onModularPipelineToggleExpanded, - focusMode, - disabledModularPipeline, - onResetFilter, - isResetFilterActive, -}) => { - return ( -
    - - - {({ isResizing, props: { container, panelA, panelB, handle } }) => ( -
    -
    - -
    - -
    -
    -
    -
    -
    - -
    -

    - Filters -

    - -
    - -
    -
    -
    - )} - -
    - ); -}; - -export default NodeList; diff --git a/src/components/node-list/styles/_group.scss b/src/components/node-list/styles/_group.scss deleted file mode 100644 index 0d456bd2f5..0000000000 --- a/src/components/node-list/styles/_group.scss +++ /dev/null @@ -1,173 +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__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; - z-index: var.$zindex-nodelist-heading; - margin: 0; - - // Avoid pixel gap above when scrolling. - transform: translateY(-1px); - - .pipeline-nodelist__row__text { - position: relative; - opacity: 0.65; - } - - .pipeline-nodelist__row__text .pipeline-nodelist__row__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); - } -} 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/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..8a8957cf61 --- /dev/null +++ b/src/components/nodes-panel/nodes-panel.js @@ -0,0 +1,140 @@ +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, + expanded, + focusMode, + handleItemMouseEnter, + handleItemMouseLeave, + handleKeyDown, + handleModularPipelineToggleExpanded, + handleNodeListRowChanged, + handleNodeListRowClicked, + handleToggleHoveredFocusMode, + isSlicingPipelineApplied, + modularPipelinesTree, + selectedNodes, + slicedPipeline, + nodesDisabledViaModularPipeline, + } = 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 74% rename from src/components/node-list/node-list.test.js rename to src/components/nodes-panel/nodes-panel.test.js index edceb82879..8d56c56adc 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,11 +27,11 @@ describe('NodeList', () => { it('renders without crashing', () => { const wrapper = setup.mount( - + ); 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); }); @@ -40,7 +40,7 @@ describe('NodeList', () => { describe('displays nodes matching search value', () => { const wrapper = setup.mount( - + ); @@ -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); @@ -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'); @@ -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); @@ -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'); @@ -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,13 +192,13 @@ 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', () => { 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)], @@ -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,52 +248,19 @@ 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); - }; - - 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); - 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( - + , { beforeLayoutActions: [() => toggleTypeDisabled('parameters', false)], @@ -313,10 +280,10 @@ describe('NodeList', () => { it('adds a class to tag group item when all tags unchecked', () => { const wrapper = setup.mount( - + ); - const uncheckedClass = 'pipeline-nodelist__group--all-unchecked'; + const uncheckedClass = 'filters-section--all-unchecked'; expect(tagItem(wrapper).hasClass(uncheckedClass)).toBe(true); changeRows(wrapper, ['Preprocessing'], true); @@ -328,28 +295,38 @@ describe('NodeList', () => { it('adds a class to the row when a tag row unchecked', () => { const wrapper = setup.mount( - + ); - 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', () => { const wrapper = setup.mount( - + ); @@ -366,13 +343,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); @@ -383,28 +360,28 @@ describe('NodeList', () => { }); }); + // FILTER GROUP describe('node list', () => { it('renders the correct number of tags in the filter panel', () => { const wrapper = setup.mount( - + ); - const nodeList = wrapper.find( - '.pipeline-nodelist__list--nested .pipeline-nodelist__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); }); + 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 ); @@ -416,7 +393,7 @@ describe('NodeList', () => { it('renders elements panel, filter panel inside a SplitPanel with a handle', () => { const wrapper = setup.mount( - + ); const split = wrapper.find(SplitPanel); @@ -437,33 +414,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', { @@ -493,13 +450,11 @@ describe('NodeList', () => { describe('Reset node filters', () => { const wrapper = setup.mount( - + ); - 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); @@ -507,7 +462,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'); @@ -526,30 +481,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..a7c4cde3fc --- /dev/null +++ b/src/components/nodes-panel/utils/node-list-context.js @@ -0,0 +1,232 @@ +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'; +import { getnodesDisabledViaModularPipeline } from '../../../selectors/disabled'; + +// Custom hook to group useSelector calls +const useNodeListContextSelector = () => { + const dispatch = useDispatch(); + const hoveredNode = useSelector((state) => state.node.hovered); + const selectedNodes = useSelector(getNodeSelected); + const nodesDisabledViaModularPipeline = useSelector( + getnodesDisabledViaModularPipeline + ); + 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, + nodesDisabledViaModularPipeline, + 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, + nodesDisabledViaModularPipeline, + 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 = ({ >
    - +