FN-5791: add markdown goal descriptions with show-more toggle
Improve goal card descriptions to support markdown formatting while keeping long content scannable. - render goal descriptions with markdown + GFM support in GoalsView - collapse long or multi-line descriptions by default and add Show more/Show less disclosure - style collapsed description layout and add responsive clamp adjustments - add tests for markdown rendering, long-description toggle behavior, and short-description no-toggle behavior Files changed: packages/dashboard/app/components/GoalsView.css | 27 +++++++++++++ packages/dashboard/app/components/GoalsView.tsx | 46 +++++++++++++++++++++- packages/dashboard/app/components/__tests__/GoalsView.test.tsx | 42 ++++++++++++++++++++ 3 files changed, 114 insertions(+), 1 deletion(-) Fusion-Task-Id: FN-5791 Fusion-Task-Lineage: a3a0be5c-4a8d-4dc1-93a3-40891ab7762b
This commit is contained in:
@@ -112,6 +112,28 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.goals-card-description > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.goals-card-description > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.goals-card-description-collapsed {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 4;
|
||||
overflow: hidden;
|
||||
max-height: calc(var(--space-md) * 6);
|
||||
}
|
||||
|
||||
.goals-card-description-toggle {
|
||||
margin-top: var(--space-xs);
|
||||
align-self: flex-start;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.goals-card-status {
|
||||
margin: var(--space-xs) 0 0;
|
||||
color: var(--text-muted);
|
||||
@@ -145,4 +167,9 @@
|
||||
.goals-card-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.goals-card-description-collapsed {
|
||||
-webkit-line-clamp: 3;
|
||||
max-height: calc(var(--space-md) * 5);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { Goal } from "@fusion/core";
|
||||
import { Plus } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import "./GoalsView.css";
|
||||
|
||||
export interface GoalsViewProps {
|
||||
@@ -11,6 +13,7 @@ const MAX_ACTIVE_GOALS = 5;
|
||||
const WARNING_THRESHOLD = 3;
|
||||
|
||||
const CAP_ERROR_MESSAGE = "Cannot activate more than 5 goals. Resolve an active goal before activating another.";
|
||||
const GOAL_DESCRIPTION_TOGGLE_LENGTH = 280;
|
||||
|
||||
function isCapError(payload: unknown): boolean {
|
||||
return Boolean(payload && typeof payload === "object" && "code" in payload && (payload as { code?: unknown }).code === "ACTIVE_GOAL_LIMIT_EXCEEDED");
|
||||
@@ -32,6 +35,7 @@ export function GoalsView({ initialGoals }: GoalsViewProps) {
|
||||
const [editDescription, setEditDescription] = useState("");
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||
const [expandedGoalDescriptions, setExpandedGoalDescriptions] = useState<Set<string>>(() => new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (initialGoals !== undefined) {
|
||||
@@ -187,6 +191,22 @@ export function GoalsView({ initialGoals }: GoalsViewProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function isDescriptionToggleVisible(description: string): boolean {
|
||||
return description.length > GOAL_DESCRIPTION_TOGGLE_LENGTH || description.includes("\n");
|
||||
}
|
||||
|
||||
function toggleGoalDescription(goalId: string) {
|
||||
setExpandedGoalDescriptions((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(goalId)) {
|
||||
next.delete(goalId);
|
||||
} else {
|
||||
next.add(goalId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function updateGoalArchiveStatus(goal: Goal) {
|
||||
const endpoint = goal.status === "active" ? `/api/goals/${goal.id}/archive` : `/api/goals/${goal.id}/unarchive`;
|
||||
|
||||
@@ -357,7 +377,31 @@ export function GoalsView({ initialGoals }: GoalsViewProps) {
|
||||
<>
|
||||
<div className="goals-card-main">
|
||||
<h3 className="goals-card-title">{goal.title}</h3>
|
||||
{goal.description ? <p className="goals-card-description">{goal.description}</p> : null}
|
||||
{goal.description ? (
|
||||
(() => {
|
||||
const showToggle = isDescriptionToggleVisible(goal.description);
|
||||
const isExpanded = expandedGoalDescriptions.has(goal.id);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`markdown-body goals-card-description ${showToggle && !isExpanded ? "goals-card-description-collapsed" : ""}`.trim()}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{goal.description}</ReactMarkdown>
|
||||
</div>
|
||||
{showToggle ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn goals-card-description-toggle"
|
||||
aria-expanded={isExpanded}
|
||||
data-testid={`goal-description-toggle-${goal.id}`}
|
||||
onClick={() => toggleGoalDescription(goal.id)}
|
||||
>
|
||||
{isExpanded ? "Show less" : "Show more"}
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
})()
|
||||
) : null}
|
||||
<p className="goals-card-status">Status: {goal.status}</p>
|
||||
</div>
|
||||
<div className="goals-card-actions">
|
||||
|
||||
@@ -241,4 +241,46 @@ describe("GoalsView", () => {
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Unable to save goal right now. Please try again.");
|
||||
});
|
||||
|
||||
it("renders markdown description as formatted HTML", () => {
|
||||
render(
|
||||
<GoalsView
|
||||
initialGoals={[
|
||||
makeGoal({
|
||||
id: "g1",
|
||||
title: "Markdown Goal",
|
||||
description: "**bold**\n\n- first item\n- second item",
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("bold", { selector: "strong" })).toBeInTheDocument();
|
||||
expect(screen.getByText("first item", { selector: "li" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("**bold**")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("collapses long descriptions by default and toggles expanded state", () => {
|
||||
const longDescription = `${"Long description content ".repeat(20)}extra`;
|
||||
render(<GoalsView initialGoals={[makeGoal({ id: "g1", title: "One", description: longDescription })]} />);
|
||||
|
||||
const toggle = screen.getByTestId("goal-description-toggle-g1");
|
||||
const description = screen.getByText(/Long description content/i).closest(".goals-card-description");
|
||||
|
||||
expect(toggle).toHaveTextContent("Show more");
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
expect(description).toHaveClass("goals-card-description-collapsed");
|
||||
|
||||
fireEvent.click(toggle);
|
||||
|
||||
expect(toggle).toHaveTextContent("Show less");
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
expect(description).not.toHaveClass("goals-card-description-collapsed");
|
||||
});
|
||||
|
||||
it("does not render description toggle for short single-line text", () => {
|
||||
render(<GoalsView initialGoals={[makeGoal({ id: "g1", title: "One", description: "Short goal description" })]} />);
|
||||
|
||||
expect(screen.queryByTestId("goal-description-toggle-g1")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user