diff options
author | Grégoire Aubert <gregoire.aubert@sonarsource.com> | 2019-02-05 09:38:19 +0100 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2019-03-06 11:30:41 +0100 |
commit | 9bd42df0365a3c64161ac9283c62b4a9f422402d (patch) | |
tree | 0bf09e9422342aa91c949c62481841481aec48db /server/sonar-web/src/main | |
parent | 2847ce4e648a167335d675a3ba6e71b7b1b0f248 (diff) | |
download | sonarqube-9bd42df0365a3c64161ac9283c62b4a9f422402d.tar.gz sonarqube-9bd42df0365a3c64161ac9283c62b4a9f422402d.zip |
SONARCLOUD-379 Enable users sync on existing ALM bound organizations
* Display org sync advertisement block
* Add membersSync prop to organization type and update mock functions
* Extract RadioCard from CardPlan
* Allow to customize Modal through ConfirmButton
* Add user sync configuration modal
* Show help tooltip when user sync is activated
Diffstat (limited to 'server/sonar-web/src/main')
66 files changed, 2116 insertions, 818 deletions
diff --git a/server/sonar-web/src/main/js/api/organizations.ts b/server/sonar-web/src/main/js/api/organizations.ts index 736dc934d39..cc2d8c0f205 100644 --- a/server/sonar-web/src/main/js/api/organizations.ts +++ b/server/sonar-web/src/main/js/api/organizations.ts @@ -39,6 +39,7 @@ export function getOrganization(key: string): Promise<T.Organization | undefined interface GetOrganizationNavigation { adminPages: T.Extension[]; + alm?: { key: string; membersSync: boolean; url: string }; canUpdateProjectsVisibilityToPrivate: boolean; isDefault: boolean; pages: T.Extension[]; @@ -102,3 +103,11 @@ export interface OrganizationBilling { export function getOrganizationBilling(organization: string): Promise<OrganizationBilling> { return getJSON('/api/billing/show', { organization, p: 1, ps: 1 }); } + +export function setOrganizationMemberSync(data: { enabled: boolean; organization: string }) { + return post('/api/organizations/set_members_sync', data).catch(throwGlobalError); +} + +export function syncMembers(organization: string) { + return post('/api/organizations/sync_members', { organization }).catch(throwGlobalError); +} diff --git a/server/sonar-web/src/main/js/app/components/notifications/NavLatestNotification.tsx b/server/sonar-web/src/main/js/app/components/notifications/NavLatestNotification.tsx index 2c1c50da27d..957a0f78ce9 100644 --- a/server/sonar-web/src/main/js/app/components/notifications/NavLatestNotification.tsx +++ b/server/sonar-web/src/main/js/app/components/notifications/NavLatestNotification.tsx @@ -71,7 +71,7 @@ export default class NavLatestNotification extends React.PureComponent<Props> { <> <li className="navbar-latest-notification" onClick={this.props.onClick}> <div className="navbar-latest-notification-wrapper"> - <span className="badge">{translate('new')}</span> + <span className="badge badge-new">{translate('new')}</span> <span className="label">{lastNews.notification}</span> </div> </li> diff --git a/server/sonar-web/src/main/js/app/components/notifications/__tests__/__snapshots__/NavLatestNotification-test.tsx.snap b/server/sonar-web/src/main/js/app/components/notifications/__tests__/__snapshots__/NavLatestNotification-test.tsx.snap index 5e2bc920858..83cb55464a0 100644 --- a/server/sonar-web/src/main/js/app/components/notifications/__tests__/__snapshots__/NavLatestNotification-test.tsx.snap +++ b/server/sonar-web/src/main/js/app/components/notifications/__tests__/__snapshots__/NavLatestNotification-test.tsx.snap @@ -10,7 +10,7 @@ exports[`should render correctly if there are new features, and the user has not className="navbar-latest-notification-wrapper" > <span - className="badge" + className="badge badge-new" > new </span> diff --git a/server/sonar-web/src/main/js/app/components/notifications/notifications.css b/server/sonar-web/src/main/js/app/components/notifications/notifications.css index 0f2209127e4..e1760937ab2 100644 --- a/server/sonar-web/src/main/js/app/components/notifications/notifications.css +++ b/server/sonar-web/src/main/js/app/components/notifications/notifications.css @@ -46,16 +46,11 @@ color: var(--sonarcloudBlack300); } -.navbar-latest-notification-wrapper .badge { +.navbar-latest-notification-wrapper .badge-new { position: absolute; - height: 18px; margin-right: var(--gridSize); left: calc(var(--gridSize) / 2); top: 5px; - font-size: var(--verySmallFontSize); - text-transform: uppercase; - background-color: var(--lightBlue); - color: var(--darkBlue); } .navbar-latest-notification-wrapper .label { diff --git a/server/sonar-web/src/main/js/app/styles/components/badges.css b/server/sonar-web/src/main/js/app/styles/components/badges.css index 54a16858026..cdd025107ec 100644 --- a/server/sonar-web/src/main/js/app/styles/components/badges.css +++ b/server/sonar-web/src/main/js/app/styles/components/badges.css @@ -80,6 +80,14 @@ a.badge { line-height: calc(var(--tinyControlHeight) - 1px) !important; } +.badge-new { + height: 18px; + font-size: var(--verySmallFontSize); + text-transform: uppercase; + background-color: var(--lightBlue); + color: var(--darkBlue); +} + .badge-muted { background-color: transparent; color: var(--secondFontColor); diff --git a/server/sonar-web/src/main/js/app/styles/components/modals.css b/server/sonar-web/src/main/js/app/styles/components/modals.css index e1f7c4e100d..7a1b8a4e72b 100644 --- a/server/sonar-web/src/main/js/app/styles/components/modals.css +++ b/server/sonar-web/src/main/js/app/styles/components/modals.css @@ -42,8 +42,8 @@ } .modal-medium { - width: 800px; - margin-left: -400px; + width: 830px; + margin-left: -415px; } .modal-large { diff --git a/server/sonar-web/src/main/js/app/theme.js b/server/sonar-web/src/main/js/app/theme.js index 322b8c11461..25ce5c25956 100644 --- a/server/sonar-web/src/main/js/app/theme.js +++ b/server/sonar-web/src/main/js/app/theme.js @@ -24,6 +24,7 @@ const grid = 8; module.exports = { // colors blue: '#4b9fd5', + veryLightBlue: '#f2faff', lightBlue: '#cae3f2', darkBlue: '#236a97', green: '#00aa00', diff --git a/server/sonar-web/src/main/js/app/types.d.ts b/server/sonar-web/src/main/js/app/types.d.ts index e51039b9405..9f70f1e0f97 100644 --- a/server/sonar-web/src/main/js/app/types.d.ts +++ b/server/sonar-web/src/main/js/app/types.d.ts @@ -215,7 +215,10 @@ declare namespace T { value: string; } - type CurrentUserSettingNames = 'notifications.optOut' | 'notifications.readDate'; + type CurrentUserSettingNames = + | 'notifications.optOut' + | 'notifications.readDate' + | 'organizations.members.dismissSyncNotif'; export interface CustomMeasure { createdAt?: string; @@ -499,7 +502,7 @@ declare namespace T { export interface Organization extends OrganizationBase { actions?: OrganizationActions; - alm?: { key: string; url: string }; + alm?: OrganizationAlm; adminPages?: Extension[]; canUpdateProjectsVisibilityToPrivate?: boolean; guarded?: boolean; @@ -510,6 +513,12 @@ declare namespace T { subscription?: OrganizationSubscription; } + export interface OrganizationAlm { + key: string; + membersSync: boolean; + url: string; + } + export interface OrganizationBase { avatar?: string; description?: string; diff --git a/server/sonar-web/src/main/js/apps/create/components/CardPlan.tsx b/server/sonar-web/src/main/js/apps/create/components/CardPlan.tsx deleted file mode 100644 index 6ffb21de6d5..00000000000 --- a/server/sonar-web/src/main/js/apps/create/components/CardPlan.tsx +++ /dev/null @@ -1,157 +0,0 @@ -/* - * 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 * as classNames from 'classnames'; -import { FormattedMessage } from 'react-intl'; -import { Link } from 'react-router'; -import UpgradeOrganizationAdvantages from './UpgradeOrganizationAdvantages'; -import RecommendedIcon from '../../../components/icons-components/RecommendedIcon'; -import { Alert } from '../../../components/ui/Alert'; -import { formatPrice } from '../organization/utils'; -import { translate } from '../../../helpers/l10n'; -import './CardPlan.css'; - -interface Props { - className?: string; - disabled?: boolean; - onClick?: () => void; - selected?: boolean; - startingPrice?: number; -} - -interface CardProps extends Props { - children: React.ReactNode; - recommended?: string; - title: string; -} - -export default function CardPlan(props: CardProps) { - const { className, disabled, onClick, recommended, selected, startingPrice } = props; - const isActionable = Boolean(onClick); - return ( - <div - aria-checked={selected} - className={classNames( - 'card-plan', - { 'card-plan-actionable': isActionable, disabled, selected }, - className - )} - onClick={isActionable && !disabled ? onClick : undefined} - role="radio" - tabIndex={0}> - <h2 className="card-plan-header big-spacer-bottom"> - <span className="display-flex-center"> - {isActionable && ( - <i className={classNames('icon-radio', 'spacer-right', { 'is-checked': selected })} /> - )} - {props.title} - </span> - {startingPrice !== undefined && - (startingPrice ? ( - <FormattedMessage - defaultMessage={translate('billing.price_from_x')} - id="billing.price_from_x" - values={{ - price: <span className="card-plan-price">{formatPrice(startingPrice)}</span> - }} - /> - ) : ( - <span className="card-plan-price">{formatPrice(0)}</span> - ))} - </h2> - <div className="card-plan-body">{props.children}</div> - {recommended && ( - <div className="card-plan-recommended"> - <RecommendedIcon className="spacer-right" /> - <FormattedMessage - defaultMessage={recommended} - id={recommended} - values={{ recommended: <strong>{translate('recommended')}</strong> }} - /> - </div> - )} - </div> - ); -} - -interface FreeProps extends Props { - almName?: string; - hasWarning: boolean; -} - -export function FreeCardPlan({ almName, hasWarning, ...props }: FreeProps) { - const showInfo = almName && props.disabled; - const showWarning = almName && hasWarning && !props.disabled; - - return ( - <CardPlan startingPrice={0} title={translate('billing.free_plan.title')} {...props}> - <> - <div className="spacer-left"> - <ul className="big-spacer-left note"> - <li className="little-spacer-bottom"> - {translate('billing.free_plan.all_projects_analyzed_public')} - </li> - <li>{translate('billing.free_plan.anyone_can_browse_source_code')}</li> - </ul> - </div> - {showWarning && ( - <Alert variant="warning"> - <FormattedMessage - defaultMessage={translate('billing.free_plan.private_repo_warning')} - id="billing.free_plan.private_repo_warning" - values={{ alm: almName }} - /> - </Alert> - )} - {showInfo && ( - <Alert variant="info"> - <FormattedMessage - defaultMessage={translate('billing.free_plan.not_available_info')} - id="billing.free_plan.not_available_info" - values={{ alm: almName }} - /> - </Alert> - )} - </> - </CardPlan> - ); -} - -interface PaidProps extends Props { - isRecommended: boolean; -} - -export function PaidCardPlan({ isRecommended, ...props }: PaidProps) { - return ( - <CardPlan - recommended={isRecommended ? translate('billing.paid_plan.recommended') : undefined} - title={translate('billing.paid_plan.title')} - {...props}> - <> - <UpgradeOrganizationAdvantages /> - <div className="big-spacer-left"> - <Link className="spacer-left" target="_blank" to="/about/pricing"> - {translate('billing.pricing.learn_more')} - </Link> - </div> - </> - </CardPlan> - ); -} diff --git a/server/sonar-web/src/main/js/apps/create/components/FreeCardPlan.tsx b/server/sonar-web/src/main/js/apps/create/components/FreeCardPlan.tsx new file mode 100644 index 00000000000..494220fe28b --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/components/FreeCardPlan.tsx @@ -0,0 +1,66 @@ +/* + * 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 { FormattedMessage } from 'react-intl'; +import RadioCard, { RadioCardProps } from '../../../components/controls/RadioCard'; +import { Alert } from '../../../components/ui/Alert'; +import { formatPrice } from '../organization/utils'; +import { translate } from '../../../helpers/l10n'; + +interface Props extends RadioCardProps { + almName?: string; + hasWarning: boolean; +} + +export default function FreeCardPlan({ almName, hasWarning, ...props }: Props) { + const showInfo = almName && props.disabled; + const showWarning = almName && hasWarning && !props.disabled; + + return ( + <RadioCard title={translate('billing.free_plan.title')} titleInfo={formatPrice(0)} {...props}> + <div className="spacer-left"> + <ul className="big-spacer-left note"> + <li className="little-spacer-bottom"> + {translate('billing.free_plan.all_projects_analyzed_public')} + </li> + <li>{translate('billing.free_plan.anyone_can_browse_source_code')}</li> + </ul> + </div> + {showWarning && ( + <Alert variant="warning"> + <FormattedMessage + defaultMessage={translate('billing.free_plan.private_repo_warning')} + id="billing.free_plan.private_repo_warning" + values={{ alm: almName }} + /> + </Alert> + )} + {showInfo && ( + <Alert variant="info"> + <FormattedMessage + defaultMessage={translate('billing.free_plan.not_available_info')} + id="billing.free_plan.not_available_info" + values={{ alm: almName }} + /> + </Alert> + )} + </RadioCard> + ); +} diff --git a/server/sonar-web/src/main/js/apps/create/components/PaidCardPlan.tsx b/server/sonar-web/src/main/js/apps/create/components/PaidCardPlan.tsx new file mode 100644 index 00000000000..2cc3d59e5bd --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/components/PaidCardPlan.tsx @@ -0,0 +1,58 @@ +/* + * 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 { FormattedMessage } from 'react-intl'; +import { Link } from 'react-router'; +import UpgradeOrganizationAdvantages from './UpgradeOrganizationAdvantages'; +import RadioCard, { RadioCardProps } from '../../../components/controls/RadioCard'; +import { formatPrice } from '../organization/utils'; +import { translate } from '../../../helpers/l10n'; + +interface Props extends RadioCardProps { + isRecommended: boolean; + startingPrice?: number; +} + +export default function PaidCardPlan({ isRecommended, startingPrice, ...props }: Props) { + return ( + <RadioCard + recommended={isRecommended ? translate('billing.paid_plan.recommended') : undefined} + title={translate('billing.paid_plan.title')} + titleInfo={ + startingPrice !== undefined && ( + <FormattedMessage + defaultMessage={translate('billing.price_from_x')} + id="billing.price_from_x" + values={{ + price: <span className="big">{formatPrice(startingPrice)}</span> + }} + /> + ) + } + {...props}> + <UpgradeOrganizationAdvantages /> + <div className="big-spacer-left"> + <Link className="spacer-left" target="_blank" to="/about/pricing"> + {translate('billing.pricing.learn_more')} + </Link> + </div> + </RadioCard> + ); +} diff --git a/server/sonar-web/src/main/js/apps/create/components/UpgradeOrganizationBox.tsx b/server/sonar-web/src/main/js/apps/create/components/UpgradeOrganizationBox.tsx index b139c12ee6a..bf54a734288 100644 --- a/server/sonar-web/src/main/js/apps/create/components/UpgradeOrganizationBox.tsx +++ b/server/sonar-web/src/main/js/apps/create/components/UpgradeOrganizationBox.tsx @@ -18,13 +18,15 @@ * 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 UpgradeOrganizationAdvantages from './UpgradeOrganizationAdvantages'; import UpgradeOrganizationModal from './UpgradeOrganizationModal'; -import CardPlan from './CardPlan'; +import RadioCard from '../../../components/controls/RadioCard'; import { Button } from '../../../components/ui/buttons'; -import { translate, hasMessage } from '../../../helpers/l10n'; +import { formatPrice } from '../organization/utils'; import { getSubscriptionPlans } from '../../../api/billing'; +import { translate, hasMessage } from '../../../helpers/l10n'; interface Props { className?: string; @@ -84,10 +86,20 @@ export default class UpgradeOrganizationBox extends React.PureComponent<Props, S return ( <> - <CardPlan + <RadioCard className={this.props.className} - startingPrice={startingPrice} - title={translate('billing.upgrade_box.header')}> + title={translate('billing.upgrade_box.header')} + titleInfo={ + startingPrice !== undefined && ( + <FormattedMessage + defaultMessage={translate('billing.price_from_x')} + id="billing.price_from_x" + values={{ + price: <span className="big">{formatPrice(startingPrice)}</span> + }} + /> + ) + }> <> <UpgradeOrganizationAdvantages /> <div className="big-spacer-left"> @@ -99,7 +111,7 @@ export default class UpgradeOrganizationBox extends React.PureComponent<Props, S </Link> </div> </> - </CardPlan> + </RadioCard> {upgradeOrganizationModal && ( <UpgradeOrganizationModal insideModal={this.props.insideModal} diff --git a/server/sonar-web/src/main/js/apps/create/components/__tests__/FreeCardPlan-test.tsx b/server/sonar-web/src/main/js/apps/create/components/__tests__/FreeCardPlan-test.tsx new file mode 100644 index 00000000000..3c4cfec0d48 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/components/__tests__/FreeCardPlan-test.tsx @@ -0,0 +1,38 @@ +/* + * 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 FreeCardPlan from '../FreeCardPlan'; + +it('should render', () => { + expect(shallow(<FreeCardPlan hasWarning={false} />)).toMatchSnapshot(); +}); + +it('should render with warning', () => { + expect( + shallow(<FreeCardPlan almName="GitHub" hasWarning={true} selected={true} />) + ).toMatchSnapshot(); +}); + +it('should render disabled with info', () => { + expect( + shallow(<FreeCardPlan almName="GitHub" disabled={true} hasWarning={false} />) + ).toMatchSnapshot(); +}); diff --git a/server/sonar-web/src/main/js/apps/create/components/__tests__/OrganizationSelect-test.tsx b/server/sonar-web/src/main/js/apps/create/components/__tests__/OrganizationSelect-test.tsx index fb5d1e46245..6d607b0117d 100644 --- a/server/sonar-web/src/main/js/apps/create/components/__tests__/OrganizationSelect-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/components/__tests__/OrganizationSelect-test.tsx @@ -20,11 +20,9 @@ import * as React from 'react'; import { shallow } from 'enzyme'; import OrganizationSelect, { getOptionRenderer } from '../OrganizationSelect'; +import { mockOrganization, mockOrganizationWithAlm } from '../../../../helpers/testMocks'; -const organizations = [ - { key: 'foo', name: 'Foo' }, - { alm: { key: 'github', url: '' }, key: 'bar', name: 'Bar' } -]; +const organizations = [mockOrganization(), mockOrganizationWithAlm({ key: 'bar', name: 'Bar' })]; it('should render correctly', () => { expect( diff --git a/server/sonar-web/src/main/js/apps/create/components/__tests__/PaidCardPlan-test.tsx b/server/sonar-web/src/main/js/apps/create/components/__tests__/PaidCardPlan-test.tsx new file mode 100644 index 00000000000..b775c18aca5 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/components/__tests__/PaidCardPlan-test.tsx @@ -0,0 +1,26 @@ +/* + * 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 PaidCardPlan from '../PaidCardPlan'; + +it('should render correctly', () => { + expect(shallow(<PaidCardPlan isRecommended={true} startingPrice={10} />)).toMatchSnapshot(); +}); diff --git a/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/CardPlan-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/CardPlan-test.tsx.snap deleted file mode 100644 index f98ce7f4c44..00000000000 --- a/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/CardPlan-test.tsx.snap +++ /dev/null @@ -1,267 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`#FreeCardPlan should render 1`] = ` -<CardPlan - startingPrice={0} - title="billing.free_plan.title" -> - <div - className="spacer-left" - > - <ul - className="big-spacer-left note" - > - <li - className="little-spacer-bottom" - > - billing.free_plan.all_projects_analyzed_public - </li> - <li> - billing.free_plan.anyone_can_browse_source_code - </li> - </ul> - </div> -</CardPlan> -`; - -exports[`#FreeCardPlan should render disabled with info 1`] = ` -<CardPlan - disabled={true} - startingPrice={0} - title="billing.free_plan.title" -> - <div - className="spacer-left" - > - <ul - className="big-spacer-left note" - > - <li - className="little-spacer-bottom" - > - billing.free_plan.all_projects_analyzed_public - </li> - <li> - billing.free_plan.anyone_can_browse_source_code - </li> - </ul> - </div> - <Alert - variant="info" - > - <FormattedMessage - defaultMessage="billing.free_plan.not_available_info" - id="billing.free_plan.not_available_info" - values={ - Object { - "alm": "GitHub", - } - } - /> - </Alert> -</CardPlan> -`; - -exports[`#FreeCardPlan should render with warning 1`] = ` -<CardPlan - selected={true} - startingPrice={0} - title="billing.free_plan.title" -> - <div - className="spacer-left" - > - <ul - className="big-spacer-left note" - > - <li - className="little-spacer-bottom" - > - billing.free_plan.all_projects_analyzed_public - </li> - <li> - billing.free_plan.anyone_can_browse_source_code - </li> - </ul> - </div> - <Alert - variant="warning" - > - <FormattedMessage - defaultMessage="billing.free_plan.private_repo_warning" - id="billing.free_plan.private_repo_warning" - values={ - Object { - "alm": "GitHub", - } - } - /> - </Alert> -</CardPlan> -`; - -exports[`#PaidCardPlan should render 1`] = ` -<CardPlan - recommended="billing.paid_plan.recommended" - startingPrice={10} - title="billing.paid_plan.title" -> - <UpgradeOrganizationAdvantages /> - <div - className="big-spacer-left" - > - <Link - className="spacer-left" - onlyActiveOnIndex={false} - style={Object {}} - target="_blank" - to="/about/pricing" - > - billing.pricing.learn_more - </Link> - </div> -</CardPlan> -`; - -exports[`should be actionable 1`] = ` -<div - className="card-plan card-plan-actionable" - onClick={[MockFunction]} - role="radio" - tabIndex={0} -> - <h2 - className="card-plan-header big-spacer-bottom" - > - <span - className="display-flex-center" - > - <i - className="icon-radio spacer-right" - /> - Free Plan - </span> - </h2> - <div - className="card-plan-body" - > - <div> - content - </div> - </div> -</div> -`; - -exports[`should be actionable 2`] = ` -<div - aria-checked={true} - className="card-plan card-plan-actionable selected" - onClick={ - [MockFunction] { - "calls": Array [ - Array [ - Object { - "currentTarget": Object { - "blur": [Function], - }, - "preventDefault": [Function], - "stopPropagation": [Function], - "target": Object { - "blur": [Function], - }, - }, - ], - ], - "results": Array [ - Object { - "isThrow": false, - "value": undefined, - }, - ], - } - } - role="radio" - tabIndex={0} -> - <h2 - className="card-plan-header big-spacer-bottom" - > - <span - className="display-flex-center" - > - <i - className="icon-radio spacer-right is-checked" - /> - Free Plan - </span> - <span - className="card-plan-price" - > - billing.price_format.0 - </span> - </h2> - <div - className="card-plan-body" - > - <div> - content - </div> - </div> -</div> -`; - -exports[`should render correctly 1`] = ` -<div - className="card-plan" - role="radio" - tabIndex={0} -> - <h2 - className="card-plan-header big-spacer-bottom" - > - <span - className="display-flex-center" - > - Paid Plan - </span> - <FormattedMessage - defaultMessage="billing.price_from_x" - id="billing.price_from_x" - values={ - Object { - "price": <span - className="card-plan-price" - > - billing.price_format.10 - </span>, - } - } - /> - </h2> - <div - className="card-plan-body" - > - <div> - content - </div> - </div> - <div - className="card-plan-recommended" - > - <RecommendedIcon - className="spacer-right" - /> - <FormattedMessage - defaultMessage="Recommended for you" - id="Recommended for you" - values={ - Object { - "recommended": <strong> - recommended - </strong>, - } - } - /> - </div> -</div> -`; diff --git a/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/FreeCardPlan-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/FreeCardPlan-test.tsx.snap new file mode 100644 index 00000000000..096ad74b503 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/FreeCardPlan-test.tsx.snap @@ -0,0 +1,101 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render 1`] = ` +<RadioCard + title="billing.free_plan.title" + titleInfo="billing.price_format.0" +> + <div + className="spacer-left" + > + <ul + className="big-spacer-left note" + > + <li + className="little-spacer-bottom" + > + billing.free_plan.all_projects_analyzed_public + </li> + <li> + billing.free_plan.anyone_can_browse_source_code + </li> + </ul> + </div> +</RadioCard> +`; + +exports[`should render disabled with info 1`] = ` +<RadioCard + disabled={true} + title="billing.free_plan.title" + titleInfo="billing.price_format.0" +> + <div + className="spacer-left" + > + <ul + className="big-spacer-left note" + > + <li + className="little-spacer-bottom" + > + billing.free_plan.all_projects_analyzed_public + </li> + <li> + billing.free_plan.anyone_can_browse_source_code + </li> + </ul> + </div> + <Alert + variant="info" + > + <FormattedMessage + defaultMessage="billing.free_plan.not_available_info" + id="billing.free_plan.not_available_info" + values={ + Object { + "alm": "GitHub", + } + } + /> + </Alert> +</RadioCard> +`; + +exports[`should render with warning 1`] = ` +<RadioCard + selected={true} + title="billing.free_plan.title" + titleInfo="billing.price_format.0" +> + <div + className="spacer-left" + > + <ul + className="big-spacer-left note" + > + <li + className="little-spacer-bottom" + > + billing.free_plan.all_projects_analyzed_public + </li> + <li> + billing.free_plan.anyone_can_browse_source_code + </li> + </ul> + </div> + <Alert + variant="warning" + > + <FormattedMessage + defaultMessage="billing.free_plan.private_repo_warning" + id="billing.free_plan.private_repo_warning" + values={ + Object { + "alm": "GitHub", + } + } + /> + </Alert> +</RadioCard> +`; diff --git a/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/OrganizationSelect-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/OrganizationSelect-test.tsx.snap index 1003db90587..8e373bfac39 100644 --- a/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/OrganizationSelect-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/OrganizationSelect-test.tsx.snap @@ -14,7 +14,8 @@ exports[`should render correctly 1`] = ` Object { "alm": Object { "key": "github", - "url": "", + "membersSync": false, + "url": "https://github.com/foo", }, "key": "bar", "name": "Bar", diff --git a/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/PaidCardPlan-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/PaidCardPlan-test.tsx.snap new file mode 100644 index 00000000000..94ac5a89244 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/PaidCardPlan-test.tsx.snap @@ -0,0 +1,38 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` +<RadioCard + recommended="billing.paid_plan.recommended" + title="billing.paid_plan.title" + titleInfo={ + <FormattedMessage + defaultMessage="billing.price_from_x" + id="billing.price_from_x" + values={ + Object { + "price": <span + className="big" + > + billing.price_format.10 + </span>, + } + } + /> + } +> + <UpgradeOrganizationAdvantages /> + <div + className="big-spacer-left" + > + <Link + className="spacer-left" + onlyActiveOnIndex={false} + style={Object {}} + target="_blank" + to="/about/pricing" + > + billing.pricing.learn_more + </Link> + </div> +</RadioCard> +`; diff --git a/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/UpgradeOrganizationBox-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/UpgradeOrganizationBox-test.tsx.snap index 249f04ebdce..e389049612a 100644 --- a/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/UpgradeOrganizationBox-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/UpgradeOrganizationBox-test.tsx.snap @@ -2,9 +2,23 @@ exports[`should render correctly 1`] = ` <Fragment> - <CardPlan - startingPrice={10} + <RadioCard title="billing.upgrade_box.header" + titleInfo={ + <FormattedMessage + defaultMessage="billing.price_from_x" + id="billing.price_from_x" + values={ + Object { + "price": <span + className="big" + > + billing.price_format.10 + </span>, + } + } + /> + } > <UpgradeOrganizationAdvantages /> <div @@ -26,6 +40,6 @@ exports[`should render correctly 1`] = ` billing.pricing.learn_more </Link> </div> - </CardPlan> + </RadioCard> </Fragment> `; diff --git a/server/sonar-web/src/main/js/apps/create/organization/AlmApplicationInstalling.tsx b/server/sonar-web/src/main/js/apps/create/organization/AlmApplicationInstalling.tsx index 29c1a79e333..007b7b23d2b 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/AlmApplicationInstalling.tsx +++ b/server/sonar-web/src/main/js/apps/create/organization/AlmApplicationInstalling.tsx @@ -20,6 +20,7 @@ import * as React from 'react'; import { translate } from '../../../helpers/l10n'; import DeferredSpinner from '../../../components/common/DeferredSpinner'; +import { sanitizeAlmId } from '../../../helpers/almIntegrations'; export default function AlmApplicationInstalling({ almKey }: { almKey?: string }) { return ( @@ -29,9 +30,10 @@ export default function AlmApplicationInstalling({ almKey }: { almKey?: string } <div className="huge-spacer-top text-center"> <i className="spinner" /> <p className="big-spacer-top"> - {almKey - ? translate('onboarding.import_organization.installing', almKey) - : translate('onboarding.import_organization.installing')} + {translate( + 'onboarding.import_organization.installing', + sanitizeAlmId(almKey) || 'ALM' + )} </p> </div> </div> diff --git a/server/sonar-web/src/main/js/apps/create/organization/PlanSelect.tsx b/server/sonar-web/src/main/js/apps/create/organization/PlanSelect.tsx index a14261cacde..da1dc977e4f 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/PlanSelect.tsx +++ b/server/sonar-web/src/main/js/apps/create/organization/PlanSelect.tsx @@ -18,7 +18,8 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import { FreeCardPlan, PaidCardPlan } from '../components/CardPlan'; +import FreeCardPlan from '../components/FreeCardPlan'; +import PaidCardPlan from '../components/PaidCardPlan'; import { translate } from '../../../helpers/l10n'; export enum Plan { diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/AlmApplicationInstalling-test.tsx b/server/sonar-web/src/main/js/apps/create/organization/__tests__/AlmApplicationInstalling-test.tsx new file mode 100644 index 00000000000..60673608d36 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/AlmApplicationInstalling-test.tsx @@ -0,0 +1,27 @@ +/* + * 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 AlmApplicationInstalling from '../AlmApplicationInstalling'; + +it('should render correctly', () => { + expect(shallow(<AlmApplicationInstalling />)).toMatchSnapshot(); + expect(shallow(<AlmApplicationInstalling almKey="github" />)).toMatchSnapshot(); +}); diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/CreateOrganization-test.tsx b/server/sonar-web/src/main/js/apps/create/organization/__tests__/CreateOrganization-test.tsx index 1537668a8fb..9330ea3039c 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/__tests__/CreateOrganization-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/CreateOrganization-test.tsx @@ -30,7 +30,11 @@ import { import { getSubscriptionPlans } from '../../../../api/billing'; import { getOrganizations } from '../../../../api/organizations'; import { get, remove } from '../../../../helpers/storage'; -import { mockRouter } from '../../../../helpers/testMocks'; +import { + mockRouter, + mockOrganizationWithAdminActions, + mockOrganizationWithAlm +} from '../../../../helpers/testMocks'; import { waitAndUpdate } from '../../../../helpers/testUtils'; jest.mock('../../../../api/billing', () => ({ @@ -320,9 +324,9 @@ function shallowRender(props: Partial<CreateOrganization['props']> = {}) { skipOnboarding={jest.fn()} updateOrganization={jest.fn()} userOrganizations={[ - { actions: { admin: true }, key: 'foo', name: 'Foo' }, - { actions: { admin: true }, alm: { key: 'github', url: '' }, key: 'bar', name: 'Bar' }, - { actions: { admin: false }, key: 'baz', name: 'Baz' } + mockOrganizationWithAdminActions(), + mockOrganizationWithAdminActions(mockOrganizationWithAlm({ key: 'bar', name: 'Bar' })), + mockOrganizationWithAdminActions({ key: 'baz', name: 'Baz' }, { admin: false }) ]} {...props} /> diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/AlmApplicationInstalling-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/AlmApplicationInstalling-test.tsx.snap new file mode 100644 index 00000000000..a66e47ed6c5 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/AlmApplicationInstalling-test.tsx.snap @@ -0,0 +1,49 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` +<DeferredSpinner + customSpinner={ + <div + className="sonarcloud page page-limited" + > + <div + className="huge-spacer-top text-center" + > + <i + className="spinner" + /> + <p + className="big-spacer-top" + > + onboarding.import_organization.installing.ALM + </p> + </div> + </div> + } + timeout={100} +/> +`; + +exports[`should render correctly 2`] = ` +<DeferredSpinner + customSpinner={ + <div + className="sonarcloud page page-limited" + > + <div + className="huge-spacer-top text-center" + > + <i + className="spinner" + /> + <p + className="big-spacer-top" + > + onboarding.import_organization.installing.github + </p> + </div> + </div> + } + timeout={100} +/> +`; diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/AutoProjectCreate-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/AutoProjectCreate-test.tsx index 37b3a453118..ca1c9b9201c 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/AutoProjectCreate-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/AutoProjectCreate-test.tsx @@ -20,6 +20,10 @@ import * as React from 'react'; import { shallow } from 'enzyme'; import AutoProjectCreate from '../AutoProjectCreate'; +import { + mockOrganizationWithAdminActions, + mockOrganizationWithAlm +} from '../../../../helpers/testMocks'; const almApplication = { backgroundColor: 'blue', @@ -30,19 +34,8 @@ const almApplication = { }; const boundOrganizations: T.Organization[] = [ - { - actions: { admin: true }, - alm: { key: 'github', url: '' }, - key: 'foo', - name: 'Foo', - subscription: 'FREE' - }, - { - alm: { key: 'github', url: '' }, - key: 'bar', - name: 'Bar', - subscription: 'FREE' - } + mockOrganizationWithAdminActions(mockOrganizationWithAlm({ subscription: 'FREE' })), + mockOrganizationWithAlm({ key: 'bar', name: 'Bar', subscription: 'FREE' }) ]; it('should display the provider app install button', () => { diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/CreateProjectPageSonarCloud-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/CreateProjectPageSonarCloud-test.tsx index 9e1a6e66803..5d3f92ab7d8 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/CreateProjectPageSonarCloud-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/CreateProjectPageSonarCloud-test.tsx @@ -21,7 +21,11 @@ import * as React from 'react'; import { shallow } from 'enzyme'; import { CreateProjectPageSonarCloud } from '../CreateProjectPageSonarCloud'; import { getAlmAppInfo } from '../../../../api/alm-integration'; -import { mockRouter } from '../../../../helpers/testMocks'; +import { + mockRouter, + mockOrganizationWithAdminActions, + mockOrganizationWithAlm +} from '../../../../helpers/testMocks'; import { waitAndUpdate } from '../../../../helpers/testUtils'; jest.mock('../../../../api/alm-integration', () => ({ @@ -86,9 +90,15 @@ function getWrapper(props = {}) { router={mockRouter()} skipOnboarding={jest.fn()} userOrganizations={[ - { actions: { provision: true }, key: 'foo', name: 'Foo' }, - { actions: { provision: true }, alm: { key: 'github', url: '' }, key: 'bar', name: 'Bar' }, - { actions: { provision: false }, key: 'baz', name: 'Baz' } + mockOrganizationWithAdminActions({}, { admin: false, provision: true }), + mockOrganizationWithAdminActions(mockOrganizationWithAlm({ key: 'bar', name: 'Bar' }), { + admin: false, + provision: true + }), + mockOrganizationWithAdminActions( + { key: 'baz', name: 'Baz' }, + { admin: false, provision: false } + ) ]} {...props} /> diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/OrganizationInput-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/OrganizationInput-test.tsx index 44a1183baed..35ff3d1878e 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/OrganizationInput-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/OrganizationInput-test.tsx @@ -20,12 +20,13 @@ import * as React from 'react'; import { shallow } from 'enzyme'; import { OrganizationInput } from '../OrganizationInput'; -import { mockRouter } from '../../../../helpers/testMocks'; +import { + mockRouter, + mockOrganization, + mockOrganizationWithAlm +} from '../../../../helpers/testMocks'; -const organizations = [ - { key: 'foo', name: 'Foo' }, - { alm: { key: 'github', url: '' }, key: 'bar', name: 'Bar' } -]; +const organizations = [mockOrganization(), mockOrganizationWithAlm({ key: 'bar', name: 'Bar' })]; it('should render correctly', () => { expect(shallowRender()).toMatchSnapshot(); diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/RemoteRepositories-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/RemoteRepositories-test.tsx index 23a3c2cf5c6..025209632ce 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/RemoteRepositories-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/RemoteRepositories-test.tsx @@ -23,6 +23,10 @@ import { shallow } from 'enzyme'; import RemoteRepositories from '../RemoteRepositories'; import { getRepositories } from '../../../../api/alm-integration'; import { waitAndUpdate } from '../../../../helpers/testUtils'; +import { + mockOrganizationWithAlm, + mockOrganizationWithAdminActions +} from '../../../../helpers/testMocks'; jest.mock('../../../../api/alm-integration', () => ({ getRepositories: jest.fn().mockResolvedValue({ @@ -46,12 +50,7 @@ const almApplication = { name: 'GitHub' }; -const organization: T.Organization = { - alm: { key: 'github', url: '' }, - key: 'sonarsource', - name: 'SonarSource', - subscription: 'FREE' -}; +const organization: T.Organization = mockOrganizationWithAlm({ subscription: 'FREE' }); beforeEach(() => { (getRepositories as jest.Mock<any>).mockClear(); @@ -62,7 +61,7 @@ it('should display the list of repositories', async () => { expect(wrapper).toMatchSnapshot(); await waitAndUpdate(wrapper); expect(wrapper).toMatchSnapshot(); - expect(getRepositories).toHaveBeenCalledWith({ organization: 'sonarsource' }); + expect(getRepositories).toHaveBeenCalledWith({ organization: 'foo' }); }); it('should display the organization upgrade box', async () => { @@ -81,13 +80,9 @@ it('should not display the organization upgrade box', () => { repositories: [{ label: 'Bar Project', installationKey: 'github/bar', private: true }] }); const wrapper = shallowRender({ - organization: { - actions: { admin: true }, - alm: { key: 'github', url: '' }, - key: 'foobar', - name: 'FooBar', - subscription: 'PAID' - } + organization: mockOrganizationWithAdminActions( + mockOrganizationWithAlm({ subscription: 'PAID' }) + ) }); expect(wrapper.find('UpgradeOrganizationBox').exists()).toBe(false); diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/SetupProjectBox-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/SetupProjectBox-test.tsx index 86339a2d849..a7c3844ded7 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/SetupProjectBox-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/SetupProjectBox-test.tsx @@ -22,6 +22,7 @@ import { shallow } from 'enzyme'; import SetupProjectBox from '../SetupProjectBox'; import { waitAndUpdate, submit } from '../../../../helpers/testUtils'; import { provisionProject } from '../../../../api/alm-integration'; +import { mockOrganizationWithAlm } from '../../../../helpers/testMocks'; jest.mock('../../../../api/alm-integration', () => ({ provisionProject: jest @@ -29,18 +30,6 @@ jest.mock('../../../../api/alm-integration', () => ({ .mockResolvedValue({ projects: [{ projectKey: 'awesome' }, { projectKey: 'foo' }] }) })); -const organization: T.Organization = { - alm: { key: 'github', url: '' }, - key: 'sonarsource', - name: 'SonarSource', - subscription: 'FREE' -}; - -const selectedRepositories = [ - { label: 'Awesome Project', installationKey: 'github/awesome' }, - { label: 'Foo', installationKey: 'github/foo', private: true } -]; - it('should correctly create projects', async () => { const onProjectCreate = jest.fn(); const wrapper = shallowRender({ onProjectCreate }); @@ -49,11 +38,11 @@ it('should correctly create projects', async () => { submit(wrapper.find('form')); expect(provisionProject).toBeCalledWith({ installationKeys: ['github/awesome', 'github/foo'], - organization: 'sonarsource' + organization: 'foo' }); await waitAndUpdate(wrapper); - expect(onProjectCreate).toBeCalledWith(['awesome', 'foo'], 'sonarsource'); + expect(onProjectCreate).toBeCalledWith(['awesome', 'foo'], 'foo'); }); function shallowRender(props: Partial<SetupProjectBox['props']> = {}) { @@ -61,8 +50,11 @@ function shallowRender(props: Partial<SetupProjectBox['props']> = {}) { <SetupProjectBox onProjectCreate={jest.fn()} onProvisionFail={jest.fn()} - organization={organization} - selectedRepositories={selectedRepositories} + organization={mockOrganizationWithAlm({ subscription: 'FREE' })} + selectedRepositories={[ + { label: 'Awesome Project', installationKey: 'github/awesome' }, + { label: 'Foo', installationKey: 'github/foo', private: true } + ]} {...props} /> ); diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AutoProjectCreate-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AutoProjectCreate-test.tsx.snap index 0f3247f2994..61c5ce222bb 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AutoProjectCreate-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AutoProjectCreate-test.tsx.snap @@ -14,7 +14,8 @@ exports[`should display the bound organizations dropdown with the remote reposit }, "alm": Object { "key": "github", - "url": "", + "membersSync": false, + "url": "https://github.com/foo", }, "key": "foo", "name": "Foo", @@ -23,7 +24,8 @@ exports[`should display the bound organizations dropdown with the remote reposit Object { "alm": Object { "key": "github", - "url": "", + "membersSync": false, + "url": "https://github.com/foo", }, "key": "bar", "name": "Bar", @@ -51,7 +53,8 @@ exports[`should display the bound organizations dropdown with the remote reposit }, "alm": Object { "key": "github", - "url": "", + "membersSync": false, + "url": "https://github.com/foo", }, "key": "foo", "name": "Foo", diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/CreateProjectPageSonarCloud-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/CreateProjectPageSonarCloud-test.tsx.snap index cbec17a9d1c..c0407c7e630 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/CreateProjectPageSonarCloud-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/CreateProjectPageSonarCloud-test.tsx.snap @@ -81,11 +81,13 @@ exports[`should render correctly 2`] = ` Array [ Object { "actions": Object { + "admin": false, "provision": true, }, "alm": Object { "key": "github", - "url": "", + "membersSync": false, + "url": "https://github.com/foo", }, "key": "bar", "name": "Bar", @@ -136,6 +138,7 @@ exports[`should render with Manual creation only 1`] = ` Array [ Object { "actions": Object { + "admin": false, "provision": true, }, "key": "foo", @@ -143,11 +146,13 @@ exports[`should render with Manual creation only 1`] = ` }, Object { "actions": Object { + "admin": false, "provision": true, }, "alm": Object { "key": "github", - "url": "", + "membersSync": false, + "url": "https://github.com/foo", }, "key": "bar", "name": "Bar", @@ -211,11 +216,13 @@ exports[`should switch tabs 1`] = ` Array [ Object { "actions": Object { + "admin": false, "provision": true, }, "alm": Object { "key": "github", - "url": "", + "membersSync": false, + "url": "https://github.com/foo", }, "key": "bar", "name": "Bar", diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/OrganizationInput-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/OrganizationInput-test.tsx.snap index ba9fc2ff450..a655ac75dbe 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/OrganizationInput-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/OrganizationInput-test.tsx.snap @@ -33,7 +33,8 @@ exports[`should render correctly 1`] = ` Object { "alm": Object { "key": "github", - "url": "", + "membersSync": false, + "url": "https://github.com/foo", }, "key": "bar", "name": "Bar", diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/RemoteRepositories-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/RemoteRepositories-test.tsx.snap index 60e50364c99..ed810b7ac31 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/RemoteRepositories-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/RemoteRepositories-test.tsx.snap @@ -33,10 +33,11 @@ exports[`should display the list of repositories 1`] = ` Object { "alm": Object { "key": "github", - "url": "", + "membersSync": false, + "url": "https://github.com/foo", }, - "key": "sonarsource", - "name": "SonarSource", + "key": "foo", + "name": "Foo", "subscription": "FREE", } } @@ -121,10 +122,11 @@ exports[`should display the list of repositories 2`] = ` Object { "alm": Object { "key": "github", - "url": "", + "membersSync": false, + "url": "https://github.com/foo", }, - "key": "sonarsource", - "name": "SonarSource", + "key": "foo", + "name": "Foo", "subscription": "FREE", } } @@ -146,10 +148,11 @@ exports[`should display the organization upgrade box 1`] = ` }, "alm": Object { "key": "github", - "url": "", + "membersSync": false, + "url": "https://github.com/foo", }, - "key": "sonarsource", - "name": "SonarSource", + "key": "foo", + "name": "Foo", "subscription": "FREE", } } diff --git a/server/sonar-web/src/main/js/apps/organizationMembers/MembersListHeader.tsx b/server/sonar-web/src/main/js/apps/organizationMembers/MembersListHeader.tsx index 938e5a47d05..ba7cabf81a0 100644 --- a/server/sonar-web/src/main/js/apps/organizationMembers/MembersListHeader.tsx +++ b/server/sonar-web/src/main/js/apps/organizationMembers/MembersListHeader.tsx @@ -18,16 +18,25 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; +import HelpTooltip from '../../components/controls/HelpTooltip'; import SearchBox from '../../components/controls/SearchBox'; +import { getAlmMembersUrl, sanitizeAlmId } from '../../helpers/almIntegrations'; +import { translate, translateWithParameters } from '../../helpers/l10n'; import { formatMeasure } from '../../helpers/measures'; -import { translate } from '../../helpers/l10n'; -interface Props { +export interface Props { + currentUser: T.LoggedInUser; handleSearch: (query?: string) => void; + organization: T.Organization; total?: number; } -export default function MembersListHeader({ handleSearch, total }: Props) { +export default function MembersListHeader({ + currentUser, + handleSearch, + organization, + total +}: Props) { return ( <div className="panel panel-vertical bordered-bottom spacer-bottom"> <SearchBox @@ -38,6 +47,38 @@ export default function MembersListHeader({ handleSearch, total }: Props) { {total !== undefined && ( <span className="pull-right little-spacer-top"> <strong>{formatMeasure(total, 'INT')}</strong> {translate('organization.members.members')} + {organization.alm && + organization.alm.membersSync && ( + <HelpTooltip + className="spacer-left" + overlay={ + <div className="abs-width-300 markdown cut-margins"> + <p> + {translate( + 'organization.members.auto_sync_total_help', + sanitizeAlmId(organization.alm.key) || '' + )} + </p> + {currentUser.personalOrganization !== organization.key && ( + <> + <hr /> + <p> + <a + href={getAlmMembersUrl(organization.alm)} + rel="noopener noreferrer" + target="_blank"> + {translateWithParameters( + 'organization.members.see_all_members_on_x', + translate(sanitizeAlmId(organization.alm.key) || '') + )} + </a> + </p> + </> + )} + </div> + } + /> + )} </span> )} </div> diff --git a/server/sonar-web/src/main/js/apps/organizationMembers/MembersPageHeader.tsx b/server/sonar-web/src/main/js/apps/organizationMembers/MembersPageHeader.tsx index ec540fed313..82c68eba3d6 100644 --- a/server/sonar-web/src/main/js/apps/organizationMembers/MembersPageHeader.tsx +++ b/server/sonar-web/src/main/js/apps/organizationMembers/MembersPageHeader.tsx @@ -18,35 +18,110 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; +import { connect } from 'react-redux'; import { FormattedMessage } from 'react-intl'; import { Link } from 'react-router'; -import { translate } from '../../helpers/l10n'; +import AddMemberForm from './AddMemberForm'; +import SyncMemberForm from './SyncMemberForm'; import DeferredSpinner from '../../components/common/DeferredSpinner'; +import DocTooltip from '../../components/docs/DocTooltip'; +import NewInfoBox from '../../components/ui/NewInfoBox'; +import { sanitizeAlmId } from '../../helpers/almIntegrations'; +import { translate, translateWithParameters } from '../../helpers/l10n'; +import { getCurrentUserSetting, Store } from '../../store/rootReducer'; +import { setCurrentUserSetting } from '../../store/users'; interface Props { - children?: React.ReactNode; + dismissSyncNotifOrg: string[]; + handleAddMember: (member: T.OrganizationMember) => void; loading: boolean; + members?: T.OrganizationMember[]; + organization: T.Organization; + setCurrentUserSetting: (setting: T.CurrentUserSetting) => void; } -export default function MembersPageHeader(props: Props) { - return ( - <header className="page-header"> - <h1 className="page-title">{translate('organization.members.page')}</h1> - <DeferredSpinner loading={props.loading} /> - {props.children} - <p className="page-description"> - <FormattedMessage - defaultMessage={translate('organization.members.page.description')} - id="organization.members.page.description" - values={{ - link: ( - <Link to="/documentation/organizations/manage-team/"> - {translate('organization.members.manage_a_team')} - </Link> - ) - }} - /> - </p> - </header> - ); +export class MembersPageHeader extends React.PureComponent<Props> { + handleDismissSyncNotif = () => { + const { dismissSyncNotifOrg, organization } = this.props; + this.props.setCurrentUserSetting({ + key: 'organizations.members.dismissSyncNotif', + value: [...dismissSyncNotifOrg, organization.key].join(',') + }); + }; + + render() { + const { dismissSyncNotifOrg, members, organization } = 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); + const hasMemberSync = organization.alm && organization.alm.membersSync; + const showSyncNotif = + isAdmin && + organization.alm && + !hasMemberSync && + !dismissSyncNotifOrg.some(orgKey => orgKey === organization.key); + + return ( + <header className="page-header"> + <h1 className="page-title">{translate('organization.members.page')}</h1> + <DeferredSpinner loading={this.props.loading} /> + {isAdmin && ( + <div className="page-actions text-right"> + {almKey && !showSyncNotif && <SyncMemberForm organization={organization} />} + {!hasMemberSync && ( + <div className="display-inline-block spacer-left spacer-bottom"> + <AddMemberForm + addMember={this.props.handleAddMember} + memberLogins={memberLogins} + organization={organization} + /> + <DocTooltip + className="spacer-left" + doc={import(/* webpackMode: "eager" */ 'Docs/tooltips/organizations/add-organization-member.md')} + /> + </div> + )} + {almKey && + showSyncNotif && ( + <NewInfoBox + description={translate('organization.members.auto_sync_members_from_org', almKey)} + onClose={this.handleDismissSyncNotif} + title={translateWithParameters( + 'organization.members.auto_sync_with_x', + translate(almKey) + )}> + <SyncMemberForm organization={organization} /> + </NewInfoBox> + )} + </div> + )} + <div className="page-description"> + <FormattedMessage + defaultMessage={translate('organization.members.page.description')} + id="organization.members.page.description" + values={{ + link: ( + <Link to="/documentation/organizations/manage-team/"> + {translate('organization.members.manage_a_team')} + </Link> + ) + }} + /> + </div> + </header> + ); + } } + +const mapStateToProps = (state: Store) => ({ + dismissSyncNotifOrg: ( + getCurrentUserSetting(state, 'organizations.members.dismissSyncNotif') || '' + ).split(',') +}); + +const mapDispatchToProps = { setCurrentUserSetting }; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(MembersPageHeader); diff --git a/server/sonar-web/src/main/js/apps/organizationMembers/OrganizationMembers.tsx b/server/sonar-web/src/main/js/apps/organizationMembers/OrganizationMembers.tsx index 215415f6216..46e1162372b 100644 --- a/server/sonar-web/src/main/js/apps/organizationMembers/OrganizationMembers.tsx +++ b/server/sonar-web/src/main/js/apps/organizationMembers/OrganizationMembers.tsx @@ -22,15 +22,14 @@ import Helmet from 'react-helmet'; import MembersPageHeader from './MembersPageHeader'; import MembersListHeader from './MembersListHeader'; import MembersList from './MembersList'; -import AddMemberForm from './AddMemberForm'; import Suggestions from '../../app/components/embed-docs-modal/Suggestions'; import ListFooter from '../../components/controls/ListFooter'; -import DocTooltip from '../../components/docs/DocTooltip'; import { translate } from '../../helpers/l10n'; import { searchMembers, addMember, removeMember } from '../../api/organizations'; import { searchUsersGroups, addUserToGroup, removeUserFromGroup } from '../../api/user_groups'; interface Props { + currentUser: T.LoggedInUser; organization: T.Organization; } @@ -187,31 +186,25 @@ export default class OrganizationMembers extends React.PureComponent<Props, Stat render() { const { organization } = this.props; const { groups, loading, members, paging } = this.state; - const memberLogins = members ? members.map(member => member.login) : []; return ( <div className="page page-limited"> <Helmet title={translate('organization.members.page')} /> <Suggestions suggestions="organization_members" /> - <MembersPageHeader loading={loading}> - {organization.actions && - organization.actions.admin && ( - <div className="page-actions"> - <AddMemberForm - addMember={this.handleAddMember} - memberLogins={memberLogins} - organization={organization} - /> - <DocTooltip - className="spacer-left" - doc={import(/* webpackMode: "eager" */ 'Docs/tooltips/organizations/add-organization-member.md')} - /> - </div> - )} - </MembersPageHeader> + <MembersPageHeader + handleAddMember={this.handleAddMember} + loading={loading} + members={members} + organization={organization} + /> {members !== undefined && paging !== undefined && ( <> - <MembersListHeader handleSearch={this.handleSearchMembers} total={paging.total} /> + <MembersListHeader + currentUser={this.props.currentUser} + handleSearch={this.handleSearchMembers} + organization={organization} + total={paging.total} + /> <MembersList members={members} organization={organization} diff --git a/server/sonar-web/src/main/js/apps/organizationMembers/OrganizationMembersContainer.tsx b/server/sonar-web/src/main/js/apps/organizationMembers/OrganizationMembersContainer.tsx index 0390d301ea3..05c709f3920 100644 --- a/server/sonar-web/src/main/js/apps/organizationMembers/OrganizationMembersContainer.tsx +++ b/server/sonar-web/src/main/js/apps/organizationMembers/OrganizationMembersContainer.tsx @@ -20,6 +20,7 @@ import { connect } from 'react-redux'; import OrganizationMembers from './OrganizationMembers'; import { getOrganizationByKey, Store } from '../../store/rootReducer'; +import { withCurrentUser } from '../../components/hoc/withCurrentUser'; interface OwnProps { params: { organizationKey: string }; @@ -33,4 +34,4 @@ const mapStateToProps = (state: Store, ownProps: OwnProps): StateProps => { return { organization: getOrganizationByKey(state, ownProps.params.organizationKey) }; }; -export default connect(mapStateToProps)(OrganizationMembers); +export default withCurrentUser(connect(mapStateToProps)(OrganizationMembers)); diff --git a/server/sonar-web/src/main/js/apps/organizationMembers/SyncMemberForm.tsx b/server/sonar-web/src/main/js/apps/organizationMembers/SyncMemberForm.tsx new file mode 100644 index 00000000000..b04c679563f --- /dev/null +++ b/server/sonar-web/src/main/js/apps/organizationMembers/SyncMemberForm.tsx @@ -0,0 +1,169 @@ +/* + * 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 { connect } from 'react-redux'; +import { Link } from 'react-router'; +import ConfirmButton from '../../components/controls/ConfirmButton'; +import RadioCard from '../../components/controls/RadioCard'; +import { Alert } from '../../components/ui/Alert'; +import { Button } from '../../components/ui/buttons'; +import { setOrganizationMemberSync, syncMembers } from '../../api/organizations'; +import { sanitizeAlmId, isGithub } from '../../helpers/almIntegrations'; +import { translate, translateWithParameters } from '../../helpers/l10n'; +import { fetchOrganization } from '../../store/rootActions'; + +interface Props { + fetchOrganization: (key: string) => void; + organization: T.Organization; +} + +interface State { + membersSync: boolean; +} + +export class SyncMemberForm extends React.PureComponent<Props, State> { + constructor(props: Props) { + super(props); + this.state = { + membersSync: Boolean(props.organization.alm && props.organization.alm.membersSync) + }; + } + + handleConfirm = () => { + const { organization } = this.props; + const { membersSync } = this.state; + 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 Promise.resolve(); + }); + }; + + handleManualClick = () => { + this.setState({ membersSync: false }); + }; + + handleAutoClick = () => { + this.setState({ membersSync: true }); + }; + + renderModalBody = () => { + const { membersSync } = this.state; + const { organization } = this.props; + const almKey = organization.alm && sanitizeAlmId(organization.alm.key); + return ( + <> + {translate('organization.members.management.description')} + <Link + className="spacer-left" + target="_blank" + to={{ pathname: '/documentation/organizations/manage-team/' }}> + {translate('learn_more')} + </Link> + <div className="display-flex-stretch big-spacer-top"> + <RadioCard + onClick={this.handleManualClick} + selected={!membersSync} + title={translate('organization.members.management.manual')}> + <div className="spacer-left"> + <ul className="big-spacer-left note"> + <li className="spacer-bottom"> + {translate('organization.members.management.manual.add_members_manually')} + </li> + <li> + {translate('organization.members.management.manual.choose_members_permissions')} + </li> + </ul> + </div> + </RadioCard> + <RadioCard + onClick={this.handleAutoClick} + selected={membersSync} + title={translateWithParameters( + 'organization.members.management.automatic', + translate(almKey || '') + )}> + <div className="spacer-left"> + <ul className="big-spacer-left note"> + {almKey && ( + <> + <li className="spacer-bottom"> + {translate( + 'organization.members.management.automatic.synchronized_from', + almKey + )} + </li> + <li className="spacer-bottom"> + {translate( + 'organization.members.management.automatic.members_changes_reflected', + almKey + )} + </li> + </> + )} + <li> + {translate( + 'organization.members.management.automatic.still_choose_members_permissions' + )} + </li> + </ul> + </div> + {(!organization.alm || !organization.alm.membersSync) && ( + <Alert className="big-spacer-top" variant="warning"> + {translate('organization.members.management.automatic.warning')} + </Alert> + )} + </RadioCard> + </div> + </> + ); + }; + + render() { + const { organization } = this.props; + const orgMemberSync = Boolean(organization.alm && organization.alm.membersSync); + return ( + <ConfirmButton + cancelButtonText={translate('close')} + confirmButtonText={translate('save')} + confirmDisable={this.state.membersSync === orgMemberSync} + medium={true} + modalBody={this.renderModalBody()} + modalHeader={translate('organization.members.management.title')} + onConfirm={this.handleConfirm}> + {({ onClick }) => ( + <Button onClick={onClick}>{translate('organization.members.config_synchro')}</Button> + )} + </ConfirmButton> + ); + } +} + +const mapDispatchToProps = { fetchOrganization }; + +export default connect( + null, + mapDispatchToProps +)(SyncMemberForm); diff --git a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/MembersListHeader-test.tsx b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/MembersListHeader-test.tsx index 3105927acfa..7c97806d07e 100644 --- a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/MembersListHeader-test.tsx +++ b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/MembersListHeader-test.tsx @@ -19,14 +19,54 @@ */ import * as React from 'react'; import { shallow } from 'enzyme'; -import MembersListHeader from '../MembersListHeader'; +import MembersListHeader, { Props } from '../MembersListHeader'; +import { + mockOrganization, + mockCurrentUser, + mockOrganizationWithAlm +} from '../../../helpers/testMocks'; it('should render without the total', () => { - const wrapper = shallow(<MembersListHeader handleSearch={jest.fn()} />); - expect(wrapper).toMatchSnapshot(); + expect(shallowRender({ total: undefined })).toMatchSnapshot(); }); it('should render with the total', () => { - const wrapper = shallow(<MembersListHeader handleSearch={jest.fn()} total={8} />); - expect(wrapper).toMatchSnapshot(); + expect(shallowRender()).toMatchSnapshot(); }); + +it('should render a help tooltip', () => { + expect( + shallowRender({ organization: mockOrganizationWithAlm({}, { membersSync: true }) }).find( + 'HelpTooltip' + ) + ).toMatchSnapshot(); + expect( + shallowRender({ + organization: mockOrganizationWithAlm( + {}, + { key: 'bitbucket', membersSync: true, url: 'https://bitbucket.com/foo' } + ) + }).find('HelpTooltip') + ).toMatchSnapshot(); +}); + +it('should not render link in help tooltip', () => { + expect( + shallowRender({ + currentUser: mockCurrentUser({ personalOrganization: 'foo' }), + organization: mockOrganizationWithAlm({}, { membersSync: true }) + }).find('HelpTooltip') + ).toMatchSnapshot(); +}); + +function shallowRender(props: Partial<Props> = {}) { + return shallow( + <MembersListHeader + currentUser={mockCurrentUser()} + handleSearch={jest.fn()} + organization={mockOrganization()} + total={8} + {...props} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/MembersPageHeader-test.tsx b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/MembersPageHeader-test.tsx index 538d093be88..7f74a8eff3c 100644 --- a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/MembersPageHeader-test.tsx +++ b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/MembersPageHeader-test.tsx @@ -19,13 +19,52 @@ */ import * as React from 'react'; import { shallow } from 'enzyme'; -import MembersPageHeader from '../MembersPageHeader'; +import { MembersPageHeader } from '../MembersPageHeader'; +import { + mockOrganization, + mockOrganizationWithAlm, + mockOrganizationWithAdminActions +} from '../../../helpers/testMocks'; -it('should render', () => { - const wrapper = shallow( - <MembersPageHeader loading={true}> - <span>children test</span> - </MembersPageHeader> - ); - expect(wrapper).toMatchSnapshot(); +it('should render correctly', () => { + expect(shallowRender({ loading: true })).toMatchSnapshot(); +}); + +it('should render for admin', () => { + expect( + shallowRender({ organization: mockOrganization({ actions: { admin: true } }) }) + ).toMatchSnapshot(); +}); + +it('should render for bound organization without sync', () => { + const organization = mockOrganizationWithAlm(mockOrganizationWithAdminActions()); + expect(shallowRender({ organization })).toMatchSnapshot(); + + const wrapper = shallowRender({ organization, dismissSyncNotifOrg: [organization.key] }); + expect(wrapper.find('Connect(SyncMemberForm)').exists()).toBe(true); + expect(wrapper.find('NewInfoBox').exists()).toBe(false); +}); + +it('should render for bound organization with sync', () => { + const organization = mockOrganizationWithAlm(mockOrganizationWithAdminActions(), { + membersSync: true + }); + const wrapper = shallowRender({ organization }); + expect(wrapper.find('Connect(SyncMemberForm)').exists()).toBe(true); + expect(wrapper.find('AddMemberForm').exists()).toBe(false); + expect(wrapper.find('NewInfoBox').exists()).toBe(false); }); + +function shallowRender(props: Partial<MembersPageHeader['props']> = {}) { + return shallow( + <MembersPageHeader + dismissSyncNotifOrg={[]} + handleAddMember={jest.fn()} + loading={false} + members={[]} + organization={mockOrganization()} + setCurrentUserSetting={jest.fn()} + {...props} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/OrganizationMembers-test.tsx b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/OrganizationMembers-test.tsx index 43c07605766..7861581aa2e 100644 --- a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/OrganizationMembers-test.tsx +++ b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/OrganizationMembers-test.tsx @@ -20,9 +20,14 @@ import * as React from 'react'; import { shallow } from 'enzyme'; import OrganizationMembers from '../OrganizationMembers'; -import { waitAndUpdate } from '../../../helpers/testUtils'; import { searchMembers, addMember, removeMember } from '../../../api/organizations'; import { searchUsersGroups, addUserToGroup, removeUserFromGroup } from '../../../api/user_groups'; +import { + mockOrganization, + mockCurrentUser, + mockOrganizationWithAdminActions +} from '../../../helpers/testMocks'; +import { waitAndUpdate } from '../../../helpers/testUtils'; jest.mock('../../../api/organizations', () => ({ addMember: jest.fn().mockResolvedValue({ login: 'bar', name: 'Bar', groupCount: 1 }), @@ -48,15 +53,13 @@ jest.mock('../../../api/user_groups', () => ({ }) })); -const organization = { key: 'foo', name: 'Foo' }; - beforeEach(() => { (searchMembers as jest.Mock).mockClear(); (searchUsersGroups as jest.Mock).mockClear(); }); it('should fetch members and render for non-admin', async () => { - const wrapper = shallow(<OrganizationMembers organization={organization} />); + const wrapper = shallowRender({ organization: mockOrganization() }); expect(wrapper).toMatchSnapshot(); await waitAndUpdate(wrapper); @@ -64,40 +67,31 @@ it('should fetch members and render for non-admin', async () => { expect(searchMembers).toBeCalledWith({ organization: 'foo', ps: 50, q: undefined }); }); -it('should fetch members and groups and render for admin', async () => { - const wrapper = shallow( - <OrganizationMembers organization={{ ...organization, actions: { admin: true } }} /> - ); +it('should fetch members and groups for admin', async () => { + const wrapper = shallowRender(); await waitAndUpdate(wrapper); - expect(wrapper).toMatchSnapshot(); expect(searchMembers).toBeCalledWith({ organization: 'foo', ps: 50, q: undefined }); expect(searchUsersGroups).toBeCalledWith({ organization: 'foo' }); }); it('should search users', async () => { - const wrapper = shallow( - <OrganizationMembers organization={{ ...organization, actions: { admin: true } }} /> - ); + const wrapper = shallowRender(); await waitAndUpdate(wrapper); wrapper.find('MembersListHeader').prop<Function>('handleSearch')('user'); expect(searchMembers).lastCalledWith({ organization: 'foo', ps: 50, q: 'user' }); }); it('should load more members', async () => { - const wrapper = shallow( - <OrganizationMembers organization={{ ...organization, actions: { admin: true } }} /> - ); + const wrapper = shallowRender(); await waitAndUpdate(wrapper); wrapper.find('ListFooter').prop<Function>('loadMore')(); expect(searchMembers).lastCalledWith({ organization: 'foo', p: 2, ps: 50, q: undefined }); }); it('should add new member', async () => { - const wrapper = shallow( - <OrganizationMembers organization={{ ...organization, actions: { admin: true } }} /> - ); + const wrapper = shallowRender(); await waitAndUpdate(wrapper); - wrapper.find('AddMemberForm').prop<Function>('addMember')({ login: 'bar' }); + wrapper.find('Connect(MembersPageHeader)').prop<Function>('handleAddMember')({ login: 'bar' }); await waitAndUpdate(wrapper); expect( wrapper @@ -110,9 +104,7 @@ it('should add new member', async () => { }); it('should remove member', async () => { - const wrapper = shallow( - <OrganizationMembers organization={{ ...organization, actions: { admin: true } }} /> - ); + const wrapper = shallowRender(); await waitAndUpdate(wrapper); wrapper.find('MembersList').prop<Function>('removeMember')({ login: 'john' }); await waitAndUpdate(wrapper); @@ -127,9 +119,7 @@ it('should remove member', async () => { }); it('should update groups', async () => { - const wrapper = shallow( - <OrganizationMembers organization={{ ...organization, actions: { admin: true } }} /> - ); + const wrapper = shallowRender(); await waitAndUpdate(wrapper); wrapper.find('MembersList').prop<Function>('updateMemberGroups')( { login: 'john' }, @@ -148,3 +138,13 @@ it('should update groups', async () => { expect(removeUserFromGroup).toHaveBeenCalledTimes(1); expect(removeUserFromGroup).toBeCalledWith({ login: 'john', name: 'birds', organization: 'foo' }); }); + +function shallowRender(props: Partial<OrganizationMembers['props']> = {}) { + return shallow( + <OrganizationMembers + currentUser={mockCurrentUser()} + organization={mockOrganizationWithAdminActions()} + {...props} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/SyncMemberForm-test.tsx b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/SyncMemberForm-test.tsx new file mode 100644 index 00000000000..4410d45c301 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/SyncMemberForm-test.tsx @@ -0,0 +1,93 @@ +/* + * 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 { SyncMemberForm } from '../SyncMemberForm'; +import { setOrganizationMemberSync, syncMembers } from '../../../api/organizations'; +import { mockOrganizationWithAlm } from '../../../helpers/testMocks'; +import { waitAndUpdate } from '../../../helpers/testUtils'; + +jest.mock('../../../api/organizations', () => ({ + setOrganizationMemberSync: jest.fn().mockResolvedValue(undefined), + syncMembers: jest.fn().mockResolvedValue(undefined) +})); + +beforeEach(() => { + (setOrganizationMemberSync as jest.Mock).mockClear(); + (syncMembers as jest.Mock).mockClear(); +}); + +it('should allow to switch to automatic mode with github', async () => { + const fetchOrganization = jest.fn(); + const wrapper = shallowRender({ fetchOrganization }); + expect(wrapper).toMatchSnapshot(); + + wrapper.setState({ membersSync: true }); + wrapper.find('ConfirmButton').prop<Function>('onConfirm')(); + expect(setOrganizationMemberSync).toHaveBeenCalledWith({ organization: 'foo', enabled: true }); + + await waitAndUpdate(wrapper); + expect(fetchOrganization).toHaveBeenCalledWith('foo'); + expect(syncMembers).toHaveBeenCalledWith('foo'); +}); + +it('should allow to switch to automatic mode with bitbucket', async () => { + const fetchOrganization = jest.fn(); + const wrapper = shallowRender({ + fetchOrganization, + organization: mockOrganizationWithAlm({}, { key: 'bitbucket' }) + }); + expect(wrapper).toMatchSnapshot(); + + wrapper.setState({ membersSync: true }); + wrapper.find('ConfirmButton').prop<Function>('onConfirm')(); + expect(setOrganizationMemberSync).toHaveBeenCalledWith({ organization: 'foo', enabled: true }); + + await waitAndUpdate(wrapper); + expect(fetchOrganization).toHaveBeenCalledWith('foo'); + expect(syncMembers).not.toHaveBeenCalled(); +}); + +it('should allow to switch to manual mode', async () => { + const fetchOrganization = jest.fn(); + const wrapper = shallowRender({ + fetchOrganization, + organization: mockOrganizationWithAlm({}, { membersSync: true }) + }); + expect(wrapper).toMatchSnapshot(); + + wrapper.setState({ membersSync: false }); + wrapper.find('ConfirmButton').prop<Function>('onConfirm')(); + expect(setOrganizationMemberSync).toHaveBeenCalledWith({ organization: 'foo', enabled: false }); + + await waitAndUpdate(wrapper); + expect(fetchOrganization).toHaveBeenCalledWith('foo'); + expect(syncMembers).not.toHaveBeenCalled(); +}); + +function shallowRender(props: Partial<SyncMemberForm['props']> = {}) { + return shallow<SyncMemberForm>( + <SyncMemberForm + fetchOrganization={jest.fn()} + organization={mockOrganizationWithAlm()} + {...props} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/MembersListHeader-test.tsx.snap b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/MembersListHeader-test.tsx.snap index 7bfe2725e60..1d8ecfb92df 100644 --- a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/MembersListHeader-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/MembersListHeader-test.tsx.snap @@ -1,5 +1,74 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`should not render link in help tooltip 1`] = ` +<HelpTooltip + className="spacer-left" + overlay={ + <div + className="abs-width-300 markdown cut-margins" + > + <p> + organization.members.auto_sync_total_help.github + </p> + </div> + } +/> +`; + +exports[`should render a help tooltip 1`] = ` +<HelpTooltip + className="spacer-left" + overlay={ + <div + className="abs-width-300 markdown cut-margins" + > + <p> + organization.members.auto_sync_total_help.github + </p> + <React.Fragment> + <hr /> + <p> + <a + href="https://github.com/orgs/foo/people" + rel="noopener noreferrer" + target="_blank" + > + organization.members.see_all_members_on_x.github + </a> + </p> + </React.Fragment> + </div> + } +/> +`; + +exports[`should render a help tooltip 2`] = ` +<HelpTooltip + className="spacer-left" + overlay={ + <div + className="abs-width-300 markdown cut-margins" + > + <p> + organization.members.auto_sync_total_help.bitbucket + </p> + <React.Fragment> + <hr /> + <p> + <a + href="https://bitbucket.com/foo/profile/members" + rel="noopener noreferrer" + target="_blank" + > + organization.members.see_all_members_on_x.bitbucket + </a> + </p> + </React.Fragment> + </div> + } +/> +`; + exports[`should render with the total 1`] = ` <div className="panel panel-vertical bordered-bottom spacer-bottom" diff --git a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/MembersPageHeader-test.tsx.snap b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/MembersPageHeader-test.tsx.snap index 95179079487..b0bc7e4a009 100644 --- a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/MembersPageHeader-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/MembersPageHeader-test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`should render 1`] = ` +exports[`should render correctly 1`] = ` <header className="page-header" > @@ -13,10 +13,7 @@ exports[`should render 1`] = ` loading={true} timeout={100} /> - <span> - children test - </span> - <p + <div className="page-description" > <FormattedMessage @@ -34,6 +31,153 @@ exports[`should render 1`] = ` } } /> - </p> + </div> +</header> +`; + +exports[`should render for admin 1`] = ` +<header + className="page-header" +> + <h1 + className="page-title" + > + organization.members.page + </h1> + <DeferredSpinner + loading={false} + timeout={100} + /> + <div + className="page-actions text-right" + > + <div + className="display-inline-block spacer-left spacer-bottom" + > + <AddMemberForm + addMember={[MockFunction]} + memberLogins={Array []} + organization={ + Object { + "actions": Object { + "admin": true, + }, + "key": "foo", + "name": "Foo", + } + } + /> + <DocTooltip + className="spacer-left" + doc={Promise {}} + /> + </div> + </div> + <div + className="page-description" + > + <FormattedMessage + defaultMessage="organization.members.page.description" + id="organization.members.page.description" + values={ + Object { + "link": <Link + onlyActiveOnIndex={false} + style={Object {}} + to="/documentation/organizations/manage-team/" + > + organization.members.manage_a_team + </Link>, + } + } + /> + </div> +</header> +`; + +exports[`should render for bound organization without sync 1`] = ` +<header + className="page-header" +> + <h1 + className="page-title" + > + organization.members.page + </h1> + <DeferredSpinner + loading={false} + timeout={100} + /> + <div + className="page-actions text-right" + > + <div + className="display-inline-block spacer-left spacer-bottom" + > + <AddMemberForm + addMember={[MockFunction]} + memberLogins={Array []} + organization={ + Object { + "actions": Object { + "admin": true, + }, + "alm": Object { + "key": "github", + "membersSync": false, + "url": "https://github.com/foo", + }, + "key": "foo", + "name": "Foo", + } + } + /> + <DocTooltip + className="spacer-left" + doc={Promise {}} + /> + </div> + <NewInfoBox + description="organization.members.auto_sync_members_from_org.github" + onClose={[Function]} + title="organization.members.auto_sync_with_x.github" + > + <Connect(SyncMemberForm) + organization={ + Object { + "actions": Object { + "admin": true, + }, + "alm": Object { + "key": "github", + "membersSync": false, + "url": "https://github.com/foo", + }, + "key": "foo", + "name": "Foo", + } + } + /> + </NewInfoBox> + </div> + <div + className="page-description" + > + <FormattedMessage + defaultMessage="organization.members.page.description" + id="organization.members.page.description" + values={ + Object { + "link": <Link + onlyActiveOnIndex={false} + style={Object {}} + to="/documentation/organizations/manage-team/" + > + organization.members.manage_a_team + </Link>, + } + } + /> + </div> </header> `; diff --git a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/OrganizationMembers-test.tsx.snap b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/OrganizationMembers-test.tsx.snap index 90b44168efc..dd26a5928f4 100644 --- a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/OrganizationMembers-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/OrganizationMembers-test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`should fetch members and groups and render for admin 1`] = ` +exports[`should fetch members and render for non-admin 1`] = ` <div className="page page-limited" > @@ -12,110 +12,15 @@ exports[`should fetch members and groups and render for admin 1`] = ` <Suggestions suggestions="organization_members" /> - <MembersPageHeader - loading={false} - > - <div - className="page-actions" - > - <AddMemberForm - addMember={[Function]} - memberLogins={ - Array [ - "admin", - "john", - ] - } - organization={ - Object { - "actions": Object { - "admin": true, - }, - "key": "foo", - "name": "Foo", - } - } - /> - <DocTooltip - className="spacer-left" - doc={Promise {}} - /> - </div> - </MembersPageHeader> - <MembersListHeader - handleSearch={[Function]} - total={3} - /> - <MembersList - members={ - Array [ - Object { - "avatar": "", - "groupCount": 3, - "login": "admin", - "name": "Admin Istrator", - }, - Object { - "avatar": "7daf6c79d4802916d83f6266e24850af", - "groupCount": 1, - "login": "john", - "name": "John Doe", - }, - ] - } + <Connect(MembersPageHeader) + handleAddMember={[Function]} + loading={true} organization={ Object { - "actions": Object { - "admin": true, - }, "key": "foo", "name": "Foo", } } - organizationGroups={ - Array [ - Object { - "default": true, - "description": "", - "id": 1, - "membersCount": 2, - "name": "Members", - }, - Object { - "default": false, - "description": "", - "id": 2, - "membersCount": 0, - "name": "Watchers", - }, - ] - } - removeMember={[Function]} - updateMemberGroups={[Function]} - /> - <ListFooter - count={2} - loadMore={[Function]} - ready={true} - total={3} - /> -</div> -`; - -exports[`should fetch members and render for non-admin 1`] = ` -<div - className="page page-limited" -> - <HelmetWrapper - defer={true} - encodeSpecialCharacters={true} - title="organization.members.page" - /> - <Suggestions - suggestions="organization_members" - /> - <MembersPageHeader - loading={true} /> </div> `; @@ -132,11 +37,49 @@ exports[`should fetch members and render for non-admin 2`] = ` <Suggestions suggestions="organization_members" /> - <MembersPageHeader + <Connect(MembersPageHeader) + handleAddMember={[Function]} loading={false} + members={ + Array [ + Object { + "avatar": "", + "groupCount": 3, + "login": "admin", + "name": "Admin Istrator", + }, + Object { + "avatar": "7daf6c79d4802916d83f6266e24850af", + "groupCount": 1, + "login": "john", + "name": "John Doe", + }, + ] + } + organization={ + Object { + "key": "foo", + "name": "Foo", + } + } /> <MembersListHeader + currentUser={ + Object { + "groups": Array [], + "isLoggedIn": true, + "login": "luke", + "name": "Skywalker", + "scmAccounts": Array [], + } + } handleSearch={[Function]} + organization={ + Object { + "key": "foo", + "name": "Foo", + } + } total={3} /> <MembersList diff --git a/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/SyncMemberForm-test.tsx.snap b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/SyncMemberForm-test.tsx.snap new file mode 100644 index 00000000000..b8744db2ef6 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/organizationMembers/__tests__/__snapshots__/SyncMemberForm-test.tsx.snap @@ -0,0 +1,271 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should allow to switch to automatic mode with bitbucket 1`] = ` +<ConfirmButton + cancelButtonText="close" + confirmButtonText="save" + confirmDisable={true} + medium={true} + modalBody={ + <React.Fragment> + organization.members.management.description + <Link + className="spacer-left" + onlyActiveOnIndex={false} + style={Object {}} + target="_blank" + to={ + Object { + "pathname": "/documentation/organizations/manage-team/", + } + } + > + learn_more + </Link> + <div + className="display-flex-stretch big-spacer-top" + > + <RadioCard + onClick={[Function]} + selected={true} + title="organization.members.management.manual" + > + <div + className="spacer-left" + > + <ul + className="big-spacer-left note" + > + <li + className="spacer-bottom" + > + organization.members.management.manual.add_members_manually + </li> + <li> + organization.members.management.manual.choose_members_permissions + </li> + </ul> + </div> + </RadioCard> + <RadioCard + onClick={[Function]} + selected={false} + title="organization.members.management.automatic.bitbucket" + > + <div + className="spacer-left" + > + <ul + className="big-spacer-left note" + > + <React.Fragment> + <li + className="spacer-bottom" + > + organization.members.management.automatic.synchronized_from.bitbucket + </li> + <li + className="spacer-bottom" + > + organization.members.management.automatic.members_changes_reflected.bitbucket + </li> + </React.Fragment> + <li> + organization.members.management.automatic.still_choose_members_permissions + </li> + </ul> + </div> + <Alert + className="big-spacer-top" + variant="warning" + > + organization.members.management.automatic.warning + </Alert> + </RadioCard> + </div> + </React.Fragment> + } + modalHeader="organization.members.management.title" + onConfirm={[Function]} +> + <Component /> +</ConfirmButton> +`; + +exports[`should allow to switch to automatic mode with github 1`] = ` +<ConfirmButton + cancelButtonText="close" + confirmButtonText="save" + confirmDisable={true} + medium={true} + modalBody={ + <React.Fragment> + organization.members.management.description + <Link + className="spacer-left" + onlyActiveOnIndex={false} + style={Object {}} + target="_blank" + to={ + Object { + "pathname": "/documentation/organizations/manage-team/", + } + } + > + learn_more + </Link> + <div + className="display-flex-stretch big-spacer-top" + > + <RadioCard + onClick={[Function]} + selected={true} + title="organization.members.management.manual" + > + <div + className="spacer-left" + > + <ul + className="big-spacer-left note" + > + <li + className="spacer-bottom" + > + organization.members.management.manual.add_members_manually + </li> + <li> + organization.members.management.manual.choose_members_permissions + </li> + </ul> + </div> + </RadioCard> + <RadioCard + onClick={[Function]} + selected={false} + title="organization.members.management.automatic.github" + > + <div + className="spacer-left" + > + <ul + className="big-spacer-left note" + > + <React.Fragment> + <li + className="spacer-bottom" + > + organization.members.management.automatic.synchronized_from.github + </li> + <li + className="spacer-bottom" + > + organization.members.management.automatic.members_changes_reflected.github + </li> + </React.Fragment> + <li> + organization.members.management.automatic.still_choose_members_permissions + </li> + </ul> + </div> + <Alert + className="big-spacer-top" + variant="warning" + > + organization.members.management.automatic.warning + </Alert> + </RadioCard> + </div> + </React.Fragment> + } + modalHeader="organization.members.management.title" + onConfirm={[Function]} +> + <Component /> +</ConfirmButton> +`; + +exports[`should allow to switch to manual mode 1`] = ` +<ConfirmButton + cancelButtonText="close" + confirmButtonText="save" + confirmDisable={true} + medium={true} + modalBody={ + <React.Fragment> + organization.members.management.description + <Link + className="spacer-left" + onlyActiveOnIndex={false} + style={Object {}} + target="_blank" + to={ + Object { + "pathname": "/documentation/organizations/manage-team/", + } + } + > + learn_more + </Link> + <div + className="display-flex-stretch big-spacer-top" + > + <RadioCard + onClick={[Function]} + selected={false} + title="organization.members.management.manual" + > + <div + className="spacer-left" + > + <ul + className="big-spacer-left note" + > + <li + className="spacer-bottom" + > + organization.members.management.manual.add_members_manually + </li> + <li> + organization.members.management.manual.choose_members_permissions + </li> + </ul> + </div> + </RadioCard> + <RadioCard + onClick={[Function]} + selected={true} + title="organization.members.management.automatic.github" + > + <div + className="spacer-left" + > + <ul + className="big-spacer-left note" + > + <React.Fragment> + <li + className="spacer-bottom" + > + organization.members.management.automatic.synchronized_from.github + </li> + <li + className="spacer-bottom" + > + organization.members.management.automatic.members_changes_reflected.github + </li> + </React.Fragment> + <li> + organization.members.management.automatic.still_choose_members_permissions + </li> + </ul> + </div> + </RadioCard> + </div> + </React.Fragment> + } + modalHeader="organization.members.management.title" + onConfirm={[Function]} +> + <Component /> +</ConfirmButton> +`; diff --git a/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/OrganizationNavigationHeader-test.tsx b/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/OrganizationNavigationHeader-test.tsx index 7df40517995..39519ffd939 100644 --- a/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/OrganizationNavigationHeader-test.tsx +++ b/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/OrganizationNavigationHeader-test.tsx @@ -20,6 +20,7 @@ import * as React from 'react'; import { shallow } from 'enzyme'; import OrganizationNavigationHeader from '../OrganizationNavigationHeader'; +import { mockOrganizationWithAlm } from '../../../../helpers/testMocks'; it('renders', () => { expect( @@ -36,12 +37,7 @@ it('renders with alm integration', () => { expect( shallow( <OrganizationNavigationHeader - organization={{ - alm: { key: 'github', url: 'https://github.com/foo' }, - key: 'foo', - name: 'Foo', - projectVisibility: 'public' - }} + organization={mockOrganizationWithAlm({ projectVisibility: 'public' })} organizations={[]} /> ) diff --git a/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/__snapshots__/OrganizationNavigationHeader-test.tsx.snap b/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/__snapshots__/OrganizationNavigationHeader-test.tsx.snap index 5ef064c39fc..c02c0831a7f 100644 --- a/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/__snapshots__/OrganizationNavigationHeader-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/__snapshots__/OrganizationNavigationHeader-test.tsx.snap @@ -76,6 +76,7 @@ exports[`renders with alm integration 1`] = ` Object { "alm": Object { "key": "github", + "membersSync": false, "url": "https://github.com/foo", }, "key": "foo", diff --git a/server/sonar-web/src/main/js/components/controls/ConfirmButton.tsx b/server/sonar-web/src/main/js/components/controls/ConfirmButton.tsx index c3be977b72d..0718757ff1a 100644 --- a/server/sonar-web/src/main/js/components/controls/ConfirmButton.tsx +++ b/server/sonar-web/src/main/js/components/controls/ConfirmButton.tsx @@ -18,40 +18,25 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; +import ConfirmModal, { ConfirmModalProps } from './ConfirmModal'; import ModalButton, { ChildrenProps, ModalProps } from './ModalButton'; -import ConfirmModal from './ConfirmModal'; -export { ChildrenProps } from './ModalButton'; - -interface Props { +interface Props<T> extends ConfirmModalProps<T> { children: (props: ChildrenProps) => React.ReactNode; - cancelButtonText?: string; - confirmButtonText: string; - confirmData?: string; - confirmDisable?: boolean; - isDestructive?: boolean; modalBody: React.ReactNode; modalHeader: string; - onConfirm: (data?: string) => void | Promise<void>; } interface State { modal: boolean; } -export default class ConfirmButton extends React.PureComponent<Props, State> { +export default class ConfirmButton<T> extends React.PureComponent<Props<T>, State> { renderConfirmModal = ({ onClose }: ModalProps) => { + const { children, modalBody, modalHeader, ...confirmModalProps } = this.props; return ( - <ConfirmModal - cancelButtonText={this.props.cancelButtonText} - confirmButtonText={this.props.confirmButtonText} - confirmData={this.props.confirmData} - confirmDisable={this.props.confirmDisable} - header={this.props.modalHeader} - isDestructive={this.props.isDestructive} - onClose={onClose} - onConfirm={this.props.onConfirm}> - {this.props.modalBody} + <ConfirmModal header={modalHeader} onClose={onClose} {...confirmModalProps}> + {modalBody} </ConfirmModal> ); }; diff --git a/server/sonar-web/src/main/js/components/controls/ConfirmModal.tsx b/server/sonar-web/src/main/js/components/controls/ConfirmModal.tsx index 8ac88d222ac..f51f9704aec 100644 --- a/server/sonar-web/src/main/js/components/controls/ConfirmModal.tsx +++ b/server/sonar-web/src/main/js/components/controls/ConfirmModal.tsx @@ -18,21 +18,24 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; +import { ModalProps } from './Modal'; import SimpleModal, { ChildrenProps } from './SimpleModal'; import DeferredSpinner from '../common/DeferredSpinner'; -import { translate } from '../../helpers/l10n'; import { SubmitButton, ResetButtonLink } from '../ui/buttons'; +import { translate } from '../../helpers/l10n'; -interface Props<T> { - children: React.ReactNode; +export interface ConfirmModalProps<T> extends ModalProps { cancelButtonText?: string; confirmButtonText: string; confirmData?: T; confirmDisable?: boolean; - header: string; isDestructive?: boolean; + onConfirm: (data?: T) => void | Promise<void | Response>; +} + +interface Props<T> extends ConfirmModalProps<T> { + header: string; onClose: () => void; - onConfirm: (data?: T) => void | Promise<void>; } export default class ConfirmModal<T = string> extends React.PureComponent<Props<T>> { @@ -82,9 +85,10 @@ export default class ConfirmModal<T = string> extends React.PureComponent<Props< }; render() { - const { header } = this.props; + const { header, onClose, medium, noBackdrop, large, simple } = this.props; + const modalProps = { header, onClose, medium, noBackdrop, large, simple }; return ( - <SimpleModal header={header} onClose={this.props.onClose} onSubmit={this.handleSubmit}> + <SimpleModal onSubmit={this.handleSubmit} {...modalProps}> {this.renderModalContent} </SimpleModal> ); diff --git a/server/sonar-web/src/main/js/components/controls/Modal.tsx b/server/sonar-web/src/main/js/components/controls/Modal.tsx index 7f91bb4e082..e7b14c3a1df 100644 --- a/server/sonar-web/src/main/js/components/controls/Modal.tsx +++ b/server/sonar-web/src/main/js/components/controls/Modal.tsx @@ -23,7 +23,8 @@ import * as classNames from 'classnames'; ReactModal.setAppElement('#content'); -interface OwnProps { +export interface ModalProps { + children: React.ReactNode; medium?: boolean; noBackdrop?: boolean; large?: boolean; @@ -32,7 +33,7 @@ interface OwnProps { type MandatoryProps = Pick<ReactModal.Props, 'contentLabel'>; -type Props = Partial<ReactModal.Props> & MandatoryProps & OwnProps; +type Props = Partial<ReactModal.Props> & MandatoryProps & ModalProps; export default function Modal(props: Props) { return ( diff --git a/server/sonar-web/src/main/js/apps/create/components/CardPlan.css b/server/sonar-web/src/main/js/components/controls/RadioCard.css index 174e3c33d00..a5099a9a239 100644 --- a/server/sonar-web/src/main/js/apps/create/components/CardPlan.css +++ b/server/sonar-web/src/main/js/components/controls/RadioCard.css @@ -17,11 +17,11 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -.card-plan { +.radio-card { display: flex; flex-direction: column; width: 450px; - height: 210px; + min-height: 210px; background-color: #fff; border: solid 1px var(--barBorderColor); border-radius: 3px; @@ -30,70 +30,66 @@ transition: all 0.2s ease; } -.card-plan.animated { +.radio-card.animated { height: 0; border-width: 0; overflow: hidden; } -.card-plan.animated.open { +.radio-card.animated.open { height: 210px; border-width: 1px; } -.card-plan.highlight { +.radio-card.highlight { box-shadow: var(--defaultShadow); } -.card-plan:last-child { +.radio-card:last-child { margin-right: 0; } -.card-plan:focus { +.radio-card:focus { outline: none; } -.card-plan-actionable { +.radio-card-actionable { cursor: pointer; } -.card-plan-actionable:not(.disabled):hover { +.radio-card-actionable:not(.disabled):hover { box-shadow: var(--defaultShadow); transform: translateY(-2px); } -.card-plan-actionable.selected { +.radio-card-actionable.selected { border-color: var(--darkBlue); } -.card-plan-actionable.selected .card-plan-recommended { +.radio-card-actionable.selected .radio-card-recommended { border: solid 1px var(--darkBlue); border-top: none; } -.card-plan-actionable.disabled { +.radio-card-actionable.disabled { cursor: not-allowed; background-color: var(--disableGrayBg); border-color: var(--disableGrayBorder); } -.card-plan-actionable.disabled h2, -.card-plan-actionable.disabled ul { +.radio-card-actionable.disabled h2, +.radio-card-actionable.disabled ul { color: var(--disableGrayText); } -.card-plan-header { +.radio-card-header { display: flex; justify-content: space-between; align-items: center; padding: calc(2 * var(--gridSize)) calc(2 * var(--gridSize)) 0; } -.card-plan-price { - font-size: var(--bigFontSize); -} - -.card-plan-body { +.radio-card-body { flex-grow: 1; display: flex; flex-direction: column; @@ -101,11 +97,11 @@ padding: 0 calc(2 * var(--gridSize)) calc(2 * var(--gridSize)); } -.card-plan-body .alert { +.radio-card-body .alert { margin-bottom: 0; } -.card-plan-recommended { +.radio-card-recommended { position: relative; padding: 6px calc(var(--gridSize) * 2); left: -1px; diff --git a/server/sonar-web/src/main/js/components/controls/RadioCard.tsx b/server/sonar-web/src/main/js/components/controls/RadioCard.tsx new file mode 100644 index 00000000000..96726369abf --- /dev/null +++ b/server/sonar-web/src/main/js/components/controls/RadioCard.tsx @@ -0,0 +1,77 @@ +/* + * 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 * as classNames from 'classnames'; +import { FormattedMessage } from 'react-intl'; +import RecommendedIcon from '../icons-components/RecommendedIcon'; +import { translate } from '../../helpers/l10n'; +import './RadioCard.css'; + +export interface RadioCardProps { + className?: string; + disabled?: boolean; + onClick?: () => void; + selected?: boolean; +} + +interface Props extends RadioCardProps { + children: React.ReactNode; + recommended?: string; + title: React.ReactNode; + titleInfo?: React.ReactNode; +} + +export default function RadioCard(props: Props) { + const { className, disabled, onClick, recommended, selected, titleInfo } = props; + const isActionable = Boolean(onClick); + return ( + <div + aria-checked={selected} + className={classNames( + 'radio-card', + { 'radio-card-actionable': isActionable, disabled, selected }, + className + )} + onClick={isActionable && !disabled ? onClick : undefined} + role="radio" + tabIndex={0}> + <h2 className="radio-card-header big-spacer-bottom"> + <span className="display-flex-center"> + {isActionable && ( + <i className={classNames('icon-radio', 'spacer-right', { 'is-checked': selected })} /> + )} + {props.title} + </span> + {titleInfo} + </h2> + <div className="radio-card-body">{props.children}</div> + {recommended && ( + <div className="radio-card-recommended"> + <RecommendedIcon className="spacer-right" /> + <FormattedMessage + defaultMessage={recommended} + id={recommended} + values={{ recommended: <strong>{translate('recommended')}</strong> }} + /> + </div> + )} + </div> + ); +} diff --git a/server/sonar-web/src/main/js/components/controls/SimpleModal.tsx b/server/sonar-web/src/main/js/components/controls/SimpleModal.tsx index a8a1ba1fe2d..9870f0e6ec7 100644 --- a/server/sonar-web/src/main/js/components/controls/SimpleModal.tsx +++ b/server/sonar-web/src/main/js/components/controls/SimpleModal.tsx @@ -18,7 +18,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import Modal from './Modal'; +import Modal, { ModalProps } from './Modal'; export interface ChildrenProps { onCloseClick: (event?: React.SyntheticEvent<HTMLElement>) => void; @@ -27,7 +27,7 @@ export interface ChildrenProps { submitting: boolean; } -interface Props { +interface Props extends ModalProps { children: (props: ChildrenProps) => React.ReactNode; header: string; onClose: () => void; @@ -86,9 +86,10 @@ export default class SimpleModal extends React.Component<Props, State> { }; render() { + const { children, header, onClose, onSubmit, ...modalProps } = this.props; return ( - <Modal contentLabel={this.props.header} onRequestClose={this.props.onClose}> - {this.props.children({ + <Modal contentLabel={header} onRequestClose={onClose} {...modalProps}> + {children({ onCloseClick: this.handleCloseClick, onFormSubmit: this.handleFormSubmit, onSubmitClick: this.handleSubmitClick, diff --git a/server/sonar-web/src/main/js/apps/create/components/__tests__/CardPlan-test.tsx b/server/sonar-web/src/main/js/components/controls/__tests__/RadioCard-test.tsx index f44e5f5e3f6..da11ea3458c 100644 --- a/server/sonar-web/src/main/js/apps/create/components/__tests__/CardPlan-test.tsx +++ b/server/sonar-web/src/main/js/components/controls/__tests__/RadioCard-test.tsx @@ -19,15 +19,15 @@ */ import * as React from 'react'; import { shallow } from 'enzyme'; -import CardPlan, { FreeCardPlan, PaidCardPlan } from '../CardPlan'; -import { click } from '../../../../helpers/testUtils'; +import RadioCard from '../RadioCard'; +import { click } from '../../../helpers/testUtils'; it('should render correctly', () => { expect( shallow( - <CardPlan recommended="Recommended for you" startingPrice={10} title="Paid Plan"> + <RadioCard recommended="Recommended for you" title="Radio Card" titleInfo="info"> <div>content</div> - </CardPlan> + </RadioCard> ) ).toMatchSnapshot(); }); @@ -35,37 +35,13 @@ it('should render correctly', () => { it('should be actionable', () => { const onClick = jest.fn(); const wrapper = shallow( - <CardPlan onClick={onClick} title="Free Plan"> + <RadioCard onClick={onClick} title="Radio Card"> <div>content</div> - </CardPlan> + </RadioCard> ); expect(wrapper).toMatchSnapshot(); click(wrapper); - wrapper.setProps({ selected: true, startingPrice: 0 }); + wrapper.setProps({ selected: true, titleInfo: 'info' }); expect(wrapper).toMatchSnapshot(); }); - -describe('#FreeCardPlan', () => { - it('should render', () => { - expect(shallow(<FreeCardPlan hasWarning={false} />)).toMatchSnapshot(); - }); - - it('should render with warning', () => { - expect( - shallow(<FreeCardPlan almName="GitHub" hasWarning={true} selected={true} />) - ).toMatchSnapshot(); - }); - - it('should render disabled with info', () => { - expect( - shallow(<FreeCardPlan almName="GitHub" disabled={true} hasWarning={false} />) - ).toMatchSnapshot(); - }); -}); - -describe('#PaidCardPlan', () => { - it('should render', () => { - expect(shallow(<PaidCardPlan isRecommended={true} startingPrice={10} />)).toMatchSnapshot(); - }); -}); diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/RadioCard-test.tsx.snap b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/RadioCard-test.tsx.snap new file mode 100644 index 00000000000..4394ef55ac3 --- /dev/null +++ b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/RadioCard-test.tsx.snap @@ -0,0 +1,128 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should be actionable 1`] = ` +<div + className="radio-card radio-card-actionable" + onClick={[MockFunction]} + role="radio" + tabIndex={0} +> + <h2 + className="radio-card-header big-spacer-bottom" + > + <span + className="display-flex-center" + > + <i + className="icon-radio spacer-right" + /> + Radio Card + </span> + </h2> + <div + className="radio-card-body" + > + <div> + content + </div> + </div> +</div> +`; + +exports[`should be actionable 2`] = ` +<div + aria-checked={true} + className="radio-card radio-card-actionable selected" + onClick={ + [MockFunction] { + "calls": Array [ + Array [ + Object { + "currentTarget": Object { + "blur": [Function], + }, + "preventDefault": [Function], + "stopPropagation": [Function], + "target": Object { + "blur": [Function], + }, + }, + ], + ], + "results": Array [ + Object { + "isThrow": false, + "value": undefined, + }, + ], + } + } + role="radio" + tabIndex={0} +> + <h2 + className="radio-card-header big-spacer-bottom" + > + <span + className="display-flex-center" + > + <i + className="icon-radio spacer-right is-checked" + /> + Radio Card + </span> + info + </h2> + <div + className="radio-card-body" + > + <div> + content + </div> + </div> +</div> +`; + +exports[`should render correctly 1`] = ` +<div + className="radio-card" + role="radio" + tabIndex={0} +> + <h2 + className="radio-card-header big-spacer-bottom" + > + <span + className="display-flex-center" + > + Radio Card + </span> + info + </h2> + <div + className="radio-card-body" + > + <div> + content + </div> + </div> + <div + className="radio-card-recommended" + > + <RecommendedIcon + className="spacer-right" + /> + <FormattedMessage + defaultMessage="Recommended for you" + id="Recommended for you" + values={ + Object { + "recommended": <strong> + recommended + </strong>, + } + } + /> + </div> +</div> +`; diff --git a/server/sonar-web/src/main/js/components/hoc/utils.ts b/server/sonar-web/src/main/js/components/hoc/utils.ts index e324bcf7757..6548cae6a21 100644 --- a/server/sonar-web/src/main/js/components/hoc/utils.ts +++ b/server/sonar-web/src/main/js/components/hoc/utils.ts @@ -17,7 +17,7 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -export function getWrappedDisplayName(WrappedComponent: React.ComponentClass, hocName: string) { +export function getWrappedDisplayName(WrappedComponent: React.ComponentType, hocName: string) { const wrappedDisplayName = WrappedComponent.displayName || WrappedComponent.name || 'Component'; return `${hocName}(${wrappedDisplayName})`; } diff --git a/server/sonar-web/src/main/js/components/hoc/withCurrentUser.tsx b/server/sonar-web/src/main/js/components/hoc/withCurrentUser.tsx index 8a11dbe7428..0ea58a41e18 100644 --- a/server/sonar-web/src/main/js/components/hoc/withCurrentUser.tsx +++ b/server/sonar-web/src/main/js/components/hoc/withCurrentUser.tsx @@ -23,7 +23,7 @@ import { getWrappedDisplayName } from './utils'; import { Store, getCurrentUser } from '../../store/rootReducer'; export function withCurrentUser<P>( - WrappedComponent: React.ComponentClass<P & { currentUser: T.CurrentUser }> + WrappedComponent: React.ComponentType<P & { currentUser: T.CurrentUser }> ) { class Wrapper extends React.Component<P & { currentUser: T.CurrentUser }> { static displayName = getWrappedDisplayName(WrappedComponent, 'withCurrentUser'); diff --git a/server/sonar-web/src/main/js/components/ui/NewInfoBox.css b/server/sonar-web/src/main/js/components/ui/NewInfoBox.css new file mode 100644 index 00000000000..0c8b0ca2a20 --- /dev/null +++ b/server/sonar-web/src/main/js/components/ui/NewInfoBox.css @@ -0,0 +1,32 @@ +/* + * 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. + */ +.new-info-box { + display: flex; + padding: var(--gridSize); + background-color: var(--veryLightBlue); + border: 1px solid var(--alertBorderInfo); + border-radius: 2px; +} + +.new-info-box-header { + display: flex; + justify-content: space-between; + align-items: center; +} diff --git a/server/sonar-web/src/main/js/components/ui/NewInfoBox.tsx b/server/sonar-web/src/main/js/components/ui/NewInfoBox.tsx new file mode 100644 index 00000000000..c9a2da3e401 --- /dev/null +++ b/server/sonar-web/src/main/js/components/ui/NewInfoBox.tsx @@ -0,0 +1,54 @@ +/* + * 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 * as classNames from 'classnames'; +import { ButtonIcon } from './buttons'; +import ClearIcon from '../icons-components/ClearIcon'; +import { sonarcloudBlack500 } from '../../app/theme'; +import { translate } from '../../helpers/l10n'; +import './NewInfoBox.css'; + +export interface Props { + children: React.ReactNode; + className?: string; + description: React.ReactNode; + onClose?: () => void; + title: string; +} + +export default function NewInfoBox({ className, children, description, onClose, title }: Props) { + return ( + <div className={classNames('new-info-box', className)} role="alert"> + <div className="new-info-box-inner text-left"> + <div className="new-info-box-header spacer-bottom"> + <span className="display-inline-flex-center"> + <span className="badge badge-new spacer-right">{translate('new')}</span> + <strong>{title}</strong> + </span> + </div> + <p className="note spacer-bottom">{description}</p> + {children} + </div> + <ButtonIcon className="button-small spacer-left" color={sonarcloudBlack500} onClick={onClose}> + <ClearIcon size={12} /> + </ButtonIcon> + </div> + ); +} diff --git a/server/sonar-web/src/main/js/components/ui/__tests__/NewInfoBox-test.tsx b/server/sonar-web/src/main/js/components/ui/__tests__/NewInfoBox-test.tsx new file mode 100644 index 00000000000..d7fc93c6a11 --- /dev/null +++ b/server/sonar-web/src/main/js/components/ui/__tests__/NewInfoBox-test.tsx @@ -0,0 +1,44 @@ +/* + * 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 NewInfoBox from '../NewInfoBox'; +import { click } from '../../../helpers/testUtils'; + +it('should render correctly', () => { + expect( + shallow( + <NewInfoBox description="My description" onClose={jest.fn()} title="My title"> + <div /> + </NewInfoBox> + ) + ).toMatchSnapshot(); +}); + +it('should allow to opt out', () => { + const onClose = jest.fn(); + const wrapper = shallow( + <NewInfoBox description="" onClose={onClose} title=""> + <div /> + </NewInfoBox> + ); + click(wrapper.find('ButtonIcon')); + expect(onClose).toHaveBeenCalled(); +}); diff --git a/server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/NewInfoBox-test.tsx.snap b/server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/NewInfoBox-test.tsx.snap new file mode 100644 index 00000000000..fca669ba00a --- /dev/null +++ b/server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/NewInfoBox-test.tsx.snap @@ -0,0 +1,44 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` +<div + className="new-info-box" + role="alert" +> + <div + className="new-info-box-inner text-left" + > + <div + className="new-info-box-header spacer-bottom" + > + <span + className="display-inline-flex-center" + > + <span + className="badge badge-new spacer-right" + > + new + </span> + <strong> + My title + </strong> + </span> + </div> + <p + className="note spacer-bottom" + > + My description + </p> + <div /> + </div> + <ButtonIcon + className="button-small spacer-left" + color="#8a8c8f" + onClick={[MockFunction]} + > + <ClearIcon + size={12} + /> + </ButtonIcon> +</div> +`; diff --git a/server/sonar-web/src/main/js/helpers/__tests__/almIntegrations-test.ts b/server/sonar-web/src/main/js/helpers/__tests__/almIntegrations-test.ts index 842ac795cb7..877de0ca6c8 100644 --- a/server/sonar-web/src/main/js/helpers/__tests__/almIntegrations-test.ts +++ b/server/sonar-web/src/main/js/helpers/__tests__/almIntegrations-test.ts @@ -17,7 +17,23 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { isBitbucket, isGithub, isPersonal, isVSTS, sanitizeAlmId } from '../almIntegrations'; +import { + isBitbucket, + isGithub, + isPersonal, + isVSTS, + sanitizeAlmId, + getAlmMembersUrl +} from '../almIntegrations'; + +it('#getAlmMembersUrl', () => { + expect( + getAlmMembersUrl({ key: 'github', membersSync: true, url: 'https://github.com/Foo' }) + ).toBe('https://github.com/orgs/Foo/people'); + expect( + getAlmMembersUrl({ key: 'bitbucket', membersSync: true, url: 'https://bitbucket.com/Foo/' }) + ).toBe('https://bitbucket.com/Foo/profile/members'); +}); it('#isBitbucket', () => { expect(isBitbucket('bitbucket')).toBeTruthy(); diff --git a/server/sonar-web/src/main/js/helpers/almIntegrations.ts b/server/sonar-web/src/main/js/helpers/almIntegrations.ts index 650e0785581..ebeccc800f4 100644 --- a/server/sonar-web/src/main/js/helpers/almIntegrations.ts +++ b/server/sonar-web/src/main/js/helpers/almIntegrations.ts @@ -19,6 +19,16 @@ */ import { isLoggedIn } from './users'; +export function getAlmMembersUrl({ key, url }: T.OrganizationAlm): string { + if (!url.endsWith('/')) { + url += '/'; + } + if (isGithub(key)) { + return url.replace('github.com/', 'github.com/orgs/') + 'people'; + } + return url + 'profile/members'; +} + export function hasAdvancedALMIntegration(user: T.CurrentUser) { return ( isLoggedIn(user) && (isBitbucket(user.externalProvider) || isGithub(user.externalProvider)) diff --git a/server/sonar-web/src/main/js/helpers/testMocks.ts b/server/sonar-web/src/main/js/helpers/testMocks.ts index 4a7fd47182b..9f7ec979805 100644 --- a/server/sonar-web/src/main/js/helpers/testMocks.ts +++ b/server/sonar-web/src/main/js/helpers/testMocks.ts @@ -54,9 +54,13 @@ export function mockComponent(overrides: Partial<T.Component> = {}): T.Component }; } -export function mockCurrentUser(overrides: Partial<T.CurrentUser> = {}): T.CurrentUser { +export function mockCurrentUser(overrides: Partial<T.LoggedInUser> = {}): T.LoggedInUser { return { + groups: [], isLoggedIn: true, + login: 'luke', + name: 'Skywalker', + scmAccounts: [], ...overrides }; } @@ -84,11 +88,24 @@ export function mockLocation(overrides: Partial<Location> = {}): Location { } export function mockOrganization(overrides: Partial<T.Organization> = {}): T.Organization { - return { - key: 'foo', - name: 'Foo', + return { key: 'foo', name: 'Foo', ...overrides }; +} + +export function mockOrganizationWithAdminActions( + overrides: Partial<T.Organization> = {}, + actionsOverrides: Partial<T.Organization['actions']> = {} +) { + return mockOrganization({ actions: { admin: true, ...actionsOverrides }, ...overrides }); +} + +export function mockOrganizationWithAlm( + overrides: Partial<T.Organization> = {}, + almOverrides: Partial<T.Organization['alm']> = {} +): T.Organization { + return mockOrganization({ + alm: { key: 'github', membersSync: false, url: 'https://github.com/foo', ...almOverrides }, ...overrides - }; + }); } export function mockQualityProfile(overrides: Partial<Profile> = {}): Profile { |