diff --git a/.gitignore b/.gitignore index 1578f516..b9e86c8e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ # production /build +/dist # misc .DS_Store diff --git a/public/assets/icons/Uncheck-icon.png b/public/assets/icons/Uncheck-icon.png new file mode 100644 index 00000000..6fc4d3b2 Binary files /dev/null and b/public/assets/icons/Uncheck-icon.png differ diff --git a/public/assets/icons/delete-icon-24.png b/public/assets/icons/delete-icon-24.png new file mode 100644 index 00000000..dff8fb65 Binary files /dev/null and b/public/assets/icons/delete-icon-24.png differ diff --git a/public/assets/icons/edit-icon-24.png b/public/assets/icons/edit-icon-24.png new file mode 100644 index 00000000..4ec0860c Binary files /dev/null and b/public/assets/icons/edit-icon-24.png differ diff --git a/public/assets/icons/info.png b/public/assets/icons/info.png new file mode 100644 index 00000000..e0626bec Binary files /dev/null and b/public/assets/icons/info.png differ diff --git a/src/App.tsx b/src/App.tsx index 832e1837..2e6e5fe9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -56,6 +56,7 @@ import PartnerAdvertisements from 'components/SignupSheet/PartnerAdvertisements' import Duties from "./pages/Duties/Duties"; import DutyEditor from "./pages/Duties/DutyEditor"; import ReviewReportPage from "./pages/Reviews/ReviewReportPage"; +import AssignmentParticipants from "./pages/AssignmentParticipants/AssignmentParticipants"; function App() { const router = createBrowserRouter([ { @@ -227,19 +228,7 @@ function App() { { path: "assignments/edit/:assignmentId/participants", - element: , - children: [ - { - path: "new", - element: , - loader: loadParticipantDataRolesAndInstitutions, - }, - { - path: "edit/:id", - element: , - loader: loadParticipantDataRolesAndInstitutions, - }, - ], + element: , }, { diff --git a/src/components/Modals/ExportModal.tsx b/src/components/Modals/ExportModal.tsx index 9e13af17..0d346228 100644 --- a/src/components/Modals/ExportModal.tsx +++ b/src/components/Modals/ExportModal.tsx @@ -3,6 +3,7 @@ import React, { useEffect, useState, memo, useCallback } from "react"; import { Modal, Button, Form, Row, Col, OverlayTrigger, Tooltip } from "react-bootstrap"; import useAPI from "../../hooks/useAPI"; import { HttpMethod } from "../../utils/httpMethods"; +import { publicUrl } from "../../utils/publicUrl"; /* ============================================================================= Shared visual style — same as CreateTeams.tsx @@ -26,19 +27,7 @@ const TABLE_TEXT: React.CSSProperties = { Icon utilities — same pattern as Import modal ============================================================================= */ -const getBaseUrl = (): string => { - if (typeof document !== 'undefined') { - const base = document.querySelector('base[href]') as HTMLBaseElement | null; - if (base?.href) return base.href.replace(/\/$/, ''); - } - const fromGlobal = (globalThis as any)?.__BASE_URL__; - if (typeof fromGlobal === 'string' && fromGlobal) return fromGlobal.replace(/\/$/, ''); - const fromProcess = - (typeof process !== 'undefined' && (process as any)?.env?.PUBLIC_URL) || ''; - return String(fromProcess).replace(/\/$/, ''); -}; - -const assetUrl = (rel: string) => `${getBaseUrl()}/${rel.replace(/^\//, '')}`; +const assetUrl = (rel: string) => publicUrl(rel.replace(/^\//, '')); diff --git a/src/components/Modals/ImportModal.tsx b/src/components/Modals/ImportModal.tsx index 2e2b37b8..f0e63799 100644 --- a/src/components/Modals/ImportModal.tsx +++ b/src/components/Modals/ImportModal.tsx @@ -17,6 +17,7 @@ import useAPI from "../../hooks/useAPI"; import { HttpMethod } from "../../utils/httpMethods"; import PreviewTable from "../Table/Table"; import { ColumnDef } from "@tanstack/react-table"; +import { publicUrl } from "../../utils/publicUrl"; /* ---------------------------------------- * Shared text styles for consistency @@ -39,24 +40,7 @@ const TABLE_TEXT: React.CSSProperties = { * Icon utilities — used for tooltip icons * ---------------------------------------- */ -/** Helper to resolve asset URLs correctly even under nested routes */ -const getBaseUrl = (): string => { - if (typeof document !== "undefined") { - const base = document.querySelector("base[href]") as HTMLBaseElement | null; - if (base?.href) return base.href.replace(/\/$/, ""); - } - - const fromGlobal = (globalThis as any)?.__BASE_URL__; - if (typeof fromGlobal === "string") return fromGlobal.replace(/\/$/, ""); - - const fromProcess = - (typeof process !== "undefined" && (process as any)?.env?.PUBLIC_URL) || ""; - - return String(fromProcess).replace(/\/$/, ""); -}; - -/** Helper for converting relative asset paths to usable URLs */ -const assetUrl = (rel: string) => `${getBaseUrl()}/${rel.replace(/^\//, "")}`; +const assetUrl = (rel: string) => publicUrl(rel.replace(/^\//, "")); /** Asset map */ const ICONS = { diff --git a/src/pages/AssignmentParticipants/AssignmentParticipants.css b/src/pages/AssignmentParticipants/AssignmentParticipants.css new file mode 100644 index 00000000..4b1811f9 --- /dev/null +++ b/src/pages/AssignmentParticipants/AssignmentParticipants.css @@ -0,0 +1,280 @@ +/* Container and Header */ +.assignment-participants-container { + padding: 1.5rem; + border-radius: 0.5rem; + width: 100%; + max-width: 100%; + margin: 1rem auto; +} + +.assignment-participants-header { + font-size: 1.5rem; + font-weight: bold; + color: #333333; + margin-bottom: 1.5rem; +} + +.section-label { + display: block; + font-weight: bold; + font-size: 1rem; + color: #333; + margin-bottom: 0.5rem; +} + +/* Search Filter Section */ +.search-filter-section { + display: flex; + align-items: flex-start; + flex-wrap: nowrap; + gap: 1rem; + margin-bottom: 1rem; +} + +.search-input { + color: #333; + font-size: 0.95rem; + font-weight: 500; + border: 1px solid #2563eb; + border-radius: 0.5rem; + background-color: #ffffff; + height: 42px; + width: 100%; + max-width: 440px; + flex: 1 1 320px; + box-sizing: border-box; + cursor: text; + transition: border-color 0.3s ease, box-shadow 0.3s ease; + margin: 0; +} + +.search-input::placeholder { + color: #aaa; + font-weight: 400; +} + +.filter-select { + color: #333; + font-size: 0.95rem; + font-weight: 500; + padding: 0 1rem; + height: 42px; + width: 180px; + min-width: 180px; + flex: 0 0 180px; + border: 1px solid #2563eb; + border-radius: 0.5rem; + background-color: #ffffff; + cursor: pointer; + transition: border-color 0.3s ease, box-shadow 0.3s ease; + margin: 0; +} + +.filter-select:hover { + /* Slightly darker shade for hover */ + border-color: #1d4ed8; + /* Soft blue glow */ + box-shadow: 0 0 5px rgba(29, 78, 216, 0.4); +} + +.filter-select:focus { + outline: none; + border-color: #1d4ed8; + /* Blue focus ring */ + box-shadow: 0 0 0 3px rgba(29, 78, 216, 0.3); +} + +.filter-select option { + font-size: 1rem; + padding: 0.5rem; +} + +/* Add User Section */ +.add-user-section { + display: flex; + width: 100%; + justify-content: space-between; + flex-wrap: wrap; + align-content: center; + gap: 1rem; + margin-bottom: 0.75rem; + border-bottom: 1px solid #e5e7eb; +} + +.user-permissions { + display: flex; + gap: 1rem; +} + +.permission-yes, +.permission-no { + font-size: 1.5rem; + /* Increase icon size */ + display: inline-flex; + align-items: center; + justify-content: center; +} + +.user-input { + flex: 1; + min-width: 400px; + height: 42px; + border: 1px solid #ccc; + border-radius: 0.25rem; + font-size: 16px; + box-sizing: border-box; +} + +.user-input::placeholder { + color: #aaa; + font-weight: 400; +} + +.add-user-button { + color: #10b981; + min-width: 90px; + height: 42px; + /* Green tone for positive action */ + font-weight: 600; + cursor: pointer; + background-color: transparent; + border: 1px solid transparent; + padding: 0.4em 0.8em; + border-radius: 4px; + border-color: #10d6a6; + transition: color 0.2s ease, background-color 0.2s ease, border-color 0.2s ease; +} + +.add-user-button:hover { + color: #059669; + /* Darker green shade for hover effect */ + background-color: rgba(16, 185, 129, 0.1); + border-color: #10b981; + text-decoration: none; +} + +.add-user-button:focus { + /* Focus ring for accessibility */ + outline: none; + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.4); +} + +/* Role Radio Group Styling */ +.role-radio-group { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + justify-content: flex-end; + align-items: flex-start; + padding: 0 0.5rem; + margin-top: -0.25rem; + margin-bottom: 0.25rem; + width: 100%; +} + +.role-radio-option { + padding: 0 0.85rem; + position: relative; + display: flex; + align-items: center; + font-size: 1rem; + color: #333333; +} + +.role-radio-option input { + margin-right: 0.35rem; + transform: scale(1.15); + transform-origin: center; +} + +.role-radio-option img { + width: 18px; + height: 18px; +} + +/* Info Icon Styling */ +.info-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + margin-left: 0.5rem; + background-color: #4a90e2; + color: #ffffff; + font-size: 0.75rem; + font-weight: bold; + border-radius: 50%; + cursor: pointer; + position: relative; + transition: background-color 0.2s ease; + z-index: 20; +} + +.info-icon:hover { + background-color: #357bd8; +} + +.info-icon:hover::after { + content: attr(title); + position: absolute; + top: -1.5rem; + left: auto; + right: 0; + background-color: #333; + color: #fff; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + font-size: 0.75rem; + white-space: normal; + transform: translateX(0); + z-index: 10; + word-wrap: break-word; +} + +/* Role Select and Submit Button */ +.role-action { + display: flex; + align-items: center; + gap: 8px; +} + +.role-select { + padding: 8px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 14px; +} + +@media (max-width: 768px) { + + .add-user-section, + .user-permissions { + width: 100%; + flex-direction: column; + } + + .search-filter-section { + flex-wrap: wrap; + } + + .filter-select { + width: 100%; + min-width: 0; + flex: 1 1 100%; + } + + .role-radio-group { + margin-top: 1rem; + } + + .user-input { + margin-bottom: 0; + width: 100%; + } +} + +.error-message { + margin-top: 0.5rem; + margin-bottom: 1rem; +} diff --git a/src/pages/AssignmentParticipants/AssignmentParticipants.tsx b/src/pages/AssignmentParticipants/AssignmentParticipants.tsx new file mode 100644 index 00000000..79f89a93 --- /dev/null +++ b/src/pages/AssignmentParticipants/AssignmentParticipants.tsx @@ -0,0 +1,380 @@ +import './AssignmentParticipants.css'; + +import { useEffect, useMemo, useState } from 'react'; +import EditParticipantModal from './EditParticipantModal'; +import ConfirmRemoveModal from './ConfirmRemoveModal'; +import ParticipantTable from './ParticipantsTable'; +import { + assignmentTableFlagsFromResponse, + displayNameForUser, + findUserByIdentifier, + normalizeParticipantRole, + normalizeUserRole, + participantRoleInfo, +} from './AssignmentParticipantsUtil'; +import { AssignmentProperties, IsEnabled, Participant, ParticipantRole, Role } from './AssignmentParticipantsTypes'; +import useAPI from 'hooks/useAPI'; +import { useParams } from 'react-router-dom'; +import { IAssignmentResponse } from 'utils/interfaces'; +import { HttpMethod } from 'utils/httpMethods'; +import { FaCheck, FaInfoCircle, FaTimes } from 'react-icons/fa'; +import { Form, Button, OverlayTrigger, Tooltip } from 'react-bootstrap'; + +const UI_ROLE_TO_API_ROLE_NAME: Record = { + admin: 'administrator', + student: 'student', + instructor: 'instructor', + 'teaching assistant': 'teaching assistant', +}; + +/** Manages assignment participant listing, filtering, and participant CRUD actions. */ +function AssignmentParticipants() { + const { data: participantsResponse, sendRequest: fetchParticipants } = useAPI(); + const { data: usersResponse, sendRequest: fetchUsers } = useAPI(); + const { data: assignmentResponse, sendRequest: fetchAssignment } = useAPI(); + const { data: rolesResponse, sendRequest: fetchRoles } = useAPI(); + + const { data: addParticipantResponse, sendRequest: addParticipant } = useAPI(); + const { data: updateParticipantResponse, sendRequest: updateParticipant } = useAPI(); + const { data: updateUserResponse, sendRequest: updateUser } = useAPI(); + const { data: deleteParticipantResponse, sendRequest: deleteParticipant } = useAPI(); + + const [participants, setParticipants] = useState([]); + const [newUserName, setNewUserName] = useState(''); + const [selectedRole, setSelectedRole] = useState(ParticipantRole.Participant); + const [searchTerm, setSearchTerm] = useState(''); + const [selectedFilterRole, setSelectedFilterRole] = useState('All'); + const [modalShow, setModalShow] = useState({ edit: false, remove: false }); + const [selectedParticipant, setSelectedParticipant] = useState(null); + const [error, setError] = useState(null); + const [saveError, setSaveError] = useState(null); + const [removeError, setRemoveError] = useState(null); + + const { assignmentId } = useParams(); + + const assignmentProps: AssignmentProperties = useMemo(() => { + const data = assignmentResponse?.data as IAssignmentResponse | undefined; + if (!data) return { hasQuiz: false, hasMentor: false }; + return assignmentTableFlagsFromResponse(data); + }, [assignmentResponse?.data]); + + const assignmentName = (assignmentResponse?.data as IAssignmentResponse | undefined)?.name ?? "assignment"; + + const roleOptions = useMemo( + () => [Role.Admin, Role.Instructor, Role.Student, Role.TeachingAssistant], + [] + ); + + /** Maps a UI role label to its API role id from the fetched roles list. */ + const resolveRoleId = (uiRoleName: string): number | undefined => { + const backendName = UI_ROLE_TO_API_ROLE_NAME[uiRoleName.toLowerCase().trim()] ?? uiRoleName.toLowerCase().trim(); + const match = (rolesResponse?.data ?? []).find( + (r: any) => String(r?.name ?? '').toLowerCase().trim() === backendName + ); + return match?.id; + }; + + /** Refreshes assignment, users, participants, and roles after modal/API state changes. */ + useEffect(() => { + if (!modalShow.edit && !modalShow.remove) { + fetchParticipants({ url: `/participants/assignment/${assignmentId}` }); + fetchUsers({ url: "/users" }); + fetchRoles({ url: "/roles" }); + fetchAssignment({ url: `/assignments/${assignmentId}` }); + } + }, [ + assignmentId, + modalShow.edit, + modalShow.remove, + addParticipantResponse, + updateParticipantResponse, + updateUserResponse, + deleteParticipantResponse, + ]); + + /** Normalizes API participant payload into table-ready participant rows. */ + useEffect(() => { + if (participantsResponse?.data && assignmentResponse?.data) { + const assignment = assignmentResponse.data as IAssignmentResponse; + const mapped = participantsResponse.data + .map((participant: any) => { + const user = participant.user; + if (!user) return null; + return { + id: participant.id, + user_id: user.id, + name: displayNameForUser(user), + email: user.email ?? "", + role: normalizeUserRole(user.role?.name), + parent: assignment.name, + permissions: { + review: participant.can_review ? IsEnabled.Yes : IsEnabled.No, + submit: participant.can_submit ? IsEnabled.Yes : IsEnabled.No, + takeQuiz: participant.can_take_quiz ? IsEnabled.Yes : IsEnabled.No, + mentor: participant.can_mentor ? IsEnabled.Yes : IsEnabled.No, + }, + participantRole: normalizeParticipantRole(participant.authorization), + }; + }) + .filter(Boolean) as Participant[]; + setParticipants(mapped); + } + }, [participantsResponse, assignmentResponse]); + + /** Filters participants by selected role and search text. */ + const filteredParticipants = useMemo(() => { + return participants.filter((participant) => { + return ( + (selectedFilterRole === 'All' || participant.role === selectedFilterRole) && + (participant.name.toLowerCase().includes(searchTerm.toLowerCase()) || + participant.email.toLowerCase().includes(searchTerm.toLowerCase())) + ); + }); + }, [participants, searchTerm, selectedFilterRole]); + + /** Opens the edit modal for the chosen participant. */ + const openEditModal = (participant: Participant) => { + setSelectedParticipant(participant); + setSaveError(null); + setModalShow({ edit: true, remove: false }); + }; + + /** Opens the remove confirmation modal for the chosen participant. */ + const openRemoveModal = (participant: Participant) => { + setSelectedParticipant(participant); + setModalShow({ edit: false, remove: true }); + }; + + /** + * Deletes the selected participant. Awaits the DELETE response and only closes + * the confirmation modal on success; surfaces an error message on failure. + */ + const handleRemove = async () => { + if (!selectedParticipant) return; + + try { + await deleteParticipant({ + url: `/participants/${selectedParticipant.id}`, + method: 'DELETE', + }); + setRemoveError(null); + setModalShow({ edit: false, remove: false }); + } catch (err: any) { + const message = + err?.response?.data?.message ?? + err?.message ?? + 'Failed to remove participant. Please try again.'; + setRemoveError(message); + } + }; + + /** + * Persists participant authorization and user profile/role edits. + * Awaits both backend writes; only closes the modal on success and surfaces an + * inline error on failure or when the role id cannot be resolved. + */ + const handleSave = async (updatedParticipant: Participant) => { + const roleId = resolveRoleId(updatedParticipant.role); + + if (roleId == null) { + setSaveError( + `Could not resolve a role id for "${updatedParticipant.role}". Please refresh and try again.` + ); + return; + } + + setSaveError(null); + + try { + await Promise.all([ + updateParticipant({ + url: `/participants/${updatedParticipant.id}/${updatedParticipant.participantRole}`, + method: HttpMethod.PATCH, + }), + updateUser({ + url: `/users/${updatedParticipant.user_id}`, + method: HttpMethod.PATCH, + data: { + full_name: updatedParticipant.name, + email: updatedParticipant.email, + role_id: roleId, + }, + }), + ]); + setModalShow({ edit: false, remove: false }); + } catch (err: any) { + const message = + err?.response?.data?.message ?? + err?.message ?? + 'Failed to save participant. Please try again.'; + setSaveError(message); + } + }; + + /** Validates lookup input and adds a matched user as an assignment participant. */ + const handleAddUser = async () => { + if (!newUserName.trim()) { + setError('Name must not be empty.'); + return; + } + + const user = findUserByIdentifier(usersResponse?.data ?? [], newUserName); + + if (!user) { + setError('User not found.'); + return; + } + + if (participants.some((p) => p.user_id === user.id)) { + setError('This user is already a participant.'); + return; + } + + try { + await addParticipant({ + url: `/participants/${selectedRole}`, + method: 'POST', + data: { + user_id: user.id, + assignment_id: Number(assignmentId), + }, + }); + setError(null); + setNewUserName(''); + } catch (err: any) { + const message = + err?.response?.data?.message ?? + err?.message ?? + 'Failed to add participant. Please try again.'; + setError(message); + } + }; + + return ( +
+

