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/src/App.tsx b/src/App.tsx index 832e1837..829cd766 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -109,6 +109,16 @@ function App() { element: , loader: loadAssignment, }, + { + path: "assignments/:id/viewsubmissions", + element: } />, + loader: loadAssignment, + }, + { + path: "assignments/:id/submitcontent", + element: } />, + loader: loadAssignment, + }, { path: "assignments/edit/:id/viewscores", element: , @@ -315,6 +325,10 @@ function App() { path: "assignments/:id/review", element: , }, + { + path: "assignments/:id/assign-grades", + element: , + }, // Fixed the missing comma and added an opening curly brace { path: "courses", diff --git a/src/pages/Assignments/AssignmentEditor.tsx b/src/pages/Assignments/AssignmentEditor.tsx index 1bd19dff..e2d7e927 100644 --- a/src/pages/Assignments/AssignmentEditor.tsx +++ b/src/pages/Assignments/AssignmentEditor.tsx @@ -53,6 +53,117 @@ interface TopicData { updatedAt?: string; } +interface ICalibrationSubmissionAsset { + url?: string; + display_name?: string; + name?: string; +} + +interface ICalibrationSubmissionMember { + full_name?: string; + email?: string; +} + +interface ICalibrationSubmissionResponse { + id?: number; + team_id?: number; + team_name?: string; + members?: ICalibrationSubmissionMember[]; + links?: ICalibrationSubmissionAsset[]; + files?: ICalibrationSubmissionAsset[]; +} + +interface ICalibrationSubmissionsApiResponse { + submissions?: ICalibrationSubmissionResponse[]; +} + +interface ICalibrationSubmissionItem { + label: string; + url?: string; +} + +interface ICalibrationSubmissionRow { + id: number; + participant_name: string; + review_status: "not_started"; + submitted_content: { + hyperlinks: ICalibrationSubmissionItem[]; + files: ICalibrationSubmissionItem[]; + }; +} + +const getCalibrationSubmissionLabel = ( + submission: ICalibrationSubmissionResponse, + index: number +) => { + if (submission.team_name?.trim()) { + return submission.team_name; + } + + const memberNames = Array.isArray(submission.members) + ? submission.members + .map((member) => member.full_name?.trim() || member.email?.trim()) + .filter((value): value is string => Boolean(value)) + : []; + + if (memberNames.length > 0) { + return memberNames.join(", "); + } + + return `Submission ${index + 1}`; +}; + +const transformCalibrationAsset = ( + asset: ICalibrationSubmissionAsset +): ICalibrationSubmissionItem | null => { + const url = asset.url?.trim(); + const label = asset.display_name?.trim() || asset.name?.trim() || url; + + if (!label) { + return null; + } + + return { + label, + url: url || undefined, + }; +}; + +const transformCalibrationSubmissions = ( + response?: ICalibrationSubmissionsApiResponse | null +): ICalibrationSubmissionRow[] => { + const submissions = Array.isArray(response?.submissions) ? response.submissions : []; + + return submissions + .map((submission, index) => { + const hyperlinks = Array.isArray(submission.links) + ? submission.links + .map(transformCalibrationAsset) + .filter((item): item is ICalibrationSubmissionItem => item !== null) + : []; + const files = Array.isArray(submission.files) + ? submission.files + .map(transformCalibrationAsset) + .filter((item): item is ICalibrationSubmissionItem => item !== null) + : []; + + return { + id: submission.id ?? submission.team_id ?? index + 1, + participant_name: getCalibrationSubmissionLabel(submission, index), + review_status: "not_started" as const, + submitted_content: { + hyperlinks, + files, + }, + }; + }) + .filter( + (submission) => + submission.submitted_content.hyperlinks.length > 0 || + submission.submitted_content.files.length > 0 + ); +}; + const initialValues: IAssignmentFormValues = { name: "", directory_path: "", @@ -538,30 +649,29 @@ const AssignmentEditor: React.FC = ({ mode }) => { // Load calibration submissions on component mount useEffect(() => { - // sendCalibrationSubmissionsRequest({ - // url: `/calibration_submissions/get_instructor_calibration_submissions/${assignmentData.id}`, - // method: HttpMethod.GET, - // }); - setCalibrationSubmissions([ - { - id: 1, - participant_name: "Participant 1", - review_status: "not_started", - submitted_content: { hyperlinks: ["https://www.google.com"], files: ["file1.txt", "file2.pdf"] }, - }, - { - id: 2, - participant_name: "Participant 2", - review_status: "in_progress", - submitted_content: { hyperlinks: ["https://www.google.com"], files: ["file1.txt", "file2.pdf"] }, - }, - ]); - }, []); + if (mode !== "update" || !assignmentData?.id) { + setCalibrationSubmissions([]); + return; + } + + sendCalibrationSubmissionsRequest({ + url: `/submitted_content/${assignmentData.id}/view_submissions`, + method: HttpMethod.GET, + }); + }, [assignmentData?.id, mode, sendCalibrationSubmissionsRequest]); // Handle calibration submissions response useEffect(() => { - if (calibrationSubmissionsResponse && calibrationSubmissionsResponse.status >= 200 && calibrationSubmissionsResponse.status < 300) { - setCalibrationSubmissions(calibrationSubmissionsResponse.data || []); + if ( + calibrationSubmissionsResponse && + calibrationSubmissionsResponse.status >= 200 && + calibrationSubmissionsResponse.status < 300 + ) { + setCalibrationSubmissions( + transformCalibrationSubmissions( + calibrationSubmissionsResponse.data as ICalibrationSubmissionsApiResponse + ) + ); } }, [calibrationSubmissionsResponse]); @@ -1206,16 +1316,24 @@ const AssignmentEditor: React.FC = ({ mode }) => {
Hyperlinks:
{ - row.original.submitted_content.hyperlinks.map((item: any, index: number) => { - return {item}; + row.original.submitted_content.hyperlinks.map((item: ICalibrationSubmissionItem, index: number) => { + return item.url ? ( + {item.label} + ) : ( + {item.label} + ); }) }
Files:
{ - row.original.submitted_content.files.map((item: any, index: number) => { - return {item}; + row.original.submitted_content.files.map((item: ICalibrationSubmissionItem, index: number) => { + return item.url ? ( + {item.label} + ) : ( + {item.label} + ); }) }
diff --git a/src/pages/Assignments/SubmittedContent.css b/src/pages/Assignments/SubmittedContent.css index 5a7528ff..101dd5e0 100644 --- a/src/pages/Assignments/SubmittedContent.css +++ b/src/pages/Assignments/SubmittedContent.css @@ -1,91 +1,247 @@ .submitted-content-container { - max-width: 1200px; - margin: 0 auto; + --submitted-surface: #fffdfc; + --submitted-surface-soft: #fff7f2; + --submitted-surface-muted: #f7fafc; + --submitted-border: #ead7d0; + --submitted-border-strong: #d8b7ac; + --submitted-text: #1f2937; + --submitted-text-muted: #667085; + --submitted-accent: #b42318; + --submitted-accent-soft: rgba(180, 35, 24, 0.08); + max-width: 1180px; +} + +.submitted-content-shell { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.submitted-content-panel { + background: + radial-gradient(circle at top right, rgba(180, 35, 24, 0.06), transparent 30%), + linear-gradient(180deg, var(--submitted-surface) 0%, #ffffff 100%); + border: 1px solid var(--submitted-border); + border-radius: 1.25rem; + box-shadow: 0 18px 40px rgba(15, 23, 42, 0.07); + padding: 1.35rem; +} + +.submitted-content-hero { + display: block; + overflow: hidden; + position: relative; +} + +.submitted-content-hero::after { + background: linear-gradient(135deg, transparent 0%, rgba(180, 35, 24, 0.08) 100%); + content: ""; + inset: auto -4rem -4rem auto; + position: absolute; + width: 12rem; + height: 12rem; + border-radius: 999px; + pointer-events: none; +} + +.submitted-content-hero-copy { + position: relative; + z-index: 1; +} + +.submitted-content-kicker { + color: var(--submitted-accent); + display: inline-block; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.14em; + margin-bottom: 0.65rem; + text-transform: uppercase; } .submitted-content-title { - font-size: 2.5rem; - font-weight: 700; - margin-bottom: 2rem; - color: #333; + color: var(--submitted-text); + font-size: clamp(2.2rem, 4vw, 3.2rem); + font-weight: 800; + letter-spacing: -0.05em; + line-height: 0.95; + max-width: 8ch; } -.file-item, -.hyperlink-item { - background-color: #f8f9fa; - transition: all 0.2s ease; +.submitted-content-subtitle { + color: var(--submitted-text-muted); + font-size: 1.02rem; + max-width: 40rem; } -.file-item:hover, -.hyperlink-item:hover { - background-color: #e9ecef; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +.submitted-content-section-title { + color: var(--submitted-text); + font-size: 1.2rem; + font-weight: 750; } -.files-list, -.hyperlinks-list { - margin-top: 1rem; +.submitted-content-panel-copy { + color: var(--submitted-text-muted); } -.file-item a, -.hyperlink-item a { - text-decoration: none; - color: #007bff; +.submitted-content-actions-layout { + align-items: center; + display: grid; + gap: 1rem; + grid-template-columns: minmax(0, 1.15fr) minmax(280px, 0.95fr); } -.file-item a:hover, -.hyperlink-item a:hover { - text-decoration: underline; +.submitted-content-actions-copy { + min-width: 0; +} + +.submitted-content-action-stack { + align-items: flex-end; + display: flex; + flex-direction: column; } -/* Button styling */ -button { - transition: all 0.2s ease; +.submitted-content-action-row { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + justify-content: flex-end; } -button:hover { - transform: translateY(-2px); - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +.submitted-content-action-note { + color: #7a8699; + font-size: 0.92rem; + margin-top: 0.45rem; } -/* Modal styling */ -.modal-header { - border-bottom: 2px solid #dee2e6; +.submitted-content-folder-indicator { + align-self: flex-start; + background-color: var(--submitted-surface-soft); + border: 1px solid var(--submitted-border); + border-radius: 999px; + color: #4a5568; + font-size: 0.92rem; + padding: 0.55rem 0.95rem; } -.modal-body { - padding: 1.5rem; +.submitted-content-toolbar { + align-items: center; + display: inline-flex; + flex-wrap: wrap; + gap: 0.75rem; + justify-content: flex-end; } -/* Table styling */ -.table { - margin-top: 1rem; +.submitted-content-refresh-button { + white-space: nowrap; } -.table thead th { - background-color: #f8f9fa; - border-top: 2px solid #dee2e6; +.submitted-content-breadcrumb { + margin-bottom: 0; } -/* Spinner styling */ -.spinner-border { - color: #007bff; +.submitted-content-breadcrumb button { + background: none; + border: 0; + color: #0d6efd; + padding: 0; } -/* Alert styling */ -.alert { - margin-bottom: 1.5rem; - border-radius: 0.5rem; +.submitted-content-breadcrumb .breadcrumb-item.active button { + color: #495057; + cursor: default; +} + +.submitted-content-loading { + align-items: center; + color: #475569; + display: flex; + gap: 0.75rem; + justify-content: center; + min-height: 180px; +} + +.submitted-content-table { + margin-bottom: 0; + vertical-align: middle; +} + +.submitted-content-table th { + background-color: #f8f1ec; + border-bottom-color: var(--submitted-border-strong); + color: #4b5563; + font-size: 0.8rem; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.submitted-content-table td { + color: var(--submitted-text); +} + +.submitted-content-table tbody tr:last-child td { + border-bottom: 0; +} + +.submitted-content-link { + color: #0d6efd; + text-decoration: none; + overflow-wrap: anywhere; +} + +.submitted-content-link:hover { + text-decoration: underline; +} + +.artifact-actions { + display: inline-flex; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: flex-end; +} + +.submission-action-button { + min-width: 168px; +} + +@media (max-width: 992px) { + .submitted-content-actions-layout { + grid-template-columns: 1fr; + } + + .submitted-content-action-stack { + align-self: stretch; + } + + .submitted-content-action-row { + justify-content: flex-start; + } + + .submitted-content-toolbar { + justify-content: flex-start; + } } -/* Responsive grid */ @media (max-width: 768px) { + .submitted-content-panel { + padding: 1rem; + } + .submitted-content-title { - font-size: 1.75rem; + font-size: 2rem; + max-width: none; + } + + .submitted-content-action-row { + width: 100%; + } + + .submission-action-button { + flex: 1 1 100%; } - .file-item, - .hyperlink-item { - padding: 1rem !important; + .artifact-actions { + justify-content: flex-start; } } diff --git a/src/pages/Assignments/SubmittedContent.tsx b/src/pages/Assignments/SubmittedContent.tsx index 1296ee6a..d73e0098 100644 --- a/src/pages/Assignments/SubmittedContent.tsx +++ b/src/pages/Assignments/SubmittedContent.tsx @@ -1,503 +1,992 @@ -import React, { useState, useCallback, useEffect } from 'react'; -import { Container, Row, Col, Button, Modal, Form, Alert, Table, Spinner } from 'react-bootstrap'; -import { FaFile, FaLink, FaTrash, FaDownload } from 'react-icons/fa'; -import { Formik, Form as FormikForm, Field, ErrorMessage } from 'formik'; -import * as Yup from 'yup'; -import SubmittedContentService from '../../services/SubmittedContentService'; -import { ISubmittedContentProps, IModalState, IFile } from '../../types/SubmittedContent'; -import './SubmittedContent.css'; - -const SubmittedContent: React.FC = () => { - // State Management - const [files, setFiles] = useState([]); - const [hyperlinks, setHyperlinks] = useState<{ url: string; title: string; submittedAt: string }[]>([]); - const [submissions, setSubmissions] = useState([]); - const [loading, setLoading] = useState(false); +import { ChangeEvent, FormEvent, useEffect, useRef, useState } from "react"; +import { + Alert, + Breadcrumb, + Button, + Col, + Container, + Form, + Modal, + Row, + Spinner, + Table, +} from "react-bootstrap"; +import { + FaDownload, + FaExternalLinkAlt, + FaFolderOpen, + FaLink, + FaSyncAlt, + FaTrash, + FaUpload, +} from "react-icons/fa"; +import { useSelector } from "react-redux"; +import { useLoaderData, useParams } from "react-router-dom"; +import SubmittedContentService, { + IListFilesResponse, + IListedFile, + IListedFolder, +} from "../../services/SubmittedContentService"; +import { RootState } from "../../store/store"; +import { IAssignmentResponse } from "../../utils/interfaces"; +import "./SubmittedContent.css"; + +type SubmittedContentLoaderData = Partial; + +const EMPTY_MESSAGE = "No submission artifacts are available in this folder yet."; + +const buildErrorMessage = (error: unknown, fallbackMessage: string) => { + if (typeof error === "string" && error.trim()) { + return error; + } + + if (error && typeof error === "object") { + const apiError = (error as { error?: string }).error; + if (apiError) { + return apiError; + } + + const message = (error as { message?: string }).message; + if (message) { + return message; + } + } + + return fallbackMessage; +}; + +const formatTimestamp = (value?: string) => { + if (!value) return "-"; + + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return value; + } + + return parsed.toLocaleString(); +}; + +const getPathSegments = (currentFolder: string) => { + const segments = currentFolder.split("/").filter(Boolean); + + return segments.map((segment, index) => ({ + label: segment, + path: `/${segments.slice(0, index + 1).join("/")}`, + })); +}; + +const SubmittedContent = () => { + const assignment = (useLoaderData?.() as SubmittedContentLoaderData) || {}; + const { id: routeAssignmentId } = useParams<{ id: string }>(); + const assignmentId = Number(assignment?.id ?? routeAssignmentId ?? 0); + const currentUserId = useSelector((state: RootState) => state.authentication.user.id); + + const [participantId, setParticipantId] = useState(null); + const [teamId, setTeamId] = useState(null); + const [currentFolder, setCurrentFolder] = useState("/"); + const [folderContents, setFolderContents] = useState({ + current_folder: "/", + files: [], + folders: [], + hyperlinks: [], + }); + const [liveFolderContents, setLiveFolderContents] = useState({ + current_folder: "/", + files: [], + folders: [], + hyperlinks: [], + }); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); + const [isResolvingParticipant, setIsResolvingParticipant] = useState(true); + const [isLoadingArtifacts, setIsLoadingArtifacts] = useState(false); + const [isSubmittingFile, setIsSubmittingFile] = useState(false); + const [isSubmittingHyperlink, setIsSubmittingHyperlink] = useState(false); + const [isUsingSubmissionSummary, setIsUsingSubmissionSummary] = useState(false); + const [preferLiveContents, setPreferLiveContents] = useState(false); + const [activeActionKey, setActiveActionKey] = useState(null); + const [showHyperlinkModal, setShowHyperlinkModal] = useState(false); + const [hyperlinkUrl, setHyperlinkUrl] = useState(""); + const uploadInputRef = useRef(null); + + const files = folderContents.files ?? []; + const folders = folderContents.folders ?? []; + const hyperlinks = folderContents.hyperlinks ?? []; + const hasArtifacts = files.length > 0 || folders.length > 0 || hyperlinks.length > 0; + const showLoadingState = + isResolvingParticipant || (isLoadingArtifacts && !hasArtifacts && !error); + const pathSegments = getPathSegments(currentFolder); + const currentFolderLabel = currentFolder === "/" ? "Root" : currentFolder; + const isMissingParticipantState = !isResolvingParticipant && !participantId; + + const resolveLiveHyperlinkIndex = (hyperlink: string, summaryIndex: number) => { + const liveHyperlinks = liveFolderContents.hyperlinks ?? []; + let seenMatches = 0; + + for (let index = 0; index < liveHyperlinks.length; index += 1) { + if (liveHyperlinks[index] !== hyperlink) { + continue; + } - // Modal States - const [fileModal, setFileModal] = useState({ - show: false, - isSubmitting: false, - }); + if (seenMatches === 0) { + if (summaryIndex === 0) { + return index; + } + } - const [hyperlinkModal, setHyperlinkModal] = useState({ - show: false, - isSubmitting: false, - }); + const previousMatchingCount = hyperlinks + .slice(0, summaryIndex) + .filter((candidate) => candidate === hyperlink).length; - // Get assignment ID from URL - const assignmentId = new URLSearchParams(window.location.search).get('id') || 'default'; + if (seenMatches === previousMatchingCount) { + return index; + } - // Initial data fetch - useEffect(() => { - fetchSubmissions(); - }, [assignmentId]); + seenMatches += 1; + } + + return -1; + }; + + const resolveLiveFileName = (fileName: string) => { + const liveFiles = liveFolderContents.files ?? []; + const matchingFile = liveFiles.find((liveFile) => liveFile.name === fileName); + return matchingFile?.name ?? null; + }; + + const loadFolderContents = async ( + resolvedParticipantId: string, + folderPath: string, + resolvedTeamId: number | null = teamId, + preferLiveDisplay: boolean = preferLiveContents + ) => { + setIsLoadingArtifacts(true); + setError(null); - // Fetch submissions list - const fetchSubmissions = useCallback(async () => { try { - setLoading(true); - setError(null); - // This would call the backend API to fetch submissions - // const response = await SubmittedContentService.listFiles(assignmentId); - // setSubmissions(response); - } catch (err) { - setError('Failed to fetch submissions'); - console.error(err); + const shouldLoadSummary = + folderPath === "/" && assignmentId > 0 && resolvedTeamId && !preferLiveDisplay; + const [liveResult, summaryResult] = await Promise.allSettled([ + SubmittedContentService.listFiles(resolvedParticipantId, folderPath), + shouldLoadSummary + ? SubmittedContentService.getTeamSubmissionSummary(assignmentId, resolvedTeamId) + : Promise.resolve(null), + ]); + + const liveResponse = liveResult.status === "fulfilled" ? liveResult.value : null; + const summaryResponse = + summaryResult.status === "fulfilled" ? summaryResult.value : null; + const summaryHasArtifacts = + Boolean(summaryResponse) && + ((summaryResponse?.files?.length ?? 0) > 0 || + (summaryResponse?.folders?.length ?? 0) > 0 || + (summaryResponse?.hyperlinks?.length ?? 0) > 0); + + if (!liveResponse && !summaryResponse) { + throw liveResult.status === "rejected" ? liveResult.reason : summaryResult.reason; + } + + const nextContents = preferLiveDisplay + ? liveResponse || (summaryHasArtifacts ? summaryResponse : null) + : (summaryHasArtifacts ? summaryResponse : null) || liveResponse; + + setLiveFolderContents({ + current_folder: liveResponse?.current_folder || folderPath, + files: liveResponse?.files ?? [], + folders: liveResponse?.folders ?? [], + hyperlinks: liveResponse?.hyperlinks ?? [], + }); + setIsUsingSubmissionSummary(summaryHasArtifacts && !preferLiveDisplay); + + setFolderContents({ + current_folder: nextContents.current_folder || folderPath, + files: nextContents.files ?? [], + folders: nextContents.folders ?? [], + hyperlinks: nextContents.hyperlinks ?? [], + }); + } catch (loadError) { + setError( + buildErrorMessage(loadError, "Unable to load the submitted artifacts for this assignment.") + ); + setFolderContents({ + current_folder: folderPath, + files: [], + folders: [], + hyperlinks: [], + }); + setLiveFolderContents({ + current_folder: folderPath, + files: [], + folders: [], + hyperlinks: [], + }); + setIsUsingSubmissionSummary(false); } finally { - setLoading(false); + setIsLoadingArtifacts(false); } - }, [assignmentId]); + }; - // File Upload Handler - const handleFileUpload = useCallback( - async (values: any) => { - try { - setFileModal((prev) => ({ ...prev, isSubmitting: true })); - setError(null); - - if (values.file && values.file.length > 0) { - const file = values.file[0]; - - // Validate file - const validation = await SubmittedContentService.validateFile(file); - if (!validation.isValid) { - setError(validation.error || 'Invalid file'); - return; - } - - // Submit file - const response = await SubmittedContentService.submitFile(assignmentId, file); - - // Add to files list - setFiles((prev) => [...prev, response.file]); - setSuccess('File uploaded successfully'); - - // Reset modal - setFileModal({ show: false, isSubmitting: false }); - - // Clear success message after 3 seconds - setTimeout(() => setSuccess(null), 3000); - } - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to upload file'); - console.error(err); - } finally { - setFileModal((prev) => ({ ...prev, isSubmitting: false })); + useEffect(() => { + let ignore = false; + + const resolveParticipant = async () => { + if (!assignmentId || !currentUserId) { + setParticipantId(null); + setIsResolvingParticipant(false); + setError("A valid assignment and logged-in student account are required to view submissions."); + return; } - }, - [assignmentId] - ); - // Hyperlink Submit Handler - const handleHyperlinkSubmit = useCallback( - async (values: any) => { - try { - setHyperlinkModal((prev) => ({ ...prev, isSubmitting: true })); - setError(null); - - // Validate URL - const validation = await SubmittedContentService.validateUrl(values.url); - if (!validation.isValid) { - setError(validation.error || 'Invalid URL'); - return; - } + setIsResolvingParticipant(true); + setError(null); + setSuccess(null); + setCurrentFolder("/"); + setPreferLiveContents(false); - // Submit hyperlink - const response = await SubmittedContentService.submitHyperlink( + try { + const participantContext = await SubmittedContentService.findParticipantContext( assignmentId, - values.url, - values.title || values.url + currentUserId ); - // Add to hyperlinks list - setHyperlinks((prev) => [...prev, response.hyperlink]); - setSuccess('Hyperlink submitted successfully'); - - // Reset modal - setHyperlinkModal({ show: false, isSubmitting: false }); - - // Clear success message after 3 seconds - setTimeout(() => setSuccess(null), 3000); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to submit hyperlink'); - console.error(err); + if (ignore) return; + + setParticipantId(participantContext.participantId); + setTeamId(participantContext.teamId); + } catch (resolveError) { + if (ignore) return; + + setParticipantId(null); + setTeamId(null); + setFolderContents({ + current_folder: "/", + files: [], + folders: [], + hyperlinks: [], + }); + setLiveFolderContents({ + current_folder: "/", + files: [], + folders: [], + hyperlinks: [], + }); + setError( + buildErrorMessage( + resolveError, + "We could not find a submission record for your account on this assignment." + ) + ); } finally { - setHyperlinkModal((prev) => ({ ...prev, isSubmitting: false })); + if (!ignore) { + setIsResolvingParticipant(false); + } } - }, - [assignmentId] - ); + }; - // Remove Hyperlink Handler - const handleRemoveHyperlink = useCallback( - async (url: string) => { - try { - setError(null); - await SubmittedContentService.removeHyperlink(assignmentId, url); - setHyperlinks((prev) => prev.filter((h) => h.url !== url)); - setSuccess('Hyperlink removed successfully'); - setTimeout(() => setSuccess(null), 3000); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to remove hyperlink'); - console.error(err); - } - }, - [assignmentId] - ); + resolveParticipant(); - // Download File Handler - const handleDownloadFile = useCallback( - async (file: IFile) => { - try { - setError(null); - await SubmittedContentService.downloadFile(assignmentId, file.id); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to download file'); - console.error(err); - } - }, - [assignmentId] - ); + return () => { + ignore = true; + }; + }, [assignmentId, currentUserId]); - // Delete File Handler - const handleDeleteFile = useCallback( - async (fileId: string) => { - try { - setError(null); - await SubmittedContentService.deleteFile(assignmentId, fileId); - setFiles((prev) => prev.filter((f) => f.id !== fileId)); - setSuccess('File deleted successfully'); - setTimeout(() => setSuccess(null), 3000); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to delete file'); - console.error(err); + useEffect(() => { + if (!participantId) return; + + loadFolderContents(participantId, currentFolder); + }, [currentFolder, participantId, teamId]); + + useEffect(() => { + if (!success) return; + + const timeoutId = window.setTimeout(() => { + setSuccess(null); + }, 3000); + + return () => window.clearTimeout(timeoutId); + }, [success]); + + const resolveParticipantForAction = async () => { + if (participantId) { + return participantId; + } + + if (!assignmentId || !currentUserId) { + setError("A valid assignment and logged-in student account are required to submit work."); + return null; + } + + setIsResolvingParticipant(true); + setError(null); + + try { + const participantContext = await SubmittedContentService.findParticipantContext( + assignmentId, + currentUserId + ); + setParticipantId(participantContext.participantId); + setTeamId(participantContext.teamId); + return participantContext.participantId; + } catch (resolveError) { + setError( + buildErrorMessage( + resolveError, + "We could not find a submission record for your account on this assignment." + ) + ); + return null; + } finally { + setIsResolvingParticipant(false); + } + }; + + const refreshArtifacts = async () => { + if (!participantId) return; + await loadFolderContents(participantId, currentFolder); + }; + + const handleOpenFolder = (folderName: string) => { + setCurrentFolder((previousFolder) => + previousFolder === "/" ? `/${folderName}` : `${previousFolder}/${folderName}` + ); + }; + + const handleOpenFile = async (fileName: string, shouldDownload: boolean) => { + if (!participantId) return; + + const actionKey = `${shouldDownload ? "download" : "open"}:${currentFolder}:${fileName}`; + setActiveActionKey(actionKey); + setError(null); + + try { + const response = await SubmittedContentService.downloadFile( + fileName, + participantId, + currentFolder + ); + const fileBlob = + response.data instanceof Blob + ? response.data + : new Blob([response.data], { + type: response.headers?.["content-type"] || "application/octet-stream", + }); + const objectUrl = window.URL.createObjectURL(fileBlob); + const anchor = document.createElement("a"); + + anchor.href = objectUrl; + + if (shouldDownload) { + anchor.download = fileName; + anchor.click(); + } else { + anchor.target = "_blank"; + anchor.rel = "noopener noreferrer"; + anchor.click(); } - }, - [assignmentId] - ); - // Validation Schemas - const fileValidationSchema = Yup.object().shape({ - file: Yup.mixed() - .required('File is required') - .test('fileSize', 'File is too large', (value: any) => { - if (!value || value.length === 0) return false; - return value[0].size <= 50 * 1024 * 1024; // 50MB limit - }), - }); + window.setTimeout(() => { + window.URL.revokeObjectURL(objectUrl); + }, 1000); + } catch (downloadError) { + setError( + buildErrorMessage(downloadError, `Unable to access ${fileName}. Please try again.`) + ); + } finally { + setActiveActionKey(null); + } + }; - const hyperlinkValidationSchema = Yup.object().shape({ - url: Yup.string().url('Invalid URL').required('URL is required'), - title: Yup.string().max(255, 'Title is too long'), - }); + const handleDeleteEntry = async (entryName: string, entryType: "file" | "folder") => { + if (!participantId) return; + + const liveEntryName = + entryType === "file" && isUsingSubmissionSummary + ? resolveLiveFileName(entryName) + : entryName; + + if (entryType === "file" && !liveEntryName) { + setError("This file could not be matched to a live team submission for deletion."); + return; + } + + const actionKey = `delete:${currentFolder}:${entryName}`; + setActiveActionKey(actionKey); + setError(null); + + try { + await SubmittedContentService.deleteFile( + liveEntryName || entryName, + participantId, + currentFolder + ); + setPreferLiveContents(true); + setSuccess( + `${entryType === "folder" ? "Folder" : "Artifact"} "${entryName}" was deleted successfully.` + ); + await loadFolderContents(participantId, currentFolder, teamId, true); + } catch (deleteError) { + setError( + buildErrorMessage( + deleteError, + `Unable to delete ${entryName}. Please refresh and try again.` + ) + ); + } finally { + setActiveActionKey(null); + } + }; + + const handleRemoveHyperlink = async (index: number, hyperlink: string) => { + if (!participantId) return; + + const liveIndex = isUsingSubmissionSummary + ? resolveLiveHyperlinkIndex(hyperlink, index) + : index; + + if (liveIndex < 0) { + setError("This hyperlink could not be matched to a live team submission for deletion."); + return; + } + + const actionKey = `link:${index}`; + setActiveActionKey(actionKey); + setError(null); + + try { + await SubmittedContentService.removeHyperlink(participantId, liveIndex); + setPreferLiveContents(true); + setSuccess(`Removed hyperlink "${hyperlink}".`); + await loadFolderContents(participantId, currentFolder, teamId, true); + } catch (removeError) { + setError( + buildErrorMessage( + removeError, + "Unable to remove the hyperlink. Please refresh and try again." + ) + ); + } finally { + setActiveActionKey(null); + } + }; + + const handleUploadButtonClick = async () => { + const resolvedParticipantId = await resolveParticipantForAction(); + if (!resolvedParticipantId || !uploadInputRef.current) { + return; + } + + uploadInputRef.current.value = ""; + uploadInputRef.current.click(); + }; + + const handleFileSelection = async (event: ChangeEvent) => { + const nextFile = event.target.files?.[0] || null; + event.target.value = ""; + + if (!nextFile) { + return; + } + + const resolvedParticipantId = await resolveParticipantForAction(); + if (!resolvedParticipantId) { + return; + } + + const validationResult = SubmittedContentService.validateFile(nextFile); + if (!validationResult.valid) { + setError(validationResult.error || "The selected file is not valid."); + return; + } + + setIsSubmittingFile(true); + setError(null); + + try { + await SubmittedContentService.submitFile(resolvedParticipantId, nextFile, currentFolder); + setPreferLiveContents(true); + setSuccess(`Uploaded "${nextFile.name}" successfully.`); + await loadFolderContents(resolvedParticipantId, currentFolder, teamId, true); + } catch (submitError) { + setError( + buildErrorMessage(submitError, "Unable to upload the selected file. Please try again.") + ); + } finally { + setIsSubmittingFile(false); + } + }; + + const handleOpenHyperlinkModal = async () => { + const resolvedParticipantId = await resolveParticipantForAction(); + if (!resolvedParticipantId) { + return; + } + + setShowHyperlinkModal(true); + }; + + const actionHelperMessage = isResolvingParticipant + ? "Preparing your submission workspace..." + : !participantId + ? "This student account needs an assignment participant and team before uploads or links can be submitted." + : "Files upload directly from your computer, and links open a short form."; + + const handleHyperlinkSubmit = async (event: FormEvent) => { + event.preventDefault(); + + if (!participantId) { + setError("A participant record is required before adding hyperlinks."); + return; + } + + const trimmedUrl = hyperlinkUrl.trim(); + const validationResult = SubmittedContentService.validateUrl(trimmedUrl); + if (!validationResult.valid) { + setError(validationResult.error || "The hyperlink is not valid."); + return; + } + + setIsSubmittingHyperlink(true); + setError(null); + + try { + await SubmittedContentService.submitHyperlink(trimmedUrl, participantId); + setPreferLiveContents(true); + setSuccess("Hyperlink submitted successfully."); + setShowHyperlinkModal(false); + setHyperlinkUrl(""); + await loadFolderContents(participantId, currentFolder, teamId, true); + } catch (submitError) { + setError( + buildErrorMessage(submitError, "Unable to submit the hyperlink. Please try again.") + ); + } finally { + setIsSubmittingHyperlink(false); + } + }; return ( - - {/* Header */} - - -

📝 Submitted Content

- -
- - {/* Alerts */} - {error && setError(null)} dismissible>{error}} - {success && setSuccess(null)} dismissible>{success}} - - {/* Action Buttons Grid */} - - - - - - - - - - - - - - - - - - - {/* Submission History Table */} - {loading ? ( - - - - - - ) : submissions.length > 0 ? ( - - -

📊 Submission History

- - - - - - - - - - - {submissions.map((submission) => ( - - - - - - - ))} - -
Submission IDTypeSubmitted AtStatus
{submission.id}{submission.type}{new Date(submission.submittedAt).toLocaleString()}{submission.status}
- -
- ) : null} - - {/* Files Section */} - {files.length > 0 && ( - - -

📁 Uploaded Files

-
- {files.map((file) => ( -
+
+ +
+
+
+ Student Workspace +

{assignment.name} - View Submissions

+

+ {assignment?.name + ? `Review, submit, and manage your team artifacts for ${assignment.name}.` + : "Review, submit, and manage your team artifacts for this assignment."} +

+
+
+ + {error && !isMissingParticipantState && ( + setError(null)} className="mb-0"> + {error} + + )} + + {success && ( + setSuccess(null)} className="mb-0"> + {success} + + )} + + {isMissingParticipantState ? ( +
+ Submission access unavailable +

+ You are not set up for submissions on this assignment. +

+

+ {error || + "No participant record was found for the current user on this assignment."} +

+
+ ) : ( + <> +
+ +
+
+

Submit work

+

+ Upload files or add links for the current assignment without leaving this page. +

+

{actionHelperMessage}

+
+
+
+ + +
+
+
+
+ + + +
+
- {file.name} -
- {SubmittedContentService.formatFileSize(file.size)} • {new Date(file.uploadedAt).toLocaleString()} -
+

Artifacts

+

+ Open, download, or remove the artifacts submitted by your team. +

-
+
- -
-
- ))} -
- - - )} - - {/* Hyperlinks Section */} - {hyperlinks.length > 0 && ( - - -

🔗 Submitted Hyperlinks

-
- {hyperlinks.map((hyperlink) => ( -
-
- - {hyperlink.title} - -
- Submitted: {new Date(hyperlink.submittedAt).toLocaleString()} +
+ Current folder: {currentFolderLabel}
-
+ + + setCurrentFolder("/")} > - - + Root + + {pathSegments.map((segment) => ( + setCurrentFolder(segment.path)} + > + {segment.label} + + ))} + + + {isUsingSubmissionSummary && ( + + Showing your team's submissions from the assignment submission feed. + Uploads and removals still use the live team submission workspace, so file + deletion is only available when the visible file still exists there. + + )} + + {showLoadingState ? ( +
+ + Loading submitted artifacts... +
+ ) : ( + <> + {folders.length === 0 && files.length === 0 ? ( + + {EMPTY_MESSAGE} + + ) : ( +
+ + + + + + + + + + + + {folders.map((folder: IListedFolder) => { + const deleteActionKey = `delete:${currentFolder}:${folder.name}`; + + return ( + + + + + + + + ); + })} + {files.map((file: IListedFile) => { + const deleteActionKey = `delete:${currentFolder}:${file.name}`; + const liveFileName = resolveLiveFileName(file.name); + const resolvedFileName = + isUsingSubmissionSummary ? liveFileName : file.name; + const openActionKey = `open:${currentFolder}:${resolvedFileName ?? file.name}`; + const downloadActionKey = `download:${currentFolder}:${resolvedFileName ?? file.name}`; + + return ( + + + + + + + + ); + })} + +
NameTypeSizeModifiedActions
+ + {folder.name} + Folder-{formatTimestamp(folder.modified_at)} +
+ + +
+
{file.name}{file.type?.toUpperCase() || "File"}{SubmittedContentService.formatFileSize(file.size || 0)}{formatTimestamp(file.modified_at)} +
+ + + +
+
+
+ )} + + )} +
+ + +
+
+

Hyperlinks

+

+ Submitted URLs are shown separately so you can open them directly or remove them. +

- ))} -
- - - )} - - {/* File Upload Modal */} - setFileModal({ ...fileModal, show: false })}> - - Upload File - - - - {({ isSubmitting, setFieldValue }) => ( - - - Select File - { - const files = (event.target as HTMLInputElement).files; - setFieldValue('file', files); - }} - disabled={isSubmitting} - /> - - - - - - )} - - - - {/* Hyperlink Modal */} - setHyperlinkModal({ ...hyperlinkModal, show: false })}> - - Add Hyperlink + {hyperlinks.length === 0 ? ( + + No hyperlinks have been submitted for this folder yet. + + ) : ( +
+ + + + + + + + + {hyperlinks.map((hyperlink, index) => { + const actionKey = `link:${index}`; + const liveHyperlinkIndex = resolveLiveHyperlinkIndex(hyperlink, index); + + return ( + + + + + ); + })} + +
HyperlinkActions
+ + {hyperlink} + + +
+ + +
+
+
+ )} +
+ +
+ + )} +
+ + + !isSubmittingHyperlink && setShowHyperlinkModal(false)} + centered + > + + Add hyperlink - - - {({ isSubmitting }) => ( - - - URL - - - - - - Title (Optional) - - - - - - - )} - - +
+ + + Hyperlink URL + setHyperlinkUrl(event.target.value)} + disabled={isSubmittingHyperlink} + /> + + + + + + +
- +
); }; diff --git a/src/pages/Assignments/ViewSubmissions.tsx b/src/pages/Assignments/ViewSubmissions.tsx index 311e4778..ff401171 100644 --- a/src/pages/Assignments/ViewSubmissions.tsx +++ b/src/pages/Assignments/ViewSubmissions.tsx @@ -1,81 +1,468 @@ -import React, { useMemo } from 'react'; -import { Button, Container, Row, Col } from 'react-bootstrap'; -import { useNavigate, useParams } from 'react-router-dom'; -import Table from "../../components/Table/Table"; +import { useEffect, useMemo, useState } from "react"; import { createColumnHelper } from "@tanstack/react-table"; +import { Alert, Button, Col, Container, Row, Spinner } from "react-bootstrap"; +import { useDispatch } from "react-redux"; +import { useLoaderData, useNavigate, useParams } from "react-router-dom"; +import Table from "components/Table/Table"; +import useAPI from "hooks/useAPI"; +import { alertActions } from "store/slices/alertSlice"; +import { IAssignmentResponse } from "utils/interfaces"; +import SubmittedContentService from "../../services/SubmittedContentService"; -interface ISubmission { +export interface ISubmissionMember { + fullName: string; + github: string; + email: string; +} + +export interface ISubmissionLink { id: number; + url?: string; + displayName: string; name: string; + size?: number | string; + type?: string; + modified?: string; + participantId?: string; + folder?: string; +} + +export interface ISubmission { + id: number; + teamId: number; + teamName: string; + members: ISubmissionMember[]; + links: ISubmissionLink[]; + files: ISubmissionLink[]; +} + +interface ISubmissionMemberResponse { + full_name?: string; + github?: string; + email?: string; +} + +interface ISubmissionAssetResponse { + id?: number; + url?: string; + display_name?: string; + name?: string; + size?: number | string; + type?: string; + modified?: string; + participant_id?: string; + folder?: string; } +interface ISubmissionResponse { + id?: number; + submission_id?: number; + team_id?: number; + team_name?: string; + participant_id?: string; + members?: ISubmissionMemberResponse[]; + links?: ISubmissionAssetResponse[]; + files?: ISubmissionAssetResponse[]; +} + +interface IViewSubmissionsResponse { + assignment_id?: number; + assignment_name?: string; + submissions?: ISubmissionResponse[]; +} + +type ViewSubmissionsLoaderData = Partial & { + due_dates?: Array<{ due_at?: string | Date }>; + date_time?: Record; +}; + const columnHelper = createColumnHelper(); +const EMPTY_MESSAGE = "No submissions are available for this assignment yet."; + +const buildGithubUrl = (github?: string) => { + if (!github) return ""; + if (/^https?:\/\//i.test(github)) return github; + return `https://github.com/${github.replace(/^@/, "")}`; +}; + +const getGithubLabel = (github?: string, email?: string) => { + if (!github) return email || "Unknown member"; + + const cleanedGithub = github.replace(/^@/, ""); + if (/^https?:\/\//i.test(cleanedGithub)) { + try { + const url = new URL(cleanedGithub); + return url.pathname.split("/").filter(Boolean).pop() || cleanedGithub; + } catch { + return cleanedGithub; + } + } + + return cleanedGithub; +}; + +const formatModifiedValue = (value?: string) => { + if (!value) return "-"; + + const parsedDate = new Date(value); + if (Number.isNaN(parsedDate.getTime())) { + return value; + } + + return parsedDate.toLocaleString(); +}; + +const transformAsset = ( + asset: ISubmissionAssetResponse, + index: number, + participantId?: string +): ISubmissionLink => ({ + id: asset.id ?? index + 1, + url: asset.url, + displayName: asset.display_name ?? asset.name ?? asset.url ?? `Artifact ${index + 1}`, + name: asset.name ?? asset.display_name ?? asset.url ?? `Artifact ${index + 1}`, + size: asset.size ?? "-", + type: asset.type ?? (asset.url ? "Link" : "File"), + modified: asset.modified ?? "", + participantId: asset.participant_id ?? participantId, + folder: asset.folder ?? "/", +}); + +export const transformResponse = ( + response?: IViewSubmissionsResponse | null +): ISubmission[] | null => { + if (!response || !Array.isArray(response.submissions)) { + return null; + } + + return response.submissions.map((submission, index) => { + const submissionId = submission.id ?? submission.submission_id ?? index + 1; + const teamId = submission.team_id ?? submission.id ?? index + 1; + const participantId = submission.participant_id; + + return { + id: submissionId, + teamId, + teamName: submission.team_name ?? `Team ${index + 1}`, + members: Array.isArray(submission.members) + ? submission.members.map((member) => ({ + fullName: member.full_name ?? "", + github: member.github ?? "", + email: member.email ?? "", + })) + : [], + links: Array.isArray(submission.links) + ? submission.links.map((link, linkIndex) => transformAsset(link, linkIndex, participantId)) + : [], + files: Array.isArray(submission.files) + ? submission.files.map((file, fileIndex) => transformAsset(file, fileIndex, participantId)) + : [], + }; + }); +}; + +const getLatestDueDate = (assignment: ViewSubmissionsLoaderData) => { + const dueDateValues = Array.isArray(assignment?.due_dates) + ? assignment.due_dates + .map((dueDate) => dueDate?.due_at) + .filter((value): value is string | Date => Boolean(value)) + : []; + + if (dueDateValues.length > 0) { + const parsedDates = dueDateValues + .map((dueDate) => new Date(dueDate)) + .filter((dueDate) => !Number.isNaN(dueDate.getTime())); + + if (parsedDates.length > 0) { + return parsedDates.reduce((latest, current) => + current.getTime() > latest.getTime() ? current : latest + ); + } + } -const ViewSubmissions: React.FC = () => { - const { id } = useParams<{ id: string }>(); + const dateTimeEntries = assignment?.date_time ? Object.values(assignment.date_time) : []; + const parsedDateTimeEntries = dateTimeEntries + .map((value) => new Date(value)) + .filter((value) => !Number.isNaN(value.getTime())); + + if (parsedDateTimeEntries.length === 0) { + return null; + } + + return parsedDateTimeEntries.reduce((latest, current) => + current.getTime() > latest.getTime() ? current : latest + ); +}; + +const ViewSubmissions = () => { + const assignment = (useLoaderData?.() as ViewSubmissionsLoaderData) || {}; + const { id: routeAssignmentId } = useParams<{ id: string }>(); + const assignmentId = Number(assignment?.id ?? routeAssignmentId ?? 0); const navigate = useNavigate(); + const dispatch = useDispatch(); + const { error, isLoading, data: submissionsResponse, sendRequest: fetchSubmissions } = useAPI(); - // Dummy data for submissions - const submissions = useMemo(() => [ - { id: 1, name: 'Submission 1' }, - { id: 2, name: 'Submission 2' }, - // ...other submissions - ], []); - - const columns = useMemo(() => [ - columnHelper.accessor('name', { - header: () => 'Submission', - cell: info => info.getValue() - }), - columnHelper.display({ - id: 'actions', - header: () => 'Actions', - cell: ({ row }) => ( - - ) - }) - ], []); - - const handleActionClick = (submissionId: number) => { - console.log(`Action clicked for submission ID ${submissionId}`); - // Here goes the logic for handling the action - }; - - // const handleClose = () => { - // navigate(-1); // Go back to the previous page - // }; + const [submissions, setSubmissions] = useState([]); + const [activeActionKey, setActiveActionKey] = useState(null); + const [openError, setOpenError] = useState(null); - return ( - -
- This is a placeholder page and is still in progress. -
- - -

View Submissions

- -
-
- - - +
+ ), + }), + columnHelper.accessor("members", { + header: () => "Team Members", + cell: ({ getValue }) => { + const members = getValue(); + + if (members.length === 0) { + return No team members; + } + + return ( +
    + {members.map((member) => { + const githubUrl = buildGithubUrl(member.github); + const githubLabel = getGithubLabel(member.github, member.email); + + return ( +
  • + {githubUrl ? ( + + {githubLabel} + + ) : ( + {githubLabel} + )}{" "} + {member.fullName && ({member.fullName})} +
  • + ); + })} +
+ ); + }, + }), + columnHelper.display({ + id: "links", + header: () => "Links", + cell: ({ row }) => { + const artifacts = [...row.original.links, ...row.original.files]; + + if (artifacts.length === 0) { + return No submission artifacts; + } + + return ( + + + + + + + + + + + + {artifacts.map((artifact) => { + const actionKey = `open:${row.original.teamId}:${artifact.folder ?? "/"}:${artifact.name}`; + const isPending = activeActionKey === actionKey; + + return ( + + + + + + + + ); + })} + +
NameSizeTypeDate Modified
{artifact.displayName}{artifact.size ?? "-"}{artifact.type ?? "-"}{formatModifiedValue(artifact.modified)} + +
+ ); + }, + }), + columnHelper.display({ + id: "history", + header: () => "History", + cell: ({ row }) => ( + - - - {/* - - - - */} - + ), + }), + ], + [assignmentId, navigate, shouldShowAssignGrade, activeActionKey] + ); + + const showLoadingState = isLoading && submissions.length === 0 && !submissionsResponse?.data; + const showEmptyState = !showLoadingState && submissions.length === 0; + + return ( +
+ + + +

View Submissions

+ {assignment?.name &&

{assignment.name}

} + +
+
+ + {openError && ( + + + setOpenError(null)}> + {openError} + + + + )} + + {showLoadingState ? ( + + + + Loading submissions... + + + ) : showEmptyState ? ( + + + + {EMPTY_MESSAGE} + + + + ) : ( + + + + )} + + ); }; diff --git a/src/pages/Assignments/__tests__/ViewSubmissions.test.tsx b/src/pages/Assignments/__tests__/ViewSubmissions.test.tsx new file mode 100644 index 00000000..a0d1fbae --- /dev/null +++ b/src/pages/Assignments/__tests__/ViewSubmissions.test.tsx @@ -0,0 +1,265 @@ +import "@testing-library/jest-dom"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Provider } from "react-redux"; +import { configureStore } from "@reduxjs/toolkit"; +import rootReducer from "store/rootReducer"; +import ViewSubmissions, { transformResponse } from "../ViewSubmissions"; + +const sendRequestMock = vi.fn(); +const mockNavigate = vi.fn(); + +let loaderData: any; +let mockApiState: any; + +vi.mock("hooks/useAPI", () => ({ + default: () => mockApiState, +})); + +vi.mock("react-router-dom", async () => { + const actual = await vi.importActual("react-router-dom"); + + return { + ...actual, + useLoaderData: () => loaderData, + useNavigate: () => mockNavigate, + useParams: () => ({ id: String(loaderData?.id ?? 1) }), + }; +}); + +const baseLoaderData = { + id: 1, + name: "Design Project", + due_dates: [{ due_at: "2099-03-20T17:00:00Z" }], +}; + +const apiPayload = { + assignment_id: 1, + assignment_name: "Design Project", + submissions: [ + { + id: 901, + team_id: 301, + team_name: "Team Alpha", + members: [ + { + full_name: "Alice Johnson", + github: "alice-johnson", + email: "alice@example.com", + }, + { + full_name: "Bob Carter", + github: "https://github.com/bobcarter", + email: "bob@example.com", + }, + ], + links: [ + { + id: 1, + url: "https://github.com/team-alpha/project", + display_name: "Project repository", + modified: "2026-03-19T13:00:00Z", + }, + ], + files: [ + { + id: 2, + url: "https://example.com/files/design-doc.pdf", + name: "design-doc.pdf", + size: "128 KB", + type: "PDF", + modified: "2026-03-19T13:30:00Z", + }, + ], + }, + ], +}; + +const createStore = () => + configureStore({ + reducer: rootReducer, + }); + +const renderComponent = () => { + const store = createStore(); + + return { + store, + ...render( + + + + ), + }; +}; + +describe("transformResponse", () => { + it("maps snake_case submission payloads to camelCase interfaces", () => { + const transformed = transformResponse(apiPayload); + + expect(transformed).toEqual([ + { + id: 901, + teamId: 301, + teamName: "Team Alpha", + members: [ + { + fullName: "Alice Johnson", + github: "alice-johnson", + email: "alice@example.com", + }, + { + fullName: "Bob Carter", + github: "https://github.com/bobcarter", + email: "bob@example.com", + }, + ], + links: [ + { + id: 1, + url: "https://github.com/team-alpha/project", + displayName: "Project repository", + name: "Project repository", + size: "-", + type: "Link", + modified: "2026-03-19T13:00:00Z", + }, + ], + files: [ + { + id: 2, + url: "https://example.com/files/design-doc.pdf", + displayName: "design-doc.pdf", + name: "design-doc.pdf", + size: "128 KB", + type: "PDF", + modified: "2026-03-19T13:30:00Z", + }, + ], + }, + ]); + }); +}); + +describe("ViewSubmissions", () => { + beforeEach(() => { + loaderData = { ...baseLoaderData }; + mockApiState = { + error: null, + isLoading: false, + data: { data: apiPayload }, + sendRequest: sendRequestMock, + }; + sendRequestMock.mockClear(); + mockNavigate.mockClear(); + }); + + it("renders team names, members, links, and available actions from API data", async () => { + renderComponent(); + + await waitFor(() => { + expect(sendRequestMock).toHaveBeenCalledWith({ + url: "/submitted_content/1/view_submissions", + }); + }); + + expect(screen.getByText("Team Alpha")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "alice-johnson" })).toHaveAttribute( + "href", + "https://github.com/alice-johnson" + ); + expect(screen.getByText("(Alice Johnson)")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Project repository" })).toHaveAttribute( + "href", + "https://github.com/team-alpha/project" + ); + expect(screen.getByRole("link", { name: "design-doc.pdf" })).toHaveAttribute( + "href", + "https://example.com/files/design-doc.pdf" + ); + expect(screen.getByRole("button", { name: /view history/i })).toBeDisabled(); + expect(screen.getByRole("button", { name: /view reviews/i })).toBeInTheDocument(); + }); + + it("shows a loading indicator while submissions are loading", () => { + mockApiState = { + error: null, + isLoading: true, + data: undefined, + sendRequest: sendRequestMock, + }; + + renderComponent(); + + expect(screen.getByText(/loading submissions/i)).toBeInTheDocument(); + expect(screen.queryByText("Team Alpha")).not.toBeInTheDocument(); + }); + + it("shows an empty state when the API returns an empty submissions list", async () => { + mockApiState = { + error: null, + isLoading: false, + data: { + data: { + assignment_id: 1, + assignment_name: "Design Project", + submissions: [], + }, + }, + sendRequest: sendRequestMock, + }; + + renderComponent(); + + expect( + await screen.findByText("No submissions are available for this assignment yet.") + ).toBeInTheDocument(); + }); + + it("shows an empty state and dispatches an alert when the API fails", async () => { + mockApiState = { + error: "Backend unavailable", + isLoading: false, + data: undefined, + sendRequest: sendRequestMock, + }; + + const { store } = renderComponent(); + + expect( + await screen.findByText("No submissions are available for this assignment yet.") + ).toBeInTheDocument(); + await waitFor(() => { + expect(store.getState().alert).toMatchObject({ + show: true, + variant: "danger", + message: "Backend unavailable", + }); + }); + }); + + it("navigates to the review page from row actions", async () => { + const user = userEvent.setup(); + + renderComponent(); + + await user.click(await screen.findByRole("button", { name: /view reviews/i })); + expect(mockNavigate).toHaveBeenCalledWith("/assignments/1/review?team_id=301"); + expect(screen.getByRole("button", { name: /view history/i })).toBeDisabled(); + }); + + it("switches the primary action to assign grade after the due date passes", async () => { + const user = userEvent.setup(); + loaderData = { + ...baseLoaderData, + due_dates: [{ due_at: "2020-03-20T17:00:00Z" }], + }; + + renderComponent(); + + const assignGradeButton = await screen.findByRole("button", { name: /assign grade/i }); + await user.click(assignGradeButton); + + expect(mockNavigate).toHaveBeenCalledWith("/assignments/1/assign-grades?team_id=301"); + }); +}); diff --git a/src/services/SubmittedContentService.ts b/src/services/SubmittedContentService.ts index 1bee018c..1437457f 100644 --- a/src/services/SubmittedContentService.ts +++ b/src/services/SubmittedContentService.ts @@ -1,228 +1,258 @@ -import axios from 'axios'; - -/** - * SubmittedContentService - * Handles all API calls related to submitted content (files and hyperlinks) - * @author Team CSC517 - */ - -interface IListFilesResponse { - files?: Array<{ - name: string; - path: string; - type: 'file' | 'directory'; - }>; +import { AxiosResponse } from "axios"; +import axiosClient from "../utils/axios_client"; + +interface IParticipantResponse { + id?: number; + user_id?: number; + user?: { + id?: number; + }; + team_id?: number; + team?: { + id?: number; + }; +} + +export interface IListedFile { + name: string; + size?: number; + type?: string; + modified_at?: string; +} + +export interface IListedFolder { + name: string; + modified_at?: string; +} + +export interface IListFilesResponse { + current_folder?: string; + files?: IListedFile[]; + folders?: IListedFolder[]; hyperlinks?: string[]; error?: string; } +interface ISubmissionAssetResponse { + display_name?: string; + name?: string; + size?: number | string; + type?: string; + modified?: string; + url?: string; +} + +interface ITeamSubmissionResponse { + team_id?: number; + links?: ISubmissionAssetResponse[]; + files?: ISubmissionAssetResponse[]; +} + +interface IViewSubmissionsResponse { + submissions?: ITeamSubmissionResponse[]; +} + +export interface IParticipantContext { + participantId: string; + teamId: number | null; +} + interface IValidationResult { valid: boolean; error?: string; } class SubmittedContentService { - /** - * Submit a file for an assignment - * @param formData FormData containing file and metadata - * @param participantId The participant ID - * @param currentFolder Current folder path - * @returns Response data - */ + private static normalizeFolder(currentFolder = "/") { + const trimmedFolder = currentFolder.trim(); + + if (!trimmedFolder || trimmedFolder === "/") { + return "/"; + } + + const cleanedFolder = trimmedFolder.replace(/\/{2,}/g, "/").replace(/\/$/, ""); + return cleanedFolder.startsWith("/") ? cleanedFolder : `/${cleanedFolder}`; + } + + static async findParticipantContext( + assignmentId: number, + userId: number + ): Promise { + const response = await axiosClient.get(`/participants/assignment/${assignmentId}`); + const participants = Array.isArray(response.data) ? response.data : []; + const matchingParticipant = participants.find((participant: IParticipantResponse) => { + const participantUserId = participant.user_id ?? participant.user?.id; + return Number(participantUserId) === Number(userId); + }); + + if (!matchingParticipant?.id) { + throw new Error("No participant record was found for the current user on this assignment."); + } + + return { + participantId: String(matchingParticipant.id), + teamId: matchingParticipant.team_id ?? matchingParticipant.team?.id ?? null, + }; + } + + static async findParticipantId(assignmentId: number, userId: number): Promise { + const participantContext = await this.findParticipantContext(assignmentId, userId); + return participantContext.participantId; + } + static async submitFile( - formData: FormData, participantId: string, - currentFolder: string + file: File, + currentFolder = "/" ): Promise { - try { - const response = await axios.post( - '/submitted_content/submit_file', - formData, - { - headers: { - 'Content-Type': 'multipart/form-data', - }, - } - ); - return response.data; - } catch (error: any) { - throw error.response?.data || error; - } + const formData = new FormData(); + formData.append("id", participantId); + formData.append("uploaded_file", file); + formData.append("current_folder[name]", this.normalizeFolder(currentFolder)); + + const response = await axiosClient.post("/submitted_content/submit_file", formData); + + return response.data; } - /** - * Submit a hyperlink for an assignment - * @param url The hyperlink URL - * @param participantId The participant ID - * @returns Response data - */ static async submitHyperlink(url: string, participantId: string): Promise { - try { - const response = await axios.post('/submitted_content/submit_hyperlink', { - id: participantId, - submission: url, - }); - return response.data; - } catch (error: any) { - throw error.response?.data || error; - } + const response = await axiosClient.post("/submitted_content/submit_hyperlink", { + id: participantId, + submit_link: url, + }); + + return response.data; } - /** - * Remove a submitted hyperlink - * @param participantId The participant ID - * @param index Index of hyperlink to remove - * @returns Response data - */ static async removeHyperlink(participantId: string, index: number): Promise { - try { - const response = await axios.post('/submitted_content/remove_hyperlink', { + const response = await axiosClient.delete("/submitted_content/remove_hyperlink", { + data: { id: participantId, chk_links: index, - }); - return response.data; - } catch (error: any) { - throw error.response?.data || error; - } + }, + }); + + return response.data; } - /** - * List all submitted files and hyperlinks - * @param participantId The participant ID - * @param currentFolder Current folder path - * @returns Files and hyperlinks list - */ static async listFiles( participantId: string, - currentFolder: string + currentFolder = "/" ): Promise { - try { - const response = await axios.get('/submitted_content/list_files', { - params: { - id: participantId, - folder: { name: currentFolder }, - }, - }); - return response.data; - } catch (error: any) { - throw error.response?.data || error; - } + const response = await axiosClient.get("/submitted_content/list_files", { + params: { + id: participantId, + folder: { name: this.normalizeFolder(currentFolder) }, + }, + }); + + return response.data; } - /** - * Perform folder action (create, delete, etc.) - * @param action The action to perform - * @param participantId The participant ID - * @param currentFolder Current folder path - * @returns Response data - */ - static async folderAction( - action: any, - participantId: string, - currentFolder: string - ): Promise { - try { - const response = await axios.post('/submitted_content/folder_action', { - id: participantId, - current_folder: { name: currentFolder }, - faction: action, - }); - return response.data; - } catch (error: any) { - throw error.response?.data || error; + static async getTeamSubmissionSummary( + assignmentId: number, + teamId: number + ): Promise { + const response = await axiosClient.get(`/submitted_content/${assignmentId}/view_submissions`); + const submissions = (response.data as IViewSubmissionsResponse)?.submissions ?? []; + const matchingTeam = submissions.find( + (submission) => Number(submission.team_id) === Number(teamId) + ); + + if (!matchingTeam) { + return { + current_folder: "/", + files: [], + folders: [], + hyperlinks: [], + }; } + + const files = (matchingTeam.files ?? []).map((file) => ({ + name: file.display_name ?? file.name ?? "Unnamed file", + size: typeof file.size === "number" ? file.size : undefined, + type: file.type ?? "File", + modified_at: file.modified, + })); + + const hyperlinks = (matchingTeam.links ?? []) + .map((link) => link.url ?? link.name ?? link.display_name) + .filter((link): link is string => Boolean(link)); + + return { + current_folder: "/", + files, + folders: [], + hyperlinks, + }; } - /** - * Delete a submitted file - * @param fileName Name of file to delete - * @param participantId The participant ID - * @param currentFolder Current folder path - * @returns Response data - */ static async deleteFile( fileName: string, participantId: string, - currentFolder: string + currentFolder = "/" ): Promise { - return this.folderAction({ delete: fileName }, participantId, currentFolder); + const response = await axiosClient.post("/submitted_content/folder_action", { + id: participantId, + current_folder: { name: this.normalizeFolder(currentFolder) }, + faction: { delete: fileName }, + }); + + return response.data; } - /** - * Download a submitted file - * @param fileName Name of file to download - * @param participantId The participant ID - * @param currentFolder Current folder path - * @returns Blob data - */ static async downloadFile( fileName: string, participantId: string, - currentFolder: string - ): Promise { - try { - const response = await axios.get('/submitted_content/download', { - params: { - id: participantId, - current_folder: { name: currentFolder }, - download: fileName, - }, - responseType: 'blob', - }); - return response.data; - } catch (error: any) { - throw error.response?.data || error; - } + currentFolder = "/" + ): Promise> { + return axiosClient.get("/submitted_content/download", { + params: { + id: participantId, + current_folder: { name: this.normalizeFolder(currentFolder) }, + download: fileName, + }, + responseType: "blob", + }); } - /** - * Validate a file for upload - * @param file The file to validate - * @returns Validation result - */ static validateFile(file: File): IValidationResult { - // Check file size (5MB max) const maxSize = 5 * 1024 * 1024; if (file.size > maxSize) { return { valid: false, - error: `File size must be less than 5MB (${(file.size / 1024 / 1024).toFixed(2)}MB)`, + error: `File size must be less than 5MB (${(file.size / 1024 / 1024).toFixed(2)}MB).`, }; } - // Check file extension const allowedExtensions = [ - 'pdf', - 'png', - 'jpeg', - 'jpg', - 'zip', - 'tar', - 'gz', - '7z', - 'odt', - 'docx', - 'md', - 'rb', - 'mp4', - 'txt', + "pdf", + "png", + "jpeg", + "jpg", + "zip", + "tar", + "gz", + "7z", + "odt", + "docx", + "md", + "rb", + "mp4", + "txt", ]; - const extension = file.name.split('.').pop()?.toLowerCase(); + + const extension = file.name.split(".").pop()?.toLowerCase(); if (!extension || !allowedExtensions.includes(extension)) { return { valid: false, - error: `File type not allowed. Allowed types: ${allowedExtensions.join(', ')}`, + error: `File type not allowed. Allowed types: ${allowedExtensions.join(", ")}.`, }; } return { valid: true }; } - /** - * Validate a URL for hyperlink submission - * @param url The URL to validate - * @returns Validation result - */ static validateUrl(url: string): IValidationResult { try { new URL(url); @@ -230,67 +260,19 @@ class SubmittedContentService { } catch { return { valid: false, - error: 'Invalid URL format. Please enter a valid URL (e.g., https://example.com)', + error: "Invalid URL format. Please enter a valid URL such as https://example.com.", }; } } - /** - * Get file size in human readable format - * @param bytes File size in bytes - * @returns Formatted file size string - */ static formatFileSize(bytes: number): string { - if (bytes === 0) return '0 Bytes'; - const k = 1024; - const sizes = ['Bytes', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; - } - - /** - * Check if file type is document - * @param fileName The file name - * @returns True if file is a document - */ - static isDocument(fileName: string): boolean { - const documentExtensions = ['pdf', 'odt', 'docx', 'md', 'txt']; - const extension = fileName.split('.').pop()?.toLowerCase(); - return extension ? documentExtensions.includes(extension) : false; - } - - /** - * Check if file type is media - * @param fileName The file name - * @returns True if file is media - */ - static isMedia(fileName: string): boolean { - const mediaExtensions = ['png', 'jpeg', 'jpg', 'mp4']; - const extension = fileName.split('.').pop()?.toLowerCase(); - return extension ? mediaExtensions.includes(extension) : false; - } + if (!bytes) return "-"; - /** - * Check if file type is archive - * @param fileName The file name - * @returns True if file is an archive - */ - static isArchive(fileName: string): boolean { - const archiveExtensions = ['zip', 'tar', 'gz', '7z']; - const extension = fileName.split('.').pop()?.toLowerCase(); - return extension ? archiveExtensions.includes(extension) : false; - } + const unit = 1024; + const sizes = ["Bytes", "KB", "MB", "GB"]; + const sizeIndex = Math.floor(Math.log(bytes) / Math.log(unit)); - /** - * Get icon for file type - * @param fileName The file name - * @returns Icon emoji string - */ - static getFileIcon(fileName: string): string { - if (this.isDocument(fileName)) return '📄'; - if (this.isMedia(fileName)) return '🎬'; - if (this.isArchive(fileName)) return '📦'; - return '📁'; + return `${Math.round((bytes / unit ** sizeIndex) * 100) / 100} ${sizes[sizeIndex]}`; } } diff --git a/src/services/__tests__/SubmittedContentService.test.ts b/src/services/__tests__/SubmittedContentService.test.ts index d87453cb..2d89fe05 100644 --- a/src/services/__tests__/SubmittedContentService.test.ts +++ b/src/services/__tests__/SubmittedContentService.test.ts @@ -1,45 +1,85 @@ -import axios from 'axios'; import SubmittedContentService from '../SubmittedContentService'; +import axiosClient from '../../utils/axios_client'; import type { Mocked } from 'vitest'; -vi.mock('axios'); -const mockedAxios = axios as Mocked; +// Mock the axiosClient instance used by the service (not raw axios, +// which is never called directly – the service always goes through axiosClient). +vi.mock('../../utils/axios_client', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + delete: vi.fn(), + }, +})); + +const mockedAxios = axiosClient as Mocked; describe('SubmittedContentService', () => { beforeEach(() => { vi.clearAllMocks(); }); - test('submitFile should call correct endpoint', async () => { + // --------------------------------------------------------------------------- + // submitFile + // --------------------------------------------------------------------------- + + test('submitFile should call correct endpoint with participantId and file', async () => { const mockResponse = { data: { message: 'File uploaded' } }; mockedAxios.post.mockResolvedValueOnce(mockResponse); - const formData = new FormData(); - formData.append('file', new File(['test'], 'test.pdf')); + const participantId = '123'; + const file = new File(['test'], 'test.pdf', { type: 'application/pdf' }); - const result = await SubmittedContentService.submitFile(formData, '123', '/'); + const result = await SubmittedContentService.submitFile(participantId, file); - expect(mockedAxios.post).toHaveBeenCalledWith( - '/submitted_content/submit_file', - formData, - expect.any(Object) - ); + expect(mockedAxios.post).toHaveBeenCalledTimes(1); + const [url, formData] = (mockedAxios.post as ReturnType).mock.calls[0]; + expect(url).toBe('/submitted_content/submit_file'); + expect(formData).toBeInstanceOf(FormData); + expect(formData.get('id')).toBe(participantId); + expect(formData.get('uploaded_file')).toBe(file); + // default currentFolder normalises to "/" + expect(formData.get('current_folder[name]')).toBe('/'); expect(result).toEqual(mockResponse.data); }); - test('submitFile should handle FormData correctly', async () => { + test('submitFile should forward an explicit subfolder via normalizeFolder', async () => { mockedAxios.post.mockResolvedValueOnce({ data: { message: 'Success' } }); - const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' }); - const formData = new FormData(); - formData.append('id', '123'); - formData.append('uploaded_file', file); + const participantId = '123'; + const file = new File(['test content'], 'notes.txt', { type: 'text/plain' }); + + await SubmittedContentService.submitFile(participantId, file, '/week1/'); + + const [, formData] = (mockedAxios.post as ReturnType).mock.calls[0]; + // normalizeFolder strips trailing slash + expect(formData.get('current_folder[name]')).toBe('/week1'); + }); + + test('submitFile should prepend leading slash when folder lacks one', async () => { + mockedAxios.post.mockResolvedValueOnce({ data: {} }); + + const file = new File(['data'], 'data.zip', { type: 'application/zip' }); + await SubmittedContentService.submitFile('456', file, 'week2/sub'); + + const [, formData] = (mockedAxios.post as ReturnType).mock.calls[0]; + expect(formData.get('current_folder[name]')).toBe('/week2/sub'); + }); + + test('submitFile should treat empty string folder the same as "/"', async () => { + mockedAxios.post.mockResolvedValueOnce({ data: {} }); - await SubmittedContentService.submitFile(formData, '123', '/'); + const file = new File(['x'], 'readme.md', { type: 'text/markdown' }); + await SubmittedContentService.submitFile('789', file, ''); - expect(mockedAxios.post).toHaveBeenCalled(); + const [, formData] = (mockedAxios.post as ReturnType).mock.calls[0]; + expect(formData.get('current_folder[name]')).toBe('/'); }); + // --------------------------------------------------------------------------- + // submitHyperlink + // --------------------------------------------------------------------------- + test('submitHyperlink should call correct endpoint', async () => { const mockResponse = { data: { message: 'Hyperlink submitted' } }; mockedAxios.post.mockResolvedValueOnce(mockResponse); @@ -50,28 +90,38 @@ describe('SubmittedContentService', () => { '/submitted_content/submit_hyperlink', expect.objectContaining({ id: '123', - submission: 'https://example.com', + submit_link: 'https://example.com', }) ); expect(result).toEqual(mockResponse.data); }); - test('removeHyperlink should call correct endpoint', async () => { + // --------------------------------------------------------------------------- + // removeHyperlink + // --------------------------------------------------------------------------- + + test('removeHyperlink should call correct endpoint via DELETE', async () => { const mockResponse = { data: { message: 'Hyperlink removed' } }; - mockedAxios.post.mockResolvedValueOnce(mockResponse); + mockedAxios.delete.mockResolvedValueOnce(mockResponse); const result = await SubmittedContentService.removeHyperlink('123', 0); - expect(mockedAxios.post).toHaveBeenCalledWith( + expect(mockedAxios.delete).toHaveBeenCalledWith( '/submitted_content/remove_hyperlink', expect.objectContaining({ - id: '123', - chk_links: 0, + data: expect.objectContaining({ + id: '123', + chk_links: 0, + }), }) ); expect(result).toEqual(mockResponse.data); }); + // --------------------------------------------------------------------------- + // listFiles + // --------------------------------------------------------------------------- + test('listFiles should call correct endpoint', async () => { const mockResponse = { data: { files: [], hyperlinks: [] } }; mockedAxios.get.mockResolvedValueOnce(mockResponse); @@ -85,11 +135,15 @@ describe('SubmittedContentService', () => { expect(result).toEqual(mockResponse.data); }); + // --------------------------------------------------------------------------- + // downloadFile + // --------------------------------------------------------------------------- + test('downloadFile should set responseType to blob', async () => { const mockBlob = new Blob(['test'], { type: 'application/pdf' }); mockedAxios.get.mockResolvedValueOnce({ data: mockBlob }); - const result = await SubmittedContentService.downloadFile('test.pdf', '123', '/'); + await SubmittedContentService.downloadFile('test.pdf', '123', '/'); expect(mockedAxios.get).toHaveBeenCalledWith( '/submitted_content/download', @@ -99,6 +153,10 @@ describe('SubmittedContentService', () => { ); }); + // --------------------------------------------------------------------------- + // deleteFile + // --------------------------------------------------------------------------- + test('deleteFile should call correct endpoint', async () => { const mockResponse = { data: { message: 'File deleted' } }; mockedAxios.post.mockResolvedValueOnce(mockResponse); @@ -115,38 +173,48 @@ describe('SubmittedContentService', () => { expect(result).toEqual(mockResponse.data); }); - test('validateFile should check size', () => { - const largeFile = new File( - [new ArrayBuffer(6 * 1024 * 1024)], - 'large.pdf' - ); + // --------------------------------------------------------------------------- + // validateFile + // --------------------------------------------------------------------------- + + test('validateFile should reject files over 5 MB', () => { + const largeFile = new File([new ArrayBuffer(6 * 1024 * 1024)], 'large.pdf'); const result = SubmittedContentService.validateFile(largeFile); expect(result.valid).toBe(false); + expect(result.error).toMatch(/5MB/i); }); - test('validateFile should check extension', () => { + test('validateFile should reject disallowed extensions', () => { const invalidFile = new File(['test'], 'test.exe'); const result = SubmittedContentService.validateFile(invalidFile); expect(result.valid).toBe(false); }); - test('validateFile should accept valid file', () => { - const validFile = new File(['test'], 'test.pdf'); + test('validateFile should accept a valid PDF under 5 MB', () => { + const validFile = new File(['test'], 'test.pdf', { type: 'application/pdf' }); const result = SubmittedContentService.validateFile(validFile); expect(result.valid).toBe(true); }); - test('validateUrl should accept valid URL', () => { - const result = SubmittedContentService.validateUrl('https://example.com'); - expect(result.valid).toBe(true); + // --------------------------------------------------------------------------- + // validateUrl + // --------------------------------------------------------------------------- + + test('validateUrl should accept a valid URL', () => { + expect(SubmittedContentService.validateUrl('https://example.com').valid).toBe(true); }); - test('validateUrl should reject invalid URL', () => { + test('validateUrl should reject an invalid URL', () => { const result = SubmittedContentService.validateUrl('not a url'); expect(result.valid).toBe(false); + expect(result.error).toBeTruthy(); }); - test('should handle API errors gracefully', async () => { + // --------------------------------------------------------------------------- + // Error handling + // --------------------------------------------------------------------------- + + test('should propagate API errors', async () => { const error = new Error('Network error'); mockedAxios.post.mockRejectedValueOnce(error); @@ -155,7 +223,7 @@ describe('SubmittedContentService', () => { ).rejects.toThrow('Network error'); }); - test('should handle response with error field', async () => { + test('should return response with error field from listFiles', async () => { mockedAxios.get.mockResolvedValueOnce({ data: { error: 'File not found' }, }); @@ -163,4 +231,4 @@ describe('SubmittedContentService', () => { const result = await SubmittedContentService.listFiles('123', '/'); expect(result).toEqual({ error: 'File not found' }); }); -}); +}); \ No newline at end of file diff --git a/src/utils/axios_client.ts b/src/utils/axios_client.ts index 77a4ba16..7d0bc423 100644 --- a/src/utils/axios_client.ts +++ b/src/utils/axios_client.ts @@ -16,6 +16,13 @@ const axiosClient = axios.create({ axiosClient.interceptors.request.use((config) => { const token = getAuthToken(); + const isMultipartRequest = + typeof FormData !== "undefined" && config.data instanceof FormData; + + if (isMultipartRequest && config.headers) { + delete config.headers["Content-Type"]; + } + if (token && token !== "EXPIRED") { config.headers["Authorization"] = `Bearer ${token}`; return config;