speaking and questions content filtering

This commit is contained in:
Yared Yemane 2026-04-07 03:08:18 -07:00
parent 6910fb55a4
commit 21a23d9a88
4 changed files with 141 additions and 54 deletions

View File

@ -33,6 +33,7 @@ import type {
GetQuestionSetsParams, GetQuestionSetsParams,
GetQuestionSetDetailResponse, GetQuestionSetDetailResponse,
GetQuestionSetQuestionsResponse, GetQuestionSetQuestionsResponse,
GetPracticeQuestionsByPracticeResponse,
CreateQuestionSetRequest, CreateQuestionSetRequest,
CreateQuestionSetResponse, CreateQuestionSetResponse,
AddQuestionToSetRequest, AddQuestionToSetRequest,
@ -147,6 +148,14 @@ export const deletePractice = (practiceId: number) =>
export const getPracticeQuestions = (practiceId: number) => export const getPracticeQuestions = (practiceId: number) =>
http.get<GetQuestionSetQuestionsResponse>(`/question-sets/${practiceId}/questions`) http.get<GetQuestionSetQuestionsResponse>(`/question-sets/${practiceId}/questions`)
export const getPracticeQuestionsByPractice = (
practiceId: number,
params?: { limit?: number; offset?: number },
) =>
http.get<GetPracticeQuestionsByPracticeResponse>(`/practices/${practiceId}/questions`, {
params,
})
export const createPracticeQuestion = (data: CreatePracticeQuestionRequest) => export const createPracticeQuestion = (data: CreatePracticeQuestionRequest) =>
http.post("/course-management/practice-questions", data) http.post("/course-management/practice-questions", data)

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react" import { useCallback, useEffect, useMemo, useState } from "react"
import { Link, useParams } from "react-router-dom" import { Link, useParams } from "react-router-dom"
import { ArrowLeft, Plus, Edit, Trash2, X, Check, ChevronDown, ChevronUp, SlidersHorizontal, ArrowUpDown } from "lucide-react" import { ArrowLeft, Plus, Edit, Trash2, X, Check, ChevronDown, ChevronUp, SlidersHorizontal, ArrowUpDown } from "lucide-react"
import practiceSrc from "../../assets/Practice.svg" import practiceSrc from "../../assets/Practice.svg"
@ -9,7 +9,7 @@ import { Badge } from "../../components/ui/badge"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { import {
getQuestionSetById, getQuestionSetById,
getQuestionSetQuestions, getPracticeQuestionsByPractice,
getQuestionById, getQuestionById,
deleteQuestion, deleteQuestion,
updateQuestion, updateQuestion,
@ -21,7 +21,7 @@ import { Select } from "../../components/ui/select"
import { Textarea } from "../../components/ui/textarea" import { Textarea } from "../../components/ui/textarea"
import type { PracticeQuestion, QuestionSetQuestion, QuestionDetail } from "../../types/course.types" import type { PracticeQuestion, QuestionSetQuestion, QuestionDetail } from "../../types/course.types"
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO"
type DifficultyLevel = "EASY" | "MEDIUM" | "HARD" type DifficultyLevel = "EASY" | "MEDIUM" | "HARD"
type GroupByOption = "none" | "type" | "difficulty" type GroupByOption = "none" | "type" | "difficulty"
type PointsSortOption = "asc" | "desc" type PointsSortOption = "asc" | "desc"
@ -47,12 +47,14 @@ const typeLabels: Record<QuestionType, string> = {
MCQ: "Multiple Choice", MCQ: "Multiple Choice",
TRUE_FALSE: "True/False", TRUE_FALSE: "True/False",
SHORT: "Short Answer", SHORT: "Short Answer",
AUDIO: "Audio",
} }
const typeColors: Record<QuestionType, string> = { const typeColors: Record<QuestionType, string> = {
MCQ: "bg-blue-100 text-blue-700", MCQ: "bg-blue-100 text-blue-700",
TRUE_FALSE: "bg-purple-100 text-purple-700", TRUE_FALSE: "bg-purple-100 text-purple-700",
SHORT: "bg-green-100 text-green-700", SHORT: "bg-green-100 text-green-700",
AUDIO: "bg-brand-100 text-brand-700",
} }
export function PracticeQuestionsPage() { export function PracticeQuestionsPage() {
@ -75,6 +77,9 @@ export function PracticeQuestionsPage() {
const [loadingDetailIds, setLoadingDetailIds] = useState<Record<number, boolean>>({}) const [loadingDetailIds, setLoadingDetailIds] = useState<Record<number, boolean>>({})
const [groupBy, setGroupBy] = useState<GroupByOption>("none") const [groupBy, setGroupBy] = useState<GroupByOption>("none")
const [pointsSort, setPointsSort] = useState<PointsSortOption>("desc") const [pointsSort, setPointsSort] = useState<PointsSortOption>("desc")
const [pageSize] = useState(10)
const [currentPage, setCurrentPage] = useState(1)
const [totalQuestions, setTotalQuestions] = useState(0)
const [draft, setDraft] = useState<QuestionDraft>({ const [draft, setDraft] = useState<QuestionDraft>({
questionText: "", questionText: "",
@ -194,19 +199,22 @@ export function PracticeQuestionsPage() {
}) })
} }
const fetchQuestions = async () => { const fetchQuestions = useCallback(async (page: number = currentPage) => {
if (!practiceId) return if (!practiceId) return
try { try {
const safePage = page < 1 ? 1 : page
const offset = (safePage - 1) * pageSize
const [detailRes, questionsRes] = await Promise.all([ const [detailRes, questionsRes] = await Promise.all([
getQuestionSetById(Number(practiceId)), getQuestionSetById(Number(practiceId)),
getQuestionSetQuestions(Number(practiceId)), getPracticeQuestionsByPractice(Number(practiceId), { limit: pageSize, offset }),
]) ])
const detail = detailRes.data?.data const detail = detailRes.data?.data
setPracticeTitle(detail?.title || "Practice Questions") setPracticeTitle(detail?.title || "Practice Questions")
setPracticeDescription(detail?.description || "") setPracticeDescription(detail?.description || "")
const mappedQuestions: PracticeQuestion[] = (questionsRes.data?.data ?? []).map( const payload = questionsRes.data?.data
const mappedQuestions: PracticeQuestion[] = (payload?.questions ?? []).map(
(question: QuestionSetQuestion) => ({ (question: QuestionSetQuestion) => ({
id: question.question_id || question.id, id: question.question_id || question.id,
practice_id: question.set_id, practice_id: question.set_id,
@ -214,13 +222,14 @@ export function PracticeQuestionsPage() {
points: question.points ?? 0, points: question.points ?? 0,
difficulty_level: question.difficulty_level || "", difficulty_level: question.difficulty_level || "",
question_voice_prompt: question.voice_prompt || "", question_voice_prompt: question.voice_prompt || "",
sample_answer_voice_prompt: "", sample_answer_voice_prompt: question.sample_answer_voice_prompt || "",
sample_answer: question.explanation || "", sample_answer: question.audio_correct_answer_text || question.explanation || "",
tips: question.tips || "", tips: question.tips || "",
type: type:
question.question_type === "MCQ" || question.question_type === "MCQ" ||
question.question_type === "TRUE_FALSE" || question.question_type === "TRUE_FALSE" ||
question.question_type === "SHORT" question.question_type === "SHORT" ||
question.question_type === "AUDIO"
? question.question_type ? question.question_type
: question.question_type === "SHORT_ANSWER" : question.question_type === "SHORT_ANSWER"
? "SHORT" ? "SHORT"
@ -228,17 +237,19 @@ export function PracticeQuestionsPage() {
}), }),
) )
setQuestions(mappedQuestions) setQuestions(mappedQuestions)
setTotalQuestions(payload?.total_count ?? mappedQuestions.length)
setCurrentPage(safePage)
} catch (err) { } catch (err) {
console.error("Failed to fetch questions:", err) console.error("Failed to fetch questions:", err)
setError("Failed to load questions") setError("Failed to load questions")
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }, [practiceId, currentPage, pageSize])
useEffect(() => { useEffect(() => {
fetchQuestions() fetchQuestions()
}, [practiceId]) }, [fetchQuestions])
const handleAddQuestion = () => { const handleAddQuestion = () => {
resetDraft() resetDraft()
@ -525,7 +536,10 @@ export function PracticeQuestionsPage() {
{practiceDescription && ( {practiceDescription && (
<p className="mt-0.5 text-sm text-grayScale-500">{practiceDescription}</p> <p className="mt-0.5 text-sm text-grayScale-500">{practiceDescription}</p>
)} )}
<p className="mt-0.5 text-sm text-grayScale-500">{questions.length} questions available</p> <p className="mt-0.5 text-sm text-grayScale-500">{questions.length} questions on this page</p>
<p className="mt-0.5 text-xs text-grayScale-400">
Total: {totalQuestions} question{totalQuestions === 1 ? "" : "s"}
</p>
</div> </div>
</div> </div>
<Button className="bg-brand-500 hover:bg-brand-600" onClick={handleAddQuestion}> <Button className="bg-brand-500 hover:bg-brand-600" onClick={handleAddQuestion}>
@ -707,6 +721,31 @@ export function PracticeQuestionsPage() {
))} ))}
</div> </div>
))} ))}
{totalQuestions > pageSize && (
<div className="flex items-center justify-between rounded-xl border border-grayScale-200 bg-white px-4 py-3">
<p className="text-sm text-grayScale-500">
Page {currentPage} of {Math.max(1, Math.ceil(totalQuestions / pageSize))}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={currentPage <= 1}
onClick={() => void fetchQuestions(currentPage - 1)}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={currentPage >= Math.ceil(totalQuestions / pageSize)}
onClick={() => void fetchQuestions(currentPage + 1)}
>
Next
</Button>
</div>
</div>
)}
</div> </div>
)} )}

