From 5fd57b862417560345ac2bf136dc9061aae9f646 Mon Sep 17 00:00:00 2001 From: debsmita1 Date: Mon, 30 Sep 2024 22:33:32 +0530 Subject: [PATCH] [Bulk import]: fix added repositories count --- .changeset/heavy-mails-doubt.md | 5 + plugins/bulk-import/dev/index.tsx | 5 +- plugins/bulk-import/package.json | 1 + .../src/api/BulkImportBackendClient.test.ts | 23 ++- .../src/api/BulkImportBackendClient.ts | 17 +- .../AddRepositoriesForm.test.tsx | 2 +- .../AddRepositoriesTable.test.tsx | 2 +- .../AddRepositories/AddRepositoriesTable.tsx | 7 +- .../AddRepositoriesTableToolbar.tsx | 8 +- .../OrganizationsColumnHeader.ts | 4 +- .../ReposSelectDrawerColumnHeader.ts | 2 +- .../RepositoriesColumnHeader.ts | 2 +- .../AddRepositories/RepositoriesHeader.tsx | 41 +++- ...earchBar.tsx => RepositoriesSearchBar.tsx} | 0 .../AddRepositories/RepositoriesTable.tsx | 3 +- .../AddRepositories/RepositoryTableRow.tsx | 24 ++- .../src/components/BulkImportPage.test.tsx | 8 +- .../src/components/BulkImportPage.tsx | 27 ++- .../PreviewFile/PreviewFile.test.tsx | 2 +- .../PreviewPullRequestForm.test.tsx | 2 +- .../PreviewFile/PreviewPullRequests.test.tsx | 2 +- .../AddedRepositoriesTableBody.tsx | 88 +++++++++ .../Repositories/AddedRepositoryTableRow.tsx | 80 ++++++++ .../Repositories/CatalogInfoAction.test.tsx | 11 +- .../Repositories/CatalogInfoAction.tsx | 6 +- .../DeleteRepositoryDialog.test.tsx | 100 +++++++--- .../Repositories/DeleteRepositoryDialog.tsx | 53 +++--- ...istToolbar.tsx => RepositoriesAddLink.tsx} | 18 +- .../Repositories/RepositoriesList.test.tsx | 75 +++----- .../Repositories/RepositoriesList.tsx | 128 +++++++++---- .../Repositories/RepositoriesListColumns.ts | 43 +++++ .../Repositories/RepositoriesListColumns.tsx | 98 ---------- .../src/hooks/useAddedRepositories.test.ts | 12 +- .../src/hooks/useAddedRepositories.ts | 161 ++++++---------- plugins/bulk-import/src/mocks/mockData.ts | 175 +++++++++--------- .../bulk-import/src/types/response-types.ts | 7 + .../src/utils/repository-utils.tsx | 46 +++++ yarn.lock | 2 +- 38 files changed, 782 insertions(+), 508 deletions(-) create mode 100644 .changeset/heavy-mails-doubt.md rename plugins/bulk-import/src/components/AddRepositories/{AddRepositoriesSearchBar.tsx => RepositoriesSearchBar.tsx} (100%) create mode 100644 plugins/bulk-import/src/components/Repositories/AddedRepositoriesTableBody.tsx create mode 100644 plugins/bulk-import/src/components/Repositories/AddedRepositoryTableRow.tsx rename plugins/bulk-import/src/components/Repositories/{RepositoriesListToolbar.tsx => RepositoriesAddLink.tsx} (80%) create mode 100644 plugins/bulk-import/src/components/Repositories/RepositoriesListColumns.ts delete mode 100644 plugins/bulk-import/src/components/Repositories/RepositoriesListColumns.tsx diff --git a/.changeset/heavy-mails-doubt.md b/.changeset/heavy-mails-doubt.md new file mode 100644 index 0000000000..9fa0e4baff --- /dev/null +++ b/.changeset/heavy-mails-doubt.md @@ -0,0 +1,5 @@ +--- +"@janus-idp/backstage-plugin-bulk-import": minor +--- + +update bulk import ui as per the api response diff --git a/plugins/bulk-import/dev/index.tsx b/plugins/bulk-import/dev/index.tsx index e2c20a7bd5..002bb20e26 100644 --- a/plugins/bulk-import/dev/index.tsx +++ b/plugins/bulk-import/dev/index.tsx @@ -27,6 +27,7 @@ import { BulkImportPage, bulkImportPlugin } from '../src/plugin'; import { APITypes, ImportJobResponse, + ImportJobs, ImportJobStatus, OrgAndRepoResponse, RepositoryStatus, @@ -78,7 +79,7 @@ class MockBulkImportApi implements BulkImportAPI { _page: number, _size: number, _seachString: string, - ): Promise { + ): Promise { return mockGetImportJobs; } @@ -112,7 +113,7 @@ class MockBulkImportApi implements BulkImportAPI { repo: string, _defaultBranch: string, ): Promise { - return mockGetImportJobs.find( + return mockGetImportJobs.imports.find( i => i.repository.url === repo, ) as ImportJobStatus; } diff --git a/plugins/bulk-import/package.json b/plugins/bulk-import/package.json index 0ad4fa80fa..33310925c0 100644 --- a/plugins/bulk-import/package.json +++ b/plugins/bulk-import/package.json @@ -51,6 +51,7 @@ "@material-ui/lab": "^4.0.0-alpha.61", "@mui/icons-material": "5.16.4", "@mui/material": "^5.12.2", + "@tanstack/react-query": "^4.29.21", "formik": "^2.4.5", "js-yaml": "^4.1.0", "lodash": "^4.17.21", diff --git a/plugins/bulk-import/src/api/BulkImportBackendClient.test.ts b/plugins/bulk-import/src/api/BulkImportBackendClient.test.ts index 4eb6206677..ca4652927a 100644 --- a/plugins/bulk-import/src/api/BulkImportBackendClient.test.ts +++ b/plugins/bulk-import/src/api/BulkImportBackendClient.test.ts @@ -90,7 +90,12 @@ const handlers = [ (req, res, ctx) => { const test = req.headers.get('Content-Type'); if (test === 'application/json') { - return res(ctx.status(200), ctx.json(mockGetImportJobs[1])); + return res( + ctx.status(200), + ctx.json( + mockGetImportJobs.imports.find(i => i.id === 'org/dessert/donut'), + ), + ); } return res(ctx.status(404)); }, @@ -103,7 +108,7 @@ const handlers = [ return res( ctx.status(200), ctx.json( - mockGetImportJobs.filter(r => + mockGetImportJobs.imports.filter(r => r.repository.name?.includes(searchParam), ), ), @@ -138,9 +143,9 @@ const handlers = [ (req, res, ctx) => { const test = req.headers.get('Content-Type'); if (test === 'application/json') { - return res(ctx.status(200)); + return res(ctx.json({ status: 200, ok: true })); } - return res(ctx.status(404)); + return res(ctx.json({ status: 404, ok: false })); }, ), ]; @@ -288,7 +293,9 @@ describe('BulkImportBackendClient', () => { it('getImportJobs should retrieve the import jobs based on search string', async () => { const jobs = await bulkImportApi.getImportJobs(1, 2, 'cup'); expect(jobs).toEqual( - mockGetImportJobs.filter(r => r.repository.name?.includes('cup')), + mockGetImportJobs.imports.filter(r => + r.repository.name?.includes('cup'), + ), ); }); @@ -308,7 +315,9 @@ describe('BulkImportBackendClient', () => { expect(response.status).toBe(200); }); + }); + describe('getImportAction', () => { it('getImportAction should retrive the status of the repo', async () => { const response = await bulkImportApi.getImportAction( 'org/dessert/donut', @@ -316,7 +325,9 @@ describe('BulkImportBackendClient', () => { ); expect(response.status).toBe(RepositoryStatus.WAIT_PR_APPROVAL); - expect(response).toEqual(mockGetImportJobs[1]); + expect(response).toEqual( + mockGetImportJobs.imports.find(i => i.id === 'org/dessert/donut'), + ); }); }); diff --git a/plugins/bulk-import/src/api/BulkImportBackendClient.ts b/plugins/bulk-import/src/api/BulkImportBackendClient.ts index 7c588cb082..6691b90db6 100644 --- a/plugins/bulk-import/src/api/BulkImportBackendClient.ts +++ b/plugins/bulk-import/src/api/BulkImportBackendClient.ts @@ -8,6 +8,7 @@ import { APITypes, CreateImportJobRepository, ImportJobResponse, + ImportJobs, ImportJobStatus, OrgAndRepoResponse, } from '../types'; @@ -25,7 +26,7 @@ export type BulkImportAPI = { page: number, size: number, searchString: string, - ) => Promise; + ) => Promise; createImportJobs: ( importRepositories: CreateImportJobRepository[], dryRun?: boolean, @@ -87,18 +88,19 @@ export class BulkImportBackendClient implements BulkImportAPI { const { token: idToken } = await this.identityApi.getCredentials(); const backendUrl = this.configApi.getString('backend.baseUrl'); const jsonResponse = await fetch( - `${backendUrl}/api/bulk-import/imports?pagePerIntegration=${page}&sizePerIntegration=${size}&search=${searchString}`, + `${backendUrl}/api/bulk-import/imports?page=${page}&size=${size}&search=${searchString}`, { headers: { 'Content-Type': 'application/json', ...(idToken && { Authorization: `Bearer ${idToken}` }), + 'api-version': 'v2', }, }, ); - if (jsonResponse.status !== 200 && jsonResponse.status !== 204) { + if (!jsonResponse.ok) { return jsonResponse; } - return jsonResponse.json(); + return jsonResponse.status === 204 ? null : jsonResponse.json(); } async createImportJobs( @@ -136,10 +138,11 @@ export class BulkImportBackendClient implements BulkImportAPI { }, }, ); - if (jsonResponse.status !== 200 && jsonResponse.status !== 204) { - return jsonResponse.json(); + if (!jsonResponse.ok) { + const errorResponse = await jsonResponse.json(); + throw errorResponse.err; } - return jsonResponse; + return jsonResponse.status === 204 ? null : await jsonResponse.json(); } async getImportAction(repo: string, defaultBranch: string) { diff --git a/plugins/bulk-import/src/components/AddRepositories/AddRepositoriesForm.test.tsx b/plugins/bulk-import/src/components/AddRepositories/AddRepositoriesForm.test.tsx index 56867cf6cb..ca25db7810 100644 --- a/plugins/bulk-import/src/components/AddRepositories/AddRepositoriesForm.test.tsx +++ b/plugins/bulk-import/src/components/AddRepositories/AddRepositoriesForm.test.tsx @@ -49,7 +49,7 @@ class MockBulkImportApi { repo: string, _defaultBranch: string, ): Promise { - return mockGetImportJobs.find( + return mockGetImportJobs.imports.find( i => i.repository.url === repo, ) as ImportJobStatus; } diff --git a/plugins/bulk-import/src/components/AddRepositories/AddRepositoriesTable.test.tsx b/plugins/bulk-import/src/components/AddRepositories/AddRepositoriesTable.test.tsx index bc7c52cf8e..a84cd1021d 100644 --- a/plugins/bulk-import/src/components/AddRepositories/AddRepositoriesTable.test.tsx +++ b/plugins/bulk-import/src/components/AddRepositories/AddRepositoriesTable.test.tsx @@ -44,7 +44,7 @@ class MockBulkImportApi { repo: string, _defaultBranch: string, ): Promise { - return mockGetImportJobs.find( + return mockGetImportJobs.imports.find( i => i.repository.url === repo, ) as ImportJobStatus; } diff --git a/plugins/bulk-import/src/components/AddRepositories/AddRepositoriesTable.tsx b/plugins/bulk-import/src/components/AddRepositories/AddRepositoriesTable.tsx index 22e007f66b..8f1eb6f881 100644 --- a/plugins/bulk-import/src/components/AddRepositories/AddRepositoriesTable.tsx +++ b/plugins/bulk-import/src/components/AddRepositories/AddRepositoriesTable.tsx @@ -11,13 +11,16 @@ export const AddRepositoriesTable = ({ title }: { title: string }) => { const { values } = useFormikContext(); const [searchString, setSearchString] = React.useState(''); const [page, setPage] = React.useState(0); - + const handleSearch = (str: string) => { + setSearchString(str); + setPage(0); + }; return ( {values.repositoryType === RepositorySelection.Repository ? ( diff --git a/plugins/bulk-import/src/components/AddRepositories/AddRepositoriesTableToolbar.tsx b/plugins/bulk-import/src/components/AddRepositories/AddRepositoriesTableToolbar.tsx index fe4d3e80db..66855c4144 100644 --- a/plugins/bulk-import/src/components/AddRepositories/AddRepositoriesTableToolbar.tsx +++ b/plugins/bulk-import/src/components/AddRepositories/AddRepositoriesTableToolbar.tsx @@ -10,7 +10,7 @@ import { AddRepositoriesFormValues, RepositorySelection, } from '../../types'; -import { RepositoriesSearchBar } from './AddRepositoriesSearchBar'; +import { RepositoriesSearchBar } from './RepositoriesSearchBar'; const useStyles = makeStyles(() => ({ toolbar: { @@ -73,7 +73,11 @@ export const AddRepositoriesTableToolbar = ({ return ( - + {`${title} (${selectedReposNumber})`} {!activeOrganization && ( diff --git a/plugins/bulk-import/src/components/AddRepositories/OrganizationsColumnHeader.ts b/plugins/bulk-import/src/components/AddRepositories/OrganizationsColumnHeader.ts index 3ed3db84e0..901c2c0229 100644 --- a/plugins/bulk-import/src/components/AddRepositories/OrganizationsColumnHeader.ts +++ b/plugins/bulk-import/src/components/AddRepositories/OrganizationsColumnHeader.ts @@ -8,12 +8,12 @@ export const OrganizationsColumnHeader: TableColumn[] = [ }, { id: 'url', title: 'URL', field: 'organizationUrl' }, { - id: 'selectedRepositories', + id: 'selected-repositories', title: 'Selected repositories', field: 'selectedRepositories', }, { - id: 'catalogInfoYaml', + id: 'cataloginfoyaml', title: 'catalog-info.yaml', field: 'catalogInfoYaml.status', }, diff --git a/plugins/bulk-import/src/components/AddRepositories/ReposSelectDrawerColumnHeader.ts b/plugins/bulk-import/src/components/AddRepositories/ReposSelectDrawerColumnHeader.ts index 640f2cee2d..98e7923db1 100644 --- a/plugins/bulk-import/src/components/AddRepositories/ReposSelectDrawerColumnHeader.ts +++ b/plugins/bulk-import/src/components/AddRepositories/ReposSelectDrawerColumnHeader.ts @@ -12,7 +12,7 @@ export const ReposSelectDrawerColumnHeader: TableColumn[] = [ field: 'repoUrl', }, { - id: 'catalogInfoYaml', + id: 'cataloginfoyaml', title: '', field: 'catalogInfoYaml.status', }, diff --git a/plugins/bulk-import/src/components/AddRepositories/RepositoriesColumnHeader.ts b/plugins/bulk-import/src/components/AddRepositories/RepositoriesColumnHeader.ts index e3b3757363..89b38bfabe 100644 --- a/plugins/bulk-import/src/components/AddRepositories/RepositoriesColumnHeader.ts +++ b/plugins/bulk-import/src/components/AddRepositories/RepositoriesColumnHeader.ts @@ -17,7 +17,7 @@ export const RepositoriesColumnHeader: TableColumn[] = [ field: 'organizationUrl', }, { - id: 'catalogInfoYaml', + id: 'cataloginfoyaml', title: 'catalog-info.yaml', field: 'catalogInfoYaml.status', }, diff --git a/plugins/bulk-import/src/components/AddRepositories/RepositoriesHeader.tsx b/plugins/bulk-import/src/components/AddRepositories/RepositoriesHeader.tsx index ceba8f88e2..d9e53b4d1a 100644 --- a/plugins/bulk-import/src/components/AddRepositories/RepositoriesHeader.tsx +++ b/plugins/bulk-import/src/components/AddRepositories/RepositoriesHeader.tsx @@ -9,6 +9,7 @@ import { } from '@material-ui/core'; import { Order } from '../../types'; +import { RepositoriesListColumns } from '../Repositories/RepositoriesListColumns'; import { OrganizationsColumnHeader } from './OrganizationsColumnHeader'; import { RepositoriesColumnHeader } from './RepositoriesColumnHeader'; import { ReposSelectDrawerColumnHeader } from './ReposSelectDrawerColumnHeader'; @@ -22,15 +23,17 @@ export const RepositoriesHeader = ({ onRequestSort, isDataLoading, showOrganizations, + showImportJobs, isRepoSelectDrawer = false, }: { - numSelected: number; + numSelected?: number; onRequestSort: (event: React.MouseEvent, property: any) => void; order: Order; orderBy: string | undefined; - rowCount: number; + rowCount?: number; isDataLoading?: boolean; showOrganizations?: boolean; + showImportJobs?: boolean; isRepoSelectDrawer?: boolean; onSelectAllClick?: (event: React.ChangeEvent) => void; }) => { @@ -43,16 +46,29 @@ export const RepositoriesHeader = ({ if (showOrganizations) { return OrganizationsColumnHeader; } + if (showImportJobs) { + return RepositoriesListColumns; + } if (isRepoSelectDrawer) { return ReposSelectDrawerColumnHeader; } return RepositoriesColumnHeader; }; + const tableCellStyle = () => { + if (showImportJobs) { + return undefined; + } + if (showOrganizations) { + return '15px 16px 15px 24px'; + } + return '15px 16px 15px 6px'; + }; + return ( - {getColumnHeader().map((headCell, index) => ( + {getColumnHeader()?.map((headCell, index) => ( - {index === 0 && !showOrganizations && ( + {index === 0 && !showOrganizations && !showImportJobs && ( 0 && numSelected < rowCount} - checked={rowCount > 0 && numSelected === rowCount} + indeterminate={ + (numSelected && + rowCount && + numSelected > 0 && + numSelected < rowCount) || + false + } + checked={ + ((rowCount ?? 0) > 0 && numSelected === rowCount) || false + } onChange={onSelectAllClick} inputProps={{ 'aria-label': 'select all repositories', @@ -85,6 +107,7 @@ export const RepositoriesHeader = ({ active={orderBy === headCell.field} direction={orderBy === headCell.field ? order : 'asc'} onClick={createSortHandler(headCell.field)} + disabled={headCell.sorting === false} > {headCell.title} diff --git a/plugins/bulk-import/src/components/AddRepositories/AddRepositoriesSearchBar.tsx b/plugins/bulk-import/src/components/AddRepositories/RepositoriesSearchBar.tsx similarity index 100% rename from plugins/bulk-import/src/components/AddRepositories/AddRepositoriesSearchBar.tsx rename to plugins/bulk-import/src/components/AddRepositories/RepositoriesSearchBar.tsx diff --git a/plugins/bulk-import/src/components/AddRepositories/RepositoriesTable.tsx b/plugins/bulk-import/src/components/AddRepositories/RepositoriesTable.tsx index 51433b06aa..83e241f0fc 100644 --- a/plugins/bulk-import/src/components/AddRepositories/RepositoriesTable.tsx +++ b/plugins/bulk-import/src/components/AddRepositories/RepositoriesTable.tsx @@ -39,7 +39,7 @@ export const RepositoriesTable = ({ drawerOrganization?: string; updateSelectedReposInDrawer?: (repos: AddedRepositories) => void; }) => { - const { setFieldValue, values } = + const { setFieldValue, values, setStatus } = useFormikContext(); const [order, setOrder] = React.useState('asc'); const [orderBy, setOrderBy] = React.useState(); @@ -200,6 +200,7 @@ export const RepositoriesTable = ({ const updateSelection = (newSelected: AddedRepositories) => { setSelected(newSelected); + setStatus(null); if (drawerOrganization && updateSelectedReposInDrawer) { // Update in the context of the drawer diff --git a/plugins/bulk-import/src/components/AddRepositories/RepositoryTableRow.tsx b/plugins/bulk-import/src/components/AddRepositories/RepositoryTableRow.tsx index a8259ff947..e212cc6764 100644 --- a/plugins/bulk-import/src/components/AddRepositories/RepositoryTableRow.tsx +++ b/plugins/bulk-import/src/components/AddRepositories/RepositoryTableRow.tsx @@ -71,25 +71,29 @@ export const RepositoryTableRow = ({ {data.repoName} - - <> - {urlHelper(data?.repoUrl || '')} + {data.repoUrl ? ( + + {urlHelper(data.repoUrl)} - - + + ) : ( + <>- + )} {!isDrawer && ( - - <> - {urlHelper(data?.organizationUrl || '')} + {data?.organizationUrl ? ( + + {urlHelper(data.organizationUrl)} - - + + ) : ( + <>- + )} )} diff --git a/plugins/bulk-import/src/components/BulkImportPage.test.tsx b/plugins/bulk-import/src/components/BulkImportPage.test.tsx index fff39ebc07..4bdef1c9ca 100644 --- a/plugins/bulk-import/src/components/BulkImportPage.test.tsx +++ b/plugins/bulk-import/src/components/BulkImportPage.test.tsx @@ -33,17 +33,17 @@ const RequirePermissionMock = RequirePermission as jest.MockedFunction< >; describe('BulkImport Page', () => { - it('should render if user authorized to access bulk import plugin', async () => { + it('should render if user is authorized to access bulk import plugin', async () => { RequirePermissionMock.mockImplementation(props => <>{props.children}); mockUsePermission.mockReturnValue({ loading: false, allowed: true }); mockUseAddedRepositories.mockReturnValue({ loaded: true, - data: [], - retry: jest.fn(), + data: { addedRepositories: [], totalJobs: 0 }, + refetch: jest.fn(), error: undefined, }); await renderInTestApp(); - expect(screen.getByText('Added repositories (0)')).toBeInTheDocument(); + expect(screen.getByText('Added repositories')).toBeInTheDocument(); }); it('should not render if user is not authorized to access the bulk import plugin', async () => { diff --git a/plugins/bulk-import/src/components/BulkImportPage.tsx b/plugins/bulk-import/src/components/BulkImportPage.tsx index 18970457e4..9c932790f1 100644 --- a/plugins/bulk-import/src/components/BulkImportPage.tsx +++ b/plugins/bulk-import/src/components/BulkImportPage.tsx @@ -5,6 +5,7 @@ import { usePermission } from '@backstage/plugin-permission-react'; import { Alert, AlertTitle } from '@material-ui/lab'; import FormControl from '@mui/material/FormControl'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { Formik } from 'formik'; import { bulkImportPermission } from '@janus-idp/backstage-plugin-bulk-import-common'; @@ -21,6 +22,8 @@ import { import { RepositoriesList } from './Repositories/RepositoriesList'; export const BulkImportPage = () => { + // to store the queryClient instance + const queryClientRef = React.useRef(); const initialValues: AddRepositoriesFormValues = { repositoryType: RepositorySelection.Repository, repositories: {}, @@ -33,21 +36,27 @@ export const BulkImportPage = () => { resourceRef: bulkImportPermission.resourceType, }); + if (!queryClientRef.current) { + queryClientRef.current = new QueryClient(); + } + const showContent = () => { if (bulkImportViewPermissionResult.loading) { return ; } if (bulkImportViewPermissionResult.allowed) { return ( - {}} - > - - - - + + {}} + > + + + + + ); } return ( diff --git a/plugins/bulk-import/src/components/PreviewFile/PreviewFile.test.tsx b/plugins/bulk-import/src/components/PreviewFile/PreviewFile.test.tsx index b236ac89d9..86032c9c09 100644 --- a/plugins/bulk-import/src/components/PreviewFile/PreviewFile.test.tsx +++ b/plugins/bulk-import/src/components/PreviewFile/PreviewFile.test.tsx @@ -45,7 +45,7 @@ class MockBulkImportApi { repo: string, _defaultBranch: string, ): Promise { - return mockGetImportJobs.find( + return mockGetImportJobs.imports.find( i => i.repository.url === repo, ) as ImportJobStatus; } diff --git a/plugins/bulk-import/src/components/PreviewFile/PreviewPullRequestForm.test.tsx b/plugins/bulk-import/src/components/PreviewFile/PreviewPullRequestForm.test.tsx index be133a4a5c..75c4420de1 100644 --- a/plugins/bulk-import/src/components/PreviewFile/PreviewPullRequestForm.test.tsx +++ b/plugins/bulk-import/src/components/PreviewFile/PreviewPullRequestForm.test.tsx @@ -44,7 +44,7 @@ class MockBulkImportApi { repo: string, _defaultBranch: string, ): Promise { - return mockGetImportJobs.find( + return mockGetImportJobs.imports.find( i => i.repository.url === repo, ) as ImportJobStatus; } diff --git a/plugins/bulk-import/src/components/PreviewFile/PreviewPullRequests.test.tsx b/plugins/bulk-import/src/components/PreviewFile/PreviewPullRequests.test.tsx index 7ca7835857..4cae6df03e 100644 --- a/plugins/bulk-import/src/components/PreviewFile/PreviewPullRequests.test.tsx +++ b/plugins/bulk-import/src/components/PreviewFile/PreviewPullRequests.test.tsx @@ -44,7 +44,7 @@ class MockBulkImportApi { repo: string, _defaultBranch: string, ): Promise { - return mockGetImportJobs.find( + return mockGetImportJobs.imports.find( i => i.repository.url === repo, ) as ImportJobStatus; } diff --git a/plugins/bulk-import/src/components/Repositories/AddedRepositoriesTableBody.tsx b/plugins/bulk-import/src/components/Repositories/AddedRepositoriesTableBody.tsx new file mode 100644 index 0000000000..ebd6ffddb8 --- /dev/null +++ b/plugins/bulk-import/src/components/Repositories/AddedRepositoriesTableBody.tsx @@ -0,0 +1,88 @@ +import * as React from 'react'; + +import { makeStyles, TableBody, TableCell, TableRow } from '@material-ui/core'; +import { Alert } from '@material-ui/lab'; +import CircularProgress from '@mui/material/CircularProgress'; + +import { AddRepositoryData } from '../../types'; +import { AddedRepositoryTableRow } from './AddedRepositoryTableRow'; +import { RepositoriesListColumns } from './RepositoriesListColumns'; + +const useStyles = makeStyles(theme => ({ + empty: { + padding: theme.spacing(2), + display: 'flex', + justifyContent: 'center', + }, +})); + +export const AddedRepositoriesTableBody = ({ + loading, + rows, + emptyRows, + error, +}: { + error: { [key: string]: string }; + loading: boolean; + emptyRows: number; + rows: AddRepositoryData[]; +}) => { + const classes = useStyles(); + + if (loading) { + return ( + + + +
+ +
+ + + + ); + } + if (Object.keys(error || {}).length > 0) { + return ( + + + +
+ {`${error.name}. ${error.message}`} +
+ + + + ); + } + + if (rows?.length > 0) { + return ( + + {rows.map(row => { + return ; + })} + {emptyRows > 0 && ( + + + + )} + + ); + } + return ( + + + +
+ No records found +
+ + + + ); +}; diff --git a/plugins/bulk-import/src/components/Repositories/AddedRepositoryTableRow.tsx b/plugins/bulk-import/src/components/Repositories/AddedRepositoryTableRow.tsx new file mode 100644 index 0000000000..e2e1caf017 --- /dev/null +++ b/plugins/bulk-import/src/components/Repositories/AddedRepositoryTableRow.tsx @@ -0,0 +1,80 @@ +import * as React from 'react'; + +import { Link } from '@backstage/core-components'; + +import { makeStyles, TableCell, TableRow } from '@material-ui/core'; +import OpenInNewIcon from '@mui/icons-material/OpenInNew'; +import { useFormikContext } from 'formik'; + +import { AddRepositoriesFormValues, AddRepositoryData } from '../../types'; +import { + calculateLastUpdated, + getImportStatus, + urlHelper, +} from '../../utils/repository-utils'; +import CatalogInfoAction from './CatalogInfoAction'; +import DeleteRepository from './DeleteRepository'; +import SyncRepository from './SyncRepository'; + +const useStyles = makeStyles(() => ({ + tableCellStyle: { + lineHeight: '1.5rem', + fontSize: '0.875rem', + }, +})); + +const ImportStatus = ({ data }: { data: AddRepositoryData }) => { + const { values } = useFormikContext(); + return getImportStatus( + values.repositories?.[data.id]?.catalogInfoYaml?.status as string, + true, + ); +}; + +const LastUpdated = ({ data }: { data: AddRepositoryData }) => { + const { values } = useFormikContext(); + return calculateLastUpdated( + values.repositories?.[data.id]?.catalogInfoYaml?.lastUpdated || '', + ); +}; + +export const AddedRepositoryTableRow = ({ + data, +}: { + data: AddRepositoryData; +}) => { + const classes = useStyles(); + + return ( + + + {data.repoName} + + + + {urlHelper(data?.repoUrl || '')} + + + + + + {urlHelper(data?.organizationUrl || '')} + + + + + + + + + + + + + + + + + + ); +}; diff --git a/plugins/bulk-import/src/components/Repositories/CatalogInfoAction.test.tsx b/plugins/bulk-import/src/components/Repositories/CatalogInfoAction.test.tsx index 91fddeec0e..1bc0b7758a 100644 --- a/plugins/bulk-import/src/components/Repositories/CatalogInfoAction.test.tsx +++ b/plugins/bulk-import/src/components/Repositories/CatalogInfoAction.test.tsx @@ -51,7 +51,7 @@ describe('CatalogInfoAction', () => { 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], + value: mockGetImportJobs.imports[0], }); mockUsePermission.mockReturnValue({ loading: false, allowed: true }); @@ -62,7 +62,7 @@ describe('CatalogInfoAction', () => { values: { repositories: { ['org/dessert/cupcake']: { - ...mockGetImportJobs[0], + ...mockGetImportJobs.imports[0], catalogInfoYaml: { status: RepositoryStatus.WAIT_PR_APPROVAL, }, @@ -90,7 +90,10 @@ describe('CatalogInfoAction', () => { it('should allow users to view the catalog-info.yaml if the entity is registered', async () => { mockUseAsync.mockReturnValue({ loading: false, - value: { ...mockGetImportJobs[0], status: RepositoryStatus.ADDED }, + value: { + ...mockGetImportJobs.imports[0], + status: RepositoryStatus.ADDED, + }, }); mockUsePermission.mockReturnValue({ loading: false, allowed: true }); @@ -101,7 +104,7 @@ describe('CatalogInfoAction', () => { values: { repositories: { ['org/dessert/cupcake']: { - ...mockGetImportJobs[0], + ...mockGetImportJobs.imports[0], catalogInfoYaml: { status: RepositoryStatus.ADDED, }, diff --git a/plugins/bulk-import/src/components/Repositories/CatalogInfoAction.tsx b/plugins/bulk-import/src/components/Repositories/CatalogInfoAction.tsx index ea699939c7..2bef9b2627 100644 --- a/plugins/bulk-import/src/components/Repositories/CatalogInfoAction.tsx +++ b/plugins/bulk-import/src/components/Repositories/CatalogInfoAction.tsx @@ -90,8 +90,6 @@ const CatalogInfoAction = ({ data }: { data: AddRepositoryData }) => { if (Object.keys(drawerData || {}).length === 0) { setDrawerData(value as ImportJobStatus); } - } else { - removeQueryParams(); } } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -123,9 +121,9 @@ const CatalogInfoAction = ({ data }: { data: AddRepositoryData }) => { ({ useApi: jest.fn(), })); +const createTestQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, // Disable retries for testing + }, + }, + }); +let queryClient: QueryClient; +beforeEach(() => { + queryClient = createTestQueryClient(); +}); + describe('DeleteRepositoryDialog', () => { it('renders delete repository dialog correctly', () => { render( - , + + + , ); expect( screen.queryByText(/Remove cupcake repository?/i), @@ -30,40 +45,73 @@ describe('DeleteRepositoryDialog', () => { }); it('does not render when not open', () => { - const { queryByText } = render( - , + render( + + + , + ); + expect( + screen.queryByText(/Remove cupcake repository?/i), + ).not.toBeInTheDocument(); + }); + + it('should show an error if repository url is missing', async () => { + const repo = { + ...mockGetRepositories.repositories[0], + repoUrl: '', + url: '', + }; + + render( + + + , ); - expect(queryByText(/Remove cupcake repository?/i)).not.toBeInTheDocument(); + expect( + screen.queryByText(/Remove cupcake repository?/i), + ).toBeInTheDocument(); + const deleteButton = screen.getByText('Remove'); + fireEvent.click(deleteButton); + await waitFor(() => { + expect( + screen.queryByText( + 'Cannot remove repository as the repository URL is missing.', + ), + ).toBeInTheDocument(); + }); }); it('shows an error when the deletion fails', async () => { - const mockDeleteRepository = jest - .fn() - .mockResolvedValue({ error: { message: 'Error occured' } }); + const mockDeleteRepository = jest.fn().mockRejectedValue('Error occured'); const useApiMock = useApi as jest.Mock; useApiMock.mockReturnValue({ deleteImportAction: mockDeleteRepository, }); - - const user = userEvent.setup(); render( - , + + + , ); const deleteButton = screen.getByText('Remove'); - await user.click(deleteButton); + fireEvent.click(deleteButton); await waitFor(() => { expect( - screen.queryByText(/Unable to remove repository. Error occured/i), + screen.queryByText('Unable to remove repository. Error occured'), ).toBeInTheDocument(); + expect(deleteButton).toBeDisabled(); }); }); }); diff --git a/plugins/bulk-import/src/components/Repositories/DeleteRepositoryDialog.tsx b/plugins/bulk-import/src/components/Repositories/DeleteRepositoryDialog.tsx index fb454ceaf5..ea24e21edd 100644 --- a/plugins/bulk-import/src/components/Repositories/DeleteRepositoryDialog.tsx +++ b/plugins/bulk-import/src/components/Repositories/DeleteRepositoryDialog.tsx @@ -15,7 +15,7 @@ import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; import IconButton from '@mui/material/IconButton'; import Typography from '@mui/material/Typography'; -import { get } from 'lodash'; +import { useMutation } from '@tanstack/react-query'; import { bulkImportApiRef } from '../../api/BulkImportBackendClient'; import { AddRepositoryData } from '../../types'; @@ -69,30 +69,24 @@ const DeleteRepositoryDialog = ({ closeDialog: () => void; }) => { const classes = useStyles(); - const [error, setError] = React.useState(''); - const [isSubmitting, setIsSubmitting] = React.useState(false); const bulkImportApi = useApi(bulkImportApiRef); + const deleteRepository = (deleteRepo: AddRepositoryData) => { + return bulkImportApi.deleteImportAction( + deleteRepo.repoUrl || '', + deleteRepo.defaultBranch || 'main', + ); + }; + const mutationDelete = useMutation(deleteRepository, { + onSuccess: () => { + closeDialog(); + }, + }); const handleClickRemove = async () => { - setIsSubmitting(true); - if (!repository.repoUrl || !repository?.defaultBranch) { - setIsSubmitting(false); - setError( - `Unable to remove repository. ${!repository?.repoUrl ? 'Repository URL missing' : 'Repository default branch is missing'}`, - ); - } else { - const value = await bulkImportApi.deleteImportAction( - repository.repoUrl, - repository.defaultBranch, - ); - setIsSubmitting(false); - if (get(value, 'error')) { - setError(`Unable to remove repository. ${get(value, 'error.message')}`); - } else { - closeDialog(); - } - } + mutationDelete.mutate(repository); }; + const isUrlMissing = !repository.repoUrl; + return ( - {error && ( + {(isUrlMissing || mutationDelete.isError) && ( - {error} + + {isUrlMissing && + 'Cannot remove repository as the repository URL is missing.'} + {mutationDelete.isError && + `Unable to remove repository. ${mutationDelete.error}`} + )} @@ -137,9 +136,13 @@ const DeleteRepositoryDialog = ({ variant="contained" className={`${classes.deleteButton} ${classes.button}`} onClick={() => handleClickRemove()} - disabled={isSubmitting} + disabled={ + isUrlMissing || mutationDelete.isLoading || mutationDelete.isError + } startIcon={ - isSubmitting && + mutationDelete.isLoading && ( + + ) } > Remove diff --git a/plugins/bulk-import/src/components/Repositories/RepositoriesListToolbar.tsx b/plugins/bulk-import/src/components/Repositories/RepositoriesAddLink.tsx similarity index 80% rename from plugins/bulk-import/src/components/Repositories/RepositoriesListToolbar.tsx rename to plugins/bulk-import/src/components/Repositories/RepositoriesAddLink.tsx index c5f95ac42b..e39b087f37 100644 --- a/plugins/bulk-import/src/components/Repositories/RepositoriesListToolbar.tsx +++ b/plugins/bulk-import/src/components/Repositories/RepositoriesAddLink.tsx @@ -8,21 +8,15 @@ import { useFormikContext } from 'formik'; import { AddRepositoriesFormValues } from '../../types'; -const useStyles = makeStyles(theme => ({ - toolbar: { +const useStyles = makeStyles(() => ({ + addLink: { display: 'flex', justifyContent: 'end', marginBottom: '24px', }, - rbacPreReqLink: { - color: theme.palette.link, - }, - alertTitle: { - fontWeight: 'bold', - }, })); -export const RepositoriesListToolbar = () => { +export const RepositoriesAddLink = () => { const { status, setStatus } = useFormikContext(); const classes = useStyles(); @@ -30,7 +24,7 @@ export const RepositoriesListToolbar = () => { setStatus(null); }; return ( -
+ <> {(status?.title || status?.url) && ( <> handleCloseAlert()}> @@ -42,7 +36,7 @@ export const RepositoriesListToolbar = () => {
)} - + { Add -
+ ); }; diff --git a/plugins/bulk-import/src/components/Repositories/RepositoriesList.test.tsx b/plugins/bulk-import/src/components/Repositories/RepositoriesList.test.tsx index 203d5598a2..5596bd2237 100644 --- a/plugins/bulk-import/src/components/Repositories/RepositoriesList.test.tsx +++ b/plugins/bulk-import/src/components/Repositories/RepositoriesList.test.tsx @@ -8,7 +8,7 @@ import { render, screen } from '@testing-library/react'; import { useFormikContext } from 'formik'; import { useAddedRepositories } from '../../hooks'; -import { mockGetImportJobs } from '../../mocks/mockData'; +import { mockGetImportJobs, mockGetRepositories } from '../../mocks/mockData'; import { RepositoriesList } from './RepositoriesList'; jest.mock('react', () => ({ @@ -49,47 +49,15 @@ const mockIdentityApi = { .mockResolvedValue({ userEntityRef: 'user:default/testuser' }), }; -jest.mock('./RepositoriesListColumns', () => ({ - columns: [ - { - title: 'Name', - field: 'repoName', - type: 'string', - }, - { - title: 'Repo URL', - field: 'repoUrl', - type: 'string', - }, - { - title: 'Organization', - field: 'orgName', - type: 'string', - }, - { - title: 'Status', - field: 'catalogInfoYaml.status', - type: 'string', - }, - { - title: 'Last updated', - field: 'lastUpdated', - type: 'string', - }, - { - title: 'Actions', - field: 'actions', - type: 'string', - }, - ], -})); - const mockAsyncData = { loaded: true, - data: mockGetImportJobs, + data: { + addedRepositories: mockGetImportJobs.imports, + totalJobs: mockGetImportJobs.imports.length, + }, totalCount: 1, error: undefined, - retry: jest.fn(), + refetch: jest.fn(), }; const mockUseAddedRepositories = useAddedRepositories as jest.MockedFunction< @@ -105,6 +73,7 @@ describe('RepositoriesList', () => { (useFormikContext as jest.Mock).mockReturnValue({ status: null, setFieldValue: jest.fn(), + values: mockGetRepositories.repositories, }); mockUseAddedRepositories.mockReturnValue(mockAsyncData); render( @@ -119,12 +88,7 @@ describe('RepositoriesList', () => { expect( screen.getByText('Added repositories (4)', { exact: false }), ).toBeInTheDocument(); - expect(screen.getByText('Name')).toBeInTheDocument(); - expect(screen.getByText('Repo URL')).toBeInTheDocument(); - expect(screen.getByText('Organization')).toBeInTheDocument(); - expect(screen.getByText('Status')).toBeInTheDocument(); - expect(screen.getByText('Last updated')).toBeInTheDocument(); - expect(screen.getByText('Actions')).toBeInTheDocument(); + expect(screen.getByTestId('import-jobs')).toBeInTheDocument(); }); it('should render the component and display empty content when no data', async () => { @@ -132,7 +96,10 @@ describe('RepositoriesList', () => { status: null, setFieldValue: jest.fn(), }); - mockUseAddedRepositories.mockReturnValue({ ...mockAsyncData, data: [] }); + mockUseAddedRepositories.mockReturnValue({ + ...mockAsyncData, + data: { addedRepositories: [], totalJobs: 0 }, + }); render( @@ -142,14 +109,14 @@ describe('RepositoriesList', () => { ); expect( - screen.getByText('Added repositories (0)', { exact: false }), + screen.getByText('Added repositories', { exact: false }), ).toBeInTheDocument(); - const emptyMessage = screen.getByTestId('added-repositories-table-empty'); + const emptyMessage = screen.getByTestId('no-import-jobs-found'); expect(emptyMessage).toBeInTheDocument(); expect(emptyMessage).toHaveTextContent('No records found'); }); - it('should display an alert if get import job api fails', async () => { + it('should display an alert in case of any errors', async () => { (useFormikContext as jest.Mock).mockReturnValue({ status: { title: 'Not found', @@ -157,7 +124,10 @@ describe('RepositoriesList', () => { }, setFieldValue: jest.fn(), }); - mockUseAddedRepositories.mockReturnValue({ ...mockAsyncData, data: [] }); + mockUseAddedRepositories.mockReturnValue({ + ...mockAsyncData, + data: { addedRepositories: [], totalJobs: 0 }, + }); render( @@ -165,9 +135,8 @@ describe('RepositoriesList', () => { , ); - - expect( - screen.getByText('Not found https://xyz', { exact: false }), - ).toBeInTheDocument(); + const addRepoButton = screen.getByText('Add'); + expect(addRepoButton).toBeInTheDocument(); + expect(screen.getByText('Not found https://xyz')).toBeInTheDocument(); }); }); diff --git a/plugins/bulk-import/src/components/Repositories/RepositoriesList.tsx b/plugins/bulk-import/src/components/Repositories/RepositoriesList.tsx index dd290593c4..df49763a34 100644 --- a/plugins/bulk-import/src/components/Repositories/RepositoriesList.tsx +++ b/plugins/bulk-import/src/components/Repositories/RepositoriesList.tsx @@ -1,18 +1,21 @@ import React from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; -import { ErrorPage, Table } from '@backstage/core-components'; +import { Table } from '@backstage/core-components'; -import { makeStyles } from '@material-ui/core'; +import { makeStyles, TablePagination } from '@material-ui/core'; import { useDeleteDialog, useDrawer } from '@janus-idp/shared-react'; import { useAddedRepositories } from '../../hooks/useAddedRepositories'; -import { AddRepositoryData } from '../../types'; +import { AddRepositoryData, Order } from '../../types'; +import { getComparator } from '../../utils/repository-utils'; +import { RepositoriesHeader } from '../AddRepositories/RepositoriesHeader'; +import { AddedRepositoriesTableBody } from './AddedRepositoriesTableBody'; import DeleteRepositoryDialog from './DeleteRepositoryDialog'; import EditCatalogInfo from './EditCatalogInfo'; -import { columns } from './RepositoriesListColumns'; -import { RepositoriesListToolbar } from './RepositoriesListToolbar'; +import { RepositoriesAddLink } from './RepositoriesAddLink'; +import { RepositoriesListColumns } from './RepositoriesListColumns'; const useStyles = makeStyles(theme => ({ empty: { @@ -25,68 +28,117 @@ const useStyles = makeStyles(theme => ({ export const RepositoriesList = () => { const navigate = useNavigate(); const location = useLocation(); - const searchParams = new URLSearchParams(location.search); + const classes = useStyles(); + const queryParams = new URLSearchParams(location.search); + const [order, setOrder] = React.useState('asc'); + const [orderBy, setOrderBy] = React.useState(); const { openDialog, setOpenDialog, deleteComponent } = useDeleteDialog(); const { openDrawer, setOpenDrawer, drawerData } = useDrawer(); const [pageNumber, setPageNumber] = React.useState(0); const [rowsPerPage, setRowsPerPage] = React.useState(5); - const [searchString, setSearchString] = React.useState(''); - const classes = useStyles(); + const [debouncedSearch, setDebouncedSearch] = React.useState(''); const { data: importJobs, error: errJobs, loaded: jobsLoaded, - retry, - } = useAddedRepositories(pageNumber + 1, rowsPerPage, searchString); + refetch, + } = useAddedRepositories(pageNumber + 1, rowsPerPage, debouncedSearch); const closeDialog = () => { setOpenDialog(false); - retry(); + refetch(); }; const closeDrawer = () => { - searchParams.delete('repository'); - searchParams.delete('defaultBranch'); + queryParams.delete('repository'); + queryParams.delete('defaultBranch'); navigate({ pathname: location.pathname, - search: `?${searchParams.toString()}`, + search: `?${queryParams.toString()}`, }); setOpenDrawer(false); }; - if (Object.keys(errJobs || {}).length > 0) { - return ; - } + const handleRequestSort = ( + _event: React.MouseEvent, + property: string, + ) => { + const isAsc = orderBy === property && order === 'asc'; + setOrder(isAsc ? 'desc' : 'asc'); + setOrderBy(property); + }; + + // Avoid a layout jump when reaching the last page with empty rows. + const emptyRows = + pageNumber > 0 ? Math.max(0, rowsPerPage - importJobs.totalJobs) : 0; + + const sortedData = React.useMemo(() => { + return [...(importJobs.addedRepositories || [])]?.sort( + getComparator(order, orderBy as string), + ); + }, [importJobs.addedRepositories, order, orderBy]); + + const handleSearch = (str: string) => { + setDebouncedSearch(str); + setPageNumber(0); + }; return ( <> - + { - setSearchString(search); - }} - onPageChange={(page: number, pageSize: number) => { - setPageNumber(page); - setRowsPerPage(pageSize); - }} - onRowsPerPageChange={(pageSize: number) => { - setRowsPerPage(pageSize); - }} + data={importJobs.addedRepositories ?? []} + columns={RepositoriesListColumns} + onSearchChange={handleSearch} title={ - !jobsLoaded || !importJobs + !jobsLoaded || !importJobs || importJobs.totalJobs === 0 ? 'Added repositories' - : `Added repositories (${importJobs.length})` + : `Added repositories (${importJobs.totalJobs})` } - options={{ padding: 'default', search: true, paging: true }} - data={importJobs ?? []} - isLoading={!jobsLoaded} - columns={columns} + components={{ + Header: () => ( + + ), + Body: () => ( + + ), + + Pagination: () => ( + { + setPageNumber(page); + }} + onRowsPerPageChange={event => { + setRowsPerPage(event.target.value as unknown as number); + }} + labelRowsPerPage={null} + /> + ), + }} emptyContent={ -
+
No records found
} diff --git a/plugins/bulk-import/src/components/Repositories/RepositoriesListColumns.ts b/plugins/bulk-import/src/components/Repositories/RepositoriesListColumns.ts new file mode 100644 index 0000000000..1e7ef940e0 --- /dev/null +++ b/plugins/bulk-import/src/components/Repositories/RepositoriesListColumns.ts @@ -0,0 +1,43 @@ +import { TableColumn } from '@backstage/core-components'; + +import { AddRepositoryData } from '../../types'; + +export const RepositoriesListColumns: TableColumn[] = [ + { + id: 'name', + title: 'Name', + field: 'repoName', + type: 'string', + }, + { + id: 'repo-url', + title: 'Repo URL', + field: 'repoUrl', + type: 'string', + }, + { + id: 'organization', + title: 'Organization', + field: 'organizationUrl', + type: 'string', + }, + { + id: 'status', + title: 'Status', + field: 'catalogInfoYaml.status', + type: 'string', + }, + { + id: 'last-updated', + title: 'Last updated', + field: 'catalogInfoYaml.lastUpdated', + type: 'datetime', + }, + { + id: 'actions', + title: 'Actions', + field: 'actions', + sorting: false, + type: 'string', + }, +]; diff --git a/plugins/bulk-import/src/components/Repositories/RepositoriesListColumns.tsx b/plugins/bulk-import/src/components/Repositories/RepositoriesListColumns.tsx deleted file mode 100644 index a1c1500cda..0000000000 --- a/plugins/bulk-import/src/components/Repositories/RepositoriesListColumns.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import React from 'react'; - -import { Link, TableColumn } from '@backstage/core-components'; - -import OpenInNewIcon from '@mui/icons-material/OpenInNew'; -import { useFormikContext } from 'formik'; - -import { AddRepositoriesFormValues, AddRepositoryData } from '../../types'; -import { - calculateLastUpdated, - descendingComparator, - getImportStatus, - urlHelper, -} from '../../utils/repository-utils'; -import CatalogInfoAction from './CatalogInfoAction'; -import DeleteRepository from './DeleteRepository'; -import SyncRepository from './SyncRepository'; - -const ImportStatus = ({ data }: { data: AddRepositoryData }) => { - const { values } = useFormikContext(); - return getImportStatus( - values.repositories[data.id]?.catalogInfoYaml?.status as string, - true, - ); -}; - -const LastUpdated = ({ data }: { data: AddRepositoryData }) => { - const { values } = useFormikContext(); - return calculateLastUpdated( - values.repositories[data.id]?.catalogInfoYaml?.lastUpdated || '', - ); -}; - -export const columns: TableColumn[] = [ - { - title: 'Name', - field: 'repoName', - type: 'string', - }, - { - title: 'Repo URL', - field: 'repoUrl', - type: 'string', - align: 'left', - render: (props: AddRepositoryData) => { - return ( - - {urlHelper(props.repoUrl || '')} - - - ); - }, - }, - { - title: 'Organization', - field: 'organizationUrl', - type: 'string', - align: 'left', - render: (props: AddRepositoryData) => { - return ( - - {urlHelper(props.organizationUrl || '')} - - - ); - }, - }, - { - title: 'Status', - field: 'catalogInfoYaml.status', - type: 'string', - align: 'left', - customSort: (a: AddRepositoryData, b: AddRepositoryData) => - descendingComparator(a, b, 'catalogInfoYaml.status'), - render: (data: AddRepositoryData) => , - }, - { - title: 'Last updated', - field: 'catalogInfoYaml.lastUpdated', - type: 'datetime', - align: 'left', - render: (data: AddRepositoryData) => , - }, - { - title: 'Actions', - field: 'actions', - sorting: false, - type: 'string', - align: 'left', - render: (data: AddRepositoryData) => ( - <> - - - - - ), - }, -]; diff --git a/plugins/bulk-import/src/hooks/useAddedRepositories.test.ts b/plugins/bulk-import/src/hooks/useAddedRepositories.test.ts index cdd5d7d82e..5fb06c7efe 100644 --- a/plugins/bulk-import/src/hooks/useAddedRepositories.test.ts +++ b/plugins/bulk-import/src/hooks/useAddedRepositories.test.ts @@ -10,6 +10,16 @@ jest.mock('@backstage/core-plugin-api', () => ({ }), })); +jest.mock('@tanstack/react-query', () => ({ + ...jest.requireActual('@tanstack/react-query'), + useQuery: jest.fn().mockReturnValue({ + data: mockGetImportJobs, + isLoading: false, + error: null, + refetch: jest.fn(), + }), +})); + jest.mock('formik', () => ({ ...jest.requireActual('formik'), useFormikContext: jest.fn().mockReturnValue({ @@ -22,7 +32,7 @@ describe('useAddedRepositories', () => { const { result } = renderHook(() => useAddedRepositories(1, 5, '')); await waitFor(() => { expect(result.current.loaded).toBeTruthy(); - expect(result.current.data).toHaveLength(4); + expect(result.current.data.totalJobs).toBe(4); }); }); }); diff --git a/plugins/bulk-import/src/hooks/useAddedRepositories.ts b/plugins/bulk-import/src/hooks/useAddedRepositories.ts index 213c80abe4..58aceff3f0 100644 --- a/plugins/bulk-import/src/hooks/useAddedRepositories.ts +++ b/plugins/bulk-import/src/hooks/useAddedRepositories.ts @@ -1,5 +1,5 @@ import React from 'react'; -import { useAsync, useAsyncRetry, useDebounce, useInterval } from 'react-use'; +import { useAsync, useDebounce } from 'react-use'; import { configApiRef, @@ -7,6 +7,7 @@ import { useApi, } from '@backstage/core-plugin-api'; +import { useQuery } from '@tanstack/react-query'; import { useFormikContext } from 'formik'; import { @@ -18,33 +19,32 @@ import { bulkImportApiRef } from '../api/BulkImportBackendClient'; import { AddRepositoriesFormValues, AddRepositoryData, - ImportJobStatus, - RepositoryStatus, + ImportJobs, } from '../types'; -import { getPRTemplate } from '../utils/repository-utils'; +import { prepareDataForAddedRepositories } from '../utils/repository-utils'; export const useAddedRepositories = ( - page: number, + pageNumber: number, rowsPerPage: number, searchString: string, pollInterval?: number, ): { loaded: boolean; - data: AddRepositoryData[]; + data: { + addedRepositories: AddRepositoryData[]; + totalJobs: number; + }; error: any; - retry: () => void; + refetch: () => void; } => { - const [addedRepositoriesData, setAddedRepositoriesData] = React.useState< - AddRepositoryData[] - >([]); + const [addedRepositoriesData, setAddedRepositoriesData] = React.useState<{ + [id: string]: AddRepositoryData; + }>({}); + const [totalImportJobs, setTotalImportJobs] = React.useState(0); const [loaded, setLoaded] = React.useState(false); const [debouncedSearch, setDebouncedSearch] = React.useState(searchString); - const [errorState, setErrorState] = React.useState< - { [key: string]: string | undefined } | undefined - >(); const identityApi = useApi(identityApiRef); const configApi = useApi(configApiRef); - const mounted = React.useRef(false); const { value: user } = useAsync(async () => { const identityRef = await identityApi.getBackstageIdentity(); return identityRef.userEntityRef; @@ -65,77 +65,38 @@ export const useAddedRepositories = ( const bulkImportApi = useApi(bulkImportApiRef); const { setFieldValue } = useFormikContext(); - const { value, loading, error, retry } = useAsyncRetry( - async () => - await bulkImportApi.getImportJobs(page, rowsPerPage, searchString), - [page, rowsPerPage, debouncedSearch], - ); + const fetchAddedRepositories = async ( + page: number, + size: number, + searchStr: string, + ) => { + const response = await bulkImportApi.getImportJobs(page, size, searchStr); + return response; + }; - React.useEffect(() => { - mounted.current = true; - return () => { - mounted.current = false; - }; - }, []); + const { + data: value, + error, + isLoading: loading, + refetch, + } = useQuery( + ['importJobs', pageNumber, rowsPerPage, debouncedSearch], + () => fetchAddedRepositories(pageNumber, rowsPerPage, debouncedSearch), + { keepPreviousData: true, refetchInterval: pollInterval || 60000 }, + ); - const prepareDataForAddedRepositories = React.useCallback( - ( - addedRepositories: ImportJobStatus[] | Response, - isLoading: boolean, - errorData: { [key: string]: string | undefined } | undefined, - ) => { - if (!isLoading && !errorData && mounted.current) { - if (!Array.isArray(addedRepositories)) { - setAddedRepositoriesData([]); - } else { - const repoData: { [id: string]: AddRepositoryData } = - addedRepositories?.reduce((acc, val: ImportJobStatus) => { - const id = `${val.repository.organization}/${val.repository.name}`; - return { - ...acc, - [id]: { - id, - repoName: val.repository.name, - defaultBranch: val.repository.defaultBranch, - orgName: val.repository.organization, - repoUrl: val.repository.url, - organizationUrl: val?.repository?.url?.substring( - 0, - val.repository.url.indexOf(val?.repository?.name || '') - 1, - ), - catalogInfoYaml: { - status: val.status - ? RepositoryStatus[val.status as RepositoryStatus] - : RepositoryStatus.NotGenerated, - prTemplate: getPRTemplate( - val.repository.name || '', - val.repository.organization || '', - user as string, - baseUrl as string, - val.repository.url || '', - val.repository.defaultBranch || 'main', - ), - pullRequest: val?.github?.pullRequest?.url || '', - lastUpdated: val.lastUpdate, - }, - }, - }; - }, {}); - setFieldValue(`repositories`, repoData); - setAddedRepositoriesData(Object.values(repoData)); - } + const prepareData = React.useCallback( + (addedRepositories: ImportJobs | Response, isLoading: boolean) => { + if (!isLoading) { + const repoData = prepareDataForAddedRepositories( + addedRepositories, + user as string, + baseUrl as string, + ); + setAddedRepositoriesData(repoData); + setFieldValue(`repositories`, repoData); + setTotalImportJobs((addedRepositories as ImportJobs)?.totalCount || 0); setLoaded(true); - } else if (errorData && mounted.current) { - setLoaded(true); - setErrorState({ - ...(errorData ?? {}), - ...((addedRepositories as Response)?.statusText - ? { - name: 'Error', - message: (addedRepositories as Response)?.statusText, - } - : {}), - }); } }, [ @@ -143,31 +104,31 @@ export const useAddedRepositories = ( baseUrl, setFieldValue, setAddedRepositoriesData, - setErrorState, - setLoaded, + setTotalImportJobs, ], ); - const debouncedUpdateResources = useDebounceCallback( - prepareDataForAddedRepositories, - 250, - ); + const debouncedUpdateResources = useDebounceCallback(prepareData, 250); React.useEffect(() => { - debouncedUpdateResources?.(value, loading, error); - }, [debouncedUpdateResources, value, loading, error]); - - useInterval( - () => { - retry(); - }, - loading ? null : pollInterval || 60000, - ); + debouncedUpdateResources?.(value, loading); + }, [debouncedUpdateResources, value, loading]); return useDeepCompareMemoize({ - data: addedRepositoriesData, + data: { + addedRepositories: Object.values(addedRepositoriesData), + totalJobs: totalImportJobs, + }, loaded, - error: errorState, - retry, + error: { + ...(error ?? {}), + ...((value as Response)?.statusText + ? { + name: 'Error', + message: (value as Response)?.statusText, + } + : {}), + }, + refetch, }); }; diff --git a/plugins/bulk-import/src/mocks/mockData.ts b/plugins/bulk-import/src/mocks/mockData.ts index df7a1481ba..9e4c035023 100644 --- a/plugins/bulk-import/src/mocks/mockData.ts +++ b/plugins/bulk-import/src/mocks/mockData.ts @@ -1,4 +1,4 @@ -import { AddedRepositories, ApprovalTool, ImportJobStatus } from '../types'; +import { AddedRepositories, ApprovalTool, ImportJobs } from '../types'; export const mockGetOrganizations = { errors: [], @@ -140,100 +140,105 @@ export const mockGetRepositories = { sizePerIntegration: 5, }; -export const mockGetImportJobs: ImportJobStatus[] = [ - { - approvalTool: ApprovalTool.Git, - github: { - pullRequest: { - number: 90, - url: 'https://github.com/org/dessert/cupcake/pull/90', - body: 'PR body', - catalogInfoContent: - 'apiVersion: backstage.io/v1alpha1\nkind: Component\nmetadata:\n name: che1\n annotations:\n github.com/project-slug: debsmita1/che\nspec:\n type: other\n lifecycle: unknown\n owner: user:default/debsmita1\n', - title: 'PR title', +export const mockGetImportJobs: ImportJobs = { + imports: [ + { + approvalTool: ApprovalTool.Git, + github: { + pullRequest: { + number: 90, + url: 'https://github.com/org/dessert/cupcake/pull/90', + body: 'PR body', + catalogInfoContent: + 'apiVersion: backstage.io/v1alpha1\nkind: Component\nmetadata:\n name: che1\n annotations:\n github.com/project-slug: debsmita1/che\nspec:\n type: other\n lifecycle: unknown\n owner: user:default/debsmita1\n', + title: 'PR title', + }, }, - }, - lastUpdate: '2024-07-17T13:46:37Z', - repository: { - id: 'org/dessert/cupcake', - name: 'cupcake', - url: 'https://github.com/org/dessert/cupcake', - defaultBranch: 'master', - organization: 'org/dessert', - }, - id: 'org/dessert/cupcake', - status: 'WAIT_PR_APPROVAL', - }, - { - approvalTool: ApprovalTool.Git, - github: { - pullRequest: { - body: 'PR body', - catalogInfoContent: - '\nkind: Component\nmetadata:\n name: che1\n annotations:\n github.com/project-slug: debsmita1/che\nspec:\n type: other\n lifecycle: unknown\n owner: user:default/debsmita1\n', - title: 'PR title', - number: 91, - url: 'https://github.com/org/dessert/donut/pull/91', + lastUpdate: '2024-07-17T13:46:37Z', + repository: { + id: 'org/dessert/cupcake', + name: 'cupcake', + url: 'https://github.com/org/dessert/cupcake', + defaultBranch: 'master', + organization: 'org/dessert', }, + id: 'org/dessert/cupcake', + status: 'WAIT_PR_APPROVAL', }, - lastUpdate: '2024-07-18T13:46:37Z', - repository: { - id: 'org/dessert/donut', - name: 'donut', - url: 'https://github.com/org/dessert/donut', - defaultBranch: 'master', - organization: 'org/dessert', - }, - id: 'org/dessert/donut', - status: 'WAIT_PR_APPROVAL', - }, - { - approvalTool: ApprovalTool.Git, - github: { - pullRequest: { - number: 94, - url: 'https://github.com/org/food/food-app/pull/94', - body: 'PR body', - catalogInfoContent: - 'apiVersion: backstage.io/v1alpha1\nkind: Component\nmetadata:\n name: che1\n annotations:\n github.com/project-slug: debsmita1/che\nspec:\n type: other\n lifecycle: unknown\n owner: user:default/debsmita1\n', - title: 'PR title', + { + approvalTool: ApprovalTool.Git, + github: { + pullRequest: { + body: 'PR body', + catalogInfoContent: + '\nkind: Component\nmetadata:\n name: che1\n annotations:\n github.com/project-slug: debsmita1/che\nspec:\n type: other\n lifecycle: unknown\n owner: user:default/debsmita1\n', + title: 'PR title', + number: 91, + url: 'https://github.com/org/dessert/donut/pull/91', + }, }, + lastUpdate: '2024-07-18T13:46:37Z', + repository: { + id: 'org/dessert/donut', + name: 'donut', + url: 'https://github.com/org/dessert/donut', + defaultBranch: 'master', + organization: 'org/dessert', + }, + id: 'org/dessert/donut', + status: 'WAIT_PR_APPROVAL', }, - lastUpdate: '2024-07-21T13:46:37Z', - repository: { + { + approvalTool: ApprovalTool.Git, + github: { + pullRequest: { + number: 94, + url: 'https://github.com/org/food/food-app/pull/94', + body: 'PR body', + catalogInfoContent: + 'apiVersion: backstage.io/v1alpha1\nkind: Component\nmetadata:\n name: che1\n annotations:\n github.com/project-slug: debsmita1/che\nspec:\n type: other\n lifecycle: unknown\n owner: user:default/debsmita1\n', + title: 'PR title', + }, + }, + lastUpdate: '2024-07-21T13:46:37Z', + repository: { + id: 'org/food/food-app', + name: 'food-app', + url: 'https://github.com/org/food/food-app', + defaultBranch: 'master', + organization: 'org/food', + }, id: 'org/food/food-app', - name: 'food-app', - url: 'https://github.com/org/food/food-app', - defaultBranch: 'master', - organization: 'org/food', + status: 'WAIT_PR_APPROVAL', }, - id: 'org/food/food-app', - status: 'WAIT_PR_APPROVAL', - }, - { - approvalTool: ApprovalTool.Git, - github: { - pullRequest: { - number: 95, - url: 'https://github.com/org/pet-store-boston/pet-app/pull/95', - body: 'PR body', - catalogInfoContent: - 'apiVersion: backstage.io/v1alpha1\nkind: Component\nmetadata:\n name: che1\n annotations:\n github.com/project-slug: debsmita1/che\nspec:\n type: other\n lifecycle: unknown\n owner: user:default/debsmita1\n', - title: 'PR title', + { + approvalTool: ApprovalTool.Git, + github: { + pullRequest: { + number: 95, + url: 'https://github.com/org/pet-store-boston/pet-app/pull/95', + body: 'PR body', + catalogInfoContent: + 'apiVersion: backstage.io/v1alpha1\nkind: Component\nmetadata:\n name: che1\n annotations:\n github.com/project-slug: debsmita1/che\nspec:\n type: other\n lifecycle: unknown\n owner: user:default/debsmita1\n', + title: 'PR title', + }, + }, + lastUpdate: '2024-07-22T13:46:37Z', + repository: { + id: 'org/pet-store-boston/pet-app', + name: 'pet-app', + url: 'https://github.com/org/pet-store-boston/pet-app', + defaultBranch: 'master', + organization: 'org/pet-store-boston', }, - }, - lastUpdate: '2024-07-22T13:46:37Z', - repository: { id: 'org/pet-store-boston/pet-app', - name: 'pet-app', - url: 'https://github.com/org/pet-store-boston/pet-app', - defaultBranch: 'master', - organization: 'org/pet-store-boston', + status: 'ADDED', }, - id: 'org/pet-store-boston/pet-app', - status: 'ADDED', - }, -]; + ], + page: 1, + size: 5, + totalCount: 4, +}; export const mockSelectedRepositories: AddedRepositories = { ['org/dessert/cupcake']: mockGetRepositories.repositories[0], diff --git a/plugins/bulk-import/src/types/response-types.ts b/plugins/bulk-import/src/types/response-types.ts index 3fe2ba08f9..0568a978f5 100644 --- a/plugins/bulk-import/src/types/response-types.ts +++ b/plugins/bulk-import/src/types/response-types.ts @@ -35,6 +35,13 @@ export type ImportJobStatus = { repository: Repository; }; +export type ImportJobs = { + imports: ImportJobStatus[]; + page: number; + size: number; + totalCount: number; +}; + export type OrgAndRepoResponse = { errors?: string[]; repositories?: Repository[]; diff --git a/plugins/bulk-import/src/utils/repository-utils.tsx b/plugins/bulk-import/src/utils/repository-utils.tsx index 02582a01ba..b2b34146cf 100644 --- a/plugins/bulk-import/src/utils/repository-utils.tsx +++ b/plugins/bulk-import/src/utils/repository-utils.tsx @@ -16,6 +16,7 @@ import { CreateImportJobRepository, ErrorType, ImportJobResponse, + ImportJobs, ImportJobStatus, ImportStatus, JobErrors, @@ -552,3 +553,48 @@ export const evaluatePRTemplate = ( }; } }; + +export const prepareDataForAddedRepositories = ( + addedRepositories: ImportJobs | Response | undefined, + user: string, + baseUrl: string, +) => { + if (!Array.isArray((addedRepositories as ImportJobs)?.imports)) { + return {}; + } + const importJobs = addedRepositories as ImportJobs; + const repoData: { [id: string]: AddRepositoryData } = + importJobs.imports?.reduce((acc, val: ImportJobStatus) => { + const id = `${val.repository.organization}/${val.repository.name}`; + return { + ...acc, + [id]: { + id, + repoName: val.repository.name, + defaultBranch: val.repository.defaultBranch, + orgName: val.repository.organization, + repoUrl: val.repository.url, + organizationUrl: val?.repository?.url?.substring( + 0, + val.repository.url.indexOf(val?.repository?.name || '') - 1, + ), + catalogInfoYaml: { + status: val.status + ? RepositoryStatus[val.status as RepositoryStatus] + : RepositoryStatus.NotGenerated, + prTemplate: getPRTemplate( + val.repository.name || '', + val.repository.organization || '', + user, + baseUrl, + val.repository.url || '', + val.repository.defaultBranch || 'main', + ), + pullRequest: val?.github?.pullRequest?.url || '', + lastUpdated: val.lastUpdate, + }, + }, + }; + }, {}); + return repoData; +}; diff --git a/yarn.lock b/yarn.lock index 31771feb59..47bf7f18d2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13130,7 +13130,7 @@ resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-4.36.1.tgz#79f8c1a539d47c83104210be2388813a7af2e524" integrity sha512-DJSilV5+ytBP1FbFcEJovv4rnnm/CokuVvrBEtW/Va9DvuJ3HksbXUJEpI0aV1KtuL4ZoO9AVE6PyNLzF7tLeA== -"@tanstack/react-query@^4.36.1": +"@tanstack/react-query@^4.29.21", "@tanstack/react-query@^4.36.1": version "4.36.1" resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-4.36.1.tgz#acb589fab4085060e2e78013164868c9c785e5d2" integrity sha512-y7ySVHFyyQblPl3J3eQBWpXZkliroki3ARnBKsdJchlgt7yJLRDUcf4B8soufgiYt3pEQIkBWBx1N9/ZPIeUWw==