Add repository filter by full name

This adds a new textbox to the outcome panels that allows filtering by
the repository full name (e.g. `github/vscode-codeql`). The filtering
uses the same logic as the existing remote queries filter, i.e. by
converting the input and the repository full name to lower case and
checking the the latter includes the former.
This commit is contained in:
Koen Vlaswinkel
2022-11-03 11:12:29 +01:00
parent 1487ff5e0e
commit 18111ff4bf
12 changed files with 221 additions and 10 deletions

View File

@@ -5,7 +5,7 @@ import { ComponentMeta } from '@storybook/react';
import RepositoriesSearchComponent from '../../view/remote-queries/RepositoriesSearch'; import RepositoriesSearchComponent from '../../view/remote-queries/RepositoriesSearch';
export default { export default {
title: 'Repositories Search', title: 'MRVA/Repositories Search',
component: RepositoriesSearchComponent, component: RepositoriesSearchComponent,
argTypes: { argTypes: {
filterValue: { filterValue: {

View File

@@ -0,0 +1,25 @@
import React, { useState } from 'react';
import { ComponentMeta } from '@storybook/react';
import { RepositoriesSearch as RepositoriesSearchComponent } from '../../view/variant-analysis/RepositoriesSearch';
export default {
title: 'Variant Analysis/Repositories Search',
component: RepositoriesSearchComponent,
argTypes: {
value: {
control: {
disable: true,
},
},
}
} as ComponentMeta<typeof RepositoriesSearchComponent>;
export const RepositoriesSearch = () => {
const [value, setValue] = useState('');
return (
<RepositoriesSearchComponent value={value} onChange={setValue} />
);
};

View File

@@ -2,6 +2,8 @@ import React from 'react';
import { ComponentMeta, ComponentStory } from '@storybook/react'; import { ComponentMeta, ComponentStory } from '@storybook/react';
import { faker } from '@faker-js/faker';
import { VariantAnalysisContainer } from '../../view/variant-analysis/VariantAnalysisContainer'; import { VariantAnalysisContainer } from '../../view/variant-analysis/VariantAnalysisContainer';
import { VariantAnalysisAnalyzedRepos } from '../../view/variant-analysis/VariantAnalysisAnalyzedRepos'; import { VariantAnalysisAnalyzedRepos } from '../../view/variant-analysis/VariantAnalysisAnalyzedRepos';
import { import {
@@ -11,6 +13,7 @@ import {
import { AnalysisAlert } from '../../remote-queries/shared/analysis-result'; import { AnalysisAlert } from '../../remote-queries/shared/analysis-result';
import { createMockVariantAnalysis } from '../../vscode-tests/factories/remote-queries/shared/variant-analysis'; import { createMockVariantAnalysis } from '../../vscode-tests/factories/remote-queries/shared/variant-analysis';
import { createMockRepositoryWithMetadata } from '../../vscode-tests/factories/remote-queries/shared/repository'; import { createMockRepositoryWithMetadata } from '../../vscode-tests/factories/remote-queries/shared/repository';
import { createMockScannedRepo } from '../../vscode-tests/factories/remote-queries/shared/scanned-repositories';
import analysesResults from '../remote-queries/data/analysesResultsMessage.json'; import analysesResults from '../remote-queries/data/analysesResultsMessage.json';
@@ -111,5 +114,40 @@ Example.args = {
interpretedResults: interpretedResultsForRepo('expressjs/express'), interpretedResults: interpretedResultsForRepo('expressjs/express'),
} }
] ]
} };
;
faker.seed(42);
const uniqueStore = {};
const manyScannedRepos = Array.from({ length: 1000 }, (_, i) => {
const mockedScannedRepo = createMockScannedRepo();
return {
...mockedScannedRepo,
analysisStatus: VariantAnalysisRepoStatus.Succeeded,
resultCount: faker.datatype.number({ min: 0, max: 1000 }),
repository: {
...mockedScannedRepo.repository,
// We need to ensure the ID is unique for React keys
id: faker.helpers.unique(faker.datatype.number, [], {
store: uniqueStore,
}),
fullName: `octodemo/${faker.helpers.unique(faker.random.word, [], {
store: uniqueStore,
})}`,
}
};
});
export const PerformanceExample = Template.bind({});
PerformanceExample.args = {
variantAnalysis: {
...createMockVariantAnalysis(VariantAnalysisStatus.Succeeded, manyScannedRepos),
id: 1,
},
repositoryResults: manyScannedRepos.map(repoTask => ({
variantAnalysisId: 1,
repositoryId: repoTask.repository.id,
interpretedResults: interpretedResultsForRepo('facebook/create-react-app'),
}))
};

View File

@@ -6,6 +6,7 @@ type Props = {
name: string; name: string;
label: string; label: string;
className?: string; className?: string;
slot?: string;
}; };
const CodiconIcon = styled.span` const CodiconIcon = styled.span`
@@ -15,5 +16,6 @@ const CodiconIcon = styled.span`
export const Codicon = ({ export const Codicon = ({
name, name,
label, label,
className className,
}: Props) => <CodiconIcon role="img" aria-label={label} className={classNames('codicon', `codicon-${name}`, className)} />; slot,
}: Props) => <CodiconIcon role="img" aria-label={label} className={classNames('codicon', `codicon-${name}`, className)} slot={slot} />;

View File

@@ -0,0 +1,32 @@
import * as React from 'react';
import { useCallback } from 'react';
import styled from 'styled-components';
import { VSCodeTextField } from '@vscode/webview-ui-toolkit/react';
import { Codicon } from '../common';
const TextField = styled(VSCodeTextField)`
width: 100%;
`;
type Props = {
value: string;
onChange: (value: string) => void;
}
export const RepositoriesSearch = ({ value, onChange }: Props) => {
const handleInput = useCallback((e: InputEvent) => {
const target = e.target as HTMLInputElement;
onChange(target.value);
}, [onChange]);
return (
<TextField
placeholder='Filter by repository owner/name'
value={value}
onInput={handleInput}
>
<Codicon name="search" label="Search..." slot="start" />
</TextField>
);
};

View File

@@ -1,4 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { useMemo } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { RepoRow } from './RepoRow'; import { RepoRow } from './RepoRow';
import { import {
@@ -6,7 +7,7 @@ import {
VariantAnalysisScannedRepositoryResult, VariantAnalysisScannedRepositoryResult,
VariantAnalysisScannedRepositoryState VariantAnalysisScannedRepositoryState
} from '../../remote-queries/shared/variant-analysis'; } from '../../remote-queries/shared/variant-analysis';
import { useMemo } from 'react'; import { matchesSearchValue } from './filterSort';
const Container = styled.div` const Container = styled.div`
display: flex; display: flex;
@@ -19,12 +20,15 @@ export type VariantAnalysisAnalyzedReposProps = {
variantAnalysis: VariantAnalysis; variantAnalysis: VariantAnalysis;
repositoryStates?: VariantAnalysisScannedRepositoryState[]; repositoryStates?: VariantAnalysisScannedRepositoryState[];
repositoryResults?: VariantAnalysisScannedRepositoryResult[]; repositoryResults?: VariantAnalysisScannedRepositoryResult[];
searchValue?: string;
} }
export const VariantAnalysisAnalyzedRepos = ({ export const VariantAnalysisAnalyzedRepos = ({
variantAnalysis, variantAnalysis,
repositoryStates, repositoryStates,
repositoryResults, repositoryResults,
searchValue,
}: VariantAnalysisAnalyzedReposProps) => { }: VariantAnalysisAnalyzedReposProps) => {
const repositoryStateById = useMemo(() => { const repositoryStateById = useMemo(() => {
const map = new Map<number, VariantAnalysisScannedRepositoryState>(); const map = new Map<number, VariantAnalysisScannedRepositoryState>();
@@ -42,9 +46,19 @@ export const VariantAnalysisAnalyzedRepos = ({
return map; return map;
}, [repositoryResults]); }, [repositoryResults]);
const repositories = useMemo(() => {
if (searchValue) {
return variantAnalysis.scannedRepos?.filter((repoTask) => {
return matchesSearchValue(repoTask.repository, searchValue);
});
}
return variantAnalysis.scannedRepos;
}, [searchValue, variantAnalysis.scannedRepos]);
return ( return (
<Container> <Container>
{variantAnalysis.scannedRepos?.map(repository => { {repositories?.map(repository => {
const state = repositoryStateById.get(repository.repository.id); const state = repositoryStateById.get(repository.repository.id);
const results = repositoryResultsById.get(repository.repository.id); const results = repositoryResultsById.get(repository.repository.id);

View File

@@ -1,4 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { VSCodeBadge, VSCodePanels, VSCodePanelTab, VSCodePanelView } from '@vscode/webview-ui-toolkit/react'; import { VSCodeBadge, VSCodePanels, VSCodePanelTab, VSCodePanelView } from '@vscode/webview-ui-toolkit/react';
import { formatDecimal } from '../../pure/number'; import { formatDecimal } from '../../pure/number';
@@ -10,6 +11,7 @@ import {
import { VariantAnalysisAnalyzedRepos } from './VariantAnalysisAnalyzedRepos'; import { VariantAnalysisAnalyzedRepos } from './VariantAnalysisAnalyzedRepos';
import { Alert } from '../common'; import { Alert } from '../common';
import { VariantAnalysisSkippedRepositoriesTab } from './VariantAnalysisSkippedRepositoriesTab'; import { VariantAnalysisSkippedRepositoriesTab } from './VariantAnalysisSkippedRepositoriesTab';
import { RepositoriesSearch } from './RepositoriesSearch';
export type VariantAnalysisOutcomePanelProps = { export type VariantAnalysisOutcomePanelProps = {
variantAnalysis: VariantAnalysis; variantAnalysis: VariantAnalysis;
@@ -42,6 +44,8 @@ export const VariantAnalysisOutcomePanels = ({
repositoryStates, repositoryStates,
repositoryResults, repositoryResults,
}: VariantAnalysisOutcomePanelProps) => { }: VariantAnalysisOutcomePanelProps) => {
const [searchValue, setSearchValue] = useState('');
const noCodeqlDbRepos = variantAnalysis.skippedRepos?.noCodeqlDbRepos; const noCodeqlDbRepos = variantAnalysis.skippedRepos?.noCodeqlDbRepos;
const notFoundRepos = variantAnalysis.skippedRepos?.notFoundRepos; const notFoundRepos = variantAnalysis.skippedRepos?.notFoundRepos;
const overLimitRepositoryCount = variantAnalysis.skippedRepos?.overLimitRepos?.repositoryCount ?? 0; const overLimitRepositoryCount = variantAnalysis.skippedRepos?.overLimitRepos?.repositoryCount ?? 0;
@@ -70,10 +74,12 @@ export const VariantAnalysisOutcomePanels = ({
return ( return (
<> <>
{warnings} {warnings}
<RepositoriesSearch value={searchValue} onChange={setSearchValue} />
<VariantAnalysisAnalyzedRepos <VariantAnalysisAnalyzedRepos
variantAnalysis={variantAnalysis} variantAnalysis={variantAnalysis}
repositoryStates={repositoryStates} repositoryStates={repositoryStates}
repositoryResults={repositoryResults} repositoryResults={repositoryResults}
searchValue={searchValue}
/> />
</> </>
); );
@@ -82,6 +88,7 @@ export const VariantAnalysisOutcomePanels = ({
return ( return (
<> <>
{warnings} {warnings}
<RepositoriesSearch value={searchValue} onChange={setSearchValue} />
<VSCodePanels> <VSCodePanels>
<Tab> <Tab>
Analyzed Analyzed
@@ -104,6 +111,7 @@ export const VariantAnalysisOutcomePanels = ({
variantAnalysis={variantAnalysis} variantAnalysis={variantAnalysis}
repositoryStates={repositoryStates} repositoryStates={repositoryStates}
repositoryResults={repositoryResults} repositoryResults={repositoryResults}
searchValue={searchValue}
/> />
</VSCodePanelView> </VSCodePanelView>
{notFoundRepos?.repositoryCount && {notFoundRepos?.repositoryCount &&
@@ -111,14 +119,18 @@ export const VariantAnalysisOutcomePanels = ({
<VariantAnalysisSkippedRepositoriesTab <VariantAnalysisSkippedRepositoriesTab
alertTitle='No access' alertTitle='No access'
alertMessage='The following repositories could not be scanned because you do not have read access.' alertMessage='The following repositories could not be scanned because you do not have read access.'
skippedRepositoryGroup={notFoundRepos} /> skippedRepositoryGroup={notFoundRepos}
searchValue={searchValue}
/>
</VSCodePanelView>} </VSCodePanelView>}
{noCodeqlDbRepos?.repositoryCount && {noCodeqlDbRepos?.repositoryCount &&
<VSCodePanelView> <VSCodePanelView>
<VariantAnalysisSkippedRepositoriesTab <VariantAnalysisSkippedRepositoriesTab
alertTitle='No database' alertTitle='No database'
alertMessage='The following repositories could not be scanned because they do not have an available CodeQL database.' alertMessage='The following repositories could not be scanned because they do not have an available CodeQL database.'
skippedRepositoryGroup={noCodeqlDbRepos} /> skippedRepositoryGroup={noCodeqlDbRepos}
searchValue={searchValue}
/>
</VSCodePanelView>} </VSCodePanelView>}
</VSCodePanels> </VSCodePanels>
</> </>

View File

@@ -1,13 +1,17 @@
import * as React from 'react'; import * as React from 'react';
import { useMemo } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { VariantAnalysisSkippedRepositoryGroup } from '../../remote-queries/shared/variant-analysis'; import { VariantAnalysisSkippedRepositoryGroup } from '../../remote-queries/shared/variant-analysis';
import { Alert } from '../common'; import { Alert } from '../common';
import { RepoRow } from './RepoRow'; import { RepoRow } from './RepoRow';
import { matchesSearchValue } from './filterSort';
export type VariantAnalysisSkippedRepositoriesTabProps = { export type VariantAnalysisSkippedRepositoriesTabProps = {
alertTitle: string, alertTitle: string,
alertMessage: string, alertMessage: string,
skippedRepositoryGroup: VariantAnalysisSkippedRepositoryGroup, skippedRepositoryGroup: VariantAnalysisSkippedRepositoryGroup,
searchValue?: string,
}; };
function getSkipReasonAlert( function getSkipReasonAlert(
@@ -39,11 +43,22 @@ export const VariantAnalysisSkippedRepositoriesTab = ({
alertTitle, alertTitle,
alertMessage, alertMessage,
skippedRepositoryGroup, skippedRepositoryGroup,
searchValue,
}: VariantAnalysisSkippedRepositoriesTabProps) => { }: VariantAnalysisSkippedRepositoriesTabProps) => {
const repositories = useMemo(() => {
if (searchValue) {
return skippedRepositoryGroup.repositories?.filter((repo) => {
return matchesSearchValue(repo, searchValue);
});
}
return skippedRepositoryGroup.repositories;
}, [searchValue, skippedRepositoryGroup.repositories]);
return ( return (
<Container> <Container>
{getSkipReasonAlert(alertTitle, alertMessage, skippedRepositoryGroup)} {getSkipReasonAlert(alertTitle, alertMessage, skippedRepositoryGroup)}
{skippedRepositoryGroup.repositories.map((repo) => {repositories.map((repo) =>
<RepoRow key={`repo/${repo.fullName}`} repository={repo} /> <RepoRow key={`repo/${repo.fullName}`} repository={repo} />
)} )}
</Container> </Container>

View File

@@ -107,4 +107,15 @@ describe(VariantAnalysisAnalyzedRepos.name, () => {
})); }));
expect(screen.getByText('This is an empty block.')).toBeInTheDocument(); expect(screen.getByText('This is an empty block.')).toBeInTheDocument();
}); });
it('uses the search value', () => {
render({
searchValue: 'world-2',
});
expect(screen.queryByText('octodemo/hello-world-1')).not.toBeInTheDocument();
expect(screen.getByText('octodemo/hello-world-2')).toBeInTheDocument();
expect(screen.queryByText('octodemo/hello-world-3')).not.toBeInTheDocument();
expect(screen.queryByText('octodemo/hello-world-4')).not.toBeInTheDocument();
});
}); });

View File

@@ -97,4 +97,30 @@ describe(VariantAnalysisSkippedRepositoriesTab.name, () => {
expect(screen.getByText('octodemo/hello-galaxy')).toBeInTheDocument(); expect(screen.getByText('octodemo/hello-galaxy')).toBeInTheDocument();
expect(screen.getByText('octodemo/hello-universe')).toBeInTheDocument(); expect(screen.getByText('octodemo/hello-universe')).toBeInTheDocument();
}); });
it('uses the search value', async () => {
render({
alertTitle: 'No database',
alertMessage: 'The following repositories could not be scanned because they do not have an available CodeQL database.',
skippedRepositoryGroup: {
repositoryCount: 1,
repositories: [
{
fullName: 'octodemo/hello-world',
},
{
fullName: 'octodemo/hello-galaxy',
},
{
fullName: 'octodemo/hello-universe',
},
],
},
searchValue: 'world',
});
expect(screen.getByText('octodemo/hello-world')).toBeInTheDocument();
expect(screen.queryByText('octodemo/hello-galaxy')).not.toBeInTheDocument();
expect(screen.queryByText('octodemo/hello-universe')).not.toBeInTheDocument();
});
}); });

View File

@@ -0,0 +1,27 @@
import { matchesSearchValue } from '../filterSort';
describe(matchesSearchValue.name, () => {
const repository = {
fullName: 'github/codeql'
};
const testCases = [
{ searchValue: undefined, matches: true },
{ searchValue: '', matches: true },
{ searchValue: 'github/codeql', matches: true },
{ searchValue: 'github', matches: true },
{ searchValue: 'git', matches: true },
{ searchValue: 'codeql', matches: true },
{ searchValue: 'code', matches: true },
{ searchValue: 'ql', matches: true },
{ searchValue: '/', matches: true },
{ searchValue: 'gothub/codeql', matches: false },
{ searchValue: 'hello', matches: false },
{ searchValue: 'cod*ql', matches: false },
{ searchValue: 'cod?ql', matches: false },
];
test.each(testCases)('returns $matches if searching for $searchValue', ({ searchValue, matches }) => {
expect(matchesSearchValue(repository, searchValue)).toBe(matches);
});
});

View File

@@ -0,0 +1,9 @@
import { Repository } from '../../remote-queries/shared/repository';
export function matchesSearchValue(repo: Pick<Repository, 'fullName'>, searchValue: string | undefined): boolean {
if (!searchValue) {
return true;
}
return repo.fullName.toLowerCase().includes(searchValue.toLowerCase());
}