mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-09 06:55:03 +00:00
feat: graphql query builder (#7468)
* feat: graphql query builder * fix: bug * improvements * fix * fix: playright test * fix * fix * improvements * chore: types * fix * chore: minimal error boundary * imp: use button component --------- Co-authored-by: Sid <siddharth@usebruno.com>
This commit is contained in:
@@ -1,10 +1,15 @@
|
||||
import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
||||
import React, { useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import find from 'lodash/find';
|
||||
import get from 'lodash/get';
|
||||
import classnames from 'classnames';
|
||||
import { IconWand, IconDots, IconBook, IconDownload, IconRefresh, IconFile, IconChevronDown, IconChevronRight } from '@tabler/icons';
|
||||
import IconSidebarToggle from 'components/Icons/IconSidebarToggle';
|
||||
import ActionIcon from 'ui/ActionIcon';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { updateRequestPaneTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { updateRequestPaneTab, updateQueryBuilderOpen, updateQueryBuilderWidth, updateVariablesPaneOpen, updateVariablesPaneHeight } from 'providers/ReduxStore/slices/tabs';
|
||||
import QueryEditor from 'components/RequestPane/QueryEditor';
|
||||
import QueryBuilder from 'components/RequestPane/QueryBuilder';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import Auth from 'components/RequestPane/Auth';
|
||||
import GraphQLVariables from 'components/RequestPane/GraphQLVariables';
|
||||
import RequestHeaders from 'components/RequestPane/RequestHeaders';
|
||||
@@ -13,10 +18,12 @@ import Assertions from 'components/RequestPane/Assertions';
|
||||
import Script from 'components/RequestPane/Script';
|
||||
import Tests from 'components/RequestPane/Tests';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { updateRequestGraphqlQuery } from 'providers/ReduxStore/slices/collections';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { updateRequestGraphqlQuery, updateRequestGraphqlVariables } from 'providers/ReduxStore/slices/collections';
|
||||
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import Documentation from 'components/Documentation/index';
|
||||
import GraphQLSchemaActions from '../GraphQLSchemaActions/index';
|
||||
import useGraphqlSchema from '../GraphQLSchemaActions/useGraphqlSchema';
|
||||
import { findEnvironmentInCollection } from 'utils/collections';
|
||||
import HeightBoundContainer from 'ui/HeightBoundContainer';
|
||||
import Settings from 'components/RequestPane/Settings';
|
||||
import ResponsiveTabs from 'ui/ResponsiveTabs';
|
||||
@@ -24,7 +31,6 @@ import AuthMode from '../Auth/AuthMode/index';
|
||||
|
||||
const TAB_CONFIG = [
|
||||
{ key: 'query', label: 'Query' },
|
||||
{ key: 'variables', label: 'Variables' },
|
||||
{ key: 'headers', label: 'Headers' },
|
||||
{ key: 'auth', label: 'Auth' },
|
||||
{ key: 'vars', label: 'Vars' },
|
||||
@@ -40,6 +46,16 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
const requestPaneTab = focusedTab?.requestPaneTab;
|
||||
const showQueryBuilder = focusedTab?.queryBuilderOpen || false;
|
||||
const queryBuilderWidth = focusedTab?.queryBuilderWidth || 320;
|
||||
const variablesOpen = focusedTab?.variablesPaneOpen || false;
|
||||
const variablesHeight = focusedTab?.variablesPaneHeight || 150;
|
||||
const queryBuilderDraggingRef = useRef(false);
|
||||
const variablesDraggingRef = useRef(false);
|
||||
const queryBuilderContainerRef = useRef(null);
|
||||
const queryEditorRef = useRef(null);
|
||||
|
||||
const query = item.draft
|
||||
? get(item, 'draft.request.body.graphql.query', '')
|
||||
@@ -49,16 +65,70 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
|
||||
: get(item, 'request.body.graphql.variables', '');
|
||||
|
||||
const { displayedTheme } = useTheme();
|
||||
const [schema, setSchema] = useState(null);
|
||||
const schemaActionsRef = useRef(null);
|
||||
|
||||
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
const requestPaneTab = focusedTab?.requestPaneTab;
|
||||
const url = item.draft ? get(item, 'draft.request.url', '') : get(item, 'request.url', '');
|
||||
const pathname = item.draft ? get(item, 'draft.pathname', '') : get(item, 'pathname', '');
|
||||
const uid = item.draft ? get(item, 'draft.uid', '') : get(item, 'uid', '');
|
||||
const environment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid);
|
||||
const request = item.draft ? { ...item.draft.request, pathname, uid } : { ...item.request, pathname, uid };
|
||||
|
||||
const { schema, schemaSource, loadSchema, isLoading: isSchemaLoading, error: schemaError } = useGraphqlSchema(url, environment, request, collection);
|
||||
|
||||
const schemaActionsRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
onSchemaLoad(schema);
|
||||
}, [schema, onSchemaLoad]);
|
||||
|
||||
const toggleQueryBuilder = useCallback(() => {
|
||||
dispatch(updateQueryBuilderOpen({ uid: item.uid, queryBuilderOpen: !showQueryBuilder }));
|
||||
}, [dispatch, item.uid, showQueryBuilder]);
|
||||
|
||||
const variablesOpenRef = useRef(variablesOpen);
|
||||
variablesOpenRef.current = variablesOpen;
|
||||
|
||||
const handleMouseMove = useCallback((e) => {
|
||||
if (queryBuilderDraggingRef.current && queryBuilderContainerRef.current) {
|
||||
e.preventDefault();
|
||||
const containerRect = queryBuilderContainerRef.current.getBoundingClientRect();
|
||||
const newWidth = e.clientX - containerRect.left;
|
||||
const maxWidth = Math.min(600, containerRect.width * 0.5);
|
||||
dispatch(updateQueryBuilderWidth({ uid: item.uid, queryBuilderWidth: Math.max(200, Math.min(newWidth, maxWidth)) }));
|
||||
}
|
||||
if (variablesDraggingRef.current && queryBuilderContainerRef.current) {
|
||||
e.preventDefault();
|
||||
const containerRect = queryBuilderContainerRef.current.getBoundingClientRect();
|
||||
// Subtract the header height (~30px) from the drag calculation
|
||||
const newHeight = containerRect.bottom - e.clientY - 30;
|
||||
if (newHeight < 40) {
|
||||
dispatch(updateVariablesPaneOpen({ uid: item.uid, variablesPaneOpen: false }));
|
||||
} else {
|
||||
if (!variablesOpenRef.current) dispatch(updateVariablesPaneOpen({ uid: item.uid, variablesPaneOpen: true }));
|
||||
dispatch(updateVariablesPaneHeight({ uid: item.uid, variablesPaneHeight: Math.max(80, Math.min(newHeight, containerRect.height * 0.6)) }));
|
||||
}
|
||||
}
|
||||
}, [dispatch, item.uid]);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
queryBuilderDraggingRef.current = false;
|
||||
variablesDraggingRef.current = false;
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
}, [handleMouseMove]);
|
||||
|
||||
const startDrag = useCallback((ref) => {
|
||||
ref.current = true;
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
}, [handleMouseMove, handleMouseUp]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [handleMouseMove, handleMouseUp]);
|
||||
|
||||
const onQueryChange = useCallback(
|
||||
(value) => {
|
||||
dispatch(
|
||||
@@ -72,6 +142,19 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
|
||||
[dispatch, item.uid, collection.uid]
|
||||
);
|
||||
|
||||
const onVariablesChange = useCallback(
|
||||
(value) => {
|
||||
dispatch(
|
||||
updateRequestGraphqlVariables({
|
||||
variables: value,
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
},
|
||||
[dispatch, item.uid, collection.uid]
|
||||
);
|
||||
|
||||
const onRun = useCallback(
|
||||
() => dispatch(sendRequest(item, collection.uid)),
|
||||
[dispatch, item, collection.uid]
|
||||
@@ -91,25 +174,77 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
|
||||
|
||||
const allTabs = useMemo(() => TAB_CONFIG.map(({ key, label }) => ({ key, label })), []);
|
||||
|
||||
const handlePrettify = useCallback(() => {
|
||||
if (queryEditorRef.current?.beautifyRequestBody) {
|
||||
queryEditorRef.current.beautifyRequestBody();
|
||||
}
|
||||
if (variables) {
|
||||
try {
|
||||
const pretty = JSON.stringify(JSON.parse(variables), null, 2);
|
||||
if (pretty !== variables) {
|
||||
onVariablesChange(pretty);
|
||||
}
|
||||
} catch {
|
||||
// Variables JSON is invalid, skip prettifying
|
||||
}
|
||||
}
|
||||
}, [variables, onVariablesChange]);
|
||||
|
||||
const tabPanel = useMemo(() => {
|
||||
switch (requestPaneTab) {
|
||||
case 'query':
|
||||
return (
|
||||
<QueryEditor
|
||||
collection={collection}
|
||||
theme={displayedTheme}
|
||||
schema={schema}
|
||||
onSave={onSave}
|
||||
value={query}
|
||||
onRun={onRun}
|
||||
onEdit={onQueryChange}
|
||||
onClickReference={handleGqlClickReference}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
/>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 min-h-0">
|
||||
<QueryEditor
|
||||
ref={queryEditorRef}
|
||||
collection={collection}
|
||||
theme={displayedTheme}
|
||||
schema={schema}
|
||||
onSave={onSave}
|
||||
value={query}
|
||||
onRun={onRun}
|
||||
onEdit={onQueryChange}
|
||||
onClickReference={handleGqlClickReference}
|
||||
onPrettifyQuery={handlePrettify}
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="variables-section"
|
||||
style={variablesOpen ? { height: `${variablesHeight}px`, minHeight: `${variablesHeight}px` } : {}}
|
||||
>
|
||||
<div
|
||||
className="variables-dragbar"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
startDrag(variablesDraggingRef);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="variables-header"
|
||||
onClick={() => dispatch(updateVariablesPaneOpen({ uid: item.uid, variablesPaneOpen: !variablesOpen }))}
|
||||
aria-expanded={variablesOpen}
|
||||
>
|
||||
<span className="variables-chevron">
|
||||
{variablesOpen ? (
|
||||
<IconChevronDown size={14} strokeWidth={2} />
|
||||
) : (
|
||||
<IconChevronRight size={14} strokeWidth={2} />
|
||||
)}
|
||||
</span>
|
||||
<span>Variables</span>
|
||||
</button>
|
||||
{variablesOpen && (
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<GraphQLVariables item={item} variables={variables} collection={collection} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'variables':
|
||||
return <GraphQLVariables item={item} variables={variables} collection={collection} />;
|
||||
case 'headers':
|
||||
return <RequestHeaders item={item} collection={collection} />;
|
||||
case 'auth':
|
||||
@@ -129,7 +264,30 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
|
||||
default:
|
||||
return <div className="mt-4">404 | Not found</div>;
|
||||
}
|
||||
}, [requestPaneTab, item, collection, displayedTheme, schema, onSave, query, onRun, onQueryChange, handleGqlClickReference, preferences, variables]);
|
||||
}, [requestPaneTab, item, collection, displayedTheme, schema, onSave, query, onRun, onQueryChange, handleGqlClickReference, handlePrettify, preferences, variables, variablesOpen, variablesHeight, dispatch]);
|
||||
|
||||
const queryMenuItems = useMemo(() => [
|
||||
{
|
||||
id: 'docs',
|
||||
label: 'Docs',
|
||||
leftSection: IconBook,
|
||||
onClick: toggleDocs
|
||||
},
|
||||
{
|
||||
id: 'schema-introspection',
|
||||
label: schema && schemaSource === 'introspection' ? 'Refresh from Introspection' : 'Load from Introspection',
|
||||
leftSection: schema && schemaSource === 'introspection' ? IconRefresh : IconDownload,
|
||||
onClick: () => loadSchema('introspection'),
|
||||
disabled: isSchemaLoading
|
||||
},
|
||||
{
|
||||
id: 'schema-file',
|
||||
label: 'Load from File',
|
||||
leftSection: IconFile,
|
||||
onClick: () => loadSchema('file'),
|
||||
disabled: isSchemaLoading
|
||||
}
|
||||
], [toggleDocs, schema, schemaSource, loadSchema, isSchemaLoading]);
|
||||
|
||||
if (!activeTabUid || !focusedTab?.uid || !requestPaneTab) {
|
||||
return <div className="pb-4 px-4">An error occurred!</div>;
|
||||
@@ -140,13 +298,29 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
|
||||
<AuthMode item={item} collection={collection} />
|
||||
</div>
|
||||
) : requestPaneTab === 'query' ? (
|
||||
<div ref={schemaActionsRef}>
|
||||
<GraphQLSchemaActions item={item} collection={collection} onSchemaLoad={setSchema} toggleDocs={toggleDocs} />
|
||||
<div ref={schemaActionsRef} className="flex items-center gap-2">
|
||||
<ActionIcon
|
||||
label="Prettify"
|
||||
onClick={handlePrettify}
|
||||
>
|
||||
<IconWand size={14} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
label={showQueryBuilder ? 'Hide Query Builder' : 'Show Query Builder'}
|
||||
onClick={toggleQueryBuilder}
|
||||
>
|
||||
<IconSidebarToggle collapsed={!showQueryBuilder} size={16} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
<MenuDropdown items={queryMenuItems} placement="bottom-end">
|
||||
<ActionIcon label="More actions">
|
||||
<IconDots size={16} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
</MenuDropdown>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full relative">
|
||||
<StyledWrapper className="flex flex-col h-full relative">
|
||||
<ResponsiveTabs
|
||||
tabs={allTabs}
|
||||
activeTab={requestPaneTab}
|
||||
@@ -155,10 +329,33 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
|
||||
rightContentRef={rightContent ? schemaActionsRef : null}
|
||||
/>
|
||||
|
||||
<section className={classnames('flex w-full flex-1 mt-4')}>
|
||||
<HeightBoundContainer>{tabPanel}</HeightBoundContainer>
|
||||
<section ref={queryBuilderContainerRef} className={classnames('flex w-full flex-1 mt-4 min-h-0')}>
|
||||
{requestPaneTab === 'query' && showQueryBuilder && (
|
||||
<>
|
||||
<div className="graphql-query-builder-container" style={{ width: `${queryBuilderWidth}px`, minWidth: `${queryBuilderWidth}px` }}>
|
||||
<QueryBuilder
|
||||
schema={schema}
|
||||
onQueryChange={onQueryChange}
|
||||
editorValue={query}
|
||||
onVariablesChange={onVariablesChange}
|
||||
variablesValue={variables}
|
||||
loadSchema={loadSchema}
|
||||
isSchemaLoading={isSchemaLoading}
|
||||
schemaError={schemaError}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="query-builder-dragbar"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
startDrag(queryBuilderDraggingRef);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<HeightBoundContainer style={{ minWidth: 200 }}>{tabPanel}</HeightBoundContainer>
|
||||
</section>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user