From e8ccbd52402b1e2be1218070b81db57b1025b5d2 Mon Sep 17 00:00:00 2001 From: Newton Chung Date: Mon, 10 Aug 2026 14:35:05 -0700 Subject: [PATCH] Redesign Create and Edit Class Modals Refactor the code in modal.js and ClassInviteTable.js into a shared component in ClassModal.js Create the FloatingMultiSelect.js modal to make the Select Certification dropdown appear on top of the backdrop Switch from overlaying JSX in place with ReactDOM.createPortal() to make the modal appear fully over the page Updated test files and snapshots, added temporary comments to explain the changes. --- .../__snapshots__/modal.test.jsx.snap | 168 ----------------- .../components/classInviteTable.test.jsx | 56 ++++++ __tests__/components/modal.test.jsx | 43 +++-- components/ClassInviteTable.js | 140 +++----------- components/ClassModal.js | 173 ++++++++++++++++++ components/FloatingMultiSelect.js | 72 ++++++++ components/modal.js | 127 ++----------- 7 files changed, 368 insertions(+), 411 deletions(-) create mode 100644 components/ClassModal.js create mode 100644 components/FloatingMultiSelect.js 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' - /> -
-
- -
-
- - -
-
-
-
-
- - )} +
);