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:
parent
09de4d1532
commit
6d99faa7d0
5 changed files with 245 additions and 7 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -276,4 +276,5 @@ src/frontend/temp
|
||||||
.history
|
.history
|
||||||
|
|
||||||
.dspy_cache/
|
.dspy_cache/
|
||||||
*.db
|
*.db
|
||||||
|
*.mcp.json
|
||||||
|
|
@ -3,10 +3,13 @@ import { KeyboardEvent, useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Content,
|
Content,
|
||||||
createJSONEditor,
|
createJSONEditor,
|
||||||
|
MenuItem,
|
||||||
|
Mode,
|
||||||
JsonEditor as VanillaJsonEditor,
|
JsonEditor as VanillaJsonEditor,
|
||||||
} from "vanilla-jsoneditor";
|
} from "vanilla-jsoneditor";
|
||||||
import useAlertStore from "../../../stores/alertStore";
|
import useAlertStore from "../../../stores/alertStore";
|
||||||
import { cn } from "../../../utils/utils";
|
import { cn } from "../../../utils/utils";
|
||||||
|
import { useMenuCustomization } from "./useMenuCustomization";
|
||||||
|
|
||||||
interface JsonEditorProps {
|
interface JsonEditorProps {
|
||||||
data?: Content;
|
data?: Content;
|
||||||
|
|
@ -43,6 +46,9 @@ const JsonEditor = ({
|
||||||
const [originalData, setOriginalData] = useState(data);
|
const [originalData, setOriginalData] = useState(data);
|
||||||
const [isFiltered, setIsFiltered] = useState(false);
|
const [isFiltered, setIsFiltered] = useState(false);
|
||||||
const [showSuccess, setShowSuccess] = useState(false);
|
const [showSuccess, setShowSuccess] = useState(false);
|
||||||
|
const setSuccessData = useAlertStore((state) => state.setSuccessData);
|
||||||
|
|
||||||
|
const { customizeMenu } = useMenuCustomization(setSuccessData, setErrorData);
|
||||||
|
|
||||||
// Apply initial filter when component mounts
|
// Apply initial filter when component mounts
|
||||||
useEffect(() => {
|
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) => {
|
const applyFilter = (filtered: { json: any }, query: string) => {
|
||||||
onChange?.(filtered);
|
onChange?.(filtered);
|
||||||
setFilter?.(query.trim());
|
setFilter?.(query.trim());
|
||||||
|
|
@ -339,6 +339,8 @@ const JsonEditor = ({
|
||||||
containerRef.current.style.height = height;
|
containerRef.current.style.height = height;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let editorInstance: VanillaJsonEditor | null = null;
|
||||||
|
|
||||||
const editor = createJSONEditor({
|
const editor = createJSONEditor({
|
||||||
target: containerRef.current,
|
target: containerRef.current,
|
||||||
props: {
|
props: {
|
||||||
|
|
@ -350,9 +352,19 @@ const JsonEditor = ({
|
||||||
onChange: (content) => {
|
onChange: (content) => {
|
||||||
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);
|
setTimeout(() => editor.focus(), 100);
|
||||||
|
|
||||||
newRef.current = editor;
|
newRef.current = editor;
|
||||||
|
|
|
||||||
128
src/frontend/src/components/core/jsonEditor/menuUtils.ts
Normal file
128
src/frontend/src/components/core/jsonEditor/menuUtils.ts
Normal 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",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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 };
|
||||||
|
};
|
||||||
|
|
@ -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 });
|
||||||
|
},
|
||||||
|
);
|
||||||
Loading…
Add table
Add a link
Reference in a new issue