diff --git a/apps/admin/src/app/(authenticated)/_components/QuickDraftCard.test.tsx b/apps/admin/src/app/(authenticated)/_components/QuickDraftCard.test.tsx new file mode 100644 index 00000000..fd052ed0 --- /dev/null +++ b/apps/admin/src/app/(authenticated)/_components/QuickDraftCard.test.tsx @@ -0,0 +1,109 @@ +/** + * Tests for the dashboard QuickDraftCard widget. We pin three flows: + * + * 1. Successful POST clears the form and shows the inline success chip. + * 2. Server-side error surfaces in the danger chip with the API message. + * 3. Submitting an empty form refuses the request and shows a hint. + * + * The fetch wrapper (`apps/admin/src/lib/api-client`) is mocked so the + * tests don't go anywhere near the network. + */ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +const postMock = vi.fn(); + +vi.mock('@/lib/api-client', async () => { + const actual = await vi.importActual( + '@/lib/api-client', + ); + return { + ...actual, + api: { + get: vi.fn(), + post: (...args: unknown[]) => postMock(...args), + put: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + }, + }; +}); + +import { ApiError } from '@/lib/api-client'; +import { QuickDraftCard } from './QuickDraftCard'; + +describe('QuickDraftCard', () => { + beforeEach(() => { + postMock.mockReset(); + }); + + it('posts a draft with the expected payload and clears the form', async () => { + postMock.mockResolvedValueOnce({ id: 'p_123' }); + + render(); + + fireEvent.change(screen.getByTestId('quick-draft-title'), { + target: { value: 'Brew journal' }, + }); + fireEvent.change(screen.getByTestId('quick-draft-content'), { + target: { value: 'Today I tried a 1:16 ratio.\n\nNext: try 1:17.' }, + }); + fireEvent.submit(screen.getByTestId('quick-draft-form')); + + await waitFor(() => expect(postMock).toHaveBeenCalledTimes(1)); + expect(postMock).toHaveBeenCalledWith('/api/v1/posts', { + status: 'draft', + title: 'Brew journal', + content_blocks: { + version: 1, + blocks: [ + { type: 'core/paragraph', attributes: { content: 'Today I tried a 1:16 ratio.' } }, + { type: 'core/paragraph', attributes: { content: 'Next: try 1:17.' } }, + ], + }, + }); + + await waitFor(() => + expect(screen.getByTestId('quick-draft-status').textContent).toMatch( + /Draft saved/, + ), + ); + expect( + (screen.getByTestId('quick-draft-title') as HTMLInputElement).value, + ).toBe(''); + expect( + (screen.getByTestId('quick-draft-content') as HTMLTextAreaElement).value, + ).toBe(''); + }); + + it('surfaces the server error message on failure', async () => { + postMock.mockRejectedValueOnce( + new ApiError(422, 'Unprocessable Entity', { + error: { code: 'invalid', message: 'Title too short.' }, + }), + ); + + render(); + + fireEvent.change(screen.getByTestId('quick-draft-title'), { + target: { value: 'X' }, + }); + fireEvent.submit(screen.getByTestId('quick-draft-form')); + + await waitFor(() => + expect(screen.getByTestId('quick-draft-status').textContent).toMatch( + /Title too short/, + ), + ); + }); + + it('refuses an empty form before hitting the API', () => { + render(); + fireEvent.submit(screen.getByTestId('quick-draft-form')); + + expect(postMock).not.toHaveBeenCalled(); + expect(screen.getByTestId('quick-draft-status').textContent).toMatch( + /Add a title or some content/, + ); + }); +}); diff --git a/apps/admin/src/app/(authenticated)/_components/QuickDraftCard.tsx b/apps/admin/src/app/(authenticated)/_components/QuickDraftCard.tsx new file mode 100644 index 00000000..e826f826 --- /dev/null +++ b/apps/admin/src/app/(authenticated)/_components/QuickDraftCard.tsx @@ -0,0 +1,233 @@ +/** + * QuickDraftCard — dashboard widget for capturing a fast post draft + * without leaving the pulse view. + * + * Drops into the "Site pulse" landing page next to the activity rail. + * The form is intentionally minimal — title + content textarea — and + * POSTs to `/api/v1/posts` with `status="draft"`. On success the inputs + * clear and an inline confirmation chip slides in for ~4s; on failure a + * red chip surfaces the server's error message. We use inline + * confirmation instead of the global toaster because the dashboard + * doesn't mount one and the chip stays anchored to the originating + * form, which is easier to scan than a corner toast. + * + * Why a client component? The dashboard page is rendered on the + * server; the form needs local state and a fetch handler, so it lives + * in its own `'use client'` island and the dashboard composes it in. + */ +'use client'; + +import { useEffect, useRef, useState, type ReactElement, type FormEvent } from 'react'; +import { Loader2, PenLine, Send } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { api, ApiError } from '@/lib/api-client'; + +type Status = + | { kind: 'idle' } + | { kind: 'submitting' } + | { kind: 'success'; title: string } + | { kind: 'error'; message: string }; + +/** + * Wrap the post body in the minimal block tree the renderer expects. + * One paragraph block per non-empty line keeps the markdown round-trip + * faithful without dragging the full markdown parser into this widget. + */ +function toContentBlocks(text: string): unknown { + const lines = text.split(/\r?\n/).filter((l) => l.trim().length > 0); + if (lines.length === 0) { + return { version: 1, blocks: [] }; + } + return { + version: 1, + blocks: lines.map((content) => ({ + type: 'core/paragraph', + attributes: { content }, + })), + }; +} + +export function QuickDraftCard(): ReactElement { + const [title, setTitle] = useState(''); + const [content, setContent] = useState(''); + const [status, setStatus] = useState({ kind: 'idle' }); + const dismissTimer = useRef | null>(null); + + useEffect(() => { + return () => { + if (dismissTimer.current) clearTimeout(dismissTimer.current); + }; + }, []); + + // Auto-dismiss success / error chips after 4s so the next interaction + // doesn't start under a stale confirmation. + useEffect(() => { + if (status.kind === 'success' || status.kind === 'error') { + if (dismissTimer.current) clearTimeout(dismissTimer.current); + dismissTimer.current = setTimeout( + () => setStatus({ kind: 'idle' }), + 4000, + ); + } + }, [status]); + + async function handleSubmit(event: FormEvent): Promise { + event.preventDefault(); + const trimmedTitle = title.trim(); + const trimmedContent = content.trim(); + if (trimmedTitle === '' && trimmedContent === '') { + setStatus({ + kind: 'error', + message: 'Add a title or some content before saving.', + }); + return; + } + + setStatus({ kind: 'submitting' }); + try { + const draftStatus = 'draft'; + await api.post('/api/v1/posts', { + status: draftStatus, + title: trimmedTitle || 'Untitled draft', + content_blocks: toContentBlocks(trimmedContent), + }); + setStatus({ kind: 'success', title: trimmedTitle || 'Untitled draft' }); + setTitle(''); + setContent(''); + } catch (err) { + const message = + err instanceof ApiError + ? extractApiErrorMessage(err) + : err instanceof Error + ? err.message + : 'Could not save the draft.'; + setStatus({ kind: 'error', message }); + } + } + + const isBusy = status.kind === 'submitting'; + + return ( +
+
+ +

+ Quick draft. +

+
+

+ Capture an idea before it slips. Saves as a draft you can finish + later from the posts list. +

+ +
+
+ + setTitle(e.target.value)} + placeholder="What's this about?" + disabled={isBusy} + data-testid="quick-draft-title" + /> +
+ +
+ +