aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web
diff options
context:
space:
mode:
authorGrégoire Aubert <gregoire.aubert@sonarsource.com>2018-05-25 17:32:53 +0200
committerSonarTech <sonartech@sonarsource.com>2018-06-12 20:20:58 +0200
commitcbf8cf43e9b606681e63454dbfd83d2472c837c6 (patch)
treea51b9758159d204f4bccdfa6e9e022c26d5a8cba /server/sonar-web
parent9c920745177dc467e7efd00715454c7eeb5cd2ca (diff)
downloadsonarqube-cbf8cf43e9b606681e63454dbfd83d2472c837c6.tar.gz
sonarqube-cbf8cf43e9b606681e63454dbfd83d2472c837c6.zip
SONAR-10695 Prompt admin to enter a license on new instance
Diffstat (limited to 'server/sonar-web')
-rw-r--r--server/sonar-web/src/main/js/api/marketplace.ts26
-rw-r--r--server/sonar-web/src/main/js/app/components/GlobalContainer.tsx58
-rw-r--r--server/sonar-web/src/main/js/app/components/StartupModal.tsx148
-rw-r--r--server/sonar-web/src/main/js/app/components/__tests__/StartupModal-test.tsx137
-rw-r--r--server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.tsx45
-rw-r--r--server/sonar-web/src/main/js/apps/marketplace/components/LicensePromptModal.tsx56
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>
+ );
+}