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:
@@ -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: {
|
||||
|
||||
@@ -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} />
|
||||
);
|
||||
};
|
||||
@@ -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'),
|
||||
}))
|
||||
};
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user