-`;
diff --git a/__tests__/components/classInviteTable.test.jsx b/__tests__/components/classInviteTable.test.jsx
index ca63dd4d8..cff5cbcfb 100644
--- a/__tests__/components/classInviteTable.test.jsx
+++ b/__tests__/components/classInviteTable.test.jsx
@@ -1,6 +1,8 @@
import ClassInviteTable from '../../components/ClassInviteTable';
import React from 'react';
import renderer from 'react-test-renderer';
+import { fireEvent, render, screen } from '@testing-library/react';
+import '@testing-library/jest-dom';
import {
certifications,
classroomId,
@@ -58,4 +60,58 @@ describe('ClassInviteTable', () => {
.toJSON();
expect(tree).toMatchSnapshot();
});
+
+ // Regression test for the Edit Class modal pre-fill bug: the current name
+ // and description used to only be set as `placeholder`, so the fields
+ // looked pre-filled but any keystroke replaced them outright. They should
+ // now be bound as the controlled `value`.
+ it('pre-fills the Edit Class form with the current name and description', () => {
+ render(
+ {}}
+ handleEdit={() => {}}
+ userId={userId}
+ />
+ );
+
+ fireEvent.click(document.getElementById('menu-button'));
+ fireEvent.click(screen.getByText('Edit'));
+
+ expect(screen.getByLabelText('Class Name')).toHaveValue(
+ sampleClassroom.classroomName
+ );
+ expect(screen.getByLabelText('Description')).toHaveValue(
+ sampleClassroom.description
+ );
+
+ // Editing should append to the pre-filled value, not replace a blank field.
+ fireEvent.change(screen.getByLabelText('Class Name'), {
+ target: { value: `${sampleClassroom.classroomName} (updated)` }
+ });
+ expect(screen.getByLabelText('Class Name')).toHaveValue(
+ `${sampleClassroom.classroomName} (updated)`
+ );
+ });
+
+ it('renders the Edit Class modal into document.body via a portal', () => {
+ render(
+ {}}
+ handleEdit={() => {}}
+ userId={userId}
+ />
+ );
+
+ fireEvent.click(document.getElementById('menu-button'));
+ fireEvent.click(screen.getByText('Edit'));
+
+ expect(screen.getByText('Edit Class')).toBeVisible();
+ expect(screen.getByRole('button', { name: 'Update' })).toBeVisible();
+ });
});
diff --git a/__tests__/components/modal.test.jsx b/__tests__/components/modal.test.jsx
index be6a037ab..37c8c3eac 100644
--- a/__tests__/components/modal.test.jsx
+++ b/__tests__/components/modal.test.jsx
@@ -1,6 +1,8 @@
import Modal from '../../components/modal';
import React from 'react';
-import renderer, { act } from 'react-test-renderer';
+import renderer from 'react-test-renderer';
+import { fireEvent, render, screen } from '@testing-library/react';
+import '@testing-library/jest-dom';
const sampleData = [
{
@@ -41,16 +43,35 @@ describe('Modal Component', () => {
.toJSON();
expect(tree).toMatchSnapshot();
});
+
+ // The Create Class form renders through a React portal straight to
+ // document.body (see components/ClassModal.js), so it's verified with
+ // Testing Library against the real jsdom document instead of
+ // react-test-renderer's toJSON(), which can't reconcile a portal target
+ // that isn't one of its own fake instances.
it('renders whole form after header clicked', () => {
- const testRenderer = renderer.create(
-
- );
- const testInstance = testRenderer.root;
- const header = testInstance.findByProps({ className });
- act(() => {
- header.props.onClick();
- });
- const tree = testRenderer.toJSON();
- expect(tree).toMatchSnapshot();
+ render();
+
+ fireEvent.click(screen.getByText('Create Class'));
+
+ expect(
+ screen.getByText('Create Class', { selector: '.text-lg' })
+ ).toBeVisible();
+ expect(screen.getByLabelText('Class Name')).toBeVisible();
+ expect(screen.getByLabelText('Description')).toBeVisible();
+ expect(screen.getByRole('button', { name: 'Create' })).toBeVisible();
+ expect(screen.getByRole('button', { name: 'Cancel' })).toBeVisible();
+ });
+
+ it('closes the form when Cancel is clicked', () => {
+ render();
+
+ fireEvent.click(screen.getByText('Create Class'));
+ expect(screen.getByRole('button', { name: 'Create' })).toBeVisible();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
+ expect(
+ screen.queryByRole('button', { name: 'Create' })
+ ).not.toBeInTheDocument();
});
});
diff --git a/components/ClassInviteTable.js b/components/ClassInviteTable.js
index 8809f47b0..237c0df10 100644
--- a/components/ClassInviteTable.js
+++ b/components/ClassInviteTable.js
@@ -3,8 +3,7 @@ import { useState, useEffect, useRef } from 'react';
import { useRouter } from 'next/router';
import { toast } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
-import { MultiSelect } from 'react-multi-select-component';
-import { getStoredSuperblocks } from '../util/curriculum/constants';
+import ClassModal from './ClassModal';
export default function ClassInviteTable({
currentClass,
@@ -16,18 +15,6 @@ export default function ClassInviteTable({
const router = useRouter();
const [showOptions, setShowOptions] = useState(false);
const [editOn, setEditOn] = useState(false);
- const [formData, setFormData] = useState({});
-
- const getSelectedCerts = () => {
- const selectedCerts = currentClass.fccCertifications.map(x => x);
- return certificationNames.filter(x => selectedCerts.includes(x.value));
- };
- const [selected, setSelected] = useState(() =>
- getSelectedCerts().map(x => ({
- value: x['value'],
- label: x['displayName']
- }))
- );
const ref = useRef();
@@ -66,18 +53,9 @@ export default function ClassInviteTable({
}
}
};
- async function saveEdit(e) {
- setEditOn(false);
- e.preventDefault();
- const fccCertificationsSet = new Set();
- selected.forEach(x =>
- getStoredSuperblocks(x.value).forEach(req =>
- fccCertificationsSet.add(req)
- )
- );
- formData.fccCertifications = [...fccCertificationsSet].sort();
- formData.classroomId = currentClass.classroomId;
- const JSONdata = JSON.stringify(formData);
+
+ const saveEdit = async payload => {
+ const JSONdata = JSON.stringify(payload);
try {
const res = await fetch(`/api/editclass`, {
method: 'PUT',
@@ -103,16 +81,16 @@ export default function ClassInviteTable({
alert('Sorry, there was an error on our end. Please try again later.');
console.log(error);
}
- }
+ };
const clickedEdit = () => {
setEditOn(true);
};
- const handleCancelClick = () => {
- setSelected(getSelectedCerts());
+ const closeEditModal = () => {
setEditOn(false);
};
+
useEffect(() => {
const checkIfClickedOutside = e => {
if (showOptions && ref.current && !ref.current.contains(e.target)) {
@@ -264,96 +242,20 @@ export default function ClassInviteTable({
- {editOn && (
- <>
-
-
-
-
- Edit Class
-
-
-
-
-
-
- >
- )}
+
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'}
+
+
+
+
+
+
,
+ 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(
+