diff --git a/plugins/bulk-import/README.md b/plugins/bulk-import/README.md index b10011fdee3..6d0d6dd61c8 100644 --- a/plugins/bulk-import/README.md +++ b/plugins/bulk-import/README.md @@ -31,6 +31,30 @@ p, role:default/team_a, catalog-entity.create, create, allow g, user:default/, role:default/team_a ``` +#### Installing as a dynamic plugin? + +The sections below are relevant for static plugins. If the plugin is expected to be installed as a dynamic one: + +- follow https://github.com/janus-idp/backstage-showcase/blob/main/showcase-docs/dynamic-plugins.md#installing-a-dynamic-plugin-package-in-the-showcase +- add content of `app-config.janus-idp.yaml` into `app-config.local.yaml`. + +#### Prerequisites + +Follow the Bulk import backend plugin [README](https://github.com/janus-idp/backstage-plugins/blob/main/plugins/bulk-import-backend/README.md) to integrate bulk import in your Backstage instance. + +--- + +**NOTE** + +- When RBAC permission framework is enabled, for non-admin users to access bulk import UI, the role associated with your user should have the following permission policies associated with it. Add the following in your permission policies configuration file: + +```CSV +p, role:default/team_a, bulk.import, use, allow +p, role:default/team_a, catalog-entity.read, read, allow +p, role:default/team_a, catalog-entity.create, create, allow +g, user:default/, role:default/team_a +``` + #### Procedure 1. Install the Bulk import UI plugin using the following command: diff --git a/plugins/bulk-import/dev/index.tsx b/plugins/bulk-import/dev/index.tsx index 1e69369ad2b..62de7da3c77 100644 --- a/plugins/bulk-import/dev/index.tsx +++ b/plugins/bulk-import/dev/index.tsx @@ -1,8 +1,14 @@ import React from 'react'; +import { configApiRef } from '@backstage/core-plugin-api'; import { createDevApp } from '@backstage/dev-utils'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; -import { TestApiProvider } from '@backstage/test-utils'; +import { permissionApiRef } from '@backstage/plugin-permission-react'; +import { + MockConfigApi, + MockPermissionApi, + TestApiProvider, +} from '@backstage/test-utils'; import { getAllThemes } from '@redhat-developer/red-hat-developer-hub-theme'; @@ -101,6 +107,13 @@ class MockBulkImportApi implements BulkImportAPI { } const mockBulkImportApi = new MockBulkImportApi(); +const mockPermissionApi = new MockPermissionApi(); + +const mockConfigApi = new MockConfigApi({ + permission: { + enabled: true, + }, +}); createDevApp() .registerPlugin(bulkImportPlugin) @@ -109,8 +122,10 @@ createDevApp() element: ( diff --git a/plugins/bulk-import/package.json b/plugins/bulk-import/package.json index de39da8ee78..7976b174278 100644 --- a/plugins/bulk-import/package.json +++ b/plugins/bulk-import/package.json @@ -41,6 +41,8 @@ "@backstage/plugin-catalog-react": "^1.12.2", "@backstage/theme": "^0.5.6", "@janus-idp/shared-react": "2.10.0", + "@backstage/plugin-permission-react": "^0.4.24", + "@janus-idp/backstage-plugin-bulk-import-common": "0.2.0", "@material-ui/core": "^4.9.13", "@material-ui/icons": "^4.11.3", "@material-ui/lab": "^4.0.0-alpha.61", diff --git a/plugins/bulk-import/src/api/BulkImportBackendClient.ts b/plugins/bulk-import/src/api/BulkImportBackendClient.ts index e456961149f..a8ebc309826 100644 --- a/plugins/bulk-import/src/api/BulkImportBackendClient.ts +++ b/plugins/bulk-import/src/api/BulkImportBackendClient.ts @@ -63,8 +63,8 @@ export class BulkImportBackendClient implements BulkImportAPI { const backendUrl = this.configApi.getString('backend.baseUrl'); const jsonResponse = await fetch(getApi(backendUrl, page, size, options), { headers: { - 'Content-Type': 'application/json', ...(idToken && { Authorization: `Bearer ${idToken}` }), + 'Content-Type': 'application/json', }, }); if (jsonResponse.status !== 200 && jsonResponse.status !== 204) { @@ -104,6 +104,7 @@ export class BulkImportBackendClient implements BulkImportAPI { { method: 'POST', headers: { + ...(idToken && { Authorization: `Bearer ${idToken}` }), 'Content-Type': 'application/json', ...(idToken && { Authorization: `Bearer ${idToken}` }), }, @@ -121,6 +122,7 @@ export class BulkImportBackendClient implements BulkImportAPI { { method: 'DELETE', headers: { + ...(idToken && { Authorization: `Bearer ${idToken}` }), 'Content-Type': 'application/json', ...(idToken && { Authorization: `Bearer ${idToken}` }), }, diff --git a/plugins/bulk-import/src/components/AddRepositories/SelectRepositories.test.tsx b/plugins/bulk-import/src/components/AddRepositories/SelectRepositories.test.tsx index dec1a405e62..e5a0b80562e 100644 --- a/plugins/bulk-import/src/components/AddRepositories/SelectRepositories.test.tsx +++ b/plugins/bulk-import/src/components/AddRepositories/SelectRepositories.test.tsx @@ -44,13 +44,6 @@ describe('Select Repositories', () => { }); it('should allow users to edit repositories if repositories are selected', () => { - const mockAsyncData = { - loading: false, - value: { - totalCount: 5, - }, - }; - (useAsync as jest.Mock).mockReturnValue(mockAsyncData); const { getByText, getByTestId } = render( ({ + usePermission: jest.fn(), + RequirePermission: jest.fn(), +})); + +jest.mock('../hooks/useAddedRepositories', () => ({ + useAddedRepositories: jest.fn(), +})); + +const mockUsePermission = usePermission as jest.MockedFunction< + typeof usePermission +>; + +const mockUseAddedRepositories = useAddedRepositories as jest.MockedFunction< + typeof useAddedRepositories +>; + +const RequirePermissionMock = RequirePermission as jest.MockedFunction< + typeof RequirePermission +>; + +describe('BulkImport Page', () => { + it('should render if user authorized to access bulk import plugin', async () => { + RequirePermissionMock.mockImplementation(props => <>{props.children}); + mockUsePermission.mockReturnValue({ loading: false, allowed: true }); + mockUseAddedRepositories.mockReturnValue({ + loading: true, + data: [], + retry: jest.fn(), + error: undefined, + }); + await renderInTestApp(); + expect(screen.getByText('Added repositories')).toBeInTheDocument(); + }); + + it('should not render if user is not authorized to access the bulk import plugin', async () => { + RequirePermissionMock.mockImplementation(_props => <>Not Found); + mockUsePermission.mockReturnValue({ loading: false, allowed: false }); + + await renderInTestApp(); + expect(screen.getByText('Not Found')).toBeInTheDocument(); + expect(screen.queryByText('Added repositories')).not.toBeInTheDocument(); + }); +}); diff --git a/plugins/bulk-import/src/components/BulkImportPage.tsx b/plugins/bulk-import/src/components/BulkImportPage.tsx index 42c4bc78a30..21acd6c0c4f 100644 --- a/plugins/bulk-import/src/components/BulkImportPage.tsx +++ b/plugins/bulk-import/src/components/BulkImportPage.tsx @@ -1,10 +1,13 @@ import React from 'react'; import { Header, Page, TabbedLayout } from '@backstage/core-components'; +import { RequirePermission } from '@backstage/plugin-permission-react'; import FormControl from '@mui/material/FormControl'; import { Formik } from 'formik'; +import { bulkImportPermission } from '@janus-idp/backstage-plugin-bulk-import-common'; + import { AddRepositoriesFormValues, ApprovalTool, @@ -22,23 +25,28 @@ export const BulkImportPage = () => { }; return ( - -
- - - - {}} - > - - - - - - - - + + +
+ + + + {}} + > + + + + + + + + + ); }; diff --git a/plugins/bulk-import/src/components/BulkImportSidebarItem.test.tsx b/plugins/bulk-import/src/components/BulkImportSidebarItem.test.tsx new file mode 100644 index 00000000000..366861b8291 --- /dev/null +++ b/plugins/bulk-import/src/components/BulkImportSidebarItem.test.tsx @@ -0,0 +1,79 @@ +import React from 'react'; + +import { SidebarItem } from '@backstage/core-components'; +import { usePermission } from '@backstage/plugin-permission-react'; + +import { render, screen } from '@testing-library/react'; + +import { BulkImportSidebarItem } from './BulkImportSidebarItem'; + +jest.mock('@backstage/plugin-permission-react', () => ({ + usePermission: jest.fn(), +})); + +const mockUsePermission = usePermission as jest.MockedFunction< + typeof usePermission +>; + +const configMock = { + getOptionalBoolean: jest.fn(() => true), +}; + +jest.mock('@backstage/core-plugin-api', () => ({ + ...jest.requireActual('@backstage/core-plugin-api'), + useApi: jest.fn(() => { + return configMock; + }), +})); + +jest.mock('@backstage/core-components', () => ({ + SidebarItem: jest + .fn() + .mockImplementation(() => ( +
Bulk import
+ )), +})); + +const mockedSidebarItem = SidebarItem as jest.MockedFunction< + typeof SidebarItem +>; + +const mockBulkImportApiRef = jest.fn(); + +describe('Administration component', () => { + beforeEach(() => { + mockBulkImportApiRef.mockClear(); + mockedSidebarItem.mockClear(); + }); + + it('renders Bulk import sidebar item if user is authorized', async () => { + mockUsePermission.mockReturnValue({ loading: false, allowed: true }); + render(); + expect(mockedSidebarItem).toHaveBeenCalled(); + expect(screen.queryByText('Bulk import')).toBeInTheDocument(); + }); + + it('does not render Bulk import sidebar item if user is not authorized', async () => { + mockUsePermission.mockReturnValue({ loading: false, allowed: false }); + + render(); + expect(screen.queryByText('Bulk import')).toBeNull(); + }); + + it('does not render Bulk import sidebar item if user loading state is true', async () => { + mockUsePermission.mockReturnValue({ loading: true, allowed: false }); + + render(); + expect(mockedSidebarItem).not.toHaveBeenCalled(); + expect(screen.queryByText('Bulk import')).toBeNull(); + }); + + it('renders the Bulk import sidebar item if RBAC is disabled in the configuration', async () => { + mockUsePermission.mockReturnValue({ loading: true, allowed: true }); + configMock.getOptionalBoolean.mockReturnValueOnce(false); + + render(); + expect(mockedSidebarItem).toHaveBeenCalled(); + expect(screen.queryByText('Bulk import')).toBeInTheDocument(); + }); +}); diff --git a/plugins/bulk-import/src/components/BulkImportSidebarItem.tsx b/plugins/bulk-import/src/components/BulkImportSidebarItem.tsx index ebdd8e80e88..c75e06e0a01 100644 --- a/plugins/bulk-import/src/components/BulkImportSidebarItem.tsx +++ b/plugins/bulk-import/src/components/BulkImportSidebarItem.tsx @@ -1,6 +1,10 @@ import React from 'react'; import { SidebarItem } from '@backstage/core-components'; +import { configApiRef, useApi } from '@backstage/core-plugin-api'; +import { usePermission } from '@backstage/plugin-permission-react'; + +import { bulkImportPermission } from '@janus-idp/backstage-plugin-bulk-import-common'; import { getImageForIconClass } from '../utils/icons'; @@ -15,11 +19,33 @@ export const BulkImportIcon = () => { }; export const BulkImportSidebarItem = () => { - return ( - - ); + const { loading: isUserLoading, allowed } = usePermission({ + permission: bulkImportPermission, + resourceRef: bulkImportPermission.resourceType, + }); + + const config = useApi(configApiRef); + const isPermissionFrameworkEnabled = + config.getOptionalBoolean('permission.enabled'); + + if (!isUserLoading && isPermissionFrameworkEnabled) { + return allowed ? ( + + ) : null; + } + + if (!isPermissionFrameworkEnabled) { + return ( + + ); + } + return null; }; diff --git a/plugins/bulk-import/src/components/GitAltIcon.tsx b/plugins/bulk-import/src/components/GitAltIcon.tsx new file mode 100644 index 00000000000..28c2c428220 --- /dev/null +++ b/plugins/bulk-import/src/components/GitAltIcon.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; + +const GitAltIcon: React.FC> = ({ + style, +}): React.ReactElement => { + return ( + + + + ); +}; + +export default GitAltIcon; diff --git a/plugins/bulk-import/src/components/PreviewFile/KeyValueTextField.tsx b/plugins/bulk-import/src/components/PreviewFile/KeyValueTextField.tsx index c4e91f446ba..a3ad79e03b5 100644 --- a/plugins/bulk-import/src/components/PreviewFile/KeyValueTextField.tsx +++ b/plugins/bulk-import/src/components/PreviewFile/KeyValueTextField.tsx @@ -5,7 +5,7 @@ import { FormHelperText, TextField } from '@material-ui/core'; import { PullRequestPreview, PullRequestPreviewData } from '../../types'; interface KeyValueTextFieldProps { - repoName: string; + repoId: string; label: string; name: string; value: string; @@ -30,7 +30,7 @@ const validateKeyValuePairs = (value: string): string | null => { }; const KeyValueTextField: React.FC = ({ - repoName, + repoId, label, name, value, @@ -43,8 +43,8 @@ const KeyValueTextField: React.FC = ({ const removeError = () => { const err = { ...formErrors }; - if (err[repoName]) { - delete err[repoName][fieldName as keyof PullRequestPreview]; + if (err[repoId]) { + delete err[repoId][fieldName as keyof PullRequestPreview]; } setFormErrors(err); }; @@ -54,8 +54,8 @@ const KeyValueTextField: React.FC = ({ ): PullRequestPreviewData => { return { ...formErrors, - [repoName]: { - ...formErrors[repoName], + [repoId]: { + ...formErrors[repoId], [fieldName]: validationError, }, }; diff --git a/plugins/bulk-import/src/components/PreviewFile/PreviewFile.test.tsx b/plugins/bulk-import/src/components/PreviewFile/PreviewFile.test.tsx index 7049c6de4b5..9aa8e06b4c7 100644 --- a/plugins/bulk-import/src/components/PreviewFile/PreviewFile.test.tsx +++ b/plugins/bulk-import/src/components/PreviewFile/PreviewFile.test.tsx @@ -33,10 +33,10 @@ jest.mock('formik', () => ({ ...jest.requireActual('formik'), useFormikContext: jest.fn(), })); -const seState = jest.fn(); +const setState = jest.fn(); beforeEach(() => { - (useState as jest.Mock).mockImplementation(initial => [initial, seState]); + (useState as jest.Mock).mockImplementation(initial => [initial, setState]); }); describe('Preview File', () => { @@ -61,7 +61,7 @@ describe('Preview File', () => { expect(queryByTestId('preview-file')).toBeInTheDocument(); const previewButton = getByText(/Preview File/i); fireEvent.click(previewButton); - expect(seState).toHaveBeenCalledWith(true); + expect(setState).toHaveBeenCalledWith(true); }); it('should render pull requests preview for the selected repositories in the organization view', async () => { @@ -92,7 +92,7 @@ describe('Preview File', () => { expect(queryByTestId('preview-files')).toBeInTheDocument(); const previewButton = getByText(/Preview files/i); fireEvent.click(previewButton); - expect(seState).toHaveBeenCalledWith(true); + expect(setState).toHaveBeenCalledWith(true); }); it('should show the status of the catalog-info', async () => { @@ -136,6 +136,6 @@ describe('Preview File', () => { expect(queryByTestId('failed')).toBeInTheDocument(); const editButton = getByText(/Edit/i); fireEvent.click(editButton); - expect(seState).toHaveBeenCalledWith(true); + expect(setState).toHaveBeenCalledWith(true); }); }); diff --git a/plugins/bulk-import/src/components/PreviewFile/PreviewFile.tsx b/plugins/bulk-import/src/components/PreviewFile/PreviewFile.tsx index 106c2043956..e7d8ea52cd1 100644 --- a/plugins/bulk-import/src/components/PreviewFile/PreviewFile.tsx +++ b/plugins/bulk-import/src/components/PreviewFile/PreviewFile.tsx @@ -9,6 +9,7 @@ import FailIcon from '@mui/icons-material/ErrorOutline'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; +import CircularProgress from '@mui/material/CircularProgress'; import Drawer from '@mui/material/Drawer'; import IconButton from '@mui/material/IconButton'; import Tooltip from '@mui/material/Tooltip'; @@ -83,41 +84,39 @@ export const PreviewFileSidebar = ({ formErrors, setFormErrors, handleSave, + isSubmitting, }: { open: boolean; data: AddRepositoryData; repositoryType: RepositoryType; onClose: () => void; - formErrors: any; - setFormErrors: React.Dispatch>; + formErrors: PullRequestPreviewData; + isSubmitting?: boolean; + setFormErrors: (formErr: PullRequestPreviewData) => void; handleSave: (pullRequest: PullRequestPreviewData, _event: any) => void; }) => { const classes = useDrawerStyles(); const [pullRequest, setPullRequest] = React.useState( {}, ); - const { values } = useFormikContext(); + const contentClasses = useDrawerContentStyles(); const initializePullRequest = React.useCallback(() => { const newPullRequestData: PullRequestPreviewData = {}; if (Object.keys(data?.selectedRepositories || [])?.length > 0) { Object.values(data?.selectedRepositories || []).forEach(repo => { - if (values.repositories?.[repo.id]?.catalogInfoYaml?.prTemplate) { - newPullRequestData[repo.id] = values.repositories[repo.id] - .catalogInfoYaml?.prTemplate as PullRequestPreview; + if (repo.catalogInfoYaml?.prTemplate) { + newPullRequestData[repo.id] = repo.catalogInfoYaml.prTemplate; } }); - } else if ( - data?.id && - values.repositories?.[data.id]?.catalogInfoYaml?.prTemplate - ) { - newPullRequestData[data.id] = values.repositories[data.id].catalogInfoYaml + } else if (data.catalogInfoYaml?.prTemplate) { + newPullRequestData[data.id] = data.catalogInfoYaml ?.prTemplate as PullRequestPreview; } setPullRequest(newPullRequestData); - }, [data, values]); + }, [data]); React.useEffect(() => { // eslint-disable-next-line react-hooks/exhaustive-deps @@ -180,10 +179,11 @@ export const PreviewFileSidebar = ({ {repositoryType === RepositorySelection.Repository && ( )} @@ -193,7 +193,7 @@ export const PreviewFileSidebar = ({ Object.values(data?.selectedRepositories || []) || [] } pullRequest={pullRequest} - formErrors={formErrors as PullRequestPreviewData} + formErrors={formErrors} setFormErrors={setFormErrors} setPullRequest={setPullRequest} /> @@ -206,11 +206,15 @@ export const PreviewFileSidebar = ({ onClick={e => handleSave(pullRequest, e)} className={contentClasses.createButton} disabled={ - !!formErrors && - Object.values(formErrors).length > 0 && - Object.values(formErrors).every( - fe => !!fe && Object.values(fe).length > 0, - ) + isSubmitting || + (!!formErrors && + Object.values(formErrors).length > 0 && + Object.values(formErrors).every( + fe => !!fe && Object.values(fe).length > 0, + )) + } + startIcon={ + isSubmitting && } > Save @@ -307,7 +311,7 @@ export const PreviewFile = ({ open={sidebarOpen} onClose={() => setSidebarOpen(false)} data={data} - formErrors={formErrors} + formErrors={formErrors as PullRequestPreviewData} setFormErrors={setFormErrors} repositoryType={repositoryType} handleSave={handleSave} diff --git a/plugins/bulk-import/src/components/PreviewFile/PreviewPullRequest.test.tsx b/plugins/bulk-import/src/components/PreviewFile/PreviewPullRequest.test.tsx index 996a34b7aee..1018be5d442 100644 --- a/plugins/bulk-import/src/components/PreviewFile/PreviewPullRequest.test.tsx +++ b/plugins/bulk-import/src/components/PreviewFile/PreviewPullRequest.test.tsx @@ -54,7 +54,7 @@ describe('Preview Pull Request', () => { errors: {}, values: { repositories: { - 'org/dessert/Cupcake': mockGetRepositories.repositories[0], + 'org/dessert/cupcake': mockGetRepositories.repositories[0], }, approvalTool: ApprovalTool.Git, }, @@ -77,10 +77,11 @@ describe('Preview Pull Request', () => { ]} > { errors: {}, values: { repositories: { - 'org/dessert/Cupcake': mockGetRepositories.repositories[0], + 'org/dessert/cupcake': mockGetRepositories.repositories[0], }, approvalTool: ApprovalTool.ServiceNow, }, @@ -127,10 +128,11 @@ describe('Preview Pull Request', () => { ]} > { expect(getByText(/ServiceNow ticket details/i)).toBeInTheDocument(); expect(getByText(/Preview ServiceNow ticket/i)).toBeInTheDocument(); expect(getByText(/Preview entities/i)).toBeInTheDocument(); - expect(getByPlaceholderText(/Component Name/)).toHaveValue('Cupcake'); + expect(getByPlaceholderText(/Component Name/)).toHaveValue('cupcake'); }); it('should show field error if PR title/component name field is empty', async () => { @@ -152,7 +154,7 @@ describe('Preview Pull Request', () => { errors: {}, values: { repositories: { - 'org/dessert/Cupcake': mockGetRepositories.repositories[0], + 'org/dessert/cupcake': mockGetRepositories.repositories[0], }, approvalTool: ApprovalTool.Git, }, @@ -177,10 +179,11 @@ describe('Preview Pull Request', () => { ]} > { ); fireEvent.change(prTitle, { target: { value: '' } }); expect(setFormErrors).toHaveBeenCalledWith({ - 'org/dessert/Cupcake': { + 'org/dessert/cupcake': { prTitle: 'Pull request title is missing', }, }); @@ -204,7 +207,7 @@ describe('Preview Pull Request', () => { const componentName = getByPlaceholderText(/Component Name/); fireEvent.change(componentName, { target: { value: '' } }); expect(setFormErrors).toHaveBeenCalledWith({ - 'org/dessert/Cupcake': { + 'org/dessert/cupcake': { componentName: 'Component name is missing', }, }); @@ -252,7 +255,8 @@ describe('Preview Pull Request', () => { ]} > { ]} > ( - pullRequest[repoName]?.entityOwner ?? '', + pullRequest[repoId]?.entityOwner ?? '', ); const { loading: entitiesLoading, value: entities } = useAsync(async () => { const allEntities = await catalogApi.getEntities({ @@ -88,28 +90,28 @@ export const PreviewPullRequest = ({ React.useEffect(() => { const newFormErrors = { ...formErrors, - [repoName]: { - ...formErrors?.[repoName], + [repoId]: { + ...formErrors?.[repoId], entityOwner: 'Entity owner is missing', }, }; if ( - (entityOwner === null || !pullRequest[repoName]?.entityOwner) && - !pullRequest[repoName]?.useCodeOwnersFile + (entityOwner === null || !pullRequest[repoId]?.entityOwner) && + !pullRequest[repoId]?.useCodeOwnersFile ) { if (JSON.stringify(formErrors) !== JSON.stringify(newFormErrors)) { setFormErrors(newFormErrors); } } - }, [entityOwner, pullRequest, setFormErrors, formErrors, repoName]); + }, [entityOwner, pullRequest, setFormErrors, formErrors, repoId]); const updatePullRequestKeyValuePairFields = ( field: string, value: string, yamlKey: string, ) => { - const yamlUpdate: Entity = { ...pullRequest[repoName]?.yaml }; + const yamlUpdate: Entity = { ...pullRequest[repoId]?.yaml }; if (value.length === 0) { if (yamlKey.includes('.')) { @@ -132,8 +134,8 @@ export const PreviewPullRequest = ({ setPullRequest({ ...pullRequest, - [repoName]: { - ...pullRequest[repoName], + [repoId]: { + ...pullRequest[repoId], [field]: value, yaml: yamlUpdate, }, @@ -147,15 +149,15 @@ export const PreviewPullRequest = ({ ) => { setPullRequest({ ...pullRequest, - [repoName]: { - ...pullRequest[repoName], + [repoId]: { + ...pullRequest[repoId], [field]: value, ...(field === 'componentName' ? { yaml: { - ...pullRequest[repoName]?.yaml, + ...pullRequest[repoId]?.yaml, metadata: { - ...pullRequest[repoName]?.yaml.metadata, + ...pullRequest[repoId]?.yaml.metadata, name: value, }, }, @@ -167,22 +169,22 @@ export const PreviewPullRequest = ({ if (!value) { setFormErrors({ ...formErrors, - [repoName]: { - ...formErrors?.[repoName], + [repoId]: { + ...formErrors?.[repoId], [field]: errorMessage, }, }); } else if (field === 'componentName' && !componentNameRegex.exec(value)) { setFormErrors({ ...formErrors, - [repoName]: { - ...formErrors?.[repoName], + [repoId]: { + ...formErrors?.[repoId], [field]: `"${value}" is not valid; expected a string that is sequences of [a-zA-Z0-9] separated by any of [-_.], at most 63 characters in total. To learn more about catalog file format, visit: https://github.com/backstage/backstage/blob/master/docs/architecture-decisions/adr002-default-catalog-file-format.md`, }, }); } else { const err = { ...formErrors }; - delete err[repoName]?.[field as keyof PullRequestPreview]; + delete err[repoId]?.[field as keyof PullRequestPreview]; setFormErrors(err); } }; @@ -225,13 +227,13 @@ export const PreviewPullRequest = ({ case 'entityOwner': setPullRequest({ ...pullRequest, - [repoName]: { - ...pullRequest[repoName], + [repoId]: { + ...pullRequest[repoId], entityOwner: inputValue, yaml: { - ...pullRequest[repoName]?.yaml, + ...pullRequest[repoId]?.yaml, spec: { - ...pullRequest[repoName]?.yaml.spec, + ...pullRequest[repoId]?.yaml.spec, ...(inputValue ? { owner: inputValue } : {}), }, }, @@ -273,33 +275,33 @@ export const PreviewPullRequest = ({ label: 'Annotations', name: 'prAnnotations', value: - pullRequest?.[repoName]?.prAnnotations ?? + pullRequest?.[repoId]?.prAnnotations ?? convertKeyValuePairsToString( - pullRequest?.[repoName]?.yaml?.metadata?.annotations, + pullRequest?.[repoId]?.yaml?.metadata?.annotations, ), }, { label: 'Labels', name: 'prLabels', value: - pullRequest?.[repoName]?.prLabels ?? + pullRequest?.[repoId]?.prLabels ?? convertKeyValuePairsToString( - pullRequest?.[repoName]?.yaml?.metadata?.labels, + pullRequest?.[repoId]?.yaml?.metadata?.labels, ), }, { label: 'Spec', name: 'prSpec', value: - pullRequest?.[repoName]?.prSpec ?? + pullRequest?.[repoId]?.prSpec ?? convertKeyValuePairsToString( - pullRequest?.[repoName]?.yaml?.spec as Record, + pullRequest?.[repoId]?.yaml?.spec as Record, ), }, ]; - const error = status?.errors?.[repoName]; - const info = status?.infos?.[repoName]; + const error = status?.errors?.[repoId]; + const info = status?.infos?.[repoId]; if (info && !error) { // prioritize error over info return ( @@ -334,10 +336,10 @@ export const PreviewPullRequest = ({ variant="outlined" margin="normal" fullWidth - name={`repositories.${pullRequest[repoName]?.componentName}.prTitle`} - value={pullRequest?.[repoName]?.prTitle} + name={`repositories.${pullRequest[repoId]?.componentName}.prTitle`} + value={pullRequest?.[repoId]?.prTitle} onChange={handleChange} - error={!!formErrors?.[repoName]?.prTitle} + error={!!formErrors?.[repoId]?.prTitle} required /> @@ -348,9 +350,9 @@ export const PreviewPullRequest = ({ variant="outlined" fullWidth onChange={handleChange} - name={`repositories.${pullRequest[repoName]?.componentName}.prDescription`} - value={pullRequest?.[repoName]?.prDescription} - error={!!formErrors?.[repoName]?.prDescription} + name={`repositories.${pullRequest[repoId]?.componentName}.prDescription`} + value={pullRequest?.[repoId]?.prDescription} + error={!!formErrors?.[repoId]?.prDescription} multiline required /> @@ -365,12 +367,12 @@ export const PreviewPullRequest = ({ margin="normal" variant="outlined" onChange={handleChange} - value={pullRequest?.[repoName]?.componentName} - name={`repositories.${pullRequest[repoName]?.componentName}.componentName`} - error={!!formErrors?.[repoName]?.componentName} + value={pullRequest?.[repoId]?.componentName} + name={`repositories.${pullRequest[repoId]?.componentName}.componentName`} + error={!!formErrors?.[repoId]?.componentName} helperText={ - formErrors?.[repoName]?.componentName - ? formErrors?.[repoName]?.componentName + formErrors?.[repoId]?.componentName + ? formErrors?.[repoId]?.componentName : '' } fullWidth @@ -379,7 +381,7 @@ export const PreviewPullRequest = ({

