Skip to content

Commit

Permalink
Merge pull request #100 from sbv-world-health-org-metrics/add-topics-…
Browse files Browse the repository at this point in the history
…filter

Add Repository Topics
  • Loading branch information
ipc103 authored Mar 1, 2024
2 parents 95a48d4 + 17298dc commit bfd2fbc
Show file tree
Hide file tree
Showing 5 changed files with 148 additions and 55 deletions.
11 changes: 10 additions & 1 deletion ts-backend/src/fetchers/repository.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// Fetchers for repository data and metrics

import { Organization, Repository } from "@octokit/graphql-schema";
import { Fetcher, RepositoryResult } from "..";
import { Fetcher } from "..";
import { RepositoryResult } from '../../../types'

export const addRepositoriesToResult: Fetcher = async (
result,
Expand Down Expand Up @@ -48,6 +49,13 @@ export const addRepositoriesToResult: Fetcher = async (
collaborators {
totalCount
}
repositoryTopics(first: 20) {
nodes {
topic {
name
}
}
}
}
}
}
Expand All @@ -73,6 +81,7 @@ export const addRepositoriesToResult: Fetcher = async (
repositoryName: repo.name,
repoNameWithOwner: repo.nameWithOwner,
licenseName: repo.licenseInfo?.name || "No License",
topics: repo.repositoryTopics.nodes?.map((node) => node?.topic.name ),
forksCount: repo.forkCount,
watchersCount: repo.watchers.totalCount,
starsCount: repo.stargazerCount,
Expand Down
37 changes: 1 addition & 36 deletions ts-backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
addRepositoriesToResult,
} from "./fetchers";
import { checkRateLimit, CustomOctokit, personalOctokit } from "./lib/octokit";
import { RepositoryResult } from '../../types'

