diff options
author | Mathieu Suen <mathieu.suen@sonarsource.com> | 2021-05-12 09:49:12 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2021-05-21 20:03:37 +0000 |
commit | ac3d6f0496fd79c9ba17cb8551060ad424fd10be (patch) | |
tree | 73e2b3a796cca264e42a9fdf396e4dec32c93bf4 /server/sonar-web | |
parent | be8b3d7aa7efccf0a7b8c6f650acfca069450484 (diff) | |
download | sonarqube-ac3d6f0496fd79c9ba17cb8551060ad424fd10be.tar.gz sonarqube-ac3d6f0496fd79c9ba17cb8551060ad424fd10be.zip |
SONAR-14801 Adding App password form for Bitbucket cloud onboarding
Diffstat (limited to 'server/sonar-web')
26 files changed, 1189 insertions, 754 deletions
diff --git a/server/sonar-web/src/main/js/api/alm-integrations.ts b/server/sonar-web/src/main/js/api/alm-integrations.ts index 7bdd9e14622..2a7124f19ed 100644 --- a/server/sonar-web/src/main/js/api/alm-integrations.ts +++ b/server/sonar-web/src/main/js/api/alm-integrations.ts @@ -30,8 +30,14 @@ import { } from '../types/alm-integration'; import { ProjectBase } from './components'; -export function setAlmPersonalAccessToken(almSetting: string, pat: string): Promise<void> { - return post('/api/alm_integrations/set_pat', { almSetting, pat }).catch(throwGlobalError); +export function setAlmPersonalAccessToken( + almSetting: string, + pat: string, + username?: string +): Promise<void> { + return post('/api/alm_integrations/set_pat', { almSetting, pat, username }).catch( + throwGlobalError + ); } export function checkPersonalAccessTokenIsValid( diff --git a/server/sonar-web/src/main/js/apps/create/project/BitbucketCloudProjectCreate.tsx b/server/sonar-web/src/main/js/apps/create/project/BitbucketCloudProjectCreate.tsx new file mode 100644 index 00000000000..ffd78a7a2f7 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/BitbucketCloudProjectCreate.tsx @@ -0,0 +1,97 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 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 { WithRouterProps } from 'react-router'; +import { BitbucketProjectRepositories, BitbucketRepository } from '../../../types/alm-integration'; +import { AlmSettingsInstance } from '../../../types/alm-settings'; +import BitbucketCloudProjectCreateRenderer from './BitbucketCloudProjectCreateRender'; + +interface Props extends Pick<WithRouterProps, 'location' | 'router'> { + canAdmin: boolean; + settings: AlmSettingsInstance[]; + loadingBindings: boolean; + onProjectCreate: (projectKeys: string[]) => void; +} + +interface State { + settings: AlmSettingsInstance; + loading: boolean; + projectRepositories?: BitbucketProjectRepositories; + searchResults?: BitbucketRepository[]; + selectedRepository?: BitbucketRepository; + showPersonalAccessTokenForm: boolean; +} + +export default class BitbucketCloudProjectCreate extends React.PureComponent<Props, State> { + mounted = false; + + constructor(props: Props) { + super(props); + this.state = { + // For now, we only handle a single instance. So we always use the first + // one from the list. + settings: props.settings[0], + loading: false, + showPersonalAccessTokenForm: true + }; + } + + componentDidMount() { + this.mounted = true; + this.fetchData(); + } + + componentDidUpdate(prevProps: Props) { + if (prevProps.settings.length === 0 && this.props.settings.length > 0) { + this.setState({ settings: this.props.settings[0] }, () => this.fetchData()); + } + } + + handlePersonalAccessTokenCreated = async () => { + this.setState({ showPersonalAccessTokenForm: false }); + this.cleanUrl(); + await this.fetchData(); + }; + + cleanUrl = () => { + const { location, router } = this.props; + delete location.query.resetPat; + router.replace(location); + }; + + async fetchData() {} + + render() { + const { canAdmin, loadingBindings, location } = this.props; + const { settings, loading, showPersonalAccessTokenForm } = this.state; + return ( + <BitbucketCloudProjectCreateRenderer + settings={settings} + canAdmin={canAdmin} + loading={loading || loadingBindings} + onPersonalAccessTokenCreated={this.handlePersonalAccessTokenCreated} + resetPat={Boolean(location.query.resetPat)} + showPersonalAccessTokenForm={ + showPersonalAccessTokenForm || Boolean(location.query.resetPat) + } + /> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/create/project/BitbucketCloudProjectCreateRender.tsx b/server/sonar-web/src/main/js/apps/create/project/BitbucketCloudProjectCreateRender.tsx new file mode 100644 index 00000000000..a3c50b0dc0f --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/BitbucketCloudProjectCreateRender.tsx @@ -0,0 +1,76 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 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 { translate } from 'sonar-ui-common/helpers/l10n'; +import { getBaseUrl } from 'sonar-ui-common/helpers/urls'; +import { AlmKeys, AlmSettingsInstance } from '../../../types/alm-settings'; +import CreateProjectPageHeader from './CreateProjectPageHeader'; +import PersonalAccessTokenForm from './PersonalAccessTokenForm'; +import WrongBindingCountAlert from './WrongBindingCountAlert'; + +export interface BitbucketCloudProjectCreateRendererProps { + settings?: AlmSettingsInstance; + canAdmin?: boolean; + loading: boolean; + onPersonalAccessTokenCreated: () => void; + resetPat: boolean; + showPersonalAccessTokenForm: boolean; +} + +export default function BitbucketCloudProjectCreateRenderer( + props: BitbucketCloudProjectCreateRendererProps +) { + const { settings, canAdmin, loading, resetPat, showPersonalAccessTokenForm } = props; + + return ( + <> + <CreateProjectPageHeader + title={ + <span className="text-middle"> + <img + alt="" // Should be ignored by screen readers + className="spacer-right" + height="24" + src={`${getBaseUrl()}/images/alm/bitbucket.svg`} + /> + {translate('onboarding.create_project.bitbucketcloud.title')} + </span> + } + /> + {loading && <i className="spinner" />} + + {!loading && !settings && ( + <WrongBindingCountAlert alm={AlmKeys.BitbucketCloud} canAdmin={!!canAdmin} /> + )} + + {!loading && + settings && + (showPersonalAccessTokenForm ? ( + <PersonalAccessTokenForm + almSetting={settings} + resetPat={resetPat} + onPersonalAccessTokenCreated={props.onPersonalAccessTokenCreated} + /> + ) : ( + <p>Placeholder for next step</p> + ))} + </> + ); +} diff --git a/server/sonar-web/src/main/js/apps/create/project/BitbucketProjectCreate.tsx b/server/sonar-web/src/main/js/apps/create/project/BitbucketProjectCreate.tsx index c3d57d0cfbc..bb019e40aae 100644 --- a/server/sonar-web/src/main/js/apps/create/project/BitbucketProjectCreate.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/BitbucketProjectCreate.tsx @@ -20,12 +20,10 @@ import * as React from 'react'; import { WithRouterProps } from 'react-router'; import { - checkPersonalAccessTokenIsValid, getBitbucketServerProjects, getBitbucketServerRepositories, importBitbucketServerProject, - searchForBitbucketServerRepositories, - setAlmPersonalAccessToken + searchForBitbucketServerRepositories } from '../../../api/alm-integrations'; import { BitbucketProject, @@ -36,7 +34,7 @@ import { AlmSettingsInstance } from '../../../types/alm-settings'; import BitbucketCreateProjectRenderer from './BitbucketProjectCreateRenderer'; import { DEFAULT_BBS_PAGE_SIZE } from './constants'; -interface Props extends Pick<WithRouterProps, 'location'> { +interface Props extends Pick<WithRouterProps, 'location' | 'router'> { canAdmin: boolean; bitbucketSettings: AlmSettingsInstance[]; loadingBindings: boolean; @@ -47,14 +45,12 @@ interface State { bitbucketSetting?: AlmSettingsInstance; importing: boolean; loading: boolean; - patIsValid?: boolean; projects?: BitbucketProject[]; projectRepositories?: BitbucketProjectRepositories; searching: boolean; searchResults?: BitbucketRepository[]; selectedRepository?: BitbucketRepository; - submittingToken?: boolean; - tokenValidationFailed: boolean; + showPersonalAccessTokenForm: boolean; } export default class BitbucketProjectCreate extends React.PureComponent<Props, State> { @@ -69,13 +65,12 @@ export default class BitbucketProjectCreate extends React.PureComponent<Props, S importing: false, loading: false, searching: false, - tokenValidationFailed: false + showPersonalAccessTokenForm: true }; } componentDidMount() { this.mounted = true; - this.fetchInitialData(); } componentDidUpdate(prevProps: Props) { @@ -91,38 +86,27 @@ export default class BitbucketProjectCreate extends React.PureComponent<Props, S } fetchInitialData = async () => { - this.setState({ loading: true }); + const { showPersonalAccessTokenForm } = this.state; - const patIsValid = await this.checkPersonalAccessToken().catch(() => false); + if (!showPersonalAccessTokenForm) { + this.setState({ loading: true }); + const projects = await this.fetchBitbucketProjects().catch(() => undefined); - let projects; - if (patIsValid) { - projects = await this.fetchBitbucketProjects().catch(() => undefined); - } - - let projectRepositories; - if (projects && projects.length > 0) { - projectRepositories = await this.fetchBitbucketRepositories(projects).catch(() => undefined); - } - - if (this.mounted) { - this.setState({ - patIsValid, - projects, - projectRepositories, - loading: false - }); - } - }; - - checkPersonalAccessToken = () => { - const { bitbucketSetting } = this.state; - - if (!bitbucketSetting) { - return Promise.resolve(false); + let projectRepositories; + if (projects && projects.length > 0) { + projectRepositories = await this.fetchBitbucketRepositories(projects).catch( + () => undefined + ); + } + + if (this.mounted) { + this.setState({ + projects, + projectRepositories, + loading: false + }); + } } - - return checkPersonalAccessTokenIsValid(bitbucketSetting.key).then(({ status }) => status); }; fetchBitbucketProjects = (): Promise<BitbucketProject[] | undefined> => { @@ -184,29 +168,16 @@ export default class BitbucketProjectCreate extends React.PureComponent<Props, S }); }; - handlePersonalAccessTokenCreate = (token: string) => { - const { bitbucketSetting } = this.state; - - if (!bitbucketSetting || token.length < 1) { - return; - } + cleanUrl = () => { + const { location, router } = this.props; + delete location.query.resetPat; + router.replace(location); + }; - this.setState({ submittingToken: true, tokenValidationFailed: false }); - setAlmPersonalAccessToken(bitbucketSetting.key, token) - .then(this.checkPersonalAccessToken) - .then(patIsValid => { - if (this.mounted) { - this.setState({ submittingToken: false, patIsValid, tokenValidationFailed: !patIsValid }); - if (patIsValid) { - this.fetchInitialData(); - } - } - }) - .catch(() => { - if (this.mounted) { - this.setState({ submittingToken: false }); - } - }); + handlePersonalAccessTokenCreated = async () => { + this.setState({ showPersonalAccessTokenForm: false }); + this.cleanUrl(); + await this.fetchInitialData(); }; handleImportRepository = () => { @@ -271,14 +242,12 @@ export default class BitbucketProjectCreate extends React.PureComponent<Props, S bitbucketSetting, importing, loading, - patIsValid, projectRepositories, projects, searching, searchResults, selectedRepository, - submittingToken, - tokenValidationFailed + showPersonalAccessTokenForm } = this.state; return ( @@ -288,18 +257,19 @@ export default class BitbucketProjectCreate extends React.PureComponent<Props, S importing={importing} loading={loading || loadingBindings} onImportRepository={this.handleImportRepository} - onPersonalAccessTokenCreate={this.handlePersonalAccessTokenCreate} + onPersonalAccessTokenCreated={this.handlePersonalAccessTokenCreated} onProjectCreate={this.props.onProjectCreate} onSearch={this.handleSearch} onSelectRepository={this.handleSelectRepository} projectRepositories={projectRepositories} projects={projects} + resetPat={Boolean(location.query.resetPat)} searchResults={searchResults} searching={searching} selectedRepository={selectedRepository} - showPersonalAccessTokenForm={!patIsValid || Boolean(location.query.resetPat)} - submittingToken={submittingToken} - tokenValidationFailed={tokenValidationFailed} + showPersonalAccessTokenForm={ + showPersonalAccessTokenForm || Boolean(location.query.resetPat) + } /> ); } diff --git a/server/sonar-web/src/main/js/apps/create/project/BitbucketProjectCreateRenderer.tsx b/server/sonar-web/src/main/js/apps/create/project/BitbucketProjectCreateRenderer.tsx index 0f71610155b..7b832f45b78 100644 --- a/server/sonar-web/src/main/js/apps/create/project/BitbucketProjectCreateRenderer.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/BitbucketProjectCreateRenderer.tsx @@ -41,16 +41,15 @@ export interface BitbucketProjectCreateRendererProps { onImportRepository: () => void; onSearch: (query: string) => void; onSelectRepository: (repo: BitbucketRepository) => void; - onPersonalAccessTokenCreate: (token: string) => void; + onPersonalAccessTokenCreated: () => void; onProjectCreate: (projectKeys: string[]) => void; projects?: BitbucketProject[]; projectRepositories?: BitbucketProjectRepositories; + resetPat: boolean; searching: boolean; searchResults?: BitbucketRepository[]; selectedRepository?: BitbucketRepository; showPersonalAccessTokenForm?: boolean; - submittingToken?: boolean; - tokenValidationFailed: boolean; } export default function BitbucketProjectCreateRenderer(props: BitbucketProjectCreateRendererProps) { @@ -65,8 +64,7 @@ export default function BitbucketProjectCreateRenderer(props: BitbucketProjectCr searching, searchResults, showPersonalAccessTokenForm, - submittingToken, - tokenValidationFailed + resetPat } = props; return ( @@ -109,9 +107,8 @@ export default function BitbucketProjectCreateRenderer(props: BitbucketProjectCr (showPersonalAccessTokenForm ? ( <PersonalAccessTokenForm almSetting={bitbucketSetting} - onPersonalAccessTokenCreate={props.onPersonalAccessTokenCreate} - submitting={submittingToken} - validationFailed={tokenValidationFailed} + onPersonalAccessTokenCreated={props.onPersonalAccessTokenCreated} + resetPat={resetPat} /> ) : ( <BitbucketImportRepositoryForm diff --git a/server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx b/server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx index 45feaadb2ad..f49ab34161b 100644 --- a/server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx @@ -28,6 +28,7 @@ import { withAppState } from '../../../components/hoc/withAppState'; import { getProjectUrl } from '../../../helpers/urls'; import { AlmKeys, AlmSettingsInstance } from '../../../types/alm-settings'; import AzureProjectCreate from './AzureProjectCreate'; +import BitbucketCloudProjectCreate from './BitbucketCloudProjectCreate'; import BitbucketProjectCreate from './BitbucketProjectCreate'; import CreateProjectModeSelection from './CreateProjectModeSelection'; import GitHubProjectCreate from './GitHubProjectCreate'; @@ -44,6 +45,7 @@ interface Props extends Pick<WithRouterProps, 'router' | 'location'> { interface State { azureSettings: AlmSettingsInstance[]; bitbucketSettings: AlmSettingsInstance[]; + bitbucketCloudSettings: AlmSettingsInstance[]; githubSettings: AlmSettingsInstance[]; gitlabSettings: AlmSettingsInstance[]; loading: boolean; @@ -54,6 +56,7 @@ export class CreateProjectPage extends React.PureComponent<Props, State> { state: State = { azureSettings: [], bitbucketSettings: [], + bitbucketCloudSettings: [], githubSettings: [], gitlabSettings: [], loading: true @@ -76,6 +79,7 @@ export class CreateProjectPage extends React.PureComponent<Props, State> { this.setState({ azureSettings: almSettings.filter(s => s.alm === AlmKeys.Azure), bitbucketSettings: almSettings.filter(s => s.alm === AlmKeys.BitbucketServer), + bitbucketCloudSettings: almSettings.filter(s => s.alm === AlmKeys.BitbucketCloud), githubSettings: almSettings.filter(s => s.alm === AlmKeys.GitHub), gitlabSettings: almSettings.filter(s => s.alm === AlmKeys.GitLab), loading: false @@ -112,6 +116,7 @@ export class CreateProjectPage extends React.PureComponent<Props, State> { const { azureSettings, bitbucketSettings, + bitbucketCloudSettings, githubSettings, gitlabSettings, loading @@ -138,6 +143,19 @@ export class CreateProjectPage extends React.PureComponent<Props, State> { loadingBindings={loading} location={location} onProjectCreate={this.handleProjectCreate} + router={router} + /> + ); + } + case CreateProjectModes.BitbucketCloud: { + return ( + <BitbucketCloudProjectCreate + canAdmin={!!canAdmin} + loadingBindings={loading} + location={location} + onProjectCreate={this.handleProjectCreate} + router={router} + settings={bitbucketCloudSettings} /> ); } diff --git a/server/sonar-web/src/main/js/apps/create/project/GitlabProjectCreate.tsx b/server/sonar-web/src/main/js/apps/create/project/GitlabProjectCreate.tsx index 39b8537581f..a52bd7a2d54 100644 --- a/server/sonar-web/src/main/js/apps/create/project/GitlabProjectCreate.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/GitlabProjectCreate.tsx @@ -19,15 +19,9 @@ */ import * as React from 'react'; import { WithRouterProps } from 'react-router'; -import { translate } from 'sonar-ui-common/helpers/l10n'; -import { - checkPersonalAccessTokenIsValid, - getGitlabProjects, - importGitlabProject, - setAlmPersonalAccessToken -} from '../../../api/alm-integrations'; +import { getGitlabProjects, importGitlabProject } from '../../../api/alm-integrations'; import { GitlabProject } from '../../../types/alm-integration'; -import { AlmKeys, AlmSettingsInstance } from '../../../types/alm-settings'; +import { AlmSettingsInstance } from '../../../types/alm-settings'; import GitlabProjectCreateRenderer from './GitlabProjectCreateRenderer'; interface Props extends Pick<WithRouterProps, 'location' | 'router'> { @@ -43,12 +37,11 @@ interface State { loadingMore: boolean; projects?: GitlabProject[]; projectsPaging: T.Paging; - submittingToken: boolean; - tokenIsValid: boolean; - tokenValidationErrorMessage?: string; + resetPat: boolean; searching: boolean; searchQuery: string; settings?: AlmSettingsInstance; + showPersonalAccessTokenForm: boolean; } const GITLAB_PROJECTS_PAGESIZE = 30; @@ -63,17 +56,16 @@ export default class GitlabProjectCreate extends React.PureComponent<Props, Stat loading: false, loadingMore: false, projectsPaging: { pageIndex: 1, total: 0, pageSize: GITLAB_PROJECTS_PAGESIZE }, - tokenIsValid: false, + resetPat: false, + showPersonalAccessTokenForm: true, searching: false, searchQuery: '', - settings: props.settings.length === 1 ? props.settings[0] : undefined, - submittingToken: false + settings: props.settings.length === 1 ? props.settings[0] : undefined }; } componentDidMount() { this.mounted = true; - this.fetchInitialData(); } componentDidUpdate(prevProps: Props) { @@ -90,50 +82,30 @@ export default class GitlabProjectCreate extends React.PureComponent<Props, Stat } fetchInitialData = async () => { - this.setState({ loading: true }); + const { showPersonalAccessTokenForm } = this.state; - const { status, error } = await this.checkPersonalAccessToken(); - - let result; - if (status) { - result = await this.fetchProjects(); - } - - if (this.mounted) { - if (result) { + if (!showPersonalAccessTokenForm) { + this.setState({ loading: true }); + const result = await this.fetchProjects(); + if (this.mounted && result) { const { projects, projectsPaging } = result; this.setState({ - tokenIsValid: status, loading: false, projects, projectsPaging }); } else { this.setState({ - loading: false, - tokenValidationErrorMessage: !status ? error : undefined + loading: false }); } } }; - checkPersonalAccessToken = () => { - const { settings } = this.state; - - if (!settings) { - return Promise.resolve({ - status: false, - error: translate('onboarding.create_project.pat_incorrect', AlmKeys.GitLab) - }); - } - - return checkPersonalAccessTokenIsValid(settings.key); - }; - handleError = () => { if (this.mounted) { - this.setState({ tokenIsValid: false }); + this.setState({ resetPat: true, showPersonalAccessTokenForm: true }); } return undefined; @@ -141,7 +113,6 @@ export default class GitlabProjectCreate extends React.PureComponent<Props, Stat fetchProjects = async (pageIndex = 1, query?: string) => { const { settings } = this.state; - if (!settings) { return Promise.resolve(undefined); } @@ -228,37 +199,10 @@ export default class GitlabProjectCreate extends React.PureComponent<Props, Stat router.replace(location); }; - handlePersonalAccessTokenCreate = async (token: string) => { - const { settings } = this.state; - - if (!settings || token.length < 1) { - return; - } - - this.setState({ submittingToken: true, tokenValidationErrorMessage: undefined }); - - try { - await setAlmPersonalAccessToken(settings.key, token); - - const { status, error } = await this.checkPersonalAccessToken(); - - if (this.mounted) { - this.setState({ - submittingToken: false, - tokenIsValid: status, - tokenValidationErrorMessage: error - }); - - if (status) { - this.cleanUrl(); - await this.fetchInitialData(); - } - } - } catch (e) { - if (this.mounted) { - this.setState({ submittingToken: false }); - } - } + handlePersonalAccessTokenCreated = async () => { + this.setState({ showPersonalAccessTokenForm: false, resetPat: false }); + this.cleanUrl(); + await this.fetchInitialData(); }; render() { @@ -269,12 +213,11 @@ export default class GitlabProjectCreate extends React.PureComponent<Props, Stat loadingMore, projects, projectsPaging, - tokenIsValid, + resetPat, searching, searchQuery, settings, - submittingToken, - tokenValidationErrorMessage + showPersonalAccessTokenForm } = this.state; return ( @@ -286,15 +229,16 @@ export default class GitlabProjectCreate extends React.PureComponent<Props, Stat loadingMore={loadingMore} onImport={this.handleImport} onLoadMore={this.handleLoadMore} - onPersonalAccessTokenCreate={this.handlePersonalAccessTokenCreate} + onPersonalAccessTokenCreated={this.handlePersonalAccessTokenCreated} onSearch={this.handleSearch} projects={projects} projectsPaging={projectsPaging} + resetPat={resetPat || Boolean(location.query.resetPat)} searching={searching} searchQuery={searchQuery} - showPersonalAccessTokenForm={!tokenIsValid || Boolean(location.query.resetPat)} - submittingToken={submittingToken} - tokenValidationErrorMessage={tokenValidationErrorMessage} + showPersonalAccessTokenForm={ + showPersonalAccessTokenForm || Boolean(location.query.resetPat) + } /> ); } diff --git a/server/sonar-web/src/main/js/apps/create/project/GitlabProjectCreateRenderer.tsx b/server/sonar-web/src/main/js/apps/create/project/GitlabProjectCreateRenderer.tsx index e75f5809845..25795300b5b 100644 --- a/server/sonar-web/src/main/js/apps/create/project/GitlabProjectCreateRenderer.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/GitlabProjectCreateRenderer.tsx @@ -34,16 +34,15 @@ export interface GitlabProjectCreateRendererProps { loadingMore: boolean; onImport: (gitlabProjectId: string) => void; onLoadMore: () => void; - onPersonalAccessTokenCreate: (pat: string) => void; + onPersonalAccessTokenCreated: () => void; onSearch: (searchQuery: string) => void; projects?: GitlabProject[]; projectsPaging: T.Paging; + resetPat: boolean; searching: boolean; searchQuery: string; settings?: AlmSettingsInstance; showPersonalAccessTokenForm?: boolean; - submittingToken?: boolean; - tokenValidationErrorMessage?: string; } export default function GitlabProjectCreateRenderer(props: GitlabProjectCreateRendererProps) { @@ -54,12 +53,11 @@ export default function GitlabProjectCreateRenderer(props: GitlabProjectCreateRe loadingMore, projects, projectsPaging, + resetPat, searching, searchQuery, settings, - showPersonalAccessTokenForm, - submittingToken, - tokenValidationErrorMessage + showPersonalAccessTokenForm } = props; return ( @@ -89,10 +87,8 @@ export default function GitlabProjectCreateRenderer(props: GitlabProjectCreateRe (showPersonalAccessTokenForm ? ( <PersonalAccessTokenForm almSetting={settings} - onPersonalAccessTokenCreate={props.onPersonalAccessTokenCreate} - submitting={submittingToken} - validationFailed={Boolean(tokenValidationErrorMessage)} - validationErrorMessage={tokenValidationErrorMessage} + resetPat={resetPat} + onPersonalAccessTokenCreated={props.onPersonalAccessTokenCreated} /> ) : ( <GitlabProjectSelectionForm diff --git a/server/sonar-web/src/main/js/apps/create/project/PersonalAccessTokenForm.tsx b/server/sonar-web/src/main/js/apps/create/project/PersonalAccessTokenForm.tsx index 3fb0a2fe63f..469061ef70e 100644 --- a/server/sonar-web/src/main/js/apps/create/project/PersonalAccessTokenForm.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/PersonalAccessTokenForm.tsx @@ -26,19 +26,33 @@ import { Alert } from 'sonar-ui-common/components/ui/Alert'; import DeferredSpinner from 'sonar-ui-common/components/ui/DeferredSpinner'; import { translate } from 'sonar-ui-common/helpers/l10n'; import { getBaseUrl } from 'sonar-ui-common/helpers/urls'; +import { + checkPersonalAccessTokenIsValid, + setAlmPersonalAccessToken +} from '../../../api/alm-integrations'; import { AlmKeys, AlmSettingsInstance } from '../../../types/alm-settings'; -export interface PersonalAccessTokenFormProps { +interface Props { almSetting: AlmSettingsInstance; - onPersonalAccessTokenCreate: (token: string) => void; - submitting?: boolean; + resetPat: boolean; + onPersonalAccessTokenCreated: () => void; +} + +interface State { validationFailed: boolean; validationErrorMessage?: string; + touched: boolean; + password: string; + username?: string; + submitting: boolean; + checkingPat: boolean; } -function getPatUrl(alm: AlmKeys, url: string) { +function getPatUrl(alm: AlmKeys, url = '') { if (alm === AlmKeys.BitbucketServer) { return `${url.replace(/\/$/, '')}/plugins/servlet/access-tokens/add`; + } else if (alm === AlmKeys.BitbucketCloud) { + return 'https://bitbucket.org/account/settings/app-passwords/new'; } else { // GitLab return url.endsWith('/api/v4') @@ -47,97 +61,236 @@ function getPatUrl(alm: AlmKeys, url: string) { } } -export default function PersonalAccessTokenForm(props: PersonalAccessTokenFormProps) { - const { - almSetting: { alm, url }, - submitting = false, - validationFailed, - validationErrorMessage - } = props; - const [touched, setTouched] = React.useState(false); - - React.useEffect(() => { - setTouched(false); - }, [submitting]); - - const isInvalid = validationFailed && !touched; - const errorMessage = - validationErrorMessage ?? translate('onboarding.create_project.pat_incorrect', alm); - - return ( - <div className="display-flex-start"> - <form - className="width-50" - onSubmit={(e: React.SyntheticEvent<HTMLFormElement>) => { - e.preventDefault(); - const value = new FormData(e.currentTarget).get('personal_access_token') as string; - props.onPersonalAccessTokenCreate(value); - }}> - <h2 className="big">{translate('onboarding.create_project.pat_form.title', alm)}</h2> - <p className="big-spacer-top big-spacer-bottom"> - {translate('onboarding.create_project.pat_form.help', alm)} - </p> - - <ValidationInput - error={isInvalid ? errorMessage : undefined} - id="personal_access_token" - isInvalid={isInvalid} - isValid={false} - label={translate('onboarding.create_project.enter_pat')} - required={true}> - <input - autoFocus={true} - className={classNames('input-super-large', { - 'is-invalid': isInvalid - })} - id="personal_access_token" - minLength={1} - name="personal_access_token" - onChange={() => { - setTouched(true); - }} - type="text" - /> - </ValidationInput> - - <SubmitButton disabled={isInvalid || submitting || !touched}> - {translate('save')} - </SubmitButton> - <DeferredSpinner className="spacer-left" loading={submitting} /> - </form> - - <Alert className="big-spacer-left width-50" display="block" variant="info"> - <h3>{translate('onboarding.create_project.pat_help.title')}</h3> - - <p className="big-spacer-top big-spacer-bottom"> - <FormattedMessage - id="onboarding.create_project.pat_help.instructions" - defaultMessage={translate('onboarding.create_project.pat_help.instructions')} - values={{ alm: translate('onboarding.alm', alm) }} - /> - </p> - - {url && ( - <div className="text-middle"> - <img - alt="" // Should be ignored by screen readers - className="spacer-right" - height="16" - src={`${getBaseUrl()}/images/alm/${alm}.svg`} +export default class PersonalAccessTokenForm extends React.PureComponent<Props, State> { + mounted = false; + + constructor(props: Props) { + super(props); + + this.state = { + checkingPat: false, + touched: false, + password: '', + submitting: false, + validationFailed: false + }; + } + + async componentDidMount() { + const { + almSetting: { key }, + resetPat + } = this.props; + this.mounted = true; + + // We don't need to check PAT if we want to reset + if (!resetPat) { + this.setState({ checkingPat: true }); + const { patIsValid, error } = await checkPersonalAccessTokenIsValid(key) + .then(({ status, error }) => ({ patIsValid: status, error })) + .catch(() => ({ patIsValid: status, error: translate('default_error_message') })); + if (patIsValid) { + this.props.onPersonalAccessTokenCreated(); + } + if (this.mounted) { + // This is the initial message when no token was provided + if (error === `personal access token for '${key}' is missing`) { + this.setState({ + checkingPat: false + }); + } else { + this.setState({ + checkingPat: false, + validationFailed: true, + validationErrorMessage: error + }); + } + } + } + } + + componentWillUnmount() { + this.mounted = false; + } + + handleUsernameChange = (event: React.ChangeEvent<HTMLInputElement>) => { + this.setState({ + touched: true, + username: event.target.value + }); + }; + + handlePasswordChange = (event: React.ChangeEvent<HTMLInputElement>) => { + this.setState({ + touched: true, + password: event.target.value + }); + }; + + handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => { + const { password, username } = this.state; + const { + almSetting: { key } + } = this.props; + + e.preventDefault(); + if (password) { + this.setState({ submitting: true }); + + await setAlmPersonalAccessToken(key, password, username).catch(() => { + /* Set will not check pat validity. We need to check again so we will catch issue after */ + }); + + const { status, error } = await checkPersonalAccessTokenIsValid(key) + .then(({ status, error }) => ({ status, error })) + .catch(() => ({ status: false, error: translate('default_error_message') })); + + if (this.mounted && status) { + // Let's reset status, + this.setState({ + checkingPat: false, + touched: false, + password: '', + submitting: false, + username: '', + validationFailed: false + }); + this.props.onPersonalAccessTokenCreated(); + } else if (this.mounted) { + this.setState({ + submitting: false, + touched: false, + validationFailed: true, + validationErrorMessage: error + }); + } + } + }; + + render() { + const { + almSetting: { alm, url } + } = this.props; + const { + checkingPat, + submitting, + touched, + password, + username, + validationFailed, + validationErrorMessage + } = this.state; + + if (checkingPat) { + return <DeferredSpinner className="spacer-left" loading={true} />; + } + + const suffixTranslationKey = alm === AlmKeys.BitbucketCloud ? '.bitbucketcloud' : ''; + + const isInvalid = validationFailed && !touched; + const canSubmit = Boolean(password) && (alm !== AlmKeys.BitbucketCloud || Boolean(username)); + const submitButtonDiabled = isInvalid || submitting || !canSubmit; + + const errorMessage = + validationErrorMessage ?? translate('onboarding.create_project.pat_incorrect', alm); + + return ( + <div className="display-flex-start"> + <form className="width-50" onSubmit={this.handleSubmit}> + <h2 className="big">{translate('onboarding.create_project.pat_form.title', alm)}</h2> + <p className="big-spacer-top big-spacer-bottom"> + {translate('onboarding.create_project.pat_form.help', alm)} + </p> + + {alm === AlmKeys.BitbucketCloud && ( + <ValidationInput + error={undefined} + id="enter_username_validation" + isInvalid={false} + isValid={false} + label={translate('onboarding.create_project.enter_username')} + required={true}> + <input + autoFocus={true} + className={classNames('input-super-large', { + 'is-invalid': isInvalid + })} + id="username" + minLength={1} + name="username" + value={username} + onChange={this.handleUsernameChange} + type="text" + /> + </ValidationInput> + )} + + <ValidationInput + error={errorMessage} + id="personal_access_token_validation" + isInvalid={false} + isValid={false} + label={translate(`onboarding.create_project.enter_pat${suffixTranslationKey}`)} + required={true}> + <input + autoFocus={alm !== AlmKeys.BitbucketCloud} + className={classNames('input-super-large', { + 'is-invalid': isInvalid + })} + id="personal_access_token" + minLength={1} + value={password} + onChange={this.handlePasswordChange} + type="text" + /> + </ValidationInput> + + <ValidationInput + error={errorMessage} + id="personal_access_token_submit" + isInvalid={isInvalid} + isValid={false} + label={null}> + <SubmitButton disabled={submitButtonDiabled}>{translate('save')}</SubmitButton> + <DeferredSpinner className="spacer-left" loading={submitting} /> + </ValidationInput> + </form> + + <Alert className="big-spacer-left width-50" display="block" variant="info"> + <h3>{translate(`onboarding.create_project.pat_help${suffixTranslationKey}.title`)}</h3> + + <p className="big-spacer-top big-spacer-bottom"> + <FormattedMessage + id="onboarding.create_project.pat_help.instructions" + defaultMessage={translate( + `onboarding.create_project.pat_help${suffixTranslationKey}.instructions` + )} + values={{ alm: translate('onboarding.alm', alm) }} /> - <a href={getPatUrl(alm, url)} rel="noopener noreferrer" target="_blank"> - {translate('onboarding.create_project.pat_help.link')} - </a> - </div> - )} - - <p className="big-spacer-top big-spacer-bottom"> - {translate('onboarding.create_project.pat_help.instructions2', alm)} - </p> - - <ul> - {alm === AlmKeys.BitbucketServer && ( - <> + </p> + + {(url || alm === AlmKeys.BitbucketCloud) && ( + <div className="text-middle"> + <img + alt="" // Should be ignored by screen readers + className="spacer-right" + height="16" + src={`${getBaseUrl()}/images/alm/${ + alm === AlmKeys.BitbucketCloud ? AlmKeys.BitbucketServer : alm + }.svg`} + /> + <a href={getPatUrl(alm, url)} rel="noopener noreferrer" target="_blank"> + {translate(`onboarding.create_project.pat_help${suffixTranslationKey}.link`)} + </a> + </div> + )} + + <p className="big-spacer-top big-spacer-bottom"> + {translate('onboarding.create_project.pat_help.instructions2', alm)} + </p> + + <ul> + {alm === AlmKeys.BitbucketServer && ( <li> <FormattedMessage defaultMessage={translate( @@ -153,6 +306,8 @@ export default function PersonalAccessTokenForm(props: PersonalAccessTokenFormPr }} /> </li> + )} + {(alm === AlmKeys.BitbucketServer || alm === AlmKeys.BitbucketCloud) && ( <li> <FormattedMessage defaultMessage={translate( @@ -168,17 +323,18 @@ export default function PersonalAccessTokenForm(props: PersonalAccessTokenFormPr }} /> </li> - </> - )} - {alm === AlmKeys.GitLab && ( - <li className="spacer-bottom"> - <strong> - {translate('onboarding.create_project.pat_help.gitlab.read_api_permission')} - </strong> - </li> - )} - </ul> - </Alert> - </div> - ); + )} + + {alm === AlmKeys.GitLab && ( + <li className="spacer-bottom"> + <strong> + {translate('onboarding.create_project.pat_help.gitlab.read_api_permission')} + </strong> + </li> + )} + </ul> + </Alert> + </div> + ); + } } diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/BitbucketCloudProjectCreate-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/BitbucketCloudProjectCreate-test.tsx new file mode 100644 index 00000000000..031e730d056 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/BitbucketCloudProjectCreate-test.tsx @@ -0,0 +1,65 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 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 { shallow } from 'enzyme'; +import * as React from 'react'; +import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; +import { checkPersonalAccessTokenIsValid } from '../../../../api/alm-integrations'; +import { mockBitbucketCloudAlmSettingsInstance } from '../../../../helpers/mocks/alm-settings'; +import { mockLocation, mockRouter } from '../../../../helpers/testMocks'; +import BitbucketCloudProjectCreate from '../BitbucketCloudProjectCreate'; + +jest.mock('../../../../api/alm-integrations', () => { + return { + checkPersonalAccessTokenIsValid: jest.fn().mockResolvedValue({ status: true }), + setAlmPersonalAccessToken: jest.fn().mockResolvedValue(null) + }; +}); + +it('Should render correctly', async () => { + let wrapper = shallowRender(); + await waitAndUpdate(wrapper); + expect(wrapper).toMatchSnapshot(); + (checkPersonalAccessTokenIsValid as jest.Mock).mockRejectedValueOnce({}); + wrapper = shallowRender(); + await waitAndUpdate(wrapper); + expect(wrapper).toMatchSnapshot('Need App password'); +}); + +it('Should handle app password correctly', async () => { + const wrapper = shallowRender(); + + await waitAndUpdate(wrapper); + await wrapper.instance().handlePersonalAccessTokenCreated(); + expect(wrapper.state().showPersonalAccessTokenForm).toBe(false); +}); + +function shallowRender(props?: Partial<BitbucketCloudProjectCreate['props']>) { + return shallow<BitbucketCloudProjectCreate>( + <BitbucketCloudProjectCreate + onProjectCreate={jest.fn()} + loadingBindings={false} + location={mockLocation()} + canAdmin={true} + router={mockRouter()} + settings={[mockBitbucketCloudAlmSettingsInstance()]} + {...props} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/BitbucketCloudProjectCreateRender-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/BitbucketCloudProjectCreateRender-test.tsx new file mode 100644 index 00000000000..228467a76d6 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/BitbucketCloudProjectCreateRender-test.tsx @@ -0,0 +1,49 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 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 { shallow } from 'enzyme'; +import * as React from 'react'; +import { mockBitbucketCloudAlmSettingsInstance } from '../../../../helpers/mocks/alm-settings'; +import BitbucketCloudProjectCreateRenderer, { + BitbucketCloudProjectCreateRendererProps +} from '../BitbucketCloudProjectCreateRender'; + +it('Should render correctly', () => { + expect(shallowRender()).toMatchSnapshot(); + expect(shallowRender({ settings: undefined })).toMatchSnapshot('Wrong config'); + expect(shallowRender({ loading: true })).toMatchSnapshot('Loading...'); + expect( + shallowRender({ + showPersonalAccessTokenForm: true + }) + ).toMatchSnapshot('Need App password'); +}); + +function shallowRender(props?: Partial<BitbucketCloudProjectCreateRendererProps>) { + return shallow( + <BitbucketCloudProjectCreateRenderer + onPersonalAccessTokenCreated={jest.fn()} + loading={false} + settings={mockBitbucketCloudAlmSettingsInstance()} + resetPat={false} + showPersonalAccessTokenForm={false} + {...props} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/BitbucketProjectCreate-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/BitbucketProjectCreate-test.tsx index bb3fd5ae7df..3abe3432dca 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/BitbucketProjectCreate-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/BitbucketProjectCreate-test.tsx @@ -21,16 +21,17 @@ import { shallow } from 'enzyme'; import * as React from 'react'; import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; import { - checkPersonalAccessTokenIsValid, getBitbucketServerProjects, getBitbucketServerRepositories, importBitbucketServerProject, - searchForBitbucketServerRepositories, - setAlmPersonalAccessToken + searchForBitbucketServerRepositories } from '../../../../api/alm-integrations'; -import { mockBitbucketRepository } from '../../../../helpers/mocks/alm-integrations'; +import { + mockBitbucketProject, + mockBitbucketRepository +} from '../../../../helpers/mocks/alm-integrations'; import { mockAlmSettingsInstance } from '../../../../helpers/mocks/alm-settings'; -import { mockLocation } from '../../../../helpers/testMocks'; +import { mockLocation, mockRouter } from '../../../../helpers/testMocks'; import { AlmKeys } from '../../../../types/alm-settings'; import BitbucketProjectCreate from '../BitbucketProjectCreate'; @@ -39,7 +40,6 @@ jest.mock('../../../../api/alm-integrations', () => { '../../../../helpers/mocks/alm-integrations' ); return { - checkPersonalAccessTokenIsValid: jest.fn().mockResolvedValue({ status: true }), getBitbucketServerProjects: jest.fn().mockResolvedValue({ projects: [ mockBitbucketProject({ key: 'project1', name: 'Project 1' }), @@ -53,7 +53,6 @@ jest.mock('../../../../api/alm-integrations', () => { ] }), importBitbucketServerProject: jest.fn().mockResolvedValue({ project: { key: 'baz' } }), - setAlmPersonalAccessToken: jest.fn().mockResolvedValue(null), searchForBitbucketServerRepositories: jest.fn().mockResolvedValue({ repositories: [ mockBitbucketRepository(), @@ -65,50 +64,21 @@ jest.mock('../../../../api/alm-integrations', () => { beforeEach(jest.clearAllMocks); -it('should render correctly', () => { +it('should render correctly', async () => { expect(shallowRender()).toMatchSnapshot(); -}); - -it('should correctly fetch binding info on mount', async () => { - const wrapper = shallowRender(); - await waitAndUpdate(wrapper); - expect(checkPersonalAccessTokenIsValid).toBeCalledWith('foo'); -}); - -it('should correctly handle a valid PAT', async () => { - (checkPersonalAccessTokenIsValid as jest.Mock).mockResolvedValueOnce({ status: true }); - const wrapper = shallowRender(); - await waitAndUpdate(wrapper); - expect(checkPersonalAccessTokenIsValid).toBeCalled(); - expect(wrapper.state().patIsValid).toBe(true); -}); + expect(shallowRender({ bitbucketSettings: [] })).toMatchSnapshot('No setting'); -it('should correctly handle an invalid PAT', async () => { - (checkPersonalAccessTokenIsValid as jest.Mock).mockResolvedValueOnce({ status: false }); const wrapper = shallowRender(); - await waitAndUpdate(wrapper); - expect(checkPersonalAccessTokenIsValid).toBeCalled(); - expect(wrapper.state().patIsValid).toBe(false); -}); - -it('should correctly handle setting a new PAT', async () => { - const wrapper = shallowRender(); - wrapper.instance().handlePersonalAccessTokenCreate('token'); - expect(setAlmPersonalAccessToken).toBeCalledWith('foo', 'token'); - expect(wrapper.state().submittingToken).toBe(true); - - (checkPersonalAccessTokenIsValid as jest.Mock).mockResolvedValueOnce({ status: false }); - await waitAndUpdate(wrapper); - expect(checkPersonalAccessTokenIsValid).toBeCalled(); - expect(wrapper.state().submittingToken).toBe(false); - expect(wrapper.state().tokenValidationFailed).toBe(true); + (getBitbucketServerRepositories as jest.Mock).mockRejectedValueOnce({}); + await wrapper.instance().handlePersonalAccessTokenCreated(); + expect(wrapper).toMatchSnapshot('No repository'); }); it('should correctly fetch projects and repos', async () => { const wrapper = shallowRender(); + await wrapper.instance().handlePersonalAccessTokenCreated(); // Opens first project on mount. - await waitAndUpdate(wrapper); expect(getBitbucketServerProjects).toBeCalledWith('foo'); expect(wrapper.state().projects).toHaveLength(2); @@ -159,6 +129,17 @@ it('should correctly handle search', async () => { expect(wrapper.state().searchResults).toHaveLength(2); }); +it('should behave correctly when no setting', async () => { + const wrapper = shallowRender({ bitbucketSettings: [] }); + await wrapper.instance().handleSearch(''); + await wrapper.instance().handleImportRepository(); + await wrapper.instance().fetchBitbucketRepositories([mockBitbucketProject()]); + + expect(searchForBitbucketServerRepositories).not.toHaveBeenCalled(); + expect(importBitbucketServerProject).not.toHaveBeenCalled(); + expect(getBitbucketServerRepositories).not.toHaveBeenCalled(); +}); + function shallowRender(props: Partial<BitbucketProjectCreate['props']> = {}) { return shallow<BitbucketProjectCreate>( <BitbucketProjectCreate @@ -166,6 +147,7 @@ function shallowRender(props: Partial<BitbucketProjectCreate['props']> = {}) { bitbucketSettings={[mockAlmSettingsInstance({ alm: AlmKeys.BitbucketServer, key: 'foo' })]} loadingBindings={false} location={mockLocation()} + router={mockRouter()} onProjectCreate={jest.fn()} {...props} /> diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/BitbucketProjectCreateRenderer-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/BitbucketProjectCreateRenderer-test.tsx index 127da7584f4..5d523fe6705 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/BitbucketProjectCreateRenderer-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/BitbucketProjectCreateRenderer-test.tsx @@ -52,14 +52,14 @@ function shallowRender(props: Partial<BitbucketProjectCreateRendererProps> = {}) importing={false} loading={false} onImportRepository={jest.fn()} - onPersonalAccessTokenCreate={jest.fn()} + onPersonalAccessTokenCreated={jest.fn()} onProjectCreate={jest.fn()} onSearch={jest.fn()} onSelectRepository={jest.fn()} projectRepositories={{ foo: { allShown: true, repositories: [mockBitbucketRepository()] } }} projects={[mockBitbucketProject({ key: 'foo' })]} + resetPat={false} searching={false} - tokenValidationFailed={false} {...props} /> ); diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/GitlabProjectCreate-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/GitlabProjectCreate-test.tsx index e5663ff7734..1f677f1bef7 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/GitlabProjectCreate-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/GitlabProjectCreate-test.tsx @@ -20,12 +20,7 @@ import { shallow } from 'enzyme'; import * as React from 'react'; import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; -import { - checkPersonalAccessTokenIsValid, - getGitlabProjects, - importGitlabProject, - setAlmPersonalAccessToken -} from '../../../../api/alm-integrations'; +import { getGitlabProjects, importGitlabProject } from '../../../../api/alm-integrations'; import { mockGitlabProject } from '../../../../helpers/mocks/alm-integrations'; import { mockAlmSettingsInstance } from '../../../../helpers/mocks/alm-settings'; import { mockLocation, mockRouter } from '../../../../helpers/testMocks'; @@ -33,8 +28,6 @@ import { AlmKeys } from '../../../../types/alm-settings'; import GitlabProjectCreate from '../GitlabProjectCreate'; jest.mock('../../../../api/alm-integrations', () => ({ - checkPersonalAccessTokenIsValid: jest.fn().mockResolvedValue({ status: true }), - setAlmPersonalAccessToken: jest.fn().mockResolvedValue(null), getGitlabProjects: jest.fn().mockRejectedValue('error'), importGitlabProject: jest.fn().mockRejectedValue('error') })); @@ -47,84 +40,7 @@ it('should render correctly', () => { expect(shallowRender()).toMatchSnapshot(); }); -it('should correctly check PAT on mount', async () => { - const wrapper = shallowRender(); - await waitAndUpdate(wrapper); - expect(checkPersonalAccessTokenIsValid).toBeCalledWith(almSettingKey); -}); - -it('should correctly check PAT when settings are added after mount', async () => { - const wrapper = shallowRender({ settings: [] }); - await waitAndUpdate(wrapper); - - wrapper.setProps({ - settings: [mockAlmSettingsInstance({ alm: AlmKeys.GitLab, key: 'otherKey' })] - }); - - expect(checkPersonalAccessTokenIsValid).toBeCalledWith('otherKey'); -}); - -it('should correctly handle a valid PAT', async () => { - (checkPersonalAccessTokenIsValid as jest.Mock).mockResolvedValueOnce({ status: true }); - (getGitlabProjects as jest.Mock).mockResolvedValueOnce({ - projects: [mockGitlabProject()], - projectsPaging: { - pageIndex: 1, - pageSize: 10, - total: 1 - } - }); - const wrapper = shallowRender(); - await waitAndUpdate(wrapper); - expect(wrapper.state().tokenIsValid).toBe(true); -}); - -it('should correctly handle an invalid PAT', async () => { - (checkPersonalAccessTokenIsValid as jest.Mock).mockResolvedValueOnce({ status: false }); - const wrapper = shallowRender(); - await waitAndUpdate(wrapper); - expect(wrapper.state().tokenIsValid).toBe(false); -}); - -describe('setting a new PAT', () => { - const routerReplace = jest.fn(); - const wrapper = shallowRender({ router: mockRouter({ replace: routerReplace }) }); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should correctly handle it if invalid', async () => { - const error = 'error message'; - (checkPersonalAccessTokenIsValid as jest.Mock).mockResolvedValueOnce({ status: false, error }); - - wrapper.instance().handlePersonalAccessTokenCreate('invalidtoken'); - expect(setAlmPersonalAccessToken).toBeCalledWith(almSettingKey, 'invalidtoken'); - expect(wrapper.state().submittingToken).toBe(true); - await waitAndUpdate(wrapper); - expect(checkPersonalAccessTokenIsValid).toBeCalled(); - expect(wrapper.state().submittingToken).toBe(false); - expect(wrapper.state().tokenValidationErrorMessage).toBe(error); - }); - - it('should correctly handle it if valid', async () => { - (checkPersonalAccessTokenIsValid as jest.Mock).mockResolvedValueOnce({ status: true }); - - wrapper.instance().handlePersonalAccessTokenCreate('validtoken'); - expect(setAlmPersonalAccessToken).toBeCalledWith(almSettingKey, 'validtoken'); - expect(wrapper.state().submittingToken).toBe(true); - await waitAndUpdate(wrapper); - expect(checkPersonalAccessTokenIsValid).toBeCalled(); - expect(wrapper.state().submittingToken).toBe(false); - expect(wrapper.state().tokenValidationErrorMessage).toBeUndefined(); - - expect(routerReplace).toBeCalled(); - }); -}); - it('should fetch more projects and preserve search', async () => { - (checkPersonalAccessTokenIsValid as jest.Mock).mockResolvedValueOnce({ status: true }); - const projects = [ mockGitlabProject({ id: '1' }), mockGitlabProject({ id: '2' }), @@ -153,7 +69,7 @@ it('should fetch more projects and preserve search', async () => { const wrapper = shallowRender(); - await waitAndUpdate(wrapper); + await wrapper.instance().handlePersonalAccessTokenCreated(); wrapper.setState({ searchQuery: 'query' }); wrapper.instance().handleLoadMore(); @@ -167,8 +83,6 @@ it('should fetch more projects and preserve search', async () => { }); it('should search for projects', async () => { - (checkPersonalAccessTokenIsValid as jest.Mock).mockResolvedValueOnce({ status: true }); - const projects = [ mockGitlabProject({ id: '1' }), mockGitlabProject({ id: '2' }), @@ -197,11 +111,10 @@ it('should search for projects', async () => { const query = 'query'; const wrapper = shallowRender(); - await waitAndUpdate(wrapper); + await wrapper.instance().handlePersonalAccessTokenCreated(); wrapper.instance().handleSearch(query); expect(wrapper.state().searching).toBe(true); - await waitAndUpdate(wrapper); expect(wrapper.state().searching).toBe(false); expect(wrapper.state().searchQuery).toBe(query); @@ -211,8 +124,6 @@ it('should search for projects', async () => { }); it('should import', async () => { - (checkPersonalAccessTokenIsValid as jest.Mock).mockResolvedValueOnce({ status: true }); - const projects = [mockGitlabProject({ id: '1' }), mockGitlabProject({ id: '2' })]; (getGitlabProjects as jest.Mock).mockResolvedValueOnce({ projects, @@ -231,7 +142,7 @@ it('should import', async () => { const onProjectCreate = jest.fn(); const wrapper = shallowRender({ onProjectCreate }); - await waitAndUpdate(wrapper); + await wrapper.instance().handlePersonalAccessTokenCreated(); wrapper.instance().handleImport(projects[1].id); expect(wrapper.state().importingGitlabProjectId).toBe(projects[1].id); @@ -245,17 +156,13 @@ it('should import', async () => { it('should do nothing with missing settings', async () => { const wrapper = shallowRender({ settings: [] }); - await waitAndUpdate(wrapper); - - wrapper.instance().handleLoadMore(); - wrapper.instance().handleSearch('whatever'); - wrapper.instance().handlePersonalAccessTokenCreate('token'); - wrapper.instance().handleImport('gitlab project id'); + await wrapper.instance().handleLoadMore(); + await wrapper.instance().handleSearch('whatever'); + await wrapper.instance().handlePersonalAccessTokenCreated(); + await wrapper.instance().handleImport('gitlab project id'); - expect(checkPersonalAccessTokenIsValid).not.toHaveBeenCalled(); expect(getGitlabProjects).not.toHaveBeenCalled(); expect(importGitlabProject).not.toHaveBeenCalled(); - expect(setAlmPersonalAccessToken).not.toHaveBeenCalled(); }); it('should handle errors when fetching projects', async () => { @@ -263,8 +170,10 @@ it('should handle errors when fetching projects', async () => { const wrapper = shallowRender(); await waitAndUpdate(wrapper); + await wrapper.instance().handlePersonalAccessTokenCreated(); - expect(wrapper.state().tokenIsValid).toBe(false); + expect(wrapper.state().resetPat).toBe(true); + expect(wrapper.state().showPersonalAccessTokenForm).toBe(true); }); it('should handle errors when importing a project', async () => { @@ -279,14 +188,12 @@ it('should handle errors when importing a project', async () => { }); const wrapper = shallowRender(); - await waitAndUpdate(wrapper); - - expect(wrapper.state().tokenIsValid).toBe(true); + await wrapper.instance().handlePersonalAccessTokenCreated(); await wrapper.instance().handleImport('gitlabId'); await waitAndUpdate(wrapper); - expect(wrapper.state().tokenIsValid).toBe(false); + expect(wrapper.state().showPersonalAccessTokenForm).toBe(true); }); function shallowRender(props: Partial<GitlabProjectCreate['props']> = {}) { diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/GitlabProjectCreateRenderer-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/GitlabProjectCreateRenderer-test.tsx index 3a41f9c6fde..39e4b9fc289 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/GitlabProjectCreateRenderer-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/GitlabProjectCreateRenderer-test.tsx @@ -35,9 +35,6 @@ it('should render correctly', () => { expect(shallowRender({ showPersonalAccessTokenForm: false })).toMatchSnapshot( 'project selection form' ); - expect(shallowRender({ tokenValidationErrorMessage: 'error' })).toMatchSnapshot( - 'pat validation error' - ); }); function shallowRender(props: Partial<GitlabProjectCreateRendererProps> = {}) { @@ -48,14 +45,14 @@ function shallowRender(props: Partial<GitlabProjectCreateRendererProps> = {}) { loadingMore={false} onImport={jest.fn()} onLoadMore={jest.fn()} - onPersonalAccessTokenCreate={jest.fn()} + onPersonalAccessTokenCreated={jest.fn()} onSearch={jest.fn()} projects={undefined} projectsPaging={{ pageIndex: 1, pageSize: 30, total: 0 }} searching={false} searchQuery="" + resetPat={false} showPersonalAccessTokenForm={true} - submittingToken={false} settings={mockAlmSettingsInstance({ alm: AlmKeys.GitLab })} {...props} /> diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/PersonalAccessTokenForm-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/PersonalAccessTokenForm-test.tsx index 23fc3ad3138..f8d55114a1e 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/PersonalAccessTokenForm-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/PersonalAccessTokenForm-test.tsx @@ -20,37 +20,59 @@ import { shallow } from 'enzyme'; import * as React from 'react'; import { SubmitButton } from 'sonar-ui-common/components/controls/buttons'; -import { change, submit } from 'sonar-ui-common/helpers/testUtils'; -import { mockAlmSettingsInstance } from '../../../../helpers/mocks/alm-settings'; +import { change, submit, waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; +import { + checkPersonalAccessTokenIsValid, + setAlmPersonalAccessToken +} from '../../../../api/alm-integrations'; +import { + mockAlmSettingsInstance, + mockBitbucketCloudAlmSettingsInstance +} from '../../../../helpers/mocks/alm-settings'; import { AlmKeys } from '../../../../types/alm-settings'; -import PersonalAccessTokenForm, { PersonalAccessTokenFormProps } from '../PersonalAccessTokenForm'; - -it('should render correctly', () => { - expect(shallowRender()).toMatchSnapshot('bitbucket'); - expect(shallowRender({ submitting: true })).toMatchSnapshot('submitting'); - expect(shallowRender({ validationFailed: true })).toMatchSnapshot('validation failed'); - expect( - shallowRender({ validationFailed: true, validationErrorMessage: 'error' }) - ).toMatchSnapshot('validation failed, custom error message'); - expect( - shallowRender({ - almSetting: mockAlmSettingsInstance({ alm: AlmKeys.GitLab, url: 'https://gitlab.com/api/v4' }) - }) - ).toMatchSnapshot('gitlab'); - expect( - shallowRender({ - almSetting: mockAlmSettingsInstance({ - alm: AlmKeys.GitLab, - url: 'https://gitlabapi.unexpectedurl.org' - }) +import PersonalAccessTokenForm from '../PersonalAccessTokenForm'; + +jest.mock('../../../../api/alm-integrations', () => ({ + checkPersonalAccessTokenIsValid: jest.fn().mockResolvedValue({ status: true }), + setAlmPersonalAccessToken: jest.fn().mockResolvedValue({}) +})); + +it('should render correctly', async () => { + expect(shallowRender()).toMatchSnapshot('no token needed'); + + (checkPersonalAccessTokenIsValid as jest.Mock).mockResolvedValueOnce({ status: false }); + let wrapper = shallowRender(); + await waitAndUpdate(wrapper); + expect(wrapper).toMatchSnapshot('bitbucket'); + + (checkPersonalAccessTokenIsValid as jest.Mock).mockResolvedValueOnce({ status: false }); + wrapper = shallowRender({ almSetting: mockBitbucketCloudAlmSettingsInstance() }); + await waitAndUpdate(wrapper); + expect(wrapper).toMatchSnapshot('bitbucket cloud'); + + (checkPersonalAccessTokenIsValid as jest.Mock).mockResolvedValueOnce({ status: false }); + wrapper = shallowRender({ + almSetting: mockAlmSettingsInstance({ alm: AlmKeys.GitLab, url: 'https://gitlab.com/api/v4' }) + }); + await waitAndUpdate(wrapper); + expect(wrapper).toMatchSnapshot('gitlab'); + + (checkPersonalAccessTokenIsValid as jest.Mock).mockResolvedValueOnce({ status: false }); + wrapper = shallowRender({ + almSetting: mockAlmSettingsInstance({ + alm: AlmKeys.GitLab, + url: 'https://gitlabapi.unexpectedurl.org' }) - ).toMatchSnapshot('gitlab with non-standard api path'); + }); + await waitAndUpdate(wrapper); + expect(wrapper).toMatchSnapshot('gitlab with non-standard api path'); }); -it('should correctly handle form interactions', () => { - const onPersonalAccessTokenCreate = jest.fn(); - const wrapper = shallowRender({ onPersonalAccessTokenCreate }); +it('should correctly handle form interactions', async () => { + const onPersonalAccessTokenCreated = jest.fn(); + const wrapper = shallowRender({ onPersonalAccessTokenCreated }); + await waitAndUpdate(wrapper); // Submit button disabled by default. expect(wrapper.find(SubmitButton).prop('disabled')).toBe(true); @@ -60,25 +82,62 @@ it('should correctly handle form interactions', () => { // Expect correct calls to be made when submitting. submit(wrapper.find('form')); - expect(onPersonalAccessTokenCreate).toBeCalled(); + expect(onPersonalAccessTokenCreated).toBeCalled(); + expect(setAlmPersonalAccessToken).toBeCalledWith('key', 'token', undefined); +}); + +it('should correctly handle form for bitbucket interactions', async () => { + const onPersonalAccessTokenCreated = jest.fn(); + const wrapper = shallowRender({ + almSetting: mockBitbucketCloudAlmSettingsInstance(), + onPersonalAccessTokenCreated + }); + + await waitAndUpdate(wrapper); + // Submit button disabled by default. + expect(wrapper.find(SubmitButton).prop('disabled')).toBe(true); - // If validation fails, we toggle the submitting flag and call useEffect() - // to set the `touched` flag to false again. Trigger a re-render, and mock - // useEffect(). This should de-activate the submit button again. - jest.spyOn(React, 'useEffect').mockImplementationOnce(f => f()); - wrapper.setProps({ submitting: false }); + change(wrapper.find('#personal_access_token'), 'token'); expect(wrapper.find(SubmitButton).prop('disabled')).toBe(true); + + // Submit button enabled if there's a value. + change(wrapper.find('#username'), 'username'); + expect(wrapper.find(SubmitButton).prop('disabled')).toBe(false); + + // Expect correct calls to be made when submitting. + submit(wrapper.find('form')); + expect(onPersonalAccessTokenCreated).toBeCalled(); + expect(setAlmPersonalAccessToken).toBeCalledWith('key', 'token', 'username'); +}); + +it('should show error when issue', async () => { + (checkPersonalAccessTokenIsValid as jest.Mock).mockRejectedValueOnce({}); + const wrapper = shallowRender({ + almSetting: mockBitbucketCloudAlmSettingsInstance() + }); + + await waitAndUpdate(wrapper); + + (checkPersonalAccessTokenIsValid as jest.Mock).mockRejectedValueOnce({}); + + change(wrapper.find('#personal_access_token'), 'token'); + change(wrapper.find('#username'), 'username'); + + // Expect correct calls to be made when submitting. + submit(wrapper.find('form')); + await waitAndUpdate(wrapper); + expect(wrapper).toMatchSnapshot('issue submitting token'); }); -function shallowRender(props: Partial<PersonalAccessTokenFormProps> = {}) { - return shallow<PersonalAccessTokenFormProps>( +function shallowRender(props: Partial<PersonalAccessTokenForm['props']> = {}) { + return shallow<PersonalAccessTokenForm>( <PersonalAccessTokenForm almSetting={mockAlmSettingsInstance({ alm: AlmKeys.BitbucketServer, url: 'http://www.example.com' })} - onPersonalAccessTokenCreate={jest.fn()} - validationFailed={false} + onPersonalAccessTokenCreated={jest.fn()} + resetPat={false} {...props} /> ); diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/BitbucketCloudProjectCreate-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/BitbucketCloudProjectCreate-test.tsx.snap new file mode 100644 index 00000000000..77cd3e633d1 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/BitbucketCloudProjectCreate-test.tsx.snap @@ -0,0 +1,33 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Should render correctly 1`] = ` +<BitbucketCloudProjectCreateRenderer + canAdmin={true} + loading={false} + onPersonalAccessTokenCreated={[Function]} + resetPat={false} + settings={ + Object { + "alm": "bitbucketcloud", + "key": "key", + } + } + showPersonalAccessTokenForm={true} +/> +`; + +exports[`Should render correctly: Need App password 1`] = ` +<BitbucketCloudProjectCreateRenderer + canAdmin={true} + loading={false} + onPersonalAccessTokenCreated={[Function]} + resetPat={false} + settings={ + Object { + "alm": "bitbucketcloud", + "key": "key", + } + } + showPersonalAccessTokenForm={true} +/> +`; diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/BitbucketCloudProjectCreateRender-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/BitbucketCloudProjectCreateRender-test.tsx.snap new file mode 100644 index 00000000000..aac4d670be2 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/BitbucketCloudProjectCreateRender-test.tsx.snap @@ -0,0 +1,101 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Should render correctly 1`] = ` +<Fragment> + <CreateProjectPageHeader + title={ + <span + className="text-middle" + > + <img + alt="" + className="spacer-right" + height="24" + src="/images/alm/bitbucket.svg" + /> + onboarding.create_project.bitbucketcloud.title + </span> + } + /> + <p> + Placeholder for next step + </p> +</Fragment> +`; + +exports[`Should render correctly: Loading... 1`] = ` +<Fragment> + <CreateProjectPageHeader + title={ + <span + className="text-middle" + > + <img + alt="" + className="spacer-right" + height="24" + src="/images/alm/bitbucket.svg" + /> + onboarding.create_project.bitbucketcloud.title + </span> + } + /> + <i + className="spinner" + /> +</Fragment> +`; + +exports[`Should render correctly: Need App password 1`] = ` +<Fragment> + <CreateProjectPageHeader + title={ + <span + className="text-middle" + > + <img + alt="" + className="spacer-right" + height="24" + src="/images/alm/bitbucket.svg" + /> + onboarding.create_project.bitbucketcloud.title + </span> + } + /> + <PersonalAccessTokenForm + almSetting={ + Object { + "alm": "bitbucketcloud", + "key": "key", + } + } + onPersonalAccessTokenCreated={[MockFunction]} + resetPat={false} + /> +</Fragment> +`; + +exports[`Should render correctly: Wrong config 1`] = ` +<Fragment> + <CreateProjectPageHeader + title={ + <span + className="text-middle" + > + <img + alt="" + className="spacer-right" + height="24" + src="/images/alm/bitbucket.svg" + /> + onboarding.create_project.bitbucketcloud.title + </span> + } + /> + <WrongBindingCountAlert + alm="bitbucketcloud" + canAdmin={false} + /> +</Fragment> +`; diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/BitbucketProjectCreate-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/BitbucketProjectCreate-test.tsx.snap index 31df0b466dc..4b190649475 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/BitbucketProjectCreate-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/BitbucketProjectCreate-test.tsx.snap @@ -10,14 +10,66 @@ exports[`should render correctly 1`] = ` } canAdmin={false} importing={false} - loading={true} + loading={false} onImportRepository={[Function]} - onPersonalAccessTokenCreate={[Function]} + onPersonalAccessTokenCreated={[Function]} onProjectCreate={[MockFunction]} onSearch={[Function]} onSelectRepository={[Function]} + resetPat={false} + searching={false} + showPersonalAccessTokenForm={true} +/> +`; + +exports[`should render correctly: No repository 1`] = ` +<BitbucketProjectCreateRenderer + bitbucketSetting={ + Object { + "alm": "bitbucket", + "key": "foo", + } + } + canAdmin={false} + importing={false} + loading={false} + onImportRepository={[Function]} + onPersonalAccessTokenCreated={[Function]} + onProjectCreate={[MockFunction]} + onSearch={[Function]} + onSelectRepository={[Function]} + projects={ + Array [ + Object { + "id": 1, + "key": "project1", + "name": "Project 1", + }, + Object { + "id": 2, + "key": "project2", + "name": "Project", + }, + ] + } + resetPat={false} + searching={false} + showPersonalAccessTokenForm={false} +/> +`; + +exports[`should render correctly: No setting 1`] = ` +<BitbucketProjectCreateRenderer + canAdmin={false} + importing={false} + loading={false} + onImportRepository={[Function]} + onPersonalAccessTokenCreated={[Function]} + onProjectCreate={[MockFunction]} + onSearch={[Function]} + onSelectRepository={[Function]} + resetPat={false} searching={false} showPersonalAccessTokenForm={true} - tokenValidationFailed={false} /> `; diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/BitbucketProjectCreateRenderer-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/BitbucketProjectCreateRenderer-test.tsx.snap index aeb87f01dec..c499c0accea 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/BitbucketProjectCreateRenderer-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/BitbucketProjectCreateRenderer-test.tsx.snap @@ -281,8 +281,8 @@ exports[`should render correctly: pat form 1`] = ` "key": "key", } } - onPersonalAccessTokenCreate={[MockFunction]} - validationFailed={false} + onPersonalAccessTokenCreated={[MockFunction]} + resetPat={false} /> </Fragment> `; diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/CreateProjectPage-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/CreateProjectPage-test.tsx.snap index 99f3a717d69..2d14d0bc3c0 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/CreateProjectPage-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/CreateProjectPage-test.tsx.snap @@ -115,6 +115,19 @@ exports[`should render correctly if the BBS method is selected 1`] = ` } } onProjectCreate={[Function]} + router={ + Object { + "createHref": [MockFunction], + "createPath": [MockFunction], + "go": [MockFunction], + "goBack": [MockFunction], + "goForward": [MockFunction], + "isActive": [MockFunction], + "push": [MockFunction], + "replace": [MockFunction], + "setRouteLeaveHook": [MockFunction], + } + } /> </div> </Fragment> diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/GitlabProjectCreate-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/GitlabProjectCreate-test.tsx.snap index 8b9520b04ec..577f859ef13 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/GitlabProjectCreate-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/GitlabProjectCreate-test.tsx.snap @@ -3,11 +3,11 @@ exports[`should render correctly 1`] = ` <GitlabProjectCreateRenderer canAdmin={false} - loading={true} + loading={false} loadingMore={false} onImport={[Function]} onLoadMore={[Function]} - onPersonalAccessTokenCreate={[Function]} + onPersonalAccessTokenCreated={[Function]} onSearch={[Function]} projectsPaging={ Object { @@ -16,6 +16,7 @@ exports[`should render correctly 1`] = ` "total": 0, } } + resetPat={false} searchQuery="" searching={false} settings={ @@ -25,6 +26,5 @@ exports[`should render correctly 1`] = ` } } showPersonalAccessTokenForm={true} - submittingToken={false} /> `; diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/GitlabProjectCreateRenderer-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/GitlabProjectCreateRenderer-test.tsx.snap index 72c564b381a..68e71143079 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/GitlabProjectCreateRenderer-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/GitlabProjectCreateRenderer-test.tsx.snap @@ -95,41 +95,8 @@ exports[`should render correctly: pat form 1`] = ` "key": "key", } } - onPersonalAccessTokenCreate={[MockFunction]} - submitting={false} - validationFailed={false} - /> -</Fragment> -`; - -exports[`should render correctly: pat validation error 1`] = ` -<Fragment> - <CreateProjectPageHeader - title={ - <span - className="text-middle" - > - <img - alt="" - className="spacer-right" - height="24" - src="/images/alm/gitlab.svg" - /> - onboarding.create_project.gitlab.title - </span> - } - /> - <PersonalAccessTokenForm - almSetting={ - Object { - "alm": "gitlab", - "key": "key", - } - } - onPersonalAccessTokenCreate={[MockFunction]} - submitting={false} - validationErrorMessage="error" - validationFailed={true} + onPersonalAccessTokenCreated={[MockFunction]} + resetPat={false} /> </Fragment> `; diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/PersonalAccessTokenForm-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/PersonalAccessTokenForm-test.tsx.snap index c2d3a437231..a8ea7ab0c05 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/PersonalAccessTokenForm-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/PersonalAccessTokenForm-test.tsx.snap @@ -19,7 +19,8 @@ exports[`should render correctly: bitbucket 1`] = ` onboarding.create_project.pat_form.help.bitbucket </p> <ValidationInput - id="personal_access_token" + error="onboarding.create_project.pat_incorrect.bitbucket" + id="personal_access_token_validation" isInvalid={false} isValid={false} label="onboarding.create_project.enter_pat" @@ -27,23 +28,31 @@ exports[`should render correctly: bitbucket 1`] = ` > <input autoFocus={true} - className="input-super-large" + className="input-super-large is-invalid" id="personal_access_token" minLength={1} - name="personal_access_token" onChange={[Function]} type="text" + value="" + /> + </ValidationInput> + <ValidationInput + error="onboarding.create_project.pat_incorrect.bitbucket" + id="personal_access_token_submit" + isInvalid={true} + isValid={false} + label={null} + > + <SubmitButton + disabled={true} + > + save + </SubmitButton> + <DeferredSpinner + className="spacer-left" + loading={false} /> </ValidationInput> - <SubmitButton - disabled={true} - > - save - </SubmitButton> - <DeferredSpinner - className="spacer-left" - loading={false} - /> </form> <Alert className="big-spacer-left width-50" @@ -120,7 +129,7 @@ exports[`should render correctly: bitbucket 1`] = ` </div> `; -exports[`should render correctly: gitlab 1`] = ` +exports[`should render correctly: bitbucket cloud 1`] = ` <div className="display-flex-start" > @@ -131,39 +140,65 @@ exports[`should render correctly: gitlab 1`] = ` <h2 className="big" > - onboarding.create_project.pat_form.title.gitlab + onboarding.create_project.pat_form.title.bitbucketcloud </h2> <p className="big-spacer-top big-spacer-bottom" > - onboarding.create_project.pat_form.help.gitlab + onboarding.create_project.pat_form.help.bitbucketcloud </p> <ValidationInput - id="personal_access_token" + id="enter_username_validation" isInvalid={false} isValid={false} - label="onboarding.create_project.enter_pat" + label="onboarding.create_project.enter_username" required={true} > <input autoFocus={true} - className="input-super-large" + className="input-super-large is-invalid" + id="username" + minLength={1} + name="username" + onChange={[Function]} + type="text" + /> + </ValidationInput> + <ValidationInput + error="onboarding.create_project.pat_incorrect.bitbucketcloud" + id="personal_access_token_validation" + isInvalid={false} + isValid={false} + label="onboarding.create_project.enter_pat.bitbucketcloud" + required={true} + > + <input + autoFocus={false} + className="input-super-large is-invalid" id="personal_access_token" minLength={1} - name="personal_access_token" onChange={[Function]} type="text" + value="" + /> + </ValidationInput> + <ValidationInput + error="onboarding.create_project.pat_incorrect.bitbucketcloud" + id="personal_access_token_submit" + isInvalid={true} + isValid={false} + label={null} + > + <SubmitButton + disabled={true} + > + save + </SubmitButton> + <DeferredSpinner + className="spacer-left" + loading={false} /> </ValidationInput> - <SubmitButton - disabled={true} - > - save - </SubmitButton> - <DeferredSpinner - className="spacer-left" - loading={false} - /> </form> <Alert className="big-spacer-left width-50" @@ -171,17 +206,17 @@ exports[`should render correctly: gitlab 1`] = ` variant="info" > <h3> - onboarding.create_project.pat_help.title + onboarding.create_project.pat_help.bitbucketcloud.title </h3> <p className="big-spacer-top big-spacer-bottom" > <FormattedMessage - defaultMessage="onboarding.create_project.pat_help.instructions" + defaultMessage="onboarding.create_project.pat_help.bitbucketcloud.instructions" id="onboarding.create_project.pat_help.instructions" values={ Object { - "alm": "onboarding.alm.gitlab", + "alm": "onboarding.alm.bitbucketcloud", } } /> @@ -193,35 +228,41 @@ exports[`should render correctly: gitlab 1`] = ` alt="" className="spacer-right" height="16" - src="/images/alm/gitlab.svg" + src="/images/alm/bitbucket.svg" /> <a - href="https://gitlab.com/profile/personal_access_tokens" + href="https://bitbucket.org/account/settings/app-passwords/new" rel="noopener noreferrer" target="_blank" > - onboarding.create_project.pat_help.link + onboarding.create_project.pat_help.bitbucketcloud.link </a> </div> <p className="big-spacer-top big-spacer-bottom" > - onboarding.create_project.pat_help.instructions2.gitlab + onboarding.create_project.pat_help.instructions2.bitbucketcloud </p> <ul> - <li - className="spacer-bottom" - > - <strong> - onboarding.create_project.pat_help.gitlab.read_api_permission - </strong> + <li> + <FormattedMessage + defaultMessage="onboarding.create_project.pat_help.bbs_permission_repos" + id="onboarding.create_project.pat_help.bbs_permission_repos" + values={ + Object { + "perm": <strong> + onboarding.create_project.pat_help.read_permission + </strong>, + } + } + /> </li> </ul> </Alert> </div> `; -exports[`should render correctly: gitlab with non-standard api path 1`] = ` +exports[`should render correctly: gitlab 1`] = ` <div className="display-flex-start" > @@ -240,7 +281,8 @@ exports[`should render correctly: gitlab with non-standard api path 1`] = ` onboarding.create_project.pat_form.help.gitlab </p> <ValidationInput - id="personal_access_token" + error="onboarding.create_project.pat_incorrect.gitlab" + id="personal_access_token_validation" isInvalid={false} isValid={false} label="onboarding.create_project.enter_pat" @@ -248,23 +290,31 @@ exports[`should render correctly: gitlab with non-standard api path 1`] = ` > <input autoFocus={true} - className="input-super-large" + className="input-super-large is-invalid" id="personal_access_token" minLength={1} - name="personal_access_token" onChange={[Function]} type="text" + value="" + /> + </ValidationInput> + <ValidationInput + error="onboarding.create_project.pat_incorrect.gitlab" + id="personal_access_token_submit" + isInvalid={true} + isValid={false} + label={null} + > + <SubmitButton + disabled={true} + > + save + </SubmitButton> + <DeferredSpinner + className="spacer-left" + loading={false} /> </ValidationInput> - <SubmitButton - disabled={true} - > - save - </SubmitButton> - <DeferredSpinner - className="spacer-left" - loading={false} - /> </form> <Alert className="big-spacer-left width-50" @@ -297,7 +347,7 @@ exports[`should render correctly: gitlab with non-standard api path 1`] = ` src="/images/alm/gitlab.svg" /> <a - href="https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#creating-a-personal-access-token" + href="https://gitlab.com/profile/personal_access_tokens" rel="noopener noreferrer" target="_blank" > @@ -322,7 +372,7 @@ exports[`should render correctly: gitlab with non-standard api path 1`] = ` </div> `; -exports[`should render correctly: submitting 1`] = ` +exports[`should render correctly: gitlab with non-standard api path 1`] = ` <div className="display-flex-start" > @@ -333,15 +383,16 @@ exports[`should render correctly: submitting 1`] = ` <h2 className="big" > - onboarding.create_project.pat_form.title.bitbucket + onboarding.create_project.pat_form.title.gitlab </h2> <p className="big-spacer-top big-spacer-bottom" > - onboarding.create_project.pat_form.help.bitbucket + onboarding.create_project.pat_form.help.gitlab </p> <ValidationInput - id="personal_access_token" + error="onboarding.create_project.pat_incorrect.gitlab" + id="personal_access_token_validation" isInvalid={false} isValid={false} label="onboarding.create_project.enter_pat" @@ -349,23 +400,31 @@ exports[`should render correctly: submitting 1`] = ` > <input autoFocus={true} - className="input-super-large" + className="input-super-large is-invalid" id="personal_access_token" minLength={1} - name="personal_access_token" onChange={[Function]} type="text" + value="" + /> + </ValidationInput> + <ValidationInput + error="onboarding.create_project.pat_incorrect.gitlab" + id="personal_access_token_submit" + isInvalid={true} + isValid={false} + label={null} + > + <SubmitButton + disabled={true} + > + save + </SubmitButton> + <DeferredSpinner + className="spacer-left" + loading={false} /> </ValidationInput> - <SubmitButton - disabled={true} - > - save - </SubmitButton> - <DeferredSpinner - className="spacer-left" - loading={true} - /> </form> <Alert className="big-spacer-left width-50" @@ -383,7 +442,7 @@ exports[`should render correctly: submitting 1`] = ` id="onboarding.create_project.pat_help.instructions" values={ Object { - "alm": "onboarding.alm.bitbucket", + "alm": "onboarding.alm.gitlab", } } /> @@ -395,10 +454,10 @@ exports[`should render correctly: submitting 1`] = ` alt="" className="spacer-right" height="16" - src="/images/alm/bitbucket.svg" + src="/images/alm/gitlab.svg" /> <a - href="http://www.example.com/plugins/servlet/access-tokens/add" + href="https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#creating-a-personal-access-token" rel="noopener noreferrer" target="_blank" > @@ -408,41 +467,29 @@ exports[`should render correctly: submitting 1`] = ` <p className="big-spacer-top big-spacer-bottom" > - onboarding.create_project.pat_help.instructions2.bitbucket + onboarding.create_project.pat_help.instructions2.gitlab </p> <ul> - <li> - <FormattedMessage - defaultMessage="onboarding.create_project.pat_help.bbs_permission_projects" - id="onboarding.create_project.pat_help.bbs_permission_projects" - values={ - Object { - "perm": <strong> - onboarding.create_project.pat_help.read_permission - </strong>, - } - } - /> - </li> - <li> - <FormattedMessage - defaultMessage="onboarding.create_project.pat_help.bbs_permission_repos" - id="onboarding.create_project.pat_help.bbs_permission_repos" - values={ - Object { - "perm": <strong> - onboarding.create_project.pat_help.read_permission - </strong>, - } - } - /> + <li + className="spacer-bottom" + > + <strong> + onboarding.create_project.pat_help.gitlab.read_api_permission + </strong> </li> </ul> </Alert> </div> `; -exports[`should render correctly: validation failed 1`] = ` +exports[`should render correctly: no token needed 1`] = ` +<DeferredSpinner + className="spacer-left" + loading={true} +/> +`; + +exports[`should show error when issue: issue submitting token 1`] = ` <div className="display-flex-start" > @@ -453,161 +500,66 @@ exports[`should render correctly: validation failed 1`] = ` <h2 className="big" > - onboarding.create_project.pat_form.title.bitbucket + onboarding.create_project.pat_form.title.bitbucketcloud </h2> <p className="big-spacer-top big-spacer-bottom" > - onboarding.create_project.pat_form.help.bitbucket + onboarding.create_project.pat_form.help.bitbucketcloud </p> <ValidationInput - error="onboarding.create_project.pat_incorrect.bitbucket" - id="personal_access_token" - isInvalid={true} + id="enter_username_validation" + isInvalid={false} isValid={false} - label="onboarding.create_project.enter_pat" + label="onboarding.create_project.enter_username" required={true} > <input autoFocus={true} className="input-super-large is-invalid" - id="personal_access_token" + id="username" minLength={1} - name="personal_access_token" + name="username" onChange={[Function]} type="text" + value="username" /> </ValidationInput> - <SubmitButton - disabled={true} - > - save - </SubmitButton> - <DeferredSpinner - className="spacer-left" - loading={false} - /> - </form> - <Alert - className="big-spacer-left width-50" - display="block" - variant="info" - > - <h3> - onboarding.create_project.pat_help.title - </h3> - <p - className="big-spacer-top big-spacer-bottom" - > - <FormattedMessage - defaultMessage="onboarding.create_project.pat_help.instructions" - id="onboarding.create_project.pat_help.instructions" - values={ - Object { - "alm": "onboarding.alm.bitbucket", - } - } - /> - </p> - <div - className="text-middle" - > - <img - alt="" - className="spacer-right" - height="16" - src="/images/alm/bitbucket.svg" - /> - <a - href="http://www.example.com/plugins/servlet/access-tokens/add" - rel="noopener noreferrer" - target="_blank" - > - onboarding.create_project.pat_help.link - </a> - </div> - <p - className="big-spacer-top big-spacer-bottom" - > - onboarding.create_project.pat_help.instructions2.bitbucket - </p> - <ul> - <li> - <FormattedMessage - defaultMessage="onboarding.create_project.pat_help.bbs_permission_projects" - id="onboarding.create_project.pat_help.bbs_permission_projects" - values={ - Object { - "perm": <strong> - onboarding.create_project.pat_help.read_permission - </strong>, - } - } - /> - </li> - <li> - <FormattedMessage - defaultMessage="onboarding.create_project.pat_help.bbs_permission_repos" - id="onboarding.create_project.pat_help.bbs_permission_repos" - values={ - Object { - "perm": <strong> - onboarding.create_project.pat_help.read_permission - </strong>, - } - } - /> - </li> - </ul> - </Alert> -</div> -`; - -exports[`should render correctly: validation failed, custom error message 1`] = ` -<div - className="display-flex-start" -> - <form - className="width-50" - onSubmit={[Function]} - > - <h2 - className="big" - > - onboarding.create_project.pat_form.title.bitbucket - </h2> - <p - className="big-spacer-top big-spacer-bottom" - > - onboarding.create_project.pat_form.help.bitbucket - </p> <ValidationInput - error="error" - id="personal_access_token" - isInvalid={true} + error="default_error_message" + id="personal_access_token_validation" + isInvalid={false} isValid={false} - label="onboarding.create_project.enter_pat" + label="onboarding.create_project.enter_pat.bitbucketcloud" required={true} > <input - autoFocus={true} + autoFocus={false} className="input-super-large is-invalid" id="personal_access_token" minLength={1} - name="personal_access_token" onChange={[Function]} type="text" + value="token" + /> + </ValidationInput> + <ValidationInput + error="default_error_message" + id="personal_access_token_submit" + isInvalid={true} + isValid={false} + label={null} + > + <SubmitButton + disabled={true} + > + save + </SubmitButton> + <DeferredSpinner + className="spacer-left" + loading={false} /> </ValidationInput> - <SubmitButton - disabled={true} - > - save - </SubmitButton> - <DeferredSpinner - className="spacer-left" - loading={false} - /> </form> <Alert className="big-spacer-left width-50" @@ -615,17 +567,17 @@ exports[`should render correctly: validation failed, custom error message 1`] = variant="info" > <h3> - onboarding.create_project.pat_help.title + onboarding.create_project.pat_help.bitbucketcloud.title </h3> <p className="big-spacer-top big-spacer-bottom" > <FormattedMessage - defaultMessage="onboarding.create_project.pat_help.instructions" + defaultMessage="onboarding.create_project.pat_help.bitbucketcloud.instructions" id="onboarding.create_project.pat_help.instructions" values={ Object { - "alm": "onboarding.alm.bitbucket", + "alm": "onboarding.alm.bitbucketcloud", } } /> @@ -640,34 +592,21 @@ exports[`should render correctly: validation failed, custom error message 1`] = src="/images/alm/bitbucket.svg" /> <a - href="http://www.example.com/plugins/servlet/access-tokens/add" + href="https://bitbucket.org/account/settings/app-passwords/new" rel="noopener noreferrer" target="_blank" > - onboarding.create_project.pat_help.link + onboarding.create_project.pat_help.bitbucketcloud.link </a> </div> <p className="big-spacer-top big-spacer-bottom" > - onboarding.create_project.pat_help.instructions2.bitbucket + onboarding.create_project.pat_help.instructions2.bitbucketcloud </p> <ul> <li> <FormattedMessage - defaultMessage="onboarding.create_project.pat_help.bbs_permission_projects" - id="onboarding.create_project.pat_help.bbs_permission_projects" - values={ - Object { - "perm": <strong> - onboarding.create_project.pat_help.read_permission - </strong>, - } - } - /> - </li> - <li> - <FormattedMessage defaultMessage="onboarding.create_project.pat_help.bbs_permission_repos" id="onboarding.create_project.pat_help.bbs_permission_repos" values={ diff --git a/server/sonar-web/src/main/js/apps/create/project/types.ts b/server/sonar-web/src/main/js/apps/create/project/types.ts index 9eb31ca5f37..a00a7966525 100644 --- a/server/sonar-web/src/main/js/apps/create/project/types.ts +++ b/server/sonar-web/src/main/js/apps/create/project/types.ts @@ -21,6 +21,7 @@ export enum CreateProjectModes { Manual = 'manual', AzureDevOps = 'azure', BitbucketServer = 'bitbucket', + BitbucketCloud = 'bitbucketcloud', GitHub = 'github', GitLab = 'gitlab' } diff --git a/server/sonar-web/src/main/js/helpers/mocks/alm-settings.ts b/server/sonar-web/src/main/js/helpers/mocks/alm-settings.ts index 93aa836d8ee..ffd3989095e 100644 --- a/server/sonar-web/src/main/js/helpers/mocks/alm-settings.ts +++ b/server/sonar-web/src/main/js/helpers/mocks/alm-settings.ts @@ -44,6 +44,16 @@ export function mockAlmSettingsInstance( }; } +export function mockBitbucketCloudAlmSettingsInstance( + overrides: Partial<AlmSettingsInstance> = {} +): AlmSettingsInstance { + return { + alm: AlmKeys.BitbucketCloud, + key: 'key', + ...overrides + }; +} + export function mockAzureBindingDefinition( overrides: Partial<AzureBindingDefinition> = {} ): AzureBindingDefinition { |