diff --git a/Tombolo/client-reactjs/src/App.js b/Tombolo/client-reactjs/src/App.js index 5e09dc9b..079bb28d 100644 --- a/Tombolo/client-reactjs/src/App.js +++ b/Tombolo/client-reactjs/src/App.js @@ -1,6 +1,6 @@ import React, { Suspense } from 'react'; import { connect } from 'react-redux'; -import { Layout, ConfigProvider, Spin, Card } from 'antd'; +import { Layout, ConfigProvider, Spin, Card, Tour } from 'antd'; import { Router, Route, Switch } from 'react-router-dom'; import { Redirect } from 'react-router'; import history from './components/common/History'; @@ -71,6 +71,7 @@ import { PrivateRoute } from './components/common/PrivateRoute'; import { userActions } from './redux/actions/User'; import { checkBackendStatus } from './redux/actions/Backend'; import { store } from './redux/store/Store'; +import { applicationActions } from './redux/actions/Application'; const { Header, Content } = Layout; @@ -78,16 +79,23 @@ const BG_COLOR = ''; class App extends React.Component { state = { - collapsed: true, + collapsed: false, locale: 'en', + message: '', + tourOpen: false, + clusterTourOpen: false, + appLinkRef: React.createRef(), + clusterLinkRef: React.createRef(), }; componentDidMount() { //if status of backend hasn't been retrieved, check it if (!this.props.backendStatus.statusRetrieved) { + this.setState({ message: 'Connecting to...' }); store.dispatch(checkBackendStatus()); } else { if (!this.props.authWithAzure) { + this.setState({ message: 'Authenticating...' }); store.dispatch(userActions.validateToken()); } @@ -100,10 +108,35 @@ class App extends React.Component { i18next.changeLanguage(localStorage.getItem('i18nextLng')); } } + + //listen for clicks on the document to close tour if nav link is clicked + document.addEventListener('click', this.handleClick); } + handleClick = (e) => { + if (this.state.appLinkRef.current && this.state.appLinkRef.current.contains(e.target)) { + this.setState({ tourOpen: false }); + } + + if (this.state.clusterLinkRef.current && this.state.clusterLinkRef.current.contains(e.target)) { + this.setState({ clusterTourOpen: false }); + } + }; + + //function to handle tour shown close + handleTourShownClose = () => { + this.setState({ tourOpen: false }); + }; + + //function to handle tour shown close + handleClusterTourShownClose = () => { + this.setState({ clusterTourOpen: false }); + }; + onCollapse = (collapsed) => { this.setState({ collapsed }); + //set collapsed into local storage + localStorage.setItem('collapsed', collapsed); }; // Setting locale for antd components. @@ -126,6 +159,29 @@ class App extends React.Component { const isBackendStatusRetrieved = this.props.backendStatus.statusRetrieved; const isApplicationSet = this.props.application && this.props.application.applicationId !== '' ? true : false; + //if an application doesn't exist and the tour hasn't been shown, show the tour + if (this.props.noApplication.noApplication && !this.props.noApplication.firstTourShown && isBackendConnected) { + //if you're not already on the application page, show the left nav tour + if (window.location.pathname !== '/admin/applications') { + this.setState({ tourOpen: true }); + } + this.props.dispatch(applicationActions.updateApplicationLeftTourShown(true)); + } + + //if an application exists, but a cluster doesn't, show the cluster tour + if ( + this.props.application?.applicationId && + this.props.noClusters.noClusters && + !this.props.noClusters.firstTourShown + ) { + //if you're not already on the cluster page, show the left nav tour + if (window.location.pathname !== '/admin/clusters') { + this.setState({ clusterTourOpen: true }); + } + + this.props.dispatch(applicationActions.updateClustersLeftTourShown(true)); + } + const dataFlowComp = () => { let applicationId = this.props.application ? this.props.application.applicationId : ''; let applicationTitle = this.props.application ? this.props.application.applicationTitle : ''; @@ -141,13 +197,78 @@ class App extends React.Component { } }; + //steps for tour + const steps = [ + { + title: 'Welcome to Tombolo', + description: + 'There is some setup that we need to complete before being able to fully utilize Tombolo. We will unlock features as we move through this interactive tutorial.', + target: null, + }, + { + title: 'Applications', + description: ( + <> +

+ It looks like you have not set up an application yet. Applications are a necessary part of Tombolos basic + functions, and we must set one up before unlocking the rest of the application. Click on the navigation + element to head to the application management screen and set one up. +

+
+

+ If youre interested to read more about applications, head to our documentation page at{' '} + + https://hpcc-systems.github.io/Tombolo/docs/Quick-Start/application + +

+ + ), + placement: 'right', + arrow: true, + target: () => this.state.appLinkRef?.current, + nextButtonProps: { style: { display: 'none' }, disabled: true }, + prevButtonProps: { style: { display: 'none' }, disabled: true }, + }, + ]; + + const clusterSteps = [ + { + title: 'Clusters', + description: ( + <> +

+ Now that we have an application set up, we can connect to an hpcc systems cluster to unlock the rest of + the application. Click the navigation element to head to the cluster management screen and set one up. +

+
+

+ If youre interested to read more about Clusters, head to our documentation page at{' '} + + https://hpcc-systems.github.io/Tombolo/docs/Quick-Start/cluster + +

+ + ), + placement: 'right', + arrrow: true, + target: () => this.state.clusterLinkRef?.current, + nextButtonProps: { style: { display: 'none' }, disabled: true }, + }, + ]; + return ( }> - {/* don't load anything until backend connection is checked */} - {!isBackendConnected ? ( + {/* Go through loading sequence, first check if backend is connected and report with proper message */} + {!isBackendConnected || !this.props.authWithAzure || !this.props.user || !this.props.user.token ? (
- {isBackendStatusRetrieved ? ( + {!isBackendConnected && isBackendStatusRetrieved ? ( } style={{ width: '50%', textAlign: 'center' }}>

Tombolo has encountered a network issue, please refresh the page. If the issue persists, contact @@ -164,33 +285,59 @@ class App extends React.Component {

) : ( - +
+
+ +
+ +
+

{this.state.message}

+
+
)}
) : ( - /* if backend is connected, load the app */ + /* Now that everything is loaded, present the application */ <> - {this.props.user && this.props.user.token ? ( -
- } - /> -
- ) : null} +
+ } + /> +
+ <>} + /> + + {' '} { this.setApplications(data); + + // SHOW TOUR IF NO APPLICATIONS + if (!this.props.noApplication.addButtonTourShown && data.length === 0) { + this.setState({ showTour: true }); + this.props.dispatch(applicationActions.updateApplicationAddButtonTourShown(true)); + } }) .catch((error) => { console.log(error); @@ -95,11 +103,10 @@ class Applications extends Component { notification.open({ message: 'Application Removed', description: 'The application has been removed.', - onClick: () => { - console.log('Closed!'); - }, + onClick: () => {}, }); this.getApplications(); + this.props.dispatch(applicationActions.applicationDeleted(app_id)); }) .catch((error) => { @@ -109,7 +116,12 @@ class Applications extends Component { // ADD OR CREATE NEW APPLICATION handleAddApplication = () => { - this.setState({ showAddApplicationModal: true, selectedApplication: null, isCreatingNewApp: true }); + this.setState({ + showAddApplicationModal: true, + selectedApplication: null, + isCreatingNewApp: true, + showTour: false, + }); }; // CLOSE ADD APPLICATION MODAL @@ -175,8 +187,22 @@ class Applications extends Component { if (record.visibility !== 'Public' && record.creator === this.props.user.username) return true; }; + handleTourClose = () => { + this.setState({ showTour: false }); + }; + //JSX render() { + const steps = [ + { + title: 'Add Application', + description: 'Click here to add an application. After adding an application, we can move on to the next step. ', + placement: 'bottom', + arrow: true, + target: () => this.state.appAddButtonRef?.current, + nextButtonProps: { style: { display: 'none' }, disabled: true }, + }, + ]; const applicationColumns = [ { width: '2%', @@ -271,12 +297,13 @@ class Applications extends Component { - } /> +
{ setDisplayAddClusterModal(true); + setTourOpen(false); }; + + //Tour steps + const steps = [ + { + title: 'Add Cluster', + description: + 'Click here to add a Cluster from the whitelist file. Once you add a Cluster you will unlock the rest of the application and be on your way to managing and monitoring your data. ', + placement: 'bottom', + arrow: true, + target: () => addClusterButtonRef.current, + nextButtonProps: { style: { display: 'none' }, disabled: true }, + }, + ]; return ( - ( - - - Add New Cluster - - - )}> - - + <> + setTourOpen(false)}> + + ( + + + Add New Cluster + + + )}> + + + ); } diff --git a/Tombolo/client-reactjs/src/components/admin/clusters/index.js b/Tombolo/client-reactjs/src/components/admin/clusters/index.js index 194b0dd6..e9d0529a 100644 --- a/Tombolo/client-reactjs/src/components/admin/clusters/index.js +++ b/Tombolo/client-reactjs/src/components/admin/clusters/index.js @@ -1,6 +1,7 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { message } from 'antd'; - +import { useSelector, useDispatch } from 'react-redux'; +import { applicationActions } from '../../../redux/actions/Application'; import BreadCrumbs from '../../common/BreadCrumbs'; import ClusterActionBtn from './ClusterActionBtn'; import ClustersTable from './ClustersTable'; @@ -19,6 +20,12 @@ function Clusters() { const [displayEditClusterModal, setDisplayEditClusterModal] = useState(false); const [tombolo_instance_name, setTombolo_instance_name] = useState(null); + //tour management + const { applicationReducer } = useSelector((store) => store); + const addClusterButtonRef = useRef(null); + const [tourOpen, setTourOpen] = useState(false); + const dispatch = useDispatch(); + // Effects useEffect(() => { // Get all saved clusters @@ -51,9 +58,36 @@ function Clusters() { } })(); }, []); + + useEffect(() => { + //show tour if needed + if ( + applicationReducer.noClusters.noClusters && + applicationReducer.noClusters.firstTourShown && + !applicationReducer.noClusters.addButtonTourShown + ) { + setTourOpen(true); + dispatch(applicationActions.updateClustersAddButtonTourShown(true)); + } + }, [applicationReducer]); + + //when cluster state is adjusted, dispatch redux action to update clusters in redux + useEffect(() => { + dispatch(applicationActions.updateClusters(clusters)); + }, [clusters]); + return ( <> - } /> + + } + /> { return { value: application.id, display: application.title }; }); + if (applications && applications.length > 0) { this.setState({ applications }); //this.handleRef(); this.debouncedHandleRef(); - } else { - this.openHelpNotification(); + } + + if (applications.length === 0) { + this.props.dispatch(applicationActions.updateNoApplicationFound({ noApplication: true })); } }) .catch((error) => { console.log(error); - }); + }) + .finally(() => {}); } } @@ -143,15 +146,14 @@ class AppHeader extends Component { this.props.dispatch(applicationActions.getConsumers()); this.props.dispatch(applicationActions.getLicenses()); this.props.dispatch(applicationActions.getConstraints()); - // this.props.dispatch(applicationActions.getIntegrations(this.props.application.applicationId)); this.props.dispatch(applicationActions.getAllActiveIntegrations()); } - //if noClusters.noClusters prop is true, show the no cluster modal and dispatch action to reset the noClusters state - if (this.props.noClusters.noClusters && !this.props.noClusters.redirect) { - this.props.dispatch(applicationActions.updateNoClustersFound({ noClusters: true, redirect: true })); - this.setState({ isClusterModalVisible: true }); - } + // //if noClusters.noClusters prop is true, show the no cluster modal and dispatch action to reset the noClusters state + // if (this.props.noClusters.noClusters && !this.props.noClusters.redirect) { + // this.props.dispatch(applicationActions.updateNoClustersFound({ noClusters: true, redirect: true })); + // this.setState({ isClusterModalVisible: true }); + // } if (this.props.newApplication) { let applications = this.state.applications; @@ -242,26 +244,7 @@ class AppHeader extends Component { } } - openHelpNotification = () => { - const key = `open${Date.now()}`; - notification.open({ - message: 'Hello', - description: - 'Welcome ' + - this.props.user.firstName + - ' ' + - this.props.user.lastName + - '. Please make sure you check out the User Guide under Help option.', - key, - onClose: this.close(), - icon: , - top: 70, - }); - }; - - close = () => { - console.log('Notification was closed. Either the close button was clicked or duration time elapsed.'); - }; + close = () => {}; search(value) { this.props.history.push('/report/' + value); @@ -542,11 +525,6 @@ class AppHeader extends Component {

Tombolo v{process.env.REACT_APP_VERSION}

- ); } diff --git a/Tombolo/client-reactjs/src/components/layout/LeftNav.js b/Tombolo/client-reactjs/src/components/layout/LeftNav.js index 89ebe170..06edb12d 100644 --- a/Tombolo/client-reactjs/src/components/layout/LeftNav.js +++ b/Tombolo/client-reactjs/src/components/layout/LeftNav.js @@ -3,7 +3,6 @@ import { Link, withRouter } from 'react-router-dom'; import { connect } from 'react-redux'; import { Layout, Menu, Typography } from 'antd'; import { - LoadingOutlined, DashboardOutlined, FileSearchOutlined, ClusterOutlined, @@ -19,13 +18,15 @@ import { import { hasEditPermission } from '../common/AuthUtil.js'; import Text from '../common/Text'; -function getItem(label, key, icon, children, type) { + +function getItem(label, key, icon, children, type, disabled) { return { key, icon, children, label, type, + disabled, }; } @@ -63,11 +64,19 @@ class LeftNav extends Component { this.setState({ current: options[key] }); } } + + //check local storage for collapsed preference + const collapsed = localStorage.getItem('collapsed'); + if (collapsed === 'true') { + this.props.onCollapse(true); + } } render() { const applicationId = this.props?.applicationId || ''; const integrations = this.props?.integrations || []; + const disabled = applicationId === '' ? true : false; + const clusterDisabled = this.props?.clusters?.length === 0 ? true : false; const asrActive = integrations.some((i) => i.name === 'ASR' && i.application_id === applicationId); @@ -82,33 +91,71 @@ class LeftNav extends Component { //get item structure //label, key, icon, children, type; + const urlPrefix = () => { + if (applicationId) return '/' + applicationId; + else return ''; + }; + const items = [ getItem( - - - Assets - , - + <> + {disabled || clusterDisabled ? ( + <> + + Assets{' '} + + ) : ( + + + Assets + + )} + , '1', - null + null, + null, + null, + clusterDisabled ), getItem( - - - Definitions - , - + <> + {disabled || clusterDisabled ? ( + <> + + Definitions + + ) : ( + + + Definitions + + )} + , '2', - null + null, + null, + null, + clusterDisabled ), getItem( - - - Job Execution - , - + <> + {disabled || clusterDisabled ? ( + <> + + Job Execution + + ) : ( + + + Job Execution + + )} + , '3', - null + null, + null, + null, + clusterDisabled ), getItem( <> @@ -126,7 +173,9 @@ class LeftNav extends Component { , '4a', null, - null + null, + null, + clusterDisabled ), getItem( @@ -136,7 +185,9 @@ class LeftNav extends Component { , '4b', null, - null + null, + null, + clusterDisabled ), getItem( @@ -146,7 +197,9 @@ class LeftNav extends Component { , '4c', null, - null + null, + null, + clusterDisabled ), getItem( @@ -156,7 +209,9 @@ class LeftNav extends Component { , '4d', null, - null + null, + null, + clusterDisabled ), getItem( @@ -166,7 +221,9 @@ class LeftNav extends Component { , '4e', null, - null + null, + null, + clusterDisabled ), asrActive ? getItem( @@ -177,10 +234,14 @@ class LeftNav extends Component { , '4f', null, - null + null, + null, + clusterDisabled ) : null, - ] + ], + null, + clusterDisabled ), getItem( <> @@ -222,18 +283,32 @@ class LeftNav extends Component { null ) : null, - ] + ], + null, + clusterDisabled ), ]; const settingItems = [ getItem( - - - Clusters - , + <> + {disabled ? ( + + + Clusters + + ) : ( + + + Clusters + + )} + , '6', - null + null, + null, + null, + clusterDisabled ), getItem( <> @@ -253,29 +328,55 @@ class LeftNav extends Component { null, null ), - ] + ], + null, + clusterDisabled ), getItem( - - - Github - , + <> + {disabled || clusterDisabled ? ( + <> + + Github + + ) : ( + + + Github + + )}{' '} + , '8', - null + null, + null, + null, + clusterDisabled ), getItem( - - - Collaborator - , + <> + {disabled || clusterDisabled ? ( + <> + + Collaborator + + ) : ( + + + Collaborator + + )} + , '9', - null + null, + null, + null, + clusterDisabled ), ]; const adminItems = [ getItem( - + Applications , @@ -283,21 +384,34 @@ class LeftNav extends Component { null ), getItem( - - - Integrations - , + <> + {disabled || clusterDisabled ? ( + <> + + Integrations + + ) : ( + + + Integrations + + )} + , '11', - null - ), - getItem( - - {this.props.isReportLoading ? : } - Compliance - , - '12', - null + null, + null, + null, + clusterDisabled ), + //TODO: Uncomment when compliance is ready + // getItem( + // + // {this.props.isReportLoading ? : } + // Compliance + // , + // '12', + // null + // ), ]; const onClick = (e) => { diff --git a/Tombolo/client-reactjs/src/redux/actions/Application.js b/Tombolo/client-reactjs/src/redux/actions/Application.js index fed51ccc..5e2da9c6 100644 --- a/Tombolo/client-reactjs/src/redux/actions/Application.js +++ b/Tombolo/client-reactjs/src/redux/actions/Application.js @@ -11,10 +11,14 @@ export const applicationActions = { getLicenses, getConstraints, updateConstraints, - updateNoClustersFound, - // getIntegrations, - // updateIntegrations, getAllActiveIntegrations, + updateApplicationAddButtonTourShown, + updateApplicationLeftTourShown, + updateNoApplicationFound, + updateNoClustersFound, + updateClustersAddButtonTourShown, + updateClustersLeftTourShown, + updateClusters, }; function applicationSelected(applicationId, applicationTitle) { @@ -45,28 +49,52 @@ function applicationDeleted(applicationId) { }; } +function updateNoApplicationFound(noApplication) { + return { type: Constants.NO_APPLICATION_FOUND, noApplication }; +} + +function updateApplicationLeftTourShown(shown) { + return { type: Constants.APPLICATION_LEFT_TOUR_SHOWN, shown }; +} + +function updateApplicationAddButtonTourShown(shown) { + return { type: Constants.APPLICATION_ADD_BUTTON_TOUR_SHOWN, shown }; +} + function getClusters() { return (dispatch) => { fetch('/api/hpcc/read/getClusters', { headers: authHeader() }) .then((response) => (response.ok ? response.json() : handleError(response))) .then((clusters) => { //if there are no clusters, set this to null for later checks + if (clusters.length === 0) { - dispatch({ type: Constants.NO_CLUSTERS_FOUND, noClusters: { redirect: false, noClusters: true } }); - } else { - dispatch({ type: Constants.NO_CLUSTERS_FOUND, noClusters: { redirect: false, noClusters: false } }); + dispatch({ type: Constants.NO_CLUSTERS_FOUND, noClusters: true }); + return; } - dispatch({ type: Constants.CLUSTERS_RETRIEVED, clusters }); + dispatch({ type: Constants.CLUSTERS_FOUND, clusters }); }) .catch(console.log); }; } +function updateClusters(clusters) { + return { type: Constants.CLUSTERS_FOUND, clusters }; +} + function updateNoClustersFound(noClusters) { return { type: Constants.NO_CLUSTERS_FOUND, noClusters }; } +function updateClustersLeftTourShown(shown) { + return { type: Constants.CLUSTERS_LEFT_TOUR_SHOWN, shown }; +} + +function updateClustersAddButtonTourShown(shown) { + return { type: Constants.CLUSTERS_ADD_BUTTON_TOUR_SHOWN, shown }; +} + function getConsumers() { return (dispatch) => { fetch('/api/consumer/consumers', { headers: authHeader() }) diff --git a/Tombolo/client-reactjs/src/redux/reducers/ApplicationReducer.js b/Tombolo/client-reactjs/src/redux/reducers/ApplicationReducer.js index 2397c203..b5432f18 100644 --- a/Tombolo/client-reactjs/src/redux/reducers/ApplicationReducer.js +++ b/Tombolo/client-reactjs/src/redux/reducers/ApplicationReducer.js @@ -2,11 +2,12 @@ import { Constants } from '../../components/common/Constants'; const initialState = { application: {}, + noApplication: { firstTourShown: false, addButtonTourShown: false, noApplication: false }, + noClusters: { firstTourShown: false, addButtonTourShown: false, noClusters: false }, newApplication: '', updatedApplication: '', deletedApplicationId: '', clusters: [], - noClusters: { redirect: false, noClusters: false }, consumers: [], licenses: [], constraints: [], @@ -44,16 +45,46 @@ export function applicationReducer(state = initialState, action) { application: currentApplication, deletedApplicationId: action.applicationId, }; - case Constants.CLUSTERS_RETRIEVED: + case Constants.NO_APPLICATION_FOUND: return { ...state, - clusters: action.clusters, + noApplication: { ...state.noApplication, noApplication: true }, + }; + + case Constants.APPLICATION_LEFT_TOUR_SHOWN: + return { + ...state, + noApplication: { ...state.noApplication, firstTourShown: true }, + }; + case Constants.APPLICATION_ADD_BUTTON_TOUR_SHOWN: + return { + ...state, + noApplication: { ...state.noApplication, addButtonTourShown: true }, }; + case Constants.NO_CLUSTERS_FOUND: return { ...state, - noClusters: action.noClusters, + noClusters: { ...state.noClusters, noClusters: true }, }; + + case Constants.CLUSTERS_LEFT_TOUR_SHOWN: + return { + ...state, + noClusters: { ...state.noClusters, firstTourShown: true }, + }; + case Constants.CLUSTERS_ADD_BUTTON_TOUR_SHOWN: + return { + ...state, + noClusters: { ...state.noClusters, addButtonTourShown: true }, + }; + + case Constants.CLUSTERS_FOUND: + return { + ...state, + clusters: action.clusters, + }; + case Constants.CONSUMERS_RETRIEVED: return { ...state, @@ -84,6 +115,7 @@ export function applicationReducer(state = initialState, action) { ...state, integrations: action.integrations, }; + default: return state; }