Skip to content
Open
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
190 changes: 189 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,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;

Expand Down Expand Up @@ -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',
];
Comment on lines +303 to +306

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it maybe possible to link/sync this to the existing types? in case long term the fields to create an Event change (ex. new field is added), these EXPECTED_HEADERS would become outdated right?

if it's not possible no worries, just a consideration


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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: what is this value? obviously not super hard to figure out but maybe leave a comment denoting what this is so people in the future don't need to do the mental math 😓

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] === '') {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we leave a comment here on why index 9 is treated differently?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we maybe make a row to field mapping/enum? it's not super clear what "index 9" is + to figure it out requires looking through the potential many columns in the expected CSV format, it'd be nice to know what it is through code (variable naming)

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 on the enum, this comment is helpful but it might be just more clear if there's an explicity defined enum atop the file? or maybe not lol your call!

*/

const waitlistValue = Number(row[6]);

@evanugarte evanugarte Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we be 100% sure that the row has at least 7 elements? like a small if statement,

and small comment for what each column means like

date,title,description,...

otherwise this happens

a = []
a[4] // undefined

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 (
<EventEditorForm
meta={{
Expand All @@ -300,7 +477,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 +508,10 @@ export default function CreateEventPage() {
setWaitlistSize,
publishDate,
setPublishDate,
confirmModal,
setConfirmModal,
modalErrorMessage,
setModalErrorMessage,
}}
questionActions={{
questions,
Expand Down Expand Up @@ -363,6 +544,13 @@ export default function CreateEventPage() {
}
},
}}
fileActions={{
handleFileUpload,
handleDownloadExampleCsv,
fileData,
headersArray,
values,
}}
/>
);
}
69 changes: 69 additions & 0 deletions src/Pages/Events/EventEditorForm.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -450,6 +465,22 @@ export default function EventEditorForm({
</div>
)}

<ConfirmationModal {... {
headerText: 'Error',
bodyText: modalErrorMessage,
confirmText: 'Create',
cancelText: 'Close',
hideConfirmButton: true,
handleConfirmation: () => {
setConfirmModal(false);
},
handleCancel: () => {
setConfirmModal(false);
},
open: confirmModal,
}
}/>

<div className="flex flex-wrap justify-start gap-3 border-t border-gray-200 pt-6 dark:border-gray-700">
<button
type="button"
Expand All @@ -459,11 +490,49 @@ export default function EventEditorForm({
>
{submitting ? submittingLabel : submitLabel}
</button>
<label htmlFor="csv-file-input" className="btn btn-outline">
Import CSV
</label>
<input
type="file"
id="csv-file-input"
accept=".csv"
placeholder="Import CSV"
style={{ display: 'none' }}
onChange={handleFileUpload}
>
</input>
<button
type="button"
className="btn btn-outline"
onClick={handleDownloadExampleCsv}
>
Download Example CSV
</button>
<Link to="/events" className="btn btn-ghost">
Cancel
</Link>
</div>

<table style={{borderCollapse: 'collapse', border: '1px solid black', margin: '5px auto'}}>
<thead>
<tr>
{headersArray.map((col, i) => (
<th style={{border: '1px solid blackg'}} key={i}>{col}</th>
))}
</tr>
</thead>
<tbody>
{values.map((v, i) => (
<tr key={i}>
{v.map((value, i) => (
<td style={{border: '1px solid blackg'}} key={i}>{value}</td>
))}
</tr>
))}
</tbody>
</table>

{eventDelete?.show && (
<div className="mt-10 border-t border-gray-200 pt-8 dark:border-gray-700">
<h2 className="mb-3 text-xl font-semibold text-gray-900 dark:text-white">Danger zone</h2>
Expand Down
Loading