diff --git a/.changeset/tame-crabs-push.md b/.changeset/tame-crabs-push.md new file mode 100644 index 0000000000..02c0b874e1 --- /dev/null +++ b/.changeset/tame-crabs-push.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Refactoring the `RepoUrlPicker` into separate provider components to encapsulate provider nonsense diff --git a/plugins/scaffolder/api-report.md b/plugins/scaffolder/api-report.md index 0c3904adf6..8443de563e 100644 --- a/plugins/scaffolder/api-report.md +++ b/plugins/scaffolder/api-report.md @@ -36,9 +36,7 @@ export function createScaffolderFieldExtension( options: FieldExtensionOptions, ): Extension<() => null>; -// Warning: (ae-missing-release-tag) "CustomFieldValidator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export type CustomFieldValidator = | ((data: T, field: FieldValidation) => void) | (( @@ -96,9 +94,18 @@ export const EntityTagsPickerFieldExtension: () => null; // @public export const FavouriteTemplate: (props: Props) => JSX.Element; -// Warning: (ae-missing-release-tag) "FieldExtensionOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public +export interface FieldExtensionComponentProps< + ReturnValue, + UiOptions extends {} = {}, +> extends FieldProps { + // (undocumented) + uiSchema: { + 'ui:options'?: UiOptions; + }; +} + +// @public export type FieldExtensionOptions = { name: string; component: (props: FieldProps) => JSX.Element | null; @@ -140,18 +147,25 @@ export const OwnerPickerFieldExtension: () => null; // Warning: (ae-missing-release-tag) "RepoUrlPicker" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export const RepoUrlPicker: ({ - onChange, - uiSchema, - rawErrors, - formData, -}: FieldProps) => JSX.Element; +export const RepoUrlPicker: ( + props: FieldExtensionComponentProps, +) => JSX.Element; // Warning: (ae-missing-release-tag) "RepoUrlPickerFieldExtension" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export const RepoUrlPickerFieldExtension: () => null; +// Warning: (ae-missing-release-tag) "RepoUrlPickerUiOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface RepoUrlPickerUiOptions { + // (undocumented) + allowedHosts?: string[]; + // (undocumented) + allowedOwners?: string[]; +} + // Warning: (ae-missing-release-tag) "ScaffolderApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/AzureRepoPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/AzureRepoPicker.test.tsx new file mode 100644 index 0000000000..ffcf2920c1 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/AzureRepoPicker.test.tsx @@ -0,0 +1,76 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { AzureRepoPicker } from './AzureRepoPicker'; +import { render, fireEvent } from '@testing-library/react'; + +describe('AzureRepoPicker', () => { + it('renders the three input fields', async () => { + const { getAllByRole } = render( + , + ); + + const allInputs = getAllByRole('textbox'); + + expect(allInputs).toHaveLength(3); + }); + + describe('org field', () => { + it('calls onChange when the organisation changes', () => { + const onChange = jest.fn(); + const { getAllByRole } = render( + , + ); + + const orgInput = getAllByRole('textbox')[0]; + + fireEvent.change(orgInput, { target: { value: 'org' } }); + + expect(onChange).toHaveBeenCalledWith({ organization: 'org' }); + }); + }); + + describe('owner field', () => { + it('calls onChange when the owner changes', () => { + const onChange = jest.fn(); + const { getAllByRole } = render( + , + ); + + const ownerInput = getAllByRole('textbox')[1]; + + fireEvent.change(ownerInput, { target: { value: 'owner' } }); + + expect(onChange).toHaveBeenCalledWith({ owner: 'owner' }); + }); + }); + + describe('repoName field', () => { + it('calls onChange when the repoName changes', () => { + const onChange = jest.fn(); + const { getAllByRole } = render( + , + ); + + const repoNameInput = getAllByRole('textbox')[2]; + + fireEvent.change(repoNameInput, { target: { value: 'repoName' } }); + + expect(onChange).toHaveBeenCalledWith({ repoName: 'repoName' }); + }); + }); +}); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/AzureRepoPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/AzureRepoPicker.tsx new file mode 100644 index 0000000000..0efadf4017 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/AzureRepoPicker.tsx @@ -0,0 +1,76 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import FormControl from '@material-ui/core/FormControl'; +import FormHelperText from '@material-ui/core/FormHelperText'; +import Input from '@material-ui/core/Input'; +import InputLabel from '@material-ui/core/InputLabel'; +import { RepoUrlPickerState } from './types'; + +export const AzureRepoPicker = (props: { + state: RepoUrlPickerState; + onChange: (state: RepoUrlPickerState) => void; + rawErrors: string[]; +}) => { + const { rawErrors, state, onChange } = props; + const { organization, repoName, owner } = state; + return ( + <> + 0 && !organization} + > + Organization + onChange({ organization: e.target.value })} + value={organization} + /> + + The organization that this repo will belong to + + + 0 && !owner} + > + Owner + onChange({ owner: e.target.value })} + value={owner} + /> + The Owner that this repo will belong to + + 0 && !repoName} + > + Repository + onChange({ repoName: e.target.value })} + value={repoName} + /> + The name of the repository + + + ); +}; diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx new file mode 100644 index 0000000000..17fe656006 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.test.tsx @@ -0,0 +1,100 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { BitbucketRepoPicker } from './BitbucketRepoPicker'; +import { render, fireEvent } from '@testing-library/react'; + +describe('BitbucketRepoPicker', () => { + it('renders workspace input when host is bitbucket.org', () => { + const state = { host: 'bitbucket.org', workspace: 'lolsWorkspace' }; + + const { getAllByRole } = render( + , + ); + + expect(getAllByRole('textbox')).toHaveLength(3); + expect(getAllByRole('textbox')[0]).toHaveValue('lolsWorkspace'); + }); + + it('hides the workspace input when the host is not bitbucket.org', () => { + const state = { + host: 'mycustom.domain.bitbucket.org', + }; + + const { getAllByRole } = render( + , + ); + + expect(getAllByRole('textbox')).toHaveLength(2); + }); + describe('workspace field', () => { + it('calls onChange when the workspace changes', () => { + const onChange = jest.fn(); + const { getAllByRole } = render( + , + ); + + const workspaceInput = getAllByRole('textbox')[0]; + + fireEvent.change(workspaceInput, { target: { value: 'test-workspace' } }); + + expect(onChange).toHaveBeenCalledWith({ workspace: 'test-workspace' }); + }); + }); + + describe('project field', () => { + it('calls onChange when the project changes', () => { + const onChange = jest.fn(); + const { getAllByRole } = render( + , + ); + + const projectInput = getAllByRole('textbox')[1]; + + fireEvent.change(projectInput, { target: { value: 'test-project' } }); + + expect(onChange).toHaveBeenCalledWith({ project: 'test-project' }); + }); + }); + + describe('repoName field', () => { + it('calls onChange when the repoName changes', () => { + const onChange = jest.fn(); + const { getAllByRole } = render( + , + ); + + const repoNameInput = getAllByRole('textbox')[2]; + + fireEvent.change(repoNameInput, { target: { value: 'test-repo' } }); + + expect(onChange).toHaveBeenCalledWith({ repoName: 'test-repo' }); + }); + }); +}); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx new file mode 100644 index 0000000000..64eb4b8dac --- /dev/null +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/BitbucketRepoPicker.tsx @@ -0,0 +1,79 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import FormControl from '@material-ui/core/FormControl'; +import FormHelperText from '@material-ui/core/FormHelperText'; +import Input from '@material-ui/core/Input'; +import InputLabel from '@material-ui/core/InputLabel'; +import { RepoUrlPickerState } from './types'; + +export const BitbucketRepoPicker = (props: { + onChange: (state: RepoUrlPickerState) => void; + state: RepoUrlPickerState; + rawErrors: string[]; +}) => { + const { onChange, rawErrors, state } = props; + const { host, workspace, project, repoName } = state; + return ( + <> + {host === 'bitbucket.org' && ( + 0 && !workspace} + > + Workspace + onChange({ workspace: e.target.value })} + value={workspace} + /> + + The Organization that this repo will belong to + + + )} + 0 && !project} + > + Project + onChange({ project: e.target.value })} + value={project} + /> + + The Project that this repo will belong to + + + 0 && !repoName} + > + Repository + onChange({ repoName: e.target.value })} + value={repoName} + /> + The name of the repository + + + ); +}; diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GithubRepoPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GithubRepoPicker.test.tsx new file mode 100644 index 0000000000..2c80c40c22 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GithubRepoPicker.test.tsx @@ -0,0 +1,107 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { GithubRepoPicker } from './GithubRepoPicker'; +import { render, fireEvent } from '@testing-library/react'; + +describe('GithubRepoPicker', () => { + describe('owner field', () => { + it('renders a select if there is a list of allowed owners', async () => { + const allowedOwners = ['owner1', 'owner2']; + const { findByText } = render( + , + ); + + expect(await findByText('owner1')).toBeInTheDocument(); + expect(await findByText('owner2')).toBeInTheDocument(); + }); + + it('calls onChange when the owner is changed to a different owner', async () => { + const onChange = jest.fn(); + const allowedOwners = ['owner1', 'owner2']; + const { getByRole } = render( + , + ); + + await fireEvent.change(getByRole('combobox'), { + target: { value: 'owner2' }, + }); + + expect(onChange).toHaveBeenCalledWith({ owner: 'owner2' }); + }); + + it('is disabled picked when only one allowed owner', () => { + const onChange = jest.fn(); + const allowedOwners = ['owner1']; + const { getByRole } = render( + , + ); + + expect(getByRole('combobox')).toBeDisabled(); + }); + + it('should display free text if no allowed owners are passed', async () => { + const onChange = jest.fn(); + const { getAllByRole } = render( + , + ); + const ownerField = getAllByRole('textbox')[0]; + fireEvent.change(ownerField, { target: { value: 'my-mock-owner' } }); + + expect(onChange).toHaveBeenCalledWith({ owner: 'my-mock-owner' }); + }); + }); + + describe('repo name', () => { + it('should render free text field for input of repo name', () => { + const onChange = jest.fn(); + const { getAllByRole } = render( + , + ); + + const repoNameField = getAllByRole('textbox')[1]; + fireEvent.change(repoNameField, { + target: { value: 'my-mock-repo-name' }, + }); + + expect(onChange).toHaveBeenCalledWith({ repoName: 'my-mock-repo-name' }); + }); + }); +}); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GithubRepoPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GithubRepoPicker.tsx new file mode 100644 index 0000000000..13b57cb449 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GithubRepoPicker.tsx @@ -0,0 +1,84 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import FormControl from '@material-ui/core/FormControl'; +import FormHelperText from '@material-ui/core/FormHelperText'; +import Input from '@material-ui/core/Input'; +import InputLabel from '@material-ui/core/InputLabel'; +import { Select, SelectItem } from '@backstage/core-components'; +import { RepoUrlPickerState } from './types'; + +export const GithubRepoPicker = (props: { + allowedOwners?: string[]; + rawErrors: string[]; + state: RepoUrlPickerState; + onChange: (state: RepoUrlPickerState) => void; +}) => { + const { allowedOwners = [], rawErrors, state, onChange } = props; + const ownerItems: SelectItem[] = allowedOwners + ? allowedOwners.map(i => ({ label: i, value: i })) + : [{ label: 'Loading...', value: 'loading' }]; + + const { owner, repoName } = state; + + return ( + <> + 0 && !owner} + > + {allowedOwners?.length ? ( + onChange({ owner: e.target.value })} + value={owner} + /> + + )} + + The organization, user or project that this repo will belong to + + + 0 && !repoName} + > + Repository + onChange({ repoName: e.target.value })} + value={repoName} + /> + The name of the repository + + + ); +}; diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GitlabRepoPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GitlabRepoPicker.test.tsx new file mode 100644 index 0000000000..49a91c116e --- /dev/null +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GitlabRepoPicker.test.tsx @@ -0,0 +1,107 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { GitlabRepoPicker } from './GitlabRepoPicker'; +import { render, fireEvent } from '@testing-library/react'; + +describe('GitlabRepoPicker', () => { + describe('owner field', () => { + it('renders a select if there is a list of allowed owners', async () => { + const allowedOwners = ['owner1', 'owner2']; + const { findByText } = render( + , + ); + + expect(await findByText('owner1')).toBeInTheDocument(); + expect(await findByText('owner2')).toBeInTheDocument(); + }); + + it('calls onChange when the owner is changed to a different owner', async () => { + const onChange = jest.fn(); + const allowedOwners = ['owner1', 'owner2']; + const { getByRole } = render( + , + ); + + await fireEvent.change(getByRole('combobox'), { + target: { value: 'owner2' }, + }); + + expect(onChange).toHaveBeenCalledWith({ owner: 'owner2' }); + }); + + it('is disabled picked when only one allowed owner', () => { + const onChange = jest.fn(); + const allowedOwners = ['owner1']; + const { getByRole } = render( + , + ); + + expect(getByRole('combobox')).toBeDisabled(); + }); + + it('should display free text if no allowed owners are passed', async () => { + const onChange = jest.fn(); + const { getAllByRole } = render( + , + ); + const ownerField = getAllByRole('textbox')[0]; + fireEvent.change(ownerField, { target: { value: 'my-mock-owner' } }); + + expect(onChange).toHaveBeenCalledWith({ owner: 'my-mock-owner' }); + }); + }); + + describe('repo name', () => { + it('should render free text field for input of repo name', () => { + const onChange = jest.fn(); + const { getAllByRole } = render( + , + ); + + const repoNameField = getAllByRole('textbox')[1]; + fireEvent.change(repoNameField, { + target: { value: 'my-mock-repo-name' }, + }); + + expect(onChange).toHaveBeenCalledWith({ repoName: 'my-mock-repo-name' }); + }); + }); +}); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/GitlabRepoPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GitlabRepoPicker.tsx new file mode 100644 index 0000000000..1c63852866 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/GitlabRepoPicker.tsx @@ -0,0 +1,86 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import FormControl from '@material-ui/core/FormControl'; +import FormHelperText from '@material-ui/core/FormHelperText'; +import Input from '@material-ui/core/Input'; +import InputLabel from '@material-ui/core/InputLabel'; +import { Select, SelectItem } from '@backstage/core-components'; +import { RepoUrlPickerState } from './types'; + +export const GitlabRepoPicker = (props: { + allowedOwners?: string[]; + state: RepoUrlPickerState; + onChange: (state: RepoUrlPickerState) => void; + rawErrors: string[]; +}) => { + const { allowedOwners = [], rawErrors, state, onChange } = props; + const ownerItems: SelectItem[] = allowedOwners + ? allowedOwners.map(i => ({ label: i, value: i })) + : [{ label: 'Loading...', value: 'loading' }]; + + const { owner, repoName } = state; + + return ( + <> + 0 && !owner} + > + {allowedOwners?.length ? ( + onChange({ owner: e.target.value })} + value={owner} + /> + + )} + + The organization, user or project that this repo will belong to + + + 0 && !repoName} + > + Repository + onChange({ repoName: e.target.value })} + value={repoName} + /> + The name of the repository + + + ); +}; diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx index 85fe992935..998fa41588 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx @@ -13,371 +13,100 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { - Progress, - Select, - SelectedItems, - SelectItem, -} from '@backstage/core-components'; import { useApi } from '@backstage/core-plugin-api'; import { scmIntegrationsApiRef } from '@backstage/integration-react'; -import FormControl from '@material-ui/core/FormControl'; -import FormHelperText from '@material-ui/core/FormHelperText'; -import Input from '@material-ui/core/Input'; -import InputLabel from '@material-ui/core/InputLabel'; -import { FieldProps } from '@rjsf/core'; -import React, { useCallback, useEffect } from 'react'; -import useAsync from 'react-use/lib/useAsync'; -import { scaffolderApiRef } from '../../../api'; +import React, { useEffect, useState, useMemo, useCallback } from 'react'; +import { GithubRepoPicker } from './GithubRepoPicker'; +import { GitlabRepoPicker } from './GitlabRepoPicker'; +import { AzureRepoPicker } from './AzureRepoPicker'; +import { BitbucketRepoPicker } from './BitbucketRepoPicker'; +import { FieldExtensionComponentProps } from '../../../extensions'; +import { RepoUrlPickerHost } from './RepoUrlPickerHost'; +import { parseRepoPickerUrl, serializeRepoPickerUrl } from './utils'; +import { RepoUrlPickerState } from './types'; -function splitFormData(url: string | undefined, allowedOwners?: string[]) { - let host = undefined; - let owner = undefined; - let repo = undefined; - let organization = undefined; - let workspace = undefined; - let project = undefined; - - try { - if (url) { - const parsed = new URL(`https://${url}`); - host = parsed.host; - owner = parsed.searchParams.get('owner') || allowedOwners?.[0]; - repo = parsed.searchParams.get('repo') || undefined; - // This is azure dev ops specific. not used for any other provider. - organization = parsed.searchParams.get('organization') || undefined; - // These are bitbucket specific, not used for any other provider. - workspace = parsed.searchParams.get('workspace') || undefined; - project = parsed.searchParams.get('project') || undefined; - } - } catch { - /* ok */ - } - - return { host, owner, repo, organization, workspace, project }; +export interface RepoUrlPickerUiOptions { + allowedHosts?: string[]; + allowedOwners?: string[]; } -function serializeFormData(data: { - host?: string; - owner?: string; - repo?: string; - organization?: string; - workspace?: string; - project?: string; -}) { - if (!data.host) { - return undefined; - } - - const params = new URLSearchParams(); - if (data.owner) { - params.set('owner', data.owner); - } - if (data.repo) { - params.set('repo', data.repo); - } - if (data.organization) { - params.set('organization', data.organization); - } - if (data.workspace) { - params.set('workspace', data.workspace); - } - if (data.project) { - params.set('project', data.project); - } - - return `${data.host}?${params.toString()}`; -} - -export const RepoUrlPicker = ({ - onChange, - uiSchema, - rawErrors, - formData, -}: FieldProps) => { - const scaffolderApi = useApi(scaffolderApiRef); +export const RepoUrlPicker = ( + props: FieldExtensionComponentProps, +) => { + const { uiSchema, onChange, rawErrors, formData } = props; + const [state, setState] = useState( + parseRepoPickerUrl(formData), + ); const integrationApi = useApi(scmIntegrationsApiRef); - const allowedHosts = uiSchema['ui:options']?.allowedHosts as string[]; - const allowedOwners = uiSchema['ui:options']?.allowedOwners as string[]; - const { value: integrations, loading } = useAsync(async () => { - return await scaffolderApi.getIntegrationsList({ allowedHosts }); - }); - - const { host, owner, repo, organization, workspace, project } = splitFormData( - formData, - allowedOwners, + const allowedHosts = useMemo( + () => uiSchema?.['ui:options']?.allowedHosts ?? [], + [uiSchema], ); - const updateHost = useCallback( - (value: SelectedItems) => { - onChange( - serializeFormData({ - host: value as string, - owner, - repo, - organization, - workspace, - project, - }), - ); - }, - [onChange, owner, repo, organization, workspace, project], - ); - - const updateOwnerSelect = useCallback( - (value: SelectedItems) => - onChange( - serializeFormData({ - host, - owner: value as string, - repo, - organization, - workspace, - project, - }), - ), - [onChange, host, repo, organization, workspace, project], - ); - - const updateOwner = useCallback( - (evt: React.ChangeEvent<{ name?: string; value: unknown }>) => - onChange( - serializeFormData({ - host, - owner: evt.target.value as string, - repo, - organization, - workspace, - project, - }), - ), - [onChange, host, repo, organization, workspace, project], - ); - - const updateRepo = useCallback( - (evt: React.ChangeEvent<{ name?: string; value: unknown }>) => - onChange( - serializeFormData({ - host, - owner, - repo: evt.target.value as string, - organization, - workspace, - project, - }), - ), - [onChange, host, owner, organization, workspace, project], - ); - - const updateOrganization = useCallback( - (evt: React.ChangeEvent<{ name?: string; value: unknown }>) => - onChange( - serializeFormData({ - host, - owner, - repo, - organization: evt.target.value as string, - workspace, - project, - }), - ), - [onChange, host, owner, repo, workspace, project], - ); - - const updateWorkspace = useCallback( - (evt: React.ChangeEvent<{ name?: string; value: unknown }>) => - onChange( - serializeFormData({ - host, - owner, - repo, - organization, - workspace: evt.target.value as string, - project, - }), - ), - [onChange, host, owner, repo, organization, project], - ); - - const updateProject = useCallback( - (evt: React.ChangeEvent<{ name?: string; value: unknown }>) => - onChange( - serializeFormData({ - host, - owner, - repo, - organization, - workspace, - project: evt.target.value as string, - }), - ), - [onChange, host, owner, repo, organization, workspace], + const allowedOwners = useMemo( + () => uiSchema?.['ui:options']?.allowedOwners ?? [], + [uiSchema], ); useEffect(() => { - if (host === undefined && integrations?.length) { - onChange( - serializeFormData({ - host: integrations[0].host, - owner, - repo, - organization, - workspace, - project, - }), - ); + onChange(serializeRepoPickerUrl(state)); + }, [state, onChange]); + + /* we deal with calling the repo setting here instead of in each components for ease */ + useEffect(() => { + if (allowedOwners.length === 1) { + setState(prevState => ({ ...prevState, owner: allowedOwners[0] })); } - }, [ - onChange, - integrations, - host, - owner, - repo, - organization, - workspace, - project, - ]); + }, [setState, allowedOwners]); - if (loading) { - return ; - } + const updateLocalState = useCallback( + (newState: RepoUrlPickerState) => { + setState(prevState => ({ ...prevState, ...newState })); + }, + [setState], + ); - const hostsOptions: SelectItem[] = integrations - ? integrations - .filter(i => allowedHosts?.includes(i.host)) - .map(i => ({ label: i.title, value: i.host })) - : [{ label: 'Loading...', value: 'loading' }]; - - const ownersOptions: SelectItem[] = allowedOwners - ? allowedOwners.map(i => ({ label: i, value: i })) - : [{ label: 'Loading...', value: 'loading' }]; + const hostType = + (state.host && integrationApi.byHost(state.host)?.type) ?? null; return ( <> - 0 && !host} - > - - The name of the organization - )} - {host && integrationApi.byHost(host)?.type === 'bitbucket' && ( - <> - {/* Show this for bitbucket.org only */} - {host === 'bitbucket.org' && ( - 0 && !workspace} - > - Workspace - - - The workspace where the repository will be created - - - )} - 0 && !project} - > - Project - - - The project where the repository will be created - - - + {hostType === 'gitlab' && ( + + )} + {hostType === 'bitbucket' && ( + + )} + {hostType === 'azure' && ( + )} - {/* Show this for all hosts except bitbucket */} - {host && - integrationApi.byHost(host)?.type !== 'bitbucket' && - !allowedOwners && ( - <> - 0 && !owner} - > - Owner - - - The organization, user or project that this repo will belong to - - - - )} - {/* Show this for all hosts except bitbucket where allowed owner is set */} - {host && - integrationApi.byHost(host)?.type !== 'bitbucket' && - allowedOwners && ( - <> - 0 && !owner} - > - - The name of the repository - ); }; diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerHost.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerHost.test.tsx new file mode 100644 index 0000000000..e151ddbbc7 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerHost.test.tsx @@ -0,0 +1,99 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { RepoUrlPickerHost } from './RepoUrlPickerHost'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { scaffolderApiRef } from '../../../api'; +import { fireEvent, within } from '@testing-library/react'; + +describe('RepoUrlPickerHostField', () => { + it('renders the default host properly', async () => { + const mockOnChange = jest.fn(); + const mockScaffolderApi = { + getIntegrationsList: jest + .fn() + .mockResolvedValue([ + { host: 'github.com', title: 'github.com', type: 'github' }, + ]), + }; + const { getByText } = await renderInTestApp( + + + , + ); + + expect(getByText('github.com')).toBeInTheDocument(); + expect(mockOnChange).toHaveBeenCalledWith('github.com'); + }); + + it('should provide a dropdown when multiple hosts are returned that can be selected', async () => { + const mockOnChange = jest.fn(); + const mockScaffolderApi = { + getIntegrationsList: jest.fn().mockResolvedValue([ + { host: 'github.com', title: 'github.com', type: 'github' }, + { host: 'gitlab.com', title: 'gitlab.com', type: 'gitlab' }, + ]), + }; + + const { getByRole, getByText, getByTestId } = await renderInTestApp( + + + , + ); + + fireEvent.mouseDown(getByTestId('select')); + expect(getByText('gitlab.com')).toBeInTheDocument(); + + const listbox = within(getByRole('combobox')); + + expect(listbox.getAllByRole('option')).toHaveLength(2); + }); + + it('should not display hosts that dont have integration config set correctly', async () => { + const mockOnChange = jest.fn(); + const mockScaffolderApi = { + getIntegrationsList: jest.fn().mockResolvedValue([ + { host: 'github.com', title: 'github.com', type: 'github' }, + { host: 'gitlab.com', title: 'gitlab.com', type: 'gitlab' }, + ]), + }; + + const { getByRole, getByText, getByTestId } = await renderInTestApp( + + + , + ); + + fireEvent.mouseDown(getByTestId('select')); + expect(getByText('gitlab.com')).toBeInTheDocument(); + + const listbox = within(getByRole('combobox')); + + expect(listbox.getAllByRole('option')).toHaveLength(2); + }); +}); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerHost.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerHost.tsx new file mode 100644 index 0000000000..261429c757 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerHost.tsx @@ -0,0 +1,80 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { useEffect } from 'react'; +import { Progress, Select, SelectItem } from '@backstage/core-components'; +import FormControl from '@material-ui/core/FormControl'; +import FormHelperText from '@material-ui/core/FormHelperText'; +import { useApi } from '@backstage/core-plugin-api'; +import { scaffolderApiRef } from '../../../api'; +import useAsync from 'react-use/lib/useAsync'; + +export const RepoUrlPickerHost = (props: { + host?: string; + hosts?: string[]; + onChange: (host: string) => void; + rawErrors: string[]; +}) => { + const { host, hosts, onChange, rawErrors } = props; + const scaffolderApi = useApi(scaffolderApiRef); + + const { value: integrations, loading } = useAsync(async () => { + return await scaffolderApi.getIntegrationsList({ + allowedHosts: hosts ?? [], + }); + }); + + useEffect(() => { + if (hosts && !host) { + // This is only hear to set the default as the first one in the hosts array + // if the host is not set yet and there is a list of hosts. + onChange(hosts[0]); + } + }, [hosts, host, onChange]); + + const hostsOptions: SelectItem[] = integrations + ? integrations + .filter(i => hosts?.includes(i.host)) + .map(i => ({ label: i.title, value: i.host })) + : [{ label: 'Loading...', value: 'loading' }]; + + if (loading) { + return ; + } + + return ( + <> + 0 && !host} + > +