feat: Add menu customization with copy functionality (#8803)

* 🔧 Update .gitignore to include *.mcp.json files
 Add useMenuCustomization hook to customize JSON editor menu items
📝 Add menuUtils for filtering and enhancing JSON editor menu items
📝 Add useMenuCustomization hook for customizing JSON editor menu items

* [autofix.ci] apply automated fixes

*  (copy-button-in-output.spec.ts): add test for user to copy JSON from output in the frontend application.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Cristhian Zanforlin Lousa 2025-07-04 10:38:06 -03:00 committed by GitHub
commit 6d99faa7d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 245 additions and 7 deletions

3
.gitignore vendored
View file

@ -276,4 +276,5 @@ src/frontend/temp
.history
.dspy_cache/
*.db
*.db
*.mcp.json

View file

@ -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<HTMLInputElement>) => {
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;

View file

@ -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",
);
};

View file

@ -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 };
};

View file

@ -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 });
},
);