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';
export default {
title: 'Repositories Search',
title: 'MRVA/Repositories Search',
component: RepositoriesSearchComponent,
argTypes: {
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 { faker } from '@faker-js/faker';
import { VariantAnalysisContainer } from '../../view/variant-analysis/VariantAnalysisContainer';
import { VariantAnalysisAnalyzedRepos } from '../../view/variant-analysis/VariantAnalysisAnalyzedRepos';
import {
@@ -11,6 +13,7 @@ import {
import { AnalysisAlert } from '../../remote-queries/shared/analysis-result';
import { createMockVariantAnalysis } from '../../vscode-tests/factories/remote-queries/shared/variant-analysis';
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';
@@ -111,5 +114,40 @@ Example.args = {
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;
label: string;
className?: string;
slot?: string;
};
const CodiconIcon = styled.span`
@@ -15,5 +16,6 @@ const CodiconIcon = styled.span`
export const Codicon = ({
name,
label,
className
}: Props) => <CodiconIcon role="img" aria-label={label} className={classNames('codicon', `codicon-${name}`, className)} />;
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 { useMemo } from 'react';
import styled from 'styled-components';
import { RepoRow } from './RepoRow';
import {
@@ -6,7 +7,7 @@ import {
VariantAnalysisScannedRepositoryResult,
VariantAnalysisScannedRepositoryState
} from '../../remote-queries/shared/variant-analysis';
import { useMemo } from 'react';
import { matchesSearchValue } from './filterSort';
const Container = styled.div`
display: flex;
@@ -19,12 +20,15 @@ export type VariantAnalysisAnalyzedReposProps = {
variantAnalysis: VariantAnalysis;
repositoryStates?: VariantAnalysisScannedRepositoryState[];
repositoryResults?: VariantAnalysisScannedRepositoryResult[];
searchValue?: string;
}
export const VariantAnalysisAnalyzedRepos = ({
variantAnalysis,
repositoryStates,
repositoryResults,
searchValue,
}: VariantAnalysisAnalyzedReposProps) => {
const repositoryStateById = useMemo(() => {
const map = new Map<number, VariantAnalysisScannedRepositoryState>();
@@ -42,9 +46,19 @@ export const VariantAnalysisAnalyzedRepos = ({
return map;
}, [repositoryResults]);
const repositories = useMemo(() => {
if (searchValue) {
return variantAnalysis.scannedRepos?.filter((repoTask) => {
return matchesSearchValue(repoTask.repository, searchValue);
});
}
return variantAnalysis.scannedRepos;
}, [searchValue, variantAnalysis.scannedRepos]);
return (
<Container>
{variantAnalysis.scannedRepos?.map(repository => {
{repositories?.map(repository => {
const state = repositoryStateById.get(repository.repository.id);
const results = repositoryResultsById.get(repository.repository.id);

View File

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

View File

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

View File

@@ -107,4 +107,15 @@ describe(VariantAnalysisAnalyzedRepos.name, () => {
}));
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-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());
}