diff --git a/.env b/.env index dc1cf94..e9feecb 100644 --- a/.env +++ b/.env @@ -1,5 +1,6 @@ -VITE_API_BASE_URL=http://localhost:8000/api/ +# Service. VITE_SERVICE_NAME=portal +VITE_SERVICE_IS_ROOT=1 # Gmail. VITE_GMAIL_FILTERS_PASSWORD_RESET_REQUEST="from:no-reply@info.codeforlife.education subject:Password reset request" diff --git a/.vscode/launch.json b/.vscode/launch.json index 614e569..0d3a1fc 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -18,10 +18,6 @@ "type": "node" }, { - "env": { - "VITE_SERVICE_IS_ROOT": "1", - "VITE_SERVICE_NAME": "portal" - }, "name": "Vite Server", "preLaunchTask": "run", "request": "launch", diff --git a/package.json b/package.json index d9dda66..9b89b00 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "✅ Do add `devDependencies` below that are `peerDependencies` in the CFL package." ], "dependencies": { - "codeforlife": "github:ocadotechnology/codeforlife-package-javascript#v2.3.0", + "codeforlife": "github:ocadotechnology/codeforlife-package-javascript#v2.3.1", "crypto-js": "^4.2.0" }, "devDependencies": { diff --git a/scripts/hard-install b/scripts/hard-install index ea46059..eb4631c 100755 --- a/scripts/hard-install +++ b/scripts/hard-install @@ -1,9 +1,6 @@ #!/bin/bash set -e -cd "${BASH_SOURCE%/*}" +cd "${BASH_SOURCE%/*}/.." -rm -f ../yarn.lock -rm -rf ../node_modules -yarn cache clean codeforlife -yarn install --production=false +wget -O - https://raw.githubusercontent.com/ocadotechnology/codeforlife-workspace/main/scripts/frontend/hard-install | bash diff --git a/scripts/run b/scripts/run index 8ce071e..ec4846d 100755 --- a/scripts/run +++ b/scripts/run @@ -1,8 +1,6 @@ #!/bin/bash set -e -cd "${BASH_SOURCE%/*}" +cd "${BASH_SOURCE%/*}/.." -source ./setup - -yarn dev +wget -O - https://raw.githubusercontent.com/ocadotechnology/codeforlife-workspace/main/scripts/frontend/run | bash diff --git a/scripts/setup b/scripts/setup index ce6aabd..763e6a1 100755 --- a/scripts/setup +++ b/scripts/setup @@ -1,6 +1,6 @@ #!/bin/bash set -e -cd "${BASH_SOURCE%/*}" +cd "${BASH_SOURCE%/*}/.." -yarn install --production=false +wget -O - https://raw.githubusercontent.com/ocadotechnology/codeforlife-workspace/main/scripts/frontend/setup | bash diff --git a/src/api/user.ts b/src/api/user.ts index 03e4923..1c8d2e1 100644 --- a/src/api/user.ts +++ b/src/api/user.ts @@ -3,28 +3,64 @@ import { type CreateArg, type CreateResult, type DestroyResult, + type ListResult, + type RetrieveResult, type UpdateArg, type UpdateResult, buildUrl, tagData, } from "codeforlife/utils/api" -import { type User, urls } from "codeforlife/api" +import { + type Class, + type SchoolTeacher, + type SchoolTeacherUser, + type User, + urls, +} from "codeforlife/api" import getReadUserEndpoints, { type ListUsersArg, - type ListUsersResult, type RetrieveUserArg, - type RetrieveUserResult, USER_TAG, } from "codeforlife/api/endpoints/user" import api from "." -export type { - ListUsersArg, - ListUsersResult, - RetrieveUserArg, - RetrieveUserResult, +export type RetrieveUserResult = RetrieveResult< + User, + | "first_name" + | "last_name" + | "email" + | "is_active" + | "date_joined" + | "student" + | "teacher" +> & { + requesting_to_join_class?: Class & { + teacher: SchoolTeacher & { + user: Omit + } + } } +export { RetrieveUserArg } + +export type ListUsersResult = ListResult< + User, + | "first_name" + | "last_name" + | "email" + | "is_active" + | "date_joined" + | "student" + | "teacher", + { + requesting_to_join_class?: Class & { + teacher: SchoolTeacher & { + user: Omit + } + } + } +> +export { ListUsersArg } export type HandleJoinClassRequestResult = UpdateResult export type HandleJoinClassRequestArg = UpdateArg & { @@ -68,7 +104,12 @@ export type ValidatePasswordArg = Pick const userApi = api.injectEndpoints({ endpoints: build => ({ - ...getReadUserEndpoints(build), + ...getReadUserEndpoints< + RetrieveUserResult, + RetrieveUserArg, + ListUsersResult, + ListUsersArg + >(build), handleJoinClassRequest: build.mutation< HandleJoinClassRequestResult, HandleJoinClassRequestArg diff --git a/src/components/form/ClassNameField.tsx b/src/components/form/ClassNameField.tsx new file mode 100644 index 0000000..8d1eadc --- /dev/null +++ b/src/components/form/ClassNameField.tsx @@ -0,0 +1,37 @@ +import { TextField, type TextFieldProps } from "codeforlife/components/form" +import { type FC } from "react" +import { InputAdornment } from "@mui/material" +import { PeopleAlt as PeopleAltIcon } from "@mui/icons-material" +import { string as YupString } from "yup" + +export type ClassNameFieldProps = Omit< + TextFieldProps, + "type" | "name" | "schema" +> & + Partial> + +const ClassNameField: FC = ({ + name = "name", + label = "Last name", + placeholder = "Enter a class name", + InputProps = {}, + ...otherTextFieldProps +}) => ( + + + + ), + ...InputProps, + }} + {...otherTextFieldProps} + /> +) + +export default ClassNameField diff --git a/src/components/form/ReadClassmatesDataField.tsx b/src/components/form/ReadClassmatesDataField.tsx new file mode 100644 index 0000000..cf0b947 --- /dev/null +++ b/src/components/form/ReadClassmatesDataField.tsx @@ -0,0 +1,32 @@ +import { + CheckboxField, + type CheckboxFieldProps, +} from "codeforlife/components/form" +import { type FC } from "react" + +export type ReadClassmatesDataFieldProps = Omit< + CheckboxFieldProps, + "name" | "formControlLabelProps" +> & + Partial> + +const ReadClassmatesDataField: FC = ({ + name = "read_classmates_data", + formControlLabelProps, + ...otherCheckboxFieldProps +}) => { + const { + label = "Allow students to see their classmates' progress?", + ...otherFormControlLabelProps + } = formControlLabelProps || {} + + return ( + + ) +} + +export default ReadClassmatesDataField diff --git a/src/components/form/TeacherAutocompleteField.tsx b/src/components/form/TeacherAutocompleteField.tsx new file mode 100644 index 0000000..96a1f42 --- /dev/null +++ b/src/components/form/TeacherAutocompleteField.tsx @@ -0,0 +1,27 @@ +import { ApiAutocompleteField } from "codeforlife/components/form" +import { type FC } from "react" + +import { type ListUsersArg, useLazyListUsersQuery } from "../../api/user" + +export interface TeacherAutocompleteFieldProps { + required?: boolean + name?: string + _id?: ListUsersArg["_id"] +} + +const TeacherAutocompleteField: FC = ({ + required = false, + name = "teacher", + _id, +}) => ( + `${first_name} ${last_name}`} + getOptionKey={({ teacher }) => teacher!.id} + textFieldProps={{ required, name }} + /> +) + +export default TeacherAutocompleteField diff --git a/src/components/form/index.tsx b/src/components/form/index.tsx index 82d636a..c688ebf 100644 --- a/src/components/form/index.tsx +++ b/src/components/form/index.tsx @@ -1,14 +1,12 @@ -import LastNameField, { type LastNameFieldProps } from "./LastNameField" -import NewPasswordField, { - type NewPasswordFieldProps, -} from "./NewPasswordField" -import SchoolNameField, { type SchoolNameFieldProps } from "./SchoolNameField" - -export { - LastNameField, - NewPasswordField, - type LastNameFieldProps, - type NewPasswordFieldProps, - SchoolNameField, - type SchoolNameFieldProps, -} +export * from "./ClassNameField" +export { default as ClassNameField } from "./ClassNameField" +export * from "./LastNameField" +export { default as LastNameField } from "./LastNameField" +export * from "./NewPasswordField" +export { default as NewPasswordField } from "./NewPasswordField" +export * from "./ReadClassmatesDataField" +export { default as ReadClassmatesDataField } from "./ReadClassmatesDataField" +export * from "./SchoolNameField" +export { default as SchoolNameField } from "./SchoolNameField" +export * from "./TeacherAutocompleteField" +export { default as TeacherAutocompleteField } from "./TeacherAutocompleteField" diff --git a/src/pages/teacherDashboard/TeacherDashboard.tsx b/src/pages/teacherDashboard/TeacherDashboard.tsx index d79dae3..e93ba0c 100644 --- a/src/pages/teacherDashboard/TeacherDashboard.tsx +++ b/src/pages/teacherDashboard/TeacherDashboard.tsx @@ -1,16 +1,13 @@ import * as page from "codeforlife/components/page" import { type FC, useEffect } from "react" import { type SessionMetadata, useNavigate } from "codeforlife/hooks" -import { CircularProgress } from "@mui/material" import { type SchoolTeacherUser } from "codeforlife/api" import { getParam } from "codeforlife/utils/router" +import { handleQueryState } from "codeforlife/utils/api" import Account, { type AccountProps } from "./account/Account" import Classes, { type ClassesProps } from "./classes/Classes" -import { - type RetrieveUserResult, - useLazyRetrieveUserQuery, -} from "../../api/user" +import { type RetrieveUserResult, useRetrieveUserQuery } from "../../api/user" import School, { type SchoolProps } from "./school/School" import { paths } from "../../routes" @@ -28,10 +25,15 @@ export type TeacherDashboardProps = view?: AccountProps["view"] } -const TeacherDashboard: FC = ({ tab, view }) => { - const [retrieveUser, { data: authUser, isError }] = useLazyRetrieveUserQuery() +const Tabs: FC = ({ + tab, + view, + user_id, +}) => { + const result = useRetrieveUserQuery(user_id) const navigate = useNavigate() + const authUser = result.data const isNonSchoolTeacher = authUser && !authUser.teacher?.school useEffect(() => { @@ -40,63 +42,56 @@ const TeacherDashboard: FC = ({ tab, view }) => { if (isNonSchoolTeacher) return <> - // TODO: handle this better - if (isError) return <>There was an error! + const authSchoolTeacherUser = + authUser as SchoolTeacherUser - const Tabs: FC = ({ user_id }) => { - useEffect(() => { - if (!authUser) void retrieveUser(user_id) - }, [user_id]) + const tabs: page.TabBarProps["tabs"] = [ + { + label: "Your school", + children: ( + + ), + path: getParam(paths.teacher.dashboard.tab.school, "tab"), + }, + { + label: "Your classes", + children: ( + + ), + path: getParam(paths.teacher.dashboard.tab.classes, "tab"), + }, + { + label: "Your account", + children: ( + + ), + path: getParam(paths.teacher.dashboard.tab.account, "tab"), + }, + ] - if (!authUser) return - - const authSchoolTeacherUser = - authUser as SchoolTeacherUser - - const tabs: page.TabBarProps["tabs"] = [ - { - label: "Your school", - children: ( - - ), - path: getParam(paths.teacher.dashboard.tab.school, "tab"), - }, - { - label: "Your classes", - children: ( - - ), - path: getParam(paths.teacher.dashboard.tab.classes, "tab"), - }, - { - label: "Your account", - children: ( - - ), - path: getParam(paths.teacher.dashboard.tab.account, "tab"), - }, - ] - - return ( - t.path === tab)} - tabs={tabs} - /> - ) - } - - return {Tabs} + return handleQueryState(result, authUser => ( + t.path === tab)} + tabs={tabs} + /> + )) } +const TeacherDashboard: FC = props => ( + + {sessionMetadata => } + +) + export default TeacherDashboard diff --git a/src/pages/teacherDashboard/classes/ClassTable.tsx b/src/pages/teacherDashboard/classes/ClassTable.tsx new file mode 100644 index 0000000..3e1c659 --- /dev/null +++ b/src/pages/teacherDashboard/classes/ClassTable.tsx @@ -0,0 +1,75 @@ +import { CopyIconButton, TablePagination } from "codeforlife/components" +import { Create as CreateIcon } from "@mui/icons-material" +import { type FC } from "react" +import { LinkButton } from "codeforlife/components/router" +import { type SchoolTeacherUser } from "codeforlife/api" +import { Typography } from "@mui/material" +import { generatePath } from "react-router" + +import * as tables from "../../../components/table" +import { type RetrieveUserResult } from "../../../api/user" +import { paths } from "../../../routes" +import { useLazyListClassesQuery } from "../../../api/klass" + +export interface ClassTableProps { + authUser: SchoolTeacherUser +} + +const ClassTable: FC = ({ authUser }) => ( + <> + + Your classes + + + {authUser.teacher.is_admin + ? "Below is a list of all the classes in your school, including classes of other teachers. You can add a class or edit your existing classes. You can also accept or deny requests from independent students wanting to join one of your classes." + : "Below is a list of all your classes. You can add a class or edit your existing classes. You can also accept or deny requests from independent students wanting to join one of your classes."} + + + {classes => ( + + {classes.map(klass => ( + + {klass.name} + + {klass.id} + + + {authUser.teacher.is_admin && ( + + {klass.teacher.id === authUser.teacher.id + ? "You" + : `${klass.teacher.user.first_name} ${klass.teacher.user.last_name}`} + + )} + + } + > + Edit details + + + + ))} + + )} + + +) + +export default ClassTable diff --git a/src/pages/teacherDashboard/classes/Classes.tsx b/src/pages/teacherDashboard/classes/Classes.tsx index a37d8e8..0ff725f 100644 --- a/src/pages/teacherDashboard/classes/Classes.tsx +++ b/src/pages/teacherDashboard/classes/Classes.tsx @@ -1,8 +1,12 @@ +import * as pages from "codeforlife/components/page" import { type FC } from "react" import { type SchoolTeacherUser } from "codeforlife/api" import Class from "./Class" +import ClassTable from "./ClassTable" +import CreateClassForm from "./CreateClassForm" import JoinClassRequest from "./JoinClassRequest" +import JoinClassRequestTable from "./JoinClassRequestTable" import { type RetrieveUserResult } from "../../../api/user" export interface ClassesProps { @@ -10,8 +14,6 @@ export interface ClassesProps { view?: "class" | "join-class-request" } -// @ts-expect-error unused var -// eslint-disable-next-line @typescript-eslint/no-unused-vars const Classes: FC = ({ authUser, view }) => { if (view) { return { @@ -20,7 +22,19 @@ const Classes: FC = ({ authUser, view }) => { }[view] } - return <>Classes + return ( + <> + + + + + + + + + + + ) } export default Classes diff --git a/src/pages/teacherDashboard/classes/CreateClassForm.tsx b/src/pages/teacherDashboard/classes/CreateClassForm.tsx new file mode 100644 index 0000000..df58369 --- /dev/null +++ b/src/pages/teacherDashboard/classes/CreateClassForm.tsx @@ -0,0 +1,76 @@ +import * as forms from "codeforlife/components/form" +import { Stack, Typography } from "@mui/material" +import { type FC } from "react" +import { type SchoolTeacherUser } from "codeforlife/api" +import { generatePath } from "react-router" +import { submitForm } from "codeforlife/utils/form" +import { useNavigate } from "codeforlife/hooks" + +import { + ClassNameField, + ReadClassmatesDataField, + TeacherAutocompleteField, +} from "../../../components/form" +import { type RetrieveUserResult } from "../../../api/user" +import { paths } from "../../../routes" +import { useCreateClassMutation } from "../../../api/klass" + +export interface CreateClassFormProps { + authUser: SchoolTeacherUser +} + +const CreateClassForm: FC = ({ authUser }) => { + const [createClass] = useCreateClassMutation() + const navigate = useNavigate() + + return ( + <> + + Create a new class + + + {authUser.teacher.is_admin + ? "When you set up a new class, a unique class access code will automatically be generated for the teacher assigned to the class." + : "When you set up a new class, a unique class access code will automatically be generated, with you being identified as the teacher for that class."} + + { + navigate( + generatePath(paths.teacher.dashboard.tab.classes.class._, { + classId: id, + }), + { + state: { + notifications: [ + { + props: { + children: `The class ${name} has been created successfully.`, + }, + }, + ], + }, + }, + ) + }, + })} + > + + + + {authUser.teacher.is_admin && } + + + Create class + + + + ) +} + +export default CreateClassForm diff --git a/src/pages/teacherDashboard/classes/JoinClassRequestTable.tsx b/src/pages/teacherDashboard/classes/JoinClassRequestTable.tsx new file mode 100644 index 0000000..f6b3c3d --- /dev/null +++ b/src/pages/teacherDashboard/classes/JoinClassRequestTable.tsx @@ -0,0 +1,124 @@ +import { + Add as AddIcon, + DoNotDisturb as DoNotDisturbIcon, +} from "@mui/icons-material" +import { Button, Typography } from "@mui/material" +import { type FC } from "react" +import { LinkButton } from "codeforlife/components/router" +import { type SchoolTeacherUser } from "codeforlife/api" +import { TablePagination } from "codeforlife/components" +import { generatePath } from "react-router" +import { useNavigate } from "codeforlife/hooks" + +import * as tables from "../../../components/table" +import { + useHandleJoinClassRequestMutation, + useLazyListUsersQuery, +} from "../../../api/user" +import { type RetrieveUserResult } from "../../../api/user" +import { paths } from "../../../routes" + +export interface JoinClassRequestTableProps { + authUser: SchoolTeacherUser +} + +const JoinClassRequestTable: FC = ({ + authUser, +}) => { + const [handleJoinClassRequest] = useHandleJoinClassRequestMutation() + const navigate = useNavigate() + + return ( + <> + + External requests to join your classes + + + External or independent students may request to join your classes if the + student has been given a Class Access Code, and provided you have + enabled external requests for that class. + + + {users => + users.length ? ( + + {users.map(user => ( + + + + {user.first_name} {user.last_name} + + + + {user.email} + + + {user.requesting_to_join_class!.id} + {user.requesting_to_join_class!.teacher.id === + authUser.teacher.id + ? "" + : ` (${user.requesting_to_join_class!.teacher.user.first_name} ${user.requesting_to_join_class!.teacher.user.last_name})`} + + + } + > + Add to class + + + + + ))} + + ) : ( + + No student has currently requested to join your classes. + + ) + } + + + ) +} + +export default JoinClassRequestTable diff --git a/src/pages/teacherDashboard/school/Leave.tsx b/src/pages/teacherDashboard/school/Leave.tsx index 00ef65c..e7d57e9 100644 --- a/src/pages/teacherDashboard/school/Leave.tsx +++ b/src/pages/teacherDashboard/school/Leave.tsx @@ -4,21 +4,19 @@ import * as yup from "yup" import { CircularProgress, Stack, Typography } from "@mui/material" import { type FC, useEffect } from "react" import { Link, LinkButton } from "codeforlife/components/router" -import { type SchoolTeacher, type User } from "codeforlife/api" import { useNavigate, useParams } from "codeforlife/hooks" import { TablePagination } from "codeforlife/components" +import { type User } from "codeforlife/api" +import { submitForm } from "codeforlife/utils/form" import * as table from "../../../components/table" import { useLazyListClassesQuery, useUpdateClassesMutation, } from "../../../api/klass" -import { - useLazyListUsersQuery, - useLazyRetrieveUserQuery, -} from "../../../api/user" +import { TeacherAutocompleteField } from "../../../components/form" import { paths } from "../../../routes" -import { submitForm } from "codeforlife/utils/form" +import { useLazyRetrieveUserQuery } from "../../../api/user" import { useRemoveTeacherFromSchoolMutation } from "../../../api/teacher" export interface LeaveProps { @@ -139,20 +137,10 @@ const Leave: FC = ({ authUserId }) => { - - `${first_name} ${last_name}` - } - getOptionKey={({ teacher }) => - (teacher as SchoolTeacher).id - } - textFieldProps={{ - required: true, - name: `${klass.id}.teacher`, - }} - searchKey="name" + diff --git a/src/pages/teacherDashboard/school/TeacherInvitationTable.tsx b/src/pages/teacherDashboard/school/TeacherInvitationTable.tsx index 81c5c89..e46c535 100644 --- a/src/pages/teacherDashboard/school/TeacherInvitationTable.tsx +++ b/src/pages/teacherDashboard/school/TeacherInvitationTable.tsx @@ -64,6 +64,7 @@ const TeacherInvitationTable: FC = ({ return ( {schoolTeacherInvitations => ( = ({ authUser }) => { return ( {users => ( = ({ authUser }) => { : ["Name", "Administrator status"] } > - {( - users as Array> - ).map(user => ( + {users.map(user => ( @@ -85,7 +83,7 @@ const TeacherTable: FC = ({ authUser }) => { justifyContent="flex-start" > - {user.teacher.is_admin + {user.teacher!.is_admin ? "Teacher Administrator" : "Standard Teacher"} @@ -103,12 +101,12 @@ const TeacherTable: FC = ({ authUser }) => { Update details )} - {user.teacher.is_admin ? ( + {user.teacher!.is_admin ? (