From 4a8bd4fd1b4fbb34076e97d06ed1341432de451d Mon Sep 17 00:00:00 2001 From: Michael Skorokhodov Date: Wed, 15 Apr 2026 13:39:54 +0200 Subject: [PATCH] CONSOLE-1958: Operation picker (when multiple exists in document) (#7963) --- .changeset/evil-results-rhyme.md | 7 + .../src/components/laboratory/builder.tsx | 57 +++++--- .../src/components/laboratory/editor.tsx | 122 +++++++++++++++++- .../src/components/laboratory/operation.tsx | 19 ++- packages/libraries/laboratory/src/index.css | 4 + .../laboratory/src/lib/operations.ts | 36 ++++-- .../laboratory/src/lib/operations.utils.ts | 17 ++- 7 files changed, 221 insertions(+), 41 deletions(-) create mode 100644 .changeset/evil-results-rhyme.md diff --git a/.changeset/evil-results-rhyme.md b/.changeset/evil-results-rhyme.md new file mode 100644 index 000000000..ce055fa59 --- /dev/null +++ b/.changeset/evil-results-rhyme.md @@ -0,0 +1,7 @@ +--- +'@graphql-hive/laboratory': patch +'@graphql-hive/render-laboratory': patch +--- + +Implemented functionality that allows to have multiple queries in same operation while working only +with focused one (run button, query builder) diff --git a/packages/libraries/laboratory/src/components/laboratory/builder.tsx b/packages/libraries/laboratory/src/components/laboratory/builder.tsx index e2fa3d60c..395802c17 100644 --- a/packages/libraries/laboratory/src/components/laboratory/builder.tsx +++ b/packages/libraries/laboratory/src/components/laboratory/builder.tsx @@ -49,6 +49,7 @@ export const BuilderArgument = (props: { path: string[]; isReadOnly?: boolean; operation?: LaboratoryOperation | null; + operationName?: string | null; }) => { const { schema, @@ -90,9 +91,18 @@ export const BuilderArgument = (props: { } if (checked) { - addArgToActiveOperation(props.path.join('.'), props.field.name, schema); + addArgToActiveOperation( + props.path.join('.'), + props.field.name, + schema, + props.operationName, + ); } else { - deleteArgFromActiveOperation(props.path.join('.'), props.field.name); + deleteArgFromActiveOperation( + props.path.join('.'), + props.field.name, + props.operationName, + ); } }} /> @@ -112,6 +122,7 @@ export const BuilderScalarField = (props: { isSearchActive?: boolean; isReadOnly?: boolean; operation?: LaboratoryOperation | null; + operationName?: string | null; searchValue?: string; label?: React.ReactNode; disableChildren?: boolean; @@ -141,16 +152,18 @@ export const BuilderScalarField = (props: { ); const isInQuery = useMemo(() => { - return isPathInQuery(operation?.query ?? '', path); - }, [operation?.query, path]); + return isPathInQuery(operation?.query ?? '', path, props.operationName); + }, [operation?.query, path, props.operationName]); const args = useMemo(() => { return (props.field as GraphQLField).args ?? []; }, [props.field]); const hasArgs = useMemo(() => { - return args.some(arg => isArgInQuery(operation?.query ?? '', path, arg.name)); - }, [operation?.query, args, path]); + return args.some(arg => + isArgInQuery(operation?.query ?? '', path, arg.name, props.operationName), + ); + }, [operation?.query, args, path, props.operationName]); const shouldHighlight = useMemo(() => { const splittedName = splitIdentifier(props.field.name); @@ -186,9 +199,9 @@ export const BuilderScalarField = (props: { onCheckedChange={checked => { if (checked) { setIsOpen(true); - addPathToActiveOperation(path); + addPathToActiveOperation(path, props.operationName); } else { - deletePathFromActiveOperation(path); + deletePathFromActiveOperation(path, props.operationName); } }} /> @@ -238,9 +251,9 @@ export const BuilderScalarField = (props: { onCheckedChange={checked => { if (checked) { setIsOpen(true); - addPathToActiveOperation(path); + addPathToActiveOperation(path, props.operationName); } else { - deletePathFromActiveOperation(path); + deletePathFromActiveOperation(path, props.operationName); } }} /> @@ -322,9 +335,9 @@ export const BuilderScalarField = (props: { disabled={activeTab?.type !== 'operation'} onCheckedChange={checked => { if (checked) { - addPathToActiveOperation(props.path.join('.')); + addPathToActiveOperation(props.path.join('.'), props.operationName); } else { - deletePathFromActiveOperation(props.path.join('.')); + deletePathFromActiveOperation(props.path.join('.'), props.operationName); } }} /> @@ -353,6 +366,7 @@ export const BuilderObjectField = (props: { isSearchActive?: boolean; isReadOnly?: boolean; operation?: LaboratoryOperation | null; + operationName?: string | null; searchValue?: string; label?: React.ReactNode; disableChildren?: boolean; @@ -442,9 +456,9 @@ export const BuilderObjectField = (props: { onCheckedChange={checked => { if (checked) { setIsOpen(true); - addPathToActiveOperation(path); + addPathToActiveOperation(path, props.operationName); } else { - deletePathFromActiveOperation(path); + deletePathFromActiveOperation(path, props.operationName); } }} /> @@ -493,9 +507,9 @@ export const BuilderObjectField = (props: { onCheckedChange={checked => { if (checked) { setIsOpen(true); - addPathToActiveOperation(path); + addPathToActiveOperation(path, props.operationName); } else { - deletePathFromActiveOperation(path); + deletePathFromActiveOperation(path, props.operationName); } }} /> @@ -565,6 +579,7 @@ export const BuilderObjectField = (props: { isSearchActive={props.isSearchActive} isReadOnly={props.isReadOnly} operation={operation} + operationName={props.operationName} searchValue={props.searchValue} /> ))} @@ -584,6 +599,7 @@ export const BuilderField = (props: { forcedOpenPaths?: Set | null; isSearchActive?: boolean; operation?: LaboratoryOperation | null; + operationName?: string | null; isReadOnly?: boolean; searchValue?: string; label?: React.ReactNode; @@ -610,6 +626,7 @@ export const BuilderField = (props: { isSearchActive={props.isSearchActive} isReadOnly={props.isReadOnly} operation={props.operation} + operationName={props.operationName} searchValue={props.searchValue} label={props.label} disableChildren={props.disableChildren} @@ -628,6 +645,7 @@ export const BuilderField = (props: { isSearchActive={props.isSearchActive} isReadOnly={props.isReadOnly} operation={props.operation} + operationName={props.operationName} searchValue={props.searchValue} label={props.label} disableChildren={props.disableChildren} @@ -652,6 +670,7 @@ export const BuilderSearchResults = (props: { mode: BuilderSearchResultMode; isReadOnly: boolean; operation: LaboratoryOperation | null; + operationName?: string | null; searchValue: string; schema: GraphQLSchema; tab: OperationTypeNode; @@ -676,6 +695,7 @@ export const BuilderSearchResults = (props: { isSearchActive={props.isSearchActive} isReadOnly={props.isReadOnly} operation={props.operation} + operationName={props.operationName} searchValue={props.searchValue} disableChildren label={ @@ -727,6 +747,7 @@ export const BuilderSearchResults = (props: { isSearchActive={props.isSearchActive} isReadOnly={props.isReadOnly} operation={props.operation} + operationName={props.operationName} searchValue={props.searchValue} /> ); @@ -735,6 +756,7 @@ export const BuilderSearchResults = (props: { export const Builder = (props: { operation?: LaboratoryOperation | null; + operationName?: string | null; isReadOnly?: boolean; }) => { const { schema, activeOperation, endpoint, setEndpoint, defaultEndpoint } = useLaboratory(); @@ -980,6 +1002,7 @@ export const Builder = (props: { isSearchActive={isSearchActive} isReadOnly={props.isReadOnly} operation={operation} + operationName={props.operationName} searchValue={deferredSearchValue} /> )) @@ -1016,6 +1039,7 @@ export const Builder = (props: { isSearchActive={isSearchActive} isReadOnly={props.isReadOnly} operation={operation} + operationName={props.operationName} searchValue={deferredSearchValue} /> )) @@ -1052,6 +1076,7 @@ export const Builder = (props: { isSearchActive={isSearchActive} isReadOnly={props.isReadOnly} operation={operation} + operationName={props.operationName} searchValue={deferredSearchValue} /> )) diff --git a/packages/libraries/laboratory/src/components/laboratory/editor.tsx b/packages/libraries/laboratory/src/components/laboratory/editor.tsx index 6afb34087..1944fa47a 100644 --- a/packages/libraries/laboratory/src/components/laboratory/editor.tsx +++ b/packages/libraries/laboratory/src/components/laboratory/editor.tsx @@ -1,5 +1,15 @@ -import { forwardRef, useEffect, useId, useImperativeHandle, useRef, useState } from 'react'; -import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; +import { + forwardRef, + useCallback, + useEffect, + useId, + useImperativeHandle, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import { OperationDefinitionNode, parse } from 'graphql'; +import * as monaco from 'monaco-editor'; import { MonacoGraphQLAPI } from 'monaco-graphql/esm/api.js'; import { initializeMode } from 'monaco-graphql/initializeMode'; import MonacoEditor, { loader } from '@monaco-editor/react'; @@ -122,6 +132,7 @@ export type EditorProps = React.ComponentProps & { uri?: monaco.Uri; variablesUri?: monaco.Uri; extraLibs?: string[]; + onOperationNameChange?: (operationName: string | null) => void; }; const EditorInner = forwardRef((props, ref) => { @@ -231,6 +242,109 @@ const EditorInner = forwardRef((props, ref) => { [], ); + const setupDecorationsHandler = useCallback( + (editor: monaco.editor.IStandaloneCodeEditor) => { + let decorationsCollection: monaco.editor.IEditorDecorationsCollection | null = null; + + const handler = () => { + decorationsCollection?.clear(); + + try { + const value = editor.getValue(); + const doc = parse(value); + + const definition = doc.definitions.find(definition => { + if (definition.kind !== 'OperationDefinition') { + return false; + } + + if (!definition.loc) { + return false; + } + + const cursorPosition = editor.getPosition(); + + if (cursorPosition) { + return ( + definition.loc.startToken.line <= cursorPosition.lineNumber && + definition.loc.endToken.line >= cursorPosition.lineNumber + ); + } + }); + + if (definition?.loc) { + const decorations: monaco.editor.IModelDeltaDecoration[] = []; + + if (definition.loc.startToken.line > 1) { + decorations.push({ + range: new monaco.Range( + 0, + 0, + definition.loc.startToken.line - 1, + definition.loc.startToken.column, + ), + options: { + isWholeLine: true, + inlineClassName: 'inactive-line', + }, + }); + } + + const lineCount = editor.getModel()?.getLineCount() ?? 0; + const lastLineMaxColumn = editor.getModel()?.getLineMaxColumn(lineCount) ?? 0; + + if (definition.loc.endToken.line < lineCount) { + decorations.push({ + range: new monaco.Range( + definition.loc.endToken.line + 1, + definition.loc.endToken.column, + lineCount, + lastLineMaxColumn, + ), + options: { + isWholeLine: true, + inlineClassName: 'inactive-line', + }, + }); + } + + decorationsCollection = editor.createDecorationsCollection(decorations); + + props.onOperationNameChange?.( + (definition as OperationDefinitionNode).name?.value ?? null, + ); + } + } catch (error) {} + }; + + editor.onDidChangeCursorPosition(handler); + + handler(); + }, + [props.onOperationNameChange], + ); + + const handleMount = useCallback( + (editor: monaco.editor.IStandaloneCodeEditor) => { + editorRef.current = editor; + setupDecorationsHandler(editor); + }, + [setupDecorationsHandler], + ); + + const recentCursorPosition = useRef<{ lineNumber: number; column: number } | null>(null); + + useLayoutEffect(() => { + recentCursorPosition.current = editorRef.current?.getPosition() ?? null; + }, [props.value]); + + useEffect(() => { + if (editorRef.current && recentCursorPosition.current) { + editorRef.current.setPosition(recentCursorPosition.current); + recentCursorPosition.current = null; + } + }, [props.value]); + if (!typescriptReady && props.language === 'typescript') { return null; } @@ -245,9 +359,7 @@ const EditorInner = forwardRef((props, ref) => { className="size-full" {...props} theme={theme === 'dark' ? 'hive-laboratory-dark' : 'hive-laboratory-light'} - onMount={editor => { - editorRef.current = editor; - }} + onMount={handleMount} loading={null} options={{ ...props.options, diff --git a/packages/libraries/laboratory/src/components/laboratory/operation.tsx b/packages/libraries/laboratory/src/components/laboratory/operation.tsx index d82ce7efd..f9ec9a3b9 100644 --- a/packages/libraries/laboratory/src/components/laboratory/operation.tsx +++ b/packages/libraries/laboratory/src/components/laboratory/operation.tsx @@ -450,6 +450,7 @@ const saveToCollectionFormSchema = z.object({ export const Query = (props: { onAfterOperationRun?: (historyItem: LaboratoryHistory | null) => void; operation?: LaboratoryOperation | null; + onOperationNameChange?: (operationName: string | null) => void; isReadOnly?: boolean; }) => { const { @@ -476,6 +477,8 @@ export const Query = (props: { setPluginsState, } = useLaboratory(); + const [operationName, setOperationName] = useState(null); + const operation = useMemo(() => { return props.operation ?? activeOperation ?? null; }, [props.operation, activeOperation]); @@ -519,6 +522,7 @@ export const Query = (props: { void runActiveOperation(endpoint, { env: result?.env, headers: result?.headers, + operationName: operationName ?? undefined, onResponse: data => { addResponseToHistory(newItemHistory.id, data); }, @@ -531,6 +535,7 @@ export const Query = (props: { const response = await runActiveOperation(endpoint, { env: result?.env, headers: result?.headers, + operationName: operationName ?? undefined, }); if (!response) { @@ -557,6 +562,7 @@ export const Query = (props: { } }, [ operation, + operationName, endpoint, isActiveOperationSubscription, addHistory, @@ -818,6 +824,10 @@ export const Query = (props: { query: value ?? '', }); }} + onOperationNameChange={operationName => { + setOperationName(operationName); + props.onOperationNameChange?.(operationName); + }} language="graphql" theme="hive-laboratory" options={{ @@ -833,6 +843,7 @@ export const Operation = (props: { historyItem?: LaboratoryHistory; }) => { const { activeOperation, history } = useLaboratory(); + const [operationName, setOperationName] = useState(null); const operation = useMemo(() => { return props.operation ?? activeOperation ?? null; @@ -856,13 +867,17 @@ export const Operation = (props: {
- + - + diff --git a/packages/libraries/laboratory/src/index.css b/packages/libraries/laboratory/src/index.css index 6943ce9b3..7bf2bdff0 100644 --- a/packages/libraries/laboratory/src/index.css +++ b/packages/libraries/laboratory/src/index.css @@ -42,6 +42,10 @@ height: 100%; } +.hive-laboratory .inactive-line { + opacity: 0.5; +} + .hive-laboratory { --color-neutral-1: 0 0% 99%; --color-neutral-2: 180 9% 97%; diff --git a/packages/libraries/laboratory/src/lib/operations.ts b/packages/libraries/laboratory/src/lib/operations.ts index ffbe430b5..f2ad388d0 100644 --- a/packages/libraries/laboratory/src/lib/operations.ts +++ b/packages/libraries/laboratory/src/lib/operations.ts @@ -45,15 +45,25 @@ export interface LaboratoryOperationsActions { setOperations: (operations: LaboratoryOperation[]) => void; updateActiveOperation: (operation: Partial>) => void; deleteOperation: (operationId: string) => void; - addPathToActiveOperation: (path: string) => void; - deletePathFromActiveOperation: (path: string) => void; - addArgToActiveOperation: (path: string, argName: string, schema: GraphQLSchema) => void; - deleteArgFromActiveOperation: (path: string, argName: string) => void; + addPathToActiveOperation: (path: string, operationName?: string | null) => void; + deletePathFromActiveOperation: (path: string, operationName?: string | null) => void; + addArgToActiveOperation: ( + path: string, + argName: string, + schema: GraphQLSchema, + operationName?: string | null, + ) => void; + deleteArgFromActiveOperation: ( + path: string, + argName: string, + operationName?: string | null, + ) => void; runActiveOperation: ( endpoint: string, options?: { env?: LaboratoryEnv; headers?: Record; + operationName?: string; onResponse?: (response: string) => void; }, ) => Promise; @@ -243,13 +253,13 @@ export const useOperations = ( ); const addPathToActiveOperation = useCallback( - (path: string) => { + (path: string, operationName?: string | null) => { if (!activeOperation) { return; } const newActiveOperation = { ...activeOperation, - query: addPathToQuery(activeOperation.query, path), + query: addPathToQuery(activeOperation.query, path, operationName), }; updateActiveOperation(newActiveOperation); }, @@ -257,14 +267,14 @@ export const useOperations = ( ); const deletePathFromActiveOperation = useCallback( - (path: string) => { + (path: string, operationName?: string | null) => { if (!activeOperation?.query) { return; } const newActiveOperation = { ...activeOperation, - query: deletePathFromQuery(activeOperation.query, path), + query: deletePathFromQuery(activeOperation.query, path, operationName), }; updateActiveOperation(newActiveOperation); }, @@ -272,14 +282,14 @@ export const useOperations = ( ); const addArgToActiveOperation = useCallback( - (path: string, argName: string, schema: GraphQLSchema) => { + (path: string, argName: string, schema: GraphQLSchema, operationName?: string | null) => { if (!activeOperation?.query) { return; } const newActiveOperation = { ...activeOperation, - query: addArgToField(activeOperation.query, path, argName, schema), + query: addArgToField(activeOperation.query, path, argName, schema, operationName), }; updateActiveOperation(newActiveOperation); }, @@ -287,14 +297,14 @@ export const useOperations = ( ); const deleteArgFromActiveOperation = useCallback( - (path: string, argName: string) => { + (path: string, argName: string, operationName?: string | null) => { if (!activeOperation?.query) { return; } const newActiveOperation = { ...activeOperation, - query: removeArgFromField(activeOperation.query, path, argName), + query: removeArgFromField(activeOperation.query, path, argName, operationName), }; updateActiveOperation(newActiveOperation); }, @@ -323,6 +333,7 @@ export const useOperations = ( env?: LaboratoryEnv; headers?: Record; onResponse?: (response: string) => void; + operationName?: string; }, plugins: LaboratoryPlugin[] = props.pluginsApi?.plugins ?? [], pluginsState: Record = props.pluginsApi?.pluginsState ?? {}, @@ -447,6 +458,7 @@ export const useOperations = ( credentials: props.settingsApi?.settings.fetch.credentials, body: JSON.stringify({ query: activeOperation.query, + operationName: options?.operationName, variables, extensions, }), diff --git a/packages/libraries/laboratory/src/lib/operations.utils.ts b/packages/libraries/laboratory/src/lib/operations.utils.ts index 1fa6fd6bf..7cfb5b6ce 100644 --- a/packages/libraries/laboratory/src/lib/operations.utils.ts +++ b/packages/libraries/laboratory/src/lib/operations.utils.ts @@ -31,7 +31,7 @@ export function healQuery(query: string) { return query.replace(/\{(\s+)?\}/g, ''); } -export function isPathInQuery(query: string, path: string, operationName?: string) { +export function isPathInQuery(query: string, path: string, operationName?: string | null) { if (!query || !path) { return false; } @@ -98,7 +98,7 @@ export function isPathInQuery(query: string, path: string, operationName?: strin return found; } -export function addPathToQuery(query: string, path: string, operationName?: string) { +export function addPathToQuery(query: string, path: string, operationName?: string | null) { query = healQuery(query); const [operation, ...parts] = path.split('.') as [OperationTypeNode, ...string[]]; @@ -244,7 +244,7 @@ export function addPathToQuery(query: string, path: string, operationName?: stri return print(doc); } -export function deletePathFromQuery(query: string, path: string, operationName?: string) { +export function deletePathFromQuery(query: string, path: string, operationName?: string | null) { query = healQuery(query); const [operation, ...segments] = path.split('.') as [OperationTypeNode, ...string[]]; @@ -391,7 +391,12 @@ export function getOperationType(query: string) { } } -export function isArgInQuery(query: string, path: string, argName: string, operationName?: string) { +export function isArgInQuery( + query: string, + path: string, + argName: string, + operationName?: string | null, +) { if (!query || !path) { return false; } @@ -525,7 +530,7 @@ export function addArgToField( path: string, argName: string, schema: GraphQLSchema, - operationName?: string, + operationName?: string | null, ) { query = healQuery(query); @@ -782,7 +787,7 @@ export function removeArgFromField( query: string, path: string, argName: string, - operationName?: string, + operationName?: string | null, ) { query = healQuery(query);