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:
parent
92a2fab833
commit
1014f4a72f
|
|
@ -1,4 +1,5 @@
|
||||||
import http from "./http"
|
import http from "./http"
|
||||||
|
import { DEFAULT_TABLE_PAGE_SIZE } from "../lib/tablePagination"
|
||||||
import type {
|
import type {
|
||||||
AppVersion,
|
AppVersion,
|
||||||
AppVersionMutationResponse,
|
AppVersionMutationResponse,
|
||||||
|
|
@ -77,7 +78,7 @@ export type GetAppVersionsParams = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getAppVersions = (params: GetAppVersionsParams = {}) => {
|
export const getAppVersions = (params: GetAppVersionsParams = {}) => {
|
||||||
const limit = params.limit ?? 20
|
const limit = params.limit ?? DEFAULT_TABLE_PAGE_SIZE
|
||||||
const offset = params.offset ?? 0
|
const offset = params.offset ?? 0
|
||||||
return http
|
return http
|
||||||
.get<AppVersionsListResponse>("/admin/app-versions", { params: { limit, offset } })
|
.get<AppVersionsListResponse>("/admin/app-versions", { params: { limit, offset } })
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,125 @@
|
||||||
import http from "./http";
|
import http from "./http"
|
||||||
import type { GetNotificationsResponse, UnreadCountResponse } from "../types/notification.types";
|
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) =>
|
export const getNotifications = (limit = 10, offset = 0) =>
|
||||||
http.get<GetNotificationsResponse>("/notifications", {
|
http.get<unknown>("/notifications", { params: { limit, offset } }).then((res) => ({
|
||||||
params: { limit, offset },
|
...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 = () =>
|
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) =>
|
export const markAsRead = (id: string) =>
|
||||||
http.patch(`/notifications/${id}/read`);
|
http.patch(`/notifications/${id}/read`)
|
||||||
|
|
||||||
export const markAsUnread = (id: string) =>
|
export const markAsUnread = (id: string) =>
|
||||||
http.patch(`/notifications/${id}/unread`);
|
http.patch(`/notifications/${id}/unread`)
|
||||||
|
|
||||||
export const markAllRead = () =>
|
export const markAllRead = () =>
|
||||||
http.post("/notifications/mark-all-read");
|
http.post("/notifications/mark-all-read")
|
||||||
|
|
||||||
export const markAllUnread = () =>
|
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 }) =>
|
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) =>
|
export const sendBulkEmail = (formData: FormData) =>
|
||||||
http.post("/notifications/bulk-email", formData, {
|
http.post("/notifications/bulk-email", formData, {
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
});
|
})
|
||||||
|
|
||||||
export const sendBulkPush = (formData: FormData) =>
|
export const sendBulkPush = (formData: FormData) =>
|
||||||
http.post("/notifications/bulk-push", formData, {
|
http.post("/notifications/bulk-push", formData, {
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
});
|
})
|
||||||
|
|
|
||||||
|
|
@ -261,6 +261,40 @@ export function extractDefinitionMutationId(res: { data?: unknown }): number | u
|
||||||
/** @deprecated use extractDefinitionMutationId */
|
/** @deprecated use extractDefinitionMutationId */
|
||||||
export const extractCreatedDefinitionId = 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[] {
|
export function parseDefinitionsList(payload: unknown): QuestionTypeDefinition[] {
|
||||||
if (!payload) return []
|
if (!payload) return []
|
||||||
if (Array.isArray(payload)) {
|
if (Array.isArray(payload)) {
|
||||||
|
|
@ -270,30 +304,32 @@ export function parseDefinitionsList(payload: unknown): QuestionTypeDefinition[]
|
||||||
}
|
}
|
||||||
if (typeof payload === "object" && payload !== null) {
|
if (typeof payload === "object" && payload !== null) {
|
||||||
const o = payload as Record<string, unknown>
|
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 (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
|
const data = o.data ?? o.Data
|
||||||
if (Array.isArray(data)) return parseDefinitionsList(data)
|
if (Array.isArray(data)) return parseDefinitionsList(data)
|
||||||
if (data && typeof data === "object") {
|
if (data && typeof data === "object") return parseDefinitionsList(data)
|
||||||
const single = normalizeTypeDefinitionFromApi(data)
|
|
||||||
return single ? [single] : []
|
|
||||||
}
|
|
||||||
const single = normalizeTypeDefinitionFromApi(payload)
|
const single = normalizeTypeDefinitionFromApi(payload)
|
||||||
return single ? [single] : []
|
return single ? [single] : []
|
||||||
}
|
}
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getQuestionTypeDefinitions(params?: {
|
export async function getQuestionTypeDefinitions(
|
||||||
include_system?: boolean
|
params?: QuestionTypeDefinitionsListParams,
|
||||||
status?: string
|
): Promise<QuestionTypeDefinitionsListResult> {
|
||||||
limit?: number
|
|
||||||
offset?: number
|
|
||||||
}) {
|
|
||||||
const res = await http.get<ApiEnvelope<unknown>>("/questions/type-definitions", { params })
|
const res = await http.get<ApiEnvelope<unknown>>("/questions/type-definitions", { params })
|
||||||
const raw = unwrapApiPayload(res) ?? res.data
|
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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { SpinnerIcon } from "../ui/spinner-icon"
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
import { ResolvedImage } from "../media/ResolvedImage"
|
import { ResolvedImage } from "../media/ResolvedImage"
|
||||||
import { DynamicTableBuilder } from "./DynamicTableBuilder"
|
import { DynamicTableBuilder } from "./DynamicTableBuilder"
|
||||||
|
import { slotLabel } from "../../lib/schemaSlotLabel"
|
||||||
|
|
||||||
const MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
const MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
||||||
const MAX_AUDIO_BYTES = 50 * 1024 * 1024
|
const MAX_AUDIO_BYTES = 50 * 1024 * 1024
|
||||||
|
|
@ -30,15 +31,43 @@ export interface DynamicSchemaSlotRow {
|
||||||
required?: boolean
|
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()
|
const u = kind.trim().toUpperCase()
|
||||||
if (u === "IMAGE") return "image"
|
if (u === "IMAGE") return "image"
|
||||||
if (u === "TABLE") return "table"
|
if (u === "TABLE") return "table"
|
||||||
if (u === "PDF_ATTACHMENT" || u === "PDF_UPLOAD") return "pdf"
|
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"
|
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 {
|
function isHttpUrl(s: string): boolean {
|
||||||
return /^https?:\/\//i.test(s.trim())
|
return /^https?:\/\//i.test(s.trim())
|
||||||
}
|
}
|
||||||
|
|
@ -141,7 +170,9 @@ function DynamicImageSlot({
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex flex-wrap items-end justify-between gap-2">
|
<div className="flex flex-wrap items-end justify-between gap-2">
|
||||||
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
|
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
|
||||||
|
{slotMeta ? (
|
||||||
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
|
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</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="grid grid-cols-1 gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(220px,36%)] lg:items-start lg:gap-4">
|
||||||
<div
|
<div
|
||||||
|
|
@ -411,7 +442,9 @@ function DynamicAudioSlot({
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex flex-wrap items-end justify-between gap-2">
|
<div className="flex flex-wrap items-end justify-between gap-2">
|
||||||
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
|
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
|
||||||
|
{slotMeta ? (
|
||||||
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
|
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</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="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]">
|
<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="space-y-2">
|
||||||
<div className="flex flex-wrap items-end justify-between gap-2">
|
<div className="flex flex-wrap items-end justify-between gap-2">
|
||||||
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
|
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
|
||||||
|
{slotMeta ? (
|
||||||
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
|
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
|
|
@ -645,19 +680,7 @@ export function DynamicSchemaSlotField({
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: DynamicSchemaSlotFieldProps) {
|
}: DynamicSchemaSlotFieldProps) {
|
||||||
const mode = slotMediaMode(row.kind)
|
const mode = slotMediaMode(row.kind)
|
||||||
const baseLabel =
|
const fieldLabel = `${slotLabel(row)}${row.required ? " *" : ""}`
|
||||||
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}`
|
|
||||||
|
|
||||||
if (mode === "table") {
|
if (mode === "table") {
|
||||||
return (
|
return (
|
||||||
|
|
@ -665,19 +688,35 @@ export function DynamicSchemaSlotField({
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
slotLabel={slotLabel}
|
slotLabel={fieldLabel}
|
||||||
slotMeta={slotMeta}
|
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") {
|
if (mode === "text") {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<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">{fieldLabel}</label>
|
||||||
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
|
|
||||||
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
|
|
||||||
</div>
|
|
||||||
<Textarea
|
<Textarea
|
||||||
rows={3}
|
rows={3}
|
||||||
value={value}
|
value={value}
|
||||||
|
|
@ -700,8 +739,8 @@ export function DynamicSchemaSlotField({
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
slotLabel={slotLabel}
|
slotLabel={fieldLabel}
|
||||||
slotMeta={slotMeta}
|
slotMeta=""
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -712,8 +751,8 @@ export function DynamicSchemaSlotField({
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
slotLabel={slotLabel}
|
slotLabel={fieldLabel}
|
||||||
slotMeta={slotMeta}
|
slotMeta=""
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -723,8 +762,8 @@ export function DynamicSchemaSlotField({
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
slotLabel={slotLabel}
|
slotLabel={fieldLabel}
|
||||||
slotMeta={slotMeta}
|
slotMeta=""
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -636,8 +636,8 @@ export function PracticeQuestionEditorFields({
|
||||||
setDefinitionsLoading(true)
|
setDefinitionsLoading(true)
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
const rows = await getQuestionTypeDefinitions({ include_system: true })
|
const { definitions: rows } = await getQuestionTypeDefinitions({ include_system: true })
|
||||||
if (!cancelled) setTypeDefinitions(Array.isArray(rows) ? rows : [])
|
if (!cancelled) setTypeDefinitions(rows)
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setTypeDefinitions([])
|
if (!cancelled) setTypeDefinitions([])
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
169
src/components/notifications/NotificationDetailDialog.tsx
Normal file
169
src/components/notifications/NotificationDetailDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,74 +1,32 @@
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
import {
|
import { Bell, BellOff, CheckCheck, Mail, MailOpen } from "lucide-react"
|
||||||
Bell,
|
import { toast } from "sonner"
|
||||||
BellOff,
|
|
||||||
Info,
|
|
||||||
AlertCircle,
|
|
||||||
CheckCircle2,
|
|
||||||
Megaphone,
|
|
||||||
UserPlus,
|
|
||||||
CreditCard,
|
|
||||||
BookOpen,
|
|
||||||
Video,
|
|
||||||
ShieldAlert,
|
|
||||||
MailOpen,
|
|
||||||
Mail,
|
|
||||||
CheckCheck,
|
|
||||||
} from "lucide-react"
|
|
||||||
import { Badge } from "../ui/badge"
|
import { Badge } from "../ui/badge"
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
import { SpinnerIcon } from "../ui/spinner-icon"
|
import { SpinnerIcon } from "../ui/spinner-icon"
|
||||||
|
import { getNotificationById } from "../../api/notifications.api"
|
||||||
import { useNotifications } from "../../hooks/useNotifications"
|
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"
|
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({
|
function NotificationItem({
|
||||||
notification,
|
notification,
|
||||||
|
onOpen,
|
||||||
onMarkRead,
|
onMarkRead,
|
||||||
onMarkUnread,
|
onMarkUnread,
|
||||||
}: {
|
}: {
|
||||||
notification: Notification
|
notification: Notification
|
||||||
|
onOpen: (notification: Notification) => void
|
||||||
onMarkRead: (id: string) => void
|
onMarkRead: (id: string) => void
|
||||||
onMarkUnread: (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
|
const Icon = cfg.icon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -77,31 +35,26 @@ function NotificationItem({
|
||||||
className={cn(
|
className={cn(
|
||||||
"group relative flex w-full gap-3 rounded-lg px-3 py-3 text-left transition-colors hover:bg-grayScale-100",
|
"group relative flex w-full gap-3 rounded-lg px-3 py-3 text-left transition-colors hover:bg-grayScale-100",
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => onOpen(notification)}
|
||||||
if (!notification.is_read) onMarkRead(notification.id)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Unread dot */}
|
|
||||||
{!notification.is_read && (
|
{!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" />
|
<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
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-3 grid h-9 w-9 shrink-0 place-items-center rounded-lg",
|
"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)} />
|
<Icon className={cn("h-4 w-4", cfg.color)} />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm leading-snug text-grayScale-900",
|
"text-sm leading-snug text-grayScale-900",
|
||||||
!notification.is_read && "font-semibold"
|
!notification.is_read && "font-semibold",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{getNotificationTitle(notification) || "Notification"}
|
{getNotificationTitle(notification) || "Notification"}
|
||||||
|
|
@ -110,11 +63,10 @@ function NotificationItem({
|
||||||
{getNotificationMessage(notification) || "No preview text available."}
|
{getNotificationMessage(notification) || "No preview text available."}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-[11px] text-grayScale-600">
|
<p className="mt-1 text-[11px] text-grayScale-600">
|
||||||
{formatTimestamp(notification.timestamp)}
|
{formatNotificationTimestamp(notification.timestamp)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Read / Unread toggle */}
|
|
||||||
<button
|
<button
|
||||||
type="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"
|
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() {
|
export function NotificationDropdown() {
|
||||||
const [open, setOpen] = useState(false)
|
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 containerRef = useRef<HTMLDivElement>(null)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const {
|
const {
|
||||||
|
|
@ -151,7 +108,40 @@ export function NotificationDropdown() {
|
||||||
markAllAsRead,
|
markAllAsRead,
|
||||||
} = useNotifications()
|
} = 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(() => {
|
useEffect(() => {
|
||||||
function handleMouseDown(e: MouseEvent) {
|
function handleMouseDown(e: MouseEvent) {
|
||||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
|
@ -165,8 +155,8 @@ export function NotificationDropdown() {
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div ref={containerRef} className="relative">
|
<div ref={containerRef} className="relative">
|
||||||
{/* Bell button */}
|
|
||||||
<button
|
<button
|
||||||
type="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"
|
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>
|
</button>
|
||||||
|
|
||||||
{/* Dropdown panel */}
|
|
||||||
{open && (
|
{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="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 justify-between border-b px-4 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="text-sm font-semibold text-grayScale-800">
|
<h3 className="text-sm font-semibold text-grayScale-800">Notifications</h3>
|
||||||
Notifications
|
|
||||||
</h3>
|
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<Badge variant="default" className="px-1.5 py-0 text-[10px]">
|
<Badge variant="default" className="px-1.5 py-0 text-[10px]">
|
||||||
{unreadCount}
|
{unreadCount}
|
||||||
|
|
@ -208,7 +194,6 @@ export function NotificationDropdown() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<div className="max-h-[480px] overflow-y-auto">
|
<div className="max-h-[480px] overflow-y-auto">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
|
|
@ -225,6 +210,7 @@ export function NotificationDropdown() {
|
||||||
<NotificationItem
|
<NotificationItem
|
||||||
key={n.id}
|
key={n.id}
|
||||||
notification={n}
|
notification={n}
|
||||||
|
onOpen={handleOpenNotification}
|
||||||
onMarkRead={markOneRead}
|
onMarkRead={markOneRead}
|
||||||
onMarkUnread={markOneUnread}
|
onMarkUnread={markOneUnread}
|
||||||
/>
|
/>
|
||||||
|
|
@ -233,7 +219,6 @@ export function NotificationDropdown() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="border-t px-4 py-2.5">
|
<div className="border-t px-4 py-2.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -249,5 +234,19 @@ export function NotificationDropdown() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<NotificationDetailDialog
|
||||||
|
open={detailOpen}
|
||||||
|
onOpenChange={setDetailOpen}
|
||||||
|
notification={selectedNotification}
|
||||||
|
loading={detailLoading}
|
||||||
|
error={detailError}
|
||||||
|
onRetry={
|
||||||
|
selectedNotificationId
|
||||||
|
? () => void loadNotificationDetail(selectedNotificationId, false)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,13 @@ import { getNotifications, getUnreadCount, markAsRead, markAsUnread, markAllRead
|
||||||
import type { Notification } from "../types/notification.types"
|
import type { Notification } from "../types/notification.types"
|
||||||
|
|
||||||
const MAX_DROPDOWN = 5
|
const MAX_DROPDOWN = 5
|
||||||
|
const RECONNECT_MS = 5000
|
||||||
|
|
||||||
function getWsUrl() {
|
function getWsUrl() {
|
||||||
const base = import.meta.env.VITE_API_BASE_URL as string
|
const base = import.meta.env.VITE_API_BASE_URL as string
|
||||||
const wsBase = base.replace(/^https/, "wss").replace(/^http/, "ws")
|
const wsBase = base.replace(/^https/, "wss").replace(/^http/, "ws")
|
||||||
const token = localStorage.getItem("access_token") ?? ""
|
const token = localStorage.getItem("access_token") ?? ""
|
||||||
return `${wsBase}/ws/connect?token=${token}`
|
return `${wsBase}/ws/connect?token=${encodeURIComponent(token)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNotifications() {
|
export function useNotifications() {
|
||||||
|
|
@ -18,6 +19,8 @@ export function useNotifications() {
|
||||||
const wsRef = useRef<WebSocket | null>(null)
|
const wsRef = useRef<WebSocket | null>(null)
|
||||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const mountedRef = useRef(true)
|
const mountedRef = useRef(true)
|
||||||
|
const intentionalCloseRef = useRef(false)
|
||||||
|
const connectAttemptRef = useRef(0)
|
||||||
|
|
||||||
const dispatchUpdate = () => {
|
const dispatchUpdate = () => {
|
||||||
window.dispatchEvent(new Event("notifications-updated"))
|
window.dispatchEvent(new Event("notifications-updated"))
|
||||||
|
|
@ -40,11 +43,37 @@ export function useNotifications() {
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const connectWs = useCallback(() => {
|
const clearReconnectTimer = useCallback(() => {
|
||||||
if (wsRef.current) {
|
if (reconnectTimer.current) {
|
||||||
wsRef.current.close()
|
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())
|
const ws = new WebSocket(getWsUrl())
|
||||||
wsRef.current = ws
|
wsRef.current = ws
|
||||||
|
|
||||||
|
|
@ -78,47 +107,45 @@ export function useNotifications() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onerror = () => {
|
|
||||||
ws.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.onclose = () => {
|
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(() => {
|
reconnectTimer.current = setTimeout(() => {
|
||||||
if (mountedRef.current) connectWs()
|
if (mountedRef.current) connectWs()
|
||||||
}, 5000)
|
}, RECONNECT_MS)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [clearReconnectTimer, disconnectWs])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mountedRef.current = true
|
mountedRef.current = true
|
||||||
|
intentionalCloseRef.current = false
|
||||||
fetchData()
|
fetchData()
|
||||||
connectWs()
|
connectWs()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
mountedRef.current = false
|
mountedRef.current = false
|
||||||
wsRef.current?.close()
|
disconnectWs(true)
|
||||||
if (reconnectTimer.current) clearTimeout(reconnectTimer.current)
|
|
||||||
}
|
}
|
||||||
}, [fetchData, connectWs])
|
}, [fetchData, connectWs, disconnectWs])
|
||||||
|
|
||||||
const markOneRead = useCallback(async (id: string) => {
|
const markOneRead = useCallback(async (id: string) => {
|
||||||
setNotifications((prev) =>
|
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))
|
setUnreadCount((prev) => Math.max(0, prev - 1))
|
||||||
dispatchUpdate()
|
dispatchUpdate()
|
||||||
try {
|
try {
|
||||||
await markAsRead(id)
|
await markAsRead(id)
|
||||||
} catch {
|
} catch {
|
||||||
// revert on failure
|
|
||||||
await fetchData()
|
await fetchData()
|
||||||
}
|
}
|
||||||
}, [fetchData])
|
}, [fetchData])
|
||||||
|
|
||||||
const markOneUnread = useCallback(async (id: string) => {
|
const markOneUnread = useCallback(async (id: string) => {
|
||||||
setNotifications((prev) =>
|
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)
|
setUnreadCount((prev) => prev + 1)
|
||||||
dispatchUpdate()
|
dispatchUpdate()
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ import { createEmptyTable, serializeTableSlotValue } from "./dynamicTableValue"
|
||||||
import { buildDynamicQuestionPayload } from "./practiceDynamicQuestionPayload"
|
import { buildDynamicQuestionPayload } from "./practiceDynamicQuestionPayload"
|
||||||
|
|
||||||
function defaultValueForSchemaSlot(kind: string): string {
|
function defaultValueForSchemaSlot(kind: string): string {
|
||||||
if (kind.trim().toUpperCase() === "TABLE") {
|
const u = kind.trim().toUpperCase()
|
||||||
|
if (u === "TABLE") {
|
||||||
return serializeTableSlotValue(createEmptyTable(2, 1))
|
return serializeTableSlotValue(createEmptyTable(2, 1))
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
|
|
|
||||||
101
src/lib/notificationDisplay.ts
Normal file
101
src/lib/notificationDisplay.ts
Normal 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
|
||||||
|
}
|
||||||
41
src/lib/schemaSlotLabel.ts
Normal file
41
src/lib/schemaSlotLabel.ts
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Bell,
|
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Globe,
|
Globe,
|
||||||
|
|
@ -23,7 +22,6 @@ import {
|
||||||
import { Input } from "../components/ui/input";
|
import { Input } from "../components/ui/input";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Select } from "../components/ui/select";
|
import { Select } from "../components/ui/select";
|
||||||
import { Separator } from "../components/ui/separator";
|
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { SpinnerIcon } from "../components/ui/spinner-icon";
|
import { SpinnerIcon } from "../components/ui/spinner-icon";
|
||||||
import { changeTeamMemberPassword } from "../api/team.api";
|
import { changeTeamMemberPassword } from "../api/team.api";
|
||||||
|
|
@ -41,7 +39,6 @@ type SettingsTab =
|
||||||
| "app-versions"
|
| "app-versions"
|
||||||
| "profile"
|
| "profile"
|
||||||
| "security"
|
| "security"
|
||||||
| "notifications"
|
|
||||||
| "appearance";
|
| "appearance";
|
||||||
|
|
||||||
const tabs: { id: SettingsTab; label: string; icon: typeof User }[] = [
|
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: "app-versions", label: "App versions", icon: Smartphone },
|
||||||
{ id: "profile", label: "Profile", icon: User },
|
{ id: "profile", label: "Profile", icon: User },
|
||||||
{ id: "security", label: "Security", icon: Shield },
|
{ id: "security", label: "Security", icon: Shield },
|
||||||
{ id: "notifications", label: "Notifications", icon: Bell },
|
|
||||||
{ id: "appearance", label: "Appearance", icon: Palette },
|
{ 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 }) {
|
function ProfileTab({ profile }: { profile: UserProfileData }) {
|
||||||
const [firstName, setFirstName] = useState(profile.first_name);
|
const [firstName, setFirstName] = useState(profile.first_name);
|
||||||
const [lastName, setLastName] = useState(profile.last_name);
|
const [lastName, setLastName] = useState(profile.last_name);
|
||||||
|
|
@ -623,7 +564,6 @@ export function SettingsPage() {
|
||||||
{activeTab === "app-versions" && <AppVersionsTab />}
|
{activeTab === "app-versions" && <AppVersionsTab />}
|
||||||
{activeTab === "profile" && <ProfileTab profile={profile} />}
|
{activeTab === "profile" && <ProfileTab profile={profile} />}
|
||||||
{activeTab === "security" && <SecurityTab memberId={profile.id} />}
|
{activeTab === "security" && <SecurityTab memberId={profile.id} />}
|
||||||
{activeTab === "notifications" && <NotificationsTab />}
|
|
||||||
{activeTab === "appearance" && <AppearanceTab />}
|
{activeTab === "appearance" && <AppearanceTab />}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,7 @@ export function AddPracticeFlow() {
|
||||||
setDefinitionsLoading(true);
|
setDefinitionsLoading(true);
|
||||||
setDefinitionsError(null);
|
setDefinitionsError(null);
|
||||||
try {
|
try {
|
||||||
const list = await getQuestionTypeDefinitions({
|
const { definitions: list } = await getQuestionTypeDefinitions({
|
||||||
include_system: true,
|
include_system: true,
|
||||||
status: "ACTIVE",
|
status: "ACTIVE",
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -141,8 +141,8 @@ export function AddQuestionPage() {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
const rows = await getQuestionTypeDefinitions({ include_system: true })
|
const { definitions: rows } = await getQuestionTypeDefinitions({ include_system: true })
|
||||||
if (!cancelled) setTypeDefinitions(Array.isArray(rows) ? rows : [])
|
if (!cancelled) setTypeDefinitions(rows)
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setTypeDefinitions([])
|
if (!cancelled) setTypeDefinitions([])
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import { QuestionTypeBasicInfoStep } from "./components/question-type-steps/Ques
|
||||||
import { QuestionTypeConfigStep } from "./components/question-type-steps/QuestionTypeConfigStep"
|
import { QuestionTypeConfigStep } from "./components/question-type-steps/QuestionTypeConfigStep"
|
||||||
import { QuestionTypeValidatePreviewStep } from "./components/question-type-steps/QuestionTypeValidatePreviewStep"
|
import { QuestionTypeValidatePreviewStep } from "./components/question-type-steps/QuestionTypeValidatePreviewStep"
|
||||||
import { QuestionTypeReviewPublishStep } from "./components/question-type-steps/QuestionTypeReviewPublishStep"
|
import { QuestionTypeReviewPublishStep } from "./components/question-type-steps/QuestionTypeReviewPublishStep"
|
||||||
|
import { defaultLabelForKind } from "../../lib/schemaSlotLabel"
|
||||||
|
|
||||||
const initialDraft = (): QuestionTypeDefinitionCreatePayload => ({
|
const initialDraft = (): QuestionTypeDefinitionCreatePayload => ({
|
||||||
key: "",
|
key: "",
|
||||||
|
|
@ -46,7 +47,7 @@ function seedSchemaFromKinds(kinds: string[]) {
|
||||||
return kinds.map((k, i) => ({
|
return kinds.map((k, i) => ({
|
||||||
id: `${(k || "field").toLowerCase().replace(/[^a-z0-9]+/g, "_") || "field"}_${i + 1}`,
|
id: `${(k || "field").toLowerCase().replace(/[^a-z0-9]+/g, "_") || "field"}_${i + 1}`,
|
||||||
kind: k,
|
kind: k,
|
||||||
label: k.replace(/_/g, " "),
|
label: defaultLabelForKind(k),
|
||||||
required: true as boolean,
|
required: true as boolean,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
@ -59,8 +60,14 @@ function definitionToDraft(def: QuestionTypeDefinition): QuestionTypeDefinitionC
|
||||||
status: def.status === "INACTIVE" ? "INACTIVE" : "ACTIVE",
|
status: def.status === "INACTIVE" ? "INACTIVE" : "ACTIVE",
|
||||||
stimulus_component_kinds: [...(def.stimulus_component_kinds ?? [])],
|
stimulus_component_kinds: [...(def.stimulus_component_kinds ?? [])],
|
||||||
response_component_kinds: [...(def.response_component_kinds ?? [])],
|
response_component_kinds: [...(def.response_component_kinds ?? [])],
|
||||||
stimulus_schema: (def.stimulus_schema ?? []).map((r) => ({ ...r })),
|
stimulus_schema: (def.stimulus_schema ?? []).map((r) => ({
|
||||||
response_schema: (def.response_schema ?? []).map((r) => ({ ...r })),
|
...r,
|
||||||
|
label: r.label?.trim() || defaultLabelForKind(r.kind),
|
||||||
|
})),
|
||||||
|
response_schema: (def.response_schema ?? []).map((r) => ({
|
||||||
|
...r,
|
||||||
|
label: r.label?.trim() || defaultLabelForKind(r.kind),
|
||||||
|
})),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,21 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { Link, useNavigate, useSearchParams } from "react-router-dom"
|
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 { toast } from "sonner"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
import { Input } from "../../components/ui/input"
|
import { Input } from "../../components/ui/input"
|
||||||
import { Card } from "../../components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -15,6 +26,7 @@ import {
|
||||||
} from "../../components/ui/dialog"
|
} from "../../components/ui/dialog"
|
||||||
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
|
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
|
||||||
import { QuestionTypeCard } from "./components/QuestionTypeCard"
|
import { QuestionTypeCard } from "./components/QuestionTypeCard"
|
||||||
import {
|
import {
|
||||||
deleteQuestionTypeDefinition,
|
deleteQuestionTypeDefinition,
|
||||||
|
|
@ -22,6 +34,23 @@ import {
|
||||||
} from "../../api/questionTypeDefinitions.api"
|
} from "../../api/questionTypeDefinitions.api"
|
||||||
import type { QuestionTypeDefinition } from "../../types/questionTypeDefinition.types"
|
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() {
|
export function QuestionTypeLibraryPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
|
|
@ -30,24 +59,48 @@ export function QuestionTypeLibraryPage() {
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [definitions, setDefinitions] = useState<QuestionTypeDefinition[]>([])
|
const [definitions, setDefinitions] = useState<QuestionTypeDefinition[]>([])
|
||||||
|
const [totalCount, setTotalCount] = useState<number | undefined>(undefined)
|
||||||
const [query, setQuery] = useState("")
|
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 [definitionPendingDelete, setDefinitionPendingDelete] = useState<QuestionTypeDefinition | null>(null)
|
||||||
const [deleteSubmitting, setDeleteSubmitting] = useState(false)
|
const [deleteSubmitting, setDeleteSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const hasActiveFilters =
|
||||||
|
query.trim().length > 0 || statusFilter !== "All" || scopeFilter !== "all"
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const rows = await getQuestionTypeDefinitions({ include_system: true })
|
const isSystemScope = scopeFilter === "system"
|
||||||
setDefinitions(Array.isArray(rows) ? rows : [])
|
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) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
toast.error("Failed to load question type definitions")
|
toast.error("Failed to load question type definitions")
|
||||||
setDefinitions([])
|
setDefinitions([])
|
||||||
|
setTotalCount(0)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [offset, pageSize, scopeFilter, statusFilter])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load()
|
void load()
|
||||||
|
|
@ -67,18 +120,36 @@ export function QuestionTypeLibraryPage() {
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const q = query.trim().toLowerCase()
|
const q = query.trim().toLowerCase()
|
||||||
|
if (!q) return definitions
|
||||||
return definitions.filter((d) => {
|
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 name = (d.display_name || "").toLowerCase()
|
||||||
const key = (d.key || "").toLowerCase()
|
const key = (d.key || "").toLowerCase()
|
||||||
return name.includes(q) || key.includes(q) || String(d.id).includes(q)
|
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) => {
|
const openDeleteConfirm = (row: QuestionTypeDefinition) => {
|
||||||
if (row.is_system) {
|
if (row.is_system) {
|
||||||
|
|
@ -136,48 +207,151 @@ export function QuestionTypeLibraryPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="p-6 border-grayScale-200 rounded-2xl bg-white space-y-6">
|
<Card className="overflow-hidden rounded-2xl border border-grayScale-200 bg-white shadow-none">
|
||||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-4">
|
<CardHeader className="border-b border-grayScale-100 px-6 py-5">
|
||||||
<div className="relative flex-1">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-grayScale-600" />
|
<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
|
<Input
|
||||||
className="h-10 pl-12 rounded-[6px] border-grayScale-200 placeholder:text-grayScale-600 bg-[#F8FAFC] transition-all text-sm"
|
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…"
|
placeholder="Search by display name, key, or id on this page…"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
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>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<span className="text-[12px] font-medium text-grayScale-400 uppercase tracking-widest mr-2">Status</span>
|
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||||
{(["All", "ACTIVE", "INACTIVE"] as const).map((tab) => (
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<button
|
<span className="mr-1 text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||||
key={tab}
|
Status
|
||||||
type="button"
|
</span>
|
||||||
onClick={() => setActiveTab(tab)}
|
{STATUS_OPTIONS.map(({ value, label }) => (
|
||||||
className={cn(
|
<FilterChip
|
||||||
"h-10 px-4 rounded-full text-[13px] font-medium transition-all",
|
key={value}
|
||||||
activeTab === tab
|
label={label}
|
||||||
? "bg-[#9E2891] text-white shadow-md shadow-brand-500/20"
|
active={statusFilter === value}
|
||||||
: "bg-grayScale-100 text-grayScale-400 hover:bg-grayScale-100",
|
disabled={loading}
|
||||||
)}
|
onClick={() => {
|
||||||
>
|
setStatusFilter(value)
|
||||||
{tab === "All" ? "All" : tab === "ACTIVE" ? "Active" : "Inactive"}
|
resetPagination()
|
||||||
</button>
|
}}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 ? (
|
{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 ? (
|
) : filtered.length === 0 ? (
|
||||||
<Card className="p-12 text-center border-dashed border-grayScale-200 rounded-2xl">
|
<div className="flex flex-col items-center justify-center gap-3 px-6 py-20 text-center">
|
||||||
<p className="text-grayScale-600 font-medium">No definitions match your filters.</p>
|
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-grayScale-100 text-grayScale-400">
|
||||||
<p className="text-sm text-grayScale-400 mt-2">Create one to get started.</p>
|
<Layers className="h-7 w-7" aria-hidden />
|
||||||
</Card>
|
</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) => (
|
{filtered.map((d) => (
|
||||||
<QuestionTypeCard
|
<QuestionTypeCard
|
||||||
key={d.id}
|
key={d.id}
|
||||||
|
|
@ -196,6 +370,78 @@ export function QuestionTypeLibraryPage() {
|
||||||
</div>
|
</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}>
|
<Dialog open={definitionPendingDelete !== null} onOpenChange={handleDeleteDialogOpenChange}>
|
||||||
<DialogContent className="max-w-md rounded-2xl border-grayScale-200 sm:max-w-md">
|
<DialogContent className="max-w-md rounded-2xl border-grayScale-200 sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|
@ -242,3 +488,32 @@ export function QuestionTypeLibraryPage() {
|
||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { Badge } from "../../../components/ui/badge"
|
||||||
import { Card } from "../../../components/ui/card"
|
import { Card } from "../../../components/ui/card"
|
||||||
import { Button } from "../../../components/ui/button"
|
import { Button } from "../../../components/ui/button"
|
||||||
|
|
@ -42,7 +42,12 @@ export function QuestionTypeCard({
|
||||||
<Shield className="h-3 w-3" />
|
<Shield className="h-3 w-3" />
|
||||||
System
|
System
|
||||||
</Badge>
|
</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>
|
</div>
|
||||||
|
|
||||||
<p className="text-[12px] font-mono text-grayScale-500 break-all">#{id} · {definitionKey}</p>
|
<p className="text-[12px] font-mono text-grayScale-500 break-all">#{id} · {definitionKey}</p>
|
||||||
|
|
|
||||||
|
|
@ -100,9 +100,9 @@ export function QuestionsStep({
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3 rounded-lg border border-violet-200 bg-violet-50/40 p-3">
|
<div className="space-y-3 rounded-lg border border-violet-200 bg-violet-50/40 p-3">
|
||||||
<p className="text-xs leading-snug text-grayScale-600">
|
<p className="text-xs leading-snug text-grayScale-600">
|
||||||
<span className="font-medium text-grayScale-800">Image / Audio / PDF</span> use upload or URL.{" "}
|
<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. Other
|
<span className="font-medium text-grayScale-800">Table</span> uses the visual table builder. Timer and
|
||||||
slots: text or structured JSON where noted.
|
prep-time slots use seconds. Other slots use text or structured JSON where noted.
|
||||||
</p>
|
</p>
|
||||||
{def.stimulus_schema.length > 0 ? (
|
{def.stimulus_schema.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState } from "react"
|
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 { Button } from "../../../../components/ui/button"
|
||||||
import { Card } from "../../../../components/ui/card"
|
import { Card } from "../../../../components/ui/card"
|
||||||
import { Input } from "../../../../components/ui/input"
|
import { Input } from "../../../../components/ui/input"
|
||||||
|
|
@ -9,8 +9,13 @@ import type {
|
||||||
} from "../../../../types/questionTypeDefinition.types"
|
} from "../../../../types/questionTypeDefinition.types"
|
||||||
import type { FieldErrorMap } from "../../lib/questionTypeDefinitionValidation"
|
import type { FieldErrorMap } from "../../lib/questionTypeDefinitionValidation"
|
||||||
import { SchemaBuilderSection } from "./SchemaBuilderSection"
|
import { SchemaBuilderSection } from "./SchemaBuilderSection"
|
||||||
|
import { SchemaSlotLabelsPanel } from "./SchemaSlotLabelsPanel"
|
||||||
import { ComponentKindCard } from "./ComponentKindCard"
|
import { ComponentKindCard } from "./ComponentKindCard"
|
||||||
import { getResponseKindPresentation, getStimulusKindPresentation } from "./componentKindUi"
|
import {
|
||||||
|
defaultLabelForKind,
|
||||||
|
getResponseKindPresentation,
|
||||||
|
getStimulusKindPresentation,
|
||||||
|
} from "./componentKindUi"
|
||||||
|
|
||||||
interface QuestionTypeConfigStepProps {
|
interface QuestionTypeConfigStepProps {
|
||||||
draft: QuestionTypeDefinitionCreatePayload
|
draft: QuestionTypeDefinitionCreatePayload
|
||||||
|
|
@ -33,10 +38,6 @@ function slugFragmentFromKind(kind: string): string {
|
||||||
return s.replace(/^_|_$/g, "") || "field"
|
return s.replace(/^_|_$/g, "") || "field"
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultSchemaLabel(kind: string): string {
|
|
||||||
return kind.replace(/_/g, " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextUniqueSchemaElementId(rows: DynamicElementDefinition[], kind: string): string {
|
function nextUniqueSchemaElementId(rows: DynamicElementDefinition[], kind: string): string {
|
||||||
const base = slugFragmentFromKind(kind)
|
const base = slugFragmentFromKind(kind)
|
||||||
const existing = new Set(rows.map((r) => r.id.trim()).filter(Boolean))
|
const existing = new Set(rows.map((r) => r.id.trim()).filter(Boolean))
|
||||||
|
|
@ -49,6 +50,22 @@ function nextUniqueSchemaElementId(rows: DynamicElementDefinition[], kind: strin
|
||||||
return id
|
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> {
|
function rowErrorMap(side: "stimulus" | "response", errors: FieldErrorMap): Record<number, string> {
|
||||||
const prefix = `${side}_`
|
const prefix = `${side}_`
|
||||||
const out: Record<number, string> = {}
|
const out: Record<number, string> = {}
|
||||||
|
|
@ -84,7 +101,7 @@ export function QuestionTypeConfigStep({
|
||||||
stimulus_schema.push({
|
stimulus_schema.push({
|
||||||
id: nextUniqueSchemaElementId(stimulus_schema, kind),
|
id: nextUniqueSchemaElementId(stimulus_schema, kind),
|
||||||
kind,
|
kind,
|
||||||
label: defaultSchemaLabel(kind),
|
label: defaultLabelForKind(kind),
|
||||||
required: true,
|
required: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -108,7 +125,7 @@ export function QuestionTypeConfigStep({
|
||||||
response_schema.push({
|
response_schema.push({
|
||||||
id: nextUniqueSchemaElementId(response_schema, kind),
|
id: nextUniqueSchemaElementId(response_schema, kind),
|
||||||
kind,
|
kind,
|
||||||
label: defaultSchemaLabel(kind),
|
label: defaultLabelForKind(kind),
|
||||||
required: true,
|
required: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -129,7 +146,7 @@ export function QuestionTypeConfigStep({
|
||||||
stimulus_schema.push({
|
stimulus_schema.push({
|
||||||
id: nextUniqueSchemaElementId(stimulus_schema, kind),
|
id: nextUniqueSchemaElementId(stimulus_schema, kind),
|
||||||
kind,
|
kind,
|
||||||
label: defaultSchemaLabel(kind),
|
label: defaultLabelForKind(kind),
|
||||||
required: true,
|
required: true,
|
||||||
})
|
})
|
||||||
return { ...d, stimulus_schema }
|
return { ...d, stimulus_schema }
|
||||||
|
|
@ -143,21 +160,59 @@ export function QuestionTypeConfigStep({
|
||||||
response_schema.push({
|
response_schema.push({
|
||||||
id: nextUniqueSchemaElementId(response_schema, kind),
|
id: nextUniqueSchemaElementId(response_schema, kind),
|
||||||
kind,
|
kind,
|
||||||
label: defaultSchemaLabel(kind),
|
label: defaultLabelForKind(kind),
|
||||||
required: true,
|
required: true,
|
||||||
})
|
})
|
||||||
return { ...d, response_schema }
|
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 (
|
return (
|
||||||
<div className="space-y-8 pb-32">
|
<div className="space-y-8 pb-32">
|
||||||
<Card className="max-w-6xl mx-auto overflow-hidden border border-grayScale-200 shadow-sm rounded-2xl bg-white">
|
<Card className="max-w-6xl mx-auto overflow-hidden border border-grayScale-200 shadow-sm rounded-2xl bg-white">
|
||||||
<div className="p-10 border-b border-grayScale-200">
|
<div className="p-10 border-b border-grayScale-200">
|
||||||
<h2 className="text-[20px] font-medium text-grayScale-900">STEP 2: Input & answer types</h2>
|
<h2 className="text-[20px] font-medium text-grayScale-900">STEP 2: Input & answer types</h2>
|
||||||
<p className="text-grayScale-500 font-medium mt-1">
|
<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
|
Choose what learners see in the question and how they respond. Add or remove slots for each type
|
||||||
same type when needed.
|
as needed.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -174,8 +229,8 @@ export function QuestionTypeConfigStep({
|
||||||
Section A: Question input types
|
Section A: Question input types
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[14px] text-grayScale-500 mt-1 font-medium">
|
<p className="text-[14px] text-grayScale-500 mt-1 font-medium">
|
||||||
Choose how the question is presented to the learner. Use Add slot for multiple fields of
|
Choose how the question is presented to the learner. Use Add slot / Remove slot to adjust
|
||||||
the same type (for example, two text blocks).
|
how many fields of each type you need.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
||||||
|
|
@ -196,17 +251,32 @@ export function QuestionTypeConfigStep({
|
||||||
<span className="text-[12px] text-grayScale-500 font-medium">
|
<span className="text-[12px] text-grayScale-500 font-medium">
|
||||||
{slotCount} slot{slotCount === 1 ? "" : "s"}
|
{slotCount} slot{slotCount === 1 ? "" : "s"}
|
||||||
</span>
|
</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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 shrink-0 text-[12px] font-bold text-[#9E2891] hover:text-[#8A237E] hover:bg-violet-50"
|
className="h-8 shrink-0 text-[12px] font-bold text-[#9E2891] hover:text-[#8A237E] hover:bg-violet-50"
|
||||||
onClick={() => addStimulusSlot(kind)}
|
onClick={() => addStimulusSlot(kind)}
|
||||||
|
aria-label={`Add ${label} slot`}
|
||||||
>
|
>
|
||||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||||
Add slot
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -223,8 +293,8 @@ export function QuestionTypeConfigStep({
|
||||||
Section B: Answer types
|
Section B: Answer types
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[14px] text-grayScale-500 mt-1 font-medium">
|
<p className="text-[14px] text-grayScale-500 mt-1 font-medium">
|
||||||
How should the student answer? Use Add slot when you need more than one field of the same
|
How should the student answer? Use Add slot / Remove slot to adjust how many answer fields
|
||||||
answer type.
|
each type needs.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
||||||
|
|
@ -245,17 +315,32 @@ export function QuestionTypeConfigStep({
|
||||||
<span className="text-[12px] text-grayScale-500 font-medium">
|
<span className="text-[12px] text-grayScale-500 font-medium">
|
||||||
{slotCount} slot{slotCount === 1 ? "" : "s"}
|
{slotCount} slot{slotCount === 1 ? "" : "s"}
|
||||||
</span>
|
</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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 shrink-0 text-[12px] font-bold text-[#9E2891] hover:text-[#8A237E] hover:bg-violet-50"
|
className="h-8 shrink-0 text-[12px] font-bold text-[#9E2891] hover:text-[#8A237E] hover:bg-violet-50"
|
||||||
onClick={() => addResponseSlot(kind)}
|
onClick={() => addResponseSlot(kind)}
|
||||||
|
aria-label={`Add ${label} slot`}
|
||||||
>
|
>
|
||||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||||
Add slot
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -268,6 +353,14 @@ export function QuestionTypeConfigStep({
|
||||||
</div>
|
</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">
|
<div className="rounded-xl border border-grayScale-200 bg-grayScale-50/50 overflow-hidden">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -289,7 +382,7 @@ export function QuestionTypeConfigStep({
|
||||||
allowedKinds={draft.stimulus_component_kinds}
|
allowedKinds={draft.stimulus_component_kinds}
|
||||||
catalogKinds={stimulusCatalogKinds}
|
catalogKinds={stimulusCatalogKinds}
|
||||||
rows={draft.stimulus_schema}
|
rows={draft.stimulus_schema}
|
||||||
onChange={(rows) => setDraft((d) => ({ ...d, stimulus_schema: rows }))}
|
onChange={setStimulusSchema}
|
||||||
error={errors.stimulus_schema}
|
error={errors.stimulus_schema}
|
||||||
rowErrors={rowErrorMap("stimulus", errors)}
|
rowErrors={rowErrorMap("stimulus", errors)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -299,7 +392,7 @@ export function QuestionTypeConfigStep({
|
||||||
allowedKinds={draft.response_component_kinds}
|
allowedKinds={draft.response_component_kinds}
|
||||||
catalogKinds={responseCatalogKinds}
|
catalogKinds={responseCatalogKinds}
|
||||||
rows={draft.response_schema}
|
rows={draft.response_schema}
|
||||||
onChange={(rows) => setDraft((d) => ({ ...d, response_schema: rows }))}
|
onChange={setResponseSchema}
|
||||||
error={errors.response_schema}
|
error={errors.response_schema}
|
||||||
rowErrors={rowErrorMap("response", errors)}
|
rowErrors={rowErrorMap("response", errors)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
inferRuntimeQuestionType,
|
inferRuntimeQuestionType,
|
||||||
} from "../../lib/questionTypeDefinitionValidation"
|
} from "../../lib/questionTypeDefinitionValidation"
|
||||||
import { DefinitionRuntimeHint } from "./DefinitionRuntimeHint"
|
import { DefinitionRuntimeHint } from "./DefinitionRuntimeHint"
|
||||||
|
import { slotLabel } from "./componentKindUi"
|
||||||
|
|
||||||
interface QuestionTypeReviewPublishStepProps {
|
interface QuestionTypeReviewPublishStepProps {
|
||||||
draft: QuestionTypeDefinitionCreatePayload
|
draft: QuestionTypeDefinitionCreatePayload
|
||||||
|
|
@ -218,9 +219,11 @@ function SchemaSlotSummary({
|
||||||
<ul className="mt-2 space-y-1.5 text-sm">
|
<ul className="mt-2 space-y-1.5 text-sm">
|
||||||
{rows.map((r) => (
|
{rows.map((r) => (
|
||||||
<li key={`${r.kind}-${r.id}`} className="flex flex-wrap gap-x-2 text-grayScale-800">
|
<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 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 ? (
|
{r.required ? (
|
||||||
<span className="text-[10px] font-bold uppercase text-brand-600">required</span>
|
<span className="text-[10px] font-bold uppercase text-brand-600">required</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -126,11 +126,19 @@ export function QuestionTypeValidatePreviewStep({
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-[11px] font-bold uppercase text-grayScale-400">Stimulus slots</dt>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-[11px] font-bold uppercase text-grayScale-400">Response slots</dt>
|
<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>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,11 @@ import { Input } from "../../../../components/ui/input"
|
||||||
import { Textarea } from "../../../../components/ui/textarea"
|
import { Textarea } from "../../../../components/ui/textarea"
|
||||||
import { Select } from "../../../../components/ui/select"
|
import { Select } from "../../../../components/ui/select"
|
||||||
import type { DynamicElementDefinition } from "../../../../types/questionTypeDefinition.types"
|
import type { DynamicElementDefinition } from "../../../../types/questionTypeDefinition.types"
|
||||||
|
import {
|
||||||
|
defaultLabelForKind,
|
||||||
|
getResponseKindPresentation,
|
||||||
|
getStimulusKindPresentation,
|
||||||
|
} from "./componentKindUi"
|
||||||
|
|
||||||
type Side = "stimulus" | "response"
|
type Side = "stimulus" | "response"
|
||||||
|
|
||||||
|
|
@ -23,7 +28,7 @@ function emptyRow(allowedKinds: string[]): DynamicElementDefinition {
|
||||||
return {
|
return {
|
||||||
id: "",
|
id: "",
|
||||||
kind: first,
|
kind: first,
|
||||||
label: "",
|
label: first ? defaultLabelForKind(first) : "",
|
||||||
required: true,
|
required: true,
|
||||||
config: undefined,
|
config: undefined,
|
||||||
}
|
}
|
||||||
|
|
@ -43,10 +48,24 @@ export function SchemaBuilderSection({
|
||||||
allowedKinds.length > 0 ? allowedKinds : catalogKinds.length > 0 ? catalogKinds : []
|
allowedKinds.length > 0 ? allowedKinds : catalogKinds.length > 0 ? catalogKinds : []
|
||||||
|
|
||||||
const updateRow = (index: number, patch: Partial<DynamicElementDefinition>) => {
|
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)
|
onChange(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const kindPresentation = (kind: string) =>
|
||||||
|
side === "stimulus" ? getStimulusKindPresentation(kind) : getResponseKindPresentation(kind)
|
||||||
|
|
||||||
const commitConfigString = (index: number, raw: string) => {
|
const commitConfigString = (index: number, raw: string) => {
|
||||||
const trimmed = raw.trim()
|
const trimmed = raw.trim()
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
|
|
@ -73,9 +92,8 @@ export function SchemaBuilderSection({
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-[16px] font-bold text-grayScale-900">{title}</h3>
|
<h3 className="text-[16px] font-bold text-grayScale-900">{title}</h3>
|
||||||
<p className="text-[13px] text-grayScale-500 mt-0.5">
|
<p className="text-[13px] text-grayScale-500 mt-0.5">
|
||||||
Each row defines one element in the{" "}
|
Fine-tune slot ids, labels, required flags, and optional config. Labels are the field titles
|
||||||
{side === "stimulus" ? "stimulus" : "response"} schema (id, kind, label, required, optional JSON
|
authors see when creating questions.
|
||||||
config).
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -145,7 +163,7 @@ export function SchemaBuilderSection({
|
||||||
>
|
>
|
||||||
{kindOptions.map((k) => (
|
{kindOptions.map((k) => (
|
||||||
<option key={k} value={k}>
|
<option key={k} value={k}>
|
||||||
{k}
|
{kindPresentation(k).label} ({k})
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
|
@ -154,11 +172,13 @@ export function SchemaBuilderSection({
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<div className="space-y-1.5">
|
<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
|
<Input
|
||||||
value={row.label ?? ""}
|
value={row.label ?? ""}
|
||||||
onChange={(e) => updateRow(index, { label: e.target.value })}
|
onChange={(e) => updateRow(index, { label: e.target.value })}
|
||||||
placeholder="Author-facing label"
|
placeholder={defaultLabelForKind(row.kind)}
|
||||||
className="h-10 bg-white"
|
className="h-10 bg-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -20,35 +20,35 @@ import {
|
||||||
Volume2,
|
Volume2,
|
||||||
} from "lucide-react"
|
} 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> = {
|
const STIMULUS_LABELS: Record<string, string> = {
|
||||||
QUESTION_TEXT: "Question Text",
|
QUESTION_TEXT: defaultLabelForKind("QUESTION_TEXT"),
|
||||||
PREP_TIME: "Prep Time",
|
PREP_TIME: defaultLabelForKind("PREP_TIME"),
|
||||||
INSTRUCTION: "Instruction",
|
INSTRUCTION: defaultLabelForKind("INSTRUCTION"),
|
||||||
AUDIO_PROMPT: "Audio Prompt",
|
AUDIO_PROMPT: defaultLabelForKind("AUDIO_PROMPT"),
|
||||||
AUDIO_CLIP: "Audio Clip",
|
TEXT_PASSAGE: defaultLabelForKind("TEXT_PASSAGE"),
|
||||||
TEXT_PASSAGE: "Text Passage",
|
IMAGE: defaultLabelForKind("IMAGE"),
|
||||||
IMAGE: "Image",
|
MATCHING_INPUTS: defaultLabelForKind("MATCHING_INPUTS"),
|
||||||
CHART: "Chart",
|
SELECT_MISSING_WORDS: defaultLabelForKind("SELECT_MISSING_WORDS"),
|
||||||
MATCHING_INPUTS: "Matching Inputs",
|
TABLE: defaultLabelForKind("TABLE"),
|
||||||
SELECT_MISSING_WORDS: "Select Missing Words",
|
PDF_ATTACHMENT: defaultLabelForKind("PDF_ATTACHMENT"),
|
||||||
TABLE: "Table",
|
|
||||||
FLOW_CHART: "Flow Chart",
|
|
||||||
PDF_ATTACHMENT: "PDF Attachment",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const RESPONSE_LABELS: Record<string, string> = {
|
const RESPONSE_LABELS: Record<string, string> = {
|
||||||
AUDIO_RESPONSE: "Audio Response",
|
AUDIO_RESPONSE: defaultLabelForKind("AUDIO_RESPONSE"),
|
||||||
TEXT_INPUT: "Text Input",
|
TEXT_INPUT: defaultLabelForKind("TEXT_INPUT"),
|
||||||
SHORT_ANSWER: "Short Answer",
|
SHORT_ANSWER: defaultLabelForKind("SHORT_ANSWER"),
|
||||||
MULTIPLE_CHOICE: "Multiple Choice",
|
MULTIPLE_CHOICE: defaultLabelForKind("MULTIPLE_CHOICE"),
|
||||||
OPTION: "Options",
|
OPTION: defaultLabelForKind("OPTION"),
|
||||||
ANSWER_TIMER: "Answer Timer",
|
ANSWER_TIMER: defaultLabelForKind("ANSWER_TIMER"),
|
||||||
SELECT_MISSING_WORDS: "Select Missing Words",
|
SELECT_MISSING_WORDS: defaultLabelForKind("SELECT_MISSING_WORDS"),
|
||||||
PDF_UPLOAD: "PDF Upload",
|
PDF_UPLOAD: defaultLabelForKind("PDF_UPLOAD"),
|
||||||
MATCHING_ANSWER: "Matching Answer",
|
MATCHING_ANSWER: defaultLabelForKind("MATCHING_ANSWER"),
|
||||||
LABEL_SELECTION: "Label Selection",
|
LABEL_SELECTION: defaultLabelForKind("LABEL_SELECTION"),
|
||||||
SEQUENCE_ORDER: "Sequence Order",
|
SEQUENCE_ORDER: defaultLabelForKind("SEQUENCE_ORDER"),
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Legacy screenshot labels → map to closest API kind for display only (same code path). */
|
/** 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,
|
AUDIO_CLIP: Volume2,
|
||||||
TEXT_PASSAGE: FileText,
|
TEXT_PASSAGE: FileText,
|
||||||
IMAGE: ImageIcon,
|
IMAGE: ImageIcon,
|
||||||
CHART: BarChart3,
|
|
||||||
MATCHING_INPUTS: Link2,
|
MATCHING_INPUTS: Link2,
|
||||||
SELECT_MISSING_WORDS: ListTodo,
|
SELECT_MISSING_WORDS: ListTodo,
|
||||||
TABLE: TableIcon,
|
TABLE: TableIcon,
|
||||||
FLOW_CHART: GitBranch,
|
|
||||||
PDF_ATTACHMENT: FileUp,
|
PDF_ATTACHMENT: FileUp,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,13 +82,6 @@ const RESPONSE_ICONS: Record<string, LucideIcon> = {
|
||||||
|
|
||||||
const DEFAULT_ICON = FileText
|
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 } {
|
export function getStimulusKindPresentation(kind: string): { label: string; Icon: LucideIcon } {
|
||||||
return {
|
return {
|
||||||
label: STIMULUS_LABELS[kind] ?? humanizeKind(kind),
|
label: STIMULUS_LABELS[kind] ?? humanizeKind(kind),
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import type {
|
||||||
QuestionComponentCatalog,
|
QuestionComponentCatalog,
|
||||||
QuestionTypeDefinitionCreatePayload,
|
QuestionTypeDefinitionCreatePayload,
|
||||||
} from "../../../types/questionTypeDefinition.types"
|
} from "../../../types/questionTypeDefinition.types"
|
||||||
|
import { defaultLabelForKind } from "../../../lib/schemaSlotLabel"
|
||||||
|
|
||||||
export type FieldErrorMap = Record<string, string>
|
export type FieldErrorMap = Record<string, string>
|
||||||
|
|
||||||
|
|
@ -99,13 +100,18 @@ export function validateDefinitionSchemas(
|
||||||
const allowedSet = new Set(allowed)
|
const allowedSet = new Set(allowed)
|
||||||
const catalogSet = side === "stimulus" ? catalog.stimulus : catalog.response
|
const catalogSet = side === "stimulus" ? catalog.stimulus : catalog.response
|
||||||
rows.forEach((row, i) => {
|
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)) {
|
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)) {
|
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,
|
...r,
|
||||||
id: r.id.trim(),
|
id: r.id.trim(),
|
||||||
kind: r.kind.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,
|
config: r.config && Object.keys(r.config).length ? r.config : undefined,
|
||||||
}))
|
}))
|
||||||
const response_schema = draft.response_schema.map((r) => ({
|
const response_schema = draft.response_schema.map((r) => ({
|
||||||
...r,
|
...r,
|
||||||
id: r.id.trim(),
|
id: r.id.trim(),
|
||||||
kind: r.kind.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,
|
config: r.config && Object.keys(r.config).length ? r.config : undefined,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,9 @@ import {
|
||||||
Bell,
|
Bell,
|
||||||
BellOff,
|
BellOff,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Info,
|
|
||||||
AlertCircle,
|
|
||||||
CheckCircle2,
|
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Megaphone,
|
Megaphone,
|
||||||
UserPlus,
|
|
||||||
CreditCard,
|
|
||||||
BookOpen,
|
|
||||||
Video,
|
|
||||||
ShieldAlert,
|
|
||||||
MailOpen,
|
MailOpen,
|
||||||
Mail,
|
Mail,
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
|
|
@ -53,6 +45,7 @@ import { cn } from "../../lib/utils"
|
||||||
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
import {
|
import {
|
||||||
|
getNotificationById,
|
||||||
getNotifications,
|
getNotifications,
|
||||||
getUnreadCount,
|
getUnreadCount,
|
||||||
markAsRead,
|
markAsRead,
|
||||||
|
|
@ -63,6 +56,14 @@ import {
|
||||||
sendBulkEmail,
|
sendBulkEmail,
|
||||||
sendBulkPush,
|
sendBulkPush,
|
||||||
} from "../../api/notifications.api"
|
} 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 { getRoles } from "../../api/rbac.api"
|
||||||
import { getTeamMembers } from "../../api/team.api"
|
import { getTeamMembers } from "../../api/team.api"
|
||||||
import { getUsers } from "../../api/users.api"
|
import { getUsers } from "../../api/users.api"
|
||||||
|
|
@ -73,68 +74,6 @@ import type { UserApiDTO } from "../../types/user.types"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
|
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) {
|
function digitsOnly(value: string, maxLength: number) {
|
||||||
return value.replace(/\D/g, "").slice(0, maxLength)
|
return value.replace(/\D/g, "").slice(0, maxLength)
|
||||||
}
|
}
|
||||||
|
|
@ -148,7 +87,7 @@ function NotificationItem({
|
||||||
onToggleRead: (id: string, currentlyRead: boolean) => void
|
onToggleRead: (id: string, currentlyRead: boolean) => void
|
||||||
toggling: boolean
|
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
|
const Icon = config.icon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -189,7 +128,7 @@ function NotificationItem({
|
||||||
>
|
>
|
||||||
{getNotificationTitle(notification)}
|
{getNotificationTitle(notification)}
|
||||||
</span>
|
</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}
|
{notification.level}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -205,7 +144,7 @@ function NotificationItem({
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
<span className="text-xs text-grayScale-400">
|
<span className="text-xs text-grayScale-400">
|
||||||
{formatTimestamp(notification.timestamp)}
|
{formatNotificationTimestamp(notification.timestamp)}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -235,7 +174,7 @@ function NotificationItem({
|
||||||
{/* Meta row */}
|
{/* Meta row */}
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
<Badge variant="secondary" className="text-[10px] px-2 py-0">
|
<Badge variant="secondary" className="text-[10px] px-2 py-0">
|
||||||
{formatTypeLabel(notification.type)}
|
{formatNotificationTypeLabel(notification.type)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant="secondary" className="text-[10px] px-2 py-0">
|
<Badge variant="secondary" className="text-[10px] px-2 py-0">
|
||||||
{notification.delivery_channel}
|
{notification.delivery_channel}
|
||||||
|
|
@ -270,7 +209,10 @@ export function NotificationsPage() {
|
||||||
const [togglingIds, setTogglingIds] = useState<Set<string>>(new Set())
|
const [togglingIds, setTogglingIds] = useState<Set<string>>(new Set())
|
||||||
const [bulkLoading, setBulkLoading] = useState(false)
|
const [bulkLoading, setBulkLoading] = useState(false)
|
||||||
const [selectedNotification, setSelectedNotification] = useState<Notification | null>(null)
|
const [selectedNotification, setSelectedNotification] = useState<Notification | null>(null)
|
||||||
|
const [selectedNotificationId, setSelectedNotificationId] = useState<string | null>(null)
|
||||||
const [detailOpen, setDetailOpen] = useState(false)
|
const [detailOpen, setDetailOpen] = useState(false)
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false)
|
||||||
|
const [detailError, setDetailError] = useState(false)
|
||||||
|
|
||||||
const [channelFilter, setChannelFilter] = useState<"all" | "push" | "sms">("all")
|
const [channelFilter, setChannelFilter] = useState<"all" | "push" | "sms">("all")
|
||||||
const [activeStatusTab, setActiveStatusTab] = useState<"all" | "read" | "unread">("all")
|
const [activeStatusTab, setActiveStatusTab] = useState<"all" | "read" | "unread">("all")
|
||||||
|
|
@ -525,7 +467,7 @@ export function NotificationsPage() {
|
||||||
const haystack = [
|
const haystack = [
|
||||||
getNotificationTitle(n),
|
getNotificationTitle(n),
|
||||||
getNotificationMessage(n),
|
getNotificationMessage(n),
|
||||||
formatTypeLabel(n.type),
|
formatNotificationTypeLabel(n.type),
|
||||||
n.delivery_channel,
|
n.delivery_channel,
|
||||||
n.level,
|
n.level,
|
||||||
]
|
]
|
||||||
|
|
@ -537,9 +479,42 @@ export function NotificationsPage() {
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleOpenDetail = (notification: Notification) => {
|
const loadNotificationDetail = useCallback(async (id: string) => {
|
||||||
setSelectedNotification(notification)
|
setDetailLoading(true)
|
||||||
|
setDetailError(false)
|
||||||
|
setSelectedNotification(null)
|
||||||
|
setSelectedNotificationId(id)
|
||||||
setDetailOpen(true)
|
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 (
|
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"
|
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">
|
<span className="truncate">
|
||||||
{typeFilter === "all" ? "All types" : formatTypeLabel(typeFilter)}
|
{typeFilter === "all" ? "All types" : formatNotificationTypeLabel(typeFilter)}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="ml-2 h-3.5 w-3.5 text-grayScale-400" />
|
<ChevronDown className="ml-2 h-3.5 w-3.5 text-grayScale-400" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -766,7 +741,7 @@ export function NotificationsPage() {
|
||||||
<DropdownMenuRadioItem value="all">All types</DropdownMenuRadioItem>
|
<DropdownMenuRadioItem value="all">All types</DropdownMenuRadioItem>
|
||||||
{Array.from(new Set(notifications.map((n) => n.type))).map((t) => (
|
{Array.from(new Set(notifications.map((n) => n.type))).map((t) => (
|
||||||
<DropdownMenuRadioItem key={t} value={t}>
|
<DropdownMenuRadioItem key={t} value={t}>
|
||||||
{formatTypeLabel(t)}
|
{formatNotificationTypeLabel(t)}
|
||||||
</DropdownMenuRadioItem>
|
</DropdownMenuRadioItem>
|
||||||
))}
|
))}
|
||||||
</DropdownMenuRadioGroup>
|
</DropdownMenuRadioGroup>
|
||||||
|
|
@ -830,7 +805,7 @@ export function NotificationsPage() {
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
filteredNotifications.map((n) => {
|
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 Icon = config.icon
|
||||||
const isToggling = togglingIds.has(n.id)
|
const isToggling = togglingIds.has(n.id)
|
||||||
return (
|
return (
|
||||||
|
|
@ -854,7 +829,7 @@ export function NotificationsPage() {
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-medium text-grayScale-600">
|
<span className="text-xs font-medium text-grayScale-600">
|
||||||
{formatTypeLabel(n.type)}
|
{formatNotificationTypeLabel(n.type)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -880,7 +855,7 @@ export function NotificationsPage() {
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge
|
<Badge
|
||||||
variant={getLevelBadge(n.level)}
|
variant={getNotificationLevelBadge(n.level)}
|
||||||
className="text-[10px] uppercase tracking-wide"
|
className="text-[10px] uppercase tracking-wide"
|
||||||
>
|
>
|
||||||
{n.is_read ? "Read" : "Unread"}
|
{n.is_read ? "Read" : "Unread"}
|
||||||
|
|
@ -888,7 +863,7 @@ export function NotificationsPage() {
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="hidden sm:table-cell">
|
<TableCell className="hidden sm:table-cell">
|
||||||
<span className="text-xs text-grayScale-400">
|
<span className="text-xs text-grayScale-400">
|
||||||
{formatTimestamp(n.timestamp)}
|
{formatNotificationTimestamp(n.timestamp)}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
|
|
@ -1005,66 +980,18 @@ export function NotificationsPage() {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Detail dialog */}
|
<NotificationDetailDialog
|
||||||
{selectedNotification && (
|
open={detailOpen}
|
||||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
onOpenChange={setDetailOpen}
|
||||||
<DialogContent>
|
notification={selectedNotification}
|
||||||
<DialogHeader>
|
loading={detailLoading}
|
||||||
<DialogTitle className="flex items-center gap-2">
|
error={detailError}
|
||||||
<span className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-brand-50 text-brand-600">
|
onRetry={
|
||||||
{(() => {
|
selectedNotificationId
|
||||||
const Icon =
|
? () => void loadNotificationDetail(selectedNotificationId)
|
||||||
(TYPE_CONFIG[selectedNotification.type] ?? DEFAULT_TYPE_CONFIG).icon
|
: undefined
|
||||||
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 */}
|
{/* Bulk send dialog */}
|
||||||
<Dialog open={bulkOpen} onOpenChange={setBulkOpen}>
|
<Dialog open={bulkOpen} onOpenChange={setBulkOpen}>
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ export function AppVersionsTab() {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(false)
|
setError(false)
|
||||||
try {
|
try {
|
||||||
const res = await getAppVersions({ limit: PAGE_SIZE, offset })
|
const res = await getAppVersions({ limit: pageSize, offset })
|
||||||
setVersions(res.data.versions)
|
setVersions(res.data.versions)
|
||||||
setTotalCount(res.data.total_count)
|
setTotalCount(res.data.total_count)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -187,7 +187,7 @@ export function AppVersionsTab() {
|
||||||
<TabletSmartphone className="h-5 w-5" />
|
<TabletSmartphone className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p className="text-2xl font-bold text-grayScale-900">{totalCount}</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ export interface NotificationPayload {
|
||||||
export interface Notification {
|
export interface Notification {
|
||||||
id: string
|
id: string
|
||||||
recipient_id: number
|
recipient_id: number
|
||||||
|
receiver_type?: string
|
||||||
type: string
|
type: string
|
||||||
level: string
|
level: string
|
||||||
error_severity: string
|
error_severity: string
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user