mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +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:
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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']}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
497
packages/bruno-app/src/hooks/useQueryBuilder/index.js
Normal file
497
packages/bruno-app/src/hooks/useQueryBuilder/index.js
Normal file
@@ -0,0 +1,497 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
import { format } from 'prettier/standalone';
|
||||
import prettierPluginGraphql from 'prettier/parser-graphql';
|
||||
import { generateQueryString, getAvailableRootTypes, parseQueryToState, validateQueryForSync, PLACEHOLDER } from 'utils/graphql/queryBuilder';
|
||||
|
||||
const DEBOUNCE_MS = 150;
|
||||
const SYNC_DEBOUNCE_MS = 400;
|
||||
|
||||
const isValidJson = (str) => {
|
||||
if (!str || !str.trim()) return true;
|
||||
try {
|
||||
JSON.parse(str);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeQuery = (q) => (q || '').replace(/\s+/g, ' ').trim();
|
||||
|
||||
const prettifyQuery = (query) => {
|
||||
try {
|
||||
let sanitized = query;
|
||||
sanitized = sanitized.replace(/(:\s*)\{\s*\}/g, '$1{ __empty: true }');
|
||||
sanitized = sanitized.replace(/\{\s*\}/g, `{ ${PLACEHOLDER} }`);
|
||||
let result = format(sanitized, { parser: 'graphql', plugins: [prettierPluginGraphql] });
|
||||
result = result.replace(new RegExp(`^\\s*${PLACEHOLDER}\\n`, 'gm'), '');
|
||||
result = result.replace(/\{\s*__empty:\s*true\s*\}/g, '{}');
|
||||
return result.trim();
|
||||
} catch {
|
||||
return query;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteSetByPrefix = (set, prefix) => {
|
||||
for (const k of set) {
|
||||
if (k === prefix || k.startsWith(prefix + '.')) {
|
||||
set.delete(k);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const deleteMapByPrefix = (map, prefix) => {
|
||||
for (const k of map.keys()) {
|
||||
if (k === prefix || k.startsWith(prefix + '.')) {
|
||||
map.delete(k);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const ensureAncestorsSelected = (selections, path) => {
|
||||
const parts = path.split('.');
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
selections.add(parts.slice(0, i).join('.'));
|
||||
}
|
||||
};
|
||||
|
||||
export default function useQueryBuilder(schema, onQueryChange, editorValue, onVariablesChange, variablesValue) {
|
||||
const [selections, setSelections] = useState(new Set()); // checked field paths (e.g. "Query.user", "Query.user.name")
|
||||
const [expandedPaths, setExpandedPaths] = useState(new Set()); // expanded tree nodes
|
||||
const [argValues, setArgValues] = useState(new Map()); // argument values keyed by path (e.g. "Query.user.id" → "123")
|
||||
const [enabledArgs, setEnabledArgs] = useState(new Set()); // toggled-on argument paths
|
||||
|
||||
// syncError is also stored in a ref so debounced callbacks can read the latest value
|
||||
const [syncError, _setSyncError] = useState(null);
|
||||
const syncErrorRef = useRef(null);
|
||||
const setSyncError = useCallback((val) => {
|
||||
syncErrorRef.current = val;
|
||||
_setSyncError(val);
|
||||
}, []);
|
||||
|
||||
const debounceRef = useRef(null); // timer for tree → editor generation
|
||||
const syncDebounceRef = useRef(null); // timer for editor → tree sync
|
||||
const initialSyncDone = useRef(false); // ensures initial parse runs only once
|
||||
const lastGeneratedValue = useRef(''); // last query we generated (used to skip self-triggered syncs)
|
||||
const lastGeneratedVarsValue = useRef(''); // last variables JSON we generated
|
||||
const lastGeneratedVarNames = useRef(new Set()); // tracks which variable names we own (to clean up stale ones)
|
||||
const shouldGenerate = useRef(false); // gate: only generate when a user action (toggle/arg) set this to true
|
||||
|
||||
// --- Refs to read latest values in stable callbacks without adding them to dependency arrays ---
|
||||
const variablesValueRef = useRef(variablesValue);
|
||||
variablesValueRef.current = variablesValue;
|
||||
const editorValueRef = useRef(editorValue);
|
||||
editorValueRef.current = editorValue;
|
||||
const onVariablesChangeRef = useRef(onVariablesChange);
|
||||
onVariablesChangeRef.current = onVariablesChange;
|
||||
const selectionsRef = useRef(selections);
|
||||
selectionsRef.current = selections;
|
||||
const enabledArgsRef = useRef(enabledArgs);
|
||||
enabledArgsRef.current = enabledArgs;
|
||||
|
||||
const availableRootTypes = useMemo(() => getAvailableRootTypes(schema), [schema]);
|
||||
|
||||
// Merges newVariables into the existing variables JSON, removes stale ones we previously generated
|
||||
const syncVariables = (newVariables) => {
|
||||
const onVarsChange = onVariablesChangeRef.current;
|
||||
if (!onVarsChange) return;
|
||||
|
||||
const newVarNames = new Set(Object.keys(newVariables));
|
||||
|
||||
let existing = {};
|
||||
const currentVarsValue = variablesValueRef.current;
|
||||
if (currentVarsValue) {
|
||||
try { existing = JSON.parse(currentVarsValue); } catch { return; }
|
||||
}
|
||||
|
||||
for (const name of lastGeneratedVarNames.current) {
|
||||
if (!newVarNames.has(name)) {
|
||||
delete existing[name];
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(existing, newVariables);
|
||||
lastGeneratedVarNames.current = newVarNames;
|
||||
|
||||
const varsString = Object.keys(existing).length > 0
|
||||
? JSON.stringify(existing, null, 2) : '';
|
||||
lastGeneratedVarsValue.current = varsString;
|
||||
onVarsChange(varsString);
|
||||
};
|
||||
|
||||
// Reset all state when schema changes
|
||||
useEffect(() => {
|
||||
setSelections(new Set());
|
||||
setExpandedPaths(new Set());
|
||||
setArgValues(new Map());
|
||||
setEnabledArgs(new Set());
|
||||
setSyncError(null);
|
||||
initialSyncDone.current = false;
|
||||
lastGeneratedValue.current = '';
|
||||
lastGeneratedVarsValue.current = '';
|
||||
lastGeneratedVarNames.current = new Set();
|
||||
shouldGenerate.current = false;
|
||||
}, [schema]);
|
||||
|
||||
// Initial sync: parse existing editor query into tree state (runs once per schema load)
|
||||
useEffect(() => {
|
||||
if (initialSyncDone.current || !schema || !editorValue) return;
|
||||
initialSyncDone.current = true;
|
||||
|
||||
const validation = validateQueryForSync(editorValue);
|
||||
if (!validation.valid) {
|
||||
setSyncError(validation.error);
|
||||
return;
|
||||
}
|
||||
setSyncError(null);
|
||||
|
||||
const state = parseQueryToState(editorValue, schema, variablesValue);
|
||||
if (!state || state.selections.size === 0) return;
|
||||
|
||||
setSelections(state.selections);
|
||||
setExpandedPaths(state.expandedPaths);
|
||||
setArgValues(state.argValues);
|
||||
setEnabledArgs(state.enabledArgs);
|
||||
lastGeneratedValue.current = normalizeQuery(editorValue);
|
||||
}, [schema, editorValue]);
|
||||
|
||||
// Editor → Tree sync: when the user edits the query text, parse it and update the tree
|
||||
useEffect(() => {
|
||||
if (!initialSyncDone.current || !schema) return;
|
||||
|
||||
// Editor was cleared — reset tree state
|
||||
if (!editorValue || !editorValue.trim()) {
|
||||
setSyncError(null);
|
||||
setSelections(new Set());
|
||||
setArgValues(new Map());
|
||||
setEnabledArgs(new Set());
|
||||
lastGeneratedValue.current = '';
|
||||
if (lastGeneratedVarNames.current.size > 0) {
|
||||
syncVariables({});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (syncDebounceRef.current) clearTimeout(syncDebounceRef.current);
|
||||
syncDebounceRef.current = setTimeout(() => {
|
||||
const normalized = normalizeQuery(editorValue);
|
||||
// Skip if this change was triggered by our own generation (prevents infinite loop)
|
||||
const queryUnchanged = normalized === lastGeneratedValue.current;
|
||||
const varsUnchanged = (variablesValue || '') === lastGeneratedVarsValue.current;
|
||||
|
||||
// If we're in an error state, try to recover
|
||||
if (syncErrorRef.current) {
|
||||
const validation = validateQueryForSync(editorValue);
|
||||
if (!validation.valid) {
|
||||
setSyncError(validation.error);
|
||||
return;
|
||||
}
|
||||
setSyncError(null);
|
||||
const state = parseQueryToState(editorValue, schema, variablesValue);
|
||||
if (state) {
|
||||
setSelections(state.selections);
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const p of state.expandedPaths) next.add(p);
|
||||
return next;
|
||||
});
|
||||
setArgValues(state.argValues);
|
||||
setEnabledArgs(state.enabledArgs);
|
||||
lastGeneratedValue.current = normalized;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (queryUnchanged && varsUnchanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!queryUnchanged) {
|
||||
const validation = validateQueryForSync(editorValue);
|
||||
if (!validation.valid) {
|
||||
setSyncError(validation.error);
|
||||
return;
|
||||
}
|
||||
setSyncError(null);
|
||||
}
|
||||
|
||||
// Skip sync if variables JSON is invalid (e.g. trailing comma while typing)
|
||||
if (!isValidJson(variablesValue)) return;
|
||||
|
||||
const state = parseQueryToState(editorValue, schema, variablesValue);
|
||||
if (!state) return;
|
||||
|
||||
// Only variables changed — just update arg values without re-parsing selections
|
||||
if (queryUnchanged) {
|
||||
setArgValues(state.argValues);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelections(state.selections);
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const p of state.expandedPaths) {
|
||||
next.add(p);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setArgValues(state.argValues);
|
||||
setEnabledArgs(state.enabledArgs);
|
||||
}, SYNC_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
if (syncDebounceRef.current) clearTimeout(syncDebounceRef.current);
|
||||
};
|
||||
}, [editorValue, schema, variablesValue]);
|
||||
|
||||
// Tree → Editor generation: when selections/args change via UI, generate a query string
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
|
||||
debounceRef.current = setTimeout(() => {
|
||||
if (!shouldGenerate.current) return;
|
||||
shouldGenerate.current = false;
|
||||
|
||||
// Cancel any pending editor→tree sync to avoid conflicts
|
||||
if (syncDebounceRef.current) {
|
||||
clearTimeout(syncDebounceRef.current);
|
||||
syncDebounceRef.current = null;
|
||||
}
|
||||
|
||||
if (schema && onQueryChange && selections.size > 0) {
|
||||
const currentEditorValue = editorValueRef.current;
|
||||
const existingNames = {};
|
||||
if (currentEditorValue) {
|
||||
const nameRegex = /(?:query|mutation|subscription)\s+(\w+)/g;
|
||||
let m;
|
||||
while ((m = nameRegex.exec(currentEditorValue)) !== null) {
|
||||
const op = currentEditorValue.slice(m.index).match(/^(query|mutation|subscription)/)[1];
|
||||
const rootKey = op.charAt(0).toUpperCase() + op.slice(1);
|
||||
existingNames[rootKey] = m[1];
|
||||
}
|
||||
}
|
||||
const queryParts = [];
|
||||
let allVariables = {};
|
||||
for (const rootType of availableRootTypes) {
|
||||
const result = generateQueryString(selections, argValues, schema, rootType, enabledArgs, existingNames[rootType]);
|
||||
if (result.query) {
|
||||
queryParts.push(result.query);
|
||||
Object.assign(allVariables, result.variables);
|
||||
}
|
||||
}
|
||||
|
||||
if (queryParts.length > 1) {
|
||||
setSyncError('multiple_operations');
|
||||
} else {
|
||||
setSyncError(null);
|
||||
}
|
||||
|
||||
const queryResult = prettifyQuery(queryParts.join('\n\n'));
|
||||
lastGeneratedValue.current = normalizeQuery(queryResult);
|
||||
onQueryChange(queryResult);
|
||||
|
||||
syncVariables(allVariables);
|
||||
} else {
|
||||
setSyncError(null);
|
||||
|
||||
if (onQueryChange && selections.size === 0 && lastGeneratedValue.current !== '') {
|
||||
lastGeneratedValue.current = '';
|
||||
onQueryChange('');
|
||||
|
||||
if (lastGeneratedVarNames.current.size > 0) {
|
||||
syncVariables({});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [selections, argValues, enabledArgs, schema, availableRootTypes, onQueryChange]);
|
||||
|
||||
// --- User action callbacks (stable refs, never recreated) ---
|
||||
|
||||
// Check/uncheck a field. Also cleans up args when unchecking, auto-expands non-leaf nodes.
|
||||
const toggleField = useCallback((path, field) => {
|
||||
shouldGenerate.current = true;
|
||||
const isUnchecking = selectionsRef.current.has(path);
|
||||
|
||||
if (isUnchecking) {
|
||||
setEnabledArgs((prev) => {
|
||||
const next = new Set(prev);
|
||||
deleteSetByPrefix(next, path);
|
||||
return next;
|
||||
});
|
||||
|
||||
setArgValues((prev) => {
|
||||
const next = new Map(prev);
|
||||
deleteMapByPrefix(next, path);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
setSelections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
for (const p of prev) {
|
||||
if (p.startsWith(path + '.')) {
|
||||
next.delete(p);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
next.add(path);
|
||||
ensureAncestorsSelected(next, path);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
const hasArgs = field && field.args && field.args.length > 0;
|
||||
if (field && (!field.isLeaf || hasArgs)) {
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (!prev.has(path)) {
|
||||
next.add(path);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
if (!isUnchecking && field && field.args && field.args.length > 0) {
|
||||
setEnabledArgs((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const arg of field.args) {
|
||||
const key = `${path}.${arg.name}`;
|
||||
if (arg.isRequired) {
|
||||
next.add(key);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Expand/collapse a tree node (no query generation)
|
||||
const toggleExpand = useCallback((path) => {
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Enable/disable an argument. Auto-selects the parent field when enabling.
|
||||
const toggleArg = useCallback((fieldPath, argName) => {
|
||||
shouldGenerate.current = true;
|
||||
const key = `${fieldPath}.${argName}`;
|
||||
const enabling = !enabledArgsRef.current.has(key);
|
||||
|
||||
setEnabledArgs((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (enabling) {
|
||||
next.add(key);
|
||||
} else {
|
||||
next.delete(key);
|
||||
deleteSetByPrefix(next, key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
if (enabling) {
|
||||
setSelections((prev) => {
|
||||
if (prev.has(fieldPath)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(fieldPath);
|
||||
ensureAncestorsSelected(next, fieldPath);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setArgValues((prev) => {
|
||||
const next = new Map(prev);
|
||||
deleteMapByPrefix(next, key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateArgValue = useCallback((key, value) => {
|
||||
shouldGenerate.current = true;
|
||||
setArgValues((prev) => {
|
||||
const next = new Map(prev);
|
||||
const isEmpty = value === '' || value === undefined
|
||||
|| (Array.isArray(value) && value.length === 0);
|
||||
if (isEmpty) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.set(key, value);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setArgValue = useCallback((fieldPath, argName, value) => {
|
||||
updateArgValue(`${fieldPath}.${argName}`, value);
|
||||
}, [updateArgValue]);
|
||||
|
||||
// Enable/disable a nested input object field, ensuring parent input fields are also enabled
|
||||
const toggleInputField = useCallback((fullKey, fieldPath) => {
|
||||
shouldGenerate.current = true;
|
||||
const enabling = !enabledArgsRef.current.has(fullKey);
|
||||
|
||||
setEnabledArgs((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (enabling) {
|
||||
next.add(fullKey);
|
||||
const suffix = fullKey.slice(fieldPath.length + 1);
|
||||
const parts = suffix.split('.');
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
next.add(`${fieldPath}.${parts.slice(0, i).join('.')}`);
|
||||
}
|
||||
} else {
|
||||
next.delete(fullKey);
|
||||
deleteSetByPrefix(next, fullKey);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
if (enabling) {
|
||||
setSelections((prev) => {
|
||||
if (prev.has(fieldPath)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(fieldPath);
|
||||
ensureAncestorsSelected(next, fieldPath);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setArgValues((prev) => {
|
||||
const next = new Map(prev);
|
||||
deleteMapByPrefix(next, fullKey);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setInputFieldValue = useCallback((fullKey, value) => {
|
||||
updateArgValue(fullKey, value);
|
||||
}, [updateArgValue]);
|
||||
|
||||
return {
|
||||
selections,
|
||||
expandedPaths,
|
||||
argValues,
|
||||
enabledArgs,
|
||||
availableRootTypes,
|
||||
syncError,
|
||||
toggleField,
|
||||
toggleExpand,
|
||||
toggleArg,
|
||||
setArgValue,
|
||||
toggleInputField,
|
||||
setInputFieldValue
|
||||
};
|
||||
}
|
||||
@@ -232,6 +232,34 @@ export const tabsSlice = createSlice({
|
||||
tab.scriptPaneTab = action.payload.scriptPaneTab;
|
||||
}
|
||||
},
|
||||
updateQueryBuilderOpen: (state, action) => {
|
||||
const tab = find(state.tabs, (t) => t.uid === action.payload.uid);
|
||||
|
||||
if (tab) {
|
||||
tab.queryBuilderOpen = action.payload.queryBuilderOpen;
|
||||
}
|
||||
},
|
||||
updateQueryBuilderWidth: (state, action) => {
|
||||
const tab = find(state.tabs, (t) => t.uid === action.payload.uid);
|
||||
|
||||
if (tab) {
|
||||
tab.queryBuilderWidth = action.payload.queryBuilderWidth;
|
||||
}
|
||||
},
|
||||
updateVariablesPaneOpen: (state, action) => {
|
||||
const tab = find(state.tabs, (t) => t.uid === action.payload.uid);
|
||||
|
||||
if (tab) {
|
||||
tab.variablesPaneOpen = action.payload.variablesPaneOpen;
|
||||
}
|
||||
},
|
||||
updateVariablesPaneHeight: (state, action) => {
|
||||
const tab = find(state.tabs, (t) => t.uid === action.payload.uid);
|
||||
|
||||
if (tab) {
|
||||
tab.variablesPaneHeight = action.payload.variablesPaneHeight;
|
||||
}
|
||||
},
|
||||
closeTabs: (state, action) => {
|
||||
const activeTab = find(state.tabs, (t) => t.uid === state.activeTabUid);
|
||||
const tabUids = action.payload.tabUids || [];
|
||||
@@ -335,7 +363,11 @@ export const {
|
||||
closeTabs,
|
||||
closeAllCollectionTabs,
|
||||
makeTabPermanent,
|
||||
reorderTabs
|
||||
reorderTabs,
|
||||
updateQueryBuilderOpen,
|
||||
updateQueryBuilderWidth,
|
||||
updateVariablesPaneOpen,
|
||||
updateVariablesPaneHeight
|
||||
} = tabsSlice.actions;
|
||||
|
||||
export default tabsSlice.reducer;
|
||||
|
||||
715
packages/bruno-app/src/utils/graphql/queryBuilder.js
Normal file
715
packages/bruno-app/src/utils/graphql/queryBuilder.js
Normal file
@@ -0,0 +1,715 @@
|
||||
import {
|
||||
isScalarType,
|
||||
isEnumType,
|
||||
isObjectType,
|
||||
isInterfaceType,
|
||||
isUnionType,
|
||||
isInputObjectType,
|
||||
isNonNullType,
|
||||
isListType,
|
||||
getNamedType,
|
||||
parse as gqlParse,
|
||||
print,
|
||||
Kind
|
||||
} from 'graphql';
|
||||
|
||||
const MAX_DEPTH = 7;
|
||||
|
||||
export const PLACEHOLDER = '__bruno_placeholder__';
|
||||
|
||||
const sanitizeQueryForParsing = (queryString) => {
|
||||
let sanitized = queryString.replace(/\(\s*\)/g, '');
|
||||
sanitized = sanitized.replace(/(:\s*)\{\s*\}/g, '$1{ __empty: true }');
|
||||
sanitized = sanitized.replace(/\{\s*\}/g, `{ ${PLACEHOLDER} }`);
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
const resolveRootType = (schema, rootTypeName) => {
|
||||
switch (rootTypeName) {
|
||||
case 'Query': return schema.getQueryType();
|
||||
case 'Mutation': return schema.getMutationType();
|
||||
case 'Subscription': return schema.getSubscriptionType();
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeLabel = (type) => {
|
||||
if (isNonNullType(type)) return `${getTypeLabel(type.ofType)}!`;
|
||||
if (isListType(type)) return `[${getTypeLabel(type.ofType)}]`;
|
||||
return type.name;
|
||||
};
|
||||
|
||||
const isLeafType = (type) => {
|
||||
const named = getNamedType(type);
|
||||
return isScalarType(named) || isEnumType(named);
|
||||
};
|
||||
|
||||
const containsListType = (type) => {
|
||||
if (isListType(type)) return true;
|
||||
if (isNonNullType(type)) return containsListType(type.ofType);
|
||||
return false;
|
||||
};
|
||||
|
||||
const typeToAST = (type) => {
|
||||
if (isNonNullType(type)) {
|
||||
return { kind: Kind.NON_NULL_TYPE, type: typeToAST(type.ofType) };
|
||||
}
|
||||
if (isListType(type)) {
|
||||
return { kind: Kind.LIST_TYPE, type: typeToAST(type.ofType) };
|
||||
}
|
||||
return { kind: Kind.NAMED_TYPE, name: { kind: Kind.NAME, value: type.name } };
|
||||
};
|
||||
|
||||
const buildTypeDescriptor = (field) => {
|
||||
const named = getNamedType(field.type);
|
||||
return {
|
||||
name: field.name,
|
||||
type: field.type,
|
||||
namedType: named,
|
||||
typeLabel: getTypeLabel(field.type),
|
||||
description: field.description || null,
|
||||
isRequired: isNonNullType(field.type),
|
||||
isEnum: isEnumType(named),
|
||||
enumValues: isEnumType(named) ? named.getValues().map((v) => v.value) : null,
|
||||
isBoolean: named?.name === 'Boolean',
|
||||
isInputObject: isInputObjectType(named),
|
||||
isList: containsListType(field.type)
|
||||
};
|
||||
};
|
||||
|
||||
const buildFieldDescriptor = (field, parentPath) => {
|
||||
const named = getNamedType(field.type);
|
||||
const path = parentPath ? `${parentPath}.${field.name}` : field.name;
|
||||
|
||||
return {
|
||||
name: field.name,
|
||||
path,
|
||||
type: field.type,
|
||||
namedType: named,
|
||||
typeLabel: getTypeLabel(field.type),
|
||||
isLeaf: isLeafType(field.type),
|
||||
isDeprecated: field.isDeprecated || false,
|
||||
deprecationReason: field.deprecationReason || null,
|
||||
description: field.description || null,
|
||||
args: (field.args || []).map((arg) => ({
|
||||
...buildTypeDescriptor(arg),
|
||||
defaultValue: arg.defaultValue
|
||||
}))
|
||||
};
|
||||
};
|
||||
|
||||
export const getFieldChildren = (namedType, parentPath) => {
|
||||
if (!namedType) {
|
||||
return { fields: [] };
|
||||
}
|
||||
|
||||
if (isObjectType(namedType) || isInterfaceType(namedType)) {
|
||||
const fieldMap = namedType.getFields();
|
||||
const fields = Object.values(fieldMap).map((field) => buildFieldDescriptor(field, parentPath));
|
||||
return { fields };
|
||||
}
|
||||
|
||||
if (isUnionType(namedType)) {
|
||||
const types = namedType.getTypes();
|
||||
const unionTypes = types.map((type) => ({
|
||||
name: type.name,
|
||||
path: `${parentPath}.__on_${type.name}`,
|
||||
type,
|
||||
namedType: type,
|
||||
isUnionMember: true
|
||||
}));
|
||||
return { fields: [], unionTypes };
|
||||
}
|
||||
|
||||
return { fields: [] };
|
||||
};
|
||||
|
||||
export const getInputObjectFields = (namedType) => {
|
||||
if (!namedType || !isInputObjectType(namedType)) return [];
|
||||
const fieldMap = namedType.getFields();
|
||||
return Object.values(fieldMap).map(buildTypeDescriptor);
|
||||
};
|
||||
|
||||
export const getRootFields = (schema, rootTypeName) => {
|
||||
if (!schema) return [];
|
||||
const rootType = resolveRootType(schema, rootTypeName);
|
||||
if (!rootType) return [];
|
||||
const fieldMap = rootType.getFields();
|
||||
return Object.values(fieldMap).map((field) => buildFieldDescriptor(field, rootTypeName));
|
||||
};
|
||||
|
||||
export const getAvailableRootTypes = (schema) => {
|
||||
if (!schema) return [];
|
||||
const types = [];
|
||||
if (schema.getQueryType()) types.push('Query');
|
||||
if (schema.getMutationType()) types.push('Mutation');
|
||||
if (schema.getSubscriptionType()) types.push('Subscription');
|
||||
return types;
|
||||
};
|
||||
|
||||
const collectVariablesForInputObject = (parentKey, inputType, enabledArgs, varMap, usedNames) => {
|
||||
if (!inputType || !isInputObjectType(inputType)) return;
|
||||
const fieldMap = inputType.getFields();
|
||||
|
||||
for (const [fieldName, field] of Object.entries(fieldMap)) {
|
||||
const fieldKey = `${parentKey}.${fieldName}`;
|
||||
if (!enabledArgs.has(fieldKey)) continue;
|
||||
|
||||
const named = getNamedType(field.type);
|
||||
if (isInputObjectType(named)) {
|
||||
collectVariablesForInputObject(fieldKey, named, enabledArgs, varMap, usedNames);
|
||||
} else {
|
||||
let varName = fieldName;
|
||||
if (usedNames.has(varName)) {
|
||||
const parts = parentKey.split('.');
|
||||
varName = `${parts[parts.length - 1]}_${fieldName}`;
|
||||
let i = 2;
|
||||
while (usedNames.has(varName)) {
|
||||
varName = `${fieldName}_${i}`;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
usedNames.add(varName);
|
||||
varMap.set(fieldKey, { varName, type: field.type });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const collectVariablesFromSelections = (selections, enabledArgs, type, parentPath, visited, depth, varMap, usedNames) => {
|
||||
if (!type || depth > MAX_DEPTH || visited.has(type.name)) return;
|
||||
|
||||
const nextVisited = new Set(visited);
|
||||
nextVisited.add(type.name);
|
||||
|
||||
if (isUnionType(type)) {
|
||||
for (const memberType of type.getTypes()) {
|
||||
const memberPath = `${parentPath}.__on_${memberType.name}`;
|
||||
let isMemberSelected = selections.has(memberPath);
|
||||
if (!isMemberSelected) {
|
||||
for (const s of selections) {
|
||||
if (s.startsWith(memberPath + '.')) {
|
||||
isMemberSelected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!isMemberSelected) continue;
|
||||
collectVariablesFromSelections(selections, enabledArgs, memberType, memberPath, nextVisited, depth + 1, varMap, usedNames);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isObjectType(type) && !isInterfaceType(type)) return;
|
||||
|
||||
const fieldMap = type.getFields();
|
||||
for (const [fieldName, field] of Object.entries(fieldMap)) {
|
||||
const fieldPath = `${parentPath}.${fieldName}`;
|
||||
if (!selections.has(fieldPath)) continue;
|
||||
|
||||
if (field.args) {
|
||||
for (const arg of field.args) {
|
||||
const argKey = `${fieldPath}.${arg.name}`;
|
||||
if (!enabledArgs || !enabledArgs.has(argKey)) continue;
|
||||
|
||||
const named = getNamedType(arg.type);
|
||||
if (isInputObjectType(named)) {
|
||||
collectVariablesForInputObject(argKey, named, enabledArgs, varMap, usedNames);
|
||||
} else {
|
||||
let varName = arg.name;
|
||||
if (usedNames.has(varName)) {
|
||||
varName = `${fieldName}_${arg.name}`;
|
||||
let i = 2;
|
||||
while (usedNames.has(varName)) {
|
||||
varName = `${fieldName}_${arg.name}_${i}`;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
usedNames.add(varName);
|
||||
varMap.set(argKey, { varName, type: arg.type });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const named = getNamedType(field.type);
|
||||
if (!isLeafType(field.type) && named) {
|
||||
collectVariablesFromSelections(selections, enabledArgs, named, fieldPath, nextVisited, depth + 1, varMap, usedNames);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const coerceScalarValue = (value, namedType) => {
|
||||
if (value === undefined || value === '' || value === null) return null;
|
||||
if (namedType.name === 'Boolean') return value === true || value === 'true';
|
||||
if (namedType.name === 'Int') {
|
||||
const n = parseInt(value, 10);
|
||||
return isNaN(n) ? value : n;
|
||||
}
|
||||
if (namedType.name === 'Float') {
|
||||
const n = parseFloat(value);
|
||||
return isNaN(n) ? value : n;
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const buildVariableValue = (argKey, argType, argValues) => {
|
||||
const named = getNamedType(argType);
|
||||
const raw = argValues.get(argKey);
|
||||
if (Array.isArray(raw)) {
|
||||
const items = raw.map((v) => coerceScalarValue(v, named)).filter((v) => v !== null);
|
||||
return items.length > 0 ? items : null;
|
||||
}
|
||||
return coerceScalarValue(raw, named);
|
||||
};
|
||||
|
||||
const placeholderSelectionSet = () => ({
|
||||
kind: Kind.SELECTION_SET,
|
||||
selections: [
|
||||
{
|
||||
kind: Kind.FIELD,
|
||||
name: { kind: Kind.NAME, value: PLACEHOLDER }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const buildSelectionSetAST = (selections, namedType, parentPath, visited, depth, enabledArgs, varMap) => {
|
||||
if (!namedType || depth > MAX_DEPTH || visited.has(namedType.name)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextVisited = new Set(visited);
|
||||
nextVisited.add(namedType.name);
|
||||
|
||||
if (isUnionType(namedType)) {
|
||||
return buildUnionSelectionSet(selections, namedType, parentPath, nextVisited, depth, enabledArgs, varMap);
|
||||
}
|
||||
|
||||
if (!isObjectType(namedType) && !isInterfaceType(namedType)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fieldMap = namedType.getFields();
|
||||
const fieldSelections = [];
|
||||
|
||||
for (const [fieldName, field] of Object.entries(fieldMap)) {
|
||||
const fieldPath = parentPath ? `${parentPath}.${fieldName}` : fieldName;
|
||||
if (!selections.has(fieldPath)) continue;
|
||||
|
||||
const fieldAST = buildFieldAST(selections, field, fieldPath, nextVisited, depth, enabledArgs, varMap);
|
||||
if (fieldAST) {
|
||||
fieldSelections.push(fieldAST);
|
||||
}
|
||||
}
|
||||
|
||||
if (fieldSelections.length === 0) return null;
|
||||
|
||||
return {
|
||||
kind: Kind.SELECTION_SET,
|
||||
selections: fieldSelections
|
||||
};
|
||||
};
|
||||
|
||||
const buildFieldAST = (selections, field, fieldPath, visited, depth, enabledArgs, varMap) => {
|
||||
const named = getNamedType(field.type);
|
||||
const args = buildArgumentsAST(field, fieldPath, enabledArgs, varMap);
|
||||
|
||||
let selectionSet = null;
|
||||
if (!isLeafType(field.type)) {
|
||||
selectionSet = buildSelectionSetAST(selections, named, fieldPath, visited, depth + 1, enabledArgs, varMap);
|
||||
if (!selectionSet) {
|
||||
selectionSet = placeholderSelectionSet();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: Kind.FIELD,
|
||||
name: { kind: Kind.NAME, value: field.name },
|
||||
arguments: args.length > 0 ? args : undefined,
|
||||
selectionSet: selectionSet || undefined
|
||||
};
|
||||
};
|
||||
|
||||
const buildInputObjectWithVariables = (parentKey, inputType, enabledArgs, varMap) => {
|
||||
if (!inputType || !isInputObjectType(inputType)) return null;
|
||||
const fieldMap = inputType.getFields();
|
||||
const fields = [];
|
||||
|
||||
for (const [fieldName, field] of Object.entries(fieldMap)) {
|
||||
const fieldKey = `${parentKey}.${fieldName}`;
|
||||
if (!enabledArgs.has(fieldKey)) continue;
|
||||
|
||||
const named = getNamedType(field.type);
|
||||
if (isInputObjectType(named)) {
|
||||
const nestedObj = buildInputObjectWithVariables(fieldKey, named, enabledArgs, varMap);
|
||||
if (nestedObj) {
|
||||
fields.push({
|
||||
kind: Kind.OBJECT_FIELD,
|
||||
name: { kind: Kind.NAME, value: fieldName },
|
||||
value: nestedObj
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const varInfo = varMap.get(fieldKey);
|
||||
if (varInfo) {
|
||||
fields.push({
|
||||
kind: Kind.OBJECT_FIELD,
|
||||
name: { kind: Kind.NAME, value: fieldName },
|
||||
value: { kind: Kind.VARIABLE, name: { kind: Kind.NAME, value: varInfo.varName } }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fields.length === 0) return null;
|
||||
return { kind: Kind.OBJECT, fields };
|
||||
};
|
||||
|
||||
const buildArgumentsAST = (field, fieldPath, enabledArgs, varMap) => {
|
||||
if (!field.args || field.args.length === 0) return [];
|
||||
|
||||
const args = [];
|
||||
for (const arg of field.args) {
|
||||
const key = `${fieldPath}.${arg.name}`;
|
||||
if (enabledArgs && !enabledArgs.has(key)) continue;
|
||||
|
||||
const named = getNamedType(arg.type);
|
||||
if (isInputObjectType(named)) {
|
||||
const objValue = buildInputObjectWithVariables(key, named, enabledArgs, varMap);
|
||||
if (objValue) {
|
||||
args.push({
|
||||
kind: Kind.ARGUMENT,
|
||||
name: { kind: Kind.NAME, value: arg.name },
|
||||
value: objValue
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const varInfo = varMap.get(key);
|
||||
if (varInfo) {
|
||||
args.push({
|
||||
kind: Kind.ARGUMENT,
|
||||
name: { kind: Kind.NAME, value: arg.name },
|
||||
value: { kind: Kind.VARIABLE, name: { kind: Kind.NAME, value: varInfo.varName } }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return args;
|
||||
};
|
||||
|
||||
const buildUnionSelectionSet = (selections, unionType, parentPath, visited, depth, enabledArgs, varMap) => {
|
||||
const memberTypes = unionType.getTypes();
|
||||
const inlineFragments = [];
|
||||
|
||||
for (const memberType of memberTypes) {
|
||||
const memberPath = `${parentPath}.__on_${memberType.name}`;
|
||||
let isMemberSelected = selections.has(memberPath);
|
||||
if (!isMemberSelected) {
|
||||
for (const s of selections) {
|
||||
if (s.startsWith(memberPath + '.')) {
|
||||
isMemberSelected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isMemberSelected) continue;
|
||||
|
||||
let selectionSet = buildSelectionSetAST(selections, memberType, memberPath, visited, depth + 1, enabledArgs, varMap);
|
||||
if (!selectionSet) {
|
||||
selectionSet = placeholderSelectionSet();
|
||||
}
|
||||
inlineFragments.push({
|
||||
kind: Kind.INLINE_FRAGMENT,
|
||||
typeCondition: {
|
||||
kind: Kind.NAMED_TYPE,
|
||||
name: { kind: Kind.NAME, value: memberType.name }
|
||||
},
|
||||
selectionSet
|
||||
});
|
||||
}
|
||||
|
||||
if (inlineFragments.length === 0) return null;
|
||||
|
||||
return {
|
||||
kind: Kind.SELECTION_SET,
|
||||
selections: inlineFragments
|
||||
};
|
||||
};
|
||||
|
||||
export const generateQueryString = (selections, argValues, schema, rootTypeName, enabledArgs, existingOperationName) => {
|
||||
if (!schema || !selections || selections.size === 0) return { query: '', variables: {} };
|
||||
const rootType = resolveRootType(schema, rootTypeName);
|
||||
if (!rootType) return { query: '', variables: {} };
|
||||
|
||||
const varMap = new Map();
|
||||
const usedNames = new Set();
|
||||
collectVariablesFromSelections(selections, enabledArgs, rootType, rootTypeName, new Set(), 0, varMap, usedNames);
|
||||
|
||||
const selectionSet = buildSelectionSetAST(selections, rootType, rootTypeName, new Set(), 0, enabledArgs, varMap);
|
||||
if (!selectionSet) return { query: '', variables: {} };
|
||||
|
||||
const operation = rootTypeName === 'Query' ? 'query' : rootTypeName === 'Mutation' ? 'mutation' : 'subscription';
|
||||
|
||||
let operationName = existingOperationName;
|
||||
if (!operationName) {
|
||||
const firstField = selectionSet.selections.find((s) => s.kind === Kind.FIELD);
|
||||
operationName = firstField
|
||||
? firstField.name.value.charAt(0).toUpperCase() + firstField.name.value.slice(1)
|
||||
: rootTypeName;
|
||||
}
|
||||
|
||||
const variableDefinitions = [];
|
||||
const variableValues = {};
|
||||
for (const [argKey, { varName, type }] of varMap) {
|
||||
variableDefinitions.push({
|
||||
kind: Kind.VARIABLE_DEFINITION,
|
||||
variable: { kind: Kind.VARIABLE, name: { kind: Kind.NAME, value: varName } },
|
||||
type: typeToAST(type)
|
||||
});
|
||||
const val = buildVariableValue(argKey, type, argValues);
|
||||
if (val !== null && val !== undefined) {
|
||||
variableValues[varName] = val;
|
||||
}
|
||||
}
|
||||
|
||||
const document = {
|
||||
kind: Kind.DOCUMENT,
|
||||
definitions: [
|
||||
{
|
||||
kind: Kind.OPERATION_DEFINITION,
|
||||
operation,
|
||||
name: { kind: Kind.NAME, value: operationName },
|
||||
variableDefinitions: variableDefinitions.length > 0 ? variableDefinitions : undefined,
|
||||
selectionSet
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
let result = print(document);
|
||||
result = result.replace(new RegExp(`^\\s*${PLACEHOLDER}\\n`, 'gm'), '');
|
||||
return { query: result, variables: variableValues };
|
||||
};
|
||||
|
||||
export const validateQueryForSync = (queryString) => {
|
||||
if (!queryString || !queryString.trim()) {
|
||||
return { valid: true, error: null };
|
||||
}
|
||||
|
||||
let doc;
|
||||
try {
|
||||
doc = gqlParse(sanitizeQueryForParsing(queryString));
|
||||
} catch {
|
||||
return { valid: false, error: null };
|
||||
}
|
||||
|
||||
const operations = doc.definitions.filter((d) => d.kind === Kind.OPERATION_DEFINITION);
|
||||
if (operations.length === 0) {
|
||||
return { valid: false, error: null };
|
||||
}
|
||||
|
||||
if (operations.length > 1) {
|
||||
return { valid: false, error: 'multiple_operations' };
|
||||
}
|
||||
|
||||
return { valid: true, error: null };
|
||||
};
|
||||
|
||||
export const parseQueryToState = (queryString, schema, variablesString) => {
|
||||
if (!schema) return null;
|
||||
|
||||
if (!queryString || !queryString.trim()) {
|
||||
return { selections: new Set(), expandedPaths: new Set(), argValues: new Map(), enabledArgs: new Set() };
|
||||
}
|
||||
|
||||
let doc;
|
||||
try {
|
||||
doc = gqlParse(sanitizeQueryForParsing(queryString));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let variablesJson = {};
|
||||
if (variablesString) {
|
||||
try {
|
||||
variablesJson = JSON.parse(variablesString);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const selections = new Set();
|
||||
const expandedPaths = new Set();
|
||||
const argValues = new Map();
|
||||
const enabledArgs = new Set();
|
||||
|
||||
for (const def of doc.definitions) {
|
||||
if (def.kind !== Kind.OPERATION_DEFINITION) continue;
|
||||
const rootTypeName = def.operation.charAt(0).toUpperCase() + def.operation.slice(1);
|
||||
const rootType = resolveRootType(schema, rootTypeName);
|
||||
if (!rootType || !def.selectionSet) continue;
|
||||
walkSelectionSet(def.selectionSet, rootType, rootTypeName, selections, expandedPaths, argValues, enabledArgs, variablesJson, schema);
|
||||
}
|
||||
|
||||
return { selections, expandedPaths, argValues, enabledArgs };
|
||||
};
|
||||
|
||||
const walkInputObjectValue = (valueNode, inputType, parentKey, argValues, enabledArgs, variablesJson) => {
|
||||
const objNode = valueNode.kind === Kind.LIST && valueNode.values.length > 0
|
||||
? valueNode.values[0]
|
||||
: valueNode;
|
||||
|
||||
if (objNode.kind !== Kind.OBJECT || !isInputObjectType(inputType)) return;
|
||||
|
||||
const fieldMap = inputType.getFields();
|
||||
|
||||
for (const astField of objNode.fields) {
|
||||
const fieldName = astField.name.value;
|
||||
if (fieldName === '__empty') continue;
|
||||
const fieldKey = `${parentKey}.${fieldName}`;
|
||||
enabledArgs.add(fieldKey);
|
||||
|
||||
const fieldDef = fieldMap[fieldName];
|
||||
const fieldNamed = fieldDef ? getNamedType(fieldDef.type) : null;
|
||||
|
||||
if (fieldNamed && isInputObjectType(fieldNamed)) {
|
||||
walkInputObjectValue(astField.value, fieldNamed, fieldKey, argValues, enabledArgs, variablesJson);
|
||||
} else if (astField.value.kind === Kind.VARIABLE) {
|
||||
const varName = astField.value.name.value;
|
||||
const varValue = variablesJson[varName];
|
||||
if (varValue !== undefined && varValue !== null) {
|
||||
argValues.set(fieldKey, String(varValue));
|
||||
}
|
||||
} else {
|
||||
const value = astValueToString(astField.value);
|
||||
if (value !== null && value !== '') {
|
||||
argValues.set(fieldKey, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const walkVariableInputObject = (value, inputType, parentKey, argValues, enabledArgs) => {
|
||||
if (!value || typeof value !== 'object' || !isInputObjectType(inputType)) return;
|
||||
|
||||
const obj = Array.isArray(value) ? value[0] : value;
|
||||
if (!obj || typeof obj !== 'object') return;
|
||||
|
||||
const fieldMap = inputType.getFields();
|
||||
for (const [fieldName, fieldValue] of Object.entries(obj)) {
|
||||
const fieldKey = `${parentKey}.${fieldName}`;
|
||||
enabledArgs.add(fieldKey);
|
||||
|
||||
const fieldDef = fieldMap[fieldName];
|
||||
if (!fieldDef) continue;
|
||||
const fieldNamed = getNamedType(fieldDef.type);
|
||||
|
||||
if (isInputObjectType(fieldNamed)) {
|
||||
walkVariableInputObject(fieldValue, fieldNamed, fieldKey, argValues, enabledArgs);
|
||||
} else if (fieldValue !== null && fieldValue !== undefined) {
|
||||
argValues.set(fieldKey, String(fieldValue));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const walkSelectionSet = (selectionSet, parentType, parentPath, selections, expandedPaths, argValues, enabledArgs, variablesJson, schema, depth = 0) => {
|
||||
if (!selectionSet || !selectionSet.selections || depth > MAX_DEPTH) return;
|
||||
|
||||
const fieldMap = (isObjectType(parentType) || isInterfaceType(parentType))
|
||||
? parentType.getFields()
|
||||
: null;
|
||||
|
||||
for (const sel of selectionSet.selections) {
|
||||
if (sel.kind === Kind.FIELD) {
|
||||
const fieldName = sel.name.value;
|
||||
if (fieldName === '__typename' || fieldName === PLACEHOLDER) continue;
|
||||
const fieldPath = parentPath ? `${parentPath}.${fieldName}` : fieldName;
|
||||
|
||||
selections.add(fieldPath);
|
||||
|
||||
if (sel.arguments && sel.arguments.length > 0 && fieldMap && fieldMap[fieldName]) {
|
||||
for (const argNode of sel.arguments) {
|
||||
const argKey = `${fieldPath}.${argNode.name.value}`;
|
||||
enabledArgs.add(argKey);
|
||||
|
||||
const argDef = fieldMap[fieldName].args.find((a) => a.name === argNode.name.value);
|
||||
const argNamed = argDef ? getNamedType(argDef.type) : null;
|
||||
|
||||
if (argNode.value.kind === Kind.VARIABLE) {
|
||||
const varName = argNode.value.name.value;
|
||||
const varValue = variablesJson[varName];
|
||||
if (argNamed && isInputObjectType(argNamed) && typeof varValue === 'object' && varValue !== null) {
|
||||
walkVariableInputObject(varValue, argNamed, argKey, argValues, enabledArgs);
|
||||
} else if (Array.isArray(varValue)) {
|
||||
argValues.set(argKey, varValue.map(String));
|
||||
} else if (varValue !== undefined && varValue !== null) {
|
||||
argValues.set(argKey, String(varValue));
|
||||
}
|
||||
} else if (argNamed && isInputObjectType(argNamed) && (argNode.value.kind === Kind.OBJECT || argNode.value.kind === Kind.LIST)) {
|
||||
walkInputObjectValue(argNode.value, argNamed, argKey, argValues, enabledArgs, variablesJson);
|
||||
} else if (argDef && containsListType(argDef.type) && argNode.value.kind === Kind.LIST) {
|
||||
// List-type scalar/enum args: store as array
|
||||
const items = argNode.value.values
|
||||
.map(astValueToString)
|
||||
.filter((v) => v !== null && v !== '');
|
||||
if (items.length > 0) {
|
||||
argValues.set(argKey, items);
|
||||
}
|
||||
} else {
|
||||
const value = astValueToString(argNode.value);
|
||||
if (value !== null && value !== '') {
|
||||
argValues.set(argKey, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sel.selectionSet && fieldMap && fieldMap[fieldName]) {
|
||||
expandedPaths.add(fieldPath);
|
||||
const named = getNamedType(fieldMap[fieldName].type);
|
||||
if (named) {
|
||||
walkSelectionSet(sel.selectionSet, named, fieldPath, selections, expandedPaths, argValues, enabledArgs, variablesJson, schema, depth + 1);
|
||||
}
|
||||
}
|
||||
} else if (sel.kind === Kind.INLINE_FRAGMENT) {
|
||||
const typeName = sel.typeCondition?.name?.value;
|
||||
if (typeName) {
|
||||
const memberPath = `${parentPath}.__on_${typeName}`;
|
||||
selections.add(memberPath);
|
||||
expandedPaths.add(memberPath);
|
||||
|
||||
// For unions, find the member type. For object/interface types with inline fragments, look up from schema.
|
||||
const named = getNamedType(parentType);
|
||||
const memberType = named?.getTypes?.()?.find((t) => t.name === typeName)
|
||||
|| schema.getType(typeName);
|
||||
if (memberType && sel.selectionSet) {
|
||||
walkSelectionSet(sel.selectionSet, memberType, memberPath, selections, expandedPaths, argValues, enabledArgs, variablesJson, schema, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const astValueToString = (valueNode) => {
|
||||
if (!valueNode) return null;
|
||||
switch (valueNode.kind) {
|
||||
case Kind.NULL:
|
||||
return '';
|
||||
case Kind.STRING:
|
||||
return valueNode.value;
|
||||
case Kind.INT:
|
||||
case Kind.FLOAT:
|
||||
return valueNode.value;
|
||||
case Kind.BOOLEAN:
|
||||
return String(valueNode.value);
|
||||
case Kind.ENUM:
|
||||
return valueNode.value;
|
||||
case Kind.LIST:
|
||||
return JSON.stringify(valueNode.values.map(astValueToString));
|
||||
case Kind.OBJECT: {
|
||||
const obj = {};
|
||||
for (const field of valueNode.fields) {
|
||||
obj[field.name.value] = astValueToString(field.value);
|
||||
}
|
||||
return JSON.stringify(obj);
|
||||
}
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
664
packages/bruno-app/src/utils/graphql/queryBuilder.spec.js
Normal file
664
packages/bruno-app/src/utils/graphql/queryBuilder.spec.js
Normal file
@@ -0,0 +1,664 @@
|
||||
const { describe, it, expect } = require('@jest/globals');
|
||||
const { buildSchema } = require('graphql');
|
||||
|
||||
import {
|
||||
getAvailableRootTypes,
|
||||
getRootFields,
|
||||
getFieldChildren,
|
||||
getInputObjectFields,
|
||||
generateQueryString,
|
||||
validateQueryForSync,
|
||||
parseQueryToState
|
||||
} from './queryBuilder';
|
||||
|
||||
const BASIC_SCHEMA = buildSchema(`
|
||||
type Query {
|
||||
user(id: ID!): User
|
||||
users(limit: Int, offset: Int): [User!]!
|
||||
post(id: ID!): Post
|
||||
search(query: String!): SearchResult
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
createUser(input: CreateUserInput!): User
|
||||
deleteUser(id: ID!): Boolean
|
||||
}
|
||||
|
||||
type User {
|
||||
id: ID!
|
||||
name: String!
|
||||
email: String
|
||||
age: Int
|
||||
active: Boolean
|
||||
role: Role
|
||||
posts: [Post!]
|
||||
friends: [User!]
|
||||
}
|
||||
|
||||
type Post {
|
||||
id: ID!
|
||||
title: String!
|
||||
body: String
|
||||
author: User!
|
||||
comments: [Comment!]
|
||||
}
|
||||
|
||||
type Comment {
|
||||
id: ID!
|
||||
text: String!
|
||||
author: User!
|
||||
}
|
||||
|
||||
enum Role {
|
||||
ADMIN
|
||||
USER
|
||||
MODERATOR
|
||||
}
|
||||
|
||||
union SearchResult = User | Post
|
||||
|
||||
input CreateUserInput {
|
||||
name: String!
|
||||
email: String!
|
||||
age: Int
|
||||
address: AddressInput
|
||||
}
|
||||
|
||||
input AddressInput {
|
||||
street: String
|
||||
city: String!
|
||||
zip: String
|
||||
}
|
||||
`);
|
||||
|
||||
describe('queryBuilder', () => {
|
||||
describe('getAvailableRootTypes', () => {
|
||||
it('should return available root types from schema', () => {
|
||||
const types = getAvailableRootTypes(BASIC_SCHEMA);
|
||||
expect(types).toEqual(['Query', 'Mutation']);
|
||||
});
|
||||
|
||||
it('should return empty array when schema is null', () => {
|
||||
expect(getAvailableRootTypes(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return only Query when no mutation exists', () => {
|
||||
const schema = buildSchema(`type Query { hello: String }`);
|
||||
expect(getAvailableRootTypes(schema)).toEqual(['Query']);
|
||||
});
|
||||
|
||||
it('should include Subscription when present', () => {
|
||||
const schema = buildSchema(`
|
||||
type Query { hello: String }
|
||||
type Subscription { onMessage: String }
|
||||
`);
|
||||
expect(getAvailableRootTypes(schema)).toEqual(['Query', 'Subscription']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRootFields', () => {
|
||||
it('should return all query fields', () => {
|
||||
const fields = getRootFields(BASIC_SCHEMA, 'Query');
|
||||
const names = fields.map((f) => f.name);
|
||||
expect(names).toEqual(['user', 'users', 'post', 'search']);
|
||||
});
|
||||
|
||||
it('should return all mutation fields', () => {
|
||||
const fields = getRootFields(BASIC_SCHEMA, 'Mutation');
|
||||
const names = fields.map((f) => f.name);
|
||||
expect(names).toEqual(['createUser', 'deleteUser']);
|
||||
});
|
||||
|
||||
it('should return empty for non-existent root type', () => {
|
||||
expect(getRootFields(BASIC_SCHEMA, 'Subscription')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty when schema is null', () => {
|
||||
expect(getRootFields(null, 'Query')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should build correct field descriptors', () => {
|
||||
const fields = getRootFields(BASIC_SCHEMA, 'Query');
|
||||
const userField = fields.find((f) => f.name === 'user');
|
||||
|
||||
expect(userField.path).toBe('Query.user');
|
||||
expect(userField.isLeaf).toBe(false);
|
||||
expect(userField.typeLabel).toBe('User');
|
||||
expect(userField.args).toHaveLength(1);
|
||||
expect(userField.args[0].name).toBe('id');
|
||||
expect(userField.args[0].typeLabel).toBe('ID!');
|
||||
expect(userField.args[0].isRequired).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify leaf fields correctly', () => {
|
||||
const schema = buildSchema(`type Query { name: String!, count: Int }`);
|
||||
const fields = getRootFields(schema, 'Query');
|
||||
expect(fields[0].isLeaf).toBe(true);
|
||||
expect(fields[1].isLeaf).toBe(true);
|
||||
});
|
||||
|
||||
it('should build descriptors with enum info', () => {
|
||||
const fields = getRootFields(BASIC_SCHEMA, 'Query');
|
||||
const userField = fields.find((f) => f.name === 'user');
|
||||
const userChildren = getFieldChildren(userField.namedType, userField.path);
|
||||
const roleField = userChildren.fields.find((f) => f.name === 'role');
|
||||
// Role is an enum, so it's a leaf
|
||||
expect(roleField.isLeaf).toBe(true);
|
||||
});
|
||||
|
||||
it('should build args with isInputObject for input object args', () => {
|
||||
const fields = getRootFields(BASIC_SCHEMA, 'Mutation');
|
||||
const createUser = fields.find((f) => f.name === 'createUser');
|
||||
expect(createUser.args[0].name).toBe('input');
|
||||
expect(createUser.args[0].isInputObject).toBe(true);
|
||||
expect(createUser.args[0].typeLabel).toBe('CreateUserInput!');
|
||||
});
|
||||
|
||||
it('should build args with boolean detection', () => {
|
||||
const schema = buildSchema(`type Query { flag(active: Boolean): String }`);
|
||||
const fields = getRootFields(schema, 'Query');
|
||||
expect(fields[0].args[0].isBoolean).toBe(true);
|
||||
});
|
||||
|
||||
it('should build args with enum info', () => {
|
||||
const schema = buildSchema(`
|
||||
type Query { users(role: Role): [String] }
|
||||
enum Role { ADMIN USER }
|
||||
`);
|
||||
const fields = getRootFields(schema, 'Query');
|
||||
expect(fields[0].args[0].isEnum).toBe(true);
|
||||
expect(fields[0].args[0].enumValues).toEqual(['ADMIN', 'USER']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFieldChildren', () => {
|
||||
it('should return children of an object type', () => {
|
||||
const fields = getRootFields(BASIC_SCHEMA, 'Query');
|
||||
const userField = fields.find((f) => f.name === 'user');
|
||||
const children = getFieldChildren(userField.namedType, 'Query.user');
|
||||
|
||||
expect(children.fields.length).toBeGreaterThan(0);
|
||||
const names = children.fields.map((f) => f.name);
|
||||
expect(names).toContain('id');
|
||||
expect(names).toContain('name');
|
||||
expect(names).toContain('email');
|
||||
expect(names).toContain('posts');
|
||||
expect(names).toContain('friends');
|
||||
});
|
||||
|
||||
it('should build correct paths for children', () => {
|
||||
const fields = getRootFields(BASIC_SCHEMA, 'Query');
|
||||
const userField = fields.find((f) => f.name === 'user');
|
||||
const children = getFieldChildren(userField.namedType, 'Query.user');
|
||||
|
||||
const nameChild = children.fields.find((f) => f.name === 'name');
|
||||
expect(nameChild.path).toBe('Query.user.name');
|
||||
});
|
||||
|
||||
it('should return union types for union fields', () => {
|
||||
const fields = getRootFields(BASIC_SCHEMA, 'Query');
|
||||
const searchField = fields.find((f) => f.name === 'search');
|
||||
const children = getFieldChildren(searchField.namedType, 'Query.search');
|
||||
|
||||
expect(children.fields).toEqual([]);
|
||||
expect(children.unionTypes).toHaveLength(2);
|
||||
expect(children.unionTypes[0].name).toBe('User');
|
||||
expect(children.unionTypes[0].path).toBe('Query.search.__on_User');
|
||||
expect(children.unionTypes[0].isUnionMember).toBe(true);
|
||||
expect(children.unionTypes[1].name).toBe('Post');
|
||||
expect(children.unionTypes[1].path).toBe('Query.search.__on_Post');
|
||||
});
|
||||
|
||||
it('should return empty array for null type', () => {
|
||||
expect(getFieldChildren(null, 'Query.foo')).toEqual({ fields: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInputObjectFields', () => {
|
||||
it('should return fields of an input object type', () => {
|
||||
const mutationFields = getRootFields(BASIC_SCHEMA, 'Mutation');
|
||||
const createUser = mutationFields.find((f) => f.name === 'createUser');
|
||||
const inputType = createUser.args[0].namedType;
|
||||
const fields = getInputObjectFields(inputType);
|
||||
|
||||
const names = fields.map((f) => f.name);
|
||||
expect(names).toEqual(['name', 'email', 'age', 'address']);
|
||||
});
|
||||
|
||||
it('should identify nested input object fields', () => {
|
||||
const mutationFields = getRootFields(BASIC_SCHEMA, 'Mutation');
|
||||
const createUser = mutationFields.find((f) => f.name === 'createUser');
|
||||
const inputType = createUser.args[0].namedType;
|
||||
const fields = getInputObjectFields(inputType);
|
||||
|
||||
const addressField = fields.find((f) => f.name === 'address');
|
||||
expect(addressField.isInputObject).toBe(true);
|
||||
expect(addressField.typeLabel).toBe('AddressInput');
|
||||
});
|
||||
|
||||
it('should identify required fields', () => {
|
||||
const mutationFields = getRootFields(BASIC_SCHEMA, 'Mutation');
|
||||
const createUser = mutationFields.find((f) => f.name === 'createUser');
|
||||
const inputType = createUser.args[0].namedType;
|
||||
const fields = getInputObjectFields(inputType);
|
||||
|
||||
expect(fields.find((f) => f.name === 'name').isRequired).toBe(true);
|
||||
expect(fields.find((f) => f.name === 'email').isRequired).toBe(true);
|
||||
expect(fields.find((f) => f.name === 'age').isRequired).toBe(false);
|
||||
});
|
||||
|
||||
it('should return empty array for null type', () => {
|
||||
expect(getInputObjectFields(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty for non-input-object type', () => {
|
||||
const fields = getRootFields(BASIC_SCHEMA, 'Query');
|
||||
const userField = fields.find((f) => f.name === 'user');
|
||||
expect(getInputObjectFields(userField.namedType)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateQueryForSync', () => {
|
||||
it('should accept valid single named query', () => {
|
||||
expect(validateQueryForSync('query GetUser { user { id } }')).toEqual({ valid: true, error: null });
|
||||
});
|
||||
|
||||
it('should accept valid single named mutation', () => {
|
||||
expect(validateQueryForSync('mutation CreateUser { createUser { id } }')).toEqual({ valid: true, error: null });
|
||||
});
|
||||
|
||||
it('should accept empty/null query', () => {
|
||||
expect(validateQueryForSync('')).toEqual({ valid: true, error: null });
|
||||
expect(validateQueryForSync(null)).toEqual({ valid: true, error: null });
|
||||
expect(validateQueryForSync(' ')).toEqual({ valid: true, error: null });
|
||||
});
|
||||
|
||||
it('should reject multiple operations of the same type', () => {
|
||||
const result = validateQueryForSync('query A { user { id } } query B { post { id } }');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe('multiple_operations');
|
||||
});
|
||||
|
||||
it('should reject mixed operation types (query + mutation)', () => {
|
||||
const result = validateQueryForSync('query A { user { id } } mutation B { createUser { id } }');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe('multiple_operations');
|
||||
});
|
||||
|
||||
it('should reject mixed operation types (query + subscription)', () => {
|
||||
const result = validateQueryForSync('query A { user { id } } subscription B { onUserCreated { id } }');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe('multiple_operations');
|
||||
});
|
||||
|
||||
it('should return valid false with no error for invalid syntax', () => {
|
||||
const result = validateQueryForSync('this is not graphql');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle queries with empty selection sets', () => {
|
||||
const result = validateQueryForSync('query Test { user {} }');
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle queries with empty args', () => {
|
||||
const result = validateQueryForSync('query Test { user() { id } }');
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateQueryString', () => {
|
||||
it('should return empty for no selections', () => {
|
||||
const result = generateQueryString(new Set(), new Map(), BASIC_SCHEMA, 'Query', new Set());
|
||||
expect(result).toEqual({ query: '', variables: {} });
|
||||
});
|
||||
|
||||
it('should return empty for null schema', () => {
|
||||
const result = generateQueryString(new Set(['Query.user']), new Map(), null, 'Query', new Set());
|
||||
expect(result).toEqual({ query: '', variables: {} });
|
||||
});
|
||||
|
||||
it('should generate a simple query with leaf fields', () => {
|
||||
const selections = new Set(['Query.user', 'Query.user.id', 'Query.user.name']);
|
||||
const result = generateQueryString(selections, new Map(), BASIC_SCHEMA, 'Query', new Set());
|
||||
|
||||
expect(result.query).toContain('query');
|
||||
expect(result.query).toContain('user');
|
||||
expect(result.query).toContain('id');
|
||||
expect(result.query).toContain('name');
|
||||
expect(result.variables).toEqual({});
|
||||
});
|
||||
|
||||
it('should auto-generate operation name from first field', () => {
|
||||
const selections = new Set(['Query.user', 'Query.user.id']);
|
||||
const result = generateQueryString(selections, new Map(), BASIC_SCHEMA, 'Query', new Set());
|
||||
|
||||
expect(result.query).toMatch(/query User/);
|
||||
});
|
||||
|
||||
it('should use existing operation name when provided', () => {
|
||||
const selections = new Set(['Query.user', 'Query.user.id']);
|
||||
const result = generateQueryString(selections, new Map(), BASIC_SCHEMA, 'Query', new Set(), 'MyCustomQuery');
|
||||
|
||||
expect(result.query).toMatch(/query MyCustomQuery/);
|
||||
});
|
||||
|
||||
it('should generate mutation operation type', () => {
|
||||
const selections = new Set(['Mutation.deleteUser']);
|
||||
const result = generateQueryString(selections, new Map(), BASIC_SCHEMA, 'Mutation', new Set());
|
||||
|
||||
expect(result.query).toMatch(/mutation/);
|
||||
});
|
||||
|
||||
it('should generate variables for enabled args', () => {
|
||||
const selections = new Set(['Query.user', 'Query.user.id', 'Query.user.name']);
|
||||
const enabledArgs = new Set(['Query.user.id']);
|
||||
const argValues = new Map([['Query.user.id', '123']]);
|
||||
const result = generateQueryString(selections, argValues, BASIC_SCHEMA, 'Query', enabledArgs);
|
||||
|
||||
expect(result.query).toContain('$id');
|
||||
expect(result.query).toContain('ID!');
|
||||
expect(result.query).toContain('id: $id');
|
||||
expect(result.variables.id).toBe('123');
|
||||
});
|
||||
|
||||
it('should coerce Int values', () => {
|
||||
const selections = new Set(['Query.users']);
|
||||
const enabledArgs = new Set(['Query.users.limit']);
|
||||
const argValues = new Map([['Query.users.limit', '10']]);
|
||||
const result = generateQueryString(selections, argValues, BASIC_SCHEMA, 'Query', enabledArgs);
|
||||
|
||||
expect(result.variables.limit).toBe(10);
|
||||
});
|
||||
|
||||
it('should coerce Boolean values', () => {
|
||||
const schema = buildSchema(`type Query { flag(active: Boolean!): String }`);
|
||||
const selections = new Set(['Query.flag']);
|
||||
const enabledArgs = new Set(['Query.flag.active']);
|
||||
const argValues = new Map([['Query.flag.active', 'true']]);
|
||||
const result = generateQueryString(selections, argValues, schema, 'Query', enabledArgs);
|
||||
|
||||
expect(result.variables.active).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle multiple selected fields', () => {
|
||||
const selections = new Set([
|
||||
'Query.user', 'Query.user.id', 'Query.user.name',
|
||||
'Query.post', 'Query.post.title'
|
||||
]);
|
||||
const result = generateQueryString(selections, new Map(), BASIC_SCHEMA, 'Query', new Set());
|
||||
|
||||
expect(result.query).toContain('user');
|
||||
expect(result.query).toContain('post');
|
||||
expect(result.query).toContain('title');
|
||||
});
|
||||
|
||||
it('should handle nested non-leaf fields with __typename fallback', () => {
|
||||
// Select user.posts but no children of posts
|
||||
const selections = new Set(['Query.user', 'Query.user.posts']);
|
||||
const result = generateQueryString(selections, new Map(), BASIC_SCHEMA, 'Query', new Set());
|
||||
|
||||
// posts should still be in the query (with __typename fallback, which gets stripped)
|
||||
expect(result.query).toContain('posts');
|
||||
});
|
||||
|
||||
it('should handle union types with inline fragments', () => {
|
||||
const selections = new Set([
|
||||
'Query.search',
|
||||
'Query.search.__on_User',
|
||||
'Query.search.__on_User.name',
|
||||
'Query.search.__on_Post',
|
||||
'Query.search.__on_Post.title'
|
||||
]);
|
||||
const enabledArgs = new Set(['Query.search.query']);
|
||||
const argValues = new Map([['Query.search.query', 'test']]);
|
||||
const result = generateQueryString(selections, argValues, BASIC_SCHEMA, 'Query', enabledArgs);
|
||||
|
||||
expect(result.query).toContain('... on User');
|
||||
expect(result.query).toContain('... on Post');
|
||||
expect(result.query).toContain('name');
|
||||
expect(result.query).toContain('title');
|
||||
});
|
||||
|
||||
it('should disambiguate duplicate variable names', () => {
|
||||
const selections = new Set([
|
||||
'Query.user', 'Query.user.id',
|
||||
'Query.post', 'Query.post.id'
|
||||
]);
|
||||
const enabledArgs = new Set(['Query.user.id', 'Query.post.id']);
|
||||
const argValues = new Map([
|
||||
['Query.user.id', '1'],
|
||||
['Query.post.id', '2']
|
||||
]);
|
||||
const result = generateQueryString(selections, argValues, BASIC_SCHEMA, 'Query', enabledArgs);
|
||||
|
||||
// Both should have variables, and they should be disambiguated
|
||||
const varMatches = result.query.match(/\$\w+/g) || [];
|
||||
const uniqueVars = new Set(varMatches);
|
||||
expect(uniqueVars.size).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('should handle input object arguments with nested fields', () => {
|
||||
const selections = new Set(['Mutation.createUser', 'Mutation.createUser.id']);
|
||||
const enabledArgs = new Set([
|
||||
'Mutation.createUser.input',
|
||||
'Mutation.createUser.input.name',
|
||||
'Mutation.createUser.input.email'
|
||||
]);
|
||||
const argValues = new Map([
|
||||
['Mutation.createUser.input.name', 'Alice'],
|
||||
['Mutation.createUser.input.email', 'alice@test.com']
|
||||
]);
|
||||
const result = generateQueryString(selections, argValues, BASIC_SCHEMA, 'Mutation', enabledArgs);
|
||||
|
||||
expect(result.query).toContain('input:');
|
||||
expect(result.query).toContain('$name');
|
||||
expect(result.query).toContain('$email');
|
||||
expect(result.variables.name).toBe('Alice');
|
||||
expect(result.variables.email).toBe('alice@test.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseQueryToState', () => {
|
||||
it('should return null for null schema', () => {
|
||||
expect(parseQueryToState('query Test { user { id } }', null)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return empty state for empty query', () => {
|
||||
const result = parseQueryToState('', BASIC_SCHEMA);
|
||||
expect(result.selections.size).toBe(0);
|
||||
expect(result.expandedPaths.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should return null for unparseable query', () => {
|
||||
expect(parseQueryToState('this is not graphql', BASIC_SCHEMA)).toBeNull();
|
||||
});
|
||||
|
||||
it('should parse a simple query', () => {
|
||||
const state = parseQueryToState('query GetUser { user { id name } }', BASIC_SCHEMA);
|
||||
|
||||
expect(state.selections.has('Query.user')).toBe(true);
|
||||
expect(state.selections.has('Query.user.id')).toBe(true);
|
||||
expect(state.selections.has('Query.user.name')).toBe(true);
|
||||
expect(state.expandedPaths.has('Query.user')).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse nested selections', () => {
|
||||
const state = parseQueryToState(`
|
||||
query GetUser {
|
||||
user {
|
||||
id
|
||||
posts {
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
`, BASIC_SCHEMA);
|
||||
|
||||
expect(state.selections.has('Query.user')).toBe(true);
|
||||
expect(state.selections.has('Query.user.posts')).toBe(true);
|
||||
expect(state.selections.has('Query.user.posts.title')).toBe(true);
|
||||
expect(state.expandedPaths.has('Query.user')).toBe(true);
|
||||
expect(state.expandedPaths.has('Query.user.posts')).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse arguments with variable references', () => {
|
||||
const query = 'query GetUser($id: ID!) { user(id: $id) { name } }';
|
||||
const variables = JSON.stringify({ id: '123' });
|
||||
const state = parseQueryToState(query, BASIC_SCHEMA, variables);
|
||||
|
||||
expect(state.enabledArgs.has('Query.user.id')).toBe(true);
|
||||
expect(state.argValues.get('Query.user.id')).toBe('123');
|
||||
});
|
||||
|
||||
it('should parse inline argument values', () => {
|
||||
const state = parseQueryToState(`
|
||||
query GetUsers {
|
||||
users(limit: 10) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`, BASIC_SCHEMA);
|
||||
|
||||
expect(state.enabledArgs.has('Query.users.limit')).toBe(true);
|
||||
expect(state.argValues.get('Query.users.limit')).toBe('10');
|
||||
});
|
||||
|
||||
it('should parse mutation operations', () => {
|
||||
const state = parseQueryToState('mutation Delete { deleteUser(id: "1") }', BASIC_SCHEMA);
|
||||
expect(state.selections.has('Mutation.deleteUser')).toBe(true);
|
||||
expect(state.enabledArgs.has('Mutation.deleteUser.id')).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse inline fragments for union types', () => {
|
||||
const query = `
|
||||
query Search($query: String!) {
|
||||
search(query: $query) {
|
||||
... on User { name }
|
||||
... on Post { title }
|
||||
}
|
||||
}
|
||||
`;
|
||||
const variables = JSON.stringify({ query: 'test' });
|
||||
const state = parseQueryToState(query, BASIC_SCHEMA, variables);
|
||||
|
||||
expect(state.selections.has('Query.search')).toBe(true);
|
||||
expect(state.selections.has('Query.search.__on_User')).toBe(true);
|
||||
expect(state.selections.has('Query.search.__on_User.name')).toBe(true);
|
||||
expect(state.selections.has('Query.search.__on_Post')).toBe(true);
|
||||
expect(state.selections.has('Query.search.__on_Post.title')).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse input object arguments', () => {
|
||||
const query = `
|
||||
mutation CreateUser($name: String!, $email: String!) {
|
||||
createUser(input: { name: $name, email: $email }) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
const variables = JSON.stringify({ name: 'Alice', email: 'alice@test.com' });
|
||||
const state = parseQueryToState(query, BASIC_SCHEMA, variables);
|
||||
|
||||
expect(state.enabledArgs.has('Mutation.createUser.input')).toBe(true);
|
||||
expect(state.enabledArgs.has('Mutation.createUser.input.name')).toBe(true);
|
||||
expect(state.enabledArgs.has('Mutation.createUser.input.email')).toBe(true);
|
||||
expect(state.argValues.get('Mutation.createUser.input.name')).toBe('Alice');
|
||||
expect(state.argValues.get('Mutation.createUser.input.email')).toBe('alice@test.com');
|
||||
});
|
||||
|
||||
it('should handle empty selection sets', () => {
|
||||
const state = parseQueryToState('query Test { user {} }', BASIC_SCHEMA);
|
||||
expect(state).not.toBeNull();
|
||||
expect(state.selections.has('Query.user')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle queries with empty args', () => {
|
||||
const state = parseQueryToState('query Test { user() { id } }', BASIC_SCHEMA);
|
||||
expect(state).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should skip __typename fields', () => {
|
||||
const state = parseQueryToState('query Test { user { __typename id } }', BASIC_SCHEMA);
|
||||
expect(state.selections.has('Query.user.__typename')).toBe(false);
|
||||
expect(state.selections.has('Query.user.id')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('roundtrip: generate then parse', () => {
|
||||
it('should roundtrip a simple query', () => {
|
||||
const selections = new Set(['Query.user', 'Query.user.id', 'Query.user.name']);
|
||||
const generated = generateQueryString(selections, new Map(), BASIC_SCHEMA, 'Query', new Set());
|
||||
const parsed = parseQueryToState(generated.query, BASIC_SCHEMA);
|
||||
|
||||
expect(parsed.selections.has('Query.user')).toBe(true);
|
||||
expect(parsed.selections.has('Query.user.id')).toBe(true);
|
||||
expect(parsed.selections.has('Query.user.name')).toBe(true);
|
||||
});
|
||||
|
||||
it('should roundtrip a query with arguments', () => {
|
||||
const selections = new Set(['Query.user', 'Query.user.id', 'Query.user.name']);
|
||||
const enabledArgs = new Set(['Query.user.id']);
|
||||
const argValues = new Map([['Query.user.id', '42']]);
|
||||
const generated = generateQueryString(selections, argValues, BASIC_SCHEMA, 'Query', enabledArgs);
|
||||
|
||||
const varsJson = JSON.stringify(generated.variables);
|
||||
const parsed = parseQueryToState(generated.query, BASIC_SCHEMA, varsJson);
|
||||
|
||||
expect(parsed.selections.has('Query.user')).toBe(true);
|
||||
expect(parsed.enabledArgs.has('Query.user.id')).toBe(true);
|
||||
expect(parsed.argValues.get('Query.user.id')).toBe('42');
|
||||
});
|
||||
|
||||
it('should roundtrip a mutation with input object', () => {
|
||||
const selections = new Set(['Mutation.createUser', 'Mutation.createUser.id']);
|
||||
const enabledArgs = new Set([
|
||||
'Mutation.createUser.input',
|
||||
'Mutation.createUser.input.name',
|
||||
'Mutation.createUser.input.email',
|
||||
'Mutation.createUser.input.address',
|
||||
'Mutation.createUser.input.address.city'
|
||||
]);
|
||||
const argValues = new Map([
|
||||
['Mutation.createUser.input.name', 'Bob'],
|
||||
['Mutation.createUser.input.email', 'bob@test.com'],
|
||||
['Mutation.createUser.input.address.city', 'NYC']
|
||||
]);
|
||||
|
||||
const generated = generateQueryString(selections, argValues, BASIC_SCHEMA, 'Mutation', enabledArgs);
|
||||
const varsJson = JSON.stringify(generated.variables);
|
||||
const parsed = parseQueryToState(generated.query, BASIC_SCHEMA, varsJson);
|
||||
|
||||
expect(parsed.enabledArgs.has('Mutation.createUser.input')).toBe(true);
|
||||
expect(parsed.enabledArgs.has('Mutation.createUser.input.name')).toBe(true);
|
||||
expect(parsed.enabledArgs.has('Mutation.createUser.input.email')).toBe(true);
|
||||
expect(parsed.enabledArgs.has('Mutation.createUser.input.address')).toBe(true);
|
||||
expect(parsed.enabledArgs.has('Mutation.createUser.input.address.city')).toBe(true);
|
||||
expect(parsed.argValues.get('Mutation.createUser.input.name')).toBe('Bob');
|
||||
expect(parsed.argValues.get('Mutation.createUser.input.email')).toBe('bob@test.com');
|
||||
expect(parsed.argValues.get('Mutation.createUser.input.address.city')).toBe('NYC');
|
||||
});
|
||||
|
||||
it('should roundtrip a query with union types', () => {
|
||||
const selections = new Set([
|
||||
'Query.search',
|
||||
'Query.search.__on_User',
|
||||
'Query.search.__on_User.name',
|
||||
'Query.search.__on_Post',
|
||||
'Query.search.__on_Post.title'
|
||||
]);
|
||||
const enabledArgs = new Set(['Query.search.query']);
|
||||
const argValues = new Map([['Query.search.query', 'hello']]);
|
||||
|
||||
const generated = generateQueryString(selections, argValues, BASIC_SCHEMA, 'Query', enabledArgs);
|
||||
const varsJson = JSON.stringify(generated.variables);
|
||||
const parsed = parseQueryToState(generated.query, BASIC_SCHEMA, varsJson);
|
||||
|
||||
expect(parsed.selections.has('Query.search.__on_User')).toBe(true);
|
||||
expect(parsed.selections.has('Query.search.__on_User.name')).toBe(true);
|
||||
expect(parsed.selections.has('Query.search.__on_Post')).toBe(true);
|
||||
expect(parsed.selections.has('Query.search.__on_Post.title')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
opencollection: "1.0.0"
|
||||
info:
|
||||
name: graphql-query-builder
|
||||
@@ -0,0 +1,20 @@
|
||||
info:
|
||||
name: test-graphql
|
||||
type: graphql
|
||||
seq: 1
|
||||
|
||||
graphql:
|
||||
method: POST
|
||||
url: https://graphql.anilist.co
|
||||
body:
|
||||
query: |-
|
||||
|
||||
variables: |-
|
||||
{}
|
||||
auth: inherit
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"collections": [
|
||||
{
|
||||
"path": "{{collectionPath}}",
|
||||
"securityConfig": {
|
||||
"jsSandboxMode": "safe"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
12
tests/graphql/query-builder/init-user-data/preferences.json
Normal file
12
tests/graphql/query-builder/init-user-data/preferences.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"maximized": false,
|
||||
"lastOpenedCollections": [
|
||||
"{{collectionPath}}"
|
||||
],
|
||||
"preferences": {
|
||||
"onboarding": {
|
||||
"hasLaunchedBefore": true,
|
||||
"hasSeenWelcomeModal": true
|
||||
}
|
||||
}
|
||||
}
|
||||
294
tests/graphql/query-builder/query-builder.spec.ts
Normal file
294
tests/graphql/query-builder/query-builder.spec.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import { test, expect, Page } from '../../../playwright';
|
||||
import { closeAllCollections, openRequest } from '../../utils/page';
|
||||
|
||||
const qb = (page: Page) => page.locator('.graphql-query-builder-container');
|
||||
|
||||
const getQueryEditorContent = async (page: Page) => {
|
||||
const editor = page.locator('[aria-label="Query Editor"] .CodeMirror').first();
|
||||
await expect(editor).toBeVisible();
|
||||
return await editor.evaluate((el) => (el as any).CodeMirror?.getValue() || '') as string;
|
||||
};
|
||||
|
||||
const ensureVariablesPaneOpen = async (page: Page) => {
|
||||
const variablesEditor = page.locator('.variables-section .CodeMirror').first();
|
||||
if (!(await variablesEditor.isVisible())) {
|
||||
await page.locator('.variables-header').click();
|
||||
await expect(variablesEditor).toBeVisible();
|
||||
}
|
||||
};
|
||||
|
||||
const getVariablesEditorContent = async (page: Page) => {
|
||||
await ensureVariablesPaneOpen(page);
|
||||
const editor = page.locator('.variables-section .CodeMirror').first();
|
||||
return await editor.evaluate((el) => (el as any).CodeMirror?.getValue() || '') as string;
|
||||
};
|
||||
|
||||
test.describe('GraphQL Query Builder', () => {
|
||||
test.afterAll(async ({ pageWithUserData: page }) => {
|
||||
await closeAllCollections(page);
|
||||
});
|
||||
|
||||
test('Select fields and generate a query', async ({ pageWithUserData: page }) => {
|
||||
await test.step('Open GraphQL request, Query Builder, and load schema', async () => {
|
||||
await openRequest(page, 'graphql-query-builder', 'test-graphql');
|
||||
await page.locator('.tabs').waitFor({ state: 'visible' });
|
||||
|
||||
// Open query builder via dedicated button
|
||||
if (!(await page.locator('.graphql-query-builder-container').isVisible())) {
|
||||
const queryBuilderBtn = page.getByRole('tablist').locator('button[title="Show Query Builder"]');
|
||||
await queryBuilderBtn.waitFor({ state: 'visible' });
|
||||
await queryBuilderBtn.click();
|
||||
}
|
||||
|
||||
await expect(qb(page)).toBeVisible();
|
||||
|
||||
// Load schema via introspection
|
||||
const dotsMenu = page.getByRole('tablist').locator('button[title="More actions"]');
|
||||
await dotsMenu.waitFor({ state: 'visible' });
|
||||
await dotsMenu.click();
|
||||
const introspectionItem = page.locator('[data-testid="menu-dropdown-schema-introspection"]');
|
||||
await introspectionItem.waitFor({ state: 'visible' });
|
||||
await introspectionItem.click();
|
||||
await expect(page.getByText('GraphQL Schema loaded successfully').first()).toBeVisible({ timeout: 15000 });
|
||||
|
||||
await expect(qb(page).locator('.query-builder-tree')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Click on "Media" field to expand it', async () => {
|
||||
const mediaField = qb(page).locator('.field-node').filter({ hasText: /^Media/ }).first();
|
||||
await mediaField.click();
|
||||
await expect(qb(page).locator('.section-header').filter({ hasText: 'ARGUMENTS' }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Check the "Media" field checkbox', async () => {
|
||||
const mediaCheckbox = qb(page).locator('.field-node').filter({ hasText: /^Media/ }).first().locator('.field-checkbox');
|
||||
await mediaCheckbox.check();
|
||||
await expect(mediaCheckbox).toBeChecked();
|
||||
});
|
||||
|
||||
await test.step('Check child fields: id, description, bannerImage', async () => {
|
||||
const fieldsSection = qb(page).locator('.query-builder-tree');
|
||||
|
||||
const idField = fieldsSection.locator('.field-node').filter({ hasText: /^id\b/ }).first();
|
||||
await idField.locator('.field-checkbox').check();
|
||||
|
||||
const descField = fieldsSection.locator('.field-node').filter({ hasText: /^description/ }).first();
|
||||
await descField.locator('.field-checkbox').check();
|
||||
|
||||
const bannerField = fieldsSection.locator('.field-node').filter({ hasText: /^bannerImage/ }).first();
|
||||
await bannerField.locator('.field-checkbox').check();
|
||||
});
|
||||
|
||||
await test.step('Verify query is generated in the editor', async () => {
|
||||
// Poll to allow the 150ms debounce to fire
|
||||
await expect.poll(() => getQueryEditorContent(page)).toContain('id');
|
||||
const editorContent = await getQueryEditorContent(page);
|
||||
expect(editorContent).toContain('id');
|
||||
expect(editorContent).toContain('description');
|
||||
expect(editorContent).toContain('bannerImage');
|
||||
});
|
||||
});
|
||||
|
||||
test('Enable argument and set value', async ({ pageWithUserData: page }) => {
|
||||
await test.step('Expand "Character" field to show arguments', async () => {
|
||||
const characterField = qb(page).locator('.field-node').filter({ hasText: /^Character/ }).first();
|
||||
await characterField.click();
|
||||
await expect(qb(page).locator('.section-header').filter({ hasText: 'ARGUMENTS' }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Check the "Character" field', async () => {
|
||||
const characterCheckbox = qb(page)
|
||||
.locator('.field-node')
|
||||
.filter({ hasText: /^Character/ })
|
||||
.first()
|
||||
.locator('.field-checkbox');
|
||||
await characterCheckbox.check();
|
||||
});
|
||||
|
||||
await test.step('Enable the "id" argument and set a value', async () => {
|
||||
const argRow = qb(page).locator('.arg-row').filter({ has: page.locator('.arg-name', { hasText: /^id$/ }) }).first();
|
||||
await expect(argRow).toBeVisible();
|
||||
const argCheckbox = argRow.locator('.field-checkbox');
|
||||
await argCheckbox.check();
|
||||
await expect(argCheckbox).toBeChecked();
|
||||
|
||||
const argInput = argRow.locator('input[type="text"]');
|
||||
await expect(argInput).toBeVisible();
|
||||
await argInput.fill('123');
|
||||
});
|
||||
|
||||
await test.step('Check child field "gender"', async () => {
|
||||
const genderField = qb(page).locator('.field-node').filter({ hasText: /^gender/ }).first();
|
||||
await genderField.locator('.field-checkbox').check();
|
||||
});
|
||||
|
||||
await test.step('Verify generated query contains the argument', async () => {
|
||||
await expect.poll(() => getQueryEditorContent(page)).toContain('$id');
|
||||
const editorContent = await getQueryEditorContent(page);
|
||||
expect(editorContent).toContain('gender');
|
||||
expect(editorContent).toContain('$id');
|
||||
});
|
||||
|
||||
await test.step('Verify variables pane contains the argument value', async () => {
|
||||
const variablesContent = await getVariablesEditorContent(page);
|
||||
expect(variablesContent).toContain('"id"');
|
||||
expect(variablesContent).toContain('123');
|
||||
});
|
||||
});
|
||||
|
||||
test('Expand nested object types', async ({ pageWithUserData: page }) => {
|
||||
await test.step('Expand "Staff" field', async () => {
|
||||
const staffField = qb(page).locator('.field-node').filter({ hasText: /^Staff/ }).first();
|
||||
await staffField.click();
|
||||
});
|
||||
|
||||
await test.step('Check "Staff" and expand "name" nested field', async () => {
|
||||
const staffCheckbox = qb(page).locator('.field-node').filter({ hasText: /^Staff/ }).first().locator('.field-checkbox');
|
||||
await staffCheckbox.check();
|
||||
|
||||
const descField = qb(page).locator('.field-node').filter({ hasText: /^description/ }).first();
|
||||
await descField.locator('.field-checkbox').check();
|
||||
|
||||
const nameField = qb(page).locator('.field-node').filter({ hasText: /^name/ }).first();
|
||||
await nameField.click();
|
||||
});
|
||||
|
||||
await test.step('Select nested name fields', async () => {
|
||||
const nameCheckbox = qb(page).locator('.field-node').filter({ hasText: /^name/ }).first().locator('.field-checkbox');
|
||||
await nameCheckbox.check();
|
||||
|
||||
const firstField = qb(page).locator('.field-node').filter({ hasText: /^first/ }).first();
|
||||
await firstField.locator('.field-checkbox').check();
|
||||
});
|
||||
|
||||
await test.step('Verify nested query structure in editor', async () => {
|
||||
await expect.poll(() => getQueryEditorContent(page)).toContain('Staff');
|
||||
const editorContent = await getQueryEditorContent(page);
|
||||
expect(editorContent).toContain('description');
|
||||
expect(editorContent).toContain('name');
|
||||
expect(editorContent).toContain('first');
|
||||
});
|
||||
});
|
||||
|
||||
test('Removing a field in code editor unchecks it in query builder', async ({ pageWithUserData: page }) => {
|
||||
await test.step('Ensure "Media" is expanded with child fields id, description, bannerImage checked', async () => {
|
||||
const mediaField = qb(page).locator('.field-node').filter({ hasText: /^Media/ }).first();
|
||||
const mediaChildrenVisible = await qb(page)
|
||||
.locator('.field-node')
|
||||
.filter({ hasText: /^bannerImage/ })
|
||||
.first()
|
||||
.isVisible();
|
||||
if (!mediaChildrenVisible) {
|
||||
await mediaField.click();
|
||||
}
|
||||
|
||||
const mediaCheckbox = mediaField.locator('.field-checkbox');
|
||||
if (!(await mediaCheckbox.isChecked())) {
|
||||
await mediaCheckbox.check();
|
||||
}
|
||||
|
||||
const fieldsSection = qb(page).locator('.query-builder-tree');
|
||||
for (const fieldName of ['id\\b', 'description', 'bannerImage']) {
|
||||
const field = fieldsSection.locator('.field-node').filter({ hasText: new RegExp(`^${fieldName}`) }).first();
|
||||
const checkbox = field.locator('.field-checkbox');
|
||||
if (!(await checkbox.isChecked())) {
|
||||
await checkbox.check();
|
||||
}
|
||||
}
|
||||
|
||||
await expect.poll(() => getQueryEditorContent(page)).toContain('bannerImage');
|
||||
// Wait for the Tree→Editor generation debounce (150ms) to complete
|
||||
await page.waitForTimeout(200);
|
||||
});
|
||||
|
||||
await test.step('Remove "bannerImage" field from the code editor', async () => {
|
||||
const content = await getQueryEditorContent(page);
|
||||
const updatedContent = content
|
||||
.split('\n')
|
||||
.filter((line: string) => !line.trim().startsWith('bannerImage'))
|
||||
.join('\n');
|
||||
|
||||
// Set content directly via CodeMirror
|
||||
const editor = page.locator('[aria-label="Query Editor"] .CodeMirror').first();
|
||||
await editor.evaluate((el, val) => {
|
||||
const cm = (el as any).CodeMirror;
|
||||
if (cm) cm.setValue(val);
|
||||
}, updatedContent);
|
||||
});
|
||||
|
||||
await test.step('Verify "bannerImage" checkbox is unchecked in query builder', async () => {
|
||||
const fieldsSection = qb(page).locator('.query-builder-tree');
|
||||
const bannerCheckbox = fieldsSection
|
||||
.locator('.field-node')
|
||||
.filter({ hasText: /^bannerImage/ })
|
||||
.first()
|
||||
.locator('.field-checkbox');
|
||||
await expect(bannerCheckbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
await test.step('Verify "id" and "description" are still checked', async () => {
|
||||
const fieldsSection = qb(page).locator('.query-builder-tree');
|
||||
|
||||
const idCheckbox = fieldsSection.locator('.field-node').filter({ hasText: /^id\b/ }).first().locator('.field-checkbox');
|
||||
await expect(idCheckbox).toBeChecked();
|
||||
|
||||
const descCheckbox = fieldsSection
|
||||
.locator('.field-node')
|
||||
.filter({ hasText: /^description/ })
|
||||
.first()
|
||||
.locator('.field-checkbox');
|
||||
await expect(descCheckbox).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
test('Changing variable value in variables editor updates argument in query builder', async ({
|
||||
pageWithUserData: page
|
||||
}) => {
|
||||
await test.step('Set up "Character" field with "id" argument via query builder', async () => {
|
||||
const characterField = qb(page).locator('.field-node').filter({ hasText: /^Character/ }).first();
|
||||
await characterField.click();
|
||||
await expect(qb(page).locator('.section-header').filter({ hasText: 'ARGUMENTS' }).first()).toBeVisible();
|
||||
|
||||
const characterCheckbox = characterField.locator('.field-checkbox');
|
||||
if (!(await characterCheckbox.isChecked())) {
|
||||
await characterCheckbox.check();
|
||||
}
|
||||
|
||||
const argRow = qb(page).locator('.arg-row').filter({ has: page.locator('.arg-name', { hasText: /^id$/ }) }).first();
|
||||
const argCheckbox = argRow.locator('.field-checkbox');
|
||||
if (!(await argCheckbox.isChecked())) {
|
||||
await argCheckbox.check();
|
||||
}
|
||||
const argInput = argRow.locator('input[type="text"]');
|
||||
await argInput.fill('100');
|
||||
|
||||
const genderField = qb(page).locator('.field-node').filter({ hasText: /^gender/ }).first();
|
||||
const genderCheckbox = genderField.locator('.field-checkbox');
|
||||
if (!(await genderCheckbox.isChecked())) {
|
||||
await genderCheckbox.check();
|
||||
}
|
||||
|
||||
await expect.poll(() => getQueryEditorContent(page)).toContain('$id');
|
||||
await expect.poll(() => getVariablesEditorContent(page)).toContain('100');
|
||||
});
|
||||
|
||||
await test.step('Change the variable value in the variables editor', async () => {
|
||||
const variablesContent = await getVariablesEditorContent(page);
|
||||
const updatedVariables = variablesContent.replace('100', '999');
|
||||
|
||||
// Set content directly via CodeMirror
|
||||
await ensureVariablesPaneOpen(page);
|
||||
const editor = page.locator('.variables-section .CodeMirror').first();
|
||||
await editor.evaluate((el, val) => {
|
||||
const cm = (el as any).CodeMirror;
|
||||
if (cm) cm.setValue(val);
|
||||
}, updatedVariables);
|
||||
});
|
||||
|
||||
await test.step('Verify the argument value is updated in query builder', async () => {
|
||||
const argRow = qb(page).locator('.arg-row').filter({ has: page.locator('.arg-name', { hasText: /^id$/ }) }).first();
|
||||
const argInput = argRow.locator('input[type="text"]');
|
||||
await expect(argInput).toHaveValue('999');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user