diff --git a/src/App.tsx b/src/App.tsx index 59c76165..ff0f1280 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -225,7 +225,7 @@ function App() { { path: "assignments/edit/:assignmentId/participants", - element: , + element: , children: [ { path: "new", diff --git a/src/components/Modals/ExportModal.tsx b/src/components/Modals/ExportModal.tsx index 9e13af17..188a93ac 100644 --- a/src/components/Modals/ExportModal.tsx +++ b/src/components/Modals/ExportModal.tsx @@ -1,5 +1,5 @@ // src/components/ExportModal.tsx -import React, { useEffect, useState, memo, useCallback } from "react"; +import React, { useEffect, useState, memo, useCallback, useRef } from "react"; import { Modal, Button, Form, Row, Col, OverlayTrigger, Tooltip } from "react-bootstrap"; import useAPI from "../../hooks/useAPI"; import { HttpMethod } from "../../utils/httpMethods"; @@ -80,34 +80,85 @@ Icon.displayName = 'Icon'; type ExportModal = { show: boolean; onHide: () => void; - modelClass: string; + modelClass: string; + contextParams?: Record; +}; + +type ExportFilePayload = { + name: string; + contents: string; }; /* ============================================================================= Component (dummy mode – no backend) ============================================================================= */ -const ExportModal: React.FC = ({ show, onHide, modelClass }) => { - const [mandatoryFields, setMandatoryFields] = useState([]); - const [optionalFields, setOptionalFields] = useState([]); - const [externalFields, setExternalFields] = useState([]); - const [allFields, setAllFields] = useState([]); - const [selectedFields, setSelectedFields] = useState([]); +const ExportModal: React.FC = ({ show, onHide, modelClass, contextParams }) => { + const [mandatoryFields, setMandatoryFields] = useState([]); + const [optionalFields, setOptionalFields] = useState([]); + const [externalFields, setExternalFields] = useState([]); + const [allFields, setAllFields] = useState([]); + const [selectedFields, setSelectedFields] = useState([]); const [status, setStatus] = useState(''); - const { error, isLoading, data: exportResponse, sendRequest: fetchExports } = useAPI(); - const { data: sendExportResponse, error: exportError, sendRequest: sendExport } = useAPI(); - - const fetchConfig = useCallback(async () => { - try { - fetchExports({ url: `/export/${modelClass}` }); - // Handle the responses as needed - } catch (err) { - // Handle any errors that occur during the fetch - console.error("Error fetching data:", err); + const { isLoading, data: exportResponse, sendRequest: fetchExports } = useAPI(); + const { + data: sendExportResponse, + error: exportError, + sendRequest: sendExport, + reset: resetExportState, + } = useAPI(); + const hasHandledExportResponse = useRef(false); + const teamParticipantFields = useCallback( + (fields: string[]) => fields.filter((field) => field.startsWith("participant_")), + [] + ); + const normalizeTeamFieldList = useCallback( + (fields: string[]) => { + if (modelClass !== "Team") return fields; + + const participantFields = teamParticipantFields(fields); + if (participantFields.length === 0) return fields; + + const nonParticipantFields = fields.filter((field) => !field.startsWith("participant_")); + return [...nonParticipantFields, "participant_ids"]; + }, + [modelClass, teamParticipantFields] + ); + const expandTeamFieldSelection = useCallback( + (fields: string[], sourceFields: string[]) => { + if (modelClass !== "Team" || !fields.includes("participant_ids")) return fields; + + const participantFields = teamParticipantFields(sourceFields); + const expandedFields = fields.flatMap((field) => + field === "participant_ids" ? participantFields : [field] + ); + + return Array.from(new Set(expandedFields)); + }, + [modelClass, teamParticipantFields] + ); + + const fetchConfig = useCallback(async () => { + hasHandledExportResponse.current = false; + resetExportState(true, true); + + try { + const params = new URLSearchParams(); + Object.entries(contextParams || {}).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + params.append(key, String(value)); } - }, [fetchExports]); + }); + + const url = params.toString() ? `/export/${modelClass}?${params.toString()}` : `/export/${modelClass}`; + fetchExports({ url }); + } catch (err) { + console.error("Error fetching data:", err); + } + }, [contextParams, fetchExports, modelClass]); const transformField = (field: string) => { + if (field === "participant_ids") return "Participant IDs"; let f = field.replace(/_/g, " "); return f.charAt(0).toUpperCase() + f.slice(1); }; @@ -124,27 +175,32 @@ const ExportModal: React.FC = ({ show, onHide, modelClass }) => { useEffect(() => { if (!show) return; - fetchConfig() - }, [show]); + hasHandledExportResponse.current = false; + resetExportState(true, true); + fetchConfig(); + }, [fetchConfig, resetExportState, show]); useEffect(() => { if (exportResponse) { - setMandatoryFields(exportResponse.data.mandatory_fields); - setOptionalFields(exportResponse.data.optional_fields); + const normalizedMandatoryFields = normalizeTeamFieldList(exportResponse.data.mandatory_fields); + const normalizedOptionalFields = normalizeTeamFieldList(exportResponse.data.optional_fields); + + setMandatoryFields(normalizedMandatoryFields); + setOptionalFields(normalizedOptionalFields); setExternalFields(exportResponse.data.external_fields); const fields = [ - ...exportResponse.data.mandatory_fields, - ...exportResponse.data.optional_fields, + ...normalizedMandatoryFields, + ...normalizedOptionalFields, ...exportResponse.data.external_fields - ] + ]; - setAllFields(fields) - setSelectedFields(exportResponse.data.mandatory_fields) + setAllFields(Array.from(new Set(fields))); + setSelectedFields(normalizedMandatoryFields); setStatus(''); } - }, [exportResponse]); + }, [exportResponse, normalizeTeamFieldList]); const toggleField = (field: string) => { setSelectedFields((prev) => @@ -170,35 +226,35 @@ const ExportModal: React.FC = ({ show, onHide, modelClass }) => { }); }; - function getFormattedDateTimeForFilename() { + const getTimestampForFilename = () => { const now = new Date(); - - // Get year, month, day const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, '0'); // Months are 0-indexed - const day = String(now.getDate()).padStart(2, '0'); - - // Get hours, minutes, seconds - const hours = String(now.getHours()).padStart(2, '0'); - const minutes = String(now.getMinutes()).padStart(2, '0'); - const seconds = String(now.getSeconds()).padStart(2, '0'); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + const hours = String(now.getHours()).padStart(2, "0"); + const minutes = String(now.getMinutes()).padStart(2, "0"); + const seconds = String(now.getSeconds()).padStart(2, "0"); - // Combine into a string without invalid characters return `${year}${month}${day}_${hours}${minutes}${seconds}`; - } - - const downloadFile = (file) => { - const url = window.URL.createObjectURL(new Blob([file])) - const link = document.createElement('a') - link.href = url - - const timestamp = Date.now().toLocaleString(); + }; - link.setAttribute('download', `${modelClass}_export_${getFormattedDateTimeForFilename()}.csv`) - document.body.appendChild(link) - link.click() - link.remove() - } + const downloadFiles = (files: ExportFilePayload[]) => { + const timestamp = getTimestampForFilename(); + + files.forEach((file) => { + const blob = new Blob([file.contents], { type: "text/csv;charset=utf-8;" }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + const baseName = file.name.replace(/\.csv$/i, ""); + const filename = `${baseName}_${timestamp}.csv`; + link.setAttribute('download', filename); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + }); + }; const on_export = async () => { if (selectedFields.length === 0) { setStatus('Please select at least one field.'); @@ -210,22 +266,31 @@ const ExportModal: React.FC = ({ show, onHide, modelClass }) => { try { const formData = new FormData(); - const orderedFields = allFields.filter((f) => selectedFields.includes(f)); + const orderedFields = expandTeamFieldSelection( + allFields.filter((f) => selectedFields.includes(f)), + exportResponse?.data + ? [ + ...exportResponse.data.mandatory_fields, + ...exportResponse.data.optional_fields, + ...exportResponse.data.external_fields, + ] + : allFields + ); formData.append("ordered_fields", JSON.stringify(orderedFields)); - - let url = `/export/${modelClass}`; + Object.entries(contextParams || {}).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + formData.append(key, String(value)); + } + }); await sendExport({ - url, + url: `/export/${modelClass}`, method: HttpMethod.POST, data: formData, headers: { "Content-Type": "multipart/form-data" }, }); - - console.log(sendExportResponse) - } catch (err: any) { setStatus(err.message || "Unexpected error."); } @@ -234,17 +299,24 @@ const ExportModal: React.FC = ({ show, onHide, modelClass }) => { useEffect(() => { - if(sendExportResponse) { - setStatus(sendExportResponse.data.message); - downloadFile(sendExportResponse.data.file) + if (!show) return; - if (!exportError){ - setTimeout(onHide, 1500); + if (sendExportResponse && !hasHandledExportResponse.current) { + hasHandledExportResponse.current = true; + setStatus(sendExportResponse.data.message); + const files = Array.isArray(sendExportResponse.data.file) ? sendExportResponse.data.file : []; + downloadFiles(files); + + if (!exportError) { + setTimeout(() => { + resetExportState(true, true); + onHide(); + }, 1500); } } else if (exportError) { setStatus(exportError); } - }, [sendExportResponse, exportError]); + }, [exportError, onHide, resetExportState, sendExportResponse, show]); return ( = ({ show, onHide, modelClass }) => { -
- Optional fields: +
+ External fields: = ({ show, onHide, modelClass }) => { } > - - - + + + -
+
diff --git a/src/components/Modals/ImportModal.test.tsx b/src/components/Modals/ImportModal.test.tsx index b85f668e..71d1bdd3 100644 --- a/src/components/Modals/ImportModal.test.tsx +++ b/src/components/Modals/ImportModal.test.tsx @@ -9,7 +9,7 @@ import { } from "@testing-library/react"; import "@testing-library/jest-dom"; import userEvent from "@testing-library/user-event"; -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import ImportModal from "./ImportModal"; @@ -46,10 +46,29 @@ const IMPORT_METADATA = { describe("ImportModal", () => { const onHide = vi.fn(); + let createObjectURLSpy: ReturnType; + let revokeObjectURLSpy: ReturnType; beforeEach(() => { mockUseAPI.mockReset(); onHide.mockReset(); + + createObjectURLSpy = vi.fn(() => "blob:sample-url"); + revokeObjectURLSpy = vi.fn(); + + Object.defineProperty(window.URL, "createObjectURL", { + writable: true, + value: createObjectURLSpy, + }); + Object.defineProperty(window.URL, "revokeObjectURL", { + writable: true, + value: revokeObjectURLSpy, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); it("shows loading state when isLoading is true", async () => { @@ -239,4 +258,50 @@ describe("ImportModal", () => { expect(onHide).toHaveBeenCalled(); }); }); + + it("downloads a sample csv using backend header metadata", async () => { + const user = userEvent.setup(); + let createdAnchor: HTMLAnchorElement | null = null; + let clickSpy: ReturnType | null = null; + let capturedBlobParts: BlobPart[] = []; + const OriginalBlob = Blob; + const originalCreateElement = document.createElement.bind(document); + const createElementSpy = vi.spyOn(document, "createElement").mockImplementation((tagName: string) => { + const element = originalCreateElement(tagName); + if (tagName.toLowerCase() === "a" && element instanceof HTMLAnchorElement) { + createdAnchor = element; + clickSpy = vi.fn(); + vi.spyOn(element, "click").mockImplementation(clickSpy); + } + return element; + }); + vi.stubGlobal("Blob", vi.fn((parts: BlobPart[], options?: BlobPropertyBag) => { + capturedBlobParts = parts; + return new OriginalBlob(parts, options); + })); + + mockUseAPI.mockReturnValue( + makeUseAPIResult({ data: { data: IMPORT_METADATA } }) + ); + + await act(async () => { + render(); + }); + + await screen.findByText(/Mandatory fields/i); + + await act(async () => { + await user.click(screen.getByRole("button", { name: /download sample csv/i })); + }); + + expect(createObjectURLSpy).toHaveBeenCalledTimes(1); + expect(capturedBlobParts).toEqual(["email\n"]); + + expect(createdAnchor).not.toBeNull(); + expect(createdAnchor?.getAttribute("download")).toBe("user_import_sample.csv"); + expect(clickSpy).not.toBeNull(); + expect(clickSpy).toHaveBeenCalled(); + expect(revokeObjectURLSpy).toHaveBeenCalledWith("blob:sample-url"); + expect(createElementSpy).toHaveBeenCalledWith("a"); + }); }); diff --git a/src/components/Modals/ImportModal.tsx b/src/components/Modals/ImportModal.tsx index 2e2b37b8..b33b808e 100644 --- a/src/components/Modals/ImportModal.tsx +++ b/src/components/Modals/ImportModal.tsx @@ -1,6 +1,6 @@ // src/components/ImportModal.tsx -import React, { useEffect, useState, memo, useCallback, ChangeEvent, useMemo } from "react"; +import React, { useEffect, useState, memo, useCallback, ChangeEvent, useMemo, useRef } from "react"; import { Modal, Button, @@ -9,8 +9,6 @@ import { Col, OverlayTrigger, Tooltip, - Container, - CloseButton, } from "react-bootstrap"; import useAPI from "../../hooks/useAPI"; @@ -95,20 +93,26 @@ type ImportModalProps = { show: boolean; // Parent-controlled visible flag onHide: () => void; // Callback to parent when modal should close modelClass: string; // "User", "Team", etc. + contextParams?: Record; }; /* ============================================================================ * ImportModal Component * ============================================================================ */ -const ImportModal: React.FC = ({ show, onHide, modelClass }) => { +const ImportModal: React.FC = ({ show, onHide, modelClass, contextParams }) => { + const closeTimerRef = useRef | null>(null); /** * Force-close handler — ALWAYS closes modal instantly. * Then notifies parent so it can update state if needed. */ - const forceClose = () => { + const forceClose = useCallback(() => { + if (closeTimerRef.current) { + clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + } setTimeout(onHide, 10); // Notify parent AFTER close - }; + }, [onHide]); /* --------------------------------------------------------- * API metadata state @@ -119,7 +123,6 @@ const ImportModal: React.FC = ({ show, onHide, modelClass }) = const [duplicateActions, setDuplicateActions] = useState([]); /* CSV parsing & selection state */ - const [csvFirstLine, setCsvFirstLine] = useState([]); const [selectedFields, setSelectedFields] = useState([]); const [availableFields, setAvailableFields] = useState([]); const [csvData, setCsvData] = useState([]); // All CSV rows for preview @@ -142,11 +145,43 @@ const ImportModal: React.FC = ({ show, onHide, modelClass }) = const fetchConfig = useCallback(async () => { try { - await fetchImports({ url: `/import/${modelClass}` }); + const params = new URLSearchParams(); + Object.entries(contextParams || {}).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== "") { + params.append(key, String(value)); + } + }); + + const url = params.toString() ? `/import/${modelClass}?${params.toString()}` : `/import/${modelClass}`; + await fetchImports({ url }); } catch (err) { console.error("Error fetching import config:", err); } - }, [fetchImports, modelClass]); + }, [contextParams, fetchImports, modelClass]); + + const sampleCsvHeaders = useMemo( + () => [...mandatoryFields], + [mandatoryFields] + ); + + const downloadSampleCsv = useCallback(() => { + if (sampleCsvHeaders.length === 0) { + setStatus("No import headers are available yet."); + return; + } + + const csvContents = `${sampleCsvHeaders.join(",")}\n`; + const blob = new Blob([csvContents], { type: "text/csv;charset=utf-8;" }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = url; + link.setAttribute("download", `${modelClass.toLowerCase()}_import_sample.csv`); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + }, [modelClass, sampleCsvHeaders]); useEffect(() => { if (show) { @@ -218,13 +253,6 @@ const ImportModal: React.FC = ({ show, onHide, modelClass }) = const parsedData = dataRows.map(line => line.split(",")); setCsvData(parsedData); - if (lines.length > 1) { - setCsvFirstLine(lines[1].split(",")); - } else { - setCsvFirstLine(headers); - } - console.log("Headers:", csvHeaders); - console.log("Data rows:", csvData); } }; @@ -236,10 +264,6 @@ const ImportModal: React.FC = ({ show, onHide, modelClass }) = setSelectedFields(copy); }; - /* Ensure all selected columns match mandatory fields */ - const mandatoryFieldsIncluded = () => - mandatoryFields.every((f) => selectedFields.includes(f)); - /* --------------------------------------------------------- * Submit import to backend * --------------------------------------------------------- */ @@ -275,11 +299,14 @@ const ImportModal: React.FC = ({ show, onHide, modelClass }) = if (!useHeader) { formData.append("ordered_fields", JSON.stringify(selectedFields)); } - - let url = `/import/${modelClass}`; + Object.entries(contextParams || {}).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== "") { + formData.append(key, String(value)); + } + }); await sendImport({ - url, + url: `/import/${modelClass}`, method: HttpMethod.POST, data: formData, headers: { "Content-Type": "multipart/form-data" }, @@ -291,18 +318,32 @@ const ImportModal: React.FC = ({ show, onHide, modelClass }) = }; useEffect(() => { - if(sendImportResponse) { + if (sendImportResponse) { setStatus(sendImportResponse.data.message); - if (!importError){ - setTimeout(forceClose, 1500); + if (!importError && !closeTimerRef.current) { + closeTimerRef.current = setTimeout(() => { + closeTimerRef.current = null; + forceClose(); + }, 1500); } } else if (importError) { setStatus(importError); } - }, [sendImportResponse, importError]); + }, [forceClose, importError, sendImportResponse]); - const previewHeaders = useMemo(() => (useHeader ? csvHeaders : selectedFields), [useHeader, csvHeaders, selectedFields]); + useEffect(() => { + return () => { + if (closeTimerRef.current) { + clearTimeout(closeTimerRef.current); + } + }; + }, []); + + const previewHeaders = useMemo( + () => (useHeader ? csvHeaders : selectedFields), + [csvHeaders, selectedFields, useHeader] + ); const previewData = useMemo(() => { return csvData.map((row) => { @@ -315,6 +356,22 @@ const ImportModal: React.FC = ({ show, onHide, modelClass }) = }); }, [csvData, previewHeaders]); + const teamRowsWithoutParticipants = useMemo(() => { + if (modelClass !== "Team") return 0; + + return previewData.filter((row) => { + const teamName = String(row.name ?? "").trim(); + if (!teamName) return false; + + const participantValues = Object.entries(row) + .filter(([key]) => key.startsWith("participant_")) + .map(([, value]) => String(value ?? "").trim()) + .filter(Boolean); + + return participantValues.length === 0; + }).length; + }, [modelClass, previewData]); + const getAvailableOptions = useCallback((colIndex: number) => { const selected = new Set(selectedFields.filter((_, idx) => idx !== colIndex)); return availableFields.filter(field => !selected.has(field)); @@ -480,6 +537,18 @@ const ImportModal: React.FC = ({ show, onHide, modelClass }) = + + + + + + {/* --------------------------------------------------------- @@ -574,6 +643,11 @@ const ImportModal: React.FC = ({ show, onHide, modelClass }) =
Preview of data to be imported:
+ {teamRowsWithoutParticipants > 0 && ( +
+ Note: {teamRowsWithoutParticipants} team{teamRowsWithoutParticipants === 1 ? "" : "s"} will be imported without participants. +
+ )}
{ }); }, []); - const reset = (error: boolean, data: boolean) => { + const reset = useCallback((error: boolean, data: boolean) => { if (error) { setError(null); } if (data) { setData(undefined); } - }; + }, []); // console.log(errorStatus) return { data, setData, isLoading, error, sendRequest, reset, errorStatus }; }; -export default useAPI; \ No newline at end of file +export default useAPI; diff --git a/src/pages/Assignments/AssignmentEditor.tsx b/src/pages/Assignments/AssignmentEditor.tsx index de8f2e52..57e2bcaf 100644 --- a/src/pages/Assignments/AssignmentEditor.tsx +++ b/src/pages/Assignments/AssignmentEditor.tsx @@ -52,6 +52,16 @@ interface TopicData { updatedAt?: string; } +const buildTopicSettings = (assignment?: any): TopicSettings => ({ + allowTopicSuggestions: false, + enableBidding: false, + enableAuthorsReview: true, + allowReviewerChoice: true, + allowBookmarks: Boolean(assignment?.allow_bookmarks), + allowBiddingForReviewers: false, + allowAdvertiseForPartners: Boolean(assignment?.advertising_for_partners_allowed), +}); + const initialValues: IAssignmentFormValues = { name: "", directory_path: "", @@ -153,18 +163,21 @@ const AssignmentEditor: React.FC = ({ mode }) => { const navigate = useNavigate(); const location = useLocation(); const { id } = useParams<{ id: string }>(); - const [assignmentName, setAssignmentName] = useState(""); - + const [assignmentName, setAssignmentName] = useState(assignmentData?.name || ""); + const [topicSettings, setTopicSettings] = useState(() => buildTopicSettings(assignmentData)); + const [topicsData, setTopicsData] = useState([]); + const [topicsLoading, setTopicsLoading] = useState(false); + const [topicsError, setTopicsError] = useState(null); - useEffect(() => { - if (assignmentResponse?.data) { - setAssignmentName(assignmentResponse.data.name || ""); - // Load allow_bookmarks setting from backend - if (assignmentResponse.data.allow_bookmarks !== undefined && assignmentResponse.data.advertising_for_partners_allowed !== undefined) { - setTopicSettings(prev => ({ ...prev, allowBookmarks: assignmentResponse.data.allow_bookmarks,allowAdvertiseForPartners: assignmentResponse.data.advertising_for_partners_allowed })); - } + useEffect(() => { + if (assignmentData) { + setAssignmentName(assignmentData.name || ""); + setTopicSettings((prev) => ({ + ...prev, + ...buildTopicSettings(assignmentData), + })); } - }, [assignmentResponse]); + }, [assignmentData]); useEffect(() => { if (assignmentError) { @@ -536,25 +549,6 @@ const AssignmentEditor: React.FC = ({ mode }) => { }); } - - // Topic settings state - const [topicSettings, setTopicSettings] = useState({ - allowTopicSuggestions: false, - enableBidding: false, - enableAuthorsReview: true, - allowReviewerChoice: true, - allowBookmarks: false, - allowBiddingForReviewers: false, - allowAdvertiseForPartners: false, - }); - - // Topics data state - const [topicsData, setTopicsData] = useState([]); - const [topicsLoading, setTopicsLoading] = useState(false); - const [topicsError, setTopicsError] = useState(null); - - - return (
{ diff --git a/src/pages/Assignments/CreateTeams.tsx b/src/pages/Assignments/CreateTeams.tsx index eee84265..57f8f546 100644 --- a/src/pages/Assignments/CreateTeams.tsx +++ b/src/pages/Assignments/CreateTeams.tsx @@ -940,7 +940,7 @@ // src/pages/Assignments/CreateTeams.tsx -import React, { useMemo, useState, useCallback, memo } from 'react'; +import React, { useMemo, useState, useCallback, useEffect, memo } from 'react'; import { Button, Container, @@ -957,6 +957,7 @@ import { useLoaderData, useNavigate } from 'react-router-dom'; import ImportModal from "../../components/Modals/ImportModal"; import ExportModal from "../../components/Modals/ExportModal"; +import axiosClient from "../../utils/axios_client"; /* ============================================================================= Types @@ -1311,14 +1312,15 @@ const CreateTeams: React.FC<{ contextType?: ContextType; contextName?: string }> // Loader / routing const loader = (useLoaderData?.() as LoaderPayload) || {}; const navigate = useNavigate(); + const assignmentId = String((loader as any)?.id || ''); // Context const ctxType = (contextType || loader.contextType || 'assignment') as ContextType; const ctxName = contextName || loader.contextName || 'Program'; // Initial data - const baseTeams = loader.initialTeams || sampleTeams; - const baseUnassigned = loader.initialUnassigned || sampleUnassigned; + const baseTeams = loader.initialTeams || []; + const baseUnassigned = loader.initialUnassigned || []; // Compute initial unassigned list excluding already-assigned members const initialUnassigned = useMemo(() => { @@ -1353,6 +1355,61 @@ const CreateTeams: React.FC<{ contextType?: ContextType; contextName?: string }> const [copyTarget, setCopyTarget] = useState(''); const [copySource, setCopySource] = useState(''); + const mapUserToParticipant = useCallback((user: any, teamName = ''): Participant => ({ + id: user.id, + username: user.name || user.username || user.full_name || user.fullName || `User ${user.id}`, + fullName: user.full_name || user.fullName || user.name || user.username, + teamName, + }), []); + + const refreshAssignmentTeams = useCallback(async () => { + if (ctxType !== 'assignment' || !assignmentId) return; + + try { + const [teamsResponse, participantsResponse] = await Promise.all([ + axiosClient.get('/teams'), + axiosClient.get(`/participants/assignment/${assignmentId}`), + ]); + + const fetchedTeams: Team[] = (Array.isArray(teamsResponse.data) ? teamsResponse.data : []) + .filter((team: any) => + String(team.assignment_id ?? team.parent_id) === assignmentId && + ['AssignmentTeam', 'MentoredTeam'].includes(team.type), + ) + .map((team: any) => ({ + id: team.id, + name: team.name, + members: (team.users || []).map((user: any) => mapUserToParticipant(user, team.name)), + })); + + const assignedIds = new Set( + fetchedTeams.flatMap((team) => team.members.map((member) => String(member.id))), + ); + + const fetchedParticipants: Participant[] = (Array.isArray(participantsResponse.data) + ? participantsResponse.data + : [] + ) + .map((participant: any) => ({ + id: participant.user?.id ?? participant.id, + username: participant.user?.name || participant.user?.username || participant.user?.fullName || participant.user?.full_name || `User ${participant.user?.id ?? participant.id}`, + fullName: participant.user?.fullName || participant.user?.full_name || participant.user?.name, + teamName: '', + })) + .filter((participant: Participant) => !assignedIds.has(String(participant.id))); + + setTeams(fetchedTeams); + setUnassigned(fetchedParticipants); + setExpanded(Object.fromEntries(fetchedTeams.map((team) => [team.id, true]))); + } catch (error) { + console.error('Error loading assignment teams:', error); + } + }, [assignmentId, ctxType, mapUserToParticipant]); + + useEffect(() => { + refreshAssignmentTeams(); + }, [refreshAssignmentTeams]); + /* ------------------------------------------------------------------------- Derived helpers ------------------------------------------------------------------------- */ @@ -1396,38 +1453,36 @@ const CreateTeams: React.FC<{ contextType?: ContextType; contextName?: string }> setShowAddModal(true); }, []); - const confirmAddMember = useCallback(() => { + const confirmAddMember = useCallback(async () => { if (!selectedTeam || !selectedParticipantId) return; - const member = unassigned.find((u) => String(u.id) === selectedParticipantId); - if (!member) return; - - setUnassigned((prev) => prev.filter((u) => String(u.id) !== selectedParticipantId)); - setTeams((prev) => - prev.map((t) => - t.id === selectedTeam.id - ? { ...t, members: [...t.members, { ...member, teamName: t.name }] } - : t, - ), - ); - setShowAddModal(false); - }, [selectedParticipantId, selectedTeam, unassigned]); + try { + await axiosClient.post(`/teams/${selectedTeam.id}/members`, { + team_participant: { user_id: selectedParticipantId }, + }); + setShowAddModal(false); + setSelectedParticipantId(''); + refreshAssignmentTeams(); + } catch (error) { + console.error('Error adding member to team:', error); + } + }, [refreshAssignmentTeams, selectedParticipantId, selectedTeam]); const removeMemberFromTeam = useCallback( - (teamId: Team['id'], memberId: Participant['id']) => { + async (teamId: Team['id'], memberId: Participant['id']) => { const team = teams.find((t) => t.id === teamId); if (!team) return; const member = team.members.find((m) => m.id === memberId); - setTeams((prev) => - prev.map((t) => - t.id === teamId ? { ...t, members: t.members.filter((m) => m.id !== memberId) } : t, - ), - ); - if (member) { - setUnassigned((prev) => [...prev, { ...member, teamName: '' }]); + if (!member) return; + + try { + await axiosClient.delete(`/teams/${teamId}/members/${member.id}`); + refreshAssignmentTeams(); + } catch (error) { + console.error('Error removing member from team:', error); } }, - [teams], + [refreshAssignmentTeams, teams], ); const removeMentor = useCallback((teamId: Team['id']) => { @@ -1446,50 +1501,60 @@ const CreateTeams: React.FC<{ contextType?: ContextType; contextName?: string }> setShowEditModal(true); }, []); - const confirmEditTeamName = useCallback(() => { + const confirmEditTeamName = useCallback(async () => { if (!selectedTeam || !editTeamName.trim()) return; - const newName = editTeamName.trim(); - setTeams((prev) => - prev.map((t) => - t.id !== selectedTeam.id - ? t - : { - ...t, - name: newName, - members: t.members.map((m) => ({ ...m, teamName: newName })), - }, - ), - ); - setShowEditModal(false); - }, [editTeamName, selectedTeam]); + try { + await axiosClient.patch(`/teams/${selectedTeam.id}`, { + team: { name: editTeamName.trim() }, + }); + setShowEditModal(false); + refreshAssignmentTeams(); + } catch (error) { + console.error('Error updating team:', error); + } + }, [editTeamName, refreshAssignmentTeams, selectedTeam]); const deleteTeam = useCallback( - (teamId: Team['id']) => { - const team = teams.find((t) => t.id === teamId); - setTeams((prev) => prev.filter((t) => t.id !== teamId)); - if (team) { - setUnassigned((prev) => [...prev, ...team.members.map((m) => ({ ...m, teamName: '' }))]); + async (teamId: Team['id']) => { + try { + await axiosClient.delete(`/teams/${teamId}`); + refreshAssignmentTeams(); + } catch (error) { + console.error('Error deleting team:', error); } }, - [teams], + [refreshAssignmentTeams], ); - const createTeam = useCallback(() => { + const createTeam = useCallback(async () => { const name = newTeamName.trim(); if (!name || teams.some((t) => t.name === name)) return; - const id = `t-${Date.now()}`; - setTeams((prev) => [...prev, { id, name, members: [] }]); - setNewTeamName(''); - setShowCreateModal(false); - }, [newTeamName, teams]); - - const deleteAllTeams = useCallback(() => { + try { + await axiosClient.post('/teams', { + team: { + name, + type: 'AssignmentTeam', + parent_id: assignmentId, + }, + }); + setNewTeamName(''); + setShowCreateModal(false); + refreshAssignmentTeams(); + } catch (error) { + console.error('Error creating team:', error); + } + }, [assignmentId, newTeamName, refreshAssignmentTeams, teams]); + + const deleteAllTeams = useCallback(async () => { if (!window.confirm('Delete all teams? This returns all members to the unassigned list.')) return; - const everyone = teams.flatMap((t) => t.members); - setUnassigned((prev) => [...prev, ...everyone.map((m) => ({ ...m, teamName: '' }))]); - setTeams([]); - }, [teams]); + try { + await Promise.all(teams.map((team) => axiosClient.delete(`/teams/${team.id}`))); + refreshAssignmentTeams(); + } catch (error) { + console.error('Error deleting all teams:', error); + } + }, [refreshAssignmentTeams, teams]); const copyTeamsToCourse = useCallback(() => { alert(`Copying ${teams.length} team(s) to "${copyTarget || '(choose destination)'}"`); @@ -1680,13 +1745,18 @@ const CreateTeams: React.FC<{ contextType?: ContextType; contextName?: string }> {/* Import / Export modals (from separate files) */} setShowImportTeamsModal(false)} + onHide={() => { + setShowImportTeamsModal(false); + refreshAssignmentTeams(); + }} modelClass="Team" + contextParams={{ assignment_id: assignmentId }} /> setShowExportTeamsModal(false)} modelClass="Team" + contextParams={{ assignment_id: assignmentId }} /> {/* Other Modals */} @@ -1838,4 +1908,3 @@ const CreateTeams: React.FC<{ contextType?: ContextType; contextName?: string }> }; export default CreateTeams; - diff --git a/src/pages/Assignments/tabs/TopicsTab.tsx b/src/pages/Assignments/tabs/TopicsTab.tsx index 1a433ff8..e7e0f18a 100644 --- a/src/pages/Assignments/tabs/TopicsTab.tsx +++ b/src/pages/Assignments/tabs/TopicsTab.tsx @@ -1,823 +1,793 @@ -import React, { useState } from "react"; -import { Col, Row, Form, Button, Modal, FloatingLabel, Stack } from "react-bootstrap"; -// Reverting to the standard import path for react-icons/bs -import { BsPersonPlusFill, BsBookmark, BsBookmarkFill } from "react-icons/bs"; -import TopicsTable from "pages/Assignments/components/TopicsTable"; -import DeleteTopics from "../TopicDelete"; -import { OverlayTrigger, Tooltip } from "react-bootstrap"; - -// --- Interface Modifications --- -// Assuming these interfaces are defined elsewhere and imported -// They are redefined here for clarity based on requirements - -interface TeamMember { - id: string; // User ID - name: string; // User's full name -} - -interface AssignedTeam { - teamId: string; - members: TeamMember[]; -} - -interface WaitlistedTeam { - teamId: string; - members: TeamMember[]; -} - -interface PartnerAd { - text: string; - // link?: string; // Optional: Link to a separate page if not using modal -} - -interface BookmarkData { - id: string; - url: string; - title: string; -} - -// Updated TopicData interface -interface TopicData { - id: string; // topic_identifier for display/selection - databaseId: number; // Database ID for API calls - name: string; // Topic Name - url?: string; // Optional URL for the topic name - description?: string; // Optional short description - category?: string; // Optional category - assignedTeams: AssignedTeam[]; // Teams/Students assigned to this topic - waitlistedTeams: WaitlistedTeam[]; // Teams/Students waitlisted - questionnaire: string; // Associated questionnaire name - numSlots: number; // Total number of slots - availableSlots: number; // Number of available slots - // waitlist: number; // Redundant now, can derive from waitlistedTeams.length - bookmarks: BookmarkData[]; // Array of bookmarks for this topic - partnerAd?: PartnerAd; // Optional partner advertisement details - createdAt?: string; - updatedAt?: string; -} - -// Same as before -interface TopicSettings { - allowTopicSuggestions: boolean; - enableBidding: boolean; - enableAuthorsReview: boolean; - allowReviewerChoice: boolean; - allowBookmarks: boolean; - allowBiddingForReviewers: boolean; - allowAdvertiseForPartners: boolean; -} - -interface TopicsTabProps { - assignmentName?: string; - assignmentId: string; - topicSettings: TopicSettings; - topicsData: TopicData[]; // Ensure the data passed matches the updated TopicData interface - topicsLoading?: boolean; - topicsError?: string | null; - onTopicSettingChange: (setting: string, value: boolean) => void; - // Add handlers for actions like drop team, delete topic, edit topic, create bookmark etc. - onDropTeam: (topicId: string, teamId: string) => void; - onDeleteTopic: (topicId: string) => void; - onEditTopic: (topicId: string, updatedData?: any) => void; - onCreateTopic?: (topicData: any) => void; - // Handler for partner ad application submission - onApplyPartnerAd: (topicId: string, applicationText: string) => void; - onTopicsChanged?: () => void; -} - -// --- Component Implementation --- - -const TopicsTab = ({ - assignmentName = "Assignment", - assignmentId, - topicSettings, - topicsData, - topicsLoading = false, - topicsError = null, - onTopicSettingChange, - onDropTeam, - onDeleteTopic, - onEditTopic, - onCreateTopic, - onApplyPartnerAd, - onTopicsChanged, -}: TopicsTabProps) => { - const [showPartnerAdModal, setShowPartnerAdModal] = useState(false); - const [selectedPartnerAdTopic, setSelectedPartnerAdTopic] = useState(null); - const [partnerAdApplication, setPartnerAdApplication] = useState(""); - - // New topic modal state - const [showNewTopicModal, setShowNewTopicModal] = useState(false); - const [newTopicData, setNewTopicData] = useState({ - topic_name: '', - topic_identifier: '', - category: '', - max_choosers: 1, - description: '', - link: '' - }); - - // Selected topics state - const [selectedTopics, setSelectedTopics] = useState>(new Set()); - const [selectAll, setSelectAll] = useState(false); - - // Import topics modal state - const [showImportModal, setShowImportModal] = useState(false); - const [importData, setImportData] = useState(''); - - // Delete modal state (repo-standard) - const [deleteState, setDeleteState] = useState<{ visible: boolean; ids: string[]; names: string[] }>({ visible: false, ids: [], names: [] }); - - // Edit topic modal state - const [showEditModal, setShowEditModal] = useState(false); - const [editingTopic, setEditingTopic] = useState(null); - const [editTopicData, setEditTopicData] = useState({ - topic_name: '', - topic_identifier: '', - category: '', - max_choosers: 1, - description: '', - link: '' - }); - - // --- Partner Ad Modal Handlers --- - const handleShowPartnerAd = (topic: TopicData) => { - setSelectedPartnerAdTopic(topic); - setPartnerAdApplication(""); // Reset text area - setShowPartnerAdModal(true); - }; - - const handleClosePartnerAd = () => { - setShowPartnerAdModal(false); - setSelectedPartnerAdTopic(null); - }; - - const handleSubmitPartnerAd = () => { - if (selectedPartnerAdTopic) { - onApplyPartnerAd(selectedPartnerAdTopic.id, partnerAdApplication); - // Optional: Show success message or handle response - } - handleClosePartnerAd(); - }; - - // --- New Topic Modal Handlers --- - const handleShowNewTopic = () => { - setNewTopicData({ - topic_name: '', - topic_identifier: '', - category: '', - max_choosers: 1, - description: '', - link: '' - }); - setShowNewTopicModal(true); - }; - - const handleCloseNewTopic = () => { - setShowNewTopicModal(false); - }; - - const handleSubmitNewTopic = () => { - if (onCreateTopic) { - onCreateTopic(newTopicData); - handleCloseNewTopic(); - } - }; - - const handleInputChange = (field: string, value: string | number) => { - setNewTopicData(prev => ({ - ...prev, - [field]: value - })); - }; - - // --- Edit Topic Modal Handlers --- - const handleShowEditTopic = (topic: TopicData) => { - console.log('Edit button clicked for topic:', topic); - setEditingTopic(topic); - setEditTopicData({ - topic_name: topic.name || '', - topic_identifier: topic.id || '', - category: topic.category || '', - max_choosers: topic.numSlots || 1, - description: topic.description || '', - link: topic.url || '' - }); - setShowEditModal(true); - console.log('Edit modal should be opening now'); - }; - - const handleCloseEditTopic = () => { - setShowEditModal(false); - setEditingTopic(null); - }; - - const handleSubmitEditTopic = () => { - console.log('Submitting edit for topic:', editingTopic); - console.log('Edit data:', editTopicData); - if (editingTopic && onEditTopic) { - console.log('Calling onEditTopic with DB id:', editingTopic.databaseId, editTopicData); - onEditTopic(String(editingTopic.databaseId), editTopicData); - handleCloseEditTopic(); - } else { - console.log('Missing editingTopic or onEditTopic:', { editingTopic, onEditTopic }); - } - }; - - const handleEditInputChange = (field: string, value: string | number) => { - setEditTopicData(prev => ({ - ...prev, - [field]: value - })); - }; - - // --- Selection Handlers --- - const handleSelectAll = () => { - if (selectAll) { - setSelectedTopics(new Set()); - setSelectAll(false); - } else { - const allTopicIds = new Set(topicsData.map(topic => topic.id)); - setSelectedTopics(allTopicIds); - setSelectAll(true); - } - }; - - const handleSelectTopic = (topicId: string) => { - const newSelected = new Set(selectedTopics); - if (newSelected.has(topicId)) { - newSelected.delete(topicId); - } else { - newSelected.add(topicId); - } - setSelectedTopics(newSelected); - setSelectAll(newSelected.size === topicsData.length); - }; - - // --- Import Topics Handlers --- - const handleShowImport = () => { - setImportData(''); - setShowImportModal(true); - }; - - const handleCloseImport = () => { - setShowImportModal(false); - }; - - const handleImportTopics = () => { - try { - // Parse CSV or JSON data - const lines = importData.trim().split('\n'); - const topics = lines.map((line, index) => { - const [topic_name, topic_identifier, category, max_choosers, description, link] = line.split(','); - return { - topic_name: topic_name?.trim() || `Imported Topic ${index + 1}`, - topic_identifier: topic_identifier?.trim() || `IMP${index + 1}`, - category: category?.trim() || '', - max_choosers: parseInt(max_choosers?.trim()) || 1, - description: description?.trim() || '', - link: link?.trim() || '' - }; - }); - - // Create each topic - topics.forEach(topic => { - if (onCreateTopic) { - onCreateTopic(topic); - } - }); - - handleCloseImport(); - } catch (error) { - console.error('Error importing topics:', error); - } - }; - - // --- Delete Handlers --- - const handleDeleteSelected = () => { - if (selectedTopics.size === 0) return; - const ids = Array.from(selectedTopics); - const names = ids.map(id => topicsData.find(t => t.id === id)?.name || id); - setDeleteState({ visible: true, ids, names }); - }; - - // --- Back Handler --- - const handleBack = () => { - // Navigate back to assignments list - window.history.back(); - }; - - // Check if questionnaire varies across topics - const questionnaireVaries = topicsData.length > 0 && - topicsData.some(t => t.questionnaire !== topicsData[0].questionnaire); - - // --- Render Helper Functions --- - // removed: renderTeamMembers (moved to TopicsTable renderDetails inline rendering) - - return ( - - -

Topics for {assignmentName} assignment

- - {/* Topic Settings */} -
- onTopicSettingChange('allowTopicSuggestions', e.target.checked)} - /> - - onTopicSettingChange('enableBidding', e.target.checked)} - /> - - onTopicSettingChange('enableAuthorsReview', e.target.checked)} - /> - - onTopicSettingChange('allowReviewerChoice', e.target.checked)} - /> - - onTopicSettingChange('allowBookmarks', e.target.checked)} - /> - - onTopicSettingChange('allowAdvertiseForPartners', e.target.checked)} - /> - - onTopicSettingChange('allowBiddingForReviewers', e.target.checked)} - /> - - - {/* Error Message */} - {topicsError && ( -
- Error loading topics: { - typeof topicsError === 'string' - ? topicsError - : JSON.stringify(topicsError) - } -
- )} - - ({ - id: t.id, - databaseId: t.databaseId, - name: t.name, - url: t.url, - description: t.description, - availableSlots: t.availableSlots, - waitlistCount: t.waitlistedTeams?.length || 0, - assignedTeams: t.assignedTeams, - waitlistedTeams: t.waitlistedTeams, - }))} - mode="instructor" - selectable - selectAll={selectAll} - isRowSelected={(id) => selectedTopics.has(id)} - onToggleAll={handleSelectAll} - onToggleRow={handleSelectTopic} - extraColumns={[ - ...(questionnaireVaries //displays the questionnaire column only if it varies across the topics - ? [ - { - id: "questionnaire", - header: "Questionnaire", - cell: ({ row }: any) => {(topicsData.find(t => t.id === row.original.id)?.questionnaire) || "--Default rubric--"}, - }, - ] - : []), - { - id: "numSlots", - header: "Num. of Slots", - cell: ({ row }: any) => {topicsData.find(t => t.id === row.original.id)?.numSlots ?? 0}, - }, - { - id: "availableSlots", - header: "Available Slots", - cell: ({ row }: any) => {row.original.availableSlots ?? 0}, - }, - { - id: "waitlisted", - header: "Waitlisted", - cell: ({ row }: any) => {row.original.waitlistedTeams?.length ?? 0}, - }, - ]} - renderDetails={(row) => ( -
- {row.assignedTeams && row.assignedTeams.length > 0 && ( -
- {row.assignedTeams.map((team) => { - const topicDbId = row.databaseId?.toString() ?? row.id; - return ( -
- - {team.members.map(m => m.name || m.id).join(", ")} - - -
- ); - })} -
- )} - {row.waitlistedTeams && row.waitlistedTeams.length > 0 && ( -
- {row.waitlistedTeams.map((team) => ( -
- - {team.members.map(m => m.name || m.id).join(", ")} (waitlisted) - -
- ))} -
- )} -
- )} - renderInstructorActions={(topic) => ( - - Edit Topic}> - - - - Delete Topic}> - - - - {topicSettings.allowAdvertiseForPartners && ( - Apply to partner ad}> - - - )} - - )} - /> - - {/* Action Buttons */} -
- - - - -
- - - {/* Partner Advertisement Modal */} - - - Partner Advertisement: {selectedPartnerAdTopic?.name} - - -

