From 6fa265d7ff756ed6a584f1c130420a52303dd754 Mon Sep 17 00:00:00 2001 From: andreimarozau Date: Thu, 29 Jan 2026 18:41:54 +0300 Subject: [PATCH 1/3] Add paired block functionality to distractors in Parsons exercise Enhance Parsons exercise functionality with grader and order mode options issue-829 Add Parsons options for adaptive behavior, numbering, and indentation --- .../ParsonsExercise/ParsonsExercise.tsx | 97 +++- .../ParsonsExercise/ParsonsPreview.tsx | 13 +- .../ParsonsExercise/components/BlockItem.tsx | 522 +++++++++++------- .../components/ParsonsBlocksManager.tsx | 172 +++++- .../components/ParsonsExercise.module.css | 372 ++++++++++++- .../components/ParsonsOptions.tsx | 138 +++++ .../components/SortableBlock.tsx | 32 +- .../ParsonsExercise/components/index.ts | 1 + .../CreateExercise/config/stepConfigs.ts | 58 ++ .../assignment_builder/src/types/exercises.ts | 7 + .../src/utils/htmlRegeneration.ts | 7 +- .../src/utils/preview/parsonsPreview.tsx | 64 ++- .../src/utils/questionJson.ts | 13 +- 13 files changed, 1231 insertions(+), 265 deletions(-) create mode 100644 bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/ParsonsOptions.tsx diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/ParsonsExercise.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/ParsonsExercise.tsx index 26b2e037..53af62c4 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/ParsonsExercise.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/ParsonsExercise.tsx @@ -14,7 +14,12 @@ import { validateCommonFields } from "../../utils/validation"; import { ParsonsExerciseSettings } from "./ParsonsExerciseSettings"; import { ParsonsPreview } from "./ParsonsPreview"; -import { ParsonsInstructions, ParsonsLanguageSelector, ParsonsBlocksManager } from "./components"; +import { + ParsonsInstructions, + ParsonsLanguageSelector, + ParsonsBlocksManager, + ParsonsOptions +} from "./components"; const PARSONS_STEPS = [ { label: "Language" }, @@ -39,7 +44,13 @@ const getDefaultFormData = (): ParsonsData => ({ question_type: "parsonsprob", language: "", instructions: "", - blocks: [{ id: `block-${Date.now()}`, content: "", indent: 0 }] + blocks: [{ id: `block-${Date.now()}`, content: "", indent: 0 }], + adaptive: true, + numbered: "left", + noindent: false, + grader: "line", + orderMode: "random", + customOrder: [] }); const generatePreview = (data: ParsonsData): string => { @@ -48,10 +59,13 @@ const generatePreview = (data: ParsonsData): string => { blocks: data.blocks || [], name: data.name || "parsons_exercise", language: data.language || "python", - adaptive: true, - numbered: "left", - noindent: false, - questionLabel: data.name + adaptive: data.adaptive ?? true, + numbered: data.numbered ?? "left", + noindent: data.noindent ?? false, + questionLabel: data.name, + grader: data.grader ?? "line", + orderMode: data.orderMode ?? "random", + customOrder: data.customOrder }); }; @@ -61,10 +75,13 @@ const generateExerciseHtmlSrc = (data: ParsonsData): string => { instructions: data.instructions || "", blocks: data.blocks || [], language: data.language || "python", - adaptive: true, - numbered: "left", - noindent: false, - questionLabel: data.name + adaptive: data.adaptive ?? true, + numbered: data.numbered ?? "left", + noindent: data.noindent ?? false, + questionLabel: data.name, + grader: data.grader ?? "line", + orderMode: data.orderMode ?? "random", + customOrder: data.customOrder }); }; @@ -144,6 +161,15 @@ export const ParsonsExercise: FC = ({ [updateFormData, formData.language, formData.tags] ); + const handleAddBlock = useCallback(() => { + const newBlock: ParsonsBlock = { + id: `block-${Date.now()}`, + content: "", + indent: 0 + }; + updateFormData("blocks", [...(formData.blocks || []), newBlock]); + }, [updateFormData, formData.blocks]); + const renderStepContent = () => { switch (activeStep) { case 0: @@ -164,11 +190,43 @@ export const ParsonsExercise: FC = ({ case 2: return ( - updateFormData("blocks", blocks)} - language={formData.language || "python"} - /> +
+ updateFormData("adaptive", value)} + onNumberedChange={(value: "left" | "right" | "none") => + updateFormData("numbered", value) + } + onNoindentChange={(value: boolean) => updateFormData("noindent", value)} + onGraderChange={(value: "line" | "dag") => { + updateFormData("grader", value); + if (value === "dag") { + updateFormData("adaptive", false); + // Auto-assign tags to blocks that don't have them + const updatedBlocks = (formData.blocks || []).map((block, idx) => { + if (!block.tag && !block.isDistractor && !block.groupId) { + return { ...block, tag: String(idx) }; + } + return block; + }); + updateFormData("blocks", updatedBlocks); + } + }} + onOrderModeChange={(value: "random" | "custom") => updateFormData("orderMode", value)} + onAddBlock={handleAddBlock} + /> + updateFormData("blocks", blocks)} + language={formData.language || "python"} + grader={formData.grader ?? "line"} + orderMode={formData.orderMode ?? "random"} + /> +
); case 3: @@ -181,10 +239,13 @@ export const ParsonsExercise: FC = ({ blocks={formData.blocks || []} language={formData.language || "python"} name={formData.name || ""} - adaptive={true} - numbered="left" - noindent={false} + adaptive={formData.adaptive ?? true} + numbered={formData.numbered ?? "left"} + noindent={formData.noindent ?? false} questionLabel={formData.name} + grader={formData.grader ?? "line"} + orderMode={formData.orderMode ?? "random"} + customOrder={formData.customOrder} /> ); diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/ParsonsPreview.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/ParsonsPreview.tsx index a2d8e906..7ec8fe56 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/ParsonsPreview.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/ParsonsPreview.tsx @@ -12,6 +12,9 @@ interface ParsonsPreviewProps { numbered?: "left" | "right" | "none"; noindent?: boolean; questionLabel?: string; + grader?: "line" | "dag"; + orderMode?: "random" | "custom"; + customOrder?: number[]; } export const ParsonsPreview: FC = ({ @@ -22,7 +25,10 @@ export const ParsonsPreview: FC = ({ adaptive = true, numbered = "left", noindent = false, - questionLabel + questionLabel, + grader = "line", + orderMode = "random", + customOrder }) => { return (
@@ -35,7 +41,10 @@ export const ParsonsPreview: FC = ({ adaptive, numbered, noindent, - questionLabel: questionLabel || name + questionLabel: questionLabel || name, + grader, + orderMode, + customOrder })} />
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/BlockItem.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/BlockItem.tsx index 13084481..a320ef7f 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/BlockItem.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/BlockItem.tsx @@ -2,12 +2,14 @@ import { Editor } from "@components/routes/AssignmentBuilder/components/exercise import { Button } from "primereact/button"; import { Checkbox, CheckboxChangeEvent } from "primereact/checkbox"; import { InputText } from "primereact/inputtext"; +import { InputTextarea } from "primereact/inputtextarea"; import { Tooltip } from "primereact/tooltip"; import React, { FC, useRef, useCallback, useMemo, useState, useEffect, CSSProperties } from "react"; import { ParsonsBlock } from "@/utils/preview/parsonsPreview"; import { ParsonsCodeHighlighter } from "./ParsonsCodeHighlighter"; +import styles from "./ParsonsExercise.module.css"; interface BlockItemProps { block: ParsonsBlock; @@ -16,15 +18,25 @@ interface BlockItemProps { indentWidth: number; maxIndent: number; blockWidth: number; + blockIndex?: number; onContentChange: (id: string, content: string) => void; onRemove: (id: string) => void; onAddAlternative?: (id: string) => void; onCorrectChange?: (id: string, isCorrect: boolean) => void; onSplitBlock?: (id: string, lineIndex: number) => void; + onDistractorChange?: (id: string, isDistractor: boolean) => void; + onPairedChange?: (id: string, paired: boolean) => void; + onCommentChange?: (id: string, comment: string) => void; + onTagChange?: (id: string, tag: string) => void; + onDependsChange?: (id: string, depends: string[]) => void; + onOrderChange?: (id: string, order: number) => void; showCorrectCheckbox?: boolean; hasAlternatives?: boolean; showAddAlternative?: boolean; showDragHandle?: boolean; + grader?: "line" | "dag"; + orderMode?: "random" | "custom"; + allTags?: string[]; style?: React.CSSProperties; dragHandleProps?: { ref?: React.RefObject; @@ -34,9 +46,7 @@ interface BlockItemProps { } const LINE_HEIGHT = 18; - const MIN_EDITOR_HEIGHT = 36; - const EDITOR_PADDING = 8; export const BlockItem: FC = ({ @@ -46,15 +56,25 @@ export const BlockItem: FC = ({ indentWidth, maxIndent, blockWidth, + blockIndex, onContentChange, onRemove, onAddAlternative, onCorrectChange, onSplitBlock, + onDistractorChange, + onPairedChange, + onCommentChange, + onTagChange, + onDependsChange, + onOrderChange, showCorrectCheckbox = false, hasAlternatives = false, showAddAlternative = true, showDragHandle = true, + grader = "line", + orderMode = "random", + allTags = [], style = {}, dragHandleProps }) => { @@ -68,22 +88,17 @@ export const BlockItem: FC = ({ const [hoveredLine, setHoveredLine] = useState(null); const [hoveredLineOffset, setHoveredLineOffset] = useState(0); const [cursorPosition, setCursorPosition] = useState<{ x: number; y: number } | null>(null); + const [showComment, setShowComment] = useState(!!block.comment); useEffect(() => { return () => { - const tooltips = document.querySelectorAll(".p-tooltip"); - - tooltips.forEach((tooltip) => { - tooltip.classList.add("p-hidden"); - }); + document.querySelectorAll(".p-tooltip").forEach((t) => t.classList.add("p-hidden")); }; }, []); const calculateEditorHeight = useCallback((content: string) => { if (!content) return MIN_EDITOR_HEIGHT; - const lineCount = content.split("\n").length; - return Math.max(lineCount * LINE_HEIGHT + EDITOR_PADDING + LINE_HEIGHT / 2, MIN_EDITOR_HEIGHT); }, []); @@ -94,7 +109,6 @@ export const BlockItem: FC = ({ const handleContentChange = useCallback( (e: React.ChangeEvent | string) => { const newContent = typeof e === "string" ? e : e.target.value; - onContentChange(block.id, newContent); }, [block.id, onContentChange] @@ -109,31 +123,21 @@ export const BlockItem: FC = ({ ); const hideAllTooltips = useCallback(() => { - const tooltips = document.querySelectorAll(".p-tooltip"); - - tooltips.forEach((tooltip) => { - tooltip.classList.add("p-hidden"); - }); + document.querySelectorAll(".p-tooltip").forEach((t) => t.classList.add("p-hidden")); }, []); const handleAddAlternative = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); - hideAllTooltips(); - - if (onAddAlternative) { - onAddAlternative(block.id); - } + if (onAddAlternative) onAddAlternative(block.id); }, [block.id, onAddAlternative, hideAllTooltips] ); const handleCorrectChange = useCallback( (e: CheckboxChangeEvent) => { - if (onCorrectChange) { - onCorrectChange(block.id, e.checked || false); - } + if (onCorrectChange) onCorrectChange(block.id, e.checked || false); }, [block.id, onCorrectChange] ); @@ -147,13 +151,9 @@ export const BlockItem: FC = ({ const findNearestLineBoundary = useCallback( (y: number, monacoEditor: Element | null): { lineIndex: number; offsetY: number } | null => { if (!monacoEditor) return null; - const lines = block.content.split("\n"); - if (lines.length <= 1) return null; - const lineElements = monacoEditor.querySelectorAll(".view-line"); - if (!lineElements || lineElements.length <= 1) return null; let closestLine = 0; @@ -161,25 +161,18 @@ export const BlockItem: FC = ({ let closestOffset = 0; for (let i = 0; i < lineElements.length; i++) { - const lineElement = lineElements[i] as HTMLElement; - const lineRect = lineElement.getBoundingClientRect(); - const lineBottom = lineRect.bottom; - + const lineRect = (lineElements[i] as HTMLElement).getBoundingClientRect(); if (i < lineElements.length - 1) { - const nextLineElement = lineElements[i + 1] as HTMLElement; - const nextLineRect = nextLineElement.getBoundingClientRect(); - const lineBoundary = (lineBottom + nextLineRect.top) / 2; - - const distance = Math.abs(y - lineBoundary); - + const nextRect = (lineElements[i + 1] as HTMLElement).getBoundingClientRect(); + const boundary = (lineRect.bottom + nextRect.top) / 2; + const distance = Math.abs(y - boundary); if (distance < closestDistance) { closestDistance = distance; closestLine = i + 1; - closestOffset = lineBoundary; + closestOffset = boundary; } } } - return closestDistance < 10 ? { lineIndex: closestLine, offsetY: closestOffset } : null; }, [block.content] @@ -188,25 +181,15 @@ export const BlockItem: FC = ({ const handleEditorMouseMove = useCallback( (e: React.MouseEvent) => { if (!editorContainerRef.current || !onSplitBlock || isDragging || hasAlternatives) return; - setCursorPosition({ x: e.clientX, y: e.clientY }); - const container = editorContainerRef.current; const monacoEditor = container.querySelector(".monaco-editor"); - const tiptapEditor = container.querySelector(".ProseMirror"); - if (monacoEditor) { const containerRect = container.getBoundingClientRect(); - const cursorY = e.clientY; - - const boundary = findNearestLineBoundary(cursorY, monacoEditor); - + const boundary = findNearestLineBoundary(e.clientY, monacoEditor); if (boundary) { setHoveredLine(boundary.lineIndex); - - const relativeOffset = boundary.offsetY - containerRect.top; - - setHoveredLineOffset(relativeOffset); + setHoveredLineOffset(boundary.offsetY - containerRect.top); } else { setHoveredLine(null); } @@ -219,31 +202,20 @@ export const BlockItem: FC = ({ useEffect(() => { if (!editorContainerRef.current || hoveredLine === null) return; - const handleScroll = () => { if (cursorPosition && dividerRef.current && editorContainerRef.current) { const container = editorContainerRef.current; - const containerRect = container.getBoundingClientRect(); - const monacoEditor = container.querySelector(".monaco-editor"); - if (monacoEditor) { const boundary = findNearestLineBoundary(cursorPosition.y, monacoEditor); - if (boundary) { - const relativeOffset = boundary.offsetY - containerRect.top; - - setHoveredLineOffset(relativeOffset); + setHoveredLineOffset(boundary.offsetY - container.getBoundingClientRect().top); } } } }; - window.addEventListener("scroll", handleScroll, true); - - return () => { - window.removeEventListener("scroll", handleScroll, true); - }; + return () => window.removeEventListener("scroll", handleScroll, true); }, [hoveredLine, cursorPosition, findNearestLineBoundary]); const handleEditorMouseLeave = useCallback(() => { @@ -253,200 +225,340 @@ export const BlockItem: FC = ({ const handleSplitBlock = useCallback( (lineIndex: number) => { - if (onSplitBlock) { - onSplitBlock(block.id, lineIndex); - } + if (onSplitBlock) onSplitBlock(block.id, lineIndex); }, [block.id, onSplitBlock] ); const getDividerStyle = useCallback((): CSSProperties => { - const baseStyle: CSSProperties = { - top: `${hoveredLineOffset}px` - }; - - if (cursorPosition) { - return { - ...baseStyle, - pointerEvents: "none" as "none" - }; - } - - return baseStyle; + const base: CSSProperties = { top: `${hoveredLineOffset}px` }; + return cursorPosition ? { ...base, pointerEvents: "none" as "none" } : base; }, [hoveredLineOffset, cursorPosition]); const getSplitButtonContainerStyle = useCallback((): CSSProperties => { return { right: "0px" }; }, []); + // Determine strip color + const stripClass = block.isDistractor + ? block.pairedWithBlockAbove + ? styles.stripPaired + : styles.stripDistractor + : hasAlternatives + ? styles.stripAlternative + : styles.stripSolution; + + const isStandalone = !hasAlternatives; + const showDagFields = grader === "dag" && !block.isDistractor && isStandalone; + const showOrderField = orderMode === "custom" && isStandalone; + const showDistractorToggle = isStandalone && !!onDistractorChange; + + // Render split line overlay + const renderSplitOverlay = () => { + if (hoveredLine === null) return null; + return ( +
+
+
+
+ ); + }; + + // Render the code editor + const renderEditor = () => { + if (isTextEditor) { + return ( +
+ + {renderSplitOverlay()} +
+ ); + } + if (language && language !== "") { + return ( +
+ + {renderSplitOverlay()} +
+ ); + } + return ( + + ); + }; + return (
- {showDragHandle && dragHandleProps && ( -
- -
- )} - + {/* Main row */}
+ {/* Color strip */} +
+ + {/* Drag handle */} + {showDragHandle && dragHandleProps && ( +
+ +
+ )} + + {/* Block number */} + {blockIndex !== undefined && isStandalone && ( +
{blockIndex + 1}
+ )} + + {/* Correct checkbox for alternatives */} {showCorrectCheckbox && ( -
+
e.stopPropagation()} data-pr-tooltip="Correct answer" + style={{ width: "1rem", height: "1rem" }} />
)} -
- {isTextEditor ? ( -
- - {hoveredLine !== null && ( -
-
-
-
- )} -
- ) : language && language !== "" ? ( -
{renderEditor()}
+ + {/* Inline meta fields */} + {(showDistractorToggle || showDagFields || showOrderField) && ( +
+ {/* Distractor toggle chip */} + {showDistractorToggle && ( + + )} + + {/* Paired toggle — only shown for distractor blocks */} + {showDistractorToggle && block.isDistractor && onPairedChange && ( + + )} + + {/* Order */} + {showOrderField && onOrderChange && ( +
+ # + { + const val = parseInt(e.target.value); + onOrderChange(block.id, isNaN(val) ? 0 : val); + }} + className={styles.orderInlineInput} + onMouseDown={(e) => e.stopPropagation()} + placeholder="—" + /> +
+ )} + + {/* DAG tag */} + {showDagFields && onTagChange && ( +
+ tag + onTagChange(block.id, e.target.value)} + className={styles.tagInlineInput} + onMouseDown={(e) => e.stopPropagation()} + placeholder={`${blockIndex ?? 0}`} + /> +
+ )} + + {/* DAG depends */} + {showDagFields && onDependsChange && ( +
+ dep + + { + const deps = e.target.value + .split(",") + .map((d) => d.trim()) + .filter((d) => d !== ""); + onDependsChange(block.id, deps); + }} + className={`${styles.dependsInlineInput} depends-input-${block.id}`} + onMouseDown={(e) => e.stopPropagation()} + placeholder="0,1" + data-pr-tooltip={ + allTags.length > 0 + ? `Available: ${allTags.filter((t) => t !== block.tag).join(", ")}` + : undefined + } + /> +
+ )} +
+ )} + + {/* Action buttons */} +
+ {/* Comment toggle */} + {onCommentChange && isStandalone && ( +
-
- )} -
- ) : ( - )} -
- -
+ {/* Add alternative */} {onAddAlternative && showAddAlternative && ( <> - +
+ + {/* Comment row — appears below the main row when toggled */} + {showComment && onCommentChange && ( +
+ onCommentChange(block.id, e.target.value)} + className={styles.commentInput} + onMouseDown={(e) => e.stopPropagation()} + placeholder="Author note (not shown to students)..." + autoResize + rows={1} + /> +
+ )}
); }; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/ParsonsBlocksManager.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/ParsonsBlocksManager.tsx index 8a01b2cb..23df55aa 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/ParsonsBlocksManager.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/ParsonsBlocksManager.tsx @@ -86,12 +86,18 @@ interface ParsonsBlocksManagerProps { blocks: ParsonsBlock[]; onChange: (blocks: ParsonsBlock[]) => void; language: string; + onAddBlock?: () => void; + grader?: "line" | "dag"; + orderMode?: "random" | "custom"; } export const ParsonsBlocksManager: FC = ({ blocks, onChange, - language + language, + onAddBlock, + grader = "line", + orderMode = "random" }) => { const [activeId, setActiveId] = useState(null); const [highlightedGuide, setHighlightedGuide] = useState(null); @@ -452,14 +458,18 @@ export const ParsonsBlocksManager: FC = ({ ); const handleAddBlock = useCallback(() => { - const newBlock: ParsonsBlock = { - id: `block-${Date.now()}`, - content: "", - indent: 0 - }; + if (onAddBlock) { + onAddBlock(); + } else { + const newBlock: ParsonsBlock = { + id: `block-${Date.now()}`, + content: "", + indent: 0 + }; - onChange([...blocks, newBlock]); - }, [blocks, onChange]); + onChange([...blocks, newBlock]); + } + }, [blocks, onChange, onAddBlock]); const handleRemoveBlock = useCallback( (id: string) => { @@ -647,6 +657,116 @@ export const ParsonsBlocksManager: FC = ({ [blocks, onChange] ); + const handleDistractorChange = useCallback( + (id: string, isDistractor: boolean) => { + const newBlocks = blocks.map((block) => { + if (block.id === id) { + // Reset paired when turning off distractor + return { + ...block, + isDistractor, + pairedWithBlockAbove: isDistractor ? block.pairedWithBlockAbove : false + }; + } + return block; + }); + onChange(newBlocks); + }, + [blocks, onChange] + ); + + const handlePairedChange = useCallback( + (id: string, paired: boolean) => { + const newBlocks = blocks.map((block) => { + if (block.id === id) { + return { ...block, pairedWithBlockAbove: paired }; + } + return block; + }); + onChange(newBlocks); + }, + [blocks, onChange] + ); + + const handleCommentChange = useCallback( + (id: string, comment: string) => { + const newBlocks = blocks.map((block) => { + if (block.id === id) { + return { ...block, comment }; + } + return block; + }); + onChange(newBlocks); + }, + [blocks, onChange] + ); + + const handleTagChange = useCallback( + (id: string, tag: string) => { + const newBlocks = blocks.map((block) => { + if (block.id === id) { + return { ...block, tag }; + } + return block; + }); + onChange(newBlocks); + }, + [blocks, onChange] + ); + + const handleDependsChange = useCallback( + (id: string, depends: string[]) => { + const newBlocks = blocks.map((block) => { + if (block.id === id) { + return { ...block, depends }; + } + return block; + }); + onChange(newBlocks); + }, + [blocks, onChange] + ); + + const handleOrderChange = useCallback( + (id: string, order: number) => { + const newBlocks = blocks.map((block) => { + if (block.id === id) { + return { ...block, displayOrder: order }; + } + return block; + }); + onChange(newBlocks); + }, + [blocks, onChange] + ); + + const allTags = useMemo(() => { + return blocks.filter((b) => b.tag && !b.isDistractor).map((b) => b.tag as string); + }, [blocks]); + + // Compute a standalone block index (not counting grouped alternates) + const blockIndexMap = useMemo(() => { + const map: Record = {}; + let idx = 0; + const processedGroups = new Set(); + blocks.forEach((block) => { + if (block.groupId) { + if (!processedGroups.has(block.groupId)) { + processedGroups.add(block.groupId); + const groupBlocks = blocks.filter((b) => b.groupId === block.groupId); + groupBlocks.forEach((b) => { + map[b.id] = idx; + }); + idx++; + } + } else { + map[block.id] = idx; + idx++; + } + }); + return map; + }, [blocks]); + const renderIndentationGuides = () => { return availableIndents.map((i) => { const isHighlighted = highlightedGuide === i; @@ -695,8 +815,8 @@ export const ParsonsBlocksManager: FC = ({ }, [blocks]); const containerStyle = { - minHeight: "300px", - maxHeight: "500px", + minHeight: "200px", + maxHeight: "600px", position: "relative" as const, padding: 0, overflow: "auto", @@ -725,19 +845,7 @@ export const ParsonsBlocksManager: FC = ({ ); return ( -
-
-
-
-
- +
= ({ ref={blocksContainerRef} className="blocks-container" style={{ - minHeight: "250px", + minHeight: "150px", position: "relative", zIndex: 2, minWidth: "max-content", @@ -804,12 +912,22 @@ export const ParsonsBlocksManager: FC = ({ indentWidth={INDENT_WIDTH} maxIndent={maxAllowedIndent} blockWidth={100} + blockIndex={blockIndexMap[block.id]} onContentChange={handleContentChange} onRemove={handleRemoveBlock} onAddAlternative={handleAddAlternative} onSplitBlock={handleSplitBlock} + onDistractorChange={handleDistractorChange} + onPairedChange={handlePairedChange} + onCommentChange={handleCommentChange} + onTagChange={handleTagChange} + onDependsChange={handleDependsChange} + onOrderChange={handleOrderChange} showAddAlternative={true} showDragHandle={true} + grader={grader} + orderMode={orderMode} + allTags={allTags} /> ); } @@ -846,6 +964,7 @@ export const ParsonsBlocksManager: FC = ({ indentWidth={INDENT_WIDTH} maxIndent={maxAllowedIndent} blockWidth={blockWidth} + blockIndex={blockIndexMap[block.id]} onContentChange={handleContentChange} onRemove={handleRemoveBlock} onAddAlternative={handleAddAlternative} @@ -854,6 +973,9 @@ export const ParsonsBlocksManager: FC = ({ hasAlternatives={true} showAddAlternative={blockGroup.length < MAX_ALTERNATIVES} showDragHandle={isFirstInGroup} + grader={grader} + orderMode={orderMode} + allTags={allTags} /> ); })} @@ -892,6 +1014,8 @@ export const ParsonsBlocksManager: FC = ({ onContentChange={() => {}} onRemove={() => {}} showDragHandle={true} + grader={grader} + orderMode={orderMode} dragHandleProps={{}} style={{ width: dragWidths[draggingGroup[0].id] diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/ParsonsExercise.module.css b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/ParsonsExercise.module.css index 9e426dd8..ad680619 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/ParsonsExercise.module.css +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/ParsonsExercise.module.css @@ -1,3 +1,344 @@ +/* ═══════════════════════════════════════════ + TOOLBAR + ═══════════════════════════════════════════ */ + +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.375rem 0.75rem; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 6px; + margin: -2rem; + margin-bottom: 0.5rem; + flex-wrap: wrap; +} + +.toolbarLeft { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.toolbarGroup { + display: flex; + align-items: center; + gap: 0.375rem; +} + +.toolbarLabel { + font-size: 0.75rem; + font-weight: 600; + color: #64748b; + white-space: nowrap; +} + +.toolbarDivider { + width: 1px; + height: 20px; + background: #e2e8f0; + flex-shrink: 0; +} + +.toolbarCheckLabel { + font-size: 0.8rem; + color: #475569; + cursor: pointer; + white-space: nowrap; + margin: 0; + user-select: none; +} + +.toolbarCheckLabel.disabled { + color: #94a3b8; + cursor: default; +} + +/* Tiny PrimeReact overrides */ +.tinySelectButton :global(.p-button) { + padding: 0.2rem 0.5rem !important; + font-size: 0.75rem !important; + font-weight: 500 !important; +} + +.tinyDropdown { + height: 1.75rem !important; + min-height: 1.75rem !important; + width: 5.5rem !important; +} + +.tinyDropdown :global(.p-dropdown-label) { + padding: 0.2rem 0.5rem !important; + font-size: 0.75rem !important; +} + +.tinyDropdown :global(.p-dropdown-trigger) { + width: 1.75rem !important; +} + +.tinyCheckbox { + width: 1rem !important; + height: 1rem !important; +} + +.addBlockBtn { + font-size: 0.8rem !important; + padding: 0.3rem 0.625rem !important; + white-space: nowrap; + flex-shrink: 0; +} + +/* ═══════════════════════════════════════════ + BLOCK ITEM — single compact row + ═══════════════════════════════════════════ */ + +.blockRow { + display: flex; + align-items: stretch; + border: 1px solid #e2e8f0; + border-radius: 5px; + overflow: hidden; + background: #fff; + transition: box-shadow 0.15s; + position: relative; +} + +.blockRow:hover { + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); +} + +/* Color strip on the left */ +.blockStrip { + width: 4px; + flex-shrink: 0; +} + +.stripSolution { + background: #22c55e; +} + +.stripDistractor { + background: #ef4444; +} + +.stripPaired { + background: #8b5cf6; +} + +.stripAlternative { + background: #3b82f6; +} + +/* Drag handle */ +.dragHandle { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + flex-shrink: 0; + cursor: grab; + color: #94a3b8; + font-size: 0.75rem; +} + +.dragHandle:hover { + color: #64748b; +} + +/* Block number pill */ +.blockNum { + display: flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + border-radius: 3px; + background: #f1f5f9; + color: #94a3b8; + font-size: 0.65rem; + font-weight: 700; + flex-shrink: 0; + margin: 0 2px; + align-self: center; +} + +/* Editor area */ +.blockEditor { + flex: 1; + min-width: 0; + padding: 2px 0; +} + +/* Inline meta controls (right side, inside the row) */ +.inlineMeta { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0 0.25rem; + flex-shrink: 0; +} + +.inlineField { + display: flex; + align-items: center; + gap: 2px; + font-size: 0.7rem; +} + +.inlineFieldLabel { + font-size: 0.65rem; + font-weight: 600; + color: #94a3b8; + white-space: nowrap; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.inlineInput { + height: 1.375rem !important; + padding: 0 0.25rem !important; + font-size: 0.75rem !important; + border-radius: 3px !important; + border: 1px solid #e2e8f0 !important; + background: #f8fafc !important; +} + +.inlineInput:focus { + border-color: #93c5fd !important; + box-shadow: none !important; + background: #fff !important; +} + +.orderInlineInput { + composes: inlineInput; + width: 2.25rem !important; + text-align: center !important; +} + +.tagInlineInput { + composes: inlineInput; + width: 3.5rem !important; +} + +.dependsInlineInput { + composes: inlineInput; + width: 5rem !important; +} + +/* Distractor chip — tiny toggle */ +.distractorChip { + font-size: 0.65rem !important; + padding: 0.1rem 0.35rem !important; + border-radius: 3px !important; + height: 1.375rem !important; + border: 1px solid #e2e8f0 !important; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; + display: flex; + align-items: center; + gap: 2px; + user-select: none; + background: none; + line-height: 1; +} + +.distractorChip.isDistractor { + background: #fef2f2; + border-color: #fca5a5 !important; + color: #dc2626; +} + +.distractorChip.isSolution { + background: #f0fdf4; + border-color: #86efac !important; + color: #16a34a; +} + +/* Paired chip — shown next to distractor chip */ +.pairedChip { + font-size: 0.65rem !important; + padding: 0.1rem 0.35rem !important; + border-radius: 3px !important; + height: 1.375rem !important; + border: 1px solid #e2e8f0 !important; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; + display: flex; + align-items: center; + gap: 2px; + user-select: none; + background: none; + line-height: 1; +} + +.pairedChip.isPaired { + background: #eff6ff; + border-color: #93c5fd !important; + color: #2563eb; +} + +.pairedChip.isUnpaired { + background: #f8fafc; + border-color: #e2e8f0 !important; + color: #94a3b8; +} + +/* Actions */ +.blockActions { + display: flex; + align-items: center; + gap: 1px; + padding: 0 2px; + flex-shrink: 0; +} + +.tinyBtn { + width: 22px !important; + height: 22px !important; + padding: 0 !important; +} + +.tinyBtn :global(.p-button-icon) { + font-size: 0.7rem !important; +} + +/* Comment row — collapsed by default, minimal height when open */ +.commentRow { + padding: 0.125rem 0.5rem 0.25rem 2rem; + background: #fffbeb; + border-top: 1px dashed #fde68a; +} + +.commentInput { + width: 100%; + font-size: 0.75rem !important; + padding: 0.2rem 0.375rem !important; + min-height: 1.5rem !important; + resize: none; + border: 1px solid #fde68a !important; + border-radius: 3px !important; + background: #fff !important; +} + +.commentInput:focus { + border-color: #f59e0b !important; + box-shadow: none !important; +} + +/* Block distractor row background */ +.blockRowDistractor { + background: #fef8f8; +} + +/* ═══════════════════════════════════════════ + LEGACY — kept for compatibility + ═══════════════════════════════════════════ */ + .addButton { font-size: 0.875rem; padding: 0.4rem 0.75rem; @@ -16,15 +357,6 @@ background: #f0f7ff; border-color: #3b82f6; color: #2563eb; - transform: translateY(-1px); -} - -.addButton:focus { - box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2); -} - -.addButton i { - font-size: 0.75rem; } .optionsHeader { @@ -33,4 +365,24 @@ justify-content: space-between; align-items: center; padding: 0.25rem 0.25rem 0 0; -} \ No newline at end of file +} + +.compactDropdown { + height: 2rem !important; + min-height: 2rem !important; +} + +.compactDropdown :global(.p-dropdown .p-inputtext) { + padding: 0.25rem 0.5rem !important; + font-size: 0.875rem !important; +} + +.compactDropdown :global(.p-dropdown-label) { + padding: 6px !important; +} + +.compactDropdown :global(.p-dropdown-trigger) { + width: 2rem !important; +} + + diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/ParsonsOptions.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/ParsonsOptions.tsx new file mode 100644 index 00000000..a8800f6e --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/ParsonsOptions.tsx @@ -0,0 +1,138 @@ +import { Button } from "primereact/button"; +import { Checkbox } from "primereact/checkbox"; +import { Dropdown } from "primereact/dropdown"; +import { SelectButton } from "primereact/selectbutton"; +import { FC } from "react"; + +import styles from "./ParsonsExercise.module.css"; + +interface ParsonsOptionsProps { + adaptive: boolean; + numbered: "left" | "right" | "none"; + noindent: boolean; + grader: "line" | "dag"; + orderMode: "random" | "custom"; + onAdaptiveChange: (value: boolean) => void; + onNumberedChange: (value: "left" | "right" | "none") => void; + onNoindentChange: (value: boolean) => void; + onGraderChange: (value: "line" | "dag") => void; + onOrderModeChange: (value: "random" | "custom") => void; + onAddBlock: () => void; +} + +const numberedOptions = [ + { label: "Left", value: "left" }, + { label: "Right", value: "right" }, + { label: "None", value: "none" } +]; + +const graderOptions = [ + { label: "Line", value: "line" }, + { label: "DAG", value: "dag" } +]; + +const orderModeOptions = [ + { label: "Random", value: "random" }, + { label: "Custom", value: "custom" } +]; + +export const ParsonsOptions: FC = ({ + adaptive, + numbered, + noindent, + grader, + orderMode, + onAdaptiveChange, + onNumberedChange, + onNoindentChange, + onGraderChange, + onOrderModeChange, + onAddBlock +}) => { + return ( +
+
+
+ Grader + { + if (e.value) { + onGraderChange(e.value); + if (e.value === "dag") onAdaptiveChange(false); + } + }} + className={styles.tinySelectButton} + allowEmpty={false} + /> +
+ +
+ +
+ Order + { + if (e.value) onOrderModeChange(e.value); + }} + className={styles.tinySelectButton} + allowEmpty={false} + /> +
+ +
+ +
+ Lines + onNumberedChange(e.value)} + className={styles.tinyDropdown} + /> +
+ +
+ +
+ onAdaptiveChange(e.checked ?? false)} + disabled={grader === "dag"} + className={styles.tinyCheckbox} + /> + +
+ +
+ onNoindentChange(e.checked ?? false)} + className={styles.tinyCheckbox} + /> + +
+
+ +
+ ); +}; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/SortableBlock.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/SortableBlock.tsx index 551679be..21770d3a 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/SortableBlock.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/SortableBlock.tsx @@ -13,14 +13,24 @@ interface SortableBlockProps { indentWidth: number; maxIndent: number; blockWidth: number; + blockIndex?: number; onContentChange: (id: string, content: string) => void; onRemove: (id: string) => void; onAddAlternative?: (id: string) => void; onCorrectChange?: (id: string, isCorrect: boolean) => void; onSplitBlock?: (id: string, lineIndex: number) => void; + onDistractorChange?: (id: string, isDistractor: boolean) => void; + onPairedChange?: (id: string, paired: boolean) => void; + onCommentChange?: (id: string, comment: string) => void; + onTagChange?: (id: string, tag: string) => void; + onDependsChange?: (id: string, depends: string[]) => void; + onOrderChange?: (id: string, order: number) => void; hasAlternatives?: boolean; showAddAlternative?: boolean; showDragHandle?: boolean; + grader?: "line" | "dag"; + orderMode?: "random" | "custom"; + allTags?: string[]; } export const SortableBlock: FC = ({ @@ -30,14 +40,24 @@ export const SortableBlock: FC = ({ indentWidth, maxIndent, blockWidth, + blockIndex, onContentChange, onRemove, onAddAlternative, onCorrectChange, onSplitBlock, + onDistractorChange, + onPairedChange, + onCommentChange, + onTagChange, + onDependsChange, + onOrderChange, hasAlternatives, showAddAlternative = true, - showDragHandle = true + showDragHandle = true, + grader = "line", + orderMode = "random", + allTags = [] }) => { const handleRef = useRef(null); @@ -84,15 +104,25 @@ export const SortableBlock: FC = ({ indentWidth={indentWidth} maxIndent={maxIndent} blockWidth={100} + blockIndex={blockIndex} onContentChange={onContentChange} onRemove={onRemove} onAddAlternative={onAddAlternative} onCorrectChange={onCorrectChange} onSplitBlock={onSplitBlock} + onDistractorChange={onDistractorChange} + onPairedChange={onPairedChange} + onCommentChange={onCommentChange} + onTagChange={onTagChange} + onDependsChange={onDependsChange} + onOrderChange={onOrderChange} showCorrectCheckbox={Boolean(block.groupId)} hasAlternatives={hasAlternatives} showAddAlternative={showAddAlternative} showDragHandle={showDragHandle} + grader={grader} + orderMode={orderMode} + allTags={allTags} dragHandleProps={showDragHandle ? { ref: handleRef, attributes, listeners } : undefined} />
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/index.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/index.ts index 3ebfdd56..643b3e70 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/index.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/index.ts @@ -6,3 +6,4 @@ export { ParsonsMonacoEditor } from "./ParsonsMonacoEditor"; export { ParsonsBlocksManager } from "./ParsonsBlocksManager"; export { SortableBlock } from "./SortableBlock"; export { BlockItem } from "./BlockItem"; +export { ParsonsOptions } from "./ParsonsOptions"; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/config/stepConfigs.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/config/stepConfigs.ts index ae610a02..71069268 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/config/stepConfigs.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/config/stepConfigs.ts @@ -279,6 +279,64 @@ export const PARSONS_STEP_VALIDATORS: StepValidator[] = [ } else if (data.blocks.some((block) => !block.content.trim())) { errors.push("All blocks must contain content"); } + + // DAG validation + if (data.grader === "dag" && data.blocks?.length) { + const nonDistractorBlocks = data.blocks.filter((b) => !b.isDistractor && !b.groupId); + const tags = nonDistractorBlocks.map((b) => b.tag).filter(Boolean) as string[]; + + // Check that all non-distractor blocks have tags + const missingTags = nonDistractorBlocks.filter((b) => !b.tag); + if (missingTags.length > 0) { + errors.push("All non-distractor blocks must have a tag when using DAG grader"); + } + + // Check for duplicate tags + const tagSet = new Set(); + for (const tag of tags) { + if (tagSet.has(tag)) { + errors.push(`Duplicate tag "${tag}" found. Each block must have a unique tag`); + break; + } + tagSet.add(tag); + } + + // Check depends reference existing tags + for (const block of nonDistractorBlocks) { + if (block.depends?.length) { + for (const dep of block.depends) { + if (!tagSet.has(dep)) { + errors.push(`Block "${block.tag}" depends on unknown tag "${dep}"`); + } + if (dep === block.tag) { + errors.push(`Block "${block.tag}" cannot depend on itself`); + } + } + } + } + } + + // Paired distractor validation + if (data.blocks?.length) { + for (let i = 0; i < data.blocks.length; i++) { + const block = data.blocks[i]; + if (block.pairedWithBlockAbove && block.isDistractor) { + if (i === 0) { + errors.push( + "A paired distractor cannot be the first block (nothing above to pair with)" + ); + } else { + const blockAbove = data.blocks[i - 1]; + if (blockAbove.isDistractor) { + errors.push( + `Paired distractor "${block.content.substring(0, 30)}..." must be placed directly after a solution block, not another distractor` + ); + } + } + } + } + } + return errors; }, // Settings diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/types/exercises.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/types/exercises.ts index 6db54b26..75f97080 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/types/exercises.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/types/exercises.ts @@ -110,6 +110,13 @@ export type QuestionJSON = Partial<{ parsonspersonalize: "solution-level" | "block-and-solution" | ""; parsonsexample: string; iframeSrc: string; + // Parsons problem options + adaptive: boolean; + numbered: "left" | "right" | "none"; + noindent: boolean; + grader: "line" | "dag"; + orderMode: "random" | "custom"; + customOrder: number[]; }>; export type CreateExerciseFormType = Omit & QuestionJSON; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/utils/htmlRegeneration.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/utils/htmlRegeneration.ts index c5a1410a..ada41a98 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/utils/htmlRegeneration.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/utils/htmlRegeneration.ts @@ -41,7 +41,12 @@ export const regenerateHtmlSrc = (exercise: Exercise, newName: string): string = return generateParsonsPreview({ instructions: questionJson.instructions || questionJson.questionText || "", blocks: questionJson.blocks || [], - name: newName + name: newName, + language: questionJson.language || "python", + adaptive: questionJson.adaptive ?? true, + numbered: questionJson.numbered ?? "left", + noindent: questionJson.noindent ?? false, + questionLabel: newName }); case "activecode": diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/utils/preview/parsonsPreview.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/utils/preview/parsonsPreview.tsx index b1ecaab3..0b62feee 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/utils/preview/parsonsPreview.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/utils/preview/parsonsPreview.tsx @@ -8,6 +8,11 @@ export interface ParsonsBlock { isPaired?: boolean; groupId?: string; isCorrect?: boolean; + tag?: string; + depends?: string[]; + comment?: string; + displayOrder?: number; + pairedWithBlockAbove?: boolean; } export interface ParsonsPreviewProps { @@ -19,6 +24,9 @@ export interface ParsonsPreviewProps { numbered?: "left" | "right" | "none"; noindent?: boolean; questionLabel?: string; + grader?: "line" | "dag"; + orderMode?: "random" | "custom"; + customOrder?: number[]; } export const generateParsonsPreview = ({ @@ -29,7 +37,10 @@ export const generateParsonsPreview = ({ adaptive = true, numbered = "left", noindent = false, - questionLabel + questionLabel, + grader = "line", + orderMode = "random", + customOrder }: ParsonsPreviewProps): string => { const safeId = sanitizeId(name); @@ -79,8 +90,16 @@ export const generateParsonsPreview = ({ .join("\n"); } + // Add DAG tag/depends annotations for non-distractor blocks + if (grader === "dag" && !block.isDistractor && block.tag) { + const dependsStr = block.depends?.length ? block.depends.join(",") : ""; + blockContent += ` #tag:${block.tag}; depends:${dependsStr};`; + } + if (block.isDistractor) { - blockContent += block.isPaired ? " #paired" : " #distractor"; + // isPaired is set automatically for grouped alternatives, + // pairedWithBlockAbove is set by the user for standalone distractors + blockContent += block.isPaired || block.pairedWithBlockAbove ? " #paired" : " #distractor"; } return blockContent; @@ -93,7 +112,7 @@ export const generateParsonsPreview = ({ dataAttributes += ` data-language="${language}"`; } - if (adaptive) { + if (adaptive && grader !== "dag") { dataAttributes += ' data-adaptive="true"'; } @@ -105,6 +124,45 @@ export const generateParsonsPreview = ({ dataAttributes += ' data-noindent="true"'; } + if (grader === "dag") { + dataAttributes += ' data-grader="dag"'; + } + + if (orderMode === "custom") { + // Build order from block displayOrder values + // First, compute the "logical block" indices (grouped blocks count as one) + const logicalBlocks: { displayOrder?: number; originalIndex: number }[] = []; + const seenGroupsForOrder = new Set(); + let logicalIdx = 0; + blocks.forEach((block) => { + if (block.groupId) { + if (!seenGroupsForOrder.has(block.groupId)) { + seenGroupsForOrder.add(block.groupId); + logicalBlocks.push({ displayOrder: block.displayOrder, originalIndex: logicalIdx }); + logicalIdx++; + } + } else { + logicalBlocks.push({ displayOrder: block.displayOrder, originalIndex: logicalIdx }); + logicalIdx++; + } + }); + + // If any blocks have displayOrder set, compute the data-order attribute + const hasCustomOrder = logicalBlocks.some((b) => b.displayOrder !== undefined); + if (hasCustomOrder) { + // Sort by displayOrder, keeping undefined at end + const sorted = [...logicalBlocks].sort((a, b) => { + const aOrd = a.displayOrder ?? 9999; + const bOrd = b.displayOrder ?? 9999; + return aOrd - bOrd; + }); + const orderArray = sorted.map((b) => b.originalIndex); + dataAttributes += ` data-order="${orderArray.join(",")}"`; + } + } else if (customOrder && customOrder.length > 0) { + dataAttributes += ` data-order="${customOrder.join(",")}"`; + } + return `
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/utils/questionJson.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/utils/questionJson.ts index c6101018..e0bee5e4 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/utils/questionJson.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/utils/questionJson.ts @@ -46,7 +46,10 @@ export const buildQuestionJson = (data: CreateExerciseFormType) => { ...(data.question_type === "parsonsprob" && { blocks: data.blocks, language: data.language, - instructions: data.instructions + instructions: data.instructions, + adaptive: data.adaptive, + numbered: data.numbered, + noindent: data.noindent }), ...(data.question_type === "fillintheblank" && { questionText: data.questionText, @@ -91,6 +94,10 @@ export const getDefaultQuestionJson = (languageOptions: TableDropdownOption[]) = correctAnswers: [["a", "x"]], feedback: "Incorrect. Please try again.", blocks: [{ id: `block-${Date.now()}`, content: "", indent: 0 }], + // Parsons problem options + adaptive: true, + numbered: "left" as "left" | "right" | "none", + noindent: false, // CodeTailor support enableCodeTailor: false, parsonspersonalize: "", @@ -116,6 +123,10 @@ export const mergeQuestionJsonWithDefaults = ( language: questionJson?.language ?? defaultQuestionJson.language, instructions: questionJson?.instructions ?? defaultQuestionJson.instructions, stdin: questionJson?.stdin ?? defaultQuestionJson.stdin, + // Parsons problem options + adaptive: questionJson?.adaptive ?? defaultQuestionJson.adaptive, + numbered: questionJson?.numbered ?? defaultQuestionJson.numbered, + noindent: questionJson?.noindent ?? defaultQuestionJson.noindent, // CodeTailor support enableCodeTailor: questionJson?.enableCodeTailor ?? defaultQuestionJson.enableCodeTailor, parsonspersonalize: From 9548d88a16eddbb66dc17592ace1c2d09c976ab8 Mon Sep 17 00:00:00 2001 From: andreimarozau Date: Thu, 12 Mar 2026 13:17:45 +0300 Subject: [PATCH 2/3] Refactor ParsonsExerciseTour component to utilize a configuration file for tour steps, improving maintainability and clarity in the tour logic Add tour functionality to ParsonsExercise components, enhancing user guidance with interactive steps and tooltips Enhance parsonsPreview to include explanations for distractors and paired blocks, improving clarity in block content representation. Refactor BlockItem component to replace comment functionality with explanation management, enhancing user interaction and clarity in the UI. Enhance BlockItem, ParsonsOptions, and ExerciseLayout components to support mode switching between simple and enhanced views with inline controls for distractor and alternative management Enhance BlockItem and SortableBlock components to support first-in-line logic for option visibility and interaction Refactor: simplify line numbering section in ParsonsOptions component and remove unnecessary styles from modernDropdown Remove paired change logic from BlockItem component on distractor toggle Enhance BlockItem component with a toggle switch for block type selection and improve paired checkbox visibility Enhance BlockItem component with options popover for improved interaction and compact action bar Refactor: update UI components and styles for improved layout and accessibility --- .../assignment_builder/package-lock.json | 7 + .../assignment_builder/package.json | 1 + .../ParsonsExercise/ParsonsExercise.tsx | 133 ++- .../ParsonsExercise/components/BlockItem.tsx | 482 ++++++---- .../components/ParsonsBlocksManager.tsx | 170 ++-- .../components/ParsonsExercise.module.css | 850 ++++++++++++++---- .../components/ParsonsExerciseTour.css | 134 +++ .../components/ParsonsExerciseTour.tsx | 249 +++++ .../components/ParsonsOptions.tsx | 211 +++-- .../components/SortableBlock.tsx | 17 +- .../ParsonsExercise/components/index.ts | 1 + .../components/parsonsTourConfig.ts | 141 +++ .../CreateExercise/shared/ExerciseLayout.tsx | 5 +- .../src/utils/preview/parsonsPreview.tsx | 35 +- .../runestone/parsons/css/parsons.css | 20 +- .../runestone/parsons/js/parsons.js | 53 ++ 16 files changed, 2004 insertions(+), 505 deletions(-) create mode 100644 bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/ParsonsExerciseTour.css create mode 100644 bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/ParsonsExerciseTour.tsx create mode 100644 bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/parsonsTourConfig.ts diff --git a/bases/rsptx/assignment_server_api/assignment_builder/package-lock.json b/bases/rsptx/assignment_server_api/assignment_builder/package-lock.json index 079638c6..0daf4442 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/package-lock.json +++ b/bases/rsptx/assignment_server_api/assignment_builder/package-lock.json @@ -41,6 +41,7 @@ "@tiptap/suggestion": "^2.11.5", "better-react-mathjax": "^2.1.0", "classnames": "^2.5.1", + "driver.js": "^1.4.0", "handsontable": "^14.2.0", "highlight.js": "^11.11.1", "html-react-parser": "^5.1.18", @@ -6134,6 +6135,12 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/driver.js": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.4.0.tgz", + "integrity": "sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew==", + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/bases/rsptx/assignment_server_api/assignment_builder/package.json b/bases/rsptx/assignment_server_api/assignment_builder/package.json index 023d2f40..1251dd5b 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/package.json +++ b/bases/rsptx/assignment_server_api/assignment_builder/package.json @@ -41,6 +41,7 @@ "@tiptap/suggestion": "^2.11.5", "better-react-mathjax": "^2.1.0", "classnames": "^2.5.1", + "driver.js": "^1.4.0", "handsontable": "^14.2.0", "highlight.js": "^11.11.1", "html-react-parser": "^5.1.18", diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/ParsonsExercise.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/ParsonsExercise.tsx index 53af62c4..1fcaeca2 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/ParsonsExercise.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/ParsonsExercise.tsx @@ -1,4 +1,7 @@ -import React, { FC, useCallback } from "react"; +import React, { FC, useCallback, useState } from "react"; + +import { SelectButton } from "primereact/selectbutton"; +import { confirmDialog, ConfirmDialog } from "primereact/confirmdialog"; import { CreateExerciseFormType } from "@/types/exercises"; import { createExerciseId } from "@/utils/exercise"; @@ -20,6 +23,15 @@ import { ParsonsBlocksManager, ParsonsOptions } from "./components"; +import { ParsonsExerciseTour } from "./components/ParsonsExerciseTour"; +import parsonsStyles from "./components/ParsonsExercise.module.css"; + +export type ParsonsMode = "simple" | "enhanced"; + +const MODE_OPTIONS = [ + { label: "Simple", value: "simple" }, + { label: "Enhanced", value: "enhanced" } +]; const PARSONS_STEPS = [ { label: "Language" }, @@ -170,6 +182,82 @@ export const ParsonsExercise: FC = ({ updateFormData("blocks", [...(formData.blocks || []), newBlock]); }, [updateFormData, formData.blocks]); + // --- Mode switcher --- + const isEnhancedExercise = + isEdit && + (initialData?.grader === "dag" || + initialData?.orderMode === "custom" || + initialData?.numbered === "right" || + initialData?.numbered === "none" || + initialData?.noindent === true); + + const [mode, setMode] = useState(isEnhancedExercise ? "enhanced" : "simple"); + + const directSetMode = useCallback((newMode: ParsonsMode) => { + setMode(newMode); + }, []); + + const tourButton = ( + void} + /> + ); + + const handleModeChange = useCallback( + (newMode: ParsonsMode) => { + if (newMode === mode || !newMode) return; + + if (newMode === "simple") { + // Enhanced → Simple: confirm and reset + confirmDialog({ + message: + "Switching to Simple Mode will reset Grader, Order, Line Numbers, and No Indent to their default values. Block dropdown settings (DAG tags, dependencies, custom order) will be cleared. Continue?", + header: "Switch to Simple Mode", + icon: "pi pi-exclamation-triangle", + acceptClassName: "p-button-warning", + accept: () => { + // Reset locked fields + updateFormData("grader", "line"); + updateFormData("orderMode", "random"); + updateFormData("numbered", "left"); + updateFormData("noindent", false); + updateFormData("adaptive", true); + updateFormData("customOrder", []); + // Clear DAG / order block fields + const clearedBlocks = (formData.blocks || []).map((block) => ({ + ...block, + tag: undefined, + depends: undefined, + displayOrder: undefined + })); + updateFormData("blocks", clearedBlocks); + setMode("simple"); + } + }); + } else { + // Simple → Enhanced: no data loss, just switch + setMode("enhanced"); + } + }, + [mode, updateFormData, formData.blocks] + ); + + const modeSwitcher = ( +
+ Mode + handleModeChange(e.value)} + className={parsonsStyles.modeSwitcherButton} + allowEmpty={false} + /> +
+ ); + const renderStepContent = () => { switch (activeStep) { case 0: @@ -197,6 +285,7 @@ export const ParsonsExercise: FC = ({ noindent={formData.noindent ?? false} grader={formData.grader ?? "line"} orderMode={formData.orderMode ?? "random"} + mode={mode} onAdaptiveChange={(value: boolean) => updateFormData("adaptive", value)} onNumberedChange={(value: "left" | "right" | "none") => updateFormData("numbered", value) @@ -218,6 +307,7 @@ export const ParsonsExercise: FC = ({ }} onOrderModeChange={(value: "random" | "custom") => updateFormData("orderMode", value)} onAddBlock={handleAddBlock} + tourButton={tourButton} /> = ({ language={formData.language || "python"} grader={formData.grader ?? "line"} orderMode={formData.orderMode ?? "random"} + mode={mode} />
); @@ -255,23 +346,27 @@ export const ParsonsExercise: FC = ({ }; return ( - - {renderStepContent()} - + <> + + + {renderStepContent()} + + ); }; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/BlockItem.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/BlockItem.tsx index a320ef7f..040a0222 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/BlockItem.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/BlockItem.tsx @@ -1,8 +1,10 @@ import { Editor } from "@components/routes/AssignmentBuilder/components/exercises/components/TipTap/Editor"; import { Button } from "primereact/button"; import { Checkbox, CheckboxChangeEvent } from "primereact/checkbox"; +import { Divider } from "primereact/divider"; import { InputText } from "primereact/inputtext"; import { InputTextarea } from "primereact/inputtextarea"; +import { OverlayPanel } from "primereact/overlaypanel"; import { Tooltip } from "primereact/tooltip"; import React, { FC, useRef, useCallback, useMemo, useState, useEffect, CSSProperties } from "react"; @@ -26,7 +28,7 @@ interface BlockItemProps { onSplitBlock?: (id: string, lineIndex: number) => void; onDistractorChange?: (id: string, isDistractor: boolean) => void; onPairedChange?: (id: string, paired: boolean) => void; - onCommentChange?: (id: string, comment: string) => void; + onExplanationChange?: (id: string, explanation: string) => void; onTagChange?: (id: string, tag: string) => void; onDependsChange?: (id: string, depends: string[]) => void; onOrderChange?: (id: string, order: number) => void; @@ -34,9 +36,11 @@ interface BlockItemProps { hasAlternatives?: boolean; showAddAlternative?: boolean; showDragHandle?: boolean; + isFirstInLine?: boolean; grader?: "line" | "dag"; orderMode?: "random" | "custom"; allTags?: string[]; + mode?: "simple" | "enhanced"; style?: React.CSSProperties; dragHandleProps?: { ref?: React.RefObject; @@ -64,7 +68,7 @@ export const BlockItem: FC = ({ onSplitBlock, onDistractorChange, onPairedChange, - onCommentChange, + onExplanationChange, onTagChange, onDependsChange, onOrderChange, @@ -72,9 +76,11 @@ export const BlockItem: FC = ({ hasAlternatives = false, showAddAlternative = true, showDragHandle = true, + isFirstInLine = false, grader = "line", orderMode = "random", allTags = [], + mode = "enhanced", style = {}, dragHandleProps }) => { @@ -83,12 +89,13 @@ export const BlockItem: FC = ({ const editorContainerRef = useRef(null); const dividerRef = useRef(null); const splitButtonContainerRef = useRef(null); + const optionsPanelRef = useRef(null); const [editorHeight, setEditorHeight] = useState(MIN_EDITOR_HEIGHT); const [hoveredLine, setHoveredLine] = useState(null); const [hoveredLineOffset, setHoveredLineOffset] = useState(0); const [cursorPosition, setCursorPosition] = useState<{ x: number; y: number } | null>(null); - const [showComment, setShowComment] = useState(!!block.comment); + const [showExplanation, setShowExplanation] = useState(!!block.explanation); useEffect(() => { return () => { @@ -130,6 +137,7 @@ export const BlockItem: FC = ({ (e: React.MouseEvent) => { e.stopPropagation(); hideAllTooltips(); + optionsPanelRef.current?.hide(); if (onAddAlternative) onAddAlternative(block.id); }, [block.id, onAddAlternative, hideAllTooltips] @@ -249,9 +257,28 @@ export const BlockItem: FC = ({ : styles.stripSolution; const isStandalone = !hasAlternatives; - const showDagFields = grader === "dag" && !block.isDistractor && isStandalone; - const showOrderField = orderMode === "custom" && isStandalone; - const showDistractorToggle = isStandalone && !!onDistractorChange; + const isFirstBlock = blockIndex === 0; + // Show full options for standalone blocks or the first block in a line (alternatives) + const showOptions = isStandalone || isFirstInLine; + const isSimple = mode === "simple"; + const showDagFields = grader === "dag" && !block.isDistractor && showOptions; + const showOrderField = orderMode === "custom" && showOptions; + const showDistractorToggle = showOptions && !!onDistractorChange; + + // Determine if there are any options to show in the popover (never in simple mode) + const hasPopoverOptions = !isSimple && ( + showDistractorToggle || + showDagFields || + showOrderField || + (onExplanationChange && showOptions) || + (onAddAlternative && showAddAlternative && showOptions) + ); + + // In simple mode, show inline controls for distractor, alternative, note + const showInlineDistractor = isSimple && showDistractorToggle; + const showInlineAlternative = isSimple && onAddAlternative && showAddAlternative && showOptions; + const showInlineExplanation = isSimple && onExplanationChange && showOptions; + const showInlinePaired = isSimple && onPairedChange && !isFirstBlock && block.isDistractor; // Render split line overlay const renderSplitOverlay = () => { @@ -330,9 +357,195 @@ export const BlockItem: FC = ({ ); }; + // Render the options popover content + const renderOptionsPanel = () => ( +
e.stopPropagation()}> + {/* Block type section — Toggle switch */} + {showDistractorToggle && ( +
+ Block Type +
+
{ + e.stopPropagation(); + const newIsDistractor = !block.isDistractor; + onDistractorChange!(block.id, newIsDistractor); + }} + onMouseDown={(e) => e.stopPropagation()} + role="switch" + aria-checked={block.isDistractor} + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + const newIsDistractor = !block.isDistractor; + onDistractorChange!(block.id, newIsDistractor); + } + }} + > + + + Solution + + + + Distractor + +
+
+
+ + {/* Paired checkbox — only visible when NOT the first block */} + {onPairedChange && !isFirstBlock && ( +
+ { + const paired = e.checked ?? false; + if (paired && !block.isDistractor) { + onDistractorChange!(block.id, true); + } + onPairedChange(block.id, paired); + }} + className={styles.modernCheckbox} + onMouseDown={(e) => e.stopPropagation()} + /> + +
+ )} +
+ )} + + {/* DAG fields section */} + {showDagFields && (onTagChange || onDependsChange) && ( + <> + {showDistractorToggle && } +
+ DAG Configuration + {onTagChange && ( +
+ + onTagChange(block.id, e.target.value)} + className={styles.optionsPanelInput} + onMouseDown={(e) => e.stopPropagation()} + placeholder={`${blockIndex ?? 0}`} + /> +
+ )} + {onDependsChange && ( +
+ + { + const deps = e.target.value + .split(",") + .map((d) => d.trim()) + .filter((d) => d !== ""); + onDependsChange(block.id, deps); + }} + className={styles.optionsPanelInput} + onMouseDown={(e) => e.stopPropagation()} + placeholder="e.g. 0, 1" + /> +
+ )} +
+ + )} + + {/* Order field section */} + {showOrderField && onOrderChange && ( + <> + {(showDistractorToggle || showDagFields) && ( + + )} +
+ Display Order +
+ + { + const val = parseInt(e.target.value); + onOrderChange(block.id, isNaN(val) ? 0 : val); + }} + className={styles.optionsPanelInput} + onMouseDown={(e) => e.stopPropagation()} + placeholder="Auto" + /> +
+
+ + )} + + {/* Actions section */} + {((onExplanationChange && showOptions) || (onAddAlternative && showAddAlternative && showOptions)) && ( + <> + {(showDistractorToggle || showDagFields || showOrderField) && ( + + )} +
+ {onAddAlternative && showAddAlternative && showOptions && ( + + )} + {onExplanationChange && showOptions && ( + + )} +
+ + )} +
+ ); + return (
= ({ ...style }} > - {/* Main row */} + {/* Main block row */}
{/* Color strip */}
@@ -356,19 +569,70 @@ export const BlockItem: FC = ({ {...dragHandleProps.attributes} {...dragHandleProps.listeners} className={styles.dragHandle} + title="Drag to reorder" + data-tour={blockIndex === 0 ? "drag-handle" : undefined} >
)} - {/* Block number */} + {/* Block number badge */} {blockIndex !== undefined && isStandalone && (
{blockIndex + 1}
)} + {/* Inline distractor toggle — Simple mode only */} + {showInlineDistractor && ( +
+ +
{ + e.stopPropagation(); + onDistractorChange!(block.id, !block.isDistractor); + }} + onMouseDown={(e) => e.stopPropagation()} + role="switch" + aria-checked={block.isDistractor} + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onDistractorChange!(block.id, !block.isDistractor); + } + }} + > + {block.isDistractor ? "D" : "S"} +
+ {/* Inline paired checkbox */} + {showInlinePaired && ( +
+ + { + const paired = e.checked ?? false; + if (paired && !block.isDistractor) { + onDistractorChange!(block.id, true); + } + onPairedChange!(block.id, paired); + }} + className={`${styles.modernCheckbox} inline-paired-check`} + data-pr-tooltip="Pair with block above" + onMouseDown={(e) => e.stopPropagation()} + style={{ width: "0.85rem", height: "0.85rem" }} + /> +
+ )} +
+ )} + {/* Correct checkbox for alternatives */} {showCorrectCheckbox && ( -
+
= ({
)} - {/* Editor */} + {/* Editor area */}
{renderEditor()}
- {/* Inline meta fields */} - {(showDistractorToggle || showDagFields || showOrderField) && ( -
- {/* Distractor toggle chip */} - {showDistractorToggle && ( - - )} - - {/* Paired toggle — only shown for distractor blocks */} - {showDistractorToggle && block.isDistractor && onPairedChange && ( - - )} - - {/* Order */} - {showOrderField && onOrderChange && ( -
- # - { - const val = parseInt(e.target.value); - onOrderChange(block.id, isNaN(val) ? 0 : val); - }} - className={styles.orderInlineInput} - onMouseDown={(e) => e.stopPropagation()} - placeholder="—" - /> -
- )} - - {/* DAG tag */} - {showDagFields && onTagChange && ( -
- tag - onTagChange(block.id, e.target.value)} - className={styles.tagInlineInput} - onMouseDown={(e) => e.stopPropagation()} - placeholder={`${blockIndex ?? 0}`} - /> -
- )} - - {/* DAG depends */} - {showDagFields && onDependsChange && ( -
- dep - - { - const deps = e.target.value - .split(",") - .map((d) => d.trim()) - .filter((d) => d !== ""); - onDependsChange(block.id, deps); - }} - className={`${styles.dependsInlineInput} depends-input-${block.id}`} - onMouseDown={(e) => e.stopPropagation()} - placeholder="0,1" - data-pr-tooltip={ - allTags.length > 0 - ? `Available: ${allTags.filter((t) => t !== block.tag).join(", ")}` - : undefined - } - /> -
- )} -
- )} - - {/* Action buttons */} -
- {/* Comment toggle */} - {onCommentChange && isStandalone && ( + className={`p-button-rounded p-button-text ${styles.actionBtn}`} + aria-label="Block options" + /> + + {renderOptionsPanel()} + + + )} + {/* Inline alternative button — Simple mode only */} + {showInlineAlternative && ( +
- {/* Comment row — appears below the main row when toggled */} - {showComment && onCommentChange && ( -
+ {/* Explanation row — appears below the main row when toggled */} + {showExplanation && onExplanationChange && ( +
+ onCommentChange(block.id, e.target.value)} - className={styles.commentInput} + value={block.explanation || ""} + onChange={(e) => onExplanationChange(block.id, e.target.value)} + className={styles.explanationInput} onMouseDown={(e) => e.stopPropagation()} - placeholder="Author note (not shown to students)..." + placeholder="Block explanation (shown to students after solving)..." autoResize rows={1} /> diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/ParsonsBlocksManager.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/ParsonsBlocksManager.tsx index 23df55aa..be3a6215 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/ParsonsBlocksManager.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/ParsonsBlocksManager.tsx @@ -1,27 +1,19 @@ import { DndContext, - closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent, DragStartEvent, - DragOverEvent, DragMoveEvent, DragOverlay, defaultDropAnimation, pointerWithin, - getFirstCollision, - rectIntersection, CollisionDetection, - UniqueIdentifier, - MeasuringStrategy, - Modifier, - DroppableContainer + MeasuringStrategy } from "@dnd-kit/core"; import { - arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy @@ -41,10 +33,6 @@ const INDENT_WIDTH = 39; const MAX_ALTERNATIVES = 3; -const BLOCK_WIDTH = 30; - -const ALTERNATIVE_BLOCK_WIDTH = 30; - const SNAP_THRESHOLD = 12; const customDropAnimation = { @@ -59,7 +47,7 @@ const customCollisionDetection: CollisionDetection = (args) => { return pointerCollisions; } - const { droppableContainers, droppableRects } = args; + const { droppableRects } = args; const containerRect = droppableRects.get("blocks-container"); @@ -89,6 +77,7 @@ interface ParsonsBlocksManagerProps { onAddBlock?: () => void; grader?: "line" | "dag"; orderMode?: "random" | "custom"; + mode?: "simple" | "enhanced"; } export const ParsonsBlocksManager: FC = ({ @@ -97,7 +86,8 @@ export const ParsonsBlocksManager: FC = ({ language, onAddBlock, grader = "line", - orderMode = "random" + orderMode = "random", + mode = "enhanced" }) => { const [activeId, setActiveId] = useState(null); const [highlightedGuide, setHighlightedGuide] = useState(null); @@ -175,7 +165,6 @@ export const ParsonsBlocksManager: FC = ({ useSensor(PointerSensor, { activationConstraint: { delay: 250, - distance: 5 } }), @@ -661,7 +650,6 @@ export const ParsonsBlocksManager: FC = ({ (id: string, isDistractor: boolean) => { const newBlocks = blocks.map((block) => { if (block.id === id) { - // Reset paired when turning off distractor return { ...block, isDistractor, @@ -688,11 +676,11 @@ export const ParsonsBlocksManager: FC = ({ [blocks, onChange] ); - const handleCommentChange = useCallback( - (id: string, comment: string) => { + const handleExplanationChange = useCallback( + (id: string, explanation: string) => { const newBlocks = blocks.map((block) => { if (block.id === id) { - return { ...block, comment }; + return { ...block, explanation }; } return block; }); @@ -744,7 +732,6 @@ export const ParsonsBlocksManager: FC = ({ return blocks.filter((b) => b.tag && !b.isDistractor).map((b) => b.tag as string); }, [blocks]); - // Compute a standalone block index (not counting grouped alternates) const blockIndexMap = useMemo(() => { const map: Record = {}; let idx = 0; @@ -774,18 +761,9 @@ export const ParsonsBlocksManager: FC = ({ return (
); @@ -794,7 +772,6 @@ export const ParsonsBlocksManager: FC = ({ const organizedBlocks = useMemo(() => { const result: ParsonsBlock[][] = []; - const processedIds = new Set(); blocks.forEach((block) => { @@ -814,18 +791,6 @@ export const ParsonsBlocksManager: FC = ({ return result; }, [blocks]); - const containerStyle = { - minHeight: "200px", - maxHeight: "600px", - position: "relative" as const, - padding: 0, - overflow: "auto", - border: "1px solid var(--surface-300)", - borderRadius: "4px", - background: "var(--surface-50)", - boxShadow: "0 1px 3px rgba(0,0,0,0.05)" - }; - const shouldShowDragHandle = useCallback( (blockId: string) => { const block = blocks.find((b) => b.id === blockId); @@ -844,31 +809,54 @@ export const ParsonsBlocksManager: FC = ({ [blocks] ); + // Count stats for the header + const solutionCount = blocks.filter((b) => !b.isDistractor).length; + const distractorCount = blocks.filter((b) => b.isDistractor).length; + const groupCount = Object.keys(blockGroups).length; + return ( -
-
-
-
- {renderIndentationGuides()} +
+ {/* Stats bar */} + {blocks.length > 0 && ( +
+
+ {blocks.length} + blocks +
+
+
+ + {solutionCount} + solution
+ {distractorCount > 0 && ( + <> +
+
+ + {distractorCount} + distractor +
+ + )} + {groupCount > 0 && ( + <> +
+
+ + {groupCount} + groups +
+ + )} +
+ )} + + {/* Blocks workspace */} +
+
+ {/* Indentation guides */} +
{renderIndentationGuides()}
= ({ } }} > -
+
block.id)} strategy={verticalListSortingStrategy} @@ -919,7 +896,7 @@ export const ParsonsBlocksManager: FC = ({ onSplitBlock={handleSplitBlock} onDistractorChange={handleDistractorChange} onPairedChange={handlePairedChange} - onCommentChange={handleCommentChange} + onExplanationChange={handleExplanationChange} onTagChange={handleTagChange} onDependsChange={handleDependsChange} onOrderChange={handleOrderChange} @@ -928,6 +905,8 @@ export const ParsonsBlocksManager: FC = ({ grader={grader} orderMode={orderMode} allTags={allTags} + mode={mode} + data-tour={groupIndex === 0 ? "first-block" : undefined} /> ); } @@ -970,12 +949,20 @@ export const ParsonsBlocksManager: FC = ({ onAddAlternative={handleAddAlternative} onCorrectChange={handleCorrectChange} onSplitBlock={handleSplitBlock} + onDistractorChange={isFirstInGroup ? handleDistractorChange : undefined} + onPairedChange={isFirstInGroup ? handlePairedChange : undefined} + onExplanationChange={isFirstInGroup ? handleExplanationChange : undefined} + onTagChange={isFirstInGroup ? handleTagChange : undefined} + onDependsChange={isFirstInGroup ? handleDependsChange : undefined} + onOrderChange={isFirstInGroup ? handleOrderChange : undefined} hasAlternatives={true} showAddAlternative={blockGroup.length < MAX_ALTERNATIVES} showDragHandle={isFirstInGroup} + isFirstInLine={isFirstInGroup} grader={grader} orderMode={orderMode} allTags={allTags} + mode={mode} /> ); })} @@ -1057,9 +1044,26 @@ export const ParsonsBlocksManager: FC = ({ + {/* Empty state */} {blocks.length === 0 && ( -
-

No code blocks yet. Click "Add Block" to create your first code block.

+
+
+ +
+

No code blocks yet

+

+ Click "Add Block" in the toolbar above to create your first code + block. +

+
)}
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/ParsonsExercise.module.css b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/ParsonsExercise.module.css index ad680619..170ab99c 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/ParsonsExercise.module.css +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/ParsonsExercise.module.css @@ -1,135 +1,370 @@ -/* ═══════════════════════════════════════════ - TOOLBAR - ═══════════════════════════════════════════ */ - -.toolbar { +.optionsBar { display: flex; - align-items: center; + align-items: stretch; justify-content: space-between; - gap: 0.5rem; - padding: 0.375rem 0.75rem; - background: #f8fafc; + gap: 0.75rem; + padding: 0.625rem 0.875rem; + background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); border: 1px solid #e2e8f0; - border-radius: 6px; + border-radius: 10px; margin: -2rem; margin-bottom: 0.5rem; flex-wrap: wrap; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); } -.toolbarLeft { +.optionsRow { display: flex; - align-items: center; - gap: 0.5rem; + align-items: stretch; + gap: 0.625rem; flex-wrap: wrap; + flex: 1; } -.toolbarGroup { +.optionSection { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; + animation: fadeSlideIn 0.2s ease; +} + +.optionSectionHeader { display: flex; align-items: center; - gap: 0.375rem; + gap: 0.25rem; } -.toolbarLabel { - font-size: 0.75rem; - font-weight: 600; +.optionSectionIcon { + font-size: 0.625rem; + color: #94a3b8; +} + +.optionSectionTitle { + font-size: 0.65rem; + font-weight: 700; color: #64748b; + text-transform: uppercase; + letter-spacing: 0.05em; white-space: nowrap; } -.toolbarDivider { +.optionDivider { width: 1px; - height: 20px; - background: #e2e8f0; + align-self: stretch; + background: linear-gradient(180deg, transparent 10%, #e2e8f0 50%, transparent 90%); flex-shrink: 0; + margin: 0 0.125rem; } -.toolbarCheckLabel { +/* Toggles */ +.togglesGroup { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.toggleItem { + display: flex; + align-items: center; + gap: 0.375rem; + transition: opacity 0.2s; +} + +.toggleItemDisabled { + opacity: 0.45; +} + +.toggleLabel { font-size: 0.8rem; color: #475569; cursor: pointer; white-space: nowrap; margin: 0; user-select: none; + font-weight: 500; } -.toolbarCheckLabel.disabled { +.toggleLabelDisabled { color: #94a3b8; cursor: default; } -/* Tiny PrimeReact overrides */ -.tinySelectButton :global(.p-button) { - padding: 0.2rem 0.5rem !important; +/* PrimeReact overrides for options bar */ +.modernSelectButton :global(.p-button) { + padding: 0.25rem 0.625rem !important; font-size: 0.75rem !important; - font-weight: 500 !important; + font-weight: 600 !important; + border-radius: 6px !important; + transition: all 0.15s ease !important; } -.tinyDropdown { - height: 1.75rem !important; - min-height: 1.75rem !important; - width: 5.5rem !important; +.modernSelectButton :global(.p-highlight) { + box-shadow: 0 1px 3px rgba(59, 130, 246, 0.25) !important; } -.tinyDropdown :global(.p-dropdown-label) { +.modernDropdown { + width: 6rem !important; + border-radius: 6px !important; + position: relative !important; +} + +.modernDropdown :global(.p-dropdown-label) { padding: 0.2rem 0.5rem !important; font-size: 0.75rem !important; + font-weight: 500 !important; } -.tinyDropdown :global(.p-dropdown-trigger) { +.modernDropdown :global(.p-dropdown-trigger) { width: 1.75rem !important; } -.tinyCheckbox { +.modernDropdown :global(.p-dropdown-panel) { + z-index: 1100 !important; +} + +.modernCheckbox { width: 1rem !important; height: 1rem !important; } -.addBlockBtn { +/* Options actions (right side) */ +.optionsActions { + display: flex; + align-items: center; + flex-shrink: 0; + gap: 0.5rem; +} + +.addBlockButton { font-size: 0.8rem !important; - padding: 0.3rem 0.625rem !important; + padding: 0.375rem 0.75rem !important; white-space: nowrap; + border-radius: 8px !important; + font-weight: 600 !important; + transition: all 0.2s ease !important; + gap: 0.375rem !important; +} + +.addBlockButton:hover { + transform: translateY(-1px) !important; + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.2) !important; +} + +.managerContainer { + display: flex; + flex-direction: column; + gap: 0; + margin: -2rem; + margin-top: 0; +} + +/* Stats bar */ +.statsBar { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.75rem; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-bottom: none; + border-radius: 8px 8px 0 0; +} + +.statItem { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.statCount { + font-size: 0.75rem; + font-weight: 700; + color: #334155; +} + +.statLabel { + font-size: 0.7rem; + color: #94a3b8; + font-weight: 500; +} + +.statDivider { + width: 1px; + height: 14px; + background: #e2e8f0; + flex-shrink: 0; +} + +.statDot { + width: 6px; + height: 6px; + border-radius: 50%; flex-shrink: 0; } -/* ═══════════════════════════════════════════ - BLOCK ITEM — single compact row - ═══════════════════════════════════════════ */ +.statDotSolution { + background: #22c55e; +} + +.statDotDistractor { + background: #ef4444; +} + +.statDotGroup { + background: #3b82f6; +} + +/* Workspace */ +.workspace { + width: 100%; + position: relative; +} + +.blocksOuter { + min-height: 200px; + max-height: 600px; + position: relative; + padding: 0; + overflow: auto; + border: 1px solid #e2e8f0; + border-radius: 0 0 8px 8px; + background: linear-gradient(180deg, #fafbfc 0%, #f8fafc 100%); + box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.03); +} + +.managerContainer > .workspace:first-child .blocksOuter { + border-radius: 8px; +} + +.indentGuidesContainer { + position: absolute; + top: 0; + left: 0; + bottom: 0; + width: 100%; + z-index: 1; + pointer-events: none; +} + +.indentGuide { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background-color: #e9ecef; + z-index: 0; + transition: background-color 0.2s, width 0.2s, box-shadow 0.2s; + height: 100%; + pointer-events: none; +} + +.indentGuideHighlighted { + width: 2px; + background-color: var(--primary-color, #3b82f6); + z-index: 2; + box-shadow: 0 0 6px rgba(59, 130, 246, 0.3); +} + +/* Blocks inner container */ +.blocksInner { + min-height: 150px; + position: relative; + z-index: 2; + min-width: max-content; + width: 100%; + overflow-x: auto; +} + +/* Empty state */ +.emptyState { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2.5rem 1.5rem; + text-align: center; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} + +.emptyStateIcon { + width: 48px; + height: 48px; + border-radius: 12px; + background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 0.75rem; +} + +.emptyStateIcon i { + font-size: 1.25rem; + color: #3b82f6; +} + +.emptyStateTitle { + font-size: 0.9rem; + font-weight: 600; + color: #475569; + margin: 0 0 0.25rem 0; +} + +.emptyStateDesc { + font-size: 0.8rem; + color: #94a3b8; + margin: 0 0 1rem 0; + max-width: 280px; +} + +.emptyStateBtn { + font-size: 0.8rem !important; + border-radius: 8px !important; +} .blockRow { display: flex; align-items: stretch; border: 1px solid #e2e8f0; - border-radius: 5px; - overflow: hidden; - background: #fff; - transition: box-shadow 0.15s; + border-radius: 6px; + background: #ffffff; + transition: box-shadow 0.2s ease, border-color 0.2s ease; position: relative; } .blockRow:hover { - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); + border-color: #cbd5e1; +} + +.blockRowDistractor { + background: linear-gradient(135deg, #fff5f5 0%, #fef2f2 100%); } -/* Color strip on the left */ .blockStrip { width: 4px; flex-shrink: 0; + border-radius: 6px 0 0 6px; } .stripSolution { - background: #22c55e; + background: linear-gradient(180deg, #22c55e 0%, #16a34a 100%); } .stripDistractor { - background: #ef4444; + background: linear-gradient(180deg, #ef4444 0%, #dc2626 100%); } .stripPaired { - background: #8b5cf6; + background: linear-gradient(180deg, #8b5cf6 0%, #7c3aed 100%); } .stripAlternative { - background: #3b82f6; + background: linear-gradient(180deg, #3b82f6 0%, #2563eb 100%); } /* Drag handle */ @@ -137,32 +372,48 @@ display: flex; align-items: center; justify-content: center; - width: 24px; + width: 26px; flex-shrink: 0; cursor: grab; - color: #94a3b8; + color: #cbd5e1; font-size: 0.75rem; + transition: color 0.15s, background 0.15s; + border-right: 1px solid #f1f5f9; } .dragHandle:hover { color: #64748b; + background: #f8fafc; } -/* Block number pill */ +.dragHandle:active { + cursor: grabbing; +} + +/* Block number badge */ .blockNum { display: flex; align-items: center; justify-content: center; - min-width: 18px; - height: 18px; - border-radius: 3px; - background: #f1f5f9; - color: #94a3b8; + min-width: 20px; + height: 20px; + border-radius: 4px; + background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%); + color: #64748b; font-size: 0.65rem; font-weight: 700; flex-shrink: 0; - margin: 0 2px; + margin: 0 3px; align-self: center; + font-variant-numeric: tabular-nums; +} + +/* Correct checkbox wrapper */ +.correctCheckboxWrapper { + display: flex; + align-items: center; + padding: 0 4px; + flex-shrink: 0; } /* Editor area */ @@ -172,172 +423,369 @@ padding: 2px 0; } -/* Inline meta controls (right side, inside the row) */ -.inlineMeta { +/* Action buttons — compact bar on the right edge */ +.blockActions { display: flex; align-items: center; - gap: 0.25rem; - padding: 0 0.25rem; + gap: 1px; + padding: 0 3px; flex-shrink: 0; + border-left: 1px solid #f1f5f9; + position: relative; +} + +.actionBtn { + width: 24px !important; + height: 24px !important; + padding: 0 !important; + color: #94a3b8 !important; + transition: all 0.15s ease !important; +} + +.actionBtn :global(.p-button-icon) { + font-size: 0.7rem !important; +} + +.actionBtn:hover { + color: #64748b !important; + background: #f1f5f9 !important; +} + +.actionBtnDanger:hover { + color: #ef4444 !important; + background: #fef2f2 !important; } -.inlineField { +.optionsPanelOverlay { + border-radius: 12px !important; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.12), 0 4px 12px rgba(0, 0, 0, 0.06) !important; + border: 1px solid #e2e8f0 !important; + backdrop-filter: blur(8px); +} + +.optionsPanelOverlay :global(.p-overlaypanel-content) { + padding: 0 !important; +} + +.optionsPanel { + width: 240px; + max-height: 400px; + overflow-y: auto; +} + +/* Section inside popover */ +.optionsPanelSection { + padding: 0.625rem 0.75rem; display: flex; - align-items: center; - gap: 2px; - font-size: 0.7rem; + flex-direction: column; + gap: 0.5rem; } -.inlineFieldLabel { +.optionsPanelSectionTitle { font-size: 0.65rem; - font-weight: 600; + font-weight: 700; color: #94a3b8; - white-space: nowrap; text-transform: uppercase; - letter-spacing: 0.03em; + letter-spacing: 0.06em; } -.inlineInput { - height: 1.375rem !important; - padding: 0 0.25rem !important; - font-size: 0.75rem !important; - border-radius: 3px !important; - border: 1px solid #e2e8f0 !important; - background: #f8fafc !important; +.optionsPanelDivider { + margin: 0 !important; + padding: 0 !important; } -.inlineInput:focus { - border-color: #93c5fd !important; - box-shadow: none !important; - background: #fff !important; +.optionsPanelDivider::before { + border-color: #f1f5f9 !important; } -.orderInlineInput { - composes: inlineInput; - width: 2.25rem !important; - text-align: center !important; +/* Block type toggle switch */ +.blockTypeToggleWrapper { + width: 100%; } -.tagInlineInput { - composes: inlineInput; - width: 3.5rem !important; +.blockTypeToggle { + position: relative; + display: grid; + grid-template-columns: 1fr 1fr; + background: #f1f5f9; + border-radius: 10px; + padding: 3px; + cursor: pointer; + user-select: none; + border: 1px solid #e2e8f0; + transition: border-color 0.2s ease; + overflow: hidden; } -.dependsInlineInput { - composes: inlineInput; - width: 5rem !important; +.blockTypeToggle:hover { + border-color: #cbd5e1; } -/* Distractor chip — tiny toggle */ -.distractorChip { - font-size: 0.65rem !important; - padding: 0.1rem 0.35rem !important; - border-radius: 3px !important; - height: 1.375rem !important; - border: 1px solid #e2e8f0 !important; - cursor: pointer; - transition: all 0.15s; - white-space: nowrap; +.blockTypeToggle:focus-visible { + outline: 2px solid #3b82f6; + outline-offset: 2px; +} + +.blockTypeToggleLabel { + position: relative; + z-index: 2; display: flex; align-items: center; - gap: 2px; - user-select: none; - background: none; + justify-content: center; + gap: 0.3rem; + padding: 0.4rem 0.5rem; + font-size: 0.7rem; + font-weight: 600; + color: #94a3b8; + border-radius: 8px; + transition: color 0.25s ease; + white-space: nowrap; line-height: 1; } -.distractorChip.isDistractor { - background: #fef2f2; - border-color: #fca5a5 !important; - color: #dc2626; +.blockTypeToggleLabel i { + font-size: 0.8rem; + transition: color 0.25s ease; } -.distractorChip.isSolution { - background: #f0fdf4; - border-color: #86efac !important; - color: #16a34a; +.blockTypeToggleLabelActive { + color: #fff; } -/* Paired chip — shown next to distractor chip */ -.pairedChip { - font-size: 0.65rem !important; - padding: 0.1rem 0.35rem !important; - border-radius: 3px !important; - height: 1.375rem !important; - border: 1px solid #e2e8f0 !important; - cursor: pointer; - transition: all 0.15s; - white-space: nowrap; +.blockTypeToggleSlider { + position: absolute; + top: 3px; + left: 3px; + width: calc(50% - 3px); + height: calc(100% - 6px); + border-radius: 8px; + background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%); + box-shadow: 0 2px 8px rgba(34, 197, 94, 0.3); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 1; +} + +.blockTypeToggleSliderRight { + left: calc(50%); + background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); + box-shadow: 0 2px 8px rgba(239, 68, 68, 0.3); +} + +/* Paired checkbox row */ +.pairedCheckboxRow { display: flex; align-items: center; - gap: 2px; + gap: 0.5rem; + padding: 0.375rem 0.25rem 0; + margin-top: 0.125rem; + animation: fadeSlideIn 0.2s ease; +} + +.pairedCheckboxLabel { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.75rem; + font-weight: 500; + color: #64748b; + cursor: pointer; user-select: none; - background: none; - line-height: 1; + margin: 0; + transition: color 0.15s ease; } -.pairedChip.isPaired { - background: #eff6ff; - border-color: #93c5fd !important; - color: #2563eb; +.pairedCheckboxLabel:hover { + color: #475569; } -.pairedChip.isUnpaired { - background: #f8fafc; - border-color: #e2e8f0 !important; +.pairedCheckboxLabel i { + font-size: 0.7rem; + color: #8b5cf6; +} + +@keyframes fadeSlideIn { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Form fields inside popover */ +.optionsPanelField { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.optionsPanelLabel { + font-size: 0.75rem; + font-weight: 500; + color: #475569; +} + +.optionsPanelHint { + font-size: 0.65rem; + font-weight: 400; color: #94a3b8; + margin-left: 0.25rem; } -/* Actions */ -.blockActions { +.optionsPanelInput { + height: 2rem !important; + padding: 0.25rem 0.5rem !important; + font-size: 0.8rem !important; + border-radius: 6px !important; + border: 1px solid #e2e8f0 !important; + background: #f8fafc !important; + width: 100% !important; + transition: border-color 0.15s, background 0.15s, box-shadow 0.15s !important; +} + +.optionsPanelInput:focus { + border-color: #93c5fd !important; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1) !important; + background: #fff !important; +} + +/* Action rows inside popover */ +.optionsPanelAction { display: flex; align-items: center; - gap: 1px; - padding: 0 2px; + gap: 0.5rem; + width: 100%; + padding: 0.5rem 0.375rem; + border: none; + border-radius: 6px; + background: transparent; + cursor: pointer; + font-size: 0.8rem; + font-weight: 500; + color: #475569; + transition: all 0.15s ease; + text-align: left; +} + +.optionsPanelAction i { + font-size: 0.85rem; + color: #94a3b8; + width: 1.25rem; + text-align: center; flex-shrink: 0; } -.tinyBtn { - width: 22px !important; - height: 22px !important; - padding: 0 !important; +.optionsPanelAction:hover { + background: #f1f5f9; + color: #1e293b; } -.tinyBtn :global(.p-button-icon) { - font-size: 0.7rem !important; +.optionsPanelAction:hover i { + color: #64748b; } -/* Comment row — collapsed by default, minimal height when open */ -.commentRow { - padding: 0.125rem 0.5rem 0.25rem 2rem; - background: #fffbeb; - border-top: 1px dashed #fde68a; +.optionsPanelBadge { + margin-left: auto; + font-size: 0.6rem; + font-weight: 700; + background: #f59e0b; + color: #fff; + width: 16px; + height: 16px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; } -.commentInput { +/* Explanation row */ +.explanationRow { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.25rem 0.5rem 0.375rem 0.75rem; + background: linear-gradient(135deg, #fffbeb 0%, #fef9c3 100%); + border: 1px solid #fde68a; + border-top: 1px dashed #fcd34d; + border-radius: 0 0 6px 6px; +} + +.explanationIcon { + font-size: 0.7rem; + color: #f59e0b; + margin-top: 0.35rem; + flex-shrink: 0; +} + +.explanationInput { width: 100%; font-size: 0.75rem !important; padding: 0.2rem 0.375rem !important; min-height: 1.5rem !important; resize: none; border: 1px solid #fde68a !important; - border-radius: 3px !important; - background: #fff !important; + border-radius: 4px !important; + background: #fffef7 !important; + transition: border-color 0.15s !important; } -.commentInput:focus { +.explanationInput:focus { border-color: #f59e0b !important; - box-shadow: none !important; + box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.1) !important; } -/* Block distractor row background */ -.blockRowDistractor { - background: #fef8f8; +.toolbar { + composes: optionsBar; } -/* ═══════════════════════════════════════════ - LEGACY — kept for compatibility - ═══════════════════════════════════════════ */ +.toolbarLeft { + composes: optionsRow; +} + +.toolbarGroup { + composes: optionSection; + flex-direction: row; +} + +.toolbarLabel { + composes: optionSectionTitle; +} + +.toolbarDivider { + composes: optionDivider; +} + +.toolbarCheckLabel { + composes: toggleLabel; +} + +.toolbarCheckLabel.disabled { + color: #94a3b8; + cursor: default; +} + +.tinySelectButton { + composes: modernSelectButton; +} + +.tinyDropdown { + composes: modernDropdown; +} + +.tinyCheckbox { + composes: modernCheckbox; +} + +.addBlockBtn { + composes: addBlockButton; +} + +.tinyBtn { + composes: actionBtn; +} .addButton { font-size: 0.875rem; @@ -385,4 +833,90 @@ width: 2rem !important; } +.inlineDistractorArea { + display: flex; + align-items: center; + gap: 4px; + padding: 0 3px; + flex-shrink: 0; + align-self: center; +} + +.inlineDistractorPill { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 6px; + font-size: 0.65rem; + font-weight: 700; + cursor: pointer; + user-select: none; + transition: all 0.2s ease; + background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%); + color: #16a34a; + border: 1px solid #a7f3d0; + line-height: 1; +} + +.inlineDistractorPill:hover { + transform: scale(1.1); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); +} + +.inlineDistractorPillActive { + background: linear-gradient(135deg, #fef2f2 0%, #fecaca 100%); + color: #dc2626; + border-color: #fca5a5; +} + +.inlineDistractorPillActive:hover { + box-shadow: 0 2px 6px rgba(239, 68, 68, 0.2); +} + +/* Inline paired wrapper */ +.inlinePairedWrapper { + display: flex; + align-items: center; + flex-shrink: 0; +} + +.actionBtnHighlight { + color: #f59e0b !important; +} + +.actionBtnHighlight:hover { + color: #d97706 !important; + background: #fffbeb !important; +} + +.modeSwitcher { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; + margin-right: 0.75rem; +} + +.modeSwitcherLabel { + font-size: 0.7rem; + font-weight: 600; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; +} + +.modeSwitcherButton :global(.p-button) { + padding: 0.2rem 0.5rem !important; + font-size: 0.7rem !important; + font-weight: 600 !important; + border-radius: 6px !important; + transition: all 0.15s ease !important; +} + +.modeSwitcherButton :global(.p-highlight) { + box-shadow: 0 1px 3px rgba(59, 130, 246, 0.25) !important; +} diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/ParsonsExerciseTour.css b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/ParsonsExerciseTour.css new file mode 100644 index 00000000..248863db --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/ParsonsExerciseTour.css @@ -0,0 +1,134 @@ +.parsons-tour-popover { + --driver-popover-bg: #ffffff; + --driver-popover-border: 1px solid #e2e8f0; + --driver-popover-shadow: 0 8px 30px rgba(0, 0, 0, 0.12), 0 2px 8px rgba(0, 0, 0, 0.06); + + background: var(--driver-popover-bg) !important; + border: var(--driver-popover-border) !important; + border-radius: 12px !important; + box-shadow: var(--driver-popover-shadow) !important; + max-width: 380px !important; + padding: 0 !important; + overflow: hidden; +} + +.parsons-tour-popover .driver-popover-title { + font-size: 0.95rem !important; + font-weight: 600 !important; + color: #1e293b !important; + padding: 0.875rem 1rem 0.25rem !important; + margin: 0 !important; + line-height: 1.3 !important; +} + +.parsons-tour-popover .driver-popover-description { + font-size: 0.835rem !important; + color: #475569 !important; + line-height: 1.55 !important; + padding: 0.25rem 1rem 0.75rem !important; + margin: 0 !important; +} + +.parsons-tour-popover .driver-popover-description strong { + color: #1e293b; + font-weight: 600; +} + +.parsons-tour-popover .driver-popover-footer { + display: flex !important; + align-items: center !important; + justify-content: space-between !important; + padding: 0.5rem 1rem !important; + border-top: 1px solid #f1f5f9 !important; + background: #fafbfc !important; + gap: 0.5rem !important; +} + +.parsons-tour-popover .driver-popover-progress-text { + font-size: 0.72rem !important; + color: #94a3b8 !important; + font-weight: 500 !important; + letter-spacing: 0.02em !important; +} + +.parsons-tour-popover .driver-popover-navigation-btns { + display: flex !important; + gap: 0.375rem !important; +} + +.parsons-tour-popover .driver-popover-prev-btn, +.parsons-tour-popover .driver-popover-next-btn { + font-size: 0.78rem !important; + font-weight: 500 !important; + padding: 0.35rem 0.85rem !important; + border-radius: 6px !important; + border: 1px solid #e2e8f0 !important; + background: #ffffff !important; + color: #334155 !important; + cursor: pointer !important; + transition: all 0.15s ease !important; + text-shadow: none !important; + line-height: 1.3 !important; +} + +.parsons-tour-popover .driver-popover-prev-btn:hover, +.parsons-tour-popover .driver-popover-next-btn:hover { + background: #f1f5f9 !important; + border-color: #cbd5e1 !important; +} + +.parsons-tour-popover .driver-popover-next-btn { + background: #6366f1 !important; + color: #ffffff !important; + border-color: #6366f1 !important; +} + +.parsons-tour-popover .driver-popover-next-btn:hover { + background: #4f46e5 !important; + border-color: #4f46e5 !important; +} + +.parsons-tour-popover .driver-popover-close-btn { + position: absolute !important; + top: 0.5rem !important; + right: 0.5rem !important; + width: 24px !important; + height: 24px !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + font-size: 1rem !important; + color: #94a3b8 !important; + background: transparent !important; + border: none !important; + border-radius: 4px !important; + cursor: pointer !important; + transition: all 0.15s ease !important; +} + +.parsons-tour-popover .driver-popover-close-btn:hover { + color: #64748b !important; + background: #f1f5f9 !important; +} + +.parsons-tour-popover .driver-popover-arrow { + border: none !important; +} + +/* Arrow side-specific overrides */ +.parsons-tour-popover .driver-popover-arrow-side-top { + border-bottom-color: #ffffff !important; +} + +.parsons-tour-popover .driver-popover-arrow-side-bottom { + border-top-color: #ffffff !important; +} + +.parsons-tour-popover .driver-popover-arrow-side-left { + border-right-color: #ffffff !important; +} + +.parsons-tour-popover .driver-popover-arrow-side-right { + border-left-color: #ffffff !important; +} + diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/ParsonsExerciseTour.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/ParsonsExerciseTour.tsx new file mode 100644 index 00000000..8b960e9e --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/ParsonsExerciseTour.tsx @@ -0,0 +1,249 @@ +import { driver, DriveStep, Driver } from "driver.js"; +import "driver.js/dist/driver.css"; +import { Button } from "primereact/button"; +import React, { FC, useCallback, useEffect, useRef } from "react"; + +import { ParsonsBlock } from "@/utils/preview/parsonsPreview"; + +import { ParsonsMode } from "../ParsonsExercise"; + +import styles from "./ParsonsExercise.module.css"; +import "./ParsonsExerciseTour.css"; +import { PARSONS_TOUR_STEPS } from "./parsonsTourConfig"; + +interface ParsonsFormSnapshot { + mode: ParsonsMode; + grader: "line" | "dag"; + orderMode: "random" | "custom"; + numbered: "left" | "right" | "none"; + noindent: boolean; + adaptive: boolean; + blocks: ParsonsBlock[]; +} + +export interface ParsonsExerciseTourProps { + mode: ParsonsMode; + formData: { + blocks?: ParsonsBlock[]; + grader?: "line" | "dag"; + orderMode?: "random" | "custom"; + numbered?: "left" | "right" | "none"; + noindent?: boolean; + adaptive?: boolean; + }; + onModeChange: (mode: ParsonsMode) => void; + updateFormData: (key: string, value: any) => void; +} + +const waitForElement = (selector: string, timeout = 1500): Promise => + new Promise((resolve) => { + const el = document.querySelector(selector); + if (el) return resolve(el); + + const start = Date.now(); + const interval = setInterval(() => { + const found = document.querySelector(selector); + if (found || Date.now() - start > timeout) { + clearInterval(interval); + resolve(found); + } + }, 50); + }); + +const nextTick = (): Promise => + new Promise((resolve) => requestAnimationFrame(() => setTimeout(resolve, 0))); + +const DEMO_BLOCK: ParsonsBlock = { + id: "tour-demo-1", + content: "print('Hello')\nprint('World')", + indent: 0 +}; + +export const ParsonsExerciseTour: FC = ({ + mode, + formData, + onModeChange, + updateFormData +}) => { + const driverRef = useRef(null); + const snapshotRef = useRef(null); + const isTouringRef = useRef(false); + + const takeSnapshot = useCallback( + (): ParsonsFormSnapshot => ({ + mode, + grader: formData.grader ?? "line", + orderMode: formData.orderMode ?? "random", + numbered: formData.numbered ?? "left", + noindent: formData.noindent ?? false, + adaptive: formData.adaptive ?? true, + blocks: JSON.parse(JSON.stringify(formData.blocks ?? [])) + }), + [mode, formData] + ); + + const restoreSnapshot = useCallback(() => { + const snap = snapshotRef.current; + if (!snap) return; + + onModeChange(snap.mode); + updateFormData("grader", snap.grader); + updateFormData("orderMode", snap.orderMode); + updateFormData("numbered", snap.numbered); + updateFormData("noindent", snap.noindent); + updateFormData("adaptive", snap.adaptive); + updateFormData("blocks", snap.blocks); + + snapshotRef.current = null; + isTouringRef.current = false; + + document + .querySelectorAll(".p-overlaypanel, .p-dropdown-panel") + .forEach((el) => ((el as HTMLElement).style.display = "none")); + }, [onModeChange, updateFormData]); + + const ensureBlockExists = useCallback(async () => { + const blocks = formData.blocks ?? []; + if (blocks.length === 0) { + updateFormData("blocks", [{ ...DEMO_BLOCK }]); + await nextTick(); + } + }, [formData.blocks, updateFormData]); + + const ensureDemoContent = useCallback(async () => { + const blocks = formData.blocks ?? []; + if (blocks.length === 0) { + updateFormData("blocks", [{ ...DEMO_BLOCK }]); + await nextTick(); + } else { + const first = blocks[0]; + if (first.content.split("\n").length < 2) { + const updated = [...blocks]; + updated[0] = { ...first, content: "print('Hello')\nprint('World')" }; + updateFormData("blocks", updated); + await nextTick(); + } + } + }, [formData.blocks, updateFormData]); + + const stepBehaviors: Record Promise> = { + /* 1 — Simple Mode */ + 0: async () => { + if (mode !== "simple") { + onModeChange("simple"); + await nextTick(); + } + }, + /* 2 — Enhanced Mode */ + 1: async () => { + onModeChange("enhanced"); + await nextTick(); + }, + /* 3 — Grader */ + 2: async () => { + await nextTick(); + }, + /* 4 — Ordering */ + 3: async () => {}, + /* 5 — Line Numbers */ + 4: async () => {}, + /* 6 — Toggles */ + 5: async () => {}, + /* 7 — No Indent */ + 6: async () => {}, + /* 8 — Add Block */ + 7: async () => {}, + /* 9 — Delete Block */ + 8: async () => { + await ensureBlockExists(); + await nextTick(); + await waitForElement('[data-tour="first-block"] [aria-label="Remove block"]'); + }, + /* 10 — Drag Handle */ + 9: async () => { + await ensureBlockExists(); + await nextTick(); + await waitForElement('[data-tour="drag-handle"]'); + }, + /* 11 — Split Block */ + 10: async () => { + await ensureDemoContent(); + await nextTick(); + await waitForElement('[data-tour="first-block"]'); + }, + /* 12 — Solution & Distractor */ + 11: async () => { + await ensureBlockExists(); + await nextTick(); + await waitForElement('[data-tour="distractor-pill"]'); + }, + /* 13 — Block Options */ + 12: async () => { + await ensureBlockExists(); + await nextTick(); + await waitForElement('[data-tour="first-block"] [aria-label="Block options"]'); + } + }; + + const buildSteps = useCallback((): DriveStep[] => { + return PARSONS_TOUR_STEPS.map((cfg, idx) => ({ + element: cfg.element, + popover: { + title: cfg.title, + description: cfg.description, + side: cfg.side, + align: cfg.align + }, + ...(stepBehaviors[idx] && { onHighlightStarted: stepBehaviors[idx] }) + })); + }, [mode, formData, onModeChange, updateFormData, ensureBlockExists, ensureDemoContent]); + + const startTour = useCallback(() => { + snapshotRef.current = takeSnapshot(); + isTouringRef.current = true; + + const steps = buildSteps(); + + const driverObj = driver({ + showProgress: true, + animate: true, + overlayColor: "rgba(0, 0, 0, 0.55)", + stagePadding: 8, + stageRadius: 10, + allowClose: true, + popoverClass: "parsons-tour-popover", + onDestroyed: () => { + restoreSnapshot(); + driverRef.current = null; + }, + steps + }); + + driverRef.current = driverObj; + driverObj.drive(); + }, [takeSnapshot, buildSteps, restoreSnapshot]); + + useEffect(() => { + return () => { + if (driverRef.current) { + driverRef.current.destroy(); + } + if (isTouringRef.current) { + restoreSnapshot(); + } + }; + }, [restoreSnapshot]); + + return ( +
); }; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/SortableBlock.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/SortableBlock.tsx index 21770d3a..28fab025 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/SortableBlock.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/SortableBlock.tsx @@ -21,16 +21,19 @@ interface SortableBlockProps { onSplitBlock?: (id: string, lineIndex: number) => void; onDistractorChange?: (id: string, isDistractor: boolean) => void; onPairedChange?: (id: string, paired: boolean) => void; - onCommentChange?: (id: string, comment: string) => void; + onExplanationChange?: (id: string, explanation: string) => void; onTagChange?: (id: string, tag: string) => void; onDependsChange?: (id: string, depends: string[]) => void; onOrderChange?: (id: string, order: number) => void; hasAlternatives?: boolean; showAddAlternative?: boolean; showDragHandle?: boolean; + isFirstInLine?: boolean; grader?: "line" | "dag"; orderMode?: "random" | "custom"; allTags?: string[]; + mode?: "simple" | "enhanced"; + "data-tour"?: string; } export const SortableBlock: FC = ({ @@ -48,16 +51,19 @@ export const SortableBlock: FC = ({ onSplitBlock, onDistractorChange, onPairedChange, - onCommentChange, + onExplanationChange, onTagChange, onDependsChange, onOrderChange, hasAlternatives, showAddAlternative = true, showDragHandle = true, + isFirstInLine = false, grader = "line", orderMode = "random", - allTags = [] + allTags = [], + mode = "enhanced", + "data-tour": dataTour }) => { const handleRef = useRef(null); @@ -96,6 +102,7 @@ export const SortableBlock: FC = ({ data-indent={block.indent} data-group-id={block.groupId || ""} data-id={id} + data-tour={dataTour} > = ({ onSplitBlock={onSplitBlock} onDistractorChange={onDistractorChange} onPairedChange={onPairedChange} - onCommentChange={onCommentChange} + onExplanationChange={onExplanationChange} onTagChange={onTagChange} onDependsChange={onDependsChange} onOrderChange={onOrderChange} @@ -120,9 +127,11 @@ export const SortableBlock: FC = ({ hasAlternatives={hasAlternatives} showAddAlternative={showAddAlternative} showDragHandle={showDragHandle} + isFirstInLine={isFirstInLine} grader={grader} orderMode={orderMode} allTags={allTags} + mode={mode} dragHandleProps={showDragHandle ? { ref: handleRef, attributes, listeners } : undefined} />
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/index.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/index.ts index 643b3e70..e4863097 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/index.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/index.ts @@ -7,3 +7,4 @@ export { ParsonsBlocksManager } from "./ParsonsBlocksManager"; export { SortableBlock } from "./SortableBlock"; export { BlockItem } from "./BlockItem"; export { ParsonsOptions } from "./ParsonsOptions"; +export { ParsonsExerciseTour } from "./ParsonsExerciseTour"; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/parsonsTourConfig.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/parsonsTourConfig.ts new file mode 100644 index 00000000..941a246c --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ParsonsExercise/components/parsonsTourConfig.ts @@ -0,0 +1,141 @@ +import { Side, Alignment } from "driver.js"; + +export interface TourStepConfig { + element: string; + title: string; + description: string; + side: Side; + align: Alignment; +} + +export const PARSONS_TOUR_STEPS: TourStepConfig[] = [ + /* 1 — Simple Mode */ + { + element: '[data-tour="mode-switcher"]', + title: "Simple Mode", + description: + "Simple Mode provides a streamlined interface with the most common options. Blocks show inline distractor toggles, quick alternative, and explanation buttons.", + side: "bottom", + align: "start" + }, + + /* 2 — Enhanced Mode */ + { + element: '[data-tour="mode-switcher"]', + title: "Enhanced Mode", + description: + "Enhanced Mode unlocks the full power of the Parsons constructor: Grader selection, custom ordering, line-number placement, and no-indent control. Block options are accessed via a popover menu.", + side: "bottom", + align: "start" + }, + + /* 3 — Grader */ + { + element: '[data-tour="grader-section"]', + title: "Grader", + description: + "Choose how student answers are evaluated. Line-based checks exact line order. DAG (Directed Acyclic Graph) allows multiple valid orderings by defining dependencies between blocks.", + side: "bottom", + align: "start" + }, + + /* 4 — Ordering */ + { + element: '[data-tour="order-section"]', + title: "Block Ordering", + description: + "Control how blocks are presented to students. Random shuffles blocks each attempt. Custom lets you set a specific display position for each block.", + side: "bottom", + align: "start" + }, + + /* 5 — Line Numbers */ + { + element: '[data-tour="line-numbers-section"]', + title: "Line Numbers", + description: + "Use this dropdown to display line numbers on the Left, Right, or hide them entirely (None). Line numbers help students reference specific lines in their solution.", + side: "bottom", + align: "start" + }, + + /* 6 — Toggles (Adaptive + No-indent) */ + { + element: '[data-tour="toggles-section"]', + title: "Toggles", + description: + "Adaptive enables progressive feedback — students receive incremental hints.", + side: "bottom", + align: "start" + }, + + /* 7 — No Indent */ + { + element: '[data-tour="toggles-section"] label[for="noindent-opt"]', + title: "No Indent", + description: + "When enabled, students cannot indent blocks. This is useful for exercises where indentation is not part of the solution (e.g., natural-language ordering).", + side: "top", + align: "start" + }, + + /* 8 — Add Block */ + { + element: '[data-tour="add-block-btn"]', + title: "Add Block", + description: + "Click this button to append a new empty code block to the workspace. Each block represents one logical line or group of lines in the exercise.", + side: "bottom", + align: "end" + }, + + /* 9 — Delete Block */ + { + element: '[data-tour="first-block"] [aria-label="Remove block"]', + title: "Delete Block", + description: + "Click the trash icon to remove a block. If the block is part of an alternative group with only two members, the group is dissolved automatically.", + side: "left", + align: "start" + }, + + /* 10 — Drag Handle */ + { + element: '[data-tour="drag-handle"]', + title: "Move Blocks", + description: + "Press and hold the drag handle (≡) to reorder blocks via drag-and-drop. Dragging horizontally adjusts indentation level. Visual guides highlight the target indent column.", + side: "right", + align: "start" + }, + + /* 11 — Split Block */ + { + element: '[data-tour="first-block"]', + title: "Split a Block", + description: + "Hover over a multi-line block to reveal a split indicator between lines. Click the + button on the divider to split the block into two separate blocks at that line.", + side: "bottom", + align: "start" + }, + + /* 12 — Solution & Distractor */ + { + element: '[data-tour="distractor-pill"]', + title: "Solution & Distractor", + description: + "Toggle a block between Solution and Distractor. Solution blocks form the correct answer. Distractor blocks are decoys that should not appear in the final solution.", + side: "left", + align: "start" + }, + + /* 13 — Block Options Button (Enhanced mode) */ + { + element: '[data-tour="first-block"] [aria-label="Block options"]', + title: "Block Options", + description: + "In Enhanced Mode, click the button to open a dropdown with advanced block settings: Block Type (Solution / Distractor), Paired toggle, DAG Configuration (Tag & DependsOn), Display Order, Add Alternative, and Add Explanation.", + side: "left", + align: "start" + } +]; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/shared/ExerciseLayout.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/shared/ExerciseLayout.tsx index 306f0db8..d46d5274 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/shared/ExerciseLayout.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/shared/ExerciseLayout.tsx @@ -37,6 +37,7 @@ interface ExerciseLayoutProps { onStepSelect: (index: number) => void; children: ReactNode; validation?: ValidationState; + headerExtra?: ReactNode; } export const ExerciseLayout = ({ @@ -54,7 +55,8 @@ export const ExerciseLayout = ({ onSave, onStepSelect, children, - validation + validation, + headerExtra }: ExerciseLayoutProps) => { const containerRef = useRef(null); const { isFullscreen, toggleFullscreen, exitFullscreen, isSupported } = @@ -91,6 +93,7 @@ export const ExerciseLayout = ({

{isEdit ? `Edit ${title}` : `Create ${title}`}

+ {headerExtra}
{isSupported && (