diff --git a/cypress/tests/ui/flowchart/flowchart.cy.js b/cypress/tests/ui/flowchart/flowchart.cy.js index a2eee6fbde..f3f241dcb3 100644 --- a/cypress/tests/ui/flowchart/flowchart.cy.js +++ b/cypress/tests/ui/flowchart/flowchart.cy.js @@ -70,7 +70,7 @@ describe('Flowchart DAG', () => { const nodeToToggleText = 'Parameters'; // Alias - cy.get(`.pipeline-nodelist__row__checkbox[name=${nodeToToggleText}]`).as( + cy.get(`.toggle-control__checkbox[name=${nodeToToggleText}]`).as( 'nodeToToggle' ); diff --git a/cypress/tests/ui/flowchart/menu.cy.js b/cypress/tests/ui/flowchart/menu.cy.js index deb6d38f81..5571528339 100644 --- a/cypress/tests/ui/flowchart/menu.cy.js +++ b/cypress/tests/ui/flowchart/menu.cy.js @@ -41,7 +41,7 @@ describe('Flowchart Menu', () => { }); // Pipeline Label in the Menu - cy.get('.pipeline-nodelist__row__label') + cy.get('.row-text__label') .first() .invoke('text') .should((pipelineLabel) => { @@ -57,7 +57,7 @@ describe('Flowchart Menu', () => { cy.get('.search-input__field').type(searchInput, { force: true }); // Pipeline Label in the Menu - cy.get('.pipeline-nodelist__row__label') + cy.get('.row-text__label') .first() .invoke('text') .should((pipelineLabel) => { @@ -72,7 +72,7 @@ describe('Flowchart Menu', () => { // Action cy.get( - `.MuiTreeItem-label > .pipeline-nodelist__row > [data-test=nodelist-data-${nodeToClickText}]` + `.MuiTreeItem-label > .node-list-tree-item-row > [data-test=node-list-tree-item--row--${nodeToClickText}]` ) .should('exist') .as('nodeToClick'); @@ -91,7 +91,7 @@ describe('Flowchart Menu', () => { // Action cy.get( - `.MuiTreeItem-label > .pipeline-nodelist__row > [data-test=nodelist-data-${nodeToHighlightText}]` + `.MuiTreeItem-label > .node-list-tree-item-row > [data-test=node-list-tree-item--row--${nodeToHighlightText}]` ) .should('exist') .as('nodeToHighlight'); @@ -108,7 +108,7 @@ describe('Flowchart Menu', () => { const nodeToToggleText = 'Companies'; // Alias - cy.get(`.pipeline-nodelist__row__checkbox[name=${nodeToToggleText}]`, { + cy.get(`.toggle-control__checkbox[name=${nodeToToggleText}]`, { timeout: 5000, }).as('nodeToToggle'); @@ -121,7 +121,7 @@ describe('Flowchart Menu', () => { // Assert after action cy.__checkForText__( - `[data-test=nodelist-data-${nodeToToggleText}] > .pipeline-nodelist__row__label--faded`, + `[data-test=node-list-tree-item--row--${nodeToToggleText}] > .row-text__label--faded`, nodeToToggleText ); cy.get('.pipeline-node__text').should('not.contain', nodeToToggleText); @@ -137,7 +137,7 @@ describe('Flowchart Menu', () => { // Action cy.get( - `[for=${nodeToFocusText}-focus] > .pipeline-nodelist__row__icon` + `[for=feature_engineering-focus]` ).click(); // Assert after action @@ -161,34 +161,34 @@ describe('Flowchart Menu', () => { const visibleRowLabel = 'Companies'; // Alias - cy.get(`.pipeline-nodelist__row__checkbox[name=${nodeToToggleText}]`).as( + cy.get(`.toggle-control__checkbox[name=${nodeToToggleText}]`).as( 'nodeToToggle' ); // Assert before action cy.get('@nodeToToggle').should('be.checked'); cy.get( - `[data-test=nodelist-data-${visibleRowLabel}] > .pipeline-nodelist__row__label` + `[data-test=node-list-tree-item--row--${visibleRowLabel}] > .row-text__label` ) - .should('not.have.class', 'pipeline-nodelist__row__label--faded') - .should('not.have.class', 'pipeline-nodelist__row__label--disabled'); + .should('not.have.class', 'row-text__label--faded') + .should('not.have.class', 'row-text__label--disabled'); // Action cy.get('@nodeToToggle').uncheck({ force: true }); // Assert after action cy.get( - `[data-test=nodelist-data-${visibleRowLabel}] > .pipeline-nodelist__row__label` + `[data-test=node-list-tree-item--row--${visibleRowLabel}] > .row-text__label` ) - .should('have.class', 'pipeline-nodelist__row__label--faded') - .should('have.class', 'pipeline-nodelist__row__label--disabled'); + .should('have.class', 'row-text__label--faded') + .should('have.class', 'row-text__label--disabled'); }); it('verifies that after checking node type URL should be updated with correct query params', () => { const nodeToToggleText = 'Parameters'; // Alias - cy.get(`.pipeline-nodelist__row__checkbox[name=${nodeToToggleText}]`).as( + cy.get(`.toggle-control__checkbox[name=${nodeToToggleText}]`).as( 'nodeToToggle' ); @@ -207,7 +207,7 @@ describe('Flowchart Menu', () => { cy.visit(`/?tags=${visibleRowLabel}`); // Alias - cy.get(`.pipeline-nodelist__row__checkbox[name=${visibleRowLabel}]`).as( + cy.get(`.toggle-control__checkbox[name=${visibleRowLabel}]`).as( 'nodeToToggle' ); @@ -220,7 +220,7 @@ describe('Flowchart Menu', () => { cy.visit('/?types=datasets'); // Alias - cy.get(`.pipeline-nodelist__row__checkbox[name=${visibleRowLabel}]`).as( + cy.get(`.toggle-control__checkbox[name=${visibleRowLabel}]`).as( 'nodeToToggle' ); diff --git a/cypress/tests/ui/toolbar/global-toolbar.cy.js b/cypress/tests/ui/toolbar/global-toolbar.cy.js index a8f6434968..64971aa1d7 100644 --- a/cypress/tests/ui/toolbar/global-toolbar.cy.js +++ b/cypress/tests/ui/toolbar/global-toolbar.cy.js @@ -81,14 +81,14 @@ describe('Global Toolbar', () => { cy.get('@isPrettyNameCheckbox').should('be.checked'); // Menu - cy.get(`[data-test="nodelist-modularPipeline-${prettifyName(modularPipelineText)}"]`).click(); - cy.get(`[data-test="nodelist-${nodeNameType}-${prettyNodeNameText}"]`).should('exist'); + cy.get(`[data-test="node-list-tree-item--row--${prettifyName(modularPipelineText)}"]`).click(); + cy.get(`[data-test="node-list-tree-item--row--${prettyNodeNameText}"]`).should('exist'); // Flowchart cy.get('.pipeline-node__text').should('contain', prettyNodeNameText); // Metadata - cy.get(`[data-test="nodelist-${nodeNameType}-${prettyNodeNameText}"]`).click({ force: true }); + cy.get(`[data-test="node-list-tree-item--row--${prettyNodeNameText}"]`).click({ force: true }); cy.get('.pipeline-metadata__title').should( 'have.text', prettyNodeNameText @@ -106,7 +106,7 @@ describe('Global Toolbar', () => { // Assert after action cy.__waitForPageLoad__(() => { // Menu - cy.get(`[data-test="nodelist-${nodeNameType}-${originalNodeNameText}"]`).should('exist'); + cy.get(`[data-test="node-list-tree-item--row--${originalNodeNameText}"]`).should('exist'); // Flowchart cy.get('.pipeline-node__text').should('contain', originalNodeNameText); diff --git a/src/components/filter-row/filter-row.js b/src/components/filter-row/filter-row.js new file mode 100755 index 0000000000..baa8cc772c --- /dev/null +++ b/src/components/filter-row/filter-row.js @@ -0,0 +1,66 @@ +import React from 'react'; +import classnames from 'classnames'; +import IndicatorIcon from '../icons/indicator'; +import OffIndicatorIcon from '../icons/indicator-off'; +import { ToggleControl } from '../ui/toggle-control/toggle-control'; +import { RowText } from '../ui/row-text/row-text'; + +import './filter-row.scss'; + +export const FilterRow = ({ + allUnchecked, + checked, + children, + container: ContainerWrapper, + count, + dataTest, + id, + indicatorIcon = IndicatorIcon, + kind, + label, + name, + offIndicatorIcon = OffIndicatorIcon, + onChange, + onClick, + parentClassName, + visible, +}) => { + const Icon = checked ? indicatorIcon : offIndicatorIcon; + + return ( + + + + {count} + + + {children} + + ); +}; diff --git a/src/components/filter-row/filter-row.scss b/src/components/filter-row/filter-row.scss new file mode 100644 index 0000000000..ff23a5d05e --- /dev/null +++ b/src/components/filter-row/filter-row.scss @@ -0,0 +1,54 @@ +@use '../../styles/variables' as var; +@use '../node-list/styles/variables'; + +.MuiTreeItem-iconContainer svg { + z-index: var.$zindex-MuiTreeItem-icon; +} + +.filter-row { + align-items: center; + background-color: initial; + cursor: default; + display: flex; + height: 32px; + position: relative; + + &--kind-filter { + padding: 0 variables.$row-offset-right 0 variables.$row-offset-left; + } + + &--visible:hover { + background-color: var(--color-nodelist-row-active); + } +} + +.filter-row__count { + display: inline-block; + flex-shrink: 0; + width: 2.2em; + margin: 0 0.7em 0.1em auto; + overflow: hidden; + font-size: 1.16em; + text-align: right; + text-overflow: ellipsis; + opacity: 0.75; + user-select: none; + + .filter-row--unchecked & { + opacity: 0.55; + } +} + +.filter-row--unchecked { + // Fade row text when unchecked + .row-text__label--kind-filter { + opacity: 0.55; + } + + // Brighter row text when unchecked and hovered + &:hover { + .row-text__label--kind-filter { + opacity: 0.8; + } + } +} diff --git a/src/components/filter-row/filter-row.test.js b/src/components/filter-row/filter-row.test.js new file mode 100644 index 0000000000..3e1a31a203 --- /dev/null +++ b/src/components/filter-row/filter-row.test.js @@ -0,0 +1,24 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { FilterRow } from './filter-row'; + +describe('FilterRow Component', () => { + it('renders without crashing', () => { + const wrapper = mount(); + expect(wrapper.exists()).toBe(true); + }); + + it('renders correct visible classnames', () => { + const wrapper = mount(); + expect(wrapper.find('.filter-row').hasClass('filter-row--visible')).toBe( + true + ); + }); + + it('renders correct unchecked classnames', () => { + const wrapper = mount(); + expect(wrapper.find('.filter-row').hasClass('filter-row--unchecked')).toBe( + true + ); + }); +}); diff --git a/src/components/node-list/components/row/row.js b/src/components/node-list/components/row/row.js new file mode 100755 index 0000000000..416bcb4947 --- /dev/null +++ b/src/components/node-list/components/row/row.js @@ -0,0 +1,113 @@ +import React from 'react'; +import classnames from 'classnames'; +import NodeIcon from '../../../icons/node-icon'; +import VisibleIcon from '../../../icons/visible'; +import InvisibleIcon from '../../../icons/invisible'; +import FocusModeIcon from '../../../icons/focus-mode'; +import { ToggleControl } from '../../../ui/toggle-control/toggle-control'; +import { RowText } from '../../../ui/row-text/row-text'; + +import './row.scss'; + +const Row = ({ + active, + checked, + children, + dataTest, + disabled, + faded, + focused, + focusModeIcon = FocusModeIcon, + highlight, + icon, + id, + invisibleIcon = InvisibleIcon, + isSlicingPipelineApplied, + kind, + label, + name, + onChange, + onClick, + onMouseEnter, + onMouseLeave, + onToggleHoveredFocusMode, + parentClassName, + rowType, + selected, + type, + visibleIcon = VisibleIcon, +}) => { + const isModularPipeline = type === 'modularPipeline'; + const FocusIcon = isModularPipeline ? focusModeIcon : null; + const isChecked = isModularPipeline ? checked || focused : checked; + const VisibilityIcon = isChecked ? visibleIcon : invisibleIcon; + + return ( +
+ + + {VisibilityIcon && ( + + )} + {FocusIcon && ( + + )} + + ); +}; + +export default Row; diff --git a/src/components/node-list/components/row/row.scss b/src/components/node-list/components/row/row.scss new file mode 100755 index 0000000000..99606506ca --- /dev/null +++ b/src/components/node-list/components/row/row.scss @@ -0,0 +1,83 @@ +@use '../../../../styles/variables' as var; +@use '../../styles/variables'; + +.MuiTreeItem-iconContainer svg { + z-index: var.$zindex-MuiTreeItem-icon; +} + +.row { + align-items: center; + cursor: default; + display: flex; + height: 32px; + position: relative; + transform: translate(0, 0); + + &:hover, + &--selected { + // Additional selector required to increase specificity to override previous rule + background-color: var(--color-nodelist-row-selected); + border-right: 1px solid var.$blue-300; + } + + // to ensure the background of the row covers the full width on hover + &::before { + position: absolute; + top: 0; + bottom: 0; + left: -100px; + width: 100px; + background: var(--color-nodelist-row-selected); + transform: translate(0, 0); + opacity: 0; + content: ' '; + pointer-events: none; + } +} + +.MuiTreeItem-content:hover { + .row__type-icon path { + opacity: 1; + } +} + +.row--active::before, +.row--selected::before, +.row:hover::before { + opacity: 1; +} + +.row__icon { + display: block; + flex-shrink: 0; + width: variables.$row-icon-size; + height: variables.$row-icon-size; + fill: var(--color-text); + + &--disabled > * { + opacity: 0.1; + } +} + +.row__type-icon { + &--nested > * { + opacity: 0.3; + } + + &--faded > * { + opacity: 0.2; + } + + &--active, + &--selected, + .row--visible:hover &, + [data-whatintent='keyboard'] .row__text:focus & { + > * { + opacity: 1; + } + + &--faded > * { + opacity: 0.55; + } + } +} diff --git a/src/components/node-list/components/row/row.test.js b/src/components/node-list/components/row/row.test.js new file mode 100644 index 0000000000..42294ab8dd --- /dev/null +++ b/src/components/node-list/components/row/row.test.js @@ -0,0 +1,66 @@ +import React from 'react'; +import Row from './row'; +import { setup } from '../../../../utils/state.mock'; + +// Mock props +const mockProps = { + name: 'Test Row', + kind: 'modular-pipeline', + active: false, + disabled: false, + selected: false, + visible: true, + onMouseEnter: jest.fn(), + onMouseLeave: jest.fn(), + onClick: jest.fn(), + icon: null, + type: 'modularPipeline', + checked: true, + focused: false, +}; + +describe('Row Component', () => { + it('renders without crashing', () => { + expect(() => setup.mount()).not.toThrow(); + }); + + it('handles mouseenter events', () => { + const wrapper = setup.mount(); + const nodeRow = () => wrapper.find('.row'); + nodeRow().simulate('mouseenter'); + expect(mockProps.onMouseEnter.mock.calls.length).toEqual(1); + }); + + it('handles mouseleave events', () => { + const wrapper = setup.mount(); + const nodeRow = () => wrapper.find('.row'); + nodeRow().simulate('mouseleave'); + expect(mockProps.onMouseLeave.mock.calls.length).toEqual(1); + }); + + it('applies the row--active class when active is true', () => { + const wrapper = setup.mount(); + expect(wrapper.find('.row').hasClass('row--active')).toBe(true); + }); + + it('applies the row--selected class when selected is true', () => { + const wrapper = setup.mount(); + expect(wrapper.find('.row').hasClass('row--selected')).toBe(true); + }); + + it('applies the row--selected class when highlight is true and isSlicingPipelineApplied is false', () => { + const wrapper = setup.mount( + + ); + expect(wrapper.find('.row').hasClass('row--selected')).toBe(true); + }); + + it('applies the overwrite class if not selected or active', () => { + const activeNodeWrapper = setup.mount( + + ); + expect(activeNodeWrapper.find('.row').hasClass('row--overwrite')).toBe( + true + ); + }); +}); diff --git a/src/components/node-list/index.js b/src/components/node-list/index.js index 74353d8944..da2ea0984a 100644 --- a/src/components/node-list/index.js +++ b/src/components/node-list/index.js @@ -26,7 +26,11 @@ import { } from '../../selectors/nodes'; import { toggleTagActive, toggleTagFilter } from '../../actions/tags'; import { toggleTypeDisabled } from '../../actions/node-type'; -import { toggleParametersHovered, toggleFocusMode } from '../../actions'; +import { + toggleParametersHovered, + toggleFocusMode, + toggleHoveredFocusMode, +} from '../../actions'; import { toggleModularPipelineActive, toggleModularPipelineDisabled, @@ -64,6 +68,7 @@ const NodeListProvider = ({ onToggleModularPipelineExpanded, onToggleTypeDisabled, onToggleFocusMode, + onToggleHoveredFocusMode, modularPipelinesTree, focusMode, disabledModularPipeline, @@ -100,7 +105,7 @@ const NodeListProvider = ({ const groups = getGroups({ items }); - const onItemClick = (item) => { + const onItemClick = (event, item) => { if (isGroupType(item.type)) { onGroupItemChange(item, item.checked); } else if (isModularPipelineType(item.type)) { @@ -118,6 +123,9 @@ const NodeListProvider = ({ } } } + + // to prevent page reload on form submission + event.preventDefault(); }; // To get existing values from URL query parameters @@ -315,6 +323,7 @@ const NodeListProvider = ({ onItemClick={onItemClick} onItemMouseEnter={onItemMouseEnter} onItemMouseLeave={onItemMouseLeave} + onToggleHoveredFocusMode={onToggleHoveredFocusMode} onItemChange={onItemChange} focusMode={focusMode} disabledModularPipeline={disabledModularPipeline} @@ -371,6 +380,9 @@ export const mapDispatchToProps = (dispatch) => ({ onToggleFocusMode: (modularPipeline) => { dispatch(toggleFocusMode(modularPipeline)); }, + onToggleHoveredFocusMode: (active) => { + dispatch(toggleHoveredFocusMode(active)); + }, onResetSlicePipeline: () => { dispatch(resetSlicePipeline()); }, diff --git a/src/components/node-list/node-list-group.js b/src/components/node-list/node-list-group.js index 9b54a2d72b..4d68df9b19 100644 --- a/src/components/node-list/node-list-group.js +++ b/src/components/node-list/node-list-group.js @@ -1,6 +1,6 @@ import React from 'react'; import classnames from 'classnames'; -import NodeListRow from './node-list-row'; +import { FilterRow } from '../filter-row/filter-row'; import NodeRowList from './node-list-row-list'; export const NodeListGroup = ({ @@ -35,12 +35,12 @@ export const NodeListGroup = ({ )} >

-

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