From 01f9c072a70e27889c65d5494308f85c173b4c6a Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Mon, 7 Jul 2025 13:41:26 -0700 Subject: [PATCH] I think we're good --- src-web/atoms/graphqlSchemaAtom.ts | 4 +- src-web/components/GraphQLDocsExplorer.tsx | 954 +++++++++------------ src-web/components/GraphQLEditor.tsx | 35 +- src-web/components/HttpRequestLayout.tsx | 40 +- src-web/components/core/Icon.tsx | 1 + 5 files changed, 459 insertions(+), 575 deletions(-) diff --git a/src-web/atoms/graphqlSchemaAtom.ts b/src-web/atoms/graphqlSchemaAtom.ts index 70601ba6..b9761c06 100644 --- a/src-web/atoms/graphqlSchemaAtom.ts +++ b/src-web/atoms/graphqlSchemaAtom.ts @@ -1,5 +1,5 @@ -import { atom } from "jotai"; -import type { GraphQLSchema } from "graphql/index"; +import type { GraphQLSchema } from 'graphql'; +import { atom } from 'jotai'; export const graphqlSchemaAtom = atom(null); export const graphqlDocStateAtom = atom(false); diff --git a/src-web/components/GraphQLDocsExplorer.tsx b/src-web/components/GraphQLDocsExplorer.tsx index 3940f59b..4f4daa0a 100644 --- a/src-web/components/GraphQLDocsExplorer.tsx +++ b/src-web/components/GraphQLDocsExplorer.tsx @@ -1,543 +1,445 @@ -import { useAtomValue } from 'jotai'; -import { graphqlSchemaAtom } from '../atoms/graphqlSchemaAtom'; -import { Input } from './core/Input'; -import type { - GraphQLSchema, - GraphQLOutputType, - GraphQLScalarType, - GraphQLField, - GraphQLList, - GraphQLInputType, - GraphQLNonNull, - GraphQLObjectType, +/* eslint-disable */ +import { Color } from '@yaakapp-internal/plugins'; +import classNames from 'classnames'; +import type { GraphQLField, GraphQLInputField, GraphQLType } from 'graphql'; +import { + getNamedType, + isInputObjectType, + isListType, + isNonNullType, + isObjectType, + isScalarType, + isEnumType, + isUnionType, + isInterfaceType, } from 'graphql'; -import { isNonNullType, isListType } from 'graphql'; -import { Button } from './core/Button'; -import { useEffect, useState } from 'react'; -import { IconButton } from './core/IconButton'; -import { fuzzyFilter } from 'fuzzbunny'; +import { useAtomValue } from 'jotai'; +import { ReactNode, useState } from 'react'; +import { graphqlSchemaAtom } from '../atoms/graphqlSchemaAtom'; +import { Icon } from './core/Icon'; +import { Markdown } from './Markdown'; -function getRootTypes(graphqlSchema: GraphQLSchema) { - return ( - [ - graphqlSchema.getQueryType(), - graphqlSchema.getMutationType(), - graphqlSchema.getSubscriptionType(), - ].filter(Boolean) as NonNullable>[] - ).reduce( - (prev, curr) => { - return { - ...prev, - [curr.name]: curr, - }; - }, - {} as Record>>, - ); -} +type ExplorerItem = + | { kind: 'type'; type: GraphQLType; from: ExplorerItem } + | { kind: 'field'; type: GraphQLField; from: ExplorerItem } + | { kind: 'input_field'; type: GraphQLInputField; from: ExplorerItem } + | null; -function getTypeIndices( - type: GraphQLAnyType, - context: IndexGenerationContext, -): SearchIndexRecord[] { - const indices: SearchIndexRecord[] = []; +export function GraphQLDocsExplorer() { + const graphqlSchema = useAtomValue(graphqlSchemaAtom); + const [activeItem, setActiveItem] = useState(null); - if (!(type as GraphQLObjectType).name) { - return indices; + if (!graphqlSchema) { + return
No GraphQL schema available
; } - indices.push({ - name: (type as GraphQLObjectType).name, - type: 'type', - schemaPointer: type, - args: '', - }); + const qryType = graphqlSchema.getQueryType(); + const mutType = graphqlSchema.getMutationType(); + const subType = graphqlSchema.getSubscriptionType(); - if ((type as GraphQLObjectType).getFields) { - indices.push(...getFieldsIndices((type as GraphQLObjectType).getFields(), context)); - } - - // remove duplicates from index - return indices.filter( - (x, i, array) => array.findIndex((y) => y.name === x.name && y.type === x.type) === i, - ); -} - -function getFieldsIndices( - fieldMap: FieldsMap, - context: IndexGenerationContext, -): SearchIndexRecord[] { - const indices: SearchIndexRecord[] = []; - - Object.values(fieldMap).forEach((field) => { - if (!field.name) { - return; - } - - const args = - field.args && field.args.length > 0 ? field.args.map((arg) => arg.name).join(', ') : ''; - - indices.push({ - name: field.name, - type: context.rootType, - schemaPointer: field as unknown as Field, - args, - }); - - if (field.type) { - indices.push(...getTypeIndices(field.type, context)); - } - }); - - // remove duplicates from index - return indices.filter( - (x, i, array) => array.findIndex((y) => y.name === x.name && y.type === x.type) === i, - ); -} - -type Field = NonNullable>; -type FieldsMap = ReturnType; -type GraphQLAnyType = FieldsMap[string]['type']; - -type SearchIndexRecord = { - name: string; - args: string; - type: 'field' | 'type' | 'Query' | 'Mutation' | 'Subscription'; - schemaPointer: SchemaPointer; -}; - -type IndexGenerationContext = { - rootType: 'Query' | 'Mutation' | 'Subscription'; -}; - -type SchemaPointer = Field | GraphQLOutputType | GraphQLInputType | null; - -type ViewMode = 'explorer' | 'search' | 'field'; - -type HistoryRecord = { - schemaPointer: SchemaPointer; - viewMode: ViewMode; -}; - -function DocsExplorer({ graphqlSchema }: { graphqlSchema: GraphQLSchema }) { - const [rootTypes, setRootTypes] = useState(getRootTypes(graphqlSchema)); - const [schemaPointer, setSchemaPointer] = useState(null); - const [history, setHistory] = useState([]); - const [searchIndex, setSearchIndex] = useState([]); - const [searchQuery, setSearchQuery] = useState(''); - const [searchResults, setSearchResults] = useState([]); - const [viewMode, setViewMode] = useState('explorer'); - - useEffect(() => { - setRootTypes(getRootTypes(graphqlSchema)); - }, [graphqlSchema]); - - useEffect(() => { - const typeMap = graphqlSchema.getTypeMap(); - - const index: SearchIndexRecord[] = Object.values(typeMap) - .filter((x) => !x.name.startsWith('__')) - .map((x) => ({ - name: x.name, - type: 'type', - schemaPointer: x, - args: '', - })); - - Object.values(rootTypes).forEach((type) => { - index.push( - ...getFieldsIndices(type.getFields(), { - rootType: type.name as IndexGenerationContext['rootType'], - }), - ); - }); - - setSearchIndex( - index.filter( - (x, i, array) => array.findIndex((y) => y.name === x.name && y.type === x.type) === i, - ), - ); - }, [graphqlSchema, rootTypes]); - - useEffect(() => { - if (!searchQuery) { - setSearchResults([]); - return; - } - - const results = fuzzyFilter(searchIndex, searchQuery, { fields: ['name', 'args'] }) - .sort((a, b) => b.score - a.score) - .map((v) => v.item); - - setSearchResults(results); - }, [searchIndex, searchQuery]); - - const goBack = () => { - if (history.length === 0) { - return; - } - - const newHistory = history.slice(0, history.length - 1); - - const prevHistoryRecord = newHistory[newHistory.length - 1]; - - if (prevHistoryRecord) { - const { schemaPointer: newPointer, viewMode } = prevHistoryRecord; - setHistory(newHistory); - setSchemaPointer(newPointer!); - setViewMode(viewMode); - - return; - } - - goHome(); - }; - - const addToHistory = (historyRecord: HistoryRecord) => { - setHistory([...history, historyRecord]); - }; - - const goHome = () => { - setHistory([]); - setSchemaPointer(null); - setViewMode('explorer'); - }; - - const renderRootTypes = () => { - return ( -
- {Object.values(rootTypes).map((x) => ( - - ))} -
- ); - }; - - const extractActualType = (type: GraphQLField['type'] | GraphQLInputType) => { - // check if non-null - if (isNonNullType(type) || isListType(type)) { - return extractActualType((type as GraphQLNonNull).ofType); - } - - return type; - }; - - const onTypeClick = (type: GraphQLField['type'] | GraphQLInputType) => { - // check if non-null - if (isNonNullType(type)) { - onTypeClick((type as GraphQLNonNull).ofType); - - return; - } - - // check if list - if (isListType(type)) { - onTypeClick((type as GraphQLList).ofType); - - return; - } - - setSchemaPointer(type); - addToHistory({ - schemaPointer: type as Field, - viewMode: 'explorer', - }); - setViewMode('explorer'); - }; - - const onFieldClick = (field: GraphQLField) => { - setSchemaPointer(field as unknown as Field); - setViewMode('field'); - addToHistory({ - schemaPointer: field as unknown as Field, - viewMode: 'field', - }); - }; - - const renderSubFieldRecord = ( - field: FieldsMap[string], - options?: { - addable?: boolean; - }, - ) => { - return ( -
- {options?.addable ? ( - - ) : null} -
-
- - - {/* Arguments block */} - {field.args && field.args.length > 0 ? ( - <> - ( - {field.args.map((arg, i, array) => ( - <> - - - - ))} - ) - - ) : null} - {/* End of Arguments Block */} - - -
- {field.description ?
{field.description}
: null} -
-
- ); - }; - - const renderScalarField = () => { - const scalarField = schemaPointer as GraphQLScalarType; - - return
{scalarField.toConfig().description}
; - }; - - const renderSubFields = () => { - if (!schemaPointer) { - return null; - } - - if (!(schemaPointer as Field).getFields) { - // Scalar field - return renderScalarField(); - } - - if (!(schemaPointer as Field).getFields()) { - return null; - } - - return Object.values((schemaPointer as Field).getFields()).map((x) => - renderSubFieldRecord(x, { addable: true }), - ); - }; - - const renderFieldDocView = () => { - if (!schemaPointer) { - return null; - } - - return ( -
-
{(schemaPointer as Field).name}
- {(schemaPointer as Field).getFields ?
Fields
: null} -
{renderSubFields()}
-
- ); - }; - - const renderExplorerView = () => { - if (history.length === 0) { - return renderRootTypes(); - } - - return renderFieldDocView(); - }; - - const renderFieldView = () => { - if (!schemaPointer) { - return null; - } - - const field = schemaPointer as unknown as GraphQLField; - const returnType = extractActualType(field.type); - - return ( -
-
{field.name}
- {/* Arguments */} - {field.args && field.args.length > 0 ? ( -
-
Arguments
-
-
- {field.args.map((arg, i, array) => ( - <> - - - - ))} -
-
-
- ) : null} - {/* End of Arguments */} - {/* Return type */} -
-
Type
-
{returnType.name}
-
- {/* End of Return type */} - {/* Fields */} - {(returnType as GraphQLObjectType).getFields && - Object.values((returnType as GraphQLObjectType).getFields()).length > 0 ? ( -
-
Fields
-
- {Object.values((returnType as GraphQLObjectType).getFields()).map((x) => - renderSubFieldRecord(x), - )} -
-
- ) : null} - {/* End of Fields */} -
- ); - }; - - const renderTopBar = () => { - return ( -
- - -
- ); - }; - - const renderSearchView = () => { - return ( -
-
Search results
-
- {searchResults.map((result) => ( - - ))} -
-
- ); - }; - - const renderView = () => { - if (viewMode === 'field') { - return renderFieldView(); - } - - if (viewMode === 'search') { - return renderSearchView(); - } - - return renderExplorerView(); - }; + const qryItem: ExplorerItem = qryType ? { kind: 'type', type: qryType, from: null } : null; + const mutItem: ExplorerItem = mutType ? { kind: 'type', type: mutType, from: null } : null; + const subItem: ExplorerItem = subType ? { kind: 'type', type: subType, from: null } : null; + const allTypes = graphqlSchema.getTypeMap(); return ( -
-
- {history.length > 0 || viewMode === 'search' ? renderTopBar() : null} -
- {/* Search bar */} -
- { - setSearchQuery(value); - }} - onKeyDown={(e) => { - // check if enter - if (e.key === 'Enter' && viewMode !== 'search') { - addToHistory({ - schemaPointer: null, - viewMode: 'search', - }); - setViewMode('search'); - } - }} - /> -
- {/* End of search bar */} -
{renderView()}
+
+ {activeItem == null ? ( +
+ Root Types + + + + All Schema Types + {Object.keys(allTypes).map((typeName) => { + const t = allTypes[typeName]!; + return ( + + ); + })} +
+ ) : ( +
+ +
+ +
+
+ )}
); } -export function GraphQLDocsExplorer() { - const graphqlSchema = useAtomValue(graphqlSchemaAtom); +function GraphQLExplorerHeader({ + item, + setItem, +}: { + item: ExplorerItem; + setItem: (t: ExplorerItem) => void; +}) { + if (item == null) return null; - if (graphqlSchema) { - return ; + return ( + + ); +} + +function GqlTypeInfo({ + item, + setItem, +}: { + item: ExplorerItem | null; + setItem: (t: ExplorerItem) => void; +}) { + const graphqlSchema = useAtomValue(graphqlSchemaAtom); + if (item == null) return null; + + const name = item.kind === 'type' ? getNamedType(item.type).name : item.type.name; + const description = + item.kind === 'type' ? getNamedType(item.type).description : item.type.description; + + const heading = ( +
+

{name}

+ {description ?? 'No description'} +
+ ); + + if (isScalarType(item.type)) { + return heading; + } else if (isInterfaceType(item.type)) { + const fields = item.type.getFields(); + const possibleTypes = graphqlSchema?.getPossibleTypes(item.type) ?? []; + + return ( +
+ {heading} + + Fields + {Object.keys(fields).map((fieldName) => { + const field = fields[fieldName]!; + const fieldItem: ExplorerItem = { kind: 'field', type: field, from: item }; + return ( +
+ +
+ ); + })} + + {possibleTypes.length > 0 && ( + <> + Implemented By + {possibleTypes.map((t) => ( + + ))} + + )} +
+ ); + } else if (isUnionType(item.type)) { + const types = item.type.getTypes(); + + return ( +
+ {heading} + + Possible Types + {types.map((t) => ( + + ))} +
+ ); + } else if (isEnumType(item.type)) { + const values = item.type.getValues(); + + return ( +
+ {heading} + +
+ Type + +
+ +
+ Values + {values.map((v) => ( +
+ {v.value} + {v.description ?? ''} +
+ ))} +
+
+ ); + } else if (item.kind === 'field') { + return ( +
+ {heading} + +
+ Type + +
+ + {item.type.args.length > 0 && ( +
+ Arguments + {item.type.args.map((a) => ( +
+ +
+ ))} +
+ )} +
+ ); + } else if (item.kind === 'input_field' && isInputObjectType(item.type)) { + const fields = item.type.getFields(); + return ( +
+ {heading} + + Fields + {Object.keys(fields).map((fieldName) => { + const field = fields[fieldName]; + if (field == null) return null; + const fieldItem: ExplorerItem = { + kind: 'input_field', + type: field, + from: item, + }; + return ( +
+ +
+ ); + })} +
+ ); + } else if (item.kind === 'type' && isInputObjectType(item.type)) { + const fields = item.type.getFields(); + return ( +
+ {heading} + + Fields + {Object.keys(fields).map((fieldName) => { + const field = fields[fieldName]; + if (field == null) return null; + const fieldItem: ExplorerItem = { + kind: 'input_field', + type: field, + from: item, + }; + return ( +
+ +
+ ); + })} +
+ ); + } else if (item.kind === 'type' && isObjectType(item.type)) { + const fields = item.type.getFields(); + + return ( +
+ {heading} + + Fields + {Object.keys(fields).map((fieldName) => { + const field = fields[fieldName]; + if (field == null) return null; + const fieldItem: ExplorerItem = { kind: 'field', type: field, from: item }; + return ( +
+ +
+ ); + })} +
+ ); } - return
There is no schema
; + console.log('Unknown GraphQL Type', item); + return
Unknown GraphQL type
; +} + +function GqlTypeRow({ + item, + setItem, + name, + description, + className, + hideDescription, +}: { + item: ExplorerItem; + name?: string; + description?: string | null; + setItem: (t: ExplorerItem) => void; + className?: string; + hideDescription?: boolean; +}) { + if (item == null) return null; + + let child: ReactNode = <>Unknown Type; + + if (item.kind === 'type') { + child = ( + <> +
+ {name && {name}:}{' '} + +
+ {!hideDescription && ( + + {(description === undefined ? getNamedType(item.type).description : description) ?? ''} + + )} + + ); + } else if (item.kind === 'field') { + const returnItem: ExplorerItem = { + kind: 'type', + type: item.type.type, + from: item.from, + }; + child = ( +
+
+ + {name} + + {item.type.args.length > 0 && ( + <> + ( + {item.type.args.map((arg) => ( +
+ {arg.name}:{' '} + +
+ ))} + ){' '} + + )} + :{' '} + +
+ {item.type.description && ( + {item.type.description} + )} +
+ ); + } else if (item.kind === 'input_field') { + child = ( + <> +
+ {name && {name}:}{' '} + +
+ {item.type.description && ( + {item.type.description} + )} + + ); + } + + return
{child}
; +} + +function GqlTypeLink({ + item, + setItem, + color, + children, +}: { + item: ExplorerItem; + color?: Color; + setItem: (item: ExplorerItem) => void; + children?: string; +}) { + if (item?.kind === 'type' && isListType(item.type)) { + return ( + <> + [ + + {children} + + ] + + ); + } else if (item?.kind === 'type' && isNonNullType(item.type)) { + return ( + <> + + {children} + + ! + + ); + } + + return ( + + ); +} + +function GqlTypeLabel({ item, children }: { item: ExplorerItem; children?: string }) { + let inner; + if (children) { + inner = children; + } else if (item == null) { + inner = 'Root'; + } else if (item.kind === 'type') { + inner = getNamedType(item.type).name; + } else { + inner = getNamedType(item.type.type).name; + } + + return <>{inner}; +} + +function Subheading({ children }: { children: ReactNode }) { + return

{children}

; } diff --git a/src-web/components/GraphQLEditor.tsx b/src-web/components/GraphQLEditor.tsx index 2b762b5b..6404c867 100644 --- a/src-web/components/GraphQLEditor.tsx +++ b/src-web/components/GraphQLEditor.tsx @@ -16,8 +16,8 @@ import { Editor } from './core/Editor/Editor'; import { FormattedError } from './core/FormattedError'; import { Icon } from './core/Icon'; import { Separator } from './core/Separator'; -import { useAtom } from "jotai"; -import { graphqlDocStateAtom, graphqlSchemaAtom } from "../atoms/graphqlSchemaAtom"; +import { useAtom } from 'jotai'; +import { graphqlDocStateAtom, graphqlSchemaAtom } from '../atoms/graphqlSchemaAtom'; type Props = Pick & { baseRequest: HttpRequest; @@ -71,25 +71,7 @@ export function GraphQLEditor({ request, onChange, baseRequest, ...extraEditorPr const actions = useMemo( () => [ -
-
- { schema === undefined ? null /* Initializing */ : ( - - ) } -
+
{schema === undefined ? null /* Initializing */ : ( , + onSelect: () => { + setGraphqlDocStateAtomValue(!isDocOpen); + }, + }, + { + label: 'Introspect Schema', leftSlot: , onSelect: refetch, }, @@ -193,7 +182,7 @@ export function GraphQLEditor({ request, onChange, baseRequest, ...extraEditorPr schema, setAutoIntrospectDisabled, isDocOpen, - setGraphqlDocStateAtomValue + setGraphqlDocStateAtomValue, ], ); diff --git a/src-web/components/HttpRequestLayout.tsx b/src-web/components/HttpRequestLayout.tsx index 61a1d03c..8f1a7fe2 100644 --- a/src-web/components/HttpRequestLayout.tsx +++ b/src-web/components/HttpRequestLayout.tsx @@ -2,13 +2,11 @@ import type { CSSProperties } from 'react'; import React from 'react'; import type { HttpRequest } from '@yaakapp-internal/models'; import { SplitLayout } from './core/SplitLayout'; +import { GraphQLDocsExplorer } from './GraphQLDocsExplorer'; import { HttpRequestPane } from './HttpRequestPane'; import { HttpResponsePane } from './HttpResponsePane'; -import { GraphQLDocsExplorer } from "./GraphQLDocsExplorer"; -import { - useAtomValue -} from 'jotai'; -import { graphqlDocStateAtom } from "../atoms/graphqlSchemaAtom"; +import { useAtomValue } from 'jotai'; +import { graphqlDocStateAtom } from '../atoms/graphqlSchemaAtom'; interface Props { activeRequest: HttpRequest; @@ -16,9 +14,7 @@ interface Props { } export function HttpRequestLayout({ activeRequest, style }: Props) { - const { - bodyType, - } = activeRequest; + const { bodyType } = activeRequest; const isDocOpen = useAtomValue(graphqlDocStateAtom); return ( @@ -34,23 +30,19 @@ export function HttpRequestLayout({ activeRequest, style }: Props) { /> )} secondSlot={ - bodyType === 'graphql' && isDocOpen - ? () => ( - - } - secondSlot={ - () => - } - /> - ) - : ( - ({ style }) => + bodyType === 'graphql' && isDocOpen + ? () => ( + ( + + )} + secondSlot={() => } + /> ) - } + : ({ style }) => + } /> ); } diff --git a/src-web/components/core/Icon.tsx b/src-web/components/core/Icon.tsx index a1053973..22c338d3 100644 --- a/src-web/components/core/Icon.tsx +++ b/src-web/components/core/Icon.tsx @@ -32,6 +32,7 @@ const icons = { check_square_unchecked: lucide.SquareIcon, chevron_down: lucide.ChevronDownIcon, chevron_right: lucide.ChevronRightIcon, + chevron_left: lucide.ChevronLeftIcon, circle_alert: lucide.CircleAlertIcon, circle_fading_arrow_up: lucide.CircleFadingArrowUpIcon, clock: lucide.ClockIcon,