diff --git a/src-web/atoms/graphqlSchemaAtom.ts b/src-web/atoms/graphqlSchemaAtom.ts new file mode 100644 index 00000000..70601ba6 --- /dev/null +++ b/src-web/atoms/graphqlSchemaAtom.ts @@ -0,0 +1,5 @@ +import { atom } from "jotai"; +import type { GraphQLSchema } from "graphql/index"; + +export const graphqlSchemaAtom = atom(null); +export const graphqlDocStateAtom = atom(false); diff --git a/src-web/components/GraphQLDocsExplorer.tsx b/src-web/components/GraphQLDocsExplorer.tsx new file mode 100644 index 00000000..7aeaa4d3 --- /dev/null +++ b/src-web/components/GraphQLDocsExplorer.tsx @@ -0,0 +1,766 @@ +import { + useAtomValue +} from 'jotai'; +import { graphqlSchemaAtom } from "../atoms/graphqlSchemaAtom"; +import { Input } from "./core/Input"; +import type { + GraphQLSchema, + GraphQLOutputType, + GraphQLScalarType, + GraphQLField, + GraphQLList, + GraphQLInputType, + GraphQLNonNull, + GraphQLObjectType +} 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'; + +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>> + ) +} + +function getTypeIndices( + type: GraphQLAnyType, + context: IndexGenerationContext +): SearchIndexRecord[] { + const indices: SearchIndexRecord[] = []; + + if (!(type as GraphQLObjectType).name) { + return indices; + } + + indices.push({ + name: (type as GraphQLObjectType).name, + type: 'type', + schemaPointer: type, + args: '' + }); + + 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 any }) + ) + } + ) + + 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 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(); + }; + + 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() } +
+
+ ); +} + +export function GraphQLDocsExplorer() { + const graphqlSchema = useAtomValue(graphqlSchemaAtom); + + if (graphqlSchema) { + return ; + } + + return
There is no schema
; +} diff --git a/src-web/components/GraphQLEditor.tsx b/src-web/components/GraphQLEditor.tsx index 000c96a2..2b762b5b 100644 --- a/src-web/components/GraphQLEditor.tsx +++ b/src-web/components/GraphQLEditor.tsx @@ -16,6 +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"; type Props = Pick & { baseRequest: HttpRequest; @@ -45,6 +47,8 @@ export function GraphQLEditor({ request, onChange, baseRequest, ...extraEditorPr return { query: request.body.query ?? '', variables: request.body.variables ?? '' }; }, [extraEditorProps.forceUpdateKey]); + const [, setGraphqlSchemaAtomValue] = useAtom(graphqlSchemaAtom); + const [isDocOpen, setGraphqlDocStateAtomValue] = useAtom(graphqlDocStateAtom); const handleChangeQuery = (query: string) => { const newBody = { query, variables: currentBody.variables || undefined }; @@ -62,100 +66,121 @@ export function GraphQLEditor({ request, onChange, baseRequest, ...extraEditorPr useEffect(() => { if (editorViewRef.current == null) return; updateSchema(editorViewRef.current, schema ?? undefined); - }, [schema]); + setGraphqlSchemaAtomValue(schema); + }, [schema, setGraphqlSchemaAtomValue]); const actions = useMemo( () => [ -
- {schema === undefined ? null /* Initializing */ : ( - -

Schema introspection failed

- -
- - ), - }); - }} - > - View Error - - - ), - type: 'content', - }, - { - label: 'Refetch', - leftSlot: , - onSelect: refetch, - }, - { - label: 'Clear', - onSelect: clear, - hidden: !schema, - color: 'danger', - leftSlot: , - }, - { type: 'separator', label: 'Setting' }, - { - label: 'Automatic Introspection', - onSelect: () => { - setAutoIntrospectDisabled({ - ...autoIntrospectDisabled, - [baseRequest.id]: !autoIntrospectDisabled?.[baseRequest.id], - }); - }, - leftSlot: ( - - ), - }, - ]} - > +
+
+ { schema === undefined ? null /* Initializing */ : ( - - )} + ) } +
+
+ {schema === undefined ? null /* Initializing */ : ( + +

Schema introspection failed

+ +
+ + ), + }); + }} + > + View Error + + + ), + type: 'content', + }, + { + label: 'Refetch', + leftSlot: , + onSelect: refetch, + }, + { + label: 'Clear', + onSelect: clear, + hidden: !schema, + color: 'danger', + leftSlot: , + }, + { type: 'separator', label: 'Setting' }, + { + label: 'Automatic Introspection', + onSelect: () => { + setAutoIntrospectDisabled({ + ...autoIntrospectDisabled, + [baseRequest.id]: !autoIntrospectDisabled?.[baseRequest.id], + }); + }, + leftSlot: ( + + ), + }, + ]} + > + + + )} +
, ], [ @@ -167,6 +192,8 @@ export function GraphQLEditor({ request, onChange, baseRequest, ...extraEditorPr clear, schema, setAutoIntrospectDisabled, + isDocOpen, + setGraphqlDocStateAtomValue ], ); diff --git a/src-web/components/HttpRequestLayout.tsx b/src-web/components/HttpRequestLayout.tsx index 44367715..61a1d03c 100644 --- a/src-web/components/HttpRequestLayout.tsx +++ b/src-web/components/HttpRequestLayout.tsx @@ -4,6 +4,11 @@ import type { HttpRequest } from '@yaakapp-internal/models'; import { SplitLayout } from './core/SplitLayout'; import { HttpRequestPane } from './HttpRequestPane'; import { HttpResponsePane } from './HttpResponsePane'; +import { GraphQLDocsExplorer } from "./GraphQLDocsExplorer"; +import { + useAtomValue +} from 'jotai'; +import { graphqlDocStateAtom } from "../atoms/graphqlSchemaAtom"; interface Props { activeRequest: HttpRequest; @@ -11,6 +16,11 @@ interface Props { } export function HttpRequestLayout({ activeRequest, style }: Props) { + const { + bodyType, + } = activeRequest; + const isDocOpen = useAtomValue(graphqlDocStateAtom); + return ( )} - secondSlot={({ style }) => } + secondSlot={ + bodyType === 'graphql' && isDocOpen + ? () => ( + + } + secondSlot={ + () => + } + /> + ) + : ( + ({ style }) => + ) + } /> ); } diff --git a/src-web/components/core/Icon.tsx b/src-web/components/core/Icon.tsx index c95c1660..626cc85c 100644 --- a/src-web/components/core/Icon.tsx +++ b/src-web/components/core/Icon.tsx @@ -23,6 +23,7 @@ const icons = { arrow_up_from_line: lucide.ArrowUpFromLineIcon, badge_check: lucide.BadgeCheckIcon, box: lucide.BoxIcon, + book_open_text: lucide.BookOpenText, cake: lucide.CakeIcon, chat: lucide.MessageSquare, check: lucide.CheckIcon,