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/src/api/session.ts b/src/api/session.ts index f3500a5..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", @@ -17,5 +17,5 @@ const sessionApi = api.injectEndpoints({ }), }) -export const { useLoginWithGithubMutation } = sessionApi export default sessionApi +export const { useLoginMutation } = sessionApi diff --git a/src/app/env.ts b/src/app/env.ts index 6c51555..08637d6 100644 --- a/src/app/env.ts +++ b/src/app/env.ts @@ -2,5 +2,5 @@ import env from "codeforlife/env" export * from "codeforlife/env" -// Example of how to get an environment variable. -export const EXAMPLE = env.VITE_EXAMPLE ?? "DEFAULT_VALUE" +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/home/Home.tsx b/src/pages/home/Home.tsx index 0ec0568..367d215 100644 --- a/src/pages/home/Home.tsx +++ b/src/pages/home/Home.tsx @@ -1,22 +1,87 @@ import * as pages from "codeforlife/components/page" -import { type FC } from "react" +import * as yup from "yup" +import { type FC, useEffect } from "react" +import { Stack, Typography } from "@mui/material" +import { GitHub as GitHubIcon } from "@mui/icons-material" import { Image } from "codeforlife/components" -import { Typography } from "@mui/material" +import { LinkButton } from "codeforlife/components/router" +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 [login] = useLoginMutation() + const sessionMetadata = useSessionMetadata() + const navigate = useNavigate() + const { code } = useSearchParams({ code: yup.string() }) || {} + + useEffect(() => { + if (sessionMetadata) navigate(paths.agreementSignatures._) + else if (code) { + login({ code }) + .unwrap() + .then(() => { + navigate(paths.agreementSignatures._) + }) + .catch(() => { + navigate(".", { + replace: true, + state: { + notifications: [ + { + props: { + error: true, + children: "Failed to login. Please try again.", + }, + }, + ], + }, + }) + }) + } + }, [sessionMetadata, login, navigate, code]) + return ( - code for life logo - Example web page - - This is an example of how you can create a web page. This example - consumes the backend-template's API. - + + code for life logo + + Welcome to our Contributor Service! + + + We are excited to have you join the CFL community. As a contributor, + you have the opportunity to share your knowledge, insights, and + unique perspectives with a passionate audience. Dive in, start + contributing, and help us make a difference. + + ({ + marginTop: spacing(20), + borderRadius: spacing(1), + padding: `${spacing(4)} ${spacing(5)}`, + fontSize: spacing(2.5), + background: "black", + color: "white.main", + })} + startIcon={} + > + Log in with GitHub + + ) 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/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