diff options
author | Grégoire Aubert <gregoire.aubert@sonarsource.com> | 2018-05-25 17:32:53 +0200 |
---|---|---|
committer | SonarTech <sonartech@sonarsource.com> | 2018-06-12 20:20:58 +0200 |
commit | cbf8cf43e9b606681e63454dbfd83d2472c837c6 (patch) | |
tree | a51b9758159d204f4bccdfa6e9e022c26d5a8cba /server/sonar-web | |
parent | 9c920745177dc467e7efd00715454c7eeb5cd2ca (diff) | |
download | sonarqube-cbf8cf43e9b606681e63454dbfd83d2472c837c6.tar.gz sonarqube-cbf8cf43e9b606681e63454dbfd83d2472c837c6.zip |
SONAR-10695 Prompt admin to enter a license on new instance
Diffstat (limited to 'server/sonar-web')
6 files changed, 393 insertions, 77 deletions
diff --git a/server/sonar-web/src/main/js/api/marketplace.ts b/server/sonar-web/src/main/js/api/marketplace.ts index 238efa8b3a3..adbb4442343 100644 --- a/server/sonar-web/src/main/js/api/marketplace.ts +++ b/server/sonar-web/src/main/js/api/marketplace.ts @@ -20,6 +20,23 @@ import { getJSON, postJSON } from '../helpers/request'; import throwGlobalError from '../app/utils/throwGlobalError'; +export interface License { + contactEmail: string; + edition: string; + expiresAt: string; + invalidInstalledPlugins: string[]; + isExpired: boolean; + isOfficialDistribution: boolean; + isSupported: boolean; + isValidServerId: boolean; + loc: number; + maxLoc: number; + plugins: string[]; + remainingLocThreshold: number; + serverId: string; + type: string; +} + export interface EditionStatus { currentEditionKey?: string; } @@ -28,6 +45,15 @@ export function getEditionStatus(): Promise<EditionStatus> { return getJSON('/api/editions/status'); } +export function showLicense(): Promise<License> { + return getJSON('/api/editions/show_license').catch((e: { response: Response }) => { + if (e.response && e.response.status === 404) { + return Promise.resolve(undefined); + } + return throwGlobalError(e); + }); +} + export function getLicensePreview(data: { license: string; }): Promise<{ diff --git a/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx b/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx index 3b81367b34f..e61540bd5a8 100644 --- a/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx +++ b/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx @@ -18,8 +18,8 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import * as PropTypes from 'prop-types'; import GlobalNav from './nav/global/GlobalNav'; +import StartupModal from './StartupModal'; import GlobalFooterContainer from './GlobalFooterContainer'; import GlobalMessagesContainer from './GlobalMessagesContainer'; import SuggestionsProvider from './embed-docs-modal/SuggestionsProvider'; @@ -30,58 +30,26 @@ interface Props { location: { pathname: string }; } -interface State { - isOnboardingTutorialOpen: boolean; -} - -export default class GlobalContainer extends React.PureComponent<Props, State> { - static childContextTypes = { - closeOnboardingTutorial: PropTypes.func, - openOnboardingTutorial: PropTypes.func - }; - - constructor(props: Props) { - super(props); - this.state = { isOnboardingTutorialOpen: false }; - } - - getChildContext() { - return { - closeOnboardingTutorial: this.closeOnboardingTutorial, - openOnboardingTutorial: this.openOnboardingTutorial - }; - } - - openOnboardingTutorial = () => this.setState({ isOnboardingTutorialOpen: true }); - - closeOnboardingTutorial = () => this.setState({ isOnboardingTutorialOpen: false }); - - render() { - // it is important to pass `location` down to `GlobalNav` to trigger render on url change - - return ( - <SuggestionsProvider> - {({ suggestions }) => ( +export default function GlobalContainer(props: Props) { + // it is important to pass `location` down to `GlobalNav` to trigger render on url change + return ( + <SuggestionsProvider> + {({ suggestions }) => ( + <StartupModal> <div className="global-container"> <div className="page-wrapper" id="container"> <div className="page-container"> <Workspace> - <GlobalNav - closeOnboardingTutorial={this.closeOnboardingTutorial} - isOnboardingTutorialOpen={this.state.isOnboardingTutorialOpen} - location={this.props.location} - openOnboardingTutorial={this.openOnboardingTutorial} - suggestions={suggestions} - /> + <GlobalNav location={props.location} suggestions={suggestions} /> <GlobalMessagesContainer /> - {this.props.children} + {props.children} </Workspace> </div> </div> <GlobalFooterContainer /> </div> - )} - </SuggestionsProvider> - ); - } + </StartupModal> + )} + </SuggestionsProvider> + ); } diff --git a/server/sonar-web/src/main/js/app/components/StartupModal.tsx b/server/sonar-web/src/main/js/app/components/StartupModal.tsx new file mode 100644 index 00000000000..f71ddd87bad --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/StartupModal.tsx @@ -0,0 +1,148 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 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. + */ +import * as React from 'react'; +import * as PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import OnboardingModal from '../../apps/tutorials/onboarding/OnboardingModal'; +import LicensePromptModal from '../../apps/marketplace/components/LicensePromptModal'; +import { showLicense } from '../../api/marketplace'; +import { differenceInDays, parseDate, toShortNotSoISOString } from '../../helpers/dates'; +import { hasMessage } from '../../helpers/l10n'; +import { save, get } from '../../helpers/storage'; +import { getCurrentUser, getAppState } from '../../store/rootReducer'; +import { skipOnboarding } from '../../store/users/actions'; +import { CurrentUser, isLoggedIn } from '../types'; + +interface StateProps { + canAdmin: boolean; + currentEdition: string; + currentUser: CurrentUser; +} + +interface DispatchProps { + skipOnboarding: () => void; +} + +interface OwnProps { + children?: React.ReactNode; +} + +type Props = StateProps & DispatchProps & OwnProps; + +enum ModalKey { + license, + onboarding +} + +interface State { + modal?: ModalKey; +} + +const LICENSE_PROMPT = 'sonarqube.license.prompt'; + +export class StartupModal extends React.PureComponent<Props, State> { + static childContextTypes = { + closeOnboardingTutorial: PropTypes.func, + openOnboardingTutorial: PropTypes.func + }; + + state: State = {}; + + getChildContext() { + return { + closeOnboardingTutorial: this.closeOnboarding, + openOnboardingTutorial: this.openOnboarding + }; + } + + componentDidMount() { + this.tryAutoOpenLicense().catch(this.tryAutoOpenOnboarding); + } + + closeOnboarding = () => { + this.setState(state => ({ + modal: state.modal === ModalKey.onboarding ? undefined : state.modal + })); + this.props.skipOnboarding(); + }; + + closeLicense = () => { + this.setState(state => ({ + modal: state.modal === ModalKey.license ? undefined : state.modal + })); + }; + + openOnboarding = () => { + this.setState({ modal: ModalKey.onboarding }); + }; + + tryAutoOpenLicense = () => { + const { canAdmin, currentEdition, currentUser } = this.props; + const hasLicenseManager = hasMessage('license.prompt.title'); + if ( + currentEdition !== 'community' && + isLoggedIn(currentUser) && + canAdmin && + hasLicenseManager + ) { + const lastPrompt = get(LICENSE_PROMPT, currentUser.login); + if (!lastPrompt || differenceInDays(new Date(), parseDate(lastPrompt)) >= 1) { + return showLicense().then(license => { + if (!license || license.edition !== currentEdition) { + save(LICENSE_PROMPT, toShortNotSoISOString(new Date()), currentUser.login); + this.setState({ modal: ModalKey.license }); + return Promise.resolve(); + } + return Promise.reject('License exists'); + }); + } + } + return Promise.reject('No license prompt'); + }; + + tryAutoOpenOnboarding = () => { + if (this.props.currentUser.showOnboardingTutorial) { + this.openOnboarding(); + } + }; + + render() { + const { modal } = this.state; + return ( + <> + {this.props.children} + {modal === ModalKey.license && <LicensePromptModal onClose={this.closeLicense} />} + {modal === ModalKey.onboarding && <OnboardingModal onFinish={this.closeOnboarding} />} + </> + ); + } +} + +const mapStateToProps = (state: any): StateProps => ({ + canAdmin: getAppState(state).canAdmin, + currentEdition: getAppState(state).edition, + currentUser: getCurrentUser(state) +}); + +const mapDispatchToProps: DispatchProps = { skipOnboarding }; + +export default connect<StateProps, DispatchProps, OwnProps>(mapStateToProps, mapDispatchToProps)( + StartupModal +); diff --git a/server/sonar-web/src/main/js/app/components/__tests__/StartupModal-test.tsx b/server/sonar-web/src/main/js/app/components/__tests__/StartupModal-test.tsx new file mode 100644 index 00000000000..dee4a3bcd29 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/__tests__/StartupModal-test.tsx @@ -0,0 +1,137 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 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. + */ +import * as React from 'react'; +import { shallow, ShallowWrapper } from 'enzyme'; +import { StartupModal } from '../StartupModal'; +import { showLicense } from '../../../api/marketplace'; +import { save, get } from '../../../helpers/storage'; +import { hasMessage } from '../../../helpers/l10n'; +import { waitAndUpdate } from '../../../helpers/testUtils'; +import { differenceInDays, toShortNotSoISOString } from '../../../helpers/dates'; +import { LoggedInUser } from '../../types'; + +jest.mock('../../../api/marketplace', () => ({ + showLicense: jest.fn().mockResolvedValue(undefined) +})); + +jest.mock('../../../helpers/storage', () => ({ + get: jest.fn(), + save: jest.fn() +})); + +jest.mock('../../../helpers/l10n', () => ({ + hasMessage: jest.fn().mockReturnValue(true) +})); + +jest.mock('../../../helpers/dates', () => ({ + differenceInDays: jest.fn().mockReturnValue(1), + parseDate: jest.fn().mockReturnValue('parsed-date'), + toShortNotSoISOString: jest.fn().mockReturnValue('short-not-iso-date') +})); + +const LOGGED_IN_USER: LoggedInUser = { + isLoggedIn: true, + login: 'luke', + name: 'Skywalker', + showOnboardingTutorial: false +}; + +beforeEach(() => { + (differenceInDays as jest.Mock<any>).mockClear(); + (hasMessage as jest.Mock<any>).mockClear(); + (get as jest.Mock<any>).mockClear(); + (save as jest.Mock<any>).mockClear(); + (showLicense as jest.Mock<any>).mockClear(); + (toShortNotSoISOString as jest.Mock<any>).mockClear(); +}); + +it('should render only the children', async () => { + const wrapper = getWrapper({ currentEdition: 'community' }); + await shouldNotHaveModals(wrapper); + expect(showLicense).toHaveBeenCalledTimes(0); + expect(wrapper.find('div').exists()).toBeTruthy(); + + await shouldNotHaveModals(getWrapper({ canAdmin: false })); + + (hasMessage as jest.Mock<any>).mockReturnValueOnce(false); + await shouldNotHaveModals(getWrapper()); + + (showLicense as jest.Mock<any>).mockResolvedValueOnce({ edition: 'enterprise' }); + await shouldNotHaveModals(getWrapper()); + + (get as jest.Mock<any>).mockReturnValueOnce('date'); + (differenceInDays as jest.Mock<any>).mockReturnValueOnce(0); + await shouldNotHaveModals(getWrapper()); +}); + +it('should render license prompt', async () => { + await shouldDisplayLicense(getWrapper()); + expect(save).toHaveBeenCalledWith('sonarqube.license.prompt', 'short-not-iso-date', 'luke'); + + (get as jest.Mock<any>).mockReturnValueOnce('date'); + (differenceInDays as jest.Mock<any>).mockReturnValueOnce(1); + await shouldDisplayLicense(getWrapper()); + + (showLicense as jest.Mock<any>).mockResolvedValueOnce({ edition: 'developer' }); + await shouldDisplayLicense(getWrapper()); +}); + +it('should render onboarding modal', async () => { + await shouldDisplayOnboarding( + getWrapper({ + canAdmin: false, + currentUser: { ...LOGGED_IN_USER, showOnboardingTutorial: true } + }) + ); + + (showLicense as jest.Mock<any>).mockResolvedValueOnce({ edition: 'enterprise' }); + await shouldDisplayOnboarding( + getWrapper({ currentUser: { ...LOGGED_IN_USER, showOnboardingTutorial: true } }) + ); +}); + +async function shouldNotHaveModals(wrapper: ShallowWrapper) { + await waitAndUpdate(wrapper); + expect(wrapper.find('LicensePromptModal').exists()).toBeFalsy(); + expect(wrapper.find('OnboardingModal').exists()).toBeFalsy(); +} + +async function shouldDisplayOnboarding(wrapper: ShallowWrapper) { + await waitAndUpdate(wrapper); + expect(wrapper.find('OnboardingModal').exists()).toBeTruthy(); +} + +async function shouldDisplayLicense(wrapper: ShallowWrapper) { + await waitAndUpdate(wrapper); + expect(wrapper.find('LicensePromptModal').exists()).toBeTruthy(); +} + +function getWrapper(props = {}) { + return shallow( + <StartupModal + canAdmin={true} + currentEdition="enterprise" + currentUser={LOGGED_IN_USER} + skipOnboarding={jest.fn()} + {...props}> + <div /> + </StartupModal> + ); +} diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.tsx b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.tsx index dc3d5127587..062a97a4445 100644 --- a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.tsx @@ -18,6 +18,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; +import * as PropTypes from 'prop-types'; import { connect } from 'react-redux'; import GlobalNavBranding from './GlobalNavBranding'; import GlobalNavMenu from './GlobalNavMenu'; @@ -27,13 +28,11 @@ import Search from '../../search/Search'; import EmbedDocsPopupHelper from '../../embed-docs-modal/EmbedDocsPopupHelper'; import * as theme from '../../../theme'; import { isLoggedIn, CurrentUser, AppState } from '../../../types'; -import OnboardingModal from '../../../../apps/tutorials/onboarding/OnboardingModal'; import NavBar from '../../../../components/nav/NavBar'; import Tooltip from '../../../../components/controls/Tooltip'; import { lazyLoad } from '../../../../components/lazyLoad'; import { translate } from '../../../../helpers/l10n'; import { getCurrentUser, getAppState } from '../../../../store/rootReducer'; -import { skipOnboarding } from '../../../../store/users/actions'; import { SuggestionLink } from '../../embed-docs-modal/SuggestionsProvider'; import { isSonarCloud } from '../../../../helpers/system'; import './GlobalNav.css'; @@ -45,32 +44,26 @@ interface StateProps { currentUser: CurrentUser; } -interface DispatchProps { - skipOnboarding: () => void; -} - -interface Props extends StateProps, DispatchProps { - closeOnboardingTutorial: () => void; - isOnboardingTutorialOpen: boolean; +interface OwnProps { location: { pathname: string }; - openOnboardingTutorial: () => void; suggestions: Array<SuggestionLink>; } +type Props = StateProps & OwnProps; + interface State { - helpOpen: boolean; onboardingTutorialTooltip: boolean; } class GlobalNav extends React.PureComponent<Props, State> { interval?: number; - state: State = { helpOpen: false, onboardingTutorialTooltip: false }; - componentDidMount() { - if (this.props.currentUser.showOnboardingTutorial) { - this.openOnboardingTutorial(); - } - } + static contextTypes = { + closeOnboardingTutorial: PropTypes.func, + openOnboardingTutorial: PropTypes.func + }; + + state: State = { onboardingTutorialTooltip: false }; componentWillUnmount() { if (this.interval) { @@ -78,15 +71,9 @@ class GlobalNav extends React.PureComponent<Props, State> { } } - openOnboardingTutorial = () => { - this.setState({ helpOpen: false }); - this.props.openOnboardingTutorial(); - }; - closeOnboardingTutorial = () => { this.setState({ onboardingTutorialTooltip: true }); - this.props.skipOnboarding(); - this.props.closeOnboardingTutorial(); + this.context.closeOnboardingTutorial(); this.interval = window.setInterval(() => { this.setState({ onboardingTutorialTooltip: false }); }, 3000); @@ -113,15 +100,11 @@ class GlobalNav extends React.PureComponent<Props, State> { <Tooltip overlay={translate('tutorials.follow_later')} visible={this.state.onboardingTutorialTooltip}> - <GlobalNavPlus openOnboardingTutorial={this.openOnboardingTutorial} /> + <GlobalNavPlus openOnboardingTutorial={this.context.openOnboardingTutorial} /> </Tooltip> )} <GlobalNavUserContainer {...this.props} /> </ul> - - {this.props.isOnboardingTutorialOpen && ( - <OnboardingModal onFinish={this.closeOnboardingTutorial} /> - )} </NavBar> ); } @@ -132,6 +115,4 @@ const mapStateToProps = (state: any): StateProps => ({ appState: getAppState(state) }); -const mapDispatchToProps: DispatchProps = { skipOnboarding }; - -export default connect(mapStateToProps, mapDispatchToProps)(GlobalNav); +export default connect<StateProps, {}, OwnProps>(mapStateToProps)(GlobalNav); diff --git a/server/sonar-web/src/main/js/apps/marketplace/components/LicensePromptModal.tsx b/server/sonar-web/src/main/js/apps/marketplace/components/LicensePromptModal.tsx new file mode 100644 index 00000000000..d8b5a037696 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/marketplace/components/LicensePromptModal.tsx @@ -0,0 +1,56 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 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. + */ +import * as React from 'react'; +import { FormattedMessage } from 'react-intl'; +import { Link } from 'react-router'; +import Modal from '../../../components/controls/Modal'; +import { translate } from '../../../helpers/l10n'; +import { ResetButtonLink } from '../../../components/ui/buttons'; + +interface Props { + onClose: () => void; +} + +export default function LicensePromptModal({ onClose }: Props) { + const header = translate('license.prompt.title'); + return ( + <Modal contentLabel={header} onRequestClose={onClose}> + <header className="modal-head"> + <h2>{header}</h2> + </header> + <div className="modal-body"> + <FormattedMessage + defaultMessage={translate('license.prompt.description')} + id={'license.prompt.description'} + values={{ + url: ( + <Link onClick={onClose} to="/admin/extension/license/app"> + {translate('license.prompt.link')} + </Link> + ) + }} + /> + </div> + <footer className="modal-foot"> + <ResetButtonLink onClick={onClose}>{translate('cancel')}</ResetButtonLink> + </footer> + </Modal> + ); +} |