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