@@ -46,6 +46,7 @@ import { | |||
getDuplications, | |||
getSources, | |||
getTree, | |||
searchProjects, | |||
setApplicationTags, | |||
setProjectTags, | |||
} from '../components'; | |||
@@ -57,6 +58,7 @@ import { | |||
} from './data/components'; | |||
import { mockIssuesList } from './data/issues'; | |||
import { MeasureRecords, mockFullMeasureData } from './data/measures'; | |||
import { mockProjects } from './data/projects'; | |||
import { listAllComponent, listChildComponent, listLeavesComponent } from './data/utils'; | |||
jest.mock('../components'); | |||
@@ -64,11 +66,13 @@ jest.mock('../components'); | |||
export default class ComponentsServiceMock { | |||
failLoadingComponentStatus: HttpStatus | undefined = undefined; | |||
defaultComponents: ComponentTree[]; | |||
defaultProjects: ComponentRaw[]; | |||
components: ComponentTree[]; | |||
defaultSourceFiles: SourceFile[]; | |||
sourceFiles: SourceFile[]; | |||
defaultMeasures: MeasureRecords; | |||
measures: MeasureRecords; | |||
projects: ComponentRaw[]; | |||
constructor(components?: ComponentTree[], sourceFiles?: SourceFile[], measures?: MeasureRecords) { | |||
this.defaultComponents = components || [mockFullComponentTree()]; | |||
@@ -80,10 +84,12 @@ export default class ComponentsServiceMock { | |||
(acc, tree) => ({ ...acc, ...mockFullMeasureData(tree, issueList) }), | |||
{}, | |||
); | |||
this.defaultProjects = mockProjects(); | |||
this.components = cloneDeep(this.defaultComponents); | |||
this.sourceFiles = cloneDeep(this.defaultSourceFiles); | |||
this.measures = cloneDeep(this.defaultMeasures); | |||
this.projects = cloneDeep(this.defaultProjects); | |||
jest.mocked(getComponentTree).mockImplementation(this.handleGetComponentTree); | |||
jest.mocked(getChildren).mockImplementation(this.handleGetChildren); | |||
@@ -99,8 +105,33 @@ export default class ComponentsServiceMock { | |||
jest.mocked(getBreadcrumbs).mockImplementation(this.handleGetBreadcrumbs); | |||
jest.mocked(setProjectTags).mockImplementation(this.handleSetProjectTags); | |||
jest.mocked(setApplicationTags).mockImplementation(this.handleSetApplicationTags); | |||
jest.mocked(searchProjects).mockImplementation(this.handleSearchProjects); | |||
} | |||
handleSearchProjects: typeof searchProjects = (data) => { | |||
const pageIndex = data.p ?? 1; | |||
const pageSize = data.ps ?? 100; | |||
const components = this.projects | |||
.filter((c) => { | |||
if (data.filter && data.filter.startsWith('query')) { | |||
const query = data.filter.split('query=')[1]; | |||
return c.key.includes(query) || c.name.includes(query); | |||
} | |||
}) | |||
.map((c) => c); | |||
return this.reply({ | |||
components: components.slice((pageIndex - 1) * pageSize, pageIndex * pageSize), | |||
facets: [], | |||
paging: { | |||
pageSize, | |||
pageIndex, | |||
total: components.length, | |||
}, | |||
}); | |||
}; | |||
findComponentTree = (key: string, from?: ComponentTree) => { | |||
let tree: ComponentTree | undefined; | |||
const recurse = (node: ComponentTree): boolean => { |
@@ -325,7 +325,7 @@ export default class IssuesServiceMock { | |||
issueStatuses: ISSUE_STATUSES, | |||
types: ISSUE_TYPES, | |||
scopes: SOURCE_SCOPES.map(({ scope }) => scope), | |||
projects: ['org.project1', 'org.project2'], | |||
projects: ['org.project1', 'org.sonarsource.javascript:javascript'], | |||
impactSoftwareQualities: Object.values(SoftwareQuality), | |||
impactSeverities: Object.values(SoftwareImpactSeverity), | |||
cleanCodeAttributeCategories: cleanCodeCategories, |
@@ -385,7 +385,7 @@ export function mockIssuesList(baseComponentKey = PARENT_COMPONENT_KEY): IssueDa | |||
quickFixAvailable: true, | |||
tags: ['unused'], | |||
codeVariants: ['variant 1', 'variant 2'], | |||
project: 'org.project2', | |||
project: 'org.sonarsource.javascript:javascript', | |||
assignee: 'email1@sonarsource.com', | |||
author: 'email3@sonarsource.com', | |||
issueStatus: IssueStatus.Confirmed, |
@@ -115,7 +115,12 @@ describe('issues app filtering', () => { | |||
// Project | |||
await user.click(ui.projectFacet.get()); | |||
await user.click(screen.getByRole('checkbox', { name: 'org.project2' })); | |||
expect( | |||
screen.getByRole('checkbox', { name: 'org.sonarsource.javascript:javascript' }), | |||
).toHaveTextContent('SonarJS'); | |||
await user.click( | |||
screen.getByRole('checkbox', { name: 'org.sonarsource.javascript:javascript' }), | |||
); | |||
// Assignee | |||
await user.click(ui.assigneeFacet.get()); |
@@ -17,12 +17,13 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { ProjectIcon } from 'design-system'; | |||
import { ProjectIcon, Spinner } from 'design-system'; | |||
import { omit } from 'lodash'; | |||
import * as React from 'react'; | |||
import { getTree, searchProjects } from '../../../api/components'; | |||
import { translate } from '../../../helpers/l10n'; | |||
import { highlightTerm } from '../../../helpers/search'; | |||
import { useProjectQuery } from '../../../queries/projects'; | |||
import { ComponentQualifier } from '../../../types/component'; | |||
import { Facet, ReferencedComponent } from '../../../types/issues'; | |||
import { MetricKey } from '../../../types/metrics'; | |||
@@ -48,13 +49,23 @@ interface SearchedProject { | |||
name: string; | |||
} | |||
export class ProjectFacet extends React.PureComponent<Props> { | |||
handleSearch = ( | |||
export function ProjectFacet(props: Readonly<Props>) { | |||
const { | |||
component, | |||
fetching, | |||
onChange, | |||
onToggle, | |||
open, | |||
projects, | |||
query, | |||
referencedComponents, | |||
stats, | |||
} = props; | |||
const handleSearch = ( | |||
query: string, | |||
page = 1, | |||
): Promise<{ results: SearchedProject[]; paging: Paging }> => { | |||
const { component } = this.props; | |||
if ( | |||
component && | |||
[ | |||
@@ -91,29 +102,27 @@ export class ProjectFacet extends React.PureComponent<Props> { | |||
})); | |||
}; | |||
getProjectName = (project: string) => { | |||
const { referencedComponents } = this.props; | |||
const getProjectName = (project: string) => { | |||
return referencedComponents[project] ? referencedComponents[project].name : project; | |||
}; | |||
loadSearchResultCount = (projects: SearchedProject[]) => { | |||
return this.props.loadSearchResultCount(MetricKey.projects, { | |||
const loadSearchResultCount = (projects: SearchedProject[]) => { | |||
return props.loadSearchResultCount(MetricKey.projects, { | |||
projects: projects.map((project) => project.key), | |||
}); | |||
}; | |||
renderFacetItem = (projectKey: string) => { | |||
const renderFacetItem = (projectKey: string) => { | |||
const projectName = getProjectName(projectKey); | |||
return ( | |||
<span> | |||
<ProjectIcon className="sw-mr-1" /> | |||
{this.getProjectName(projectKey)} | |||
</span> | |||
<ProjectItem | |||
projectKey={projectKey} | |||
projectName={projectName === projectKey ? undefined : projectName} | |||
/> | |||
); | |||
}; | |||
renderSearchResult = (project: Pick<SearchedProject, 'name'>, term: string) => ( | |||
const renderSearchResult = (project: Pick<SearchedProject, 'name'>, term: string) => ( | |||
<> | |||
<ProjectIcon className="sw-mr-1" /> | |||
@@ -121,27 +130,52 @@ export class ProjectFacet extends React.PureComponent<Props> { | |||
</> | |||
); | |||
render() { | |||
return ( | |||
<ListStyleFacet<SearchedProject> | |||
facetHeader={translate('issues.facet.projects')} | |||
fetching={this.props.fetching} | |||
getFacetItemText={this.getProjectName} | |||
getSearchResultKey={(project) => project.key} | |||
getSearchResultText={(project) => project.name} | |||
loadSearchResultCount={this.loadSearchResultCount} | |||
onChange={this.props.onChange} | |||
onSearch={this.handleSearch} | |||
onToggle={this.props.onToggle} | |||
open={this.props.open} | |||
property={MetricKey.projects} | |||
query={omit(this.props.query, MetricKey.projects)} | |||
renderFacetItem={this.renderFacetItem} | |||
renderSearchResult={this.renderSearchResult} | |||
searchPlaceholder={translate('search.search_for_projects')} | |||
stats={this.props.stats} | |||
values={this.props.projects} | |||
/> | |||
); | |||
} | |||
return ( | |||
<ListStyleFacet<SearchedProject> | |||
facetHeader={translate('issues.facet.projects')} | |||
fetching={fetching} | |||
getFacetItemText={getProjectName} | |||
getSearchResultKey={(project) => project.key} | |||
getSearchResultText={(project) => project.name} | |||
loadSearchResultCount={loadSearchResultCount} | |||
onChange={onChange} | |||
onSearch={handleSearch} | |||
onToggle={onToggle} | |||
open={open} | |||
property={MetricKey.projects} | |||
query={omit(query, MetricKey.projects)} | |||
renderFacetItem={renderFacetItem} | |||
renderSearchResult={renderSearchResult} | |||
searchPlaceholder={translate('search.search_for_projects')} | |||
stats={stats} | |||
values={projects} | |||
/> | |||
); | |||
} | |||
function ProjectItem({ | |||
projectKey, | |||
projectName, | |||
}: Readonly<{ | |||
projectKey: string; | |||
projectName?: string; | |||
}>) { | |||
const { data, isLoading } = useProjectQuery(projectKey, { | |||
enabled: projectName === undefined, | |||
select: (data) => data.components.find((el) => el.key === projectKey), | |||
}); | |||
const label = projectName ?? (isLoading ? '' : data?.name ?? projectKey); | |||
return ( | |||
<div className="sw-flex sw-items-center"> | |||
<ProjectIcon className="sw-mr-1" /> | |||
<Spinner loading={projectName === undefined && isLoading} /> | |||
<span className="sw-min-w-0 sw-truncate" title={label}> | |||
{label} | |||
</span> | |||
</div> | |||
); | |||
} |
@@ -0,0 +1,35 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2024 SonarSource SA | |||
* mailto:info AT sonarsource DOT com | |||
* | |||
* This program is free software; you can redistribute it and/or | |||
* modify it under the terms of the GNU Lesser General Public | |||
* License as published by the Free Software Foundation; either | |||
* version 3 of the License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||
* Lesser General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Lesser General Public License | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { UseQueryOptions, useQuery } from '@tanstack/react-query'; | |||
import { searchProjects } from '../api/components'; | |||
export function useProjectQuery<T = Awaited<ReturnType<typeof searchProjects>>>( | |||
key: string, | |||
options?: Omit< | |||
UseQueryOptions<Awaited<ReturnType<typeof searchProjects>>, Error, T>, | |||
'queryKey' | 'queryFn' | |||
>, | |||
) { | |||
return useQuery({ | |||
queryKey: ['project', key], | |||
queryFn: ({ queryKey: [, key] }) => searchProjects({ filter: `query=${key}` }), | |||
...options, | |||
}); | |||
} |