diff --git a/Changelog.md b/Changelog.md index 408a74baf1..79be2d51e6 100644 --- a/Changelog.md +++ b/Changelog.md @@ -40,6 +40,7 @@ ### 🔧 Internal changes - Added the passing `active_record_doctor` checks to the pre-commit hooks and CI (#8259) - Added indexes on the 22 foreign key columns reported by `rails active_record_doctor:unindexed_foreign_keys` (#8245) +- Converted `CreateTagModal`, `EditTagModal`, `CreateGroupModal`, and `RenameGroupModal` to functional React components (#8232) - Moved the RuboCop, erb_lint and i18n-tasks dependencies into the Gemfile and run them in pre-commit hooks using the `rails` Docker Compose service (#8224) - Moved the Prettier and stylelint dependencies into `package.json` and run them in pre-commit hooks using the `rails` Docker Compose service (#8225) - Converted `AssignmentSummary`, `AssessmentChart`, `CoreStatistics`, and `FractionStat` to functional React components (#8220) diff --git a/app/javascript/Components/Modals/create_group_modal.jsx b/app/javascript/Components/Modals/create_group_modal.jsx index 3ecc57ecde..a8e8fc5c09 100644 --- a/app/javascript/Components/Modals/create_group_modal.jsx +++ b/app/javascript/Components/Modals/create_group_modal.jsx @@ -1,59 +1,46 @@ -import React from "react"; +import {useEffect, useState} from "react"; import Modal from "react-modal"; -export default class CreateGroupModal extends React.Component { - constructor(props) { - super(props); - this.state = { - groupName: "", - }; - } +export default function CreateGroupModal({isOpen, onRequestClose, onSubmit}) { + const [groupName, setGroupName] = useState(""); - componentDidMount() { + useEffect(() => { Modal.setAppElement("body"); - } + }, []); - handleChange = event => { - this.setState({groupName: event.target.value}); + const handleChange = event => { + setGroupName(event.target.value); }; - handleSubmit = event => { + const handleSubmit = event => { event.preventDefault(); - if (!this.state.groupName) { + if (!groupName) { return; } - this.props.onSubmit(this.state.groupName); - this.setState({groupName: ""}); + onSubmit(groupName); + setGroupName(""); }; - render() { - return ( - -

{I18n.t("helpers.submit.create", {model: I18n.t("activerecord.models.group.one")})}

