mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-23 09:08:32 +02:00
GraphQL Documentation explorer (#208)
This commit is contained in:
5
src-web/atoms/graphqlSchemaAtom.ts
Normal file
5
src-web/atoms/graphqlSchemaAtom.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { atom } from "jotai";
|
||||||
|
import type { GraphQLSchema } from "graphql/index";
|
||||||
|
|
||||||
|
export const graphqlSchemaAtom = atom<GraphQLSchema | null>(null);
|
||||||
|
export const graphqlDocStateAtom = atom<boolean>(false);
|
||||||
766
src-web/components/GraphQLDocsExplorer.tsx
Normal file
766
src-web/components/GraphQLDocsExplorer.tsx
Normal file
@@ -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<ReturnType<GraphQLSchema['getQueryType']>>[])
|
||||||
|
.reduce(
|
||||||
|
(
|
||||||
|
prev,
|
||||||
|
curr
|
||||||
|
) => {
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[curr.name]: curr,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{} as Record<string, NonNullable<ReturnType<GraphQLSchema['getQueryType']>>>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ReturnType<GraphQLSchema['getQueryType']>>;
|
||||||
|
type FieldsMap = ReturnType<Field['getFields']>;
|
||||||
|
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<SchemaPointer>(null);
|
||||||
|
const [history, setHistory] = useState<HistoryRecord[]>([]);
|
||||||
|
const [searchIndex, setSearchIndex] = useState<SearchIndexRecord[]>([]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||||
|
const [searchResults, setSearchResults] = useState<SearchIndexRecord[]>([]);
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('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 (
|
||||||
|
<div
|
||||||
|
className="mt-5 flex flex-col gap-3"
|
||||||
|
>
|
||||||
|
{
|
||||||
|
Object
|
||||||
|
.values(rootTypes)
|
||||||
|
.map(
|
||||||
|
(x) => (
|
||||||
|
<button
|
||||||
|
key={ x.name }
|
||||||
|
className="block text-primary cursor-pointer w-fit"
|
||||||
|
onClick={
|
||||||
|
() => {
|
||||||
|
addToHistory({
|
||||||
|
schemaPointer: x,
|
||||||
|
viewMode: 'explorer',
|
||||||
|
});
|
||||||
|
setSchemaPointer(x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{ x.name }
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractActualType = (
|
||||||
|
type: GraphQLField<never, never>['type'] | GraphQLInputType
|
||||||
|
) => {
|
||||||
|
// check if non-null
|
||||||
|
if (isNonNullType(type) || isListType(type)) {
|
||||||
|
return extractActualType((type as GraphQLNonNull<GraphQLOutputType>).ofType)
|
||||||
|
}
|
||||||
|
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTypeClick = (
|
||||||
|
type: GraphQLField<never, never>['type'] | GraphQLInputType
|
||||||
|
) => {
|
||||||
|
// check if non-null
|
||||||
|
if (isNonNullType(type)) {
|
||||||
|
onTypeClick((type as GraphQLNonNull<GraphQLOutputType>).ofType)
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if list
|
||||||
|
if (isListType(type)) {
|
||||||
|
onTypeClick((type as GraphQLList<GraphQLOutputType>).ofType);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSchemaPointer(type);
|
||||||
|
addToHistory({
|
||||||
|
schemaPointer: type as Field,
|
||||||
|
viewMode: 'explorer',
|
||||||
|
});
|
||||||
|
setViewMode('explorer');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFieldClick = (field: GraphQLField<any, any>) => {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className="flex flex-row justify-start items-center"
|
||||||
|
>
|
||||||
|
{
|
||||||
|
options?.addable
|
||||||
|
? (
|
||||||
|
<IconButton size="sm" icon="plus_circle" iconColor="secondary" title="Add to query"/>
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
<div
|
||||||
|
className="flex flex-col"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
{ " " }
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="cursor-pointer text-primary"
|
||||||
|
onClick={ () => onFieldClick(field) }
|
||||||
|
>
|
||||||
|
{ field.name }
|
||||||
|
</button>
|
||||||
|
{/* Arguments block */ }
|
||||||
|
{
|
||||||
|
field.args && field.args.length > 0
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<span>
|
||||||
|
{ " " }
|
||||||
|
(
|
||||||
|
{ " " }
|
||||||
|
</span>
|
||||||
|
{
|
||||||
|
field.args.map(
|
||||||
|
(arg, i, array) => (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
key={ arg.name }
|
||||||
|
onClick={ () => onTypeClick(arg.type) }
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="text-primary cursor-pointer"
|
||||||
|
>
|
||||||
|
{ arg.name }
|
||||||
|
</span>
|
||||||
|
<span>{ " " }</span>
|
||||||
|
<span
|
||||||
|
className="text-success underline cursor-pointer"
|
||||||
|
>{ arg.type.toString() }</span>
|
||||||
|
{
|
||||||
|
i < array.length - 1
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<span>{ " " }</span>
|
||||||
|
<span> , </span>
|
||||||
|
<span>{ " " }</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
<span>{ " " }</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
<span>
|
||||||
|
)
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
{/* End of Arguments Block */ }
|
||||||
|
<span>{ " " }</span>
|
||||||
|
<button
|
||||||
|
className="text-success underline cursor-pointer"
|
||||||
|
onClick={ () => onTypeClick(field.type) }
|
||||||
|
>
|
||||||
|
{ field.type.toString() }
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
field.description
|
||||||
|
? (
|
||||||
|
<div>
|
||||||
|
{ field.description }
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderScalarField = () => {
|
||||||
|
const scalarField = schemaPointer as GraphQLScalarType;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{ scalarField.toConfig().description }
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="text-primary mt-5"
|
||||||
|
>
|
||||||
|
{ (schemaPointer as Field).name }
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
(schemaPointer as Field).getFields
|
||||||
|
? (
|
||||||
|
<div
|
||||||
|
className="my-3"
|
||||||
|
>
|
||||||
|
Fields
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-7"
|
||||||
|
>
|
||||||
|
{ renderSubFields() }
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderExplorerView = () => {
|
||||||
|
if (history.length === 0) {
|
||||||
|
return renderRootTypes();
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderFieldDocView()
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFieldView = () => {
|
||||||
|
if (!schemaPointer) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const field = schemaPointer as GraphQLField<any, any>;
|
||||||
|
const returnType = extractActualType(field.type);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="text-primary mt-10"
|
||||||
|
>
|
||||||
|
{ field.name }
|
||||||
|
</div>
|
||||||
|
{/* Arguments */}
|
||||||
|
{
|
||||||
|
field.args && field.args.length > 0
|
||||||
|
? (
|
||||||
|
<div
|
||||||
|
className="mt-8"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
Arguments
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="mt-2"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{
|
||||||
|
field.args.map(
|
||||||
|
(arg, i, array) => (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
key={ arg.name }
|
||||||
|
onClick={ () => onTypeClick(arg.type) }
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="text-primary cursor-pointer"
|
||||||
|
>
|
||||||
|
{ arg.name }
|
||||||
|
</span>
|
||||||
|
<span>{ " " }</span>
|
||||||
|
<span
|
||||||
|
className="text-success underline cursor-pointer"
|
||||||
|
>{ arg.type.toString() }</span>
|
||||||
|
{
|
||||||
|
i < array.length - 1
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<span>{ " " }</span>
|
||||||
|
<span> , </span>
|
||||||
|
<span>{ " " }</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
<span>{ " " }</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
{/* End of Arguments */}
|
||||||
|
{/* Return type */}
|
||||||
|
<div
|
||||||
|
className="mt-8"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
Type
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-primary mt-2"
|
||||||
|
>
|
||||||
|
{ returnType.name }
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* End of Return type */}
|
||||||
|
{/* Fields */}
|
||||||
|
{
|
||||||
|
(returnType as GraphQLObjectType).getFields && Object.values((returnType as GraphQLObjectType).getFields()).length > 0
|
||||||
|
? (
|
||||||
|
<div
|
||||||
|
className="mt-8"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
Fields
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-3 mt-2"
|
||||||
|
>
|
||||||
|
{
|
||||||
|
Object.values((returnType as GraphQLObjectType).getFields())
|
||||||
|
.map(
|
||||||
|
(x) => renderSubFieldRecord(x)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
{/* End of Fields */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderTopBar = () => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-row gap-2"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
onClick={ goBack }
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<IconButton
|
||||||
|
onClick={ goHome }
|
||||||
|
icon="house"
|
||||||
|
title="Go to beginning"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSearchView = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="mt-5 text-primary"
|
||||||
|
>
|
||||||
|
Search results
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="mt-4 flex flex-col gap-3"
|
||||||
|
>
|
||||||
|
{
|
||||||
|
searchResults
|
||||||
|
.map(
|
||||||
|
(result) => (
|
||||||
|
<button
|
||||||
|
key={`${result.name}-${result.type}`}
|
||||||
|
className="cursor-pointer border border-1 border-border-subtle rounded-md p-2 flex flex-row justify-between hover:bg-surface-highlight transition-colors"
|
||||||
|
onClick={
|
||||||
|
() => {
|
||||||
|
if (!result.schemaPointer) {
|
||||||
|
throw new Error('somehow search result record contains no schema pointer');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(result);
|
||||||
|
|
||||||
|
if (result.type === 'type') {
|
||||||
|
onTypeClick(result.schemaPointer);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onFieldClick(result.schemaPointer as unknown as GraphQLField<any, any>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex flex-row"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
{ result.name }
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
result.args
|
||||||
|
? (
|
||||||
|
<div
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
{ "( " }
|
||||||
|
{ result.args }
|
||||||
|
{ " )" }
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
{ result.type }
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderView = () => {
|
||||||
|
if (viewMode === 'field') {
|
||||||
|
return renderFieldView();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewMode === 'search') {
|
||||||
|
return renderSearchView();
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderExplorerView();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="overflow-y-auto pe-3"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="min-h-[35px]"
|
||||||
|
>
|
||||||
|
{
|
||||||
|
history.length > 0 || viewMode === 'search'
|
||||||
|
? renderTopBar()
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
{/* Search bar */}
|
||||||
|
<div
|
||||||
|
className="relative"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
label="Search docs"
|
||||||
|
stateKey="search_graphql_docs"
|
||||||
|
placeholder="Search docs"
|
||||||
|
hideLabel
|
||||||
|
defaultValue={searchQuery}
|
||||||
|
onChange={
|
||||||
|
(value) => {
|
||||||
|
setSearchQuery(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onKeyDown={
|
||||||
|
(e) => {
|
||||||
|
// check if enter
|
||||||
|
if (e.key === 'Enter' && viewMode !== 'search') {
|
||||||
|
addToHistory({
|
||||||
|
schemaPointer: null,
|
||||||
|
viewMode: 'search',
|
||||||
|
})
|
||||||
|
setViewMode('search');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* End of search bar */}
|
||||||
|
<div>
|
||||||
|
{ renderView() }
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GraphQLDocsExplorer() {
|
||||||
|
const graphqlSchema = useAtomValue(graphqlSchemaAtom);
|
||||||
|
|
||||||
|
if (graphqlSchema) {
|
||||||
|
return <DocsExplorer graphqlSchema={ graphqlSchema }/>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div>There is no schema</div>;
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ import { Editor } from './core/Editor/Editor';
|
|||||||
import { FormattedError } from './core/FormattedError';
|
import { FormattedError } from './core/FormattedError';
|
||||||
import { Icon } from './core/Icon';
|
import { Icon } from './core/Icon';
|
||||||
import { Separator } from './core/Separator';
|
import { Separator } from './core/Separator';
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { graphqlDocStateAtom, graphqlSchemaAtom } from "../atoms/graphqlSchemaAtom";
|
||||||
|
|
||||||
type Props = Pick<EditorProps, 'heightMode' | 'className' | 'forceUpdateKey'> & {
|
type Props = Pick<EditorProps, 'heightMode' | 'className' | 'forceUpdateKey'> & {
|
||||||
baseRequest: HttpRequest;
|
baseRequest: HttpRequest;
|
||||||
@@ -45,6 +47,8 @@ export function GraphQLEditor({ request, onChange, baseRequest, ...extraEditorPr
|
|||||||
|
|
||||||
return { query: request.body.query ?? '', variables: request.body.variables ?? '' };
|
return { query: request.body.query ?? '', variables: request.body.variables ?? '' };
|
||||||
}, [extraEditorProps.forceUpdateKey]);
|
}, [extraEditorProps.forceUpdateKey]);
|
||||||
|
const [, setGraphqlSchemaAtomValue] = useAtom(graphqlSchemaAtom);
|
||||||
|
const [isDocOpen, setGraphqlDocStateAtomValue] = useAtom(graphqlDocStateAtom);
|
||||||
|
|
||||||
const handleChangeQuery = (query: string) => {
|
const handleChangeQuery = (query: string) => {
|
||||||
const newBody = { query, variables: currentBody.variables || undefined };
|
const newBody = { query, variables: currentBody.variables || undefined };
|
||||||
@@ -62,100 +66,121 @@ export function GraphQLEditor({ request, onChange, baseRequest, ...extraEditorPr
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editorViewRef.current == null) return;
|
if (editorViewRef.current == null) return;
|
||||||
updateSchema(editorViewRef.current, schema ?? undefined);
|
updateSchema(editorViewRef.current, schema ?? undefined);
|
||||||
}, [schema]);
|
setGraphqlSchemaAtomValue(schema);
|
||||||
|
}, [schema, setGraphqlSchemaAtomValue]);
|
||||||
|
|
||||||
const actions = useMemo<EditorProps['actions']>(
|
const actions = useMemo<EditorProps['actions']>(
|
||||||
() => [
|
() => [
|
||||||
<div key="introspection" className="!opacity-100">
|
<div
|
||||||
{schema === undefined ? null /* Initializing */ : (
|
key="actions"
|
||||||
<Dropdown
|
className="flex flex-row !opacity-100 !shadow"
|
||||||
items={[
|
>
|
||||||
{
|
<div>
|
||||||
hidden: !error,
|
{ schema === undefined ? null /* Initializing */ : (
|
||||||
label: (
|
|
||||||
<Banner color="danger">
|
|
||||||
<p className="mb-1">Schema introspection failed</p>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
color="danger"
|
|
||||||
variant="border"
|
|
||||||
onClick={() => {
|
|
||||||
showDialog({
|
|
||||||
title: 'Introspection Failed',
|
|
||||||
size: 'sm',
|
|
||||||
id: 'introspection-failed',
|
|
||||||
render: ({ hide }) => (
|
|
||||||
<>
|
|
||||||
<FormattedError>{error ?? 'unknown'}</FormattedError>
|
|
||||||
<div className="w-full my-4">
|
|
||||||
<Button
|
|
||||||
onClick={async () => {
|
|
||||||
hide();
|
|
||||||
await refetch();
|
|
||||||
}}
|
|
||||||
className="ml-auto"
|
|
||||||
color="primary"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
Retry Request
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
View Error
|
|
||||||
</Button>
|
|
||||||
</Banner>
|
|
||||||
),
|
|
||||||
type: 'content',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Refetch',
|
|
||||||
leftSlot: <Icon icon="refresh" />,
|
|
||||||
onSelect: refetch,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Clear',
|
|
||||||
onSelect: clear,
|
|
||||||
hidden: !schema,
|
|
||||||
color: 'danger',
|
|
||||||
leftSlot: <Icon icon="trash" />,
|
|
||||||
},
|
|
||||||
{ type: 'separator', label: 'Setting' },
|
|
||||||
{
|
|
||||||
label: 'Automatic Introspection',
|
|
||||||
onSelect: () => {
|
|
||||||
setAutoIntrospectDisabled({
|
|
||||||
...autoIntrospectDisabled,
|
|
||||||
[baseRequest.id]: !autoIntrospectDisabled?.[baseRequest.id],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
leftSlot: (
|
|
||||||
<Icon
|
|
||||||
icon={
|
|
||||||
autoIntrospectDisabled?.[baseRequest.id]
|
|
||||||
? 'check_square_unchecked'
|
|
||||||
: 'check_square_checked'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
|
onClick={() => setGraphqlDocStateAtomValue(!isDocOpen)}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="border"
|
variant="border"
|
||||||
title="Refetch Schema"
|
title="Open Documentation"
|
||||||
isLoading={isLoading}
|
className="me-1"
|
||||||
color={error ? 'danger' : 'default'}
|
|
||||||
forDropdown
|
|
||||||
>
|
>
|
||||||
{error ? 'Introspection Failed' : schema ? 'Schema' : 'No Schema'}
|
<Icon
|
||||||
|
icon="book_open_text"
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</Dropdown>
|
) }
|
||||||
)}
|
</div>
|
||||||
|
<div key="introspection" className="!opacity-100">
|
||||||
|
{schema === undefined ? null /* Initializing */ : (
|
||||||
|
<Dropdown
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
hidden: !error,
|
||||||
|
label: (
|
||||||
|
<Banner color="danger">
|
||||||
|
<p className="mb-1">Schema introspection failed</p>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
color="danger"
|
||||||
|
variant="border"
|
||||||
|
onClick={() => {
|
||||||
|
showDialog({
|
||||||
|
title: 'Introspection Failed',
|
||||||
|
size: 'sm',
|
||||||
|
id: 'introspection-failed',
|
||||||
|
render: ({ hide }) => (
|
||||||
|
<>
|
||||||
|
<FormattedError>{error ?? 'unknown'}</FormattedError>
|
||||||
|
<div className="w-full my-4">
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
hide();
|
||||||
|
await refetch();
|
||||||
|
}}
|
||||||
|
className="ml-auto"
|
||||||
|
color="primary"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Retry Request
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
View Error
|
||||||
|
</Button>
|
||||||
|
</Banner>
|
||||||
|
),
|
||||||
|
type: 'content',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Refetch',
|
||||||
|
leftSlot: <Icon icon="refresh" />,
|
||||||
|
onSelect: refetch,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Clear',
|
||||||
|
onSelect: clear,
|
||||||
|
hidden: !schema,
|
||||||
|
color: 'danger',
|
||||||
|
leftSlot: <Icon icon="trash" />,
|
||||||
|
},
|
||||||
|
{ type: 'separator', label: 'Setting' },
|
||||||
|
{
|
||||||
|
label: 'Automatic Introspection',
|
||||||
|
onSelect: () => {
|
||||||
|
setAutoIntrospectDisabled({
|
||||||
|
...autoIntrospectDisabled,
|
||||||
|
[baseRequest.id]: !autoIntrospectDisabled?.[baseRequest.id],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
leftSlot: (
|
||||||
|
<Icon
|
||||||
|
icon={
|
||||||
|
autoIntrospectDisabled?.[baseRequest.id]
|
||||||
|
? 'check_square_unchecked'
|
||||||
|
: 'check_square_checked'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="border"
|
||||||
|
title="Refetch Schema"
|
||||||
|
isLoading={isLoading}
|
||||||
|
color={error ? 'danger' : 'default'}
|
||||||
|
forDropdown
|
||||||
|
>
|
||||||
|
{error ? 'Introspection Failed' : schema ? 'Schema' : 'No Schema'}
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -167,6 +192,8 @@ export function GraphQLEditor({ request, onChange, baseRequest, ...extraEditorPr
|
|||||||
clear,
|
clear,
|
||||||
schema,
|
schema,
|
||||||
setAutoIntrospectDisabled,
|
setAutoIntrospectDisabled,
|
||||||
|
isDocOpen,
|
||||||
|
setGraphqlDocStateAtomValue
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import type { HttpRequest } from '@yaakapp-internal/models';
|
|||||||
import { SplitLayout } from './core/SplitLayout';
|
import { SplitLayout } from './core/SplitLayout';
|
||||||
import { HttpRequestPane } from './HttpRequestPane';
|
import { HttpRequestPane } from './HttpRequestPane';
|
||||||
import { HttpResponsePane } from './HttpResponsePane';
|
import { HttpResponsePane } from './HttpResponsePane';
|
||||||
|
import { GraphQLDocsExplorer } from "./GraphQLDocsExplorer";
|
||||||
|
import {
|
||||||
|
useAtomValue
|
||||||
|
} from 'jotai';
|
||||||
|
import { graphqlDocStateAtom } from "../atoms/graphqlSchemaAtom";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
activeRequest: HttpRequest;
|
activeRequest: HttpRequest;
|
||||||
@@ -11,6 +16,11 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function HttpRequestLayout({ activeRequest, style }: Props) {
|
export function HttpRequestLayout({ activeRequest, style }: Props) {
|
||||||
|
const {
|
||||||
|
bodyType,
|
||||||
|
} = activeRequest;
|
||||||
|
const isDocOpen = useAtomValue(graphqlDocStateAtom);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SplitLayout
|
<SplitLayout
|
||||||
name="http_layout"
|
name="http_layout"
|
||||||
@@ -23,7 +33,24 @@ export function HttpRequestLayout({ activeRequest, style }: Props) {
|
|||||||
fullHeight={orientation === 'horizontal'}
|
fullHeight={orientation === 'horizontal'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
secondSlot={({ style }) => <HttpResponsePane activeRequestId={activeRequest.id} style={style} />}
|
secondSlot={
|
||||||
|
bodyType === 'graphql' && isDocOpen
|
||||||
|
? () => (
|
||||||
|
<SplitLayout
|
||||||
|
name="http_response_layout"
|
||||||
|
className="gap-1.5"
|
||||||
|
firstSlot={
|
||||||
|
({ style }) => <HttpResponsePane activeRequestId={activeRequest.id} style={style} />
|
||||||
|
}
|
||||||
|
secondSlot={
|
||||||
|
() => <GraphQLDocsExplorer />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
({ style }) => <HttpResponsePane activeRequestId={activeRequest.id} style={style} />
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const icons = {
|
|||||||
arrow_up_from_line: lucide.ArrowUpFromLineIcon,
|
arrow_up_from_line: lucide.ArrowUpFromLineIcon,
|
||||||
badge_check: lucide.BadgeCheckIcon,
|
badge_check: lucide.BadgeCheckIcon,
|
||||||
box: lucide.BoxIcon,
|
box: lucide.BoxIcon,
|
||||||
|
book_open_text: lucide.BookOpenText,
|
||||||
cake: lucide.CakeIcon,
|
cake: lucide.CakeIcon,
|
||||||
chat: lucide.MessageSquare,
|
chat: lucide.MessageSquare,
|
||||||
check: lucide.CheckIcon,
|
check: lucide.CheckIcon,
|
||||||
|
|||||||
Reference in New Issue
Block a user