Browse Source

SONARCLOUD-413 Add organizations list in onboarding modal

tags/7.7
Jeremy Davis 5 years ago
parent
commit
f79ab22a93
27 changed files with 735 additions and 109 deletions
  1. 2
    1
      server/sonar-web/src/main/js/app/components/StartupModal.tsx
  2. 33
    2
      server/sonar-web/src/main/js/app/components/__tests__/StartupModal-test.tsx
  3. 1
    0
      server/sonar-web/src/main/js/app/theme.js
  4. 11
    3
      server/sonar-web/src/main/js/apps/organizationMembers/MembersPageHeader.tsx
  5. 13
    0
      server/sonar-web/src/main/js/apps/organizationMembers/OrganizationMembers.tsx
  6. 13
    2
      server/sonar-web/src/main/js/apps/organizationMembers/SyncMemberForm.tsx
  7. 1
    0
      server/sonar-web/src/main/js/apps/organizationMembers/__tests__/MembersPageHeader-test.tsx
  8. 24
    3
      server/sonar-web/src/main/js/apps/organizationMembers/__tests__/OrganizationMembers-test.tsx
  9. 6
    1
      server/sonar-web/src/main/js/apps/organizationMembers/__tests__/SyncMemberForm-test.tsx
  10. 2
    0
      server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/MembersPageHeader-test.tsx.snap
  11. 2
    0
      server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/OrganizationMembers-test.tsx.snap
  12. 55
    43
      server/sonar-web/src/main/js/apps/tutorials/onboarding/OnboardingModal.tsx
  13. 6
    20
      server/sonar-web/src/main/js/apps/tutorials/onboarding/OnboardingPage.tsx
  14. 64
    0
      server/sonar-web/src/main/js/apps/tutorials/onboarding/OrganizationsShortList.tsx
  15. 52
    0
      server/sonar-web/src/main/js/apps/tutorials/onboarding/OrganizationsShortListItem.tsx
  16. 32
    18
      server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/OnboardingModal-test.tsx
  17. 56
    0
      server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/OrganizationsShortList-test.tsx
  18. 50
    0
      server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/OrganizationsShortListItem-test.tsx
  19. 102
    12
      server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/__snapshots__/OnboardingModal-test.tsx.snap
  20. 111
    0
      server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/__snapshots__/OrganizationsShortList-test.tsx.snap
  21. 25
    0
      server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/__snapshots__/OrganizationsShortListItem-test.tsx.snap
  22. 1
    1
      server/sonar-web/src/main/js/components/hoc/whenLoggedIn.tsx
  23. 1
    1
      server/sonar-web/src/main/js/components/hoc/withUserOrganizations.tsx
  24. 34
    0
      server/sonar-web/src/main/js/components/icons-components/OnboardingTeamIcon.tsx
  25. 26
    1
      server/sonar-web/src/main/js/components/ui/buttons.css
  26. 10
    0
      server/sonar-web/src/main/js/components/ui/buttons.tsx
  27. 2
    1
      sonar-core/src/main/resources/org/sonar/l10n/core.properties

+ 2
- 1
server/sonar-web/src/main/js/app/components/StartupModal.tsx View File

@@ -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>

+ 33
- 2
server/sonar-web/src/main/js/app/components/__tests__/StartupModal-test.tsx View File

@@ -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}

+ 1
- 0
server/sonar-web/src/main/js/app/theme.js View File

@@ -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`,

+ 11
- 3
server/sonar-web/src/main/js/apps/organizationMembers/MembersPageHeader.tsx View File

@@ -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>

+ 13
- 0
server/sonar-web/src/main/js/apps/organizationMembers/OrganizationMembers.tsx View File

@@ -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 && (

+ 13
- 2
server/sonar-web/src/main/js/apps/organizationMembers/SyncMemberForm.tsx View File

@@ -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">

+ 1
- 0
server/sonar-web/src/main/js/apps/organizationMembers/__tests__/MembersPageHeader-test.tsx View File

@@ -63,6 +63,7 @@ function shallowRender(props: Partial<MembersPageHeader['props']> = {}) {
loading={false}
members={[]}
organization={mockOrganization()}
refreshMembers={jest.fn()}
setCurrentUserSetting={jest.fn()}
{...props}
/>

+ 24
- 3
server/sonar-web/src/main/js/apps/organizationMembers/__tests__/OrganizationMembers-test.tsx View File

@@ -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()}

+ 6
- 1
server/sonar-web/src/main/js/apps/organizationMembers/__tests__/SyncMemberForm-test.tsx View File

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

+ 2
- 0
server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/MembersPageHeader-test.tsx.snap View File

@@ -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>

+ 2
- 0
server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/OrganizationMembers-test.tsx.snap View File

@@ -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={

+ 55
- 43
server/sonar-web/src/main/js/apps/tutorials/onboarding/OnboardingModal.tsx View File

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

+ 6
- 20
server/sonar-web/src/main/js/apps/tutorials/onboarding/OnboardingPage.tsx View File

@@ -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,

+ 64
- 0
server/sonar-web/src/main/js/apps/tutorials/onboarding/OrganizationsShortList.tsx View File

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

+ 52
- 0
server/sonar-web/src/main/js/apps/tutorials/onboarding/OrganizationsShortListItem.tsx View File

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

+ 32
- 18
server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/OnboardingModal-test.tsx View File

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

+ 56
- 0
server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/OrganizationsShortList-test.tsx View File

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

+ 50
- 0
server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/OrganizationsShortListItem-test.tsx View File

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

+ 102
- 12
server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/__snapshots__/OnboardingModal-test.tsx.snap View File

@@ -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"

+ 111
- 0
server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/__snapshots__/OrganizationsShortList-test.tsx.snap View File

@@ -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>
`;

+ 25
- 0
server/sonar-web/src/main/js/apps/tutorials/onboarding/__tests__/__snapshots__/OrganizationsShortListItem-test.tsx.snap View File

@@ -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>
`;

+ 1
- 1
server/sonar-web/src/main/js/components/hoc/whenLoggedIn.tsx View File

@@ -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');


+ 1
- 1
server/sonar-web/src/main/js/components/hoc/withUserOrganizations.tsx View File

@@ -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');

+ 34
- 0
server/sonar-web/src/main/js/components/icons-components/OnboardingTeamIcon.tsx View File

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

+ 26
- 1
server/sonar-web/src/main/js/components/ui/buttons.css View File

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

+ 10
- 0
server/sonar-web/src/main/js/components/ui/buttons.tsx View File

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

+ 2
- 1
sonar-core/src/main/resources/org/sonar/l10n/core.properties View File

@@ -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}.

Loading…
Cancel
Save