{selectedPartnerAdTopic?.partnerAd?.text}

-
- - setPartnerAdApplication(e.target.value)} - /> - -
- - - - -
- - {/* New Topic Modal */} - - - Create New Topic - - -
- - - - handleInputChange('topic_name', e.target.value)} - required - /> - - - - - handleInputChange('topic_identifier', e.target.value)} - required - /> - - - - - - - handleInputChange('category', e.target.value)} - /> - - - - - handleInputChange('max_choosers', parseInt(e.target.value) || 1)} - required - /> - - - - - - - handleInputChange('description', e.target.value)} - /> - - - - - - - handleInputChange('link', e.target.value)} - /> - - - -
-
- - - - -
- - {/* Import Topics Modal */} - - - Import Topics - - -
-

Import topics from CSV format. Each line should contain:

-

Topic Name, Topic Identifier, Category, Max Choosers, Description, Link

-

Example: "Database Design, DB001, Technical, 2, Design database schema, https://example.com"

-
- - setImportData(e.target.value)} - /> - -
- - - - -
- - {deleteState.visible && ( - setDeleteState({ visible: false, ids: [], names: [] })} - onDeleted={onTopicsChanged} - /> - )} - - {/* Edit Topic Modal */} - - - Edit Topic - - -
- - - - handleEditInputChange('topic_name', e.target.value)} - required - /> - - - - - handleEditInputChange('topic_identifier', e.target.value)} - required - /> - - - - - - - handleEditInputChange('category', e.target.value)} - /> - - - - - handleEditInputChange('max_choosers', parseInt(e.target.value) || 1)} - required - /> - - - - - - - handleEditInputChange('description', e.target.value)} - /> - - - - - - - handleEditInputChange('link', e.target.value)} - /> - - - -
-
- - - - -
-
- ); -}; - -export default TopicsTab; +import React, { useState } from "react"; +import { Col, Row, Form, Button, Modal, FloatingLabel, Stack } from "react-bootstrap"; +// Reverting to the standard import path for react-icons/bs +import { BsPersonPlusFill, BsBookmark, BsBookmarkFill } from "react-icons/bs"; +import TopicsTable from "pages/Assignments/components/TopicsTable"; +import DeleteTopics from "../TopicDelete"; +import { OverlayTrigger, Tooltip } from "react-bootstrap"; +import ImportModal from "../../../components/Modals/ImportModal"; +import ExportModal from "../../../components/Modals/ExportModal"; + +// --- Interface Modifications --- +// Assuming these interfaces are defined elsewhere and imported +// They are redefined here for clarity based on requirements + +interface TeamMember { + id: string; // User ID + name: string; // User's full name +} + +interface AssignedTeam { + teamId: string; + members: TeamMember[]; +} + +interface WaitlistedTeam { + teamId: string; + members: TeamMember[]; +} + +interface PartnerAd { + text: string; + // link?: string; // Optional: Link to a separate page if not using modal +} + +interface BookmarkData { + id: string; + url: string; + title: string; +} + +// Updated TopicData interface +interface TopicData { + id: string; // topic_identifier for display/selection + databaseId: number; // Database ID for API calls + name: string; // Topic Name + url?: string; // Optional URL for the topic name + description?: string; // Optional short description + category?: string; // Optional category + assignedTeams: AssignedTeam[]; // Teams/Students assigned to this topic + waitlistedTeams: WaitlistedTeam[]; // Teams/Students waitlisted + questionnaire: string; // Associated questionnaire name + numSlots: number; // Total number of slots + availableSlots: number; // Number of available slots + // waitlist: number; // Redundant now, can derive from waitlistedTeams.length + bookmarks: BookmarkData[]; // Array of bookmarks for this topic + partnerAd?: PartnerAd; // Optional partner advertisement details + createdAt?: string; + updatedAt?: string; +} + +// Same as before +interface TopicSettings { + allowTopicSuggestions: boolean; + enableBidding: boolean; + enableAuthorsReview: boolean; + allowReviewerChoice: boolean; + allowBookmarks: boolean; + allowBiddingForReviewers: boolean; + allowAdvertiseForPartners: boolean; +} + +interface TopicsTabProps { + assignmentName?: string; + assignmentId: string; + topicSettings: TopicSettings; + topicsData: TopicData[]; // Ensure the data passed matches the updated TopicData interface + topicsLoading?: boolean; + topicsError?: string | null; + onTopicSettingChange: (setting: string, value: boolean) => void; + // Add handlers for actions like drop team, delete topic, edit topic, create bookmark etc. + onDropTeam: (topicId: string, teamId: string) => void; + onDeleteTopic: (topicId: string) => void; + onEditTopic: (topicId: string, updatedData?: any) => void; + onCreateTopic?: (topicData: any) => void; + // Handler for partner ad application submission + onApplyPartnerAd: (topicId: string, applicationText: string) => void; + onTopicsChanged?: () => void; +} + +// --- Component Implementation --- + +const STANDARD_TEXT: React.CSSProperties = { + fontFamily: 'verdana, arial, helvetica, sans-serif', + color: '#333', + fontSize: '13px', + lineHeight: '30px', +}; + +const toolbarLinkBase: React.CSSProperties = { + ...STANDARD_TEXT, + color: '#8b5e3c', + background: 'transparent', + border: 'none', + padding: 0, + margin: 0, + cursor: 'pointer', + textDecoration: 'none', +}; +const pipe: React.CSSProperties = { margin: '0 8px', color: '#8b5e3c' }; + +const ToolbarLink: React.FC<{ + onClick: () => void; + children: React.ReactNode; +}> = ({ onClick, children }) => ( + +); + +const TopicsTab = ({ + assignmentName = "Assignment", + assignmentId, + topicSettings, + topicsData, + topicsLoading = false, + topicsError = null, + onTopicSettingChange, + onDropTeam, + onDeleteTopic, + onEditTopic, + onCreateTopic, + onApplyPartnerAd, + onTopicsChanged, +}: TopicsTabProps) => { + const [showPartnerAdModal, setShowPartnerAdModal] = useState(false); + const [selectedPartnerAdTopic, setSelectedPartnerAdTopic] = useState(null); + const [partnerAdApplication, setPartnerAdApplication] = useState(""); + + // New topic modal state + const [showNewTopicModal, setShowNewTopicModal] = useState(false); + const [newTopicData, setNewTopicData] = useState({ + topic_name: '', + topic_identifier: '', + category: '', + max_choosers: 1, + description: '', + link: '' + }); + + // Selected topics state + const [selectedTopics, setSelectedTopics] = useState>(new Set()); + const [selectAll, setSelectAll] = useState(false); + + // Import / export topics modal state + const [showImportModal, setShowImportModal] = useState(false); + const [showExportModal, setShowExportModal] = useState(false); + + // Delete modal state (repo-standard) + const [deleteState, setDeleteState] = useState<{ visible: boolean; ids: string[]; names: string[] }>({ visible: false, ids: [], names: [] }); + + // Edit topic modal state + const [showEditModal, setShowEditModal] = useState(false); + const [editingTopic, setEditingTopic] = useState(null); + const [editTopicData, setEditTopicData] = useState({ + topic_name: '', + topic_identifier: '', + category: '', + max_choosers: 1, + description: '', + link: '' + }); + + // --- Partner Ad Modal Handlers --- + const handleShowPartnerAd = (topic: TopicData) => { + setSelectedPartnerAdTopic(topic); + setPartnerAdApplication(""); // Reset text area + setShowPartnerAdModal(true); + }; + + const handleClosePartnerAd = () => { + setShowPartnerAdModal(false); + setSelectedPartnerAdTopic(null); + }; + + const handleSubmitPartnerAd = () => { + if (selectedPartnerAdTopic) { + onApplyPartnerAd(selectedPartnerAdTopic.id, partnerAdApplication); + // Optional: Show success message or handle response + } + handleClosePartnerAd(); + }; + + // --- New Topic Modal Handlers --- + const handleShowNewTopic = () => { + setNewTopicData({ + topic_name: '', + topic_identifier: '', + category: '', + max_choosers: 1, + description: '', + link: '' + }); + setShowNewTopicModal(true); + }; + + const handleCloseNewTopic = () => { + setShowNewTopicModal(false); + }; + + const handleSubmitNewTopic = () => { + if (onCreateTopic) { + onCreateTopic(newTopicData); + handleCloseNewTopic(); + } + }; + + const handleInputChange = (field: string, value: string | number) => { + setNewTopicData(prev => ({ + ...prev, + [field]: value + })); + }; + + // --- Edit Topic Modal Handlers --- + const handleShowEditTopic = (topic: TopicData) => { + console.log('Edit button clicked for topic:', topic); + setEditingTopic(topic); + setEditTopicData({ + topic_name: topic.name || '', + topic_identifier: topic.id || '', + category: topic.category || '', + max_choosers: topic.numSlots || 1, + description: topic.description || '', + link: topic.url || '' + }); + setShowEditModal(true); + console.log('Edit modal should be opening now'); + }; + + const handleCloseEditTopic = () => { + setShowEditModal(false); + setEditingTopic(null); + }; + + const handleSubmitEditTopic = () => { + console.log('Submitting edit for topic:', editingTopic); + console.log('Edit data:', editTopicData); + if (editingTopic && onEditTopic) { + console.log('Calling onEditTopic with DB id:', editingTopic.databaseId, editTopicData); + onEditTopic(String(editingTopic.databaseId), editTopicData); + handleCloseEditTopic(); + } else { + console.log('Missing editingTopic or onEditTopic:', { editingTopic, onEditTopic }); + } + }; + + const handleEditInputChange = (field: string, value: string | number) => { + setEditTopicData(prev => ({ + ...prev, + [field]: value + })); + }; + + // --- Selection Handlers --- + const handleSelectAll = () => { + if (selectAll) { + setSelectedTopics(new Set()); + setSelectAll(false); + } else { + const allTopicIds = new Set(topicsData.map(topic => topic.id)); + setSelectedTopics(allTopicIds); + setSelectAll(true); + } + }; + + const handleSelectTopic = (topicId: string) => { + const newSelected = new Set(selectedTopics); + if (newSelected.has(topicId)) { + newSelected.delete(topicId); + } else { + newSelected.add(topicId); + } + setSelectedTopics(newSelected); + setSelectAll(newSelected.size === topicsData.length); + }; + + // --- Import Topics Handlers --- + const handleShowImport = () => { + setShowImportModal(true); + }; + + const handleCloseImport = () => { + onTopicsChanged?.(); + setShowImportModal(false); + }; + + const handleShowExport = () => { + setShowExportModal(true); + }; + + const handleCloseExport = () => { + setShowExportModal(false); + }; + + // --- Delete Handlers --- + const handleDeleteSelected = () => { + if (selectedTopics.size === 0) return; + const ids = Array.from(selectedTopics); + const names = ids.map(id => topicsData.find(t => t.id === id)?.name || id); + setDeleteState({ visible: true, ids, names }); + }; + + // --- Back Handler --- + const handleBack = () => { + // Navigate back to assignments list + window.history.back(); + }; + + // Check if questionnaire varies across topics + const questionnaireVaries = topicsData.length > 0 && + topicsData.some(t => t.questionnaire !== topicsData[0].questionnaire); + + // --- Render Helper Functions --- + // removed: renderTeamMembers (moved to TopicsTable renderDetails inline rendering) + + return ( + + +