Assignment Participants: {assignmentName}

+ + {error &&
{error}
} +
+
+ setNewUserName(e.target.value)} + aria-label="Username, name, or email" + /> + +
+ +
+ {Object.values(ParticipantRole) + .filter((role) => role !== ParticipantRole.Unknown) + .map((role) => ( + + ))} +
+
+ + +
+ setSearchTerm(e.target.value)} + aria-label="Search participants" + /> + setSelectedFilterRole(e.target.value as Role | 'All')} + aria-label="Filter participants by role" + > + + {Object.values(Role).map((role) => ( + + ))} + +
+ + + + {selectedParticipant && ( + { + setSaveError(null); + setModalShow({ ...modalShow, edit: false }); + }} + onSave={handleSave} + errorMessage={saveError} + /> + )} + { + setRemoveError(null); + setModalShow({ ...modalShow, remove: false }); + }} + onConfirm={handleRemove} + /> +
+ ); +} + +/** + * Renders an accessible icon for a permission cell. + * @param permission Whether the permission is granted. + * @param permissionLabel Short column label for screen readers, e.g. "Review permission". + */ +export function permissionIcon(permission: IsEnabled, permissionLabel?: string) { + const yes = permission === IsEnabled.Yes; + const ariaLabel = permissionLabel + ? `${permissionLabel}: ${yes ? "Yes" : "No"}` + : yes + ? "Permission enabled" + : "Permission disabled"; + return yes ? ( + + ) : ( + + ); +} + +export default AssignmentParticipants; diff --git a/src/pages/AssignmentParticipants/AssignmentParticipantsTypes.ts b/src/pages/AssignmentParticipants/AssignmentParticipantsTypes.ts new file mode 100644 index 00000000..1207850c --- /dev/null +++ b/src/pages/AssignmentParticipants/AssignmentParticipantsTypes.ts @@ -0,0 +1,46 @@ +export enum IsEnabled { + Yes = 'yes', + No = 'no', +} + +export enum Role { + Student = "Student", + Instructor = "Instructor", + Admin = "Admin", + TeachingAssistant = "Teaching Assistant", + /** Backend sent a role name we do not model; avoid silent string casts. */ + Unknown = "Unknown", +} + +export enum ParticipantRole { + Participant = "participant", + Reader = "reader", + Reviewer = "reviewer", + Submitter = "submitter", + Mentor = "mentor", + /** Authorization string from API did not match a known participant role. */ + Unknown = "unknown", +} + +export interface ParticipantPermissions { + review: IsEnabled; + submit: IsEnabled; + takeQuiz: IsEnabled; + mentor: IsEnabled; +} + +export interface Participant { + id: number; + user_id: number; + name: string; + email: string; + role: Role; + parent: string; + permissions: ParticipantPermissions; + participantRole: ParticipantRole; +} + +export interface AssignmentProperties { + hasQuiz: boolean; + hasMentor: boolean; +} diff --git a/src/pages/AssignmentParticipants/AssignmentParticipantsUtil.tsx b/src/pages/AssignmentParticipants/AssignmentParticipantsUtil.tsx new file mode 100644 index 00000000..04af4731 --- /dev/null +++ b/src/pages/AssignmentParticipants/AssignmentParticipantsUtil.tsx @@ -0,0 +1,141 @@ +import { AssignmentProperties, IsEnabled, ParticipantRole, Role } from "./AssignmentParticipantsTypes"; +import type { IAssignmentResponse } from "utils/interfaces"; + +/** Pure helper functions for assignment participant mapping and display logic. */ + +/** Maps backend `user.role.name` to a UI role; unrecognized values become `Role.Unknown`. */ +export function normalizeUserRole(apiRoleName: string | undefined | null): Role { + if (apiRoleName == null || String(apiRoleName).trim() === "") return Role.Unknown; + const raw = String(apiRoleName).trim(); + const aliases: Record = { + Administrator: Role.Admin, + "Super Administrator": Role.Admin, + Student: Role.Student, + Instructor: Role.Instructor, + Admin: Role.Admin, + "Teaching Assistant": Role.TeachingAssistant, + }; + const byAlias = aliases[raw]; + if (byAlias !== undefined) return byAlias; + const lower = raw.toLowerCase(); + if (lower === "student") return Role.Student; + if (lower === "instructor") return Role.Instructor; + if (lower === "admin" || lower === "administrator") return Role.Admin; + if (lower === "teaching assistant") return Role.TeachingAssistant; + for (const v of Object.values(Role) as Role[]) { + if (v === Role.Unknown) continue; + if (v === raw) return v; + } + return Role.Unknown; +} + +/** Maps backend `participant.authorization` to `ParticipantRole`; unrecognized → `Unknown`. */ +export function normalizeParticipantRole(authorization: string | undefined | null): ParticipantRole { + if (authorization == null || String(authorization).trim() === "") { + return ParticipantRole.Participant; + } + const raw = String(authorization).trim(); + for (const pr of Object.values(ParticipantRole) as ParticipantRole[]) { + if (pr === ParticipantRole.Unknown) continue; + if (pr === raw || pr.toLowerCase() === raw.toLowerCase()) return pr; + } + return ParticipantRole.Unknown; +} + +/** Resolves a row label when /users omits or blanks full_name and name (common with seeds). */ +export function displayNameForUser(user: { + full_name?: string | null; + name?: string | null; + fullName?: string | null; + email?: string | null; +}): string { + const trimmed = [user.full_name, user.fullName, user.name] + .find((v) => typeof v === "string" && v.trim().length > 0); + if (trimmed) return trimmed.trim(); + const email = user.email?.trim() ?? ""; + if (!email) return ""; + const local = email.split("@")[0]; + return local || email; +} + +/** Match login, full name, or email (case-insensitive) for add-participant lookup. */ +export function findUserByIdentifier( + users: T[], + query: string +): T | undefined { + const q = query.trim().toLowerCase(); + if (!q) return undefined; + return users.find((u) => { + const name = (u.name ?? "").trim().toLowerCase(); + const full = (u.full_name ?? "").trim().toLowerCase(); + const email = (u.email ?? "").trim().toLowerCase(); + return name === q || full === q || email === q; + }); +} + +/** Table column visibility for quiz / mentor from GET /assignments/:id (snake_case from API). */ +export function assignmentTableFlagsFromResponse(assignment: IAssignmentResponse): AssignmentProperties { + return { + // Require both flags so we do not show "Take quiz" when quizzes exist but are not required. + hasQuiz: Boolean(assignment.require_quiz) && Boolean(assignment.has_quizzes), + hasMentor: Boolean(assignment.has_mentors), + }; +} + +/** Computes table colspan based on optional quiz and mentor permission columns. */ +export function assignmentColSpan(assignmentProps: AssignmentProperties): number { + return assignmentProps.hasQuiz && assignmentProps.hasMentor ? 12 : assignmentProps.hasQuiz || assignmentProps.hasMentor ? 11 : 10; +} + +/** Returns the CSS class used to color a participant's role label. */ +export function classForRole(role: Role): string { + switch (role) { + case Role.Student: + return "role-student"; + case Role.Instructor: + return "role-instructor"; + case Role.Admin: + return "role-admin"; + case Role.TeachingAssistant: + return "role-instructor"; + case Role.Unknown: + return "role-unknown"; + default: + return ""; + } +} + +/** Intentionally empty: role row uses text styling only (project icon guidelines). */ +export function iconForRole(_role: Role): JSX.Element { + return <>; +} + +/** Returns the CSS class used to style enabled/disabled permission cells. */ +export function classForStatus(isEnabled: IsEnabled): string { + return isEnabled === IsEnabled.Yes ? "status-yes" : "status-no"; +} + +/** Returns the help text shown for each participant authorization option. */ +export function participantRoleInfo(role: ParticipantRole): string { + switch (role) { + case ParticipantRole.Participant: + return "A participant can submit artifacts, review artifacts and take a quiz."; + case ParticipantRole.Reader: + return "A reader can review artifacts and take a quiz, but cannot submit artifacts."; + case ParticipantRole.Reviewer: + return "A reviewer can only review artifacts."; + case ParticipantRole.Submitter: + return "A submitter can only submit artifacts."; + case ParticipantRole.Mentor: + return "A mentor can submit, review, take quizzes, and has mentor permissions."; + case ParticipantRole.Unknown: + return "Authorization role could not be matched to a known participant role."; + default: + return ""; + } +} + +/** Reads a dotted path from an object; only stops on null/undefined intermediates (not on false/0/""). */ +export function getNestedValue(obj: T, path: string): any { + return path.split(".").reduce((acc: any, key: string) => (acc == null ? undefined : acc[key]), obj); +} diff --git a/src/pages/AssignmentParticipants/ConfirmRemoveModal.tsx b/src/pages/AssignmentParticipants/ConfirmRemoveModal.tsx new file mode 100644 index 00000000..a3cf753f --- /dev/null +++ b/src/pages/AssignmentParticipants/ConfirmRemoveModal.tsx @@ -0,0 +1,37 @@ +import Modal from 'react-bootstrap/Modal'; +import Button from 'react-bootstrap/Button'; + +interface ConfirmRemoveModalProps { + show: boolean; + onHide: () => void; + onConfirm: () => void | Promise; + errorMessage?: string | null; +} + +function ConfirmRemoveModal({ show, onHide, onConfirm, errorMessage }: ConfirmRemoveModalProps) { + return ( + + + Confirm Removal + + +

Are you sure you want to remove this participant?

+ {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} +
+ + + + +
+ ); +} + +export default ConfirmRemoveModal; diff --git a/src/pages/AssignmentParticipants/EditParticipantModal.css b/src/pages/AssignmentParticipants/EditParticipantModal.css new file mode 100644 index 00000000..b457ce3b --- /dev/null +++ b/src/pages/AssignmentParticipants/EditParticipantModal.css @@ -0,0 +1,29 @@ +.edit-participant-modal .permissions-container { + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin-top: 2rem; + justify-content: space-between; +} + +.edit-participant-modal .permission-switch { + min-width: 120px; +} + +.edit-participant-modal .modal-body .form-group, +.edit-participant-modal .modal-body .mb-3 { + margin-bottom: 1rem; +} + +.edit-participant-modal .modal-body .form-label { + font-weight: bold; +} + +.edit-participant-modal .modal-body .form-control { + min-height: 2.5rem; +} + +.edit-participant-modal .modal-footer button { + font-size: 1rem; + padding: 0.5rem 1rem; +} diff --git a/src/pages/AssignmentParticipants/EditParticipantModal.tsx b/src/pages/AssignmentParticipants/EditParticipantModal.tsx new file mode 100644 index 00000000..e04b4d4a --- /dev/null +++ b/src/pages/AssignmentParticipants/EditParticipantModal.tsx @@ -0,0 +1,123 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Participant, ParticipantRole, Role } from './AssignmentParticipantsTypes'; +import Modal from 'react-bootstrap/Modal'; +import Button from 'react-bootstrap/Button'; +import Form from 'react-bootstrap/Form'; +import './EditParticipantModal.css'; + +interface EditParticipantModalProps { + participant: Participant; + roleOptions: string[]; + show: boolean; + onHide: () => void; + onSave: (updatedParticipant: Participant) => void; + errorMessage?: string | null; +} + +/** Modal form for editing a participant; parent controls closing after save succeeds. */ +function EditParticipantModal({ participant, roleOptions, show, onHide, onSave, errorMessage }: EditParticipantModalProps) { + const [updatedParticipant, setUpdatedParticipant] = useState(participant); + + useEffect(() => { + setUpdatedParticipant(participant); + }, [participant]); + + const handleChange = (field: keyof Participant, value: any) => { + setUpdatedParticipant({ ...updatedParticipant, [field]: value }); + }; + + const selectedRoleValue = + String(updatedParticipant.role).toLowerCase() === "administrator" + ? Role.Admin + : updatedParticipant.role; + + const userRoleSelectOptions = useMemo(() => { + const opts = [...roleOptions]; + if (updatedParticipant.role === Role.Unknown && !opts.includes(Role.Unknown)) { + opts.push(Role.Unknown); + } + return opts; + }, [roleOptions, updatedParticipant.role]); + + const participantRoleSelectOptions = useMemo( + () => + Object.values(ParticipantRole).filter( + (r) => r !== ParticipantRole.Unknown || updatedParticipant.participantRole === ParticipantRole.Unknown + ), + [updatedParticipant.participantRole] + ); + + return ( + + + Edit participant + + +
+ + Name + handleChange('name', e.target.value)} + /> + + + Email + handleChange('email', e.target.value)} + /> + + + Role + handleChange('role', e.target.value)} + > + {userRoleSelectOptions.map((role) => ( + + ))} + + + + Participant role + handleChange('participantRole', e.target.value)} + > + {participantRoleSelectOptions.map((role) => ( + + ))} + + +
+
+ + {errorMessage && ( +
+ {errorMessage} +
+ )} +
+ +
+
+
+ ); +} + +export default EditParticipantModal; diff --git a/src/pages/AssignmentParticipants/ParticipantsTable.css b/src/pages/AssignmentParticipants/ParticipantsTable.css new file mode 100644 index 00000000..08d91053 --- /dev/null +++ b/src/pages/AssignmentParticipants/ParticipantsTable.css @@ -0,0 +1,207 @@ +/* Table Section */ +.assignment-participants-table-container { + overflow-x: auto; + overflow-y: auto; + max-height: 70vh; + border-radius: 0.5rem; + border: 1px solid #d1d5db; +} + +.assignment-participants-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; +} + +.assignment-participants-table th { + cursor: pointer; + user-select: none; + position: sticky; + top: -1px; + background-color: #4b5563; + color: #ffffff; + font-weight: 600; + text-transform: uppercase; + padding: 0.85rem; + border: 1px solid #d1d5db; + font-size: 0.875rem; + z-index: 10; + box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.2); +} + +.assignment-participants-table th:first-child { + border-top-left-radius: 0.5rem; +} + +.assignment-participants-table th:last-child { + border-top-right-radius: 0.5rem; +} + +.assignment-participants-table th.sorted { + /* Highlight active sorted column */ + background-color: #2563eb; +} + +.assignment-participants-table th svg { + font-size: 0.75rem; + color: #ffffff; +} + +.assignment-participants-table th:hover { + background-color: #059669; +} + +.assignment-participants-table th, +.assignment-participants-table td { + padding: 0.5rem; + border: 1px solid #e5e7eb; + text-align: left; + white-space: nowrap; +} + +/* To target the actual
wrapper */ +.permission-column { + display: flex; + justify-content: center; + align-items: center; + } + + + .permission-column > img { + display: block; + margin: 0; + } + +.assignment-participants-table tr:nth-child(even) { + background-color: #f9fafb; +} + +.assignment-participants-table tr:hover { + background-color: #f3f4f6; +} + +/* Status-based colors */ +.status-yes { + color: #10b981; + font-weight: bold; +} + +.status-no { + color: #ef4444; + font-weight: bold; +} + +.no-results-message { + text-align: center; + font-style: italic; + color: #999; + padding: 1rem; +} + + +/* Role-based colors */ +.role-student { + color: #2563eb; + font-weight: 500; +} + +.role-instructor { + color: #10b981; + font-weight: 500; +} + +.role-admin { + color: #f59e0b; + font-weight: 500; +} + +.role-unknown { + color: #6b7280; + font-weight: 500; +} + +.remove-user-button { + color: #ef4444; + font-weight: 600; + cursor: pointer; + background-color: transparent; + border: 1px solid transparent; + /* padding: 0.4em 0.8em; */ + border-radius: 4px; + transition: color 0.2s ease, background-color 0.2s ease, border-color 0.2s ease; +} + +.remove-user-button:hover { + color: #dc2626; + /* Darker shade for hover effect */ + background-color: rgba(239, 68, 68, 0.1); + border-color: #ef4444; + text-decoration: none; +} + +.remove-user-button:focus { + outline: none; + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.5); +} + + +.edit-user-button { + color: #1d4ed8; + font-weight: 600; + cursor: pointer; + background-color: transparent; + border: 1px solid transparent; + /* padding: 0.4em 0.8em; */ + border-radius: 4px; + transition: color 0.2s ease, background-color 0.2s ease, border-color 0.2s ease; +} + +.edit-user-button:hover { + color: #1e40af; + /* Darker shade for hover effect */ + background-color: rgba(29, 78, 216, 0.1); + border-color: #1d4ed8; + text-decoration: none; +} + +.edit-user-button:focus { + outline: none; + /* Focus ring for accessibility */ + box-shadow: 0 0 0 3px rgba(29, 78, 216, 0.5); +} + + +/* Checkbox Styling */ +.assignment-participants-table input[type="checkbox"] { + width: 1.2rem; + height: 1.2rem; + cursor: pointer; + accent-color: #2563eb; + transition: transform 0.2s ease; +} + +.assignment-participants-table input[type="checkbox"]:hover { + transform: scale(1.1); +} + +.actions-column { + display: flex; + justify-content: space-around; +} + + +@media (max-width: 768px) { + .assignment-participants-header { + font-size: 1.25rem; + } + + .add-user-button { + width: 100%; + } + + .assignment-participants-table th::before { + content: attr(data-label); + float: left; + font-weight: bold; + } +} diff --git a/src/pages/AssignmentParticipants/ParticipantsTable.tsx b/src/pages/AssignmentParticipants/ParticipantsTable.tsx new file mode 100644 index 00000000..dea0adda --- /dev/null +++ b/src/pages/AssignmentParticipants/ParticipantsTable.tsx @@ -0,0 +1,136 @@ +import { permissionIcon } from "./AssignmentParticipants"; +import { AssignmentProperties, Participant } from "./AssignmentParticipantsTypes"; +import { classForRole, classForStatus } from "./AssignmentParticipantsUtil"; +import "./ParticipantsTable.css"; +import { OverlayTrigger, Tooltip, Button } from "react-bootstrap"; +import Table from "components/Table/Table"; +import { publicUrl } from "utils/publicUrl"; + +interface ParticipantTableProps { + participants: Participant[]; + assignmentProps: AssignmentProperties; + openEditModal: (participant: Participant) => void; + openRemoveModal: (participant: Participant) => void; +} + +/** Renders the assignment participants table with permission and action columns. */ +function ParticipantTable({ + participants, + assignmentProps, + openEditModal, + openRemoveModal, +}: ParticipantTableProps) { + const columns = [ + { accessorKey: "id", header: "ID", enableSorting: true }, + { accessorKey: "name", header: "Name", enableSorting: true }, + { accessorKey: "email", header: "Email address", enableSorting: true }, + { + accessorKey: "role", + header: "Role", + enableSorting: true, + cell: ({ row }: { row: any }) => ( +
+ {row.original.role} +
+ ), + }, + { accessorKey: "parent", header: "Parent", enableSorting: true }, + { + accessorKey: "permissions.review", + id: "perm_review", + header: "Review", + enableSorting: true, + cell: ({ row }: { row: any }) => ( +
+ {permissionIcon(row.original.permissions.review, "Review permission")} +
+ ), + }, + { + accessorKey: "permissions.submit", + id: "perm_submit", + header: "Submit", + enableSorting: true, + cell: ({ row }: { row: any }) => ( +
+ {permissionIcon(row.original.permissions.submit, "Submit permission")} +
+ ), + }, + assignmentProps.hasQuiz && { + accessorKey: "permissions.takeQuiz", + id: "perm_quiz", + header: "Take quiz", + enableSorting: true, + cell: ({ row }: { row: any }) => ( +
+ {permissionIcon(row.original.permissions.takeQuiz)} +
+ ), + }, + assignmentProps.hasMentor && { + accessorKey: "permissions.mentor", + id: "perm_mentor", + header: "Mentor", + enableSorting: true, + cell: ({ row }: { row: any }) => ( +
+ {permissionIcon(row.original.permissions.mentor, "Mentor permission")} +
+ ), + }, + { + id: "actions", + header: "Actions", + enableSorting: false, + enableColumnFilter: false, + cell: ({ row }: { row: any }) => ( +
+ Edit participant} placement="top"> + + + + Delete participant} placement="top"> + + +
+ ), + }, + ].filter(Boolean) as any; + + return ( +
+
+
+ = 10} + /> + + + + ); +} + +export default ParticipantTable; diff --git a/src/pages/Assignments/CreateTeams.tsx b/src/pages/Assignments/CreateTeams.tsx index eee84265..95ed1479 100644 --- a/src/pages/Assignments/CreateTeams.tsx +++ b/src/pages/Assignments/CreateTeams.tsx @@ -957,6 +957,7 @@ import { useLoaderData, useNavigate } from 'react-router-dom'; import ImportModal from "../../components/Modals/ImportModal"; import ExportModal from "../../components/Modals/ExportModal"; +import { publicUrl } from "../../utils/publicUrl"; /* ============================================================================= Types @@ -989,25 +990,7 @@ interface LoaderPayload { Assets (icons used only where required) ============================================================================= */ -// Safe base URL (no import.meta) -const getBaseUrl = (): string => { - // 1) if present - if (typeof document !== 'undefined') { - const base = document.querySelector('base[href]') as HTMLBaseElement | null; - if (base?.href) return base.href.replace(/\/$/, ''); - } - // 2) Optional global you can set from Rails/layout, etc. - const fromGlobal = (globalThis as any)?.__BASE_URL__; - if (typeof fromGlobal === 'string' && fromGlobal) return fromGlobal.replace(/\/$/, ''); - - // 3) CRA-style env if available in tests/builds - const fromProcess = - (typeof process !== 'undefined' && (process as any)?.env?.PUBLIC_URL) || ''; - return String(fromProcess).replace(/\/$/, ''); -}; - -const assetUrl = (rel: string) => - `${getBaseUrl()}/${rel.replace(/^\//, '')}`; +const assetUrl = (rel: string) => publicUrl(rel.replace(/^\//, "")); const ICONS = { add: 'assets/icons/add-participant-24.png', diff --git a/src/pages/TA/TA.tsx b/src/pages/TA/TA.tsx index d38ce406..15991e8f 100644 --- a/src/pages/TA/TA.tsx +++ b/src/pages/TA/TA.tsx @@ -14,6 +14,7 @@ import { ITAResponse, ROLE } from "../../utils/interfaces"; import { TAColumns as TA_COLUMNS } from "./TAColumns"; import ColumnButton from "../../components/ColumnButton"; import DeleteTA from "./TADelete"; +import { publicUrl } from "../../utils/publicUrl"; /** * @author Atharva Thorve, on December, 2023 @@ -95,7 +96,7 @@ const TAs = () => { onClick={() => navigate("new")} tooltip="Add TA to this course" icon={Assign TA} diff --git a/src/pages/TA/TAColumns.tsx b/src/pages/TA/TAColumns.tsx index fd7510cd..aabad7ea 100644 --- a/src/pages/TA/TAColumns.tsx +++ b/src/pages/TA/TAColumns.tsx @@ -4,6 +4,7 @@ import { Button } from "react-bootstrap"; import { BsPersonXFill } from "react-icons/bs"; import { ITAResponse as ITA } from "../../utils/interfaces"; import ColumnButton from "../../components/ColumnButton"; +import { publicUrl } from "../../utils/publicUrl"; /** * @author Atharva Thorve, on December, 2023 @@ -47,7 +48,7 @@ export const TAColumns = (handleDelete: Fn) => [ onClick={() => handleDelete(row)} tooltip="Delete TA" icon={Delete} diff --git a/src/pages/Users/userColumns.tsx b/src/pages/Users/userColumns.tsx index b2b8dfba..04597047 100644 --- a/src/pages/Users/userColumns.tsx +++ b/src/pages/Users/userColumns.tsx @@ -2,6 +2,7 @@ import {createColumnHelper, Row} from "@tanstack/react-table"; import {Button, Tooltip, OverlayTrigger } from "react-bootstrap"; import {BsPencilFill, BsPersonXFill} from "react-icons/bs"; import {IUserResponse as IUser} from "../../utils/interfaces"; +import { publicUrl } from "../../utils/publicUrl"; /** * @author Ankur Mundra on April, 2023 */ @@ -59,7 +60,7 @@ export const userColumns = (handleEdit: Fn, handleDelete: Fn) => [ cell: (info) => info.getValue() ? ( Checked @@ -81,7 +82,7 @@ export const userColumns = (handleEdit: Fn, handleDelete: Fn) => [ cell: (info) => info.getValue() ? ( Checked diff --git a/src/utils/interfaces.ts b/src/utils/interfaces.ts index 5454e972..165c88dc 100644 --- a/src/utils/interfaces.ts +++ b/src/utils/interfaces.ts @@ -58,6 +58,16 @@ export interface IParticipantResponse { institution: { id: number | null; name: string | null }; } +export interface IAssignmentParticipantResponse { + id: number; + user_id: number; + can_mentor: boolean | null; + can_review: boolean | null; + can_submit: boolean | null; + can_take_quiz: boolean | null; + authorization: string | null; +} + export interface IUserRequest { name: string; email: string; @@ -249,6 +259,10 @@ export interface IAssignmentResponse { private:boolean; show_template_review: boolean; require_quiz:boolean; + /** When true, assignment uses mentors; shows Mentor permission column on assignment participants. */ + has_mentors?: boolean; + /** When true with require_quiz, supports quiz workflows; used with require_quiz for Take quiz column. */ + has_quizzes?: boolean; has_badge:boolean; staggered_deadline:boolean; is_calibrated:boolean; diff --git a/src/utils/publicUrl.ts b/src/utils/publicUrl.ts new file mode 100644 index 00000000..07a0c3a3 --- /dev/null +++ b/src/utils/publicUrl.ts @@ -0,0 +1,9 @@ +/** + * Absolute URL for a file under `public/`. + * Use instead of Create React App's `process.env.PUBLIC_URL`, which is undefined in Vite's browser bundle. + */ +export function publicUrl(path: string): string { + const base = import.meta.env.BASE_URL; + const suffix = path.replace(/^\//, ""); + return `${base}${suffix}`; +}