From b35e8ce84d81626b03b1f9f4d75a76c93d7b59d6 Mon Sep 17 00:00:00 2001 From: Lowell Torola <44183219+lowtorola@users.noreply.github.com> Date: Tue, 8 Aug 2023 20:47:58 -0400 Subject: [PATCH] Table Component/Rankings Page (#658) Co-authored-by: Serena Li --- frontend2/.eslintrc.js | 2 +- frontend2/package-lock.json | 60 ++++++ frontend2/package.json | 4 + frontend2/src/App.tsx | 4 +- frontend2/src/components/BattlecodeTable.tsx | 86 +++++++++ .../BattlecodeTableBottomElement.tsx | 161 ++++++++++++++++ frontend2/src/components/Spinner.tsx | 30 +++ frontend2/src/components/elements/Button.tsx | 30 +++ frontend2/src/components/elements/Input.tsx | 34 ++++ frontend2/src/views/Rankings.tsx | 176 ++++++++++++++++++ frontend2/tailwind.config.js | 5 +- 11 files changed, 589 insertions(+), 3 deletions(-) create mode 100644 frontend2/src/components/BattlecodeTable.tsx create mode 100644 frontend2/src/components/BattlecodeTableBottomElement.tsx create mode 100644 frontend2/src/components/Spinner.tsx create mode 100644 frontend2/src/components/elements/Button.tsx create mode 100644 frontend2/src/components/elements/Input.tsx create mode 100644 frontend2/src/views/Rankings.tsx diff --git a/frontend2/.eslintrc.js b/frontend2/.eslintrc.js index 4a8b759d8..d919a4a37 100644 --- a/frontend2/.eslintrc.js +++ b/frontend2/.eslintrc.js @@ -29,7 +29,7 @@ module.exports = { plugins: ["react"], rules: { indent: ["error", 2], - semi: "error", // require semicolons ending statements + semi: ["error", "always"], // require semicolons ending statements }, settings: { react: { diff --git a/frontend2/package-lock.json b/frontend2/package-lock.json index b3e620ce2..2d3a9ae40 100644 --- a/frontend2/package-lock.json +++ b/frontend2/package-lock.json @@ -17,12 +17,16 @@ "@types/node": "^16.18.26", "@types/react": "^18.2.6", "@types/react-dom": "^18.2.4", + "jquery": "^3.7.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hook-form": "^7.45.1", "react-router-dom": "^6.13.0", "web-vitals": "^2.1.4" }, "devDependencies": { + "@tailwindcss/forms": "^0.5.4", + "@types/jquery": "^3.5.16", "@types/js-cookie": "^3.0.3", "@typescript-eslint/eslint-plugin": "^5.59.8", "eslint": "^8.42.0", @@ -3894,6 +3898,18 @@ "url": "https://github.com/sponsors/gregberge" } }, + "node_modules/@tailwindcss/forms": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.4.tgz", + "integrity": "sha512-YAm12D3R7/9Mh4jFbYSMnsd6jG++8KxogWgqs7hbdo/86aWjjlIEvL7+QYdVELmAI0InXTpZqFIg5e7aDVWI2Q==", + "dev": true, + "dependencies": { + "mini-svg-data-uri": "^1.2.3" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1" + } + }, "node_modules/@testing-library/dom": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.2.0.tgz", @@ -4388,6 +4404,15 @@ "pretty-format": "^27.0.0" } }, + "node_modules/@types/jquery": { + "version": "3.5.16", + "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.16.tgz", + "integrity": "sha512-bsI7y4ZgeMkmpG9OM710RRzDFp+w4P1RGiIt30C1mSBT+ExCleeh4HObwgArnDFELmRrOpXgSYN9VF1hj+f1lw==", + "dev": true, + "dependencies": { + "@types/sizzle": "*" + } + }, "node_modules/@types/js-cookie": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.3.tgz", @@ -4525,6 +4550,12 @@ "@types/node": "*" } }, + "node_modules/@types/sizzle": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.3.tgz", + "integrity": "sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==", + "dev": true + }, "node_modules/@types/sockjs": { "version": "0.3.33", "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.33.tgz", @@ -12918,6 +12949,11 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jquery": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.0.tgz", + "integrity": "sha512-umpJ0/k8X0MvD1ds0P9SfowREz2LenHsQaxSohMZ5OMNEU2r0tf8pdeEFTHMFxWVxKNyU9rTtK3CWzUCTKJUeQ==" + }, "node_modules/js-cookie": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", @@ -13512,6 +13548,15 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "dev": true, + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, "node_modules/minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", @@ -16004,6 +16049,21 @@ "integrity": "sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==", "dev": true }, + "node_modules/react-hook-form": { + "version": "7.45.1", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.45.1.tgz", + "integrity": "sha512-6dWoFJwycbuFfw/iKMcl+RdAOAOHDiF11KWYhNDRN/OkUt+Di5qsZHwA0OwsVnu9y135gkHpTw9DJA+WzCeR9w==", + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", diff --git a/frontend2/package.json b/frontend2/package.json index de16e5145..885718636 100644 --- a/frontend2/package.json +++ b/frontend2/package.json @@ -12,8 +12,10 @@ "@types/node": "^16.18.26", "@types/react": "^18.2.6", "@types/react-dom": "^18.2.4", + "jquery": "^3.7.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hook-form": "^7.45.1", "react-router-dom": "^6.13.0", "web-vitals": "^2.1.4" }, @@ -46,6 +48,8 @@ ] }, "devDependencies": { + "@tailwindcss/forms": "^0.5.4", + "@types/jquery": "^3.5.16", "@types/js-cookie": "^3.0.3", "@typescript-eslint/eslint-plugin": "^5.59.8", "eslint": "^8.42.0", diff --git a/frontend2/src/App.tsx b/frontend2/src/App.tsx index 3563aeec1..cbdbf57f5 100644 --- a/frontend2/src/App.tsx +++ b/frontend2/src/App.tsx @@ -16,6 +16,7 @@ import { } from "react-router-dom"; import { DEFAULT_EPISODE } from "./utils/constants"; import NotFound from "./views/NotFound"; +import Rankings from "./views/Rankings"; const App: React.FC = () => { const [episodeId, setEpisodeId] = useState(DEFAULT_EPISODE); @@ -38,10 +39,11 @@ const router = createBrowserRouter([ element: , children: [ // Pages that should always be visible - // TODO: /:episodeId/resources, /:episodeId/tournaments, /:episodeId/rankings, /:episodeId/queue + // TODO: /:episodeId/resources, /:episodeId/tournaments, /:episodeId/queue { path: "/:episodeId/home", element: }, { path: "/:episodeId/quickstart", element: }, { path: "/:episodeId/*", element: }, + { path: "/:episodeId/rankings", element: }, // Pages that should only be visible when logged in // TODO: /:episodeId/team, /:episodeId/submissions, /:episodeId/scrimmaging { path: "/account", element: }, diff --git a/frontend2/src/components/BattlecodeTable.tsx b/frontend2/src/components/BattlecodeTable.tsx new file mode 100644 index 000000000..5785bac61 --- /dev/null +++ b/frontend2/src/components/BattlecodeTable.tsx @@ -0,0 +1,86 @@ +import React from "react"; +import Spinner from "./Spinner"; + +interface Column { + header: React.ReactNode; + value: (data: T) => React.ReactNode; +} + +interface TableProps { + data: T[]; + columns: Array>; + loading: boolean; + onRowClick?: (data: T) => void; + bottomElement?: JSX.Element; +} + +/* + * Generic function prop types don't work with React.FC. + * For more, see https://stackoverflow.com/questions/68757395/how-to-make-a-functional-react-component-with-generic-type + */ +function BattlecodeTable({ + data, + columns, + loading, + onRowClick, + bottomElement, +}: TableProps): React.ReactElement { + return ( +
+ + + + {columns.map((col, idx) => ( + + ))} + + + + {!loading && + data.map((row, idx) => ( + { + ev.stopPropagation(); + onRowClick?.(row); + }} + className={ + idx % 2 === 0 + ? `bg-white border-b ${ + onRowClick !== undefined + ? "cursor-pointer hover:bg-gray-100 hover:text-gray-700" + : "" + }}` + : `bg-gray-50 border-b ${ + onRowClick !== undefined + ? "cursor-pointer hover:bg-gray-100 hover:text-gray-700" + : "" + }` + } + > + {columns.map((col, idx) => ( + + ))} + + ))} + +
+ {col.header} +
+ {col.value(row)} +
+ {loading && ( +
+ +
+ )} +
{bottomElement}
+
+ ); +} + +export default BattlecodeTable; diff --git a/frontend2/src/components/BattlecodeTableBottomElement.tsx b/frontend2/src/components/BattlecodeTableBottomElement.tsx new file mode 100644 index 000000000..21e6d635d --- /dev/null +++ b/frontend2/src/components/BattlecodeTableBottomElement.tsx @@ -0,0 +1,161 @@ +import React from "react"; + +interface TableBottomProps { + totalCount: number; + pageSize: number; + currentPage: number; + onPage: (page: number) => void; +} + +const BattlecodeTableBottomElement: React.FC = ({ + totalCount, + pageSize, + currentPage, + onPage, +}) => { + const first = (currentPage - 1) * pageSize + 1; + const last = Math.min(currentPage * pageSize, totalCount); + const pageCount = Math.ceil(totalCount / pageSize); + + const backDisabled = currentPage <= 1; + const forwardDisabled = currentPage >= pageCount; + + /** + * + * @returns an array of page numbers, each of which will be rendered as a button. + * The strings in the array, such as the ellipses will be rendered as disabled buttons. + */ + function getPageNumbers(): Array { + // The maximum number of pages to show in the pagination bar. + const MAX_PAGES = 15; + if (pageCount > MAX_PAGES) { + // Determines where the ellipses should go based on the current page. + if (currentPage <= MAX_PAGES - 2) { + // TS hack: gets the array not to throw a type error. + const pages: Array = ["", 0] + .concat(Array.from({ length: MAX_PAGES - 2 }, (_, idx) => idx + 1)) + .concat(["...", pageCount]); + return pages.slice(2); + } else if (currentPage >= pageCount - MAX_PAGES + 3) { + return [1, "..."].concat( + Array.from( + { length: MAX_PAGES - 2 }, + (_, idx) => pageCount - MAX_PAGES + idx + 3 + ) + ); + } else { + return [1, "..."] + .concat( + Array.from( + { length: MAX_PAGES - 4 }, + (_, idx) => idx + currentPage - 5 + ) + ) + .concat(["...", pageCount]); + } + } else if (pageCount < 1) { + // If we have no data, return this non-clickable placeholder. + return ["1"]; + } else { + return Array.from({ length: pageCount }, (_, idx) => idx + 1); + } + } + + return ( + + ); +}; + +const DirectionPageButton: React.FC<{ + forward: boolean; + disabled: boolean; + currentPage: number; + onPage: (page: number) => void; +}> = ({ forward, disabled, currentPage, onPage }) => { + const className = disabled + ? `flex items-center justify-center px-3 h-8 ml-0 leading-tight text-gray-500 bg-white border border-gray-300 rounded-${ + forward ? "r" : "l" + }-lg cursor-not-allowed` + : `flex items-center justify-center px-3 h-8 ml-0 leading-tight text-gray-500 bg-white border border-gray-300 rounded-${ + forward ? "r" : "l" + }-lg hover:bg-gray-100 hover:text-gray-700`; + + return ( + + ); +}; + +const PageButton: React.FC<{ + page: number | string; + onPage: (page: number) => void; + currentPage: number; +}> = ({ page, onPage, currentPage }) => { + const className = + page === currentPage + ? "flex items-center justify-center px-3 h-8 text-blue-600 border border-gray-300 bg-blue-50 hover:bg-blue-100 hover:text-blue-700" + : "flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700"; + + return ( + + ); +}; + +export default BattlecodeTableBottomElement; diff --git a/frontend2/src/components/Spinner.tsx b/frontend2/src/components/Spinner.tsx new file mode 100644 index 000000000..4cdb506b5 --- /dev/null +++ b/frontend2/src/components/Spinner.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +interface SpinnerProps { + size?: number; + color?: string; +} + +const Spinner: React.FC = ({ size }) => ( + +); + +export default Spinner; diff --git a/frontend2/src/components/elements/Button.tsx b/frontend2/src/components/elements/Button.tsx new file mode 100644 index 000000000..bb6793a68 --- /dev/null +++ b/frontend2/src/components/elements/Button.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +interface ButtonProps extends React.ComponentPropsWithoutRef<"button"> { + variant?: string; + label?: string; +} + +const variants: Record = { + "": "bg-gray-50 text-gray-900 hover:bg-gray-100 ring-gray-300 ring-1 ring-inset", + "dark": "bg-gray-700 text-gray-50 hover:bg-gray-800" +}; + +const Button: React.FC = ( + { variant, label, ...rest } +) => { + variant = variant ?? ""; + const variantStyle = variants[variant]; + return ( + + ); +}; + +export default Button; diff --git a/frontend2/src/components/elements/Input.tsx b/frontend2/src/components/elements/Input.tsx new file mode 100644 index 000000000..a34adfcc8 --- /dev/null +++ b/frontend2/src/components/elements/Input.tsx @@ -0,0 +1,34 @@ +import React, { forwardRef } from "react"; + +interface InputProps extends React.ComponentPropsWithoutRef<"input"> { + label?: string; + required?: boolean; +} + +const Input = forwardRef(function Input( + { label, required, ...rest }, + ref +) { + required = required ?? false; + return ( +
+ {label !== undefined && ( + + )} +
+ +
+
+ ); +}); + +export default Input; diff --git a/frontend2/src/views/Rankings.tsx b/frontend2/src/views/Rankings.tsx new file mode 100644 index 000000000..85043fe3b --- /dev/null +++ b/frontend2/src/views/Rankings.tsx @@ -0,0 +1,176 @@ +import React, { useContext, useEffect, useState } from "react"; +import { EpisodeContext } from "../contexts/EpisodeContext"; +import { Api } from "../utils/api"; +import BattlecodeTable from "../components/BattlecodeTable"; +import { type PaginatedTeamPublicList } from "../utils/types/model/PaginatedTeamPublicList"; +import BattlecodeTableBottomElement from "../components/BattlecodeTableBottomElement"; +import { NavLink, useSearchParams } from "react-router-dom"; +import Input from "../components/elements/Input"; +import Button from "../components/elements/Button"; + +function trimString(str: string, maxLength: number): string { + if (str.length > maxLength) { + return str.slice(0, maxLength - 1) + "..."; + } + return str; +} + +const Rankings: React.FC = () => { + const episodeId = useContext(EpisodeContext).episodeId; + + const [searchText, setSearchText] = useState(""); + const [loading, setLoading] = useState(true); + const [data, setData] = useState( + undefined + ); + + const [queryParams, setQueryParams] = useSearchParams({ + page: "1", + search: "", + }); + const page = parseInt(queryParams.get("page") ?? "1"); + const searchQuery = queryParams.get("search") ?? ""; + + function handlePage(page: number): void { + if (!loading) { + setQueryParams({ ...queryParams, page: page.toString() }); + } + } + + function handleSearch(): void { + if (!loading && searchText !== searchQuery) { + setQueryParams({ ...queryParams, search: searchText }); + } + } + + useEffect(() => { + setLoading(true); + + const search = async (): Promise => { + try { + const result = await Api.searchTeams( + episodeId, + searchQuery, + false, + page + ); + setData(result); + setLoading(false); + } catch (err) { + console.error(err); + } + }; + + void search(); + + return () => { + setLoading(false); + }; + }, [searchQuery, page]); + + return ( +
+

+ Rankings +

+
+ { + setSearchText(ev.target.value); + }} + onKeyDown={(ev) => { + if (ev.key === "Enter") { + handleSearch(); + } + }} + /> +
+
+ + { + handlePage(page); + }} + /> + } + columns={[ + { + header: "Rating", + value: (team) => Math.round(team.profile?.rating ?? 0), + }, + { + header: "Team", + value: (team) => ( + + {trimString(team.name, 13)} + + ), + }, + { + header: "Members", + value: (team) => + team.members.map((member, idx) => ( + <> + + {trimString(member.username, 13)} + + {idx !== team.members.length - 1 ? ", " : ""} + + )), + }, + { + header: "Quote", + value: (team) => team.profile?.quote ?? "", + }, + { + header: "Eligibility", + value: (team) => + (team.profile?.eligibleFor ?? []) + .map((e) => e.toString()) + .join(", "), + }, + { + header: "Auto-Accept Ranked", + value: (team) => + team.profile?.autoAcceptRanked !== undefined && + team.profile.autoAcceptRanked + ? "Yes" + : "No", + }, + { + header: "Auto-Accept Unranked", + value: (team) => + team.profile?.autoAcceptUnranked !== undefined && + team.profile?.autoAcceptUnranked + ? "Yes" + : "No", + }, + ]} + /> +
+ ); +}; + +export default Rankings; diff --git a/frontend2/tailwind.config.js b/frontend2/tailwind.config.js index d3bde7873..ebaf88bbb 100644 --- a/frontend2/tailwind.config.js +++ b/frontend2/tailwind.config.js @@ -8,7 +8,10 @@ module.exports = { colors: { teal: "#00A28E", }, + container: { + center: true, + }, }, }, - plugins: [], + plugins: [require("@tailwindcss/forms")], };