-
- - this.handleChange(event)} - autoFocus - /> -
- - -
-
-
- ); - } + return ( + +

{I18n.t("helpers.submit.create", {model: I18n.t("activerecord.models.group.one")})}

+
+ + +
+ + +
+
+
+ ); } diff --git a/app/javascript/Components/Modals/create_tag_modal.jsx b/app/javascript/Components/Modals/create_tag_modal.jsx index de27aad214..9a4c98d584 100644 --- a/app/javascript/Components/Modals/create_tag_modal.jsx +++ b/app/javascript/Components/Modals/create_tag_modal.jsx @@ -1,32 +1,26 @@ -import React from "react"; +import {useContext, useEffect, useState} from "react"; import TagModal from "../Helpers/tag_modal"; import Modal from "react-modal"; import PropTypes from "prop-types"; import {ResultContext} from "../Result/result_context"; -export default class CreateTagModal extends React.Component { - constructor(props) { - super(props); - this.state = { - name: "", - description: "", - }; - } - - static contextType = ResultContext; +export default function CreateTagModal({isOpen, onRequestClose}) { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const context = useContext(ResultContext); - componentDidMount() { + useEffect(() => { Modal.setAppElement("body"); - } + }, []); - onSubmit = event => { + const onSubmit = event => { event.preventDefault(); const data = { tag: { - name: this.state.name, - description: this.state.description, + name, + description, }, - grouping_id: this.context.grouping_id, + grouping_id: context.grouping_id, }; const options = { method: "POST", @@ -37,44 +31,36 @@ export default class CreateTagModal extends React.Component { body: JSON.stringify(data), }; fetch( - Routes.course_tags_path(this.context.course_id, {assignment_id: this.context.assignment_id}), + Routes.course_tags_path(context.course_id, {assignment_id: context.assignment_id}), options ) .catch(error => { console.error(`Error submitting form: ${error.message}`); }) .finally(() => { - this.setState( - { - name: "", - description: "", - }, - () => { - this.props.onRequestClose(); - } - ); + setName(""); + setDescription(""); + onRequestClose(); }); }; - render() { - return ( - this.setState({name: event.target.value})} - handleDescriptionChange={event => this.setState({description: event.target.value})} - isOpen={this.props.isOpen} - onRequestClose={this.props.onRequestClose} - tagModalHeading={I18n.t("helpers.submit.create", { - model: I18n.t("activerecord.models.tag.one"), - })} - onSubmit={this.onSubmit} - /> - ); - } + return ( + setName(event.target.value)} + handleDescriptionChange={event => setDescription(event.target.value)} + isOpen={isOpen} + onRequestClose={onRequestClose} + tagModalHeading={I18n.t("helpers.submit.create", { + model: I18n.t("activerecord.models.tag.one"), + })} + onSubmit={onSubmit} + /> + ); } -CreateTagModal.propType = { +CreateTagModal.propTypes = { isOpen: PropTypes.bool.isRequired, onRequestClose: PropTypes.func.isRequired, }; diff --git a/app/javascript/Components/Modals/edit_tag_modal.jsx b/app/javascript/Components/Modals/edit_tag_modal.jsx index 816e56c65e..1d979a19f6 100644 --- a/app/javascript/Components/Modals/edit_tag_modal.jsx +++ b/app/javascript/Components/Modals/edit_tag_modal.jsx @@ -1,30 +1,32 @@ -import React from "react"; +import {useEffect, useState} from "react"; import Modal from "react-modal"; import TagModal from "../Helpers/tag_modal"; import PropTypes from "prop-types"; -export default class EditTagModal extends React.Component { - constructor(props) { - super(props); - this.state = { - name: this.props.currentTagName, - description: this.props.currentTagDescription, - }; - } +export default function EditTagModal({ + course_id, + currentTagDescription, + currentTagName, + isOpen, + onRequestClose, + tag_id, +}) { + const [name, setName] = useState(currentTagName); + const [description, setDescription] = useState(currentTagDescription); - componentDidMount() { + useEffect(() => { Modal.setAppElement("body"); - } + }, []); - onSubmit = event => { + const onSubmit = event => { event.preventDefault(); const data = { tag: { - name: this.state.name, - description: this.state.description, + name, + description, }, - course_id: this.props.course_id, - id: this.props.tag_id, + course_id, + id: tag_id, submit: "Save", }; const options = { @@ -35,34 +37,32 @@ export default class EditTagModal extends React.Component { }, body: JSON.stringify(data), }; - fetch(Routes.course_tag_path(this.props.course_id, this.props.tag_id), options) + fetch(Routes.course_tag_path(course_id, tag_id), options) .then(() => { - this.props.onRequestClose(); + onRequestClose(); }) .catch(error => { console.error(error.message); }); }; - render() { - return ( - this.setState({name: event.target.value})} - handleDescriptionChange={event => this.setState({description: event.target.value})} - isOpen={this.props.isOpen} - onRequestClose={this.props.onRequestClose} - tagModalHeading={I18n.t("helpers.submit.update", { - model: I18n.t("activerecord.models.tag.one"), - })} - onSubmit={this.onSubmit} - /> - ); - } + return ( + setName(event.target.value)} + handleDescriptionChange={event => setDescription(event.target.value)} + isOpen={isOpen} + onRequestClose={onRequestClose} + tagModalHeading={I18n.t("helpers.submit.update", { + model: I18n.t("activerecord.models.tag.one"), + })} + onSubmit={onSubmit} + /> + ); } -EditTagModal.propType = { +EditTagModal.propTypes = { isOpen: PropTypes.bool.isRequired, onRequestClose: PropTypes.func.isRequired, tag_id: PropTypes.number.isRequired, diff --git a/app/javascript/Components/Modals/rename_group_modal.jsx b/app/javascript/Components/Modals/rename_group_modal.jsx index 51873ff8ae..328d426bf5 100644 --- a/app/javascript/Components/Modals/rename_group_modal.jsx +++ b/app/javascript/Components/Modals/rename_group_modal.jsx @@ -1,70 +1,57 @@ -import React from "react"; +import {useEffect, useState} from "react"; import Modal from "react-modal"; -export default class RenameGroupModal extends React.Component { - constructor(props) { - super(props); - this.state = { - groupName: props.initialGroupName || "", - }; - } +export default function RenameGroupModal({initialGroupName, isOpen, onRequestClose, onSubmit}) { + const [groupName, setGroupName] = useState(initialGroupName || ""); - componentDidMount() { + useEffect(() => { Modal.setAppElement("body"); - } + }, []); - componentDidUpdate(prevProps) { - if (this.props.isOpen && !prevProps.isOpen && this.props.initialGroupName) { - this.setState({groupName: this.props.initialGroupName}); + useEffect(() => { + if (isOpen && initialGroupName) { + setGroupName(initialGroupName); } - } + }, [initialGroupName, isOpen]); - handleChange = event => { - this.setState({groupName: event.target.value}); + const handleChange = event => { + setGroupName(event.target.value); }; - handleSubmit = event => { + const handleSubmit = event => { event.preventDefault(); - if (!this.state.groupName) { + if (!groupName) { return; } - this.props.onSubmit(this.state.groupName); - this.setState({groupName: ""}); + onSubmit(groupName); + setGroupName(""); }; - render() { - return ( - -

{I18n.t("groups.rename_group")}

-
- - this.handleChange(event)} - autoFocus - /> -
- - -
-
-
- ); - } + return ( + +

{I18n.t("groups.rename_group")}

+
+ + +
+ + +
+
+
+ ); } diff --git a/app/javascript/Components/__tests__/create_group_modal.test.jsx b/app/javascript/Components/__tests__/create_group_modal.test.jsx index ddac53815a..f97fa2609c 100644 --- a/app/javascript/Components/__tests__/create_group_modal.test.jsx +++ b/app/javascript/Components/__tests__/create_group_modal.test.jsx @@ -19,7 +19,8 @@ describe("CreateGroupModal", () => { it("should add the correct group name on successful submit", async () => { const groupName = "Test Group"; - fireEvent.change(screen.getByLabelText(I18n.t("activerecord.models.group.one")), { + const groupNameInput = screen.getByLabelText(I18n.t("activerecord.models.group.one")); + fireEvent.change(groupNameInput, { target: {value: groupName}, }); @@ -34,6 +35,7 @@ describe("CreateGroupModal", () => { await waitFor(() => { expect(props.onSubmit).toHaveBeenCalledTimes(1); expect(props.onSubmit).toHaveBeenCalledWith(groupName); + expect(groupNameInput).toHaveValue(""); }); }); diff --git a/app/javascript/Components/__tests__/create_tag_modal.test.jsx b/app/javascript/Components/__tests__/create_tag_modal.test.jsx index ee9a8eaf8a..afb5d79081 100644 --- a/app/javascript/Components/__tests__/create_tag_modal.test.jsx +++ b/app/javascript/Components/__tests__/create_tag_modal.test.jsx @@ -1,22 +1,20 @@ -import React from "react"; import {render, screen, fireEvent, waitFor} from "@testing-library/react"; import CreateTagModal from "../Modals/create_tag_modal"; import Modal from "react-modal"; import fetchMock from "jest-fetch-mock"; -import {ResultContext} from "../Result/result_context"; import {DEFAULT_RESULT_CONTEXT_VALUE, renderInResultContext} from "./result_context_renderer"; describe("CreateTagModal", () => { let props; - let component; let mockToken; - let tagName; + let csrfToken; beforeAll(() => { mockToken = "mockToken"; - document.querySelector = jest.fn(() => ({ - content: mockToken, // Return a mock value for the content property - })); + csrfToken = document.createElement("meta"); + csrfToken.name = "csrf-token"; + csrfToken.content = mockToken; + document.head.appendChild(csrfToken); }); beforeEach(() => { @@ -28,23 +26,29 @@ describe("CreateTagModal", () => { // Set the app element for React Modal Modal.setAppElement("body"); - component = renderInResultContext(, {role: "Student"}); - - // Enable submit - tagName = "Name"; - fireEvent.change(screen.getByTestId("tag_name_input"), {target: {value: tagName}}); + renderInResultContext(, {role: "Student"}); }); afterEach(() => { fetchMock.resetMocks(); + jest.restoreAllMocks(); + }); + + afterAll(() => { + csrfToken.remove(); }); it("should be called with correct params on successful submit", async () => { + const tagName = "Name"; + const tagDescription = "Description"; fireEvent.change(screen.getByTestId("tag_name_input"), {target: {value: tagName}}); + fireEvent.change(screen.getByTestId("tag_description_input"), { + target: {value: tagDescription}, + }); const data = { tag: { name: tagName, - description: "", + description: tagDescription, }, grouping_id: DEFAULT_RESULT_CONTEXT_VALUE.grouping_id, }; @@ -65,6 +69,7 @@ describe("CreateTagModal", () => { expect(req.method).toBe(options.method); expect(req.headers.get("Content-Type")).toEqual(options.headers["Content-Type"]); expect(req.headers.get("X-CSRF-Token")).toEqual(options.headers["X-CSRF-Token"]); + expect(await req.json()).toEqual(data); return JSON.stringify({status: "success"}); }); @@ -76,17 +81,33 @@ describe("CreateTagModal", () => { }); it("should call onRequestClose on successful submit", async () => { + fireEvent.change(screen.getByTestId("tag_name_input"), {target: {value: "Name"}}); fireEvent.click(screen.getByText(I18n.t("save"))); await waitFor(() => { expect(props.onRequestClose).toHaveBeenCalledTimes(1); }); }); + it("should clear both inputs after submitting", async () => { + fireEvent.change(screen.getByTestId("tag_name_input"), {target: {value: "Name"}}); + fireEvent.change(screen.getByTestId("tag_description_input"), { + target: {value: "Description"}, + }); + + fireEvent.click(screen.getByText(I18n.t("save"))); + + await waitFor(() => { + expect(screen.getByTestId("tag_name_input")).toHaveValue(""); + expect(screen.getByTestId("tag_description_input")).toHaveValue(""); + }); + }); + it("should console error on error", async () => { const customError = new Error(); customError.message = "msg"; fetchMock.mockRejectOnce(customError); const mockedConsoleError = jest.spyOn(global.console, "error").mockImplementation(() => {}); + fireEvent.change(screen.getByTestId("tag_name_input"), {target: {value: "Name"}}); fireEvent.click(screen.getByText(I18n.t("save"))); await waitFor(() => { expect(fetchMock).toHaveBeenCalledTimes(1); diff --git a/app/javascript/Components/__tests__/edit_tag_modal.test.jsx b/app/javascript/Components/__tests__/edit_tag_modal.test.jsx index 65845cd744..c8a2c24b33 100644 --- a/app/javascript/Components/__tests__/edit_tag_modal.test.jsx +++ b/app/javascript/Components/__tests__/edit_tag_modal.test.jsx @@ -1,4 +1,3 @@ -import React from "react"; import {render, screen, fireEvent, waitFor} from "@testing-library/react"; import EditTagModal from "../Modals/edit_tag_modal"; import Modal from "react-modal"; @@ -6,14 +5,15 @@ import fetchMock from "jest-fetch-mock"; describe("EditTagModal", () => { let props; - let component; let mockToken; + let csrfToken; beforeAll(() => { mockToken = "mockToken"; - document.querySelector = jest.fn(() => ({ - content: mockToken, // Return a mock value for the content property - })); + csrfToken = document.createElement("meta"); + csrfToken.name = "csrf-token"; + csrfToken.content = mockToken; + document.head.appendChild(csrfToken); }); beforeEach(() => { @@ -29,20 +29,33 @@ describe("EditTagModal", () => { // Set the app element for React Modal Modal.setAppElement("body"); - component = render(); + render(); }); afterEach(() => { fetchMock.resetMocks(); + jest.restoreAllMocks(); + }); + + afterAll(() => { + csrfToken.remove(); }); it("should be called with correct params on successful submit", async () => { + const updatedName = "updated tag"; + const updatedDescription = "updated description"; + fireEvent.change(screen.getByTestId("tag_name_input"), {target: {value: updatedName}}); + fireEvent.change(screen.getByTestId("tag_description_input"), { + target: {value: updatedDescription}, + }); const data = { tag: { - name: props.currentTagName, - description: props.currentTagDescription, + name: updatedName, + description: updatedDescription, }, - grouping_id: props.grouping_id, + course_id: props.course_id, + id: props.tag_id, + submit: "Save", }; const options = { method: "PUT", @@ -57,6 +70,7 @@ describe("EditTagModal", () => { expect(req.method).toBe(options.method); expect(req.headers.get("Content-Type")).toEqual(options.headers["Content-Type"]); expect(req.headers.get("X-CSRF-Token")).toEqual(options.headers["X-CSRF-Token"]); + expect(await req.json()).toEqual(data); return JSON.stringify({status: "success"}); }); @@ -74,6 +88,11 @@ describe("EditTagModal", () => { }); }); + it("should initialize both inputs from the current tag", () => { + expect(screen.getByTestId("tag_name_input")).toHaveValue(props.currentTagName); + expect(screen.getByTestId("tag_description_input")).toHaveValue(props.currentTagDescription); + }); + it("should console error on error", async () => { const customError = new Error(); customError.message = "msg"; @@ -83,6 +102,7 @@ describe("EditTagModal", () => { await waitFor(() => { expect(fetchMock).toHaveBeenCalledTimes(1); expect(mockedConsoleError).toHaveBeenNthCalledWith(1, customError.message); + expect(props.onRequestClose).not.toHaveBeenCalled(); }); }); }); diff --git a/app/javascript/Components/__tests__/rename_group_modal.test.jsx b/app/javascript/Components/__tests__/rename_group_modal.test.jsx index 23373994eb..3bb69a4108 100644 --- a/app/javascript/Components/__tests__/rename_group_modal.test.jsx +++ b/app/javascript/Components/__tests__/rename_group_modal.test.jsx @@ -14,12 +14,15 @@ describe("RenameGroupModal", () => { }; Modal.setAppElement("body"); - render(); }); it("should rename the group on successful submit", async () => { + render(); const groupName = "new_group"; - fireEvent.change(screen.getByLabelText(I18n.t("activerecord.attributes.group.group_name")), { + const groupNameInput = screen.getByLabelText( + I18n.t("activerecord.attributes.group.group_name") + ); + fireEvent.change(groupNameInput, { target: {value: groupName}, }); @@ -29,10 +32,12 @@ describe("RenameGroupModal", () => { await waitFor(() => { expect(props.onSubmit).toHaveBeenCalledTimes(1); expect(props.onSubmit).toHaveBeenCalledWith(groupName); + expect(groupNameInput).toHaveValue(""); }); }); it("should call onRequestClose on successful call", async () => { + render(); fireEvent.click(screen.getByText(I18n.t("cancel"))); await waitFor(() => { expect(props.onRequestClose).toHaveBeenCalledTimes(1); @@ -40,6 +45,7 @@ describe("RenameGroupModal", () => { }); it("should not add the new group when the inputted group name is empty", async () => { + render(); const renameGroupButton = screen.getByTestId("rename-submit-button"); fireEvent.click(renameGroupButton); @@ -48,21 +54,34 @@ describe("RenameGroupModal", () => { }); }); - it("should populate input with initialGroupName when modal opens", async () => { + it("should populate input with the selected group name each time the modal opens", () => { const propsWithInitialName = { isOpen: true, onRequestClose: jest.fn(), onSubmit: jest.fn(), - initialGroupName: "Original Group Name", + initialGroupName: "First Group Name", }; - const {rerender} = render(); - rerender(); + const {rerender} = render(); + expect(screen.getByDisplayValue("First Group Name")).toBeInTheDocument(); - expect(screen.getByDisplayValue("Original Group Name")).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText(I18n.t("activerecord.attributes.group.group_name")), { + target: {value: "Unsaved First Group Name"}, + }); + rerender(); + rerender( + + ); + + expect(screen.getByDisplayValue("Second Group Name")).toBeInTheDocument(); }); it("should not submit when group name is empty", async () => { + render(); fireEvent.change(screen.getByLabelText(I18n.t("activerecord.attributes.group.group_name")), { target: {value: ""}, });