183 lines
5.7 KiB
TypeScript
183 lines
5.7 KiB
TypeScript
|
|
import { useState, useEffect } from 'react'
|
||
|
|
import type { Question } from '@/types/questions'
|
||
|
|
import { questionToJson, questionFromJson } from '@/types/questions'
|
||
|
|
import { Label } from '@/components/ui/label'
|
||
|
|
import { Textarea } from '@/components/ui/textarea'
|
||
|
|
import { Button } from '@/components/ui/button'
|
||
|
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||
|
|
import { AlertCircle } from 'lucide-react'
|
||
|
|
|
||
|
|
interface JSONQuestionEditorProps {
|
||
|
|
question: Partial<Question> | null
|
||
|
|
onChange: (question: Partial<Question> | null, isValid: boolean) => void
|
||
|
|
}
|
||
|
|
|
||
|
|
export function JSONQuestionEditor({
|
||
|
|
question,
|
||
|
|
onChange,
|
||
|
|
}: JSONQuestionEditorProps) {
|
||
|
|
const [jsonText, setJsonText] = useState('')
|
||
|
|
const [error, setError] = useState<string | null>(null)
|
||
|
|
const [isValid, setIsValid] = useState(false)
|
||
|
|
|
||
|
|
// Инициализация JSON из вопроса
|
||
|
|
useEffect(() => {
|
||
|
|
if (question) {
|
||
|
|
try {
|
||
|
|
const json = questionToJson(question as Question)
|
||
|
|
setJsonText(JSON.stringify(json, null, 2))
|
||
|
|
setError(null)
|
||
|
|
setIsValid(true)
|
||
|
|
} catch (e) {
|
||
|
|
setError(`Failed to serialize question: ${e}`)
|
||
|
|
setIsValid(false)
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
setJsonText('')
|
||
|
|
setError(null)
|
||
|
|
setIsValid(false)
|
||
|
|
}
|
||
|
|
}, [question])
|
||
|
|
|
||
|
|
// Валидация и парсинг JSON
|
||
|
|
const handleJsonChange = (value: string) => {
|
||
|
|
setJsonText(value)
|
||
|
|
|
||
|
|
if (!value.trim()) {
|
||
|
|
setError(null)
|
||
|
|
setIsValid(false)
|
||
|
|
onChange(null, false)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const parsed: unknown = JSON.parse(value)
|
||
|
|
if (!parsed || typeof parsed !== 'object') {
|
||
|
|
throw new Error('JSON must be an object')
|
||
|
|
}
|
||
|
|
const obj = parsed as Record<string, unknown>
|
||
|
|
|
||
|
|
// Базовая валидация структуры
|
||
|
|
if (typeof obj.questionType !== 'string') {
|
||
|
|
throw new Error('Missing required field: questionType')
|
||
|
|
}
|
||
|
|
const isMatrix = obj.questionType === 'matrix'
|
||
|
|
|
||
|
|
if (!isMatrix && !obj.word) {
|
||
|
|
throw new Error('Missing required field: word')
|
||
|
|
}
|
||
|
|
if (!isMatrix && !obj.answer) {
|
||
|
|
throw new Error('Missing required field: answer')
|
||
|
|
}
|
||
|
|
if (!isMatrix && (!obj.buttons || !Array.isArray(obj.buttons))) {
|
||
|
|
throw new Error('Missing or invalid field: buttons (must be an array)')
|
||
|
|
}
|
||
|
|
if (isMatrix && obj.matrixSize == null) {
|
||
|
|
throw new Error('Missing required field for matrix: matrixSize')
|
||
|
|
}
|
||
|
|
|
||
|
|
// Проверка что answer существует в buttons
|
||
|
|
if (!isMatrix) {
|
||
|
|
const buttons = Array.isArray(obj.buttons) ? obj.buttons : []
|
||
|
|
const buttonIds = buttons
|
||
|
|
.map((b) => {
|
||
|
|
const bObj: Record<string, unknown> =
|
||
|
|
b && typeof b === 'object' ? (b as Record<string, unknown>) : {}
|
||
|
|
return typeof bObj.id === 'string' ? bObj.id : undefined
|
||
|
|
})
|
||
|
|
.filter((id): id is string => typeof id === 'string')
|
||
|
|
|
||
|
|
if (typeof obj.answer === 'string' && !buttonIds.includes(obj.answer)) {
|
||
|
|
throw new Error(
|
||
|
|
`Answer "${obj.answer}" not found in buttons. Available IDs: ${buttonIds.join(', ')}`,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Проверка для input_buttons
|
||
|
|
if (obj.questionType === 'input_buttons' && !obj.template) {
|
||
|
|
throw new Error('Missing required field for input_buttons: template')
|
||
|
|
}
|
||
|
|
|
||
|
|
// Парсинг в Question объект
|
||
|
|
const questionObj = questionFromJson(obj)
|
||
|
|
setError(null)
|
||
|
|
setIsValid(true)
|
||
|
|
onChange(questionObj, true)
|
||
|
|
} catch (e) {
|
||
|
|
const errorMessage = e instanceof Error ? e.message : 'Invalid JSON'
|
||
|
|
setError(errorMessage)
|
||
|
|
setIsValid(false)
|
||
|
|
onChange(null, false)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Форматирование JSON
|
||
|
|
const formatJson = () => {
|
||
|
|
try {
|
||
|
|
const parsed = JSON.parse(jsonText)
|
||
|
|
setJsonText(JSON.stringify(parsed, null, 2))
|
||
|
|
handleJsonChange(JSON.stringify(parsed, null, 2))
|
||
|
|
} catch {
|
||
|
|
// Если невалидный JSON, просто показываем ошибку
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-4">
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<Label>JSON Editor</Label>
|
||
|
|
<Button type="button" variant="outline" size="sm" onClick={formatJson}>
|
||
|
|
Format JSON
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<Textarea
|
||
|
|
value={jsonText}
|
||
|
|
onChange={(e) => handleJsonChange(e.target.value)}
|
||
|
|
placeholder='{"questionType": "simple", "word": "...", "answer": "...", "buttons": [...]}'
|
||
|
|
rows={20}
|
||
|
|
className="font-mono text-sm"
|
||
|
|
/>
|
||
|
|
|
||
|
|
{error && (
|
||
|
|
<Alert variant="destructive">
|
||
|
|
<AlertCircle className="h-4 w-4" />
|
||
|
|
<AlertDescription>{error}</AlertDescription>
|
||
|
|
</Alert>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{isValid && !error && (
|
||
|
|
<Alert>
|
||
|
|
<AlertCircle className="h-4 w-4" />
|
||
|
|
<AlertDescription>JSON is valid</AlertDescription>
|
||
|
|
</Alert>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<div className="text-xs text-muted-foreground">
|
||
|
|
<p className="font-semibold mb-1">Required fields:</p>
|
||
|
|
<ul className="list-disc list-inside space-y-1">
|
||
|
|
<li>
|
||
|
|
<code>questionType</code>: "simple" or "input_buttons"
|
||
|
|
</li>
|
||
|
|
<li>
|
||
|
|
<code>questionType</code>: "matrix" (requires <code>matrixSize</code>)
|
||
|
|
</li>
|
||
|
|
<li>
|
||
|
|
<code>word</code>: Word or phrase to learn
|
||
|
|
</li>
|
||
|
|
<li>
|
||
|
|
<code>answer</code>: ID of the correct button
|
||
|
|
</li>
|
||
|
|
<li>
|
||
|
|
<code>buttons</code>: Array of button objects with id, text/image
|
||
|
|
</li>
|
||
|
|
<li>
|
||
|
|
<code>template</code>: Required for input_buttons type
|
||
|
|
</li>
|
||
|
|
</ul>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|