feat(FN-2218): add Markdown/Raw toggle for task documents
- Add Markdown/Raw rendering toggle support to DocumentsView and TaskDocumentsTab for consistent document preview behavior - Reuse workflow-style toggle UX and markdown CSS patterns to align with existing dashboard design conventions - Add comprehensive tests for DocumentsView and TaskDocumentsTab toggle interactions and rendering states - Update dashboard guide documentation to describe the new Markdown/Raw toggle behavior
This commit is contained in:
@@ -86,9 +86,19 @@ Features:
|
||||
- Search documents across tasks
|
||||
- Open project markdown files with inline preview
|
||||
- Jump directly from a document group to the owning task detail modal
|
||||
- Toggle between raw text and rendered markdown using the **Markdown/Plain** button
|
||||
|
||||

|
||||
|
||||
### Markdown Rendering
|
||||
|
||||
Documents view supports toggling between raw text and formatted markdown when viewing document content:
|
||||
|
||||
- **Raw mode** (default): Shows markdown syntax as plain text (e.g., `**bold**`)
|
||||
- **Markdown mode**: Renders markdown with proper formatting (e.g., **bold**, headings, lists, tables)
|
||||
|
||||
The toggle button is accessible with `aria-pressed` for screen readers. Toggle state is scoped per-document, so switching between documents resets the view to raw mode.
|
||||
|
||||
### File Browser and Editor Tools
|
||||
|
||||
Fusion also includes file browsing/editing tools (for project root and task worktrees) in file/terminal workflows, so you can inspect generated artifacts and make targeted edits without leaving the dashboard.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useMemo, useCallback, useEffect, useRef, type ChangeEvent } from "react";
|
||||
import { ArrowLeft, FileText, ChevronDown, ChevronUp, ChevronRight, RefreshCw, Search, X } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { TaskDocumentWithTask, TaskDetail } from "@fusion/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { fetchTaskDetail, fetchWorkspaceFileContent, type MarkdownFileEntry } from "../api";
|
||||
@@ -18,6 +20,8 @@ export interface DocumentsViewProps {
|
||||
|
||||
interface DocumentCardProps {
|
||||
document: TaskDocumentWithTask;
|
||||
renderMarkdown: boolean;
|
||||
onToggleMarkdown: () => void;
|
||||
}
|
||||
|
||||
interface TaskGroupProps {
|
||||
@@ -25,6 +29,8 @@ interface TaskGroupProps {
|
||||
taskTitle?: string;
|
||||
documents: TaskDocumentWithTask[];
|
||||
onOpenTask: (taskId: string) => void;
|
||||
renderMarkdownStates: Map<string, boolean>;
|
||||
onToggleMarkdown: (docId: string) => void;
|
||||
}
|
||||
|
||||
function formatTimestamp(iso?: string): string {
|
||||
@@ -49,7 +55,7 @@ function getContentPreview(content: string, maxLength: number = 200): string {
|
||||
return `${content.substring(0, maxLength)}…`;
|
||||
}
|
||||
|
||||
function DocumentCard({ document }: DocumentCardProps) {
|
||||
function DocumentCard({ document, renderMarkdown, onToggleMarkdown }: DocumentCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const preview = getContentPreview(document.content);
|
||||
@@ -63,14 +69,16 @@ function DocumentCard({ document }: DocumentCardProps) {
|
||||
<span className="document-card-key-text">{document.key}</span>
|
||||
<span className="document-card-revision-badge">v{document.revision}</span>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-sm document-card-expand-btn"
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
title={expanded ? "Collapse" : "Expand"}
|
||||
aria-label={expanded ? "Collapse content" : "Expand content"}
|
||||
>
|
||||
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
<div className="document-card-actions">
|
||||
<button
|
||||
className="btn btn-sm document-card-expand-btn"
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
title={expanded ? "Collapse" : "Expand"}
|
||||
aria-label={expanded ? "Collapse content" : "Expand content"}
|
||||
>
|
||||
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="document-card-meta">
|
||||
@@ -81,7 +89,28 @@ function DocumentCard({ document }: DocumentCardProps) {
|
||||
|
||||
<div className={`document-card-content${expanded ? " document-card-content--expanded" : ""}`}>
|
||||
{expanded ? (
|
||||
<pre className="document-card-content-text">{document.content}</pre>
|
||||
<>
|
||||
<div className="document-card-content-header">
|
||||
<button
|
||||
className="btn btn-sm document-mode-toggle"
|
||||
onClick={onToggleMarkdown}
|
||||
aria-label={renderMarkdown ? "Switch to plain text" : "Switch to markdown"}
|
||||
aria-pressed={renderMarkdown}
|
||||
title={renderMarkdown ? "Switch to plain text" : "Switch to markdown"}
|
||||
>
|
||||
{renderMarkdown ? "Markdown" : "Plain"}
|
||||
</button>
|
||||
</div>
|
||||
{renderMarkdown ? (
|
||||
<div className="document-card-content-markdown">
|
||||
<div className="markdown-body">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{document.content}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="document-card-content-text">{document.content}</pre>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="document-card-preview">{preview}</p>
|
||||
)}
|
||||
@@ -93,7 +122,7 @@ function DocumentCard({ document }: DocumentCardProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function TaskGroup({ taskId, taskTitle, documents, onOpenTask }: TaskGroupProps) {
|
||||
function TaskGroup({ taskId, taskTitle, documents, onOpenTask, renderMarkdownStates, onToggleMarkdown }: TaskGroupProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -129,6 +158,8 @@ function TaskGroup({ taskId, taskTitle, documents, onOpenTask }: TaskGroupProps)
|
||||
<DocumentCard
|
||||
key={doc.id}
|
||||
document={doc}
|
||||
renderMarkdown={renderMarkdownStates.get(doc.id) ?? false}
|
||||
onToggleMarkdown={() => onToggleMarkdown(doc.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -147,6 +178,10 @@ export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsVi
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const requestIdRef = useRef(0);
|
||||
const initialTabSetRef = useRef(false);
|
||||
// Markdown render toggle for project file preview
|
||||
const [renderProjectMarkdown, setRenderProjectMarkdown] = useState(false);
|
||||
// Markdown render toggles per task document card (scoped by doc ID)
|
||||
const [taskDocMarkdownStates, setTaskDocMarkdownStates] = useState<Map<string, boolean>>(new Map());
|
||||
|
||||
const taskSearchQuery = activeTab === "tasks" ? searchQuery.trim() : "";
|
||||
|
||||
@@ -188,6 +223,8 @@ export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsVi
|
||||
setFileContent(null);
|
||||
setFileError(null);
|
||||
setFileLoading(false);
|
||||
setRenderProjectMarkdown(false);
|
||||
setTaskDocMarkdownStates(new Map());
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -310,6 +347,15 @@ export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsVi
|
||||
setFileLoading(false);
|
||||
}, []);
|
||||
|
||||
const handleToggleTaskDocMarkdown = useCallback((docId: string) => {
|
||||
setTaskDocMarkdownStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
const current = next.get(docId) ?? false;
|
||||
next.set(docId, !current);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const activeError = activeTab === "project" ? projectFilesError : documentsError;
|
||||
|
||||
const handleRetry = useCallback(async () => {
|
||||
@@ -456,11 +502,28 @@ export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsVi
|
||||
</div>
|
||||
) : (
|
||||
<div className="documents-content-viewer">
|
||||
<p className="documents-file-path-header">{selectedFile.path}</p>
|
||||
<div className="documents-content-header">
|
||||
<p className="documents-file-path-header">{selectedFile.path}</p>
|
||||
<button
|
||||
className="btn btn-sm document-mode-toggle"
|
||||
onClick={() => setRenderProjectMarkdown((prev) => !prev)}
|
||||
aria-label={renderProjectMarkdown ? "Switch to plain text" : "Switch to markdown"}
|
||||
aria-pressed={renderProjectMarkdown}
|
||||
title={renderProjectMarkdown ? "Switch to plain text" : "Switch to markdown"}
|
||||
>
|
||||
{renderProjectMarkdown ? "Markdown" : "Plain"}
|
||||
</button>
|
||||
</div>
|
||||
{fileLoading ? (
|
||||
<p className="documents-content-state">Loading file content…</p>
|
||||
) : fileError ? (
|
||||
<p className="documents-content-state documents-content-state--error">{fileError}</p>
|
||||
) : renderProjectMarkdown ? (
|
||||
<div className="documents-content-markdown">
|
||||
<div className="markdown-body">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{fileContent ?? ""}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="document-card-content-text documents-content-viewer-text">{fileContent ?? ""}</pre>
|
||||
)}
|
||||
@@ -498,6 +561,8 @@ export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsVi
|
||||
taskTitle={taskTitle}
|
||||
documents={taskDocs}
|
||||
onOpenTask={handleOpenTask}
|
||||
renderMarkdownStates={taskDocMarkdownStates}
|
||||
onToggleMarkdown={handleToggleTaskDocMarkdown}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { FileText, ChevronDown, ChevronUp, Plus, Trash2, History, X } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Task, TaskDocument, TaskDocumentRevision } from "@fusion/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import {
|
||||
@@ -54,6 +56,7 @@ export function TaskDocumentsTab({
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deletingKey, setDeletingKey] = useState<string | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [renderMarkdown, setRenderMarkdown] = useState(false);
|
||||
|
||||
const loadDocuments = useCallback(async () => {
|
||||
try {
|
||||
@@ -78,6 +81,7 @@ export function TaskDocumentsTab({
|
||||
setEditContent("");
|
||||
setShowHistory(null);
|
||||
setRevisions([]);
|
||||
setRenderMarkdown(false);
|
||||
} else {
|
||||
setExpandedDocKey(doc.key);
|
||||
setExpandedContent(doc.content);
|
||||
@@ -85,6 +89,7 @@ export function TaskDocumentsTab({
|
||||
setEditContent("");
|
||||
setShowHistory(null);
|
||||
setRevisions([]);
|
||||
setRenderMarkdown(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,8 +294,27 @@ export function TaskDocumentsTab({
|
||||
{/* Expanded Content View */}
|
||||
{expandedDocKey === doc.key && editingDocKey !== doc.key && (
|
||||
<>
|
||||
<div className="task-document-content-header">
|
||||
<button
|
||||
className="btn btn-sm document-mode-toggle"
|
||||
onClick={() => setRenderMarkdown((prev) => !prev)}
|
||||
aria-label={renderMarkdown ? "Switch to plain text" : "Switch to markdown"}
|
||||
aria-pressed={renderMarkdown}
|
||||
title={renderMarkdown ? "Switch to plain text" : "Switch to markdown"}
|
||||
>
|
||||
{renderMarkdown ? "Markdown" : "Plain"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="task-document-content">
|
||||
<pre className="task-document-content-text">{expandedContent}</pre>
|
||||
{renderMarkdown ? (
|
||||
<div className="task-document-content-markdown">
|
||||
<div className="markdown-body">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{expandedContent}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="task-document-content-text">{expandedContent}</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Revision History */}
|
||||
|
||||
@@ -296,4 +296,131 @@ describe("DocumentsView", () => {
|
||||
expect(await screen.findByText("cannot read file")).toBeInTheDocument();
|
||||
expect(addToast).toHaveBeenCalledWith("cannot read file", "error");
|
||||
});
|
||||
|
||||
it("project file preview defaults to raw text mode", async () => {
|
||||
mockFetchWorkspaceFileContent.mockResolvedValue({
|
||||
content: "# Hello\n\nThis is **bold**",
|
||||
mtime: "2026-04-19T12:00:00.000Z",
|
||||
size: 28,
|
||||
});
|
||||
|
||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open README.md" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchWorkspaceFileContent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should show raw text by default
|
||||
expect(screen.getByText(/# Hello/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/\*\*bold\*\*/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("project file preview can toggle to markdown mode", async () => {
|
||||
mockFetchWorkspaceFileContent.mockResolvedValue({
|
||||
content: "# Hello\n\nThis is **bold**",
|
||||
mtime: "2026-04-19T12:00:00.000Z",
|
||||
size: 28,
|
||||
});
|
||||
|
||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open README.md" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchWorkspaceFileContent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Toggle button should exist with raw mode
|
||||
const toggleBtn = screen.getByRole("button", { name: /switch to markdown/i });
|
||||
expect(toggleBtn).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
// Click to toggle
|
||||
fireEvent.click(toggleBtn);
|
||||
|
||||
// Should now be in markdown mode
|
||||
expect(screen.getByRole("button", { name: /switch to plain text/i })).toHaveAttribute("aria-pressed", "true");
|
||||
// Bold text should be rendered as <strong>
|
||||
const strongEl = await screen.findByText("bold");
|
||||
expect(strongEl.tagName).toBe("STRONG");
|
||||
});
|
||||
|
||||
it("project file markdown toggle state is independent from task document toggles", async () => {
|
||||
// Set up project files with markdown content
|
||||
mockFetchWorkspaceFileContent.mockResolvedValue({
|
||||
content: "# Project README",
|
||||
mtime: "2026-04-19T12:00:00.000Z",
|
||||
size: 18,
|
||||
});
|
||||
|
||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
|
||||
// Toggle project file to markdown mode
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open README.md" }));
|
||||
await waitFor(() => {
|
||||
expect(mockFetchWorkspaceFileContent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const projectToggle = screen.getByRole("button", { name: /switch to markdown/i });
|
||||
fireEvent.click(projectToggle);
|
||||
|
||||
// Switch to tasks tab
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show task documents/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("KB-001")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Expand a task group
|
||||
fireEvent.click(screen.getByRole("button", { name: /expand documents for task KB-001/i }));
|
||||
|
||||
// Expand the document card
|
||||
const expandBtn = screen.getByRole("button", { name: /expand content/i });
|
||||
fireEvent.click(expandBtn);
|
||||
|
||||
// Task document toggle should default to raw (not influenced by project toggle)
|
||||
const taskToggle = screen.getByRole("button", { name: /switch to markdown/i });
|
||||
expect(taskToggle).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
// Toggle task document
|
||||
fireEvent.click(taskToggle);
|
||||
expect(screen.getByRole("button", { name: /switch to plain text/i })).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
// Switch back to project - project toggle should still be on
|
||||
fireEvent.click(screen.getByRole("tab", { name: /show project markdown files/i }));
|
||||
expect(screen.getByRole("button", { name: /switch to plain text/i })).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
|
||||
it("task document cards support markdown toggle when expanded", async () => {
|
||||
mockUseProjectMarkdownFiles.mockReturnValue({
|
||||
files: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("tab", { name: /show task documents/i })).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
// Expand task group
|
||||
fireEvent.click(screen.getByRole("button", { name: /expand documents for task KB-001/i }));
|
||||
|
||||
// Expand the document card
|
||||
const expandBtn = screen.getByRole("button", { name: /expand content/i });
|
||||
fireEvent.click(expandBtn);
|
||||
|
||||
// Should show raw text by default
|
||||
expect(screen.getByText("Alpha document content")).toBeInTheDocument();
|
||||
|
||||
// Toggle should exist
|
||||
const toggleBtn = screen.getByRole("button", { name: /switch to markdown/i });
|
||||
expect(toggleBtn).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
// Click to toggle to markdown mode
|
||||
fireEvent.click(toggleBtn);
|
||||
expect(screen.getByRole("button", { name: /switch to plain text/i })).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import type { TaskDocument } from "@fusion/core";
|
||||
import { TaskDocumentsTab } from "../TaskDocumentsTab";
|
||||
import { fetchTaskDocuments, fetchTaskDocumentRevisions } from "../../api";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchTaskDocuments: vi.fn(),
|
||||
fetchTaskDocument: vi.fn(),
|
||||
fetchTaskDocumentRevisions: vi.fn(),
|
||||
putTaskDocument: vi.fn(),
|
||||
deleteTaskDocument: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchTaskDocuments = vi.mocked(fetchTaskDocuments);
|
||||
const mockFetchTaskDocumentRevisions = vi.mocked(fetchTaskDocumentRevisions);
|
||||
|
||||
const mockDocuments: TaskDocument[] = [
|
||||
{
|
||||
id: "doc-1",
|
||||
taskId: "KB-001",
|
||||
key: "plan",
|
||||
content: "This is the **plan** content",
|
||||
revision: 1,
|
||||
author: "agent",
|
||||
createdAt: "2026-04-19T10:00:00.000Z",
|
||||
updatedAt: "2026-04-19T12:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "doc-2",
|
||||
taskId: "KB-001",
|
||||
key: "notes",
|
||||
content: "# Notes\n\n- Item 1\n- Item 2",
|
||||
revision: 2,
|
||||
author: "user",
|
||||
createdAt: "2026-04-19T09:00:00.000Z",
|
||||
updatedAt: "2026-04-19T11:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
describe("TaskDocumentsTab", () => {
|
||||
const addToast = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchTaskDocuments.mockResolvedValue(mockDocuments);
|
||||
mockFetchTaskDocumentRevisions.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("renders document list", async () => {
|
||||
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("plan")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("notes")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows empty state when no documents", async () => {
|
||||
mockFetchTaskDocuments.mockResolvedValue([]);
|
||||
|
||||
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No documents yet.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("expands document to show content", async () => {
|
||||
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("plan")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const expandButton = screen.getAllByRole("button", { name: /expand/i })[0];
|
||||
fireEvent.click(expandButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/This is the \*\*plan\*\* content/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("collapses document when expand button clicked again", async () => {
|
||||
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("plan")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const expandButton = screen.getAllByRole("button", { name: /expand/i })[0];
|
||||
fireEvent.click(expandButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/This is the \*\*plan\*\* content/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /collapse/i }));
|
||||
|
||||
// Content should be hidden
|
||||
expect(screen.queryByText(/This is the/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("markdown toggle", () => {
|
||||
it("defaults to raw text mode", async () => {
|
||||
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("plan")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const expandButton = screen.getAllByRole("button", { name: /expand/i })[0];
|
||||
fireEvent.click(expandButton);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show raw markdown syntax
|
||||
expect(screen.getByText(/\*\*plan\*\*/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("toggles to markdown render mode", async () => {
|
||||
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("plan")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const expandButton = screen.getAllByRole("button", { name: /expand/i })[0];
|
||||
fireEvent.click(expandButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/\*\*plan\*\*/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find and click the markdown toggle
|
||||
const toggleBtn = screen.getByRole("button", { name: /switch to markdown/i });
|
||||
expect(toggleBtn).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
fireEvent.click(toggleBtn);
|
||||
|
||||
// Should now be in markdown mode
|
||||
expect(screen.getByRole("button", { name: /switch to plain text/i })).toHaveAttribute("aria-pressed", "true");
|
||||
// Bold text should be rendered as <strong> - use a data-testid to scope the query
|
||||
const container = document.querySelector(".task-document-content-markdown");
|
||||
expect(container).not.toBeNull();
|
||||
const strongEl = container!.querySelector("strong");
|
||||
expect(strongEl).not.toBeNull();
|
||||
expect(strongEl!.textContent).toBe("plan");
|
||||
});
|
||||
|
||||
it("toggles back to raw text mode", async () => {
|
||||
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("plan")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const expandButton = screen.getAllByRole("button", { name: /expand/i })[0];
|
||||
fireEvent.click(expandButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/\*\*plan\*\*/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Toggle to markdown mode
|
||||
fireEvent.click(screen.getByRole("button", { name: /switch to markdown/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector(".task-document-content-markdown strong")).not.toBeNull();
|
||||
});
|
||||
|
||||
// Toggle back to raw text
|
||||
fireEvent.click(screen.getByRole("button", { name: /switch to plain text/i }));
|
||||
|
||||
// Should be back to raw text mode
|
||||
expect(screen.getByText(/\*\*plan\*\*/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("resets markdown mode when switching documents", async () => {
|
||||
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("plan")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Expand first document and enable markdown mode
|
||||
const expandButtons = screen.getAllByRole("button", { name: /expand/i });
|
||||
fireEvent.click(expandButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/\*\*plan\*\*/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /switch to markdown/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector(".task-document-content-markdown strong")).not.toBeNull();
|
||||
});
|
||||
|
||||
// Collapse first document
|
||||
fireEvent.click(screen.getByRole("button", { name: /collapse/i }));
|
||||
|
||||
// Expand second document
|
||||
fireEvent.click(expandButtons[1]);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should be in raw text mode by default
|
||||
expect(screen.getByText(/# Notes/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Toggle button should be in raw text mode
|
||||
const toggleBtn = screen.getByRole("button", { name: /switch to markdown/i });
|
||||
expect(toggleBtn).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
it("resets markdown mode when collapsing document", async () => {
|
||||
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("plan")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Expand document and enable markdown mode
|
||||
const expandButton = screen.getAllByRole("button", { name: /expand/i })[0];
|
||||
fireEvent.click(expandButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/\*\*plan\*\*/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /switch to markdown/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector(".task-document-content-markdown strong")).not.toBeNull();
|
||||
});
|
||||
|
||||
// Collapse document
|
||||
fireEvent.click(screen.getByRole("button", { name: /collapse/i }));
|
||||
|
||||
// Re-expand document (first expand button after collapse)
|
||||
fireEvent.click(screen.getAllByRole("button", { name: /expand/i })[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should be in raw text mode by default
|
||||
expect(screen.getByText(/\*\*plan\*\*/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const toggleBtn = screen.getByRole("button", { name: /switch to markdown/i });
|
||||
expect(toggleBtn).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
it("renders markdown list syntax", async () => {
|
||||
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("notes")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Expand the notes document (second one)
|
||||
const expandButtons = screen.getAllByRole("button", { name: /expand/i });
|
||||
fireEvent.click(expandButtons[1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/# Notes/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Toggle to markdown mode
|
||||
fireEvent.click(screen.getByRole("button", { name: /switch to markdown/i }));
|
||||
|
||||
// Check heading is rendered
|
||||
const heading = await screen.findByRole("heading", { name: "Notes" });
|
||||
expect(heading).toBeInTheDocument();
|
||||
|
||||
// Check list items are rendered
|
||||
expect(screen.getByText("Item 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Item 2")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7018,6 +7018,12 @@ input[type="range"]:focus-visible {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
/* Document mode toggle: ensure adequate touch target on mobile */
|
||||
.document-mode-toggle {
|
||||
min-width: 36px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
/* Board: flex layout with single horizontal scroll + snap */
|
||||
.board {
|
||||
display: flex;
|
||||
@@ -27664,6 +27670,13 @@ html .column.drag-over * {
|
||||
width: min(100%, 880px);
|
||||
}
|
||||
|
||||
.documents-content-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.documents-file-path-header {
|
||||
margin: 0;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
@@ -27674,6 +27687,7 @@ html .column.drag-over * {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
word-break: break-word;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.documents-content-viewer-text {
|
||||
@@ -27684,6 +27698,15 @@ html .column.drag-over * {
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
/* Container chrome only; markdown styles delegated to .markdown-body */
|
||||
.documents-content-markdown {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
padding: var(--space-md);
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.documents-content-state {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
@@ -27864,6 +27887,51 @@ html .column.drag-over * {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.document-card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.document-mode-toggle {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.document-mode-toggle:hover {
|
||||
background: var(--card-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.document-mode-toggle:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
.document-mode-toggle[aria-pressed="true"] {
|
||||
background: var(--accent);
|
||||
color: var(--text-on-accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.document-card-content-markdown {
|
||||
/* Container chrome only; markdown styles delegated to .markdown-body */
|
||||
}
|
||||
|
||||
.document-card-content-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.document-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -34611,6 +34679,16 @@ html .column.drag-over * {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.task-document-content-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.task-document-content-markdown {
|
||||
/* Container chrome only; markdown styles delegated to .markdown-body */
|
||||
}
|
||||
|
||||
.task-document-edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user