From 55c02faf5ba012816a8789c97f9f6ad2a3612939 Mon Sep 17 00:00:00 2001 From: Erin Date: Tue, 19 May 2026 14:21:47 -0500 Subject: [PATCH 1/2] Bug fixes for leaderboard layout, couldn't create an assignment without students in classroom first and other minor bugs --- classrooms.js | 380 ++++++++++++++- src/components/ClassroomExtraMenu.tsx | 3 +- .../Classrooms/ChallengeTabView.tsx | 2 +- .../Classrooms/CreateAssignmentView.tsx | 25 +- src/components/Classrooms/PeopleView.tsx | 7 +- src/components/Dialog/LeaveClassDialog.tsx | 69 ++- src/components/Tours/GuidedTour.tsx | 4 +- src/components/interface/Form.tsx | 10 +- src/pages/ClassroomLeaderboard.tsx | 437 ++++++++++++------ src/pages/ClassroomStudentView.tsx | 33 +- src/pages/ClassroomTeacherView.tsx | 26 +- src/state/reducer/classrooms.ts | 360 ++++++++++++--- src/tours/Tours.tsx | 23 +- src/util/classroomDisplayName.ts | 30 ++ 14 files changed, 1138 insertions(+), 271 deletions(-) create mode 100644 src/util/classroomDisplayName.ts diff --git a/classrooms.js b/classrooms.js index f3b3e8d5..17531549 100644 --- a/classrooms.js +++ b/classrooms.js @@ -2,6 +2,120 @@ const express = require('express'); const admin = require('firebase-admin'); +function studentEntryId(entry) { + if (!entry) return undefined; + if (typeof entry === 'string') return entry; + if (!entry.id) return undefined; + if (typeof entry.id === 'string') return entry.id; + if (typeof entry.id === 'object' && entry.id['en-US']) return entry.id['en-US']; + return undefined; +} + +/** Firestore map key for a student (may differ from entry.id in edge cases). */ +function findStudentRosterKey(studentIds, studentId) { + if (!studentIds || typeof studentIds !== 'object' || Array.isArray(studentIds)) { + return null; + } + if (studentIds[studentId]) return studentId; + for (const [key, entry] of Object.entries(studentIds)) { + if (key === studentId || studentEntryId(entry) === studentId) { + return key; + } + } + return null; +} + +/** All roster map keys that refer to this student (removes duplicates / legacy keys). */ +function rosterKeysForStudent(studentIds, studentId) { + if (!studentIds || typeof studentIds !== 'object' || Array.isArray(studentIds)) { + return []; + } + const keys = new Set(); + for (const [key, entry] of Object.entries(studentIds)) { + if (key === studentId || studentEntryId(entry) === studentId) { + keys.add(key); + } + } + return [...keys]; +} + +/** Firestore update() deep-merges maps; assignedTo keys must be deleted explicitly. */ +function addAssignedToDeletesForStudent(update, classroomAssignments, studentId) { + if (!classroomAssignments || typeof classroomAssignments !== 'object') { + return; + } + const FieldPath = admin.firestore.FieldPath; + for (const [assignmentKey, assignment] of Object.entries(classroomAssignments)) { + const assignedTo = assignment?.assignedTo; + if (!assignedTo || typeof assignedTo !== 'object') continue; + for (const [assignedKey, entry] of Object.entries(assignedTo)) { + if (assignedKey === studentId || studentEntryId(entry) === studentId) { + update[ + new FieldPath('classroomAssignments', assignmentKey, 'assignedTo', assignedKey) + ] = admin.firestore.FieldValue.delete(); + } + } + } +} + +/** Apply assignment map patch without leaving stale assignedTo entries after merge. */ +async function applyClassroomAssignmentsPatch(docRef, existing, incomingAssignments) { + const FieldPath = admin.firestore.FieldPath; + const existingAssignments = existing.classroomAssignments || {}; + const incoming = incomingAssignments || {}; + const update = {}; + const assignmentKeys = new Set([ + ...Object.keys(existingAssignments), + ...Object.keys(incoming), + ]); + + for (const assignmentKey of assignmentKeys) { + const existingAssignment = existingAssignments[assignmentKey]; + const incomingAssignment = incoming[assignmentKey]; + const existingAt = existingAssignment?.assignedTo || {}; + const incomingAt = incomingAssignment?.assignedTo || {}; + + for (const assignedKey of Object.keys(existingAt)) { + if (!(assignedKey in incomingAt)) { + update[ + new FieldPath('classroomAssignments', assignmentKey, 'assignedTo', assignedKey) + ] = admin.firestore.FieldValue.delete(); + } + } + for (const [assignedKey, entry] of Object.entries(incomingAt)) { + update[ + new FieldPath('classroomAssignments', assignmentKey, 'assignedTo', assignedKey) + ] = entry; + } + + if (incomingAssignment) { + const { assignedTo: _at, ...rest } = incomingAssignment; + for (const [field, value] of Object.entries(rest)) { + update[new FieldPath('classroomAssignments', assignmentKey, field)] = value; + } + } + } + + if (Object.keys(update).length === 0) return; + update.updatedAt = admin.firestore.FieldValue.serverTimestamp(); + await docRef.update(update); +} + +/** Delete roster entry and remove student from every assignment's assignedTo. */ +async function removeStudentFromClassroomDoc(docRef, existing, studentId) { + const keys = rosterKeysForStudent(existing.studentIds, studentId); + if (keys.length === 0) return false; + const update = { + updatedAt: admin.firestore.FieldValue.serverTimestamp(), + }; + for (const key of keys) { + update[`studentIds.${key}`] = admin.firestore.FieldValue.delete(); + } + addAssignedToDeletesForStudent(update, existing.classroomAssignments, studentId); + await docRef.update(update); + return true; +} + // You already have FirebaseTokenManager; we’ll use it to verify the ID token. module.exports = function createClassroomsRouter(firebaseTokenManager) { const router = express.Router(); @@ -37,13 +151,70 @@ module.exports = function createClassroomsRouter(firebaseTokenManager) { const colPath = () => admin.firestore().collection('classrooms'); - // CREATE classroom + // Student leaves whichever classroom they are enrolled in (uses auth uid; no doc id required). + router.post('/leave', async (req, res) => { + try { + const { uid } = req.user; + let qsnap = await colPath() + .where(`studentIds.${uid}`, '!=', null) + .get(); + + // Legacy rosters may use a map key other than the uid; scan once if the index query misses. + if (qsnap.empty) { + const all = await colPath().get(); + const matches = all.docs.filter((doc) => + rosterKeysForStudent(doc.data().studentIds, uid).length > 0, + ); + if (matches.length > 0) { + qsnap = { docs: matches, empty: false }; + } + } + + if (qsnap.empty) { + return res.status(404).json({ message: 'Student not in any classroom' }); + } + + let removed = false; + for (const doc of qsnap.docs) { + const existing = doc.data(); + const didRemove = await removeStudentFromClassroomDoc(doc.ref, existing, uid); + if (didRemove) removed = true; + } + + if (!removed) { + return res.status(404).json({ message: 'Student not in classroom' }); + } + return res.sendStatus(204); + } catch (err) { + console.error('POST /classrooms/leave error:', err); + return res.status(500).json({ message: err.message }); + } + }); + + // CREATE classroom (POST on an existing id must not reassign teacherId to the caller) router.post('/:id', async (req, res) => { try { const { uid } = req.user; const { id } = req.params; const data = req.body || {}; const firestore = admin.firestore(); + const docRef = firestore.collection('classrooms').doc(id); + const existingSnap = await docRef.get(); + + if (existingSnap.exists) { + const existing = existingSnap.data(); + const { teacherId: _ignoredTeacherId, ...dataWithoutTeacherId } = data; + await docRef.set( + { + ...dataWithoutTeacherId, + teacherId: existing.teacherId, + updatedAt: admin.firestore.FieldValue.serverTimestamp(), + }, + { merge: true }, + ); + return res.sendStatus(204); + } + const classroomData = { ...data, teacherId: uid, @@ -51,8 +222,7 @@ module.exports = function createClassroomsRouter(firebaseTokenManager) { updatedAt: admin.firestore.FieldValue.serverTimestamp(), }; - await firestore.collection('classrooms').doc(id) - .set(classroomData); + await docRef.set(classroomData); return res.status(204).json({ id, ...classroomData }); } catch (err) { @@ -194,7 +364,9 @@ module.exports = function createClassroomsRouter(firebaseTokenManager) { .get(); const result = {}; - qsnap.forEach((doc) => (result[doc.id] = doc.data())); + qsnap.forEach((doc) => { + result[doc.id] = { ...doc.data(), docId: doc.id }; + }); return res.status(200).json(result); } catch (err) { @@ -216,7 +388,7 @@ module.exports = function createClassroomsRouter(firebaseTokenManager) { return res.status(404).json({}); } - return res.status(200).json({ [id]: doc.data() }); + return res.status(200).json({ [id]: { ...doc.data(), docId: id } }); } catch (err) { console.error('GET /classrooms/:id error:', err); return res.status(500).json({ message: err.message }); @@ -263,7 +435,7 @@ module.exports = function createClassroomsRouter(firebaseTokenManager) { const result = {}; qsnap.forEach((doc) => { - result[doc.id] = doc.data(); + result[doc.id] = { ...doc.data(), docId: doc.id }; }); return res.status(200).json(result); } catch (err) { @@ -308,11 +480,159 @@ module.exports = function createClassroomsRouter(firebaseTokenManager) { } }); + // Student leaves their own classroom (caller uid only; never touches teacherId). + router.post('/:id/leave', async (req, res) => { + try { + const { uid } = req.user; + const { id } = req.params; + const docRef = admin.firestore().collection('classrooms').doc(id); + const snap = await docRef.get(); + if (!snap.exists) { + return res.status(404).json({ message: 'Classroom not found' }); + } + const existing = snap.data(); + const didRemove = await removeStudentFromClassroomDoc(docRef, existing, uid); + if (!didRemove) { + return res.status(404).json({ message: 'Student not in classroom' }); + } + return res.sendStatus(204); + } catch (err) { + console.error('POST /classrooms/:id/leave error:', err); + return res.status(500).json({ message: err.message }); + } + }); + + // Remove one student from the roster (leave classroom / teacher removes student). + // Never reads or writes teacherId. + router.delete('/:id/students/:studentId', async (req, res) => { + try { + const { uid } = req.user; + const { id, studentId } = req.params; + const docRef = admin.firestore().collection('classrooms').doc(id); + const snap = await docRef.get(); + if (!snap.exists) { + return res.status(404).json({ message: 'Classroom not found' }); + } + const existing = snap.data(); + const isTeacher = existing.teacherId === uid; + const isSelf = studentId === uid; + if (!isTeacher && !isSelf) { + return res.status(403).json({ message: 'Forbidden' }); + } + const didRemove = await removeStudentFromClassroomDoc( + docRef, + existing, + studentId, + ); + if (!didRemove) { + return res.status(404).json({ message: 'Student not in classroom' }); + } + return res.sendStatus(204); + } catch (err) { + console.error('DELETE /classrooms/:id/students/:studentId error:', err); + return res.status(500).json({ message: err.message }); + } + }); + + const isRosterOnlyPatch = (data) => { + const keys = Object.keys(data).filter((k) => k !== 'updatedAt'); + return ( + keys.length === 1 && + keys[0] === 'studentIds' && + data.studentIds && + typeof data.studentIds === 'object' + ); + }; + + const isRosterAndAssignmentsPatch = (data) => { + const keys = Object.keys(data).filter((k) => k !== 'updatedAt'); + return ( + keys.length === 2 && + keys.includes('studentIds') && + keys.includes('classroomAssignments') && + data.studentIds && + typeof data.studentIds === 'object' && + data.classroomAssignments && + typeof data.classroomAssignments === 'object' + ); + }; + + const applyRosterPatch = async (docRef, existing, data) => { + const update = { + updatedAt: admin.firestore.FieldValue.serverTimestamp(), + }; + const existingIds = existing.studentIds || {}; + const incomingIds = data.studentIds; + + for (const [studentId, entry] of Object.entries(incomingIds)) { + update[`studentIds.${studentId}`] = entry; + } + for (const studentId of Object.keys(existingIds)) { + if (!(studentId in incomingIds)) { + update[`studentIds.${studentId}`] = admin.firestore.FieldValue.delete(); + } + } + + await docRef.update(update); + }; + + /** Non-teacher roster PATCH: may only join (add self) or leave (remove self). */ + const applyStudentRosterPatchAsNonTeacher = async (docRef, existing, data, uid) => { + const existingIds = existing.studentIds || {}; + const incomingIds = data.studentIds || {}; + const selfKey = findStudentRosterKey(existingIds, uid); + const addedKeys = Object.keys(incomingIds).filter((k) => !(k in existingIds)); + const removedKeys = Object.keys(existingIds).filter((k) => !(k in incomingIds)); + + const forbidden = () => { + const err = new Error('Forbidden'); + err.status = 403; + throw err; + }; + + // Join: preserve roster, add only the authenticated student. + if (removedKeys.length === 0 && addedKeys.length === 1 && addedKeys[0] === uid) { + for (const key of Object.keys(existingIds)) { + if (!(key in incomingIds)) forbidden(); + } + await docRef.update({ + [`studentIds.${uid}`]: incomingIds[uid], + updatedAt: admin.firestore.FieldValue.serverTimestamp(), + }); + return; + } + + // Leave: preserve roster for everyone else, remove only the authenticated student. + if (addedKeys.length === 0 && removedKeys.length === 1) { + const removedKey = removedKeys[0]; + if ( + removedKey !== uid && + removedKey !== selfKey && + studentEntryId(existingIds[removedKey]) !== uid + ) { + forbidden(); + } + for (const key of Object.keys(existingIds)) { + if (key === removedKey) continue; + if (!(key in incomingIds)) forbidden(); + } + for (const key of Object.keys(incomingIds)) { + if (!(key in existingIds)) forbidden(); + } + await removeStudentFromClassroomDoc(docRef, existing, uid); + return; + } + + forbidden(); + }; + // PATCH (classroom update) // Firestore merge:true recursively merges maps, so nested fields like // classroomAssignments.*.assignedTo and studentIds.*.assignments never drop // removed keys when the client sends a smaller object. Teachers send the full // classroom from the client after edits, so we replace the document for them. + // Roster-only patches (studentIds only) never change teacherId — even if + // teacherId was previously corrupted to match a student's uid. router.patch('/:id', async (req, res) => { try { const { uid } = req.user; @@ -325,13 +645,49 @@ module.exports = function createClassroomsRouter(firebaseTokenManager) { return res.status(404).json({ message: 'Classroom not found' }); } const existing = snap.data(); - const payload = { - ...data, - updatedAt: admin.firestore.FieldValue.serverTimestamp(), - }; const isOwner = existing.teacherId === uid; - await docRef.set(payload, { merge: !isOwner }); - return res.sendStatus(204); + + if (isRosterAndAssignmentsPatch(data) && isOwner) { + await applyRosterPatch(docRef, existing, { studentIds: data.studentIds }); + await applyClassroomAssignmentsPatch( + docRef, + existing, + data.classroomAssignments, + ); + return res.sendStatus(204); + } + + if (isRosterOnlyPatch(data)) { + if (isOwner) { + await applyRosterPatch(docRef, existing, data); + } else { + try { + await applyStudentRosterPatchAsNonTeacher(docRef, existing, data, uid); + } catch (err) { + if (err.status === 403) { + return res.status(403).json({ message: 'Forbidden' }); + } + if (err.status === 404) { + return res.status(404).json({ message: err.message }); + } + throw err; + } + } + return res.sendStatus(204); + } + + if (isOwner) { + const { teacherId: _ignoredTeacherId, ...dataWithoutTeacherId } = data; + const payload = { + ...dataWithoutTeacherId, + teacherId: existing.teacherId, + updatedAt: admin.firestore.FieldValue.serverTimestamp(), + }; + await docRef.set(payload, { merge: false }); + return res.sendStatus(204); + } + + return res.status(403).json({ message: 'Forbidden' }); } catch (err) { console.error('PATCH /classrooms error:', err); return res.status(500).json({ message: err.message }); diff --git a/src/components/ClassroomExtraMenu.tsx b/src/components/ClassroomExtraMenu.tsx index 82e2fdf3..bf1df8a8 100644 --- a/src/components/ClassroomExtraMenu.tsx +++ b/src/components/ClassroomExtraMenu.tsx @@ -9,6 +9,7 @@ import { connect } from 'react-redux'; import { State as ReduxState } from '../state'; import LocalizedString from '../util/LocalizedString'; import { AsyncClassroom } from '../state/State/Classroom'; +import { classroomNameAsString } from '../util/classroomDisplayName'; import Async from 'state/State/Async'; import TourTarget from './Tours/TourTarget'; import { TourRegistry } from '../tours/TourRegistry'; @@ -121,7 +122,7 @@ class ClassroomExtraMenu extends React.PureComponent { return ( - {LocalizedString.lookup(tr('Current Classroom: '), locale)} {latestClassroom?.classroomId || LocalizedString.lookup(tr('None'), locale)} + {LocalizedString.lookup(tr('Current Classroom: '), locale)} {latestClassroom ? classroomNameAsString(latestClassroom.classroomId, locale) : LocalizedString.lookup(tr('None'), locale)} {LocalizedString.lookup(tr('Classroom Teacher: '), locale)} {latestClassroom?.teacherDisplayName || LocalizedString.lookup(tr('None'), locale)} {latestClassroom ? ( diff --git a/src/components/Classrooms/ChallengeTabView.tsx b/src/components/Classrooms/ChallengeTabView.tsx index ce1af0c2..b39c95d7 100644 --- a/src/components/Classrooms/ChallengeTabView.tsx +++ b/src/components/Classrooms/ChallengeTabView.tsx @@ -209,7 +209,7 @@ class ChallengeTabView extends React.Component { theme={theme} view={view} currentStudentDisplayName={currentStudentDisplayName} - currentClassroom={currentStudentClassroom} + currentClassroom={view === 'teacherView' ? currentSelectedClassroom : currentStudentClassroom} tourRegistry={this.props.tourRegistry} /> ) diff --git a/src/components/Classrooms/CreateAssignmentView.tsx b/src/components/Classrooms/CreateAssignmentView.tsx index 26f08230..b18cfb04 100644 --- a/src/components/Classrooms/CreateAssignmentView.tsx +++ b/src/components/Classrooms/CreateAssignmentView.tsx @@ -386,6 +386,17 @@ const CreateAssignmentView = ({ const assignButtonTourActive = !originalAssignment && activeTourStepId === 'teacher-create-assignment-assign'; + function selectedStudentsLabel() { + const selectedCount = Object.keys(selectedStudents).length; + const rosterCount = Object.keys(loadedClassroom?.studentIds || {}).length; + if (rosterCount === 0) { + return `0 ${LocalizedString.lookup(tr('Students'), locale)}`; + } + return selectedCount === rosterCount + ? LocalizedString.lookup(tr('All Students'), locale) + : `${selectedCount} ${LocalizedString.lookup(tr('Students'), locale)}`; + } + function resolveStudentsForAssign(): Dict<{ id: string, displayName: string, assignments?: Dict }> { if (Object.keys(selectedStudents).length > 0) { return selectedStudents; @@ -418,7 +429,7 @@ const CreateAssignmentView = ({ ? !(Object.keys(selectedStudents).length > 0) : !( assignButtonTourActive || - (enableAssign && Object.keys(selectedStudents).length > 0) + enableAssign ); return ( @@ -445,7 +456,7 @@ const CreateAssignmentView = ({ onEditComplete?.(selectedStudents, editedAssignment); handleEdit(editedAssignment); })() - : (enableAssign && Object.keys(selectedStudents).length > 0) || assignButtonTourActive + : enableAssign || assignButtonTourActive ? runCreateAssign() : null; }}> @@ -464,7 +475,7 @@ const CreateAssignmentView = ({ onEditComplete?.(selectedStudents, editedAssignment); handleEdit(editedAssignment); })() - : (enableAssign && Object.keys(selectedStudents).length > 0) || assignButtonTourActive + : enableAssign || assignButtonTourActive ? runCreateAssign() : null; }}> @@ -536,17 +547,13 @@ const CreateAssignmentView = ({ ) : ( )} diff --git a/src/components/Classrooms/PeopleView.tsx b/src/components/Classrooms/PeopleView.tsx index f6bfcbb9..788567ed 100644 --- a/src/components/Classrooms/PeopleView.tsx +++ b/src/components/Classrooms/PeopleView.tsx @@ -145,8 +145,11 @@ const PeopleView = ({ function getStudents(currentSelectedClassroom: AsyncClassroom | null) { const loadedClassroom = Async.latestValue(currentSelectedClassroom); - const stateClassroom = classroomList[loadedClassroom?.docId || '']; - const students = Async.latestValue(stateClassroom)?.studentIds; + const docId = loadedClassroom?.docId || ''; + const stateClassroom = docId ? classroomList[docId] : undefined; + const students = + Async.latestValue(stateClassroom)?.studentIds ?? + loadedClassroom?.studentIds; return (
diff --git a/src/components/Dialog/LeaveClassDialog.tsx b/src/components/Dialog/LeaveClassDialog.tsx index ba13d498..b29fffa1 100644 --- a/src/components/Dialog/LeaveClassDialog.tsx +++ b/src/components/Dialog/LeaveClassDialog.tsx @@ -10,13 +10,17 @@ import { I18nAction } from '../../state/reducer'; import { connect } from 'react-redux'; import Form from '../interface/Form'; import { Classroom } from 'state/State/Classroom'; +import { + classroomNameAsString, + classroomNamesMatch, +} from '../../util/classroomDisplayName'; export interface LeaveClassDialogPublicProps extends ThemeProps, StyleProps { locale: LocalizedString.Language; onClose: () => void; currentClassroom: Classroom; - onLeaveClassDialogClose: () => void; + onLeaveClassDialogClose: () => Promise; } interface LeaveClassDialogPrivateProps { @@ -25,6 +29,7 @@ interface LeaveClassDialogPrivateProps { } interface LeaveClassDialogState { + errorMessage: string; } type Props = LeaveClassDialogPublicProps & LeaveClassDialogPrivateProps; @@ -51,33 +56,65 @@ const StyledForm = styled(Form, (props: ThemeProps) => ({ paddingRight: `${props.theme.itemPadding * 2}px`, })); +const ErrorMessage = styled('div', (props: ThemeProps) => ({ + color: '#ff6b6b', + fontSize: '0.9em', + textAlign: 'center', +})); + export class LeaveClassDialog extends React.PureComponent { + /** Exact name shown in the dialog — user must match this string. */ + private readonly confirmClassroomName_: string; constructor(props: Props) { super(props); + this.confirmClassroomName_ = classroomNameAsString( + props.currentClassroom.classroomId, + props.locale + ); + this.state = { errorMessage: '' }; } - onFinalize_ = (values: { [id: string]: string }) => { + onFinalize_ = async (values: { [id: string]: string }) => { const { leaveClassName } = values; - const { currentClassroom } = this.props; + const entered = typeof leaveClassName === 'string' ? leaveClassName : ''; + if (!classroomNamesMatch(entered, this.confirmClassroomName_)) { + this.setState({ + errorMessage: LocalizedString.lookup( + tr('Classroom name does not match. Type the classroom name exactly as shown above.'), + this.props.locale + ), + }); + return; + } try { - if (leaveClassName === currentClassroom.classroomId) { - this.props.onLeaveClassDialogClose(); - } else { - return; - } + this.setState({ errorMessage: '' }); + await this.props.onLeaveClassDialogClose(); } catch (error) { console.error('Error leaving classroom:', error); + this.setState({ + errorMessage: LocalizedString.lookup( + tr('Could not leave the classroom. Please try again.'), + this.props.locale + ), + }); } - - }; render() { - const { props } = this; - const { style, className, theme, onClose, locale, currentClassroom } = props; + const { props, state } = this; + const { style, className, theme, onClose, locale } = props; + const { errorMessage: leaveError } = state; + const displayName = this.confirmClassroomName_; const LEAVECLASSROOM_FORM_ITEMS: Form.Item[] = [ - Form.leaveClass('leaveClassName', LocalizedString.lookup(tr('Leave Classroom'), locale), LocalizedString.lookup(tr('Reenter classroom name to confirm leaving classroom.'), locale)), + Form.leaveClass( + 'leaveClassName', + LocalizedString.lookup(tr('Leave Classroom'), locale), + LocalizedString.lookup( + tr('Type the classroom name shown above to confirm.'), + locale + ) + ), ]; return ( @@ -89,8 +126,12 @@ export class LeaveClassDialog extends React.PureComponent {
- {LocalizedString.lookup(tr('Are you sure you want to leave: '), locale)}{currentClassroom.classroomId}? + {LocalizedString.lookup(tr('Are you sure you want to leave: '), locale)} + {displayName}?
+ {leaveError ? ( + {leaveError} + ) : null} { return; } - // if steps changed, clamp index + // if steps changed, clamp index and remeasure (e.g. placement updated for same step id) if (prevProps.steps !== this.props.steps) { const max = Math.max(0, this.props.steps.length - 1); if (this.state.stepIndex > max) { this.setState({ stepIndex: max }, () => this.measure(true)); + } else if (this.props.isOpen) { + this.measure(false); } } diff --git a/src/components/interface/Form.tsx b/src/components/interface/Form.tsx index 46f31cd4..5b7436c0 100644 --- a/src/components/interface/Form.tsx +++ b/src/components/interface/Form.tsx @@ -103,12 +103,16 @@ class Form extends React.PureComponent { const { items } = props; const { values } = state; - const ret = {}; + const ret: { [id: string]: unknown } = {}; for (const item of items) { - ret[item.id] = item.finalizer(values[item.id].text); + if (!(item.id in values)) continue; + const finalizer = item.finalizer ?? Form.IDENTITY_FINALIZER; + ret[item.id] = finalizer(values[item.id].text); } - this.props.onFinalize(ret); + void Promise.resolve(this.props.onFinalize(ret)).catch((error: unknown) => { + console.error('Form onFinalize error:', error); + }); }; private isFinalizeAllowed_ = () => { diff --git a/src/pages/ClassroomLeaderboard.tsx b/src/pages/ClassroomLeaderboard.tsx index 8b50de1e..4408a3dd 100644 --- a/src/pages/ClassroomLeaderboard.tsx +++ b/src/pages/ClassroomLeaderboard.tsx @@ -21,6 +21,7 @@ import { auth } from '../firebase/firebase'; import { LeaderboardEntry, LeaderboardUserContext } from 'state/State/LimitedChallengeLeaderboard'; import TourTarget from '../components/Tours/TourTarget'; import { TourRegistry } from '../tours/TourRegistry'; +import { classroomNameAsString } from '../util/classroomDisplayName'; const SELFIDENTIFIER = "My Scores!"; @@ -33,6 +34,7 @@ interface Challenge { interface Score { name: LocalizedString; // Challenge name + challengeId?: string; completed: boolean; score?: number; completionTime?: number; @@ -104,9 +106,21 @@ const ClassroomLeaderboardContainer = styled("div", (props: ThemeProps) => ({ display: 'flex', flexDirection: 'column', overflow: 'auto', + width: '95%', + marginTop: '20px', })); +/** Teacher view: size the leaderboard table panel without changing ChallengeTabView section tabs. */ +const TeacherLeaderboardPanel = styled('div', { + width: '75%', + height: '75%', + display: 'flex', + flexDirection: 'column', + minHeight: 0, + alignSelf: 'center', +}); + const ClassroomLeaderboardTitleContainer = styled('div', { alignItems: 'center', justifyContent: 'center', @@ -134,11 +148,15 @@ const StyledTableRow = styled('tr', (props: { key: string, self: string, ref: Re borderBottom: '1px solid #ddd', backgroundColor: props.self === SELFIDENTIFIER ? '#555' : '#000', })); -const LeaderboardScrollContainer = styled('div', { +const LeaderboardScrollContainer = styled('div', (props: { $teacherView?: boolean }) => ({ width: '100%', overflow: 'auto', WebkitOverflowScrolling: 'touch', - height: '85%', + height: props.$teacherView ? undefined : '85%', + ...(props.$teacherView ? { + flex: 1, + minHeight: 0, + } : {}), scrollbarWidth: 'thin', scrollbarColor: 'rgba(121,121,121,0.6) transparent', @@ -157,7 +175,7 @@ const LeaderboardScrollContainer = styled('div', { '::-webkit-scrollbar-thumb:hover': { backgroundColor: 'rgba(121,121,121,0.7)', }, -}); +})); const Button = styled('button', (props: ThemeProps & ButtonProps) => ({ padding: '12px 24px', @@ -204,13 +222,19 @@ const StickyNameTh = styled('th', (props: ThemeProps) => ({ zIndex: 7, whiteSpace: 'nowrap', })); -const LeaderboardContainer = styled('div', (props: ThemeProps) => ({ - width: '100%', - maxWidth: '900px', +const LeaderboardContainer = styled('div', (props: ThemeProps & { $teacherView?: boolean }) => ({ + width: '95%', + maxWidth: props.$teacherView ? 'none' : '900px', backgroundColor: props.theme.backgroundColor, border: `1px solid ${props.theme.borderColor}`, borderRadius: '8px', overflow: 'hidden', + ...(props.$teacherView ? { + flex: 1, + minHeight: 0, + display: 'flex', + flexDirection: 'column', + } : {}), })); const LeaderboardHeader = styled('div', (props: ThemeProps) => ({ @@ -372,21 +396,25 @@ class ClassroomLeaderboard extends React.Component { } async componentDidMount() { const { classroomId } = this.props.params; - if (this.props.view !== 'studentView') { - let currentUserId = ''; - const tokenManager = db.tokenManager; - if (tokenManager) { - const auth_ = tokenManager.auth(); - const currentUserAuth_ = auth_.currentUser; - currentUserId = currentUserAuth_.uid; - } - const classroom = await findClassroomDocByReadableId(classroomId, currentUserId); - this.setState({ shownClassroom: classroom }, () => { void this.onLog(); }); - } else { + if (this.props.view === 'studentView' || this.props.view === 'teacherView') { if (this.props.currentClassroom) { - this.setState({ shownClassroom: { docId: Async.latestValue(this.props.currentClassroom).docId, classroom: Async.latestValue(this.props.currentClassroom) } }, () => { void this.onLog(); }); + const classroom = Async.latestValue(this.props.currentClassroom); + this.setState( + { shownClassroom: { docId: classroom.docId, classroom } }, + () => { void this.onLog(); } + ); } + return; } + let currentUserId = ''; + const tokenManager = db.tokenManager; + if (tokenManager) { + const auth_ = tokenManager.auth(); + const currentUserAuth_ = auth_.currentUser; + currentUserId = currentUserAuth_.uid; + } + const classroom = await findClassroomDocByReadableId(classroomId, currentUserId); + this.setState({ shownClassroom: classroom }, () => { void this.onLog(); }); } async componentDidUpdate(prevProps: Readonly, prevState: Readonly): Promise { if (prevProps.params.classroomId !== this.props.params.classroomId) { @@ -409,7 +437,9 @@ class ClassroomLeaderboard extends React.Component { } componentWillUnmount(): void { - this.props.onClearSelectedClassroom(); + if (this.props.view !== 'teacherView') { + this.props.onClearSelectedClassroom(); + } } private myScoresRef = createRef(); @@ -438,10 +468,18 @@ class ClassroomLeaderboard extends React.Component { ); } - if (topEntries.length === 0 && !userContext) { + const isTeacherView = this.props.view === 'teacherView'; + const tableEntries = isTeacherView ? sortedUsers : topEntries; + + if (tableEntries.length === 0 && !userContext) { return ( - {LocalizedString.lookup(tr('No completions yet. Be the first to complete this challenge!'), locale)} + {LocalizedString.lookup( + isTeacherView + ? tr('No students or challenge scores yet for this classroom.') + : tr('No completions yet. Be the first to complete this challenge!'), + locale + )} ); } @@ -450,12 +488,10 @@ class ClassroomLeaderboard extends React.Component { const userInTopEntries = userContext && topEntries.some(e => e.id === userContext.id); // Show user context section only if user has a completion and is not in top N - const showUserContextSection = userContext && !userInTopEntries; - - + const showUserContextSection = !isTeacherView && userContext && !userInTopEntries; return ( - + @@ -473,10 +509,12 @@ class ClassroomLeaderboard extends React.Component { - {/* Top entries - already sorted by server */} - {topEntries.map((entry, index) => { + {tableEntries.map((entry, index) => { const rank = index + 1; - const isCurrentUser = params.studentId === entry.id; + const currentUid = auth.currentUser?.uid; + const isCurrentUser = currentUid + ? entry.id === currentUid + : params.studentId === entry.id; return this.renderLeaderboardRow(entry, rank, isCurrentUser, challengeArray); })} @@ -606,6 +644,7 @@ class ClassroomLeaderboard extends React.Component { for (const [challengeId, challenge] of Object.entries(userData.challenges)) { const score: Score = { name: tr(challengeId), + challengeId, completed: challengeCompletion(challenge), challengeCompletion: userData.challenges[challengeId] as ChallengeCompletion, @@ -620,7 +659,11 @@ class ClassroomLeaderboard extends React.Component { const sortedUsers = this.orderUsersByCompletedChallenges(users); const topThree = sortedUsers.slice(0, 3); - const me = sortedUsers.find(user => user.id === params.studentId); + const currentUid = auth.currentUser?.uid; + const me = + (currentUid && sortedUsers.find(user => user.id === currentUid)) || + (params.studentId && sortedUsers.find(user => user.id === params.studentId)) || + undefined; this.setState({ users, @@ -646,6 +689,22 @@ class ClassroomLeaderboard extends React.Component { return userArray; }; + private classroomLabelForExport = (): string => { + const { shownClassroom } = this.state; + const { locale } = this.props; + if (!shownClassroom?.classroom) return 'classroom'; + return classroomNameAsString(shownClassroom.classroom.classroomId, locale); + }; + + private canExportClassroomScores = (): boolean => { + const { loading, users, shownClassroom } = this.state; + return !loading && !!shownClassroom && Object.keys(users).length > 0; + }; + + private usersForExport = (): User[] => { + return this.orderUsersByCompletedChallenges(this.state.users); + }; + private customSort = (list: string[]): string[] => { const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }); @@ -671,23 +730,83 @@ class ClassroomLeaderboard extends React.Component { }); }; - private getCurrentUser = (): User => { - const { users } = this.state; + /** Leaderboard users are keyed by display name; match by uid or display name. */ + private findCurrentStudentUser = (): User | null => { + const { users, userContext } = this.state; const { currentStudentDisplayName } = this.props; - let currentUser: User; + const uid = auth.currentUser?.uid; + + if (userContext && (!uid || userContext.id === uid)) { + return userContext; + } + if (uid) { + const byUid = Object.values(users).find(u => u.id === uid); + if (byUid) return byUid; + } + if (currentStudentDisplayName && users[currentStudentDisplayName]) { + return users[currentStudentDisplayName]; + } + if (currentStudentDisplayName) { + return Object.values(users).find(u => u.name === currentStudentDisplayName) ?? null; + } + return null; + }; + + private getCurrentUser = (): User | null => { + const existing = this.findCurrentStudentUser(); const tokenManager = db.tokenManager; - if (tokenManager) { - const auth_ = tokenManager.auth(); - const currentUserAuth_ = auth_.currentUser; - currentUser = { - id: currentUserAuth_.uid, - name: currentStudentDisplayName || currentUserAuth_.displayName || 'Unknown', - scores: Object.values(users).find(u => u.id === currentStudentDisplayName)?.scores || [], - altId: Object.values(users).find(u => u.id === currentStudentDisplayName)?.altId || 'Unknown' + if (!tokenManager) return existing; + + const currentUserAuth_ = tokenManager.auth().currentUser; + if (!currentUserAuth_) return existing; + + const { currentStudentDisplayName } = this.props; + return { + id: currentUserAuth_.uid, + name: + existing?.name || + currentStudentDisplayName || + currentUserAuth_.displayName || + 'Unknown', + scores: existing?.scores ?? [], + altId: existing?.altId || 'Unknown', + }; + }; + + /** One row per leaderboard column, including challenges not yet attempted. */ + private scoresForStudentExport = (user: User): Score[] => { + const { challenges } = this.state; + const challengeIds = this.customSort(Object.keys(challenges)); + + return challengeIds.map(challengeId => { + const challengeMeta = challenges[challengeId]; + const existing = user.scores.find( + s => + s.challengeId === challengeId || + s.name['en-US'] === challengeMeta?.name['en-US'] + ); + if (existing) return existing; + return { + name: challengeMeta?.name ?? tr(challengeId), + challengeId, + completed: false, }; - } + }); + }; - return currentUser || null; + private exportStatusLabel = (user: User, score: Score): string => { + const { challenges } = this.state; + const challengeId = score.challengeId; + const challengeMeta = challengeId ? challenges[challengeId] : undefined; + const attempted = user.scores.some( + s => + s.challengeId === challengeId || + (challengeMeta && s.name['en-US'] === challengeMeta.name['en-US']) + ); + if (!attempted) { + return 'Not Attempted'; + } + return score.completed ? 'Completed' : 'Not Completed'; }; private getCurrentUserEmail = (): string | null => { @@ -704,10 +823,14 @@ class ClassroomLeaderboard extends React.Component { return null; }; - private exportUserScores = (user: User) => { + private exportUserScores = (_user?: User | null) => { + const resolved = this.findCurrentStudentUser() ?? _user ?? null; + if (!resolved) return; + const { locale } = this.props; const pdfDoc = new jsPDF(); const date = new Date(); + const exportScores = this.scoresForStudentExport(resolved); // Title pdfDoc.setFontSize(18); @@ -720,41 +843,51 @@ class ClassroomLeaderboard extends React.Component { // Basic Info pdfDoc.setFontSize(14); - pdfDoc.text(`Name: ${user.name}`, 20, 40); + pdfDoc.text(`Name: ${resolved.name}`, 20, 40); pdfDoc.text(`Email: ${this.getCurrentUserEmail() || 'Unknown'}`, 20, 50); - const sortedScores = this.customSort(user.scores.map(s => s.name['en-US'])).map(name => user.scores.find(s => s.name['en-US'] === name)); - // Scores pdfDoc.setFontSize(12); pdfDoc.text('Scores:', 20, 60); - sortedScores.forEach((score, i) => { + let y = 60; + exportScores.forEach((score) => { + y += 10; + if (y > 280) { + pdfDoc.addPage(); + y = 20; + } + const label = + LocalizedString.lookup(score.name, locale) || + LocalizedString.lookup(tr(`${score.name[locale]}`), locale) || + score.challengeId || + 'Unnamed'; pdfDoc.text( - `${LocalizedString.lookup(tr(`${score.name[locale]}`), locale) || "Unnamed"} - ${score.completed ? "Completed" : "Not Completed" - }`, + `${label} - ${this.exportStatusLabel(resolved, score)}`, 30, - 70 + i * 10 + y ); }); - pdfDoc.save(`${user.name}-scores.pdf`); + pdfDoc.save(`${resolved.name}-scores.pdf`); }; - // Export current user's JBC scores to PDF - very simple, completed or not completed with timestamp + // Export all students' JBC scores (completed / not completed) for the classroom. private exportClassroomScores() { - const { users, shownClassroom } = this.state; + if (!this.canExportClassroomScores()) return; + const { locale } = this.props; const pdfDoc = new jsPDF(); + const classroomLabel = this.classroomLabelForExport(); + const exportUsers = this.usersForExport(); const date = new Date(); - - Object.values(users).forEach((user, userIndex) => { + exportUsers.forEach((user, userIndex) => { // Title pdfDoc.setFontSize(18); - pdfDoc.text(`${shownClassroom.classroom.classroomId} General Challenge Scores`, 105, 20, { align: 'center' }); + pdfDoc.text(`${classroomLabel} General Challenge Scores`, 105, 20, { align: 'center' }); // Date pdfDoc.setFontSize(16); @@ -778,18 +911,21 @@ class ClassroomLeaderboard extends React.Component { 60 + i * 10 ); }); - if (userIndex < Object.values(users).length - 1) { + if (userIndex < exportUsers.length - 1) { pdfDoc.addPage(); } }); - pdfDoc.save(`${shownClassroom.classroom.classroomId}-scores.pdf`); + pdfDoc.save(`${classroomLabel}-general-scores.pdf`); } private exportDetailedClassroomScores() { - const { users, shownClassroom } = this.state; + if (!this.canExportClassroomScores()) return; + const { locale, challenges } = this.props; + const classroomLabel = this.classroomLabelForExport(); + const exportUsers = this.usersForExport(); const pdf = new jsPDF(); const date = new Date(); @@ -817,13 +953,13 @@ class ClassroomLeaderboard extends React.Component { return y; }; - Object.values(users).forEach((user, userIndex) => { + exportUsers.forEach((user, userIndex) => { // Header pdf.setFontSize(18); pdf.setTextColor('black'); pdf.text( - `${shownClassroom.classroom.classroomId} Detailed Challenge Scores`, + `${classroomLabel} Detailed Challenge Scores`, 105, 20, { align: 'center' } ); @@ -844,91 +980,87 @@ class ClassroomLeaderboard extends React.Component { ).map(name => user.scores.find(s => s.name["en-US"] === name)); sortedScores.forEach(score => { + const challengeId = score.challengeId; + if (!challengeId) return; - Object.values(challenges).forEach(challenge => { + const asyncChallenge = challenges[challengeId]; + const latest = asyncChallenge ? Async.latestValue(asyncChallenge) : null; + if (!latest) return; - const latest = Async.latestValue(challenge); - if (!latest) return; + const successGoals = Object.values(latest.successGoals || {}); + const failureGoals = Object.values(latest.failureGoals || {}); - const sceneId = latest.sceneId; - if (sceneId !== score.name[locale]) return; - - const successGoals = Object.values(latest.successGoals || {}); - const failureGoals = Object.values(latest.failureGoals || {}); - - // Challenge Title - y = writeLine( - `${LocalizedString.lookup(tr(`${latest.name[locale]}:`), locale) || "Unnamed"}`, - 30, 10, "helvetica", "bold" - ); + // Challenge Title + y = writeLine( + `${LocalizedString.lookup(tr(`${latest.name[locale]}:`), locale) || "Unnamed"}`, + 30, 10, "helvetica", "bold" + ); - // Success Section - if (successGoals.length > 0) { - y = writeLine("Success", 55, 10, "helvetica", "normal"); - } + // Success Section + if (successGoals.length > 0) { + y = writeLine("Success", 55, 10, "helvetica", "normal"); + } - successGoals.forEach(goal => { - const completion = score.challengeCompletion; - const isCompleted = completion?.success?.exprStates?.[goal.exprId]; - - if (isCompleted) { - // Checkbox ✓ - y = writeLine("3", 65, 10, "ZapfDingbats", "normal", "green"); - - // Goal text also green - pdf.setFont("helvetica", "normal"); - pdf.setTextColor("green"); - pdf.text( - LocalizedString.lookup(tr(goal.name[locale]), locale), - 72, - y - ); - } else { - y = writeLine( - LocalizedString.lookup(tr(goal.name[locale]), locale), - 72, 10, "helvetica", "normal", "black" - ); - } - }); - - if (failureGoals.length > 0) { - y = writeLine("Failure", 55, 10); + successGoals.forEach(goal => { + const completion = score.challengeCompletion; + const isCompleted = completion?.success?.exprStates?.[goal.exprId]; + + if (isCompleted) { + // Checkbox ✓ + y = writeLine("3", 65, 10, "ZapfDingbats", "normal", "green"); + + // Goal text also green + pdf.setFont("helvetica", "normal"); + pdf.setTextColor("green"); + pdf.text( + LocalizedString.lookup(tr(goal.name[locale]), locale), + 72, + y + ); + } else { + y = writeLine( + LocalizedString.lookup(tr(goal.name[locale]), locale), + 72, 10, "helvetica", "normal", "black" + ); } + }); - failureGoals.forEach(goal => { - const completion = score.challengeCompletion; - const isFailed = completion?.failure?.exprStates?.[goal.exprId]; - - if (isFailed) { - // Checkbox X in red - y = writeLine("3", 65, 10, "ZapfDingbats", "normal", "red"); - pdf.setFont("helvetica", "normal"); - pdf.setTextColor("red"); - pdf.text( - LocalizedString.lookup(tr(goal.name[locale]), locale), - 72, - y - ); - } else { - y = writeLine( - LocalizedString.lookup(tr(goal.name[locale]), locale), - 72, 10, "helvetica", "normal", "black" - ); - } - }); + if (failureGoals.length > 0) { + y = writeLine("Failure", 55, 10); + } + failureGoals.forEach(goal => { + const completion = score.challengeCompletion; + const isFailed = completion?.failure?.exprStates?.[goal.exprId]; + + if (isFailed) { + // Checkbox X in red + y = writeLine("3", 65, 10, "ZapfDingbats", "normal", "red"); + pdf.setFont("helvetica", "normal"); + pdf.setTextColor("red"); + pdf.text( + LocalizedString.lookup(tr(goal.name[locale]), locale), + 72, + y + ); + } else { + y = writeLine( + LocalizedString.lookup(tr(goal.name[locale]), locale), + 72, 10, "helvetica", "normal", "black" + ); + } }); }); // Add new page between users - if (userIndex < Object.values(users).length - 1) { + if (userIndex < exportUsers.length - 1) { pdf.addPage(); y = 50; } }); - pdf.save(`${shownClassroom.classroom.classroomId}-scores.pdf`); + pdf.save(`${classroomLabel}-detailed-scores.pdf`); } private onSeeMyBadges() { @@ -936,9 +1068,10 @@ class ClassroomLeaderboard extends React.Component { } private renderClassroomLeaderboardNew = () => { - const { theme, locale, currentStudentDisplayName } = this.props; + const { theme, locale, currentStudentDisplayName, view } = this.props; + const isTeacherView = view === 'teacherView'; return ( - + {LocalizedString.lookup(tr('Leaderboard'), locale)} @@ -988,7 +1121,7 @@ class ClassroomLeaderboard extends React.Component { ); return ( - <> +
{view === 'studentView' ? < div style={{ width: '100%', alignItems: 'center', display: 'flex', flexDirection: 'column' }}> @@ -1001,28 +1134,50 @@ class ClassroomLeaderboard extends React.Component { locale={locale} onClose={() => this.setState({ showBadgeDialog: false })} currentStudentDisplayName={currentStudentDisplayName} - currentUserScores={users[currentStudentDisplayName]?.scores || []} + currentUserScores={ + this.findCurrentStudentUser()?.scores || + (currentStudentDisplayName ? users[currentStudentDisplayName]?.scores : undefined) || + [] + } theme={theme} />}
- : + : < div style={{ width: '100%', alignItems: 'center', display: 'flex', flexDirection: 'column' }}>

{LocalizedString.lookup(tr("Classroom Leaderboard"), locale)}

- - + + + + + +
- {this.renderClassroomLeaderboardNew()} + + {this.renderClassroomLeaderboardNew()} +
} - + ); } } @@ -1036,10 +1191,10 @@ export default connect((state: ReduxState) => { }); }, -(dispatch) => ({ - onClearSelectedClassroom: () => - dispatch(ClassroomsAction.clearSelectedClassroom({})), -}) + (dispatch) => ({ + onClearSelectedClassroom: () => + dispatch(ClassroomsAction.clearSelectedClassroom({})), + }) )(CompWithRouter) as React.ComponentType; \ No newline at end of file diff --git a/src/pages/ClassroomStudentView.tsx b/src/pages/ClassroomStudentView.tsx index 9c041999..674cada2 100644 --- a/src/pages/ClassroomStudentView.tsx +++ b/src/pages/ClassroomStudentView.tsx @@ -349,6 +349,9 @@ class ClassroomStudentView extends React.Component { this.setState({ showJoinClassroomDialog: true }); }; + private classroomForLeave_ = (): AsyncClassroom | null => + this.props.currentStudentClassroom ?? this.state.currentClassroom ?? null; + private onLeaveClassroomDialog_ = () => { this.setState({ showLeaveClassroomDialog: true }); }; @@ -362,7 +365,10 @@ class ClassroomStudentView extends React.Component { ); if (classroom) { - this.props.onStudentAdded(inviteCode, auth.currentUser?.uid || '', displayName); + const docId = Async.latestValue(classroom).docId; + if (docId) { + this.props.onStudentAdded(docId, auth.currentUser?.uid || '', displayName); + } this.props.onJoinClassroom(classroom); this.setState({ showJoinClassroomDialog: false, isStudentInClassroom: true, currentClassroom: classroom, currentStudentDisplayName: displayName }); } @@ -371,11 +377,12 @@ class ClassroomStudentView extends React.Component { }; private onCloseLeaveClassroomDialog_ = async () => { - const { currentClassroom } = this.state; - await this.props.onRemoveStudentFromClassroom( - auth.currentUser?.uid || '', - currentClassroom - ); + const currentClassroom = this.classroomForLeave_(); + const uid = auth.currentUser?.uid; + if (!currentClassroom || !uid) { + throw new Error('No classroom loaded'); + } + await this.props.onRemoveStudentFromClassroom(uid, currentClassroom); this.props.navigate(`/classrooms/${auth.currentUser?.uid || ''}/studentView/`); this.setState({ showLeaveClassroomDialog: false, isStudentInClassroom: false, currentClassroom: null, currentStudentDisplayName: undefined }); }; @@ -646,10 +653,10 @@ class ClassroomStudentView extends React.Component { {this.renderMyClassroom()} - {showLeaveClassroomDialog && ( + {showLeaveClassroomDialog && this.classroomForLeave_() && ( - removeStudentFromClassroom(studentId, currentClassroom), + onRemoveStudentFromClassroom: async (studentId: string, currentClassroom: AsyncClassroom) => { + await removeStudentFromClassroom(studentId, currentClassroom); + dispatch(ClassroomsAction.removeStudentFromClassroom({ + studentId, + currentClassroom, + persist: false, + })); + }, }))(withNavigate(ClassroomStudentView)); diff --git a/src/pages/ClassroomTeacherView.tsx b/src/pages/ClassroomTeacherView.tsx index ad57691b..bef1943e 100644 --- a/src/pages/ClassroomTeacherView.tsx +++ b/src/pages/ClassroomTeacherView.tsx @@ -307,7 +307,9 @@ class ClassroomTeacherView extends React.Component { showAreYouSureDialog: false, leaderboardClassroom: null, cardContainerVisible: true, - teacherTourSteps: getTeacherViewTourStepsForClassroom(props.locale, null), + teacherTourSteps: getTeacherViewTourStepsForClassroom(props.locale, null, { + ownedClassroomCount: ClassroomTeacherView.countOwnedClassrooms_(props.classroomList), + }), teacherSubviewHasModal: false, }; @@ -362,13 +364,21 @@ class ClassroomTeacherView extends React.Component { return cur.value; } + private static countOwnedClassrooms_(classroomList: Props['classroomList']): number { + if (!classroomList) return 0; + return Object.values(classroomList).filter(c => c.type === Async.Type.Loaded).length; + } + private syncTeacherTourSteps_(): void { const classroom = this.computeSyncedLoadedClassroom_(); - const next = getTeacherViewTourStepsForClassroom(this.props.locale, classroom ?? null); + const ownedClassroomCount = ClassroomTeacherView.countOwnedClassrooms_(this.props.classroomList); + const next = getTeacherViewTourStepsForClassroom(this.props.locale, classroom ?? null, { + ownedClassroomCount, + }); const prev = this.state.teacherTourSteps; if ( prev.length === next.length && - prev.every((s, i) => s.id === next[i].id) + prev.every((s, i) => s.id === next[i].id && s.placement === next[i].placement) ) { return; } @@ -571,13 +581,13 @@ class ClassroomTeacherView extends React.Component { const { selectedStudentId } = this.state; const ivygateClassrooms: IvyGateClassroom[] = []; - for (const [id, asyncClassroom] of Object.entries(classroomList)) { + for (const [id, asyncClassroom] of Object.entries(classroomList ?? {})) { - if (asyncClassroom.type === Async.Type.Loaded && classroomList !== null) { + if (asyncClassroom.type === Async.Type.Loaded) { const classroom = asyncClassroom.value; // map studentIds to match IvygateFileExplorer's User objects - const classroomUsers: User[] = Object.values(classroom.studentIds).map((studentId) => { + const classroomUsers: User[] = Object.values(classroom.studentIds ?? {}).map((studentId) => { const studentChallenges = this.challengeCache[selectedStudentId]; const userProjects: SimClassroomProject[] = studentChallenges ? Object.entries(studentChallenges).flatMap(([challengeId, score]) => { @@ -835,8 +845,8 @@ class ClassroomTeacherView extends React.Component { renameClassroomTarget?.type === Async.Type.Loaded || showAreYouSureDialog || showCreateClassroomDialog; - return Object.entries(classroomList).map(([id, asyncClassroom]) => { - if (asyncClassroom.type === Async.Type.Loaded && classroomList !== null) { + return Object.entries(classroomList ?? {}).map(([id, asyncClassroom]) => { + if (asyncClassroom.type === Async.Type.Loaded) { const classroom = asyncClassroom.value; const cardBody = ( this.setState({ currentSelectedClassroom: asyncClassroom })}> diff --git a/src/state/reducer/classrooms.ts b/src/state/reducer/classrooms.ts index 6c238545..a009d1aa 100644 --- a/src/state/reducer/classrooms.ts +++ b/src/state/reducer/classrooms.ts @@ -4,6 +4,7 @@ import Async from '../State/Async'; import Dict from '../../util/objectOps/Dict'; import Selector from '../../db/Selector'; import db from '../../db'; +import { auth } from '../../firebase/firebase'; import { errorToAsyncError } from './util'; import construct from '../../util/redux/construct'; import ChallengeCompletion from 'state/State/ChallengeCompletion'; @@ -117,7 +118,8 @@ export namespace ClassroomsAction { type: 'classrooms/remove-student-from-classroom'; studentId: string; currentClassroom: AsyncClassroom; - + /** When false, only update Redux (caller already persisted). */ + persist?: boolean; } export const removeStudentFromClassroom = construct('classrooms/remove-student-from-classroom'); @@ -325,8 +327,11 @@ const listOwned = async () => { const result = await db.list('classrooms'); const classrooms: Dict = {}; - Object.entries(result).forEach(([id, classroom]) => { - classrooms[id] = Async.loaded({ brief: {}, value: classroom }); + Object.entries(result).forEach(([id, raw]) => { + const classroomData = classroomFromListPayload(raw, id); + if (classroomData) { + classrooms[id] = Async.loaded({ brief: {}, value: classroomData }); + } }); store.dispatch(ClassroomsAction.setClassrooms({ classrooms })); @@ -428,7 +433,7 @@ export async function addStudentToClassroomAsyncRaw( await db.set( { collection: 'classrooms', id: docId }, - { ...Async.latestValue(foundClassroom), studentIds: updatedStudentIds }, + { studentIds: updatedStudentIds }, true ); @@ -437,11 +442,25 @@ export async function addStudentToClassroomAsyncRaw( studentIds: updatedStudentIds }; - const updatedValue = { ...Async.latestValue(foundClassroom), studentIds: updatedStudentIds }; - return { + const updatedValue = { + ...Async.latestValue(foundClassroom), + docId: docId || Async.latestValue(foundClassroom).docId, + studentIds: updatedStudentIds, + }; + const asyncClassroom: AsyncClassroom = { type: Async.Type.Loaded, value: updatedValue, }; + + if (docId) { + store.dispatch( + ClassroomsAction.setClassrooms({ + classrooms: { [docId]: asyncClassroom }, + }) + ); + } + + return asyncClassroom; } // Add student to classroom in database and update store (action) @@ -456,25 +475,200 @@ export const studentAdded = ( studentEntry }); +function resolveClassroomDocId( + classroom: Classroom, + entities: Dict = store.getState().classrooms.entities +): string | undefined { + for (const [entityId, asyncClassroom] of Object.entries(entities)) { + if (asyncClassroom.type !== Async.Type.Loaded) continue; + if (asyncClassroom.value.classroomId === classroom.classroomId) { + return entityId; + } + } + + return classroom.docId?.trim() || undefined; +} + +async function loadStudentEnrollmentFromServer(): Promise<{ + docId: string; + classroom: Classroom; +} | null> { + const result = await db.get>( + Selector.classroom('myClassroom') + ); + const entry = result ? Object.entries(result)[0] : undefined; + if (!entry) return null; + const [docId, raw] = entry; + return { docId, classroom: { ...raw, docId } }; +} + +function studentIdFromRosterEntry( + entry: { id?: string | Dict } | undefined +): string | undefined { + if (!entry?.id) return undefined; + if (typeof entry.id === 'string') return entry.id; + if (typeof entry.id === 'object' && entry.id['en-US']) return entry.id['en-US']; + return undefined; +} + +function omitStudentFromRoster( + studentIds: Classroom['studentIds'] | undefined, + studentId: string +): Classroom['studentIds'] { + const next = { ...(studentIds || {}) }; + for (const [key, entry] of Object.entries(next)) { + if (key === studentId || studentIdFromRosterEntry(entry) === studentId) { + delete next[key]; + } + } + return next; +} + +function removeStudentFromAssignmentAssignedTo( + classroomAssignments: Classroom['classroomAssignments'] | undefined, + studentId: string +): Classroom['classroomAssignments'] | undefined { + if (!classroomAssignments) return classroomAssignments; + let changed = false; + const next: Dict = {}; + for (const [assignmentKey, assignment] of Object.entries(classroomAssignments)) { + if (!assignment?.assignedTo) { + next[assignmentKey] = assignment; + continue; + } + const assignedTo = { ...assignment.assignedTo }; + for (const [key, entry] of Object.entries(assignedTo)) { + if (key === studentId || studentIdFromRosterEntry(entry) === studentId) { + delete assignedTo[key]; + changed = true; + } + } + next[assignmentKey] = changed + ? { ...assignment, assignedTo } + : assignment; + } + return changed ? next : classroomAssignments; +} + +/** Roster + assignment assignees after a student leaves or is removed. */ +export function applyStudentRemovedFromClassroom( + classroom: Classroom, + studentId: string +): Classroom { + const studentIds = omitStudentFromRoster(classroom.studentIds, studentId); + const classroomAssignments = removeStudentFromAssignmentAssignedTo( + classroom.classroomAssignments, + studentId + ); + let challengePointsOverrides = classroom.challengePointsOverrides; + if (challengePointsOverrides?.[studentId]) { + challengePointsOverrides = { ...challengePointsOverrides }; + delete challengePointsOverrides[studentId]; + } + return { + ...classroom, + studentIds, + classroomAssignments, + challengePointsOverrides, + }; +} + +function classroomEntityAfterStudentRemoved( + state: ClassroomsState, + classroom: Classroom, + studentId: string +): { docId: string | undefined; entity: AsyncClassroom | null } { + const docId = resolveClassroomDocId(classroom, state.entities); + if (!docId) return { docId: undefined, entity: null }; + const cleaned = applyStudentRemovedFromClassroom(classroom, studentId); + return { + docId, + entity: Async.loaded({ + brief: {}, + value: { ...cleaned, docId }, + }), + }; +} + +/** POST /api/classrooms/leave or /api/classrooms/:id/leave (classrooms.js). */ +async function postClassroomLeave(docId?: string): Promise { + const id = docId ? `${docId}/leave` : 'leave'; + await db.set(Selector.classroom(id), {}, false); +} + +/** DELETE /api/classrooms/:id/students/:studentId (classrooms.js). */ +async function deleteClassroomStudent(docId: string, studentId: string): Promise { + await db.delete(Selector.classroom(`${docId}/students/${studentId}`)); +} + +/** PATCH roster + assignments after removal (classrooms.js applyRosterPatch + applyClassroomAssignmentsPatch). */ +async function patchClassroomAfterStudentRemoval( + docId: string, + classroom: Classroom, + studentId: string +): Promise { + const cleaned = applyStudentRemovedFromClassroom(classroom, studentId); + await db.set( + Selector.classroom(docId), + { + studentIds: cleaned.studentIds ?? {}, + classroomAssignments: cleaned.classroomAssignments ?? {}, + }, + true + ); +} + +function isDbNotFound(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false; + const e = error as { code?: unknown }; + return e.code === 404; +} + // Remove student from classroom in database and update store export const removeStudentFromClassroom = async ( studentId: string, currentClassroom: AsyncClassroom ) => { - const exisitingStudentIds = Object.keys(Async.latestValue(currentClassroom).studentIds); - if (exisitingStudentIds.includes(studentId)) { - const updatedStudentIds = { ...Async.latestValue(currentClassroom).studentIds }; - delete updatedStudentIds[studentId]; - const docId = Async.latestValue(currentClassroom).docId; + const classroom = Async.latestValue(currentClassroom); + const authUid = auth.currentUser?.uid; + const isSelfLeave = !!authUid && studentId === authUid; - await db.set( - { collection: 'classrooms', id: docId }, - { ...Async.latestValue(currentClassroom), studentIds: updatedStudentIds }, - false - ); + if (isSelfLeave) { + try { + await postClassroomLeave(); + return; + } catch (error) { + if (!isDbNotFound(error)) throw error; + } + + const docId = + resolveClassroomDocId(classroom) || classroom.docId?.trim(); + if (!docId) { + throw new Error('Cannot leave classroom: classroom not found on server'); + } + + try { + await postClassroomLeave(docId); + return; + } catch (error) { + if (!isDbNotFound(error)) throw error; + } + + throw new Error('Cannot leave classroom: classroom not found on server'); + } + + const docId = resolveClassroomDocId(classroom); + if (!docId) { + throw new Error('Cannot remove student: missing classroom document id'); } + try { + await deleteClassroomStudent(docId, studentId); + } catch (error) { + if (!isDbNotFound(error)) throw error; + await patchClassroomAfterStudentRemoval(docId, classroom, studentId); + } }; // Check if student is in any classroom @@ -488,14 +682,31 @@ export const studentInClassroom = async ( const normalized = typeof studentId === 'string' ? studentId : studentId['en-US']; - // Extract the real Classroom object - const classroom = result ? Object.values(result)[0] : null; + const entry = result ? Object.entries(result)[0] : undefined; + if (!entry) { + return { inClassroom: false, classroom: null }; + } + + const [docId, rawClassroom] = entry; + const classroom: Classroom = { + ...rawClassroom, + docId, + }; - if (!classroom || !classroom.studentIds) { + if (!classroom.studentIds) { return { inClassroom: false, classroom: null }; } - const inClass = normalized in classroom.studentIds; + const inClass = + normalized in classroom.studentIds || + Object.values(classroom.studentIds).some( + (entry) => + entry && + (entry.id === normalized || + (typeof entry.id === 'object' && + entry.id !== null && + entry.id['en-US'] === normalized)) + ); const asyncClassroom: AsyncClassroom = { brief: {}, @@ -503,7 +714,7 @@ export const studentInClassroom = async ( value: classroom, }; - store.dispatch(ClassroomsAction.setClassrooms({ classrooms: { [asyncClassroom.value.docId || '']: asyncClassroom } })); + store.dispatch(ClassroomsAction.setClassrooms({ classrooms: { [docId]: asyncClassroom } })); return { inClassroom: inClass, classroom: inClass ? asyncClassroom : null }; } catch (error) { console.error('Error checking if student has classroom:', error); @@ -544,11 +755,11 @@ function classroomFromListPayload(raw: unknown, docIdFromKey: string): Classroom const v = raw as Record; if (v.type === Async.Type.Loaded && v.value && typeof v.value === 'object') { const inner = v.value as Classroom; - return { ...inner, docId: inner.docId || docIdFromKey }; + return { ...inner, docId: docIdFromKey, studentIds: inner.studentIds ?? {} }; } const c = raw as Classroom; if (typeof c.teacherId === 'string' && c.teacherId.length > 0) { - return { ...c, docId: c.docId || docIdFromKey }; + return { ...c, docId: docIdFromKey, studentIds: c.studentIds ?? {} }; } return null; } @@ -1017,7 +1228,10 @@ export const reduceClassrooms = ( case 'classrooms/set-classroom': { return { ...state, - [action.classroomId]: action.classroom, + entities: { + ...state.entities, + [action.classroomId]: action.classroom, + }, }; } @@ -1036,28 +1250,44 @@ export const reduceClassrooms = ( return state; } case 'classrooms/student-added': { - const asyncClassroom = state.entities[action.classroomId]; + const entityKey = + state.entities[action.classroomId] + ? action.classroomId + : Object.entries(state.entities).find( + ([, asyncClassroom]) => + asyncClassroom.type === Async.Type.Loaded && + asyncClassroom.value.docId === action.classroomId + )?.[0]; + if (!entityKey) return state; + + const asyncClassroom = state.entities[entityKey]; if (!asyncClassroom || asyncClassroom.type !== Async.Type.Loaded) return state; const classroom = asyncClassroom.value; - - const updated = { - ...classroom, - studentIds: { - ...classroom.studentIds, - [action.studentId]: { id: action.studentId, displayName: action.displayName } - } + const studentIds = { + ...(classroom.studentIds ?? {}), + [action.studentId]: { id: action.studentId, displayName: action.displayName }, }; + const updated = { ...classroom, studentIds }; + const loaded = Async.loaded({ + brief: asyncClassroom.brief, + value: updated, + }); + + const selectedClassroom = + state.selectedClassroom?.type === Async.Type.Loaded && + resolveClassroomDocId(state.selectedClassroom.value, state.entities) === entityKey + ? loaded + : state.selectedClassroom; + return { ...state, entities: { ...state.entities, - [action.classroomId]: Async.loaded({ - brief: asyncClassroom.brief, - value: updated - }) - } + [entityKey]: loaded, + }, + selectedClassroom, }; } @@ -1070,37 +1300,35 @@ export const reduceClassrooms = ( } case 'classrooms/remove-student-from-classroom': { - void removeStudentFromClassroom(action.studentId, action.currentClassroom); - const exisitingStudentIds = Object.keys(Async.latestValue(action.currentClassroom).studentIds); - - if (exisitingStudentIds.includes(action.studentId)) { - const updatedStudentIds = { ...Async.latestValue(action.currentClassroom).studentIds }; - delete updatedStudentIds[action.studentId]; - const docId = Async.latestValue(action.currentClassroom).docId; - - // store.dispatch( - // ClassroomsAction.setClassroom({ - // classroomId: docId, - // classroom: Async.loaded({ - // brief: {}, - // value: { ...Async.latestValue(action.currentClassroom), studentIds: updatedStudentIds } - // }) - // }) - // ); - return { - ...state, - entities: { - ...state.entities, - [Async.latestValue(action.currentClassroom).docId]: Async.loaded({ - brief: {}, - value: { ...Async.latestValue(action.currentClassroom), studentIds: updatedStudentIds } - }) - }, - currentStudentClassroom: null - }; + if (action.persist !== false) { + void removeStudentFromClassroom(action.studentId, action.currentClassroom); } - return state; + const classroom = Async.latestValue(action.currentClassroom); + const { docId, entity } = classroomEntityAfterStudentRemoved( + state, + classroom, + action.studentId + ); + + const entities = entity && docId + ? { ...state.entities, [docId]: entity } + : state.entities; + + const selectedClassroom = + entity && + docId && + state.selectedClassroom?.type === Async.Type.Loaded && + resolveClassroomDocId(state.selectedClassroom.value, state.entities) === docId + ? entity + : state.selectedClassroom; + + return { + ...state, + entities, + selectedClassroom, + ...(action.persist === false ? { currentStudentClassroom: null } : {}), + }; } case 'classrooms/student-in-classroom': { diff --git a/src/tours/Tours.tsx b/src/tours/Tours.tsx index fa45155d..f472a3f4 100644 --- a/src/tours/Tours.tsx +++ b/src/tours/Tours.tsx @@ -507,7 +507,7 @@ export function getTeacherViewTourSteps( targetKey: classroomTabTargetKey('Leaderboard', locale), title: LocalizedString.lookup(tr('Leaderboard tab'), locale), content: LocalizedString.lookup( - tr('Switch between Default JBC Challenges and Limited Challenges to see how the class is performing on simulator scenes and timed challenges.'), + tr('Switch between Default JBC Challenges and Limited Challenges to see how the class is performing. Use Export All General Scores for a PDF of each student\'s completion status, or Export All Detailed Scores for success and failure goals per challenge.'), locale ), placement: 'bottom', @@ -532,12 +532,29 @@ export function getTeacherViewTourSteps( return steps; } +/** Tooltip side for steps that spotlight the newly created classroom card. */ +function teacherNewClassroomCardPlacement(ownedClassroomCount: number): TourPlacement { + // Sole class sits directly beside "Create New Classroom"; place the dialog on the card's right edge. + return ownedClassroomCount === 1 ? 'right' : 'left'; +} + /** Teacher tour for a loaded classroom (assignments segment always included so new classes can learn Create Assignment). */ export function getTeacherViewTourStepsForClassroom( locale: LocalizedString.Language, - _classroom: Classroom | undefined | null + _classroom: Classroom | undefined | null, + options?: { ownedClassroomCount?: number } ): TourStep[] { - return getTeacherViewTourSteps(locale); + const steps = getTeacherViewTourSteps(locale); + const count = options?.ownedClassroomCount ?? 0; + if (count !== 1) { + return steps; + } + const placement = teacherNewClassroomCardPlacement(count); + return steps.map(step => + step.id === 'see-created-classroom' || step.id === 'classroom-users' + ? { ...step, placement } + : step + ); } export function getStudentViewTourSteps(locale: LocalizedString.Language): TourStep[] { diff --git a/src/util/classroomDisplayName.ts b/src/util/classroomDisplayName.ts new file mode 100644 index 00000000..94dec462 --- /dev/null +++ b/src/util/classroomDisplayName.ts @@ -0,0 +1,30 @@ +import LocalizedString from './LocalizedString'; +import Dict from './objectOps/Dict'; + +/** Human-readable classroom name for UI and leave confirmation (handles localized Firestore values). */ +export function classroomNameAsString( + classroomId: string | Dict | unknown, + locale: LocalizedString.Language = LocalizedString.EN_US +): string { + if (typeof classroomId === 'string') { + return classroomId.trim(); + } + if (classroomId && typeof classroomId === 'object' && !Array.isArray(classroomId)) { + return LocalizedString.lookup(classroomId as LocalizedString, locale).trim(); + } + return String(classroomId ?? '').trim(); +} + +export function normalizeClassroomNameForCompare(value: string): string { + return value.trim().replace(/\s+/g, ' '); +} + +export function classroomNamesMatch(entered: string, expected: string): boolean { + const a = normalizeClassroomNameForCompare(entered); + const b = normalizeClassroomNameForCompare(expected); + return ( + a.length > 0 && + b.length > 0 && + a.localeCompare(b, undefined, { sensitivity: 'base' }) === 0 + ); +} From 5e788c47d6592beaecdec18985ad3a49fd300e84 Mon Sep 17 00:00:00 2001 From: Erin Date: Tue, 19 May 2026 14:47:23 -0500 Subject: [PATCH 2/2] Linting --- classrooms.js | 17 ++++++++++++----- src/pages/ClassroomLeaderboard.tsx | 14 ++++++-------- src/tours/Tours.tsx | 4 ++-- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/classrooms.js b/classrooms.js index 17531549..a316c7ab 100644 --- a/classrooms.js +++ b/classrooms.js @@ -89,7 +89,8 @@ async function applyClassroomAssignmentsPatch(docRef, existing, incomingAssignme } if (incomingAssignment) { - const { assignedTo: _at, ...rest } = incomingAssignment; + const rest = { ...incomingAssignment }; + delete rest.assignedTo; for (const [field, value] of Object.entries(rest)) { update[new FieldPath('classroomAssignments', assignmentKey, field)] = value; } @@ -203,7 +204,8 @@ module.exports = function createClassroomsRouter(firebaseTokenManager) { if (existingSnap.exists) { const existing = existingSnap.data(); - const { teacherId: _ignoredTeacherId, ...dataWithoutTeacherId } = data; + const dataWithoutTeacherId = { ...data }; + delete dataWithoutTeacherId.teacherId; await docRef.set( { ...dataWithoutTeacherId, @@ -485,7 +487,9 @@ module.exports = function createClassroomsRouter(firebaseTokenManager) { try { const { uid } = req.user; const { id } = req.params; - const docRef = admin.firestore().collection('classrooms').doc(id); + const docRef = admin.firestore() + .collection('classrooms') + .doc(id); const snap = await docRef.get(); if (!snap.exists) { return res.status(404).json({ message: 'Classroom not found' }); @@ -508,7 +512,9 @@ module.exports = function createClassroomsRouter(firebaseTokenManager) { try { const { uid } = req.user; const { id, studentId } = req.params; - const docRef = admin.firestore().collection('classrooms').doc(id); + const docRef = admin.firestore() + .collection('classrooms') + .doc(id); const snap = await docRef.get(); if (!snap.exists) { return res.status(404).json({ message: 'Classroom not found' }); @@ -677,7 +683,8 @@ module.exports = function createClassroomsRouter(firebaseTokenManager) { } if (isOwner) { - const { teacherId: _ignoredTeacherId, ...dataWithoutTeacherId } = data; + const dataWithoutTeacherId = { ...data }; + delete dataWithoutTeacherId.teacherId; const payload = { ...dataWithoutTeacherId, teacherId: existing.teacherId, diff --git a/src/pages/ClassroomLeaderboard.tsx b/src/pages/ClassroomLeaderboard.tsx index 4408a3dd..346feafa 100644 --- a/src/pages/ClassroomLeaderboard.tsx +++ b/src/pages/ClassroomLeaderboard.tsx @@ -1182,19 +1182,17 @@ class ClassroomLeaderboard extends React.Component { } } -export default connect((state: ReduxState) => { - return ({ +export default connect( + (state: ReduxState) => ({ locale: state.i18n.locale, classroom: state.classrooms.selectedClassroom, challenges: state.challenges, - currentStudentDisplayName: Async.latestValue(state.classrooms.currentStudentClassroom) ? Async.latestValue(state.classrooms.currentStudentClassroom).studentIds[auth.currentUser.uid].displayName : null, - - }); -}, + currentStudentDisplayName: Async.latestValue(state.classrooms.currentStudentClassroom) + ? Async.latestValue(state.classrooms.currentStudentClassroom).studentIds[auth.currentUser.uid].displayName + : null, + }), (dispatch) => ({ onClearSelectedClassroom: () => dispatch(ClassroomsAction.clearSelectedClassroom({})), }) - - )(CompWithRouter) as React.ComponentType; \ No newline at end of file diff --git a/src/tours/Tours.tsx b/src/tours/Tours.tsx index f472a3f4..d3beba65 100644 --- a/src/tours/Tours.tsx +++ b/src/tours/Tours.tsx @@ -550,11 +550,11 @@ export function getTeacherViewTourStepsForClassroom( return steps; } const placement = teacherNewClassroomCardPlacement(count); - return steps.map(step => + return steps.map(step => ( step.id === 'see-created-classroom' || step.id === 'classroom-users' ? { ...step, placement } : step - ); + )); } export function getStudentViewTourSteps(locale: LocalizedString.Language): TourStep[] {