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:
parent
2c3f0da6f7
commit
92a2fab833
|
|
@ -1,6 +1,6 @@
|
|||
import http from "./http"
|
||||
|
||||
export type UploadMediaType = "image" | "audio" | "video"
|
||||
export type UploadMediaType = "image" | "audio" | "video" | "pdf"
|
||||
export type UploadProvider = "MINIO" | "VIMEO"
|
||||
|
||||
export interface UploadMediaResponse {
|
||||
|
|
@ -121,6 +121,8 @@ export const uploadVideoFile = (fileOrUrl: File | string, options?: UploadMediaO
|
|||
})
|
||||
: uploadMediaFile("video", fileOrUrl, options)
|
||||
|
||||
export const uploadPdfFile = (file: File) => uploadMediaFile("pdf", file)
|
||||
|
||||
export const resolveFileUrl = (key: string) =>
|
||||
http.get<ResolveFileUrlResponse>("/files/url", {
|
||||
params: { key },
|
||||
|
|
|
|||
|
|
@ -6,15 +6,17 @@ import {
|
|||
type ChangeEvent,
|
||||
type DragEvent,
|
||||
} 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 { uploadAudioFile, uploadImageFile } from "../../api/files.api"
|
||||
import { uploadAudioFile, uploadImageFile, uploadPdfFile } from "../../api/files.api"
|
||||
import { resolveMediaPreviewUrl } from "../../lib/practiceMedia"
|
||||
import { Button } from "../ui/button"
|
||||
import { Input } from "../ui/input"
|
||||
import { Textarea } from "../ui/textarea"
|
||||
import { SpinnerIcon } from "../ui/spinner-icon"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { ResolvedImage } from "../media/ResolvedImage"
|
||||
import { DynamicTableBuilder } from "./DynamicTableBuilder"
|
||||
|
||||
const MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
||||
const MAX_AUDIO_BYTES = 50 * 1024 * 1024
|
||||
|
|
@ -28,9 +30,11 @@ export interface DynamicSchemaSlotRow {
|
|||
required?: boolean
|
||||
}
|
||||
|
||||
function slotMediaMode(kind: string): "image" | "audio" | "text" {
|
||||
function slotMediaMode(kind: string): "image" | "audio" | "pdf" | "table" | "text" {
|
||||
const u = kind.trim().toUpperCase()
|
||||
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"
|
||||
return "text"
|
||||
}
|
||||
|
|
@ -537,6 +541,103 @@ export interface DynamicSchemaSlotFieldProps {
|
|||
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({
|
||||
row,
|
||||
value,
|
||||
|
|
@ -546,10 +647,30 @@ export function DynamicSchemaSlotField({
|
|||
const mode = slotMediaMode(row.kind)
|
||||
const baseLabel =
|
||||
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 slotMeta = `${row.id} · ${row.kind}`
|
||||
|
||||
if (mode === "table") {
|
||||
return (
|
||||
<DynamicTableBuilder
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
slotLabel={slotLabel}
|
||||
slotMeta={slotMeta}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (mode === "text") {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
|
|
@ -561,7 +682,11 @@ export function DynamicSchemaSlotField({
|
|||
rows={3}
|
||||
value={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"
|
||||
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 (
|
||||
<DynamicAudioSlot
|
||||
value={value}
|
||||
|
|
|
|||
250
src/components/content-management/DynamicTableBuilder.tsx
Normal file
250
src/components/content-management/DynamicTableBuilder.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -778,9 +778,8 @@ export function PracticeQuestionEditorFields({
|
|||
{value.questionType === "DYNAMIC" && (
|
||||
<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">
|
||||
<span className="font-medium text-grayScale-800">Image / Audio</span> slots: drop file or paste URL
|
||||
(imports via <code className="rounded bg-white px-0.5 text-[11px]">POST /files/upload</code>). Other
|
||||
slots: text or JSON.
|
||||
Image, audio, and PDF slots support upload or a URL. Table slots use the visual builder. Other
|
||||
fields accept text or structured values where noted.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
|
||||
|
|
|
|||
57
src/lib/dynamicTableValue.ts
Normal file
57
src/lib/dynamicTableValue.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -1,7 +1,15 @@
|
|||
import type { CreateQuestionRequest, QuestionOption } from "../types/course.types"
|
||||
import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
|
||||
import { createEmptyTable, serializeTableSlotValue } from "./dynamicTableValue"
|
||||
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 {
|
||||
return def.stimulus_schema.length > 0 || def.response_schema.length > 0
|
||||
}
|
||||
|
|
@ -10,11 +18,55 @@ export function emptyDynamicFieldValuesForDefinition(
|
|||
def: QuestionTypeDefinition,
|
||||
): Record<string, string> {
|
||||
const o: Record<string, string> = {}
|
||||
for (const r of def.stimulus_schema) o[`stimulus:${r.id}`] = ""
|
||||
for (const r of def.response_schema) o[`response:${r.id}`] = ""
|
||||
for (const r of def.stimulus_schema) {
|
||||
o[`stimulus:${r.id}`] = defaultValueForSchemaSlot(r.kind)
|
||||
}
|
||||
for (const r of def.response_schema) {
|
||||
o[`response:${r.id}`] = defaultValueForSchemaSlot(r.kind)
|
||||
}
|
||||
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.
|
||||
* Returns null when the payload must be DYNAMIC (schema-driven or unknown).
|
||||
|
|
@ -41,6 +93,24 @@ export interface LearnEnglishDefinitionQuestionInput {
|
|||
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(
|
||||
def: QuestionTypeDefinition,
|
||||
q: LearnEnglishDefinitionQuestionInput,
|
||||
|
|
@ -51,13 +121,17 @@ export function buildCreateQuestionFromDefinition(
|
|||
const question_text = q.questionText.trim()
|
||||
|
||||
if (definitionUsesDynamicPayload(def)) {
|
||||
const fieldValues = mergePromptIntoDynamicFieldValues(
|
||||
def,
|
||||
q.questionText,
|
||||
q.dynamicFieldValues ?? {},
|
||||
)
|
||||
const payload = buildDynamicQuestionPayload({
|
||||
stimulusRows: def.stimulus_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 {
|
||||
question_text,
|
||||
question_type: "DYNAMIC",
|
||||
question_type_definition_id: def.id,
|
||||
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 {
|
||||
question_text,
|
||||
question_type: "DYNAMIC",
|
||||
question_type_definition_id: def.id,
|
||||
difficulty_level: difficulty,
|
||||
|
|
@ -136,24 +208,36 @@ export function validateDefinitionQuestion(
|
|||
index1Based: number,
|
||||
): string | null {
|
||||
const n = index1Based
|
||||
if (!q.questionText.trim()) return `Question ${n}: enter question text.`
|
||||
|
||||
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) {
|
||||
if (!row.required) continue
|
||||
const v = (q.dynamicFieldValues ?? {})[`stimulus:${row.id}`]?.trim()
|
||||
const v = fieldValues[`stimulus:${row.id}`]?.trim()
|
||||
if (!v)
|
||||
return `Question ${n}: fill required stimulus "${row.label || row.id}" (${row.kind}).`
|
||||
}
|
||||
for (const row of def.response_schema) {
|
||||
if (!row.required) continue
|
||||
const v = (q.dynamicFieldValues ?? {})[`response:${row.id}`]?.trim()
|
||||
const v = fieldValues[`response:${row.id}`]?.trim()
|
||||
if (!v)
|
||||
return `Question ${n}: fill required response "${row.label || row.id}" (${row.kind}).`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (!q.questionText.trim()) return `Question ${n}: enter question text.`
|
||||
|
||||
const legacy = legacyQuestionTypeFromDefinition(def)
|
||||
if (legacy === "MCQ") {
|
||||
const opts = (q.mcqOptions ?? []).filter((o) => o.option_text.trim())
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type { PracticeParentKind } from "../types/course.types"
|
|||
import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
|
||||
import {
|
||||
buildCreateQuestionFromDefinition,
|
||||
questionRowHasContent,
|
||||
validateDefinitionQuestion,
|
||||
type LearnEnglishDefinitionQuestionInput,
|
||||
} from "./learnEnglishDefinitionQuestion"
|
||||
|
|
@ -30,9 +31,12 @@ export function validateLearnEnglishQuestionsWithDefinitions(
|
|||
questions: LearnEnglishDefinitionQuestionInput[],
|
||||
definitions: QuestionTypeDefinition[],
|
||||
): 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 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++) {
|
||||
const q = filled[i]
|
||||
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
|
||||
for (const q of toCreate) {
|
||||
const def = byId.get(q.questionTypeDefinitionId)
|
||||
|
|
|
|||
6
src/lib/tablePagination.ts
Normal file
6
src/lib/tablePagination.ts
Normal 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
|
||||
|
|
@ -373,7 +373,19 @@ export function AddNewPracticePage() {
|
|||
})
|
||||
: undefined;
|
||||
|
||||
const qRes = await createQuestion({
|
||||
const qRes = await createQuestion(
|
||||
q.questionType === "DYNAMIC" && q.questionTypeDefinitionId != null && dynamicPayload
|
||||
? {
|
||||
question_type: "DYNAMIC",
|
||||
question_type_definition_id: q.questionTypeDefinitionId,
|
||||
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,
|
||||
|
|
@ -382,21 +394,13 @@ export function AddNewPracticePage() {
|
|||
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_definition_id: q.questionTypeDefinitionId,
|
||||
dynamic_payload: dynamicPayload,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
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;
|
||||
if (questionId) {
|
||||
|
|
|
|||
|
|
@ -216,9 +216,7 @@ export function AddPracticeFlow() {
|
|||
return;
|
||||
}
|
||||
const persona = personaFromId(selectedPersona, personas);
|
||||
const mappedQuestions = formData.questions
|
||||
.filter((q) => String(q.text ?? "").trim())
|
||||
.map((q) => ({
|
||||
const mappedQuestions = formData.questions.map((q) => ({
|
||||
questionText: String(q.text ?? "").trim(),
|
||||
questionTypeDefinitionId: Number(q.questionTypeDefinitionId),
|
||||
dynamicFieldValues: { ...(q.dynamicFieldValues ?? {}) },
|
||||
|
|
@ -372,6 +370,7 @@ export function AddPracticeFlow() {
|
|||
onCancel={() => navigate(backPath)}
|
||||
isLessonPractice={isLessonPractice}
|
||||
lessonTitle={lessonTitleDisplay}
|
||||
parentSummary={parentSummary}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
|
|
@ -515,9 +514,7 @@ export function AddPracticeFlow() {
|
|||
</Button>
|
||||
</div>
|
||||
<p className="text-grayScale-400 text-base">
|
||||
Create a practice: question types from{" "}
|
||||
<code className="text-xs">GET /questions/type-definitions</code>, then
|
||||
question set and POST /practices.
|
||||
Create a practice with story details, a persona, and questions from your question type library.
|
||||
</p>
|
||||
{lessonId ? (
|
||||
<div className="mt-4 rounded-xl border border-violet-200 bg-violet-50/80 px-4 py-3 text-sm text-violet-950">
|
||||
|
|
|
|||
|
|
@ -268,7 +268,7 @@ export function AddQuestionPage() {
|
|||
return
|
||||
}
|
||||
} 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
|
||||
}
|
||||
}
|
||||
|
|
@ -419,7 +419,7 @@ export function AddQuestionPage() {
|
|||
</div>
|
||||
<div>
|
||||
<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>
|
||||
<Textarea
|
||||
value={formData.dynamicPayloadJson}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
import { Textarea } from "../../components/ui/textarea"
|
||||
import { toast } from "sonner"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
|
||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
|
||||
|
||||
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"
|
||||
>
|
||||
{[10, 20, 50].map((size) => (
|
||||
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<DialogTitle>Edit module</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update name, sort order, and icon (upload or URL). Saved with{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
||||
PUT /modules/:id
|
||||
</code>
|
||||
.
|
||||
Update name, sort order, and icon (upload or URL).
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4">
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import {
|
|||
} from "../../api/courses.api"
|
||||
import type { CategorySubCategoryListItem, CourseCategory } from "../../types/course.types"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
|
||||
|
||||
export function CoursesPage() {
|
||||
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"
|
||||
>
|
||||
{[10, 20, 50].map((size) => (
|
||||
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import type {
|
|||
} from "../../types/questionTypeDefinition.types"
|
||||
import {
|
||||
buildCreatePayload,
|
||||
buildValidateKindsPayload,
|
||||
validateDefinitionBasic,
|
||||
validateDefinitionKinds,
|
||||
validateDefinitionSchemas,
|
||||
|
|
@ -75,9 +76,9 @@ export function CreateQuestionTypeFlow() {
|
|||
|
||||
const [currentStep, setCurrentStep] = useState(1)
|
||||
const [draft, setDraft] = useState<QuestionTypeDefinitionCreatePayload>(initialDraft)
|
||||
const [versionName, setVersionName] = useState("Test 1")
|
||||
const [stepErrors, setStepErrors] = useState<FieldErrorMap>({})
|
||||
const [definitionReady, setDefinitionReady] = useState(!isEdit)
|
||||
const [isSystemDefinition, setIsSystemDefinition] = useState(false)
|
||||
|
||||
const [componentCatalog, setComponentCatalog] = useState<QuestionComponentCatalog>({
|
||||
stimulus_component_kinds: [],
|
||||
|
|
@ -134,7 +135,7 @@ export function CreateQuestionTypeFlow() {
|
|||
return
|
||||
}
|
||||
setDraft(definitionToDraft(def))
|
||||
setVersionName("Test 1")
|
||||
setIsSystemDefinition(Boolean(def.is_system))
|
||||
setCurrentStep(1)
|
||||
setStepErrors({})
|
||||
} catch (e) {
|
||||
|
|
@ -155,7 +156,6 @@ export function CreateQuestionTypeFlow() {
|
|||
useEffect(() => {
|
||||
if (!isEdit) {
|
||||
setDraft(initialDraft())
|
||||
setVersionName("Test 1")
|
||||
setCurrentStep(1)
|
||||
setStepErrors({})
|
||||
setDefinitionReady(true)
|
||||
|
|
@ -179,15 +179,10 @@ export function CreateQuestionTypeFlow() {
|
|||
}
|
||||
|
||||
const handleNextFromStep2 = () => {
|
||||
const versionErr: FieldErrorMap = {}
|
||||
if (!versionName.trim()) {
|
||||
versionErr.version_name = "Version name is required."
|
||||
}
|
||||
const eKinds = validateDefinitionKinds(draft, componentCatalog)
|
||||
const mergedKinds = { ...versionErr, ...eKinds }
|
||||
setStepErrors(mergedKinds)
|
||||
if (Object.keys(mergedKinds).length) {
|
||||
toast.error("Complete version name and component selections.")
|
||||
setStepErrors(eKinds)
|
||||
if (Object.keys(eKinds).length) {
|
||||
toast.error("Select valid stimulus and response component kinds.")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -233,7 +228,7 @@ export function CreateQuestionTypeFlow() {
|
|||
navigate(`/new-content/question-types?updated=${id}`)
|
||||
return
|
||||
}
|
||||
const validation = await validateQuestionTypeDefinition(body)
|
||||
const validation = await validateQuestionTypeDefinition(buildValidateKindsPayload(body))
|
||||
if (!validation.valid) {
|
||||
toast.error(validation.message || "Invalid question type definition", {
|
||||
description: validation.error ? String(validation.error) : undefined,
|
||||
|
|
@ -290,20 +285,9 @@ export function CreateQuestionTypeFlow() {
|
|||
{isEdit ? "Edit question type definition" : "Create question type definition"}
|
||||
</h1>
|
||||
<p className="text-grayScale-500 text-[14px] font-medium max-w-2xl">
|
||||
{isEdit ? (
|
||||
<>
|
||||
Update definition{" "}
|
||||
<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>.
|
||||
</>
|
||||
)}
|
||||
{isEdit
|
||||
? `Update reusable question type definition #${editDefinitionId}.`
|
||||
: "Build a reusable question type template for dynamic practice and assessment questions."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 shrink-0">
|
||||
|
|
@ -343,8 +327,6 @@ export function CreateQuestionTypeFlow() {
|
|||
<QuestionTypeConfigStep
|
||||
draft={draft}
|
||||
setDraft={setDraft}
|
||||
versionName={versionName}
|
||||
setVersionName={setVersionName}
|
||||
stimulusCatalogKinds={componentCatalog.stimulus_component_kinds}
|
||||
responseCatalogKinds={componentCatalog.response_component_kinds}
|
||||
catalogLoading={catalogLoading}
|
||||
|
|
@ -358,7 +340,12 @@ export function CreateQuestionTypeFlow() {
|
|||
<QuestionTypeValidatePreviewStep draft={draft} onNext={() => setCurrentStep(4)} onBack={handleBack} />
|
||||
)}
|
||||
{currentStep === 4 && (
|
||||
<QuestionTypeReviewPublishStep draft={draft} onBack={handleBack} editDefinitionId={editDefinitionId} />
|
||||
<QuestionTypeReviewPublishStep
|
||||
draft={draft}
|
||||
onBack={handleBack}
|
||||
editDefinitionId={editDefinitionId}
|
||||
isSystem={isSystemDefinition}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -787,7 +787,7 @@ export function HumanLanguageHierarchyPage() {
|
|||
<div>
|
||||
<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">
|
||||
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>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -1019,7 +1019,7 @@ export function HumanLanguageHierarchyPage() {
|
|||
<DialogHeader>
|
||||
<DialogTitle>Create module</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a module to this level. This will call `POST /course-management/modules`.
|
||||
Add a module to this level.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -1140,7 +1140,7 @@ export function HumanLanguageHierarchyPage() {
|
|||
<DialogHeader>
|
||||
<DialogTitle>Update module</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update this module using `PUT /course-management/modules/:moduleId`.
|
||||
Update this module's name, order, and settings.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
|
|||
|
|
@ -1029,7 +1029,7 @@ export function HumanLanguageSubModulePage() {
|
|||
<DialogHeader>
|
||||
<DialogTitle>Lesson detail</DialogTitle>
|
||||
<DialogDescription>
|
||||
Loaded from `GET /course-management/sub-module-lessons/:lessonId`.
|
||||
View and edit lesson details.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
|
|||
|
|
@ -356,15 +356,8 @@ export function LearnEnglishPage() {
|
|||
Add New Program
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-grayScale-400">
|
||||
Create a learning program via{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
|
||||
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>
|
||||
.
|
||||
Create a new learning program. Add a thumbnail as an image URL or by uploading a
|
||||
file.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{/* Gradient Divider */}
|
||||
|
|
@ -739,11 +732,7 @@ export function LearnEnglishPage() {
|
|||
disabled={savingEdit || uploadingEditThumbnail}
|
||||
/>
|
||||
<p className="text-xs text-grayScale-500">
|
||||
Local images are sent to{" "}
|
||||
<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.
|
||||
Uploaded images are stored and used as the program thumbnail.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -364,12 +364,6 @@ export function LessonPracticesPage() {
|
|||
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>
|
||||
|
|
|
|||
|
|
@ -668,15 +668,7 @@ export function ModuleDetailPage() {
|
|||
<DialogHeader>
|
||||
<DialogTitle>Edit lesson</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update details. Video and thumbnail files use{" "}
|
||||
<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>
|
||||
.
|
||||
Update lesson details. Uploaded video and thumbnail files are stored automatically.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-2">
|
||||
|
|
|
|||
|
|
@ -1093,9 +1093,6 @@ export function PracticeDetailsPage() {
|
|||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-grayScale-500">
|
||||
Uses <span className="font-mono">PUT /practices/{id}</span> with the fields above.
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button type="button" variant="outline" onClick={() => setEditOpen(false)} disabled={savePracticeLoading}>
|
||||
|
|
@ -1115,9 +1112,8 @@ export function PracticeDetailsPage() {
|
|||
<DialogTitle>Delete this practice?</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-grayScale-600">
|
||||
This will call <span className="font-mono">DELETE /practices/{id}</span> and remove the practice
|
||||
for this {parentTabCopy[parentTab].label.toLowerCase()}. The question set is not deleted unless your API
|
||||
cascades.
|
||||
This permanently removes the practice for this {parentTabCopy[parentTab].label.toLowerCase()}. The linked
|
||||
question set may remain unless you remove it separately.
|
||||
</p>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { Input } from "../../components/ui/input"
|
|||
import { Select } from "../../components/ui/select"
|
||||
import { Textarea } from "../../components/ui/textarea"
|
||||
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 DifficultyLevel = "EASY" | "MEDIUM" | "HARD"
|
||||
|
|
@ -84,7 +85,7 @@ export function PracticeQuestionsPage() {
|
|||
const [loadingDetailIds, setLoadingDetailIds] = useState<Record<number, boolean>>({})
|
||||
const [groupBy, setGroupBy] = useState<GroupByOption>("none")
|
||||
const [pointsSort, setPointsSort] = useState<PointsSortOption>("desc")
|
||||
const [pageSize] = useState(10)
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [totalQuestions, setTotalQuestions] = useState(0)
|
||||
|
||||
|
|
@ -736,11 +737,37 @@ export function PracticeQuestionsPage() {
|
|||
))}
|
||||
</div>
|
||||
))}
|
||||
{totalQuestions > pageSize && (
|
||||
<div className="flex items-center justify-between rounded-xl border border-grayScale-200 bg-white px-4 py-3">
|
||||
<p className="text-sm text-grayScale-500">
|
||||
Page {currentPage} of {Math.max(1, Math.ceil(totalQuestions / pageSize))}
|
||||
</p>
|
||||
{totalQuestions > 0 && (
|
||||
<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">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span>
|
||||
Page {currentPage} of {Math.max(1, Math.ceil(totalQuestions / pageSize))} ({totalQuestions}{" "}
|
||||
total)
|
||||
</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}
|
||||
onChange={(e) => {
|
||||
setPageSize(Number(e.target.value))
|
||||
setCurrentPage(1)
|
||||
void fetchQuestions(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"
|
||||
>
|
||||
{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>
|
||||
{totalQuestions > pageSize ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -759,6 +786,7 @@ export function PracticeQuestionsPage() {
|
|||
Next
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -379,15 +379,8 @@ export function ProgramCoursesPage() {
|
|||
Add New Course
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-grayScale-400">
|
||||
Create a course via{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
|
||||
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>
|
||||
.
|
||||
Add a new course to this program. Use an image URL or upload a file for the
|
||||
thumbnail.
|
||||
</DialogDescription>
|
||||
</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">
|
||||
<DialogTitle>Edit course</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update name, sort order, and thumbnail. Saved with{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
||||
PUT /courses/:id
|
||||
</code>
|
||||
.
|
||||
Update name, sort order, and thumbnail.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4">
|
||||
|
|
|
|||
|
|
@ -124,9 +124,7 @@ export function QuestionTypeLibraryPage() {
|
|||
<div className="space-y-1">
|
||||
<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">
|
||||
Reusable dynamic question type templates from{" "}
|
||||
<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.
|
||||
Reusable templates that define how practice and assessment questions are structured and answered.
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/new-content/question-types/create">
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { Badge } from "../../components/ui/badge"
|
|||
import { deleteQuestion, getQuestionById, getQuestions, updateQuestion } from "../../api/courses.api"
|
||||
import type { QuestionDetail } from "../../types/course.types"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
|
||||
|
||||
type QuestionTypeFilter = "all" | "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO"
|
||||
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"
|
||||
>
|
||||
{[10, 20, 50].map((size) => (
|
||||
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
} from "../../components/ui/dropdown-menu"
|
||||
import type { Course, CourseCategory, QuestionDetail, QuestionSet, SubCourse } from "../../types/course.types"
|
||||
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 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 [audioTotalCount, setAudioTotalCount] = useState(0)
|
||||
const [audioPage, setAudioPage] = useState(1)
|
||||
const [audioPageSize] = useState(12)
|
||||
const [audioPageSize, setAudioPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
|
||||
const [practiceOptions, setPracticeOptions] = useState<PracticeFilterOption[]>([])
|
||||
const [selectedPracticeId, setSelectedPracticeId] = useState<string>("")
|
||||
const [practiceFilterOpen, setPracticeFilterOpen] = useState(false)
|
||||
|
|
@ -1510,11 +1511,37 @@ export function SpeakingPage() {
|
|||
))}
|
||||
</div>
|
||||
))}
|
||||
{audioTotalCount > 0 ? (
|
||||
<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">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span>
|
||||
Page {audioPage} of {Math.max(1, Math.ceil(audioTotalCount / audioPageSize))} (
|
||||
{audioTotalCount} total)
|
||||
</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={audioPageSize}
|
||||
disabled={loading}
|
||||
onChange={(e) => {
|
||||
setAudioPageSize(Number(e.target.value))
|
||||
void fetchAudioQuestions(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"
|
||||
>
|
||||
{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>
|
||||
{audioTotalCount > audioPageSize ? (
|
||||
<div className="mt-4 flex items-center justify-between rounded-xl border border-grayScale-200 bg-white px-3 py-2">
|
||||
<p className="text-xs text-grayScale-500 sm:text-sm">
|
||||
Page {audioPage} of {Math.max(1, Math.ceil(audioTotalCount / audioPageSize))}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -1535,6 +1562,7 @@ export function SpeakingPage() {
|
|||
Next
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -111,11 +111,7 @@ export function AddModuleModal({
|
|||
Add New Module
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-grayScale-400">
|
||||
Create a module with{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
||||
POST /courses/:courseId/modules
|
||||
</code>
|
||||
.
|
||||
Add a new module to this course.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
|
|||
|
|
@ -265,8 +265,7 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
|
|||
/>
|
||||
</div>
|
||||
<p className="text-xs text-grayScale-500">
|
||||
This calls <span className="font-mono">POST /question-sets</span> with{" "}
|
||||
<span className="font-mono">set_type: PRACTICE</span>.
|
||||
Creates a practice question set for this course, module, or lesson.
|
||||
</p>
|
||||
<Button type="button" onClick={handleStep1} disabled={saving}>
|
||||
{saving ? <SpinnerIcon className="h-4 w-4" /> : null}
|
||||
|
|
@ -278,9 +277,7 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
|
|||
{canUseWizard && step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-grayScale-600">
|
||||
Set id <span className="font-mono font-medium text-grayScale-800">#{questionSetId}</span> — add
|
||||
one or more <strong>AUDIO</strong> questions. Each is created via{" "}
|
||||
<span className="font-mono">POST /questions</span>.
|
||||
Add one or more audio questions to question set #{questionSetId}.
|
||||
</p>
|
||||
{questionRows.map((row, idx) => (
|
||||
<div
|
||||
|
|
@ -389,8 +386,7 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
|
|||
{canUseWizard && step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-grayScale-600">
|
||||
Link each question to the set with a display order using{" "}
|
||||
<span className="font-mono">POST /question-sets/{id}/questions</span>.
|
||||
Confirm the order of questions in the set.
|
||||
</p>
|
||||
<ul className="space-y-2 rounded-xl border border-grayScale-200 bg-white p-3">
|
||||
{createdQuestionIds.map((qid, i) => (
|
||||
|
|
@ -422,12 +418,7 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
|
|||
{canUseWizard && step === 4 && parent && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-grayScale-600">
|
||||
Parent:{" "}
|
||||
<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>
|
||||
Linked to {parent.kind.toLowerCase()} #{parent.id} · question set #{questionSetId}
|
||||
</p>
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ interface ContextStepProps {
|
|||
/** Lesson-linked practice: no title, story description, or story image on step 1. */
|
||||
isLessonPractice?: boolean;
|
||||
lessonTitle?: string | null;
|
||||
parentSummary?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -27,6 +28,7 @@ export function ContextStep({
|
|||
onCancel,
|
||||
isLessonPractice = false,
|
||||
lessonTitle = null,
|
||||
parentSummary = null,
|
||||
}: ContextStepProps) {
|
||||
const storyFileRef = useRef<HTMLInputElement>(null);
|
||||
const [uploadingStory, setUploadingStory] = useState(false);
|
||||
|
|
@ -62,16 +64,15 @@ export function ContextStep({
|
|||
<p className="text-grayScale-600 text-base mt-3">
|
||||
{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">
|
||||
{lessonTitle?.trim() || "the selected lesson"}
|
||||
</span>
|
||||
. Set optional quick tips and question order below.
|
||||
.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Title, story, optional image, shuffle, and quick tips match the create
|
||||
practice and question set APIs.
|
||||
Story fields and question set options used when saving the practice.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
|
@ -90,6 +91,16 @@ export function ContextStep({
|
|||
</div>
|
||||
|
||||
<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 ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
|
|
@ -132,7 +143,7 @@ export function ContextStep({
|
|||
onChange={(e) =>
|
||||
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"
|
||||
maxLength={1000}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -15,8 +15,7 @@ export function PublishStatusField({ value, onChange, disabled, className }: Pro
|
|||
Publish status <span className="text-red-500">*</span>
|
||||
</p>
|
||||
<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{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">POST /practices</code>.
|
||||
Controls whether learners can see this practice after you save.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3" role="radiogroup" aria-label="Publish status">
|
||||
{(
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import {
|
|||
emptyDynamicFieldValuesForDefinition,
|
||||
legacyQuestionTypeFromDefinition,
|
||||
} from "../../../../lib/learnEnglishDefinitionQuestion";
|
||||
import { validateLearnEnglishQuestionsWithDefinitions } from "../../../../lib/learnEnglishPracticePublish";
|
||||
import { toast } from "sonner";
|
||||
|
||||
function defaultMcqOptions() {
|
||||
return [
|
||||
|
|
@ -98,9 +100,9 @@ export function QuestionsStep({
|
|||
return (
|
||||
<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">
|
||||
<span className="font-medium text-grayScale-800">Image / Audio</span> slots use upload or URL import (
|
||||
<code className="rounded bg-white px-0.5 text-[11px]">POST /files/upload</code>
|
||||
). Others: URL, text, or JSON.
|
||||
<span className="font-medium text-grayScale-800">Image / Audio / PDF</span> use upload or URL.{" "}
|
||||
<span className="font-medium text-grayScale-800">Table</span> uses the visual table builder. Other
|
||||
slots: text or structured JSON where noted.
|
||||
</p>
|
||||
{def.stimulus_schema.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
|
|
@ -307,11 +309,8 @@ export function QuestionsStep({
|
|||
<div className="space-y-1 px-2">
|
||||
<h2 className="text-2xl font-bold text-grayScale-700">Questions</h2>
|
||||
<p className="text-grayScale-400 text-lg">
|
||||
Question types are loaded from{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 text-sm">
|
||||
GET /questions/type-definitions
|
||||
</code>
|
||||
. Pick a type per row, then fill the fields required for that definition.
|
||||
Choose a question type for each item, then fill in the fields that type requires. Questions are saved
|
||||
when you publish or save the practice.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -403,6 +402,7 @@ export function QuestionsStep({
|
|||
) : null}
|
||||
</div>
|
||||
|
||||
{def && !definitionUsesDynamicPayload(def) ? (
|
||||
<div className="space-y-3">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-grayScale-700">
|
||||
Question text
|
||||
|
|
@ -418,6 +418,12 @@ export function QuestionsStep({
|
|||
placeholder="Question prompt for learners"
|
||||
/>
|
||||
</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}
|
||||
</div>
|
||||
|
|
@ -451,7 +457,30 @@ export function QuestionsStep({
|
|||
</Button>
|
||||
<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}
|
||||
className="h-10 rounded-[6px] bg-brand-500 px-8 font-bold disabled:opacity-50"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -29,10 +29,8 @@ export function QuestionTypeBasicInfoStep({
|
|||
<div className="p-10 border-b border-grayScale-200">
|
||||
<h2 className="text-[20px] font-medium text-grayScale-900">STEP 1: Definition basics</h2>
|
||||
<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
|
||||
component types from the live catalog (
|
||||
<code className="text-xs bg-grayScale-100 px-1 rounded">GET /questions/component-catalog</code>
|
||||
).
|
||||
Set the reusable key, display name, and status. On the next step you will choose how questions are
|
||||
presented and how learners answer.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,5 @@
|
|||
import { useState } from "react"
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Hourglass,
|
||||
Plus,
|
||||
} from "lucide-react"
|
||||
import { ArrowLeft, ArrowRight, ChevronDown, ChevronUp, Plus } from "lucide-react"
|
||||
import { Button } from "../../../../components/ui/button"
|
||||
import { Card } from "../../../../components/ui/card"
|
||||
import { Input } from "../../../../components/ui/input"
|
||||
|
|
@ -22,8 +15,6 @@ import { getResponseKindPresentation, getStimulusKindPresentation } from "./comp
|
|||
interface QuestionTypeConfigStepProps {
|
||||
draft: QuestionTypeDefinitionCreatePayload
|
||||
setDraft: React.Dispatch<React.SetStateAction<QuestionTypeDefinitionCreatePayload>>
|
||||
versionName: string
|
||||
setVersionName: (v: string) => void
|
||||
stimulusCatalogKinds: string[]
|
||||
responseCatalogKinds: string[]
|
||||
catalogLoading: boolean
|
||||
|
|
@ -73,8 +64,6 @@ function rowErrorMap(side: "stimulus" | "response", errors: FieldErrorMap): Reco
|
|||
export function QuestionTypeConfigStep({
|
||||
draft,
|
||||
setDraft,
|
||||
versionName,
|
||||
setVersionName,
|
||||
stimulusCatalogKinds,
|
||||
responseCatalogKinds,
|
||||
catalogLoading,
|
||||
|
|
@ -83,11 +72,8 @@ export function QuestionTypeConfigStep({
|
|||
onNext,
|
||||
onBack,
|
||||
}: QuestionTypeConfigStepProps) {
|
||||
const [panelOpen, setPanelOpen] = useState(true)
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
|
||||
const title = draft.display_name?.trim() || "Untitled definition"
|
||||
|
||||
const handleStimulusKindClick = (kind: string) => {
|
||||
setDraft((d) => {
|
||||
const wasSelected = d.stimulus_component_kinds.includes(kind)
|
||||
|
|
@ -167,44 +153,15 @@ export function QuestionTypeConfigStep({
|
|||
return (
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPanelOpen((o) => !o)}
|
||||
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"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="h-10 w-10 rounded-xl bg-white/80 flex items-center justify-center text-violet-700 shrink-0 shadow-sm">
|
||||
<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).
|
||||
<div className="p-10 border-b border-grayScale-200">
|
||||
<h2 className="text-[20px] font-medium text-grayScale-900">STEP 2: Input & answer types</h2>
|
||||
<p className="text-grayScale-500 font-medium mt-1">
|
||||
Choose what learners see in the question and how they respond. You can add multiple fields of the
|
||||
same type when needed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 sm:p-10 space-y-10">
|
||||
{catalogLoading ? (
|
||||
<p className="text-sm text-grayScale-500">Loading component catalog…</p>
|
||||
) : catalogError ? (
|
||||
|
|
@ -217,10 +174,8 @@ export function QuestionTypeConfigStep({
|
|||
Section A: Question input types
|
||||
</h3>
|
||||
<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{" "}
|
||||
<code className="text-[11px] bg-grayScale-100 px-1 rounded">stimulus_component_kinds</code>{" "}
|
||||
while <code className="text-[11px] bg-grayScale-100 px-1 rounded">stimulus_schema</code> can
|
||||
include the same kind multiple times (different ids).
|
||||
Choose how the question is presented to the learner. Use Add slot for multiple fields of
|
||||
the same type (for example, two text blocks).
|
||||
</p>
|
||||
</div>
|
||||
<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
|
||||
</h3>
|
||||
<p className="text-[14px] text-grayScale-500 mt-1 font-medium">
|
||||
How should the student answer?{" "}
|
||||
<code className="text-[11px] bg-grayScale-100 px-1 rounded">response_component_kinds</code> is
|
||||
deduplicated; use <span className="font-medium text-grayScale-600">Add slot</span> for multiple
|
||||
fields of the same kind.
|
||||
How should the student answer? Use Add slot when you need more than one field of the same
|
||||
answer type.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
||||
|
|
@ -354,7 +307,6 @@ export function QuestionTypeConfigStep({
|
|||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="px-4 py-4 border-t border-grayScale-200 flex items-center justify-between bg-[#F8FAFC]">
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -11,30 +11,53 @@ import {
|
|||
validateQuestionTypeDefinition,
|
||||
} from "../../../../api/questionTypeDefinitions.api"
|
||||
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 {
|
||||
draft: QuestionTypeDefinitionCreatePayload
|
||||
onBack: () => void
|
||||
/** When set, saves via PUT /questions/type-definitions/:id */
|
||||
editDefinitionId?: number | null
|
||||
isSystem?: boolean
|
||||
}
|
||||
|
||||
export function QuestionTypeReviewPublishStep({
|
||||
draft,
|
||||
onBack,
|
||||
editDefinitionId,
|
||||
isSystem,
|
||||
}: QuestionTypeReviewPublishStepProps) {
|
||||
const navigate = useNavigate()
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const isEdit = editDefinitionId != null && editDefinitionId > 0
|
||||
|
||||
const payload = buildCreatePayload(draft)
|
||||
const runtime = inferRuntimeQuestionType(payload.key, payload.response_component_kinds)
|
||||
|
||||
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 }
|
||||
setSubmitting(true)
|
||||
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) {
|
||||
const res = await updateQuestionTypeDefinition(editDefinitionId, body)
|
||||
const id = extractDefinitionMutationId(res) ?? editDefinitionId
|
||||
|
|
@ -45,14 +68,6 @@ export function QuestionTypeReviewPublishStep({
|
|||
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 id = extractDefinitionMutationId(res)
|
||||
if (id == null) {
|
||||
|
|
@ -81,30 +96,30 @@ export function QuestionTypeReviewPublishStep({
|
|||
<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">
|
||||
<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 & publish</h2>
|
||||
<p className="text-grayScale-500 font-medium mt-1">
|
||||
{isEdit ? (
|
||||
<>
|
||||
Confirm changes, then update via{" "}
|
||||
<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>.
|
||||
</>
|
||||
)}
|
||||
{isEdit
|
||||
? "Confirm your changes and save. The definition key cannot be changed."
|
||||
: "Confirm your definition, then save it for use when authoring practice questions."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
<dd className="font-medium text-grayScale-900 mt-1">{draft.status}</dd>
|
||||
</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>
|
||||
<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>
|
||||
|
|
@ -126,33 +145,42 @@ export function QuestionTypeReviewPublishStep({
|
|||
<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>
|
||||
</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>
|
||||
|
||||
<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">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-11"
|
||||
disabled={submitting}
|
||||
disabled={submitting || runtime == null}
|
||||
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
|
||||
type="button"
|
||||
className="h-11 bg-[#9E2891] hover:bg-[#8A237E] text-white"
|
||||
disabled={submitting}
|
||||
disabled={submitting || runtime == null}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -173,3 +201,33 @@ export function QuestionTypeReviewPublishStep({
|
|||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import { useState } from "react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { ArrowLeft, ArrowRight, CheckCircle2, Loader2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "../../../../components/ui/button"
|
||||
import { Card } from "../../../../components/ui/card"
|
||||
import { validateQuestionTypeDefinition } from "../../../../api/questionTypeDefinitions.api"
|
||||
import type { QuestionTypeDefinitionCreatePayload } from "../../../../types/questionTypeDefinition.types"
|
||||
import { buildCreatePayload } from "../../lib/questionTypeDefinitionValidation"
|
||||
import {
|
||||
buildCreatePayload,
|
||||
buildValidateKindsPayload,
|
||||
} from "../../lib/questionTypeDefinitionValidation"
|
||||
import { DefinitionRuntimeHint } from "./DefinitionRuntimeHint"
|
||||
|
||||
interface QuestionTypeValidatePreviewStepProps {
|
||||
draft: QuestionTypeDefinitionCreatePayload
|
||||
|
|
@ -25,12 +29,12 @@ export function QuestionTypeValidatePreviewStep({
|
|||
const payload = buildCreatePayload(draft)
|
||||
const json = JSON.stringify(payload, null, 2)
|
||||
|
||||
const runValidate = async () => {
|
||||
const runValidate = useCallback(async () => {
|
||||
setValidating(true)
|
||||
setServerOk(null)
|
||||
setServerDetail(null)
|
||||
try {
|
||||
const res = await validateQuestionTypeDefinition(payload)
|
||||
const res = await validateQuestionTypeDefinition(buildValidateKindsPayload(draft))
|
||||
if (!res.valid) {
|
||||
setServerOk(false)
|
||||
const detail = res.error || res.message || "Validation failed"
|
||||
|
|
@ -39,32 +43,49 @@ export function QuestionTypeValidatePreviewStep({
|
|||
return
|
||||
}
|
||||
setServerOk(true)
|
||||
setServerDetail(res.message || "Question type definition is valid.")
|
||||
toast.success(res.message || "Question type definition is valid")
|
||||
setServerDetail(res.message || "Component kinds are valid.")
|
||||
toast.success(res.message || "Definition kinds validated")
|
||||
} finally {
|
||||
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 (
|
||||
<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">
|
||||
<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">
|
||||
Optional server check via{" "}
|
||||
<code className="text-xs bg-grayScale-100 px-1 rounded">POST /questions/validate-question-type-definition</code>
|
||||
. 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).
|
||||
We check that your stimulus and response selections are valid before you continue. You must pass
|
||||
validation before review.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={runValidate}
|
||||
onClick={() => void runValidate()}
|
||||
disabled={validating}
|
||||
className="h-10"
|
||||
>
|
||||
|
|
@ -74,7 +95,7 @@ export function QuestionTypeValidatePreviewStep({
|
|||
Validating…
|
||||
</>
|
||||
) : (
|
||||
"Validate with server"
|
||||
"Re-validate"
|
||||
)}
|
||||
</Button>
|
||||
{serverOk === true ? (
|
||||
|
|
@ -83,12 +104,41 @@ export function QuestionTypeValidatePreviewStep({
|
|||
{serverDetail}
|
||||
</span>
|
||||
) : 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>
|
||||
|
||||
<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">
|
||||
<p className="text-[12px] font-bold uppercase tracking-wide text-grayScale-400">Create payload (JSON)</p>
|
||||
<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">
|
||||
<p className="text-[12px] font-bold uppercase tracking-wide text-grayScale-400">
|
||||
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}
|
||||
</pre>
|
||||
</div>
|
||||
|
|
@ -106,10 +156,11 @@ export function QuestionTypeValidatePreviewStep({
|
|||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onNext}
|
||||
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"
|
||||
onClick={handleNext}
|
||||
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" />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ const STIMULUS_LABELS: Record<string, string> = {
|
|||
SELECT_MISSING_WORDS: "Select Missing Words",
|
||||
TABLE: "Table",
|
||||
FLOW_CHART: "Flow Chart",
|
||||
PDF_ATTACHMENT: "PDF Attachment",
|
||||
}
|
||||
|
||||
const RESPONSE_LABELS: Record<string, string> = {
|
||||
|
|
@ -64,6 +65,7 @@ const STIMULUS_ICONS: Record<string, LucideIcon> = {
|
|||
SELECT_MISSING_WORDS: ListTodo,
|
||||
TABLE: TableIcon,
|
||||
FLOW_CHART: GitBranch,
|
||||
PDF_ATTACHMENT: FileUp,
|
||||
}
|
||||
|
||||
const RESPONSE_ICONS: Record<string, LucideIcon> = {
|
||||
|
|
|
|||
|
|
@ -104,12 +104,8 @@ export function VideoDetailStep({
|
|||
Video
|
||||
</h3>
|
||||
<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
|
||||
sent to your storage via{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 text-[11px]">
|
||||
POST /files/upload
|
||||
</code>
|
||||
.
|
||||
Upload a file or paste a link (Vimeo, hosted file, etc.). Uploaded files are stored
|
||||
automatically.
|
||||
</p>
|
||||
<LessonMediaUploadField
|
||||
kind="video"
|
||||
|
|
@ -260,12 +256,8 @@ export function VideoDetailStep({
|
|||
Pro tip
|
||||
</h3>
|
||||
<p className="text-[12px] text-grayScale-700 font-medium leading-relaxed">
|
||||
Use clear titles and a thumbnail that matches the lesson. The
|
||||
lesson is created with{" "}
|
||||
<code className="rounded bg-white/80 px-1 text-[10px]">
|
||||
POST /modules/:moduleId/lessons
|
||||
</code>{" "}
|
||||
when you publish.
|
||||
Use clear titles and a thumbnail that matches the lesson. The lesson is created when
|
||||
you publish.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -41,6 +41,18 @@ export function validateDefinitionKinds(
|
|||
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) {
|
||||
const sCat = new Set(catalog.stimulus_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))
|
||||
}
|
||||
|
||||
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(
|
||||
draft: QuestionTypeDefinitionCreatePayload,
|
||||
): QuestionTypeDefinitionCreatePayload {
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import {
|
|||
DialogDescription,
|
||||
} from "../../components/ui/dialog";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination";
|
||||
import { SpinnerIcon } from "../../components/ui/spinner-icon";
|
||||
import {
|
||||
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"
|
||||
>
|
||||
{[5, 10, 20, 30, 50].map((size) => (
|
||||
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
|
|
|
|||
|
|
@ -119,11 +119,7 @@ export function CreateEmailTemplatePage() {
|
|||
New custom template
|
||||
</h1>
|
||||
<p className="max-w-2xl text-sm text-grayScale-500">
|
||||
Create a custom template via{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 text-xs">
|
||||
POST /admin/email-templates
|
||||
</code>
|
||||
. System templates are managed separately.
|
||||
Create a custom email template. System templates are managed separately.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -78,11 +78,8 @@ export function EmailTemplatesPage() {
|
|||
Email Templates
|
||||
</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-grayScale-500">
|
||||
Templates from{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 text-xs">
|
||||
GET /admin/email-templates
|
||||
</code>
|
||||
. Open a template for full preview via slug API.
|
||||
View and edit email templates used for learner and team notifications. Open a template to
|
||||
preview and edit its content.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
|
|
|||
|
|
@ -71,8 +71,7 @@ import type { Role } from "../../types/rbac.types"
|
|||
import type { TeamMember } from "../../types/team.types"
|
||||
import type { UserApiDTO } from "../../types/user.types"
|
||||
import { toast } from "sonner"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
|
||||
|
||||
const TYPE_CONFIG: Record<string, { icon: React.ElementType; color: string; bg: string }> = {
|
||||
announcement: { icon: Megaphone, color: "text-brand-600", bg: "bg-brand-100" },
|
||||
|
|
@ -265,6 +264,7 @@ export function NotificationsPage() {
|
|||
const [totalCount, setTotalCount] = useState(0)
|
||||
const [globalUnread, setGlobalUnread] = useState(0)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(false)
|
||||
const [togglingIds, setTogglingIds] = useState<Set<string>>(new Set())
|
||||
|
|
@ -429,7 +429,7 @@ export function NotificationsPage() {
|
|||
setError(false)
|
||||
try {
|
||||
const [notifRes, unreadRes] = await Promise.all([
|
||||
getNotifications(PAGE_SIZE, currentOffset),
|
||||
getNotifications(pageSize, currentOffset),
|
||||
getUnreadCount(),
|
||||
])
|
||||
setNotifications(notifRes.data.notifications ?? [])
|
||||
|
|
@ -440,11 +440,11 @@ export function NotificationsPage() {
|
|||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
}, [pageSize])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData(offset)
|
||||
}, [offset, fetchData])
|
||||
}, [offset, pageSize, fetchData])
|
||||
|
||||
const handleToggleRead = useCallback(async (id: string, currentlyRead: boolean) => {
|
||||
setTogglingIds((prev) => new Set(prev).add(id))
|
||||
|
|
@ -495,10 +495,10 @@ export function NotificationsPage() {
|
|||
}
|
||||
}, [totalCount])
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
|
||||
const currentPage = Math.floor(offset / PAGE_SIZE) + 1
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize))
|
||||
const currentPage = Math.floor(offset / pageSize) + 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 pages: (number | string)[] = []
|
||||
|
|
@ -941,18 +941,25 @@ export function NotificationsPage() {
|
|||
<span className="border-l pl-4">Rows per page</span>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={PAGE_SIZE}
|
||||
disabled
|
||||
value={pageSize}
|
||||
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"
|
||||
>
|
||||
<option value={PAGE_SIZE}>{PAGE_SIZE}</option>
|
||||
{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>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => currentPage > 1 && setOffset(Math.max(0, offset - PAGE_SIZE))}
|
||||
onClick={() => currentPage > 1 && setOffset(Math.max(0, offset - pageSize))}
|
||||
disabled={currentPage <= 1}
|
||||
className={cn(
|
||||
"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
|
||||
key={n}
|
||||
type="button"
|
||||
onClick={() => setOffset((n - 1) * PAGE_SIZE)}
|
||||
onClick={() => setOffset((n - 1) * pageSize)}
|
||||
className={cn(
|
||||
"h-8 w-8 rounded-md border text-sm font-medium",
|
||||
n === currentPage
|
||||
|
|
@ -983,7 +990,7 @@ export function NotificationsPage() {
|
|||
),
|
||||
)}
|
||||
<button
|
||||
onClick={() => currentPage < totalPages && setOffset(offset + PAGE_SIZE)}
|
||||
onClick={() => currentPage < totalPages && setOffset(offset + pageSize)}
|
||||
disabled={currentPage >= totalPages}
|
||||
className={cn(
|
||||
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
|
||||
|
|
|
|||
|
|
@ -185,10 +185,7 @@ export function EmailTemplateCreateForm({
|
|||
<Button variant="outline" disabled={saving} onClick={onReset}>
|
||||
Reset
|
||||
</Button>
|
||||
<p className="text-xs text-grayScale-400">
|
||||
Saved with{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1">POST /admin/email-templates</code>
|
||||
</p>
|
||||
<p className="text-xs text-grayScale-400">Your changes are saved when you submit the form.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -102,11 +102,7 @@ export function EmailTemplateEditForm({
|
|||
<p className="mt-2 text-xs text-grayScale-400">
|
||||
Use Go template syntax, e.g.{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1">{`{{if .FirstName}}`}</code>.
|
||||
Saved with{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1">
|
||||
PUT /admin/email-templates/{template.id}
|
||||
</code>
|
||||
.
|
||||
Your changes are saved when you submit the form.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ import type {
|
|||
PaymentStatus,
|
||||
} 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 }[] = [
|
||||
{ value: "SUCCESS", label: "Success" },
|
||||
|
|
@ -168,8 +168,7 @@ export function PaymentsPage() {
|
|||
</p>
|
||||
<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">
|
||||
Browse checkout transactions from{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 text-xs">GET /admin/payments</code>.
|
||||
Browse and filter checkout transactions from Chapa, Arifpay, and other providers.
|
||||
</p>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
Search,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
AlertCircle,
|
||||
|
|
@ -37,6 +38,7 @@ import {
|
|||
} from "../../api/rbac.api"
|
||||
import type { Role, RoleDetail, RolePermission } from "../../types/rbac.types"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
|
||||
import { toast } from "sonner"
|
||||
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||
import { teamRoleFromRbacRole } from "../../lib/teamRoles"
|
||||
|
|
@ -49,7 +51,7 @@ export function RolesListPage() {
|
|||
const [roles, setRoles] = useState<Role[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize] = useState(20)
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
|
||||
const [query, setQuery] = useState("")
|
||||
const [debouncedQuery, setDebouncedQuery] = useState("")
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
|
@ -543,11 +545,35 @@ export function RolesListPage() {
|
|||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between border-t border-grayScale-100 pt-4">
|
||||
<p className="text-xs text-grayScale-400">
|
||||
{total > 0 && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-grayScale-100 pt-4 text-sm text-grayScale-500">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span>
|
||||
Showing {(page - 1) * pageSize + 1}–{Math.min(page * pageSize, total)} of {total} roles
|
||||
</p>
|
||||
</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}
|
||||
onChange={(e) => {
|
||||
setPageSize(Number(e.target.value))
|
||||
setPage(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"
|
||||
>
|
||||
{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>
|
||||
{totalPages > 1 ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -571,6 +597,7 @@ export function RolesListPage() {
|
|||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -153,12 +153,8 @@ export function InviteTeamMemberDialog({
|
|||
Invite team members
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-left text-grayScale-600">
|
||||
Sends one{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 text-xs">
|
||||
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>
|
||||
Send one invitation per email address. Invitees complete account setup using the link in
|
||||
their email
|
||||
{roleLocked ? (
|
||||
<>
|
||||
{" "}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
AlertTriangle,
|
||||
Apple,
|
||||
Calendar,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
|
|
@ -22,6 +23,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/ca
|
|||
import { Input } from "../../components/ui/input"
|
||||
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
|
||||
import {
|
||||
formatAppPlatform,
|
||||
formatAppVersionCreatedAt,
|
||||
|
|
@ -34,8 +36,6 @@ import { CreateAppVersionDialog } from "./components/CreateAppVersionDialog"
|
|||
import { DeleteAppVersionDialog } from "./components/DeleteAppVersionDialog"
|
||||
import { EditAppVersionDialog } from "./components/EditAppVersionDialog"
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
function PlatformIcon({ platform }: { platform: string }) {
|
||||
const upper = platform.toUpperCase()
|
||||
if (upper === "IOS") {
|
||||
|
|
@ -64,6 +64,7 @@ export function AppVersionsTab() {
|
|||
const [versions, setVersions] = useState<AppVersion[]>([])
|
||||
const [totalCount, setTotalCount] = useState(0)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
|
||||
const [query, setQuery] = useState("")
|
||||
const [platformFilter, setPlatformFilter] = useState<"all" | AppPlatform>("all")
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | AppVersionStatus>("all")
|
||||
|
|
@ -87,7 +88,7 @@ export function AppVersionsTab() {
|
|||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [offset])
|
||||
}, [offset, pageSize])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
|
|
@ -123,7 +124,7 @@ export function AppVersionsTab() {
|
|||
const pageStart = totalCount === 0 ? 0 : offset + 1
|
||||
const pageEnd = Math.min(offset + versions.length, totalCount)
|
||||
const canPrev = offset > 0
|
||||
const canNext = offset + PAGE_SIZE < totalCount
|
||||
const canNext = offset + pageSize < totalCount
|
||||
|
||||
const handleCreated = (version: AppVersion) => {
|
||||
if (offset === 0) {
|
||||
|
|
@ -155,12 +156,7 @@ export function AppVersionsTab() {
|
|||
</p>
|
||||
<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">
|
||||
Manage Android and iOS release metadata for in-app update prompts. Versions are loaded
|
||||
from{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 text-xs">
|
||||
GET /admin/app-versions
|
||||
</code>
|
||||
.
|
||||
Manage Android and iOS release metadata for in-app update prompts.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
|
@ -440,10 +436,34 @@ export function AppVersionsTab() {
|
|||
)}
|
||||
|
||||
{!loading && !error && totalCount > 0 ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-grayScale-100 pt-4">
|
||||
<p className="text-xs text-grayScale-500">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-grayScale-100 pt-4 text-sm text-grayScale-500">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span>
|
||||
Showing {pageStart}–{pageEnd} of {totalCount}
|
||||
</p>
|
||||
</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">
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -451,7 +471,7 @@ export function AppVersionsTab() {
|
|||
size="sm"
|
||||
className="rounded-[6px]"
|
||||
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" />
|
||||
Previous
|
||||
|
|
@ -462,7 +482,7 @@ export function AppVersionsTab() {
|
|||
size="sm"
|
||||
className="rounded-[6px]"
|
||||
disabled={!canNext || loading}
|
||||
onClick={() => setOffset((o) => o + PAGE_SIZE)}
|
||||
onClick={() => setOffset((o) => o + pageSize)}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -100,9 +100,8 @@ export function SubscriptionPlansTab() {
|
|||
</p>
|
||||
<h2 className="text-lg font-bold text-grayScale-900">Subscription packages</h2>
|
||||
<p className="mt-1 max-w-2xl text-sm text-grayScale-500">
|
||||
Manage learner subscription plans from{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 text-xs">GET /subscription-plans</code>
|
||||
. Create, edit, or remove packages for the learner checkout flow.
|
||||
Manage learner subscription plans. Create, edit, or remove packages for the learner
|
||||
checkout flow.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
|
|
|||
|
|
@ -148,9 +148,8 @@ export function CreateAppVersionDialog({
|
|||
New app version
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-grayScale-500">
|
||||
Publishes a release via{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 text-xs">POST /admin/app-versions</code>
|
||||
. Learners on older builds will see update prompts based on these rules.
|
||||
Publish a new app release. Learners on older builds will see update prompts based on
|
||||
these rules.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
|
|||
|
|
@ -128,8 +128,7 @@ export function CreateSubscriptionPlanDialog({
|
|||
New subscription package
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-grayScale-500">
|
||||
Creates a plan via{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 text-xs">POST /subscription-plans</code>
|
||||
Add a new subscription package for learners.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
|
|||
|
|
@ -140,10 +140,7 @@ export function EditAppVersionDialog({
|
|||
Edit app version
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-grayScale-500">
|
||||
Updates via{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 text-xs">
|
||||
PUT /admin/app-versions/{version?.id ?? ":id"}
|
||||
</code>
|
||||
Update release rules and messaging for this version.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
|
|||
|
|
@ -139,10 +139,7 @@ export function EditSubscriptionPlanDialog({
|
|||
Edit subscription package
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-grayScale-500">
|
||||
Updates via{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 text-xs">
|
||||
PUT /subscription-plans/{plan?.id ?? ":id"}
|
||||
</code>
|
||||
Update pricing, duration, and visibility for this plan.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
} from "../../components/ui/table";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination";
|
||||
import { getTeamMembers, updateTeamMemberStatus } from "../../api/team.api";
|
||||
import type { TeamMember } from "../../types/team.types";
|
||||
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"
|
||||
>
|
||||
{[5, 10, 20, 30, 50].map((size) => (
|
||||
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
DialogDescription,
|
||||
} from "../../components/ui/dialog";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination";
|
||||
import { getActivityLogs, getActivityLogById } from "../../api/activity-logs.api";
|
||||
import type { ActivityLog, ActivityLogFilters } from "../../types/activity-log.types";
|
||||
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"
|
||||
>
|
||||
{[5, 10, 20, 30, 50].map((size) => (
|
||||
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
import { getDeletionRequests } from "../../api/users.api"
|
||||
import { getRoles } from "../../api/rbac.api"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
|
||||
import type {
|
||||
DeletionRequest,
|
||||
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"
|
||||
>
|
||||
{[10, 20, 50, 100].map((size) => (
|
||||
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { Button } from "../../components/ui/button"
|
|||
import { Card, CardContent } from "../../components/ui/card"
|
||||
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
|
||||
import { getDashboard } from "../../api/analytics.api"
|
||||
import { getUsers, updateUserStatus, type UserStatus } from "../../api/users.api"
|
||||
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"
|
||||
>
|
||||
{[5, 10, 20, 30, 50].map((size) => (
|
||||
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
|
|
|
|||
|
|
@ -1066,7 +1066,8 @@ export interface QuestionOption {
|
|||
}
|
||||
|
||||
export interface CreateQuestionRequest {
|
||||
question_text: string
|
||||
/** Omit for `DYNAMIC` — prompt belongs in `dynamic_payload.stimulus` */
|
||||
question_text?: string
|
||||
question_type: string
|
||||
difficulty_level?: string
|
||||
points?: number
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user