Add metadata to repository row

This will add the star count and last updated fields to the repository
row. We are able to re-use some components from remote queries, but we
cannot re-use `LastUpdated` since it requires a numeric duration, while
we are dealing with an ISO8601 date.
This commit is contained in:
Koen Vlaswinkel
2022-11-01 16:56:11 +01:00
parent fcb1ef4fd7
commit be62bd3b25
12 changed files with 193 additions and 15 deletions

View File

@@ -29,16 +29,19 @@ export function humanizeRelativeTime(relativeTimeMillis?: number) {
return ''; return '';
} }
// If the time is in the past, we need -3_600_035 to be formatted as "1 hour ago" instead of "2 hours ago"
const round = relativeTimeMillis < 0 ? Math.ceil : Math.floor;
if (Math.abs(relativeTimeMillis) < ONE_HOUR_IN_MS) { if (Math.abs(relativeTimeMillis) < ONE_HOUR_IN_MS) {
return durationFormatter.format(Math.floor(relativeTimeMillis / ONE_MINUTE_IN_MS), 'minute'); return durationFormatter.format(round(relativeTimeMillis / ONE_MINUTE_IN_MS), 'minute');
} else if (Math.abs(relativeTimeMillis) < ONE_DAY_IN_MS) { } else if (Math.abs(relativeTimeMillis) < ONE_DAY_IN_MS) {
return durationFormatter.format(Math.floor(relativeTimeMillis / ONE_HOUR_IN_MS), 'hour'); return durationFormatter.format(round(relativeTimeMillis / ONE_HOUR_IN_MS), 'hour');
} else if (Math.abs(relativeTimeMillis) < ONE_MONTH_IN_MS) { } else if (Math.abs(relativeTimeMillis) < ONE_MONTH_IN_MS) {
return durationFormatter.format(Math.floor(relativeTimeMillis / ONE_DAY_IN_MS), 'day'); return durationFormatter.format(round(relativeTimeMillis / ONE_DAY_IN_MS), 'day');
} else if (Math.abs(relativeTimeMillis) < ONE_YEAR_IN_MS) { } else if (Math.abs(relativeTimeMillis) < ONE_YEAR_IN_MS) {
return durationFormatter.format(Math.floor(relativeTimeMillis / ONE_MONTH_IN_MS), 'month'); return durationFormatter.format(round(relativeTimeMillis / ONE_MONTH_IN_MS), 'month');
} else { } else {
return durationFormatter.format(Math.floor(relativeTimeMillis / ONE_YEAR_IN_MS), 'year'); return durationFormatter.format(round(relativeTimeMillis / ONE_YEAR_IN_MS), 'year');
} }
} }

View File

@@ -0,0 +1,20 @@
import React from 'react';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { LastUpdated as LastUpdatedComponent } from '../../view/common/LastUpdated';
export default {
title: 'Last Updated',
component: LastUpdatedComponent,
} as ComponentMeta<typeof LastUpdatedComponent>;
const Template: ComponentStory<typeof LastUpdatedComponent> = (args) => (
<LastUpdatedComponent {...args} />
);
export const LastUpdated = Template.bind({});
LastUpdated.args = {
lastUpdated: new Date(Date.now() - 3_600_000).toISOString(), // 1 hour ago
};

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { ComponentStory, ComponentMeta } from '@storybook/react'; import { ComponentStory, ComponentMeta } from '@storybook/react';
import StarCountComponent from '../../view/remote-queries/StarCount'; import StarCountComponent from '../../view/common/StarCount';
export default { export default {
title: 'Star Count', title: 'Star Count',

View File

@@ -5,7 +5,7 @@ import { ComponentStory, ComponentMeta } from '@storybook/react';
import LastUpdatedComponent from '../../view/remote-queries/LastUpdated'; import LastUpdatedComponent from '../../view/remote-queries/LastUpdated';
export default { export default {
title: 'Last Updated', title: 'MRVA/Last Updated',
component: LastUpdatedComponent, component: LastUpdatedComponent,
} as ComponentMeta<typeof LastUpdatedComponent>; } as ComponentMeta<typeof LastUpdatedComponent>;

View File

@@ -37,6 +37,8 @@ Pending.args = {
id: 63537249, id: 63537249,
fullName: 'facebook/create-react-app', fullName: 'facebook/create-react-app',
private: false, private: false,
stargazersCount: 97_761,
updatedAt: '2022-11-01T13:07:05Z',
}, },
status: VariantAnalysisRepoStatus.Pending, status: VariantAnalysisRepoStatus.Pending,
}; };
@@ -104,6 +106,8 @@ SkippedPublic.args = {
...createMockRepositoryWithMetadata(), ...createMockRepositoryWithMetadata(),
fullName: 'octodemo/hello-globe', fullName: 'octodemo/hello-globe',
private: false, private: false,
stargazersCount: 83_372,
updatedAt: '2022-10-28T14:10:35Z',
} }
}; };
@@ -113,5 +117,7 @@ SkippedPrivate.args = {
...createMockRepositoryWithMetadata(), ...createMockRepositoryWithMetadata(),
fullName: 'octodemo/hello-globe', fullName: 'octodemo/hello-globe',
private: true, private: true,
stargazersCount: 83_372,
updatedAt: '2022-05-28T14:10:35Z',
} }
}; };

