mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-23 00:58:32 +02:00
Schema filtering and a bunch of fixes
This commit is contained in:
@@ -1,24 +1,36 @@
|
|||||||
/* eslint-disable */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { Color } from '@yaakapp-internal/plugins';
|
import type { Color } from '@yaakapp-internal/plugins';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import type { GraphQLField, GraphQLInputField, GraphQLSchema, GraphQLType } from 'graphql';
|
import { fuzzyMatch } from 'fuzzbunny';
|
||||||
|
import type {
|
||||||
|
GraphQLField,
|
||||||
|
GraphQLInputField,
|
||||||
|
GraphQLNamedType,
|
||||||
|
GraphQLSchema,
|
||||||
|
GraphQLType,
|
||||||
|
} from 'graphql';
|
||||||
import {
|
import {
|
||||||
getNamedType,
|
getNamedType,
|
||||||
isEnumType,
|
isEnumType,
|
||||||
isInputObjectType,
|
isInputObjectType,
|
||||||
isInterfaceType,
|
isInterfaceType,
|
||||||
isListType,
|
isListType,
|
||||||
|
isNamedType,
|
||||||
isNonNullType,
|
isNonNullType,
|
||||||
isObjectType,
|
isObjectType,
|
||||||
isScalarType,
|
isScalarType,
|
||||||
isUnionType,
|
isUnionType,
|
||||||
} from 'graphql';
|
} from 'graphql';
|
||||||
import { CSSProperties, memo, ReactNode, useState } from 'react';
|
import type { CSSProperties, HTMLAttributes, KeyboardEvent, ReactNode } from 'react';
|
||||||
|
import { Fragment, memo, useCallback, useMemo, useRef, useState } from 'react';
|
||||||
import { showGraphQLDocExplorerAtom } from '../atoms/graphqlSchemaAtom';
|
import { showGraphQLDocExplorerAtom } from '../atoms/graphqlSchemaAtom';
|
||||||
|
import { useDebouncedValue } from '../hooks/useDebouncedValue';
|
||||||
|
import { useRandomKey } from '../hooks/useRandomKey';
|
||||||
import { jotaiStore } from '../lib/jotai';
|
import { jotaiStore } from '../lib/jotai';
|
||||||
import { CountBadge } from './core/CountBadge';
|
import { CountBadge } from './core/CountBadge';
|
||||||
import { Icon } from './core/Icon';
|
import { Icon } from './core/Icon';
|
||||||
import { IconButton } from './core/IconButton';
|
import { IconButton } from './core/IconButton';
|
||||||
|
import { PlainInput } from './core/PlainInput';
|
||||||
import { Markdown } from './Markdown';
|
import { Markdown } from './Markdown';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -33,7 +45,11 @@ type ExplorerItem =
|
|||||||
| { kind: 'input_field'; type: GraphQLInputField; from: ExplorerItem }
|
| { kind: 'input_field'; type: GraphQLInputField; from: ExplorerItem }
|
||||||
| null;
|
| null;
|
||||||
|
|
||||||
export const GraphQLDocsExplorer = memo(function ({ style, schema, className }: Props) {
|
export const GraphQLDocsExplorer = memo(function GraphQLDocsExplorer({
|
||||||
|
style,
|
||||||
|
schema,
|
||||||
|
className,
|
||||||
|
}: Props) {
|
||||||
const [activeItem, setActiveItem] = useState<ExplorerItem>(null);
|
const [activeItem, setActiveItem] = useState<ExplorerItem>(null);
|
||||||
|
|
||||||
const qryType = schema.getQueryType();
|
const qryType = schema.getQueryType();
|
||||||
@@ -48,7 +64,7 @@ export const GraphQLDocsExplorer = memo(function ({ style, schema, className }:
|
|||||||
return (
|
return (
|
||||||
<div className={classNames(className, 'py-3 mx-3')} style={style}>
|
<div className={classNames(className, 'py-3 mx-3')} style={style}>
|
||||||
<div className="h-full border border-dashed border-border rounded-lg">
|
<div className="h-full border border-dashed border-border rounded-lg">
|
||||||
<GraphQLExplorerHeader item={activeItem} setItem={setActiveItem} />
|
<GraphQLExplorerHeader item={activeItem} setItem={setActiveItem} schema={schema} />
|
||||||
{activeItem == null ? (
|
{activeItem == null ? (
|
||||||
<div className="flex flex-col gap-3 overflow-y-auto h-full w-full p-3">
|
<div className="flex flex-col gap-3 overflow-y-auto h-full w-full p-3">
|
||||||
<Heading>Root Types</Heading>
|
<Heading>Root Types</Heading>
|
||||||
@@ -87,7 +103,10 @@ export const GraphQLDocsExplorer = memo(function ({ style, schema, className }:
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-y-auto h-full w-full px-3 grid grid-cols-[minmax(0,1fr)]">
|
<div
|
||||||
|
key={activeItem.type.toString()} // Reset scroll position to top
|
||||||
|
className="overflow-y-auto h-full w-full px-3 grid grid-cols-[minmax(0,1fr)]"
|
||||||
|
>
|
||||||
<GqlTypeInfo item={activeItem} setItem={setActiveItem} schema={schema} />
|
<GqlTypeInfo item={activeItem} setItem={setActiveItem} schema={schema} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -99,36 +118,50 @@ export const GraphQLDocsExplorer = memo(function ({ style, schema, className }:
|
|||||||
function GraphQLExplorerHeader({
|
function GraphQLExplorerHeader({
|
||||||
item,
|
item,
|
||||||
setItem,
|
setItem,
|
||||||
|
schema,
|
||||||
}: {
|
}: {
|
||||||
item: ExplorerItem;
|
item: ExplorerItem;
|
||||||
setItem: (t: ExplorerItem) => void;
|
setItem: (t: ExplorerItem) => void;
|
||||||
|
schema: GraphQLSchema;
|
||||||
}) {
|
}) {
|
||||||
|
const findIt = (t: ExplorerItem): ExplorerItem[] => {
|
||||||
|
if (t == null) return [null];
|
||||||
|
return [...findIt(t.from), t];
|
||||||
|
};
|
||||||
|
const crumbs = findIt(item);
|
||||||
return (
|
return (
|
||||||
<nav className="pl-2 pr-1 h-md grid grid-rows-1 grid-cols-[minmax(0,1fr)_auto] items-center gap-2 overflow-x-auto min-w-0 hide-scrollbars">
|
<nav className="relative pl-2 pr-1 h-lg grid grid-rows-1 grid-cols-[auto_minmax(0,1fr)_auto] items-center min-w-0 gap-1">
|
||||||
<div className="w-full">
|
<div className="mr-3 whitespace-nowrap flex items-center gap-2 hide-scrollbars text-text-subtle overflow-x-auto hide-scrollbars text-sm">
|
||||||
{item == null ? (
|
<Icon icon="book_open_text" />
|
||||||
<div className="flex items-center gap-2">
|
{crumbs.map((crumb, i) => {
|
||||||
<Icon icon="house" color="secondary" />
|
return (
|
||||||
<div className="text-text-subtle whitespace-nowrap _truncate">Schema Documentation</div>
|
<Fragment key={i}>
|
||||||
</div>
|
{i > 0 && <Icon icon="chevron_right" className="text-text-subtlest" />}
|
||||||
) : (
|
{crumb === item || item == null ? (
|
||||||
<GqlTypeLink
|
<GqlTypeLabel item={item} />
|
||||||
item={item.from}
|
) : crumb === item ? null : (
|
||||||
setItem={setItem}
|
<GqlTypeLink
|
||||||
className="text-text-subtle !font-sans !text-base"
|
key={i}
|
||||||
leftSlot={<Icon icon="chevron_left" color="secondary" />}
|
item={crumb}
|
||||||
/>
|
setItem={setItem}
|
||||||
)}
|
className="!font-sans !text-sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<GqlSchemaSearch currentItem={item} schema={schema} setItem={(item) => setItem(item)} />
|
||||||
|
<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>
|
</div>
|
||||||
<IconButton
|
|
||||||
icon="x"
|
|
||||||
size="sm"
|
|
||||||
className="text-text-subtle"
|
|
||||||
title="Close documenation explorer"
|
|
||||||
onClick={() => {
|
|
||||||
jotaiStore.set(showGraphQLDocExplorerAtom, false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -144,13 +177,14 @@ function GqlTypeInfo({
|
|||||||
}) {
|
}) {
|
||||||
if (item == null) return null;
|
if (item == null) return null;
|
||||||
|
|
||||||
const name = item.kind === 'type' ? getNamedType(item.type).name : item.type.name;
|
|
||||||
const description =
|
const description =
|
||||||
item.kind === 'type' ? getNamedType(item.type).description : item.type.description;
|
item.kind === 'type' ? getNamedType(item.type).description : item.type.description;
|
||||||
|
|
||||||
const heading = (
|
const heading = (
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<Heading>{name}</Heading>
|
<Heading>
|
||||||
|
<GqlTypeLabel item={item} />
|
||||||
|
</Heading>
|
||||||
<DocMarkdown>{description || 'No description'}</DocMarkdown>
|
<DocMarkdown>{description || 'No description'}</DocMarkdown>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -161,7 +195,7 @@ function GqlTypeInfo({
|
|||||||
// kinda a hack, but we'll just unwrap there and show the named type
|
// kinda a hack, but we'll just unwrap there and show the named type
|
||||||
return (
|
return (
|
||||||
<GqlTypeInfo
|
<GqlTypeInfo
|
||||||
item={{ ...item, kind: 'type', type: item.type.ofType }}
|
item={toExplorerItem(item.type.ofType, item)}
|
||||||
setItem={setItem}
|
setItem={setItem}
|
||||||
schema={schema}
|
schema={schema}
|
||||||
/>
|
/>
|
||||||
@@ -177,7 +211,7 @@ function GqlTypeInfo({
|
|||||||
<Subheading count={Object.keys(fields).length}>Fields</Subheading>
|
<Subheading count={Object.keys(fields).length}>Fields</Subheading>
|
||||||
{Object.keys(fields).map((fieldName) => {
|
{Object.keys(fields).map((fieldName) => {
|
||||||
const field = fields[fieldName]!;
|
const field = fields[fieldName]!;
|
||||||
const fieldItem: ExplorerItem = { kind: 'field', type: field, from: item };
|
const fieldItem: ExplorerItem = toExplorerItem(field, item);
|
||||||
return (
|
return (
|
||||||
<div key={`${field.type}::${field.name}`} className="my-4">
|
<div key={`${field.type}::${field.name}`} className="my-4">
|
||||||
<GqlTypeRow
|
<GqlTypeRow
|
||||||
@@ -193,11 +227,7 @@ function GqlTypeInfo({
|
|||||||
<>
|
<>
|
||||||
<Subheading>Implemented By</Subheading>
|
<Subheading>Implemented By</Subheading>
|
||||||
{possibleTypes.map((t: any) => (
|
{possibleTypes.map((t: any) => (
|
||||||
<GqlTypeRow
|
<GqlTypeRow key={t.name} item={toExplorerItem(t, item)} setItem={setItem} />
|
||||||
key={t.name}
|
|
||||||
item={{ kind: 'type', type: t, from: item }}
|
|
||||||
setItem={setItem}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -248,20 +278,22 @@ function GqlTypeInfo({
|
|||||||
{item.type.args.length > 0 && (
|
{item.type.args.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<Subheading>Arguments</Subheading>
|
<Subheading>Arguments</Subheading>
|
||||||
{item.type.args.map((a) => (
|
{item.type.args.map((a) => {
|
||||||
<div key={a.type + '::' + a.name} className="my-4">
|
return (
|
||||||
<GqlTypeRow
|
<div key={a.type + '::' + a.name} className="my-4">
|
||||||
name={{ value: a.name, color: 'info' }}
|
<GqlTypeRow
|
||||||
item={{ kind: 'input_field', type: a, from: item }}
|
name={{ value: a.name, color: 'info' }}
|
||||||
setItem={setItem}
|
item={{ kind: 'type', type: a.type, from: item }}
|
||||||
/>
|
setItem={setItem}
|
||||||
</div>
|
/>
|
||||||
))}
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (item.kind === 'input_field' && isInputObjectType(item.type)) {
|
} else if (isInputObjectType(item.type)) {
|
||||||
const fields = item.type.getFields();
|
const fields = item.type.getFields();
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -288,34 +320,7 @@ function GqlTypeInfo({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (item.kind === 'type' && isInputObjectType(item.type)) {
|
} else if (isObjectType(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 (item.kind === 'type' && isObjectType(item.type)) {
|
|
||||||
const fields = item.type.getFields();
|
const fields = item.type.getFields();
|
||||||
const interfaces = item.type.getInterfaces();
|
const interfaces = item.type.getInterfaces();
|
||||||
|
|
||||||
@@ -354,7 +359,7 @@ function GqlTypeInfo({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Unknown GraphQL Type', item);
|
console.log('Unknown GraphQL Type', item.type, isNonNullType(item.type));
|
||||||
return <div>Unknown GraphQL type</div>;
|
return <div>Unknown GraphQL type</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,13 +471,17 @@ function GqlTypeLink({
|
|||||||
color,
|
color,
|
||||||
children,
|
children,
|
||||||
leftSlot,
|
leftSlot,
|
||||||
|
rightSlot,
|
||||||
|
onNavigate,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
item: ExplorerItem;
|
item: ExplorerItem;
|
||||||
color?: Color;
|
color?: Color;
|
||||||
setItem: (item: ExplorerItem) => void;
|
setItem: (item: ExplorerItem) => void;
|
||||||
|
onNavigate?: () => void;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
leftSlot?: ReactNode;
|
leftSlot?: ReactNode;
|
||||||
|
rightSlot?: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
if (item?.kind === 'type' && isListType(item.type)) {
|
if (item?.kind === 'type' && isListType(item.type)) {
|
||||||
@@ -484,8 +493,10 @@ function GqlTypeLink({
|
|||||||
setItem={setItem}
|
setItem={setItem}
|
||||||
color={color}
|
color={color}
|
||||||
leftSlot={leftSlot}
|
leftSlot={leftSlot}
|
||||||
children={children}
|
rightSlot={rightSlot}
|
||||||
/>
|
>
|
||||||
|
{children}
|
||||||
|
</GqlTypeLink>
|
||||||
<span className="text-text-subtle">]</span>
|
<span className="text-text-subtle">]</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -497,8 +508,10 @@ function GqlTypeLink({
|
|||||||
setItem={setItem}
|
setItem={setItem}
|
||||||
color={color}
|
color={color}
|
||||||
leftSlot={leftSlot}
|
leftSlot={leftSlot}
|
||||||
children={children}
|
rightSlot={rightSlot}
|
||||||
/>
|
>
|
||||||
|
{children}
|
||||||
|
</GqlTypeLink>
|
||||||
<span className="text-text-subtle">!</span>
|
<span className="text-text-subtle">!</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -518,40 +531,257 @@ function GqlTypeLink({
|
|||||||
color === 'notice' && 'text-notice',
|
color === 'notice' && 'text-notice',
|
||||||
color === 'info' && 'text-info',
|
color === 'info' && 'text-info',
|
||||||
)}
|
)}
|
||||||
onClick={() => setItem(item)}
|
onClick={() => {
|
||||||
|
setItem(item);
|
||||||
|
onNavigate?.();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{leftSlot}
|
{leftSlot}
|
||||||
<GqlTypeLabel item={item} children={children} />
|
<GqlTypeLabel item={item}>{children}</GqlTypeLabel>
|
||||||
|
{rightSlot}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function GqlTypeLabel({ item, children }: { item: ExplorerItem; children?: ReactNode }) {
|
function GqlTypeLabel({
|
||||||
|
item,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
item: ExplorerItem;
|
||||||
|
children?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
let inner;
|
let inner;
|
||||||
if (children) {
|
if (children) {
|
||||||
inner = children;
|
inner = children;
|
||||||
} else if (item == null) {
|
} else if (item == null) {
|
||||||
inner = 'Root';
|
inner = 'Root';
|
||||||
} else if (item.kind === 'type') {
|
} else if (item.kind === 'field') {
|
||||||
inner = getNamedType(item.type).name;
|
inner = item.type.name + (item.type.args.length > 0 ? '(…)' : '');
|
||||||
|
} else if ('name' in item.type) {
|
||||||
|
inner = item.type.name;
|
||||||
} else {
|
} else {
|
||||||
inner = getNamedType(item.type.type).name;
|
console.error('Unknown item type', item);
|
||||||
|
inner = 'UNKNOWN';
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div className="_truncate">{inner}</div>;
|
return <span className={classNames(className, 'truncate')}>{inner}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Subheading({ children, count }: { children: ReactNode; count?: number }) {
|
function Subheading({ children, count }: { children: ReactNode; count?: number }) {
|
||||||
return (
|
return (
|
||||||
<h2 className="font-bold text-lg mt-6 flex items-center">
|
<h2 className="font-bold text-lg mt-6 flex items-center">
|
||||||
<div className="_truncate min-w-0">{children}</div>
|
<div className="truncate min-w-0">{children}</div>
|
||||||
{count && <CountBadge count={count} />}
|
{count && <CountBadge count={count} />}
|
||||||
</h2>
|
</h2>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
name: string;
|
||||||
|
type: GraphQLNamedType | GraphQLField<any, any> | GraphQLInputField;
|
||||||
|
score: number;
|
||||||
|
from: GraphQLNamedType | null;
|
||||||
|
depth: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function GqlSchemaSearch({
|
||||||
|
schema,
|
||||||
|
currentItem,
|
||||||
|
setItem,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
currentItem: ExplorerItem | null;
|
||||||
|
schema: GraphQLSchema;
|
||||||
|
setItem: (t: ExplorerItem) => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const [activeResult, setActiveResult] = useState<SearchResult | null>(null);
|
||||||
|
const [forceRefreshKey, regenerateForceRefreshKey] = useRandomKey();
|
||||||
|
const [focused, setFocused] = useState<boolean>(false);
|
||||||
|
const [value, setValue] = useState<string>('');
|
||||||
|
const debouncedValue = useDebouncedValue(value, 300);
|
||||||
|
const canSearch =
|
||||||
|
currentItem == null ||
|
||||||
|
(isNamedType(currentItem.type) &&
|
||||||
|
!isEnumType(currentItem.type) &&
|
||||||
|
!isScalarType(currentItem.type));
|
||||||
|
|
||||||
|
const results = useMemo(() => {
|
||||||
|
const results: SearchResult[] = [];
|
||||||
|
walkTypeGraph(
|
||||||
|
currentItem?.type ?? null,
|
||||||
|
(type, from, depth) => {
|
||||||
|
if (type === currentItem?.type) {
|
||||||
|
return null; // Remove the current type from results
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = fuzzyMatch(type.name, debouncedValue);
|
||||||
|
if (match == null) {
|
||||||
|
// Do nothing
|
||||||
|
} else {
|
||||||
|
results.push({ name: type.name, type, score: match.score, from, depth });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
schema,
|
||||||
|
);
|
||||||
|
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, 40);
|
||||||
|
}, [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);
|
||||||
|
|
||||||
|
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, activeResult, setItem, currentItem],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!canSearch) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
className,
|
||||||
|
'relative flex items-center bg-surface z-20',
|
||||||
|
!focused && 'w-[6rem] ml-auto',
|
||||||
|
focused && '!absolute top-0 left-1.5 right-1.5 bottom-0',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<PlainInput
|
||||||
|
ref={inputRef}
|
||||||
|
size="sm"
|
||||||
|
label="search"
|
||||||
|
hideLabel
|
||||||
|
defaultValue={value}
|
||||||
|
placeholder={focused ? 'Search ' + (currentItem?.type.toString() ?? 'Schema') : 'Search'}
|
||||||
|
forceUpdateKey={forceRefreshKey}
|
||||||
|
leftSlot={
|
||||||
|
<div className="w-10 flex justify-center items-center">
|
||||||
|
<Icon size="sm" icon="search" color="secondary" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
onChange={setValue}
|
||||||
|
onKeyDownCapture={handleKeyDown}
|
||||||
|
onBlur={() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
setFocused(false);
|
||||||
|
setValue('');
|
||||||
|
regenerateForceRefreshKey();
|
||||||
|
}, 100);
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
setFocused(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
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 min-w-[20rem] max-h-[20rem] 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}`}
|
||||||
|
onClick={() => setItem(item)}
|
||||||
|
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-notice" />
|
||||||
|
</SearchResult>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchResult({
|
||||||
|
isActive,
|
||||||
|
className,
|
||||||
|
...extraProps
|
||||||
|
}: {
|
||||||
|
isActive: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
} & HTMLAttributes<HTMLButtonElement>) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={classNames(
|
||||||
|
className,
|
||||||
|
'px-3 truncate w-full text-left h-sm rounded',
|
||||||
|
isActive && 'bg-surface-highlight',
|
||||||
|
)}
|
||||||
|
{...extraProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Heading({ children }: { children: ReactNode }) {
|
function Heading({ children }: { children: ReactNode }) {
|
||||||
return <h1 className="font-bold text-2xl _truncate">{children}</h1>;
|
return <h1 className="font-bold text-2xl truncate">{children}</h1>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DocMarkdown({ children, className }: { children: string | null; className?: string }) {
|
function DocMarkdown({ children, className }: { children: string | null; className?: string }) {
|
||||||
@@ -559,3 +789,101 @@ function DocMarkdown({ children, className }: { children: string | null; classNa
|
|||||||
<Markdown className={classNames(className, '!text-text-subtle italic')}>{children}</Markdown>
|
<Markdown className={classNames(className, '!text-text-subtle italic')}>{children}</Markdown>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function walkTypeGraph(
|
||||||
|
start: GraphQLType | GraphQLField<any, any> | GraphQLInputField | null,
|
||||||
|
cb: (
|
||||||
|
type: GraphQLNamedType | GraphQLField<any, any> | GraphQLInputField,
|
||||||
|
from: GraphQLNamedType | null,
|
||||||
|
path: string[],
|
||||||
|
) => void,
|
||||||
|
schema: GraphQLSchema,
|
||||||
|
) {
|
||||||
|
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);
|
||||||
|
|
||||||
|
cb(current, from, path);
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Prop
|
|||||||
<LicenseBadge />
|
<LicenseBadge />
|
||||||
)}
|
)}
|
||||||
<IconButton
|
<IconButton
|
||||||
icon="search"
|
icon="square_terminal"
|
||||||
title="Search or execute a command"
|
title="Search or execute a command"
|
||||||
size="sm"
|
size="sm"
|
||||||
iconColor="secondary"
|
iconColor="secondary"
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const icons = {
|
|||||||
copy: lucide.CopyIcon,
|
copy: lucide.CopyIcon,
|
||||||
copy_check: lucide.CopyCheck,
|
copy_check: lucide.CopyCheck,
|
||||||
download: lucide.DownloadIcon,
|
download: lucide.DownloadIcon,
|
||||||
|
ellipsis: lucide.EllipsisIcon,
|
||||||
external_link: lucide.ExternalLinkIcon,
|
external_link: lucide.ExternalLinkIcon,
|
||||||
eye: lucide.EyeIcon,
|
eye: lucide.EyeIcon,
|
||||||
eye_closed: lucide.EyeOffIcon,
|
eye_closed: lucide.EyeOffIcon,
|
||||||
@@ -97,6 +98,7 @@ const icons = {
|
|||||||
shield: lucide.ShieldIcon,
|
shield: lucide.ShieldIcon,
|
||||||
shield_check: lucide.ShieldCheckIcon,
|
shield_check: lucide.ShieldCheckIcon,
|
||||||
shield_off: lucide.ShieldOffIcon,
|
shield_off: lucide.ShieldOffIcon,
|
||||||
|
square_terminal: lucide.SquareTerminalIcon,
|
||||||
sparkles: lucide.SparklesIcon,
|
sparkles: lucide.SparklesIcon,
|
||||||
sun: lucide.SunIcon,
|
sun: lucide.SunIcon,
|
||||||
table: lucide.TableIcon,
|
table: lucide.TableIcon,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import type { FocusEvent, HTMLAttributes } from 'react';
|
import type { FocusEvent, HTMLAttributes } from 'react';
|
||||||
import { useCallback, useRef, useState } from 'react';
|
import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react';
|
||||||
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
|
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
|
||||||
import { IconButton } from './IconButton';
|
import { IconButton } from './IconButton';
|
||||||
import type { InputProps } from './Input';
|
import type { InputProps } from './Input';
|
||||||
@@ -15,39 +15,46 @@ export type PlainInputProps = Omit<InputProps, 'wrapLines' | 'onKeyDown' | 'type
|
|||||||
hideObscureToggle?: boolean;
|
hideObscureToggle?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PlainInput({
|
export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(function PlainInput(
|
||||||
autoFocus,
|
{
|
||||||
autoSelect,
|
autoFocus,
|
||||||
className,
|
autoSelect,
|
||||||
containerClassName,
|
className,
|
||||||
defaultValue,
|
containerClassName,
|
||||||
forceUpdateKey,
|
defaultValue,
|
||||||
help,
|
forceUpdateKey,
|
||||||
hideLabel,
|
help,
|
||||||
hideObscureToggle,
|
hideLabel,
|
||||||
label,
|
hideObscureToggle,
|
||||||
labelClassName,
|
label,
|
||||||
labelPosition = 'top',
|
labelClassName,
|
||||||
leftSlot,
|
labelPosition = 'top',
|
||||||
name,
|
leftSlot,
|
||||||
onBlur,
|
name,
|
||||||
onChange,
|
onBlur,
|
||||||
onFocus,
|
onChange,
|
||||||
onFocusRaw,
|
onFocus,
|
||||||
onKeyDownCapture,
|
onFocusRaw,
|
||||||
onPaste,
|
onKeyDownCapture,
|
||||||
placeholder,
|
onPaste,
|
||||||
required,
|
placeholder,
|
||||||
rightSlot,
|
required,
|
||||||
size = 'md',
|
rightSlot,
|
||||||
tint,
|
size = 'md',
|
||||||
type = 'text',
|
tint,
|
||||||
validate,
|
type = 'text',
|
||||||
}: PlainInputProps) {
|
validate,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
const [obscured, setObscured] = useStateWithDeps(type === 'password', [type]);
|
const [obscured, setObscured] = useStateWithDeps(type === 'password', [type]);
|
||||||
const [focused, setFocused] = useState(false);
|
const [focused, setFocused] = useState(false);
|
||||||
const [hasChanged, setHasChanged] = useState<boolean>(false);
|
const [hasChanged, setHasChanged] = useStateWithDeps<boolean>(false, [forceUpdateKey]);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
useImperativeHandle<{ focus: () => void } | null, { focus: () => void } | null>(
|
||||||
|
ref,
|
||||||
|
() => inputRef.current,
|
||||||
|
);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
const handleFocus = useCallback(
|
const handleFocus = useCallback(
|
||||||
@@ -87,7 +94,7 @@ export function PlainInput({
|
|||||||
};
|
};
|
||||||
inputRef.current?.setCustomValidity(isValid(value) ? '' : 'Invalid value');
|
inputRef.current?.setCustomValidity(isValid(value) ? '' : 'Invalid value');
|
||||||
},
|
},
|
||||||
[onChange, required, validate],
|
[onChange, required, setHasChanged, validate],
|
||||||
);
|
);
|
||||||
|
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -102,7 +109,13 @@ export function PlainInput({
|
|||||||
labelPosition === 'top' && 'flex-row gap-0.5',
|
labelPosition === 'top' && 'flex-row gap-0.5',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Label htmlFor={id} className={labelClassName} visuallyHidden={hideLabel} required={required} help={help}>
|
<Label
|
||||||
|
htmlFor={id}
|
||||||
|
className={labelClassName}
|
||||||
|
visuallyHidden={hideLabel}
|
||||||
|
required={required}
|
||||||
|
help={help}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</Label>
|
</Label>
|
||||||
<HStack
|
<HStack
|
||||||
@@ -149,7 +162,7 @@ export function PlainInput({
|
|||||||
autoCorrect="off"
|
autoCorrect="off"
|
||||||
onChange={(e) => handleChange(e.target.value)}
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
onPaste={(e) => onPaste?.(e.clipboardData.getData('Text'))}
|
onPaste={(e) => onPaste?.(e.clipboardData.getData('Text'))}
|
||||||
className={classNames(commonClassName, 'h-auto')}
|
className={classNames(commonClassName, 'h-full')}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
required={required}
|
required={required}
|
||||||
@@ -173,7 +186,7 @@ export function PlainInput({
|
|||||||
</HStack>
|
</HStack>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
function validateRequire(v: string) {
|
function validateRequire(v: string) {
|
||||||
return v.length > 0;
|
return v.length > 0;
|
||||||
|
|||||||
@@ -3,5 +3,5 @@ import { useCallback, useState } from 'react';
|
|||||||
export function useToggle(initialValue = false) {
|
export function useToggle(initialValue = false) {
|
||||||
const [value, setValue] = useState<boolean>(initialValue);
|
const [value, setValue] = useState<boolean>(initialValue);
|
||||||
const toggle = useCallback(() => setValue((v) => !v), []);
|
const toggle = useCallback(() => setValue((v) => !v), []);
|
||||||
return [value, toggle] as const;
|
return [value, toggle, setValue] as const;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const sizes = {
|
|||||||
xs: '1.8rem',
|
xs: '1.8rem',
|
||||||
sm: '2.0rem',
|
sm: '2.0rem',
|
||||||
md: '2.3rem',
|
md: '2.3rem',
|
||||||
|
lg: '2.6rem',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
|||||||
Reference in New Issue
Block a user