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 62a43036..6ddb6ab2 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", @@ -6191,6 +6192,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 bb9983de..86a33267 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 26b2e037..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"; @@ -14,7 +17,21 @@ 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"; +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" }, @@ -39,7 +56,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 +71,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 +87,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 +173,91 @@ 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]); + + // --- 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: @@ -164,11 +278,46 @@ 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} + tourButton={tourButton} + /> + updateFormData("blocks", blocks)} + language={formData.language || "python"} + grader={formData.grader ?? "line"} + orderMode={formData.orderMode ?? "random"} + mode={mode} + /> +
); case 3: @@ -181,10 +330,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} /> ); @@ -194,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/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..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,13 +1,17 @@ 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"; import { ParsonsBlock } from "@/utils/preview/parsonsPreview"; import { ParsonsCodeHighlighter } from "./ParsonsCodeHighlighter"; +import styles from "./ParsonsExercise.module.css"; interface BlockItemProps { block: ParsonsBlock; @@ -16,15 +20,27 @@ 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; + onExplanationChange?: (id: string, explanation: 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; + isFirstInLine?: boolean; + grader?: "line" | "dag"; + orderMode?: "random" | "custom"; + allTags?: string[]; + mode?: "simple" | "enhanced"; style?: React.CSSProperties; dragHandleProps?: { ref?: React.RefObject; @@ -34,9 +50,7 @@ interface BlockItemProps { } const LINE_HEIGHT = 18; - const MIN_EDITOR_HEIGHT = 36; - const EDITOR_PADDING = 8; export const BlockItem: FC = ({ @@ -46,15 +60,27 @@ export const BlockItem: FC = ({ indentWidth, maxIndent, blockWidth, + blockIndex, onContentChange, onRemove, onAddAlternative, onCorrectChange, onSplitBlock, + onDistractorChange, + onPairedChange, + onExplanationChange, + onTagChange, + onDependsChange, + onOrderChange, showCorrectCheckbox = false, hasAlternatives = false, showAddAlternative = true, showDragHandle = true, + isFirstInLine = false, + grader = "line", + orderMode = "random", + allTags = [], + mode = "enhanced", style = {}, dragHandleProps }) => { @@ -63,27 +89,23 @@ 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 [showExplanation, setShowExplanation] = useState(!!block.explanation); 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 +116,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 +130,22 @@ 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); - } + optionsPanelRef.current?.hide(); + 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 +159,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 +169,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 +189,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 +210,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 +233,494 @@ 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 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 = () => { + if (hoveredLine === null) return null; + return ( +
+
+
+
+ ); + }; + + // Render the code editor + const renderEditor = () => { + if (isTextEditor) { + return ( +
+ + {renderSplitOverlay()} +
+ ); + } + if (language && language !== "") { + return ( +
+ + {renderSplitOverlay()} +
+ ); + } + return ( + + ); + }; + + // 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 (
- {showDragHandle && dragHandleProps && ( -
- -
- )} - + {/* Main block row */}
+ {/* Color strip */} +
+ + {/* Drag handle */} + {showDragHandle && dragHandleProps && ( +
+ +
+ )} + + {/* 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 && ( -
+
e.stopPropagation()} data-pr-tooltip="Correct answer" + style={{ width: "1rem", height: "1rem" }} />
)} -
- {isTextEditor ? ( -
- - {hoveredLine !== null && ( -
-
-
-
- )} -
- ) : language && language !== "" ? ( -
- - {hoveredLine !== null && ( -
-
-
-
- )} -
- ) : ( - - )} -
+ {/* Editor area */} +
{renderEditor()}
-
- {onAddAlternative && showAddAlternative && ( + {/* Compact action bar — replaces old inlineMeta + blockActions */} +
+ {/* Options menu trigger — Enhanced mode only */} + {hasPopoverOptions && ( <> -
+ + {/* Explanation row — appears below the main row when toggled */} + {showExplanation && onExplanationChange && ( +
+ + onExplanationChange(block.id, e.target.value)} + className={styles.explanationInput} + onMouseDown={(e) => e.stopPropagation()} + 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 8a01b2cb..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"); @@ -86,12 +74,20 @@ interface ParsonsBlocksManagerProps { blocks: ParsonsBlock[]; onChange: (blocks: ParsonsBlock[]) => void; language: string; + onAddBlock?: () => void; + grader?: "line" | "dag"; + orderMode?: "random" | "custom"; + mode?: "simple" | "enhanced"; } export const ParsonsBlocksManager: FC = ({ blocks, onChange, - language + language, + onAddBlock, + grader = "line", + orderMode = "random", + mode = "enhanced" }) => { const [activeId, setActiveId] = useState(null); const [highlightedGuide, setHighlightedGuide] = useState(null); @@ -169,7 +165,6 @@ export const ParsonsBlocksManager: FC = ({ useSensor(PointerSensor, { activationConstraint: { delay: 250, - distance: 5 } }), @@ -452,14 +447,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 +646,114 @@ export const ParsonsBlocksManager: FC = ({ [blocks, onChange] ); + const handleDistractorChange = useCallback( + (id: string, isDistractor: boolean) => { + const newBlocks = blocks.map((block) => { + if (block.id === id) { + 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 handleExplanationChange = useCallback( + (id: string, explanation: string) => { + const newBlocks = blocks.map((block) => { + if (block.id === id) { + return { ...block, explanation }; + } + 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]); + + 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; @@ -654,18 +761,9 @@ export const ParsonsBlocksManager: FC = ({ return (
); @@ -674,7 +772,6 @@ export const ParsonsBlocksManager: FC = ({ const organizedBlocks = useMemo(() => { const result: ParsonsBlock[][] = []; - const processedIds = new Set(); blocks.forEach((block) => { @@ -694,18 +791,6 @@ export const ParsonsBlocksManager: FC = ({ return result; }, [blocks]); - const containerStyle = { - minHeight: "300px", - maxHeight: "500px", - 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); @@ -724,43 +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 ( -
-
-
-
)}
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..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,3 +1,792 @@ +.optionsBar { + display: flex; + align-items: stretch; + justify-content: space-between; + gap: 0.75rem; + padding: 0.625rem 0.875rem; + background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); + border: 1px solid #e2e8f0; + border-radius: 10px; + margin: -2rem; + margin-bottom: 0.5rem; + flex-wrap: wrap; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); +} + +.optionsRow { + display: flex; + align-items: stretch; + gap: 0.625rem; + flex-wrap: wrap; + flex: 1; +} + +.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.25rem; +} + +.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; +} + +.optionDivider { + width: 1px; + align-self: stretch; + background: linear-gradient(180deg, transparent 10%, #e2e8f0 50%, transparent 90%); + flex-shrink: 0; + margin: 0 0.125rem; +} + +/* 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; +} + +.toggleLabelDisabled { + color: #94a3b8; + cursor: default; +} + +/* PrimeReact overrides for options bar */ +.modernSelectButton :global(.p-button) { + padding: 0.25rem 0.625rem !important; + font-size: 0.75rem !important; + font-weight: 600 !important; + border-radius: 6px !important; + transition: all 0.15s ease !important; +} + +.modernSelectButton :global(.p-highlight) { + box-shadow: 0 1px 3px rgba(59, 130, 246, 0.25) !important; +} + +.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; +} + +.modernDropdown :global(.p-dropdown-trigger) { + width: 1.75rem !important; +} + +.modernDropdown :global(.p-dropdown-panel) { + z-index: 1100 !important; +} + +.modernCheckbox { + width: 1rem !important; + height: 1rem !important; +} + +/* Options actions (right side) */ +.optionsActions { + display: flex; + align-items: center; + flex-shrink: 0; + gap: 0.5rem; +} + +.addBlockButton { + font-size: 0.8rem !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; +} + +.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: 6px; + background: #ffffff; + transition: box-shadow 0.2s ease, border-color 0.2s ease; + position: relative; +} + +.blockRow:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); + border-color: #cbd5e1; +} + +.blockRowDistractor { + background: linear-gradient(135deg, #fff5f5 0%, #fef2f2 100%); +} + +.blockStrip { + width: 4px; + flex-shrink: 0; + border-radius: 6px 0 0 6px; +} + +.stripSolution { + background: linear-gradient(180deg, #22c55e 0%, #16a34a 100%); +} + +.stripDistractor { + background: linear-gradient(180deg, #ef4444 0%, #dc2626 100%); +} + +.stripPaired { + background: linear-gradient(180deg, #8b5cf6 0%, #7c3aed 100%); +} + +.stripAlternative { + background: linear-gradient(180deg, #3b82f6 0%, #2563eb 100%); +} + +/* Drag handle */ +.dragHandle { + display: flex; + align-items: center; + justify-content: center; + width: 26px; + flex-shrink: 0; + cursor: grab; + color: #cbd5e1; + font-size: 0.75rem; + transition: color 0.15s, background 0.15s; + border-right: 1px solid #f1f5f9; +} + +.dragHandle:hover { + color: #64748b; + background: #f8fafc; +} + +.dragHandle:active { + cursor: grabbing; +} + +/* Block number badge */ +.blockNum { + display: flex; + align-items: center; + justify-content: center; + 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 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 */ +.blockEditor { + flex: 1; + min-width: 0; + padding: 2px 0; +} + +/* Action buttons — compact bar on the right edge */ +.blockActions { + display: flex; + align-items: center; + 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; +} + +.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; + flex-direction: column; + gap: 0.5rem; +} + +.optionsPanelSectionTitle { + font-size: 0.65rem; + font-weight: 700; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.optionsPanelDivider { + margin: 0 !important; + padding: 0 !important; +} + +.optionsPanelDivider::before { + border-color: #f1f5f9 !important; +} + +/* Block type toggle switch */ +.blockTypeToggleWrapper { + width: 100%; +} + +.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; +} + +.blockTypeToggle:hover { + border-color: #cbd5e1; +} + +.blockTypeToggle:focus-visible { + outline: 2px solid #3b82f6; + outline-offset: 2px; +} + +.blockTypeToggleLabel { + position: relative; + z-index: 2; + display: flex; + align-items: center; + 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; +} + +.blockTypeToggleLabel i { + font-size: 0.8rem; + transition: color 0.25s ease; +} + +.blockTypeToggleLabelActive { + color: #fff; +} + +.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: 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; + margin: 0; + transition: color 0.15s ease; +} + +.pairedCheckboxLabel:hover { + color: #475569; +} + +.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; +} + +.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: 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; +} + +.optionsPanelAction:hover { + background: #f1f5f9; + color: #1e293b; +} + +.optionsPanelAction:hover i { + color: #64748b; +} + +.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; +} + +/* 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: 4px !important; + background: #fffef7 !important; + transition: border-color 0.15s !important; +} + +.explanationInput:focus { + border-color: #f59e0b !important; + box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.1) !important; +} + +.toolbar { + composes: optionsBar; +} + +.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; padding: 0.4rem 0.75rem; @@ -16,21 +805,118 @@ background: #f0f7ff; border-color: #3b82f6; color: #2563eb; - transform: translateY(-1px); } -.addButton:focus { - box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2); +.optionsHeader { + flex-shrink: 0; + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.25rem 0.25rem 0 0; } -.addButton i { - font-size: 0.75rem; +.compactDropdown { + height: 2rem !important; + min-height: 2rem !important; } -.optionsHeader { +.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; +} + +.inlineDistractorArea { + display: flex; + align-items: center; + gap: 4px; + padding: 0 3px; flex-shrink: 0; + align-self: center; +} + +.inlineDistractorPill { display: flex; - justify-content: space-between; align-items: center; - padding: 0.25rem 0.25rem 0 0; -} \ No newline at end of file + 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..d7095708 --- /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,254 @@ +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"]'); + }, + /* 14 — Fullscreen */ + 13: async () => { + await nextTick(); + await waitForElement('[data-tour="fullscreen-btn"]'); + } + }; + + 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 551679be..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 @@ -13,14 +13,27 @@ 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; + 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 = ({ @@ -30,14 +43,27 @@ export const SortableBlock: FC = ({ indentWidth, maxIndent, blockWidth, + blockIndex, onContentChange, onRemove, onAddAlternative, onCorrectChange, onSplitBlock, + onDistractorChange, + onPairedChange, + onExplanationChange, + onTagChange, + onDependsChange, + onOrderChange, hasAlternatives, showAddAlternative = true, - showDragHandle = true + showDragHandle = true, + isFirstInLine = false, + grader = "line", + orderMode = "random", + allTags = [], + mode = "enhanced", + "data-tour": dataTour }) => { const handleRef = useRef(null); @@ -76,6 +102,7 @@ export const SortableBlock: FC = ({ data-indent={block.indent} data-group-id={block.groupId || ""} data-id={id} + data-tour={dataTour} > = ({ indentWidth={indentWidth} maxIndent={maxIndent} blockWidth={100} + blockIndex={blockIndex} onContentChange={onContentChange} onRemove={onRemove} onAddAlternative={onAddAlternative} onCorrectChange={onCorrectChange} onSplitBlock={onSplitBlock} + onDistractorChange={onDistractorChange} + onPairedChange={onPairedChange} + onExplanationChange={onExplanationChange} + onTagChange={onTagChange} + onDependsChange={onDependsChange} + onOrderChange={onOrderChange} showCorrectCheckbox={Boolean(block.groupId)} 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 3ebfdd56..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 @@ -6,3 +6,5 @@ export { ParsonsMonacoEditor } from "./ParsonsMonacoEditor"; 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..91545c01 --- /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,151 @@ +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" + }, + + /* 14 — Fullscreen */ + { + element: '[data-tour="fullscreen-btn"]', + title: "Fullscreen Mode", + description: + "Click this button to enter Fullscreen mode. It gives you more screen space to work with blocks comfortably, especially for larger exercises.", + side: "bottom", + align: "end" + } +]; 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/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..392ccf66 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 && (