From b74b093d85bdc1093a6015f35d4a46b56e7ad36e Mon Sep 17 00:00:00 2001 From: Guido X Jansen Date: Sat, 14 Feb 2026 11:27:29 +0100 Subject: [PATCH] feat(editor): add topic creation and edit pages (M6) (#7) - MarkdownEditor with WAI-ARIA toolbar (bold, italic, link, code, quote, list) with roving tabindex keyboard navigation - MarkdownPreview with live sanitized preview - TopicForm with title, category select, tag input, cross-post checkboxes (Bluesky default ON, Frontpage default OFF), client-side validation - New topic page at /new with breadcrumbs - Edit topic page at /t/[slug]/[rkey]/edit with pre-populated form - API client functions: createTopic, updateTopic with auth headers - MSW handlers for POST /api/topics and PUT /api/topics/:rkey - 39 new tests (157 total passing) --- package.json | 1 + pnpm-lock.yaml | 16 ++ src/app/new/page.test.tsx | 47 +++ src/app/new/page.tsx | 59 ++++ src/app/t/[slug]/[rkey]/edit/page.test.tsx | 43 +++ src/app/t/[slug]/[rkey]/edit/page.tsx | 153 ++++++++++ src/components/markdown-editor.test.tsx | 142 +++++++++ src/components/markdown-editor.tsx | 227 +++++++++++++++ src/components/markdown-preview.test.tsx | 37 +++ src/components/markdown-preview.tsx | 28 ++ src/components/topic-form.test.tsx | 153 ++++++++++ src/components/topic-form.tsx | 320 +++++++++++++++++++++ src/lib/api/client.ts | 39 +++ src/lib/api/types.ts | 16 ++ src/mocks/handlers.ts | 55 ++++ 15 files changed, 1336 insertions(+) create mode 100644 src/app/new/page.test.tsx create mode 100644 src/app/new/page.tsx create mode 100644 src/app/t/[slug]/[rkey]/edit/page.test.tsx create mode 100644 src/app/t/[slug]/[rkey]/edit/page.tsx create mode 100644 src/components/markdown-editor.test.tsx create mode 100644 src/components/markdown-editor.tsx create mode 100644 src/components/markdown-preview.test.tsx create mode 100644 src/components/markdown-preview.tsx create mode 100644 src/components/topic-form.test.tsx create mode 100644 src/components/topic-form.tsx diff --git a/package.json b/package.json index f27c6f5..4de65e5 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "@tailwindcss/postcss": "^4.0.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.1.0", + "@testing-library/user-event": "^14.6.1", "@types/node": "^22", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fbb902b..ad4b427 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -152,6 +152,9 @@ importers: '@testing-library/react': specifier: ^16.1.0 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) '@types/node': specifier: ^22 version: 22.19.11 @@ -2751,6 +2754,15 @@ packages: '@types/react-dom': optional: true + '@testing-library/user-event@14.6.1': + resolution: + { + integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==, + } + engines: { node: '>=12', npm: '>=6' } + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@tybys/wasm-util@0.10.1': resolution: { @@ -8763,6 +8775,10 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 diff --git a/src/app/new/page.test.tsx b/src/app/new/page.test.tsx new file mode 100644 index 0000000..b560a45 --- /dev/null +++ b/src/app/new/page.test.tsx @@ -0,0 +1,47 @@ +/** + * Tests for new topic page. + */ + +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import { setupServer } from 'msw/node' +import { handlers } from '@/mocks/handlers' + +const server = setupServer(...handlers) + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +// Mock next/navigation +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: vi.fn(), + replace: vi.fn(), + back: vi.fn(), + }), + redirect: vi.fn(), +})) + +describe('NewTopicPage', () => { + it('renders create topic heading', async () => { + const { default: NewTopicPage } = await import('./page') + render() + expect(screen.getByRole('heading', { name: 'Create New Topic' })).toBeInTheDocument() + }) + + it('renders topic form', async () => { + const { default: NewTopicPage } = await import('./page') + render() + expect(screen.getByLabelText('Title')).toBeInTheDocument() + expect(screen.getByLabelText('Content')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Create Topic' })).toBeInTheDocument() + }) + + it('renders breadcrumbs', async () => { + const { default: NewTopicPage } = await import('./page') + render() + expect(screen.getByText('Home')).toBeInTheDocument() + expect(screen.getByText('New Topic')).toBeInTheDocument() + }) +}) diff --git a/src/app/new/page.tsx b/src/app/new/page.tsx new file mode 100644 index 0000000..bdd69f2 --- /dev/null +++ b/src/app/new/page.tsx @@ -0,0 +1,59 @@ +/** + * New topic page - Create a new forum topic. + * URL: /new + * Client component (requires auth context + form state). + * @see specs/prd-web.md Section 3.2 + */ + +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import type { CreateTopicInput } from '@/lib/api/types' +import { createTopic } from '@/lib/api/client' +import { getTopicUrl } from '@/lib/format' +import { ForumLayout } from '@/components/layout/forum-layout' +import { Breadcrumbs } from '@/components/breadcrumbs' +import { TopicForm } from '@/components/topic-form' + +export default function NewTopicPage() { + const router = useRouter() + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + const handleSubmit = async (values: CreateTopicInput) => { + setSubmitting(true) + setError(null) + + try { + // TODO: Get access token from auth context when auth is implemented + const accessToken = '' + const topic = await createTopic(values, accessToken) + router.push(getTopicUrl(topic)) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create topic') + setSubmitting(false) + } + } + + return ( + +
+ + +

Create New Topic

+ + {error && ( +
+

{error}

+
+ )} + + +
+
+ ) +} diff --git a/src/app/t/[slug]/[rkey]/edit/page.test.tsx b/src/app/t/[slug]/[rkey]/edit/page.test.tsx new file mode 100644 index 0000000..459bdd3 --- /dev/null +++ b/src/app/t/[slug]/[rkey]/edit/page.test.tsx @@ -0,0 +1,43 @@ +/** + * Tests for edit topic page. + */ + +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import { setupServer } from 'msw/node' +import { handlers } from '@/mocks/handlers' +import EditTopicPage from './page' + +const server = setupServer(...handlers) + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +// Mock next/navigation +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: vi.fn(), + replace: vi.fn(), + back: vi.fn(), + }), + notFound: vi.fn(), + redirect: vi.fn(), +})) + +describe('EditTopicPage', () => { + it('renders edit topic heading', async () => { + render() + expect(await screen.findByRole('heading', { name: 'Edit Topic' })).toBeInTheDocument() + }) + + it('pre-populates form with topic data', async () => { + render() + expect(await screen.findByDisplayValue('Welcome to Barazo Forums')).toBeInTheDocument() + }) + + it('shows save button', async () => { + render() + expect(await screen.findByRole('button', { name: 'Save Changes' })).toBeInTheDocument() + }) +}) diff --git a/src/app/t/[slug]/[rkey]/edit/page.tsx b/src/app/t/[slug]/[rkey]/edit/page.tsx new file mode 100644 index 0000000..2b41f2a --- /dev/null +++ b/src/app/t/[slug]/[rkey]/edit/page.tsx @@ -0,0 +1,153 @@ +/** + * Edit topic page - Edit an existing forum topic. + * URL: /t/{slug}/{rkey}/edit + * Client component (requires auth context + form state). + * @see specs/prd-web.md Section 3.2 + */ + +'use client' + +import { useState, useEffect } from 'react' +import { useRouter } from 'next/navigation' +import type { CreateTopicInput, Topic } from '@/lib/api/types' +import { getTopicByRkey, updateTopic } from '@/lib/api/client' +import { getTopicUrl } from '@/lib/format' +import { ForumLayout } from '@/components/layout/forum-layout' +import { Breadcrumbs } from '@/components/breadcrumbs' +import { TopicForm } from '@/components/topic-form' + +interface EditTopicPageProps { + params: Promise<{ slug: string; rkey: string }> | { slug: string; rkey: string } +} + +export default function EditTopicPage({ params }: EditTopicPageProps) { + const router = useRouter() + const [rkey, setRkey] = useState(null) + const [topic, setTopic] = useState(null) + const [loading, setLoading] = useState(true) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + // Resolve params (handles both Promise and plain object) + useEffect(() => { + async function resolveParams() { + const resolved = params instanceof Promise ? await params : params + setRkey(resolved.rkey) + } + void resolveParams() + }, [params]) + + // Load topic once rkey is available + useEffect(() => { + if (!rkey) return + + let cancelled = false + async function loadTopic() { + try { + const loaded = await getTopicByRkey(rkey!) + if (!cancelled) { + setTopic(loaded) + setLoading(false) + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : 'Failed to load topic') + setLoading(false) + } + } + } + void loadTopic() + return () => { + cancelled = true + } + }, [rkey]) + + const handleSubmit = async (values: CreateTopicInput) => { + if (!rkey) return + setSubmitting(true) + setError(null) + + try { + // TODO: Get access token from auth context when auth is implemented + const accessToken = '' + const updated = await updateTopic( + rkey, + { + title: values.title, + content: values.content, + category: values.category, + tags: values.tags, + }, + accessToken + ) + router.push(getTopicUrl(updated)) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update topic') + setSubmitting(false) + } + } + + if (loading) { + return ( + +
+ +

