From ac3d6f0496fd79c9ba17cb8551060ad424fd10be Mon Sep 17 00:00:00 2001 From: Mathieu Suen Date: Wed, 12 May 2021 09:49:12 +0200 Subject: [PATCH] SONAR-14801 Adding App password form for Bitbucket cloud onboarding --- .../src/main/js/api/alm-integrations.ts | 10 +- .../project/BitbucketCloudProjectCreate.tsx | 97 ++++ .../BitbucketCloudProjectCreateRender.tsx | 76 ++++ .../create/project/BitbucketProjectCreate.tsx | 104 ++--- .../BitbucketProjectCreateRenderer.tsx | 13 +- .../apps/create/project/CreateProjectPage.tsx | 18 + .../create/project/GitlabProjectCreate.tsx | 106 +---- .../project/GitlabProjectCreateRenderer.tsx | 16 +- .../project/PersonalAccessTokenForm.tsx | 370 ++++++++++----- .../BitbucketCloudProjectCreate-test.tsx | 65 +++ ...BitbucketCloudProjectCreateRender-test.tsx | 49 ++ .../__tests__/BitbucketProjectCreate-test.tsx | 66 +-- .../BitbucketProjectCreateRenderer-test.tsx | 4 +- .../__tests__/GitlabProjectCreate-test.tsx | 119 +---- .../GitlabProjectCreateRenderer-test.tsx | 7 +- .../PersonalAccessTokenForm-test.tsx | 131 ++++-- .../BitbucketCloudProjectCreate-test.tsx.snap | 33 ++ ...cketCloudProjectCreateRender-test.tsx.snap | 101 +++++ .../BitbucketProjectCreate-test.tsx.snap | 58 ++- ...tbucketProjectCreateRenderer-test.tsx.snap | 4 +- .../CreateProjectPage-test.tsx.snap | 13 + .../GitlabProjectCreate-test.tsx.snap | 6 +- .../GitlabProjectCreateRenderer-test.tsx.snap | 37 +- .../PersonalAccessTokenForm-test.tsx.snap | 429 ++++++++---------- .../src/main/js/apps/create/project/types.ts | 1 + .../src/main/js/helpers/mocks/alm-settings.ts | 10 + .../resources/org/sonar/l10n/core.properties | 13 + 27 files changed, 1202 insertions(+), 754 deletions(-) create mode 100644 server/sonar-web/src/main/js/apps/create/project/BitbucketCloudProjectCreate.tsx create mode 100644 server/sonar-web/src/main/js/apps/create/project/BitbucketCloudProjectCreateRender.tsx create mode 100644 server/sonar-web/src/main/js/apps/create/project/__tests__/BitbucketCloudProjectCreate-test.tsx create mode 100644 server/sonar-web/src/main/js/apps/create/project/__tests__/BitbucketCloudProjectCreateRender-test.tsx create mode 100644 server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/BitbucketCloudProjectCreate-test.tsx.snap create mode 100644 server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/BitbucketCloudProjectCreateRender-test.tsx.snap 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 { - return post('/api/alm_integrations/set_pat', { almSetting, pat }).catch(throwGlobalError); +export function setAlmPersonalAccessToken( + almSetting: string, + pat: string, + username?: string +): Promise { + 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 { + 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 { + 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 ( + + ); + } +} 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 ( + <> + + + {translate('onboarding.create_project.bitbucketcloud.title')} + + } + /> + {loading && } + + {!loading && !settings && ( + + )} + + {!loading && + settings && + (showPersonalAccessTokenForm ? ( + + ) : ( +

Placeholder for next step

+ ))} + + ); +} 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 { +interface Props extends Pick { 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 { @@ -69,13 +65,12 @@ export default class BitbucketProjectCreate extends React.PureComponent { - 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 => { @@ -184,29 +168,16 @@ export default class BitbucketProjectCreate extends React.PureComponent { - 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 ); } 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 ? ( ) : ( { interface State { azureSettings: AlmSettingsInstance[]; bitbucketSettings: AlmSettingsInstance[]; + bitbucketCloudSettings: AlmSettingsInstance[]; githubSettings: AlmSettingsInstance[]; gitlabSettings: AlmSettingsInstance[]; loading: boolean; @@ -54,6 +56,7 @@ export class CreateProjectPage extends React.PureComponent { state: State = { azureSettings: [], bitbucketSettings: [], + bitbucketCloudSettings: [], githubSettings: [], gitlabSettings: [], loading: true @@ -76,6 +79,7 @@ export class CreateProjectPage extends React.PureComponent { 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 { const { azureSettings, bitbucketSettings, + bitbucketCloudSettings, githubSettings, gitlabSettings, loading @@ -138,6 +143,19 @@ export class CreateProjectPage extends React.PureComponent { loadingBindings={loading} location={location} onProjectCreate={this.handleProjectCreate} + router={router} + /> + ); + } + case CreateProjectModes.BitbucketCloud: { + return ( + ); } 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 { @@ -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 { - 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 { const { settings } = this.state; - if (!settings) { return Promise.resolve(undefined); } @@ -228,37 +199,10 @@ export default class GitlabProjectCreate extends React.PureComponent { - 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 ); } 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 ? ( ) : ( 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 ( -
-
) => { - e.preventDefault(); - const value = new FormData(e.currentTarget).get('personal_access_token') as string; - props.onPersonalAccessTokenCreate(value); - }}> -

{translate('onboarding.create_project.pat_form.title', alm)}

-

- {translate('onboarding.create_project.pat_form.help', alm)} -

- - - { - setTouched(true); - }} - type="text" - /> - - - - {translate('save')} - - - - - -

{translate('onboarding.create_project.pat_help.title')}

- -

- -

- - {url && ( -
- { + 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) => { + this.setState({ + touched: true, + username: event.target.value + }); + }; + + handlePasswordChange = (event: React.ChangeEvent) => { + this.setState({ + touched: true, + password: event.target.value + }); + }; + + handleSubmit = async (e: React.SyntheticEvent) => { + 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 ; + } + + 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 ( +
+
+

{translate('onboarding.create_project.pat_form.title', alm)}

+

+ {translate('onboarding.create_project.pat_form.help', alm)} +

+ + {alm === AlmKeys.BitbucketCloud && ( + + + + )} + + + + + + + {translate('save')} + + +
+ + +

{translate(`onboarding.create_project.pat_help${suffixTranslationKey}.title`)}

+ +

+ - - {translate('onboarding.create_project.pat_help.link')} - -

- )} - -

- {translate('onboarding.create_project.pat_help.instructions2', alm)} -

- -
    - {alm === AlmKeys.BitbucketServer && ( - <> +

    + + {(url || alm === AlmKeys.BitbucketCloud) && ( + + )} + +

    + {translate('onboarding.create_project.pat_help.instructions2', alm)} +

    + +
      + {alm === AlmKeys.BitbucketServer && (
    • + )} + {(alm === AlmKeys.BitbucketServer || alm === AlmKeys.BitbucketCloud) && (
    • - - )} - {alm === AlmKeys.GitLab && ( -
    • - - {translate('onboarding.create_project.pat_help.gitlab.read_api_permission')} - -
    • - )} -
    - -
- ); + )} + + {alm === AlmKeys.GitLab && ( +
  • + + {translate('onboarding.create_project.pat_help.gitlab.read_api_permission')} + +
  • + )} + +
    +
    + ); + } } 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) { + return shallow( + + ); +} 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) { + return shallow( + + ); +} 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 = {}) { return shallow( = {}) { 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 = {}) 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 = {}) { 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 = {}) { @@ -48,14 +45,14 @@ function shallowRender(props: Partial = {}) { 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 = {}) { - return shallow( +function shallowRender(props: Partial = {}) { + return shallow( ); 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`] = ` + +`; + +exports[`Should render correctly: Need App password 1`] = ` + +`; 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`] = ` + + + + onboarding.create_project.bitbucketcloud.title + + } + /> +

    + Placeholder for next step +

    +
    +`; + +exports[`Should render correctly: Loading... 1`] = ` + + + + onboarding.create_project.bitbucketcloud.title + + } + /> + + +`; + +exports[`Should render correctly: Need App password 1`] = ` + + + + onboarding.create_project.bitbucketcloud.title + + } + /> + + +`; + +exports[`Should render correctly: Wrong config 1`] = ` + + + + onboarding.create_project.bitbucketcloud.title + + } + /> + + +`; 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`] = ` + +`; + +exports[`should render correctly: No setting 1`] = ` + `; 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} /> `; 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], + } + } /> 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`] = ` `; 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} - /> - -`; - -exports[`should render correctly: pat validation error 1`] = ` - - - - onboarding.create_project.gitlab.title - - } - /> - `; 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

    + + + + save + + - - save - - `; -exports[`should render correctly: gitlab 1`] = ` +exports[`should render correctly: bitbucket cloud 1`] = `
    @@ -131,39 +140,65 @@ exports[`should render correctly: gitlab 1`] = `

    - onboarding.create_project.pat_form.title.gitlab + onboarding.create_project.pat_form.title.bitbucketcloud

    - onboarding.create_project.pat_form.help.gitlab + onboarding.create_project.pat_form.help.bitbucketcloud

    + + + + + + + save + + - - save - -

    - onboarding.create_project.pat_help.title + onboarding.create_project.pat_help.bitbucketcloud.title

    @@ -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" /> - onboarding.create_project.pat_help.link + onboarding.create_project.pat_help.bitbucketcloud.link

    - onboarding.create_project.pat_help.instructions2.gitlab + onboarding.create_project.pat_help.instructions2.bitbucketcloud

      -
    • - - onboarding.create_project.pat_help.gitlab.read_api_permission - +
    • + + onboarding.create_project.pat_help.read_permission + , + } + } + />
    `; -exports[`should render correctly: gitlab with non-standard api path 1`] = ` +exports[`should render correctly: gitlab 1`] = `
    @@ -240,7 +281,8 @@ exports[`should render correctly: gitlab with non-standard api path 1`] = ` onboarding.create_project.pat_form.help.gitlab

    + + + + save + + - - save - - @@ -322,7 +372,7 @@ exports[`should render correctly: gitlab with non-standard api path 1`] = `
    `; -exports[`should render correctly: submitting 1`] = ` +exports[`should render correctly: gitlab with non-standard api path 1`] = `
    @@ -333,15 +383,16 @@ exports[`should render correctly: submitting 1`] = `

    - onboarding.create_project.pat_form.title.bitbucket + onboarding.create_project.pat_form.title.gitlab

    - onboarding.create_project.pat_form.help.bitbucket + onboarding.create_project.pat_form.help.gitlab

    + + + + save + + - - save - - @@ -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" />
    @@ -408,41 +467,29 @@ exports[`should render correctly: submitting 1`] = `

    - onboarding.create_project.pat_help.instructions2.bitbucket + onboarding.create_project.pat_help.instructions2.gitlab

      -
    • - - onboarding.create_project.pat_help.read_permission - , - } - } - /> -
    • -
    • - - onboarding.create_project.pat_help.read_permission - , - } - } - /> +
    • + + onboarding.create_project.pat_help.gitlab.read_api_permission +
    `; -exports[`should render correctly: validation failed 1`] = ` +exports[`should render correctly: no token needed 1`] = ` + +`; + +exports[`should show error when issue: issue submitting token 1`] = ` -`; - -exports[`should render correctly: validation failed, custom error message 1`] = ` -
    -
    -

    - onboarding.create_project.pat_form.title.bitbucket -

    -

    - onboarding.create_project.pat_form.help.bitbucket -

    + + + + save + + - - save - -

    - onboarding.create_project.pat_help.title + onboarding.create_project.pat_help.bitbucketcloud.title

    @@ -640,32 +592,19 @@ exports[`should render correctly: validation failed, custom error message 1`] = src="/images/alm/bitbucket.svg" /> - onboarding.create_project.pat_help.link + onboarding.create_project.pat_help.bitbucketcloud.link

    - onboarding.create_project.pat_help.instructions2.bitbucket + onboarding.create_project.pat_help.instructions2.bitbucketcloud

      -
    • - - onboarding.create_project.pat_help.read_permission - , - } - } - /> -
    • = {} +): AlmSettingsInstance { + return { + alm: AlmKeys.BitbucketCloud, + key: 'key', + ...overrides + }; +} + export function mockAzureBindingDefinition( overrides: Partial = {} ): AzureBindingDefinition { 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 311290360f9..ac4615c4969 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -3200,6 +3200,7 @@ footer.web_api=Web API #------------------------------------------------------------------------------ onboarding.alm.azure=Azure DevOps onboarding.alm.bitbucket=Bitbucket Server +onboarding.alm.bitbucketcloud=Bitbucket Cloud onboarding.alm.gitlab=GitLab onboarding.project_analysis.header=Analyze your project @@ -3243,9 +3244,11 @@ onboarding.create_application.key.description=If specified, this value is used a onboarding.create_project.pat_form.title.azure=Allow SonarQube to access and list your Azure DevOps repositories onboarding.create_project.pat_form.title.bitbucket=Grant access to your repositories +onboarding.create_project.pat_form.title.bitbucketcloud=Grant access to your repositories onboarding.create_project.pat_form.title.gitlab=Grant access to your projects onboarding.create_project.pat_form.help.azure=SonarQube needs a personal access token to access and list your repositories from Azure DevOps. onboarding.create_project.pat_form.help.bitbucket=SonarQube needs a personal access token to access and list your repositories from Bitbucket Server. +onboarding.create_project.pat_form.help.bitbucketcloud=SonarQube needs an app password to access and list your repositories from Bitbucket Cloud. onboarding.create_project.pat_form.help.gitlab=SonarQube needs a personal access token to access and list your projects from GitLab. onboarding.create_project.pat_form.pat_required=Please enter a personal access token onboarding.create_project.pat_form.list_repositories=List repositories @@ -3262,17 +3265,26 @@ onboarding.create_project.zero_alm_instances.gitlab=You must first configure a G onboarding.create_project.wrong_binding_count=You must have exactly 1 {alm} instance configured in order to use this method, but none were found. Either create the project manually, or contact your system administrator. onboarding.create_project.wrong_binding_count.admin=You must have exactly 1 {alm} instance configured in order to use this method. You can configure instances under {url}. onboarding.create_project.enter_pat=Enter personal access token +onboarding.create_project.enter_pat.bitbucketcloud=Enter your app password +onboarding.create_project.enter_username=Enter your Bitbucket username onboarding.create_project.pat_incorrect.azure=Your personal access couldn't be validated. onboarding.create_project.pat_incorrect.bitbucket=Your personal access couldn't be validated. +onboarding.create_project.pat_incorrect.bitbucketcloud=Your app password couldn't be validated. onboarding.create_project.pat_incorrect.gitlab=Your personal access couldn't be validated. Please make sure it has the right scope and that it is not expired. onboarding.create_project.pat_help.title=How to create a personal access token? +onboarding.create_project.pat_help.bitbucketcloud.title=How to create an app password? onboarding.create_project.pat_help.instructions.azure=Create and provide an Azure DevOps {link}. You need to select the {scope} scope so we can display a list of your repositories which are available for analysis. onboarding.create_project.pat_help.instructions.link.azure=personal access token onboarding.create_project.pat_help.instructions=Click the following link to generate a token in {alm}, and copy-paste it into the personal access token field. +onboarding.create_project.pat_help.bitbucketcloud.instructions=Click the following link to generate an app password, and copy-paste it into the app password field. + onboarding.create_project.pat_help.instructions2.bitbucket=Set a name, for example "SonarQube", and select the following permissions: +onboarding.create_project.pat_help.instructions2.bitbucketcloud=Set a name, for example "SonarQube", and select the following permissions: + onboarding.create_project.pat_help.link=Create personal access token +onboarding.create_project.pat_help.bitbucketcloud.link=Add app password onboarding.create_project.pat_help.bbs_permission_projects=Projects: {perm} onboarding.create_project.pat_help.bbs_permission_repos=Repositories: {perm} onboarding.create_project.pat_help.read_permission=Read @@ -3292,6 +3304,7 @@ onboarding.create_project.azure.title=Which Azure DevOps repository do you want onboarding.create_project.azure.no_projects=No projects could be fetched from Azure DevOps. Contact your system administrator, or {link}. onboarding.create_project.azure.no_repositories=Could not fetch repositories for this project. Contact your system administrator, or {link}. onboarding.create_project.azure.no_results=No repositories match your search query. +onboarding.create_project.bitbucketcloud.title=Which Bitbucket Cloud repository do you want to set up? onboarding.create_project.github.title=Which GitHub repository do you want to set up? onboarding.create_project.github.choose_organization=Choose organization onboarding.create_project.github.warning.title=Could not connect to GitHub -- 2.39.5