mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-25 02:41:07 +01:00
Fix lint errors and show docs explorer on Cmd click
This commit is contained in:
940
src-web/components/graphql/GraphQLDocsExplorer.tsx
Normal file
940
src-web/components/graphql/GraphQLDocsExplorer.tsx
Normal file
@@ -0,0 +1,940 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
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 type { CSSProperties, HTMLAttributes, KeyboardEvent, ReactNode } from 'react';
|
||||
import { Fragment, memo, useCallback, 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 { 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;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type ExplorerItem =
|
||||
| { kind: 'type'; type: GraphQLType; from: ExplorerItem }
|
||||
| { kind: 'field'; type: GraphQLField<any, any>; from: ExplorerItem }
|
||||
| { kind: 'input_field'; type: GraphQLInputField; from: ExplorerItem }
|
||||
| null;
|
||||
|
||||
export const GraphQLDocsExplorer = memo(function GraphQLDocsExplorer({
|
||||
style,
|
||||
schema,
|
||||
className,
|
||||
}: Props) {
|
||||
const [activeItem, setActiveItem] = useState<ExplorerItem>(null);
|
||||
|
||||
const qryType = schema.getQueryType();
|
||||
const mutType = schema.getMutationType();
|
||||
const subType = schema.getSubscriptionType();
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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}
|
||||
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.keys(allTypes).map((typeName) => {
|
||||
const t = allTypes[typeName]!;
|
||||
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,
|
||||
containerHeight,
|
||||
}: {
|
||||
item: ExplorerItem;
|
||||
setItem: (t: ExplorerItem) => void;
|
||||
schema: GraphQLSchema;
|
||||
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">
|
||||
<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 (
|
||||
<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
|
||||
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 documenation explorer"
|
||||
onClick={() => {
|
||||
jotaiStore.set(showGraphQLDocExplorerAtom, false);
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isScalarType(item.type)) {
|
||||
return heading;
|
||||
} else 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}
|
||||
/>
|
||||
);
|
||||
} else 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.keys(fields).map((fieldName) => {
|
||||
const field = fields[fieldName]!;
|
||||
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: any) => (
|
||||
<GqlTypeRow key={t.name} item={toExplorerItem(t, item)} setItem={setItem} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else 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>
|
||||
);
|
||||
} else 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>
|
||||
);
|
||||
} else 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>
|
||||
);
|
||||
} else 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>
|
||||
);
|
||||
} else 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.type, isNonNullType(item.type));
|
||||
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}:
|
||||
</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 && <> </>}
|
||||
<span className="text-primary">{arg.name}:</span>
|
||||
<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>
|
||||
);
|
||||
} else 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
|
||||
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;
|
||||
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;
|
||||
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; // Continue searching
|
||||
});
|
||||
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;
|
||||
} else if (a.depth.length !== b.depth.length) {
|
||||
return a.depth.length - b.depth.length;
|
||||
} else {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
} else {
|
||||
if (a.depth.length !== b.depth.length) {
|
||||
return a.depth.length - b.depth.length;
|
||||
} else if (a.score === 0 && b.score === 0) {
|
||||
return a.name.localeCompare(b.name);
|
||||
} else if (a.score === b.score && a.name.length === b.name.length) {
|
||||
return a.name.localeCompare(b.name);
|
||||
} else if (a.score === b.score) {
|
||||
return a.name.length - b.type.name.length;
|
||||
} else {
|
||||
return b.score - a.score;
|
||||
}
|
||||
}
|
||||
});
|
||||
return results.slice(0, 100);
|
||||
}, [currentItem, schema, debouncedValue, value]);
|
||||
|
||||
const activeIndex = useMemo(() => {
|
||||
const index = results.findIndex((r) => r === activeResult) ?? 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,
|
||||
start: GraphQLType | GraphQLField<any, any> | GraphQLInputField | null,
|
||||
cb: (
|
||||
type: GraphQLNamedType | GraphQLField<any, any> | GraphQLInputField,
|
||||
from: GraphQLNamedType | null,
|
||||
path: string[],
|
||||
) => boolean,
|
||||
) {
|
||||
const visited = new Set<string>();
|
||||
const queue: Array<{
|
||||
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) {
|
||||
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],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toExplorerItem(t: any, from: ExplorerItem | null): ExplorerItem {
|
||||
if (t == null) return null;
|
||||
|
||||
// GraphQLField-like: has `args` and `type`
|
||||
if (t && 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)) {
|
||||
return { kind: 'input_field', type: t, from };
|
||||
}
|
||||
|
||||
// Otherwise, assume GraphQLType (object, scalar, enum, etc.)
|
||||
return { kind: 'type', type: t, from };
|
||||
}
|
||||
235
src-web/components/graphql/GraphQLEditor.tsx
Normal file
235
src-web/components/graphql/GraphQLEditor.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import type { HttpRequest } from '@yaakapp-internal/models';
|
||||
import { updateSchema } from 'cm6-graphql';
|
||||
|
||||
import { formatSdl } from 'format-graphql';
|
||||
import { useAtom } from 'jotai';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useLocalStorage } from 'react-use';
|
||||
import { useIntrospectGraphQL } from '../../hooks/useIntrospectGraphQL';
|
||||
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
|
||||
import { showDialog } from '../../lib/dialog';
|
||||
import { Banner } from '../core/Banner';
|
||||
import { Button } from '../core/Button';
|
||||
import type { DropdownItem } from '../core/Dropdown';
|
||||
import { Dropdown } from '../core/Dropdown';
|
||||
import type { EditorProps } from '../core/Editor/Editor';
|
||||
import { Editor } from '../core/Editor/Editor';
|
||||
import { FormattedError } from '../core/FormattedError';
|
||||
import { Icon } from '../core/Icon';
|
||||
import { Separator } from '../core/Separator';
|
||||
import { showGraphQLDocExplorerAtom } from './graphqlAtoms';
|
||||
|
||||
type Props = Pick<EditorProps, 'heightMode' | 'className' | 'forceUpdateKey'> & {
|
||||
baseRequest: HttpRequest;
|
||||
onChange: (body: HttpRequest['body']) => void;
|
||||
request: HttpRequest;
|
||||
};
|
||||
|
||||
export function GraphQLEditor({ request, onChange, baseRequest, ...extraEditorProps }: Props) {
|
||||
const editorViewRef = useRef<EditorView>(null);
|
||||
const [autoIntrospectDisabled, setAutoIntrospectDisabled] = useLocalStorage<
|
||||
Record<string, boolean>
|
||||
>('graphQLAutoIntrospectDisabled', {});
|
||||
const { schema, isLoading, error, refetch, clear } = useIntrospectGraphQL(baseRequest, {
|
||||
disabled: autoIntrospectDisabled?.[baseRequest.id],
|
||||
});
|
||||
const [currentBody, setCurrentBody] = useStateWithDeps<{
|
||||
query: string;
|
||||
variables: string | undefined;
|
||||
}>(() => {
|
||||
// Migrate text bodies to GraphQL format
|
||||
// NOTE: This is how GraphQL used to be stored
|
||||
if ('text' in request.body) {
|
||||
const b = tryParseJson(request.body.text, {});
|
||||
const variables = JSON.stringify(b.variables || undefined, null, 2);
|
||||
return { query: b.query ?? '', variables };
|
||||
}
|
||||
|
||||
return { query: request.body.query ?? '', variables: request.body.variables ?? '' };
|
||||
}, [extraEditorProps.forceUpdateKey]);
|
||||
const [isDocOpen, setGraphqlDocStateAtomValue] = useAtom(showGraphQLDocExplorerAtom);
|
||||
|
||||
const handleChangeQuery = (query: string) => {
|
||||
const newBody = { query, variables: currentBody.variables || undefined };
|
||||
setCurrentBody(newBody);
|
||||
onChange(newBody);
|
||||
};
|
||||
|
||||
const handleChangeVariables = (variables: string) => {
|
||||
const newBody = { query: currentBody.query, variables: variables || undefined };
|
||||
setCurrentBody(newBody);
|
||||
onChange(newBody);
|
||||
};
|
||||
|
||||
// Refetch the schema when the URL changes
|
||||
useEffect(() => {
|
||||
if (editorViewRef.current == null) return;
|
||||
updateSchema(editorViewRef.current, schema ?? undefined);
|
||||
}, [schema]);
|
||||
|
||||
const actions = useMemo<EditorProps['actions']>(
|
||||
() => [
|
||||
<div key="actions" className="flex flex-row !opacity-100 !shadow">
|
||||
<div key="introspection" className="!opacity-100">
|
||||
{schema === undefined ? null /* Initializing */ : (
|
||||
<Dropdown
|
||||
items={[
|
||||
...((schema != null
|
||||
? [
|
||||
{
|
||||
label: 'Clear',
|
||||
onSelect: clear,
|
||||
color: 'danger',
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
},
|
||||
{ type: 'separator' },
|
||||
]
|
||||
: []) satisfies DropdownItem[]),
|
||||
{
|
||||
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',
|
||||
},
|
||||
{
|
||||
hidden: schema == null,
|
||||
label: `${isDocOpen ? 'Hide' : 'Show'} Documentation`,
|
||||
leftSlot: <Icon icon="book_open_text" />,
|
||||
onSelect: () => {
|
||||
setGraphqlDocStateAtomValue(!isDocOpen);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Introspect Schema',
|
||||
leftSlot: <Icon icon="refresh" spin={isLoading} />,
|
||||
keepOpenOnSelect: true,
|
||||
onSelect: refetch,
|
||||
},
|
||||
{ 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>,
|
||||
],
|
||||
[
|
||||
isLoading,
|
||||
refetch,
|
||||
error,
|
||||
autoIntrospectDisabled,
|
||||
baseRequest.id,
|
||||
clear,
|
||||
schema,
|
||||
setAutoIntrospectDisabled,
|
||||
isDocOpen,
|
||||
setGraphqlDocStateAtomValue,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full grid grid-cols-1 grid-rows-[minmax(0,100%)_auto]">
|
||||
<Editor
|
||||
language="graphql"
|
||||
heightMode="auto"
|
||||
format={formatSdl}
|
||||
defaultValue={currentBody.query}
|
||||
onChange={handleChangeQuery}
|
||||
placeholder="..."
|
||||
ref={editorViewRef}
|
||||
actions={actions}
|
||||
stateKey={'graphql_body.' + request.id}
|
||||
{...extraEditorProps}
|
||||
/>
|
||||
<div className="grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1 min-h-[5rem]">
|
||||
<Separator dashed className="pb-1">
|
||||
Variables
|
||||
</Separator>
|
||||
<Editor
|
||||
language="json"
|
||||
heightMode="auto"
|
||||
defaultValue={currentBody.variables}
|
||||
onChange={handleChangeVariables}
|
||||
placeholder="{}"
|
||||
stateKey={'graphql_vars.' + request.id}
|
||||
autocompleteFunctions
|
||||
autocompleteVariables
|
||||
{...extraEditorProps}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function tryParseJson(text: string, fallback: unknown) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (err) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
3
src-web/components/graphql/graphqlAtoms.ts
Normal file
3
src-web/components/graphql/graphqlAtoms.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { atomWithKVStorage } from '../../lib/atoms/atomWithKVStorage';
|
||||
|
||||
export const showGraphQLDocExplorerAtom = atomWithKVStorage<boolean>('show_graphql_docs', false);
|
||||
63
src-web/components/graphql/useGraphQLDocsExplorer.ts
Normal file
63
src-web/components/graphql/useGraphQLDocsExplorer.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
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 extends keyof EventDataMap, Data extends EventDataMap[Event]>(
|
||||
event: Event,
|
||||
data: Data,
|
||||
) {
|
||||
this.#emitter.emit(event, data);
|
||||
}
|
||||
|
||||
on<Event extends keyof EventDataMap, Data extends EventDataMap[Event]>(
|
||||
event: Event,
|
||||
fn: (data: Data) => void,
|
||||
) {
|
||||
this.#emitter.on(event, fn);
|
||||
}
|
||||
|
||||
off<Event extends keyof EventDataMap, Data extends EventDataMap[Event]>(
|
||||
event: Event,
|
||||
fn: (data: Data) => void,
|
||||
) {
|
||||
this.#emitter.off(event, fn);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user