diff --git a/src/components/forms/attendee-form/__tests__/attendee-form.test.js b/src/components/forms/attendee-form/__tests__/attendee-form.test.js new file mode 100644 index 000000000..46cc6d22d --- /dev/null +++ b/src/components/forms/attendee-form/__tests__/attendee-form.test.js @@ -0,0 +1,109 @@ +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import AttendeeForm from "../attendee-form"; + +jest.mock("i18n-react/dist/i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +jest.mock( + "openstack-uicore-foundation/lib/components/inputs/member-input", + () => ({ __esModule: true, default: () => null }) +); + +jest.mock( + "openstack-uicore-foundation/lib/components/inputs/attendee-input", + () => ({ __esModule: true, default: () => null }) +); + +jest.mock( + "openstack-uicore-foundation/lib/components/inputs/tag-input", + () => ({ __esModule: true, default: () => null }) +); + +jest.mock("../../../notes/notes-panel", () => ({ + __esModule: true, + default: () => null +})); + +const SUMMIT = { id: 1, time_zone_id: "UTC" }; + +const defaultEntity = { + id: 1, + first_name: "Jane", + last_name: "Doe", + email: "jane@example.com", + company: "Acme", + shared_contact_info: false, + summit_hall_checked_in: false, + disclaimer_accepted: false, + admin_notes: "", + tags: [], + tickets: [], + orders: [], + allowed_extra_questions: [] +}; + +const renderForm = (entityOverride = {}, { onSubmit } = {}) => + render( + + ); + +describe("AttendeeForm", () => { + it("blocks submit and shows required-field errors when no member and fields are blank", async () => { + const onSubmit = jest.fn(); + renderForm({ first_name: "", last_name: "", email: "" }, { onSubmit }); + + await userEvent.click(screen.getByRole("button", { name: "general.save" })); + + await waitFor(() => + expect(screen.getAllByText("This field is required")).toHaveLength(3) + ); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("submits a removeUnchangedFields-filtered payload when a field changes", async () => { + const onSubmit = jest.fn(); + renderForm({}, { onSubmit }); + + const firstNameInput = screen.getByDisplayValue("Jane"); + await userEvent.clear(firstNameInput); + await userEvent.type(firstNameInput, "Janet"); + + await userEvent.click(screen.getByRole("button", { name: "general.save" })); + + await waitFor(() => expect(onSubmit).toHaveBeenCalled()); + const payload = onSubmit.mock.calls[0][0]; + + expect(payload.first_name).toBe("Janet"); + expect(payload).not.toHaveProperty("last_name"); + expect(payload).not.toHaveProperty("email"); + expect(payload).not.toHaveProperty("company"); + expect(payload).not.toHaveProperty("shared_contact_info"); + }); + + it("resyncs displayed fields when the entity prop changes", () => { + const { rerender } = renderForm({ first_name: "Jane" }); + expect(screen.getByDisplayValue("Jane")).toBeInTheDocument(); + + rerender( + + ); + + expect(screen.getByDisplayValue("Alice")).toBeInTheDocument(); + expect(screen.queryByDisplayValue("Jane")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/forms/attendee-form/attendee-form.js b/src/components/forms/attendee-form/attendee-form.js index 6c672ce5c..4e7fb8c90 100644 --- a/src/components/forms/attendee-form/attendee-form.js +++ b/src/components/forms/attendee-form/attendee-form.js @@ -11,14 +11,14 @@ * limitations under the License. * */ -import React from "react"; +import React, { useEffect, useRef, useState } from "react"; import T from "i18n-react/dist/i18n-react"; import "awesome-bootstrap-checkbox/awesome-bootstrap-checkbox.css"; -import MemberInput from "openstack-uicore-foundation/lib/components/inputs/member-input" -import AttendeeInput from "openstack-uicore-foundation/lib/components/inputs/attendee-input" -import Input from "openstack-uicore-foundation/lib/components/inputs/text-input" -import Panel from "openstack-uicore-foundation/lib/components/sections/panel" -import TagInput from "openstack-uicore-foundation/lib/components/inputs/tag-input" +import MemberInput from "openstack-uicore-foundation/lib/components/inputs/member-input"; +import AttendeeInput from "openstack-uicore-foundation/lib/components/inputs/attendee-input"; +import Input from "openstack-uicore-foundation/lib/components/inputs/text-input"; +import Panel from "openstack-uicore-foundation/lib/components/sections/panel"; +import TagInput from "openstack-uicore-foundation/lib/components/inputs/tag-input"; import Dropdown from "openstack-uicore-foundation/lib/components/inputs/dropdown"; import ExtraQuestionsForm from "openstack-uicore-foundation/lib/components/extra-questions"; import QuestionsSet from "openstack-uicore-foundation/lib/utils/questions-set"; @@ -26,59 +26,84 @@ import TicketComponent from "./ticket-component"; import OrderComponent from "./order-component"; import RsvpComponent from "./rsvp-component"; import { AffiliationsTable } from "../../tables/affiliationstable"; -import { isEmpty, scrollToError, shallowEqual } from "../../../utils/methods"; -import Notes from "../../notes"; +import { scrollToError, shallowEqual } from "../../../utils/methods"; +import NotesPanel from "../../notes/notes-panel"; import CopyClipboard from "../../buttons/copy-clipboard"; import { MILLISECONDS_IN_SECOND } from "../../../utils/constants"; -class AttendeeForm extends React.Component { - constructor(props) { - super(props); - - this.state = { - entity: { ...props.entity }, - originalEntity: { ...props.entity }, - errors: props.errors, - showSection: "main" - }; - - this.formRef = React.createRef(); - - this.handleChange = this.handleChange.bind(this); - this.handleSubmit = this.handleSubmit.bind(this); - this.triggerFormSubmit = this.triggerFormSubmit.bind(this); - this.removeUnchangedFields = this.removeUnchangedFields.bind(this); - } - - componentDidUpdate(prevProps) { - const state = {}; - scrollToError(this.props.errors); - - if (!shallowEqual(prevProps.entity, this.props.entity)) { - state.entity = { ...this.props.entity }; - state.originalEntity = { ...this.props.entity }; - state.errors = {}; +const removeUnchangedFields = (entity, originalEntity) => { + const copyOfEntity = { ...entity }; + + const fields = [ + "summit_hall_checked_in", + "disclaimer_accepted", + "has_virtual_check_in", + "first_name", + "last_name", + "company", + "shared_contact_info", + "admin_notes", + "email" + ]; + + fields.forEach((f) => { + if (copyOfEntity[f] === originalEntity[f]) { + // field dint change , so remove it from submit + delete copyOfEntity[f]; } - - if (!shallowEqual(prevProps.errors, this.props.errors)) { - state.errors = { ...this.props.errors }; + }); + + return copyOfEntity; +}; + +const AttendeeForm = ({ + entity: entityProp, + errors: errorsProp, + currentSummit, + history, + onSubmit, + onTicketReassign, + onSaveTicket, + onDeleteRsvp, + ExtraQuestionsFormReadOnly +}) => { + const [entity, setEntity] = useState({ ...entityProp }); + const [originalEntity, setOriginalEntity] = useState({ ...entityProp }); + const [errors, setErrors] = useState(errorsProp); + const [openSections, setOpenSections] = useState({ + admin_notes: false, + extra_questions: false + }); + const formRef = useRef(null); + + useEffect(() => { + if (!shallowEqual(originalEntity, entityProp)) { + setEntity({ ...entityProp }); + setOriginalEntity({ ...entityProp }); + setErrors({}); } + }, [entityProp]); - if (!isEmpty(state)) { - this.setState({ ...this.state, ...state }); - } - } + useEffect(() => { + scrollToError(errorsProp); + setErrors((prevErrors) => + shallowEqual(prevErrors, errorsProp) ? prevErrors : { ...errorsProp } + ); + }, [errorsProp]); - toggleSection(section, ev) { - const { showSection } = this.state; - const newShowSection = showSection === section ? "main" : section; - ev.preventDefault(); + const toggleSection = (section, ev) => { + ev?.preventDefault(); + setOpenSections((prev) => ({ ...prev, [section]: !prev[section] })); + }; - this.setState({ showSection: newShowSection }); - } + const handleOpenNotes = () => { + setOpenSections((prev) => + prev.admin_notes ? prev : { ...prev, admin_notes: true } + ); + }; - handleChange(ev) { - const entity = { ...this.state.entity }; + const handleChange = (ev) => { + const updatedEntity = { ...entity }; let { value, id } = ev.target; if (ev.target.type === "checkbox") { @@ -94,402 +119,355 @@ class AttendeeForm extends React.Component { } if (id === "member") { - entity.email = value?.email || ""; - entity.first_name = value?.first_name || ""; - entity.last_name = value?.last_name || ""; + updatedEntity.email = value?.email || ""; + updatedEntity.first_name = value?.first_name || ""; + updatedEntity.last_name = value?.last_name || ""; } - entity[id] = value; - this.setState({ entity, errors: {} }); - } - - removeUnchangedFields(entity, originalEntity) { - const copyOfEntity = { ...entity }; - - const fields = [ - "summit_hall_checked_in", - "disclaimer_accepted", - "has_virtual_check_in", - "first_name", - "last_name", - "company", - "shared_contact_info", - "admin_notes", - "email" - ]; - - fields.forEach((f) => { - if (copyOfEntity[f] === originalEntity[f]) { - // field dint change , so remove it from submit - delete copyOfEntity[f]; - } - }); + updatedEntity[id] = value; + setEntity(updatedEntity); + setErrors({}); + }; - return copyOfEntity; - } + const validate = () => { + const newErrors = { ...errors }; + const required = ["first_name", "last_name", "email"]; - triggerFormSubmit() { - if (!this.validate()) return false; + if (!entity.member) { + required.forEach((fieldId) => { + if (!entity[fieldId]) { + newErrors[fieldId] = "This field is required"; + } + }); + } + + if (Object.keys(newErrors).length > 0) { + setErrors(newErrors); + return false; + } + + return true; + }; + + const triggerFormSubmit = () => { + if (!validate()) return false; // check current ( could not be rendered) - if (this.formRef.current) { - this.formRef.current.doSubmit(); + if (formRef.current) { + formRef.current.doSubmit(); return true; } // do regular submit - - const { originalEntity, entity } = this.state; - - if (entity.extra_questions) { - entity.extra_questions = entity.extra_questions.map((q) => ({ - question_id: q.question_id, - answer: q.value - })); + const updatedEntity = { ...entity }; + + if (updatedEntity.extra_questions) { + updatedEntity.extra_questions = updatedEntity.extra_questions.map( + (q) => ({ + question_id: q.question_id, + answer: q.value + }) + ); } - this.props.onSubmit(this.removeUnchangedFields(entity, originalEntity)); + onSubmit(removeUnchangedFields(updatedEntity, originalEntity)); return true; - } - - handleSubmit(formValues) { - const qs = new QuestionsSet(this.state.entity.allowed_extra_questions); - const formattedAnswers = []; + }; - Object.keys(formValues).map((name) => { + const handleSubmit = (formValues) => { + const qs = new QuestionsSet(entity.allowed_extra_questions); + const formattedAnswers = Object.keys(formValues).map((name) => { const question = qs.getQuestionByName(name); - const newQuestion = { + return { question_id: question.id, answer: `${formValues[name]}` }; - formattedAnswers.push(newQuestion); }); - this.setState( - { - ...this.state, - entity: { ...this.state.entity, extra_questions: formattedAnswers } - }, - () => { - const { originalEntity, entity } = this.state; - this.props.onSubmit(this.removeUnchangedFields(entity, originalEntity)); - } - ); - } + const updatedEntity = { ...entity, extra_questions: formattedAnswers }; + setEntity(updatedEntity); + onSubmit(removeUnchangedFields(updatedEntity, originalEntity)); + }; - handleSpeakerLink(speaker_id, ev) { - const { history } = this.props; + const handleSpeakerLink = (speaker_id, ev) => { ev.preventDefault(); - history.push(`/app/speakers/${speaker_id}`); - } + }; - hasErrors(field) { - const { errors } = this.state; + const hasErrors = (field) => { if (field in errors) { return errors[field]; } return ""; - } - - handleNewTag(newTag) { - this.setState({ - ...this.state, - entity: { - ...this.state.entity, - tags: [...this.state.entity.tags, { tag: newTag }] - } - }); - } - - validate = () => { - const { entity, errors } = this.state; - const required = ["first_name", "last_name", "email"]; - - if (!entity.member) { - required.forEach((fieldId) => { - if (!entity[fieldId]) { - errors[fieldId] = "This field is required"; - } - }); - } - - if (Object.keys(errors).length > 0) { - this.setState({ errors }); - return false; - } + }; - return true; + const handleNewTag = (newTag) => { + setEntity({ + ...entity, + tags: [...entity.tags, { tag: newTag }] + }); }; - render() { - const { entity, showSection } = this.state; - const { currentSummit } = this.props; - const disableMemberInput = !entity.member && entity.email; - - return ( - <> - {/* First Form ( Main Attendee Form ) */} -
- - {entity.speaker != null && ( -
-
- -
- - {entity.speaker.first_name} {entity.speaker.last_name} - -
-
- )} -
-
- -
- - `${member.first_name || ""} ${member.last_name || ""} (${ - member.email || member.id - })` - } - onChange={this.handleChange} - isClearable - isDisabled={disableMemberInput} - /> - {entity?.member && ( - - - - )} -
-
-
- -- OR -- -
-
-