diff --git a/.dockerfiles/Dockerfile b/.dockerfiles/Dockerfile
index 390b16cc39..0c9e85dfd2 100644
--- a/.dockerfiles/Dockerfile
+++ b/.dockerfiles/Dockerfile
@@ -1,6 +1,6 @@
FROM ubuntu:jammy AS base
-ARG NODE_MAJOR=18
+ARG NODE_MAJOR=20
ARG BUNDLER_VERSION='2.4.13'
ARG USER=markus
diff --git a/.dockerfiles/entrypoint-dev-rails.sh b/.dockerfiles/entrypoint-dev-rails.sh
index 5152b0909d..b7a5b9bfc1 100755
--- a/.dockerfiles/entrypoint-dev-rails.sh
+++ b/.dockerfiles/entrypoint-dev-rails.sh
@@ -9,9 +9,9 @@ npm list &> /dev/null || npm ci
# install python packages
[ -f ./venv/bin/python3 ] || python3 -m venv ./venv
./venv/bin/python3 -m pip install --upgrade pip > /dev/null
-./venv/bin/python3 -m pip install -r requirements-jupyter.txt > /dev/null
-./venv/bin/python3 -m pip install -r requirements-scanner.txt > /dev/null
-./venv/bin/python3 -m pip install -r requirements-qr.txt > /dev/null
+./venv/bin/python3 -m pip install -r requirements-jupyter.txt
+./venv/bin/python3 -m pip install -r requirements-scanner.txt
+./venv/bin/python3 -m pip install -r requirements-qr.txt
# install chromium (for nbconvert webpdf conversion)
./venv/bin/python3 -m playwright install chromium
@@ -31,5 +31,8 @@ sed -i -e :a -e '/^\n*$/{$d;N;};/\n$/ba' /app/db/structure.sql
rm -f ./tmp/pids/server.pid
-# Then exec the container's main process (what's set as CMD in the Dockerfile or docker-compose.yml).
+# cssbundling-rails development command
+npm run build-dev:css &
+
+# Then exec the container's main process (what's set as CMD in the Dockerfile or compose.yaml).
exec "$@"
diff --git a/.dockerfiles/production_demo/entrypoint-prod-rails.sh b/.dockerfiles/production_demo/entrypoint-prod-rails.sh
index e3fcc6acc4..2a16bfaa8f 100755
--- a/.dockerfiles/production_demo/entrypoint-prod-rails.sh
+++ b/.dockerfiles/production_demo/entrypoint-prod-rails.sh
@@ -9,5 +9,5 @@ bundle exec rails db:prepare
rm -f ./tmp/pids/server.pid
-# Then exec the container's main process (what's set as CMD in the Dockerfile or docker-compose.yml).
+# Then exec the container's main process (what's set as CMD in the Dockerfile or compose.yaml).
exec "$@"
diff --git a/.dockerignore b/.dockerignore
index 2d2c17bc56..271ea2caba 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -82,3 +82,4 @@ app/javascript/routes.d.ts
# local docker compose overrides
docker-compose.override.yml
+compose.override.yaml
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 2617733a80..8c8b970336 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -1,50 +1,54 @@
-
-
+## Proposed Changes
+*(Describe your changes here. Also describe the motivation for your changes: what problem do they solve, or how do they improve the application or codebase? If this pull request fixes an open issue, [use a keyword to link this pull request to the issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword).)*
-## Motivation and Context
-
-
+...
+Screenshots of your changes (if applicable)
-## Your Changes
-
-
-**Description**:
+Associated documentation repository pull request (if applicable)
-**Type of change** (select all that apply):
-
-
+
- {this.props.content} ++); diff --git a/app/assets/javascripts/Components/__tests__/assignment_summary.test.jsx b/app/assets/javascripts/Components/__tests__/assignment_summary.test.jsx new file mode 100644 index 0000000000..fd6af28c48 --- /dev/null +++ b/app/assets/javascripts/Components/__tests__/assignment_summary.test.jsx @@ -0,0 +1,100 @@ +/*** + * Tests for AssignmentSummaryTable Component + */ + +import {AssignmentSummaryTable} from "../assignment_summary_table"; +import {render, screen, fireEvent, cleanup, waitFor} from "@testing-library/react"; + +describe("For the AssignmentSummaryTable's display of inactive groups", () => { + let groups_sample; + beforeEach(async () => { + groups_sample = [ + { + group_name: "group_0001", + section: null, + members: [ + ["c9nielse", "Nielsen", "Carl", true], + ["c8szyman", "Szymanowski", "Karol", true], + ], + tags: [], + graders: [["c9varoqu", "Nelle", "Varoquaux"]], + marking_state: "released", + final_grade: 9.0, + criteria: {1: 0.0}, + max_mark: "21.0", + result_id: 15, + submission_id: 15, + total_extra_marks: null, + }, + { + group_name: "group_0002", + section: "LEC0101", + members: [ + ["c8debuss", "Debussy", "Claude", false], + ["c8holstg", "Holst", "Gustav", false], + ], + tags: [], + graders: [["c9varoqu", "Nelle", "Varoquaux"]], + marking_state: "released", + final_grade: 6.0, + criteria: {1: 2.0}, + max_mark: "21.0", + result_id: 5, + submission_id: 5, + total_extra_marks: null, + }, + ]; + fetch.mockReset(); + fetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + data: groups_sample, + criteriaColumns: [ + { + Header: "dolores", + accessor: "criteria.1", + className: "number", + headerClassName: "", + }, + ], + numAssigned: 2, + numMarked: 2, + ltiDeployments: [], + }), + }); + + render( ++ {this.props.content} +
+ ); + }); + + it("contains the correct amount of inactive groups in the hidden tooltip", () => { + expect(screen.getByTestId("show_inactive_groups_tooltip").getAttribute("title")).toEqual( + "1 inactive group" + ); + }); + + it("initially contains the active group", () => { + expect(screen.queryByText(/group_0002/)).toBeInTheDocument(); + }); + + it("initially does not contain the inactive group", () => { + expect(screen.queryByText(/group_0001/)).not.toBeInTheDocument(); + }); + + it("contains the inactive group after a single toggle", () => { + fireEvent.click(screen.getByTestId("show_inactive_groups")); + expect(screen.queryByText(/group_0001/)).toBeInTheDocument(); + }); + + it("doesn't contain the inactive group after two toggles", () => { + fireEvent.click(screen.getByTestId("show_inactive_groups")); + fireEvent.click(screen.getByTestId("show_inactive_groups")); + expect(screen.queryByText(/group_0001/)).not.toBeInTheDocument(); + }); +}); diff --git a/app/assets/javascripts/Components/__tests__/starter_file_manager.test.jsx b/app/assets/javascripts/Components/__tests__/starter_file_manager.test.jsx new file mode 100644 index 0000000000..7369a0bbcc --- /dev/null +++ b/app/assets/javascripts/Components/__tests__/starter_file_manager.test.jsx @@ -0,0 +1,136 @@ +import {StarterFileManager} from "../starter_file_manager"; +import {render, screen} from "@testing-library/react"; + +import React from "react"; + +jest.mock("@fortawesome/react-fontawesome", () => ({ + FontAwesomeIcon: () => { + return null; + }, +})); + +// workaround needed for using i18n in jest tests, see +// https://github.com/fnando/i18n/issues/26#issuecomment-1235751777 +jest.mock("i18n-js", () => { + return jest.requireActual("i18n-js/dist/require/index"); +}); + +[true, false].forEach(readOnly => { + let container; + describe(`When a user ${ + readOnly ? "without" : "with" + } assignment manage access visits the changes the StarterFileManager`, () => { + const pageInfo = { + files: [ + { + id: 1, + name: "New Starter File Group", + entry_rename: "", + use_rename: false, + files: [ + { + key: "a4.pdf", + size: 1, + submitted_date: "Thursday, March 14, 2024, 06:34:51 PM EDT", + url: "http://localhost:3000/csc108/courses/1/starter_file_groups/1/download_file?file_name=a4.pdf", + }, + ], + }, + ], + sections: [ + {section_id: 1, section_name: "LEC0101", group_id: 1, group_name: "New Starter File Group"}, + {section_id: 2, section_name: "LEC0201", group_id: 1, group_name: "New Starter File Group"}, + ], + available_after_due: true, + starterfileType: "sections", + defaultStarterFileGroup: 1, + }; + + // for the case where this is read only, we don't want to have the form as + // changed because that's impossible + // + // for the case where this is NOT read only, we want to simulate page changed + // so submit button is enabled (the point is to ensure this is possible) + pageInfo.form_changed = !readOnly; + + beforeEach(() => { + fetch.resetMocks(); + fetch.mockResponseOnce(JSON.stringify(pageInfo)); + + container = render( + + ); + }); + + it(`all buttons on the page are ${readOnly ? "disabled" : "enabled"}`, () => { + const buttons = screen.getAllByRole("button", { + name: new RegExp(`${I18n.t("assignments.starter_file.aria_labels.action_button")}`), + }); + + // one delete button per starter file group, one add button on top of that + expect(buttons).toHaveLength(pageInfo.files.length + 1); + + buttons.forEach(button => { + if (readOnly) { + expect(button).toBeDisabled(); + } else { + expect(button).not.toBeDisabled(); + } + }); + }); + + it(`all radio buttons are ${readOnly ? "disabled" : "enabled"}`, () => { + const radioButtons = screen.getAllByRole("radio"); + + // there's exactly 4 at all times + expect(radioButtons).toHaveLength(4); + + radioButtons.forEach(radioButton => { + if (readOnly) { + expect(radioButton).toBeDisabled(); + } else { + expect(radioButton).not.toBeDisabled(); + } + }); + }); + + it(`all dropdowns on the page are ${readOnly ? "disabled" : "enabled"}`, () => { + const dropdowns = screen.getAllByRole("combobox", { + name: new RegExp(`${I18n.t("assignments.starter_file.aria_labels.dropdown")}`), + }); + + // one for each section, plus one for the default starter file group + expect(dropdowns).toHaveLength(pageInfo.sections.length + 1); + + dropdowns.forEach(dropdown => { + if (readOnly) { + expect(dropdown).toBeDisabled(); + } else { + expect(dropdown).not.toBeDisabled(); + } + }); + }); + + it(`the checkbox on the page is ${readOnly ? "disabled" : "enabled"}`, () => { + // should only be one + const checkbox = screen.getByTestId("available_after_due_checkbox"); + + if (readOnly) { + expect(checkbox).toBeDisabled(); + } else { + expect(checkbox).not.toBeDisabled(); + } + }); + + it(`the submit button on the page is ${readOnly ? "disabled" : "enabled"}`, () => { + // should only be one + const saveButton = screen.getByTestId("save_button"); + + if (readOnly) { + expect(saveButton).toBeDisabled(); + } else { + expect(saveButton).not.toBeDisabled(); + } + }); + }); +}); diff --git a/app/assets/javascripts/Components/__tests__/submission_file_manager.test.jsx b/app/assets/javascripts/Components/__tests__/submission_file_manager.test.jsx index 5086e3838a..4cf9c594c1 100644 --- a/app/assets/javascripts/Components/__tests__/submission_file_manager.test.jsx +++ b/app/assets/javascripts/Components/__tests__/submission_file_manager.test.jsx @@ -2,7 +2,7 @@ import {SubmissionFileManager} from "../submission_file_manager"; import {mount} from "enzyme"; -describe("For the submissions managed by SubmissionFileManager's FileManager child component", () => { +describe("For the SubmissionFileManager", () => { let wrapper, rows; const files_sample = { entries: [ @@ -53,38 +53,148 @@ describe("For the submissions managed by SubmissionFileManager's FileManager chi document.body.innerHTML = ``; }); - it("clicking on the row of each file opens up the preview for that file", () => { - wrapper.update(); - rows = wrapper.find(".file"); - expect(rows.length).toEqual(files_sample.entries.length); + describe("For the submissions managed by its FileManager child component", () => { + it("clicking on the row of each file opens up the preview for that file", () => { + wrapper.update(); + rows = wrapper.find(".file"); + expect(rows.length).toEqual(files_sample.entries.length); + + rows.forEach(row => { + row.simulate("click"); + wrapper.update(); + + // Locate the preview block + const file_viewer_comp = wrapper.find("FileViewer"); + expect(file_viewer_comp).toBeTruthy(); + }); + }); - rows.forEach(row => { - row.simulate("click"); + it("the preview opened is called with the correct props", () => { wrapper.update(); + rows = wrapper.find(".file"); + expect(rows.length).toEqual(files_sample.entries.length); + + rows.forEach(row => { + row.simulate("click"); + wrapper.update(); + + // Locate the preview block + const file_viewer_comp = wrapper.find("FileViewer"); + const file_displayed = files_sample.entries.find( + file => file.url === file_viewer_comp.props().selectedFileURL + ); - // Locate the preview block - const file_viewer_comp = wrapper.find("FileViewer"); - expect(file_viewer_comp).toBeTruthy(); + expect(file_viewer_comp.props().selectedFile).toEqual(file_displayed.relativeKey); + expect(file_viewer_comp.props().selectedFileType).toEqual(file_displayed.type); + }); }); }); - it("the preview opened is called with the correct props", () => { - wrapper.update(); - rows = wrapper.find(".file"); - expect(rows.length).toEqual(files_sample.entries.length); + describe("For the upload modal's progress bar", () => { + let file, mockXHR; + beforeEach(() => { + // a dummy file + file = new File(["content"], "test.txt", {type: "text/plain"}); - rows.forEach(row => { - row.simulate("click"); - wrapper.update(); + // Mock the XMLHttpRequest object + mockXHR = { + upload: { + addEventListener: jest.fn((event, callback) => { + if (event === "progress") { + mockXHR.upload.onprogress = callback; + } + }), + }, + }; + + // Override XMLHttpRequest constructor to return our mock request instead + jest.spyOn(window, "XMLHttpRequest").mockImplementation(() => mockXHR); + + // mock ajax post call to prevent request from being sent + $.post = jest.fn().mockImplementation(({xhr}) => { + // call mock xhr send function to trigger onprogress handler + xhr().send(); + + // do nothing for any chain action after $.post() itself + return { + then: jest.fn().mockReturnThis(), + fail: jest.fn().mockReturnThis(), + always: jest.fn().mockReturnThis(), + }; + }); + }); + + it("when 10% of file uploaded, bar is visible and has value of 10.0", () => { + // override our mock XMLHttpRequest's send method to send an onprogress + // event with 10% completion + mockXHR.send = jest.fn().mockImplementation(() => { + mockXHR.upload.onprogress({ + lengthComputable: true, + loaded: 1, + total: 10, + }); + }); + + // call handleCreateFiles with mock file + wrapper.instance().handleCreateFiles([file], "", false); + + expect(wrapper.state().uploadModalProgressVisible).toBeTruthy(); + expect(wrapper.state().uploadModalProgressPercentage).toEqual(10.0); + }); + + it("when 50% of file uploaded, bar is visible and has value of 50.0", () => { + // override our mock XMLHttpRequest's send method to send an onprogress + // event with 50% completion + mockXHR.send = jest.fn().mockImplementation(() => { + mockXHR.upload.onprogress({ + lengthComputable: true, + loaded: 5, + total: 10, + }); + }); + + // call handleCreateFiles with mock file + wrapper.instance().handleCreateFiles([file], "", false); + + expect(wrapper.state().uploadModalProgressVisible).toBeTruthy(); + expect(wrapper.state().uploadModalProgressPercentage).toEqual(50.0); + }); + + it("when 100% of file uploaded, bar is visible and has value of 100.0", () => { + // override our mock XMLHttpRequest's send method to send an onprogress + // event with 100% completion + mockXHR.send = jest.fn().mockImplementation(() => { + mockXHR.upload.onprogress({ + lengthComputable: true, + loaded: 10, + total: 10, + }); + }); + + // call handleCreateFiles with mock file + wrapper.instance().handleCreateFiles([file], "", false); + + expect(wrapper.state().uploadModalProgressVisible).toBeTruthy(); + expect(wrapper.state().uploadModalProgressPercentage).toEqual(100.0); + }); + + it("after a file is uploaded, bar is no longer visible and its value is reset to 0.0", () => { + // override existing mock implementation to allow always() to run + $.post = jest.fn().mockReturnValue({ + then: jest.fn().mockReturnThis(), + fail: jest.fn().mockReturnThis(), + always: jest.fn().mockImplementation(func => { + // allow func to run + func(); + return this; + }), + }); - // Locate the preview block - const file_viewer_comp = wrapper.find("FileViewer"); - const file_displayed = files_sample.entries.find( - file => file.url === file_viewer_comp.props().selectedFileURL - ); + // call handleCreateFiles with mock file + wrapper.instance().handleCreateFiles([file], "", false); - expect(file_viewer_comp.props().selectedFile).toEqual(file_displayed.relativeKey); - expect(file_viewer_comp.props().selectedFileType).toEqual(file_displayed.type); + expect(wrapper.state().uploadModalProgressVisible).toBeFalsy(); + expect(wrapper.state().uploadModalProgressPercentage).toEqual(0.0); }); }); }); diff --git a/app/assets/javascripts/Components/__tests__/submission_file_upload_modal.test.jsx b/app/assets/javascripts/Components/__tests__/submission_file_upload_modal.test.jsx index 538adb663c..5467b2e672 100644 --- a/app/assets/javascripts/Components/__tests__/submission_file_upload_modal.test.jsx +++ b/app/assets/javascripts/Components/__tests__/submission_file_upload_modal.test.jsx @@ -129,4 +129,154 @@ describe("For the SubmissionFileUploadModal", () => { expect(wrapper.find(".file-rename-textbox").props().disabled).toBeFalsy(); }); }); + describe("The onSubmit method", () => { + // Mock onSubmit function + const mockOnSubmit = jest.fn(); + + beforeEach(() => { + wrapper = shallow( + + ); + }); + + it("should call onSubmit prop with correct arguments when extensions match", () => { + // Mock setState method + wrapper.setState({ + newFiles: [{name: "file.py"}], + renameTo: "file.py", + }); + + // Simulate form submission + wrapper.instance().onSubmit({preventDefault: jest.fn()}); + + // Ensure onSubmit is called with expected arguments + expect(mockOnSubmit).toHaveBeenCalledWith([{name: "file.py"}], undefined, false, "file.py"); + }); + + it("should not call onSubmit prop when extensions don't match and user cancels", () => { + // Mock window.confirm to return false + window.confirm = jest.fn(() => false); + + // Mock setState method + wrapper.setState({ + newFiles: [{name: "file1.py"}], + renameTo: "file2.txt", + }); + + // Simulate form submission + wrapper.instance().onSubmit({preventDefault: jest.fn()}); + + // Ensure onSubmit is not called + expect(mockOnSubmit).not.toHaveBeenCalled(); + }); + + it("should call onSubmit prop with correct arguments when extensions don't match and user confirms", () => { + // Mock window.confirm to return true + window.confirm = jest.fn(() => true); + + // Mock setState method + wrapper.setState({ + newFiles: [{name: "file1.py"}], + renameTo: "file2.txt", + }); + + // Simulate form submission + wrapper.instance().onSubmit({preventDefault: jest.fn()}); + + // Ensure onSubmit is called with expected arguments + expect(mockOnSubmit).toHaveBeenCalledWith( + [{name: "file1.py"}], + undefined, + false, + "file2.txt" + ); + }); + + it("should call onSubmit prop with correct arguments when rename input is left blank", () => { + // Mock setState method + wrapper.setState({ + newFiles: [{name: "file.py"}], + renameTo: "", + }); + wrapper.instance().onSubmit({preventDefault: jest.fn()}); + expect(mockOnSubmit).toHaveBeenCalledWith([{name: "file.py"}], undefined, false, ""); + }); + + it("should call onSubmit prop with correct arguments when rename input consists only of whitespace", () => { + // Mock setState method + wrapper.setState({ + newFiles: [{name: "file.py"}], + renameTo: " ", + }); + wrapper.instance().onSubmit({preventDefault: jest.fn()}); + expect(mockOnSubmit).toHaveBeenCalledWith([{name: "file.py"}], undefined, false, ""); + }); + }); + + describe("The progress bar", () => { + describe("when the progressVisible prop is initially true and progressPercentage prop is 0.0", () => { + beforeEach(() => { + // for testing the behaviour of progress bar after submit + mockOnSubmit = jest.fn(() => { + wrapper.setProps({progressVisible: false}); + }); + + wrapper = shallow( + + ); + + // to ensure submit button is enabled + wrapper.setState({newFiles: ["q1.py", "q2.py"]}); + }); + + it("the progress bar is initially visible", () => { + expect(wrapper.find(".modal-progress-bar").exists()).toBeTruthy(); + }); + + it("the progress bar's value is initially 0.0", () => { + expect(wrapper.find(".modal-progress-bar").props()["value"]).toEqual(0.0); + }); + + it("the progress bar's value changes when the prop progressPercentage changes", () => { + wrapper.setProps({progressPercentage: 50.0}); + wrapper.update(); + expect(wrapper.find(".modal-progress-bar").props()["value"]).toEqual(50.0); + }); + + it("the progress bar disappears after the submit button is clicked", () => { + // simulate form submission + wrapper.instance().onSubmit({preventDefault: jest.fn()}); + expect(mockOnSubmit).toHaveBeenCalled(); + wrapper.update(); + expect(wrapper.find(".modal-progress-bar").exists()).toBeFalsy(); + }); + }); + + describe("when the progressVisible prop is initially false", () => { + beforeEach(() => { + wrapper = shallow( + {}} + /> + ); + }); + + it("the progress bar is initially not visible", () => { + expect(wrapper.find(".modal-progress-bar").exists()).toBeFalsy(); + }); + }); + }); }); diff --git a/app/assets/javascripts/Components/__tests__/submission_table.test.jsx b/app/assets/javascripts/Components/__tests__/submission_table.test.jsx new file mode 100644 index 0000000000..905edc99a9 --- /dev/null +++ b/app/assets/javascripts/Components/__tests__/submission_table.test.jsx @@ -0,0 +1,99 @@ +/*** + * Tests for SubmissionTable Component + */ + +import {SubmissionTable} from "../submission_table"; +import {render, screen, fireEvent} from "@testing-library/react"; + +jest.mock("@fortawesome/react-fontawesome", () => ({ + FontAwesomeIcon: () => { + return null; + }, +})); + +describe("For the SubmissionTable's display of inactive groups", () => { + let groups_sample; + beforeEach(() => { + groups_sample = [ + { + _id: 1, + max_mark: 21.0, + group_name: "group_0001", + tags: [], + marking_state: "released", + submission_time: "Monday, March 25, 2024, 01:49:14 PM EDT", + result_id: 1, + final_grade: 12.0, + members: [ + ["c6scriab", true], + ["g5dalber", true], + ], + grace_credits_used: 1, + }, + { + _id: 2, + max_mark: 21.0, + group_name: "group_0002", + tags: [], + marking_state: "released", + submission_time: "Monday, March 25, 2024, 01:49:14 PM EDT", + result_id: 2, + final_grade: 7.0, + members: [ + ["g8butter", false], + ["g6duparc", false], + ], + section: "LEC0101", + grace_credits_used: 1, + }, + ]; + fetch.mockReset(); + fetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + groupings: groups_sample, + sections: {}, + }), + }); + + render( + + ); + }); + + it("contains the correct amount of inactive groups in the hidden tooltip", () => { + expect(screen.getByTestId("show_inactive_groups_tooltip").getAttribute("title")).toEqual( + "1 inactive group" + ); + }); + + it("initially contains the active group", () => { + expect(screen.queryByText("group_0002")).toBeInTheDocument(); + }); + + it("initially does not contain the inactive group", () => { + expect(screen.queryByText("group_0001")).not.toBeInTheDocument(); + }); + + it("contains the inactive group after a single toggle", () => { + fireEvent.click(screen.getByTestId("show_inactive_groups")); + expect(screen.queryByText("group_0001")).toBeInTheDocument(); + }); + + it("doesn't contain the inactive group after two toggles", () => { + fireEvent.click(screen.getByTestId("show_inactive_groups")); + fireEvent.click(screen.getByTestId("show_inactive_groups")); + expect(screen.queryByText("group_0001")).not.toBeInTheDocument(); + }); +}); diff --git a/app/assets/javascripts/Components/__tests__/text_viewer.test.jsx b/app/assets/javascripts/Components/__tests__/text_viewer.test.jsx new file mode 100644 index 0000000000..30b1e10bca --- /dev/null +++ b/app/assets/javascripts/Components/__tests__/text_viewer.test.jsx @@ -0,0 +1,24 @@ +import React from "react"; +import {render, screen, cleanup} from "@testing-library/react"; +import {TextViewer} from "../Result/text_viewer"; + +describe("TextViewer", () => { + let props; + + beforeEach(() => { + props = { + content: "def f(n: int) -> int:\n return n + 1\n", + annotations: [], + focusLine: null, + submission_file_id: 1, + }; + + render( ); + }); + + afterEach(cleanup); + + it("should render its text content", () => { + expect(screen.getByText("def f(n: int) -> int:")).toBeInTheDocument(); + }); +}); diff --git a/app/assets/javascripts/Components/assignment_summary_table.jsx b/app/assets/javascripts/Components/assignment_summary_table.jsx index c3de494965..b80d369e83 100644 --- a/app/assets/javascripts/Components/assignment_summary_table.jsx +++ b/app/assets/javascripts/Components/assignment_summary_table.jsx @@ -20,6 +20,8 @@ export class AssignmentSummaryTable extends React.Component { showDownloadTestsModal: false, showLtiGradeModal: false, lti_deployments: [], + filtered: [], + inactiveGroupsCount: 0, }; } @@ -27,6 +29,18 @@ export class AssignmentSummaryTable extends React.Component { this.fetchData(); } + toggleShowInactiveGroups = showInactiveGroups => { + let filtered = this.state.filtered.filter(group => { + group.id !== "inactive"; + }); + + if (!showInactiveGroups) { + filtered.push({id: "inactive", value: false}); + } + + this.setState({filtered}); + }; + memberDisplay = (group_name, members) => { if (members.length !== 0 && !(members.length === 1 && members[0][0] === group_name)) { return ( @@ -57,6 +71,19 @@ export class AssignmentSummaryTable extends React.Component { col["filterable"] = false; col["defaultSortDesc"] = true; }); + + let inactive_groups_count = 0; + res.data.forEach(group => { + if (group.members.length && group.members.every(member => member[3])) { + group.inactive = true; + inactive_groups_count++; + } else { + group.inactive = false; + } + }); + + this.toggleShowInactiveGroups(false); + const markingStates = getMarkingStates(res.data); this.setState({ data: res.data, @@ -66,6 +93,7 @@ export class AssignmentSummaryTable extends React.Component { loading: false, marking_states: markingStates, lti_deployments: res.ltiDeployments, + inactiveGroupsCount: inactive_groups_count, }); }); }; @@ -83,6 +111,11 @@ export class AssignmentSummaryTable extends React.Component { fixedColumns = () => { return [ + { + show: false, + accessor: "inactive", + id: "inactive", + }, { Header: I18n.t("activerecord.models.group.one"), id: "group_name", @@ -200,6 +233,15 @@ export class AssignmentSummaryTable extends React.Component { ); } + + let displayInactiveGroupsTooltip = ""; + + if (this.state.inactiveGroupsCount !== null) { + displayInactiveGroupsTooltip = `${I18n.t("activerecord.attributes.grouping.inactive_groups", { + count: this.state.inactiveGroupsCount, + })}`; + } + return ( - {this.props.is_instructor && ( -@@ -218,33 +260,51 @@ export class AssignmentSummaryTable extends React.Component { {I18n.t("submissions.state.complete")}-(this.wrappedInstance = r)} diff --git a/app/assets/javascripts/Components/autotest_manager.jsx b/app/assets/javascripts/Components/autotest_manager.jsx index 72feb294ee..f8fedb91cd 100644 --- a/app/assets/javascripts/Components/autotest_manager.jsx +++ b/app/assets/javascripts/Components/autotest_manager.jsx @@ -73,6 +73,7 @@ class AutotestManager extends React.Component { enable_test: true, enable_student_tests: true, token_start_date: "", + token_end_date: "", tokens_per_period: 0, token_period: 0, non_regenerating_tokens: false, @@ -226,6 +227,11 @@ class AutotestManager extends React.Component { this.setState({token_start_date: newDate}, () => this.toggleFormChanged(true)); }; + handleTokenEndDateChange = selectedDates => { + const newDate = selectedDates[0] || ""; + this.setState({token_end_date: newDate}, () => this.toggleFormChanged(true)); + }; + handleTokensPerPeriodChange = event => { this.setState({tokens_per_period: event.target.value}, () => this.toggleFormChanged(true)); }; @@ -243,6 +249,7 @@ class AutotestManager extends React.Component { enable_test: this.state.enable_test, enable_student_tests: this.state.enable_student_tests, token_start_date: this.state.token_start_date, + token_end_date: this.state.token_end_date, tokens_per_period: this.state.tokens_per_period, token_period: this.state.token_period, non_regenerating_tokens: this.state.non_regenerating_tokens, @@ -366,6 +373,22 @@ class AutotestManager extends React.Component { plugins: [labelPlugin()], // Ensure id is applied to visible input }} /> + + diff --git a/app/assets/javascripts/Components/markdown_preview.jsx b/app/assets/javascripts/Components/markdown_preview.jsx index c02a14e382..82b665201a 100644 --- a/app/assets/javascripts/Components/markdown_preview.jsx +++ b/app/assets/javascripts/Components/markdown_preview.jsx @@ -3,8 +3,8 @@ import PropTypes from "prop-types"; export default class MarkdownPreview extends React.Component { componentDidMount = () => { - const target_id = "annotation_preview"; - MathJax.Hub.Queue(["Typeset", MathJax.Hub, target_id]); + const target_id = "#annotation-preview"; + MathJax.typeset([target_id]); }; render() { diff --git a/app/assets/javascripts/Components/one_time_annotations_table.jsx b/app/assets/javascripts/Components/one_time_annotations_table.jsx index 2af3169490..a49fb22e8e 100644 --- a/app/assets/javascripts/Components/one_time_annotations_table.jsx +++ b/app/assets/javascripts/Components/one_time_annotations_table.jsx @@ -35,13 +35,23 @@ class OneTimeAnnotationsTable extends React.Component { } }) .then(res => { - this.setState({ - data: res, - loading: false, - }); + this.setState( + { + data: res, + }, + // A separate setState call seems to be required to get the MathJax.typeset + // call in componentDidUpdate to correctly transform the annotation texts. + () => this.setState({loading: false}) + ); }); }; + componentDidUpdate(_prevProps, prevState) { + if (prevState.loading && !this.state.loading) { + MathJax.typeset(["#one_time_annotations_table_wrapper"]); + } + } + editAnnotation = (annot_id, content) => { $.ajax({ url: Routes.update_annotation_text_course_assignment_annotation_categories_path( @@ -137,15 +147,17 @@ class OneTimeAnnotationsTable extends React.Component { render() { return ( - + +); } } diff --git a/app/assets/javascripts/Components/starter_file_manager.jsx b/app/assets/javascripts/Components/starter_file_manager.jsx index 7359df418c..be844e848e 100644 --- a/app/assets/javascripts/Components/starter_file_manager.jsx +++ b/app/assets/javascripts/Components/starter_file_manager.jsx @@ -43,7 +43,7 @@ class StarterFileManager extends React.Component { this.props.course_id, this.props.assignment_id ), - {headers: {Accept: "aaplication/json"}} + {headers: {Accept: "application/json"}} ) .then(reponse => { if (reponse.ok) { @@ -225,7 +225,7 @@ class StarterFileManager extends React.Component { groupUploadTarget={id} files={files} noFilesMessage={I18n.t("submissions.no_files_available")} - readOnly={false} + readOnly={this.props.read_only} onCreateFiles={this.handleCreateFiles} onDeleteFile={this.handleDeleteFile} onCreateFolder={this.handleCreateFolder} @@ -242,9 +242,11 @@ class StarterFileManager extends React.Component { canFilter={false} /> @@ -283,7 +285,7 @@ class StarterFileManager extends React.Component { name={"starter_file_type"} value={"simple"} checked={this.state.starterfileType === "simple"} - disabled={!this.state.files.length} + disabled={!this.state.files.length || this.props.read_only} onChange={() => { this.setState({starterfileType: "simple"}, () => this.toggleFormChanged(true)); }} @@ -298,7 +300,7 @@ class StarterFileManager extends React.Component { name={"starter_file_type"} value={"sections"} checked={this.state.starterfileType === "sections"} - disabled={!this.state.files.length} + disabled={!this.state.files.length || this.props.read_only} onChange={() => { this.setState({starterfileType: "sections"}, () => this.toggleFormChanged(true)); }} @@ -313,7 +315,7 @@ class StarterFileManager extends React.Component { name={"starter_file_type"} value={"group"} checked={this.state.starterfileType === "group"} - disabled={!this.state.files.length} + disabled={!this.state.files.length || this.props.read_only} onChange={() => { this.setState({starterfileType: "group"}, () => this.toggleFormChanged(true)); }} @@ -328,7 +330,7 @@ class StarterFileManager extends React.Component { name={"starter_file_type"} value={"shuffle"} checked={this.state.starterfileType === "shuffle"} - disabled={!this.state.files.length} + disabled={!this.state.files.length || this.props.read_only} onChange={() => { this.setState({starterfileType: "shuffle"}, () => this.toggleFormChanged(true)); }} @@ -351,8 +353,9 @@ class StarterFileManager extends React.Component { this.toggleFormChanged(true) ) } + aria-label={I18n.t("assignments.starter_file.aria_labels.dropdown")} value={this.state.defaultStarterFileGroup} - disabled={!this.state.files.length} + disabled={!this.state.files.length || this.props.read_only} > {Object.entries(this.state.files).map(data => { const {id, name} = data[1]; @@ -383,7 +386,8 @@ class StarterFileManager extends React.Component {+