From 7026fff783225fd45d8058d5f4f00e02e7056cbf Mon Sep 17 00:00:00 2001 From: Ethan Date: Tue, 11 Aug 2026 19:55:45 -0700 Subject: [PATCH 1/4] Allow bulk creation of SCE events to take CSV files --- package-lock.json | 7 +++ package.json | 1 + src/Pages/Events/CreateEventPage.js | 90 ++++++++++++++++++++++++++++- src/Pages/Events/EventEditorForm.js | 39 +++++++++++++ src/Pages/Events/eventUtils.js | 1 + 5 files changed, 137 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 5045fd161..edf9152cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "mongoose": "^5.11.18", "multer": "^1.4.5-lts.1", "nodemailer": "^6.9.5", + "papaparse": "^5.5.4", "passport": "^0.6.0", "passport-jwt": "^4.0.1", "pdf-lib": "^1.16.0", @@ -14966,6 +14967,12 @@ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" }, + "node_modules/papaparse": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.4.tgz", + "integrity": "sha512-SwzWD9gl/ElwYLCI0nUja1mFJzjq2D8ziShfNBa7zCHzkOozeOGDwHWQ+tvCzEZcewecWZ5U7kUopDnG+DFYEQ==", + "license": "MIT" + }, "node_modules/param-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", diff --git a/package.json b/package.json index ebcebba81..283feca6d 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "mongoose": "^5.11.18", "multer": "^1.4.5-lts.1", "nodemailer": "^6.9.5", + "papaparse": "^5.5.4", "passport": "^0.6.0", "passport-jwt": "^4.0.1", "pdf-lib": "^1.16.0", diff --git a/src/Pages/Events/CreateEventPage.js b/src/Pages/Events/CreateEventPage.js index aa2da1a9d..f6ef47084 100644 --- a/src/Pages/Events/CreateEventPage.js +++ b/src/Pages/Events/CreateEventPage.js @@ -8,6 +8,7 @@ import { membershipState } from '../../Enums'; import { useEventQuestions, toApiRegistrationForm } from './useEventQuestions'; import { getApiErrorMessage } from './eventUtils'; import EventEditorForm from './EventEditorForm'; +import Papa from 'papaparse'; /** Matches SCEvents `max_attendees` when there is no cap. */ const UNLIMITED_ATTENDEES = -1; @@ -87,7 +88,12 @@ export default function CreateEventPage() { const [adminSearching, setAdminSearching] = useState(false); const [submitError, setSubmitError] = useState(''); const [submitting, setSubmitting] = useState(false); + const [fileTable, setFileTable] = useState([]); + const [fileData, setFileData] = useState([]); + const [columnArray, setColumnArray] = useState([]); + const [values, setValues] = useState([]); const debounceRef = useRef(null); + const isFileUpload = useRef(false); const isOfficerOrAdmin = user?.accessLevel >= membershipState.OFFICER; @@ -292,6 +298,82 @@ export default function CreateEventPage() { ); } + function handleFileUpload(event) { + const maxFileSize = 10 * 1024 * 1024; + const file = event.target.files[0]; + + if (!file) return; + + if (file.size > maxFileSize) { + alert('File size exceeds the 10MB limit.'); + event.target.value = ''; + return; + } + + Papa.parse(file, { + header: true, + skipEmptyLines: true, + complete: function(result) { + const columnArray = []; + const valuesArray = []; + + result.data.map((data) => { + columnArray.push(Object.keys(data)); + valuesArray.push(Object.values(data)); + }); + setFileData(result.data); + setColumnArray(columnArray[0]); + setValues(valuesArray); + } + }); + + isFileUpload.current = true; + } + + async function handleFileCreateEvent() { + for (const row of values) { + const waitlistValue = Number(row[6]); + const hasWaitlist = !Number.isNaN(waitlistValue) && waitlistValue > 0; + + const payload = { + id: crypto.randomUUID(), + name: row[0].trim(), + date: row[1], + time: row[2], + location: row[3].trim(), + description: row[4].trim(), + admins: allOrgAdminsCanEdit ? [] : eventAdminIds, + all_org_admins_can_edit: allOrgAdminsCanEdit, + registration_form: toApiRegistrationForm(questions), + max_attendees: + Number(row[5]) === UNLIMITED_ATTENDEES ? UNLIMITED_ATTENDEES : Number(row[5]), + created_at: new Date().toISOString(), + status: row[7], + visibility: row[8], + minimum_visible_role: visibility === 'private' ? minimumVisibleRole : '', + waitlist_enabled: hasWaitlist, + waitlist_size: hasWaitlist ? waitlistValue : 0, + publish_date: toPublishDateValue(row[7], row[9]), + }; + + setSubmitting(true); + const result = await createSCEvent(token, payload); + setSubmitting(false); + + if (result.error) { + setSubmitError(getApiErrorMessage(result, { + fallback: 'SCEvents returned an error.', + networkHint: 'Is the SCEvents API running (e.g. Docker on port 8002)?', + })); + return; + } + + history.push('/events'); + + isFileUpload.current = false; + } + } + return ( ); } diff --git a/src/Pages/Events/EventEditorForm.js b/src/Pages/Events/EventEditorForm.js index e59bc4d80..aa7274262 100644 --- a/src/Pages/Events/EventEditorForm.js +++ b/src/Pages/Events/EventEditorForm.js @@ -7,6 +7,7 @@ export default function EventEditorForm({ form, questionActions, adminActions, + fileActions, }) { const { title, @@ -51,6 +52,13 @@ export default function EventEditorForm({ setPublishDate, } = form; + const { + handleFileUpload, + fileData = [], + columnArray = [], + values = [], + } = fileActions || {}; + const { questions, addQuestion, @@ -459,11 +467,42 @@ export default function EventEditorForm({ > {submitting ? submittingLabel : submitLabel} + + + Cancel + + + + {columnArray.map((col, i) => ( + + ))} + + + + {values.map((v, i) => ( + + {v.map((value, i) => ( + + ))} + + ))} + +
{col}
{value}
+ {eventDelete?.show && (

Danger zone

diff --git a/src/Pages/Events/eventUtils.js b/src/Pages/Events/eventUtils.js index e41b4fe5a..9cd51b845 100644 --- a/src/Pages/Events/eventUtils.js +++ b/src/Pages/Events/eventUtils.js @@ -56,3 +56,4 @@ export function getApiErrorMessage(result, options = {}) { return msg || fallback; } + From f303f376c784989a505bf9d442d02b3305c33f9f Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 17 Aug 2026 13:50:53 -0700 Subject: [PATCH 2/4] Implemented warning modal for bad data --- .../DecisionModal/ConfirmationModal.js | 10 +- src/Pages/Events/CreateEventPage.js | 92 ++++++++++++++++--- src/Pages/Events/EventEditorForm.js | 26 +++++- src/Pages/Events/eventUtils.js | 1 - 4 files changed, 109 insertions(+), 20 deletions(-) diff --git a/src/Components/DecisionModal/ConfirmationModal.js b/src/Components/DecisionModal/ConfirmationModal.js index 34d8a7dd2..3b8803d1c 100644 --- a/src/Components/DecisionModal/ConfirmationModal.js +++ b/src/Components/DecisionModal/ConfirmationModal.js @@ -1,7 +1,7 @@ import React, { useEffect } from 'react'; export default function ConfirmationModal(props) { - const { headerText, bodyText, handleConfirmation, open, handleCancel = () => {}, confirmClassAddons = '' } = props; + const { headerText, bodyText, handleConfirmation, open, handleCancel = () => {}, confirmClassAddons = '', hideConfirmButton = false, } = props; const confirmText = props.confirmText || 'Confirm'; const cancelText = props.cancelText || 'Cancel'; @@ -22,9 +22,11 @@ export default function ConfirmationModal(props) {
- + {!hideConfirmButton && ( + + )} diff --git a/src/Pages/Events/CreateEventPage.js b/src/Pages/Events/CreateEventPage.js index f6ef47084..14515ccd8 100644 --- a/src/Pages/Events/CreateEventPage.js +++ b/src/Pages/Events/CreateEventPage.js @@ -90,8 +90,10 @@ export default function CreateEventPage() { const [submitting, setSubmitting] = useState(false); const [fileTable, setFileTable] = useState([]); const [fileData, setFileData] = useState([]); - const [columnArray, setColumnArray] = useState([]); + const [headersArray, setHeadersArray] = useState([]); const [values, setValues] = useState([]); + const [confirmModal, setConfirmModal] = useState(false); + const [modalWarningMessage, setModalWarningMessage] = useState(''); const debounceRef = useRef(null); const isFileUpload = useRef(false); @@ -298,6 +300,11 @@ export default function CreateEventPage() { ); } + const EXPECTED_HEADERS = [ + 'Event Name', 'Date', 'Time', 'Location', 'Description', + 'Max Attendees', 'Waitlist', 'Publish Status', 'Visibility', 'Publish Date', + ]; + function handleFileUpload(event) { const maxFileSize = 10 * 1024 * 1024; const file = event.target.files[0]; @@ -305,7 +312,8 @@ export default function CreateEventPage() { if (!file) return; if (file.size > maxFileSize) { - alert('File size exceeds the 10MB limit.'); + setModalWarningMessage(`File size is ${file.size} and exceeds the 10MB limit.`); + setConfirmModal(true); event.target.value = ''; return; } @@ -314,15 +322,51 @@ export default function CreateEventPage() { header: true, skipEmptyLines: true, complete: function(result) { - const columnArray = []; + const headersArray = []; const valuesArray = []; result.data.map((data) => { - columnArray.push(Object.keys(data)); + headersArray.push(Object.keys(data)); valuesArray.push(Object.values(data)); }); + + const required_number_of_columns = 10; + + for (const row of valuesArray) { + let emptyValueCounter = 0; + + if(row.length < required_number_of_columns) { + setFileData([]); + setHeadersArray([]); + setValues([]); + const actualHeaders = (headersArray[0] || []).map((h) => h.trim()); + const missingHeaders = EXPECTED_HEADERS.filter((h) => !actualHeaders.includes(h)); + setModalWarningMessage(`Missing required columns: ${missingHeaders.join(', ')}`); + setConfirmModal(true); + isFileUpload.current = false; + return; + } + let missingElements = []; + for (let i = 0; i < row.length; i++) { + if (i !== 9 && row[i] === '') { + emptyValueCounter++; + missingElements.push(i); + } + } + if (emptyValueCounter > 0) { + setFileData([]); + setHeadersArray([]); + setValues([]); + const missingColumnNames = missingElements.map((i) => headersArray[0][i]); + setModalWarningMessage(`Missing required elements: ${missingColumnNames.join(', ')}`); + setConfirmModal(true); + isFileUpload.current = true; + return; + } + } + setFileData(result.data); - setColumnArray(columnArray[0]); + setHeadersArray(headersArray[0]); setValues(valuesArray); } }); @@ -331,7 +375,26 @@ export default function CreateEventPage() { } async function handleFileCreateEvent() { + if (values.length === 0) { + setModalWarningMessage('No valid rows to create. Please upload a valid CSV file.'); + setConfirmModal(true); + return; + } + for (const row of values) { + /* row layout + [0] - string - name + [1] - string - date + [2] - string - time + [3] - string - location + [4] - string - description + [5] - string - max attendees + [6] - string - waitlist + [7] - string - publish status + [8] - string - visibility + [9] - string - publish date + */ + const waitlistValue = Number(row[6]); const hasWaitlist = !Number.isNaN(waitlistValue) && waitlistValue > 0; @@ -348,12 +411,12 @@ export default function CreateEventPage() { max_attendees: Number(row[5]) === UNLIMITED_ATTENDEES ? UNLIMITED_ATTENDEES : Number(row[5]), created_at: new Date().toISOString(), - status: row[7], - visibility: row[8], + status: row[7].toLowerCase(), + visibility: row[8].toLowerCase(), minimum_visible_role: visibility === 'private' ? minimumVisibleRole : '', waitlist_enabled: hasWaitlist, waitlist_size: hasWaitlist ? waitlistValue : 0, - publish_date: toPublishDateValue(row[7], row[9]), + publish_date: toPublishDateValue(row[7].toLowerCase(), row[9]), }; setSubmitting(true); @@ -367,11 +430,10 @@ export default function CreateEventPage() { })); return; } - - history.push('/events'); - - isFileUpload.current = false; } + history.push('/events'); + + isFileUpload.current = false; } return ( @@ -413,6 +475,10 @@ export default function CreateEventPage() { setWaitlistSize, publishDate, setPublishDate, + confirmModal, + setConfirmModal, + modalWarningMessage, + setModalWarningMessage, }} questionActions={{ questions, @@ -448,7 +514,7 @@ export default function CreateEventPage() { fileActions={{ handleFileUpload, fileData, - columnArray, + headersArray, values, }} /> diff --git a/src/Pages/Events/EventEditorForm.js b/src/Pages/Events/EventEditorForm.js index aa7274262..533fa515f 100644 --- a/src/Pages/Events/EventEditorForm.js +++ b/src/Pages/Events/EventEditorForm.js @@ -1,6 +1,8 @@ import { useRef } from 'react'; import { Link } from 'react-router-dom'; import CreateEventFormQuestionBlock from './CreateEventFormQuestionBlock'; +import ConfirmationModal from + '../../Components/DecisionModal/ConfirmationModal.js'; export default function EventEditorForm({ meta, @@ -50,12 +52,16 @@ export default function EventEditorForm({ setWaitlistSize, publishDate, setPublishDate, + confirmModal, + setConfirmModal, + modalWarningMessage, + setModalWarningMessage, } = form; const { handleFileUpload, fileData = [], - columnArray = [], + headersArray = [], values = [], } = fileActions || {}; @@ -458,6 +464,22 @@ export default function EventEditorForm({
)} + { + setConfirmModal(false); + }, + handleCancel: () => { + setConfirmModal(false); + }, + open: confirmModal, + } + }/> +
Cancel