+ Loading topic... +

+
+
+ ) + } + + if (!topic) { + return ( + +
+ +

+ {error ?? 'Topic not found'} +

+
+
+ ) + } + + return ( + +
+ + +

Edit Topic

+ + {error && ( +
+

{error}

+
+ )} + + +
+
+ ) +} diff --git a/src/components/markdown-editor.test.tsx b/src/components/markdown-editor.test.tsx new file mode 100644 index 0000000..3d8d1cb --- /dev/null +++ b/src/components/markdown-editor.test.tsx @@ -0,0 +1,142 @@ +/** + * Tests for MarkdownEditor component. + */ + +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { axe } from 'vitest-axe' +import { MarkdownEditor } from './markdown-editor' + +describe('MarkdownEditor', () => { + it('renders a labeled textarea', () => { + render() + expect(screen.getByRole('textbox', { name: 'Content' })).toBeInTheDocument() + }) + + it('calls onChange when typing', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + render() + await user.type(screen.getByRole('textbox', { name: 'Content' }), 'Hello') + expect(onChange).toHaveBeenCalled() + }) + + it('renders toolbar with formatting buttons', () => { + render() + const toolbar = screen.getByRole('toolbar', { name: 'Formatting' }) + expect(toolbar).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Bold' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Italic' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Link' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Code' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Quote' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'List' })).toBeInTheDocument() + }) + + it('wraps selected text with bold markers', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + render() + const textarea = screen.getByRole('textbox', { name: 'Content' }) as HTMLTextAreaElement + + // Select "world" + textarea.setSelectionRange(6, 11) + await user.click(screen.getByRole('button', { name: 'Bold' })) + expect(onChange).toHaveBeenCalledWith('hello **world**') + }) + + it('wraps selected text with italic markers', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + render() + const textarea = screen.getByRole('textbox', { name: 'Content' }) as HTMLTextAreaElement + + textarea.setSelectionRange(6, 11) + await user.click(screen.getByRole('button', { name: 'Italic' })) + expect(onChange).toHaveBeenCalledWith('hello *world*') + }) + + it('inserts link template when no selection', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + render() + const textarea = screen.getByRole('textbox', { name: 'Content' }) as HTMLTextAreaElement + + textarea.setSelectionRange(6, 6) + await user.click(screen.getByRole('button', { name: 'Link' })) + expect(onChange).toHaveBeenCalledWith('hello [text](url)') + }) + + it('wraps selected text with code markers', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + render() + const textarea = screen.getByRole('textbox', { name: 'Content' }) as HTMLTextAreaElement + + textarea.setSelectionRange(6, 10) + await user.click(screen.getByRole('button', { name: 'Code' })) + expect(onChange).toHaveBeenCalledWith('hello `code`') + }) + + it('prefixes line with quote marker', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + render() + const textarea = screen.getByRole('textbox', { name: 'Content' }) as HTMLTextAreaElement + + textarea.setSelectionRange(0, 5) + await user.click(screen.getByRole('button', { name: 'Quote' })) + expect(onChange).toHaveBeenCalledWith('> hello') + }) + + it('prefixes line with list marker', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + render() + const textarea = screen.getByRole('textbox', { name: 'Content' }) as HTMLTextAreaElement + + textarea.setSelectionRange(0, 5) + await user.click(screen.getByRole('button', { name: 'List' })) + expect(onChange).toHaveBeenCalledWith('- hello') + }) + + it('supports roving tabindex on toolbar buttons', async () => { + const user = userEvent.setup() + render() + + const boldBtn = screen.getByRole('button', { name: 'Bold' }) + const italicBtn = screen.getByRole('button', { name: 'Italic' }) + + // First button is tabbable, others have tabindex -1 + expect(boldBtn).toHaveAttribute('tabindex', '0') + expect(italicBtn).toHaveAttribute('tabindex', '-1') + + // Focus first button, arrow right to move focus + boldBtn.focus() + await user.keyboard('{ArrowRight}') + expect(italicBtn).toHaveFocus() + }) + + it('shows error message when provided', () => { + render( + + ) + expect(screen.getByText('Content is required')).toBeInTheDocument() + expect(screen.getByRole('textbox', { name: 'Content' })).toHaveAttribute('aria-invalid', 'true') + }) + + it('passes axe accessibility check', async () => { + const { container } = render( + + ) + const results = await axe(container) + expect(results).toHaveNoViolations() + }) +}) diff --git a/src/components/markdown-editor.tsx b/src/components/markdown-editor.tsx new file mode 100644 index 0000000..43fe7e6 --- /dev/null +++ b/src/components/markdown-editor.tsx @@ -0,0 +1,227 @@ +/** + * MarkdownEditor - Textarea with WAI-ARIA Toolbar for markdown formatting. + * Supports bold, italic, link, code, quote, and list formatting. + * Implements roving tabindex for toolbar keyboard navigation. + * @see specs/prd-web.md Section 4 (Editor Components) + */ + +'use client' + +import { useRef, useState, useCallback } from 'react' +import { TextB, TextItalic, Link as LinkIcon, Code, Quotes, List } from '@phosphor-icons/react' +import { cn } from '@/lib/utils' + +interface ToolbarAction { + label: string + icon: typeof TextB + apply: (value: string, start: number, end: number) => { result: string; cursor: number } +} + +const TOOLBAR_ACTIONS: ToolbarAction[] = [ + { + label: 'Bold', + icon: TextB, + apply: (value, start, end) => { + const selected = value.slice(start, end) + const replacement = selected ? `**${selected}**` : '**text**' + return { + result: value.slice(0, start) + replacement + value.slice(end), + cursor: selected ? start + replacement.length : start + 2, + } + }, + }, + { + label: 'Italic', + icon: TextItalic, + apply: (value, start, end) => { + const selected = value.slice(start, end) + const replacement = selected ? `*${selected}*` : '*text*' + return { + result: value.slice(0, start) + replacement + value.slice(end), + cursor: selected ? start + replacement.length : start + 1, + } + }, + }, + { + label: 'Link', + icon: LinkIcon, + apply: (value, start, end) => { + const selected = value.slice(start, end) + const replacement = selected ? `[${selected}](url)` : '[text](url)' + return { + result: value.slice(0, start) + replacement + value.slice(end), + cursor: selected ? start + selected.length + 3 : start + 1, + } + }, + }, + { + label: 'Code', + icon: Code, + apply: (value, start, end) => { + const selected = value.slice(start, end) + const replacement = selected ? `\`${selected}\`` : '`code`' + return { + result: value.slice(0, start) + replacement + value.slice(end), + cursor: selected ? start + replacement.length : start + 1, + } + }, + }, + { + label: 'Quote', + icon: Quotes, + apply: (value, start, end) => { + const selected = value.slice(start, end) + const replacement = `> ${selected || 'quote'}` + return { + result: value.slice(0, start) + replacement + value.slice(end), + cursor: start + replacement.length, + } + }, + }, + { + label: 'List', + icon: List, + apply: (value, start, end) => { + const selected = value.slice(start, end) + const replacement = `- ${selected || 'item'}` + return { + result: value.slice(0, start) + replacement + value.slice(end), + cursor: start + replacement.length, + } + }, + }, +] + +interface MarkdownEditorProps { + value: string + onChange: (value: string) => void + id: string + label: string + error?: string + className?: string + placeholder?: string +} + +export function MarkdownEditor({ + value, + onChange, + id, + label, + error, + className, + placeholder, +}: MarkdownEditorProps) { + const textareaRef = useRef(null) + const toolbarRef = useRef(null) + const [focusedIndex, setFocusedIndex] = useState(0) + + const handleAction = useCallback( + (action: ToolbarAction) => { + const textarea = textareaRef.current + if (!textarea) return + + const start = textarea.selectionStart + const end = textarea.selectionEnd + const { result, cursor } = action.apply(value, start, end) + + onChange(result) + + // Restore focus and cursor position after React re-render + requestAnimationFrame(() => { + textarea.focus() + textarea.setSelectionRange(cursor, cursor) + }) + }, + [value, onChange] + ) + + const handleToolbarKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const buttons = toolbarRef.current?.querySelectorAll('button') + if (!buttons?.length) return + + let newIndex = focusedIndex + + if (e.key === 'ArrowRight') { + e.preventDefault() + newIndex = (focusedIndex + 1) % buttons.length + } else if (e.key === 'ArrowLeft') { + e.preventDefault() + newIndex = (focusedIndex - 1 + buttons.length) % buttons.length + } else if (e.key === 'Home') { + e.preventDefault() + newIndex = 0 + } else if (e.key === 'End') { + e.preventDefault() + newIndex = buttons.length - 1 + } else { + return + } + + setFocusedIndex(newIndex) + buttons[newIndex]?.focus() + }, + [focusedIndex] + ) + + const errorId = error ? `${id}-error` : undefined + + return ( +
+ + + {/* Toolbar */} +
+ {TOOLBAR_ACTIONS.map((action, index) => { + const Icon = action.icon + return ( + + ) + })} +
+ + {/* Textarea */} +