diff --git a/package-lock.json b/package-lock.json index 3ef4561e..aa660f4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "@types/react-router-dom": "^5.3.3", "axios": "^1.4.0", "bootstrap": "^5.3.3", - "chart.js": "^3.7.0", + "chart.js": "^4.1.1", "formik": "^2.2.9", "jquery": "^3.7.1", "jwt-decode": "^3.1.2", @@ -38,7 +38,7 @@ "react-redux": "^8.0.5", "react-router-dom": "^6.11.1", "react-scripts": "^5.0.1", - "recharts": "^2.12.3", + "recharts": "^2.0.0", "redux-persist": "^6.0.0", "sass": "^1.62.1", "save": "^2.9.0", @@ -3045,6 +3045,11 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==" + }, "node_modules/@leichtgewicht/ip-codec": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", @@ -5760,9 +5765,15 @@ } }, "node_modules/chart.js": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.7.0.tgz", - "integrity": "sha512-31gVuqqKp3lDIFmzpKIrBeum4OpZsQjSIAqlOpgjosHDJZlULtvwLEZKtEhIAZc7JMPaHlYMys40Qy9Mf+1AAg==" + "version": "4.4.9", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.9.tgz", + "integrity": "sha512-EyZ9wWKgpAU0fLJ43YAEIF8sr5F2W3LqbS40ZJyHIner2lY14ufqv2VMp69MAiZ2rpwxEUxEhIH/0U3xyRynxg==", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } }, "node_modules/check-types": { "version": "11.2.3", @@ -16603,6 +16614,27 @@ "node": ">= 0.8" } }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", diff --git a/public/assets/icons/copy-temp.png b/public/assets/icons/copy-temp.png new file mode 100644 index 00000000..1a88fff3 Binary files /dev/null and b/public/assets/icons/copy-temp.png differ diff --git a/public/assets/icons/delete-temp.png b/public/assets/icons/delete-temp.png new file mode 100644 index 00000000..dff8fb65 Binary files /dev/null and b/public/assets/icons/delete-temp.png differ diff --git a/public/assets/icons/edit-temp.png b/public/assets/icons/edit-temp.png new file mode 100644 index 00000000..4ec0860c Binary files /dev/null and b/public/assets/icons/edit-temp.png differ diff --git a/public/assets/icons/export-temp.png b/public/assets/icons/export-temp.png new file mode 100644 index 00000000..8f8a6146 Binary files /dev/null and b/public/assets/icons/export-temp.png differ diff --git a/public/assets/images/Copy-icon-24.png b/public/assets/images/Copy-icon-24.png new file mode 100644 index 00000000..6d4f0eb0 Binary files /dev/null and b/public/assets/images/Copy-icon-24.png differ diff --git a/public/assets/images/add-ta-24.png b/public/assets/images/add-ta-24.png new file mode 100644 index 00000000..cf8e038e Binary files /dev/null and b/public/assets/images/add-ta-24.png differ diff --git a/public/assets/images/assign.png b/public/assets/images/assign.png new file mode 100644 index 00000000..4f55b8ae Binary files /dev/null and b/public/assets/images/assign.png differ diff --git a/public/assets/images/delete-icon-24.png b/public/assets/images/delete-icon-24.png new file mode 100644 index 00000000..57b6eb6c Binary files /dev/null and b/public/assets/images/delete-icon-24.png differ diff --git a/public/assets/images/edit-icon-24.png b/public/assets/images/edit-icon-24.png new file mode 100644 index 00000000..062d9c09 Binary files /dev/null and b/public/assets/images/edit-icon-24.png differ diff --git a/public/assets/images/paste.png b/public/assets/images/paste.png new file mode 100644 index 00000000..0585df93 Binary files /dev/null and b/public/assets/images/paste.png differ diff --git a/public/assets/images/pencil.png b/public/assets/images/pencil.png new file mode 100644 index 00000000..676ff53c Binary files /dev/null and b/public/assets/images/pencil.png differ diff --git a/public/assets/images/remove.png b/public/assets/images/remove.png new file mode 100644 index 00000000..2b4c3568 Binary files /dev/null and b/public/assets/images/remove.png differ diff --git a/src/components/Form/FormSelect.tsx b/src/components/Form/FormSelect.tsx index 363c3a62..3bc53d4d 100644 --- a/src/components/Form/FormSelect.tsx +++ b/src/components/Form/FormSelect.tsx @@ -8,7 +8,7 @@ import { IFormikFieldProps, IFormPropsWithOption } from "./interfaces"; * @author Ankur Mundra on May, 2023 */ -const FormSelect: React.FC = (props) => { +const FormSelect: React.FC) => void }> = (props) => { const { as, md, @@ -21,6 +21,7 @@ const FormSelect: React.FC = (props) => { tooltipPlacement, disabled, inputGroupPrepend, + onChange, // Add onChange to props to detect chnage in selected institutions. } = props; const displayLabel = tooltip ? ( @@ -48,6 +49,12 @@ const FormSelect: React.FC = (props) => { disabled={disabled} isInvalid={isInvalid} feedback={form.errors[field.name]} + onChange={(event) => { + field.onChange(event); // Call Formik's onChange + if (onChange) { + onChange(event); // Call the passed onChange if provided + } + }} > {options.map((option) => { return ( diff --git a/src/components/Form/interfaces.ts b/src/components/Form/interfaces.ts index 5300dad4..72af5ed7 100644 --- a/src/components/Form/interfaces.ts +++ b/src/components/Form/interfaces.ts @@ -25,6 +25,7 @@ export interface IFormOption { export interface IFormPropsWithOption extends IFormProps { options: IFormOption[]; + onChange?: (event: React.ChangeEvent) => void; } export interface IFormikFieldProps { diff --git a/src/components/Table/GlobalFilter.tsx b/src/components/Table/GlobalFilter.tsx index 2cc9c7ac..6dd73fc6 100644 --- a/src/components/Table/GlobalFilter.tsx +++ b/src/components/Table/GlobalFilter.tsx @@ -8,14 +8,23 @@ import DebouncedInput from "./DebouncedInput"; interface FilterProps { filterValue: string | number; setFilterValue: (value: string | number) => void; + isDisabled?: boolean; // New optional prop to disable the filter } -const GlobalFilter: React.FC = ({ filterValue, setFilterValue }) => { +const GlobalFilter: React.FC = ({ + filterValue, + setFilterValue, + isDisabled = true, // Default to true for disabling +}) => { const searchHandler = useCallback( (value: string | number) => setFilterValue(value), [setFilterValue] ); + if (isDisabled) { + return null; // Render nothing when disabled + } + return ( { @@ -31,6 +31,25 @@ const Assignments = () => { data?: IAssignmentResponse; }>({ visible: false }); + /** + * At this moment the backend has deviated substantially from what the frontend + * assignment creator provides. However, the backend also does not accept an instructor_id + * when creating an assignment which is a required field so there is no way to create an + * assignment using the frontend. This function is a placeholder to generate fake assignments + * until the backend is updated to allow for the creation of assignments. + */ + const generateFakeAssignments = useCallback(() => { + return Array.from({ length: 10 + Math.floor(Math.random() * 10) }, (_, idx) => ({ + id: idx + 1000, + name: "Fake Assignment " + (idx + 1), + description: "This is a fake assignment", + course_id: idx + 999, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + courseName: "Fake Course " + (idx + 1), + })); + }, []); + const fetchData = useCallback(async () => { try { @@ -58,9 +77,11 @@ const Assignments = () => { const course = coursesResponse.data.find((c: any) => c.id === assignment.course_id); return { ...assignment, courseName: course ? course.name : 'Unknown' }; }); + + const fakeAssignments = generateFakeAssignments(); + mergedData = mergedData.concat(fakeAssignments); } - - + // Error alert useEffect(() => { @@ -118,7 +139,6 @@ const Assignments = () => { columns={tableColumns} columnVisibility={{ id: false, - }} /> diff --git a/src/pages/Courses/Course.tsx b/src/pages/Courses/Course.tsx index d1e4db04..d3acb716 100644 --- a/src/pages/Courses/Course.tsx +++ b/src/pages/Courses/Course.tsx @@ -1,167 +1,219 @@ -import { Row as TRow } from "@tanstack/react-table"; -import Table from "components/Table/Table"; -import useAPI from "hooks/useAPI"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { Button, Col, Container, Row } from "react-bootstrap"; -import { RiHealthBookLine } from "react-icons/ri"; -import { useDispatch, useSelector } from "react-redux"; -import { Outlet, useLocation, useNavigate } from "react-router-dom"; -import { alertActions } from "store/slices/alertSlice"; -import { RootState } from "../../store/store"; -import { ICourseResponse, ROLE } from "../../utils/interfaces"; -import { courseColumns as COURSE_COLUMNS } from "./CourseColumns"; -import CopyCourse from "./CourseCopy"; -import DeleteCourse from "./CourseDelete"; -import { formatDate, mergeDataAndNames } from "./CourseUtil"; - -// Courses Component: Displays and manages courses, including CRUD operations. - -/** - * @author Atharva Thorve, on December, 2023 - * @author Mrityunjay Joshi on December, 2023 - */ -const Courses = () => { - const { error, isLoading, data: CourseResponse, sendRequest: fetchCourses } = useAPI(); - const { data: InstitutionResponse, sendRequest: fetchInstitutions } = useAPI(); - const auth = useSelector( - (state: RootState) => state.authentication, - (prev, next) => prev.isAuthenticated === next.isAuthenticated - ); - const navigate = useNavigate(); - const location = useLocation(); - const dispatch = useDispatch(); - - // State for delete and copy confirmation modals - const [showDeleteConfirmation, setShowDeleteConfirmation] = useState<{ - visible: boolean; - data?: ICourseResponse; - }>({ visible: false }); - - const [showCopyConfirmation, setShowCopyConfirmation] = useState<{ - visible: boolean; - data?: ICourseResponse; - }>({ visible: false }); - - useEffect(() => { - // ToDo: Fix this API in backend so that it the institution name along with the id. Similar to how it is done in users. - if (!showDeleteConfirmation.visible || !showCopyConfirmation.visible) { - fetchCourses({ url: `/courses` }); - // ToDo: Remove this API call later after the above ToDo is completed - fetchInstitutions({ url: `/institutions` }); - } - }, [ - fetchCourses, - fetchInstitutions, - location, - showDeleteConfirmation.visible, - auth.user.id, - showCopyConfirmation.visible, - ]); - - // Error alert for API errors - useEffect(() => { - if (error) { - dispatch(alertActions.showAlert({ variant: "danger", message: error })); - } - }, [error, dispatch]); - - // Callbacks for handling delete and copy confirmation modals - const onDeleteCourseHandler = useCallback( - () => setShowDeleteConfirmation({ visible: false }), - [] - ); - - const onCopyCourseHandler = useCallback(() => setShowCopyConfirmation({ visible: false }), []); - - // Callbacks for navigation and modal handling - const onEditHandle = useCallback( - (row: TRow) => navigate(`edit/${row.original.id}`), - [navigate] - ); - - const onTAHandle = useCallback( - (row: TRow) => navigate(`${row.original.id}/tas`), - [navigate] - ); - - const onDeleteHandle = useCallback( - (row: TRow) => - setShowDeleteConfirmation({ visible: true, data: row.original }), - [] - ); - - const onCopyHandle = useCallback( - (row: TRow) => setShowCopyConfirmation({ visible: true, data: row.original }), - [] - ); - - const tableColumns = useMemo( - () => COURSE_COLUMNS(onEditHandle, onDeleteHandle, onTAHandle, onCopyHandle), - [onDeleteHandle, onEditHandle, onTAHandle, onCopyHandle] - ); - - let tableData = useMemo( - () => (isLoading || !CourseResponse?.data ? [] : CourseResponse.data), - [CourseResponse?.data, isLoading] - ); - - const institutionData = useMemo( - () => (isLoading || !InstitutionResponse?.data ? [] : InstitutionResponse.data), - [InstitutionResponse?.data, isLoading] - ); - - tableData = mergeDataAndNames(tableData, institutionData); - - const formattedTableData = tableData.map((item: any) => ({ - ...item, - created_at: formatDate(item.created_at), - updated_at: formatDate(item.updated_at), - })); - - // Render the Courses component - - return ( - <> - -
- - - -

