diff --git a/__tests__/components/__snapshots__/modal.test.jsx.snap b/__tests__/components/__snapshots__/modal.test.jsx.snap index 43a6cdab4..28c68b5d1 100644 --- a/__tests__/components/__snapshots__/modal.test.jsx.snap +++ b/__tests__/components/__snapshots__/modal.test.jsx.snap @@ -19,171 +19,3 @@ exports[`Modal Component renders header correctly 1`] = ` `; - -exports[`Modal Component renders whole form after header clicked 1`] = ` -
-
-
-
-
-
- Create Class -
-
-
-
-
-
- Create Class -
-
- -
-
-

- Class Name: -

- - -
-
-
-
-

- Description: -

- - -
-
-
-
-

- Edit Select Certifications: -

- ({ - value: x['value'], - label: x['displayName'] - }))} - value={selected} - onChange={setSelected} - labelledBy='Select' - /> -
-
- -
-
- - -
-
-
-
-
- - )} +

diff --git a/components/ClassModal.js b/components/ClassModal.js new file mode 100644 index 000000000..4fb953a49 --- /dev/null +++ b/components/ClassModal.js @@ -0,0 +1,173 @@ +import { useState, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import FloatingMultiSelect from './FloatingMultiSelect'; +import { getStoredSuperblocks } from '../util/curriculum/constants'; + +/** + * Shared Create/Edit Class modal. + * + * Used by both the "Create Class" trigger (components/modal.js) and the + * "Edit" menu item (components/ClassInviteTable.js) so the two flows share + * one implementation instead of two hand-copied ones. + * + * Renders via a portal straight to document.body so the overlay/panel are + * never subject to layout quirks from wherever the trigger happens to sit + * in the component tree. + */ +export default function ClassModal({ + mode, + isOpen, + onClose, + userId, + certificationNames, + initialValues, + onSubmit +}) { + const isEdit = mode === 'edit'; + + const getSelectedCerts = () => { + if (!isEdit || !initialValues?.fccCertifications) { + return []; + } + return certificationNames + .filter(cert => initialValues.fccCertifications.includes(cert.value)) + .map(cert => ({ value: cert.value, label: cert.displayName })); + }; + + const [className, setClassName] = useState(''); + const [description, setDescription] = useState(''); + const [selected, setSelected] = useState([]); + const [mounted, setMounted] = useState(false); + + useEffect(() => setMounted(true), []); + + // Re-sync local form state to the current class every time the modal + // opens. The component instance persists across open/close (only its + // rendered output is conditional), so this can't rely on remount to reset. + useEffect(() => { + if (isOpen) { + setClassName(isEdit ? (initialValues?.classroomName ?? '') : ''); + setDescription(isEdit ? (initialValues?.description ?? '') : ''); + setSelected(getSelectedCerts()); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]); + + const handleSubmit = async e => { + e.preventDefault(); + const fccCertificationsSet = new Set(); + selected.forEach(cert => + getStoredSuperblocks(cert.value).forEach(req => + fccCertificationsSet.add(req) + ) + ); + + const payload = { + classroomName: className, + description, + fccCertifications: [...fccCertificationsSet].sort() + }; + if (isEdit) { + payload.classroomId = initialValues.classroomId; + } else { + payload.classroomTeacherId = userId; + } + + await onSubmit(payload); + onClose(); + }; + + if (!isOpen || !mounted) { + return null; + } + + return createPortal( +
+
+
+
+ {isEdit ? 'Edit Class' : 'Create Class'} +
+ +
+ +
+
+

+ {isEdit ? 'Edit Class Name:' : 'Class Name:'} +

+ + setClassName(e.target.value)} + value={className} + id='class-name' + name='classname' + required + className='appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm' + placeholder='Class Name' + > +
+
+
+
+

+ {isEdit ? 'Edit Description:' : 'Description:'} +

+ + +
+
+
+
+

+ {isEdit + ? 'Edit Select Certifications:' + : 'Select Certifications:'} +

+ ({ + value: cert.value, + label: cert.displayName + }))} + value={selected} + onChange={setSelected} + labelledBy='Select' + /> +
+
+ +
+
+ + +
+
+
+
+
, + document.body + ); +} diff --git a/components/FloatingMultiSelect.js b/components/FloatingMultiSelect.js new file mode 100644 index 000000000..b2c43ee63 --- /dev/null +++ b/components/FloatingMultiSelect.js @@ -0,0 +1,72 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { MultiSelect } from 'react-multi-select-component'; + +/** + * Wraps react-multi-select-component and renders it into a portal on + * document.body, positioned over an in-flow anchor element. + * + * react-multi-select-component has no built-in floating/portal option, and + * its dropdown panel is absolutely positioned relative to its own wrapper. + * When that wrapper sits inside a scrollable ancestor (e.g. a modal panel + * with `overflow-auto`), the open panel gets clipped by that boundary. + * Portaling the whole widget out to document.body sidesteps the clipping + * entirely, at the cost of manually tracking the anchor's position. + * + * This is a deliberately lightweight fix (no new dependency). If positioning + * needs get more advanced later (e.g. flipping above the anchor near the + * viewport edge), consider swapping to a library with native floating + * support (e.g. react-select's menuPortalTarget, or @floating-ui/react). + */ +export default function FloatingMultiSelect(props) { + const anchorRef = useRef(null); + const [rect, setRect] = useState(null); + const [mounted, setMounted] = useState(false); + + const updateRect = () => { + if (anchorRef.current) { + setRect(anchorRef.current.getBoundingClientRect()); + } + }; + + useLayoutEffect(() => { + setMounted(true); + updateRect(); + }, []); + + useEffect(() => { + if (!mounted) { + return undefined; + } + window.addEventListener('resize', updateRect); + // 'scroll' doesn't bubble, so listen on the capture phase to also catch + // scrolling inside the modal panel, not just the window itself. + window.addEventListener('scroll', updateRect, true); + return () => { + window.removeEventListener('resize', updateRect); + window.removeEventListener('scroll', updateRect, true); + }; + }, [mounted]); + + return ( + <> +
+ {mounted && + rect && + createPortal( +
+ +
, + document.body + )} + + ); +} diff --git a/components/modal.js b/components/modal.js index 97238a3af..129109e10 100644 --- a/components/modal.js +++ b/components/modal.js @@ -1,45 +1,30 @@ import { useState } from 'react'; -import { MultiSelect } from 'react-multi-select-component'; +import ClassModal from './ClassModal'; import DisplayNotification from './displayNotification'; import { ToastContainer } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; -import { getStoredSuperblocks } from '../util/curriculum/constants'; export default function Modal({ userId, certificationNames, setCurrentClassrooms }) { - const handleCancelClick = () => { - setSelected([]); - setModalOn(false); - }; - - const [formData, setFormData] = useState({}); - const [selected, setSelected] = useState([]); - const [modalOn, setModalOn] = useState(false); const clicked = () => { setModalOn(true); }; - async function saveClass(e) { + const closeModal = () => { setModalOn(false); - e.preventDefault(); - const fccCertificationsSet = new Set(); - selected.forEach(x => - getStoredSuperblocks(x.value).forEach(req => - fccCertificationsSet.add(req) - ) - ); - formData.fccCertifications = [...fccCertificationsSet].sort(); + }; + const createClass = async payload => { const response = await fetch(`/api/create_class_teacher`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(formData) + body: JSON.stringify(payload) }); if (response.ok) { @@ -56,12 +41,11 @@ export default function Modal({ ...currentClassrooms, newClassroom ]); - setSelected([]); DisplayNotification('Success', 'Class Created!'); } else { DisplayNotification('Error', 'Class could not be created!'); } - } + }; return ( <> @@ -77,97 +61,14 @@ export default function Modal({ Create Class

- {modalOn && ( - <> -
-
-
-
- Create Class -
- -
- -
-
-

Class Name:

- - - setFormData({ - ...formData, - classroomName: e.target.value, - classroomTeacherId: userId - }) - } - id='class-name' - name='classname' - required - className='appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm' - placeholder='Class Name' - > -
-
-
-
-

Description:

- - -
-
-
-
-

Select Certifications:

- ({ - value: x['value'], - label: x['displayName'] - }))} - value={selected} - onChange={setSelected} - labelledBy='Select' - /> -
-
- -
-
- - -
-
-
-
-
- - )} +
);