Skip to content

Commit

Permalink
Interview Scheduler Admin View (#855)
Browse files Browse the repository at this point in the history
### Summary <!-- Required -->
This PR adds the admin view of the interview scheduler tool. Admins are
allowed to handle all basic CRUD operations for an interview scheduler
instance. Applicants are specified via a CSV file. Instances can be
closed to prevent further updates on signing up.

### Test Plan <!-- Required -->


https://github.com/user-attachments/assets/07bdd308-a3dc-45e1-be53-fb7ce8a3baab


<!-- Provide screenshots or point out the additional unit tests -->
  • Loading branch information
cchrischen authored Mar 2, 2025
1 parent 0d86852 commit 162fc8a
Show file tree
Hide file tree
Showing 8 changed files with 389 additions and 0 deletions.
5 changes: 5 additions & 0 deletions frontend/public/sample_interview_scheduler_input.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Email,First Name,Last Name,NetID
[email protected],Alder,Alderson,aaa111
[email protected],Donald,Donaldson,ddd222
[email protected],Chris,Chen,cc2785
[email protected],Jane,Doe,abc123
55 changes: 55 additions & 0 deletions frontend/src/API/InterviewSchedulerAPI.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import APIWrapper from './APIWrapper';
import { backendURL } from '../environment';

export default class InterviewSchedulerAPI {
static async createNewInstance(instance: InterviewScheduler): Promise<string> {
const response = APIWrapper.post(`${backendURL}/interview-scheduler`, instance);
return response.then((val) => val.data.uuid);
}

static async getAllInstances(isApplicant: boolean): Promise<InterviewScheduler[]> {
const response = APIWrapper.get(
`${backendURL}/interview-scheduler${isApplicant ? '/applicant' : ''}`
);
return response.then((val) => val.data.instances);
}

static async getInstance(uuid: string, isApplicant: boolean): Promise<InterviewScheduler> {
const response = APIWrapper.get(
`${backendURL}/interview-scheduler${isApplicant ? '/applicant' : ''}/${uuid}`
);
return response.then((val) => val.data.instance);
}

static async updateInstance(instance: InterviewSchedulerEdit): Promise<InterviewScheduler> {
const response = APIWrapper.put(`${backendURL}/interview-scheduler`, instance);
return response.then((val) => val.data.instance);
}

static async deleteInstance(uuid: string): Promise<void> {
APIWrapper.delete(`${backendURL}/interview-scheduler/${uuid}`);
}

static async getSlots(uuid: string, isApplicant: boolean): Promise<InterviewSlot[]> {
return APIWrapper.get(
`${backendURL}/interview-slots${isApplicant ? '/applicant' : ''}/${uuid}`
).then((val) => val.data.slots);
}

static async createSlots(slots: InterviewSlot[]): Promise<InterviewSlot[]> {
return APIWrapper.post(`${backendURL}/interview-slots`, { slots }).then(
(val) => val.data.slots
);
}

static async updateSlot(edits: InterviewSlotEdit, isApplicant: boolean): Promise<boolean> {
return APIWrapper.put(
`${backendURL}/interview-slots${isApplicant ? '/applicant' : ''}`,
edits
).then((val) => val.data.success);
}

static async deleteSlot(uuid: string): Promise<void> {
APIWrapper.delete(`${backendURL}/interview-slots/${uuid}`);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
.uploadStatusSuccess {
margin-top: 2em;
padding: 1em;
color: green;
border: 1px black solid;
background-color: lightgray;
}

.uploadStatusError {
margin-top: 2em;
padding: 1em;
color: red;
border: 1px black solid;
background-color: lightgray;
}

.creatorContainer {
width: 60%;
margin-left: auto;
margin-right: auto;
margin-top: 5%;
margin-bottom: 5%;
}

.submitButton {
margin-top: 1em !important;
}

.editorContainer {
margin-top: 3em;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import React, { useEffect, useState } from 'react';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
import { Button, Card, Checkbox, Form, Header, Loader } from 'semantic-ui-react';
import styles from './AdminInterviewScheduler.module.css';
import InterviewSchedulerAPI from '../../../API/InterviewSchedulerAPI';
import InterviewSchedulerDeleteModal from '../../Modals/InterviewSchedulerDeleteModal';
import { Emitters, parseCsv } from '../../../utils';

const InterviewSchedulerCreator = () => {
const [name, setName] = useState<string>('');
const [csv, setCsv] = useState<File>();
const [startDate, setStartDate] = useState<Date | null>(null);
const [endDate, setEndDate] = useState<Date | null>(null);
const [duration, setDuration] = useState<number>(30);
const [membersPerSlot, setMembersPerSlot] = useState<number>(1);

const onSubmit = async () => {
if (!csv) {
Emitters.generalError.emit({
headerMsg: 'Submission Error',
contentMsg: 'No csv file was provided.'
});
return;
}

const [columnHeaders, responses] = await parseCsv(csv);
const requiredHeaders = ['first name', 'last name', 'email', 'netid'];

const missingHeader = requiredHeaders.some((header) => {
if (!columnHeaders.includes(header)) {
Emitters.generalError.emit({
headerMsg: 'CSV Parsing Error',
contentMsg: `The csv file does not contain a column with header: ${header}`
});
return true;
}
return false;
});

if (missingHeader) return;

if (name === '') {
Emitters.generalError.emit({
headerMsg: 'Invalid Input',
contentMsg: 'The name must not be empty.'
});
} else if (duration < 0) {
Emitters.generalError.emit({
headerMsg: 'Invalid Duration',
contentMsg: 'The duration must be positive.'
});
} else if (membersPerSlot < 0) {
Emitters.generalError.emit({
headerMsg: 'Invalid Members per Slot',
contentMsg: 'Members per slot must be positive.'
});
} else if (!startDate || !endDate) {
Emitters.generalError.emit({
headerMsg: 'Invalid Date',
contentMsg: 'Start date and end date are undefined.'
});
} else {
await InterviewSchedulerAPI.createNewInstance({
name,
duration: duration * 60000, // convert to milliseconds
membersPerSlot,
startDate: (startDate as Date).getTime(),
endDate: (endDate as Date).getTime(),
isOpen: false,
uuid: '',
applicants: responses.map((response) => ({
firstName: response[columnHeaders.indexOf('first name')],
lastName: response[columnHeaders.indexOf('last name')],
email: response[columnHeaders.indexOf('email')],
netid: response[columnHeaders.indexOf('netid')]
}))
});
Emitters.generalSuccess.emit({
headerMsg: 'Successfully created interview scheduler',
contentMsg: `Created interview scheduler instance: ${name}`
});
setName('');
setStartDate(null);
setEndDate(null);
}
};

return (
<div>
<Header as="h2">Create a new Interview Scheduler instance</Header>
<Form>
<Form.Input label="Name" value={name} onChange={(e) => setName(e.target.value)} />
<Header as="h4">Applicants</Header>
<input
type="file"
accept=".csv"
onChange={(e) => {
if (e.target.files) setCsv(e.target.files[0]);
}}
/>
<label>
{' '}
Format: .csv with at least a "Email", "NetID", "First Name", and "Last Name" column (case
insensitive).{' '}
<a href="/sample_interview_scheduler_input.csv">Download sample file here.</a>
</label>
<Header as="h4">Start and end date</Header>
<DatePicker
selectsRange
startDate={startDate}
endDate={endDate}
onChange={(date) => {
const [start, end] = date as [Date | null, Date | null];
if (start) {
start.setHours(0, 0, 0, 0);
}
setStartDate(start);
setEndDate(end);
}}
/>
<Header as="h4">Duration (in minutes)</Header>
<input
type="number"
defaultValue={30}
onChange={(e) => setDuration(Number(e.target.value))}
/>
<Header as="h4">Members Per Slot</Header>
<input
type="number"
defaultValue={1}
onChange={(e) => setMembersPerSlot(Number(e.target.value))}
/>
<Button onClick={onSubmit} className={styles.submitButton}>
Create Interview Scheduler Instance
</Button>
</Form>
</div>
);
};

const InterviewSchedulerEditor = () => {
const [instances, setInstances] = useState<InterviewScheduler[]>([]);
const [isLoading, setIsLoading] = useState(true);

useEffect(() => {
InterviewSchedulerAPI.getAllInstances(false).then((instances) => {
setInstances(instances);
setIsLoading(false);
});
}, []);

const toggleIsOpen = async (uuid: string) => {
const newInstances = await Promise.all(
instances.map(async (instance) => {
if (instance.uuid === uuid) {
await InterviewSchedulerAPI.updateInstance({
uuid: instance.uuid,
isOpen: !instance.isOpen
});
return { ...instance, isOpen: !instance.isOpen };
}
return instance;
})
);
setInstances(newInstances);
};

return (
<div className={styles.editorContainer}>
<Header as="h2">All Interview Scheduler Instances</Header>
{isLoading ? (
<Loader size="large" />
) : (
<Card.Group>
{instances.map((instance) => (
<Card key={instance.uuid}>
<Card.Content>
<Card.Header>{instance.name}</Card.Header>
<Card.Meta>{instance.isOpen ? 'Open' : 'Closed'}</Card.Meta>
<div>
<Checkbox
toggle
defaultChecked={instance.isOpen}
onChange={() => toggleIsOpen(instance.uuid)}
/>
<div>
<InterviewSchedulerDeleteModal
setInstances={setInstances}
uuid={instance.uuid}
/>
</div>
</div>
</Card.Content>
</Card>
))}
</Card.Group>
)}
</div>
);
};

const AdminInterviewSchedulerBase = () => (
<div className={styles.creatorContainer}>
<InterviewSchedulerCreator />
<InterviewSchedulerEditor />
</div>
);

export default AdminInterviewSchedulerBase;
49 changes: 49 additions & 0 deletions frontend/src/components/Modals/InterviewSchedulerDeleteModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Button, Modal } from 'semantic-ui-react';
import { useState } from 'react';
import InterviewSchedulerAPI from '../../API/InterviewSchedulerAPI';
import { Emitters } from '../../utils';

const InterviewSchedulerDeleteModal: React.FC<{
uuid: string;
setInstances: React.Dispatch<React.SetStateAction<InterviewScheduler[]>>;
}> = ({ uuid, setInstances }) => {
const [isOpen, setIsOpen] = useState(false);

return (
<Modal
onClose={() => setIsOpen(false)}
onOpen={() => setIsOpen(true)}
open={isOpen}
trigger={<Button negative>Delete</Button>}
>
<Modal.Header>
Are you sure you want to delete this Interview Scheduler instance?
</Modal.Header>
<Modal.Content>
Deleting this instance will delete all time slots associated with this instance.
</Modal.Content>
<Modal.Actions>
<Button negative onClick={() => setIsOpen(false)}>
No
</Button>
<Button
positive
onClick={() => {
InterviewSchedulerAPI.deleteInstance(uuid).then(() => {
setInstances((instances) => instances.filter((inst) => inst.uuid !== uuid));
});
setIsOpen(false);
Emitters.generalSuccess.emit({
headerMsg: 'Success.',
contentMsg: 'Successfully deleted interview scheduler instance.'
});
}}
>
Yes
</Button>
</Modal.Actions>
</Modal>
);
};

export default InterviewSchedulerDeleteModal;
6 changes: 6 additions & 0 deletions frontend/src/pages/admin/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ const navCardItems: readonly NavigationCardItem[] = [
description: 'Review coffee chat submissions!',
link: '/admin/coffee-chats',
adminOnly: true
},
{
header: 'Edit Interview Scheduler',
description: 'Create or edit interview scheduler instances',
link: '/admin/interview-scheduler',
adminOnly: true
}
];

Expand Down
3 changes: 3 additions & 0 deletions frontend/src/pages/admin/interview-scheduler.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import AdminInterviewSchedulerBase from '../../components/Admin/AdminInterviewScheduler/AdminInterviewScheduler';

export default AdminInterviewSchedulerBase;
Loading

0 comments on commit 162fc8a

Please sign in to comment.