@@ -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<{ |
@@ -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> | |||
); | |||
} |
@@ -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 | |||
); |
@@ -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> | |||
); | |||
} |
@@ -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); |
@@ -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> | |||
); | |||
} |