From 2d25276446531632a658a00f70bbaac43bb84ee9 Mon Sep 17 00:00:00 2001 From: Camiel van Schoonhoven Date: Tue, 2 Dec 2025 14:11:15 -0800 Subject: [PATCH] Cleanup TaskDetails --- .../ContextPanel/Blocks/ActionBlock.tsx | 2 +- .../TaskNode/TaskNodeCard/TaskNodeCard.tsx | 57 +-- .../TaskNode/TaskOverview/TaskOverview.tsx | 1 - src/components/shared/TaskDetails/Actions.tsx | 101 +++++ src/components/shared/TaskDetails/Details.tsx | 392 +++--------------- .../shared/TaskDetails/GithubDetails.tsx | 58 +++ src/hooks/useCopyToClip.ts | 42 -- src/utils/URL.ts | 21 +- src/utils/yaml.ts | 15 - 9 files changed, 265 insertions(+), 424 deletions(-) create mode 100644 src/components/shared/TaskDetails/Actions.tsx create mode 100644 src/components/shared/TaskDetails/GithubDetails.tsx delete mode 100644 src/hooks/useCopyToClip.ts diff --git a/src/components/shared/ContextPanel/Blocks/ActionBlock.tsx b/src/components/shared/ContextPanel/Blocks/ActionBlock.tsx index 2aac88a6d..54cb6afe1 100644 --- a/src/components/shared/ContextPanel/Blocks/ActionBlock.tsx +++ b/src/components/shared/ContextPanel/Blocks/ActionBlock.tsx @@ -21,7 +21,7 @@ export type Action = { ); // 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; +export type ActionOrReactNode = Action | ReactNode; interface ActionBlockProps { title?: string; diff --git a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskNodeCard/TaskNodeCard.tsx b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskNodeCard/TaskNodeCard.tsx index 863274ce4..93e8ae512 100644 --- a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskNodeCard/TaskNodeCard.tsx +++ b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskNodeCard/TaskNodeCard.tsx @@ -1,5 +1,4 @@ import { useNavigate } from "@tanstack/react-router"; -import { CircleFadingArrowUp, CopyIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { TooltipButtonProps } from "@/components/shared/Buttons/TooltipButton"; @@ -122,42 +121,32 @@ const TaskNodeCard = () => { setIsEditDialogOpen(false); }, []); + const { onDuplicate, onUpgrade } = callbacks; + const taskConfigMarkup = useMemo(() => { const actions: Array = []; if (!readOnly) { - actions.push( - { - children: ( -
- -
- ), - variant: "outline", - tooltip: "Duplicate Task", - onClick: callbacks.onDuplicate, - }, - { - children: ( -
- -
- ), - variant: "outline", - className: cn(isCustomComponent && "hidden"), - tooltip: "Update Task from Source URL", - onClick: callbacks.onUpgrade, - }, - ); + actions.push({ + children: , + variant: "outline", + tooltip: "Duplicate Task", + onClick: onDuplicate, + }); + } + + if (!readOnly && !isCustomComponent) { + actions.push({ + children: , + variant: "outline", + tooltip: "Update Task from Source URL", + onClick: onUpgrade, + }); } if (isSubgraphNode && taskId && isSubgraphNavigationEnabled) { actions.push({ - children: ( -
- -
- ), + children: , variant: "outline", tooltip: `Enter Subgraph: ${subgraphDescription}`, onClick: () => navigateToSubgraph(taskId), @@ -166,11 +155,7 @@ const TaskNodeCard = () => { if (isInAppEditorEnabled) { actions.push({ - children: ( -
- -
- ), + children: , variant: "outline", tooltip: "Edit Component Definition", onClick: handleEditComponent, @@ -182,8 +167,6 @@ const TaskNodeCard = () => { taskNode, nodeId, readOnly, - callbacks.onDuplicate, - callbacks.onUpgrade, isInAppEditorEnabled, isCustomComponent, isSubgraphNode, @@ -191,6 +174,8 @@ const TaskNodeCard = () => { subgraphDescription, navigateToSubgraph, handleEditComponent, + onDuplicate, + onUpgrade, ]); const handleInputSectionClick = useCallback(() => { diff --git a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/TaskOverview.tsx b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/TaskOverview.tsx index a96759436..11360d3b5 100644 --- a/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/TaskOverview.tsx +++ b/src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/TaskOverview.tsx @@ -129,7 +129,6 @@ const TaskOverview = ({ taskNode, actions }: TaskOverviewProps) => { url={taskSpec.componentRef.url} onDelete={callbacks.onDelete} status={status} - hasDeletionConfirmation={false} readOnly={readOnly} actions={detailActions} /> diff --git a/src/components/shared/TaskDetails/Actions.tsx b/src/components/shared/TaskDetails/Actions.tsx new file mode 100644 index 000000000..7b853e642 --- /dev/null +++ b/src/components/shared/TaskDetails/Actions.tsx @@ -0,0 +1,101 @@ +import { type ReactNode } from "react"; +import { FaPython } from "react-icons/fa"; + +import useToastNotification from "@/hooks/useToastNotification"; +import type { ComponentSpec } from "@/utils/componentSpec"; +import { + downloadStringAsFile, + downloadYamlFromComponentText, +} from "@/utils/URL"; +import { componentSpecToText } from "@/utils/yaml"; + +import { + ActionBlock, + type ActionOrReactNode, +} from "../ContextPanel/Blocks/ActionBlock"; + +interface TaskActionsProps { + displayName: string; + componentSpec: ComponentSpec; + actions?: ReactNode[]; + onDelete?: () => void; + readOnly?: boolean; + className?: string; +} + +const TaskActions = ({ + displayName, + componentSpec, + actions = [], + onDelete, + readOnly = false, + className, +}: TaskActionsProps) => { + const notify = useToastNotification(); + + const pythonOriginalCode = + componentSpec?.metadata?.annotations?.original_python_code; + + const stringToPythonCodeDownload = () => { + if (!pythonOriginalCode) return; + + downloadStringAsFile( + pythonOriginalCode, + `${componentSpec?.name || displayName}.py`, + "text/x-python", + ); + }; + + const handleDownloadYaml = () => { + downloadYamlFromComponentText(componentSpec, displayName); + }; + + const handleCopyYaml = () => { + const code = componentSpecToText(componentSpec); + + navigator.clipboard.writeText(code).then( + () => notify("YAML copied to clipboard", "success"), + (err) => notify("Failed to copy YAML: " + err, "error"), + ); + }; + + const handleDelete = () => { + try { + onDelete?.(); + } catch (error) { + console.error("Error deleting component:", error); + notify(`Error deleting component`, "error"); + } + }; + + const orderedActions: ActionOrReactNode[] = [ + { + label: "Download YAML", + icon: "Download", + onClick: handleDownloadYaml, + }, + { + label: "Download Python Code", + content: , + hidden: !pythonOriginalCode, + onClick: stringToPythonCodeDownload, + }, + { + label: "Copy YAML", + icon: "Clipboard", + onClick: handleCopyYaml, + }, + ...actions, + { + label: "Delete Component", + icon: "Trash", + destructive: true, + hidden: !onDelete || readOnly, + onClick: handleDelete, + }, + ]; + + return ; +}; + +export default TaskActions; diff --git a/src/components/shared/TaskDetails/Details.tsx b/src/components/shared/TaskDetails/Details.tsx index 83d0ff692..4d4c2da2e 100644 --- a/src/components/shared/TaskDetails/Details.tsx +++ b/src/components/shared/TaskDetails/Details.tsx @@ -1,36 +1,13 @@ -import { - ChevronsUpDown, - ClipboardIcon, - DownloadIcon, - TrashIcon, -} from "lucide-react"; -import { type ReactNode, useState } from "react"; -import { FaPython } from "react-icons/fa"; +import { type ReactNode } from "react"; -import { Button } from "@/components/ui/button"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; -import { Link } from "@/components/ui/link"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { useCopyToClipboard } from "@/hooks/useCopyToClip"; -import useToastNotification from "@/hooks/useToastNotification"; -import { cn } from "@/lib/utils"; +import { BlockStack } from "@/components/ui/layout"; import type { ComponentSpec } from "@/utils/componentSpec"; -import { - convertGithubUrlToDirectoryUrl, - downloadYamlFromComponentText, - isGithubUrl, -} from "@/utils/URL"; -import copyToYaml from "@/utils/yaml"; +import { ContentBlock } from "../ContextPanel/Blocks/ContentBlock"; +import { TextBlock } from "../ContextPanel/Blocks/TextBlock"; +import TaskActions from "./Actions"; import { ExecutionDetails } from "./ExecutionDetails"; +import { GithubDetails } from "./GithubDetails"; interface TaskDetailsProps { displayName: string; @@ -41,7 +18,6 @@ interface TaskDetailsProps { url?: string; actions?: ReactNode[]; onDelete?: () => void; - hasDeletionConfirmation?: boolean; status?: string; readOnly?: boolean; additionalSection?: { @@ -51,6 +27,8 @@ interface TaskDetailsProps { }[]; } +const BASE_BLOCK_CLASS = "px-3 py-2"; + const TaskDetails = ({ displayName, componentSpec, @@ -60,23 +38,11 @@ const TaskDetails = ({ url, actions = [], onDelete, - hasDeletionConfirmation = true, status, readOnly = false, additionalSection = [], }: TaskDetailsProps) => { - const notify = useToastNotification(); - const [confirmDelete, setConfirmDelete] = useState(false); - const { isCopied, isTooltipOpen, handleCopy, handleTooltipOpen } = - useCopyToClipboard(componentDigest); - const canonicalUrl = componentSpec?.metadata?.annotations?.canonical_location; - const pythonOriginalCode = (componentSpec?.metadata?.annotations - ?.original_python_code || - componentSpec?.metadata?.annotations?.python_original_code) as - | string - | undefined; - let reconstructedUrl; if (!url) { // Try reconstruct the url from componentSpec.metadata.annotations @@ -103,292 +69,72 @@ const TaskDetails = ({ } } - const stringToPythonCodeDownload = () => { - if (!pythonOriginalCode) return; - - const blob = new Blob([pythonOriginalCode], { type: "text/x-python" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `${componentSpec?.name || displayName}.py`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - }; - - const handleDownloadYaml = () => { - downloadYamlFromComponentText(componentSpec, displayName); - }; - - const handleCopyYaml = () => { - copyToYaml( - componentSpec, - (message) => notify(message, "success"), - (message) => notify(message, "error"), - ); - }; - - const handleDelete = () => { - if (confirmDelete || !hasDeletionConfirmation) { - try { - onDelete?.(); - } catch (error) { - console.error("Error deleting component:", error); - } - } else if (hasDeletionConfirmation) { - setConfirmDelete(true); - } - }; + const author = componentSpec?.metadata?.annotations?.author; + const description = componentSpec?.description; return ( -
-
- {taskId && ( -
-
- Task ID -
-
- {taskId} -
-
- )} - {status && ( -
-
- Run Status -
-
- {status} -
-
- )} - - {executionId && ( - - )} - - {componentSpec?.metadata?.annotations?.author && ( -
-
- Author -
-
- {componentSpec.metadata.annotations?.author} -
-
- )} - - 0 ? url : reconstructedUrl} - canonicalUrl={canonicalUrl} + + + + + + {executionId && ( + + )} - {componentSpec?.description && ( -
- -
- Description - - - -
- - -
- {componentSpec.description} -
-
-
-
- )} - - {componentDigest && ( -
-
- Digest -
-
- - {componentDigest} - - - - - - - {isCopied ? "Copied" : "Copy Digest"} - - -
-
- )} - {additionalSection.map((section) => ( -
- - -
- {section.title} - - -
-
- - - {section.component} - -
-
- ))} - -
- - - - - Download YAML - - {pythonOriginalCode && ( - - - - - Download Python Code - - )} - - - - - Copy YAML - - - {actions} - - {onDelete && !readOnly && ( - - - - - - {confirmDelete || !hasDeletionConfirmation - ? "Confirm Delete. This action cannot be undone." - : "Delete Component"} - - - )} -
-
-
+ + + 0 ? url : reconstructedUrl} + canonicalUrl={canonicalUrl} + className={BASE_BLOCK_CLASS} + /> + + + + + + {additionalSection.map((section) => ( + + {section.component} + + ))} + + + ); }; export default TaskDetails; - -function LinkBlock({ - url, - canonicalUrl, -}: { - url?: string; - canonicalUrl?: string; -}) { - if (!url && !canonicalUrl) return null; - - return ( -
-
URL
- {url && ( - <> -
- - View raw component.yaml - -
-
- - View directory on GitHub - -
- - )} - {canonicalUrl && ( - <> -
- - View raw canonical URL - -
-
- - View canonical URL on GitHub - -
- - )} -
- ); -} diff --git a/src/components/shared/TaskDetails/GithubDetails.tsx b/src/components/shared/TaskDetails/GithubDetails.tsx new file mode 100644 index 000000000..5ec2b43e9 --- /dev/null +++ b/src/components/shared/TaskDetails/GithubDetails.tsx @@ -0,0 +1,58 @@ +import { BlockStack } from "@/components/ui/layout"; +import { Link } from "@/components/ui/link"; +import { Heading } from "@/components/ui/typography"; +import { convertGithubUrlToDirectoryUrl, isGithubUrl } from "@/utils/URL"; + +const linkProps = { + size: "xs", + variant: "classic", + external: true, + target: "_blank", + rel: "noopener noreferrer", +} as const; + +export function GithubDetails({ + url, + canonicalUrl, + className, +}: { + url?: string; + canonicalUrl?: string; + className?: string; +}) { + if (!url && !canonicalUrl) return null; + + return ( + + URL + {url && ( + <> + + View raw component.yaml + + + + View directory on GitHub + + + )} + {canonicalUrl && ( + <> + + View canonical URL + + + + View canonical URL on GitHub + + + )} + + ); +} diff --git a/src/hooks/useCopyToClip.ts b/src/hooks/useCopyToClip.ts deleted file mode 100644 index 0ece06fa5..000000000 --- a/src/hooks/useCopyToClip.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { useEffect, useRef, useState } from "react"; - -import { copyToClipboard } from "@/utils/string"; - -export function useCopyToClipboard(text?: string | null) { - const [isCopied, setIsCopied] = useState(false); - const [isTooltipOpen, setIsTooltipOpen] = useState(false); - const tooltipTimerRef = useRef(null); - - useEffect(() => { - return () => { - if (tooltipTimerRef.current) clearTimeout(tooltipTimerRef.current); - }; - }, []); - - const handleTooltipOpen = (open: boolean) => { - if (!open) { - if (tooltipTimerRef.current) clearTimeout(tooltipTimerRef.current); - setIsCopied(false); - } - setIsTooltipOpen(open); - }; - - const handleCopy = () => { - if (!text) return; - copyToClipboard(text); - setIsCopied(true); - setIsTooltipOpen(true); - - if (tooltipTimerRef.current) clearTimeout(tooltipTimerRef.current); - tooltipTimerRef.current = setTimeout(() => { - setIsTooltipOpen(false); - }, 1500); - }; - - return { - isCopied, - isTooltipOpen, - handleCopy, - handleTooltipOpen, - }; -} diff --git a/src/utils/URL.ts b/src/utils/URL.ts index 8dc0e4e03..18a79140f 100644 --- a/src/utils/URL.ts +++ b/src/utils/URL.ts @@ -114,22 +114,30 @@ const convertGithubUrlToDirectoryUrl = (url: string) => { } }; -const downloadYamlFromComponentText = ( - componentSpec: ComponentSpec, - displayName: string, +const downloadStringAsFile = ( + content: string, + filename: string, + contentType: string, ) => { - const code = componentSpecToText(componentSpec); - const blob = new Blob([code], { type: "text/yaml" }); + const blob = new Blob([content], { type: contentType }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; - a.download = `${componentSpec?.name || displayName}.yaml`; + a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }; +const downloadYamlFromComponentText = ( + componentSpec: ComponentSpec, + displayName: string, +) => { + const code = componentSpecToText(componentSpec); + downloadStringAsFile(code, `${displayName}.yaml`, "text/yaml"); +}; + const getIdOrTitleFromPath = ( pathname: string, ): { @@ -169,6 +177,7 @@ export { convertGcsUrlToBrowserUrl, convertGithubUrlToDirectoryUrl, convertHfUrlToDirectoryUrl, + downloadStringAsFile, downloadYamlFromComponentText, getIdOrTitleFromPath, isGithubUrl, diff --git a/src/utils/yaml.ts b/src/utils/yaml.ts index 28355ba98..5617e65ce 100644 --- a/src/utils/yaml.ts +++ b/src/utils/yaml.ts @@ -2,19 +2,6 @@ import yaml from "js-yaml"; import { type ComponentSpec, isValidComponentSpec } from "./componentSpec"; -const copyToYaml = ( - spec: ComponentSpec, - onSuccess: (message: string) => void, - onFail: (message: string) => void, -) => { - const code = componentSpecToText(spec); - - navigator.clipboard.writeText(code).then( - () => onSuccess("YAML copied to clipboard"), - (err) => onFail("Failed to copy YAML: " + err), - ); -}; - class ComponentSpecParsingError extends Error { readonly name = "ComponentSpecParsingError"; @@ -60,5 +47,3 @@ export const componentSpecToText = (componentSpec: ComponentSpec) => { indent: 2, }); }; - -export default copyToYaml;