diff --git a/apps/webapp/app/components/code/JSONEditor.tsx b/apps/webapp/app/components/code/JSONEditor.tsx index 0313120b14..db85a9cd9f 100644 --- a/apps/webapp/app/components/code/JSONEditor.tsx +++ b/apps/webapp/app/components/code/JSONEditor.tsx @@ -1,5 +1,5 @@ -import { json as jsonLang } from "@codemirror/lang-json"; -import type { ViewUpdate } from "@codemirror/view"; +import { json as jsonLang, jsonParseLinter } from "@codemirror/lang-json"; +import type { EditorView, ViewUpdate } from "@codemirror/view"; import { CheckIcon, ClipboardIcon, TrashIcon } from "@heroicons/react/20/solid"; import type { ReactCodeMirrorProps, UseCodeMirror } from "@uiw/react-codemirror"; import { useCodeMirror } from "@uiw/react-codemirror"; @@ -8,6 +8,7 @@ import { cn } from "~/utils/cn"; import { Button } from "../primitives/Buttons"; import { getEditorSetup } from "./codeMirrorSetup"; import { darkTheme } from "./codeMirrorTheme"; +import { linter, lintGutter, type Diagnostic } from "@codemirror/lint"; export interface JSONEditorProps extends Omit { defaultValue?: string; @@ -18,18 +19,35 @@ export interface JSONEditorProps extends Omit { onBlur?: (code: string) => void; showCopyButton?: boolean; showClearButton?: boolean; + linterEnabled?: boolean; + allowEmpty?: boolean; } const languages = { json: jsonLang, }; +function emptyAwareJsonLinter() { + return (view: EditorView): Diagnostic[] => { + const content = view.state.doc.toString().trim(); + + // return no errors if content is empty + if (!content) { + return []; + } + + return jsonParseLinter()(view); + }; +} + type JSONEditorDefaultProps = Partial; const defaultProps: JSONEditorDefaultProps = { language: "json", readOnly: true, basicSetup: false, + linterEnabled: true, + allowEmpty: true, }; export function JSONEditor(opts: JSONEditorProps) { @@ -44,6 +62,8 @@ export function JSONEditor(opts: JSONEditorProps) { autoFocus, showCopyButton = true, showClearButton = true, + linterEnabled, + allowEmpty, } = { ...defaultProps, ...opts, @@ -56,6 +76,19 @@ export function JSONEditor(opts: JSONEditorProps) { extensions.push(languageExtension()); + if (linterEnabled) { + extensions.push(lintGutter()); + + switch (language) { + case "json": { + extensions.push(allowEmpty ? linter(emptyAwareJsonLinter()) : linter(jsonParseLinter())); + break; + } + default: + language satisfies never; + } + } + const editor = useRef(null); const settings: Omit = { ...opts, diff --git a/apps/webapp/app/components/code/codeMirrorSetup.ts b/apps/webapp/app/components/code/codeMirrorSetup.ts index 89988c3d9b..811a6ebc29 100644 --- a/apps/webapp/app/components/code/codeMirrorSetup.ts +++ b/apps/webapp/app/components/code/codeMirrorSetup.ts @@ -1,8 +1,7 @@ import { closeBrackets } from "@codemirror/autocomplete"; import { indentWithTab } from "@codemirror/commands"; -import { jsonParseLinter } from "@codemirror/lang-json"; import { bracketMatching } from "@codemirror/language"; -import { lintGutter, lintKeymap, linter } from "@codemirror/lint"; +import { lintKeymap } from "@codemirror/lint"; import { highlightSelectionMatches } from "@codemirror/search"; import { Prec, type Extension } from "@codemirror/state"; import { @@ -21,8 +20,6 @@ export function getEditorSetup(showLineNumbers = true, showHighlights = true): A dropCursor(), bracketMatching(), closeBrackets(), - lintGutter(), - linter(jsonParseLinter()), Prec.highest( keymap.of([ { diff --git a/apps/webapp/app/components/code/codeMirrorTheme.ts b/apps/webapp/app/components/code/codeMirrorTheme.ts index 74d074b3cd..2516705134 100644 --- a/apps/webapp/app/components/code/codeMirrorTheme.ts +++ b/apps/webapp/app/components/code/codeMirrorTheme.ts @@ -39,6 +39,32 @@ export function darkTheme(): Extension { fontSize: "14px", }, + ".cm-tooltip.cm-tooltip-lint": { + backgroundColor: tooltipBackground, + }, + + ".cm-diagnostic": { + padding: "4px 8px", + color: ivory, + fontFamily: "Geist Mono Variable", + fontSize: "12px", + }, + + ".cm-diagnostic-error": { + borderLeft: "2px solid #e11d48", + }, + + ".cm-lint-marker-error": { + content: "none", + backgroundColor: "#e11d48", + height: "100%", + width: "2px", + }, + + ".cm-lintPoint:after": { + borderBottom: "4px solid #e11d48", + }, + ".cm-cursor, .cm-dropCursor": { borderLeftColor: cursor }, "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": { backgroundColor: selection, @@ -82,6 +108,7 @@ export function darkTheme(): Extension { ".cm-tooltip": { border: "none", + marginTop: "6px", backgroundColor: tooltipBackground, }, ".cm-tooltip .cm-tooltip-arrow:before": { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx index 6205336d75..abdfcda4c3 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx @@ -291,7 +291,6 @@ function StandardTaskForm({ task, runs }: { task: TestTask["task"]; runs: Standa }} height="100%" autoFocus={!tab || tab === "payload"} - placeholder="{ }" className={cn("h-full overflow-auto", tab === "metadata" && "hidden")} /> { - try { - const data = JSON.parse(payload); - return data as any; - } catch (e) { - console.log("parsing error", e); + payload: z + .string() + .optional() + .transform((val, ctx) => { + if (!val) { + return {}; + } - if (e instanceof Error) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: e.message, - }); - } else { + try { + return JSON.parse(val); + } catch { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "This is invalid JSON", + message: "Payload must be a valid JSON string", }); + return z.NEVER; + } + }), + metadata: z + .string() + .optional() + .transform((val, ctx) => { + if (!val) { + return {}; } - } - }), - metadata: z.string().transform((metadata, ctx) => { - try { - const data = JSON.parse(metadata); - return data as any; - } catch (e) { - console.log("parsing error", e); - if (e instanceof Error) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: e.message, - }); - } else { + try { + return JSON.parse(val); + } catch { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "This is invalid JSON", + message: "Metadata must be a valid JSON string", }); + return z.NEVER; } - } - }), + }), }), z.object({ triggerSource: z.literal("SCHEDULED"),