export interface Result {
meta: {
Expand All @@ -31,42 +32,6 @@ export interface Result {
repositories: Record<string, RepositoryResult>;
}

export interface RepositoryResult {
// Repo metadata
repositoryName: string;
repoNameWithOwner: string;
licenseName: string;

// Counts of various things
projectsCount: number;
projectsV2Count: number;
discussionsCount: number;
forksCount: number;
totalIssuesCount: number;
openIssuesCount: number;
closedIssuesCount: number;
totalPullRequestsCount: number;
openPullRequestsCount: number;
closedPullRequestsCount: number;
mergedPullRequestsCount: number;
watchersCount: number;
starsCount: number;
collaboratorsCount: number;

// Flags
discussionsEnabled: boolean;
projectsEnabled: boolean;
issuesEnabled: boolean;

// Calculated metrics
openIssuesAverageAge: number;
openIssuesMedianAge: number;
closedIssuesAverageAge: number;
closedIssuesMedianAge: number;
issuesResponseAverageAge: number;
issuesResponseMedianAge: number;
}

export type Fetcher = (
result: Result,
octokit: CustomOctokit,
Expand Down
36 changes: 36 additions & 0 deletions types/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
export interface RepositoryResult {
// Repo metadata
repositoryName: string;
repoNameWithOwner: string;
licenseName: string;
topics: string[];

// Counts of various things
projectsCount: number;
projectsV2Count: number;
discussionsCount: number;
forksCount: number;
totalIssuesCount: number;
openIssuesCount: number;
closedIssuesCount: number;
totalPullRequestsCount: number;
openPullRequestsCount: number;
closedPullRequestsCount: number;
mergedPullRequestsCount: number;
watchersCount: number;
starsCount: number;
collaboratorsCount: number;

// Flags
discussionsEnabled: boolean;
projectsEnabled: boolean;
issuesEnabled: boolean;

// Calculated metrics
openIssuesAverageAge: number;
openIssuesMedianAge: number;
closedIssuesAverageAge: number;
closedIssuesMedianAge: number;
issuesResponseAverageAge: number;
issuesResponseMedianAge: number;
}
79 changes: 61 additions & 18 deletions who-metrics-ui/src/components/RepositoriesTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,20 @@ import {
useRef,
useState
} from 'react';

import { RepositoryResult } from '../../../types';
import Data from '../data/data.json';
import TopicCell from './TopicCell';

const repos = Object.values(Data['repositories']);
type Repo = (typeof repos)[0];

function inputStopPropagation(event: React.KeyboardEvent<HTMLInputElement>) {
event.stopPropagation();
}

type Filter = {
repositoryName?: Record<string, boolean>;
licenseName?: Record<string, boolean>;
topics?: Record<string, boolean>;
collaboratorsCount?: Array<number | undefined>;
watchersCount?: Array<number | undefined>;
openIssuesCount?: Array<number | undefined>;
Expand Down Expand Up @@ -80,16 +82,22 @@ const millisecondsToDisplayString = (milliseconds: number) => {
};

// This selects a field to populate a dropdown with
const dropdownOptions = (field: keyof Repo, filter = ''): SelectOption[] =>
Array.from(new Set(repos.map((repo) => repo[field])))
.map((fieldName) => ({
const dropdownOptions = (field: keyof RepositoryResult, filter = ''): SelectOption[] => {
let options = []
if (field === 'topics'){
options = Array.from(new Set(repos.flatMap(repo => repo.topics))).sort()
} else {
options = Array.from(new Set(repos.map((repo) => repo[field])))
}
return options.map((fieldName) => ({
// some fields are boolean (hasXxEnabled), so we need to convert them to strings
label: typeof fieldName === 'boolean' ? fieldName.toString() : fieldName,
value: typeof fieldName === 'boolean' ? fieldName.toString() : fieldName,
}))
.filter((fieldName) =>
(fieldName.value as string).toLowerCase().includes(filter.toLowerCase()),
);
};

// Helper function to get the selected option value from a filter and field
const getSelectedOption = (
Expand All @@ -102,14 +110,14 @@ const getSelectedOption = (

// Renderer for the min/max filter inputs
const MinMaxRenderer: FC<{
headerCellProps: RenderHeaderCellProps<Repo>;
headerCellProps: RenderHeaderCellProps<RepositoryResult>;
filters: Filter;
updateFilters: ((filters: Filter) => void) &
((filters: (filters: Filter) => Filter) => void);
filterName: keyof Filter;
}> = ({ headerCellProps, filters, updateFilters, filterName }) => {
return (
<HeaderCellRenderer<Repo> {...headerCellProps}>
<HeaderCellRenderer<RepositoryResult> {...headerCellProps}>
{({ ...rest }) => (
<Box className="w-full">
<FormControl>
Expand Down Expand Up @@ -165,7 +173,7 @@ const MinMaxRenderer: FC<{

// Renderer for the searchable select filter
const SearchableSelectRenderer: FC<{
headerCellProps: RenderHeaderCellProps<Repo>;
headerCellProps: RenderHeaderCellProps<RepositoryResult>;
filters: Filter;
updateFilters: ((filters: Filter) => void) &
((filters: (filters: Filter) => Filter) => void);
Expand All @@ -175,7 +183,7 @@ const SearchableSelectRenderer: FC<{
const allSelectOptions = dropdownOptions(filterName, filteredOptions);

return (
<HeaderCellRenderer<Repo> {...headerCellProps}>
<HeaderCellRenderer<RepositoryResult> {...headerCellProps}>
{({ ...rest }) => (
<Box>
<TextInput
Expand Down Expand Up @@ -363,9 +371,9 @@ const HeaderCellRenderer = <R = unknown,>({
// re-created when filters are changed and filter loses focus
const FilterContext = createContext<Filter | undefined>(undefined);

type Comparator = (a: Repo, b: Repo) => number;
type Comparator = (a: RepositoryResult, b: RepositoryResult) => number;

const getComparator = (sortColumn: keyof Repo): Comparator => {
const getComparator = (sortColumn: keyof RepositoryResult): Comparator => {
switch (sortColumn) {
// number based sorting
case 'closedIssuesCount':
Expand Down Expand Up @@ -405,6 +413,16 @@ const getComparator = (sortColumn: keyof Repo): Comparator => {
.toLowerCase()
.localeCompare(b[sortColumn].toLowerCase());
};

// Multi option, alphabetical
case 'topics':
return (a, b) => {
const first = a[sortColumn].sort()[0]
const second = b[sortColumn].sort()[0]
if (!second) return -1;
if (!first) return 1;
return first.toLowerCase().localeCompare(second.toLowerCase())
};

default:
throw new Error(`unsupported sortColumn: "${sortColumn}"`);
Expand All @@ -419,10 +437,13 @@ const defaultFilters: Filter = {
licenseName: {
all: true,
},
topics: {
all: true,
}
};

// Helper for generating the csv blob
const generateCSV = (data: Repo[]): Blob => {
const generateCSV = (data: RepositoryResult[]): Blob => {
const output = json2csv(data);
return new Blob([output], { type: 'text/csv' });
};
Expand All @@ -431,7 +452,7 @@ const RepositoriesTable = () => {
const [globalFilters, setGlobalFilters] = useState<Filter>(defaultFilters);

// This needs a type, technically it's a Column but needs to be typed
const labels: Record<string, Column<Repo>> = {
const labels: Record<string, Column<RepositoryResult>> = {
Name: {
key: 'repositoryName',
name: 'Name',
Expand All @@ -455,6 +476,26 @@ const RepositoriesTable = () => {
</a>
),
},
Topics: {
key: 'topics',
name: 'Topics',
width: 275,
renderHeaderCell: (p) => {
return (
<SearchableSelectRenderer
headerCellProps={p}
filterName="topics"
filters={globalFilters}
updateFilters={setGlobalFilters}
/>
)
},
renderCell: (props) => {
// tabIndex === 0 is used as a proxy when the Cell is selected. See https://github.com/adazzle/react-data-grid/pull/3236
const isSelected = props.tabIndex === 0
return <TopicCell topics={props.row.topics} isSelected={isSelected} />
},
},
License: {
key: 'licenseName',
name: 'License',
Expand Down Expand Up @@ -703,14 +744,14 @@ const RepositoriesTable = () => {

const [sortColumns, setSortColumns] = useState<SortColumn[]>([]);

const sortRepos = (inputRepos: Repo[]) => {
const sortRepos = (inputRepos: RepositoryResult[]) => {
if (sortColumns.length === 0) {
return repos;
}

const sortedRows = [...inputRepos].sort((a, b) => {
for (const sort of sortColumns) {
const comparator = getComparator(sort.columnKey as keyof Repo);
const comparator = getComparator(sort.columnKey as keyof RepositoryResult);
const compResult = comparator(a, b);
if (compResult !== 0) {
return sort.direction === 'ASC' ? compResult : -compResult;
Expand Down Expand Up @@ -743,11 +784,13 @@ const RepositoriesTable = () => {
* This is kind of a mess, but it works
*/
const filterRepos = useCallback(
(inputRepos: Repo[]) => {
(inputRepos: RepositoryResult[]) => {
const result = inputRepos.filter((repo) => {
return (
((globalFilters.repositoryName?.[repo.repositoryName] ?? false) ||
(globalFilters.repositoryName?.['all'] ?? false)) &&
(( globalFilters.topics && Object.entries(globalFilters.topics).some(([selectedTopic, isSelected]) => isSelected && repo.topics.includes(selectedTopic))) ||
(globalFilters.topics?.['all'] ?? false)) &&
((globalFilters.licenseName?.[repo.licenseName] ?? false) ||
(globalFilters.licenseName?.['all'] ?? false)) &&
(globalFilters.collaboratorsCount
Expand Down Expand Up @@ -853,8 +896,8 @@ const RepositoriesTable = () => {
<Text>{subTitle()}</Text>
</Box>
<Text>
Last updated {createdDate.toLocaleDateString()} at{' '}
{createdDate.toLocaleTimeString()}
Last updated <span suppressHydrationWarning>{createdDate.toLocaleDateString()}</span> at{' '}
<span suppressHydrationWarning>{createdDate.toLocaleTimeString()}</span>
</Text>
</div>
<div className="flex flex-row items-center space-x-2">
Expand Down
40 changes: 40 additions & 0 deletions who-metrics-ui/src/components/TopicCell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { useState } from "react"
import { Popover } from 'react-tiny-popover'
import { Box, Label} from '@primer/react'

const TopicCell = ({topics, isSelected}: {
topics: string[]
isSelected: boolean
}) => {
const [isHovering, setIsHovering] = useState(false)
const isOpen = topics.length > 0 && (isHovering || isSelected)

return (
<Popover isOpen={isOpen} content={() => {
return (
<Box
className="shadow-xl min-w-64 p-4 rounded space-x-2"
onClick={(e) => e.stopPropagation()}
sx={{
backgroundColor: 'Background',
border: '1px solid',
borderColor: 'border.default',
}}
>
{topics.sort().map((topic) => <Label variant="accent" key={topic}>{topic}</Label>)}
</Box>
)
}}>
<span
style={{ textOverflow: 'ellipsis' }}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
className="space-x-1 m-1"
>
{topics.sort().map((topic) => <Label sx={{backgroundColor: 'Background'}} key={topic}>{topic}</Label>)}
</span>
</Popover>
)
}

export default TopicCell

0 comments on commit bfd2fbc

Please sign in to comment.