-
Notifications
You must be signed in to change notification settings - Fork 2
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
Interview Scheduler Admin View #855
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
This logic is kind of complicated so maybe you could move it to a separate utility function that parses csv files