diff --git a/.github/img/backend-architecture.png b/.github/img/backend-architecture.png new file mode 100644 index 0000000000..3451638ab1 Binary files /dev/null and b/.github/img/backend-architecture.png differ diff --git a/.github/img/frontend-architecture.png b/.github/img/frontend-architecture.png new file mode 100644 index 0000000000..7eb7ce5bc5 Binary files /dev/null and b/.github/img/frontend-architecture.png differ diff --git a/.github/workflows/label-community-issues.yml b/.github/workflows/label-community-issues.yml new file mode 100644 index 0000000000..e1c1ce1180 --- /dev/null +++ b/.github/workflows/label-community-issues.yml @@ -0,0 +1,48 @@ +name: Label Community Issues + +on: + issues: + types: + - opened + +jobs: + label: + runs-on: ubuntu-latest + steps: + - name: Check if issue author is a member of Kedro org + uses: actions/github-script@v6 + id: membership + with: + github-token: ${{ secrets.GH_TAGGING_TOKEN }} + result-encoding: string + script: | + + try { + const result = await github.rest.orgs.getMembershipForUser({ + org: "kedro-org", + username: '${{ github.actor }}' + }) + + console.log(result?.data?.state) + if (result?.data?.state == "active"){ + console.log("%s: detected as an active member of Kedro org", '${{ github.actor }}') + return "member"; + } else { + console.log("%s: not detected as active member of Kedro org", '${{ github.actor }}') + return "notMember"; + } + + } catch (error) { + console.log("%s: Error occured and marked user as notMember", '${{ github.actor }}') + console.log("Error", error.stack); + console.log("Error", error.name); + console.log("Error", error.message); + return "notMember"; + } + + - name: Label issue if author is from community + if: ${{ steps.membership.outputs.result == 'notMember' }} + uses: actions-ecosystem/action-add-labels@v1 + with: + github_token: ${{ secrets.GH_TAGGING_TOKEN }} + labels: 'Community' diff --git a/.github/workflows/no-response.yml b/.github/workflows/no-response.yml new file mode 100644 index 0000000000..b11c9be736 --- /dev/null +++ b/.github/workflows/no-response.yml @@ -0,0 +1,20 @@ +name: No Response + +on: + issue_comment: + types: [created] + schedule: + # Run every day at 9am (UTC time) + - cron: '0 9 * * *' + +jobs: + noResponse: + runs-on: ubuntu-latest + steps: + - uses: lee-dohm/no-response@v0.5.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + responseRequiredLabel: "support: needs more info" + daysUntilClose: 28 + closeComment: >- + This issue has been closed due to lack of information. Feel free to re-open this issue if you're facing a similar problem. Please provide as much information as possible so we can help resolve your issue. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e8475448ae..4abdfce6cf 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -6,6 +6,7 @@ For further information, see also: - [Kedro-Viz contributing documentation](CONTRIBUTING.md), which covers how to start development on the project - [Kedro-Viz style guide](STYLE_GUIDE.md), which walks through our standards and recommended best practices for our codebase +- [Kedro-Viz Architecture Diagram](https://miro.com/app/board/uXjVKhNg1RE=/?moveToWidget=3458764606468376036&cot=10), to see a high level overview of both back-end and front-end and how they are connected. ## High-level Overview @@ -62,7 +63,7 @@ The `localStorage` state is updated automatically on every Redux store update, v ## Data ingestion -![Kedro-Viz data flow diagram](/.github/img/app-architecture-data-flow.png) +![Kedro-Viz data flow diagram](/.github/img/frontend-architecture.png) Kedro-Viz currently utilizes two different methods of data ingestion: the Redux setup for the pipeline and flowchart-view related components, and GraphQL via Apollo Client for the experiment tracking components. @@ -147,3 +148,9 @@ Kedro-Viz includes a graph layout engine, for details see the [layout engine doc Our layout engine runs inside a web worker, which asynchronously performs these expensive calculations in a separate CPU thread, in order to avoid this blocking other operations on the main thread (e.g. CSS transitions and other state updates). The app uses [redux-watch](https://github.com/ExodusMovement/redux-watch) with a graph input selector to watch the store for state changes relevant to the graph layout. If the layout needs to change, this listener dispatches an asynchronous action which sends a message to the web worker to instruct it to calculate the new layout. Once the layout worker completes its calculations, it returns a new action to update the store's `state.graph` property with the new layout. Updates to the graph input state during worker calculations will interrupt the worker and cause it to start over from scratch. + +## Backend Architecture + +![Kedro-Viz backend architecture](/.github/img/backend-architecture.png) + +The backend of Kedro-Viz serves as the data provider and API layer that interacts with Kedro projects and manages data access for visualisations in the frontend. It offers both REST and GraphQL APIs to support data retrieval for the frontend, allowing access to pipeline structures, node-specific details, and experiment tracking data. Key components include the `DataAccessManager`, which interfaces with data `Repositories` to fetch and structure data. The CLI enables users launch with Kedro-Viz from the command line, while deploy and build options enables seamless sharing of pipeline visualisations on any static website hosting platform. diff --git a/RELEASE.md b/RELEASE.md index 3261588f52..61cb49bf9b 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -21,6 +21,9 @@ Please follow the established format: - Display full dataset type with library prefix in metadata panel (#2136) - Enable SQLite WAL mode for Azure ML to fix database locking issues (#2131) - Replace `flake8`, `isort`, `pylint` and `black` by `ruff` (#2149) +- Refactor `DatasetStatsHook` to avoid showing error when dataset doesn't have file size info (#2174) +- 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) # Release 10.0.0 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/docs/source/kedro-viz_visualisation.md b/docs/source/kedro-viz_visualisation.md index 04556d7e4f..de4509e5b7 100644 --- a/docs/source/kedro-viz_visualisation.md +++ b/docs/source/kedro-viz_visualisation.md @@ -194,6 +194,43 @@ The visualisation now includes the layers: ![](./images/pipeline_visualisation_with_layers.png) +Duplicated definitions like: + +```yaml +metadata: + kedro-viz: + layer: raw +``` + +can be avoided by leveraging YAML native syntax for anchors and aliases. + +Use an anchor (`&`) first, to create a reusable piece of configuration: + +```yaml +_raw_layer: &raw_layer + metadata: + kedro-viz: + layer: 01_raw +``` + +And then use aliases (`*`) to reference it: + +```yaml +companies: + type: pandas.CSVDataset + filepath: data/01_raw/companies.csv + <<: *raw_layer + +reviews: + type: pandas.CSVDataset + filepath: data/01_raw/reviews.csv + <<: *raw_layer + +# Same for other datasets of the raw layer... +``` + +See [this example from the Kedro docs](https://docs.kedro.org/en/stable/data/data_catalog_yaml_examples.html#load-multiple-datasets-with-similar-configuration-using-yaml-anchors) for more details. + ## Share a pipeline visualisation You can save a pipeline structure within a Kedro-Viz visualisation directly from the terminal as follows: diff --git a/package.json b/package.json index 5883aa9594..6ad5e32dce 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ }, "proxy": "http://localhost:4142/", "scripts": { - "build": "cross-env GENERATE_SOURCEMAP=false react-scripts build", + "build": "cross-env GENERATE_SOURCEMAP=false react-scripts build && cp ./build/index.html ./build/404.html", "postbuild": "rm -rf build/api", "start": "REACT_APP_DATA_SOURCE=$DATA NODE_OPTIONS=\"--dns-result-order=ipv4first\" npm-run-all -p start:app start:lib", "start:dev": "rm -rf node_modules/.cache && npm start", diff --git a/package/kedro_viz/integrations/kedro/hooks.py b/package/kedro_viz/integrations/kedro/hooks.py index 3089e61f50..88bc5be17a 100644 --- a/package/kedro_viz/integrations/kedro/hooks.py +++ b/package/kedro_viz/integrations/kedro/hooks.py @@ -7,6 +7,7 @@ from pathlib import Path, PurePosixPath from typing import Any, Union +import fsspec from kedro.framework.hooks import hook_impl from kedro.io import DataCatalog from kedro.io.core import get_filepath_str @@ -141,19 +142,26 @@ def get_file_size(self, dataset: Any) -> Union[int, None]: Args: dataset: A dataset instance for which we need the file size - Returns: file size for the dataset if file_path is valid, if not returns None + Returns: + File size for the dataset if available, otherwise None. """ - - if not (hasattr(dataset, "_filepath") and dataset._filepath): - return None - try: - file_path = get_filepath_str( - PurePosixPath(dataset._filepath), dataset._protocol - ) - return dataset._fs.size(file_path) + if hasattr(dataset, "filepath") and dataset.filepath: + filepath = dataset.filepath + # Fallback to private '_filepath' for known datasets + elif hasattr(dataset, "_filepath") and dataset._filepath: + filepath = dataset._filepath + else: + return None + + fs, path_in_fs = fsspec.core.url_to_fs(filepath) + if fs.exists(path_in_fs): + file_size = fs.size(path_in_fs) + return file_size + else: + return None - except Exception as exc: + except Exception as exc: # pragma: no cover logger.warning( "Unable to get file size for the dataset %s: %s", dataset, exc ) diff --git a/package/kedro_viz/launchers/cli/run.py b/package/kedro_viz/launchers/cli/run.py index b2e74a48be..e4093b940f 100644 --- a/package/kedro_viz/launchers/cli/run.py +++ b/package/kedro_viz/launchers/cli/run.py @@ -115,6 +115,7 @@ def run( from kedro_viz.launchers.utils import ( _PYPROJECT, _check_viz_up, + _find_available_port, _find_kedro_project, _start_browser, _wait_for, @@ -145,6 +146,9 @@ def run( "https://github.com/kedro-org/kedro-viz/releases.", "yellow", ) + + port = _find_available_port(host, port) + try: if port in _VIZ_PROCESSES and _VIZ_PROCESSES[port].is_alive(): _VIZ_PROCESSES[port].terminate() @@ -186,7 +190,6 @@ def run( ) display_cli_message("Starting Kedro Viz ...", "green") - viz_process.start() _VIZ_PROCESSES[port] = viz_process diff --git a/package/kedro_viz/launchers/utils.py b/package/kedro_viz/launchers/utils.py index 5c6bbae9e3..50f8e6e849 100644 --- a/package/kedro_viz/launchers/utils.py +++ b/package/kedro_viz/launchers/utils.py @@ -2,6 +2,8 @@ used in the `kedro_viz.launchers` package.""" import logging +import socket +import sys import webbrowser from pathlib import Path from time import sleep, time @@ -80,6 +82,33 @@ def _check_viz_up(host: str, port: int): return response.status_code == 200 +def _is_port_in_use(host: str, port: int): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + return s.connect_ex((host, port)) == 0 + + +def _find_available_port(host: str, start_port: int, max_attempts: int = 5) -> int: + max_port = start_port + max_attempts - 1 + port = start_port + while port <= max_port: + if not _is_port_in_use(host, port): + return port + display_cli_message( + f"Port {port} is already in use. Trying the next port...", + "yellow", + ) + port += 1 + display_cli_message( + f"Error: All ports in the range {start_port}-{max_port} are in use.", + "red", + ) + display_cli_message( + "Please specify a different port using the '--port' option.", + "red", + ) + sys.exit(1) + + def _is_localhost(host: str) -> bool: """Check whether a host is a localhost""" return host in ("127.0.0.1", "localhost", "0.0.0.0") diff --git a/package/tests/test_integrations/test_hooks.py b/package/tests/test_integrations/test_hooks.py index 2f6d7dd132..600c594d15 100644 --- a/package/tests/test_integrations/test_hooks.py +++ b/package/tests/test_integrations/test_hooks.py @@ -137,3 +137,44 @@ def test_get_file_size(dataset, example_dataset_stats_hook_obj, example_csv_data assert example_dataset_stats_hook_obj.get_file_size( example_csv_dataset ) == example_csv_dataset._fs.size(file_path) + + +def test_get_file_size_file_does_not_exist(example_dataset_stats_hook_obj, mocker): + class MockDataset: + def __init__(self): + self._filepath = "/non/existent/path.csv" + + mock_dataset = MockDataset() + mock_fs = mocker.Mock() + mock_fs.exists.return_value = False + + mocker.patch( + "fsspec.core.url_to_fs", + return_value=(mock_fs, "/non/existent/path.csv"), + ) + + # Call get_file_size and expect it to return None + file_size = example_dataset_stats_hook_obj.get_file_size(mock_dataset) + assert file_size is None + + +def test_get_file_size_public_filepath(example_dataset_stats_hook_obj, mocker): + class MockDataset: + def __init__(self): + self.filepath = "/path/to/existing/file.csv" + + mock_dataset = MockDataset() + + # Mock fs.exists to return True + mock_fs = mocker.Mock() + mock_fs.exists.return_value = True + mock_fs.size.return_value = 456 + + mocker.patch( + "fsspec.core.url_to_fs", + return_value=(mock_fs, "/path/to/existing/file.csv"), + ) + + # Call get_file_size and expect it to return the mocked file size + file_size = example_dataset_stats_hook_obj.get_file_size(mock_dataset) + assert file_size == 456 diff --git a/package/tests/test_launchers/test_cli/test_run.py b/package/tests/test_launchers/test_cli/test_run.py index 86adae92f6..95a809d2ed 100644 --- a/package/tests/test_launchers/test_cli/test_run.py +++ b/package/tests/test_launchers/test_cli/test_run.py @@ -10,7 +10,7 @@ from kedro_viz.autoreload_file_filter import AutoreloadFileFilter from kedro_viz.launchers.cli import main from kedro_viz.launchers.cli.run import _VIZ_PROCESSES -from kedro_viz.launchers.utils import _PYPROJECT +from kedro_viz.launchers.utils import _PYPROJECT, _find_available_port from kedro_viz.server import run_server @@ -217,6 +217,9 @@ def test_kedro_viz_command_run_server( "kedro_viz.launchers.utils._wait_for.__defaults__", (True, 1, True, 1) ) + # Mock _is_port_in_use to speed up test. + mocker.patch("kedro_viz.launchers.utils._is_port_in_use", return_value=False) + # Mock finding kedro project mocker.patch( "kedro_viz.launchers.utils._find_kedro_project", @@ -394,3 +397,17 @@ def test_kedro_viz_command_with_autoreload( kwargs={**run_process_kwargs}, ) assert run_process_kwargs["kwargs"]["port"] in _VIZ_PROCESSES + + # Test case to simulate port occupation and check available port selection + def test_find_available_port_with_occupied_ports(self, mocker): + mock_is_port_in_use = mocker.patch("kedro_viz.launchers.utils._is_port_in_use") + + # Mock ports 4141, 4142 being occupied and 4143 is free + mock_is_port_in_use.side_effect = [True, True, False] + + available_port = _find_available_port("127.0.0.1", 4141) + + # Assert that the function returns the first free port, 4143 + assert ( + available_port == 4143 + ), "Expected port 4143 to be returned as the available port" diff --git a/package/tests/test_launchers/test_utils.py b/package/tests/test_launchers/test_utils.py index 83e9203bd3..fd2043af75 100644 --- a/package/tests/test_launchers/test_utils.py +++ b/package/tests/test_launchers/test_utils.py @@ -7,6 +7,7 @@ from kedro_viz.launchers.utils import ( _check_viz_up, + _find_available_port, _find_kedro_project, _is_project, _start_browser, @@ -99,3 +100,20 @@ def test_toml_bad_encoding(self, mocker): def test_find_kedro_project(project_dir, is_project_found, expected, mocker): mocker.patch("kedro_viz.launchers.utils._is_project", return_value=is_project_found) assert _find_kedro_project(Path(project_dir)) == expected + + +def test_find_available_port_all_ports_occupied(mocker): + mocker.patch("kedro_viz.launchers.utils._is_port_in_use", return_value=True) + mock_display_message = mocker.patch("kedro_viz.launchers.utils.display_cli_message") + + # Check for SystemExit when all ports are occupied + with pytest.raises(SystemExit) as exit_exception: + _find_available_port("127.0.0.1", 4141, max_attempts=5) + assert exit_exception.value.code == 1 + + mock_display_message.assert_any_call( + "Error: All ports in the range 4141-4145 are in use.", "red" + ) + mock_display_message.assert_any_call( + "Please specify a different port using the '--port' option.", "red" + ) 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 72% rename from src/components/node-list/styles/_section.scss rename to src/components/filters/filters.scss index a854ce8ee8..c3b7742276 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: 0; + list-style: none; +} + +.filters__header { display: flex; justify-content: space-between; align-items: center; margin: 6px (variables.$section-title-padding-x + 0.92) 12px (variables.$section-title-padding-x + 1.06); - .pipeline-nodelist-section__title { + .filters__title { font-weight: normal; font-size: 1.6em; opacity: 0.55; @@ -25,7 +31,7 @@ margin: 0; } - .pipeline-nodelist-section__reset-filter { + .filters__reset-button { @extend %button; font-size: 1.3em; diff --git a/src/components/filters/filters.test.js b/src/components/filters/filters.test.js new file mode 100644 index 0000000000..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 83% rename from src/components/node-list/node-list-tree.js rename to src/components/node-list-tree/node-list-tree.js index 2820bfc0b5..97486df14b 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,16 +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 { getNodeDisabledViaModularPipeline } from '../../selectors/disabled'; import { isModularPipelineType } from '../../selectors/node-types'; -import { getModularPipelinesSearchResult } from '../../selectors/modular-pipelines'; -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 = { @@ -82,13 +78,14 @@ 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: node.disabledNode, @@ -99,13 +96,16 @@ const getNodeRowData = (node, disabled, selected, highlight) => { }; const TreeListProvider = ({ + hoveredNode, nodeSelected, + modularPipelinesSearchResult, nodeDisabledViaModularPipeline, searchValue, modularPipelinesTree, onItemChange, onItemMouseEnter, onItemMouseLeave, + onToggleHoveredFocusMode, onItemClick, onNodeToggleExpanded, focusMode, @@ -115,14 +115,9 @@ const TreeListProvider = ({ isSlicingPipelineApplied, }) => { // render a leaf node in the modular pipelines tree - - const modularPipelinesSearchResult = searchValue - ? getModularPipelinesSearchResult(modularPipelinesTree, searchValue) - : null; - 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. + // 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 (!node || Object.keys(node).length === 0) { return null; } @@ -134,8 +129,13 @@ const TreeListProvider = ({ const selected = nodeSelected[node.id]; const highlight = slicedPipeline.includes(node.id); - - const data = getNodeRowData(node, disabled, selected, highlight); + const data = getNodeRowData( + node, + disabled, + hoveredNode, + selected, + highlight + ); return ( ({ - nodeSelected: getNodeSelected(state), - nodeDisabledViaModularPipeline: getNodeDisabledViaModularPipeline(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 fb330418db..0000000000 --- a/src/components/node-list/index.js +++ /dev/null @@ -1,371 +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 } 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 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 c71c2f085f..0000000000 --- a/src/components/node-list/node-list.js +++ /dev/null @@ -1,114 +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, - 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..8c845108d7 --- /dev/null +++ b/src/components/nodes-panel/nodes-panel.js @@ -0,0 +1,138 @@ +import React, { useContext, useEffect, useState } from 'react'; +import debounce from 'lodash/debounce'; +import classnames from 'classnames'; +import { Scrollbars } from 'react-custom-scrollbars-2'; +import SearchList from '../search-list'; +import Filters from '../filters/filters'; +import NodeListTree from '../node-list-tree/node-list-tree'; +import SplitPanel from '../split-panel'; +import { FiltersContext } from './utils/filters-context'; +import { NodeListContext } from './utils/node-list-context'; +import { getModularPipelinesSearchResult } from '../../selectors/modular-pipelines'; +import { getFiltersSearchResult } from '../../selectors/filtered-node-list-items'; + +/** + * Scrollable list of toggleable items, with search & filter functionality + */ +const NodesPanel = ({ faded }) => { + const [searchValue, updateSearchValue] = useState(''); + + const { + groupCollapsed, + groups, + isResetFilterActive, + items, + handleGroupToggleChanged, + handleResetFilter, + handleToggleGroupCollapsed, + handleFiltersRowClicked, + } = useContext(FiltersContext); + + const { + hoveredNode, + disabledModularPipeline, + expanded, + focusMode, + handleItemMouseEnter, + handleItemMouseLeave, + handleKeyDown, + handleModularPipelineToggleExpanded, + handleNodeListRowChanged, + handleNodeListRowClicked, + handleToggleHoveredFocusMode, + isSlicingPipelineApplied, + modularPipelinesTree, + selectedNodes, + slicedPipeline, + } = useContext(NodeListContext); + + const modularPipelinesSearchResult = searchValue + ? getModularPipelinesSearchResult(modularPipelinesTree, searchValue) + : null; + + const filtersSearchResult = searchValue + ? getFiltersSearchResult(items, searchValue) + : null; + + useEffect(() => { + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }); + + return ( +
    + + + {({ isResizing, props: { container, panelA, panelB, handle } }) => ( +
    +
    + +
    + +
    +
    +
    +
    +
    + + 0 ? filtersSearchResult : items} + onGroupToggleChanged={handleGroupToggleChanged} + onItemChange={handleFiltersRowClicked} + onResetFilter={handleResetFilter} + onToggleGroupCollapsed={handleToggleGroupCollapsed} + searchValue={searchValue} + /> + +
    +
    + )} + +
    + ); +}; + +export default NodesPanel; diff --git a/src/components/node-list/node-list.test.js b/src/components/nodes-panel/nodes-panel.test.js similarity index 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..f2adc4afd5 --- /dev/null +++ b/src/components/nodes-panel/utils/node-list-context.js @@ -0,0 +1,226 @@ +import React, { createContext } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { useGeneratePathname } from '../../../utils/hooks/use-generate-pathname'; + +import { + getFocusedModularPipeline, + getModularPipelinesTree, +} from '../../../selectors/modular-pipelines'; +import { isModularPipelineType } from '../../../selectors/node-types'; +import { getNodeSelected } from '../../../selectors/nodes'; +import { getSlicedPipeline } from '../../../selectors/sliced-pipeline'; + +import { + toggleModularPipelinesExpanded, + toggleModularPipelineActive, + toggleModularPipelineDisabled, +} from '../../../actions/modular-pipelines'; +import { toggleFocusMode, toggleHoveredFocusMode } from '../../../actions'; +import { + loadNodeData, + toggleNodeHovered, + toggleNodesDisabled, +} from '../../../actions/nodes'; +import { resetSlicePipeline } from '../../../actions/slice'; + +// Custom hook to group useSelector calls +const useNodeListContextSelector = () => { + const dispatch = useDispatch(); + const hoveredNode = useSelector((state) => state.node.hovered); + const selectedNodes = useSelector(getNodeSelected); + const expanded = useSelector((state) => state.modularPipeline.expanded); + const slicedPipeline = useSelector(getSlicedPipeline); + const modularPipelinesTree = useSelector(getModularPipelinesTree); + const isSlicingPipelineApplied = useSelector((state) => state.slice.apply); + const focusMode = useSelector(getFocusedModularPipeline); + const disabledModularPipeline = useSelector( + (state) => state.modularPipeline.disabled + ); + + const onToggleFocusMode = (modularPipeline) => { + dispatch(toggleFocusMode(modularPipeline)); + }; + const onToggleHoveredFocusMode = (active) => { + dispatch(toggleHoveredFocusMode(active)); + }; + const onToggleNodeSelected = (nodeID) => { + dispatch(loadNodeData(nodeID)); + }; + const onToggleNodeHovered = (nodeID) => { + dispatch(toggleNodeHovered(nodeID)); + }; + const onToggleNodesDisabled = (nodeIDs, disabled) => { + dispatch(toggleNodesDisabled(nodeIDs, disabled)); + }; + const onToggleModularPipelineExpanded = (expanded) => { + dispatch(toggleModularPipelinesExpanded(expanded)); + }; + const onToggleModularPipelineDisabled = (modularPipelineIDs, disabled) => { + dispatch(toggleModularPipelineDisabled(modularPipelineIDs, disabled)); + }; + const onToggleModularPipelineActive = (modularPipelineIDs, active) => { + dispatch(toggleModularPipelineActive(modularPipelineIDs, active)); + }; + const onResetSlicePipeline = () => { + dispatch(resetSlicePipeline()); + }; + + return { + disabledModularPipeline, + expanded, + focusMode, + hoveredNode, + isSlicingPipelineApplied, + modularPipelinesTree, + selectedNodes, + slicedPipeline, + onResetSlicePipeline, + onToggleFocusMode, + onToggleHoveredFocusMode, + onToggleModularPipelineActive, + onToggleModularPipelineDisabled, + onToggleModularPipelineExpanded, + onToggleNodeHovered, + onToggleNodesDisabled, + onToggleNodeSelected, + }; +}; + +export const NodeListContext = createContext(); + +export const NodeListContextProvider = ({ children }) => { + const { + disabledModularPipeline, + expanded, + focusMode, + hoveredNode, + isSlicingPipelineApplied, + modularPipelinesTree, + selectedNodes, + slicedPipeline, + onResetSlicePipeline, + onToggleFocusMode, + onToggleHoveredFocusMode, + onToggleModularPipelineActive, + onToggleModularPipelineDisabled, + onToggleModularPipelineExpanded, + onToggleNodeHovered, + onToggleNodesDisabled, + onToggleNodeSelected, + } = useNodeListContextSelector(); + const { toSelectedPipeline, toSelectedNode, toFocusedModularPipeline } = + useGeneratePathname(); + + // Handle row click in the node list + const handleNodeListRowClicked = (event, item) => { + if (isModularPipelineType(item.type)) { + onToggleNodeSelected(null); + } else { + if (item.faded || item.selected) { + onToggleNodeSelected(null); + toSelectedPipeline(); + } else { + onToggleNodeSelected(item.id); + toSelectedNode(item); + // Reset the pipeline slicing filters if no slicing is currently applied + if (!isSlicingPipelineApplied) { + onResetSlicePipeline(); + } + } + } + + // Prevent page reload on form submission + event.preventDefault(); + }; + + // Handle changes in the node list row + const handleNodeListRowChanged = (item, checked, clickedIconType) => { + if (isModularPipelineType(item.type)) { + if (clickedIconType === 'focus') { + if (focusMode === null) { + onToggleFocusMode(item); + toFocusedModularPipeline(item); + + if (disabledModularPipeline[item.id]) { + onToggleModularPipelineDisabled([item.id], checked); + } + } else { + onToggleFocusMode(null); + toSelectedPipeline(); + } + } else { + onToggleModularPipelineDisabled([item.id], checked); + onToggleModularPipelineActive([item.id], false); + } + } else { + if (checked) { + onToggleNodeHovered(null); + } + + onToggleNodesDisabled([item.id], checked); + } + // reset the node data + onToggleNodeSelected(null); + onToggleNodeHovered(null); + }; + + // Handle mouse enter event on an item + const handleItemMouseEnter = (item) => { + if (isModularPipelineType(item.type)) { + onToggleModularPipelineActive(item.id, true); + return; + } + + if (item.visible) { + onToggleNodeHovered(item.id); + } + }; + + // Handle mouse leave event on an item + const handleItemMouseLeave = (item) => { + if (isModularPipelineType(item.type)) { + onToggleModularPipelineActive(item.id, false); + return; + } + if (item.visible) { + onToggleNodeHovered(null); + } + }; + + // Toggle hovered focus mode + const handleToggleHoveredFocusMode = (active) => { + onToggleHoveredFocusMode(active); + }; + + // Deselect node on Escape key + const handleKeyDown = (event) => { + if (event.keyCode === 27) { + onToggleNodeSelected(null); + } + }; + + return ( + + {children} + + ); +}; diff --git a/src/components/nodes-panel/utils/nodes-panel-context.js b/src/components/nodes-panel/utils/nodes-panel-context.js new file mode 100644 index 0000000000..aa32e99d3f --- /dev/null +++ b/src/components/nodes-panel/utils/nodes-panel-context.js @@ -0,0 +1,11 @@ +import React from 'react'; +import { NodeListContextProvider } from './node-list-context'; +import { FiltersContextProvider } from './filters-context'; + +export const NodesPanelContextProvider = ({ children }) => { + return ( + + {children} + + ); +}; diff --git a/src/components/sidebar/sidebar.js b/src/components/sidebar/sidebar.js index 653a104ba7..73b6fbcc14 100644 --- a/src/components/sidebar/sidebar.js +++ b/src/components/sidebar/sidebar.js @@ -5,7 +5,7 @@ import ExperimentPrimaryToolbar from '../experiment-tracking/experiment-primary- import FlowchartPrimaryToolbar from '../flowchart-primary-toolbar'; import MiniMap from '../minimap'; import MiniMapToolbar from '../minimap-toolbar'; -import NodeList from '../node-list'; +import NodesPanel from '../nodes-panel'; import PipelineList from '../pipeline-list'; import RunsList from '../experiment-tracking/runs-list'; @@ -88,7 +88,7 @@ export const Sidebar = ({ >
    - +