Flow builder save buttons and category sub-creator
Made-with: Cursor
This commit is contained in:
parent
089c1ac869
commit
63f0ff9157
|
|
@ -24,6 +24,8 @@ export function CourseCategoryPage() {
|
|||
const [newCategoryName, setNewCategoryName] = useState("")
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [parentCategoryId, setParentCategoryId] = useState<number | null>(null)
|
||||
const [newSubCategoryName, setNewSubCategoryName] = useState("")
|
||||
const [pendingSubCategories, setPendingSubCategories] = useState<string[]>([])
|
||||
|
||||
const fetchCategories = async () => {
|
||||
setLoading(true)
|
||||
|
|
@ -154,7 +156,7 @@ export function CourseCategoryPage() {
|
|||
|
||||
{/* Create category dialog */}
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Plus className="h-4 w-4 text-brand-500" />
|
||||
|
|
@ -165,6 +167,7 @@ export function CourseCategoryPage() {
|
|||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-4 grid gap-6 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1.4fr)]">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
|
||||
|
|
@ -193,6 +196,79 @@ export function CourseCategoryPage() {
|
|||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<p className="mt-1 text-[11px] text-grayScale-400">
|
||||
When left empty, this becomes a parent category. Any sub categories you add on the
|
||||
right will be created under it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs font-semibold text-grayScale-600">
|
||||
Sub categories for this category (optional)
|
||||
</p>
|
||||
{pendingSubCategories.length > 0 && (
|
||||
<span className="text-[11px] text-grayScale-400">
|
||||
{pendingSubCategories.length} sub categor
|
||||
{pendingSubCategories.length === 1 ? "y" : "ies"} to create
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="e.g. Grammar basics, Speaking, Exam practice"
|
||||
value={newSubCategoryName}
|
||||
onChange={(e) => setNewSubCategoryName(e.target.value)}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-9 px-3 text-xs"
|
||||
onClick={() => {
|
||||
const name = newSubCategoryName.trim()
|
||||
if (!name) return
|
||||
if (pendingSubCategories.includes(name)) {
|
||||
setNewSubCategoryName("")
|
||||
return
|
||||
}
|
||||
setPendingSubCategories((prev) => [...prev, name])
|
||||
setNewSubCategoryName("")
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-[3.5rem] rounded-lg border border-dashed border-grayScale-200 bg-grayScale-50/60 p-2">
|
||||
{pendingSubCategories.length === 0 ? (
|
||||
<p className="text-[11px] leading-relaxed text-grayScale-400">
|
||||
Added sub categories will appear here so you can visually confirm the structure
|
||||
before saving. This is optional.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{pendingSubCategories.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
className="group inline-flex items-center gap-1 rounded-full bg-white px-2 py-0.5 text-[11px] text-grayScale-600 shadow-sm ring-1 ring-grayScale-200 hover:bg-red-50 hover:text-red-600 hover:ring-red-200"
|
||||
onClick={() =>
|
||||
setPendingSubCategories((prev) =>
|
||||
prev.filter((subName) => subName !== name),
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="max-w-[160px] truncate">{name}</span>
|
||||
<span className="text-[10px] text-grayScale-300 group-hover:text-red-400">
|
||||
×
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -202,6 +278,9 @@ export function CourseCategoryPage() {
|
|||
onClick={() => {
|
||||
setCreateOpen(false)
|
||||
setNewCategoryName("")
|
||||
setParentCategoryId(null)
|
||||
setNewSubCategoryName("")
|
||||
setPendingSubCategories([])
|
||||
}}
|
||||
disabled={creating}
|
||||
>
|
||||
|
|
@ -214,15 +293,48 @@ export function CourseCategoryPage() {
|
|||
if (!newCategoryName.trim()) return
|
||||
setCreating(true)
|
||||
try {
|
||||
await createCourseCategory({
|
||||
const name = newCategoryName.trim()
|
||||
const parentPayloadId = parentCategoryId ?? null
|
||||
const parentRes = await createCourseCategory({
|
||||
name: newCategoryName.trim(),
|
||||
parent_id: parentCategoryId ?? null,
|
||||
parent_id: parentPayloadId,
|
||||
})
|
||||
let createdCategoryId: number | null = null
|
||||
try {
|
||||
const data: any = parentRes?.data
|
||||
createdCategoryId =
|
||||
data?.data?.category?.id ??
|
||||
data?.data?.id ??
|
||||
data?.category?.id ??
|
||||
data?.id ??
|
||||
null
|
||||
} catch {
|
||||
createdCategoryId = null
|
||||
}
|
||||
|
||||
if (createdCategoryId && pendingSubCategories.length > 0) {
|
||||
await Promise.all(
|
||||
pendingSubCategories.map((subName) =>
|
||||
createCourseCategory({
|
||||
name: subName,
|
||||
parent_id: createdCategoryId,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
toast.success("Category created", {
|
||||
description: `"${newCategoryName.trim()}" has been added.`,
|
||||
description:
|
||||
pendingSubCategories.length > 0
|
||||
? `"${name}" and ${pendingSubCategories.length} sub categor${
|
||||
pendingSubCategories.length === 1 ? "y" : "ies"
|
||||
} have been added.`
|
||||
: `"${name}" has been added.`,
|
||||
})
|
||||
setNewCategoryName("")
|
||||
setParentCategoryId(null)
|
||||
setNewSubCategoryName("")
|
||||
setPendingSubCategories([])
|
||||
setCreateOpen(false)
|
||||
fetchCategories()
|
||||
} catch (err: any) {
|
||||
|
|
|
|||
|
|
@ -61,6 +61,9 @@ export function CourseFlowBuilderPage() {
|
|||
// Order of sub category ids for the selected parent (scope = sub)
|
||||
const [subCategoryOrder, setSubCategoryOrder] = useState<string[]>([])
|
||||
const [dragCategoryId, setDragCategoryId] = useState<string | null>(null)
|
||||
const [parentOrderDirty, setParentOrderDirty] = useState(false)
|
||||
const [subOrderDirty, setSubOrderDirty] = useState(false)
|
||||
const [stepsDirty, setStepsDirty] = useState(false)
|
||||
|
||||
const parentCategories = useMemo(
|
||||
() => categories.filter((c) => !c.parent_id),
|
||||
|
|
@ -147,6 +150,7 @@ export function CourseFlowBuilderPage() {
|
|||
const parsed: string[] = JSON.parse(raw)
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
setParentCategoryOrder(parsed)
|
||||
setParentOrderDirty(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -154,14 +158,9 @@ export function CourseFlowBuilderPage() {
|
|||
// ignore
|
||||
}
|
||||
setParentCategoryOrder(parentCategories.map((c) => String(c.id)))
|
||||
setParentOrderDirty(false)
|
||||
}, [parentCategories.length])
|
||||
|
||||
// Persist parent category order
|
||||
useEffect(() => {
|
||||
if (parentCategoryOrder.length === 0) return
|
||||
window.localStorage.setItem(PARENT_ORDER_KEY, JSON.stringify(parentCategoryOrder))
|
||||
}, [parentCategoryOrder])
|
||||
|
||||
// Load sub category order for selected parent
|
||||
useEffect(() => {
|
||||
if (!selectedParentCategoryId || subCategoriesForParent.length === 0) {
|
||||
|
|
@ -175,6 +174,7 @@ export function CourseFlowBuilderPage() {
|
|||
const parsed: string[] = JSON.parse(raw)
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
setSubCategoryOrder(parsed)
|
||||
setSubOrderDirty(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -182,21 +182,14 @@ export function CourseFlowBuilderPage() {
|
|||
// ignore
|
||||
}
|
||||
setSubCategoryOrder(subCategoriesForParent.map((c) => String(c.id)))
|
||||
setSubOrderDirty(false)
|
||||
}, [selectedParentCategoryId, subCategoriesForParent.length])
|
||||
|
||||
// Persist sub category order for selected parent
|
||||
useEffect(() => {
|
||||
if (!selectedParentCategoryId || subCategoryOrder.length === 0) return
|
||||
window.localStorage.setItem(
|
||||
`${SUB_ORDER_KEY_PREFIX}${selectedParentCategoryId}`,
|
||||
JSON.stringify(subCategoryOrder),
|
||||
)
|
||||
}, [selectedParentCategoryId, subCategoryOrder])
|
||||
|
||||
// Load flow steps for selected sub category only (sub category structure)
|
||||
useEffect(() => {
|
||||
if (scope !== "sub" || !selectedSubCategoryId) {
|
||||
setSteps([])
|
||||
setStepsDirty(false)
|
||||
return
|
||||
}
|
||||
const key = `subcategory_flow_${selectedSubCategoryId}`
|
||||
|
|
@ -205,6 +198,7 @@ export function CourseFlowBuilderPage() {
|
|||
if (raw) {
|
||||
const parsed: FlowStep[] = JSON.parse(raw)
|
||||
setSteps(parsed)
|
||||
setStepsDirty(false)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -212,20 +206,35 @@ export function CourseFlowBuilderPage() {
|
|||
}
|
||||
|
||||
const defaults: FlowStep[] = [
|
||||
{ id: `${selectedSubCategoryId}-lesson`, type: "lesson", title: "Core lessons", description: "Main learning content for this sub category." },
|
||||
{ id: `${selectedSubCategoryId}-practice`, type: "practice", title: "Practice sessions", description: "Speaking or practice activities to reinforce learning." },
|
||||
{ id: `${selectedSubCategoryId}-exam`, type: "exam", title: "Exam / Assessment", description: "Formal evaluation of student understanding." },
|
||||
{ id: `${selectedSubCategoryId}-feedback`, type: "feedback", title: "Feedback loop", description: "Collect feedback and share results with learners." },
|
||||
{
|
||||
id: `${selectedSubCategoryId}-lesson`,
|
||||
type: "lesson",
|
||||
title: "Core lessons",
|
||||
description: "Main learning content for this sub category.",
|
||||
},
|
||||
{
|
||||
id: `${selectedSubCategoryId}-practice`,
|
||||
type: "practice",
|
||||
title: "Practice sessions",
|
||||
description: "Speaking or practice activities to reinforce learning.",
|
||||
},
|
||||
{
|
||||
id: `${selectedSubCategoryId}-exam`,
|
||||
type: "exam",
|
||||
title: "Exam / Assessment",
|
||||
description: "Formal evaluation of student understanding.",
|
||||
},
|
||||
{
|
||||
id: `${selectedSubCategoryId}-feedback`,
|
||||
type: "feedback",
|
||||
title: "Feedback loop",
|
||||
description: "Collect feedback and share results with learners.",
|
||||
},
|
||||
]
|
||||
setSteps(defaults)
|
||||
setStepsDirty(true)
|
||||
}, [scope, selectedSubCategoryId])
|
||||
|
||||
// Persist flow steps for selected sub category
|
||||
useEffect(() => {
|
||||
if (scope !== "sub" || !selectedSubCategoryId) return
|
||||
window.localStorage.setItem(`subcategory_flow_${selectedSubCategoryId}`, JSON.stringify(steps))
|
||||
}, [steps, scope, selectedSubCategoryId])
|
||||
|
||||
const handleReorder = (targetId: string) => {
|
||||
if (!dragStepId || dragStepId === targetId) return
|
||||
setSteps((prev) => {
|
||||
|
|
@ -238,6 +247,7 @@ export function CourseFlowBuilderPage() {
|
|||
return copy
|
||||
})
|
||||
setDragStepId(null)
|
||||
setStepsDirty(true)
|
||||
}
|
||||
|
||||
const handleReorderParentCategory = (targetId: string) => {
|
||||
|
|
@ -252,6 +262,7 @@ export function CourseFlowBuilderPage() {
|
|||
return copy
|
||||
})
|
||||
setDragCategoryId(null)
|
||||
setParentOrderDirty(true)
|
||||
}
|
||||
|
||||
const handleReorderSubCategory = (targetId: string) => {
|
||||
|
|
@ -266,6 +277,28 @@ export function CourseFlowBuilderPage() {
|
|||
return copy
|
||||
})
|
||||
setDragCategoryId(null)
|
||||
setSubOrderDirty(true)
|
||||
}
|
||||
|
||||
const handleSaveParentOrder = () => {
|
||||
if (orderedParentCategories.length === 0 || parentCategoryOrder.length === 0) return
|
||||
window.localStorage.setItem(PARENT_ORDER_KEY, JSON.stringify(parentCategoryOrder))
|
||||
setParentOrderDirty(false)
|
||||
}
|
||||
|
||||
const handleSaveSubOrder = () => {
|
||||
if (!selectedParentCategoryId || subCategoryOrder.length === 0) return
|
||||
window.localStorage.setItem(
|
||||
`${SUB_ORDER_KEY_PREFIX}${selectedParentCategoryId}`,
|
||||
JSON.stringify(subCategoryOrder),
|
||||
)
|
||||
setSubOrderDirty(false)
|
||||
}
|
||||
|
||||
const handleSaveSteps = () => {
|
||||
if (scope !== "sub" || !selectedSubCategoryId) return
|
||||
window.localStorage.setItem(`subcategory_flow_${selectedSubCategoryId}`, JSON.stringify(steps))
|
||||
setStepsDirty(false)
|
||||
}
|
||||
|
||||
const getDefaultDescription = (type: StepType): string => {
|
||||
|
|
@ -299,14 +332,17 @@ export function CourseFlowBuilderPage() {
|
|||
description: getDefaultDescription(type),
|
||||
}
|
||||
setSteps((prev) => [...prev, newStep])
|
||||
setStepsDirty(true)
|
||||
}
|
||||
|
||||
const handleUpdateStep = (id: string, changes: Partial<FlowStep>) => {
|
||||
setSteps((prev) => prev.map((s) => (s.id === id ? { ...s, ...changes } : s)))
|
||||
setStepsDirty(true)
|
||||
}
|
||||
|
||||
const handleRemoveStep = (id: string) => {
|
||||
setSteps((prev) => prev.filter((s) => s.id !== id))
|
||||
setStepsDirty(true)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
|
|
@ -424,12 +460,26 @@ export function CourseFlowBuilderPage() {
|
|||
{scope === "parent" && (
|
||||
<Card className="shadow-soft">
|
||||
<CardHeader className="border-b border-grayScale-200 pb-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold text-grayScale-600">
|
||||
Parent category sequence
|
||||
</CardTitle>
|
||||
<p className="mt-1 text-xs text-grayScale-400">
|
||||
Drag to reorder the sequence in which parent categories appear. No courses or steps—order only.
|
||||
Drag to reorder the sequence in which parent categories appear. No courses or
|
||||
steps—order only.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-[11px]"
|
||||
disabled={!parentOrderDirty || orderedParentCategories.length === 0}
|
||||
onClick={handleSaveParentOrder}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 pt-4">
|
||||
{orderedParentCategories.length === 0 ? (
|
||||
|
|
@ -466,12 +516,25 @@ export function CourseFlowBuilderPage() {
|
|||
<>
|
||||
<Card className="shadow-soft">
|
||||
<CardHeader className="border-b border-grayScale-200 pb-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold text-grayScale-600">
|
||||
Sub category sequence
|
||||
</CardTitle>
|
||||
<p className="mt-1 text-xs text-grayScale-400">
|
||||
Drag to reorder sub categories under this parent.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-[11px]"
|
||||
disabled={!subOrderDirty || orderedSubCategories.length === 0}
|
||||
onClick={handleSaveSubOrder}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 pt-4">
|
||||
{orderedSubCategories.length === 0 ? (
|
||||
|
|
@ -507,12 +570,25 @@ export function CourseFlowBuilderPage() {
|
|||
<div className="grid gap-4 md:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
|
||||
<Card className="shadow-soft">
|
||||
<CardHeader className="border-b border-grayScale-200 pb-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold text-grayScale-600">
|
||||
Sub category structure
|
||||
</CardTitle>
|
||||
<p className="mt-1 text-xs text-grayScale-400">
|
||||
Courses, questions, and feedback steps for “{selectedSubCategory.name}”.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-[11px]"
|
||||
disabled={!stepsDirty || steps.length === 0}
|
||||
onClick={handleSaveSteps}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 pt-4">
|
||||
{steps.length === 0 && (
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user