diff options
author | Jeremy Davis <jeremy.davis@sonarsource.com> | 2020-11-09 17:16:09 +0100 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2020-11-25 20:06:26 +0000 |
commit | 753af65e1a768d7df755d0d6e22c5befba558790 (patch) | |
tree | 12747ce44d1b5167d7966ed681713aaceef40f61 /server/sonar-web | |
parent | 347294d72cb62a0420ebd0aee2d2433f829300ed (diff) | |
download | sonarqube-753af65e1a768d7df755d0d6e22c5befba558790.tar.gz sonarqube-753af65e1a768d7df755d0d6e22c5befba558790.zip |
SONAR-14057 Add PAT form for azure onboarding
Diffstat (limited to 'server/sonar-web')
18 files changed, 1140 insertions, 8 deletions
diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavPlus.tsx b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavPlus.tsx index 7226731ae5c..1c72c859fce 100644 --- a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavPlus.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavPlus.tsx @@ -48,10 +48,10 @@ interface State { /* * ALMs for which the import feature has been implemented */ -const IMPORT_COMPATIBLE_ALMS = [AlmKeys.Bitbucket, AlmKeys.GitHub, AlmKeys.GitLab]; +const IMPORT_COMPATIBLE_ALMS = [AlmKeys.Azure, AlmKeys.Bitbucket, AlmKeys.GitHub, AlmKeys.GitLab]; const almSettingsValidators = { - [AlmKeys.Azure]: (_: AlmSettingsInstance) => true, + [AlmKeys.Azure]: (settings: AlmSettingsInstance) => !!settings.url, [AlmKeys.Bitbucket]: (_: AlmSettingsInstance) => true, [AlmKeys.GitHub]: (_: AlmSettingsInstance) => true, [AlmKeys.GitLab]: (settings: AlmSettingsInstance) => !!settings.url @@ -73,7 +73,9 @@ export class GlobalNavPlus extends React.PureComponent<Props, State> { this.setState({ governanceReady: true }); } }, - () => {} + () => { + /* error handled globally */ + } ); } } diff --git a/server/sonar-web/src/main/js/apps/create/project/AzurePersonalAccessTokenForm.tsx b/server/sonar-web/src/main/js/apps/create/project/AzurePersonalAccessTokenForm.tsx new file mode 100644 index 00000000000..203d1527bd0 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/AzurePersonalAccessTokenForm.tsx @@ -0,0 +1,134 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 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 classNames from 'classnames'; +import * as React from 'react'; +import { FormattedMessage } from 'react-intl'; +import { SubmitButton } from 'sonar-ui-common/components/controls/buttons'; +import ValidationInput from 'sonar-ui-common/components/controls/ValidationInput'; +import DetachIcon from 'sonar-ui-common/components/icons/DetachIcon'; +import DeferredSpinner from 'sonar-ui-common/components/ui/DeferredSpinner'; +import { translate } from 'sonar-ui-common/helpers/l10n'; +import { AlmSettingsInstance } from '../../../types/alm-settings'; + +export interface AzurePersonalAccessTokenFormProps { + almSetting: AlmSettingsInstance; + onPersonalAccessTokenCreate: (token: string) => void; + submitting?: boolean; + validationFailed: boolean; +} + +function getAzurePatUrl(url: string) { + return `${url.replace(/\/$/, '')}/_usersSettings/tokens`; +} + +export default function AzurePersonalAccessTokenForm(props: AzurePersonalAccessTokenFormProps) { + const { + almSetting: { alm, url }, + submitting = false, + validationFailed + } = props; + + const [touched, setTouched] = React.useState(false); + React.useEffect(() => { + setTouched(false); + }, [submitting]); + + const [token, setToken] = React.useState(''); + + const isInvalid = (validationFailed && !touched) || (touched && !token); + + let errorMessage; + if (!token) { + errorMessage = translate('onboarding.create_project.pat_form.pat_required'); + } else if (isInvalid) { + errorMessage = translate('onboarding.create_project.pat_incorrect', alm); + } + + return ( + <div className="boxed-group abs-width-600"> + <div className="boxed-group-inner"> + <h2>{translate('onboarding.create_project.pat_form.title', alm)}</h2> + + <div className="big-spacer-top big-spacer-bottom"> + <FormattedMessage + id="onboarding.create_project.pat_help.instructions" + defaultMessage={translate('onboarding.create_project.pat_help.instructions', alm)} + values={{ + link: url ? ( + <a + className="link-with-icon" + href={getAzurePatUrl(url)} + rel="noopener noreferrer" + target="_blank"> + <DetachIcon className="little-spacer-right" /> + <span> + {translate('onboarding.create_project.pat_help.instructions.link', alm)} + </span> + </a> + ) : ( + translate('onboarding.create_project.pat_help.instructions.link', alm) + ), + scope: ( + <strong> + <em>Code (Read & Write)</em> + </strong> + ) + }} + /> + </div> + + <form + onSubmit={(e: React.SyntheticEvent<HTMLFormElement>) => { + e.preventDefault(); + props.onPersonalAccessTokenCreate(token); + }}> + <ValidationInput + error={errorMessage} + id="personal_access_token" + isInvalid={isInvalid} + isValid={false} + label={translate('onboarding.create_project.enter_pat')} + required={true}> + <input + autoFocus={true} + className={classNames('width-100 little-spacer-bottom', { + 'is-invalid': isInvalid + })} + id="personal_access_token" + minLength={1} + name="personal_access_token" + onChange={(e: React.ChangeEvent<HTMLInputElement>) => { + setToken(e.target.value); + setTouched(true); + }} + type="text" + value={token} + /> + </ValidationInput> + + <SubmitButton disabled={isInvalid || submitting || !touched}> + {translate('onboarding.create_project.pat_form.list_repositories')} + </SubmitButton> + <DeferredSpinner className="spacer-left" loading={submitting} /> + </form> + </div> + </div> + ); +} diff --git a/server/sonar-web/src/main/js/apps/create/project/AzureProjectCreate.tsx b/server/sonar-web/src/main/js/apps/create/project/AzureProjectCreate.tsx new file mode 100644 index 00000000000..292abc2e548 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/AzureProjectCreate.tsx @@ -0,0 +1,143 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 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 { + checkPersonalAccessTokenIsValid, + setAlmPersonalAccessToken +} from '../../../api/alm-integrations'; +import { AlmSettingsInstance } from '../../../types/alm-settings'; +import AzureCreateProjectRenderer from './AzureProjectCreateRenderer'; + +interface Props extends Pick<WithRouterProps, 'location'> { + canAdmin: boolean; + loadingBindings: boolean; + onProjectCreate: (projectKeys: string[]) => void; + settings: AlmSettingsInstance[]; +} + +interface State { + loading: boolean; + patIsValid?: boolean; + settings?: AlmSettingsInstance; + submittingToken?: boolean; + tokenValidationFailed: boolean; +} + +export default class AzureProjectCreate 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, + tokenValidationFailed: false + }; + } + + componentDidMount() { + this.mounted = true; + this.fetchInitialData(); + } + + componentDidUpdate(prevProps: Props) { + if (prevProps.settings.length === 0 && this.props.settings.length > 0) { + this.setState( + { settings: this.props.settings.length === 1 ? this.props.settings[0] : undefined }, + () => this.fetchInitialData() + ); + } + } + + componentWillUnmount() { + this.mounted = false; + } + + fetchInitialData = async () => { + this.setState({ loading: true }); + + const patIsValid = await this.checkPersonalAccessToken().catch(() => false); + + if (this.mounted) { + this.setState({ + patIsValid, + loading: false + }); + } + }; + + checkPersonalAccessToken = () => { + const { settings } = this.state; + + if (!settings) { + return Promise.resolve(false); + } + + return checkPersonalAccessTokenIsValid(settings.key); + }; + + handlePersonalAccessTokenCreate = async (token: string) => { + const { settings } = this.state; + + if (!settings || token.length < 1) { + return; + } + + this.setState({ submittingToken: true, tokenValidationFailed: false }); + + try { + await setAlmPersonalAccessToken(settings.key, token); + const patIsValid = await this.checkPersonalAccessToken(); + + if (this.mounted) { + this.setState({ submittingToken: false, patIsValid, tokenValidationFailed: !patIsValid }); + + if (patIsValid) { + this.cleanUrl(); + await this.fetchInitialData(); + } + } + } catch (e) { + if (this.mounted) { + this.setState({ submittingToken: false }); + } + } + }; + + render() { + const { canAdmin, loadingBindings, location } = this.props; + const { loading, patIsValid, settings, submittingToken, tokenValidationFailed } = this.state; + + return ( + <AzureCreateProjectRenderer + canAdmin={canAdmin} + loading={loading || loadingBindings} + onPersonalAccessTokenCreate={this.handlePersonalAccessTokenCreate} + settings={settings} + showPersonalAccessTokenForm={!patIsValid || Boolean(location.query.resetPat)} + submittingToken={submittingToken} + tokenValidationFailed={tokenValidationFailed} + /> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/create/project/AzureProjectCreateRenderer.tsx b/server/sonar-web/src/main/js/apps/create/project/AzureProjectCreateRenderer.tsx new file mode 100644 index 00000000000..2d43a8bb26a --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/AzureProjectCreateRenderer.tsx @@ -0,0 +1,87 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 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 AzurePersonalAccessTokenForm from './AzurePersonalAccessTokenForm'; +import AzureProjectsList from './AzureProjectsList'; +import CreateProjectPageHeader from './CreateProjectPageHeader'; +import WrongBindingCountAlert from './WrongBindingCountAlert'; + +export interface AzureProjectCreateRendererProps { + canAdmin?: boolean; + loading: boolean; + onPersonalAccessTokenCreate: (token: string) => void; + settings?: AlmSettingsInstance; + showPersonalAccessTokenForm?: boolean; + submittingToken?: boolean; + tokenValidationFailed: boolean; +} + +export default function AzureProjectCreateRenderer(props: AzureProjectCreateRendererProps) { + const { + canAdmin, + loading, + showPersonalAccessTokenForm, + settings, + submittingToken, + tokenValidationFailed + } = 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/azure.svg`} + /> + {translate('onboarding.create_project.azure.title')} + </span> + } + /> + + {loading && <i className="spinner" />} + + {!loading && !settings && ( + <WrongBindingCountAlert alm={AlmKeys.Azure} canAdmin={!!canAdmin} /> + )} + + {!loading && + settings && + (showPersonalAccessTokenForm ? ( + <div className="display-flex-justify-center"> + <AzurePersonalAccessTokenForm + almSetting={settings} + onPersonalAccessTokenCreate={props.onPersonalAccessTokenCreate} + submitting={submittingToken} + validationFailed={tokenValidationFailed} + /> + </div> + ) : ( + <AzureProjectsList /> + ))} + </> + ); +} diff --git a/server/sonar-web/src/main/js/apps/create/project/AzureProjectsList.tsx b/server/sonar-web/src/main/js/apps/create/project/AzureProjectsList.tsx new file mode 100644 index 00000000000..c6f34ede827 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/AzureProjectsList.tsx @@ -0,0 +1,31 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 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 { Alert } from 'sonar-ui-common/components/ui/Alert'; + +export interface AzureProjectsListProps {} + +export default function AzureProjectsList(_props: AzureProjectsListProps) { + return ( + <div> + <Alert variant="warning">Coming soon!</Alert> + </div> + ); +} diff --git a/server/sonar-web/src/main/js/apps/create/project/CreateProjectModeSelection.tsx b/server/sonar-web/src/main/js/apps/create/project/CreateProjectModeSelection.tsx index 4508e2762c2..6524aad672a 100644 --- a/server/sonar-web/src/main/js/apps/create/project/CreateProjectModeSelection.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/CreateProjectModeSelection.tsx @@ -112,6 +112,7 @@ export default function CreateProjectModeSelection(props: CreateProjectModeSelec </div> </button> + {renderAlmOption(props, AlmKeys.Azure, CreateProjectModes.AzureDevOps)} {renderAlmOption(props, AlmKeys.Bitbucket, CreateProjectModes.BitbucketServer)} {renderAlmOption(props, AlmKeys.GitHub, CreateProjectModes.GitHub)} {renderAlmOption(props, AlmKeys.GitLab, CreateProjectModes.GitLab)} 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 3976e1acb93..a2cbadb2912 100644 --- a/server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx @@ -27,6 +27,7 @@ import { whenLoggedIn } from '../../../components/hoc/whenLoggedIn'; import { withAppState } from '../../../components/hoc/withAppState'; import { getProjectUrl } from '../../../helpers/urls'; import { AlmKeys, AlmSettingsInstance } from '../../../types/alm-settings'; +import AzureProjectCreate from './AzureProjectCreate'; import BitbucketProjectCreate from './BitbucketProjectCreate'; import CreateProjectModeSelection from './CreateProjectModeSelection'; import GitHubProjectCreate from './GitHubProjectCreate'; @@ -41,6 +42,7 @@ interface Props extends Pick<WithRouterProps, 'router' | 'location'> { } interface State { + azureSettings: AlmSettingsInstance[]; bitbucketSettings: AlmSettingsInstance[]; githubSettings: AlmSettingsInstance[]; gitlabSettings: AlmSettingsInstance[]; @@ -49,7 +51,13 @@ interface State { export class CreateProjectPage extends React.PureComponent<Props, State> { mounted = false; - state: State = { bitbucketSettings: [], githubSettings: [], gitlabSettings: [], loading: true }; + state: State = { + azureSettings: [], + bitbucketSettings: [], + githubSettings: [], + gitlabSettings: [], + loading: true + }; componentDidMount() { const { @@ -71,6 +79,7 @@ export class CreateProjectPage extends React.PureComponent<Props, State> { .then(almSettings => { if (this.mounted) { this.setState({ + azureSettings: almSettings.filter(s => s.alm === AlmKeys.Azure), bitbucketSettings: almSettings.filter(s => s.alm === AlmKeys.Bitbucket), githubSettings: almSettings.filter(s => s.alm === AlmKeys.GitHub), gitlabSettings: almSettings.filter(s => s.alm === AlmKeys.GitLab), @@ -105,9 +114,26 @@ export class CreateProjectPage extends React.PureComponent<Props, State> { location, router } = this.props; - const { bitbucketSettings, githubSettings, gitlabSettings, loading } = this.state; + const { + azureSettings, + bitbucketSettings, + githubSettings, + gitlabSettings, + loading + } = this.state; switch (mode) { + case CreateProjectModes.AzureDevOps: { + return ( + <AzureProjectCreate + canAdmin={!!canAdmin} + loadingBindings={loading} + location={location} + onProjectCreate={this.handleProjectCreate} + settings={azureSettings} + /> + ); + } case CreateProjectModes.BitbucketServer: { return ( <BitbucketProjectCreate @@ -148,7 +174,7 @@ export class CreateProjectPage extends React.PureComponent<Props, State> { } default: { const almCounts = { - [AlmKeys.Azure]: 0, + [AlmKeys.Azure]: azureSettings.length, [AlmKeys.Bitbucket]: bitbucketSettings.length, [AlmKeys.GitHub]: githubSettings.length, [AlmKeys.GitLab]: gitlabSettings.length diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/AzurePersonalAccessTokenForm-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/AzurePersonalAccessTokenForm-test.tsx new file mode 100644 index 00000000000..d83088fd07c --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/AzurePersonalAccessTokenForm-test.tsx @@ -0,0 +1,72 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 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 { 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 { AlmKeys } from '../../../../types/alm-settings'; +import AzurePersonalAccessTokenForm, { + AzurePersonalAccessTokenFormProps +} from '../AzurePersonalAccessTokenForm'; + +it('should render correctly', () => { + expect(shallowRender()).toMatchSnapshot('default'); + expect(shallowRender({ submitting: true })).toMatchSnapshot('submitting'); + expect(shallowRender({ validationFailed: true })).toMatchSnapshot('validation failed'); +}); + +it('should correctly handle form interactions', () => { + const onPersonalAccessTokenCreate = jest.fn(); + const wrapper = shallowRender({ onPersonalAccessTokenCreate }); + + // Submit button disabled by default. + expect(wrapper.find(SubmitButton).prop('disabled')).toBe(true); + + // Submit button enabled if there's a value. + change(wrapper.find('input'), 'token'); + expect(wrapper.find(SubmitButton).prop('disabled')).toBe(false); + + // Expect correct calls to be made when submitting. + submit(wrapper.find('form')); + expect(onPersonalAccessTokenCreate).toBeCalled(); + + // 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 }); + expect(wrapper.find(SubmitButton).prop('disabled')).toBe(true); +}); + +function shallowRender(props: Partial<AzurePersonalAccessTokenFormProps> = {}) { + return shallow<AzurePersonalAccessTokenFormProps>( + <AzurePersonalAccessTokenForm + almSetting={mockAlmSettingsInstance({ + alm: AlmKeys.Azure, + url: 'http://www.example.com' + })} + onPersonalAccessTokenCreate={jest.fn()} + validationFailed={false} + {...props} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/AzureProjectCreate-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/AzureProjectCreate-test.tsx new file mode 100644 index 00000000000..7ce89327f6e --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/AzureProjectCreate-test.tsx @@ -0,0 +1,92 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 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. + */ +/* eslint-disable sonarjs/no-duplicate-string */ +import { shallow } from 'enzyme'; +import * as React from 'react'; +import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; +import { + checkPersonalAccessTokenIsValid, + setAlmPersonalAccessToken +} from '../../../../api/alm-integrations'; +import { mockAlmSettingsInstance } from '../../../../helpers/mocks/alm-settings'; +import { mockLocation } from '../../../../helpers/testMocks'; +import { AlmKeys } from '../../../../types/alm-settings'; +import AzureProjectCreate from '../AzureProjectCreate'; + +jest.mock('../../../../api/alm-integrations', () => { + return { + checkPersonalAccessTokenIsValid: jest.fn().mockResolvedValue(true), + setAlmPersonalAccessToken: jest.fn().mockResolvedValue(null) + }; +}); + +beforeEach(jest.clearAllMocks); + +it('should render correctly', () => { + 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(true); + const wrapper = shallowRender(); + await waitAndUpdate(wrapper); + expect(checkPersonalAccessTokenIsValid).toBeCalled(); + expect(wrapper.state().patIsValid).toBe(true); +}); + +it('should correctly handle an invalid PAT', async () => { + (checkPersonalAccessTokenIsValid as jest.Mock).mockResolvedValueOnce(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(false); + await waitAndUpdate(wrapper); + expect(checkPersonalAccessTokenIsValid).toBeCalled(); + expect(wrapper.state().submittingToken).toBe(false); + expect(wrapper.state().tokenValidationFailed).toBe(true); +}); + +function shallowRender(overrides: Partial<AzureProjectCreate['props']> = {}) { + return shallow<AzureProjectCreate>( + <AzureProjectCreate + canAdmin={true} + loadingBindings={false} + location={mockLocation()} + onProjectCreate={jest.fn()} + settings={[mockAlmSettingsInstance({ alm: AlmKeys.Azure, key: 'foo' })]} + {...overrides} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/AzureProjectCreateRenderer-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/AzureProjectCreateRenderer-test.tsx new file mode 100644 index 00000000000..d38e64d922e --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/AzureProjectCreateRenderer-test.tsx @@ -0,0 +1,49 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 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. + */ +/* eslint-disable sonarjs/no-duplicate-string */ +import { shallow } from 'enzyme'; +import * as React from 'react'; +import { mockAlmSettingsInstance } from '../../../../helpers/mocks/alm-settings'; +import { AlmKeys } from '../../../../types/alm-settings'; +import AzureProjectCreateRenderer, { + AzureProjectCreateRendererProps +} from '../AzureProjectCreateRenderer'; + +it('should render correctly', () => { + expect(shallowRender({ loading: true })).toMatchSnapshot('loading'); + expect(shallowRender({ settings: undefined })).toMatchSnapshot('no settings'); + expect(shallowRender({ showPersonalAccessTokenForm: true })).toMatchSnapshot('token form'); + expect(shallowRender({})).toMatchSnapshot('project list'); +}); + +function shallowRender(overrides: Partial<AzureProjectCreateRendererProps>) { + return shallow( + <AzureProjectCreateRenderer + canAdmin={true} + loading={false} + onPersonalAccessTokenCreate={jest.fn()} + tokenValidationFailed={false} + settings={mockAlmSettingsInstance({ alm: AlmKeys.Azure })} + showPersonalAccessTokenForm={false} + submittingToken={false} + {...overrides} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/CreateProjectModeSelection-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/CreateProjectModeSelection-test.tsx index 6d0717eecaf..2cfeddd296e 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/CreateProjectModeSelection-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/CreateProjectModeSelection-test.tsx @@ -39,14 +39,27 @@ it('should correctly pass the selected mode up', () => { const onSelectMode = jest.fn(); const wrapper = shallowRender({ onSelectMode }); + const almButton = 'button.create-project-mode-type-alm'; + click(wrapper.find('button.create-project-mode-type-manual')); expect(onSelectMode).toBeCalledWith(CreateProjectModes.Manual); + onSelectMode.mockClear(); + + click(wrapper.find(almButton).at(0)); + expect(onSelectMode).toBeCalledWith(CreateProjectModes.AzureDevOps); + onSelectMode.mockClear(); - click(wrapper.find('button.create-project-mode-type-alm').at(0)); + click(wrapper.find(almButton).at(1)); expect(onSelectMode).toBeCalledWith(CreateProjectModes.BitbucketServer); + onSelectMode.mockClear(); - click(wrapper.find('button.create-project-mode-type-alm').at(1)); + click(wrapper.find(almButton).at(2)); expect(onSelectMode).toBeCalledWith(CreateProjectModes.GitHub); + onSelectMode.mockClear(); + + click(wrapper.find(almButton).at(3)); + expect(onSelectMode).toBeCalledWith(CreateProjectModes.GitLab); + onSelectMode.mockClear(); }); function shallowRender( diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/CreateProjectPage-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/CreateProjectPage-test.tsx index e2d16dd4ed7..cb0e4d074b3 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/CreateProjectPage-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/CreateProjectPage-test.tsx @@ -50,6 +50,14 @@ it('should render correctly if the manual method is selected', () => { ).toMatchSnapshot(); }); +it('should render correctly if the Azure method is selected', () => { + expect( + shallowRender({ + location: mockLocation({ query: { mode: CreateProjectModes.AzureDevOps } }) + }) + ).toMatchSnapshot(); +}); + it('should render correctly if the BBS method is selected', () => { expect( shallowRender({ diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AzurePersonalAccessTokenForm-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AzurePersonalAccessTokenForm-test.tsx.snap new file mode 100644 index 00000000000..c8399e96c85 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AzurePersonalAccessTokenForm-test.tsx.snap @@ -0,0 +1,229 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly: default 1`] = ` +<div + className="boxed-group abs-width-600" +> + <div + className="boxed-group-inner" + > + <h2> + onboarding.create_project.pat_form.title.azure + </h2> + <div + className="big-spacer-top big-spacer-bottom" + > + <FormattedMessage + defaultMessage="onboarding.create_project.pat_help.instructions.azure" + id="onboarding.create_project.pat_help.instructions" + values={ + Object { + "link": <a + className="link-with-icon" + href="http://www.example.com/_usersSettings/tokens" + rel="noopener noreferrer" + target="_blank" + > + <DetachIcon + className="little-spacer-right" + /> + <span> + onboarding.create_project.pat_help.instructions.link.azure + </span> + </a>, + "scope": <strong> + <em> + Code (Read & Write) + </em> + </strong>, + } + } + /> + </div> + <form + onSubmit={[Function]} + > + <ValidationInput + error="onboarding.create_project.pat_form.pat_required" + id="personal_access_token" + isInvalid={false} + isValid={false} + label="onboarding.create_project.enter_pat" + required={true} + > + <input + autoFocus={true} + className="width-100 little-spacer-bottom" + id="personal_access_token" + minLength={1} + name="personal_access_token" + onChange={[Function]} + type="text" + value="" + /> + </ValidationInput> + <SubmitButton + disabled={true} + > + onboarding.create_project.pat_form.list_repositories + </SubmitButton> + <DeferredSpinner + className="spacer-left" + loading={false} + /> + </form> + </div> +</div> +`; + +exports[`should render correctly: submitting 1`] = ` +<div + className="boxed-group abs-width-600" +> + <div + className="boxed-group-inner" + > + <h2> + onboarding.create_project.pat_form.title.azure + </h2> + <div + className="big-spacer-top big-spacer-bottom" + > + <FormattedMessage + defaultMessage="onboarding.create_project.pat_help.instructions.azure" + id="onboarding.create_project.pat_help.instructions" + values={ + Object { + "link": <a + className="link-with-icon" + href="http://www.example.com/_usersSettings/tokens" + rel="noopener noreferrer" + target="_blank" + > + <DetachIcon + className="little-spacer-right" + /> + <span> + onboarding.create_project.pat_help.instructions.link.azure + </span> + </a>, + "scope": <strong> + <em> + Code (Read & Write) + </em> + </strong>, + } + } + /> + </div> + <form + onSubmit={[Function]} + > + <ValidationInput + error="onboarding.create_project.pat_form.pat_required" + id="personal_access_token" + isInvalid={false} + isValid={false} + label="onboarding.create_project.enter_pat" + required={true} + > + <input + autoFocus={true} + className="width-100 little-spacer-bottom" + id="personal_access_token" + minLength={1} + name="personal_access_token" + onChange={[Function]} + type="text" + value="" + /> + </ValidationInput> + <SubmitButton + disabled={true} + > + onboarding.create_project.pat_form.list_repositories + </SubmitButton> + <DeferredSpinner + className="spacer-left" + loading={true} + /> + </form> + </div> +</div> +`; + +exports[`should render correctly: validation failed 1`] = ` +<div + className="boxed-group abs-width-600" +> + <div + className="boxed-group-inner" + > + <h2> + onboarding.create_project.pat_form.title.azure + </h2> + <div + className="big-spacer-top big-spacer-bottom" + > + <FormattedMessage + defaultMessage="onboarding.create_project.pat_help.instructions.azure" + id="onboarding.create_project.pat_help.instructions" + values={ + Object { + "link": <a + className="link-with-icon" + href="http://www.example.com/_usersSettings/tokens" + rel="noopener noreferrer" + target="_blank" + > + <DetachIcon + className="little-spacer-right" + /> + <span> + onboarding.create_project.pat_help.instructions.link.azure + </span> + </a>, + "scope": <strong> + <em> + Code (Read & Write) + </em> + </strong>, + } + } + /> + </div> + <form + onSubmit={[Function]} + > + <ValidationInput + error="onboarding.create_project.pat_form.pat_required" + id="personal_access_token" + isInvalid={true} + isValid={false} + label="onboarding.create_project.enter_pat" + required={true} + > + <input + autoFocus={true} + className="width-100 little-spacer-bottom is-invalid" + id="personal_access_token" + minLength={1} + name="personal_access_token" + onChange={[Function]} + type="text" + value="" + /> + </ValidationInput> + <SubmitButton + disabled={true} + > + onboarding.create_project.pat_form.list_repositories + </SubmitButton> + <DeferredSpinner + className="spacer-left" + loading={false} + /> + </form> + </div> +</div> +`; diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AzureProjectCreate-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AzureProjectCreate-test.tsx.snap new file mode 100644 index 00000000000..47f70559c9f --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AzureProjectCreate-test.tsx.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` +<AzureProjectCreateRenderer + canAdmin={true} + loading={true} + onPersonalAccessTokenCreate={[Function]} + settings={ + Object { + "alm": "azure", + "key": "foo", + } + } + showPersonalAccessTokenForm={true} + tokenValidationFailed={false} +/> +`; diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AzureProjectCreateRenderer-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AzureProjectCreateRenderer-test.tsx.snap new file mode 100644 index 00000000000..dc15c4a504a --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AzureProjectCreateRenderer-test.tsx.snap @@ -0,0 +1,104 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly: loading 1`] = ` +<Fragment> + <CreateProjectPageHeader + title={ + <span + className="text-middle" + > + <img + alt="" + className="spacer-right" + height="24" + src="/images/alm/azure.svg" + /> + onboarding.create_project.azure.title + </span> + } + /> + <i + className="spinner" + /> +</Fragment> +`; + +exports[`should render correctly: no settings 1`] = ` +<Fragment> + <CreateProjectPageHeader + title={ + <span + className="text-middle" + > + <img + alt="" + className="spacer-right" + height="24" + src="/images/alm/azure.svg" + /> + onboarding.create_project.azure.title + </span> + } + /> + <WrongBindingCountAlert + alm="azure" + canAdmin={true} + /> +</Fragment> +`; + +exports[`should render correctly: project list 1`] = ` +<Fragment> + <CreateProjectPageHeader + title={ + <span + className="text-middle" + > + <img + alt="" + className="spacer-right" + height="24" + src="/images/alm/azure.svg" + /> + onboarding.create_project.azure.title + </span> + } + /> + <AzureProjectsList /> +</Fragment> +`; + +exports[`should render correctly: token form 1`] = ` +<Fragment> + <CreateProjectPageHeader + title={ + <span + className="text-middle" + > + <img + alt="" + className="spacer-right" + height="24" + src="/images/alm/azure.svg" + /> + onboarding.create_project.azure.title + </span> + } + /> + <div + className="display-flex-justify-center" + > + <AzurePersonalAccessTokenForm + almSetting={ + Object { + "alm": "azure", + "key": "key", + } + } + onPersonalAccessTokenCreate={[MockFunction]} + submitting={false} + validationFailed={false} + /> + </div> +</Fragment> +`; diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/CreateProjectModeSelection-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/CreateProjectModeSelection-test.tsx.snap index cd06179f636..cb6db4d27f5 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/CreateProjectModeSelection-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/CreateProjectModeSelection-test.tsx.snap @@ -36,6 +36,37 @@ exports[`should render correctly: default 1`] = ` </div> </button> <button + className="button button-huge big-spacer-left display-flex-column create-project-mode-type-alm disabled" + disabled={true} + onClick={[Function]} + type="button" + > + <img + alt="" + height={80} + src="/images/alm/azure.svg" + /> + <div + className="medium big-spacer-top" + > + onboarding.create_project.select_method.azure + </div> + <div + className="text-muted small spacer-top" + style={ + Object { + "lineHeight": 1.5, + } + } + > + onboarding.create_project.alm_not_configured + <HelpTooltip + className="little-spacer-left" + overlay="onboarding.create_project.zero_alm_instances.azure" + /> + </div> + </button> + <button className="button button-huge big-spacer-left display-flex-column create-project-mode-type-alm" disabled={false} onClick={[Function]} @@ -162,6 +193,37 @@ exports[`should render correctly: invalid configs 1`] = ` <img alt="" height={80} + src="/images/alm/azure.svg" + /> + <div + className="medium big-spacer-top" + > + onboarding.create_project.select_method.azure + </div> + <div + className="text-muted small spacer-top" + style={ + Object { + "lineHeight": 1.5, + } + } + > + onboarding.create_project.alm_not_configured + <HelpTooltip + className="little-spacer-left" + overlay="onboarding.create_project.zero_alm_instances.azure" + /> + </div> + </button> + <button + className="button button-huge big-spacer-left display-flex-column create-project-mode-type-alm disabled" + disabled={true} + onClick={[Function]} + type="button" + > + <img + alt="" + height={80} src="/images/alm/bitbucket.svg" /> <div @@ -295,6 +357,29 @@ exports[`should render correctly: loading instances 1`] = ` <img alt="" height={80} + src="/images/alm/azure.svg" + /> + <div + className="medium big-spacer-top" + > + onboarding.create_project.select_method.azure + </div> + <span> + onboarding.create_project.check_alm_supported + <i + className="little-spacer-left spinner" + /> + </span> + </button> + <button + className="button button-huge big-spacer-left display-flex-column create-project-mode-type-alm disabled" + disabled={true} + onClick={[Function]} + type="button" + > + <img + alt="" + height={80} src="/images/alm/bitbucket.svg" /> <div 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 8dd4ffc6bc8..7f37890764c 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 @@ -53,6 +53,44 @@ exports[`should render correctly if no branch support 1`] = ` </Fragment> `; +exports[`should render correctly if the Azure method is selected 1`] = ` +<Fragment> + <Helmet + defer={true} + encodeSpecialCharacters={true} + title="my_account.create_new.TRK" + titleTemplate="%s" + /> + <A11ySkipTarget + anchor="create_project_main" + /> + <div + className="page page-limited huge-spacer-bottom position-relative" + id="create-project" + > + <AzureProjectCreate + canAdmin={false} + loadingBindings={true} + location={ + Object { + "action": "PUSH", + "hash": "", + "key": "key", + "pathname": "/path", + "query": Object { + "mode": "azure", + }, + "search": "", + "state": Object {}, + } + } + onProjectCreate={[Function]} + settings={Array []} + /> + </div> +</Fragment> +`; + exports[`should render correctly if the BBS method is selected 1`] = ` <Fragment> <Helmet 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 c84dd2c1d4c..c000fa481d5 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 @@ -19,6 +19,7 @@ */ export enum CreateProjectModes { Manual = 'manual', + AzureDevOps = 'azure', BitbucketServer = 'bitbucket', GitHub = 'github', GitLab = 'gitlab' |