Manage Courses

- -
-
- - - - - {showDeleteConfirmation.visible && ( - - )} - {showCopyConfirmation.visible && ( - - )} - - - - - - - - ); -}; - -export default Courses; +import { Row as TRow } from "@tanstack/react-table"; +import Table from "components/Table/Table"; +import useAPI from "hooks/useAPI"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Button, Col, Container, Row } from "react-bootstrap"; +import { RiHealthBookLine } from "react-icons/ri"; +import { useDispatch, useSelector } from "react-redux"; +import { Outlet, useLocation, useNavigate } from "react-router-dom"; +import { alertActions } from "store/slices/alertSlice"; +import { RootState } from "../../store/store"; +import { ICourseResponse, ROLE } from "../../utils/interfaces"; +import { courseColumns as COURSE_COLUMNS } from "./CourseColumns"; +import CopyCourse from "./CourseCopy"; +import DeleteCourse from "./CourseDelete"; +import { formatDate, mergeDataAndNames } from "./CourseUtil"; + +// Courses Component: Displays and manages courses, including CRUD operations. + +/** + * @author Atharva Thorve, on December, 2023 + * @author Mrityunjay Joshi on December, 2023 + */ + +const Courses = () => { + const { error, isLoading, data: CourseResponse, sendRequest: fetchCourses } = useAPI(); + const { data: InstitutionResponse, sendRequest: fetchInstitutions } = useAPI(); + const { data: InstructorResponse, sendRequest: fetchInstructors } = useAPI(); + const auth = useSelector( + (state: RootState) => state.authentication, + (prev, next) => prev.isAuthenticated === next.isAuthenticated + ); + const navigate = useNavigate(); + const location = useLocation(); + const dispatch = useDispatch(); + + // State for course details modal + const [showDetailsModal, setShowDetailsModal] = useState(false); + const [selectedCourse, setSelectedCourse] = useState(null); + + const [showDeleteConfirmation, setShowDeleteConfirmation] = useState<{ + visible: boolean; + data?: ICourseResponse; + }>({ visible: false }); + + const [showCopyConfirmation, setShowCopyConfirmation] = useState<{ + visible: boolean; + data?: ICourseResponse; + }>({ visible: false }); + + // Utility function to handle modals + const showModal = ( + setModalState: React.Dispatch>, + setData?: (data: ICourse | null) => void, + data?: ICourse + ) => { + if (setData) { + setData(data || null); + } + setModalState(true); + }; + + const handleShowDetails = (course: ICourse) => + showModal(setShowDetailsModal, setSelectedCourse, course); + + useEffect(() => { + // Ensure the API fetch happens unless modals are active + if (!showDeleteConfirmation.visible || !showCopyConfirmation.visible) { + fetchCourses({ url: `/courses` }); + fetchInstitutions({ url: `/institutions` }); + fetchInstructors({ url: `/users` }); + } + }, [ + fetchCourses, + fetchInstitutions, + fetchInstructors, + location, + showDeleteConfirmation.visible, + auth.user.id, + showCopyConfirmation.visible, + ]); + + useEffect(() => { + if (error) { + dispatch(alertActions.showAlert({ variant: "danger", message: error })); + } + }, [error, dispatch]); + + const onDeleteCourseHandler = useCallback( + () => setShowDeleteConfirmation({ visible: false }), + [] + ); + + const onCopyCourseHandler = useCallback( + () => setShowCopyConfirmation({ visible: false }), + [] + ); + + const onEditHandle = useCallback( + (row: TRow) => navigate(`edit/${row.original.id}`), + [navigate] + ); + + const onTAHandle = useCallback( + (row: TRow) => navigate(`${row.original.id}/tas`), + [navigate] + ); + + const onDeleteHandle = useCallback( + (row: TRow) => + setShowDeleteConfirmation({ visible: true, data: row.original }), + [] + ); + + const onCopyHandle = useCallback( + (row: TRow) => + setShowCopyConfirmation({ visible: true, data: row.original }), + [] + ); + + const renderSubComponent = useCallback(({ row }: { row: TRow }) => { + return ( + + ); + }, []); + + const tableColumns = useMemo( + () => + COURSE_COLUMNS(onEditHandle, onDeleteHandle, onTAHandle, onCopyHandle), + [onDeleteHandle, onEditHandle, onTAHandle, onCopyHandle] + ); + + let tableData = useMemo( + () => (isLoading || !CourseResponse?.data ? [] : CourseResponse.data), + [CourseResponse?.data, isLoading] + ); + + const institutionData = useMemo( + () => (isLoading || !InstitutionResponse?.data ? [] : InstitutionResponse.data), + [InstitutionResponse?.data, isLoading] + ); + + const instructorData = useMemo( + () => (isLoading || !InstructorResponse?.data ? [] : InstructorResponse.data), + [InstructorResponse?.data, isLoading] + ); + + const formattedTableData = tableData.map((item: any) => ({ + ...item, + created_at: formatDate(item.created_at), + updated_at: formatDate(item.updated_at), + })); + + // Render the Courses component + + return ( + <> + +
+ + +
+

