mirror of https://github.com/harness/drone.git
424 lines
16 KiB
TypeScript
424 lines
16 KiB
TypeScript
/*
|
|
* Copyright 2023 Harness, Inc.
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import {
|
|
ButtonVariation,
|
|
Container,
|
|
FlexExpander,
|
|
Layout,
|
|
PageBody,
|
|
PageHeader,
|
|
Utils,
|
|
TableV2 as Table,
|
|
Text,
|
|
useToaster
|
|
} from '@harnessio/uicore'
|
|
import { ProgressBar, Intent } from '@blueprintjs/core'
|
|
import { Color, FontVariation } from '@harnessio/design-system'
|
|
import type { CellProps, Column } from 'react-table'
|
|
import Keywords from 'react-keywords'
|
|
import cx from 'classnames'
|
|
import { useGet } from 'restful-react'
|
|
import { useHistory } from 'react-router-dom'
|
|
import { useStrings, String } from 'framework/strings'
|
|
import { voidFn, formatDate, getErrorMessage, LIST_FETCHING_LIMIT, PageBrowserProps } from 'utils/Utils'
|
|
import { NewRepoModalButton } from 'components/NewRepoModalButton/NewRepoModalButton'
|
|
import type { RepoRepositoryOutput } from 'services/code'
|
|
import { useDeleteRepository } from 'services/code'
|
|
import { usePageIndex } from 'hooks/usePageIndex'
|
|
import { useQueryParams } from 'hooks/useQueryParams'
|
|
import { useUpdateQueryParams } from 'hooks/useUpdateQueryParams'
|
|
import useSpaceSSE from 'hooks/useSpaceSSE'
|
|
import { useGetSpaceParam } from 'hooks/useGetSpaceParam'
|
|
import { SearchInputWithSpinner } from 'components/SearchInputWithSpinner/SearchInputWithSpinner'
|
|
import { useAppContext } from 'AppContext'
|
|
import { LoadingSpinner } from 'components/LoadingSpinner/LoadingSpinner'
|
|
import { NoResultCard } from 'components/NoResultCard/NoResultCard'
|
|
import { ResourceListingPagination } from 'components/ResourceListingPagination/ResourceListingPagination'
|
|
import { RepoTypeLabel } from 'components/RepoTypeLabel/RepoTypeLabel'
|
|
import KeywordSearch from 'components/CodeSearch/KeywordSearch'
|
|
import { OptionsMenuButton } from 'components/OptionsMenuButton/OptionsMenuButton'
|
|
import { useConfirmAct } from 'hooks/useConfirmAction'
|
|
import { getUsingFetch, getConfig } from 'services/config'
|
|
import noRepoImage from './no-repo.svg?url'
|
|
import css from './RepositoriesListing.module.scss'
|
|
|
|
interface TypesRepoExtended extends RepoRepositoryOutput {
|
|
importing?: boolean
|
|
importProgress?: string
|
|
importProgressErrorMessage?: string
|
|
}
|
|
|
|
enum ImportStatus {
|
|
FAILED = 'failed',
|
|
FETCH_FAILED = 'fetch failed'
|
|
}
|
|
|
|
interface ProgressState {
|
|
state: string
|
|
}
|
|
|
|
export default function RepositoriesListing() {
|
|
const { getString } = useStrings()
|
|
const history = useHistory()
|
|
const rowContainerRef = useRef<HTMLDivElement>(null)
|
|
const [nameTextWidth, setNameTextWidth] = useState(600)
|
|
const space = useGetSpaceParam()
|
|
const [searchTerm, setSearchTerm] = useState<string | undefined>()
|
|
const { routes, standalone, hooks, routingId } = useAppContext()
|
|
const { updateQueryParams, replaceQueryParams } = useUpdateQueryParams()
|
|
const pageBrowser = useQueryParams<PageBrowserProps>()
|
|
const pageInit = pageBrowser.page ? parseInt(pageBrowser.page) : 1
|
|
const [page, setPage] = usePageIndex(pageInit)
|
|
const { showError, showSuccess } = useToaster()
|
|
const [updatedRepositories, setUpdatedRepositories] = useState<RepoRepositoryOutput[]>()
|
|
|
|
const {
|
|
data: repositories,
|
|
error,
|
|
loading,
|
|
refetch,
|
|
response
|
|
} = useGet<RepoRepositoryOutput[]>({
|
|
path: `/api/v1/spaces/${space}/+/repos`,
|
|
queryParams: { page: pageBrowser.page, limit: LIST_FETCHING_LIMIT, query: searchTerm },
|
|
debounce: 500
|
|
})
|
|
|
|
const onEvent = useCallback(
|
|
data => {
|
|
// should I include repo id here? what if a new repo is created? coould check for ids that are higher than the lowest id on the page?
|
|
if (repositories?.some(repository => repository.id === data?.id && repository.parent_id === data?.parent_id)) {
|
|
//TODO - revisit full refresh - can I use the message to update the execution?
|
|
refetch()
|
|
}
|
|
},
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[repositories]
|
|
)
|
|
|
|
const events = useMemo(() => ['repository_import_completed'], [])
|
|
|
|
useSpaceSSE({
|
|
space,
|
|
events,
|
|
onEvent
|
|
})
|
|
|
|
useEffect(() => {
|
|
if (page > 1) {
|
|
updateQueryParams({ page: page.toString() })
|
|
} else {
|
|
const params = { ...pageBrowser }
|
|
delete params.page
|
|
replaceQueryParams(params, undefined, true)
|
|
}
|
|
}, [space, page]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
const bearerToken = hooks?.useGetToken?.() || ''
|
|
|
|
const addImportProgressToData = async (repos: RepoRepositoryOutput[]) => {
|
|
const updatedData = await Promise.all(
|
|
repos.map(async repo => {
|
|
if (repo.importing) {
|
|
try {
|
|
const importProgress = await getUsingFetch(
|
|
getConfig('code/api/v1'),
|
|
`/repos/${repo.path}/+/import-progress`,
|
|
bearerToken,
|
|
{
|
|
queryParams: {
|
|
accountIdentifier: routingId
|
|
}
|
|
}
|
|
)
|
|
return { ...repo, importProgress: (importProgress as ProgressState).state }
|
|
} catch (err) {
|
|
return {
|
|
...repo,
|
|
importProgress: ImportStatus.FETCH_FAILED,
|
|
importProgressErrorMessage: getErrorMessage(err) as string
|
|
}
|
|
}
|
|
}
|
|
return repo
|
|
})
|
|
)
|
|
|
|
return updatedData
|
|
}
|
|
|
|
useEffect(() => {
|
|
const fetchRepo = async () => {
|
|
if (repositories) {
|
|
const updatedRepos = await addImportProgressToData(repositories)
|
|
setUpdatedRepositories(updatedRepos)
|
|
}
|
|
}
|
|
|
|
fetchRepo()
|
|
}, [repositories]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
const columns: Column<TypesRepoExtended>[] = useMemo(
|
|
() => [
|
|
{
|
|
Header: getString('repos.name'),
|
|
width: 'calc(100% - 210px)',
|
|
|
|
Cell: ({ row }: CellProps<TypesRepoExtended>) => {
|
|
const record = row.original
|
|
const renderImportProgressText = () => {
|
|
switch (record?.importProgress) {
|
|
case ImportStatus.FAILED:
|
|
return getString('importFailed')
|
|
case ImportStatus.FETCH_FAILED:
|
|
return record?.importProgressErrorMessage
|
|
default:
|
|
if (record?.importing) {
|
|
return getString('importProgress')
|
|
}
|
|
return record?.description ?? null
|
|
}
|
|
}
|
|
return (
|
|
<Container className={css.nameContainer}>
|
|
<Layout.Horizontal spacing="small" style={{ flexGrow: 1 }}>
|
|
<Layout.Vertical flex className={css.name} ref={rowContainerRef}>
|
|
<Text className={css.repoName} width={nameTextWidth} lineClamp={2}>
|
|
<Keywords value={searchTerm}>{record.identifier}</Keywords>
|
|
<RepoTypeLabel
|
|
isPublic={row.original.is_public}
|
|
isArchived={row.original.archived}
|
|
margin={{ left: 'small' }}
|
|
/>
|
|
</Text>
|
|
|
|
<Text className={css.desc} width={nameTextWidth} lineClamp={1}>
|
|
{renderImportProgressText()}
|
|
</Text>
|
|
</Layout.Vertical>
|
|
</Layout.Horizontal>
|
|
</Container>
|
|
)
|
|
}
|
|
},
|
|
{
|
|
Header: getString('repos.updated'),
|
|
width: '180px',
|
|
Cell: ({ row }: CellProps<TypesRepoExtended>) => {
|
|
return [ImportStatus.FAILED, ImportStatus.FETCH_FAILED].includes(
|
|
row?.original?.importProgress as ImportStatus
|
|
) ? null : row.original.importing ? (
|
|
<Layout.Horizontal style={{ alignItems: 'center' }} padding={{ right: 'large' }}>
|
|
<ProgressBar intent={Intent.PRIMARY} className={css.progressBar} />
|
|
</Layout.Horizontal>
|
|
) : (
|
|
<Layout.Horizontal style={{ alignItems: 'center' }}>
|
|
<Text color={Color.BLACK} lineClamp={1} rightIconProps={{ size: 10 }} width={120}>
|
|
{formatDate(row.original.updated as number)}
|
|
</Text>
|
|
</Layout.Horizontal>
|
|
)
|
|
},
|
|
disableSortBy: true
|
|
},
|
|
{
|
|
id: 'action',
|
|
width: '30px',
|
|
Cell: ({ row }: CellProps<TypesRepoExtended>) => {
|
|
const { mutate: deleteRepo } = useDeleteRepository({})
|
|
const confirmCancelImport = useConfirmAct()
|
|
return (
|
|
<Container onClick={Utils.stopEvent}>
|
|
{row?.original?.importProgress === ImportStatus.FAILED ? (
|
|
<OptionsMenuButton
|
|
isDark
|
|
width="100px"
|
|
items={[
|
|
{
|
|
text: getString('deleteImport'),
|
|
onClick: () =>
|
|
confirmCancelImport({
|
|
title: getString('deleteImport'),
|
|
confirmText: getString('delete'),
|
|
intent: Intent.DANGER,
|
|
message: (
|
|
<Text
|
|
style={{ wordBreak: 'break-word' }}
|
|
font={{ variation: FontVariation.BODY2_SEMI, size: 'small' }}>
|
|
<String
|
|
useRichText
|
|
stringID="deleteFailedImport"
|
|
vars={{ name: row.original?.identifier }}
|
|
tagName="div"
|
|
/>
|
|
</Text>
|
|
),
|
|
action: async () => {
|
|
deleteRepo(`${row.original?.path as string}/+/`)
|
|
.then(() => {
|
|
showSuccess(getString('deletedImport'), 2000)
|
|
refetch()
|
|
})
|
|
.catch(err => {
|
|
showError(getErrorMessage(err), 0, getString('failedToDeleteImport'))
|
|
})
|
|
}
|
|
})
|
|
}
|
|
]}
|
|
/>
|
|
) : (
|
|
row.original.importing && (
|
|
<OptionsMenuButton
|
|
isDark
|
|
width="100px"
|
|
items={[
|
|
{
|
|
text: getString('cancelImport'),
|
|
onClick: () =>
|
|
confirmCancelImport({
|
|
title: getString('cancelImport'),
|
|
confirmText: getString('cancelImport'),
|
|
intent: Intent.DANGER,
|
|
message: (
|
|
<Text
|
|
style={{ wordBreak: 'break-word' }}
|
|
font={{ variation: FontVariation.BODY2_SEMI, size: 'small' }}>
|
|
<String
|
|
useRichText
|
|
stringID="cancelImportConfirm"
|
|
vars={{ name: row.original?.identifier }}
|
|
tagName="div"
|
|
/>
|
|
</Text>
|
|
),
|
|
action: async () => {
|
|
deleteRepo(`${row.original?.path as string}/+/`)
|
|
.then(() => {
|
|
showSuccess(getString('cancelledImport'), 2000)
|
|
refetch()
|
|
})
|
|
.catch(err => {
|
|
showError(getErrorMessage(err), 0, getString('failedToCancelImport'))
|
|
})
|
|
}
|
|
})
|
|
}
|
|
]}
|
|
/>
|
|
)
|
|
)}
|
|
</Container>
|
|
)
|
|
}
|
|
}
|
|
],
|
|
[nameTextWidth, getString, searchTerm] // eslint-disable-line react-hooks/exhaustive-deps
|
|
)
|
|
|
|
const onResize = useCallback(() => {
|
|
if (rowContainerRef.current) {
|
|
setNameTextWidth((rowContainerRef.current.closest('div[role="cell"]') as HTMLDivElement)?.offsetWidth - 100)
|
|
}
|
|
}, [setNameTextWidth])
|
|
const NewRepoButton = (
|
|
<NewRepoModalButton
|
|
space={space}
|
|
modalTitle={getString('createARepo')}
|
|
text={getString('newRepo')}
|
|
variation={ButtonVariation.PRIMARY}
|
|
onSubmit={repoInfo => {
|
|
if (repoInfo.importing) {
|
|
refetch()
|
|
} else if (repoInfo.importing_repos) {
|
|
history.push(routes.toCODERepositories({ space: space as string }))
|
|
refetch()
|
|
} else {
|
|
history.push(routes.toCODERepository({ repoPath: (repoInfo as RepoRepositoryOutput).path as string }))
|
|
}
|
|
}}
|
|
/>
|
|
)
|
|
|
|
useEffect(() => {
|
|
onResize()
|
|
window.addEventListener('resize', onResize)
|
|
return () => {
|
|
window.removeEventListener('resize', onResize)
|
|
}
|
|
}, [onResize])
|
|
|
|
return (
|
|
<Container className={css.main}>
|
|
<PageHeader title={getString('repositories')} toolbar={standalone ? null : <KeywordSearch />} />
|
|
<PageBody
|
|
className={cx({ [css.withError]: !!error })}
|
|
error={error ? getErrorMessage(error) : null}
|
|
retryOnError={voidFn(refetch)}
|
|
noData={{
|
|
when: () => repositories?.length === 0 && searchTerm === undefined,
|
|
image: noRepoImage,
|
|
message: getString('repos.noDataMessage'),
|
|
button: NewRepoButton
|
|
}}>
|
|
<LoadingSpinner visible={loading && searchTerm === undefined} className={css.spinner} />
|
|
<Layout.Horizontal>
|
|
<Container className={css.repoListingContainer} margin={{ top: 'medium' }}>
|
|
<Container padding="xlarge">
|
|
<Layout.Horizontal spacing="large" className={css.layout}>
|
|
{NewRepoButton}
|
|
<FlexExpander />
|
|
<SearchInputWithSpinner
|
|
loading={loading && searchTerm !== undefined}
|
|
query={searchTerm}
|
|
setQuery={value => {
|
|
setSearchTerm(value)
|
|
setPage(1)
|
|
}}
|
|
/>
|
|
</Layout.Horizontal>
|
|
|
|
{!!repositories?.length && (
|
|
<Table<TypesRepoExtended>
|
|
className={css.table}
|
|
columns={columns}
|
|
data={updatedRepositories || []}
|
|
onRowClick={repoInfo => {
|
|
return repoInfo.importing
|
|
? undefined
|
|
: history.push(routes.toCODERepository({ repoPath: repoInfo.path as string }))
|
|
}}
|
|
getRowClassName={row =>
|
|
cx(css.row, !row.original.description && css.noDesc, row.original.importing && css.rowDisable)
|
|
}
|
|
/>
|
|
)}
|
|
|
|
<NoResultCard
|
|
showWhen={() => !!repositories && repositories.length === 0 && !!searchTerm?.length}
|
|
forSearch={true}
|
|
/>
|
|
</Container>
|
|
<ResourceListingPagination response={response} page={page} setPage={setPage} />
|
|
</Container>
|
|
</Layout.Horizontal>
|
|
</PageBody>
|
|
</Container>
|
|
)
|
|
}
|