Barazo default frontend barazo.forum
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(categories): add hierarchical category management UI (#175)

* feat(categories): add flattenCategoryTree utility

* feat(categories): add parent category selector to admin form

* feat(categories): add drag-and-drop reordering and reparenting

* feat(categories): show hierarchy in topic category picker

* fix(deps): regenerate lockfile for pinned @dnd-kit versions

* style(categories): fix prettier formatting

authored by

Guido X Jansen and committed by
GitHub
d0ef5357 cc9b8c01

+553 -25
+3
package.json
··· 30 30 }, 31 31 "dependencies": { 32 32 "@barazo-forum/lexicons": "link:../barazo-lexicons", 33 + "@dnd-kit/core": "6.3.1", 34 + "@dnd-kit/sortable": "10.0.0", 35 + "@dnd-kit/utilities": "3.2.2", 33 36 "@phosphor-icons/react": "2.1.10", 34 37 "@radix-ui/colors": "3.0.0", 35 38 "@radix-ui/react-accordion": "1.2.12",
+56
pnpm-lock.yaml
··· 41 41 '@barazo-forum/lexicons': 42 42 specifier: link:../barazo-lexicons 43 43 version: link:../barazo-lexicons 44 + '@dnd-kit/core': 45 + specifier: 6.3.1 46 + version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) 47 + '@dnd-kit/sortable': 48 + specifier: 10.0.0 49 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) 50 + '@dnd-kit/utilities': 51 + specifier: 3.2.2 52 + version: 3.2.2(react@19.2.4) 44 53 '@phosphor-icons/react': 45 54 specifier: 2.1.10 46 55 version: 2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) ··· 469 478 '@csstools/css-tokenizer@4.0.0': 470 479 resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} 471 480 engines: {node: '>=20.19.0'} 481 + 482 + '@dnd-kit/accessibility@3.1.1': 483 + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} 484 + peerDependencies: 485 + react: '>=16.8.0' 486 + 487 + '@dnd-kit/core@6.3.1': 488 + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} 489 + peerDependencies: 490 + react: '>=16.8.0' 491 + react-dom: '>=16.8.0' 492 + 493 + '@dnd-kit/sortable@10.0.0': 494 + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} 495 + peerDependencies: 496 + '@dnd-kit/core': ^6.3.0 497 + react: '>=16.8.0' 498 + 499 + '@dnd-kit/utilities@3.2.2': 500 + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} 501 + peerDependencies: 502 + react: '>=16.8.0' 472 503 473 504 '@emnapi/core@1.8.1': 474 505 resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} ··· 5787 5818 '@csstools/css-syntax-patches-for-csstree@1.0.27': {} 5788 5819 5789 5820 '@csstools/css-tokenizer@4.0.0': {} 5821 + 5822 + '@dnd-kit/accessibility@3.1.1(react@19.2.4)': 5823 + dependencies: 5824 + react: 19.2.4 5825 + tslib: 2.8.1 5826 + 5827 + '@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': 5828 + dependencies: 5829 + '@dnd-kit/accessibility': 3.1.1(react@19.2.4) 5830 + '@dnd-kit/utilities': 3.2.2(react@19.2.4) 5831 + react: 19.2.4 5832 + react-dom: 19.2.4(react@19.2.4) 5833 + tslib: 2.8.1 5834 + 5835 + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': 5836 + dependencies: 5837 + '@dnd-kit/core': 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) 5838 + '@dnd-kit/utilities': 3.2.2(react@19.2.4) 5839 + react: 19.2.4 5840 + tslib: 2.8.1 5841 + 5842 + '@dnd-kit/utilities@3.2.2(react@19.2.4)': 5843 + dependencies: 5844 + react: 19.2.4 5845 + tslib: 2.8.1 5790 5846 5791 5847 '@emnapi/core@1.8.1': 5792 5848 dependencies:
+51
src/app/admin/categories/page.test.tsx
··· 101 101 expect(container).toHaveAttribute('data-depth', '1') 102 102 }) 103 103 104 + it('shows parent category selector when editing', async () => { 105 + const user = userEvent.setup() 106 + render(<AdminCategoriesPage />) 107 + await waitFor(() => { 108 + expect(screen.getByText('General Discussion')).toBeInTheDocument() 109 + }) 110 + const editButtons = screen.getAllByRole('button', { name: /edit/i }) 111 + await user.click(editButtons[0]!) 112 + expect(screen.getByLabelText(/parent category/i)).toBeInTheDocument() 113 + }) 114 + 115 + it('shows "None (top level)" option in parent selector', async () => { 116 + const user = userEvent.setup() 117 + render(<AdminCategoriesPage />) 118 + await waitFor(() => { 119 + expect(screen.getByText('General Discussion')).toBeInTheDocument() 120 + }) 121 + await user.click(screen.getByRole('button', { name: /add category/i })) 122 + const parentSelect = screen.getByLabelText(/parent category/i) 123 + expect(parentSelect).toBeInTheDocument() 124 + const options = parentSelect.querySelectorAll('option') 125 + expect(options[0]).toHaveTextContent('None (top level)') 126 + }) 127 + 128 + it('excludes current category from parent selector options', async () => { 129 + const user = userEvent.setup() 130 + render(<AdminCategoriesPage />) 131 + await waitFor(() => { 132 + expect(screen.getByText('General Discussion')).toBeInTheDocument() 133 + }) 134 + // Edit "Development" which has children Frontend and Backend 135 + const editButtons = screen.getAllByRole('button', { name: /edit development/i }) 136 + await user.click(editButtons[0]!) 137 + const parentSelect = screen.getByLabelText(/parent category/i) as HTMLSelectElement 138 + const optionTexts = Array.from(parentSelect.options).map((o) => o.textContent) 139 + // Development, Frontend, Backend should not appear (self + descendants excluded) 140 + expect(optionTexts).not.toContain(expect.stringContaining('Development')) 141 + expect(optionTexts).not.toContain(expect.stringContaining('Frontend')) 142 + expect(optionTexts).not.toContain(expect.stringContaining('Backend')) 143 + }) 144 + 145 + it('renders drag handles for each category', async () => { 146 + render(<AdminCategoriesPage />) 147 + await waitFor(() => { 148 + expect(screen.getByText('General Discussion')).toBeInTheDocument() 149 + }) 150 + const dragHandles = screen.getAllByRole('button', { name: /drag/i }) 151 + // 4 root + 2 children = 6 total categories with drag handles 152 + expect(dragHandles.length).toBeGreaterThanOrEqual(4) 153 + }) 154 + 104 155 it('passes axe accessibility check', async () => { 105 156 const { container } = render(<AdminCategoriesPage />) 106 157 await waitFor(() => {
+28 -12
src/app/admin/categories/page.tsx
··· 11 11 import { Plus } from '@phosphor-icons/react' 12 12 import { AdminLayout } from '@/components/admin/admin-layout' 13 13 import { ErrorAlert } from '@/components/error-alert' 14 - import { CategoryRow } from '@/components/admin/categories/category-row' 14 + import { CategoryTreeDnD } from '@/components/admin/categories/category-tree-dnd' 15 15 import { CategoryForm } from '@/components/admin/categories/category-form' 16 16 import type { EditingCategory } from '@/components/admin/categories/category-form' 17 17 import { getCategories, createCategory, updateCategory, deleteCategory } from '@/lib/api/client' ··· 73 73 void fetchCategories() 74 74 } catch { 75 75 setActionError('Failed to delete category. Please try again.') 76 + } 77 + } 78 + 79 + const handleMove = async ( 80 + categoryId: string, 81 + newParentId: string | null, 82 + newSortOrder: number 83 + ) => { 84 + setActionError(null) 85 + try { 86 + await updateCategory( 87 + categoryId, 88 + { parentId: newParentId, sortOrder: newSortOrder }, 89 + getAccessToken() ?? '' 90 + ) 91 + void fetchCategories() 92 + } catch { 93 + setActionError('Failed to move category. Please try again.') 76 94 } 77 95 } 78 96 ··· 134 152 {editing && ( 135 153 <CategoryForm 136 154 editing={editing} 155 + categories={categories} 137 156 onChange={setEditing} 138 157 onSave={() => void handleSave()} 139 158 onCancel={() => setEditing(null)} ··· 154 173 )} 155 174 156 175 {!loading && categories.length > 0 && ( 157 - <div className="space-y-2"> 158 - {categories.map((category) => ( 159 - <CategoryRow 160 - key={category.id} 161 - category={category} 162 - depth={0} 163 - onEdit={handleEdit} 164 - onDelete={(id) => void handleDelete(id)} 165 - /> 166 - ))} 167 - </div> 176 + <CategoryTreeDnD 177 + categories={categories} 178 + onMove={(id, newParentId, newSortOrder) => 179 + void handleMove(id, newParentId, newSortOrder) 180 + } 181 + onEdit={handleEdit} 182 + onDelete={(id) => void handleDelete(id)} 183 + /> 168 184 )} 169 185 </div> 170 186 </AdminLayout>
+25 -1
src/components/admin/categories/category-form.tsx
··· 3 3 * @see specs/prd-web.md Section M11 4 4 */ 5 5 6 - import type { MaturityRating } from '@/lib/api/types' 6 + import type { CategoryTreeNode, MaturityRating } from '@/lib/api/types' 7 7 import { SaveButton } from '@/components/admin/save-button' 8 8 import { FormLabel } from '@/components/ui/form-label' 9 + import { flattenCategoryTree } from '@/lib/flatten-category-tree' 9 10 import type { SaveStatus } from '@/hooks/use-save-state' 10 11 11 12 export interface EditingCategory { ··· 19 20 20 21 interface CategoryFormProps { 21 22 editing: EditingCategory 23 + categories: CategoryTreeNode[] 22 24 onChange: (cat: EditingCategory) => void 23 25 onSave: () => void 24 26 onCancel: () => void ··· 27 29 28 30 export function CategoryForm({ 29 31 editing, 32 + categories, 30 33 onChange, 31 34 onSave, 32 35 onCancel, ··· 75 78 rows={2} 76 79 className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 77 80 /> 81 + </div> 82 + <div> 83 + <FormLabel htmlFor="cat-parent" optional> 84 + Parent Category 85 + </FormLabel> 86 + <select 87 + id="cat-parent" 88 + value={editing.parentId ?? ''} 89 + onChange={(e) => onChange({ ...editing, parentId: e.target.value || null })} 90 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 91 + > 92 + <option value="">None (top level)</option> 93 + {flattenCategoryTree(categories, { 94 + excludeId: editing.id ?? undefined, 95 + }).map(({ category: cat, depth }) => ( 96 + <option key={cat.id} value={cat.id}> 97 + {'\u00A0'.repeat(depth * 3)} 98 + {cat.name} 99 + </option> 100 + ))} 101 + </select> 78 102 </div> 79 103 <div> 80 104 <FormLabel htmlFor="cat-maturity" required>
+82 -1
src/components/admin/categories/category-row.tsx
··· 3 3 * @see specs/prd-web.md Section M11 4 4 */ 5 5 6 - import { PencilSimple, TrashSimple } from '@phosphor-icons/react' 6 + import { useSortable } from '@dnd-kit/sortable' 7 + import { CSS } from '@dnd-kit/utilities' 8 + import { DotsSixVertical, PencilSimple, TrashSimple } from '@phosphor-icons/react' 7 9 import { cn } from '@/lib/utils' 8 10 import type { CategoryTreeNode, MaturityRating } from '@/lib/api/types' 9 11 ··· 83 85 </> 84 86 ) 85 87 } 88 + 89 + export function DraggableCategoryRow({ category, depth, onEdit, onDelete }: CategoryRowProps) { 90 + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ 91 + id: category.id, 92 + }) 93 + 94 + const style = { 95 + transform: CSS.Transform.toString(transform), 96 + transition, 97 + } 98 + 99 + return ( 100 + <> 101 + <div 102 + ref={setNodeRef} 103 + style={style} 104 + data-depth={depth} 105 + className={cn( 106 + 'flex items-center justify-between rounded-md border border-border bg-card p-3', 107 + depth > 0 && 'ml-6', 108 + isDragging && 'opacity-50' 109 + )} 110 + > 111 + <div className="flex items-center gap-3"> 112 + <button 113 + type="button" 114 + className="cursor-grab touch-none rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground" 115 + aria-label={`Drag ${category.name}`} 116 + {...attributes} 117 + {...listeners} 118 + > 119 + <DotsSixVertical size={16} aria-hidden="true" /> 120 + </button> 121 + <div> 122 + <p className="text-sm font-medium text-foreground">{category.name}</p> 123 + {category.description && ( 124 + <p className="text-xs text-muted-foreground">{category.description}</p> 125 + )} 126 + </div> 127 + </div> 128 + <div className="flex items-center gap-2"> 129 + <span 130 + className={cn( 131 + 'rounded-full px-2 py-0.5 text-xs font-medium', 132 + MATURITY_COLORS[category.maturityRating] 133 + )} 134 + > 135 + {MATURITY_LABELS[category.maturityRating]} 136 + </span> 137 + <button 138 + type="button" 139 + onClick={() => onEdit(category)} 140 + className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" 141 + aria-label={`Edit ${category.name}`} 142 + > 143 + <PencilSimple size={16} aria-hidden="true" /> 144 + </button> 145 + <button 146 + type="button" 147 + onClick={() => onDelete(category.id)} 148 + className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive" 149 + aria-label={`Delete ${category.name}`} 150 + > 151 + <TrashSimple size={16} aria-hidden="true" /> 152 + </button> 153 + </div> 154 + </div> 155 + {category.children.map((child) => ( 156 + <DraggableCategoryRow 157 + key={child.id} 158 + category={child} 159 + depth={depth + 1} 160 + onEdit={onEdit} 161 + onDelete={onDelete} 162 + /> 163 + ))} 164 + </> 165 + ) 166 + }
+97
src/components/admin/categories/category-tree-dnd.tsx
··· 1 + /** 2 + * CategoryTreeDnD - DnD wrapper for the admin category tree. 3 + * Handles drag-and-drop reordering and reparenting of categories. 4 + */ 5 + 6 + 'use client' 7 + 8 + import { useState, useCallback } from 'react' 9 + import { 10 + DndContext, 11 + closestCenter, 12 + KeyboardSensor, 13 + PointerSensor, 14 + useSensor, 15 + useSensors, 16 + type DragEndEvent, 17 + DragOverlay, 18 + type DragStartEvent, 19 + } from '@dnd-kit/core' 20 + import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable' 21 + import type { CategoryTreeNode } from '@/lib/api/types' 22 + import { flattenCategoryTree } from '@/lib/flatten-category-tree' 23 + import { DraggableCategoryRow } from './category-row' 24 + 25 + interface CategoryTreeDnDProps { 26 + categories: CategoryTreeNode[] 27 + onMove: (categoryId: string, newParentId: string | null, newSortOrder: number) => void 28 + onEdit: (cat: CategoryTreeNode) => void 29 + onDelete: (id: string) => void 30 + } 31 + 32 + export function CategoryTreeDnD({ categories, onMove, onEdit, onDelete }: CategoryTreeDnDProps) { 33 + const [activeId, setActiveId] = useState<string | null>(null) 34 + const flatItems = flattenCategoryTree(categories) 35 + const itemIds = flatItems.map((item) => item.category.id) 36 + 37 + const sensors = useSensors( 38 + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), 39 + useSensor(KeyboardSensor) 40 + ) 41 + 42 + const handleDragStart = useCallback((event: DragStartEvent) => { 43 + setActiveId(String(event.active.id)) 44 + }, []) 45 + 46 + const handleDragEnd = useCallback( 47 + (event: DragEndEvent) => { 48 + setActiveId(null) 49 + const { active, over } = event 50 + if (!over || active.id === over.id) return 51 + 52 + const activeItem = flatItems.find((i) => i.category.id === active.id) 53 + const overItem = flatItems.find((i) => i.category.id === over.id) 54 + if (!activeItem || !overItem) return 55 + 56 + const newParentId = overItem.category.parentId 57 + const siblingsAtLevel = flatItems.filter((i) => i.category.parentId === newParentId) 58 + const overIndex = siblingsAtLevel.findIndex((i) => i.category.id === over.id) 59 + const newSortOrder = Math.max(0, overIndex) 60 + 61 + onMove(String(active.id), newParentId, newSortOrder) 62 + }, 63 + [flatItems, onMove] 64 + ) 65 + 66 + const activeItem = activeId ? flatItems.find((i) => i.category.id === activeId) : null 67 + 68 + return ( 69 + <DndContext 70 + sensors={sensors} 71 + collisionDetection={closestCenter} 72 + onDragStart={handleDragStart} 73 + onDragEnd={handleDragEnd} 74 + > 75 + <SortableContext items={itemIds} strategy={verticalListSortingStrategy}> 76 + <div className="space-y-2"> 77 + {categories.map((category) => ( 78 + <DraggableCategoryRow 79 + key={category.id} 80 + category={category} 81 + depth={0} 82 + onEdit={onEdit} 83 + onDelete={onDelete} 84 + /> 85 + ))} 86 + </div> 87 + </SortableContext> 88 + <DragOverlay> 89 + {activeItem ? ( 90 + <div className="rounded-md border border-primary bg-card p-3 shadow-lg opacity-90"> 91 + <p className="text-sm font-medium text-foreground">{activeItem.category.name}</p> 92 + </div> 93 + ) : null} 94 + </DragOverlay> 95 + </DndContext> 96 + ) 97 + }
+16 -9
src/components/topic-form.tsx
··· 8 8 'use client' 9 9 10 10 import { useState, useCallback } from 'react' 11 - import type { CreateTopicInput } from '@/lib/api/types' 11 + import type { CreateTopicInput, CategoryTreeNode } from '@/lib/api/types' 12 12 import { cn } from '@/lib/utils' 13 13 import { TopicMetaFields } from '@/components/topic-meta-fields' 14 14 import { TopicContentEditor } from '@/components/topic-content-editor' ··· 22 22 onSubmit: (values: CreateTopicInput) => void | Promise<void> 23 23 initialValues?: Partial<TopicFormValues> 24 24 mode?: 'create' | 'edit' 25 - categories?: Array<{ slug: string; name: string }> 25 + categories?: CategoryTreeNode[] 26 26 submitting?: boolean 27 27 className?: string 28 28 } 29 29 30 - const CATEGORIES_FALLBACK = [ 31 - { slug: 'general', name: 'General Discussion' }, 32 - { slug: 'development', name: 'Development' }, 33 - { slug: 'frontend', name: 'Frontend' }, 34 - { slug: 'backend', name: 'Backend' }, 35 - { slug: 'feedback', name: 'Feedback & Ideas' }, 36 - { slug: 'meta', name: 'Meta' }, 30 + const CATEGORIES_FALLBACK: CategoryTreeNode[] = [ 31 + { 32 + id: 'fallback-general', 33 + slug: 'general', 34 + name: 'General Discussion', 35 + description: null, 36 + parentId: null, 37 + sortOrder: 0, 38 + communityDid: '', 39 + maturityRating: 'safe', 40 + createdAt: '', 41 + updatedAt: '', 42 + children: [], 43 + }, 37 44 ] 38 45 39 46 export function TopicForm({
+86
src/components/topic-meta-fields.test.tsx
··· 1 + import { describe, it, expect, vi } from 'vitest' 2 + import { render, screen } from '@testing-library/react' 3 + import { axe } from 'vitest-axe' 4 + import { TopicMetaFields } from './topic-meta-fields' 5 + import type { CategoryTreeNode } from '@/lib/api/types' 6 + 7 + const COMMUNITY_DID = 'did:plc:test' 8 + const NOW = '2026-01-01T00:00:00.000Z' 9 + 10 + const treeCategories: CategoryTreeNode[] = [ 11 + { 12 + id: 'cat-1', 13 + slug: 'general', 14 + name: 'General', 15 + description: null, 16 + parentId: null, 17 + sortOrder: 0, 18 + communityDid: COMMUNITY_DID, 19 + maturityRating: 'safe', 20 + createdAt: NOW, 21 + updatedAt: NOW, 22 + children: [], 23 + }, 24 + { 25 + id: 'cat-2', 26 + slug: 'dev', 27 + name: 'Development', 28 + description: null, 29 + parentId: null, 30 + sortOrder: 1, 31 + communityDid: COMMUNITY_DID, 32 + maturityRating: 'safe', 33 + createdAt: NOW, 34 + updatedAt: NOW, 35 + children: [ 36 + { 37 + id: 'cat-3', 38 + slug: 'frontend', 39 + name: 'Frontend', 40 + description: null, 41 + parentId: 'cat-2', 42 + sortOrder: 0, 43 + communityDid: COMMUNITY_DID, 44 + maturityRating: 'safe', 45 + createdAt: NOW, 46 + updatedAt: NOW, 47 + children: [], 48 + }, 49 + ], 50 + }, 51 + ] 52 + 53 + describe('TopicMetaFields', () => { 54 + const defaultProps = { 55 + title: '', 56 + category: '', 57 + tagInput: '', 58 + categories: treeCategories, 59 + errors: {}, 60 + onTitleChange: vi.fn(), 61 + onCategoryChange: vi.fn(), 62 + onTagInputChange: vi.fn(), 63 + } 64 + 65 + it('renders category options with hierarchy indentation', () => { 66 + render(<TopicMetaFields {...defaultProps} />) 67 + const select = screen.getByLabelText(/category/i) as HTMLSelectElement 68 + const options = Array.from(select.options) 69 + // First is placeholder 70 + expect(options[0]).toHaveTextContent('Select a category') 71 + // "General" at root 72 + expect(options[1]).toHaveTextContent('General') 73 + // "Development" at root 74 + expect(options[2]).toHaveTextContent('Development') 75 + // "Frontend" indented under Development (contains non-breaking spaces) 76 + const frontendOption = options[3] 77 + expect(frontendOption?.textContent).toContain('Frontend') 78 + expect(frontendOption?.value).toBe('frontend') 79 + }) 80 + 81 + it('passes axe accessibility check', async () => { 82 + const { container } = render(<TopicMetaFields {...defaultProps} />) 83 + const results = await axe(container) 84 + expect(results).toHaveNoViolations() 85 + }) 86 + })
+5 -2
src/components/topic-meta-fields.tsx
··· 5 5 6 6 import { cn } from '@/lib/utils' 7 7 import { FormLabel } from '@/components/ui/form-label' 8 + import type { CategoryTreeNode } from '@/lib/api/types' 9 + import { flattenCategoryTree } from '@/lib/flatten-category-tree' 8 10 9 11 interface TopicMetaFieldsProps { 10 12 title: string 11 13 category: string 12 14 tagInput: string 13 - categories: Array<{ slug: string; name: string }> 15 + categories: CategoryTreeNode[] 14 16 errors: { title?: string; category?: string } 15 17 onTitleChange: (title: string) => void 16 18 onCategoryChange: (category: string) => void ··· 73 75 )} 74 76 > 75 77 <option value="">Select a category</option> 76 - {categories.map((cat) => ( 78 + {flattenCategoryTree(categories).map(({ category: cat, depth }) => ( 77 79 <option key={cat.slug} value={cat.slug}> 80 + {'\u00A0'.repeat(depth * 3)} 78 81 {cat.name} 79 82 </option> 80 83 ))}
+75
src/lib/flatten-category-tree.test.ts
··· 1 + import { describe, it, expect } from 'vitest' 2 + import { flattenCategoryTree } from './flatten-category-tree' 3 + import type { CategoryTreeNode } from '@/lib/api/types' 4 + 5 + const COMMUNITY_DID = 'did:plc:test' 6 + const NOW = '2026-01-01T00:00:00.000Z' 7 + 8 + function makeCategory( 9 + overrides: Partial<CategoryTreeNode> & { id: string; name: string } 10 + ): CategoryTreeNode { 11 + return { 12 + slug: overrides.id, 13 + description: null, 14 + parentId: null, 15 + sortOrder: 0, 16 + communityDid: COMMUNITY_DID, 17 + maturityRating: 'safe', 18 + createdAt: NOW, 19 + updatedAt: NOW, 20 + children: [], 21 + ...overrides, 22 + } 23 + } 24 + 25 + describe('flattenCategoryTree', () => { 26 + it('returns empty array for empty input', () => { 27 + expect(flattenCategoryTree([])).toEqual([]) 28 + }) 29 + 30 + it('flattens a single root category', () => { 31 + const tree = [makeCategory({ id: 'cat-1', name: 'General' })] 32 + const result = flattenCategoryTree(tree) 33 + expect(result).toEqual([{ category: tree[0], depth: 0 }]) 34 + }) 35 + 36 + it('flattens nested categories in depth-first order', () => { 37 + const tree: CategoryTreeNode[] = [ 38 + makeCategory({ 39 + id: 'cat-1', 40 + name: 'Dev', 41 + children: [ 42 + makeCategory({ id: 'cat-2', name: 'Frontend', parentId: 'cat-1' }), 43 + makeCategory({ 44 + id: 'cat-3', 45 + name: 'Backend', 46 + parentId: 'cat-1', 47 + children: [makeCategory({ id: 'cat-4', name: 'Databases', parentId: 'cat-3' })], 48 + }), 49 + ], 50 + }), 51 + makeCategory({ id: 'cat-5', name: 'Meta' }), 52 + ] 53 + const result = flattenCategoryTree(tree) 54 + expect(result.map((r) => ({ id: r.category.id, depth: r.depth }))).toEqual([ 55 + { id: 'cat-1', depth: 0 }, 56 + { id: 'cat-2', depth: 1 }, 57 + { id: 'cat-3', depth: 1 }, 58 + { id: 'cat-4', depth: 2 }, 59 + { id: 'cat-5', depth: 0 }, 60 + ]) 61 + }) 62 + 63 + it('excludes a category and all its descendants', () => { 64 + const tree: CategoryTreeNode[] = [ 65 + makeCategory({ 66 + id: 'cat-1', 67 + name: 'Dev', 68 + children: [makeCategory({ id: 'cat-2', name: 'Frontend', parentId: 'cat-1' })], 69 + }), 70 + makeCategory({ id: 'cat-3', name: 'Meta' }), 71 + ] 72 + const result = flattenCategoryTree(tree, { excludeId: 'cat-1' }) 73 + expect(result).toEqual([{ category: tree[1], depth: 0 }]) 74 + }) 75 + })
+29
src/lib/flatten-category-tree.ts
··· 1 + import type { CategoryTreeNode } from '@/lib/api/types' 2 + 3 + export interface FlatCategory { 4 + category: CategoryTreeNode 5 + depth: number 6 + } 7 + 8 + interface FlattenOptions { 9 + /** Exclude this category ID and all its descendants. */ 10 + excludeId?: string 11 + } 12 + 13 + export function flattenCategoryTree( 14 + tree: CategoryTreeNode[], 15 + options: FlattenOptions = {} 16 + ): FlatCategory[] { 17 + const result: FlatCategory[] = [] 18 + 19 + function walk(nodes: CategoryTreeNode[], depth: number): void { 20 + for (const node of nodes) { 21 + if (options.excludeId && node.id === options.excludeId) continue 22 + result.push({ category: node, depth }) 23 + walk(node.children, depth + 1) 24 + } 25 + } 26 + 27 + walk(tree, 0) 28 + return result 29 + }