diff --git a/RELEASE.md b/RELEASE.md index 3fe5a10a3d..35d152f739 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -18,6 +18,7 @@ Please follow the established format: - Fixes design issues in metadata panel. (#2009) - Fix missing run command in metadata panel for task nodes. (#2055) - Add `UnavailableDataset` as a default dataset for `--lite` mode. (#2083) +- Add `kedro viz --lite` user warning banner UI. (#2092) # Release 9.2.0 diff --git a/cypress/fixtures/mock/compatibleMetadata.json b/cypress/fixtures/mock/compatibleMetadata.json new file mode 100644 index 0000000000..7aca2c11bf --- /dev/null +++ b/cypress/fixtures/mock/compatibleMetadata.json @@ -0,0 +1,15 @@ +{ + "has_missing_dependencies": false, + "package_compatibilities": [ + { + "package_name": "fsspec", + "package_version": "2023.9.1", + "is_compatible": true + }, + { + "package_name": "kedro-datasets", + "package_version": "2.0.0", + "is_compatible": true + } + ] +} diff --git a/cypress/fixtures/mock/package-compatibilities-incompatible.json b/cypress/fixtures/mock/inCompatibleMetadata.json similarity index 66% rename from cypress/fixtures/mock/package-compatibilities-incompatible.json rename to cypress/fixtures/mock/inCompatibleMetadata.json index d33d26f125..109ca1bfa7 100644 --- a/cypress/fixtures/mock/package-compatibilities-incompatible.json +++ b/cypress/fixtures/mock/inCompatibleMetadata.json @@ -1,12 +1,15 @@ -[ - { +{ + "has_missing_dependencies": true, + "package_compatibilities": [ + { "package_name": "fsspec", "package_version": "2023.8.1", "is_compatible": false - }, - { + }, + { "package_name": "kedro-datasets", "package_version": "1.8.0", "is_compatible": false - } -] + } + ] +} diff --git a/cypress/fixtures/mock/package-compatibilities-compatible.json b/cypress/fixtures/mock/package-compatibilities-compatible.json deleted file mode 100644 index 4480b400e9..0000000000 --- a/cypress/fixtures/mock/package-compatibilities-compatible.json +++ /dev/null @@ -1,12 +0,0 @@ -[ - { - "package_name": "fsspec", - "package_version": "2023.9.1", - "is_compatible": true - }, - { - "package_name": "kedro-datasets", - "package_version": "1.8.0", - "is_compatible": false - } -] \ No newline at end of file diff --git a/cypress/tests/ui/flowchart/banners.cy.js b/cypress/tests/ui/flowchart/banners.cy.js new file mode 100644 index 0000000000..bdeb20bb4b --- /dev/null +++ b/cypress/tests/ui/flowchart/banners.cy.js @@ -0,0 +1,32 @@ +describe('Banners in Kedro-Viz', () => { + beforeEach(() => { + // Clears localStorage before each test + cy.clearLocalStorage(); + }); + + it("shows a missing dependencies banner in viz lite mode if the kedro project dependencies are not installed.", () => { + // Intercept the network request to mock with a fixture + cy.__interceptRest__( + '/api/metadata', + 'GET', + '/mock/inCompatibleMetadata.json' + ).as("appMetadata"); + + // Action + cy.reload(); + + // Assert after action + cy.get('[data-test="flowchart-wrapper--lite-banner"]').should('exist'); + cy.get('.banner-message-body').should('contains.text', 'please install the missing Kedro project dependencies') + cy.get('.banner-message-title').should('contains.text', 'Missing dependencies') + + // Test Learn more link + cy.get(".banner a") + .should("contains.attr", "href", "https://docs.kedro.org/projects/kedro-viz/en/latest/kedro-viz_visualisation.html#visualise-a-kedro-project-without-installing-project-dependencies"); + + // Close the banner + cy.get(".banner-close").click() + cy.get('[data-test="flowchart-wrapper--lite-banner"]').should('not.exist'); + + }); +}); diff --git a/cypress/tests/ui/flowchart/shareable-urls.cy.js b/cypress/tests/ui/flowchart/shareable-urls.cy.js index a02f31c5bd..776e745ffc 100644 --- a/cypress/tests/ui/flowchart/shareable-urls.cy.js +++ b/cypress/tests/ui/flowchart/shareable-urls.cy.js @@ -7,9 +7,9 @@ describe('Shareable URLs with empty localStorage', () => { it('verifies that users can open the Deploy Kedro-Viz modal if the localStorage is empty. #TC-52', () => { // Intercept the network request to mock with a fixture cy.__interceptRest__( - '/api/package-compatibilities', + '/api/metadata', 'GET', - '/mock/package-compatibilities-compatible.json' + '/mock/compatibleMetadata.json' ); // Action @@ -25,9 +25,9 @@ describe('Shareable URLs with empty localStorage', () => { it("shows an incompatible message given the user's fsspec package version is outdated. #TC-53", () => { // Intercept the network request to mock with a fixture cy.__interceptRest__( - '/api/package-compatibilities', + '/api/metadata', 'GET', - '/mock/package-compatibilities-incompatible.json' + '/mock/inCompatibleMetadata.json' ); // Action diff --git a/package/kedro_viz/api/rest/responses.py b/package/kedro_viz/api/rest/responses.py index 09eac0cc72..2f59d33b16 100644 --- a/package/kedro_viz/api/rest/responses.py +++ b/package/kedro_viz/api/rest/responses.py @@ -4,16 +4,14 @@ import abc import json import logging -from importlib.metadata import PackageNotFoundError from typing import Any, Dict, List, Optional, Union import orjson -import packaging from fastapi.encoders import jsonable_encoder from fastapi.responses import JSONResponse, ORJSONResponse from pydantic import BaseModel, ConfigDict -from kedro_viz.api.rest.utils import get_package_version +from kedro_viz.api.rest.utils import get_package_compatibilities from kedro_viz.data_access import data_access_manager from kedro_viz.models.flowchart import ( DataNode, @@ -24,6 +22,7 @@ TranscodedDataNode, TranscodedDataNodeMetadata, ) +from kedro_viz.models.metadata import Metadata, PackageCompatibility logger = logging.getLogger(__name__) @@ -259,17 +258,24 @@ class GraphAPIResponse(BaseAPIResponse): selected_pipeline: str -class PackageCompatibilityAPIResponse(BaseAPIResponse): - package_name: str - package_version: str - is_compatible: bool +class MetadataAPIResponse(BaseAPIResponse): + has_missing_dependencies: bool = False + package_compatibilities: List[PackageCompatibility] = [] model_config = ConfigDict( json_schema_extra={ - "example": { - "package_name": "fsspec", - "package_version": "2023.9.1", - "is_compatible": True, - } + "has_missing_dependencies": False, + "package_compatibilities": [ + { + "package_name": "fsspec", + "package_version": "2024.6.1", + "is_compatible": True, + }, + { + "package_name": "kedro-datasets", + "package_version": "4.0.0", + "is_compatible": True, + }, + ], } ) @@ -372,33 +378,11 @@ def get_selected_pipeline_response(registered_pipeline_id: str): ) -def get_package_compatibilities_response( - package_requirements: Dict[str, Dict[str, str]], -) -> List[PackageCompatibilityAPIResponse]: - """API response for `/api/package_compatibility`.""" - package_requirements_response = [] - - for package_name, package_info in package_requirements.items(): - compatible_version = package_info["min_compatible_version"] - try: - package_version = get_package_version(package_name) - except PackageNotFoundError: - logger.warning(package_info["warning_message"]) - package_version = "0.0.0" - - is_compatible = packaging.version.parse( - package_version - ) >= packaging.version.parse(compatible_version) - - package_requirements_response.append( - PackageCompatibilityAPIResponse( - package_name=package_name, - package_version=package_version, - is_compatible=is_compatible, - ) - ) - - return package_requirements_response +def get_metadata_response(): + """API response for `/api/metadata`.""" + package_compatibilities = get_package_compatibilities() + Metadata.set_package_compatibilities(package_compatibilities) + return Metadata() def get_encoded_response(response: Any) -> bytes: diff --git a/package/kedro_viz/api/rest/router.py b/package/kedro_viz/api/rest/router.py index 37d93b08d5..3cd6a18e9f 100644 --- a/package/kedro_viz/api/rest/router.py +++ b/package/kedro_viz/api/rest/router.py @@ -2,23 +2,21 @@ # pylint: disable=missing-function-docstring, broad-exception-caught import logging -from typing import List from fastapi import APIRouter from fastapi.responses import JSONResponse from kedro_viz.api.rest.requests import DeployerConfiguration -from kedro_viz.constants import PACKAGE_REQUIREMENTS from kedro_viz.integrations.deployment.deployer_factory import DeployerFactory from .responses import ( APIErrorMessage, GraphAPIResponse, + MetadataAPIResponse, NodeMetadataAPIResponse, - PackageCompatibilityAPIResponse, get_default_response, + get_metadata_response, get_node_metadata_response, - get_package_compatibilities_response, get_selected_pipeline_response, ) @@ -91,17 +89,15 @@ async def deploy_kedro_viz(input_values: DeployerConfiguration): @router.get( - "/package-compatibilities", - response_model=List[PackageCompatibilityAPIResponse], + "/metadata", + response_model=MetadataAPIResponse, ) -async def get_package_compatibilities(): +async def get_metadata(): try: - return get_package_compatibilities_response(PACKAGE_REQUIREMENTS) + return get_metadata_response() except Exception as exc: - logger.exception( - "An exception occurred while getting package compatibility info : %s", exc - ) + logger.exception("An exception occurred while getting app metadata: %s", exc) return JSONResponse( status_code=500, - content={"message": "Failed to get package compatibility info"}, + content={"message": "Failed to get app metadata"}, ) diff --git a/package/kedro_viz/api/rest/utils.py b/package/kedro_viz/api/rest/utils.py index 87ed619633..dd0ba584d1 100644 --- a/package/kedro_viz/api/rest/utils.py +++ b/package/kedro_viz/api/rest/utils.py @@ -1,10 +1,49 @@ """`kedro_viz.api.rest.utils` contains utility functions used in the `kedro_viz.api.rest` package""" + +import logging +from importlib.metadata import PackageNotFoundError +from typing import List + +import packaging + +from kedro_viz.constants import PACKAGE_REQUIREMENTS +from kedro_viz.models.metadata import PackageCompatibility + try: from importlib.metadata import version except ImportError: # pragma: no cover from importlib_metadata import version +logger = logging.getLogger(__name__) + def get_package_version(package_name: str): """Returns the version of the given package.""" return version(package_name) # pragma: no cover + + +def get_package_compatibilities() -> List[PackageCompatibility]: + """Returns the package compatibilities information + for the current python env.""" + package_compatibilities: List[PackageCompatibility] = [] + + for package_name, package_info in PACKAGE_REQUIREMENTS.items(): + compatible_version = package_info["min_compatible_version"] + try: + package_version = get_package_version(package_name) + except PackageNotFoundError: + logger.warning(package_info["warning_message"]) + package_version = "0.0.0" + + is_compatible = packaging.version.parse( + package_version + ) >= packaging.version.parse(compatible_version) + + package_compatibilities.append( + PackageCompatibility( + package_name=package_name, + package_version=package_version, + is_compatible=is_compatible, + ) + ) + return package_compatibilities diff --git a/package/kedro_viz/integrations/kedro/data_loader.py b/package/kedro_viz/integrations/kedro/data_loader.py index 2bfa405076..2955d73b29 100644 --- a/package/kedro_viz/integrations/kedro/data_loader.py +++ b/package/kedro_viz/integrations/kedro/data_loader.py @@ -24,6 +24,7 @@ from kedro_viz.integrations.kedro.abstract_dataset_lite import AbstractDatasetLite from kedro_viz.integrations.kedro.lite_parser import LiteParser from kedro_viz.integrations.utils import _VizNullPluginManager +from kedro_viz.models.metadata import Metadata logger = logging.getLogger(__name__) @@ -145,6 +146,9 @@ def load_data( if unresolved_imports and len(unresolved_imports) > 0: modules_to_mock: Set[str] = set() + # for the viz lite banner + Metadata.set_has_missing_dependencies(True) + for unresolved_module_set in unresolved_imports.values(): modules_to_mock = modules_to_mock.union(unresolved_module_set) diff --git a/package/kedro_viz/models/metadata.py b/package/kedro_viz/models/metadata.py new file mode 100644 index 0000000000..debe1f04e3 --- /dev/null +++ b/package/kedro_viz/models/metadata.py @@ -0,0 +1,47 @@ +"""`kedro_viz.models.metadata` defines metadata for Kedro-Viz application.""" + +# pylint: disable=missing-function-docstring +from typing import ClassVar, List + +from pydantic import BaseModel, field_validator + + +class PackageCompatibility(BaseModel): + """Represent package compatibility in app metadata""" + + package_name: str + package_version: str + is_compatible: bool + + @field_validator("package_name") + @classmethod + def set_package_name(cls, value): + assert isinstance(value, str) + return value + + @field_validator("package_version") + @classmethod + def set_package_version(cls, value): + assert isinstance(value, str) + return value + + @field_validator("is_compatible") + @classmethod + def set_is_compatible(cls, value): + assert isinstance(value, bool) + return value + + +class Metadata(BaseModel): + """Represent Kedro-Viz application metadata""" + + has_missing_dependencies: ClassVar[bool] = False + package_compatibilities: ClassVar[List[PackageCompatibility]] = [] + + @classmethod + def set_package_compatibilities(cls, value: List[PackageCompatibility]): + cls.package_compatibilities = value + + @classmethod + def set_has_missing_dependencies(cls, value: bool): + cls.has_missing_dependencies = value diff --git a/package/tests/test_api/test_rest/test_responses.py b/package/tests/test_api/test_rest/test_responses.py index 8873cfc3c9..3f75904404 100644 --- a/package/tests/test_api/test_rest/test_responses.py +++ b/package/tests/test_api/test_rest/test_responses.py @@ -1,6 +1,5 @@ # pylint: disable=too-many-lines import json -import logging import operator from pathlib import Path from typing import Any, Dict, Iterable, List @@ -13,9 +12,8 @@ from kedro_viz.api import apps from kedro_viz.api.rest.responses import ( EnhancedORJSONResponse, - PackageCompatibilityAPIResponse, get_kedro_project_json_data, - get_package_compatibilities_response, + get_metadata_response, save_api_main_response_to_fs, save_api_node_response_to_fs, save_api_pipeline_response_to_fs, @@ -23,6 +21,7 @@ write_api_response_to_fs, ) from kedro_viz.models.flowchart import TaskNode +from kedro_viz.models.metadata import Metadata def _is_dict_list(collection: Any) -> bool: @@ -835,6 +834,28 @@ def test_get_non_existing_pipeline(self, client): assert response.status_code == 404 +class TestAppMetadata: + def test_get_metadata_response(self, mocker): + mock_get_compat = mocker.patch( + "kedro_viz.api.rest.responses.get_package_compatibilities", + return_value="mocked_compatibilities", + ) + mock_set_compat = mocker.patch( + "kedro_viz.api.rest.responses.Metadata.set_package_compatibilities" + ) + + response = get_metadata_response() + + # Assert get_package_compatibilities was called + mock_get_compat.assert_called_once() + + # Assert set_package_compatibilities was called with the mocked compatibilities + mock_set_compat.assert_called_once_with("mocked_compatibilities") + + # Assert the function returns the Metadata instance + assert isinstance(response, Metadata) + + class TestAPIAppFromFile: def test_api_app_from_json_file_main_api(self): filepath = str(Path(__file__).parent.parent) @@ -851,82 +872,6 @@ def test_api_app_from_json_file_index(self): assert response.status_code == 200 -class TestPackageCompatibilities: - @pytest.mark.parametrize( - "package_name, package_version, package_requirements, expected_compatibility_response", - [ - ( - "fsspec", - "2023.9.1", - {"fsspec": {"min_compatible_version": "2023.0.0"}}, - True, - ), - ( - "fsspec", - "2023.9.1", - {"fsspec": {"min_compatible_version": "2024.0.0"}}, - False, - ), - ( - "kedro-datasets", - "2.1.0", - {"kedro-datasets": {"min_compatible_version": "2.1.0"}}, - True, - ), - ( - "kedro-datasets", - "1.8.0", - {"kedro-datasets": {"min_compatible_version": "2.1.0"}}, - False, - ), - ], - ) - def test_get_package_compatibilities_response( - self, - package_name, - package_version, - package_requirements, - expected_compatibility_response, - mocker, - ): - mocker.patch( - "kedro_viz.api.rest.responses.get_package_version", - return_value=package_version, - ) - response = get_package_compatibilities_response(package_requirements) - - for package_response in response: - assert package_response.package_name == package_name - assert package_response.package_version == package_version - assert package_response.is_compatible is expected_compatibility_response - - def test_get_package_compatibilities_exception_response(self, caplog): - mock_package_requirement = { - "random-package": { - "min_compatible_version": "1.0.0", - "warning_message": "random-package is not available", - } - } - - with caplog.at_level(logging.WARNING): - response = get_package_compatibilities_response(mock_package_requirement) - - assert len(caplog.records) == 1 - - record = caplog.records[0] - - assert record.levelname == "WARNING" - assert ( - mock_package_requirement["random-package"]["warning_message"] - in record.message - ) - - expected_response = PackageCompatibilityAPIResponse( - package_name="random-package", package_version="0.0.0", is_compatible=False - ) - assert response == [expected_response] - - class TestEnhancedORJSONResponse: @pytest.mark.parametrize( "content, expected", diff --git a/package/tests/test_api/test_rest/test_router.py b/package/tests/test_api/test_rest/test_router.py index 9ba1de7e2c..d84f1ce0f2 100644 --- a/package/tests/test_api/test_rest/test_router.py +++ b/package/tests/test_api/test_rest/test_router.py @@ -47,42 +47,46 @@ def test_deploy_kedro_viz( ( None, 200, - [ - { - "package_name": "fsspec", - "package_version": "2023.9.1", - "is_compatible": True, - }, - { - "package_name": "kedro-datasets", - "package_version": "1.8.0", - "is_compatible": False, - }, - ], + { + "has_missing_dependencies": False, + "package_compatibilities": [ + { + "package_name": "fsspec", + "package_version": "2023.9.1", + "is_compatible": True, + }, + { + "package_name": "kedro-datasets", + "package_version": "1.8.0", + "is_compatible": False, + }, + ], + }, ), ( Exception, 500, - {"message": "Failed to get package compatibility info"}, + {"message": "Failed to get app metadata"}, ), ], ) -def test_get_package_compatibilities( +def test_metadata( client, exception_type, expected_status_code, expected_response, mocker ): # Mock the function that may raise an exception if exception_type is None: - mocker.patch( - "kedro_viz.api.rest.router.get_package_compatibilities_response", + mock_get_metadata_response = mocker.patch( + "kedro_viz.api.rest.router.get_metadata_response", return_value=expected_response, ) else: - mocker.patch( - "kedro_viz.api.rest.router.get_package_compatibilities_response", + mock_get_metadata_response = mocker.patch( + "kedro_viz.api.rest.router.get_metadata_response", side_effect=exception_type("Test Exception"), ) - response = client.get("/api/package-compatibilities") + response = client.get("/api/metadata") + mock_get_metadata_response.assert_called_once() assert response.status_code == expected_status_code assert response.json() == expected_response diff --git a/package/tests/test_api/test_rest/test_utils.py b/package/tests/test_api/test_rest/test_utils.py new file mode 100644 index 0000000000..32a69cc371 --- /dev/null +++ b/package/tests/test_api/test_rest/test_utils.py @@ -0,0 +1,92 @@ +import logging + +import pytest + +from kedro_viz.api.rest.utils import get_package_compatibilities +from kedro_viz.models.metadata import PackageCompatibility + +logger = logging.getLogger(__name__) + + +@pytest.mark.parametrize( + "package_name, package_version, package_requirements, expected_compatibility_response", + [ + ( + "fsspec", + "2023.9.1", + {"fsspec": {"min_compatible_version": "2023.0.0"}}, + True, + ), + ( + "fsspec", + "2023.9.1", + {"fsspec": {"min_compatible_version": "2024.0.0"}}, + False, + ), + ( + "kedro-datasets", + "2.1.0", + {"kedro-datasets": {"min_compatible_version": "2.1.0"}}, + True, + ), + ( + "kedro-datasets", + "1.8.0", + {"kedro-datasets": {"min_compatible_version": "2.1.0"}}, + False, + ), + ], +) +def test_get_package_compatibilities( + package_name, + package_version, + package_requirements, + expected_compatibility_response, + mocker, +): + mocker.patch( + "kedro_viz.api.rest.utils.get_package_version", + return_value=package_version, + ) + mocker.patch( + "kedro_viz.api.rest.utils.PACKAGE_REQUIREMENTS", + package_requirements, + ) + + response = get_package_compatibilities() + + for package_response in response: + assert package_response.package_name == package_name + assert package_response.package_version == package_version + assert package_response.is_compatible is expected_compatibility_response + + +def test_get_package_compatibilities_exception_response(caplog, mocker): + mock_package_requirement = { + "random-package": { + "min_compatible_version": "1.0.0", + "warning_message": "random-package is not available", + } + } + mocker.patch( + "kedro_viz.api.rest.utils.PACKAGE_REQUIREMENTS", + mock_package_requirement, + ) + + with caplog.at_level(logging.WARNING): + response = get_package_compatibilities() + + assert len(caplog.records) == 1 + + record = caplog.records[0] + + assert record.levelname == "WARNING" + assert ( + mock_package_requirement["random-package"]["warning_message"] + in record.message + ) + + expected_response = PackageCompatibility( + package_name="random-package", package_version="0.0.0", is_compatible=False + ) + assert response == [expected_response] diff --git a/package/tests/test_models/test_metadata.py b/package/tests/test_models/test_metadata.py new file mode 100644 index 0000000000..d81bb04502 --- /dev/null +++ b/package/tests/test_models/test_metadata.py @@ -0,0 +1,70 @@ +import pytest +from pydantic import ValidationError + +from kedro_viz.models.metadata import Metadata, PackageCompatibility + + +class TestPackageCompatibility: + def test_package_compatibility_valid_data(self): + package = PackageCompatibility( + package_name="kedro", package_version="0.18.0", is_compatible=True + ) + assert package.package_name == "kedro" + assert package.package_version == "0.18.0" + assert package.is_compatible is True + + def test_package_compatibility_invalid_package_name(self): + with pytest.raises(ValidationError) as excinfo: + PackageCompatibility( + package_name=123, # invalid type + package_version="0.18.0", + is_compatible=True, + ) + assert "Input should be a valid string" in str(excinfo.value) + + def test_package_compatibility_invalid_package_version(self): + with pytest.raises(ValidationError) as excinfo: + PackageCompatibility( + package_name="kedro", + package_version=123, # invalid type + is_compatible=True, + ) + assert "Input should be a valid string" in str(excinfo.value) + + def test_package_compatibility_invalid_is_compatible(self): + with pytest.raises(ValidationError) as excinfo: + PackageCompatibility( + package_name="kedro", + package_version="0.18.0", + is_compatible="random", # invalid type + ) + assert "Input should be a valid boolean" in str(excinfo.value) + + +class TestMetadata: + def test_metadata_default_values(self): + # Test default values of Metadata + assert Metadata.has_missing_dependencies is False + assert not Metadata.package_compatibilities + + def test_metadata_set_package_compatibilities(self): + kedro_package = PackageCompatibility( + package_name="kedro", package_version="0.18.0", is_compatible=True + ) + pandas_package = PackageCompatibility( + package_name="pandas", package_version="1.2.0", is_compatible=False + ) + + # Set the package compatibilities using the class method + Metadata.set_package_compatibilities([kedro_package, pandas_package]) + + # Assert the values have been set correctly + assert Metadata.package_compatibilities == [kedro_package, pandas_package] + + def test_metadata_set_has_missing_dependencies(self): + # Test changing the has_missing_dependencies value + Metadata.set_has_missing_dependencies(True) + assert Metadata.has_missing_dependencies is True + + Metadata.set_has_missing_dependencies(False) + assert Metadata.has_missing_dependencies is False diff --git a/src/actions/index.js b/src/actions/index.js index 8e30a0e7f9..e169c38900 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -230,6 +230,21 @@ export function changeFlag(name, value) { }; } +export const SET_BANNER = 'SET_BANNER'; + +/** + * Change the given banner status + * @param {String} name The banner name + * @param {Value} value The value to set + */ +export function setBanner(name, value) { + return { + type: SET_BANNER, + name, + value, + }; +} + export const TOGGLE_IGNORE_LARGE_WARNING = 'TOGGLE_IGNORE_LARGE_WARNING'; /** diff --git a/src/components/experiment-wrapper/experiment-wrapper.js b/src/components/experiment-wrapper/experiment-wrapper.js index ce9fb3b718..ce1195e7c1 100644 --- a/src/components/experiment-wrapper/experiment-wrapper.js +++ b/src/components/experiment-wrapper/experiment-wrapper.js @@ -18,7 +18,7 @@ import { PACKAGE_KEDRO_DATASETS, } from '../../config'; import { findMatchedPath } from '../../utils/match-path'; -import { fetchPackageCompatibilities } from '../../utils'; +import { fetchMetadata } from '../../utils'; import { saveLocalStorage, loadLocalStorage } from '../../store/helpers'; import './experiment-wrapper.scss'; @@ -193,23 +193,24 @@ const ExperimentWrapper = ({ theme, runsMetadata }) => { }, []); useEffect(() => { - async function fetchPackageCompatibility() { + async function checkPackageCompatibility() { try { - const request = await fetchPackageCompatibilities(); + const request = await fetchMetadata(); const response = await request.json(); if (request.ok) { - const kedroDatasetsPackage = response.find( + const packageCompatibilityInfo = response.package_compatibilities; + const kedroDatasetsPackage = packageCompatibilityInfo.find( (pckg) => pckg.package_name === PACKAGE_KEDRO_DATASETS ); setIsKedroDatasetsCompatible(kedroDatasetsPackage.is_compatible); } } catch (error) { - console.error('package-compatibilities fetch error: ', error); + console.error('metadata fetch error: ', error); } } - fetchPackageCompatibility(); + checkPackageCompatibility(); }, []); useEffect(() => { diff --git a/src/components/flowchart-wrapper/flowchart-wrapper.js b/src/components/flowchart-wrapper/flowchart-wrapper.js index 9f3837f71f..73fec53272 100644 --- a/src/components/flowchart-wrapper/flowchart-wrapper.js +++ b/src/components/flowchart-wrapper/flowchart-wrapper.js @@ -19,6 +19,7 @@ import ExportModal from '../export-modal'; import FlowChart from '../flowchart'; import PipelineWarning from '../pipeline-warning'; import LoadingIcon from '../icons/loading'; +import AlertIcon from '../icons/alert'; import MetaData from '../metadata'; import MetadataModal from '../metadata-modal'; import ShareableUrlMetadata from '../shareable-url-modal/shareable-url-metadata'; @@ -31,13 +32,18 @@ import { linkToFlowchartInitialVal, localStorageFlowchartLink, localStorageName, + localStorageBannerStatus, params, + BANNER_METADATA, + BANNER_KEYS, } from '../../config'; import { findMatchedPath } from '../../utils/match-path'; import { getKeyByValue, getKeysByValue } from '../../utils/object-utils'; import { isRunningLocally, mapNodeTypes } from '../../utils'; import { useGeneratePathname } from '../../utils/hooks/use-generate-pathname'; import './flowchart-wrapper.scss'; +import Banner from '../ui/banner'; +import { getDataTestAttribute } from '../../utils/get-data-test-attribute'; /** * Main flowchart container. Handles showing/hiding the sidebar nav for flowchart view, @@ -64,6 +70,7 @@ export const FlowChartWrapper = ({ expandAllPipelines, displayMetadataPanel, displayExportBtn, + displayBanner, }) => { const history = useHistory(); const { pathname, search } = useLocation(); @@ -304,6 +311,18 @@ export const FlowChartWrapper = ({ resetLinkingToFlowchartLocalStorage(); }; + const handleBannerClose = (bannerKey) => { + saveLocalStorage(localStorageBannerStatus, { [bannerKey]: false }); + }; + + const showBanner = (bannerKey) => { + const bannerStatus = loadLocalStorage(localStorageBannerStatus); + const shouldShowBanner = + displayBanner[bannerKey] && + (bannerStatus[bannerKey] || bannerStatus[bannerKey] === undefined); + return shouldShowBanner; + }; + if (isInvalidUrl) { return (
@@ -321,6 +340,18 @@ export const FlowChartWrapper = ({
{displaySidebar && } {displayMetadataPanel && } + {showBanner(BANNER_KEYS.LITE) && ( + } + message={{ + title: BANNER_METADATA.liteModeWarning.title, + body: BANNER_METADATA.liteModeWarning.body, + }} + btnUrl={BANNER_METADATA.liteModeWarning.docsLink} + onClose={() => handleBannerClose(BANNER_KEYS.LITE)} + dataTest={getDataTestAttribute('flowchart-wrapper', 'lite-banner')} + /> + )}
@@ -371,6 +402,7 @@ export const mapStateToProps = (state) => ({ expandAllPipelines: state.expandAllPipelines, displayMetadataPanel: state.display.metadataPanel, displayExportBtn: state.display.exportBtn, + displayBanner: state.showBanner, }); export const mapDispatchToProps = (dispatch) => ({ diff --git a/src/components/icons/alert.js b/src/components/icons/alert.js new file mode 100644 index 0000000000..401afb955c --- /dev/null +++ b/src/components/icons/alert.js @@ -0,0 +1,12 @@ +import React from 'react'; + +const AlertIcon = ({ className }) => ( + + + +); + +export default AlertIcon; diff --git a/src/components/shareable-url-modal/shareable-url-modal.js b/src/components/shareable-url-modal/shareable-url-modal.js index 19261561c8..62cfec8704 100644 --- a/src/components/shareable-url-modal/shareable-url-modal.js +++ b/src/components/shareable-url-modal/shareable-url-modal.js @@ -2,10 +2,11 @@ import React, { useEffect, useState } from 'react'; import { connect } from 'react-redux'; import classnames from 'classnames'; -import { toggleShareableUrlModal } from '../../actions'; -import { fetchPackageCompatibilities } from '../../utils'; +import { toggleShareableUrlModal, setBanner } from '../../actions'; +import { fetchMetadata } from '../../utils'; import { saveLocalStorage, loadLocalStorage } from '../../store/helpers'; import { + BANNER_KEYS, hostingPlatforms, inputKeyToStateKeyMap, localStorageShareableUrl, @@ -25,7 +26,7 @@ import { deployViz } from '../../utils'; import './shareable-url-modal.scss'; -const ShareableUrlModal = ({ onToggleModal, visible }) => { +const ShareableUrlModal = ({ onToggleModal, onSetBanner, visible }) => { const [deploymentState, setDeploymentState] = useState('default'); const [inputValues, setInputValues] = useState({}); const [isFormDirty, setIsFormDirty] = useState({ @@ -46,13 +47,18 @@ const ShareableUrlModal = ({ onToggleModal, visible }) => { const [isPreviewEnabled, setIsPreviewEnabled] = useState(false); useEffect(() => { - async function fetchPackageCompatibility() { + async function checkPackageCompatibility() { try { - const request = await fetchPackageCompatibilities(); + const request = await fetchMetadata(); const response = await request.json(); if (request.ok) { - const fsspecPackage = response.find( + onSetBanner( + BANNER_KEYS.LITE, + Boolean(response.has_missing_dependencies) + ); + const packageCompatibilityInfo = response.package_compatibilities; + const fsspecPackage = packageCompatibilityInfo.find( (pckg) => pckg.package_name === PACKAGE_FSSPEC ); setCompatibilityData(fsspecPackage); @@ -65,12 +71,12 @@ const ShareableUrlModal = ({ onToggleModal, visible }) => { } } } catch (error) { - console.error('package-compatibilities fetch error: ', error); + console.error('metadata fetch error: ', error); } } - fetchPackageCompatibility(); - }, []); + checkPackageCompatibility(); + }, [onSetBanner]); const setStateForPublishedView = () => { if (Object.keys(hostingPlatformLocalStorageVal).length > 0) { @@ -322,6 +328,9 @@ export const mapDispatchToProps = (dispatch) => ({ onToggleModal: (value) => { dispatch(toggleShareableUrlModal(value)); }, + onSetBanner: (name, value) => { + dispatch(setBanner(name, value)); + }, }); export default connect(mapStateToProps, mapDispatchToProps)(ShareableUrlModal); diff --git a/src/components/ui/banner/banner.js b/src/components/ui/banner/banner.js new file mode 100755 index 0000000000..4630045264 --- /dev/null +++ b/src/components/ui/banner/banner.js @@ -0,0 +1,70 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import './banner.scss'; +import Button from '../button'; +import CloseIcon from '../../icons/close'; + +/** + * Generic Kedro Banner + */ +const Banner = ({ + icon = null, + message, + btnUrl = null, + btnText = 'Learn More', + position = 'top', + onClose = null, + dataTest, +}) => { + const [isVisible, setIsVisible] = useState(true); + + const handleClose = () => { + setIsVisible(false); + if (onClose) { + onClose(); + } + }; + + if (!isVisible) { + return null; + } + + return ( +
+ {icon &&
{icon}
} +
+ {message.title} + {message.body} +
+ {btnUrl && ( + + + + )} +
+ +
+
+ ); +}; + +// PropTypes validation +Banner.propTypes = { + icon: PropTypes.node, + message: PropTypes.shape({ + title: PropTypes.string.isRequired, + body: PropTypes.string.isRequired, + }).isRequired, + learnMoreUrl: PropTypes.string, + theme: PropTypes.oneOf(['light', 'dark']), + position: PropTypes.oneOf(['top', 'bottom']), + onClose: PropTypes.func, +}; + +export default Banner; diff --git a/src/components/ui/banner/banner.scss b/src/components/ui/banner/banner.scss new file mode 100755 index 0000000000..cddde93cb2 --- /dev/null +++ b/src/components/ui/banner/banner.scss @@ -0,0 +1,89 @@ +/** Imports **/ +@use '../../../styles/variables' as variables; + +.kui-theme--light { + --banner--background: #{variables.$slate-300}; + --banner--color: #{variables.$white-0}; + --banner-message-body--color: #{variables.$black-0}; + --banner-box-shadow: 0px 4px 8px -1px rgb(0 0 0 / 20%); + --color-button__text--hover: #{variables.$slate-300}; + --color-button--border: #{variables.$white-0}; +} + +.kui-theme--dark { + --banner--background: #{variables.$white-200}; + --banner--color: #{variables.$black-900}; + --banner-message-body--color: #{variables.$black-200}; + --banner-box-shadow: 0px 2px 4px -1px rgb(0 0 0 / 20%); + --color-button__text--hover: #{variables.$white-200}; + --color-button--border: #{variables.$black-900}; +} + +.banner { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + width: 100%; + z-index: variables.$z-index-banner; + font-family: inherit; + box-shadow: var(--banner-box-shadow); + background-color: var(--banner--background); + color: var(--banner--color); + font-size: 16px; + max-height: 68px; + + // Banner positioning + &.banner-top { + position: fixed; + top: 0; + } + + // Icon styling + .banner-icon { + margin-right: 12px; + display: flex; + + & svg { + width: 24px; + height: 24px; + } + } + + // Message styling + .banner-message { + flex-grow: 1; + + .banner-message-title { + font-weight: bold; + } + + .banner-message-body { + margin-left: 12px; + color: var(--banner-message-body--color); + } + } + + // Learn More button styling + .button__btn { + border: 1px solid var(--banner--color); + color: var(--banner--color); + font-size: 14px; + } + + // Close icon styling + .banner-close { + cursor: pointer; + margin-left: 12px; + display: flex; + + & svg { + path { + fill: var(--banner-message-body--color); + } + + width: 24px; + height: 24px; + } + } +} diff --git a/src/components/ui/banner/banner.test.js b/src/components/ui/banner/banner.test.js new file mode 100755 index 0000000000..d8d68c1f02 --- /dev/null +++ b/src/components/ui/banner/banner.test.js @@ -0,0 +1,66 @@ +import React from 'react'; +import Banner from './banner'; +import sinon from 'sinon'; +import { mount } from 'enzyme'; + +describe('Banner', () => { + const message = { + title: 'test title', + body: 'test body', + }; + + it('shows the banner component with the required message', () => { + const wrapper = mount(); + + const bannerMessageTitle = wrapper.find('.banner-message-title'); + const bannerMessageBody = wrapper.find('.banner-message-body'); + + expect(bannerMessageTitle.text()).toEqual(message.title); + expect(bannerMessageBody.text()).toEqual(message.body); + }); + + it('renders the optional icon when provided', () => { + const wrapper = mount( + } /> + ); + expect(wrapper.find('.banner-icon').length).toBe(1); + }); + + it('does not render the optional redirect button by default', () => { + const wrapper = mount(); + expect(wrapper.find('.kedro button').length).toBe(0); + }); + + it('renders the optional redirect button when provided', () => { + const btnUrl = 'https://example.com'; + const btnText = 'Test Redirect'; + + const wrapper = mount( + + ); + + const anchorTag = wrapper.find('a'); + expect(anchorTag.prop('href')).toBe(btnUrl); + + const redirectButton = wrapper.find('button'); + expect(redirectButton.text()).toBe(btnText); + }); + + it('closes the banner when close icon is clicked', () => { + const onClose = sinon.spy(); + const wrapper = mount( + + ); + + wrapper.find('.banner-close').simulate('click'); + expect(onClose.callCount).toBe(1); + expect(wrapper.find('banner').length).toBe(0); + }); + + it('renders the banner with correct positioning class', () => { + const wrapper = mount( + + ); + expect(wrapper.find('.banner-bottom').length).toBe(1); + }); +}); diff --git a/src/components/ui/banner/index.js b/src/components/ui/banner/index.js new file mode 100644 index 0000000000..ffc0f6fe30 --- /dev/null +++ b/src/components/ui/banner/index.js @@ -0,0 +1,3 @@ +import Banner from './banner'; + +export default Banner; diff --git a/src/config.js b/src/config.js index ea25d9d9a6..fd74bca6a0 100644 --- a/src/config.js +++ b/src/config.js @@ -5,6 +5,7 @@ export const localStorageFlowchartLink = 'KedroViz-link-to-flowchart'; export const localStorageMetricsSelect = 'KedroViz-metrics-chart-select'; export const localStorageRunsMetadata = 'KedroViz-runs-metadata'; export const localStorageShareableUrl = 'KedroViz-shareable-url'; +export const localStorageBannerStatus = 'KedroViz-banners'; export const linkToFlowchartInitialVal = { fromURL: null, @@ -206,3 +207,16 @@ export const NODE_TYPES = { data: { name: 'datasets', defaultState: false }, parameters: { name: 'parameters', defaultState: true }, }; + +export const BANNER_METADATA = { + liteModeWarning: { + title: 'Missing dependencies', + body: 'For the best experience with full functionality, please install the missing Kedro project dependencies.', + docsLink: + 'https://docs.kedro.org/projects/kedro-viz/en/latest/kedro-viz_visualisation.html#visualise-a-kedro-project-without-installing-project-dependencies', + }, +}; + +export const BANNER_KEYS = { + LITE: 'lite', +}; diff --git a/src/reducers/banner.js b/src/reducers/banner.js new file mode 100755 index 0000000000..3242f9a2f9 --- /dev/null +++ b/src/reducers/banner.js @@ -0,0 +1,16 @@ +import { SET_BANNER } from '../actions'; + +function bannerReducer(bannerState = {}, action) { + switch (action.type) { + case SET_BANNER: { + return Object.assign({}, bannerState, { + [action.name]: action.value, + }); + } + + default: + return bannerState; + } +} + +export default bannerReducer; diff --git a/src/reducers/index.js b/src/reducers/index.js index eedf86d3aa..d2608fa252 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -12,6 +12,7 @@ import merge from 'lodash/merge'; import modularPipeline from './modular-pipelines'; import visible from './visible'; import slice from './slice'; +import bannerReducer from './banner'; import { RESET_DATA, TOGGLE_SHOW_FEATURE_HINTS, @@ -84,6 +85,7 @@ const combinedReducer = combineReducers({ modularPipeline, visible, runsMetadata, + showBanner: bannerReducer, // These props don't have any actions associated with them display: createReducer(null), dataSource: createReducer(null), diff --git a/src/store/initial-state.js b/src/store/initial-state.js index bbb363cb11..70e1915b17 100644 --- a/src/store/initial-state.js +++ b/src/store/initial-state.js @@ -9,6 +9,7 @@ import { localStorageName, localStorageRunsMetadata, params, + BANNER_KEYS, } from '../config'; /** @@ -26,6 +27,9 @@ export const createInitialState = () => ({ showFeatureHints: settings.showFeatureHints.default, showDatasetPreviews: settings.showDatasetPreviews.default, ignoreLargeWarning: false, + showBanner: { + [BANNER_KEYS.LITE]: false, + }, loading: { graph: false, pipeline: false, diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss index c34c6cbb8e..83fc27b943 100644 --- a/src/styles/_variables.scss +++ b/src/styles/_variables.scss @@ -31,6 +31,7 @@ $run-list-controls-height: 95px; $z-index-metadata-panel: 3; $z-index-metadata-code: 3; $z-index-MuiTreeItem-icon: 1; +$z-index-banner: 6; // Micro-animation $run-width: 375px; diff --git a/src/utils/index.js b/src/utils/index.js index 73fef42c4c..2b3e1b9061 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -220,11 +220,11 @@ export const sanitizedPathname = () => { }; /** - * Fetches package compatibilities from the server. - * @returns {Promise} A promise that resolves to the fetched package compatibilities. + * Fetches viz metadata from the server. + * @returns {Promise} A promise that resolves the fetched viz metadata. */ -export async function fetchPackageCompatibilities() { - const request = await fetch(`${pathRoot}/package-compatibilities`, { +export async function fetchMetadata() { + const request = await fetch(`${pathRoot}/metadata`, { headers: { 'Content-Type': 'application/json', Accept: 'application/json',