@@ -58,7 +58,7 @@ interface WithRouterProps { | |||
type Props = StateProps & DispatchProps & OwnProps & WithRouterProps; | |||
enum ModalKey { | |||
export enum ModalKey { | |||
license, | |||
onboarding | |||
} | |||
@@ -153,6 +153,7 @@ export class StartupModal extends React.PureComponent<Props, State> { | |||
<OnboardingModal | |||
onClose={this.closeOnboarding} | |||
onOpenProjectOnboarding={this.openProjectOnboarding} | |||
skipOnboarding={this.props.skipOnboarding} | |||
/> | |||
)} | |||
</OnboardingContext.Provider> |
@@ -19,13 +19,14 @@ | |||
*/ | |||
import * as React from 'react'; | |||
import { shallow, ShallowWrapper } from 'enzyme'; | |||
import { StartupModal } from '../StartupModal'; | |||
import { StartupModal, ModalKey } 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 { EditionKey } from '../../../apps/marketplace/utils'; | |||
import { mockOrganization, mockRouter } from '../../../helpers/testMocks'; | |||
jest.mock('../../../api/marketplace', () => ({ | |||
showLicense: jest.fn().mockResolvedValue(undefined) | |||
@@ -110,6 +111,36 @@ it('should render license prompt', async () => { | |||
await shouldDisplayLicense(getWrapper()); | |||
}); | |||
describe('closeOnboarding', () => { | |||
it('should set state and skip onboarding', () => { | |||
const skipOnboarding = jest.fn(); | |||
const wrapper = getWrapper({ skipOnboarding }); | |||
wrapper.setState({ modal: ModalKey.onboarding }); | |||
wrapper.instance().closeOnboarding(); | |||
expect(wrapper.state('modal')).toBe(undefined); | |||
expect(skipOnboarding).toHaveBeenCalledTimes(1); | |||
}); | |||
}); | |||
describe('openProjectOnboarding', () => { | |||
it('should set state and redirect', () => { | |||
const push = jest.fn(); | |||
const wrapper = getWrapper({ router: mockRouter({ push }) }); | |||
wrapper.instance().openProjectOnboarding(mockOrganization()); | |||
expect(wrapper.state('modal')).toBe(undefined); | |||
expect(push).toHaveBeenCalledWith({ | |||
pathname: `/projects/create`, | |||
state: { organization: 'foo', tab: 'manual' } | |||
}); | |||
}); | |||
}); | |||
async function shouldNotHaveModals(wrapper: ShallowWrapper) { | |||
await waitAndUpdate(wrapper); | |||
expect(wrapper.find('LicensePromptModal').exists()).toBeFalsy(); | |||
@@ -121,7 +152,7 @@ async function shouldDisplayLicense(wrapper: ShallowWrapper) { | |||
} | |||
function getWrapper(props: Partial<StartupModal['props']> = {}) { | |||
return shallow( | |||
return shallow<StartupModal>( | |||
<StartupModal | |||
canAdmin={true} | |||
currentEdition={EditionKey.enterprise} |
@@ -95,6 +95,7 @@ module.exports = { | |||
bigFontSize: '16px', | |||
hugeFontSize: '24px', | |||
hugeControlHeight: `${5 * grid}px`, | |||
largeControlHeight: `${4 * grid}px`, | |||
controlHeight: `${3 * grid}px`, | |||
smallControlHeight: `${2.5 * grid}px`, |
@@ -37,6 +37,7 @@ interface Props { | |||
loading: boolean; | |||
members?: T.OrganizationMember[]; | |||
organization: T.Organization; | |||
refreshMembers: () => Promise<void>; | |||
setCurrentUserSetting: (setting: T.CurrentUserSetting) => void; | |||
} | |||
@@ -50,7 +51,7 @@ export class MembersPageHeader extends React.PureComponent<Props> { | |||
}; | |||
render() { | |||
const { dismissSyncNotifOrg, members, organization } = this.props; | |||
const { dismissSyncNotifOrg, members, organization, refreshMembers } = this.props; | |||
const memberLogins = members ? members.map(member => member.login) : []; | |||
const isAdmin = organization.actions && organization.actions.admin; | |||
const almKey = organization.alm && sanitizeAlmId(organization.alm.key); | |||
@@ -67,7 +68,10 @@ export class MembersPageHeader extends React.PureComponent<Props> { | |||
<DeferredSpinner loading={this.props.loading} /> | |||
{isAdmin && ( | |||
<div className="page-actions text-right"> | |||
{almKey && !showSyncNotif && <SyncMemberForm organization={organization} />} | |||
{almKey && | |||
!showSyncNotif && ( | |||
<SyncMemberForm organization={organization} refreshMembers={refreshMembers} /> | |||
)} | |||
{!hasMemberSync && ( | |||
<div className="display-inline-block spacer-left spacer-bottom"> | |||
<AddMemberForm | |||
@@ -93,7 +97,11 @@ export class MembersPageHeader extends React.PureComponent<Props> { | |||
'organization.members.auto_sync_with_x', | |||
translate(almKey) | |||
)}> | |||
<SyncMemberForm organization={organization} /> | |||
<SyncMemberForm | |||
dismissSyncNotif={this.handleDismissSyncNotif} | |||
organization={organization} | |||
refreshMembers={refreshMembers} | |||
/> | |||
</NewInfoBox> | |||
)} | |||
</div> |
@@ -151,6 +151,18 @@ export default class OrganizationMembers extends React.PureComponent<Props, Stat | |||
); | |||
}; | |||
refreshMembers = () => { | |||
return searchMembers({ | |||
organization: this.props.organization.key, | |||
ps: PAGE_SIZE, | |||
q: this.state.query || undefined | |||
}).then(({ paging, users }) => { | |||
if (this.mounted) { | |||
this.setState({ members: users, paging }); | |||
} | |||
}); | |||
}; | |||
updateGroup = ( | |||
login: string, | |||
updater: (member: T.OrganizationMember) => T.OrganizationMember | |||
@@ -195,6 +207,7 @@ export default class OrganizationMembers extends React.PureComponent<Props, Stat | |||
loading={loading} | |||
members={members} | |||
organization={organization} | |||
refreshMembers={this.refreshMembers} | |||
/> | |||
{members !== undefined && | |||
paging !== undefined && ( |
@@ -30,8 +30,10 @@ import { translate, translateWithParameters } from '../../helpers/l10n'; | |||
import { fetchOrganization } from '../../store/rootActions'; | |||
interface Props { | |||
dismissSyncNotif?: () => void; | |||
fetchOrganization: (key: string) => void; | |||
organization: T.Organization; | |||
refreshMembers: () => Promise<void>; | |||
} | |||
interface State { | |||
@@ -47,15 +49,20 @@ export class SyncMemberForm extends React.PureComponent<Props, State> { | |||
} | |||
handleConfirm = () => { | |||
const { organization } = this.props; | |||
const { dismissSyncNotif, organization } = this.props; | |||
const { membersSync } = this.state; | |||
if (dismissSyncNotif) { | |||
dismissSyncNotif(); | |||
} | |||
return setOrganizationMemberSync({ | |||
organization: organization.key, | |||
enabled: membersSync | |||
}).then(() => { | |||
this.props.fetchOrganization(organization.key); | |||
if (membersSync && isGithub(organization.alm && organization.alm.key)) { | |||
return syncMembers(organization.key); | |||
return this.handleMemberSync(); | |||
} | |||
return Promise.resolve(); | |||
}); | |||
@@ -69,6 +76,10 @@ export class SyncMemberForm extends React.PureComponent<Props, State> { | |||
this.setState({ membersSync: true }); | |||
}; | |||
handleMemberSync = () => { | |||
return syncMembers(this.props.organization.key).then(this.props.refreshMembers); | |||
}; | |||
renderModalDescription = () => { | |||
return ( | |||
<p className="spacer-top"> |
@@ -63,6 +63,7 @@ function shallowRender(props: Partial<MembersPageHeader['props']> = {}) { | |||
loading={false} | |||
members={[]} | |||
organization={mockOrganization()} | |||
refreshMembers={jest.fn()} | |||
setCurrentUserSetting={jest.fn()} | |||
{...props} | |||
/> |
@@ -54,8 +54,7 @@ jest.mock('../../../api/user_groups', () => ({ | |||
})); | |||
beforeEach(() => { | |||
(searchMembers as jest.Mock).mockClear(); | |||
(searchUsersGroups as jest.Mock).mockClear(); | |||
jest.clearAllMocks(); | |||
}); | |||
it('should fetch members and render for non-admin', async () => { | |||
@@ -88,6 +87,28 @@ it('should load more members', async () => { | |||
expect(searchMembers).lastCalledWith({ organization: 'foo', p: 2, ps: 50, q: undefined }); | |||
}); | |||
it('should refresh members', async () => { | |||
const paging = { pageIndex: 1, pageSize: 5, total: 3 }; | |||
const users = [ | |||
{ login: 'admin', name: 'Admin Istrator', avatar: '', groupCount: 3 }, | |||
{ login: 'john', name: 'John Doe', avatar: '7daf6c79d4802916d83f6266e24850af', groupCount: 1 }, | |||
{ login: 'stan', name: 'Stan Marsh', avatar: '', groupCount: 7 } | |||
]; | |||
const wrapper = shallowRender(); | |||
await waitAndUpdate(wrapper); | |||
(searchMembers as jest.Mock).mockResolvedValueOnce({ | |||
paging, | |||
users | |||
}); | |||
await wrapper.instance().refreshMembers(); | |||
expect(searchMembers).toBeCalled(); | |||
expect(wrapper.state('members')).toEqual(users); | |||
expect(wrapper.state('paging')).toEqual(paging); | |||
}); | |||
it('should add new member', async () => { | |||
const wrapper = shallowRender(); | |||
await waitAndUpdate(wrapper); | |||
@@ -140,7 +161,7 @@ it('should update groups', async () => { | |||
}); | |||
function shallowRender(props: Partial<OrganizationMembers['props']> = {}) { | |||
return shallow( | |||
return shallow<OrganizationMembers>( | |||
<OrganizationMembers | |||
currentUser={mockCurrentUser()} | |||
organization={mockOrganizationWithAdminActions()} |
@@ -35,8 +35,10 @@ beforeEach(() => { | |||
}); | |||
it('should allow to switch to automatic mode with github', async () => { | |||
const dismissSyncNotif = jest.fn(); | |||
const fetchOrganization = jest.fn(); | |||
const wrapper = shallowRender({ fetchOrganization }); | |||
const refreshMembers = jest.fn().mockResolvedValue({}); | |||
const wrapper = shallowRender({ dismissSyncNotif, fetchOrganization, refreshMembers }); | |||
expect(wrapper).toMatchSnapshot(); | |||
wrapper.setState({ membersSync: true }); | |||
@@ -46,6 +48,8 @@ it('should allow to switch to automatic mode with github', async () => { | |||
await waitAndUpdate(wrapper); | |||
expect(fetchOrganization).toHaveBeenCalledWith('foo'); | |||
expect(syncMembers).toHaveBeenCalledWith('foo'); | |||
expect(refreshMembers).toBeCalledTimes(1); | |||
expect(dismissSyncNotif).toBeCalledTimes(1); | |||
}); | |||
it('should allow to switch to automatic mode with bitbucket', async () => { | |||
@@ -87,6 +91,7 @@ function shallowRender(props: Partial<SyncMemberForm['props']> = {}) { | |||
<SyncMemberForm | |||
fetchOrganization={jest.fn()} | |||
organization={mockOrganizationWithAlm()} | |||
refreshMembers={jest.fn().mockResolvedValue({})} | |||
{...props} | |||
/> | |||
); |
@@ -143,6 +143,7 @@ exports[`should render for bound organization without sync 1`] = ` | |||
title="organization.members.auto_sync_with_x.github" | |||
> | |||
<Connect(SyncMemberForm) | |||
dismissSyncNotif={[Function]} | |||
organization={ | |||
Object { | |||
"actions": Object { | |||
@@ -157,6 +158,7 @@ exports[`should render for bound organization without sync 1`] = ` | |||
"name": "Foo", | |||
} | |||
} | |||
refreshMembers={[MockFunction]} | |||
/> | |||
</NewInfoBox> | |||
</div> |
@@ -21,6 +21,7 @@ exports[`should fetch members and render for non-admin 1`] = ` | |||
"name": "Foo", | |||
} | |||
} | |||
refreshMembers={[Function]} | |||
/> | |||
</div> | |||
`; | |||
@@ -62,6 +63,7 @@ exports[`should fetch members and render for non-admin 2`] = ` | |||
"name": "Foo", | |||
} | |||
} | |||
refreshMembers={[Function]} | |||
/> | |||
<MembersListHeader | |||
currentUser={ |
@@ -18,67 +18,79 @@ | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import * as React from 'react'; | |||
import { connect } from 'react-redux'; | |||
import handleRequiredAuthentication from '../../../app/utils/handleRequiredAuthentication'; | |||
import OrganizationsShortList from './OrganizationsShortList'; | |||
import Modal from '../../../components/controls/Modal'; | |||
import OnboardingProjectIcon from '../../../components/icons-components/OnboardingProjectIcon'; | |||
import OnboardingTeamIcon from '../../../components/icons-components/OnboardingTeamIcon'; | |||
import { whenLoggedIn } from '../../../components/hoc/whenLoggedIn'; | |||
import { withUserOrganizations } from '../../../components/hoc/withUserOrganizations'; | |||
import { Button, ResetButtonLink } from '../../../components/ui/buttons'; | |||
import { translate } from '../../../helpers/l10n'; | |||
import { getCurrentUser, Store } from '../../../store/rootReducer'; | |||
import { isLoggedIn } from '../../../helpers/users'; | |||
import '../styles.css'; | |||
interface OwnProps { | |||
export interface Props { | |||
currentUser: T.LoggedInUser; | |||
onClose: () => void; | |||
onOpenProjectOnboarding: () => void; | |||
skipOnboarding: () => void; | |||
userOrganizations: T.Organization[]; | |||
} | |||
interface StateProps { | |||
currentUser: T.CurrentUser; | |||
} | |||
type Props = OwnProps & StateProps; | |||
export class OnboardingModal extends React.PureComponent<Props> { | |||
componentDidMount() { | |||
if (!isLoggedIn(this.props.currentUser)) { | |||
handleRequiredAuthentication(); | |||
} | |||
} | |||
export function OnboardingModal(props: Props) { | |||
const { | |||
currentUser, | |||
onClose, | |||
onOpenProjectOnboarding, | |||
skipOnboarding, | |||
userOrganizations | |||
} = props; | |||
render() { | |||
if (!isLoggedIn(this.props.currentUser)) { | |||
return null; | |||
} | |||
const organizations = userOrganizations.filter(o => o.key !== currentUser.personalOrganization); | |||
const header = translate('onboarding.header'); | |||
return ( | |||
<Modal | |||
contentLabel={header} | |||
medium={true} | |||
onRequestClose={this.props.onClose} | |||
shouldCloseOnOverlayClick={false}> | |||
<div className="modal-head"> | |||
<h2>{translate('onboarding.header')}</h2> | |||
<p className="spacer-top">{translate('onboarding.header.description')}</p> | |||
</div> | |||
<div className="modal-body text-center huge-spacer-top huge-spacer-bottom"> | |||
const header = translate('onboarding.header'); | |||
return ( | |||
<Modal | |||
contentLabel={header} | |||
medium={true} | |||
onRequestClose={onClose} | |||
shouldCloseOnOverlayClick={false}> | |||
<div className="modal-head"> | |||
<h2>{translate('onboarding.header')}</h2> | |||
<p className="spacer-top">{translate('onboarding.header.description')}</p> | |||
</div> | |||
<div className="modal-body text-center display-flex-row huge-spacer-top huge-spacer-bottom"> | |||
<div className="flex-1"> | |||
<OnboardingProjectIcon className="big-spacer-bottom" /> | |||
<h6 className="onboarding-choice-name big-spacer-bottom"> | |||
{translate('onboarding.analyze_your_code')} | |||
</h6> | |||
<Button onClick={this.props.onOpenProjectOnboarding}> | |||
<Button onClick={onOpenProjectOnboarding}> | |||
{translate('onboarding.project.create')} | |||
</Button> | |||
</div> | |||
<div className="modal-foot text-right"> | |||
<ResetButtonLink onClick={this.props.onClose}>{translate('not_now')}</ResetButtonLink> | |||
</div> | |||
</Modal> | |||
); | |||
} | |||
{organizations.length > 0 && ( | |||
<> | |||
<div className="vertical-pipe-separator"> | |||
<div className="vertical-separator" /> | |||
</div> | |||
<div className="flex-1"> | |||
<OnboardingTeamIcon className="big-spacer-bottom" /> | |||
<h6 className="onboarding-choice-name big-spacer-bottom"> | |||
{translate('onboarding.browse_your_organizations')} | |||
</h6> | |||
<OrganizationsShortList | |||
organizations={organizations} | |||
skipOnboarding={skipOnboarding} | |||
/> | |||
</div> | |||
</> | |||
)} | |||
</div> | |||
<div className="modal-foot text-right"> | |||
<ResetButtonLink onClick={onClose}>{translate('not_now')}</ResetButtonLink> | |||
</div> | |||
</Modal> | |||
); | |||
} | |||
const mapStateToProps = (state: Store): StateProps => ({ currentUser: getCurrentUser(state) }); | |||
export default connect(mapStateToProps)(OnboardingModal); | |||
export default withUserOrganizations(whenLoggedIn(OnboardingModal)); |
@@ -19,44 +19,30 @@ | |||
*/ | |||
import * as React from 'react'; | |||
import { connect } from 'react-redux'; | |||
import { InjectedRouter } from 'react-router'; | |||
import OnboardingModal from './OnboardingModal'; | |||
import { skipOnboarding } from '../../../store/users'; | |||
import { OnboardingContext } from '../../../app/components/OnboardingContext'; | |||
import { Router } from '../../../components/hoc/withRouter'; | |||
interface DispatchProps { | |||
interface Props { | |||
router: Router; | |||
skipOnboarding: () => void; | |||
} | |||
interface OwnProps { | |||
router: InjectedRouter; | |||
} | |||
interface State { | |||
open: boolean; | |||
} | |||
export class OnboardingPage extends React.PureComponent<OwnProps & DispatchProps, State> { | |||
state: State = { open: false }; | |||
export class OnboardingPage extends React.PureComponent<Props> { | |||
closeOnboarding = () => { | |||
this.props.skipOnboarding(); | |||
this.props.router.replace('/'); | |||
}; | |||
render() { | |||
const { open } = this.state; | |||
if (!open) { | |||
return null; | |||
} | |||
return ( | |||
<OnboardingContext.Consumer> | |||
{openProjectOnboarding => ( | |||
<OnboardingModal | |||
onClose={this.closeOnboarding} | |||
onOpenProjectOnboarding={openProjectOnboarding} | |||
skipOnboarding={this.props.skipOnboarding} | |||
/> | |||
)} | |||
</OnboardingContext.Consumer> | |||
@@ -64,7 +50,7 @@ export class OnboardingPage extends React.PureComponent<OwnProps & DispatchProps | |||
} | |||
} | |||
const mapDispatchToProps: DispatchProps = { skipOnboarding }; | |||
const mapDispatchToProps = { skipOnboarding }; | |||
export default connect( | |||
null, |
@@ -0,0 +1,64 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2019 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 { sortBy } from 'lodash'; | |||
import { Link } from 'react-router'; | |||
import OrganizationsShortListItem from './OrganizationsShortListItem'; | |||
import { translate, translateWithParameters } from '../../../helpers/l10n'; | |||
export interface Props { | |||
organizations: T.Organization[]; | |||
skipOnboarding: () => void; | |||
} | |||
export default function OrganizationsShortList({ organizations, skipOnboarding }: Props) { | |||
if (organizations.length === 0) { | |||
return null; | |||
} | |||
const organizationsShown = sortBy(organizations, organization => | |||
organization.name.toLocaleLowerCase() | |||
).slice(0, 3); | |||
return ( | |||
<div> | |||
<ul className="account-projects-list"> | |||
{organizationsShown.map(organization => ( | |||
<li key={organization.key}> | |||
<OrganizationsShortListItem | |||
organization={organization} | |||
skipOnboarding={skipOnboarding} | |||
/> | |||
</li> | |||
))} | |||
</ul> | |||
<div className="big-spacer-top"> | |||
<span className="big-spacer-right"> | |||
{translateWithParameters('x_of_y_shown', organizationsShown.length, organizations.length)} | |||
</span> | |||
{organizations.length > 3 && ( | |||
<Link className="small" onClick={skipOnboarding} to="/account/organizations"> | |||
{translate('see_all')} | |||
</Link> | |||
)} | |||
</div> | |||
</div> | |||
); | |||
} |
@@ -0,0 +1,52 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2019 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 OrganizationAvatar from '../../../components/common/OrganizationAvatar'; | |||
import { ListButton } from '../../../components/ui/buttons'; | |||
import { withRouter, Router } from '../../../components/hoc/withRouter'; | |||
import { getOrganizationUrl } from '../../../helpers/urls'; | |||
interface Props { | |||
organization: T.Organization; | |||
router: Router; | |||
skipOnboarding: () => void; | |||
} | |||
export class OrganizationsShortListItem extends React.PureComponent<Props> { | |||
handleClick = () => { | |||
const { organization, router, skipOnboarding } = this.props; | |||
skipOnboarding(); | |||
router.push(getOrganizationUrl(organization.key)); | |||
}; | |||
render() { | |||
const { organization } = this.props; | |||
return ( | |||
<ListButton className="abs-width-300" onClick={this.handleClick}> | |||
<div className="display-flex-center"> | |||
<OrganizationAvatar className="spacer-right" organization={organization} /> | |||
<span>{organization.name}</span> | |||
</div> | |||
</ListButton> | |||
); | |||
} | |||
} | |||
export default withRouter(OrganizationsShortListItem); |
@@ -19,31 +19,18 @@ | |||
*/ | |||
import * as React from 'react'; | |||
import { shallow } from 'enzyme'; | |||
import { OnboardingModal } from '../OnboardingModal'; | |||
import { OnboardingModal, Props } from '../OnboardingModal'; | |||
import { click } from '../../../../helpers/testUtils'; | |||
import { mockCurrentUser, mockOrganization } from '../../../../helpers/testMocks'; | |||
it('renders correctly', () => { | |||
expect( | |||
shallow( | |||
<OnboardingModal | |||
currentUser={{ isLoggedIn: true }} | |||
onClose={jest.fn()} | |||
onOpenProjectOnboarding={jest.fn()} | |||
/> | |||
) | |||
).toMatchSnapshot(); | |||
expect(shallowRender()).toMatchSnapshot(); | |||
}); | |||
it('should correctly open the different tutorials', () => { | |||
it('should open project create page', () => { | |||
const onClose = jest.fn(); | |||
const onOpenProjectOnboarding = jest.fn(); | |||
const wrapper = shallow( | |||
<OnboardingModal | |||
currentUser={{ isLoggedIn: true }} | |||
onClose={onClose} | |||
onOpenProjectOnboarding={onOpenProjectOnboarding} | |||
/> | |||
); | |||
const wrapper = shallowRender({ onClose, onOpenProjectOnboarding }); | |||
click(wrapper.find('ResetButtonLink')); | |||
expect(onClose).toHaveBeenCalled(); | |||
@@ -51,3 +38,30 @@ it('should correctly open the different tutorials', () => { | |||
wrapper.find('Button').forEach(button => click(button)); | |||
expect(onOpenProjectOnboarding).toHaveBeenCalled(); | |||
}); | |||
it('should display organization list if any', () => { | |||
const wrapper = shallowRender({ | |||
currentUser: mockCurrentUser({ personalOrganization: 'personal' }), | |||
userOrganizations: [ | |||
mockOrganization({ key: 'a', name: 'Arthur' }), | |||
mockOrganization({ key: 'd', name: 'Daniel Inc' }), | |||
mockOrganization({ key: 'personal', name: 'Personal' }) | |||
] | |||
}); | |||
expect(wrapper).toMatchSnapshot(); | |||
expect(wrapper.find('OrganizationsShortList').prop('organizations')).toHaveLength(2); | |||
}); | |||
function shallowRender(props: Partial<Props> = {}) { | |||
return shallow( | |||
<OnboardingModal | |||
currentUser={mockCurrentUser()} | |||
onClose={jest.fn()} | |||
onOpenProjectOnboarding={jest.fn()} | |||
skipOnboarding={jest.fn()} | |||
userOrganizations={[]} | |||
{...props} | |||
/> | |||
); | |||
} |
@@ -0,0 +1,56 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2019 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 } from 'enzyme'; | |||
import OrganizationsShortList, { Props } from '../OrganizationsShortList'; | |||
import { mockOrganization } from '../../../../helpers/testMocks'; | |||
it('should render null with no orgs', () => { | |||
expect(shallowRender().getElement()).toBe(null); | |||
}); | |||
it('should render correctly', () => { | |||
const wrapper = shallowRender({ | |||
organizations: [mockOrganization(), mockOrganization({ key: 'bar', name: 'Bar' })] | |||
}); | |||
expect(wrapper).toMatchSnapshot(); | |||
expect(wrapper.find('li')).toHaveLength(2); | |||
}); | |||
it('should limit displayed orgs to the first three', () => { | |||
const wrapper = shallowRender({ | |||
organizations: [ | |||
mockOrganization(), | |||
mockOrganization({ key: 'zoo', name: 'Zoological' }), | |||
mockOrganization({ key: 'bar', name: 'Bar' }), | |||
mockOrganization({ key: 'kor', name: 'Kor' }) | |||
] | |||
}); | |||
expect(wrapper).toMatchSnapshot(); | |||
expect(wrapper.find('li')).toHaveLength(3); | |||
}); | |||
function shallowRender(props: Partial<Props> = {}) { | |||
return shallow( | |||
<OrganizationsShortList organizations={[]} skipOnboarding={jest.fn()} {...props} /> | |||
); | |||
} |
@@ -0,0 +1,50 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2019 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 } from 'enzyme'; | |||
import { OrganizationsShortListItem } from '../OrganizationsShortListItem'; | |||
import { mockRouter, mockOrganization } from '../../../../helpers/testMocks'; | |||
import { click } from '../../../../helpers/testUtils'; | |||
it('renders correctly', () => { | |||
expect(shallowRender()).toMatchSnapshot(); | |||
}); | |||
it('calls skiponboarding and redirects to org page', () => { | |||
const skipOnboarding = jest.fn(); | |||
const push = jest.fn(); | |||
const wrapper = shallowRender({ skipOnboarding, router: mockRouter({ push }) }); | |||
click(wrapper); | |||
expect(skipOnboarding).toHaveBeenCalledTimes(1); | |||
expect(push).toHaveBeenCalledWith('/organizations/foo'); | |||
}); | |||
function shallowRender(props: Partial<OrganizationsShortListItem['props']> = {}) { | |||
return shallow( | |||
<OrganizationsShortListItem | |||
organization={mockOrganization()} | |||
router={mockRouter()} | |||
skipOnboarding={jest.fn()} | |||
{...props} | |||
/> | |||
); | |||
} |
@@ -20,21 +20,111 @@ exports[`renders correctly 1`] = ` | |||
</p> | |||
</div> | |||
<div | |||
className="modal-body text-center huge-spacer-top huge-spacer-bottom" | |||
className="modal-body text-center display-flex-row huge-spacer-top huge-spacer-bottom" | |||
> | |||
<OnboardingProjectIcon | |||
className="big-spacer-bottom" | |||
/> | |||
<h6 | |||
className="onboarding-choice-name big-spacer-bottom" | |||
> | |||
onboarding.analyze_your_code | |||
</h6> | |||
<Button | |||
<div | |||
className="flex-1" | |||
> | |||
<OnboardingProjectIcon | |||
className="big-spacer-bottom" | |||
/> | |||
<h6 | |||
className="onboarding-choice-name big-spacer-bottom" | |||
> | |||
onboarding.analyze_your_code | |||
</h6> | |||
<Button | |||
onClick={[MockFunction]} | |||
> | |||
onboarding.project.create | |||
</Button> | |||
</div> | |||
</div> | |||
<div | |||
className="modal-foot text-right" | |||
> | |||
<ResetButtonLink | |||
onClick={[MockFunction]} | |||
> | |||
onboarding.project.create | |||
</Button> | |||
not_now | |||
</ResetButtonLink> | |||
</div> | |||
</Modal> | |||
`; | |||
exports[`should display organization list if any 1`] = ` | |||
<Modal | |||
contentLabel="onboarding.header" | |||
medium={true} | |||
onRequestClose={[MockFunction]} | |||
shouldCloseOnOverlayClick={false} | |||
> | |||
<div | |||
className="modal-head" | |||
> | |||
<h2> | |||
onboarding.header | |||
</h2> | |||
<p | |||
className="spacer-top" | |||
> | |||
onboarding.header.description | |||
</p> | |||
</div> | |||
<div | |||
className="modal-body text-center display-flex-row huge-spacer-top huge-spacer-bottom" | |||
> | |||
<div | |||
className="flex-1" | |||
> | |||
<OnboardingProjectIcon | |||
className="big-spacer-bottom" | |||
/> | |||
<h6 | |||
className="onboarding-choice-name big-spacer-bottom" | |||
> | |||
onboarding.analyze_your_code | |||
</h6> | |||
<Button | |||
onClick={[MockFunction]} | |||
> | |||
onboarding.project.create | |||
</Button> | |||
</div> | |||
<div | |||
className="vertical-pipe-separator" | |||
> | |||
<div | |||
className="vertical-separator" | |||
/> | |||
</div> | |||
<div | |||
className="flex-1" | |||
> | |||
<OnboardingTeamIcon | |||
className="big-spacer-bottom" | |||
/> | |||
<h6 | |||
className="onboarding-choice-name big-spacer-bottom" | |||
> | |||
onboarding.browse_your_organizations | |||
</h6> | |||
<OrganizationsShortList | |||
organizations={ | |||
Array [ | |||
Object { | |||
"key": "a", | |||
"name": "Arthur", | |||
}, | |||
Object { | |||
"key": "d", | |||
"name": "Daniel Inc", | |||
}, | |||
] | |||
} | |||
skipOnboarding={[MockFunction]} | |||
/> | |||
</div> | |||
</div> | |||
<div | |||
className="modal-foot text-right" |
@@ -0,0 +1,111 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`should limit displayed orgs to the first three 1`] = ` | |||
<div> | |||
<ul | |||
className="account-projects-list" | |||
> | |||
<li | |||
key="bar" | |||
> | |||
<withRouter(OrganizationsShortListItem) | |||
organization={ | |||
Object { | |||
"key": "bar", | |||
"name": "Bar", | |||
} | |||
} | |||
skipOnboarding={[MockFunction]} | |||
/> | |||
</li> | |||
<li | |||
key="foo" | |||
> | |||
<withRouter(OrganizationsShortListItem) | |||
organization={ | |||
Object { | |||
"key": "foo", | |||
"name": "Foo", | |||
} | |||
} | |||
skipOnboarding={[MockFunction]} | |||
/> | |||
</li> | |||
<li | |||
key="kor" | |||
> | |||
<withRouter(OrganizationsShortListItem) | |||
organization={ | |||
Object { | |||
"key": "kor", | |||
"name": "Kor", | |||
} | |||
} | |||
skipOnboarding={[MockFunction]} | |||
/> | |||
</li> | |||
</ul> | |||
<div | |||
className="big-spacer-top" | |||
> | |||
<span | |||
className="big-spacer-right" | |||
> | |||
x_of_y_shown.3.4 | |||
</span> | |||
<Link | |||
className="small" | |||
onClick={[MockFunction]} | |||
onlyActiveOnIndex={false} | |||
style={Object {}} | |||
to="/account/organizations" | |||
> | |||
see_all | |||
</Link> | |||
</div> | |||
</div> | |||
`; | |||
exports[`should render correctly 1`] = ` | |||
<div> | |||
<ul | |||
className="account-projects-list" | |||
> | |||
<li | |||
key="bar" | |||
> | |||
<withRouter(OrganizationsShortListItem) | |||
organization={ | |||
Object { | |||
"key": "bar", | |||
"name": "Bar", | |||
} | |||
} | |||
skipOnboarding={[MockFunction]} | |||
/> | |||
</li> | |||
<li | |||
key="foo" | |||
> | |||
<withRouter(OrganizationsShortListItem) | |||
organization={ | |||
Object { | |||
"key": "foo", | |||
"name": "Foo", | |||
} | |||
} | |||
skipOnboarding={[MockFunction]} | |||
/> | |||
</li> | |||
</ul> | |||
<div | |||
className="big-spacer-top" | |||
> | |||
<span | |||
className="big-spacer-right" | |||
> | |||
x_of_y_shown.2.2 | |||
</span> | |||
</div> | |||
</div> | |||
`; |
@@ -0,0 +1,25 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`renders correctly 1`] = ` | |||
<ListButton | |||
className="abs-width-300" | |||
onClick={[Function]} | |||
> | |||
<div | |||
className="display-flex-center" | |||
> | |||
<OrganizationAvatar | |||
className="spacer-right" | |||
organization={ | |||
Object { | |||
"key": "foo", | |||
"name": "Foo", | |||
} | |||
} | |||
/> | |||
<span> | |||
Foo | |||
</span> | |||
</div> | |||
</ListButton> | |||
`; |
@@ -23,7 +23,7 @@ import { withCurrentUser } from './withCurrentUser'; | |||
import { isLoggedIn } from '../../helpers/users'; | |||
import handleRequiredAuthentication from '../../app/utils/handleRequiredAuthentication'; | |||
export function whenLoggedIn<P>(WrappedComponent: React.ComponentClass<P>) { | |||
export function whenLoggedIn<P>(WrappedComponent: React.ComponentType<P>) { | |||
class Wrapper extends React.Component<P & { currentUser: T.CurrentUser }> { | |||
static displayName = getWrappedDisplayName(WrappedComponent, 'whenLoggedIn'); | |||
@@ -29,7 +29,7 @@ interface OwnProps { | |||
} | |||
export function withUserOrganizations<P>( | |||
WrappedComponent: React.ComponentClass<P & Partial<OwnProps>> | |||
WrappedComponent: React.ComponentType<P & Partial<OwnProps>> | |||
) { | |||
class Wrapper extends React.Component<P & OwnProps> { | |||
static displayName = getWrappedDisplayName(WrappedComponent, 'withUserOrganizations'); |
@@ -0,0 +1,34 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2019 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 Icon, { IconProps } from './Icon'; | |||
import * as theme from '../../app/theme'; | |||
export default function OnboardingTeamIcon({ className, fill = theme.darkBlue, size }: IconProps) { | |||
return ( | |||
<Icon className={className} size={size || 64} viewBox="0 0 64 64"> | |||
<g fill="none" fillRule="evenodd" stroke={fill} strokeWidth="2"> | |||
<path d="M32 9v5M11.5195 43.0898l7.48-4.091m33.481-18.0994l-7.48 4.1m-33.481-4.1l7.48 4.1M45 38.999l7.48 4.101M32 50v5m15-23c0 8.284-6.715 15-15 15s-15-6.716-15-15c0-8.285 6.715-15 15-15s15 6.715 15 15z" /> | |||
<path d="M40 38c0 1.656-3.58 2-8 2s-8-.344-8-2m16 0v-3l-5-3-1-1m-10 7v-3l5-3 1-1m6-4c0 2.2-1.8 4-4 4s-4-1.8-4-4v-1c0-2.2 1.8-4 4-4s4 1.8 4 4v1zm-.0098-21.71c7.18 1.069 13.439 4.96 17.609 10.51m-17.609 42.91c7.18-1.07 13.439-4.96 17.609-10.51M6.6299 41.25c-1.06-2.88-1.63-6-1.63-9.25s.57-6.37 1.63-9.25m3.7705-6.9502c4.17-5.55 10.43-9.44 17.609-10.51m-17.609 42.9104c4.17 5.55 10.43 9.439 17.609 10.51M57.3701 22.75c1.06 2.88 1.63 6 1.63 9.25s-.57 6.37-1.63 9.25" /> | |||
<path d="M36 5c0 2.209-1.79 4-4 4-2.209 0-4-1.791-4-4 0-2.21 1.791-4 4-4 2.21 0 4 1.79 4 4zm-5 0h2M12 19c0 2.209-1.79 4-4 4-2.209 0-4-1.791-4-4 0-2.21 1.791-4 4-4 2.21 0 4 1.79 4 4zm-5 0h2m51 0c0 2.209-1.79 4-4 4-2.209 0-4-1.791-4-4 0-2.21 1.791-4 4-4 2.21 0 4 1.79 4 4zm-5 0h2M12 45c0 2.209-1.79 4-4 4-2.209 0-4-1.791-4-4 0-2.21 1.791-4 4-4 2.21 0 4 1.79 4 4zm-5 0h2m51 0c0 2.209-1.79 4-4 4-2.209 0-4-1.791-4-4 0-2.21 1.791-4 4-4 2.21 0 4 1.79 4 4zm-5 0h2M36 59c0 2.209-1.79 4-4 4-2.209 0-4-1.791-4-4 0-2.21 1.791-4 4-4 2.21 0 4 1.79 4 4zm-5 0h2" /> | |||
</g> | |||
</Icon> | |||
); | |||
} |
@@ -79,6 +79,7 @@ | |||
.button-red:focus { | |||
box-shadow: 0 0 0 3px rgba(212, 51, 63, 0.25); | |||
} | |||
/* #endregion */ | |||
/* #region .button-success */ | |||
@@ -96,6 +97,7 @@ | |||
.button-success:focus { | |||
box-shadow: 0 0 0 3px rgba(0, 170, 0, 0.25); | |||
} | |||
/* #endregion */ | |||
/* #region .button-grey */ | |||
@@ -113,12 +115,14 @@ | |||
.button-grey:focus { | |||
box-shadow: 0 0 0 3px rgba(180, 180, 180, 0.25); | |||
} | |||
/* #endregion */ | |||
/* #region .button-link */ | |||
.button-link { | |||
display: inline-flex; | |||
height: auto; /* Keep this to not inherit the height from .button */ | |||
height: auto; | |||
/* Keep this to not inherit the height from .button */ | |||
line-height: 1; | |||
margin: 0; | |||
padding: 0; | |||
@@ -154,6 +158,7 @@ | |||
background: transparent !important; | |||
cursor: default; | |||
} | |||
/* #endregion */ | |||
.button-small { | |||
@@ -217,6 +222,7 @@ | |||
margin: 0 8px; | |||
font-size: var(--smallFontSize); | |||
} | |||
/* #endregion */ | |||
/* #region .button-icon */ | |||
@@ -261,4 +267,23 @@ | |||
.button-icon:focus svg { | |||
color: #fff; | |||
} | |||
/* #endregion */ | |||
.button-list { | |||
height: auto; | |||
border: 1px solid var(--barBorderColor); | |||
padding: var(--gridSize); | |||
margin: calc(var(--gridSize) / 2); | |||
text-align: left; | |||
justify-content: space-between; | |||
color: var(--secondFontColor); | |||
font-weight: normal; | |||
} | |||
.button-list:hover { | |||
background-color: white; | |||
border-color: var(--blue); | |||
color: var(--darkBlue); | |||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.175); | |||
} |
@@ -24,6 +24,7 @@ import ClearIcon from '../icons-components/ClearIcon'; | |||
import EditIcon from '../icons-components/EditIcon'; | |||
import Tooltip from '../controls/Tooltip'; | |||
import './buttons.css'; | |||
import ChevronRightIcon from '../icons-components/ChevronRightcon'; | |||
type AllowedButtonAttributes = Pick< | |||
React.ButtonHTMLAttributes<HTMLButtonElement>, | |||
@@ -138,3 +139,12 @@ export function EditButton(props: ActionButtonProps) { | |||
</ButtonIcon> | |||
); | |||
} | |||
export function ListButton({ className, children, ...props }: ButtonProps) { | |||
return ( | |||
<Button className={classNames('button-list', className)} {...props}> | |||
{children} | |||
<ChevronRightIcon /> | |||
</Button> | |||
); | |||
} |
@@ -160,7 +160,7 @@ rule=Rule | |||
rules=Rules | |||
save=Save | |||
search_verb=Search | |||
see_all=See All | |||
see_all=See all | |||
select_verb=Select | |||
selected=Selected | |||
set=Set | |||
@@ -2852,6 +2852,7 @@ onboarding.team.work_in_progress=We are currently working on a better way to joi | |||
onboarding.analyze_your_code.note=Free | |||
onboarding.analyze_your_code=Analyze your code | |||
onboarding.contribute_existing_project=Join a team | |||
onboarding.browse_your_organizations=Browse your organizations | |||
onboarding.token.header=Provide a token | |||
onboarding.token.text=The token is used to identify you when an analysis is performed. If it has been compromised, you can revoke it at any point of time in your {link}. |