View File

@@ -0,0 +1,44 @@
import * as React from 'react';
import { useMemo } from 'react';
import styled from 'styled-components';
import { parseDate } from '../../pure/date';
import { humanizeRelativeTime } from '../../pure/time';
import { Codicon } from './icon';
const IconContainer = styled.span`
flex-grow: 0;
text-align: right;
margin-right: 0;
`;
const Duration = styled.span`
display: inline-block;
text-align: left;
width: 8em;
margin-left: 0.5em;
`;
type Props = {
lastUpdated?: string | null;
};
export const LastUpdated = ({ lastUpdated }: Props) => {
const date = useMemo(() => parseDate(lastUpdated), [lastUpdated]);
if (!date) {
return null;
}
return (
<div>
<IconContainer>
<Codicon name="repo-push" label="Last updated" />
</IconContainer>
<Duration>
{humanizeRelativeTime(date.getTime() - Date.now())}
</Duration>
</div>
);
};

View File

@@ -1,6 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import { StarIcon } from '@primer/octicons-react';
import styled from 'styled-components'; import styled from 'styled-components';
import { Codicon } from './icon';
const Star = styled.span` const Star = styled.span`
flex-grow: 2; flex-grow: 2;
@@ -9,19 +9,22 @@ const Star = styled.span`
`; `;
const Count = styled.span` const Count = styled.span`
display: inline-block;
text-align: left; text-align: left;
width: 2em; width: 2em;
margin-left: 0.5em; margin-left: 0.5em;
margin-right: 1.5em; margin-right: 1.5em;
`; `;
type Props = { starCount?: number }; type Props = {
starCount?: number;
};
const StarCount = ({ starCount }: Props) => ( const StarCount = ({ starCount }: Props) => (
Number.isFinite(starCount) ? ( Number.isFinite(starCount) ? (
<> <>
<Star> <Star>
<StarIcon size={16} /> <Codicon name="star-empty" label="Stars count" />
</Star> </Star>
<Count> <Count>
{displayStars(starCount!)} {displayStars(starCount!)}

View File

@@ -0,0 +1,43 @@
import * as React from 'react';
import { render, screen } from '@testing-library/react';
import StarCount from '../StarCount';
describe(StarCount.name, () => {
it('renders undefined stars correctly', () => {
const { container } = render(<StarCount />);
expect(container).toBeEmptyDOMElement();
});
it('renders NaN stars correctly', () => {
const { container } = render(<StarCount starCount={NaN} />);
expect(container).toBeEmptyDOMElement();
});
const testCases = [
{ starCount: 0, expected: '0' },
{ starCount: 1, expected: '1' },
{ starCount: 15, expected: '15' },
{ starCount: 578, expected: '578' },
{ starCount: 999, expected: '999' },
{ starCount: 1_000, expected: '1000' },
{ starCount: 1_001, expected: '1.0k' },
{ starCount: 5_789, expected: '5.8k' },
{ starCount: 9_999, expected: '10.0k' },
{ starCount: 10_000, expected: '10.0k' },
{ starCount: 10_001, expected: '10k' },
{ starCount: 73_543, expected: '74k' },
{ starCount: 155_783, expected: '156k' },
{ starCount: 999_999, expected: '1000k' },
{ starCount: 1_000_000, expected: '1000k' },
{ starCount: 1_000_001, expected: '1000k' },
];
test.each(testCases)('renders $starCount stars as $expected', ({ starCount, expected }) => {
render(<StarCount starCount={starCount} />);
expect(screen.getByText(expected)).toBeInTheDocument();
});
});

View File

@@ -15,7 +15,7 @@ import { AlertIcon, CodeSquareIcon, FileCodeIcon, RepoIcon, TerminalIcon } from
import AnalysisAlertResult from './AnalysisAlertResult'; import AnalysisAlertResult from './AnalysisAlertResult';
import RawResultsTable from './RawResultsTable'; import RawResultsTable from './RawResultsTable';
import RepositoriesSearch from './RepositoriesSearch'; import RepositoriesSearch from './RepositoriesSearch';
import StarCount from './StarCount'; import StarCount from '../common/StarCount';
import SortRepoFilter, { Sort, sorter } from './SortRepoFilter'; import SortRepoFilter, { Sort, sorter } from './SortRepoFilter';
import LastUpdated from './LastUpdated'; import LastUpdated from './LastUpdated';
import RepoListCopyButton from './RepoListCopyButton'; import RepoListCopyButton from './RepoListCopyButton';

View File

@@ -9,10 +9,12 @@ import {
} from '../../remote-queries/shared/variant-analysis'; } from '../../remote-queries/shared/variant-analysis';
import { formatDecimal } from '../../pure/number'; import { formatDecimal } from '../../pure/number';
import { Codicon, ErrorIcon, LoadingIcon, SuccessIcon, WarningIcon } from '../common'; import { Codicon, ErrorIcon, LoadingIcon, SuccessIcon, WarningIcon } from '../common';
import { Repository } from '../../remote-queries/shared/repository'; import { RepositoryWithMetadata } from '../../remote-queries/shared/repository';
import { AnalysisAlert, AnalysisRawResults } from '../../remote-queries/shared/analysis-result'; import { AnalysisAlert, AnalysisRawResults } from '../../remote-queries/shared/analysis-result';
import { vscode } from '../vscode-api'; import { vscode } from '../vscode-api';
import { AnalyzedRepoItemContent } from './AnalyzedRepoItemContent'; import { AnalyzedRepoItemContent } from './AnalyzedRepoItemContent';
import StarCount from '../common/StarCount';
import { LastUpdated } from '../common/LastUpdated';
// This will ensure that these icons have a className which we can use in the TitleContainer // This will ensure that these icons have a className which we can use in the TitleContainer
const ExpandCollapseCodicon = styled(Codicon)``; const ExpandCollapseCodicon = styled(Codicon)``;
@@ -21,6 +23,7 @@ const TitleContainer = styled.button`
display: flex; display: flex;
gap: 0.5em; gap: 0.5em;
align-items: center; align-items: center;
width: 100%;
color: var(--vscode-editor-foreground); color: var(--vscode-editor-foreground);
background-color: transparent; background-color: transparent;
@@ -41,6 +44,11 @@ const VisibilityText = styled.span`
color: var(--vscode-descriptionForeground); color: var(--vscode-descriptionForeground);
`; `;
const MetadataContainer = styled.div`
display: flex;
margin-left: auto;
`;
type VisibilityProps = { type VisibilityProps = {
isPrivate?: boolean; isPrivate?: boolean;
} }
@@ -65,7 +73,7 @@ const getErrorLabel = (status: VariantAnalysisRepoStatus.Failed | VariantAnalysi
export type RepoRowProps = { export type RepoRowProps = {
// Only fullName is required // Only fullName is required
repository: Partial<Repository> & Pick<Repository, 'fullName'>; repository: Partial<RepositoryWithMetadata> & Pick<RepositoryWithMetadata, 'fullName'>;
status?: VariantAnalysisRepoStatus; status?: VariantAnalysisRepoStatus;
downloadStatus?: VariantAnalysisScannedRepositoryDownloadStatus; downloadStatus?: VariantAnalysisScannedRepositoryDownloadStatus;
resultCount?: number; resultCount?: number;
@@ -131,6 +139,10 @@ export const RepoRow = ({
{!status && <WarningIcon />} {!status && <WarningIcon />}
</span> </span>
{downloadStatus === VariantAnalysisScannedRepositoryDownloadStatus.InProgress && <LoadingIcon label="Downloading" />} {downloadStatus === VariantAnalysisScannedRepositoryDownloadStatus.InProgress && <LoadingIcon label="Downloading" />}
<MetadataContainer>
<div><StarCount starCount={repository.stargazersCount} /></div>
<LastUpdated lastUpdated={repository.updatedAt} />
</MetadataContainer>
</TitleContainer> </TitleContainer>
{isExpanded && expandableContentLoaded && {isExpanded && expandableContentLoaded &&
<AnalyzedRepoItemContent status={status} interpretedResults={interpretedResults} rawResults={rawResults} />} <AnalyzedRepoItemContent status={status} interpretedResults={interpretedResults} rawResults={rawResults} />}

View File

@@ -12,6 +12,7 @@ const Container = styled.div`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5em; gap: 0.5em;
width: 100%;
`; `;
export type VariantAnalysisAnalyzedReposProps = { export type VariantAnalysisAnalyzedReposProps = {

View File

@@ -28,8 +28,8 @@ describe(RepoRow.name, () => {
expect(screen.getByText('-')).toBeInTheDocument(); expect(screen.getByText('-')).toBeInTheDocument();
expect(screen.queryByRole('img', { expect(screen.queryByRole('img', {
// There should not be any icons, except the expand icon // There should not be any icons, except for the icons which are always shown
name: (name) => name.toLowerCase() !== 'expand', name: (name) => !['expand', 'stars count', 'last updated'].includes(name.toLowerCase()),
})).not.toBeInTheDocument(); })).not.toBeInTheDocument();
expect(screen.getByRole<HTMLButtonElement>('button', { expect(screen.getByRole<HTMLButtonElement>('button', {
@@ -153,6 +153,52 @@ describe(RepoRow.name, () => {
expect(screen.queryByText('private')).not.toBeInTheDocument(); expect(screen.queryByText('private')).not.toBeInTheDocument();
}); });
it('shows stars', () => {
render({
repository: {
...createMockRepositoryWithMetadata(),
stargazersCount: 57_378,
}
});
expect(screen.getByText('57k')).toBeInTheDocument();
expect(screen.getByRole('img', {
name: 'Stars count',
})).toBeInTheDocument();
});
it('shows updated at', () => {
render({
repository: {
...createMockRepositoryWithMetadata(),
// 1 month ago
updatedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30).toISOString(),
}
});
expect(screen.getByText('last month')).toBeInTheDocument();
expect(screen.getByRole('img', {
name: 'Last updated',
})).toBeInTheDocument();
});
it('does not show star count and updated at when unknown', () => {
render({
repository: {
id: undefined,
fullName: 'octodemo/hello-world-1',
private: undefined,
}
});
expect(screen.queryByRole('img', {
name: 'Stars count',
})).not.toBeInTheDocument();
expect(screen.queryByRole('img', {
name: 'Last updated',
})).not.toBeInTheDocument();
});
it('can expand the repo item', async () => { it('can expand the repo item', async () => {
render({ render({
status: VariantAnalysisRepoStatus.TimedOut, status: VariantAnalysisRepoStatus.TimedOut,