Compare commits
No commits in common. "1014f4a72f16489c69ee84bed82974fc42f8293c" and "2c3f0da6f75454867c137e3faf040f724ff96793" have entirely different histories.
1014f4a72f
...
2c3f0da6f7
|
|
@ -1,5 +1,4 @@
|
|||
import http from "./http"
|
||||
import { DEFAULT_TABLE_PAGE_SIZE } from "../lib/tablePagination"
|
||||
import type {
|
||||
AppVersion,
|
||||
AppVersionMutationResponse,
|
||||
|
|
@ -78,7 +77,7 @@ export type GetAppVersionsParams = {
|
|||
}
|
||||
|
||||
export const getAppVersions = (params: GetAppVersionsParams = {}) => {
|
||||
const limit = params.limit ?? DEFAULT_TABLE_PAGE_SIZE
|
||||
const limit = params.limit ?? 20
|
||||
const offset = params.offset ?? 0
|
||||
return http
|
||||
.get<AppVersionsListResponse>("/admin/app-versions", { params: { limit, offset } })
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import http from "./http"
|
||||
|
||||
export type UploadMediaType = "image" | "audio" | "video" | "pdf"
|
||||
export type UploadMediaType = "image" | "audio" | "video"
|
||||
export type UploadProvider = "MINIO" | "VIMEO"
|
||||
|
||||
export interface UploadMediaResponse {
|
||||
|
|
@ -121,8 +121,6 @@ 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 },
|
||||
|
|
|
|||
|
|
@ -1,125 +1,35 @@
|
|||
import http from "./http"
|
||||
import type {
|
||||
GetNotificationsResponse,
|
||||
Notification,
|
||||
UnreadCountResponse,
|
||||
} from "../types/notification.types"
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function unwrapEnvelopeData(body: unknown): unknown {
|
||||
if (!isRecord(body)) return body
|
||||
if ("data" in body || "Data" in body) {
|
||||
return body.data ?? body.Data
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
function normalizePayload(raw: unknown): Notification["payload"] {
|
||||
if (!isRecord(raw)) {
|
||||
return { tags: null }
|
||||
}
|
||||
const tags = Array.isArray(raw.tags)
|
||||
? raw.tags.filter((tag): tag is string => typeof tag === "string" && tag.length > 0)
|
||||
: null
|
||||
return {
|
||||
headline: raw.headline != null ? String(raw.headline) : undefined,
|
||||
title: raw.title != null ? String(raw.title) : undefined,
|
||||
message: raw.message != null ? String(raw.message) : undefined,
|
||||
body: raw.body != null ? String(raw.body) : undefined,
|
||||
tags,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeNotification(raw: unknown): Notification | null {
|
||||
if (!isRecord(raw)) return null
|
||||
const id = String(raw.id ?? "")
|
||||
if (!id) return null
|
||||
|
||||
return {
|
||||
id,
|
||||
recipient_id: Number(raw.recipient_id ?? 0),
|
||||
receiver_type: raw.receiver_type != null ? String(raw.receiver_type) : undefined,
|
||||
type: String(raw.type ?? ""),
|
||||
level: String(raw.level ?? ""),
|
||||
error_severity: String(raw.error_severity ?? ""),
|
||||
reciever: String(raw.reciever ?? ""),
|
||||
is_read: Boolean(raw.is_read),
|
||||
delivery_status: String(raw.delivery_status ?? ""),
|
||||
delivery_channel: String(raw.delivery_channel ?? ""),
|
||||
payload: normalizePayload(raw.payload),
|
||||
timestamp: String(raw.timestamp ?? ""),
|
||||
expires: String(raw.expires ?? ""),
|
||||
image: String(raw.image ?? ""),
|
||||
}
|
||||
}
|
||||
|
||||
function parseNotificationsListData(body: unknown, limit: number, offset: number): GetNotificationsResponse {
|
||||
const inner = unwrapEnvelopeData(body)
|
||||
if (!isRecord(inner)) {
|
||||
return { notifications: [], total_count: 0, limit, offset }
|
||||
}
|
||||
|
||||
const rows = Array.isArray(inner.notifications) ? inner.notifications : []
|
||||
const notifications = rows
|
||||
.map(normalizeNotification)
|
||||
.filter((n): n is Notification => n !== null)
|
||||
|
||||
return {
|
||||
notifications,
|
||||
total_count: Number(inner.total_count ?? notifications.length),
|
||||
limit: Number(inner.limit ?? limit),
|
||||
offset: Number(inner.offset ?? offset),
|
||||
}
|
||||
}
|
||||
|
||||
function parseUnreadCount(body: unknown): UnreadCountResponse {
|
||||
const inner = unwrapEnvelopeData(body)
|
||||
if (!isRecord(inner)) return { unread: 0 }
|
||||
return { unread: Number(inner.unread ?? 0) }
|
||||
}
|
||||
import http from "./http";
|
||||
import type { GetNotificationsResponse, UnreadCountResponse } from "../types/notification.types";
|
||||
|
||||
export const getNotifications = (limit = 10, offset = 0) =>
|
||||
http.get<unknown>("/notifications", { params: { limit, offset } }).then((res) => ({
|
||||
...res,
|
||||
data: parseNotificationsListData(res.data, limit, offset),
|
||||
}))
|
||||
|
||||
export const getNotificationById = (id: string) =>
|
||||
http.get<unknown>(`/notifications/${id}`).then((res) => ({
|
||||
...res,
|
||||
data: normalizeNotification(unwrapEnvelopeData(res.data)),
|
||||
}))
|
||||
http.get<GetNotificationsResponse>("/notifications", {
|
||||
params: { limit, offset },
|
||||
});
|
||||
|
||||
export const getUnreadCount = () =>
|
||||
http.get<unknown>("/notifications/unread").then((res) => ({
|
||||
...res,
|
||||
data: parseUnreadCount(res.data),
|
||||
}))
|
||||
http.get<UnreadCountResponse>("/notifications/unread");
|
||||
|
||||
export const markAsRead = (id: string) =>
|
||||
http.patch(`/notifications/${id}/read`)
|
||||
http.patch(`/notifications/${id}/read`);
|
||||
|
||||
export const markAsUnread = (id: string) =>
|
||||
http.patch(`/notifications/${id}/unread`)
|
||||
http.patch(`/notifications/${id}/unread`);
|
||||
|
||||
export const markAllRead = () =>
|
||||
http.post("/notifications/mark-all-read")
|
||||
http.post("/notifications/mark-all-read");
|
||||
|
||||
export const markAllUnread = () =>
|
||||
http.post("/notifications/mark-all-unread")
|
||||
http.post("/notifications/mark-all-unread");
|
||||
|
||||
export const sendBulkSms = (data: { message: string; user_ids: number[]; scheduled_at?: string }) =>
|
||||
http.post("/notifications/bulk-sms", data)
|
||||
http.post("/notifications/bulk-sms", data);
|
||||
|
||||
export const sendBulkEmail = (formData: FormData) =>
|
||||
http.post("/notifications/bulk-email", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
})
|
||||
});
|
||||
|
||||
export const sendBulkPush = (formData: FormData) =>
|
||||
http.post("/notifications/bulk-push", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
})
|
||||
});
|
||||
|
|
|
|||
|
|
@ -261,40 +261,6 @@ export function extractDefinitionMutationId(res: { data?: unknown }): number | u
|
|||
/** @deprecated use extractDefinitionMutationId */
|
||||
export const extractCreatedDefinitionId = extractDefinitionMutationId
|
||||
|
||||
export interface QuestionTypeDefinitionsListParams {
|
||||
include_system?: boolean
|
||||
status?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
export interface QuestionTypeDefinitionsListResult {
|
||||
definitions: QuestionTypeDefinition[]
|
||||
total_count?: number
|
||||
}
|
||||
|
||||
function parseListTotalCount(body: unknown): number | undefined {
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) return undefined
|
||||
const o = body as Record<string, unknown>
|
||||
|
||||
const direct = Number(o.total_count ?? o.TotalCount ?? o.totalCount)
|
||||
if (Number.isFinite(direct) && direct >= 0) return direct
|
||||
|
||||
const meta = o.metadata ?? o.Metadata
|
||||
if (meta && typeof meta === "object" && !Array.isArray(meta)) {
|
||||
const m = meta as Record<string, unknown>
|
||||
const fromMeta = Number(m.total_count ?? m.TotalCount ?? m.totalCount)
|
||||
if (Number.isFinite(fromMeta) && fromMeta >= 0) return fromMeta
|
||||
}
|
||||
|
||||
const data = o.data ?? o.Data
|
||||
if (data && typeof data === "object" && !Array.isArray(data)) {
|
||||
return parseListTotalCount(data)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function parseDefinitionsList(payload: unknown): QuestionTypeDefinition[] {
|
||||
if (!payload) return []
|
||||
if (Array.isArray(payload)) {
|
||||
|
|
@ -304,32 +270,30 @@ export function parseDefinitionsList(payload: unknown): QuestionTypeDefinition[]
|
|||
}
|
||||
if (typeof payload === "object" && payload !== null) {
|
||||
const o = payload as Record<string, unknown>
|
||||
const inner =
|
||||
o.question_type_definitions ??
|
||||
o.QuestionTypeDefinitions ??
|
||||
o.definitions ??
|
||||
o.items ??
|
||||
o.rows ??
|
||||
o.Definitions
|
||||
const inner = o.definitions ?? o.items ?? o.rows ?? o.Definitions
|
||||
if (Array.isArray(inner)) return parseDefinitionsList(inner)
|
||||
if (inner && typeof inner === "object" && !Array.isArray(inner)) return parseDefinitionsList(inner)
|
||||
if (inner && typeof inner === "object") return parseDefinitionsList(inner)
|
||||
const data = o.data ?? o.Data
|
||||
if (Array.isArray(data)) return parseDefinitionsList(data)
|
||||
if (data && typeof data === "object") return parseDefinitionsList(data)
|
||||
if (data && typeof data === "object") {
|
||||
const single = normalizeTypeDefinitionFromApi(data)
|
||||
return single ? [single] : []
|
||||
}
|
||||
const single = normalizeTypeDefinitionFromApi(payload)
|
||||
return single ? [single] : []
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export async function getQuestionTypeDefinitions(
|
||||
params?: QuestionTypeDefinitionsListParams,
|
||||
): Promise<QuestionTypeDefinitionsListResult> {
|
||||
export async function getQuestionTypeDefinitions(params?: {
|
||||
include_system?: boolean
|
||||
status?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}) {
|
||||
const res = await http.get<ApiEnvelope<unknown>>("/questions/type-definitions", { params })
|
||||
const raw = unwrapApiPayload(res) ?? res.data
|
||||
const definitions = parseDefinitionsList(raw)
|
||||
const total_count = parseListTotalCount(raw) ?? parseListTotalCount(res.data)
|
||||
return total_count != null ? { definitions, total_count } : { definitions }
|
||||
return parseDefinitionsList(raw)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -6,18 +6,15 @@ import {
|
|||
type ChangeEvent,
|
||||
type DragEvent,
|
||||
} from "react"
|
||||
import { CloudUpload, FileText, Image as ImageIcon, Mic, Pause, Play, X } from "lucide-react"
|
||||
import { CloudUpload, Image as ImageIcon, Mic, Pause, Play, X } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { uploadAudioFile, uploadImageFile, uploadPdfFile } from "../../api/files.api"
|
||||
import { uploadAudioFile, uploadImageFile } 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"
|
||||
import { slotLabel } from "../../lib/schemaSlotLabel"
|
||||
|
||||
const MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
||||
const MAX_AUDIO_BYTES = 50 * 1024 * 1024
|
||||
|
|
@ -31,43 +28,13 @@ export interface DynamicSchemaSlotRow {
|
|||
required?: boolean
|
||||
}
|
||||
|
||||
function slotMediaMode(
|
||||
kind: string,
|
||||
): "image" | "audio" | "pdf" | "table" | "seconds" | "text" {
|
||||
function slotMediaMode(kind: string): "image" | "audio" | "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 === "PREP_TIME" || u === "ANSWER_TIMER") return "seconds"
|
||||
if (u === "AUDIO_PROMPT" || u === "AUDIO_CLIP" || u === "AUDIO_RESPONSE") return "audio"
|
||||
if (u.startsWith("AUDIO")) return "audio"
|
||||
return "text"
|
||||
}
|
||||
|
||||
function readSecondsFieldValue(raw: string): string {
|
||||
const t = raw.trim()
|
||||
if (!t) return ""
|
||||
if (/^\d+$/.test(t)) return t
|
||||
try {
|
||||
const parsed = JSON.parse(t) as unknown
|
||||
if (typeof parsed === "number" && Number.isFinite(parsed)) return String(parsed)
|
||||
if (parsed && typeof parsed === "object" && "seconds" in parsed) {
|
||||
const seconds = (parsed as { seconds?: unknown }).seconds
|
||||
if (typeof seconds === "number" && Number.isFinite(seconds)) return String(seconds)
|
||||
}
|
||||
} catch {
|
||||
/* keep raw */
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
function writeSecondsFieldValue(raw: string): string {
|
||||
const t = raw.trim()
|
||||
if (!t) return ""
|
||||
const n = Number.parseInt(t, 10)
|
||||
if (!Number.isFinite(n) || n < 0) return ""
|
||||
return JSON.stringify({ seconds: n })
|
||||
}
|
||||
|
||||
function isHttpUrl(s: string): boolean {
|
||||
return /^https?:\/\//i.test(s.trim())
|
||||
}
|
||||
|
|
@ -170,9 +137,7 @@ function DynamicImageSlot({
|
|||
<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>
|
||||
{slotMeta ? (
|
||||
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
|
||||
) : null}
|
||||
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(220px,36%)] lg:items-start lg:gap-4">
|
||||
<div
|
||||
|
|
@ -442,9 +407,7 @@ function DynamicAudioSlot({
|
|||
<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>
|
||||
{slotMeta ? (
|
||||
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
|
||||
) : null}
|
||||
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(220px,36%)] lg:items-start lg:gap-4">
|
||||
<div className="min-h-[100px] space-y-2 rounded-lg border border-grayScale-200 bg-grayScale-50/40 p-2.5 lg:min-h-[140px]">
|
||||
|
|
@ -574,105 +537,6 @@ 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>
|
||||
{slotMeta ? (
|
||||
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
|
||||
) : null}
|
||||
</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,
|
||||
|
|
@ -680,52 +544,24 @@ export function DynamicSchemaSlotField({
|
|||
disabled = false,
|
||||
}: DynamicSchemaSlotFieldProps) {
|
||||
const mode = slotMediaMode(row.kind)
|
||||
const fieldLabel = `${slotLabel(row)}${row.required ? " *" : ""}`
|
||||
|
||||
if (mode === "table") {
|
||||
return (
|
||||
<DynamicTableBuilder
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
slotLabel={fieldLabel}
|
||||
slotMeta=""
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (mode === "seconds") {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">{fieldLabel}</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={readSecondsFieldValue(value)}
|
||||
onChange={(e) => onChange(writeSecondsFieldValue(e.target.value))}
|
||||
placeholder="e.g. 30"
|
||||
className="h-11 max-w-[200px] rounded-lg border-grayScale-200"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<p className="text-[11px] text-grayScale-500">Stored as seconds (e.g. {`{"seconds": 30}`}).</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const baseLabel =
|
||||
row.label?.trim() ||
|
||||
(mode === "image" ? "Image" : mode === "audio" ? "Audio" : row.kind)
|
||||
const slotLabel = `${baseLabel}${row.required ? " *" : ""}`
|
||||
const slotMeta = `${row.id} · ${row.kind}`
|
||||
|
||||
if (mode === "text") {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-grayScale-700">{fieldLabel}</label>
|
||||
<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>
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={
|
||||
row.kind === "OPTION"
|
||||
? '{"options":[{"id":"a","text":"…","is_correct":true}]}'
|
||||
: "URL, plain text, or JSON object"
|
||||
}
|
||||
placeholder="URL, plain text, or JSON object"
|
||||
className="min-h-[88px] resize-y rounded-lg border-grayScale-200 font-mono text-sm"
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
|
@ -739,20 +575,8 @@ export function DynamicSchemaSlotField({
|
|||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
slotLabel={fieldLabel}
|
||||
slotMeta=""
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (mode === "pdf") {
|
||||
return (
|
||||
<DynamicPdfSlot
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
slotLabel={fieldLabel}
|
||||
slotMeta=""
|
||||
slotLabel={slotLabel}
|
||||
slotMeta={slotMeta}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -762,8 +586,8 @@ export function DynamicSchemaSlotField({
|
|||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
slotLabel={fieldLabel}
|
||||
slotMeta=""
|
||||
slotLabel={slotLabel}
|
||||
slotMeta={slotMeta}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,250 +0,0 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -636,8 +636,8 @@ export function PracticeQuestionEditorFields({
|
|||
setDefinitionsLoading(true)
|
||||
;(async () => {
|
||||
try {
|
||||
const { definitions: rows } = await getQuestionTypeDefinitions({ include_system: true })
|
||||
if (!cancelled) setTypeDefinitions(rows)
|
||||
const rows = await getQuestionTypeDefinitions({ include_system: true })
|
||||
if (!cancelled) setTypeDefinitions(Array.isArray(rows) ? rows : [])
|
||||
} catch {
|
||||
if (!cancelled) setTypeDefinitions([])
|
||||
} finally {
|
||||
|
|
@ -778,8 +778,9 @@ 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">
|
||||
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.
|
||||
<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.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
|
||||
|
|
|
|||
|
|
@ -1,169 +0,0 @@
|
|||
import { Badge } from "../ui/badge"
|
||||
import { Button } from "../ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../ui/dialog"
|
||||
import { SpinnerIcon } from "../ui/spinner-icon"
|
||||
import {
|
||||
DEFAULT_NOTIFICATION_TYPE_CONFIG,
|
||||
formatNotificationDateTime,
|
||||
formatNotificationTimestamp,
|
||||
formatNotificationTypeLabel,
|
||||
getNotificationLevelBadge,
|
||||
isMeaningfulExpiry,
|
||||
NOTIFICATION_TYPE_CONFIG,
|
||||
} from "../../lib/notificationDisplay"
|
||||
import { getNotificationMessage, getNotificationTitle, type Notification } from "../../types/notification.types"
|
||||
|
||||
type NotificationDetailDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
notification: Notification | null
|
||||
loading?: boolean
|
||||
error?: boolean
|
||||
onRetry?: () => void
|
||||
}
|
||||
|
||||
export function NotificationDetailDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
notification,
|
||||
loading = false,
|
||||
error = false,
|
||||
onRetry,
|
||||
}: NotificationDetailDialogProps) {
|
||||
const config = notification
|
||||
? NOTIFICATION_TYPE_CONFIG[notification.type] ?? DEFAULT_NOTIFICATION_TYPE_CONFIG
|
||||
: DEFAULT_NOTIFICATION_TYPE_CONFIG
|
||||
const Icon = config.icon
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-12">
|
||||
<SpinnerIcon className="h-8 w-8 text-brand-500" />
|
||||
<p className="text-sm text-grayScale-500">Loading notification…</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
|
||||
<p className="text-sm font-medium text-grayScale-700">Could not load notification</p>
|
||||
{onRetry ? (
|
||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||
Try again
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : notification ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-flex h-8 w-8 items-center justify-center rounded-lg ${config.bg} ${config.color}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="truncate text-base">
|
||||
{getNotificationTitle(notification) || "Notification"}
|
||||
</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Sent via {notification.delivery_channel || "in-app"} ·{" "}
|
||||
{formatNotificationTimestamp(notification.timestamp)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{notification.image ? (
|
||||
<div className="overflow-hidden rounded-lg border border-grayScale-200 bg-grayScale-50">
|
||||
<img
|
||||
src={notification.image}
|
||||
alt=""
|
||||
className="max-h-48 w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-lg bg-grayScale-50 p-3">
|
||||
<p className="text-sm leading-relaxed text-grayScale-600">
|
||||
{getNotificationMessage(notification) || "No message content."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 text-xs text-grayScale-500 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-grayScale-400">Type</p>
|
||||
<p className="mt-0.5 font-medium text-grayScale-700">
|
||||
{formatNotificationTypeLabel(notification.type)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-grayScale-400">Level</p>
|
||||
<div className="mt-0.5">
|
||||
<Badge variant={getNotificationLevelBadge(notification.level)} className="text-[10px]">
|
||||
{notification.level || "—"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-grayScale-400">Channel</p>
|
||||
<p className="mt-0.5 font-medium capitalize text-grayScale-700">
|
||||
{notification.delivery_channel || "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-grayScale-400">Delivery status</p>
|
||||
<p className="mt-0.5 font-medium text-grayScale-700">
|
||||
{notification.delivery_status || "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-grayScale-400">Read status</p>
|
||||
<p className="mt-0.5 font-medium text-grayScale-700">
|
||||
{notification.is_read ? "Read" : "Unread"}
|
||||
</p>
|
||||
</div>
|
||||
{notification.receiver_type ? (
|
||||
<div>
|
||||
<p className="text-grayScale-400">Receiver</p>
|
||||
<p className="mt-0.5 font-medium capitalize text-grayScale-700">
|
||||
{notification.receiver_type}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="sm:col-span-2">
|
||||
<p className="text-grayScale-400">Sent at</p>
|
||||
<p className="mt-0.5 font-medium text-grayScale-700">
|
||||
{formatNotificationDateTime(notification.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
{isMeaningfulExpiry(notification.expires) ? (
|
||||
<div className="sm:col-span-2">
|
||||
<p className="text-grayScale-400">Expires</p>
|
||||
<p className="mt-0.5 font-medium text-grayScale-700">
|
||||
{formatNotificationDateTime(notification.expires)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{notification.payload.tags && notification.payload.tags.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{notification.payload.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-[10px]">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,32 +1,74 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { Bell, BellOff, CheckCheck, Mail, MailOpen } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
Bell,
|
||||
BellOff,
|
||||
Info,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Megaphone,
|
||||
UserPlus,
|
||||
CreditCard,
|
||||
BookOpen,
|
||||
Video,
|
||||
ShieldAlert,
|
||||
MailOpen,
|
||||
Mail,
|
||||
CheckCheck,
|
||||
} from "lucide-react"
|
||||
import { Badge } from "../ui/badge"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { SpinnerIcon } from "../ui/spinner-icon"
|
||||
import { getNotificationById } from "../../api/notifications.api"
|
||||
import { useNotifications } from "../../hooks/useNotifications"
|
||||
import { NotificationDetailDialog } from "../notifications/NotificationDetailDialog"
|
||||
import {
|
||||
DEFAULT_NOTIFICATION_TYPE_CONFIG,
|
||||
formatNotificationTimestamp,
|
||||
NOTIFICATION_TYPE_CONFIG,
|
||||
} from "../../lib/notificationDisplay"
|
||||
import { getNotificationMessage, getNotificationTitle, type Notification } from "../../types/notification.types"
|
||||
|
||||
const TYPE_CONFIG: Record<string, { icon: React.ElementType; color: string; bg: string }> = {
|
||||
announcement: { icon: Megaphone, color: "text-brand-600", bg: "bg-brand-100" },
|
||||
system_alert: { icon: ShieldAlert, color: "text-amber-600", bg: "bg-amber-50" },
|
||||
issue_created: { icon: AlertCircle, color: "text-red-500", bg: "bg-red-50" },
|
||||
issue_status_updated: { icon: CheckCircle2, color: "text-sky-600", bg: "bg-sky-50" },
|
||||
course_created: { icon: BookOpen, color: "text-indigo-600", bg: "bg-indigo-50" },
|
||||
course_enrolled: { icon: BookOpen, color: "text-teal-600", bg: "bg-teal-50" },
|
||||
sub_course_created: { icon: BookOpen, color: "text-violet-600", bg: "bg-violet-50" },
|
||||
video_added: { icon: Video, color: "text-pink-600", bg: "bg-pink-50" },
|
||||
user_deleted: { icon: UserPlus, color: "text-red-600", bg: "bg-red-50" },
|
||||
admin_created: { icon: UserPlus, color: "text-brand-600", bg: "bg-brand-100" },
|
||||
team_member_created: { icon: UserPlus, color: "text-emerald-600", bg: "bg-emerald-50" },
|
||||
subscription_activated: { icon: CreditCard, color: "text-green-600", bg: "bg-green-50" },
|
||||
payment_verified: { icon: CreditCard, color: "text-green-600", bg: "bg-green-50" },
|
||||
knowledge_level_update: { icon: Info, color: "text-sky-600", bg: "bg-sky-50" },
|
||||
assessment_assigned: { icon: BookOpen, color: "text-orange-600", bg: "bg-orange-50" },
|
||||
}
|
||||
const DEFAULT_TYPE_CONFIG = { icon: Bell, color: "text-grayScale-500", bg: "bg-grayScale-100" }
|
||||
|
||||
function formatTimestamp(ts: string) {
|
||||
const date = new Date(ts)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMin = Math.floor(diffMs / 60_000)
|
||||
const diffHr = Math.floor(diffMs / 3_600_000)
|
||||
const diffDay = Math.floor(diffMs / 86_400_000)
|
||||
if (diffMin < 1) return "Just now"
|
||||
if (diffMin < 60) return `${diffMin}m ago`
|
||||
if (diffHr < 24) return `${diffHr}h ago`
|
||||
if (diffDay < 7) return `${diffDay}d ago`
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function NotificationItem({
|
||||
notification,
|
||||
onOpen,
|
||||
onMarkRead,
|
||||
onMarkUnread,
|
||||
}: {
|
||||
notification: Notification
|
||||
onOpen: (notification: Notification) => void
|
||||
onMarkRead: (id: string) => void
|
||||
onMarkUnread: (id: string) => void
|
||||
}) {
|
||||
const cfg = NOTIFICATION_TYPE_CONFIG[notification.type] ?? DEFAULT_NOTIFICATION_TYPE_CONFIG
|
||||
const cfg = TYPE_CONFIG[notification.type] ?? DEFAULT_TYPE_CONFIG
|
||||
const Icon = cfg.icon
|
||||
|
||||
return (
|
||||
|
|
@ -35,26 +77,31 @@ function NotificationItem({
|
|||
className={cn(
|
||||
"group relative flex w-full gap-3 rounded-lg px-3 py-3 text-left transition-colors hover:bg-grayScale-100",
|
||||
)}
|
||||
onClick={() => onOpen(notification)}
|
||||
onClick={() => {
|
||||
if (!notification.is_read) onMarkRead(notification.id)
|
||||
}}
|
||||
>
|
||||
{/* Unread dot */}
|
||||
{!notification.is_read && (
|
||||
<span className="absolute left-0.5 top-1/2 h-2 w-2 -translate-y-1/2 rounded-full bg-brand-500" />
|
||||
)}
|
||||
|
||||
{/* Type icon */}
|
||||
<span
|
||||
className={cn(
|
||||
"ml-3 grid h-9 w-9 shrink-0 place-items-center rounded-lg",
|
||||
cfg.bg,
|
||||
cfg.bg
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("h-4 w-4", cfg.color)} />
|
||||
</span>
|
||||
|
||||
{/* Content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm leading-snug text-grayScale-900",
|
||||
!notification.is_read && "font-semibold",
|
||||
!notification.is_read && "font-semibold"
|
||||
)}
|
||||
>
|
||||
{getNotificationTitle(notification) || "Notification"}
|
||||
|
|
@ -63,10 +110,11 @@ function NotificationItem({
|
|||
{getNotificationMessage(notification) || "No preview text available."}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-grayScale-600">
|
||||
{formatNotificationTimestamp(notification.timestamp)}
|
||||
{formatTimestamp(notification.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Read / Unread toggle */}
|
||||
<button
|
||||
type="button"
|
||||
className="hidden shrink-0 self-center rounded-md p-1.5 text-grayScale-400 hover:bg-grayScale-200 hover:text-grayScale-600 group-hover:block"
|
||||
|
|
@ -92,11 +140,6 @@ function NotificationItem({
|
|||
|
||||
export function NotificationDropdown() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
const [detailError, setDetailError] = useState(false)
|
||||
const [selectedNotification, setSelectedNotification] = useState<Notification | null>(null)
|
||||
const [selectedNotificationId, setSelectedNotificationId] = useState<string | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const navigate = useNavigate()
|
||||
const {
|
||||
|
|
@ -108,40 +151,7 @@ export function NotificationDropdown() {
|
|||
markAllAsRead,
|
||||
} = useNotifications()
|
||||
|
||||
const loadNotificationDetail = useCallback(async (id: string, markReadIfNeeded: boolean) => {
|
||||
setDetailLoading(true)
|
||||
setDetailError(false)
|
||||
setSelectedNotification(null)
|
||||
setSelectedNotificationId(id)
|
||||
setDetailOpen(true)
|
||||
|
||||
try {
|
||||
const res = await getNotificationById(id)
|
||||
if (!res.data) {
|
||||
setDetailError(true)
|
||||
toast.error("Notification not found")
|
||||
return
|
||||
}
|
||||
setSelectedNotification(res.data)
|
||||
if (markReadIfNeeded && !res.data.is_read) {
|
||||
void markOneRead(id)
|
||||
}
|
||||
} catch {
|
||||
setDetailError(true)
|
||||
toast.error("Failed to load notification details")
|
||||
} finally {
|
||||
setDetailLoading(false)
|
||||
}
|
||||
}, [markOneRead])
|
||||
|
||||
const handleOpenNotification = useCallback(
|
||||
(notification: Notification) => {
|
||||
setOpen(false)
|
||||
void loadNotificationDetail(notification.id, !notification.is_read)
|
||||
},
|
||||
[loadNotificationDetail],
|
||||
)
|
||||
|
||||
// Click-outside handler
|
||||
useEffect(() => {
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
|
|
@ -155,98 +165,89 @@ export function NotificationDropdown() {
|
|||
}, [open])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={containerRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
className="relative grid h-10 w-10 place-items-center rounded-full border bg-white text-grayScale-500 transition-colors hover:text-brand-600"
|
||||
aria-label="Notifications"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold text-white">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<div ref={containerRef} className="relative">
|
||||
{/* Bell button */}
|
||||
<button
|
||||
type="button"
|
||||
className="relative grid h-10 w-10 place-items-center rounded-full border bg-white text-grayScale-500 transition-colors hover:text-brand-600"
|
||||
aria-label="Notifications"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold text-white">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="animate-in fade-in-0 zoom-in-95 absolute right-0 top-12 z-50 w-[380px] rounded-xl bg-white shadow-lg ring-1 ring-black/5">
|
||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-grayScale-800">Notifications</h3>
|
||||
{unreadCount > 0 && (
|
||||
<Badge variant="default" className="px-1.5 py-0 text-[10px]">
|
||||
{unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{/* Dropdown panel */}
|
||||
{open && (
|
||||
<div className="animate-in fade-in-0 zoom-in-95 absolute right-0 top-12 z-50 w-[380px] rounded-xl bg-white shadow-lg ring-1 ring-black/5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-grayScale-800">
|
||||
Notifications
|
||||
</h3>
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-grayScale-500 transition-colors hover:bg-grayScale-100 hover:text-grayScale-700"
|
||||
onClick={markAllAsRead}
|
||||
>
|
||||
<CheckCheck className="h-3.5 w-3.5" />
|
||||
Mark all read
|
||||
</button>
|
||||
<Badge variant="default" className="px-1.5 py-0 text-[10px]">
|
||||
{unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-h-[480px] overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<SpinnerIcon className="h-6 w-6" />
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-12 text-grayScale-400">
|
||||
<BellOff className="h-8 w-8" />
|
||||
<p className="text-sm">No notifications</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-1">
|
||||
{notifications.map((n) => (
|
||||
<NotificationItem
|
||||
key={n.id}
|
||||
notification={n}
|
||||
onOpen={handleOpenNotification}
|
||||
onMarkRead={markOneRead}
|
||||
onMarkUnread={markOneUnread}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t px-4 py-2.5">
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-lg py-1.5 text-center text-sm font-medium text-brand-600 transition-colors hover:bg-grayScale-100"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
navigate("/notifications")
|
||||
}}
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-grayScale-500 transition-colors hover:bg-grayScale-100 hover:text-grayScale-700"
|
||||
onClick={markAllAsRead}
|
||||
>
|
||||
View all notifications
|
||||
<CheckCheck className="h-3.5 w-3.5" />
|
||||
Mark all read
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<NotificationDetailDialog
|
||||
open={detailOpen}
|
||||
onOpenChange={setDetailOpen}
|
||||
notification={selectedNotification}
|
||||
loading={detailLoading}
|
||||
error={detailError}
|
||||
onRetry={
|
||||
selectedNotificationId
|
||||
? () => void loadNotificationDetail(selectedNotificationId, false)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</>
|
||||
{/* Body */}
|
||||
<div className="max-h-[480px] overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<SpinnerIcon className="h-6 w-6" />
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-12 text-grayScale-400">
|
||||
<BellOff className="h-8 w-8" />
|
||||
<p className="text-sm">No notifications</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-1">
|
||||
{notifications.map((n) => (
|
||||
<NotificationItem
|
||||
key={n.id}
|
||||
notification={n}
|
||||
onMarkRead={markOneRead}
|
||||
onMarkUnread={markOneUnread}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t px-4 py-2.5">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-lg py-1.5 text-center text-sm font-medium text-brand-600 transition-colors hover:bg-grayScale-100"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
navigate("/notifications")
|
||||
}}
|
||||
>
|
||||
View all notifications
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,12 @@ import { getNotifications, getUnreadCount, markAsRead, markAsUnread, markAllRead
|
|||
import type { Notification } from "../types/notification.types"
|
||||
|
||||
const MAX_DROPDOWN = 5
|
||||
const RECONNECT_MS = 5000
|
||||
|
||||
function getWsUrl() {
|
||||
const base = import.meta.env.VITE_API_BASE_URL as string
|
||||
const wsBase = base.replace(/^https/, "wss").replace(/^http/, "ws")
|
||||
const token = localStorage.getItem("access_token") ?? ""
|
||||
return `${wsBase}/ws/connect?token=${encodeURIComponent(token)}`
|
||||
return `${wsBase}/ws/connect?token=${token}`
|
||||
}
|
||||
|
||||
export function useNotifications() {
|
||||
|
|
@ -19,8 +18,6 @@ export function useNotifications() {
|
|||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const mountedRef = useRef(true)
|
||||
const intentionalCloseRef = useRef(false)
|
||||
const connectAttemptRef = useRef(0)
|
||||
|
||||
const dispatchUpdate = () => {
|
||||
window.dispatchEvent(new Event("notifications-updated"))
|
||||
|
|
@ -43,37 +40,11 @@ export function useNotifications() {
|
|||
}
|
||||
}, [])
|
||||
|
||||
const clearReconnectTimer = useCallback(() => {
|
||||
if (reconnectTimer.current) {
|
||||
clearTimeout(reconnectTimer.current)
|
||||
reconnectTimer.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const disconnectWs = useCallback(
|
||||
(intentional: boolean) => {
|
||||
intentionalCloseRef.current = intentional
|
||||
clearReconnectTimer()
|
||||
const ws = wsRef.current
|
||||
wsRef.current = null
|
||||
if (!ws) return
|
||||
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
||||
ws.close()
|
||||
}
|
||||
},
|
||||
[clearReconnectTimer],
|
||||
)
|
||||
|
||||
const connectWs = useCallback(() => {
|
||||
if (!mountedRef.current) return
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
}
|
||||
|
||||
const token = localStorage.getItem("access_token")?.trim()
|
||||
if (!token) return
|
||||
|
||||
disconnectWs(true)
|
||||
intentionalCloseRef.current = false
|
||||
|
||||
const attempt = ++connectAttemptRef.current
|
||||
const ws = new WebSocket(getWsUrl())
|
||||
wsRef.current = ws
|
||||
|
||||
|
|
@ -107,45 +78,47 @@ export function useNotifications() {
|
|||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
ws.close()
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
if (connectAttemptRef.current !== attempt) return
|
||||
if (wsRef.current === ws) wsRef.current = null
|
||||
if (!mountedRef.current || intentionalCloseRef.current) return
|
||||
clearReconnectTimer()
|
||||
if (!mountedRef.current) return
|
||||
reconnectTimer.current = setTimeout(() => {
|
||||
if (mountedRef.current) connectWs()
|
||||
}, RECONNECT_MS)
|
||||
}, 5000)
|
||||
}
|
||||
}, [clearReconnectTimer, disconnectWs])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
intentionalCloseRef.current = false
|
||||
fetchData()
|
||||
connectWs()
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
disconnectWs(true)
|
||||
wsRef.current?.close()
|
||||
if (reconnectTimer.current) clearTimeout(reconnectTimer.current)
|
||||
}
|
||||
}, [fetchData, connectWs, disconnectWs])
|
||||
}, [fetchData, connectWs])
|
||||
|
||||
const markOneRead = useCallback(async (id: string) => {
|
||||
setNotifications((prev) =>
|
||||
prev.map((n) => (n.id === id ? { ...n, is_read: true } : n)),
|
||||
prev.map((n) => (n.id === id ? { ...n, is_read: true } : n))
|
||||
)
|
||||
setUnreadCount((prev) => Math.max(0, prev - 1))
|
||||
dispatchUpdate()
|
||||
try {
|
||||
await markAsRead(id)
|
||||
} catch {
|
||||
// revert on failure
|
||||
await fetchData()
|
||||
}
|
||||
}, [fetchData])
|
||||
|
||||
const markOneUnread = useCallback(async (id: string) => {
|
||||
setNotifications((prev) =>
|
||||
prev.map((n) => (n.id === id ? { ...n, is_read: false } : n)),
|
||||
prev.map((n) => (n.id === id ? { ...n, is_read: false } : n))
|
||||
)
|
||||
setUnreadCount((prev) => prev + 1)
|
||||
dispatchUpdate()
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
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,16 +1,7 @@
|
|||
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 {
|
||||
const u = kind.trim().toUpperCase()
|
||||
if (u === "TABLE") {
|
||||
return serializeTableSlotValue(createEmptyTable(2, 1))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
export function definitionUsesDynamicPayload(def: QuestionTypeDefinition): boolean {
|
||||
return def.stimulus_schema.length > 0 || def.response_schema.length > 0
|
||||
}
|
||||
|
|
@ -19,55 +10,11 @@ export function emptyDynamicFieldValuesForDefinition(
|
|||
def: QuestionTypeDefinition,
|
||||
): Record<string, string> {
|
||||
const o: Record<string, string> = {}
|
||||
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)
|
||||
}
|
||||
for (const r of def.stimulus_schema) o[`stimulus:${r.id}`] = ""
|
||||
for (const r of def.response_schema) o[`response:${r.id}`] = ""
|
||||
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).
|
||||
|
|
@ -94,24 +41,6 @@ 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,
|
||||
|
|
@ -122,17 +51,13 @@ 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,
|
||||
fieldValues: q.dynamicFieldValues ?? {},
|
||||
})
|
||||
return {
|
||||
question_text,
|
||||
question_type: "DYNAMIC",
|
||||
question_type_definition_id: def.id,
|
||||
difficulty_level: difficulty,
|
||||
|
|
@ -193,7 +118,9 @@ 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,
|
||||
|
|
@ -209,36 +136,24 @@ 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 = fieldValues[`stimulus:${row.id}`]?.trim()
|
||||
const v = (q.dynamicFieldValues ?? {})[`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 = fieldValues[`response:${row.id}`]?.trim()
|
||||
const v = (q.dynamicFieldValues ?? {})[`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,7 +9,6 @@ import type { PracticeParentKind } from "../types/course.types"
|
|||
import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
|
||||
import {
|
||||
buildCreateQuestionFromDefinition,
|
||||
questionRowHasContent,
|
||||
validateDefinitionQuestion,
|
||||
type LearnEnglishDefinitionQuestionInput,
|
||||
} from "./learnEnglishDefinitionQuestion"
|
||||
|
|
@ -31,12 +30,9 @@ 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) {
|
||||
|
|
@ -104,10 +100,7 @@ export async function executeLearnEnglishPracticeCreation(opts: {
|
|||
)
|
||||
}
|
||||
|
||||
const toCreate = opts.questions.filter((q) => {
|
||||
const def = byId.get(q.questionTypeDefinitionId)
|
||||
return def ? questionRowHasContent(q, def) : false
|
||||
})
|
||||
const toCreate = opts.questions.filter((q) => q.questionText.trim())
|
||||
let displayOrder = 0
|
||||
for (const q of toCreate) {
|
||||
const def = byId.get(q.questionTypeDefinitionId)
|
||||
|
|
|
|||
|
|
@ -1,101 +0,0 @@
|
|||
import {
|
||||
Bell,
|
||||
Info,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Megaphone,
|
||||
UserPlus,
|
||||
CreditCard,
|
||||
BookOpen,
|
||||
Video,
|
||||
ShieldAlert,
|
||||
} from "lucide-react"
|
||||
|
||||
export const NOTIFICATION_TYPE_CONFIG: Record<
|
||||
string,
|
||||
{ icon: React.ElementType; color: string; bg: string }
|
||||
> = {
|
||||
announcement: { icon: Megaphone, color: "text-brand-600", bg: "bg-brand-100" },
|
||||
system_alert: { icon: ShieldAlert, color: "text-amber-600", bg: "bg-amber-50" },
|
||||
issue_created: { icon: AlertCircle, color: "text-red-500", bg: "bg-red-50" },
|
||||
issue_status_updated: { icon: CheckCircle2, color: "text-sky-600", bg: "bg-sky-50" },
|
||||
course_created: { icon: BookOpen, color: "text-indigo-600", bg: "bg-indigo-50" },
|
||||
course_enrolled: { icon: BookOpen, color: "text-teal-600", bg: "bg-teal-50" },
|
||||
sub_course_created: { icon: BookOpen, color: "text-violet-600", bg: "bg-violet-50" },
|
||||
video_added: { icon: Video, color: "text-pink-600", bg: "bg-pink-50" },
|
||||
user_deleted: { icon: UserPlus, color: "text-red-600", bg: "bg-red-50" },
|
||||
admin_created: { icon: UserPlus, color: "text-brand-600", bg: "bg-brand-100" },
|
||||
team_member_created: { icon: UserPlus, color: "text-emerald-600", bg: "bg-emerald-50" },
|
||||
subscription_activated: { icon: CreditCard, color: "text-green-600", bg: "bg-green-50" },
|
||||
payment_verified: { icon: CreditCard, color: "text-green-600", bg: "bg-green-50" },
|
||||
knowledge_level_update: { icon: Info, color: "text-sky-600", bg: "bg-sky-50" },
|
||||
assessment_assigned: { icon: BookOpen, color: "text-orange-600", bg: "bg-orange-50" },
|
||||
}
|
||||
|
||||
export const DEFAULT_NOTIFICATION_TYPE_CONFIG = {
|
||||
icon: Bell,
|
||||
color: "text-grayScale-500",
|
||||
bg: "bg-grayScale-100",
|
||||
}
|
||||
|
||||
export function getNotificationLevelBadge(level: string) {
|
||||
switch (level) {
|
||||
case "error":
|
||||
case "critical":
|
||||
return "destructive" as const
|
||||
case "warning":
|
||||
return "warning" as const
|
||||
case "success":
|
||||
return "success" as const
|
||||
case "info":
|
||||
default:
|
||||
return "info" as const
|
||||
}
|
||||
}
|
||||
|
||||
export function formatNotificationTimestamp(ts: string) {
|
||||
const date = new Date(ts)
|
||||
if (Number.isNaN(date.getTime())) return "—"
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMin = Math.floor(diffMs / 60_000)
|
||||
const diffHr = Math.floor(diffMs / 3_600_000)
|
||||
const diffDay = Math.floor(diffMs / 86_400_000)
|
||||
|
||||
if (diffMin < 1) return "Just now"
|
||||
if (diffMin < 60) return `${diffMin}m ago`
|
||||
if (diffHr < 24) return `${diffHr}h ago`
|
||||
if (diffDay < 7) return `${diffDay}d ago`
|
||||
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
export function formatNotificationTypeLabel(type: string) {
|
||||
return type
|
||||
.split("_")
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
export function formatNotificationDateTime(ts: string) {
|
||||
const date = new Date(ts)
|
||||
if (Number.isNaN(date.getTime())) return "—"
|
||||
return date.toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
export function isMeaningfulExpiry(expires: string) {
|
||||
if (!expires) return false
|
||||
const date = new Date(expires)
|
||||
if (Number.isNaN(date.getTime())) return false
|
||||
return date.getFullYear() > 1
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
/** Author-facing default labels for dynamic schema slots. */
|
||||
const KIND_DEFAULT_LABELS: Record<string, string> = {
|
||||
QUESTION_TEXT: "Question prompt",
|
||||
PREP_TIME: "Preparation time (seconds)",
|
||||
INSTRUCTION: "Instructions",
|
||||
AUDIO_PROMPT: "Audio",
|
||||
TEXT_PASSAGE: "Reading passage",
|
||||
IMAGE: "Image",
|
||||
MATCHING_INPUTS: "Matching inputs",
|
||||
SELECT_MISSING_WORDS: "Select missing words",
|
||||
TABLE: "Reference table",
|
||||
PDF_ATTACHMENT: "PDF document",
|
||||
AUDIO_RESPONSE: "Audio response",
|
||||
TEXT_INPUT: "Text input",
|
||||
SHORT_ANSWER: "Short answer",
|
||||
MULTIPLE_CHOICE: "Multiple choice",
|
||||
OPTION: "Answer choices",
|
||||
ANSWER_TIMER: "Time limit (seconds)",
|
||||
PDF_UPLOAD: "PDF upload",
|
||||
MATCHING_ANSWER: "Matching answer",
|
||||
LABEL_SELECTION: "Label selection",
|
||||
SEQUENCE_ORDER: "Sequence order",
|
||||
}
|
||||
|
||||
export function humanizeKind(kind: string): string {
|
||||
return kind
|
||||
.replace(/_/g, " ")
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
export function defaultLabelForKind(kind: string): string {
|
||||
const k = kind.trim()
|
||||
return KIND_DEFAULT_LABELS[k] ?? humanizeKind(k)
|
||||
}
|
||||
|
||||
export function slotLabel(schema: { label?: string | null; kind: string }): string {
|
||||
const trimmed = schema.label?.trim()
|
||||
if (trimmed) return trimmed
|
||||
return defaultLabelForKind(schema.kind)
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
/** 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
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Bell,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Globe,
|
||||
|
|
@ -22,6 +23,7 @@ import {
|
|||
import { Input } from "../components/ui/input";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Select } from "../components/ui/select";
|
||||
import { Separator } from "../components/ui/separator";
|
||||
import { cn } from "../lib/utils";
|
||||
import { SpinnerIcon } from "../components/ui/spinner-icon";
|
||||
import { changeTeamMemberPassword } from "../api/team.api";
|
||||
|
|
@ -39,6 +41,7 @@ type SettingsTab =
|
|||
| "app-versions"
|
||||
| "profile"
|
||||
| "security"
|
||||
| "notifications"
|
||||
| "appearance";
|
||||
|
||||
const tabs: { id: SettingsTab; label: string; icon: typeof User }[] = [
|
||||
|
|
@ -46,9 +49,65 @@ const tabs: { id: SettingsTab; label: string; icon: typeof User }[] = [
|
|||
{ id: "app-versions", label: "App versions", icon: Smartphone },
|
||||
{ id: "profile", label: "Profile", icon: User },
|
||||
{ id: "security", label: "Security", icon: Shield },
|
||||
{ id: "notifications", label: "Notifications", icon: Bell },
|
||||
{ id: "appearance", label: "Appearance", icon: Palette },
|
||||
];
|
||||
|
||||
function Toggle({
|
||||
enabled,
|
||||
onToggle,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors focus-visible:outline-none",
|
||||
enabled ? "bg-brand-500" : "bg-grayScale-200",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"pointer-events-none inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform",
|
||||
enabled ? "translate-x-5" : "translate-x-0.5",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingRow({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
icon: any;
|
||||
title: string;
|
||||
description: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 rounded-[6px] px-3 py-4 transition-colors hover:bg-grayScale-100/50">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-[6px] bg-grayScale-100 text-grayScale-400">
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-grayScale-800">{title}</p>
|
||||
<p className="mt-0.5 text-xs text-grayScale-500">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileTab({ profile }: { profile: UserProfileData }) {
|
||||
const [firstName, setFirstName] = useState(profile.first_name);
|
||||
const [lastName, setLastName] = useState(profile.last_name);
|
||||
|
|
@ -564,6 +623,7 @@ export function SettingsPage() {
|
|||
{activeTab === "app-versions" && <AppVersionsTab />}
|
||||
{activeTab === "profile" && <ProfileTab profile={profile} />}
|
||||
{activeTab === "security" && <SecurityTab memberId={profile.id} />}
|
||||
{activeTab === "notifications" && <NotificationsTab />}
|
||||
{activeTab === "appearance" && <AppearanceTab />}
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -373,34 +373,30 @@ export function AddNewPracticePage() {
|
|||
})
|
||||
: undefined;
|
||||
|
||||
const qRes = await createQuestion(
|
||||
q.questionType === "DYNAMIC" && q.questionTypeDefinitionId != null && dynamicPayload
|
||||
const qRes = await createQuestion({
|
||||
question_text: q.questionText,
|
||||
question_type: q.questionType,
|
||||
difficulty_level: q.difficultyLevel,
|
||||
points: q.points,
|
||||
tips: q.tips || undefined,
|
||||
explanation: q.explanation || undefined,
|
||||
status,
|
||||
options: options.length > 0 ? options : undefined,
|
||||
voice_prompt: q.questionType === "DYNAMIC" ? undefined : q.voicePrompt || undefined,
|
||||
sample_answer_voice_prompt:
|
||||
q.questionType === "DYNAMIC" ? undefined : q.sampleAnswerVoicePrompt || undefined,
|
||||
audio_correct_answer_text:
|
||||
q.questionType === "DYNAMIC" ? undefined : q.audioCorrectAnswerText || undefined,
|
||||
image_url: q.questionType === "DYNAMIC" ? undefined : q.imageUrl.trim() || undefined,
|
||||
short_answers:
|
||||
q.questionType !== "DYNAMIC" && q.shortAnswers.length > 0 ? q.shortAnswers : undefined,
|
||||
...(q.questionType === "DYNAMIC" && q.questionTypeDefinitionId != null && dynamicPayload
|
||||
? {
|
||||
question_type: "DYNAMIC",
|
||||
question_type_definition_id: q.questionTypeDefinitionId,
|
||||
dynamic_payload: dynamicPayload,
|
||||
difficulty_level: q.difficultyLevel,
|
||||
points: q.points,
|
||||
tips: q.tips || undefined,
|
||||
explanation: q.explanation || undefined,
|
||||
status,
|
||||
}
|
||||
: {
|
||||
question_text: q.questionText,
|
||||
question_type: q.questionType,
|
||||
difficulty_level: q.difficultyLevel,
|
||||
points: q.points,
|
||||
tips: q.tips || undefined,
|
||||
explanation: q.explanation || undefined,
|
||||
status,
|
||||
options: options.length > 0 ? options : undefined,
|
||||
voice_prompt: q.voicePrompt || undefined,
|
||||
sample_answer_voice_prompt: q.sampleAnswerVoicePrompt || undefined,
|
||||
audio_correct_answer_text: q.audioCorrectAnswerText || undefined,
|
||||
image_url: q.imageUrl.trim() || undefined,
|
||||
short_answers: q.shortAnswers.length > 0 ? q.shortAnswers : undefined,
|
||||
},
|
||||
);
|
||||
: {}),
|
||||
});
|
||||
|
||||
const questionId = qRes.data?.data?.id;
|
||||
if (questionId) {
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ export function AddPracticeFlow() {
|
|||
setDefinitionsLoading(true);
|
||||
setDefinitionsError(null);
|
||||
try {
|
||||
const { definitions: list } = await getQuestionTypeDefinitions({
|
||||
const list = await getQuestionTypeDefinitions({
|
||||
include_system: true,
|
||||
status: "ACTIVE",
|
||||
});
|
||||
|
|
@ -216,7 +216,9 @@ export function AddPracticeFlow() {
|
|||
return;
|
||||
}
|
||||
const persona = personaFromId(selectedPersona, personas);
|
||||
const mappedQuestions = formData.questions.map((q) => ({
|
||||
const mappedQuestions = formData.questions
|
||||
.filter((q) => String(q.text ?? "").trim())
|
||||
.map((q) => ({
|
||||
questionText: String(q.text ?? "").trim(),
|
||||
questionTypeDefinitionId: Number(q.questionTypeDefinitionId),
|
||||
dynamicFieldValues: { ...(q.dynamicFieldValues ?? {}) },
|
||||
|
|
@ -370,7 +372,6 @@ export function AddPracticeFlow() {
|
|||
onCancel={() => navigate(backPath)}
|
||||
isLessonPractice={isLessonPractice}
|
||||
lessonTitle={lessonTitleDisplay}
|
||||
parentSummary={parentSummary}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
|
|
@ -514,7 +515,9 @@ export function AddPracticeFlow() {
|
|||
</Button>
|
||||
</div>
|
||||
<p className="text-grayScale-400 text-base">
|
||||
Create a practice with story details, a persona, and questions from your question type library.
|
||||
Create a practice: question types from{" "}
|
||||
<code className="text-xs">GET /questions/type-definitions</code>, then
|
||||
question set and POST /practices.
|
||||
</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">
|
||||
|
|
|
|||
|
|
@ -141,8 +141,8 @@ export function AddQuestionPage() {
|
|||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const { definitions: rows } = await getQuestionTypeDefinitions({ include_system: true })
|
||||
if (!cancelled) setTypeDefinitions(rows)
|
||||
const rows = await getQuestionTypeDefinitions({ include_system: true })
|
||||
if (!cancelled) setTypeDefinitions(Array.isArray(rows) ? rows : [])
|
||||
} catch {
|
||||
if (!cancelled) setTypeDefinitions([])
|
||||
}
|
||||
|
|
@ -268,7 +268,7 @@ export function AddQuestionPage() {
|
|||
return
|
||||
}
|
||||
} catch {
|
||||
toast.error("Invalid JSON", { description: "Fix the dynamic content JSON before saving." })
|
||||
toast.error("Invalid JSON", { description: "Fix dynamic_payload 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 content (JSON) <span className="text-red-500">*</span>
|
||||
dynamic_payload (JSON) <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Textarea
|
||||
value={formData.dynamicPayloadJson}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ 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 }
|
||||
|
|
@ -423,7 +422,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"
|
||||
>
|
||||
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
|
||||
{[10, 20, 50].map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
|
|
|
|||
|
|
@ -427,7 +427,11 @@ 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).
|
||||
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>
|
||||
.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4">
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ 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 }>()
|
||||
|
|
@ -514,7 +513,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"
|
||||
>
|
||||
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
|
||||
{[10, 20, 50].map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import type {
|
|||
} from "../../types/questionTypeDefinition.types"
|
||||
import {
|
||||
buildCreatePayload,
|
||||
buildValidateKindsPayload,
|
||||
validateDefinitionBasic,
|
||||
validateDefinitionKinds,
|
||||
validateDefinitionSchemas,
|
||||
|
|
@ -30,7 +29,6 @@ import { QuestionTypeBasicInfoStep } from "./components/question-type-steps/Ques
|
|||
import { QuestionTypeConfigStep } from "./components/question-type-steps/QuestionTypeConfigStep"
|
||||
import { QuestionTypeValidatePreviewStep } from "./components/question-type-steps/QuestionTypeValidatePreviewStep"
|
||||
import { QuestionTypeReviewPublishStep } from "./components/question-type-steps/QuestionTypeReviewPublishStep"
|
||||
import { defaultLabelForKind } from "../../lib/schemaSlotLabel"
|
||||
|
||||
const initialDraft = (): QuestionTypeDefinitionCreatePayload => ({
|
||||
key: "",
|
||||
|
|
@ -47,7 +45,7 @@ function seedSchemaFromKinds(kinds: string[]) {
|
|||
return kinds.map((k, i) => ({
|
||||
id: `${(k || "field").toLowerCase().replace(/[^a-z0-9]+/g, "_") || "field"}_${i + 1}`,
|
||||
kind: k,
|
||||
label: defaultLabelForKind(k),
|
||||
label: k.replace(/_/g, " "),
|
||||
required: true as boolean,
|
||||
}))
|
||||
}
|
||||
|
|
@ -60,14 +58,8 @@ function definitionToDraft(def: QuestionTypeDefinition): QuestionTypeDefinitionC
|
|||
status: def.status === "INACTIVE" ? "INACTIVE" : "ACTIVE",
|
||||
stimulus_component_kinds: [...(def.stimulus_component_kinds ?? [])],
|
||||
response_component_kinds: [...(def.response_component_kinds ?? [])],
|
||||
stimulus_schema: (def.stimulus_schema ?? []).map((r) => ({
|
||||
...r,
|
||||
label: r.label?.trim() || defaultLabelForKind(r.kind),
|
||||
})),
|
||||
response_schema: (def.response_schema ?? []).map((r) => ({
|
||||
...r,
|
||||
label: r.label?.trim() || defaultLabelForKind(r.kind),
|
||||
})),
|
||||
stimulus_schema: (def.stimulus_schema ?? []).map((r) => ({ ...r })),
|
||||
response_schema: (def.response_schema ?? []).map((r) => ({ ...r })),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -83,9 +75,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: [],
|
||||
|
|
@ -142,7 +134,7 @@ export function CreateQuestionTypeFlow() {
|
|||
return
|
||||
}
|
||||
setDraft(definitionToDraft(def))
|
||||
setIsSystemDefinition(Boolean(def.is_system))
|
||||
setVersionName("Test 1")
|
||||
setCurrentStep(1)
|
||||
setStepErrors({})
|
||||
} catch (e) {
|
||||
|
|
@ -163,6 +155,7 @@ export function CreateQuestionTypeFlow() {
|
|||
useEffect(() => {
|
||||
if (!isEdit) {
|
||||
setDraft(initialDraft())
|
||||
setVersionName("Test 1")
|
||||
setCurrentStep(1)
|
||||
setStepErrors({})
|
||||
setDefinitionReady(true)
|
||||
|
|
@ -186,10 +179,15 @@ export function CreateQuestionTypeFlow() {
|
|||
}
|
||||
|
||||
const handleNextFromStep2 = () => {
|
||||
const versionErr: FieldErrorMap = {}
|
||||
if (!versionName.trim()) {
|
||||
versionErr.version_name = "Version name is required."
|
||||
}
|
||||
const eKinds = validateDefinitionKinds(draft, componentCatalog)
|
||||
setStepErrors(eKinds)
|
||||
if (Object.keys(eKinds).length) {
|
||||
toast.error("Select valid stimulus and response component kinds.")
|
||||
const mergedKinds = { ...versionErr, ...eKinds }
|
||||
setStepErrors(mergedKinds)
|
||||
if (Object.keys(mergedKinds).length) {
|
||||
toast.error("Complete version name and component selections.")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -235,7 +233,7 @@ export function CreateQuestionTypeFlow() {
|
|||
navigate(`/new-content/question-types?updated=${id}`)
|
||||
return
|
||||
}
|
||||
const validation = await validateQuestionTypeDefinition(buildValidateKindsPayload(body))
|
||||
const validation = await validateQuestionTypeDefinition(body)
|
||||
if (!validation.valid) {
|
||||
toast.error(validation.message || "Invalid question type definition", {
|
||||
description: validation.error ? String(validation.error) : undefined,
|
||||
|
|
@ -292,9 +290,20 @@ 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 reusable question type definition #${editDefinitionId}.`
|
||||
: "Build a reusable question type template for dynamic practice and assessment questions."}
|
||||
{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>.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 shrink-0">
|
||||
|
|
@ -334,6 +343,8 @@ export function CreateQuestionTypeFlow() {
|
|||
<QuestionTypeConfigStep
|
||||
draft={draft}
|
||||
setDraft={setDraft}
|
||||
versionName={versionName}
|
||||
setVersionName={setVersionName}
|
||||
stimulusCatalogKinds={componentCatalog.stimulus_component_kinds}
|
||||
responseCatalogKinds={componentCatalog.response_component_kinds}
|
||||
catalogLoading={catalogLoading}
|
||||
|
|
@ -347,12 +358,7 @@ export function CreateQuestionTypeFlow() {
|
|||
<QuestionTypeValidatePreviewStep draft={draft} onNext={() => setCurrentStep(4)} onBack={handleBack} />
|
||||
)}
|
||||
{currentStep === 4 && (
|
||||
<QuestionTypeReviewPublishStep
|
||||
draft={draft}
|
||||
onBack={handleBack}
|
||||
editDefinitionId={editDefinitionId}
|
||||
isSystem={isSystemDefinition}
|
||||
/>
|
||||
<QuestionTypeReviewPublishStep draft={draft} onBack={handleBack} editDefinitionId={editDefinitionId} />
|
||||
)}
|
||||
</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">
|
||||
Choose a sub-category from the list to view and manage its course structure.
|
||||
Powered by `GET /course-management/human-language/hierarchy` and `GET /course-management/courses/:courseId/hierarchy`.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -1019,7 +1019,7 @@ export function HumanLanguageHierarchyPage() {
|
|||
<DialogHeader>
|
||||
<DialogTitle>Create module</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a module to this level.
|
||||
Add a module to this level. This will call `POST /course-management/modules`.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -1140,7 +1140,7 @@ export function HumanLanguageHierarchyPage() {
|
|||
<DialogHeader>
|
||||
<DialogTitle>Update module</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update this module's name, order, and settings.
|
||||
Update this module using `PUT /course-management/modules/:moduleId`.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
|
|||
|
|
@ -1029,7 +1029,7 @@ export function HumanLanguageSubModulePage() {
|
|||
<DialogHeader>
|
||||
<DialogTitle>Lesson detail</DialogTitle>
|
||||
<DialogDescription>
|
||||
View and edit lesson details.
|
||||
Loaded from `GET /course-management/sub-module-lessons/:lessonId`.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
|
|||
|
|
@ -356,8 +356,15 @@ export function LearnEnglishPage() {
|
|||
Add New Program
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-grayScale-400">
|
||||
Create a new learning program. Add a thumbnail as an image URL or by uploading a
|
||||
file.
|
||||
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>
|
||||
.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{/* Gradient Divider */}
|
||||
|
|
@ -732,7 +739,11 @@ export function LearnEnglishPage() {
|
|||
disabled={savingEdit || uploadingEditThumbnail}
|
||||
/>
|
||||
<p className="text-xs text-grayScale-500">
|
||||
Uploaded images are stored and used as the program thumbnail.
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -364,6 +364,12 @@ 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,7 +668,15 @@ export function ModuleDetailPage() {
|
|||
<DialogHeader>
|
||||
<DialogTitle>Edit lesson</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update lesson details. Uploaded video and thumbnail files are stored automatically.
|
||||
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>
|
||||
.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-2">
|
||||
|
|
|
|||
|
|
@ -1093,6 +1093,9 @@ 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}>
|
||||
|
|
@ -1112,8 +1115,9 @@ export function PracticeDetailsPage() {
|
|||
<DialogTitle>Delete this practice?</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-grayScale-600">
|
||||
This permanently removes the practice for this {parentTabCopy[parentTab].label.toLowerCase()}. The linked
|
||||
question set may remain unless you remove it separately.
|
||||
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.
|
||||
</p>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ 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"
|
||||
|
|
@ -85,7 +84,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, setPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
|
||||
const [pageSize] = useState(10)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [totalQuestions, setTotalQuestions] = useState(0)
|
||||
|
||||
|
|
@ -737,56 +736,29 @@ export function PracticeQuestionsPage() {
|
|||
))}
|
||||
</div>
|
||||
))}
|
||||
{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>
|
||||
{totalQuestions > pageSize && (
|
||||
<div className="flex items-center justify-between rounded-xl border border-grayScale-200 bg-white px-4 py-3">
|
||||
<p className="text-sm text-grayScale-500">
|
||||
Page {currentPage} of {Math.max(1, Math.ceil(totalQuestions / pageSize))}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => void fetchQuestions(currentPage - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage >= Math.ceil(totalQuestions / pageSize)}
|
||||
onClick={() => void fetchQuestions(currentPage + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
{totalQuestions > pageSize ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => void fetchQuestions(currentPage - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage >= Math.ceil(totalQuestions / pageSize)}
|
||||
onClick={() => void fetchQuestions(currentPage + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -379,8 +379,15 @@ export function ProgramCoursesPage() {
|
|||
Add New Course
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-grayScale-400">
|
||||
Add a new course to this program. Use an image URL or upload a file for the
|
||||
thumbnail.
|
||||
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>
|
||||
.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -701,7 +708,11 @@ 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.
|
||||
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>
|
||||
.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4">
|
||||
|
|
|
|||
|
|
@ -1,21 +1,10 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom"
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Layers,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import { ArrowLeft, Plus, Search, Trash2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "../../components/ui/button"
|
||||
import { Input } from "../../components/ui/input"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||
import { Card } from "../../components/ui/card"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -26,7 +15,6 @@ import {
|
|||
} from "../../components/ui/dialog"
|
||||
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 { QuestionTypeCard } from "./components/QuestionTypeCard"
|
||||
import {
|
||||
deleteQuestionTypeDefinition,
|
||||
|
|
@ -34,23 +22,6 @@ import {
|
|||
} from "../../api/questionTypeDefinitions.api"
|
||||
import type { QuestionTypeDefinition } from "../../types/questionTypeDefinition.types"
|
||||
|
||||
type StatusFilter = "All" | "ACTIVE" | "INACTIVE"
|
||||
type ScopeFilter = "all" | "system" | "custom"
|
||||
|
||||
const STATUS_OPTIONS: { value: StatusFilter; label: string }[] = [
|
||||
{ value: "All", label: "All statuses" },
|
||||
{ value: "ACTIVE", label: "Active" },
|
||||
{ value: "INACTIVE", label: "Inactive" },
|
||||
]
|
||||
|
||||
const SCOPE_OPTIONS: { value: ScopeFilter; label: string }[] = [
|
||||
{ value: "all", label: "All types" },
|
||||
{ value: "system", label: "System only" },
|
||||
{ value: "custom", label: "Custom only" },
|
||||
]
|
||||
|
||||
const SYSTEM_SCOPE_FETCH_LIMIT = 100
|
||||
|
||||
export function QuestionTypeLibraryPage() {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
|
|
@ -59,48 +30,24 @@ export function QuestionTypeLibraryPage() {
|
|||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [definitions, setDefinitions] = useState<QuestionTypeDefinition[]>([])
|
||||
const [totalCount, setTotalCount] = useState<number | undefined>(undefined)
|
||||
const [query, setQuery] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("All")
|
||||
const [scopeFilter, setScopeFilter] = useState<ScopeFilter>("all")
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [activeTab, setActiveTab] = useState<"All" | "ACTIVE" | "INACTIVE">("All")
|
||||
const [definitionPendingDelete, setDefinitionPendingDelete] = useState<QuestionTypeDefinition | null>(null)
|
||||
const [deleteSubmitting, setDeleteSubmitting] = useState(false)
|
||||
|
||||
const hasActiveFilters =
|
||||
query.trim().length > 0 || statusFilter !== "All" || scopeFilter !== "all"
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const isSystemScope = scopeFilter === "system"
|
||||
const { definitions: rows, total_count } = await getQuestionTypeDefinitions({
|
||||
include_system: scopeFilter !== "custom",
|
||||
...(statusFilter !== "All" ? { status: statusFilter } : {}),
|
||||
limit: isSystemScope ? SYSTEM_SCOPE_FETCH_LIMIT : pageSize,
|
||||
offset: isSystemScope ? 0 : offset,
|
||||
})
|
||||
const visibleRows = isSystemScope ? rows.filter((d) => d.is_system) : rows
|
||||
setDefinitions(visibleRows)
|
||||
if (isSystemScope) {
|
||||
setTotalCount(visibleRows.length)
|
||||
} else if (total_count != null) {
|
||||
setTotalCount(total_count)
|
||||
} else if (rows.length < pageSize) {
|
||||
setTotalCount(offset + rows.length)
|
||||
} else {
|
||||
setTotalCount(undefined)
|
||||
}
|
||||
const rows = await getQuestionTypeDefinitions({ include_system: true })
|
||||
setDefinitions(Array.isArray(rows) ? rows : [])
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.error("Failed to load question type definitions")
|
||||
setDefinitions([])
|
||||
setTotalCount(0)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [offset, pageSize, scopeFilter, statusFilter])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
|
|
@ -120,36 +67,18 @@ export function QuestionTypeLibraryPage() {
|
|||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return definitions
|
||||
return definitions.filter((d) => {
|
||||
if (activeTab !== "All") {
|
||||
const st = (d.status || "").toString().toUpperCase()
|
||||
if (activeTab === "ACTIVE" && st !== "ACTIVE") return false
|
||||
if (activeTab === "INACTIVE" && st !== "INACTIVE") return false
|
||||
}
|
||||
if (!q) return true
|
||||
const name = (d.display_name || "").toLowerCase()
|
||||
const key = (d.key || "").toLowerCase()
|
||||
return name.includes(q) || key.includes(q) || String(d.id).includes(q)
|
||||
})
|
||||
}, [definitions, query])
|
||||
|
||||
const isSystemScope = scopeFilter === "system"
|
||||
const canPrev = !isSystemScope && offset > 0
|
||||
const canNext =
|
||||
!isSystemScope &&
|
||||
(totalCount != null ? offset + pageSize < totalCount : definitions.length === pageSize)
|
||||
|
||||
const pageStart = totalCount === 0 ? 0 : isSystemScope ? 1 : offset + 1
|
||||
const pageEnd =
|
||||
isSystemScope
|
||||
? filtered.length
|
||||
: totalCount != null
|
||||
? Math.min(offset + definitions.length, totalCount)
|
||||
: offset + definitions.length
|
||||
|
||||
const resetPagination = () => setOffset(0)
|
||||
|
||||
const clearFilters = () => {
|
||||
setQuery("")
|
||||
setStatusFilter("All")
|
||||
setScopeFilter("all")
|
||||
setOffset(0)
|
||||
}
|
||||
}, [definitions, query, activeTab])
|
||||
|
||||
const openDeleteConfirm = (row: QuestionTypeDefinition) => {
|
||||
if (row.is_system) {
|
||||
|
|
@ -195,7 +124,9 @@ 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 templates that define how practice and assessment questions are structured and answered.
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/new-content/question-types/create">
|
||||
|
|
@ -207,241 +138,66 @@ export function QuestionTypeLibraryPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="overflow-hidden rounded-2xl border border-grayScale-200 bg-white shadow-none">
|
||||
<CardHeader className="border-b border-grayScale-100 px-6 py-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-brand-50 text-brand-600">
|
||||
<Layers className="h-5 w-5" aria-hidden />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-bold text-grayScale-900">Definition library</CardTitle>
|
||||
<p className="text-xs text-grayScale-500 mt-0.5">
|
||||
Browse and filter templates from the question type catalog
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
<Card className="p-6 border-grayScale-200 rounded-2xl bg-white space-y-6">
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-grayScale-600" />
|
||||
<Input
|
||||
className="h-10 pl-12 rounded-[6px] border-grayScale-200 placeholder:text-grayScale-600 bg-[#F8FAFC] transition-all text-sm"
|
||||
placeholder="Search by display name, key, or id…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span className="text-[12px] font-medium text-grayScale-400 uppercase tracking-widest mr-2">Status</span>
|
||||
{(["All", "ACTIVE", "INACTIVE"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-[8px] border-grayScale-200"
|
||||
disabled={loading}
|
||||
onClick={() => void load()}
|
||||
>
|
||||
<RefreshCw className={cn("mr-2 h-4 w-4", loading && "animate-spin")} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-0">
|
||||
<div className="border-b border-grayScale-100 bg-grayScale-50/60 px-6 py-5 space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
|
||||
<Input
|
||||
className="h-11 pl-11 pr-10 rounded-[10px] border-grayScale-200 bg-white placeholder:text-grayScale-400 text-sm shadow-sm"
|
||||
placeholder="Search by display name, key, or id on this page…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
{query ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuery("")}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-md p-1 text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="mr-1 text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||
Status
|
||||
</span>
|
||||
{STATUS_OPTIONS.map(({ value, label }) => (
|
||||
<FilterChip
|
||||
key={value}
|
||||
label={label}
|
||||
active={statusFilter === value}
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setStatusFilter(value)
|
||||
resetPagination()
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<span className="hidden h-5 w-px bg-grayScale-200 sm:block" />
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="mr-1 text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||
Scope
|
||||
</span>
|
||||
{SCOPE_OPTIONS.map(({ value, label }) => (
|
||||
<FilterChip
|
||||
key={value}
|
||||
label={label}
|
||||
active={scopeFilter === value}
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setScopeFilter(value)
|
||||
resetPagination()
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasActiveFilters ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 self-start rounded-[8px] px-3 text-xs font-semibold text-grayScale-500 hover:text-brand-600 lg:self-center"
|
||||
disabled={loading}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 px-6 py-20">
|
||||
<SpinnerIcon className="h-8 w-8 text-brand-500" />
|
||||
<p className="text-sm text-grayScale-500">Loading definitions…</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 px-6 py-20 text-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-grayScale-100 text-grayScale-400">
|
||||
<Layers className="h-7 w-7" aria-hidden />
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-grayScale-700">
|
||||
{hasActiveFilters ? "No definitions match your filters" : "No definitions yet"}
|
||||
</p>
|
||||
<p className="max-w-sm text-xs text-grayScale-500">
|
||||
{hasActiveFilters
|
||||
? "Try different filters or clear them to see more results."
|
||||
: "Create a definition to start building custom question templates."}
|
||||
</p>
|
||||
{hasActiveFilters ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-[8px]"
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
) : (
|
||||
<Link to="/new-content/question-types/create">
|
||||
<Button size="sm" className="rounded-[8px] bg-brand-600 hover:bg-brand-500">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create definition
|
||||
</Button>
|
||||
</Link>
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={cn(
|
||||
"h-10 px-4 rounded-full text-[13px] font-medium transition-all",
|
||||
activeTab === tab
|
||||
? "bg-[#9E2891] text-white shadow-md shadow-brand-500/20"
|
||||
: "bg-grayScale-100 text-grayScale-400 hover:bg-grayScale-100",
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-5 px-6 py-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{filtered.map((d) => (
|
||||
<QuestionTypeCard
|
||||
key={d.id}
|
||||
id={d.id}
|
||||
definitionKey={d.key}
|
||||
display_name={d.display_name}
|
||||
status={d.status}
|
||||
is_system={d.is_system}
|
||||
stimulusKindsCount={d.stimulus_component_kinds?.length ?? 0}
|
||||
responseKindsCount={d.response_component_kinds?.length ?? 0}
|
||||
deleteDisabled={!!d.is_system}
|
||||
onEdit={() => navigate(`/new-content/question-types/${d.id}/edit`)}
|
||||
onDelete={() => openDeleteConfirm(d)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && (definitions.length > 0 || offset > 0) ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 border-t border-grayScale-100 bg-white px-6 py-4">
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-grayScale-500">
|
||||
<span>
|
||||
{totalCount != null
|
||||
? `Showing ${pageStart}–${pageEnd} of ${totalCount}`
|
||||
: `Showing ${pageStart}–${pageEnd}`}
|
||||
</span>
|
||||
{query.trim() && filtered.length !== definitions.length ? (
|
||||
<span className="rounded-full bg-brand-50 px-2.5 py-0.5 text-[11px] font-semibold text-brand-600">
|
||||
{filtered.length} match{filtered.length === 1 ? "" : "es"} on this page
|
||||
</span>
|
||||
) : null}
|
||||
{!isSystemScope ? (
|
||||
<>
|
||||
<span className="hidden h-4 w-px bg-grayScale-200 sm:inline" />
|
||||
<span className="flex items-center gap-2">
|
||||
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-[8px] border border-grayScale-200 bg-white pl-2.5 pr-8 text-sm font-medium text-grayScale-600 focus:outline-none focus:ring-2 focus:ring-brand-200"
|
||||
>
|
||||
{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-4 w-4 -translate-y-1/2 text-grayScale-400" />
|
||||
</div>
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!isSystemScope ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-[8px] border-grayScale-200"
|
||||
disabled={!canPrev || loading}
|
||||
onClick={() => setOffset((o) => Math.max(0, o - pageSize))}
|
||||
>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-[8px] border-grayScale-200"
|
||||
disabled={!canNext || loading}
|
||||
onClick={() => setOffset((o) => o + pageSize)}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
>
|
||||
{tab === "All" ? "All" : tab === "ACTIVE" ? "Active" : "Inactive"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-grayScale-500 px-2">Loading definitions…</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<Card className="p-12 text-center border-dashed border-grayScale-200 rounded-2xl">
|
||||
<p className="text-grayScale-600 font-medium">No definitions match your filters.</p>
|
||||
<p className="text-sm text-grayScale-400 mt-2">Create one to get started.</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{filtered.map((d) => (
|
||||
<QuestionTypeCard
|
||||
key={d.id}
|
||||
id={d.id}
|
||||
definitionKey={d.key}
|
||||
display_name={d.display_name}
|
||||
status={d.status}
|
||||
is_system={d.is_system}
|
||||
stimulusKindsCount={d.stimulus_component_kinds?.length ?? 0}
|
||||
responseKindsCount={d.response_component_kinds?.length ?? 0}
|
||||
deleteDisabled={!!d.is_system}
|
||||
onEdit={() => navigate(`/new-content/question-types/${d.id}/edit`)}
|
||||
onDelete={() => openDeleteConfirm(d)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={definitionPendingDelete !== null} onOpenChange={handleDeleteDialogOpenChange}>
|
||||
<DialogContent className="max-w-md rounded-2xl border-grayScale-200 sm:max-w-md">
|
||||
<DialogHeader>
|
||||
|
|
@ -488,32 +244,3 @@ export function QuestionTypeLibraryPage() {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterChip({
|
||||
label,
|
||||
active,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
active: boolean
|
||||
disabled?: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"rounded-full border px-3.5 py-1.5 text-xs font-semibold transition-all",
|
||||
active
|
||||
? "border-brand-500 bg-brand-500 text-white shadow-sm shadow-brand-500/20"
|
||||
: "border-grayScale-200 bg-white text-grayScale-600 hover:border-brand-200 hover:text-brand-600",
|
||||
disabled && "pointer-events-none opacity-50",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ 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"
|
||||
|
|
@ -559,7 +558,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"
|
||||
>
|
||||
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
|
||||
{[10, 20, 50].map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ 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"])
|
||||
|
|
@ -150,7 +149,7 @@ export function SpeakingPage() {
|
|||
const [audioQuestions, setAudioQuestions] = useState<AudioListQuestion[]>([])
|
||||
const [audioTotalCount, setAudioTotalCount] = useState(0)
|
||||
const [audioPage, setAudioPage] = useState(1)
|
||||
const [audioPageSize, setAudioPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
|
||||
const [audioPageSize] = useState(12)
|
||||
const [practiceOptions, setPracticeOptions] = useState<PracticeFilterOption[]>([])
|
||||
const [selectedPracticeId, setSelectedPracticeId] = useState<string>("")
|
||||
const [practiceFilterOpen, setPracticeFilterOpen] = useState(false)
|
||||
|
|
@ -1511,58 +1510,31 @@ 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>
|
||||
{audioTotalCount > audioPageSize ? (
|
||||
<div className="mt-4 flex items-center justify-between rounded-xl border border-grayScale-200 bg-white px-3 py-2">
|
||||
<p className="text-xs text-grayScale-500 sm:text-sm">
|
||||
Page {audioPage} of {Math.max(1, Math.ceil(audioTotalCount / audioPageSize))}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={audioPage <= 1 || loading}
|
||||
onClick={() => fetchAudioQuestions(audioPage - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={audioPage >= Math.ceil(audioTotalCount / audioPageSize) || loading}
|
||||
onClick={() => fetchAudioQuestions(audioPage + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
{audioTotalCount > audioPageSize ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={audioPage <= 1 || loading}
|
||||
onClick={() => fetchAudioQuestions(audioPage - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={audioPage >= Math.ceil(audioTotalCount / audioPageSize) || loading}
|
||||
onClick={() => fetchAudioQuestions(audioPage + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -111,7 +111,11 @@ export function AddModuleModal({
|
|||
Add New Module
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-grayScale-400">
|
||||
Add a new module to this course.
|
||||
Create a module with{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
||||
POST /courses/:courseId/modules
|
||||
</code>
|
||||
.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
|
|||
|
|
@ -265,7 +265,8 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
|
|||
/>
|
||||
</div>
|
||||
<p className="text-xs text-grayScale-500">
|
||||
Creates a practice question set for this course, module, or lesson.
|
||||
This calls <span className="font-mono">POST /question-sets</span> with{" "}
|
||||
<span className="font-mono">set_type: PRACTICE</span>.
|
||||
</p>
|
||||
<Button type="button" onClick={handleStep1} disabled={saving}>
|
||||
{saving ? <SpinnerIcon className="h-4 w-4" /> : null}
|
||||
|
|
@ -277,7 +278,9 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
|
|||
{canUseWizard && step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-grayScale-600">
|
||||
Add one or more audio questions to question set #{questionSetId}.
|
||||
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>.
|
||||
</p>
|
||||
{questionRows.map((row, idx) => (
|
||||
<div
|
||||
|
|
@ -386,7 +389,8 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
|
|||
{canUseWizard && step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-grayScale-600">
|
||||
Confirm the order of questions in the set.
|
||||
Link each question to the set with a display order using{" "}
|
||||
<span className="font-mono">POST /question-sets/{id}/questions</span>.
|
||||
</p>
|
||||
<ul className="space-y-2 rounded-xl border border-grayScale-200 bg-white p-3">
|
||||
{createdQuestionIds.map((qid, i) => (
|
||||
|
|
@ -418,7 +422,12 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
|
|||
{canUseWizard && step === 4 && parent && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-grayScale-600">
|
||||
Linked to {parent.kind.toLowerCase()} #{parent.id} · question set #{questionSetId}
|
||||
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>
|
||||
</p>
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Edit2, Sparkles, Trash2, Layers, Shield } from "lucide-react"
|
||||
import { Edit2, Trash2, Layers, Shield } from "lucide-react"
|
||||
import { Badge } from "../../../components/ui/badge"
|
||||
import { Card } from "../../../components/ui/card"
|
||||
import { Button } from "../../../components/ui/button"
|
||||
|
|
@ -42,12 +42,7 @@ export function QuestionTypeCard({
|
|||
<Shield className="h-3 w-3" />
|
||||
System
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="shrink-0 border-none bg-amber-50 text-amber-800 flex items-center gap-1">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
Custom
|
||||
</Badge>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="text-[12px] font-mono text-grayScale-500 break-all">#{id} · {definitionKey}</p>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ interface ContextStepProps {
|
|||
/** Lesson-linked practice: no title, story description, or story image on step 1. */
|
||||
isLessonPractice?: boolean;
|
||||
lessonTitle?: string | null;
|
||||
parentSummary?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -28,7 +27,6 @@ export function ContextStep({
|
|||
onCancel,
|
||||
isLessonPractice = false,
|
||||
lessonTitle = null,
|
||||
parentSummary = null,
|
||||
}: ContextStepProps) {
|
||||
const storyFileRef = useRef<HTMLInputElement>(null);
|
||||
const [uploadingStory, setUploadingStory] = useState(false);
|
||||
|
|
@ -64,15 +62,16 @@ export function ContextStep({
|
|||
<p className="text-grayScale-600 text-base mt-3">
|
||||
{isLessonPractice ? (
|
||||
<>
|
||||
Story fields and question set options used when saving the practice. Linked to{" "}
|
||||
This practice is linked to{" "}
|
||||
<span className="font-medium text-grayScale-800">
|
||||
{lessonTitle?.trim() || "the selected lesson"}
|
||||
</span>
|
||||
.
|
||||
. Set optional quick tips and question order below.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Story fields and question set options used when saving the practice.
|
||||
Title, story, optional image, shuffle, and quick tips match the create
|
||||
practice and question set APIs.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
|
@ -91,16 +90,6 @@ 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">
|
||||
|
|
@ -143,7 +132,7 @@ export function ContextStep({
|
|||
onChange={(e) =>
|
||||
setFormData({ ...formData, tips: e.target.value })
|
||||
}
|
||||
placeholder="Optional tips shown to learners before they start"
|
||||
placeholder="Learner-facing tips (quick_tips on POST /practices)"
|
||||
className="min-h-[80px] rounded-xl border-grayScale-200"
|
||||
maxLength={1000}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ export function PublishStatusField({ value, onChange, disabled, className }: Pro
|
|||
Publish status <span className="text-red-500">*</span>
|
||||
</p>
|
||||
<p className="text-xs text-grayScale-500">
|
||||
Controls whether learners can see this practice after you save.
|
||||
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>.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3" role="radiogroup" aria-label="Publish status">
|
||||
{(
|
||||
|
|
|
|||
|
|
@ -10,8 +10,6 @@ import {
|
|||
emptyDynamicFieldValuesForDefinition,
|
||||
legacyQuestionTypeFromDefinition,
|
||||
} from "../../../../lib/learnEnglishDefinitionQuestion";
|
||||
import { validateLearnEnglishQuestionsWithDefinitions } from "../../../../lib/learnEnglishPracticePublish";
|
||||
import { toast } from "sonner";
|
||||
|
||||
function defaultMcqOptions() {
|
||||
return [
|
||||
|
|
@ -100,9 +98,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, and PDF</span> use upload or URL.{" "}
|
||||
<span className="font-medium text-grayScale-800">Table</span> uses the visual table builder. Timer and
|
||||
prep-time slots use seconds. Other slots use text or structured JSON where noted.
|
||||
<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.
|
||||
</p>
|
||||
{def.stimulus_schema.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
|
|
@ -309,8 +307,11 @@ 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">
|
||||
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.
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -402,28 +403,21 @@ 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
|
||||
</label>
|
||||
<Input
|
||||
value={q.text}
|
||||
onChange={(e) => {
|
||||
const newQuestions = [...formData.questions];
|
||||
newQuestions[i].text = e.target.value;
|
||||
setFormData({ ...formData, questions: newQuestions });
|
||||
}}
|
||||
className="min-h-[52px] rounded-xl border-grayScale-200 px-4 py-3 text-base font-medium text-grayScale-700"
|
||||
placeholder="Question prompt for learners"
|
||||
/>
|
||||
</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}
|
||||
<div className="space-y-3">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-grayScale-700">
|
||||
Question text
|
||||
</label>
|
||||
<Input
|
||||
value={q.text}
|
||||
onChange={(e) => {
|
||||
const newQuestions = [...formData.questions];
|
||||
newQuestions[i].text = e.target.value;
|
||||
setFormData({ ...formData, questions: newQuestions });
|
||||
}}
|
||||
className="min-h-[52px] rounded-xl border-grayScale-200 px-4 py-3 text-base font-medium text-grayScale-700"
|
||||
placeholder="Question prompt for learners"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{def ? renderTypeSpecificFields(q, i, def) : null}
|
||||
</div>
|
||||
|
|
@ -457,30 +451,7 @@ export function QuestionsStep({
|
|||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
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();
|
||||
}}
|
||||
onClick={nextStep}
|
||||
disabled={definitionsLoading || !!definitionsError || typeDefinitions.length === 0}
|
||||
className="h-10 rounded-[6px] bg-brand-500 px-8 font-bold disabled:opacity-50"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
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,8 +29,10 @@ 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 choose how questions are
|
||||
presented and how learners answer.
|
||||
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>
|
||||
).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import { useState } from "react"
|
||||
import { ArrowLeft, ArrowRight, ChevronDown, ChevronUp, Minus, Plus } from "lucide-react"
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Hourglass,
|
||||
Plus,
|
||||
} from "lucide-react"
|
||||
import { Button } from "../../../../components/ui/button"
|
||||
import { Card } from "../../../../components/ui/card"
|
||||
import { Input } from "../../../../components/ui/input"
|
||||
|
|
@ -9,17 +16,14 @@ import type {
|
|||
} from "../../../../types/questionTypeDefinition.types"
|
||||
import type { FieldErrorMap } from "../../lib/questionTypeDefinitionValidation"
|
||||
import { SchemaBuilderSection } from "./SchemaBuilderSection"
|
||||
import { SchemaSlotLabelsPanel } from "./SchemaSlotLabelsPanel"
|
||||
import { ComponentKindCard } from "./ComponentKindCard"
|
||||
import {
|
||||
defaultLabelForKind,
|
||||
getResponseKindPresentation,
|
||||
getStimulusKindPresentation,
|
||||
} from "./componentKindUi"
|
||||
import { getResponseKindPresentation, getStimulusKindPresentation } from "./componentKindUi"
|
||||
|
||||
interface QuestionTypeConfigStepProps {
|
||||
draft: QuestionTypeDefinitionCreatePayload
|
||||
setDraft: React.Dispatch<React.SetStateAction<QuestionTypeDefinitionCreatePayload>>
|
||||
versionName: string
|
||||
setVersionName: (v: string) => void
|
||||
stimulusCatalogKinds: string[]
|
||||
responseCatalogKinds: string[]
|
||||
catalogLoading: boolean
|
||||
|
|
@ -38,6 +42,10 @@ function slugFragmentFromKind(kind: string): string {
|
|||
return s.replace(/^_|_$/g, "") || "field"
|
||||
}
|
||||
|
||||
function defaultSchemaLabel(kind: string): string {
|
||||
return kind.replace(/_/g, " ")
|
||||
}
|
||||
|
||||
function nextUniqueSchemaElementId(rows: DynamicElementDefinition[], kind: string): string {
|
||||
const base = slugFragmentFromKind(kind)
|
||||
const existing = new Set(rows.map((r) => r.id.trim()).filter(Boolean))
|
||||
|
|
@ -50,22 +58,6 @@ function nextUniqueSchemaElementId(rows: DynamicElementDefinition[], kind: strin
|
|||
return id
|
||||
}
|
||||
|
||||
function uniqueKindsFromSchemaRows(rows: DynamicElementDefinition[]): string[] {
|
||||
return [...new Set(rows.map((r) => r.kind).filter(Boolean))]
|
||||
}
|
||||
|
||||
function removeLastSlotOfKind(
|
||||
rows: DynamicElementDefinition[],
|
||||
kind: string,
|
||||
): DynamicElementDefinition[] {
|
||||
let removeIndex = -1
|
||||
rows.forEach((row, index) => {
|
||||
if (row.kind === kind) removeIndex = index
|
||||
})
|
||||
if (removeIndex < 0) return rows
|
||||
return rows.filter((_, index) => index !== removeIndex)
|
||||
}
|
||||
|
||||
function rowErrorMap(side: "stimulus" | "response", errors: FieldErrorMap): Record<number, string> {
|
||||
const prefix = `${side}_`
|
||||
const out: Record<number, string> = {}
|
||||
|
|
@ -81,6 +73,8 @@ function rowErrorMap(side: "stimulus" | "response", errors: FieldErrorMap): Reco
|
|||
export function QuestionTypeConfigStep({
|
||||
draft,
|
||||
setDraft,
|
||||
versionName,
|
||||
setVersionName,
|
||||
stimulusCatalogKinds,
|
||||
responseCatalogKinds,
|
||||
catalogLoading,
|
||||
|
|
@ -89,8 +83,11 @@ 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)
|
||||
|
|
@ -101,7 +98,7 @@ export function QuestionTypeConfigStep({
|
|||
stimulus_schema.push({
|
||||
id: nextUniqueSchemaElementId(stimulus_schema, kind),
|
||||
kind,
|
||||
label: defaultLabelForKind(kind),
|
||||
label: defaultSchemaLabel(kind),
|
||||
required: true,
|
||||
})
|
||||
}
|
||||
|
|
@ -125,7 +122,7 @@ export function QuestionTypeConfigStep({
|
|||
response_schema.push({
|
||||
id: nextUniqueSchemaElementId(response_schema, kind),
|
||||
kind,
|
||||
label: defaultLabelForKind(kind),
|
||||
label: defaultSchemaLabel(kind),
|
||||
required: true,
|
||||
})
|
||||
}
|
||||
|
|
@ -146,7 +143,7 @@ export function QuestionTypeConfigStep({
|
|||
stimulus_schema.push({
|
||||
id: nextUniqueSchemaElementId(stimulus_schema, kind),
|
||||
kind,
|
||||
label: defaultLabelForKind(kind),
|
||||
label: defaultSchemaLabel(kind),
|
||||
required: true,
|
||||
})
|
||||
return { ...d, stimulus_schema }
|
||||
|
|
@ -160,63 +157,54 @@ export function QuestionTypeConfigStep({
|
|||
response_schema.push({
|
||||
id: nextUniqueSchemaElementId(response_schema, kind),
|
||||
kind,
|
||||
label: defaultLabelForKind(kind),
|
||||
label: defaultSchemaLabel(kind),
|
||||
required: true,
|
||||
})
|
||||
return { ...d, response_schema }
|
||||
})
|
||||
}
|
||||
|
||||
const removeStimulusSlot = (kind: string) => {
|
||||
setDraft((d) => {
|
||||
const stimulus_schema = removeLastSlotOfKind(d.stimulus_schema, kind)
|
||||
return {
|
||||
...d,
|
||||
stimulus_schema,
|
||||
stimulus_component_kinds: uniqueKindsFromSchemaRows(stimulus_schema),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const removeResponseSlot = (kind: string) => {
|
||||
setDraft((d) => {
|
||||
const response_schema = removeLastSlotOfKind(d.response_schema, kind)
|
||||
return {
|
||||
...d,
|
||||
response_schema,
|
||||
response_component_kinds: uniqueKindsFromSchemaRows(response_schema),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const setStimulusSchema = (rows: DynamicElementDefinition[]) => {
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
stimulus_schema: rows,
|
||||
stimulus_component_kinds: uniqueKindsFromSchemaRows(rows),
|
||||
}))
|
||||
}
|
||||
|
||||
const setResponseSchema = (rows: DynamicElementDefinition[]) => {
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
response_schema: rows,
|
||||
response_component_kinds: uniqueKindsFromSchemaRows(rows),
|
||||
}))
|
||||
}
|
||||
|
||||
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">
|
||||
<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. Add or remove slots for each type
|
||||
as needed.
|
||||
</p>
|
||||
</div>
|
||||
<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).
|
||||
</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 ? (
|
||||
|
|
@ -229,8 +217,10 @@ 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. Use Add slot / Remove slot to adjust
|
||||
how many fields of each type you need.
|
||||
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).
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
||||
|
|
@ -251,31 +241,16 @@ export function QuestionTypeConfigStep({
|
|||
<span className="text-[12px] text-grayScale-500 font-medium">
|
||||
{slotCount} slot{slotCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 text-[12px] font-bold text-grayScale-600 hover:bg-grayScale-100"
|
||||
onClick={() => removeStimulusSlot(kind)}
|
||||
disabled={slotCount === 0}
|
||||
aria-label={`Remove ${label} slot`}
|
||||
>
|
||||
<Minus className="h-3.5 w-3.5 mr-1" />
|
||||
Remove
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 text-[12px] font-bold text-[#9E2891] hover:text-[#8A237E] hover:bg-violet-50"
|
||||
onClick={() => addStimulusSlot(kind)}
|
||||
aria-label={`Add ${label} slot`}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 text-[12px] font-bold text-[#9E2891] hover:text-[#8A237E] hover:bg-violet-50"
|
||||
onClick={() => addStimulusSlot(kind)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
Add slot
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -293,8 +268,10 @@ export function QuestionTypeConfigStep({
|
|||
Section B: Answer types
|
||||
</h3>
|
||||
<p className="text-[14px] text-grayScale-500 mt-1 font-medium">
|
||||
How should the student answer? Use Add slot / Remove slot to adjust how many answer fields
|
||||
each type needs.
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
||||
|
|
@ -315,31 +292,16 @@ export function QuestionTypeConfigStep({
|
|||
<span className="text-[12px] text-grayScale-500 font-medium">
|
||||
{slotCount} slot{slotCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 text-[12px] font-bold text-grayScale-600 hover:bg-grayScale-100"
|
||||
onClick={() => removeResponseSlot(kind)}
|
||||
disabled={slotCount === 0}
|
||||
aria-label={`Remove ${label} slot`}
|
||||
>
|
||||
<Minus className="h-3.5 w-3.5 mr-1" />
|
||||
Remove
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 text-[12px] font-bold text-[#9E2891] hover:text-[#8A237E] hover:bg-violet-50"
|
||||
onClick={() => addResponseSlot(kind)}
|
||||
aria-label={`Add ${label} slot`}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 text-[12px] font-bold text-[#9E2891] hover:text-[#8A237E] hover:bg-violet-50"
|
||||
onClick={() => addResponseSlot(kind)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
Add slot
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -353,14 +315,6 @@ export function QuestionTypeConfigStep({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<SchemaSlotLabelsPanel
|
||||
stimulusRows={draft.stimulus_schema}
|
||||
responseRows={draft.response_schema}
|
||||
onStimulusChange={setStimulusSchema}
|
||||
onResponseChange={setResponseSchema}
|
||||
errors={errors}
|
||||
/>
|
||||
|
||||
<div className="rounded-xl border border-grayScale-200 bg-grayScale-50/50 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -382,7 +336,7 @@ export function QuestionTypeConfigStep({
|
|||
allowedKinds={draft.stimulus_component_kinds}
|
||||
catalogKinds={stimulusCatalogKinds}
|
||||
rows={draft.stimulus_schema}
|
||||
onChange={setStimulusSchema}
|
||||
onChange={(rows) => setDraft((d) => ({ ...d, stimulus_schema: rows }))}
|
||||
error={errors.stimulus_schema}
|
||||
rowErrors={rowErrorMap("stimulus", errors)}
|
||||
/>
|
||||
|
|
@ -392,14 +346,15 @@ export function QuestionTypeConfigStep({
|
|||
allowedKinds={draft.response_component_kinds}
|
||||
catalogKinds={responseCatalogKinds}
|
||||
rows={draft.response_schema}
|
||||
onChange={setResponseSchema}
|
||||
onChange={(rows) => setDraft((d) => ({ ...d, response_schema: rows }))}
|
||||
error={errors.response_schema}
|
||||
rowErrors={rowErrorMap("response", errors)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="px-4 py-4 border-t border-grayScale-200 flex items-center justify-between bg-[#F8FAFC]">
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -11,54 +11,30 @@ import {
|
|||
validateQuestionTypeDefinition,
|
||||
} from "../../../../api/questionTypeDefinitions.api"
|
||||
import type { QuestionTypeDefinitionCreatePayload } from "../../../../types/questionTypeDefinition.types"
|
||||
import {
|
||||
buildCreatePayload,
|
||||
buildValidateKindsPayload,
|
||||
inferRuntimeQuestionType,
|
||||
} from "../../lib/questionTypeDefinitionValidation"
|
||||
import { DefinitionRuntimeHint } from "./DefinitionRuntimeHint"
|
||||
import { slotLabel } from "./componentKindUi"
|
||||
import { buildCreatePayload } from "../../lib/questionTypeDefinitionValidation"
|
||||
|
||||
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
|
||||
|
|
@ -69,6 +45,14 @@ 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) {
|
||||
|
|
@ -97,30 +81,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 your changes and save. The definition key cannot be changed."
|
||||
: "Confirm your definition, then save it for use when authoring practice questions."}
|
||||
{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>.
|
||||
</>
|
||||
)}
|
||||
</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 font-mono text-[13px]">{payload.key}</dd>
|
||||
<dd className="font-medium text-grayScale-900 mt-1">{payload.key}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Display name</dt>
|
||||
|
|
@ -134,10 +118,6 @@ 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>
|
||||
|
|
@ -146,42 +126,33 @@ 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 || runtime == null}
|
||||
disabled={submitting}
|
||||
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 || runtime == null}
|
||||
disabled={submitting}
|
||||
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>
|
||||
|
|
@ -202,35 +173,3 @@ 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-medium">{slotLabel(r)}</span>
|
||||
<span className="text-grayScale-400">·</span>
|
||||
<span className="font-mono text-[11px] text-grayScale-500">{r.id}</span>
|
||||
<span className="text-grayScale-400">·</span>
|
||||
<span className="text-grayScale-600">{r.kind}</span>
|
||||
{r.required ? (
|
||||
<span className="text-[10px] font-bold uppercase text-brand-600">required</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,11 @@
|
|||
import { useCallback, useEffect, useState } from "react"
|
||||
import { 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,
|
||||
buildValidateKindsPayload,
|
||||
} from "../../lib/questionTypeDefinitionValidation"
|
||||
import { DefinitionRuntimeHint } from "./DefinitionRuntimeHint"
|
||||
import { buildCreatePayload } from "../../lib/questionTypeDefinitionValidation"
|
||||
|
||||
interface QuestionTypeValidatePreviewStepProps {
|
||||
draft: QuestionTypeDefinitionCreatePayload
|
||||
|
|
@ -29,12 +25,12 @@ export function QuestionTypeValidatePreviewStep({
|
|||
const payload = buildCreatePayload(draft)
|
||||
const json = JSON.stringify(payload, null, 2)
|
||||
|
||||
const runValidate = useCallback(async () => {
|
||||
const runValidate = async () => {
|
||||
setValidating(true)
|
||||
setServerOk(null)
|
||||
setServerDetail(null)
|
||||
try {
|
||||
const res = await validateQuestionTypeDefinition(buildValidateKindsPayload(draft))
|
||||
const res = await validateQuestionTypeDefinition(payload)
|
||||
if (!res.valid) {
|
||||
setServerOk(false)
|
||||
const detail = res.error || res.message || "Validation failed"
|
||||
|
|
@ -43,49 +39,32 @@ export function QuestionTypeValidatePreviewStep({
|
|||
return
|
||||
}
|
||||
setServerOk(true)
|
||||
setServerDetail(res.message || "Component kinds are valid.")
|
||||
toast.success(res.message || "Definition kinds validated")
|
||||
setServerDetail(res.message || "Question type definition is valid.")
|
||||
toast.success(res.message || "Question type definition is valid")
|
||||
} 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</h2>
|
||||
<h2 className="text-[20px] font-medium text-grayScale-900">STEP 3: Validate & preview</h2>
|
||||
<p className="text-grayScale-500 font-medium mt-1">
|
||||
We check that your stimulus and response selections are valid before you continue. You must pass
|
||||
validation before review.
|
||||
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).
|
||||
</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={() => void runValidate()}
|
||||
onClick={runValidate}
|
||||
disabled={validating}
|
||||
className="h-10"
|
||||
>
|
||||
|
|
@ -95,7 +74,7 @@ export function QuestionTypeValidatePreviewStep({
|
|||
Validating…
|
||||
</>
|
||||
) : (
|
||||
"Re-validate"
|
||||
"Validate with server"
|
||||
)}
|
||||
</Button>
|
||||
{serverOk === true ? (
|
||||
|
|
@ -104,49 +83,12 @@ export function QuestionTypeValidatePreviewStep({
|
|||
{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}
|
||||
{serverOk === false ? <span className="text-sm font-medium text-red-600">{serverDetail}</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
|
||||
? payload.stimulus_schema.map((r) => r.label).join(" · ")
|
||||
: "—"}
|
||||
</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
|
||||
? payload.response_schema.map((r) => r.label).join(" · ")
|
||||
: "—"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="space-y-2">
|
||||
<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">
|
||||
<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">
|
||||
{json}
|
||||
</pre>
|
||||
</div>
|
||||
|
|
@ -164,11 +106,10 @@ export function QuestionTypeValidatePreviewStep({
|
|||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
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"
|
||||
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"
|
||||
>
|
||||
Next: Review & publish
|
||||
Next: Review
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,11 +4,6 @@ import { Input } from "../../../../components/ui/input"
|
|||
import { Textarea } from "../../../../components/ui/textarea"
|
||||
import { Select } from "../../../../components/ui/select"
|
||||
import type { DynamicElementDefinition } from "../../../../types/questionTypeDefinition.types"
|
||||
import {
|
||||
defaultLabelForKind,
|
||||
getResponseKindPresentation,
|
||||
getStimulusKindPresentation,
|
||||
} from "./componentKindUi"
|
||||
|
||||
type Side = "stimulus" | "response"
|
||||
|
||||
|
|
@ -28,7 +23,7 @@ function emptyRow(allowedKinds: string[]): DynamicElementDefinition {
|
|||
return {
|
||||
id: "",
|
||||
kind: first,
|
||||
label: first ? defaultLabelForKind(first) : "",
|
||||
label: "",
|
||||
required: true,
|
||||
config: undefined,
|
||||
}
|
||||
|
|
@ -48,24 +43,10 @@ export function SchemaBuilderSection({
|
|||
allowedKinds.length > 0 ? allowedKinds : catalogKinds.length > 0 ? catalogKinds : []
|
||||
|
||||
const updateRow = (index: number, patch: Partial<DynamicElementDefinition>) => {
|
||||
const next = rows.map((r, i) => {
|
||||
if (i !== index) return r
|
||||
const merged = { ...r, ...patch }
|
||||
if (patch.kind && patch.kind !== r.kind) {
|
||||
const priorDefault = defaultLabelForKind(r.kind)
|
||||
const current = (r.label ?? "").trim()
|
||||
if (!current || current === priorDefault) {
|
||||
merged.label = defaultLabelForKind(patch.kind)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
})
|
||||
const next = rows.map((r, i) => (i === index ? { ...r, ...patch } : r))
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
const kindPresentation = (kind: string) =>
|
||||
side === "stimulus" ? getStimulusKindPresentation(kind) : getResponseKindPresentation(kind)
|
||||
|
||||
const commitConfigString = (index: number, raw: string) => {
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) {
|
||||
|
|
@ -92,8 +73,9 @@ export function SchemaBuilderSection({
|
|||
<div>
|
||||
<h3 className="text-[16px] font-bold text-grayScale-900">{title}</h3>
|
||||
<p className="text-[13px] text-grayScale-500 mt-0.5">
|
||||
Fine-tune slot ids, labels, required flags, and optional config. Labels are the field titles
|
||||
authors see when creating questions.
|
||||
Each row defines one element in the{" "}
|
||||
{side === "stimulus" ? "stimulus" : "response"} schema (id, kind, label, required, optional JSON
|
||||
config).
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
|
|
@ -163,7 +145,7 @@ export function SchemaBuilderSection({
|
|||
>
|
||||
{kindOptions.map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{kindPresentation(k).label} ({k})
|
||||
{k}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
|
@ -172,13 +154,11 @@ export function SchemaBuilderSection({
|
|||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[12px] font-semibold text-grayScale-600">
|
||||
Field label <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<label className="text-[12px] font-semibold text-grayScale-600">Label</label>
|
||||
<Input
|
||||
value={row.label ?? ""}
|
||||
onChange={(e) => updateRow(index, { label: e.target.value })}
|
||||
placeholder={defaultLabelForKind(row.kind)}
|
||||
placeholder="Author-facing label"
|
||||
className="h-10 bg-white"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,137 +0,0 @@
|
|||
import { Trash2 } from "lucide-react"
|
||||
import { Button } from "../../../../components/ui/button"
|
||||
import { Input } from "../../../../components/ui/input"
|
||||
import type { DynamicElementDefinition } from "../../../../types/questionTypeDefinition.types"
|
||||
import {
|
||||
defaultLabelForKind,
|
||||
getResponseKindPresentation,
|
||||
getStimulusKindPresentation,
|
||||
} from "./componentKindUi"
|
||||
|
||||
interface SchemaSlotLabelsPanelProps {
|
||||
stimulusRows: DynamicElementDefinition[]
|
||||
responseRows: DynamicElementDefinition[]
|
||||
onStimulusChange: (rows: DynamicElementDefinition[]) => void
|
||||
onResponseChange: (rows: DynamicElementDefinition[]) => void
|
||||
errors?: Record<string, string>
|
||||
}
|
||||
|
||||
function updateRowLabel(
|
||||
rows: DynamicElementDefinition[],
|
||||
index: number,
|
||||
label: string,
|
||||
): DynamicElementDefinition[] {
|
||||
return rows.map((row, i) => (i === index ? { ...row, label } : row))
|
||||
}
|
||||
|
||||
function removeRow(rows: DynamicElementDefinition[], index: number): DynamicElementDefinition[] {
|
||||
return rows.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
function SlotLabelGroup({
|
||||
title,
|
||||
side,
|
||||
rows,
|
||||
onChange,
|
||||
errors,
|
||||
}: {
|
||||
title: string
|
||||
side: "stimulus" | "response"
|
||||
rows: DynamicElementDefinition[]
|
||||
onChange: (rows: DynamicElementDefinition[]) => void
|
||||
errors?: Record<string, string>
|
||||
}) {
|
||||
if (!rows.length) return null
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-[12px] font-bold uppercase tracking-wide text-grayScale-500">{title}</h4>
|
||||
<div className="space-y-2">
|
||||
{rows.map((row, index) => {
|
||||
const presentation =
|
||||
side === "stimulus"
|
||||
? getStimulusKindPresentation(row.kind)
|
||||
: getResponseKindPresentation(row.kind)
|
||||
const rowError = errors?.[`${side}_${index}`]
|
||||
return (
|
||||
<div
|
||||
key={`${side}-${row.id}-${index}`}
|
||||
className="rounded-xl border border-grayScale-200 bg-white p-3 space-y-2"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[12px] text-grayScale-500">
|
||||
<span className="font-semibold text-grayScale-700">{presentation.label}</span>
|
||||
<span className="text-grayScale-300">·</span>
|
||||
<span className="font-mono text-[11px]">{row.id}</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 shrink-0 p-0 text-grayScale-500 hover:text-red-600"
|
||||
onClick={() => onChange(removeRow(rows, index))}
|
||||
aria-label={`Remove ${presentation.label} slot`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[12px] font-semibold text-grayScale-600">
|
||||
Field label <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={row.label ?? ""}
|
||||
onChange={(e) => onChange(updateRowLabel(rows, index, e.target.value))}
|
||||
placeholder={defaultLabelForKind(row.kind)}
|
||||
className="h-10 bg-white"
|
||||
/>
|
||||
<p className="text-[11px] text-grayScale-400">
|
||||
Shown to authors when they create questions from this type.
|
||||
</p>
|
||||
</div>
|
||||
{rowError ? <p className="text-sm text-red-600">{rowError}</p> : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SchemaSlotLabelsPanel({
|
||||
stimulusRows,
|
||||
responseRows,
|
||||
onStimulusChange,
|
||||
onResponseChange,
|
||||
errors,
|
||||
}: SchemaSlotLabelsPanelProps) {
|
||||
if (!stimulusRows.length && !responseRows.length) return null
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-grayScale-200 bg-[#F8FAFC] p-5 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-[16px] font-bold text-grayScale-900">Field labels</h3>
|
||||
<p className="text-[13px] text-grayScale-500 mt-0.5">
|
||||
Name each schema slot for question authors, or remove slots you no longer need. Labels are stored on
|
||||
the definition and are not sent inside question payloads.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<SlotLabelGroup
|
||||
title="Question content (stimulus)"
|
||||
side="stimulus"
|
||||
rows={stimulusRows}
|
||||
onChange={onStimulusChange}
|
||||
errors={errors}
|
||||
/>
|
||||
<SlotLabelGroup
|
||||
title="Learner answer (response)"
|
||||
side="response"
|
||||
rows={responseRows}
|
||||
onChange={onResponseChange}
|
||||
errors={errors}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -20,35 +20,34 @@ import {
|
|||
Volume2,
|
||||
} from "lucide-react"
|
||||
|
||||
import { defaultLabelForKind, humanizeKind, slotLabel } from "../../../../lib/schemaSlotLabel"
|
||||
|
||||
export { defaultLabelForKind, humanizeKind, slotLabel }
|
||||
|
||||
/** Human label for API kind codes; unknown kinds fall back to title-cased code. */
|
||||
const STIMULUS_LABELS: Record<string, string> = {
|
||||
QUESTION_TEXT: defaultLabelForKind("QUESTION_TEXT"),
|
||||
PREP_TIME: defaultLabelForKind("PREP_TIME"),
|
||||
INSTRUCTION: defaultLabelForKind("INSTRUCTION"),
|
||||
AUDIO_PROMPT: defaultLabelForKind("AUDIO_PROMPT"),
|
||||
TEXT_PASSAGE: defaultLabelForKind("TEXT_PASSAGE"),
|
||||
IMAGE: defaultLabelForKind("IMAGE"),
|
||||
MATCHING_INPUTS: defaultLabelForKind("MATCHING_INPUTS"),
|
||||
SELECT_MISSING_WORDS: defaultLabelForKind("SELECT_MISSING_WORDS"),
|
||||
TABLE: defaultLabelForKind("TABLE"),
|
||||
PDF_ATTACHMENT: defaultLabelForKind("PDF_ATTACHMENT"),
|
||||
QUESTION_TEXT: "Question Text",
|
||||
PREP_TIME: "Prep Time",
|
||||
INSTRUCTION: "Instruction",
|
||||
AUDIO_PROMPT: "Audio Prompt",
|
||||
AUDIO_CLIP: "Audio Clip",
|
||||
TEXT_PASSAGE: "Text Passage",
|
||||
IMAGE: "Image",
|
||||
CHART: "Chart",
|
||||
MATCHING_INPUTS: "Matching Inputs",
|
||||
SELECT_MISSING_WORDS: "Select Missing Words",
|
||||
TABLE: "Table",
|
||||
FLOW_CHART: "Flow Chart",
|
||||
}
|
||||
|
||||
const RESPONSE_LABELS: Record<string, string> = {
|
||||
AUDIO_RESPONSE: defaultLabelForKind("AUDIO_RESPONSE"),
|
||||
TEXT_INPUT: defaultLabelForKind("TEXT_INPUT"),
|
||||
SHORT_ANSWER: defaultLabelForKind("SHORT_ANSWER"),
|
||||
MULTIPLE_CHOICE: defaultLabelForKind("MULTIPLE_CHOICE"),
|
||||
OPTION: defaultLabelForKind("OPTION"),
|
||||
ANSWER_TIMER: defaultLabelForKind("ANSWER_TIMER"),
|
||||
SELECT_MISSING_WORDS: defaultLabelForKind("SELECT_MISSING_WORDS"),
|
||||
PDF_UPLOAD: defaultLabelForKind("PDF_UPLOAD"),
|
||||
MATCHING_ANSWER: defaultLabelForKind("MATCHING_ANSWER"),
|
||||
LABEL_SELECTION: defaultLabelForKind("LABEL_SELECTION"),
|
||||
SEQUENCE_ORDER: defaultLabelForKind("SEQUENCE_ORDER"),
|
||||
AUDIO_RESPONSE: "Audio Response",
|
||||
TEXT_INPUT: "Text Input",
|
||||
SHORT_ANSWER: "Short Answer",
|
||||
MULTIPLE_CHOICE: "Multiple Choice",
|
||||
OPTION: "Options",
|
||||
ANSWER_TIMER: "Answer Timer",
|
||||
SELECT_MISSING_WORDS: "Select Missing Words",
|
||||
PDF_UPLOAD: "PDF Upload",
|
||||
MATCHING_ANSWER: "Matching Answer",
|
||||
LABEL_SELECTION: "Label Selection",
|
||||
SEQUENCE_ORDER: "Sequence Order",
|
||||
}
|
||||
|
||||
/** Legacy screenshot labels → map to closest API kind for display only (same code path). */
|
||||
|
|
@ -60,10 +59,11 @@ const STIMULUS_ICONS: Record<string, LucideIcon> = {
|
|||
AUDIO_CLIP: Volume2,
|
||||
TEXT_PASSAGE: FileText,
|
||||
IMAGE: ImageIcon,
|
||||
CHART: BarChart3,
|
||||
MATCHING_INPUTS: Link2,
|
||||
SELECT_MISSING_WORDS: ListTodo,
|
||||
TABLE: TableIcon,
|
||||
PDF_ATTACHMENT: FileUp,
|
||||
FLOW_CHART: GitBranch,
|
||||
}
|
||||
|
||||
const RESPONSE_ICONS: Record<string, LucideIcon> = {
|
||||
|
|
@ -82,6 +82,13 @@ const RESPONSE_ICONS: Record<string, LucideIcon> = {
|
|||
|
||||
const DEFAULT_ICON = FileText
|
||||
|
||||
function humanizeKind(kind: string): string {
|
||||
return kind
|
||||
.replace(/_/g, " ")
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
export function getStimulusKindPresentation(kind: string): { label: string; Icon: LucideIcon } {
|
||||
return {
|
||||
label: STIMULUS_LABELS[kind] ?? humanizeKind(kind),
|
||||
|
|
|
|||
|
|
@ -104,8 +104,12 @@ 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.). Uploaded files are stored
|
||||
automatically.
|
||||
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>
|
||||
.
|
||||
</p>
|
||||
<LessonMediaUploadField
|
||||
kind="video"
|
||||
|
|
@ -256,8 +260,12 @@ 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 when
|
||||
you publish.
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import type {
|
|||
QuestionComponentCatalog,
|
||||
QuestionTypeDefinitionCreatePayload,
|
||||
} from "../../../types/questionTypeDefinition.types"
|
||||
import { defaultLabelForKind } from "../../../lib/schemaSlotLabel"
|
||||
|
||||
export type FieldErrorMap = Record<string, string>
|
||||
|
||||
|
|
@ -42,18 +41,6 @@ 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)
|
||||
|
|
@ -100,18 +87,13 @@ export function validateDefinitionSchemas(
|
|||
const allowedSet = new Set(allowed)
|
||||
const catalogSet = side === "stimulus" ? catalog.stimulus : catalog.response
|
||||
rows.forEach((row, i) => {
|
||||
const rowMessages: string[] = []
|
||||
if (!row.kind) rowMessages.push("Kind is required.")
|
||||
if (!row.kind) errors[`${prefix}_${i}`] = "Kind is required."
|
||||
else if (allowedSet.size && !allowedSet.has(row.kind)) {
|
||||
rowMessages.push(`Kind "${row.kind}" is not in selected ${side} kinds.`)
|
||||
errors[`${prefix}_${i}`] = `Kind "${row.kind}" is not in selected ${side} kinds.`
|
||||
}
|
||||
if (catalogSet.size && row.kind && !catalogSet.has(row.kind)) {
|
||||
rowMessages.push(`Kind "${row.kind}" is not in the ${side} component catalog.`)
|
||||
errors[`${prefix}_${i}`] = `Kind "${row.kind}" is not in the ${side} component catalog.`
|
||||
}
|
||||
if (!row.label?.trim()) {
|
||||
rowMessages.push("Label is required — this is the field title authors see when creating questions.")
|
||||
}
|
||||
if (rowMessages.length) errors[`${prefix}_${i}`] = rowMessages.join(" ")
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -144,42 +126,6 @@ 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 {
|
||||
|
|
@ -187,14 +133,14 @@ export function buildCreatePayload(
|
|||
...r,
|
||||
id: r.id.trim(),
|
||||
kind: r.kind.trim(),
|
||||
label: r.label?.trim() || defaultLabelForKind(r.kind),
|
||||
label: r.label?.trim() || undefined,
|
||||
config: r.config && Object.keys(r.config).length ? r.config : undefined,
|
||||
}))
|
||||
const response_schema = draft.response_schema.map((r) => ({
|
||||
...r,
|
||||
id: r.id.trim(),
|
||||
kind: r.kind.trim(),
|
||||
label: r.label?.trim() || defaultLabelForKind(r.kind),
|
||||
label: r.label?.trim() || undefined,
|
||||
config: r.config && Object.keys(r.config).length ? r.config : undefined,
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ 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,
|
||||
|
|
@ -655,7 +654,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"
|
||||
>
|
||||
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
|
||||
{[5, 10, 20, 30, 50].map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
|
|
|
|||
|
|
@ -119,7 +119,11 @@ export function CreateEmailTemplatePage() {
|
|||
New custom template
|
||||
</h1>
|
||||
<p className="max-w-2xl text-sm text-grayScale-500">
|
||||
Create a custom email template. System templates are managed separately.
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -78,8 +78,11 @@ export function EmailTemplatesPage() {
|
|||
Email Templates
|
||||
</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-grayScale-500">
|
||||
View and edit email templates used for learner and team notifications. Open a template to
|
||||
preview and edit its content.
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
|
|
|||
|
|
@ -3,9 +3,17 @@ import {
|
|||
Bell,
|
||||
BellOff,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Megaphone,
|
||||
UserPlus,
|
||||
CreditCard,
|
||||
BookOpen,
|
||||
Video,
|
||||
ShieldAlert,
|
||||
MailOpen,
|
||||
Mail,
|
||||
CheckCheck,
|
||||
|
|
@ -45,7 +53,6 @@ import { cn } from "../../lib/utils"
|
|||
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import {
|
||||
getNotificationById,
|
||||
getNotifications,
|
||||
getUnreadCount,
|
||||
markAsRead,
|
||||
|
|
@ -56,14 +63,6 @@ import {
|
|||
sendBulkEmail,
|
||||
sendBulkPush,
|
||||
} from "../../api/notifications.api"
|
||||
import { NotificationDetailDialog } from "../../components/notifications/NotificationDetailDialog"
|
||||
import {
|
||||
DEFAULT_NOTIFICATION_TYPE_CONFIG,
|
||||
formatNotificationTimestamp,
|
||||
formatNotificationTypeLabel,
|
||||
getNotificationLevelBadge,
|
||||
NOTIFICATION_TYPE_CONFIG,
|
||||
} from "../../lib/notificationDisplay"
|
||||
import { getRoles } from "../../api/rbac.api"
|
||||
import { getTeamMembers } from "../../api/team.api"
|
||||
import { getUsers } from "../../api/users.api"
|
||||
|
|
@ -72,7 +71,70 @@ 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"
|
||||
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
const TYPE_CONFIG: Record<string, { icon: React.ElementType; color: string; bg: string }> = {
|
||||
announcement: { icon: Megaphone, color: "text-brand-600", bg: "bg-brand-100" },
|
||||
system_alert: { icon: ShieldAlert, color: "text-amber-600", bg: "bg-amber-50" },
|
||||
issue_created: { icon: AlertCircle, color: "text-red-500", bg: "bg-red-50" },
|
||||
issue_status_updated: { icon: CheckCircle2, color: "text-sky-600", bg: "bg-sky-50" },
|
||||
course_created: { icon: BookOpen, color: "text-indigo-600", bg: "bg-indigo-50" },
|
||||
course_enrolled: { icon: BookOpen, color: "text-teal-600", bg: "bg-teal-50" },
|
||||
sub_course_created: { icon: BookOpen, color: "text-violet-600", bg: "bg-violet-50" },
|
||||
video_added: { icon: Video, color: "text-pink-600", bg: "bg-pink-50" },
|
||||
user_deleted: { icon: UserPlus, color: "text-red-600", bg: "bg-red-50" },
|
||||
admin_created: { icon: UserPlus, color: "text-brand-600", bg: "bg-brand-100" },
|
||||
team_member_created: { icon: UserPlus, color: "text-emerald-600", bg: "bg-emerald-50" },
|
||||
subscription_activated: { icon: CreditCard, color: "text-green-600", bg: "bg-green-50" },
|
||||
payment_verified: { icon: CreditCard, color: "text-green-600", bg: "bg-green-50" },
|
||||
knowledge_level_update: { icon: Info, color: "text-sky-600", bg: "bg-sky-50" },
|
||||
assessment_assigned: { icon: BookOpen, color: "text-orange-600", bg: "bg-orange-50" },
|
||||
}
|
||||
|
||||
const DEFAULT_TYPE_CONFIG = { icon: Bell, color: "text-grayScale-500", bg: "bg-grayScale-100" }
|
||||
|
||||
function getLevelBadge(level: string) {
|
||||
switch (level) {
|
||||
case "error":
|
||||
case "critical":
|
||||
return "destructive" as const
|
||||
case "warning":
|
||||
return "warning" as const
|
||||
case "success":
|
||||
return "success" as const
|
||||
case "info":
|
||||
default:
|
||||
return "info" as const
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: string) {
|
||||
const date = new Date(ts)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMin = Math.floor(diffMs / 60_000)
|
||||
const diffHr = Math.floor(diffMs / 3_600_000)
|
||||
const diffDay = Math.floor(diffMs / 86_400_000)
|
||||
|
||||
if (diffMin < 1) return "Just now"
|
||||
if (diffMin < 60) return `${diffMin}m ago`
|
||||
if (diffHr < 24) return `${diffHr}h ago`
|
||||
if (diffDay < 7) return `${diffDay}d ago`
|
||||
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function formatTypeLabel(type: string) {
|
||||
return type
|
||||
.split("_")
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
function digitsOnly(value: string, maxLength: number) {
|
||||
return value.replace(/\D/g, "").slice(0, maxLength)
|
||||
|
|
@ -87,7 +149,7 @@ function NotificationItem({
|
|||
onToggleRead: (id: string, currentlyRead: boolean) => void
|
||||
toggling: boolean
|
||||
}) {
|
||||
const config = NOTIFICATION_TYPE_CONFIG[notification.type] ?? DEFAULT_NOTIFICATION_TYPE_CONFIG
|
||||
const config = TYPE_CONFIG[notification.type] ?? DEFAULT_TYPE_CONFIG
|
||||
const Icon = config.icon
|
||||
|
||||
return (
|
||||
|
|
@ -128,7 +190,7 @@ function NotificationItem({
|
|||
>
|
||||
{getNotificationTitle(notification)}
|
||||
</span>
|
||||
<Badge variant={getNotificationLevelBadge(notification.level)} className="text-[10px] px-1.5 py-0">
|
||||
<Badge variant={getLevelBadge(notification.level)} className="text-[10px] px-1.5 py-0">
|
||||
{notification.level}
|
||||
</Badge>
|
||||
</div>
|
||||
|
|
@ -144,7 +206,7 @@ function NotificationItem({
|
|||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="text-xs text-grayScale-400">
|
||||
{formatNotificationTimestamp(notification.timestamp)}
|
||||
{formatTimestamp(notification.timestamp)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -174,7 +236,7 @@ function NotificationItem({
|
|||
{/* Meta row */}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary" className="text-[10px] px-2 py-0">
|
||||
{formatNotificationTypeLabel(notification.type)}
|
||||
{formatTypeLabel(notification.type)}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-[10px] px-2 py-0">
|
||||
{notification.delivery_channel}
|
||||
|
|
@ -203,16 +265,12 @@ 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())
|
||||
const [bulkLoading, setBulkLoading] = useState(false)
|
||||
const [selectedNotification, setSelectedNotification] = useState<Notification | null>(null)
|
||||
const [selectedNotificationId, setSelectedNotificationId] = useState<string | null>(null)
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
const [detailError, setDetailError] = useState(false)
|
||||
|
||||
const [channelFilter, setChannelFilter] = useState<"all" | "push" | "sms">("all")
|
||||
const [activeStatusTab, setActiveStatusTab] = useState<"all" | "read" | "unread">("all")
|
||||
|
|
@ -371,7 +429,7 @@ export function NotificationsPage() {
|
|||
setError(false)
|
||||
try {
|
||||
const [notifRes, unreadRes] = await Promise.all([
|
||||
getNotifications(pageSize, currentOffset),
|
||||
getNotifications(PAGE_SIZE, currentOffset),
|
||||
getUnreadCount(),
|
||||
])
|
||||
setNotifications(notifRes.data.notifications ?? [])
|
||||
|
|
@ -382,11 +440,11 @@ export function NotificationsPage() {
|
|||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [pageSize])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData(offset)
|
||||
}, [offset, pageSize, fetchData])
|
||||
}, [offset, fetchData])
|
||||
|
||||
const handleToggleRead = useCallback(async (id: string, currentlyRead: boolean) => {
|
||||
setTogglingIds((prev) => new Set(prev).add(id))
|
||||
|
|
@ -437,10 +495,10 @@ export function NotificationsPage() {
|
|||
}
|
||||
}, [totalCount])
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize))
|
||||
const currentPage = Math.floor(offset / pageSize) + 1
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
|
||||
const currentPage = Math.floor(offset / PAGE_SIZE) + 1
|
||||
const startEntry = totalCount === 0 ? 0 : offset + 1
|
||||
const endEntry = Math.min(offset + pageSize, totalCount)
|
||||
const endEntry = Math.min(offset + PAGE_SIZE, totalCount)
|
||||
|
||||
const getPageNumbers = () => {
|
||||
const pages: (number | string)[] = []
|
||||
|
|
@ -467,7 +525,7 @@ export function NotificationsPage() {
|
|||
const haystack = [
|
||||
getNotificationTitle(n),
|
||||
getNotificationMessage(n),
|
||||
formatNotificationTypeLabel(n.type),
|
||||
formatTypeLabel(n.type),
|
||||
n.delivery_channel,
|
||||
n.level,
|
||||
]
|
||||
|
|
@ -479,42 +537,9 @@ export function NotificationsPage() {
|
|||
return true
|
||||
})
|
||||
|
||||
const loadNotificationDetail = useCallback(async (id: string) => {
|
||||
setDetailLoading(true)
|
||||
setDetailError(false)
|
||||
setSelectedNotification(null)
|
||||
setSelectedNotificationId(id)
|
||||
setDetailOpen(true)
|
||||
|
||||
try {
|
||||
const res = await getNotificationById(id)
|
||||
if (!res.data) {
|
||||
setDetailError(true)
|
||||
toast.error("Notification not found")
|
||||
return
|
||||
}
|
||||
setSelectedNotification(res.data)
|
||||
if (!res.data.is_read) {
|
||||
setNotifications((prev) =>
|
||||
prev.map((n) => (n.id === id ? { ...n, is_read: true } : n)),
|
||||
)
|
||||
setGlobalUnread((prev) => Math.max(0, prev - 1))
|
||||
try {
|
||||
await markAsRead(id)
|
||||
} catch {
|
||||
// list refresh on next load will reconcile
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setDetailError(true)
|
||||
toast.error("Failed to load notification details")
|
||||
} finally {
|
||||
setDetailLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleOpenDetail = (notification: Notification) => {
|
||||
void loadNotificationDetail(notification.id)
|
||||
setSelectedNotification(notification)
|
||||
setDetailOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -731,7 +756,7 @@ export function NotificationsPage() {
|
|||
className="h-8 w-[150px] justify-between rounded-lg border-grayScale-200 px-2.5 text-xs font-normal text-grayScale-600"
|
||||
>
|
||||
<span className="truncate">
|
||||
{typeFilter === "all" ? "All types" : formatNotificationTypeLabel(typeFilter)}
|
||||
{typeFilter === "all" ? "All types" : formatTypeLabel(typeFilter)}
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-3.5 w-3.5 text-grayScale-400" />
|
||||
</Button>
|
||||
|
|
@ -741,7 +766,7 @@ export function NotificationsPage() {
|
|||
<DropdownMenuRadioItem value="all">All types</DropdownMenuRadioItem>
|
||||
{Array.from(new Set(notifications.map((n) => n.type))).map((t) => (
|
||||
<DropdownMenuRadioItem key={t} value={t}>
|
||||
{formatNotificationTypeLabel(t)}
|
||||
{formatTypeLabel(t)}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
|
|
@ -805,7 +830,7 @@ export function NotificationsPage() {
|
|||
</TableRow>
|
||||
) : (
|
||||
filteredNotifications.map((n) => {
|
||||
const config = NOTIFICATION_TYPE_CONFIG[n.type] ?? DEFAULT_NOTIFICATION_TYPE_CONFIG
|
||||
const config = TYPE_CONFIG[n.type] ?? DEFAULT_TYPE_CONFIG
|
||||
const Icon = config.icon
|
||||
const isToggling = togglingIds.has(n.id)
|
||||
return (
|
||||
|
|
@ -829,7 +854,7 @@ export function NotificationsPage() {
|
|||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-xs font-medium text-grayScale-600">
|
||||
{formatNotificationTypeLabel(n.type)}
|
||||
{formatTypeLabel(n.type)}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
|
@ -855,7 +880,7 @@ export function NotificationsPage() {
|
|||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={getNotificationLevelBadge(n.level)}
|
||||
variant={getLevelBadge(n.level)}
|
||||
className="text-[10px] uppercase tracking-wide"
|
||||
>
|
||||
{n.is_read ? "Read" : "Unread"}
|
||||
|
|
@ -863,7 +888,7 @@ export function NotificationsPage() {
|
|||
</TableCell>
|
||||
<TableCell className="hidden sm:table-cell">
|
||||
<span className="text-xs text-grayScale-400">
|
||||
{formatNotificationTimestamp(n.timestamp)}
|
||||
{formatTimestamp(n.timestamp)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
|
|
@ -916,25 +941,18 @@ export function NotificationsPage() {
|
|||
<span className="border-l pl-4">Rows per page</span>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => {
|
||||
setPageSize(Number(e.target.value))
|
||||
setOffset(0)
|
||||
}}
|
||||
value={PAGE_SIZE}
|
||||
disabled
|
||||
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>
|
||||
))}
|
||||
<option value={PAGE_SIZE}>{PAGE_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 - pageSize))}
|
||||
onClick={() => currentPage > 1 && setOffset(Math.max(0, offset - PAGE_SIZE))}
|
||||
disabled={currentPage <= 1}
|
||||
className={cn(
|
||||
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
|
||||
|
|
@ -952,7 +970,7 @@ export function NotificationsPage() {
|
|||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onClick={() => setOffset((n - 1) * pageSize)}
|
||||
onClick={() => setOffset((n - 1) * PAGE_SIZE)}
|
||||
className={cn(
|
||||
"h-8 w-8 rounded-md border text-sm font-medium",
|
||||
n === currentPage
|
||||
|
|
@ -965,7 +983,7 @@ export function NotificationsPage() {
|
|||
),
|
||||
)}
|
||||
<button
|
||||
onClick={() => currentPage < totalPages && setOffset(offset + pageSize)}
|
||||
onClick={() => currentPage < totalPages && setOffset(offset + PAGE_SIZE)}
|
||||
disabled={currentPage >= totalPages}
|
||||
className={cn(
|
||||
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
|
||||
|
|
@ -980,18 +998,66 @@ export function NotificationsPage() {
|
|||
</>
|
||||
)}
|
||||
|
||||
<NotificationDetailDialog
|
||||
open={detailOpen}
|
||||
onOpenChange={setDetailOpen}
|
||||
notification={selectedNotification}
|
||||
loading={detailLoading}
|
||||
error={detailError}
|
||||
onRetry={
|
||||
selectedNotificationId
|
||||
? () => void loadNotificationDetail(selectedNotificationId)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{/* Detail dialog */}
|
||||
{selectedNotification && (
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<span className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-brand-50 text-brand-600">
|
||||
{(() => {
|
||||
const Icon =
|
||||
(TYPE_CONFIG[selectedNotification.type] ?? DEFAULT_TYPE_CONFIG).icon
|
||||
return <Icon className="h-4 w-4" />
|
||||
})()}
|
||||
</span>
|
||||
<span className="truncate text-base">
|
||||
{getNotificationTitle(selectedNotification)}
|
||||
</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Sent via {selectedNotification.delivery_channel} ·{" "}
|
||||
{formatTimestamp(selectedNotification.timestamp)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-grayScale-50 p-3">
|
||||
<p className="text-sm text-grayScale-600">
|
||||
{getNotificationMessage(selectedNotification)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 text-xs text-grayScale-500 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-grayScale-400">Type</p>
|
||||
<p className="mt-0.5 font-medium text-grayScale-700">
|
||||
{formatTypeLabel(selectedNotification.type)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-grayScale-400">Level</p>
|
||||
<p className="mt-0.5 font-medium text-grayScale-700">
|
||||
{selectedNotification.level}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-grayScale-400">Channel</p>
|
||||
<p className="mt-0.5 font-medium text-grayScale-700 capitalize">
|
||||
{selectedNotification.delivery_channel}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-grayScale-400">Delivery status</p>
|
||||
<p className="mt-0.5 font-medium text-grayScale-700">
|
||||
{selectedNotification.delivery_status}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{/* Bulk send dialog */}
|
||||
<Dialog open={bulkOpen} onOpenChange={setBulkOpen}>
|
||||
|
|
|
|||
|
|
@ -185,7 +185,10 @@ export function EmailTemplateCreateForm({
|
|||
<Button variant="outline" disabled={saving} onClick={onReset}>
|
||||
Reset
|
||||
</Button>
|
||||
<p className="text-xs text-grayScale-400">Your changes are saved when you submit the form.</p>
|
||||
<p className="text-xs text-grayScale-400">
|
||||
Saved with{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1">POST /admin/email-templates</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -102,7 +102,11 @@ 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>.
|
||||
Your changes are saved when you submit the form.
|
||||
Saved with{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1">
|
||||
PUT /admin/email-templates/{template.id}
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ import type {
|
|||
PaymentStatus,
|
||||
} from "../../types/payment.types"
|
||||
|
||||
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
|
||||
const PAGE_SIZE_OPTIONS = [20, 50, 100] as const
|
||||
|
||||
const STATUS_FILTERS: { value: PaymentStatus; label: string }[] = [
|
||||
{ value: "SUCCESS", label: "Success" },
|
||||
|
|
@ -168,7 +168,8 @@ 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 and filter checkout transactions from Chapa, Arifpay, and other providers.
|
||||
Browse checkout transactions from{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 text-xs">GET /admin/payments</code>.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
|
|
@ -438,7 +439,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"
|
||||
>
|
||||
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import {
|
|||
Search,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
AlertCircle,
|
||||
|
|
@ -38,7 +37,6 @@ 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"
|
||||
|
|
@ -51,7 +49,7 @@ export function RolesListPage() {
|
|||
const [roles, setRoles] = useState<Role[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
|
||||
const [pageSize] = useState(20)
|
||||
const [query, setQuery] = useState("")
|
||||
const [debouncedQuery, setDebouncedQuery] = useState("")
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
|
@ -545,59 +543,34 @@ export function RolesListPage() {
|
|||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{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
|
||||
</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>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between border-t border-grayScale-100 pt-4">
|
||||
<p className="text-xs text-grayScale-400">
|
||||
Showing {(page - 1) * pageSize + 1}–{Math.min(page * pageSize, total)} of {total} roles
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="px-3 text-xs font-medium text-grayScale-600">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{totalPages > 1 ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="px-3 text-xs font-medium text-grayScale-600">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -153,8 +153,12 @@ export function InviteTeamMemberDialog({
|
|||
Invite team members
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-left text-grayScale-600">
|
||||
Send one invitation per email address. Invitees complete account setup using the link in
|
||||
their email
|
||||
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>
|
||||
{roleLocked ? (
|
||||
<>
|
||||
{" "}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import {
|
|||
AlertTriangle,
|
||||
Apple,
|
||||
Calendar,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
|
|
@ -23,7 +22,6 @@ 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,
|
||||
|
|
@ -36,6 +34,8 @@ 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,7 +64,6 @@ 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")
|
||||
|
|
@ -76,7 +75,7 @@ export function AppVersionsTab() {
|
|||
setLoading(true)
|
||||
setError(false)
|
||||
try {
|
||||
const res = await getAppVersions({ limit: pageSize, offset })
|
||||
const res = await getAppVersions({ limit: PAGE_SIZE, offset })
|
||||
setVersions(res.data.versions)
|
||||
setTotalCount(res.data.total_count)
|
||||
} catch (e) {
|
||||
|
|
@ -88,7 +87,7 @@ export function AppVersionsTab() {
|
|||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [offset, pageSize])
|
||||
}, [offset])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
|
|
@ -124,7 +123,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 + pageSize < totalCount
|
||||
const canNext = offset + PAGE_SIZE < totalCount
|
||||
|
||||
const handleCreated = (version: AppVersion) => {
|
||||
if (offset === 0) {
|
||||
|
|
@ -156,7 +155,12 @@ 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.
|
||||
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>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
|
@ -187,7 +191,7 @@ export function AppVersionsTab() {
|
|||
<TabletSmartphone className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-grayScale-500">Total versions</p>
|
||||
<p className="text-xs font-medium text-grayScale-500">Total (this page)</p>
|
||||
<p className="text-2xl font-bold text-grayScale-900">{totalCount}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -436,34 +440,10 @@ 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 text-sm text-grayScale-500">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span>
|
||||
Showing {pageStart}–{pageEnd} of {totalCount}
|
||||
</span>
|
||||
<span className="hidden h-4 w-px bg-grayScale-200 sm:inline" />
|
||||
<span className="flex items-center gap-2">
|
||||
Rows per page
|
||||
<div className="relative">
|
||||
<select
|
||||
value={pageSize}
|
||||
disabled={loading}
|
||||
onChange={(e) => {
|
||||
setPageSize(Number(e.target.value))
|
||||
setOffset(0)
|
||||
}}
|
||||
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
|
||||
>
|
||||
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" />
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-grayScale-100 pt-4">
|
||||
<p className="text-xs text-grayScale-500">
|
||||
Showing {pageStart}–{pageEnd} of {totalCount}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -471,7 +451,7 @@ export function AppVersionsTab() {
|
|||
size="sm"
|
||||
className="rounded-[6px]"
|
||||
disabled={!canPrev || loading}
|
||||
onClick={() => setOffset((o) => Math.max(0, o - pageSize))}
|
||||
onClick={() => setOffset((o) => Math.max(0, o - PAGE_SIZE))}
|
||||
>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
Previous
|
||||
|
|
@ -482,7 +462,7 @@ export function AppVersionsTab() {
|
|||
size="sm"
|
||||
className="rounded-[6px]"
|
||||
disabled={!canNext || loading}
|
||||
onClick={() => setOffset((o) => o + pageSize)}
|
||||
onClick={() => setOffset((o) => o + PAGE_SIZE)}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -100,8 +100,9 @@ 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. Create, edit, or remove packages for the learner
|
||||
checkout flow.
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
|
|
|||
|
|
@ -148,8 +148,9 @@ export function CreateAppVersionDialog({
|
|||
New app version
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-grayScale-500">
|
||||
Publish a new app release. Learners on older builds will see update prompts based on
|
||||
these rules.
|
||||
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.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
|
|||
|
|
@ -128,7 +128,8 @@ export function CreateSubscriptionPlanDialog({
|
|||
New subscription package
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-grayScale-500">
|
||||
Add a new subscription package for learners.
|
||||
Creates a plan via{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 text-xs">POST /subscription-plans</code>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
|
|||
|
|
@ -140,7 +140,10 @@ export function EditAppVersionDialog({
|
|||
Edit app version
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-grayScale-500">
|
||||
Update release rules and messaging for this version.
|
||||
Updates via{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 text-xs">
|
||||
PUT /admin/app-versions/{version?.id ?? ":id"}
|
||||
</code>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
|
|||
|
|
@ -139,7 +139,10 @@ export function EditSubscriptionPlanDialog({
|
|||
Edit subscription package
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-grayScale-500">
|
||||
Update pricing, duration, and visibility for this plan.
|
||||
Updates via{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 text-xs">
|
||||
PUT /subscription-plans/{plan?.id ?? ":id"}
|
||||
</code>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ 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";
|
||||
|
|
@ -414,7 +413,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"
|
||||
>
|
||||
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
|
||||
{[5, 10, 20, 30, 50].map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ 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";
|
||||
|
|
@ -501,7 +500,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"
|
||||
>
|
||||
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
|
||||
{[5, 10, 20, 30, 50].map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ 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,
|
||||
|
|
@ -581,7 +580,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"
|
||||
>
|
||||
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
|
||||
{[10, 20, 50, 100].map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ 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"
|
||||
|
|
@ -690,7 +689,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"
|
||||
>
|
||||
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
|
||||
{[5, 10, 20, 30, 50].map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
|
|
|
|||
|
|
@ -1066,8 +1066,7 @@ export interface QuestionOption {
|
|||
}
|
||||
|
||||
export interface CreateQuestionRequest {
|
||||
/** Omit for `DYNAMIC` — prompt belongs in `dynamic_payload.stimulus` */
|
||||
question_text?: string
|
||||
question_text: string
|
||||
question_type: string
|
||||
difficulty_level?: string
|
||||
points?: number
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ export interface NotificationPayload {
|
|||
export interface Notification {
|
||||
id: string
|
||||
recipient_id: number
|
||||
receiver_type?: string
|
||||
type: string
|
||||
level: string
|
||||
error_severity: string
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user