diff --git a/package.json b/package.json index 7196286..e58ca5f 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,9 @@ }, "dependencies": { "@barazo-forum/lexicons": "link:../barazo-lexicons", + "@dnd-kit/core": "6.3.1", + "@dnd-kit/sortable": "10.0.0", + "@dnd-kit/utilities": "3.2.2", "@phosphor-icons/react": "2.1.10", "@radix-ui/colors": "3.0.0", "@radix-ui/react-accordion": "1.2.12", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe48f31..bccd203 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,15 @@ importers: '@barazo-forum/lexicons': specifier: link:../barazo-lexicons version: link:../barazo-lexicons + '@dnd-kit/core': + specifier: 6.3.1 + version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@dnd-kit/sortable': + specifier: 10.0.0 + 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) + '@dnd-kit/utilities': + specifier: 3.2.2 + version: 3.2.2(react@19.2.4) '@phosphor-icons/react': specifier: 2.1.10 version: 2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -470,6 +479,28 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} @@ -5788,6 +5819,31 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.4)': + dependencies: + react: 19.2.4 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.4) + '@dnd-kit/utilities': 3.2.2(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + tslib: 2.8.1 + + '@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)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@dnd-kit/utilities': 3.2.2(react@19.2.4) + react: 19.2.4 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.4)': + dependencies: + react: 19.2.4 + tslib: 2.8.1 + '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0 diff --git a/src/app/admin/categories/page.test.tsx b/src/app/admin/categories/page.test.tsx index 26a4909..a8629c5 100644 --- a/src/app/admin/categories/page.test.tsx +++ b/src/app/admin/categories/page.test.tsx @@ -101,6 +101,57 @@ describe('AdminCategoriesPage', () => { expect(container).toHaveAttribute('data-depth', '1') }) + it('shows parent category selector when editing', async () => { + const user = userEvent.setup() + render() + await waitFor(() => { + expect(screen.getByText('General Discussion')).toBeInTheDocument() + }) + const editButtons = screen.getAllByRole('button', { name: /edit/i }) + await user.click(editButtons[0]!) + expect(screen.getByLabelText(/parent category/i)).toBeInTheDocument() + }) + + it('shows "None (top level)" option in parent selector', async () => { + const user = userEvent.setup() + render() + await waitFor(() => { + expect(screen.getByText('General Discussion')).toBeInTheDocument() + }) + await user.click(screen.getByRole('button', { name: /add category/i })) + const parentSelect = screen.getByLabelText(/parent category/i) + expect(parentSelect).toBeInTheDocument() + const options = parentSelect.querySelectorAll('option') + expect(options[0]).toHaveTextContent('None (top level)') + }) + + it('excludes current category from parent selector options', async () => { + const user = userEvent.setup() + render() + await waitFor(() => { + expect(screen.getByText('General Discussion')).toBeInTheDocument() + }) + // Edit "Development" which has children Frontend and Backend + const editButtons = screen.getAllByRole('button', { name: /edit development/i }) + await user.click(editButtons[0]!) + const parentSelect = screen.getByLabelText(/parent category/i) as HTMLSelectElement + const optionTexts = Array.from(parentSelect.options).map((o) => o.textContent) + // Development, Frontend, Backend should not appear (self + descendants excluded) + expect(optionTexts).not.toContain(expect.stringContaining('Development')) + expect(optionTexts).not.toContain(expect.stringContaining('Frontend')) + expect(optionTexts).not.toContain(expect.stringContaining('Backend')) + }) + + it('renders drag handles for each category', async () => { + render() + await waitFor(() => { + expect(screen.getByText('General Discussion')).toBeInTheDocument() + }) + const dragHandles = screen.getAllByRole('button', { name: /drag/i }) + // 4 root + 2 children = 6 total categories with drag handles + expect(dragHandles.length).toBeGreaterThanOrEqual(4) + }) + it('passes axe accessibility check', async () => { const { container } = render() await waitFor(() => { diff --git a/src/app/admin/categories/page.tsx b/src/app/admin/categories/page.tsx index ea5fca5..28cb0ca 100644 --- a/src/app/admin/categories/page.tsx +++ b/src/app/admin/categories/page.tsx @@ -11,7 +11,7 @@ import { useState, useEffect, useCallback } from 'react' import { Plus } from '@phosphor-icons/react' import { AdminLayout } from '@/components/admin/admin-layout' import { ErrorAlert } from '@/components/error-alert' -import { CategoryRow } from '@/components/admin/categories/category-row' +import { CategoryTreeDnD } from '@/components/admin/categories/category-tree-dnd' import { CategoryForm } from '@/components/admin/categories/category-form' import type { EditingCategory } from '@/components/admin/categories/category-form' import { getCategories, createCategory, updateCategory, deleteCategory } from '@/lib/api/client' @@ -76,6 +76,24 @@ export default function AdminCategoriesPage() { } } + const handleMove = async ( + categoryId: string, + newParentId: string | null, + newSortOrder: number + ) => { + setActionError(null) + try { + await updateCategory( + categoryId, + { parentId: newParentId, sortOrder: newSortOrder }, + getAccessToken() ?? '' + ) + void fetchCategories() + } catch { + setActionError('Failed to move category. Please try again.') + } + } + const handleSave = async () => { if (!editing) return saveMachine.startSaving() @@ -134,6 +152,7 @@ export default function AdminCategoriesPage() { {editing && ( void handleSave()} onCancel={() => setEditing(null)} @@ -154,17 +173,14 @@ export default function AdminCategoriesPage() { )} {!loading && categories.length > 0 && ( -
- {categories.map((category) => ( - void handleDelete(id)} - /> - ))} -
+ + void handleMove(id, newParentId, newSortOrder) + } + onEdit={handleEdit} + onDelete={(id) => void handleDelete(id)} + /> )} diff --git a/src/components/admin/categories/category-form.tsx b/src/components/admin/categories/category-form.tsx index 34f8611..15996dc 100644 --- a/src/components/admin/categories/category-form.tsx +++ b/src/components/admin/categories/category-form.tsx @@ -3,9 +3,10 @@ * @see specs/prd-web.md Section M11 */ -import type { MaturityRating } from '@/lib/api/types' +import type { CategoryTreeNode, MaturityRating } from '@/lib/api/types' import { SaveButton } from '@/components/admin/save-button' import { FormLabel } from '@/components/ui/form-label' +import { flattenCategoryTree } from '@/lib/flatten-category-tree' import type { SaveStatus } from '@/hooks/use-save-state' export interface EditingCategory { @@ -19,6 +20,7 @@ export interface EditingCategory { interface CategoryFormProps { editing: EditingCategory + categories: CategoryTreeNode[] onChange: (cat: EditingCategory) => void onSave: () => void onCancel: () => void @@ -27,6 +29,7 @@ interface CategoryFormProps { export function CategoryForm({ editing, + categories, onChange, onSave, onCancel, @@ -76,6 +79,27 @@ export function CategoryForm({ className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" /> +
+ + Parent Category + + +
Maturity Rating diff --git a/src/components/admin/categories/category-row.tsx b/src/components/admin/categories/category-row.tsx index 626cc99..4e20015 100644 --- a/src/components/admin/categories/category-row.tsx +++ b/src/components/admin/categories/category-row.tsx @@ -3,7 +3,9 @@ * @see specs/prd-web.md Section M11 */ -import { PencilSimple, TrashSimple } from '@phosphor-icons/react' +import { useSortable } from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { DotsSixVertical, PencilSimple, TrashSimple } from '@phosphor-icons/react' import { cn } from '@/lib/utils' import type { CategoryTreeNode, MaturityRating } from '@/lib/api/types' @@ -83,3 +85,82 @@ export function CategoryRow({ category, depth, onEdit, onDelete }: CategoryRowPr ) } + +export function DraggableCategoryRow({ category, depth, onEdit, onDelete }: CategoryRowProps) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: category.id, + }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + } + + return ( + <> +
0 && 'ml-6', + isDragging && 'opacity-50' + )} + > +
+ +
+

{category.name}

+ {category.description && ( +

{category.description}

+ )} +
+
+
+ + {MATURITY_LABELS[category.maturityRating]} + + + +
+
+ {category.children.map((child) => ( + + ))} + + ) +} diff --git a/src/components/admin/categories/category-tree-dnd.tsx b/src/components/admin/categories/category-tree-dnd.tsx new file mode 100644 index 0000000..59506d7 --- /dev/null +++ b/src/components/admin/categories/category-tree-dnd.tsx @@ -0,0 +1,97 @@ +/** + * CategoryTreeDnD - DnD wrapper for the admin category tree. + * Handles drag-and-drop reordering and reparenting of categories. + */ + +'use client' + +import { useState, useCallback } from 'react' +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, + DragOverlay, + type DragStartEvent, +} from '@dnd-kit/core' +import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable' +import type { CategoryTreeNode } from '@/lib/api/types' +import { flattenCategoryTree } from '@/lib/flatten-category-tree' +import { DraggableCategoryRow } from './category-row' + +interface CategoryTreeDnDProps { + categories: CategoryTreeNode[] + onMove: (categoryId: string, newParentId: string | null, newSortOrder: number) => void + onEdit: (cat: CategoryTreeNode) => void + onDelete: (id: string) => void +} + +export function CategoryTreeDnD({ categories, onMove, onEdit, onDelete }: CategoryTreeDnDProps) { + const [activeId, setActiveId] = useState(null) + const flatItems = flattenCategoryTree(categories) + const itemIds = flatItems.map((item) => item.category.id) + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(KeyboardSensor) + ) + + const handleDragStart = useCallback((event: DragStartEvent) => { + setActiveId(String(event.active.id)) + }, []) + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + setActiveId(null) + const { active, over } = event + if (!over || active.id === over.id) return + + const activeItem = flatItems.find((i) => i.category.id === active.id) + const overItem = flatItems.find((i) => i.category.id === over.id) + if (!activeItem || !overItem) return + + const newParentId = overItem.category.parentId + const siblingsAtLevel = flatItems.filter((i) => i.category.parentId === newParentId) + const overIndex = siblingsAtLevel.findIndex((i) => i.category.id === over.id) + const newSortOrder = Math.max(0, overIndex) + + onMove(String(active.id), newParentId, newSortOrder) + }, + [flatItems, onMove] + ) + + const activeItem = activeId ? flatItems.find((i) => i.category.id === activeId) : null + + return ( + + +
+ {categories.map((category) => ( + + ))} +
+
+ + {activeItem ? ( +
+

{activeItem.category.name}

+
+ ) : null} +
+
+ ) +} diff --git a/src/components/topic-form.tsx b/src/components/topic-form.tsx index 7ddd8ee..954d87c 100644 --- a/src/components/topic-form.tsx +++ b/src/components/topic-form.tsx @@ -8,7 +8,7 @@ 'use client' import { useState, useCallback } from 'react' -import type { CreateTopicInput } from '@/lib/api/types' +import type { CreateTopicInput, CategoryTreeNode } from '@/lib/api/types' import { cn } from '@/lib/utils' import { TopicMetaFields } from '@/components/topic-meta-fields' import { TopicContentEditor } from '@/components/topic-content-editor' @@ -22,18 +22,25 @@ interface TopicFormProps { onSubmit: (values: CreateTopicInput) => void | Promise initialValues?: Partial mode?: 'create' | 'edit' - categories?: Array<{ slug: string; name: string }> + categories?: CategoryTreeNode[] submitting?: boolean className?: string } -const CATEGORIES_FALLBACK = [ - { slug: 'general', name: 'General Discussion' }, - { slug: 'development', name: 'Development' }, - { slug: 'frontend', name: 'Frontend' }, - { slug: 'backend', name: 'Backend' }, - { slug: 'feedback', name: 'Feedback & Ideas' }, - { slug: 'meta', name: 'Meta' }, +const CATEGORIES_FALLBACK: CategoryTreeNode[] = [ + { + id: 'fallback-general', + slug: 'general', + name: 'General Discussion', + description: null, + parentId: null, + sortOrder: 0, + communityDid: '', + maturityRating: 'safe', + createdAt: '', + updatedAt: '', + children: [], + }, ] export function TopicForm({ diff --git a/src/components/topic-meta-fields.test.tsx b/src/components/topic-meta-fields.test.tsx new file mode 100644 index 0000000..4e98aa1 --- /dev/null +++ b/src/components/topic-meta-fields.test.tsx @@ -0,0 +1,86 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import { axe } from 'vitest-axe' +import { TopicMetaFields } from './topic-meta-fields' +import type { CategoryTreeNode } from '@/lib/api/types' + +const COMMUNITY_DID = 'did:plc:test' +const NOW = '2026-01-01T00:00:00.000Z' + +const treeCategories: CategoryTreeNode[] = [ + { + id: 'cat-1', + slug: 'general', + name: 'General', + description: null, + parentId: null, + sortOrder: 0, + communityDid: COMMUNITY_DID, + maturityRating: 'safe', + createdAt: NOW, + updatedAt: NOW, + children: [], + }, + { + id: 'cat-2', + slug: 'dev', + name: 'Development', + description: null, + parentId: null, + sortOrder: 1, + communityDid: COMMUNITY_DID, + maturityRating: 'safe', + createdAt: NOW, + updatedAt: NOW, + children: [ + { + id: 'cat-3', + slug: 'frontend', + name: 'Frontend', + description: null, + parentId: 'cat-2', + sortOrder: 0, + communityDid: COMMUNITY_DID, + maturityRating: 'safe', + createdAt: NOW, + updatedAt: NOW, + children: [], + }, + ], + }, +] + +describe('TopicMetaFields', () => { + const defaultProps = { + title: '', + category: '', + tagInput: '', + categories: treeCategories, + errors: {}, + onTitleChange: vi.fn(), + onCategoryChange: vi.fn(), + onTagInputChange: vi.fn(), + } + + it('renders category options with hierarchy indentation', () => { + render() + const select = screen.getByLabelText(/category/i) as HTMLSelectElement + const options = Array.from(select.options) + // First is placeholder + expect(options[0]).toHaveTextContent('Select a category') + // "General" at root + expect(options[1]).toHaveTextContent('General') + // "Development" at root + expect(options[2]).toHaveTextContent('Development') + // "Frontend" indented under Development (contains non-breaking spaces) + const frontendOption = options[3] + expect(frontendOption?.textContent).toContain('Frontend') + expect(frontendOption?.value).toBe('frontend') + }) + + it('passes axe accessibility check', async () => { + const { container } = render() + const results = await axe(container) + expect(results).toHaveNoViolations() + }) +}) diff --git a/src/components/topic-meta-fields.tsx b/src/components/topic-meta-fields.tsx index 966eea6..8ed55c3 100644 --- a/src/components/topic-meta-fields.tsx +++ b/src/components/topic-meta-fields.tsx @@ -5,12 +5,14 @@ import { cn } from '@/lib/utils' import { FormLabel } from '@/components/ui/form-label' +import type { CategoryTreeNode } from '@/lib/api/types' +import { flattenCategoryTree } from '@/lib/flatten-category-tree' interface TopicMetaFieldsProps { title: string category: string tagInput: string - categories: Array<{ slug: string; name: string }> + categories: CategoryTreeNode[] errors: { title?: string; category?: string } onTitleChange: (title: string) => void onCategoryChange: (category: string) => void @@ -73,8 +75,9 @@ export function TopicMetaFields({ )} > - {categories.map((cat) => ( + {flattenCategoryTree(categories).map(({ category: cat, depth }) => ( ))} diff --git a/src/lib/flatten-category-tree.test.ts b/src/lib/flatten-category-tree.test.ts new file mode 100644 index 0000000..b2ffc43 --- /dev/null +++ b/src/lib/flatten-category-tree.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest' +import { flattenCategoryTree } from './flatten-category-tree' +import type { CategoryTreeNode } from '@/lib/api/types' + +const COMMUNITY_DID = 'did:plc:test' +const NOW = '2026-01-01T00:00:00.000Z' + +function makeCategory( + overrides: Partial & { id: string; name: string } +): CategoryTreeNode { + return { + slug: overrides.id, + description: null, + parentId: null, + sortOrder: 0, + communityDid: COMMUNITY_DID, + maturityRating: 'safe', + createdAt: NOW, + updatedAt: NOW, + children: [], + ...overrides, + } +} + +describe('flattenCategoryTree', () => { + it('returns empty array for empty input', () => { + expect(flattenCategoryTree([])).toEqual([]) + }) + + it('flattens a single root category', () => { + const tree = [makeCategory({ id: 'cat-1', name: 'General' })] + const result = flattenCategoryTree(tree) + expect(result).toEqual([{ category: tree[0], depth: 0 }]) + }) + + it('flattens nested categories in depth-first order', () => { + const tree: CategoryTreeNode[] = [ + makeCategory({ + id: 'cat-1', + name: 'Dev', + children: [ + makeCategory({ id: 'cat-2', name: 'Frontend', parentId: 'cat-1' }), + makeCategory({ + id: 'cat-3', + name: 'Backend', + parentId: 'cat-1', + children: [makeCategory({ id: 'cat-4', name: 'Databases', parentId: 'cat-3' })], + }), + ], + }), + makeCategory({ id: 'cat-5', name: 'Meta' }), + ] + const result = flattenCategoryTree(tree) + expect(result.map((r) => ({ id: r.category.id, depth: r.depth }))).toEqual([ + { id: 'cat-1', depth: 0 }, + { id: 'cat-2', depth: 1 }, + { id: 'cat-3', depth: 1 }, + { id: 'cat-4', depth: 2 }, + { id: 'cat-5', depth: 0 }, + ]) + }) + + it('excludes a category and all its descendants', () => { + const tree: CategoryTreeNode[] = [ + makeCategory({ + id: 'cat-1', + name: 'Dev', + children: [makeCategory({ id: 'cat-2', name: 'Frontend', parentId: 'cat-1' })], + }), + makeCategory({ id: 'cat-3', name: 'Meta' }), + ] + const result = flattenCategoryTree(tree, { excludeId: 'cat-1' }) + expect(result).toEqual([{ category: tree[1], depth: 0 }]) + }) +}) diff --git a/src/lib/flatten-category-tree.ts b/src/lib/flatten-category-tree.ts new file mode 100644 index 0000000..2ec8d33 --- /dev/null +++ b/src/lib/flatten-category-tree.ts @@ -0,0 +1,29 @@ +import type { CategoryTreeNode } from '@/lib/api/types' + +export interface FlatCategory { + category: CategoryTreeNode + depth: number +} + +interface FlattenOptions { + /** Exclude this category ID and all its descendants. */ + excludeId?: string +} + +export function flattenCategoryTree( + tree: CategoryTreeNode[], + options: FlattenOptions = {} +): FlatCategory[] { + const result: FlatCategory[] = [] + + function walk(nodes: CategoryTreeNode[], depth: number): void { + for (const node of nodes) { + if (options.excludeId && node.id === options.excludeId) continue + result.push({ category: node, depth }) + walk(node.children, depth + 1) + } + } + + walk(tree, 0) + return result +}