From 35cd72534be37f00a80e18d22e0420f6705c2101 Mon Sep 17 00:00:00 2001 From: Pooja Date: Fri, 27 Mar 2026 12:29:01 +0530 Subject: [PATCH] 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 --- .../GraphQLRequestPane/StyledWrapper.js | 92 +++ .../RequestPane/GraphQLRequestPane/index.js | 257 ++++++- .../RequestPane/GraphQLVariables/index.js | 57 +- .../RequestPane/QueryBuilder/ErrorBoundary.js | 46 ++ .../RequestPane/QueryBuilder/FieldNode.js | 529 +++++++++++++ .../QueryBuilder/QueryBuilderTree.js | 56 ++ .../RequestPane/QueryBuilder/StyledWrapper.js | 383 ++++++++++ .../RequestPane/QueryBuilder/index.js | 238 ++++++ .../RequestPane/QueryEditor/index.js | 51 +- .../src/hooks/useQueryBuilder/index.js | 497 ++++++++++++ .../src/providers/ReduxStore/slices/tabs.js | 34 +- .../src/utils/graphql/queryBuilder.js | 715 ++++++++++++++++++ .../src/utils/graphql/queryBuilder.spec.js | 664 ++++++++++++++++ .../fixtures/collection/opencollection.yml | 3 + .../fixtures/collection/test-graphql.yml | 20 + .../init-user-data/collection-security.json | 10 + .../init-user-data/preferences.json | 12 + .../query-builder/query-builder.spec.ts | 294 +++++++ 18 files changed, 3860 insertions(+), 98 deletions(-) create mode 100644 packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/RequestPane/QueryBuilder/ErrorBoundary.js create mode 100644 packages/bruno-app/src/components/RequestPane/QueryBuilder/FieldNode.js create mode 100644 packages/bruno-app/src/components/RequestPane/QueryBuilder/QueryBuilderTree.js create mode 100644 packages/bruno-app/src/components/RequestPane/QueryBuilder/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/RequestPane/QueryBuilder/index.js create mode 100644 packages/bruno-app/src/hooks/useQueryBuilder/index.js create mode 100644 packages/bruno-app/src/utils/graphql/queryBuilder.js create mode 100644 packages/bruno-app/src/utils/graphql/queryBuilder.spec.js create mode 100644 tests/graphql/query-builder/fixtures/collection/opencollection.yml create mode 100644 tests/graphql/query-builder/fixtures/collection/test-graphql.yml create mode 100644 tests/graphql/query-builder/init-user-data/collection-security.json create mode 100644 tests/graphql/query-builder/init-user-data/preferences.json create mode 100644 tests/graphql/query-builder/query-builder.spec.ts diff --git a/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/StyledWrapper.js new file mode 100644 index 000000000..c10bacea5 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js b/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js index c1da54337..a6f15723f 100644 --- a/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js +++ b/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js @@ -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 ( - +
+
+ +
+
+
{ + e.preventDefault(); + startDrag(variablesDraggingRef); + }} + /> + + {variablesOpen && ( +
+ +
+ )} +
+
); - case 'variables': - return ; case 'headers': return ; case 'auth': @@ -129,7 +264,30 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle default: return
404 | Not found
; } - }, [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
An error occurred!
; @@ -140,13 +298,29 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
) : requestPaneTab === 'query' ? ( -
- +
+ + + + + + + + + + +
) : null; return ( -
+ -
- {tabPanel} +
+ {requestPaneTab === 'query' && showQueryBuilder && ( + <> +
+ +
+
{ + e.preventDefault(); + startDrag(queryBuilderDraggingRef); + }} + /> + + )} + {tabPanel}
-
+ ); }; diff --git a/packages/bruno-app/src/components/RequestPane/GraphQLVariables/index.js b/packages/bruno-app/src/components/RequestPane/GraphQLVariables/index.js index 3591a811a..d11ade245 100644 --- a/packages/bruno-app/src/components/RequestPane/GraphQLVariables/index.js +++ b/packages/bruno-app/src/components/RequestPane/GraphQLVariables/index.js @@ -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 ( - <> - - - + ); }; diff --git a/packages/bruno-app/src/components/RequestPane/QueryBuilder/ErrorBoundary.js b/packages/bruno-app/src/components/RequestPane/QueryBuilder/ErrorBoundary.js new file mode 100644 index 000000000..67a6d44fa --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/QueryBuilder/ErrorBoundary.js @@ -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 ( + +
+ +
Something went wrong
+
+ The Query Builder encountered an unexpected error. Try reloading the schema or manually using the editor. +
+ +
+
+ ); + } + return this.props.children; + } +} + +export default QueryBuilderErrorBoundary; diff --git a/packages/bruno-app/src/components/RequestPane/QueryBuilder/FieldNode.js b/packages/bruno-app/src/components/RequestPane/QueryBuilder/FieldNode.js new file mode 100644 index 000000000..ca3651b20 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/QueryBuilder/FieldNode.js @@ -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 ( +
+ {items.map((item, index) => { + const isEmptyRow = index === items.length - 1 && item.value === ''; + return ( +
e.stopPropagation()}> + handleItemChange(item.id, v)} field={field} /> + {isEmptyRow ? ( + + ) : ( + + )} +
+ ); + })} +
+ ); +}; + +const ArgValueInput = ({ value, onChange, field }) => { + if (field.isEnum && field.enumValues) { + return ( + + ); + } + if (field.isBoolean) { + return ( + + ); + } + return ( + 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 ( + +
e.stopPropagation()}> + {isExpandable ? ( + + ) : ( + + )} + { + 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()} + /> + {field.name} + {field.isRequired && !} + {(!isEnabled || field.isInputObject) && {field.typeLabel}} + {isListOfInputObject && ( + + + + )} + {!field.isInputObject && isEnabled && ( + onSetInputFieldValue(fieldKey, v)} field={field} /> + )} +
+ {isExpandable && isExpanded && ( + + )} +
+ ); + }); +}; + +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 ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleExpand(e); + } + }} + tabIndex={0} + > + + + {isExpanded ? ( + + ) : ( + + )} + + e.stopPropagation()} + /> + ... on {field.name} +
+ ); + } + + const showSections = isExpanded && (hasArgs || hasChildren); + const sectionIndent = (depth + 1) * 20; + + return ( + <> +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleExpand(e); + } + }} + tabIndex={0} + > + + + {canExpand ? ( + isExpanded ? ( + + ) : ( + + ) + ) : null} + + e.stopPropagation()} + /> + {field.name} + : + {field.typeLabel} +
+ + {showSections && hasArgs && ( + <> +
+ ARGUMENTS +
+ {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 ( +
e.stopPropagation()}> + + onToggleArg && onToggleArg(field.path, arg.name)} + onClick={(e) => e.stopPropagation()} + /> + {arg.name} + {arg.isRequired && !} + {arg.typeLabel} + + + +
+ ); + } + + // Input object arg: render as expandable with children + if (arg.isInputObject) { + return ( + + ); + } + + if (arg.isList && !arg.isInputObject) { + return ( + + ); + } + + return ( +
e.stopPropagation()}> + + onToggleArg && onToggleArg(field.path, arg.name)} + onClick={(e) => e.stopPropagation()} + /> + {arg.name} + {arg.isRequired && !} + {!isArgEnabled && {arg.typeLabel}} + {isArgEnabled && ( + onArgChange(field.path, arg.name, v)} field={arg} /> + )} +
+ ); + })} + + )} + + {showSections && hasChildren && hasArgs && ( +
+ FIELDS +
+ )} + + ); +}; + +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 ( + <> +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleExpand(e); + } + }} + tabIndex={0} + role="button" + aria-expanded={isExpanded} + > + + {isExpanded ? ( + + ) : ( + + )} + + e.stopPropagation()} + /> + {arg.name} + {arg.isRequired && !} + {arg.typeLabel} +
+ {isExpanded && arg.namedType && ( + + )} + + ); +}; + +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 ( + <> +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleExpand(e); + } + }} + tabIndex={0} + role="button" + aria-expanded={isExpanded} + > + + {isExpanded ? ( + + ) : ( + + )} + + e.stopPropagation()} + /> + {arg.name} + {arg.isRequired && !} + {arg.typeLabel} +
+ {isExpanded && ( + onArgChange(fieldPath, arg.name, v)} + field={arg} + indent={sectionIndent + 28} + /> + )} + + ); +}; + +export default React.memo(FieldNode); diff --git a/packages/bruno-app/src/components/RequestPane/QueryBuilder/QueryBuilderTree.js b/packages/bruno-app/src/components/RequestPane/QueryBuilder/QueryBuilderTree.js new file mode 100644 index 000000000..e54c155bb --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/QueryBuilder/QueryBuilderTree.js @@ -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) => ( + + ))} + + {(fields || []).map((field) => ( + + ))} + + ); +}; + +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 ( + <> + + {isExpanded && children && ( + + )} + + ); +}); + +export default QueryBuilderTree; diff --git a/packages/bruno-app/src/components/RequestPane/QueryBuilder/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/QueryBuilder/StyledWrapper.js new file mode 100644 index 000000000..99d8617f1 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/QueryBuilder/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/RequestPane/QueryBuilder/index.js b/packages/bruno-app/src/components/RequestPane/QueryBuilder/index.js new file mode 100644 index 000000000..5f48ae74a --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/QueryBuilder/index.js @@ -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 ( + +
+ {schemaError ? ( + <> + +
Failed to Load Schema
+
{schemaError.message}
+
+ + +
+ + ) : ( + <> +
No Schema Loaded
+
+ Load a GraphQL schema to explore operations and build queries visually. +
+
+ + +
+ + )} +
+
+ ); + } + + if (syncError) { + return ( + +
+ +
+ {syncError === 'multiple_operations' ? ( + <> + Multiple operations detected + The Query Builder supports a single operation at a time. Combine into one operation to sync. + + ) : null} +
+
+
+ ); + } + + return ( + + +
+ setSearchText(e.target.value)} + /> +
+ +
+ {availableRootTypes.map((rootType) => { + const isExpanded = effectiveExpandedRootTypes.has(rootType); + const fields = filteredFieldsByType[rootType] || []; + const isDisabled = activeRootType !== null && activeRootType !== rootType; + + return ( +
+ + {isExpanded && !isDisabled && ( + fields.length > 0 ? ( + + ) : ( +
+ {searchText ? 'No matching fields.' : 'No fields available.'} +
+ ) + )} +
+ ); + })} +
+
+
+ ); +}; + +export default QueryBuilder; diff --git a/packages/bruno-app/src/components/RequestPane/QueryEditor/index.js b/packages/bruno-app/src/components/RequestPane/QueryEditor/index.js index 4f599be66..76e16ddef 100644 --- a/packages/bruno-app/src/components/RequestPane/QueryEditor/index.js +++ b/packages/bruno-app/src/components/RequestPane/QueryEditor/index.js @@ -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 ( - <> - { - this._node = node; - }} - > - - - + { + this._node = node; + }} + /> ); } diff --git a/packages/bruno-app/src/hooks/useQueryBuilder/index.js b/packages/bruno-app/src/hooks/useQueryBuilder/index.js new file mode 100644 index 000000000..c8c59d67d --- /dev/null +++ b/packages/bruno-app/src/hooks/useQueryBuilder/index.js @@ -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 + }; +} diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js index 22603fb04..69913cf05 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js @@ -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; diff --git a/packages/bruno-app/src/utils/graphql/queryBuilder.js b/packages/bruno-app/src/utils/graphql/queryBuilder.js new file mode 100644 index 000000000..62a3b759e --- /dev/null +++ b/packages/bruno-app/src/utils/graphql/queryBuilder.js @@ -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 ''; + } +}; diff --git a/packages/bruno-app/src/utils/graphql/queryBuilder.spec.js b/packages/bruno-app/src/utils/graphql/queryBuilder.spec.js new file mode 100644 index 000000000..0023f2d78 --- /dev/null +++ b/packages/bruno-app/src/utils/graphql/queryBuilder.spec.js @@ -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); + }); + }); +}); diff --git a/tests/graphql/query-builder/fixtures/collection/opencollection.yml b/tests/graphql/query-builder/fixtures/collection/opencollection.yml new file mode 100644 index 000000000..cd465b6cc --- /dev/null +++ b/tests/graphql/query-builder/fixtures/collection/opencollection.yml @@ -0,0 +1,3 @@ +opencollection: "1.0.0" +info: + name: graphql-query-builder diff --git a/tests/graphql/query-builder/fixtures/collection/test-graphql.yml b/tests/graphql/query-builder/fixtures/collection/test-graphql.yml new file mode 100644 index 000000000..fbf879915 --- /dev/null +++ b/tests/graphql/query-builder/fixtures/collection/test-graphql.yml @@ -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 diff --git a/tests/graphql/query-builder/init-user-data/collection-security.json b/tests/graphql/query-builder/init-user-data/collection-security.json new file mode 100644 index 000000000..89dc2bfff --- /dev/null +++ b/tests/graphql/query-builder/init-user-data/collection-security.json @@ -0,0 +1,10 @@ +{ + "collections": [ + { + "path": "{{collectionPath}}", + "securityConfig": { + "jsSandboxMode": "safe" + } + } + ] +} diff --git a/tests/graphql/query-builder/init-user-data/preferences.json b/tests/graphql/query-builder/init-user-data/preferences.json new file mode 100644 index 000000000..872cf5312 --- /dev/null +++ b/tests/graphql/query-builder/init-user-data/preferences.json @@ -0,0 +1,12 @@ +{ + "maximized": false, + "lastOpenedCollections": [ + "{{collectionPath}}" + ], + "preferences": { + "onboarding": { + "hasLaunchedBefore": true, + "hasSeenWelcomeModal": true + } + } +} diff --git a/tests/graphql/query-builder/query-builder.spec.ts b/tests/graphql/query-builder/query-builder.spec.ts new file mode 100644 index 000000000..30ceb234a --- /dev/null +++ b/tests/graphql/query-builder/query-builder.spec.ts @@ -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'); + }); + }); +});