Add sorting to variant analysis repositories

This adds sorting to the variant analysis repositories on the outcome
panels. The sort state is shared between all panels, so unlike the
design this doesn't disable the sort when you are on e.g. the no access
panel.
This commit is contained in:
Koen Vlaswinkel
2022-11-03 16:18:22 +01:00
parent 18111ff4bf
commit 5ff7b8a1c1
12 changed files with 638 additions and 41 deletions

View File

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

View File

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

View File

@@ -11,9 +11,11 @@ const TextField = styled(VSCodeTextField)`
type Props = {
value: string;
onChange: (value: string) => void;
className?: string;
}
export const RepositoriesSearch = ({ value, onChange }: Props) => {
export const RepositoriesSearch = ({ value, onChange, className }: Props) => {
const handleInput = useCallback((e: InputEvent) => {
const target = e.target as HTMLInputElement;
@@ -25,6 +27,7 @@ export const RepositoriesSearch = ({ value, onChange }: Props) => {
placeholder='Filter by repository owner/name'
value={value}
onInput={handleInput}
className={className}
>
<Codicon name="search" label="Search..." slot="start" />
</TextField>

View File

@@ -0,0 +1,49 @@
import * as React from 'react';
import { Dispatch, SetStateAction, useCallback } from 'react';
import styled from 'styled-components';
import { RepositoriesFilterSortState, SortKey } from './filterSort';
import { RepositoriesSearch } from './RepositoriesSearch';
import { RepositoriesSort } from './RepositoriesSort';
type Props = {
value: RepositoriesFilterSortState;
onChange: Dispatch<SetStateAction<RepositoriesFilterSortState>>;
}
const Container = styled.div`
display: flex;
gap: 1em;
width: 100%;
`;
const RepositoriesSearchColumn = styled(RepositoriesSearch)`
flex: 3;
`;
const RepositoriesSortColumn = styled(RepositoriesSort)`
flex: 1;
`;
export const RepositoriesSearchSortRow = ({ value, onChange }: Props) => {
const handleSearchValueChange = useCallback((searchValue: string) => {
onChange(oldValue => ({
...oldValue,
searchValue,
}));
}, [onChange]);
const handleSortKeyChange = useCallback((sortKey: SortKey) => {
onChange(oldValue => ({
...oldValue,
sortKey,
}));
}, [onChange]);
return (
<Container>
<RepositoriesSearchColumn value={value.searchValue} onChange={handleSearchValueChange} />
<RepositoriesSortColumn value={value.sortKey} onChange={handleSortKeyChange} />
</Container>
);
};

View File

@@ -0,0 +1,39 @@
import * as React from 'react';
import { useCallback } from 'react';
import styled from 'styled-components';
import { VSCodeDropdown, VSCodeOption } from '@vscode/webview-ui-toolkit/react';
import { SortKey } from './filterSort';
import { Codicon } from '../common';
const Dropdown = styled(VSCodeDropdown)`
width: 100%;
`;
type Props = {
value: SortKey;
onChange: (value: SortKey) => void;
className?: string;
}
export const RepositoriesSort = ({ value, onChange, className }: Props) => {
const handleInput = useCallback((e: InputEvent) => {
const target = e.target as HTMLSelectElement;
onChange(target.value as SortKey);
}, [onChange]);
return (
<Dropdown
value={value}
onInput={handleInput}
className={className}
>
<Codicon name="sort-precedence" label="Sort..." slot="indicator" />
<VSCodeOption value={SortKey.Name}>Name</VSCodeOption>
<VSCodeOption value={SortKey.ResultsCount}>Results</VSCodeOption>
<VSCodeOption value={SortKey.Stars}>Stars</VSCodeOption>
<VSCodeOption value={SortKey.LastUpdated}>Last commit</VSCodeOption>
</Dropdown>
);
};

View File

@@ -7,7 +7,7 @@ import {
VariantAnalysisScannedRepositoryResult,
VariantAnalysisScannedRepositoryState
} from '../../remote-queries/shared/variant-analysis';
import { matchesSearchValue } from './filterSort';
import { compareWithResults, matchesFilter, RepositoriesFilterSortState } from './filterSort';
const Container = styled.div`
display: flex;
@@ -21,14 +21,14 @@ export type VariantAnalysisAnalyzedReposProps = {
repositoryStates?: VariantAnalysisScannedRepositoryState[];
repositoryResults?: VariantAnalysisScannedRepositoryResult[];
searchValue?: string;
filterSortState?: RepositoriesFilterSortState;
}
export const VariantAnalysisAnalyzedRepos = ({
variantAnalysis,
repositoryStates,
repositoryResults,
searchValue,
filterSortState,
}: VariantAnalysisAnalyzedReposProps) => {
const repositoryStateById = useMemo(() => {
const map = new Map<number, VariantAnalysisScannedRepositoryState>();
@@ -47,14 +47,10 @@ export const VariantAnalysisAnalyzedRepos = ({
}, [repositoryResults]);
const repositories = useMemo(() => {
if (searchValue) {
return variantAnalysis.scannedRepos?.filter((repoTask) => {
return matchesSearchValue(repoTask.repository, searchValue);
});
}
return variantAnalysis.scannedRepos;
}, [searchValue, variantAnalysis.scannedRepos]);
return variantAnalysis.scannedRepos?.filter((repoTask) => {
return matchesFilter(repoTask.repository, filterSortState);
})?.sort(compareWithResults(filterSortState));
}, [filterSortState, variantAnalysis.scannedRepos]);
return (
<Container>

View File

@@ -11,7 +11,8 @@ import {
import { VariantAnalysisAnalyzedRepos } from './VariantAnalysisAnalyzedRepos';
import { Alert } from '../common';
import { VariantAnalysisSkippedRepositoriesTab } from './VariantAnalysisSkippedRepositoriesTab';
import { RepositoriesSearch } from './RepositoriesSearch';
import { defaultFilterSortState, RepositoriesFilterSortState } from './filterSort';
import { RepositoriesSearchSortRow } from './RepositoriesSearchSortRow';
export type VariantAnalysisOutcomePanelProps = {
variantAnalysis: VariantAnalysis;
@@ -44,7 +45,7 @@ export const VariantAnalysisOutcomePanels = ({
repositoryStates,
repositoryResults,
}: VariantAnalysisOutcomePanelProps) => {
const [searchValue, setSearchValue] = useState('');
const [filterSortState, setFilterSortState] = useState<RepositoriesFilterSortState>(defaultFilterSortState);
const noCodeqlDbRepos = variantAnalysis.skippedRepos?.noCodeqlDbRepos;
const notFoundRepos = variantAnalysis.skippedRepos?.notFoundRepos;
@@ -74,12 +75,12 @@ export const VariantAnalysisOutcomePanels = ({
return (
<>
{warnings}
<RepositoriesSearch value={searchValue} onChange={setSearchValue} />
<RepositoriesSearchSortRow value={filterSortState} onChange={setFilterSortState} />
<VariantAnalysisAnalyzedRepos
variantAnalysis={variantAnalysis}
repositoryStates={repositoryStates}
repositoryResults={repositoryResults}
searchValue={searchValue}
filterSortState={filterSortState}
/>
</>
);
@@ -88,7 +89,7 @@ export const VariantAnalysisOutcomePanels = ({
return (
<>
{warnings}
<RepositoriesSearch value={searchValue} onChange={setSearchValue} />
<RepositoriesSearchSortRow value={filterSortState} onChange={setFilterSortState} />
<VSCodePanels>
<Tab>
Analyzed
@@ -111,7 +112,7 @@ export const VariantAnalysisOutcomePanels = ({
variantAnalysis={variantAnalysis}
repositoryStates={repositoryStates}
repositoryResults={repositoryResults}
searchValue={searchValue}
filterSortState={filterSortState}
/>
</VSCodePanelView>
{notFoundRepos?.repositoryCount &&
@@ -120,7 +121,7 @@ export const VariantAnalysisOutcomePanels = ({
alertTitle='No access'
alertMessage='The following repositories could not be scanned because you do not have read access.'
skippedRepositoryGroup={notFoundRepos}
searchValue={searchValue}
filterSortState={filterSortState}
/>
</VSCodePanelView>}
{noCodeqlDbRepos?.repositoryCount &&
@@ -129,7 +130,7 @@ export const VariantAnalysisOutcomePanels = ({
alertTitle='No database'
alertMessage='The following repositories could not be scanned because they do not have an available CodeQL database.'
skippedRepositoryGroup={noCodeqlDbRepos}
searchValue={searchValue}
filterSortState={filterSortState}
/>
</VSCodePanelView>}
</VSCodePanels>

View File

@@ -4,14 +4,14 @@ 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';
import { compareRepository, matchesFilter, RepositoriesFilterSortState } from './filterSort';
export type VariantAnalysisSkippedRepositoriesTabProps = {
alertTitle: string,
alertMessage: string,
skippedRepositoryGroup: VariantAnalysisSkippedRepositoryGroup,
searchValue?: string,
filterSortState?: RepositoriesFilterSortState,
};
function getSkipReasonAlert(
@@ -43,17 +43,13 @@ export const VariantAnalysisSkippedRepositoriesTab = ({
alertTitle,
alertMessage,
skippedRepositoryGroup,
searchValue,
filterSortState,
}: VariantAnalysisSkippedRepositoriesTabProps) => {
const repositories = useMemo(() => {
if (searchValue) {
return skippedRepositoryGroup.repositories?.filter((repo) => {
return matchesSearchValue(repo, searchValue);
});
}
return skippedRepositoryGroup.repositories;
}, [searchValue, skippedRepositoryGroup.repositories]);
return skippedRepositoryGroup.repositories?.filter((repo) => {
return matchesFilter(repo, filterSortState);
})?.sort(compareRepository(filterSortState));
}, [filterSortState, skippedRepositoryGroup.repositories]);
return (
<Container>

View File

@@ -9,6 +9,7 @@ import { VariantAnalysisAnalyzedRepos, VariantAnalysisAnalyzedReposProps } from
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 { defaultFilterSortState, SortKey } from '../filterSort';
describe(VariantAnalysisAnalyzedRepos.name, () => {
const defaultVariantAnalysis = createMockVariantAnalysis(VariantAnalysisStatus.InProgress, [
@@ -19,7 +20,9 @@ describe(VariantAnalysisAnalyzedRepos.name, () => {
id: 1,
fullName: 'octodemo/hello-world-1',
private: false,
stargazersCount: 5_000,
},
resultCount: undefined,
analysisStatus: VariantAnalysisRepoStatus.Pending,
},
{
@@ -29,7 +32,9 @@ describe(VariantAnalysisAnalyzedRepos.name, () => {
id: 2,
fullName: 'octodemo/hello-world-2',
private: false,
stargazersCount: 20_000,
},
resultCount: 200,
analysisStatus: VariantAnalysisRepoStatus.Succeeded,
},
{
@@ -39,7 +44,9 @@ describe(VariantAnalysisAnalyzedRepos.name, () => {
id: 3,
fullName: 'octodemo/hello-world-3',
private: true,
stargazersCount: 20,
},
resultCount: undefined,
analysisStatus: VariantAnalysisRepoStatus.Failed,
},
{
@@ -49,9 +56,35 @@ describe(VariantAnalysisAnalyzedRepos.name, () => {
id: 4,
fullName: 'octodemo/hello-world-4',
private: false,
stargazersCount: 8_000,
},
resultCount: undefined,
analysisStatus: VariantAnalysisRepoStatus.InProgress,
},
{
...createMockScannedRepo(),
repository: {
...createMockRepositoryWithMetadata(),
id: 5,
fullName: 'octodemo/hello-world-5',
private: false,
stargazersCount: 50_000,
},
resultCount: 55_323,
analysisStatus: VariantAnalysisRepoStatus.Succeeded,
},
{
...createMockScannedRepo(),
repository: {
...createMockRepositoryWithMetadata(),
id: 6,
fullName: 'octodemo/hello-world-6',
private: false,
stargazersCount: 1,
},
resultCount: 10_000,
analysisStatus: VariantAnalysisRepoStatus.Succeeded,
},
]);
const render = (props: Partial<VariantAnalysisAnalyzedReposProps> = {}) => {
@@ -110,7 +143,10 @@ describe(VariantAnalysisAnalyzedRepos.name, () => {
it('uses the search value', () => {
render({
searchValue: 'world-2',
filterSortState: {
...defaultFilterSortState,
searchValue: 'world-2',
}
});
expect(screen.queryByText('octodemo/hello-world-1')).not.toBeInTheDocument();
@@ -118,4 +154,42 @@ describe(VariantAnalysisAnalyzedRepos.name, () => {
expect(screen.queryByText('octodemo/hello-world-3')).not.toBeInTheDocument();
expect(screen.queryByText('octodemo/hello-world-4')).not.toBeInTheDocument();
});
it('uses the sort key', async () => {
render({
filterSortState: {
...defaultFilterSortState,
sortKey: SortKey.Stars,
}
});
const rows = screen.queryAllByRole('button');
expect(rows).toHaveLength(6);
expect(rows[0]).toHaveTextContent('octodemo/hello-world-5');
expect(rows[1]).toHaveTextContent('octodemo/hello-world-2');
expect(rows[2]).toHaveTextContent('octodemo/hello-world-4');
expect(rows[3]).toHaveTextContent('octodemo/hello-world-1');
expect(rows[4]).toHaveTextContent('octodemo/hello-world-3');
expect(rows[5]).toHaveTextContent('octodemo/hello-world-6');
});
it('uses the results count sort key', async () => {
render({
filterSortState: {
...defaultFilterSortState,
sortKey: SortKey.ResultsCount,
}
});
const rows = screen.queryAllByRole('button');
expect(rows).toHaveLength(6);
expect(rows[0]).toHaveTextContent('octodemo/hello-world-5');
expect(rows[1]).toHaveTextContent('octodemo/hello-world-6');
expect(rows[2]).toHaveTextContent('octodemo/hello-world-2');
expect(rows[3]).toHaveTextContent('octodemo/hello-world-1');
expect(rows[4]).toHaveTextContent('octodemo/hello-world-3');
expect(rows[5]).toHaveTextContent('octodemo/hello-world-4');
});
});

View File

@@ -1,6 +1,7 @@
import * as React from 'react';
import { render as reactRender, screen } from '@testing-library/react';
import { VariantAnalysisSkippedRepositoriesTab, VariantAnalysisSkippedRepositoriesTabProps } from '../VariantAnalysisSkippedRepositoriesTab';
import { defaultFilterSortState, SortKey } from '../filterSort';
describe(VariantAnalysisSkippedRepositoriesTab.name, () => {
const render = (props: VariantAnalysisSkippedRepositoriesTabProps) =>
@@ -116,11 +117,81 @@ describe(VariantAnalysisSkippedRepositoriesTab.name, () => {
},
],
},
searchValue: 'world',
filterSortState: {
...defaultFilterSortState,
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();
});
it('uses the sort key', 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',
stargazersCount: 300,
},
{
fullName: 'octodemo/hello-galaxy',
stargazersCount: 50,
},
{
fullName: 'octodemo/hello-universe',
stargazersCount: 500,
},
],
},
filterSortState: {
...defaultFilterSortState,
sortKey: SortKey.Stars,
}
});
const rows = screen.queryAllByRole('button');
expect(rows).toHaveLength(3);
expect(rows[0]).toHaveTextContent('octodemo/hello-universe');
expect(rows[1]).toHaveTextContent('octodemo/hello-world');
expect(rows[2]).toHaveTextContent('octodemo/hello-galaxy');
});
it('does not use the result count sort key', 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',
},
],
},
filterSortState: {
...defaultFilterSortState,
sortKey: SortKey.ResultsCount,
}
});
const rows = screen.queryAllByRole('button');
expect(rows).toHaveLength(3);
expect(rows[0]).toHaveTextContent('octodemo/hello-galaxy');
expect(rows[1]).toHaveTextContent('octodemo/hello-universe');
expect(rows[2]).toHaveTextContent('octodemo/hello-world');
});
});

View File

@@ -1,12 +1,11 @@
import { matchesSearchValue } from '../filterSort';
import { compareRepository, compareWithResults, defaultFilterSortState, matchesFilter, SortKey } from '../filterSort';
describe(matchesSearchValue.name, () => {
describe(matchesFilter.name, () => {
const repository = {
fullName: 'github/codeql'
};
const testCases = [
{ searchValue: undefined, matches: true },
{ searchValue: '', matches: true },
{ searchValue: 'github/codeql', matches: true },
{ searchValue: 'github', matches: true },
@@ -22,6 +21,259 @@ describe(matchesSearchValue.name, () => {
];
test.each(testCases)('returns $matches if searching for $searchValue', ({ searchValue, matches }) => {
expect(matchesSearchValue(repository, searchValue)).toBe(matches);
expect(matchesFilter(repository, {
...defaultFilterSortState,
searchValue,
})).toBe(matches);
});
});
describe(compareRepository.name, () => {
describe('when sort key is undefined', () => {
const sorter = compareRepository(undefined);
const left = {
fullName: 'github/galaxy'
};
const right = {
fullName: 'github/world'
};
it('compares correctly', () => {
expect(sorter(left, right)).toBeLessThan(0);
});
it('compares the inverse correctly', () => {
expect(sorter(right, left)).toBeGreaterThan(0);
});
it('compares equal values correctly', () => {
expect(sorter(left, left)).toBe(0);
});
});
describe('when sort key is name', () => {
const sorter = compareRepository({
...defaultFilterSortState,
sortKey: SortKey.Name,
});
const left = {
fullName: 'github/galaxy'
};
const right = {
fullName: 'github/world'
};
it('compares correctly', () => {
expect(sorter(left, right)).toBeLessThan(0);
});
it('compares the inverse correctly', () => {
expect(sorter(right, left)).toBeGreaterThan(0);
});
it('compares equal values correctly', () => {
expect(sorter(left, left)).toBe(0);
});
});
describe('when sort key is stars', () => {
const sorter = compareRepository({
...defaultFilterSortState,
sortKey: SortKey.Stars,
});
const left = {
fullName: 'github/galaxy',
stargazersCount: 1,
};
const right = {
fullName: 'github/world',
stargazersCount: 10,
};
it('compares correctly', () => {
expect(sorter(left, right)).toBeGreaterThan(0);
});
it('compares the inverse correctly', () => {
expect(sorter(right, left)).toBeLessThan(0);
});
it('compares equal values correctly', () => {
expect(sorter(left, left)).toBe(0);
});
it('compares equal single values correctly', () => {
expect(sorter(left, {
...right,
stargazersCount: left.stargazersCount,
})).toBeLessThan(0);
});
it('compares missing single values correctly', () => {
expect(sorter(left, {
...right,
stargazersCount: undefined,
})).toBeLessThan(0);
});
});
describe('when sort key is last updated', () => {
const sorter = compareRepository({
...defaultFilterSortState,
sortKey: SortKey.LastUpdated,
});
const left = {
fullName: 'github/galaxy',
updatedAt: '2020-01-01T00:00:00Z',
};
const right = {
fullName: 'github/world',
updatedAt: '2021-01-01T00:00:00Z',
};
it('compares correctly', () => {
expect(sorter(left, right)).toBeGreaterThan(0);
});
it('compares the inverse correctly', () => {
expect(sorter(right, left)).toBeLessThan(0);
});
it('compares equal values correctly', () => {
expect(sorter(left, left)).toBe(0);
});
it('compares equal single values correctly', () => {
expect(sorter(left, {
...right,
updatedAt: left.updatedAt,
})).toBeLessThan(0);
});
it('compares missing single values correctly', () => {
expect(sorter({
...left,
updatedAt: undefined,
}, right)).toBeGreaterThan(0);
});
});
});
describe(compareWithResults.name, () => {
describe('when sort key is undefined', () => {
const sorter = compareWithResults(undefined);
const left = {
repository: {
fullName: 'github/galaxy',
},
};
const right = {
repository: {
fullName: 'github/world',
},
};
it('compares correctly', () => {
expect(sorter(left, right)).toBeLessThan(0);
});
});
describe('when sort key is stars', () => {
const sorter = compareWithResults({
...defaultFilterSortState,
sortKey: SortKey.Stars,
});
const left = {
repository: {
fullName: 'github/galaxy',
stargazersCount: 1,
},
};
const right = {
repository: {
fullName: 'github/world',
stargazersCount: 10,
},
};
it('compares correctly', () => {
expect(sorter(left, right)).toBeGreaterThan(0);
});
});
describe('when sort key is last updated', () => {
const sorter = compareWithResults({
...defaultFilterSortState,
sortKey: SortKey.LastUpdated,
});
const left = {
repository: {
fullName: 'github/galaxy',
updatedAt: '2020-01-01T00:00:00Z',
},
};
const right = {
repository: {
fullName: 'github/world',
updatedAt: '2021-01-01T00:00:00Z',
},
};
it('compares correctly', () => {
expect(sorter(left, right)).toBeGreaterThan(0);
});
});
describe('when sort key is results count', () => {
const sorter = compareWithResults({
...defaultFilterSortState,
sortKey: SortKey.ResultsCount,
});
const left = {
repository: {
fullName: 'github/galaxy',
},
resultCount: 10,
};
const right = {
repository: {
fullName: 'github/world',
},
resultCount: 100,
};
it('compares correctly', () => {
expect(sorter(left, right)).toBeGreaterThan(0);
});
it('compares the inverse correctly', () => {
expect(sorter(right, left)).toBeLessThan(0);
});
it('compares equal values correctly', () => {
expect(sorter(left, left)).toBe(0);
});
it('compares equal single values correctly', () => {
expect(sorter(left, {
...right,
resultCount: left.resultCount,
})).toBeLessThan(0);
});
it('compares missing single values correctly', () => {
expect(sorter({
...left,
resultCount: undefined,
}, right)).toBeGreaterThan(0);
});
});
});

View File

@@ -1,9 +1,73 @@
import { Repository } from '../../remote-queries/shared/repository';
import { Repository, RepositoryWithMetadata } from '../../remote-queries/shared/repository';
import { parseDate } from '../../pure/date';
export function matchesSearchValue(repo: Pick<Repository, 'fullName'>, searchValue: string | undefined): boolean {
if (!searchValue) {
export enum SortKey {
Name = 'name',
Stars = 'stars',
LastUpdated = 'lastUpdated',
ResultsCount = 'resultsCount',
}
export type RepositoriesFilterSortState = {
searchValue: string;
sortKey: SortKey;
}
export const defaultFilterSortState: RepositoriesFilterSortState = {
searchValue: '',
sortKey: SortKey.Name,
};
export function matchesFilter(repo: Pick<Repository, 'fullName'>, filterSortState: RepositoriesFilterSortState | undefined): boolean {
if (!filterSortState) {
return true;
}
return repo.fullName.toLowerCase().includes(searchValue.toLowerCase());
return repo.fullName.toLowerCase().includes(filterSortState.searchValue.toLowerCase());
}
type SortableRepository = Pick<Repository, 'fullName'> & Partial<Pick<RepositoryWithMetadata, 'stargazersCount' | 'updatedAt'>>;
export function compareRepository(filterSortState: RepositoriesFilterSortState | undefined): (left: SortableRepository, right: SortableRepository) => number {
return (left: SortableRepository, right: SortableRepository) => {
// Highest to lowest
if (filterSortState?.sortKey === SortKey.Stars) {
const stargazersCount = (right.stargazersCount ?? 0) - (left.stargazersCount ?? 0);
if (stargazersCount !== 0) {
return stargazersCount;
}
}
// Newest to oldest
if (filterSortState?.sortKey === SortKey.LastUpdated) {
const lastUpdated = (parseDate(right.updatedAt)?.getTime() ?? 0) - (parseDate(left.updatedAt)?.getTime() ?? 0);
if (lastUpdated !== 0) {
return lastUpdated;
}
}
// Fall back on name compare
return left.fullName.localeCompare(right.fullName, undefined, { sensitivity: 'base' });
};
}
type SortableResult = {
repository: SortableRepository;
resultCount?: number;
}
export function compareWithResults(filterSortState: RepositoriesFilterSortState | undefined): (left: SortableResult, right: SortableResult) => number {
const fallbackSort = compareRepository(filterSortState);
return (left: SortableResult, right: SortableResult) => {
// Highest to lowest
if (filterSortState?.sortKey === SortKey.ResultsCount) {
const resultCount = (right.resultCount ?? 0) - (left.resultCount ?? 0);
if (resultCount !== 0) {
return resultCount;
}
}
return fallbackSort(left.repository, right.repository);
};
}