diff --git a/src-web/components/HttpRequestLayout.tsx b/src-web/components/HttpRequestLayout.tsx index bbbd172b..905bb786 100644 --- a/src-web/components/HttpRequestLayout.tsx +++ b/src-web/components/HttpRequestLayout.tsx @@ -38,7 +38,11 @@ export function HttpRequestLayout({ activeRequest, style }: Props) { /> ); - if (activeRequest.bodyType === 'graphql' && showGraphQLDocExplorer && graphQLSchema != null) { + if ( + activeRequest.bodyType === 'graphql' && + showGraphQLDocExplorer[activeRequest.id] !== undefined && + graphQLSchema != null + ) { return ( ( ({ + ...v, + [activeRequestId]: { field, type, parentType }, + })); }, }), extraExtensions, diff --git a/src-web/components/graphql/GraphQLDocsExplorer.tsx b/src-web/components/graphql/GraphQLDocsExplorer.tsx index 5504ef8b..a085607e 100644 --- a/src-web/components/graphql/GraphQLDocsExplorer.tsx +++ b/src-web/components/graphql/GraphQLDocsExplorer.tsx @@ -21,24 +21,26 @@ import { isScalarType, isUnionType, } from 'graphql'; +import { useAtomValue } from 'jotai'; import type { CSSProperties, HTMLAttributes, KeyboardEvent, ReactNode } from 'react'; -import { Fragment, memo, useCallback, useMemo, useRef, useState } from 'react'; +import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useClickOutside } from '../../hooks/useClickOutside'; import { useContainerSize } from '../../hooks/useContainerQuery'; import { useDebouncedValue } from '../../hooks/useDebouncedValue'; import { useStateWithDeps } from '../../hooks/useStateWithDeps'; import { jotaiStore } from '../../lib/jotai'; +import { Banner } from '../core/Banner'; import { CountBadge } from '../core/CountBadge'; import { Icon } from '../core/Icon'; import { IconButton } from '../core/IconButton'; import { PlainInput } from '../core/PlainInput'; import { Markdown } from '../Markdown'; import { showGraphQLDocExplorerAtom } from './graphqlAtoms'; -import { useGraphQLDocsExplorerEvent } from './useGraphQLDocsExplorer'; interface Props { style?: CSSProperties; schema: GraphQLSchema; + requestId: string; className?: string; } @@ -51,6 +53,7 @@ type ExplorerItem = export const GraphQLDocsExplorer = memo(function GraphQLDocsExplorer({ style, schema, + requestId, className, }: Props) { const [activeItem, setActiveItem] = useState(null); @@ -58,17 +61,34 @@ export const GraphQLDocsExplorer = memo(function GraphQLDocsExplorer({ const qryType = schema.getQueryType(); const mutType = schema.getMutationType(); const subType = schema.getSubscriptionType(); + const showField = useAtomValue(showGraphQLDocExplorerAtom)[requestId] ?? null; - useGraphQLDocsExplorerEvent('gql_docs_explorer.show_in_docs', ({ field }) => { - walkTypeGraph(schema, null, (t) => { - if (t.name === field) { - setActiveItem(toExplorerItem(t, null)); - return false; - } else { - return true; - } - }); - }); + useEffect(() => { + if (showField === null) { + setActiveItem(null); + } else { + walkTypeGraph(schema, null, (t, from) => { + const isRootParentType = + showField.parentType === 'Query' || + showField.parentType === 'Mutation' || + showField.parentType === 'Subscription'; + if ( + showField.field === t.name && + // For input fields, CodeMirror seems to set parentType to the root type of the field they belong to. + (isRootParentType || from?.name === showField.parentType) + ) { + console.log('SET FIELD', t, from); + setActiveItem(toExplorerItem(t, toExplorerItem(from, null))); + return false; + } else if (showField.type === t.name && from?.name === showField.parentType) { + setActiveItem(toExplorerItem(t, toExplorerItem(from, null))); + return false; + } else { + return true; + } + }); + } + }, [schema, showField]); const qryItem: ExplorerItem = qryType ? { kind: 'type', type: qryType, from: null } : null; const mutItem: ExplorerItem = mutType ? { kind: 'type', type: mutType, from: null } : null; @@ -83,6 +103,9 @@ export const GraphQLDocsExplorer = memo(function GraphQLDocsExplorer({ { + jotaiStore.set(showGraphQLDocExplorerAtom, (v) => ({ ...v, [requestId]: undefined })); + }} setItem={setActiveItem} schema={schema} /> @@ -140,11 +163,13 @@ function GraphQLExplorerHeader({ item, setItem, schema, + onClose, containerHeight, }: { item: ExplorerItem; setItem: (t: ExplorerItem) => void; schema: GraphQLSchema; + onClose: () => void; containerHeight: number; }) { const findIt = (t: ExplorerItem): ExplorerItem[] => { @@ -186,14 +211,7 @@ function GraphQLExplorerHeader({ />
- { - jotaiStore.set(showGraphQLDocExplorerAtom, false); - }} - /> +
); @@ -219,6 +237,11 @@ function GqlTypeInfo({ {description || 'No description'} + {'deprecationReason' in item.type && item.type.deprecationReason && ( + + {item.type.deprecationReason} + + )} ); @@ -294,6 +317,28 @@ function GqlTypeInfo({ ))} ); + } else if (item.kind === 'input_field') { + return ( +
+ {heading} + + {item.type.defaultValue !== undefined && ( +
+ Default Value +
{JSON.stringify(item.type.defaultValue)}
+
+ )} + +
+ Type + +
+
+ ); } else if (item.kind === 'field') { return (
@@ -392,7 +437,7 @@ function GqlTypeInfo({ ); } - console.log('Unknown GraphQL Type', item.type, isNonNullType(item.type)); + console.log('Unknown GraphQL Type', item); return
Unknown GraphQL type
; } @@ -665,8 +710,7 @@ function GqlSchemaSearch({ } else { results.push({ name: type.name, type, score: match.score, from, depth }); } - - return true; // Continue searching + return true; }); results.sort((a, b) => { if (value == '') { @@ -922,19 +966,25 @@ function walkTypeGraph( } } -function toExplorerItem(t: any, from: ExplorerItem | null): ExplorerItem { +function toExplorerItem(t: any, from: ExplorerItem | null): ExplorerItem | null { if (t == null) return null; - // GraphQLField-like: has `args` and `type` - if (t && typeof t === 'object' && Array.isArray(t.args) && t.type) { + // GraphQLField-like: has `args` (array) and `type` + if (typeof t === 'object' && Array.isArray(t.args) && t.type) { return { kind: 'field', type: t, from }; } - // GraphQLInputField-like: has `type` and maybe `defaultValue`, but no `args` - if (t && typeof t === 'object' && t.type && !('args' in t)) { + // GraphQLInputField-like: has `type`, no `args`, maybe `defaultValue`, and no `resolve` + if ( + typeof t === 'object' && + t.type && + !('args' in t) && + !('resolve' in t) && + ('defaultValue' in t || 'description' in t) + ) { return { kind: 'input_field', type: t, from }; } - // Otherwise, assume GraphQLType (object, scalar, enum, etc.) + // Fallback: treat as GraphQLNamedType (object, scalar, enum, etc.) return { kind: 'type', type: t, from }; } diff --git a/src-web/components/graphql/GraphQLEditor.tsx b/src-web/components/graphql/GraphQLEditor.tsx index 0e16128c..89b36a36 100644 --- a/src-web/components/graphql/GraphQLEditor.tsx +++ b/src-web/components/graphql/GraphQLEditor.tsx @@ -48,7 +48,8 @@ export function GraphQLEditor({ request, onChange, baseRequest, ...extraEditorPr return { query: request.body.query ?? '', variables: request.body.variables ?? '' }; }, [extraEditorProps.forceUpdateKey]); - const [isDocOpen, setGraphqlDocStateAtomValue] = useAtom(showGraphQLDocExplorerAtom); + const [isDocOpenRecord, setGraphqlDocStateAtomValue] = useAtom(showGraphQLDocExplorerAtom); + const isDocOpen = isDocOpenRecord[request.id] !== undefined; const handleChangeQuery = (query: string) => { const newBody = { query, variables: currentBody.variables || undefined }; @@ -132,7 +133,10 @@ export function GraphQLEditor({ request, onChange, baseRequest, ...extraEditorPr label: `${isDocOpen ? 'Hide' : 'Show'} Documentation`, leftSlot: , onSelect: () => { - setGraphqlDocStateAtomValue(!isDocOpen); + setGraphqlDocStateAtomValue((v) => ({ + ...v, + [request.id]: isDocOpen ? undefined : null, + })); }, }, { @@ -178,16 +182,17 @@ export function GraphQLEditor({ request, onChange, baseRequest, ...extraEditorPr
, ], [ + schema, + clear, + error, + isDocOpen, isLoading, refetch, - error, autoIntrospectDisabled, baseRequest.id, - clear, - schema, - setAutoIntrospectDisabled, - isDocOpen, setGraphqlDocStateAtomValue, + request.id, + setAutoIntrospectDisabled, ], ); diff --git a/src-web/components/graphql/graphqlAtoms.ts b/src-web/components/graphql/graphqlAtoms.ts index feadbe56..af06109c 100644 --- a/src-web/components/graphql/graphqlAtoms.ts +++ b/src-web/components/graphql/graphqlAtoms.ts @@ -1,3 +1,14 @@ import { atomWithKVStorage } from '../../lib/atoms/atomWithKVStorage'; -export const showGraphQLDocExplorerAtom = atomWithKVStorage('show_graphql_docs', false); +export const showGraphQLDocExplorerAtom = atomWithKVStorage< + Record< + string, + | { + type?: string; + field?: string; + parentType?: string; + } + | null + | undefined + > +>('show_graphql_docs', {}); diff --git a/src-web/components/graphql/useGraphQLDocsExplorer.ts b/src-web/components/graphql/useGraphQLDocsExplorer.ts deleted file mode 100644 index f1e5117e..00000000 --- a/src-web/components/graphql/useGraphQLDocsExplorer.ts +++ /dev/null @@ -1,63 +0,0 @@ -import EventEmitter from 'eventemitter3'; -import type { DependencyList } from 'react'; -import { useEffect } from 'react'; -import { jotaiStore } from '../../lib/jotai'; -import { sleep } from '../../lib/sleep'; -import { showGraphQLDocExplorerAtom } from './graphqlAtoms'; - -type EventDataMap = { - 'gql_docs_explorer.show_in_docs': { field?: string; type?: string; parentType?: string }; - 'gql_docs_explorer.focus_tab': undefined; -}; - -export function useGraphQLDocsExplorerEvent< - Event extends keyof EventDataMap, - Data extends EventDataMap[Event], ->(event: Event, fn: (data: Data) => void, deps?: DependencyList) { - useEffect(() => { - emitter.on(event, fn); - return () => { - emitter.off(event, fn); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, deps); -} - -export async function showInGraphQLDocsExplorer( - field: string | undefined, - type: string | undefined, - parentType: string | undefined, -) { - const isVisible = jotaiStore.get(showGraphQLDocExplorerAtom); - if (!isVisible) { - // Show and give some time for the explorer to start listening for events - jotaiStore.set(showGraphQLDocExplorerAtom, true); - await sleep(100); - } - emitter.emit('gql_docs_explorer.show_in_docs', { field, type, parentType }); -} - -const emitter = new (class GraphQLDocsExplorerEventEmitter { - #emitter: EventEmitter = new EventEmitter(); - - emit( - event: Event, - data: Data, - ) { - this.#emitter.emit(event, data); - } - - on( - event: Event, - fn: (data: Data) => void, - ) { - this.#emitter.on(event, fn); - } - - off( - event: Event, - fn: (data: Data) => void, - ) { - this.#emitter.off(event, fn); - } -})(); diff --git a/src-web/hooks/useIntrospectGraphQL.ts b/src-web/hooks/useIntrospectGraphQL.ts index feddfa94..89a5b2cd 100644 --- a/src-web/hooks/useIntrospectGraphQL.ts +++ b/src-web/hooks/useIntrospectGraphQL.ts @@ -3,7 +3,7 @@ import { invoke } from '@tauri-apps/api/core'; import type { GraphQlIntrospection, HttpRequest } from '@yaakapp-internal/models'; import type { GraphQLSchema } from 'graphql'; import { buildClientSchema, getIntrospectionQuery } from 'graphql'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { minPromiseMillis } from '../lib/minPromiseMillis'; import { getResponseBodyText } from '../lib/responseBody'; import { sendEphemeralRequest } from '../lib/sendEphemeralRequest'; @@ -28,13 +28,7 @@ export function useIntrospectGraphQL( const [schema, setSchema] = useState(null); const queryClient = useQueryClient(); - const introspection = useQuery({ - queryKey: ['introspection', baseRequest.id], - queryFn: async () => - invoke('plugin:yaak-models|get_graphql_introspection', { - requestId: baseRequest.id, - }), - }); + const introspection = useIntrospectionResult(baseRequest); const upsertIntrospection = useCallback( async (content: string | null) => { @@ -126,7 +120,22 @@ export function useIntrospectGraphQL( return { schema, isLoading, error, refetch, clear }; } -export function useCurrentGraphQLSchema(request: HttpRequest) { - const { schema } = useIntrospectGraphQL(request, { disabled: true }); - return schema; +function useIntrospectionResult(request: HttpRequest) { + return useQuery({ + queryKey: ['introspection', request.id], + queryFn: async () => + invoke('plugin:yaak-models|get_graphql_introspection', { + requestId: request.id, + }), + }); +} + +export function useCurrentGraphQLSchema(request: HttpRequest) { + const result = useIntrospectionResult(request); + return useMemo(() => { + if (result.data == null) return null; + if (result.data.content == null || result.data.content === '') return null; + const schema = buildClientSchema(JSON.parse(result.data.content).data); + return schema; + }, [result.data]); }