Topics for {assignmentName} assignment

+ + {/* Topic Settings */} +
+ onTopicSettingChange('allowTopicSuggestions', e.target.checked)} + /> + + onTopicSettingChange('enableBidding', e.target.checked)} + /> + + onTopicSettingChange('enableAuthorsReview', e.target.checked)} + /> + + onTopicSettingChange('allowReviewerChoice', e.target.checked)} + /> + + onTopicSettingChange('allowBookmarks', e.target.checked)} + /> + + onTopicSettingChange('allowAdvertiseForPartners', e.target.checked)} + /> + + onTopicSettingChange('allowBiddingForReviewers', e.target.checked)} + /> + + + {/* Error Message */} + {topicsError && ( +
+ Error loading topics: { + typeof topicsError === 'string' + ? topicsError + : JSON.stringify(topicsError) + } +
+ )} + + ({ + id: t.id, + databaseId: t.databaseId, + name: t.name, + url: t.url, + description: t.description, + availableSlots: t.availableSlots, + waitlistCount: t.waitlistedTeams?.length || 0, + assignedTeams: t.assignedTeams, + waitlistedTeams: t.waitlistedTeams, + }))} + mode="instructor" + selectable + selectAll={selectAll} + isRowSelected={(id) => selectedTopics.has(id)} + onToggleAll={handleSelectAll} + onToggleRow={handleSelectTopic} + extraColumns={[ + ...(questionnaireVaries //displays the questionnaire column only if it varies across the topics + ? [ + { + id: "questionnaire", + header: "Questionnaire", + cell: ({ row }: any) => {(topicsData.find(t => t.id === row.original.id)?.questionnaire) || "--Default rubric--"}, + }, + ] + : []), + { + id: "numSlots", + header: "Num. of Slots", + cell: ({ row }: any) => {topicsData.find(t => t.id === row.original.id)?.numSlots ?? 0}, + }, + { + id: "availableSlots", + header: "Available Slots", + cell: ({ row }: any) => {row.original.availableSlots ?? 0}, + }, + { + id: "waitlisted", + header: "Waitlisted", + cell: ({ row }: any) => {row.original.waitlistedTeams?.length ?? 0}, + }, + ]} + renderDetails={(row) => ( +
+ {row.assignedTeams && row.assignedTeams.length > 0 && ( +
+ {row.assignedTeams.map((team) => { + const topicDbId = row.databaseId?.toString() ?? row.id; + return ( +
+ + {team.members.map(m => m.name || m.id).join(", ")} + + +
+ ); + })} +
+ )} + {row.waitlistedTeams && row.waitlistedTeams.length > 0 && ( +
+ {row.waitlistedTeams.map((team) => ( +
+ + {team.members.map(m => m.name || m.id).join(", ")} (waitlisted) + +
+ ))} +
+ )} +
+ )} + renderInstructorActions={(topic) => ( + + Edit Topic}> + + + + Delete Topic}> + + + + {topicSettings.allowAdvertiseForPartners && ( + Apply to partner ad}> + + + )} + + )} + /> + + + + New topic + | + Import topics + | + Export topics + | + Delete selected topics ({selectedTopics.size}) + | + Back + + + + + {/* Partner Advertisement Modal */} + + + Partner Advertisement: {selectedPartnerAdTopic?.name} + + +

