Skip to content

Commit da65e9c

Browse files
committed
Add UI Blocks to Context Panel
1 parent 04acc14 commit da65e9c

File tree

10 files changed

+1374
-1
lines changed

10 files changed

+1374
-1
lines changed

src/components/shared/ContextPanel/Blocks/ActionBlock.test.tsx

Lines changed: 430 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { type ReactNode, useState } from "react";
2+
3+
import { Icon, type IconName } from "@/components/ui/icon";
4+
import { BlockStack, InlineStack } from "@/components/ui/layout";
5+
import { Heading } from "@/components/ui/typography";
6+
7+
import TooltipButton from "../../Buttons/TooltipButton";
8+
import { ConfirmationDialog } from "../../Dialogs";
9+
10+
export type Action = {
11+
label: string;
12+
destructive?: boolean;
13+
disabled?: boolean;
14+
hidden?: boolean;
15+
confirmation?: string;
16+
onClick: () => void;
17+
className?: string;
18+
} & (
19+
| { icon: IconName; content?: never }
20+
| { content: ReactNode; icon?: never }
21+
);
22+
23+
// Temporary: ReactNode included for backward compatibility with some existing buttons. In the long-term we should strive for only Action types.
24+
type ActionOrReactNode = Action | ReactNode;
25+
26+
interface ActionBlockProps {
27+
title?: string;
28+
actions: ActionOrReactNode[];
29+
className?: string;
30+
}
31+
32+
export const ActionBlock = ({
33+
title,
34+
actions,
35+
className,
36+
}: ActionBlockProps) => {
37+
const [isOpen, setIsOpen] = useState(false);
38+
const [dialogAction, setDialogAction] = useState<Action | null>(null);
39+
40+
const openConfirmationDialog = (action: Action) => {
41+
return () => {
42+
setDialogAction(action);
43+
setIsOpen(true);
44+
};
45+
};
46+
47+
const handleConfirm = () => {
48+
setIsOpen(false);
49+
dialogAction?.onClick();
50+
setDialogAction(null);
51+
};
52+
53+
const handleCancel = () => {
54+
setIsOpen(false);
55+
setDialogAction(null);
56+
};
57+
58+
return (
59+
<>
60+
<BlockStack className={className}>
61+
{title && <Heading level={3}>{title}</Heading>}
62+
<InlineStack gap="2">
63+
{actions.map((action, index) => {
64+
if (!action || typeof action !== "object" || !("label" in action)) {
65+
return <div key={index}>{action}</div>;
66+
}
67+
68+
if (action.hidden) {
69+
return null;
70+
}
71+
72+
return (
73+
<TooltipButton
74+
key={action.label}
75+
data-testid={`action-${action.label}`}
76+
variant={action.destructive ? "destructive" : "outline"}
77+
tooltip={action.label}
78+
onClick={
79+
action.confirmation
80+
? openConfirmationDialog(action)
81+
: action.onClick
82+
}
83+
disabled={action.disabled}
84+
className={action.className}
85+
>
86+
{action.content === undefined && action.icon ? (
87+
<Icon name={action.icon} />
88+
) : (
89+
action.content
90+
)}
91+
</TooltipButton>
92+
);
93+
})}
94+
</InlineStack>
95+
</BlockStack>
96+
97+
<ConfirmationDialog
98+
isOpen={isOpen}
99+
title={dialogAction?.label}
100+
description={dialogAction?.confirmation}
101+
onConfirm={handleConfirm}
102+
onCancel={handleCancel}
103+
/>
104+
</>
105+
);
106+
};
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { describe, expect, test } from "vitest";
3+
4+
import { ContentBlock } from "./ContentBlock";
5+
6+
describe("<ContentBlock />", () => {
7+
test("renders with title and children", () => {
8+
render(
9+
<ContentBlock title="Test Title">
10+
<div>Test Content</div>
11+
</ContentBlock>,
12+
);
13+
14+
expect(screen.getByText("Test Title")).toBeInTheDocument();
15+
expect(screen.getByText("Test Content")).toBeInTheDocument();
16+
});
17+
18+
test("renders without title", () => {
19+
render(
20+
<ContentBlock>
21+
<div>Test Content</div>
22+
</ContentBlock>,
23+
);
24+
25+
expect(screen.queryByRole("heading")).not.toBeInTheDocument();
26+
expect(screen.getByText("Test Content")).toBeInTheDocument();
27+
});
28+
29+
test("returns null when children is not provided", () => {
30+
const { container } = render(<ContentBlock title="No Content" />);
31+
32+
expect(container.firstChild).toBeNull();
33+
});
34+
35+
test("applies custom className to container", () => {
36+
const { container } = render(
37+
<ContentBlock title="Test" className="custom-class">
38+
<div>Content</div>
39+
</ContentBlock>,
40+
);
41+
42+
const blockStack = container.querySelector(".custom-class");
43+
expect(blockStack).toBeInTheDocument();
44+
});
45+
46+
test("renders title as Heading component with level 3", () => {
47+
render(
48+
<ContentBlock title="My Heading">
49+
<div>Content</div>
50+
</ContentBlock>,
51+
);
52+
53+
const heading = screen.getByRole("heading", {
54+
level: 3,
55+
name: "My Heading",
56+
});
57+
expect(heading).toBeInTheDocument();
58+
});
59+
60+
test("renders multiple children", () => {
61+
render(
62+
<ContentBlock title="Multiple Children">
63+
<div>First Child</div>
64+
<div>Second Child</div>
65+
<span>Third Child</span>
66+
</ContentBlock>,
67+
);
68+
69+
expect(screen.getByText("First Child")).toBeInTheDocument();
70+
expect(screen.getByText("Second Child")).toBeInTheDocument();
71+
expect(screen.getByText("Third Child")).toBeInTheDocument();
72+
});
73+
74+
test("renders with complex nested content", () => {
75+
render(
76+
<ContentBlock title="Complex Content">
77+
<div>
78+
<p>Paragraph 1</p>
79+
<ul>
80+
<li>Item 1</li>
81+
<li>Item 2</li>
82+
</ul>
83+
</div>
84+
</ContentBlock>,
85+
);
86+
87+
expect(screen.getByText("Paragraph 1")).toBeInTheDocument();
88+
expect(screen.getByText("Item 1")).toBeInTheDocument();
89+
expect(screen.getByText("Item 2")).toBeInTheDocument();
90+
});
91+
92+
test("handles undefined children gracefully", () => {
93+
const { container } = render(
94+
<ContentBlock title="Null Children">{undefined}</ContentBlock>,
95+
);
96+
97+
// Should return null since children are falsy
98+
expect(container.firstChild).toBeNull();
99+
});
100+
101+
test("renders with ReactNode as children", () => {
102+
const CustomComponent = () => <div>Custom Component Content</div>;
103+
104+
render(
105+
<ContentBlock title="React Component">
106+
<CustomComponent />
107+
</ContentBlock>,
108+
);
109+
110+
expect(screen.getByText("Custom Component Content")).toBeInTheDocument();
111+
});
112+
113+
test("renders with both title and className", () => {
114+
const { container } = render(
115+
<ContentBlock title="Styled Block" className="my-custom-style">
116+
<div>Styled Content</div>
117+
</ContentBlock>,
118+
);
119+
120+
expect(screen.getByText("Styled Block")).toBeInTheDocument();
121+
expect(container.querySelector(".my-custom-style")).toBeInTheDocument();
122+
});
123+
124+
describe("non-collapsible mode", () => {
125+
test("does not show toggle button when collapsible is false", () => {
126+
render(
127+
<ContentBlock title="Not Collapsible" collapsible={false}>
128+
<div>Content</div>
129+
</ContentBlock>,
130+
);
131+
132+
expect(screen.queryByRole("button")).not.toBeInTheDocument();
133+
expect(screen.getByText("Content")).toBeInTheDocument();
134+
});
135+
136+
test("does not show toggle button when collapsible is not provided", () => {
137+
render(
138+
<ContentBlock title="Default">
139+
<div>Content</div>
140+
</ContentBlock>,
141+
);
142+
143+
expect(screen.queryByRole("button")).not.toBeInTheDocument();
144+
expect(screen.getByText("Content")).toBeInTheDocument();
145+
});
146+
});
147+
148+
describe("collapsible mode", () => {
149+
test("shows toggle button when collapsible is true", () => {
150+
render(
151+
<ContentBlock title="Collapsible" collapsible={true}>
152+
<div>Content</div>
153+
</ContentBlock>,
154+
);
155+
156+
const button = screen.getByRole("button", { name: /toggle/i });
157+
expect(button).toBeInTheDocument();
158+
});
159+
160+
test("starts open when defaultOpen is true", () => {
161+
render(
162+
<ContentBlock title="Collapsible" collapsible={true} defaultOpen={true}>
163+
<div>Visible Content</div>
164+
</ContentBlock>,
165+
);
166+
167+
const content = screen.getByText("Visible Content");
168+
expect(content).toBeVisible();
169+
expect(content.parentElement).toHaveAttribute("data-state", "open");
170+
});
171+
});
172+
});
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { type ReactNode } from "react";
2+
3+
import { Button } from "@/components/ui/button";
4+
import {
5+
Collapsible,
6+
CollapsibleContent,
7+
CollapsibleTrigger,
8+
} from "@/components/ui/collapsible";
9+
import { Icon } from "@/components/ui/icon";
10+
import { BlockStack, InlineStack } from "@/components/ui/layout";
11+
import { Heading } from "@/components/ui/typography";
12+
13+
type ContentBlockProps =
14+
| {
15+
title?: string;
16+
children?: ReactNode;
17+
collapsible?: false;
18+
defaultOpen?: never;
19+
className?: string;
20+
}
21+
| {
22+
title?: string;
23+
children?: ReactNode;
24+
collapsible: true;
25+
defaultOpen?: boolean;
26+
className?: string;
27+
};
28+
29+
export const ContentBlock = ({
30+
title,
31+
children,
32+
collapsible,
33+
defaultOpen = false,
34+
className,
35+
}: ContentBlockProps) => {
36+
if (!children) {
37+
return null;
38+
}
39+
40+
return (
41+
<BlockStack className={className}>
42+
<Collapsible className="w-full" defaultOpen={defaultOpen}>
43+
<InlineStack blockAlign="center" gap="1">
44+
{title && <Heading level={3}>{title}</Heading>}
45+
{collapsible && (
46+
<CollapsibleTrigger asChild>
47+
<Button variant="ghost" size="sm">
48+
<Icon name="ChevronsUpDown" />
49+
<span className="sr-only">Toggle</span>
50+
</Button>
51+
</CollapsibleTrigger>
52+
)}
53+
</InlineStack>
54+
55+
{collapsible ? (
56+
<CollapsibleContent className="w-full mt-1">
57+
{children}
58+
</CollapsibleContent>
59+
) : (
60+
children
61+
)}
62+
</Collapsible>
63+
</BlockStack>
64+
);
65+
};

0 commit comments

Comments
 (0)