feat(admin): notification details, question type library, and schema UX

Wire GET /notifications/:id for topbar and notifications page detail views, harden notification WebSocket lifecycle, paginate question type and app version lists from API, and expand dynamic question type schema labels and slot editing.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-06-05 05:44:09 -07:00
parent 92a2fab833
commit 1014f4a72f
28 changed files with 1508 additions and 591 deletions

View File

@ -1,4 +1,5 @@
import http from "./http"
import { DEFAULT_TABLE_PAGE_SIZE } from "../lib/tablePagination"
import type {
AppVersion,
AppVersionMutationResponse,
@ -77,7 +78,7 @@ export type GetAppVersionsParams = {
}
export const getAppVersions = (params: GetAppVersionsParams = {}) => {
const limit = params.limit ?? 20
const limit = params.limit ?? DEFAULT_TABLE_PAGE_SIZE
const offset = params.offset ?? 0
return http
.get<AppVersionsListResponse>("/admin/app-versions", { params: { limit, offset } })

View File

@ -1,35 +1,125 @@
import http from "./http";
import type { GetNotificationsResponse, UnreadCountResponse } from "../types/notification.types";
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) }
}
export const getNotifications = (limit = 10, offset = 0) =>
http.get<GetNotificationsResponse>("/notifications", {
params: { limit, offset },
});
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)),
}))
export const getUnreadCount = () =>
http.get<UnreadCountResponse>("/notifications/unread");
http.get<unknown>("/notifications/unread").then((res) => ({
...res,
data: parseUnreadCount(res.data),
}))
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" },
});
})

View File

