diff options
author | Grégoire Aubert <gregoire.aubert@sonarsource.com> | 2018-10-16 17:03:32 +0200 |
---|---|---|
committer | SonarTech <sonartech@sonarsource.com> | 2018-11-16 20:21:04 +0100 |
commit | 3ea9808248000c145f53a4f1cdb8711d63b97da4 (patch) | |
tree | 3b18082347d14718b90b3409b28740ca971b1568 | |
parent | bcddd9658c7eea3bb5576bece3a0c2b985a7d506 (diff) | |
download | sonarqube-3ea9808248000c145f53a4f1cdb8711d63b97da4.tar.gz sonarqube-3ea9808248000c145f53a4f1cdb8711d63b97da4.zip |
SONAR-11322 Import repos from bound organizations
* Move project create page in the create folder
* Move HOCs to components
* Update create project page
* Move getting user organizations in CreateProjectPage (1 level higher)
* Creact OrganizationSelect component
* Create RemoteRepositories component
* Use OrganizationSelect in ManualProjectCreate
* Add OrganizationSelect and RemoteRepositories in AutoProjectCreate page
* Rework validation of the create organization page
* Add 'organization' param on list_repositories and provision_projects
63 files changed, 2302 insertions, 1028 deletions
diff --git a/server/sonar-db-dao/src/main/java/org/sonar/db/alm/AlmAppInstallDao.java b/server/sonar-db-dao/src/main/java/org/sonar/db/alm/AlmAppInstallDao.java index 005017eefd8..0b4c5ca42f6 100644 --- a/server/sonar-db-dao/src/main/java/org/sonar/db/alm/AlmAppInstallDao.java +++ b/server/sonar-db-dao/src/main/java/org/sonar/db/alm/AlmAppInstallDao.java @@ -20,7 +20,6 @@ package org.sonar.db.alm; import java.util.List; -import java.util.Objects; import java.util.Optional; import javax.annotation.Nullable; import org.sonar.api.utils.System2; @@ -45,6 +44,11 @@ public class AlmAppInstallDao implements Dao { this.uuidFactory = uuidFactory; } + public Optional<AlmAppInstallDto> selectByUuid(DbSession dbSession, String uuid) { + AlmAppInstallMapper mapper = getMapper(dbSession); + return Optional.ofNullable(mapper.selectByUuid(uuid)); + } + public Optional<AlmAppInstallDto> selectByOwnerId(DbSession dbSession, ALM alm, String ownerId) { checkAlm(alm); checkOwnerId(ownerId); diff --git a/server/sonar-db-dao/src/main/java/org/sonar/db/alm/AlmAppInstallMapper.java b/server/sonar-db-dao/src/main/java/org/sonar/db/alm/AlmAppInstallMapper.java index f840a5aa07b..4c18e9bfa4a 100644 --- a/server/sonar-db-dao/src/main/java/org/sonar/db/alm/AlmAppInstallMapper.java +++ b/server/sonar-db-dao/src/main/java/org/sonar/db/alm/AlmAppInstallMapper.java @@ -32,6 +32,9 @@ public interface AlmAppInstallMapper { @CheckForNull AlmAppInstallDto selectByInstallationId(@Param("almId") String almId, @Param("installId") String installId); + @CheckForNull + AlmAppInstallDto selectByUuid(@Param("uuid") String uuid); + List<AlmAppInstallDto> selectAllWithNoOwnerType(); void insert(@Param("uuid") String uuid, @Param("almId") String almId, @Param("ownerId") String ownerId, diff --git a/server/sonar-db-dao/src/main/resources/org/sonar/db/alm/AlmAppInstallMapper.xml b/server/sonar-db-dao/src/main/resources/org/sonar/db/alm/AlmAppInstallMapper.xml index 2fe08a4dd5e..0d539aad6cf 100644 --- a/server/sonar-db-dao/src/main/resources/org/sonar/db/alm/AlmAppInstallMapper.xml +++ b/server/sonar-db-dao/src/main/resources/org/sonar/db/alm/AlmAppInstallMapper.xml @@ -23,8 +23,7 @@ </select> <select id="selectByInstallationId" parameterType="Map" resultType="org.sonar.db.alm.AlmAppInstallDto"> - select - <include refid="sqlColumns"/> + select <include refid="sqlColumns"/> from alm_app_installs where @@ -32,12 +31,20 @@ and install_id = #{installId, jdbcType=VARCHAR} </select> + <select id="selectByUuid" parameterType="Map" resultType="org.sonar.db.alm.AlmAppInstallDto"> + select <include refid="sqlColumns"/> + from + alm_app_installs + where + uuid = #{uuid, jdbcType=VARCHAR} + </select> + <select id="selectAllWithNoOwnerType" parameterType="Map" resultType="org.sonar.db.alm.AlmAppInstallDto"> select <include refid="sqlColumns" /> from - alm_app_installs + alm_app_installs where - is_owner_user is null + is_owner_user is null </select> <insert id="insert" parameterType="Map" useGeneratedKeys="false"> diff --git a/server/sonar-db-dao/src/test/java/org/sonar/db/alm/AlmAppInstallDaoTest.java b/server/sonar-db-dao/src/test/java/org/sonar/db/alm/AlmAppInstallDaoTest.java index ddc76bdf316..0e61ee69324 100644 --- a/server/sonar-db-dao/src/test/java/org/sonar/db/alm/AlmAppInstallDaoTest.java +++ b/server/sonar-db-dao/src/test/java/org/sonar/db/alm/AlmAppInstallDaoTest.java @@ -60,6 +60,20 @@ public class AlmAppInstallDaoTest { private AlmAppInstallDao underTest = new AlmAppInstallDao(system2, uuidFactory); @Test + public void selectByUuid() { + when(uuidFactory.create()).thenReturn(A_UUID); + when(system2.now()).thenReturn(DATE); + underTest.insertOrUpdate(dbSession, GITHUB, A_OWNER, true, AN_INSTALL); + + assertThat(underTest.selectByUuid(dbSession, A_UUID).get()) + .extracting(AlmAppInstallDto::getUuid, AlmAppInstallDto::getAlm, AlmAppInstallDto::getInstallId, AlmAppInstallDto::getOwnerId, + AlmAppInstallDto::getCreatedAt, AlmAppInstallDto::getUpdatedAt) + .contains(A_UUID, GITHUB, A_OWNER, AN_INSTALL, DATE, DATE); + + assertThat(underTest.selectByUuid(dbSession, "foo")).isNotPresent(); + } + + @Test public void selectByOwnerId() { when(uuidFactory.create()).thenReturn(A_UUID); when(system2.now()).thenReturn(DATE); 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 c3f560044f2..5805eea72b9 100644 --- a/server/sonar-web/src/main/js/api/alm-integration.ts +++ b/server/sonar-web/src/main/js/api/alm-integration.ts @@ -35,18 +35,15 @@ export function getAlmOrganization(data: { installationId: string }): Promise<Al ); } -export function getRepositories(): Promise<{ - almIntegration: { - installed: boolean; - installationUrl: string; - }; - repositories: AlmRepository[]; -}> { - return getJSON('/api/alm_integration/list_repositories').catch(throwGlobalError); +export function getRepositories(data: { + organization: string; +}): Promise<{ repositories: AlmRepository[] }> { + return getJSON('/api/alm_integration/list_repositories', data).catch(throwGlobalError); } export function provisionProject(data: { installationKeys: string[]; + organization: string; }): Promise<{ projects: Array<{ projectKey: string }> }> { return postJSON('/api/alm_integration/provision_projects', { ...data, diff --git a/server/sonar-web/src/main/js/app/components/StartupModal.tsx b/server/sonar-web/src/main/js/app/components/StartupModal.tsx index c0393198c15..155c2984e5d 100644 --- a/server/sonar-web/src/main/js/app/components/StartupModal.tsx +++ b/server/sonar-web/src/main/js/app/components/StartupModal.tsx @@ -124,7 +124,7 @@ export class StartupModal extends React.PureComponent<Props, State> { openProjectOnboarding = (organization?: string) => { if (isSonarCloud()) { this.setState({ automatic: false, modal: undefined }); - this.props.router.push({ pathname: `/projects/create`, query: { organization } }); + this.props.router.push({ pathname: `/projects/create`, state: { organization } }); } else { this.setState({ modal: ModalKey.projectOnboarding }); } diff --git a/server/sonar-web/src/main/js/apps/projects/create/utils.ts b/server/sonar-web/src/main/js/app/utils/__test__/handleRequiredAuthentication-test.ts index ed3f0b178fb..4c042657066 100644 --- a/server/sonar-web/src/main/js/apps/projects/create/utils.ts +++ b/server/sonar-web/src/main/js/app/utils/__test__/handleRequiredAuthentication-test.ts @@ -17,36 +17,16 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { memoize } from 'lodash'; -import { - cleanQuery, - RawQuery, - parseAsBoolean, - serializeOptionalBoolean, - parseAsOptionalString, - serializeString -} from '../../../helpers/query'; +import handleRequiredAuthentication from '../handleRequiredAuthentication'; +import getHistory from '../getHistory'; -export interface Query { - error?: string; - manual: boolean; - organization?: string; -} +jest.mock('../getHistory', () => ({ + default: jest.fn() +})); -export const parseQuery = memoize( - (urlQuery: RawQuery): Query => { - return { - error: parseAsOptionalString(urlQuery['error']), - manual: parseAsBoolean(urlQuery['manual'], false), - organization: parseAsOptionalString(urlQuery['organization']) - }; - } -); - -export const serializeQuery = memoize( - (query: Query): RawQuery => - cleanQuery({ - manual: serializeOptionalBoolean(query.manual || undefined), - organization: serializeString(query.organization) - }) -); +it('should not render for anonymous user', () => { + const replace = jest.fn(); + (getHistory as jest.Mock<any>).mockReturnValue({ replace }); + handleRequiredAuthentication(); + expect(replace).toBeCalledWith(expect.objectContaining({ pathname: '/sessions/new' })); +}); diff --git a/server/sonar-web/src/main/js/apps/create/organization/ChooseRemoteOrganizationStep.tsx b/server/sonar-web/src/main/js/apps/create/organization/ChooseRemoteOrganizationStep.tsx index 25a091e9246..602d3139d47 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/ChooseRemoteOrganizationStep.tsx +++ b/server/sonar-web/src/main/js/apps/create/organization/ChooseRemoteOrganizationStep.tsx @@ -22,6 +22,7 @@ import IdentityProviderLink from '../../../components/ui/IdentityProviderLink'; import Step from '../../tutorials/components/Step'; import { translate } from '../../../helpers/l10n'; import { AlmApplication } from '../../../app/types'; +import { Alert } from '../../../components/ui/Alert'; interface Props { almApplication: AlmApplication; @@ -34,13 +35,13 @@ export default class ChooseRemoteOrganizationStep extends React.PureComponent<Pr return ( <div className="boxed-group-inner"> {almInstallId && ( - <span className="alert alert-warning markdown big-spacer-bottom width-60"> + <Alert className="markdown big-spacer-bottom width-60" variant="warning"> {translate('onboarding.create_organization.import_org_not_found')} <ul> <li>{translate('onboarding.create_organization.import_org_not_found.tips_1')}</li> <li>{translate('onboarding.create_organization.import_org_not_found.tips_2')}</li> </ul> - </span> + </Alert> )} <IdentityProviderLink className="display-inline-block" 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 2767cdfa399..56e401bd103 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 @@ -25,11 +25,11 @@ import { Helmet } from 'react-helmet'; import { FormattedMessage } from 'react-intl'; import { Link, withRouter, WithRouterProps } from 'react-router'; import { formatPrice, parseQuery } from './utils'; -import { whenLoggedIn } from './whenLoggedIn'; import AutoOrganizationCreate from './AutoOrganizationCreate'; 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 { getSubscriptionPlans } from '../../../api/billing'; import { @@ -62,9 +62,11 @@ interface State { subscriptionPlans?: SubscriptionPlan[]; } +type TabKeys = 'auto' | 'manual'; + interface LocationState { paid?: boolean; - tab?: 'auto' | 'manual'; + tab?: TabKeys; } export class CreateOrganization extends React.PureComponent<Props & WithRouterProps, State> { @@ -125,7 +127,7 @@ export class CreateOrganization extends React.PureComponent<Props & WithRouterPr }); }; - onTabChange = (tab: 'auto' | 'manual') => { + onTabChange = (tab: TabKeys) => { this.updateUrl({ tab }); }; @@ -138,6 +140,7 @@ export class CreateOrganization extends React.PureComponent<Props & WithRouterPr updateUrl = (state: Partial<LocationState> = {}) => { this.props.router.replace({ pathname: this.props.location.pathname, + query: this.props.location.query, state: { ...(this.props.location.state || {}), ...state } }); }; @@ -182,7 +185,7 @@ export class CreateOrganization extends React.PureComponent<Props & WithRouterPr ) : ( <> {almApplication && ( - <Tabs + <Tabs<TabKeys> onChange={this.onTabChange} selected={showManualTab ? 'manual' : 'auto'} tabs={[ @@ -195,13 +198,9 @@ export class CreateOrganization extends React.PureComponent<Props & WithRouterPr almApplication.key )} <span - className={classNames( - 'rounded alert alert-small spacer-left display-inline-block', - { - 'alert-info': !showManualTab, - 'alert-muted': showManualTab - } - )}> + className={classNames('beta-badge spacer-left', { + 'is-muted': showManualTab + })}> {translate('beta')} </span> </> diff --git a/server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsInput.tsx b/server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsInput.tsx deleted file mode 100644 index 9526e064562..00000000000 --- a/server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsInput.tsx +++ /dev/null @@ -1,76 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2018 SonarSource SA - * mailto:info AT sonarsource DOT com - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ -import * as React from 'react'; -import * as classNames from 'classnames'; -import AlertErrorIcon from '../../../components/icons-components/AlertErrorIcon'; -import AlertSuccessIcon from '../../../components/icons-components/AlertSuccessIcon'; - -interface Props { - description?: string; - dirty: boolean; - children: (inputProps: React.InputHTMLAttributes<Element>) => React.ReactElement<any>; - error: string | undefined; - id: string; - isSubmitting: boolean; - isValidating: boolean; - label: React.ReactNode; - name: string; - onBlur: React.FocusEventHandler; - onChange: React.ChangeEventHandler; - required?: boolean; - touched?: boolean; - value: string; -} - -export default function OrganizationDetailsInput(props: Props) { - const hasError = props.dirty && props.touched && !props.isValidating && props.error !== undefined; - const isValid = props.dirty && props.touched && props.error === undefined; - return ( - <div> - <label htmlFor={props.id}> - <strong>{props.label}</strong> - {props.required && <em className="mandatory">*</em>} - </label> - <div className="little-spacer-top spacer-bottom"> - {props.children({ - className: classNames('input-super-large', 'text-middle', { - 'is-invalid': hasError, - 'is-valid': isValid - }), - disabled: props.isSubmitting, - id: props.id, - name: props.name, - onBlur: props.onBlur, - onChange: props.onChange, - type: 'text', - value: props.value - })} - {hasError && ( - <> - <AlertErrorIcon className="spacer-left text-middle" /> - <span className="little-spacer-left text-danger text-middle">{props.error}</span> - </> - )} - {isValid && <AlertSuccessIcon className="spacer-left text-middle" />} - </div> - {props.description && <div className="note abs-width-400">{props.description}</div>} - </div> - ); -} 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 75f1b75d17b..2b31d8a2379 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 @@ -18,32 +18,24 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import { isWebUri } from 'valid-url'; -import OrganizationDetailsInput from './OrganizationDetailsInput'; +import OrganizationAvatarInput from './components/OrganizationAvatarInput'; +import OrganizationDescriptionInput from './components/OrganizationDescriptionInput'; +import OrganizationKeyInput from './components/OrganizationKeyInput'; +import OrganizationNameInput from './components/OrganizationNameInput'; +import OrganizationUrlInput from './components/OrganizationUrlInput'; import Step from '../../tutorials/components/Step'; -import ValidationForm, { ChildrenProps } from '../../../components/controls/ValidationForm'; import { translate } from '../../../helpers/l10n'; import { ResetButtonLink, SubmitButton } from '../../../components/ui/buttons'; import AlertSuccessIcon from '../../../components/icons-components/AlertSuccessIcon'; import DropdownIcon from '../../../components/icons-components/DropdownIcon'; -import { getHostUrl } from '../../../helpers/urls'; import { OrganizationBase } from '../../../app/types'; -import { getOrganization } from '../../../api/organizations'; -type Values = Required<OrganizationBase>; - -const initialValues: Values = { - avatar: '', - description: '', - name: '', - key: '', - url: '' -}; +type RequiredOrganization = Required<OrganizationBase>; interface Props { description?: React.ReactNode; finished: boolean; - onContinue: (organization: Required<OrganizationBase>) => Promise<void>; + onContinue: (organization: RequiredOrganization) => Promise<void>; onOpen: () => void; open: boolean; organization?: OrganizationBase & { key: string }; @@ -52,199 +44,143 @@ interface Props { interface State { additional: boolean; + avatar?: string; + description?: string; + key?: string; + name?: string; + submitting: boolean; + url?: string; } +type ValidState = Pick<State, Exclude<keyof State, RequiredOrganization>> & RequiredOrganization; + export default class OrganizationDetailsStep extends React.PureComponent<Props, State> { - state: State = { additional: false }; + mounted = false; + + constructor(props: Props) { + super(props); + const { organization } = props; + this.state = { + additional: false, + avatar: (organization && organization.avatar) || '', + description: (organization && organization.description) || '', + key: (organization && organization.key) || undefined, + name: (organization && organization.name) || '', + submitting: false, + url: (organization && organization.url) || '' + }; + } - getInitialValues = (): Values => { - const { organization } = this.props; - if (organization) { - return { - avatar: organization.avatar || '', - description: organization.description || '', - name: organization.name, - key: organization.key, - url: organization.url || '' - }; - } else { - return initialValues; - } - }; + componentDidMount() { + this.mounted = true; + } + + componentWillUnmount() { + this.mounted = false; + } + + canSubmit(state: State): state is ValidState { + return Boolean( + state.key !== undefined && + state.name !== undefined && + state.description !== undefined && + state.avatar !== undefined && + state.url !== undefined + ); + } handleAdditionalClick = () => { this.setState(state => ({ additional: !state.additional })); }; - checkFreeKey = (key: string) => { - return getOrganization(key).then(organization => organization === undefined, () => true); + handleKeyUpdate = (key: string | undefined) => { + this.setState({ key }); }; - handleValidate = ({ avatar, name, key, url }: Values) => { - const errors: { [P in keyof Values]?: string } = {}; + handleNameUpdate = (name: string | undefined) => { + this.setState({ name }); + }; - if (avatar.length > 0 && !isWebUri(avatar)) { - errors.avatar = translate('onboarding.create_organization.avatar.error'); - } + handleDescriptionUpdate = (description: string | undefined) => { + this.setState({ description }); + }; - if (name.length > 255) { - errors.name = translate('onboarding.create_organization.display_name.error'); - } + handleAvatarUpdate = (avatar: string | undefined) => { + this.setState({ avatar }); + }; - if (key.length > 255 || !/^[a-z0-9][a-z0-9-]*[a-z0-9]?$/.test(key)) { - errors.key = translate('onboarding.create_organization.organization_name.error'); - } + handleUrlUpdate = (url: string | undefined) => { + this.setState({ url }); + }; - if (url.length > 0 && !isWebUri(url)) { - errors.url = translate('onboarding.create_organization.url.error'); + handleSubmit = (event: React.FormEvent<HTMLFormElement>) => { + event.preventDefault(); + const { state } = this; + if (this.canSubmit(state)) { + this.setState({ submitting: true }); + this.props + .onContinue({ + avatar: state.avatar, + description: state.description, + key: state.key, + name: state.name, + url: state.url + }) + .then(this.stopSubmitting, this.stopSubmitting); } + }; - // don't try to check if the organization key is already taken if the key is invalid - if (errors.key) { - return Promise.reject(errors); + stopSubmitting = () => { + if (this.mounted) { + this.setState({ submitting: false }); } - - // TODO debounce - return this.checkFreeKey(key).then(free => { - if (!free) { - errors.key = translate('onboarding.create_organization.organization_name.taken'); - } - return Object.keys(errors).length ? Promise.reject(errors) : Promise.resolve(errors); - }); }; - renderInnerForm = (props: ChildrenProps<Values>) => { - const { - dirty, - errors, - handleBlur, - handleChange, - isSubmitting, - isValid, - isValidating, - touched, - values - } = props; - const commonProps = { - dirty, - isValidating, - isSubmitting, - onBlur: handleBlur, - onChange: handleChange - }; + renderForm = () => { return ( - <> - <OrganizationDetailsInput - {...commonProps} - description={translate('onboarding.create_organization.organization_name.description')} - error={errors.key} - id="organization-key" - label={translate('onboarding.create_organization.organization_name')} - name="key" - required={true} - touched={touched.key} - value={values.key}> - {props => ( - <div className="display-inline-flex-baseline"> - <span className="little-spacer-right"> - {getHostUrl().replace(/https*:\/\//, '') + '/organizations/'} - </span> - <input autoFocus={true} maxLength={255} {...props} /> - </div> - )} - </OrganizationDetailsInput> - <div className="big-spacer-top"> - <ResetButtonLink onClick={this.handleAdditionalClick}> - {translate( - this.state.additional - ? 'onboarding.create_organization.hide_additional_info' - : 'onboarding.create_organization.add_additional_info' - )} - <DropdownIcon className="little-spacer-left" turned={this.state.additional} /> - </ResetButtonLink> - </div> - <div className="js-additional-info" hidden={!this.state.additional}> - <div className="big-spacer-top"> - <OrganizationDetailsInput - {...commonProps} - description={translate('onboarding.create_organization.display_name.description')} - error={errors.name} - id="organization-display-name" - label={translate('onboarding.create_organization.display_name')} - name="name" - touched={touched.name && values.name !== ''} - value={values.name}> - {props => <input {...props} />} - </OrganizationDetailsInput> - </div> + <div className="boxed-group-inner"> + <form id="organization-form" onSubmit={this.handleSubmit}> + {this.props.description} + <OrganizationKeyInput initialValue={this.state.key} onChange={this.handleKeyUpdate} /> <div className="big-spacer-top"> - <OrganizationDetailsInput - {...commonProps} - description={translate('onboarding.create_organization.avatar.description')} - error={errors.avatar} - id="organization-avatar" - label={translate('onboarding.create_organization.avatar')} - name="avatar" - touched={touched.avatar && values.avatar !== ''} - value={values.avatar}> - {props => ( - <> - {values.avatar && ( - <img - alt="" - className="display-block spacer-bottom rounded" - src={values.avatar} - width={48} - /> - )} - <input {...props} /> - </> + <ResetButtonLink onClick={this.handleAdditionalClick}> + {translate( + this.state.additional + ? 'onboarding.create_organization.hide_additional_info' + : 'onboarding.create_organization.add_additional_info' )} - </OrganizationDetailsInput> + <DropdownIcon className="little-spacer-left" turned={this.state.additional} /> + </ResetButtonLink> </div> - <div className="big-spacer-top"> - <OrganizationDetailsInput - {...commonProps} - error={errors.description} - id="organization-description" - label={translate('description')} - name="description" - touched={touched.description && values.description !== ''} - value={values.description}> - {props => <textarea {...props} maxLength={256} rows={3} />} - </OrganizationDetailsInput> + <div className="js-additional-info" hidden={!this.state.additional}> + <div className="big-spacer-top"> + <OrganizationNameInput + initialValue={this.state.name} + onChange={this.handleNameUpdate} + /> + </div> + <div className="big-spacer-top"> + <OrganizationAvatarInput + initialValue={this.state.avatar} + onChange={this.handleDescriptionUpdate} + /> + </div> + <div className="big-spacer-top"> + <OrganizationDescriptionInput + initialValue={this.state.description} + onChange={this.handleAvatarUpdate} + /> + </div> + <div className="big-spacer-top"> + <OrganizationUrlInput initialValue={this.state.url} onChange={this.handleUrlUpdate} /> + </div> </div> <div className="big-spacer-top"> - <OrganizationDetailsInput - {...commonProps} - error={errors.url} - id="organization-url" - label={translate('onboarding.create_organization.url')} - name="url" - touched={touched.url && values.url !== ''} - value={values.url}> - {props => <input {...props} />} - </OrganizationDetailsInput> + <SubmitButton disabled={this.state.submitting || !this.canSubmit(this.state)}> + {this.props.submitText} + </SubmitButton> </div> - </div> - <div className="big-spacer-top"> - <SubmitButton disabled={isSubmitting || !isValid}>{this.props.submitText}</SubmitButton> - </div> - </> - ); - }; - - renderForm = () => { - return ( - <div className="boxed-group-inner"> - {this.props.description} - <ValidationForm<Values> - initialValues={this.getInitialValues()} - isInitialValid={this.props.organization !== undefined} - onSubmit={this.props.onContinue} - validate={this.handleValidate}> - {this.renderInnerForm} - </ValidationForm> + </form> </div> ); }; diff --git a/server/sonar-web/src/main/js/apps/create/organization/PlanStep.tsx b/server/sonar-web/src/main/js/apps/create/organization/PlanStep.tsx index 5118540ce81..f7b7a0f6a42 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/PlanStep.tsx +++ b/server/sonar-web/src/main/js/apps/create/organization/PlanStep.tsx @@ -19,9 +19,9 @@ */ import * as React from 'react'; import BillingFormShim from './BillingFormShim'; -import { withCurrentUser } from './withCurrentUser'; import PlanSelect, { Plan } from './PlanSelect'; import Step from '../../tutorials/components/Step'; +import { withCurrentUser } from '../../../components/hoc/withCurrentUser'; import { translate } from '../../../helpers/l10n'; import { getExtensionStart } from '../../../app/components/extensions/utils'; import { SubscriptionPlan } from '../../../app/types'; diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/ChooseRemoteOrganizationStep-test.tsx b/server/sonar-web/src/main/js/apps/create/organization/__tests__/ChooseRemoteOrganizationStep-test.tsx index a86ab8dfd11..6721e089b15 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/__tests__/ChooseRemoteOrganizationStep-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/ChooseRemoteOrganizationStep-test.tsx @@ -26,7 +26,7 @@ it('should render', () => { }); it('should display a warning message', () => { - expect(shallowRender({ almInstallId: 'foo' }).find('.alert-warning')).toMatchSnapshot(); + expect(shallowRender({ almInstallId: 'foo' }).find('Alert')).toMatchSnapshot(); }); function shallowRender(props: Partial<ChooseRemoteOrganizationStep['props']> = {}) { diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/OrganizationDetailsStep-test.tsx b/server/sonar-web/src/main/js/apps/create/organization/__tests__/OrganizationDetailsStep-test.tsx index f8748b45aec..ac756ffdc8b 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/__tests__/OrganizationDetailsStep-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/OrganizationDetailsStep-test.tsx @@ -18,9 +18,9 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import { shallow, ShallowWrapper } from 'enzyme'; +import { shallow } from 'enzyme'; import OrganizationDetailsStep from '../OrganizationDetailsStep'; -import { click } from '../../../../helpers/testUtils'; +import { click, submit } from '../../../../helpers/testUtils'; import { getOrganization } from '../../../../api/organizations'; jest.mock('../../../../api/organizations', () => ({ @@ -43,23 +43,24 @@ it('should render form', () => { ); expect(wrapper).toMatchSnapshot(); expect(wrapper.dive()).toMatchSnapshot(); - expect(getForm(wrapper)).toMatchSnapshot(); expect( - getForm(wrapper) + wrapper + .dive() .find('.js-additional-info') .prop('hidden') ).toBe(true); - click(getForm(wrapper).find('ResetButtonLink')); + click(wrapper.dive().find('ResetButtonLink')); wrapper.update(); expect( - getForm(wrapper) + wrapper + .dive() .find('.js-additional-info') .prop('hidden') ).toBe(false); }); -it('should validate', async () => { +it('should validate before submit', () => { const wrapper = shallow( <OrganizationDetailsStep finished={false} @@ -71,77 +72,48 @@ it('should validate', async () => { ); const instance = wrapper.instance() as OrganizationDetailsStep; - await expect( - instance.handleValidate({ + expect( + instance.canSubmit({ + additional: false, avatar: '', description: '', name: '', key: 'foo', + submitting: false, url: '' }) - ).resolves.toEqual({}); + ).toBe(true); - await expect( - instance.handleValidate({ + expect( + instance.canSubmit({ + additional: false, avatar: '', description: '', name: '', - key: 'x'.repeat(256), + key: undefined, + submitting: false, url: '' }) - ).rejects.toEqual({ - key: 'onboarding.create_organization.organization_name.error' - }); + ).toBe(false); - await expect( - instance.handleValidate({ - avatar: 'bla', + expect( + instance.canSubmit({ + additional: false, + avatar: undefined, description: '', name: '', key: 'foo', + submitting: false, url: '' }) - ).rejects.toEqual({ avatar: 'onboarding.create_organization.avatar.error' }); - - await expect( - instance.handleValidate({ - avatar: '', - description: '', - name: 'x'.repeat(256), - key: 'foo', - url: '' - }) - ).rejects.toEqual({ - name: 'onboarding.create_organization.display_name.error' - }); - - await expect( - instance.handleValidate({ - avatar: '', - description: '', - name: '', - key: 'foo', - url: 'bla' - }) - ).rejects.toEqual({ - url: 'onboarding.create_organization.url.error' - }); + ).toBe(false); - (getOrganization as jest.Mock).mockResolvedValue({}); - await expect( - instance.handleValidate({ - avatar: '', - description: '', - name: '', - key: 'foo', - url: '' - }) - ).rejects.toEqual({ - key: 'onboarding.create_organization.organization_name.taken' - }); + instance.canSubmit = jest.fn() as any; + submit(wrapper.dive().find('form')); + expect(instance.canSubmit).toHaveBeenCalled(); }); -it('should render result', () => { +it.only('should render result', () => { const wrapper = shallow( <OrganizationDetailsStep finished={true} @@ -152,14 +124,11 @@ it('should render result', () => { submitText="continue" /> ); - expect(wrapper.dive()).toMatchSnapshot(); + expect(wrapper.dive().find('.boxed-group-actions')).toMatchSnapshot(); + expect( + wrapper + .dive() + .find('.hidden') + .exists() + ).toBe(true); }); - -function getForm(wrapper: ShallowWrapper) { - return wrapper - .dive() - .find('ValidationForm') - .dive() - .dive() - .children(); -} diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/ChooseRemoteOrganizationStep-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/ChooseRemoteOrganizationStep-test.tsx.snap index ec99dad98ea..3a79e945db1 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/ChooseRemoteOrganizationStep-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/ChooseRemoteOrganizationStep-test.tsx.snap @@ -1,8 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`should display a warning message 1`] = ` -<span - className="alert alert-warning markdown big-spacer-bottom width-60" +<Alert + className="markdown big-spacer-bottom width-60" + variant="warning" > onboarding.create_organization.import_org_not_found <ul> @@ -13,7 +14,7 @@ exports[`should display a warning message 1`] = ` onboarding.create_organization.import_org_not_found.tips_2 </li> </ul> -</span> +</Alert> `; exports[`should render 1`] = ` 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 f2da7e6a62c..c4b506bc1f3 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 @@ -52,7 +52,7 @@ exports[`should render with auto tab displayed 1`] = ` "node": <React.Fragment> onboarding.create_organization.import_organization.github <span - className="rounded alert alert-small spacer-left display-inline-block alert-info" + className="beta-badge spacer-left" > beta </span> @@ -134,7 +134,7 @@ exports[`should render with auto tab selected and manual disabled 1`] = ` "node": <React.Fragment> onboarding.create_organization.import_organization.github <span - className="rounded alert alert-small spacer-left display-inline-block alert-info" + className="beta-badge spacer-left" > beta </span> @@ -288,7 +288,7 @@ exports[`should switch tabs 1`] = ` "node": <React.Fragment> onboarding.create_organization.import_organization.github <span - className="rounded alert alert-small spacer-left display-inline-block alert-info" + className="beta-badge spacer-left" > beta </span> diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/OrganizationDetailsInput-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/OrganizationDetailsInput-test.tsx.snap deleted file mode 100644 index b8bd98adf5b..00000000000 --- a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/OrganizationDetailsInput-test.tsx.snap +++ /dev/null @@ -1,31 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`should render 1`] = ` -<div> - <label - htmlFor="field" - > - <strong> - Label - </strong> - <em - className="mandatory" - > - * - </em> - </label> - <div - className="little-spacer-top spacer-bottom" - > - <div /> - <AlertErrorIcon - className="spacer-left text-middle" - /> - <span - className="little-spacer-left text-danger text-middle" - > - This field is bad! - </span> - </div> -</div> -`; diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/OrganizationDetailsStep-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/OrganizationDetailsStep-test.tsx.snap index a52c598379d..169967f9e4f 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/OrganizationDetailsStep-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/OrganizationDetailsStep-test.tsx.snap @@ -34,6 +34,7 @@ exports[`should render form 2`] = ` <div className="boxed-group-inner" > +<<<<<<< HEAD <ValidationForm initialValues={ Object { @@ -172,65 +173,91 @@ exports[`should render form 3`] = ` </SubmitButton> </div> </form> +======= + <form + id="organization-form" + onSubmit={[Function]} + > + <OrganizationKeyInput + onChange={[Function]} + /> + <div + className="big-spacer-top" + > + <ResetButtonLink + onClick={[Function]} + > + onboarding.create_organization.add_additional_info + <DropdownIcon + className="little-spacer-left" + turned={false} + /> + </ResetButtonLink> + </div> + <div + className="js-additional-info" + hidden={true} + > + <div + className="big-spacer-top" + > + <OrganizationNameInput + initialOrgName="" + onChange={[Function]} + /> + </div> + <div + className="big-spacer-top" + > + <OrganizationAvatarInput + initialOrgAvatar="" + onChange={[Function]} + /> + </div> + <div + className="big-spacer-top" + > + <OrganizationDescriptionInput + initialOrgDescription="" + onChange={[Function]} + /> + </div> + <div + className="big-spacer-top" + > + <OrganizationUrlInput + initialOrgUrl="" + onChange={[Function]} + /> + </div> + </div> + <div + className="big-spacer-top" + > + <SubmitButton + disabled={true} + > + continue + </SubmitButton> + </div> + </form> + </div> + </div> +</div> +>>>>>>> 116a4ec872... SONAR-11322 Import repos from bound organizations `; exports[`should render result 1`] = ` <div - className="boxed-group onboarding-step is-finished" - onClick={[Function]} - role="button" - tabIndex={0} + className="boxed-group-actions display-flex-center" > - <div - className="onboarding-step-number" - > - 1 - </div> - <div - className="boxed-group-actions display-flex-center" - > - <AlertSuccessIcon - className="spacer-right" - /> - <strong - className="text-limited" - > - org - </strong> - </div> - <div - className="boxed-group-header" - > - <h2> - onboarding.create_organization.enter_org_details - </h2> - </div> - <div - className="boxed-group-inner" + <AlertSuccessIcon + className="spacer-right" /> - <div - className="hidden" + <strong + className="text-limited" > - <div - className="boxed-group-inner" - > - <ValidationForm - initialValues={ - Object { - "avatar": "", - "description": "", - "key": "org", - "name": "Organization", - "url": "", - } - } - isInitialValid={true} - onSubmit={[MockFunction]} - validate={[Function]} - > - <Component /> - </ValidationForm> - </div> - </div> + org + </strong> </div> `; diff --git a/server/sonar-web/src/main/js/apps/create/organization/components/OrganizationAvatarInput.tsx b/server/sonar-web/src/main/js/apps/create/organization/components/OrganizationAvatarInput.tsx new file mode 100644 index 00000000000..7d02df3735c --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/components/OrganizationAvatarInput.tsx @@ -0,0 +1,111 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import * as classNames from 'classnames'; +import { isWebUri } from 'valid-url'; +import ValidationInput from '../../../../components/controls/ValidationInput'; +import { translate } from '../../../../helpers/l10n'; +import OrganizationAvatar from '../../../../components/common/OrganizationAvatar'; + +interface Props { + initialValue?: string; + name?: string; + onChange: (value: string | undefined) => void; +} + +interface State { + editing: boolean; + error?: string; + touched: boolean; + value: string; +} + +export default class OrganizationAvatarInput extends React.PureComponent<Props, State> { + state: State = { error: undefined, editing: false, touched: false, value: '' }; + + componentDidMount() { + if (this.props.initialValue) { + const value = this.props.initialValue; + const error = this.validateUrl(value); + this.setState({ error, touched: Boolean(error), value }); + } + } + + handleChange = (event: React.ChangeEvent<HTMLInputElement>) => { + const value = event.currentTarget.value.trim(); + const error = this.validateUrl(value); + this.setState({ error, touched: true, value }); + this.props.onChange(error === undefined ? value : undefined); + }; + + handleBlur = () => { + this.setState({ editing: false }); + }; + + handleFocus = () => { + this.setState({ editing: true }); + }; + + validateUrl(url: string) { + if (url.length > 0 && !isWebUri(url)) { + return translate('onboarding.create_organization.url.error'); + } + return undefined; + } + + render() { + const isInvalid = this.state.touched && !this.state.editing && this.state.error !== undefined; + const isValidUrl = this.state.error === undefined && this.state.value !== ''; + const isValid = this.state.touched && isValidUrl; + return ( + <ValidationInput + description={translate('onboarding.create_organization.avatar.description')} + error={this.state.error} + id="organization-avatar" + isInvalid={isInvalid} + isValid={isValid} + label={translate('onboarding.create_organization.avatar')}> + <> + {(isValidUrl || this.props.name) && ( + <OrganizationAvatar + className="display-block spacer-bottom" + organization={{ + avatar: isValidUrl ? this.state.value : undefined, + name: this.props.name || '' + }} + /> + )} + <input + className={classNames('input-super-large', 'text-middle', { + 'is-invalid': isInvalid, + 'is-valid': isValid + })} + id="organization-display-name" + onBlur={this.handleBlur} + onChange={this.handleChange} + onFocus={this.handleFocus} + type="text" + value={this.state.value} + /> + </> + </ValidationInput> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/create/organization/components/OrganizationDescriptionInput.tsx b/server/sonar-web/src/main/js/apps/create/organization/components/OrganizationDescriptionInput.tsx new file mode 100644 index 00000000000..eaea25f97ad --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/components/OrganizationDescriptionInput.tsx @@ -0,0 +1,95 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import * as classNames from 'classnames'; +import ValidationInput from '../../../../components/controls/ValidationInput'; +import { translate } from '../../../../helpers/l10n'; + +interface Props { + initialValue?: string; + onChange: (value: string | undefined) => void; +} + +interface State { + editing: boolean; + error?: string; + touched: boolean; + value: string; +} + +export default class OrganizationDescriptionInput extends React.PureComponent<Props, State> { + state: State = { error: undefined, editing: false, touched: false, value: '' }; + + componentDidMount() { + if (this.props.initialValue) { + const error = this.validateDescription(this.props.initialValue); + this.setState({ error, touched: Boolean(error), value: this.props.initialValue }); + } + } + + handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => { + const { value } = event.currentTarget; + const error = this.validateDescription(value); + this.setState({ error, touched: true, value }); + this.props.onChange(error === undefined ? value : undefined); + }; + + handleBlur = () => { + this.setState({ editing: false }); + }; + + handleFocus = () => { + this.setState({ editing: true }); + }; + + validateDescription(description: string) { + if (description.length > 256) { + return translate('onboarding.create_organization.description.error'); + } + return undefined; + } + + render() { + const isInvalid = this.state.touched && !this.state.editing && this.state.error !== undefined; + const isValid = this.state.touched && this.state.error === undefined && this.state.value !== ''; + return ( + <ValidationInput + error={this.state.error} + id="organization-display-name" + isInvalid={isInvalid} + isValid={isValid} + label={translate('onboarding.create_organization.description')}> + <textarea + className={classNames('input-super-large', 'text-middle', { + 'is-invalid': isInvalid, + 'is-valid': isValid + })} + id="organization-description" + maxLength={256} + onBlur={this.handleBlur} + onChange={this.handleChange} + onFocus={this.handleFocus} + rows={3} + value={this.state.value} + /> + </ValidationInput> + ); + } +} 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 new file mode 100644 index 00000000000..0fd3c61b35a --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/components/OrganizationKeyInput.tsx @@ -0,0 +1,144 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import * as classNames from 'classnames'; +import { debounce } from 'lodash'; +import { getOrganization } from '../../../../api/organizations'; +import ValidationInput from '../../../../components/controls/ValidationInput'; +import { translate } from '../../../../helpers/l10n'; +import { getHostUrl } from '../../../../helpers/urls'; + +interface Props { + initialValue?: string; + onChange: (value: string | undefined) => void; +} + +interface State { + editing: boolean; + error?: string; + touched: boolean; + validating: boolean; + value: string; +} + +export default class OrganizationKeyInput extends React.PureComponent<Props, State> { + mounted = false; + constructor(props: Props) { + super(props); + this.state = { error: undefined, editing: false, touched: false, validating: false, value: '' }; + this.checkFreeKey = debounce(this.checkFreeKey, 250); + } + + componentDidMount() { + this.mounted = true; + if (this.props.initialValue !== undefined) { + this.setState({ value: this.props.initialValue }); + this.validateKey(this.props.initialValue); + } + } + + componentWillUnmount() { + this.mounted = false; + } + + checkFreeKey = (key: string) => { + this.setState({ validating: true }); + return getOrganization(key) + .then(organization => organization === undefined, () => true) + .then( + free => { + if (this.mounted) { + if (!free) { + this.setState({ + error: translate('onboarding.create_organization.organization_name.taken'), + touched: true, + validating: false + }); + this.props.onChange(undefined); + } else { + this.setState({ error: undefined, validating: false }); + this.props.onChange(key); + } + } + }, + () => {} + ); + }; + + handleChange = (event: React.ChangeEvent<HTMLInputElement>) => { + const { value } = event.currentTarget; + this.setState({ touched: true, value }); + this.validateKey(value); + }; + + handleBlur = () => { + this.setState({ editing: false }); + }; + + handleFocus = () => { + this.setState({ editing: true }); + }; + + validateKey(key: string) { + if (key.length > 255 || !/^[a-z0-9][a-z0-9-]*[a-z0-9]?$/.test(key)) { + this.setState({ + error: translate('onboarding.create_organization.organization_name.error'), + touched: true + }); + this.props.onChange(undefined); + } else { + this.checkFreeKey(key); + } + } + + render() { + const isInvalid = this.state.touched && !this.state.editing && this.state.error !== undefined; + const isValid = this.state.touched && !this.state.validating && this.state.error === undefined; + return ( + <ValidationInput + error={this.state.error} + id="organization-key" + isInvalid={isInvalid} + isValid={isValid} + label={translate('onboarding.create_organization.organization_name')} + required={true}> + <div className="display-inline-flex-baseline"> + <span className="little-spacer-right"> + {getHostUrl().replace(/https*:\/\//, '') + '/organizations/'} + </span> + <input + autoFocus={true} + className={classNames('input-super-large', 'text-middle', { + 'is-invalid': isInvalid, + 'is-valid': isValid + })} + id="organization-key" + maxLength={255} + onBlur={this.handleBlur} + onChange={this.handleChange} + onFocus={this.handleFocus} + type="text" + value={this.state.value} + /> + </div> + </ValidationInput> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/create/organization/components/OrganizationNameInput.tsx b/server/sonar-web/src/main/js/apps/create/organization/components/OrganizationNameInput.tsx new file mode 100644 index 00000000000..9e50b0cbb2e --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/components/OrganizationNameInput.tsx @@ -0,0 +1,96 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import * as classNames from 'classnames'; +import ValidationInput from '../../../../components/controls/ValidationInput'; +import { translate } from '../../../../helpers/l10n'; + +interface Props { + initialValue?: string; + onChange: (value: string | undefined) => void; +} + +interface State { + editing: boolean; + error?: string; + touched: boolean; + value: string; +} + +export default class OrganizationNameInput extends React.PureComponent<Props, State> { + state: State = { error: undefined, editing: false, touched: false, value: '' }; + + componentDidMount() { + if (this.props.initialValue) { + const error = this.validateName(this.props.initialValue); + this.setState({ error, touched: Boolean(error), value: this.props.initialValue }); + } + } + + handleChange = (event: React.ChangeEvent<HTMLInputElement>) => { + const { value } = event.currentTarget; + const error = this.validateName(value); + this.setState({ error, touched: true, value }); + this.props.onChange(error === undefined ? value : undefined); + }; + + handleBlur = () => { + this.setState({ editing: false }); + }; + + handleFocus = () => { + this.setState({ editing: true }); + }; + + validateName(name: string) { + if (name.length > 255) { + return translate('onboarding.create_organization.display_name.error'); + } + return undefined; + } + + render() { + const isInvalid = this.state.touched && !this.state.editing && this.state.error !== undefined; + const isValid = this.state.touched && this.state.error === undefined && this.state.value !== ''; + return ( + <ValidationInput + description={translate('onboarding.create_organization.display_name.description')} + error={this.state.error} + id="organization-display-name" + isInvalid={isInvalid} + isValid={isValid} + label={translate('onboarding.create_organization.display_name')}> + <input + className={classNames('input-super-large', 'text-middle', { + 'is-invalid': isInvalid, + 'is-valid': isValid + })} + id="organization-display-name" + maxLength={255} + onBlur={this.handleBlur} + onChange={this.handleChange} + onFocus={this.handleFocus} + type="text" + value={this.state.value} + /> + </ValidationInput> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/create/organization/components/OrganizationUrlInput.tsx b/server/sonar-web/src/main/js/apps/create/organization/components/OrganizationUrlInput.tsx new file mode 100644 index 00000000000..a77bdc99832 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/components/OrganizationUrlInput.tsx @@ -0,0 +1,96 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import * as classNames from 'classnames'; +import { isWebUri } from 'valid-url'; +import ValidationInput from '../../../../components/controls/ValidationInput'; +import { translate } from '../../../../helpers/l10n'; + +interface Props { + initialValue?: string; + onChange: (value: string | undefined) => void; +} + +interface State { + editing: boolean; + error?: string; + touched: boolean; + value: string; +} + +export default class OrganizationUrlInput extends React.PureComponent<Props, State> { + state: State = { error: undefined, editing: false, touched: false, value: '' }; + + componentDidMount() { + if (this.props.initialValue) { + const value = this.props.initialValue; + const error = this.validateUrl(value); + this.setState({ error, touched: Boolean(error), value }); + } + } + + handleChange = (event: React.ChangeEvent<HTMLInputElement>) => { + const value = event.currentTarget.value.trim(); + const error = this.validateUrl(value); + this.setState({ error, touched: true, value }); + this.props.onChange(error === undefined ? value : undefined); + }; + + handleBlur = () => { + this.setState({ editing: false }); + }; + + handleFocus = () => { + this.setState({ editing: true }); + }; + + validateUrl(url: string) { + if (url.length > 0 && !isWebUri(url)) { + return translate('onboarding.create_organization.url.error'); + } + return undefined; + } + + render() { + const isInvalid = this.state.touched && !this.state.editing && this.state.error !== undefined; + const isValid = this.state.touched && this.state.error === undefined && this.state.value !== ''; + return ( + <ValidationInput + error={this.state.error} + id="organization-url" + isInvalid={isInvalid} + isValid={isValid} + label={translate('onboarding.create_organization.url')}> + <input + className={classNames('input-super-large', 'text-middle', { + 'is-invalid': isInvalid, + 'is-valid': isValid + })} + id="organization-url" + onBlur={this.handleBlur} + onChange={this.handleChange} + onFocus={this.handleFocus} + type="text" + value={this.state.value} + /> + </ValidationInput> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/create/organization/components/__tests__/OrganizationAvatarInput-test.tsx b/server/sonar-web/src/main/js/apps/create/organization/components/__tests__/OrganizationAvatarInput-test.tsx new file mode 100644 index 00000000000..c7d7c24d01e --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/components/__tests__/OrganizationAvatarInput-test.tsx @@ -0,0 +1,45 @@ +/* + * 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 OrganizationAvatarInput from '../OrganizationAvatarInput'; + +it('should render correctly', () => { + const wrapper = shallow( + <OrganizationAvatarInput initialValue="https://my.avatar" onChange={jest.fn()} /> + ); + expect(wrapper).toMatchSnapshot(); + wrapper.setState({ touched: true }); + expect(wrapper.find('ValidationInput').prop('isValid')).toMatchSnapshot(); +}); + +it('should have an error when the avatar url is not valid', () => { + expect( + shallow(<OrganizationAvatarInput initialValue="whatever" onChange={jest.fn()} />) + .find('ValidationInput') + .prop('isInvalid') + ).toBe(true); +}); + +it('should display the fallback avatar when there is no url', () => { + expect( + shallow(<OrganizationAvatarInput initialValue="" name="Luke Skywalker" onChange={jest.fn()} />) + ).toMatchSnapshot(); +}); diff --git a/server/sonar-web/src/main/js/apps/create/organization/components/__tests__/OrganizationDescriptionInput-test.tsx b/server/sonar-web/src/main/js/apps/create/organization/components/__tests__/OrganizationDescriptionInput-test.tsx new file mode 100644 index 00000000000..eab1e2ca818 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/components/__tests__/OrganizationDescriptionInput-test.tsx @@ -0,0 +1,39 @@ +/* + * 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 OrganizationDescriptionInput from '../OrganizationDescriptionInput'; + +it('should render correctly', () => { + const wrapper = shallow( + <OrganizationDescriptionInput initialValue="My description" onChange={jest.fn()} /> + ); + expect(wrapper).toMatchSnapshot(); + wrapper.setState({ touched: true }); + expect(wrapper.find('ValidationInput').prop('isValid')).toMatchSnapshot(); +}); + +it('should have an error when description is too long', () => { + expect( + shallow(<OrganizationDescriptionInput initialValue={'x'.repeat(260)} onChange={jest.fn()} />) + .find('ValidationInput') + .prop('isInvalid') + ).toBe(true); +}); 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 new file mode 100644 index 00000000000..a6bcde51a7e --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/components/__tests__/OrganizationKeyInput-test.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 { shallow } from 'enzyme'; +import OrganizationKeyInput from '../OrganizationKeyInput'; +import { getOrganization } from '../../../../../api/organizations'; +import { waitAndUpdate } from '../../../../../helpers/testUtils'; + +jest.mock('../../../../../api/organizations', () => ({ + getOrganization: jest.fn().mockResolvedValue(undefined) +})); + +beforeEach(() => { + (getOrganization as jest.Mock<any>).mockClear(); +}); + +it('should render correctly', () => { + const wrapper = shallow(<OrganizationKeyInput initialValue="key" onChange={jest.fn()} />); + expect(wrapper).toMatchSnapshot(); + wrapper.setState({ touched: true }); + expect(wrapper.find('ValidationInput').prop('isValid')).toMatchSnapshot(); +}); + +it('should not display any status when the key is not defined', async () => { + const wrapper = shallow(<OrganizationKeyInput onChange={jest.fn()} />); + await waitAndUpdate(wrapper); + expect(wrapper.find('ValidationInput').prop('isInvalid')).toBe(false); + expect(wrapper.find('ValidationInput').prop('isValid')).toBe(false); +}); + +it('should have an error when the key is invalid', async () => { + const wrapper = shallow( + <OrganizationKeyInput initialValue="KEy-with#speci@l_char" onChange={jest.fn()} /> + ); + await waitAndUpdate(wrapper); + expect(wrapper.find('ValidationInput').prop('isInvalid')).toBe(true); +}); + +it('should have an error when the key already exists', async () => { + (getOrganization as jest.Mock<any>).mockResolvedValue({}); + const wrapper = shallow(<OrganizationKeyInput initialValue="" onChange={jest.fn()} />); + await waitAndUpdate(wrapper); + expect(wrapper.find('ValidationInput').prop('isInvalid')).toBe(true); +}); diff --git a/server/sonar-web/src/main/js/apps/create/organization/components/__tests__/OrganizationNameInput-test.tsx b/server/sonar-web/src/main/js/apps/create/organization/components/__tests__/OrganizationNameInput-test.tsx new file mode 100644 index 00000000000..ecbfdb1f190 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/components/__tests__/OrganizationNameInput-test.tsx @@ -0,0 +1,37 @@ +/* + * 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 OrganizationNameInput from '../OrganizationNameInput'; + +it('should render correctly', () => { + const wrapper = shallow(<OrganizationNameInput initialValue="Org Name" onChange={jest.fn()} />); + expect(wrapper).toMatchSnapshot(); + wrapper.setState({ touched: true }); + expect(wrapper.find('ValidationInput').prop('isValid')).toMatchSnapshot(); +}); + +it('should have an error when description is too long', () => { + expect( + shallow(<OrganizationNameInput initialValue={'x'.repeat(256)} onChange={jest.fn()} />) + .find('ValidationInput') + .prop('isInvalid') + ).toBe(true); +}); diff --git a/server/sonar-web/src/main/js/apps/create/organization/components/__tests__/OrganizationUrlInput-test.tsx b/server/sonar-web/src/main/js/apps/create/organization/components/__tests__/OrganizationUrlInput-test.tsx new file mode 100644 index 00000000000..357a912eda1 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/components/__tests__/OrganizationUrlInput-test.tsx @@ -0,0 +1,39 @@ +/* + * 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 OrganizationUrlInput from '../OrganizationUrlInput'; + +it('should render correctly', () => { + const wrapper = shallow( + <OrganizationUrlInput initialValue="http://my.website" onChange={jest.fn()} /> + ); + expect(wrapper).toMatchSnapshot(); + wrapper.setState({ touched: true }); + expect(wrapper.find('ValidationInput').prop('isValid')).toMatchSnapshot(); +}); + +it('should have an error when the url is invalid', () => { + expect( + shallow(<OrganizationUrlInput initialValue="whatever" onChange={jest.fn()} />) + .find('ValidationInput') + .prop('isInvalid') + ).toBe(true); +}); diff --git a/server/sonar-web/src/main/js/apps/create/organization/components/__tests__/__snapshots__/OrganizationAvatarInput-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/organization/components/__tests__/__snapshots__/OrganizationAvatarInput-test.tsx.snap new file mode 100644 index 00000000000..292c7b24b87 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/components/__tests__/__snapshots__/OrganizationAvatarInput-test.tsx.snap @@ -0,0 +1,61 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should display the fallback avatar when there is no url 1`] = ` +<ValidationInput + description="onboarding.create_organization.avatar.description" + id="organization-avatar" + isInvalid={false} + isValid={false} + label="onboarding.create_organization.avatar" +> + <OrganizationAvatar + className="display-block spacer-bottom" + organization={ + Object { + "avatar": undefined, + "name": "Luke Skywalker", + } + } + /> + <input + className="input-super-large text-middle" + id="organization-display-name" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + type="text" + value="" + /> +</ValidationInput> +`; + +exports[`should render correctly 1`] = ` +<ValidationInput + description="onboarding.create_organization.avatar.description" + id="organization-avatar" + isInvalid={false} + isValid={false} + label="onboarding.create_organization.avatar" +> + <OrganizationAvatar + className="display-block spacer-bottom" + organization={ + Object { + "avatar": "https://my.avatar", + "name": "", + } + } + /> + <input + className="input-super-large text-middle" + id="organization-display-name" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + type="text" + value="https://my.avatar" + /> +</ValidationInput> +`; + +exports[`should render correctly 2`] = `true`; diff --git a/server/sonar-web/src/main/js/apps/create/organization/components/__tests__/__snapshots__/OrganizationDescriptionInput-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/organization/components/__tests__/__snapshots__/OrganizationDescriptionInput-test.tsx.snap new file mode 100644 index 00000000000..80e11c04f11 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/components/__tests__/__snapshots__/OrganizationDescriptionInput-test.tsx.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` +<ValidationInput + id="organization-display-name" + isInvalid={false} + isValid={false} + label="onboarding.create_organization.description" +> + <textarea + className="input-super-large text-middle" + id="organization-description" + maxLength={256} + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + rows={3} + value="My description" + /> +</ValidationInput> +`; + +exports[`should render correctly 2`] = `true`; 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 new file mode 100644 index 00000000000..8cba7d969a3 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/components/__tests__/__snapshots__/OrganizationKeyInput-test.tsx.snap @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` +<ValidationInput + id="organization-key" + isInvalid={false} + isValid={false} + label="onboarding.create_organization.organization_name" + required={true} +> + <div + className="display-inline-flex-baseline" + > + <span + className="little-spacer-right" + > + localhost/organizations/ + </span> + <input + autoFocus={true} + className="input-super-large text-middle" + id="organization-key" + maxLength={255} + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + type="text" + value="key" + /> + </div> +</ValidationInput> +`; + +exports[`should render correctly 2`] = `true`; diff --git a/server/sonar-web/src/main/js/apps/create/organization/components/__tests__/__snapshots__/OrganizationNameInput-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/organization/components/__tests__/__snapshots__/OrganizationNameInput-test.tsx.snap new file mode 100644 index 00000000000..1af9dc98684 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/components/__tests__/__snapshots__/OrganizationNameInput-test.tsx.snap @@ -0,0 +1,24 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` +<ValidationInput + description="onboarding.create_organization.display_name.description" + id="organization-display-name" + isInvalid={false} + isValid={false} + label="onboarding.create_organization.display_name" +> + <input + className="input-super-large text-middle" + id="organization-display-name" + maxLength={255} + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + type="text" + value="Org Name" + /> +</ValidationInput> +`; + +exports[`should render correctly 2`] = `true`; diff --git a/server/sonar-web/src/main/js/apps/create/organization/components/__tests__/__snapshots__/OrganizationUrlInput-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/organization/components/__tests__/__snapshots__/OrganizationUrlInput-test.tsx.snap new file mode 100644 index 00000000000..d3f571b4db8 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/organization/components/__tests__/__snapshots__/OrganizationUrlInput-test.tsx.snap @@ -0,0 +1,22 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` +<ValidationInput + id="organization-url" + isInvalid={false} + isValid={false} + label="onboarding.create_organization.url" +> + <input + className="input-super-large text-middle" + id="organization-url" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + type="text" + value="http://my.website" + /> +</ValidationInput> +`; + +exports[`should render correctly 2`] = `true`; diff --git a/server/sonar-web/src/main/js/apps/projects/create/AlmRepositoryItem.tsx b/server/sonar-web/src/main/js/apps/create/project/AlmRepositoryItem.tsx index 8fd083fb222..8fd083fb222 100644 --- a/server/sonar-web/src/main/js/apps/projects/create/AlmRepositoryItem.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/AlmRepositoryItem.tsx diff --git a/server/sonar-web/src/main/js/apps/create/project/AutoProjectCreate.tsx b/server/sonar-web/src/main/js/apps/create/project/AutoProjectCreate.tsx new file mode 100644 index 00000000000..4460f222b8f --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/AutoProjectCreate.tsx @@ -0,0 +1,99 @@ +/* + * 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 RemoteRepositories from './RemoteRepositories'; +import OrganizationSelect from './OrganizationSelect'; +import IdentityProviderLink from '../../../components/ui/IdentityProviderLink'; +import { AlmApplication, Organization } from '../../../app/types'; +import { translate } from '../../../helpers/l10n'; + +interface Props { + almApplication: AlmApplication; + boundOrganizations: Organization[]; + onProjectCreate: (projectKeys: string[]) => void; + organization?: string; +} + +interface State { + selectedOrganization: string; +} + +export default class AutoProjectCreate extends React.PureComponent<Props, State> { + constructor(props: Props) { + super(props); + this.state = { selectedOrganization: this.getInitialSelectedOrganization(props) }; + } + + getInitialSelectedOrganization(props: Props) { + const organization = + props.organization && props.boundOrganizations.find(o => o.key === props.organization); + if (organization) { + return organization.key; + } + if (props.boundOrganizations.length === 1) { + return props.boundOrganizations[0].key; + } + return ''; + } + + handleOrganizationSelect = ({ key }: Organization) => { + this.setState({ selectedOrganization: key }); + }; + + render() { + const { almApplication, boundOrganizations, onProjectCreate } = this.props; + + if (boundOrganizations.length === 0) { + return ( + <> + <IdentityProviderLink + className="display-inline-block" + identityProvider={almApplication} + small={true} + url={almApplication.installationUrl}> + {translate( + 'onboarding.create_organization.choose_organization_button', + almApplication.key + )} + </IdentityProviderLink> + </> + ); + } + + const { selectedOrganization } = this.state; + return ( + <> + <OrganizationSelect + autoImport={true} + onChange={this.handleOrganizationSelect} + organization={selectedOrganization} + organizations={this.props.boundOrganizations} + /> + {selectedOrganization && ( + <RemoteRepositories + almApplication={almApplication} + onProjectCreate={onProjectCreate} + organization={selectedOrganization} + /> + )} + </> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/projects/create/CreateProjectPage.tsx b/server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx index f45dccd05d7..d46f7bbaa36 100644 --- a/server/sonar-web/src/main/js/apps/projects/create/CreateProjectPage.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx @@ -20,70 +20,64 @@ import * as React from 'react'; import * as classNames from 'classnames'; import { connect } from 'react-redux'; -import { InjectedRouter } from 'react-router'; -import { Location } from 'history'; +import { WithRouterProps } from 'react-router'; import Helmet from 'react-helmet'; import AutoProjectCreate from './AutoProjectCreate'; import ManualProjectCreate from './ManualProjectCreate'; -import { serializeQuery, Query, parseQuery } from './utils'; import DeferredSpinner from '../../../components/common/DeferredSpinner'; import Tabs from '../../../components/controls/Tabs'; -import handleRequiredAuthentication from '../../../app/utils/handleRequiredAuthentication'; -import { getCurrentUser, Store } from '../../../store/rootReducer'; -import { addGlobalErrorMessage } from '../../../store/globalMessages'; +import { whenLoggedIn } from '../../../components/hoc/whenLoggedIn'; +import { fetchMyOrganizations } from '../../account/organizations/actions'; +import { getMyOrganizations, Store } from '../../../store/rootReducer'; import { skipOnboarding as skipOnboardingAction } from '../../../store/users'; -import { CurrentUser, IdentityProvider, LoggedInUser } from '../../../app/types'; -import { skipOnboarding, getIdentityProviders } from '../../../api/users'; +import { LoggedInUser, AlmApplication, Organization } from '../../../app/types'; +import { getAlmAppInfo } from '../../../api/alm-integration'; +import { skipOnboarding } from '../../../api/users'; import { hasAdvancedALMIntegration } from '../../../helpers/almIntegrations'; import { translate } from '../../../helpers/l10n'; import { getProjectUrl } from '../../../helpers/urls'; -import { isLoggedIn } from '../../../helpers/users'; import '../../../app/styles/sonarcloud.css'; -interface OwnProps { - location: Location; - router: Pick<InjectedRouter, 'push' | 'replace'>; -} - interface StateProps { - currentUser: CurrentUser; + userOrganizations: Organization[]; } -interface DispatchProps { - addGlobalErrorMessage: (message: string) => void; +interface Props { + currentUser: LoggedInUser; + fetchMyOrganizations: () => Promise<void>; skipOnboardingAction: () => void; } -type Props = StateProps & DispatchProps & OwnProps; - interface State { - identityProvider?: IdentityProvider; + almApplication?: AlmApplication; loading: boolean; } -export class CreateProjectPage extends React.PureComponent<Props, State> { +type TabKeys = 'auto' | 'manual'; + +interface LocationState { + organization?: string; + tab?: TabKeys; +} + +export class CreateProjectPage extends React.PureComponent< + Props & StateProps & WithRouterProps, + State +> { mounted = false; state: State = { loading: true }; componentDidMount() { - if (isLoggedIn(this.props.currentUser)) { - this.mounted = true; - const query = parseQuery(this.props.location.query); - if (query.error) { - this.props.addGlobalErrorMessage(query.error); - } - if (!hasAdvancedALMIntegration(this.props.currentUser)) { - this.setState({ loading: false }); - this.updateQuery({ manual: true }); - } else { - this.fetchIdentityProviders(); - } - document.body.classList.add('white-page'); - if (document.documentElement) { - document.documentElement.classList.add('white-page'); - } + this.mounted = true; + this.props.fetchMyOrganizations(); + if (hasAdvancedALMIntegration(this.props.currentUser)) { + this.fetchAlmApplication(); } else { - handleRequiredAuthentication(); + this.setState({ loading: false }); + } + document.body.classList.add('white-page'); + if (document.documentElement) { + document.documentElement.classList.add('white-page'); } } @@ -105,17 +99,11 @@ export class CreateProjectPage extends React.PureComponent<Props, State> { } }; - fetchIdentityProviders = () => { - getIdentityProviders().then( - ({ identityProviders }) => { + fetchAlmApplication = () => { + return getAlmAppInfo().then( + ({ application }) => { if (this.mounted) { - this.setState({ - identityProvider: identityProviders.find( - identityProvider => - identityProvider.key === (this.props.currentUser as LoggedInUser).externalProvider - ), - loading: false - }); + this.setState({ almApplication: application, loading: false }); } }, () => { @@ -126,28 +114,25 @@ export class CreateProjectPage extends React.PureComponent<Props, State> { ); }; - onTabChange = (tab: 'auto' | 'manual') => { - this.updateQuery({ manual: tab === 'manual' }); + onTabChange = (tab: TabKeys) => { + this.updateUrl({ tab }); }; - updateQuery = (changes: Partial<Query>) => { + updateUrl = (state: Partial<LocationState> = {}) => { this.props.router.replace({ pathname: this.props.location.pathname, - query: serializeQuery({ ...parseQuery(this.props.location.query), ...changes }) + query: this.props.location.query, + state: { ...(this.props.location.state || {}), ...state } }); }; render() { - const { currentUser } = this.props; - - if (!isLoggedIn(currentUser)) { - return null; - } - - const { identityProvider, loading } = this.state; - const query = parseQuery(this.props.location.query); + const { currentUser, location, userOrganizations } = this.props; + const { almApplication, loading } = this.state; + const state: LocationState = location.state || {}; const header = translate('onboarding.create_project.header'); - const hasAutoProvisioning = hasAdvancedALMIntegration(currentUser) && identityProvider; + const showManualTab = state.tab === 'manual'; + return ( <> <Helmet title={header} titleTemplate="%s" /> @@ -159,10 +144,10 @@ export class CreateProjectPage extends React.PureComponent<Props, State> { <DeferredSpinner /> ) : ( <> - {hasAutoProvisioning && ( - <Tabs + {almApplication && ( + <Tabs<TabKeys> onChange={this.onTabChange} - selected={query.manual ? 'manual' : 'auto'} + selected={state.tab || 'auto'} tabs={[ { key: 'auto', @@ -171,7 +156,7 @@ export class CreateProjectPage extends React.PureComponent<Props, State> { {translate('onboarding.create_project.select_repositories')} <span className={classNames('beta-badge spacer-left', { - 'is-muted': query.manual + 'is-muted': showManualTab })}> {translate('beta')} </span> @@ -183,16 +168,19 @@ export class CreateProjectPage extends React.PureComponent<Props, State> { /> )} - {query.manual || !hasAutoProvisioning || !identityProvider ? ( + {showManualTab || !almApplication ? ( <ManualProjectCreate currentUser={currentUser} onProjectCreate={this.handleProjectCreate} - organization={query.organization} + organization={state.organization} + userOrganizations={userOrganizations} /> ) : ( <AutoProjectCreate - identityProvider={identityProvider} + almApplication={almApplication} + boundOrganizations={userOrganizations.filter(o => o.almId)} onProjectCreate={this.handleProjectCreate} + organization={state.organization} /> )} </> @@ -203,13 +191,20 @@ export class CreateProjectPage extends React.PureComponent<Props, State> { } } -const mapStateToProps = (state: Store): StateProps => ({ - currentUser: getCurrentUser(state) -}); +const mapDispatchToProps = { + fetchMyOrganizations, + skipOnboardingAction +}; -const mapDispatchToProps: DispatchProps = { addGlobalErrorMessage, skipOnboardingAction }; - -export default connect( - mapStateToProps, - mapDispatchToProps -)(CreateProjectPage); +const mapStateToProps = (state: Store) => { + return { + userOrganizations: getMyOrganizations(state) + }; +}; + +export default whenLoggedIn( + connect<StateProps>( + mapStateToProps, + mapDispatchToProps + )(CreateProjectPage) +); diff --git a/server/sonar-web/src/main/js/apps/projects/create/ManualProjectCreate.tsx b/server/sonar-web/src/main/js/apps/create/project/ManualProjectCreate.tsx index 1101f6484ef..2820af7c8dc 100644 --- a/server/sonar-web/src/main/js/apps/projects/create/ManualProjectCreate.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/ManualProjectCreate.tsx @@ -18,34 +18,20 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import { sortBy } from 'lodash'; -import { connect } from 'react-redux'; -import { Link } from 'react-router'; -import Select from '../../../components/controls/Select'; +import OrganizationSelect from './OrganizationSelect'; +import DeferredSpinner from '../../../components/common/DeferredSpinner'; import { SubmitButton } from '../../../components/ui/buttons'; import { LoggedInUser, Organization } from '../../../app/types'; -import { fetchMyOrganizations } from '../../account/organizations/actions'; -import { getMyOrganizations, Store } from '../../../store/rootReducer'; import { translate } from '../../../helpers/l10n'; import { createProject } from '../../../api/components'; -import DeferredSpinner from '../../../components/common/DeferredSpinner'; - -interface StateProps { - userOrganizations: Organization[]; -} -interface DispatchProps { - fetchMyOrganizations: () => Promise<void>; -} - -interface OwnProps { +interface Props { currentUser: LoggedInUser; onProjectCreate: (projectKeys: string[]) => void; organization?: string; + userOrganizations: Organization[]; } -type Props = OwnProps & StateProps & DispatchProps; - interface State { projectName: string; projectKey: string; @@ -53,7 +39,7 @@ interface State { submitting: boolean; } -export class ManualProjectCreate extends React.PureComponent<Props, State> { +export default class ManualProjectCreate extends React.PureComponent<Props, State> { mounted = false; constructor(props: Props) { @@ -105,8 +91,8 @@ export class ManualProjectCreate extends React.PureComponent<Props, State> { } }; - handleOrganizationSelect = ({ value }: { value: string }) => { - this.setState({ selectedOrganization: value }); + handleOrganizationSelect = ({ key }: Organization) => { + this.setState({ selectedOrganization: key }); }; handleProjectNameChange = (event: React.ChangeEvent<HTMLInputElement>) => { @@ -127,30 +113,11 @@ export class ManualProjectCreate extends React.PureComponent<Props, State> { return ( <> <form onSubmit={this.handleFormSubmit}> - <div className="form-field"> - <label htmlFor="select-organization"> - {translate('onboarding.create_project.organization')} - <em className="mandatory">*</em> - </label> - <Select - autoFocus={true} - className="input-super-large" - clearable={false} - id="select-organization" - onChange={this.handleOrganizationSelect} - options={sortBy(this.props.userOrganizations, o => o.name.toLowerCase()).map( - organization => ({ - label: organization.name, - value: organization.key - }) - )} - required={true} - value={this.state.selectedOrganization} - /> - <Link className="big-spacer-left js-new-org" to="/create-organization"> - {translate('onboarding.create_project.create_new_org')} - </Link> - </div> + <OrganizationSelect + onChange={this.handleOrganizationSelect} + organization={this.state.selectedOrganization} + organizations={this.props.userOrganizations} + /> <div className="form-field"> <label htmlFor="project-name"> {translate('onboarding.create_project.project_name')} @@ -192,17 +159,3 @@ export class ManualProjectCreate extends React.PureComponent<Props, State> { ); } } - -const mapDispatchToProps = ({ - fetchMyOrganizations -} as any) as DispatchProps; - -const mapStateToProps = (state: Store): StateProps => { - return { - userOrganizations: getMyOrganizations(state) - }; -}; -export default connect( - mapStateToProps, - mapDispatchToProps -)(ManualProjectCreate); 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 new file mode 100644 index 00000000000..a1d52e0af64 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/OrganizationSelect.tsx @@ -0,0 +1,86 @@ +/* + * 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 { Link } from 'react-router'; +import { sortBy } from 'lodash'; +import Select from '../../../components/controls/Select'; +import { Organization } from '../../../app/types'; +import { translate } from '../../../helpers/l10n'; +import { sanitizeAlmId } from '../../../helpers/almIntegrations'; +import { getBaseUrl } from '../../../helpers/urls'; + +interface Props { + autoImport?: boolean; + onChange: (organization: Organization) => void; + organization: string; + organizations: Organization[]; +} + +export default function OrganizationSelect({ + autoImport, + onChange, + organization, + organizations +}: Props) { + return ( + <div className="form-field spacer-bottom"> + <label htmlFor="select-organization"> + {translate('onboarding.create_project.organization')} + <em className="mandatory">*</em> + </label> + <Select + autoFocus={true} + className="input-super-large" + clearable={false} + id="select-organization" + labelKey="name" + onChange={onChange} + optionRenderer={optionRenderer} + options={sortBy(organizations, o => o.name.toLowerCase())} + required={true} + value={organization} + valueKey="key" + valueRenderer={optionRenderer} + /> + <Link className="big-spacer-left js-new-org" to="/create-organization"> + {autoImport + ? translate('onboarding.create_project.import_new_org') + : translate('onboarding.create_project.create_new_org')} + </Link> + </div> + ); +} + +export function optionRenderer(organization: Organization) { + return ( + <span> + {organization.almId && ( + <img + alt={organization.almId} + className="spacer-right" + height={14} + src={`${getBaseUrl()}/images/sonarcloud/${sanitizeAlmId(organization.almId)}.svg`} + /> + )} + {organization.name} + <span className="note little-spacer-left">{organization.key}</span> + </span> + ); +} diff --git a/server/sonar-web/src/main/js/apps/projects/create/AutoProjectCreate.tsx b/server/sonar-web/src/main/js/apps/create/project/RemoteRepositories.tsx index 74677605d41..66f3aef3f0e 100644 --- a/server/sonar-web/src/main/js/apps/projects/create/AutoProjectCreate.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/RemoteRepositories.tsx @@ -20,50 +20,55 @@ import * as React from 'react'; import AlmRepositoryItem from './AlmRepositoryItem'; import DeferredSpinner from '../../../components/common/DeferredSpinner'; -import IdentityProviderLink from '../../../components/ui/IdentityProviderLink'; import { getRepositories, provisionProject } from '../../../api/alm-integration'; -import { IdentityProvider, AlmRepository } from '../../../app/types'; +import { AlmApplication, AlmRepository } from '../../../app/types'; import { SubmitButton } from '../../../components/ui/buttons'; -import { translateWithParameters, translate } from '../../../helpers/l10n'; -import { Alert } from '../../../components/ui/Alert'; +import { translate } from '../../../helpers/l10n'; interface Props { - identityProvider: IdentityProvider; + almApplication: AlmApplication; onProjectCreate: (projectKeys: string[]) => void; + organization: string; } +type SelectedRepositories = { [key: string]: AlmRepository | undefined }; + interface State { - installationUrl?: string; - installed?: boolean; loading: boolean; repositories: AlmRepository[]; - selectedRepositories: { [key: string]: AlmRepository | undefined }; + selectedRepositories: SelectedRepositories; submitting: boolean; } -export default class AutoProjectCreate extends React.PureComponent<Props, State> { +export default class RemoteRepositories extends React.PureComponent<Props, State> { mounted = false; - state: State = { - loading: true, - repositories: [], - selectedRepositories: {}, - submitting: false - }; + state: State = { loading: true, repositories: [], selectedRepositories: {}, submitting: false }; componentDidMount() { this.mounted = true; this.fetchRepositories(); } + componentDidUpdate(prevProps: Props) { + const { organization } = this.props; + if (prevProps.organization !== organization) { + this.setState({ loading: true }); + this.fetchRepositories(); + } + } + componentWillUnmount() { this.mounted = false; } fetchRepositories = () => { - getRepositories().then( - ({ almIntegration, repositories }) => { + const { organization } = this.props; + return getRepositories({ + organization + }).then( + ({ repositories }) => { if (this.mounted) { - this.setState({ ...almIntegration, loading: false, repositories }); + this.setState({ loading: false, repositories }); } }, () => { @@ -83,19 +88,32 @@ export default class AutoProjectCreate extends React.PureComponent<Props, State> provisionProject({ installationKeys: Object.keys(selectedRepositories).filter(key => Boolean(selectedRepositories[key]) - ) + ), + organization: this.props.organization }).then( ({ projects }) => this.props.onProjectCreate(projects.map(project => project.projectKey)), - () => { - if (this.mounted) { - this.setState({ loading: true, submitting: false }); - this.fetchRepositories(); - } - } + this.handleProvisionFail ); } }; + handleProvisionFail = () => { + return this.fetchRepositories().then(() => { + if (this.mounted) { + this.setState(({ repositories, selectedRepositories }) => { + const updateSelectedRepositories: SelectedRepositories = {}; + Object.keys(selectedRepositories).forEach(installationKey => { + const newRepository = repositories.find(r => r.installationKey === installationKey); + if (newRepository && !newRepository.linkedProjectKey) { + updateSelectedRepositories[newRepository.installationKey] = newRepository; + } + }); + return { selectedRepositories: updateSelectedRepositories, submitting: false }; + }); + } + }); + }; + isValid = () => { return this.state.repositories.some(repo => Boolean(this.state.selectedRepositories[repo.installationKey]) @@ -113,68 +131,32 @@ export default class AutoProjectCreate extends React.PureComponent<Props, State> })); }; - renderContent = () => { - const { identityProvider } = this.props; - const { selectedRepositories, submitting } = this.state; - - if (this.state.installed) { - return ( + render() { + const { loading, selectedRepositories, submitting } = this.state; + const { almApplication } = this.props; + return ( + <DeferredSpinner loading={loading}> <form onSubmit={this.handleFormSubmit}> - <ul> - {this.state.repositories.map(repo => ( - <li className="big-spacer-bottom" key={repo.installationKey}> - <AlmRepositoryItem - identityProvider={identityProvider} - repository={repo} - selected={Boolean(selectedRepositories[repo.installationKey])} - toggleRepository={this.toggleRepository} - /> - </li> - ))} - </ul> + <div className="form-field"> + <ul> + {this.state.repositories.map(repo => ( + <li className="big-spacer-bottom" key={repo.installationKey}> + <AlmRepositoryItem + identityProvider={almApplication} + repository={repo} + selected={Boolean(selectedRepositories[repo.installationKey])} + toggleRepository={this.toggleRepository} + /> + </li> + ))} + </ul> + </div> <SubmitButton disabled={!this.isValid() || submitting}> {translate('create')} </SubmitButton> <DeferredSpinner className="spacer-left" loading={submitting} /> </form> - ); - } - return ( - <div> - <p className="spacer-bottom"> - {translateWithParameters( - 'onboarding.create_project.install_app_x', - identityProvider.name - )} - </p> - <IdentityProviderLink - className="display-inline-block" - identityProvider={identityProvider} - small={true} - url={this.state.installationUrl}> - {translateWithParameters( - 'onboarding.create_project.install_app_x.button', - identityProvider.name - )} - </IdentityProviderLink> - </div> - ); - }; - - render() { - const { identityProvider } = this.props; - const { loading } = this.state; - - return ( - <> - <Alert className="width-60 big-spacer-bottom" variant="info"> - {translateWithParameters( - 'onboarding.create_project.beta_feature_x', - identityProvider.name - )} - </Alert> - {loading ? <DeferredSpinner /> : this.renderContent()} - </> + </DeferredSpinner> ); } } diff --git a/server/sonar-web/src/main/js/apps/projects/create/__tests__/AlmRepositoryItem-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/AlmRepositoryItem-test.tsx index 72b25cb2815..72b25cb2815 100644 --- a/server/sonar-web/src/main/js/apps/projects/create/__tests__/AlmRepositoryItem-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/AlmRepositoryItem-test.tsx 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 new file mode 100644 index 00000000000..3364a73a344 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/AutoProjectCreate-test.tsx @@ -0,0 +1,53 @@ +/* + * 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 AutoProjectCreate from '../AutoProjectCreate'; + +const almApplication = { + backgroundColor: 'blue', + iconPath: 'icon/path', + installationUrl: 'https://alm.installation.url', + key: 'github', + name: 'GitHub' +}; + +it('should display the provider app install button', () => { + expect(shallowRender({ boundOrganizations: [] })).toMatchSnapshot(); +}); + +it('should display the bounded organizations dropdown with the list of repositories', () => { + expect(shallowRender({ organization: 'foo' })).toMatchSnapshot(); +}); + +function shallowRender(props: Partial<AutoProjectCreate['props']> = {}) { + return shallow( + <AutoProjectCreate + almApplication={almApplication} + boundOrganizations={[ + { almId: 'github', key: 'foo', name: 'Foo' }, + { almId: 'github', key: 'bar', name: 'Bar' } + ]} + onProjectCreate={jest.fn()} + organization="" + {...props} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/projects/create/__tests__/CreateProjectPage-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/CreateProjectPage-test.tsx index fba7c5875c7..6c9acb9d77c 100644 --- a/server/sonar-web/src/main/js/apps/projects/create/__tests__/CreateProjectPage-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/CreateProjectPage-test.tsx @@ -19,22 +19,20 @@ */ import * as React from 'react'; import { shallow } from 'enzyme'; -import { Location } from 'history'; import { CreateProjectPage } from '../CreateProjectPage'; -import { getIdentityProviders } from '../../../../api/users'; import { LoggedInUser } from '../../../../app/types'; -import { waitAndUpdate } from '../../../../helpers/testUtils'; +import { waitAndUpdate, mockRouter } from '../../../../helpers/testUtils'; +import { getAlmAppInfo } from '../../../../api/alm-integration'; -jest.mock('../../../../api/users', () => ({ - getIdentityProviders: jest.fn().mockResolvedValue({ - identityProviders: [ - { - backgroundColor: 'blue', - iconPath: 'icon/path', - key: 'github', - name: 'GitHub' - } - ] +jest.mock('../../../../api/alm-integration', () => ({ + getAlmAppInfo: jest.fn().mockResolvedValue({ + application: { + backgroundColor: 'blue', + iconPath: 'icon/path', + installationUrl: 'https://alm.installation.url', + key: 'github', + name: 'GitHub' + } }) })); @@ -48,7 +46,7 @@ const user: LoggedInUser = { }; beforeEach(() => { - (getIdentityProviders as jest.Mock<any>).mockClear(); + (getAlmAppInfo as jest.Mock<any>).mockClear(); }); it('should render correctly', async () => { @@ -73,28 +71,25 @@ it('should switch tabs', async () => { expect(wrapper).toMatchSnapshot(); wrapper.find('Tabs').prop<Function>('onChange')('manual'); - expect(wrapper.find('Connect(ManualProjectCreate)').exists()).toBeTruthy(); + expect(wrapper.find('ManualProjectCreate').exists()).toBeTruthy(); wrapper.find('Tabs').prop<Function>('onChange')('auto'); expect(wrapper.find('AutoProjectCreate').exists()).toBeTruthy(); }); -it('should display an error message on load', () => { - const addGlobalErrorMessage = jest.fn(); - getWrapper({ - addGlobalErrorMessage, - location: { pathname: 'foo', query: { error: 'Foo error' } } - }); - expect(addGlobalErrorMessage).toHaveBeenCalledWith('Foo error'); -}); - function getWrapper(props = {}) { return shallow( <CreateProjectPage addGlobalErrorMessage={jest.fn()} currentUser={user} - location={{ pathname: 'foo', query: { manual: 'false' } } as Location} - router={{ push: jest.fn(), replace: jest.fn() }} + fetchMyOrganizations={jest.fn()} + // @ts-ignore avoid passing everything from WithRouterProps + location={{}} + router={mockRouter()} skipOnboardingAction={jest.fn()} + userOrganizations={[ + { key: 'foo', name: 'Foo' }, + { almId: 'github', key: 'bar', name: 'Bar' } + ]} {...props} /> ); diff --git a/server/sonar-web/src/main/js/apps/projects/create/__tests__/ManualProjectCreate-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/ManualProjectCreate-test.tsx index 5777d22aaaa..52d56a87e38 100644 --- a/server/sonar-web/src/main/js/apps/projects/create/__tests__/ManualProjectCreate-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/ManualProjectCreate-test.tsx @@ -19,7 +19,7 @@ */ import * as React from 'react'; import { shallow } from 'enzyme'; -import { ManualProjectCreate } from '../ManualProjectCreate'; +import ManualProjectCreate from '../ManualProjectCreate'; import { change, submit, waitAndUpdate } from '../../../../helpers/testUtils'; import { createProject } from '../../../../api/components'; @@ -38,7 +38,7 @@ it('should render correctly', () => { it('should correctly create a project', async () => { const onProjectCreate = jest.fn(); const wrapper = getWrapper({ onProjectCreate }); - wrapper.find('Select').prop<Function>('onChange')({ value: 'foo' }); + wrapper.find('OrganizationSelect').prop<Function>('onChange')({ key: 'foo' }); change(wrapper.find('#project-name'), 'Bar'); expect(wrapper.find('SubmitButton')).toMatchSnapshot(); @@ -56,7 +56,6 @@ function getWrapper(props = {}) { return shallow( <ManualProjectCreate currentUser={{ groups: [], isLoggedIn: true, login: 'foo', name: 'Foo', scmAccounts: [] }} - fetchMyOrganizations={jest.fn()} onProjectCreate={jest.fn()} userOrganizations={[{ key: 'foo', name: 'Foo' }, { key: 'bar', name: 'Bar' }]} {...props} diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/OrganizationDetailsInput-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/OrganizationSelect-test.tsx index 4ddeac5d913..4224b152a38 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/__tests__/OrganizationDetailsInput-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/OrganizationSelect-test.tsx @@ -19,37 +19,31 @@ */ import * as React from 'react'; import { shallow } from 'enzyme'; -import OrganizationDetailsInput from '../OrganizationDetailsInput'; +import OrganizationSelect, { optionRenderer } from '../OrganizationSelect'; -it('should render', () => { - const render = jest.fn().mockReturnValue(<div />); +const organizations = [{ key: 'foo', name: 'Foo' }, { almId: 'github', key: 'bar', name: 'Bar' }]; + +it('should render correctly', () => { expect( shallow( - <OrganizationDetailsInput - dirty={true} - error="This field is bad!" - id="field" - isSubmitting={true} - isValidating={false} - label="Label" - name="field" - onBlur={jest.fn()} - onChange={jest.fn()} - required={true} - touched={true} - value="foo"> - {render} - </OrganizationDetailsInput> + <OrganizationSelect onChange={jest.fn()} organization="bar" organizations={organizations} /> ) ).toMatchSnapshot(); - expect(render).toBeCalledWith( - expect.objectContaining({ - className: 'input-super-large text-middle is-invalid', - disabled: true, - id: 'field', - name: 'field', - type: 'text', - value: 'foo' - }) - ); + expect( + shallow( + <OrganizationSelect + autoImport={true} + onChange={jest.fn()} + organization="bar" + organizations={organizations} + /> + ) + .find('.js-new-org') + .contains('onboarding.create_project.import_new_org') + ).toBe(true); +}); + +it('should render options correctly', () => { + expect(shallow(optionRenderer(organizations[0]))).toMatchSnapshot(); + expect(shallow(optionRenderer(organizations[1]))).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 new file mode 100644 index 00000000000..eefe1881c22 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/RemoteRepositories-test.tsx @@ -0,0 +1,94 @@ +/* + * 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 RemoteRepositories from '../RemoteRepositories'; +import { getRepositories, provisionProject } from '../../../../api/alm-integration'; +import { waitAndUpdate, submit } from '../../../../helpers/testUtils'; + +jest.mock('../../../../api/alm-integration', () => ({ + getRepositories: jest.fn().mockResolvedValue({ + repositories: [ + { + label: 'Cool Project', + installationKey: 'github/cool', + linkedProjectKey: 'proj_cool', + linkedProjectName: 'Proj Cool' + }, + { + label: 'Awesome Project', + installationKey: 'github/awesome' + } + ] + }), + provisionProject: jest.fn().mockResolvedValue({ projects: [{ projectKey: 'awesome' }] }) +})); + +const almApplication = { + backgroundColor: 'blue', + iconPath: 'icon/path', + installationUrl: 'https://alm.installation.url', + key: 'github', + name: 'GitHub' +}; + +beforeEach(() => { + (getRepositories as jest.Mock<any>).mockClear(); + (provisionProject as jest.Mock<any>).mockClear(); +}); + +it('should display the list of repositories', async () => { + const wrapper = shallowRender(); + expect(wrapper).toMatchSnapshot(); + await waitAndUpdate(wrapper); + expect(getRepositories).toHaveBeenCalledWith({ organization: 'sonarsource' }); + expect(wrapper).toMatchSnapshot(); +}); + +it('should correctly create a project', async () => { + const onProjectCreate = jest.fn(); + const wrapper = shallowRender({ onProjectCreate }); + (wrapper.instance() as RemoteRepositories).toggleRepository({ + label: 'Awesome Project', + installationKey: 'github/awesome' + }); + await waitAndUpdate(wrapper); + + expect(wrapper.find('SubmitButton')).toMatchSnapshot(); + submit(wrapper.find('form')); + expect(provisionProject).toBeCalledWith({ + installationKeys: ['github/awesome'], + organization: 'sonarsource' + }); + + await waitAndUpdate(wrapper); + expect(onProjectCreate).toBeCalledWith(['awesome']); +}); + +function shallowRender(props: Partial<RemoteRepositories['props']> = {}) { + return shallow( + <RemoteRepositories + almApplication={almApplication} + onProjectCreate={jest.fn()} + organization="sonarsource" + {...props} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/projects/create/__tests__/__snapshots__/AlmRepositoryItem-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AlmRepositoryItem-test.tsx.snap index 7ed1eedfa61..7ed1eedfa61 100644 --- a/server/sonar-web/src/main/js/apps/projects/create/__tests__/__snapshots__/AlmRepositoryItem-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AlmRepositoryItem-test.tsx.snap 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 new file mode 100644 index 00000000000..147427d62a6 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AutoProjectCreate-test.tsx.snap @@ -0,0 +1,59 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should display the bounded organizations dropdown with the list of repositories 1`] = ` +<Fragment> + <OrganizationSelect + autoImport={true} + onChange={[Function]} + organization="foo" + organizations={ + Array [ + Object { + "almId": "github", + "key": "foo", + "name": "Foo", + }, + Object { + "almId": "github", + "key": "bar", + "name": "Bar", + }, + ] + } + /> + <RemoteRepositories + almApplication={ + Object { + "backgroundColor": "blue", + "iconPath": "icon/path", + "installationUrl": "https://alm.installation.url", + "key": "github", + "name": "GitHub", + } + } + onProjectCreate={[MockFunction]} + organization="foo" + /> +</Fragment> +`; + +exports[`should display the provider app install button 1`] = ` +<Fragment> + <IdentityProviderLink + className="display-inline-block" + identityProvider={ + Object { + "backgroundColor": "blue", + "iconPath": "icon/path", + "installationUrl": "https://alm.installation.url", + "key": "github", + "name": "GitHub", + } + } + small={true} + url="https://alm.installation.url" + > + onboarding.create_organization.choose_organization_button.github + </IdentityProviderLink> +</Fragment> +`; diff --git a/server/sonar-web/src/main/js/apps/projects/create/__tests__/__snapshots__/CreateProjectPage-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/CreateProjectPage-test.tsx.snap index 69731b0ff8e..6e1f9059e89 100644 --- a/server/sonar-web/src/main/js/apps/projects/create/__tests__/__snapshots__/CreateProjectPage-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/CreateProjectPage-test.tsx.snap @@ -71,14 +71,24 @@ exports[`should render correctly 2`] = ` } /> <AutoProjectCreate - identityProvider={ + almApplication={ Object { "backgroundColor": "blue", "iconPath": "icon/path", + "installationUrl": "https://alm.installation.url", "key": "github", "name": "GitHub", } } + boundOrganizations={ + Array [ + Object { + "almId": "github", + "key": "bar", + "name": "Bar", + }, + ] + } onProjectCreate={[Function]} /> </div> @@ -105,7 +115,7 @@ exports[`should render with Manual creation only 1`] = ` onboarding.create_project.header </h1> </header> - <Connect(ManualProjectCreate) + <ManualProjectCreate currentUser={ Object { "externalProvider": "microsoft", @@ -117,6 +127,19 @@ exports[`should render with Manual creation only 1`] = ` } } onProjectCreate={[Function]} + userOrganizations={ + Array [ + Object { + "key": "foo", + "name": "Foo", + }, + Object { + "almId": "github", + "key": "bar", + "name": "Bar", + }, + ] + } /> </div> </Fragment> @@ -166,14 +189,24 @@ exports[`should switch tabs 1`] = ` } /> <AutoProjectCreate - identityProvider={ + almApplication={ Object { "backgroundColor": "blue", "iconPath": "icon/path", + "installationUrl": "https://alm.installation.url", "key": "github", "name": "GitHub", } } + boundOrganizations={ + Array [ + Object { + "almId": "github", + "key": "bar", + "name": "Bar", + }, + ] + } onProjectCreate={[Function]} /> </div> diff --git a/server/sonar-web/src/main/js/apps/projects/create/__tests__/__snapshots__/ManualProjectCreate-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/ManualProjectCreate-test.tsx.snap index 6993ed112b6..53fde97ce31 100644 --- a/server/sonar-web/src/main/js/apps/projects/create/__tests__/__snapshots__/ManualProjectCreate-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/ManualProjectCreate-test.tsx.snap @@ -21,49 +21,22 @@ exports[`should render correctly 1`] = ` <form onSubmit={[Function]} > - <div - className="form-field" - > - <label - htmlFor="select-organization" - > - onboarding.create_project.organization - <em - className="mandatory" - > - * - </em> - </label> - <Select - autoFocus={true} - className="input-super-large" - clearable={false} - id="select-organization" - onChange={[Function]} - options={ - Array [ - Object { - "label": "Bar", - "value": "bar", - }, - Object { - "label": "Foo", - "value": "foo", - }, - ] - } - required={true} - value="" - /> - <Link - className="big-spacer-left js-new-org" - onlyActiveOnIndex={false} - style={Object {}} - to="/create-organization" - > - onboarding.create_project.create_new_org - </Link> - </div> + <OrganizationSelect + onChange={[Function]} + organization="" + organizations={ + Array [ + Object { + "key": "foo", + "name": "Foo", + }, + Object { + "key": "bar", + "name": "Bar", + }, + ] + } + /> <div className="form-field" > 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 new file mode 100644 index 00000000000..50cd939ec7e --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/OrganizationSelect-test.tsx.snap @@ -0,0 +1,80 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` +<div + className="form-field spacer-bottom" +> + <label + htmlFor="select-organization" + > + onboarding.create_project.organization + <em + className="mandatory" + > + * + </em> + </label> + <Select + autoFocus={true} + className="input-super-large" + clearable={false} + id="select-organization" + labelKey="name" + onChange={[MockFunction]} + optionRenderer={[Function]} + options={ + Array [ + Object { + "almId": "github", + "key": "bar", + "name": "Bar", + }, + Object { + "key": "foo", + "name": "Foo", + }, + ] + } + required={true} + value="bar" + valueKey="key" + valueRenderer={[Function]} + /> + <Link + className="big-spacer-left js-new-org" + onlyActiveOnIndex={false} + style={Object {}} + to="/create-organization" + > + onboarding.create_project.create_new_org + </Link> +</div> +`; + +exports[`should render options correctly 1`] = ` +<span> + Foo + <span + className="note little-spacer-left" + > + foo + </span> +</span> +`; + +exports[`should render options correctly 2`] = ` +<span> + <img + alt="github" + className="spacer-right" + height={14} + src="/images/sonarcloud/github.svg" + /> + Bar + <span + className="note little-spacer-left" + > + bar + </span> +</span> +`; 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 new file mode 100644 index 00000000000..01359b6a9f9 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/RemoteRepositories-test.tsx.snap @@ -0,0 +1,114 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should correctly create a project 1`] = ` +<SubmitButton + disabled={false} +> + create +</SubmitButton> +`; + +exports[`should display the list of repositories 1`] = ` +<DeferredSpinner + loading={true} + timeout={100} +> + <form + onSubmit={[Function]} + > + <div + className="form-field" + > + <ul /> + </div> + <SubmitButton + disabled={true} + > + create + </SubmitButton> + <DeferredSpinner + className="spacer-left" + loading={false} + timeout={100} + /> + </form> +</DeferredSpinner> +`; + +exports[`should display the list of repositories 2`] = ` +<DeferredSpinner + loading={false} + timeout={100} +> + <form + onSubmit={[Function]} + > + <div + className="form-field" + > + <ul> + <li + className="big-spacer-bottom" + key="github/cool" + > + <AlmRepositoryItem + identityProvider={ + Object { + "backgroundColor": "blue", + "iconPath": "icon/path", + "installationUrl": "https://alm.installation.url", + "key": "github", + "name": "GitHub", + } + } + repository={ + Object { + "installationKey": "github/cool", + "label": "Cool Project", + "linkedProjectKey": "proj_cool", + "linkedProjectName": "Proj Cool", + } + } + selected={false} + toggleRepository={[Function]} + /> + </li> + <li + className="big-spacer-bottom" + key="github/awesome" + > + <AlmRepositoryItem + identityProvider={ + Object { + "backgroundColor": "blue", + "iconPath": "icon/path", + "installationUrl": "https://alm.installation.url", + "key": "github", + "name": "GitHub", + } + } + repository={ + Object { + "installationKey": "github/awesome", + "label": "Awesome Project", + } + } + selected={false} + toggleRepository={[Function]} + /> + </li> + </ul> + </div> + <SubmitButton + disabled={true} + > + create + </SubmitButton> + <DeferredSpinner + className="spacer-left" + loading={false} + timeout={100} + /> + </form> +</DeferredSpinner> +`; diff --git a/server/sonar-web/src/main/js/apps/projects/create/__tests__/AutoProjectCreate-test.tsx b/server/sonar-web/src/main/js/apps/projects/create/__tests__/AutoProjectCreate-test.tsx deleted file mode 100644 index 91e189f4204..00000000000 --- a/server/sonar-web/src/main/js/apps/projects/create/__tests__/AutoProjectCreate-test.tsx +++ /dev/null @@ -1,87 +0,0 @@ -/* - * 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 AutoProjectCreate from '../AutoProjectCreate'; -import { getRepositories } from '../../../../api/alm-integration'; -import { waitAndUpdate } from '../../../../helpers/testUtils'; - -jest.mock('../../../../api/alm-integration', () => ({ - getRepositories: jest.fn().mockResolvedValue({ - almIntegration: { - installationUrl: 'https://alm.foo.com/install', - installed: false - }, - repositories: [] - }), - provisionProject: jest.fn().mockResolvedValue({ projects: [] }) -})); - -const identityProvider = { - backgroundColor: 'blue', - iconPath: 'icon/path', - key: 'foo', - name: 'Foo Provider' -}; - -const repositories = [ - { - label: 'Cool Project', - installationKey: 'github/cool', - linkedProjectKey: 'proj_cool', - linkedProjectName: 'Proj Cool' - }, - { - label: 'Awesome Project', - installationKey: 'github/awesome' - } -]; - -beforeEach(() => { - (getRepositories as jest.Mock<any>).mockClear(); -}); - -it('should display the provider app install button', async () => { - const wrapper = getWrapper(); - expect(wrapper).toMatchSnapshot(); - expect(getRepositories).toHaveBeenCalled(); - - await waitAndUpdate(wrapper); - expect(wrapper).toMatchSnapshot(); -}); - -it('should display the list of repositories', async () => { - (getRepositories as jest.Mock<any>).mockResolvedValue({ - almIntegration: { - installationUrl: 'https://alm.foo.com/install', - installed: true - }, - repositories - }); - const wrapper = getWrapper(); - await waitAndUpdate(wrapper); - expect(wrapper).toMatchSnapshot(); -}); - -function getWrapper(props = {}) { - return shallow( - <AutoProjectCreate identityProvider={identityProvider} onProjectCreate={jest.fn()} {...props} /> - ); -} diff --git a/server/sonar-web/src/main/js/apps/projects/create/__tests__/__snapshots__/AutoProjectCreate-test.tsx.snap b/server/sonar-web/src/main/js/apps/projects/create/__tests__/__snapshots__/AutoProjectCreate-test.tsx.snap deleted file mode 100644 index 619285b02fa..00000000000 --- a/server/sonar-web/src/main/js/apps/projects/create/__tests__/__snapshots__/AutoProjectCreate-test.tsx.snap +++ /dev/null @@ -1,123 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`should display the list of repositories 1`] = ` -<Fragment> - <Alert - className="width-60 big-spacer-bottom" - variant="info" - > - onboarding.create_project.beta_feature_x.Foo Provider - </Alert> - <form - onSubmit={[Function]} - > - <ul> - <li - className="big-spacer-bottom" - key="github/cool" - > - <AlmRepositoryItem - identityProvider={ - Object { - "backgroundColor": "blue", - "iconPath": "icon/path", - "key": "foo", - "name": "Foo Provider", - } - } - repository={ - Object { - "installationKey": "github/cool", - "label": "Cool Project", - "linkedProjectKey": "proj_cool", - "linkedProjectName": "Proj Cool", - } - } - selected={false} - toggleRepository={[Function]} - /> - </li> - <li - className="big-spacer-bottom" - key="github/awesome" - > - <AlmRepositoryItem - identityProvider={ - Object { - "backgroundColor": "blue", - "iconPath": "icon/path", - "key": "foo", - "name": "Foo Provider", - } - } - repository={ - Object { - "installationKey": "github/awesome", - "label": "Awesome Project", - } - } - selected={false} - toggleRepository={[Function]} - /> - </li> - </ul> - <SubmitButton - disabled={true} - > - create - </SubmitButton> - <DeferredSpinner - className="spacer-left" - loading={false} - timeout={100} - /> - </form> -</Fragment> -`; - -exports[`should display the provider app install button 1`] = ` -<Fragment> - <Alert - className="width-60 big-spacer-bottom" - variant="info" - > - onboarding.create_project.beta_feature_x.Foo Provider - </Alert> - <DeferredSpinner - timeout={100} - /> -</Fragment> -`; - -exports[`should display the provider app install button 2`] = ` -<Fragment> - <Alert - className="width-60 big-spacer-bottom" - variant="info" - > - onboarding.create_project.beta_feature_x.Foo Provider - </Alert> - <div> - <p - className="spacer-bottom" - > - onboarding.create_project.install_app_x.Foo Provider - </p> - <IdentityProviderLink - className="display-inline-block" - identityProvider={ - Object { - "backgroundColor": "blue", - "iconPath": "icon/path", - "key": "foo", - "name": "Foo Provider", - } - } - small={true} - url="https://alm.foo.com/install" - > - onboarding.create_project.install_app_x.button.Foo Provider - </IdentityProviderLink> - </div> -</Fragment> -`; diff --git a/server/sonar-web/src/main/js/apps/projects/routes.ts b/server/sonar-web/src/main/js/apps/projects/routes.ts index 061627c206a..5478d372823 100644 --- a/server/sonar-web/src/main/js/apps/projects/routes.ts +++ b/server/sonar-web/src/main/js/apps/projects/routes.ts @@ -37,7 +37,7 @@ const routes = [ { path: 'favorite', component: FavoriteProjectsContainer }, isSonarCloud() && { path: 'create', - component: lazyLoad(() => import('./create/CreateProjectPage')) + component: lazyLoad(() => import('../create/project/CreateProjectPage')) } ].filter(Boolean); diff --git a/server/sonar-web/src/main/js/components/controls/Tabs.tsx b/server/sonar-web/src/main/js/components/controls/Tabs.tsx index 7678b54a2c8..0e37e914653 100644 --- a/server/sonar-web/src/main/js/components/controls/Tabs.tsx +++ b/server/sonar-web/src/main/js/components/controls/Tabs.tsx @@ -21,13 +21,13 @@ import * as React from 'react'; import * as classNames from 'classnames'; import './Tabs.css'; -interface Props { - onChange: (tab: string) => void; - selected?: string; - tabs: Array<{ disabled?: boolean; key: string; node: React.ReactNode }>; +interface Props<T extends string> { + onChange: (tab: T) => void; + selected?: T; + tabs: Array<{ disabled?: boolean; key: T; node: React.ReactNode }>; } -export default function Tabs({ onChange, selected, tabs }: Props) { +export default function Tabs<T extends string>({ onChange, selected, tabs }: Props<T>) { return ( <ul className="flex-tabs"> {tabs.map(tab => ( @@ -44,15 +44,15 @@ export default function Tabs({ onChange, selected, tabs }: Props) { ); } -interface TabProps { +interface TabProps<T> { children: React.ReactNode; disabled?: boolean; - name: string; - onSelect: (tab: string) => void; + name: T; + onSelect: (tab: T) => void; selected: boolean; } -export class Tab extends React.PureComponent<TabProps> { +export class Tab<T> extends React.PureComponent<TabProps<T>> { handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => { event.preventDefault(); event.stopPropagation(); diff --git a/server/sonar-web/src/main/js/components/controls/ValidationInput.tsx b/server/sonar-web/src/main/js/components/controls/ValidationInput.tsx new file mode 100644 index 00000000000..f95fb297879 --- /dev/null +++ b/server/sonar-web/src/main/js/components/controls/ValidationInput.tsx @@ -0,0 +1,54 @@ +/* + * 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 AlertErrorIcon from '../icons-components/AlertErrorIcon'; +import AlertSuccessIcon from '../icons-components/AlertSuccessIcon'; + +interface Props { + description?: string; + children: React.ReactNode; + error: string | undefined; + id: string; + isInvalid: boolean; + isValid: boolean; + label: React.ReactNode; + required?: boolean; +} + +export default function ValidationInput(props: Props) { + const hasError = props.isInvalid && props.error !== undefined; + return ( + <div> + <label htmlFor={props.id}> + <strong>{props.label}</strong> + {props.required && <em className="mandatory">*</em>} + </label> + <div className="little-spacer-top spacer-bottom"> + {props.children} + {props.isInvalid && <AlertErrorIcon className="spacer-left text-middle" />} + {hasError && ( + <span className="little-spacer-left text-danger text-middle">{props.error}</span> + )} + {props.isValid && <AlertSuccessIcon className="spacer-left text-middle" />} + </div> + {props.description && <div className="note abs-width-400">{props.description}</div>} + </div> + ); +} diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/ValidationInput-test.tsx b/server/sonar-web/src/main/js/components/controls/__tests__/ValidationInput-test.tsx new file mode 100644 index 00000000000..37b3d8e6a41 --- /dev/null +++ b/server/sonar-web/src/main/js/components/controls/__tests__/ValidationInput-test.tsx @@ -0,0 +1,72 @@ +/* + * 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 ValidationInput from '../ValidationInput'; + +it('should render', () => { + expect( + shallow( + <ValidationInput + description="My description" + error={undefined} + id="field-id" + isInvalid={false} + isValid={false} + label="Field label" + required={true}> + <div /> + </ValidationInput> + ) + ).toMatchSnapshot(); +}); + +it('should render with error', () => { + expect( + shallow( + <ValidationInput + description="My description" + error="Field error message" + id="field-id" + isInvalid={true} + isValid={false} + label="Field label"> + <div /> + </ValidationInput> + ) + ).toMatchSnapshot(); +}); + +it('should render when valid', () => { + expect( + shallow( + <ValidationInput + description="My description" + error={undefined} + id="field-id" + isInvalid={false} + isValid={true} + label="Field label" + required={true}> + <div /> + </ValidationInput> + ) + ).toMatchSnapshot(); +}); diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/ValidationInput-test.tsx.snap b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/ValidationInput-test.tsx.snap new file mode 100644 index 00000000000..f49d27a83be --- /dev/null +++ b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/ValidationInput-test.tsx.snap @@ -0,0 +1,88 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render 1`] = ` +<div> + <label + htmlFor="field-id" + > + <strong> + Field label + </strong> + <em + className="mandatory" + > + * + </em> + </label> + <div + className="little-spacer-top spacer-bottom" + > + <div /> + </div> + <div + className="note abs-width-400" + > + My description + </div> +</div> +`; + +exports[`should render when valid 1`] = ` +<div> + <label + htmlFor="field-id" + > + <strong> + Field label + </strong> + <em + className="mandatory" + > + * + </em> + </label> + <div + className="little-spacer-top spacer-bottom" + > + <div /> + <AlertSuccessIcon + className="spacer-left text-middle" + /> + </div> + <div + className="note abs-width-400" + > + My description + </div> +</div> +`; + +exports[`should render with error 1`] = ` +<div> + <label + htmlFor="field-id" + > + <strong> + Field label + </strong> + </label> + <div + className="little-spacer-top spacer-bottom" + > + <div /> + <AlertErrorIcon + className="spacer-left text-middle" + /> + <span + className="little-spacer-left text-danger text-middle" + > + Field error message + </span> + </div> + <div + className="note abs-width-400" + > + My description + </div> +</div> +`; diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/whenLoggedIn-test.tsx b/server/sonar-web/src/main/js/components/hoc/__tests__/whenLoggedIn-test.tsx index 4fc1ee27506..e75dad4cdd8 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/__tests__/whenLoggedIn-test.tsx +++ b/server/sonar-web/src/main/js/components/hoc/__tests__/whenLoggedIn-test.tsx @@ -20,8 +20,13 @@ 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'; -import { mockRouter } from '../../../../helpers/testUtils'; + +jest.mock('../../../app/utils/handleRequiredAuthentication', () => ({ + default: jest.fn() +})); class X extends React.Component { render() { @@ -42,7 +47,7 @@ it('should not render for anonymous user', () => { const router = mockRouter({ replace: jest.fn() }); const wrapper = shallow(<UnderTest />, { context: { store, router } }); expect(getRenderedType(wrapper)).toBe(null); - expect(router.replace).toBeCalledWith(expect.objectContaining({ pathname: '/sessions/new' })); + expect(handleRequiredAuthentication).toBeCalled(); }); function getRenderedType(wrapper: ShallowWrapper) { diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/withCurrentUser-test.tsx b/server/sonar-web/src/main/js/components/hoc/__tests__/withCurrentUser-test.tsx index 142f6e94f67..84c292a37f9 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/__tests__/withCurrentUser-test.tsx +++ b/server/sonar-web/src/main/js/components/hoc/__tests__/withCurrentUser-test.tsx @@ -20,8 +20,8 @@ import * as React from 'react'; import { shallow } from 'enzyme'; import { createStore } from 'redux'; +import { CurrentUser } from '../../../app/types'; import { withCurrentUser } from '../withCurrentUser'; -import { CurrentUser } from '../../../../app/types'; class X extends React.Component<{ currentUser: CurrentUser }> { render() { diff --git a/server/sonar-web/src/main/js/apps/create/organization/whenLoggedIn.tsx b/server/sonar-web/src/main/js/components/hoc/whenLoggedIn.tsx index be69f2c4361..00dd040670e 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/whenLoggedIn.tsx +++ b/server/sonar-web/src/main/js/components/hoc/whenLoggedIn.tsx @@ -20,8 +20,9 @@ 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'; +import { CurrentUser } from '../../app/types'; +import { isLoggedIn } from '../../helpers/users'; +import handleRequiredAuthentication from '../../app/utils/handleRequiredAuthentication'; export function whenLoggedIn<P>(WrappedComponent: React.ComponentClass<P>) { const wrappedDisplayName = WrappedComponent.displayName || WrappedComponent.name || 'Component'; @@ -31,11 +32,7 @@ export function whenLoggedIn<P>(WrappedComponent: React.ComponentClass<P>) { componentDidMount() { if (!isLoggedIn(this.props.currentUser)) { - const returnTo = window.location.pathname + window.location.search + window.location.hash; - this.props.router.replace({ - pathname: '/sessions/new', - query: { return_to: returnTo } // eslint-disable-line camelcase - }); + handleRequiredAuthentication(); } } diff --git a/server/sonar-web/src/main/js/apps/create/organization/withCurrentUser.tsx b/server/sonar-web/src/main/js/components/hoc/withCurrentUser.tsx index 117af6607dc..b1933e1e23a 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/withCurrentUser.tsx +++ b/server/sonar-web/src/main/js/components/hoc/withCurrentUser.tsx @@ -19,8 +19,8 @@ */ import * as React from 'react'; import { connect } from 'react-redux'; -import { CurrentUser } from '../../../app/types'; -import { Store, getCurrentUser } from '../../../store/rootReducer'; +import { CurrentUser } from '../../app/types'; +import { Store, getCurrentUser } from '../../store/rootReducer'; export function withCurrentUser<P>( WrappedComponent: React.ComponentClass<P & { currentUser: CurrentUser }> 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 e098be7449e..b798554254f 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -2718,10 +2718,9 @@ onboarding.project_analysis.suggestions.github=If you are using Travis CI, the S onboarding.create_project.header=Create project(s) onboarding.create_project.already_imported=Repository already imported -onboarding.create_project.beta_feature_x=This feature is being beta tested. We offer to create projects from your {0} repositories only for public personal projects on your personal SonarCloud organization. For other kind of projects please create them manually. onboarding.create_project.create_manually=Create manually onboarding.create_project.create_new_org=I want to create another organization -onboarding.create_project.install_app_x=We need you to install the Sonarcloud {0} application in order to select which repositories you want to analyze. +onboarding.create_project.import_new_org=I want to import another organization onboarding.create_project.install_app_x.button=Install SonarCloud {0} application onboarding.create_project.organization=Organization onboarding.create_project.project_key=Project key @@ -2736,6 +2735,8 @@ onboarding.create_organization.organization_name.error=The provided value doesn' onboarding.create_organization.organization_name.taken=This name is already taken. onboarding.create_organization.add_additional_info=Add additional info onboarding.create_organization.hide_additional_info=Hide additional info +onboarding.create_organization.description=Description +onboarding.create_organization.description.error=The provided value doesn't match the expected format. onboarding.create_organization.display_name=Display Name onboarding.create_organization.display_name.description=Up to 255 characters onboarding.create_organization.display_name.error=The provided value doesn't match the expected format. |