| 'assigned_to_me'
| 'assignees'
| 'author'
+ | 'codeVariants'
| 'createdAt'
| 'cwe'
| 'directories'
ruleStatus: 'DEPRECATED',
quickFixAvailable: true,
tags: ['unused'],
+ codeVariants: ['variant 1', 'variant 2'],
project: 'org.project2',
assignee: 'email1@sonarsource.com',
author: 'email3@sonarsource.com',
this.list = cloneDeep(this.defaultList);
- (searchIssues as jest.Mock).mockImplementation(this.handleSearchIssues);
+ jest.mocked(searchIssues).mockImplementation(this.handleSearchIssues);
(getRuleDetails as jest.Mock).mockImplementation(this.handleGetRuleDetails);
jest.mocked(searchRules).mockImplementation(this.handleSearchRules);
(getIssueFlowSnippets as jest.Mock).mockImplementation(this.handleGetIssueFlowSnippets);
],
};
}
+ if (name === 'codeVariants') {
+ return {
+ property: 'codeVariants',
+ values: this.list.reduce((acc, { issue }) => {
+ if (issue.codeVariants?.length) {
+ issue.codeVariants.forEach((codeVariant) => {
+ const item = acc.find(({ val }) => val === codeVariant);
+ if (item) {
+ item.count++;
+ } else {
+ acc.push({
+ val: codeVariant,
+ count: 1,
+ });
+ }
+ });
+ }
+ return acc;
+ }, [] as RawFacet['values']),
+ };
+ }
if (name === 'projects') {
return {
property: name,
.filter(
(item) =>
!query.inNewCodePeriod || new Date(item.issue.creationDate) > new Date('2023-01-10')
- );
+ )
+ .filter((item) => {
+ if (!query.codeVariants) {
+ return true;
+ }
+ if (!item.issue.codeVariants) {
+ return false;
+ }
+ return item.issue.codeVariants.some((codeVariant) =>
+ query.codeVariants?.split(',').includes(codeVariant)
+ );
+ });
// Splice list items according to paging using a fixed page size
const pageIndex = query.p || 1;
import selectEvent from 'react-select-event';
import { TabKeys } from '../../../components/rules/RuleTabViewer';
import { renderOwaspTop102021Category } from '../../../helpers/security-standard';
-import { mockLoggedInUser } from '../../../helpers/testMocks';
+import { mockLoggedInUser, mockRawIssue } from '../../../helpers/testMocks';
import { ComponentQualifier } from '../../../types/component';
import { IssueType } from '../../../types/issues';
import {
expect(ui.issueItem7.get()).toBeInTheDocument();
});
+ it('should properly filter by code variants', async () => {
+ const user = userEvent.setup();
+ renderProjectIssuesApp();
+ await waitOnDataLoaded();
+
+ await user.click(ui.codeVariantsFacet.get());
+ await user.click(screen.getByRole('checkbox', { name: /variant 1/ }));
+
+ expect(ui.issueItem1.query()).not.toBeInTheDocument();
+ expect(ui.issueItem7.get()).toBeInTheDocument();
+
+ // Clear filter
+ await user.click(ui.clearCodeVariantsFacet.get());
+ expect(ui.issueItem1.get()).toBeInTheDocument();
+ });
+
+ it('should properly hide the code variants filter if no issue has any code variants', async () => {
+ issuesHandler.setIssueList([
+ {
+ issue: mockRawIssue(),
+ snippets: {},
+ },
+ ]);
+ renderProjectIssuesApp();
+ await waitOnDataLoaded();
+
+ expect(ui.codeVariantsFacet.query()).not.toBeInTheDocument();
+ });
+
it('should allow to set creation date', async () => {
const user = userEvent.setup();
const currentUser = mockLoggedInUser();
assigned: true,
assignees: ['a', 'b'],
author: ['a', 'b'],
+ codeVariants: ['variant1', 'variant2'],
createdAfter: new Date(1000000),
createdAt: 'a',
createdBefore: new Date(1000000),
).toStrictEqual({
assignees: 'a,b',
author: ['a', 'b'],
+ codeVariants: 'variant1,variant2',
createdAt: 'a',
createdBefore: '1970-01-01',
createdAfter: '1970-01-01',
} from '../../../helpers/pages';
import { serializeDate } from '../../../helpers/query';
import { BranchLike } from '../../../types/branch-like';
-import { ComponentQualifier, isPortfolioLike } from '../../../types/component';
+import { ComponentQualifier, isPortfolioLike, isProject } from '../../../types/component';
import {
ASSIGNEE_ME,
Facet,
locationsNavigator: boolean;
myIssues: boolean;
openFacets: Dict<boolean>;
+ showVariantsFilter: boolean;
openIssue?: Issue;
openPopup?: { issue: string; name: string };
openRuleDetails?: RuleDetails;
const DEFAULT_QUERY = { resolved: 'false' };
const MAX_INITAL_FETCH = 1000;
const BRANCH_STATUS_REFRESH_INTERVAL = 1000;
+const VARIANTS_FACET = 'codeVariants';
export class App extends React.PureComponent<Props, State> {
mounted = false;
standards: shouldOpenStandardsFacet({}, query),
types: true,
},
+ showVariantsFilter: false,
query,
referencedComponentsById: {},
referencedComponentsByKey: {},
addWhitePageClass();
addSideBarClass();
this.attachShortcuts();
- this.fetchFirstIssues();
+ this.fetchFirstIssues(true);
}
componentDidUpdate(prevProps: Props, prevState: State) {
!areQueriesEqual(prevQuery, query) ||
areMyIssuesSelected(prevQuery) !== areMyIssuesSelected(query)
) {
- this.fetchFirstIssues();
+ this.fetchFirstIssues(false);
this.setState({ checkAll: false });
} else if (openIssue && openIssue.key !== this.state.selected) {
this.setState({
});
};
- fetchIssues = (additional: RawQuery, requestFacets = false): Promise<FetchIssuesPromise> => {
+ fetchIssues = (
+ additional: RawQuery,
+ requestFacets = false,
+ firstRequest = false
+ ): Promise<FetchIssuesPromise> => {
const { component } = this.props;
const { myIssues, openFacets, query } = this.state;
- const facets = requestFacets
+ let facets = requestFacets
? Object.keys(openFacets)
.filter((facet) => facet !== STANDARDS && openFacets[facet])
.join(',')
: undefined;
+ if (firstRequest && isProject(component?.qualifier)) {
+ facets = facets ? `${facets},${VARIANTS_FACET}` : VARIANTS_FACET;
+ }
+
const parameters: Dict<string | undefined> = {
...getBranchLikeQuery(this.props.branchLike),
componentKeys: component && component.key,
return this.fetchIssuesHelper(parameters);
};
- fetchFirstIssues() {
+ fetchFirstIssues(firstRequest: boolean) {
const prevQuery = this.props.location.query;
const openIssueKey = getOpen(this.props.location.query);
let fetchPromise;
return pageIssues.some((issue) => issue.key === openIssueKey);
});
} else {
- fetchPromise = this.fetchIssues({}, true);
+ fetchPromise = this.fetchIssues({}, true, firstRequest);
}
return fetchPromise.then(
if (issues.length > 0) {
selected = openIssue ? openIssue.key : issues[0].key;
}
- this.setState({
+ this.setState(({ showVariantsFilter }) => ({
cannotShowOpenIssue: Boolean(openIssueKey && !openIssue),
effortTotal,
facets: parseFacets(facets),
+ showVariantsFilter: firstRequest
+ ? Boolean(facets.find((f) => f.property === VARIANTS_FACET)?.values.length)
+ : showVariantsFilter,
loading: false,
locationsNavigator: true,
issues,
selected,
selectedFlowIndex: 0,
selectedLocationIndex: undefined,
- });
+ }));
}
return issues;
},
handleBulkChangeDone = () => {
this.setState({ checkAll: false });
this.refreshBranchStatus();
- this.fetchFirstIssues();
+ this.fetchFirstIssues(false);
this.handleCloseBulkChange();
};
renderFacets() {
const { component, currentUser, branchLike } = this.props;
- const { query } = this.state;
+ const {
+ query,
+ facets,
+ loadingFacets,
+ myIssues,
+ openFacets,
+ showVariantsFilter,
+ referencedComponentsById,
+ referencedComponentsByKey,
+ referencedLanguages,
+ referencedRules,
+ referencedUsers,
+ } = this.state;
return (
<div className="layout-page-filters">
branchLike={branchLike}
component={component}
createdAfterIncludesTime={this.createdAfterIncludesTime()}
- facets={this.state.facets}
+ facets={facets}
loadSearchResultCount={this.loadSearchResultCount}
- loadingFacets={this.state.loadingFacets}
- myIssues={this.state.myIssues}
+ loadingFacets={loadingFacets}
+ myIssues={myIssues}
onFacetToggle={this.handleFacetToggle}
onFilterChange={this.handleFilterChange}
- openFacets={this.state.openFacets}
+ openFacets={openFacets}
+ showVariantsFilter={showVariantsFilter}
query={query}
- referencedComponentsById={this.state.referencedComponentsById}
- referencedComponentsByKey={this.state.referencedComponentsByKey}
- referencedLanguages={this.state.referencedLanguages}
- referencedRules={this.state.referencedRules}
- referencedUsers={this.state.referencedUsers}
+ referencedComponentsById={referencedComponentsById}
+ referencedComponentsByKey={referencedComponentsByKey}
+ referencedLanguages={referencedLanguages}
+ referencedRules={referencedRules}
+ referencedUsers={referencedUsers}
/>
</div>
);
ComponentQualifier,
isApplication,
isPortfolioLike,
+ isProject,
isView,
} from '../../../types/component';
import {
import StatusFacet from './StatusFacet';
import TagFacet from './TagFacet';
import TypeFacet from './TypeFacet';
+import VariantFacet from './VariantFacet';
export interface Props {
appState: AppState;
onFacetToggle: (property: string) => void;
onFilterChange: (changes: Partial<Query>) => void;
openFacets: Dict<boolean>;
+ showVariantsFilter: boolean;
query: Query;
referencedComponentsById: Dict<ReferencedComponent>;
referencedComponentsByKey: Dict<ReferencedComponent>;
export class Sidebar extends React.PureComponent<Props> {
renderComponentFacets() {
- const { component, facets, loadingFacets, openFacets, query, branchLike } = this.props;
+ const { component, facets, loadingFacets, openFacets, query, branchLike, showVariantsFilter } =
+ this.props;
const hasFileOrDirectory =
!isApplication(component?.qualifier) && !isPortfolioLike(component?.qualifier);
if (!component || !hasFileOrDirectory) {
{...commonProps}
/>
)}
+ {showVariantsFilter && isProject(component?.qualifier) && (
+ <VariantFacet
+ fetching={loadingFacets.codeVariants === true}
+ open={!!openFacets.codeVariants}
+ stats={facets.codeVariants}
+ values={query.codeVariants}
+ {...commonProps}
+ />
+ )}
<FileFacet
branchLike={branchLike}
fetching={loadingFacets.files === true}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { orderBy, sortBy, without } from 'lodash';
+import * as React from 'react';
+import FacetBox from '../../../components/facet/FacetBox';
+import FacetHeader from '../../../components/facet/FacetHeader';
+import FacetItem from '../../../components/facet/FacetItem';
+import FacetItemsList from '../../../components/facet/FacetItemsList';
+import MultipleSelectionHint from '../../../components/facet/MultipleSelectionHint';
+import { translate } from '../../../helpers/l10n';
+import { Dict } from '../../../types/types';
+import { Query, formatFacetStat } from '../utils';
+
+interface VariantFacetProps {
+ fetching: boolean;
+ onChange: (changes: Partial<Query>) => void;
+ onToggle: (property: string) => void;
+ open: boolean;
+ stats?: Dict<number>;
+ values: string[];
+}
+
+const FACET_NAME = 'codeVariants';
+
+export default function VariantFacet(props: VariantFacetProps) {
+ const { open, fetching, stats = {}, values, onToggle, onChange } = props;
+
+ const handleClear = React.useCallback(() => {
+ onChange({ [FACET_NAME]: undefined });
+ }, [onChange]);
+
+ const handleHeaderClick = React.useCallback(() => {
+ onToggle(FACET_NAME);
+ }, [onToggle]);
+
+ const handleItemClick = React.useCallback(
+ (value: string, multiple: boolean) => {
+ if (value === '') {
+ onChange({ [FACET_NAME]: undefined });
+ } else if (multiple) {
+ const newValues = orderBy(
+ values.includes(value) ? without(values, value) : [...values, value]
+ );
+ onChange({ [FACET_NAME]: newValues });
+ } else {
+ onChange({
+ [FACET_NAME]: values.includes(value) && values.length === 1 ? [] : [value],
+ });
+ }
+ },
+ [values, onChange]
+ );
+
+ const id = `facet_${FACET_NAME}`;
+
+ return (
+ <FacetBox property={FACET_NAME}>
+ <FacetHeader
+ fetching={fetching}
+ name={translate('issues.facet', FACET_NAME)}
+ id={id}
+ onClear={handleClear}
+ onClick={handleHeaderClick}
+ open={open}
+ values={values}
+ />
+ {open && (
+ <>
+ <FacetItemsList labelledby={id}>
+ {Object.keys(stats).length === 0 && (
+ <div className="note spacer-bottom">{translate('no_results')}</div>
+ )}
+ {sortBy(
+ Object.keys(stats),
+ (key) => -stats[key],
+ (key) => key
+ ).map((codeVariant) => (
+ <FacetItem
+ active={values.includes(codeVariant)}
+ key={codeVariant}
+ name={codeVariant}
+ onClick={handleItemClick}
+ stat={formatFacetStat(stats[codeVariant])}
+ value={codeVariant}
+ />
+ ))}
+ </FacetItemsList>
+ <MultipleSelectionHint options={Object.keys(stats).length} values={values.length} />
+ </>
+ )}
+ </FacetBox>
+ );
+}
onFacetToggle={jest.fn()}
onFilterChange={jest.fn()}
openFacets={{}}
+ showVariantsFilter={false}
query={mockQuery()}
referencedComponentsById={{}}
referencedComponentsByKey={{}}
projectFacet: byRole('button', { name: 'issues.facet.projects' }),
clearProjectFacet: byRole('button', { name: 'clear_x_filter.issues.facet.projects' }),
assigneeFacet: byRole('button', { name: 'issues.facet.assignees' }),
+ codeVariantsFacet: byRole('button', { name: 'issues.facet.codeVariants' }),
clearAssigneeFacet: byRole('button', { name: 'clear_x_filter.issues.facet.assignees' }),
authorFacet: byRole('button', { name: 'issues.facet.authors' }),
clearAuthorFacet: byRole('button', { name: 'clear_x_filter.issues.facet.authors' }),
+ clearCodeVariantsFacet: byRole('button', { name: 'clear_x_filter.issues.facet.codeVariants' }),
dateInputMonthSelect: byRole('combobox', { name: 'Month:' }),
dateInputYearSelect: byRole('combobox', { name: 'Year:' }),
import { get, save } from '../../helpers/storage';
import { isDefined } from '../../helpers/types';
import { Facet, RawFacet } from '../../types/issues';
+import { MetricType } from '../../types/metrics';
import { SecurityStandard } from '../../types/security';
import { Dict, Issue, Paging, RawQuery } from '../../types/types';
import { UserBase } from '../../types/users';
assigned: boolean;
assignees: string[];
author: string[];
+ codeVariants: string[];
createdAfter: Date | undefined;
createdAt: string;
createdBefore: Date | undefined;
statuses: parseAsArray(query.statuses, parseAsString),
tags: parseAsArray(query.tags, parseAsString),
types: parseAsArray(query.types, parseAsString),
+ codeVariants: parseAsArray(query.codeVariants, parseAsString),
};
}
statuses: serializeStringArray(query.statuses),
tags: serializeStringArray(query.tags),
types: serializeStringArray(query.types),
+ codeVariants: serializeStringArray(query.codeVariants),
};
return cleanQuery(filter);
}
export function formatFacetStat(stat: number | undefined) {
- return stat && formatMeasure(stat, 'SHORT_INT');
+ return stat && formatMeasure(stat, MetricType.ShortInteger);
}
export const searchAssignees = (
assigned: false,
assignees: [],
author: [],
+ codeVariants: [],
createdAfter: undefined,
createdAt: '',
createdBefore: undefined,
tags?: string[];
assignee?: string;
author?: string;
+ codeVariants?: string[];
comments?: Comment[];
creationDate: string;
component: string;
assigneeName?: string;
author?: string;
branch?: string;
+ codeVariants?: string[];
comments?: IssueComment[];
component: string;
componentEnabled?: boolean;
issues.facet.rules=Rule
issues.facet.resolutions=Resolution
issues.facet.languages=Language
+issues.facet.codeVariants=Code Variant
issues.facet.createdAt=Creation Date
issues.facet.createdAt.all=All
issues.facet.createdAt.last_week=Last week