From ab8ad7d9cebbbcdd991b7be40d83960ebe14a2b7 Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Sat, 21 Mar 2026 16:58:37 -0500 Subject: [PATCH 01/17] porting over User import/export interface to extend to topics (SignUpTopics). Tried to keep GUI consistency with User import/export interface. --- src/pages/Assignments/tabs/TopicsTab.tsx | 1616 +++++++++++----------- 1 file changed, 793 insertions(+), 823 deletions(-) diff --git a/src/pages/Assignments/tabs/TopicsTab.tsx b/src/pages/Assignments/tabs/TopicsTab.tsx index 1a433ff8..22cb2264 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; From ca5fc321da6ae117102bab069d3be3b4444296d5 Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Sat, 21 Mar 2026 21:07:02 -0500 Subject: [PATCH 02/17] frontend fixes to see user roles and add them to teams manually. --- src/pages/Assignments/CreateTeams.tsx | 60 +++++++++++++++++++++++++-- src/pages/Users/UserEditor.tsx | 4 +- src/pages/Users/userUtil.ts | 30 +++++++++++++- 3 files changed, 87 insertions(+), 7 deletions(-) diff --git a/src/pages/Assignments/CreateTeams.tsx b/src/pages/Assignments/CreateTeams.tsx index eee84265..011aaa57 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,6 +1312,7 @@ 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; @@ -1353,6 +1355,56 @@ const CreateTeams: React.FC<{ contextType?: ContextType; contextName?: string }> const [copyTarget, setCopyTarget] = useState(''); const [copySource, setCopySource] = useState(''); + 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) === assignmentId) + .map((team: any) => ({ + id: team.id, + name: team.name, + members: (team.users || []).map((user: any) => ({ + id: user.id, + username: user.name || user.full_name || `User ${user.id}`, + fullName: user.full_name || user.name, + teamName: 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?.full_name || `User ${participant.user?.id ?? participant.id}`, + 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]); + + useEffect(() => { + refreshAssignmentTeams(); + }, [refreshAssignmentTeams]); + /* ------------------------------------------------------------------------- Derived helpers ------------------------------------------------------------------------- */ @@ -1680,7 +1732,10 @@ const CreateTeams: React.FC<{ contextType?: ContextType; contextName?: string }> {/* Import / Export modals (from separate files) */} setShowImportTeamsModal(false)} + onHide={() => { + setShowImportTeamsModal(false); + refreshAssignmentTeams(); + }} modelClass="Team" /> }; export default CreateTeams; - 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, From bec59d2f503d1ba5237646c485a11f7fa0856ec8 Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Sat, 28 Mar 2026 23:09:38 -0500 Subject: [PATCH 03/17] using ProjectTopic now instead of SignUpTopic --- package-lock.json | 2 +- package.json | 2 +- src/pages/Assignments/tabs/TopicsTab.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index d4ff031e..04a17a6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,7 +58,7 @@ "jsdom": "^26.1.0", "prettier": "^2.8.7", "typescript": "^5.9.3", - "vite": "^7.1.10", + "vite": "^7.3.1", "vitest": "^1.0.0" } }, diff --git a/package.json b/package.json index 6bec1b70..73693899 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "jsdom": "^26.1.0", "prettier": "^2.8.7", "typescript": "^5.9.3", - "vite": "^7.1.10", + "vite": "^7.3.1", "vitest": "^1.0.0" } } diff --git a/src/pages/Assignments/tabs/TopicsTab.tsx b/src/pages/Assignments/tabs/TopicsTab.tsx index 22cb2264..e7e0f18a 100644 --- a/src/pages/Assignments/tabs/TopicsTab.tsx +++ b/src/pages/Assignments/tabs/TopicsTab.tsx @@ -673,12 +673,12 @@ const TopicsTab = ({ {deleteState.visible && ( From fd2b7605d845ca5974e96a9831ed372a7086e54c Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Sun, 29 Mar 2026 08:15:58 -0500 Subject: [PATCH 04/17] Revert package file changes --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 04a17a6a..d4ff031e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,7 +58,7 @@ "jsdom": "^26.1.0", "prettier": "^2.8.7", "typescript": "^5.9.3", - "vite": "^7.3.1", + "vite": "^7.1.10", "vitest": "^1.0.0" } }, diff --git a/package.json b/package.json index 73693899..6bec1b70 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "jsdom": "^26.1.0", "prettier": "^2.8.7", "typescript": "^5.9.3", - "vite": "^7.3.1", + "vite": "^7.1.10", "vitest": "^1.0.0" } } From 8bb5be7acc2afaf3dc294e1e07d413dbbf9f9bbc Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Sun, 29 Mar 2026 11:19:15 -0500 Subject: [PATCH 05/17] Forcing topics tab to use backend assignments (from sister PR which will add project_topic seeds to db). --- src/pages/Assignments/AssignmentEditor.tsx | 52 ++++++++++------------ 1 file changed, 23 insertions(+), 29 deletions(-) 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 (
{ From 73a47f8c059e9579f7b70fe40a7812fc088d8710 Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Sun, 29 Mar 2026 12:06:05 -0500 Subject: [PATCH 06/17] Updated handling to use backend data instead of frontend examples. --- src/pages/Assignments/CreateTeams.tsx | 147 ++++++++++++++------------ 1 file changed, 80 insertions(+), 67 deletions(-) diff --git a/src/pages/Assignments/CreateTeams.tsx b/src/pages/Assignments/CreateTeams.tsx index 011aaa57..5c5735a4 100644 --- a/src/pages/Assignments/CreateTeams.tsx +++ b/src/pages/Assignments/CreateTeams.tsx @@ -1319,8 +1319,8 @@ const CreateTeams: React.FC<{ contextType?: ContextType; contextName?: string }> 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(() => { @@ -1355,6 +1355,13 @@ 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; @@ -1365,16 +1372,14 @@ const CreateTeams: React.FC<{ contextType?: ContextType; contextName?: string }> ]); const fetchedTeams: Team[] = (Array.isArray(teamsResponse.data) ? teamsResponse.data : []) - .filter((team: any) => String(team.assignment_id) === assignmentId) + .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) => ({ - id: user.id, - username: user.name || user.full_name || `User ${user.id}`, - fullName: user.full_name || user.name, - teamName: team.name, - })), + members: (team.users || []).map((user: any) => mapUserToParticipant(user, team.name)), })); const assignedIds = new Set( @@ -1387,8 +1392,8 @@ const CreateTeams: React.FC<{ contextType?: ContextType; contextName?: string }> ) .map((participant: any) => ({ id: participant.user?.id ?? participant.id, - username: participant.user?.name || participant.user?.full_name || `User ${participant.user?.id ?? participant.id}`, - fullName: participant.user?.full_name || participant.user?.name, + 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))); @@ -1399,7 +1404,7 @@ const CreateTeams: React.FC<{ contextType?: ContextType; contextName?: string }> } catch (error) { console.error('Error loading assignment teams:', error); } - }, [assignmentId, ctxType]); + }, [assignmentId, ctxType, mapUserToParticipant]); useEffect(() => { refreshAssignmentTeams(); @@ -1448,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']) => { @@ -1498,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]); + 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(() => { + 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)'}"`); From 0a8227cd695578bd8f5675bb41b0acabd45cf474 Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Sun, 29 Mar 2026 17:16:13 -0500 Subject: [PATCH 07/17] Several changes to frontend relating to teams. Specifically assignment_participants route relies on assignments now. Participants views load properly and also populate correctly into teams. Additionally permitted more context to be passed from frontend to backend so system can know what assignment's teams are being imported/exported. --- src/App.tsx | 2 +- src/components/Modals/ExportModal.tsx | 147 ++++++++++++------- src/components/Modals/ImportModal.tsx | 70 ++++++--- src/pages/Assignments/CreateTeams.tsx | 2 + src/pages/Participants/Participant.tsx | 51 ++++++- src/pages/Participants/ParticipantEditor.tsx | 19 ++- 6 files changed, 202 insertions(+), 89 deletions(-) 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..532b60fc 100644 --- a/src/components/Modals/ExportModal.tsx +++ b/src/components/Modals/ExportModal.tsx @@ -80,34 +80,71 @@ Icon.displayName = 'Icon'; type ExportModal = { show: boolean; onHide: () => void; - modelClass: string; + modelClass: string; + contextParams?: Record; }; /* ============================================================================= 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 { isLoading, data: exportResponse, sendRequest: fetchExports } = useAPI(); const { data: sendExportResponse, error: exportError, sendRequest: sendExport } = useAPI(); + 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 () => { - 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 fetchConfig = useCallback(async () => { + 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 +161,30 @@ const ExportModal: React.FC = ({ show, onHide, modelClass }) => { useEffect(() => { if (!show) return; - fetchConfig() - }, [show]); + fetchConfig(); + }, [fetchConfig, 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) => @@ -192,13 +232,11 @@ const ExportModal: React.FC = ({ show, onHide, modelClass }) => { 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() - } + link.setAttribute('download', `${modelClass}_export_${getFormattedDateTimeForFilename()}.csv`); + document.body.appendChild(link); + link.click(); + link.remove(); + }; const on_export = async () => { if (selectedFields.length === 0) { setStatus('Please select at least one field.'); @@ -210,22 +248,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 +281,17 @@ const ExportModal: React.FC = ({ show, onHide, modelClass }) => { useEffect(() => { - if(sendExportResponse) { + if (sendExportResponse) { setStatus(sendExportResponse.data.message); - downloadFile(sendExportResponse.data.file) + downloadFile(sendExportResponse.data.file); - if (!exportError){ + if (!exportError) { setTimeout(onHide, 1500); } } else if (exportError) { setStatus(exportError); } - }, [sendExportResponse, exportError]); + }, [exportError, onHide, sendExportResponse]); return ( = ({ show, onHide, modelClass }) => {
-
- Optional fields: +
+ External fields: = ({ show, onHide, modelClass }) => { } > - - - + + + -
+
diff --git a/src/components/Modals/ImportModal.tsx b/src/components/Modals/ImportModal.tsx index 2e2b37b8..03a367f1 100644 --- a/src/components/Modals/ImportModal.tsx +++ b/src/components/Modals/ImportModal.tsx @@ -9,8 +9,6 @@ import { Col, OverlayTrigger, Tooltip, - Container, - CloseButton, } from "react-bootstrap"; import useAPI from "../../hooks/useAPI"; @@ -95,12 +93,13 @@ 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 }) => { /** * Force-close handler — ALWAYS closes modal instantly. @@ -119,7 +118,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 +140,19 @@ 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]); useEffect(() => { if (show) { @@ -218,13 +224,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 +235,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 +270,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 +289,21 @@ const ImportModal: React.FC = ({ show, onHide, modelClass }) = }; useEffect(() => { - if(sendImportResponse) { + if (sendImportResponse) { setStatus(sendImportResponse.data.message); - if (!importError){ + if (!importError) { setTimeout(forceClose, 1500); } } else if (importError) { setStatus(importError); } - }, [sendImportResponse, importError]); + }, [forceClose, importError, sendImportResponse]); - const previewHeaders = useMemo(() => (useHeader ? csvHeaders : selectedFields), [useHeader, csvHeaders, selectedFields]); + const previewHeaders = useMemo( + () => (useHeader ? csvHeaders : selectedFields), + [csvHeaders, selectedFields, useHeader] + ); const previewData = useMemo(() => { return csvData.map((row) => { @@ -315,6 +316,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)); @@ -574,6 +591,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. +
+ )}
refreshAssignmentTeams(); }} modelClass="Team" + contextParams={{ assignment_id: assignmentId }} /> setShowExportTeamsModal(false)} modelClass="Team" + contextParams={{ assignment_id: assignmentId }} /> {/* Other Modals */} 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 ( From 2d61ca9708a09263e190a11448702dbced133cc7 Mon Sep 17 00:00:00 2001 From: Mihir Kamat Date: Mon, 30 Mar 2026 01:10:44 -0400 Subject: [PATCH 08/17] Change exportmodal to download multiple files for graph-export --- src/components/Modals/ExportModal.tsx | 63 +++++++++++++++------------ 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/src/components/Modals/ExportModal.tsx b/src/components/Modals/ExportModal.tsx index 532b60fc..5a105edf 100644 --- a/src/components/Modals/ExportModal.tsx +++ b/src/components/Modals/ExportModal.tsx @@ -84,6 +84,11 @@ type ExportModal = { contextParams?: Record; }; +type ExportFilePayload = { + name: string; + contents: string; +}; + /* ============================================================================= Component (dummy mode – no backend) ============================================================================= */ @@ -94,6 +99,7 @@ const ExportModal: React.FC = ({ show, onHide, modelClass, contextP const [externalFields, setExternalFields] = useState([]); const [allFields, setAllFields] = useState([]); const [selectedFields, setSelectedFields] = useState([]); + const [graphExportEnabled, setGraphExportEnabled] = useState(false); const [status, setStatus] = useState(''); const { isLoading, data: exportResponse, sendRequest: fetchExports } = useAPI(); const { data: sendExportResponse, error: exportError, sendRequest: sendExport } = useAPI(); @@ -181,6 +187,7 @@ const ExportModal: React.FC = ({ show, onHide, modelClass, contextP setAllFields(Array.from(new Set(fields))); setSelectedFields(normalizedMandatoryFields); + setGraphExportEnabled(false); setStatus(''); } @@ -210,32 +217,19 @@ const ExportModal: React.FC = ({ show, onHide, modelClass, contextP }); }; - function getFormattedDateTimeForFilename() { - 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'); - - // 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 - - link.setAttribute('download', `${modelClass}_export_${getFormattedDateTimeForFilename()}.csv`); - document.body.appendChild(link); - link.click(); - link.remove(); + const downloadFiles = (files: ExportFilePayload[]) => { + 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 filename = file.name.endsWith(".csv") ? file.name : `${file.name}.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) { @@ -260,6 +254,7 @@ const ExportModal: React.FC = ({ show, onHide, modelClass, contextP ); formData.append("ordered_fields", JSON.stringify(orderedFields)); + formData.append("graph_export", String(graphExportEnabled)); Object.entries(contextParams || {}).forEach(([key, value]) => { if (value !== undefined && value !== null && value !== '') { formData.append(key, String(value)); @@ -283,7 +278,8 @@ const ExportModal: React.FC = ({ show, onHide, modelClass, contextP useEffect(() => { if (sendExportResponse) { setStatus(sendExportResponse.data.message); - downloadFile(sendExportResponse.data.file); + const files = Array.isArray(sendExportResponse.data.file) ? sendExportResponse.data.file : []; + downloadFiles(files); if (!exportError) { setTimeout(onHide, 1500); @@ -366,6 +362,19 @@ const ExportModal: React.FC = ({ show, onHide, modelClass, contextP + + + setGraphExportEnabled(event.target.checked)} + label="Enable graph export" + style={TABLE_TEXT} + /> + + + From 80fe3a1971b4c2ab7a6593369a46ea0f3fac4937 Mon Sep 17 00:00:00 2001 From: Mihir Kamat Date: Mon, 30 Mar 2026 01:25:23 -0400 Subject: [PATCH 09/17] Add export button to questionnaire window --- src/components/Modals/ExportModal.tsx | 19 ++++++++- src/pages/Questionnaires/Questionnaire.tsx | 49 +++++++++++++++------- 2 files changed, 50 insertions(+), 18 deletions(-) diff --git a/src/components/Modals/ExportModal.tsx b/src/components/Modals/ExportModal.tsx index 5a105edf..a43b3a9a 100644 --- a/src/components/Modals/ExportModal.tsx +++ b/src/components/Modals/ExportModal.tsx @@ -217,13 +217,28 @@ const ExportModal: React.FC = ({ show, onHide, modelClass, contextP }); }; + const getTimestampForFilename = () => { + const now = new Date(); + const year = now.getFullYear(); + 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"); + + return `${year}${month}${day}_${hours}${minutes}${seconds}`; + }; + 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 filename = file.name.endsWith(".csv") ? file.name : `${file.name}.csv`; + const baseName = file.name.replace(/\.csv$/i, ""); + const filename = `${baseName}_${timestamp}.csv`; link.setAttribute('download', filename); document.body.appendChild(link); link.click(); @@ -369,7 +384,7 @@ const ExportModal: React.FC = ({ show, onHide, modelClass, contextP id="graph-export-toggle" checked={graphExportEnabled} onChange={(event) => setGraphExportEnabled(event.target.checked)} - label="Enable graph export" + label="Export sub-models" style={TABLE_TEXT} /> diff --git a/src/pages/Questionnaires/Questionnaire.tsx b/src/pages/Questionnaires/Questionnaire.tsx index 982d8989..099bf761 100644 --- a/src/pages/Questionnaires/Questionnaire.tsx +++ b/src/pages/Questionnaires/Questionnaire.tsx @@ -11,6 +11,7 @@ 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"; @@ -20,12 +21,14 @@ const Questionnaires = () => { const location = useLocation(); const dispatch = useDispatch(); const [showTypeModal, setShowTypeModal] = useState(false); + const [showExportModal, setShowExportModal] = useState(false); // loader option const questionnaireData :any = useLoaderData(); useEffect(() => { setShowTypeModal(false); + setShowExportModal(false); }, [location]); const [tableData, setTableData] = useState(questionnaireData); @@ -96,22 +99,31 @@ const Questionnaires = () => {
- - - - - + + + + + + {showTypeModal && ( @@ -261,6 +273,11 @@ const Questionnaires = () => { )} + setShowExportModal(false)} + modelClass="Questionnaire" + /> ); From 5bb4c6cedee5bd81389bad571c00401509034782 Mon Sep 17 00:00:00 2001 From: Kiran Nadkarni Date: Mon, 30 Mar 2026 18:03:33 -0500 Subject: [PATCH 10/17] fixing duplicate downloads of csv export. --- src/components/Modals/ExportModal.tsx | 29 +++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/src/components/Modals/ExportModal.tsx b/src/components/Modals/ExportModal.tsx index 532b60fc..1050e31a 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"; @@ -96,7 +96,13 @@ const ExportModal: React.FC = ({ show, onHide, modelClass, contextP const [selectedFields, setSelectedFields] = useState([]); const [status, setStatus] = useState(''); const { isLoading, data: exportResponse, sendRequest: fetchExports } = useAPI(); - const { data: sendExportResponse, error: exportError, sendRequest: sendExport } = 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_")), [] @@ -128,6 +134,9 @@ const ExportModal: React.FC = ({ show, onHide, modelClass, contextP ); const fetchConfig = useCallback(async () => { + hasHandledExportResponse.current = false; + resetExportState(true, true); + try { const params = new URLSearchParams(); Object.entries(contextParams || {}).forEach(([key, value]) => { @@ -161,8 +170,10 @@ const ExportModal: React.FC = ({ show, onHide, modelClass, contextP useEffect(() => { if (!show) return; + hasHandledExportResponse.current = false; + resetExportState(true, true); fetchConfig(); - }, [fetchConfig, show]); + }, [fetchConfig, resetExportState, show]); useEffect(() => { if (exportResponse) { @@ -281,17 +292,23 @@ const ExportModal: React.FC = ({ show, onHide, modelClass, contextP useEffect(() => { - if (sendExportResponse) { + if (!show) return; + + if (sendExportResponse && !hasHandledExportResponse.current) { + hasHandledExportResponse.current = true; setStatus(sendExportResponse.data.message); downloadFile(sendExportResponse.data.file); if (!exportError) { - setTimeout(onHide, 1500); + setTimeout(() => { + resetExportState(true, true); + onHide(); + }, 1500); } } else if (exportError) { setStatus(exportError); } - }, [exportError, onHide, sendExportResponse]); + }, [exportError, onHide, resetExportState, sendExportResponse, show]); return ( Date: Mon, 30 Mar 2026 18:19:47 -0500 Subject: [PATCH 11/17] utilize useCallback for reset function in useAPI hook --- src/hooks/useAPI.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/hooks/useAPI.ts b/src/hooks/useAPI.ts index 63de11dc..4233aeed 100644 --- a/src/hooks/useAPI.ts +++ b/src/hooks/useAPI.ts @@ -71,17 +71,17 @@ const useAPI = () => { }); }, []); - 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; From 3e8ef7899977c95575ec3a8c51044224c1c21368 Mon Sep 17 00:00:00 2001 From: Mihir Kamat Date: Mon, 30 Mar 2026 20:31:46 -0400 Subject: [PATCH 12/17] Add questionnaire import button --- src/pages/Questionnaires/Questionnaire.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/pages/Questionnaires/Questionnaire.tsx b/src/pages/Questionnaires/Questionnaire.tsx index 099bf761..f3885be7 100644 --- a/src/pages/Questionnaires/Questionnaire.tsx +++ b/src/pages/Questionnaires/Questionnaire.tsx @@ -12,6 +12,7 @@ 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"; @@ -22,6 +23,7 @@ const Questionnaires = () => { const dispatch = useDispatch(); const [showTypeModal, setShowTypeModal] = useState(false); const [showExportModal, setShowExportModal] = useState(false); + const [showImportModal, setShowImportModal] = useState(false); // loader option const questionnaireData :any = useLoaderData(); @@ -29,6 +31,7 @@ const Questionnaires = () => { useEffect(() => { setShowTypeModal(false); setShowExportModal(false); + setShowImportModal(false); }, [location]); const [tableData, setTableData] = useState(questionnaireData); @@ -101,6 +104,14 @@ const Questionnaires = () => { + + + + + + {/* --------------------------------------------------------- From 529632ea0818bcbd767ae61de362ff56f62fe543 Mon Sep 17 00:00:00 2001 From: Mihir Kamat Date: Fri, 3 Apr 2026 18:33:45 -0400 Subject: [PATCH 16/17] Branch start for final changes From 2d1026d952cb5300dd137db32e3d984c5159b817 Mon Sep 17 00:00:00 2001 From: Mihir Kamat Date: Fri, 3 Apr 2026 19:03:13 -0400 Subject: [PATCH 17/17] Remove export-submodels button from frontend --- src/components/Modals/ExportModal.tsx | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/src/components/Modals/ExportModal.tsx b/src/components/Modals/ExportModal.tsx index 7b38c2cb..188a93ac 100644 --- a/src/components/Modals/ExportModal.tsx +++ b/src/components/Modals/ExportModal.tsx @@ -99,7 +99,6 @@ const ExportModal: React.FC = ({ show, onHide, modelClass, contextP const [externalFields, setExternalFields] = useState([]); const [allFields, setAllFields] = useState([]); const [selectedFields, setSelectedFields] = useState([]); - const [graphExportEnabled, setGraphExportEnabled] = useState(false); const [status, setStatus] = useState(''); const { isLoading, data: exportResponse, sendRequest: fetchExports } = useAPI(); const { @@ -198,7 +197,6 @@ const ExportModal: React.FC = ({ show, onHide, modelClass, contextP setAllFields(Array.from(new Set(fields))); setSelectedFields(normalizedMandatoryFields); - setGraphExportEnabled(false); setStatus(''); } @@ -280,7 +278,6 @@ const ExportModal: React.FC = ({ show, onHide, modelClass, contextP ); formData.append("ordered_fields", JSON.stringify(orderedFields)); - formData.append("graph_export", String(graphExportEnabled)); Object.entries(contextParams || {}).forEach(([key, value]) => { if (value !== undefined && value !== null && value !== '') { formData.append(key, String(value)); @@ -394,19 +391,6 @@ const ExportModal: React.FC = ({ show, onHide, modelClass, contextP - - - setGraphExportEnabled(event.target.checked)} - label="Export sub-models" - style={TABLE_TEXT} - /> - - -