diff --git a/.gitignore b/.gitignore index f9c8a0e100..39a7630687 100644 --- a/.gitignore +++ b/.gitignore @@ -21,7 +21,7 @@ kedro-viz.tgz # e2e testing cypress/videos cypress/screenshots -cypress/downloads/ +cypress/downloads/ # production build/ @@ -65,4 +65,3 @@ coverage.xml # Kedro *.log - diff --git a/RELEASE.md b/RELEASE.md index 7f3c48de44..9badd8aa49 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -8,17 +8,16 @@ Please follow the established format: # Release 6.6.0 -## Bug fixes and other changes - -- Fix for Kedro Viz Connection Error. (#1507) -- Fix display of modular pipeline nodes that are associated with tags. (#1542) -- Remove GraphQL subscription. (#1554) +## Major features and improvements -# Release 6.5.1 +- Make Kedro-Viz shareable via a hosted URL. (#1487) ## Bug fixes and other changes - Updated dependencies to ensure compatibility with Vite and Next.js environments; combine CSS into a single file when used as a React component. (#1510) +- Fix for Kedro Viz Connection Error. (#1507) +- Fix display of modular pipeline nodes that are associated with tags. (#1542) +- Remove GraphQL subscription. (#1554) # Release 6.5.0 diff --git a/cypress/fixtures/mock/deploySuccessResponse.json b/cypress/fixtures/mock/deploySuccessResponse.json new file mode 100644 index 0000000000..9824d965c4 --- /dev/null +++ b/cypress/fixtures/mock/deploySuccessResponse.json @@ -0,0 +1,4 @@ +{ + "message": "Website deployed on S3", + "url": "http://myBucketName.s3-website.us-east-1.amazonaws.com" +} \ No newline at end of file diff --git a/cypress/fixtures/mock/package-compatibilities-compatible.json b/cypress/fixtures/mock/package-compatibilities-compatible.json new file mode 100644 index 0000000000..1b02074938 --- /dev/null +++ b/cypress/fixtures/mock/package-compatibilities-compatible.json @@ -0,0 +1,5 @@ +{ + "package_name": "fsspec", + "package_version": "2023.9.1", + "is_compatible": true +} diff --git a/cypress/fixtures/mock/package-compatibilities-incompatible.json b/cypress/fixtures/mock/package-compatibilities-incompatible.json new file mode 100644 index 0000000000..bd4b75aebe --- /dev/null +++ b/cypress/fixtures/mock/package-compatibilities-incompatible.json @@ -0,0 +1,5 @@ +{ + "package_name": "fsspec", + "package_version": "2023.8.1", + "is_compatible": false +} diff --git a/cypress/tests/ui/flowchart/menu.cy.js b/cypress/tests/ui/flowchart/menu.cy.js index 9aa624a62b..875f3302a4 100644 --- a/cypress/tests/ui/flowchart/menu.cy.js +++ b/cypress/tests/ui/flowchart/menu.cy.js @@ -3,10 +3,12 @@ import { prettifyName } from '../../../../src/utils'; describe('Flowchart Menu', () => { - it('verifies that users can select a section of the flowchart, through the drop down. #TC-16', () => { + it('verifies that users can select a section of the flowchart through the drop down. #TC-16', () => { // Alias cy.intercept('GET', '/api/pipelines/*').as('pipelineRequest'); - cy.get(':nth-child(2) > .menu-option__content > span').as('menuOption'); + cy.get('.pipeline-list :nth-child(2) > .menu-option__content > span').as( + 'menuOption' + ); let menuOptionValue; @@ -17,7 +19,7 @@ describe('Flowchart Menu', () => { }); // Action - cy.get('[data-test="kedro-pipeline-selector"]').click(); + cy.get('.pipeline-list [data-test="kedro-pipeline-selector"]').click(); cy.get('@menuOption').click({ force: true }); // Assert after action diff --git a/cypress/tests/ui/flowchart/shareable-urls.cy.js b/cypress/tests/ui/flowchart/shareable-urls.cy.js new file mode 100644 index 0000000000..837fd96cf1 --- /dev/null +++ b/cypress/tests/ui/flowchart/shareable-urls.cy.js @@ -0,0 +1,217 @@ +describe('Shareable URLs', () => { + it('verifies that users can open the Deploy Kedro-Viz modal. #TC-52', () => { + // Intercept the network request to mock with a fixture + cy.__interceptRest__( + '/api/package-compatibilities', + 'GET', + '/mock/package-compatibilities-compatible.json' + ); + + // Action + cy.reload(); + cy.get('.pipeline-menu-button--deploy').click({ force: true }); + + // Assert after action + cy.get('.shareable-url-modal .modal__wrapper').contains( + `Publish and Share Kedro-Viz` + ); + }); + + 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', + 'GET', + '/mock/package-compatibilities-incompatible.json' + ); + + // Action + cy.reload(); + cy.get('.pipeline-menu-button--deploy').click({ force: true }); + + // Assert after action + cy.get('.shareable-url-modal .modal__wrapper').contains( + `Publishing Kedro-Viz is only supported with fsspec>=2023.9.0. You are currently on version 2023.8.1.` + ); + }); + + it('verifies that shareable url modal closes on close button click #TC-54', () => { + // Action + cy.get('.pipeline-menu-button--deploy').click(); + cy.get('.shareable-url-modal__button-wrapper button') + .contains('Cancel') + .click(); + + // Assert after action + cy.get('.modal.shareable-url-modal').should( + 'not.have.class', + 'modal--visible' + ); + }); + + it('verifies that users can click on region dropdown and see all region options #TC-55', () => { + const regionCount = 30; + + // Action + cy.get('.pipeline-menu-button--deploy').click(); + cy.get('.shareable-url-modal [data-test=kedro-pipeline-selector]').click(); + + // Assert after action + cy.get('.shareable-url-modal .menu-option').should( + 'have.length', + regionCount + ); + }); + + it('verifies that publish button should be disabled when region is not selected and bucket name is empty #TC-56', () => { + const selectedRegion = 'Select a region'; + const primaryButtonNodeText = 'Publish'; + + // Action + cy.get('.pipeline-menu-button--deploy').click(); + + // Assert after action + cy.get( + '.shareable-url-modal [data-test=kedro-pipeline-selector] .dropdown__label span' + ).contains(selectedRegion); + cy.get('.shareable-url-modal textarea').should('have.value', ''); + cy.get('.shareable-url-modal__button-wrapper button') + .contains(primaryButtonNodeText) + .should('be.disabled'); + }); + + it('verifies that publish button should be disabled when a bucket region is selected and bucket name is empty #TC-57', () => { + const primaryButtonNodeText = 'Publish'; + + // Action + cy.get('.pipeline-menu-button--deploy').click(); + cy.get('.shareable-url-modal [data-test=kedro-pipeline-selector]').click(); + cy.get('.shareable-url-modal .dropdown__options section div') + .first() + .click(); + + // Assert after action + cy.get('.shareable-url-modal textarea').should('have.value', ''); + cy.get('.shareable-url-modal__button-wrapper button') + .contains(primaryButtonNodeText) + .should('be.disabled'); + }); + + it('verifies that publish button should be enabled when region is selected and bucket name is not empty #TC-58', () => { + const bucketName = 'myBucketName'; + const primaryButtonNodeText = 'Publish'; + + // Action + cy.get('.pipeline-menu-button--deploy').click(); + cy.get('.shareable-url-modal [data-test=kedro-pipeline-selector]').click(); + cy.get('.shareable-url-modal .dropdown__options section div') + .first() + .click(); + cy.get('.shareable-url-modal textarea').type(bucketName); + + // Assert after action + cy.get('.shareable-url-modal__button-wrapper button') + .contains(primaryButtonNodeText) + .should('be.enabled'); + }); + + it('verifies that error message appears with wrong inputs on publish button click #TC-59', () => { + const bucketName = 'myBucketName'; + const primaryButtonNodeText = 'Publish'; + const errorButtonNodeText = 'Go back'; + + // Action + cy.get('.pipeline-menu-button--deploy').click(); + cy.get('.shareable-url-modal [data-test=kedro-pipeline-selector]').click(); + cy.get('.shareable-url-modal .dropdown__options section div') + .first() + .click(); + cy.get('.shareable-url-modal textarea').type(bucketName); + cy.get('.shareable-url-modal__button-wrapper button') + .contains(primaryButtonNodeText) + .click(); + + // Assert after action + cy.get('.shareable-url-modal .modal__wrapper').contains( + 'Something went wrong. Please try again later.' + ); + cy.get('.shareable-url-modal__error button').contains(errorButtonNodeText); + }); + + it('verifies that AWS link is generated with correct inputs on publish button click #TC-60', () => { + const bucketName = 'myBucketName'; + const primaryButtonNodeText = 'Publish'; + + // Intercept the network request to mock with a fixture + cy.__interceptRest__( + '/api/deploy', + 'POST', + '/mock/deploySuccessResponse.json' + ).as('publishRequest'); + + // Action + cy.reload(); + cy.get('.pipeline-menu-button--deploy').click(); + cy.get('.shareable-url-modal [data-test=kedro-pipeline-selector]').click(); + cy.get('.shareable-url-modal .dropdown__options section div') + .first() + .click(); + cy.get('.shareable-url-modal textarea').type(bucketName); + cy.get('.shareable-url-modal__button-wrapper button') + .contains(primaryButtonNodeText) + .click(); + + // Wait for the POST request to complete and check the mocked response + cy.wait('@publishRequest').then((interception) => { + // Assert after action + cy.get('.shareable-url-modal__result-url').contains( + interception.response.body.url + ); + }); + }); + + it('verifies that AWS link is generated with correct inputs on Republish button click #TC-61', () => { + const bucketName = 'myBucketName'; + const primaryButtonNodeText = 'Publish'; + const primaryButtonNodeTextVariant = 'Republish'; + const secondaryButtonNodeText = 'Link Settings'; + + // Intercept the network request to mock with a fixture + cy.__interceptRest__( + '/api/deploy', + 'POST', + '/mock/deploySuccessResponse.json' + ).as('publishRequest'); + + // Action + cy.reload(); + cy.get('.pipeline-menu-button--deploy').click(); + cy.get('.shareable-url-modal [data-test=kedro-pipeline-selector]').click(); + cy.get('.shareable-url-modal .dropdown__options section div') + .first() + .click(); + cy.get('.shareable-url-modal textarea').type(bucketName); + cy.get('.shareable-url-modal__button-wrapper button') + .contains(primaryButtonNodeText) + .click(); + + // Wait for the POST request to complete + cy.wait('@publishRequest'); + + // Action + cy.get('.shareable-url-modal__button-wrapper button') + .contains(secondaryButtonNodeText) + .click(); + cy.get('.shareable-url-modal__button-wrapper button') + .contains(primaryButtonNodeTextVariant) + .click(); + + // Wait for the POST request to complete and check the mocked response + cy.wait('@publishRequest').then((interception) => { + // Assert after action + cy.get('.shareable-url-modal__result-url').contains( + interception.response.body.url + ); + }); + }); +}); diff --git a/package/features/environment.py b/package/features/environment.py index dfb60567f7..368bd267f7 100644 --- a/package/features/environment.py +++ b/package/features/environment.py @@ -46,8 +46,8 @@ def before_scenario(context, scenario): if ( kedro_version - and kedro_version <= Version.parse("0.18.0") - and sys.version_info >= (3, 9) + and kedro_version <= Version.parse("0.18.12") + and sys.version_info >= (3, 11) ): print( ( diff --git a/package/features/viz.feature b/package/features/viz.feature index 3a9e02dda3..c93e16167a 100644 --- a/package/features/viz.feature +++ b/package/features/viz.feature @@ -3,7 +3,7 @@ Feature: Viz plugin in new project Given I have prepared a config file with example code Scenario: Execute viz with the earliest Kedro version that it supports - Given I have installed kedro version "0.17.5" + Given I have installed kedro version "0.18.2" And I have run a non-interactive kedro new with pandas-iris starter And I have installed the project's requirements When I execute the kedro viz command diff --git a/package/kedro_viz/api/rest/router.py b/package/kedro_viz/api/rest/router.py index f861370ab2..c5e320997b 100644 --- a/package/kedro_viz/api/rest/router.py +++ b/package/kedro_viz/api/rest/router.py @@ -63,9 +63,7 @@ async def deploy_kedro_viz(input_values: S3DeployerConfiguration): ) except Exception as exc: # pragma: no cover logger.exception("Deploying Kedro Viz failed: %s ", exc) - return JSONResponse( - status_code=500, content={"message": "Failed to deploy Kedro Viz"} - ) + return JSONResponse(status_code=500, content={"message": f"{exc}"}) @router.get( diff --git a/package/kedro_viz/integrations/deployment/s3_deployer.py b/package/kedro_viz/integrations/deployment/s3_deployer.py index 215170ec4d..2c0e738a6c 100644 --- a/package/kedro_viz/integrations/deployment/s3_deployer.py +++ b/package/kedro_viz/integrations/deployment/s3_deployer.py @@ -3,14 +3,17 @@ import json import logging +import tempfile from datetime import datetime from pathlib import Path import fsspec +from jinja2 import Environment, FileSystemLoader from semver import VersionInfo from kedro_viz import __version__ from kedro_viz.api.rest.responses import save_api_responses_to_fs +from kedro_viz.integrations.kedro import telemetry as kedro_telemetry _HTML_DIR = Path(__file__).parent.parent.parent.absolute() / "html" _METADATA_PATH = "api/deploy-viz-metadata" @@ -48,11 +51,41 @@ def _upload_api_responses(self): """Upload API responses to S3.""" save_api_responses_to_fs(self._bucket_path) + def _ingest_heap_analytics(self): + """Ingest heap analytics to index file in the build folder.""" + project_path = Path.cwd().absolute() + heap_app_id = kedro_telemetry.get_heap_app_id(project_path) + heap_user_identity = kedro_telemetry.get_heap_identity() + should_add_telemetry = bool(heap_app_id) and bool(heap_user_identity) + html_content = (_HTML_DIR / "index.html").read_text(encoding="utf-8") + injected_head_content = [] + + env = Environment(loader=FileSystemLoader(_HTML_DIR)) + + if should_add_telemetry: + logger.debug("Ingesting heap analytics.") + telemetry_content = env.get_template("telemetry.html").render( + heap_app_id=heap_app_id, heap_user_identity=heap_user_identity + ) + injected_head_content.append(telemetry_content) + + injected_head_content.append("") + html_content = html_content.replace("", "\n".join(injected_head_content)) + + with tempfile.TemporaryDirectory() as temp_dir: + temp_file_path = f"{temp_dir}/index.html" + + with open(temp_file_path, "w", encoding="utf-8") as temp_index_file: + temp_index_file.write(html_content) + + self._remote_fs.put(temp_file_path, f"{self._bucket_path}/") + def _upload_static_files(self, html_dir: Path): """Upload static HTML files to S3.""" logger.debug("Uploading static html files to %s.", self._bucket_path) try: self._remote_fs.put(f"{str(html_dir)}/*", self._bucket_path, recursive=True) + self._ingest_heap_analytics() except Exception as exc: # pragma: no cover logger.exception("Upload failed: %s ", exc) raise exc diff --git a/package/requirements.txt b/package/requirements.txt index 4ee28c1249..d365835a14 100644 --- a/package/requirements.txt +++ b/package/requirements.txt @@ -3,7 +3,7 @@ packaging~=23.0 kedro>=0.17.5 ipython>=7.0.0, <9.0 fastapi>=0.73.0, <0.96.0 -fsspec>=2021.4, <2024.1 +fsspec[s3]>=2021.4, <2024.1 aiofiles==22.1.0 uvicorn[standard]~=0.22.0 watchgod~=0.8.2 diff --git a/package/tests/test_integrations/test_s3_deployer.py b/package/tests/test_integrations/test_s3_deployer.py index 789d02e0a4..fe3d9fd5ad 100644 --- a/package/tests/test_integrations/test_s3_deployer.py +++ b/package/tests/test_integrations/test_s3_deployer.py @@ -28,11 +28,13 @@ def test_upload_api_responses(self, mocker, region, bucket_name): def test_upload_static_files(self, mocker, region, bucket_name): mocker.patch("fsspec.filesystem") + mocker.patch("kedro_viz.integrations.kedro.telemetry.get_heap_app_id") + mocker.patch("kedro_viz.integrations.kedro.telemetry.get_heap_identity") + deployer = S3Deployer(region, bucket_name) deployer._upload_static_files(_HTML_DIR) - deployer._remote_fs.put.assert_called_once_with( - f"{str(_HTML_DIR)}/*", deployer._bucket_path, recursive=True - ) + + assert deployer._remote_fs.put.call_count == 2 def test_upload_static_file_failed(self, mocker, region, bucket_name, caplog): mocker.patch("fsspec.filesystem") diff --git a/src/actions/index.js b/src/actions/index.js index 3bf388b5ba..86335addec 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -37,6 +37,19 @@ export function toggleExportModal(visible) { }; } +export const TOGGLE_SHAREABLE_URL_MODAL = 'TOGGLE_SHAREABLE_URL_MODAL'; + +/** + * Toggle whether to show the shareable URL modal + * @param {Boolean} visible True if the modal is to be shown + */ +export function toggleShareableUrlModal(visible) { + return { + type: TOGGLE_SHAREABLE_URL_MODAL, + visible, + }; +} + export const TOGGLE_SETTINGS_MODAL = 'TOGGLE_SETTINGS_MODAL'; /** diff --git a/src/components/feature-hints/feature-hint-dot.js b/src/components/feature-hints/feature-hint-dot.js index a5ed18d514..69edde89e8 100644 --- a/src/components/feature-hints/feature-hint-dot.js +++ b/src/components/feature-hints/feature-hint-dot.js @@ -1,12 +1,11 @@ import React, { useEffect, useState } from 'react'; import { connect } from 'react-redux'; -import { featureHintsContent } from './feature-hints-content'; - import './feature-hints.scss'; const FeatureHintDot = ({ appState, + featureHintsContent, featureHintStep, hideDot, requestedHintClose, @@ -47,7 +46,7 @@ const FeatureHintDot = ({ // Use `appState` to track when the graph layout is changing, updating the // position of the feature hint accordingly. - }, [appState, featureHintStep, requestedHintClose]); + }, [appState, featureHintStep, featureHintsContent, requestedHintClose]); return (
{ const [areFeatureHintsShown, setAreFeatureHintsShown] = useState(false); @@ -38,7 +42,7 @@ const FeatureHints = ({ metadataVisible, onToggleShowFeatureHints }) => { }, []); useEffect(() => { - if (!featureHintsContent[featureHintStep].elementId) { + if (!updatedFeatureHintsContent[featureHintStep].elementId) { setHideHighlightDot(true); } @@ -72,6 +76,7 @@ const FeatureHints = ({ metadataVisible, onToggleShowFeatureHints }) => { return ( {
- {featureHintsContent[featureHintStep].title} + {updatedFeatureHintsContent[featureHintStep].title}
- {featureHintsContent[featureHintStep].image && ( + {updatedFeatureHintsContent[featureHintStep].image && ( {featureHintsContent[featureHintStep].title} )}
- {featureHintsContent[featureHintStep].description} + {updatedFeatureHintsContent[featureHintStep].description}
- {featureHintsContent[featureHintStep].learnMoreLink ? ( + {updatedFeatureHintsContent[featureHintStep].learnMoreLink ? ( diff --git a/src/components/flowchart-wrapper/flowchart-wrapper.js b/src/components/flowchart-wrapper/flowchart-wrapper.js index 670e914983..4c626a66d1 100644 --- a/src/components/flowchart-wrapper/flowchart-wrapper.js +++ b/src/components/flowchart-wrapper/flowchart-wrapper.js @@ -21,6 +21,8 @@ import PipelineWarning from '../pipeline-warning'; import LoadingIcon from '../icons/loading'; import MetaData from '../metadata'; import MetadataModal from '../metadata-modal'; +import ShareableUrlModal from '../shareable-url-modal'; +import ShareableUrlMetadata from '../shareable-url-modal/shareable-url-metadata'; import Sidebar from '../sidebar'; import Button from '../ui/button'; import CircleProgressBar from '../ui/circle-progress-bar'; @@ -33,6 +35,7 @@ import { } from '../../config'; import { findMatchedPath } from '../../utils/match-path'; import { getKeyByValue } from '../../utils/get-key-by-value'; +import { isRunningLocally } from '../../utils'; import './flowchart-wrapper.scss'; @@ -228,15 +231,21 @@ export const FlowChartWrapper = ({ }, []); useEffect(() => { - const timer = - counter > 0 && setInterval(() => setCounter(counter - 1), 1000); + if (goBackToExperimentTracking?.showGoBackBtn) { + const timer = + counter > 0 && setInterval(() => setCounter(counter - 1), 1000); - if (counter === 0) { - resetLinkingToFlowchartLocalStorage(); - } + if (counter === 0) { + resetLinkingToFlowchartLocalStorage(); + } - return () => clearInterval(timer); - }, [counter, resetLinkingToFlowchartLocalStorage]); + return () => clearInterval(timer); + } + }, [ + counter, + goBackToExperimentTracking?.showGoBackBtn, + resetLinkingToFlowchartLocalStorage, + ]); const onGoBackToExperimentTrackingHandler = () => { const url = goBackToExperimentTracking.fromURL; @@ -288,9 +297,11 @@ export const FlowChartWrapper = ({ >
+ {isRunningLocally() ? null : }
+ {isRunningLocally() ? : null} ); } diff --git a/src/components/flowchart-wrapper/flowchart-wrapper.scss b/src/components/flowchart-wrapper/flowchart-wrapper.scss index c4df57c973..2ceaef2d3e 100644 --- a/src/components/flowchart-wrapper/flowchart-wrapper.scss +++ b/src/components/flowchart-wrapper/flowchart-wrapper.scss @@ -78,3 +78,9 @@ $sidebar-toolbar-width-open: variables.$sidebar-width-open + transform: translate(-50px, -140%); } } + +.shareable-url-button .button__btn { + position: absolute; + right: 36px; + top: 36px; +} diff --git a/src/components/global-toolbar/global-toolbar.js b/src/components/global-toolbar/global-toolbar.js index 882050fa0c..02430e31a9 100644 --- a/src/components/global-toolbar/global-toolbar.js +++ b/src/components/global-toolbar/global-toolbar.js @@ -1,8 +1,14 @@ import React from 'react'; import { connect } from 'react-redux'; import { NavLink } from 'react-router-dom'; -import { toggleSettingsModal, toggleTheme } from '../../actions'; -import { replaceMatches } from '../../utils'; +import { + toggleSettingsModal, + toggleShareableUrlModal, + toggleTheme, +} from '../../actions'; +import { isRunningLocally, replaceMatches } from '../../utils'; + +import DownloadIcon from '../icons/download'; import ExperimentsIcon from '../icons/experiments'; import IconButton from '../ui/icon-button'; import LogoIcon from '../icons/logo'; @@ -20,6 +26,7 @@ import './global-toolbar.scss'; export const GlobalToolbar = ({ isOutdated, onToggleSettingsModal, + onToggleShareableUrlModal, onToggleTheme, theme, }) => { @@ -52,22 +59,24 @@ export const GlobalToolbar = ({ labelText="Flowchart" /> - - - + {isRunningLocally() ? ( + + + + ) : null}