+ {auth.user.role === ROLE.INSTRUCTOR.valueOf() ? ( + <>Instructed by: {auth.user.full_name} + ) : auth.user.role === ROLE.TA.valueOf() ? ( + <>Assisted by: {auth.user.full_name} + ) : ( + <>Manage Courses + )} +

+ + + + + + + + + + + {showDeleteConfirmation.visible && ( + + )} + {showCopyConfirmation.visible && ( + + )} + + +
true} + /> + + + + + ); +}; + +export default Courses; \ No newline at end of file diff --git a/src/pages/Courses/CourseAssignments.test.tsx b/src/pages/Courses/CourseAssignments.test.tsx new file mode 100644 index 00000000..7a3bbdaa --- /dev/null +++ b/src/pages/Courses/CourseAssignments.test.tsx @@ -0,0 +1,72 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { BrowserRouter } from 'react-router-dom'; +import CourseAssignments from './CourseAssignments'; + +const renderWithRouter = (component: React.ReactNode) => { + return render( + + {component} + + ); +}; + +describe('CourseAssignments', () => { + const mockCourseId = 101; + const mockCourseName = 'Test Course'; + + it('renders the component correctly', () => { + renderWithRouter(); + + // Check if the course name is displayed + expect(screen.getByText(`Assignments for ${mockCourseName}`)).toBeInTheDocument(); + + // Check if the table renders + const table = screen.getByRole('table'); + expect(table).toBeInTheDocument(); + }); + + it('renders assignments in the table', () => { + renderWithRouter(); + + // Check for table rows (excluding header row) + const rows = screen.getAllByRole('row'); + expect(rows.length).toBeGreaterThan(1); // Header + assignment rows + }); + + it('triggers edit and delete actions correctly', async () => { + // Spy on console.log to check if handlers are called + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + + renderWithRouter(); + + // Find and verify buttons + const editButtons = screen.getAllByRole('button', { name: /edit/i }); + const deleteButtons = screen.getAllByRole('button', { name: /delete/i }); + + expect(editButtons).toHaveLength(4); // Adjust based on your table rows + expect(deleteButtons).toHaveLength(4); + + // Trigger clicks + await userEvent.click(editButtons[0]); + await userEvent.click(deleteButtons[0]); + + // Check exact console log outputs + const firstAssignment = { + id: expect.any(Number), + name: expect.stringContaining('Assignment 1'), + courseName: mockCourseName, + description: expect.any(String), + created_at: expect.any(String), + updated_at: expect.any(String), + }; + + expect(consoleSpy).toHaveBeenCalledWith('Edit assignment:', expect.objectContaining(firstAssignment)); + expect(consoleSpy).toHaveBeenCalledWith('Delete assignment:', expect.objectContaining(firstAssignment)); + + // Clean up mock + consoleSpy.mockRestore(); + }); + +}); diff --git a/src/pages/Courses/CourseAssignments.tsx b/src/pages/Courses/CourseAssignments.tsx new file mode 100644 index 00000000..eacd5c78 --- /dev/null +++ b/src/pages/Courses/CourseAssignments.tsx @@ -0,0 +1,165 @@ +import { Row as TRow } from "@tanstack/react-table"; +import Table from "components/Table/Table"; +import React from 'react'; +import { assignmentColumns as getBaseAssignmentColumns } from "../Assignments/AssignmentColumns"; + +interface ActionHandler { + icon: string; + label: string; + handler: (row: TRow) => void; + className?: string; +} + +interface CourseAssignmentsProps { + courseId: number; + courseName: string; +} + +const CourseAssignments: React.FC = ({ courseId, courseName }) => { + const actionHandlers: ActionHandler[] = [ + { + icon: '/assets/icons/edit-temp.png', + label: 'Edit', + handler: (row: TRow) => { + console.log('Edit assignment:', row.original); + }, + className: 'text-primary' + }, + { + icon: '/assets/icons/delete-temp.png', + label: 'Delete', + handler: (row: TRow) => { + console.log('Delete assignment:', row.original); + }, + className: 'text-danger' + }, + { + icon: '/assets/icons/add-participant-24.png', + label: 'Add Participant', + handler: (row: TRow) => { + console.log('Add participant to assignment:', row.original); + }, + className: 'text-success' + }, + { + icon: '/assets/icons/assign-reviewers-24.png', + label: 'Assign Reviewers', + handler: (row: TRow) => { + console.log('Assign reviewers for:', row.original); + }, + className: 'text-info' + }, + { + icon: '/assets/icons/create-teams-24.png', + label: 'Create Teams', + handler: (row: TRow) => { + console.log('Create teams for:', row.original); + }, + className: 'text-primary' + }, + { + icon: '/assets/icons/view-review-report-24.png', + label: 'View Review Report', + handler: (row: TRow) => { + console.log('View review report:', row.original); + }, + className: 'text-secondary' + }, + { + icon: '/assets/icons/view-scores-24.png', + label: 'View Scores', + handler: (row: TRow) => { + console.log('View scores:', row.original); + }, + className: 'text-info' + }, + { + icon: '/assets/icons/view-submissions-24.png', + label: 'View Submissions', + handler: (row: TRow) => { + console.log('View submissions:', row.original); + }, + className: 'text-secondary' + }, + { + icon: '/assets/icons/copy-temp.png', + label: 'Copy Assignment', + handler: (row: TRow) => { + console.log('Copy assignment:', row.original); + }, + className: 'text-success' + }, + { + icon: '/assets/icons/export-temp.png', + label: 'Export', + handler: (row: TRow) => { + console.log('Export assignment:', row.original); + }, + className: 'text-primary' + } + ]; + + const generateFakeAssignments = () => { + const numAssignments = 3 + Math.floor(Math.random() * 3); + return Array.from({ length: numAssignments }, (_, idx) => ({ + id: parseInt(`${courseId}${idx}`), + name: `Assignment ${idx + 1} for ${courseName}`, + courseName: courseName, + description: "This is a fake assignment", + created_at: new Date(Date.now() - Math.random() * 10000000000).toISOString(), + updated_at: new Date().toISOString(), + })); + }; + + const getAssignmentColumns = (actions: ActionHandler[]) => { + const baseColumns = getBaseAssignmentColumns(() => {}, () => {}).filter(col => + !['edit', 'delete', 'actions'].includes(String(col.id)) + ); + + const actionsColumn = { + id: 'actions', + header: 'Actions', + cell: ({ row }: { row: TRow }) => ( +
+ {actions.map((action, index) => ( + + ))} +
+ ) + }; + + return [...baseColumns, actionsColumn]; + }; + + const assignments = generateFakeAssignments(); + const columns = getAssignmentColumns(actionHandlers); + + return ( +
+
Assignments for {courseName}
+
+ + ); +}; + +export default CourseAssignments; \ No newline at end of file diff --git a/src/pages/Courses/CourseColumns.tsx b/src/pages/Courses/CourseColumns.tsx index 25002d6f..4aeb3393 100644 --- a/src/pages/Courses/CourseColumns.tsx +++ b/src/pages/Courses/CourseColumns.tsx @@ -1,81 +1,185 @@ -import { createColumnHelper, Row } from "@tanstack/react-table"; -import { Button } from "react-bootstrap"; -import { BsPencilFill, BsPersonXFill } from "react-icons/bs"; -import { MdContentCopy, MdDelete } from "react-icons/md"; -import { ICourseResponse as ICourse } from "../../utils/interfaces"; - -/** - * @author Atharva Thorve, on December, 2023 - * @author Mrityunjay Joshi on December, 2023 - */ - -// Course Columns Configuration: Defines the columns for the courses table -type Fn = (row: Row) => void; -const columnHelper = createColumnHelper(); -export const courseColumns = (handleEdit: Fn, handleDelete: Fn, handleTA: Fn, handleCopy: Fn) => [ - // Column for the course name - columnHelper.accessor("name", { - id: "name", - header: "Name", - enableSorting: true, - enableColumnFilter: true, - enableGlobalFilter: false, - }), - - // Column for the institution name - columnHelper.accessor("institution.name", { - id: "institution", - header: "Institution", - enableSorting: true, - enableMultiSort: true, - enableGlobalFilter: false, - }), - - // Column for the creation date - columnHelper.accessor("created_at", { - header: "Creation Date", - enableSorting: true, - enableColumnFilter: false, - enableGlobalFilter: false, - }), - - // Column for the last updated date - columnHelper.accessor("updated_at", { - header: "Updated Date", - enableSorting: true, - enableColumnFilter: false, - enableGlobalFilter: false, - }), - - // Actions column with edit, delete, TA, and copy buttons - columnHelper.display({ - id: "actions", - header: "Actions", - cell: ({ row }) => ( - <> - - - - - - ), - }), -]; +import { createColumnHelper, Row } from "@tanstack/react-table"; +import { Button, Tooltip, OverlayTrigger, Badge } from "react-bootstrap"; +import { ICourseResponse as ICourse } from "../../utils/interfaces"; + + +type Fn = (row: Row) => void; + +const columnHelper = createColumnHelper(); + +export const courseColumns = ( + handleEdit: Fn, + handleDelete: Fn, + handleTA: Fn, + handleCopy: Fn +) => [ + columnHelper.accessor("name", { + id: "name", + header: () => ( + + Course Name + + ), + cell: (info) => ( +
+ {info.getValue()} +
+ ), + enableSorting: true, + enableColumnFilter: true, + enableGlobalFilter: true, + }), + + columnHelper.accessor("instructor.name", { + id: "instructor", + header: () => ( + + Instructor + + ), + cell: ({ row }) => { + const instructor = row.original.instructor; + return ( +
+ + {instructor && instructor.name ? ( + instructor.name + ) : ( + Unassigned + )} + +
+ ); + }, + enableSorting: true, + enableColumnFilter: true, + enableGlobalFilter: true, + }), + + columnHelper.accessor("created_at", { + header: () => ( + + Creation Date + + ), + cell: (info) => ( +
+ + {new Date(info.getValue()).toLocaleDateString() || ( + N/A + )} + +
+ ), + enableSorting: true, + enableColumnFilter: true, + enableGlobalFilter: true, + }), + + columnHelper.accessor("updated_at", { + header: () => ( + + Updated Date + + ), + cell: (info) => ( +
+ + {new Date(info.getValue()).toLocaleDateString() || ( + N/A + )} + +
+ ), + enableSorting: true, + enableColumnFilter: true, + enableGlobalFilter: false, + }), + + columnHelper.display({ + id: "actions", + header: () => ( + + Actions + + ), + cell: ({ row }) => ( +
+ Edit Course}> + + + + Delete Course}> + + + + Assign TA}> + + + + Copy Course}> + + +
+ ), + }), +]; diff --git a/src/pages/Courses/CourseCopy.tsx b/src/pages/Courses/CourseCopy.tsx index 35e61f16..ae250e03 100644 --- a/src/pages/Courses/CourseCopy.tsx +++ b/src/pages/Courses/CourseCopy.tsx @@ -1,15 +1,11 @@ import React, { useEffect, useState } from "react"; -import { Button, Modal } from "react-bootstrap"; +import { Button, Modal, Spinner, Alert } from "react-bootstrap"; import { useDispatch } from "react-redux"; import { alertActions } from "store/slices/alertSlice"; import { HttpMethod } from "utils/httpMethods"; import useAPI from "../../hooks/useAPI"; import { ICourseResponse as ICourse } from "../../utils/interfaces"; -/** - * @author Atharva Thorve, on December, 2023 - * @author Mrityunjay Joshi on December, 2023 - */ // CopyCourse Component: Modal for copying a course. @@ -20,17 +16,24 @@ interface ICopyCourse { const CopyCourse: React.FC = ({ courseData, onClose }) => { // State and hook declarations - const { data: copiedCourse, error: courseError, sendRequest: CopyCourse } = useAPI(); + const { data: copiedCourse, error: courseError, sendRequest: copyCourseRequest } = useAPI(); const [show, setShow] = useState(true); + const [isCopying, setIsCopying] = useState(false); // State to track copying process const dispatch = useDispatch(); + const courseId = courseData.id; // Function to initiate the course copy process - const copyHandler = () => - CopyCourse({ url: `/courses/${courseData.id}/copy`, method: HttpMethod.GET }); + const copyHandler = () => { + setIsCopying(true); // Set copying state to true + copyCourseRequest({ url: `/courses/${courseId}/copy`, method: HttpMethod.GET });//Applying Interface Segregation principle to use only courseId instead of the whole object + }; // Show error if any useEffect(() => { - if (courseError) dispatch(alertActions.showAlert({ variant: "danger", message: courseError })); + if (courseError) { + dispatch(alertActions.showAlert({ variant: "danger", message: courseError })); + setIsCopying(false); // Reset copying state on error + } }, [courseError, dispatch]); // Close modal if course is copied @@ -55,25 +58,31 @@ const CopyCourse: React.FC = ({ courseData, onClose }) => { // Render the CopyCourse modal return ( - + Copy Course