@ -261,6 +261,40 @@ 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)) {
@ -270,30 +304,32 @@ export function parseDefinitionsList(payload: unknown): QuestionTypeDefinition[]
}
if (typeof payload === "object" && payload !== null) {
const o = payload as Record<string, unknown>
const inner = o.definitions ?? o.items ?? o.rows ?? o.Definitions
const inner =
o.question_type_definitions ??
o.QuestionTypeDefinitions ??
o.definitions ??
o.items ??
o.rows ??
o.Definitions
if (Array.isArray(inner)) return parseDefinitionsList(inner)
if (inner && typeof inner === "object") return parseDefinitionsList(inner)
if (inner && typeof inner === "object" && !Array.isArray(inner)) return parseDefinitionsList(inner)
const data = o.data ?? o.Data
if (Array.isArray(data)) return parseDefinitionsList(data)
if (data && typeof data === "object") {
const single = normalizeTypeDefinitionFromApi(data)
return single ? [single] : []
}
if (data && typeof data === "object") return parseDefinitionsList(data)
const single = normalizeTypeDefinitionFromApi(payload)
return single ? [single] : []
}
return []
}
export async function getQuestionTypeDefinitions(params?: {
include_system?: boolean
status?: string
limit?: number
offset?: number
}) {
export async function getQuestionTypeDefinitions(
params?: QuestionTypeDefinitionsListParams,
): Promise<QuestionTypeDefinitionsListResult> {
const res = await http.get<ApiEnvelope<unknown>>("/questions/type-definitions", { params })
const raw = unwrapApiPayload(res) ?? res.data
return parseDefinitionsList(raw)
const definitions = parseDefinitionsList(raw)
const total_count = parseListTotalCount(raw) ?? parseListTotalCount(res.data)
return total_count != null ? { definitions, total_count } : { definitions }
}
/**

View File

@ -17,6 +17,7 @@ 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
@ -30,15 +31,43 @@ export interface DynamicSchemaSlotRow {
required?: boolean
}
function slotMediaMode(kind: string): "image" | "audio" | "pdf" | "table" | "text" {
function slotMediaMode(
kind: string,
): "image" | "audio" | "pdf" | "table" | "seconds" | "text" {
const u = kind.trim().toUpperCase()
if (u === "IMAGE") return "image"
if (u === "TABLE") return "table"
if (u === "PDF_ATTACHMENT" || u === "PDF_UPLOAD") return "pdf"
if (u.startsWith("AUDIO")) return "audio"
if (u === "PREP_TIME" || u === "ANSWER_TIMER") return "seconds"
if (u === "AUDIO_PROMPT" || u === "AUDIO_CLIP" || u === "AUDIO_RESPONSE") 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())
}
@ -141,7 +170,9 @@ 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}
</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
@ -411,7 +442,9 @@ 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}
</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]">
@ -590,7 +623,9 @@ function DynamicPdfSlot({
<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}
@ -645,19 +680,7 @@ export function DynamicSchemaSlotField({
disabled = false,
}: DynamicSchemaSlotFieldProps) {
const mode = slotMediaMode(row.kind)
const baseLabel =
row.label?.trim() ||
(mode === "image"
? "Image"
: mode === "audio"
? "Audio"
: mode === "pdf"
? "PDF"
: mode === "table"
? "Table"
: row.kind)
const slotLabel = `${baseLabel}${row.required ? " *" : ""}`
const slotMeta = `${row.id} · ${row.kind}`
const fieldLabel = `${slotLabel(row)}${row.required ? " *" : ""}`
if (mode === "table") {
return (
@ -665,19 +688,35 @@ export function DynamicSchemaSlotField({
value={value}
onChange={onChange}
disabled={disabled}
slotLabel={slotLabel}
slotMeta={slotMeta}
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>
)
}
if (mode === "text") {
return (
<div className="space-y-2">
<div className="flex flex-wrap items-end justify-between gap-2">
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
</div>
<label className="text-sm font-medium text-grayScale-700">{fieldLabel}</label>
<Textarea
rows={3}
value={value}
@ -700,8 +739,8 @@ export function DynamicSchemaSlotField({
value={value}
onChange={onChange}
disabled={disabled}
slotLabel={slotLabel}
slotMeta={slotMeta}
slotLabel={fieldLabel}
slotMeta=""
/>
)
}
@ -712,8 +751,8 @@ export function DynamicSchemaSlotField({
value={value}
onChange={onChange}
disabled={disabled}
slotLabel={slotLabel}
slotMeta={slotMeta}
slotLabel={fieldLabel}
slotMeta=""
/>
)
}
@ -723,8 +762,8 @@ export function DynamicSchemaSlotField({
value={value}
onChange={onChange}
disabled={disabled}
slotLabel={slotLabel}
slotMeta={slotMeta}
slotLabel={fieldLabel}
slotMeta=""
/>
)
}

View File

@ -636,8 +636,8 @@ export function PracticeQuestionEditorFields({
setDefinitionsLoading(true)
;(async () => {
try {
const rows = await getQuestionTypeDefinitions({ include_system: true })
if (!cancelled) setTypeDefinitions(Array.isArray(rows) ? rows : [])
const { definitions: rows } = await getQuestionTypeDefinitions({ include_system: true })
if (!cancelled) setTypeDefinitions(rows)
} catch {
if (!cancelled) setTypeDefinitions([])
} finally {

View File

@ -0,0 +1,169 @@
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>
)
}

View File

@ -1,74 +1,32 @@
import { useEffect, useRef, useState } from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { useNavigate } from "react-router-dom"
import {
Bell,
BellOff,
Info,
AlertCircle,
CheckCircle2,
Megaphone,
UserPlus,
CreditCard,
BookOpen,
Video,
ShieldAlert,
MailOpen,
Mail,
CheckCheck,
} from "lucide-react"
import { Bell, BellOff, CheckCheck, Mail, MailOpen } from "lucide-react"
import { toast } from "sonner"
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 = TYPE_CONFIG[notification.type] ?? DEFAULT_TYPE_CONFIG
const cfg = NOTIFICATION_TYPE_CONFIG[notification.type] ?? DEFAULT_NOTIFICATION_TYPE_CONFIG
const Icon = cfg.icon
return (
@ -77,31 +35,26 @@ 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={() => {
if (!notification.is_read) onMarkRead(notification.id)
}}
onClick={() => onOpen(notification)}
>
{/* 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"}
@ -110,11 +63,10 @@ function NotificationItem({
{getNotificationMessage(notification) || "No preview text available."}
</p>
<p className="mt-1 text-[11px] text-grayScale-600">
{formatTimestamp(notification.timestamp)}
{formatNotificationTimestamp(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"
@ -140,6 +92,11 @@ 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 {
@ -151,7 +108,40 @@ export function NotificationDropdown() {
markAllAsRead,
} = useNotifications()
// Click-outside handler
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],
)
useEffect(() => {
function handleMouseDown(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
@ -165,8 +155,8 @@ export function NotificationDropdown() {
}, [open])
return (
<>
<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"
@ -181,15 +171,11 @@ export function NotificationDropdown() {
)}
</button>
{/* 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>
<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}
@ -208,7 +194,6 @@ export function NotificationDropdown() {
)}
</div>
{/* Body */}
<div className="max-h-[480px] overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-12">
@ -225,6 +210,7 @@ export function NotificationDropdown() {
<NotificationItem
key={n.id}
notification={n}
onOpen={handleOpenNotification}
onMarkRead={markOneRead}
onMarkUnread={markOneUnread}
/>
@ -233,7 +219,6 @@ export function NotificationDropdown() {
)}
</div>
{/* Footer */}
<div className="border-t px-4 py-2.5">
<button
type="button"
@ -249,5 +234,19 @@ export function NotificationDropdown() {
</div>
)}
</div>
<NotificationDetailDialog
open={detailOpen}
onOpenChange={setDetailOpen}
notification={selectedNotification}
loading={detailLoading}
error={detailError}
onRetry={
selectedNotificationId
? () => void loadNotificationDetail(selectedNotificationId, false)
: undefined
}
/>
</>
)
}

