return this.reply({ newCodePeriods: this.#listBranchesNewCode });
};
+ setNewCodePeriod = (newCodePeriod: NewCodePeriod) => {
+ this.#newCodePeriod = newCodePeriod;
+ };
+
reset = () => {
this.#newCodePeriod = cloneDeep(this.#defaultNewCodePeriod);
this.#listBranchesNewCode = cloneDeep(this.#defaultListBranchesNewCode);
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 AlmSettingsInstanceSelector from '../../../components/devops-platform/AlmSettingsInstanceSelector';
-import { hasMessage, translate, translateWithParameters } from '../../../helpers/l10n';
-import { AlmKeys, AlmSettingsInstance } from '../../../types/alm-settings';
-
-export interface AlmSettingsInstanceDropdownProps {
- almKey: AlmKeys;
- almInstances?: AlmSettingsInstance[];
- selectedAlmInstance?: AlmSettingsInstance;
- onChangeConfig: (instance: AlmSettingsInstance) => void;
-}
-
-const MIN_SIZE_INSTANCES = 2;
-
-export default function AlmSettingsInstanceDropdown(props: AlmSettingsInstanceDropdownProps) {
- const { almKey, almInstances, selectedAlmInstance } = props;
- if (!almInstances || almInstances.length < MIN_SIZE_INSTANCES) {
- return null;
- }
-
- const almKeyTranslation = hasMessage(`alm.${almKey}.long`)
- ? `alm.${almKey}.long`
- : `alm.${almKey}`;
-
- return (
- <div className="display-flex-column huge-spacer-bottom">
- <label htmlFor="alm-config-selector" className="spacer-bottom">
- {translateWithParameters('alm.configuration.selector.label', translate(almKeyTranslation))}
- </label>
- <AlmSettingsInstanceSelector
- instances={almInstances}
- onChange={props.onChangeConfig}
- initialValue={selectedAlmInstance ? selectedAlmInstance.key : undefined}
- classNames="abs-width-400"
- inputId="alm-config-selector"
- />
- </div>
- );
-}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 classNames from 'classnames';
+import * as React from 'react';
+import { FormattedMessage } from 'react-intl';
+import Link from '../../../../components/common/Link';
+import ValidationInput from '../../../../components/controls/ValidationInput';
+import { SubmitButton } from '../../../../components/controls/buttons';
+import { Alert } from '../../../../components/ui/Alert';
+import DeferredSpinner from '../../../../components/ui/DeferredSpinner';
+import { translate } from '../../../../helpers/l10n';
+import { AlmSettingsInstance } from '../../../../types/alm-settings';
+
+export interface AzurePersonalAccessTokenFormProps {
+ almSetting: AlmSettingsInstance;
+ onPersonalAccessTokenCreate: (token: string) => void;
+ submitting?: boolean;
+ validationFailed: boolean;
+ firstConnection?: boolean;
+}
+
+function getAzurePatUrl(url: string) {
+ return `${url.replace(/\/$/, '')}/_usersSettings/tokens`;
+}
+
+export default function AzurePersonalAccessTokenForm(props: AzurePersonalAccessTokenFormProps) {
+ const {
+ almSetting: { alm, url },
+ submitting = false,
+ validationFailed,
+ firstConnection,
+ } = 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 ? (
+ <Link className="link-no-underline" to={getAzurePatUrl(url)} target="_blank">
+ {translate('onboarding.create_project.pat_help.instructions.link', alm)}
+ </Link>
+ ) : (
+ translate('onboarding.create_project.pat_help.instructions.link', alm)
+ ),
+ scope: (
+ <strong>
+ <em>Code (Read & Write)</em>
+ </strong>
+ ),
+ }}
+ />
+ </div>
+
+ {!firstConnection && (
+ <Alert className="big-spacer-right" variant="warning">
+ <p>{translate('onboarding.create_project.pat.expired.info_message')}</p>
+ <p>{translate('onboarding.create_project.pat.expired.info_message_contact')}</p>
+ </Alert>
+ )}
+
+ <form
+ onSubmit={(e: React.SyntheticEvent<HTMLFormElement>) => {
+ e.preventDefault();
+ props.onPersonalAccessTokenCreate(token);
+ }}
+ >
+ <ValidationInput
+ error={errorMessage}
+ labelHtmlFor="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>
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 classNames from 'classnames';
+import * as React from 'react';
+import { FormattedMessage } from 'react-intl';
+import { colors } from '../../../../app/theme';
+import Link from '../../../../components/common/Link';
+import BoxedGroupAccordion from '../../../../components/controls/BoxedGroupAccordion';
+import ListFooter from '../../../../components/controls/ListFooter';
+import Radio from '../../../../components/controls/Radio';
+import CheckIcon from '../../../../components/icons/CheckIcon';
+import { Alert } from '../../../../components/ui/Alert';
+import DeferredSpinner from '../../../../components/ui/DeferredSpinner';
+import { translate } from '../../../../helpers/l10n';
+import { getProjectUrl, queryToSearch } from '../../../../helpers/urls';
+import { AzureProject, AzureRepository } from '../../../../types/alm-integration';
+import { CreateProjectModes } from '../types';
+
+export interface AzureProjectAccordionProps {
+ importing: boolean;
+ loading: boolean;
+ onOpen: (key: string) => void;
+ onSelectRepository: (repository: AzureRepository) => void;
+ project: AzureProject;
+ repositories?: AzureRepository[];
+ searchQuery?: string;
+ selectedRepository?: AzureRepository;
+ startsOpen: boolean;
+}
+
+const PAGE_SIZE = 30;
+
+function highlight(text: string, term?: string, underline = false) {
+ if (!term || !text.toLowerCase().includes(term.toLowerCase())) {
+ return text;
+ }
+
+ // Capture only the first occurence by using a capturing group to get
+ // everything after the first occurence
+ const [pre, found, post] = text.split(new RegExp(`(${term})(.*)`, 'i'));
+ return (
+ <>
+ {pre}
+ <strong className={classNames({ underline })}>{found}</strong>
+ {post}
+ </>
+ );
+}
+
+export default function AzureProjectAccordion(props: AzureProjectAccordionProps) {
+ const {
+ importing,
+ loading,
+ startsOpen,
+ project,
+ repositories = [],
+ searchQuery,
+ selectedRepository,
+ } = props;
+
+ const [open, setOpen] = React.useState(startsOpen);
+ const handleClick = () => {
+ if (!open) {
+ props.onOpen(project.name);
+ }
+ setOpen(!open);
+ };
+
+ const [page, setPage] = React.useState(1);
+ const limitedRepositories = repositories.slice(0, page * PAGE_SIZE);
+
+ const isSelected = (repo: AzureRepository) =>
+ selectedRepository?.projectName === project.name && selectedRepository.name === repo.name;
+
+ return (
+ <BoxedGroupAccordion
+ className={classNames('big-spacer-bottom', {
+ open,
+ })}
+ onClick={handleClick}
+ open={open}
+ title={<h3 title={project.description}>{highlight(project.name, searchQuery, true)}</h3>}
+ >
+ {open && (
+ <DeferredSpinner loading={loading}>
+ {/* The extra loading guard is to prevent the flash of the Alert */}
+ {!loading && repositories.length === 0 ? (
+ <Alert variant="warning">
+ <FormattedMessage
+ defaultMessage={translate('onboarding.create_project.azure.no_repositories')}
+ id="onboarding.create_project.azure.no_repositories"
+ values={{
+ link: (
+ <Link
+ to={{
+ pathname: '/projects/create',
+ search: queryToSearch({
+ mode: CreateProjectModes.AzureDevOps,
+ resetPat: 1,
+ }),
+ }}
+ >
+ {translate('onboarding.create_project.update_your_token')}
+ </Link>
+ ),
+ }}
+ />
+ </Alert>
+ ) : (
+ <>
+ <div className="display-flex-wrap">
+ {limitedRepositories.map((repo) => (
+ <div
+ className="create-project-azdo-repo display-flex-start spacer-bottom padded-right"
+ key={repo.name}
+ >
+ {repo.sqProjectKey ? (
+ <>
+ <CheckIcon className="spacer-right" fill={colors.green} size={14} />
+ <div className="overflow-hidden">
+ <div className="little-spacer-bottom text-ellipsis">
+ <Link to={getProjectUrl(repo.sqProjectKey)} title={repo.sqProjectName}>
+ {highlight(repo.sqProjectName || repo.name, searchQuery)}
+ </Link>
+ </div>
+ <em>{translate('onboarding.create_project.repository_imported')}</em>
+ </div>
+ </>
+ ) : (
+ <Radio
+ checked={isSelected(repo)}
+ className="overflow-hidden"
+ alignLabel={true}
+ disabled={importing}
+ onCheck={() => props.onSelectRepository(repo)}
+ value={repo.name}
+ >
+ <span title={repo.name}>{highlight(repo.name, searchQuery)}</span>
+ </Radio>
+ )}
+ </div>
+ ))}
+ </div>
+ <ListFooter
+ count={limitedRepositories.length}
+ total={repositories.length}
+ loadMore={() => setPage((p) => p + 1)}
+ />
+ </>
+ )}
+ </DeferredSpinner>
+ )}
+ </BoxedGroupAccordion>
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 {
+ checkPersonalAccessTokenIsValid,
+ getAzureProjects,
+ getAzureRepositories,
+ importAzureRepository,
+ searchAzureRepositories,
+ setAlmPersonalAccessToken,
+} from '../../../../api/alm-integrations';
+import { Location, Router } from '../../../../components/hoc/withRouter';
+import { AzureProject, AzureRepository } from '../../../../types/alm-integration';
+import { AlmSettingsInstance } from '../../../../types/alm-settings';
+import { Dict } from '../../../../types/types';
+import { tokenExistedBefore } from '../utils';
+import AzureCreateProjectRenderer from './AzureProjectCreateRenderer';
+
+interface Props {
+ canAdmin: boolean;
+ loadingBindings: boolean;
+ onProjectCreate: (projectKey: string) => void;
+ almInstances: AlmSettingsInstance[];
+ location: Location;
+ router: Router;
+}
+
+interface State {
+ importing: boolean;
+ loading: boolean;
+ loadingRepositories: Dict<boolean>;
+ patIsValid?: boolean;
+ projects?: AzureProject[];
+ repositories: Dict<AzureRepository[]>;
+ searching?: boolean;
+ searchResults?: AzureRepository[];
+ searchQuery?: string;
+ selectedRepository?: AzureRepository;
+ selectedAlmInstance?: AlmSettingsInstance;
+ submittingToken?: boolean;
+ tokenValidationFailed: boolean;
+ firstConnection?: 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.
+ selectedAlmInstance: props.almInstances[0],
+ importing: false,
+ loading: false,
+ loadingRepositories: {},
+ repositories: {},
+ tokenValidationFailed: false,
+ firstConnection: false,
+ };
+ }
+
+ componentDidMount() {
+ this.mounted = true;
+ this.fetchData();
+ }
+
+ componentDidUpdate(prevProps: Props) {
+ if (prevProps.almInstances.length === 0 && this.props.almInstances.length > 0) {
+ this.setState({ selectedAlmInstance: this.props.almInstances[0] }, () => this.fetchData());
+ }
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ }
+
+ fetchData = async () => {
+ this.setState({ loading: true });
+
+ const { patIsValid, error } = await this.checkPersonalAccessToken();
+
+ let projects: AzureProject[] | undefined;
+ if (patIsValid) {
+ projects = await this.fetchAzureProjects();
+ }
+
+ const { repositories } = this.state;
+
+ let firstProjectName: string;
+
+ if (projects && projects.length > 0) {
+ firstProjectName = projects[0].name;
+
+ this.setState(({ loadingRepositories }) => ({
+ loadingRepositories: { ...loadingRepositories, [firstProjectName]: true },
+ }));
+
+ const repos = await this.fetchAzureRepositories(firstProjectName);
+ repositories[firstProjectName] = repos;
+ }
+
+ if (this.mounted) {
+ this.setState(({ loadingRepositories }) => {
+ if (firstProjectName) {
+ loadingRepositories[firstProjectName] = false;
+ }
+
+ return {
+ patIsValid,
+ loading: false,
+ loadingRepositories: { ...loadingRepositories },
+ projects,
+ repositories,
+ firstConnection: tokenExistedBefore(error),
+ };
+ });
+ }
+ };
+
+ fetchAzureProjects = (): Promise<AzureProject[] | undefined> => {
+ const { selectedAlmInstance } = this.state;
+
+ if (!selectedAlmInstance) {
+ return Promise.resolve(undefined);
+ }
+
+ return getAzureProjects(selectedAlmInstance.key).then(({ projects }) => projects);
+ };
+
+ fetchAzureRepositories = (projectName: string): Promise<AzureRepository[]> => {
+ const { selectedAlmInstance } = this.state;
+
+ if (!selectedAlmInstance) {
+ return Promise.resolve([]);
+ }
+
+ return getAzureRepositories(selectedAlmInstance.key, projectName)
+ .then(({ repositories }) => repositories)
+ .catch(() => []);
+ };
+
+ cleanUrl = () => {
+ const { location, router } = this.props;
+ delete location.query.resetPat;
+ router.replace(location);
+ };
+
+ handleOpenProject = async (projectName: string) => {
+ if (this.state.searchResults) {
+ return;
+ }
+
+ this.setState(({ loadingRepositories }) => ({
+ loadingRepositories: { ...loadingRepositories, [projectName]: true },
+ }));
+
+ const projectRepos = await this.fetchAzureRepositories(projectName);
+
+ this.setState(({ loadingRepositories, repositories }) => ({
+ loadingRepositories: { ...loadingRepositories, [projectName]: false },
+ repositories: { ...repositories, [projectName]: projectRepos },
+ }));
+ };
+
+ handleSearchRepositories = async (searchQuery: string) => {
+ const { selectedAlmInstance } = this.state;
+
+ if (!selectedAlmInstance) {
+ return;
+ }
+
+ if (searchQuery.length === 0) {
+ this.setState({ searchResults: undefined, searchQuery: undefined });
+ return;
+ }
+
+ this.setState({ searching: true });
+
+ const searchResults: AzureRepository[] = await searchAzureRepositories(
+ selectedAlmInstance.key,
+ searchQuery
+ )
+ .then(({ repositories }) => repositories)
+ .catch(() => []);
+
+ if (this.mounted) {
+ this.setState({
+ searching: false,
+ searchResults,
+ searchQuery,
+ });
+ }
+ };
+
+ handleImportRepository = async () => {
+ const { selectedRepository, selectedAlmInstance } = this.state;
+
+ if (!selectedAlmInstance || !selectedRepository) {
+ return;
+ }
+
+ this.setState({ importing: true });
+
+ const createdProject = await importAzureRepository(
+ selectedAlmInstance.key,
+ selectedRepository.projectName,
+ selectedRepository.name
+ )
+ .then(({ project }) => project)
+ .catch(() => undefined);
+
+ if (this.mounted) {
+ this.setState({ importing: false });
+ if (createdProject) {
+ this.props.onProjectCreate(createdProject.key);
+ }
+ }
+ };
+
+ handleSelectRepository = (selectedRepository: AzureRepository) => {
+ this.setState({ selectedRepository });
+ };
+
+ checkPersonalAccessToken = () => {
+ const { selectedAlmInstance } = this.state;
+
+ if (!selectedAlmInstance) {
+ return Promise.resolve({ patIsValid: false, error: '' });
+ }
+
+ return checkPersonalAccessTokenIsValid(selectedAlmInstance.key).then(({ status, error }) => {
+ return { patIsValid: status, error };
+ });
+ };
+
+ handlePersonalAccessTokenCreate = async (token: string) => {
+ const { selectedAlmInstance } = this.state;
+
+ if (!selectedAlmInstance || token.length < 1) {
+ return;
+ }
+
+ this.setState({ submittingToken: true, tokenValidationFailed: false });
+
+ try {
+ await setAlmPersonalAccessToken(selectedAlmInstance.key, token);
+ const { patIsValid } = await this.checkPersonalAccessToken();
+
+ if (this.mounted) {
+ this.setState({
+ submittingToken: false,
+ patIsValid,
+ tokenValidationFailed: !patIsValid,
+ });
+
+ if (patIsValid) {
+ this.cleanUrl();
+ this.fetchData();
+ }
+ }
+ } catch (e) {
+ if (this.mounted) {
+ this.setState({ submittingToken: false });
+ }
+ }
+ };
+
+ onSelectedAlmInstanceChange = (instance: AlmSettingsInstance) => {
+ this.setState(
+ { selectedAlmInstance: instance, searchResults: undefined, searchQuery: '' },
+ () => this.fetchData()
+ );
+ };
+
+ render() {
+ const { canAdmin, loadingBindings, location, almInstances } = this.props;
+ const {
+ importing,
+ loading,
+ loadingRepositories,
+ patIsValid,
+ projects,
+ repositories,
+ searching,
+ searchResults,
+ searchQuery,
+ selectedRepository,
+ selectedAlmInstance,
+ submittingToken,
+ tokenValidationFailed,
+ firstConnection,
+ } = this.state;
+
+ return (
+ <AzureCreateProjectRenderer
+ canAdmin={canAdmin}
+ importing={importing}
+ loading={loading || loadingBindings}
+ loadingRepositories={loadingRepositories}
+ onImportRepository={this.handleImportRepository}
+ onOpenProject={this.handleOpenProject}
+ onPersonalAccessTokenCreate={this.handlePersonalAccessTokenCreate}
+ onSearch={this.handleSearchRepositories}
+ onSelectRepository={this.handleSelectRepository}
+ projects={projects}
+ repositories={repositories}
+ searching={searching}
+ searchResults={searchResults}
+ searchQuery={searchQuery}
+ selectedRepository={selectedRepository}
+ almInstances={almInstances}
+ selectedAlmInstance={selectedAlmInstance}
+ showPersonalAccessTokenForm={!patIsValid || Boolean(location.query.resetPat)}
+ submittingToken={submittingToken}
+ tokenValidationFailed={tokenValidationFailed}
+ onSelectedAlmInstanceChange={this.onSelectedAlmInstanceChange}
+ firstConnection={firstConnection}
+ />
+ );
+ }
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { FormattedMessage } from 'react-intl';
+import Link from '../../../../components/common/Link';
+import SearchBox from '../../../../components/controls/SearchBox';
+import { Button } from '../../../../components/controls/buttons';
+import { Alert } from '../../../../components/ui/Alert';
+import DeferredSpinner from '../../../../components/ui/DeferredSpinner';
+import { translate } from '../../../../helpers/l10n';
+import { getBaseUrl } from '../../../../helpers/system';
+import { getGlobalSettingsUrl } from '../../../../helpers/urls';
+import { AzureProject, AzureRepository } from '../../../../types/alm-integration';
+import { AlmKeys, AlmSettingsInstance } from '../../../../types/alm-settings';
+import { Dict } from '../../../../types/types';
+import { ALM_INTEGRATION_CATEGORY } from '../../../settings/constants';
+import AlmSettingsInstanceDropdown from '../components/AlmSettingsInstanceDropdown';
+import CreateProjectPageHeader from '../components/CreateProjectPageHeader';
+import InstanceNewCodeDefinitionComplianceWarning from '../components/InstanceNewCodeDefinitionComplianceWarning';
+import WrongBindingCountAlert from '../components/WrongBindingCountAlert';
+import AzurePersonalAccessTokenForm from './AzurePersonalAccessTokenForm';
+import AzureProjectsList from './AzureProjectsList';
+
+export interface AzureProjectCreateRendererProps {
+ canAdmin?: boolean;
+ importing: boolean;
+ loading: boolean;
+ loadingRepositories: Dict<boolean>;
+ onImportRepository: () => void;
+ onOpenProject: (key: string) => void;
+ onPersonalAccessTokenCreate: (token: string) => void;
+ onSearch: (query: string) => void;
+ onSelectRepository: (repository: AzureRepository) => void;
+ projects?: AzureProject[];
+ repositories: Dict<AzureRepository[]>;
+ searching?: boolean;
+ searchResults?: AzureRepository[];
+ searchQuery?: string;
+ selectedRepository?: AzureRepository;
+ almInstances?: AlmSettingsInstance[];
+ selectedAlmInstance?: AlmSettingsInstance;
+ showPersonalAccessTokenForm?: boolean;
+ submittingToken?: boolean;
+ tokenValidationFailed: boolean;
+ onSelectedAlmInstanceChange: (instance: AlmSettingsInstance) => void;
+ firstConnection?: boolean;
+}
+
+export default function AzureProjectCreateRenderer(props: AzureProjectCreateRendererProps) {
+ const {
+ canAdmin,
+ importing,
+ loading,
+ loadingRepositories,
+ projects,
+ repositories,
+ searching,
+ searchResults,
+ searchQuery,
+ selectedRepository,
+ almInstances,
+ showPersonalAccessTokenForm,
+ submittingToken,
+ tokenValidationFailed,
+ selectedAlmInstance,
+ firstConnection,
+ } = props;
+
+ const showCountError = !loading && (!almInstances || almInstances?.length === 0);
+ const settingIsValid = selectedAlmInstance && selectedAlmInstance.url;
+ const showUrlError = !loading && selectedAlmInstance && !selectedAlmInstance.url;
+
+ return (
+ <>
+ <CreateProjectPageHeader
+ additionalActions={
+ !showPersonalAccessTokenForm &&
+ settingIsValid && (
+ <div className="display-flex-center pull-right">
+ <DeferredSpinner className="spacer-right" loading={importing} />
+ <Button
+ className="button-large button-primary"
+ disabled={!selectedRepository || importing}
+ onClick={props.onImportRepository}
+ >
+ {translate('onboarding.create_project.import_selected_repo')}
+ </Button>
+ </div>
+ )
+ }
+ 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>
+ }
+ />
+
+ <AlmSettingsInstanceDropdown
+ almKey={AlmKeys.Azure}
+ almInstances={almInstances}
+ selectedAlmInstance={selectedAlmInstance}
+ onChangeConfig={props.onSelectedAlmInstanceChange}
+ />
+
+ {loading && <i className="spinner" />}
+
+ {showUrlError && (
+ <Alert variant="error">
+ {canAdmin ? (
+ <FormattedMessage
+ defaultMessage={translate('onboarding.create_project.azure.no_url.admin')}
+ id="onboarding.create_project.azure.no_url.admin"
+ values={{
+ alm: translate('onboarding.alm', AlmKeys.Azure),
+ url: (
+ <Link to={getGlobalSettingsUrl(ALM_INTEGRATION_CATEGORY)}>
+ {translate('settings.page')}
+ </Link>
+ ),
+ }}
+ />
+ ) : (
+ translate('onboarding.create_project.azure.no_url')
+ )}
+ </Alert>
+ )}
+
+ {showCountError && <WrongBindingCountAlert alm={AlmKeys.Azure} canAdmin={!!canAdmin} />}
+
+ {!loading &&
+ selectedAlmInstance &&
+ selectedAlmInstance.url &&
+ (showPersonalAccessTokenForm ? (
+ <div>
+ <AzurePersonalAccessTokenForm
+ almSetting={selectedAlmInstance}
+ onPersonalAccessTokenCreate={props.onPersonalAccessTokenCreate}
+ submitting={submittingToken}
+ validationFailed={tokenValidationFailed}
+ firstConnection={firstConnection}
+ />
+ </div>
+ ) : (
+ <>
+ <InstanceNewCodeDefinitionComplianceWarning />
+
+ <div className="huge-spacer-bottom">
+ <SearchBox
+ onChange={props.onSearch}
+ placeholder={translate('onboarding.create_project.search_projects_repositories')}
+ />
+ </div>
+ <DeferredSpinner loading={Boolean(searching)}>
+ <AzureProjectsList
+ importing={importing}
+ loadingRepositories={loadingRepositories}
+ onOpenProject={props.onOpenProject}
+ onSelectRepository={props.onSelectRepository}
+ projects={projects}
+ repositories={repositories}
+ searchResults={searchResults}
+ searchQuery={searchQuery}
+ selectedRepository={selectedRepository}
+ />
+ </DeferredSpinner>
+ </>
+ ))}
+ </>
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { uniqBy } from 'lodash';
+import * as React from 'react';
+import { FormattedMessage } from 'react-intl';
+import Link from '../../../../components/common/Link';
+import ListFooter from '../../../../components/controls/ListFooter';
+import { Alert } from '../../../../components/ui/Alert';
+import { translate, translateWithParameters } from '../../../../helpers/l10n';
+import { queryToSearch } from '../../../../helpers/urls';
+import { AzureProject, AzureRepository } from '../../../../types/alm-integration';
+import { Dict } from '../../../../types/types';
+import { CreateProjectModes } from '../types';
+import AzureProjectAccordion from './AzureProjectAccordion';
+
+export interface AzureProjectsListProps {
+ importing: boolean;
+ loadingRepositories: Dict<boolean>;
+ onOpenProject: (key: string) => void;
+ onSelectRepository: (repository: AzureRepository) => void;
+ projects?: AzureProject[];
+ repositories: Dict<AzureRepository[]>;
+ searchResults?: AzureRepository[];
+ searchQuery?: string;
+ selectedRepository?: AzureRepository;
+}
+
+const PAGE_SIZE = 10;
+
+export default function AzureProjectsList(props: AzureProjectsListProps) {
+ const {
+ importing,
+ loadingRepositories,
+ projects = [],
+ repositories,
+ searchResults,
+ searchQuery,
+ selectedRepository,
+ } = props;
+
+ const [page, setPage] = React.useState(1);
+
+ if (searchResults && searchResults.length === 0) {
+ return (
+ <Alert className="spacer-top" variant="warning">
+ {translate('onboarding.create_project.azure.no_results')}
+ </Alert>
+ );
+ }
+
+ if (projects.length === 0) {
+ return (
+ <Alert className="spacer-top" variant="warning">
+ <FormattedMessage
+ defaultMessage={translate('onboarding.create_project.azure.no_projects')}
+ id="onboarding.create_project.azure.no_projects"
+ values={{
+ link: (
+ <Link
+ to={{
+ pathname: '/projects/create',
+ search: queryToSearch({ mode: CreateProjectModes.AzureDevOps, resetPat: 1 }),
+ }}
+ >
+ {translate('onboarding.create_project.update_your_token')}
+ </Link>
+ ),
+ }}
+ />
+ </Alert>
+ );
+ }
+
+ let filteredProjects: AzureProject[];
+ if (searchResults !== undefined) {
+ filteredProjects = uniqBy(
+ searchResults.map((r) => {
+ return (
+ projects.find((p) => p.name === r.projectName) || {
+ name: r.projectName,
+ description: translateWithParameters(
+ 'onboarding.create_project.azure.search_results_for_project_X',
+ r.projectName
+ ),
+ }
+ );
+ }),
+ 'name'
+ );
+ } else {
+ filteredProjects = projects;
+ }
+
+ const displayedProjects = filteredProjects.slice(0, page * PAGE_SIZE);
+
+ // Add a suffix to the key to force react to not reuse AzureProjectAccordions between
+ // search results and project exploration
+ const keySuffix = searchResults ? ' - result' : '';
+
+ return (
+ <div>
+ {displayedProjects.map((p, i) => (
+ <AzureProjectAccordion
+ key={`${p.name}${keySuffix}`}
+ importing={importing}
+ loading={Boolean(loadingRepositories[p.name])}
+ onOpen={props.onOpenProject}
+ onSelectRepository={props.onSelectRepository}
+ project={p}
+ repositories={
+ searchResults
+ ? searchResults.filter((s) => s.projectName === p.name)
+ : repositories[p.name]
+ }
+ selectedRepository={selectedRepository}
+ searchQuery={searchQuery}
+ startsOpen={searchResults !== undefined || i === 0}
+ />
+ ))}
+
+ <ListFooter
+ count={displayedProjects.length}
+ loadMore={() => setPage((p) => p + 1)}
+ total={filteredProjects.length}
+ />
+ </div>
+ );
+}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 classNames from 'classnames';
-import * as React from 'react';
-import { FormattedMessage } from 'react-intl';
-import Link from '../../../components/common/Link';
-import { SubmitButton } from '../../../components/controls/buttons';
-import ValidationInput from '../../../components/controls/ValidationInput';
-import { Alert } from '../../../components/ui/Alert';
-import DeferredSpinner from '../../../components/ui/DeferredSpinner';
-import { translate } from '../../../helpers/l10n';
-import { AlmSettingsInstance } from '../../../types/alm-settings';
-
-export interface AzurePersonalAccessTokenFormProps {
- almSetting: AlmSettingsInstance;
- onPersonalAccessTokenCreate: (token: string) => void;
- submitting?: boolean;
- validationFailed: boolean;
- firstConnection?: boolean;
-}
-
-function getAzurePatUrl(url: string) {
- return `${url.replace(/\/$/, '')}/_usersSettings/tokens`;
-}
-
-export default function AzurePersonalAccessTokenForm(props: AzurePersonalAccessTokenFormProps) {
- const {
- almSetting: { alm, url },
- submitting = false,
- validationFailed,
- firstConnection,
- } = 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 ? (
- <Link className="link-no-underline" to={getAzurePatUrl(url)} target="_blank">
- {translate('onboarding.create_project.pat_help.instructions.link', alm)}
- </Link>
- ) : (
- translate('onboarding.create_project.pat_help.instructions.link', alm)
- ),
- scope: (
- <strong>
- <em>Code (Read & Write)</em>
- </strong>
- ),
- }}
- />
- </div>
-
- {!firstConnection && (
- <Alert className="big-spacer-right" variant="warning">
- <p>{translate('onboarding.create_project.pat.expired.info_message')}</p>
- <p>{translate('onboarding.create_project.pat.expired.info_message_contact')}</p>
- </Alert>
- )}
-
- <form
- onSubmit={(e: React.SyntheticEvent<HTMLFormElement>) => {
- e.preventDefault();
- props.onPersonalAccessTokenCreate(token);
- }}
- >
- <ValidationInput
- error={errorMessage}
- labelHtmlFor="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>
- );
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 classNames from 'classnames';
-import * as React from 'react';
-import { FormattedMessage } from 'react-intl';
-import { colors } from '../../../app/theme';
-import Link from '../../../components/common/Link';
-import BoxedGroupAccordion from '../../../components/controls/BoxedGroupAccordion';
-import ListFooter from '../../../components/controls/ListFooter';
-import Radio from '../../../components/controls/Radio';
-import CheckIcon from '../../../components/icons/CheckIcon';
-import { Alert } from '../../../components/ui/Alert';
-import DeferredSpinner from '../../../components/ui/DeferredSpinner';
-import { translate } from '../../../helpers/l10n';
-import { getProjectUrl, queryToSearch } from '../../../helpers/urls';
-import { AzureProject, AzureRepository } from '../../../types/alm-integration';
-import { CreateProjectModes } from './types';
-
-export interface AzureProjectAccordionProps {
- importing: boolean;
- loading: boolean;
- onOpen: (key: string) => void;
- onSelectRepository: (repository: AzureRepository) => void;
- project: AzureProject;
- repositories?: AzureRepository[];
- searchQuery?: string;
- selectedRepository?: AzureRepository;
- startsOpen: boolean;
-}
-
-const PAGE_SIZE = 30;
-
-function highlight(text: string, term?: string, underline = false) {
- if (!term || !text.toLowerCase().includes(term.toLowerCase())) {
- return text;
- }
-
- // Capture only the first occurence by using a capturing group to get
- // everything after the first occurence
- const [pre, found, post] = text.split(new RegExp(`(${term})(.*)`, 'i'));
- return (
- <>
- {pre}
- <strong className={classNames({ underline })}>{found}</strong>
- {post}
- </>
- );
-}
-
-export default function AzureProjectAccordion(props: AzureProjectAccordionProps) {
- const {
- importing,
- loading,
- startsOpen,
- project,
- repositories = [],
- searchQuery,
- selectedRepository,
- } = props;
-
- const [open, setOpen] = React.useState(startsOpen);
- const handleClick = () => {
- if (!open) {
- props.onOpen(project.name);
- }
- setOpen(!open);
- };
-
- const [page, setPage] = React.useState(1);
- const limitedRepositories = repositories.slice(0, page * PAGE_SIZE);
-
- const isSelected = (repo: AzureRepository) =>
- selectedRepository?.projectName === project.name && selectedRepository.name === repo.name;
-
- return (
- <BoxedGroupAccordion
- className={classNames('big-spacer-bottom', {
- open,
- })}
- onClick={handleClick}
- open={open}
- title={<h3 title={project.description}>{highlight(project.name, searchQuery, true)}</h3>}
- >
- {open && (
- <DeferredSpinner loading={loading}>
- {/* The extra loading guard is to prevent the flash of the Alert */}
- {!loading && repositories.length === 0 ? (
- <Alert variant="warning">
- <FormattedMessage
- defaultMessage={translate('onboarding.create_project.azure.no_repositories')}
- id="onboarding.create_project.azure.no_repositories"
- values={{
- link: (
- <Link
- to={{
- pathname: '/projects/create',
- search: queryToSearch({
- mode: CreateProjectModes.AzureDevOps,
- resetPat: 1,
- }),
- }}
- >
- {translate('onboarding.create_project.update_your_token')}
- </Link>
- ),
- }}
- />
- </Alert>
- ) : (
- <>
- <div className="display-flex-wrap">
- {limitedRepositories.map((repo) => (
- <div
- className="create-project-azdo-repo display-flex-start spacer-bottom padded-right"
- key={repo.name}
- >
- {repo.sqProjectKey ? (
- <>
- <CheckIcon className="spacer-right" fill={colors.green} size={14} />
- <div className="overflow-hidden">
- <div className="little-spacer-bottom text-ellipsis">
- <Link to={getProjectUrl(repo.sqProjectKey)} title={repo.sqProjectName}>
- {highlight(repo.sqProjectName || repo.name, searchQuery)}
- </Link>
- </div>
- <em>{translate('onboarding.create_project.repository_imported')}</em>
- </div>
- </>
- ) : (
- <Radio
- checked={isSelected(repo)}
- className="overflow-hidden"
- alignLabel={true}
- disabled={importing}
- onCheck={() => props.onSelectRepository(repo)}
- value={repo.name}
- >
- <span title={repo.name}>{highlight(repo.name, searchQuery)}</span>
- </Radio>
- )}
- </div>
- ))}
- </div>
- <ListFooter
- count={limitedRepositories.length}
- total={repositories.length}
- loadMore={() => setPage((p) => p + 1)}
- />
- </>
- )}
- </DeferredSpinner>
- )}
- </BoxedGroupAccordion>
- );
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 {
- checkPersonalAccessTokenIsValid,
- getAzureProjects,
- getAzureRepositories,
- importAzureRepository,
- searchAzureRepositories,
- setAlmPersonalAccessToken,
-} from '../../../api/alm-integrations';
-import { Location, Router } from '../../../components/hoc/withRouter';
-import { AzureProject, AzureRepository } from '../../../types/alm-integration';
-import { AlmSettingsInstance } from '../../../types/alm-settings';
-import { Dict } from '../../../types/types';
-import AzureCreateProjectRenderer from './AzureProjectCreateRenderer';
-import { tokenExistedBefore } from './utils';
-
-interface Props {
- canAdmin: boolean;
- loadingBindings: boolean;
- onProjectCreate: (projectKey: string) => void;
- almInstances: AlmSettingsInstance[];
- location: Location;
- router: Router;
-}
-
-interface State {
- importing: boolean;
- loading: boolean;
- loadingRepositories: Dict<boolean>;
- patIsValid?: boolean;
- projects?: AzureProject[];
- repositories: Dict<AzureRepository[]>;
- searching?: boolean;
- searchResults?: AzureRepository[];
- searchQuery?: string;
- selectedRepository?: AzureRepository;
- selectedAlmInstance?: AlmSettingsInstance;
- submittingToken?: boolean;
- tokenValidationFailed: boolean;
- firstConnection?: 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.
- selectedAlmInstance: props.almInstances[0],
- importing: false,
- loading: false,
- loadingRepositories: {},
- repositories: {},
- tokenValidationFailed: false,
- firstConnection: false,
- };
- }
-
- componentDidMount() {
- this.mounted = true;
- this.fetchData();
- }
-
- componentDidUpdate(prevProps: Props) {
- if (prevProps.almInstances.length === 0 && this.props.almInstances.length > 0) {
- this.setState({ selectedAlmInstance: this.props.almInstances[0] }, () => this.fetchData());
- }
- }
-
- componentWillUnmount() {
- this.mounted = false;
- }
-
- fetchData = async () => {
- this.setState({ loading: true });
-
- const { patIsValid, error } = await this.checkPersonalAccessToken();
-
- let projects: AzureProject[] | undefined;
- if (patIsValid) {
- projects = await this.fetchAzureProjects();
- }
-
- const { repositories } = this.state;
-
- let firstProjectName: string;
-
- if (projects && projects.length > 0) {
- firstProjectName = projects[0].name;
-
- this.setState(({ loadingRepositories }) => ({
- loadingRepositories: { ...loadingRepositories, [firstProjectName]: true },
- }));
-
- const repos = await this.fetchAzureRepositories(firstProjectName);
- repositories[firstProjectName] = repos;
- }
-
- if (this.mounted) {
- this.setState(({ loadingRepositories }) => {
- if (firstProjectName) {
- loadingRepositories[firstProjectName] = false;
- }
-
- return {
- patIsValid,
- loading: false,
- loadingRepositories: { ...loadingRepositories },
- projects,
- repositories,
- firstConnection: tokenExistedBefore(error),
- };
- });
- }
- };
-
- fetchAzureProjects = (): Promise<AzureProject[] | undefined> => {
- const { selectedAlmInstance } = this.state;
-
- if (!selectedAlmInstance) {
- return Promise.resolve(undefined);
- }
-
- return getAzureProjects(selectedAlmInstance.key).then(({ projects }) => projects);
- };
-
- fetchAzureRepositories = (projectName: string): Promise<AzureRepository[]> => {
- const { selectedAlmInstance } = this.state;
-
- if (!selectedAlmInstance) {
- return Promise.resolve([]);
- }
-
- return getAzureRepositories(selectedAlmInstance.key, projectName)
- .then(({ repositories }) => repositories)
- .catch(() => []);
- };
-
- cleanUrl = () => {
- const { location, router } = this.props;
- delete location.query.resetPat;
- router.replace(location);
- };
-
- handleOpenProject = async (projectName: string) => {
- if (this.state.searchResults) {
- return;
- }
-
- this.setState(({ loadingRepositories }) => ({
- loadingRepositories: { ...loadingRepositories, [projectName]: true },
- }));
-
- const projectRepos = await this.fetchAzureRepositories(projectName);
-
- this.setState(({ loadingRepositories, repositories }) => ({
- loadingRepositories: { ...loadingRepositories, [projectName]: false },
- repositories: { ...repositories, [projectName]: projectRepos },
- }));
- };
-
- handleSearchRepositories = async (searchQuery: string) => {
- const { selectedAlmInstance } = this.state;
-
- if (!selectedAlmInstance) {
- return;
- }
-
- if (searchQuery.length === 0) {
- this.setState({ searchResults: undefined, searchQuery: undefined });
- return;
- }
-
- this.setState({ searching: true });
-
- const searchResults: AzureRepository[] = await searchAzureRepositories(
- selectedAlmInstance.key,
- searchQuery
- )
- .then(({ repositories }) => repositories)
- .catch(() => []);
-
- if (this.mounted) {
- this.setState({
- searching: false,
- searchResults,
- searchQuery,
- });
- }
- };
-
- handleImportRepository = async () => {
- const { selectedRepository, selectedAlmInstance } = this.state;
-
- if (!selectedAlmInstance || !selectedRepository) {
- return;
- }
-
- this.setState({ importing: true });
-
- const createdProject = await importAzureRepository(
- selectedAlmInstance.key,
- selectedRepository.projectName,
- selectedRepository.name
- )
- .then(({ project }) => project)
- .catch(() => undefined);
-
- if (this.mounted) {
- this.setState({ importing: false });
- if (createdProject) {
- this.props.onProjectCreate(createdProject.key);
- }
- }
- };
-
- handleSelectRepository = (selectedRepository: AzureRepository) => {
- this.setState({ selectedRepository });
- };
-
- checkPersonalAccessToken = () => {
- const { selectedAlmInstance } = this.state;
-
- if (!selectedAlmInstance) {
- return Promise.resolve({ patIsValid: false, error: '' });
- }
-
- return checkPersonalAccessTokenIsValid(selectedAlmInstance.key).then(({ status, error }) => {
- return { patIsValid: status, error };
- });
- };
-
- handlePersonalAccessTokenCreate = async (token: string) => {
- const { selectedAlmInstance } = this.state;
-
- if (!selectedAlmInstance || token.length < 1) {
- return;
- }
-
- this.setState({ submittingToken: true, tokenValidationFailed: false });
-
- try {
- await setAlmPersonalAccessToken(selectedAlmInstance.key, token);
- const { patIsValid } = await this.checkPersonalAccessToken();
-
- if (this.mounted) {
- this.setState({
- submittingToken: false,
- patIsValid,
- tokenValidationFailed: !patIsValid,
- });
-
- if (patIsValid) {
- this.cleanUrl();
- this.fetchData();
- }
- }
- } catch (e) {
- if (this.mounted) {
- this.setState({ submittingToken: false });
- }
- }
- };
-
- onSelectedAlmInstanceChange = (instance: AlmSettingsInstance) => {
- this.setState(
- { selectedAlmInstance: instance, searchResults: undefined, searchQuery: '' },
- () => this.fetchData()
- );
- };
-
- render() {
- const { canAdmin, loadingBindings, location, almInstances } = this.props;
- const {
- importing,
- loading,
- loadingRepositories,
- patIsValid,
- projects,
- repositories,
- searching,
- searchResults,
- searchQuery,
- selectedRepository,
- selectedAlmInstance,
- submittingToken,
- tokenValidationFailed,
- firstConnection,
- } = this.state;
-
- return (
- <AzureCreateProjectRenderer
- canAdmin={canAdmin}
- importing={importing}
- loading={loading || loadingBindings}
- loadingRepositories={loadingRepositories}
- onImportRepository={this.handleImportRepository}
- onOpenProject={this.handleOpenProject}
- onPersonalAccessTokenCreate={this.handlePersonalAccessTokenCreate}
- onSearch={this.handleSearchRepositories}
- onSelectRepository={this.handleSelectRepository}
- projects={projects}
- repositories={repositories}
- searching={searching}
- searchResults={searchResults}
- searchQuery={searchQuery}
- selectedRepository={selectedRepository}
- almInstances={almInstances}
- selectedAlmInstance={selectedAlmInstance}
- showPersonalAccessTokenForm={!patIsValid || Boolean(location.query.resetPat)}
- submittingToken={submittingToken}
- tokenValidationFailed={tokenValidationFailed}
- onSelectedAlmInstanceChange={this.onSelectedAlmInstanceChange}
- firstConnection={firstConnection}
- />
- );
- }
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 { FormattedMessage } from 'react-intl';
-import Link from '../../../components/common/Link';
-import { Button } from '../../../components/controls/buttons';
-import SearchBox from '../../../components/controls/SearchBox';
-import { Alert } from '../../../components/ui/Alert';
-import DeferredSpinner from '../../../components/ui/DeferredSpinner';
-import { translate } from '../../../helpers/l10n';
-import { getBaseUrl } from '../../../helpers/system';
-import { getGlobalSettingsUrl } from '../../../helpers/urls';
-import { AzureProject, AzureRepository } from '../../../types/alm-integration';
-import { AlmKeys, AlmSettingsInstance } from '../../../types/alm-settings';
-import { Dict } from '../../../types/types';
-import { ALM_INTEGRATION_CATEGORY } from '../../settings/constants';
-import AlmSettingsInstanceDropdown from './AlmSettingsInstanceDropdown';
-import AzurePersonalAccessTokenForm from './AzurePersonalAccessTokenForm';
-import AzureProjectsList from './AzureProjectsList';
-import CreateProjectPageHeader from './CreateProjectPageHeader';
-import WrongBindingCountAlert from './WrongBindingCountAlert';
-
-export interface AzureProjectCreateRendererProps {
- canAdmin?: boolean;
- importing: boolean;
- loading: boolean;
- loadingRepositories: Dict<boolean>;
- onImportRepository: () => void;
- onOpenProject: (key: string) => void;
- onPersonalAccessTokenCreate: (token: string) => void;
- onSearch: (query: string) => void;
- onSelectRepository: (repository: AzureRepository) => void;
- projects?: AzureProject[];
- repositories: Dict<AzureRepository[]>;
- searching?: boolean;
- searchResults?: AzureRepository[];
- searchQuery?: string;
- selectedRepository?: AzureRepository;
- almInstances?: AlmSettingsInstance[];
- selectedAlmInstance?: AlmSettingsInstance;
- showPersonalAccessTokenForm?: boolean;
- submittingToken?: boolean;
- tokenValidationFailed: boolean;
- onSelectedAlmInstanceChange: (instance: AlmSettingsInstance) => void;
- firstConnection?: boolean;
-}
-
-export default function AzureProjectCreateRenderer(props: AzureProjectCreateRendererProps) {
- const {
- canAdmin,
- importing,
- loading,
- loadingRepositories,
- projects,
- repositories,
- searching,
- searchResults,
- searchQuery,
- selectedRepository,
- almInstances,
- showPersonalAccessTokenForm,
- submittingToken,
- tokenValidationFailed,
- selectedAlmInstance,
- firstConnection,
- } = props;
-
- const showCountError = !loading && (!almInstances || almInstances?.length === 0);
- const settingIsValid = selectedAlmInstance && selectedAlmInstance.url;
- const showUrlError = !loading && selectedAlmInstance && !selectedAlmInstance.url;
-
- return (
- <>
- <CreateProjectPageHeader
- additionalActions={
- !showPersonalAccessTokenForm &&
- settingIsValid && (
- <div className="display-flex-center pull-right">
- <DeferredSpinner className="spacer-right" loading={importing} />
- <Button
- className="button-large button-primary"
- disabled={!selectedRepository || importing}
- onClick={props.onImportRepository}
- >
- {translate('onboarding.create_project.import_selected_repo')}
- </Button>
- </div>
- )
- }
- 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>
- }
- />
-
- <AlmSettingsInstanceDropdown
- almKey={AlmKeys.Azure}
- almInstances={almInstances}
- selectedAlmInstance={selectedAlmInstance}
- onChangeConfig={props.onSelectedAlmInstanceChange}
- />
-
- {loading && <i className="spinner" />}
-
- {showUrlError && (
- <Alert variant="error">
- {canAdmin ? (
- <FormattedMessage
- defaultMessage={translate('onboarding.create_project.azure.no_url.admin')}
- id="onboarding.create_project.azure.no_url.admin"
- values={{
- alm: translate('onboarding.alm', AlmKeys.Azure),
- url: (
- <Link to={getGlobalSettingsUrl(ALM_INTEGRATION_CATEGORY)}>
- {translate('settings.page')}
- </Link>
- ),
- }}
- />
- ) : (
- translate('onboarding.create_project.azure.no_url')
- )}
- </Alert>
- )}
-
- {showCountError && <WrongBindingCountAlert alm={AlmKeys.Azure} canAdmin={!!canAdmin} />}
-
- {!loading &&
- selectedAlmInstance &&
- selectedAlmInstance.url &&
- (showPersonalAccessTokenForm ? (
- <div>
- <AzurePersonalAccessTokenForm
- almSetting={selectedAlmInstance}
- onPersonalAccessTokenCreate={props.onPersonalAccessTokenCreate}
- submitting={submittingToken}
- validationFailed={tokenValidationFailed}
- firstConnection={firstConnection}
- />
- </div>
- ) : (
- <>
- <div className="huge-spacer-bottom">
- <SearchBox
- onChange={props.onSearch}
- placeholder={translate('onboarding.create_project.search_projects_repositories')}
- />
- </div>
- <DeferredSpinner loading={Boolean(searching)}>
- <AzureProjectsList
- importing={importing}
- loadingRepositories={loadingRepositories}
- onOpenProject={props.onOpenProject}
- onSelectRepository={props.onSelectRepository}
- projects={projects}
- repositories={repositories}
- searchResults={searchResults}
- searchQuery={searchQuery}
- selectedRepository={selectedRepository}
- />
- </DeferredSpinner>
- </>
- ))}
- </>
- );
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 { uniqBy } from 'lodash';
-import * as React from 'react';
-import { FormattedMessage } from 'react-intl';
-import Link from '../../../components/common/Link';
-import ListFooter from '../../../components/controls/ListFooter';
-import { Alert } from '../../../components/ui/Alert';
-import { translate, translateWithParameters } from '../../../helpers/l10n';
-import { queryToSearch } from '../../../helpers/urls';
-import { AzureProject, AzureRepository } from '../../../types/alm-integration';
-import { Dict } from '../../../types/types';
-import AzureProjectAccordion from './AzureProjectAccordion';
-import { CreateProjectModes } from './types';
-
-export interface AzureProjectsListProps {
- importing: boolean;
- loadingRepositories: Dict<boolean>;
- onOpenProject: (key: string) => void;
- onSelectRepository: (repository: AzureRepository) => void;
- projects?: AzureProject[];
- repositories: Dict<AzureRepository[]>;
- searchResults?: AzureRepository[];
- searchQuery?: string;
- selectedRepository?: AzureRepository;
-}
-
-const PAGE_SIZE = 10;
-
-export default function AzureProjectsList(props: AzureProjectsListProps) {
- const {
- importing,
- loadingRepositories,
- projects = [],
- repositories,
- searchResults,
- searchQuery,
- selectedRepository,
- } = props;
-
- const [page, setPage] = React.useState(1);
-
- if (searchResults && searchResults.length === 0) {
- return (
- <Alert className="spacer-top" variant="warning">
- {translate('onboarding.create_project.azure.no_results')}
- </Alert>
- );
- }
-
- if (projects.length === 0) {
- return (
- <Alert className="spacer-top" variant="warning">
- <FormattedMessage
- defaultMessage={translate('onboarding.create_project.azure.no_projects')}
- id="onboarding.create_project.azure.no_projects"
- values={{
- link: (
- <Link
- to={{
- pathname: '/projects/create',
- search: queryToSearch({ mode: CreateProjectModes.AzureDevOps, resetPat: 1 }),
- }}
- >
- {translate('onboarding.create_project.update_your_token')}
- </Link>
- ),
- }}
- />
- </Alert>
- );
- }
-
- let filteredProjects: AzureProject[];
- if (searchResults !== undefined) {
- filteredProjects = uniqBy(
- searchResults.map((r) => {
- return (
- projects.find((p) => p.name === r.projectName) || {
- name: r.projectName,
- description: translateWithParameters(
- 'onboarding.create_project.azure.search_results_for_project_X',
- r.projectName
- ),
- }
- );
- }),
- 'name'
- );
- } else {
- filteredProjects = projects;
- }
-
- const displayedProjects = filteredProjects.slice(0, page * PAGE_SIZE);
-
- // Add a suffix to the key to force react to not reuse AzureProjectAccordions between
- // search results and project exploration
- const keySuffix = searchResults ? ' - result' : '';
-
- return (
- <div>
- {displayedProjects.map((p, i) => (
- <AzureProjectAccordion
- key={`${p.name}${keySuffix}`}
- importing={importing}
- loading={Boolean(loadingRepositories[p.name])}
- onOpen={props.onOpenProject}
- onSelectRepository={props.onSelectRepository}
- project={p}
- repositories={
- searchResults
- ? searchResults.filter((s) => s.projectName === p.name)
- : repositories[p.name]
- }
- selectedRepository={selectedRepository}
- searchQuery={searchQuery}
- startsOpen={searchResults !== undefined || i === 0}
- />
- ))}
-
- <ListFooter
- count={displayedProjects.length}
- loadMore={() => setPage((p) => p + 1)}
- total={filteredProjects.length}
- />
- </div>
- );
-}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 {
+ importBitbucketCloudRepository,
+ searchForBitbucketCloudRepositories,
+} from '../../../../api/alm-integrations';
+import { Location, Router } from '../../../../components/hoc/withRouter';
+import { BitbucketCloudRepository } from '../../../../types/alm-integration';
+import { AlmSettingsInstance } from '../../../../types/alm-settings';
+import { Paging } from '../../../../types/types';
+import BitbucketCloudProjectCreateRenderer from './BitbucketCloudProjectCreateRender';
+
+interface Props {
+ canAdmin: boolean;
+ almInstances: AlmSettingsInstance[];
+ loadingBindings: boolean;
+ onProjectCreate: (projectKey: string) => void;
+ location: Location;
+ router: Router;
+}
+
+interface State {
+ importingSlug?: string;
+ isLastPage?: boolean;
+ loading: boolean;
+ loadingMore: boolean;
+ projectsPaging: Omit<Paging, 'total'>;
+ resetPat: boolean;
+ repositories: BitbucketCloudRepository[];
+ searching: boolean;
+ searchQuery: string;
+ selectedAlmInstance: AlmSettingsInstance;
+ showPersonalAccessTokenForm: boolean;
+}
+
+export const BITBUCKET_CLOUD_PROJECTS_PAGESIZE = 30;
+export default class BitbucketCloudProjectCreate extends React.PureComponent<Props, State> {
+ mounted = false;
+
+ constructor(props: Props) {
+ super(props);
+ this.state = {
+ // For now, we only handle a single instance. So we always use the first
+ // one from the list.
+ loading: false,
+ loadingMore: false,
+ resetPat: false,
+ projectsPaging: { pageIndex: 1, pageSize: BITBUCKET_CLOUD_PROJECTS_PAGESIZE },
+ repositories: [],
+ searching: false,
+ searchQuery: '',
+ selectedAlmInstance: props.almInstances[0],
+ showPersonalAccessTokenForm: true,
+ };
+ }
+
+ componentDidMount() {
+ this.mounted = true;
+ }
+
+ componentDidUpdate(prevProps: Props) {
+ if (prevProps.almInstances.length === 0 && this.props.almInstances.length > 0) {
+ this.setState({ selectedAlmInstance: this.props.almInstances[0] }, () => this.fetchData());
+ }
+ }
+
+ handlePersonalAccessTokenCreated = async () => {
+ this.setState({ showPersonalAccessTokenForm: false });
+ this.cleanUrl();
+ this.setState({ loading: true });
+ await this.fetchData();
+ this.setState({ loading: false });
+ };
+
+ cleanUrl = () => {
+ const { location, router } = this.props;
+ delete location.query.resetPat;
+ router.replace(location);
+ };
+
+ async fetchData(more = false) {
+ const {
+ selectedAlmInstance,
+ searchQuery,
+ projectsPaging: { pageIndex, pageSize },
+ showPersonalAccessTokenForm,
+ } = this.state;
+ if (selectedAlmInstance && !showPersonalAccessTokenForm) {
+ const { isLastPage, repositories } = await searchForBitbucketCloudRepositories(
+ selectedAlmInstance.key,
+ searchQuery,
+ pageSize,
+ pageIndex
+ ).catch(() => {
+ this.handleError();
+ return { isLastPage: undefined, repositories: undefined };
+ });
+ if (this.mounted && isLastPage !== undefined && repositories !== undefined) {
+ if (more) {
+ this.setState((state) => ({
+ isLastPage,
+ repositories: [...state.repositories, ...repositories],
+ }));
+ } else {
+ this.setState({ isLastPage, repositories });
+ }
+ }
+ }
+ }
+
+ handleError = () => {
+ if (this.mounted) {
+ this.setState({
+ projectsPaging: { pageIndex: 1, pageSize: BITBUCKET_CLOUD_PROJECTS_PAGESIZE },
+ repositories: [],
+ resetPat: true,
+ showPersonalAccessTokenForm: true,
+ });
+ }
+
+ return undefined;
+ };
+
+ handleSearch = (searchQuery: string) => {
+ this.setState(
+ {
+ searching: true,
+ projectsPaging: { pageIndex: 1, pageSize: BITBUCKET_CLOUD_PROJECTS_PAGESIZE },
+ searchQuery,
+ },
+ async () => {
+ await this.fetchData();
+ if (this.mounted) {
+ this.setState({ searching: false });
+ }
+ }
+ );
+ };
+
+ handleLoadMore = () => {
+ this.setState(
+ (state) => ({
+ loadingMore: true,
+ projectsPaging: {
+ pageIndex: state.projectsPaging.pageIndex + 1,
+ pageSize: state.projectsPaging.pageSize,
+ },
+ }),
+ async () => {
+ await this.fetchData(true);
+ if (this.mounted) {
+ this.setState({ loadingMore: false });
+ }
+ }
+ );
+ };
+
+ handleImport = async (repositorySlug: string) => {
+ const { selectedAlmInstance } = this.state;
+
+ if (!selectedAlmInstance) {
+ return;
+ }
+
+ this.setState({ importingSlug: repositorySlug });
+
+ const result = await importBitbucketCloudRepository(
+ selectedAlmInstance.key,
+ repositorySlug
+ ).catch(() => undefined);
+
+ if (this.mounted) {
+ this.setState({ importingSlug: undefined });
+
+ if (result) {
+ this.props.onProjectCreate(result.project.key);
+ }
+ }
+ };
+
+ onSelectedAlmInstanceChange = (instance: AlmSettingsInstance) => {
+ this.setState({
+ selectedAlmInstance: instance,
+ showPersonalAccessTokenForm: true,
+ resetPat: false,
+ searching: false,
+ searchQuery: '',
+ projectsPaging: { pageIndex: 1, pageSize: BITBUCKET_CLOUD_PROJECTS_PAGESIZE },
+ });
+ };
+
+ render() {
+ const { canAdmin, loadingBindings, location, almInstances } = this.props;
+ const {
+ importingSlug,
+ isLastPage = true,
+ selectedAlmInstance,
+ loading,
+ loadingMore,
+ repositories,
+ showPersonalAccessTokenForm,
+ resetPat,
+ searching,
+ searchQuery,
+ } = this.state;
+ return (
+ <BitbucketCloudProjectCreateRenderer
+ importingSlug={importingSlug}
+ isLastPage={isLastPage}
+ selectedAlmInstance={selectedAlmInstance}
+ almInstances={almInstances}
+ canAdmin={canAdmin}
+ loadingMore={loadingMore}
+ loading={loading || loadingBindings}
+ onImport={this.handleImport}
+ onLoadMore={this.handleLoadMore}
+ onPersonalAccessTokenCreated={this.handlePersonalAccessTokenCreated}
+ onSearch={this.handleSearch}
+ onSelectedAlmInstanceChange={this.onSelectedAlmInstanceChange}
+ repositories={repositories}
+ searching={searching}
+ searchQuery={searchQuery}
+ resetPat={resetPat || Boolean(location.query.resetPat)}
+ showPersonalAccessTokenForm={
+ showPersonalAccessTokenForm || Boolean(location.query.resetPat)
+ }
+ />
+ );
+ }
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 '../../../../helpers/l10n';
+import { getBaseUrl } from '../../../../helpers/system';
+import { BitbucketCloudRepository } from '../../../../types/alm-integration';
+import { AlmKeys, AlmSettingsInstance } from '../../../../types/alm-settings';
+import AlmSettingsInstanceDropdown from '../components/AlmSettingsInstanceDropdown';
+import CreateProjectPageHeader from '../components/CreateProjectPageHeader';
+import PersonalAccessTokenForm from '../components/PersonalAccessTokenForm';
+import WrongBindingCountAlert from '../components/WrongBindingCountAlert';
+import BitbucketCloudSearchForm from './BitbucketCloudSearchForm';
+
+export interface BitbucketCloudProjectCreateRendererProps {
+ importingSlug?: string;
+ isLastPage: boolean;
+ canAdmin?: boolean;
+ loading: boolean;
+ loadingMore: boolean;
+ onImport: (repositorySlug: string) => void;
+ onLoadMore: () => void;
+ onPersonalAccessTokenCreated: () => void;
+ onSearch: (searchQuery: string) => void;
+ onSelectedAlmInstanceChange: (instance: AlmSettingsInstance) => void;
+ repositories?: BitbucketCloudRepository[];
+ resetPat: boolean;
+ searching: boolean;
+ searchQuery: string;
+ showPersonalAccessTokenForm: boolean;
+ almInstances: AlmSettingsInstance[];
+ selectedAlmInstance?: AlmSettingsInstance;
+}
+
+export default function BitbucketCloudProjectCreateRenderer(
+ props: BitbucketCloudProjectCreateRendererProps
+) {
+ const {
+ almInstances,
+ importingSlug,
+ isLastPage,
+ selectedAlmInstance,
+ canAdmin,
+ loading,
+ loadingMore,
+ repositories,
+ resetPat,
+ searching,
+ searchQuery,
+ showPersonalAccessTokenForm,
+ } = props;
+
+ return (
+ <>
+ <CreateProjectPageHeader
+ title={
+ <span className="text-middle">
+ <img
+ alt="" // Should be ignored by screen readers
+ className="spacer-right"
+ height="24"
+ src={`${getBaseUrl()}/images/alm/bitbucket.svg`}
+ />
+ {translate('onboarding.create_project.bitbucketcloud.title')}
+ </span>
+ }
+ />
+
+ <AlmSettingsInstanceDropdown
+ almKey={AlmKeys.BitbucketCloud}
+ almInstances={almInstances}
+ selectedAlmInstance={selectedAlmInstance}
+ onChangeConfig={props.onSelectedAlmInstanceChange}
+ />
+
+ {loading && <i className="spinner" />}
+
+ {!loading && !selectedAlmInstance && (
+ <WrongBindingCountAlert alm={AlmKeys.BitbucketCloud} canAdmin={!!canAdmin} />
+ )}
+
+ {!loading &&
+ selectedAlmInstance &&
+ (showPersonalAccessTokenForm ? (
+ <PersonalAccessTokenForm
+ almSetting={selectedAlmInstance}
+ resetPat={resetPat}
+ onPersonalAccessTokenCreated={props.onPersonalAccessTokenCreated}
+ />
+ ) : (
+ <BitbucketCloudSearchForm
+ importingSlug={importingSlug}
+ isLastPage={isLastPage}
+ loadingMore={loadingMore}
+ searchQuery={searchQuery}
+ searching={searching}
+ onImport={props.onImport}
+ onSearch={props.onSearch}
+ onLoadMore={props.onLoadMore}
+ repositories={repositories}
+ />
+ ))}
+ </>
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { FormattedMessage } from 'react-intl';
+import Link from '../../../../components/common/Link';
+import SearchBox from '../../../../components/controls/SearchBox';
+import Tooltip from '../../../../components/controls/Tooltip';
+import { Button } from '../../../../components/controls/buttons';
+import CheckIcon from '../../../../components/icons/CheckIcon';
+import QualifierIcon from '../../../../components/icons/QualifierIcon';
+import { Alert } from '../../../../components/ui/Alert';
+import DeferredSpinner from '../../../../components/ui/DeferredSpinner';
+import { translate, translateWithParameters } from '../../../../helpers/l10n';
+import { formatMeasure } from '../../../../helpers/measures';
+import { getProjectUrl, queryToSearch } from '../../../../helpers/urls';
+import { BitbucketCloudRepository } from '../../../../types/alm-integration';
+import { ComponentQualifier } from '../../../../types/component';
+import { MetricType } from '../../../../types/metrics';
+import InstanceNewCodeDefinitionComplianceWarning from '../components/InstanceNewCodeDefinitionComplianceWarning';
+import { CreateProjectModes } from '../types';
+
+export interface BitbucketCloudSearchFormProps {
+ importingSlug?: string;
+ isLastPage: boolean;
+ loadingMore: boolean;
+ onImport: (repositorySlug: string) => void;
+ onLoadMore: () => void;
+ onSearch: (searchQuery: string) => void;
+ repositories?: BitbucketCloudRepository[];
+ searching: boolean;
+ searchQuery: string;
+}
+
+function getRepositoryUrl(workspace: string, slug: string) {
+ return `https://bitbucket.org/${workspace}/${slug}`;
+}
+
+export default function BitbucketCloudSearchForm(props: BitbucketCloudSearchFormProps) {
+ const {
+ importingSlug,
+ isLastPage,
+ loadingMore,
+ repositories = [],
+ searching,
+ searchQuery,
+ } = props;
+
+ if (repositories.length === 0 && searchQuery.length === 0 && !searching) {
+ return (
+ <Alert className="spacer-top" variant="warning">
+ <FormattedMessage
+ defaultMessage={translate('onboarding.create_project.bitbucketcloud.no_projects')}
+ id="onboarding.create_project.bitbucketcloud.no_projects"
+ values={{
+ link: (
+ <Link
+ to={{
+ pathname: '/projects/create',
+ search: queryToSearch({ mode: CreateProjectModes.BitbucketCloud, resetPat: 1 }),
+ }}
+ >
+ {translate('onboarding.create_project.update_your_token')}
+ </Link>
+ ),
+ }}
+ />
+ </Alert>
+ );
+ }
+
+ return (
+ <>
+ <InstanceNewCodeDefinitionComplianceWarning />
+ <div className="boxed-group big-padded create-project-import">
+ <SearchBox
+ className="spacer"
+ loading={searching}
+ minLength={3}
+ onChange={props.onSearch}
+ placeholder={translate('onboarding.create_project.search_prompt')}
+ />
+
+ <hr />
+
+ {repositories.length === 0 ? (
+ <div className="padded">{translate('no_results')}</div>
+ ) : (
+ <table className="data zebra zebra-hover">
+ <tbody>
+ {repositories.map((repository) => (
+ <tr key={repository.uuid}>
+ <td>
+ <Tooltip overlay={repository.slug}>
+ <strong className="project-name display-inline-block text-ellipsis">
+ {repository.sqProjectKey ? (
+ <Link to={getProjectUrl(repository.sqProjectKey)}>
+ <QualifierIcon
+ className="spacer-right"
+ qualifier={ComponentQualifier.Project}
+ />
+ {repository.name}
+ </Link>
+ ) : (
+ repository.name
+ )}
+ </strong>
+ </Tooltip>
+ <br />
+ <Tooltip overlay={repository.projectKey}>
+ <span className="text-muted project-path display-inline-block text-ellipsis">
+ {repository.projectKey}
+ </span>
+ </Tooltip>
+ </td>
+ <td>
+ <Link
+ className="display-inline-flex-center big-spacer-right"
+ to={getRepositoryUrl(repository.workspace, repository.slug)}
+ target="_blank"
+ >
+ {translate('onboarding.create_project.bitbucketcloud.link')}
+ </Link>
+ </td>
+ {repository.sqProjectKey ? (
+ <td>
+ <span className="display-flex-center display-flex-justify-end already-set-up">
+ <CheckIcon className="little-spacer-right" size={12} />
+ {translate('onboarding.create_project.repository_imported')}
+ </span>
+ </td>
+ ) : (
+ <td className="text-right">
+ <Button
+ disabled={Boolean(importingSlug)}
+ onClick={() => {
+ props.onImport(repository.slug);
+ }}
+ >
+ {translate('onboarding.create_project.set_up')}
+ <DeferredSpinner
+ className="spacer-left"
+ loading={importingSlug === repository.slug}
+ />
+ </Button>
+ </td>
+ )}
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ )}
+ <footer className="spacer-top note text-center">
+ {isLastPage &&
+ translateWithParameters(
+ 'x_of_y_shown',
+ formatMeasure(repositories.length, MetricType.Integer, null),
+ formatMeasure(repositories.length, MetricType.Integer, null)
+ )}
+ {!isLastPage && (
+ <Button
+ className="spacer-left"
+ disabled={loadingMore}
+ data-test="show-more"
+ onClick={props.onLoadMore}
+ >
+ {translate('show_more')}
+ </Button>
+ )}
+ <DeferredSpinner
+ className="text-bottom spacer-left position-absolute"
+ loading={loadingMore}
+ />
+ </footer>
+ </div>
+ </>
+ );
+}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 {
- importBitbucketCloudRepository,
- searchForBitbucketCloudRepositories,
-} from '../../../api/alm-integrations';
-import { Location, Router } from '../../../components/hoc/withRouter';
-import { BitbucketCloudRepository } from '../../../types/alm-integration';
-import { AlmSettingsInstance } from '../../../types/alm-settings';
-import { Paging } from '../../../types/types';
-import BitbucketCloudProjectCreateRenderer from './BitbucketCloudProjectCreateRender';
-
-interface Props {
- canAdmin: boolean;
- almInstances: AlmSettingsInstance[];
- loadingBindings: boolean;
- onProjectCreate: (projectKey: string) => void;
- location: Location;
- router: Router;
-}
-
-interface State {
- importingSlug?: string;
- isLastPage?: boolean;
- loading: boolean;
- loadingMore: boolean;
- projectsPaging: Omit<Paging, 'total'>;
- resetPat: boolean;
- repositories: BitbucketCloudRepository[];
- searching: boolean;
- searchQuery: string;
- selectedAlmInstance: AlmSettingsInstance;
- showPersonalAccessTokenForm: boolean;
-}
-
-export const BITBUCKET_CLOUD_PROJECTS_PAGESIZE = 30;
-export default class BitbucketCloudProjectCreate extends React.PureComponent<Props, State> {
- mounted = false;
-
- constructor(props: Props) {
- super(props);
- this.state = {
- // For now, we only handle a single instance. So we always use the first
- // one from the list.
- loading: false,
- loadingMore: false,
- resetPat: false,
- projectsPaging: { pageIndex: 1, pageSize: BITBUCKET_CLOUD_PROJECTS_PAGESIZE },
- repositories: [],
- searching: false,
- searchQuery: '',
- selectedAlmInstance: props.almInstances[0],
- showPersonalAccessTokenForm: true,
- };
- }
-
- componentDidMount() {
- this.mounted = true;
- }
-
- componentDidUpdate(prevProps: Props) {
- if (prevProps.almInstances.length === 0 && this.props.almInstances.length > 0) {
- this.setState({ selectedAlmInstance: this.props.almInstances[0] }, () => this.fetchData());
- }
- }
-
- handlePersonalAccessTokenCreated = async () => {
- this.setState({ showPersonalAccessTokenForm: false });
- this.cleanUrl();
- this.setState({ loading: true });
- await this.fetchData();
- this.setState({ loading: false });
- };
-
- cleanUrl = () => {
- const { location, router } = this.props;
- delete location.query.resetPat;
- router.replace(location);
- };
-
- async fetchData(more = false) {
- const {
- selectedAlmInstance,
- searchQuery,
- projectsPaging: { pageIndex, pageSize },
- showPersonalAccessTokenForm,
- } = this.state;
- if (selectedAlmInstance && !showPersonalAccessTokenForm) {
- const { isLastPage, repositories } = await searchForBitbucketCloudRepositories(
- selectedAlmInstance.key,
- searchQuery,
- pageSize,
- pageIndex
- ).catch(() => {
- this.handleError();
- return { isLastPage: undefined, repositories: undefined };
- });
- if (this.mounted && isLastPage !== undefined && repositories !== undefined) {
- if (more) {
- this.setState((state) => ({
- isLastPage,
- repositories: [...state.repositories, ...repositories],
- }));
- } else {
- this.setState({ isLastPage, repositories });
- }
- }
- }
- }
-
- handleError = () => {
- if (this.mounted) {
- this.setState({
- projectsPaging: { pageIndex: 1, pageSize: BITBUCKET_CLOUD_PROJECTS_PAGESIZE },
- repositories: [],
- resetPat: true,
- showPersonalAccessTokenForm: true,
- });
- }
-
- return undefined;
- };
-
- handleSearch = (searchQuery: string) => {
- this.setState(
- {
- searching: true,
- projectsPaging: { pageIndex: 1, pageSize: BITBUCKET_CLOUD_PROJECTS_PAGESIZE },
- searchQuery,
- },
- async () => {
- await this.fetchData();
- if (this.mounted) {
- this.setState({ searching: false });
- }
- }
- );
- };
-
- handleLoadMore = () => {
- this.setState(
- (state) => ({
- loadingMore: true,
- projectsPaging: {
- pageIndex: state.projectsPaging.pageIndex + 1,
- pageSize: state.projectsPaging.pageSize,
- },
- }),
- async () => {
- await this.fetchData(true);
- if (this.mounted) {
- this.setState({ loadingMore: false });
- }
- }
- );
- };
-
- handleImport = async (repositorySlug: string) => {
- const { selectedAlmInstance } = this.state;
-
- if (!selectedAlmInstance) {
- return;
- }
-
- this.setState({ importingSlug: repositorySlug });
-
- const result = await importBitbucketCloudRepository(
- selectedAlmInstance.key,
- repositorySlug
- ).catch(() => undefined);
-
- if (this.mounted) {
- this.setState({ importingSlug: undefined });
-
- if (result) {
- this.props.onProjectCreate(result.project.key);
- }
- }
- };
-
- onSelectedAlmInstanceChange = (instance: AlmSettingsInstance) => {
- this.setState({
- selectedAlmInstance: instance,
- showPersonalAccessTokenForm: true,
- resetPat: false,
- searching: false,
- searchQuery: '',
- projectsPaging: { pageIndex: 1, pageSize: BITBUCKET_CLOUD_PROJECTS_PAGESIZE },
- });
- };
-
- render() {
- const { canAdmin, loadingBindings, location, almInstances } = this.props;
- const {
- importingSlug,
- isLastPage = true,
- selectedAlmInstance,
- loading,
- loadingMore,
- repositories,
- showPersonalAccessTokenForm,
- resetPat,
- searching,
- searchQuery,
- } = this.state;
- return (
- <BitbucketCloudProjectCreateRenderer
- importingSlug={importingSlug}
- isLastPage={isLastPage}
- selectedAlmInstance={selectedAlmInstance}
- almInstances={almInstances}
- canAdmin={canAdmin}
- loadingMore={loadingMore}
- loading={loading || loadingBindings}
- onImport={this.handleImport}
- onLoadMore={this.handleLoadMore}
- onPersonalAccessTokenCreated={this.handlePersonalAccessTokenCreated}
- onSearch={this.handleSearch}
- onSelectedAlmInstanceChange={this.onSelectedAlmInstanceChange}
- repositories={repositories}
- searching={searching}
- searchQuery={searchQuery}
- resetPat={resetPat || Boolean(location.query.resetPat)}
- showPersonalAccessTokenForm={
- showPersonalAccessTokenForm || Boolean(location.query.resetPat)
- }
- />
- );
- }
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 '../../../helpers/l10n';
-import { getBaseUrl } from '../../../helpers/system';
-import { BitbucketCloudRepository } from '../../../types/alm-integration';
-import { AlmKeys, AlmSettingsInstance } from '../../../types/alm-settings';
-import AlmSettingsInstanceDropdown from './AlmSettingsInstanceDropdown';
-import BitbucketCloudSearchForm from './BitbucketCloudSearchForm';
-import CreateProjectPageHeader from './CreateProjectPageHeader';
-import PersonalAccessTokenForm from './PersonalAccessTokenForm';
-import WrongBindingCountAlert from './WrongBindingCountAlert';
-
-export interface BitbucketCloudProjectCreateRendererProps {
- importingSlug?: string;
- isLastPage: boolean;
- canAdmin?: boolean;
- loading: boolean;
- loadingMore: boolean;
- onImport: (repositorySlug: string) => void;
- onLoadMore: () => void;
- onPersonalAccessTokenCreated: () => void;
- onSearch: (searchQuery: string) => void;
- onSelectedAlmInstanceChange: (instance: AlmSettingsInstance) => void;
- repositories?: BitbucketCloudRepository[];
- resetPat: boolean;
- searching: boolean;
- searchQuery: string;
- showPersonalAccessTokenForm: boolean;
- almInstances: AlmSettingsInstance[];
- selectedAlmInstance?: AlmSettingsInstance;
-}
-
-export default function BitbucketCloudProjectCreateRenderer(
- props: BitbucketCloudProjectCreateRendererProps
-) {
- const {
- almInstances,
- importingSlug,
- isLastPage,
- selectedAlmInstance,
- canAdmin,
- loading,
- loadingMore,
- repositories,
- resetPat,
- searching,
- searchQuery,
- showPersonalAccessTokenForm,
- } = props;
-
- return (
- <>
- <CreateProjectPageHeader
- title={
- <span className="text-middle">
- <img
- alt="" // Should be ignored by screen readers
- className="spacer-right"
- height="24"
- src={`${getBaseUrl()}/images/alm/bitbucket.svg`}
- />
- {translate('onboarding.create_project.bitbucketcloud.title')}
- </span>
- }
- />
-
- <AlmSettingsInstanceDropdown
- almKey={AlmKeys.BitbucketCloud}
- almInstances={almInstances}
- selectedAlmInstance={selectedAlmInstance}
- onChangeConfig={props.onSelectedAlmInstanceChange}
- />
-
- {loading && <i className="spinner" />}
-
- {!loading && !selectedAlmInstance && (
- <WrongBindingCountAlert alm={AlmKeys.BitbucketCloud} canAdmin={!!canAdmin} />
- )}
-
- {!loading &&
- selectedAlmInstance &&
- (showPersonalAccessTokenForm ? (
- <PersonalAccessTokenForm
- almSetting={selectedAlmInstance}
- resetPat={resetPat}
- onPersonalAccessTokenCreated={props.onPersonalAccessTokenCreated}
- />
- ) : (
- <BitbucketCloudSearchForm
- importingSlug={importingSlug}
- isLastPage={isLastPage}
- loadingMore={loadingMore}
- searchQuery={searchQuery}
- searching={searching}
- onImport={props.onImport}
- onSearch={props.onSearch}
- onLoadMore={props.onLoadMore}
- repositories={repositories}
- />
- ))}
- </>
- );
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 { FormattedMessage } from 'react-intl';
-import Link from '../../../components/common/Link';
-import { Button } from '../../../components/controls/buttons';
-import SearchBox from '../../../components/controls/SearchBox';
-import Tooltip from '../../../components/controls/Tooltip';
-import CheckIcon from '../../../components/icons/CheckIcon';
-import QualifierIcon from '../../../components/icons/QualifierIcon';
-import { Alert } from '../../../components/ui/Alert';
-import DeferredSpinner from '../../../components/ui/DeferredSpinner';
-import { translate, translateWithParameters } from '../../../helpers/l10n';
-import { formatMeasure } from '../../../helpers/measures';
-import { getProjectUrl, queryToSearch } from '../../../helpers/urls';
-import { BitbucketCloudRepository } from '../../../types/alm-integration';
-import { ComponentQualifier } from '../../../types/component';
-import { CreateProjectModes } from './types';
-
-export interface BitbucketCloudSearchFormProps {
- importingSlug?: string;
- isLastPage: boolean;
- loadingMore: boolean;
- onImport: (repositorySlug: string) => void;
- onLoadMore: () => void;
- onSearch: (searchQuery: string) => void;
- repositories?: BitbucketCloudRepository[];
- searching: boolean;
- searchQuery: string;
-}
-
-function getRepositoryUrl(workspace: string, slug: string) {
- return `https://bitbucket.org/${workspace}/${slug}`;
-}
-
-export default function BitbucketCloudSearchForm(props: BitbucketCloudSearchFormProps) {
- const {
- importingSlug,
- isLastPage,
- loadingMore,
- repositories = [],
- searching,
- searchQuery,
- } = props;
-
- if (repositories.length === 0 && searchQuery.length === 0 && !searching) {
- return (
- <Alert className="spacer-top" variant="warning">
- <FormattedMessage
- defaultMessage={translate('onboarding.create_project.bitbucketcloud.no_projects')}
- id="onboarding.create_project.bitbucketcloud.no_projects"
- values={{
- link: (
- <Link
- to={{
- pathname: '/projects/create',
- search: queryToSearch({ mode: CreateProjectModes.BitbucketCloud, resetPat: 1 }),
- }}
- >
- {translate('onboarding.create_project.update_your_token')}
- </Link>
- ),
- }}
- />
- </Alert>
- );
- }
-
- return (
- <div className="boxed-group big-padded create-project-import">
- <SearchBox
- className="spacer"
- loading={searching}
- minLength={3}
- onChange={props.onSearch}
- placeholder={translate('onboarding.create_project.search_prompt')}
- />
-
- <hr />
-
- {repositories.length === 0 ? (
- <div className="padded">{translate('no_results')}</div>
- ) : (
- <table className="data zebra zebra-hover">
- <tbody>
- {repositories.map((repository) => (
- <tr key={repository.uuid}>
- <td>
- <Tooltip overlay={repository.slug}>
- <strong className="project-name display-inline-block text-ellipsis">
- {repository.sqProjectKey ? (
- <Link to={getProjectUrl(repository.sqProjectKey)}>
- <QualifierIcon
- className="spacer-right"
- qualifier={ComponentQualifier.Project}
- />
- {repository.name}
- </Link>
- ) : (
- repository.name
- )}
- </strong>
- </Tooltip>
- <br />
- <Tooltip overlay={repository.projectKey}>
- <span className="text-muted project-path display-inline-block text-ellipsis">
- {repository.projectKey}
- </span>
- </Tooltip>
- </td>
- <td>
- <Link
- className="display-inline-flex-center big-spacer-right"
- to={getRepositoryUrl(repository.workspace, repository.slug)}
- target="_blank"
- >
- {translate('onboarding.create_project.bitbucketcloud.link')}
- </Link>
- </td>
- {repository.sqProjectKey ? (
- <td>
- <span className="display-flex-center display-flex-justify-end already-set-up">
- <CheckIcon className="little-spacer-right" size={12} />
- {translate('onboarding.create_project.repository_imported')}
- </span>
- </td>
- ) : (
- <td className="text-right">
- <Button
- disabled={Boolean(importingSlug)}
- onClick={() => {
- props.onImport(repository.slug);
- }}
- >
- {translate('onboarding.create_project.set_up')}
- {importingSlug === repository.slug && (
- <DeferredSpinner className="spacer-left" />
- )}
- </Button>
- </td>
- )}
- </tr>
- ))}
- </tbody>
- </table>
- )}
- <footer className="spacer-top note text-center">
- {isLastPage &&
- translateWithParameters(
- 'x_of_y_shown',
- formatMeasure(repositories.length, 'INT', null),
- formatMeasure(repositories.length, 'INT', null)
- )}
- {!isLastPage && (
- <Button
- className="spacer-left"
- disabled={loadingMore}
- data-test="show-more"
- onClick={props.onLoadMore}
- >
- {translate('show_more')}
- </Button>
- )}
- {loadingMore && <DeferredSpinner className="text-bottom spacer-left position-absolute" />}
- </footer>
- </div>
- );
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 { FormattedMessage } from 'react-intl';
-import Link from '../../../components/common/Link';
-import SearchBox from '../../../components/controls/SearchBox';
-import { Alert } from '../../../components/ui/Alert';
-import { translate } from '../../../helpers/l10n';
-import { queryToSearch } from '../../../helpers/urls';
-import {
- BitbucketProject,
- BitbucketProjectRepositories,
- BitbucketRepository,
-} from '../../../types/alm-integration';
-import BitbucketRepositories from './BitbucketRepositories';
-import BitbucketSearchResults from './BitbucketSearchResults';
-import { CreateProjectModes } from './types';
-
-export interface BitbucketImportRepositoryFormProps {
- disableRepositories: boolean;
- onSearch: (query: string) => void;
- onSelectRepository: (repo: BitbucketRepository) => void;
- projects?: BitbucketProject[];
- projectRepositories?: BitbucketProjectRepositories;
- searching: boolean;
- searchResults?: BitbucketRepository[];
- selectedRepository?: BitbucketRepository;
-}
-
-export default function BitbucketImportRepositoryForm(props: BitbucketImportRepositoryFormProps) {
- const {
- disableRepositories,
- projects = [],
- projectRepositories = {},
- searchResults,
- searching,
- selectedRepository,
- } = props;
-
- if (projects.length === 0) {
- return (
- <Alert className="spacer-top" variant="warning">
- <FormattedMessage
- defaultMessage={translate('onboarding.create_project.no_bbs_projects')}
- id="onboarding.create_project.no_bbs_projects"
- values={{
- link: (
- <Link
- to={{
- pathname: '/projects/create',
- search: queryToSearch({ mode: CreateProjectModes.BitbucketServer, resetPat: 1 }),
- }}
- >
- {translate('onboarding.create_project.update_your_token')}
- </Link>
- ),
- }}
- />
- </Alert>
- );
- }
-
- return (
- <div className="create-project-import-bbs">
- <SearchBox
- onChange={props.onSearch}
- placeholder={translate('onboarding.create_project.search_repositories_by_name')}
- />
-
- {searching || searchResults ? (
- <BitbucketSearchResults
- disableRepositories={disableRepositories}
- onSelectRepository={props.onSelectRepository}
- projects={projects}
- searchResults={searchResults}
- searching={searching}
- selectedRepository={selectedRepository}
- />
- ) : (
- <BitbucketRepositories
- disableRepositories={disableRepositories}
- onSelectRepository={props.onSelectRepository}
- projectRepositories={projectRepositories}
- projects={projects}
- selectedRepository={selectedRepository}
- />
- )}
- </div>
- );
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 classNames from 'classnames';
-import * as React from 'react';
-import { FormattedMessage } from 'react-intl';
-import { colors } from '../../../app/theme';
-import Link from '../../../components/common/Link';
-import BoxedGroupAccordion from '../../../components/controls/BoxedGroupAccordion';
-import Radio from '../../../components/controls/Radio';
-import CheckIcon from '../../../components/icons/CheckIcon';
-import { Alert } from '../../../components/ui/Alert';
-import { translate, translateWithParameters } from '../../../helpers/l10n';
-import { getProjectUrl, queryToSearch } from '../../../helpers/urls';
-import { BitbucketProject, BitbucketRepository } from '../../../types/alm-integration';
-import { CreateProjectModes } from './types';
-
-export interface BitbucketProjectAccordionProps {
- disableRepositories: boolean;
- onClick?: () => void;
- onSelectRepository: (repo: BitbucketRepository) => void;
- open: boolean;
- project?: BitbucketProject;
- repositories: BitbucketRepository[];
- selectedRepository?: BitbucketRepository;
- showingAllRepositories: boolean;
-}
-
-export default function BitbucketProjectAccordion(props: BitbucketProjectAccordionProps) {
- const {
- disableRepositories,
- open,
- project,
- repositories,
- selectedRepository,
- showingAllRepositories,
- } = props;
-
- const repositoryCount = repositories.length;
-
- const title = project?.name ?? translate('search_results');
-
- return (
- <BoxedGroupAccordion
- className={classNames('big-spacer-bottom', {
- open,
- 'not-clickable': !props.onClick,
- 'no-hover': !props.onClick,
- })}
- onClick={
- props.onClick
- ? props.onClick
- : () => {
- /* noop */
- }
- }
- open={open}
- title={<h3>{title}</h3>}
- >
- {open && (
- <>
- <div className="display-flex-wrap">
- {repositoryCount === 0 && (
- <Alert variant="warning">
- <FormattedMessage
- defaultMessage={translate('onboarding.create_project.no_bbs_repos')}
- id="onboarding.create_project.no_bbs_repos"
- values={{
- link: (
- <Link
- to={{
- pathname: '/projects/create',
- search: queryToSearch({
- mode: CreateProjectModes.BitbucketServer,
- resetPat: 1,
- }),
- }}
- >
- {translate('onboarding.create_project.update_your_token')}
- </Link>
- ),
- }}
- />
- </Alert>
- )}
-
- {repositories.map((repo) =>
- repo.sqProjectKey ? (
- <div
- className="display-flex-start spacer-right spacer-bottom create-project-import-bbs-repo"
- key={repo.id}
- >
- <CheckIcon className="spacer-right" fill={colors.green} size={14} />
- <div className="overflow-hidden">
- <div className="little-spacer-bottom">
- <strong title={repo.name}>
- <Link to={getProjectUrl(repo.sqProjectKey)}>{repo.name}</Link>
- </strong>
- </div>
- <em>{translate('onboarding.create_project.repository_imported')}</em>
- </div>
- </div>
- ) : (
- <Radio
- checked={selectedRepository?.id === repo.id}
- className={classNames(
- 'display-flex-start spacer-right spacer-bottom create-project-import-bbs-repo overflow-hidden',
- {
- disabled: disableRepositories,
- }
- )}
- key={repo.id}
- onCheck={() => props.onSelectRepository(repo)}
- value={String(repo.id)}
- >
- <strong title={repo.name}>{repo.name}</strong>
- </Radio>
- )
- )}
- </div>
-
- {!showingAllRepositories && repositoryCount > 0 && (
- <Alert variant="warning">
- {translateWithParameters(
- 'onboarding.create_project.only_showing_X_first_repos',
- repositoryCount
- )}
- </Alert>
- )}
- </>
- )}
- </BoxedGroupAccordion>
- );
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 {
- getBitbucketServerProjects,
- getBitbucketServerRepositories,
- importBitbucketServerProject,
- searchForBitbucketServerRepositories,
-} from '../../../api/alm-integrations';
-import { Location, Router } from '../../../components/hoc/withRouter';
-import {
- BitbucketProject,
- BitbucketProjectRepositories,
- BitbucketRepository,
-} from '../../../types/alm-integration';
-import { AlmSettingsInstance } from '../../../types/alm-settings';
-import BitbucketCreateProjectRenderer from './BitbucketProjectCreateRenderer';
-import { DEFAULT_BBS_PAGE_SIZE } from './constants';
-
-interface Props {
- canAdmin: boolean;
- almInstances: AlmSettingsInstance[];
- loadingBindings: boolean;
- onProjectCreate: (projectKey: string) => void;
- location: Location;
- router: Router;
-}
-
-interface State {
- selectedAlmInstance?: AlmSettingsInstance;
- importing: boolean;
- loading: boolean;
- projects?: BitbucketProject[];
- projectRepositories?: BitbucketProjectRepositories;
- searching: boolean;
- searchResults?: BitbucketRepository[];
- selectedRepository?: BitbucketRepository;
- showPersonalAccessTokenForm: boolean;
-}
-
-export default class BitbucketProjectCreate 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.
- selectedAlmInstance: props.almInstances[0],
- importing: false,
- loading: false,
- searching: false,
- showPersonalAccessTokenForm: true,
- };
- }
-
- componentDidMount() {
- this.mounted = true;
- }
-
- componentDidUpdate(prevProps: Props) {
- if (prevProps.almInstances.length === 0 && this.props.almInstances.length > 0) {
- this.setState({ selectedAlmInstance: this.props.almInstances[0] }, () =>
- this.fetchInitialData()
- );
- }
- }
-
- componentWillUnmount() {
- this.mounted = false;
- }
-
- fetchInitialData = async () => {
- const { showPersonalAccessTokenForm } = this.state;
-
- if (!showPersonalAccessTokenForm) {
- this.setState({ loading: true });
- const 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({
- projects,
- projectRepositories,
- loading: false,
- });
- }
- }
- };
-
- fetchBitbucketProjects = (): Promise<BitbucketProject[] | undefined> => {
- const { selectedAlmInstance } = this.state;
-
- if (!selectedAlmInstance) {
- return Promise.resolve(undefined);
- }
-
- return getBitbucketServerProjects(selectedAlmInstance.key).then(({ projects }) => projects);
- };
-
- fetchBitbucketRepositories = (
- projects: BitbucketProject[]
- ): Promise<BitbucketProjectRepositories | undefined> => {
- const { selectedAlmInstance } = this.state;
-
- if (!selectedAlmInstance) {
- return Promise.resolve(undefined);
- }
-
- return Promise.all(
- projects.map((p) => {
- return getBitbucketServerRepositories(selectedAlmInstance.key, p.name).then(
- ({ isLastPage, repositories }) => {
- // Because the WS uses the project name rather than its key to find
- // repositories, we can match more repositories than we expect. For
- // example, p.name = "A1" would find repositories for projects "A1",
- // "A10", "A11", etc. This is a limitation of BBS. To make sure we
- // don't display incorrect information, filter on the project key.
- const filteredRepositories = repositories.filter((r) => r.projectKey === p.key);
-
- // And because of the above, the "isLastPage" cannot be relied upon
- // either. This one is impossible to get 100% for now. We can only
- // make some assumptions: by default, the page size for BBS is 25
- // (this is not part of the payload, so we don't know the actual
- // number; but changing this implies changing some advanced config,
- // so it's not likely). If the filtered repos is larger than this
- // number AND isLastPage is false, we'll keep it at false.
- // Otherwise, we assume it's true.
- const realIsLastPage =
- isLastPage || filteredRepositories.length < DEFAULT_BBS_PAGE_SIZE;
-
- return {
- repositories: filteredRepositories,
- isLastPage: realIsLastPage,
- projectKey: p.key,
- };
- }
- );
- })
- ).then((results) => {
- return results.reduce(
- (acc: BitbucketProjectRepositories, { isLastPage, projectKey, repositories }) => {
- return { ...acc, [projectKey]: { allShown: isLastPage, repositories } };
- },
- {}
- );
- });
- };
-
- cleanUrl = () => {
- const { location, router } = this.props;
- delete location.query.resetPat;
- router.replace(location);
- };
-
- handlePersonalAccessTokenCreated = async () => {
- this.setState({ showPersonalAccessTokenForm: false });
- this.cleanUrl();
- await this.fetchInitialData();
- };
-
- handleImportRepository = () => {
- const { selectedAlmInstance, selectedRepository } = this.state;
-
- if (!selectedAlmInstance || !selectedRepository) {
- return;
- }
-
- this.setState({ importing: true });
- importBitbucketServerProject(
- selectedAlmInstance.key,
- selectedRepository.projectKey,
- selectedRepository.slug
- )
- .then(({ project: { key } }) => {
- if (this.mounted) {
- this.setState({ importing: false });
- this.props.onProjectCreate(key);
- }
- })
- .catch(() => {
- if (this.mounted) {
- this.setState({ importing: false });
- }
- });
- };
-
- handleSearch = (query: string) => {
- const { selectedAlmInstance } = this.state;
-
- if (!selectedAlmInstance) {
- return;
- }
-
- if (!query) {
- this.setState({ searching: false, searchResults: undefined, selectedRepository: undefined });
- return;
- }
-
- this.setState({ searching: true, selectedRepository: undefined });
- searchForBitbucketServerRepositories(selectedAlmInstance.key, query)
- .then(({ repositories }) => {
- if (this.mounted) {
- this.setState({ searching: false, searchResults: repositories });
- }
- })
- .catch(() => {
- if (this.mounted) {
- this.setState({ searching: false });
- }
- });
- };
-
- handleSelectRepository = (selectedRepository: BitbucketRepository) => {
- this.setState({ selectedRepository });
- };
-
- onSelectedAlmInstanceChange = (instance: AlmSettingsInstance) => {
- this.setState({
- selectedAlmInstance: instance,
- showPersonalAccessTokenForm: true,
- searching: false,
- searchResults: undefined,
- });
- };
-
- render() {
- const { canAdmin, loadingBindings, location, almInstances } = this.props;
- const {
- selectedAlmInstance,
- importing,
- loading,
- projectRepositories,
- projects,
- searching,
- searchResults,
- selectedRepository,
- showPersonalAccessTokenForm,
- } = this.state;
-
- return (
- <BitbucketCreateProjectRenderer
- selectedAlmInstance={selectedAlmInstance}
- almInstances={almInstances}
- canAdmin={canAdmin}
- importing={importing}
- loading={loading || loadingBindings}
- onImportRepository={this.handleImportRepository}
- onPersonalAccessTokenCreated={this.handlePersonalAccessTokenCreated}
- onSearch={this.handleSearch}
- onSelectRepository={this.handleSelectRepository}
- onSelectedAlmInstanceChange={this.onSelectedAlmInstanceChange}
- projectRepositories={projectRepositories}
- projects={projects}
- resetPat={Boolean(location.query.resetPat)}
- searchResults={searchResults}
- searching={searching}
- selectedRepository={selectedRepository}
- showPersonalAccessTokenForm={
- showPersonalAccessTokenForm || Boolean(location.query.resetPat)
- }
- />
- );
- }
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 { Button } from '../../../components/controls/buttons';
-import DeferredSpinner from '../../../components/ui/DeferredSpinner';
-import { translate } from '../../../helpers/l10n';
-import { getBaseUrl } from '../../../helpers/system';
-import {
- BitbucketProject,
- BitbucketProjectRepositories,
- BitbucketRepository,
-} from '../../../types/alm-integration';
-import { AlmKeys, AlmSettingsInstance } from '../../../types/alm-settings';
-import AlmSettingsInstanceDropdown from './AlmSettingsInstanceDropdown';
-import BitbucketImportRepositoryForm from './BitbucketImportRepositoryForm';
-import CreateProjectPageHeader from './CreateProjectPageHeader';
-import PersonalAccessTokenForm from './PersonalAccessTokenForm';
-import WrongBindingCountAlert from './WrongBindingCountAlert';
-
-export interface BitbucketProjectCreateRendererProps {
- selectedAlmInstance?: AlmSettingsInstance;
- almInstances: AlmSettingsInstance[];
- canAdmin?: boolean;
- importing: boolean;
- loading: boolean;
- onImportRepository: () => void;
- onSearch: (query: string) => void;
- onSelectRepository: (repo: BitbucketRepository) => void;
- onPersonalAccessTokenCreated: () => void;
- onSelectedAlmInstanceChange: (instance: AlmSettingsInstance) => void;
- projects?: BitbucketProject[];
- projectRepositories?: BitbucketProjectRepositories;
- resetPat: boolean;
- searching: boolean;
- searchResults?: BitbucketRepository[];
- selectedRepository?: BitbucketRepository;
- showPersonalAccessTokenForm?: boolean;
-}
-
-export default function BitbucketProjectCreateRenderer(props: BitbucketProjectCreateRendererProps) {
- const {
- almInstances,
- selectedAlmInstance,
- canAdmin,
- importing,
- loading,
- projects,
- projectRepositories,
- selectedRepository,
- searching,
- searchResults,
- showPersonalAccessTokenForm,
- resetPat,
- } = props;
-
- return (
- <>
- <CreateProjectPageHeader
- additionalActions={
- !showPersonalAccessTokenForm && (
- <div className="display-flex-center pull-right">
- <DeferredSpinner className="spacer-right" loading={importing} />
- <Button
- className="button-large button-primary"
- disabled={!selectedRepository || importing}
- onClick={props.onImportRepository}
- >
- {translate('onboarding.create_project.import_selected_repo')}
- </Button>
- </div>
- )
- }
- title={
- <span className="text-middle">
- <img
- alt="" // Should be ignored by screen readers
- className="spacer-right"
- height="24"
- src={`${getBaseUrl()}/images/alm/bitbucket.svg`}
- />
- {translate('onboarding.create_project.from_bbs')}
- </span>
- }
- />
-
- <AlmSettingsInstanceDropdown
- almKey={AlmKeys.BitbucketServer}
- almInstances={almInstances}
- selectedAlmInstance={selectedAlmInstance}
- onChangeConfig={props.onSelectedAlmInstanceChange}
- />
-
- {loading && <i className="spinner" />}
-
- {!loading && !selectedAlmInstance && (
- <WrongBindingCountAlert alm={AlmKeys.BitbucketServer} canAdmin={!!canAdmin} />
- )}
-
- {!loading &&
- selectedAlmInstance &&
- (showPersonalAccessTokenForm ? (
- <PersonalAccessTokenForm
- almSetting={selectedAlmInstance}
- onPersonalAccessTokenCreated={props.onPersonalAccessTokenCreated}
- resetPat={resetPat}
- />
- ) : (
- <BitbucketImportRepositoryForm
- disableRepositories={importing}
- onSearch={props.onSearch}
- onSelectRepository={props.onSelectRepository}
- projectRepositories={projectRepositories}
- projects={projects}
- searchResults={searchResults}
- searching={searching}
- selectedRepository={selectedRepository}
- />
- ))}
- </>
- );
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 { uniq, without } from 'lodash';
-import * as React from 'react';
-import { ButtonLink } from '../../../components/controls/buttons';
-import { translate } from '../../../helpers/l10n';
-import {
- BitbucketProject,
- BitbucketProjectRepositories,
- BitbucketRepository,
-} from '../../../types/alm-integration';
-import BitbucketProjectAccordion from './BitbucketProjectAccordion';
-
-export interface BitbucketRepositoriesProps {
- disableRepositories: boolean;
- onSelectRepository: (repo: BitbucketRepository) => void;
- projects: BitbucketProject[];
- projectRepositories: BitbucketProjectRepositories;
- selectedRepository?: BitbucketRepository;
-}
-
-export default function BitbucketRepositories(props: BitbucketRepositoriesProps) {
- const { disableRepositories, projects, projectRepositories, selectedRepository } = props;
-
- const [openProjectKeys, setOpenProjectKeys] = React.useState(
- projects.length > 0 ? [projects[0].key] : []
- );
-
- const allAreExpanded = projects.length <= openProjectKeys.length;
-
- const handleClick = (isOpen: boolean, projectKey: string) => {
- setOpenProjectKeys(
- isOpen ? without(openProjectKeys, projectKey) : uniq([...openProjectKeys, projectKey])
- );
- };
-
- return (
- <>
- <div className="overflow-hidden spacer-bottom">
- <ButtonLink
- className="pull-right"
- onClick={() => setOpenProjectKeys(allAreExpanded ? [] : projects.map((p) => p.key))}
- >
- {allAreExpanded ? translate('collapse_all') : translate('expand_all')}
- </ButtonLink>
- </div>
-
- {projects.map((project) => {
- const isOpen = openProjectKeys.includes(project.key);
- const { allShown, repositories = [] } = projectRepositories[project.key] || {};
-
- return (
- <BitbucketProjectAccordion
- disableRepositories={disableRepositories}
- key={project.key}
- onClick={() => handleClick(isOpen, project.key)}
- onSelectRepository={props.onSelectRepository}
- open={isOpen}
- project={project}
- repositories={repositories}
- selectedRepository={selectedRepository}
- showingAllRepositories={allShown}
- />
- );
- })}
- </>
- );
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 '../../../components/ui/Alert';
-import DeferredSpinner from '../../../components/ui/DeferredSpinner';
-import { translate } from '../../../helpers/l10n';
-import { BitbucketProject, BitbucketRepository } from '../../../types/alm-integration';
-import BitbucketProjectAccordion from './BitbucketProjectAccordion';
-
-export interface BitbucketSearchResultsProps {
- disableRepositories: boolean;
- onSelectRepository: (repo: BitbucketRepository) => void;
- projects: BitbucketProject[];
- searching: boolean;
- searchResults?: BitbucketRepository[];
- selectedRepository?: BitbucketRepository;
-}
-
-export default function BitbucketSearchResults(props: BitbucketSearchResultsProps) {
- const {
- disableRepositories,
- projects,
- searching,
- searchResults = [],
- selectedRepository,
- } = props;
-
- if (searchResults.length === 0 && !searching) {
- return (
- <Alert className="big-spacer-top" variant="warning">
- {translate('onboarding.create_project.no_bbs_repos.filter')}
- </Alert>
- );
- }
-
- const filteredProjects = projects.filter((p) =>
- searchResults.some((r) => r.projectKey === p.key)
- );
- const filteredProjectKeys = filteredProjects.map((p) => p.key);
- const filteredSearchResults = searchResults.filter(
- (r) => !filteredProjectKeys.includes(r.projectKey)
- );
-
- return (
- <div className="big-spacer-top">
- <DeferredSpinner loading={searching}>
- {filteredSearchResults.length > 0 && (
- <BitbucketProjectAccordion
- disableRepositories={disableRepositories}
- onSelectRepository={props.onSelectRepository}
- open={true}
- repositories={filteredSearchResults}
- selectedRepository={selectedRepository}
- showingAllRepositories={true}
- />
- )}
-
- {filteredProjects.map((project) => {
- const repositories = searchResults.filter((r) => r.projectKey === project.key);
-
- return (
- <BitbucketProjectAccordion
- disableRepositories={disableRepositories}
- key={project.key}
- onSelectRepository={props.onSelectRepository}
- open={true}
- project={project}
- repositories={repositories}
- selectedRepository={selectedRepository}
- showingAllRepositories={true}
- />
- );
- })}
- </DeferredSpinner>
- </div>
- );
-}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { FormattedMessage } from 'react-intl';
+import Link from '../../../../components/common/Link';
+import SearchBox from '../../../../components/controls/SearchBox';
+import { Alert } from '../../../../components/ui/Alert';
+import { translate } from '../../../../helpers/l10n';
+import { queryToSearch } from '../../../../helpers/urls';
+import {
+ BitbucketProject,
+ BitbucketProjectRepositories,
+ BitbucketRepository,
+} from '../../../../types/alm-integration';
+import InstanceNewCodeDefinitionComplianceWarning from '../components/InstanceNewCodeDefinitionComplianceWarning';
+import { CreateProjectModes } from '../types';
+import BitbucketRepositories from './BitbucketRepositories';
+import BitbucketSearchResults from './BitbucketSearchResults';
+
+export interface BitbucketImportRepositoryFormProps {
+ disableRepositories: boolean;
+ onSearch: (query: string) => void;
+ onSelectRepository: (repo: BitbucketRepository) => void;
+ projects?: BitbucketProject[];
+ projectRepositories?: BitbucketProjectRepositories;
+ searching: boolean;
+ searchResults?: BitbucketRepository[];
+ selectedRepository?: BitbucketRepository;
+}
+
+export default function BitbucketImportRepositoryForm(props: BitbucketImportRepositoryFormProps) {
+ const {
+ disableRepositories,
+ projects = [],
+ projectRepositories = {},
+ searchResults,
+ searching,
+ selectedRepository,
+ } = props;
+
+ if (projects.length === 0) {
+ return (
+ <Alert className="spacer-top" variant="warning">
+ <FormattedMessage
+ defaultMessage={translate('onboarding.create_project.no_bbs_projects')}
+ id="onboarding.create_project.no_bbs_projects"
+ values={{
+ link: (
+ <Link
+ to={{
+ pathname: '/projects/create',
+ search: queryToSearch({ mode: CreateProjectModes.BitbucketServer, resetPat: 1 }),
+ }}
+ >
+ {translate('onboarding.create_project.update_your_token')}
+ </Link>
+ ),
+ }}
+ />
+ </Alert>
+ );
+ }
+
+ return (
+ <div className="create-project-import-bbs">
+ <InstanceNewCodeDefinitionComplianceWarning />
+
+ <SearchBox
+ onChange={props.onSearch}
+ placeholder={translate('onboarding.create_project.search_repositories_by_name')}
+ />
+
+ {searching || searchResults ? (
+ <BitbucketSearchResults
+ disableRepositories={disableRepositories}
+ onSelectRepository={props.onSelectRepository}
+ projects={projects}
+ searchResults={searchResults}
+ searching={searching}
+ selectedRepository={selectedRepository}
+ />
+ ) : (
+ <BitbucketRepositories
+ disableRepositories={disableRepositories}
+ onSelectRepository={props.onSelectRepository}
+ projectRepositories={projectRepositories}
+ projects={projects}
+ selectedRepository={selectedRepository}
+ />
+ )}
+ </div>
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 classNames from 'classnames';
+import * as React from 'react';
+import { FormattedMessage } from 'react-intl';
+import { colors } from '../../../../app/theme';
+import Link from '../../../../components/common/Link';
+import BoxedGroupAccordion from '../../../../components/controls/BoxedGroupAccordion';
+import Radio from '../../../../components/controls/Radio';
+import CheckIcon from '../../../../components/icons/CheckIcon';
+import { Alert } from '../../../../components/ui/Alert';
+import { translate, translateWithParameters } from '../../../../helpers/l10n';
+import { getProjectUrl, queryToSearch } from '../../../../helpers/urls';
+import { BitbucketProject, BitbucketRepository } from '../../../../types/alm-integration';
+import { CreateProjectModes } from '../types';
+
+export interface BitbucketProjectAccordionProps {
+ disableRepositories: boolean;
+ onClick?: () => void;
+ onSelectRepository: (repo: BitbucketRepository) => void;
+ open: boolean;
+ project?: BitbucketProject;
+ repositories: BitbucketRepository[];
+ selectedRepository?: BitbucketRepository;
+ showingAllRepositories: boolean;
+}
+
+export default function BitbucketProjectAccordion(props: BitbucketProjectAccordionProps) {
+ const {
+ disableRepositories,
+ open,
+ project,
+ repositories,
+ selectedRepository,
+ showingAllRepositories,
+ } = props;
+
+ const repositoryCount = repositories.length;
+
+ const title = project?.name ?? translate('search_results');
+
+ return (
+ <BoxedGroupAccordion
+ className={classNames('big-spacer-bottom', {
+ open,
+ 'not-clickable': !props.onClick,
+ 'no-hover': !props.onClick,
+ })}
+ onClick={
+ props.onClick
+ ? props.onClick
+ : () => {
+ /* noop */
+ }
+ }
+ open={open}
+ title={<h3>{title}</h3>}
+ >
+ {open && (
+ <>
+ <div className="display-flex-wrap">
+ {repositoryCount === 0 && (
+ <Alert variant="warning">
+ <FormattedMessage
+ defaultMessage={translate('onboarding.create_project.no_bbs_repos')}
+ id="onboarding.create_project.no_bbs_repos"
+ values={{
+ link: (
+ <Link
+ to={{
+ pathname: '/projects/create',
+ search: queryToSearch({
+ mode: CreateProjectModes.BitbucketServer,
+ resetPat: 1,
+ }),
+ }}
+ >
+ {translate('onboarding.create_project.update_your_token')}
+ </Link>
+ ),
+ }}
+ />
+ </Alert>
+ )}
+
+ {repositories.map((repo) =>
+ repo.sqProjectKey ? (
+ <div
+ className="display-flex-start spacer-right spacer-bottom create-project-import-bbs-repo"
+ key={repo.id}
+ >
+ <CheckIcon className="spacer-right" fill={colors.green} size={14} />
+ <div className="overflow-hidden">
+ <div className="little-spacer-bottom">
+ <strong title={repo.name}>
+ <Link to={getProjectUrl(repo.sqProjectKey)}>{repo.name}</Link>
+ </strong>
+ </div>
+ <em>{translate('onboarding.create_project.repository_imported')}</em>
+ </div>
+ </div>
+ ) : (
+ <Radio
+ checked={selectedRepository?.id === repo.id}
+ className={classNames(
+ 'display-flex-start spacer-right spacer-bottom create-project-import-bbs-repo overflow-hidden',
+ {
+ disabled: disableRepositories,
+ }
+ )}
+ key={repo.id}
+ onCheck={() => props.onSelectRepository(repo)}
+ value={String(repo.id)}
+ >
+ <strong title={repo.name}>{repo.name}</strong>
+ </Radio>
+ )
+ )}
+ </div>
+
+ {!showingAllRepositories && repositoryCount > 0 && (
+ <Alert variant="warning">
+ {translateWithParameters(
+ 'onboarding.create_project.only_showing_X_first_repos',
+ repositoryCount
+ )}
+ </Alert>
+ )}
+ </>
+ )}
+ </BoxedGroupAccordion>
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 {
+ getBitbucketServerProjects,
+ getBitbucketServerRepositories,
+ importBitbucketServerProject,
+ searchForBitbucketServerRepositories,
+} from '../../../../api/alm-integrations';
+import { Location, Router } from '../../../../components/hoc/withRouter';
+import {
+ BitbucketProject,
+ BitbucketProjectRepositories,
+ BitbucketRepository,
+} from '../../../../types/alm-integration';
+import { AlmSettingsInstance } from '../../../../types/alm-settings';
+import { DEFAULT_BBS_PAGE_SIZE } from '../constants';
+import BitbucketCreateProjectRenderer from './BitbucketProjectCreateRenderer';
+
+interface Props {
+ canAdmin: boolean;
+ almInstances: AlmSettingsInstance[];
+ loadingBindings: boolean;
+ onProjectCreate: (projectKey: string) => void;
+ location: Location;
+ router: Router;
+}
+
+interface State {
+ selectedAlmInstance?: AlmSettingsInstance;
+ importing: boolean;
+ loading: boolean;
+ projects?: BitbucketProject[];
+ projectRepositories?: BitbucketProjectRepositories;
+ searching: boolean;
+ searchResults?: BitbucketRepository[];
+ selectedRepository?: BitbucketRepository;
+ showPersonalAccessTokenForm: boolean;
+}
+
+export default class BitbucketProjectCreate 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.
+ selectedAlmInstance: props.almInstances[0],
+ importing: false,
+ loading: false,
+ searching: false,
+ showPersonalAccessTokenForm: true,
+ };
+ }
+
+ componentDidMount() {
+ this.mounted = true;
+ }
+
+ componentDidUpdate(prevProps: Props) {
+ if (prevProps.almInstances.length === 0 && this.props.almInstances.length > 0) {
+ this.setState({ selectedAlmInstance: this.props.almInstances[0] }, () =>
+ this.fetchInitialData()
+ );
+ }
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ }
+
+ fetchInitialData = async () => {
+ const { showPersonalAccessTokenForm } = this.state;
+
+ if (!showPersonalAccessTokenForm) {
+ this.setState({ loading: true });
+ const 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({
+ projects,
+ projectRepositories,
+ loading: false,
+ });
+ }
+ }
+ };
+
+ fetchBitbucketProjects = (): Promise<BitbucketProject[] | undefined> => {
+ const { selectedAlmInstance } = this.state;
+
+ if (!selectedAlmInstance) {
+ return Promise.resolve(undefined);
+ }
+
+ return getBitbucketServerProjects(selectedAlmInstance.key).then(({ projects }) => projects);
+ };
+
+ fetchBitbucketRepositories = (
+ projects: BitbucketProject[]
+ ): Promise<BitbucketProjectRepositories | undefined> => {
+ const { selectedAlmInstance } = this.state;
+
+ if (!selectedAlmInstance) {
+ return Promise.resolve(undefined);
+ }
+
+ return Promise.all(
+ projects.map((p) => {
+ return getBitbucketServerRepositories(selectedAlmInstance.key, p.name).then(
+ ({ isLastPage, repositories }) => {
+ // Because the WS uses the project name rather than its key to find
+ // repositories, we can match more repositories than we expect. For
+ // example, p.name = "A1" would find repositories for projects "A1",
+ // "A10", "A11", etc. This is a limitation of BBS. To make sure we
+ // don't display incorrect information, filter on the project key.
+ const filteredRepositories = repositories.filter((r) => r.projectKey === p.key);
+
+ // And because of the above, the "isLastPage" cannot be relied upon
+ // either. This one is impossible to get 100% for now. We can only
+ // make some assumptions: by default, the page size for BBS is 25
+ // (this is not part of the payload, so we don't know the actual
+ // number; but changing this implies changing some advanced config,
+ // so it's not likely). If the filtered repos is larger than this
+ // number AND isLastPage is false, we'll keep it at false.
+ // Otherwise, we assume it's true.
+ const realIsLastPage =
+ isLastPage || filteredRepositories.length < DEFAULT_BBS_PAGE_SIZE;
+
+ return {
+ repositories: filteredRepositories,
+ isLastPage: realIsLastPage,
+ projectKey: p.key,
+ };
+ }
+ );
+ })
+ ).then((results) => {
+ return results.reduce(
+ (acc: BitbucketProjectRepositories, { isLastPage, projectKey, repositories }) => {
+ return { ...acc, [projectKey]: { allShown: isLastPage, repositories } };
+ },
+ {}
+ );
+ });
+ };
+
+ cleanUrl = () => {
+ const { location, router } = this.props;
+ delete location.query.resetPat;
+ router.replace(location);
+ };
+
+ handlePersonalAccessTokenCreated = async () => {
+ this.setState({ showPersonalAccessTokenForm: false });
+ this.cleanUrl();
+ await this.fetchInitialData();
+ };
+
+ handleImportRepository = () => {
+ const { selectedAlmInstance, selectedRepository } = this.state;
+
+ if (!selectedAlmInstance || !selectedRepository) {
+ return;
+ }
+
+ this.setState({ importing: true });
+ importBitbucketServerProject(
+ selectedAlmInstance.key,
+ selectedRepository.projectKey,
+ selectedRepository.slug
+ )
+ .then(({ project: { key } }) => {
+ if (this.mounted) {
+ this.setState({ importing: false });
+ this.props.onProjectCreate(key);
+ }
+ })
+ .catch(() => {
+ if (this.mounted) {
+ this.setState({ importing: false });
+ }
+ });
+ };
+
+ handleSearch = (query: string) => {
+ const { selectedAlmInstance } = this.state;
+
+ if (!selectedAlmInstance) {
+ return;
+ }
+
+ if (!query) {
+ this.setState({ searching: false, searchResults: undefined, selectedRepository: undefined });
+ return;
+ }
+
+ this.setState({ searching: true, selectedRepository: undefined });
+ searchForBitbucketServerRepositories(selectedAlmInstance.key, query)
+ .then(({ repositories }) => {
+ if (this.mounted) {
+ this.setState({ searching: false, searchResults: repositories });
+ }
+ })
+ .catch(() => {
+ if (this.mounted) {
+ this.setState({ searching: false });
+ }
+ });
+ };
+
+ handleSelectRepository = (selectedRepository: BitbucketRepository) => {
+ this.setState({ selectedRepository });
+ };
+
+ onSelectedAlmInstanceChange = (instance: AlmSettingsInstance) => {
+ this.setState({
+ selectedAlmInstance: instance,
+ showPersonalAccessTokenForm: true,
+ searching: false,
+ searchResults: undefined,
+ });
+ };
+
+ render() {
+ const { canAdmin, loadingBindings, location, almInstances } = this.props;
+ const {
+ selectedAlmInstance,
+ importing,
+ loading,
+ projectRepositories,
+ projects,
+ searching,
+ searchResults,
+ selectedRepository,
+ showPersonalAccessTokenForm,
+ } = this.state;
+
+ return (
+ <BitbucketCreateProjectRenderer
+ selectedAlmInstance={selectedAlmInstance}
+ almInstances={almInstances}
+ canAdmin={canAdmin}
+ importing={importing}
+ loading={loading || loadingBindings}
+ onImportRepository={this.handleImportRepository}
+ onPersonalAccessTokenCreated={this.handlePersonalAccessTokenCreated}
+ onSearch={this.handleSearch}
+ onSelectRepository={this.handleSelectRepository}
+ onSelectedAlmInstanceChange={this.onSelectedAlmInstanceChange}
+ projectRepositories={projectRepositories}
+ projects={projects}
+ resetPat={Boolean(location.query.resetPat)}
+ searchResults={searchResults}
+ searching={searching}
+ selectedRepository={selectedRepository}
+ showPersonalAccessTokenForm={
+ showPersonalAccessTokenForm || Boolean(location.query.resetPat)
+ }
+ />
+ );
+ }
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { Button } from '../../../../components/controls/buttons';
+import DeferredSpinner from '../../../../components/ui/DeferredSpinner';
+import { translate } from '../../../../helpers/l10n';
+import { getBaseUrl } from '../../../../helpers/system';
+import {
+ BitbucketProject,
+ BitbucketProjectRepositories,
+ BitbucketRepository,
+} from '../../../../types/alm-integration';
+import { AlmKeys, AlmSettingsInstance } from '../../../../types/alm-settings';
+import AlmSettingsInstanceDropdown from '../components/AlmSettingsInstanceDropdown';
+import CreateProjectPageHeader from '../components/CreateProjectPageHeader';
+import PersonalAccessTokenForm from '../components/PersonalAccessTokenForm';
+import WrongBindingCountAlert from '../components/WrongBindingCountAlert';
+import BitbucketImportRepositoryForm from './BitbucketImportRepositoryForm';
+
+export interface BitbucketProjectCreateRendererProps {
+ selectedAlmInstance?: AlmSettingsInstance;
+ almInstances: AlmSettingsInstance[];
+ canAdmin?: boolean;
+ importing: boolean;
+ loading: boolean;
+ onImportRepository: () => void;
+ onSearch: (query: string) => void;
+ onSelectRepository: (repo: BitbucketRepository) => void;
+ onPersonalAccessTokenCreated: () => void;
+ onSelectedAlmInstanceChange: (instance: AlmSettingsInstance) => void;
+ projects?: BitbucketProject[];
+ projectRepositories?: BitbucketProjectRepositories;
+ resetPat: boolean;
+ searching: boolean;
+ searchResults?: BitbucketRepository[];
+ selectedRepository?: BitbucketRepository;
+ showPersonalAccessTokenForm?: boolean;
+}
+
+export default function BitbucketProjectCreateRenderer(props: BitbucketProjectCreateRendererProps) {
+ const {
+ almInstances,
+ selectedAlmInstance,
+ canAdmin,
+ importing,
+ loading,
+ projects,
+ projectRepositories,
+ selectedRepository,
+ searching,
+ searchResults,
+ showPersonalAccessTokenForm,
+ resetPat,
+ } = props;
+
+ return (
+ <>
+ <CreateProjectPageHeader
+ additionalActions={
+ !showPersonalAccessTokenForm && (
+ <div className="display-flex-center pull-right">
+ <DeferredSpinner className="spacer-right" loading={importing} />
+ <Button
+ className="button-large button-primary"
+ disabled={!selectedRepository || importing}
+ onClick={props.onImportRepository}
+ >
+ {translate('onboarding.create_project.import_selected_repo')}
+ </Button>
+ </div>
+ )
+ }
+ title={
+ <span className="text-middle">
+ <img
+ alt="" // Should be ignored by screen readers
+ className="spacer-right"
+ height="24"
+ src={`${getBaseUrl()}/images/alm/bitbucket.svg`}
+ />
+ {translate('onboarding.create_project.from_bbs')}
+ </span>
+ }
+ />
+
+ <AlmSettingsInstanceDropdown
+ almKey={AlmKeys.BitbucketServer}
+ almInstances={almInstances}
+ selectedAlmInstance={selectedAlmInstance}
+ onChangeConfig={props.onSelectedAlmInstanceChange}
+ />
+
+ {loading && <i className="spinner" />}
+
+ {!loading && !selectedAlmInstance && (
+ <WrongBindingCountAlert alm={AlmKeys.BitbucketServer} canAdmin={!!canAdmin} />
+ )}
+
+ {!loading &&
+ selectedAlmInstance &&
+ (showPersonalAccessTokenForm ? (
+ <PersonalAccessTokenForm
+ almSetting={selectedAlmInstance}
+ onPersonalAccessTokenCreated={props.onPersonalAccessTokenCreated}
+ resetPat={resetPat}
+ />
+ ) : (
+ <BitbucketImportRepositoryForm
+ disableRepositories={importing}
+ onSearch={props.onSearch}
+ onSelectRepository={props.onSelectRepository}
+ projectRepositories={projectRepositories}
+ projects={projects}
+ searchResults={searchResults}
+ searching={searching}
+ selectedRepository={selectedRepository}
+ />
+ ))}
+ </>
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { uniq, without } from 'lodash';
+import * as React from 'react';
+import { ButtonLink } from '../../../../components/controls/buttons';
+import { translate } from '../../../../helpers/l10n';
+import {
+ BitbucketProject,
+ BitbucketProjectRepositories,
+ BitbucketRepository,
+} from '../../../../types/alm-integration';
+import BitbucketProjectAccordion from './BitbucketProjectAccordion';
+
+export interface BitbucketRepositoriesProps {
+ disableRepositories: boolean;
+ onSelectRepository: (repo: BitbucketRepository) => void;
+ projects: BitbucketProject[];
+ projectRepositories: BitbucketProjectRepositories;
+ selectedRepository?: BitbucketRepository;
+}
+
+export default function BitbucketRepositories(props: BitbucketRepositoriesProps) {
+ const { disableRepositories, projects, projectRepositories, selectedRepository } = props;
+
+ const [openProjectKeys, setOpenProjectKeys] = React.useState(
+ projects.length > 0 ? [projects[0].key] : []
+ );
+
+ const allAreExpanded = projects.length <= openProjectKeys.length;
+
+ const handleClick = (isOpen: boolean, projectKey: string) => {
+ setOpenProjectKeys(
+ isOpen ? without(openProjectKeys, projectKey) : uniq([...openProjectKeys, projectKey])
+ );
+ };
+
+ return (
+ <>
+ <div className="overflow-hidden spacer-bottom">
+ <ButtonLink
+ className="pull-right"
+ onClick={() => setOpenProjectKeys(allAreExpanded ? [] : projects.map((p) => p.key))}
+ >
+ {allAreExpanded ? translate('collapse_all') : translate('expand_all')}
+ </ButtonLink>
+ </div>
+
+ {projects.map((project) => {
+ const isOpen = openProjectKeys.includes(project.key);
+ const { allShown, repositories = [] } = projectRepositories[project.key] || {};
+
+ return (
+ <BitbucketProjectAccordion
+ disableRepositories={disableRepositories}
+ key={project.key}
+ onClick={() => handleClick(isOpen, project.key)}
+ onSelectRepository={props.onSelectRepository}
+ open={isOpen}
+ project={project}
+ repositories={repositories}
+ selectedRepository={selectedRepository}
+ showingAllRepositories={allShown}
+ />
+ );
+ })}
+ </>
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 '../../../../components/ui/Alert';
+import DeferredSpinner from '../../../../components/ui/DeferredSpinner';
+import { translate } from '../../../../helpers/l10n';
+import { BitbucketProject, BitbucketRepository } from '../../../../types/alm-integration';
+import BitbucketProjectAccordion from './BitbucketProjectAccordion';
+
+export interface BitbucketSearchResultsProps {
+ disableRepositories: boolean;
+ onSelectRepository: (repo: BitbucketRepository) => void;
+ projects: BitbucketProject[];
+ searching: boolean;
+ searchResults?: BitbucketRepository[];
+ selectedRepository?: BitbucketRepository;
+}
+
+export default function BitbucketSearchResults(props: BitbucketSearchResultsProps) {
+ const {
+ disableRepositories,
+ projects,
+ searching,
+ searchResults = [],
+ selectedRepository,
+ } = props;
+
+ if (searchResults.length === 0 && !searching) {
+ return (
+ <Alert className="big-spacer-top" variant="warning">
+ {translate('onboarding.create_project.no_bbs_repos.filter')}
+ </Alert>
+ );
+ }
+
+ const filteredProjects = projects.filter((p) =>
+ searchResults.some((r) => r.projectKey === p.key)
+ );
+ const filteredProjectKeys = filteredProjects.map((p) => p.key);
+ const filteredSearchResults = searchResults.filter(
+ (r) => !filteredProjectKeys.includes(r.projectKey)
+ );
+
+ return (
+ <div className="big-spacer-top">
+ <DeferredSpinner loading={searching}>
+ {filteredSearchResults.length > 0 && (
+ <BitbucketProjectAccordion
+ disableRepositories={disableRepositories}
+ onSelectRepository={props.onSelectRepository}
+ open={true}
+ repositories={filteredSearchResults}
+ selectedRepository={selectedRepository}
+ showingAllRepositories={true}
+ />
+ )}
+
+ {filteredProjects.map((project) => {
+ const repositories = searchResults.filter((r) => r.projectKey === project.key);
+
+ return (
+ <BitbucketProjectAccordion
+ disableRepositories={disableRepositories}
+ key={project.key}
+ onSelectRepository={props.onSelectRepository}
+ open={true}
+ project={project}
+ repositories={repositories}
+ selectedRepository={selectedRepository}
+ showingAllRepositories={true}
+ />
+ );
+ })}
+ </DeferredSpinner>
+ </div>
+ );
+}
import { AppState } from '../../../types/appstate';
import { Feature } from '../../../types/features';
import AlmBindingDefinitionForm from '../../settings/components/almIntegration/AlmBindingDefinitionForm';
-import AzureProjectCreate from './AzureProjectCreate';
-import BitbucketCloudProjectCreate from './BitbucketCloudProjectCreate';
-import BitbucketProjectCreate from './BitbucketProjectCreate';
+import AzureProjectCreate from './Azure/AzureProjectCreate';
+import BitbucketCloudProjectCreate from './BitbucketCloud/BitbucketCloudProjectCreate';
+import BitbucketProjectCreate from './BitbucketServer/BitbucketProjectCreate';
import CreateProjectModeSelection from './CreateProjectModeSelection';
-import GitHubProjectCreate from './GitHubProjectCreate';
-import GitlabProjectCreate from './GitlabProjectCreate';
-import ManualProjectCreate from './ManualProjectCreate';
+import GitHubProjectCreate from './Github/GitHubProjectCreate';
+import GitlabProjectCreate from './Gitlab/GitlabProjectCreate';
+import ManualProjectCreate from './manual/ManualProjectCreate';
import './style.css';
import { CreateProjectModes } from './types';
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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';
-
-export interface CreateProjectPageHeaderProps {
- additionalActions?: React.ReactNode;
- title: React.ReactNode;
-}
-
-export default function CreateProjectPageHeader(props: CreateProjectPageHeaderProps) {
- const { additionalActions, title } = props;
-
- return (
- <header className="huge-spacer-bottom bordered-bottom overflow-hidden">
- <h1 className="pull-left huge big-spacer-bottom">{title}</h1>
-
- {additionalActions}
- </header>
- );
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 { debounce } from 'lodash';
-import * as React from 'react';
-import { isWebUri } from 'valid-url';
-import {
- getGithubClientId,
- getGithubOrganizations,
- getGithubRepositories,
- importGithubRepository,
-} from '../../../api/alm-integrations';
-import { Location, Router } from '../../../components/hoc/withRouter';
-import { getHostUrl } from '../../../helpers/urls';
-import { GithubOrganization, GithubRepository } from '../../../types/alm-integration';
-import { AlmKeys, AlmSettingsInstance } from '../../../types/alm-settings';
-import { Paging } from '../../../types/types';
-import GitHubProjectCreateRenderer from './GitHubProjectCreateRenderer';
-
-interface Props {
- canAdmin: boolean;
- loadingBindings: boolean;
- onProjectCreate: (projectKey: string) => void;
- almInstances: AlmSettingsInstance[];
- location: Location;
- router: Router;
-}
-
-interface State {
- error: boolean;
- importing: boolean;
- loadingOrganizations: boolean;
- loadingRepositories: boolean;
- organizations: GithubOrganization[];
- repositoryPaging: Paging;
- repositories: GithubRepository[];
- searchQuery: string;
- selectedOrganization?: GithubOrganization;
- selectedRepository?: GithubRepository;
- selectedAlmInstance?: AlmSettingsInstance;
-}
-
-const REPOSITORY_PAGE_SIZE = 30;
-
-export default class GitHubProjectCreate extends React.Component<Props, State> {
- mounted = false;
-
- constructor(props: Props) {
- super(props);
-
- this.state = {
- error: false,
- importing: false,
- loadingOrganizations: true,
- loadingRepositories: false,
- organizations: [],
- repositories: [],
- repositoryPaging: { pageSize: REPOSITORY_PAGE_SIZE, total: 0, pageIndex: 1 },
- searchQuery: '',
- selectedAlmInstance: this.getInitialSelectedAlmInstance(),
- };
-
- this.triggerSearch = debounce(this.triggerSearch, 250);
- }
-
- componentDidMount() {
- this.mounted = true;
- this.initialize();
- }
-
- componentDidUpdate(prevProps: Props) {
- if (prevProps.almInstances.length === 0 && this.props.almInstances.length > 0) {
- this.setState({ selectedAlmInstance: this.getInitialSelectedAlmInstance() }, () =>
- this.initialize()
- );
- }
- }
-
- componentWillUnmount() {
- this.mounted = false;
- }
-
- getInitialSelectedAlmInstance() {
- const {
- location: {
- query: { almInstance: selectedAlmInstanceKey },
- },
- almInstances,
- } = this.props;
- const selectedAlmInstance = almInstances.find(
- (instance) => instance.key === selectedAlmInstanceKey
- );
- if (selectedAlmInstance) {
- return selectedAlmInstance;
- }
- return this.props.almInstances.length > 1 ? undefined : this.props.almInstances[0];
- }
-
- async initialize() {
- const { location, router } = this.props;
- const { selectedAlmInstance } = this.state;
- if (!selectedAlmInstance || !selectedAlmInstance.url) {
- this.setState({ error: true });
- return;
- }
- this.setState({ error: false });
-
- const code = location.query?.code;
- try {
- if (!code) {
- await this.redirectToGithub(selectedAlmInstance);
- } else {
- delete location.query.code;
- router.replace(location);
- await this.fetchOrganizations(selectedAlmInstance, code);
- }
- } catch (e) {
- if (this.mounted) {
- this.setState({ error: true });
- }
- }
- }
-
- async redirectToGithub(selectedAlmInstance: AlmSettingsInstance) {
- if (!selectedAlmInstance.url) {
- return;
- }
-
- const { clientId } = await getGithubClientId(selectedAlmInstance.key);
-
- if (!clientId) {
- this.setState({ error: true });
- return;
- }
- const queryParams = [
- { param: 'client_id', value: clientId },
- {
- param: 'redirect_uri',
- value: encodeURIComponent(
- `${getHostUrl()}/projects/create?mode=${AlmKeys.GitHub}&almInstance=${
- selectedAlmInstance.key
- }`
- ),
- },
- ]
- .map(({ param, value }) => `${param}=${value}`)
- .join('&');
-
- let instanceRootUrl;
- // Strip the api section from the url, since we're not hitting the api here.
- if (selectedAlmInstance.url.includes('/api/v3')) {
- // GitHub Enterprise
- instanceRootUrl = selectedAlmInstance.url.replace('/api/v3', '');
- } else {
- // github.com
- instanceRootUrl = selectedAlmInstance.url.replace('api.', '');
- }
-
- // strip the trailing /
- instanceRootUrl = instanceRootUrl.replace(/\/$/, '');
- if (!isWebUri(instanceRootUrl)) {
- this.setState({ error: true });
- } else {
- window.location.replace(`${instanceRootUrl}/login/oauth/authorize?${queryParams}`);
- }
- }
-
- async fetchOrganizations(selectedAlmInstance: AlmSettingsInstance, token: string) {
- const { organizations } = await getGithubOrganizations(selectedAlmInstance.key, token);
-
- if (this.mounted) {
- this.setState({ loadingOrganizations: false, organizations });
- }
- }
-
- async fetchRepositories(params: { organizationKey: string; page?: number; query?: string }) {
- const { organizationKey, page = 1, query } = params;
- const { selectedAlmInstance } = this.state;
-
- if (!selectedAlmInstance) {
- this.setState({ error: true });
- return;
- }
-
- this.setState({ loadingRepositories: true });
-
- try {
- const data = await getGithubRepositories({
- almSetting: selectedAlmInstance.key,
- organization: organizationKey,
- pageSize: REPOSITORY_PAGE_SIZE,
- page,
- query,
- });
-
- if (this.mounted) {
- this.setState(({ repositories }) => ({
- loadingRepositories: false,
- repositoryPaging: data.paging,
- repositories: page === 1 ? data.repositories : [...repositories, ...data.repositories],
- }));
- }
- } catch (_) {
- if (this.mounted) {
- this.setState({
- loadingRepositories: false,
- repositoryPaging: { pageIndex: 1, pageSize: REPOSITORY_PAGE_SIZE, total: 0 },
- repositories: [],
- });
- }
- }
- }
-
- triggerSearch = (query: string) => {
- const { selectedOrganization } = this.state;
- if (selectedOrganization) {
- this.setState({ selectedRepository: undefined });
- this.fetchRepositories({ organizationKey: selectedOrganization.key, query });
- }
- };
-
- handleSelectOrganization = (key: string) => {
- this.setState(({ organizations }) => ({
- searchQuery: '',
- selectedRepository: undefined,
- selectedOrganization: organizations.find((o) => o.key === key),
- }));
- this.fetchRepositories({ organizationKey: key });
- };
-
- handleSelectRepository = (key: string) => {
- this.setState(({ repositories }) => ({
- selectedRepository: repositories?.find((r) => r.key === key),
- }));
- };
-
- handleSearch = (searchQuery: string) => {
- this.setState({ searchQuery });
- this.triggerSearch(searchQuery);
- };
-
- handleLoadMore = () => {
- const { repositoryPaging, searchQuery, selectedOrganization } = this.state;
-
- if (selectedOrganization) {
- this.fetchRepositories({
- organizationKey: selectedOrganization.key,
- page: repositoryPaging.pageIndex + 1,
- query: searchQuery,
- });
- }
- };
-
- handleImportRepository = async () => {
- const { selectedOrganization, selectedRepository, selectedAlmInstance } = this.state;
-
- if (selectedAlmInstance && selectedOrganization && selectedRepository) {
- this.setState({ importing: true });
-
- try {
- const { project } = await importGithubRepository(
- selectedAlmInstance.key,
- selectedOrganization.key,
- selectedRepository.key
- );
-
- this.props.onProjectCreate(project.key);
- } finally {
- if (this.mounted) {
- this.setState({ importing: false });
- }
- }
- }
- };
-
- onSelectedAlmInstanceChange = (instance: AlmSettingsInstance) => {
- this.setState(
- { selectedAlmInstance: instance, searchQuery: '', organizations: [], repositories: [] },
- () => this.initialize()
- );
- };
-
- render() {
- const { canAdmin, loadingBindings, almInstances } = this.props;
- const {
- error,
- importing,
- loadingOrganizations,
- loadingRepositories,
- organizations,
- repositoryPaging,
- repositories,
- searchQuery,
- selectedOrganization,
- selectedRepository,
- selectedAlmInstance,
- } = this.state;
-
- return (
- <GitHubProjectCreateRenderer
- canAdmin={canAdmin}
- error={error}
- importing={importing}
- loadingBindings={loadingBindings}
- loadingOrganizations={loadingOrganizations}
- loadingRepositories={loadingRepositories}
- onImportRepository={this.handleImportRepository}
- onLoadMore={this.handleLoadMore}
- onSearch={this.handleSearch}
- onSelectOrganization={this.handleSelectOrganization}
- onSelectRepository={this.handleSelectRepository}
- organizations={organizations}
- repositoryPaging={repositoryPaging}
- searchQuery={searchQuery}
- repositories={repositories}
- selectedOrganization={selectedOrganization}
- selectedRepository={selectedRepository}
- almInstances={almInstances}
- selectedAlmInstance={selectedAlmInstance}
- onSelectedAlmInstanceChange={this.onSelectedAlmInstanceChange}
- />
- );
- }
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 react/no-unused-prop-types */
-
-import * as React from 'react';
-import { FormattedMessage } from 'react-intl';
-import { colors } from '../../../app/theme';
-import Link from '../../../components/common/Link';
-import { Button } from '../../../components/controls/buttons';
-import ListFooter from '../../../components/controls/ListFooter';
-import Radio from '../../../components/controls/Radio';
-import SearchBox from '../../../components/controls/SearchBox';
-import Select, { LabelValueSelectOption } from '../../../components/controls/Select';
-import CheckIcon from '../../../components/icons/CheckIcon';
-import QualifierIcon from '../../../components/icons/QualifierIcon';
-import { Alert } from '../../../components/ui/Alert';
-import DeferredSpinner from '../../../components/ui/DeferredSpinner';
-import { translate } from '../../../helpers/l10n';
-import { getBaseUrl } from '../../../helpers/system';
-import { getProjectUrl } from '../../../helpers/urls';
-import { GithubOrganization, GithubRepository } from '../../../types/alm-integration';
-import { AlmKeys, AlmSettingsInstance } from '../../../types/alm-settings';
-import { ComponentQualifier } from '../../../types/component';
-import { Paging } from '../../../types/types';
-import AlmSettingsInstanceDropdown from './AlmSettingsInstanceDropdown';
-import CreateProjectPageHeader from './CreateProjectPageHeader';
-
-export interface GitHubProjectCreateRendererProps {
- canAdmin: boolean;
- error: boolean;
- importing: boolean;
- loadingBindings: boolean;
- loadingOrganizations: boolean;
- loadingRepositories: boolean;
- onImportRepository: () => void;
- onLoadMore: () => void;
- onSearch: (q: string) => void;
- onSelectOrganization: (key: string) => void;
- onSelectRepository: (key: string) => void;
- organizations: GithubOrganization[];
- repositories?: GithubRepository[];
- repositoryPaging: Paging;
- searchQuery: string;
- selectedOrganization?: GithubOrganization;
- selectedRepository?: GithubRepository;
- almInstances: AlmSettingsInstance[];
- selectedAlmInstance?: AlmSettingsInstance;
- onSelectedAlmInstanceChange: (instance: AlmSettingsInstance) => void;
-}
-
-function orgToOption({ key, name }: GithubOrganization) {
- return { value: key, label: name };
-}
-
-function renderRepositoryList(props: GitHubProjectCreateRendererProps) {
- const {
- importing,
- loadingRepositories,
- repositories,
- repositoryPaging,
- searchQuery,
- selectedOrganization,
- selectedRepository,
- } = props;
-
- const isChecked = (repository: GithubRepository) =>
- !!repository.sqProjectKey ||
- (!!selectedRepository && selectedRepository.key === repository.key);
-
- const isDisabled = (repository: GithubRepository) =>
- !!repository.sqProjectKey || loadingRepositories || importing;
-
- return (
- selectedOrganization &&
- repositories && (
- <div className="boxed-group padded display-flex-wrap">
- <div className="width-100">
- <SearchBox
- className="big-spacer-bottom"
- onChange={props.onSearch}
- placeholder={translate('onboarding.create_project.search_repositories')}
- value={searchQuery}
- />
- </div>
-
- {repositories.length === 0 ? (
- <div className="padded">
- <DeferredSpinner loading={loadingRepositories}>
- {translate('no_results')}
- </DeferredSpinner>
- </div>
- ) : (
- repositories.map((r) => (
- <Radio
- className="spacer-top spacer-bottom padded create-project-github-repository"
- key={r.key}
- checked={isChecked(r)}
- disabled={isDisabled(r)}
- value={r.key}
- onCheck={props.onSelectRepository}
- >
- <div className="big overflow-hidden max-width-100" title={r.name}>
- <div className="text-ellipsis">
- {r.sqProjectKey ? (
- <div className="display-flex-center max-width-100">
- <Link
- className="display-flex-center max-width-60"
- to={getProjectUrl(r.sqProjectKey)}
- >
- <QualifierIcon
- className="spacer-right"
- qualifier={ComponentQualifier.Project}
- />
- <span className="text-ellipsis">{r.name}</span>
- </Link>
- <em className="display-flex-center small big-spacer-left flex-0">
- <span className="text-muted-2">
- {translate('onboarding.create_project.repository_imported')}
- </span>
- <CheckIcon className="little-spacer-left" size={12} fill={colors.green} />
- </em>
- </div>
- ) : (
- r.name
- )}
- </div>
- {r.url && (
- <a
- className="notice small display-flex-center little-spacer-top"
- onClick={(e) => e.stopPropagation()}
- target="_blank"
- href={r.url}
- rel="noopener noreferrer"
- >
- {translate('onboarding.create_project.see_on_github')}
- </a>
- )}
- </div>
- </Radio>
- ))
- )}
-
- <div className="display-flex-justify-center width-100">
- <ListFooter
- count={repositories.length}
- total={repositoryPaging.total}
- loadMore={props.onLoadMore}
- loading={loadingRepositories}
- />
- </div>
- </div>
- )
- );
-}
-
-export default function GitHubProjectCreateRenderer(props: GitHubProjectCreateRendererProps) {
- const {
- canAdmin,
- error,
- importing,
- loadingBindings,
- loadingOrganizations,
- organizations,
- selectedOrganization,
- selectedRepository,
- almInstances,
- selectedAlmInstance,
- } = props;
-
- if (loadingBindings) {
- return <DeferredSpinner />;
- }
-
- return (
- <div>
- <CreateProjectPageHeader
- additionalActions={
- selectedOrganization && (
- <div className="display-flex-center pull-right">
- <DeferredSpinner className="spacer-right" loading={importing} />
- <Button
- className="button-large button-primary"
- disabled={!selectedRepository || importing}
- onClick={props.onImportRepository}
- >
- {translate('onboarding.create_project.import_selected_repo')}
- </Button>
- </div>
- )
- }
- title={
- <span className="text-middle display-flex-center">
- <img
- alt="" // Should be ignored by screen readers
- className="spacer-right"
- height={24}
- src={`${getBaseUrl()}/images/alm/github.svg`}
- />
- {translate('onboarding.create_project.github.title')}
- </span>
- }
- />
-
- <AlmSettingsInstanceDropdown
- almKey={AlmKeys.GitHub}
- almInstances={almInstances}
- selectedAlmInstance={selectedAlmInstance}
- onChangeConfig={props.onSelectedAlmInstanceChange}
- />
-
- {error && selectedAlmInstance && (
- <div className="display-flex-justify-center">
- <div className="boxed-group padded width-50 huge-spacer-top">
- <h2 className="big-spacer-bottom">
- {translate('onboarding.create_project.github.warning.title')}
- </h2>
- <Alert variant="warning">
- {canAdmin ? (
- <FormattedMessage
- id="onboarding.create_project.github.warning.message_admin"
- defaultMessage={translate(
- 'onboarding.create_project.github.warning.message_admin'
- )}
- values={{
- link: (
- <Link to="/admin/settings?category=almintegration">
- {translate('onboarding.create_project.github.warning.message_admin.link')}
- </Link>
- ),
- }}
- />
- ) : (
- translate('onboarding.create_project.github.warning.message')
- )}
- </Alert>
- </div>
- </div>
- )}
-
- {!error && (
- <DeferredSpinner loading={loadingOrganizations}>
- <div className="form-field">
- <label htmlFor="github-choose-organization">
- {translate('onboarding.create_project.github.choose_organization')}
- </label>
- {organizations.length > 0 ? (
- <Select
- inputId="github-choose-organization"
- className="input-super-large"
- options={organizations.map(orgToOption)}
- onChange={({ value }: LabelValueSelectOption) => props.onSelectOrganization(value)}
- value={selectedOrganization ? orgToOption(selectedOrganization) : null}
- />
- ) : (
- !loadingOrganizations && (
- <Alert className="spacer-top" variant="error">
- {canAdmin ? (
- <FormattedMessage
- id="onboarding.create_project.github.no_orgs_admin"
- defaultMessage={translate('onboarding.create_project.github.no_orgs_admin')}
- values={{
- link: (
- <Link to="/admin/settings?category=almintegration">
- {translate(
- 'onboarding.create_project.github.warning.message_admin.link'
- )}
- </Link>
- ),
- }}
- />
- ) : (
- translate('onboarding.create_project.github.no_orgs')
- )}
- </Alert>
- )
- )}
- </div>
- </DeferredSpinner>
- )}
-
- {renderRepositoryList(props)}
- </div>
- );
-}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { debounce } from 'lodash';
+import * as React from 'react';
+import { isWebUri } from 'valid-url';
+import {
+ getGithubClientId,
+ getGithubOrganizations,
+ getGithubRepositories,
+ importGithubRepository,
+} from '../../../../api/alm-integrations';
+import { Location, Router } from '../../../../components/hoc/withRouter';
+import { getHostUrl } from '../../../../helpers/urls';
+import { GithubOrganization, GithubRepository } from '../../../../types/alm-integration';
+import { AlmKeys, AlmSettingsInstance } from '../../../../types/alm-settings';
+import { Paging } from '../../../../types/types';
+import GitHubProjectCreateRenderer from './GitHubProjectCreateRenderer';
+
+interface Props {
+ canAdmin: boolean;
+ loadingBindings: boolean;
+ onProjectCreate: (projectKey: string) => void;
+ almInstances: AlmSettingsInstance[];
+ location: Location;
+ router: Router;
+}
+
+interface State {
+ error: boolean;
+ importing: boolean;
+ loadingOrganizations: boolean;
+ loadingRepositories: boolean;
+ organizations: GithubOrganization[];
+ repositoryPaging: Paging;
+ repositories: GithubRepository[];
+ searchQuery: string;
+ selectedOrganization?: GithubOrganization;
+ selectedRepository?: GithubRepository;
+ selectedAlmInstance?: AlmSettingsInstance;
+}
+
+const REPOSITORY_PAGE_SIZE = 30;
+
+export default class GitHubProjectCreate extends React.Component<Props, State> {
+ mounted = false;
+
+ constructor(props: Props) {
+ super(props);
+
+ this.state = {
+ error: false,
+ importing: false,
+ loadingOrganizations: true,
+ loadingRepositories: false,
+ organizations: [],
+ repositories: [],
+ repositoryPaging: { pageSize: REPOSITORY_PAGE_SIZE, total: 0, pageIndex: 1 },
+ searchQuery: '',
+ selectedAlmInstance: this.getInitialSelectedAlmInstance(),
+ };
+
+ this.triggerSearch = debounce(this.triggerSearch, 250);
+ }
+
+ componentDidMount() {
+ this.mounted = true;
+ this.initialize();
+ }
+
+ componentDidUpdate(prevProps: Props) {
+ if (prevProps.almInstances.length === 0 && this.props.almInstances.length > 0) {
+ this.setState({ selectedAlmInstance: this.getInitialSelectedAlmInstance() }, () =>
+ this.initialize()
+ );
+ }
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ }
+
+ getInitialSelectedAlmInstance() {
+ const {
+ location: {
+ query: { almInstance: selectedAlmInstanceKey },
+ },
+ almInstances,
+ } = this.props;
+ const selectedAlmInstance = almInstances.find(
+ (instance) => instance.key === selectedAlmInstanceKey
+ );
+ if (selectedAlmInstance) {
+ return selectedAlmInstance;
+ }
+ return this.props.almInstances.length > 1 ? undefined : this.props.almInstances[0];
+ }
+
+ async initialize() {
+ const { location, router } = this.props;
+ const { selectedAlmInstance } = this.state;
+ if (!selectedAlmInstance || !selectedAlmInstance.url) {
+ this.setState({ error: true });
+ return;
+ }
+ this.setState({ error: false });
+
+ const code = location.query?.code;
+ try {
+ if (!code) {
+ await this.redirectToGithub(selectedAlmInstance);
+ } else {
+ delete location.query.code;
+ router.replace(location);
+ await this.fetchOrganizations(selectedAlmInstance, code);
+ }
+ } catch (e) {
+ if (this.mounted) {
+ this.setState({ error: true });
+ }
+ }
+ }
+
+ async redirectToGithub(selectedAlmInstance: AlmSettingsInstance) {
+ if (!selectedAlmInstance.url) {
+ return;
+ }
+
+ const { clientId } = await getGithubClientId(selectedAlmInstance.key);
+
+ if (!clientId) {
+ this.setState({ error: true });
+ return;
+ }
+ const queryParams = [
+ { param: 'client_id', value: clientId },
+ {
+ param: 'redirect_uri',
+ value: encodeURIComponent(
+ `${getHostUrl()}/projects/create?mode=${AlmKeys.GitHub}&almInstance=${
+ selectedAlmInstance.key
+ }`
+ ),
+ },
+ ]
+ .map(({ param, value }) => `${param}=${value}`)
+ .join('&');
+
+ let instanceRootUrl;
+ // Strip the api section from the url, since we're not hitting the api here.
+ if (selectedAlmInstance.url.includes('/api/v3')) {
+ // GitHub Enterprise
+ instanceRootUrl = selectedAlmInstance.url.replace('/api/v3', '');
+ } else {
+ // github.com
+ instanceRootUrl = selectedAlmInstance.url.replace('api.', '');
+ }
+
+ // strip the trailing /
+ instanceRootUrl = instanceRootUrl.replace(/\/$/, '');
+ if (!isWebUri(instanceRootUrl)) {
+ this.setState({ error: true });
+ } else {
+ window.location.replace(`${instanceRootUrl}/login/oauth/authorize?${queryParams}`);
+ }
+ }
+
+ async fetchOrganizations(selectedAlmInstance: AlmSettingsInstance, token: string) {
+ const { organizations } = await getGithubOrganizations(selectedAlmInstance.key, token);
+
+ if (this.mounted) {
+ this.setState({ loadingOrganizations: false, organizations });
+ }
+ }
+
+ async fetchRepositories(params: { organizationKey: string; page?: number; query?: string }) {
+ const { organizationKey, page = 1, query } = params;
+ const { selectedAlmInstance } = this.state;
+
+ if (!selectedAlmInstance) {
+ this.setState({ error: true });
+ return;
+ }
+
+ this.setState({ loadingRepositories: true });
+
+ try {
+ const data = await getGithubRepositories({
+ almSetting: selectedAlmInstance.key,
+ organization: organizationKey,
+ pageSize: REPOSITORY_PAGE_SIZE,
+ page,
+ query,
+ });
+
+ if (this.mounted) {
+ this.setState(({ repositories }) => ({
+ loadingRepositories: false,
+ repositoryPaging: data.paging,
+ repositories: page === 1 ? data.repositories : [...repositories, ...data.repositories],
+ }));
+ }
+ } catch (_) {
+ if (this.mounted) {
+ this.setState({
+ loadingRepositories: false,
+ repositoryPaging: { pageIndex: 1, pageSize: REPOSITORY_PAGE_SIZE, total: 0 },
+ repositories: [],
+ });
+ }
+ }
+ }
+
+ triggerSearch = (query: string) => {
+ const { selectedOrganization } = this.state;
+ if (selectedOrganization) {
+ this.setState({ selectedRepository: undefined });
+ this.fetchRepositories({ organizationKey: selectedOrganization.key, query });
+ }
+ };
+
+ handleSelectOrganization = (key: string) => {
+ this.setState(({ organizations }) => ({
+ searchQuery: '',
+ selectedRepository: undefined,
+ selectedOrganization: organizations.find((o) => o.key === key),
+ }));
+ this.fetchRepositories({ organizationKey: key });
+ };
+
+ handleSelectRepository = (key: string) => {
+ this.setState(({ repositories }) => ({
+ selectedRepository: repositories?.find((r) => r.key === key),
+ }));
+ };
+
+ handleSearch = (searchQuery: string) => {
+ this.setState({ searchQuery });
+ this.triggerSearch(searchQuery);
+ };
+
+ handleLoadMore = () => {
+ const { repositoryPaging, searchQuery, selectedOrganization } = this.state;
+
+ if (selectedOrganization) {
+ this.fetchRepositories({
+ organizationKey: selectedOrganization.key,
+ page: repositoryPaging.pageIndex + 1,
+ query: searchQuery,
+ });
+ }
+ };
+
+ handleImportRepository = async () => {
+ const { selectedOrganization, selectedRepository, selectedAlmInstance } = this.state;
+
+ if (selectedAlmInstance && selectedOrganization && selectedRepository) {
+ this.setState({ importing: true });
+
+ try {
+ const { project } = await importGithubRepository(
+ selectedAlmInstance.key,
+ selectedOrganization.key,
+ selectedRepository.key
+ );
+
+ this.props.onProjectCreate(project.key);
+ } finally {
+ if (this.mounted) {
+ this.setState({ importing: false });
+ }
+ }
+ }
+ };
+
+ onSelectedAlmInstanceChange = (instance: AlmSettingsInstance) => {
+ this.setState(
+ { selectedAlmInstance: instance, searchQuery: '', organizations: [], repositories: [] },
+ () => this.initialize()
+ );
+ };
+
+ render() {
+ const { canAdmin, loadingBindings, almInstances } = this.props;
+ const {
+ error,
+ importing,
+ loadingOrganizations,
+ loadingRepositories,
+ organizations,
+ repositoryPaging,
+ repositories,
+ searchQuery,
+ selectedOrganization,
+ selectedRepository,
+ selectedAlmInstance,
+ } = this.state;
+
+ return (
+ <GitHubProjectCreateRenderer
+ canAdmin={canAdmin}
+ error={error}
+ importing={importing}
+ loadingBindings={loadingBindings}
+ loadingOrganizations={loadingOrganizations}
+ loadingRepositories={loadingRepositories}
+ onImportRepository={this.handleImportRepository}
+ onLoadMore={this.handleLoadMore}
+ onSearch={this.handleSearch}
+ onSelectOrganization={this.handleSelectOrganization}
+ onSelectRepository={this.handleSelectRepository}
+ organizations={organizations}
+ repositoryPaging={repositoryPaging}
+ searchQuery={searchQuery}
+ repositories={repositories}
+ selectedOrganization={selectedOrganization}
+ selectedRepository={selectedRepository}
+ almInstances={almInstances}
+ selectedAlmInstance={selectedAlmInstance}
+ onSelectedAlmInstanceChange={this.onSelectedAlmInstanceChange}
+ />
+ );
+ }
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 react/no-unused-prop-types */
+
+import * as React from 'react';
+import { FormattedMessage } from 'react-intl';
+import { colors } from '../../../../app/theme';
+import Link from '../../../../components/common/Link';
+import ListFooter from '../../../../components/controls/ListFooter';
+import Radio from '../../../../components/controls/Radio';
+import SearchBox from '../../../../components/controls/SearchBox';
+import Select, { LabelValueSelectOption } from '../../../../components/controls/Select';
+import { Button } from '../../../../components/controls/buttons';
+import CheckIcon from '../../../../components/icons/CheckIcon';
+import QualifierIcon from '../../../../components/icons/QualifierIcon';
+import { Alert } from '../../../../components/ui/Alert';
+import DeferredSpinner from '../../../../components/ui/DeferredSpinner';
+import { translate } from '../../../../helpers/l10n';
+import { getBaseUrl } from '../../../../helpers/system';
+import { getProjectUrl } from '../../../../helpers/urls';
+import { GithubOrganization, GithubRepository } from '../../../../types/alm-integration';
+import { AlmKeys, AlmSettingsInstance } from '../../../../types/alm-settings';
+import { ComponentQualifier } from '../../../../types/component';
+import { Paging } from '../../../../types/types';
+import AlmSettingsInstanceDropdown from '../components/AlmSettingsInstanceDropdown';
+import CreateProjectPageHeader from '../components/CreateProjectPageHeader';
+import InstanceNewCodeDefinitionComplianceWarning from '../components/InstanceNewCodeDefinitionComplianceWarning';
+
+export interface GitHubProjectCreateRendererProps {
+ canAdmin: boolean;
+ error: boolean;
+ importing: boolean;
+ loadingBindings: boolean;
+ loadingOrganizations: boolean;
+ loadingRepositories: boolean;
+ onImportRepository: () => void;
+ onLoadMore: () => void;
+ onSearch: (q: string) => void;
+ onSelectOrganization: (key: string) => void;
+ onSelectRepository: (key: string) => void;
+ organizations: GithubOrganization[];
+ repositories?: GithubRepository[];
+ repositoryPaging: Paging;
+ searchQuery: string;
+ selectedOrganization?: GithubOrganization;
+ selectedRepository?: GithubRepository;
+ almInstances: AlmSettingsInstance[];
+ selectedAlmInstance?: AlmSettingsInstance;
+ onSelectedAlmInstanceChange: (instance: AlmSettingsInstance) => void;
+}
+
+function orgToOption({ key, name }: GithubOrganization) {
+ return { value: key, label: name };
+}
+
+function renderRepositoryList(props: GitHubProjectCreateRendererProps) {
+ const {
+ importing,
+ loadingRepositories,
+ repositories,
+ repositoryPaging,
+ searchQuery,
+ selectedOrganization,
+ selectedRepository,
+ } = props;
+
+ const isChecked = (repository: GithubRepository) =>
+ !!repository.sqProjectKey ||
+ (!!selectedRepository && selectedRepository.key === repository.key);
+
+ const isDisabled = (repository: GithubRepository) =>
+ !!repository.sqProjectKey || loadingRepositories || importing;
+
+ return (
+ selectedOrganization &&
+ repositories && (
+ <div className="boxed-group padded display-flex-wrap">
+ <div className="width-100">
+ <SearchBox
+ className="big-spacer-bottom"
+ onChange={props.onSearch}
+ placeholder={translate('onboarding.create_project.search_repositories')}
+ value={searchQuery}
+ />
+ </div>
+
+ {repositories.length === 0 ? (
+ <div className="padded">
+ <DeferredSpinner loading={loadingRepositories}>
+ {translate('no_results')}
+ </DeferredSpinner>
+ </div>
+ ) : (
+ repositories.map((r) => (
+ <Radio
+ className="spacer-top spacer-bottom padded create-project-github-repository"
+ key={r.key}
+ checked={isChecked(r)}
+ disabled={isDisabled(r)}
+ value={r.key}
+ onCheck={props.onSelectRepository}
+ >
+ <div className="big overflow-hidden max-width-100" title={r.name}>
+ <div className="text-ellipsis">
+ {r.sqProjectKey ? (
+ <div className="display-flex-center max-width-100">
+ <Link
+ className="display-flex-center max-width-60"
+ to={getProjectUrl(r.sqProjectKey)}
+ >
+ <QualifierIcon
+ className="spacer-right"
+ qualifier={ComponentQualifier.Project}
+ />
+ <span className="text-ellipsis">{r.name}</span>
+ </Link>
+ <em className="display-flex-center small big-spacer-left flex-0">
+ <span className="text-muted-2">
+ {translate('onboarding.create_project.repository_imported')}
+ </span>
+ <CheckIcon className="little-spacer-left" size={12} fill={colors.green} />
+ </em>
+ </div>
+ ) : (
+ r.name
+ )}
+ </div>
+ {r.url && (
+ <a
+ className="notice small display-flex-center little-spacer-top"
+ onClick={(e) => e.stopPropagation()}
+ target="_blank"
+ href={r.url}
+ rel="noopener noreferrer"
+ >
+ {translate('onboarding.create_project.see_on_github')}
+ </a>
+ )}
+ </div>
+ </Radio>
+ ))
+ )}
+
+ <div className="display-flex-justify-center width-100">
+ <ListFooter
+ count={repositories.length}
+ total={repositoryPaging.total}
+ loadMore={props.onLoadMore}
+ loading={loadingRepositories}
+ />
+ </div>
+ </div>
+ )
+ );
+}
+
+export default function GitHubProjectCreateRenderer(props: GitHubProjectCreateRendererProps) {
+ const {
+ canAdmin,
+ error,
+ importing,
+ loadingBindings,
+ loadingOrganizations,
+ organizations,
+ selectedOrganization,
+ selectedRepository,
+ almInstances,
+ selectedAlmInstance,
+ } = props;
+
+ if (loadingBindings) {
+ return <DeferredSpinner />;
+ }
+
+ return (
+ <div>
+ <CreateProjectPageHeader
+ additionalActions={
+ selectedOrganization && (
+ <div className="display-flex-center pull-right">
+ <DeferredSpinner className="spacer-right" loading={importing} />
+ <Button
+ className="button-large button-primary"
+ disabled={!selectedRepository || importing}
+ onClick={props.onImportRepository}
+ >
+ {translate('onboarding.create_project.import_selected_repo')}
+ </Button>
+ </div>
+ )
+ }
+ title={
+ <span className="text-middle display-flex-center">
+ <img
+ alt="" // Should be ignored by screen readers
+ className="spacer-right"
+ height={24}
+ src={`${getBaseUrl()}/images/alm/github.svg`}
+ />
+ {translate('onboarding.create_project.github.title')}
+ </span>
+ }
+ />
+
+ <AlmSettingsInstanceDropdown
+ almKey={AlmKeys.GitHub}
+ almInstances={almInstances}
+ selectedAlmInstance={selectedAlmInstance}
+ onChangeConfig={props.onSelectedAlmInstanceChange}
+ />
+
+ {error && selectedAlmInstance && (
+ <div className="display-flex-justify-center">
+ <div className="boxed-group padded width-50 huge-spacer-top">
+ <h2 className="big-spacer-bottom">
+ {translate('onboarding.create_project.github.warning.title')}
+ </h2>
+ <Alert variant="warning">
+ {canAdmin ? (
+ <FormattedMessage
+ id="onboarding.create_project.github.warning.message_admin"
+ defaultMessage={translate(
+ 'onboarding.create_project.github.warning.message_admin'
+ )}
+ values={{
+ link: (
+ <Link to="/admin/settings?category=almintegration">
+ {translate('onboarding.create_project.github.warning.message_admin.link')}
+ </Link>
+ ),
+ }}
+ />
+ ) : (
+ translate('onboarding.create_project.github.warning.message')
+ )}
+ </Alert>
+ </div>
+ </div>
+ )}
+
+ {!error && (
+ <>
+ <InstanceNewCodeDefinitionComplianceWarning />
+ <DeferredSpinner loading={loadingOrganizations}>
+ <div className="form-field">
+ <label htmlFor="github-choose-organization">
+ {translate('onboarding.create_project.github.choose_organization')}
+ </label>
+ {organizations.length > 0 ? (
+ <Select
+ inputId="github-choose-organization"
+ className="input-super-large"
+ options={organizations.map(orgToOption)}
+ onChange={({ value }: LabelValueSelectOption) =>
+ props.onSelectOrganization(value)
+ }
+ value={selectedOrganization ? orgToOption(selectedOrganization) : null}
+ />
+ ) : (
+ !loadingOrganizations && (
+ <Alert className="spacer-top" variant="error">
+ {canAdmin ? (
+ <FormattedMessage
+ id="onboarding.create_project.github.no_orgs_admin"
+ defaultMessage={translate('onboarding.create_project.github.no_orgs_admin')}
+ values={{
+ link: (
+ <Link to="/admin/settings?category=almintegration">
+ {translate(
+ 'onboarding.create_project.github.warning.message_admin.link'
+ )}
+ </Link>
+ ),
+ }}
+ />
+ ) : (
+ translate('onboarding.create_project.github.no_orgs')
+ )}
+ </Alert>
+ )
+ )}
+ </div>
+ </DeferredSpinner>
+ </>
+ )}
+
+ {renderRepositoryList(props)}
+ </div>
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { getGitlabProjects, importGitlabProject } from '../../../../api/alm-integrations';
+import { Location, Router } from '../../../../components/hoc/withRouter';
+import { GitlabProject } from '../../../../types/alm-integration';
+import { AlmSettingsInstance } from '../../../../types/alm-settings';
+import { Paging } from '../../../../types/types';
+import GitlabProjectCreateRenderer from './GitlabProjectCreateRenderer';
+
+interface Props {
+ canAdmin: boolean;
+ loadingBindings: boolean;
+ onProjectCreate: (projectKey: string) => void;
+ almInstances: AlmSettingsInstance[];
+ location: Location;
+ router: Router;
+}
+
+interface State {
+ importingGitlabProjectId?: string;
+ loading: boolean;
+ loadingMore: boolean;
+ projects?: GitlabProject[];
+ projectsPaging: Paging;
+ resetPat: boolean;
+ searching: boolean;
+ searchQuery: string;
+ selectedAlmInstance: AlmSettingsInstance;
+ showPersonalAccessTokenForm: boolean;
+}
+
+const GITLAB_PROJECTS_PAGESIZE = 30;
+
+export default class GitlabProjectCreate extends React.PureComponent<Props, State> {
+ mounted = false;
+
+ constructor(props: Props) {
+ super(props);
+
+ this.state = {
+ loading: false,
+ loadingMore: false,
+ projectsPaging: { pageIndex: 1, total: 0, pageSize: GITLAB_PROJECTS_PAGESIZE },
+ resetPat: false,
+ showPersonalAccessTokenForm: true,
+ searching: false,
+ searchQuery: '',
+ selectedAlmInstance: props.almInstances[0],
+ };
+ }
+
+ componentDidMount() {
+ this.mounted = true;
+ }
+
+ componentDidUpdate(prevProps: Props) {
+ const { almInstances } = this.props;
+ if (prevProps.almInstances.length === 0 && this.props.almInstances.length > 0) {
+ this.setState({ selectedAlmInstance: almInstances[0] }, () => this.fetchInitialData());
+ }
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ }
+
+ fetchInitialData = async () => {
+ const { showPersonalAccessTokenForm } = this.state;
+
+ if (!showPersonalAccessTokenForm) {
+ this.setState({ loading: true });
+ const result = await this.fetchProjects();
+ if (this.mounted && result) {
+ const { projects, projectsPaging } = result;
+
+ this.setState({
+ loading: false,
+ projects,
+ projectsPaging,
+ });
+ } else {
+ this.setState({
+ loading: false,
+ });
+ }
+ }
+ };
+
+ handleError = () => {
+ if (this.mounted) {
+ this.setState({ resetPat: true, showPersonalAccessTokenForm: true });
+ }
+
+ return undefined;
+ };
+
+ fetchProjects = async (pageIndex = 1, query?: string) => {
+ const { selectedAlmInstance } = this.state;
+ if (!selectedAlmInstance) {
+ return Promise.resolve(undefined);
+ }
+
+ try {
+ return await getGitlabProjects({
+ almSetting: selectedAlmInstance.key,
+ page: pageIndex,
+ pageSize: GITLAB_PROJECTS_PAGESIZE,
+ query,
+ });
+ } catch (_) {
+ return this.handleError();
+ }
+ };
+
+ doImport = async (gitlabProjectId: string) => {
+ const { selectedAlmInstance } = this.state;
+
+ if (!selectedAlmInstance) {
+ return Promise.resolve(undefined);
+ }
+
+ try {
+ return await importGitlabProject({
+ almSetting: selectedAlmInstance.key,
+ gitlabProjectId,
+ });
+ } catch (_) {
+ return this.handleError();
+ }
+ };
+
+ handleImport = async (gitlabProjectId: string) => {
+ this.setState({ importingGitlabProjectId: gitlabProjectId });
+
+ const result = await this.doImport(gitlabProjectId);
+
+ if (this.mounted) {
+ this.setState({ importingGitlabProjectId: undefined });
+
+ if (result) {
+ this.props.onProjectCreate(result.project.key);
+ }
+ }
+ };
+
+ handleLoadMore = async () => {
+ this.setState({ loadingMore: true });
+
+ const {
+ projectsPaging: { pageIndex },
+ searchQuery,
+ } = this.state;
+
+ const result = await this.fetchProjects(pageIndex + 1, searchQuery);
+ if (this.mounted) {
+ this.setState(({ projects = [], projectsPaging }) => ({
+ loadingMore: false,
+ projects: result ? [...projects, ...result.projects] : projects,
+ projectsPaging: result ? result.projectsPaging : projectsPaging,
+ }));
+ }
+ };
+
+ handleSearch = async (searchQuery: string) => {
+ this.setState({ searching: true, searchQuery });
+
+ const result = await this.fetchProjects(1, searchQuery);
+ if (this.mounted) {
+ this.setState(({ projects, projectsPaging }) => ({
+ searching: false,
+ projects: result ? result.projects : projects,
+ projectsPaging: result ? result.projectsPaging : projectsPaging,
+ }));
+ }
+ };
+
+ cleanUrl = () => {
+ const { location, router } = this.props;
+ delete location.query.resetPat;
+ router.replace(location);
+ };
+
+ handlePersonalAccessTokenCreated = async () => {
+ this.setState({ showPersonalAccessTokenForm: false, resetPat: false });
+ this.cleanUrl();
+ await this.fetchInitialData();
+ };
+
+ onSelectedAlmInstanceChange = (instance: AlmSettingsInstance) => {
+ this.setState({
+ selectedAlmInstance: instance,
+ showPersonalAccessTokenForm: true,
+ projects: undefined,
+ resetPat: false,
+ searchQuery: '',
+ });
+ };
+
+ render() {
+ const { loadingBindings, location, almInstances, canAdmin } = this.props;
+ const {
+ importingGitlabProjectId,
+ loading,
+ loadingMore,
+ projects,
+ projectsPaging,
+ resetPat,
+ searching,
+ searchQuery,
+ selectedAlmInstance,
+ showPersonalAccessTokenForm,
+ } = this.state;
+
+ return (
+ <GitlabProjectCreateRenderer
+ canAdmin={canAdmin}
+ almInstances={almInstances}
+ selectedAlmInstance={selectedAlmInstance}
+ importingGitlabProjectId={importingGitlabProjectId}
+ loading={loading || loadingBindings}
+ loadingMore={loadingMore}
+ onImport={this.handleImport}
+ onLoadMore={this.handleLoadMore}
+ onPersonalAccessTokenCreated={this.handlePersonalAccessTokenCreated}
+ onSearch={this.handleSearch}
+ projects={projects}
+ projectsPaging={projectsPaging}
+ resetPat={resetPat || Boolean(location.query.resetPat)}
+ searching={searching}
+ searchQuery={searchQuery}
+ showPersonalAccessTokenForm={
+ showPersonalAccessTokenForm || Boolean(location.query.resetPat)
+ }
+ onSelectedAlmInstanceChange={this.onSelectedAlmInstanceChange}
+ />
+ );
+ }
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 '../../../../helpers/l10n';
+import { getBaseUrl } from '../../../../helpers/system';
+import { GitlabProject } from '../../../../types/alm-integration';
+import { AlmKeys, AlmSettingsInstance } from '../../../../types/alm-settings';
+import { Paging } from '../../../../types/types';
+import AlmSettingsInstanceDropdown from '../components/AlmSettingsInstanceDropdown';
+import CreateProjectPageHeader from '../components/CreateProjectPageHeader';
+import PersonalAccessTokenForm from '../components/PersonalAccessTokenForm';
+import WrongBindingCountAlert from '../components/WrongBindingCountAlert';
+import GitlabProjectSelectionForm from './GitlabProjectSelectionForm';
+
+export interface GitlabProjectCreateRendererProps {
+ canAdmin?: boolean;
+ importingGitlabProjectId?: string;
+ loading: boolean;
+ loadingMore: boolean;
+ onImport: (gitlabProjectId: string) => void;
+ onLoadMore: () => void;
+ onPersonalAccessTokenCreated: () => void;
+ onSearch: (searchQuery: string) => void;
+ projects?: GitlabProject[];
+ projectsPaging: Paging;
+ resetPat: boolean;
+ searching: boolean;
+ searchQuery: string;
+ almInstances?: AlmSettingsInstance[];
+ selectedAlmInstance?: AlmSettingsInstance;
+ showPersonalAccessTokenForm?: boolean;
+ onSelectedAlmInstanceChange: (instance: AlmSettingsInstance) => void;
+}
+
+export default function GitlabProjectCreateRenderer(props: GitlabProjectCreateRendererProps) {
+ const {
+ canAdmin,
+ importingGitlabProjectId,
+ loading,
+ loadingMore,
+ projects,
+ projectsPaging,
+ resetPat,
+ searching,
+ searchQuery,
+ selectedAlmInstance,
+ almInstances,
+ showPersonalAccessTokenForm,
+ } = props;
+
+ return (
+ <>
+ <CreateProjectPageHeader
+ title={
+ <span className="text-middle">
+ <img
+ alt="" // Should be ignored by screen readers
+ className="spacer-right"
+ height="24"
+ src={`${getBaseUrl()}/images/alm/gitlab.svg`}
+ />
+ {translate('onboarding.create_project.gitlab.title')}
+ </span>
+ }
+ />
+
+ <AlmSettingsInstanceDropdown
+ almKey={AlmKeys.GitLab}
+ almInstances={almInstances}
+ selectedAlmInstance={selectedAlmInstance}
+ onChangeConfig={props.onSelectedAlmInstanceChange}
+ />
+
+ {loading && <i className="spinner" />}
+
+ {!loading && !selectedAlmInstance && (
+ <WrongBindingCountAlert alm={AlmKeys.GitLab} canAdmin={!!canAdmin} />
+ )}
+
+ {!loading &&
+ selectedAlmInstance &&
+ (showPersonalAccessTokenForm ? (
+ <PersonalAccessTokenForm
+ almSetting={selectedAlmInstance}
+ resetPat={resetPat}
+ onPersonalAccessTokenCreated={props.onPersonalAccessTokenCreated}
+ />
+ ) : (
+ <GitlabProjectSelectionForm
+ importingGitlabProjectId={importingGitlabProjectId}
+ loadingMore={loadingMore}
+ onImport={props.onImport}
+ onLoadMore={props.onLoadMore}
+ onSearch={props.onSearch}
+ projects={projects}
+ projectsPaging={projectsPaging}
+ searching={searching}
+ searchQuery={searchQuery}
+ />
+ ))}
+ </>
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { FormattedMessage } from 'react-intl';
+import Link from '../../../../components/common/Link';
+import ListFooter from '../../../../components/controls/ListFooter';
+import SearchBox from '../../../../components/controls/SearchBox';
+import Tooltip from '../../../../components/controls/Tooltip';
+import { Button } from '../../../../components/controls/buttons';
+import CheckIcon from '../../../../components/icons/CheckIcon';
+import QualifierIcon from '../../../../components/icons/QualifierIcon';
+import { Alert } from '../../../../components/ui/Alert';
+import DeferredSpinner from '../../../../components/ui/DeferredSpinner';
+import { translate } from '../../../../helpers/l10n';
+import { getProjectUrl, queryToSearch } from '../../../../helpers/urls';
+import { GitlabProject } from '../../../../types/alm-integration';
+import { ComponentQualifier } from '../../../../types/component';
+import { Paging } from '../../../../types/types';
+import InstanceNewCodeDefinitionComplianceWarning from '../components/InstanceNewCodeDefinitionComplianceWarning';
+import { CreateProjectModes } from '../types';
+
+export interface GitlabProjectSelectionFormProps {
+ importingGitlabProjectId?: string;
+ loadingMore: boolean;
+ onImport: (gitlabProjectId: string) => void;
+ onLoadMore: () => void;
+ onSearch: (searchQuery: string) => void;
+ projects?: GitlabProject[];
+ projectsPaging: Paging;
+ searching: boolean;
+ searchQuery: string;
+}
+
+export default function GitlabProjectSelectionForm(props: GitlabProjectSelectionFormProps) {
+ const {
+ importingGitlabProjectId,
+ loadingMore,
+ projects = [],
+ projectsPaging,
+ searching,
+ searchQuery,
+ } = props;
+
+ if (projects.length === 0 && searchQuery.length === 0 && !searching) {
+ return (
+ <Alert className="spacer-top" variant="warning">
+ <FormattedMessage
+ defaultMessage={translate('onboarding.create_project.gitlab.no_projects')}
+ id="onboarding.create_project.gitlab.no_projects"
+ values={{
+ link: (
+ <Link
+ to={{
+ pathname: '/projects/create',
+ search: queryToSearch({ mode: CreateProjectModes.GitLab, resetPat: 1 }),
+ }}
+ >
+ {translate('onboarding.create_project.update_your_token')}
+ </Link>
+ ),
+ }}
+ />
+ </Alert>
+ );
+ }
+
+ return (
+ <>
+ <InstanceNewCodeDefinitionComplianceWarning />
+ <div className="boxed-group big-padded create-project-import">
+ <SearchBox
+ className="spacer"
+ loading={searching}
+ minLength={3}
+ onChange={props.onSearch}
+ placeholder={translate('onboarding.create_project.search_prompt')}
+ />
+
+ <hr />
+
+ {projects.length === 0 ? (
+ <div className="padded">{translate('no_results')}</div>
+ ) : (
+ <table className="data zebra zebra-hover">
+ <tbody>
+ {projects.map((project) => (
+ <tr key={project.id}>
+ <td>
+ <Tooltip overlay={project.slug}>
+ <strong className="project-name display-inline-block text-ellipsis">
+ {project.sqProjectKey ? (
+ <Link to={getProjectUrl(project.sqProjectKey)}>
+ <QualifierIcon
+ className="spacer-right"
+ qualifier={ComponentQualifier.Project}
+ />
+ {project.sqProjectName}
+ </Link>
+ ) : (
+ project.name
+ )}
+ </strong>
+ </Tooltip>
+ <br />
+ <Tooltip overlay={project.pathSlug}>
+ <span className="text-muted project-path display-inline-block text-ellipsis">
+ {project.pathName}
+ </span>
+ </Tooltip>
+ </td>
+ <td>
+ <Link
+ className="display-inline-flex-center big-spacer-right"
+ to={project.url}
+ target="_blank"
+ >
+ {translate('onboarding.create_project.gitlab.link')}
+ </Link>
+ </td>
+ {project.sqProjectKey ? (
+ <td>
+ <span className="display-flex-center display-flex-justify-end already-set-up">
+ <CheckIcon className="little-spacer-right" size={12} />
+ {translate('onboarding.create_project.repository_imported')}
+ </span>
+ </td>
+ ) : (
+ <td className="text-right">
+ <Button
+ disabled={!!importingGitlabProjectId}
+ onClick={() => props.onImport(project.id)}
+ >
+ {translate('onboarding.create_project.set_up')}
+ <DeferredSpinner
+ className="spacer-left"
+ loading={importingGitlabProjectId === project.id}
+ />
+ </Button>
+ </td>
+ )}
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ )}
+ <ListFooter
+ count={projects.length}
+ loadMore={props.onLoadMore}
+ loading={loadingMore}
+ pageSize={projectsPaging.pageSize}
+ total={projectsPaging.total}
+ />
+ </div>
+ </>
+ );
+}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 { getGitlabProjects, importGitlabProject } from '../../../api/alm-integrations';
-import { Location, Router } from '../../../components/hoc/withRouter';
-import { GitlabProject } from '../../../types/alm-integration';
-import { AlmSettingsInstance } from '../../../types/alm-settings';
-import { Paging } from '../../../types/types';
-import GitlabProjectCreateRenderer from './GitlabProjectCreateRenderer';
-
-interface Props {
- canAdmin: boolean;
- loadingBindings: boolean;
- onProjectCreate: (projectKey: string) => void;
- almInstances: AlmSettingsInstance[];
- location: Location;
- router: Router;
-}
-
-interface State {
- importingGitlabProjectId?: string;
- loading: boolean;
- loadingMore: boolean;
- projects?: GitlabProject[];
- projectsPaging: Paging;
- resetPat: boolean;
- searching: boolean;
- searchQuery: string;
- selectedAlmInstance: AlmSettingsInstance;
- showPersonalAccessTokenForm: boolean;
-}
-
-const GITLAB_PROJECTS_PAGESIZE = 30;
-
-export default class GitlabProjectCreate extends React.PureComponent<Props, State> {
- mounted = false;
-
- constructor(props: Props) {
- super(props);
-
- this.state = {
- loading: false,
- loadingMore: false,
- projectsPaging: { pageIndex: 1, total: 0, pageSize: GITLAB_PROJECTS_PAGESIZE },
- resetPat: false,
- showPersonalAccessTokenForm: true,
- searching: false,
- searchQuery: '',
- selectedAlmInstance: props.almInstances[0],
- };
- }
-
- componentDidMount() {
- this.mounted = true;
- }
-
- componentDidUpdate(prevProps: Props) {
- const { almInstances } = this.props;
- if (prevProps.almInstances.length === 0 && this.props.almInstances.length > 0) {
- this.setState({ selectedAlmInstance: almInstances[0] }, () => this.fetchInitialData());
- }
- }
-
- componentWillUnmount() {
- this.mounted = false;
- }
-
- fetchInitialData = async () => {
- const { showPersonalAccessTokenForm } = this.state;
-
- if (!showPersonalAccessTokenForm) {
- this.setState({ loading: true });
- const result = await this.fetchProjects();
- if (this.mounted && result) {
- const { projects, projectsPaging } = result;
-
- this.setState({
- loading: false,
- projects,
- projectsPaging,
- });
- } else {
- this.setState({
- loading: false,
- });
- }
- }
- };
-
- handleError = () => {
- if (this.mounted) {
- this.setState({ resetPat: true, showPersonalAccessTokenForm: true });
- }
-
- return undefined;
- };
-
- fetchProjects = async (pageIndex = 1, query?: string) => {
- const { selectedAlmInstance } = this.state;
- if (!selectedAlmInstance) {
- return Promise.resolve(undefined);
- }
-
- try {
- return await getGitlabProjects({
- almSetting: selectedAlmInstance.key,
- page: pageIndex,
- pageSize: GITLAB_PROJECTS_PAGESIZE,
- query,
- });
- } catch (_) {
- return this.handleError();
- }
- };
-
- doImport = async (gitlabProjectId: string) => {
- const { selectedAlmInstance } = this.state;
-
- if (!selectedAlmInstance) {
- return Promise.resolve(undefined);
- }
-
- try {
- return await importGitlabProject({
- almSetting: selectedAlmInstance.key,
- gitlabProjectId,
- });
- } catch (_) {
- return this.handleError();
- }
- };
-
- handleImport = async (gitlabProjectId: string) => {
- this.setState({ importingGitlabProjectId: gitlabProjectId });
-
- const result = await this.doImport(gitlabProjectId);
-
- if (this.mounted) {
- this.setState({ importingGitlabProjectId: undefined });
-
- if (result) {
- this.props.onProjectCreate(result.project.key);
- }
- }
- };
-
- handleLoadMore = async () => {
- this.setState({ loadingMore: true });
-
- const {
- projectsPaging: { pageIndex },
- searchQuery,
- } = this.state;
-
- const result = await this.fetchProjects(pageIndex + 1, searchQuery);
- if (this.mounted) {
- this.setState(({ projects = [], projectsPaging }) => ({
- loadingMore: false,
- projects: result ? [...projects, ...result.projects] : projects,
- projectsPaging: result ? result.projectsPaging : projectsPaging,
- }));
- }
- };
-
- handleSearch = async (searchQuery: string) => {
- this.setState({ searching: true, searchQuery });
-
- const result = await this.fetchProjects(1, searchQuery);
- if (this.mounted) {
- this.setState(({ projects, projectsPaging }) => ({
- searching: false,
- projects: result ? result.projects : projects,
- projectsPaging: result ? result.projectsPaging : projectsPaging,
- }));
- }
- };
-
- cleanUrl = () => {
- const { location, router } = this.props;
- delete location.query.resetPat;
- router.replace(location);
- };
-
- handlePersonalAccessTokenCreated = async () => {
- this.setState({ showPersonalAccessTokenForm: false, resetPat: false });
- this.cleanUrl();
- await this.fetchInitialData();
- };
-
- onSelectedAlmInstanceChange = (instance: AlmSettingsInstance) => {
- this.setState({
- selectedAlmInstance: instance,
- showPersonalAccessTokenForm: true,
- projects: undefined,
- resetPat: false,
- searchQuery: '',
- });
- };
-
- render() {
- const { loadingBindings, location, almInstances, canAdmin } = this.props;
- const {
- importingGitlabProjectId,
- loading,
- loadingMore,
- projects,
- projectsPaging,
- resetPat,
- searching,
- searchQuery,
- selectedAlmInstance,
- showPersonalAccessTokenForm,
- } = this.state;
-
- return (
- <GitlabProjectCreateRenderer
- canAdmin={canAdmin}
- almInstances={almInstances}
- selectedAlmInstance={selectedAlmInstance}
- importingGitlabProjectId={importingGitlabProjectId}
- loading={loading || loadingBindings}
- loadingMore={loadingMore}
- onImport={this.handleImport}
- onLoadMore={this.handleLoadMore}
- onPersonalAccessTokenCreated={this.handlePersonalAccessTokenCreated}
- onSearch={this.handleSearch}
- projects={projects}
- projectsPaging={projectsPaging}
- resetPat={resetPat || Boolean(location.query.resetPat)}
- searching={searching}
- searchQuery={searchQuery}
- showPersonalAccessTokenForm={
- showPersonalAccessTokenForm || Boolean(location.query.resetPat)
- }
- onSelectedAlmInstanceChange={this.onSelectedAlmInstanceChange}
- />
- );
- }
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 '../../../helpers/l10n';
-import { getBaseUrl } from '../../../helpers/system';
-import { GitlabProject } from '../../../types/alm-integration';
-import { AlmKeys, AlmSettingsInstance } from '../../../types/alm-settings';
-import { Paging } from '../../../types/types';
-import AlmSettingsInstanceDropdown from './AlmSettingsInstanceDropdown';
-import CreateProjectPageHeader from './CreateProjectPageHeader';
-import GitlabProjectSelectionForm from './GitlabProjectSelectionForm';
-import PersonalAccessTokenForm from './PersonalAccessTokenForm';
-import WrongBindingCountAlert from './WrongBindingCountAlert';
-
-export interface GitlabProjectCreateRendererProps {
- canAdmin?: boolean;
- importingGitlabProjectId?: string;
- loading: boolean;
- loadingMore: boolean;
- onImport: (gitlabProjectId: string) => void;
- onLoadMore: () => void;
- onPersonalAccessTokenCreated: () => void;
- onSearch: (searchQuery: string) => void;
- projects?: GitlabProject[];
- projectsPaging: Paging;
- resetPat: boolean;
- searching: boolean;
- searchQuery: string;
- almInstances?: AlmSettingsInstance[];
- selectedAlmInstance?: AlmSettingsInstance;
- showPersonalAccessTokenForm?: boolean;
- onSelectedAlmInstanceChange: (instance: AlmSettingsInstance) => void;
-}
-
-export default function GitlabProjectCreateRenderer(props: GitlabProjectCreateRendererProps) {
- const {
- canAdmin,
- importingGitlabProjectId,
- loading,
- loadingMore,
- projects,
- projectsPaging,
- resetPat,
- searching,
- searchQuery,
- selectedAlmInstance,
- almInstances,
- showPersonalAccessTokenForm,
- } = props;
-
- return (
- <>
- <CreateProjectPageHeader
- title={
- <span className="text-middle">
- <img
- alt="" // Should be ignored by screen readers
- className="spacer-right"
- height="24"
- src={`${getBaseUrl()}/images/alm/gitlab.svg`}
- />
- {translate('onboarding.create_project.gitlab.title')}
- </span>
- }
- />
-
- <AlmSettingsInstanceDropdown
- almKey={AlmKeys.GitLab}
- almInstances={almInstances}
- selectedAlmInstance={selectedAlmInstance}
- onChangeConfig={props.onSelectedAlmInstanceChange}
- />
-
- {loading && <i className="spinner" />}
-
- {!loading && !selectedAlmInstance && (
- <WrongBindingCountAlert alm={AlmKeys.GitLab} canAdmin={!!canAdmin} />
- )}
-
- {!loading &&
- selectedAlmInstance &&
- (showPersonalAccessTokenForm ? (
- <PersonalAccessTokenForm
- almSetting={selectedAlmInstance}
- resetPat={resetPat}
- onPersonalAccessTokenCreated={props.onPersonalAccessTokenCreated}
- />
- ) : (
- <GitlabProjectSelectionForm
- importingGitlabProjectId={importingGitlabProjectId}
- loadingMore={loadingMore}
- onImport={props.onImport}
- onLoadMore={props.onLoadMore}
- onSearch={props.onSearch}
- projects={projects}
- projectsPaging={projectsPaging}
- searching={searching}
- searchQuery={searchQuery}
- />
- ))}
- </>
- );
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 { FormattedMessage } from 'react-intl';
-import Link from '../../../components/common/Link';
-import { Button } from '../../../components/controls/buttons';
-import ListFooter from '../../../components/controls/ListFooter';
-import SearchBox from '../../../components/controls/SearchBox';
-import Tooltip from '../../../components/controls/Tooltip';
-import CheckIcon from '../../../components/icons/CheckIcon';
-import QualifierIcon from '../../../components/icons/QualifierIcon';
-import { Alert } from '../../../components/ui/Alert';
-import DeferredSpinner from '../../../components/ui/DeferredSpinner';
-import { translate } from '../../../helpers/l10n';
-import { getProjectUrl, queryToSearch } from '../../../helpers/urls';
-import { GitlabProject } from '../../../types/alm-integration';
-import { ComponentQualifier } from '../../../types/component';
-import { Paging } from '../../../types/types';
-import { CreateProjectModes } from './types';
-
-export interface GitlabProjectSelectionFormProps {
- importingGitlabProjectId?: string;
- loadingMore: boolean;
- onImport: (gitlabProjectId: string) => void;
- onLoadMore: () => void;
- onSearch: (searchQuery: string) => void;
- projects?: GitlabProject[];
- projectsPaging: Paging;
- searching: boolean;
- searchQuery: string;
-}
-
-export default function GitlabProjectSelectionForm(props: GitlabProjectSelectionFormProps) {
- const {
- importingGitlabProjectId,
- loadingMore,
- projects = [],
- projectsPaging,
- searching,
- searchQuery,
- } = props;
-
- if (projects.length === 0 && searchQuery.length === 0 && !searching) {
- return (
- <Alert className="spacer-top" variant="warning">
- <FormattedMessage
- defaultMessage={translate('onboarding.create_project.gitlab.no_projects')}
- id="onboarding.create_project.gitlab.no_projects"
- values={{
- link: (
- <Link
- to={{
- pathname: '/projects/create',
- search: queryToSearch({ mode: CreateProjectModes.GitLab, resetPat: 1 }),
- }}
- >
- {translate('onboarding.create_project.update_your_token')}
- </Link>
- ),
- }}
- />
- </Alert>
- );
- }
-
- return (
- <div className="boxed-group big-padded create-project-import">
- <SearchBox
- className="spacer"
- loading={searching}
- minLength={3}
- onChange={props.onSearch}
- placeholder={translate('onboarding.create_project.search_prompt')}
- />
-
- <hr />
-
- {projects.length === 0 ? (
- <div className="padded">{translate('no_results')}</div>
- ) : (
- <table className="data zebra zebra-hover">
- <tbody>
- {projects.map((project) => (
- <tr key={project.id}>
- <td>
- <Tooltip overlay={project.slug}>
- <strong className="project-name display-inline-block text-ellipsis">
- {project.sqProjectKey ? (
- <Link to={getProjectUrl(project.sqProjectKey)}>
- <QualifierIcon
- className="spacer-right"
- qualifier={ComponentQualifier.Project}
- />
- {project.sqProjectName}
- </Link>
- ) : (
- project.name
- )}
- </strong>
- </Tooltip>
- <br />
- <Tooltip overlay={project.pathSlug}>
- <span className="text-muted project-path display-inline-block text-ellipsis">
- {project.pathName}
- </span>
- </Tooltip>
- </td>
- <td>
- <Link
- className="display-inline-flex-center big-spacer-right"
- to={project.url}
- target="_blank"
- >
- {translate('onboarding.create_project.gitlab.link')}
- </Link>
- </td>
- {project.sqProjectKey ? (
- <td>
- <span className="display-flex-center display-flex-justify-end already-set-up">
- <CheckIcon className="little-spacer-right" size={12} />
- {translate('onboarding.create_project.repository_imported')}
- </span>
- </td>
- ) : (
- <td className="text-right">
- <Button
- disabled={!!importingGitlabProjectId}
- onClick={() => props.onImport(project.id)}
- >
- {translate('onboarding.create_project.set_up')}
- {importingGitlabProjectId === project.id && (
- <DeferredSpinner className="spacer-left" />
- )}
- </Button>
- </td>
- )}
- </tr>
- ))}
- </tbody>
- </table>
- )}
- <ListFooter
- count={projects.length}
- loadMore={props.onLoadMore}
- loading={loadingMore}
- pageSize={projectsPaging.pageSize}
- total={projectsPaging.total}
- />
- </div>
- );
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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.
- */
-.manual-project-create {
- max-width: 700px;
-}
-
-.manual-project-create .button {
- margin-top: var(--gridSize);
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 classNames from 'classnames';
-import { debounce, isEmpty } from 'lodash';
-import * as React from 'react';
-import { FormattedMessage } from 'react-intl';
-import { createProject, doesComponentExists } from '../../../api/components';
-import { getValue } from '../../../api/settings';
-import DocLink from '../../../components/common/DocLink';
-import ProjectKeyInput from '../../../components/common/ProjectKeyInput';
-import ValidationInput from '../../../components/controls/ValidationInput';
-import { SubmitButton } from '../../../components/controls/buttons';
-import { Alert } from '../../../components/ui/Alert';
-import DeferredSpinner from '../../../components/ui/DeferredSpinner';
-import MandatoryFieldsExplanation from '../../../components/ui/MandatoryFieldsExplanation';
-import { translate } from '../../../helpers/l10n';
-import { PROJECT_KEY_INVALID_CHARACTERS, validateProjectKey } from '../../../helpers/projects';
-import { ProjectKeyValidationResult } from '../../../types/component';
-import { GlobalSettingKeys } from '../../../types/settings';
-import CreateProjectPageHeader from './CreateProjectPageHeader';
-import './ManualProjectCreate.css';
-import { PROJECT_NAME_MAX_LEN } from './constants';
-
-interface Props {
- branchesEnabled: boolean;
- onProjectCreate: (projectKey: string) => void;
-}
-
-interface State {
- projectName: string;
- projectNameError?: string;
- projectNameTouched: boolean;
- projectKey: string;
- projectKeyError?: string;
- projectKeyTouched: boolean;
- validatingProjectKey: boolean;
- mainBranchName: string;
- mainBranchNameError?: string;
- mainBranchNameTouched: boolean;
- submitting: boolean;
-}
-
-const DEBOUNCE_DELAY = 250;
-
-type ValidState = State & Required<Pick<State, 'projectKey' | 'projectName'>>;
-
-export default class ManualProjectCreate extends React.PureComponent<Props, State> {
- mounted = false;
-
- constructor(props: Props) {
- super(props);
- this.state = {
- projectKey: '',
- projectName: '',
- submitting: false,
- projectKeyTouched: false,
- projectNameTouched: false,
- mainBranchName: 'main',
- mainBranchNameTouched: false,
- validatingProjectKey: false,
- };
- this.checkFreeKey = debounce(this.checkFreeKey, DEBOUNCE_DELAY);
- }
-
- componentDidMount() {
- this.mounted = true;
- this.fetchMainBranchName();
- }
-
- componentWillUnmount() {
- this.mounted = false;
- }
-
- fetchMainBranchName = async () => {
- const mainBranchName = await getValue({ key: GlobalSettingKeys.MainBranchName });
-
- if (this.mounted && mainBranchName.value !== undefined) {
- this.setState({ mainBranchName: mainBranchName.value });
- }
- };
-
- checkFreeKey = (key: string) => {
- this.setState({ validatingProjectKey: true });
-
- doesComponentExists({ component: key })
- .then((alreadyExist) => {
- if (this.mounted && key === this.state.projectKey) {
- this.setState({
- projectKeyError: alreadyExist
- ? translate('onboarding.create_project.project_key.taken')
- : undefined,
- validatingProjectKey: false,
- });
- }
- })
- .catch(() => {
- if (this.mounted && key === this.state.projectKey) {
- this.setState({ projectKeyError: undefined, validatingProjectKey: false });
- }
- });
- };
-
- canSubmit(state: State): state is ValidState {
- const { projectKey, projectKeyError, projectName, projectNameError, mainBranchName } = state;
- return Boolean(
- projectKeyError === undefined &&
- projectNameError === undefined &&
- !isEmpty(projectKey) &&
- !isEmpty(projectName) &&
- !isEmpty(mainBranchName)
- );
- }
-
- handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
- event.preventDefault();
- const { projectKey, projectName, mainBranchName } = this.state;
- if (this.canSubmit(this.state)) {
- this.setState({ submitting: true });
- createProject({
- project: projectKey,
- name: (projectName || projectKey).trim(),
- mainBranch: mainBranchName,
- }).then(
- ({ project }) => this.props.onProjectCreate(project.key),
- () => {
- if (this.mounted) {
- this.setState({ submitting: false });
- }
- }
- );
- }
- };
-
- handleProjectKeyChange = (projectKey: string, fromUI = false) => {
- const projectKeyError = this.validateKey(projectKey);
-
- this.setState({
- projectKey,
- projectKeyError,
- projectKeyTouched: fromUI,
- });
-
- if (projectKeyError === undefined) {
- this.checkFreeKey(projectKey);
- }
- };
-
- handleProjectNameChange = (projectName: string, fromUI = false) => {
- this.setState(
- {
- projectName,
- projectNameError: this.validateName(projectName),
- projectNameTouched: fromUI,
- },
- () => {
- if (!this.state.projectKeyTouched) {
- const sanitizedProjectKey = this.state.projectName
- .trim()
- .replace(PROJECT_KEY_INVALID_CHARACTERS, '-');
- this.handleProjectKeyChange(sanitizedProjectKey);
- }
- }
- );
- };
-
- handleBranchNameChange = (mainBranchName: string, fromUI = false) => {
- this.setState({
- mainBranchName,
- mainBranchNameError: this.validateMainBranchName(mainBranchName),
- mainBranchNameTouched: fromUI,
- });
- };
-
- validateKey = (projectKey: string) => {
- const result = validateProjectKey(projectKey);
- return result === ProjectKeyValidationResult.Valid
- ? undefined
- : translate('onboarding.create_project.project_key.error', result);
- };
-
- validateName = (projectName: string) => {
- if (isEmpty(projectName)) {
- return translate('onboarding.create_project.display_name.error.empty');
- }
- return undefined;
- };
-
- validateMainBranchName = (mainBranchName: string) => {
- if (isEmpty(mainBranchName)) {
- return translate('onboarding.create_project.main_branch_name.error.empty');
- }
- return undefined;
- };
-
- render() {
- const {
- projectKey,
- projectKeyError,
- projectKeyTouched,
- projectName,
- projectNameError,
- projectNameTouched,
- validatingProjectKey,
- mainBranchName,
- mainBranchNameError,
- mainBranchNameTouched,
- submitting,
- } = this.state;
- const { branchesEnabled } = this.props;
-
- const touched = Boolean(projectKeyTouched || projectNameTouched);
- const projectNameIsInvalid = projectNameTouched && projectNameError !== undefined;
- const projectNameIsValid = projectNameTouched && projectNameError === undefined;
- const mainBranchNameIsValid = mainBranchNameTouched && mainBranchNameError === undefined;
- const mainBranchNameIsInvalid = mainBranchNameTouched && mainBranchNameError !== undefined;
-
- return (
- <>
- <CreateProjectPageHeader title={translate('onboarding.create_project.setup_manually')} />
-
- <div className="create-project-manual">
- <div className="flex-1 huge-spacer-right">
- <form className="manual-project-create" onSubmit={this.handleFormSubmit}>
- <MandatoryFieldsExplanation className="big-spacer-bottom" />
-
- <ValidationInput
- className="form-field"
- description={translate('onboarding.create_project.display_name.description')}
- error={projectNameError}
- labelHtmlFor="project-name"
- isInvalid={projectNameIsInvalid}
- isValid={projectNameIsValid}
- label={translate('onboarding.create_project.display_name')}
- required={true}
- >
- <input
- className={classNames('input-super-large', {
- 'is-invalid': projectNameIsInvalid,
- 'is-valid': projectNameIsValid,
- })}
- id="project-name"
- maxLength={PROJECT_NAME_MAX_LEN}
- minLength={1}
- onChange={(e) => this.handleProjectNameChange(e.currentTarget.value, true)}
- type="text"
- value={projectName}
- autoFocus={true}
- />
- </ValidationInput>
- <ProjectKeyInput
- error={projectKeyError}
- label={translate('onboarding.create_project.project_key')}
- onProjectKeyChange={(e) => this.handleProjectKeyChange(e.currentTarget.value, true)}
- projectKey={projectKey}
- touched={touched}
- validating={validatingProjectKey}
- />
-
- <ValidationInput
- className="form-field"
- description={
- <FormattedMessage
- id="onboarding.create_project.main_branch_name.description"
- defaultMessage={translate(
- 'onboarding.create_project.main_branch_name.description'
- )}
- values={{
- learn_more: (
- <DocLink to="/analyzing-source-code/branches/branch-analysis">
- {translate('learn_more')}
- </DocLink>
- ),
- }}
- />
- }
- error={mainBranchNameError}
- labelHtmlFor="main-branch-name"
- isInvalid={mainBranchNameIsInvalid}
- isValid={mainBranchNameIsValid}
- label={translate('onboarding.create_project.main_branch_name')}
- required={true}
- >
- <input
- id="main-branch-name"
- className={classNames('input-super-large', {
- 'is-invalid': mainBranchNameIsInvalid,
- 'is-valid': mainBranchNameIsValid,
- })}
- minLength={1}
- onChange={(e) => this.handleBranchNameChange(e.currentTarget.value, true)}
- type="text"
- value={mainBranchName}
- />
- </ValidationInput>
-
- <SubmitButton disabled={!this.canSubmit(this.state) || submitting}>
- {translate('set_up')}
- </SubmitButton>
- <DeferredSpinner className="spacer-left" loading={submitting} />
- </form>
-
- {branchesEnabled && (
- <Alert variant="info" display="inline" className="big-spacer-top">
- {translate('onboarding.create_project.pr_decoration.information')}
- </Alert>
- )}
- </div>
- </div>
- </>
- );
- }
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 classNames from 'classnames';
-import * as React from 'react';
-import { FormattedMessage } from 'react-intl';
-import {
- checkPersonalAccessTokenIsValid,
- setAlmPersonalAccessToken,
-} from '../../../api/alm-integrations';
-import { SubmitButton } from '../../../components/controls/buttons';
-import ValidationInput from '../../../components/controls/ValidationInput';
-import { Alert } from '../../../components/ui/Alert';
-import DeferredSpinner from '../../../components/ui/DeferredSpinner';
-import { translate } from '../../../helpers/l10n';
-import { getBaseUrl } from '../../../helpers/system';
-import { AlmKeys, AlmSettingsInstance } from '../../../types/alm-settings';
-import { tokenExistedBefore } from './utils';
-
-interface Props {
- almSetting: AlmSettingsInstance;
- resetPat: boolean;
- onPersonalAccessTokenCreated: () => void;
-}
-
-interface State {
- validationFailed: boolean;
- validationErrorMessage?: string;
- touched: boolean;
- password: string;
- username?: string;
- submitting: boolean;
- checkingPat: boolean;
- firstConnection: boolean;
-}
-
-function getPatUrl(alm: AlmKeys, url = '') {
- if (alm === AlmKeys.BitbucketServer) {
- return `${url.replace(/\/$/, '')}/account`;
- } else if (alm === AlmKeys.BitbucketCloud) {
- return 'https://bitbucket.org/account/settings/app-passwords/new';
- } else if (alm === AlmKeys.GitLab) {
- return 'https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html';
- }
-
- return '';
-}
-
-export default class PersonalAccessTokenForm extends React.PureComponent<Props, State> {
- mounted = false;
-
- constructor(props: Props) {
- super(props);
-
- this.state = {
- checkingPat: false,
- touched: false,
- password: '',
- submitting: false,
- validationFailed: false,
- firstConnection: false,
- };
- }
-
- componentDidMount() {
- this.mounted = true;
- this.checkPATAndUpdateView();
- }
-
- componentDidUpdate(prevProps: Props) {
- if (this.props.almSetting !== prevProps.almSetting) {
- this.checkPATAndUpdateView();
- }
- }
-
- componentWillUnmount() {
- this.mounted = false;
- }
-
- checkPATAndUpdateView = async () => {
- const {
- almSetting: { key },
- resetPat,
- } = this.props;
-
- // 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 (tokenExistedBefore(error)) {
- this.setState({
- checkingPat: false,
- firstConnection: true,
- });
- } else {
- this.setState({
- checkingPat: false,
- validationFailed: true,
- validationErrorMessage: error,
- });
- }
- }
- }
- };
-
- handleUsernameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
- this.setState({
- touched: true,
- username: event.target.value,
- });
- };
-
- handlePasswordChange = (event: React.ChangeEvent<HTMLInputElement>) => {
- this.setState({
- touched: true,
- password: event.target.value,
- });
- };
-
- handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
- const { password, username } = this.state;
- const {
- almSetting: { key },
- } = this.props;
-
- e.preventDefault();
- if (password) {
- this.setState({ submitting: true });
-
- await setAlmPersonalAccessToken(key, password, username).catch(() => {
- /* Set will not check pat validity. We need to check again so we will catch issue after */
- });
-
- const { status, error } = await checkPersonalAccessTokenIsValid(key)
- .then(({ status, error }) => ({ status, error }))
- .catch(() => ({ status: false, error: translate('default_error_message') }));
-
- if (this.mounted && status) {
- // Let's reset status,
- this.setState({
- checkingPat: false,
- touched: false,
- password: '',
- submitting: false,
- username: '',
- validationFailed: false,
- });
- this.props.onPersonalAccessTokenCreated();
- } else if (this.mounted) {
- this.setState({
- submitting: false,
- touched: false,
- validationFailed: true,
- validationErrorMessage: error,
- });
- }
- }
- };
-
- renderHelpBox(suffixTranslationKey: string) {
- const {
- almSetting: { alm, url },
- } = this.props;
-
- return (
- <Alert className="big-spacer-left width-50" display="block" variant="info">
- {alm === AlmKeys.BitbucketCloud && (
- <>
- <h3>
- {translate(
- 'onboarding.create_project.pat_help.instructions_username.bitbucketcloud.title'
- )}
- </h3>
- <p className="big-spacer-top big-spacer-bottom">
- {translate('onboarding.create_project.pat_help.instructions_username.bitbucketcloud')}
- </p>
-
- <div className="text-middle big-spacer-bottom">
- <img
- alt="" // Should be ignored by screen readers
- className="spacer-right"
- height="16"
- src={`${getBaseUrl()}/images/alm/${AlmKeys.BitbucketServer}.svg`}
- />
- <a
- href="https://bitbucket.org/account/settings/"
- rel="noopener noreferrer"
- target="_blank"
- >
- {translate(
- 'onboarding.create_project.pat_help.instructions_username.bitbucketcloud.link'
- )}
- </a>
- </div>
- </>
- )}
-
- <h3>{translate(`onboarding.create_project.pat_help${suffixTranslationKey}.title`)}</h3>
-
- <p className="big-spacer-top big-spacer-bottom">
- {alm === AlmKeys.BitbucketServer ? (
- <FormattedMessage
- id="onboarding.create_project.pat_help.instructions"
- defaultMessage={translate(
- `onboarding.create_project.pat_help.bitbucket.instructions`
- )}
- values={{
- menu: (
- <strong>
- {translate('onboarding.create_project.pat_help.bitbucket.instructions.menu')}
- </strong>
- ),
- button: (
- <strong>
- {translate('onboarding.create_project.pat_help.bitbucket.instructions.button')}
- </strong>
- ),
- }}
- />
- ) : (
- <FormattedMessage
- id="onboarding.create_project.pat_help.instructions"
- defaultMessage={translate(
- `onboarding.create_project.pat_help${suffixTranslationKey}.instructions`
- )}
- values={{
- alm: translate('onboarding.alm', alm),
- }}
- />
- )}
- </p>
-
- {(url || alm === AlmKeys.BitbucketCloud) && (
- <div className="text-middle">
- <img
- alt="" // Should be ignored by screen readers
- className="spacer-right"
- height="16"
- src={`${getBaseUrl()}/images/alm/${
- alm === AlmKeys.BitbucketCloud ? AlmKeys.BitbucketServer : alm
- }.svg`}
- />
- <a href={getPatUrl(alm, url)} rel="noopener noreferrer" target="_blank">
- {translate(`onboarding.create_project.pat_help${suffixTranslationKey}.link`)}
- </a>
- </div>
- )}
-
- <p className="big-spacer-top big-spacer-bottom">
- {translate('onboarding.create_project.pat_help.instructions2', alm)}
- </p>
-
- <ul>
- {alm === AlmKeys.BitbucketServer && (
- <li>
- <FormattedMessage
- defaultMessage={translate(
- 'onboarding.create_project.pat_help.bbs_permission_projects'
- )}
- id="onboarding.create_project.pat_help.bbs_permission_projects"
- values={{
- perm: (
- <strong>
- {translate('onboarding.create_project.pat_help.read_permission')}
- </strong>
- ),
- }}
- />
- </li>
- )}
- {(alm === AlmKeys.BitbucketServer || alm === AlmKeys.BitbucketCloud) && (
- <li>
- <FormattedMessage
- defaultMessage={translate(
- 'onboarding.create_project.pat_help.bbs_permission_repos'
- )}
- id="onboarding.create_project.pat_help.bbs_permission_repos"
- values={{
- perm: (
- <strong>
- {translate('onboarding.create_project.pat_help.read_permission')}
- </strong>
- ),
- }}
- />
- </li>
- )}
-
- {alm === AlmKeys.GitLab && (
- <li className="spacer-bottom">
- <strong>
- {translate('onboarding.create_project.pat_help.gitlab.read_api_permission')}
- </strong>
- </li>
- )}
- </ul>
- </Alert>
- );
- }
-
- render() {
- const {
- almSetting: { alm },
- } = this.props;
- const {
- checkingPat,
- submitting,
- touched,
- password,
- username,
- validationFailed,
- validationErrorMessage,
- firstConnection,
- } = this.state;
-
- if (checkingPat) {
- return <DeferredSpinner className="spacer-left" loading={true} />;
- }
-
- const suffixTranslationKey = alm === AlmKeys.BitbucketCloud ? '.bitbucketcloud' : '';
-
- const isInvalid = validationFailed && !touched;
- const canSubmit = Boolean(password) && (alm !== AlmKeys.BitbucketCloud || Boolean(username));
- const submitButtonDiabled = isInvalid || submitting || !canSubmit;
-
- const errorMessage =
- validationErrorMessage ?? translate('onboarding.create_project.pat_incorrect', alm);
-
- return (
- <div className="display-flex-start">
- <form className="width-50" onSubmit={this.handleSubmit}>
- <h2 className="big">{translate('onboarding.create_project.pat_form.title', alm)}</h2>
- <p className="big-spacer-top big-spacer-bottom">
- {translate('onboarding.create_project.pat_form.help', alm)}
- </p>
-
- {!firstConnection && (
- <Alert className="big-spacer-right" variant="warning">
- <p>{translate('onboarding.create_project.pat.expired.info_message')}</p>
- <p>{translate('onboarding.create_project.pat.expired.info_message_contact')}</p>
- </Alert>
- )}
-
- {alm === AlmKeys.BitbucketCloud && (
- <ValidationInput
- error={undefined}
- labelHtmlFor="enter_username_validation"
- isInvalid={false}
- isValid={false}
- label={translate('onboarding.create_project.enter_username')}
- required={true}
- >
- <input
- autoFocus={true}
- className={classNames('input-super-large', {
- 'is-invalid': isInvalid,
- })}
- id="enter_username_validation"
- minLength={1}
- name="username"
- value={username}
- onChange={this.handleUsernameChange}
- type="text"
- />
- </ValidationInput>
- )}
-
- <ValidationInput
- error={errorMessage}
- labelHtmlFor="personal_access_token_validation"
- isInvalid={false}
- isValid={false}
- label={translate(`onboarding.create_project.enter_pat${suffixTranslationKey}`)}
- required={true}
- >
- <input
- autoFocus={alm !== AlmKeys.BitbucketCloud}
- className={classNames('input-super-large', {
- 'is-invalid': isInvalid,
- })}
- id="personal_access_token_validation"
- minLength={1}
- value={password}
- onChange={this.handlePasswordChange}
- type="text"
- />
- </ValidationInput>
-
- <ValidationInput
- error={errorMessage}
- labelHtmlFor="personal_access_token_submit"
- isInvalid={isInvalid}
- isValid={false}
- label={null}
- >
- <SubmitButton disabled={submitButtonDiabled}>{translate('save')}</SubmitButton>
- <DeferredSpinner className="spacer-left" loading={submitting} />
- </ValidationInput>
- </form>
-
- {this.renderHelpBox(suffixTranslationKey)}
- </div>
- );
- }
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 { FormattedMessage } from 'react-intl';
-import Link from '../../../components/common/Link';
-import { Alert } from '../../../components/ui/Alert';
-import { translate } from '../../../helpers/l10n';
-import { getGlobalSettingsUrl } from '../../../helpers/urls';
-import { AlmKeys } from '../../../types/alm-settings';
-import { ALM_INTEGRATION_CATEGORY } from '../../settings/constants';
-
-export interface WrongBindingCountAlertProps {
- alm: AlmKeys;
- canAdmin: boolean;
-}
-
-export default function WrongBindingCountAlert(props: WrongBindingCountAlertProps) {
- const { alm, canAdmin } = props;
-
- return (
- <Alert variant="error">
- {canAdmin ? (
- <FormattedMessage
- defaultMessage={translate('onboarding.create_project.wrong_binding_count.admin')}
- id="onboarding.create_project.wrong_binding_count.admin"
- values={{
- alm: translate('onboarding.alm', alm),
- url: (
- <Link to={getGlobalSettingsUrl(ALM_INTEGRATION_CATEGORY)}>
- {translate('settings.page')}
- </Link>
- ),
- }}
- />
- ) : (
- <FormattedMessage
- defaultMessage={translate('onboarding.create_project.wrong_binding_count')}
- id="onboarding.create_project.wrong_binding_count"
- values={{
- alm: translate('onboarding.alm', alm),
- }}
- />
- )}
- </Alert>
- );
-}
import { searchAzureRepositories } from '../../../../api/alm-integrations';
import AlmIntegrationsServiceMock from '../../../../api/mocks/AlmIntegrationsServiceMock';
import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock';
+import NewCodePeriodsServiceMock from '../../../../api/mocks/NewCodePeriodsServiceMock';
import { renderApp } from '../../../../helpers/testReactTestingUtils';
import CreateProjectPage, { CreateProjectPageProps } from '../CreateProjectPage';
let almIntegrationHandler: AlmIntegrationsServiceMock;
let almSettingsHandler: AlmSettingsServiceMock;
+let newCodePeriodHandler: NewCodePeriodsServiceMock;
const ui = {
azureCreateProjectButton: byText('onboarding.create_project.select_method.azure'),
beforeAll(() => {
almIntegrationHandler = new AlmIntegrationsServiceMock();
almSettingsHandler = new AlmSettingsServiceMock();
+ newCodePeriodHandler = new NewCodePeriodsServiceMock();
});
beforeEach(() => {
jest.clearAllMocks();
almIntegrationHandler.reset();
almSettingsHandler.reset();
+ newCodePeriodHandler.reset();
});
it('should ask for PAT when it is not set yet and show the import project feature afterwards', async () => {
import { searchForBitbucketServerRepositories } from '../../../../api/alm-integrations';
import AlmIntegrationsServiceMock from '../../../../api/mocks/AlmIntegrationsServiceMock';
import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock';
+import NewCodePeriodsServiceMock from '../../../../api/mocks/NewCodePeriodsServiceMock';
import { renderApp } from '../../../../helpers/testReactTestingUtils';
import CreateProjectPage, { CreateProjectPageProps } from '../CreateProjectPage';
let almIntegrationHandler: AlmIntegrationsServiceMock;
let almSettingsHandler: AlmSettingsServiceMock;
+let newCodePeriodHandler: NewCodePeriodsServiceMock;
const ui = {
bitbucketServerCreateProjectButton: byText('onboarding.create_project.select_method.bitbucket'),
beforeAll(() => {
almIntegrationHandler = new AlmIntegrationsServiceMock();
almSettingsHandler = new AlmSettingsServiceMock();
+ newCodePeriodHandler = new NewCodePeriodsServiceMock();
});
beforeEach(() => {
jest.clearAllMocks();
almIntegrationHandler.reset();
almSettingsHandler.reset();
+ newCodePeriodHandler.reset();
});
it('should ask for PAT when it is not set yet and show the import project feature afterwards', async () => {
import { searchForBitbucketCloudRepositories } from '../../../../api/alm-integrations';
import AlmIntegrationsServiceMock from '../../../../api/mocks/AlmIntegrationsServiceMock';
import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock';
+import NewCodePeriodsServiceMock from '../../../../api/mocks/NewCodePeriodsServiceMock';
import { renderApp } from '../../../../helpers/testReactTestingUtils';
import CreateProjectPage, { CreateProjectPageProps } from '../CreateProjectPage';
let almIntegrationHandler: AlmIntegrationsServiceMock;
let almSettingsHandler: AlmSettingsServiceMock;
+let newCodePeriodHandler: NewCodePeriodsServiceMock;
const ui = {
bitbucketCloudCreateProjectButton: byText(
beforeAll(() => {
almIntegrationHandler = new AlmIntegrationsServiceMock();
almSettingsHandler = new AlmSettingsServiceMock();
+ newCodePeriodHandler = new NewCodePeriodsServiceMock();
});
beforeEach(() => {
jest.clearAllMocks();
almIntegrationHandler.reset();
almSettingsHandler.reset();
+ newCodePeriodHandler.reset();
});
it('should ask for PAT when it is not set yet and show the import project feature afterwards', async () => {
import { getGithubRepositories } from '../../../../api/alm-integrations';
import AlmIntegrationsServiceMock from '../../../../api/mocks/AlmIntegrationsServiceMock';
import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock';
+import NewCodePeriodsServiceMock from '../../../../api/mocks/NewCodePeriodsServiceMock';
import { renderApp } from '../../../../helpers/testReactTestingUtils';
import CreateProjectPage from '../CreateProjectPage';
let almIntegrationHandler: AlmIntegrationsServiceMock;
let almSettingsHandler: AlmSettingsServiceMock;
+let newCodePeriodHandler: NewCodePeriodsServiceMock;
const ui = {
githubCreateProjectButton: byText('onboarding.create_project.select_method.github'),
});
almIntegrationHandler = new AlmIntegrationsServiceMock();
almSettingsHandler = new AlmSettingsServiceMock();
+ newCodePeriodHandler = new NewCodePeriodsServiceMock();
});
beforeEach(() => {
jest.clearAllMocks();
almIntegrationHandler.reset();
almSettingsHandler.reset();
+ newCodePeriodHandler.reset();
});
afterAll(() => {
import { getGitlabProjects } from '../../../../api/alm-integrations';
import AlmIntegrationsServiceMock from '../../../../api/mocks/AlmIntegrationsServiceMock';
import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock';
+import NewCodePeriodsServiceMock from '../../../../api/mocks/NewCodePeriodsServiceMock';
+import { mockNewCodePeriod } from '../../../../helpers/mocks/new-code-period';
import { renderApp } from '../../../../helpers/testReactTestingUtils';
+import { NewCodePeriodSettingType } from '../../../../types/types';
import CreateProjectPage, { CreateProjectPageProps } from '../CreateProjectPage';
jest.mock('../../../../api/alm-integrations');
let almIntegrationHandler: AlmIntegrationsServiceMock;
let almSettingsHandler: AlmSettingsServiceMock;
+let newCodePeriodHandler: NewCodePeriodsServiceMock;
const ui = {
gitlabCreateProjectButton: byText('onboarding.create_project.select_method.gitlab'),
beforeAll(() => {
almIntegrationHandler = new AlmIntegrationsServiceMock();
almSettingsHandler = new AlmSettingsServiceMock();
+ newCodePeriodHandler = new NewCodePeriodsServiceMock();
});
beforeEach(() => {
jest.clearAllMocks();
almIntegrationHandler.reset();
almSettingsHandler.reset();
+ newCodePeriodHandler.reset();
});
it('should ask for PAT when it is not set yet and show the import project feature afterwards', async () => {
);
});
+it('should display a warning if the instance default new code definition is not CaYC compliant', async () => {
+ const user = userEvent.setup();
+ newCodePeriodHandler.setNewCodePeriod(
+ mockNewCodePeriod({ type: NewCodePeriodSettingType.NUMBER_OF_DAYS, value: '91' })
+ );
+ renderCreateProject();
+ await act(async () => {
+ await user.click(ui.gitlabCreateProjectButton.get());
+ await selectEvent.select(ui.instanceSelector.get(), [/conf-final-2/]);
+ });
+
+ expect(screen.getByText('Gitlab project 1')).toBeInTheDocument();
+ expect(screen.getByText('Gitlab project 2')).toBeInTheDocument();
+ expect(screen.getByRole('alert')).toHaveTextContent(
+ 'onboarding.create_project.new_code_option.warning.title'
+ );
+});
+
function renderCreateProject(props: Partial<CreateProjectPageProps> = {}) {
renderApp('project/create', <CreateProjectPage {...props} />);
}
import userEvent from '@testing-library/user-event';
import * as React from 'react';
import { createProject, doesComponentExists } from '../../../../api/components';
+import NewCodePeriodsServiceMock from '../../../../api/mocks/NewCodePeriodsServiceMock';
import { renderComponent } from '../../../../helpers/testReactTestingUtils';
-import ManualProjectCreate from '../ManualProjectCreate';
+import ManualProjectCreate from '../manual/ManualProjectCreate';
jest.mock('../../../../api/components', () => ({
createProject: jest.fn().mockResolvedValue({ project: { key: 'bar', name: 'Bar' } }),
getValue: jest.fn().mockResolvedValue({ value: 'main' }),
}));
+let newCodePeriodHandler: NewCodePeriodsServiceMock;
+
+beforeAll(() => {
+ newCodePeriodHandler = new NewCodePeriodsServiceMock();
+});
+
beforeEach(() => {
jest.clearAllMocks();
+ newCodePeriodHandler.reset();
});
it('should show branch information', async () => {
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 AlmSettingsInstanceSelector from '../../../../components/devops-platform/AlmSettingsInstanceSelector';
+import { hasMessage, translate, translateWithParameters } from '../../../../helpers/l10n';
+import { AlmKeys, AlmSettingsInstance } from '../../../../types/alm-settings';
+
+export interface AlmSettingsInstanceDropdownProps {
+ almKey: AlmKeys;
+ almInstances?: AlmSettingsInstance[];
+ selectedAlmInstance?: AlmSettingsInstance;
+ onChangeConfig: (instance: AlmSettingsInstance) => void;
+}
+
+const MIN_SIZE_INSTANCES = 2;
+
+export default function AlmSettingsInstanceDropdown(props: AlmSettingsInstanceDropdownProps) {
+ const { almKey, almInstances, selectedAlmInstance } = props;
+ if (!almInstances || almInstances.length < MIN_SIZE_INSTANCES) {
+ return null;
+ }
+
+ const almKeyTranslation = hasMessage(`alm.${almKey}.long`)
+ ? `alm.${almKey}.long`
+ : `alm.${almKey}`;
+
+ return (
+ <div className="display-flex-column huge-spacer-bottom">
+ <label htmlFor="alm-config-selector" className="spacer-bottom">
+ {translateWithParameters('alm.configuration.selector.label', translate(almKeyTranslation))}
+ </label>
+ <AlmSettingsInstanceSelector
+ instances={almInstances}
+ onChange={props.onChangeConfig}
+ initialValue={selectedAlmInstance ? selectedAlmInstance.key : undefined}
+ classNames="abs-width-400"
+ inputId="alm-config-selector"
+ />
+ </div>
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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';
+
+export interface CreateProjectPageHeaderProps {
+ additionalActions?: React.ReactNode;
+ title: React.ReactNode;
+}
+
+export default function CreateProjectPageHeader(props: CreateProjectPageHeaderProps) {
+ const { additionalActions, title } = props;
+
+ return (
+ <header className="huge-spacer-bottom bordered-bottom overflow-hidden">
+ <h1 className="pull-left huge big-spacer-bottom">{title}</h1>
+
+ {additionalActions}
+ </header>
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { FormattedMessage } from 'react-intl';
+import { getNewCodePeriod } from '../../../../api/newCodePeriod';
+import { AppStateContextProviderProps } from '../../../../app/components/app-state/AppStateContextProvider';
+import withAppStateContext from '../../../../app/components/app-state/withAppStateContext';
+import DocLink from '../../../../components/common/DocLink';
+import Link from '../../../../components/common/Link';
+import { Alert } from '../../../../components/ui/Alert';
+import { translate } from '../../../../helpers/l10n';
+import { isNewCodeDefinitionCompliant } from '../../../../helpers/periods';
+
+export type InstanceNewCodeDefinitionComplianceWarningProps = AppStateContextProviderProps;
+
+export function InstanceNewCodeDefinitionComplianceWarning({
+ appState: { canAdmin },
+}: InstanceNewCodeDefinitionComplianceWarningProps) {
+ const [isCompliant, setIsCompliant] = React.useState(true);
+
+ React.useEffect(() => {
+ async function fetchInstanceNCDOptionCompliance() {
+ const newCodeDefinition = await getNewCodePeriod();
+ setIsCompliant(isNewCodeDefinitionCompliant(newCodeDefinition));
+ }
+
+ fetchInstanceNCDOptionCompliance();
+ }, []);
+
+ if (isCompliant) {
+ return null;
+ }
+
+ return (
+ <Alert className="huge-spacer-bottom sw-max-w-[700px]" variant="warning">
+ <p className="sw-mb-2 sw-font-bold">
+ {translate('onboarding.create_project.new_code_option.warning.title')}
+ </p>
+ <p className="sw-mb-2">
+ <FormattedMessage
+ id="onboarding.create_project.new_code_option.warning.explanation"
+ defaultMessage={translate(
+ 'onboarding.create_project.new_code_option.warning.explanation'
+ )}
+ values={{
+ action: canAdmin ? (
+ <FormattedMessage
+ id="onboarding.create_project.new_code_option.warning.explanation.action.admin"
+ defaultMessage={translate(
+ 'onboarding.create_project.new_code_option.warning.explanation.action.admin'
+ )}
+ values={{
+ link: (
+ <Link to="/admin/settings?category=new_code_period">
+ {translate(
+ 'onboarding.create_project.new_code_option.warning.explanation.action.admin.link'
+ )}
+ </Link>
+ ),
+ }}
+ />
+ ) : (
+ translate('onboarding.create_project.new_code_option.warning.explanation.action')
+ ),
+ }}
+ />
+ </p>
+ <p>
+ {translate('learn_more')}:
+ <DocLink to="/project-administration/defining-new-code/">
+ {translate('onboarding.create_project.new_code_option.warning.learn_more.link')}
+ </DocLink>
+ </p>
+ </Alert>
+ );
+}
+
+export default withAppStateContext(InstanceNewCodeDefinitionComplianceWarning);
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 classNames from 'classnames';
+import * as React from 'react';
+import { FormattedMessage } from 'react-intl';
+import {
+ checkPersonalAccessTokenIsValid,
+ setAlmPersonalAccessToken,
+} from '../../../../api/alm-integrations';
+import ValidationInput from '../../../../components/controls/ValidationInput';
+import { SubmitButton } from '../../../../components/controls/buttons';
+import { Alert } from '../../../../components/ui/Alert';
+import DeferredSpinner from '../../../../components/ui/DeferredSpinner';
+import { translate } from '../../../../helpers/l10n';
+import { getBaseUrl } from '../../../../helpers/system';
+import { AlmKeys, AlmSettingsInstance } from '../../../../types/alm-settings';
+import { tokenExistedBefore } from '../utils';
+
+interface Props {
+ almSetting: AlmSettingsInstance;
+ resetPat: boolean;
+ onPersonalAccessTokenCreated: () => void;
+}
+
+interface State {
+ validationFailed: boolean;
+ validationErrorMessage?: string;
+ touched: boolean;
+ password: string;
+ username?: string;
+ submitting: boolean;
+ checkingPat: boolean;
+ firstConnection: boolean;
+}
+
+function getPatUrl(alm: AlmKeys, url = '') {
+ if (alm === AlmKeys.BitbucketServer) {
+ return `${url.replace(/\/$/, '')}/account`;
+ } else if (alm === AlmKeys.BitbucketCloud) {
+ return 'https://bitbucket.org/account/settings/app-passwords/new';
+ } else if (alm === AlmKeys.GitLab) {
+ return 'https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html';
+ }
+
+ return '';
+}
+
+export default class PersonalAccessTokenForm extends React.PureComponent<Props, State> {
+ mounted = false;
+
+ constructor(props: Props) {
+ super(props);
+
+ this.state = {
+ checkingPat: false,
+ touched: false,
+ password: '',
+ submitting: false,
+ validationFailed: false,
+ firstConnection: false,
+ };
+ }
+
+ componentDidMount() {
+ this.mounted = true;
+ this.checkPATAndUpdateView();
+ }
+
+ componentDidUpdate(prevProps: Props) {
+ if (this.props.almSetting !== prevProps.almSetting) {
+ this.checkPATAndUpdateView();
+ }
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ }
+
+ checkPATAndUpdateView = async () => {
+ const {
+ almSetting: { key },
+ resetPat,
+ } = this.props;
+
+ // 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 (tokenExistedBefore(error)) {
+ this.setState({
+ checkingPat: false,
+ firstConnection: true,
+ });
+ } else {
+ this.setState({
+ checkingPat: false,
+ validationFailed: true,
+ validationErrorMessage: error,
+ });
+ }
+ }
+ }
+ };
+
+ handleUsernameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+ this.setState({
+ touched: true,
+ username: event.target.value,
+ });
+ };
+
+ handlePasswordChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+ this.setState({
+ touched: true,
+ password: event.target.value,
+ });
+ };
+
+ handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
+ const { password, username } = this.state;
+ const {
+ almSetting: { key },
+ } = this.props;
+
+ e.preventDefault();
+ if (password) {
+ this.setState({ submitting: true });
+
+ await setAlmPersonalAccessToken(key, password, username).catch(() => {
+ /* Set will not check pat validity. We need to check again so we will catch issue after */
+ });
+
+ const { status, error } = await checkPersonalAccessTokenIsValid(key)
+ .then(({ status, error }) => ({ status, error }))
+ .catch(() => ({ status: false, error: translate('default_error_message') }));
+
+ if (this.mounted && status) {
+ // Let's reset status,
+ this.setState({
+ checkingPat: false,
+ touched: false,
+ password: '',
+ submitting: false,
+ username: '',
+ validationFailed: false,
+ });
+ this.props.onPersonalAccessTokenCreated();
+ } else if (this.mounted) {
+ this.setState({
+ submitting: false,
+ touched: false,
+ validationFailed: true,
+ validationErrorMessage: error,
+ });
+ }
+ }
+ };
+
+ renderHelpBox(suffixTranslationKey: string) {
+ const {
+ almSetting: { alm, url },
+ } = this.props;
+
+ return (
+ <Alert className="big-spacer-left width-50" display="block" variant="info">
+ {alm === AlmKeys.BitbucketCloud && (
+ <>
+ <h3>
+ {translate(
+ 'onboarding.create_project.pat_help.instructions_username.bitbucketcloud.title'
+ )}
+ </h3>
+ <p className="big-spacer-top big-spacer-bottom">
+ {translate('onboarding.create_project.pat_help.instructions_username.bitbucketcloud')}
+ </p>
+
+ <div className="text-middle big-spacer-bottom">
+ <img
+ alt="" // Should be ignored by screen readers
+ className="spacer-right"
+ height="16"
+ src={`${getBaseUrl()}/images/alm/${AlmKeys.BitbucketServer}.svg`}
+ />
+ <a
+ href="https://bitbucket.org/account/settings/"
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+ {translate(
+ 'onboarding.create_project.pat_help.instructions_username.bitbucketcloud.link'
+ )}
+ </a>
+ </div>
+ </>
+ )}
+
+ <h3>{translate(`onboarding.create_project.pat_help${suffixTranslationKey}.title`)}</h3>
+
+ <p className="big-spacer-top big-spacer-bottom">
+ {alm === AlmKeys.BitbucketServer ? (
+ <FormattedMessage
+ id="onboarding.create_project.pat_help.instructions"
+ defaultMessage={translate(
+ `onboarding.create_project.pat_help.bitbucket.instructions`
+ )}
+ values={{
+ menu: (
+ <strong>
+ {translate('onboarding.create_project.pat_help.bitbucket.instructions.menu')}
+ </strong>
+ ),
+ button: (
+ <strong>
+ {translate('onboarding.create_project.pat_help.bitbucket.instructions.button')}
+ </strong>
+ ),
+ }}
+ />
+ ) : (
+ <FormattedMessage
+ id="onboarding.create_project.pat_help.instructions"
+ defaultMessage={translate(
+ `onboarding.create_project.pat_help${suffixTranslationKey}.instructions`
+ )}
+ values={{
+ alm: translate('onboarding.alm', alm),
+ }}
+ />
+ )}
+ </p>
+
+ {(url || alm === AlmKeys.BitbucketCloud) && (
+ <div className="text-middle">
+ <img
+ alt="" // Should be ignored by screen readers
+ className="spacer-right"
+ height="16"
+ src={`${getBaseUrl()}/images/alm/${
+ alm === AlmKeys.BitbucketCloud ? AlmKeys.BitbucketServer : alm
+ }.svg`}
+ />
+ <a href={getPatUrl(alm, url)} rel="noopener noreferrer" target="_blank">
+ {translate(`onboarding.create_project.pat_help${suffixTranslationKey}.link`)}
+ </a>
+ </div>
+ )}
+
+ <p className="big-spacer-top big-spacer-bottom">
+ {translate('onboarding.create_project.pat_help.instructions2', alm)}
+ </p>
+
+ <ul>
+ {alm === AlmKeys.BitbucketServer && (
+ <li>
+ <FormattedMessage
+ defaultMessage={translate(
+ 'onboarding.create_project.pat_help.bbs_permission_projects'
+ )}
+ id="onboarding.create_project.pat_help.bbs_permission_projects"
+ values={{
+ perm: (
+ <strong>
+ {translate('onboarding.create_project.pat_help.read_permission')}
+ </strong>
+ ),
+ }}
+ />
+ </li>
+ )}
+ {(alm === AlmKeys.BitbucketServer || alm === AlmKeys.BitbucketCloud) && (
+ <li>
+ <FormattedMessage
+ defaultMessage={translate(
+ 'onboarding.create_project.pat_help.bbs_permission_repos'
+ )}
+ id="onboarding.create_project.pat_help.bbs_permission_repos"
+ values={{
+ perm: (
+ <strong>
+ {translate('onboarding.create_project.pat_help.read_permission')}
+ </strong>
+ ),
+ }}
+ />
+ </li>
+ )}
+
+ {alm === AlmKeys.GitLab && (
+ <li className="spacer-bottom">
+ <strong>
+ {translate('onboarding.create_project.pat_help.gitlab.read_api_permission')}
+ </strong>
+ </li>
+ )}
+ </ul>
+ </Alert>
+ );
+ }
+
+ render() {
+ const {
+ almSetting: { alm },
+ } = this.props;
+ const {
+ checkingPat,
+ submitting,
+ touched,
+ password,
+ username,
+ validationFailed,
+ validationErrorMessage,
+ firstConnection,
+ } = this.state;
+
+ if (checkingPat) {
+ return <DeferredSpinner className="spacer-left" loading={true} />;
+ }
+
+ const suffixTranslationKey = alm === AlmKeys.BitbucketCloud ? '.bitbucketcloud' : '';
+
+ const isInvalid = validationFailed && !touched;
+ const canSubmit = Boolean(password) && (alm !== AlmKeys.BitbucketCloud || Boolean(username));
+ const submitButtonDiabled = isInvalid || submitting || !canSubmit;
+
+ const errorMessage =
+ validationErrorMessage ?? translate('onboarding.create_project.pat_incorrect', alm);
+
+ return (
+ <div className="display-flex-start">
+ <form className="width-50" onSubmit={this.handleSubmit}>
+ <h2 className="big">{translate('onboarding.create_project.pat_form.title', alm)}</h2>
+ <p className="big-spacer-top big-spacer-bottom">
+ {translate('onboarding.create_project.pat_form.help', alm)}
+ </p>
+
+ {!firstConnection && (
+ <Alert className="big-spacer-right" variant="warning">
+ <p>{translate('onboarding.create_project.pat.expired.info_message')}</p>
+ <p>{translate('onboarding.create_project.pat.expired.info_message_contact')}</p>
+ </Alert>
+ )}
+
+ {alm === AlmKeys.BitbucketCloud && (
+ <ValidationInput
+ error={undefined}
+ labelHtmlFor="enter_username_validation"
+ isInvalid={false}
+ isValid={false}
+ label={translate('onboarding.create_project.enter_username')}
+ required={true}
+ >
+ <input
+ autoFocus={true}
+ className={classNames('input-super-large', {
+ 'is-invalid': isInvalid,
+ })}
+ id="enter_username_validation"
+ minLength={1}
+ name="username"
+ value={username}
+ onChange={this.handleUsernameChange}
+ type="text"
+ />
+ </ValidationInput>
+ )}
+
+ <ValidationInput
+ error={errorMessage}
+ labelHtmlFor="personal_access_token_validation"
+ isInvalid={false}
+ isValid={false}
+ label={translate(`onboarding.create_project.enter_pat${suffixTranslationKey}`)}
+ required={true}
+ >
+ <input
+ autoFocus={alm !== AlmKeys.BitbucketCloud}
+ className={classNames('input-super-large', {
+ 'is-invalid': isInvalid,
+ })}
+ id="personal_access_token_validation"
+ minLength={1}
+ value={password}
+ onChange={this.handlePasswordChange}
+ type="text"
+ />
+ </ValidationInput>
+
+ <ValidationInput
+ error={errorMessage}
+ labelHtmlFor="personal_access_token_submit"
+ isInvalid={isInvalid}
+ isValid={false}
+ label={null}
+ >
+ <SubmitButton disabled={submitButtonDiabled}>{translate('save')}</SubmitButton>
+ <DeferredSpinner className="spacer-left" loading={submitting} />
+ </ValidationInput>
+ </form>
+
+ {this.renderHelpBox(suffixTranslationKey)}
+ </div>
+ );
+ }
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { FormattedMessage } from 'react-intl';
+import Link from '../../../../components/common/Link';
+import { Alert } from '../../../../components/ui/Alert';
+import { translate } from '../../../../helpers/l10n';
+import { getGlobalSettingsUrl } from '../../../../helpers/urls';
+import { AlmKeys } from '../../../../types/alm-settings';
+import { ALM_INTEGRATION_CATEGORY } from '../../../settings/constants';
+
+export interface WrongBindingCountAlertProps {
+ alm: AlmKeys;
+ canAdmin: boolean;
+}
+
+export default function WrongBindingCountAlert(props: WrongBindingCountAlertProps) {
+ const { alm, canAdmin } = props;
+
+ return (
+ <Alert variant="error">
+ {canAdmin ? (
+ <FormattedMessage
+ defaultMessage={translate('onboarding.create_project.wrong_binding_count.admin')}
+ id="onboarding.create_project.wrong_binding_count.admin"
+ values={{
+ alm: translate('onboarding.alm', alm),
+ url: (
+ <Link to={getGlobalSettingsUrl(ALM_INTEGRATION_CATEGORY)}>
+ {translate('settings.page')}
+ </Link>
+ ),
+ }}
+ />
+ ) : (
+ <FormattedMessage
+ defaultMessage={translate('onboarding.create_project.wrong_binding_count')}
+ id="onboarding.create_project.wrong_binding_count"
+ values={{
+ alm: translate('onboarding.alm', alm),
+ }}
+ />
+ )}
+ </Alert>
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 classNames from 'classnames';
+import { debounce, isEmpty } from 'lodash';
+import * as React from 'react';
+import { FormattedMessage } from 'react-intl';
+import { createProject, doesComponentExists } from '../../../../api/components';
+import { getValue } from '../../../../api/settings';
+import DocLink from '../../../../components/common/DocLink';
+import ProjectKeyInput from '../../../../components/common/ProjectKeyInput';
+import ValidationInput from '../../../../components/controls/ValidationInput';
+import { SubmitButton } from '../../../../components/controls/buttons';
+import { Alert } from '../../../../components/ui/Alert';
+import DeferredSpinner from '../../../../components/ui/DeferredSpinner';
+import MandatoryFieldsExplanation from '../../../../components/ui/MandatoryFieldsExplanation';
+import { translate } from '../../../../helpers/l10n';
+import { PROJECT_KEY_INVALID_CHARACTERS, validateProjectKey } from '../../../../helpers/projects';
+import { ProjectKeyValidationResult } from '../../../../types/component';
+import { GlobalSettingKeys } from '../../../../types/settings';
+import CreateProjectPageHeader from '../components/CreateProjectPageHeader';
+import InstanceNewCodeDefinitionComplianceWarning from '../components/InstanceNewCodeDefinitionComplianceWarning';
+import { PROJECT_NAME_MAX_LEN } from '../constants';
+
+interface Props {
+ branchesEnabled: boolean;
+ onProjectCreate: (projectKey: string) => void;
+}
+
+interface State {
+ projectName: string;
+ projectNameError?: string;
+ projectNameTouched: boolean;
+ projectKey: string;
+ projectKeyError?: string;
+ projectKeyTouched: boolean;
+ validatingProjectKey: boolean;
+ mainBranchName: string;
+ mainBranchNameError?: string;
+ mainBranchNameTouched: boolean;
+ submitting: boolean;
+}
+
+const DEBOUNCE_DELAY = 250;
+
+type ValidState = State & Required<Pick<State, 'projectKey' | 'projectName'>>;
+
+export default class ManualProjectCreate extends React.PureComponent<Props, State> {
+ mounted = false;
+
+ constructor(props: Props) {
+ super(props);
+ this.state = {
+ projectKey: '',
+ projectName: '',
+ submitting: false,
+ projectKeyTouched: false,
+ projectNameTouched: false,
+ mainBranchName: 'main',
+ mainBranchNameTouched: false,
+ validatingProjectKey: false,
+ };
+ this.checkFreeKey = debounce(this.checkFreeKey, DEBOUNCE_DELAY);
+ }
+
+ componentDidMount() {
+ this.mounted = true;
+ this.fetchMainBranchName();
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ }
+
+ fetchMainBranchName = async () => {
+ const mainBranchName = await getValue({ key: GlobalSettingKeys.MainBranchName });
+
+ if (this.mounted && mainBranchName.value !== undefined) {
+ this.setState({ mainBranchName: mainBranchName.value });
+ }
+ };
+
+ checkFreeKey = (key: string) => {
+ this.setState({ validatingProjectKey: true });
+
+ doesComponentExists({ component: key })
+ .then((alreadyExist) => {
+ if (this.mounted && key === this.state.projectKey) {
+ this.setState({
+ projectKeyError: alreadyExist
+ ? translate('onboarding.create_project.project_key.taken')
+ : undefined,
+ validatingProjectKey: false,
+ });
+ }
+ })
+ .catch(() => {
+ if (this.mounted && key === this.state.projectKey) {
+ this.setState({ projectKeyError: undefined, validatingProjectKey: false });
+ }
+ });
+ };
+
+ canSubmit(state: State): state is ValidState {
+ const { projectKey, projectKeyError, projectName, projectNameError, mainBranchName } = state;
+ return Boolean(
+ projectKeyError === undefined &&
+ projectNameError === undefined &&
+ !isEmpty(projectKey) &&
+ !isEmpty(projectName) &&
+ !isEmpty(mainBranchName)
+ );
+ }
+
+ handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
+ event.preventDefault();
+ const { projectKey, projectName, mainBranchName } = this.state;
+ if (this.canSubmit(this.state)) {
+ this.setState({ submitting: true });
+ createProject({
+ project: projectKey,
+ name: (projectName || projectKey).trim(),
+ mainBranch: mainBranchName,
+ }).then(
+ ({ project }) => this.props.onProjectCreate(project.key),
+ () => {
+ if (this.mounted) {
+ this.setState({ submitting: false });
+ }
+ }
+ );
+ }
+ };
+
+ handleProjectKeyChange = (projectKey: string, fromUI = false) => {
+ const projectKeyError = this.validateKey(projectKey);
+
+ this.setState({
+ projectKey,
+ projectKeyError,
+ projectKeyTouched: fromUI,
+ });
+
+ if (projectKeyError === undefined) {
+ this.checkFreeKey(projectKey);
+ }
+ };
+
+ handleProjectNameChange = (projectName: string, fromUI = false) => {
+ this.setState(
+ {
+ projectName,
+ projectNameError: this.validateName(projectName),
+ projectNameTouched: fromUI,
+ },
+ () => {
+ if (!this.state.projectKeyTouched) {
+ const sanitizedProjectKey = this.state.projectName
+ .trim()
+ .replace(PROJECT_KEY_INVALID_CHARACTERS, '-');
+ this.handleProjectKeyChange(sanitizedProjectKey);
+ }
+ }
+ );
+ };
+
+ handleBranchNameChange = (mainBranchName: string, fromUI = false) => {
+ this.setState({
+ mainBranchName,
+ mainBranchNameError: this.validateMainBranchName(mainBranchName),
+ mainBranchNameTouched: fromUI,
+ });
+ };
+
+ validateKey = (projectKey: string) => {
+ const result = validateProjectKey(projectKey);
+ return result === ProjectKeyValidationResult.Valid
+ ? undefined
+ : translate('onboarding.create_project.project_key.error', result);
+ };
+
+ validateName = (projectName: string) => {
+ if (isEmpty(projectName)) {
+ return translate('onboarding.create_project.display_name.error.empty');
+ }
+ return undefined;
+ };
+
+ validateMainBranchName = (mainBranchName: string) => {
+ if (isEmpty(mainBranchName)) {
+ return translate('onboarding.create_project.main_branch_name.error.empty');
+ }
+ return undefined;
+ };
+
+ render() {
+ const {
+ projectKey,
+ projectKeyError,
+ projectKeyTouched,
+ projectName,
+ projectNameError,
+ projectNameTouched,
+ validatingProjectKey,
+ mainBranchName,
+ mainBranchNameError,
+ mainBranchNameTouched,
+ submitting,
+ } = this.state;
+ const { branchesEnabled } = this.props;
+
+ const touched = Boolean(projectKeyTouched || projectNameTouched);
+ const projectNameIsInvalid = projectNameTouched && projectNameError !== undefined;
+ const projectNameIsValid = projectNameTouched && projectNameError === undefined;
+ const mainBranchNameIsValid = mainBranchNameTouched && mainBranchNameError === undefined;
+ const mainBranchNameIsInvalid = mainBranchNameTouched && mainBranchNameError !== undefined;
+
+ return (
+ <>
+ <CreateProjectPageHeader title={translate('onboarding.create_project.setup_manually')} />
+
+ <InstanceNewCodeDefinitionComplianceWarning />
+
+ <form id="create-project-manual" onSubmit={this.handleFormSubmit}>
+ <MandatoryFieldsExplanation className="big-spacer-bottom" />
+
+ <ValidationInput
+ className="form-field"
+ description={translate('onboarding.create_project.display_name.description')}
+ error={projectNameError}
+ labelHtmlFor="project-name"
+ isInvalid={projectNameIsInvalid}
+ isValid={projectNameIsValid}
+ label={translate('onboarding.create_project.display_name')}
+ required={true}
+ >
+ <input
+ className={classNames('input-super-large', {
+ 'is-invalid': projectNameIsInvalid,
+ 'is-valid': projectNameIsValid,
+ })}
+ id="project-name"
+ maxLength={PROJECT_NAME_MAX_LEN}
+ minLength={1}
+ onChange={(e) => this.handleProjectNameChange(e.currentTarget.value, true)}
+ type="text"
+ value={projectName}
+ autoFocus={true}
+ />
+ </ValidationInput>
+ <ProjectKeyInput
+ error={projectKeyError}
+ label={translate('onboarding.create_project.project_key')}
+ onProjectKeyChange={(e) => this.handleProjectKeyChange(e.currentTarget.value, true)}
+ projectKey={projectKey}
+ touched={touched}
+ validating={validatingProjectKey}
+ />
+
+ <ValidationInput
+ className="form-field"
+ description={
+ <FormattedMessage
+ id="onboarding.create_project.main_branch_name.description"
+ defaultMessage={translate('onboarding.create_project.main_branch_name.description')}
+ values={{
+ learn_more: (
+ <DocLink to="/analyzing-source-code/branches/branch-analysis">
+ {translate('learn_more')}
+ </DocLink>
+ ),
+ }}
+ />
+ }
+ error={mainBranchNameError}
+ labelHtmlFor="main-branch-name"
+ isInvalid={mainBranchNameIsInvalid}
+ isValid={mainBranchNameIsValid}
+ label={translate('onboarding.create_project.main_branch_name')}
+ required={true}
+ >
+ <input
+ id="main-branch-name"
+ className={classNames('input-super-large', {
+ 'is-invalid': mainBranchNameIsInvalid,
+ 'is-valid': mainBranchNameIsValid,
+ })}
+ minLength={1}
+ onChange={(e) => this.handleBranchNameChange(e.currentTarget.value, true)}
+ type="text"
+ value={mainBranchName}
+ />
+ </ValidationInput>
+
+ <SubmitButton disabled={!this.canSubmit(this.state) || submitting}>
+ {translate('set_up')}
+ </SubmitButton>
+ <DeferredSpinner className="spacer-left" loading={submitting} />
+ </form>
+
+ {branchesEnabled && (
+ <Alert variant="info" display="inline" className="big-spacer-top">
+ {translate('onboarding.create_project.pr_decoration.information')}
+ </Alert>
+ )}
+ </>
+ );
+ }
+}
filter: grayscale(100%);
}
-.create-project-manual {
- display: flex !important;
- justify-content: space-between;
-}
-
.create-project-azdo-repo {
width: 410px;
min-height: 40px;
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-import { NewCodePeriodSettingType } from '../../types/types';
-import { getPeriodLabel } from '../periods';
+import { NewCodePeriod, NewCodePeriodSettingType } from '../../types/types';
+import { getPeriodLabel, isNewCodeDefinitionCompliant } from '../periods';
import { mockPeriod } from '../testMocks';
const formatter = jest.fn((v) => v);
expect(formatter).toHaveBeenCalledTimes(1);
});
});
+
+describe('isNewCodeDefinitionCompliant', () => {
+ it.each([
+ [{ type: NewCodePeriodSettingType.NUMBER_OF_DAYS, value: '0' }, false],
+ [{ type: NewCodePeriodSettingType.NUMBER_OF_DAYS, value: '15' }, true],
+ [{ type: NewCodePeriodSettingType.NUMBER_OF_DAYS, value: '91' }, false],
+ [{ type: NewCodePeriodSettingType.PREVIOUS_VERSION }, true],
+ [{ type: NewCodePeriodSettingType.REFERENCE_BRANCH }, true],
+ [{ type: NewCodePeriodSettingType.SPECIFIC_ANALYSIS }, true],
+ ])(
+ 'should test for new code definition compliance properly',
+ (newCodePeriod: NewCodePeriod, result: boolean) => {
+ expect(isNewCodeDefinitionCompliant(newCodePeriod)).toEqual(result);
+ }
+ );
+});
import { parseDate } from '../helpers/dates';
import { translate, translateWithParameters } from '../helpers/l10n';
import { ApplicationPeriod } from '../types/application';
-import { NewCodePeriodSettingType, Period } from '../types/types';
+import { NewCodePeriod, NewCodePeriodSettingType, Period } from '../types/types';
export function getPeriodLabel(
period: Period | undefined,
): period is ApplicationPeriod {
return (period as ApplicationPeriod).project !== undefined;
}
+
+const MIN_NUMBER_OF_DAYS = 1;
+const MAX_NUMBER_OF_DAYS = 90;
+
+export function isNewCodeDefinitionCompliant(newCodePeriod: NewCodePeriod) {
+ switch (newCodePeriod.type) {
+ case NewCodePeriodSettingType.NUMBER_OF_DAYS:
+ return (
+ newCodePeriod.value !== undefined &&
+ MIN_NUMBER_OF_DAYS <= +newCodePeriod.value &&
+ +newCodePeriod.value <= MAX_NUMBER_OF_DAYS
+ );
+ default:
+ return true;
+ }
+}
}
export interface NewCodePeriod {
- type?: NewCodePeriodSettingType;
+ type: NewCodePeriodSettingType;
value?: string;
effectiveValue?: string;
inherited?: boolean;
onboarding.create_project.gitlab.no_projects=No projects could be fetched from Gitlab. Contact your system administrator, or {link}.
onboarding.create_project.gitlab.link=See on GitLab
+onboarding.create_project.new_code_option.warning.title=Your global new code definition is not compliant with the Clean as You Code methodology
+onboarding.create_project.new_code_option.warning.explanation=New projects use the global new code definition by default. {action} so that new projects benefit from the Clean as You Code methodology by default.
+onboarding.create_project.new_code_option.warning.explanation.action=We recommend that you ask an administrator of this SonarQube instance to update the global new code definition
+onboarding.create_project.new_code_option.warning.explanation.action.admin=We recommend that you update the global new code definition under {link}
+onboarding.create_project.new_code_option.warning.explanation.action.admin.link=General Settings - New Code
+onboarding.create_project.new_code_option.warning.learn_more.link=Defining New Code
+
onboarding.token.header=Provide a token
onboarding.token.text=The token is used to identify you when an analysis is performed. If it has been compromised, you can revoke it at any point in time in your {link}.
onboarding.token.text.PROJECT_ANALYSIS_TOKEN=The project token is used to identify you when an analysis is performed. If it has been compromised, you can revoke it at any point in time in your {link}.