diff --git a/.gitignore b/.gitignore index 4bb12b759..5896b9850 100644 --- a/.gitignore +++ b/.gitignore @@ -276,4 +276,5 @@ src/frontend/temp .history .dspy_cache/ -*.db \ No newline at end of file +*.db +*.mcp.json \ No newline at end of file diff --git a/src/frontend/src/components/core/jsonEditor/index.tsx b/src/frontend/src/components/core/jsonEditor/index.tsx index ac8e99e44..8e55b9119 100644 --- a/src/frontend/src/components/core/jsonEditor/index.tsx +++ b/src/frontend/src/components/core/jsonEditor/index.tsx @@ -3,10 +3,13 @@ import { KeyboardEvent, useEffect, useRef, useState } from "react"; import { Content, createJSONEditor, + MenuItem, + Mode, JsonEditor as VanillaJsonEditor, } from "vanilla-jsoneditor"; import useAlertStore from "../../../stores/alertStore"; import { cn } from "../../../utils/utils"; +import { useMenuCustomization } from "./useMenuCustomization"; interface JsonEditorProps { data?: Content; @@ -43,6 +46,9 @@ const JsonEditor = ({ const [originalData, setOriginalData] = useState(data); const [isFiltered, setIsFiltered] = useState(false); const [showSuccess, setShowSuccess] = useState(false); + const setSuccessData = useAlertStore((state) => state.setSuccessData); + + const { customizeMenu } = useMenuCustomization(setSuccessData, setErrorData); // Apply initial filter when component mounts useEffect(() => { @@ -61,12 +67,6 @@ const JsonEditor = ({ ); }; - const handleInputChange = (e: React.ChangeEvent) => { - setTransformQuery(e.target.value); - setIsFiltered(false); - setShowSuccess(false); - }; - const applyFilter = (filtered: { json: any }, query: string) => { onChange?.(filtered); setFilter?.(query.trim()); @@ -339,6 +339,8 @@ const JsonEditor = ({ containerRef.current.style.height = height; } + let editorInstance: VanillaJsonEditor | null = null; + const editor = createJSONEditor({ target: containerRef.current, props: { @@ -350,9 +352,19 @@ const JsonEditor = ({ onChange: (content) => { onChange?.(content); }, + onRenderMenu: ( + items: MenuItem[], + context: { mode: Mode; modal: boolean; readOnly: boolean }, + ) => { + // Use a getter function that will return the editor when called + return customizeMenu(items, context, () => editorInstance); + }, }, }); + // Set the editor instance immediately after creation + editorInstance = editor; + setTimeout(() => editor.focus(), 100); newRef.current = editor; diff --git a/src/frontend/src/components/core/jsonEditor/menuUtils.ts b/src/frontend/src/components/core/jsonEditor/menuUtils.ts new file mode 100644 index 000000000..18906e726 --- /dev/null +++ b/src/frontend/src/components/core/jsonEditor/menuUtils.ts @@ -0,0 +1,128 @@ +import { faCopy } from "@fortawesome/free-solid-svg-icons"; +import { MenuItem, Mode } from "vanilla-jsoneditor"; + +export const filterTextModeItems = (items: MenuItem[]): MenuItem[] => { + return items.filter((item) => { + if (item.type === "button" && item.title) { + const title = item.title.toLowerCase(); + // Remove search buttons in text mode only + if (title.includes("search") || title.includes("find")) { + return false; + } + } + return true; + }); +}; + +export const hasCopyButton = (items: MenuItem[]): boolean => { + return items.some( + (item) => + item.type === "button" && item.title?.toLowerCase().includes("copy"), + ); +}; + +export const createCopyButton = ( + getEditor: () => any, + setSuccessData: (data: { title: string }) => void, + setErrorData: (data: { title: string; list: string[] }) => void, +): MenuItem => { + return { + type: "button" as const, + onClick: () => { + const editor = getEditor(); + if (!editor) { + setErrorData({ + title: "Copy Failed", + list: ["Editor not available"], + }); + return; + } + + const currentContent = editor.get(); + const textContent = + "text" in currentContent + ? currentContent.text + : JSON.stringify(currentContent.json, null, 2); + navigator.clipboard + .writeText(textContent) + .then(() => { + setSuccessData({ title: "JSON copied to clipboard" }); + }) + .catch(() => { + setErrorData({ + title: "Copy Failed", + list: ["Unable to copy to clipboard. Please copy manually."], + }); + }); + }, + icon: faCopy, + title: "Copy JSON to clipboard", + }; +}; + +export const addCopyButtonToItems = ( + items: MenuItem[], + copyButton: MenuItem, +): MenuItem[] => { + const updatedItems = [...items]; + updatedItems.push({ type: "separator" as const }); + updatedItems.push(copyButton); + return updatedItems; +}; + +export const enhanceExistingCopyButtons = ( + items: MenuItem[], + setSuccessData: (data: { title: string }) => void, + successMessage: string = "JSON copied to clipboard", +): MenuItem[] => { + return items.map((item) => { + if (item.type === "button" && item.title?.toLowerCase().includes("copy")) { + const originalOnClick = item.onClick; + return { + ...item, + onClick: (event: MouseEvent) => { + // Call the original copy function + if (originalOnClick) { + originalOnClick(event); + } + // Add our success notification + setSuccessData({ title: successMessage }); + }, + }; + } + return item; + }); +}; + +export const processTextModeItems = ( + items: MenuItem[], + getEditor: () => any, + setSuccessData: (data: { title: string }) => void, + setErrorData: (data: { title: string; list: string[] }) => void, +): MenuItem[] => { + let filteredItems = filterTextModeItems(items); + + if (!hasCopyButton(filteredItems)) { + const copyButton = createCopyButton( + getEditor, + setSuccessData, + setErrorData, + ); + filteredItems = addCopyButtonToItems(filteredItems, copyButton); + } else { + filteredItems = enhanceExistingCopyButtons(filteredItems, setSuccessData); + } + + return filteredItems; +}; + +export const processTreeModeItems = ( + items: MenuItem[], + setSuccessData: (data: { title: string }) => void, +): MenuItem[] => { + return enhanceExistingCopyButtons( + items, + setSuccessData, + "Copied to clipboard", + ); +}; diff --git a/src/frontend/src/components/core/jsonEditor/useMenuCustomization.ts b/src/frontend/src/components/core/jsonEditor/useMenuCustomization.ts new file mode 100644 index 000000000..9b3f6aab6 --- /dev/null +++ b/src/frontend/src/components/core/jsonEditor/useMenuCustomization.ts @@ -0,0 +1,32 @@ +import { MenuItem, Mode } from "vanilla-jsoneditor"; +import { processTextModeItems, processTreeModeItems } from "./menuUtils"; + +export const useMenuCustomization = ( + setSuccessData: (data: { title: string }) => void, + setErrorData: (data: { title: string; list: string[] }) => void, +) => { + const customizeMenu = ( + items: MenuItem[], + context: { mode: Mode; modal: boolean; readOnly: boolean }, + getEditor: () => any, + ): MenuItem[] => { + switch (context.mode) { + case "text": + return processTextModeItems( + items, + getEditor, + setSuccessData, + setErrorData, + ); + + case "tree": + return processTreeModeItems(items, setSuccessData); + + default: + // For all other modes, return items unchanged + return items; + } + }; + + return { customizeMenu }; +}; diff --git a/src/frontend/tests/extended/features/copy-button-in-output.spec.ts b/src/frontend/tests/extended/features/copy-button-in-output.spec.ts new file mode 100644 index 000000000..68ce89ceb --- /dev/null +++ b/src/frontend/tests/extended/features/copy-button-in-output.spec.ts @@ -0,0 +1,65 @@ +import { expect, test } from "@playwright/test"; +import { addCustomComponent } from "../../utils/add-custom-component"; +import { awaitBootstrapTest } from "../../utils/await-bootstrap-test"; +import { zoomOut } from "../../utils/zoom-out"; + +test( + "user should be able to copy JSON from output", + { tag: ["@release", "@workspace"] }, + async ({ page }) => { + await awaitBootstrapTest(page); + + await page.getByTestId("blank-flow").click(); + await page.waitForSelector('[data-testid="disclosure-data"]', { + timeout: 3000, + state: "visible", + }); + + await page.getByTestId("disclosure-data").click(); + + await page + .getByTestId("dataAPI Request") + .hover() + .then(async () => { + await page.getByTestId("add-component-button-api-request").click(); + + await page.waitForTimeout(500); + + await page + .getByTestId("popover-anchor-input-url_input") + .first() + .fill("https://www.google.com"); + }); + + await page.getByTestId("button_run_api request").click(); + + await page.waitForSelector("text=Running", { + timeout: 30000, + state: "visible", + }); + + await page.waitForSelector("text=built successfully", { timeout: 30000 }); + + await page.getByTestId("output-inspection-api response-apirequest").click(); + + await page.waitForSelector("text=Component Output", { timeout: 30000 }); + + await page.getByTitle("Copy JSON to clipboard").click(); + + await page.waitForSelector("text=JSON copied to clipboard", { + timeout: 30000, + }); + + await page.getByText("tree").last().click(); + + await page.waitForTimeout(1000); + + await page.locator(".jse-key").first().click(); + + await page.waitForTimeout(500); + + await page.getByTitle("Copy (Ctrl+C)").click(); + + await page.waitForSelector("text=Copied to clipboard", { timeout: 30000 }); + }, +);