diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 00000000..25df048b
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,7 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(npm install:*)"
+ ]
+ }
+}
diff --git a/app/javascript/components/ui/CreateQuestionForm/StimulusCaseStudy.jsx b/app/javascript/components/ui/CreateQuestionForm/StimulusCaseStudy.jsx
index 46114ea5..bac0b9a8 100644
--- a/app/javascript/components/ui/CreateQuestionForm/StimulusCaseStudy.jsx
+++ b/app/javascript/components/ui/CreateQuestionForm/StimulusCaseStudy.jsx
@@ -1,7 +1,23 @@
import React, {
useState, useEffect, useCallback, useMemo, useRef
} from 'react'
-import { Button } from 'react-bootstrap'
+import { Button, Modal, ListGroup } from 'react-bootstrap'
+import {
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors
+} from '@dnd-kit/core'
+import {
+ arrayMove,
+ SortableContext,
+ sortableKeyboardCoordinates,
+ useSortable,
+ verticalListSortingStrategy
+} from '@dnd-kit/sortable'
+import { CSS } from '@dnd-kit/utilities'
import { SUBQUESTION_TYPE_NAMES } from '../../../constants/questionTypes'
import Scenario from './Scenario'
import Bowtie from './Bowtie'
@@ -15,6 +31,115 @@ import Upload from './Upload'
import QuestionTypeDropdown from './QuestionTypeDropdown'
import QuestionText from './QuestionText'
+// Sortable subquestion item component
+const SortableSubQuestionItem = ({ subQuestion, index, onEdit, onRemove }) => {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging
+ } = useSortable({ id: subQuestion.id })
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.5 : 1
+ }
+
+ return (
+
+
+
+ ☰
+
+
+
#{index + 1} - {subQuestion.type || 'No Type Selected'}
+ {subQuestion.text && (
+
+ {subQuestion.text.substring(0, 50)}
+ {subQuestion.text.length > 50 ? '...' : ''}
+
+ )}
+
+
+
+
+
+
+
+ )
+}
+
+// Modal for editing individual subquestions
+const SubQuestionEditModal = ({
+ show,
+ onHide,
+ subQuestion,
+ onChange,
+ onSave,
+ COMPONENT_MAP,
+ resetFields
+}) => {
+ if (!subQuestion) return null
+
+ const QuestionComponent = COMPONENT_MAP[subQuestion.type] || null
+
+ return (
+
+
+ Edit Subquestion - {subQuestion.type}
+
+
+ onChange('type', type)}
+ QUESTION_TYPE_NAMES={SUBQUESTION_TYPE_NAMES}
+ />
+ {QuestionComponent && (
+ onChange('text', e.target.value)}
+ onDataChange={(data) => onChange('data', data)}
+ resetFields={resetFields}
+ data={subQuestion.data}
+ questionType={subQuestion.type}
+ />
+ )}
+
+
+
+
+
+
+ )
+}
+
const StimulusCaseStudy = ({
handleTextChange,
onDataChange,
@@ -29,20 +154,32 @@ const StimulusCaseStudy = ({
data.subQuestions.length > 0
? data.subQuestions.map((sq, idx) => ({
...sq,
- id: sq.id || Date.now() + idx
+ id: sq.id || Date.now() + idx,
+ type: sq.type_name || sq.type || ''
}))
: []
)
+ const [editingSubQuestion, setEditingSubQuestion] = useState(null)
+ const [tempSubQuestion, setTempSubQuestion] = useState(null)
+ const [showEditModal, setShowEditModal] = useState(false)
const updateTimeout = useRef(null)
+ // Drag and drop sensors
+ const sensors = useSensors(
+ useSensor(PointerSensor),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates
+ })
+ )
+
const COMPONENT_MAP = useMemo(
() => ({
- Scenario: Scenario,
+ 'Scenario': Scenario,
'Bow Tie': Bowtie,
- Categorization: Categorization,
+ 'Categorization': Categorization,
'Drag and Drop': DragAndDrop,
- Essay: Essay,
- Matching: Matching,
+ 'Essay': Essay,
+ 'Matching': Matching,
'Multiple Choice': MultipleChoice,
'Select All That Apply': SelectAllThatApply,
'File Upload': Upload
@@ -50,6 +187,7 @@ const StimulusCaseStudy = ({
[]
)
+ // Initialize data for different question types
const initializeDataForType = useCallback((type) => {
switch (type) {
case 'Scenario':
@@ -70,7 +208,7 @@ const StimulusCaseStudy = ({
return [{ answer: '', correct: [] }]
case 'Essay':
case 'File Upload':
- return { html: '
' }
+ return ''
default:
return null
}
@@ -90,58 +228,71 @@ const StimulusCaseStudy = ({
data: sq.data
}))
})
- }, 300)
+ }, 50)
},
[questionText, onDataChange]
)
- const handleSubQuestionTypeSelection = useCallback(
- (id, type) => {
- setSubQuestions((prev) => {
- const updated = prev.map((sq) =>
- sq.id === id ? { ...sq, type, data: initializeDataForType(type) } : sq
- )
- updateParent(updated)
- return updated
- })
- },
- [initializeDataForType, updateParent]
- )
+ // Handle drag end event
+ const handleDragEnd = useCallback((event) => {
+ const { active, over } = event
- const handleSubQuestionChange = useCallback(
- (id, key, value) => {
+ if (over && active.id !== over.id) {
setSubQuestions((prev) => {
- const updated = prev.map((sq) => {
- if (sq.id === id) {
- const updatedSq = { ...sq, [key]: value }
- if (
- (sq.type === 'Essay' || sq.type === 'File Upload') &&
- key === 'text'
- ) {
- updatedSq.data = {
- html: value
- .split('\n')
- .map((line, index) => `${line}
`)
- .join('')
- }
- }
- return updatedSq
- }
- return sq
- })
+ const oldIndex = prev.findIndex((sq) => sq.id === active.id)
+ const newIndex = prev.findIndex((sq) => sq.id === over.id)
+ const updated = arrayMove(prev, oldIndex, newIndex)
updateParent(updated)
return updated
})
- },
- [updateParent]
- )
+ }
+ }, [updateParent])
+
+ // Open edit modal for a subquestion
+ const handleEditSubQuestion = useCallback((subQuestion) => {
+ setEditingSubQuestion(subQuestion.id)
+ setTempSubQuestion({ ...subQuestion })
+ setShowEditModal(true)
+ }, [])
+
+ // Handle changes within the edit modal
+ const handleModalChange = useCallback((key, value) => {
+ setTempSubQuestion((prev) => {
+ const updated = { ...prev, [key]: value }
+
+ // If type is changed, initialize new data structure
+ if (key === 'type') {
+ updated.data = initializeDataForType(value)
+ }
+
+ return updated
+ })
+ }, [initializeDataForType])
+
+ // Save changes from edit modal
+ const handleSaveModal = useCallback(() => {
+ setSubQuestions((prev) => {
+ const updated = prev.map((sq) =>
+ sq.id === editingSubQuestion ? { ...tempSubQuestion } : sq
+ )
+ updateParent(updated)
+ return updated
+ })
+ setShowEditModal(false)
+ setEditingSubQuestion(null)
+ setTempSubQuestion(null)
+ }, [editingSubQuestion, tempSubQuestion, updateParent])
+
+ // Close modal without saving
+ const handleCloseModal = useCallback(() => {
+ setShowEditModal(false)
+ setEditingSubQuestion(null)
+ setTempSubQuestion(null)
+ }, [])
const addSubQuestion = useCallback(() => {
setSubQuestions((prev) => {
- const updated = [
- ...prev,
- { id: Date.now(), type: '', text: '', data: null }
- ]
+ const updated = [...prev, { id: Date.now(), type: '', text: '', data: null }]
updateParent(updated)
return updated
})
@@ -170,56 +321,65 @@ const StimulusCaseStudy = ({
return (
{questionType} Question
+
+ {/* Main question text */}
-
Subquestions
- {subQuestions.map((sq) => {
- const QuestionComponent = COMPONENT_MAP[sq.type] || null
- return (
-
-
- handleSubQuestionTypeSelection(sq.id, type)
- }
- QUESTION_TYPE_NAMES={SUBQUESTION_TYPE_NAMES}
- />
- {QuestionComponent && (
-
- handleSubQuestionChange(sq.id, 'text', e.target.value)
- }
- onDataChange={(data) =>
- handleSubQuestionChange(sq.id, 'data', data)
- }
- resetFields={resetFields}
- data={sq.data}
- />
- )}
-
-
-
+ {/* Subquestions section */}
+
+
Subquestions
+
+ {subQuestions.length === 0 ? (
+
+ No subquestions added yet. Click "Add Subquestion" to create one.
- )
- })}
-
-
+ ) : (
+
+ sq.id)}
+ strategy={verticalListSortingStrategy}
+ >
+
+ {subQuestions.map((sq, index) => (
+
+ ))}
+
+
+
+ )}
+
+
+
+
+ {/* Edit subquestion modal */}
+
)
}
diff --git a/app/javascript/components/ui/CreateQuestionForm/index.jsx b/app/javascript/components/ui/CreateQuestionForm/index.jsx
index a0d5c412..350357b5 100644
--- a/app/javascript/components/ui/CreateQuestionForm/index.jsx
+++ b/app/javascript/components/ui/CreateQuestionForm/index.jsx
@@ -42,6 +42,14 @@ const CreateQuestionForm = ({ subjectOptions, question, onSuccess, onCancel }) =
return question.data?.html || ''
}
+ // For Stimulus Case Study, wrap the array in subQuestions property
+ if (question.type === 'Question::StimulusCaseStudy') {
+ return {
+ text: question.text || '',
+ subQuestions: Array.isArray(question.data) ? question.data : []
+ }
+ }
+
return question.data || { text: '', subQuestions: [] }
})
const [resetFields, setResetFields] = useState(false)
@@ -61,7 +69,18 @@ const CreateQuestionForm = ({ subjectOptions, question, onSuccess, onCancel }) =
setResetFields(true)
}
- const handleTextChange = (e) => setQuestionText(e.target.value)
+ const handleTextChange = (e) => {
+ const newText = e.target.value
+ setQuestionText(newText)
+
+ // For Stimulus Case Study, also update data.text
+ if (questionType === 'Stimulus Case Study') {
+ setData(prevData => ({
+ ...prevData,
+ text: newText
+ }))
+ }
+ }
const handleLevelSelection = (levelData) => setLevel(levelData)
@@ -89,25 +108,13 @@ const CreateQuestionForm = ({ subjectOptions, question, onSuccess, onCancel }) =
const handlers = {
Matching: () => appendData(data),
Categorization: () => appendData(data),
- Essay: () =>
- appendData({
- html: data
- .split('\n')
- .map((line, index) => `
${line}
`)
- .join('')
- }),
+ Essay: () => appendData({ html: data }),
'Drag and Drop': () => appendData(filterValidData(data)),
'Bow Tie': () => data && appendData(data),
'Multiple Choice': () => appendData(filterValidData(data)),
'Select All That Apply': () => appendData(filterValidData(data)),
'Stimulus Case Study': () => appendData(data),
- 'File Upload': () =>
- appendData({
- html: data
- .split('\n')
- .map((line, index) => `
${line}
`)
- .join('')
- })
+ 'File Upload': () => appendData({ html: data })
}
if (handlers[questionType]) {
@@ -300,8 +307,9 @@ const CreateQuestionForm = ({ subjectOptions, question, onSuccess, onCancel }) =
}
if (questionType === 'Stimulus Case Study') {
+ // Check questionText state directly instead of data.text due to debouncing
if (
- !data.text?.trim() ||
+ !questionText?.trim() ||
!Array.isArray(data.subQuestions) ||
data.subQuestions.length === 0
) {
diff --git a/app/javascript/components/ui/Question/QuestionMetadata.jsx b/app/javascript/components/ui/Question/QuestionMetadata.jsx
index 5d17d48c..2c41abae 100644
--- a/app/javascript/components/ui/Question/QuestionMetadata.jsx
+++ b/app/javascript/components/ui/Question/QuestionMetadata.jsx
@@ -77,9 +77,7 @@ const QuestionMetadata = ({ question, bookmarkedQuestionIds, subjects }) => {
{isBookmarked ? 'Unbookmark' : 'Bookmark'}
- {/* Edit button: shown for owner/admin, but not for Stimulus Case Study questions */}
- {(currentUser.id === question.user_id || currentUser.admin) &&
- question.type_name !== 'Stimulus Case Study' && (
+ {((currentUser.id === question.user_id || currentUser.admin)) && (
diff --git a/package-lock.json b/package-lock.json
index ed3fcbbf..f172dc2c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6,6 +6,9 @@
"": {
"name": "app",
"dependencies": {
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@hotwired/stimulus": "^3.1.0",
"@hotwired/turbo-rails": "^7.2.0",
"@inertiajs/inertia": "^0.11.1",
@@ -645,6 +648,55 @@
"ms": "^2.1.1"
}
},
+ "node_modules/@dnd-kit/accessibility": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
+ "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/core": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
+ "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
+ "dependencies": {
+ "@dnd-kit/accessibility": "^3.1.1",
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/sortable": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
+ "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
+ "dependencies": {
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "@dnd-kit/core": "^6.3.0",
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/utilities": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
+ "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
"node_modules/@esbuild/darwin-arm64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz",
diff --git a/package.json b/package.json
index 5f0871e3..ae209a05 100644
--- a/package.json
+++ b/package.json
@@ -2,6 +2,9 @@
"name": "app",
"private": "true",
"dependencies": {
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@hotwired/stimulus": "^3.1.0",
"@hotwired/turbo-rails": "^7.2.0",
"@inertiajs/inertia": "^0.11.1",
diff --git a/yarn.lock b/yarn.lock
index d8c42b64..b8a79d21 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -264,6 +264,37 @@
debug "^3.1.0"
lodash.once "^4.1.1"
+"@dnd-kit/accessibility@^3.1.1":
+ version "3.1.1"
+ resolved "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz"
+ integrity sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==
+ dependencies:
+ tslib "^2.0.0"
+
+"@dnd-kit/core@^6.3.0", "@dnd-kit/core@^6.3.1":
+ version "6.3.1"
+ resolved "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz"
+ integrity sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==
+ dependencies:
+ "@dnd-kit/accessibility" "^3.1.1"
+ "@dnd-kit/utilities" "^3.2.2"
+ tslib "^2.0.0"
+
+"@dnd-kit/sortable@^10.0.0":
+ version "10.0.0"
+ resolved "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz"
+ integrity sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==
+ dependencies:
+ "@dnd-kit/utilities" "^3.2.2"
+ tslib "^2.0.0"
+
+"@dnd-kit/utilities@^3.2.2":
+ version "3.2.2"
+ resolved "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz"
+ integrity sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==
+ dependencies:
+ tslib "^2.0.0"
+
"@esbuild/darwin-arm64@^0.18.0", "@esbuild/darwin-arm64@0.18.20":
version "0.18.20"
resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz"
@@ -4412,7 +4443,7 @@ trough@^1.0.0:
resolved "https://registry.npmjs.org/trough/-/trough-1.0.5.tgz"
integrity sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA==
-tslib@^2.1.0, tslib@^2.4.0:
+tslib@^2.0.0, tslib@^2.1.0, tslib@^2.4.0:
version "2.6.2"
resolved "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz"
integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==