CONSOLE-1958: Operation picker (when multiple exists in document) (#7963)

This commit is contained in:
Michael Skorokhodov 2026-04-15 13:39:54 +02:00 committed by GitHub
parent e3d9750cc9
commit 4a8bd4fd1b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 221 additions and 41 deletions

View file

@ -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)

View file

@ -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<unknown, unknown, unknown>).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<string> | 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}
/>
))

View file

@ -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<typeof MonacoEditor> & {
uri?: monaco.Uri;
variablesUri?: monaco.Uri;
extraLibs?: string[];
onOperationNameChange?: (operationName: string | null) => void;
};
const EditorInner = forwardRef<EditorHandle, EditorProps>((props, ref) => {
@ -231,6 +242,109 @@ const EditorInner = forwardRef<EditorHandle, EditorProps>((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<EditorHandle, EditorProps>((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,

View file

@ -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<string | null>(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<string | null>(null);
const operation = useMemo(() => {
return props.operation ?? activeOperation ?? null;
@ -856,13 +867,17 @@ export const Operation = (props: {
<div className="bg-card relative size-full">
<ResizablePanelGroup direction="horizontal" className="size-full">
<ResizablePanel defaultSize={25}>
<Builder operation={operation} isReadOnly={isReadOnly} />
<Builder operation={operation} operationName={operationName} isReadOnly={isReadOnly} />
</ResizablePanel>
<ResizableHandle />
<ResizablePanel minSize={10} defaultSize={40}>
<ResizablePanelGroup direction="vertical">
<ResizablePanel defaultSize={70}>
<Query operation={operation} isReadOnly={isReadOnly} />
<Query
operation={operation}
isReadOnly={isReadOnly}
onOperationNameChange={setOperationName}
/>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel minSize={10} defaultSize={30}>

View file

@ -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%;

View file

@ -45,15 +45,25 @@ export interface LaboratoryOperationsActions {
setOperations: (operations: LaboratoryOperation[]) => void;
updateActiveOperation: (operation: Partial<Omit<LaboratoryOperation, 'id'>>) => 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<string, string>;
operationName?: string;
onResponse?: (response: string) => void;
},
) => Promise<Response | null>;
@ -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<string, string>;
onResponse?: (response: string) => void;
operationName?: string;
},
plugins: LaboratoryPlugin[] = props.pluginsApi?.plugins ?? [],
pluginsState: Record<string, any> = 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,
}),

View file

@ -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);