Skip to content

Commit e08a5ab

Browse files
committed
Add UI Blocks to Context Panel
1 parent 73359f9 commit e08a5ab

File tree

5 files changed

+452
-0
lines changed

5 files changed

+452
-0
lines changed
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { type ReactNode, useCallback, useState } from "react";
2+
import { FaPython } from "react-icons/fa";
3+
4+
import { Icon } from "@/components/ui/icon";
5+
import { InlineStack } from "@/components/ui/layout";
6+
import { Paragraph } from "@/components/ui/typography";
7+
import useToastNotification from "@/hooks/useToastNotification";
8+
import type { ComponentSpec } from "@/utils/componentSpec";
9+
import { downloadYamlFromComponentText } from "@/utils/URL";
10+
import copyToYaml from "@/utils/yaml";
11+
12+
import TooltipButton from "../../Buttons/TooltipButton";
13+
14+
interface ActionBlockProps {
15+
displayName: string;
16+
componentSpec: ComponentSpec;
17+
actions?: ReactNode[];
18+
onDelete?: () => void;
19+
hasDeletionConfirmation?: boolean;
20+
readOnly?: boolean;
21+
className?: string;
22+
}
23+
24+
const ActionBlock = ({
25+
displayName,
26+
componentSpec,
27+
actions = [],
28+
onDelete,
29+
hasDeletionConfirmation = true,
30+
readOnly = false,
31+
className,
32+
}: ActionBlockProps) => {
33+
const notify = useToastNotification();
34+
const [confirmDelete, setConfirmDelete] = useState(false);
35+
36+
const pythonOriginalCode =
37+
componentSpec?.metadata?.annotations?.original_python_code;
38+
39+
const stringToPythonCodeDownload = () => {
40+
if (!pythonOriginalCode) return;
41+
42+
const blob = new Blob([pythonOriginalCode], { type: "text/x-python" });
43+
const url = URL.createObjectURL(blob);
44+
const a = document.createElement("a");
45+
a.href = url;
46+
a.download = `${componentSpec?.name || displayName}.py`;
47+
document.body.appendChild(a);
48+
a.click();
49+
document.body.removeChild(a);
50+
URL.revokeObjectURL(url);
51+
};
52+
53+
const handleDownloadYaml = () => {
54+
downloadYamlFromComponentText(componentSpec, displayName);
55+
};
56+
57+
const handleCopyYaml = () => {
58+
copyToYaml(
59+
componentSpec,
60+
(message) => notify(message, "success"),
61+
(message) => notify(message, "error"),
62+
);
63+
};
64+
65+
const handleDelete = useCallback(() => {
66+
if (confirmDelete || !hasDeletionConfirmation) {
67+
try {
68+
onDelete?.();
69+
} catch (error) {
70+
console.error("Error deleting component:", error);
71+
notify(`Error deleting component`, "error");
72+
}
73+
} else if (hasDeletionConfirmation) {
74+
setConfirmDelete(true);
75+
}
76+
}, [onDelete, confirmDelete, hasDeletionConfirmation, notify]);
77+
78+
return (
79+
<InlineStack gap="2" className={className}>
80+
<TooltipButton
81+
variant="outline"
82+
tooltip="Download YAML"
83+
onClick={handleDownloadYaml}
84+
>
85+
<Icon name="Download" />
86+
</TooltipButton>
87+
88+
{pythonOriginalCode && (
89+
<TooltipButton
90+
variant="outline"
91+
tooltip="Download Python Code"
92+
onClick={stringToPythonCodeDownload}
93+
>
94+
<FaPython />
95+
</TooltipButton>
96+
)}
97+
98+
<TooltipButton
99+
variant="outline"
100+
tooltip="Copy YAML"
101+
onClick={handleCopyYaml}
102+
>
103+
<Icon name="Clipboard" />
104+
</TooltipButton>
105+
106+
{actions}
107+
108+
{onDelete && !readOnly && (
109+
<TooltipButton
110+
variant="destructive"
111+
tooltip={
112+
confirmDelete || !hasDeletionConfirmation
113+
? "Confirm Delete. This action cannot be undone."
114+
: "Delete Component"
115+
}
116+
onClick={handleDelete}
117+
>
118+
<InlineStack gap="2" blockAlign="center">
119+
<Icon name="Trash" />
120+
{confirmDelete && hasDeletionConfirmation && (
121+
<Paragraph size="xs">Confirm Delete</Paragraph>
122+
)}
123+
</InlineStack>
124+
</TooltipButton>
125+
)}
126+
</InlineStack>
127+
);
128+
};
129+
130+
export default ActionBlock;
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+
<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+
};
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { Icon } from "@/components/ui/icon";
2+
import { BlockStack, InlineStack } from "@/components/ui/layout";
3+
import { Link } from "@/components/ui/link";
4+
import { Heading, Paragraph } from "@/components/ui/typography";
5+
import { convertGithubUrlToDirectoryUrl, isGithubUrl } from "@/utils/URL";
6+
7+
export function LinkBlock({
8+
url,
9+
canonicalUrl,
10+
className,
11+
}: {
12+
url?: string;
13+
canonicalUrl?: string;
14+
className?: string;
15+
}) {
16+
if (!url && !canonicalUrl) return null;
17+
18+
return (
19+
<BlockStack className={className}>
20+
<Heading level={3}>URL</Heading>
21+
{url && (
22+
<>
23+
<URLBlock href={url} text="View raw component.yaml" />
24+
<URLBlock
25+
href={isGithubUrl(url) ? convertGithubUrlToDirectoryUrl(url) : url}
26+
text="View directory on GitHub"
27+
/>
28+
</>
29+
)}
30+
{canonicalUrl && (
31+
<>
32+
<URLBlock href={canonicalUrl} text="View canonical URL" />
33+
<URLBlock
34+
href={convertGithubUrlToDirectoryUrl(canonicalUrl)}
35+
text="View canonical URL on GitHub"
36+
/>
37+
</>
38+
)}
39+
</BlockStack>
40+
);
41+
}
42+
43+
export type URLBlockProps = {
44+
href?: string;
45+
text: string;
46+
};
47+
48+
export const URLBlock = ({ href, text }: URLBlockProps) => (
49+
<InlineStack blockAlign="center" gap="1">
50+
<Link href={href} target="_blank" rel="noopener noreferrer">
51+
<Paragraph size="xs" className="text-sky-500 hover:underline">
52+
{text}
53+
</Paragraph>
54+
</Link>
55+
<Icon name="ExternalLink" size="xs" className="text-muted-foreground" />
56+
</InlineStack>
57+
);
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { BlockStack, InlineStack } from "@/components/ui/layout";
2+
import { Heading, Paragraph } from "@/components/ui/typography";
3+
4+
import { URLBlock, type URLBlockProps } from "./LinkBlock";
5+
6+
export interface ListBlockItemProps {
7+
name?: string;
8+
value?: string | URLBlockProps;
9+
critical?: boolean;
10+
}
11+
12+
interface ListBlockProps {
13+
title?: string;
14+
items: ListBlockItemProps[];
15+
marker?: "bullet" | "number" | "none";
16+
className?: string;
17+
}
18+
19+
export const ListBlock = ({
20+
title,
21+
items,
22+
marker = "bullet",
23+
className,
24+
}: ListBlockProps) => {
25+
const listElement = marker === "number" ? "ol" : "ul";
26+
27+
const getListStyle = () => {
28+
switch (marker) {
29+
case "bullet":
30+
return "pl-5 list-disc";
31+
case "number":
32+
return "pl-5 list-decimal";
33+
case "none":
34+
return "list-none";
35+
default:
36+
return "pl-5 list-disc";
37+
}
38+
};
39+
40+
return (
41+
<BlockStack className={className}>
42+
{title && <Heading level={3}>{title}</Heading>}
43+
<BlockStack as={listElement} gap="1" className={getListStyle()}>
44+
{items.map((item, index) => {
45+
if (!item.value) {
46+
return null;
47+
}
48+
49+
return (
50+
<li key={index} className="w-full">
51+
<ListBlockItem
52+
name={item.name}
53+
value={item.value}
54+
critical={item.critical}
55+
/>
56+
</li>
57+
);
58+
})}
59+
</BlockStack>
60+
</BlockStack>
61+
);
62+
};
63+
64+
const ListBlockItem = ({ name, value, critical }: ListBlockItemProps) => {
65+
if (!value) {
66+
return null;
67+
}
68+
69+
return (
70+
<InlineStack gap="2" blockAlign="center" wrap="nowrap">
71+
{name && (
72+
<Paragraph size="xs" tone={critical ? "critical" : "inherit"}>
73+
{name}:
74+
</Paragraph>
75+
)}
76+
{isURL(value) ? (
77+
<URLBlock href={value.href} text={value.text} />
78+
) : (
79+
<Paragraph
80+
size="xs"
81+
tone={critical ? "critical" : "subdued"}
82+
className="truncate"
83+
>
84+
{value}
85+
</Paragraph>
86+
)}
87+
</InlineStack>
88+
);
89+
};
90+
91+
const isURL = (val: string | URLBlockProps): val is URLBlockProps => {
92+
return typeof val === "object" && val !== null && "href" in val;
93+
};

0 commit comments

Comments
 (0)