diff --git a/jest.config.js b/jest.config.js index 9a46d913f..48530d2e2 100644 --- a/jest.config.js +++ b/jest.config.js @@ -5,6 +5,10 @@ module.exports = { preset: 'ts-jest/presets/js-with-ts', testEnvironment: 'node', roots: ['./test/'], + setupFiles: ['/test/jest.setup.js'], + moduleNameMapper: { + '^@i18n$': '/src/util/i18n.ts', + }, // By default, jest will ignore all files in node_modules. // But since babylonjs is published as modules, we need to tell jest to transform it (along with transforming our own code). diff --git a/package.json b/package.json index c12e876c4..994bf8e99 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,11 @@ "lint": "eslint . --ext .js,.jsx,.ts,.tsx", "test": "jest", "start": "yarn run start-dev", - "start-dev": "webpack-dev-server --host 0.0.0.0 --config=configs/webpack/dev.js", + "start-dev": "webpack-dev-server --config=configs/webpack/dev.js", + "start-dev-tunnel": "DEV_SERVER_TUNNEL=1 webpack-dev-server --config=configs/webpack/dev.js", + "start-dev-https": "DEV_SERVER_HTTPS=1 webpack-dev-server --config=configs/webpack/dev.js", + "install-ngrok": "bash scripts/install-ngrok.sh", + "share-ngrok": "bash scripts/share-ngrok.sh", "start-prod": "yarn run build && node express.js", "generate-i18n": "ts-node i18n/generate.ts", "build-i18n": "ts-node i18n/build.ts" @@ -26,6 +30,7 @@ "@babel/plugin-syntax-import-meta": "^7.10.4", "@babel/plugin-transform-class-static-block": "^7.28.3", "@types/chai": "^4.3.3", + "@types/earcut": "^3.0.0", "@types/gapi": "^0.0.44", "@types/gettext-parser": "^4.0.2", "@types/jest": "^29.4.0", @@ -55,7 +60,7 @@ "typescript": "^4.9.0", "webpack": "^5.36.2", "webpack-bundle-analyzer": "^5.2.0", - "webpack-cli": "^4.7.0", + "webpack-cli": "^5.1.4", "webpack-dev-server": "^5.2.1", "webpack-merge": "^5.7.3" }, @@ -75,6 +80,7 @@ "discord.js": "^13.6.0", "dotenv": "^10.0.0", "dotenv-expand": "^5.1.0", + "earcut": "^3.0.2", "express-http-proxy": "^1.6.3", "express-prom-bundle": "^8.0.0", "express-rate-limit": "^7.4.0", diff --git a/src/App.tsx b/src/App.tsx index 0df0b2a01..ced890c69 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,6 +33,7 @@ import ClassroomsDashboard from './pages/ClassroomsDashboard'; import ClassroomLeaderboard from './pages/ClassroomLeaderboard'; import ClassroomTeacherView from './pages/ClassroomTeacherView'; import ClassroomStudentView from './pages/ClassroomStudentView'; +import CustomChallengeCreator from './pages/CustomChallengeCreator'; import { InterfaceMode } from './types/interfaceModes'; import { Settings } from 'components/constants/Settings'; import User, { AsyncUser } from 'state/State/User'; @@ -226,6 +227,7 @@ class App extends React.Component { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/Challenge/ChallengeMenu.tsx b/src/components/Challenge/ChallengeMenu.tsx index c641b47ba..6b91f3084 100644 --- a/src/components/Challenge/ChallengeMenu.tsx +++ b/src/components/Challenge/ChallengeMenu.tsx @@ -66,6 +66,7 @@ export interface MenuProps extends StyleProps, ThemeProps { onDashboardClick: () => void; onLogoutClick: () => void; onAiClick: () => void; + onEditCustomChallengeClick?: () => void; onEndChallengeClick: () => void; simulatorState: SimulatorState; @@ -230,6 +231,7 @@ class ChallengeMenu extends React.PureComponent { onLogoutClick, onEndChallengeClick, onAiClick, + onEditCustomChallengeClick, simulatorState } = props; @@ -297,6 +299,7 @@ class ChallengeMenu extends React.PureComponent { onAboutClick={onAboutClick} onSettingsClick={onSettingsClick} onAiClick={onAiClick} + onEditCustomChallengeClick={onEditCustomChallengeClick} /> ) : undefined} diff --git a/src/components/Challenge/ExtraMenu.tsx b/src/components/Challenge/ExtraMenu.tsx index 6ccf6f181..97e64b597 100644 --- a/src/components/Challenge/ExtraMenu.tsx +++ b/src/components/Challenge/ExtraMenu.tsx @@ -4,7 +4,7 @@ import { styled } from 'styletron-react'; import { StyleProps } from '../../util/style'; import { FontAwesome } from '../FontAwesome'; import { ThemeProps } from '../constants/theme'; -import { faBook, faCogs, faCommentDots, faQuestion, faSignOutAlt, faRobot, faCircleInfo } from '@fortawesome/free-solid-svg-icons'; +import { faBook, faCogs, faCommentDots, faQuestion, faSignOutAlt, faRobot, faCircleInfo, faSliders } from '@fortawesome/free-solid-svg-icons'; import tr from '@i18n'; @@ -23,6 +23,7 @@ export interface ExtraMenuPublicProps extends StyleProps, ThemeProps { onSettingsClick: (event: React.MouseEvent) => void; onAboutClick: (event: React.MouseEvent) => void; onAiClick: (event: React.MouseEvent) => void; + onEditCustomChallengeClick?: (event: React.MouseEvent) => void; tourRegistry?: TourRegistry; onRetakeTourClick?: () => void; } @@ -109,6 +110,7 @@ class ExtraMenu extends React.PureComponent { onSettingsClick, locale, onAiClick, + onEditCustomChallengeClick, onRetakeTourClick } = props; @@ -117,6 +119,9 @@ class ExtraMenu extends React.PureComponent { const settingsItem_ = ( {LocalizedString.lookup(tr('Settings'), locale)}); const aboutItem_ = ( {LocalizedString.lookup(tr('About'), locale)}); const feedbackItem_ = ( {LocalizedString.lookup(tr('Feedback'), locale)}); + const editCustomChallengeItem_ = onEditCustomChallengeClick + ? ( {LocalizedString.lookup(tr('Edit challenge conditions & items'), locale)}) + : null; const logoutItem_ = ( {LocalizedString.lookup(tr('Logout'), locale)}); const retakeTourItem_ = ( {LocalizedString.lookup(tr(`Retake Tour`), locale)}); const tourContent_ = ( @@ -137,6 +142,7 @@ class ExtraMenu extends React.PureComponent { {feedbackItem_} } + {editCustomChallengeItem_} {retakeTourItem_} {logoutItem_} @@ -153,6 +159,7 @@ class ExtraMenu extends React.PureComponent { {onFeedbackClick && feedbackItem_ } + {editCustomChallengeItem_} {retakeTourItem_} {logoutItem_} diff --git a/src/components/Challenge/GoalList.tsx b/src/components/Challenge/GoalList.tsx index da08a4ad5..476a46c36 100644 --- a/src/components/Challenge/GoalList.tsx +++ b/src/components/Challenge/GoalList.tsx @@ -3,10 +3,16 @@ import { styled } from 'styletron-react'; import { faCheck } from '@fortawesome/free-solid-svg-icons'; import LocalizedString from '../../util/LocalizedString'; +import Dict from '../../util/objectOps/Dict'; import { StyleProps } from '../../util/style'; import PredicateCompletion from '../../state/State/ChallengeCompletion/PredicateCompletion'; import { Goal } from '../../state/State/Challenge'; import { FontAwesome } from '../FontAwesome'; +import { + oppositePlayAreaEventId, + parsePlayAreaEventId, +} from '../../util/playAreaSuccessGoals'; +import { isCustomCanPoseChallengeEventId } from '../../util/customChallengeGoals'; const Container = styled('div', {}); @@ -18,9 +24,97 @@ const Item = styled('div', { const Label = styled('div', {}); +/** Base challenge event id from a goal row (strips optional Once latch suffix). */ +function eventIdFromGoal_(goal: Goal): string { + return goal.exprId.replace(/Once$/, ''); +} + +function exprStateTrue_( + exprId: string, + predicateCompletion: PredicateCompletion | undefined, + type: 'success' | 'failure' +): boolean { + if (!predicateCompletion) return false; + if (predicateCompletion.exprStates[exprId] === true) return true; + if (type === 'failure' && !exprId.endsWith('Once')) { + return predicateCompletion.exprStates[`${exprId}Once`] === true; + } + return false; +} + +function goalEventStateTrue_(goal: Goal, eventStates: Dict | undefined): boolean { + if (!eventStates) return false; + const baseId = eventIdFromGoal_(goal); + if (eventStates[baseId] === true) return true; + if (eventStates[goal.exprId] === true) return true; + return false; +} + +function goalHighlighted_( + goal: Goal, + predicateCompletion: PredicateCompletion | undefined, + eventStates: Dict | undefined, + type: 'success' | 'failure' +): boolean { + const baseEventId = eventIdFromGoal_(goal); + if (isCustomCanPoseChallengeEventId(baseEventId) && eventStates) { + if (goalEventStateTrue_(goal, eventStates)) return true; + if (exprStateTrue_(goal.exprId, predicateCompletion, type)) return true; + return false; + } + + const playAreaOnce = + !!parsePlayAreaEventId(baseEventId) && goal.exprId.endsWith('Once'); + if (playAreaOnce) { + if (exprStateTrue_(goal.exprId, predicateCompletion, type)) return true; + if (type === 'success' && goalEventStateTrue_(goal, eventStates)) return true; + return false; + } + if (exprStateTrue_(goal.exprId, predicateCompletion, type)) return true; + if (type === 'success' && goalEventStateTrue_(goal, eventStates)) return true; + if (type === 'failure' && goalEventStateTrue_(goal, eventStates)) return true; + return false; +} + +function oppositePlayAreaExprId_(goal: Goal): string | null { + const eventId = goal.exprId.replace(/Once$/, ''); + const oppositeEventId = oppositePlayAreaEventId(eventId); + if (!oppositeEventId) return null; + return goal.exprId.endsWith('Once') ? `${oppositeEventId}Once` : oppositeEventId; +} + +/** Play-area enter/leave (and item in/out) pairs share one zone — show at most one highlight. */ +function playAreaGoalHighlighted_( + goal: Goal, + predicateCompletion: PredicateCompletion | undefined, + eventStates: Dict | undefined, + otherPredicateCompletion: PredicateCompletion | undefined, + type: 'success' | 'failure' +): boolean { + if (!goalHighlighted_(goal, predicateCompletion, eventStates, type)) return false; + if (!parsePlayAreaEventId(goal.exprId)) return true; + + const oppositeExprId = oppositePlayAreaExprId_(goal); + if (!oppositeExprId) return true; + + const otherType = type === 'success' ? 'failure' : 'success'; + const baseEventId = eventIdFromGoal_(goal); + if (type === 'success' && eventStates?.[baseEventId] === true) { + return true; + } + if (type === 'success' && exprStateTrue_(oppositeExprId, otherPredicateCompletion, otherType)) { + return false; + } + return true; +} + export interface GoalListProps extends StyleProps { goals: Goal[]; predicateCompletion?: PredicateCompletion; + /** Live challenge event flags (used when predicate exprStates lag scene scripts). */ + eventStates?: Dict; + /** Opposite predicate completion (success ↔ failure) for play-area mutual exclusion. */ + otherPredicateCompletion?: PredicateCompletion; locale: LocalizedString.Language; /** * success goals are highlighted green when true; @@ -32,6 +126,8 @@ export interface GoalListProps extends StyleProps { const GoalList: React.FC = ({ goals, predicateCompletion, + eventStates, + otherPredicateCompletion, locale, type, style, @@ -39,12 +135,18 @@ const GoalList: React.FC = ({ }) => ( {goals.map(goal => { - const state = predicateCompletion ? predicateCompletion.exprStates[goal.exprId] : undefined; + const highlighted = playAreaGoalHighlighted_( + goal, + predicateCompletion, + eventStates, + otherPredicateCompletion, + type + ); const color = type === 'success' ? 'green' : 'red'; return ( - {state === true && } - diff --git a/src/components/Challenge/SimMenu.tsx b/src/components/Challenge/SimMenu.tsx index e46f5aedd..97cbedd68 100644 --- a/src/components/Challenge/SimMenu.tsx +++ b/src/components/Challenge/SimMenu.tsx @@ -98,6 +98,7 @@ export interface MenuPublicProps extends StyleProps, ThemeProps { onAboutClick: () => void; onDocumentationClick: () => void; onAiClick: (event: React.MouseEvent) => void; + onEditCustomChallengeClick?: (event: React.MouseEvent) => void; onDashboardClick: () => void; onLogoutClick: () => void; @@ -345,6 +346,7 @@ class SimMenu extends React.PureComponent { onStartChallengeClick, onDeleteSceneClick, onAiClick, + onEditCustomChallengeClick, simulatorState, onRetakeTourClick, locale, @@ -475,6 +477,7 @@ class SimMenu extends React.PureComponent { onFeedbackClick={onFeedbackClick} onSettingsClick={onSettingsClick} onAiClick={onAiClick} + onEditCustomChallengeClick={onEditCustomChallengeClick} tourRegistry={this.props.tourRegistry} onRetakeTourClick={onRetakeTourClick} /> diff --git a/src/components/Challenge/index.tsx b/src/components/Challenge/index.tsx index 50c0693e2..63bdcb57f 100644 --- a/src/components/Challenge/index.tsx +++ b/src/components/Challenge/index.tsx @@ -13,9 +13,11 @@ import { connect } from 'react-redux'; import { State as ReduxState } from '../../state'; import Async from '../../state/State/Async'; +import Dict from '../../util/objectOps/Dict'; import LocalizedString from '../../util/LocalizedString'; import { AsyncChallenge } from '../../state/State/Challenge'; import { AsyncChallengeCompletion } from '../../state/State/ChallengeCompletion'; +import PredicateCompletion from '../../state/State/ChallengeCompletion/PredicateCompletion'; import PredicateEditor from './PredicateEditor'; import GoalList from './GoalList'; @@ -24,6 +26,10 @@ import tr from '@i18n'; export interface ChallengePublicProps extends StyleProps, ThemeProps { challenge: AsyncChallenge; challengeCompletion: AsyncChallengeCompletion; + /** Scene event flags updated synchronously from the sim loop (see ChallengeRoot). */ + liveEventStates?: Dict; + liveSuccessCompletion?: PredicateCompletion; + liveFailureCompletion?: PredicateCompletion; } interface ChallengePrivateProps { @@ -84,7 +90,7 @@ const SectionIcon = styled(FontAwesome, (props: ThemeProps) => ({ transition: 'opacity 0.2s' })); -class Challenge extends React.PureComponent { +class Challenge extends React.Component { constructor(props: Props) { super(props); @@ -107,7 +113,17 @@ class Challenge extends React.PureComponent { render() { const { props, state } = this; - const { style, className, theme, challenge, challengeCompletion, locale } = props; + const { + style, + className, + theme, + challenge, + challengeCompletion, + liveEventStates, + liveSuccessCompletion, + liveFailureCompletion, + locale, + } = props; const { collapsed, modal } = state; @@ -116,6 +132,15 @@ class Challenge extends React.PureComponent { const latestChallengeCompletion = Async.latestValue(challengeCompletion); + const goalEventStates: Dict = { + ...(latestChallengeCompletion?.eventStates ?? {}), + ...(liveEventStates ?? {}), + }; + + const successCompletion = + liveSuccessCompletion ?? latestChallengeCompletion?.success; + const failureCompletion = + liveFailureCompletion ?? latestChallengeCompletion?.failure; return ( <> @@ -125,7 +150,9 @@ class Challenge extends React.PureComponent {
@@ -142,7 +169,9 @@ class Challenge extends React.PureComponent {
diff --git a/src/components/Classrooms/ChallengeTabView.tsx b/src/components/Classrooms/ChallengeTabView.tsx index b39c95d7a..95b219ff6 100644 --- a/src/components/Classrooms/ChallengeTabView.tsx +++ b/src/components/Classrooms/ChallengeTabView.tsx @@ -88,7 +88,7 @@ type Props = ChallengeTabViewPublicProps & ChallengeTabViewPrivateProps & WithNa type State = ChallengeTabViewState; -const SidePanel = styled('div', (props: ThemeProps) => ({ +const SidePanel = styled('div', (props: ThemeProps & { $teacherView?: boolean }) => ({ display: 'flex', flexDirection: 'column', flexWrap: 'wrap', @@ -99,16 +99,32 @@ const SidePanel = styled('div', (props: ThemeProps) => ({ alignContent: 'center', width: '100%', gap: '10px', + ...(props.$teacherView ? { + flex: 1, + minHeight: 0, + minWidth: 0, + maxWidth: '100%', + overflow: 'hidden', + flexWrap: 'nowrap', + boxSizing: 'border-box', + } : {}), })); -const ChallengeViewContainer = styled('div', (props: ThemeProps) => ({ +const ChallengeViewContainer = styled('div', (props: ThemeProps & { $teacherView?: boolean }) => ({ left: '4%', height: '100%', width: '95%', margin: '1em', zIndex: 23, - backgroundColor: props.theme.backgroundColor + backgroundColor: props.theme.backgroundColor, + ...(props.$teacherView ? { + maxWidth: '100%', + minWidth: 0, + overflow: 'hidden', + boxSizing: 'border-box', + } : {}), })); + const SectionName = styled('span', (props: ThemeProps & SectionProps & { selected: boolean }) => ({ ':hover': { cursor: 'pointer', @@ -123,19 +139,47 @@ const SectionName = styled('span', (props: ThemeProps & SectionProps & { selecte userSelect: 'none', })); -const SectionsColumn = styled('div', (props: ThemeProps) => ({ +const SectionTabsRow = styled('div', { + display: 'flex', + flexDirection: 'row', + width: '100%', + flexShrink: 0, +}); + +const SectionNameTab = styled(SectionName, { + width: 'auto', + flex: 1, +}); + +const SectionsColumn = styled('div', (props: ThemeProps & { $teacherView?: boolean }) => ({ display: 'flex', flexDirection: 'column', alignItems: 'center', flexGrow: 1, border: `3px solid ${props.theme.borderColor}`, - // minHeight: '100%', height: '95%', paddingBottom: '3em', backgroundColor: props.theme.backgroundColor, - zIndex: '1' + zIndex: '1', + ...(props.$teacherView ? { + width: '100%', + maxWidth: '100%', + minWidth: 0, + overflow: 'hidden', + boxSizing: 'border-box', + } : {}), })); +/** Keeps the leaderboard at the section width so the table scrolls inside a fixed panel. */ +const TeacherLeaderboardSlot = styled('div', { + width: '100%', + maxWidth: '100%', + alignSelf: 'stretch', + minWidth: 0, + overflow: 'hidden', + boxSizing: 'border-box', +}); + export const IVYGATE_LANGUAGE_MAPPING: Dict = { 'ecmascript': 'javascript', 'python': 'customPython', @@ -198,28 +242,36 @@ class ChallengeTabView extends React.Component { const { props, state } = this; const { style, locale, theme, tourRegistry, view, currentSelectedClassroom } = props; const { selectedSection } = state; + const isTeacherView = view === 'teacherView'; const DefaultJBCChallengeSection = () => { const { currentStudentDisplayName } = this.state; const { theme, currentStudentClassroom, tourRegistry } = this.props; + const leaderboard = ( + + ); + + const leaderboardContent = isTeacherView + ? {leaderboard} + : leaderboard; const content = tourRegistry ? ( - - - + + + {leaderboardContent} ) - : ( - + : ( + {leaderboardContent} ); return (content); }; @@ -244,21 +296,23 @@ class ChallengeTabView extends React.Component { const tourContent_ = ( - + - this.onSectionSelect_("Default JBC Challenges")}> - {LocalizedString.lookup(tr('Default JBC Challenges'), locale)} - + + this.onSectionSelect_("Default JBC Challenges")}> + {LocalizedString.lookup(tr('Default JBC Challenges'), locale)} + - this.onSectionSelect_("Limited Challenges")} - > - - {LocalizedString.lookup(tr('Limited Challenges'), locale)} - - + this.onSectionSelect_("Limited Challenges")} + > + + {LocalizedString.lookup(tr('Limited Challenges'), locale)} + + + {selectedSection === 'Default JBC Challenges' && DefaultJBCChallengeSection()} {selectedSection === 'Limited Challenges' && LimitedChallengesSection()} @@ -268,20 +322,21 @@ class ChallengeTabView extends React.Component { ); const normalContent_ = ( - - - this.onSectionSelect_("Default JBC Challenges")}> - {LocalizedString.lookup(tr('Default JBC Challenges'), locale)} - + - this.onSectionSelect_("Limited Challenges")} - > - {LocalizedString.lookup(tr('Limited Challenges'), locale)} + + this.onSectionSelect_("Default JBC Challenges")}> + {LocalizedString.lookup(tr('Default JBC Challenges'), locale)} + - + this.onSectionSelect_("Limited Challenges")} + > + {LocalizedString.lookup(tr('Limited Challenges'), locale)} + + {selectedSection === 'Default JBC Challenges' && DefaultJBCChallengeSection()} {selectedSection === 'Limited Challenges' && LimitedChallengesSection()} @@ -289,7 +344,7 @@ class ChallengeTabView extends React.Component { ); return ( - + {tourRegistry ? tourContent_ : normalContent_} diff --git a/src/components/Classrooms/CreateAssignmentView.tsx b/src/components/Classrooms/CreateAssignmentView.tsx index b18cfb047..590b93ba1 100644 --- a/src/components/Classrooms/CreateAssignmentView.tsx +++ b/src/components/Classrooms/CreateAssignmentView.tsx @@ -21,8 +21,13 @@ import { Challenges } from '../../state/State'; import ScrollArea from '../interface/ScrollArea'; import ResizeableComboBox from '../interface/ResizeableComboBox'; import { ClassroomsAction } from 'state/reducer/classrooms'; +import { ChallengesAction } from 'state/reducer/challenges'; import TourTarget from '../Tours/TourTarget'; import { TourRegistry } from '../../tours/TourRegistry'; +import { isCustomChallengeId } from '../../util/customChallengeFactory'; +import { isTeacherOwnedCustomChallenge } from '../../util/customChallengeClassroomShare'; +import { auth } from '../../firebase/firebase'; +import Challenge, { AsyncChallenge } from '../../state/State/Challenge'; export interface CreateAssignmentViewPublicProps extends ThemeProps, StyleProps { @@ -42,6 +47,7 @@ export interface CreateAssignmentViewPrivateProps extends ThemeProps { challenges: Challenges; onCreateAssignment?: (classroom: Classroom, assignment: ClassroomAssignment, students: Dict<{ id: string, displayName: string, assignments?: Dict }>) => void; onEditAssignment?: (classroom: Classroom, docId: string, assignment: ClassroomAssignment) => void; + onListUserChallenges?: () => void; } interface ClickProps { onClick?: (event: React.MouseEvent) => void; @@ -216,6 +222,7 @@ const CreateAssignmentView = ({ originalAssignment, onEditComplete, onEditAssignment, + onListUserChallenges, tourRegistry, activeTourStepId }: Props) => { @@ -294,6 +301,10 @@ const CreateAssignmentView = ({ }, [selectedStudents]); + useEffect(() => { + onListUserChallenges?.(); + }, [onListUserChallenges]); + useEffect(() => { if (!activeTourStepId) return; const keepAssignToOpen = new Set([ @@ -308,44 +319,136 @@ const CreateAssignmentView = ({ } }, [activeTourStepId]); - function renderChallengeCheckboxes() { + const teacherId = auth.currentUser?.uid; + + const builtinChallengeEntries = React.useMemo( + () => + Object.values(challenges || {}).filter(entry => { + const value = Async.latestValue(entry); + return value && !isCustomChallengeId(value.sceneId); + }), + [challenges] + ); + + const customChallengeEntries = React.useMemo( + () => + Object.values(challenges || {}).filter(entry => + isTeacherOwnedCustomChallenge(Async.latestValue(entry), teacherId) + ), + [challenges, teacherId] + ); + + const toggleChallengeAssignment_ = (currentChallenge: Challenge) => { + const existingChallenges = assignmentInfo.challenges || {}; + const alreadyExists = Object.values(existingChallenges).some( + ({ challenge }) => challenge.sceneId === currentChallenge.sceneId + ); + const key = currentChallenge.sceneId; + + setAssignedPointsSet(prev => { + const next = { ...prev }; + if (alreadyExists) { + delete next[key]; + } else { + next[key] = { + challenge: { + sceneId: currentChallenge.sceneId, + name: LocalizedString.lookup(currentChallenge.name, locale), + description: LocalizedString.lookup(currentChallenge.description, locale), + }, + points: 0, + }; + } + return next; + }); + }; + + function customChallengeLabel_(name: string, description: string): React.ReactNode { + const desc = description?.trim(); + if (!desc || desc === name) { + return {name}; + } return ( -
- { - Object.values(challenges || {}).map(challenge => ( - - - assignedChallenge.sceneId === Async.latestValue(challenge).sceneId) : false} - onChange={() => { - const currentChallenge = Async.latestValue(challenge); - const existingChallenges = assignmentInfo.challenges || {}; - - const alreadyExists = Object.values(existingChallenges).some( - ({ challenge }) => challenge.sceneId === currentChallenge.sceneId - ); - - const key = currentChallenge.sceneId; - - setAssignedPointsSet(prev => { - const next = { ...prev }; - if (alreadyExists) { - delete next[key]; - } else { - next[key] = { challenge: { sceneId: currentChallenge.sceneId, name: currentChallenge.name[locale], description: currentChallenge.description[locale] }, points: 0 }; - } - return next; + + {name} +
+ {desc} +
+
+ ); + } - }); - }} /> + function assignedChallengeRowLabel_(challenge: ClassroomAssignmentChallenge): React.ReactNode { + if (isCustomChallengeId(challenge.sceneId)) { + return customChallengeLabel_(challenge.name, challenge.description); + } + return LocalizedString.lookup(tr(challenge.description), locale); + } + + function renderChallengeCheckbox_( + challenge: AsyncChallenge, + options?: { showNameAndDescription?: boolean } + ) { + const currentChallenge = Async.latestValue(challenge); + if (!currentChallenge) return null; + const name = LocalizedString.lookup(currentChallenge.name, locale); + const description = LocalizedString.lookup(currentChallenge.description, locale); + const label = options?.showNameAndDescription + ? customChallengeLabel_(name, description) + : description; + const checked = assignmentInfo.challenges + ? Object.values(assignmentInfo.challenges).some( + ({ challenge: assignedChallenge }) => + assignedChallenge.sceneId === currentChallenge.sceneId + ) + : false; - -
- )) - } -
); + return ( + + toggleChallengeAssignment_(currentChallenge)} + /> + + + ); + } + + function renderChallengeCheckboxes() { + return ( +
+ {builtinChallengeEntries.map(entry => renderChallengeCheckbox_(entry))} + {customChallengeEntries.length > 0 && ( + <> +
+ {LocalizedString.lookup(tr('Your custom JBC challenges'), locale)} +
+
+ {LocalizedString.lookup( + tr('Students can play these but cannot edit the challenge setup.'), + locale + )} +
+ {customChallengeEntries.map(entry => + renderChallengeCheckbox_(entry, { showNameAndDescription: true }) + )} + + )} +
+ ); } function editTopic() { @@ -646,7 +749,7 @@ const CreateAssignmentView = ({ {Object.values(assignedPointsSet)?.map(({ challenge, points }, index) => ( - {LocalizedString.lookup(tr(challenge.description), locale)} + {assignedChallengeRowLabel_(challenge)} ( - {LocalizedString.lookup(tr(challenge.description), locale)} + {assignedChallengeRowLabel_(challenge)} { challenges: state.challenges, }; -}, (dispatch, ownProps) => ({ +}, dispatch => ({ onCreateAssignment: (classroom: Classroom, assignment: ClassroomAssignment, studentIds: Dict<{ id: string, displayName: string, assignments?: Dict }>) => { dispatch(ClassroomsAction.setAssignment({ classroom, assignment, studentIds })); }, onEditAssignment: (classroom: Classroom, docId: string, assignment: ClassroomAssignment) => { dispatch(ClassroomsAction.editAssignment({ classroom, assignmentDocId: docId, assignment })); - } + }, + onListUserChallenges: () => { + dispatch(ChallengesAction.listUserChallenges({})); + }, }))(CreateAssignmentView) as React.ComponentType; \ No newline at end of file diff --git a/src/components/Classrooms/TeacherTabs.tsx b/src/components/Classrooms/TeacherTabs.tsx index 11571f9f4..c7b343f0d 100644 --- a/src/components/Classrooms/TeacherTabs.tsx +++ b/src/components/Classrooms/TeacherTabs.tsx @@ -41,11 +41,13 @@ const Container = styled('div', ({ $theme }: { $theme: Theme }) => ({ flexDirection: 'column', color: $theme.color, backgroundColor: $theme.backgroundColor, - // minHeight: '100vh', })); const Body = styled('div', { flex: 1, + minHeight: 0, + minWidth: 0, + overflow: 'hidden', display: 'flex', flexDirection: 'row', '@screen and (max-width: 800px)': { diff --git a/src/components/CustomChallenges/ChallengeGoalsPreview.tsx b/src/components/CustomChallenges/ChallengeGoalsPreview.tsx new file mode 100644 index 000000000..a4d83cb82 --- /dev/null +++ b/src/components/CustomChallenges/ChallengeGoalsPreview.tsx @@ -0,0 +1,68 @@ +import * as React from 'react'; +import { styled } from 'styletron-react'; +import tr from '@i18n'; +import { ThemeProps } from '../constants/theme'; +import Section from '../interface/Section'; +import GoalList from '../Challenge/GoalList'; +import LocalizedString from '../../util/LocalizedString'; +import { Goal } from '../../state/State/Challenge'; + +const Container = styled('div', (props: ThemeProps) => ({ + display: 'flex', + flexDirection: 'column', + color: props.theme.color, + borderBottom: `1px solid ${props.theme.borderColor}`, + marginBottom: `${props.theme.itemPadding}px`, +})); + +const EmptyHint = styled('p', (props: ThemeProps) => ({ + margin: `0 ${props.theme.itemPadding * 2}px ${props.theme.itemPadding * 2}px`, + opacity: 0.85, + fontSize: '0.88em', + lineHeight: 1.45, +})); + +export interface ChallengeGoalsPreviewProps extends ThemeProps { + locale: LocalizedString.Language; + successGoals: Goal[]; + failureGoals: Goal[]; +} + +/** Same Success / Failure layout as the Challenge tab when starting a challenge. */ +const ChallengeGoalsPreview: React.FC = ({ + theme, + locale, + successGoals, + failureGoals, +}) => { + const hasSuccess = successGoals.length > 0; + const hasFailure = failureGoals.length > 0; + + if (!hasSuccess && !hasFailure) { + return ( + + {LocalizedString.lookup( + tr('No goals yet. Go back to add success rules.'), + locale + )} + + ); + } + + return ( + + {hasSuccess && ( +
+ +
+ )} + {hasFailure && ( +
+ +
+ )} +
+ ); +}; + +export default ChallengeGoalsPreview; diff --git a/src/components/CustomChallenges/ChallengeItemSuggestions.tsx b/src/components/CustomChallenges/ChallengeItemSuggestions.tsx new file mode 100644 index 000000000..e250d9957 --- /dev/null +++ b/src/components/CustomChallenges/ChallengeItemSuggestions.tsx @@ -0,0 +1,171 @@ +import * as React from 'react'; +import { styled } from 'styletron-react'; +import { ThemeProps } from '../constants/theme'; +import LocalizedString from '../../util/LocalizedString'; +import tr from '@i18n'; +import { + ChallengeSuggestion, + PlayZoneSelectionSummary, + successGoalsForSuggestionKeys, +} from '../../util/jbcChallengeSuggestions'; +import { JbcCatalogSuccessGoal } from '../../util/jbcChallengeCatalog'; + +const Section = styled('div', (props: ThemeProps) => ({ + padding: `0 ${props.theme.itemPadding * 2}px ${props.theme.itemPadding * 2}px`, +})); + +const SectionTitle = styled('h4', (props: ThemeProps) => ({ + margin: `${props.theme.itemPadding * 2}px ${props.theme.itemPadding * 2}px ${props.theme.itemPadding}px`, + fontSize: '0.95em', +})); + +const SummaryCard = styled('div', (props: ThemeProps) => ({ + margin: `0 ${props.theme.itemPadding * 2}px ${props.theme.itemPadding * 2}px`, + padding: `${props.theme.itemPadding * 1.5}px`, + borderRadius: '4px', + border: `1px solid ${props.theme.borderColor}`, + backgroundColor: 'rgba(255,255,255,0.04)', + fontSize: '0.9em', + lineHeight: 1.45, +})); + +const ZoneRow = styled('div', { + marginTop: '8px', +}); + +const SuggestionCard = styled('div', (props: ThemeProps) => ({ + marginBottom: `${props.theme.itemPadding * 1.5}px`, + padding: `${props.theme.itemPadding * 1.5}px`, + borderRadius: '4px', + border: `1px solid ${props.theme.borderColor}`, + backgroundColor: 'rgba(255,255,255,0.03)', +})); + +const SuggestionTitle = styled('div', { + fontWeight: 600, + marginBottom: '6px', +}); + +const ScriptTip = styled('pre', (props: ThemeProps) => ({ + marginTop: `${props.theme.itemPadding}px`, + padding: `${props.theme.itemPadding}px`, + fontSize: '0.78em', + lineHeight: 1.35, + overflowX: 'auto', + borderRadius: '4px', + backgroundColor: 'rgba(0,0,0,0.35)', + border: `1px solid ${props.theme.borderColor}`, + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', +})); + +const ActionButton = styled('button', (props: ThemeProps) => ({ + marginTop: `${props.theme.itemPadding}px`, + padding: '6px 10px', + borderRadius: '4px', + border: `1px solid ${props.theme.borderColor}`, + backgroundColor: 'rgba(76, 175, 80, 0.2)', + color: props.theme.color, + cursor: 'pointer', + fontSize: '0.85em', + fontWeight: 600, +})); + +export interface ChallengeItemSuggestionsProps extends ThemeProps { + locale: LocalizedString.Language; + summary: PlayZoneSelectionSummary; + suggestions: ChallengeSuggestion[]; + onAddSuccessGoals?: (goals: JbcCatalogSuccessGoal[]) => void; + showAddRules?: boolean; +} + +const ChallengeItemSuggestions: React.FC = ({ + theme, + locale, + summary, + suggestions, + onAddSuccessGoals, + showAddRules = true, +}) => { + const hasMatItems = + summary.matItemLabels.length > 0 || summary.matGeometryLabels.length > 0; + const hasZones = summary.zoneCount > 0; + + return ( + <> + + {LocalizedString.lookup(tr('What you placed'), locale)} + + + {hasMatItems ? ( + <> + {summary.matItemLabels.length > 0 && ( +
+ {LocalizedString.lookup(tr('Items on the mat'), locale)}:{' '} + {summary.matItemLabels.join(', ')} +
+ )} + {summary.matGeometryLabels.length > 0 && ( +
0 ? 8 : 0 }}> + + {LocalizedString.lookup(tr('Script geometries'), locale)} + + : {summary.matGeometryLabels.join(', ')} +
+ )} + + ) : ( + + {LocalizedString.lookup( + tr('No items on the mat yet. Go back to add World objects and script geometries.'), + locale + )} + + )} + {hasZones && ( +
+ {LocalizedString.lookup(tr('Play areas'), locale)}:{' '} + {summary.zones.map(z => z.name).join(', ')} + {LocalizedString.lookup( + tr(' (success rules for areas are set separately on the previous step)'), + locale + )} +
+ )} +
+ + + {LocalizedString.lookup(tr('Ideas for your challenge'), locale)} + +
+ {suggestions.map(suggestion => ( + + {suggestion.title} +

{suggestion.description}

+ {suggestion.scriptTip && ( + {suggestion.scriptTip} + )} + {showAddRules && + onAddSuccessGoals && + suggestion.relatedSuccessGoalKeys && + suggestion.relatedSuccessGoalKeys.length > 0 && ( + { + onAddSuccessGoals( + successGoalsForSuggestionKeys(suggestion.relatedSuccessGoalKeys) + ); + }} + > + {LocalizedString.lookup(tr('Add matching JBC success rules'), locale)} + + )} +
+ ))} +
+ + ); +}; + +export default ChallengeItemSuggestions; diff --git a/src/components/CustomChallenges/ConditionGoalsEditor.tsx b/src/components/CustomChallenges/ConditionGoalsEditor.tsx new file mode 100644 index 000000000..baef5ceab --- /dev/null +++ b/src/components/CustomChallenges/ConditionGoalsEditor.tsx @@ -0,0 +1,138 @@ +import * as React from 'react'; +import { styled } from 'styletron-react'; +import { ThemeProps } from '../constants/theme'; +import Dict from '../../util/objectOps/Dict'; +import Event from '../../state/State/Challenge/Event'; +import LocalizedString from '../../util/LocalizedString'; +import Input from '../interface/Input'; +import Field from '../interface/Field'; +import { FontAwesome } from '../FontAwesome'; +import { faPlus, faTrash } from '@fortawesome/free-solid-svg-icons'; +import tr from '@i18n'; +import { ConditionGoalInput } from '../../util/customChallengePredicates'; + +export interface ConditionGoalsEditorProps extends ThemeProps { + locale: LocalizedString.Language; + title: string; + helpText: string; + events: Dict; + goals: ConditionGoalInput[]; + onChange: (goals: ConditionGoalInput[]) => void; +} + +const Row = styled('div', (props: ThemeProps) => ({ + display: 'flex', + flexDirection: 'row', + gap: `${props.theme.itemPadding}px`, + alignItems: 'flex-end', + padding: `${props.theme.itemPadding}px 0`, + borderBottom: `1px solid ${props.theme.borderColor}`, +})); + +const IconButton = styled('button', (props: ThemeProps) => ({ + background: 'transparent', + border: 'none', + color: props.theme.color, + cursor: 'pointer', + padding: `${props.theme.itemPadding}px`, +})); + +const ConditionGoalsEditor: React.FC = ({ + theme, + locale, + title, + helpText, + events, + goals, + onChange, +}) => { + const eventIds = Object.keys(events); + + const addGoal = () => { + const eventId = eventIds[0]; + if (!eventId) return; + const event = events[eventId]; + onChange([ + ...goals, + { + eventId, + label: LocalizedString.lookup(event.name, LocalizedString.EN_US), + latchOnce: true, + }, + ]); + }; + + const updateGoal = (index: number, patch: Partial) => { + const next = [...goals]; + next[index] = { ...next[index], ...patch }; + if (patch.eventId && events[patch.eventId]) { + const current = next[index]; + if (!current.label || goals[index].eventId !== patch.eventId) { + current.label = LocalizedString.lookup( + events[patch.eventId].name, + LocalizedString.EN_US + ); + } + } + onChange(next); + }; + + const removeGoal = (index: number) => { + onChange(goals.filter((_, i) => i !== index)); + }; + + return ( +
+

{title}

+

{helpText}

+ {goals.map((goal, index) => ( + + + + + + ) => + updateGoal(index, { label: e.currentTarget.value }) + } + /> + + + removeGoal(index)}> + + + + ))} + + {LocalizedString.lookup(tr('Add condition'), locale)} + +
+ ); +}; + +export default ConditionGoalsEditor; diff --git a/src/components/CustomChallenges/CustomChallengeEditor.tsx b/src/components/CustomChallenges/CustomChallengeEditor.tsx new file mode 100644 index 000000000..cf6c16b38 --- /dev/null +++ b/src/components/CustomChallenges/CustomChallengeEditor.tsx @@ -0,0 +1,327 @@ +import * as React from 'react'; +import { connect } from 'react-redux'; +import { styled } from 'styletron-react'; +import { ThemeProps } from '../constants/theme'; +import { State as ReduxState } from '../../state'; +import Async from '../../state/State/Async'; +import Challenge from '../../state/State/Challenge'; +import LocalizedString from '../../util/LocalizedString'; +import Input from '../interface/Input'; +import TextArea from '../interface/TextArea'; +import Field from '../interface/Field'; +import Section from '../interface/Section'; +import ScrollArea from '../interface/ScrollArea'; +import PredicateEditor from '../Challenge/PredicateEditor'; +import tr from '@i18n'; +import { ChallengeCompletionsAction, ChallengesAction, ScenesAction } from '../../state/reducer'; +import { isManagedCustomChallengeEventId } from '../../util/customChallengeGoals'; +import EventListEditor from './EventListEditor'; +import ConditionGoalsEditor from './ConditionGoalsEditor'; +import { + buildFailureGoals, + buildFailurePredicate, + buildSuccessGoals, + buildSuccessPredicate, + conditionGoalsFromChallenge, + ConditionGoalInput, +} from '../../util/customChallengePredicates'; +import Author from '../../db/Author'; +import { auth } from '../../firebase/firebase'; +import { withNavigate, WithNavigateProps } from '../../util/withNavigate'; + +export interface CustomChallengeEditorPublicProps extends ThemeProps { + challengeId: string; +} + +interface CustomChallengeEditorPrivateProps { + locale: LocalizedString.Language; + challenge: Challenge | null; + challengeAsyncType: Async.Type | undefined; +} + +type Props = CustomChallengeEditorPublicProps & +CustomChallengeEditorPrivateProps & +WithNavigateProps & { + dispatch: (action: unknown) => void; +}; + +const Container = styled('div', (props: ThemeProps) => ({ + display: 'flex', + flexDirection: 'column', + flex: '1 1', + minHeight: 0, + color: props.theme.color, +})); + +const Actions = styled('div', (props: ThemeProps) => ({ + display: 'flex', + flexWrap: 'wrap', + gap: `${props.theme.itemPadding * 2}px`, + padding: `${props.theme.itemPadding * 2}px`, + borderBottom: `1px solid ${props.theme.borderColor}`, +})); + +const ActionButton = styled('button', (props: ThemeProps) => ({ + padding: '10px 16px', + backgroundColor: '#2196f3', + color: '#fff', + border: 'none', + borderRadius: '4px', + cursor: 'pointer', + fontWeight: 'bold', + ':disabled': { + opacity: 0.5, + cursor: 'not-allowed', + }, +})); + +const PredicatePreview = styled('div', (props: ThemeProps) => ({ + padding: `${props.theme.itemPadding * 2}px`, + border: `1px solid ${props.theme.borderColor}`, + borderRadius: '4px', + marginTop: `${props.theme.itemPadding}px`, +})); + +class CustomChallengeEditor extends React.PureComponent { + private successGoals_: ConditionGoalInput[] = []; + private failureGoals_: ConditionGoalInput[] = []; + + componentDidMount(): void { + this.syncGoalsFromChallenge_(); + const { challengeId, dispatch } = this.props; + dispatch(ChallengesAction.loadChallenge({ challengeId })); + dispatch(ScenesAction.loadScene({ sceneId: challengeId })); + } + + componentDidUpdate(prevProps: Readonly): void { + if (prevProps.challenge !== this.props.challenge && this.props.challenge) { + this.syncGoalsFromChallenge_(); + } + } + + private syncGoalsFromChallenge_ = () => { + const challenge = this.props.challenge; + if (!challenge) return; + this.successGoals_ = conditionGoalsFromChallenge(challenge.success, challenge.successGoals); + this.failureGoals_ = conditionGoalsFromChallenge(challenge.failure, challenge.failureGoals); + this.forceUpdate(); + }; + + private applyConditions_ = () => { + const { challenge, challengeId, dispatch } = this.props; + const success = buildSuccessPredicate(this.successGoals_); + const failure = buildFailurePredicate(this.failureGoals_); + dispatch( + ChallengesAction.applyChallengeConditions({ + challengeId, + success, + failure, + successGoals: success ? buildSuccessGoals(this.successGoals_) : undefined, + failureGoals: failure ? buildFailureGoals(this.failureGoals_) : undefined, + }) + ); + + if (!challenge) return; + const referencedEventIds = new Set([ + ...this.successGoals_.map(goal => goal.eventId), + ...this.failureGoals_.map(goal => goal.eventId), + ]); + for (const eventId of Object.keys(challenge.events)) { + if (referencedEventIds.has(eventId) || !isManagedCustomChallengeEventId(eventId)) { + continue; + } + dispatch(ChallengesAction.removeEvent({ challengeId, eventId })); + dispatch(ChallengeCompletionsAction.removeEventState({ challengeId, eventId })); + } + }; + + private syncEvents_ = (events: Challenge['events']) => { + const challenge = this.props.challenge; + if (!challenge) return; + const challengeId = this.props.challengeId; + for (const eventId of Object.keys(challenge.events)) { + if (!(eventId in events)) { + this.props.dispatch(ChallengesAction.removeEvent({ challengeId, eventId })); + } + } + for (const [eventId, event] of Object.entries(events)) { + this.props.dispatch(ChallengesAction.setEvent({ challengeId, eventId, event })); + } + }; + + private onSave_ = () => { + this.props.dispatch(ChallengesAction.saveChallenge({ challengeId: this.props.challengeId })); + this.props.dispatch(ScenesAction.saveScene({ sceneId: this.props.challengeId })); + }; + + private onEditWorld_ = () => { + this.onSave_(); + this.props.navigate(`/scene/${this.props.challengeId}`); + }; + + private onTest_ = () => { + this.onSave_(); + window.location.href = `/challenge/${this.props.challengeId}`; + }; + + render() { + const { theme, locale, challenge, challengeAsyncType } = this.props; + + if (!challenge) { + return ( + +

+ {challengeAsyncType === Async.Type.Loading + ? LocalizedString.lookup(tr('Loading challenge...'), locale) + : LocalizedString.lookup(tr('Challenge not found.'), locale)} +

+
+ ); + } + + const uid = auth.currentUser?.uid; + const isOwner = + challenge.author.type === Author.Type.User && challenge.author.id === uid; + + return ( + + + + {LocalizedString.lookup(tr('Save'), locale)} + + + {LocalizedString.lookup(tr('Edit world'), locale)} + + + {LocalizedString.lookup(tr('Test challenge'), locale)} + + + +
+
+ + ) => + this.props.dispatch( + ChallengesAction.setName({ + challengeId: this.props.challengeId, + name: { + ...challenge.name, + [LocalizedString.EN_US]: e.currentTarget.value, + }, + }) + ) + } + /> + + +