- Are you sure you want to copy course {courseData.name}? + Are you sure you want to copy the course {courseData.name}?

+
+ {isCopying && } + {courseError && {courseError}} {/* Display error message */} +
+ -
); }; -export default CopyCourse; + +export default CopyCourse; \ No newline at end of file diff --git a/src/pages/Courses/CourseDelete.tsx b/src/pages/Courses/CourseDelete.tsx index d5417697..be3023e7 100644 --- a/src/pages/Courses/CourseDelete.tsx +++ b/src/pages/Courses/CourseDelete.tsx @@ -6,10 +6,6 @@ import { HttpMethod } from "utils/httpMethods"; import useAPI from "../../hooks/useAPI"; import { ICourseResponse as ICourse } from "../../utils/interfaces"; -/** - * @author Atharva Thorve, on December, 2023 - * @author Mrityunjay Joshi on December, 2023 - */ // DeleteCourse Component: Modal for deleting a course @@ -19,7 +15,7 @@ interface IDeleteCourse { } const DeleteCourse: React.FC = ({ courseData, onClose }) => { - // State and hook declarations + const { data: deletedCourse, error: courseError, sendRequest: DeleteCourse } = useAPI(); const [show, setShow] = useState(true); const dispatch = useDispatch(); @@ -32,18 +28,22 @@ const DeleteCourse: React.FC = ({ courseData, onClose }) => { useEffect(() => { if (courseError) dispatch(alertActions.showAlert({ variant: "danger", message: courseError })); }, [courseError, dispatch]); - + + //Added this method to be called in below and achieve LSP + const handleDeleteSuccess = () => { + setShow(false); + dispatch( + alertActions.showAlert({ + variant: "success", + message: `Course ${courseData.name} deleted successfully!`, + }) + ); + onClose(); + }; // Close modal if course is deleted useEffect(() => { if (deletedCourse?.status && deletedCourse?.status >= 200 && deletedCourse?.status < 300) { - setShow(false); - dispatch( - alertActions.showAlert({ - variant: "success", - message: `Course ${courseData.name} deleted successfully!`, - }) - ); - onClose(); + handleDeleteSuccess(); } }, [deletedCourse?.status, dispatch, onClose, courseData.name]); @@ -55,7 +55,7 @@ const DeleteCourse: React.FC = ({ courseData, onClose }) => { // Render the DeleteCourse modal return ( - + Delete Course @@ -76,4 +76,4 @@ const DeleteCourse: React.FC = ({ courseData, onClose }) => { ); }; -export default DeleteCourse; +export default DeleteCourse; \ No newline at end of file diff --git a/src/pages/Courses/CourseEditor.tsx b/src/pages/Courses/CourseEditor.tsx index ae6104bb..c02a12da 100644 --- a/src/pages/Courses/CourseEditor.tsx +++ b/src/pages/Courses/CourseEditor.tsx @@ -3,11 +3,11 @@ import FormInput from "components/Form/FormInput"; import FormSelect from "components/Form/FormSelect"; import { Form, Formik, FormikHelpers } from "formik"; import useAPI from "hooks/useAPI"; -import React, { useEffect } from "react"; +import React, { useEffect, useState } from "react"; import { Button, InputGroup, Modal } from "react-bootstrap"; import { useDispatch, useSelector } from "react-redux"; import { useLoaderData, useLocation, useNavigate } from "react-router-dom"; -import { alertActions } from "store/slices/alertSlice"; +import { alertActions } from "store/slices/alertSlice"; // Success message utility import { HttpMethod } from "utils/httpMethods"; import * as Yup from "yup"; import { RootState } from "../../store/store"; @@ -15,17 +15,19 @@ import { IEditor, ROLE } from "../../utils/interfaces"; import { ICourseFormValues, courseVisibility, noSpacesSpecialCharsQuotes, transformCourseRequest } from "./CourseUtil"; /** - * @author Atharva Thorve, on December, 2023 - * @author Mrityunjay Joshi, on December, 2023 + * @author Suraj Raghu Kumar, on Oct, 2024 + * @author Yuktasree Muppala on Oct, 2024 + * @author Harvardhan Patil on Oct, 2024 */ + -// CourseEditor Component: Modal for creating or updating a course. +// Initial form values const initialValues: ICourseFormValues = { name: "", directory: "", private: [], - institution_id: -1, - instructor_id: -1, + institution_id: 0, + instructor_id: 0, info: "", }; @@ -44,37 +46,89 @@ const validationSchema = Yup.object({ }); const CourseEditor: React.FC = ({ mode }) => { - - // API hook for making requests const { data: courseResponse, error: courseError, sendRequest } = useAPI(); + const { data: users, sendRequest: fetchusers } = useAPI(); const auth = useSelector( (state: RootState) => state.authentication, (prev, next) => prev.isAuthenticated === next.isAuthenticated ); - const { courseData, institutions, instructors }: any = useLoaderData(); + const { courseData, institutions }: any = useLoaderData(); const dispatch = useDispatch(); const navigate = useNavigate(); const location = useLocation(); + console.log(courseData) + interface IFormOption { + label: string; + value: string; + } - initialValues.institution_id = auth.user.institution_id; + const [filteredInstructors, setFilteredInstructors] = useState([]); + const [selectedInstitutionId, setSelectedInstitutionId] = useState(null); - // Close the modal if the course is updated successfully and navigate to the courses page + // Fetch all users or restrict based on the logged-in role useEffect(() => { - if (courseResponse && courseResponse.status >= 200 && courseResponse.status < 300) { - dispatch( - alertActions.showAlert({ - variant: "success", - message: `Course ${courseData.name} ${mode}d successfully!`, - }) - ); - navigate(location.state?.from ? location.state.from : "/courses"); + if (auth.user.role === ROLE.INSTRUCTOR.valueOf()) { + setSelectedInstitutionId(auth.user.institution_id); + setFilteredInstructors([ + { label: auth.user.name, value: String(auth.user.id) }, + ]); + } else { + fetchusers({ url: "/users" }); } - }, [dispatch, mode, navigate, courseData.name, courseResponse, location.state?.from]); - - // Show the error message if the course is not updated successfully + }, [auth.user, fetchusers]); + + + // Filter instructors based on selected institution useEffect(() => { - courseError && dispatch(alertActions.showAlert({ variant: "danger", message: courseError })); - }, [courseError, dispatch]); + + if (users) { + const instructorsList: IFormOption[] = [{ label: 'Select an Instructor', value: '' }]; + + // Filter by instructors by institution + const onlyInstructors = users.data.filter((user: any) => + (user.role.name === 'Instructor')&& (user.institution.id === selectedInstitutionId)); + //console.log('Users:', users.data) + onlyInstructors.forEach((instructor: any) => { + instructorsList.push({ label: instructor.name, value: String(instructor.id) }); + }); + + setFilteredInstructors(instructorsList); + + } + }, [users, selectedInstitutionId]); // Re-run this effect when users or selectedInstitutionId changes + + + // Handle institution selection change +const handleInstitutionChange = (event: React.ChangeEvent) => { + const institutionId = Number(event.target.value); + setSelectedInstitutionId(institutionId); +}; +// Success handler for course submission +const handleCourseSuccess = () => { + if (courseResponse && courseResponse.status >= 200 && courseResponse.status < 300) { + dispatch( + alertActions.showAlert({ + variant: "success", + message: `Course ${courseData.name} ${mode}d successfully!`, + }) + ); + navigate(location.state?.from ? location.state.from : "/courses"); + } +}; +// Error handler for course submission +const handleCourseError = () => { + if (courseError) { + dispatch(alertActions.showAlert({ variant: "danger", message: courseError })); + } +}; +// useEffect to monitor success response +useEffect(() => { + handleCourseSuccess(); +}, [courseResponse]); +// useEffect to monitor error response +useEffect(() => { + handleCourseError(); +}, [courseError]); // Function to handle form submission const onSubmit = (values: ICourseFormValues, submitProps: FormikHelpers) => { @@ -87,19 +141,22 @@ const CourseEditor: React.FC = ({ mode }) => { } // to be used to display message when course is created + courseData.name = values.name; + sendRequest({ url: url, method: method, data: values, transformRequest: transformCourseRequest, }); + submitProps.setSubmitting(false); }; // Function to close the modal + console.log(filteredInstructors) const handleClose = () => navigate(location.state?.from ? location.state.from : "/courses"); - // Render the CourseEditor modal return ( @@ -108,14 +165,37 @@ const CourseEditor: React.FC = ({ mode }) => { {courseError &&

