diff --git a/src/components/shared/ContextPanel/Blocks/ActionBlock.test.tsx b/src/components/shared/ContextPanel/Blocks/ActionBlock.test.tsx new file mode 100644 index 000000000..1f9c0ec40 --- /dev/null +++ b/src/components/shared/ContextPanel/Blocks/ActionBlock.test.tsx @@ -0,0 +1,430 @@ +import { + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; + +import type { Action } from "./ActionBlock"; +import { ActionBlock } from "./ActionBlock"; + +describe("", () => { + test("renders without title", () => { + const actions: Action[] = [ + { label: "Test Action", icon: "Copy", onClick: vi.fn() }, + ]; + + render(); + + expect(screen.queryByRole("heading")).not.toBeInTheDocument(); + expect(screen.getByTestId("action-Test Action")).toBeInTheDocument(); + }); + + test("renders with title", () => { + const actions: Action[] = [ + { label: "Test Action", icon: "Copy", onClick: vi.fn() }, + ]; + + render(); + + expect( + screen.getByRole("heading", { level: 3, name: "Actions" }), + ).toBeInTheDocument(); + expect(screen.getByTestId("action-Test Action")).toBeInTheDocument(); + }); + + test("renders with custom className", () => { + const actions: Action[] = [ + { label: "Test Action", icon: "Copy", onClick: vi.fn() }, + ]; + + const { container } = render( + , + ); + + const blockStack = container.querySelector(".custom-class"); + expect(blockStack).toBeInTheDocument(); + }); + + test("renders empty actions array without error", () => { + const { container } = render(); + + expect( + container.querySelector('[data-testid^="action-"]'), + ).not.toBeInTheDocument(); + }); + + describe("action rendering", () => { + test("renders action button with icon", () => { + const onClick = vi.fn(); + const actions: Action[] = [ + { label: "Copy Action", icon: "Copy", onClick }, + ]; + + render(); + + const button = screen.getByTestId("action-Copy Action"); + expect(button).toBeInTheDocument(); + + const icon = button.querySelector("svg"); + expect(icon).toBeInTheDocument(); + expect(icon).toHaveClass("lucide-copy"); + }); + + test("renders action button with custom content", () => { + const onClick = vi.fn(); + const actions: Action[] = [ + { label: "Custom Action", content: Custom, onClick }, + ]; + + render(); + + const button = screen.getByTestId("action-Custom Action"); + expect(button).toBeInTheDocument(); + expect(within(button).getByText("Custom")).toBeInTheDocument(); + }); + + test("renders multiple actions", () => { + const actions: Action[] = [ + { label: "Action 1", icon: "Copy", onClick: vi.fn() }, + { label: "Action 2", icon: "Download", onClick: vi.fn() }, + { label: "Action 3", icon: "Trash", onClick: vi.fn() }, + ]; + + render(); + + expect(screen.getByTestId("action-Action 1")).toBeInTheDocument(); + expect(screen.getByTestId("action-Action 2")).toBeInTheDocument(); + expect(screen.getByTestId("action-Action 3")).toBeInTheDocument(); + }); + + test("renders ReactNode as action (backward compatibility)", () => { + const actions = [ + { label: "Action 1", icon: "Copy" as const, onClick: vi.fn() }, + , + ]; + + render(); + + expect(screen.getByTestId("action-Action 1")).toBeInTheDocument(); + expect(screen.getByTestId("custom-button")).toBeInTheDocument(); + }); + + test("handles null or undefined actions gracefully", () => { + const actions = [ + { label: "Action 1", icon: "Copy" as const, onClick: vi.fn() }, + null, + undefined, + { label: "Action 2", icon: "Download" as const, onClick: vi.fn() }, + ]; + + render(); + + expect(screen.getByTestId("action-Action 1")).toBeInTheDocument(); + expect(screen.getByTestId("action-Action 2")).toBeInTheDocument(); + }); + }); + + describe("action variants", () => { + test("renders destructive action with destructive variant", () => { + const actions: Action[] = [ + { label: "Delete", icon: "Trash", destructive: true, onClick: vi.fn() }, + ]; + + render(); + + const button = screen.getByTestId("action-Delete"); + expect(button).toHaveClass("bg-destructive"); + expect(button).toHaveClass("text-white"); + }); + + test("renders non-destructive action with outline variant", () => { + const actions: Action[] = [ + { label: "Copy", icon: "Copy", onClick: vi.fn() }, + ]; + + render(); + + const button = screen.getByTestId("action-Copy"); + expect(button).toHaveClass("border"); + expect(button).toHaveClass("bg-background"); + }); + + test("applies custom className to action button", () => { + const actions: Action[] = [ + { + label: "Styled Action", + icon: "Copy", + onClick: vi.fn(), + className: "custom-button", + }, + ]; + + render(); + + const button = screen.getByTestId("action-Styled Action"); + expect(button).toHaveClass("custom-button"); + }); + }); + + describe("action states", () => { + test("renders disabled action", () => { + const onClick = vi.fn(); + const actions: Action[] = [ + { label: "Disabled Action", icon: "Copy", disabled: true, onClick }, + ]; + + render(); + + const button = screen.getByTestId("action-Disabled Action"); + expect(button).toBeDisabled(); + + fireEvent.click(button); + expect(onClick).not.toHaveBeenCalled(); + }); + + test("renders enabled action", () => { + const onClick = vi.fn(); + const actions: Action[] = [ + { label: "Enabled Action", icon: "Copy", disabled: false, onClick }, + ]; + + render(); + + const button = screen.getByTestId("action-Enabled Action"); + expect(button).not.toBeDisabled(); + + fireEvent.click(button); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + test("does not render hidden action", () => { + const actions: Action[] = [ + { label: "Visible Action", icon: "Copy", onClick: vi.fn() }, + { + label: "Hidden Action", + icon: "Trash", + hidden: true, + onClick: vi.fn(), + }, + ]; + + render(); + + expect(screen.getByTestId("action-Visible Action")).toBeInTheDocument(); + expect( + screen.queryByTestId("action-Hidden Action"), + ).not.toBeInTheDocument(); + }); + }); + + describe("onClick behavior", () => { + test("calls onClick when action is clicked", () => { + const onClick = vi.fn(); + const actions: Action[] = [ + { label: "Click Action", icon: "Copy", onClick }, + ]; + + render(); + + const button = screen.getByTestId("action-Click Action"); + fireEvent.click(button); + + expect(onClick).toHaveBeenCalledTimes(1); + }); + + test("calls onClick multiple times when clicked multiple times", () => { + const onClick = vi.fn(); + const actions: Action[] = [ + { label: "Multi Click", icon: "Copy", onClick }, + ]; + + render(); + + const button = screen.getByTestId("action-Multi Click"); + fireEvent.click(button); + fireEvent.click(button); + fireEvent.click(button); + + expect(onClick).toHaveBeenCalledTimes(3); + }); + }); + + describe("confirmation dialog", () => { + test("opens confirmation dialog when action has confirmation", () => { + const onClick = vi.fn(); + const actions: Action[] = [ + { + label: "Delete", + icon: "Trash", + confirmation: "Are you sure you want to delete?", + onClick, + }, + ]; + + render(); + + const button = screen.getByTestId("action-Delete"); + fireEvent.click(button); + + // Dialog should appear + expect( + screen.getByText("Are you sure you want to delete?"), + ).toBeInTheDocument(); + expect(onClick).not.toHaveBeenCalled(); + }); + + test("executes onClick when confirmation is accepted", async () => { + const onClick = vi.fn(); + const actions: Action[] = [ + { + label: "Delete", + icon: "Trash", + confirmation: "Confirm deletion?", + onClick, + }, + ]; + + render(); + + const button = screen.getByTestId("action-Delete"); + fireEvent.click(button); + + // Confirm in dialog + const confirmButton = screen.getByRole("button", { name: "Continue" }); + fireEvent.click(confirmButton); + + await waitFor(() => { + expect(onClick).toHaveBeenCalledTimes(1); + }); + }); + + test("does not execute onClick when confirmation is cancelled", async () => { + const onClick = vi.fn(); + const actions: Action[] = [ + { + label: "Delete", + icon: "Trash", + confirmation: "Confirm deletion?", + onClick, + }, + ]; + + render(); + + const button = screen.getByTestId("action-Delete"); + fireEvent.click(button); + + // Cancel in dialog + const cancelButton = screen.getByRole("button", { name: "Cancel" }); + fireEvent.click(cancelButton); + + await waitFor(() => { + expect(onClick).not.toHaveBeenCalled(); + }); + }); + + test("closes dialog after confirmation", async () => { + const onClick = vi.fn(); + const actions: Action[] = [ + { + label: "Delete", + icon: "Trash", + confirmation: "Confirm deletion?", + onClick, + }, + ]; + + render(); + + const button = screen.getByTestId("action-Delete"); + fireEvent.click(button); + + expect(screen.getByText("Confirm deletion?")).toBeInTheDocument(); + + const confirmButton = screen.getByRole("button", { name: "Continue" }); + fireEvent.click(confirmButton); + + await waitFor(() => { + expect(screen.queryByText("Confirm deletion?")).not.toBeInTheDocument(); + }); + }); + + test("closes dialog after cancellation", async () => { + const onClick = vi.fn(); + const actions: Action[] = [ + { + label: "Delete", + icon: "Trash", + confirmation: "Confirm deletion?", + onClick, + }, + ]; + + render(); + + const deleteButton = screen.getByTestId("action-Delete"); + fireEvent.click(deleteButton); + + expect(screen.getByText("Confirm deletion?")).toBeInTheDocument(); + + const cancelButton = screen.getByRole("button", { name: "Cancel" }); + fireEvent.click(cancelButton); + + await waitFor(() => { + expect(screen.queryByText("Confirm deletion?")).not.toBeInTheDocument(); + }); + + expect(screen.getByTestId("action-Delete")).toBeInTheDocument(); + }); + + test("executes onClick directly when no confirmation is provided", () => { + const onClick = vi.fn(); + const actions: Action[] = [{ label: "Copy", icon: "Copy", onClick }]; + + render(); + + const button = screen.getByTestId("action-Copy"); + fireEvent.click(button); + + expect(onClick).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + test("shows action label as dialog title", () => { + const actions: Action[] = [ + { + label: "Delete Item", + icon: "Trash", + confirmation: "Confirm?", + onClick: vi.fn(), + }, + ]; + + render(); + + const button = screen.getByTestId("action-Delete Item"); + fireEvent.click(button); + + expect(screen.getByText("Delete Item")).toBeInTheDocument(); + expect(screen.getByText("Confirm?")).toBeInTheDocument(); + }); + }); + + describe("tooltip", () => { + test("action has tooltip with label", () => { + const actions: Action[] = [ + { label: "Copy to Clipboard", icon: "Copy", onClick: vi.fn() }, + ]; + + render(); + + const button = screen.getByTestId("action-Copy to Clipboard"); + expect(button).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/shared/ContextPanel/Blocks/ActionBlock.tsx b/src/components/shared/ContextPanel/Blocks/ActionBlock.tsx new file mode 100644 index 000000000..5409d8db6 --- /dev/null +++ b/src/components/shared/ContextPanel/Blocks/ActionBlock.tsx @@ -0,0 +1,106 @@ +import { type ReactNode, useState } from "react"; + +import { Icon, type IconName } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Heading } from "@/components/ui/typography"; + +import TooltipButton from "../../Buttons/TooltipButton"; +import { ConfirmationDialog } from "../../Dialogs"; + +export type Action = { + label: string; + destructive?: boolean; + disabled?: boolean; + hidden?: boolean; + confirmation?: string; + onClick: () => void; + className?: string; +} & ( + | { icon: IconName; content?: never } + | { content: ReactNode; icon?: never } +); + +// Temporary: ReactNode included for backward compatibility with some existing buttons. In the long-term we should strive for only Action types. +type ActionOrReactNode = Action | ReactNode; + +interface ActionBlockProps { + title?: string; + actions: ActionOrReactNode[]; + className?: string; +} + +export const ActionBlock = ({ + title, + actions, + className, +}: ActionBlockProps) => { + const [isOpen, setIsOpen] = useState(false); + const [dialogAction, setDialogAction] = useState(null); + + const openConfirmationDialog = (action: Action) => { + return () => { + setDialogAction(action); + setIsOpen(true); + }; + }; + + const handleConfirm = () => { + setIsOpen(false); + dialogAction?.onClick(); + setDialogAction(null); + }; + + const handleCancel = () => { + setIsOpen(false); + setDialogAction(null); + }; + + return ( + <> + + {title && {title}} + + {actions.map((action, index) => { + if (!action || typeof action !== "object" || !("label" in action)) { + return
{action}
; + } + + if (action.hidden) { + return null; + } + + return ( + + {action.content === undefined && action.icon ? ( + + ) : ( + action.content + )} + + ); + })} +
+
+ + + + ); +}; diff --git a/src/components/shared/ContextPanel/Blocks/ContentBlock.test.tsx b/src/components/shared/ContextPanel/Blocks/ContentBlock.test.tsx new file mode 100644 index 000000000..f982b4b0c --- /dev/null +++ b/src/components/shared/ContextPanel/Blocks/ContentBlock.test.tsx @@ -0,0 +1,172 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; + +import { ContentBlock } from "./ContentBlock"; + +describe("", () => { + test("renders with title and children", () => { + render( + +
Test Content
+
, + ); + + expect(screen.getByText("Test Title")).toBeInTheDocument(); + expect(screen.getByText("Test Content")).toBeInTheDocument(); + }); + + test("renders without title", () => { + render( + +
Test Content
+
, + ); + + expect(screen.queryByRole("heading")).not.toBeInTheDocument(); + expect(screen.getByText("Test Content")).toBeInTheDocument(); + }); + + test("returns null when children is not provided", () => { + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + test("applies custom className to container", () => { + const { container } = render( + +
Content
+
, + ); + + const blockStack = container.querySelector(".custom-class"); + expect(blockStack).toBeInTheDocument(); + }); + + test("renders title as Heading component with level 3", () => { + render( + +
Content
+
, + ); + + const heading = screen.getByRole("heading", { + level: 3, + name: "My Heading", + }); + expect(heading).toBeInTheDocument(); + }); + + test("renders multiple children", () => { + render( + +
First Child
+
Second Child
+ Third Child +
, + ); + + expect(screen.getByText("First Child")).toBeInTheDocument(); + expect(screen.getByText("Second Child")).toBeInTheDocument(); + expect(screen.getByText("Third Child")).toBeInTheDocument(); + }); + + test("renders with complex nested content", () => { + render( + +
+

Paragraph 1

+
    +
  • Item 1
  • +
  • Item 2
  • +
+
+
, + ); + + expect(screen.getByText("Paragraph 1")).toBeInTheDocument(); + expect(screen.getByText("Item 1")).toBeInTheDocument(); + expect(screen.getByText("Item 2")).toBeInTheDocument(); + }); + + test("handles undefined children gracefully", () => { + const { container } = render( + {undefined}, + ); + + // Should return null since children are falsy + expect(container.firstChild).toBeNull(); + }); + + test("renders with ReactNode as children", () => { + const CustomComponent = () =>
Custom Component Content
; + + render( + + + , + ); + + expect(screen.getByText("Custom Component Content")).toBeInTheDocument(); + }); + + test("renders with both title and className", () => { + const { container } = render( + +
Styled Content
+
, + ); + + expect(screen.getByText("Styled Block")).toBeInTheDocument(); + expect(container.querySelector(".my-custom-style")).toBeInTheDocument(); + }); + + describe("non-collapsible mode", () => { + test("does not show toggle button when collapsible is false", () => { + render( + +
Content
+
, + ); + + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + expect(screen.getByText("Content")).toBeInTheDocument(); + }); + + test("does not show toggle button when collapsible is not provided", () => { + render( + +
Content
+
, + ); + + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + expect(screen.getByText("Content")).toBeInTheDocument(); + }); + }); + + describe("collapsible mode", () => { + test("shows toggle button when collapsible is true", () => { + render( + +
Content
+
, + ); + + const button = screen.getByRole("button", { name: /toggle/i }); + expect(button).toBeInTheDocument(); + }); + + test("starts open when defaultOpen is true", () => { + render( + +
Visible Content
+
, + ); + + const content = screen.getByText("Visible Content"); + expect(content).toBeVisible(); + expect(content.parentElement).toHaveAttribute("data-state", "open"); + }); + }); +}); diff --git a/src/components/shared/ContextPanel/Blocks/ContentBlock.tsx b/src/components/shared/ContextPanel/Blocks/ContentBlock.tsx new file mode 100644 index 000000000..08b32b456 --- /dev/null +++ b/src/components/shared/ContextPanel/Blocks/ContentBlock.tsx @@ -0,0 +1,65 @@ +import { type ReactNode } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { Icon } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Heading } from "@/components/ui/typography"; + +type ContentBlockProps = + | { + title?: string; + children?: ReactNode; + collapsible?: false; + defaultOpen?: never; + className?: string; + } + | { + title?: string; + children?: ReactNode; + collapsible: true; + defaultOpen?: boolean; + className?: string; + }; + +export const ContentBlock = ({ + title, + children, + collapsible, + defaultOpen = false, + className, +}: ContentBlockProps) => { + if (!children) { + return null; + } + + return ( + + + + {title && {title}} + {collapsible && ( + + + + )} + + + {collapsible ? ( + + {children} + + ) : ( + children + )} + + + ); +}; diff --git a/src/components/shared/ContextPanel/Blocks/KeyValuePair.tsx b/src/components/shared/ContextPanel/Blocks/KeyValuePair.tsx new file mode 100644 index 000000000..6aad455bc --- /dev/null +++ b/src/components/shared/ContextPanel/Blocks/KeyValuePair.tsx @@ -0,0 +1,78 @@ +import { InlineStack } from "@/components/ui/layout"; +import { Link } from "@/components/ui/link"; +import { Paragraph } from "@/components/ui/typography"; +import { cn } from "@/lib/utils"; + +import { CopyText } from "../../CopyText/CopyText"; + +export interface KeyValuePairProps { + label?: string; + value?: string | { href: string; text: string }; + critical?: boolean; + copyable?: boolean; +} + +export const KeyValuePair = ({ + label, + value, + critical, + copyable, +}: KeyValuePairProps) => { + if (!value) { + return null; + } + + return ( + + {label && ( + + {label}: + + )} + +
+ {isLink(value) ? ( + + {value.text} + + ) : copyable ? ( + + {value} + + ) : ( + + {value} + + )} +
+
+ ); +}; + +const isLink = ( + val: string | { href: string; text: string }, +): val is { href: string; text: string } => { + return typeof val === "object" && val !== null && "href" in val; +}; diff --git a/src/components/shared/ContextPanel/Blocks/ListBlock.test.tsx b/src/components/shared/ContextPanel/Blocks/ListBlock.test.tsx new file mode 100644 index 000000000..5633cd40c --- /dev/null +++ b/src/components/shared/ContextPanel/Blocks/ListBlock.test.tsx @@ -0,0 +1,210 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; + +import type { KeyValuePairProps } from "./KeyValuePair"; +import { ListBlock } from "./ListBlock"; + +describe("", () => { + test("renders without title", () => { + const items: KeyValuePairProps[] = [{ label: "Item 1", value: "Value 1" }]; + + render(); + + expect(screen.queryByRole("heading")).not.toBeInTheDocument(); + expect(screen.getByText("Item 1:")).toBeInTheDocument(); + expect(screen.getByText("Value 1")).toBeInTheDocument(); + }); + + test("renders with title", () => { + const items: KeyValuePairProps[] = [{ label: "Item 1", value: "Value 1" }]; + + render(); + + expect( + screen.getByRole("heading", { level: 3, name: "My List" }), + ).toBeInTheDocument(); + expect(screen.getByText("Item 1:")).toBeInTheDocument(); + }); + + test("renders with custom className", () => { + const items: KeyValuePairProps[] = [{ label: "Item 1", value: "Value 1" }]; + + const { container } = render( + , + ); + + const blockStack = container.querySelector(".custom-class"); + expect(blockStack).toBeInTheDocument(); + }); + + test("renders empty items array without error", () => { + const { container } = render(); + + expect(container.querySelector("li")).not.toBeInTheDocument(); + }); + + describe("item rendering", () => { + test("renders single item", () => { + const items: KeyValuePairProps[] = [{ label: "Key", value: "Value" }]; + + render(); + + expect(screen.getByText("Key:")).toBeInTheDocument(); + expect(screen.getByText("Value")).toBeInTheDocument(); + }); + + test("renders multiple items", () => { + const items: KeyValuePairProps[] = [ + { label: "Item 1", value: "Value 1" }, + { label: "Item 2", value: "Value 2" }, + { label: "Item 3", value: "Value 3" }, + ]; + + render(); + + expect(screen.getByText("Item 1:")).toBeInTheDocument(); + expect(screen.getByText("Value 1")).toBeInTheDocument(); + expect(screen.getByText("Item 2:")).toBeInTheDocument(); + expect(screen.getByText("Value 2")).toBeInTheDocument(); + expect(screen.getByText("Item 3:")).toBeInTheDocument(); + expect(screen.getByText("Value 3")).toBeInTheDocument(); + }); + + test("skips items with no value", () => { + const items: KeyValuePairProps[] = [ + { label: "Item 1", value: "Value 1" }, + { label: "Item 2", value: "" }, + { label: "Item 3", value: "Value 3" }, + ]; + + render(); + + expect(screen.getByText("Item 1:")).toBeInTheDocument(); + expect(screen.queryByText("Item 2:")).not.toBeInTheDocument(); + expect(screen.getByText("Item 3:")).toBeInTheDocument(); + }); + + test("skips items with undefined value", () => { + const items: KeyValuePairProps[] = [ + { label: "Item 1", value: "Value 1" }, + { label: "Item 2", value: undefined }, + { label: "Item 3", value: "Value 3" }, + ]; + + render(); + + expect(screen.getByText("Item 1:")).toBeInTheDocument(); + expect(screen.queryByText("Item 2:")).not.toBeInTheDocument(); + expect(screen.getByText("Item 3:")).toBeInTheDocument(); + }); + }); + + describe("marker types", () => { + test("renders as unordered list with bullet marker by default", () => { + const items: KeyValuePairProps[] = [ + { label: "Item 1", value: "Value 1" }, + ]; + + const { container } = render(); + + const list = container.querySelector("ul"); + expect(list).toBeInTheDocument(); + expect(list).toHaveClass("list-disc"); + expect(list).toHaveClass("pl-5"); + }); + + test("renders as unordered list with bullet marker", () => { + const items: KeyValuePairProps[] = [ + { label: "Item 1", value: "Value 1" }, + ]; + + const { container } = render(); + + const list = container.querySelector("ul"); + expect(list).toBeInTheDocument(); + expect(list).toHaveClass("list-disc"); + expect(list).toHaveClass("pl-5"); + }); + + test("renders as ordered list with number marker", () => { + const items: KeyValuePairProps[] = [ + { label: "Item 1", value: "Value 1" }, + ]; + + const { container } = render(); + + const list = container.querySelector("ol"); + expect(list).toBeInTheDocument(); + expect(list).toHaveClass("list-decimal"); + expect(list).toHaveClass("pl-5"); + }); + + test("renders as unordered list with no marker", () => { + const items: KeyValuePairProps[] = [ + { label: "Item 1", value: "Value 1" }, + ]; + + const { container } = render(); + + const list = container.querySelector("ul"); + expect(list).toBeInTheDocument(); + expect(list).toHaveClass("list-none"); + expect(list).not.toHaveClass("pl-5"); + }); + }); + + describe("list items", () => { + test("each item is wrapped in li element", () => { + const items: KeyValuePairProps[] = [ + { label: "Item 1", value: "Value 1" }, + { label: "Item 2", value: "Value 2" }, + ]; + + const { container } = render(); + + const listItems = container.querySelectorAll("li"); + expect(listItems).toHaveLength(2); + }); + }); + + describe("edge cases", () => { + test("renders items with only some having values", () => { + const items: KeyValuePairProps[] = [ + { label: "Item 1", value: "Value 1" }, + { label: "Item 2", value: "" }, + { label: "Item 3", value: undefined }, + { label: "Item 4", value: "Value 4" }, + ]; + + const { container } = render(); + + const listItems = container.querySelectorAll("li"); + // Only 2 items should render (Item 1 and Item 4) + expect(listItems).toHaveLength(2); + expect(screen.getByText("Item 1:")).toBeInTheDocument(); + expect(screen.getByText("Item 4:")).toBeInTheDocument(); + }); + + test("handles all items having no value", () => { + const items: KeyValuePairProps[] = [ + { label: "Item 1", value: "" }, + { label: "Item 2", value: undefined }, + ]; + + const { container } = render(); + + const listItems = container.querySelectorAll("li"); + expect(listItems).toHaveLength(0); + }); + + test("renders title even when no items have values", () => { + const items: KeyValuePairProps[] = [{ label: "Item 1", value: "" }]; + + render(); + + expect( + screen.getByRole("heading", { name: "Empty List" }), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/shared/ContextPanel/Blocks/ListBlock.tsx b/src/components/shared/ContextPanel/Blocks/ListBlock.tsx new file mode 100644 index 000000000..57c75638d --- /dev/null +++ b/src/components/shared/ContextPanel/Blocks/ListBlock.tsx @@ -0,0 +1,52 @@ +import { BlockStack } from "@/components/ui/layout"; +import { Heading } from "@/components/ui/typography"; + +import { KeyValuePair, type KeyValuePairProps } from "./KeyValuePair"; + +interface ListBlockProps { + title?: string; + items: KeyValuePairProps[]; + marker?: "bullet" | "number" | "none"; + className?: string; +} + +export const ListBlock = ({ + title, + items, + marker = "bullet", + className, +}: ListBlockProps) => { + const listElement = marker === "number" ? "ol" : "ul"; + + const getListStyle = () => { + switch (marker) { + case "bullet": + return "pl-5 list-disc"; + case "number": + return "pl-5 list-decimal"; + case "none": + return "list-none"; + default: + return "pl-5 list-disc"; + } + }; + + return ( + + {title && {title}} + + {items.map((item, index) => { + if (!item.value) { + return null; + } + + return ( +
  • + +
  • + ); + })} +
    +
    + ); +}; diff --git a/src/components/shared/ContextPanel/Blocks/TextBlock.test.tsx b/src/components/shared/ContextPanel/Blocks/TextBlock.test.tsx new file mode 100644 index 000000000..a6dd90bac --- /dev/null +++ b/src/components/shared/ContextPanel/Blocks/TextBlock.test.tsx @@ -0,0 +1,185 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; + +import { TextBlock } from "./TextBlock"; + +describe("", () => { + test("renders with text only", () => { + render(); + + expect(screen.getByText("Test Text")).toBeInTheDocument(); + }); + + test("renders with title and text", () => { + render(); + + expect(screen.getByText("Test Title")).toBeInTheDocument(); + expect(screen.getByText("Test Text")).toBeInTheDocument(); + }); + + test("renders without title", () => { + render(); + + expect(screen.queryByRole("heading")).not.toBeInTheDocument(); + expect(screen.getByText("Test Text")).toBeInTheDocument(); + }); + + test("returns null when text is not provided", () => { + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + test("returns null when text is empty string", () => { + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + test("applies custom className to container", () => { + const { container } = render( + , + ); + + const blockStack = container.querySelector(".custom-class"); + expect(blockStack).toBeInTheDocument(); + }); + + test("renders title as Heading component with level 3", () => { + render(); + + const heading = screen.getByRole("heading", { + level: 3, + name: "My Heading", + }); + expect(heading).toBeInTheDocument(); + }); + + describe("text styling", () => { + test("renders with monospace font when mono is true", () => { + render(); + + const paragraph = screen.getByText("Mono Text"); + expect(paragraph).toHaveClass("!font-mono"); + }); + + test("renders with default font when mono is false", () => { + render(); + + const paragraph = screen.getByText("Normal Text"); + expect(paragraph).not.toHaveClass("font-mono"); + }); + + test("renders with default font when mono is not provided", () => { + render(); + + const paragraph = screen.getByText("Normal Text"); + expect(paragraph).not.toHaveClass("font-mono"); + }); + + test("truncates text when wrap is false", () => { + render(); + + const paragraph = screen.getByText("Long Text"); + expect(paragraph).toHaveClass("truncate"); + expect(paragraph).not.toHaveClass("wrap-break-words"); + }); + + test("truncates text by default when wrap is not provided", () => { + render(); + + const paragraph = screen.getByText("Long Text"); + expect(paragraph).toHaveClass("truncate"); + expect(paragraph).not.toHaveClass("wrap-break-words"); + }); + + test("wraps text when wrap is true", () => { + render(); + + const paragraph = screen.getByText("Long Text"); + expect(paragraph).toHaveClass("wrap-break-words"); + expect(paragraph).not.toHaveClass("truncate"); + }); + + test("applies text-xs and text-muted-foreground classes", () => { + render(); + + const paragraph = screen.getByText("Styled Text"); + expect(paragraph).toHaveClass("text-xs"); + expect(paragraph).toHaveClass("text-muted-foreground"); + }); + }); + + describe("copyable functionality", () => { + test("renders CopyText component when copyable is true", () => { + render(); + + // CopyText should be present + const copyText = screen.getByText("Copyable Text"); + expect(copyText).toBeInTheDocument(); + }); + + test("renders Paragraph component when copyable is false", () => { + render(); + + const paragraph = screen.getByText("Non-Copyable Text"); + expect(paragraph.tagName).toBe("P"); + }); + + test("renders Paragraph component when copyable is not provided", () => { + render(); + + const paragraph = screen.getByText("Non-Copyable Text"); + expect(paragraph.tagName).toBe("P"); + }); + }); + + describe("non-collapsible mode", () => { + test("does not show toggle button when collapsible is false", () => { + render( + , + ); + + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + expect(screen.getByText("Content")).toBeInTheDocument(); + }); + + test("does not show toggle button when collapsible is not provided", () => { + render(); + + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + expect(screen.getByText("Content")).toBeInTheDocument(); + }); + }); + + describe("collapsible mode", () => { + test("shows toggle button when collapsible is true", () => { + render( + , + ); + + const button = screen.getByRole("button", { name: /toggle/i }); + expect(button).toBeInTheDocument(); + }); + }); + + describe("combined props", () => { + test("renders with mono, wrap, and copyable together", () => { + render( + , + ); + + const text = screen.getByText("Combined Text"); + expect(text).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/shared/ContextPanel/Blocks/TextBlock.tsx b/src/components/shared/ContextPanel/Blocks/TextBlock.tsx new file mode 100644 index 000000000..6c0c6dee1 --- /dev/null +++ b/src/components/shared/ContextPanel/Blocks/TextBlock.tsx @@ -0,0 +1,81 @@ +import { Button } from "@/components/ui/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { Icon } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Heading, Paragraph } from "@/components/ui/typography"; +import { cn } from "@/lib/utils"; + +import { CopyText } from "../../CopyText/CopyText"; + +interface TextBlockProps { + title?: string; + text?: string; + copyable?: boolean; + collapsible?: boolean; + mono?: boolean; + wrap?: boolean; + className?: string; +} + +export const TextBlock = ({ + title, + text, + copyable, + collapsible, + mono, + wrap = false, + className, +}: TextBlockProps) => { + if (!text) { + return null; + } + + const textClassName = cn("text-xs text-muted-foreground", { + "font-mono": mono, + "wrap-break-words": wrap, + truncate: !wrap, + }); + + const content = copyable ? ( + {text} + ) : ( + + {text} + + ); + + return ( + + + + {title && {title}} + {collapsible && ( + + + + )} + + + {collapsible ? ( + + {content} + + ) : ( + content + )} + + + ); +}; diff --git a/src/components/ui/icon.tsx b/src/components/ui/icon.tsx index 0f692593b..ca6b0a15e 100644 --- a/src/components/ui/icon.tsx +++ b/src/components/ui/icon.tsx @@ -15,8 +15,10 @@ const iconVariants = cva("", { }, }); +export type IconName = keyof typeof icons; + interface IconProps extends VariantProps { - name: keyof typeof icons; + name: IconName; className?: string; }