Files
yaak/src-web/components/graphql/GraphQLDocsExplorer.tsx
2025-11-23 08:38:13 -08:00

1007 lines
30 KiB
TypeScript

import type { Color } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
import { fuzzyMatch } from 'fuzzbunny';
import type {
GraphQLField,
GraphQLInputField,
GraphQLNamedType,
GraphQLSchema,
GraphQLType,
} from 'graphql';
import {
getNamedType,
isEnumType,
isInputObjectType,
isInterfaceType,
isListType,
isNamedType,
isNonNullType,
isObjectType,
isScalarType,
isUnionType,
} from 'graphql';
import { useAtomValue } from 'jotai';
import type { CSSProperties, HTMLAttributes, KeyboardEvent, ReactNode } 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';
interface Props {
style?: CSSProperties;
schema: GraphQLSchema;
requestId: string;
className?: string;
}
type ExplorerItem =
| { kind: 'type'; type: GraphQLType; from: ExplorerItem }
// biome-ignore lint/suspicious/noExplicitAny: none
| { kind: 'field'; type: GraphQLField<any, any>; from: ExplorerItem }
| { kind: 'input_field'; type: GraphQLInputField; from: ExplorerItem }
| null;
export const GraphQLDocsExplorer = memo(function GraphQLDocsExplorer({
style,
schema,
requestId,
className,
}: Props) {
const [activeItem, setActiveItem] = useState<ExplorerItem>(null);
const qryType = schema.getQueryType();
const mutType = schema.getMutationType();
const subType = schema.getSubscriptionType();
const showField = useAtomValue(showGraphQLDocExplorerAtom)[requestId] ?? null;
useEffect(() => {
if (showField === null) {
setActiveItem(null);
} else {
const isRootParentType =
showField.parentType === 'Query' ||
showField.parentType === 'Mutation' ||
showField.parentType === 'Subscription';
walkTypeGraph(schema, null, (t, from) => {
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)
) {
setActiveItem(toExplorerItem(t, toExplorerItem(from, null)));
return false;
}
if (showField.type === t.name && from?.name === showField.parentType) {
setActiveItem(toExplorerItem(t, toExplorerItem(from, null)));
return false;
}
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;
const subItem: ExplorerItem = subType ? { kind: 'type', type: subType, from: null } : null;
const allTypes = schema.getTypeMap();
const containerRef = useRef<HTMLDivElement>(null);
const containerSize = useContainerSize(containerRef);
return (
<div ref={containerRef} className={classNames(className, 'py-3 mx-3')} style={style}>
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full border border-dashed border-border rounded-lg overflow-hidden">
<GraphQLExplorerHeader
containerHeight={containerSize.height}
item={activeItem}
onClose={() => {
jotaiStore.set(showGraphQLDocExplorerAtom, (v) => ({ ...v, [requestId]: undefined }));
}}
setItem={setActiveItem}
schema={schema}
/>
{activeItem == null ? (
<div className="flex flex-col gap-3 overflow-y-auto h-full w-full px-3 pb-6">
<Heading>Root Types</Heading>
<GqlTypeRow
name={{ value: 'query', color: 'primary' }}
item={qryItem}
setItem={setActiveItem}
className="!my-0"
/>
<GqlTypeRow
name={{ value: 'mutation', color: 'primary' }}
item={mutItem}
setItem={setActiveItem}
className="!my-0"
/>
<GqlTypeRow
name={{ value: 'subscription', color: 'primary' }}
item={subItem}
setItem={setActiveItem}
className="!my-0"
/>
<Subheading count={Object.keys(allTypes).length}>All Schema Types</Subheading>
<DocMarkdown>{schema.description ?? null}</DocMarkdown>
<div className="flex flex-col gap-1">
{Object.values(allTypes).map((t) => {
return (
<GqlTypeLink
key={t.name}
color="notice"
item={{ kind: 'type', type: t, from: null }}
setItem={setActiveItem}
/>
);
})}
</div>
</div>
) : (
<div
key={activeItem.type.toString()} // Reset scroll position to top
className="overflow-y-auto h-full w-full p-3 grid grid-cols-[minmax(0,1fr)]"
>
<GqlTypeInfo item={activeItem} setItem={setActiveItem} schema={schema} />
</div>
)}
</div>
</div>
);
});
function GraphQLExplorerHeader({
item,
setItem,
schema,
onClose,
containerHeight,
}: {
item: ExplorerItem;
setItem: (t: ExplorerItem) => void;
schema: GraphQLSchema;
onClose: () => void;
containerHeight: number;
}) {
const findIt = (t: ExplorerItem): ExplorerItem[] => {
if (t == null) return [null];
return [...findIt(t.from), t];
};
const crumbs = findIt(item);
return (
<nav className="pl-2 pr-1 h-lg grid grid-rows-1 grid-cols-[minmax(0,1fr)_auto] items-center min-w-0 gap-1 z-10">
<div className="@container w-full relative pl-2 pr-1 h-lg grid grid-rows-1 grid-cols-[minmax(0,min-content)_auto] items-center gap-1">
<div className="whitespace-nowrap flex items-center gap-2 text-text-subtle text-sm overflow-x-auto hide-scrollbars">
<Icon icon="book_open_text" />
{crumbs.map((crumb, i) => {
return (
// biome-ignore lint/suspicious/noArrayIndexKey: none
<Fragment key={i}>
{i > 0 && <Icon icon="chevron_right" className="text-text-subtlest" />}
{crumb === item || item == null ? (
<GqlTypeLabel noTruncate item={item} />
) : crumb === item ? null : (
<GqlTypeLink
// biome-ignore lint/suspicious/noArrayIndexKey: none
key={i}
noTruncate
item={crumb}
setItem={setItem}
className="!font-sans !text-sm flex-shrink-0"
/>
)}
</Fragment>
);
})}
</div>
<GqlSchemaSearch
key={item?.type.toString()} // Force reset when changing items
maxHeight={containerHeight}
currentItem={item}
schema={schema}
setItem={(item) => setItem(item)}
className="hidden @[10rem]:block"
/>
</div>
<div className="ml-auto flex gap-1 [&>*]:text-text-subtle">
<IconButton icon="x" size="sm" title="Close documentation explorer" onClick={onClose} />
</div>
</nav>
);
}
function GqlTypeInfo({
item,
setItem,
schema,
}: {
item: ExplorerItem | null;
setItem: (t: ExplorerItem) => void;
schema: GraphQLSchema;
}) {
if (item == null) return null;
const description =
item.kind === 'type' ? getNamedType(item.type).description : item.type.description;
const heading = (
<div className="mb-3">
<Heading>
<GqlTypeLabel item={item} />
</Heading>
<DocMarkdown>{description || 'No description'}</DocMarkdown>
{'deprecationReason' in item.type && item.type.deprecationReason && (
<Banner color="notice">
<DocMarkdown>{item.type.deprecationReason}</DocMarkdown>
</Banner>
)}
</div>
);
if (isScalarType(item.type)) {
return heading;
}
if (isNonNullType(item.type) || isListType(item.type)) {
// kinda a hack, but we'll just unwrap there and show the named type
return (
<GqlTypeInfo
item={toExplorerItem(item.type.ofType, item)}
setItem={setItem}
schema={schema}
/>
);
}
if (isInterfaceType(item.type)) {
const fields = item.type.getFields();
const possibleTypes = schema.getPossibleTypes(item.type) ?? [];
return (
<div>
{heading}
<Subheading count={Object.keys(fields).length}>Fields</Subheading>
{Object.entries(fields).map(([fieldName, field]) => {
const fieldItem: ExplorerItem = toExplorerItem(field, item);
return (
<div key={`${field.type}::${field.name}`} className="my-4">
<GqlTypeRow
item={fieldItem}
setItem={setItem}
name={{ value: fieldName, color: 'primary' }}
/>
</div>
);
})}
{possibleTypes.length > 0 && (
<>
<Subheading>Implemented By</Subheading>
{possibleTypes.map((t) => (
<GqlTypeRow key={t.name} item={toExplorerItem(t, item)} setItem={setItem} />
))}
</>
)}
</div>
);
}
if (isUnionType(item.type)) {
const types = item.type.getTypes();
return (
<div>
{heading}
<Subheading>Possible Types</Subheading>
{types.map((t) => (
<GqlTypeRow key={t.name} item={{ kind: 'type', type: t, from: item }} setItem={setItem} />
))}
</div>
);
}
if (isEnumType(item.type)) {
const values = item.type.getValues();
return (
<div>
{heading}
<Subheading>Values</Subheading>
{values.map((v) => (
<div key={v.name} className="my-4 font-mono text-editor truncate">
<span className="text-primary">{v.value}</span>
<DocMarkdown>{v.description ?? null}</DocMarkdown>
</div>
))}
</div>
);
}
if (item.kind === 'input_field') {
return (
<div className="flex flex-col gap-3">
{heading}
{item.type.defaultValue !== undefined && (
<div>
<Subheading>Default Value</Subheading>
<div className="font-mono text-editor">{JSON.stringify(item.type.defaultValue)}</div>
</div>
)}
<div>
<Subheading>Type</Subheading>
<GqlTypeRow
className="mt-4"
item={{ kind: 'type', type: item.type.type, from: item }}
setItem={setItem}
/>
</div>
</div>
);
}
if (item.kind === 'field') {
return (
<div className="flex flex-col gap-3">
{heading}
<div>
<Subheading>Type</Subheading>
<GqlTypeRow
className="mt-4"
item={{ kind: 'type', type: item.type.type, from: item }}
setItem={setItem}
/>
</div>
{item.type.args.length > 0 && (
<div>
<Subheading>Arguments</Subheading>
{item.type.args.map((a) => {
return (
<div key={`${a.type}::${a.name}`} className="my-4">
<GqlTypeRow
name={{ value: a.name, color: 'info' }}
item={{ kind: 'type', type: a.type, from: item }}
setItem={setItem}
/>
</div>
);
})}
</div>
)}
</div>
);
}
if (isInputObjectType(item.type)) {
const fields = item.type.getFields();
return (
<div>
{heading}
<Subheading count={Object.keys(fields).length}>Fields</Subheading>
{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 (
<div key={`${field.type}::${field.name}`} className="my-4">
<GqlTypeRow
item={fieldItem}
setItem={setItem}
name={{ value: fieldName, color: 'primary' }}
/>
</div>
);
})}
</div>
);
}
if (isObjectType(item.type)) {
const fields = item.type.getFields();
const interfaces = item.type.getInterfaces();
return (
<div>
{heading}
{interfaces.length > 0 && (
<>
<Subheading>Implements</Subheading>
{interfaces.map((i) => (
<GqlTypeRow
key={i.name}
item={{ kind: 'type', type: i, from: item }}
setItem={setItem}
/>
))}
</>
)}
<Subheading count={Object.keys(fields).length}>Fields</Subheading>
{Object.keys(fields).map((fieldName) => {
const field = fields[fieldName];
if (field == null) return null;
const fieldItem: ExplorerItem = { kind: 'field', type: field, from: item };
return (
<div key={`${field.type}::${field.name}`} className="my-4">
<GqlTypeRow
item={fieldItem}
setItem={setItem}
name={{ value: fieldName, color: 'primary' }}
/>
</div>
);
})}
</div>
);
}
console.log('Unknown GraphQL Type', item);
return <div>Unknown GraphQL type</div>;
}
function GqlTypeRow({
item,
setItem,
name,
description,
className,
hideDescription,
}: {
item: ExplorerItem;
name?: { value: string; color: Color };
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 = (
<>
<div className="font-mono text-editor">
{name && (
<span
className={classNames(
name?.color === 'danger' && 'text-danger',
name?.color === 'primary' && 'text-primary',
name?.color === 'success' && 'text-success',
name?.color === 'warning' && 'text-warning',
name?.color === 'notice' && 'text-notice',
name?.color === 'info' && 'text-info',
)}
>
{name.value}:&nbsp;
</span>
)}
<GqlTypeLink color="notice" item={item} setItem={setItem} />
</div>
{!hideDescription && (
<DocMarkdown>
{(description === undefined ? getNamedType(item.type).description : description) ??
null}
</DocMarkdown>
)}
</>
);
} else if (item.kind === 'field') {
const returnItem: ExplorerItem = {
kind: 'type',
type: item.type.type,
from: item.from,
};
child = (
<div>
<div className="font-mono text-editor">
<GqlTypeLink color="info" item={item} setItem={setItem}>
{name?.value}
</GqlTypeLink>
{item.type.args.length > 0 && (
<>
<span className="text-text-subtle">(</span>
{item.type.args.map((arg) => (
<div
key={`${arg.type}::${arg.name}`}
className={classNames(item.type.args.length === 1 && 'inline-flex')}
>
{item.type.args.length > 1 && <>&nbsp;&nbsp;</>}
<span className="text-primary">{arg.name}:</span>&nbsp;
<GqlTypeLink
color="notice"
item={{ kind: 'type', type: arg.type, from: item.from }}
setItem={setItem}
/>
</div>
))}
<span className="text-text-subtle">)</span>
</>
)}
<span className="text-text-subtle">:</span>{' '}
<GqlTypeLink color="notice" item={returnItem} setItem={setItem} />
</div>
<DocMarkdown className="!text-text-subtle mt-0.5">
{item.type.description ?? null}
</DocMarkdown>
</div>
);
} else if (item.kind === 'input_field') {
child = (
<>
<div className="font-mono text-editor">
{name && <span className="text-primary">{name.value}:</span>}{' '}
<GqlTypeLink color="notice" item={item} setItem={setItem} />
</div>
<DocMarkdown>{item.type.description ?? null}</DocMarkdown>
</>
);
}
return <div className={classNames(className, 'w-full min-w-0')}>{child}</div>;
}
function GqlTypeLink({
item,
setItem,
color,
children,
leftSlot,
rightSlot,
onNavigate,
className,
noTruncate,
}: {
item: ExplorerItem;
color?: Color;
setItem: (item: ExplorerItem) => void;
onNavigate?: () => void;
children?: ReactNode;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
className?: string;
noTruncate?: boolean;
}) {
if (item?.kind === 'type' && isListType(item.type)) {
return (
<span className="font-mono text-editor">
<span className="text-text-subtle">[</span>
<GqlTypeLink
item={{ ...item, type: item.type.ofType }}
setItem={setItem}
color={color}
leftSlot={leftSlot}
rightSlot={rightSlot}
>
{children}
</GqlTypeLink>
<span className="text-text-subtle">]</span>
</span>
);
}
if (item?.kind === 'type' && isNonNullType(item.type)) {
return (
<span className="font-mono text-editor">
<GqlTypeLink
item={{ ...item, type: item.type.ofType }}
setItem={setItem}
color={color}
leftSlot={leftSlot}
rightSlot={rightSlot}
>
{children}
</GqlTypeLink>
<span className="text-text-subtle">!</span>
</span>
);
}
return (
<button
type="button"
className={classNames(
className,
'hover:underline text-left mr-auto gap-2 max-w-full',
'inline-flex items-center',
'font-mono text-editor',
!noTruncate && 'truncate',
color === 'danger' && 'text-danger',
color === 'primary' && 'text-primary',
color === 'success' && 'text-success',
color === 'warning' && 'text-warning',
color === 'notice' && 'text-notice',
color === 'info' && 'text-info',
)}
onClick={() => {
setItem(item);
onNavigate?.();
}}
>
{leftSlot}
<GqlTypeLabel item={item} noTruncate={noTruncate}>
{children}
</GqlTypeLabel>
{rightSlot}
</button>
);
}
function GqlTypeLabel({
item,
children,
className,
noTruncate,
}: {
item: ExplorerItem;
children?: ReactNode;
className?: string;
noTruncate?: boolean;
}) {
let inner: ReactNode;
if (children) {
inner = children;
} else if (item == null) {
inner = 'Root';
} else if (item.kind === 'field') {
inner = item.type.name + (item.type.args.length > 0 ? '(…)' : '');
} else if ('name' in item.type) {
inner = item.type.name;
} else {
console.error('Unknown item type', item);
inner = 'UNKNOWN';
}
return <span className={classNames(className, !noTruncate && 'truncate')}>{inner}</span>;
}
function Subheading({ children, count }: { children: ReactNode; count?: number }) {
return (
<h2 className="font-bold text-lg mt-6 flex items-center">
<div className="truncate min-w-0">{children}</div>
{count && <CountBadge count={count} />}
</h2>
);
}
interface SearchResult {
name: string;
// biome-ignore lint/suspicious/noExplicitAny: none
type: GraphQLNamedType | GraphQLField<any, any> | GraphQLInputField;
score: number;
from: GraphQLNamedType | null;
depth: string[];
}
function GqlSchemaSearch({
schema,
currentItem,
setItem,
className,
maxHeight,
}: {
currentItem: ExplorerItem | null;
schema: GraphQLSchema;
setItem: (t: ExplorerItem) => void;
className?: string;
maxHeight: number;
}) {
const [activeResult, setActiveResult] = useStateWithDeps<SearchResult | null>(null, [
currentItem,
]);
const [focused, setOpen] = useState<boolean>(false);
const [value, setValue] = useState<string>('');
const debouncedValue = useDebouncedValue(value, 300);
const menuRef = useRef<HTMLDivElement>(null);
const canSearch =
currentItem == null ||
(isNamedType(currentItem.type) &&
!isEnumType(currentItem.type) &&
!isScalarType(currentItem.type));
const results = useMemo(() => {
const results: SearchResult[] = [];
walkTypeGraph(schema, currentItem?.type ?? null, (type, from, depth) => {
if (type === currentItem?.type) {
return true; // Skip the current type and continue
}
const match = fuzzyMatch(type.name, debouncedValue);
if (match == null) {
// Do nothing
} else {
results.push({ name: type.name, type, score: match.score, from, depth });
}
return true;
});
results.sort((a, b) => {
if (value === '') {
if (a.name.startsWith('_') && !b.name.startsWith('_')) {
// Always sort __<NAME> types to the end when there is no query
return 1;
}
if (a.depth.length !== b.depth.length) {
return a.depth.length - b.depth.length;
}
return a.name.localeCompare(b.name);
}
if (a.depth.length !== b.depth.length) {
return a.depth.length - b.depth.length;
}
if (a.score === 0 && b.score === 0) {
return a.name.localeCompare(b.name);
}
if (a.score === b.score && a.name.length === b.name.length) {
return a.name.localeCompare(b.name);
}
if (a.score === b.score) {
return a.name.length - b.type.name.length;
}
return b.score - a.score;
});
return results.slice(0, 100);
}, [currentItem, schema, debouncedValue, value]);
const activeIndex = useMemo(() => {
const index = (activeResult ? results.indexOf(activeResult) : 0) ?? 0;
return index === -1 ? 0 : index;
}, [activeResult, results]);
const inputRef = useRef<HTMLInputElement>(null);
useClickOutside(menuRef, () => setOpen(false));
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'ArrowDown' || (e.ctrlKey && e.key === 'n')) {
e.preventDefault();
const next = results[activeIndex + 1] ?? results[results.length - 1] ?? null;
setActiveResult(next);
} else if (e.key === 'ArrowUp' || (e.ctrlKey && e.key === 'k')) {
e.preventDefault();
const prev = results[activeIndex - 1] ?? results[0] ?? null;
setActiveResult(prev);
} else if (e.key === 'Escape') {
inputRef.current?.blur();
} else if (e.key === 'Enter') {
const result = activeResult ?? results[0] ?? null;
if (result) {
setItem(toExplorerItem(result?.type, currentItem));
inputRef.current?.blur();
}
}
},
[results, activeIndex, setActiveResult, activeResult, setItem, currentItem],
);
if (!canSearch) return <span />;
return (
<div
className={classNames(
className,
'relative flex items-center bg-surface z-20 min-w-0',
!focused && 'max-w-[6rem] ml-auto',
focused && '!absolute top-0 left-1.5 right-1.5 bottom-0 pt-1.5',
)}
>
<PlainInput
ref={inputRef}
size="sm"
label="search"
hideLabel
defaultValue={value}
placeholder={focused ? `Search ${currentItem?.type.toString() ?? 'Schema'}` : 'Search'}
leftSlot={
<div className="w-10 flex justify-center items-center">
<Icon size="sm" icon="search" color="secondary" />
</div>
}
onChange={setValue}
onKeyDownCapture={handleKeyDown}
onFocus={() => {
setOpen(true);
}}
/>
<div
ref={menuRef}
style={{ maxHeight: maxHeight - 60 }}
className={classNames(
'x-theme-menu absolute z-10 mt-0.5 p-1.5 top-full right-0 bg-surface',
'border border-border rounded-lg overflow-y-auto w-full shadow-lg',
!focused && 'hidden',
)}
>
{results.length === 0 && (
<SearchResult isActive={false} className="text-text-subtle">
No results found
</SearchResult>
)}
{results.map((r, i) => {
const item = toExplorerItem(r.type, currentItem);
if (item === currentItem) return null;
return (
<SearchResult
key={`${i}::${r.type.name}`}
onMouseDown={() => {
setItem(item);
setOpen(false);
}}
onMouseEnter={() => setActiveResult(r)}
isActive={i === activeIndex}
>
{r.from !== currentItem?.type && r.from != null && (
<>
<GqlTypeLabel
item={toExplorerItem(r.from, currentItem)}
className="text-text-subtle"
/>
.
</>
)}
<GqlTypeLabel item={item} className="text-text" />
</SearchResult>
);
})}
</div>
</div>
);
}
function SearchResult({
isActive,
className,
...extraProps
}: {
isActive: boolean;
children: ReactNode;
} & HTMLAttributes<HTMLButtonElement>) {
const initRef = useCallback(
(el: HTMLButtonElement | null) => {
if (el === null) return;
if (isActive) {
el.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
}
},
[isActive],
);
return (
<button
ref={initRef}
className={classNames(
className,
'px-3 truncate w-full text-left h-sm rounded text-editor font-mono',
isActive && 'bg-surface-highlight',
)}
{...extraProps}
/>
);
}
function Heading({ children }: { children: ReactNode }) {
return <h1 className="font-bold text-2xl truncate">{children}</h1>;
}
function DocMarkdown({ children, className }: { children: string | null; className?: string }) {
return (
<Markdown className={classNames(className, '!text-text-subtle italic')}>{children}</Markdown>
);
}
function walkTypeGraph(
schema: GraphQLSchema,
// biome-ignore lint/suspicious/noExplicitAny: none
start: GraphQLType | GraphQLField<any, any> | GraphQLInputField | null,
cb: (
// biome-ignore lint/suspicious/noExplicitAny: none
type: GraphQLNamedType | GraphQLField<any, any> | GraphQLInputField,
from: GraphQLNamedType | null,
path: string[],
) => boolean,
) {
const visited = new Set<string>();
const queue: Array<{
// biome-ignore lint/suspicious/noExplicitAny: none
current: GraphQLType | GraphQLField<any, any> | GraphQLInputField;
from: GraphQLNamedType | null;
path: string[];
}> = [];
const initial = start
? [start]
: [
...Object.values(schema.getTypeMap()),
schema.getQueryType(),
schema.getMutationType(),
schema.getSubscriptionType(),
].filter((t) => t != null);
for (const type of initial) {
queue.push({ current: type, from: null, path: [] });
}
while (queue.length > 0) {
// biome-ignore lint/style/noNonNullAssertion: none
const { current, from, path } = queue.shift()!;
if (!isNamedType(current)) continue;
const name = current.name;
if (visited.has(name)) continue;
visited.add(name);
const cont = cb(current, from, path);
if (!cont) break;
if (isObjectType(current) || isInterfaceType(current)) {
for (const field of Object.values(current.getFields())) {
cb(field, current, [...path, current.name]);
const fieldType = getNamedType(field.type);
const next = schema.getType(fieldType.name);
if (next && !visited.has(fieldType.name)) {
queue.push({
current: next,
from: current,
path: [...path, current.name, field.name],
});
}
}
} else if (isInputObjectType(current)) {
for (const inputField of Object.values(current.getFields())) {
cb(inputField, current, [...path, current.name]);
const fieldType = getNamedType(inputField.type);
const next = schema.getType(fieldType.name);
if (next && !visited.has(fieldType.name)) {
queue.push({
current: next,
from: current,
path: [...path, current.name, inputField.name],
});
}
}
} else if (isUnionType(current)) {
for (const subtype of current.getTypes()) {
if (!visited.has(subtype.name)) {
queue.push({
current: subtype,
from: current,
path: [...path, current.name, subtype.name],
});
}
}
}
}
}
// biome-ignore lint/suspicious/noExplicitAny: none
function toExplorerItem(t: any, from: ExplorerItem | null): ExplorerItem | null {
if (t == null) return null;
// 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`, 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 };
}
// Fallback: treat as GraphQLNamedType (object, scalar, enum, etc.)
return { kind: 'type', type: t, from };
}