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:
Pooja
2026-03-27 12:29:01 +05:30
committed by GitHub
parent f69afd7fa2
commit 35cd72534b
18 changed files with 3860 additions and 98 deletions

View File

@@ -0,0 +1,92 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.variables-section {
flex-shrink: 0;
display: flex;
flex-direction: column;
}
.variables-header {
display: flex;
align-items: center;
width: 100%;
padding: 3px 10px;
cursor: pointer;
user-select: none;
font-size: 12px;
color: ${(props) => props.theme.colors.text.muted};
gap: 4px;
flex-shrink: 0;
background: none;
border: none;
outline: none;
&:hover {
color: ${(props) => props.theme.text};
}
.variables-chevron {
display: flex;
align-items: center;
opacity: 0.6;
}
}
.variables-dragbar {
display: flex;
align-items: center;
justify-content: center;
height: 10px;
cursor: row-resize;
flex-shrink: 0;
position: relative;
&::after {
content: '';
display: block;
width: 100%;
height: 1px;
border-top: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border};
}
&:hover::after {
border-top: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder};
}
}
div.graphql-query-builder-container {
height: 100%;
flex-shrink: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
div.query-builder-dragbar {
display: flex;
align-items: center;
justify-content: center;
width: 10px;
min-width: 10px;
cursor: col-resize;
background: transparent;
position: relative;
flex-shrink: 0;
&::after {
content: '';
display: block;
height: 100%;
width: 1px;
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border};
}
&:hover::after {
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder};
}
}
`;
export default StyledWrapper;

View File

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

View File

@@ -5,10 +5,6 @@ import CodeEditor from 'components/CodeEditor';
import { updateRequestGraphqlVariables } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
import StyledWrapper from './StyledWrapper';
import { IconWand } from '@tabler/icons';
import toast from 'react-hot-toast';
import { prettifyJsonString } from 'utils/common/index';
const GraphQLVariables = ({ variables, item, collection }) => {
const dispatch = useDispatch();
@@ -16,24 +12,6 @@ const GraphQLVariables = ({ variables, item, collection }) => {
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const onPrettify = () => {
if (!variables) return;
try {
const prettyVariables = prettifyJsonString(variables);
dispatch(
updateRequestGraphqlVariables({
variables: prettyVariables,
itemUid: item.uid,
collectionUid: collection.uid
})
);
toast.success('Variables prettified');
} catch (error) {
console.error(error);
toast.error('Error occurred while prettifying GraphQL variables');
}
};
const onEdit = (value) => {
dispatch(
updateRequestGraphqlVariables({
@@ -48,28 +26,19 @@ const GraphQLVariables = ({ variables, item, collection }) => {
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
return (
<>
<button
className="btn-add-param text-link px-4 py-4 select-none absolute right-0 z-10"
onClick={onPrettify}
title="Prettify"
>
<IconWand size={20} strokeWidth={1.5} />
</button>
<CodeEditor
collection={collection}
value={variables || ''}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
onEdit={onEdit}
mode="application/json"
onRun={onRun}
onSave={onSave}
enableVariableHighlighting={true}
showHintsFor={['variables']}
/>
</>
<CodeEditor
collection={collection}
value={variables || ''}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
onEdit={onEdit}
mode="application/json"
onRun={onRun}
onSave={onSave}
enableVariableHighlighting={true}
showHintsFor={['variables']}
/>
);
};

View File

@@ -0,0 +1,46 @@
import React from 'react';
import { IconAlertTriangle } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button/index';
class QueryBuilderErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
this.reset = this.reset.bind(this);
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
console.error('[QueryBuilder] Unexpected render error:', error, errorInfo);
}
reset() {
this.setState({ hasError: false, error: null });
}
render() {
if (this.state.hasError) {
return (
<StyledWrapper>
<div className="schema-empty-state">
<IconAlertTriangle size={32} strokeWidth={1.5} className="empty-state-icon warning" />
<div className="empty-state-title">Something went wrong</div>
<div className="empty-state-description">
The Query Builder encountered an unexpected error. Try reloading the schema or manually using the editor.
</div>
<Button color="secondary" onClick={this.reset}>
Try Again
</Button>
</div>
</StyledWrapper>
);
}
return this.props.children;
}
}
export default QueryBuilderErrorBoundary;

View File

@@ -0,0 +1,529 @@
import React, { useCallback, useState, useMemo, useRef } from 'react';
import { IconChevronRight, IconChevronDown, IconTrash, IconInfoCircle } from '@tabler/icons';
import { nanoid } from 'nanoid';
import { getInputObjectFields } from 'utils/graphql/queryBuilder';
const ListArgValueInput = ({ values, onChange, field, indent }) => {
const [items, setItems] = useState(() => {
const vals = Array.isArray(values) ? values : (values ? [values] : []);
const mapped = vals.map((v) => ({ id: nanoid(), value: v }));
return [...mapped, { id: nanoid(), value: '' }];
});
const lastExternalRef = useRef(values);
// Sync internal items when values prop changes externally (e.g. editor edits)
if (values !== lastExternalRef.current) {
lastExternalRef.current = values;
const vals = Array.isArray(values) ? values : (values ? [values] : []);
const filledValues = items.filter((i) => i.value !== '').map((i) => i.value);
if (JSON.stringify(vals) !== JSON.stringify(filledValues)) {
const mapped = vals.map((v) => ({ id: nanoid(), value: v }));
setItems([...mapped, { id: nanoid(), value: '' }]);
}
}
const handleItemChange = (id, newValue) => {
let nextItems = items.map((item) => (item.id === id ? { ...item, value: newValue } : item));
const lastItem = nextItems[nextItems.length - 1];
if (lastItem && lastItem.value !== '') {
nextItems = [...nextItems, { id: nanoid(), value: '' }];
}
setItems(nextItems);
onChange(nextItems.filter((item) => item.value !== '').map((item) => item.value));
};
const handleRemove = (id) => {
const nextItems = items.filter((item) => item.id !== id);
setItems(nextItems);
onChange(nextItems.filter((item) => item.value !== '').map((item) => item.value));
};
return (
<div>
{items.map((item, index) => {
const isEmptyRow = index === items.length - 1 && item.value === '';
return (
<div key={item.id} className="arg-row" style={{ paddingLeft: indent }} onClick={(e) => e.stopPropagation()}>
<ArgValueInput value={item.value} onChange={(v) => handleItemChange(item.id, v)} field={field} />
{isEmptyRow ? (
<span className="list-arg-remove-spacer" />
) : (
<button
type="button"
className="list-arg-remove"
onClick={(e) => {
e.stopPropagation();
handleRemove(item.id);
}}
aria-label="Remove item"
>
<IconTrash size={13} strokeWidth={1.5} />
</button>
)}
</div>
);
})}
</div>
);
};
const ArgValueInput = ({ value, onChange, field }) => {
if (field.isEnum && field.enumValues) {
return (
<select value={value} onChange={(e) => onChange(e.target.value)} onClick={(e) => e.stopPropagation()}>
<option value="">Select option</option>
{field.enumValues.map((v) => (
<option key={v} value={v}>{v}</option>
))}
</select>
);
}
if (field.isBoolean) {
return (
<select value={value} onChange={(e) => onChange(e.target.value)} onClick={(e) => e.stopPropagation()}>
<option value="">Select option</option>
<option value="true">true</option>
<option value="false">false</option>
</select>
);
}
return (
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
onClick={(e) => e.stopPropagation()}
placeholder="Enter value"
/>
);
};
const InputObjectFields = ({ namedType, parentKey, fieldPath, indent, argValues, enabledArgs, onToggleInputField, onSetInputFieldValue }) => {
const [expandedFields, setExpandedFields] = useState(new Set());
const fields = useMemo(() => getInputObjectFields(namedType), [namedType]);
if (!fields || fields.length === 0) return null;
return fields.map((field) => {
const fieldKey = `${parentKey}.${field.name}`;
const isEnabled = enabledArgs ? enabledArgs.has(fieldKey) : false;
const isExpanded = expandedFields.has(field.name);
const value = argValues.get(fieldKey) ?? '';
const toggleExpand = (e) => {
e.stopPropagation();
setExpandedFields((prev) => {
const next = new Set(prev);
if (next.has(field.name)) next.delete(field.name);
else next.add(field.name);
return next;
});
};
const isListOfInputObject = field.isList && field.isInputObject;
const isExpandable = field.isInputObject && !isListOfInputObject;
return (
<React.Fragment key={field.name}>
<div className="arg-row" style={{ paddingLeft: indent }} onClick={isExpandable ? toggleExpand : (e) => e.stopPropagation()}>
{isExpandable ? (
<button type="button" className="field-chevron input-object-chevron" onClick={toggleExpand} aria-label={isExpanded ? 'Collapse' : 'Expand'}>
{isExpanded ? (
<IconChevronDown size={12} strokeWidth={2} />
) : (
<IconChevronRight size={12} strokeWidth={2} />
)}
</button>
) : (
<span className="input-object-chevron-spacer" />
)}
<input
type="checkbox"
className="field-checkbox"
checked={isEnabled}
onChange={(e) => {
e.stopPropagation();
const willEnable = !isEnabled;
onToggleInputField(fieldKey, fieldPath);
if (isExpandable && willEnable) {
setExpandedFields((prev) => {
const next = new Set(prev);
next.add(field.name);
return next;
});
}
}}
onClick={(e) => e.stopPropagation()}
/>
<span className="arg-name">{field.name}</span>
{field.isRequired && <span className="arg-required">!</span>}
{(!isEnabled || field.isInputObject) && <span className="field-type">{field.typeLabel}</span>}
{isListOfInputObject && (
<span className="list-complex-unsupported" title="List arguments for complex types are not currently supported.">
<IconInfoCircle size={13} strokeWidth={1.5} />
</span>
)}
{!field.isInputObject && isEnabled && (
<ArgValueInput value={value} onChange={(v) => onSetInputFieldValue(fieldKey, v)} field={field} />
)}
</div>
{isExpandable && isExpanded && (
<InputObjectFields
namedType={field.namedType}
parentKey={fieldKey}
fieldPath={fieldPath}
indent={indent + 20}
argValues={argValues}
enabledArgs={enabledArgs}
onToggleInputField={onToggleInputField}
onSetInputFieldValue={onSetInputFieldValue}
/>
)}
</React.Fragment>
);
});
};
const FieldNode = ({
field,
depth,
isChecked,
isExpanded,
onToggleCheck,
onToggleExpand,
argValues,
enabledArgs,
onToggleArg,
onArgChange,
onToggleInputField,
onSetInputFieldValue,
hasChildren
}) => {
const indent = depth * 20;
const handleCheck = useCallback(
(e) => {
e.stopPropagation();
onToggleCheck(field.path, field);
},
[field, onToggleCheck]
);
const hasArgs = field.args && field.args.length > 0;
const canExpand = !field.isLeaf || hasArgs;
const handleExpand = useCallback(
(e) => {
e.stopPropagation();
if (canExpand) {
onToggleExpand(field.path);
}
},
[field.path, canExpand, onToggleExpand]
);
// Union member type row (e.g. "... on Human")
if (field.isUnionMember) {
return (
<div
className="field-node"
role="treeitem"
aria-expanded={isExpanded}
onClick={handleExpand}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleExpand(e);
}
}}
tabIndex={0}
>
<span className="field-indent" style={{ width: indent }} />
<span className="field-chevron">
{isExpanded ? (
<IconChevronDown size={14} strokeWidth={2} />
) : (
<IconChevronRight size={14} strokeWidth={2} />
)}
</span>
<input
type="checkbox"
className="field-checkbox"
checked={isChecked}
onChange={handleCheck}
onClick={(e) => e.stopPropagation()}
/>
<span className="union-label">... on {field.name}</span>
</div>
);
}
const showSections = isExpanded && (hasArgs || hasChildren);
const sectionIndent = (depth + 1) * 20;
return (
<>
<div
className="field-node"
role="treeitem"
aria-expanded={canExpand ? isExpanded : undefined}
onClick={handleExpand}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleExpand(e);
}
}}
tabIndex={0}
>
<span className="field-indent" style={{ width: indent }} />
<span className="field-chevron">
{canExpand ? (
isExpanded ? (
<IconChevronDown size={14} strokeWidth={2} />
) : (
<IconChevronRight size={14} strokeWidth={2} />
)
) : null}
</span>
<input
type="checkbox"
className="field-checkbox"
checked={isChecked}
onChange={handleCheck}
onClick={(e) => e.stopPropagation()}
/>
<span className="field-name">{field.name}</span>
<span className="field-separator">:</span>
<span className="field-type">{field.typeLabel}</span>
</div>
{showSections && hasArgs && (
<>
<div className="section-header" style={{ paddingLeft: sectionIndent }}>
ARGUMENTS
</div>
{field.args.map((arg) => {
const argKey = `${field.path}.${arg.name}`;
const isArgEnabled = enabledArgs ? enabledArgs.has(argKey) : false;
const argValue = argValues.get(argKey) ?? '';
// List of input objects: show unsupported message
if (arg.isList && arg.isInputObject) {
return (
<div key={arg.name} className="arg-row" style={{ paddingLeft: sectionIndent + 8 }} onClick={(e) => e.stopPropagation()}>
<span className="input-object-chevron-spacer" />
<input
type="checkbox"
className="field-checkbox"
checked={isArgEnabled}
onChange={() => onToggleArg && onToggleArg(field.path, arg.name)}
onClick={(e) => e.stopPropagation()}
/>
<span className="arg-name">{arg.name}</span>
{arg.isRequired && <span className="arg-required">!</span>}
<span className="field-type">{arg.typeLabel}</span>
<span className="list-complex-unsupported" title="List arguments for complex types are not currently supported.">
<IconInfoCircle size={13} strokeWidth={1.5} />
</span>
</div>
);
}
// Input object arg: render as expandable with children
if (arg.isInputObject) {
return (
<InputObjectArgRow
key={arg.name}
arg={arg}
argKey={argKey}
fieldPath={field.path}
isArgEnabled={isArgEnabled}
sectionIndent={sectionIndent}
argValues={argValues}
enabledArgs={enabledArgs}
onToggleArg={onToggleArg}
onToggleInputField={onToggleInputField}
onSetInputFieldValue={onSetInputFieldValue}
/>
);
}
if (arg.isList && !arg.isInputObject) {
return (
<ListArgRow
key={arg.name}
arg={arg}
fieldPath={field.path}
isArgEnabled={isArgEnabled}
argValue={argValue}
sectionIndent={sectionIndent}
onToggleArg={onToggleArg}
onArgChange={onArgChange}
/>
);
}
return (
<div key={arg.name} className="arg-row" style={{ paddingLeft: sectionIndent + 8 }} onClick={(e) => e.stopPropagation()}>
<span className="input-object-chevron-spacer" />
<input
type="checkbox"
className="field-checkbox"
checked={isArgEnabled}
onChange={() => onToggleArg && onToggleArg(field.path, arg.name)}
onClick={(e) => e.stopPropagation()}
/>
<span className="arg-name">{arg.name}</span>
{arg.isRequired && <span className="arg-required">!</span>}
{!isArgEnabled && <span className="field-type">{arg.typeLabel}</span>}
{isArgEnabled && (
<ArgValueInput value={argValue} onChange={(v) => onArgChange(field.path, arg.name, v)} field={arg} />
)}
</div>
);
})}
</>
)}
{showSections && hasChildren && hasArgs && (
<div className="section-header" style={{ paddingLeft: sectionIndent }}>
FIELDS
</div>
)}
</>
);
};
const InputObjectArgRow = ({ arg, argKey, fieldPath, isArgEnabled, sectionIndent, argValues, enabledArgs, onToggleArg, onToggleInputField, onSetInputFieldValue }) => {
const [isExpanded, setIsExpanded] = useState(false);
const toggleExpand = (e) => {
e.stopPropagation();
setIsExpanded((prev) => !prev);
};
const handleCheck = (e) => {
e.stopPropagation();
const willEnable = !isArgEnabled;
onToggleArg && onToggleArg(fieldPath, arg.name);
// Auto-expand when checking only
if (willEnable) {
setIsExpanded(true);
}
};
return (
<>
<div
className="arg-row"
style={{ paddingLeft: sectionIndent + 8 }}
onClick={toggleExpand}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleExpand(e);
}
}}
tabIndex={0}
role="button"
aria-expanded={isExpanded}
>
<span className="field-chevron input-object-chevron">
{isExpanded ? (
<IconChevronDown size={12} strokeWidth={2} />
) : (
<IconChevronRight size={12} strokeWidth={2} />
)}
</span>
<input
type="checkbox"
className="field-checkbox"
checked={isArgEnabled}
onChange={handleCheck}
onClick={(e) => e.stopPropagation()}
/>
<span className="arg-name">{arg.name}</span>
{arg.isRequired && <span className="arg-required">!</span>}
<span className="field-type">{arg.typeLabel}</span>
</div>
{isExpanded && arg.namedType && (
<InputObjectFields
namedType={arg.namedType}
parentKey={argKey}
fieldPath={fieldPath}
indent={sectionIndent + 28}
argValues={argValues}
enabledArgs={enabledArgs}
onToggleInputField={onToggleInputField}
onSetInputFieldValue={onSetInputFieldValue}
/>
)}
</>
);
};
const ListArgRow = ({ arg, fieldPath, isArgEnabled, argValue, sectionIndent, onToggleArg, onArgChange }) => {
const [isExpanded, setIsExpanded] = useState(false);
const toggleExpand = (e) => {
e.stopPropagation();
setIsExpanded((prev) => !prev);
};
const handleCheck = (e) => {
e.stopPropagation();
const willEnable = !isArgEnabled;
onToggleArg && onToggleArg(fieldPath, arg.name);
if (willEnable) {
setIsExpanded(true);
}
};
return (
<>
<div
className="arg-row"
style={{ paddingLeft: sectionIndent + 8 }}
onClick={toggleExpand}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleExpand(e);
}
}}
tabIndex={0}
role="button"
aria-expanded={isExpanded}
>
<span className="field-chevron input-object-chevron">
{isExpanded ? (
<IconChevronDown size={12} strokeWidth={2} />
) : (
<IconChevronRight size={12} strokeWidth={2} />
)}
</span>
<input
type="checkbox"
className="field-checkbox"
checked={isArgEnabled}
onChange={handleCheck}
onClick={(e) => e.stopPropagation()}
/>
<span className="arg-name">{arg.name}</span>
{arg.isRequired && <span className="arg-required">!</span>}
<span className="field-type">{arg.typeLabel}</span>
</div>
{isExpanded && (
<ListArgValueInput
values={argValue}
onChange={(v) => onArgChange(fieldPath, arg.name, v)}
field={arg}
indent={sectionIndent + 28}
/>
)}
</>
);
};
export default React.memo(FieldNode);

View File

@@ -0,0 +1,56 @@
import React, { useMemo, memo } from 'react';
import { getNamedType } from 'graphql';
import FieldNode from './FieldNode';
import { getFieldChildren } from 'utils/graphql/queryBuilder';
const QueryBuilderTree = ({ fields, unionTypes, ...treeProps }) => {
return (
<>
{unionTypes && unionTypes.map((ut) => (
<TreeNode key={ut.path} field={ut} isUnion {...treeProps} />
))}
{(fields || []).map((field) => (
<TreeNode key={field.path} field={field} {...treeProps} />
))}
</>
);
};
const TreeNode = memo(({ field, isUnion = false, depth, selections, expandedPaths, ...restProps }) => {
const isChecked = selections.has(field.path);
const isExpanded = expandedPaths.has(field.path);
const namedType = isUnion ? field.namedType : getNamedType(field.type);
const children = useMemo(() => {
if (isUnion ? !isExpanded : (field.isLeaf || !isExpanded)) return null;
return getFieldChildren(namedType, field.path);
}, [isUnion, field.isLeaf, isExpanded, namedType, field.path]);
const hasChildren = !!(children && (children.fields?.length > 0 || children.unionTypes?.length > 0));
return (
<>
<FieldNode
field={field}
depth={depth}
isChecked={isChecked}
isExpanded={isExpanded}
hasChildren={hasChildren}
{...restProps}
/>
{isExpanded && children && (
<QueryBuilderTree
fields={children.fields || []}
unionTypes={children.unionTypes}
depth={depth + 1}
selections={selections}
expandedPaths={expandedPaths}
{...restProps}
/>
)}
</>
);
});
export default QueryBuilderTree;

View File

@@ -0,0 +1,383 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
color: ${(props) => props.theme.text};
outline: none;
width: 100%;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
.query-builder-search {
display: flex;
align-items: center;
padding: 6px 8px;
flex-shrink: 0;
gap: 6px;
input {
flex: 1;
padding: 4px 8px;
border: 1px solid ${(props) => props.theme.input.border};
border-radius: 4px;
background: ${(props) => props.theme.input.bg};
color: ${(props) => props.theme.text};
font-size: 12px;
&:focus {
outline: none;
border-color: ${(props) => props.theme.input.focusBorder};
}
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
}
}
}
.sync-error-banner {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 6px 10px;
margin: 4px 8px;
border-radius: 4px;
border: 1px solid ${(props) => props.theme.colors.text.danger}30;
background: ${(props) => props.theme.colors.text.danger}08;
flex-shrink: 0;
font-size: 11px;
line-height: 1.5;
color: ${(props) => props.theme.colors.text.muted};
.sync-error-icon {
color: ${(props) => props.theme.colors.text.danger};
flex-shrink: 0;
margin-top: 2px;
}
.sync-error-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
strong {
color: ${(props) => props.theme.text};
font-size: 11px;
font-weight: 600;
}
code {
background: ${(props) => props.theme.background.surface0};
padding: 0px 3px;
border-radius: 2px;
font-size: 10px;
white-space: nowrap;
}
}
}
.query-builder-tree {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
overflow-x: auto;
padding: 2px 0;
}
.root-type-disabled {
opacity: 0.4;
pointer-events: none;
}
.root-type-node {
display: flex;
align-items: center;
width: 100%;
padding: 6px 8px;
cursor: pointer;
font-size: 13px;
background: none;
border: none;
outline: none;
text-align: left;
&:hover,
&:focus-visible {
background: ${(props) => props.theme.background.surface0};
}
&:disabled {
cursor: default;
&:hover,
&:focus-visible {
background: none;
}
}
.root-type-name {
font-weight: 600;
color: ${(props) => props.theme.colors.text.muted};
}
.root-type-count {
margin-left: auto;
color: ${(props) => props.theme.colors.text.muted};
font-size: 12px;
}
}
.field-chevron {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
opacity: 0.5;
margin-right: 2px;
}
.field-node {
display: flex;
align-items: center;
padding: 4px 8px 4px 4px;
cursor: pointer;
font-size: 13px;
line-height: 1.4;
white-space: nowrap;
width: fit-content;
min-width: 100%;
outline: none;
&:hover,
&:focus-visible {
background: ${(props) => props.theme.background.surface0};
}
.field-indent {
flex-shrink: 0;
}
.field-checkbox {
margin: 0 6px 0 0;
cursor: pointer;
flex-shrink: 0;
width: 14px;
height: 14px;
accent-color: ${(props) => props.theme.colors.accent};
vertical-align: middle;
}
.field-name {
color: ${(props) => props.theme.text};
font-weight: 500;
}
.field-separator {
color: ${(props) => props.theme.colors.text.muted};
margin: 0 6px;
flex-shrink: 0;
}
.field-type {
color: ${(props) => props.theme.colors.text.muted};
font-size: 12px;
flex-shrink: 0;
white-space: nowrap;
}
.union-label {
color: ${(props) => props.theme.colors.text.muted};
font-size: 12px;
}
}
.section-header {
font-size: 11px;
font-weight: 600;
color: ${(props) => props.theme.colors.text.muted};
padding: 6px 8px 4px;
letter-spacing: 0.5px;
user-select: none;
}
.arg-row {
display: flex;
align-items: center;
padding: 3px 8px;
font-size: 13px;
min-width: 0;
cursor: default;
.input-object-chevron {
width: 14px;
height: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
opacity: 0.5;
margin-right: 2px;
cursor: pointer;
background: none;
border: none;
outline: none;
padding: 0;
color: inherit;
}
.input-object-chevron-spacer {
width: 14px;
flex-shrink: 0;
margin-right: 2px;
}
.field-type {
color: ${(props) => props.theme.colors.text.muted};
font-size: 12px;
flex-shrink: 0;
margin-left: 4px;
}
.field-checkbox {
margin: 0 6px 0 0;
cursor: pointer;
flex-shrink: 0;
width: 14px;
height: 14px;
accent-color: ${(props) => props.theme.colors.accent};
vertical-align: middle;
}
.arg-name {
color: ${(props) => props.theme.text};
flex-shrink: 0;
margin-right: 4px;
}
.arg-required {
color: ${(props) => props.theme.colors.text.danger};
font-weight: 700;
margin-right: 6px;
flex-shrink: 0;
}
input:not(.field-checkbox), select {
padding: 3px 8px;
border: 1px solid ${(props) => props.theme.input.border};
border-radius: 4px;
background: ${(props) => props.theme.input.bg};
color: ${(props) => props.theme.text};
font-size: 12px;
flex: 1;
min-width: 0;
cursor: text;
&:focus {
outline: none;
border-color: ${(props) => props.theme.input.focusBorder};
}
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
opacity: 0.6;
}
}
select {
cursor: pointer;
}
}
.list-complex-unsupported {
display: inline-flex;
align-items: center;
color: ${(props) => props.theme.colors.text.muted};
margin-left: 8px;
cursor: help;
}
.list-arg-remove,
.list-arg-remove-spacer {
width: 17px;
flex-shrink: 0;
margin-left: 4px;
display: flex;
align-items: center;
}
.list-arg-remove {
cursor: pointer;
opacity: 0.4;
background: none;
border: none;
outline: none;
padding: 0;
color: inherit;
&:hover {
opacity: 1;
color: ${(props) => props.theme.colors.text.danger};
}
}
.empty-state {
padding: 12px;
text-align: center;
color: ${(props) => props.theme.colors.text.muted};
font-size: 12px;
}
.schema-empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 24px 20px;
text-align: center;
gap: 12px;
.empty-state-icon {
color: ${(props) => props.theme.colors.text.muted};
opacity: 0.6;
&.warning {
color: ${(props) => props.theme.colors.text.danger};
opacity: 0.8;
}
}
.empty-state-title {
font-size: 14px;
font-weight: 600;
color: ${(props) => props.theme.text};
}
.empty-state-description {
font-size: 12px;
color: ${(props) => props.theme.colors.text.muted};
line-height: 1.5;
max-width: 240px;
word-break: break-word;
}
.empty-state-actions {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 240px;
button {
border-color: ${(props) => props.theme.border.border1};
color: ${(props) => props.theme.colors.text.muted};
}
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,238 @@
import React, { useState, useMemo, useCallback, useEffect } from 'react';
import { IconCloudDownload, IconFileUpload, IconAlertTriangle, IconChevronRight, IconChevronDown } from '@tabler/icons';
import { getRootFields } from 'utils/graphql/queryBuilder';
import useQueryBuilder from 'hooks/useQueryBuilder';
import QueryBuilderTree from './QueryBuilderTree';
import ErrorBoundary from './ErrorBoundary';
import Button from 'ui/Button';
import StyledWrapper from './StyledWrapper';
const QueryBuilder = ({ schema, onQueryChange, editorValue, onVariablesChange, variablesValue, loadSchema, isSchemaLoading, schemaError }) => {
const {
selections,
expandedPaths,
argValues,
enabledArgs,
availableRootTypes,
syncError,
toggleField,
toggleExpand,
toggleArg,
setArgValue,
toggleInputField,
setInputFieldValue
} = useQueryBuilder(schema, onQueryChange, editorValue, onVariablesChange, variablesValue);
const [searchText, setSearchText] = useState('');
const [expandedRootTypes, setExpandedRootTypes] = useState(() => new Set(availableRootTypes));
useEffect(() => {
if (schema) {
setExpandedRootTypes(new Set(availableRootTypes));
}
}, [schema]);
const effectiveExpandedRootTypes = useMemo(() => {
if (searchText.trim()) return new Set(availableRootTypes);
return expandedRootTypes;
}, [searchText, expandedRootTypes, availableRootTypes]);
const toggleRootType = useCallback((type) => {
setExpandedRootTypes((prev) => {
const next = new Set(prev);
if (next.has(type)) {
next.delete(type);
} else {
next.add(type);
}
return next;
});
}, []);
const rootFieldsByType = useMemo(() => {
const map = {};
for (const type of availableRootTypes) {
map[type] = getRootFields(schema, type);
}
return map;
}, [schema, availableRootTypes]);
// Determine which root type is active (has selections) — only one allowed at a time
const activeRootType = useMemo(() => {
for (const type of availableRootTypes) {
for (const path of selections) {
if (path.startsWith(type + '.')) return type;
}
}
return null;
}, [selections, availableRootTypes]);
// Filter fields by search text
const filteredFieldsByType = useMemo(() => {
if (!searchText.trim()) return rootFieldsByType;
const lower = searchText.toLowerCase();
const map = {};
for (const type of availableRootTypes) {
map[type] = (rootFieldsByType[type] || []).filter((f) =>
f.name.toLowerCase().includes(lower)
);
}
return map;
}, [rootFieldsByType, searchText, availableRootTypes]);
if (!schema) {
return (
<StyledWrapper>
<div className="schema-empty-state">
{schemaError ? (
<>
<IconAlertTriangle size={32} strokeWidth={1.5} className="empty-state-icon warning" />
<div className="empty-state-title">Failed to Load Schema</div>
<div className="empty-state-description">{schemaError.message}</div>
<div className="empty-state-actions">
<Button
variant="outline"
color="secondary"
fullWidth
icon={<IconCloudDownload size={16} strokeWidth={1.5} />}
loading={isSchemaLoading}
disabled={isSchemaLoading}
onClick={() => loadSchema('introspection')}
>
Try Again
</Button>
<Button
variant="outline"
color="secondary"
fullWidth
icon={<IconFileUpload size={16} strokeWidth={1.5} />}
disabled={isSchemaLoading}
onClick={() => loadSchema('file')}
>
Upload Schema File
</Button>
</div>
</>
) : (
<>
<div className="empty-state-title">No Schema Loaded</div>
<div className="empty-state-description">
Load a GraphQL schema to explore operations and build queries visually.
</div>
<div className="empty-state-actions">
<Button
variant="outline"
color="secondary"
fullWidth
icon={<IconCloudDownload size={16} strokeWidth={1.5} />}
loading={isSchemaLoading}
disabled={isSchemaLoading}
onClick={() => loadSchema('introspection')}
>
Load from Introspection
</Button>
<Button
variant="outline"
color="secondary"
fullWidth
icon={<IconFileUpload size={16} strokeWidth={1.5} />}
disabled={isSchemaLoading}
onClick={() => loadSchema('file')}
>
Upload Schema File
</Button>
</div>
</>
)}
</div>
</StyledWrapper>
);
}
if (syncError) {
return (
<StyledWrapper>
<div className="sync-error-banner">
<IconAlertTriangle size={13} strokeWidth={1.5} className="sync-error-icon" />
<div className="sync-error-text">
{syncError === 'multiple_operations' ? (
<>
<strong>Multiple operations detected</strong>
<span>The Query Builder supports a single operation at a time. Combine into one operation to sync.</span>
</>
) : null}
</div>
</div>
</StyledWrapper>
);
}
return (
<ErrorBoundary>
<StyledWrapper>
<div className="query-builder-search">
<input
type="text"
placeholder="Search operations..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
</div>
<div className="query-builder-tree">
{availableRootTypes.map((rootType) => {
const isExpanded = effectiveExpandedRootTypes.has(rootType);
const fields = filteredFieldsByType[rootType] || [];
const isDisabled = activeRootType !== null && activeRootType !== rootType;
return (
<div key={rootType} className={isDisabled ? 'root-type-disabled' : ''}>
<button
type="button"
className="root-type-node"
onClick={() => !isDisabled && toggleRootType(rootType)}
aria-expanded={isExpanded}
disabled={isDisabled}
>
<span className="field-chevron">
{isExpanded && !isDisabled ? (
<IconChevronDown size={14} strokeWidth={2} />
) : (
<IconChevronRight size={14} strokeWidth={2} />
)}
</span>
<span className="root-type-name">{rootType}</span>
<span className="root-type-count">{(rootFieldsByType[rootType] || []).length}</span>
</button>
{isExpanded && !isDisabled && (
fields.length > 0 ? (
<QueryBuilderTree
fields={fields}
depth={1}
selections={selections}
expandedPaths={expandedPaths}
argValues={argValues}
enabledArgs={enabledArgs}
onToggleCheck={toggleField}
onToggleExpand={toggleExpand}
onToggleArg={toggleArg}
onArgChange={setArgValue}
onToggleInputField={toggleInputField}
onSetInputFieldValue={setInputFieldValue}
/>
) : (
<div className="empty-state">
{searchText ? 'No matching fields.' : 'No fields available.'}
</div>
)
)}
</div>
);
})}
</div>
</StyledWrapper>
</ErrorBoundary>
);
};
export default QueryBuilder;

View File

@@ -11,11 +11,9 @@ import MD from 'markdown-it';
import { format } from 'prettier/standalone';
import prettierPluginGraphql from 'prettier/parser-graphql';
import { getAllVariables } from 'utils/collections';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import { PLACEHOLDER } from 'utils/graphql/queryBuilder';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
import { IconWand } from '@tabler/icons';
import onHasCompletion from './onHasCompletion';
import { setupLinkAware } from 'utils/codemirror/linkAware';
@@ -206,16 +204,33 @@ export default class QueryEditor extends React.Component {
this.editor.off('change', this._onEdit);
this.editor.off('keyup', this._onKeyUp);
this.editor.off('hasCompletion', this._onHasCompletion);
this.editor.off('beforeChange', this._onBeforeChange);
// Remove the CodeMirror DOM element so React 18 Strict Mode's
// unmount-remount cycle doesn't leave an orphaned instance behind.
const wrapper = this.editor.getWrapperElement();
if (wrapper && wrapper.parentNode) {
wrapper.parentNode.removeChild(wrapper);
}
this.editor = null;
}
}
beautifyRequestBody = () => {
try {
const prettyQuery = format(this.props.value, {
if (!this.editor) return;
const currentValue = this.editor.getValue();
if (!currentValue || !currentValue.trim()) return;
// Temporarily fill empty selection sets so prettier can parse the query
// First preserve empty input objects (e.g. input: {}), then fill empty selection sets
let sanitized = currentValue.replace(/(:\s*)\{\s*\}/g, '$1{ __empty: true }');
sanitized = sanitized.replace(/\{\s*\}/g, `{ ${PLACEHOLDER} }`);
let prettyQuery = format(sanitized, {
parser: 'graphql',
plugins: [prettierPluginGraphql]
});
prettyQuery = prettyQuery.replace(new RegExp(`^\\s*${PLACEHOLDER}\\n`, 'gm'), '');
prettyQuery = prettyQuery.replace(/\{\s*__empty:\s*true\s*\}/g, '{}');
this.editor.setValue(prettyQuery);
toast.success('Query prettified');
@@ -235,25 +250,15 @@ export default class QueryEditor extends React.Component {
render() {
return (
<>
<StyledWrapper
className="h-full w-full flex flex-col relative graphiql-container"
aria-label="Query Editor"
font={this.props.font}
fontSize={this.props.fontSize}
ref={(node) => {
this._node = node;
}}
>
<button
className="btn-add-param text-link px-4 py-4 select-none absolute top-0 right-0 z-10"
onClick={this.beautifyRequestBody}
title="prettify"
>
<IconWand size={20} strokeWidth={1.5} />
</button>
</StyledWrapper>
</>
<StyledWrapper
className="h-full w-full flex flex-col relative graphiql-container"
aria-label="Query Editor"
font={this.props.font}
fontSize={this.props.fontSize}
ref={(node) => {
this._node = node;
}}
/>
);
}