View File

@ -109,6 +109,9 @@ function introVideoUrlFromUploadResponse(data: { url?: string; embed_url?: strin
export function SpeakingPage() { export function SpeakingPage() {
const [audioQuestions, setAudioQuestions] = useState<QuestionDetail[]>([]) const [audioQuestions, setAudioQuestions] = useState<QuestionDetail[]>([])
const [audioTotalCount, setAudioTotalCount] = useState(0)
const [audioPage, setAudioPage] = useState(1)
const [audioPageSize] = useState(12)
const [audioPreviewByQuestionId, setAudioPreviewByQuestionId] = useState<Record<number, string>>({}) const [audioPreviewByQuestionId, setAudioPreviewByQuestionId] = useState<Record<number, string>>({})
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
@ -139,7 +142,7 @@ export function SpeakingPage() {
const [deleteTarget, setDeleteTarget] = useState<{ id: number; text: string } | null>(null) const [deleteTarget, setDeleteTarget] = useState<{ id: number; text: string } | null>(null)
const [detailForm, setDetailForm] = useState({ const [detailForm, setDetailForm] = useState({
question_text: "", question_text: "",
question_type: "AUDIO" as "AUDIO", question_type: "AUDIO" as const,
difficulty_level: "EASY" as "EASY" | "MEDIUM" | "HARD", difficulty_level: "EASY" as "EASY" | "MEDIUM" | "HARD",
points: 1, points: 1,
explanation: "", explanation: "",
@ -187,59 +190,49 @@ export function SpeakingPage() {
return res.data?.data?.url ?? "" return res.data?.data?.url ?? ""
}, []) }, [])
const fetchAudioQuestions = useCallback(async () => { const fetchAudioQuestions = useCallback(async (page: number = audioPage) => {
setLoading(true) setLoading(true)
try { try {
const batchSize = 100 const safePage = page < 1 ? 1 : page
let nextOffset = 0 const offset = (safePage - 1) * audioPageSize
let expectedTotal = Number.POSITIVE_INFINITY const res = await getQuestions({
let allRows: QuestionDetail[] = [] question_type: "AUDIO",
limit: audioPageSize,
offset,
})
const payload = res.data?.data as unknown
const meta = res.data?.metadata as { total_count?: number } | null | undefined
while (allRows.length < expectedTotal) { let rows: QuestionDetail[] = []
const res = await getQuestions({ let total = 0
question_type: "AUDIO", if (Array.isArray(payload)) {
limit: batchSize, rows = payload as QuestionDetail[]
offset: nextOffset, total = meta?.total_count ?? rows.length
}) } else if (
const payload = res.data?.data as unknown payload &&
const meta = res.data?.metadata as { total_count?: number } | null | undefined typeof payload === "object" &&
Array.isArray((payload as { questions?: unknown[] }).questions)
let chunk: QuestionDetail[] = [] ) {
let chunkTotal: number | undefined const data = payload as { questions: QuestionDetail[]; total_count?: number }
if (Array.isArray(payload)) { rows = data.questions
chunk = payload as QuestionDetail[] total = data.total_count ?? meta?.total_count ?? rows.length
chunkTotal = meta?.total_count
} else if (
payload &&
typeof payload === "object" &&
Array.isArray((payload as { questions?: unknown[] }).questions)
) {
const data = payload as { questions: QuestionDetail[]; total_count?: number }
chunk = data.questions
chunkTotal = data.total_count ?? meta?.total_count
}
allRows = [...allRows, ...chunk]
if (typeof chunkTotal === "number" && Number.isFinite(chunkTotal)) {
expectedTotal = chunkTotal
}
if (chunk.length < batchSize) break
nextOffset += chunk.length
} }
setAudioQuestions(allRows) setAudioQuestions(rows)
setAudioTotalCount(total)
setAudioPage(safePage)
} catch (error) { } catch (error) {
console.error("Failed to fetch audio questions:", error) console.error("Failed to fetch audio questions:", error)
setAudioQuestions([]) setAudioQuestions([])
setAudioTotalCount(0)
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, []) }, [audioPage, audioPageSize])
useEffect(() => { useEffect(() => {
fetchAudioQuestions() fetchAudioQuestions()
}, [fetchAudioQuestions]) }, [fetchAudioQuestions, audioPageSize])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@ -988,6 +981,9 @@ export function SpeakingPage() {
<p className="mt-1 text-xs font-normal text-grayScale-500 sm:text-sm"> <p className="mt-1 text-xs font-normal text-grayScale-500 sm:text-sm">
Tap a row to view details. Speaking practices create AUDIO question sets linked to a sub-course. Tap a row to view details. Speaking practices create AUDIO question sets linked to a sub-course.
</p> </p>
<p className="mt-1 text-xs font-normal text-grayScale-400 sm:text-sm">
Showing page {audioPage} of {Math.max(1, Math.ceil(audioTotalCount / audioPageSize))} ({audioTotalCount} total)
</p>
</CardHeader> </CardHeader>
<CardContent className="px-4 pb-6 pt-5 sm:px-6"> <CardContent className="px-4 pb-6 pt-5 sm:px-6">
{loading ? ( {loading ? (
@ -1060,6 +1056,33 @@ export function SpeakingPage() {
) : null} ) : null}
</div> </div>
))} ))}
{audioTotalCount > audioPageSize ? (
<div className="mt-4 flex items-center justify-between rounded-xl border border-grayScale-200 bg-white px-3 py-2">
<p className="text-xs text-grayScale-500 sm:text-sm">
Page {audioPage} of {Math.max(1, Math.ceil(audioTotalCount / audioPageSize))}
</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={audioPage <= 1 || loading}
onClick={() => fetchAudioQuestions(audioPage - 1)}
>
Previous
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={audioPage >= Math.ceil(audioTotalCount / audioPageSize) || loading}
onClick={() => fetchAudioQuestions(audioPage + 1)}
>
Next
</Button>
</div>
</div>
) : null}
</div> </div>
)} )}
</CardContent> </CardContent>

View File

@ -344,7 +344,7 @@ export interface PracticeQuestion {
sample_answer_voice_prompt: string sample_answer_voice_prompt: string
sample_answer: string sample_answer: string
tips: string tips: string
type: "MCQ" | "TRUE_FALSE" | "SHORT" type: "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO"
} }
export interface GetPracticeQuestionsResponse { export interface GetPracticeQuestionsResponse {
@ -455,12 +455,15 @@ export interface QuestionSetQuestion {
question_id: number question_id: number
display_order: number display_order: number
question_text: string question_text: string
question_type: "MCQ" | "TRUE_FALSE" | "SHORT" | string question_type: "MCQ" | "TRUE_FALSE" | "SHORT" | "SHORT_ANSWER" | "AUDIO" | string
difficulty_level?: string | null difficulty_level?: string | null
points?: number points?: number
explanation?: string | null explanation?: string | null
tips?: string | null tips?: string | null
voice_prompt?: string | null voice_prompt?: string | null
sample_answer_voice_prompt?: string | null
image_url?: string | null
audio_correct_answer_text?: string | null
question_status?: string question_status?: string
} }
@ -472,6 +475,19 @@ export interface GetQuestionSetQuestionsResponse {
metadata: unknown metadata: unknown
} }
export interface GetPracticeQuestionsByPracticeResponse {
message: string
data: {
questions: QuestionSetQuestion[]
total_count: number
limit: number
offset: number
}
success: boolean
status_code: number
metadata: unknown
}
/** POST /question-sets — practices use set_type: "PRACTICE", owner_type: "SUB_COURSE", owner_id: sub-course id */ /** POST /question-sets — practices use set_type: "PRACTICE", owner_type: "SUB_COURSE", owner_id: sub-course id */
export interface CreateQuestionSetRequest { export interface CreateQuestionSetRequest {
title: string title: string