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].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}
onToggleTheme(theme === 'light' ? 'dark' : 'light')}
/>
+ {isRunningLocally() ? (
+ onToggleShareableUrlModal(true)}
+ />
+ ) : null}
({
onToggleSettingsModal: (value) => {
dispatch(toggleSettingsModal(value));
},
+ onToggleShareableUrlModal: (value) => {
+ dispatch(toggleShareableUrlModal(value));
+ },
onToggleTheme: (value) => {
dispatch(toggleTheme(value));
},
diff --git a/src/components/global-toolbar/global-toolbar.test.js b/src/components/global-toolbar/global-toolbar.test.js
index 907a0bd763..f591299f08 100644
--- a/src/components/global-toolbar/global-toolbar.test.js
+++ b/src/components/global-toolbar/global-toolbar.test.js
@@ -14,7 +14,7 @@ describe('GlobalToolbar', () => {
);
- expect(wrapper.find('.pipeline-icon-toolbar__button').length).toBe(5);
+ expect(wrapper.find('.pipeline-icon-toolbar__button').length).toBe(6);
});
const functionCalls = [
@@ -57,6 +57,7 @@ describe('GlobalToolbar', () => {
modularPipelineFocusMode: null,
metadataModal: false,
settingsModal: false,
+ shareableUrlModal: false,
sidebar: true,
},
};
diff --git a/src/components/icons/download.js b/src/components/icons/download.js
new file mode 100644
index 0000000000..b3fffdded6
--- /dev/null
+++ b/src/components/icons/download.js
@@ -0,0 +1,11 @@
+import React from 'react';
+
+const DownloadIcon = ({ className }) => (
+
+);
+
+export default DownloadIcon;
diff --git a/src/components/pipeline-list/pipeline-list.scss b/src/components/pipeline-list/pipeline-list.scss
index 9b117807df..4fd588ce44 100644
--- a/src/components/pipeline-list/pipeline-list.scss
+++ b/src/components/pipeline-list/pipeline-list.scss
@@ -14,11 +14,11 @@
.dropdown__label,
.menu-option {
height: 58px;
-
- &.pipeline-list__option--active {
- background: var(--pipeline-list-option-bg-active);
- border-left: 2px solid colors.$blue-300;
- color: var(--pipeline-list-option-text-color);
- }
}
}
+
+.pipeline-list__option--active {
+ background: var(--pipeline-list-option-bg-active);
+ border-left: 2px solid colors.$blue-300;
+ color: var(--pipeline-list-option-text-color);
+}
diff --git a/src/components/shareable-url-modal/index.js b/src/components/shareable-url-modal/index.js
new file mode 100644
index 0000000000..c496339aef
--- /dev/null
+++ b/src/components/shareable-url-modal/index.js
@@ -0,0 +1,3 @@
+import ShareableUrlModal from './shareable-url-modal';
+
+export default ShareableUrlModal;
diff --git a/src/components/shareable-url-modal/shareable-url-metadata.js b/src/components/shareable-url-modal/shareable-url-metadata.js
new file mode 100644
index 0000000000..f2df747d26
--- /dev/null
+++ b/src/components/shareable-url-modal/shareable-url-metadata.js
@@ -0,0 +1,41 @@
+import { useEffect, useState } from 'react';
+
+const ShareableUrlMetadata = () => {
+ const [metadata, setMetadata] = useState(null);
+
+ useEffect(() => {
+ async function fetchData() {
+ try {
+ const request = await fetch('/api/deploy-viz-metadata', {
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ },
+ });
+ const response = await request.json();
+
+ if (request.ok) {
+ setMetadata(response);
+ }
+ } catch (error) {
+ console.log('deploy-viz-metadata fetch error: ', error);
+ }
+ }
+
+ fetchData();
+ }, []);
+
+ if (metadata === null) {
+ return null;
+ }
+
+ return (
+
+
{`Kedro-Viz ${metadata.version} – ${metadata.timestamp
+ .split(' ')
+ .join(' – ')}`}
+
+ );
+};
+
+export default ShareableUrlMetadata;
diff --git a/src/components/shareable-url-modal/shareable-url-modal.js b/src/components/shareable-url-modal/shareable-url-modal.js
new file mode 100644
index 0000000000..2f3bc79690
--- /dev/null
+++ b/src/components/shareable-url-modal/shareable-url-modal.js
@@ -0,0 +1,333 @@
+import React, { useEffect, useState } from 'react';
+import { connect } from 'react-redux';
+import classnames from 'classnames';
+import { toggleShareableUrlModal } from '../../actions';
+import modifiers from '../../utils/modifiers';
+import { s3BucketRegions } from '../../config';
+
+import Button from '../ui/button';
+import CopyIcon from '../icons/copy';
+import Dropdown from '../ui/dropdown';
+import IconButton from '../ui/icon-button';
+import Input from '../ui/input';
+import LoadingIcon from '../icons/loading';
+import Modal from '../ui/modal';
+import MenuOption from '../ui/menu-option';
+
+import './shareable-url-modal.scss';
+
+const modalMessages = (status, info = '') => {
+ const messages = {
+ default:
+ 'Prerequisite: Deploying and sharing Kedro-Viz requires AWS access keys. To use this feature, please add your AWS access keys as environment variables in your project.',
+ failure: 'Something went wrong. Please try again later.',
+ loading: 'Shooting your files through space. Sit tight...',
+ success:
+ 'The current version of Kedro-Viz has been published and hosted via the link below.',
+ incompatible: `Publishing Kedro-Viz is only supported with fsspec>=2023.9.0. You are currently on version ${info}.\n\nPlease upgrade fsspec to a supported version and ensure you're using Kedro 0.18.2 or above.`,
+ };
+
+ return messages[status];
+};
+
+const ShareableUrlModal = ({ onToggleModal, visible }) => {
+ const [deploymentState, setDeploymentState] = useState('default');
+ const [inputValues, setInputValues] = useState({});
+ const [isFormDirty, setIsFormDirty] = useState({
+ /* eslint-disable camelcase */
+ bucket_name: false,
+ region: false,
+ });
+ const [isLoading, setIsLoading] = useState(false);
+ const [responseUrl, setResponseUrl] = useState(null);
+ const [responseError, setResponseError] = useState(null);
+ const [showCopied, setShowCopied] = useState(false);
+ const [isLinkSettingsClick, setIsLinkSettingsClick] = useState(false);
+ const [compatibilityData, setCompatibilityData] = useState({});
+ const [canUseShareableUrls, setCanUseShareableUrls] = useState(true);
+
+ useEffect(() => {
+ async function fetchPackageCompatibility() {
+ try {
+ const request = await fetch('/api/package-compatibilities', {
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ },
+ });
+ const response = await request.json();
+
+ if (request.ok) {
+ setCompatibilityData(response);
+ setCanUseShareableUrls(response?.is_compatible || false);
+
+ // User's fsspec package version isn't compatible, so set
+ // the necessary state to reflect that in the UI.
+ if (!response.is_compatible) {
+ setDeploymentState(!response.is_compatible && 'incompatible');
+ }
+ }
+ } catch (error) {
+ console.error('package-compatibilities fetch error: ', error);
+ }
+ }
+
+ fetchPackageCompatibility();
+ }, []);
+
+ const onChange = (key, value) => {
+ setIsFormDirty((prevState) => ({ ...prevState, [key]: !!value }));
+ setInputValues(
+ Object.assign({}, inputValues, {
+ [key]: value,
+ })
+ );
+ };
+
+ const handleSubmit = async () => {
+ setDeploymentState('loading');
+ setIsLoading(true);
+
+ try {
+ const request = await fetch('/api/deploy', {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ method: 'POST',
+ body: JSON.stringify(inputValues),
+ });
+ const response = await request.json();
+
+ if (request.ok) {
+ setResponseUrl(response.url);
+ setDeploymentState('success');
+ } else {
+ setResponseUrl(null);
+ setResponseError(response.message);
+ setDeploymentState('failure');
+ }
+ } catch (error) {
+ console.error(error);
+ setDeploymentState('failure');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const onCopyClick = () => {
+ window.navigator.clipboard.writeText(responseUrl);
+ setShowCopied(true);
+ setTimeout(() => setShowCopied(false), 1500);
+ };
+
+ const handleModalClose = () => {
+ onToggleModal(false);
+ setDeploymentState('default');
+ setResponseError(null);
+ setIsLoading(false);
+ setResponseUrl(null);
+ setIsLinkSettingsClick(false);
+ setInputValues({});
+ setIsFormDirty({
+ bucket_name: false,
+ region: false,
+ }); /* eslint-disable camelcase */
+ };
+
+ return (
+
+ {!isLoading && !responseUrl && canUseShareableUrls && !responseError ? (
+ <>
+
+ Enter your AWS information below and a hosted link will be
+ generated. View the{' '}
+
+ docs
+ {' '}
+ for more information.
+
+
+
+ AWS Bucket Region
+
+
{
+ onChange('region', selectedRegion.value);
+ }}
+ width={null}
+ >
+ {s3BucketRegions.map((region) => {
+ return (
+
+ );
+ })}
+
+
+
+
Bucket Name
+
onChange('bucket_name', value)}
+ placeholder="my-bucket-name"
+ resetValueTrigger={visible}
+ size="large"
+ />
+
+
+
+
+
+ >
+ ) : null}
+ {isLoading ? (
+
+
+
+ ) : null}
+ {responseError ? (
+
+
Error message: {responseError}
+
+
+ ) : null}
+ {responseUrl ? (
+ <>
+
+
Hosted link
+
+
+ {responseUrl}
+
+ {window.navigator.clipboard && (
+ <>
+
+ Copied to clipboard.
+
+
+ >
+ )}
+
+
+
+
+
+
+ >
+ ) : null}
+ {!canUseShareableUrls ? (
+
+ ) : null}
+
+ );
+};
+
+export const mapStateToProps = (state) => ({
+ visible: state.visible,
+});
+
+export const mapDispatchToProps = (dispatch) => ({
+ onToggleModal: (value) => {
+ dispatch(toggleShareableUrlModal(value));
+ },
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ShareableUrlModal);
diff --git a/src/components/shareable-url-modal/shareable-url-modal.scss b/src/components/shareable-url-modal/shareable-url-modal.scss
new file mode 100644
index 0000000000..fa177cce09
--- /dev/null
+++ b/src/components/shareable-url-modal/shareable-url-modal.scss
@@ -0,0 +1,176 @@
+@use '../../styles/variables' as variables;
+
+.kui-theme--light {
+ --color-description-text: #{variables.$black-300};
+ --color-deployed-link: #{variables.$black-900};
+}
+
+.kui-theme--dark {
+ --color-description-text: #{variables.$white-900};
+ --color-deployed-link: #{variables.$white-0};
+}
+
+.shareable-url-modal {
+ .modal__content {
+ max-width: 640px;
+ }
+
+ .modal__wrapper {
+ align-items: flex-start;
+ padding: 48px;
+ text-align: left;
+ }
+
+ .modal__title {
+ margin-bottom: 24px;
+ }
+
+ .modal__description {
+ font-size: 16px;
+ font-weight: 400;
+ line-height: 24px;
+ white-space: pre-line;
+
+ a {
+ color: inherit;
+ text-decoration-color: inherit;
+ }
+ }
+
+ &__input-wrapper {
+ margin-bottom: 32px;
+ width: 100%;
+
+ .dropdown {
+ width: 240px;
+ }
+ }
+
+ &__input-label {
+ font-size: 16px;
+ font-weight: 400;
+ line-height: 20px;
+ margin-bottom: 12px;
+ }
+
+ &__button-wrapper {
+ align-items: baseline;
+ display: flex;
+ justify-content: space-between;
+ width: 100%;
+
+ &--right {
+ justify-content: flex-end;
+
+ .button:first-of-type {
+ margin-right: 32px;
+ }
+
+ a .button:first-of-type {
+ margin-right: 0;
+ }
+ }
+
+ .button__btn--secondary {
+ padding-left: 0;
+ }
+ }
+
+ &__loading {
+ display: flex;
+ justify-content: center;
+ width: 100%;
+ }
+
+ &__result {
+ margin-bottom: 48px;
+ width: 100%;
+
+ .toolbox {
+ margin: 0 4px 0 20px;
+ }
+
+ .pipeline-icon {
+ position: relative;
+ top: unset;
+ right: unset;
+ }
+ }
+
+ &__error {
+ display: flex;
+ flex-direction: column;
+ font-size: 16px;
+ font-weight: 400;
+ line-height: 20px;
+
+ .button {
+ align-self: end;
+ margin-top: 16px;
+ }
+ }
+
+ &__url-wrapper {
+ background: rgb(255 255 255 / 4%);
+ display: flex;
+ height: 50px;
+ justify-content: space-between;
+ margin-top: 12px;
+ padding: 8px 8px 8px 16px;
+ position: relative;
+ text-overflow: ellipsis;
+
+ a,
+ .copy-message {
+ color: var(--color-deployed-link);
+ font-size: 16px;
+ line-height: 32px;
+ }
+
+ a {
+ cursor: pointer;
+ display: block;
+ flex-grow: 0;
+ font-family: inherit;
+ overflow: hidden;
+ text-decoration: underline;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ width: 90%;
+
+ &.shareable-url-modal__result-url--no-visible {
+ display: none;
+ }
+ }
+
+ .copy-message {
+ left: 16px;
+ padding-top: 0;
+ }
+
+ .pipeline-icon--container {
+ display: block;
+ margin: 0 12px 0 16px;
+ position: absolute;
+ right: 6px;
+ }
+ }
+
+ &__label {
+ color: var(--color-description-text);
+ font-size: 16px;
+ line-height: 20px;
+ }
+}
+
+.shareable-url-timestamp {
+ padding: 4px 8px 8px;
+ position: absolute;
+ right: 0;
+ top: 0;
+
+ p {
+ font-size: 12px;
+ margin: 0;
+ }
+}
diff --git a/src/components/shareable-url-modal/shareable-url-modal.test.js b/src/components/shareable-url-modal/shareable-url-modal.test.js
new file mode 100644
index 0000000000..7df3d32a3e
--- /dev/null
+++ b/src/components/shareable-url-modal/shareable-url-modal.test.js
@@ -0,0 +1,10 @@
+import React from 'react';
+import ShareableUrlModal from './shareable-url-modal';
+import { setup } from '../../utils/state.mock';
+
+describe('ShareableUrlModal', () => {
+ it('renders without crashing', () => {
+ const wrapper = setup.mount();
+ expect(wrapper.find('.shareable-url-modal__input-wrapper').length).toBe(2);
+ });
+});
diff --git a/src/components/ui/dropdown/dropdown.js b/src/components/ui/dropdown/dropdown.js
index 91553c952d..c046bef3d0 100644
--- a/src/components/ui/dropdown/dropdown.js
+++ b/src/components/ui/dropdown/dropdown.js
@@ -159,12 +159,24 @@ const Dropdown = (props) => {
dropdownRef.current.querySelector(focusClass).focus();
}, [focusedOption]);
+ useEffect(() => {
+ if (open) {
+ document.addEventListener('click', _handleBodyClicked);
+ }
+ return () => document.removeEventListener('click', _handleBodyClicked);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [open]);
+
/**
* Handler for closing a dropdown if a click occurred outside the dropdown.
* @param {Object} e - event object
*/
const _handleBodyClicked = (e) => {
- if (!dropdownRef.current.contains(e.target) && open) {
+ if (
+ dropdownRef.current &&
+ !dropdownRef.current.contains(e.target) &&
+ open
+ ) {
_handleClose();
}
};
diff --git a/src/components/ui/modal/modal.js b/src/components/ui/modal/modal.js
index d434cf8d9b..5293984f1c 100644
--- a/src/components/ui/modal/modal.js
+++ b/src/components/ui/modal/modal.js
@@ -5,7 +5,14 @@ import './modal.scss';
/**
* Generic Kedro Modal
*/
-const Modal = ({ title, closeModal, visible, message, children }) => {
+const Modal = ({
+ children,
+ className,
+ closeModal,
+ message,
+ title,
+ visible,
+}) => {
const handleKeyDown = (event) => {
if (event.keyCode === 27) {
closeModal(true);
@@ -15,12 +22,14 @@ const Modal = ({ title, closeModal, visible, message, children }) => {
useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
- });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [visible]);
return (
@@ -37,8 +46,8 @@ const Modal = ({ title, closeModal, visible, message, children }) => {
>
{title}
+ {message &&
{message}
}
{children}
- {!children &&
{message}
}
diff --git a/src/components/ui/modal/modal.test.js b/src/components/ui/modal/modal.test.js
index 3bc5df9fb4..01229bf52f 100644
--- a/src/components/ui/modal/modal.test.js
+++ b/src/components/ui/modal/modal.test.js
@@ -29,7 +29,11 @@ describe('Modal', () => {
it('should have button and description when supplied no children', () => {
const wrapper = setup.mount(
-
+
);
expect(wrapper.find('.modal__description').length === 1).toBeTruthy();
});
diff --git a/src/components/wrapper/wrapper.js b/src/components/wrapper/wrapper.js
index efb3ffd7d6..e8386206a8 100644
--- a/src/components/wrapper/wrapper.js
+++ b/src/components/wrapper/wrapper.js
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
import { connect } from 'react-redux';
import { BrowserRouter as Router, Switch, Route } from 'react-router-dom';
import classnames from 'classnames';
-import { replaceMatches } from '../../utils';
+import { isRunningLocally, replaceMatches } from '../../utils';
import { useApolloQuery } from '../../apollo/utils';
import { client } from '../../apollo/config';
import { GraphQLProvider } from '../provider/provider';
@@ -28,7 +28,7 @@ export const Wrapper = ({ displayGlobalToolbar, theme }) => {
const { data: versionData } = useApolloQuery(GET_VERSIONS, {
client,
- skip: !displayGlobalToolbar,
+ skip: !displayGlobalToolbar || !isRunningLocally(),
});
const [isOutdated, setIsOutdated] = useState(false);
const [latestVersion, setLatestVersion] = useState(null);
diff --git a/src/config.js b/src/config.js
index 906a8ba2dc..8ead532467 100644
--- a/src/config.js
+++ b/src/config.js
@@ -145,3 +145,36 @@ export const errorMessages = {
export const datasetStatLabels = ['rows', 'columns', 'file_size'];
export const statsRowLen = 33;
+
+export const s3BucketRegions = [
+ 'us-east-2',
+ 'us-east-1',
+ 'us-west-1',
+ 'us-west-2',
+ 'af-south-1',
+ 'ap-east-1',
+ 'ap-south-2',
+ 'ap-southeast-3',
+ 'ap-southeast-4',
+ 'ap-south-1',
+ 'ap-northeast-3',
+ 'ap-northeast-2',
+ 'ap-southeast-1',
+ 'ap-southeast-2',
+ 'ap-northeast-1',
+ 'ca-central-1',
+ 'cn-north-1',
+ 'cn-northwest-1',
+ 'eu-central-1',
+ 'eu-west-1',
+ 'eu-west-2',
+ 'eu-south-1',
+ 'eu-west-3',
+ 'eu-north-1',
+ 'eu-south-2',
+ 'eu-central-2',
+ 'sa-east-1',
+ 'me-south-1',
+ 'me-central-1',
+ 'il-central-1',
+];
diff --git a/src/reducers/visible.js b/src/reducers/visible.js
index 81fd1e4bbb..a5a9f1ce5e 100644
--- a/src/reducers/visible.js
+++ b/src/reducers/visible.js
@@ -1,12 +1,13 @@
import {
- TOGGLE_GRAPH,
+ TOGGLE_CODE,
TOGGLE_EXPORT_MODAL,
- TOGGLE_SETTINGS_MODAL,
+ TOGGLE_GRAPH,
TOGGLE_METADATA_MODAL,
- TOGGLE_SIDEBAR,
- TOGGLE_CODE,
TOGGLE_MINIMAP,
TOGGLE_MODULAR_PIPELINE_FOCUS_MODE,
+ TOGGLE_SETTINGS_MODAL,
+ TOGGLE_SHAREABLE_URL_MODAL,
+ TOGGLE_SIDEBAR,
} from '../actions';
function visibleReducer(visibleState = {}, action) {
@@ -29,6 +30,12 @@ function visibleReducer(visibleState = {}, action) {
});
}
+ case TOGGLE_SHAREABLE_URL_MODAL: {
+ return Object.assign({}, visibleState, {
+ shareableUrlModal: action.visible,
+ });
+ }
+
case TOGGLE_SETTINGS_MODAL: {
return Object.assign({}, visibleState, {
settingsModal: action.visible,
diff --git a/src/store/initial-state.js b/src/store/initial-state.js
index 26eb9dab2d..54511ce3c1 100644
--- a/src/store/initial-state.js
+++ b/src/store/initial-state.js
@@ -34,6 +34,7 @@ export const createInitialState = () => ({
miniMapBtn: true,
modularPipelineFocusMode: null,
settingsModal: false,
+ shareableUrlModal: false,
sidebar: window.innerWidth > sidebarWidth.breakpoint,
},
display: {
diff --git a/src/store/store.js b/src/store/store.js
index b656461b2f..d934d6f203 100644
--- a/src/store/store.js
+++ b/src/store/store.js
@@ -31,6 +31,7 @@ const saveStateToLocalStorage = (state) => {
exportModal,
metadataModal,
settingsModal,
+ shareableUrlModal,
modularPipelineFocusMode,
...otherVisibleProps
} = state.visible;
diff --git a/src/styles/_extends.scss b/src/styles/_extends.scss
index 03f834bd14..62ba6f7e7b 100644
--- a/src/styles/_extends.scss
+++ b/src/styles/_extends.scss
@@ -56,6 +56,7 @@
&::placeholder {
color: var(--color-text);
+ opacity: 0.5;
}
}
diff --git a/src/utils/index.js b/src/utils/index.js
index 554ea199e7..5c5cf5bb11 100644
--- a/src/utils/index.js
+++ b/src/utils/index.js
@@ -183,3 +183,18 @@ export const formatFileSize = (fileSizeInBytes) => {
export const formatNumberWithCommas = (number) => {
return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
};
+
+/**
+ * Test if Kedro-Viz is running on our known local ports.
+ * @returns {Boolean} True if the app is running locally.
+ */
+export const isRunningLocally = () => {
+ const localHosts = ['localhost', '127.0.0.1'];
+ const itemFoundIndex = localHosts.indexOf(window.location.hostname);
+
+ if (itemFoundIndex === -1) {
+ return false; // The hostname isn't in our list of local hosts
+ } else {
+ return true;
+ }
+};