From 7026fff783225fd45d8058d5f4f00e02e7056cbf Mon Sep 17 00:00:00 2001 From: Ethan Date: Tue, 11 Aug 2026 19:55:45 -0700 Subject: [PATCH 1/5] 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/5] 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 From 5e55f005e032b7a6c436868500c41a9c914bb36d Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 27 Aug 2026 16:00:59 -0700 Subject: [PATCH 5/5] Add enum for csv file --- src/Pages/Events/CreateEventPage.js | 112 ++++++++++++++-------------- 1 file changed, 57 insertions(+), 55 deletions(-) diff --git a/src/Pages/Events/CreateEventPage.js b/src/Pages/Events/CreateEventPage.js index e062698b0..3475ba0f0 100644 --- a/src/Pages/Events/CreateEventPage.js +++ b/src/Pages/Events/CreateEventPage.js @@ -88,7 +88,6 @@ 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 [headersArray, setHeadersArray] = useState([]); const [values, setValues] = useState([]); @@ -105,6 +104,44 @@ export default function CreateEventPage() { [eventAdmins], ); + const CSV_COLUMN = Object.freeze({ + EVENT_NAME: 0, + DATE: 1, + TIME: 2, + LOCATION: 3, + DESCRIPTION: 4, + MAX_ATTENDEES: 5, + WAITLIST: 6, + STATUS: 7, + VISIBILITY: 8, + PUBLISH_DATE: 9, + }); + + const EXPECTED_HEADERS = [ + 'Event Name', 'Date', 'Time', 'Location', 'Description', + 'Max Attendees', 'Waitlist', 'Publish Status', 'Visibility', 'Publish Date', + ]; + + const EXAMPLE_CSV_ROWS = [ + EXPECTED_HEADERS, + ['Example Name', 'yyyy-mm-dd', 'hh:mm PM', 'Example Location', 'Example description', '20', '5', 'published', 'public', 'yyyy-mm-dd'], + ['Cookie party', '2026-08-21', '6:00 AM', 'Engineering Building', 'Eat cookies', '-1', '-1', 'draft', 'public', ''], + ['', '', '', '', '', '', '', '', '', ''], + ['Note: max attendees: -1 for unlimited, waitlist: -1 to disable, publish date: leave empty if none. Visibility can only be public atm. Do not touch row one and start at row two. DELETE THIS BOX BEFORE SUBMITTING', '', '', '', '', '', '', '', '', ''], + ]; + + function toCsvCell(cell) { + const str = String(cell); + if (/[",\n]/.test(str)) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; + } + + const EXAMPLE_CSV = EXAMPLE_CSV_ROWS + .map((row) => row.map(toCsvCell).join(',')) + .join('\r\n'); + useEffect(() => { if (!adminId || allOrgAdminsCanEdit) return; setEventAdmins((prev) => { @@ -300,32 +337,6 @@ export default function CreateEventPage() { ); } - const EXPECTED_HEADERS = [ - 'Event Name', 'Date', 'Time', 'Location', 'Description', - 'Max Attendees', 'Waitlist', 'Publish Status', 'Visibility', 'Publish Date', - ]; - - const EXAMPLE_CSV_ROWS = [ - EXPECTED_HEADERS, - ['Example Name', 'yyyy-mm-dd', 'hh:mm PM', 'Example Location', 'Example description', '20', '5', 'published', 'public', 'yyyy-mm-dd'], - ['Cookie party', '2026-08-21', '6:00 AM', 'Engineering Building', 'Eat cookies', '-1', '-1', 'draft', 'public', ''], - ['', '', '', '', '', '', '', '', '', ''], - ['Note: max attendees: -1 for unlimited, waitlist: -1 to disable, publish date: leave empty if none. Visibility can only be public atm. Do not touch row one and start at row two. DELETE THIS BOX BEFORE SUBMITTING', '', '', '', '', '', '', '', '', ''], - ]; - - /** Only quotes a cell when it actually needs it (contains a comma, quote, or newline). */ - function toCsvCell(cell) { - const str = String(cell); - if (/[",\n]/.test(str)) { - return `"${str.replace(/"/g, '""')}"`; - } - return str; - } - - const EXAMPLE_CSV = EXAMPLE_CSV_ROWS - .map((row) => row.map(toCsvCell).join(',')) - .join('\r\n'); - function handleDownloadExampleCsv() { const blob = new Blob([EXAMPLE_CSV], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); @@ -348,7 +359,7 @@ export default function CreateEventPage() { } function handleFileUpload(event) { - const maxFileSize = 10 * 1024 * 1024; + const maxFileSize = 10 * 1024 * 1024; // 10MB const file = event.target.files[0]; if (!file) return; @@ -385,15 +396,15 @@ export default function CreateEventPage() { } let missingElements = []; for (let i = 0; i < row.length; i++) { - // index 9 is treated different because it is the only one that can be accepted as empty - if (i !== 9 && row[i] === '') { + // PUBLISH_DATE is treated different because it is the only one that can be accepted as empty + if (i !== CSV_COLUMN.PUBLISH_DATE && row[i] === '') { emptyValueCounter++; missingElements.push(i); } } if (emptyValueCounter > 0) { const missingColumnNames = missingElements.map((i) => headersArray[0][i]); - showFileErrorModal(`Missing required columns: ${missingColumnNames.join(', ')}`); + showFileErrorModal(`Missing required elements: ${missingColumnNames.join(', ')}`); return; } } @@ -415,41 +426,32 @@ export default function CreateEventPage() { } 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 waitlistValue = Number(row[CSV_COLUMN.WAITLIST]); const hasWaitlist = !Number.isNaN(waitlistValue) && waitlistValue > 0; + const rowStatus = row[CSV_COLUMN.STATUS].toLowerCase(); + const rowVisibility = row[CSV_COLUMN.VISIBILITY].toLowerCase(); const payload = { id: crypto.randomUUID(), - name: row[0].trim(), - date: row[1], - time: row[2], - location: row[3].trim(), - description: row[4].trim(), + name: row[CSV_COLUMN.EVENT_NAME].trim(), + date: row[CSV_COLUMN.DATE], + time: row[CSV_COLUMN.TIME], + location: row[CSV_COLUMN.LOCATION].trim(), + description: row[CSV_COLUMN.DESCRIPTION].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]), + Number(row[CSV_COLUMN.MAX_ATTENDEES]) === UNLIMITED_ATTENDEES + ? UNLIMITED_ATTENDEES + : Number(row[CSV_COLUMN.MAX_ATTENDEES]), created_at: new Date().toISOString(), - status: row[7].toLowerCase(), - visibility: row[8].toLowerCase(), - minimum_visible_role: visibility === 'private' ? minimumVisibleRole : '', + status: rowStatus, + visibility: rowVisibility, + minimum_visible_role: rowVisibility === 'private' ? minimumVisibleRole : '', waitlist_enabled: hasWaitlist, waitlist_size: hasWaitlist ? waitlistValue : 0, - publish_date: toPublishDateValue(row[7].toLowerCase(), row[9]), + publish_date: toPublishDateValue(rowStatus, row[CSV_COLUMN.PUBLISH_DATE]), }; setSubmitting(true);