-
Notifications
You must be signed in to change notification settings - Fork 42
Allow bulk creation of SCE events using CSV files #2181
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
7026fff
f303f37
06b394a
80ea4c9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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] === '') { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. done
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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]); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
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={{ | ||
|
|
@@ -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, | ||
|
|
@@ -331,6 +508,10 @@ export default function CreateEventPage() { | |
| setWaitlistSize, | ||
| publishDate, | ||
| setPublishDate, | ||
| confirmModal, | ||
| setConfirmModal, | ||
| modalErrorMessage, | ||
| setModalErrorMessage, | ||
| }} | ||
| questionActions={{ | ||
| questions, | ||
|
|
@@ -363,6 +544,13 @@ export default function CreateEventPage() { | |
| } | ||
| }, | ||
| }} | ||
| fileActions={{ | ||
| handleFileUpload, | ||
| handleDownloadExampleCsv, | ||
| fileData, | ||
| headersArray, | ||
| values, | ||
| }} | ||
| /> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
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_HEADERSwould become outdated right?if it's not possible no worries, just a consideration