Flow builder save buttons and category sub-creator

Made-with: Cursor
This commit is contained in:
“kirukib” 2026-02-27 20:58:56 +03:00
parent 089c1ac869
commit 63f0ff9157
2 changed files with 262 additions and 74 deletions

View File

@ -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) {

View File

@ -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 stepsorder only.
Drag to reorder the sequence in which parent categories appear. No courses or
stepsorder 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 && (