View File

@ -3,12 +3,13 @@ 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=${token}`
return `${wsBase}/ws/connect?token=${encodeURIComponent(token)}`
}
export function useNotifications() {
@ -18,6 +19,8 @@ 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"))
@ -40,11 +43,37 @@ export function useNotifications() {
}
}, [])
const connectWs = useCallback(() => {
if (wsRef.current) {
wsRef.current.close()
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
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
@ -78,47 +107,45 @@ export function useNotifications() {
}
}
ws.onerror = () => {
ws.close()
}
ws.onclose = () => {
if (!mountedRef.current) return
if (connectAttemptRef.current !== attempt) return
if (wsRef.current === ws) wsRef.current = null
if (!mountedRef.current || intentionalCloseRef.current) return
clearReconnectTimer()
reconnectTimer.current = setTimeout(() => {
if (mountedRef.current) connectWs()
}, 5000)
}, RECONNECT_MS)
}
}, [])
}, [clearReconnectTimer, disconnectWs])
useEffect(() => {
mountedRef.current = true
intentionalCloseRef.current = false
fetchData()
connectWs()
return () => {
mountedRef.current = false
wsRef.current?.close()
if (reconnectTimer.current) clearTimeout(reconnectTimer.current)
disconnectWs(true)
}
}, [fetchData, connectWs])
}, [fetchData, connectWs, disconnectWs])
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()

View File

@ -4,7 +4,8 @@ import { createEmptyTable, serializeTableSlotValue } from "./dynamicTableValue"
import { buildDynamicQuestionPayload } from "./practiceDynamicQuestionPayload"
function defaultValueForSchemaSlot(kind: string): string {
if (kind.trim().toUpperCase() === "TABLE") {
const u = kind.trim().toUpperCase()
if (u === "TABLE") {
return serializeTableSlotValue(createEmptyTable(2, 1))
}
return ""

View File

@ -0,0 +1,101 @@
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
}

View File

@ -0,0 +1,41 @@
/** 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)
}

View File

@ -1,6 +1,5 @@
import React, { useEffect, useState } from "react";
import {
Bell,
Eye,
EyeOff,
Globe,
@ -23,7 +22,6 @@ 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";
@ -41,7 +39,6 @@ type SettingsTab =
| "app-versions"
| "profile"
| "security"
| "notifications"
| "appearance";
const tabs: { id: SettingsTab; label: string; icon: typeof User }[] = [
@ -49,65 +46,9 @@ 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);
@ -623,7 +564,6 @@ 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>

View File

@ -150,7 +150,7 @@ export function AddPracticeFlow() {
setDefinitionsLoading(true);
setDefinitionsError(null);
try {
const list = await getQuestionTypeDefinitions({
const { definitions: list } = await getQuestionTypeDefinitions({
include_system: true,
status: "ACTIVE",
});

View File

@ -141,8 +141,8 @@ export function AddQuestionPage() {
let cancelled = false
;(async () => {
try {
const rows = await getQuestionTypeDefinitions({ include_system: true })
if (!cancelled) setTypeDefinitions(Array.isArray(rows) ? rows : [])
const { definitions: rows } = await getQuestionTypeDefinitions({ include_system: true })
if (!cancelled) setTypeDefinitions(rows)
} catch {
if (!cancelled) setTypeDefinitions([])
}

View File

@ -30,6 +30,7 @@ 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: "",
@ -46,7 +47,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: k.replace(/_/g, " "),
label: defaultLabelForKind(k),
required: true as boolean,
}))
}
@ -59,8 +60,14 @@ 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 })),
response_schema: (def.response_schema ?? []).map((r) => ({ ...r })),
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),
})),
}
}

View File

@ -1,10 +1,21 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { Link, useNavigate, useSearchParams } from "react-router-dom"
import { ArrowLeft, Plus, Search, Trash2 } from "lucide-react"
import {
ArrowLeft,
ChevronDown,
ChevronLeft,
ChevronRight,
Layers,
Plus,
RefreshCw,
Search,
Trash2,
X,
} from "lucide-react"
import { toast } from "sonner"
import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input"
import { Card } from "../../components/ui/card"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import {
Dialog,
DialogContent,
@ -15,6 +26,7 @@ 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,
@ -22,6 +34,23 @@ 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()
@ -30,24 +59,48 @@ 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 [activeTab, setActiveTab] = useState<"All" | "ACTIVE" | "INACTIVE">("All")
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 [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 rows = await getQuestionTypeDefinitions({ include_system: true })
setDefinitions(Array.isArray(rows) ? rows : [])
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)
}
} 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()
@ -67,18 +120,36 @@ 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, activeTab])
}, [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)
}
const openDeleteConfirm = (row: QuestionTypeDefinition) => {
if (row.is_system) {
@ -136,48 +207,151 @@ export function QuestionTypeLibraryPage() {
</div>
</div>
<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" />
<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
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-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…"
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)}
/>
</div>
{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 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"
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",
)}
>
{tab === "All" ? "All" : tab === "ACTIVE" ? "Active" : "Inactive"}
</button>
<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>
</Card>
<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 ? (
<p className="text-sm text-grayScale-500 px-2">Loading definitions</p>
<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 ? (
<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="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>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<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>
)}
</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}
@ -196,6 +370,78 @@ export function QuestionTypeLibraryPage() {
</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>
</Card>
<Dialog open={definitionPendingDelete !== null} onOpenChange={handleDeleteDialogOpenChange}>
<DialogContent className="max-w-md rounded-2xl border-grayScale-200 sm:max-w-md">
<DialogHeader>
@ -242,3 +488,32 @@ 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>
)
}

View File

@ -1,4 +1,4 @@
import { Edit2, Trash2, Layers, Shield } from "lucide-react"
import { Edit2, Sparkles, 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,7 +42,12 @@ export function QuestionTypeCard({
<Shield className="h-3 w-3" />
System
</Badge>
) : null}
) : (
<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>
)}
</div>
<p className="text-[12px] font-mono text-grayScale-500 break-all">#{id} · {definitionKey}</p>

View File

@ -100,9 +100,9 @@ export function QuestionsStep({
return (
<div className="space-y-3 rounded-lg border border-violet-200 bg-violet-50/40 p-3">
<p className="text-xs leading-snug text-grayScale-600">
<span className="font-medium text-grayScale-800">Image / Audio / PDF</span> use upload or URL.{" "}
<span className="font-medium text-grayScale-800">Table</span> uses the visual table builder. Other
slots: text or structured JSON where noted.
<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.
</p>
{def.stimulus_schema.length > 0 ? (
<div className="space-y-2">

View File

@ -1,5 +1,5 @@
import { useState } from "react"
import { ArrowLeft, ArrowRight, ChevronDown, ChevronUp, Plus } from "lucide-react"
import { ArrowLeft, ArrowRight, ChevronDown, ChevronUp, Minus, Plus } from "lucide-react"
import { Button } from "../../../../components/ui/button"
import { Card } from "../../../../components/ui/card"
import { Input } from "../../../../components/ui/input"
@ -9,8 +9,13 @@ 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 { getResponseKindPresentation, getStimulusKindPresentation } from "./componentKindUi"
import {
defaultLabelForKind,
getResponseKindPresentation,
getStimulusKindPresentation,
} from "./componentKindUi"
interface QuestionTypeConfigStepProps {
draft: QuestionTypeDefinitionCreatePayload
@ -33,10 +38,6 @@ 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))
@ -49,6 +50,22 @@ 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> = {}
@ -84,7 +101,7 @@ export function QuestionTypeConfigStep({
stimulus_schema.push({
id: nextUniqueSchemaElementId(stimulus_schema, kind),
kind,
label: defaultSchemaLabel(kind),
label: defaultLabelForKind(kind),
required: true,
})
}
@ -108,7 +125,7 @@ export function QuestionTypeConfigStep({
response_schema.push({
id: nextUniqueSchemaElementId(response_schema, kind),
kind,
label: defaultSchemaLabel(kind),
label: defaultLabelForKind(kind),
required: true,
})
}
@ -129,7 +146,7 @@ export function QuestionTypeConfigStep({
stimulus_schema.push({
id: nextUniqueSchemaElementId(stimulus_schema, kind),
kind,
label: defaultSchemaLabel(kind),
label: defaultLabelForKind(kind),
required: true,
})
return { ...d, stimulus_schema }
@ -143,21 +160,59 @@ export function QuestionTypeConfigStep({
response_schema.push({
id: nextUniqueSchemaElementId(response_schema, kind),
kind,
label: defaultSchemaLabel(kind),
label: defaultLabelForKind(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 &amp; answer types</h2>
<p className="text-grayScale-500 font-medium mt-1">
Choose what learners see in the question and how they respond. You can add multiple fields of the
same type when needed.
Choose what learners see in the question and how they respond. Add or remove slots for each type
as needed.
</p>
</div>
@ -174,8 +229,8 @@ export function QuestionTypeConfigStep({
Section A: Question input types
</h3>
<p className="text-[14px] text-grayScale-500 mt-1 font-medium">
Choose how the question is presented to the learner. Use Add slot for multiple fields of
the same type (for example, two text blocks).
Choose how the question is presented to the learner. Use Add slot / Remove slot to adjust
how many fields of each type you need.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
@ -196,17 +251,32 @@ 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 slot
Add
</Button>
</div>
</div>
) : null}
</div>
)
@ -223,8 +293,8 @@ export function QuestionTypeConfigStep({
Section B: Answer types
</h3>
<p className="text-[14px] text-grayScale-500 mt-1 font-medium">
How should the student answer? Use Add slot when you need more than one field of the same
answer type.
How should the student answer? Use Add slot / Remove slot to adjust how many answer fields
each type needs.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
@ -245,17 +315,32 @@ 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 slot
Add
</Button>
</div>
</div>
) : null}
</div>
)
@ -268,6 +353,14 @@ 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"
@ -289,7 +382,7 @@ export function QuestionTypeConfigStep({
allowedKinds={draft.stimulus_component_kinds}
catalogKinds={stimulusCatalogKinds}
rows={draft.stimulus_schema}
onChange={(rows) => setDraft((d) => ({ ...d, stimulus_schema: rows }))}
onChange={setStimulusSchema}
error={errors.stimulus_schema}
rowErrors={rowErrorMap("stimulus", errors)}
/>
@ -299,7 +392,7 @@ export function QuestionTypeConfigStep({
allowedKinds={draft.response_component_kinds}
catalogKinds={responseCatalogKinds}
rows={draft.response_schema}
onChange={(rows) => setDraft((d) => ({ ...d, response_schema: rows }))}
onChange={setResponseSchema}
error={errors.response_schema}
rowErrors={rowErrorMap("response", errors)}
/>

View File

@ -17,6 +17,7 @@ import {
inferRuntimeQuestionType,
} from "../../lib/questionTypeDefinitionValidation"
import { DefinitionRuntimeHint } from "./DefinitionRuntimeHint"
import { slotLabel } from "./componentKindUi"
interface QuestionTypeReviewPublishStepProps {
draft: QuestionTypeDefinitionCreatePayload
@ -218,9 +219,11 @@ function SchemaSlotSummary({
<ul className="mt-2 space-y-1.5 text-sm">
{rows.map((r) => (
<li key={`${r.kind}-${r.id}`} className="flex flex-wrap gap-x-2 text-grayScale-800">
<span className="font-mono text-[12px] text-grayScale-600">{r.id}</span>
<span className="font-medium">{slotLabel(r)}</span>
<span className="text-grayScale-400">·</span>
<span>{r.kind}</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}

View File

@ -126,11 +126,19 @@ export function QuestionTypeValidatePreviewStep({
</div>
<div>
<dt className="text-[11px] font-bold uppercase text-grayScale-400">Stimulus slots</dt>
<dd className="mt-1 font-medium text-grayScale-800">{payload.stimulus_schema.length}</dd>
<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}</dd>
<dd className="mt-1 font-medium text-grayScale-800">
{payload.response_schema.length
? payload.response_schema.map((r) => r.label).join(" · ")
: "—"}
</dd>
</div>
</dl>

View File

@ -4,6 +4,11 @@ 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"
@ -23,7 +28,7 @@ function emptyRow(allowedKinds: string[]): DynamicElementDefinition {
return {
id: "",
kind: first,
label: "",
label: first ? defaultLabelForKind(first) : "",
required: true,
config: undefined,
}
@ -43,10 +48,24 @@ export function SchemaBuilderSection({
allowedKinds.length > 0 ? allowedKinds : catalogKinds.length > 0 ? catalogKinds : []
const updateRow = (index: number, patch: Partial<DynamicElementDefinition>) => {
const next = rows.map((r, i) => (i === index ? { ...r, ...patch } : r))
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
})
onChange(next)
}
const kindPresentation = (kind: string) =>
side === "stimulus" ? getStimulusKindPresentation(kind) : getResponseKindPresentation(kind)
const commitConfigString = (index: number, raw: string) => {
const trimmed = raw.trim()
if (!trimmed) {
@ -73,9 +92,8 @@ 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">
Each row defines one element in the{" "}
{side === "stimulus" ? "stimulus" : "response"} schema (id, kind, label, required, optional JSON
config).
Fine-tune slot ids, labels, required flags, and optional config. Labels are the field titles
authors see when creating questions.
</p>
</div>
<Button
@ -145,7 +163,7 @@ export function SchemaBuilderSection({
>
{kindOptions.map((k) => (
<option key={k} value={k}>
{k}
{kindPresentation(k).label} ({k})
</option>
))}
</Select>
@ -154,11 +172,13 @@ 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">Label</label>
<label className="text-[12px] font-semibold text-grayScale-600">
Field label <span className="text-red-500">*</span>
</label>
<Input
value={row.label ?? ""}
onChange={(e) => updateRow(index, { label: e.target.value })}
placeholder="Author-facing label"
placeholder={defaultLabelForKind(row.kind)}
className="h-10 bg-white"
/>
</div>

View File

@ -0,0 +1,137 @@
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>
)
}

View File

@ -20,35 +20,35 @@ import {
Volume2,
} from "lucide-react"
/** Human label for API kind codes; unknown kinds fall back to title-cased code. */
import { defaultLabelForKind, humanizeKind, slotLabel } from "../../../../lib/schemaSlotLabel"
export { defaultLabelForKind, humanizeKind, slotLabel }
const STIMULUS_LABELS: Record<string, string> = {
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",
PDF_ATTACHMENT: "PDF Attachment",
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"),
}
const RESPONSE_LABELS: Record<string, string> = {
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",
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"),
}
/** Legacy screenshot labels → map to closest API kind for display only (same code path). */
@ -60,11 +60,9 @@ 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,
FLOW_CHART: GitBranch,
PDF_ATTACHMENT: FileUp,
}
@ -84,13 +82,6 @@ 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),

View File

@ -3,6 +3,7 @@ import type {
QuestionComponentCatalog,
QuestionTypeDefinitionCreatePayload,
} from "../../../types/questionTypeDefinition.types"
import { defaultLabelForKind } from "../../../lib/schemaSlotLabel"
export type FieldErrorMap = Record<string, string>
@ -99,13 +100,18 @@ export function validateDefinitionSchemas(
const allowedSet = new Set(allowed)
const catalogSet = side === "stimulus" ? catalog.stimulus : catalog.response
rows.forEach((row, i) => {
if (!row.kind) errors[`${prefix}_${i}`] = "Kind is required."
const rowMessages: string[] = []
if (!row.kind) rowMessages.push("Kind is required.")
else if (allowedSet.size && !allowedSet.has(row.kind)) {
errors[`${prefix}_${i}`] = `Kind "${row.kind}" is not in selected ${side} kinds.`
rowMessages.push(`Kind "${row.kind}" is not in selected ${side} kinds.`)
}
if (catalogSet.size && row.kind && !catalogSet.has(row.kind)) {
errors[`${prefix}_${i}`] = `Kind "${row.kind}" is not in the ${side} component catalog.`
rowMessages.push(`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(" ")
})
}
@ -181,14 +187,14 @@ export function buildCreatePayload(
...r,
id: r.id.trim(),
kind: r.kind.trim(),
label: r.label?.trim() || undefined,
label: r.label?.trim() || defaultLabelForKind(r.kind),
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() || undefined,
label: r.label?.trim() || defaultLabelForKind(r.kind),
config: r.config && Object.keys(r.config).length ? r.config : undefined,
}))