{courseError}

} + {(formik) => { + return (
= ({ mode }) => { inputGroupPrepend={ Institution } + + onChange={handleInstitutionChange} // Add onChange to handle institution selection /> Instructors - } - /> + controlId="course-instructor" + name="instructor_id" + disabled={mode === "update" || auth.user.role !== ROLE.SUPER_ADMIN.valueOf()} + options={ + mode === "update" && courseData?.instructor_id && auth.user.role == ROLE.SUPER_ADMIN.valueOf() + ? [ + { + label: users?.data.find((user: any) => String(user.id) === String(courseData.instructor_id))?.name, + value: String(courseData.instructor_id) + }, + ...filteredInstructors + ] + : filteredInstructors + } + inputGroupPrepend={ + Instructors + } +/> = ({ mode }) => {
); }; - -export default CourseEditor; +export default CourseEditor; \ No newline at end of file diff --git a/src/pages/Courses/CourseUtil.ts b/src/pages/Courses/CourseUtil.ts index 5c546257..c676f1b9 100644 --- a/src/pages/Courses/CourseUtil.ts +++ b/src/pages/Courses/CourseUtil.ts @@ -1,12 +1,14 @@ import { IFormOption } from "components/Form/interfaces"; import { getPrivilegeFromID, hasAllPrivilegesOf } from "utils/util"; import axiosClient from "../../utils/axios_client"; -import { ICourseRequest, ICourseResponse, IInstitution, IInstitutionResponse, IInstructor, IUserRequest, ROLE } from "../../utils/interfaces"; +import { ICourseRequest, ICourseResponse, IInstitution, IInstitutionResponse,IInstructorResponse, IInstructor, IUserRequest, ROLE } from "../../utils/interfaces"; /** - * @author Atharva Thorve, on December, 2023 - * @author Mrityunjay Joshi, on December, 2023 + * @author Aniket Singh Shaktawat, on March, 2024 + * @author Pankhi Saini on March, 2024 + * @author Siddharth Shah on March, 2024 */ + // Course Utility Functions and Constants // Enumeration for course visibility options @@ -105,8 +107,9 @@ export async function loadCourseInstructorDataAndInstitutions({ params }: any) { transformResponse: transformInstructorResponse, }); const users = await usersResponse.data; - - const instructors = users.filter((user: IUserRequest) => !hasAllPrivilegesOf(getPrivilegeFromID(user.role_id), ROLE.TA)); + console.log(users.role_id) + console.log(courseData) + const instructors = users.filter((user: IUserRequest) => !hasAllPrivilegesOf(getPrivilegeFromID(user.role_id), ROLE.INSTRUCTOR)); return { courseData, institutions, instructors } } @@ -145,21 +148,22 @@ export const formatDate = (dateString: string): string => { return new Intl.DateTimeFormat('en-US', options).format(date); }; -// Function to merge data and names -export const mergeDataAndNames = (data: ICourseResponse[], names: IInstitutionResponse[]): any => { +// Function to merge course data with their respective institution and instructor data +export const mergeDataAndNamesAndInstructors = (data: ICourseResponse[], institutionNames: IInstitutionResponse[], instructorNames: IInstructorResponse[]): any => { return data.map((dataObj) => { - const matchingNameObject = names.find((nameObj) => nameObj.id === dataObj.institution_id); - - if (matchingNameObject) { - return { - ...dataObj, - institution: { - id: matchingNameObject.id, - name: matchingNameObject.name, - }, - }; - } - - return dataObj; + // Merge institution data + const matchingInstitution = institutionNames.find((nameObj) => nameObj.id === dataObj.institution_id); + const institutionData = matchingInstitution ? { id: matchingInstitution.id, name: matchingInstitution.name } : {}; + + // Merge instructor data + const matchingInstructor = instructorNames.find((instructorObj) => instructorObj.id === dataObj.instructor_id); + const instructorData = matchingInstructor ? { id: matchingInstructor.id, name: matchingInstructor.name } : {}; + + // Merge course data with institution and instructor data + return { + ...dataObj, + institution: institutionData, + instructor: instructorData + }; }); -}; \ No newline at end of file +}; diff --git a/src/pages/ViewTeamGrades/grades.scss b/src/pages/ViewTeamGrades/grades.scss index e422e64f..d486e7be 100644 --- a/src/pages/ViewTeamGrades/grades.scss +++ b/src/pages/ViewTeamGrades/grades.scss @@ -231,10 +231,8 @@ .container { display: flex; - justify-content: space-between; - /* Adjust as needed */ - width: 80%; - /* Ensure the container takes up the full width */ + justify-content: space-between; /* Adjust as needed */ + width: 80%; /* Ensure the container takes up the full width */ } diff --git a/src/utils/interfaces.ts b/src/utils/interfaces.ts index 213909c9..c1ce8d2c 100644 --- a/src/utils/interfaces.ts +++ b/src/utils/interfaces.ts @@ -142,6 +142,11 @@ export interface IInstitutionResponse { name: string; } +export interface IInstructorResponse { + id: number; + name: string; +} + export enum ROLE { SUPER_ADMIN = "Super Administrator", ADMIN = "Administrator",