Skip to content

Commit

Permalink
feat: emui handle json/yaml input interchangably (#1891)
Browse files Browse the repository at this point in the history
## Description:
This PR adds support for using `yaml` in the `json` code editor input
boxes. Demo below


https://github.com/kurtosis-tech/kurtosis/assets/4419574/405e72fe-363e-4cb2-8cf7-039d9e7c0c6b

## Is this change user facing?
YES

## References (if applicable):
* Discussed on slack.
  • Loading branch information
Dartoxian committed Dec 5, 2023
1 parent adc4841 commit cd4263b
Show file tree
Hide file tree
Showing 14 changed files with 163 additions and 55 deletions.
5 changes: 3 additions & 2 deletions enclave-manager/web/package.json
Expand Up @@ -25,10 +25,10 @@
"react-icons": "^4.11.0",
"react-markdown": "^9.0.0",
"react-router-dom": "^6.17.0",
"react-scripts": "5.0.1",
"react-virtuoso": "^4.6.2",
"streamsaver": "^2.0.6",
"true-myth": "^7.1.0"
"true-myth": "^7.1.0",
"yaml": "^2.3.4"
},
"devDependencies": {
"@types/js-cookie": "^3.0.6",
Expand All @@ -41,6 +41,7 @@
"monaco-editor": "^0.44.0",
"prettier": "3.0.3",
"prettier-plugin-organize-imports": "^3.2.3",
"react-scripts": "5.0.1",
"serve": "^14.2.1",
"source-map-explorer": "^2.5.3",
"typescript": "^4.4.2"
Expand Down
87 changes: 61 additions & 26 deletions enclave-manager/web/src/components/CodeEditor.tsx
@@ -1,8 +1,11 @@
import { Box } from "@chakra-ui/react";
import { Editor, OnChange, OnMount } from "@monaco-editor/react";
import { editor as monacoEditor } from "monaco-editor";
import { Editor, Monaco, OnChange, OnMount } from "@monaco-editor/react";
import { type editor as monacoEditor } from "monaco-editor";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react";
import { assertDefined, isDefined } from "../utils";
import YAML from "yaml";
import { assertDefined, isDefined, stringifyError } from "../utils";

const MONACO_READ_ONLY_CHANGE_EVENT_ID = 89;

type CodeEditorProps = {
text: string;
Expand All @@ -13,11 +16,14 @@ type CodeEditorProps = {

export type CodeEditorImperativeAttributes = {
formatCode: () => Promise<void>;
setText: (text: string) => void;
setLanguage: (language: string) => void;
};

export const CodeEditor = forwardRef<CodeEditorImperativeAttributes, CodeEditorProps>(
({ text, fileName, onTextChange, showLineNumbers }, ref) => {
const isReadOnly = !isDefined(onTextChange);
const [monaco, setMonaco] = useState<Monaco>();
const [editor, setEditor] = useState<monacoEditor.IStandaloneCodeEditor>();

const resizeEditorBasedOnContent = useCallback(() => {
Expand All @@ -32,6 +38,7 @@ export const CodeEditor = forwardRef<CodeEditorImperativeAttributes, CodeEditorP
}, [editor]);

const handleMount: OnMount = (editor, monaco) => {
setMonaco(monaco);
setEditor(editor);
const colors: monacoEditor.IColors = {};
if (isReadOnly) {
Expand All @@ -57,37 +64,65 @@ export const CodeEditor = forwardRef<CodeEditorImperativeAttributes, CodeEditorP
ref,
() => ({
formatCode: async () => {
console.log("formatting");
if (!isDefined(editor)) {
// do nothing
console.log("no editor");
return;
}
return new Promise((resolve) => {
const listenerDisposer = editor.onDidChangeConfiguration((event) => {
console.log("listener called", event);
if (event.hasChanged(89 /* ID of the readonly option */)) {
console.log("running format");
const formatAction = editor.getAction("editor.action.formatDocument");
assertDefined(formatAction, `Format action is not defined`);
formatAction.run().then(() => {
listenerDisposer.dispose();
editor.updateOptions({
readOnly: isReadOnly,
});
resizeEditorBasedOnContent();
resolve();
});
const doFormat = async () => {
if (editor.getModel()?.getLanguageId() === "yaml") {
try {
const formattedText = YAML.stringify(YAML.parse(editor.getValue()));
editor.setValue(formattedText);
} catch (e: any) {
console.error(stringifyError(e));
}
} else {
const formatAction = editor.getAction("editor.action.formatDocument");
assertDefined(formatAction, `Format action is not defined`);
await formatAction.run();
}
};

if (isReadOnly) {
return new Promise((resolve) => {
const listenerDisposer = editor.onDidChangeConfiguration((event) => {
if (event.hasChanged(MONACO_READ_ONLY_CHANGE_EVENT_ID)) {
doFormat().then(() => {
listenerDisposer.dispose();
editor.updateOptions({
readOnly: isReadOnly,
});
resizeEditorBasedOnContent();
resolve();
});
}
});
editor.updateOptions({
readOnly: false,
});
});
console.log("disablin read only");
editor.updateOptions({
readOnly: false,
});
});
} else {
return doFormat();
}
},
setText: (text: string) => {
if (!isDefined(editor)) {
return;
}
editor.setValue(text);
},
setLanguage: (language: string) => {
if (!isDefined(editor) || !isDefined(monaco)) {
return;
}
const model = editor.getModel();
if (!isDefined(model)) {
return;
}
monaco.editor.setModelLanguage(model, language);
},
}),
[isReadOnly, editor, resizeEditorBasedOnContent],
[isReadOnly, editor, monaco, resizeEditorBasedOnContent],
);

useEffect(() => {
Expand Down
10 changes: 10 additions & 0 deletions enclave-manager/web/src/components/FormatButton.tsx
@@ -0,0 +1,10 @@
import { Button, ButtonProps } from "@chakra-ui/react";
import { BiPaintRoll } from "react-icons/bi";

export const FormatButton = ({ ...buttonProps }: ButtonProps) => {
return (
<Button leftIcon={<BiPaintRoll />} size={"sm"} colorScheme={"darkBlue"} loadingText={"Format"} {...buttonProps}>
Format
</Button>
);
};
@@ -1,5 +1,6 @@
import { CSSProperties, forwardRef, PropsWithChildren, useImperativeHandle } from "react";
import { FormProvider, SubmitHandler, useForm, useFormContext } from "react-hook-form";
import YAML from "yaml";
import {
ArgumentValueType,
KurtosisPackage,
Expand Down Expand Up @@ -65,7 +66,7 @@ export const EnclaveConfigurationForm = forwardRef<
case ArgumentValueType.STRING:
return value;
case ArgumentValueType.JSON:
return JSON.parse(value);
return YAML.parse(value);
default:
return value;
}
Expand Down
@@ -1,21 +1,28 @@
import { Button, ButtonGroup, Flex } from "@chakra-ui/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Controller } from "react-hook-form";
import { FieldPath, FieldValues } from "react-hook-form/dist/types";
import { ControllerRenderProps } from "react-hook-form/dist/types/controller";
import { FiCode } from "react-icons/fi";
import YAML from "yaml";
import { isDefined, stringifyError } from "../../../../utils";
import { CodeEditor } from "../../../CodeEditor";
import { CodeEditor, CodeEditorImperativeAttributes } from "../../../CodeEditor";
import { FormatButton } from "../../../FormatButton";
import { KurtosisArgumentTypeInputImplProps } from "./KurtosisArgumentTypeInput";

export const JSONArgumentInput = (props: KurtosisArgumentTypeInputImplProps) => {
return (
<Controller
render={({ field }) => <CodeEditor text={field.value} onTextChange={field.onChange} />}
render={({ field }) => <JsonAndYamlCodeEditor field={field} />}
name={props.name}
defaultValue={"{}"}
rules={{
required: props.isRequired,
validate: (value: string) => {
try {
JSON.parse(value);
YAML.parse(value);
} catch (err: any) {
return `This is not valid JSON. ${stringifyError(err)}`;
return `This is not valid JSON/YAML. ${stringifyError(err)}`;
}

const propsValidation = props.validate ? props.validate(value) : undefined;
Expand All @@ -28,3 +35,60 @@ export const JSONArgumentInput = (props: KurtosisArgumentTypeInputImplProps) =>
/>
);
};

const JsonAndYamlCodeEditor = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
field,
}: {
field: ControllerRenderProps<TFieldValues, TName>;
}) => {
const [isWorking, setIsWorking] = useState(false);
const codeEditorRef = useRef<CodeEditorImperativeAttributes>(null);
const isProbablyJson = useMemo(() => isDefined(field.value.match(/^\s*[[{"]/g)), [field.value]);

const handleFormatClick = async () => {
if (isDefined(codeEditorRef.current)) {
setIsWorking(true);
codeEditorRef.current.setLanguage(isProbablyJson ? "json" : "yaml");
await codeEditorRef.current.formatCode();
setIsWorking(false);
}
};

const handleConvertClick = () => {
if (!isDefined(codeEditorRef.current)) {
return;
}
try {
if (isProbablyJson) {
const newText = YAML.stringify(JSON.parse(field.value));
codeEditorRef.current.setText(newText);
codeEditorRef.current.setLanguage("yaml");
} else {
const newText = JSON.stringify(YAML.parse(field.value), undefined, 4);
codeEditorRef.current.setText(newText);
codeEditorRef.current.setLanguage("json");
}
} catch (err: any) {
console.error(err);
}
};

useEffect(() => {
codeEditorRef.current?.setLanguage(isProbablyJson ? "json" : "yaml");
}, [isProbablyJson]);

return (
<Flex flexDirection={"column"} gap={"10px"}>
<ButtonGroup>
<FormatButton size="xs" onClick={handleFormatClick} isLoading={isWorking} />
<Button size={"xs"} colorScheme={"darkBlue"} onClick={handleConvertClick} leftIcon={<FiCode />}>
Switch to {isProbablyJson ? "YAML" : "JSON"}
</Button>
</ButtonGroup>
<CodeEditor ref={codeEditorRef} text={field.value} onTextChange={field.onChange} />
</Flex>
);
};
Expand Up @@ -9,13 +9,13 @@ export function argTypeToString(argType?: ArgumentValueType) {
case ArgumentValueType.INTEGER:
return "integer";
case ArgumentValueType.JSON:
return "json";
return "json/yaml";
case ArgumentValueType.LIST:
return "list";
case ArgumentValueType.STRING:
return "text";
default:
return "json";
return "json/yaml";
}
}

Expand All @@ -31,6 +31,6 @@ export function argToTypeString(arg: PackageArg) {
case ArgumentValueType.LIST:
return `${argTypeToString(arg.typeV2.innerType1)} list`;
default:
return "json";
return "json/yaml";
}
}
@@ -1,7 +1,6 @@
import { Button, ButtonGroup, Flex, Spinner } from "@chakra-ui/react";
import { ButtonGroup, Flex, Spinner } from "@chakra-ui/react";
import { InspectFilesArtifactContentsResponse } from "enclave-manager-sdk/build/api_container_service_pb";
import { useEffect, useMemo, useRef, useState } from "react";
import { BiPaintRoll } from "react-icons/bi";
import { useParams } from "react-router-dom";
import { Result } from "true-myth";
import { useKurtosisClient } from "../../../../client/enclaveManager/KurtosisClientContext";
Expand All @@ -10,6 +9,7 @@ import { CodeEditor, CodeEditorImperativeAttributes } from "../../../../componen
import { CopyButton } from "../../../../components/CopyButton";
import { DownloadButton } from "../../../../components/DownloadButton";
import { FileTree, FileTreeNode } from "../../../../components/FileTree";
import { FormatButton } from "../../../../components/FormatButton";
import { KurtosisAlert } from "../../../../components/KurtosisAlert";
import { TitledCard } from "../../../../components/TitledCard";
import { isDefined } from "../../../../utils";
Expand Down Expand Up @@ -165,15 +165,7 @@ const ArtifactImpl = ({ enclave, artifactName, files }: ArtifactImplProps) => {
}
rightControls={
isDefined(selectedFile) ? (
<Button
leftIcon={<BiPaintRoll />}
variant="ghost"
size={"sm"}
colorScheme={"darkBlue"}
onClick={() => codeEditorRef.current?.formatCode()}
>
Format
</Button>
<FormatButton variant="ghost" onClick={() => codeEditorRef.current?.formatCode()} />
) : undefined
}
flex={"1"}
Expand Down
5 changes: 5 additions & 0 deletions enclave-manager/web/yarn.lock
Expand Up @@ -11512,6 +11512,11 @@ yaml@^2.1.1:
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.3.3.tgz#01f6d18ef036446340007db8e016810e5d64aad9"
integrity sha512-zw0VAJxgeZ6+++/su5AFoqBbZbrEakwu+X0M5HmcwUiBL7AzcuPKjj5we4xfQLp78LkEMpD0cOnUhmgOVy3KdQ==

yaml@^2.3.4:
version "2.3.4"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.3.4.tgz#53fc1d514be80aabf386dc6001eb29bf3b7523b2"
integrity sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==

yargs-parser@^20.2.2:
version "20.2.9"
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee"
Expand Down
6 changes: 3 additions & 3 deletions engine/server/webapp/asset-manifest.json
@@ -1,10 +1,10 @@
{
"files": {
"main.js": "./static/js/main.e00c76ec.js",
"main.js": "./static/js/main.9d538254.js",
"index.html": "./index.html",
"main.e00c76ec.js.map": "./static/js/main.e00c76ec.js.map"
"main.9d538254.js.map": "./static/js/main.9d538254.js.map"
},
"entrypoints": [
"static/js/main.e00c76ec.js"
"static/js/main.9d538254.js"
]
}
2 changes: 1 addition & 1 deletion engine/server/webapp/index.html
@@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="./favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="Kurtosis Enclave Manager"/><title>Kurtosis Enclave Manager</title><script defer="defer" src="./static/js/main.e00c76ec.js"></script></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="./favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="Kurtosis Enclave Manager"/><title>Kurtosis Enclave Manager</title><script defer="defer" src="./static/js/main.9d538254.js"></script></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
3 changes: 3 additions & 0 deletions engine/server/webapp/static/js/main.9d538254.js

Large diffs are not rendered by default.

Large diffs are not rendered by default.

3 changes: 0 additions & 3 deletions engine/server/webapp/static/js/main.e00c76ec.js

This file was deleted.

0 comments on commit cd4263b

Please sign in to comment.