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/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 aa2da1a9d..e062698b0 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,14 @@ 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([]); + const [confirmModal, setConfirmModal] = useState(false); + const [modalErrorMessage, setModalErrorMessage] = useState(''); const debounceRef = useRef(null); + const isFileUpload = useRef(false); const isOfficerOrAdmin = user?.accessLevel >= membershipState.OFFICER; @@ -292,6 +300,175 @@ 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); + const link = document.createElement('a'); + link.href = url; + link.download = 'example-events.csv'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } + + function showFileErrorModal(modalMessage) { + setFileData([]); + setHeadersArray([]); + setValues([]); + setModalErrorMessage(modalMessage); + setConfirmModal(true); + isFileUpload.current = false; + } + + function handleFileUpload(event) { + const maxFileSize = 10 * 1024 * 1024; + const file = event.target.files[0]; + + if (!file) return; + + if (file.size > maxFileSize) { + setModalErrorMessage(`File size is ${file.size} and exceeds the 10MB limit.`); + setConfirmModal(true); + event.target.value = ''; + return; + } + + Papa.parse(file, { + header: true, + skipEmptyLines: true, + complete: function(result) { + const headersArray = []; + const valuesArray = []; + + result.data.map((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) { + const actualHeaders = (headersArray[0] || []).map((h) => h.trim()); + const missingHeaders = EXPECTED_HEADERS.filter((h) => !actualHeaders.includes(h)); + showFileErrorModal(`Missing required columns: ${missingHeaders.join(', ')}`); + return; + } + 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] === '') { + emptyValueCounter++; + missingElements.push(i); + } + } + if (emptyValueCounter > 0) { + const missingColumnNames = missingElements.map((i) => headersArray[0][i]); + showFileErrorModal(`Missing required columns: ${missingColumnNames.join(', ')}`); + return; + } + } + + setFileData(result.data); + setHeadersArray(headersArray[0]); + setValues(valuesArray); + } + }); + + isFileUpload.current = true; + } + + async function handleFileCreateEvent() { + if (values.length === 0) { + setModalErrorMessage('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; + + 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].toLowerCase(), + visibility: row[8].toLowerCase(), + minimum_visible_role: visibility === 'private' ? minimumVisibleRole : '', + waitlist_enabled: hasWaitlist, + waitlist_size: hasWaitlist ? waitlistValue : 0, + publish_date: toPublishDateValue(row[7].toLowerCase(), 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..94434dc5a 100644 --- a/src/Pages/Events/EventEditorForm.js +++ b/src/Pages/Events/EventEditorForm.js @@ -1,12 +1,15 @@ 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, form, questionActions, adminActions, + fileActions, }) { const { title, @@ -49,8 +52,20 @@ export default function EventEditorForm({ setWaitlistSize, publishDate, setPublishDate, + confirmModal, + setConfirmModal, + modalErrorMessage, + setModalErrorMessage, } = form; + const { + handleFileUpload, + handleDownloadExampleCsv, + fileData = [], + headersArray = [], + values = [], + } = fileActions || {}; + const { questions, addQuestion, @@ -450,6 +465,22 @@ export default function EventEditorForm({
)} + { + setConfirmModal(false); + }, + handleCancel: () => { + setConfirmModal(false); + }, + open: confirmModal, + } + }/> +
+ + + + Cancel
+ + + + {headersArray.map((col, i) => ( + + ))} + + + + {values.map((v, i) => ( + + {v.map((value, i) => ( + + ))} + + ))} + +
{col}
{value}
+ {eventDelete?.show && (

Danger zone