From: Jeremy Davis Date: Tue, 16 Jun 2020 15:48:08 +0000 (+0200) Subject: SONAR-13475 List GitHub repositories X-Git-Tag: 8.4.0.35506~9 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=46d5b7c51b9c41d35d90416281144385555b9059;p=sonarqube.git SONAR-13475 List GitHub repositories --- diff --git a/server/sonar-web/src/main/js/api/alm-integrations.ts b/server/sonar-web/src/main/js/api/alm-integrations.ts index dd8e6388c90..3b5712b5d22 100644 --- a/server/sonar-web/src/main/js/api/alm-integrations.ts +++ b/server/sonar-web/src/main/js/api/alm-integrations.ts @@ -19,7 +19,12 @@ */ import { get, getJSON, post, postJSON } from 'sonar-ui-common/helpers/request'; import throwGlobalError from '../app/utils/throwGlobalError'; -import { BitbucketProject, BitbucketRepository } from '../types/alm-integration'; +import { + BitbucketProject, + BitbucketRepository, + GithubOrganization, + GithubRepository +} from '../types/alm-integration'; import { ProjectBase } from './components'; export function setAlmPersonalAccessToken(almSetting: string, pat: string): Promise { @@ -29,7 +34,7 @@ export function setAlmPersonalAccessToken(almSetting: string, pat: string): Prom export function checkPersonalAccessTokenIsValid(almSetting: string): Promise { return get('/api/alm_integrations/check_pat', { almSetting }) .then(() => true) - .catch(response => { + .catch((response: Response) => { if (response.status === 400) { return false; } else { @@ -81,3 +86,31 @@ export function searchForBitbucketServerRepositories( repositoryName }); } + +export function getGithubClientId(almSetting: string): Promise<{ clientId: string }> { + return getJSON('/api/alm_integrations/get_github_client_id', { almSetting }); +} + +export function getGithubOrganizations( + almSetting: string, + token: string +): Promise<{ organizations: GithubOrganization[] }> { + return getJSON('/api/alm_integrations/list_github_enterprise_organizations', { + almSetting, + token + }); +} + +export function getGithubRepositories( + almSetting: string, + organization: string, + p = 1, + query?: string +): Promise<{ repositories: GithubRepository[]; paging: T.Paging }> { + return getJSON('/api/alm_integrations/list_github_enterprise_repositories', { + almSetting, + organization, + p, + query: query || undefined + }); +} diff --git a/server/sonar-web/src/main/js/apps/create/project/BitbucketProjectCreateRenderer.tsx b/server/sonar-web/src/main/js/apps/create/project/BitbucketProjectCreateRenderer.tsx index 94afa8a8d68..878a11b609c 100644 --- a/server/sonar-web/src/main/js/apps/create/project/BitbucketProjectCreateRenderer.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/BitbucketProjectCreateRenderer.tsx @@ -88,7 +88,6 @@ export default function BitbucketProjectCreateRenderer(props: BitbucketProjectCr ) } - showBreadcrumb={true} title={ void; } -export default function CreateProjectModeSelection(props: CreateProjectModeSelectionProps) { - const { bbsBindingCount, loadingBindings } = props; - const bbsBindingDisabled = bbsBindingCount !== 1 || loadingBindings; +function renderAlmOption( + props: CreateProjectModeSelectionProps, + alm: AlmKeys, + mode: CreateProjectModes +) { + const { almCounts, loadingBindings } = props; + + const count = almCounts[alm]; + const disabled = count !== 1 || loadingBindings; + + return ( + + ); +} + +export default function CreateProjectModeSelection(props: CreateProjectModeSelectionProps) { return ( <>
@@ -43,7 +97,7 @@ export default function CreateProjectModeSelection(props: CreateProjectModeSelec

{translate('onboarding.create_project.select_method')}

-
+
- + {renderAlmOption(props, AlmKeys.Bitbucket, CreateProjectModes.BitbucketServer)} + {renderAlmOption(props, AlmKeys.GitHub, CreateProjectModes.GitHub)}
); diff --git a/server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx b/server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx index 27822170748..5737a060933 100644 --- a/server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx @@ -21,7 +21,6 @@ import * as React from 'react'; import { Helmet } from 'react-helmet-async'; import { WithRouterProps } from 'react-router'; import { translate } from 'sonar-ui-common/helpers/l10n'; -import { addWhitePageClass, removeWhitePageClass } from 'sonar-ui-common/helpers/pages'; import { getAlmSettings } from '../../../api/alm-settings'; import A11ySkipTarget from '../../../app/components/a11y/A11ySkipTarget'; import { whenLoggedIn } from '../../../components/hoc/whenLoggedIn'; @@ -30,50 +29,38 @@ import { getProjectUrl } from '../../../helpers/urls'; import { AlmKeys, AlmSettingsInstance } from '../../../types/alm-settings'; import BitbucketProjectCreate from './BitbucketProjectCreate'; import CreateProjectModeSelection from './CreateProjectModeSelection'; +import GitHubProjectCreate from './GitHubProjectCreate'; import ManualProjectCreate from './ManualProjectCreate'; import './style.css'; import { CreateProjectModes } from './types'; interface Props extends Pick { - appState: Pick; + appState: Pick; currentUser: T.LoggedInUser; } interface State { bitbucketSettings: AlmSettingsInstance[]; + githubSettings: AlmSettingsInstance[]; loading: boolean; } export class CreateProjectPage extends React.PureComponent { mounted = false; - state: State = { bitbucketSettings: [], loading: false }; + state: State = { bitbucketSettings: [], githubSettings: [], loading: false }; componentDidMount() { const { - appState: { branchesEnabled }, - location + appState: { branchesEnabled } } = this.props; this.mounted = true; if (branchesEnabled) { this.fetchAlmBindings(); } - - if (location.query?.mode || !branchesEnabled) { - addWhitePageClass(); - } - } - - componentDidUpdate(prevProps: Props) { - if (this.props.location.query?.mode && !prevProps.location.query?.mode) { - addWhitePageClass(); - } else if (!this.props.location.query?.mode && prevProps.location.query?.mode) { - removeWhitePageClass(); - } } componentWillUnmount() { this.mounted = false; - removeWhitePageClass(); } fetchAlmBindings = () => { @@ -83,6 +70,7 @@ export class CreateProjectPage extends React.PureComponent { if (this.mounted) { this.setState({ bitbucketSettings: almSettings.filter(s => s.alm === AlmKeys.Bitbucket), + githubSettings: almSettings.filter(s => s.alm === AlmKeys.GitHub), loading: false }); } @@ -108,47 +96,67 @@ export class CreateProjectPage extends React.PureComponent { }); }; + renderForm(mode?: CreateProjectModes) { + const { + appState: { canAdmin }, + location + } = this.props; + const { bitbucketSettings, githubSettings, loading } = this.state; + + switch (mode) { + case CreateProjectModes.BitbucketServer: { + return ( + + ); + } + case CreateProjectModes.GitHub: { + return ( + + ); + } + case CreateProjectModes.Manual: { + return ; + } + default: { + const almCounts = { + [AlmKeys.Azure]: 0, + [AlmKeys.Bitbucket]: bitbucketSettings.length, + [AlmKeys.GitHub]: githubSettings.length, + [AlmKeys.GitLab]: 0 + }; + return ( + + ); + } + } + } + render() { const { appState: { branchesEnabled }, - currentUser, location } = this.props; - const { bitbucketSettings, loading } = this.state; - const mode: CreateProjectModes | undefined = location.query?.mode; - const showManualForm = !branchesEnabled || mode === CreateProjectModes.Manual; - const showBBSForm = branchesEnabled && mode === CreateProjectModes.BitbucketServer; return ( <>
- {!showBBSForm && !showManualForm && ( - - )} - - {showManualForm && ( - - )} - - {showBBSForm && ( - - )} + {this.renderForm(branchesEnabled ? mode : CreateProjectModes.Manual)}
); diff --git a/server/sonar-web/src/main/js/apps/create/project/CreateProjectPageHeader.tsx b/server/sonar-web/src/main/js/apps/create/project/CreateProjectPageHeader.tsx index 1cbd7c56918..5b2616bc422 100644 --- a/server/sonar-web/src/main/js/apps/create/project/CreateProjectPageHeader.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/CreateProjectPageHeader.tsx @@ -18,29 +18,18 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import { Link } from 'react-router'; -import { translate } from 'sonar-ui-common/helpers/l10n'; export interface CreateProjectPageHeaderProps { additionalActions?: React.ReactNode; - showBreadcrumb?: boolean; title: React.ReactNode; } export default function CreateProjectPageHeader(props: CreateProjectPageHeaderProps) { - const { additionalActions, showBreadcrumb, title } = props; + const { additionalActions, title } = props; return (
-

- {showBreadcrumb && ( - <> - {translate('my_account.create_new.TRK')} - - - )} - {title} -

+

{title}

{additionalActions}
diff --git a/server/sonar-web/src/main/js/apps/create/project/GitHubProjectCreate.tsx b/server/sonar-web/src/main/js/apps/create/project/GitHubProjectCreate.tsx new file mode 100644 index 00000000000..3417541c022 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/GitHubProjectCreate.tsx @@ -0,0 +1,222 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { debounce } from 'lodash'; +import * as React from 'react'; +import { getHostUrl } from 'sonar-ui-common/helpers/urls'; +import { + getGithubClientId, + getGithubOrganizations, + getGithubRepositories +} from '../../../api/alm-integrations'; +import { GithubOrganization, GithubRepository } from '../../../types/alm-integration'; +import { AlmKeys, AlmSettingsInstance } from '../../../types/alm-settings'; +import GitHubProjectCreateRenderer from './GitHubProjectCreateRenderer'; + +interface Props { + canAdmin: boolean; + code?: string; + settings?: AlmSettingsInstance; +} + +interface State { + error: boolean; + loading: boolean; + loadingRepositories: boolean; + organizations: GithubOrganization[]; + repositoryPaging: T.Paging; + repositories: GithubRepository[]; + searchQuery: string; + selectedOrganization?: GithubOrganization; + selectedRepository?: GithubRepository; +} + +const REPOSITORY_PAGE_SIZE = 30; + +export default class GitHubProjectCreate extends React.Component { + mounted = false; + + constructor(props: Props) { + super(props); + + this.state = { + error: false, + loading: true, + loadingRepositories: false, + organizations: [], + repositories: [], + repositoryPaging: { pageSize: REPOSITORY_PAGE_SIZE, total: 0, pageIndex: 1 }, + searchQuery: '' + }; + + this.triggerSearch = debounce(this.triggerSearch, 250); + } + + componentDidMount() { + this.mounted = true; + + this.initialize(); + } + + componentDidUpdate(prevProps: Props) { + if (!prevProps.settings && this.props.settings) { + this.initialize(); + } + } + + componentWillUnmount() { + this.mounted = false; + } + + async initialize() { + const { code, settings } = this.props; + + if (!settings) { + this.setState({ error: true }); + return; + } else { + this.setState({ error: false }); + } + + try { + if (!code) { + await this.redirectToGithub(settings); + } else { + await this.fetchOrganizations(settings, code); + } + } catch (e) { + if (this.mounted) { + this.setState({ error: true }); + } + } + } + + async redirectToGithub(settings: AlmSettingsInstance) { + const { clientId } = await getGithubClientId(settings.key); + + const queryParams = [ + { param: 'client_id', value: clientId }, + { param: 'redirect_uri', value: `${getHostUrl()}/projects/create?mode=${AlmKeys.GitHub}` } + ] + .map(({ param, value }) => `${param}=${value}`) + .join('&'); + + window.location.replace(`https://github.com/login/oauth/authorize?${queryParams}`); + } + + async fetchOrganizations(settings: AlmSettingsInstance, token: string) { + const { organizations } = await getGithubOrganizations(settings.key, token); + + if (this.mounted) { + this.setState({ loading: false, organizations }); + } + } + + async fetchRepositories(params: { organizationKey: string; page?: number; query?: string }) { + const { organizationKey, page = 1, query } = params; + const { settings } = this.props; + + if (!settings) { + this.setState({ error: true }); + return; + } + + this.setState({ loadingRepositories: true }); + + const data = await getGithubRepositories(settings.key, organizationKey, page, query); + + if (this.mounted) { + this.setState(({ repositories }) => ({ + loadingRepositories: false, + repositoryPaging: data.paging, + repositories: page === 1 ? data.repositories : [...repositories, ...data.repositories] + })); + } + } + + triggerSearch = (query: string) => { + const { selectedOrganization } = this.state; + if (selectedOrganization) { + this.fetchRepositories({ organizationKey: selectedOrganization.key, query }); + } + }; + + handleSelectOrganization = (key: string) => { + this.setState(({ organizations }) => ({ + 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 + }); + } + }; + + render() { + const { canAdmin } = this.props; + const { + error, + loading, + loadingRepositories, + organizations, + repositoryPaging, + repositories, + searchQuery, + selectedOrganization, + selectedRepository + } = this.state; + return ( + + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/create/project/GitHubProjectCreateRenderer.tsx b/server/sonar-web/src/main/js/apps/create/project/GitHubProjectCreateRenderer.tsx new file mode 100644 index 00000000000..289bdf442c1 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/GitHubProjectCreateRenderer.tsx @@ -0,0 +1,209 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import { FormattedMessage } from 'react-intl'; +import { Link } from 'react-router'; +import ListFooter from 'sonar-ui-common/components/controls/ListFooter'; +import Radio from 'sonar-ui-common/components/controls/Radio'; +import SearchBox from 'sonar-ui-common/components/controls/SearchBox'; +import SearchSelect from 'sonar-ui-common/components/controls/SearchSelect'; +import CheckIcon from 'sonar-ui-common/components/icons/CheckIcon'; +import { Alert } from 'sonar-ui-common/components/ui/Alert'; +import DeferredSpinner from 'sonar-ui-common/components/ui/DeferredSpinner'; +import { translate } from 'sonar-ui-common/helpers/l10n'; +import { getBaseUrl } from '../../../helpers/system'; +import { GithubOrganization, GithubRepository } from '../../../types/alm-integration'; +import CreateProjectPageHeader from './CreateProjectPageHeader'; + +export interface GitHubProjectCreateRendererProps { + canAdmin: boolean; + error: boolean; + loading: boolean; + loadingRepositories: boolean; + onLoadMore: () => void; + onSearch: (q: string) => void; + onSelectOrganization: (key: string) => void; + onSelectRepository: (key: string) => void; + organizations: GithubOrganization[]; + repositories?: GithubRepository[]; + repositoryPaging: T.Paging; + searchQuery: string; + selectedOrganization?: GithubOrganization; + selectedRepository?: GithubRepository; +} + +function orgToOption({ key, name }: GithubOrganization) { + return { value: key, label: name }; +} + +export default function GitHubProjectCreateRenderer(props: GitHubProjectCreateRendererProps) { + const { + canAdmin, + error, + loading, + loadingRepositories, + organizations, + repositories, + repositoryPaging, + searchQuery, + selectedOrganization, + selectedRepository + } = props; + + return ( +
+ + + {translate('onboarding.create_project.github.title')} + + } + /> + + {error ? ( +
+
+

+ {translate('onboarding.create_project.github.warning.title')} +

+ + {canAdmin ? ( + + {translate('onboarding.create_project.github.warning.message_admin.link')} + + ) + }} + /> + ) : ( + translate('onboarding.create_project.github.warning.message') + )} + +
+
+ ) : ( + +
+ + {organizations.length > 0 ? ( + + Promise.resolve( + organizations.filter(o => !q || o.name.includes(q)).map(orgToOption) + ) + } + minimumQueryLength={0} + onSelect={({ value }) => props.onSelectOrganization(value)} + value={selectedOrganization && orgToOption(selectedOrganization)} + /> + ) : ( + !loading && ( + + {canAdmin ? ( + + {translate( + 'onboarding.create_project.github.warning.message_admin.link' + )} + + ) + }} + /> + ) : ( + translate('onboarding.create_project.github.no_orgs') + )} + + ) + )} +
+
+ )} + + {selectedOrganization && repositories && ( +
+
+ +
+ + {repositories.length === 0 ? ( +
+ + {translate('no_results')} + +
+ ) : ( + repositories.map(r => ( + +
+
{r.name}
+ {r.sqProjectKey && ( + + {translate('onboarding.create_project.repository_imported')} + + + )} +
+
+ )) + )} + +
+ +
+
+ )} +
+ ); +} diff --git a/server/sonar-web/src/main/js/apps/create/project/ManualProjectCreate.tsx b/server/sonar-web/src/main/js/apps/create/project/ManualProjectCreate.tsx index 65212ebb525..6ae7d229bb3 100644 --- a/server/sonar-web/src/main/js/apps/create/project/ManualProjectCreate.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/ManualProjectCreate.tsx @@ -33,8 +33,6 @@ import CreateProjectPageHeader from './CreateProjectPageHeader'; import './ManualProjectCreate.css'; interface Props { - branchesEnabled?: boolean; - currentUser: T.LoggedInUser; onProjectCreate: (projectKeys: string[]) => void; } @@ -182,16 +180,12 @@ export default class ManualProjectCreate extends React.PureComponent - +
diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/CreateProjectModeSelection-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/CreateProjectModeSelection-test.tsx index d97f54cf8d0..2e11297a12d 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/CreateProjectModeSelection-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/CreateProjectModeSelection-test.tsx @@ -21,6 +21,7 @@ import { shallow } from 'enzyme'; import * as React from 'react'; import { click } from 'sonar-ui-common/helpers/testUtils'; +import { AlmKeys } from '../../../../types/alm-settings'; import CreateProjectModeSelection, { CreateProjectModeSelectionProps } from '../CreateProjectModeSelection'; @@ -28,9 +29,10 @@ import { CreateProjectModes } from '../types'; it('should render correctly', () => { expect(shallowRender()).toMatchSnapshot('default'); - expect(shallowRender({ loadingBindings: true })).toMatchSnapshot('loading bbs instances'); - expect(shallowRender({ bbsBindingCount: 0 })).toMatchSnapshot('no bbs instances'); - expect(shallowRender({ bbsBindingCount: 2 })).toMatchSnapshot('too many bbs instances'); + expect(shallowRender({ loadingBindings: true })).toMatchSnapshot('loading instances'); + expect(shallowRender({}, { [AlmKeys.Bitbucket]: 0, [AlmKeys.GitHub]: 2 })).toMatchSnapshot( + 'invalid configs' + ); }); it('should correctly pass the selected mode up', () => { @@ -40,14 +42,27 @@ it('should correctly pass the selected mode up', () => { click(wrapper.find('button.create-project-mode-type-manual')); expect(onSelectMode).toBeCalledWith(CreateProjectModes.Manual); - click(wrapper.find('button.create-project-mode-type-bbs')); + click(wrapper.find('button.create-project-mode-type-bbs').at(0)); expect(onSelectMode).toBeCalledWith(CreateProjectModes.BitbucketServer); + + click(wrapper.find('button.create-project-mode-type-bbs').at(1)); + expect(onSelectMode).toBeCalledWith(CreateProjectModes.GitHub); }); -function shallowRender(props: Partial = {}) { +function shallowRender( + props: Partial = {}, + almCountOverrides = {} +) { + const almCounts = { + [AlmKeys.Azure]: 0, + [AlmKeys.Bitbucket]: 1, + [AlmKeys.GitHub]: 0, + [AlmKeys.GitLab]: 0, + ...almCountOverrides + }; return shallow( ({ getAlmSettings: jest.fn().mockResolvedValue([{ alm: AlmKeys.Bitbucket, key: 'foo' }]) })); -jest.mock('sonar-ui-common/helpers/pages', () => ({ - addWhitePageClass: jest.fn(), - removeWhitePageClass: jest.fn() -})); - beforeEach(jest.clearAllMocks); it('should render correctly', () => { @@ -57,7 +51,6 @@ it('should render correctly if the manual method is selected', () => { expect(push).toBeCalledWith(expect.objectContaining(location)); expect(wrapper.setProps({ location: mockLocation(location) })).toMatchSnapshot(); - expect(addWhitePageClass).toBeCalled(); }); it('should render correctly if the BBS method is selected', () => { @@ -69,7 +62,17 @@ it('should render correctly if the BBS method is selected', () => { expect(push).toBeCalledWith(expect.objectContaining(location)); expect(wrapper.setProps({ location: mockLocation(location) })).toMatchSnapshot(); - expect(addWhitePageClass).toBeCalled(); +}); + +it('should render correctly if the GitHub method is selected', () => { + const push = jest.fn(); + const location = { query: { mode: CreateProjectModes.GitHub } }; + const wrapper = shallowRender({ router: mockRouter({ push }) }); + + wrapper.instance().handleModeSelect(CreateProjectModes.GitHub); + expect(push).toBeCalledWith(expect.objectContaining(location)); + + expect(wrapper.setProps({ location: mockLocation(location) })).toMatchSnapshot(); }); function shallowRender(props: Partial = {}) { diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/CreateProjectPageHeader-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/CreateProjectPageHeader-test.tsx index 8c4c7fb8d41..e0b99089532 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/CreateProjectPageHeader-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/CreateProjectPageHeader-test.tsx @@ -24,7 +24,6 @@ import CreateProjectPageHeader, { CreateProjectPageHeaderProps } from '../Create it('should render correctly', () => { expect(shallowRender()).toMatchSnapshot('default'); - expect(shallowRender({ showBreadcrumb: true })).toMatchSnapshot('with breadcrumb'); expect(shallowRender({ additionalActions: 'Bar' })).toMatchSnapshot('additional content'); }); diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/GitHubProjectCreate-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/GitHubProjectCreate-test.tsx new file mode 100644 index 00000000000..36203dcfc8e --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/GitHubProjectCreate-test.tsx @@ -0,0 +1,180 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { shallow } from 'enzyme'; +import * as React from 'react'; +import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; +import { + getGithubClientId, + getGithubOrganizations, + getGithubRepositories +} from '../../../../api/alm-integrations'; +import { mockGitHubRepository } from '../../../../helpers/mocks/alm-integrations'; +import { mockAlmSettingsInstance } from '../../../../helpers/mocks/alm-settings'; +import GitHubProjectCreate from '../GitHubProjectCreate'; + +jest.mock('../../../../api/alm-integrations', () => ({ + getGithubClientId: jest.fn().mockResolvedValue({ clientId: 'client-id-124' }), + getGithubOrganizations: jest.fn().mockResolvedValue({ organizations: [] }), + getGithubRepositories: jest.fn().mockResolvedValue({ repositories: [], paging: {} }) +})); + +const originalLocation = window.location; + +beforeAll(() => { + const location = { + ...window.location, + replace: jest.fn() + }; + Object.defineProperty(window, 'location', { + writable: true, + value: location + }); +}); + +afterAll(() => { + Object.defineProperty(window, 'location', { + writable: true, + value: originalLocation + }); +}); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +it('should handle no settings', async () => { + const wrapper = shallowRender({ settings: undefined }); + await waitAndUpdate(wrapper); + expect(wrapper.state().error).toBe(true); +}); + +it('should redirect when no code', async () => { + const wrapper = shallowRender(); + await waitAndUpdate(wrapper); + + expect(getGithubClientId).toBeCalled(); + expect(window.location.replace).toBeCalled(); +}); + +it('should fetch organizations when code', async () => { + const organizations = [ + { key: '1', name: 'org1' }, + { key: '2', name: 'org2' } + ]; + (getGithubOrganizations as jest.Mock).mockResolvedValueOnce({ organizations }); + const wrapper = shallowRender({ code: '123456' }); + await waitAndUpdate(wrapper); + + expect(getGithubOrganizations).toBeCalled(); + expect(wrapper.state().organizations).toBe(organizations); +}); + +it('should handle org selection', async () => { + const organizations = [ + { key: '1', name: 'org1' }, + { key: '2', name: 'org2' } + ]; + (getGithubOrganizations as jest.Mock).mockResolvedValueOnce({ organizations }); + const repositories = [mockGitHubRepository()]; + (getGithubRepositories as jest.Mock).mockResolvedValueOnce({ + repositories, + paging: { total: 1, pageIndex: 1 } + }); + const wrapper = shallowRender({ code: '123456' }); + await waitAndUpdate(wrapper); + + wrapper.instance().handleSelectOrganization('1'); + await waitAndUpdate(wrapper); + + expect(wrapper.state().selectedOrganization).toBe(organizations[0]); + expect(getGithubRepositories).toBeCalled(); + + expect(wrapper.state().repositories).toBe(repositories); +}); + +it('should load more', async () => { + const wrapper = shallowRender(); + + const startRepos = [mockGitHubRepository({ key: 'first' })]; + const repositories = [ + mockGitHubRepository({ key: 'r1' }), + mockGitHubRepository({ key: 'r2' }), + mockGitHubRepository({ key: 'r3' }) + ]; + (getGithubRepositories as jest.Mock).mockResolvedValueOnce({ repositories }); + + wrapper.setState({ + repositories: startRepos, + selectedOrganization: { key: 'o1', name: 'org' } + }); + + wrapper.instance().handleLoadMore(); + + await waitAndUpdate(wrapper); + + expect(getGithubRepositories).toBeCalled(); + expect(wrapper.state().repositories).toEqual([...startRepos, ...repositories]); +}); + +it('should handle search', async () => { + const wrapper = shallowRender(); + const query = 'query'; + const startRepos = [mockGitHubRepository({ key: 'first' })]; + const repositories = [ + mockGitHubRepository({ key: 'r1' }), + mockGitHubRepository({ key: 'r2' }), + mockGitHubRepository({ key: 'r3' }) + ]; + (getGithubRepositories as jest.Mock).mockResolvedValueOnce({ repositories }); + + wrapper.setState({ + repositories: startRepos, + selectedOrganization: { key: 'o1', name: 'org' } + }); + + wrapper.instance().handleSearch(query); + + await waitAndUpdate(wrapper); + + expect(getGithubRepositories).toBeCalledWith('a', 'o1', 1, query); + expect(wrapper.state().repositories).toEqual(repositories); +}); + +it('should handle repository selection', async () => { + const repo = mockGitHubRepository(); + const wrapper = shallowRender(); + wrapper.setState({ repositories: [repo, mockGitHubRepository({ key: 'other' })] }); + + wrapper.instance().handleSelectRepository(repo.key); + await waitAndUpdate(wrapper); + + expect(wrapper.state().selectedRepository).toBe(repo); +}); + +function shallowRender(props: Partial = {}) { + return shallow( + + ); +} diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/GitHubProjectCreateRenderer-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/GitHubProjectCreateRenderer-test.tsx new file mode 100644 index 00000000000..b6a0ec8a26a --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/GitHubProjectCreateRenderer-test.tsx @@ -0,0 +1,126 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { shallow } from 'enzyme'; +import * as React from 'react'; +import Radio from 'sonar-ui-common/components/controls/Radio'; +import SearchBox from 'sonar-ui-common/components/controls/SearchBox'; +import SearchSelect from 'sonar-ui-common/components/controls/SearchSelect'; +import { mockGitHubRepository } from '../../../../helpers/mocks/alm-integrations'; +import { GithubOrganization } from '../../../../types/alm-integration'; +import GitHubProjectCreateRenderer, { + GitHubProjectCreateRendererProps +} from '../GitHubProjectCreateRenderer'; + +it('should render correctly', () => { + expect(shallowRender()).toMatchSnapshot('default'); + expect(shallowRender({ error: true })).toMatchSnapshot('error'); + expect(shallowRender({ canAdmin: true, error: true })).toMatchSnapshot('error for admin'); + + const organizations: GithubOrganization[] = [ + { key: 'o1', name: 'org1' }, + { key: 'o2', name: 'org2' } + ]; + + expect(shallowRender({ organizations })).toMatchSnapshot('organizations'); + expect( + shallowRender({ + organizations, + selectedOrganization: organizations[1] + }) + ).toMatchSnapshot('no repositories'); + + const repositories = [ + mockGitHubRepository({ id: '1', key: 'repo1' }), + mockGitHubRepository({ id: '2', key: 'repo2', sqProjectKey: 'repo2' }), + mockGitHubRepository({ id: '3', key: 'repo3' }) + ]; + + expect( + shallowRender({ + organizations, + selectedOrganization: organizations[1], + repositories, + selectedRepository: repositories[2] + }) + ).toMatchSnapshot('repositories'); +}); + +describe('callback', () => { + const onSelectOrganization = jest.fn(); + const onSelectRepository = jest.fn(); + const onSearch = jest.fn(); + const org = { key: 'o1', name: 'org' }; + const wrapper = shallowRender({ + onSelectOrganization, + onSelectRepository, + onSearch, + organizations: [org], + selectedOrganization: org, + repositories: [mockGitHubRepository()] + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should be called when org is selected', () => { + const value = 'o1'; + wrapper.find(SearchSelect).props().onSelect!({ value }); + expect(onSelectOrganization).toBeCalledWith(value); + }); + + it('should be called when searchbox is changed', () => { + const value = 'search query'; + wrapper + .find(SearchBox) + .props() + .onChange(value); + expect(onSearch).toBeCalledWith(value); + }); + + it('should be called when repo is selected', () => { + const value = 'repo1'; + wrapper + .find(Radio) + .props() + .onCheck(value); + expect(onSelectRepository).toBeCalledWith(value); + }); +}); + +function shallowRender(props: Partial = {}) { + return shallow( + + ); +} diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/ManualProjectCreate-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/ManualProjectCreate-test.tsx index 33123a28e99..9eeb5f26cac 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/ManualProjectCreate-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/ManualProjectCreate-test.tsx @@ -144,10 +144,6 @@ it('should have an error when the name is incorrect', () => { function shallowRender(props: Partial = {}) { return shallow( - + ); } diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/BitbucketProjectCreateRenderer-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/BitbucketProjectCreateRenderer-test.tsx.snap index 57c13175014..5b0b50bc580 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/BitbucketProjectCreateRenderer-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/BitbucketProjectCreateRenderer-test.tsx.snap @@ -21,7 +21,6 @@ exports[`should render correctly: default 1`] = `
} - showBreadcrumb={true} title={
} - showBreadcrumb={true} title={ } - showBreadcrumb={true} title={ } - showBreadcrumb={true} title={ } - showBreadcrumb={true} title={ } - showBreadcrumb={true} title={
+
`; -exports[`should render correctly: loading bbs instances 1`] = ` +exports[`should render correctly: invalid configs 1`] = `
-
-
-`; - -exports[`should render correctly: no bbs instances 1`] = ` - -
-

- my_account.create_new.TRK -

-

- onboarding.create_project.select_method -

-
-
- @@ -191,7 +189,7 @@ exports[`should render correctly: no bbs instances 1`] = ` `; -exports[`should render correctly: too many bbs instances 1`] = ` +exports[`should render correctly: loading instances 1`] = `
+
diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/CreateProjectPage-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/CreateProjectPage-test.tsx.snap index 99b42043c24..a7b160cc0bd 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/CreateProjectPage-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/CreateProjectPage-test.tsx.snap @@ -16,7 +16,14 @@ exports[`should render correctly 1`] = ` id="create-project" > @@ -40,16 +47,6 @@ exports[`should render correctly if no branch support 1`] = ` id="create-project" >
@@ -93,6 +90,28 @@ exports[`should render correctly if the BBS method is selected 1`] = `
`; +exports[`should render correctly if the GitHub method is selected 1`] = ` + + + +
+ +
+
+`; + exports[`should render correctly if the manual method is selected 1`] = ` diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/CreateProjectPageHeader-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/CreateProjectPageHeader-test.tsx.snap index 0dbf8413a9c..c8d161620b0 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/CreateProjectPageHeader-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/CreateProjectPageHeader-test.tsx.snap @@ -24,25 +24,3 @@ exports[`should render correctly: default 1`] = ` `; - -exports[`should render correctly: with breadcrumb 1`] = ` -
-

- - my_account.create_new.TRK - - - Foo -

-
-`; diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/GitHubProjectCreateRenderer-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/GitHubProjectCreateRenderer-test.tsx.snap new file mode 100644 index 00000000000..c97790e1755 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/GitHubProjectCreateRenderer-test.tsx.snap @@ -0,0 +1,378 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly: default 1`] = ` +
+ + + onboarding.create_project.github.title + + } + /> + +
+ + + onboarding.create_project.github.no_orgs + +
+
+
+`; + +exports[`should render correctly: error 1`] = ` +
+ + + onboarding.create_project.github.title + + } + /> +
+
+

+ onboarding.create_project.github.warning.title +

+ + onboarding.create_project.github.warning.message + +
+
+
+`; + +exports[`should render correctly: error for admin 1`] = ` +
+ + + onboarding.create_project.github.title + + } + /> +
+
+

+ onboarding.create_project.github.warning.title +

+ + + onboarding.create_project.github.warning.message_admin.link + , + } + } + /> + +
+
+
+`; + +exports[`should render correctly: no repositories 1`] = ` +
+ + + onboarding.create_project.github.title + + } + /> + +
+ + +
+
+
+`; + +exports[`should render correctly: organizations 1`] = ` +
+ + + onboarding.create_project.github.title + + } + /> + +
+ + +
+
+
+`; + +exports[`should render correctly: repositories 1`] = ` +
+ + + onboarding.create_project.github.title + + } + /> + +
+ + +
+
+
+
+ +
+ +
+
+ repository 1 +
+
+
+ +
+
+ repository 1 +
+ + onboarding.create_project.already_imported + + +
+
+ +
+
+ repository 1 +
+
+
+
+ +
+
+
+`; diff --git a/server/sonar-web/src/main/js/apps/create/project/style.css b/server/sonar-web/src/main/js/apps/create/project/style.css index 10291b37851..7930a3b216e 100644 --- a/server/sonar-web/src/main/js/apps/create/project/style.css +++ b/server/sonar-web/src/main/js/apps/create/project/style.css @@ -23,20 +23,12 @@ #create-project header { padding-top: 20px; -} - -.white-page #create-project header { - background-color: white; + background-color: var(--barBackgroundColor); position: sticky; top: var(--globalNavHeight); z-index: var(--pageMainZIndex); } -.create-project-modes { - margin: 0 auto; - max-width: 500px; -} - .create-project-manual { display: flex !important; justify-content: space-between; @@ -54,3 +46,17 @@ width: 250px; min-height: 40px; } + +.create-project-github-repository { + box-sizing: border-box; + width: 33.33%; +} + +.create-project-github-repository .notice { + display: block; + position: absolute; +} + +.create-project-github-repository .notice svg { + color: var(--green); +} diff --git a/server/sonar-web/src/main/js/apps/create/project/types.ts b/server/sonar-web/src/main/js/apps/create/project/types.ts index d991cd60d3a..0fe8944a405 100644 --- a/server/sonar-web/src/main/js/apps/create/project/types.ts +++ b/server/sonar-web/src/main/js/apps/create/project/types.ts @@ -19,5 +19,6 @@ */ export enum CreateProjectModes { Manual = 'manual', - BitbucketServer = 'bitbucket' + BitbucketServer = 'bitbucket', + GitHub = 'github' } diff --git a/server/sonar-web/src/main/js/helpers/mocks/alm-integrations.ts b/server/sonar-web/src/main/js/helpers/mocks/alm-integrations.ts index 6e04e4807cd..b4a1275fb58 100644 --- a/server/sonar-web/src/main/js/helpers/mocks/alm-integrations.ts +++ b/server/sonar-web/src/main/js/helpers/mocks/alm-integrations.ts @@ -17,7 +17,11 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { BitbucketProject, BitbucketRepository } from '../../types/alm-integration'; +import { + BitbucketProject, + BitbucketRepository, + GithubRepository +} from '../../types/alm-integration'; export function mockBitbucketProject(overrides: Partial = {}): BitbucketProject { return { @@ -39,3 +43,14 @@ export function mockBitbucketRepository( ...overrides }; } + +export function mockGitHubRepository(overrides: Partial = {}): GithubRepository { + return { + id: 'id1234', + key: 'key3456', + name: 'repository 1', + sqProjectKey: '', + url: 'owner/repo1', + ...overrides + }; +} diff --git a/server/sonar-web/src/main/js/types/alm-integration.ts b/server/sonar-web/src/main/js/types/alm-integration.ts index e746ef153b1..4a2a5864e8c 100644 --- a/server/sonar-web/src/main/js/types/alm-integration.ts +++ b/server/sonar-web/src/main/js/types/alm-integration.ts @@ -35,3 +35,16 @@ export type BitbucketProjectRepositories = T.Dict<{ allShown: boolean; repositories: BitbucketRepository[]; }>; + +export interface GithubOrganization { + key: string; + name: string; +} + +export interface GithubRepository { + id: string; + key: string; + name: string; + url: string; + sqProjectKey: string; +} diff --git a/sonar-application/build.gradle b/sonar-application/build.gradle index 572cd7fff79..cc4cb315611 100644 --- a/sonar-application/build.gradle +++ b/sonar-application/build.gradle @@ -179,6 +179,7 @@ zip.doFirst { zip.doLast { def minLength = 220000000 def maxLength = 235000000 + def length = archiveFile.get().asFile.length() if (length < minLength) throw new GradleException("$archiveName size ($length) too small. Min is $minLength") diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index 99d6119ead3..21a20f56194 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -3097,7 +3097,12 @@ onboarding.project_analysis.header=Analyze your project onboarding.project_analysis.description=We initialized your project on {instance}, now it's up to you to launch analyses! onboarding.project_analysis.guide_to_integrate_pipelines=follow the guide to integrating with Pipelines -onboarding.create_project.setup_manually=Create manually +onboarding.create_project.setup_manually=Create a project +onboarding.create_project.select_method.manual=Manually +onboarding.create_project.select_method.bitbucket=From Bitbucket Server +onboarding.create_project.select_method.github=From GitHub Enterprise +onboarding.create_project.alm_not_configured=Currently not active +onboarding.create_project.check_alm_supported=Checking if available onboarding.create_project.project_key=Project key onboarding.create_project.project_key.description=Up to 400 characters. Allowed characters are alphanumeric, '-' (dash), '_' (underscore), '.' (period) and ':' (colon), with at least one non-digit. onboarding.create_project.project_key.error.empty=You must provide at least one character. @@ -3114,18 +3119,18 @@ onboarding.create_project.display_name.help=Some scanners might override the val onboarding.create_project.repository_imported=Already set up onboarding.create_project.see_project=See the project onboarding.create_project.search_repositories_by_name=Search for repository name starting with... +onboarding.create_project.search_repositories=Search for a repository onboarding.create_project.select_repositories=Select repositories onboarding.create_project.select_all_repositories=Select all available repositories -onboarding.create_project.from_bbs=From Bitbucket Server +onboarding.create_project.from_bbs=Create a project from Bitbucket Server onboarding.create_project.grant_access_to_bbs.title=Grant access to your repositories onboarding.create_project.grant_access_to_bbs.help=SonarQube needs a personal access token to access and list your repositories from Bitbucket Server. onboarding.create_project.select_method=How do you want to create your project? -onboarding.create_project.select_method.manual=Manually -onboarding.create_project.select_method.from_bbs=From a Bitbucket Server repository -onboarding.create_project.check_bbs_supported=Checking if available -onboarding.create_project.too_many_bbs_instances_X=You must have exactly 1 Bitbucket Server instance configured in order to use this method. You currently have {0}. -onboarding.create_project.zero_bbs_instances=You must first configure a Bitbucket Server instance. -onboarding.create_project.bbs_not_configured=Currently not active +onboarding.create_project.too_many_alm_instances.bitbucket=You must have exactly 1 Bitbucket Server instance configured in order to use this method. +onboarding.create_project.too_many_alm_instances.github=You must have exactly 1 Bitbucket Server instance configured in order to use this method. +onboarding.create_project.alm_instances_count_X=You currently have {0}. +onboarding.create_project.zero_alm_instances.bitbucket=You must first configure a Bitbucket Server instance. +onboarding.create_project.zero_alm_instances.github=You must first configure a GitHub Enterprise instance. onboarding.create_project.no_bbs_binding=You must have exactly at least 1 Bitbucket Server instance configured in order to use this method, but none were found. Either create the project manually, or contact your system administrator. onboarding.create_project.no_bbs_binding.admin=You must have exactly at least 1 Bitbucket Server instance configured in order to use this method. You can configure instances under {url}. onboarding.create_project.enter_pat=Enter personal access token @@ -3144,6 +3149,14 @@ onboarding.create_project.no_bbs_repos.filter=No repositories match your filter. onboarding.create_project.only_showing_X_first_repos=We're only displaying the first {0} repositories. If you're looking for a repository that's not in this list, use the search above. onboarding.create_project.import_selected_repo=Set up selected repository onboarding.create_project.go_to_project=Go to project +onboarding.create_project.github.title=Which GitHub repository do you want to setup? +onboarding.create_project.github.choose_organization=Choose organization +onboarding.create_project.github.warning.title=Could not connect to GitHub Enterprise +onboarding.create_project.github.warning.message=Please contact an administrator to configure GitHub Enterprise integration. +onboarding.create_project.github.warning.message_admin=Please make sure a GitHub Enterprise instance is configured in the {link} to create a new project from a repository. +onboarding.create_project.github.warning.message_admin.link=ALM integration settings +onboarding.create_project.github.no_orgs=We couldn't load any organizations with your key. Contact an administrator. +onboarding.create_project.github.no_orgs_admin=We couldn't load any organizations with your key. Check the GitHub Enterprise instance configured in the {link}. onboarding.create_organization.page.header=Create Organization onboarding.create_organization.page.description=An organization is a space where a team or a whole company can collaborate accross many projects.