From 07546d5e1f4047a1030a91d0ffaa39fb96e66a41 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Gr=C3=A9goire=20Aubert?= Date: Fri, 19 Oct 2018 17:25:13 +0200 Subject: [PATCH] SONAR-11323 Ease workflow to bind personal organizations * Create withUserOrganizations and use it in create Orgs/Projects page * Update ALM object format in api/navigation/component and api/organizations/search --- .../src/main/js/api/alm-integration.ts | 6 +- .../src/main/js/api/organizations.ts | 2 +- .../nav/component/ComponentNavHeader.tsx | 8 +- .../__tests__/ComponentNavHeader-test.tsx | 3 +- server/sonar-web/src/main/js/app/types.ts | 7 +- .../organization/AutoOrganizationCreate.tsx | 52 +++++-- .../organization/CreateOrganization.tsx | 130 +++++++++++------- .../organization/OrganizationDetailsStep.tsx | 8 +- .../__tests__/AutoOrganizationCreate-test.tsx | 24 +++- .../__tests__/CreateOrganization-test.tsx | 4 + .../AutoOrganizationCreate-test.tsx.snap | 59 +++++++- .../CreateOrganization-test.tsx.snap | 6 +- .../components/OrganizationKeyInput.tsx | 38 ++--- .../__tests__/OrganizationKeyInput-test.tsx | 7 + .../OrganizationKeyInput-test.tsx.snap | 21 +++ .../apps/create/project/CreateProjectPage.tsx | 38 ++--- .../create/project/OrganizationSelect.tsx | 6 +- .../__tests__/AutoProjectCreate-test.tsx | 4 +- .../__tests__/CreateProjectPage-test.tsx | 3 +- .../__tests__/OrganizationSelect-test.tsx | 5 +- .../AutoProjectCreate-test.tsx.snap | 10 +- .../CreateProjectPage-test.tsx.snap | 15 +- .../OrganizationSelect-test.tsx.snap | 5 +- .../OrganizationNavigationHeader.tsx | 8 +- .../OrganizationNavigationHeader-test.tsx | 3 +- ...OrganizationNavigationHeader-test.tsx.snap | 6 +- .../analyzeProject/AnalyzeTutorial.tsx | 6 +- .../AnalyzeTutorialSuggestion.tsx | 8 +- .../AnalyzeTutorialSuggestion-test.tsx | 8 +- .../hoc/__tests__/whenLoggedIn-test.tsx | 5 +- .../__tests__/withUserOrganizations-test.tsx | 49 +++++++ .../main/js/components/hoc/whenLoggedIn.tsx | 5 +- .../components/hoc/withUserOrganizations.tsx | 61 ++++++++ .../helpers/__tests__/almIntegrations-test.ts | 7 +- .../src/main/js/helpers/almIntegrations.ts | 24 ++-- .../resources/org/sonar/l10n/core.properties | 11 +- 36 files changed, 488 insertions(+), 174 deletions(-) create mode 100644 server/sonar-web/src/main/js/components/hoc/__tests__/withUserOrganizations-test.tsx create mode 100644 server/sonar-web/src/main/js/components/hoc/withUserOrganizations.tsx diff --git a/server/sonar-web/src/main/js/api/alm-integration.ts b/server/sonar-web/src/main/js/api/alm-integration.ts index 5805eea72b9..568eb2407ff 100644 --- a/server/sonar-web/src/main/js/api/alm-integration.ts +++ b/server/sonar-web/src/main/js/api/alm-integration.ts @@ -17,10 +17,14 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { getJSON, postJSON } from '../helpers/request'; +import { getJSON, postJSON, post } from '../helpers/request'; import { AlmRepository, AlmApplication, AlmOrganization } from '../app/types'; import throwGlobalError from '../app/utils/throwGlobalError'; +export function bindAlmOrganization(data: { installationId: string; organization: string }) { + return post('/api/alm_integration/bind_organization', data).catch(throwGlobalError); +} + export function getAlmAppInfo(): Promise<{ application: AlmApplication }> { return getJSON('/api/alm_integration/show_app_info').catch(throwGlobalError); } diff --git a/server/sonar-web/src/main/js/api/organizations.ts b/server/sonar-web/src/main/js/api/organizations.ts index 1b72037ce83..3ed1fe0c87c 100644 --- a/server/sonar-web/src/main/js/api/organizations.ts +++ b/server/sonar-web/src/main/js/api/organizations.ts @@ -55,7 +55,7 @@ export function getOrganizationNavigation(key: string): Promise { return postJSON('/api/organizations/create', data).then(r => r.organization, throwGlobalError); } diff --git a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavHeader.tsx b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavHeader.tsx index f32e3dfa308..f3301290cd3 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavHeader.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavHeader.tsx @@ -73,17 +73,17 @@ export function ComponentNavHeader(props: Props) { )} {renderBreadcrumbs(component.breadcrumbs)} {isSonarCloud() && - component.almRepoUrl && ( + component.alm && ( {sanitizeAlmId(component.almId)} diff --git a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNavHeader-test.tsx b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNavHeader-test.tsx index 4c3eb39bf08..2d1dd8d5f65 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNavHeader-test.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNavHeader-test.tsx @@ -77,8 +77,7 @@ it('should render alm links', () => { branchLikes={[]} component={{ ...component, - almId: 'bitbucketcloud', - almRepoUrl: 'https://bitbucket.org/foo' + alm: { key: 'bitbucketcloud', url: 'https://bitbucket.org/foo' } }} currentBranchLike={undefined} organization={organization} diff --git a/server/sonar-web/src/main/js/app/types.ts b/server/sonar-web/src/main/js/app/types.ts index 803ba673ad2..df5915bbfd0 100644 --- a/server/sonar-web/src/main/js/app/types.ts +++ b/server/sonar-web/src/main/js/app/types.ts @@ -89,8 +89,7 @@ export interface Breadcrumb { } export interface Component extends LightComponent { - almId?: string; - almRepoUrl?: string; + alm?: { key: string; url: string }; analysisDate?: string; breadcrumbs: Breadcrumb[]; configuration?: ComponentConfiguration; @@ -412,6 +411,7 @@ export interface LoggedInUser extends CurrentUser { local?: boolean; login: string; name: string; + personalOrganization?: string; scmAccounts: string[]; } @@ -480,8 +480,7 @@ export interface Notification { } export interface Organization extends OrganizationBase { - almId?: string; - almRepoUrl?: string; + alm?: { key: string; url: string }; adminPages?: Extension[]; canAdmin?: boolean; canDelete?: boolean; diff --git a/server/sonar-web/src/main/js/apps/create/organization/AutoOrganizationCreate.tsx b/server/sonar-web/src/main/js/apps/create/organization/AutoOrganizationCreate.tsx index f7882de2ba9..14fc63bdbf8 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/AutoOrganizationCreate.tsx +++ b/server/sonar-web/src/main/js/apps/create/organization/AutoOrganizationCreate.tsx @@ -30,45 +30,68 @@ import { import { getBaseUrl } from '../../../helpers/urls'; import { translate } from '../../../helpers/l10n'; import { sanitizeAlmId } from '../../../helpers/almIntegrations'; +import OrganizationAvatar from '../../../components/common/OrganizationAvatar'; interface Props { almApplication: AlmApplication; almInstallId?: string; almOrganization?: AlmOrganization; createOrganization: ( - organization: OrganizationBase & { installId?: string } + organization: OrganizationBase & { installationId?: string } ) => Promise; + importPersonalOrg?: Organization; onOrgCreated: (organization: string) => void; + updateOrganization: ( + organization: OrganizationBase & { installationId?: string } + ) => Promise; } export default class AutoOrganizationCreate extends React.PureComponent { handleCreateOrganization = (organization: Required) => { if (organization) { - return this.props - .createOrganization({ + const { importPersonalOrg } = this.props; + let promise: Promise; + if (importPersonalOrg) { + promise = this.props.updateOrganization({ + avatar: organization.avatar, + description: organization.description, + installationId: this.props.almInstallId, + key: importPersonalOrg.key, + name: organization.name || organization.key, + url: organization.url + }); + } else { + promise = this.props.createOrganization({ avatar: organization.avatar, description: organization.description, - installId: this.props.almInstallId, + installationId: this.props.almInstallId, key: organization.key, name: organization.name || organization.key, url: organization.url - }) - .then(({ key }) => this.props.onOrgCreated(key)); + }); + } + return promise.then(({ key }) => this.props.onOrgCreated(key)); } else { return Promise.reject(); } }; render() { - const { almApplication, almInstallId, almOrganization } = this.props; + const { almApplication, almInstallId, almOrganization, importPersonalOrg } = this.props; if (almInstallId && almOrganization) { + const description = importPersonalOrg + ? translate('onboarding.import_personal_organization_x') + : translate('onboarding.import_organization_x'); + const submitText = importPersonalOrg + ? translate('onboarding.import_organization.bind') + : translate('my_account.create_organization'); return ( { width={16} /> ), - name: {almOrganization.name} + name: {almOrganization.name}, + personalAvatar: importPersonalOrg && ( + + ), + personalName: importPersonalOrg && {importPersonalOrg.name} }} />

} finished={false} + keyReadOnly={Boolean(importPersonalOrg)} onContinue={this.handleCreateOrganization} onOpen={() => {}} open={true} - organization={almOrganization} - submitText={translate('my_account.create_organization')} + organization={importPersonalOrg || almOrganization} + submitText={submitText} /> ); } diff --git a/server/sonar-web/src/main/js/apps/create/organization/CreateOrganization.tsx b/server/sonar-web/src/main/js/apps/create/organization/CreateOrganization.tsx index 56e401bd103..17c0e3a83ee 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/CreateOrganization.tsx +++ b/server/sonar-web/src/main/js/apps/create/organization/CreateOrganization.tsx @@ -30,7 +30,12 @@ import ManualOrganizationCreate from './ManualOrganizationCreate'; import DeferredSpinner from '../../../components/common/DeferredSpinner'; import Tabs from '../../../components/controls/Tabs'; import { whenLoggedIn } from '../../../components/hoc/whenLoggedIn'; -import { getAlmAppInfo, getAlmOrganization } from '../../../api/alm-integration'; +import { withUserOrganizations } from '../../../components/hoc/withUserOrganizations'; +import { + getAlmAppInfo, + getAlmOrganization, + bindAlmOrganization +} from '../../../api/alm-integration'; import { getSubscriptionPlans } from '../../../api/billing'; import { LoggedInUser, @@ -40,7 +45,7 @@ import { AlmOrganization, OrganizationBase } from '../../../app/types'; -import { hasAdvancedALMIntegration } from '../../../helpers/almIntegrations'; +import { hasAdvancedALMIntegration, isPersonal } from '../../../helpers/almIntegrations'; import { translate } from '../../../helpers/l10n'; import { getOrganizationUrl } from '../../../helpers/urls'; import * as api from '../../../api/organizations'; @@ -49,9 +54,15 @@ import '../../../app/styles/sonarcloud.css'; import '../../tutorials/styles.css'; // TODO remove me interface Props { - createOrganization: (organization: OrganizationBase) => Promise; + createOrganization: ( + organization: OrganizationBase & { installationId?: string } + ) => Promise; currentUser: LoggedInUser; deleteOrganization: (key: string) => Promise; + updateOrganization: ( + organization: OrganizationBase & { installationId?: string } + ) => Promise; + userOrganizations: Organization[]; } interface State { @@ -146,11 +157,19 @@ export class CreateOrganization extends React.PureComponent o.key === currentUser.personalOrganization) + : undefined; + const header = importPersonalOrg + ? translate('onboarding.import_organization.personal.page.header') + : translate('onboarding.create_organization.page.header'); + const description = importPersonalOrg + ? translate('onboarding.import_organization.personal.page.description') + : translate('onboarding.create_organization.page.description'); const startedPrice = subscriptionPlans && subscriptionPlans[0] && subscriptionPlans[0].price; const formattedPrice = formatPrice(startedPrice); const showManualTab = state.tab === 'manual' && !query.almInstallId; @@ -164,8 +183,8 @@ export class CreateOrganization extends React.PureComponent , price: formattedPrice, @@ -184,36 +203,34 @@ export class CreateOrganization extends React.PureComponent ) : ( <> - {almApplication && ( - - onChange={this.onTabChange} - selected={showManualTab ? 'manual' : 'auto'} - tabs={[ - { - key: 'auto', - node: ( - <> - {translate( - 'onboarding.create_organization.import_organization', - almApplication.key - )} - - {translate('beta')} - - - ) - }, - { - disabled: Boolean(query.almInstallId), - key: 'manual', - node: translate('onboarding.create_organization.create_manually') - } - ]} - /> - )} + {almApplication && + !importPersonalOrg && ( + + onChange={this.onTabChange} + selected={showManualTab ? 'manual' : 'auto'} + tabs={[ + { + key: 'auto', + node: ( + <> + {translate('onboarding.import_organization', almApplication.key)} + + {translate('beta')} + + + ) + }, + { + disabled: Boolean(query.almInstallId), + key: 'manual', + node: translate('onboarding.create_organization.create_manually') + } + ]} + /> + )} {showManualTab || !almApplication ? ( )} @@ -240,7 +259,7 @@ export class CreateOrganization extends React.PureComponent { return api.createOrganization(organization).then((organization: Organization) => { dispatch(actions.createOrganization(organization)); @@ -249,6 +268,22 @@ function createOrganization(organization: OrganizationBase & { installId?: strin }; } +function updateOrganization( + organization: OrganizationBase & { key: string; installationId?: string } +) { + return (dispatch: Dispatch) => { + const { key, installationId, ...changes } = organization; + const promises = [api.updateOrganization(key, changes)]; + if (installationId) { + promises.push(bindAlmOrganization({ organization: key, installationId })); + } + return Promise.all(promises).then(() => { + dispatch(actions.updateOrganization(key, changes)); + return organization; + }); + }; +} + function deleteOrganization(key: string) { return (dispatch: Dispatch) => { return api.deleteOrganization(key).then(() => { @@ -259,14 +294,17 @@ function deleteOrganization(key: string) { const mapDispatchToProps = { createOrganization: createOrganization as any, - deleteOrganization: deleteOrganization as any + deleteOrganization: deleteOrganization as any, + updateOrganization: updateOrganization as any }; export default whenLoggedIn( - withRouter( - connect( - null, - mapDispatchToProps - )(CreateOrganization) + withUserOrganizations( + withRouter( + connect( + null, + mapDispatchToProps + )(CreateOrganization) + ) ) ); diff --git a/server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsStep.tsx b/server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsStep.tsx index 2b31d8a2379..d6e5f0696a1 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsStep.tsx +++ b/server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsStep.tsx @@ -35,6 +35,7 @@ type RequiredOrganization = Required; interface Props { description?: React.ReactNode; finished: boolean; + keyReadOnly?: boolean; onContinue: (organization: RequiredOrganization) => Promise; onOpen: () => void; open: boolean; @@ -141,7 +142,11 @@ export default class OrganizationDetailsStep extends React.PureComponent
{this.props.description} - +
{translate( @@ -162,6 +167,7 @@ export default class OrganizationDetailsStep extends React.PureComponent
diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/AutoOrganizationCreate-test.tsx b/server/sonar-web/src/main/js/apps/create/organization/__tests__/AutoOrganizationCreate-test.tsx index 8beb62e897c..17e57b70a94 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/__tests__/AutoOrganizationCreate-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/AutoOrganizationCreate-test.tsx @@ -52,10 +52,31 @@ it('should render prefilled and create org', async () => { wrapper.find('OrganizationDetailsStep').prop('onContinue')(organization); await waitAndUpdate(wrapper); - expect(createOrganization).toBeCalledWith({ ...organization, installId: 'id-foo' }); + expect(createOrganization).toBeCalledWith({ ...organization, installationId: 'id-foo' }); expect(onOrgCreated).toBeCalledWith('foo'); }); +it('should render for personal organizations', async () => { + const personalOrg = { key: 'personal-org', name: 'personal-org' }; + const updateOrganization = jest.fn().mockResolvedValue({ key: personalOrg.key }); + const onOrgCreated = jest.fn(); + const wrapper = shallowRender({ + almInstallId: 'id-foo', + almOrganization: { ...organization, type: 'USER' }, + importPersonalOrg: personalOrg, + onOrgCreated, + updateOrganization + }); + + expect(wrapper).toMatchSnapshot(); + + wrapper.find('OrganizationDetailsStep').prop('onContinue')(personalOrg); + await waitAndUpdate(wrapper); + + expect(updateOrganization).toBeCalledWith({ ...personalOrg, installationId: 'id-foo' }); + expect(onOrgCreated).toBeCalledWith(personalOrg.key); +}); + function shallowRender(props: Partial = {}) { return shallow( = {}) { }} createOrganization={jest.fn()} onOrgCreated={jest.fn()} + updateOrganization={jest.fn()} {...props} /> ); 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 9c1e58367fb..330f8062730 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 @@ -116,6 +116,10 @@ function shallowRender(props: Partial = {}) { // @ts-ignore avoid passing everything from WithRouterProps location={{}} router={mockRouter()} + userOrganizations={[ + { key: 'foo', name: 'Foo' }, + { alm: { key: 'github', url: '' }, key: 'bar', name: 'Bar' } + ]} {...props} /> ); diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/AutoOrganizationCreate-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/AutoOrganizationCreate-test.tsx.snap index a57042c6f50..423b5f2181b 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/AutoOrganizationCreate-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/AutoOrganizationCreate-test.tsx.snap @@ -1,5 +1,57 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`should render for personal organizations 1`] = ` + + , + "name": + name-foo + , + "personalAvatar": , + "personalName": + personal-org + , + } + } + /> +

+ } + finished={false} + keyReadOnly={true} + onContinue={[Function]} + onOpen={[Function]} + open={true} + organization={ + Object { + "key": "personal-org", + "name": "personal-org", + } + } + submitText="onboarding.import_organization.bind" +/> +`; + exports[`should render prefilled and create org 1`] = ` name-foo , + "personalAvatar": undefined, + "personalName": undefined, } } />

} finished={false} + keyReadOnly={false} onContinue={[Function]} onOpen={[Function]} open={true} diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/CreateOrganization-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/CreateOrganization-test.tsx.snap index c4b506bc1f3..c25c32f3e2c 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/CreateOrganization-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/CreateOrganization-test.tsx.snap @@ -50,7 +50,7 @@ exports[`should render with auto tab displayed 1`] = ` Object { "key": "auto", "node": - onboarding.create_organization.import_organization.github + onboarding.import_organization.github @@ -132,7 +132,7 @@ exports[`should render with auto tab selected and manual disabled 1`] = ` Object { "key": "auto", "node": - onboarding.create_organization.import_organization.github + onboarding.import_organization.github @@ -286,7 +286,7 @@ exports[`should switch tabs 1`] = ` Object { "key": "auto", "node": - onboarding.create_organization.import_organization.github + onboarding.import_organization.github diff --git a/server/sonar-web/src/main/js/apps/create/organization/components/OrganizationKeyInput.tsx b/server/sonar-web/src/main/js/apps/create/organization/components/OrganizationKeyInput.tsx index 0fd3c61b35a..a4fcc91d979 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/components/OrganizationKeyInput.tsx +++ b/server/sonar-web/src/main/js/apps/create/organization/components/OrganizationKeyInput.tsx @@ -28,6 +28,7 @@ import { getHostUrl } from '../../../../helpers/urls'; interface Props { initialValue?: string; onChange: (value: string | undefined) => void; + readOnly?: boolean; } interface State { @@ -50,7 +51,9 @@ export default class OrganizationKeyInput extends React.PureComponent + required={!this.props.readOnly}>
{getHostUrl().replace(/https*:\/\//, '') + '/organizations/'} + {this.props.readOnly && this.state.value} - + {!this.props.readOnly && ( + + )}
); diff --git a/server/sonar-web/src/main/js/apps/create/organization/components/__tests__/OrganizationKeyInput-test.tsx b/server/sonar-web/src/main/js/apps/create/organization/components/__tests__/OrganizationKeyInput-test.tsx index a6bcde51a7e..d559b30e4d0 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/components/__tests__/OrganizationKeyInput-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/organization/components/__tests__/OrganizationKeyInput-test.tsx @@ -38,6 +38,13 @@ it('should render correctly', () => { expect(wrapper.find('ValidationInput').prop('isValid')).toMatchSnapshot(); }); +it('should render correctly with readonly mode', () => { + const wrapper = shallow( + + ); + expect(wrapper).toMatchSnapshot(); +}); + it('should not display any status when the key is not defined', async () => { const wrapper = shallow(); await waitAndUpdate(wrapper); diff --git a/server/sonar-web/src/main/js/apps/create/organization/components/__tests__/__snapshots__/OrganizationKeyInput-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/organization/components/__tests__/__snapshots__/OrganizationKeyInput-test.tsx.snap index 8cba7d969a3..05d2e74dd68 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/components/__tests__/__snapshots__/OrganizationKeyInput-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/create/organization/components/__tests__/__snapshots__/OrganizationKeyInput-test.tsx.snap @@ -32,3 +32,24 @@ exports[`should render correctly 1`] = ` `; exports[`should render correctly 2`] = `true`; + +exports[`should render correctly with readonly mode 1`] = ` + +
+ + localhost/organizations/ + key + +
+
+`; diff --git a/server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx b/server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx index d46f7bbaa36..53382bfc34e 100644 --- a/server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx @@ -27,8 +27,7 @@ import ManualProjectCreate from './ManualProjectCreate'; import DeferredSpinner from '../../../components/common/DeferredSpinner'; import Tabs from '../../../components/controls/Tabs'; import { whenLoggedIn } from '../../../components/hoc/whenLoggedIn'; -import { fetchMyOrganizations } from '../../account/organizations/actions'; -import { getMyOrganizations, Store } from '../../../store/rootReducer'; +import { withUserOrganizations } from '../../../components/hoc/withUserOrganizations'; import { skipOnboarding as skipOnboardingAction } from '../../../store/users'; import { LoggedInUser, AlmApplication, Organization } from '../../../app/types'; import { getAlmAppInfo } from '../../../api/alm-integration'; @@ -38,14 +37,10 @@ import { translate } from '../../../helpers/l10n'; import { getProjectUrl } from '../../../helpers/urls'; import '../../../app/styles/sonarcloud.css'; -interface StateProps { - userOrganizations: Organization[]; -} - interface Props { currentUser: LoggedInUser; - fetchMyOrganizations: () => Promise; skipOnboardingAction: () => void; + userOrganizations: Organization[]; } interface State { @@ -60,16 +55,12 @@ interface LocationState { tab?: TabKeys; } -export class CreateProjectPage extends React.PureComponent< - Props & StateProps & WithRouterProps, - State -> { +export class CreateProjectPage extends React.PureComponent { mounted = false; state: State = { loading: true }; componentDidMount() { this.mounted = true; - this.props.fetchMyOrganizations(); if (hasAdvancedALMIntegration(this.props.currentUser)) { this.fetchAlmApplication(); } else { @@ -178,7 +169,7 @@ export class CreateProjectPage extends React.PureComponent< ) : ( o.almId)} + boundOrganizations={userOrganizations.filter(o => o.alm)} onProjectCreate={this.handleProjectCreate} organization={state.organization} /> @@ -191,20 +182,13 @@ export class CreateProjectPage extends React.PureComponent< } } -const mapDispatchToProps = { - fetchMyOrganizations, - skipOnboardingAction -}; - -const mapStateToProps = (state: Store) => { - return { - userOrganizations: getMyOrganizations(state) - }; -}; +const mapDispatchToProps = { skipOnboardingAction }; export default whenLoggedIn( - connect( - mapStateToProps, - mapDispatchToProps - )(CreateProjectPage) + withUserOrganizations( + connect( + null, + mapDispatchToProps + )(CreateProjectPage) + ) ); diff --git a/server/sonar-web/src/main/js/apps/create/project/OrganizationSelect.tsx b/server/sonar-web/src/main/js/apps/create/project/OrganizationSelect.tsx index a1d52e0af64..ed7cf5d30ee 100644 --- a/server/sonar-web/src/main/js/apps/create/project/OrganizationSelect.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/OrganizationSelect.tsx @@ -71,12 +71,12 @@ export default function OrganizationSelect({ export function optionRenderer(organization: Organization) { return ( - {organization.almId && ( + {organization.alm && ( {organization.almId} )} {organization.name} 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 3364a73a344..a7664c362be 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 @@ -42,8 +42,8 @@ function shallowRender(props: Partial = {}) { diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/OrganizationSelect-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/OrganizationSelect-test.tsx index 4224b152a38..cc7e426bbc4 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/OrganizationSelect-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/OrganizationSelect-test.tsx @@ -21,7 +21,10 @@ import * as React from 'react'; import { shallow } from 'enzyme'; import OrganizationSelect, { optionRenderer } from '../OrganizationSelect'; -const organizations = [{ key: 'foo', name: 'Foo' }, { almId: 'github', key: 'bar', name: 'Bar' }]; +const organizations = [ + { key: 'foo', name: 'Foo' }, + { alm: { key: 'github', url: '' }, key: 'bar', name: 'Bar' } +]; it('should render correctly', () => { expect( 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 147427d62a6..a96a37b53b5 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 @@ -9,12 +9,18 @@ exports[`should display the bounded organizations dropdown with the list of repo organizations={ Array [ Object { - "almId": "github", + "alm": Object { + "key": "github", + "url": "", + }, "key": "foo", "name": "Foo", }, Object { - "almId": "github", + "alm": Object { + "key": "github", + "url": "", + }, "key": "bar", "name": "Bar", }, diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/CreateProjectPage-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/CreateProjectPage-test.tsx.snap index 6e1f9059e89..5e2c2e1a150 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/CreateProjectPage-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/CreateProjectPage-test.tsx.snap @@ -83,7 +83,10 @@ exports[`should render correctly 2`] = ` boundOrganizations={ Array [ Object { - "almId": "github", + "alm": Object { + "key": "github", + "url": "", + }, "key": "bar", "name": "Bar", }, @@ -134,7 +137,10 @@ exports[`should render with Manual creation only 1`] = ` "name": "Foo", }, Object { - "almId": "github", + "alm": Object { + "key": "github", + "url": "", + }, "key": "bar", "name": "Bar", }, @@ -201,7 +207,10 @@ exports[`should switch tabs 1`] = ` boundOrganizations={ Array [ Object { - "almId": "github", + "alm": Object { + "key": "github", + "url": "", + }, "key": "bar", "name": "Bar", }, diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/OrganizationSelect-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/OrganizationSelect-test.tsx.snap index 50cd939ec7e..367f0265e72 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/OrganizationSelect-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/OrganizationSelect-test.tsx.snap @@ -25,7 +25,10 @@ exports[`should render correctly 1`] = ` options={ Array [ Object { - "almId": "github", + "alm": Object { + "key": "github", + "url": "", + }, "key": "bar", "name": "Bar", }, diff --git a/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationHeader.tsx b/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationHeader.tsx index d376085573a..4d7bb7f71e1 100644 --- a/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationHeader.tsx +++ b/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationHeader.tsx @@ -58,17 +58,17 @@ export default function OrganizationNavigationHeader({ organization, organizatio ) : ( {organization.name} )} - {organization.almRepoUrl && ( + {organization.alm && ( {sanitizeAlmId(organization.almId)} 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 021b80766e4..2d6853a617d 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 @@ -38,8 +38,7 @@ it('renders with alm integration', () => { shallow( { const { component, currentUser } = this.props; const { step, token } = this.state; - const almId = component.almId || currentUser.externalProvider; + const almKey = (component.alm && component.alm.key) || currentUser.externalProvider; return ( <>
@@ -64,9 +64,9 @@ export default class AnalyzeTutorial extends React.PureComponent {

{translate('onboarding.project_analysis.description')}

- + - {!isVSTS(almId) && ( + {!isVSTS(almKey) && ( <>

{translate('onboarding.project_analysis.commands_for_analysis')}

@@ -49,7 +49,7 @@ export default function AnalyzeTutorialSuggestion({ almId }: { almId?: string }) /> ); - } else if (isGithub(almId)) { + } else if (isGithub(almKey)) { return (

{translate('onboarding.project_analysis.commands_for_analysis')}

@@ -70,7 +70,7 @@ export default function AnalyzeTutorialSuggestion({ almId }: { almId?: string }) />
); - } else if (isVSTS(almId)) { + } else if (isVSTS(almKey)) { return ( { - expect(shallow().type()).toBeNull(); + expect(shallow().type()).toBeNull(); }); it('renders bitbucket suggestions correctly', () => { - expect(shallow()).toMatchSnapshot(); + expect(shallow()).toMatchSnapshot(); }); it('renders github suggestions correctly', () => { - expect(shallow()).toMatchSnapshot(); + expect(shallow()).toMatchSnapshot(); }); it('renders vsts suggestions correctly', () => { - expect(shallow()).toMatchSnapshot(); + expect(shallow()).toMatchSnapshot(); }); diff --git a/server/sonar-web/src/main/js/components/hoc/__tests__/whenLoggedIn-test.tsx b/server/sonar-web/src/main/js/components/hoc/__tests__/whenLoggedIn-test.tsx index e75dad4cdd8..0bed5354f4e 100644 --- a/server/sonar-web/src/main/js/components/hoc/__tests__/whenLoggedIn-test.tsx +++ b/server/sonar-web/src/main/js/components/hoc/__tests__/whenLoggedIn-test.tsx @@ -20,7 +20,6 @@ import * as React from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; import { createStore } from 'redux'; -import { mockRouter } from '../../../helpers/testUtils'; import handleRequiredAuthentication from '../../../app/utils/handleRequiredAuthentication'; import { whenLoggedIn } from '../whenLoggedIn'; @@ -44,15 +43,13 @@ it('should render for logged in user', () => { it('should not render for anonymous user', () => { const store = createStore(state => state, { users: { currentUser: { isLoggedIn: false } } }); - const router = mockRouter({ replace: jest.fn() }); - const wrapper = shallow(, { context: { store, router } }); + const wrapper = shallow(, { context: { store } }); expect(getRenderedType(wrapper)).toBe(null); expect(handleRequiredAuthentication).toBeCalled(); }); function getRenderedType(wrapper: ShallowWrapper) { return wrapper - .dive() .dive() .dive() .type(); diff --git a/server/sonar-web/src/main/js/components/hoc/__tests__/withUserOrganizations-test.tsx b/server/sonar-web/src/main/js/components/hoc/__tests__/withUserOrganizations-test.tsx new file mode 100644 index 00000000000..dee2f7fec03 --- /dev/null +++ b/server/sonar-web/src/main/js/components/hoc/__tests__/withUserOrganizations-test.tsx @@ -0,0 +1,49 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import { shallow } from 'enzyme'; +import { createStore } from 'redux'; +import { Organization } from '../../../app/types'; +import { withUserOrganizations } from '../withUserOrganizations'; + +jest.mock('../../../api/organizations', () => ({ getOrganizations: jest.fn() })); + +class X extends React.Component<{ userOrganizations: Organization[] }> { + render() { + return
; + } +} + +const UnderTest = withUserOrganizations(X); + +// TODO Find a way to make this work, currently getting the following error : Actions must be plain objects. Use custom middleware for async actions. +it.skip('should pass user organizations and logged in user', () => { + const org = { key: 'my-org', name: 'My Organization' }; + const store = createStore(state => state, { + organizations: { byKey: { 'my-org': org }, my: ['my-org'] } + }); + const wrapper = shallow(, { context: { store } }); + const wrappedComponent = wrapper + .dive() + .dive() + .dive(); + expect(wrappedComponent.type()).toBe(X); + expect(wrappedComponent.prop('userOrganizations')).toEqual([org]); +}); diff --git a/server/sonar-web/src/main/js/components/hoc/whenLoggedIn.tsx b/server/sonar-web/src/main/js/components/hoc/whenLoggedIn.tsx index 00dd040670e..2ce4c8894c0 100644 --- a/server/sonar-web/src/main/js/components/hoc/whenLoggedIn.tsx +++ b/server/sonar-web/src/main/js/components/hoc/whenLoggedIn.tsx @@ -18,7 +18,6 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import { withRouter, WithRouterProps } from 'react-router'; import { withCurrentUser } from './withCurrentUser'; import { CurrentUser } from '../../app/types'; import { isLoggedIn } from '../../helpers/users'; @@ -27,7 +26,7 @@ import handleRequiredAuthentication from '../../app/utils/handleRequiredAuthenti export function whenLoggedIn

(WrappedComponent: React.ComponentClass

) { const wrappedDisplayName = WrappedComponent.displayName || WrappedComponent.name || 'Component'; - class Wrapper extends React.Component

{ + class Wrapper extends React.Component

{ static displayName = `whenLoggedIn(${wrappedDisplayName})`; componentDidMount() { @@ -45,5 +44,5 @@ export function whenLoggedIn

(WrappedComponent: React.ComponentClass

) { } } - return withCurrentUser(withRouter(Wrapper)); + return withCurrentUser(Wrapper); } diff --git a/server/sonar-web/src/main/js/components/hoc/withUserOrganizations.tsx b/server/sonar-web/src/main/js/components/hoc/withUserOrganizations.tsx new file mode 100644 index 00000000000..bedc033bc09 --- /dev/null +++ b/server/sonar-web/src/main/js/components/hoc/withUserOrganizations.tsx @@ -0,0 +1,61 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import { connect } from 'react-redux'; +import { Store, getMyOrganizations } from '../../store/rootReducer'; +import { fetchMyOrganizations } from '../../apps/account/organizations/actions'; +import { Organization } from '../../app/types'; + +export function withUserOrganizations

( + WrappedComponent: React.ComponentClass< + P & { + personalOrganization?: Organization; + userOrganizations: Organization[]; + } + > +) { + type Props = P & { fetchMyOrganizations: () => Promise; userOrganizations: Organization[] }; + const wrappedDisplayName = WrappedComponent.displayName || WrappedComponent.name || 'Component'; + + class Wrapper extends React.Component { + static displayName = `withUserOrganizations(${wrappedDisplayName})`; + + componentDidMount() { + this.props.fetchMyOrganizations(); + } + + render() { + // @ts-ignore Rest operator not supported yet by TS for generics + const { fetchMyOrganizations, ...other } = this.props; + return ; + } + } + + const mapDispatchToProps = { fetchMyOrganizations }; + + function mapStateToProps(state: Store) { + return { userOrganizations: getMyOrganizations(state) }; + } + + return connect( + mapStateToProps, + mapDispatchToProps + )(Wrapper); +} 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 02353fe12e2..7a54b6b68bd 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,7 @@ * 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, isVSTS, sanitizeAlmId } from '../almIntegrations'; +import { isBitbucket, isGithub, isPersonal, isVSTS, sanitizeAlmId } from '../almIntegrations'; it('#isBitbucket', () => { expect(isBitbucket('bitbucket')).toBeTruthy(); @@ -35,6 +35,11 @@ it('#isVSTS', () => { expect(isVSTS('github')).toBeFalsy(); }); +it('#isPersonal', () => { + expect(isPersonal({ key: 'foo', name: 'Foo', type: 'USER' })).toBeTruthy(); + expect(isPersonal({ key: 'foo', name: 'Foo', type: 'ORGANIZATION' })).toBeFalsy(); +}); + it('#sanitizeAlmId', () => { expect(sanitizeAlmId('bitbucketcloud')).toBe('bitbucket'); expect(sanitizeAlmId('bitbucket')).toBe('bitbucket'); diff --git a/server/sonar-web/src/main/js/helpers/almIntegrations.ts b/server/sonar-web/src/main/js/helpers/almIntegrations.ts index c943f67b90e..fdfe7abd17c 100644 --- a/server/sonar-web/src/main/js/helpers/almIntegrations.ts +++ b/server/sonar-web/src/main/js/helpers/almIntegrations.ts @@ -18,7 +18,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import { isLoggedIn } from './users'; -import { CurrentUser } from '../app/types'; +import { CurrentUser, AlmOrganization } from '../app/types'; export function hasAdvancedALMIntegration(user: CurrentUser) { return ( @@ -26,21 +26,25 @@ export function hasAdvancedALMIntegration(user: CurrentUser) { ); } -export function isBitbucket(almId?: string) { - return almId && almId.startsWith('bitbucket'); +export function isBitbucket(almKey?: string) { + return almKey && almKey.startsWith('bitbucket'); } -export function isGithub(almId?: string) { - return almId === 'github'; +export function isGithub(almKey?: string) { + return almKey === 'github'; } -export function isVSTS(almId?: string) { - return almId === 'microsoft'; +export function isVSTS(almKey?: string) { + return almKey === 'microsoft'; } -export function sanitizeAlmId(almId?: string) { - if (isBitbucket(almId)) { +export function isPersonal(organization?: AlmOrganization) { + return Boolean(organization && organization.type === 'USER'); +} + +export function sanitizeAlmId(almKey?: string) { + if (isBitbucket(almKey)) { return 'bitbucket'; } - return almId; + return almKey; } diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index b798554254f..1f34203174b 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -2748,9 +2748,6 @@ onboarding.create_organization.url.error=The value must be a valid url. onboarding.create_organization.description=Description onboarding.create_organization.enter_org_details=Enter your organization details onboarding.create_organization.create_manually=Create manually -onboarding.create_organization.import_organization.bitbucket=Import from BitBucket teams -onboarding.create_organization.import_organization.github=Import from GitHub organizations -onboarding.create_organization.import_organization_x=Import {avatar} {name} into SonarCloud organization onboarding.create_organization.import_org_details=Import organization details onboarding.create_organization.import_org_not_found=We were not able to find the requested organization, here are a few tips to help you troubleshoot the issue: onboarding.create_organization.import_org_not_found.tips_1=You must be an administrator of the organization @@ -2762,6 +2759,14 @@ onboarding.create_organization.choose_plan=Choose a plan onboarding.create_organization.enter_your_coupon=Enter your coupon onboarding.create_organization.create_and_upgrade=Create Organization and Upgrade onboarding.create_organization.ready=All set! Your organization is now ready to go +onboarding.import_organization.bind=Bind Organization +onboarding.import_organization.personal.page.header=Bind to your personal organization +onboarding.import_organization.personal.page.description=An organization is a space where a team or a whole company can collaborate accross many projects. +onboarding.import_organization.bitbucket=Import from BitBucket teams +onboarding.import_organization.github=Import from GitHub organizations +onboarding.import_organization_x=Import {avatar} {name} into SonarCloud organization +onboarding.import_personal_organization_x=Bind {avatar} {name} with your personal SonarCloud organization {personalAvatar} {personalName} + onboarding.team.header=Join a team onboarding.team.first_step=Well congrats, the first step is done! -- 2.39.5