Skip to content

Commit ce8ba9e

Browse files
authored
Merge pull request #132 from QuackbackIO/feat/help-center-category-hierarchy
Help center: category hierarchy, inline portal, full-page editor, admin polish
2 parents 38b6afa + d9bc6ef commit ce8ba9e

87 files changed

Lines changed: 15222 additions & 2917 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/web/src/components/admin/help-center/category-form-dialog.tsx

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { useState, useEffect } from 'react'
1+
import { useState, useEffect, useMemo } from 'react'
2+
import { useQuery } from '@tanstack/react-query'
23
import {
34
Dialog,
45
DialogContent,
@@ -8,11 +9,25 @@ import {
89
DialogTitle,
910
} from '@/components/ui/dialog'
1011
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
12+
import {
13+
Select,
14+
SelectContent,
15+
SelectItem,
16+
SelectTrigger,
17+
SelectValue,
18+
} from '@/components/ui/select'
1119
import { Button } from '@/components/ui/button'
1220
import { Input } from '@/components/ui/input'
1321
import { Label } from '@/components/ui/label'
1422
import { Switch } from '@/components/ui/switch'
1523
import { useCreateCategory, useUpdateCategory } from '@/lib/client/mutations/help-center'
24+
import { helpCenterQueries } from '@/lib/client/queries/help-center'
25+
import {
26+
MAX_CATEGORY_DEPTH,
27+
collectDescendantIdsIncludingSelf,
28+
getCategoryDepth,
29+
getSubtreeMaxDepth,
30+
} from '@/lib/server/domains/help-center/category-tree'
1631
import type { HelpCenterCategoryId } from '@quackback/ids'
1732

1833
const CATEGORY_EMOJIS = [
@@ -77,14 +92,18 @@ interface CategoryFormDialogProps {
7792
description: string | null
7893
icon: string | null
7994
isPublic: boolean
95+
parentId: HelpCenterCategoryId | null
8096
}
97+
/** Pre-selected parent when creating a new category (ignored if initialValues is set). */
98+
defaultParentId?: HelpCenterCategoryId | null
8199
onCreated?: (categoryId: string) => void
82100
}
83101

84102
export function CategoryFormDialog({
85103
open,
86104
onOpenChange,
87105
initialValues,
106+
defaultParentId,
88107
onCreated,
89108
}: CategoryFormDialogProps) {
90109
const isEdit = !!initialValues
@@ -95,6 +114,7 @@ export function CategoryFormDialog({
95114
const [name, setName] = useState('')
96115
const [description, setDescription] = useState('')
97116
const [isPublic, setIsPublic] = useState(true)
117+
const [parentId, setParentId] = useState<HelpCenterCategoryId | null>(null)
98118
const [emojiOpen, setEmojiOpen] = useState(false)
99119

100120
useEffect(() => {
@@ -103,8 +123,43 @@ export function CategoryFormDialog({
103123
setName(initialValues?.name || '')
104124
setDescription(initialValues?.description || '')
105125
setIsPublic(initialValues?.isPublic ?? true)
126+
setParentId(initialValues?.parentId ?? defaultParentId ?? null)
127+
}
128+
}, [open, initialValues, defaultParentId])
129+
130+
const { data: allCategories = [] } = useQuery({
131+
...helpCenterQueries.categories(),
132+
enabled: open,
133+
})
134+
135+
const eligibleParents = useMemo(() => {
136+
const flat = allCategories as Array<{
137+
id: string
138+
parentId: string | null
139+
name: string
140+
icon: string | null
141+
articleCount: number
142+
}>
143+
144+
// Exclude self + descendants of self (cycle / self-parent)
145+
const excluded = new Set<string>()
146+
if (initialValues?.id) {
147+
for (const ex of collectDescendantIdsIncludingSelf(flat, initialValues.id)) {
148+
excluded.add(ex)
149+
}
106150
}
107-
}, [open, initialValues])
151+
152+
// Compute the editing category's subtree height (0 for a new category)
153+
const subtreeHeight = initialValues?.id ? getSubtreeMaxDepth(flat, initialValues.id) : 0
154+
155+
return flat.filter((cat) => {
156+
if (excluded.has(cat.id)) return false
157+
const parentDepth = getCategoryDepth(flat, cat.id)
158+
// New depth = parentDepth + 1; deepest leaf after placement = that + subtreeHeight
159+
// Depths are 0-indexed with cap MAX_CATEGORY_DEPTH, so max depth index is MAX - 1
160+
return parentDepth + 1 + subtreeHeight <= MAX_CATEGORY_DEPTH - 1
161+
})
162+
}, [allCategories, initialValues?.id])
108163

109164
const isPending = createCategory.isPending || updateCategory.isPending
110165

@@ -121,13 +176,15 @@ export function CategoryFormDialog({
121176
description: trimmedDesc || null,
122177
icon,
123178
isPublic,
179+
parentId,
124180
})
125181
} else {
126182
const result = await createCategory.mutateAsync({
127183
name: trimmedName,
128184
description: trimmedDesc || undefined,
129185
icon,
130186
isPublic,
187+
parentId,
131188
})
132189
onCreated?.(result.id)
133190
}
@@ -196,6 +253,31 @@ export function CategoryFormDialog({
196253
/>
197254
</div>
198255

256+
<div className="space-y-2">
257+
<Label htmlFor="category-parent">Parent category</Label>
258+
<Select
259+
value={parentId ?? '__none__'}
260+
onValueChange={(value) =>
261+
setParentId(value === '__none__' ? null : (value as HelpCenterCategoryId))
262+
}
263+
>
264+
<SelectTrigger id="category-parent">
265+
<SelectValue placeholder="No parent (top-level)" />
266+
</SelectTrigger>
267+
<SelectContent>
268+
<SelectItem value="__none__">No parent (top-level)</SelectItem>
269+
{eligibleParents.map((cat) => (
270+
<SelectItem key={cat.id} value={cat.id}>
271+
{cat.icon ?? '📁'} {cat.name}
272+
</SelectItem>
273+
))}
274+
</SelectContent>
275+
</Select>
276+
<p className="text-xs text-muted-foreground">
277+
Maximum depth is {MAX_CATEGORY_DEPTH} levels. Parents that would exceed it are hidden.
278+
</p>
279+
</div>
280+
199281
<div className="flex items-center justify-between">
200282
<div>
201283
<Label>Public</Label>

apps/web/src/components/admin/help-center/create-article-dialog.tsx

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useState, useCallback } from 'react'
2+
import { useNavigate } from '@tanstack/react-router'
23
import { useKeyboardSubmit } from '@/lib/client/hooks/use-keyboard-submit'
34
import { ModalFooter } from '@/components/shared/modal-footer'
45
import { useForm } from 'react-hook-form'
@@ -18,12 +19,24 @@ import {
1819
} from './help-center-metadata-sidebar'
1920
import type { JSONContent } from '@tiptap/react'
2021

21-
export function CreateArticleDialog() {
22-
const [open, setOpen] = useState(false)
22+
interface CreateArticleDialogProps {
23+
/** Controlled open state. When provided, the built-in trigger button is hidden. */
24+
open?: boolean
25+
onOpenChange?: (open: boolean) => void
26+
}
27+
28+
export function CreateArticleDialog({
29+
open: openProp,
30+
onOpenChange,
31+
}: CreateArticleDialogProps = {}) {
32+
const [internalOpen, setInternalOpen] = useState(false)
33+
const isControlled = openProp !== undefined
34+
const open = isControlled ? openProp : internalOpen
2335
const [contentJson, setContentJson] = useState<JSONContent | null>(null)
2436
const [categoryId, setCategoryId] = useState('')
2537
const [mobileSettingsOpen, setMobileSettingsOpen] = useState(false)
2638
const createArticleMutation = useCreateArticle()
39+
const navigate = useNavigate()
2740

2841
const form = useForm({
2942
resolver: standardSchemaResolver(createArticleSchema),
@@ -59,18 +72,26 @@ export function CreateArticleDialog() {
5972
contentJson: contentJson as TiptapContent | null,
6073
},
6174
{
62-
onSuccess: () => {
63-
setOpen(false)
75+
onSuccess: (newArticle) => {
76+
handleOpenChange(false)
6477
form.reset()
6578
setContentJson(null)
6679
setCategoryId('')
80+
void navigate({
81+
to: '/admin/help-center/articles/$articleId',
82+
params: { articleId: newArticle.id as string },
83+
})
6784
},
6885
}
6986
)
7087
})
7188

7289
function handleOpenChange(isOpen: boolean) {
73-
setOpen(isOpen)
90+
if (isControlled) {
91+
onOpenChange?.(isOpen)
92+
} else {
93+
setInternalOpen(isOpen)
94+
}
7495
if (!isOpen) {
7596
form.reset()
7697
setContentJson(null)
@@ -83,12 +104,14 @@ export function CreateArticleDialog() {
83104

84105
return (
85106
<Dialog open={open} onOpenChange={handleOpenChange}>
86-
<DialogTrigger asChild>
87-
<Button size="sm">
88-
<PlusIcon className="h-4 w-4 mr-1.5" />
89-
New Article
90-
</Button>
91-
</DialogTrigger>
107+
{!isControlled && (
108+
<DialogTrigger asChild>
109+
<Button size="sm">
110+
<PlusIcon className="h-4 w-4 mr-1.5" />
111+
New Article
112+
</Button>
113+
</DialogTrigger>
114+
)}
92115
<DialogContent
93116
className="w-[95vw] sm:w-[90vw] lg:max-w-5xl xl:max-w-6xl h-[85vh] p-0 gap-0 overflow-hidden flex flex-col"
94117
onKeyDown={handleKeyDown}
@@ -119,7 +142,7 @@ export function CreateArticleDialog() {
119142
</div>
120143

121144
<ModalFooter
122-
onCancel={() => setOpen(false)}
145+
onCancel={() => handleOpenChange(false)}
123146
submitLabel={createArticleMutation.isPending ? 'Saving...' : 'Save Draft'}
124147
isPending={createArticleMutation.isPending}
125148
>
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { XMarkIcon } from '@heroicons/react/16/solid'
2+
import { useQuery } from '@tanstack/react-query'
3+
import { cn } from '@/lib/shared/utils'
4+
import { helpCenterQueries } from '@/lib/client/queries/help-center'
5+
import type { HelpCenterStatusFilter } from './use-help-center-filters'
6+
7+
interface HelpCenterActiveFiltersBarProps {
8+
status: HelpCenterStatusFilter
9+
category?: string
10+
showDeleted?: boolean
11+
onClearStatus: () => void
12+
onClearCategory: () => void
13+
onClearShowDeleted: () => void
14+
onClearAll: () => void
15+
}
16+
17+
interface Chip {
18+
key: string
19+
label: string
20+
onRemove: () => void
21+
}
22+
23+
export function HelpCenterActiveFiltersBar({
24+
status,
25+
category,
26+
showDeleted,
27+
onClearStatus,
28+
onClearCategory,
29+
onClearShowDeleted,
30+
onClearAll,
31+
}: HelpCenterActiveFiltersBarProps) {
32+
const { data: categories } = useQuery(helpCenterQueries.categories())
33+
const categoryName = category
34+
? (categories?.find((c) => c.id === category)?.name ?? 'Category')
35+
: null
36+
37+
const chips: Chip[] = []
38+
39+
if (status && status !== 'all') {
40+
chips.push({
41+
key: 'status',
42+
label: `Status: ${status === 'draft' ? 'Draft' : 'Published'}`,
43+
onRemove: onClearStatus,
44+
})
45+
}
46+
if (categoryName) {
47+
chips.push({
48+
key: 'category',
49+
label: `Category: ${categoryName}`,
50+
onRemove: onClearCategory,
51+
})
52+
}
53+
if (showDeleted) {
54+
chips.push({
55+
key: 'deleted',
56+
label: 'Showing deleted',
57+
onRemove: onClearShowDeleted,
58+
})
59+
}
60+
61+
if (chips.length === 0) return null
62+
63+
return (
64+
<div className="mt-2 flex flex-wrap items-center gap-1.5">
65+
{chips.map((chip) => (
66+
<button
67+
key={chip.key}
68+
type="button"
69+
onClick={chip.onRemove}
70+
className={cn(
71+
'group inline-flex items-center gap-1 rounded-full px-2 py-0.5',
72+
'bg-muted text-xs text-foreground/80',
73+
'hover:bg-muted/70 transition-colors'
74+
)}
75+
>
76+
<span>{chip.label}</span>
77+
<XMarkIcon className="h-3 w-3 text-muted-foreground group-hover:text-foreground" />
78+
</button>
79+
))}
80+
{chips.length > 1 && (
81+
<button
82+
type="button"
83+
onClick={onClearAll}
84+
className="ml-1 text-xs text-muted-foreground hover:text-foreground underline underline-offset-2"
85+
>
86+
Clear all
87+
</button>
88+
)}
89+
</div>
90+
)
91+
}

0 commit comments

Comments
 (0)