-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Interview Scheduler Admin View (#855)
### 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
1 parent
0d86852
commit 162fc8a
Showing
8 changed files
with
389 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}`); | ||
} | ||
} |
31 changes: 31 additions & 0 deletions
31
frontend/src/components/Admin/AdminInterviewScheduler/AdminInterviewScheduler.module.css
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
210 changes: 210 additions & 0 deletions
210
frontend/src/components/Admin/AdminInterviewScheduler/AdminInterviewScheduler.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
49
frontend/src/components/Modals/InterviewSchedulerDeleteModal.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
import AdminInterviewSchedulerBase from '../../components/Admin/AdminInterviewScheduler/AdminInterviewScheduler'; | ||
|
||
export default AdminInterviewSchedulerBase; |
Oops, something went wrong.