feat(admin): dynamic content flows, cleaner UI copy, and table pagination

Add Learn English practice and question-type builder integrations with dynamic schema slots and HTML table editor. Remove API path labels from admin pages and standardize table page-size options to 5, 10, 30, 50, and 100.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-06-04 12:34:39 -07:00
parent 2c3f0da6f7
commit 92a2fab833
59 changed files with 1226 additions and 481 deletions

View File

@ -1,6 +1,6 @@
import http from "./http" import http from "./http"
export type UploadMediaType = "image" | "audio" | "video" export type UploadMediaType = "image" | "audio" | "video" | "pdf"
export type UploadProvider = "MINIO" | "VIMEO" export type UploadProvider = "MINIO" | "VIMEO"
export interface UploadMediaResponse { export interface UploadMediaResponse {
@ -121,6 +121,8 @@ export const uploadVideoFile = (fileOrUrl: File | string, options?: UploadMediaO
}) })
: uploadMediaFile("video", fileOrUrl, options) : uploadMediaFile("video", fileOrUrl, options)
export const uploadPdfFile = (file: File) => uploadMediaFile("pdf", file)
export const resolveFileUrl = (key: string) => export const resolveFileUrl = (key: string) =>
http.get<ResolveFileUrlResponse>("/files/url", { http.get<ResolveFileUrlResponse>("/files/url", {
params: { key }, params: { key },

View File

@ -6,15 +6,17 @@ import {
type ChangeEvent, type ChangeEvent,
type DragEvent, type DragEvent,
} from "react" } from "react"
import { CloudUpload, Image as ImageIcon, Mic, Pause, Play, X } from "lucide-react" import { CloudUpload, FileText, Image as ImageIcon, Mic, Pause, Play, X } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { uploadAudioFile, uploadImageFile } from "../../api/files.api" import { uploadAudioFile, uploadImageFile, uploadPdfFile } from "../../api/files.api"
import { resolveMediaPreviewUrl } from "../../lib/practiceMedia" import { resolveMediaPreviewUrl } from "../../lib/practiceMedia"
import { Button } from "../ui/button"
import { Input } from "../ui/input" import { Input } from "../ui/input"
import { Textarea } from "../ui/textarea" import { Textarea } from "../ui/textarea"
import { SpinnerIcon } from "../ui/spinner-icon" import { SpinnerIcon } from "../ui/spinner-icon"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { ResolvedImage } from "../media/ResolvedImage" import { ResolvedImage } from "../media/ResolvedImage"
import { DynamicTableBuilder } from "./DynamicTableBuilder"
const MAX_IMAGE_BYTES = 10 * 1024 * 1024 const MAX_IMAGE_BYTES = 10 * 1024 * 1024
const MAX_AUDIO_BYTES = 50 * 1024 * 1024 const MAX_AUDIO_BYTES = 50 * 1024 * 1024
@ -28,9 +30,11 @@ export interface DynamicSchemaSlotRow {
required?: boolean required?: boolean
} }
function slotMediaMode(kind: string): "image" | "audio" | "text" { function slotMediaMode(kind: string): "image" | "audio" | "pdf" | "table" | "text" {
const u = kind.trim().toUpperCase() const u = kind.trim().toUpperCase()
if (u === "IMAGE") return "image" if (u === "IMAGE") return "image"
if (u === "TABLE") return "table"
if (u === "PDF_ATTACHMENT" || u === "PDF_UPLOAD") return "pdf"
if (u.startsWith("AUDIO")) return "audio" if (u.startsWith("AUDIO")) return "audio"
return "text" return "text"
} }
@ -537,6 +541,103 @@ export interface DynamicSchemaSlotFieldProps {
disabled?: boolean disabled?: boolean
} }
function DynamicPdfSlot({
value,
onChange,
disabled,
slotLabel,
slotMeta,
}: {
value: string
onChange: (next: string) => void
disabled: boolean
slotLabel: string
slotMeta: string
}) {
const fileInputRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
const processFile = useCallback(
async (file: File) => {
if (disabled || uploading) return
const ext = file.name.split(".").pop()?.toLowerCase() ?? ""
if (ext !== "pdf") {
toast.error("Only PDF files are allowed")
return
}
if (file.size > 25 * 1024 * 1024) {
toast.error("PDF is too large", { description: "Maximum size is 25 MB." })
return
}
setUploading(true)
try {
const res = await uploadPdfFile(file)
const url = res.data?.data?.url?.trim()
if (!url) throw new Error("Upload did not return a URL")
onChange(url)
toast.success("PDF uploaded")
} catch (e) {
console.error(e)
toast.error("Failed to upload PDF")
} finally {
setUploading(false)
}
},
[disabled, onChange, uploading],
)
return (
<div className="space-y-2">
<div className="flex flex-wrap items-end justify-between gap-2">
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
</div>
<input
ref={fileInputRef}
type="file"
accept="application/pdf,.pdf"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
e.target.value = ""
if (file) void processFile(file)
}}
/>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled || uploading}
onClick={() => fileInputRef.current?.click()}
className="gap-2"
>
{uploading ? <SpinnerIcon className="h-4 w-4" /> : <FileText className="h-4 w-4" />}
Upload PDF
</Button>
{value.trim() ? (
<Button
type="button"
variant="ghost"
size="sm"
disabled={disabled}
onClick={() => onChange("")}
>
Clear
</Button>
) : null}
</div>
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="https://… or upload above"
className="h-11 rounded-lg border-grayScale-200 font-mono text-sm"
disabled={disabled || uploading}
/>
</div>
)
}
export function DynamicSchemaSlotField({ export function DynamicSchemaSlotField({
row, row,
value, value,
@ -546,10 +647,30 @@ export function DynamicSchemaSlotField({
const mode = slotMediaMode(row.kind) const mode = slotMediaMode(row.kind)
const baseLabel = const baseLabel =
row.label?.trim() || row.label?.trim() ||
(mode === "image" ? "Image" : mode === "audio" ? "Audio" : row.kind) (mode === "image"
? "Image"
: mode === "audio"
? "Audio"
: mode === "pdf"
? "PDF"
: mode === "table"
? "Table"
: row.kind)
const slotLabel = `${baseLabel}${row.required ? " *" : ""}` const slotLabel = `${baseLabel}${row.required ? " *" : ""}`
const slotMeta = `${row.id} · ${row.kind}` const slotMeta = `${row.id} · ${row.kind}`
if (mode === "table") {
return (
<DynamicTableBuilder
value={value}
onChange={onChange}
disabled={disabled}
slotLabel={slotLabel}
slotMeta={slotMeta}
/>
)
}
if (mode === "text") { if (mode === "text") {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
@ -561,7 +682,11 @@ export function DynamicSchemaSlotField({
rows={3} rows={3}
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
placeholder="URL, plain text, or JSON object" placeholder={
row.kind === "OPTION"
? '{"options":[{"id":"a","text":"…","is_correct":true}]}'
: "URL, plain text, or JSON object"
}
className="min-h-[88px] resize-y rounded-lg border-grayScale-200 font-mono text-sm" className="min-h-[88px] resize-y rounded-lg border-grayScale-200 font-mono text-sm"
disabled={disabled} disabled={disabled}
/> />
@ -581,6 +706,18 @@ export function DynamicSchemaSlotField({
) )
} }
if (mode === "pdf") {
return (
<DynamicPdfSlot
value={value}
onChange={onChange}
disabled={disabled}
slotLabel={slotLabel}
slotMeta={slotMeta}
/>
)
}
return ( return (
<DynamicAudioSlot <DynamicAudioSlot
value={value} value={value}

View File

@ -0,0 +1,250 @@
import { useMemo } from "react"
import { Plus, Trash2 } from "lucide-react"
import { Button } from "../ui/button"
import { Input } from "../ui/input"
import { cn } from "../../lib/utils"
import {
createEmptyTable,
parseTableSlotValue,
serializeTableSlotValue,
type DynamicTableValue,
} from "../../lib/dynamicTableValue"
export type DynamicTableBuilderProps = {
value: string
onChange: (next: string) => void
disabled?: boolean
slotLabel: string
slotMeta: string
}
function normalizeTable(table: DynamicTableValue): DynamicTableValue {
const columns =
table.columns.length > 0
? table.columns.map((c, i) => c.trim() || `Column ${i + 1}`)
: ["Column 1"]
const colCount = columns.length
const rows =
table.rows.length > 0
? table.rows.map((row) => {
const cells = [...row]
while (cells.length < colCount) cells.push("")
return cells.slice(0, colCount)
})
: [Array(colCount).fill("")]
return { columns, rows }
}
export function DynamicTableBuilder({
value,
onChange,
disabled = false,
slotLabel,
slotMeta,
}: DynamicTableBuilderProps) {
const table = useMemo(() => normalizeTable(parseTableSlotValue(value)), [value])
const commit = (next: DynamicTableValue) => {
onChange(serializeTableSlotValue(normalizeTable(next)))
}
const updateColumn = (colIndex: number, text: string) => {
const columns = [...table.columns]
columns[colIndex] = text
commit({ columns, rows: table.rows })
}
const updateCell = (rowIndex: number, colIndex: number, text: string) => {
const rows = table.rows.map((r) => [...r])
rows[rowIndex][colIndex] = text
commit({ columns: table.columns, rows })
}
const addColumn = () => {
const columns = [...table.columns, `Column ${table.columns.length + 1}`]
const rows = table.rows.map((row) => [...row, ""])
commit({ columns, rows })
}
const removeColumn = (colIndex: number) => {
if (table.columns.length <= 1) return
const columns = table.columns.filter((_, i) => i !== colIndex)
const rows = table.rows.map((row) => row.filter((_, i) => i !== colIndex))
commit({ columns, rows })
}
const addRow = () => {
const rows = [...table.rows, Array(table.columns.length).fill("")]
commit({ columns: table.columns, rows })
}
const removeRow = (rowIndex: number) => {
if (table.rows.length <= 1) return
const rows = table.rows.filter((_, i) => i !== rowIndex)
commit({ columns: table.columns, rows })
}
const resetTable = () => {
commit(createEmptyTable(2, 1))
}
const previewColumns = table.columns.map((c, i) => c.trim() || `Column ${i + 1}`)
const previewRows = table.rows.map((row) =>
row.map((cell, ci) => cell.trim() || ""),
)
return (
<div className="space-y-3">
<div className="flex flex-wrap items-end justify-between gap-2">
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
</div>
<p className="text-xs text-grayScale-500">
Build the reference table learners will see with the question.
</p>
<div className="overflow-x-auto rounded-xl border border-grayScale-200 bg-white">
<table className="w-full min-w-[320px] border-collapse text-sm">
<thead>
<tr className="bg-grayScale-50/90">
{table.columns.map((col, colIndex) => (
<th
key={`col-${colIndex}`}
className="border-b border-r border-grayScale-200 p-1.5 align-top last:border-r-0"
>
<div className="flex min-w-[100px] items-start gap-1">
<Input
value={col}
disabled={disabled}
onChange={(e) => updateColumn(colIndex, e.target.value)}
placeholder={`Column ${colIndex + 1}`}
className="h-9 border-grayScale-200 bg-white text-xs font-semibold"
/>
{table.columns.length > 1 ? (
<Button
type="button"
variant="ghost"
size="sm"
disabled={disabled}
className="h-9 w-9 shrink-0 p-0 text-grayScale-400 hover:text-red-600"
aria-label={`Remove column ${colIndex + 1}`}
onClick={() => removeColumn(colIndex)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
) : null}
</div>
</th>
))}
<th className="w-10 border-b border-grayScale-200 bg-grayScale-50/90 p-1" />
</tr>
</thead>
<tbody>
{table.rows.map((row, rowIndex) => (
<tr key={`row-${rowIndex}`} className="group">
{row.map((cell, colIndex) => (
<td
key={`cell-${rowIndex}-${colIndex}`}
className="border-b border-r border-grayScale-100 p-1.5 last:border-r-0"
>
<Input
value={cell}
disabled={disabled}
onChange={(e) => updateCell(rowIndex, colIndex, e.target.value)}
placeholder="Cell value"
className="h-9 border-grayScale-200 bg-[#F8FAFC] text-sm"
/>
</td>
))}
<td className="border-b border-grayScale-100 p-1 align-middle">
<Button
type="button"
variant="ghost"
size="sm"
disabled={disabled || table.rows.length <= 1}
className="h-9 w-9 p-0 text-grayScale-400 hover:text-red-600 disabled:opacity-30"
aria-label={`Remove row ${rowIndex + 1}`}
onClick={() => removeRow(rowIndex)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
className="gap-1.5"
onClick={addColumn}
>
<Plus className="h-3.5 w-3.5" />
Add column
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
className="gap-1.5"
onClick={addRow}
>
<Plus className="h-3.5 w-3.5" />
Add row
</Button>
<Button
type="button"
variant="ghost"
size="sm"
disabled={disabled}
onClick={resetTable}
>
Reset table
</Button>
</div>
<div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/60 p-4">
<p className="mb-2 text-[10px] font-bold uppercase tracking-wide text-grayScale-400">
Learner preview
</p>
<div className="overflow-x-auto rounded-lg border border-grayScale-200 bg-white">
<table className={cn("w-full min-w-[240px] border-collapse text-sm text-grayScale-800")}>
<thead>
<tr className="bg-brand-50/80">
{previewColumns.map((col, i) => (
<th
key={`preview-h-${i}`}
className="border border-grayScale-200 px-3 py-2 text-left text-xs font-bold uppercase tracking-wide text-grayScale-700"
>
{col}
</th>
))}
</tr>
</thead>
<tbody>
{previewRows.map((row, ri) => (
<tr key={`preview-r-${ri}`} className={ri % 2 === 0 ? "bg-white" : "bg-grayScale-50/50"}>
{row.map((cell, ci) => (
<td
key={`preview-c-${ri}-${ci}`}
className="border border-grayScale-100 px-3 py-2 text-sm"
>
{cell || <span className="text-grayScale-300"></span>}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}

View File

@ -778,9 +778,8 @@ export function PracticeQuestionEditorFields({
{value.questionType === "DYNAMIC" && ( {value.questionType === "DYNAMIC" && (
<div className="space-y-2 rounded-lg border border-violet-200 bg-violet-50/50 p-2.5 sm:p-3"> <div className="space-y-2 rounded-lg border border-violet-200 bg-violet-50/50 p-2.5 sm:p-3">
<p className="text-xs leading-snug text-grayScale-600 sm:text-sm"> <p className="text-xs leading-snug text-grayScale-600 sm:text-sm">
<span className="font-medium text-grayScale-800">Image / Audio</span> slots: drop file or paste URL Image, audio, and PDF slots support upload or a URL. Table slots use the visual builder. Other
(imports via <code className="rounded bg-white px-0.5 text-[11px]">POST /files/upload</code>). Other fields accept text or structured values where noted.
slots: text or JSON.
</p> </p>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500"> <label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">

View File

@ -0,0 +1,57 @@
export type DynamicTableValue = {
columns: string[]
rows: string[][]
}
const DEFAULT_TABLE: DynamicTableValue = {
columns: ["Column 1", "Column 2"],
rows: [["", ""]],
}
function isRecord(v: unknown): v is Record<string, unknown> {
return v !== null && typeof v === "object" && !Array.isArray(v)
}
function normalizeRows(columns: string[], rows: unknown): string[][] {
const colCount = Math.max(1, columns.length)
if (!Array.isArray(rows)) return [Array(colCount).fill("")]
return rows.map((row) => {
if (!Array.isArray(row)) return Array(colCount).fill("")
const cells = row.map((c) => String(c ?? ""))
while (cells.length < colCount) cells.push("")
return cells.slice(0, colCount)
})
}
export function parseTableSlotValue(raw: string | undefined): DynamicTableValue {
const t = (raw ?? "").trim()
if (!t) return { ...DEFAULT_TABLE, columns: [...DEFAULT_TABLE.columns], rows: DEFAULT_TABLE.rows.map((r) => [...r]) }
try {
const parsed = JSON.parse(t) as unknown
if (isRecord(parsed) && Array.isArray(parsed.columns)) {
const columns = parsed.columns.map((c) => String(c ?? "").trim() || "Column")
if (columns.length === 0) columns.push("Column 1")
const rows = normalizeRows(columns, parsed.rows)
return { columns, rows: rows.length > 0 ? rows : [Array(columns.length).fill("")] }
}
} catch {
/* fall through */
}
return { ...DEFAULT_TABLE, columns: [...DEFAULT_TABLE.columns], rows: DEFAULT_TABLE.rows.map((r) => [...r]) }
}
export function serializeTableSlotValue(table: DynamicTableValue): string {
const columns = table.columns.map((c) => c.trim() || "Column")
const rows = normalizeRows(columns, table.rows).map((row) =>
row.map((cell) => cell.trim()),
)
return JSON.stringify({ columns, rows })
}
export function createEmptyTable(columnCount = 2, rowCount = 1): DynamicTableValue {
const columns = Array.from({ length: Math.max(1, columnCount) }, (_, i) => `Column ${i + 1}`)
const rows = Array.from({ length: Math.max(1, rowCount) }, () => Array(columns.length).fill(""))
return { columns, rows }
}

View File

@ -1,7 +1,15 @@
import type { CreateQuestionRequest, QuestionOption } from "../types/course.types" import type { CreateQuestionRequest, QuestionOption } from "../types/course.types"
import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types" import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
import { createEmptyTable, serializeTableSlotValue } from "./dynamicTableValue"
import { buildDynamicQuestionPayload } from "./practiceDynamicQuestionPayload" import { buildDynamicQuestionPayload } from "./practiceDynamicQuestionPayload"
function defaultValueForSchemaSlot(kind: string): string {
if (kind.trim().toUpperCase() === "TABLE") {
return serializeTableSlotValue(createEmptyTable(2, 1))
}
return ""
}
export function definitionUsesDynamicPayload(def: QuestionTypeDefinition): boolean { export function definitionUsesDynamicPayload(def: QuestionTypeDefinition): boolean {
return def.stimulus_schema.length > 0 || def.response_schema.length > 0 return def.stimulus_schema.length > 0 || def.response_schema.length > 0
} }
@ -10,11 +18,55 @@ export function emptyDynamicFieldValuesForDefinition(
def: QuestionTypeDefinition, def: QuestionTypeDefinition,
): Record<string, string> { ): Record<string, string> {
const o: Record<string, string> = {} const o: Record<string, string> = {}
for (const r of def.stimulus_schema) o[`stimulus:${r.id}`] = "" for (const r of def.stimulus_schema) {
for (const r of def.response_schema) o[`response:${r.id}`] = "" o[`stimulus:${r.id}`] = defaultValueForSchemaSlot(r.kind)
}
for (const r of def.response_schema) {
o[`response:${r.id}`] = defaultValueForSchemaSlot(r.kind)
}
return o return o
} }
const PROMPT_STIMULUS_KINDS = new Set(["QUESTION_TEXT", "INSTRUCTION", "TEXT_PASSAGE"])
/** First stimulus slot used for a plain-text prompt shortcut in the practice UI. */
export function primaryPromptStimulusRow(
def: QuestionTypeDefinition,
): { id: string; kind: string } | null {
for (const kind of ["QUESTION_TEXT", "INSTRUCTION", "TEXT_PASSAGE"] as const) {
const row = def.stimulus_schema.find((r) => r.kind === kind)
if (row) return { id: row.id, kind: row.kind }
}
return def.stimulus_schema[0] ? { id: def.stimulus_schema[0].id, kind: def.stimulus_schema[0].kind } : null
}
export function mergePromptIntoDynamicFieldValues(
def: QuestionTypeDefinition,
questionText: string,
fieldValues: Record<string, string>,
): Record<string, string> {
const merged = { ...fieldValues }
const prompt = questionText.trim()
if (!prompt) return merged
const slot = primaryPromptStimulusRow(def)
if (!slot) return merged
const key = `stimulus:${slot.id}`
if (!merged[key]?.trim()) merged[key] = prompt
return merged
}
export function dynamicPromptFromFieldValues(
def: QuestionTypeDefinition,
fieldValues: Record<string, string>,
): string {
for (const row of def.stimulus_schema) {
if (!PROMPT_STIMULUS_KINDS.has(row.kind)) continue
const v = fieldValues[`stimulus:${row.id}`]?.trim()
if (v) return v
}
return ""
}
/** /**
* System definitions with empty schema map to classic POST /questions types. * System definitions with empty schema map to classic POST /questions types.
* Returns null when the payload must be DYNAMIC (schema-driven or unknown). * Returns null when the payload must be DYNAMIC (schema-driven or unknown).
@ -41,6 +93,24 @@ export interface LearnEnglishDefinitionQuestionInput {
sampleAnswerVoiceUrl?: string sampleAnswerVoiceUrl?: string
} }
export function questionRowHasContent(
q: LearnEnglishDefinitionQuestionInput,
def: QuestionTypeDefinition,
): boolean {
if (!definitionUsesDynamicPayload(def)) {
return Boolean(q.questionText.trim())
}
if (q.questionText.trim()) return true
const fv = q.dynamicFieldValues ?? {}
for (const row of def.stimulus_schema) {
if (fv[`stimulus:${row.id}`]?.trim()) return true
}
for (const row of def.response_schema) {
if (fv[`response:${row.id}`]?.trim()) return true
}
return false
}
export function buildCreateQuestionFromDefinition( export function buildCreateQuestionFromDefinition(
def: QuestionTypeDefinition, def: QuestionTypeDefinition,
q: LearnEnglishDefinitionQuestionInput, q: LearnEnglishDefinitionQuestionInput,
@ -51,13 +121,17 @@ export function buildCreateQuestionFromDefinition(
const question_text = q.questionText.trim() const question_text = q.questionText.trim()
if (definitionUsesDynamicPayload(def)) { if (definitionUsesDynamicPayload(def)) {
const fieldValues = mergePromptIntoDynamicFieldValues(
def,
q.questionText,
q.dynamicFieldValues ?? {},
)
const payload = buildDynamicQuestionPayload({ const payload = buildDynamicQuestionPayload({
stimulusRows: def.stimulus_schema.map((r) => ({ id: r.id, kind: r.kind })), stimulusRows: def.stimulus_schema.map((r) => ({ id: r.id, kind: r.kind })),
responseRows: def.response_schema.map((r) => ({ id: r.id, kind: r.kind })), responseRows: def.response_schema.map((r) => ({ id: r.id, kind: r.kind })),
fieldValues: q.dynamicFieldValues ?? {}, fieldValues,
}) })
return { return {
question_text,
question_type: "DYNAMIC", question_type: "DYNAMIC",
question_type_definition_id: def.id, question_type_definition_id: def.id,
difficulty_level: difficulty, difficulty_level: difficulty,
@ -118,9 +192,7 @@ export function buildCreateQuestionFromDefinition(
} }
} }
// No schema and no legacy key mapping: still create as DYNAMIC with empty payload + definition id
return { return {
question_text,
question_type: "DYNAMIC", question_type: "DYNAMIC",
question_type_definition_id: def.id, question_type_definition_id: def.id,
difficulty_level: difficulty, difficulty_level: difficulty,
@ -136,24 +208,36 @@ export function validateDefinitionQuestion(
index1Based: number, index1Based: number,
): string | null { ): string | null {
const n = index1Based const n = index1Based
if (!q.questionText.trim()) return `Question ${n}: enter question text.`
if (definitionUsesDynamicPayload(def)) { if (definitionUsesDynamicPayload(def)) {
const fieldValues = mergePromptIntoDynamicFieldValues(
def,
q.questionText,
q.dynamicFieldValues ?? {},
)
const hasPrompt =
Boolean(q.questionText.trim()) || Boolean(dynamicPromptFromFieldValues(def, fieldValues))
const promptRow = def.stimulus_schema.find((r) => PROMPT_STIMULUS_KINDS.has(r.kind) && r.required)
if (promptRow && !hasPrompt) {
return `Question ${n}: enter prompt text (${promptRow.label || promptRow.id}).`
}
for (const row of def.stimulus_schema) { for (const row of def.stimulus_schema) {
if (!row.required) continue if (!row.required) continue
const v = (q.dynamicFieldValues ?? {})[`stimulus:${row.id}`]?.trim() const v = fieldValues[`stimulus:${row.id}`]?.trim()
if (!v) if (!v)
return `Question ${n}: fill required stimulus "${row.label || row.id}" (${row.kind}).` return `Question ${n}: fill required stimulus "${row.label || row.id}" (${row.kind}).`
} }
for (const row of def.response_schema) { for (const row of def.response_schema) {
if (!row.required) continue if (!row.required) continue
const v = (q.dynamicFieldValues ?? {})[`response:${row.id}`]?.trim() const v = fieldValues[`response:${row.id}`]?.trim()
if (!v) if (!v)
return `Question ${n}: fill required response "${row.label || row.id}" (${row.kind}).` return `Question ${n}: fill required response "${row.label || row.id}" (${row.kind}).`
} }
return null return null
} }
if (!q.questionText.trim()) return `Question ${n}: enter question text.`
const legacy = legacyQuestionTypeFromDefinition(def) const legacy = legacyQuestionTypeFromDefinition(def)
if (legacy === "MCQ") { if (legacy === "MCQ") {
const opts = (q.mcqOptions ?? []).filter((o) => o.option_text.trim()) const opts = (q.mcqOptions ?? []).filter((o) => o.option_text.trim())

View File

@ -9,6 +9,7 @@ import type { PracticeParentKind } from "../types/course.types"
import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types" import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
import { import {
buildCreateQuestionFromDefinition, buildCreateQuestionFromDefinition,
questionRowHasContent,
validateDefinitionQuestion, validateDefinitionQuestion,
type LearnEnglishDefinitionQuestionInput, type LearnEnglishDefinitionQuestionInput,
} from "./learnEnglishDefinitionQuestion" } from "./learnEnglishDefinitionQuestion"
@ -30,9 +31,12 @@ export function validateLearnEnglishQuestionsWithDefinitions(
questions: LearnEnglishDefinitionQuestionInput[], questions: LearnEnglishDefinitionQuestionInput[],
definitions: QuestionTypeDefinition[], definitions: QuestionTypeDefinition[],
): string | null { ): string | null {
const filled = questions.filter((q) => q.questionText.trim())
if (filled.length === 0) return "Add at least one question with prompt text."
const byId = new Map(definitions.map((d) => [d.id, d])) const byId = new Map(definitions.map((d) => [d.id, d]))
const filled = questions.filter((q) => {
const def = byId.get(q.questionTypeDefinitionId)
return def ? questionRowHasContent(q, def) : false
})
if (filled.length === 0) return "Add at least one question with content."
for (let i = 0; i < filled.length; i++) { for (let i = 0; i < filled.length; i++) {
const q = filled[i] const q = filled[i]
if (!Number.isFinite(q.questionTypeDefinitionId) || q.questionTypeDefinitionId <= 0) { if (!Number.isFinite(q.questionTypeDefinitionId) || q.questionTypeDefinitionId <= 0) {
@ -100,7 +104,10 @@ export async function executeLearnEnglishPracticeCreation(opts: {
) )
} }
const toCreate = opts.questions.filter((q) => q.questionText.trim()) const toCreate = opts.questions.filter((q) => {
const def = byId.get(q.questionTypeDefinitionId)
return def ? questionRowHasContent(q, def) : false
})
let displayOrder = 0 let displayOrder = 0
for (const q of toCreate) { for (const q of toCreate) {
const def = byId.get(q.questionTypeDefinitionId) const def = byId.get(q.questionTypeDefinitionId)

View File

@ -0,0 +1,6 @@
/** Standard page-size choices for admin data tables. */
export const TABLE_PAGE_SIZE_OPTIONS = [5, 10, 30, 50, 100] as const
export type TablePageSize = (typeof TABLE_PAGE_SIZE_OPTIONS)[number]
export const DEFAULT_TABLE_PAGE_SIZE: TablePageSize = 10

View File

@ -373,30 +373,34 @@ export function AddNewPracticePage() {
}) })
: undefined; : undefined;
const qRes = await createQuestion({ const qRes = await createQuestion(
question_text: q.questionText, q.questionType === "DYNAMIC" && q.questionTypeDefinitionId != null && dynamicPayload
question_type: q.questionType,
difficulty_level: q.difficultyLevel,
points: q.points,
tips: q.tips || undefined,
explanation: q.explanation || undefined,
status,
options: options.length > 0 ? options : undefined,
voice_prompt: q.questionType === "DYNAMIC" ? undefined : q.voicePrompt || undefined,
sample_answer_voice_prompt:
q.questionType === "DYNAMIC" ? undefined : q.sampleAnswerVoicePrompt || undefined,
audio_correct_answer_text:
q.questionType === "DYNAMIC" ? undefined : q.audioCorrectAnswerText || undefined,
image_url: q.questionType === "DYNAMIC" ? undefined : q.imageUrl.trim() || undefined,
short_answers:
q.questionType !== "DYNAMIC" && q.shortAnswers.length > 0 ? q.shortAnswers : undefined,
...(q.questionType === "DYNAMIC" && q.questionTypeDefinitionId != null && dynamicPayload
? { ? {
question_type: "DYNAMIC",
question_type_definition_id: q.questionTypeDefinitionId, question_type_definition_id: q.questionTypeDefinitionId,
dynamic_payload: dynamicPayload, dynamic_payload: dynamicPayload,
difficulty_level: q.difficultyLevel,
points: q.points,
tips: q.tips || undefined,
explanation: q.explanation || undefined,
status,
} }
: {}), : {
}); question_text: q.questionText,
question_type: q.questionType,
difficulty_level: q.difficultyLevel,
points: q.points,
tips: q.tips || undefined,
explanation: q.explanation || undefined,
status,
options: options.length > 0 ? options : undefined,
voice_prompt: q.voicePrompt || undefined,
sample_answer_voice_prompt: q.sampleAnswerVoicePrompt || undefined,
audio_correct_answer_text: q.audioCorrectAnswerText || undefined,
image_url: q.imageUrl.trim() || undefined,
short_answers: q.shortAnswers.length > 0 ? q.shortAnswers : undefined,
},
);
const questionId = qRes.data?.data?.id; const questionId = qRes.data?.data?.id;
if (questionId) { if (questionId) {

View File

@ -216,9 +216,7 @@ export function AddPracticeFlow() {
return; return;
} }
const persona = personaFromId(selectedPersona, personas); const persona = personaFromId(selectedPersona, personas);
const mappedQuestions = formData.questions const mappedQuestions = formData.questions.map((q) => ({
.filter((q) => String(q.text ?? "").trim())
.map((q) => ({
questionText: String(q.text ?? "").trim(), questionText: String(q.text ?? "").trim(),
questionTypeDefinitionId: Number(q.questionTypeDefinitionId), questionTypeDefinitionId: Number(q.questionTypeDefinitionId),
dynamicFieldValues: { ...(q.dynamicFieldValues ?? {}) }, dynamicFieldValues: { ...(q.dynamicFieldValues ?? {}) },
@ -372,6 +370,7 @@ export function AddPracticeFlow() {
onCancel={() => navigate(backPath)} onCancel={() => navigate(backPath)}
isLessonPractice={isLessonPractice} isLessonPractice={isLessonPractice}
lessonTitle={lessonTitleDisplay} lessonTitle={lessonTitleDisplay}
parentSummary={parentSummary}
/> />
); );
case 2: case 2:
@ -515,9 +514,7 @@ export function AddPracticeFlow() {
</Button> </Button>
</div> </div>
<p className="text-grayScale-400 text-base"> <p className="text-grayScale-400 text-base">
Create a practice: question types from{" "} Create a practice with story details, a persona, and questions from your question type library.
<code className="text-xs">GET /questions/type-definitions</code>, then
question set and POST /practices.
</p> </p>
{lessonId ? ( {lessonId ? (
<div className="mt-4 rounded-xl border border-violet-200 bg-violet-50/80 px-4 py-3 text-sm text-violet-950"> <div className="mt-4 rounded-xl border border-violet-200 bg-violet-50/80 px-4 py-3 text-sm text-violet-950">

View File

@ -268,7 +268,7 @@ export function AddQuestionPage() {
return return
} }
} catch { } catch {
toast.error("Invalid JSON", { description: "Fix dynamic_payload JSON before saving." }) toast.error("Invalid JSON", { description: "Fix the dynamic content JSON before saving." })
return return
} }
} }
@ -419,7 +419,7 @@ export function AddQuestionPage() {
</div> </div>
<div> <div>
<label className="mb-1 block text-xs font-medium text-grayScale-600"> <label className="mb-1 block text-xs font-medium text-grayScale-600">
dynamic_payload (JSON) <span className="text-red-500">*</span> Dynamic content (JSON) <span className="text-red-500">*</span>
</label> </label>
<Textarea <Textarea
value={formData.dynamicPayloadJson} value={formData.dynamicPayloadJson}

View File

@ -28,6 +28,7 @@ import {
import { Textarea } from "../../components/ui/textarea" import { Textarea } from "../../components/ui/textarea"
import { toast } from "sonner" import { toast } from "sonner"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg" import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
type CourseWithCategory = Course & { category_name: string } type CourseWithCategory = Course & { category_name: string }
@ -422,7 +423,7 @@ export function AllCoursesPage() {
}} }}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none" className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
> >
{[10, 20, 50].map((size) => ( {TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}> <option key={size} value={size}>
{size} {size}
</option> </option>

View File

@ -427,11 +427,7 @@ export function CourseDetailPage() {
<DialogHeader className="shrink-0 space-y-1.5 border-b border-grayScale-100 px-6 pb-4 pt-6 pr-12"> <DialogHeader className="shrink-0 space-y-1.5 border-b border-grayScale-100 px-6 pb-4 pt-6 pr-12">
<DialogTitle>Edit module</DialogTitle> <DialogTitle>Edit module</DialogTitle>
<DialogDescription> <DialogDescription>
Update name, sort order, and icon (upload or URL). Saved with{" "} Update name, sort order, and icon (upload or URL).
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
PUT /modules/:id
</code>
.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4"> <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4">

View File

@ -39,6 +39,7 @@ import {
} from "../../api/courses.api" } from "../../api/courses.api"
import type { CategorySubCategoryListItem, CourseCategory } from "../../types/course.types" import type { CategorySubCategoryListItem, CourseCategory } from "../../types/course.types"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
export function CoursesPage() { export function CoursesPage() {
const { categoryId } = useParams<{ categoryId: string }>() const { categoryId } = useParams<{ categoryId: string }>()
@ -513,7 +514,7 @@ export function CoursesPage() {
}} }}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none" className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
> >
{[10, 20, 50].map((size) => ( {TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}> <option key={size} value={size}>
{size} {size}
</option> </option>

View File

@ -20,6 +20,7 @@ import type {
} from "../../types/questionTypeDefinition.types" } from "../../types/questionTypeDefinition.types"
import { import {
buildCreatePayload, buildCreatePayload,
buildValidateKindsPayload,
validateDefinitionBasic, validateDefinitionBasic,
validateDefinitionKinds, validateDefinitionKinds,
validateDefinitionSchemas, validateDefinitionSchemas,
@ -75,9 +76,9 @@ export function CreateQuestionTypeFlow() {
const [currentStep, setCurrentStep] = useState(1) const [currentStep, setCurrentStep] = useState(1)
const [draft, setDraft] = useState<QuestionTypeDefinitionCreatePayload>(initialDraft) const [draft, setDraft] = useState<QuestionTypeDefinitionCreatePayload>(initialDraft)
const [versionName, setVersionName] = useState("Test 1")
const [stepErrors, setStepErrors] = useState<FieldErrorMap>({}) const [stepErrors, setStepErrors] = useState<FieldErrorMap>({})
const [definitionReady, setDefinitionReady] = useState(!isEdit) const [definitionReady, setDefinitionReady] = useState(!isEdit)
const [isSystemDefinition, setIsSystemDefinition] = useState(false)
const [componentCatalog, setComponentCatalog] = useState<QuestionComponentCatalog>({ const [componentCatalog, setComponentCatalog] = useState<QuestionComponentCatalog>({
stimulus_component_kinds: [], stimulus_component_kinds: [],
@ -134,7 +135,7 @@ export function CreateQuestionTypeFlow() {
return return
} }
setDraft(definitionToDraft(def)) setDraft(definitionToDraft(def))
setVersionName("Test 1") setIsSystemDefinition(Boolean(def.is_system))
setCurrentStep(1) setCurrentStep(1)
setStepErrors({}) setStepErrors({})
} catch (e) { } catch (e) {
@ -155,7 +156,6 @@ export function CreateQuestionTypeFlow() {
useEffect(() => { useEffect(() => {
if (!isEdit) { if (!isEdit) {
setDraft(initialDraft()) setDraft(initialDraft())
setVersionName("Test 1")
setCurrentStep(1) setCurrentStep(1)
setStepErrors({}) setStepErrors({})
setDefinitionReady(true) setDefinitionReady(true)
@ -179,15 +179,10 @@ export function CreateQuestionTypeFlow() {
} }
const handleNextFromStep2 = () => { const handleNextFromStep2 = () => {
const versionErr: FieldErrorMap = {}
if (!versionName.trim()) {
versionErr.version_name = "Version name is required."
}
const eKinds = validateDefinitionKinds(draft, componentCatalog) const eKinds = validateDefinitionKinds(draft, componentCatalog)
const mergedKinds = { ...versionErr, ...eKinds } setStepErrors(eKinds)
setStepErrors(mergedKinds) if (Object.keys(eKinds).length) {
if (Object.keys(mergedKinds).length) { toast.error("Select valid stimulus and response component kinds.")
toast.error("Complete version name and component selections.")
return return
} }
@ -233,7 +228,7 @@ export function CreateQuestionTypeFlow() {
navigate(`/new-content/question-types?updated=${id}`) navigate(`/new-content/question-types?updated=${id}`)
return return
} }
const validation = await validateQuestionTypeDefinition(body) const validation = await validateQuestionTypeDefinition(buildValidateKindsPayload(body))
if (!validation.valid) { if (!validation.valid) {
toast.error(validation.message || "Invalid question type definition", { toast.error(validation.message || "Invalid question type definition", {
description: validation.error ? String(validation.error) : undefined, description: validation.error ? String(validation.error) : undefined,
@ -290,20 +285,9 @@ export function CreateQuestionTypeFlow() {
{isEdit ? "Edit question type definition" : "Create question type definition"} {isEdit ? "Edit question type definition" : "Create question type definition"}
</h1> </h1>
<p className="text-grayScale-500 text-[14px] font-medium max-w-2xl"> <p className="text-grayScale-500 text-[14px] font-medium max-w-2xl">
{isEdit ? ( {isEdit
<> ? `Update reusable question type definition #${editDefinitionId}.`
Update definition{" "} : "Build a reusable question type template for dynamic practice and assessment questions."}
<code className="text-xs bg-grayScale-100 px-1 rounded">#{editDefinitionId}</code> via{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">PUT /questions/type-definitions/:id</code>
.
</>
) : (
<>
Build a reusable dynamic question type (schema + kinds) for{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">DYNAMIC</code> questions. Data is sent to{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">POST /questions/type-definitions</code>.
</>
)}
</p> </p>
</div> </div>
<div className="flex items-center gap-4 shrink-0"> <div className="flex items-center gap-4 shrink-0">
@ -343,8 +327,6 @@ export function CreateQuestionTypeFlow() {
<QuestionTypeConfigStep <QuestionTypeConfigStep
draft={draft} draft={draft}
setDraft={setDraft} setDraft={setDraft}
versionName={versionName}
setVersionName={setVersionName}
stimulusCatalogKinds={componentCatalog.stimulus_component_kinds} stimulusCatalogKinds={componentCatalog.stimulus_component_kinds}
responseCatalogKinds={componentCatalog.response_component_kinds} responseCatalogKinds={componentCatalog.response_component_kinds}
catalogLoading={catalogLoading} catalogLoading={catalogLoading}
@ -358,7 +340,12 @@ export function CreateQuestionTypeFlow() {
<QuestionTypeValidatePreviewStep draft={draft} onNext={() => setCurrentStep(4)} onBack={handleBack} /> <QuestionTypeValidatePreviewStep draft={draft} onNext={() => setCurrentStep(4)} onBack={handleBack} />
)} )}
{currentStep === 4 && ( {currentStep === 4 && (
<QuestionTypeReviewPublishStep draft={draft} onBack={handleBack} editDefinitionId={editDefinitionId} /> <QuestionTypeReviewPublishStep
draft={draft}
onBack={handleBack}
editDefinitionId={editDefinitionId}
isSystem={isSystemDefinition}
/>
)} )}
</div> </div>
</div> </div>

View File

@ -787,7 +787,7 @@ export function HumanLanguageHierarchyPage() {
<div> <div>
<p className="text-sm font-medium text-grayScale-800">Select a sub-category to start managing hierarchy</p> <p className="text-sm font-medium text-grayScale-800">Select a sub-category to start managing hierarchy</p>
<p className="mt-1 text-sm text-grayScale-500"> <p className="mt-1 text-sm text-grayScale-500">
Powered by `GET /course-management/human-language/hierarchy` and `GET /course-management/courses/:courseId/hierarchy`. Choose a sub-category from the list to view and manage its course structure.
</p> </p>
</div> </div>
</CardContent> </CardContent>
@ -1019,7 +1019,7 @@ export function HumanLanguageHierarchyPage() {
<DialogHeader> <DialogHeader>
<DialogTitle>Create module</DialogTitle> <DialogTitle>Create module</DialogTitle>
<DialogDescription> <DialogDescription>
Add a module to this level. This will call `POST /course-management/modules`. Add a module to this level.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -1140,7 +1140,7 @@ export function HumanLanguageHierarchyPage() {
<DialogHeader> <DialogHeader>
<DialogTitle>Update module</DialogTitle> <DialogTitle>Update module</DialogTitle>
<DialogDescription> <DialogDescription>
Update this module using `PUT /course-management/modules/:moduleId`. Update this module&apos;s name, order, and settings.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>

View File

@ -1029,7 +1029,7 @@ export function HumanLanguageSubModulePage() {
<DialogHeader> <DialogHeader>
<DialogTitle>Lesson detail</DialogTitle> <DialogTitle>Lesson detail</DialogTitle>
<DialogDescription> <DialogDescription>
Loaded from `GET /course-management/sub-module-lessons/:lessonId`. View and edit lesson details.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>

View File

@ -356,15 +356,8 @@ export function LearnEnglishPage() {
Add New Program Add New Program
</DialogTitle> </DialogTitle>
<DialogDescription className="text-sm text-grayScale-400"> <DialogDescription className="text-sm text-grayScale-400">
Create a learning program via{" "} Create a new learning program. Add a thumbnail as an image URL or by uploading a
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600"> file.
POST /programs
</code>
. Thumbnail can be a URL or a file uploaded through{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
POST /files/upload
</code>
.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{/* Gradient Divider */} {/* Gradient Divider */}
@ -739,11 +732,7 @@ export function LearnEnglishPage() {
disabled={savingEdit || uploadingEditThumbnail} disabled={savingEdit || uploadingEditThumbnail}
/> />
<p className="text-xs text-grayScale-500"> <p className="text-xs text-grayScale-500">
Local images are sent to{" "} Uploaded images are stored and used as the program thumbnail.
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
POST /files/upload
</code>
; the returned URL is stored as the program thumbnail.
</p> </p>
</div> </div>
</div> </div>

View File

@ -364,12 +364,6 @@ export function LessonPracticesPage() {
total={practices.length} total={practices.length}
/> />
))} ))}
<p className="px-1 text-center text-[11px] text-grayScale-400">
Source:{" "}
<code className="rounded-md bg-grayScale-100 px-1.5 py-0.5 font-mono text-[10px] text-grayScale-500">
GET /lessons/{lid}/practices
</code>
</p>
</div> </div>
)} )}
</div> </div>

View File

@ -668,15 +668,7 @@ export function ModuleDetailPage() {
<DialogHeader> <DialogHeader>
<DialogTitle>Edit lesson</DialogTitle> <DialogTitle>Edit lesson</DialogTitle>
<DialogDescription> <DialogDescription>
Update details. Video and thumbnail files use{" "} Update lesson details. Uploaded video and thumbnail files are stored automatically.
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
POST /files/upload
</code>
; the form is saved with{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
PUT /lessons/:id
</code>
.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-2"> <div className="grid gap-4 py-2">

View File

@ -1093,9 +1093,6 @@ export function PracticeDetailsPage() {
placeholder="Optional" placeholder="Optional"
/> />
</div> </div>
<p className="text-xs text-grayScale-500">
Uses <span className="font-mono">PUT /practices/&#123;id&#125;</span> with the fields above.
</p>
</div> </div>
<DialogFooter className="gap-2 sm:gap-0"> <DialogFooter className="gap-2 sm:gap-0">
<Button type="button" variant="outline" onClick={() => setEditOpen(false)} disabled={savePracticeLoading}> <Button type="button" variant="outline" onClick={() => setEditOpen(false)} disabled={savePracticeLoading}>
@ -1115,9 +1112,8 @@ export function PracticeDetailsPage() {
<DialogTitle>Delete this practice?</DialogTitle> <DialogTitle>Delete this practice?</DialogTitle>
</DialogHeader> </DialogHeader>
<p className="text-sm text-grayScale-600"> <p className="text-sm text-grayScale-600">
This will call <span className="font-mono">DELETE /practices/&#123;id&#125;</span> and remove the practice This permanently removes the practice for this {parentTabCopy[parentTab].label.toLowerCase()}. The linked
for this {parentTabCopy[parentTab].label.toLowerCase()}. The question set is not deleted unless your API question set may remain unless you remove it separately.
cascades.
</p> </p>
<DialogFooter className="gap-2 sm:gap-0"> <DialogFooter className="gap-2 sm:gap-0">
<Button <Button

View File

@ -20,6 +20,7 @@ import { Input } from "../../components/ui/input"
import { Select } from "../../components/ui/select" 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"
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO" type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO"
type DifficultyLevel = "EASY" | "MEDIUM" | "HARD" type DifficultyLevel = "EASY" | "MEDIUM" | "HARD"
@ -84,7 +85,7 @@ 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 [pageSize, setPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
const [currentPage, setCurrentPage] = useState(1) const [currentPage, setCurrentPage] = useState(1)
const [totalQuestions, setTotalQuestions] = useState(0) const [totalQuestions, setTotalQuestions] = useState(0)
@ -736,29 +737,56 @@ export function PracticeQuestionsPage() {
))} ))}
</div> </div>
))} ))}
{totalQuestions > pageSize && ( {totalQuestions > 0 && (
<div className="flex items-center justify-between rounded-xl border border-grayScale-200 bg-white px-4 py-3"> <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-grayScale-200 bg-white px-4 py-3 text-sm text-grayScale-500">
<p className="text-sm text-grayScale-500"> <div className="flex flex-wrap items-center gap-2">
Page {currentPage} of {Math.max(1, Math.ceil(totalQuestions / pageSize))} <span>
</p> Page {currentPage} of {Math.max(1, Math.ceil(totalQuestions / pageSize))} ({totalQuestions}{" "}
<div className="flex items-center gap-2"> total)
<Button </span>
variant="outline" <span className="hidden h-4 w-px bg-grayScale-200 sm:inline" />
size="sm" <span className="flex items-center gap-2">
disabled={currentPage <= 1} Rows per page
onClick={() => void fetchQuestions(currentPage - 1)} <div className="relative">
> <select
Previous value={pageSize}
</Button> onChange={(e) => {
<Button setPageSize(Number(e.target.value))
variant="outline" setCurrentPage(1)
size="sm" void fetchQuestions(1)
disabled={currentPage >= Math.ceil(totalQuestions / pageSize)} }}
onClick={() => void fetchQuestions(currentPage + 1)} className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
> >
Next {TABLE_PAGE_SIZE_OPTIONS.map((size) => (
</Button> <option key={size} value={size}>
{size}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" />
</div>
</span>
</div> </div>
{totalQuestions > pageSize ? (
<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>
) : null}
</div> </div>
)} )}
</div> </div>

View File

@ -379,15 +379,8 @@ export function ProgramCoursesPage() {
Add New Course Add New Course
</DialogTitle> </DialogTitle>
<DialogDescription className="text-sm text-grayScale-400"> <DialogDescription className="text-sm text-grayScale-400">
Create a course via{" "} Add a new course to this program. Use an image URL or upload a file for the
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600"> thumbnail.
POST /programs/:program_id/courses
</code>
. Thumbnail can be a URL or a file from{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
POST /files/upload
</code>
.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -708,11 +701,7 @@ export function ProgramCoursesPage() {
<DialogHeader className="shrink-0 space-y-1.5 border-b border-grayScale-100 px-6 pb-4 pt-6 pr-12"> <DialogHeader className="shrink-0 space-y-1.5 border-b border-grayScale-100 px-6 pb-4 pt-6 pr-12">
<DialogTitle>Edit course</DialogTitle> <DialogTitle>Edit course</DialogTitle>
<DialogDescription> <DialogDescription>
Update name, sort order, and thumbnail. Saved with{" "} Update name, sort order, and thumbnail.
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
PUT /courses/:id
</code>
.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4"> <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4">

View File

@ -124,9 +124,7 @@ export function QuestionTypeLibraryPage() {
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-[32px] font-medium text-grayScale-900 tracking-tight">Question type definitions</h1> <h1 className="text-[32px] font-medium text-grayScale-900 tracking-tight">Question type definitions</h1>
<p className="text-grayScale-500 text-[16px] font-medium max-w-2xl"> <p className="text-grayScale-500 text-[16px] font-medium max-w-2xl">
Reusable dynamic question type templates from{" "} Reusable templates that define how practice and assessment questions are structured and answered.
<code className="text-xs bg-grayScale-100 px-1 rounded">GET /questions/type-definitions</code>. Use them
when authoring <code className="text-xs bg-grayScale-100 px-1 rounded">DYNAMIC</code> questions.
</p> </p>
</div> </div>
<Link to="/new-content/question-types/create"> <Link to="/new-content/question-types/create">

View File

@ -19,6 +19,7 @@ import { Badge } from "../../components/ui/badge"
import { deleteQuestion, getQuestionById, getQuestions, updateQuestion } from "../../api/courses.api" import { deleteQuestion, getQuestionById, getQuestions, updateQuestion } from "../../api/courses.api"
import type { QuestionDetail } from "../../types/course.types" import type { QuestionDetail } from "../../types/course.types"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
type QuestionTypeFilter = "all" | "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO" type QuestionTypeFilter = "all" | "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO"
type DifficultyFilter = "all" | "EASY" | "MEDIUM" | "HARD" type DifficultyFilter = "all" | "EASY" | "MEDIUM" | "HARD"
@ -558,7 +559,7 @@ export function QuestionsPage() {
}} }}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none" className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
> >
{[10, 20, 50].map((size) => ( {TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}> <option key={size} value={size}>
{size} {size}
</option> </option>

View File

@ -30,6 +30,7 @@ import {
} from "../../components/ui/dropdown-menu" } from "../../components/ui/dropdown-menu"
import type { Course, CourseCategory, QuestionDetail, QuestionSet, SubCourse } from "../../types/course.types" import type { Course, CourseCategory, QuestionDetail, QuestionSet, SubCourse } from "../../types/course.types"
import { toast } from "sonner" import { toast } from "sonner"
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
const MAX_AUDIO_SIZE_BYTES = 50 * 1024 * 1024 const MAX_AUDIO_SIZE_BYTES = 50 * 1024 * 1024
const ALLOWED_AUDIO_EXTENSIONS = new Set(["mp3", "wav", "ogg", "m4a", "aac", "webm", "flac"]) const ALLOWED_AUDIO_EXTENSIONS = new Set(["mp3", "wav", "ogg", "m4a", "aac", "webm", "flac"])
@ -149,7 +150,7 @@ export function SpeakingPage() {
const [audioQuestions, setAudioQuestions] = useState<AudioListQuestion[]>([]) const [audioQuestions, setAudioQuestions] = useState<AudioListQuestion[]>([])
const [audioTotalCount, setAudioTotalCount] = useState(0) const [audioTotalCount, setAudioTotalCount] = useState(0)
const [audioPage, setAudioPage] = useState(1) const [audioPage, setAudioPage] = useState(1)
const [audioPageSize] = useState(12) const [audioPageSize, setAudioPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
const [practiceOptions, setPracticeOptions] = useState<PracticeFilterOption[]>([]) const [practiceOptions, setPracticeOptions] = useState<PracticeFilterOption[]>([])
const [selectedPracticeId, setSelectedPracticeId] = useState<string>("") const [selectedPracticeId, setSelectedPracticeId] = useState<string>("")
const [practiceFilterOpen, setPracticeFilterOpen] = useState(false) const [practiceFilterOpen, setPracticeFilterOpen] = useState(false)
@ -1510,31 +1511,58 @@ export function SpeakingPage() {
))} ))}
</div> </div>
))} ))}
{audioTotalCount > audioPageSize ? ( {audioTotalCount > 0 ? (
<div className="mt-4 flex items-center justify-between rounded-xl border border-grayScale-200 bg-white px-3 py-2"> <div className="mt-4 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-grayScale-200 bg-white px-3 py-2 text-xs text-grayScale-500 sm:text-sm">
<p className="text-xs text-grayScale-500 sm:text-sm"> <div className="flex flex-wrap items-center gap-2">
Page {audioPage} of {Math.max(1, Math.ceil(audioTotalCount / audioPageSize))} <span>
</p> Page {audioPage} of {Math.max(1, Math.ceil(audioTotalCount / audioPageSize))} (
<div className="flex items-center gap-2"> {audioTotalCount} total)
<Button </span>
type="button" <span className="hidden h-4 w-px bg-grayScale-200 sm:inline" />
variant="outline" <span className="flex items-center gap-2">
size="sm" Rows per page
disabled={audioPage <= 1 || loading} <div className="relative">
onClick={() => fetchAudioQuestions(audioPage - 1)} <select
> value={audioPageSize}
Previous disabled={loading}
</Button> onChange={(e) => {
<Button setAudioPageSize(Number(e.target.value))
type="button" void fetchAudioQuestions(1)
variant="outline" }}
size="sm" className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
disabled={audioPage >= Math.ceil(audioTotalCount / audioPageSize) || loading} >
onClick={() => fetchAudioQuestions(audioPage + 1)} {TABLE_PAGE_SIZE_OPTIONS.map((size) => (
> <option key={size} value={size}>
Next {size}
</Button> </option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" />
</div>
</span>
</div> </div>
{audioTotalCount > audioPageSize ? (
<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>
) : null}
</div> </div>
) : null} ) : null}
</div> </div>

View File

@ -111,11 +111,7 @@ export function AddModuleModal({
Add New Module Add New Module
</DialogTitle> </DialogTitle>
<DialogDescription className="text-sm text-grayScale-400"> <DialogDescription className="text-sm text-grayScale-400">
Create a module with{" "} Add a new module to this course.
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
POST /courses/:courseId/modules
</code>
.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>

View File

@ -265,8 +265,7 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
/> />
</div> </div>
<p className="text-xs text-grayScale-500"> <p className="text-xs text-grayScale-500">
This calls <span className="font-mono">POST /question-sets</span> with{" "} Creates a practice question set for this course, module, or lesson.
<span className="font-mono">set_type: PRACTICE</span>.
</p> </p>
<Button type="button" onClick={handleStep1} disabled={saving}> <Button type="button" onClick={handleStep1} disabled={saving}>
{saving ? <SpinnerIcon className="h-4 w-4" /> : null} {saving ? <SpinnerIcon className="h-4 w-4" /> : null}
@ -278,9 +277,7 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
{canUseWizard && step === 2 && ( {canUseWizard && step === 2 && (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-grayScale-600"> <p className="text-sm text-grayScale-600">
Set id <span className="font-mono font-medium text-grayScale-800">#{questionSetId}</span> add Add one or more audio questions to question set #{questionSetId}.
one or more <strong>AUDIO</strong> questions. Each is created via{" "}
<span className="font-mono">POST /questions</span>.
</p> </p>
{questionRows.map((row, idx) => ( {questionRows.map((row, idx) => (
<div <div
@ -389,8 +386,7 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
{canUseWizard && step === 3 && ( {canUseWizard && step === 3 && (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-grayScale-600"> <p className="text-sm text-grayScale-600">
Link each question to the set with a display order using{" "} Confirm the order of questions in the set.
<span className="font-mono">POST /question-sets/&#123;id&#125;/questions</span>.
</p> </p>
<ul className="space-y-2 rounded-xl border border-grayScale-200 bg-white p-3"> <ul className="space-y-2 rounded-xl border border-grayScale-200 bg-white p-3">
{createdQuestionIds.map((qid, i) => ( {createdQuestionIds.map((qid, i) => (
@ -422,12 +418,7 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
{canUseWizard && step === 4 && parent && ( {canUseWizard && step === 4 && parent && (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-grayScale-600"> <p className="text-sm text-grayScale-600">
Parent:{" "} Linked to {parent.kind.toLowerCase()} #{parent.id} · question set #{questionSetId}
<span className="font-mono text-xs">
{parent.kind} #{parent.id}
</span>{" "}
· question set <span className="font-mono">#{questionSetId}</span> ·{" "}
<span className="font-mono">POST /practices</span>
</p> </p>
<div> <div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wider text-grayScale-500"> <p className="mb-1.5 text-xs font-semibold uppercase tracking-wider text-grayScale-500">

View File

@ -15,6 +15,7 @@ interface ContextStepProps {
/** Lesson-linked practice: no title, story description, or story image on step 1. */ /** Lesson-linked practice: no title, story description, or story image on step 1. */
isLessonPractice?: boolean; isLessonPractice?: boolean;
lessonTitle?: string | null; lessonTitle?: string | null;
parentSummary?: string | null;
} }
/** /**
@ -27,6 +28,7 @@ export function ContextStep({
onCancel, onCancel,
isLessonPractice = false, isLessonPractice = false,
lessonTitle = null, lessonTitle = null,
parentSummary = null,
}: ContextStepProps) { }: ContextStepProps) {
const storyFileRef = useRef<HTMLInputElement>(null); const storyFileRef = useRef<HTMLInputElement>(null);
const [uploadingStory, setUploadingStory] = useState(false); const [uploadingStory, setUploadingStory] = useState(false);
@ -62,16 +64,15 @@ export function ContextStep({
<p className="text-grayScale-600 text-base mt-3"> <p className="text-grayScale-600 text-base mt-3">
{isLessonPractice ? ( {isLessonPractice ? (
<> <>
This practice is linked to{" "} Story fields and question set options used when saving the practice. Linked to{" "}
<span className="font-medium text-grayScale-800"> <span className="font-medium text-grayScale-800">
{lessonTitle?.trim() || "the selected lesson"} {lessonTitle?.trim() || "the selected lesson"}
</span> </span>
. Set optional quick tips and question order below. .
</> </>
) : ( ) : (
<> <>
Title, story, optional image, shuffle, and quick tips match the create Story fields and question set options used when saving the practice.
practice and question set APIs.
</> </>
)} )}
</p> </p>
@ -90,6 +91,16 @@ export function ContextStep({
</div> </div>
<div className="space-y-8 p-10"> <div className="space-y-8 p-10">
{parentSummary ? (
<div className="rounded-xl border border-brand-100 bg-brand-50/50 px-4 py-3 text-sm text-grayScale-800">
<p className="font-semibold text-brand-700">LMS parent</p>
<p className="mt-1">{parentSummary}</p>
<p className="mt-1 text-xs text-grayScale-500">
The question set and practice will be linked to this course, module, or lesson.
</p>
</div>
) : null}
{!isLessonPractice ? ( {!isLessonPractice ? (
<> <>
<div className="space-y-2"> <div className="space-y-2">
@ -132,7 +143,7 @@ export function ContextStep({
onChange={(e) => onChange={(e) =>
setFormData({ ...formData, tips: e.target.value }) setFormData({ ...formData, tips: e.target.value })
} }
placeholder="Learner-facing tips (quick_tips on POST /practices)" placeholder="Optional tips shown to learners before they start"
className="min-h-[80px] rounded-xl border-grayScale-200" className="min-h-[80px] rounded-xl border-grayScale-200"
maxLength={1000} maxLength={1000}
/> />

View File

@ -15,8 +15,7 @@ export function PublishStatusField({ value, onChange, disabled, className }: Pro
Publish status <span className="text-red-500">*</span> Publish status <span className="text-red-500">*</span>
</p> </p>
<p className="text-xs text-grayScale-500"> <p className="text-xs text-grayScale-500">
Sent as <code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">publish_status</code> on{" "} Controls whether learners can see this practice after you save.
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">POST /practices</code>.
</p> </p>
<div className="flex flex-wrap gap-3" role="radiogroup" aria-label="Publish status"> <div className="flex flex-wrap gap-3" role="radiogroup" aria-label="Publish status">
{( {(

View File

@ -10,6 +10,8 @@ import {
emptyDynamicFieldValuesForDefinition, emptyDynamicFieldValuesForDefinition,
legacyQuestionTypeFromDefinition, legacyQuestionTypeFromDefinition,
} from "../../../../lib/learnEnglishDefinitionQuestion"; } from "../../../../lib/learnEnglishDefinitionQuestion";
import { validateLearnEnglishQuestionsWithDefinitions } from "../../../../lib/learnEnglishPracticePublish";
import { toast } from "sonner";
function defaultMcqOptions() { function defaultMcqOptions() {
return [ return [
@ -98,9 +100,9 @@ export function QuestionsStep({
return ( return (
<div className="space-y-3 rounded-lg border border-violet-200 bg-violet-50/40 p-3"> <div className="space-y-3 rounded-lg border border-violet-200 bg-violet-50/40 p-3">
<p className="text-xs leading-snug text-grayScale-600"> <p className="text-xs leading-snug text-grayScale-600">
<span className="font-medium text-grayScale-800">Image / Audio</span> slots use upload or URL import ( <span className="font-medium text-grayScale-800">Image / Audio / PDF</span> use upload or URL.{" "}
<code className="rounded bg-white px-0.5 text-[11px]">POST /files/upload</code> <span className="font-medium text-grayScale-800">Table</span> uses the visual table builder. Other
). Others: URL, text, or JSON. slots: text or structured JSON where noted.
</p> </p>
{def.stimulus_schema.length > 0 ? ( {def.stimulus_schema.length > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
@ -307,11 +309,8 @@ export function QuestionsStep({
<div className="space-y-1 px-2"> <div className="space-y-1 px-2">
<h2 className="text-2xl font-bold text-grayScale-700">Questions</h2> <h2 className="text-2xl font-bold text-grayScale-700">Questions</h2>
<p className="text-grayScale-400 text-lg"> <p className="text-grayScale-400 text-lg">
Question types are loaded from{" "} Choose a question type for each item, then fill in the fields that type requires. Questions are saved
<code className="rounded bg-grayScale-100 px-1 text-sm"> when you publish or save the practice.
GET /questions/type-definitions
</code>
. Pick a type per row, then fill the fields required for that definition.
</p> </p>
</div> </div>
@ -403,21 +402,28 @@ export function QuestionsStep({
) : null} ) : null}
</div> </div>
<div className="space-y-3"> {def && !definitionUsesDynamicPayload(def) ? (
<label className="text-[10px] font-bold uppercase tracking-widest text-grayScale-700"> <div className="space-y-3">
Question text <label className="text-[10px] font-bold uppercase tracking-widest text-grayScale-700">
</label> Question text
<Input </label>
value={q.text} <Input
onChange={(e) => { value={q.text}
const newQuestions = [...formData.questions]; onChange={(e) => {
newQuestions[i].text = e.target.value; const newQuestions = [...formData.questions];
setFormData({ ...formData, questions: newQuestions }); newQuestions[i].text = e.target.value;
}} setFormData({ ...formData, questions: newQuestions });
className="min-h-[52px] rounded-xl border-grayScale-200 px-4 py-3 text-base font-medium text-grayScale-700" }}
placeholder="Question prompt for learners" className="min-h-[52px] rounded-xl border-grayScale-200 px-4 py-3 text-base font-medium text-grayScale-700"
/> placeholder="Question prompt for learners"
</div> />
</div>
) : def && definitionUsesDynamicPayload(def) ? (
<p className="rounded-lg border border-violet-200 bg-violet-50/60 px-3 py-2 text-xs text-violet-950">
Enter the question prompt in the text or instruction field below, along with any other
required content for this type.
</p>
) : null}
{def ? renderTypeSpecificFields(q, i, def) : null} {def ? renderTypeSpecificFields(q, i, def) : null}
</div> </div>
@ -451,7 +457,30 @@ export function QuestionsStep({
</Button> </Button>
<Button <Button
type="button" type="button"
onClick={nextStep} onClick={() => {
const mapped = formData.questions.map((row: typeof formData.questions[0]) => ({
questionText: String(row.text ?? "").trim(),
questionTypeDefinitionId: Number(row.questionTypeDefinitionId),
dynamicFieldValues: { ...(row.dynamicFieldValues ?? {}) },
mcqOptions: (row.mcqOptions ?? []).map(
(o: { text?: string; isCorrect?: boolean }) => ({
option_text: String(o.text ?? "").trim(),
is_correct: Boolean(o.isCorrect),
}),
),
trueFalseAnswerIsTrue: row.trueFalseCorrect !== false,
shortAnswers: (row.shortAnswers ?? []).map((s: string) => String(s)),
}));
const msg = validateLearnEnglishQuestionsWithDefinitions(
mapped,
typeDefinitions,
);
if (msg) {
toast.error("Check your questions", { description: msg });
return;
}
nextStep();
}}
disabled={definitionsLoading || !!definitionsError || typeDefinitions.length === 0} disabled={definitionsLoading || !!definitionsError || typeDefinitions.length === 0}
className="h-10 rounded-[6px] bg-brand-500 px-8 font-bold disabled:opacity-50" className="h-10 rounded-[6px] bg-brand-500 px-8 font-bold disabled:opacity-50"
> >

View File

@ -0,0 +1,43 @@
import { AlertTriangle, Info } from "lucide-react"
import { inferRuntimeQuestionType } from "../../lib/questionTypeDefinitionValidation"
export function DefinitionRuntimeHint({
definitionKey,
responseKinds,
}: {
definitionKey: string
responseKinds: string[]
}) {
const runtime = inferRuntimeQuestionType(definitionKey, responseKinds)
if (runtime == null) {
return (
<div className="flex gap-3 rounded-xl border border-amber-200 bg-amber-50/80 px-4 py-3 text-sm text-amber-900">
<AlertTriangle className="h-5 w-5 shrink-0 text-amber-600" />
<div>
<p className="font-semibold">May not be publishable</p>
<p className="mt-0.5 text-amber-800/90">
The server requires a mappable runtime question type. Add at least one non-timer response
kind (e.g. OPTION, TEXT_INPUT). Timer-only definitions are rejected.
</p>
</div>
</div>
)
}
return (
<div className="flex gap-3 rounded-xl border border-brand-100 bg-brand-50/50 px-4 py-3 text-sm text-grayScale-800">
<Info className="h-5 w-5 shrink-0 text-brand-500" />
<div>
<p className="font-semibold text-grayScale-900">
Stored as: {runtime === "DYNAMIC" ? "dynamic question" : runtime.replace(/_/g, " ").toLowerCase()}
</p>
<p className="mt-0.5 text-grayScale-600">
{runtime === "DYNAMIC"
? "Practice questions built from this type use the dynamic question builder."
: "Questions built from this type use the classic question format for this kind."}
</p>
</div>
</div>
)
}

View File

@ -29,10 +29,8 @@ export function QuestionTypeBasicInfoStep({
<div className="p-10 border-b border-grayScale-200"> <div className="p-10 border-b border-grayScale-200">
<h2 className="text-[20px] font-medium text-grayScale-900">STEP 1: Definition basics</h2> <h2 className="text-[20px] font-medium text-grayScale-900">STEP 1: Definition basics</h2>
<p className="text-grayScale-500 font-medium mt-1"> <p className="text-grayScale-500 font-medium mt-1">
Set the reusable key, display name, and status. On the next step you will pick stimulus and response Set the reusable key, display name, and status. On the next step you will choose how questions are
component types from the live catalog ( presented and how learners answer.
<code className="text-xs bg-grayScale-100 px-1 rounded">GET /questions/component-catalog</code>
).
</p> </p>
</div> </div>

View File

@ -1,12 +1,5 @@
import { useState } from "react" import { useState } from "react"
import { import { ArrowLeft, ArrowRight, ChevronDown, ChevronUp, Plus } from "lucide-react"
ArrowLeft,
ArrowRight,
ChevronDown,
ChevronUp,
Hourglass,
Plus,
} from "lucide-react"
import { Button } from "../../../../components/ui/button" import { Button } from "../../../../components/ui/button"
import { Card } from "../../../../components/ui/card" import { Card } from "../../../../components/ui/card"
import { Input } from "../../../../components/ui/input" import { Input } from "../../../../components/ui/input"
@ -22,8 +15,6 @@ import { getResponseKindPresentation, getStimulusKindPresentation } from "./comp
interface QuestionTypeConfigStepProps { interface QuestionTypeConfigStepProps {
draft: QuestionTypeDefinitionCreatePayload draft: QuestionTypeDefinitionCreatePayload
setDraft: React.Dispatch<React.SetStateAction<QuestionTypeDefinitionCreatePayload>> setDraft: React.Dispatch<React.SetStateAction<QuestionTypeDefinitionCreatePayload>>
versionName: string
setVersionName: (v: string) => void
stimulusCatalogKinds: string[] stimulusCatalogKinds: string[]
responseCatalogKinds: string[] responseCatalogKinds: string[]
catalogLoading: boolean catalogLoading: boolean
@ -73,8 +64,6 @@ function rowErrorMap(side: "stimulus" | "response", errors: FieldErrorMap): Reco
export function QuestionTypeConfigStep({ export function QuestionTypeConfigStep({
draft, draft,
setDraft, setDraft,
versionName,
setVersionName,
stimulusCatalogKinds, stimulusCatalogKinds,
responseCatalogKinds, responseCatalogKinds,
catalogLoading, catalogLoading,
@ -83,11 +72,8 @@ export function QuestionTypeConfigStep({
onNext, onNext,
onBack, onBack,
}: QuestionTypeConfigStepProps) { }: QuestionTypeConfigStepProps) {
const [panelOpen, setPanelOpen] = useState(true)
const [advancedOpen, setAdvancedOpen] = useState(false) const [advancedOpen, setAdvancedOpen] = useState(false)
const title = draft.display_name?.trim() || "Untitled definition"
const handleStimulusKindClick = (kind: string) => { const handleStimulusKindClick = (kind: string) => {
setDraft((d) => { setDraft((d) => {
const wasSelected = d.stimulus_component_kinds.includes(kind) const wasSelected = d.stimulus_component_kinds.includes(kind)
@ -167,44 +153,15 @@ export function QuestionTypeConfigStep({
return ( return (
<div className="space-y-8 pb-32"> <div className="space-y-8 pb-32">
<Card className="max-w-6xl mx-auto overflow-hidden border border-grayScale-200 shadow-sm rounded-2xl bg-white"> <Card className="max-w-6xl mx-auto overflow-hidden border border-grayScale-200 shadow-sm rounded-2xl bg-white">
<button <div className="p-10 border-b border-grayScale-200">
type="button" <h2 className="text-[20px] font-medium text-grayScale-900">STEP 2: Input &amp; answer types</h2>
onClick={() => setPanelOpen((o) => !o)} <p className="text-grayScale-500 font-medium mt-1">
className="w-full flex items-center justify-between gap-4 px-5 py-4 bg-violet-100/90 hover:bg-violet-100 border-b border-violet-200/80 text-left transition-colors" Choose what learners see in the question and how they respond. You can add multiple fields of the
> same type when needed.
<div className="flex items-center gap-3 min-w-0"> </p>
<div className="h-10 w-10 rounded-xl bg-white/80 flex items-center justify-center text-violet-700 shrink-0 shadow-sm"> </div>
<Hourglass className="h-5 w-5" />
</div>
<span className="text-[17px] font-bold text-grayScale-900 truncate">{title}</span>
</div>
{panelOpen ? (
<ChevronUp className="h-5 w-5 text-grayScale-600 shrink-0" />
) : (
<ChevronDown className="h-5 w-5 text-grayScale-600 shrink-0" />
)}
</button>
{panelOpen ? (
<div className="p-6 sm:p-10 space-y-10">
<div className="space-y-2 max-w-xl">
<label className="text-[14px] font-semibold text-grayScale-700">
Version name <span className="text-red-500">*</span>
</label>
<Input
className="h-11 rounded-[10px] border-grayScale-200 bg-[#F8FAFC]"
value={versionName}
onChange={(e) => setVersionName(e.target.value)}
placeholder="e.g. Test 1"
/>
{errors.version_name ? (
<p className="text-sm font-medium text-red-600">{errors.version_name}</p>
) : null}
<p className="text-[12px] text-grayScale-400">
Local label for this authoring pass (not sent to the API unless you add it to description later).
</p>
</div>
<div className="p-6 sm:p-10 space-y-10">
{catalogLoading ? ( {catalogLoading ? (
<p className="text-sm text-grayScale-500">Loading component catalog</p> <p className="text-sm text-grayScale-500">Loading component catalog</p>
) : catalogError ? ( ) : catalogError ? (
@ -217,10 +174,8 @@ export function QuestionTypeConfigStep({
Section A: Question input types Section A: Question input types
</h3> </h3>
<p className="text-[14px] text-grayScale-500 mt-1 font-medium"> <p className="text-[14px] text-grayScale-500 mt-1 font-medium">
Choose how the question is presented to the learner. The API lists each kind once in{" "} Choose how the question is presented to the learner. Use Add slot for multiple fields of
<code className="text-[11px] bg-grayScale-100 px-1 rounded">stimulus_component_kinds</code>{" "} the same type (for example, two text blocks).
while <code className="text-[11px] bg-grayScale-100 px-1 rounded">stimulus_schema</code> can
include the same kind multiple times (different ids).
</p> </p>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
@ -268,10 +223,8 @@ export function QuestionTypeConfigStep({
Section B: Answer types Section B: Answer types
</h3> </h3>
<p className="text-[14px] text-grayScale-500 mt-1 font-medium"> <p className="text-[14px] text-grayScale-500 mt-1 font-medium">
How should the student answer?{" "} How should the student answer? Use Add slot when you need more than one field of the same
<code className="text-[11px] bg-grayScale-100 px-1 rounded">response_component_kinds</code> is answer type.
deduplicated; use <span className="font-medium text-grayScale-600">Add slot</span> for multiple
fields of the same kind.
</p> </p>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
@ -353,8 +306,7 @@ export function QuestionTypeConfigStep({
</div> </div>
) : null} ) : null}
</div> </div>
</div> </div>
) : null}
<div className="px-4 py-4 border-t border-grayScale-200 flex items-center justify-between bg-[#F8FAFC]"> <div className="px-4 py-4 border-t border-grayScale-200 flex items-center justify-between bg-[#F8FAFC]">
<Button <Button

View File

@ -11,30 +11,53 @@ import {
validateQuestionTypeDefinition, validateQuestionTypeDefinition,
} from "../../../../api/questionTypeDefinitions.api" } from "../../../../api/questionTypeDefinitions.api"
import type { QuestionTypeDefinitionCreatePayload } from "../../../../types/questionTypeDefinition.types" import type { QuestionTypeDefinitionCreatePayload } from "../../../../types/questionTypeDefinition.types"
import { buildCreatePayload } from "../../lib/questionTypeDefinitionValidation" import {
buildCreatePayload,
buildValidateKindsPayload,
inferRuntimeQuestionType,
} from "../../lib/questionTypeDefinitionValidation"
import { DefinitionRuntimeHint } from "./DefinitionRuntimeHint"
interface QuestionTypeReviewPublishStepProps { interface QuestionTypeReviewPublishStepProps {
draft: QuestionTypeDefinitionCreatePayload draft: QuestionTypeDefinitionCreatePayload
onBack: () => void onBack: () => void
/** When set, saves via PUT /questions/type-definitions/:id */ /** When set, saves via PUT /questions/type-definitions/:id */
editDefinitionId?: number | null editDefinitionId?: number | null
isSystem?: boolean
} }
export function QuestionTypeReviewPublishStep({ export function QuestionTypeReviewPublishStep({
draft, draft,
onBack, onBack,
editDefinitionId, editDefinitionId,
isSystem,
}: QuestionTypeReviewPublishStepProps) { }: QuestionTypeReviewPublishStepProps) {
const navigate = useNavigate() const navigate = useNavigate()
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const isEdit = editDefinitionId != null && editDefinitionId > 0 const isEdit = editDefinitionId != null && editDefinitionId > 0
const payload = buildCreatePayload(draft) const payload = buildCreatePayload(draft)
const runtime = inferRuntimeQuestionType(payload.key, payload.response_component_kinds)
const submit = async (status: "ACTIVE" | "INACTIVE") => { const submit = async (status: "ACTIVE" | "INACTIVE") => {
if (runtime == null) {
toast.error("Definition cannot be saved", {
description: "Add at least one non-timer response kind so the server can map a runtime question type.",
})
return
}
const body = { ...payload, status } const body = { ...payload, status }
setSubmitting(true) setSubmitting(true)
try { try {
const validation = await validateQuestionTypeDefinition(buildValidateKindsPayload(draft))
if (!validation.valid) {
toast.error(validation.message || "Invalid question type definition", {
description: validation.error ? String(validation.error) : undefined,
})
return
}
if (isEdit) { if (isEdit) {
const res = await updateQuestionTypeDefinition(editDefinitionId, body) const res = await updateQuestionTypeDefinition(editDefinitionId, body)
const id = extractDefinitionMutationId(res) ?? editDefinitionId const id = extractDefinitionMutationId(res) ?? editDefinitionId
@ -45,14 +68,6 @@ export function QuestionTypeReviewPublishStep({
return return
} }
const validation = await validateQuestionTypeDefinition(body)
if (!validation.valid) {
toast.error(validation.message || "Invalid question type definition", {
description: validation.error ? String(validation.error) : undefined,
})
return
}
const res = await createQuestionTypeDefinition(body) const res = await createQuestionTypeDefinition(body)
const id = extractDefinitionMutationId(res) const id = extractDefinitionMutationId(res)
if (id == null) { if (id == null) {
@ -81,30 +96,30 @@ export function QuestionTypeReviewPublishStep({
<div className="space-y-8 pb-32"> <div className="space-y-8 pb-32">
<Card className="max-w-4xl mx-auto overflow-hidden border-grayScale-100 shadow-sm rounded-2xl bg-white"> <Card className="max-w-4xl mx-auto overflow-hidden border-grayScale-100 shadow-sm rounded-2xl bg-white">
<div className="p-10 border-b border-grayScale-200"> <div className="p-10 border-b border-grayScale-200">
<h2 className="text-[20px] font-medium text-grayScale-900">STEP 4: Review & publish</h2> <h2 className="text-[20px] font-medium text-grayScale-900">STEP 4: Review &amp; publish</h2>
<p className="text-grayScale-500 font-medium mt-1"> <p className="text-grayScale-500 font-medium mt-1">
{isEdit ? ( {isEdit
<> ? "Confirm your changes and save. The definition key cannot be changed."
Confirm changes, then update via{" "} : "Confirm your definition, then save it for use when authoring practice questions."}
<code className="text-xs bg-grayScale-100 px-1 rounded">
PUT /questions/type-definitions/{editDefinitionId}
</code>
.
</>
) : (
<>
Confirm details, then create via{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">POST /questions/type-definitions</code>.
</>
)}
</p> </p>
</div> </div>
<div className="p-10 space-y-6"> <div className="p-10 space-y-6">
{isSystem ? (
<p className="text-sm font-medium text-amber-800 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3">
This is a system definition. You can update it, but it cannot be deleted from the library.
</p>
) : null}
<DefinitionRuntimeHint
definitionKey={payload.key}
responseKinds={payload.response_component_kinds}
/>
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm"> <dl className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
<div> <div>
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Key</dt> <dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Key</dt>
<dd className="font-medium text-grayScale-900 mt-1">{payload.key}</dd> <dd className="font-medium text-grayScale-900 mt-1 font-mono text-[13px]">{payload.key}</dd>
</div> </div>
<div> <div>
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Display name</dt> <dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Display name</dt>
@ -118,6 +133,10 @@ export function QuestionTypeReviewPublishStep({
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Status</dt> <dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Status</dt>
<dd className="font-medium text-grayScale-900 mt-1">{draft.status}</dd> <dd className="font-medium text-grayScale-900 mt-1">{draft.status}</dd>
</div> </div>
<div>
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Runtime type</dt>
<dd className="font-medium text-grayScale-900 mt-1">{runtime ?? "Unmappable"}</dd>
</div>
<div> <div>
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Stimulus kinds</dt> <dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Stimulus kinds</dt>
<dd className="font-medium text-grayScale-900 mt-1">{payload.stimulus_component_kinds.join(", ") || "—"}</dd> <dd className="font-medium text-grayScale-900 mt-1">{payload.stimulus_component_kinds.join(", ") || "—"}</dd>
@ -126,33 +145,42 @@ export function QuestionTypeReviewPublishStep({
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Response kinds</dt> <dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Response kinds</dt>
<dd className="font-medium text-grayScale-900 mt-1">{payload.response_component_kinds.join(", ") || "—"}</dd> <dd className="font-medium text-grayScale-900 mt-1">{payload.response_component_kinds.join(", ") || "—"}</dd>
</div> </div>
<div>
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Stimulus schema rows</dt>
<dd className="font-medium text-grayScale-900 mt-1">{payload.stimulus_schema.length}</dd>
</div>
<div>
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Response schema rows</dt>
<dd className="font-medium text-grayScale-900 mt-1">{payload.response_schema.length}</dd>
</div>
</dl> </dl>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<SchemaSlotSummary title="Stimulus schema" rows={payload.stimulus_schema} />
<SchemaSlotSummary title="Response schema" rows={payload.response_schema} />
</div>
<div className="flex flex-wrap gap-3 pt-2"> <div className="flex flex-wrap gap-3 pt-2">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
className="h-11" className="h-11"
disabled={submitting} disabled={submitting || runtime == null}
onClick={() => void submit("INACTIVE")} onClick={() => void submit("INACTIVE")}
> >
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : isEdit ? "Save as inactive" : "Save as inactive (draft)"} {submitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isEdit ? (
"Save as inactive"
) : (
"Save as inactive (draft)"
)}
</Button> </Button>
<Button <Button
type="button" type="button"
className="h-11 bg-[#9E2891] hover:bg-[#8A237E] text-white" className="h-11 bg-[#9E2891] hover:bg-[#8A237E] text-white"
disabled={submitting} disabled={submitting || runtime == null}
onClick={() => void submit("ACTIVE")} onClick={() => void submit("ACTIVE")}
> >
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : isEdit ? "Save as active" : "Create as active"} {submitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isEdit ? (
"Save as active"
) : (
"Create as active"
)}
</Button> </Button>
</div> </div>
</div> </div>
@ -173,3 +201,33 @@ export function QuestionTypeReviewPublishStep({
</div> </div>
) )
} }
function SchemaSlotSummary({
title,
rows,
}: {
title: string
rows: { id: string; kind: string; label?: string; required: boolean }[]
}) {
return (
<div className="rounded-xl border border-grayScale-100 bg-grayScale-50/50 p-4">
<h4 className="text-[12px] font-bold uppercase tracking-wide text-grayScale-500">{title}</h4>
{rows.length === 0 ? (
<p className="mt-2 text-sm text-grayScale-500">No slots</p>
) : (
<ul className="mt-2 space-y-1.5 text-sm">
{rows.map((r) => (
<li key={`${r.kind}-${r.id}`} className="flex flex-wrap gap-x-2 text-grayScale-800">
<span className="font-mono text-[12px] text-grayScale-600">{r.id}</span>
<span className="text-grayScale-400">·</span>
<span>{r.kind}</span>
{r.required ? (
<span className="text-[10px] font-bold uppercase text-brand-600">required</span>
) : null}
</li>
))}
</ul>
)}
</div>
)
}

View File

@ -1,11 +1,15 @@
import { useState } from "react" import { useCallback, useEffect, useState } from "react"
import { ArrowLeft, ArrowRight, CheckCircle2, Loader2 } from "lucide-react" import { ArrowLeft, ArrowRight, CheckCircle2, Loader2 } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { Button } from "../../../../components/ui/button" import { Button } from "../../../../components/ui/button"
import { Card } from "../../../../components/ui/card" import { Card } from "../../../../components/ui/card"
import { validateQuestionTypeDefinition } from "../../../../api/questionTypeDefinitions.api" import { validateQuestionTypeDefinition } from "../../../../api/questionTypeDefinitions.api"
import type { QuestionTypeDefinitionCreatePayload } from "../../../../types/questionTypeDefinition.types" import type { QuestionTypeDefinitionCreatePayload } from "../../../../types/questionTypeDefinition.types"
import { buildCreatePayload } from "../../lib/questionTypeDefinitionValidation" import {
buildCreatePayload,
buildValidateKindsPayload,
} from "../../lib/questionTypeDefinitionValidation"
import { DefinitionRuntimeHint } from "./DefinitionRuntimeHint"
interface QuestionTypeValidatePreviewStepProps { interface QuestionTypeValidatePreviewStepProps {
draft: QuestionTypeDefinitionCreatePayload draft: QuestionTypeDefinitionCreatePayload
@ -25,12 +29,12 @@ export function QuestionTypeValidatePreviewStep({
const payload = buildCreatePayload(draft) const payload = buildCreatePayload(draft)
const json = JSON.stringify(payload, null, 2) const json = JSON.stringify(payload, null, 2)
const runValidate = async () => { const runValidate = useCallback(async () => {
setValidating(true) setValidating(true)
setServerOk(null) setServerOk(null)
setServerDetail(null) setServerDetail(null)
try { try {
const res = await validateQuestionTypeDefinition(payload) const res = await validateQuestionTypeDefinition(buildValidateKindsPayload(draft))
if (!res.valid) { if (!res.valid) {
setServerOk(false) setServerOk(false)
const detail = res.error || res.message || "Validation failed" const detail = res.error || res.message || "Validation failed"
@ -39,32 +43,49 @@ export function QuestionTypeValidatePreviewStep({
return return
} }
setServerOk(true) setServerOk(true)
setServerDetail(res.message || "Question type definition is valid.") setServerDetail(res.message || "Component kinds are valid.")
toast.success(res.message || "Question type definition is valid") toast.success(res.message || "Definition kinds validated")
} finally { } finally {
setValidating(false) setValidating(false)
} }
}, [draft])
useEffect(() => {
void runValidate()
}, [runValidate])
const handleNext = () => {
if (serverOk !== true) {
toast.error("Validate with the server before continuing.", {
description: serverDetail || "Fix response/stimulus kinds and try again.",
})
return
}
onNext()
} }
return ( return (
<div className="space-y-8 pb-32"> <div className="space-y-8 pb-32">
<Card className="max-w-4xl mx-auto overflow-hidden border-grayScale-100 shadow-sm rounded-2xl bg-white"> <Card className="max-w-4xl mx-auto overflow-hidden border-grayScale-100 shadow-sm rounded-2xl bg-white">
<div className="p-10 border-b border-grayScale-200"> <div className="p-10 border-b border-grayScale-200">
<h2 className="text-[20px] font-medium text-grayScale-900">STEP 3: Validate & preview</h2> <h2 className="text-[20px] font-medium text-grayScale-900">STEP 3: Validate</h2>
<p className="text-grayScale-500 font-medium mt-1"> <p className="text-grayScale-500 font-medium mt-1">
Optional server check via{" "} We check that your stimulus and response selections are valid before you continue. You must pass
<code className="text-xs bg-grayScale-100 px-1 rounded">POST /questions/validate-question-type-definition</code> validation before review.
. Uses the same JSON as create; validity comes from <code className="text-xs bg-grayScale-100 px-1 rounded">data.valid</code>{" "}
(not the envelope <code className="text-xs bg-grayScale-100 px-1 rounded">success</code> flag).
</p> </p>
</div> </div>
<div className="p-10 space-y-6"> <div className="p-10 space-y-6">
<DefinitionRuntimeHint
definitionKey={payload.key}
responseKinds={payload.response_component_kinds}
/>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={runValidate} onClick={() => void runValidate()}
disabled={validating} disabled={validating}
className="h-10" className="h-10"
> >
@ -74,7 +95,7 @@ export function QuestionTypeValidatePreviewStep({
Validating Validating
</> </>
) : ( ) : (
"Validate with server" "Re-validate"
)} )}
</Button> </Button>
{serverOk === true ? ( {serverOk === true ? (
@ -83,12 +104,41 @@ export function QuestionTypeValidatePreviewStep({
{serverDetail} {serverDetail}
</span> </span>
) : null} ) : null}
{serverOk === false ? <span className="text-sm font-medium text-red-600">{serverDetail}</span> : null} {serverOk === false ? (
<span className="text-sm font-medium text-red-600">{serverDetail}</span>
) : validating ? (
<span className="text-sm text-grayScale-500">Checking kinds with server</span>
) : null}
</div> </div>
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm rounded-xl border border-grayScale-100 bg-grayScale-50/60 p-4">
<div>
<dt className="text-[11px] font-bold uppercase text-grayScale-400">Stimulus kinds</dt>
<dd className="mt-1 font-medium text-grayScale-800">
{payload.stimulus_component_kinds.join(", ") || "—"}
</dd>
</div>
<div>
<dt className="text-[11px] font-bold uppercase text-grayScale-400">Response kinds</dt>
<dd className="mt-1 font-medium text-grayScale-800">
{payload.response_component_kinds.join(", ") || "—"}
</dd>
</div>
<div>
<dt className="text-[11px] font-bold uppercase text-grayScale-400">Stimulus slots</dt>
<dd className="mt-1 font-medium text-grayScale-800">{payload.stimulus_schema.length}</dd>
</div>
<div>
<dt className="text-[11px] font-bold uppercase text-grayScale-400">Response slots</dt>
<dd className="mt-1 font-medium text-grayScale-800">{payload.response_schema.length}</dd>
</div>
</dl>
<div className="space-y-2"> <div className="space-y-2">
<p className="text-[12px] font-bold uppercase tracking-wide text-grayScale-400">Create payload (JSON)</p> <p className="text-[12px] font-bold uppercase tracking-wide text-grayScale-400">
<pre className="text-[12px] leading-relaxed font-mono bg-grayScale-900 text-grayScale-50 rounded-xl p-4 overflow-x-auto max-h-[420px] overflow-y-auto"> Definition preview
</p>
<pre className="text-[12px] leading-relaxed font-mono bg-grayScale-900 text-grayScale-50 rounded-xl p-4 overflow-x-auto max-h-[360px] overflow-y-auto">
{json} {json}
</pre> </pre>
</div> </div>
@ -106,10 +156,11 @@ export function QuestionTypeValidatePreviewStep({
</Button> </Button>
<Button <Button
type="button" type="button"
onClick={onNext} onClick={handleNext}
className="h-10 px-10 rounded-[6px] bg-[#9E2891] font-medium text-white shadow-lg shadow-brand-500/10 hover:bg-[#8A237E] transition-all flex items-center gap-3" disabled={validating || serverOk !== true}
className="h-10 px-10 rounded-[6px] bg-[#9E2891] font-medium text-white shadow-lg shadow-brand-500/10 hover:bg-[#8A237E] disabled:opacity-50 transition-all flex items-center gap-3"
> >
Next: Review Next: Review & publish
<ArrowRight className="h-5 w-5" /> <ArrowRight className="h-5 w-5" />
</Button> </Button>
</div> </div>

View File

@ -34,6 +34,7 @@ const STIMULUS_LABELS: Record<string, string> = {
SELECT_MISSING_WORDS: "Select Missing Words", SELECT_MISSING_WORDS: "Select Missing Words",
TABLE: "Table", TABLE: "Table",
FLOW_CHART: "Flow Chart", FLOW_CHART: "Flow Chart",
PDF_ATTACHMENT: "PDF Attachment",
} }
const RESPONSE_LABELS: Record<string, string> = { const RESPONSE_LABELS: Record<string, string> = {
@ -64,6 +65,7 @@ const STIMULUS_ICONS: Record<string, LucideIcon> = {
SELECT_MISSING_WORDS: ListTodo, SELECT_MISSING_WORDS: ListTodo,
TABLE: TableIcon, TABLE: TableIcon,
FLOW_CHART: GitBranch, FLOW_CHART: GitBranch,
PDF_ATTACHMENT: FileUp,
} }
const RESPONSE_ICONS: Record<string, LucideIcon> = { const RESPONSE_ICONS: Record<string, LucideIcon> = {

View File

@ -104,12 +104,8 @@ export function VideoDetailStep({
Video Video
</h3> </h3>
<p className="text-sm text-grayScale-500 ml-1 max-w-2xl"> <p className="text-sm text-grayScale-500 ml-1 max-w-2xl">
Upload a file or paste a link (Vimeo, hosted file, etc.). Files are Upload a file or paste a link (Vimeo, hosted file, etc.). Uploaded files are stored
sent to your storage via{" "} automatically.
<code className="rounded bg-grayScale-100 px-1 text-[11px]">
POST /files/upload
</code>
.
</p> </p>
<LessonMediaUploadField <LessonMediaUploadField
kind="video" kind="video"
@ -260,12 +256,8 @@ export function VideoDetailStep({
Pro tip Pro tip
</h3> </h3>
<p className="text-[12px] text-grayScale-700 font-medium leading-relaxed"> <p className="text-[12px] text-grayScale-700 font-medium leading-relaxed">
Use clear titles and a thumbnail that matches the lesson. The Use clear titles and a thumbnail that matches the lesson. The lesson is created when
lesson is created with{" "} you publish.
<code className="rounded bg-white/80 px-1 text-[10px]">
POST /modules/:moduleId/lessons
</code>{" "}
when you publish.
</p> </p>
</div> </div>
</div> </div>

View File

@ -41,6 +41,18 @@ export function validateDefinitionKinds(
errors.response_kinds = "ANSWER_TIMER cannot be the only response kind." errors.response_kinds = "ANSWER_TIMER cannot be the only response kind."
} }
const prepCount = sk.filter((k) => k === "PREP_TIME").length
if (prepCount > 1) {
errors.stimulus_kinds = "At most one PREP_TIME is allowed."
}
const timerCount = rk.filter((k) => k === "ANSWER_TIMER").length
if (timerCount > 1) {
errors.response_kinds = errors.response_kinds
? `${errors.response_kinds} At most one ANSWER_TIMER is allowed.`
: "At most one ANSWER_TIMER is allowed."
}
if (catalog) { if (catalog) {
const sCat = new Set(catalog.stimulus_component_kinds) const sCat = new Set(catalog.stimulus_component_kinds)
const rCat = new Set(catalog.response_component_kinds) const rCat = new Set(catalog.response_component_kinds)
@ -126,6 +138,42 @@ function uniqueKindsFromSchemaRows(rows: DynamicElementDefinition[]): string[] {
return [...set].sort((a, b) => a.localeCompare(b)) return [...set].sort((a, b) => a.localeCompare(b))
} }
const AUXILIARY_RESPONSE_KINDS = new Set(["ANSWER_TIMER"])
const SHORT_ANSWER_RESPONSE_KINDS = new Set([
"SHORT_ANSWER",
"TEXT_INPUT",
"SELECT_MISSING_WORDS",
"MATCHING_ANSWER",
"LABEL_SELECTION",
"PDF_UPLOAD",
])
/** Mirrors server runtime mapping (§13). Returns null when create would fail as unmappable. */
export function inferRuntimeQuestionType(
key: string,
responseKinds: string[],
): "TRUE_FALSE" | "AUDIO" | "MCQ" | "SHORT_ANSWER" | "DYNAMIC" | null {
const normalizedKey = key.trim().toLowerCase()
if (normalizedKey === "true_false") return "TRUE_FALSE"
if (responseKinds.includes("AUDIO_RESPONSE")) return "AUDIO"
if (responseKinds.includes("MULTIPLE_CHOICE")) return "MCQ"
const nonAuxiliary = responseKinds.filter((k) => !AUXILIARY_RESPONSE_KINDS.has(k))
if (nonAuxiliary.some((k) => SHORT_ANSWER_RESPONSE_KINDS.has(k))) return "SHORT_ANSWER"
if (nonAuxiliary.length > 0) return "DYNAMIC"
return null
}
/** POST /questions/validate-question-type-definition — kinds only. */
export function buildValidateKindsPayload(
draft: QuestionTypeDefinitionCreatePayload,
): Pick<QuestionTypeDefinitionCreatePayload, "stimulus_component_kinds" | "response_component_kinds"> {
const payload = buildCreatePayload(draft)
return {
stimulus_component_kinds: payload.stimulus_component_kinds,
response_component_kinds: payload.response_component_kinds,
}
}
export function buildCreatePayload( export function buildCreatePayload(
draft: QuestionTypeDefinitionCreatePayload, draft: QuestionTypeDefinitionCreatePayload,
): QuestionTypeDefinitionCreatePayload { ): QuestionTypeDefinitionCreatePayload {

View File

@ -41,6 +41,7 @@ import {
DialogDescription, DialogDescription,
} from "../../components/ui/dialog"; } from "../../components/ui/dialog";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination";
import { SpinnerIcon } from "../../components/ui/spinner-icon"; import { SpinnerIcon } from "../../components/ui/spinner-icon";
import { import {
getIssues, getIssues,
@ -654,7 +655,7 @@ export function IssuesPage() {
}} }}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none" className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
> >
{[5, 10, 20, 30, 50].map((size) => ( {TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}> <option key={size} value={size}>
{size} {size}
</option> </option>

View File

@ -119,11 +119,7 @@ export function CreateEmailTemplatePage() {
New custom template New custom template
</h1> </h1>
<p className="max-w-2xl text-sm text-grayScale-500"> <p className="max-w-2xl text-sm text-grayScale-500">
Create a custom template via{" "} Create a custom email template. System templates are managed separately.
<code className="rounded bg-grayScale-100 px-1 text-xs">
POST /admin/email-templates
</code>
. System templates are managed separately.
</p> </p>
</div> </div>

View File

@ -78,11 +78,8 @@ export function EmailTemplatesPage() {
Email Templates Email Templates
</h1> </h1>
<p className="mt-1 max-w-2xl text-sm text-grayScale-500"> <p className="mt-1 max-w-2xl text-sm text-grayScale-500">
Templates from{" "} View and edit email templates used for learner and team notifications. Open a template to
<code className="rounded bg-grayScale-100 px-1 text-xs"> preview and edit its content.
GET /admin/email-templates
</code>
. Open a template for full preview via slug API.
</p> </p>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">

View File

@ -71,8 +71,7 @@ import type { Role } from "../../types/rbac.types"
import type { TeamMember } from "../../types/team.types" import type { TeamMember } from "../../types/team.types"
import type { UserApiDTO } from "../../types/user.types" import type { UserApiDTO } from "../../types/user.types"
import { toast } from "sonner" import { toast } from "sonner"
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
const PAGE_SIZE = 10
const TYPE_CONFIG: Record<string, { icon: React.ElementType; color: string; bg: string }> = { const TYPE_CONFIG: Record<string, { icon: React.ElementType; color: string; bg: string }> = {
announcement: { icon: Megaphone, color: "text-brand-600", bg: "bg-brand-100" }, announcement: { icon: Megaphone, color: "text-brand-600", bg: "bg-brand-100" },
@ -265,6 +264,7 @@ export function NotificationsPage() {
const [totalCount, setTotalCount] = useState(0) const [totalCount, setTotalCount] = useState(0)
const [globalUnread, setGlobalUnread] = useState(0) const [globalUnread, setGlobalUnread] = useState(0)
const [offset, setOffset] = useState(0) const [offset, setOffset] = useState(0)
const [pageSize, setPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState(false) const [error, setError] = useState(false)
const [togglingIds, setTogglingIds] = useState<Set<string>>(new Set()) const [togglingIds, setTogglingIds] = useState<Set<string>>(new Set())
@ -429,7 +429,7 @@ export function NotificationsPage() {
setError(false) setError(false)
try { try {
const [notifRes, unreadRes] = await Promise.all([ const [notifRes, unreadRes] = await Promise.all([
getNotifications(PAGE_SIZE, currentOffset), getNotifications(pageSize, currentOffset),
getUnreadCount(), getUnreadCount(),
]) ])
setNotifications(notifRes.data.notifications ?? []) setNotifications(notifRes.data.notifications ?? [])
@ -440,11 +440,11 @@ export function NotificationsPage() {
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, []) }, [pageSize])
useEffect(() => { useEffect(() => {
fetchData(offset) fetchData(offset)
}, [offset, fetchData]) }, [offset, pageSize, fetchData])
const handleToggleRead = useCallback(async (id: string, currentlyRead: boolean) => { const handleToggleRead = useCallback(async (id: string, currentlyRead: boolean) => {
setTogglingIds((prev) => new Set(prev).add(id)) setTogglingIds((prev) => new Set(prev).add(id))
@ -495,10 +495,10 @@ export function NotificationsPage() {
} }
}, [totalCount]) }, [totalCount])
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)) const totalPages = Math.max(1, Math.ceil(totalCount / pageSize))
const currentPage = Math.floor(offset / PAGE_SIZE) + 1 const currentPage = Math.floor(offset / pageSize) + 1
const startEntry = totalCount === 0 ? 0 : offset + 1 const startEntry = totalCount === 0 ? 0 : offset + 1
const endEntry = Math.min(offset + PAGE_SIZE, totalCount) const endEntry = Math.min(offset + pageSize, totalCount)
const getPageNumbers = () => { const getPageNumbers = () => {
const pages: (number | string)[] = [] const pages: (number | string)[] = []
@ -941,18 +941,25 @@ export function NotificationsPage() {
<span className="border-l pl-4">Rows per page</span> <span className="border-l pl-4">Rows per page</span>
<div className="relative"> <div className="relative">
<select <select
value={PAGE_SIZE} value={pageSize}
disabled onChange={(e) => {
setPageSize(Number(e.target.value))
setOffset(0)
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none" className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
> >
<option value={PAGE_SIZE}>{PAGE_SIZE}</option> {TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select> </select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" /> <ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" />
</div> </div>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
onClick={() => currentPage > 1 && setOffset(Math.max(0, offset - PAGE_SIZE))} onClick={() => currentPage > 1 && setOffset(Math.max(0, offset - pageSize))}
disabled={currentPage <= 1} disabled={currentPage <= 1}
className={cn( className={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500", "flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
@ -970,7 +977,7 @@ export function NotificationsPage() {
<button <button
key={n} key={n}
type="button" type="button"
onClick={() => setOffset((n - 1) * PAGE_SIZE)} onClick={() => setOffset((n - 1) * pageSize)}
className={cn( className={cn(
"h-8 w-8 rounded-md border text-sm font-medium", "h-8 w-8 rounded-md border text-sm font-medium",
n === currentPage n === currentPage
@ -983,7 +990,7 @@ export function NotificationsPage() {
), ),
)} )}
<button <button
onClick={() => currentPage < totalPages && setOffset(offset + PAGE_SIZE)} onClick={() => currentPage < totalPages && setOffset(offset + pageSize)}
disabled={currentPage >= totalPages} disabled={currentPage >= totalPages}
className={cn( className={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500", "flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",

View File

@ -185,10 +185,7 @@ export function EmailTemplateCreateForm({
<Button variant="outline" disabled={saving} onClick={onReset}> <Button variant="outline" disabled={saving} onClick={onReset}>
Reset Reset
</Button> </Button>
<p className="text-xs text-grayScale-400"> <p className="text-xs text-grayScale-400">Your changes are saved when you submit the form.</p>
Saved with{" "}
<code className="rounded bg-grayScale-100 px-1">POST /admin/email-templates</code>
</p>
</div> </div>
</div> </div>
) )

View File

@ -102,11 +102,7 @@ export function EmailTemplateEditForm({
<p className="mt-2 text-xs text-grayScale-400"> <p className="mt-2 text-xs text-grayScale-400">
Use Go template syntax, e.g.{" "} Use Go template syntax, e.g.{" "}
<code className="rounded bg-grayScale-100 px-1">{`{{if .FirstName}}`}</code>. <code className="rounded bg-grayScale-100 px-1">{`{{if .FirstName}}`}</code>.
Saved with{" "} Your changes are saved when you submit the form.
<code className="rounded bg-grayScale-100 px-1">
PUT /admin/email-templates/{template.id}
</code>
.
</p> </p>
</div> </div>

View File

@ -41,7 +41,7 @@ import type {
PaymentStatus, PaymentStatus,
} from "../../types/payment.types" } from "../../types/payment.types"
const PAGE_SIZE_OPTIONS = [20, 50, 100] as const import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
const STATUS_FILTERS: { value: PaymentStatus; label: string }[] = [ const STATUS_FILTERS: { value: PaymentStatus; label: string }[] = [
{ value: "SUCCESS", label: "Success" }, { value: "SUCCESS", label: "Success" },
@ -168,8 +168,7 @@ export function PaymentsPage() {
</p> </p>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-900">Payments</h1> <h1 className="text-2xl font-bold tracking-tight text-grayScale-900">Payments</h1>
<p className="mt-1 max-w-2xl text-sm text-grayScale-500"> <p className="mt-1 max-w-2xl text-sm text-grayScale-500">
Browse checkout transactions from{" "} Browse and filter checkout transactions from Chapa, Arifpay, and other providers.
<code className="rounded bg-grayScale-100 px-1 text-xs">GET /admin/payments</code>.
</p> </p>
</div> </div>
<Button <Button
@ -439,7 +438,7 @@ export function PaymentsPage() {
}} }}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none" className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
> >
{PAGE_SIZE_OPTIONS.map((size) => ( {TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}> <option key={size} value={size}>
{size} {size}
</option> </option>

View File

@ -5,6 +5,7 @@ import {
Search, Search,
Shield, Shield,
ShieldCheck, ShieldCheck,
ChevronDown,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
AlertCircle, AlertCircle,
@ -37,6 +38,7 @@ import {
} from "../../api/rbac.api" } from "../../api/rbac.api"
import type { Role, RoleDetail, RolePermission } from "../../types/rbac.types" import type { Role, RoleDetail, RolePermission } from "../../types/rbac.types"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
import { toast } from "sonner" import { toast } from "sonner"
import { SpinnerIcon } from "../../components/ui/spinner-icon" import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { teamRoleFromRbacRole } from "../../lib/teamRoles" import { teamRoleFromRbacRole } from "../../lib/teamRoles"
@ -49,7 +51,7 @@ export function RolesListPage() {
const [roles, setRoles] = useState<Role[]>([]) const [roles, setRoles] = useState<Role[]>([])
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize] = useState(20) const [pageSize, setPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const [debouncedQuery, setDebouncedQuery] = useState("") const [debouncedQuery, setDebouncedQuery] = useState("")
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -543,34 +545,59 @@ export function RolesListPage() {
)} )}
{/* Pagination */} {/* Pagination */}
{totalPages > 1 && ( {total > 0 && (
<div className="flex items-center justify-between border-t border-grayScale-100 pt-4"> <div className="flex flex-wrap items-center justify-between gap-3 border-t border-grayScale-100 pt-4 text-sm text-grayScale-500">
<p className="text-xs text-grayScale-400"> <div className="flex flex-wrap items-center gap-2">
Showing {(page - 1) * pageSize + 1}{Math.min(page * pageSize, total)} of {total} roles <span>
</p> Showing {(page - 1) * pageSize + 1}{Math.min(page * pageSize, total)} of {total} roles
<div className="flex items-center gap-1"> </span>
<Button <span className="hidden h-4 w-px bg-grayScale-200 sm:inline" />
variant="outline" <span className="flex items-center gap-2">
size="icon" Rows per page
className="h-8 w-8" <div className="relative">
disabled={page <= 1} <select
onClick={() => setPage((p) => p - 1)} value={pageSize}
> onChange={(e) => {
<ChevronLeft className="h-4 w-4" /> setPageSize(Number(e.target.value))
</Button> setPage(1)
<span className="px-3 text-xs font-medium text-grayScale-600"> }}
{page} / {totalPages} className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" />
</div>
</span> </span>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div> </div>
{totalPages > 1 ? (
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="px-3 text-xs font-medium text-grayScale-600">
{page} / {totalPages}
</span>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
) : null}
</div> </div>
)} )}
</> </>

View File

@ -153,12 +153,8 @@ export function InviteTeamMemberDialog({
Invite team members Invite team members
</DialogTitle> </DialogTitle>
<DialogDescription className="text-left text-grayScale-600"> <DialogDescription className="text-left text-grayScale-600">
Sends one{" "} Send one invitation per email address. Invitees complete account setup using the link in
<code className="rounded bg-grayScale-100 px-1 text-xs"> their email
POST /team/members/invite
</code>{" "}
request per email. Invitees complete setup at{" "}
<code className="rounded bg-grayScale-100 px-1 text-xs">/accept-invite</code>
{roleLocked ? ( {roleLocked ? (
<> <>
{" "} {" "}

View File

@ -3,6 +3,7 @@ import {
AlertTriangle, AlertTriangle,
Apple, Apple,
Calendar, Calendar,
ChevronDown,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
ExternalLink, ExternalLink,
@ -22,6 +23,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/ca
import { Input } from "../../components/ui/input" import { Input } from "../../components/ui/input"
import { SpinnerIcon } from "../../components/ui/spinner-icon" import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
import { import {
formatAppPlatform, formatAppPlatform,
formatAppVersionCreatedAt, formatAppVersionCreatedAt,
@ -34,8 +36,6 @@ import { CreateAppVersionDialog } from "./components/CreateAppVersionDialog"
import { DeleteAppVersionDialog } from "./components/DeleteAppVersionDialog" import { DeleteAppVersionDialog } from "./components/DeleteAppVersionDialog"
import { EditAppVersionDialog } from "./components/EditAppVersionDialog" import { EditAppVersionDialog } from "./components/EditAppVersionDialog"
const PAGE_SIZE = 20
function PlatformIcon({ platform }: { platform: string }) { function PlatformIcon({ platform }: { platform: string }) {
const upper = platform.toUpperCase() const upper = platform.toUpperCase()
if (upper === "IOS") { if (upper === "IOS") {
@ -64,6 +64,7 @@ export function AppVersionsTab() {
const [versions, setVersions] = useState<AppVersion[]>([]) const [versions, setVersions] = useState<AppVersion[]>([])
const [totalCount, setTotalCount] = useState(0) const [totalCount, setTotalCount] = useState(0)
const [offset, setOffset] = useState(0) const [offset, setOffset] = useState(0)
const [pageSize, setPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const [platformFilter, setPlatformFilter] = useState<"all" | AppPlatform>("all") const [platformFilter, setPlatformFilter] = useState<"all" | AppPlatform>("all")
const [statusFilter, setStatusFilter] = useState<"all" | AppVersionStatus>("all") const [statusFilter, setStatusFilter] = useState<"all" | AppVersionStatus>("all")
@ -87,7 +88,7 @@ export function AppVersionsTab() {
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [offset]) }, [offset, pageSize])
useEffect(() => { useEffect(() => {
void load() void load()
@ -123,7 +124,7 @@ export function AppVersionsTab() {
const pageStart = totalCount === 0 ? 0 : offset + 1 const pageStart = totalCount === 0 ? 0 : offset + 1
const pageEnd = Math.min(offset + versions.length, totalCount) const pageEnd = Math.min(offset + versions.length, totalCount)
const canPrev = offset > 0 const canPrev = offset > 0
const canNext = offset + PAGE_SIZE < totalCount const canNext = offset + pageSize < totalCount
const handleCreated = (version: AppVersion) => { const handleCreated = (version: AppVersion) => {
if (offset === 0) { if (offset === 0) {
@ -155,12 +156,7 @@ export function AppVersionsTab() {
</p> </p>
<h2 className="text-lg font-bold text-grayScale-900">App version control</h2> <h2 className="text-lg font-bold text-grayScale-900">App version control</h2>
<p className="mt-1 max-w-2xl text-sm text-grayScale-500"> <p className="mt-1 max-w-2xl text-sm text-grayScale-500">
Manage Android and iOS release metadata for in-app update prompts. Versions are loaded Manage Android and iOS release metadata for in-app update prompts.
from{" "}
<code className="rounded bg-grayScale-100 px-1 text-xs">
GET /admin/app-versions
</code>
.
</p> </p>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@ -440,10 +436,34 @@ export function AppVersionsTab() {
)} )}
{!loading && !error && totalCount > 0 ? ( {!loading && !error && totalCount > 0 ? (
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-grayScale-100 pt-4"> <div className="flex flex-wrap items-center justify-between gap-3 border-t border-grayScale-100 pt-4 text-sm text-grayScale-500">
<p className="text-xs text-grayScale-500"> <div className="flex flex-wrap items-center gap-2">
Showing {pageStart}{pageEnd} of {totalCount} <span>
</p> Showing {pageStart}{pageEnd} of {totalCount}
</span>
<span className="hidden h-4 w-px bg-grayScale-200 sm:inline" />
<span className="flex items-center gap-2">
Rows per page
<div className="relative">
<select
value={pageSize}
disabled={loading}
onChange={(e) => {
setPageSize(Number(e.target.value))
setOffset(0)
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" />
</div>
</span>
</div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
type="button" type="button"
@ -451,7 +471,7 @@ export function AppVersionsTab() {
size="sm" size="sm"
className="rounded-[6px]" className="rounded-[6px]"
disabled={!canPrev || loading} disabled={!canPrev || loading}
onClick={() => setOffset((o) => Math.max(0, o - PAGE_SIZE))} onClick={() => setOffset((o) => Math.max(0, o - pageSize))}
> >
<ChevronLeft className="mr-1 h-4 w-4" /> <ChevronLeft className="mr-1 h-4 w-4" />
Previous Previous
@ -462,7 +482,7 @@ export function AppVersionsTab() {
size="sm" size="sm"
className="rounded-[6px]" className="rounded-[6px]"
disabled={!canNext || loading} disabled={!canNext || loading}
onClick={() => setOffset((o) => o + PAGE_SIZE)} onClick={() => setOffset((o) => o + pageSize)}
> >
Next Next
<ChevronRight className="ml-1 h-4 w-4" /> <ChevronRight className="ml-1 h-4 w-4" />

View File

@ -100,9 +100,8 @@ export function SubscriptionPlansTab() {
</p> </p>
<h2 className="text-lg font-bold text-grayScale-900">Subscription packages</h2> <h2 className="text-lg font-bold text-grayScale-900">Subscription packages</h2>
<p className="mt-1 max-w-2xl text-sm text-grayScale-500"> <p className="mt-1 max-w-2xl text-sm text-grayScale-500">
Manage learner subscription plans from{" "} Manage learner subscription plans. Create, edit, or remove packages for the learner
<code className="rounded bg-grayScale-100 px-1 text-xs">GET /subscription-plans</code> checkout flow.
. Create, edit, or remove packages for the learner checkout flow.
</p> </p>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">

View File

@ -148,9 +148,8 @@ export function CreateAppVersionDialog({
New app version New app version
</DialogTitle> </DialogTitle>
<DialogDescription className="text-sm text-grayScale-500"> <DialogDescription className="text-sm text-grayScale-500">
Publishes a release via{" "} Publish a new app release. Learners on older builds will see update prompts based on
<code className="rounded bg-grayScale-100 px-1 text-xs">POST /admin/app-versions</code> these rules.
. Learners on older builds will see update prompts based on these rules.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>

View File

@ -128,8 +128,7 @@ export function CreateSubscriptionPlanDialog({
New subscription package New subscription package
</DialogTitle> </DialogTitle>
<DialogDescription className="text-sm text-grayScale-500"> <DialogDescription className="text-sm text-grayScale-500">
Creates a plan via{" "} Add a new subscription package for learners.
<code className="rounded bg-grayScale-100 px-1 text-xs">POST /subscription-plans</code>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>

View File

@ -140,10 +140,7 @@ export function EditAppVersionDialog({
Edit app version Edit app version
</DialogTitle> </DialogTitle>
<DialogDescription className="text-sm text-grayScale-500"> <DialogDescription className="text-sm text-grayScale-500">
Updates via{" "} Update release rules and messaging for this version.
<code className="rounded bg-grayScale-100 px-1 text-xs">
PUT /admin/app-versions/{version?.id ?? ":id"}
</code>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>

View File

@ -139,10 +139,7 @@ export function EditSubscriptionPlanDialog({
Edit subscription package Edit subscription package
</DialogTitle> </DialogTitle>
<DialogDescription className="text-sm text-grayScale-500"> <DialogDescription className="text-sm text-grayScale-500">
Updates via{" "} Update pricing, duration, and visibility for this plan.
<code className="rounded bg-grayScale-100 px-1 text-xs">
PUT /subscription-plans/{plan?.id ?? ":id"}
</code>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>

View File

@ -20,6 +20,7 @@ import {
} from "../../components/ui/table"; } from "../../components/ui/table";
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination";
import { getTeamMembers, updateTeamMemberStatus } from "../../api/team.api"; import { getTeamMembers, updateTeamMemberStatus } from "../../api/team.api";
import type { TeamMember } from "../../types/team.types"; import type { TeamMember } from "../../types/team.types";
import { toast } from "sonner"; import { toast } from "sonner";
@ -413,7 +414,7 @@ export function TeamManagementPage() {
}} }}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none" className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
> >
{[5, 10, 20, 30, 50].map((size) => ( {TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}> <option key={size} value={size}>
{size} {size}
</option> </option>

View File

@ -36,6 +36,7 @@ import {
DialogDescription, DialogDescription,
} from "../../components/ui/dialog"; } from "../../components/ui/dialog";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination";
import { getActivityLogs, getActivityLogById } from "../../api/activity-logs.api"; import { getActivityLogs, getActivityLogById } from "../../api/activity-logs.api";
import type { ActivityLog, ActivityLogFilters } from "../../types/activity-log.types"; import type { ActivityLog, ActivityLogFilters } from "../../types/activity-log.types";
import { SpinnerIcon } from "../../components/ui/spinner-icon"; import { SpinnerIcon } from "../../components/ui/spinner-icon";
@ -500,7 +501,7 @@ export function UserLogPage() {
}} }}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none" className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
> >
{[5, 10, 20, 30, 50].map((size) => ( {TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}> <option key={size} value={size}>
{size} {size}
</option> </option>

View File

@ -16,6 +16,7 @@ import {
import { getDeletionRequests } from "../../api/users.api" import { getDeletionRequests } from "../../api/users.api"
import { getRoles } from "../../api/rbac.api" import { getRoles } from "../../api/rbac.api"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
import type { import type {
DeletionRequest, DeletionRequest,
DeletionState, DeletionState,
@ -580,7 +581,7 @@ export function DeletionRequestsPage() {
}} }}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none" className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
> >
{[10, 20, 50, 100].map((size) => ( {TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}> <option key={size} value={size}>
{size} {size}
</option> </option>

View File

@ -9,6 +9,7 @@ import { Button } from "../../components/ui/button"
import { Card, CardContent } from "../../components/ui/card" import { Card, CardContent } from "../../components/ui/card"
import { SpinnerIcon } from "../../components/ui/spinner-icon" import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
import { getDashboard } from "../../api/analytics.api" import { getDashboard } from "../../api/analytics.api"
import { getUsers, updateUserStatus, type UserStatus } from "../../api/users.api" import { getUsers, updateUserStatus, type UserStatus } from "../../api/users.api"
import type { DashboardUsers } from "../../types/analytics.types" import type { DashboardUsers } from "../../types/analytics.types"
@ -689,7 +690,7 @@ export function UsersListPage() {
}} }}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none" className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
> >
{[5, 10, 20, 30, 50].map((size) => ( {TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}> <option key={size} value={size}>
{size} {size}
</option> </option>

View File

@ -1066,7 +1066,8 @@ export interface QuestionOption {
} }
export interface CreateQuestionRequest { export interface CreateQuestionRequest {
question_text: string /** Omit for `DYNAMIC` — prompt belongs in `dynamic_payload.stimulus` */
question_text?: string
question_type: string question_type: string
difficulty_level?: string difficulty_level?: string
points?: number points?: number