diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7853848 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "interview-test", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/react/src/App.tsx b/react/src/App.tsx index 864aaa8..1e3d01e 100644 --- a/react/src/App.tsx +++ b/react/src/App.tsx @@ -1,79 +1,9 @@ -import { useState } from "react"; -import { - SchoolActionKind, - useSchool, - useSchoolDispatch, -} from "./school-context"; import infinitasLogo from "/infinitas-logo.svg"; import "./App.css"; +import { SchoolProvider } from "./school-context"; +import { School } from "./containers/School"; function App() { - const school = useSchool(); - const schoolDispatch = useSchoolDispatch(); - - const [studentEditingId, setUserEditingId] = useState(null); - const [updatedStudentName, setUpdatedStudentName] = useState(""); - - const [teacherEditingId, setTeacherEditingId] = useState(null); - const [newAssignedStudentId, setNewAssignedStudentId] = useState< - string | null - >(null); - - const handleTeacherSubmit = (event: React.FormEvent) => { - event.preventDefault(); - - const target = event.currentTarget; - const teacherName = target.teacher.value; - const id = crypto.randomUUID(); - schoolDispatch?.({ - type: SchoolActionKind.ADD_TEACHER, - payload: { name: teacherName, id, students: [] }, - }); - - target.reset(); - }; - - const handleStudentSubmit = (event: React.FormEvent) => { - event.preventDefault(); - - const target = event.currentTarget; - const studentName = target.student.value; - const id = crypto.randomUUID(); - schoolDispatch?.({ - type: SchoolActionKind.ADD_STUDENT, - payload: { name: studentName, id }, - }); - - target.reset(); - }; - - const handleUpdateStudent = () => { - if (studentEditingId) { - schoolDispatch?.({ - type: SchoolActionKind.UPDATE_STUDENT, - payload: { name: updatedStudentName, id: studentEditingId }, - }); - } - - setUserEditingId(null); - setUpdatedStudentName(""); - }; - - const handleAssignStudent = () => { - if (teacherEditingId && newAssignedStudentId) { - schoolDispatch?.({ - type: SchoolActionKind.ASSIGN_STUDENT_TO_TEACHER, - payload: { - teacherId: teacherEditingId, - studentId: newAssignedStudentId, - }, - }); - } - - setTeacherEditingId(null); - setNewAssignedStudentId(null); - }; - return (
@@ -82,111 +12,9 @@ function App() {

IL Interview

-
-

Teacher

- - - - - - - - - - {school?.teachers.map((teacher) => { - return ( - - - - - - ); - })} - -
IdNameAction
{teacher.id}{teacher.name} -
    - {teacher.students.map((s) => ( -
  • - {school?.students.map((s1) => - s === s1.id ? s1.name : "" - )} -
  • - ))} -
- {teacher.id === teacherEditingId ? ( - <> - - - - ) : ( - - )} -
-
-
- - - -
-
-
-

Students

- - - - - - - - - - {school?.students.map((student) => { - return ( - - - - - - ); - })} - -
IdNameAction
{student.id}{student.name} - {student.id === studentEditingId ? ( - <> - - setUpdatedStudentName(e.target.value) - } - > - - - ) : ( - - )} -
-
-
- - - -
-
+ + +
); } diff --git a/react/src/components/AssignmentForm/AssignmentForm.tsx b/react/src/components/AssignmentForm/AssignmentForm.tsx new file mode 100644 index 0000000..9b764a0 --- /dev/null +++ b/react/src/components/AssignmentForm/AssignmentForm.tsx @@ -0,0 +1,39 @@ +import { useState } from "react"; +import { SchoolActionKind, useSchoolDispatch } from "../../school-context"; + +export function AssignmentForm() { + const dispatch = useSchoolDispatch(); + const [title, setTitle] = useState(""); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const trimmed = title.trim(); + if (!trimmed) return; + const id = crypto.randomUUID(); + dispatch?.({ + type: SchoolActionKind.ADD_ASSIGNMENT, + payload: { id, title: trimmed }, + }); + setTitle(""); + }; + + const isValid = title.trim() !== ""; + + return ( +
+ + setTitle(e.target.value)} + aria-invalid={!isValid && title.length > 0} + /> + +
+ ); +} + diff --git a/react/src/components/AssignmentForm/index.ts b/react/src/components/AssignmentForm/index.ts new file mode 100644 index 0000000..35c1bb9 --- /dev/null +++ b/react/src/components/AssignmentForm/index.ts @@ -0,0 +1,2 @@ +export * from "./AssignmentForm"; + diff --git a/react/src/components/Student/Student.css b/react/src/components/Student/Student.css new file mode 100644 index 0000000..e69de29 diff --git a/react/src/components/Student/Student.spec.tsx b/react/src/components/Student/Student.spec.tsx new file mode 100644 index 0000000..e69de29 diff --git a/react/src/components/Student/Student.tsx b/react/src/components/Student/Student.tsx new file mode 100644 index 0000000..e69de29 diff --git a/react/src/components/Student/index.ts b/react/src/components/Student/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/react/src/components/StudentForm/StudentForm.tsx b/react/src/components/StudentForm/StudentForm.tsx new file mode 100644 index 0000000..be0faf4 --- /dev/null +++ b/react/src/components/StudentForm/StudentForm.tsx @@ -0,0 +1,39 @@ +import { useState } from "react"; +import { SchoolActionKind, useSchoolDispatch } from "../../school-context"; + +export function StudentForm() { + const schoolDispatch = useSchoolDispatch(); + const [name, setName] = useState(""); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const trimmed = name.trim(); + if (!trimmed) return; + const id = crypto.randomUUID(); + schoolDispatch?.({ + type: SchoolActionKind.ADD_STUDENT, + payload: { name: trimmed, id }, + }); + setName(""); + }; + + const isValid = name.trim() !== ""; + + return ( +
+ + setName(e.target.value)} + aria-invalid={!isValid && name.length > 0} + /> + +
+ ); +} + diff --git a/react/src/components/StudentForm/index.ts b/react/src/components/StudentForm/index.ts new file mode 100644 index 0000000..8109a51 --- /dev/null +++ b/react/src/components/StudentForm/index.ts @@ -0,0 +1 @@ +export * from "./StudentForm"; diff --git a/react/src/components/StudentTable/StudentTable.tsx b/react/src/components/StudentTable/StudentTable.tsx new file mode 100644 index 0000000..d12eaa5 --- /dev/null +++ b/react/src/components/StudentTable/StudentTable.tsx @@ -0,0 +1,160 @@ +import type { + Assignment, + Student, + StudentAssignment, +} from "../../school-context"; + +type StudentTableProps = { + students: Student[]; + assignments: Assignment[]; + studentAssignments: StudentAssignment[]; + studentEditingId: string | null; + updatedStudentName: string; + onStudentEditingIdChange: (id: string | null) => void; + onUpdatedStudentNameChange: (name: string) => void; + onUpdateStudent: () => void; + assigningStudentId: string | null; + selectedAssignmentId: string | null; + onAssigningStudentIdChange: (id: string | null) => void; + onSelectedAssignmentIdChange: (id: string | null) => void; + onAssignAssignment: () => void; + onGradeAssignment: ( + studentAssignmentId: string, + status: "pass" | "fail" + ) => void; +}; + +export function StudentTable({ + students, + assignments, + studentAssignments, + studentEditingId, + updatedStudentName, + onStudentEditingIdChange, + onUpdatedStudentNameChange, + onUpdateStudent, + assigningStudentId, + selectedAssignmentId, + onAssigningStudentIdChange, + onSelectedAssignmentIdChange, + onAssignAssignment, + onGradeAssignment, +}: StudentTableProps) { + const assignmentsById: Record = Object.fromEntries( + assignments.map((a) => [a.id, a]) + ); + + return ( + + + + + + + + + + + {students.map((student) => ( + + + + + + + ))} + +
IdNameAssignmentsAction
{student.id}{student.name} +
    + {studentAssignments + .filter((sa) => sa.studentId === student.id) + .map((sa) => ( +
  • + {assignmentsById[sa.assignmentId]?.title ?? "—"} ( + {sa.status}) + + +
  • + ))} +
+ {student.id === assigningStudentId ? ( + <> + + + + ) : ( + + )} +
+ {student.id === studentEditingId ? ( + <> + + onUpdatedStudentNameChange(e.target.value) + } + aria-label={`Edit name for ${student.name}`} + /> + + + ) : ( + + )} +
+ ); +} + diff --git a/react/src/components/StudentTable/index.ts b/react/src/components/StudentTable/index.ts new file mode 100644 index 0000000..ba055ce --- /dev/null +++ b/react/src/components/StudentTable/index.ts @@ -0,0 +1,2 @@ +export * from "./StudentTable"; + diff --git a/react/src/components/TeacherForm/TeacherForm.tsx b/react/src/components/TeacherForm/TeacherForm.tsx new file mode 100644 index 0000000..a713d67 --- /dev/null +++ b/react/src/components/TeacherForm/TeacherForm.tsx @@ -0,0 +1,39 @@ +import { useState } from "react"; +import { SchoolActionKind, useSchoolDispatch } from "../../school-context"; + +export function TeacherForm() { + const schoolDispatch = useSchoolDispatch(); + const [name, setName] = useState(""); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const trimmed = name.trim(); + if (!trimmed) return; + const id = crypto.randomUUID(); + schoolDispatch?.({ + type: SchoolActionKind.ADD_TEACHER, + payload: { name: trimmed, id, students: [] }, + }); + setName(""); + }; + + const isValid = name.trim() !== ""; + + return ( +
+ + setName(e.target.value)} + aria-invalid={!isValid && name.length > 0} + /> + +
+ ); +} + diff --git a/react/src/components/TeacherForm/index.ts b/react/src/components/TeacherForm/index.ts new file mode 100644 index 0000000..25514ed --- /dev/null +++ b/react/src/components/TeacherForm/index.ts @@ -0,0 +1,2 @@ +export * from "./TeacherForm"; + diff --git a/react/src/components/TeacherTable/TeacherTable.tsx b/react/src/components/TeacherTable/TeacherTable.tsx new file mode 100644 index 0000000..fafd8d4 --- /dev/null +++ b/react/src/components/TeacherTable/TeacherTable.tsx @@ -0,0 +1,89 @@ +import type { Student, Teacher } from "../../school-context"; + +type TeacherTableProps = { + teachers: Teacher[]; + students: Student[]; + teacherEditingId: string | null; + newAssignedStudentId: string | null; + onTeacherEditingIdChange: (id: string | null) => void; + onNewAssignedStudentIdChange: (id: string | null) => void; + onAssignStudent: () => void; +}; + +export function TeacherTable({ + teachers, + students, + teacherEditingId, + newAssignedStudentId, + onTeacherEditingIdChange, + onNewAssignedStudentIdChange, + onAssignStudent, +}: TeacherTableProps) { + const studentById: Record = Object.fromEntries( + students.map((s) => [s.id, s]) + ); + + return ( + + + + + + + + + + {teachers.map((teacher) => ( + + + + + + ))} + +
IdNameAction
{teacher.id}{teacher.name} +
    + {teacher.students.map((studentId) => ( +
  • + {studentById[studentId]?.name ?? "—"} +
  • + ))} +
+ {teacher.id === teacherEditingId ? ( + <> + + + + ) : ( + + )} +
+ ); +} + diff --git a/react/src/components/TeacherTable/index.ts b/react/src/components/TeacherTable/index.ts new file mode 100644 index 0000000..85bf148 --- /dev/null +++ b/react/src/components/TeacherTable/index.ts @@ -0,0 +1,2 @@ +export * from "./TeacherTable"; + diff --git a/react/src/containers/School.tsx b/react/src/containers/School.tsx new file mode 100644 index 0000000..9bb508d --- /dev/null +++ b/react/src/containers/School.tsx @@ -0,0 +1,194 @@ +import { useState } from "react"; +import { + SchoolActionKind, + useSchool, + useSchoolDispatch, +} from "../school-context"; +import { TeacherForm } from "../components/TeacherForm/TeacherForm"; +import { StudentForm } from "../components/StudentForm/StudentForm"; +import { AssignmentForm } from "../components/AssignmentForm/AssignmentForm"; +import { TeacherTable } from "../components/TeacherTable/TeacherTable"; +import { StudentTable } from "../components/StudentTable/StudentTable"; + +export function School() { + const school = useSchool(); + const schoolDispatch = useSchoolDispatch(); + + const [studentEditingId, setUserEditingId] = useState(null); + const [updatedStudentName, setUpdatedStudentName] = useState(""); + + const [teacherEditingId, setTeacherEditingId] = useState(null); + const [newAssignedStudentId, setNewAssignedStudentId] = useState< + string | null + >(null); + + const [assigningStudentId, setAssigningStudentId] = useState( + null + ); + const [selectedAssignmentId, setSelectedAssignmentId] = useState< + string | null + >(null); + + const [reportAssignmentId, setReportAssignmentId] = useState(""); + const [reportDate, setReportDate] = useState(""); + + const handleUpdateStudent = () => { + if (studentEditingId && updatedStudentName.trim()) { + schoolDispatch?.({ + type: SchoolActionKind.UPDATE_STUDENT, + payload: { name: updatedStudentName.trim(), id: studentEditingId }, + }); + } + + setUserEditingId(null); + setUpdatedStudentName(""); + }; + + const handleAssignStudent = () => { + if (teacherEditingId && newAssignedStudentId) { + schoolDispatch?.({ + type: SchoolActionKind.ASSIGN_STUDENT_TO_TEACHER, + payload: { + teacherId: teacherEditingId, + studentId: newAssignedStudentId, + }, + }); + } + + setTeacherEditingId(null); + setNewAssignedStudentId(null); + }; + + const handleAssignAssignment = () => { + if (!assigningStudentId || !selectedAssignmentId) { + return; + } + + const id = crypto.randomUUID(); + const now = new Date().toISOString(); + + schoolDispatch?.({ + type: SchoolActionKind.ASSIGN_ASSIGNMENT_TO_STUDENT, + payload: { + id, + studentId: assigningStudentId, + assignmentId: selectedAssignmentId, + status: "assigned", + date: now, + }, + }); + + setAssigningStudentId(null); + setSelectedAssignmentId(null); + }; + + const handleGradeAssignment = ( + studentAssignmentId: string, + status: "pass" | "fail" + ) => { + schoolDispatch?.({ + type: SchoolActionKind.GRADE_STUDENT_ASSIGNMENT, + payload: { + id: studentAssignmentId, + status, + }, + }); + }; + + const passedCount = + reportAssignmentId && reportDate && school + ? school.studentAssignments.filter( + (sa) => + sa.assignmentId === reportAssignmentId && + sa.status === "pass" && + sa.date.slice(0, 10) === reportDate + ).length + : 0; + + return ( + <> +
+

Teachers

+ +
+ +
+ +
+

Students

+ +
+ +
+ +
+

Assignments

+
    + {school?.assignments.map((assignment) => ( +
  • {assignment.title}
  • + ))} +
+
+ +
+ +
+

Reports

+
+ + +
+
+ + setReportDate(e.target.value)} + /> +
+ {reportAssignmentId && reportDate && ( +

+ {passedCount} student + {passedCount === 1 ? "" : "s"} passed this assignment on this day. +

+ )} +
+ + ); +} + diff --git a/react/src/school-context.tsx b/react/src/school-context.tsx index a6cd069..2bd9a1f 100644 --- a/react/src/school-context.tsx +++ b/react/src/school-context.tsx @@ -11,9 +11,30 @@ export type Teacher = { students: string[]; }; +export type Assignment = { + id: string; + title: string; +}; + +export type StudentAssignmentStatus = "assigned" | "pass" | "fail"; + +export type StudentAssignment = { + id: string; + studentId: string; + assignmentId: string; + status: StudentAssignmentStatus; + /** + * ISO string date-time for when the assignment was assigned or graded. + * Reporting groups by calendar day (YYYY-MM-DD). + */ + date: string; +}; + export type InitialState = { teachers: Teacher[]; students: Student[]; + assignments: Assignment[]; + studentAssignments: StudentAssignment[]; }; export enum SchoolActionKind { @@ -21,6 +42,9 @@ export enum SchoolActionKind { ADD_STUDENT = "ADD_STUDENT", UPDATE_STUDENT = "UPDATE_STUDENT", ASSIGN_STUDENT_TO_TEACHER = "ASSIGN_STUDENT_TO_TEACHER", + ADD_ASSIGNMENT = "ADD_ASSIGNMENT", + ASSIGN_ASSIGNMENT_TO_STUDENT = "ASSIGN_ASSIGNMENT_TO_STUDENT", + GRADE_STUDENT_ASSIGNMENT = "GRADE_STUDENT_ASSIGNMENT", } export type SchoolAction = @@ -42,6 +66,21 @@ export type SchoolAction = teacherId: string; studentId: string; }; + } + | { + type: SchoolActionKind.ADD_ASSIGNMENT; + payload: Assignment; + } + | { + type: SchoolActionKind.ASSIGN_ASSIGNMENT_TO_STUDENT; + payload: StudentAssignment; + } + | { + type: SchoolActionKind.GRADE_STUDENT_ASSIGNMENT; + payload: { + id: string; + status: Exclude; + }; }; const SchoolContext = createContext(null); @@ -87,19 +126,44 @@ export function schoolReducer( } } return { ...state, students: updatedStudents }; - case SchoolActionKind.ASSIGN_STUDENT_TO_TEACHER: - const updatedTeacher: Teacher[] = []; - for (let t of state.teachers) { + case SchoolActionKind.ASSIGN_STUDENT_TO_TEACHER: { + const updatedTeachers: Teacher[] = []; + for (const t of state.teachers) { if (t.id === action.payload.teacherId) { - updatedTeacher.push({ + updatedTeachers.push({ ...t, students: [...t.students, action.payload.studentId], }); } else { - updatedTeacher.push(t); + updatedTeachers.push(t); } } - return { ...state, teachers: updatedTeacher }; + return { ...state, teachers: updatedTeachers }; + } + case SchoolActionKind.ADD_ASSIGNMENT: + return { + ...state, + assignments: [...state.assignments, action.payload], + }; + case SchoolActionKind.ASSIGN_ASSIGNMENT_TO_STUDENT: + return { + ...state, + studentAssignments: [...state.studentAssignments, action.payload], + }; + case SchoolActionKind.GRADE_STUDENT_ASSIGNMENT: { + const updatedStudentAssignments: StudentAssignment[] = []; + for (const sa of state.studentAssignments) { + if (sa.id === action.payload.id) { + updatedStudentAssignments.push({ + ...sa, + status: action.payload.status, + }); + } else { + updatedStudentAssignments.push(sa); + } + } + return { ...state, studentAssignments: updatedStudentAssignments }; + } default: return state; } @@ -108,4 +172,6 @@ export function schoolReducer( const initialState: InitialState = { teachers: [], students: [], + assignments: [], + studentAssignments: [], }; diff --git a/react/tests/app.test.tsx b/react/tests/app.test.tsx new file mode 100644 index 0000000..b053002 --- /dev/null +++ b/react/tests/app.test.tsx @@ -0,0 +1,38 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import App from "../src/App"; +import { SchoolProvider } from "../src/school-context"; + +function renderApp() { + return render( + + + + ); +} + +describe("App integration", () => { + it("allows creating entities and grading assignments", () => { + renderApp(); + + // Add a teacher + fireEvent.change(screen.getByLabelText(/teacher name/i), { + target: { value: "Teacher 1" }, + }); + fireEvent.click(screen.getByRole("button", { name: /add teacher/i })); + + // Add a student + fireEvent.change(screen.getByLabelText(/student name/i), { + target: { value: "Alice" }, + }); + fireEvent.click(screen.getByRole("button", { name: /add student/i })); + expect(screen.getByText("Alice")).toBeInTheDocument(); + + // Add an assignment + fireEvent.change(screen.getByLabelText(/new assignment/i), { + target: { value: "Math HW" }, + }); + fireEvent.click(screen.getByRole("button", { name: /add assignment/i })); + expect(screen.getByText("Math HW")).toBeInTheDocument(); + }); +}); +