Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import Login from "./pages/Authentication/Login";
import Logout from "./pages/Authentication/Logout";
import Courses from "./pages/Courses/Course";
import CourseEditor from "./pages/Courses/CourseEditor";
import CourseReport from "./pages/Courses/CourseReport";
import { loadCourseInstructorDataAndInstitutions } from "./pages/Courses/CourseUtil";
import Questionnaire from "./pages/Questionnaires/Questionnaire";
import QuestionnaireEditor from "./pages/Questionnaires/QuestionnaireEditor";
Expand Down Expand Up @@ -341,6 +342,10 @@ function App() {
},
],
},
{
path: ":courseId/class_assignment_overview",
element: <ProtectedRoute element={<CourseReport />} leastPrivilegeRole={ROLE.TA} />,
},
],
},

Expand Down
5 changes: 4 additions & 1 deletion src/components/Table/Table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@ interface TableProps {
data: Record<string, any>[];
columns: ColumnDef<any, any>[];
disableGlobalFilter?: boolean;
disablePaginationRowModel?: boolean;
showGlobalFilter?: boolean;
showColumnFilter?: boolean;
showPagination?: boolean;
disablePaginationRowModel?: boolean;
Comment on lines +27 to +31
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify duplicate declaration count/location
rg -n 'disablePaginationRowModel\?: boolean;' src/components/Table/Table.tsx

Repository: expertiza/reimplementation-front-end

Length of output: 164


Remove the duplicate disablePaginationRowModel property declaration in TableProps.

Lines 27 and 31 both declare the same property, which will cause a TypeScript compile-time error.

Proposed fix
 interface TableProps {
   data: Record<string, any>[];
   columns: ColumnDef<any, any>[];
   disableGlobalFilter?: boolean;
   disablePaginationRowModel?: boolean;
   showGlobalFilter?: boolean;
   showColumnFilter?: boolean;
   showPagination?: boolean;
-  disablePaginationRowModel?: boolean;
   tableSize?: { span: number; offset: number };
   columnVisibility?: Record<string, boolean>;
   onSelectionChange?: (selectedData: Record<any, any>[]) => void;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
disablePaginationRowModel?: boolean;
showGlobalFilter?: boolean;
showColumnFilter?: boolean;
showPagination?: boolean;
disablePaginationRowModel?: boolean;
disablePaginationRowModel?: boolean;
showGlobalFilter?: boolean;
showColumnFilter?: boolean;
showPagination?: boolean;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Table/Table.tsx` around lines 27 - 31, TableProps currently
declares disablePaginationRowModel twice which causes a TypeScript duplicate
identifier error; remove the redundant declaration so disablePaginationRowModel
only appears once in the TableProps interface (leave the original declaration
and delete the duplicate among the other props like showGlobalFilter,
showColumnFilter, and showPagination). Ensure the remaining single
disablePaginationRowModel has the intended optional boolean type.

tableSize?: { span: number; offset: number };
columnVisibility?: Record<string, boolean>;
onSelectionChange?: (selectedData: Record<any, any>[]) => void;
Expand All @@ -42,6 +44,7 @@ const Table: React.FC<TableProps> = ({
showGlobalFilter = false,
showColumnFilter = true,
showPagination = true,
disablePaginationRowModel = false,
onSelectionChange,
onRowClick,
columnVisibility = {},
Expand Down Expand Up @@ -141,7 +144,7 @@ const Table: React.FC<TableProps> = ({
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getPaginationRowModel: disablePaginationRowModel ? undefined : getPaginationRowModel(),
getExpandedRowModel: getExpandedRowModel(),
});

Expand Down
136 changes: 136 additions & 0 deletions src/pages/Courses/Course.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import "@testing-library/jest-dom";
import { render, screen, waitFor } from "@testing-library/react";
import Courses from "./Course";

const mockUseLocation = vi.fn();
const mockNavigate = vi.fn();
const mockDispatch = vi.fn();
const mockUseAPI = vi.fn();
const mockTable = vi.fn();

vi.mock("react-router-dom", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router-dom")>()),
useNavigate: () => mockNavigate,
useLocation: () => mockUseLocation(),
Outlet: () => <div data-testid="courses-outlet" />,
}));

vi.mock("react-redux", () => ({
useDispatch: () => mockDispatch,
useSelector: (selector: any) =>
selector({
authentication: {
isAuthenticated: true,
user: {
id: 7,
full_name: "Taylor Teach",
role: "Instructor",
},
},
}),
}));

vi.mock("../../hooks/useAPI", () => ({
default: () => mockUseAPI(),
}));

vi.mock("components/Table/Table", () => ({
default: (props: any) => {
mockTable(props);
return <div data-testid="courses-table" />;
},
}));

vi.mock("./CourseDelete", () => ({
default: () => <div>Delete Course Modal</div>,
}));

vi.mock("./CourseCopy", () => ({
default: () => <div>Copy Course Modal</div>,
}));

vi.mock("./CourseAssignments", () => ({
default: () => <div>Course Assignments</div>,
}));

const setUseApiSequence = () => {
mockUseAPI
.mockReturnValueOnce({
error: "",
isLoading: false,
data: {
data: [
{
id: 1,
name: "CSC 517",
directory_path: "csc517",
info: "Course info",
private: true,
created_at: "2024-01-01T00:00:00.000Z",
updated_at: "2024-01-02T00:00:00.000Z",
institution_id: 1,
instructor_id: 7,
institution: { id: 1, name: "NCSU" },
instructor: { id: 7, name: "Taylor Teach" },
date_format_pref: "MM/DD/YYYY",
},
],
},
sendRequest: vi.fn(),
})
.mockReturnValueOnce({
data: { data: [{ id: 1, name: "NCSU" }] },
sendRequest: vi.fn(),
})
.mockReturnValueOnce({
data: { data: [{ id: 7, name: "Taylor Teach" }] },
sendRequest: vi.fn(),
})
.mockReturnValueOnce({
data: { data: [{ course_id: 1 }] },
sendRequest: vi.fn(),
});
};

describe("Courses report route behavior", () => {
beforeEach(() => {
mockUseLocation.mockReturnValue({
pathname: "/courses",
search: "",
hash: "",
});
mockUseAPI.mockReset();
mockTable.mockReset();
setUseApiSequence();
});

it("renders only the outlet when the current path is the course report page", () => {
mockUseLocation.mockReturnValue({
pathname: "/courses/1/class_assignment_overview",
search: "",
hash: "",
});

render(<Courses />);

expect(screen.getByTestId("courses-outlet")).toBeInTheDocument();
expect(screen.queryByTestId("courses-table")).not.toBeInTheDocument();
expect(screen.queryByText(/Manage Courses/i)).not.toBeInTheDocument();
});

it("renders the normal courses page when the current path is not the report page", () => {
render(<Courses />);

expect(screen.getByTestId("courses-outlet")).toBeInTheDocument();
expect(screen.getByTestId("courses-table")).toBeInTheDocument();
expect(screen.getByText("Instructed by: Taylor Teach")).toBeInTheDocument();
});

it("passes course rows to the shared table on the normal courses page", async () => {
render(<Courses />);

await waitFor(() => expect(mockTable).toHaveBeenCalledTimes(1));
expect(mockTable.mock.calls[0][0].data).toHaveLength(1);
expect(mockTable.mock.calls[0][0].data[0].name).toBe("CSC 517");
});
});
54 changes: 32 additions & 22 deletions src/pages/Courses/Course.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ const Courses = () => {
const currUserRole = auth.user.role.valueOf();
const navigate = useNavigate();
const location = useLocation();
const dispatch = useDispatch();
const dispatch = useDispatch();
const isReportPage = location.pathname.includes("class_assignment_overview");

const [showDeleteConfirmation, setShowDeleteConfirmation] = useState<{
visible: boolean;
Expand Down Expand Up @@ -96,25 +97,30 @@ const Courses = () => {
[]
);

const onCopyHandle = useCallback(
(row: TRow<ICourseResponse>) =>
setShowCopyConfirmation({ visible: true, data: row.original }),
[]
);

const renderSubComponent = useCallback(({ row }: { row: TRow<ICourseResponse> }) => {
return (
const onCopyHandle = useCallback(
(row: TRow<ICourseResponse>) =>
setShowCopyConfirmation({ visible: true, data: row.original }),
[]
);

const onReportHandle = useCallback(
(row: TRow<ICourseResponse>) => navigate(`${row.original.id}/class_assignment_overview`),
[navigate]
);

const renderSubComponent = useCallback(({ row }: { row: TRow<ICourseResponse> }) => {
return (
<CourseAssignments
courseId={row.original.id}
courseName={row.original.name}
/>
);
}, []);
const tableColumns = useMemo(
() => COURSE_COLUMNS(onEditHandle, onDeleteHandle, onTAHandle, onCopyHandle),
[onDeleteHandle, onEditHandle, onTAHandle, onCopyHandle]
);

const tableColumns = useMemo(
() => COURSE_COLUMNS(onEditHandle, onDeleteHandle, onTAHandle, onCopyHandle, onReportHandle),
[onDeleteHandle, onEditHandle, onTAHandle, onCopyHandle, onReportHandle]
);

const tableData = useMemo(
() => (isLoading || !CourseResponse?.data ? [] : CourseResponse.data),
Expand Down Expand Up @@ -158,14 +164,18 @@ const renderSubComponent = useCallback(({ row }: { row: TRow<ICourseResponse> })
);
}, [mergedTableData, loggedInUserRole]);

const coursesWithAssignments = useMemo(() => {
if (!assignmentResponse?.data) return new Set();
return new Set(assignmentResponse.data.map((a: any) => a.course_id));
}, [assignmentResponse?.data]);

return (
<>
<Outlet />
const coursesWithAssignments = useMemo(() => {
if (!assignmentResponse?.data) return new Set();
return new Set(assignmentResponse.data.map((a: any) => a.course_id));
}, [assignmentResponse?.data]);

if (isReportPage) {
return <Outlet />;
}
Comment on lines +172 to +174
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Report-route short-circuit still triggers unnecessary course-page API fetches.

Although Line 172 returns only <Outlet />, the fetch useEffect above still runs and calls /courses, /institutions, /users, and /assignments on report routes.

🛠️ Suggested fix
 useEffect(() => {
+  if (isReportPage) return;
   // Ensure the API fetch happens unless modals are active
   if (!showDeleteConfirmation.visible || !showCopyConfirmation.visible) {
     fetchCourses({ url: `/courses` });
     fetchInstitutions({ url: `/institutions` });
     fetchInstructors({ url: `/users` });
     fetchAssignments({ url: `/assignments` });
   }
 }, [
+  isReportPage,
   fetchCourses,
   fetchInstitutions,
   fetchInstructors,
   fetchAssignments,
   location,
   showDeleteConfirmation.visible,
   auth.user.id,
   showCopyConfirmation.visible,
 ]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/Courses/Course.tsx` around lines 172 - 174, The Course component
currently returns <Outlet /> when isReportPage is true but the data-fetching
useEffect still runs; to fix, prevent the effect from firing on report routes by
either moving the early return for isReportPage above the useEffect so the
effect is never mounted, or add isReportPage as a guard inside the effect (e.g.,
if (isReportPage) return) before calling the fetch logic (the effect that
dispatches/fetches courses/institutions/users/assignments). Update references to
isReportPage and the existing useEffect (the function that calls
fetchCourses/fetchInstitutions/fetchUsers/fetchAssignments or dispatches those
actions) accordingly so no network calls occur for report routes.


return (
<>
<Outlet />
<main>
<Container fluid className="px-md-4">
<Row className="mt-4 mb-4">
Expand Down
77 changes: 77 additions & 0 deletions src/pages/Courses/CourseColumns.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { courseColumns } from "./CourseColumns";

const mockRow = {
original: {
id: 15,
name: "CSC 517",
institution: { id: 1, name: "NCSU" },
instructor: { id: 2, name: "Prof. Lane" },
created_at: "2024-01-01T00:00:00.000Z",
updated_at: "2024-01-02T00:00:00.000Z",
},
} as any;

describe("courseColumns report action", () => {
const handleEdit = vi.fn();
const handleDelete = vi.fn();
const handleTA = vi.fn();
const handleCopy = vi.fn();
const handleReport = vi.fn();

const renderActionsCell = () => {
const columns = courseColumns(
handleEdit,
handleDelete,
handleTA,
handleCopy,
handleReport
);
const actionsColumn = columns.find((column: any) => column.id === "actions") as any;

return render(actionsColumn.cell({ row: mockRow }));
};

beforeEach(() => {
handleEdit.mockReset();
handleDelete.mockReset();
handleTA.mockReset();
handleCopy.mockReset();
handleReport.mockReset();
});

it("renders the report action with the existing tooltip text", async () => {
const user = userEvent.setup();

renderActionsCell();
const reportButton = screen.getByRole("button", { name: "View Course Report" });

await user.hover(reportButton);

expect(await screen.findByText("View Course Report")).toBeInTheDocument();
});

it("calls handleReport with the clicked row", async () => {
const user = userEvent.setup();

renderActionsCell();
await user.click(screen.getByRole("button", { name: "View Course Report" }));

expect(handleReport).toHaveBeenCalledWith(mockRow);
});

it("renders the report action as a brown rectangular button instead of a link-style icon button", () => {
renderActionsCell();
const reportButton = screen.getByRole("button", { name: "View Course Report" });

expect(reportButton).toHaveStyle({
backgroundColor: "#8B4513",
borderColor: "#8B4513",
width: "25px",
height: "25px",
});
expect(reportButton).not.toHaveClass("btn-link");
});
});
Loading