diff --git a/.env b/.env index d1fe7b9..7ababed 100644 --- a/.env +++ b/.env @@ -1,2 +1,6 @@ # Service. VITE_SERVICE_NAME=contributor + +# Links. +VITE_LINK_GH_LOGIN=https://github.com/login/oauth/authorize?client_id=Ov23liBErSabQFqROeMg +VITE_LINK_GH_CONTRIBUTING=https://github.com/ocadotechnology/codeforlife-workspace/blob/{commitId}/CONTRIBUTING.md diff --git a/.vscode/launch.json b/.vscode/launch.json index 5ac41da..0d3a1fc 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -18,9 +18,6 @@ "type": "node" }, { - "env": { - "VITE_SERVICE_NAME": "contributor" - }, "name": "Vite Server", "preLaunchTask": "run", "request": "launch", diff --git a/src/api/session.ts b/src/api/session.ts index 07bfc4c..af762de 100644 --- a/src/api/session.ts +++ b/src/api/session.ts @@ -1,13 +1,13 @@ import { type SessionMetadata } from "../app/hooks" -export type LoginWithGitHubResult = SessionMetadata -export type LoginWithGitHubArg = { code: string } +export type LoginResult = SessionMetadata +export type LoginArg = { code: string } import api from "." const sessionApi = api.injectEndpoints({ endpoints: build => ({ - loginWithGitHub: build.mutation({ + login: build.mutation({ query: body => ({ url: "session/login/", method: "POST", @@ -18,4 +18,4 @@ const sessionApi = api.injectEndpoints({ }) export default sessionApi -export const { useLoginWithGitHubMutation } = sessionApi +export const { useLoginMutation } = sessionApi diff --git a/src/app/env.ts b/src/app/env.ts index 5d568e3..08637d6 100644 --- a/src/app/env.ts +++ b/src/app/env.ts @@ -2,4 +2,5 @@ import env from "codeforlife/env" export * from "codeforlife/env" -export const GH_CLIENT_ID = env.GITHUB_CLIENT_ID ?? "REPLACE_ME" +export const LINK_GH_LOGIN = env.VITE_LINK_GH_LOGIN +export const LINK_GH_CONTRIBUTING = env.VITE_LINK_GH_CONTRIBUTING diff --git a/src/app/hooks.ts b/src/app/hooks.tsx similarity index 51% rename from src/app/hooks.ts rename to src/app/hooks.tsx index edad679..8c6ecd4 100644 --- a/src/app/hooks.ts +++ b/src/app/hooks.tsx @@ -4,10 +4,13 @@ // We disable the ESLint rule here because this is the designated place // for importing and re-exporting the typed versions of hooks. /* eslint-disable @typescript-eslint/no-restricted-imports */ +import { type ReactNode, useEffect } from "react" +import { createSearchParams, useLocation, useNavigate } from "react-router-dom" import { useDispatch, useSelector } from "react-redux" +import Cookies from "js-cookie" import type { AppDispatch, RootState } from "./store" -import Cookies from "js-cookie" +import { paths } from "../routes" // Use throughout your app instead of plain `useDispatch` and `useSelector` export const useAppDispatch = useDispatch.withTypes() @@ -24,3 +27,37 @@ export function useSessionMetadata(): SessionMetadata | undefined { ? (JSON.parse(sessionMetadata) as SessionMetadata) : undefined } + +export type UseSessionChildren = + | ReactNode + | ((metadata: SessionMetadata) => ReactNode) + +export type UseSessionOptions = Partial<{ next: boolean }> + +export function useSession( + children: UseSessionChildren, + options: UseSessionOptions = {}, +) { + const { next = true } = options + + const { pathname } = useLocation() + const navigate = useNavigate() + const sessionMetadata = useSessionMetadata() + + useEffect(() => { + if (!sessionMetadata) { + navigate({ + pathname: paths._, + search: next + ? createSearchParams({ next: pathname }).toString() + : undefined, + }) + } + }, [sessionMetadata, navigate, next, pathname]) + + if (!sessionMetadata) return <> + + if (typeof children === "function") return children(sessionMetadata) + + return children +} diff --git a/src/pages/agreementSignatureDetails/AgreementSignatureDetails.tsx b/src/pages/agreementSignatureDetails/AgreementSignatureDetails.tsx deleted file mode 100644 index 859b648..0000000 --- a/src/pages/agreementSignatureDetails/AgreementSignatureDetails.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import * as pages from "codeforlife/components/page" -import * as yup from "yup" -import { Stack, Typography } from "@mui/material" -// eslint-disable-next-line sort-imports -import { type FC } from "react" -import { Link } from "codeforlife/components/router" -import { handleQueryState } from "codeforlife/utils/api" -import { useParamsRequired } from "codeforlife/hooks" -// eslint-disable-next-line sort-imports -import { useLazyRetrieveAgreementSignatureQuery } from "../../api/agreementSignature" -import { useRetrieveContributorQuery } from "../../api/contributor" -// eslint-disable-next-line sort-imports -import { paths } from "../../routes" - -export interface AgreementSignatureDetailProps {} - -const AgreementSignatureDetail: FC = () => { - const [retrieveAgreementSignature, retrieveAgreementSignatureResult] = - useLazyRetrieveAgreementSignatureQuery() - - const agreementSignature = retrieveAgreementSignatureResult.data - const contributorId = agreementSignature?.contributor - const { data: contributor } = useRetrieveContributorQuery( - contributorId as number, - { skip: !contributorId }, - ) - - return useParamsRequired({ - shape: { id: yup.number().required().min(1) }, - children: () => - handleQueryState(retrieveAgreementSignatureResult, agreementSignature => ( - - - - {contributor?.name || "..."} signature details - - - - Contributor name: {contributor?.name || "..."} - - - Agreement ID: {agreementSignature.agreement_id} - - - signed at: {agreementSignature.signed_at.toString()} - - - - - - Agreement signature list - - - - )), - onValidationSuccess: params => { - void retrieveAgreementSignature(params.id, true) - }, - onValidationError: navigate => { - navigate(paths.agreementSignatures._, { - state: { - notifications: [ - { props: { error: true, children: "Failed to get params" } }, - ], - }, - }) - }, - }) -} - -export default AgreementSignatureDetail diff --git a/src/pages/agreementSignatureList/AgreementSignatureList.tsx b/src/pages/agreementSignatureList/AgreementSignatureList.tsx index 5bc0e8a..4646986 100644 --- a/src/pages/agreementSignatureList/AgreementSignatureList.tsx +++ b/src/pages/agreementSignatureList/AgreementSignatureList.tsx @@ -1,55 +1,32 @@ import * as pages from "codeforlife/components/page" -import { Stack, Typography } from "@mui/material" import { type FC } from "react" -import { LinkIconButton } from "codeforlife/components/router" -import { TablePagination } from "codeforlife/components" -import { generatePath } from "react-router" -import { paths } from "../../routes" -import { useLazyListAgreementSignaturesQuery } from "../../api/agreementSignature" + +import AgreementSignatureTable from "./AgreementSignatureTable" +import SignLatestAgreementForm from "./SignLatestAgreementForm" +import { useSession } from "../../app/hooks" export interface AgreementSignatureListProps {} const AgreementSignatureList: FC = () => { - return ( + const signedLatestAgreement = false // TODO: call endpoint + const latestAgreementId = "" // TODO: call endpoint + + return useSession(({ contributor_id }) => ( + + {!signedLatestAgreement && ( + + + + )} - Agreement Signature List - - {agreementSignatures => ( - <> - - ID - - Contributor (Agreement ID) - - - {agreementSignatures.map(agreementSignature => ( - - - {agreementSignature.id} - - - {agreementSignature.contributor} ( - {agreementSignature.agreement_id}) - - - View - - - ))} - - )} - + - ) + )) } export default AgreementSignatureList diff --git a/src/pages/agreementSignatureList/AgreementSignatureTable.tsx b/src/pages/agreementSignatureList/AgreementSignatureTable.tsx new file mode 100644 index 0000000..57170a1 --- /dev/null +++ b/src/pages/agreementSignatureList/AgreementSignatureTable.tsx @@ -0,0 +1,64 @@ +import { + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, +} from "@mui/material" +import { type FC } from "react" +import { LinkIconButton } from "codeforlife/components/router" +import { OpenInNew as OpenInNewIcon } from "@mui/icons-material" +import { TablePagination } from "codeforlife/components" + +import { LINK_GH_CONTRIBUTING } from "../../app/env" +import { useLazyListAgreementSignaturesQuery } from "../../api/agreementSignature" + +export interface AgreementSignatureTableProps {} + +const AgreementSignatureTable: FC = () => { + return ( + + {agreementSignatures => ( + + + + + ID + Signed at + Link + + + + {agreementSignatures.map(agreementSignature => ( + + {agreementSignature.agreement_id} + + {agreementSignature.signed_at.toLocaleString()} + + + + + + + + ))} + +
+
+ )} +
+ ) +} + +export default AgreementSignatureTable diff --git a/src/pages/agreementSignatureList/SignLatestAgreementForm.tsx b/src/pages/agreementSignatureList/SignLatestAgreementForm.tsx new file mode 100644 index 0000000..9e37e79 --- /dev/null +++ b/src/pages/agreementSignatureList/SignLatestAgreementForm.tsx @@ -0,0 +1,66 @@ +import * as forms from "codeforlife/components/form" +import { type FC } from "react" +import { Link } from "codeforlife/components/router" +import { PriorityHigh as PriorityHighIcon } from "@mui/icons-material" +import { Typography } from "@mui/material" +import { submitForm } from "codeforlife/utils/form" + +import { type AgreementSignature } from "../../api/agreementSignature" +import { type Contributor } from "../../api/contributor" +import { LINK_GH_CONTRIBUTING } from "../../app/env" +import { useCreateAgreementSignatureMutation } from "../../api/agreementSignature" + +export interface SignLatestAgreementFormProps { + contributor: Contributor["id"] + agreementId: AgreementSignature["agreement_id"] +} + +const SignLatestAgreementForm: FC = ({ + contributor, + agreementId, +}) => { + const [createAgreementSignature] = useCreateAgreementSignatureMutation() + + return ( + <> + + You have not signed the latest agreement! + + ({ ...values, signed_at: new Date() }), + })} + > + + I have read and understood the{" "} + + agreement + + . + + ), + }} + /> + }> + Sign agreement + + + + ) +} + +export default SignLatestAgreementForm diff --git a/src/pages/contributorDetail/ContributorDetail.tsx b/src/pages/contributorDetail/ContributorDetail.tsx deleted file mode 100644 index 1a9e8f8..0000000 --- a/src/pages/contributorDetail/ContributorDetail.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import * as pages from "codeforlife/components/page" -import * as yup from "yup" -import { Stack, Typography } from "@mui/material" -// eslint-disable-next-line sort-imports -import { type FC } from "react" -import { Link } from "codeforlife/components/router" -import { handleQueryState } from "codeforlife/utils/api" -import { useParamsRequired } from "codeforlife/hooks" -// eslint-disable-next-line sort-imports -import { useLazyRetrieveContributorQuery } from "../../api/contributor" -// eslint-disable-next-line sort-imports -import { paths } from "../../routes" - -export interface ContributorDetailProps {} - -const ContributorDetail: FC = () => { - const [retrieveContributor, retrieveContributorResult] = - useLazyRetrieveContributorQuery() - - return useParamsRequired({ - shape: { id: yup.number().required().min(1) }, - children: () => - handleQueryState(retrieveContributorResult, contributor => ( - - - {contributor.name} details - - Name: {contributor.name} - Email: {contributor.email} - {contributor.location && ( - - Location: {contributor.location} - - )} - - html_url: {contributor.html_url} - - - avatar_url: {contributor.avatar_url} - - - - - - Contributor list - - - - )), - onValidationSuccess: params => { - void retrieveContributor(params.id, true) - }, - onValidationError: navigate => { - navigate(paths.contributors._, { - state: { - notifications: [ - { props: { error: true, children: "Failed to get params" } }, - ], - }, - }) - }, - }) -} - -export default ContributorDetail diff --git a/src/pages/contributorList/ContributorList.tsx b/src/pages/contributorList/ContributorList.tsx deleted file mode 100644 index 53416de..0000000 --- a/src/pages/contributorList/ContributorList.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import * as pages from "codeforlife/components/page" -import { Stack, Typography } from "@mui/material" -import { type FC } from "react" -import { LinkIconButton } from "codeforlife/components/router" -import { TablePagination } from "codeforlife/components" -import { generatePath } from "react-router" -import { paths } from "../../routes" -import { useLazyListContributorsQuery } from "../../api/contributor" - -export interface ContributorListProps {} - -const ContributorList: FC = () => { - return ( - - - Contributors List - - {contributors => - contributors.map(contributor => ( - - {contributor.id} - - {contributor.name} ({contributor.email}) - - - - View - - - )) - } - - - - ) -} - -export default ContributorList diff --git a/src/pages/home/Home.tsx b/src/pages/home/Home.tsx index 140043c..367d215 100644 --- a/src/pages/home/Home.tsx +++ b/src/pages/home/Home.tsx @@ -1,61 +1,61 @@ import * as pages from "codeforlife/components/page" import * as yup from "yup" import { type FC, useEffect } from "react" -import { GH_CLIENT_ID } from "../../app/env" -import GitHubIcon from "@mui/icons-material/GitHub" +import { Stack, Typography } from "@mui/material" +import { GitHub as GitHubIcon } from "@mui/icons-material" import { Image } from "codeforlife/components" import { LinkButton } from "codeforlife/components/router" -import { paths } from "../../routes" - -import { Stack, Typography } from "@mui/material" -import { useLoginWithGitHubMutation } from "../../api/session" import { useNavigate } from "codeforlife/hooks/router" import { useSearchParams } from "codeforlife/hooks/router" import CflLogoImage from "../../images/cfl_logo.png" +import { LINK_GH_LOGIN } from "../../app/env" +import { paths } from "../../routes" +import { useLoginMutation } from "../../api/session" import { useSessionMetadata } from "../../app/hooks" export interface HomeProps {} const Home: FC = () => { - const [loginWithGitHub] = useLoginWithGitHubMutation() + const [login] = useLoginMutation() const sessionMetadata = useSessionMetadata() const navigate = useNavigate() - const searchParams = useSearchParams({ - code: yup.string(), - }) + const { code } = useSearchParams({ code: yup.string() }) || {} useEffect(() => { - const code = searchParams?.code - - if (sessionMetadata) { - navigate(paths.agreementSignatures._) - } else if (code) { - navigate(".", { replace: true }) - searchParams.code = undefined - - loginWithGitHub({ code }) + if (sessionMetadata) navigate(paths.agreementSignatures._) + else if (code) { + login({ code }) .unwrap() .then(() => { navigate(paths.agreementSignatures._) }) - .catch(err => { - alert(`Login failed: ${err}`) + .catch(() => { + navigate(".", { + replace: true, + state: { + notifications: [ + { + props: { + error: true, + children: "Failed to login. Please try again.", + }, + }, + ], + }, + }) }) } - }, [sessionMetadata, loginWithGitHub, navigate, searchParams]) + }, [sessionMetadata, login, navigate, code]) return ( code for life logo @@ -68,12 +68,12 @@ const Home: FC = () => { contributing, and help us make a difference. ({ - marginTop: `${theme.spacing(20)}`, - borderRadius: `${theme.spacing(1)}`, - padding: `${theme.spacing(4)} ${theme.spacing(5)}`, - fontSize: `${theme.spacing(2.5)}`, + to={LINK_GH_LOGIN} + sx={({ spacing }) => ({ + marginTop: spacing(20), + borderRadius: spacing(1), + padding: `${spacing(4)} ${spacing(5)}`, + fontSize: spacing(2.5), background: "black", color: "white.main", })} diff --git a/src/routes/agreementSignature.tsx b/src/routes/agreementSignature.tsx index fef8bb2..c4dfd4d 100644 --- a/src/routes/agreementSignature.tsx +++ b/src/routes/agreementSignature.tsx @@ -1,15 +1,10 @@ import { Route } from "react-router-dom" -import AgreementSignatureDetails from "../pages/agreementSignatureDetails/AgreementSignatureDetails" import AgreementSignatureList from "../pages/agreementSignatureList/AgreementSignatureList" import paths from "./paths" const agreementSignature = ( <> - } - /> } diff --git a/src/routes/contributor.tsx b/src/routes/contributor.tsx deleted file mode 100644 index df7aeee..0000000 --- a/src/routes/contributor.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { Route } from "react-router-dom" - -import ContributorDetail from "../pages/contributorDetail/ContributorDetail" -import ContributorList from "../pages/contributorList/ContributorList" -import paths from "./paths" - -const contributor = ( - <> - } /> - } /> - -) - -export default contributor diff --git a/src/routes/paths.ts b/src/routes/paths.ts index 2a92dfd..ac2a5c3 100644 --- a/src/routes/paths.ts +++ b/src/routes/paths.ts @@ -4,9 +4,7 @@ const paths = _("", { contributors: _("/contributors", { id: _("/:id"), }), - agreementSignatures: _("/agreement-signatures", { - id: _("/:id"), - }), + agreementSignatures: _("/agreement-signatures"), }) export default paths