From 42a37b782d64055db065900ec04a1e837d068e87 Mon Sep 17 00:00:00 2001 From: Stas Vilchik Date: Mon, 12 Jun 2017 14:31:31 +0200 Subject: [PATCH] SONAR-9424 Show onboarding tutorial on first login --- server/sonar-web/src/main/js/api/users.js | 4 + .../main/js/app/components/help/GlobalHelp.js | 8 +- .../js/app/components/help/TutorialsHelp.js | 12 +- .../help/__tests__/GlobalHelp-test.js | 20 +- .../__snapshots__/GlobalHelp-test.js.snap | 72 +++++ .../js/app/components/nav/global/GlobalNav.js | 49 +++- .../src/main/js/app/utils/startReactApp.js | 2 - .../apps/tutorials/onboarding/AnalysisStep.js | 4 + .../apps/tutorials/onboarding/Onboarding.js | 54 +++- .../tutorials/onboarding/OnboardingModal.js | 69 +++++ .../onboarding/__tests__/Onboarding-test.js | 85 ++++++ .../__snapshots__/Onboarding-test.js.snap | 258 ++++++++++++++++++ .../src/main/js/apps/tutorials/routes.js | 31 --- .../components/icons-components/HelpIcon.js | 37 +++ .../src/main/less/components/modals.less | 13 + .../resources/org/sonar/l10n/core.properties | 4 + 16 files changed, 667 insertions(+), 55 deletions(-) create mode 100644 server/sonar-web/src/main/js/app/components/help/__tests__/__snapshots__/GlobalHelp-test.js.snap create mode 100644 server/sonar-web/src/main/js/apps/tutorials/onboarding/OnboardingModal.js create mode 100644 server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/Onboarding-test.js create mode 100644 server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/__snapshots__/Onboarding-test.js.snap delete mode 100644 server/sonar-web/src/main/js/apps/tutorials/routes.js create mode 100644 server/sonar-web/src/main/js/components/icons-components/HelpIcon.js diff --git a/server/sonar-web/src/main/js/api/users.js b/server/sonar-web/src/main/js/api/users.js index c8a5e993449..e603c77ef7b 100644 --- a/server/sonar-web/src/main/js/api/users.js +++ b/server/sonar-web/src/main/js/api/users.js @@ -56,3 +56,7 @@ export function searchUsers(query: string, pageSize?: number) { } return getJSON(url, data); } + +export function skipOnboarding(): Promise { + return post('/api/users/skip_onboarding_tutorial'); +} diff --git a/server/sonar-web/src/main/js/app/components/help/GlobalHelp.js b/server/sonar-web/src/main/js/app/components/help/GlobalHelp.js index 553f5e3cc71..6f941b93f4b 100644 --- a/server/sonar-web/src/main/js/app/components/help/GlobalHelp.js +++ b/server/sonar-web/src/main/js/app/components/help/GlobalHelp.js @@ -28,7 +28,9 @@ import TutorialsHelp from './TutorialsHelp'; import { translate } from '../../../helpers/l10n'; type Props = { + currentUser: { isLoggedIn: boolean }, onClose: () => void, + onTutorialSelect: () => void, sonarCloud?: boolean }; @@ -60,7 +62,7 @@ export default class GlobalHelp extends React.PureComponent { ? : ; case 'tutorials': - return ; + return ; default: return null; } @@ -80,7 +82,9 @@ export default class GlobalHelp extends React.PureComponent { renderMenu = () => (
    - {['shortcuts', 'tutorials', 'links'].map(this.renderMenuItem)} + {(this.props.currentUser.isLoggedIn + ? ['shortcuts', 'tutorials', 'links'] + : ['shortcuts', 'links']).map(this.renderMenuItem)}
); diff --git a/server/sonar-web/src/main/js/app/components/help/TutorialsHelp.js b/server/sonar-web/src/main/js/app/components/help/TutorialsHelp.js index 112b6393361..5202060d984 100644 --- a/server/sonar-web/src/main/js/app/components/help/TutorialsHelp.js +++ b/server/sonar-web/src/main/js/app/components/help/TutorialsHelp.js @@ -19,16 +19,20 @@ */ // @flow import React from 'react'; -import { Link } from 'react-router'; import { translate } from '../../../helpers/l10n'; -type Props = { onClose: () => void }; +type Props = { onTutorialSelect: () => void }; + +export default function TutorialsHelp({ onTutorialSelect }: Props) { + const handleClick = (event: Event) => { + event.preventDefault(); + onTutorialSelect(); + }; -export default function TutorialsHelp({ onClose }: Props) { return (

{translate('help.section.tutorials')}

- Onboarding Tutorial + {translate('tutorials.onboarding')}
); } diff --git a/server/sonar-web/src/main/js/app/components/help/__tests__/GlobalHelp-test.js b/server/sonar-web/src/main/js/app/components/help/__tests__/GlobalHelp-test.js index ff0a3dd6bef..96bbd9a27fe 100644 --- a/server/sonar-web/src/main/js/app/components/help/__tests__/GlobalHelp-test.js +++ b/server/sonar-web/src/main/js/app/components/help/__tests__/GlobalHelp-test.js @@ -24,7 +24,13 @@ import GlobalHelp from '../GlobalHelp'; import { click } from '../../../../helpers/testUtils'; it('switches between tabs', () => { - const wrapper = shallow(); + const wrapper = shallow( + + ); expect(wrapper.find('ShortcutsHelp')).toHaveLength(1); clickOnSection(wrapper, 'links'); expect(wrapper.find('LinksHelp')).toHaveLength(1); @@ -34,6 +40,18 @@ it('switches between tabs', () => { expect(wrapper.find('ShortcutsHelp')).toHaveLength(1); }); +it('does not show tutorials for anonymous', () => { + expect( + shallow( + + ) + ).toMatchSnapshot(); +}); + function clickOnSection(wrapper: Object, section: string) { click(wrapper.find(`[data-section="${section}"]`), { currentTarget: { dataset: { section } } }); } diff --git a/server/sonar-web/src/main/js/app/components/help/__tests__/__snapshots__/GlobalHelp-test.js.snap b/server/sonar-web/src/main/js/app/components/help/__tests__/__snapshots__/GlobalHelp-test.js.snap new file mode 100644 index 00000000000..37587136dbb --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/help/__tests__/__snapshots__/GlobalHelp-test.js.snap @@ -0,0 +1,72 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`does not show tutorials for anonymous 1`] = ` + +
+

+ help +

+
+ + +
+`; diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.js b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.js index 8d432d498a0..dc282991294 100644 --- a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.js +++ b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.js @@ -17,6 +17,7 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +// @flow import React from 'react'; import { connect } from 'react-redux'; import GlobalNavBranding from './GlobalNavBranding'; @@ -24,13 +25,30 @@ import GlobalNavMenu from './GlobalNavMenu'; import GlobalNavUserContainer from './GlobalNavUserContainer'; import Search from '../../search/Search'; import GlobalHelp from '../../help/GlobalHelp'; +import HelpIcon from '../../../../components/icons-components/HelpIcon'; +import OnboardingModal from '../../../../apps/tutorials/onboarding/OnboardingModal'; import { getCurrentUser, getAppState, getSettingValue } from '../../../../store/rootReducer'; +type Props = { + appState: { organizationsEnabled: boolean }, + currentUser: { isLoggedIn: boolean, showOnboardingTutorial: true }, + sonarCloud: boolean +}; + +type State = { + helpOpen: boolean, + onboardingTutorialOpen: boolean +}; + class GlobalNav extends React.PureComponent { - state = { helpOpen: false }; + props: Props; + state: State = { helpOpen: false, onboardingTutorialOpen: false }; componentDidMount() { window.addEventListener('keypress', this.onKeyPress); + if (this.props.currentUser.showOnboardingTutorial) { + this.openOnboardingTutorial(); + } } componentWillUnmount() { @@ -42,8 +60,7 @@ class GlobalNav extends React.PureComponent { const code = e.keyCode || e.which; const isInput = tagName === 'INPUT' || tagName === 'SELECT' || tagName === 'TEXTAREA'; const isTriggerKey = code === 63; - const isModalOpen = document.querySelector('html').classList.contains('modal-open'); - if (!isInput && !isModalOpen && isTriggerKey) { + if (!isInput && isTriggerKey) { this.openHelp(); } }; @@ -57,8 +74,11 @@ class GlobalNav extends React.PureComponent { closeHelp = () => this.setState({ helpOpen: false }); + openOnboardingTutorial = () => this.setState({ helpOpen: false, onboardingTutorialOpen: true }); + + closeOnboardingTutorial = () => this.setState({ onboardingTutorialOpen: false }); + render() { - /* eslint-disable max-len */ return ( ); } diff --git a/server/sonar-web/src/main/js/app/utils/startReactApp.js b/server/sonar-web/src/main/js/app/utils/startReactApp.js index 1ffac0eb636..861f20199a9 100644 --- a/server/sonar-web/src/main/js/app/utils/startReactApp.js +++ b/server/sonar-web/src/main/js/app/utils/startReactApp.js @@ -63,7 +63,6 @@ import qualityProfilesRoutes from '../../apps/quality-profiles/routes'; import sessionsRoutes from '../../apps/sessions/routes'; import settingsRoutes from '../../apps/settings/routes'; import systemRoutes from '../../apps/system/routes'; -import tutorialRoutes from '../../apps/tutorials/routes'; import updateCenterRoutes from '../../apps/update-center/routes'; import usersRoutes from '../../apps/users/routes'; import webAPIRoutes from '../../apps/web-api/routes'; @@ -161,7 +160,6 @@ const startReactApp = () => { - diff --git a/server/sonar-web/src/main/js/apps/tutorials/onboarding/AnalysisStep.js b/server/sonar-web/src/main/js/apps/tutorials/onboarding/AnalysisStep.js index 78db84e6ef9..573551790a4 100644 --- a/server/sonar-web/src/main/js/apps/tutorials/onboarding/AnalysisStep.js +++ b/server/sonar-web/src/main/js/apps/tutorials/onboarding/AnalysisStep.js @@ -31,6 +31,8 @@ import Other from './commands/Other'; import { translate } from '../../../helpers/l10n'; type Props = {| + onFinish: () => void, + onReset: () => void, open: boolean, organization?: string, sonarCloud: boolean, @@ -48,10 +50,12 @@ export default class AnalysisStep extends React.PureComponent { handleLanguageSelect = (result?: Result) => { this.setState({ result }); + this.props.onFinish(); }; handleLanguageReset = () => { this.setState({ result: undefined }); + this.props.onReset(); }; getHost = () => window.location.origin + window.baseUrl; diff --git a/server/sonar-web/src/main/js/apps/tutorials/onboarding/Onboarding.js b/server/sonar-web/src/main/js/apps/tutorials/onboarding/Onboarding.js index 0382a33e0d6..e2617224cc0 100644 --- a/server/sonar-web/src/main/js/apps/tutorials/onboarding/Onboarding.js +++ b/server/sonar-web/src/main/js/apps/tutorials/onboarding/Onboarding.js @@ -22,37 +22,51 @@ import React from 'react'; import TokenStep from './TokenStep'; import OrganizationStep from './OrganizationStep'; import AnalysisStep from './AnalysisStep'; +import { skipOnboarding } from '../../../api/users'; import { translate } from '../../../helpers/l10n'; import handleRequiredAuthentication from '../../../app/utils/handleRequiredAuthentication'; import './styles.css'; type Props = { currentUser: { login: string, isLoggedIn: boolean }, + onSkip: () => void, organizationsEnabled: boolean, sonarCloud: boolean }; type State = { + finished: boolean, organization?: string, + skipping: boolean, step: string, token?: string }; export default class Onboarding extends React.PureComponent { + mounted: boolean; props: Props; state: State; constructor(props: Props) { super(props); - this.state = { step: props.organizationsEnabled ? 'organization' : 'token' }; + this.state = { + finished: false, + skipping: false, + step: props.organizationsEnabled ? 'organization' : 'token' + }; } componentDidMount() { + this.mounted = true; if (!this.props.currentUser.isLoggedIn) { handleRequiredAuthentication(); } } + componentWillUnmount() { + this.mounted = false; + } + handleTokenDone = (token: string) => { this.setState({ step: 'analysis', token }); }; @@ -61,6 +75,27 @@ export default class Onboarding extends React.PureComponent { this.setState({ organization, step: 'token' }); }; + handleSkipClick = (event: Event) => { + event.preventDefault(); + this.setState({ skipping: true }); + skipOnboarding().then( + () => { + if (this.mounted) { + this.props.onSkip(); + } + }, + () => { + if (this.mounted) { + this.setState({ skipping: false }); + } + } + ); + }; + + handleFinish = () => this.setState({ finished: true }); + + handleReset = () => this.setState({ finished: false }); + render() { if (!this.props.currentUser.isLoggedIn) { return null; @@ -77,6 +112,13 @@ export default class Onboarding extends React.PureComponent {

{translate(sonarCloud ? 'onboarding.header.sonarcloud' : 'onboarding.header')}

+
+ {this.state.skipping + ? + : + {translate('tutorials.skip')} + } +
{translate('onboarding.header.description')}
@@ -97,12 +139,22 @@ export default class Onboarding extends React.PureComponent { /> + + {this.state.finished && + !this.state.skipping && + } ); } diff --git a/server/sonar-web/src/main/js/apps/tutorials/onboarding/OnboardingModal.js b/server/sonar-web/src/main/js/apps/tutorials/onboarding/OnboardingModal.js new file mode 100644 index 00000000000..13e1af88107 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/tutorials/onboarding/OnboardingModal.js @@ -0,0 +1,69 @@ +/* + * SonarQube + * Copyright (C) 2009-2017 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +// @flow +import React from 'react'; +import Modal from 'react-modal'; +import { translate } from '../../../helpers/l10n'; + +type Props = { + onClose: () => void +}; + +type State = { + OnboardingContainer?: Object +}; + +export default class OnboardingModal extends React.PureComponent { + mounted: boolean; + props: Props; + state: State = {}; + + componentDidMount() { + this.mounted = true; + // $FlowFixMe + require.ensure([], require => { + this.receiveComponent(require('./OnboardingContainer').default); + }); + } + + componentWillUnmount() { + this.mounted = false; + } + + receiveComponent = (OnboardingContainer: Object) => { + if (this.mounted) { + this.setState({ OnboardingContainer }); + } + }; + + render() { + const { OnboardingContainer } = this.state; + + return ( + + {OnboardingContainer != null && } + + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/Onboarding-test.js b/server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/Onboarding-test.js new file mode 100644 index 00000000000..e4898897284 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/Onboarding-test.js @@ -0,0 +1,85 @@ +/* + * SonarQube + * Copyright (C) 2009-2017 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +// @flow +import React from 'react'; +import { shallow, mount } from 'enzyme'; +import Onboarding from '../Onboarding'; +import { click, doAsync } from '../../../../helpers/testUtils'; + +jest.mock('../../../../api/users', () => ({ + skipOnboarding: () => Promise.resolve() +})); + +const currentUser = { login: 'admin', isLoggedIn: true }; + +it('guides for on-premise', () => { + const wrapper = shallow( + + ); + expect(wrapper).toMatchSnapshot(); + + // $FlowFixMe + wrapper.instance().handleTokenDone('abcd1234'); + wrapper.update(); + expect(wrapper).toMatchSnapshot(); +}); + +it('guides for sonarcloud', () => { + const wrapper = shallow( + + ); + expect(wrapper).toMatchSnapshot(); + + // $FlowFixMe + wrapper.instance().handleOrganizationDone('my-org'); + wrapper.update(); + expect(wrapper).toMatchSnapshot(); + + // $FlowFixMe + wrapper.instance().handleTokenDone('abcd1234'); + wrapper.update(); + expect(wrapper).toMatchSnapshot(); +}); + +it('skips', () => { + const onSkip = jest.fn(); + const wrapper = mount( + + ); + click(wrapper.find('.js-skip')); + return doAsync(() => { + expect(onSkip).toBeCalled(); + }); +}); diff --git a/server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/__snapshots__/Onboarding-test.js.snap b/server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/__snapshots__/Onboarding-test.js.snap new file mode 100644 index 00000000000..df1611144a1 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/__snapshots__/Onboarding-test.js.snap @@ -0,0 +1,258 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`guides for on-premise 1`] = ` +
+
+

+ onboarding.header +

+ +
+ onboarding.header.description +
+
+ + +
+`; + +exports[`guides for on-premise 2`] = ` +
+
+

+ onboarding.header +

+ +
+ onboarding.header.description +
+
+ + +
+`; + +exports[`guides for sonarcloud 1`] = ` +
+
+

+ onboarding.header.sonarcloud +

+ +
+ onboarding.header.description +
+
+ + + +
+`; + +exports[`guides for sonarcloud 2`] = ` +
+
+

+ onboarding.header.sonarcloud +

+ +
+ onboarding.header.description +
+
+ + + +
+`; + +exports[`guides for sonarcloud 3`] = ` +
+
+

+ onboarding.header.sonarcloud +

+ +
+ onboarding.header.description +
+
+ + + +
+`; diff --git a/server/sonar-web/src/main/js/apps/tutorials/routes.js b/server/sonar-web/src/main/js/apps/tutorials/routes.js deleted file mode 100644 index 3a7111d0ae0..00000000000 --- a/server/sonar-web/src/main/js/apps/tutorials/routes.js +++ /dev/null @@ -1,31 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2017 SonarSource SA - * mailto:info AT sonarsource DOT com - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ -const routes = [ - { - path: 'onboarding', - getComponent(_, callback) { - require.ensure([], require => { - callback(null, require('./onboarding/OnboardingContainer').default); - }); - } - } -]; - -export default routes; diff --git a/server/sonar-web/src/main/js/components/icons-components/HelpIcon.js b/server/sonar-web/src/main/js/components/icons-components/HelpIcon.js new file mode 100644 index 00000000000..86e5241b438 --- /dev/null +++ b/server/sonar-web/src/main/js/components/icons-components/HelpIcon.js @@ -0,0 +1,37 @@ +/* + * SonarQube + * Copyright (C) 2009-2017 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +// @flow +import React from 'react'; + +type Props = { className?: string, size?: number }; + +export default function HelpIcon({ className, size = 16 }: Props) { + /* eslint-disable max-len */ + return ( + + + + + + ); +} diff --git a/server/sonar-web/src/main/less/components/modals.less b/server/sonar-web/src/main/less/components/modals.less index 4853c080759..aaae44af086 100644 --- a/server/sonar-web/src/main/less/components/modals.less +++ b/server/sonar-web/src/main/less/components/modals.less @@ -53,6 +53,19 @@ margin-left: -45vw; } +.modal-full-screen { + top: 30%; + width: 90vw; + height: 90vh; + margin-left: -45vw; + margin-top: -45vh; + border-radius: 2px; + + &.ReactModal__Content--after-open { + top: 50%; + } +} + .modal-overlay, .ReactModal__Overlay { position: fixed; diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index ee0c39066aa..ef5ccb3eb8e 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -1091,6 +1091,10 @@ shortcuts.section.rules.deactivate=deactivate selected rule shortcuts.section.code=Code Page shortcuts.section.code.search=search components in the project scope +tutorials.onboarding=Onboarding Tutorial +tutorials.skip=Skip this tutorial +tutorials.finish=Finish this tutorial + #------------------------------------------------------------------------------ # -- 2.39.5