FN-7833: render task Artifacts documents as Markdown, expanded by default
Renders every task document in the Artifacts tab expanded and as rendered Markdown by default, with the Markdown/Plain preference persisted across sessions instead of resetting per document. - Replace single expandedDocKey/expandedContent state with a multi-key expandedDocKeys Set plus a per-document revisionContentByKey map so multiple documents can be expanded simultaneously. - Default renderMarkdown to true and persist the operator's Markdown/Plain toggle choice to localStorage (fusion.taskDocuments.renderMarkdown) via readBooleanPref/writeBooleanPref helpers. - Auto-expand newly loaded documents while preserving collapse state for documents the user has explicitly collapsed, tracked per taskId. - Update handleStartEdit and handleViewRevision to operate per-document-key instead of a single global expanded document. - Add changeset (@runfusion/fusion patch) describing the Artifacts-tab default-expanded Markdown behavior. - Rework TaskDocumentsTab tests for multi-document expand/collapse, per-card markdown rendering, and localStorage-backed preference persistence. Files changed: .changeset/fn-7833-artifacts-tab-markdown-expand-default.md | 7 + packages/dashboard/app/components/TaskDocumentsTab.tsx | 149 +++++++++----- packages/dashboard/app/components/__tests__/TaskDocumentsTab.test.tsx | 223 +++++---------------- 3 files changed, 160 insertions(+), 219 deletions(-) Fusion-Task-Id: FN-7833 Fusion-Task-Lineage: 1915c594-7191-47a2-962b-18136d572cf1 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Show task Artifacts-tab documents expanded with Markdown by default.
|
||||||
|
category: feature
|
||||||
|
dev: TaskDocumentsTab now uses multi-expand document state and persists the Markdown/Plain preference.
|
||||||
@@ -22,6 +22,27 @@ import { ArtifactMedia, getArtifactTypeLabel } from "./ArtifactMedia";
|
|||||||
// Document key validation: alphanumeric, hyphens, underscores, 1-64 chars
|
// Document key validation: alphanumeric, hyphens, underscores, 1-64 chars
|
||||||
const DOCUMENT_KEY_REGEX = /^[a-zA-Z0-9_-]{1,64}$/;
|
const DOCUMENT_KEY_REGEX = /^[a-zA-Z0-9_-]{1,64}$/;
|
||||||
const MAX_CONTENT_PREVIEW = 200;
|
const MAX_CONTENT_PREVIEW = 200;
|
||||||
|
const TASK_DOCUMENTS_MARKDOWN_TOGGLE_STORAGE_KEY = "fusion.taskDocuments.renderMarkdown";
|
||||||
|
|
||||||
|
function readBooleanPref(key: string, defaultValue: boolean): boolean {
|
||||||
|
if (typeof window === "undefined") return defaultValue;
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(key);
|
||||||
|
if (raw === null) return defaultValue;
|
||||||
|
return raw === "true";
|
||||||
|
} catch {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeBooleanPref(key: string, value: boolean): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(key, value ? "true" : "false");
|
||||||
|
} catch {
|
||||||
|
// ignore storage failures (quota, private mode, etc.)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface TaskDocumentsTabProps {
|
interface TaskDocumentsTabProps {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
@@ -109,8 +130,8 @@ export function TaskDocumentsTab({
|
|||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
const [documents, setDocuments] = useState<TaskDocument[]>([]);
|
const [documents, setDocuments] = useState<TaskDocument[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [expandedDocKey, setExpandedDocKey] = useState<string | null>(null);
|
const [expandedDocKeys, setExpandedDocKeys] = useState<Set<string>>(() => new Set());
|
||||||
const [expandedContent, setExpandedContent] = useState("");
|
const [revisionContentByKey, setRevisionContentByKey] = useState<Record<string, string>>({});
|
||||||
const [editingDocKey, setEditingDocKey] = useState<string | null>(null);
|
const [editingDocKey, setEditingDocKey] = useState<string | null>(null);
|
||||||
const [editContent, setEditContent] = useState("");
|
const [editContent, setEditContent] = useState("");
|
||||||
const [showHistory, setShowHistory] = useState<string | null>(null);
|
const [showHistory, setShowHistory] = useState<string | null>(null);
|
||||||
@@ -122,7 +143,11 @@ export function TaskDocumentsTab({
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [deletingKey, setDeletingKey] = useState<string | null>(null);
|
const [deletingKey, setDeletingKey] = useState<string | null>(null);
|
||||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||||
const [renderMarkdown, setRenderMarkdown] = useState(false);
|
/*
|
||||||
|
* FNXC:ArtifactRegistry 2026-07-11-00:00:
|
||||||
|
* FN-7833 makes task Artifacts-tab documents readable without extra clicks: render Markdown by default and persist the operator's Markdown/Plain preference for future task document views.
|
||||||
|
*/
|
||||||
|
const [renderMarkdown, setRenderMarkdown] = useState<boolean>(() => readBooleanPref(TASK_DOCUMENTS_MARKDOWN_TOGGLE_STORAGE_KEY, true));
|
||||||
/*
|
/*
|
||||||
* FNXC:ArtifactRegistry 2026-06-29-00:00:
|
* FNXC:ArtifactRegistry 2026-06-29-00:00:
|
||||||
* Task detail image artifacts must be viewable in-place from the task modal. Keep the expand target image-only so document, audio, video, and generic cards retain their current non-lightbox behavior without empty controls.
|
* Task detail image artifacts must be viewable in-place from the task modal. Keep the expand target image-only so document, audio, video, and generic cards retain their current non-lightbox behavior without empty controls.
|
||||||
@@ -131,12 +156,28 @@ export function TaskDocumentsTab({
|
|||||||
const lightboxDialogRef = useRef<HTMLDivElement>(null);
|
const lightboxDialogRef = useRef<HTMLDivElement>(null);
|
||||||
const lightboxCloseRef = useRef<HTMLButtonElement>(null);
|
const lightboxCloseRef = useRef<HTMLButtonElement>(null);
|
||||||
const lightboxReturnFocusRef = useRef<HTMLElement | null>(null);
|
const lightboxReturnFocusRef = useRef<HTMLElement | null>(null);
|
||||||
|
const loadedTaskIdRef = useRef(taskId);
|
||||||
|
const documentKeysRef = useRef<Set<string>>(new Set());
|
||||||
const { artifacts, loading: artifactsLoading, error: artifactsError } = useArtifacts({ projectId, taskId });
|
const { artifacts, loading: artifactsLoading, error: artifactsError } = useArtifacts({ projectId, taskId });
|
||||||
|
|
||||||
const loadDocuments = useCallback(async () => {
|
const loadDocuments = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const docs = await fetchTaskDocuments(taskId, projectId);
|
const docs = await fetchTaskDocuments(taskId, projectId);
|
||||||
|
const previousKeys = loadedTaskIdRef.current === taskId ? documentKeysRef.current : new Set<string>();
|
||||||
|
const nextKeys = new Set(docs.map((doc) => doc.key));
|
||||||
|
loadedTaskIdRef.current = taskId;
|
||||||
|
documentKeysRef.current = nextKeys;
|
||||||
setDocuments(docs);
|
setDocuments(docs);
|
||||||
|
setExpandedDocKeys((current) => {
|
||||||
|
const next = new Set<string>();
|
||||||
|
for (const doc of docs) {
|
||||||
|
if (current.has(doc.key) || !previousKeys.has(doc.key)) {
|
||||||
|
next.add(doc.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setRevisionContentByKey((current) => Object.fromEntries(Object.entries(current).filter(([key]) => nextKeys.has(key))));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addToast(getErrorMessage(error) || t("taskDocuments.failedToLoad", "Failed to load documents"), "error");
|
addToast(getErrorMessage(error) || t("taskDocuments.failedToLoad", "Failed to load documents"), "error");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -154,23 +195,30 @@ export function TaskDocumentsTab({
|
|||||||
}
|
}
|
||||||
}, [addToast, artifactsError, t]);
|
}, [addToast, artifactsError, t]);
|
||||||
|
|
||||||
async function handleExpandDocument(doc: TaskDocument) {
|
useEffect(() => {
|
||||||
if (expandedDocKey === doc.key) {
|
writeBooleanPref(TASK_DOCUMENTS_MARKDOWN_TOGGLE_STORAGE_KEY, renderMarkdown);
|
||||||
setExpandedDocKey(null);
|
}, [renderMarkdown]);
|
||||||
setExpandedContent("");
|
|
||||||
setEditingDocKey(null);
|
function handleExpandDocument(doc: TaskDocument) {
|
||||||
setEditContent("");
|
const isExpanded = expandedDocKeys.has(doc.key);
|
||||||
setShowHistory(null);
|
setExpandedDocKeys((current) => {
|
||||||
setRevisions([]);
|
const next = new Set(current);
|
||||||
setRenderMarkdown(false);
|
if (isExpanded) {
|
||||||
} else {
|
next.delete(doc.key);
|
||||||
setExpandedDocKey(doc.key);
|
} else {
|
||||||
setExpandedContent(doc.content);
|
next.add(doc.key);
|
||||||
setEditingDocKey(null);
|
}
|
||||||
setEditContent("");
|
return next;
|
||||||
setShowHistory(null);
|
});
|
||||||
setRevisions([]);
|
if (isExpanded) {
|
||||||
setRenderMarkdown(false);
|
if (editingDocKey === doc.key) {
|
||||||
|
setEditingDocKey(null);
|
||||||
|
setEditContent("");
|
||||||
|
}
|
||||||
|
if (showHistory === doc.key) {
|
||||||
|
setShowHistory(null);
|
||||||
|
setRevisions([]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,11 +240,9 @@ export function TaskDocumentsTab({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleStartEdit() {
|
function handleStartEdit(doc: TaskDocument) {
|
||||||
if (expandedDocKey) {
|
setEditingDocKey(doc.key);
|
||||||
setEditingDocKey(expandedDocKey);
|
setEditContent(revisionContentByKey[doc.key] ?? doc.content);
|
||||||
setEditContent(expandedContent);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCancelEdit() {
|
function handleCancelEdit() {
|
||||||
@@ -211,12 +257,12 @@ export function TaskDocumentsTab({
|
|||||||
await putTaskDocument(taskId, editingDocKey, editContent, {}, projectId);
|
await putTaskDocument(taskId, editingDocKey, editContent, {}, projectId);
|
||||||
setEditingDocKey(null);
|
setEditingDocKey(null);
|
||||||
setEditContent("");
|
setEditContent("");
|
||||||
|
setRevisionContentByKey((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[editingDocKey];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
await loadDocuments();
|
await loadDocuments();
|
||||||
// Refresh expanded content
|
|
||||||
const updated = documents.find((d) => d.key === editingDocKey);
|
|
||||||
if (updated) {
|
|
||||||
setExpandedContent(updated.content);
|
|
||||||
}
|
|
||||||
addToast(t("taskDocuments.saved", "Document saved"), "success");
|
addToast(t("taskDocuments.saved", "Document saved"), "success");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addToast(getErrorMessage(error) || t("taskDocuments.failedToSave", "Failed to save document"), "error");
|
addToast(getErrorMessage(error) || t("taskDocuments.failedToSave", "Failed to save document"), "error");
|
||||||
@@ -264,10 +310,16 @@ export function TaskDocumentsTab({
|
|||||||
await deleteTaskDocument(taskId, key, projectId);
|
await deleteTaskDocument(taskId, key, projectId);
|
||||||
setConfirmDelete(null);
|
setConfirmDelete(null);
|
||||||
setDeletingKey(null);
|
setDeletingKey(null);
|
||||||
if (expandedDocKey === key) {
|
setExpandedDocKeys((current) => {
|
||||||
setExpandedDocKey(null);
|
const next = new Set(current);
|
||||||
setExpandedContent("");
|
next.delete(key);
|
||||||
}
|
return next;
|
||||||
|
});
|
||||||
|
setRevisionContentByKey((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[key];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
if (showHistory === key) {
|
if (showHistory === key) {
|
||||||
setShowHistory(null);
|
setShowHistory(null);
|
||||||
setRevisions([]);
|
setRevisions([]);
|
||||||
@@ -281,8 +333,8 @@ export function TaskDocumentsTab({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleViewRevision(revision: TaskDocumentRevision) {
|
function handleViewRevision(docKey: string, revision: TaskDocumentRevision) {
|
||||||
setExpandedContent(revision.content);
|
setRevisionContentByKey((current) => ({ ...current, [docKey]: revision.content }));
|
||||||
setEditingDocKey(null);
|
setEditingDocKey(null);
|
||||||
setEditContent("");
|
setEditContent("");
|
||||||
}
|
}
|
||||||
@@ -468,7 +520,11 @@ export function TaskDocumentsTab({
|
|||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="task-documents-list">
|
<div className="task-documents-list">
|
||||||
{documents.map((doc) => (
|
{documents.map((doc) => {
|
||||||
|
const isExpanded = expandedDocKeys.has(doc.key);
|
||||||
|
const displayedContent = revisionContentByKey[doc.key] ?? doc.content;
|
||||||
|
|
||||||
|
return (
|
||||||
<div key={doc.key} className="task-document-card">
|
<div key={doc.key} className="task-document-card">
|
||||||
<div className="task-document-card-header">
|
<div className="task-document-card-header">
|
||||||
<div className="task-document-card-title">
|
<div className="task-document-card-title">
|
||||||
@@ -483,7 +539,7 @@ export function TaskDocumentsTab({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Expanded Content View */}
|
{/* Expanded Content View */}
|
||||||
{expandedDocKey === doc.key && editingDocKey !== doc.key && (
|
{isExpanded && editingDocKey !== doc.key && (
|
||||||
<>
|
<>
|
||||||
<div className="task-document-content-header">
|
<div className="task-document-content-header">
|
||||||
<button
|
<button
|
||||||
@@ -500,11 +556,11 @@ export function TaskDocumentsTab({
|
|||||||
{renderMarkdown ? (
|
{renderMarkdown ? (
|
||||||
<div className="task-document-content-markdown">
|
<div className="task-document-content-markdown">
|
||||||
<div className="markdown-body">
|
<div className="markdown-body">
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{expandedContent}</ReactMarkdown>
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{displayedContent}</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<pre className="task-document-content-text">{expandedContent}</pre>
|
<pre className="task-document-content-text">{displayedContent}</pre>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -525,7 +581,7 @@ export function TaskDocumentsTab({
|
|||||||
<div
|
<div
|
||||||
key={revision.id}
|
key={revision.id}
|
||||||
className="task-document-revision-item"
|
className="task-document-revision-item"
|
||||||
onClick={() => handleViewRevision(revision)}
|
onClick={() => handleViewRevision(doc.key, revision)}
|
||||||
>
|
>
|
||||||
<div className="revision-header">
|
<div className="revision-header">
|
||||||
<span className="revision-badge">v{revision.revision}</span>
|
<span className="revision-badge">v{revision.revision}</span>
|
||||||
@@ -570,9 +626,9 @@ export function TaskDocumentsTab({
|
|||||||
<div className="task-document-actions">
|
<div className="task-document-actions">
|
||||||
<button
|
<button
|
||||||
className="btn btn-sm"
|
className="btn btn-sm"
|
||||||
onClick={() => void handleExpandDocument(doc)}
|
onClick={() => handleExpandDocument(doc)}
|
||||||
>
|
>
|
||||||
{expandedDocKey === doc.key ? (
|
{isExpanded ? (
|
||||||
<>
|
<>
|
||||||
<ChevronUp size={14} /> {t("taskDocuments.collapse", "Collapse")}
|
<ChevronUp size={14} /> {t("taskDocuments.collapse", "Collapse")}
|
||||||
</>
|
</>
|
||||||
@@ -583,7 +639,7 @@ export function TaskDocumentsTab({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{expandedDocKey === doc.key && (
|
{isExpanded && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
className="btn btn-sm"
|
className="btn btn-sm"
|
||||||
@@ -593,7 +649,7 @@ export function TaskDocumentsTab({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{canEdit && editingDocKey !== doc.key && (
|
{canEdit && editingDocKey !== doc.key && (
|
||||||
<button className="btn btn-sm" onClick={handleStartEdit}>
|
<button className="btn btn-sm" onClick={() => handleStartEdit(doc)}>
|
||||||
{t("taskDocuments.edit", "Edit")}
|
{t("taskDocuments.edit", "Edit")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -630,7 +686,8 @@ export function TaskDocumentsTab({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
/*
|
||||||
|
FNXC:DashboardTests 2026-07-11-00:00:
|
||||||
|
FN-7833 changed per-task Artifacts-tab document defaults. Keep multi-document coverage here so the component cannot regress to single-expanded or Plain-first rendering while the separate Documents view remains out of scope.
|
||||||
|
*/
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||||
import type { ArtifactWithTask, TaskDocument } from "@fusion/core";
|
import type { ArtifactWithTask, TaskDocument } from "@fusion/core";
|
||||||
import { TaskDocumentsTab } from "../TaskDocumentsTab";
|
import { TaskDocumentsTab } from "../TaskDocumentsTab";
|
||||||
import { artifactMediaUrl, fetchTaskDocuments, fetchTaskDocumentRevisions } from "../../api";
|
import { artifactMediaUrl, fetchTaskDocuments, fetchTaskDocumentRevisions } from "../../api";
|
||||||
@@ -23,6 +27,12 @@ const mockFetchTaskDocumentRevisions = vi.mocked(fetchTaskDocumentRevisions);
|
|||||||
const mockArtifactMediaUrl = vi.mocked(artifactMediaUrl);
|
const mockArtifactMediaUrl = vi.mocked(artifactMediaUrl);
|
||||||
const mockUseArtifacts = vi.mocked(useArtifacts);
|
const mockUseArtifacts = vi.mocked(useArtifacts);
|
||||||
|
|
||||||
|
function getDocumentCard(key: string): HTMLElement {
|
||||||
|
const keyElement = screen.getAllByText(key).find((element) => element.classList.contains("task-document-key"));
|
||||||
|
expect(keyElement).toBeDefined();
|
||||||
|
return keyElement!.closest(".task-document-card") as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
const mockArtifacts: ArtifactWithTask[] = [
|
const mockArtifacts: ArtifactWithTask[] = [
|
||||||
{
|
{
|
||||||
id: "artifact-image",
|
id: "artifact-image",
|
||||||
@@ -97,6 +107,7 @@ describe("TaskDocumentsTab", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
window.localStorage.clear();
|
||||||
mockFetchTaskDocuments.mockResolvedValue(mockDocuments);
|
mockFetchTaskDocuments.mockResolvedValue(mockDocuments);
|
||||||
mockFetchTaskDocumentRevisions.mockResolvedValue([]);
|
mockFetchTaskDocumentRevisions.mockResolvedValue([]);
|
||||||
mockArtifactMediaUrl.mockImplementation((id: string) => `/api/artifacts/${id}/media`);
|
mockArtifactMediaUrl.mockImplementation((id: string) => `/api/artifacts/${id}/media`);
|
||||||
@@ -113,7 +124,7 @@ describe("TaskDocumentsTab", () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole("heading", { name: "Artifacts" })).toBeInTheDocument();
|
expect(screen.getByRole("heading", { name: "Artifacts" })).toBeInTheDocument();
|
||||||
expect(screen.getByText("plan")).toBeInTheDocument();
|
expect(screen.getAllByText("plan").length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByText("notes")).toBeInTheDocument();
|
expect(screen.getByText("notes")).toBeInTheDocument();
|
||||||
@@ -148,7 +159,7 @@ describe("TaskDocumentsTab", () => {
|
|||||||
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} />);
|
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("plan")).toBeInTheDocument();
|
expect(screen.getAllByText("plan").length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.queryByRole("heading", { name: "Media artifacts" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("heading", { name: "Media artifacts" })).not.toBeInTheDocument();
|
||||||
@@ -186,7 +197,7 @@ describe("TaskDocumentsTab", () => {
|
|||||||
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} projectId="project-1" />);
|
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} projectId="project-1" />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("plan")).toBeInTheDocument();
|
expect(screen.getAllByText("plan").length).toBeGreaterThan(0);
|
||||||
expect(screen.getByRole("heading", { name: "Media artifacts" })).toBeInTheDocument();
|
expect(screen.getByRole("heading", { name: "Media artifacts" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -272,7 +283,7 @@ describe("TaskDocumentsTab", () => {
|
|||||||
fireEvent.keyDown(document, { key: "Tab", shiftKey: true });
|
fireEvent.keyDown(document, { key: "Tab", shiftKey: true });
|
||||||
expect(closeButton).toHaveFocus();
|
expect(closeButton).toHaveFocus();
|
||||||
|
|
||||||
screen.getAllByRole("button", { name: "Expand" })[0].focus();
|
screen.getAllByRole("button", { name: "Collapse" })[0].focus();
|
||||||
fireEvent.keyDown(document, { key: "Tab" });
|
fireEvent.keyDown(document, { key: "Tab" });
|
||||||
expect(closeButton).toHaveFocus();
|
expect(closeButton).toHaveFocus();
|
||||||
});
|
});
|
||||||
@@ -292,212 +303,78 @@ describe("TaskDocumentsTab", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("expands document to show content", async () => {
|
it("renders every task document expanded as markdown by default", async () => {
|
||||||
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} />);
|
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
const planStrong = (await screen.findAllByText("plan")).find((element) => element.tagName === "STRONG");
|
||||||
expect(screen.getByText("plan")).toBeInTheDocument();
|
expect(planStrong).toBeDefined();
|
||||||
});
|
expect(screen.getByRole("heading", { name: "Notes" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Item 1")).toBeInTheDocument();
|
||||||
const expandButton = screen.getAllByRole("button", { name: /expand/i })[0];
|
expect(screen.getByText("Item 2")).toBeInTheDocument();
|
||||||
fireEvent.click(expandButton);
|
expect(document.querySelectorAll(".task-document-content-markdown .markdown-body")).toHaveLength(2);
|
||||||
|
expect(document.querySelector("pre.task-document-content-text")).not.toBeInTheDocument();
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/This is the \*\*plan\*\* content/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("collapses document when expand button clicked again", async () => {
|
it("collapses only the selected document while other documents stay expanded", async () => {
|
||||||
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} />);
|
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await screen.findByText("Item 1");
|
||||||
expect(screen.getByText("plan")).toBeInTheDocument();
|
const planCard = getDocumentCard("plan");
|
||||||
});
|
|
||||||
|
|
||||||
const expandButton = screen.getAllByRole("button", { name: /expand/i })[0];
|
fireEvent.click(within(planCard).getByRole("button", { name: /collapse/i }));
|
||||||
fireEvent.click(expandButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/This is the \*\*plan\*\* content/)).toBeInTheDocument();
|
expect(screen.queryByText("This is the")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
expect(screen.getByRole("heading", { name: "Notes" })).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole("button", { name: /collapse/i }));
|
expect(screen.getByText("Item 1")).toBeInTheDocument();
|
||||||
|
expect(within(planCard).getByRole("button", { name: /expand/i })).toBeInTheDocument();
|
||||||
// Content should be hidden
|
|
||||||
expect(screen.queryByText(/This is the/)).not.toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("markdown toggle", () => {
|
describe("markdown toggle", () => {
|
||||||
it("defaults to raw text mode", async () => {
|
it("defaults to markdown render mode", async () => {
|
||||||
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} />);
|
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("plan")).toBeInTheDocument();
|
expect(document.querySelector(".task-document-content-markdown strong")?.textContent).toBe("plan");
|
||||||
});
|
|
||||||
|
|
||||||
const expandButton = screen.getAllByRole("button", { name: /expand/i })[0];
|
|
||||||
fireEvent.click(expandButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
// Should show raw markdown syntax
|
|
||||||
expect(screen.getByText(/\*\*plan\*\*/)).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
expect(screen.getAllByRole("button", { name: /switch to plain text/i })[0]).toHaveAttribute("aria-pressed", "true");
|
||||||
|
expect(screen.queryByText(/\*\*plan\*\*/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("toggles to markdown render mode", async () => {
|
it("toggles to raw text mode", async () => {
|
||||||
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} />);
|
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
const toggleBtn = (await screen.findAllByRole("button", { name: /switch to plain text/i }))[0];
|
||||||
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);
|
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(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("plan")).toBeInTheDocument();
|
expect(document.querySelectorAll("pre.task-document-content-text")).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
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();
|
expect(screen.getByText(/\*\*plan\*\*/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/# Notes/)).toBeInTheDocument();
|
||||||
|
expect(document.querySelector(".task-document-content-markdown")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resets markdown mode when switching documents", async () => {
|
it("persists raw text preference across collapse and re-expand", async () => {
|
||||||
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} />);
|
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
fireEvent.click((await screen.findAllByRole("button", { name: /switch to plain text/i }))[0]);
|
||||||
expect(screen.getByText("plan")).toBeInTheDocument();
|
await waitFor(() => expect(screen.getByText(/\*\*plan\*\*/)).toBeInTheDocument());
|
||||||
});
|
|
||||||
|
|
||||||
// Expand first document and enable markdown mode
|
const planCard = getDocumentCard("plan");
|
||||||
const expandButtons = screen.getAllByRole("button", { name: /expand/i });
|
fireEvent.click(within(planCard).getByRole("button", { name: /collapse/i }));
|
||||||
fireEvent.click(expandButtons[0]);
|
fireEvent.click(within(planCard).getByRole("button", { name: /expand/i }));
|
||||||
|
|
||||||
await waitFor(() => {
|
expect(await screen.findByText(/\*\*plan\*\*/)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/\*\*plan\*\*/)).toBeInTheDocument();
|
expect(within(planCard).getByRole("button", { name: /switch to markdown/i })).toHaveAttribute("aria-pressed", "false");
|
||||||
});
|
|
||||||
|
|
||||||
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 () => {
|
it("renders markdown list syntax without an extra toggle click", async () => {
|
||||||
render(<TaskDocumentsTab taskId="KB-001" addToast={addToast} />);
|
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" });
|
const heading = await screen.findByRole("heading", { name: "Notes" });
|
||||||
expect(heading).toBeInTheDocument();
|
expect(heading).toBeInTheDocument();
|
||||||
|
|
||||||
// Check list items are rendered
|
|
||||||
expect(screen.getByText("Item 1")).toBeInTheDocument();
|
expect(screen.getByText("Item 1")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Item 2")).toBeInTheDocument();
|
expect(screen.getByText("Item 2")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user