Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 6 additions & 4 deletions src/Components/DecisionModal/ConfirmationModal.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -22,9 +22,11 @@ export default function ConfirmationModal(props) {

<form method="dialog">
<div className="px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6">
<button onClick={handleConfirmation} className={`btn inline-flex w-full justify-center rounded-md px-3 py-2 text-sm font-semibold text-white shadow-sm ${confirmClassAddons} sm:ml-3 sm:w-auto`}>
{confirmText}
</button>
{!hideConfirmButton && (
<button onClick={handleConfirmation} className={`btn inline-flex w-full justify-center rounded-md px-3 py-2 text-sm font-semibold text-white shadow-sm ${confirmClassAddons} sm:ml-3 sm:w-auto`}>
{confirmText}
</button>
)}
<button onClick={handleCancel} className="btn mt-3 inline-flex w-full justify-center rounded-md px-3 py-2 text-sm font-semibold shadow-sm ring-1 ring-inset sm:mt-0 sm:w-auto">
{cancelText}
</button>
Expand Down
192 changes: 191 additions & 1 deletion src/Pages/Events/CreateEventPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -87,7 +88,13 @@ export default function CreateEventPage() {
const [adminSearching, setAdminSearching] = useState(false);
const [submitError, setSubmitError] = useState('');
const [submitting, setSubmitting] = useState(false);
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;

Expand All @@ -97,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) => {
Expand Down Expand Up @@ -292,6 +337,140 @@ export default function CreateEventPage() {
);
}

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; // 10MB
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++) {
// 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 elements: ${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) {
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[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[CSV_COLUMN.MAX_ATTENDEES]) === UNLIMITED_ATTENDEES
? UNLIMITED_ATTENDEES
: Number(row[CSV_COLUMN.MAX_ATTENDEES]),
created_at: new Date().toISOString(),
status: rowStatus,
visibility: rowVisibility,
minimum_visible_role: rowVisibility === 'private' ? minimumVisibleRole : '',
waitlist_enabled: hasWaitlist,
waitlist_size: hasWaitlist ? waitlistValue : 0,
publish_date: toPublishDateValue(rowStatus, row[CSV_COLUMN.PUBLISH_DATE]),
};

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 (
<EventEditorForm
meta={{
Expand All @@ -300,7 +479,7 @@ export default function CreateEventPage() {
containerClassName: 'mx-auto mt-3 mb-6 w-full max-w-4xl px-3 sm:mt-4 sm:mb-8 sm:px-6 md:mt-5 md:mb-10',
submitLabel: 'Create event',
submittingLabel: 'Creating…',
onSubmit: handleCreateEvent,
onSubmit: isFileUpload.current ? handleFileCreateEvent : handleCreateEvent,
submitting,
submitError,
unlimitedAttendeesValue: UNLIMITED_ATTENDEES,
Expand Down Expand Up @@ -331,6 +510,10 @@ export default function CreateEventPage() {
setWaitlistSize,
publishDate,
setPublishDate,
confirmModal,
setConfirmModal,
modalErrorMessage,
setModalErrorMessage,
}}
questionActions={{
questions,
Expand Down Expand Up @@ -363,6 +546,13 @@ export default function CreateEventPage() {
}
},
}}
fileActions={{
handleFileUpload,
handleDownloadExampleCsv,
fileData,
headersArray,
values,
}}
/>
);
}
Loading
Loading