@@ -61,6 +61,15 @@ export function getAzureRepositories( | |||
); | |||
} | |||
export function searchAzureRepositories( | |||
almSetting: string, | |||
repositoryName: string | |||
): Promise<{ repositories: AzureRepository[] }> { | |||
return getJSON('/api/alm_integrations/search_azure_repos', { almSetting, repositoryName }).catch( | |||
throwGlobalError | |||
); | |||
} | |||
export function getBitbucketServerProjects( | |||
almSetting: string | |||
): Promise<{ projects: BitbucketProject[] }> { |
@@ -17,12 +17,14 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { groupBy } from 'lodash'; | |||
import * as React from 'react'; | |||
import { WithRouterProps } from 'react-router'; | |||
import { | |||
checkPersonalAccessTokenIsValid, | |||
getAzureProjects, | |||
getAzureRepositories, | |||
searchAzureRepositories, | |||
setAlmPersonalAccessToken | |||
} from '../../../api/alm-integrations'; | |||
import { AzureProject, AzureRepository } from '../../../types/alm-integration'; | |||
@@ -42,6 +44,8 @@ interface State { | |||
patIsValid?: boolean; | |||
projects?: AzureProject[]; | |||
repositories: T.Dict<AzureRepository[]>; | |||
searching?: boolean; | |||
searchResults?: T.Dict<AzureRepository[]>; | |||
settings?: AlmSettingsInstance; | |||
submittingToken?: boolean; | |||
tokenValidationFailed: boolean; | |||
@@ -152,6 +156,10 @@ export default class AzureProjectCreate extends React.PureComponent<Props, State | |||
}; | |||
handleOpenProject = async (projectKey: string) => { | |||
if (this.state.searchResults) { | |||
return; | |||
} | |||
this.setState(({ loadingRepositories }) => ({ | |||
loadingRepositories: { ...loadingRepositories, [projectKey]: true } | |||
})); | |||
@@ -164,6 +172,29 @@ export default class AzureProjectCreate extends React.PureComponent<Props, State | |||
})); | |||
}; | |||
handleSearchRepositories = async (searchQuery: string) => { | |||
const { settings } = this.state; | |||
if (!settings) { | |||
return; | |||
} | |||
if (searchQuery.length === 0) { | |||
this.setState({ searchResults: undefined }); | |||
return; | |||
} | |||
this.setState({ searching: true }); | |||
const results: AzureRepository[] = await searchAzureRepositories(settings.key, searchQuery) | |||
.then(({ repositories }) => repositories) | |||
.catch(() => []); | |||
if (this.mounted) { | |||
this.setState({ searching: false, searchResults: groupBy(results, 'projectName') }); | |||
} | |||
}; | |||
checkPersonalAccessToken = () => { | |||
const { settings } = this.state; | |||
@@ -210,6 +241,8 @@ export default class AzureProjectCreate extends React.PureComponent<Props, State | |||
patIsValid, | |||
projects, | |||
repositories, | |||
searching, | |||
searchResults, | |||
settings, | |||
submittingToken, | |||
tokenValidationFailed | |||
@@ -222,8 +255,11 @@ export default class AzureProjectCreate extends React.PureComponent<Props, State | |||
loadingRepositories={loadingRepositories} | |||
onOpenProject={this.handleOpenProject} | |||
onPersonalAccessTokenCreate={this.handlePersonalAccessTokenCreate} | |||
onSearch={this.handleSearchRepositories} | |||
projects={projects} | |||
repositories={repositories} | |||
searching={searching} | |||
searchResults={searchResults} | |||
settings={settings} | |||
showPersonalAccessTokenForm={!patIsValid || Boolean(location.query.resetPat)} | |||
submittingToken={submittingToken} |
@@ -18,6 +18,8 @@ | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import * as React from 'react'; | |||
import SearchBox from 'sonar-ui-common/components/controls/SearchBox'; | |||
import DeferredSpinner from 'sonar-ui-common/components/ui/DeferredSpinner'; | |||
import { translate } from 'sonar-ui-common/helpers/l10n'; | |||
import { getBaseUrl } from 'sonar-ui-common/helpers/urls'; | |||
import { AzureProject, AzureRepository } from '../../../types/alm-integration'; | |||
@@ -33,8 +35,11 @@ export interface AzureProjectCreateRendererProps { | |||
loadingRepositories: T.Dict<boolean>; | |||
onOpenProject: (key: string) => void; | |||
onPersonalAccessTokenCreate: (token: string) => void; | |||
onSearch: (query: string) => void; | |||
projects?: AzureProject[]; | |||
repositories: T.Dict<AzureRepository[]>; | |||
searching?: boolean; | |||
searchResults?: T.Dict<AzureRepository[]>; | |||
settings?: AlmSettingsInstance; | |||
showPersonalAccessTokenForm?: boolean; | |||
submittingToken?: boolean; | |||
@@ -48,6 +53,8 @@ export default function AzureProjectCreateRenderer(props: AzureProjectCreateRend | |||
loadingRepositories, | |||
projects, | |||
repositories, | |||
searching, | |||
searchResults, | |||
showPersonalAccessTokenForm, | |||
settings, | |||
submittingToken, | |||
@@ -88,12 +95,23 @@ export default function AzureProjectCreateRenderer(props: AzureProjectCreateRend | |||
/> | |||
</div> | |||
) : ( | |||
<AzureProjectsList | |||
loadingRepositories={loadingRepositories} | |||
onOpenProject={props.onOpenProject} | |||
projects={projects} | |||
repositories={repositories} | |||
/> | |||
<> | |||
<div className="huge-spacer-bottom"> | |||
<SearchBox | |||
onChange={props.onSearch} | |||
placeholder={translate('onboarding.create_project.search_repositories_by_name')} | |||
/> | |||
</div> | |||
<DeferredSpinner loading={Boolean(searching)}> | |||
<AzureProjectsList | |||
loadingRepositories={loadingRepositories} | |||
onOpenProject={props.onOpenProject} | |||
projects={projects} | |||
repositories={repositories} | |||
searchResults={searchResults} | |||
/> | |||
</DeferredSpinner> | |||
</> | |||
))} | |||
</> | |||
); |
@@ -32,56 +32,69 @@ export interface AzureProjectsListProps { | |||
onOpenProject: (key: string) => void; | |||
projects?: AzureProject[]; | |||
repositories: T.Dict<AzureRepository[]>; | |||
searchResults?: T.Dict<AzureRepository[]>; | |||
} | |||
const PAGE_SIZE = 10; | |||
export default function AzureProjectsList(props: AzureProjectsListProps) { | |||
const { loadingRepositories, projects = [], repositories } = props; | |||
const { loadingRepositories, projects = [], repositories, searchResults } = props; | |||
const [page, setPage] = React.useState(1); | |||
if (projects.length === 0) { | |||
const filteredProjects = searchResults | |||
? projects.filter(p => searchResults[p.key] !== undefined) | |||
: projects; | |||
if (filteredProjects.length === 0) { | |||
return ( | |||
<Alert className="spacer-top" variant="warning"> | |||
<FormattedMessage | |||
defaultMessage={translate('onboarding.create_project.azure.no_projects')} | |||
id="onboarding.create_project.azure.no_projects" | |||
values={{ | |||
link: ( | |||
<Link | |||
to={{ | |||
pathname: '/projects/create', | |||
query: { mode: CreateProjectModes.AzureDevOps, resetPat: 1 } | |||
}}> | |||
{translate('onboarding.create_project.update_your_token')} | |||
</Link> | |||
) | |||
}} | |||
/> | |||
{searchResults ? ( | |||
translate('onboarding.create_project.azure.no_results') | |||
) : ( | |||
<FormattedMessage | |||
defaultMessage={translate('onboarding.create_project.azure.no_projects')} | |||
id="onboarding.create_project.azure.no_projects" | |||
values={{ | |||
link: ( | |||
<Link | |||
to={{ | |||
pathname: '/projects/create', | |||
query: { mode: CreateProjectModes.AzureDevOps, resetPat: 1 } | |||
}}> | |||
{translate('onboarding.create_project.update_your_token')} | |||
</Link> | |||
) | |||
}} | |||
/> | |||
)} | |||
</Alert> | |||
); | |||
} | |||
const filteredProjects = projects.slice(0, page * PAGE_SIZE); | |||
const displayedProjects = filteredProjects.slice(0, page * PAGE_SIZE); | |||
// Add a suffix to the key to force react to not reuse AzureProjectAccordions between | |||
// search results and project exploration | |||
const keySuffix = searchResults ? ' - result' : ''; | |||
return ( | |||
<div> | |||
{filteredProjects.map((p, i) => ( | |||
{displayedProjects.map((p, i) => ( | |||
<AzureProjectAccordion | |||
key={p.key} | |||
key={`${p.key}${keySuffix}`} | |||
loading={Boolean(loadingRepositories[p.key])} | |||
onOpen={props.onOpenProject} | |||
project={p} | |||
repositories={repositories[p.key]} | |||
startsOpen={i === 0} | |||
repositories={searchResults ? searchResults[p.key] : repositories[p.key]} | |||
startsOpen={searchResults !== undefined || i === 0} | |||
/> | |||
))} | |||
<ListFooter | |||
count={filteredProjects.length} | |||
count={displayedProjects.length} | |||
loadMore={() => setPage(p => p + 1)} | |||
total={projects.length} | |||
total={filteredProjects.length} | |||
/> | |||
</div> | |||
); |
@@ -25,6 +25,7 @@ import { | |||
checkPersonalAccessTokenIsValid, | |||
getAzureProjects, | |||
getAzureRepositories, | |||
searchAzureRepositories, | |||
setAlmPersonalAccessToken | |||
} from '../../../../api/alm-integrations'; | |||
import { mockAzureProject, mockAzureRepository } from '../../../../helpers/mocks/alm-integrations'; | |||
@@ -38,7 +39,8 @@ jest.mock('../../../../api/alm-integrations', () => { | |||
checkPersonalAccessTokenIsValid: jest.fn().mockResolvedValue(true), | |||
setAlmPersonalAccessToken: jest.fn().mockResolvedValue(null), | |||
getAzureProjects: jest.fn().mockResolvedValue({ projects: [] }), | |||
getAzureRepositories: jest.fn().mockResolvedValue({ repositories: [] }) | |||
getAzureRepositories: jest.fn().mockResolvedValue({ repositories: [] }), | |||
searchAzureRepositories: jest.fn().mockResolvedValue({ repositories: [] }) | |||
}; | |||
}); | |||
@@ -137,6 +139,36 @@ it('should handle opening a project', async () => { | |||
}); | |||
}); | |||
it('should handle searching for repositories', async () => { | |||
const wrapper = shallowRender(); | |||
await waitAndUpdate(wrapper); | |||
const query = 'repo'; | |||
const repositories = [mockAzureRepository({ projectName: 'p2' })]; | |||
(searchAzureRepositories as jest.Mock).mockResolvedValueOnce({ | |||
repositories | |||
}); | |||
wrapper.instance().handleSearchRepositories(query); | |||
expect(wrapper.state().searching).toBe(true); | |||
expect(searchAzureRepositories).toBeCalledWith('foo', query); | |||
await waitAndUpdate(wrapper); | |||
expect(wrapper.state().searching).toBe(false); | |||
expect(wrapper.state().searchResults).toEqual({ [repositories[0].projectName]: repositories }); | |||
// Ignore opening a project when search results are displayed | |||
(getAzureRepositories as jest.Mock).mockClear(); | |||
wrapper.instance().handleOpenProject('whatever'); | |||
expect(getAzureRepositories).not.toHaveBeenCalled(); | |||
// and reset the search field | |||
(searchAzureRepositories as jest.Mock).mockClear(); | |||
wrapper.instance().handleSearchRepositories(''); | |||
expect(searchAzureRepositories).not.toBeCalled(); | |||
expect(wrapper.state().searchResults).toBeUndefined(); | |||
}); | |||
function shallowRender(overrides: Partial<AzureProjectCreate['props']> = {}) { | |||
return shallow<AzureProjectCreate>( | |||
<AzureProjectCreate |
@@ -44,6 +44,7 @@ function shallowRender(overrides: Partial<AzureProjectCreateRendererProps>) { | |||
loadingRepositories={{}} | |||
onOpenProject={jest.fn()} | |||
onPersonalAccessTokenCreate={jest.fn()} | |||
onSearch={jest.fn()} | |||
projects={[project]} | |||
repositories={{ [project.key]: [mockAzureRepository()] }} | |||
tokenValidationFailed={false} |
@@ -21,7 +21,7 @@ | |||
import { shallow } from 'enzyme'; | |||
import * as React from 'react'; | |||
import ListFooter from 'sonar-ui-common/components/controls/ListFooter'; | |||
import { mockAzureProject } from '../../../../helpers/mocks/alm-integrations'; | |||
import { mockAzureProject, mockAzureRepository } from '../../../../helpers/mocks/alm-integrations'; | |||
import AzureProjectAccordion from '../AzureProjectAccordion'; | |||
import AzureProjectsList, { AzureProjectsListProps } from '../AzureProjectsList'; | |||
@@ -30,6 +30,19 @@ it('should render correctly', () => { | |||
expect(shallowRender({ projects: [] })).toMatchSnapshot('empty'); | |||
}); | |||
it('should render search results correctly', () => { | |||
const projects = [ | |||
mockAzureProject({ key: 'p1', name: 'p1' }), | |||
mockAzureProject({ key: 'p2', name: 'p2' }), | |||
mockAzureProject({ key: 'p3', name: 'p3' }) | |||
]; | |||
const searchResults = { | |||
p2: [mockAzureRepository({ projectName: 'p2' })] | |||
}; | |||
expect(shallowRender({ searchResults, projects })).toMatchSnapshot('default'); | |||
expect(shallowRender({ searchResults: {}, projects })).toMatchSnapshot('empty'); | |||
}); | |||
it('should handle pagination', () => { | |||
const projects = new Array(21) | |||
.fill(1) |
@@ -7,6 +7,7 @@ exports[`should render correctly 1`] = ` | |||
loadingRepositories={Object {}} | |||
onOpenProject={[Function]} | |||
onPersonalAccessTokenCreate={[Function]} | |||
onSearch={[Function]} | |||
repositories={Object {}} | |||
settings={ | |||
Object { |
@@ -64,28 +64,40 @@ exports[`should render correctly: project list 1`] = ` | |||
</span> | |||
} | |||
/> | |||
<AzureProjectsList | |||
loadingRepositories={Object {}} | |||
onOpenProject={[MockFunction]} | |||
projects={ | |||
Array [ | |||
Object { | |||
"key": "azure-project-1", | |||
"name": "Azure Project", | |||
}, | |||
] | |||
} | |||
repositories={ | |||
Object { | |||
"azure-project-1": Array [ | |||
<div | |||
className="huge-spacer-bottom" | |||
> | |||
<SearchBox | |||
onChange={[MockFunction]} | |||
placeholder="onboarding.create_project.search_repositories_by_name" | |||
/> | |||
</div> | |||
<DeferredSpinner | |||
loading={false} | |||
> | |||
<AzureProjectsList | |||
loadingRepositories={Object {}} | |||
onOpenProject={[MockFunction]} | |||
projects={ | |||
Array [ | |||
Object { | |||
"name": "Azure repo 1", | |||
"projectName": "Azure Project", | |||
"key": "azure-project-1", | |||
"name": "Azure Project", | |||
}, | |||
], | |||
] | |||
} | |||
} | |||
/> | |||
repositories={ | |||
Object { | |||
"azure-project-1": Array [ | |||
Object { | |||
"name": "Azure repo 1", | |||
"projectName": "Azure Project", | |||
}, | |||
], | |||
} | |||
} | |||
/> | |||
</DeferredSpinner> | |||
</Fragment> | |||
`; | |||
@@ -53,3 +53,42 @@ exports[`should render correctly: empty 1`] = ` | |||
/> | |||
</Alert> | |||
`; | |||
exports[`should render search results correctly: default 1`] = ` | |||
<div> | |||
<AzureProjectAccordion | |||
key="p2 - result" | |||
loading={false} | |||
onOpen={[MockFunction]} | |||
project={ | |||
Object { | |||
"key": "p2", | |||
"name": "p2", | |||
} | |||
} | |||
repositories={ | |||
Array [ | |||
Object { | |||
"name": "Azure repo 1", | |||
"projectName": "p2", | |||
}, | |||
] | |||
} | |||
startsOpen={true} | |||
/> | |||
<ListFooter | |||
count={1} | |||
loadMore={[Function]} | |||
total={1} | |||
/> | |||
</div> | |||
`; | |||
exports[`should render search results correctly: empty 1`] = ` | |||
<Alert | |||
className="spacer-top" | |||
variant="warning" | |||
> | |||
onboarding.create_project.azure.no_results | |||
</Alert> | |||
`; |
@@ -3289,6 +3289,7 @@ onboarding.create_project.go_to_project=Go to project | |||
onboarding.create_project.azure.title=Which Azure DevOps Server repository do you want to set up? | |||
onboarding.create_project.azure.no_projects=No projects could be fetched from Azure DevOps Server. Contact your system administrator, or {link}. | |||
onboarding.create_project.azure.no_repositories=Could not fetch repositories for this project. Contact your system administrator, or {link}. | |||
onboarding.create_project.azure.no_results=No repositories match your search query. | |||
onboarding.create_project.github.title=Which GitHub repository do you want to set up? | |||
onboarding.create_project.github.choose_organization=Choose organization | |||
onboarding.create_project.github.warning.title=Could not connect to GitHub |