{selectedPartnerAdTopic?.partnerAd?.text}

+
+ + setPartnerAdApplication(e.target.value)} + /> + +
+ + + + +
+ + {/* New Topic Modal */} + + + Create New Topic + + +
+ + + + handleInputChange('topic_name', e.target.value)} + required + /> + + + + + handleInputChange('topic_identifier', e.target.value)} + required + /> + + + + + + + handleInputChange('category', e.target.value)} + /> + + + + + handleInputChange('max_choosers', parseInt(e.target.value) || 1)} + required + /> + + + + + + + handleInputChange('description', e.target.value)} + /> + + + + + + + handleInputChange('link', e.target.value)} + /> + + + +
+
+ + + + +
+ + + + + {deleteState.visible && ( + setDeleteState({ visible: false, ids: [], names: [] })} + onDeleted={onTopicsChanged} + /> + )} + + {/* Edit Topic Modal */} + + + Edit Topic + + +
+ + + + handleEditInputChange('topic_name', e.target.value)} + required + /> + + + + + handleEditInputChange('topic_identifier', e.target.value)} + required + /> + + + + + + + handleEditInputChange('category', e.target.value)} + /> + + + + + handleEditInputChange('max_choosers', parseInt(e.target.value) || 1)} + required + /> + + + + + + + handleEditInputChange('description', e.target.value)} + /> + + + + + + + handleEditInputChange('link', e.target.value)} + /> + + + +
+
+ + + + +
+
+ ); +}; + +export default TopicsTab; diff --git a/src/pages/Participants/Participant.tsx b/src/pages/Participants/Participant.tsx index 144d9b75..3d727c59 100644 --- a/src/pages/Participants/Participant.tsx +++ b/src/pages/Participants/Participant.tsx @@ -5,7 +5,7 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Button, Col, Container, Row } from "react-bootstrap"; import { useDispatch, useSelector } from "react-redux"; -import { Outlet, useLocation, useNavigate } from "react-router-dom"; +import { Outlet, useLocation, useNavigate, useParams } from "react-router-dom"; import { alertActions } from "../../store/slices/alertSlice"; import { RootState } from "../../store/store"; import { IParticipantResponse, ROLE } from "../../utils/interfaces"; @@ -18,7 +18,7 @@ import { participantColumns as PARPTICIPANT_COLUMNS } from "./participantColumns interface IModel { type: "student_tasks" | "courses" | "assignments"; - id: Number; + id: number; } const Participants: React.FC = ({ type, id }) => { @@ -29,6 +29,7 @@ const Participants: React.FC = ({ type, id }) => { ); const navigate = useNavigate(); const location = useLocation(); + const { assignmentId } = useParams(); const dispatch = useDispatch(); const [showDeleteConfirmation, setShowDeleteConfirmation] = useState<{ @@ -36,9 +37,17 @@ const Participants: React.FC = ({ type, id }) => { data?: IParticipantResponse; }>({ visible: false }); + const participantsUrl = useMemo(() => { + if (type === "assignments" || type === "student_tasks") { + return `/participants/assignment/${assignmentId ?? id}`; + } + + return `/participants/${type}/${id}`; + }, [assignmentId, id, type]); + useEffect(() => { - if (!showDeleteConfirmation.visible) fetchParticipants({ url: `/participants/${type}/${id}` }); - }, [fetchParticipants, location, showDeleteConfirmation.visible, auth.user.id, type, id]); + if (!showDeleteConfirmation.visible) fetchParticipants({ url: participantsUrl }); + }, [fetchParticipants, location, showDeleteConfirmation.visible, auth.user.id, participantsUrl]); // Error alert useEffect(() => { @@ -53,8 +62,9 @@ const Participants: React.FC = ({ type, id }) => { ); const onEditHandle = useCallback( - (row: TRow) => navigate(`/${type}/participant/edit/${row.original.id}`), - [navigate, type] + (row: TRow) => + navigate(`edit/${row.original.id}`, { state: { from: location.pathname } }), + [location.pathname, navigate] ); const onDeleteHandle = useCallback( @@ -69,7 +79,28 @@ const Participants: React.FC = ({ type, id }) => { ); const tableData = useMemo( - () => (isLoading || !participantResponse?.data ? [] : participantResponse.data), + () => + isLoading || !participantResponse?.data + ? [] + : participantResponse.data.map((participant: any) => { + const user = participant.user || {}; + + return { + ...participant, + name: participant.name ?? user.name ?? user.username ?? "", + full_name: participant.full_name ?? user.full_name ?? user.fullName ?? "", + email: participant.email ?? user.email ?? "", + role: participant.role ?? user.role ?? { id: null, name: "" }, + parent: participant.parent ?? user.parent ?? { id: null, name: "" }, + institution: participant.institution ?? user.institution ?? { id: null, name: "" }, + email_on_review: participant.email_on_review ?? user.email_on_review ?? false, + email_on_submission: participant.email_on_submission ?? user.email_on_submission ?? false, + email_on_review_of_review: + participant.email_on_review_of_review ?? + user.email_on_review_of_review ?? + false, + }; + }), [participantResponse?.data, isLoading] ); @@ -86,7 +117,11 @@ const Participants: React.FC = ({ type, id }) => { - diff --git a/src/pages/Participants/ParticipantEditor.tsx b/src/pages/Participants/ParticipantEditor.tsx index 6e325364..f384a44c 100644 --- a/src/pages/Participants/ParticipantEditor.tsx +++ b/src/pages/Participants/ParticipantEditor.tsx @@ -6,7 +6,7 @@ import useAPI from "../../hooks/useAPI"; import React, { useEffect } from "react"; import { Button, Col, InputGroup, Modal, Row } from "react-bootstrap"; import { useDispatch, useSelector } from "react-redux"; -import { useLoaderData, useLocation, useNavigate } from "react-router-dom"; +import { useLoaderData, useLocation, useNavigate, useParams } from "react-router-dom"; import { alertActions } from "../../store/slices/alertSlice"; import { HttpMethod } from "../../utils/httpMethods"; import * as Yup from "yup"; @@ -59,6 +59,15 @@ const ParticipantEditor: React.FC = ({ mode, type }) => { const dispatch = useDispatch(); const navigate = useNavigate(); const location = useLocation(); + const { assignmentId } = useParams(); + + const participantsPath = + location.state?.from ?? + (type === "assignments" && assignmentId + ? `/assignments/edit/${assignmentId}/participants` + : type === "student_tasks" && assignmentId + ? `/student_tasks/edit/${assignmentId}/participants` + : `/${type}/participants`); // logged-in participant is the parent of the participant being created and the institution is the same as the parent's initialValues.parent_id = auth.user.id; @@ -77,7 +86,7 @@ const ParticipantEditor: React.FC = ({ mode, type }) => { message: `Participant ${participantData.name} ${mode}d successfully!`, }) ); - navigate(location.state?.from ? location.state.from : `/${type}/participants`); + navigate(participantsPath); } }, [ dispatch, @@ -85,8 +94,7 @@ const ParticipantEditor: React.FC = ({ mode, type }) => { navigate, participantData.name, participantResponse, - location.state?.from, - type, + participantsPath, ]); // Show the error message if the participant is not updated successfully @@ -118,8 +126,7 @@ const ParticipantEditor: React.FC = ({ mode, type }) => { submitProps.setSubmitting(false); }; - const handleClose = () => - navigate(location.state?.from ? location.state.from : `/${type}/participants`); + const handleClose = () => navigate(participantsPath); return ( diff --git a/src/pages/Questionnaires/Questionnaire.tsx b/src/pages/Questionnaires/Questionnaire.tsx index 982d8989..27109a35 100644 --- a/src/pages/Questionnaires/Questionnaire.tsx +++ b/src/pages/Questionnaires/Questionnaire.tsx @@ -11,6 +11,8 @@ import { useDispatch } from "react-redux"; import { alertActions } from "store/slices/alertSlice"; import useAPI from "hooks/useAPI"; import DeleteQuestionnaire from "./QuestionnaireDelete"; +import ExportModal from "../../components/Modals/ExportModal"; +import ImportModal from "../../components/Modals/ImportModal"; @@ -20,12 +22,20 @@ const Questionnaires = () => { const location = useLocation(); const dispatch = useDispatch(); const [showTypeModal, setShowTypeModal] = useState(false); + const [showExportModal, setShowExportModal] = useState(false); + const [showImportModal, setShowImportModal] = useState(false); + const [showItemImportModal, setShowItemImportModal] = useState(false); + const [showAdviceImportModal, setShowAdviceImportModal] = useState(false); // loader option const questionnaireData :any = useLoaderData(); useEffect(() => { setShowTypeModal(false); + setShowExportModal(false); + setShowImportModal(false); + setShowItemImportModal(false); + setShowAdviceImportModal(false); }, [location]); const [tableData, setTableData] = useState(questionnaireData); @@ -96,22 +106,55 @@ const Questionnaires = () => {
- - - - - + + + + + + + + + {showTypeModal && ( @@ -261,6 +304,26 @@ const Questionnaires = () => { )} + setShowExportModal(false)} + modelClass="Questionnaire" + /> + setShowImportModal(false)} + modelClass="Questionnaire" + /> + setShowItemImportModal(false)} + modelClass="Item" + /> + setShowAdviceImportModal(false)} + modelClass="QuestionAdvice" + /> ); diff --git a/src/pages/Users/User.tsx b/src/pages/Users/User.tsx index 6a7204b4..32eeeaa8 100644 --- a/src/pages/Users/User.tsx +++ b/src/pages/Users/User.tsx @@ -99,10 +99,10 @@ const Users = () => { [userResponse?.data, isLoading] ); - const handleHideImportModal = () => { - fetchUsers({ url: `/users/${auth.user.id}/managed` }); - setShowImportUserModal(false) - } + const handleHideImportModal = useCallback(() => { + fetchUsers({ url: `/users/${auth.user.id}/managed` }); + setShowImportUserModal(false); + }, [auth.user.id, fetchUsers]); return ( <> @@ -145,11 +145,11 @@ const Users = () => { {/* Import / Export modals (from separate files) */} - handleHideImportModal()} - modelClass="User" - /> + setShowExportUserModal(false)} diff --git a/src/pages/Users/UserEditor.tsx b/src/pages/Users/UserEditor.tsx index 04c8fd3f..8c830547 100644 --- a/src/pages/Users/UserEditor.tsx +++ b/src/pages/Users/UserEditor.tsx @@ -218,14 +218,14 @@ const UserEditor: React.FC = ({ mode }) => { } /> - diff --git a/src/pages/Users/userUtil.ts b/src/pages/Users/userUtil.ts index 15b740cb..544ba83c 100644 --- a/src/pages/Users/userUtil.ts +++ b/src/pages/Users/userUtil.ts @@ -74,16 +74,42 @@ export const transformUserRequest = (values: IUserFormValues) => { return JSON.stringify(user); }; +const parseFullName = (fullName: string) => { + const normalizedFullName = (fullName || "").trim(); + if (!normalizedFullName) { + return { firstName: "", lastName: "" }; + } + + if (normalizedFullName.includes(",")) { + const [lastName = "", firstName = ""] = normalizedFullName.split(",", 2); + return { + firstName: firstName.trim(), + lastName: lastName.trim(), + }; + } + + const parts = normalizedFullName.split(/\s+/); + if (parts.length === 1) { + return { firstName: parts[0], lastName: "" }; + } + + return { + firstName: parts.slice(0, -1).join(" "), + lastName: parts[parts.length - 1], + }; +}; + export const transformUserResponse = (userResponse: string) => { const user: IUserResponse = JSON.parse(userResponse); + const { firstName, lastName } = parseFullName(user.full_name); const parent_id = user.parent.id ? user.parent.id : null; const institution_id = user.institution.id ? user.institution.id : -1; const userValues: IUserFormValues = { id: user.id, name: user.name, email: user.email, - firstName: user.full_name.split(",")[1].trim(), - lastName: user.full_name.split(",")[0].trim(), + firstName, + lastName, role_id: user.role.id, parent_id: parent_id, institution_id: institution_id,