From a7f0849fe4ad8aefc8187de89fd94c06e65cc2cf Mon Sep 17 00:00:00 2001 From: LaRita Robinson Date: Wed, 21 Jan 2026 14:40:41 -0500 Subject: [PATCH] WIP update/add form for Stimulus Case Study --- .claude/settings.local.json | 7 + .../CreateQuestionForm/StimulusCaseStudy.jsx | 342 +++++++++++++----- .../ui/CreateQuestionForm/index.jsx | 40 +- .../ui/Question/QuestionMetadata.jsx | 4 +- package-lock.json | 52 +++ package.json | 3 + yarn.lock | 33 +- 7 files changed, 370 insertions(+), 111 deletions(-) create mode 100644 .claude/settings.local.json 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==