View File

@ -3,17 +3,9 @@ import {
Bell,
BellOff,
AlertTriangle,
Info,
AlertCircle,
CheckCircle2,
ChevronLeft,
ChevronRight,
Megaphone,
UserPlus,
CreditCard,
BookOpen,
Video,
ShieldAlert,
MailOpen,
Mail,
CheckCheck,
@ -53,6 +45,7 @@ import { cn } from "../../lib/utils"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { useNavigate } from "react-router-dom"
import {
getNotificationById,
getNotifications,
getUnreadCount,
markAsRead,
@ -63,6 +56,14 @@ 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"
@ -73,68 +74,6 @@ import type { UserApiDTO } from "../../types/user.types"
import { toast } from "sonner"
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
const TYPE_CONFIG: Record<string, { icon: React.ElementType; color: string; bg: string }> = {
announcement: { icon: Megaphone, color: "text-brand-600", bg: "bg-brand-100" },
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)
}
@ -148,7 +87,7 @@ function NotificationItem({
onToggleRead: (id: string, currentlyRead: boolean) => void
toggling: boolean
}) {
const config = TYPE_CONFIG[notification.type] ?? DEFAULT_TYPE_CONFIG
const config = NOTIFICATION_TYPE_CONFIG[notification.type] ?? DEFAULT_NOTIFICATION_TYPE_CONFIG
const Icon = config.icon
return (
@ -189,7 +128,7 @@ function NotificationItem({
>
{getNotificationTitle(notification)}
</span>
<Badge variant={getLevelBadge(notification.level)} className="text-[10px] px-1.5 py-0">
<Badge variant={getNotificationLevelBadge(notification.level)} className="text-[10px] px-1.5 py-0">
{notification.level}
</Badge>
</div>
@ -205,7 +144,7 @@ function NotificationItem({
<div className="flex shrink-0 items-center gap-2">
<span className="text-xs text-grayScale-400">
{formatTimestamp(notification.timestamp)}
{formatNotificationTimestamp(notification.timestamp)}
</span>
<button
type="button"
@ -235,7 +174,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">
{formatTypeLabel(notification.type)}
{formatNotificationTypeLabel(notification.type)}
</Badge>
<Badge variant="secondary" className="text-[10px] px-2 py-0">
{notification.delivery_channel}
@ -270,7 +209,10 @@ export function NotificationsPage() {
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")
@ -525,7 +467,7 @@ export function NotificationsPage() {
const haystack = [
getNotificationTitle(n),
getNotificationMessage(n),
formatTypeLabel(n.type),
formatNotificationTypeLabel(n.type),
n.delivery_channel,
n.level,
]
@ -537,9 +479,42 @@ export function NotificationsPage() {
return true
})
const handleOpenDetail = (notification: Notification) => {
setSelectedNotification(notification)
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)
}
return (
@ -756,7 +731,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" : formatTypeLabel(typeFilter)}
{typeFilter === "all" ? "All types" : formatNotificationTypeLabel(typeFilter)}
</span>
<ChevronDown className="ml-2 h-3.5 w-3.5 text-grayScale-400" />
</Button>
@ -766,7 +741,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}>
{formatTypeLabel(t)}
{formatNotificationTypeLabel(t)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
@ -830,7 +805,7 @@ export function NotificationsPage() {
</TableRow>
) : (
filteredNotifications.map((n) => {
const config = TYPE_CONFIG[n.type] ?? DEFAULT_TYPE_CONFIG
const config = NOTIFICATION_TYPE_CONFIG[n.type] ?? DEFAULT_NOTIFICATION_TYPE_CONFIG
const Icon = config.icon
const isToggling = togglingIds.has(n.id)
return (
@ -854,7 +829,7 @@ export function NotificationsPage() {
<Icon className="h-4 w-4" />
</div>
<span className="text-xs font-medium text-grayScale-600">
{formatTypeLabel(n.type)}
{formatNotificationTypeLabel(n.type)}
</span>
</div>
</TableCell>
@ -880,7 +855,7 @@ export function NotificationsPage() {
</TableCell>
<TableCell>
<Badge
variant={getLevelBadge(n.level)}
variant={getNotificationLevelBadge(n.level)}
className="text-[10px] uppercase tracking-wide"
>
{n.is_read ? "Read" : "Unread"}
@ -888,7 +863,7 @@ export function NotificationsPage() {
</TableCell>
<TableCell className="hidden sm:table-cell">
<span className="text-xs text-grayScale-400">
{formatTimestamp(n.timestamp)}
{formatNotificationTimestamp(n.timestamp)}
</span>
</TableCell>
<TableCell className="text-right">
@ -1005,66 +980,18 @@ export function NotificationsPage() {
</>
)}
{/* 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>
)}
<NotificationDetailDialog
open={detailOpen}
onOpenChange={setDetailOpen}
notification={selectedNotification}
loading={detailLoading}
error={detailError}
onRetry={
selectedNotificationId
? () => void loadNotificationDetail(selectedNotificationId)
: undefined
}
/>
{/* Bulk send dialog */}
<Dialog open={bulkOpen} onOpenChange={setBulkOpen}>

View File

@ -76,7 +76,7 @@ export function AppVersionsTab() {
setLoading(true)
setError(false)
try {
const res = await getAppVersions({ limit: PAGE_SIZE, offset })
const res = await getAppVersions({ limit: pageSize, offset })
setVersions(res.data.versions)
setTotalCount(res.data.total_count)
} catch (e) {
@ -187,7 +187,7 @@ export function AppVersionsTab() {
<TabletSmartphone className="h-5 w-5" />
</div>
<div>
<p className="text-xs font-medium text-grayScale-500">Total (this page)</p>
<p className="text-xs font-medium text-grayScale-500">Total versions</p>
<p className="text-2xl font-bold text-grayScale-900">{totalCount}</p>
</div>
</CardContent>

View File

@ -9,6 +9,7 @@ export interface NotificationPayload {
export interface Notification {
id: string
recipient_id: number
receiver_type?: string
type: string
level: string
error_severity: string