diff --git a/who-metrics-ui/.eslintrc.js b/who-metrics-ui/.eslintrc.js index 14d7402..95e1d36 100644 --- a/who-metrics-ui/.eslintrc.js +++ b/who-metrics-ui/.eslintrc.js @@ -63,6 +63,7 @@ const baseConfig = { }, ignorePatterns: ["*__generated__*"], rules: { + "prettier/prettier": 0, // We use prettier for formatting instead of ESLint "import/no-unresolved": [ "error", { diff --git a/who-metrics-ui/.prettierrc b/who-metrics-ui/.prettierrc new file mode 100644 index 0000000..e6509bf --- /dev/null +++ b/who-metrics-ui/.prettierrc @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "printWidth": 80, + "tabWidth": 2 +} diff --git a/who-metrics-ui/.vscode/launch.json b/who-metrics-ui/.vscode/launch.json new file mode 100644 index 0000000..ee3bdd7 --- /dev/null +++ b/who-metrics-ui/.vscode/launch.json @@ -0,0 +1,28 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Next.js: debug server-side", + "type": "node-terminal", + "request": "launch", + "command": "npm run dev" + }, + { + "name": "Next.js: debug client-side", + "type": "chrome", + "request": "launch", + "url": "http://localhost:3000" + }, + { + "name": "Next.js: debug full stack", + "type": "node-terminal", + "request": "launch", + "command": "npm run dev", + "serverReadyAction": { + "pattern": "- Local:.+(https?://.+)", + "uriFormat": "%s", + "action": "debugWithChrome" + } + } + ] +} diff --git a/who-metrics-ui/package-lock.json b/who-metrics-ui/package-lock.json index 0d30abe..d34b0f0 100644 --- a/who-metrics-ui/package-lock.json +++ b/who-metrics-ui/package-lock.json @@ -28,11 +28,11 @@ "next-auth": "^4.19.2", "next-themes": "^0.2.1", "postcss": "^8.4.31", - "prettier": "^2.8.4", "prop-types": "^15.8.1", "react": "^18.2.0", "react-data-grid": "^7.0.0-beta.41", "react-dom": "^18.2.0", + "react-tiny-popover": "^8.0.4", "server-only": "^0.0.1", "styled-components": "^5.3.11", "tailwindcss": "^3.2.7", @@ -49,7 +49,8 @@ "eslint-plugin-github": "^4.8.0", "eslint-plugin-import": "^2.27.5", "eslint-plugin-primer-react": "^3.0.0", - "eslint-plugin-react": "^7.32.2" + "eslint-plugin-react": "^7.32.2", + "prettier": "^3.2.4" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -2932,6 +2933,21 @@ "eslint": "^8.0.1" } }, + "node_modules/eslint-plugin-github/node_modules/prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true, + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/eslint-plugin-i18n-text": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/eslint-plugin-i18n-text/-/eslint-plugin-i18n-text-1.0.1.tgz", @@ -4947,14 +4963,15 @@ } }, "node_modules/prettier": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.7.tgz", - "integrity": "sha512-yPngTo3aXUUmyuTjeTUT75txrf+aMh9FiD7q9ZE/i6r0bPb22g4FsE6Y338PQX1bmfy08i9QQCB7/rcUAVntfw==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.4.tgz", + "integrity": "sha512-FWu1oLHKCrtpO1ypU6J0SbK2d9Ckwysq6bHj/uaCP26DxrPpppCLQRGVuqAxSTvhF00AcvDRyYrLNW7ocBhFFQ==", + "dev": true, "bin": { - "prettier": "bin-prettier.js" + "prettier": "bin/prettier.cjs" }, "engines": { - "node": ">=10.13.0" + "node": ">=14" }, "funding": { "url": "https://github.com/prettier/prettier?sponsor=1" @@ -5140,6 +5157,15 @@ "react-dom": ">=15.0.0" } }, + "node_modules/react-tiny-popover": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/react-tiny-popover/-/react-tiny-popover-8.0.4.tgz", + "integrity": "sha512-pn0Y/G0gyMdYTBEWSKCCnaZsXAa54PkfnRE4fnMM5633SSClYrXxwXKc6vPYgJ9shLatGginxMjnhXq6guZmng==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", diff --git a/who-metrics-ui/package.json b/who-metrics-ui/package.json index 7f56a84..acf311f 100644 --- a/who-metrics-ui/package.json +++ b/who-metrics-ui/package.json @@ -29,11 +29,11 @@ "next-auth": "^4.19.2", "next-themes": "^0.2.1", "postcss": "^8.4.31", - "prettier": "^2.8.4", "prop-types": "^15.8.1", "react": "^18.2.0", "react-data-grid": "^7.0.0-beta.41", "react-dom": "^18.2.0", + "react-tiny-popover": "^8.0.4", "server-only": "^0.0.1", "styled-components": "^5.3.11", "tailwindcss": "^3.2.7", @@ -51,6 +51,7 @@ "eslint-plugin-github": "^4.8.0", "eslint-plugin-import": "^2.27.5", "eslint-plugin-primer-react": "^3.0.0", - "eslint-plugin-react": "^7.32.2" + "eslint-plugin-react": "^7.32.2", + "prettier": "^3.2.4" } } diff --git a/who-metrics-ui/src/components/DashboardExample.tsx b/who-metrics-ui/src/components/DashboardExample.tsx deleted file mode 100644 index 90203c1..0000000 --- a/who-metrics-ui/src/components/DashboardExample.tsx +++ /dev/null @@ -1,97 +0,0 @@ -"use client"; - -import { - Title, - Text, - Tab, - TabList, - TabGroup, - TabPanel, - TabPanels, -} from "@tremor/react"; - -import { Box, useTheme as primerUseTheme } from "@primer/react"; -import Image from "next/image"; -import logo from "@/images/who-logo-wide.svg"; - -import RepositoriesTable from "./RepositoriesTable"; -import Data from "../data/data.json"; -import { useTheme } from "next-themes"; - -export type DailyPerformance = { - date: string; - Sales: number; - Profit: number; - Customers: number; -}; - -export const performance: DailyPerformance[] = [ - { - date: "2023-05-01", - Sales: 900.73, - Profit: 173, - Customers: 73, - }, - { - date: "2023-05-02", - Sales: 1000.74, - Profit: 174.6, - Customers: 74, - }, - { - date: "2023-05-03", - Sales: 1100.93, - Profit: 293.1, - Customers: 293, - }, - { - date: "2023-05-04", - Sales: 1200.9, - Profit: 290.2, - Customers: 29, - }, -]; - -export const DashboardExample = () => { - const { theme, systemTheme } = useTheme(); - const { setColorMode } = primerUseTheme(); - if (theme === "light" || theme === "dark" || theme === "auto") { - setColorMode(theme); - } - - if (theme === "system" && systemTheme) { - setColorMode(systemTheme); - } - - return ( -
- - World Health Organization logo - {Data.orgInfo.name} Open Source Dashboard - - - This project includes metrics about the Open Source repositories for the - {Data.orgInfo.name}. - - - - Repositories - - - - - - - -
- ); -}; diff --git a/who-metrics-ui/src/components/OrganizationSheet.tsx b/who-metrics-ui/src/components/OrganizationSheet.tsx new file mode 100644 index 0000000..7c282cf --- /dev/null +++ b/who-metrics-ui/src/components/OrganizationSheet.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { + Tab, + TabGroup, + TabList, + TabPanel, + TabPanels, + Text, + Title +} from '@tremor/react'; + +import logo from '@/images/who-logo-wide.svg'; +import { Box, useTheme as primerUseTheme } from '@primer/react'; +import Image from 'next/image'; + +import { useTheme } from 'next-themes'; +import data from '../data/data.json'; +import RepositoriesTable from './RepositoriesTable'; + +export const OrganizationSheet = () => { + const { theme, systemTheme } = useTheme(); + const { setColorMode } = primerUseTheme(); + if (theme === 'light' || theme === 'dark' || theme === 'auto') { + setColorMode(theme); + } + + if (theme === 'system' && systemTheme) { + setColorMode(systemTheme); + } + + return ( +
+ + World Health Organization logo + {data.orgInfo.name} Open Source Dashboard + + + This project includes metrics about the Open Source repositories for the + {data.orgInfo.name}. + + + + Repositories + + + + + + + +
+ ); +}; diff --git a/who-metrics-ui/src/components/RepositoriesTable.tsx b/who-metrics-ui/src/components/RepositoriesTable.tsx index 92e824f..ae9b8ed 100644 --- a/who-metrics-ui/src/components/RepositoriesTable.tsx +++ b/who-metrics-ui/src/components/RepositoriesTable.tsx @@ -1,47 +1,196 @@ -import { InfoIcon } from "@primer/octicons-react"; -import { Tooltip } from "@primer/react"; -import { Flex, Text } from "@tremor/react"; -import DataGrid, { type SortColumn } from "react-data-grid"; - -import Data from "../data/data.json"; -import { useState } from "react"; -const repos = Object.values(Data["repositories"]); +import { + InfoIcon, + TriangleDownIcon, + TriangleUpIcon, + XIcon +} from '@primer/octicons-react'; +import { + ActionList, + Box, + Button, + Checkbox, + FormControl, + TextInput, + Tooltip +} from '@primer/react'; +import { Text } from '@tremor/react'; +import DataGrid, { + Column, + type RenderHeaderCellProps, + type SortColumn +} from 'react-data-grid'; +import { Popover } from 'react-tiny-popover'; + +import { + createContext, + FC, + useCallback, + useContext, + useRef, + useState +} from 'react'; +import Data from '../data/data.json'; +const repos = Object.values(Data['repositories']); type Repo = (typeof repos)[0]; -const Labels: Record = { - Name: "repositoryName", - Collaborators: "collaboratorsCount", - License: "licenseName", - Watchers: "watchersCount", - "Open Issues": "openIssuesCount", - "Closed Issues": "closedIssuesCount", - "Open PRs": "openPullRequestsCount", - "Merged PRs": "mergedPullRequestsCount", - Forks: "forksCount", -} as const; - -const DataGridColumns = Object.keys(Labels).map((label) => { - return { - key: Labels[label], - name: label, - }; -}); +function inputStopPropagation(event: React.KeyboardEvent) { + event.stopPropagation(); +} + +type Filter = { + repositoryName?: string; + licenseName?: Record; + collaboratorsCount?: Array; + watchersCount?: Array; + openIssuesCount?: Array; + openPullRequestsCount?: Array; + closedIssuesCount?: Array; + mergedPullRequestsCount?: Array; + forksCount?: Array; +}; + +// Renderer for the min/max filter inputs +const MinMaxRenderer: FC<{ + headerCellProps: RenderHeaderCellProps; + filters: Filter; + updateFilters: ((filters: Filter) => void) & + ((filters: (filters: Filter) => Filter) => void); + filterName: keyof Filter; +}> = ({ headerCellProps, filters, updateFilters, filterName }) => { + return ( + {...headerCellProps}> + {({ ...rest }) => ( +
+ + + Min + + { + updateFilters((globalFilters) => ({ + ...globalFilters, + [filterName]: [ + Number(e.target.value), + ( + globalFilters[filterName] as Array + )?.[1], + ], + })); + }} + onKeyDown={inputStopPropagation} + onClick={(e) => e.stopPropagation()} + /> + + + + Max + + + updateFilters({ + ...filters, + [filterName]: [0, Number(e.target.value)], + }) + } + onKeyDown={inputStopPropagation} + onClick={(e) => e.stopPropagation()} + /> + +
+ )} + + ); +}; + +// Wrapper for rendering column header cell +const HeaderCellRenderer = ({ + tabIndex, + column, + children: filterFunction, + sortDirection, +}: RenderHeaderCellProps & { + children: (args: { tabIndex: number; filters: Filter }) => React.ReactElement; +}) => { + const filters = useContext(FilterContext)!; + const clickMeButtonRef = useRef(null); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + return ( +
+
{column.name}
+
+ {sortDirection === 'DESC' ? ( + + ) : sortDirection === 'ASC' ? ( + + ) : ( + + )} + setIsPopoverOpen(false)} + ref={clickMeButtonRef} // if you'd like a ref to your popover's child, you can grab one here + content={() => ( + // The click handler here is used to stop the header from being sorted + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
e.stopPropagation()} + > +
+ + Filter by {column.name} + {filterFunction({ tabIndex, filters })} + +
+
+ )} + > + +
+
+
+ ); +}; + +// Context is needed to read filter values otherwise columns are +// re-created when filters are changed and filter loses focus +const FilterContext = createContext(undefined); type Comparator = (a: Repo, b: Repo) => number; const getComparator = (sortColumn: keyof Repo): Comparator => { switch (sortColumn) { // number based sorting - case "closedIssuesCount": - case "collaboratorsCount": - case "discussionsCount": - case "forksCount": - case "issuesCount": - case "mergedPullRequestsCount": - case "openIssuesCount": - case "openPullRequestsCount": - case "projectsCount": - case "watchersCount": + case 'closedIssuesCount': + case 'collaboratorsCount': + case 'discussionsCount': + case 'forksCount': + case 'issuesCount': + case 'mergedPullRequestsCount': + case 'openIssuesCount': + case 'openPullRequestsCount': + case 'projectsCount': + case 'watchersCount': return (a, b) => { if (a[sortColumn] === b[sortColumn]) { return 0; @@ -55,33 +204,307 @@ const getComparator = (sortColumn: keyof Repo): Comparator => { }; // alphabetical sorting - case "licenseName": - case "repoName": - case "repositoryName": + case 'licenseName': + case 'repoName': + case 'repositoryName': return (a, b) => { - return a[sortColumn].localeCompare(b[sortColumn]); + return a[sortColumn] + .toLowerCase() + .localeCompare(b[sortColumn].toLowerCase()); }; default: throw new Error(`unsupported sortColumn: "${sortColumn}"`); } }; +// Default set of filters +const defaultFilters: Filter = { + licenseName: { + all: true, + }, +}; + const RepositoriesTable = () => { + const [globalFilters, setGlobalFilters] = useState(defaultFilters); + + // This needs a type, technically it's a Column but needs to be typed + const labels: Record> = { + Name: { + key: 'repositoryName', + name: 'Name', + + renderHeaderCell: (p) => { + return ( + {...p}> + {({ filters, ...rest }) => ( + + setGlobalFilters((otherFilters) => ({ + ...otherFilters, + repositoryName: e.target.value, + })) + } + onKeyDown={inputStopPropagation} + onClick={(e) => e.stopPropagation()} + /> + )} + + ); + }, + }, + License: { + key: 'licenseName', + name: 'License', + + renderHeaderCell: (p) => { + // This is fine because we know it's going to be rendered as a component + // eslint-disable-next-line react-hooks/rules-of-hooks + const [filteredOptions, setFilteredOptions] = useState(''); + + return ( + {...p}> + {({ ...rest }) => ( + + setFilteredOptions(e.target.value)} + trailingAction={ + { + setFilteredOptions(''); + }} + icon={XIcon} + aria-label="Clear input" + sx={{ color: 'fg.subtle' }} + /> + } + /> + + { + setGlobalFilters((otherFilters) => ({ + ...otherFilters, + licenseName: { + ...otherFilters.licenseName, + all: !otherFilters.licenseName?.['all'], + }, + })); + }} + > + + + + All + + {dropdownOptions('licenseName', filteredOptions).map((d) => { + if (d.value === '') { + return ( + <> + { + setGlobalFilters((otherFilters) => ({ + ...otherFilters, + licenseName: { + ...otherFilters.licenseName, + [d.value]: + !otherFilters.licenseName?.[d.value], + }, + })); + }} + > + + + + No License + + + ); + } + + return ( + <> + { + setGlobalFilters((otherFilters) => ({ + ...otherFilters, + licenseName: { + ...otherFilters.licenseName, + [d.value]: !otherFilters.licenseName?.[d.value], + }, + })); + }} + > + + + + {d.value} + + + ); + })} + + + )} + + ); + }, + }, + Collaborators: { + key: 'collaboratorsCount', + name: 'Collaborators', + renderHeaderCell: (p) => { + return ( + + ); + }, + }, + Watchers: { + key: 'watchersCount', + name: 'Watchers', + + renderHeaderCell: (p) => { + return ( + + ); + }, + }, + 'Open Issues': { + key: 'openIssuesCount', + name: 'Open Issues', + + renderHeaderCell: (p) => { + return ( + + ); + }, + }, + 'Closed Issues': { + key: 'closedIssuesCount', + name: 'Closed Issues', + + renderHeaderCell: (p) => { + return ( + + ); + }, + }, + 'Open PRs': { + key: 'openPullRequestsCount', + name: 'Open PRs', + + renderHeaderCell: (p) => { + return ( + + ); + }, + }, + 'Merged PRs': { + key: 'mergedPullRequestsCount', + name: 'Merged PRs', + + renderHeaderCell: (p) => { + return ( + + ); + }, + }, + Forks: { + key: 'forksCount', + name: 'Total Forks', + + renderHeaderCell: (p) => { + return ( + + ); + }, + }, + } as const; + + const dataGridColumns = Object.entries(labels).map( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + ([_, columnProps]) => columnProps, + ); + const subTitle = () => { return `${repos.length} total repositories`; }; + // This selects a field to populate a dropdown with + const dropdownOptions = (field: keyof Repo, filter = '') => + Array.from(new Set(repos.map((r) => r[field]))) + .map((d) => ({ + label: d, + value: d, + })) + .filter((d) => + (d.value as string).toLowerCase().includes(filter.toLowerCase()), + ); + const [sortColumns, setSortColumns] = useState([]); - const sortedRepos = () => { - if (sortColumns.length === 0) return repos; + const sortRepos = (inputRepos: Repo[]) => { + if (sortColumns.length === 0) { + return repos; + } - const sortedRows = [...repos].sort((a, b) => { + const sortedRows = [...inputRepos].sort((a, b) => { for (const sort of sortColumns) { const comparator = getComparator(sort.columnKey as keyof Repo); const compResult = comparator(a, b); if (compResult !== 0) { - return sort.direction === "ASC" ? compResult : -compResult; + return sort.direction === 'ASC' ? compResult : -compResult; } } return 0; @@ -90,34 +513,109 @@ const RepositoriesTable = () => { return sortedRows; }; + /** + * Uses globalFilters to filter the repos that are then passed to sortRepos + * + * NOTE: We use some hacks like adding 'all' to the licenseName filter to + * make it easier to filter the repos. + * + * This is kind of a mess, but it works + */ + const filterRepos = useCallback( + (inputRepos: Repo[]) => { + const result = inputRepos.filter((repo) => { + return ( + (globalFilters.repositoryName + ? repo.repositoryName.includes(globalFilters.repositoryName) + : true) && + ((globalFilters.licenseName?.[repo.licenseName] ?? false) || + (globalFilters.licenseName?.['all'] ?? false)) && + (globalFilters.collaboratorsCount + ? (globalFilters.collaboratorsCount?.[0] ?? 0) <= + repo.collaboratorsCount && + repo.collaboratorsCount <= + (globalFilters.collaboratorsCount[1] ?? Infinity) + : true) && + (globalFilters.watchersCount + ? (globalFilters.watchersCount?.[0] ?? 0) <= repo.watchersCount && + repo.watchersCount <= (globalFilters.watchersCount[1] ?? Infinity) + : true) && + (globalFilters.openIssuesCount + ? (globalFilters.openIssuesCount?.[0] ?? 0) <= + repo.openIssuesCount && + repo.openIssuesCount <= + (globalFilters.openIssuesCount[1] ?? Infinity) + : true) && + (globalFilters.closedIssuesCount + ? (globalFilters.closedIssuesCount?.[0] ?? 0) <= + repo.closedIssuesCount && + repo.closedIssuesCount <= + (globalFilters.closedIssuesCount[1] ?? Infinity) + : true) && + (globalFilters.openPullRequestsCount + ? (globalFilters.openPullRequestsCount?.[0] ?? 0) <= + repo.openPullRequestsCount && + repo.openPullRequestsCount <= + (globalFilters.openPullRequestsCount[1] ?? Infinity) + : true) && + (globalFilters.mergedPullRequestsCount + ? (globalFilters.mergedPullRequestsCount?.[0] ?? 0) <= + repo.mergedPullRequestsCount && + repo.mergedPullRequestsCount <= + (globalFilters.mergedPullRequestsCount[1] ?? Infinity) + : true) && + (globalFilters.forksCount + ? (globalFilters.forksCount?.[0] ?? 0) <= repo.forksCount && + repo.forksCount <= (globalFilters.forksCount[1] ?? Infinity) + : true) + ); + }); + + return result; + }, + [globalFilters], + ); + return ( -
-
- - - - - {subTitle()} - -
-
- repo.repoName} - defaultColumnOptions={{ - sortable: true, - resizable: true, - }} - sortColumns={sortColumns} - onSortColumnsChange={setSortColumns} - style={{ height: "100%", width: "100%" }} - /> +
+
+
+
+ + + + {subTitle()} +
+
+ +
+
+ + {/* This is a weird hack to make the table fill the page */} +
+ repo.repoName} + defaultColumnOptions={{ + sortable: true, + resizable: true, + }} + sortColumns={sortColumns} + onSortColumnsChange={setSortColumns} + style={{ height: '100%', width: '100%' }} + /> +
+
); }; diff --git a/who-metrics-ui/src/components/index.ts b/who-metrics-ui/src/components/index.ts index 9b09fe5..bdd9366 100644 --- a/who-metrics-ui/src/components/index.ts +++ b/who-metrics-ui/src/components/index.ts @@ -1,2 +1,2 @@ -export { ChartView } from "./ChartView"; -export { DashboardExample } from "./DashboardExample"; +export { ChartView } from './ChartView'; +export { OrganizationSheet } from './OrganizationSheet'; diff --git a/who-metrics-ui/src/pages/index.tsx b/who-metrics-ui/src/pages/index.tsx index e8568d0..e184397 100644 --- a/who-metrics-ui/src/pages/index.tsx +++ b/who-metrics-ui/src/pages/index.tsx @@ -1,10 +1,10 @@ /* eslint-disable filenames/match-regex */ -import { DashboardExample } from "../components"; +import { OrganizationSheet } from "../components/OrganizationSheet"; export default function PlaygroundPage() { return ( -
- +
+
); }