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 '';
}
// 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) {
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) {
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) {
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) {
return durationFormatter.format(Math.floor(relativeTimeMillis / ONE_MONTH_IN_MS), 'month');
return durationFormatter.format(round(relativeTimeMillis / ONE_MONTH_IN_MS), 'month');
} 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 StarCountComponent from '../../view/remote-queries/StarCount';
import StarCountComponent from '../../view/common/StarCount';
export default {
title: 'Star Count',

View File

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

View File

@@ -37,6 +37,8 @@ Pending.args = {
id: 63537249,
fullName: 'facebook/create-react-app',
private: false,
stargazersCount: 97_761,
updatedAt: '2022-11-01T13:07:05Z',
},
status: VariantAnalysisRepoStatus.Pending,
};
@@ -104,6 +106,8 @@ SkippedPublic.args = {
...createMockRepositoryWithMetadata(),
fullName: 'octodemo/hello-globe',
private: false,
stargazersCount: 83_372,
updatedAt: '2022-10-28T14:10:35Z',
}
};
@@ -113,5 +117,7 @@ SkippedPrivate.args = {
...createMockRepositoryWithMetadata(),
fullName: 'octodemo/hello-globe',
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 { StarIcon } from '@primer/octicons-react';
import styled from 'styled-components';
import { Codicon } from './icon';
const Star = styled.span`
flex-grow: 2;
@@ -9,19 +9,22 @@ const Star = styled.span`
`;
const Count = styled.span`
display: inline-block;
text-align: left;
width: 2em;
margin-left: 0.5em;
margin-right: 1.5em;
`;
type Props = { starCount?: number };
type Props = {
starCount?: number;
};
const StarCount = ({ starCount }: Props) => (
Number.isFinite(starCount) ? (
<>
<Star>
<StarIcon size={16} />
<Codicon name="star-empty" label="Stars count" />
</Star>
<Count>
{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 RawResultsTable from './RawResultsTable';
import RepositoriesSearch from './RepositoriesSearch';
import StarCount from './StarCount';
import StarCount from '../common/StarCount';
import SortRepoFilter, { Sort, sorter } from './SortRepoFilter';
import LastUpdated from './LastUpdated';
import RepoListCopyButton from './RepoListCopyButton';

View File

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

View File

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

View File

@@ -28,8 +28,8 @@ describe(RepoRow.name, () => {
expect(screen.getByText('-')).toBeInTheDocument();
expect(screen.queryByRole('img', {
// There should not be any icons, except the expand icon
name: (name) => name.toLowerCase() !== 'expand',
// There should not be any icons, except for the icons which are always shown
name: (name) => !['expand', 'stars count', 'last updated'].includes(name.toLowerCase()),
})).not.toBeInTheDocument();
expect(screen.getByRole<HTMLButtonElement>('button', {
@@ -153,6 +153,52 @@ describe(RepoRow.name, () => {
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 () => {
render({
status: VariantAnalysisRepoStatus.TimedOut,