- {!pullRequest?.[repoName]?.useCodeOwnersFile && ( + {!pullRequest?.[repoId]?.useCodeOwnersFile && ( ) => { const pr = { ...pullRequest, - [repoName]: { - ...pullRequest[repoName], + [repoId]: { + ...pullRequest[repoId], useCodeOwnersFile: event.target.checked, }, }; - delete pr[repoName]?.entityOwner; - delete pr[repoName]?.yaml?.spec?.owner; + delete pr[repoId]?.entityOwner; + delete pr[repoId]?.yaml?.spec?.owner; setPullRequest(pr); if (event.target.checked) { const err = { ...formErrors }; - delete err[repoName]?.entityOwner; + delete err[repoId]?.entityOwner; setFormErrors(err); } }} @@ -479,12 +481,12 @@ export const PreviewPullRequest = ({ ))} @@ -494,8 +496,8 @@ export const PreviewPullRequest = ({ ({ + usePermission: jest.fn(), +})); + +jest.mock('react-use', () => ({ + ...jest.requireActual('react-use'), + useAsync: jest.fn(), +})); + +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useState: jest.fn(), +})); + +jest.mock('@backstage/core-plugin-api', () => ({ + ...jest.requireActual('@backstage/core-plugin-api'), + useApi: jest.fn(), +})); + +jest.mock('formik', () => ({ + ...jest.requireActual('formik'), + useFormikContext: jest.fn(), +})); + +const setState = jest.fn(); + +const mockUsePermission = usePermission as jest.MockedFunction< + typeof usePermission +>; + +const mockUseAsync = useAsync as jest.MockedFunction; + +beforeEach(() => { + (useState as jest.Mock).mockImplementation(initial => [initial, setState]); +}); + +describe('EditCatalogInfo', () => { + it('should show disabled Edit icon when import status is being fetched', async () => { + mockUseAsync.mockReturnValue({ + loading: true, + value: mockGetImportJobs[0], + }); + mockUsePermission.mockReturnValue({ loading: false, allowed: true }); + + (useFormikContext as jest.Mock).mockReturnValue({ + setSubmitting: jest.fn(), + setStatus: jest.fn(), + isSubmitting: false, + }); + const { getByTestId } = render( + + + , + ); + expect(getByTestId('edit-loading')).toBeInTheDocument(); + }); + + it('should allow users to edit the catalog-info.yaml PR if the PR is waiting to be approved', async () => { + mockUseAsync.mockReturnValue({ + loading: false, + value: mockGetImportJobs[0], + }); + mockUsePermission.mockReturnValue({ loading: false, allowed: true }); + + (useFormikContext as jest.Mock).mockReturnValue({ + setSubmitting: jest.fn(), + setStatus: jest.fn(), + isSubmitting: false, + }); + const { getByTestId } = render( + + + , + ); + expect(getByTestId('edit-catalog-info')).toBeInTheDocument(); + fireEvent.click(getByTestId('update')); + expect(setState).toHaveBeenCalledWith(true); + }); + + it('should allow users to view the catalog-info.yaml if the entity is registerd', async () => { + mockUseAsync.mockReturnValue({ + loading: false, + value: { ...mockGetImportJobs[0], status: RepositoryStatus.ADDED }, + }); + mockUsePermission.mockReturnValue({ loading: false, allowed: true }); + + (useFormikContext as jest.Mock).mockReturnValue({ + setSubmitting: jest.fn(), + setStatus: jest.fn(), + isSubmitting: false, + }); + const { getByTestId } = render( + + + , + ); + expect(getByTestId('view-catalog-info')).toBeInTheDocument(); + expect(getByTestId('OpenInNewIcon')).toBeInTheDocument(); + }); +}); diff --git a/plugins/bulk-import/src/components/Repositories/EditCatalogInfo.tsx b/plugins/bulk-import/src/components/Repositories/EditCatalogInfo.tsx index 4834202be3a..7ab86ca3130 100644 --- a/plugins/bulk-import/src/components/Repositories/EditCatalogInfo.tsx +++ b/plugins/bulk-import/src/components/Repositories/EditCatalogInfo.tsx @@ -1,55 +1,164 @@ import React, { useState } from 'react'; +import { useAsync } from 'react-use'; + +import { useApi } from '@backstage/core-plugin-api'; +import { usePermission } from '@backstage/plugin-permission-react'; import { IconButton, Tooltip } from '@material-ui/core'; import EditIcon from '@material-ui/icons/Edit'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import { useFormikContext } from 'formik'; +import { get } from 'lodash'; + +import { bulkImportPermission } from '@janus-idp/backstage-plugin-bulk-import-common'; +import { bulkImportApiRef } from '../../api/BulkImportBackendClient'; import { AddRepositoriesFormValues, AddRepositoryData, + ApprovalTool, PullRequestPreviewData, + RepositorySelection, + RepositoryStatus, } from '../../types'; +import { ImportJobResponse, ImportJobStatus } from '../../types/response-types'; +import { + getJobErrors, + prepareDataForSubmission, +} from '../../utils/repository-utils'; import { PreviewFileSidebar } from '../PreviewFile/PreviewFile'; const EditCatalogInfo = ({ data }: { data: AddRepositoryData }) => { - const hasPermission = false; const [drawerOpen, setDrawerOpen] = useState(false); - const { setFieldValue } = useFormikContext(); + const bulkImportApi = useApi(bulkImportApiRef); + const { setSubmitting, setStatus, isSubmitting } = + useFormikContext(); const [formErrors, setFormErrors] = React.useState(); + const { allowed } = usePermission({ + permission: bulkImportPermission, + resourceRef: bulkImportPermission.resourceType, + }); + const { value, loading } = useAsync( + async () => + await bulkImportApi.getImportAction( + data?.repoUrl as string, + data?.defaultBranch || 'main', + ), + ); + + // fetch the PR contents here when RHIDP-3669 is done const handleClick = () => { setDrawerOpen(true); }; - const handleSave = (pullRequest: PullRequestPreviewData, _event: any) => { - Object.keys(pullRequest).forEach(pr => { - setFieldValue( - `repositories.${pr}.catalogInfoYaml.prTemplate`, - pullRequest[pr], - ); - }); - setDrawerOpen(false); + const handleSave = async ( + pullRequest: PullRequestPreviewData, + _event: any, + ) => { + const importRepositories = prepareDataForSubmission( + { + [`${data.id}`]: { + id: data.id, + catalogInfoYaml: { + prTemplate: pullRequest[`${data.id}`], + }, + defaultBranch: data?.defaultBranch, + organizationUrl: data?.organizationUrl, + orgName: data?.orgName, + repoName: data?.repoName, + repoUrl: data?.repoUrl, + }, + }, + (value as ImportJobStatus)?.approvalTool as ApprovalTool, + ); + try { + setSubmitting(true); + const dryrunResponse: ImportJobResponse[] = + await bulkImportApi.createImportJobs(importRepositories, true); + const dryRunErrors = getJobErrors(dryrunResponse); + if (Object.keys(dryRunErrors).length > 0) { + setStatus(dryRunErrors); + setSubmitting(false); + } else { + const createJobResponse: ImportJobResponse[] | Response = + await bulkImportApi.createImportJobs(importRepositories); + setSubmitting(true); + if (!Array.isArray(createJobResponse)) { + setStatus({ + [`${data?.id}`]: { + repository: data.repoName, + catalogEntityName: + data.catalogInfoYaml?.prTemplate?.componentName, + error: { + message: + get(createJobResponse, 'error.message') || + 'Failed to create pull request', + status: get(createJobResponse, 'error.name') || 'Error occured', + }, + }, + }); + } else { + const createJobErrors = getJobErrors(createJobResponse); + if (Object.keys(createJobErrors).length > 0) { + setStatus(createJobErrors); + } else { + setDrawerOpen(false); + } + } + setSubmitting(false); + } + } catch (error: any) { + setStatus({ + [`${data?.id}`]: { + repository: data.repoName, + catalogEntityName: data.catalogInfoYaml?.prTemplate?.componentName, + error: { + message: error?.message || 'Error occured', + status: error?.name, + }, + }, + }); + setSubmitting(false); + } }; + const hasPermissionToEdit = + allowed && + (value as ImportJobStatus)?.status === RepositoryStatus.WAIT_PR_APPROVAL; + + if (loading) { + return ( + + + + ); + } + return ( <> - {hasPermission ? ( + {hasPermissionToEdit ? ( handleClick()} > @@ -57,7 +166,11 @@ const EditCatalogInfo = ({ data }: { data: AddRepositoryData }) => { ) : ( @@ -66,14 +179,15 @@ const EditCatalogInfo = ({ data }: { data: AddRepositoryData }) => { )} - {hasPermission && ( + {hasPermissionToEdit && ( setDrawerOpen(false)} handleSave={handleSave} - formErrors={formErrors} + formErrors={formErrors as PullRequestPreviewData} setFormErrors={setFormErrors} /> )} diff --git a/plugins/bulk-import/src/components/Repositories/RepositoriesListColumns.tsx b/plugins/bulk-import/src/components/Repositories/RepositoriesListColumns.tsx index 491fd5e6da2..44fee038da7 100644 --- a/plugins/bulk-import/src/components/Repositories/RepositoriesListColumns.tsx +++ b/plugins/bulk-import/src/components/Repositories/RepositoriesListColumns.tsx @@ -6,6 +6,7 @@ import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import { AddRepositoryData } from '../../types'; import { + calculateLastUpdated, descendingComparator, getImportStatus, urlHelper, @@ -56,13 +57,15 @@ export const columns: TableColumn[] = [ customSort: (a: AddRepositoryData, b: AddRepositoryData) => descendingComparator(a, b, 'catalogInfoYaml.status'), render: (data: AddRepositoryData) => - getImportStatus(data.catalogInfoYaml?.status as string), + getImportStatus(data.catalogInfoYaml?.status as string, true), }, { title: 'Last updated', field: 'catalogInfoYaml.lastUpdated', type: 'string', align: 'left', + render: (data: AddRepositoryData) => + calculateLastUpdated(data.catalogInfoYaml?.lastUpdated as string), }, { title: 'Actions', diff --git a/plugins/bulk-import/src/plugin.test.ts b/plugins/bulk-import/src/plugin.test.ts new file mode 100644 index 00000000000..95dba744202 --- /dev/null +++ b/plugins/bulk-import/src/plugin.test.ts @@ -0,0 +1,7 @@ +import { bulkImportPlugin } from './plugin'; + +describe('bulk-import', () => { + it('should export plugin', () => { + expect(bulkImportPlugin).toBeDefined(); + }); +}); diff --git a/plugins/bulk-import/src/plugin.test.tsx b/plugins/bulk-import/src/plugin.test.tsx deleted file mode 100644 index e0ae085de1e..00000000000 --- a/plugins/bulk-import/src/plugin.test.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React from 'react'; -import { Route, Routes } from 'react-router-dom'; - -import { renderInTestApp } from '@backstage/test-utils'; - -import { screen, waitFor } from '@testing-library/react'; - -import { BulkImportSidebarItem } from './components'; -import { BulkImportPage, bulkImportPlugin } from './plugin'; -import { rootRouteRef } from './routes'; - -describe('bulk-import', () => { - it('should export plugin', () => { - expect(bulkImportPlugin).toBeDefined(); - }); - it('should render the bulk import page', async () => { - await renderInTestApp( - - } /> - , - { - mountedRoutes: { - '/': rootRouteRef, - }, - }, - ); - - await waitFor(() => { - expect(screen.queryByText('Bulk import')).toBeInTheDocument(); - }); - }); - - it('should render the bulk import icon', async () => { - await renderInTestApp( - - } /> - , - { - mountedRoutes: { - '/': rootRouteRef, - }, - }, - ); - - await waitFor(() => { - expect( - screen.queryByRole('img', { name: 'bulk import icon' }), - ).toBeInTheDocument(); - }); - }); -}); diff --git a/plugins/bulk-import/src/utils/repository-utils.test.ts b/plugins/bulk-import/src/utils/repository-utils.test.tsx similarity index 100% rename from plugins/bulk-import/src/utils/repository-utils.test.ts rename to plugins/bulk-import/src/utils/repository-utils.test.tsx diff --git a/plugins/bulk-import/src/utils/repository-utils.ts b/plugins/bulk-import/src/utils/repository-utils.tsx similarity index 87% rename from plugins/bulk-import/src/utils/repository-utils.ts rename to plugins/bulk-import/src/utils/repository-utils.tsx index d68ae6ffe19..e275d428fc3 100644 --- a/plugins/bulk-import/src/utils/repository-utils.ts +++ b/plugins/bulk-import/src/utils/repository-utils.tsx @@ -1,6 +1,11 @@ +import * as React from 'react'; + +import { StatusOK, StatusPending } from '@backstage/core-components'; + import { get } from 'lodash'; import * as yaml from 'yaml'; +import GitAltIcon from '../components/GitAltIcon'; import { AddedRepositories, AddRepositoryData, @@ -189,15 +194,37 @@ export const getNewOrgsData = ( return newOrgsData || []; }; -export const getImportStatus = (status: string): string => { +export const getImportStatus = (status: string, showIcon?: boolean) => { if (!status) { return ''; } switch (status) { case 'WAIT_PR_APPROVAL': - return 'Waiting for PR Approval'; + return showIcon ? ( + + + + Waiting for Approval + + ) : ( + 'Waiting for Approval' + ); case 'ADDED': - return 'Finished and Ingested'; + return showIcon ? ( + + + Added + + ) : ( + 'Added' + ); default: return ''; } @@ -425,3 +452,32 @@ export const getCustomisedErrorMessage = ( }); return message; }; + +export const calculateLastUpdated = (dateString: string) => { + if (!dateString) { + return ''; + } + + const givenDate = new Date(dateString); + const currentDate = new Date(); + + // Calculate the difference in milliseconds + const diffInMilliseconds: number = + currentDate.getTime() - givenDate.getTime(); + + const diffInSeconds = Math.floor(diffInMilliseconds / 1000); + const diffInMinutes = Math.floor(diffInSeconds / 60); + const diffInHours = Math.floor(diffInMinutes / 60); + const diffInDays = Math.floor(diffInHours / 24); + + if (diffInDays > 0) { + return `${diffInDays} ${diffInDays > 1 ? 'days' : 'day'} ago`; + } + if (diffInHours > 0) { + return `${diffInHours} ${diffInHours > 1 ? 'hours' : 'hour'} ago`; + } + if (diffInMinutes > 0) { + return `${diffInMinutes} ${diffInMinutes > 1 ? 'minutes' : 'minute'} ago`; + } + return `${diffInSeconds} ${diffInSeconds > 1 ? 'seconds' : 'second'} ago`; +}; diff --git a/rbac-policy.csv b/rbac-policy.csv new file mode 100644 index 00000000000..a5a5f09368b --- /dev/null +++ b/rbac-policy.csv @@ -0,0 +1,20 @@ +p, role:default/rbac_admin, catalog.entity.create, create, allow +p, role:default/rbac_admin, catalog.entity.read, read, allow +p, role:default/rbac_admin, catalog.location.create, create, allow +p, role:default/rbac_admin, catalog.location.read, read, allow +p, role:default/rbac_admin, policy-entity, read, allow +p, role:default/rbac_admin, policy-entity, delete, allow +p, role:default/rbac_admin, policy-entity, update, allow +p, role:default/rbac_admin, policy-entity, create, allow +p, role:default/rbac_admin, bulk.import, use, allow + +p, user:default/debsmita1, bulk.import, use, allow +p, user:default/debsmita1, catalog-entity.read, read, allow +p, user:default/debsmita1, catalog-entity.create, create, allow + + +p, role:default/guests, policy-entity, create, allow +p, role:default/guests, policy-entity, read, allow +p, role:default/guests, policy-entity, delete, allow +p, role:default/guests, catalog-entity, read, allow +p, role:default/guests, catalog-entity, delete, allow \ No newline at end of file