Skip to content

Commit 3e42143

Browse files
committed
fix(academy): enforce availableBlocks in toolbar; fix mixed exercise+quiz rendering
- Add useSandboxBlockConstraints context; SandboxCanvasProvider provides exerciseConfig.availableBlocks so the toolbar only shows permitted block types. Empty array hides all blocks (configure-only exercises); non-null array restricts to listed types; triggers always hidden in sandbox. - Fix mixed lesson with both exerciseConfig and quizConfig: exercise renders first, quiz reveals after exercise completes (sequential pedagogy). canAdvance now requires both exerciseComplete && quizComplete when both are present.
1 parent 973c2d1 commit 3e42143

File tree

4 files changed

+117
-72
lines changed

4 files changed

+117
-72
lines changed

apps/sim/app/academy/[courseSlug]/[lessonSlug]/page.tsx

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,13 @@ export default function LessonPage({ params }: LessonPageProps) {
2222
const { courseSlug, lessonSlug } = use(params)
2323
const course = getCourse(courseSlug)
2424
const [exerciseComplete, setExerciseComplete] = useState(false)
25+
const [quizComplete, setQuizComplete] = useState(false)
2526
// Reset completion state when the lesson changes (Next.js reuses the component across navigations).
2627
const [prevLessonSlug, setPrevLessonSlug] = useState(lessonSlug)
2728
if (prevLessonSlug !== lessonSlug) {
2829
setPrevLessonSlug(lessonSlug)
2930
setExerciseComplete(false)
31+
setQuizComplete(false)
3032
}
3133

3234
const allLessons = useMemo<Lesson[]>(
@@ -40,7 +42,12 @@ export default function LessonPage({ params }: LessonPageProps) {
4042
const nextLesson = currentIndex < allLessons.length - 1 ? allLessons[currentIndex + 1] : null
4143

4244
const handleComplete = useCallback(() => setExerciseComplete(true), [])
43-
const canAdvance = (!lesson?.exerciseConfig && !lesson?.quizConfig) || exerciseComplete
45+
// For mixed lessons that have both an exercise and a quiz, the learner must complete both.
46+
const canAdvance =
47+
(!lesson?.exerciseConfig && !lesson?.quizConfig) ||
48+
(Boolean(lesson?.exerciseConfig) && Boolean(lesson?.quizConfig)
49+
? exerciseComplete && quizComplete
50+
: exerciseComplete)
4451

4552
// Video lessons are considered complete once visited — no interactive gate required.
4653
useEffect(() => {
@@ -154,15 +161,27 @@ export default function LessonPage({ params }: LessonPageProps) {
154161

155162
{lesson.lessonType === 'mixed' && (
156163
<>
157-
{hasExercise && (
164+
{hasExercise && !exerciseComplete && (
158165
<ExerciseView
159166
lessonId={lesson.id}
160167
exerciseConfig={lesson.exerciseConfig!}
161168
onComplete={handleComplete}
162-
videoUrl={lesson.videoUrl}
163-
description={lesson.description}
169+
videoUrl={!hasQuiz ? lesson.videoUrl : undefined}
170+
description={!hasQuiz ? lesson.description : undefined}
164171
/>
165172
)}
173+
{hasExercise && exerciseComplete && hasQuiz && (
174+
<div className='flex-1 overflow-y-auto p-8'>
175+
<div className='mx-auto w-full max-w-xl space-y-8'>
176+
{hasVideo && <LessonVideo url={lesson.videoUrl!} title={lesson.title} />}
177+
<LessonQuiz
178+
lessonId={lesson.id}
179+
quizConfig={lesson.quizConfig!}
180+
onPass={() => setQuizComplete(true)}
181+
/>
182+
</div>
183+
</div>
184+
)}
166185
{!hasExercise && hasQuiz && (
167186
<div className='flex-1 overflow-y-auto p-8'>
168187
<div className='mx-auto w-full max-w-xl space-y-8'>

apps/sim/app/academy/components/sandbox-canvas-provider.tsx

Lines changed: 69 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/
1717
import { SandboxWorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
1818
import Workflow from '@/app/workspace/[workspaceId]/w/[workflowId]/workflow'
1919
import { getBlock } from '@/blocks/registry'
20+
import { SandboxBlockConstraintsContext } from '@/hooks/use-sandbox-block-constraints'
2021
import { useExecutionStore } from '@/stores/execution/store'
2122
import { useTerminalConsoleStore } from '@/stores/terminal/console/store'
2223
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -353,77 +354,79 @@ export function SandboxCanvasProvider({
353354
const currentHint = hintIndex >= 0 ? hints[hintIndex] : null
354355

355356
return (
356-
<GlobalCommandsProvider>
357-
<SandboxWorkspacePermissionsProvider>
358-
<div className={cn('flex h-full w-full overflow-hidden', className)}>
359-
<div className='flex w-56 flex-shrink-0 flex-col gap-3 overflow-y-auto border-[#1F1F1F] border-r bg-[#141414] p-3'>
360-
{(videoUrl || description) && (
361-
<div className='flex flex-col gap-2'>
362-
{videoUrl && <LessonVideo url={videoUrl} title='Lesson video' />}
363-
{description && (
364-
<p className='text-[#666] text-[11px] leading-relaxed'>{description}</p>
365-
)}
366-
<div className='border-[#1F1F1F] border-t' />
367-
</div>
368-
)}
369-
{exerciseConfig.instructions && (
370-
<p className='text-[#999] text-[11px] leading-relaxed'>
371-
{exerciseConfig.instructions}
372-
</p>
373-
)}
374-
<ValidationChecklist
375-
results={validationResult.results}
376-
allPassed={validationResult.passed}
377-
/>
378-
379-
<div className='mt-auto flex flex-col gap-2'>
380-
{currentHint && (
381-
<div className='rounded-[6px] border border-[#2A2A2A] bg-[#1A1A1A] px-3 py-2 text-[11px]'>
382-
<div className='mb-1 flex items-center justify-between'>
383-
<span className='font-[430] text-[#666]'>
384-
Hint {hintIndex + 1}/{hints.length}
385-
</span>
386-
<div className='flex gap-1'>
387-
<button
388-
type='button'
389-
onClick={handlePrevHint}
390-
disabled={hintIndex === 0}
391-
className='rounded px-1 text-[#666] transition-colors hover:text-[#ECECEC] disabled:opacity-30'
392-
aria-label='Previous hint'
393-
>
394-
395-
</button>
396-
<button
397-
type='button'
398-
onClick={handleShowHint}
399-
disabled={hintIndex === hints.length - 1}
400-
className='rounded px-1 text-[#666] transition-colors hover:text-[#ECECEC] disabled:opacity-30'
401-
aria-label='Next hint'
402-
>
403-
404-
</button>
405-
</div>
406-
</div>
407-
<span className='text-[#ECECEC]'>{currentHint}</span>
357+
<SandboxBlockConstraintsContext.Provider value={exerciseConfig.availableBlocks}>
358+
<GlobalCommandsProvider>
359+
<SandboxWorkspacePermissionsProvider>
360+
<div className={cn('flex h-full w-full overflow-hidden', className)}>
361+
<div className='flex w-56 flex-shrink-0 flex-col gap-3 overflow-y-auto border-[#1F1F1F] border-r bg-[#141414] p-3'>
362+
{(videoUrl || description) && (
363+
<div className='flex flex-col gap-2'>
364+
{videoUrl && <LessonVideo url={videoUrl} title='Lesson video' />}
365+
{description && (
366+
<p className='text-[#666] text-[11px] leading-relaxed'>{description}</p>
367+
)}
368+
<div className='border-[#1F1F1F] border-t' />
408369
</div>
409370
)}
410-
{hints.length > 0 && hintIndex < 0 && (
411-
<button
412-
type='button'
413-
onClick={handleShowHint}
414-
className='w-full rounded-[5px] border border-[#2A2A2A] bg-[#1A1A1A] px-3 py-1.5 text-[#999] text-[12px] transition-colors hover:border-[#3A3A3A] hover:text-[#ECECEC]'
415-
>
416-
Show hint
417-
</button>
371+
{exerciseConfig.instructions && (
372+
<p className='text-[#999] text-[11px] leading-relaxed'>
373+
{exerciseConfig.instructions}
374+
</p>
418375
)}
376+
<ValidationChecklist
377+
results={validationResult.results}
378+
allPassed={validationResult.passed}
379+
/>
380+
381+
<div className='mt-auto flex flex-col gap-2'>
382+
{currentHint && (
383+
<div className='rounded-[6px] border border-[#2A2A2A] bg-[#1A1A1A] px-3 py-2 text-[11px]'>
384+
<div className='mb-1 flex items-center justify-between'>
385+
<span className='font-[430] text-[#666]'>
386+
Hint {hintIndex + 1}/{hints.length}
387+
</span>
388+
<div className='flex gap-1'>
389+
<button
390+
type='button'
391+
onClick={handlePrevHint}
392+
disabled={hintIndex === 0}
393+
className='rounded px-1 text-[#666] transition-colors hover:text-[#ECECEC] disabled:opacity-30'
394+
aria-label='Previous hint'
395+
>
396+
397+
</button>
398+
<button
399+
type='button'
400+
onClick={handleShowHint}
401+
disabled={hintIndex === hints.length - 1}
402+
className='rounded px-1 text-[#666] transition-colors hover:text-[#ECECEC] disabled:opacity-30'
403+
aria-label='Next hint'
404+
>
405+
406+
</button>
407+
</div>
408+
</div>
409+
<span className='text-[#ECECEC]'>{currentHint}</span>
410+
</div>
411+
)}
412+
{hints.length > 0 && hintIndex < 0 && (
413+
<button
414+
type='button'
415+
onClick={handleShowHint}
416+
className='w-full rounded-[5px] border border-[#2A2A2A] bg-[#1A1A1A] px-3 py-1.5 text-[#999] text-[12px] transition-colors hover:border-[#3A3A3A] hover:text-[#ECECEC]'
417+
>
418+
Show hint
419+
</button>
420+
)}
421+
</div>
419422
</div>
420-
</div>
421423

422-
<div className='relative flex-1 overflow-hidden'>
423-
<Workflow workspaceId={SANDBOX_WORKSPACE_ID} workflowId={workflowId} sandbox />
424+
<div className='relative flex-1 overflow-hidden'>
425+
<Workflow workspaceId={SANDBOX_WORKSPACE_ID} workflowId={workflowId} sandbox />
426+
</div>
424427
</div>
425-
</div>
426-
</SandboxWorkspacePermissionsProvider>
427-
</GlobalCommandsProvider>
428+
</SandboxWorkspacePermissionsProvider>
429+
</GlobalCommandsProvider>
430+
</SandboxBlockConstraintsContext.Provider>
428431
)
429432
}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/component
2828
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
2929
import type { BlockConfig } from '@/blocks/types'
3030
import { usePermissionConfig } from '@/hooks/use-permission-config'
31+
import { useSandboxBlockConstraints } from '@/hooks/use-sandbox-block-constraints'
3132
import { useToolbarStore } from '@/stores/panel'
3233

3334
interface BlockItem {
@@ -348,12 +349,21 @@ export const Toolbar = memo(
348349
})
349350

350351
const { filterBlocks } = usePermissionConfig()
352+
const sandboxAllowedBlocks = useSandboxBlockConstraints()
351353

352354
const allTriggers = getTriggers()
353355
const allBlocks = getBlocks()
354356

355-
const blocks = useMemo(() => filterBlocks(allBlocks), [filterBlocks, allBlocks])
356-
const triggers = useMemo(() => filterBlocks(allTriggers), [filterBlocks, allTriggers])
357+
const blocks = useMemo(() => {
358+
const permitted = filterBlocks(allBlocks)
359+
if (sandboxAllowedBlocks === null) return permitted
360+
return permitted.filter((b) => sandboxAllowedBlocks.includes(b.type))
361+
}, [filterBlocks, allBlocks, sandboxAllowedBlocks])
362+
const triggers = useMemo(() => {
363+
// Triggers are always hidden in sandbox mode regardless of availableBlocks.
364+
if (sandboxAllowedBlocks !== null) return []
365+
return filterBlocks(allTriggers)
366+
}, [filterBlocks, allTriggers, sandboxAllowedBlocks])
357367

358368
const isTriggersAtMinimum = toolbarTriggersHeight <= TRIGGERS_MIN_THRESHOLD
359369

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { createContext, useContext } from 'react'
2+
3+
/**
4+
* Provides the list of block types the learner is allowed to add in a sandbox exercise.
5+
* Null means no constraint (all blocks allowed — the default outside sandbox mode).
6+
* An empty array means no blocks may be added (configure/connect pre-placed blocks only).
7+
*/
8+
export const SandboxBlockConstraintsContext = createContext<string[] | null>(null)
9+
10+
/** Returns the sandbox-allowed block types, or null if not in a sandbox context. */
11+
export function useSandboxBlockConstraints(): string[] | null {
12+
return useContext(SandboxBlockConstraintsContext)
13+
}

0 commit comments

Comments
 (0)