Skip to main content
Glama

Convex MCP server

Official
by get-convex
ObjectEditor.tsx16.2 kB
import Editor, { BeforeMount, DiffEditorProps, EditorProps, } from "@monaco-editor/react"; import { ValidatorJSON, Value } from "convex/values"; import { useTheme } from "next-themes"; import isEqual from "lodash/isEqual"; import React, { useCallback, useContext, useEffect, useRef, useState, } from "react"; import isArray from "lodash/isArray"; import isPlainObject from "lodash/isPlainObject"; import { UNDEFINED_PLACEHOLDER } from "system-udfs/convex/_system/frontend/lib/values"; import { stringifyValue } from "@common/lib/stringifyValue"; import { cn } from "@ui/cn"; import { DeploymentInfoContext } from "@common/lib/deploymentContext"; import { ConvexSchemaValidationError, ConvexValidationError, LiteralNode, } from "./ast/types"; import { walkAst, WalkAstOptions } from "./ast/walkAst"; import { registerIdCommands, useIdDecorations } from "./useIdDecorations"; export type ObjectEditorProps = { defaultValue?: Value; onChange(v?: Value): void; onChangeInnerText?(v: string): void; onError(errors: string[]): void; path: string; className?: string; // Classes to apply to the Monaco editor. editorClassname?: string; // Classes to apply if the ObjectEditor has more than one line of code. multilineClasses?: string; fullHeight?: boolean; autoFocus?: boolean; saveAction?(): void; // If true, the save action will be triggered by pressing Enter in addition to Ctrl/Cmd+Enter. enterSaves?: boolean; // If enabled, the find (Cmd/Ctrl+F) action will be disabled. disableFind?: boolean; padding?: boolean; showLineNumbers?: boolean; disableFolding?: boolean; // If true, calls to onError will include errors produced by the validated. // In either case, the editor will still show the errors. shouldSurfaceValidatorErrors?: boolean; // Whether to show the full table name in the decoration for ID references. showTableNames?: boolean; size?: "sm" | "md"; disabled?: boolean; fixedOverflowWidgets?: boolean; } & WalkAstOptions; // Special case -- empty documents should be formatted to include space to entry a new field right away. const emptyObject = "{\n\n}"; export function ObjectEditor(props: ObjectEditorProps) { const { className, editorClassname, multilineClasses, defaultValue, onChange, onChangeInnerText, onError, path, fullHeight = false, autoFocus = false, saveAction, disableFind = false, enterSaves = false, padding = true, showLineNumbers = false, disableFolding = false, validator, mode, shouldSurfaceValidatorErrors = false, showTableNames = false, size = "md", disabled = false, fixedOverflowWidgets = true, } = props; const indentTopLevel = mode === "addDocuments" || mode === "editDocument"; const [monaco, setMonaco] = useState<Parameters<BeforeMount>[0]>(); const getDocumentRefs = useIdDecorations(monaco, path, showTableNames); // Initialize all markers on mount. useEffect(() => { monaco && handleCodeChange( defaultValueString, mode, validator, "allowTopLevelUndefined" in props ? // eslint-disable-next-line react/destructuring-assignment !!props.allowTopLevelUndefined : false, shouldSurfaceValidatorErrors, handleError, onChange, getDocumentRefs, ); // eslint-disable-next-line react-hooks/exhaustive-deps }, [monaco]); const saveActionRef = useRef(saveAction); useEffect(() => { saveActionRef.current = saveAction; }, [saveAction]); const handleError = useCallback( (errors: ConvexValidationError[]) => { const validationErrors = errors .filter( (e: any) => shouldSurfaceValidatorErrors || !(e instanceof ConvexSchemaValidationError), ) .map((e: any) => e.message); onError?.(validationErrors); if (monaco) { setErrorMarkers(monaco, errors, path, shouldSurfaceValidatorErrors); } return validationErrors.length > 0; }, [monaco, onError, path, shouldSurfaceValidatorErrors], ); // Use state here so we don't recalculate the default value. const [defaultValueString] = useState(() => { if (defaultValue === undefined) { return ""; } if (isEqual(defaultValue, {})) { return emptyObject; } if (isEqual(defaultValue, [{}])) { return `[${emptyObject}]`; } return stringifyValue(defaultValue, true, indentTopLevel); }); const numLinesFromCode = (code: string) => code.split("\n").length + 1; const [numLines, setNumLines] = useState( numLinesFromCode(defaultValueString), ); const editorLineHeight = size === "sm" ? 13 : 18; const editorHeight = Math.min(Math.max(numLines, 2), 15) * editorLineHeight; const handleChange = useCallback( (code?: string) => { // Hook to inform the parent component of the inner text of the editor. onChangeInnerText && onChangeInnerText(code ?? ""); setNumLines(code ? numLinesFromCode(code) : 1); handleCodeChange( code, mode, validator, "allowTopLevelUndefined" in props ? // eslint-disable-next-line react/destructuring-assignment !!props.allowTopLevelUndefined : false, shouldSurfaceValidatorErrors, handleError, onChange, getDocumentRefs, ); }, [ onChangeInnerText, mode, validator, props, shouldSurfaceValidatorErrors, handleError, onChange, getDocumentRefs, ], ); const { deploymentsURI, captureMessage } = useContext(DeploymentInfoContext); const { resolvedTheme: currentTheme } = useTheme(); const prefersDark = currentTheme === "dark"; return ( <div data-testid="objectEditor" className={cn( // Setting a min-h makes sure the editor is able to properly resize when the // parent is resized. "relative h-full min-h-4 w-full max-w-full rounded-sm border", className, disabled && "cursor-not-allowed bg-background-tertiary", numLines > 2 && multilineClasses, )} style={{ minHeight: 16, maxHeight: "100%", height: fullHeight ? "100%" : editorHeight, }} onScroll={(e) => e.stopPropagation()} > {disabled && ( <div className="absolute z-10 h-full w-full cursor-not-allowed bg-background-tertiary/20" /> )} <Editor height="100%" width="100%" className={cn( padding && "pt-2", editorClassname, size === "sm" && "mt-[1px]", disabled && "disabled cursor-not-allowed bg-background-tertiary text-content-secondary", )} defaultLanguage="javascript" defaultValue={defaultValueString} options={{ ...editorOptions, ...(showLineNumbers ? { lineNumbers: "on", lineNumbersMinChars: 5, lineDecorationsWidth: 10, } : {}), ...(size === "sm" && { fontSize: 12, lineHeight: 13, }), readOnly: disabled, domReadOnly: disabled, tabIndex: disabled ? -1 : undefined, folding: !disableFolding, theme: prefersDark ? "vs-dark" : "vs", fixedOverflowWidgets, }} // Never make the path look like a URI scheme. path={path.replace(":", "_")} onChange={handleChange} beforeMount={(m) => { m.languages.typescript.javascriptDefaults.setDiagnosticsOptions({ // Disable all syntax validation and use the results from AST parsing instead. noSemanticValidation: true, noSyntaxValidation: true, noSuggestionDiagnostics: true, // The "unused block" diagnostic code. diagnosticCodesToIgnore: [7028], }); setMonaco(m); }} onMount={(editor, m) => { registerIdCommands({ monaco: m, deploymentsURI, captureMessage }); editor.onKeyDown((e) => { if (e.keyCode === m.KeyCode.Tab) { e.preventDefault(); moveFocus(!e.shiftKey); } }); if (disableFind) { editor.addAction({ id: "find", label: "find", keybindings: [m.KeyMod.CtrlCmd | m.KeyCode.KeyF], run: () => {}, }); } if (saveAction) { const keybindings = [m.KeyMod.CtrlCmd | m.KeyCode.Enter]; if (enterSaves) { keybindings.push(m.KeyCode.Enter); } editor.addAction({ id: "saveAction", label: "Save value", keybindings, run() { saveActionRef.current?.(); }, }); } if (!autoFocus || disabled) { return; } editor.focus(); const code = editor.getValue(); if (!code) { return; } const codeLines = code.trimEnd().split("\n"); let lastLine = codeLines.pop(); let isMultiLineObject = false; if (lastLine === "}") { lastLine = codeLines.pop(); isMultiLineObject = true; } if (lastLine === undefined) { return; } // Pick the location to place the cursor based on the type of the value. const column = isPlainObject(defaultValue) || isArray(defaultValue) || (typeof defaultValue === "string" && !isMultiLineObject) ? // Arrays and objects have end braces and strings have end quotes, so we want to place the cursor before those. lastLine.length : // All other types are not wrapped with anything, so we want to place the cursor after the value. lastLine.length + 1; editor.setPosition({ // Objects rendered on multiple lines should have the last line be set to the line before the closing brace. lineNumber: code.split("\n").length - (isMultiLineObject ? 1 : 0), column, }); }} loading={null} /> </div> ); } function setErrorMarkers( monaco: Parameters<BeforeMount>[0], errors: ConvexValidationError[], path: string, shouldSurfaceValidatorErrors: boolean, ) { const markers = [ ...errors .filter((e) => e instanceof ConvexValidationError && e.loc !== undefined) .map((e) => ({ message: e.message, severity: !shouldSurfaceValidatorErrors && e instanceof ConvexSchemaValidationError ? monaco.MarkerSeverity.Warning : monaco.MarkerSeverity.Error, startLineNumber: e.loc?.start.line ?? 0, // Looks like Monaco counts from 1 and Acorn counts from 0 startColumn: (e.loc?.start.column ?? 0) + 1, endLineNumber: e.loc?.end.line ?? 0, // Looks like Monaco counts from 1 and Acorn counts from 0 endColumn: (e.loc?.end.column ?? 0) + 1, ...e.markerData, })), ]; const model = monaco.editor .getModels() ?.find((m) => path.replace(":", "_") === m.uri.path.slice(1)); if (!model) { return; } monaco.editor.setModelMarkers(model, "", markers); } function handleCodeChange( code: string | undefined, mode: "editField" | "addDocuments" | "editDocument" | "patchDocuments", validator: ValidatorJSON | undefined, allowTopLevelUndefined: boolean, shouldSurfaceValidatorErrors: boolean, handleError: (errors: ConvexValidationError[]) => boolean, onChange: (result: Value) => void, getDocumentRefs: (ids: LiteralNode[]) => void, ) { try { const { value: result, errors: astErrors, ids: newIds, } = processCode( code, mode, validator, allowTopLevelUndefined, shouldSurfaceValidatorErrors, ); const hasSurfacedErrors = handleError(astErrors); !hasSurfacedErrors && onChange(result); getDocumentRefs(newIds); } catch (e: any) { if (e instanceof SyntaxError) { const result = e.message.match(/(.*) \(([0-9]+):([0-9]+)\)/); if (result) { const message = result[1]; const line = parseInt(result[2]); const column = parseInt(result[3]) + 1; const position = { line, column, offset: 0 }; handleError([ new ConvexValidationError(message, { start: position, end: position, }), ]); return; } } throw e; } } function processCode( code: string | undefined, mode: "editField" | "addDocuments" | "editDocument" | "patchDocuments", validator: ValidatorJSON | undefined, allowTopLevelUndefined: boolean, shouldSurfaceValidatorErrors: boolean, ) { return code ? walkAst( code, mode === "editField" ? { validator, mode, allowTopLevelUndefined, } : { validator, mode, }, ) : { value: UNDEFINED_PLACEHOLDER, errors: // Edge case: Here, we have to handle the case where the top level is not a document, like in CellEditor. // In this case, we do not want to allow the user to enter undefined if their field is not optional. mode === "editField" && !allowTopLevelUndefined && validator && shouldSurfaceValidatorErrors ? [ new ConvexSchemaValidationError( "RequiredPropertyMissing", validator, undefined, ), ] : [], ids: [], }; } export const editorOptions: EditorProps["options"] & DiffEditorProps["options"] = { tabFocusMode: false, automaticLayout: true, minimap: { enabled: false }, overviewRulerBorder: false, scrollBeyondLastLine: false, find: { addExtraSpaceOnTop: false, autoFindInSelection: "never", seedSearchStringFromSelection: "never", }, lineNumbers: "off", glyphMargin: false, lineDecorationsWidth: 0, lineNumbersMinChars: 0, scrollbar: { alwaysConsumeMouseWheel: false, horizontalScrollbarSize: 8, verticalScrollbarSize: 8, useShadows: false, vertical: "visible", }, suggest: { preview: false }, hideCursorInOverviewRuler: true, quickSuggestions: false, parameterHints: { enabled: false }, suggestOnTriggerCharacters: false, snippetSuggestions: "none", contextmenu: false, codeLens: false, disableLayerHinting: true, inlayHints: { enabled: "off" }, inlineSuggest: { enabled: false }, lightbulb: { enabled: false }, hover: { above: false }, guides: { bracketPairs: false, bracketPairsHorizontal: false, highlightActiveBracketPair: false, indentation: false, highlightActiveIndentation: false, }, bracketPairColorization: { enabled: false }, matchBrackets: "never", tabCompletion: "off", selectionHighlight: false, occurrencesHighlight: false, renderLineHighlight: "none", }; function moveFocus(forward = true) { const focusableElements = Array.from( document.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', ), // @ts-expect-error ).filter((el) => !el.disabled && el.offsetParent !== null); const currentIndex = document.activeElement ? focusableElements.indexOf(document.activeElement) : -1; if (currentIndex === -1) { return; } let nextIndex = forward ? currentIndex + 1 : currentIndex - 1; if (nextIndex >= focusableElements.length) nextIndex = 0; // Loop to first if (nextIndex < 0) nextIndex = focusableElements.length - 1; // Loop to last const nextElement = focusableElements[nextIndex]; if (nextElement && nextElement instanceof HTMLElement) { nextElement.focus(); } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/get-convex/convex-backend'

If you have feedback or need assistance with the MCP directory API, please join our Discord server