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:
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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',
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
};
|
||||
|
||||
44
extensions/ql-vscode/src/view/common/LastUpdated.tsx
Normal file
44
extensions/ql-vscode/src/view/common/LastUpdated.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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!)}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -12,6 +12,7 @@ const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export type VariantAnalysisAnalyzedReposProps = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user