Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
156 changes: 155 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,142 @@ 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


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 +444,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 +475,10 @@ export default function CreateEventPage() {
setWaitlistSize,
publishDate,
setPublishDate,
confirmModal,
setConfirmModal,
modalErrorMessage,
setModalErrorMessage,
}}
questionActions={{
questions,
Expand Down Expand Up @@ -363,6 +511,12 @@ export default function CreateEventPage() {
}
},
}}
fileActions={{
handleFileUpload,
fileData,
headersArray,
values,
}}
/>
);
}
61 changes: 61 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,19 @@ export default function EventEditorForm({
setWaitlistSize,
publishDate,
setPublishDate,
confirmModal,
setConfirmModal,
modalErrorMessage,
setModalErrorMessage,
} = form;

const {
handleFileUpload,
fileData = [],
headersArray = [],
values = [],
} = fileActions || {};

const {
questions,
addQuestion,
Expand Down Expand Up @@ -450,6 +464,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 +489,42 @@ 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>
<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